Blog
[AdTech Scala Meetup] おてがる単位型パターン
こんにちは。AMoAdの鳥越(@piyo7)ですっ。
以前、社内のAdTech Scala Meetupにて、用途に適した単位型を自力で小さく実装してみよう、という発表をしました。そのスライドはQiitaで公開済みなのですが、ブログ用にリライトしてこちらでもご紹介します。コードはScala 2.12.1で動作確認しています。
できる!単位演算
アドテクで、ありそうなシチュエーションを考えてみましょう。
広告が400回閲覧されたので、30円支払いました。単価のCPMを求めるため、新人のあもえは次のようなコードを書いてみました。
1 2 3 |
val imp: Int = 400 val yen: Int = 30 val cpm: Double = yen / imp |
その様子を横からのぞきこんでいた道玄坂先輩は、ニヤニヤしながらバグの指摘を始めます。
しかしバグと言われても、電卓代わりのシンプルなコードです。Scala覚えたてのあもえも、さすがに間違えようがないでしょう。新手の新人いびりでしょうか……?
「Intの割り算は切り捨てだから、割る前にDoubleに変換しないとダメ。それだとCPMは0になっちゃう」
「それから、CPMは1000掲載(impression)単位の価格だから、1000掛けないと」
「あ、400っていうのは、閲覧(viewable impression)数ね。掲載(impression)数は500でしょ」
残念ながら、先輩の指摘は全て的を得ていたため、あもえはコードを修正しました。たった3行に3つもバグを仕込んでしまってションボリです。
1 2 3 4 |
val imp: Int = 500 val yen: Int = 30 val cpm: Double = yen.toDouble / imp * 1000 assert((cpm - 60.0).abs < 1e-4) // テストOK |
さて、このような単位演算のバグを防ぐためにはどうすればいいでしょうか?
レビューしてもらえば大丈夫でしょうか。
でも、道玄坂先輩だって冬の寒気に震えて、うっかりバグを見逃してしまうこともあるのです。
単体テストを走らせれば大丈夫でしょうか。
でも、道玄坂先輩だって春の陽気に浮かれ、うっかりテスト対象を覗いてしまい、テストも同じようにバグらせてしてしまうこともあるのです。
下記のように関数化すれば大丈夫でしょうか。
でも、道玄坂先輩だって夏の熱気に参って、うっかり引数impとyenの順序をひっくり返して呼んでしまい、にも関わらずコンパイルは通ってしまって、新たなバグを作りこんでしまうこともあるのです。
1 2 |
def calcCpm(imp: Int, yen: Int): Double = yen.toDouble / imp * 1000 |
もちろん、これは品質のために開発コストをどれだけ掛けるかという話ですから、きちんとしたテストとレビューがあれば大抵の場合は大丈夫でしょう。
でも、できることならば、コーディング時に不正な単位演算に気づける仕組みがあると良いですね? Scalaには豊かな型システムがありますね? それを使えばコンパイルエラーにできそうですね!
ILLな幽霊型とDOPEな型クラス
型テクニックの一つに、幽霊型(phantom type)があります。実際には使用しない型パラメータを宣言することで、同じ振る舞いをする別の型を作りだす、というものです。これはScalaに限らず、C++のテンプレートなどでも、よく見かけるテクニックですね。
まず準備として、次のような型AのラッパークラスUnitBaseを定義します。AnyValを継承して値クラスにすることで、実行時のオーバーヘッドを回避しています。
1 |
case class UnitBase[A, Tag](value: A) extends AnyVal |
これを元に幽霊型ImpTagを使って、新たな型Impを定義してみましょう。
1 2 |
trait ImpTag type Imp = UnitBase[Int, ImpTag] |
同様にYenやCpmも定義します。すると、先程の関数は次のように書きなおせます。
1 2 |
def calcCpm(imp: Imp, yen: Yen): Cpm = new Cpm(yen.value.toDouble / imp.value * 1000) |
やりました! これで、引数の順序を間違えたら、コンパイルエラーになりますね。めでたしめでたし。
……でもないですね。数値を取りだすためにフィールドvalueを呼ぶ必要がある分、単位演算はちょっと面倒になってしまいました。
改善しましょう。単位演算は、UnitBaseに実装した方が便利そうです。今回、ラップする型にはIntやDoubleを想定しているので、たとえばUnitBaseの足し算は、IntやDoubleの足し算をそのままラップすれば良さそうです。
♪ Yo、Yo、IntやDouble、存在しないから問題視する親クラス
♪ でも、ヒントは厚く、開いた拡張暗黙に磨いた型クラス
型クラスとは、継承を使わずに共通のインターフェースを外部から与える仕組みです。型クラスのインスタンスは、暗黙に受け渡すと便利です。
ややこしそうに聞こえるかもしれませんが、具体例を見ると分かりやすいです。Numeric型クラスを使って、3つの数値を足し算する関数を定義してみましょう。
1 2 3 |
def plus3[A](x: A, y: A, z: A)(implicit op: Numeric[A]): A = { op.plus(op.plus(x, y), z) } |
このplus3は型パラメータAがIntでもDoubleでも使うことができ、Numeric型クラスのインスタンスがないStringなどに使おうとするとコンパイルエラーになります。気が利いてますね。
単位型を作るならこんなふうに
さて、本記事の目的は、用途に適した単位型をユーティリティとして気軽に用意することです。
アドテクだと、たとえば次のような要望を満たしたくなるでしょう。
- 同じ型の足し算&引き算&大小比較は、元の数値型のものをそのままラップする。
- 掛け算&割り算は、単位の組み合わせを制限したい。
- クリック率にコンバージョン数を掛けているコードは、おそらくバグ。
- 掛け算&割り算は、固定の係数を自動で適用したい。
- CPMの1000とか。
ということで、実装したものがこちらになります。
50行程度の小さなコードで、その大部分が型の制御です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
case class UnitNum[A, Tag](value: A) extends AnyVal { def +(x: UnitNum[A, Tag])(implicit op: Numeric[A]) = UnitNum[A, Tag](op.plus(value, x.value)) def -(x: UnitNum[A, Tag])(implicit op: Numeric[A]) = UnitNum[A, Tag](op.minus(value, x.value)) def *[Tag1, Tag2](x: UnitNum[A, Tag1])(implicit op: Numeric[A], mul: UnitNum.Mul[A, Tag, Tag1, Tag2]) = UnitNum[A, Tag2](op.times(op.times(value, x.value), mul.factor)) def /[Tag1, Tag2](x: UnitNum[A, Tag1])(implicit op: Fractional[A], mul: UnitNum.Mul[A, Tag1, Tag2, Tag]) = UnitNum[A, Tag2](op.div(op.div(value, x.value), mul.factor)) def map[B](f: A => B) = UnitNum[B, Tag](f(value)) def unary_-(implicit op: Numeric[A]) = map(op.negate) def abs(implicit op: Numeric[A]) = map(op.abs) def mapInt(implicit op: Numeric[A]) = map(op.toInt) def mapLong(implicit op: Numeric[A]) = map(op.toLong) def mapFloat(implicit op: Numeric[A]) = map(op.toFloat) def mapDouble(implicit op: Numeric[A]) = map(op.toDouble) } object UnitNum { import scala.language.implicitConversions implicit def ordering[A: Ordering, Tag]: Ordering[UnitNum[A, Tag]] = Ordering.by(_.value) implicit def ordered[A: Ordering, Tag](x: UnitNum[A, Tag]): Ordered[UnitNum[A, Tag]] = Ordered.orderingToOrdered(x)(ordering[A, Tag]) case class Mul[A, Tag1, Tag2, Tag3](factor: A) extends AnyVal object Mul { implicit def commutative[A, Tag1, Tag2, Tag3](implicit mul: Mul[A, Tag1, Tag2, Tag3]) = Mul[A, Tag2, Tag1, Tag3](mul.factor) } } |
それでは時を巻き戻して、このUnitNumによる単位型を定義した世界で、新人のあもえにコーディングしてもらいましょう。
1 2 3 |
val imp: Vimp = Vimp(400) val yen: Yen = Yen(30) val cpm: Cpm = yen / imp // コンパイルエラー!!! |
おおっと、コンパイルエラーが出てしまいました。Yen型の値をVimp型の値で割っても、Cpm型は付かないというわけです。
エラーメッセージを見て、おやおやと首を傾げたあもえは、そもそも変数impにVimp型を付けているのが変だということに気づきます。そしてImp型に修正してもまだ消えてくれないコンパイルエラーに頭を抱えて、くるくるっとアーロンチェアを回したあもえは、ラップする型をDoubleに変換しないといけないことに気づきます。
そのようにして、コンパイルを通すためにコードを修正したところ、次のようになりました。
1 2 3 4 |
val imp: Imp = Imp(500) val yen: Yen = Yen(30) val cpm: Cpm = yen.mapDouble / imp assert((cpm - Cpm(60.0)).abs < Cpm(1e-4)) // テストOK |
自然にバグが解消されていますね。新人が同じ轍を踏む姿を見れなくて、道玄坂先輩はちょっと寂しそうです。
なお、実際にどう単位型を定義したかというと、次のような形で、
1 2 3 4 5 |
trait ImpTag type Imp = UnitNum[Int, ImpTag] object Imp { def apply(value: Int) = new Imp(value) } |
掛け算&割り算の制限については、
1 |
implicit val mulImpCpmYen = UnitNum.Mul[Double, ImpTag, CpmTag, YenTag](0.001) |
この定義一つで、次の四つの演算ができるようにしています。
- Imp * Cpm / 1000 = Yen
- Cpm * Imp / 1000 = Yen
- Yen / Imp * 1000 = Cpm
- Yen / Cpm * 1000 = Imp
まとめ
いくつかの型テクニックを使って、良い感じの単位型を実装してみました。
この実装は、単位演算の要件に応じて細かくカスタマイズすることができるので、ぜひ自分色に染めてみてください!
なお物理計算の場合、たとえばメートル毎秒(m/s)を秒(s)で割るとメートル毎秒毎秒(m/s^2)になるといった次元計算を型で扱おうとすると、また別のアプローチが必要になります。そのあたりの話は、また別の機会にっ。
Author