MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

UNIVERSE AdsでのScala 幽霊型 (Phantom Type) の活用方法

はじめに

こんにちは。マイクロアド21年卒サーバーサイドエンジニアの陶山(id:suyama_naoki)です。普段は主にDSP (UNIVERSE Ads) の開発を行なっています。

今回の記事では、UNIVERSE AdsでのScala 幽霊型の活用方法について共有させていただきます。

Scalaの幽霊型 (Phantom Type) とは

幽霊型 (Phantom Type) とは、コンパイル時には検査されるが、コンパイル後のプログラムでは消えているような型パラメータのことです。 コンパイル時の型検査を利用したデザインパターンとして認知されています。

具体例

以下はとあるドアを開け閉めするプログラムです。
ドアが開いているか閉まっているかの状態を表すDoorStateと開け閉めするメソッドを持つDoorクラスを定義しています。

Doorクラスでは幽霊型を用いることによって以下の制約を設けています。

  • ドアが閉じている場合のみ開くことができる
  • ドアが開いている場合のみ閉じることができる

見ていただくと分かる通り、閉じているドアを開いたり、開いているドアを閉じるプログラムは成功しています。
しかし、開いているドアを開こうとすると型引数がClosedではないという内容のエラーが発生しています。

scala> sealed trait DoorState
     | sealed trait Open extends DoorState
     | sealed trait Closed extends DoorState
trait DoorState
trait Open
trait Closed

scala> case class Door[State <: DoorState]() {
     |   def open(implicit ev: State =:= Closed): Door[Open] = {
     |     println("Door opened!")
     |     Door[Open]()
     |   }
     |   def close(implicit ev: State =:= Open): Door[Closed] = {
     |     println("Door closed!")
     |     Door[Closed]()
     |   }
     | }
class Door

scala> Door[Closed]().open
Door opened!
val res0: Door[Open] = Door()

scala> Door[Open]().close
Door closed!
val res1: Door[Closed] = Door()

scala> Door[Open]().open
                    ^
       error: Cannot prove that Open =:= Closed.

このように、幽霊型を用いることによって型パラメータによる制約を設け、制約を守っていないプログラムがあった場合、コンパイルエラーにより気づくことができます。

UNIVERSE Adsでの幽霊型の活用方法

UNIVERSE Ads (弊社で開発しているDSP) では配信候補の広告の値段を比較する際に、幽霊型を用いて通貨の種類が同じ場合のみ比較できるようなモデリングをしています。

以下に広告の値段比較の簡易化したプログラムを載せています。

Currency.scala

通貨を表すクラスです。

sealed abstract class Currency(val id: CurrencyId, val code: String)

object Currency {

  case object JPY extends Currency(CurrencyId(1), "JPY")

  case object USD extends Currency(CurrencyId(2), "USD")

  case object CNY extends Currency(CurrencyId(3), "CNY")

  case object KRW extends Currency(CurrencyId(4), "KRW")
}

AdPrice.scala

広告の値段を表すクラスです。

幽霊型を用いることによって、比較するAdPriceの型パラメータが一致していないとコンパイルエラーがでるようにしています。

import domain.model.currency.Currency

case class AdPrice[C <: Currency](amount: BigDecimal, currency: C) extends Ordered[AdPrice[C]] {

  override def compare(that: AdPrice[C]): Int = amount.compare(that.amount)
}

AdPriceのcompareメソッドを呼び出す

以下のプログラムをコンパイルします。 adPrice1adPrice2の型パラメータはCurrency.JPY.typeですが、adPrice3Currency.USD.typeですね。

object Main extends App {
  val adPrice1: AdPrice[Currency.JPY.type] = AdPrice(5.0, Currency.JPY)
  val adPrice2: AdPrice[Currency.JPY.type] = AdPrice(10.0, Currency.JPY)
  val adPrice3: AdPrice[Currency.USD.type] = AdPrice(15.0, Currency.USD)

  adPrice1.compare(adPrice2)
  adPrice1.compare(adPrice3)
}

コンパイルを実行すると、幽霊型による型検査が行われます。adPrice1adPrice2の比較は問題ないですが、adPrice1adPrice3の比較をしている箇所で型不一致のエラーが発生しました。

type mismatch;
found   : domain.model.selector.AdPrice[domain.model.currency.Currency.USD.type]
  required: domain.model.selector.AdPrice[domain.model.currency.Currency.JPY.type]
  adPrice1.compare(adPrice3)

幽霊型を用いたモデリングのおかげで、誤った実装をした際にコンパイルエラーによってバグを未然に防げるので嬉しいですね。

まとめ

今回の記事では、Scalaの幽霊型についての特徴やUNIVERSE Adsでの活用方法を共有させていただきました。
Scalaの幽霊型をこれから使用する方や、知識として学びたい方の参考になれば嬉しいです!