🐙
『なっとく!関数型プログラミング』を読んだ
関数型プログラミングへの理解をさらに深めるため本書を手に取りました。
他の「なっとく」シリーズと同じように、そのときどきで浮かぶ疑問を、その場で丁寧に紐解いてくれるのでとても理解しやすかったです。
また本書を読んだことで、関数型パラダイムが日常のコーディングをどのように改善してくれるのか言語化して説明できるようになりました。
何度読んでも良さそうなスルメのような本。
個人的なまとめ
- 関数型プログラミングとは、純粋関数とイミュータブルな値のことに他ならない
- 関数型プログラミングの本質は、入力値を新しい出力値に変換する関数を記述することにある
- 共有ミュータブル状態を極力排除し、純粋関数とイミュータブルな値にすること
- なぜ関数型プログラミングを使うのか
- 読みやすく、変更しやすく、管理しやすいから
- 実装の観点でのメリット
-
可読性が高くなる
- シグネチャや型定義だけを見て先へ進めるから
- 純粋関数は嘘をつかないため各行を調べる必要がなく、シグネチャを見て先へ進むだけでいい
- プリミティブ型ではなく、newtypeやADT(algebraic data type)を使い、そのADTをパターンマッチングで処理するという方法で、振る舞いもデータモデル化できる
- ビジネス要件をイミュータブルデータとしてモデル化できる
- エンティティは直積型で、選択肢の場合分けはenum, caseのような直和型で、データ型(ADT)として表現できる
-
try-catch
ではなくIO型を使用することで、ビジネスロジックの中にエラー処理が混ざることがなくなる
- シグネチャや型定義だけを見て先へ進めるから
-
イテレーティブに開発しやすくなる
- 小さな関数ブロックにフォーカスしながら、ブロックを組み合わせて開発できるから
- 開発対象を分割可能にし、1つの小さな関数の実装に集中できる
- トップダウン(シグネチャから始める)またはボトムアップ(小さな関数を特定する)でアプローチできる
- 関数型設計は小さなブロックから構築されるため、組み合わせて別の値を計算できる
- コードを書くときは、他の関数のことを忘れて、1つの小さな関数の実装に集中できる
- シグネチャを調べてジグソーパズルのピースのように組み合わせることでできる
- コード実装自体も、さらにはタスク自体も分割容易性が高まる
- 小さな関数ブロックにフォーカスしながら、ブロックを組み合わせて開発できるから
-
新しい要件に対応しやすくなる
- ブロックを取り外したり組み合わせたりで対応できるから
- 関数型はブロックに新たなブロックを組み合わせたり、いらないブロックを外したりできるため、変化に対応しやすい
- 再利用と合成が簡単にできる
- ブロックを取り外したり組み合わせたりで対応できるから
-
影響範囲が明確になり変更容易性が上がる
- 共有ミュータブルな状態を扱わないことで、影響範囲が限定できるから
- 関数型プログラミングとは、イミュータブルな値を操作する純粋関数を使うプログラミングである
- 共有ミュータブルな状態を扱わないことで、影響範囲が限定でき、状態を追うことによる脳内メモリの消費を抑え、コードを読む際の認知負荷を下げることができる
- コンパイルエラーによって実行時のエラーに先回りして対応できる
- ADTとEitherによってコンパイル時点でのエラーに変換できる
- 例外スローではなく値としてエラーを返すことで失敗可能性を事前に把握できたり、型でのモデル化によって間違ったパラメータを引数に渡す時点でエラーにすることができる
- IO型によって失敗をエラーとして明示的に変換できるため、関数がnullや接続エラーなどの例外を返すことがなくなる
- 命令型では、
MeetingTime
を返すことになっていても、実際にはnullであったり、例外をスローしたりすることが多い - IO型を使うとnullや例外はコンパイルエラーとなるので、エラーへの対処を強制できる
- 命令型では、
- コンパイルエラーの方が実行時のエラーよりもストレスが少ない
- ADTとEitherによってコンパイル時点でのエラーに変換できる
-
テスト容易性が高まる
- 引数を指定して、出力の期待値をアサートするだけでテストできるから
- プロパティベースのテストでランダム値も検証しやすくなる
- 単体テストをベースとしてほぼ機能やコードをカバーでき、小さく、高速で、安定したテストを作成できる
- すべてのテストが小さくなることで、それ自体が要件や実装の詳細を理解しやすいドキュメントとなる
-
並行アプリケーションが作成しやすくなる
- それぞれが依存していない純粋関数なので、並列に実行しても問題ないため
- 関数型ではマルチスレッド化は
parSequence
とするだけでよく、キャッシュ化もやりやすい
-
可読性が高くなる
- 純粋関数とは、戻り値は常に1つで、引数のみを使い、既存の値を変更しない関数である
- 関数型プログラミングは式を使うプログラミングであり、関数型プログラマは文を使わない
- イミュータブルな値を取得し、純粋関数内で別のイミュータブルな値を作成する
- 関数型のパイプラインとなるfor内包表記での列挙子は、ジェネレータ(
x <- xs
)かガード式(filter
)になる - 関数型プログラミングでは、ほとんどの関数から非純粋性を取り除く
- イミュータブルな値、純粋関数、遅延評価、信頼できるシグネチャが重要
- 関数を引数として渡すという非常に強力な関数型テクニックによって、拡張や変更の容易性が高められる
格言
- 言語において最も危険な言い回しは、「ずっとこのやり方でやってきた」である。― Grace Hopper
- 私たちは過去を知っているが、それをコントロールすることはできない。私たちは未来をコントロールするが、それを知ることはできない。― Claude Shannon
1: 関数型プログラミングを学ぶ
- これまでにないほどとっつきやすい関数型プログラミングの方を書きたいと夢見てきた。それが本書である。
- 関数は、入力値を受け取り、その値を使って何かを行い、出力値を返すボックス
- 入力値と出力値の型と名前はシグネチャ
- 実装本体はボックスの中に隠れているが、シグネチャは公開されている
- シグネチャだけ見て十分に理解できるなら、コードの可読性が高くなる
- 宣言型アプローチは、どのように行うのかではなく、何を行う必要があるのかに焦点を当てる
- 方法ではなく内容に焦点を合わせる
- SQLは宣言型の言語
- 関数型プログラミングで使う関数とは
- シグネチャが嘘をつかない
- 本体が極力宣言的である
- 重要なポイント
- FPでは、関数の本体よりもシグネチャに焦点を合わせる
- 嘘をつかない関数はFPの重要な特徴である
- FPでは、どのように起きるかよりも、何が起きるかに焦点を合わせる
2: 純粋関数
- 状態はその値が時間とともに変化するもの
- 状態を条件にしているため、状態の変化ごとに条件を変更しないといけなくなる臭うコード例の紹介
- itemsにbookがあれば割引したい
- bookAddedフラグを持つクラスを作成して、フラグにより判定する実装
- add時、remove時などitemsが変更されるたびに判定が必要になる
- itemsから直接bookを削除すると、bookAddedがtrueになったままになる
- bookAddedフラグを持つクラスを作成して、フラグにより判定する実装
- 関心の分離をする
- itemsを引数に取り、bookがあれば割引する関数を作成する
- 必要なときに判定することで状態を保持しておく必要がなくなる
- 状態を持たない純粋関数となる
- 戻り値は常に1つだけ
- 引数のみに基づいて戻り値を計算する
- 既存の値を変更しない
- itemsの追加削除参照はListで可能
- どうしても状態を扱わないといけないところはプリミティブ型を使う
- それでも扱えないときにclassを考える
- どうしても状態を扱わないといけないところはプリミティブ型を使う
- itemsを引数に取り、bookがあれば割引する関数を作成する
- itemsにbookがあれば割引したい
- 関数型プログラミングではデータのコピーを渡す
- 直接変更されないようにする
- 純粋関数とは
- 戻り値は常に1つ
- 引数だけを使う
- 既存の値は変更しない
- 純粋関数は推論しやすい
- 大規模な状態遷移モデルを頭の中で組み立てる必要がないから
- 純粋関数とクリーンなコード
- 単一責任
- 副作用がない
- 参照透過性
- 最大の利点はテスト容易性にある
- 重要なポイント
- 純粋関数はFPの土台である
- FPではデータを直接変更するのではなく、データのコピーを渡す
- FPでは、関心事を別々の関数に分離する
3: イミュータブルな値
- 関数型プログラミングとは
- イミュータブルな値を操作する純粋関数を使うプログラミングである
- 可変性を回避することが重要
- 勝手に既存の値が書き換えられてしまうから
-
コピーを使って可変性に対抗する
- 既存の値が変更されないので、同じ引数であれば同じ結果を返すようになる
- 参照透過性と呼ぶ
- 既存の値が変更されないので、同じ引数であれば同じ結果を返すようになる
- 可変性を回避することが重要
- イミュータブルな値を操作する純粋関数を使うプログラミングである
- 状態とは、1つの場所に格納され、コードからアクセスすることが可能な値のこと
- これが変更できると
- ミュータブルな状態
- コードのさまざまな部分からアクセスできるなら
- 共有ミュータブルな状態
- これは命令型プログラミングの構成要素
- 共有ミュータブルな状態
- これが変更できると
- 追跡しなければならないものが多いほど、タスクの認知負荷が高くなる
- 人間の脳の処理限界をすぐに超えてしまう
- 共有ミュータブルな状態の影響
- 可動部分が増えると複雑さが増大する
- 変更に対する影響範囲が大きくなる
- 影響範囲を考えなければいけなくなることで認知負荷が大きくなる
- 可動部分に対処する方法
- コピーを返すようにする
- オブジェクト指向だと
- カプセル化する
- 関数型だと
- イミュータブルな値を使う
- Listを、前のcitiesBeforeと後ろのcitiesAfterに分け、前にappendしたものが返される
- 値は変更されない
- Listを、前のcitiesBeforeと後ろのcitiesAfterに分け、前にappendしたものが返される
- OOPは可変性をカプセル化で解決したが、関数型は基本イミュータブルな値として、可変性は選択的に入れられるようにすることで解決した
- イミュータブルな値を使う
- まとめ
- 可変性は危険である
- コピーを使って可変性に対抗する
- イミュータブルな値を使って可変性に対抗する
- 重要なポイント
- 可変性を回避することは、FPの要である
- FPでは、ミュータブルな状態ではなく、イミュータブルな状態を使う
- FPでは、イミュータブルな値をやり取りするだけである
- 普遍性は値の間の関係に着目させる
4: 値としての関数
- 純粋関数とイミュータブルな値をどう連動させるか
- 関数のシグネチャは事実をありのままに伝えるべき
- 実装を調べなくても、パラメータリストがその説明になるようにする
-
rankedWords(words)
->rankedWords(scoreComparator, words)
- どのようにランクづけする? -> スコアの高い方を優先する
-
- 引数だけを使うというルールが適用しやすくなる
- 実装を調べなくても、パラメータリストがその説明になるようにする
- シグネチャは、それ自体がドキュメントになっているか、内部で行われることをどれくらい簡単に理解できるかの観点で設計する
- コードの読み手はシグネチャを見るだけで、実装を調べなくても、内部で何が行われるのかを完全に理解できる状態が理想
- 最も配慮すべきはコードの読み手であって書き手ではない
- コードは書かれるよりも読まれることのほうがはるかに多い
- コードベースは書かれるよりも読んで分析されることのほうが多い
- したがって、常に読むためにコードを最適化する必要がある
- コードは書かれるよりも読まれることのほうがはるかに多い
- 関数型プログラミングでは、すべてが式である
-
「何を行う必要があるか」ではなく、「どのように行うか」に気を取られると、常にコードが読みにくくなる
-
negativeScore
を入れると、なぜここはnegativeになっているのか、考える認知負荷がかかる- 処理方法を並べていく形式となるので、手続き的になる
-
sortBy(wordScore).reverse
とすると認知負荷が減る
-
- エンジニアがソフトウェアの設計に注目する理由
- ソフトウェアを管理しやすいものにするため
- 可読性を上げ、認知負荷を減らす
- すばやく確実に変更できるようになる
- 可読性を上げ、認知負荷を減らす
- ソフトウェアを管理しやすいものにするため
-
関数型では、関数ごとに1つの小さなビジネス要件を実装することに焦点を合わせる
- 他のプログラミングパラダイムとの違い
- 複数の関数の結果をまとめる関数を作ると形式的になる
-
scoreWithBonus(w)
->score(w) + bonus(w)
- これをインラインの無名関数として渡すようにする
-
def rankedWords(wordScore: String => Int, words: List(String)): List[String] = ...
- 関数を引数として他の関数に渡している
- 処理方法と処理対象をセットで渡すので、シグネチャだけで内部処理が自明となる
-
- これをインラインの無名関数として渡すようにする
-
- mapで処理をする
- これは引数として関数を受け取る関数を定義するというパターンになる
- このAPIは使いやすいかと考えてみる
- 関数型のコードベースでは、通常、APIは関数のシグネチャである
- 安易に3つ目の固定パラメータを追加して問題が解決するかどうか
- highScoringWords5関数では0,1...と同じような関数を複数作らないといけなくなる
- 新たな可変箇所を引数として渡すことで繰り返しコードを避ける
- 今度はスコア処理部分が繰り返しになる
- 共通処理をする関数から、可変箇所を引数にとる関数を返すようにする
- 使用時に可変箇所を引数として与えるだけでよくなる
-
val wordsWithScoreHigherThan: Int => List(String) = highScoringWords(w => score(w) + bonus(w) - penalty(w), words)
-
highScoringWordsは、新たな可変箇所を引数にとる関数を返すように修正する
-
higherThan => words.filter(word => wordScore(word) > higherThan)
がreturn関数となる - wordsWithScoreHigherThan関数を作成するときに処理方法と処理対象を設定する
- 設定した関数で、新たな可変箇所を引数として受け取れるようになる
- 固定できるところを先に設定していく
-
-
wordsWithScoreHigerThan(5)
とシンプルに使えるようになる- 使い方説明:「最初にパラメータを2つ指定します。そうすると関数が返されるので、さまざまな閾値を渡すことができます」
-
highScoringWordsは、新たな可変箇所を引数にとる関数を返すように修正する
- 新たな可変箇所を引数として渡すことで繰り返しコードを避ける
- highScoringWords5関数では0,1...と同じような関数を複数作らないといけなくなる
- 関数型APIは、クライアントのフィードバックにもとづいて反復的に設計していける
- 変更はいつでも歓迎される
- 例:wordsと閾値が主な可変箇所になると、繰り返しが多くなる
- 高階関数として部分的に設定していけるように修正する
-
def highScoringWords( wordScore: String => Int ): Int => List[String] => List[String] = ( higherThan => words => words.filter(word => wordScore(word) > higherThan) )
- 使用法
- スコアリングをまず設定する
val wordsWithScoreHigherThan: Int => List[String] => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w))
- wordsと閾値を同時に適用できるようになった(部分的にも適用可能)
wordsWithScoreHigherThan(5)(words)
- スコアリングをまず設定する
- 完全に可変にする場合
highScoringWords(w => score(w) + bonus(w) - penalty(w))(1)(words)
- 固定にしたいパラメータがある場合は、関数を名前付きのvalとして保存すればいい
- 関数のユーザーが選択できる
- Scalaでは複数のパラメータリストを定義できる糖衣構文がある
-
def highScoringWords(wordScore: String => Int)(higherThan: Int)(words: List[String]): List[String] = { words.filter(word => wordScore(word) > higherThan) }
- パラメータの順序は非常に重要
- 例:スコアリングアルゴリズム -> 高いスコアの閾値 -> 単語リスト
-
- 関数を返すことで柔軟なAPIにできる
- カリー化
- 複数パラメータを持つ関数を、関数から関数が返される一連のパラメータ1つの関数に変換すること
- 3つのパラメータを持つ関数:
def f(a: A, b: B, c: C): D
- 引数1つにカリー化された関数:
def f(a: A): B => C => D
- 引数1つにカリー化された関数(値名あり):
def f(a: A)(b: B)(c: C): D
- 3つのパラメータを持つ関数:
- 複数パラメータを持つ関数を、関数から関数が返される一連のパラメータ1つの関数に変換すること
- 新しい機能の設計は常に関数のシグネチャから始まる
- イミュータブルデータをモデル化する
- map, filter, foldLeftは、引数として関数を受け取る高階関数
-
language.map(_.name)
と書ける
-
- エンティティが複数の情報で構成されるときは、直積型(product type)としてモデル化する
- プログラミング言語を、名前と登場年でモデル化するなど
- Scalaでは
case class
とする-
case class ProgrammingLanguage(name: String, year: Int)
など
-
- 値は一度作成されたらプログラムが終了するまで同じまま
- map, filter, foldLeftは、引数として関数を受け取る高階関数
- 重要なポイント
- 値として格納される関数こそ、FPの目的である
- FPでは、関数は他の値とまったく同じように扱われる
5: 逐次プログラム
- 現代のプログラミングにおいて最も人気のあるパターンの1つは、パイプライン処理(pipelining)
- 一連の演算とすること
- 例:
.map(...).filter(...).size
- これは別の角度から見た逐次プログラムの作成である
- パイプラインは元の問題を解く段階的なアルゴリズムのエンコーディングである
- 例:
- 一連の演算とすること
- パイプラインは、組み立てと再利用が可能な小さなコードから構築できる
- 大きな問題を小さなパーツに分解して、各パーツを個別に解いた後、パイプラインと連結することで、元の問題を解くことができる
- 分割統治(Divide and Conquer)とも呼ばれる
- 関数型プログラマは可能な限りこのアプローチを使う
- 分割統治(Divide and Conquer)とも呼ばれる
- 大きな問題を小さなパーツに分解して、各パーツを個別に解いた後、パイプラインと連結することで、元の問題を解くことができる
- レコメンデーションフィードを返す事例
- 「bookごとに著者を取得し、authorごとにbookAdaptations関数を呼び出して映画を取得し、movieごとにレコメンデーションフィード文字列を作成する」
- 命令型だとforループを使う
- ミュータブルなリストを使うので、共有ミュータブルな状態になりやすい
- インデントレベルが深くなり認知負荷が高まっていきやすい
- ループ本体で式ではなく文を使う
- 副作用を追加する必要がある
- テストしにくくなる
- 関数型はflatMapのパイプライン
- flatMapは関数型プログラミングにおいて最も重要な関数である
- flatMapで著者を取り出し(
_.author
)、bookAdaptations関数を適用し、入れ子のflatMapでbook,author,movieの値にアクセスする- 最後のスコープ内に最初のリストの値が必要な場合、アクセスするためにflatMapを入れ子にする必要がある
- だが、入れ子にするとコードが読みにくくなるなら
- シュガーシンタックスであるfor内包表記を使う
-
for { book <- books author <- book.authors movie <- bookAdaptations(author) } yield s"You may like ${movie.title}, " + s"because you liked $author's ${book.title}"
- Scalaではforは式である
- 命令型での単にループを回す文であるforとは別のもの
- 式はグローバルな状態を操作せず、常に何かを返し、何回実行しても常に同じ結果を返すもの
- 関数型プログラマは文を使わない
- だが、入れ子にするとコードが読みにくくなるなら
- 最後のスコープ内に最初のリストの値が必要な場合、アクセスするためにflatMapを入れ子にする必要がある
- フィルタリングの方法
- for内包表記中
- filterを使う
- ガード式を使う
- flatMap関数に渡された関数を使う
- 最後に必要な値をflatMapで返す
-
for { r <- riskyRadiuses validRadius <- validateRadius(r) point <- points inPoint <- insideFilter(point, validRadius) } yield s"$point is within a radius of $r"
- for内包表記中
- なぜリストを使うのか
- リストは関数型プログラミングの多くの手法をカバーしているから
- map: 各要素に処理を適用し、元のデータ構造のList、サイズ、順序が維持された新しいリストを返す
- foldLift: 各要素に処理を適用し、累積値に適用する
- flatMap: 各要素に適用し、元の要素と同じ順序の結合された複数のリストを生成する
- for内包表記では戻り値の型は最初の列挙子(List, Setなど)によって定義される
- for内包表記の定義
- 列挙子はジェネレータ(
x <- xs
)かガード式(filter
)のどちらかになり、いくつでも使える - 最初の列挙子は戻り値の型となる
- 列挙子はジェネレータ(
- for内包表記で非コレクションを扱う
- 例:歴史的イベントを記録したい
-
// 歴史的イベントを記録したい case class Event(name: String, start: Int, end: Int) // その場しのぎの解 def parse(name: String, start: Int, end: Int): Event = if (name.size > 0 && end < 3000 & start <= end) Event(name, start, end) else null
- 課題
- すべての関心事がifの1行に絡み合っている
- 関心を分離したい
- nullは使いたくない
- すべての関心事がifの1行に絡み合っている
- 課題
-
- 解決策
- nullを回避する
- nullはシグネチャに嘘をつかせるから
- 実際には
Event
を返さないこともある
- 実際には
- Option型を使用して
Option[Event]
とする- Option型にはflatMap関数がある
- nullはシグネチャに嘘をつかせるから
- パイプラインとして解析する
-
def parse(name: String, start: Int, end: Int): Option[Event] = for { validName <- validateName(name) validEnd <- validateEnd(end) validStart <- validateStart(start, end) } yield Event(validName, validStart, validEnd)
- 1つでもNoneを返すと、for内包表記全体がNoneを返す
- flatMapで空のListを返すのと同じ
- 1つでもNoneを返すと、for内包表記全体がNoneを返す
- 関心が分離でき、追加変更もパイプラインに追加するだけでよくなる
-
- nullを回避する
- 例:歴史的イベントを記録したい
- 重要なポイント
-
flatMap
はFPにおいて最も重要な関数である - FPでは、小さな関数から大きなプログラムを構築する
-
6: エラー処理
- 例としてのテレビ番組解析エンジン
- 現実ではデータをDB、API、ユーザー入力といった外部ソースから取得する
- 間違っている可能性があるただのStringかもしれない
- 例外をスローしてアプリがクラッシュする
- 関数が約束した値でないということは純粋関数ではないということ
- try-catchで対処すると?
- エラーの場合にはnullを返すことになる
- あらゆる場所にtry-catchが入ることになる
- Optionを使うようにする
- 例外をスローしてアプリがクラッシュする
- 引数として入れる前に、パイプラインとして解析(parsing)する必要がある
- 間違っている可能性があるただのStringかもしれない
- なぜ例外スロー(throws Exception)を使わないか
- Optionはエラーを処理せざるを得ないようにしてくれる
- 関数からNoneが返される場合があることを無視できない
- Optionから安全に値を取得しない限りコンパイルされない
- 例外スローでの対応は、エラーを処理することが可能なだけ
- どうしてもそうしたい場合はエラーを処理できるというだけ
- 予想外のことが起こる場合がある
- 関数のシグネチャを見るだけではわからないことがある状態
- 単にNoneの場合にthrow Exceptionにすることで同じことはできる
-
条件付きリカバリー(conditional recovery)を実装するときに違いが現れる
- 新しい要件の例: 「年が1つだけのテレビ番組がある」
- 例外スロー方式だと、解析できないときは開始年と終了年のそれぞれでリカバリーする関数(
extractSingleYear
)を命令型として使用しなければならなくなる - 関数型だと、 for内包表記の中で
orElse(extractSingleYear)
を使い、Noneが返ってきたときに処理を継続すればいい-
orElse
はOptionで定義されている関数の1つにすぎない
-
- 例外スロー方式だと、解析できないときは開始年と終了年のそれぞれでリカバリーする関数(
- 新しい要件の例: 「年が1つだけのテレビ番組がある」
-
条件付きリカバリー(conditional recovery)を実装するときに違いが現れる
-
例外スローは合成できない
- 例外を明示的にキャッチして、正しく回復させるために必要な手順を厳密に指定する必要がある(try-catchが増えていく)
- 関数型はエラーを値として表現できるので、たとえエラーが存在するとしても、関数を合成していって大きな関数を作成できる
- Optionはエラーを処理せざるを得ないようにしてくれる
- Optionを使うとシグネチャを調べるだけでNoneが返る可能性があると認識できる
- Noneに対応しないといけないというルールを課すことができる
- 関数型設計は小さなブロックから構築される
- 組み合わせて別の値を計算できる
- 合成可能性(composability)と呼ぶ
- コードを書くときは、他の関数のことを忘れて、1つの小さな関数の実装に集中できる
- 組み合わせて別の値を計算できる
- Noneの場合は短絡(short-circuiting)として伝搬される
- 関数がOption型を値を返す
- for内包表記で使用される
- いずれかがNoneを返す
- None値が一番上の関数までバブルアップされる
- 演算全体の結果として、解析が失敗したことが示される
- 宣言型は、値の間の関係を定義することに焦点を合わせる
- 命令型は、実行される方法をステップ形式のレシピにし、それを実装しようとする
- Optionは
toList
で、Noneは空の配列に、Someは配列になる - コンパイラはコンテキストを知らない
- 何が正しいのかは実装者が教える必要がある
- Noneが無視されるという、ベストエフォート型のエラー処理戦略で問題ないのかどうか
- 何が正しいのかは実装者が教える必要がある
- エラー処理戦略
- ベストエフォート
- 渡されたリストの中身をすべて解析して有効なものだけ返し、無効なものは無視する
- 何か問題が起きたか知るためにはサイズを比較しなければならない
- 問題の原因となったアイテムやその理由はわからない
- 渡されたリストの中身をすべて解析して有効なものだけ返し、無効なものは無視する
- オールオアナッシング
- 渡されたリストの中身をすべて解析して、すべてが有効である場合にのみ解析済みのリストを返す
- Optionのリストを
foldLeft
でreduce処理していく- Noneが含まれていたらアキュムレータをNoneにした上で終了するので、戻り値はNoneになる
- 何が失敗したのかはわからない
- 例外の方がまし?
- 例外ベースのコードははるかに合成しにくい
- try-catchの量もずっと増える
- 実行フローを中断してしまう
- 問題がいくらでも起きそうな巨大なListでは、Optionはもはや最適解ではない
- 例外の方がまし?
-
記述的エラー(descriptive error)
-
Either
を使って、正常に処理されなかったものの詳細をイミュータブル値で返すことで、エラーを通知する
-
- ベストエフォート
- エラーの詳細を
Either[A, B]
を使って戻り値で伝える- RightはSome、LeftはNoneにあたる
- Noneが、
Left(s"Can't extract year start from $rawShow")
となる - このLeftが一番上までバブルアップする
- Leftにラッピングされてどのような問題があったかの説明が返ってくる
- Noneが、
- RightはSome、LeftはNoneにあたる
- 関数型のエラー処理のまとめ
- nullと例外を使わずにエラーを処理する
- Option, Either, orElseなどを使ってコーナーケースを確実に処理する
- 関数のシグネチャで失敗の可能性があることを示す(Option, Either)
- foldLeftでオールオアナッシング型のエラー処理戦略を実装
- Eitherで記述的なエラーを返す
- 重要なポイント
- FPでのエラー通知は、エラーを表すイミュータブルな値を返すことを意味する
- FPのコードには、通常は問題ごとに関数がある
- FPのエラー処理では、エラー値を取得し、別の値を返す
- FPでは、実行時のクラッシュよりもコンパイルエラーを歓迎する
7: 型としての要件
- データをモデル化した型を使って、変更容易性が高く管理しやすいコードベースを実現する
- 要件例: アーティストカタログ
- ジャンル、拠点、活動期間で検索できる
-
プリミティブ型によるモデル化の最初の実装例
-
case class Artist ( name: String, genre: String, origin: String, yearsActiveStart: Int, isActive: Boolean, yearsActiveEnd: Int ) def searchArtists ( artists: List[Artist], genres: List[String], locations: List[String], activeAfter: Int, searchByActiveYears: Boolean, activeBefore: Int ): List[Artist] = artists.filter(artist => (genres.isEmpty || genres.contains(artist.genre)) && (locations.isEmpty || locations.contains(artist.origin) && (!searchByActiveYears || ((artist.isActive || artist.yearsActiveEnd >= activeAfter) && (artist.yearsActiveStart <= activeBefore))) )
- うまくいくが、genreにU.Sが入れられてしまうなど問題が起こりやすい
- 主な問題点
-
if分岐地獄になっている
genres.isEmpty || genres.contains(artist.genre)) && ...
- ADTとパターンマッチングで解決
-
引数として渡すパラメータの順序に注意する必要がある
-
Artist("Metallica", "U.S.", "Heavy Metal", 1981, true, 0)
のようにジャンルに場所を入れたとしても、コンパイルが通ってしまう - newtypeで解決する
-
-
パラメータの組み合わせの意味をプログラマが知る必要がある
Artist("Metallica", "Heavy Metal", "U.S.", 1981, false, 2022)
- パラメータが相互に関係してしまう
- 「isActiveがtrueなら、yearsActiveEndは0になるべき」など
- ADTで解決
-
一部のパラメータの値を有限集合にする必要がある
Artist("Metallica", "Master of Puppets", "US.", 1981, true, 0)
- アルバムの名前をジャンルとして入力しているが、コンパイルエラーにはならない
- ADTで解決
-
パラメータの型に対する意味をプログラマがさらに考え出し、理解し、伝える必要がある
-
genres: List[String]
は、空のリストの場合は検索条件として扱わない、空でない場合に返ってくるアーティストはこのリストの少なくとも1つに該当していなければならない、などを「genresという名前が付いたString型のリスト」のみから読み取らないといけない - シグネチャから読み取れない情報が多い
- ADTで解決
-
-
一部のパラメータは、組み合わせたときのみに意味を持つことをプログラマが覚えておく必要がある
-
searchByActiveYears
がtrueの場合だけ期間検索がされる - シグネチャと直積型のみではすぐに説明がつかない
- ADTで解決
-
-
if分岐地獄になっている
-
- うまくモデル化されたデータは嘘をつけない
- 関数型プログラミングでは、無効な組み合わせの表現が阻止されるようにデータをモデル化する
- コーナーケースがコンパイルレベルで取り除かれる
- 関数型プログラミングでは、無効な組み合わせの表現が阻止されるようにデータをモデル化する
-
2. 引数として渡すパラメータの順序に注意する必要がある問題は、
newtype
でパラメータの配置ミスを防ぐことで解決する- プログラマがパラメータの順序を気にしなくてもいいようにする
-
Location
が収まる場所は1つしかないので、コンパイルエラーとなる
-
- newtypeはゼロコストラッパー(zero-cost wrapper)と呼ばれる
- 実行時にはただのStringなどになる、
- 特定のコンテキストで使える名前付きの型にラップするもの
-
opaque type Location = String object Location { def apply(value: String): Location = value extension (a: Location) def name: String = a }
- 実行時にStringになるが、コードを書くときはLocation型なので、地理情報以外は入れてはいけないことがわかる
- コードの意図が自己文書化される
- プログラマがパラメータの順序を気にしなくてもいいようにする
-
3. パラメータの組み合わせの意味をプログラマが知らなければならない問題は、有効なデータの組み合わせだけを可能にする直積型にすることで解決する
- 1つの論理的なビジネスエンティティを表す値が複数ある場合は、常に立ち止まって、より効果的にモデル化できないか考える
- isActiveの検索項目がtrueならyearsActiveEndは必要ないなど
- プリミティブ型の代わりにOption型を使う方法で解決する
yearsActiveEnd: Option(Int)
- 存在しなければまだ活動していることになる
- 高階関数
forall
でOption内の要素が条件を満たしているか判定できるartist.yearsActiveEnd.forall(_ >= activeAfter)
- Optionが空(None)であるか、Option値に関数fを適用したときにtrueが返された場合は、trueを返す
- 注意として、forallは、Noneの場合にはtrueを返す
-
noYear.forall(_ < 2020)
-> true - yearsActiveEndがNoneのときはまだ活動中のため、
>= activeAfter
は満たすことになるので、Noneの場合はtrueでよい - 逆に、
exists
は、Noneの場合にfalseを返す-
noYear.exists(_ > 2020)
-> false
-
-
contains
で含まれるものを抽出もできる
-
- 最後に、プリミティブ型とOptionによる直積型にする
case class PeriodInYears(start: Int, end: Option[Int])
- パラメータの組み合わせを直積型にモデル化して、1つのビジネスエンティティとして理解しやすくする
- 1つの論理的なビジネスエンティティを表す値が複数ある場合は、常に立ち止まって、より効果的にモデル化できないか考える
-
4. 一部のパラメータの値を有限集合にする必要がある問題は、有限集合の値だけを取ることができる直和型を使って解決する
- "Heavy Meta", "", "Bee Gees"をジャンルに入れてもコンパイルを通ってしまう
- 直和型(sum type)は、Scalaでは
enum
が使える- 型として列挙することで有限集合にできる
- さらに直和型 + 直積型を使って改良できる
- Optionを使ってビジネスロジックを表現している
case class PeriodInYears(start: Int, end: Option[Int])
-
enum YearsActive { case StillActive(since: Int) case ActiveBetween(start: Int, end: Int) }
- これで引数を、
PeriodInYears(1981, None)
→StillActive(since = 1981)
とできる - さらにビジネスロジックが明示的になり可読性が上がる
- Noneがある場合の型はどういう意味か考える必要がなくなる
- 5. パラメータの型に対する意味をプログラマがさらに考え出し、理解し、伝える必要がある問題も解決できる
- これで引数を、
-
- これが代数的データ型(ADT: algebraic data type)
- Optionを使ってビジネスロジックを表現している
- 代数的データ型とは
- どれか1つにしかなれないものをcaseにして、その選択肢をenumで組み合わせてモデル化したものが直和型
- 一度に1つ以上のものである何かを、同時にモデル化するのが直積型
- これら2つを組み合わせて、より複雑な型を構築できる型のこと
- 複数のケースを持つ直和型を、パターンマッチングを使って分解する
-
def wasArtistActive( artist: Artist, yearStart: Int, yearEnd: Int ): Boolean = artist.yearsActive match { case StillActive(since) => since <= yearEnd case ActiveBetween(start, end) => start <= yearEnd && end >= yearStart }
- 直和型の具体的な値を直積型のケースの1つとして分解し、内部の値を取り出し、=>演算子の右辺の式で結果を計算する
- 処理していないケースがあればコンパイラが警告してくれる
- 値がADTの場合は、常にパターンマッチングを使って各条件の指揮を提供できる
-
- OOPの継承とADTの違い
- ADTには振る舞いは含まれない
- OOPでは、ミュータブルなフィールドと振る舞いが1つのオブジェクトにまとめられる
- 関数型プログラミングでは、データと振る舞いは別々のエンティティ
- 直和型を定義するときは、その型の有効な値をすべて指定しなければならない
- ADTには振る舞いは含まれない
- 関数型データ設計の例
- 要件
- プレイリストは名前、種類、曲のリストで構成される
- プレイリストの種類は、ユーザーが作成したもの、特定のアーティストに基づくもの、特定のジャンル集合に基づくものの3つである
- 曲はアーティストと名前で構成される
- ユーザーには名前がある
- アーティストには名前がある
- 音楽ジャンルは3つだけであり、各自の好きなジャンルを3つ使う
-
object model { opaque type User = String object User { def apply(name: String): User = name } opaque type Artist = String object Artist { def apply(name: String): Artist = name } case class Song(artist: Artist, title: String) enum MusicGenre { case House case Funk case HipHop } enum PlaylistKind { case CuratedByUser(user: User) case BasedOnArtist(artist: Artist) case BasedOnGenres(genres: Set[MusicGenre]) } case class Playlist(name: String, kind: PlaylistKind, songs: List[Song]) } import model._, model.MusicGenre._, model.PlaylistKind._ val fooFighters = Artist("Foo Fighters") val playlist1 = Playlist( "This is Foo Fighters", BasedOnArtist(fooFighters), List(Song(fooFighters, "Breakout"), Song(fooFighters, "Learn To Fly")) ) val playlist2 = Playlist( "Deep Focus", BasedOnGenres(Set(House, Funk)), List( Song(Artist("Daft Punk"), "One More Time"), Song(Artist("The Chemical Brothers"), "Key Boy Key Girl") ) ) val playlist3 = Playlist( "My Playlist", CuratedByUser(User("Michal Plachta")), List(Song(fooFighters, "My Hero"), Song(Artist("Iron Maiden"), "The Trooper")) ) def gatherSongs( playlists: List[Playlist], artist: Artist, genre: MusicGenre ): List[Song] = playlists.foldLeft(List.empty[Song])((songs, playlist) => val matchingSongs = playlist.kind match { case CuratedByUser(user) => playlist.songs.filter(_.artist == artist) case BasedOnArtist(playlistArtist) => if (playlistArtist == artist) playlist.songs else List.empty case BasedOnGenres(genres) => if (genres.contains(genre)) playlist.songs else List.empty } songs.appendedAll(matchingSongs) )
- 要件
- 振る舞いをモデル化する
- 検索条件のさまざまな組み合わせをサポートしなければならない
- データはプリミティブ型から防弾仕様の関数型モデルへ変更できた
- 検索ロジックも変更する必要がある
- 要件自体をデータとして扱う
- 検索条件は、ジャンルによる検索、出身地による検索、活動期間による検索のいずれか
-
enum SearchCondition { case SearchByGenre(genres: List[MusicGenre]) case SearchByOrigin(locations: List[Location]) case SearchByActiveYears(start: Int, end: Int) }
- 型名に名詞を使うことで振る舞いをデータとしてモデル化している
- ビジネス要件を理解し、それらをnewtypeとADTとしてエンコードする作業
- ADTをパターンマッチングして式を提供する
-
def searchArtists( artists: List[Artist], requiredConditions: List[SearchCondition] ): List[Artist] = artists.filter(artist => requiredConditions.forall(condition => condition match { case SearchByGenre(genres) => genres.contains(artist.genre) case SearchByOrigin(locations) => locations.contains(artist.origin) case SearchByActiveYears(start, end) => wasArtistActive(artist, start, end) } ) )
- 1. if分岐地獄になっている問題もこれで解決する
-
- 検索条件のさまざまな組み合わせをサポートしなければならない
- 重要なポイント
- FPでは、有効な組み合わせだけが可能になるようにデータをモデル化する
- FPでは、それぞれの型に多くの高階関数がある
- 直和型と直積型の組み合わせからモデル化できる
- FPでは、振る舞いはデータモデルから切り離されている
- FPでは、一部の振る舞いをデータとしてモデル化できる
8: 値としてのIO
- 副作用となるIOに対処する方法
- 値を使って副作用のあるプログラムを表す
-
IO.delay
とIO.pure
を使う
-
- リカバリー戦略は
IO.orElse
を使って構築する - 実行は
unsafeRunSync
を使用して呼び出す - IOを戻り値として、関数に副作用があるということがシグネチャによってわかるようにする
- 本質的な関心事を純粋関数を使った関数コアにまとめ、付随的な関心事はクライアントに委譲する
- クライアントはIOアクション(コンソール入出力、HTTP、APIなど)を利用する
- 値を使って副作用のあるプログラムを表す
- 要件例: ミーティングスケジューラ
- この関数は、与えられた2人の出席者とミーティングの時間をもとに、2人の共通の空き時間枠を見つけ出すことができる
- この関数は、出席者全員のカレンダーの時間枠にミーティングを保存する
- この関数は、外部とやり取りする非純粋関数を使う。具体的には、
calendarEntriesApiCall
とcreateMeetingApiCall
を変更せずにそのまま使う(外部のクライアントライブラリから提供されると仮定する)def calendarEntriesApiCall(name: String): List[MeetingTime]
def createMeetingApiCall(names: List[String], meetingTime: MeetingTIme): Unit
-
「外の世界」に対しては最悪の事態をすべて想定する
- API呼び出しが同じ引数に対して異なる結果を返すことがある
- API呼び出しが接続(または別の)エラーで失敗することがある
- API呼び出しの完了に時間がかかりすぎることがある
- フォーマットが期待しているものと違ったためにデシリアライズエラーとなることがある
- 普通の命令型で書いた場合
-
static schedule
関数では、既存のスケジュールを取得して変数格納し、指定可能な時間枠をすべてslotsとして生成してから、希望のMTG時間と比較して可否判定する。可能な時間が存在する場合にMTGを設定し、ない場合はnullを返す。 - 正常系ではうまくいく
- 問題点
- schedule関数には少なくとも2つの責務がある
- 関心事のもつれ
- さまざまな責務とさまざまなレベルの抽象化が入り混じっている
- 変更、更新、管理が難しくなる
- 解決策: 値と、その値を取得する非純数な処理を分離して扱う
- IO型で解決
- 関心事のもつれ
- 外部API呼び出しのいずれかが失敗した場合は、関数全体が失敗する
-
命令型ではリカバリーコードがビジネスロジックの行間に絡まってしまう
- 全体的な可読性が落ちることで、長期的な管理が難しくなる
- 関数型では、互換性のあるブロック(値)を使って設計できる
- 解決策: IO型とorElseを使うことで可読性が落ちる問題を解決する
-
命令型ではリカバリーコードがビジネスロジックの行間に絡まってしまう
- シグネチャが嘘をついている
- 命令型では失敗時には例外をスローするし、空き時間がない場合はnullを返すようになっている
- schedule関数には少なくとも2つの責務がある
- その他のポイント
- エラーに対応するため
try-catch
で包む必要がある- 他の箇所でも
try-catch
が増える- 非同期通信の場所1つ1つに
try-catch
を入れることになる -
try-catch
地獄 - 現実ではそれは難しいので複数の非同期通信を
try-catch
で包むことになる - どこでエラーが発生したのかわからなくなってしまう
- 非同期通信の場所1つ1つに
- さらにリトライ戦略などのリカバリーメカニズムを検討する必要が出てくる
- ビジネスロジックが複雑化していく
- 生成される値に関心があるのに、その中に失敗処理の関心も混ざってしまっている
- 他の箇所でも
- エラーに対応するため
-
- 関数型で解決する例
-
def schedule( attendees: List[String], lengthHours: Int ): IO[Option[MeetingTime] = { for { existingMeetings <- scheduledMeetings(attendees) possibleMeeting = possibleMettings( scheduledMeetings, 8, 16, lengthHours ).headOption <- possibleMeeting match { case Some(meeting) => creatMeeting(attendees, meeting) case None => IO.unit } } yield possibleMeeting }
-
- IO型を使う
- エラー処理にEither型、存在有無にOption型を使うのと同じ
-
IO[A]
にはPure[A]
,Delay[A]
などのサブ型がある- 既知の値なら
IO.pure
コンストラクタを使うval existingInt: IO[Int] = IO.pure(6)
- 先行評価している
- 例外をスローする可能性がある非純粋関数(
getIntUnsafely()
)には、IO.delay
コンストラクタを使うval intFromUnsafePlace: IO[Int] = IO.delay(getIntUnsafely())
- 安全ではないコードの呼び出しという責務を、別の場所にある他のエンティティに委譲するために、遅延評価の力を利用している
-
IO[Int]
型の値は、Int型の値を提供する計算を表現しているにすぎない
- 既知の値なら
- 副作用のある外部の非純粋関数を、IO型の値を返す関数でラップする
-
def calendarEntries(name: String): IO[List[MeetingTime]] = { IO.delay(calendarEntriesApiCall(name)) }
- calendarEntriesApiCallが例外をどれだけスローしても、calendarEntriesは決して失敗しない
- 後から解釈(実行)できる値を返すから
- 副作用のある安全ではないコード(IOアクション)を実行する責務が委譲される
-
IO[A]
を実行するには、unsafeRunSync
という特別な関数を呼び出す-
val dieCast: IO[Int] = castTheDie() dieCast.unsafeRunSync()
- 非純粋なコードを実行する場所を別にすることで、それ以外の部分を純粋な世界のままにできる
-
- 副作用のあるIOアクションを実行することなく、そのIOアクションを表すIO型の値だけを使う
- 非純粋関数でサイコロの目の数を合計する例
castTheDieImpure() + castTheDieImpure()
- 一見簡単そうに見えるが、実際はサイコロがテーブルから落ちることもある(
throw new RuntimeException("Die fell off");
- try-catchを使うと上記のように複雑性が高まる
-
IO[Int]
は、「この値には副作用があり安全ではない可能性があるアクションだ」ということを表す - flatMapとfor内包構文で
IO[Int]
を取り扱う-
def castTheDieTwice(): IO[Int] = { for { firstCast <- castTheDie() secondCast <- castTheDie() } yield firstCast + secondCast() }
-
castTheDie() + castTheDie()
はコンパイルエラーになるが、for内包構文ではIO[Int]
をそのまま扱うのでエラーとならない
-
- 非純粋関数でサイコロの目の数を合計する例
-
- 関数型ラッパーを使うことで、値を返すことと、純粋でないIOアクションを実行することを分けることができる
- 責務を分離することができるようになる
-
値を返す
val scheduledMeetingsProgram = scheduleMeetings("Alice", "Bob") // IO[List[MeetingTime]]
-
IOアクションを実行する
scheduledMeetingsProgram.unsafeRunSync() // List(MeetingTime(8, 10), MeetingTime(11, 12), MeetingTime(9, 10))
-
-
unsafeRunSync()
を呼び出すのは別の場所となる- 関数コアの外側
- 責務を分離することができるようになる
- IO型を使うと、その値を使用している箇所はすべてIO型に変更する必要がある
- IOを使う場所はできるだけ少なくする
- ただ、IOは失敗する可能性のあるケースのタグとなる
- 失敗やエラーが起きる可能性が明示的になる
- IOの失敗はどうするか
- 失敗した場合にどうなるかを宣言しなければならない
- リカバリーするなど
- リカバリーメカニズムもIO型の値の中にエンコードできる
- OptionはNone、EitherはLeftの場合に何を使うべきか記述できるのと同じ
- IOにも
orElse
がある- 元の値が成功しなかった場合にのみ使われる、もう1つの値
- 遅延評価: IO.delay
- どこかで実際に必要になるまで先送りされる評価
-
IO.pureの後ろのorElseは実行されない
val program = IO.pure(2022).orElse( IO.delay(throw new Exception()) )
-
throw new Exception()
は決して実行されない
-
- 先行評価: IO.pure
- コードが定義された場所で直ちに実行(評価)される
-
等号の右辺に書かれたコードはすぐに実行される
val program = IO.pure(2022).orElse( IO.pure(throw new Exception()) )
- 等号の右辺に書かれたコードはすぐに実行されるため、
throw new Exception()
が実行され、例外をスローする
- 等号の右辺に書かれたコードはすぐに実行されるため、
- orElseとpureを使ってフォールバックを実装する
- IO.pureは常に成功するため、orElseチェーンの最後の呼び出しとして使うことができる
- これで、実行時に絶対に失敗しないIO型の値が得られる
- IO.delayにorElseを繋げる方法
IO.delay(castTheDie()).orElse(IO.pure(0))
- 関数に繋げる方法
-
リトライとともに関数にorElseを繋げる
calendarEntries(person1) .orElse(calendarEntries(person1)) .orElse(IO.pure(List.empty))
-
- いずれかが失敗した場合にorElseの値を返す方法
-
for内包構文でorElseを使う
(for { card <- IO.delay(drawAPointCard()) die1 <- IO.delay(castTheDie()) die2 <- IO.delay(castTheDie()) } yield card + die1 + die2).orElse(IO.pure(0))
-
- 失敗した場合にどうなるかを宣言しなければならない
- 関数型アーキテクチャは、関心事のもつれを少しずつ解いていく
- ビジネスロジックを純粋関数として抽出する
- それらの処理を関数型コアとしてひとまとめにする
- 純粋関数を個別にテストしてからメインプロセスで使う
- 関数型では入出力関数も非純粋関数として渡すようにする
-
外部入力としてconsoleGet、外部出力としてconsolePrintをIO.delayで引数として渡す
schedulingProgram( IO.delay (consoleGet()), meeting => IO.delay(consolePrint(meeting.toString)) ).unsafeRunSync()
- 関数を操作するschedulingProgramが本質的な関心事を表すのに対し、入出力の方法は付随的な関心事を表す
- 本質的な関心事を関数コアにする
- アプリケーションの何が本質的な関心事(ビジネスドメインに直接関係するもの)なのか判別するのは、エンジニアの責務
- リトライロジックは本質的なのか付随的なのか、フォールバック値を追加するとそれはビジネスロジックと言えないか
- どれが本質なのかの普遍的な答えはない
- 「ユーザーが、その言葉を聞いても、それがなんなのかわからないようなものは、すべて付随的と考えるべき」という視点もある
- 参考: システムの複雑さはどこから来るのか – Out of the tar pitを読む
-
- リトライについて
- リトライ回数などが渡される、構築可能なリトライ戦略が必要
def retry[A](action: IO[A], maxRetries: Int): IO[A]
- 使用方法:
retry(scheduledMeetings(person1, person2), 10).orElse(IO.pure(List.empty))
-
リトライ関数(rangeで回数をmapし、foldLeftで畳み込む)
def retry[A](action: IO[A], maxRetries: Int): IO[A] = { List .range(0, maxRetries) .map(_ => action) .foldLeft(action)((program, retryAction) => { program.orElse(retryAction) }) }
- リトライ回数などが渡される、構築可能なリトライ戦略が必要
- 重要なポイント
- FPでは、ほとんどの関数から非純粋性を取り除く
- FPでは、イミュータブルな値をやり取りするだけである
- FPでは、IOを扱う場合であっても、純粋関数を使ってイミュータブルな値を変換する
- 関数型コアから非純粋性が押し出されるため、テストとメンテナンスが容易になる
9: 値としてのストリーム
- 入力データを処理する際に、メモリに収まらないことに対処するために考え出されたのがストリーム
- 制御フローを設計するためのソフトウェアアーキテクチャの選択肢の1つとなっている
- ゼロ個から無限個の値を生成する
Stream
型 - 要件例: オンライン両替
- ユーザーはある通貨の特定の金額を別の通貨に両替することを要求できる
- 要求された両替が実行されるのは、その通貨ペアの為替レートが上昇傾向にある場合に限られる。(直近のn個のレートがそれぞれ1つ前のレートよりも高いとき)
- API呼び出し関数
exchangeTable
がある。この関数は、両替元の通貨からサポートされている他の通貨への、現在の為替レートテーブルだけを取得する
- 上昇傾向が見つかるまでAPIを呼び出し続ける必要がある
- API呼び出しの必要な回数はわからない
- 副作用のある外部の非純粋API
exchangeRatesTableApiCall
を呼び出す必要がある- 呼び出し失敗、タイムアウト、想定外の値が返ってくることなどがある
def exchangeRatesTableApiCall(currency: String): Map[String, BigDecimal]
- 関数型の設計アプローチ
- まずはプリミティブ値ではなく、newtypeを使うようにする
-
プリミティブ値をnewtypeにする
object model { opaque type Currency = String object Currency { def apply(name: String): Currency = name extension (currency: Currency) def name: String = currency } }
-
- 外部データを入力IOアクション(IO型の値を返す関数)として利用する
-
IOを返す関数として利用する
def exchangeTable(from: Currency): IO[Map[Currency, BigDecimal]]
- 内部のAPI呼び出しが
Currency
ではなくString
を返すため、変換する必要がある
-
- まずはプリミティブ値ではなく、newtypeを使うようにする
- イミュータブルマップ
- Scalaなどの関数型プログラミング言語では、Mapはイミュータブルである
-
Mapの使い方
// updatedを使用の際は、非存在のキーの場合はその値が追加される、また、既存キーの変更であっても元の値は変更されない(イミュータブル) val m1: Map[String, String] = Map("key" -> "value") // Map(key -> value) val m2: Map[String, String] = m1.updated("key2", "value2") // Map(key -> value, key2 -> value2) 値が追加される val m3: Map[String, String] = m2.updated("key2", "another2") // ap(key -> value, key2 -> another2) val m4: Map[String, String] = m3.removed("key") // Map(key2 -> another2) val valueFromM3: Option[String] = m3.get("key") // Some(value) 元の値は変更されていない val valueFromM4: Option[String] = m4.get("key") // None val usdRates = Map(Currency("EUR") -> BigDecimal(0.82)) usdRates.updated(Currency("EUR"), BigDecimal(0.83)) // Map(EUR -> 0.83) usdRates.removed(Currency("EUR")) // Map.empty usdRates.removed(Currency("JPY")) // Map(EUR -> 0.82) removed対象の値がない場合でも値が返る usdRates.get(Currency("EUR")) // Some(0.82) 元の値は変更されていない usdRates.get(Currency("JPY")) // None
-
- Scalaなどの関数型プログラミング言語では、Mapはイミュータブルである
- 上昇傾向を判定するためのIO呼び出し回数について
- アプローチ方法
- トップダウン設計のアプローチ
- 通常はこちらの方がうまくいく
- 決まった回数のIO呼び出しを行うリトライ戦略など
- ボトムアップ設計のアプローチ
- より小さく簡単な問題を解決することから始めて、少しずつ上昇していく
- トップダウン設計のアプローチ
- ボトムアップでの設計
- レートが上昇傾向にあるかチェックする
- トレンド分析関数が必要
def trending(rates: List[BigDecimal]): Boolean
- テーブルから通貨を1つ抽出する
- API呼び出しが3回成功すると3つのMap(
Map[Currency, BigDecimal]
)が作成されるdef lastRates(from: Currency, to: Currency): IO[List[BigDecimal]]
- 各マップから特定通貨を抽出する
usdExhcangeTables.map(extractSingleCurrencyRate(Currency("EUR")))
- API呼び出しが3回成功すると3つのMap(
- レートが上昇傾向にあるかチェックする
- アプローチ方法
- 再帰関数
- 外部APIを複数回呼び出したいが、何回呼び出せば十分かはわからないという問題
- これを解決するのが再帰(recursion)
- 別のプログラム(API呼び出しなど)が正確にn回実行されるようにし、すべての呼び出しの結果がn個の要素をもつListとして提供されるようにできる
-
trending
関数で上昇傾向レートが得られない場合に、else
でもう一度exchangeIfTrending
関数で自身を呼び出すことで再起させる - 外部の値の取得にIO型を使っているため、潜在的に無限となっているだけで遅延評価されるため、実際には無限の実行は発生しない
- 無限に実行するプログラムを表すIO型の値があるだけで、まだ誰もそれを実行していないから
- IO型の値には副作用のある演算がすべて格納され、それらの演算は
unsafeRunSync
を使った後でなければ実行されない
- IO型の値には副作用のある演算がすべて格納され、それらの演算は
- さらにベースケース(base case)として、上昇傾向があった場合にはIO.pureが呼び出される
- 無限に実行するプログラムを表すIO型の値があるだけで、まだ誰もそれを実行していないから
- なぜ無限の再帰呼び出しを使うのか
- 関心を分離できるから
- レートが上昇傾向のときに特定金額を両替したいという関心と、何回繰り返すべきか、ユーザーがどれだけ待てるかは別の関心
- 無限の再帰呼び出しを行う関数とすることで、それを何回繰り返すのか、どれくらい繰り返してタイムアウトするのかなどの別々の関心ごとを、それぞれ分けて実装することができるようになる
-
IO.timeout
関数でタイムアウトを実装できる - それぞれ独立した、互換性のあるブロックを扱うことができる
-
- 例: lastRatesの取得
- 要件
- 両替テーブルの取得に失敗したらリトライする(10回)
- 両替テーブルに対象の通貨がなかったら、再取得する
- 実際のコード
-
両替テーブルの取得に失敗したらリトライする(10回), 両替テーブルに対象の通貨がなかったら、再取得する
def currencyRate(from: Currency, to: Currency): IO[BigDecimal] = { for { table <- retry(exchangeTable(from), 10) rate <- extractSingleCurrencyRate(to)(table) match { case Some(value) => IO.pure(value) case None => currencyRate(from, to) } } yield rate }
-
- 要件
- 指定された通貨ペアの直近のn個のレートを取得する
- 実際のコード
-
指定された通貨ペアの直近のn個のレートを取得する
def lastRates(from: Currency, to: Currency, n: Int): IO[List[BigDecimal]] = { if (n < 1) { IO.pure(List.empty) } else { for { currencyRate <- currencyRate(from, to) remainingRates <- if (n == 1) IO.pure(List.empty) else lastRates(from, to, n - 1) } yield remainingRates.prepended(currencyRate) } }
-
- 要件
- 関心を分離できるから
- 関数型プログラムでは、無限、遅延、再帰は隣り合わせの関係にある
- 外部APIを複数回呼び出したいが、何回呼び出せば十分かはわからないという問題
- データストリームの導入
- 要件
- プログラムが無限に実行されないようにしたい
- ストリームベースのアプローチを使う
- 無限のストリームから、実際に必要な要素を取得して使用する
- ストリームベースのアプローチを使う
- スライディングウィンドウで分析したい(分割されたバッチ処理では連続した上昇傾向が無視されてしまうから)
- from通貨からIOアクションを使うストリームを作成し、そこからto通貨を抽出し、さらにNoneを取り除く関数を作成する(ratesプロデューサ)
-
ratesプロデューサとratesコンシューマを作成する
// ratesプロデューサ def rates(from: Currency, to: Currency): Stream[IO, BigDecimal] = { Stream .eval(exchangeTable(from)) .repeat .map(extractSingleCurrencyRate(to)) .unNone .orElse(rates(from, to)) } // ratesコンシューマ def exchangeIfTrending( amount: BigDecimal, from: Currency, to: Currency ): IO[BigDecimal] = { rates(from, to) .sliding(3) .map(_.toList) .filter(trending) .map(_.last) .take(1) .compile .lastOrError .map(_ * amount) }
- ミリ秒間位ではなく固定のペースで呼び出すようにしたい
-
rates
ストリームと、1秒ごとに実行されるticks
ストリームを結合して、固定のペースのストリームを生成する -
ratesストリームとticksストリームを結合する
val delay: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS) val ticks: Stream[IO, Unit] = Stream.fixedRate[IO](delay) def exchangeIfTrending( amount: BigDecimal, from: Currency, to: Currency ): IO[BigDecimal] = { rates(from, to) .zipLeft(ticks) .sliding(3) ... }
-
- プログラムが無限に実行されないようにしたい
- これらの不特定量のデータに対する要件には、切り離されたモジュールが必要となる
- データの取得を担当する関数
- タイムアウト、呼び出し間の遅延、リトライなどに対処する関数
- IOベースのAPI呼び出しによる遅延評価型の無限ストリームを使って解決する
- ストリーム処理とは、無限のデータストリームを表している値の変換を意味する
- プロデューサ/コンシューマパターン
- プロデューサ
- ソースデータを定義する
- 有限の場合と無限の場合がある
- JavaのSteam値またはpythonのジェネレータ
- 他のプロデューサの観点から定義される場合がある
- プロデューサーによって生成された値は中間演算や関数(map, filter)を通過する
- コンシューマ
- プロデューサによって生成されたデータを使う
- 最終的な値を作成する
- Javaのcollectまたはcount関数
- 通常は新しいストリームを返さない
- プロデューサ
- ストリーミングパラダイムは非常にスケーラブル
- プロデューサ/コンシューマパターン
- 命令型のストリームではダメなのか?
- 接続エラーをスローするAPI呼び出し関数を、100回呼び出すとほぼ確実に例外が発生する
- 要件
- 関数型のStream
- Stream型は値の遅延プロデューサを表す
- コンシューマが登場するまで何も実行されない
- IO型が
unsafeRunSync
で実行されるのと同じ
- Stream型はIO型に似ている
- IO型の値は副作用を持つ可能性があるプログラムを表す
- Stream型の値はストリーム処理のプログラムを表す
-
Stream[F, O]
には2つの型パラメータがある-
Stream[Pure, Int]
: 副作用をいっさい伴わずに整数を生成する、ストリームベースの計算を表す -
Stream[IO, String]
: Stringと、副作用を持つ可能性があるIO型の値を生成する、ストリームベースの計算を表す
-
- 関数型のストリームでのappendは遅延評価である
- なので、
append + 再帰
の組み合わせで無限のストリームを作成することに問題はまったくない - appendは渡されたものを先行評価しないため、そこでは何も起きない
-
無限のストリームを再帰を使って生成する or repeatで生成する
def numbers(): Stream[Pure, Int] = { Stream(1, 2, 3).append(numbers()) } val numbars = Stream(1, 2, 3).repeat
-
- なので、
- プリミティブ演算とコンビネータ
-
append
はプリミティブ -
repeat
はappend
に基づいて定義されているコンビネータ
-
- IOベースの値からなるストリーム
- 要件例: 6の目が出るまでサイコロをふる
- IO型の値を1つだけ提供するStreamである
dieCast
を作成し、それをeval
で評価する-
evalを使って評価する
val dieCast: Stream[IO, Int] = Stream.eval(castTheDie()) val oneDieCastProgram: IO[List[Int]] = dieCast.compile.toList // compileでIO型の値にコンパイルできる
-
- IO型の値を1つだけ提供するStreamである
- 要件例: 6の目が出るまでサイコロをふる
- 副作用のためだけに実行されるプログラムは、
IO[Unit]
型で行う- これで副作用を目的として実行されることがわかる
- サーバーのエンドポイント処理、ソケット接続など
- エラーはどう処理するか
- StreamにもorElseが定義されている
-
Streamを生成するIOアクションが失敗した場合に、orElseで対応する
def rates(from: Currency, to: Currency): Stream[IO, BigDecimal] = { Stream .eval(exchangeTable(from)) .repeat .map(extractSingleCurrencyRate(to)) .unNone .orElse(rates(from, to)) // ストリームで問題が発生した場合、再帰的にリカバリーする }
- ストリームを使うことで関心の分離ができる
- さまざまな理由(取得ペアに対象通貨が含まれていない・API呼び出しの失敗・処理のタイムアウト)で複数回APIを呼び出すことはストリーム内に関心がとじている
- ストリームのコンシューマから見ると、最初の3つ(take(3))の為替レートが取得できるだけでいい
- 内部で何回IOアクションが実行されたか、失敗したときにどうするかなどは知らなくていい
- 上昇傾向のチェックや意思決定などの関心事にフォーカスできる
- IO呼び出しの間で待機する
-
ticks
をストリームとzipして、結合されたストリームを生成する-
1秒おきのストリームとミリ秒単位のストリームを結合し、固定の遅延を表現する
val delay: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS) val ticks: Stream[IO, Unit] = Stream.fixedRate[IO](delay) val firstThreeRates: IO[List[(BigDecimal, Unit)]] = rates(Currency("USD"), Currency("EUR")) .zip(ticks).take(3).compile.toList
-
- 遅延させるためだけで値は必要ないので、
zipLeft
を使用する
-
- Stream型は値の遅延プロデューサを表す
- ストリームベースのアプローチの利点
- ストリームの定義が、使用する箇所から切り離されている
- 無限かもしれない値を、実際に必要な要素の数の定義と分離できる
- 本当に必要になるまで何も行われない
- すべての演算が遅延評価されるから
- 実装上の詳細ではなく、ビジネスドメインに集中できる
- 関心事がより分離される
- 合成可能性が高まる
- 非同期の境界のカプセル化ができる
- 異なるノードの多くのストリームを結合できる
- ノード間の境界を越えて結果を同期することへの関心をすべてカプセル化できる
- ストリームの定義が、使用する箇所から切り離されている
- 本章のまとめ
- ストリームが活用される場面
- UIプログラミングでよく使われる
- ユーザークリックのストリームを受け取って意思決定できるから
- 分散コンピューティングで使われる
- さまざまなノードとその間隔で、リアクティブストリームとして使用できるから
- ストリームアプローチはビッグデータを扱うときによく使われる
- UIプログラミングでよく使われる
- 複雑なフローを宣言型で設計する
-
map
,filter
,append
,eval
,take
,orElse
,sliding
,zip
,zipLeft
,repeat
,unNone
などのストリーム関数で設計した
-
- 再帰と遅延で意思決定を先送りできる
- 失敗から回復するため、必要なものが手に入るまで、再帰を使ってIO呼び出しを行った
- 時間に依存する機能を分離できる
- 1秒おきに生成されるUnit値のストリームとzipすることで、固定ペースで値を出力するストリームを生成できた(マルチスレッド環境)
- ストリームが活用される場面
- 重要なポイント
- FPでは、再帰を使って多くの問題を解く
- 遅延評価、再帰、潜在的な無限の実行には、多くの共通点がある
- IOプログラムとストリーミングプログラムの内部の仕組みは型が教えてくれる
- ストリームはアプリケーションでの関心の分離に役立つ
10: 並行プログラム
- マルチスレッドでは共有ミュータブル状態を扱いながら、デッドロックや競合状態を回避する必要がある
- 関数型プログラミングのアプローチは変わらず、イミュータブルな値と純粋関数
- 純粋関数が並行して評価されるだけ
- マルチスレッド環境でも、純粋関数が並行して評価されるだけで、それ以外は同じ
- 関数型プログラミングのアプローチは変わらず、イミュータブルな値と純粋関数
- 要件例: 都市ランキング
- 世界中の人々のチェックインからなるストリームを処理する必要がある(Stream[IO, City]型の値)
- チェックインがまだ処理中であっても、現在の上位3都市のランキングを取得できなければならない
- まずはnewtypeを作る
-
特定のCityの現在のチェックインカウンタを追跡する直積型
object model { opaque type City = String object City { def apply(name: String): City = name extension (city: City) def name: String = city } case class CityStats(city: City, checkIns: Int) }
-
- 逐次アプローチを使った場合
- 両方の要件を交互に実装するバッチ処理を作る必要がある
- n個のチェックインの処理...ランキングの更新...n個のチェックイン...
- これでリアルタイムに更新しているような錯覚を抱かせる
- チェックインを1つずつ処理した後、現在のランキングを生成する関数を実装する方法
- 最初のプロトタイプ実装例
-
最初のプロトタイプ
def topCities(cityCheckIns: Map[City, Int]): List[CityStats] = { cityCheckIns.toList .map(_ match { case (city, checkIns) => CityStats(city, checkIns) }) .sortBy(_.checkIns) .reverse .take(3) } def processCheckIns(checkIns: Stream[IO, City]): IO[Unit] = { checkIns .scan(Map.empty[City, Int])((cityCheckIns, city) => cityCheckIns .updatedWith(city)(_.map(_ + 1).orElse(Some(1))) ) .map(topCities) .foreach(IO.println) .compile.drain }
- この状態だとスケーラビリティがない
- 都市の数が増えるほどソートに時間がかかるようになる
- シングルスレッドなので1つの機能に時間がかるほど、どんどん遅くなる
-
chunkN
ストリームコンビネータで、バッチ処理を実装することで解決する- 600_000回(チェックインごとに1回)を6回(100_000チェックインごとに1回)にすることができる
.chunkN(100_000)
- バッチ処理のトレードオフ
- バッチサイズが大きくなればなるほど、ランキングの更新頻度が低下する
- コーナーケースがいくつか存在する
- 590_000件のチェックインがあるとき、そのあと10_000件を待たないとランキングが更新されない
- 時間ベースの制約を追加する必要がある
- 複雑さが増してしまう
- シングルスレッドに制限されている場合はこのアプローチが適切なことがある
-
- 両方の要件を交互に実装するバッチ処理を作る必要がある
- 並行処理のアプローチ
- マルチスレッドが使える場合、各機能に必要な計算時間を判断する必要はない
- 共有ミュータブル状態の問題にぶつかる
- checkInsマップにランキング機能がアクセスする必要があるから
- すべての実行スレッドが同じメモリアドレスにアクセスする必要があり、しかもこのアドレスは時間がたつと変化する
- どう対処するか?
- 命令型の並行処理
- 同じミュータブル変数を変更する2つのスレッドを作成すると、非決定論的な振る舞いになる可能性が高くなる
- モニターとロック
- 一度に1つのスレッドだけリソースを使わせるモニターと、内部でロックを使う
- アクターモデル
- アクターは状態をカプセル化し、状態を操作する唯一の方法を非同期メッセージの送受信とする
- スレッドセーフなデータ構造
- Javaの
ConcurrentHashMap
を使用する
- Javaの
- アトミック参照(atomic reference)を使用する
- compare-and-set(compare-and-swap: CAS)演算を使用するのに非常に実用的なメカニズム
- 内部でロックを使う必要がない(ロックフリー)
- compareAndSet + 命令型ループに代わって、関数型では
updateAndGet
がある -
updateAndGet
cityCheckIns.updateAndGet(oldCheckIns -> { var new CheckIns = new HashMap<>(oldCheckIns); newCheckIns.compute(cityName, (city, checkIns) -> checkIns != null ? checkIns + 1 : 1); return newCheckIns; })
- compareAndSet + 命令型ループに代わって、関数型では
- Refを使用する
-
Ref[IO, A]
- 関数型アトミック参照として、純粋関数とIO型の値だけを使って安全に格納することができる
- 同時にアクセス可能な非同期のミュータブルな参照
- 並行プリミティブのトレードオフとして、パフォーマンスへの影響はある
- どんな純粋関数が使えるのか
-
def update(f: A => A): IO[Unit]
: CAS演算として使える- IOが返るので
unsafeRunSync
するまでは実行されない
- IOが返るので
-
def of(a: A) IO[Ref[IO, A]]
: 内部に格納されているA型の値を取得または更新し、別のIOを取得できる -
def get: IO[A]
: 同時参照によって保持される値を取得できる
-
- RefのAPI例
-
RefのAPI例
val example: IO[Int] = for { counter <- Ref.of[IO, Int](0) _ <- counter.update(_ + 3) result <- counter.get } yield result example.unsafeRunSync()
-
-
- 命令型の並行処理
- 並行アプリケーションは小さな、副作用のある、逐次プログラムの集まりに過ぎない
- それらが並列に実行されるだけ
- 関数型プログラミングでは、マルチスレッドプログラムを逐次プロセスのイミュータブルなリストとしてモデル化できる
- そもそも依存していないのでバラバラに実行しても問題ない
-
parSequence
を使ってそれぞれ独自のスレッドで並列に実行する -
parSequenceを使って並列に実行する
val exampleConcurrent: IO[Int] = for { counter <- Ref.of[IO, Int](0) _ <- List(counter.update(_ + 2), counter.update(_ + 3), counter.update(_ + 4)).parSequence result <- counter.get } yield result exampleConcurrent.unsafeRunSync() // 9
- 3つのスレッドがすべて終了するのを待ってから値を返し、IOが1つでも失敗すればエラーを返す
- JSの
Promise.all
に似ている
- JSの
- IO型なので、他のIO型の値を使っていた場所であればどこでも使うことができる
- ファイバについて
- スレッドを軽くしたようなもので、OSレベルのスレッド処理とは直接関係がない
- ファイバは計算を表すオブジェクトにすぎないため、論理スレッドとも呼ばれる
- 余計な心配をする必要なく、何かを並列に実行することができる
- 並行性をモデル化する
- 値と関係性だけを使って関数型の方法でモデル化できる
- 同時アクセスが可能な参照をモデル化する
-
storedCheckIns
やstoredRanking
というRef
-
- 逐次的な並行プログラムをモデル化する
-
parSequence
を使ってIO型の値からなるリストを変換することで、ファイバを間接的に作成する
-
- 同時アクセスが可能な参照をモデル化する
- 値と関係性だけを使って関数型の方法でモデル化できる
- ファイバを使った並行プログラムから、ユーザーの希望にそってランキングを消費する方法を検討したい
- 非同期アクセスによって、最新のランキングをいつでも取得できるようにする
- そのためにスレッドを非同期で開始する必要がある
- 同期・非同期アクセスについて
- 同期アクセス
- プログラムを実行したら完了するまで待つ必要がある
- 結果を待つ間、呼び出し元のスレッドはブロックされる
- 非同期アクセス
- プログラムの終了を待たずに次のプログラムに進む
- 結果が必要な場合はコールバックを提供するか、結果にアクセスできる何らかのハンドルで結果を取得する手段が必要となる
- 同期アクセス
- 現在のランキングをいつでも何回でも取得でき、ファイバを停止することもできる関数
-
現在の値を取得でき、停止も可能な非同期プログラム
case class ProcessingCheckIns( currentRanking: IO[List[CityStats]], stop: IO[Unit] ) def processCheckIns(checkIns: Stream[IO, City]): IO[ProcessingCheckIns] = { for { storedCheckIns <- Ref.of[IO, Map[City, Int]](Map.empty) storedRanking <- Ref.of[IO, List[CityStats]](List.empty) rankingProgram = updateRanking(storedCheckIns, storedRanking) checkInsProgram = checkIns.evalMap(storeCheckIn(storedCheckIns)).compile.drain fiber <- List(rankingProgram, checkInsProgram).parSequence.start } yield ProcessingCheckIns(storedRanking.get, fiber.cancel) }
-
- 本章のまとめ
- 並行プログラムのフローを宣言型で設計する
-
parSequence
を使って、IO型のListをList型のIOに変換し、並行して実行する
-
- 軽量な仮想スレッドであるファイバを活用した
-
Ref[IO, A]
型によって各スレッドのデータを格納し、 アクセスできるようにした - イベントのストリームを非同期で処理した
-
IO.start
とFiberIO.cancel
を使用して非同期通信モデルを実装した
-
- 並行プログラムのフローを宣言型で設計する
- 重要なポイント
- FPでは、並行プログラムを作成するときにアトミック参照がよく使われる
- 同時アクセスが可能な共有ミュータブル状態をイミュータブルな値としてモデル化する
- FPでは、マルチスレッドプログラムを逐次プロセスのイミュータブルなリストとしてモデル化できる
- 関数型プログラムでは、OSレベルのスレッドではなくファイバを使う
11: 関数型プログラムを設計する
- 「まず動かし、次に正しく動かし、その上で高速に動かす」
- 要件例: ポップカルチャー旅行ガイド
- このアプリケーションはString型の値を1つだけ受け取る。これはユーザーが訪れたい観光名所の検索語である。
- 観光名所とその説明(説明がある場合)、地理的な位置で検索を行う必要がある。人口の多い場所を優先する。
- このアプリケーションは場所を使って次のことを行う
- この場所出身のアーティストを検索し、SNSのフォロワー数の順に並べる
- この場所を舞台にした映画を検索し、興行収入の順に並べる
- 上記の情報から「ポップカルチャー旅行ガイド」を作成し、ユーザーに返す。ガイドが他にもある場合は、「スコア」が最も高いものを返す。スコアは次のように計算する
- 説明は30ポイント
- アーティストまたは映画ごとに10ポイント(最大40ポイント)
- フォロワー10万人につき1ポイント(全アーティストを合わせて。最大15ポイント)
- 興行収入1000万ごとに1ポイント(すべての映画を合わせて。最大15ポイント)
- 将来的にはさらに多くのポップカルチャー(ビデオゲームなど)をサポートする予定
- 簡単な使い方
- 実行:
travelGuideProgram("Bridge of Sighs").unsafeRunSync()
- 出力:
「溜息橋はベネチアの運河にかかる橋。当地を訪れる前に、Talcoの曲を聴き、「スパイダーマン:ファー・フロム・ホーム」や「カジノ・ロワイヤル」など、ベネチアを舞台とする映画を観てはいかが。」
- 実行:
- 実装の流れ
- 純粋関数とイミュータブルな値だけを使って、ビジネスドメインの概念をコードでモデル化する
- ドメイン駆動設計のようなさまざまなアーキテクチャ設計手法は、関数型プログラミングと通じるものがある
- まずはイミュータブルな値(直積型・直和型・ADT)を使ってモデル化し、それを使った純粋関数を構築する
- ビジネスドメインをモデル化する
- 要件で主語を探す
- カスタムADT、組み込みADT(Optionsなど)、またはプリミティブとして主語をモデル化する
-
case class Attraction(name: String, ...)
やcase class Location(name: String ...)
など -
…その説明(説明がある場合)
は、description: Option[String]
- 要件の主語の属性を探す
-
…観光名所とその説明(説明がある場合)、地理的な位置…
case class Attraction(name: String, description: Option[String], location: Location)
-
将来的にはさらに多くのポップカルチャー(ビデオゲームなど)をサポートする…
-
属性を探してモデル化する
enum PopCultureSubject { case Artist(name: String, followers: Int) case Movie(name: String, boxOffice: Int) } case class TravelGuide( attraction: Attraction, subjects: List[PopCultureSubject] )
-
-
- 主語の使い方を調べる
- 主語が使われている箇所を直積型の識別フィールドとしてモデル化する
-
このアプリケーションは場所を使って次のことを行う…
-
場所をidとし、他のString型の値と混同しないようにnew typeを使う
case class Location(id: LocationId, name: String, population: Int) opaque type LocationId = String
-
- ビジネスドメインをモデル化したもの例
-
ビジネスドメインのモデル化したもの
object model { opaque type LocationId = String object LocationId { def apply(value: String): LocationId = value extension (a: LocationId) def value: String = a } case class Location(id: LocationId, name: String, population: Int) case class Attraction( name: String, description: Option[String], location: Location ) enum PopCultureSubject { case Artist(name: String, followers: Int) case Movie(name: String, boxOffice: Int) } case class TravelGuide( attraction: Attraction, subjects: List[PopCultureSubject] ) }
-
- 要件で主語を探す
- データアクセスをモデル化する
- データアクセスのアクションを探す
-
データアクセスのアクションを探す
// "…この場所出身のアーティストを検索…" def findArtistsFromLocation(locationId: LocationId, limit: Int): IO[List[Artist]] // "…この場所を舞台とした映画を検索…" def findMoviesAboutLocation(locationId: LocationId, limit: Int): IO[List[Movie]] // "このアプリケーションは特定の観光名所を検索する必要がある…" def findAttractions(locationId: LocationId, limit: Int): IO[List[Attraction]]
- BoF(bag of functions)としてまとめてもいい
-
関数をBoFとしてまとめる
// 直積型 case class DataAccess( findAttractions: (String, AttractionOrdering, Int) => IO[List[Attraction]], findArtistsFromLocation: (LocationId, Int) => IO[List[Artist]], findMoviesAboutLocation: (LocationId, Int) => IO[List[Movie]] ) // OOPのインターフェースに似ている形式 trait DataAccess { def findAttractions( name: String, ordering: AttractionOrdering, limit: Int ): IO[List[Attraction]] def findArtistsFromLocation( locationId: LocationId, limit: Int ): IO[List[Artist]] def findMoviesAboutLocation( locationId: LocationId, limit: Int ): IO[List[Movie]] }
-
DataAccess
として外側からアクセス手法を渡すことができる - 「共通のアイデンティティ」(
DataAccess
など)を見つけ出すのが難しいことが1つの欠点
-
-
- データアクセスの属性を探す
-
データアクセスの属性を探す
// "人口が多い場所を優先…" enum AttractionOrdering { case ByName case ByLocationPopulation }
-
- データアクセスのアクションを探す
- 純粋関数を使って、少なくとも1つの基本的なケースですべてを動作させることを目指す
-
基本的なケースを作成する
def travelGuide(data: DataAccess, attractionName: String): IO[Option[TravelGuide]] = { for { attractions <- data.findAttractions(attractionName, ByLocationPopulation, 1) guide <- attractions.headOption match { case None => IO.pure(None) case Some(attraction) => for { artists <- data.findArtistsFromLocation(attraction.location.id, 2) movies <- data.findMoviesAboutLocation(attraction.location.id, 2) } yield Some(TravelGuide(attraction, artists.appendedAll(movies))) } } yield guide }
-
- ビジネスドメインをモデル化する
- 純粋関数とイミュータブルな値だけを使って、ビジネスドメインの概念をコードでモデル化する
- 関数シグネチャを先に完成させて、内部の実装がないままビジネス面を実装していく方法は、OOPでいう「インターフェイスに対するコーディング」として認識される
- 多くのOOPの設計原則は、関数型プログラミングの純粋関数とイミュータブルな値でも利用できる
- 安全ではないコードはIO型の値としてラップして使う
- 例外スローを発生させる可能性があるコード
- 外部APIを使うときはIOを使用して統合する
- 命令型ライブラリとIOを使ってAPIを統合する
-
命令型ライブラリとIOを使う(サーバーに接続する)
val getConnection: IO[RDFConnection] = IO.delay( RDFConnectionRemote.create .destination("https://query.wikidata.org/") .queryEndpoint("sparql").build )
-
クエリを作成して実行する
def execQuery( getConnection: IO[RDFConnection], query: String ): IO[List[QuerySolution]] = { getConnection.flatMap(c => IO.delay( asScala(c.query(QueryFactory.create(query)).execSelect()).toList ) ) } val orderBy = ordering match { case ByName => "?attractionLabel" case ByLocationPopulation => "DESC(?population)" } val query = s"""... SELECT DISTINCT ?attraction... ORDER BY $orderBy LIMIT $limit"""
-
- 命令型ライブラリとIOを使ってAPIを統合する
- 責務の数が多すぎたり、フローが複雑すぎたり、しかもそれがその関数の主要な責務とは直接関係がない場合
- 付随的な関心事である可能性が高い
- もしそうなら、最善策は新しいパラメータを導入して、その関心事を関数のユーザーに丸投げすること
- カリー化して制御を反転する
- OOPでいうDIPとなる?
-
付随的関心事をカリー化を使って提供する
def findAttractions(connection: RDFConnection)( name: String, ordering: AttractionOrdering, limit: Int ): IO[List[Attraction]]
- リソースをきちんと取り扱って正しく動かす
-
解放可能なリソースを値としてモデル化する
def execQuery( connection: RDFConnection )(query: String): IO[List[QuerySolution]] = { val executionResource: Resource[IO, QueryExecution] = Resource.make(createExecution(connection, query))(closeExecution) executionResource.use(execution => IO.blocking(asScala(execution.execSelect())).toList ) }
-
- マルチスレッドを使って高速に動かす
-
travelGuideをマルチスレッドを使って高速に動かす
def travelGuide( data: DataAccess, attractionName: String ): IO[Option[TravelGuide]] = { for { attractions <- data.findAttractions(attractionName, ByLocationPopulation, 3) guides <- attractions .map(attraction => List( data.findArtistsFromLocation(attraction.location.id, 2), data.findMoviesAboutLocation(attraction.location.id, 2) ).parSequence .map(_.flatten) .map(popCultureSubjects => TravelGuide(attraction, popCultureSubjects) ) ) .parSequence } yield guides.sortBy(guideScore).reverse.headOption }
-
- キャッシュを使って高速に動かす
-
キャッシュを使って高速に動かす
def cachedExecQuery(connection: RDFConnection, cache: Ref[IO, Map[String, List[QuerySolution]]])( query: String ): IO(List(QuerySolution)) = { for { cachedQueries <- cache.get solutions <- cachedQueries.get(query) match { case Some(cachedSolutions) => IO.pure(cachedSolutions) case None => for { realSolutions <- execQuery(connection)(query) _ <- cache.update(_.updated(query, realSolutions)) } yield realSolutions } } yield solutions }
-
- 本章のまとめ
- 関数型コアの設計概念を使って、関数型コアを純粋関数とイミュータブルな値のみで構成した
- 要件をデータモデルに変換し、まずは動かし、正しく動かし、高速に動かした
- IOを使って現実のデータソースAPIと統合した
- データアクセス層を
DataAccess
という3つの純粋関数のBoFとしてモデル化した
- データアクセス層を
- Resource型を使ってリソースのリークを防いだ
- クエリの結果をキャッシュして実行を高速化した
- 重要なポイント
- 多くのOOPの設計原則は関数型プログラミングの純粋関数とイミュータブルな値でも利用できる
- IOを使うと、定評のある命令型クライアントライブラリを関数型プログラムで利用できる
- 新しいパラメータを要求することで関心事を外注するはFPにおいて一般的な設計手法である
- 解放可能なリソースを値としてモデル化する
12:関数型プログラムをテストする
- テストで確認できること
- プログラムが要件どおりに動作すること
- 以前に発見したバグがないこと
- 外部API、サービス、またはデータベースと適切に統合すること
- ドキュメントとしてアプリケーションを文書化できる
- アプリケーションがイミュータブルな値と純粋関数でできていると、テストの作成がサクサク進む
- サンプルを提供することによるテスト
-
サンプルを提供することによるテスト
test("score of a guide with a description, 0 artists, and 2 popular movies should be 65") { val guide = TravelGuide( Attraction( "Yellowstone National Park", Some("first national park in the world"), Location(LocationId("Q1214"), "Wyoming", 586107) ), List(Movie("The Hateful Eight", 155760117), Movie("Heaven's Gate", 3484331)) ) // 30 (description) + 0 (0 artists) + 20 (2 movies) + 15 (159 million box office) assert(guideScore(guide) == 65) }
- コーナーケースを見つけることが難しい
-
- プロパティを生成する
- プロパティとは、関数の望ましい振る舞いに関する、より一般的な説明のこと
- 特定のサンプルではない、純粋関数のプロパティベースのテストを追加する
- 旅行ガイドにのスコアは観光名所の名前と説明の文字列に依存すべきではない
- アーティストと映画はあるが、説明がない場合、旅行ガイドのスコアは常に20〜50になるはずだ
- 説明と大コケした映画が何本かある場合、旅行ガイドのスコアは30〜70になるはずだ
- プロパティベースのテストは関数をより批判的に捉えるのに役立つ
- 実装者であるというバイアスを減らせる
- サンプルベースのテストがいくつか成功している状態で、関数が正しく動作しないと想定するのは、人間にとって通常は難しい作業
- 整数オーバーフローなどの気づきにくいバグを見つけられる
- ScalaのIntの範囲は
-2147483648
〜2147483648
- プロパティとしてランダム値を生成すると発見できる
- ScalaのIntの範囲は
- テスト実装例
-
プロパティベースのテスト例(1)
test("guide score should not depend on its attraction's name and description strings") { forAll((name: String, description: String) => { val guide = TravelGuide( Attraction( name, // introduce: empty strings and shorter/longer sizes with different characters Some(description), Location(LocationId("Q1214"), "Wyoming", 586107) ), List(Movie("The Hateful Eight", 155760117), Movie("Heaven's Gate", 3484331)) ) // 30 (description) + 0 (0 artists) + 20 (2 movies) + 15 (159 million box office) assert(guideScore(guide) == 65) }) }
- ヘルパー関数
forAll
を使うことで、テストフレームワークによって複数回実行される- サンプルの生成はコンピュータに任せられる
- forAllがさまざまなパラメータを自動的に生成して関数を呼び出してくれる
- カスタムジェネレータも使える
- 非負の整数などを生成できる
-
カスタムジェネレータ
val nonNegativeInt: Gen[Int] = Gen.chooseNum(0, Int.MaxValue) val randomArtist: Gen[Artist] = for { name <- Gen.identifier // introduce Gen.identifier followers <- nonNegativeInt } yield Artist(name, followers)
-
- 小さなジェレータを組み合わせて複雑なシナリオをテストする
-
小さなジェレータを組み合わせて複雑なシナリオをテストする
val randomArtists: Gen[List[Artist]] = for { numberOfArtists <- Gen.chooseNum(0, 100) artists <- GenlistOfN(numberOfArtists, randomArtist) } yield artists
-
- 非負の整数などを生成できる
-
- TDDで実装する
- 実装後にテストを追加すると、実装前にテストを追加するよりもリスクが高くなる
- 正しいものをテストしているかどうかを常に二重にチェックする必要があるから
- プロパティベースのテストでは組み合わせ数が非常に多いため、この問題がさらに顕著になる
- 実装後にテストを追加すると、実装前にテストを追加するよりもリスクが高くなる
- 困ったらイミュータブルな値と純粋関数を作成するようにする
- 関数型のテストはすべて単なる単体テストである
- 統合テストとE2Eテストについては命令型の方法でカバーする必要がある
- だが、現実では関数型でアプローチすれば機能やコードの大部分をカバーできる
- 副作用のある要件をテストする
- 外部APIやデータベースなど
- 外部サービスとの統合をテストする
- リクエスト・レスポンスの解析
- APIの制限・パフォーマンス
- 外部データとデータ表現方法の整合性を保って変換すること
- 2種類のテストが必要
- 外部サービスと正しく統合されているかIOアクションをテストする
- サービス統合テスト
- リクエストを正しくフォーマットし、実際のサービスからレスポンスを正しく取得できるかどうかなど
-
DataAccess
の3つの関数が、本物の外部APIのエンドポイントと正しく統合することを証明するだけでよい- それらの関数の使われ方は関心外となる
- 外部APIモックの立ち上げなどは
Resource
を使用する
- サービス統合テスト
- 使う側で正しく使われているかテストする
- サービスデータ使用法テスト
- サービスデータとビジネスデータの間での変換方法など
- given-when-thenテンプレートでコメントを書く:
given:〇〇だとすれば, when: 〇〇しようとしたときに, then: 〇〇が返される
-
given-when-thenテンプレート
test("data access layer should fetch attractions from a real SPARQL server") { // given: アメリカの国立公園が含まれた実際の外部データソースがあるとすれば // when: それを使って「国立公園」という名前の観光名所を5つ検索したとき // then: 5つの観光名所が名前の順に正しく並んだリストが返される }
-
-
given
セクションでテストで使用するデータを揃えて、when
セクションでスタブ化した純粋関数を使う
- サービスデータ使用法テスト
- 外部サービスと正しく統合されているかIOアクションをテストする
- mockやstubを使用する必要がある場合
- そもそも関心事のもつれがあるというシグナルとなる
-
DataAccess
のBoFとして引数によってアクセス方法を渡すように変更できるなど - 全体的な設計が改善される
-
- そもそも関心事のもつれがあるというシグナルとなる
- テスト駆動開発(TDD)を利用する
- 要件例: 適切な旅行ガイド(スコアが55を超えるもの)がない場合は、その過程で作成された「不適切なガイド」と生成されたエラーメッセージをすべて返すようにしたい
- 一部のAPIアクセスが失敗してもIOプログラム全体が失敗する
-
Either
を使用して多くのシナリオに対応する -
attemptとEitherを使用して例外スローに対応する
def tarvelGuideForAttraction( dataAccess: DataAccess, attraction: Attraction ): IO[TravelGuide] = { List( dataAccess.findArtistsFromLocation(attraction.location.id, 2), dataAccess.findMoviesAboutLocation(attraction.location.id, 2) ).parSequence.map(_.flatten).map(subjects => TravelGuide(attraction, subjects)) } def findGoodGuide( errorsOrGuides: List[Either[Throwable, TravelGuide]], ): Either[SearchReport, TravelGuide] = { val guides: List[TravelGuide] = errorsOrGuides.collect(_ match { case Right(travelGuide) => travelGuide }) val errors: List[String] = errorsOrGuides.collect(_ match { case Left(exception) => exception.getMessage }) guides.sortBy(guideScore).reverse.headOption match { case Some(bestGuide) => if (guideScore(bestGuide) > 55) Right(bestGuide) else Left(SearchReport(guides, errors)) case None => Left(SearchReport(List.empty, errors)) } } def travelGuide( dataAccess: DataAccess, attractionName: String ): IO[Either[SearchReport, TravelGuide]] = { dataAccess .findAttractions(attractionName, ByLocationPopulation, 3) .attempt .flatMap(_ match { case Left(exception) => IO.pure(Left(SearchReport(List.empty, List(exception.getMessage)))) case Right(attractions) => attractions .map(attraction => tarvelGuideForAttraction(dataAccess, attraction)) .map(_.attempt) .parSequence .map(findGoodGuide) }) }
-
- 一部のAPIアクセスが失敗してもIOプログラム全体が失敗する
- 要件例: 適切な旅行ガイド(スコアが55を超えるもの)がない場合は、その過程で作成された「不適切なガイド」と生成されたエラーメッセージをすべて返すようにしたい
- 本章のまとめ
- サンプルを提供することで純粋関数をテストする
- プロパティを提供することで純粋関数をテストする
- モックライブラリを使わずに副作用をテストする
- 外部データを使う場合はIO型の値をスタブ化する
-
DataAccess
関数をハードコーディングして渡す - Resource型の値を使って、サーバーインスタンスが適切に解放されるようにする
- テスト駆動型で新機能を開発する
- 重要なポイント
- FPのテストは、関数を呼び出し、その出力をアサートするだけである
- プロパティブースのテストは関数をより批判的に捉えるのに役立つ
- FPでは、ランダムなテスト値の生成でさえ、1つのイミュータブルな値で表すことができる
- モック化とスタブ化では、関数に値を渡すだけである
Discussion