関数型ドメインモデリング勉強会 第4章 型の理解

  • 2024.10.07
130
関数型ドメインモデリング勉強会 第4章 型の理解

本書ではF#が実装言語として採用されているが、その辺は言語リファレンスを適宜読みつつで十分理解できると思われる。以下は必ずしも書籍の章立て通りの記述にはなっておらず、読んで思った事や特にまとめておいた方が良さそうな所の抜粋など。あと書籍の内容から大分はみ出た事も書いているが、概念として知っておいた方が有用だったり、単純に面白そうだったりする事をちょこちょこと書いている。

そもそも関数とは何か?型とは何か?

手続き型言語に慣れ過ぎていると見落としがちなのだが、関数というのは「何か値を取って別の値を返す」という、ただそれだけの定義で良い。そして関数というのは通常、入力値として許容される値の集合と、関数の出力としてあり得る値の集合が定められている。そして型というのは、その値の集合に与えられたラベルというだけの事である。数学で集合論を学ぶと写像という言葉が出てくるが、関数=写像という理解で良い。それだけ。単純。

関数のシグネチャというのは、関数の入力の型(定義域)と出力の型(値域)の事で、F#では例えば「整数を2つ取って整数を返す関数」のシグネチャはint -> int -> intなどと表現される。(この記法はHaskellなどでも使われる。)また関数が任意の型で動作する場合、その型は型パラーメーターを取る関数となる。例えば下記の関数は a -> a -> bool として推論される。

let areEqual x y = (x = y)

このような多相関数は通常の集合論的な意味での関数それ自体の集合であると見做せる。例えば上記のareEqualに具体的にint, stringなどに対して使ったとするとint -> int-> boolstring -> string-> boolといったシグネチャが得られる。この時、areEqualというのは Set<areEqual> = { areEqual_0: int -> int -> bool, areEqual_1: string -> string -> bool... }という添字付き集合の元であり、添え字によって関数のシグニチャが変わる。添字集合というのは添え字の集合からの写像であるので、これは依存性を持つ関数と言える。これをさらに普遍的に定義すると自然変換(=圏論)の話になるので略。

話が少しそれたが、結局最初に述べた通り、関数というのは値を取って値を返すものであり、型というのはその入出力の値の範囲を定めたものという単純な理解で良い。

なおこの関数のシグネチャのint -> int -> intの表記だが、これは最後の -> の後が関数の戻り値の型でそれまでは引数の型なのだが、これをint -> (int -> int)と書き換えると、「intを受け取って、『intを受け取ったらintを返す関数』を返す関数」とする事も出来る。このように多引数の関数の引数を減らして高階関数に変えていく手順をカリー化という。(逆にカリー化された関数を多引数関数にするアンカリー化もある。)

このカリー化、関数の部分適用と混同されることが非常に多い。関数の部分適用は、多引数関数の1部の引数だけを固定し、残りを受けて最終的な結果を返す関数を作る事なので厳密には違うものだ。もっとも、カリー化された関数に引数を適用して返ってきた関数は部分適用された関数になっているので、混同する気持ちはわからなくもない。

型の合成と代数的データ型

F#, Haskellなど関数プログラミング言語の多くは(そして最近は少なくない手続き型言語も)型をAND, ORの演算で構築する事ができる。

AND型の例として、以下のようなものが紹介されている。

type FruitsSalad = {
    Apple: AppleVariety
    Banana: BananaVariety
    Cherries: CherryVariety
}

これはApple, Banana, Cherriesというフィールドを持ったレコード型の定義である。ここから読み解けることは、レコード型というのは、フィールド名とそれに対する値のペアの集合という事だが、これは集合論でいう所の直積に当たる。もう少し集合論っぽい例だと、タプルが分かりやすい。

type IntPair = int * int

これはInt型の要素2つのタプルである。レコード型について別の見方をすると、レコード型というのは

type RecordItem 'a = string * a

このようにフィールド名と要素のタプルを定義して、これらをアドホックに組み合わせて第一成分のユニーク性についてのルールを定めた型、と見做す事も出来る。

また、OR型(選択型とも)の例はこのようなものになっている。

type FruitsSnack =
    | Apple of AppleVariety
    | Banana of BananaVariety
    | Cherries of CherryVariety

この時、FruitsSnack型のデータはAppleというタグで識別されるAppleVarietyか、Bananaというタグで識別されるBananaVarietyか、Cherriesというタグで識別されるCherryVarietyのどれかのデータであると読み解ける。この~ofというタグ付けが必要なのは、同じデータ型で別のタグが必要な事があるためである。タグと型のタプルを元とした直和と考えた方が簡単か。

また、AppleVariety型などはこれ自体がOR型で以下のように定義されている。この場合、タグでの識別が不要なのでタグは書かれていない。

type AppleVariety =
    | GoldenDelicious
    | GrannySmith
    | Fuji

また、1要素のタグ付きの直和型を定義する事がある。

type ProductCode = ProductCode of string

