コード上の数値の意図を明瞭かつ安全に型で表現する「量と単位」のライブラリ Squants について。
はじめに
例えば、ユーザが注文時に製品サイズを微調整できるような ECサイトで、増減値指定に使う単位を cm にするか mm にするか、あるいは幅を決めて刻み数にするかなどで、仕様に紆余曲折があったとする。この時、増減値に単なる Int
や Double
などのプリミティブな数値型を使っていると、まずソースコード上での意図が不明瞭だし、またコンパイラの助力も得られないので、修正漏れなども発見しにくく色々ややこしい。
こうした一般的すぎるプリミティブなスカラー値の濫用問題と、それに替えて具体的な型を定義する利点については、昔から指摘されていた。
- 『リファクタリング』では、コードスメルの一つとして Primitive Obsession が挙げられている。
- オブジェクト指向エクササイズでは Wrap All Primitives And Strings ルールがある。
- 『アナリシスパターン(以下アナパタ)』では「数量」と「単位」を組み合わせた「量」が提唱されている。
- DDD でも関連する以下のようなプラクティスがある
- 型を特化することによる意図の明確化 -> Intention-Revealing Interface
- 特化した型の間に成立する普遍的な法則の利用 -> Drawing on Established Formalisms
Typelevel プロジェクトのライブラリ Squants は、世の中の色々な「数量」を単位付きで表現する、スレッドセーフでイミュータブルな一群の型を提供する。この記事では、これをざっくり試してみたい。
※ バージョン等はこの辺り。
基本的な使い方
最初に、アナパタ3.1節の「量」の例題、「185ポンド」と「5ドル」で試してみる。
val m1 = 185.pounds
// m1: Mass = 185.0 lb
val (q, u) = m1.toTuple // 必要なら数値と単位文字列に分けることもできる
// q: Double = 185.0
// u: String = lb
val s5 = 5.dollars
// s5: Money = 5 USD
185 pounds
は Pounds(185)
の DSL表記で、主な単位は同様にDSL版が提供されている。以降も、DSLで書けるものは DSLを使う。
アナパタ3.2節の、フィート、インチ、ミリメートルの「換算率」の例は以下のようになる。
val f2i = 1.feet in Inches
// f2i: Length = 12.0 in
val i2f = 1.inches in Feet
// i2f: Length = 0.08333333333333334 ft
val i2mm = 1.inches in Millimeters // 25.4000508 mm
val f2mm = 1.feet in Millimeters // 304.8006096 mm
変換先単位の量は、変換元単位から主単位を介して間接的に算出される。たとえば、最後のフィート → ミリメートルでは、長さの主単位であるメートルをはさんでフィート → メートル → ミリメートル の順に変換される。
アナパタ3.3では、面積や加速度といった「複合単位」を、「原始単位」と「べき」を要素とするオブジェクトのグラフとして構成するパターンが提示されているが、Squants ではこれと異なり静的な型で表現される。つまり操作や型の組み合わせの妥当性は、基本的に1実行時ではなくコンパイル時に検証される。
150.squareYards // 150.0 yd²
150.squareYards / 15.squareYards // 10.0
150.squareYards / 15.yards // 10.0 yd
150.squareYards / 15 // 10.0 yd²
150.squareYards / 1.meters // 137.16027431999998 yd
// 150.squareYards / 1.grams // コンパイル不可
面積を重さで割ることはできない。しかし面積・長さ・次元なしの数値で割ることは、たとえ原始単位が違っていても自然にできなければならない。こうした算数としては当たり前のことが Squants ではスタティックな型で表現される。
重力加速度($LT^{−2}$: 距離/時間の二乗)を使って、速度や距離を求める計算でも、結果の型が自然に定まる。
val g = EarthGravities(1) // 重力加速度
// g: Acceleration = 1.0 g
given Acceleration = 0.01.mpss
g ≈ 9.81.mpss // 1G と 9.81 m/s²を誤差0.01 m/s²で比較
// res5: Boolean = true
val t = 2 seconds
val v = g * t // 速度
// v: Velocity = 19.6133 m/s
val d = g * t.squared / 2 // 距離
// d: Length = 19.6133 m
val f = g * 150.kilograms // 力
// f: Force = 98.06649999999999 N
加速度への積なら、質量、時間とその平方2、振動数(時間の逆数)が許容される。
Money
金額も単位付きの量には違いないが、換算率(レート)が一定でない点が科学的な単位とは異なり、少し特別な扱いになる。
val rate = USD / 112.14.yen
// r: CurrencyExchangeRate = USD/JPY 112.14
rate convert 10.dollars
// 1121.4 JPY
import squants.market.defaultMoneyContext
given MoneyContext =
defaultMoneyContext withExchangeRates List(USD / 112.14.yen)
10.dollars in JPY
// 1121.4 JPY
レートは明示的に CurrencyExchangeRate
インスタンスとして扱うこともできるし、MoneyContext
に関連付けて暗黙に参照させることもできる。
価格 Price
もある意味、換算率といえるが、分母に量を含めることもできる。
// 原油価格 1バレル(42ガロン) につき 64.39 USドルとして
val oilPrice = 64.39.dollars / 42.gallons
// oilPrice: Price[Volume] = 64.39 USD/42.0 gal
// これを1000リットル買うと日本円で、、、
(oilPrice * 1000.liters in JPY).toFormattedString
// res2: String = ¥45417
※ 上のコードでは、フォーマットされた文字列に小数部分が含まれていないが、Currency
オブジェクトを自分で作ればカスタマイズできる。
応用
他のライブラリとの組み合わせを試してみる。
PureConfig との併用
※ PureConfig は Scala 3 非対応だが、Scala 2までの参考情報としてこの節を残す。
例えば地球のコンフィギュレーションが下のようなケースクラスで表現されているとして、この各フィールドを設定ファイルで指定したい。
case class EarthConfig(
meanRadius: Length, // 平均半径
volume: Volume, // 体積
mass: Mass, // 質量
atmosphericPressure: Pressure, // 大気圧
averageTemperature: Temperature // 平均気温
)
設定ファイルを扱うためのライブラリ PureConfig では、Squants 連携のモジュール が提供されていて3、これを使うと以下のように application.conf を記述できる。
mean-radius: 6371.0 km
volume: 1.08E21 m³
mass: 5.97E24 kg
atmospheric-pressure: 101325 Pa
average-temperature: 14.9°C
これをロードして表示すると、、、
def main(args: Array[String]): Unit = {
val config: Result[Earth] = pureconfig.loadConfig[Earth]
println(config.fold(_.toList.map(_.description).mkString("\n"), _.toString))
}
下のような標準出力が得られる。
Earth(6371.0 km,1.08E21 m³,5.97E24 kg,101325.0 Pa,14.9°C)
設定内容に間違いがあれば、下のように Squants のエラーメッセージが表示される。
Cannot convert '1.08E21 mmmm³' to squants.space.Volume: null.
Cannot convert '1.0.1.3.2.5 Pa' to squants.motion.Pressure: null.
Circe との併用
上で書いた原油価格と量を Json で扱いたい。例えば量と単価を指定して原油を注文する、以下のような Json 文字列があるとする。
val rawJson: String = """{
| "name": "crude oil",
| "quantity": "456 L",
| "unitPrice": "64.39 USD/42 gal"
}""".stripMargin
これを Circe でデコードして、下記のケースクラスに取り込むにはどうするか。
case class Order(
name: NonEmptyString,
quantity: Volume,
unitPrice: Price[Volume]
)
- circe の automatic derivation を効かせたいが、そのためにはスコープ内に
Volume
とPrice[Volume]
の暗黙のデコーダを置く必要がある4。 - Squants で、文字列から量に変換する
Try
ベースの関数が提供されているので、これを Circe に合わせてEither
ベースに直せばデコーダになる。 - ただし、
Price[Volume]
については、文字列から直にパーズするメソッドが Squants にはないので自前で書くことになる。
まとめると以下のようになる。
def makeDecoder[A](f: String => Try[A]): Decoder[A] = Decoder.decodeString.emap {
f(_).toEither.leftMap(_.getMessage)
}
given Decoder[Volume] = makeDecoder(Volume(_))
given Decoder[Price[Volume]] =
given MoneyContext = defaultMoneyContext
val priceUnit = "([^/]*)/(.*)".r
makeDecoder {
case priceUnit(p, u) => (Money(p), Volume(u)) mapN (_ / _)
case _ => Failure(Exception("cannot parse unit price"))
}
下のように読み込める。
val Right(order) = parse(rawJson) flatMap (_.as[Order])
// Order(crude oil,456.0 L,64.39 USD/42.0 gal)
おわりに
- 関数型技法としては高度なものは使われていなくて、Typelevel プロジェクトにしては、わりと普通な OO+ジェネリクスなスタイルで書かれている。
- ここでは書かなかったが、簡単なベクトル計算や、範囲の操作、フォーマッターのカスタマイズなどもある。
- いずれやってみたい課題としては、、、
- Refined を併用した制約の付与。例えば摂氏の温度が −273.15°C を下回らないようにしたり。
- カスタム単位の拡充。体積に「km³」を追加するとか、なんなら尺貫法まで拡げるとか。
- Scala3 に書き換えた(2022-06-18)。PureConfig は 未対応なのでその旨コメントした。
資料
補足: 量と単位
バージョン 1.4.0 では、以下のような量と単位が提供されている。
パッケージ | 量 | 単位 |
---|---|---|
--- | (なし) | ea, %, dz, score, gr |
space | 長さ | Å, nm, µm, mm, cm, dm, m, dam, hm, km in, ft, yd, mi, mile, nmi au, ly, pc, kpc, Mpc, Gpc, R☉, RN☉ ħc/eV, mħc/eV, kħc/eV, Mħc/eV, Għc/eV, Tħc/eV, Pħc/eV, Eħc/eV |
面積 | m², cm², km², mi², yd², ft², in², ha, acre, b | |
体積 |
m³, L, nl, µl, ml, cl, dl, hl mi³, yd³, ft³, in³ gal, qt, pt, c, oz, tbsp, tsp(米) gal, qt, pt, c (米乾量) gal, qt, pt, c (英) acft |
|
角度 | rad, °, grad, turns, amin, asec | |
立体角 | sr | |
time | 時間 | ns, µs, ms, s, m, h, d |
周波数 | kHz, Hz, MHz, GHz, THz, rpm | |
motion | 速度 | ft/s, mm/s, m/s, km/s, km/h, mph, imph, kn |
加速度 | mm/s², m/s², ft/s², mph², g | |
躍度 | m/s³, ft/s³ | |
角速度 | rad/s, °/s, grad/s, turns/s | |
角加速度 | rad/s², °/s², grad/s², turns/s², amin/s², asec/s² | |
トルク | N‧m, lb‧ft | |
運動量 | Ns | |
力 | N, kgf, lbf | |
Yank | N/s | |
圧力 | Pa, bar, psi, atm, mmHg, inHg, Torr | |
圧力の時間変化率 | Pa/s, bar/s, psi/s, atm/s | |
体積流量 | m³/s, ft³/hr, GPD, GPH, GPM, GPS | |
質量流量 | kg/s, lb/s, lb/hr, klb/hr, Mlb/hr | |
mass | 質量 |
g, ng, mcg, mg, kg, t oz, lb, klb, Mlb, st gr, dwt, oz t, lb t, tola, ct M☉ eV/c², meV/c², keV/c², MeV/c², GeV/c², TeV/c², PeV/c², EeV/c² |
密度 | kg/m³ | |
面密度 | kg/m², kg/hectare, g/cm², lb/acre | |
慣性モーメント | kg‧m², lb‧ft² | |
物質量 | mol, lb-mol | |
energy | エネルギー |
Wh, mWh, kWh, MWh, GWh J, pJ, nJ, µJ, mJ, kJ, MJ, GJ, TJ Btu, MBtu, MMBtu erg eV, meV, keV, MeV, GeV, TeV, PeV, EeV |
エネルギー密度 | j/m³ | |
物質量あたりのエネルギー | J/mol | |
電力 | mW, W, kW, MW, GW, Btu/hr, erg/s, L☉ | |
電力密度 | W/m³ | |
電力変動 | W/h, W/m, kW/h, kW/m, MW/h, GW/h | |
エネルギー(特定分野) | Gy | |
electro | 電荷 | C, pC, nC, µC, mC, aC, Ah, mAh, mAs |
線電荷密度 | C/m | |
電荷面密度 | C/m² | |
電荷密度 | C/m³ | |
質量電荷比 | C/kg | |
電気容量 | F, pF, nF, μF, mF, kF | |
誘電率 | F/m | |
電流 | A, mA | |
磁場の強さ | A/m | |
電流密度 | A/m² | |
電圧 | V, μV, mV, kV, MV | |
電界強度 | V/m | |
コンダクタンス | S | |
導電率 | S/m | |
電気抵抗 | Ω, nΩ, µΩ, mΩ, kΩ, MΩ, GΩ | |
電気抵抗率 | Ω⋅m | |
インダクタンス | H, mH, μH, nH, pH | |
透磁率 | H/m, N/A² | |
磁束密度 | T, Gs | |
磁束 | Wb | |
radio | 放射強度 | W/sr |
スペクトル強度 | W/sr/m | |
放射輝度 | W/sr/m² | |
スペクトルパワー | W/m | |
放射照度 | W/m², erg/cm²/s | |
分光放射照度 | W/m³, W/m²/nm, W/m²/µm, erg/s/cm²/Å | |
thermal | 温度 | °C, °F, °K, °R |
熱容量 | J/K | |
photo | 照度 | |
露光量 | lx⋅s | |
光度 | cd | |
輝度 | cd/m² | |
光束 | lm | |
光量 | lm⋅s | |
information | 情報 |
B, o, KB, MB, GB, TB, PB, EB, ZB, YB KiB, MiB, GiB, TiB, PiB, EiB, ZiB, YiB bit, Kbit, Mbit, Gbit, Tbit, Pbit, Ebit, Zbit, Ybit Kibit, Mibit, Gibit, Tibit, Pibit, Eibit, Zibit, Yibit |
データレート |
B/s, KB/s, MB/s, GB/s, TB/s, PB/s, EB/s, ZB/s, YB/s KiB/s, MiB/s, GiB/s, TiB/s, PiB/s, EiB/s, ZiB/s, YiB/s bps, Kbps, Mbps, Gbps, Tbps, Pbps, Ebps, Zbps, Ybps Kibps, Mibps, Gibps, Tibps, Pibps, Eibps, Zibps, Yibps |
|
market | 通貨 | USD, ARS, AUD, BRL, CAD, CHF, CLP, CNY, CZK, DKK, EUR, GBP, HKD, INR, JPY, KRW, MXN, MYR, NOK, NZD, RUB, SEK, XAG, XAU, BTC, ETH, LTC, ZAR, NAD |
※ 太字は、それぞれの量ごとに定められている「主単位」。
-
金額などで変換レートが存在しないケースなどで例外をスローするコードもあり純粋関数型とは言えないが、プログラミングや設計のミスで生じるタイプの例外がほとんどっぽいので、実用的にはまあまあかと。 ↩
-
力学系の単位は、時間微分(TimeDerivative)と時間積分(TimeIntegral)を使った継承関係で表現されるものが多い。 ↩
-
PureConfig "0.10.2" では、Squants "1.4.0" ではなく "1.3.0"が使われている。 ↩
-
refined の
NonEmptyString
は circe の circe-refined モジュールで暗黙のデコーダが提供される。 ↩