🦔

単一ケースの判別共用体の利用について

2024/12/02に公開

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


はじめに

F# には 判別共用体 という便利な型が用意されています。C# でも昨今入れる入れると言って、数年くらい入っていない機能ですね (もう少しで 10 年くらい経ちますかね?)。

通常、判別共用体は一つの型の中で、複数の異種データやインスタンスごとに型が異なるデータなどのラベルつきケースを保持します。よく挙げられる例として Shape があります。
以下の Shape の例を見てみます。

Shapeの例
type Shape =
  | Rectangle of width : float * length : float
  | Circle of radius : float
  | Prism of width : float * float * height : float

Shape という型の中で RectangleCirclePrism といったラベル付きのケースがあり、そのケースそれぞれで保持できるデータが異なっていることが見て取れるかと思います。
実際に 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