26
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Units of Measureのススメ

Last updated at Posted at 2013-12-20

この記事はF# Advent Calendar 2013の21日目です。
ひとつ前の記事は、@zeclさんの判別共用体で型付きDSL。弾幕記述言語BulletMLのF#実装、FsBulletML作りました。です。
弾幕、素敵ですね。楽園の素敵な巫女さん関連の美しい弾幕もいいですが、殺しに来る弾幕も好きです。死ぬがよい。

数値型

intfloatなどの数値型はプログラム中に頻繁に登場します。プログラミングの本分は計算であるといえるのでこれは当然でしょう。
その反面、あまりにも当たり前に登場する型であるがゆえに生じる問題もあります。

数値型関連のミスはコンパイルエラーとならない!

例えば、人間を表すPerson型の変数に間違えて車を表すCar型のインスタンスを代入しちゃった! なんてミスはコンパイル時(IDEや高機能エディタを使用していれば編集時)に検出されますが、 身長を表すint型の変数に間違えて年齢を表すint型の変数を代入しちゃった! なんてミスはコンパイル時には検出されません。
(変数名やプロパティ名を工夫してこういうミスが起きにくくなるようにすることはできますが、あくまで人間が間違いに気づきやすくするという程度のことしかできません)
あるいは 「距離 / 時間」で速度を求めるコードで、間違えて「距離 - 時間」にしてしまった! のようなミスも検出できません。
かといって、これを防ぐために身長を表すHeight型や年齢を表すAge型をいちいち作るというのも非常に大変です。
また、Height型から身長の値を取り出すのにオーバーヘッドが発生し、わずかながらパフォーマンスが低下することになります。このわずかなオーバーヘッドは大量の数値データを処理するようなシビアな実行速度が求められる局面では決して無視できません。

数多く出てくる数値を持つ属性ごとに型を作りたくないけど、その属性を間違って代入したり計算したりしている場面でコンパイルエラーとなってほしい。
このトレードオフの解決に役立つのが、今回紹介する「Units of Measure」という機能です。
MSDNでは「測定単位」という訳され方をしています。 1

Units of Measure とは

簡単にいえば、数値型に「単位」を付ける機能です。単位というのは、メートルとか秒とかのアレです。物理関連以外でも、~歳とか~ドルとかも単位です。
数値型に単位を付けることで、上に挙げた「身長に年齢を代入する」や「速度を求めるのに距離から時間を引いた」みたいなミスを防ぐことができます。
身長はcmとかmという単位を使います。一方、年齢の単位は歳でしょう。cmの値に歳の値を代入するのはおかしいですので、コンパイルエラーとなります。
また、距離がmで時間が秒だとすると、mの値から秒の値を引くというのはおかしい計算なので、コンパイルエラーとなります。
すばらしいですね!

Units of Measure の基本

単位については、開発者が好きな単位を定義することができます。
日本語の単位も使えます。要は識別子として使用可能な名前であれば問題ありません。

// 単位の定義
[<Measure>] type m
[<Measure>] type s
[<Measure>] type kg
[<Measure>] type 

また、単位の別名や組立単位(単位を組み合わせたものを新たな単位とする)を定義することもできます。

// 単位の別名
[<Measure>] type meter = m
// 組立単位は単位を組み合わせて定義します。単位 N は kg * m / (s * s) の別名扱いです。
[<Measure>] type N = kg * m / (s * s)
// * は省略可能。べき乗も使用可能。べき乗は乗除よりも優先順位が高くなるので、下の例の「s^2」部分はカッコ不要。
[<Measure>] type J = kg m^2 / s^2
// 無単位と同じ次元数の単位の定義
[<Measure>] type mol = 1

単位を使用した値は、値の後ろにのように単位を付与します。

// 単位のついた値の定義
let height = 1.8<m>
let weight = 60<kg>

すべての型に単位を付与できるわけではありません。 単位が付与できる型は、符号付き整数・浮動小数点数・decimal型です。 (個人的にはbigintが非対応なのが残念)

