🦔

測定単位入門

2024/12/03に公開

この記事は 2024 年 F# アドベント カレンダー の 3 日目の記事となります。


はじめに

F# には 測定単位 (Units of Measure) という機能があります。これは数値のプリミティブ型に単位を付与できる機能です。

例えば同じ数値型で扱える値でも、その単位が異なることがあり、分けて取り扱いたいというシナリオはよくあると思います。また、「この関数にはどういった単位の値を渡せばいいんだ?」と疑問に思うときもよくあると思います。

筆者が取り分けよく遭遇するシチュエーションが時間や距離に関する関数を呼び出すときです。
例えば、setInterval という関数があった場合に、渡す値の単位はミリ秒なのか、秒なのか、はたまた分、時単位なのかと疑問に思います。距離の場合も同じです。

こういった場面でドキュメントを見ることなく、プログラム的に意図を明示できる機能が 測定単位
なのです。
先の例で言えば、以下のようにできます。

[<Measure>] type ms // millisecond
let setInterval (millisec: int<ms>) =
  // do something

setInterval 100<ms> // ✅ Good!!
setInterval 100     // 🚫 Compiled error

ここで重要なことは、<ms> という単位がない値についてはコンパイル時点でエラーになってくれることです。当然、関数を定義するタイミングで、引数名で意図を記述するわけですが、静的にそれをチェックしてくれるわけではないので、関数の利用者が millisecond 以外の単位を想像しながら利用する可能性があります。
しかし 測定単位 を利用することで、関数の利用者側へ、明示的に millisecond を指定させることを強制できるため、値の取り違いという事故を防げます。

このようなプログラミング エラーを防ぐための機能が 測定単位 となります。

単位を取り違える他の例

次に 体重 (weight) と 身長 (height) について考えてみましょう。
weightheight は同じ float で扱える値ですが、それぞれ単位が異なります。ここで BMI を計算する関数 calc_bmi を定義する場合、引数には weightheight の情報が必要となります。

let calc_bmi weight height = weight / (height + height)

さて、ここで weightheight という引数について、以下のような懸念や検討事項が考えられます。

  • weight には値を g で指定すれば良いのか、kg で指定すれば良いのか不明
  • height には値を cm で指定すれば良いのか、m で指定すれば良いのか不明
  • 判別共用体で別な型を作るのは過剰対応 (な場合がある)

こういった場合に測定単位が役立ちます。
実際に測定単位を利用したコードに修正します。

[<Measure>] type m
[<Measure>] type kg
let calc_bmi (weight: float<kg>) (height: float<m>) =
    weight / (height * height)

これで weightheight の単位をプログラム レベルで強制でき、コーディング ミスを未然に防げるようになりました。また、heightcm で指定したい場合、cm から m に変換する関数 cm2m のようなものを用意してあげると良いでしょう。

[<Measure>] type cm
[<Measure>] type m
[<Measure>] type kg
let cm2m (m: float<cm>) = m / 100.0<cm/m>
let calc_bmi' (weight: float<kg>) (height: float<cm>): float<kg/m^2> =
    let height = height |> cm2m
    weight / (height * height)

測定単位のメリット

測定単位の利用上のメリットは前述までのように、プログラム上で単位を扱えることです。ただ、そこで懸念として出てくるのは実行速度の問題です。どれだけ素晴らしい機能でも、実行時に足枷となってしまうようなものでは利用を躊躇してしまいます。
特に昨今のクラウド上での実行やゲーム開発などでは、以下に実行時のパフォーマンスを上げるかが重要課題となってきています (前者は主に金銭的なコスト面で、後者は主に UX の観点で)。

幸いなことに測定単位はコンパイル時にその情報は消されるため、実行時のコストに影響を与えません。要はタダでメリットを享受できるわけです。
また、単位を持つ値から単位を持たない値へ変換するような以下のコードもコストが掛かりません。

let inline unwrap (a: int<cm>): int = int a

開発するプログラムの堅牢性を上げるためにも積極的に利用していきたい機能ですね。

Discussion