単一ケースの判別共用体の利用について
この記事は 2024 年 F# アドベント カレンダー の 2 日目の記事となります。
はじめに
F# には 判別共用体
という便利な型が用意されています。C# でも昨今入れる入れると言って、数年くらい入っていない機能ですね (もう少しで 10 年くらい経ちますかね?)。
通常、判別共用体は一つの型の中で、複数の異種データやインスタンスごとに型が異なるデータなどのラベルつきケースを保持します。よく挙げられる例として Shape
があります。
以下の Shape
の例を見てみます。
type Shape =
| Rectangle of width : float * length : float
| Circle of radius : float
| Prism of width : float * float * height : float
Shape
という型の中で Rectangle
や Circle
、Prism
といったラベル付きのケースがあり、そのケースそれぞれで保持できるデータが異なっていることが見て取れるかと思います。
実際に Shape
を引数に持つ関数では、その内部でパターンマッチを行って、すべてのパターンの処理を漏れなく実装できます (パターンマッチに漏れがあると、警告が出るので気づけます)。
let area shape =
match shape with
| Rectangle (width, length) -> width * length
| Circle radius -> System.Math.PI * radius * radius
| Prism (width, length, height) -> width * length * height
その他にも、例えば二分木を表現することにも使えたりしますし、標準で提供されている Option<'T>
や Result<'T>
なども判別共用体で定義されています。
type BinaryTree<'T> =
| Empty
| Node of 'T * BinaryTree<'T> * BinaryTree<'T>
このように F# では、判別共用体を利用した型を標準ライブラリで普通に提供されていますし、それが判別共用体だと知らずに利用しているユーザーもいるレベルで定着している定番の機能になっています。
単一ケースの判別共用体について
実はこの判別共用体、必ずしも複数のケースを定義する必要はないです。
例えば、以下のように単一のケースの定義でもまったく問題ありません。
type Shape = Rectangle of width : float * length : float
このように判別共用体を単一ケースで定義した場合にのみ、関数の引数部分でケース名を用いたパターンマッチによる分解ができます。
例えば前述した area
は以下のようになります。
let area (Rectangle (width, length)) = width * length
単一ケースの判別共用体の利点について
では、この単一ケースの判別共用体はどういった場面で役に立つのでしょうか?
それは、概ねプリミティブ型をラップしたい場合となります。
例えば、値としては float
だけれども、それぞれ違う値として表現したい、というような場面を考えます。ここでは「緯度: latitude」と「経度: longitude」について見てみましょう。
これらは float
で十分表現できる値ですが、それぞれは異なる単位の値なので、別々の値として表現した方が間違いが起きにくいです。
例えば float
のまま扱うと、以下のような単純なミスを起こす可能性があります。
type Location = { lat: float; lon: float }
let l1 = { lat = 90.0; lon = 180.0 }
let l2 = { lat = -90.0; lon = -180.0 }
let diff = { lat = l1.lat - l2.lon; lon = l1.lon - l2.lon }
実際にはこんな単純なミスを犯すことはあまりないかもしれませんが (犯したところでテストで発見されると思いますが)、型レベルでこういったミスを未然に防げると何かとうれしいです。また、その方法が手軽であればあるほど、うれしいです。
これを叶えられるものが、単一ケースの判別共用体になります。
では、引き続き緯度と経度の例でみていきましょう。
type Latitude = Latitude of float
type Longitude = Longitude of float
構造体として定義したい場合は [<Struct>]
を付与すれば良いです。
[<Struct>]
type Latitude = Latitude of float
[<Struct>]
type Longitude = Longitude of float
たったのこれだけで新しい型が定義できました。
また以下のような、単に float
の別名を定義したわけではなく、完全に別の型として存在しています。
type Latitude = float
type Longitude = float
では、上記の判別共用体を利用して先ほどの Location
を書き換えてみます。
[<Struct>]
type Latitude = Latitude of float
[<Struct>]
type Longitude = Longitude of float
type Location = { lat: Latitude; lon: Longitude }
let l1 = { lat = Latitude 90.0; lon = Longitude 180.0 }
let l2 = { lat = Latitude -90.0; lon = Longitude -180.0 }
あとは計算用に module
を用意してあげてもいいですし、演算子を定義してあげても良いでしょう。
今回は元のサンプルに寄せるために演算子の定義を追加してみます。
[<Struct>]
type Latitude = Latitude of float
with
static member (+) (Latitude lat1, Latitude lat2) = Latitude (lat1 + lat2)
static member (-) (Latitude lat1, Latitude lat2) = Latitude (lat1 - lat2)
[<Struct>]
type Longitude = Longitude of float
with
static member (+) (Longitude lon1, Longitude lon2) = Longitude (lon1 + lon2)
static member (-) (Longitude lon1, Longitude lon2) = Longitude (lon1 - lon2)
type Location = { lat: Latitude; lon: Longitude }
let l1 = { lat = Latitude 90.0; lon = Longitude 180.0 }
let l2 = { lat = Latitude -90.0; lon = Longitude -180.0 }
let diff = { lat = l1.lat - l2.lon; lon = l1.lon - l2.lon }
これでコンパイル時にエラーを検知できる、堅牢なコードになりました。
このようにプリミティブ型をラップしつつ、その実装コストを抑えたい場合に単一ケースの判別共用体を採用すると、簡便に機能の実現ができます。
ちょっと深掘り
前述までの実装で、ほぼやりたいことについては実現できていますが、そもそも型によって取りうる値の範囲に制限があることがあります。
例えば Latitude
であれば -90.0〜+90.0
ですし、Longitude
であれば -180.0〜+180.0
になります。
こういった制限がある場合に、紹介している例だと対応ができません。
もしそういったことにも対応したい場合には、判別共用体のコンストラクタへのアクセスを private
に制限してあげて、create
などの関数経由でのみ生成できるようにしてあげます。
module Latitude =
[<Struct>]
type Latitude = private Latitude of float
with
static member (+) (Latitude lat1, Latitude lat2) = Latitude (lat1 + lat2)
static member (-) (Latitude lat1, Latitude lat2) = Latitude (lat1 - lat2)
let validate lat = -90.0 <= lat && lat <= 90.0
let create (lat: float) =
if validate lat
then Latitude lat
else failwith "Latitude must be between -90 and 90"
module Longitude =
[<Struct>]
type Longitude = private Longitude of float
with
static member (+) (Longitude lon1, Longitude lon2) = Longitude (lon1 + lon2)
static member (-) (Longitude lon1, Longitude lon2) = Longitude (lon1 - lon2)
let validate lon = -180.0 <= lon && lon <= 180.0
let create (lon: float) =
if validate lon
then Longitude lon
else failwith "Longitude must be between -180 and 180"
let lat1 = Latitude.create 10.0
let lat2 = Latitude.create 20.0
let lat = lat1 + lat2
printfn "%A" lat
let lon1 = Longitude.create 10.0
let lon2 = Longitude.create 20.0
let lon = lon1 + lon2
printfn "%A" lon
このような工夫を凝らしていくことで、開発するプログラムがより堅牢なものとなっていくでしょう。
Discussion