LoginSignup
17
19

More than 5 years have passed since last update.

おてがる単位型パターン 〜不正な単位演算はコンパイルエラーにしよう〜

Last updated at Posted at 2016-10-18
1 / 18

はじめに :warning:

  • 某所Scalaミートアップの発表資料です。
  • 用途に適した単位型を、自力で小さく実装することが目的です。
  • 実装した言語はScalaですが、各テクニックは他の言語でも応用が利くと思います。

バグを探せ :bug:

val imp: Int    = 400
val yen: Int    = 30
val cpm: Double = yen / imp

広告が400回閲覧されたので、30円支払いました。
単価のCPMはいくらでしょう?


単位演算は罠だらけ :boom:

val imp: Int    = 500
val yen: Int    = 30
val cpm: Double = yen.toDouble / imp.toDouble * 1000
  • 閲覧(viewable impression)数は、掲載(impression)数と異なる。
  • Intの割り算は切り捨てなので、割る前にDoubleに変換する。
  • CPMは、1000掲載(impression)単位の価格です。

バグを防ぐために :eyes:

  • テストすれば充分……?
    • テストも同じ勘違いをしちゃう問題。。。
  • 単位演算をモジュール化すれば、そこだけ気をつければいい……?
    • def calcCpm(imp: Int, yen: Int): Double
    • うっかり異なる単位の値を、引数に渡してしまう。。。
    • せっかく作った関数を使いわすれてしまう。。。
  • 型に単位情報を埋めこめば、\型システム/が解決してくれるのでは!

値クラス&幽霊型 :ghost:

case class UnitBase[A, Tag](value: A) extends AnyVal
  • Avaluecase class UnitBaseでラップ。
    • 実行時のオーバーヘッドは、値クラス化extends AnyValで回避。
  • 実際には使わない幽霊型Tagにより、同じ型Aに対して異なるラッパークラスを定義できる。

詳しくは、「Refactoring in Scala」お勧め。


値クラス&幽霊型 :ghost:

def calcCpm(imp: Imp, yen: Yen): Cpm =
  Cpm(yen.value.toDouble / imp.value.toDouble * 1000)
  • 単位ごとに次のような定義をしていく。
trait CpmTag
type Cpm = UnitBase[Double, CpmTag]
object Cpm {
  def apply(value: Double) = new Cpm(value)
}
  • 単位を型で明記できるようになった。
    • うっかり異なる単位の変数を引数に渡すと、コンパイルエラー。
  • 実際の演算は面倒になった。。。
    • case class UnitBaseにメソッドを実装すればいいのでは!

欲しい演算とは :thought_balloon:

たとえば広告系(アドテク)だと、次のような感じ。

  • 同じ型の足し算&引き算&大小比較は、元の数値型そのままでいい。
  • 掛け算&割り算は、単位の組み合わせを制限したい。
    • クリック率にコンバージョン数を掛けているコードは、おそらくバグ。
  • 掛け算&割り算は、固定の係数を適用したい。
    • CPMの1000とか。

数値型を統べるもの :crown:

Q. IntDoubleの演算って、どうやって共通化すればいいの? スーパークラス無いよね?

A. Numeric型クラスとかあるよ!

trait Numeric[T] extends Ordering[T] {
  def plus(x: T, y: T): T
  def minus(x: T, y: T): T
  def times(x: T, y: T): T
trait Integral[T] extends Numeric[T] {
trait IntIsIntegral extends Integral[Int] {
  def plus(x: Int, y: Int): Int = x + y
  def minus(x: Int, y: Int): Int = x - y
  def times(x: Int, y: Int): Int = x * y
implicit object IntIsIntegral extends IntIsIntegral with Ordering.IntOrdering

型クラスがいっぱい :dancers:

  • Ordering:大小比較ができる。
  • Numeric:足し算&引き算&掛け算ができる。
  • Fractional:割り算もできる。
  • 掛け算&割り算で、単位の組み合わせを制限し係数を適用する型クラスは、自力で実装する。

型クラスについては、「主要な型クラスの紹介」お勧め。


実装してみた :pencil:

  • 50行程度の小さな実装です。
  • コードの大部分が型の制御。
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)
  }
}

使ってみた :confetti_ball:

val imp: Vimp = Vimp(400)
val yen: Yen  = Yen(30)
val cpm: Cpm  = yen / imp // コンパイルエラー!!!
  • 単位の扱い間違えているとコンパイル通らない。
    • YenVimpで割る演算の結果は、Cpmでない。
    • YenImpで割る時は、実体がDoubleでないといけない。

使ってみた :confetti_ball:

val imp: Imp  = Imp(500)
val yen: Yen  = Yen(30)
val cpm: Cpm  = yen.mapDouble / imp.mapDouble
assert(cpm == Cpm(60.0))
  • バグ潰せばコンパイル通る。
  • 係数1000は自動で掛けてくれる。

ちょいテク :sunglasses:

implicit val mulImpCpmYen = UnitNum.Mul[Double, ImpTag, CpmTag, YenTag](0.001)

この定義一つで、実体がDoubleならば、次の四つの演算ができるようにしています。

  • Imp * Cpm / 1000 = Yen
  • Cpm * Imp / 1000 = Yen
  • Yen / Imp * 1000 = Cpm
  • Yen / Cpm * 1000 = Imp
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))
object Mul {
  implicit def commutative[A, Tag1, Tag2, Tag3](implicit mul: Mul[A, Tag1, Tag2, Tag3]) =
    Mul[A, Tag2, Tag1, Tag3](mul.factor)
  }
}

まとめ :white_flower:

  • いくつかの型テクニックを使って、良い感じの単位型を実装することができたよ。
  • この実装は、単位演算の要件に応じて細かくカスタマイズすることができるよ。

\さすがScala/


他の単位型パターン:物理 :dash:

  • メートル毎秒(m/s)を秒(second)で割ったら、メートル毎秒毎秒(m/s^2)になってほしい。
  • 「ミリ」や「キロ」などの接頭辞もサポートしたい。
  • Scalaのライブラリでは、KarolS / units が個人的な理想に近い。
  • C++のライブラリでは、Boost.Unitsが色々と凄い。

他の単位型パターン:通貨 :chart:

  • 円とか、ドルとか。
  • 為替レートが市場依存&動的なので、為替処理を型で解決するのは危なそう。

実行環境 :house:

$ scala -version
Scala code runner version 2.11.8 -- Copyright 2002-2016, LAMP/EPFL
17
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
19