// 単位を付与可能な基本型
let i8 = 1y<m> // sbyte
// let ui8 = 1uy<m> // byte コンパイルエラー
let i16 = 1s<m> // int16
// let ui16 = 1us<m> // uint16 コンパイルエラー
let i32 = 1<m> // int32
// let ui32 = 1u<m> // uint32 コンパイルエラー
let i64 = 1L<m> // int64
// let ui64 = 1UL<m> // uint64 コンパイルエラー
let f32 = 1.f<m> // float32
let f64 = 1.<m> // float64
let d = 1M<m> // decimal
// let bi = 1I<m> // bigint コンパイルエラー

関数の引数や戻り値を単位付き数値型にすることももちろん可能です。単位の指定方法は型名の後ろにのように単位を付与します。ジェネリクスのような見た目になりますね。
単位についてもコンパイラが推論してくれる ので、必要最低限な型情報を与えてあげるだけでよいです。
(以下のコード例では型注釈をわざわざ書いている箇所が多いですが、例示のために型を明記しているためであり、実際には型注釈を省略できることが多いです)

// 単位を使用した関数の定義。戻り値の単位も型推論されます。
let circumference (radius : float<m>) = 2. * radius * System.Math.PI
// val circumference : radius:float<m> -> float<m>

単位付き数値型同士の演算

単位付き数値型同士の演算結果はどうなるでしょうか?

// 単位の付いた数値型同士の演算。乗除算すると新たな単位が生成されます。
let speed (distance : float<m>) (time : float<s>) = distance / time
// val speed : distance:float<m> -> time:float<s> -> float<m/s>

// let invalidSpeed (distance : float<m>) (time : float<s>) = distance - time // コンパイルエラー。異なる単位の加減算は不可能。

特筆すべきなのは、上記のspeed関数の戻り値の型です。float<m/s>型と推論されていますが、単位m/s(メートル毎秒)は明示的に定義していません。単位mと単位sの演算結果から自動的に単位m/sを生成してくれたのです!
このように、 単位の付いた値同士を乗除算すると、その結果の単位が生成されます。 例えば m × m はm^2(平方メートル)になってくれます。
その一方で、 単位の違う値同士を加減算しようとするとコンパイルエラーとなります。

自分で定義した組立単位については、勝手に推論してくれたりはしないので、明示的に戻り値の型を宣言してあげる必要があります。
ただし、自分で定義した組立単位はあくまで別名単位扱いなので、自動的に生成された単位と実質的に同じであれば同じ単位として扱われます。
普通の型に対する別名定義と扱い方は同じですね。

// [<Measure>] type N = kg * m / (s * s)
// という組立単位が定義済み

// 自分で定義した組立単位にするには明示的に型を指定してあげればOK
let force (mass : float<kg>) (acceleration : float<m/s^2>) = mass * acceleration
// val force : mass:float<kg> -> acceleration:float<m/s ^ 2> -> float<kg m/s ^ 2>
let force2 (mass : float<kg>) (acceleration : float<m/s^2>) : float<N> = mass * acceleration
// val force2 : mass:float<kg> -> acceleration:float<m/s ^ 2> -> float<N>

// 別名単位同士は演算可能
let f1 = force 1.<kg> 1.<m/s^2>
// val f1 : float<kg m/s ^ 2> = 1.0
let f2 = force2 2.<kg> 3.<m/s^2>
// val f2 : float<N> = 6.0
let f = f1 + f2
// val f : float<kg m/s ^ 2> = 7.0

ただの数値型と単位付き数値型の相互変換

プログラム内で使われる数値は、ユーザー・ファイル・DB・Webなどから入力されたりした内容が出発点であることが多いです。
それらの数値は入力時点では単位が付いていないので、単位付きの世界で扱うには単位を付けてあげる必要があります。
単位なしの数値に単位を付けるには、1<m>のような単位付きの1を掛けてあげる方法と、LanguagePrimitivesモジュールに定義されている関数を使用する方法があります。

