17
3

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 3 years have passed since last update.

単一の判別共用体を使って堅牢なドメインモデリング

Last updated at Posted at 2021-11-30

この記事は 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("&", "&amp;").Replace("\"", "&quot;").Replace("<", "&lt;").Replace(">", "&gt;")
    |> 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
// &lt;script&gt;alert(&quot;XSS&quot;);&lt;/script&gt;

まとめ

単一の判別共用体をうまく利用することで、型付けされたドメインモデリングが実現できます。
単位と一緒に利用して、できるだけ生のプリミティブ型を使わずにモデリングすることで、ドメインを強く意識したコーディングが可能となります。
さあ、みなさんも Let's use F# !

17
3
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
17
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?