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

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

はじめに

第1部・第2部を通して、ドメイン駆動設計(DDD)の基本概念と、関数型アプローチでのドメインモデリング手法を学びました。ビジネスイベントを中心に共通の理解を築き、ワークフローを関数で分解、型を使って要件を明確に表現することで、実装とモデルの整合性を保つ方法がわかりました。

第3部では、モデル化したドメインを実装に落とし込み、合成やエラー処理、依存管理を行う方法を学びます。そして、この第8章では関数の基本と合成手法を学び、コードの再利用と柔軟な設計を理解します。

学べること

  • 関数の基本と合成手法を学び、コードの再利用と柔軟な設計を理解する
  • パイプラインによるワークフローの実装のイメージを掴む

とくに、「8.2.4 カリー化」「8.2.5 部分適用」については、9章以降に何度も出てきます。


8.1 関数、関数、どこにでも

関数型プログラミングとは、関数が非常に重要であるかのようにプログラミングすること。関数があらゆる場所、あらゆるものに使われること。

関数型プログラミングがオブジェクト指向プログラミングとどのように違うのか。

観点オブジェクト指向言語関数型言語
大きなプログラムがあって、そのプログラムが小さなピースで組み立てるクラスやオブジェクト関数
プログラムの一部をパラメーター化する必要や、コンポーネント間の結合を軽減する必要がある場合オブジェクト指向のアプローチでは、インターフェースや依存関係の注入関数を使ってパラメーター化
“Don’t Repeat Yourself”の原則に従って、多くのコンポーネント間でコードを再利用したい継承やDecorator パターンのようなテクニックを使用再利用可能なコードをすべて関数にまとめ、関数合成を使ってそれらを組み合わせる

このように、関数型プログラミングではオブジェクト指向言語などの別のパラダイムで生じる疑問ではなく、その疑問が解決したかった本来の課題をどう解決するかを考えやすくなる。

8.2 「もの」としての関数

ものとして扱う、とは

  • 関数自体が「もの」である
  • 「もの」は引数(入力)として扱える
  • 「もの」は戻り値(出力)として扱える

関数をあらゆる場所、あらゆるものに使うことができる。

8.2.1 F#で関数を「もの」として扱う

let キーワードは関数定義だけではなく、一般的に値に名前を割り当てるために使用される。これは偶然ではなく、関数が「もの」であるので、letで名前を割り当てて関数を定義することができる。

8.2.2 引数(入力)としての関数

// 関数fn を受け取り、それを引数5 で呼び出し、その結果に2 を加えています。
let evalWith5ThenAdd2 fn = fn(5) + 2

// シグネチャ
// evalWith5ThenAdd2 : fn:(int -> int) -> int

関数は入力(引数)として扱うことができる。上記関数のシグネチャをみると、引数fnは intを引数とし、戻り値がintの関数ということがわかる。

8.2.3 出力(戻り値)としての関数

関数を出力(戻り値)にできるということは、関数を作れるということ。

たとえば、1、2、3を足してくれる関数を add1、add2、add3をとして定義する必要があったとした場合、関数を作成する関数を定義することで実現できる。

let adderGenerator numberToAdd =
// ラムダを返す
fun x -> numberToAdd + x

// シグネチャ
// val adderGenerator : int -> (int -> int)

// 1を足す関数
let add1 = adderGenerator 1

// 2を足す関数
let add2 = adderGenerator 2

// 3を足す関数
let add3 = adderGenerator 3

8.2.4 カリー化

「関数を返す」という技を使えば、どんな多パラメーターの関数でも、1 パラメーターの関数が連なったものに変換できます。この方法をカリー化と言います。

2つの引数があるadd関数はadderGeneratorのようにすると、1つのx: intを受け取る形になり、戻り値はy:intを引数とする関数にすることで1パラメーターを実現できます。

// int -> int -> int
let add x y = x + y

// int -> (int -> int)
let adderGenerator x = fun y -> x + y

なお、F#では、これを明示的に行う必要はなく、すべての関数は暗黙のうちにカリー化されている。

8.2.5 部分適用

// sayGreeting: string -> string -> unit
let sayGreeting greeting name =
    printfn "%s %s" greeting name

