この記事は F# advent calendar 2021 の1日目です。
判別共用体とは
判別共用体とは、いくつかのパターンのどれか1つを取る型を表現するためのものです。
直和型を表現する手段とも言えます。
例えば、以下のコードでは長方形、円のいずれかの図形を表現しています。
type Point = { x: float; y: float }
type Shape =
| Rectangle of leftTop: Point * rightBottom: Point
| Circle of radius: float * center: Point
パターンマッチと併用することで判別共用体は非常に便利に取り扱うことができます。
以下は図形の面積を求める関数をパターンマッチを使って実装する例です。
let getArea shape =
match shape with
| Rectangle(lt, rb) -> (rb.x - lt.x) * (rb.y - lt.y)
| Circle(radius, _) -> radius * radius * System.Math.PI
Rectangle({x = 1; y = 2}, {x = 5; y = 10}) |> getArea
// val it: float = 32.0
単一のパターンしか持たない判別共用体
上で紹介したように、複数のパターンを定義するのが一般的な判別共用体の使い方ですが、実は判別共用体には1つのパターンのみを定義することもできます。
type Email = Email of string
上記のように型名とパターン名を同じにしても支障はありません。名前を一緒にする方が楽でしょう。
単一の判別共用体も普通の判別共用体と同様にパターンマッチで中の値を取り出す必要があります。
特に単一の判別共用体の場合 match
を使わずにパターンを決め打ちで取り出すことができます。
// Email の中に入っている値を関数の引数部分で取り出せる
let getDomain (Email email) =
email.Substring(email.IndexOf('@') + 1)
let email = Email "hoge@example.com"
getDomain email
// val it: string = "example.com"
単一の判別共用体の何が嬉しいのか
例えばアカウントを表す型を定義したとします。
type Account = {
Name: string
Email: string
Password: string
}
すべてのプロパティが string
型になっちゃっているので、email
に設定するつもりだった値を間違えて name
に設定しても型チェックが効かないので間違いに気づきにくいです。
let account = {
Name = "hoge@example.com"
Email = "adacola"
Password = "123456"
}
レコードの場合は「名前 = 値」という設定方法のため上記のようなアホみたいな間違いは起きにくいと思いますが、関数の引数の場合は順番を間違えちゃう可能性は高くなりそうです。
let createAccount name email password = {
Name = name
Email = email
Password = password
}
let account = createAccount "hoge@example.com" "adacola" "123456"
型に別名を付けることもできますが、あくまでただの別名のため、間違えて別の string
型(もしくはその別名の型)の値を設定するコードは通ってしまいます。
type Name = string
type Email = string
type Password = string
type Account = {
Name: Name
Email: Email
Password: Password
}
let createAccount (name: Name) (email: Email) (password: Password) = {
Name = name
Email = email
Password = password
}
// コンパイルが通ってしまう
let account = createAccount "hoge@example.com" "adacola" "123456"
コンパイルで間違いを検出しやすくするために、単一の判別共用体を使用することができます。
type Name = Name of string
type Email = Email of string
type Password = Password of string
type Account = {
Name: Name
Email: Email
Password: Password
}
let createAccount name email password = {
Name = name
Email = email
Password = password
}
(*
コンパイルエラー
This expression was expected to have type
'Name'
but here has type
'string'
*)
let account = createAccount "hoge@example.com" "adacola" "123456"
(*
コンパイルエラー
This expression was expected to have type
'Name'
but here has type
'Email'
*)
let account = createAccount (Email "hoge@example.com") (Name "adacola") (Password "123456")
このように、単一の判別共用体を使えば単純な値にドメイン上の意味を型として持たせることができます。
ちなみに、判別共用体は構造体として定義することもできます。
特に単一の判別共用体の場合、余計なインスタンス生成を防ぐことができるという点で構造体として定義する方が適切だと考えられます。
インスタンス生成のコストも塵も積もれば山となるので、なるべくコストを減らして堅牢性と性能を両立したいところです。
type [<Struct>] Name = Name of string
値の事前チェック
単一の判別共用体の値を作成する際に、特定の条件を満たした値のみ許可したい場合は多いです。
例えば今までの Email
の例だと、Eメールアドレスとしてのフォーマットを満たしている場合だけ Email
型の値を作成できるようにしたいのが普通でしょう。
// こんなのはEメールアドレスじゃないのに値が作れてしまう
type Email = Email of string
let email = Email "hoge"
Email
パターンのコンストラクタを private
にして外部からアクセスできないようにした上で、別に値を生成するための関数を用意してそこで値の事前チェックを行うようにすれば解決できます。
module Validation =
type [<Struct>] Email = private Email of string
with
static member Create email =
if System.Text.RegularExpressions.Regex.IsMatch(email, emailPattern)
then
Email email
else
invalidArg "email" $"Eメールアドレスとして不正な値です : {email}"
他のモジュールから Email
を作成しようとした例です。コンストラクタに直接アクセスしようとするとコンパイルエラーになります。
module AnotherModule =
open Validation
// コンパイルエラー
// The union cases or fields of the type 'Email' are not accessible from this code location
let inacessibleEmail = Email "hoge"
// OK
let validEmail = Email.Create "hoge@example.com"
// OK
let invalidEmail = Email.Create "hoge"
ただ、このままだとモジュール外部の利用者が Email
の中の値を取り出すこともできなくなってしまいます。
// コンパイルエラー
// The union cases or fields of the type 'Email' are not accessible from this code location
let (Validation.Email emailValue) = validEmail
この問題を解決するために Email
クラスに値を取り出すための関数を用意してあげるか、アクティブパターンを用意してあげる方法があります。
module Validation =
type [<Struct>] Email = private Email of string
with
static member Create email =
if System.Text.RegularExpressions.Regex.IsMatch(email, emailPattern) then
Email email
else
invalidArg "email" $"Eメールアドレスとして不正な値です : {email}"
member x.Value = let (Email value) = x in value
[<AutoOpen>]
module EmailActivePattern =
let (|Email|) (Email email) = email
module AnotherModule =
open Validation
let validEmail = Email.Create "hoge@example.com"
let emailValue = validEmail.Value
let (Email emailValue) = validEmail
単一の判別共用体と単位を使った堅牢なドメインモデリング
F#には単一の判別共用体の他にも、数値型に単位をつけることもできます。
いずれも単純なプリミティブ型をそのまま使うのではなく、ドメインとしての意味を型で表現することができます。
これらの機能を使って、型による堅牢なドメインモデリングを行うことができるのがF#の大きな利点です。
例1 : ありがちなPerson型
クラスの紹介にありがちな Person
型を、単一の判別共用体と単位を使って実装してみました。
open FSharp.Data.UnitSystems.SI.UnitSymbols
type [<Struct>] Name = Name of string
type [<Struct>] Email = private Email of string
with
static member Create email =
if System.Text.RegularExpressions.Regex.IsMatch(email, emailPattern) then
Email email
else
invalidArg "email" $"Eメールアドレスとして不正な値です : {email}"
[<AutoOpen>]
module EmailActivePattern =
let (|Email|) (Email email) = email
type Person = {
Name: Name
Email: Email
Height: float<m>
Weight: float<kg>
FootSpeed: float<m/s>
}
let getTimeOf100m person = 100.0<m> / person.FootSpeed
let adacola = {
Name = Name "adacola"
Email = Email.Create "adacola@example.com"
Height = 231.0<m>
Weight = 49.0<kg>
FootSpeed = 10.4384133612<m/s>
}
let (Name adacolaName) = adacola.Name
printfn $"{adacolaName}'s time of 100m : %.2f{getTimeOf100m adacola}"
// adacola's time of 100m : 9.58
例2 : サニタイズされた文字列
Webページに表示する文字列をサニタイズするシチュエーションを考えてみます。
実際に表示する関数ではサニタイズ済みの文字列しか受け付けないようにしたいですが、単一の判別共用体を使って実現できます。
display
関数の引数で、まるで型を指定しているかのような記述方法で SanitizedString
から中の文字列を取り出しているため、関数内部では SanitizedString
をまったく意識せずに実装できます。
type [<Struct>] SanitizedString = private SanitizedString of string
with
static member Create (dangerousString: string) =
dangerousString.Replace("&", "&").Replace("\"", """).Replace("<", "<").Replace(">", ">")
|> SanitizedString
let display (SanitizedString sanitized) =
// 実際にはWebページに文字列を表示する処理
printfn $"{sanitized}"
let dangerousString = """<script>alert("XSS");</script>"""
(*
コンパイルエラー
This expression was expected to have type
'SanitizedString'
but here has type
'string'
*)
display dangerousString
// OK
dangerousString |> SanitizedString.Create |> display
// <script>alert("XSS");</script>
まとめ
単一の判別共用体をうまく利用することで、型付けされたドメインモデリングが実現できます。
単位と一緒に利用して、できるだけ生のプリミティブ型を使わずにモデリングすることで、ドメインを強く意識したコーディングが可能となります。
さあ、みなさんも Let's use F# !