これはプリミティブに対するラッパーとして、単なる文字列ではあるが意味合いを持たせたい時などに使える。本書ではこれを単純型と呼称している。実際のところ、Javaなどの言語でも単なる文字列だが重要な意味のある識別子をID型として定義する事があるし、異なる意味合いの数値同士を演算させないために整数や実数のラッパーを作る事がある。


ところで何故代数的データ型と呼ばれるかと言えば、型=集合というのは先に述べたとおりであり、また集合に対して1つ以上の二項演算や0以上の交換律などの法則が定まっている場合、それを代数構造と呼ぶ。つまり代数的データ型というのは、数学的な意味での代数構造を持った型システムであるという事である。

例えばtype FruitsSnack = Apple of AppleVariety | Banana of BananaVarietyという定義と、type FruitsSnack = Banana of BananaVariety | Apple of AppleVarietyという定義は同じである。これは交換律を満たすなど。

大体代数的データ型といった場合、加法=ORは交換律と結合律を満たしたうえで加法単位を持ち、乗法=ANDは結合律を満たしたうえで乗法単位を持つ。また零元があり、乗法は加法に対して分配的である。大体この場合の単位は加法単位は空集合、乗法単位はunit、零元は空集合となる。そしてこのような代数構造は半環と呼ばれる。もっとも、言語によっては零元が明確にプログラマーの扱う型として存在しない事もある。F#は多分ない。私が昔作っていた言語では、空集合=never型という事で零元の型を導入しており、型演算の結果が零元になったらそれは矛盾した型定義なので型エラーのようにしていた。never型も直接使えるので、あるプロパティが絶対に存在しない事を示すために零元を使っていたりもした。(レコードの全てのプロパティが零元になったらそれもエラーにしていたと思う。)零元が存在しない場合でも、例外型などを通じて理論的にその存在を考える事が出来るだろう。

このような性質は、より複雑で強力な型の構成の際に効いてくる。例えばHaskellにおけるモナドなどは、この代数的データ型の延長に存在する。

パターンマッチとその利点

F#やその他パターンマッチのある言語の特徴として、型の構築と値の構築が似ていること、そしてパターンマッチによって構造を分解できる事がある。

例えば人物を以下のようなレコード型でモデリングしたとする。

type Person = { First: string; Last: string }

このPerson型のデータを構築するには、以下のようなリテラルを利用する。

let aPerson = { First="John"; Last="Doe" }

そしてこのレコード型のデータの個別のフィールドにアクセスするには、手続き型言語でお馴染みのドット記法の他に、パターンマッチ記法が使える。

let {First=first; Last=last} = aPerson

これは下記と同じ。

let first = aPerson.First
let last = aPerson.Last

今回はレコード型=AND型の例だったが、選択型=OR型に対しても同じような事が出来る。

type OrderQuantity =
    | UnitQuantity of int
    | KilogramQuantity of decimal

let anUq = UnitQuantity 10
let aKq = KilogramQuantity 2.5

そしてUnitQuantityKilogramQuantityOrderQuantityの別のケースであるという事で、OrderQuantityを受け取って表示する関数を考えてみる。この時、UnitQuantityKilogramQuantityで処理を分ける場合、パターンマッチでそれぞれに処理を書くことができる。

let printQuantity anOrderQty =
    match anOrderQty with
        | UnitQuantity uq -> printfn "%i units" uq
        | KilogramQuantity kq -> printfn "%g kg" kq

printQuantity anUq // "10 units"と表示
printQuantity aKq  // "2.5 kg"と表示

このパターンマッチのアイディアは、ここまで強力ではないものの、Java21以降のJavaでも使える。パターンマッチが使えると、StateパターンやVisitorパターンはこれで代用できたりする。Visitorのダブルディスパッチは型の拡張が複数個所に分散かつそれらが密結合してしまう問題があったりするが、パターンマッチで代用できる場合はかなり有力な選択肢に見える。

このように、一般的に代数的データ型を採用した言語は型と値の両方に共通したプリミティブな演算を用いて複雑なデータを構成し、それをパターンマッチの際に分解して取り出したりすることによって、複雑なデータの取り扱いを容易にしている。

型の合成によるドメインモデルの構築

型が代数的である=型に対する演算と法則が定まっているという事は、小さなプリミティブから複雑な型を構築していくことができるという事である。例として、「小切手番号」「カード番号」という型を考える。これらはタグ付けされたプリミティブのラッパーとして定義できる。

type CheckNumber = CheckNumber of int
type CardNumber = CardNumber of string

次にクレジットカードの種類と、クレジットカード情報を構築してみる。カード種別はVisaとMasterCardのどちらかで、クレジットカード情報はカード番号とカード種別を持つものとする。

type CardType = Visa | Mastercard

type CreditCardInfo = {
    CardType: CardType
    CardNumber: CardNumber
}

そして次に支払い手段について型を構築してみよう。支払い手段は現金(Cash)、小切手(Check)、カード(Card)の3水類とする。

type PaymentMethod = 
    | Cash
    | Check of CheckNumber
    | Card of CreditCardInfo

さらに、支払総額や通貨などを定義してみよう。