// 無単位の数値に単位を付与する
let floatToSecond (x : float) = x * 1.<s>
let floatToSecond' x : float<s> = LanguagePrimitives.FloatWithMeasure x
let floatToSecond'' x = LanguagePrimitives.FloatWithMeasure<s> x
// LanguagePrimitivesには各数値型ごとの~WithMeasure関数が用意されている
let intToSecond x : int<s> = LanguagePrimitives.Int32WithMeasure x

逆に単位付きの数値から単位を除去するには、1<m>のような単位付きの1で割ってあげる方法と、intfloatなどの型変換関数に渡してあげる方法があります。

let toPlainFloat (x : float<s>) = x / 1.<s>
let toPlainFloat' (x : float<s>) = x * 1.</s> // 現在の単位の逆の次元の単位を掛ける
let toPlainFloat'' (x : float<s>) = float x
let toPlainInt (x : int<s>) = int x

パフォーマンスは大丈夫?

さて、演算によって単位が変化したり、無単位の値を単位付きに変換したり戻したり・・・といった処理によって発生するオーバーヘッドはどれほどか気になるところです。
単位を付けたことによってパフォーマンスが低下するようでは、結局Height型をいちいち定義するのとそう変わらないことになるからです。

F#コンパイラはコンパイル時に単位を参照して単位の型チェックや型推論を行いますが、コンパイル後は型消去され、単位なしの数値型とまったく同じ扱いとなります。
それだけ考えると単位付きだろうが単位なしだろうが実行速度には影響しないように思えますが、実はまだパフォーマンス低下につながる懸念点が残っています。
それは 単位なし数値型と単位付き数値型の変換処理の扱い です。
元々単位がない世界だけで構築されていれば、上記の変換処理は発生しなかったはずです。それが単位を導入したことで変換処理が追加されているので、変換処理が走る分だけパフォーマンスが劣化しているのでは?と考えられます。

では、実際に検証してみましょう。
一千万回のループ内で

  • 完全に単位なしのパターン
  • 単位付きに変換して計算するパターン
  • 単位付きに変換して計算し、その後単位なしに戻すパターン

を実行してみました。
計測方法は、F# Interactive上で#time "on"にした状態で上記パターンと対応した各関数を実行させました。

// 単位を使わないパターン
let noMeasure() =
    let mutable r = 0. 
    for i = 1 to 10000000 do r <- r + float i * 42.
    printfn "%f" r

// 単位を使うパターン
let withMeasure() =
    let mutable r = 0.<m> 
    for i = 1 to 10000000 do r <- r + float i * 42.<m>
    printfn "%f" <| float r

// 内部的には単位を使って計算するが、最終的に単位を消去するパターンその1。単位を打ち消す演算をして消去。
let eraseMeasure() =
    let mutable r = 0. 
    for i = 1 to 10000000 do r <- r + float i * 42.<m> * 1.</m>
    printfn "%f" r

// 最終的に単位を消去するパターンその2。float関数を使って消去。
let eraseMeasure2() =
    let mutable r = 0. 
    for i = 1 to 10000000 do r <- r + (float i * 42.<m> |> float)
    printfn "%f" r

(*
> noMeasure();;
2100000210000000.000000
リアル: 00:00:00.131、CPU: 00:00:00.125、GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()

> withMeasure();;
2100000210000000.000000
リアル: 00:00:00.129、CPU: 00:00:00.125、GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()

> eraseMeasure();;
2100000210000000.000000
リアル: 00:00:00.128、CPU: 00:00:00.125、GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()

> eraseMeasure2();;
2100000210000000.000000
リアル: 00:00:00.131、CPU: 00:00:00.125、GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()
*)