sayGreetingは2つの引数があるが、sayHello, sayGoodbyeなどのように第1引数までを組み込んで関数を定義することができます。これを部分適用といいます。sayHello, sayGoodbyeを使うときに、sayGreetingの2つ目の引数をそれぞれの関数に渡すことでsayGreeting関数が実行されます。
(先述した通り、F#がすべての関数をカリー化しているため、特別な実装せずとも部分適用が可能になっている)

// sayHello: string -> unit
let sayHello = sayGreeting "Hello"
// sayGoodbye: string -> unit
let sayGoodbye = sayGreeting "Goodbye"

8.3 全域関数

取りうる入力すべてについて、対応する出力が1 つずつ決まるように関数を設計しようと思います。このような種類の関数は、全域関数と呼ばれます。

このtwelveDividedBy関数は、シグネチャ twelveDividedBy : int -> int だがそれはうそ。実際は例外が返ってくる。

// シグネチャ twelveDividedBy : int -> int はうそ。実際は例外が返ってくる。
let twelveDividedBy n =
    match n with
    | 6 -> 2
    ...
    | 0 -> failwith "Can't divide by zero"

これを全域関数化するには、そもそも引数に0を受け付けないような型による制限(NonZeroInteger)することで少しは良くできる。もう1つの方法は戻り値を拡張すること。この場合、Option型を使用する。下記の例の場合、シグネチャは twelveDividedBy : int -> int option になる。

/// 拡張された出力値を使う
let twelveDividedBy n =
    match n with
    | 6 -> Some 2 // 有効
    | 5 -> Some 2 // 有効
    | 4 -> Some 3 // 有効
    ...
    | 0 -> None // 未定義

8.4 関数合成

1 つ目の関数の出力を2 つ目の関数の入力につなげて、関数を組み合わせること。

8.4.1 F#における関数の合成

F#では、最初の関数の出力型と2 番目の関数の入力型が同じであれば、2 つの関数をくっつけることができます。これは通常、「パイピング」と呼ばれる方法で行われます。(Unixのパイピングとよく似ている)

パイプは|>で表現する。以下は関数合成の簡単な例。

let add1 x = x + 1 // int -> int 型の関数
let square x = x * x // int -> int 型の関数
let add1ThenSquare x =
    x |> add1 |> square

8.4.2 関数からアプリケーション全体を構築する

ここでは、9章で出てくるパイプライン合成の例を自分なりに簡単に実装してみました。使用例を見ると、パイプライン上の関数の結果によって何が返ってくるかがわかると思います。

// 注文データ型の定義
type Order = { Id: int; Quantity: int; Price: float }

// 検証関数:注文の数量と価格が妥当か確認する
let validateOrder order =
    if order.Quantity > 0 && order.Price > 0.0 then
        Some order
    else
        None

// 支払い処理関数:支払いが成功したかどうかをシミュレーション
let processPayment order =
    if order.Price * float order.Quantity < 1000.0 then
        Some "Payment Successful"
    else
        None

let processOrder order =
    order
    |> validateOrder
    |> Option.bind (fun validOrder ->
        processPayment validOrder
        |> Option.map Some
        |> Option.defaultValue (Some "Payment Failed")
    )
    |> Option.defaultValue "Order Validation Failed"

// 使用例
let order1 = { Id = 1; Quantity = 3; Price = 100.0 }
let orderRes1 = processOrder order1  // "Payment Successful" が返されます

printfn "orderRes1 %s" orderRes1

let order2 = { Id = 1; Quantity = 3000; Price = 100.0 }
let orderRes2 = processOrder order2  // "Payment Failed" が返されます

printfn "orderRes2 %s" orderRes2

let order3 = { Id = 1; Quantity = 0; Price = 0.0 }
let orderRes3 = processOrder order3  // "Payment Order Validation Failed" が返されます

printfn "orderRes3 %s" orderRes3

8.4.3 関数を合成する上での課題

関数を合成するときに、一方の関数の出力がもう一方の関数の入力と一致しない場合はどうするのか。わかりやすい例としては、成功/失敗のResult 型など。出力がint で、入力がOption<int>の場合、両者を包含する「最小」の型、つまり最小公倍数はOption です。Some を用いてfunctionA の出力をOption に変換すると、調整された値をfunctionB の入力として使用でき、合成が可能となります。これは非常に簡単な例ですが、より複雑な例は9章、10章で説明があるようです。

// int を出力とする関数
let add1 x = x + 1

// Option<int>を入力とする関数
let printOption x =
    match x with
    | Some i -> printfn "The int is %i" i
    | None -> printfn "No value"

5 |> add1 |> Some |> printOption