type PaymentAmount = PaymentAmount of decimal
type Currency = EUR | USD |JPY

最後に、支払型について構築してみよう。支払型というのは、支払総額、通貨、支払方法を持つ型なので、このような定義になる。

type Payment = {
    Amount: PaymentAmount
    Currency: Currency
    Method: PaymentMethod
}

ここで一点着目するところは、これはOOPのモデリングではないので、メソッドの類は一切規定されていない事だ。例えばここで、支払がまだ行われていない請求書をUnpaidInvoice、支払済み請求書をPaidInvoiceとして型付けしたとする。この時、支払処理を行う関数の型をPayInvoiceとするなら、その関数の型は以下のようになる。

type PayInvoice = UnpaidInvoice -> Payment -> PaidInvoice

また、支払いに利用する通貨を変換する処理は以下のようになる。

type ConvertPaymentCurrency = Payment -> Currency -> Payment

省略可能な型、コレクション、エラー

関数型プログラミングに限らず、以下のような型はプログラミングをしていると頻出する。

  • データの欠落や省略
  • エラー
  • 値を返さない関数
  • リストなどのコレクション

これらをF#ではどう扱うのかを見ていく。

省略可能な値

ここまでのデータ型はすべて、値がnullにならない、つまりデータがそもそも存在しないという事を表現できない。例えば姓名のミドルネームなどは、国によって有無が異なる例の1つだ。

type PersonName {
    FirstName: string
    MiddleName: string // これがない国も存在するんだが!?
    LastName: string
}

このように値がそもそも存在しない事を表現したい場合、F#の場合はOption型を使う。HaskellでいうMaybe、JavaではOptional型のあれ。

type PersonName {
    FirstName: string
    MiddleName: Option<string>
    LastName: string
}

Optionという型はどのように定義されているかというと、このようになっている。

type Option<'a> =
    | Some of 'a
    | None

Noneは値が存在しない事を意味しており、Someは型パラメーターで受け取った値が存在していることを意味している。これにより、コードを書く際にはOption型の値に対しては下記のようにコードを書くことになる。

match person.MiddleName with
    | Some(middlename) -> printf "Your middle name is %s" middlename
    | None -> printf "You have no middle name"

Optionは良く使うという事で、以下のような記法も特別に用意されているようだ。

type PersonName {
    FirstName: string
    MiddleName: string option
    LastName: string
}

エラーのモデリング

上記のOptionが理解できたら、エラーのモデリングも簡単。先の例は「値があるかないか」でOR演算を行っていたが、今度は「成功の値か、失敗の値か」でOR演算をすればよい。なお最近のF#では標準ライブラリにResult型というのが存在するが、自分で定義する場合はこんな感じだ。

type Result<`Success, `Failure> =
    | OK of `Success
    | Error of `Failure

これを利用すると、先の支払処理はこのように書くことができる。

type PaymentError =
    | CardTypeNotRecognized
    | PaymentRejected
    | PaymentProviderOffline

type PayInvoice = UnpaidInvoice -> Payment -> Result<PaidInvoice, PaymentError>

値が存在しないことのモデリング

関数型言語は原則として全ての関数は値を返さなければならない。そのため、JavaやCでいうvoid関数をどう定義するのかという事だが、これにはunitという特別な組み込み型を使う。unitは既に上の方で軽く触れてしまったが、基本的にこれは乗法単位として使われるシングルトンである。F#では()という記法で表現される。

一点注意が必要なのは、「値が存在しない事を表現するための特別な値」と「空集合」は異なるという事。多くの手続き型言語はこれらを区別していないというか気にしていないが、本来ここは厳密に区別されてしかるべきところ。

リストとコレクションのモデリング

F#では様々なコレクションが標準ライブラリに存在する。

  • list: 不変かつ固定長のコレクション。要素の追加がしたい場合、新たな要素を加えたリストを作成する。要素の置換も同様。
  • array: 可変かつ固定長のコレクション。インデックスで値を取得したり上書きしたりできる。
  • ResizeArray: JavaやC#のList相当。(実際にC#のList<T>へのエイリアスらしい)
  • seq: 遅延コレクション。
  • Map, Setなどもある。

本書ではlistの仕利用が推奨されている。実際のところ、可変リストよりも不変リストの方が予期せぬ副作用で値が変わることがないので扱いやすい事が多いだろう。

F#などの関数型言語では、リストの構築にはリストリテラルやリスト演算子を使う。(これらの演算子をユーザー定義できる言語もある。)

let aList = [1; 2; 3] // リストを構築
let aNewList = 0 :: aList // 新しいリストである[0; 1; 2; 3]を生成する

リストに対してアクセスする際にもパターンマッチが利用できる。

match aList with
    | [] -> // 空の時の処理
    | [x] -> // 1要素のリスト用の処理
    | [x; y] -> // 2要素のリスト用の処理
    | longerList -> // それ以上の要素のリスト用の処理

また、::演算子を使ったパターンマッチもある。

match aList with
    | [] -> // 空の時の処理
    | first::rest -> // 最初の要素とそれ以降に分解した上で処理を書ける