上記の結果を見る限り、単位付き数値での演算のみならず、単位なしと単位付きの相互変換処理が加わっていたとしても パフォーマンス劣化が見られない ことがわかります。
おそらくは最適化によって単位なし・単位付きの相互変換処理が消えているのでしょう。
(ILを解析すれば詳しいことはわかるのかもしれません。F#のすごい人に期待・・・)
とりあえず、パフォーマンスは気にせず安心して単位を使ってよさそうです。

ちなみに#time "[on|off]"についてはF#のなんかすごいところで初めて知りました。
これは便利ですね!積極的に使っていきたいです。

まだまだ広がる Units of Measure の世界

いちいち単位を定義するのはめんどくさいにゃー

SI単位系の基本単位および組立単位については、F#標準に定義が含まれています。(F#3.0から?)
また、SI単位系で定義された物理定数については F# PowerPack で定義されています。
物理分野のプログラムを書くのであればぜひ利用しましょう。

// 標準に入っているSI単位系
open Microsoft.FSharp.Data.UnitSystems.SI.UnitSymbols
// フルネーム版
// open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames
// F# PowerPack に含まれている物理定数
open Microsoft.FSharp.Math.PhysicalConstants

// 単位 kg, m, N は Microsoft.FSharp.Data.UnitSystems.SI.UnitSymbols から
// 万有引力定数 G は Microsoft.FSharp.Math.PhysicalConstants から
let forceOfGravity (m1 : float<kg>) (m2 : float<kg>) (r : float<m>) : float<N> = G * m1 * m2 / (r * r)

異なる単位間の変換はどうすればよいのでしょうか?

例えば、メートルとフィートのように同じ意味(この場合は長さ)の単位でも複数の単位が使われる場合があります。
こういうパターンでは異なる単位間の変換を行う必要があります。
その際は、単位変換の基準となる値を定義しましょう。

// 単位変換
[<Measure>] type ft
[<Literal>]
let FeetPerMeter = 3.2808399<ft/m>

let meterToFeet (x : float<m>) = x * FeetPerMeter
let feetToMeter (x : float<ft>) = x / FeetPerMeter

let oneMeter = meterToFeet 1.<m> 
// val oneMeter : float<ft> = 3.2808399
let oneFeet = feetToMeter 1.<ft>
// val oneFeet : float<m> = 0.3047999995

とはいえ、FeetPerMeterのような定数やmeterToFeetのような変換関数がモジュール内にどんどんできていくのは微妙かもしれません。
そこで、他の単位への変換方法を単位自身が持つようにする事が可能です。
単位は普通のクラスのようにstaticメンバを持つことが可能です。 なので、上記の単位変換に必要な定数や関数を単位自身のstaticメンバにしてもいいかもしれません。

// 単位にstaticなプロパティやメソッドを追加可能
[<Measure>]
type km =
    static member PerMeter = 0.001<km/m>
    static member toMeter (x : float<m>) = x * km.PerMeter

let kmDistance1 = 2500.<m> * km.PerMeter
// val kmDistance1 : float<km> = 2.5
let kmDistance2 = km.toMeter 40000.<m>
// val kmDistance2 : float<km> = 40.0

単位のジェネリクスも可能なんやで

任意の単位の数値に対して処理をする関数を作成したい場合があります。そういう時は単位をジェネリクスにすればOKです。
演算結果の組立単位もちゃんとジェネリックな単位を基準にしたものとなります。

// ジェネリック単位を使用した関数。'lengthがジェネリックな単位。
let calcSpeed (x : float<'length>) (time : float<s>) = x / time
// val calcSpeed : x:float<'length> -> time:float<s> -> float<'length/s>
let speed2 = calcSpeed 100.<m> 5.<s>
// val speed2 : float<m/s> = 20.0

ジェネリックな単位を使用した関数内で、ジェネリック単位の変数と同じ単位の定数値とを演算をしたい場合、ちょっと面倒なことになります。

// let add1 (x : float<'u>) = x + 1.<'u> // コンパイルエラー
let add1 (x : float<'u>) = x + 1.<_>
// warning FS0064: このコンストラクトによって、コードの総称性は型の注釈よりも低くなります。単位変数 'u' は単位 '1' に制約されました。
let add1' (x : float<'u>) = x + LanguagePrimitives.FloatWithMeasure 1.
let add0 (x : float<'u>) = x + 0.<_> // 0に省略したジェネリック単位を付与することはなぜか可能。使い道は0との比較をしたい場合くらい?

let add1 (x : float<'u>) = x + 1.<'u>という式はコンパイルエラーとなっていまいます。
let add1 (x : float<'u>) = x + 1.<_>はコンパイルこそ通りますが警告が出て、その内容通り実際には単位が消失したただのfloatを受け取る関数となってしまいます。
解決方法はLanguagePrimitives.~WithMeasureを使用することです。

ジェネリックな単位の次数って何それ、意味わかんない

ジェネリックな単位には次数を指定することが可能です。
例えば標準関数のsqrtfloat<'u ^ 2> -> float<'u>という型を持っています。この型は、「何らかの単位」の2乗となっている単位の値を受け取って、「何らかの単位」の値を返すことを意味します。

// ジェネリック単位の次数
let mySqrt (x : float<'u ^ 2>) = sqrt x
// val mySqrt : x:float<'u ^ 2> -> float<'u>
let area = 9.<m^2>
let length = mySqrt area
// val length : float<m> = 3.0

面積を表すm^2(平方メートル)という単位の値から、1辺の長さを表すmの単位の値を求める計算を行っています。
ちゃんと計算結果の値が自動的にmになります!

次数についてはmみたいな単純な単位ではなく、m/sのような組立単位についてもちゃんと計算されます。つまり「m^2/s^2」のように、組立単位を構成するすべての基本単位の次数が2であれば組立単位の次数も2として扱われます。

// 組立単位の次数も自動的に判別
let two = sqrt 4.<m^2/s^2>
// val two : float<m/s> = 2.0
// let akan = sqrt 4.<m/s^2> // コンパイルエラー 基本単位の m 部分の次数が2ではないので型が合わない

さて、平方根ができるのであれば、べき乗も同様にできてほしいですね。
ところが・・・

// let p = pown 2.<m> 3 // コンパイルエラー pown や ** では単位を使用できない(次数が静的に決定しないから)
let square (x : float<'u>) = x * x // 静的に次数が決定する場合はOK

標準のべき乗関数であるpown**演算子は、どちらも計算結果の次数は動的に決まることになります。(例えばpown 2 3であれば計算結果の次数は3です)
しかし、ジェネリック単位の次数は静的に次数が決まっていないといけません。なので、pown**演算子も単位付きの値には使えないのです。
逆に、square関数のように静的に次数が決定している(2乗であると確定している)場合はべき乗もOKです。
(とはいえ、実際にべき乗を使用する際に何乗するか決まっている場合はx * x * x * ...という風に書けばいいのであまり問題にはならないかもしれません)

ジェネリックな単位は代数的データ型やクラスにも使える!хорошо!

ジェネリックな単位を持つ数値型メンバを含むレコード、判別共用体、インターフェース、クラスを定義することが可能です。
まずはレコードから。

// 任意の単位を持つメンバを含むレコード
type CurrencyRatio<[<Measure>]'currency, [<Measure>]'baseCurrency> = {
    Currency : float<'currency>
    BaseCurrency : float<'baseCurrency>
} with
    member x.Ratio = x.Currency / x.BaseCurrency

[<Measure>]type USD
[<Measure>]type JPY
[<Measure>]type EUR

let jpyPerUsd = { Currency = 100.<JPY>; BaseCurrency = 1.<USD> }
let ratio = jpyPerUsd.Ratio
// val ratio : float<JPY/USD> = 100.0
let oldJpyPerUsd = { jpyPerUsd with Currency = 360.<JPY> }
// let eurPerUsd = { jpyPerUsd with Currency = 1.37<EUR> } // コンパイルエラー レコード変更後の型は変更前の型と一致しなければならない
let eurPerUsd2 = { Currency = 1.37<EUR>; BaseCurrency = 1.<USD> }

ジェネリックな単位を表す型引数(?)を定義するには、型引数にMeasure属性を付けます。 それ以外は普通のジェネリクスと同じですね。
レコードの変更については、変更前の各メンバの型と変更後の各メンバの型は単位も含めてすべて同じ型でなければなりません。なので、上記eurPerUsdの定義部分のように単位を変更することはできず、コンパイルエラーとなります。

残りの判別共用体、インターフェース、クラスについても同様ですので、一気に紹介します。

// 任意の単位を持つメンバを含む判別共用体
type Price<[<Measure>]'currency> =
    | Money of amount : int<'currency>
    | Article of unitPrice : int<'currency> * count : int
with
    member x.TotalPrice = match x with Money a -> a | Article(u, c) -> u * c

let money = Money 100<JPY>
let moneyPrice = money.TotalPrice
// val moneyPrice : int<JPY> = 100
let article = Article(unitPrice = 5<USD>, count = 3)
let articlePrice = article.TotalPrice
// val articlePrice : int<USD> = 15

// 任意の単位を持つメンバを含むインターフェース、クラス
type ILength<[<Measure>]'length> =
    abstract Length : float<'length>

type Person<'name, [<Measure>]'length>(name : 'name, age : int<>, height : float<'length>) =
    member x.Name = name
    member x.Age = age
    member x.Height = height
    interface ILength<'length> with
        member x.Length = height

let mimorin = Person("三森すずこ", 27<>, 1.59<m>)
// val mimorin : Person<m>
let mimoHeight = mimorin.Height
// val height : float<m> = 1.59
let mimoLength = (mimorin :> ILength<m>).Length
// val mimoLength : float<m> = 1.59

Units of MeasureはF#ならではのステキ機能!

まとめると、Units of Measureには次のような利点があります。

  • 単位を付けることで間違った計算をコンパイラが検知しやすくなる
  • 基本単位さえ定義しておけば組立単位はコンパイラが勝手に生成してくれる
  • 単位についても推論可能
  • 任意の単位を持つ数値に対する関数を定義可能
  • 単位の次数変換も(次数が静的に決まるのであれば)可能
  • パフォーマンスに悪影響はない

いいことずくめですね!

・・・しかし世の中そんなに甘くないわけで、以下のような欠点や面倒事もあります。

  • (SI単位系以外の)基本単位は自分で定義しないといけない
  • bigintや自前で定義した数値的な型に適用できない
  • 数値型の定義域については制限できない(明らかに負の値をとらない単位の変数にも負の数値を与えられる)
  • コンパイル後には型消去されるので実行時に単位を取り出すことはできない
  • printf系関数に単位付き数値を与えられない
  • .NET Framework自体がUnits of Measureに対応しているわけではないので、.NET標準関数などに値を渡す際にいちいち単位を除去する必要がある

とはいえ上記欠点を差し引いても、Units of Measureは 数値型を扱いやすさとパフォーマンスを保ったまま計算バグに強くしてくれる 強力な機能であると考えます。
物理計算のみならず、金融計算や業務システムでの計算にも役立つでしょう。
そして何より、Units of Measureのようにな単位を付与する機能は 他の言語ではほとんど存在しません。 F#ならではの機能といっていいでしょう。
個人的にはF#をオススメする2番目の理由となる機能です。(1番目?そりゃもちろん|>でしょう)
Units of Measureをぜひ使っていきましょう!

サンプルコード

今回記事内で使用したサンプルコードをまとめたものをGistにアップしました。

参考ページ

http://msdn.microsoft.com/ja-jp/library/dd233243.aspx
http://fsharpforfunandprofit.com/posts/units-of-measure/
http://blogs.msdn.com/b/andrewkennedy/archive/2008/08/29/units-of-measure-in-f-part-one-introducing-units.aspx
http://blogs.msdn.com/b/andrewkennedy/archive/2008/09/02/units-of-measure-in-f-part-two-unit-conversions.aspx
http://davefancher.com/2012/11/18/f-more-on-units-of-measure/

  1. MSDNのページの説明は古い内容が混ざっています。例えばSI単位系はF#PowerPackから標準に移っています。

26
14
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
26
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?