🙆‍♀️

Functional Programming in C# まとめ 3章

2024/02/19に公開

Functional Programming in C#の3章についてまとめています。

2章のまとめは、こちら

Designing function signatures and types

この章では以下を説明する。

  • うまく設計された関数シグネチャ
  • 関数への入力に対するきめ細かい制御
  • データが存在しない可能性を表現するためにOptionを使用する

これまでは、静的型付けか動的型付けかに関係のないFPの原則を説明してきたが、この章では静的型付けに絞った内容となる。関数のシグネチャについては、一般的なオブジェクト指向と考え方が異なる。

3.1. Functions signature design

まず、関数シグネチャの表記方法を新たに導入する。関数fが入力としてintを取り、出力としてstringを返す場合以下のように表記する:

f:int -> string

こっちのほうがC#の表記よりも分かりやすいので、議論ではこちらの表記を使う。voidは()で表現する。HaskellやF#でお馴染みの表記方法であるが、複数引数がある場合、(int, int) -> intなどとなり、少し異なるので注意。

関数シグネチャから、ある程度どんな処理を行うのかが予測できる。※後に予測がつきやすい場合とそうでない場合の議論がある。

3.2. Capturing date with data objects

FPでは、データを表現するために、データオブジェクトを利用する(ロジックのないデータの入れ物)。

  • ロジックは関数する
  • データはデータオブジェクトにする。データオブジェクトは、関数の入出力に利用する

静的型付け言語では、何かロジックを実装するときに、dynamic型を使って実装はしない。なぜなら、期待していない型の値が入力されてもコンパイルエラーにならないから。

これと同様の考え方の延長で、より高度な設計の考え方では、何かロジックを実装するときに、プリミティブ型を使って実装するのは好ましくない。例えば、人の年齢を表現するのに、intを使う場合があるが、負の年齢や4桁の年齢はあり得ない(年齢のデータとして有効でない)が、それらの値を制限することができない。なので、プリミティブ型のデータ1つで表現できるようなものであっても、専用の型を設計することが望ましい。

補足:確かにFPの考え方だと何でもかんでも型にする、という風潮がある。しかし、この考え方はOOPでも普通のある考え方なので、FP特有の考え方ではないと思う。

ここで大事なことは2つ

  • 型を定義することで、実行時エラーがコンパイルエラーに置き換えられる
  • 不正な値が入りこまないことで、ロジックに例外がなくなる

例えば、年齢の例であると、

int -> int

という関数があった場合、負の値が入力されたら、例外が発生するかもしれない。つまり、関数シグネチャからは予測できない動作をする関数になる。一方で、

Age -> int

とした場合、年齢として有効な範囲の値しか入っていないので、例外が発生することなく、すべての入力に対してintの結果が得られる。つまり、関数シグネチャ通りの動作をする。

3.3. Modeling the absence of data with Unit

副作用を実行し、結果を返さない関数(C#で、戻り値がvoidの関数)は、FPの色々なテクニックを使うことができない。というのも、FPでは、関数は入力に対して、必ず結果を返すものと捉えているから。

このため、他のFP系の言語では、そういう時のための値としてユニット:()がある。これに倣ってC#にもユニットを導入する。

ほとんどが、C#における技術的な話

  • System.ValueTuple(空のタプル)をUnitにする
  • Unitを使って、APIの中で、Action<...>Func<..., Unit>に置き換えて処理するようにする
    • 処理が重複してしまうので、クライアントコード側では意識させないようにする

自分がクライアント側のコードを書く時には、あらかじめ常にFuncを使った方が良いのか、Actionを使うべきなのかについては、ここでは言及がありませんでした。FPっぽいコードを書いていれば、Actionを使う状況がほとんどない(副作用は可能な限り外側に押しやる)はずなので、既存のコード等を想定してActionはそのまま受け入れる、という意図なのかもしれません。

3.4. Modeling the possible absence of data with Opton

値があるかもしれないし、ないかもしれない場合に利用する型Optionを導入する。

Optionは本質的にコンテナ。値そのものか、値がないか、をラップする。記号的な定義は以下:

Option<T> = None | Some(T)

F#のUnion型のような記述がされているが、

  • Option<T>型は、None型かSome(T)型のいずれかの値を持つ

という意味。

  • None:値が存在しないことを示す
  • Some(T):内部にT型の値を保持している

以下、利用例

Option<string> _ = None;
Option<string> johon = Some("John");

greet(_);       // Sorry, Who?
greet(johon);   // Hello, John

string greet(Option<string> greetee)
    => greetee.Match(
        None: () => "Sorry, Who?",
        Some: (name) => $"Hello, {name}");

Option<T>を使うことで、

  • 値がない場合があることをクライアントコードに認識させる
  • 部分写像を全域写像にする

ことができる。

  • 全域写像:入力されうるすべての値に対して、対応する出力を持つ写像
  • 部分写像:入力されうるすべての値に対して、必ずしも対応する出力を持つわけではない写像
    • つまり、入力されうる一部の値に対して、出力値が定義されていない(何が起きるかわからない)

プログラムでいうと、部分写像は、与えられた引数に対して、一部の値の場合には、例外を返すような関数。これを例外を返す代わりに、対応する出力がなかったということで、Noneを返すようにすることで、全域写像にする、ということ。

これにより、関数シグネチャ通りの動作をするようになり、関数の動作が予測できるようになる。

以下、何点か補足。

  • 著者も初読の人は、C#によるOption型の実装は飛ばしてよい、と言っているが、本当に飛ばした方が良い。基本的にはC#が対応していない判別共用体っぽい動作をC#でどうやって実装するか、という話で全くFPとは関係ないです。逆に混乱します。
  • プリミティブ型に対してC#にはNullable<T>型がありすが、著者は全く触れていません。おそらく、プリミティブ型に対してもそれをラップするクラスを作るものとして(上の年齢の例のような)、そのラップされたクラスに対して適用するためのOption<T>なのだと思います。
  • 部分写像から全域写像にするために、Option<T>を使うということですが、Option<T>を使うと、「なぜ値がないのか?」という情報が得られません。おそらくこの問題は後に、Result型を使って解決するのだと思います。

Discussion