Functional Programming Strategies 日本語版In Scala with Cats

Noel Welsh 著

こほ 訳

June 2024 版

Inner Product

序文

二十年ほど前、私はイギリスで初めての仕事に就いた。職場へは電車で通勤する必要があったため、一日に一時間ほど邪魔の入らない読書の時間を得ることができた。初めて『計算機プログラムの構造と解釈』のことを耳にしたのは、ちょうどその頃である。この本は表紙の絵柄から「魔術師本(Wizard Book)」とも呼ばれ、開発者たちのあいだで特別な敬意をもって語られていた。学校を卒業したばかりの、優れた開発者を目指していた自分にとって、それはまさに最適な一冊のように思われた。私はその本を購入し、通勤のあいだに読み進めた。ほとんどの演習問題は頭の中で解いた。『計算機プログラムの構造と解釈』は当時すでに古典であり、そのプログラミングスタイルは時代遅れだったが、その核心となる概念は時代を超えるものだった。それはまさに衝撃的で、自分の進む道を決定づけるきっかけとなった。私はその道をいまでも歩み続けている。

その道程におけるもうひとつの重要なできごとは、十年ほど前に Dave とともに『Scala with Cats』の執筆を始めたことである。同書では Cats ライブラリに含まれる主要な型クラスとそれらを用いたソフトウェア構築について解説しようとした。私たちがともに書き上げたこの本には誇りを感じている。だが、時を経て経験を重ねるうちに、型クラスは関数型プログラミングによるソフトウェア構築というパズルの一片にすぎないことが明らかになった。関数型プログラミングが提供するすべてのツールを活用してソフトウェアを効果的に構築する方法を示すには、対象範囲をもっと広げなければならない。とはいえ、一冊の本を書くのは大変な作業であり、私たちが他のプロジェクトに忙殺されていたこともあり、『Scala with Cats』は何年ものあいだほとんど手を加えられないままだった。

2020年ごろ、もう一度『Scala with Cats』に取り組みたいという気持ちが私の中に湧いてきた。当初の計画は、Scala3 に対応させるための単なる改訂にすぎなかった。Dave は他の仕事で忙しかったため、私は単独で執筆を進めることにした。だが、執筆を進めるうちに、前著に欠けていた話題を盛り込みたいと思っている自分に気づいた。『Scala with Cats』が良い本だったとすれば、今回は素晴らしい本を目指したかった。私がソフトウェア構築について学んできたことのほぼすべてを詰め込んだ一冊にしたかった。その内容にとって『Scala with Cats』というタイトルはもはやふさわしくなかったので、内容に見合った新しい名前を採用した。その『Functional Programming Strategies in Scala with Cats』こそ、いまあなたが読んでいるこの本である。この本があなたの助けとなることを願っている。そして、かつて私が『計算機プログラムの構造と解釈』に心を打たれたように、若い開発者の心を動かす本になれば幸いである。

『Scala with Cats』版 序文

本書の目的はふたつある。第一に、モナド、ファンクター、およびその他の関数型プログラミングのパターンを、プログラム設計の構造化手法として紹介すること。第二に、それらの概念が Cats においてどのように実装されているかを知ってもらうことである。

関数型プログラミングにおけるモナドやそれに関連する概念は、オブジェクト指向でいうところのデザインパターンに相当する。いずれも、アーキテクチャ上の構成要素として、コードの中に繰り返し登場する。ただし、オブジェクト指向のパターンとは主に次の二点において異なる。

この一般性の高さゆえに、これらの概念は理解しづらいものになっている。抽象概念は誰にとっても難しい。しかし、モナドなどの概念がこれほど多様な場面に応用できるのは、この一般性のおかげでもある。

本書では、これらの概念がどのように機能しどこで使えるのかというメンタルモデルを構築できるよう手助けしたい。そのためにこれらの概念をさまざまな角度から見ていく。具体的には、発展的なケーススタディやいくつもの小さな例、簡潔な図式表記、そしてもちろん数学的な定義も用意している。どれかひとつでも読者のみなさんの理解の助けになるものがあれば幸いである。

では、始めよう。

バージョンについて

本書は Scala 3.3.4 および Cats 2.10.0 を対象として書かれている。 以下に、必要な依存関係と設定を含んだ最小限の build.sbt を示す1

scalaVersion := "3.3.4"

libraryDependencies +=
  "org.typelevel" %% "cats-core" % "2.10.0"

scalacOptions ++= Seq(
  "-Xfatal-warnings"
)

テンプレートプロジェクト

準備の手間を省けるよう Giter8 テンプレートを用意している。以下のコマンドでテンプレートをクローンできる。

$ sbt new scalawithcats/cats-seed.g8

このコマンドにより、依存ライブラリとして Cats を含んだサンドボックスプロジェクトが生成される。生成された README.md を参照すれば、サンプルコードの実行方法や、インタラクティブな Scala コンソールの起動手順を確認できる。

この cats-seed テンプレートは最小限の構成になっている。もっと多くのライブラリやツールを含んだ構成から始めたい場合は、Typelevel の sbt-catalysts テンプレートを利用するとよいだろう。

$ sbt new typelevel/sbt-catalysts.g8

このコマンドにより、単体テストやドキュメントのテンプレートに加えて、複数の依存ライブラリやコンパイラプラグインを備えたプロジェクトが生成される。詳細については catalysts および sbt-catalysts のプロジェクトページを参照してほしい。

本書の表記

本書には技術的な情報やプログラムコードが多数含まれている。文章構成を明確にし、重要な概念を強調するために、以下のような表記規約を用いる。

表記規約

新たに導入される用語や語句はイタリック体で示す。初出以降は通常フォントで表記される。

プログラムコード中の用語、ファイル名、ファイルの内容は、等幅フォント(monospace font) で表記する。単数形と複数形の区別はしていない点に注意してほしい。たとえば java.lang.String を指して StringStrings と表記することがある。

外部リソースへの参照はハイパーリンクによって示される。API ドキュメントへの参照は、scala.Option のようにハイパーリンクと等幅フォントを組み合わせて表記する。

ソースコード

ソースコードのブロックは次のように記述される。可能な場合はシンタックスハイライトが適用される。

object MyApp extends App {
  println("Hello world!") // Print a fine message to the user!
}

ほとんどのコードは mdoc を通じて処理され、コンパイル可能であることが保証されている。 mdoc は内部的に Scala コンソールを使用するため、ときには出力をコメント形式で示すこともある。

"Hello Cats!".toUpperCase
// res2: String = "HELLO CATS!"

強調枠

特定の内容を際立たせるために、次の二種類の強調枠(callout box)を使用する。

ティップス枠は、簡単な要約やコツ、ベストプラクティスを示す。

補足枠は、例外的なケースや基盤となるしくみに関する追加情報を提供する。初読では読み飛ばしても構わない。必要になったときに戻って参照してほしい。

ライセンス

本書は CC BY-SA 4.0 の下でライセンスされている。ライセンスの全文は http://creativecommons.org/licenses/by-sa/4.0/ にて閲覧できる2

本書の一部は、Dave Pereira-Gurnell および Noel Welsh による『Scala with Cats』を元にしている。同書は CC BY-SA 3.0 の下でライセンスされている。

1 関数型プログラミング戦略

これは、関数型プログラミングの流儀でコードを書くための戦略について、Scala を通して捉え、解説した本である。もし、Scala についてはほぼ理解しているものの、この言語を効果的に用いる方法に何か不足を感じているのであれば、この本はあなたに最適だろう。また、Scala についてあまり知らない場合でも、関数型プログラミングの学習の一環として Scala を学ぶ心構えができているなら、そんなあなたにもこの本は適している。この本では、モナドやモノイドといった一般的な関数型プログラミングの抽象概念を扱うが、それ以上に、関数型プログラマらしい思考方法について教えることを目指している。思考の結果としてのコードだけでなく、思考過程について記述しており、特に私がメタ認知的プログラミング戦略と呼ぶものに焦点を当てている。

ほとんどのプログラマは、自分がコードを発想し記述するプロセスを説明せよと言われても戸惑うのではないだろうか。一部の人は「テスト駆動開発」や「ペアプログラミング」を挙げるかもしれないが、一般的なプログラマからそれ以上の説明を期待するのは難しいだろう。それらのテクニックはどちらも90年代後半に登場したエクストリームプログラミングに由来している。その後、この分野に新たな知見が積み重ねられていることを期待したが、現実はそうではなかったようだ。とはいえ、これは開発者の責任というより、彼らの多くが明示的な手法を教えられていないことに原因がある。IT業界は確かにアジャイルやカンバンボードなどの形でプロセスについて語ることが好きだし、近年ではプログラミング教育の対象を拡げるために大変な努力が費やされている。しかし、実際のプログラミング、つまりシステム開発の要であるコードを生み出す部分は、いまだに魔法のように扱われることが多い。

関数型プログラマは単純なアイデアを言い表すのに難解な言葉を使いたがる。だから、私が「メタ認知的プログラミング戦略」に惹かれるのも当然だろう。このフレーズの意味を解き明かしてみよう。「メタ認知」とは「考えることについて考える」という意味である。多くの研究が、学習におけるメタ認知の有益性と、それが専門性を養うための重要な要素であることを示している。ただし、メタ認知を「自分の考えについて考える」というひとつの事柄として大雑把に捉えるだけでは不十分である。解像度を上げれば、メタ認知されるのはさまざまな戦略の集まりであり、一般的なものもあれば、特定の分野に特化したものもある。ここから「メタ認知的プログラミング戦略」という概念が生まれる。これは、熟練したプログラマが使用するさまざまな思考戦略に、明確に名前と説明をつけるということである。

メタ認知的プログラミング戦略は、初心者にも熟練者にも有益だと私は考えている。初心者に対しては、プログラミングをより体系的で再現可能なプロセスにすることができる。ほとんどの場合、コードを生み出すのに魔法は不要となり、明確に定義された手順を適用すればよくなる。その利点はそのまま熟練者にも当てはまる。少なくとも私の経験ではそうだった(私は専門家を名乗るに十分な期間プログラムを書いてきたと思っている)。明確なプロセスがあれば、毎日それを同じ方法で実行すればよい。コードの読み書きはシンプルになり、脳のリソースをもっと重要な問題に集中させることが可能となる。ある意味、これは製造業、特に「トヨタ方式」がもたらしたプロセスや標準化の利点をプログラミングにもちこもうとする試みだと言える。トヨタのプロセスでは、作業者が自分の仕事の進め方を考え、それをどう改善できるか思考することが求められる。これは実質的に、組み立てラインにおけるメタ認知である。これは、作業者が日々の業務に全神経を集中させる必要がなく、思考に余裕があってはじめて可能となる。トヨタが先駆けた自動車製造における生産性と品質の劇的な向上は、このアプローチの有効性を示している。ソフトウェア開発は自動車製造ほど一様ではないが、それでも同様の恩恵が期待できるだろう。特に、現在の業界がまだ未熟な状態であることを考えればなおさらである。

次に問題となるのは、どのようなメタ認知的戦略が利用可能であるか、である。その答えを考えるのに関数型プログラミングが特に適していると私は考えている。関数型プログラミング研究の主要なテーマは、役立つコード構造を発見し、名前を付けることである。有用な抽象概念が発見され普及すれば、プログラマが何か問題に直面したときに、その抽象概念でその問題を解決することができるかどうか検討可能になる。これは90年代にデザインパターンのコミュニティが行ったことと本質的には同じだが、重要な違いがある。それは、関数型プログラミングの学術的コミュニティが形式的なモデルを非常に重視しているため、関数型プログラミングの構成要素はデザインパターンが欠いている精密さをもっていることである。とはいえ、プログラミングのプロセスは、単に完成したコード構造の分類にとどまらず、コードがどのように発想されてくるかという現実的な過程を含む。コードは通常、完全な形でキーボードから生まれるわけではなく、反復的な精緻化の中で構造を見出される。この点に関して関数型プログラミングの学術コミュニティはあまり語っていないが、「型駆動開発」などの技法に関する強い実践的な知恵がある。

私は、プログラミングおよびプログラミング教育を行う中で、過去10年ほどにわたり幅広く戦略を集めてきた。それらの中には他者から学んだものもあるし、自分自身で発見したものもある。たとえば、『How to Design Programs』やその多くの派生書から大きな影響を受けている。だが、突き詰めると、ここで紹介する内容に真新しいものはない。私の貢献は、これらの戦略を一か所にまとめて提示することにある。

1.1 コードを考えるための三つのレベル

それでは、プログラミングについて考えることについて考えていこう。コードについて考えるための三つの異なるレベルを記述したモデルを用いる。それらのレベルとは、上から順にパラダイム(paradigm)、理論(theory)、そして技法(craft)である。各レベルは、それより下位のレベルに対して指針を提供してくれる。

パラダイムレベルとは、オブジェクト指向や関数型プログラミングといったプログラミングパラダイムのことを指す。これらの用語には馴染みのある方も多いと思うが、プログラミングパラダイムとは一体何だろうか。私にとって、プログラミングパラダイムの核心は、よいコードとはどういうものかを緩やかに定める一連の原則である。また、「これらの原則に従ったコードは、そうでないコードよりも優れている」という暗黙の主張もパラダイムには含まれている。関数型プログラミングにおける原則は合成(composition)と推論(reasoning)であると私は考えている。このふたつの原則については改めて説明する。一方、オブジェクト指向プログラマは、たとえば SOLID 原則をコーディングにおける意思決定の指針として挙げるかもしれない。

パラダイムは、異なる実装戦略の中からいずれかを選ぶための基準を示してくれるという点で重要である。プログラミングの課題には多くの解決策が存在するが、どのアプローチを取るべきかを、パラダイムによって示される原則にもとづいて判断することができる。たとえば、関数型プログラマであれば、特定の実装についてどれだけ簡単に理解し実行結果を予測できるか、それがどれだけ合成可能であるかを検討するだろう。パラダイムがないというのは、そういった選択を行うための基準がないということである。

理論レベルでは、パラダイムのもつ概略的な原則が具体的で明確に定義されたテクニックに翻訳される。それらのテクニックは、パラダイム内の多くの言語に適用できるが、この段階でもまだコードレベルの具体性はもたない。オブジェクト指向の世界では、デザインパターンがその一例であり、関数型プログラミングにおいては、代数的データ型がその一例である。多くの関数型プログラミング言語、たとえば Haskell や O’Caml は代数的データ型をサポートしているし、マルチパラダイム言語である Rust、Scala、Swift なども同様にサポートしている。

プログラミング戦略の大部分は理論レベルにおいて見出される。

技法レベルでは、具体的なコードや、それに関連する言語固有のニュアンスを取り扱う。Scala における例として、代数的データ型は Scala2 では sealed traitfinal case class を使って実装され、Scala3 では enum で実装される。このレベルには、Scala でコンパニオンオブジェクトにコンストラクタを配置するなど、上位レベルには存在しなかった、慣用的なコードを書くための多くの考慮事項が存在する。

次節では関数型プログラミングのパラダイムについて説明し、本書の残りの部分では主に理論と技法に焦点を当てる。理論は特定の言語に依存しないが、技法は Scala に特化したものとなる。関数型プログラミングのパラダイムへと進む前に、ふたつの点を強調しておきたい。

  1. パラダイムは社会的構築物であり、時間とともに変化する。現在のオブジェクト指向プログラミングは、元々 Simula や Smalltalk で使用されていた流儀とは異なるし、今日の関数型プログラミングも、初期の LISP のコードとは大きく異なる。

  2. この三層構造は、あくまで思考のためのツールに過ぎない。現実の世界はもっと複雑である。

1.2 関数型プログラミング

この本は、関数型プログラミングの技法と実践について書かれている。ここで、関数型プログラミングとは何か、そして関数型の流儀でコードを書くとはどういうことか、という疑問が当然に生じる。関数型プログラミングを、ファーストクラス関数のような言語機能の集合として捉えたり、不変データと純粋関数を使うプログラミングスタイルとして定義したりするのは一般的で(純粋関数とは、同じ入力に対して常に同じ出力を返す関数のこと)、私も当初はそのように考えていた。だが、今では、関数型プログラミングの核心は局所推論(local reasoning)と合成(composition)であると考えている。言語機能やプログラミングスタイルは、これらの目標を達成するための手段に過ぎない。次に、局所推論と合成の意味と価値について説明しよう。

1.2.1 関数型プログラミングとは何か

関数型プログラミングはソフトウェア品質に関する仮説であると私は考えている。実行する前に理解できるソフトウェア、そして小さく再利用可能なコンポーネントで構築されたソフトウェアの方が、書くことも保守することも容易だという考え方である。前者の特性は「局所推論」として知られており、後者は「合成」と呼ばれている。

局所推論とは、コードの一部を他から切り離して理解できるということを意味する。たとえば、1 + 1 という式は、天気やデータベースの状態、現在の Kubernetes クラスタの状況に関係なく、その意味を理解可能である。外部要因が式の意味を変えることはない。この例は単純ですこし滑稽だが、要点をよく表している。関数型プログラミングの目標のひとつは、この特性をコード全体に拡げることである。

局所推論について理解しようとするなら、それが何でないかを考えることが役立つかもしれない。可変状態を共有していると局所推論はできなくなる。なぜなら、共有された状態に依存すると、そのコードの動作を他のコードが知らないうちに変えてしまう可能性があるからである。敷衍すると、多くのウェブフレームワークやグラフィックスライブラリに見られるような、グローバルな可変の設定オブジェクトも使うことができない。使えば、任意のコードがその設定を変更できてしまう。また、メタプログラミングも慎重に管理する必要がある。モンキーパッチも他のコードが明示的でない方法でコードを変更できてしまうので、避けるべきである。このように、局所推論が可能となるようコードを適応させるには、かなり大きな変更が必要になることがある。しかし、関数型プログラミングを取り入れた言語で作業するのであれば、このプログラミングスタイルが基本となる。

合成とは、小さなものを組み合わせて大きなものを作ることを指す。たとえば、数は合成可能である。どんな数でも、1を足せば新しい数が得られる。レゴもまた合成可能である。ブロック同士をくっつけることで組み立てることができる。ここで我々が指している合成という概念では、組み合わせる元となる要素が、合成をしたあとも変化しないことが求められる。たとえば、11 を足して 2 を作るとき、その結果として新しい数が得られるが、この操作は1そのものの意味を変えるわけではない。

よくあるプログラミングのタスクをモデル化する合成的手法は、探してみると結構見つかる。多くのフロントエンド開発者に馴染みのある例としては、React コンポーネントがある。React コンポーネントは、複数のコンポーネントを組み合わせて作成することができる。また、HTTP ルートも合成的手法でモデル化される。ひとつのルートとは、HTTP リクエストからハンドラ関数、もしくはルートが一致しなかったことを示す値へとマッピングする関数で、ルートを論理和として組み合わせることで、特定のルートが一致しなければ別のルートを試す、という挙動を実現する。パイプライン処理にも順次的な合成がよく利用される。パイプラインのあるステージを最初に実行し、その後に別のステージを実行する、というものである。

1.2.1.1

型は厳密には関数型プログラミングの一部ではないが、関数型言語の中でも静的型付けされたものがもっともよく用いられているので、言及しておく価値がある。型はコンパイラが効率的なコードを生成するのを助けるが、関数型プログラミングにおける型はコンパイラだけでなくプログラマのためのものでもある。型はプログラムの特性を表現し、型チェッカーはそれらの特性が守られていることを自動的に保証してくれる。たとえば、関数が何を受け取り、何を返すかや、ある値がオプションであるといったことを型が示してくれる。さらに、プログラムに対する自分の仮定を型で表現し、型チェッカーにそれが正しいかどうかを確認してもらうこともできる。たとえば、コードのある場所においてエラーは発生しないと想定していることを型でコンパイラに伝えることができる。型チェッカーはそれが正しいかどうかを教えてくれる。このように、型はコードに対する推論を行うためのもうひとつのツールとなる。

型システムはプログラムを特定の設計へと導く。型チェッカーと効果的に連携するためには、型チェッカーが理解できる作法でコードを設計することが要求されるからである。洗練された型システムが多くの言語に導入されるにつれて、それらの言語を使うプログラマは自然と関数型プログラミングのスタイルへと移行する傾向がある。

1.2.2 関数型プログラミングは何でないか

私の考えでは、関数型プログラミングの本質は不変性や「評価の置換モデル」などではない。これらは、局所推論と合成を可能にするための手段に過ぎず、目的ではないのである。たとえば、不変なコードは常に局所推論を可能にするが、可変性を避けなくても局所推論を行うことはできる。整数のコレクションを合計する例を考えてみよう。

def sum(numbers: List[Int]): Int = {
  var total = 0
  numbers.foreach(x => total = total + x)
  total
}

この実装では total 変数の値を変更しているが、問題はない。そのような操作が行われていることは外部からはわからないし、sum の利用者は引き続き局所推論を行うことができる。sum の内部では total に関して慎重に考察する必要があるが、このコードブロックは十分に小さいため、特に問題を引き起こすことはないだろう。

この場合、可変変数を使いながらも、我々はコードについて推論できるし、Scala コンパイラもこれを問題なしと判断できる。Scala は可変性を認めており、これを適切に使用するかどうかはプログラマに委ねられている。より表現力のある型システム、たとえば Rust のような機能を持つ型システムであれば、sum がシステムの他の部分から観測される変更を認めていないことを保証できるだろう3。可変性を全面的に禁止することによって問題が生じないことを保証するアプローチもある。Haskell はこの方法を採用している。

可変性は合成にも影響を与える。たとえば、ある値が内部状態に依存している場合、それを合成すると予期しない結果を生む可能性がある。Scala の Iterator を考えてみよう。Iterator は次の値を生成するために内部状態を保持している。ふたつの Iterator があり、それらを組み合わせてふたつの入力から値を返す Iterator を作りたいと思ったとする。このような合成をするには zip メソッドを使う。

この合成は、ふたつの別々のジェネレータを zip に渡せば、正しく動作する。

val it = Iterator(1, 2, 3, 4)

val it2 = Iterator(1, 2, 3, 4)
it.zip(it2).next()
// res3: Tuple2[Int, Int] = (1, 1)

だが、同じジェネレータを二度使用すると、期待と異なる結果になる。

val it3 = Iterator(1, 2, 3, 4)
it3.zip(it3).next()
// res4: Tuple2[Int, Int] = (1, 2)

関数型プログラミングにおける一般的な解決策は可変状態を避けることだが、他の方法も考えられる。たとえば、エフェクト追跡システムを使用すれば、同じメモリ領域を使用するふたつのジェネレータの組み合わせを避けることができる。しかし、これらのシステムはまだ研究段階にある。

私の考えでは、不変性(および純粋性や参照透過性、そして覚えていないが何とかというもっと難解な概念)が関数型プログラミングと結びついているのは、それらが局所推論と合成を保証するからである。そして、最近まで、可変性の安全な使い方と問題を引き起こす使い方を自動的に区別できる言語ツールは存在しなかった。不変性の制約を受け入れることは、関数型プログラミングの望ましい特性を確保するためのもっとも簡単な方法だが、言語が進化するにつれて、これが過去の遺物と見なされる可能性もある。

1.2.3 なぜ重要なのか

局所推論と合成について述べてきたが、その利点についてはまだ触れていない。これらが望ましいとされる理由は、それによって知識を効率的に活用できるからである。この点について詳しく説明しよう。

局所推論が重要なのは、コードベースの規模が大きくなっても、コードを理解する能力がスケールするからである。モジュールAとモジュールBをそれぞれ単独で理解できるなら、それらがひとつのプログラム中で一緒に用いられたとしても、その理解が変わることはない。定義上、AとBの両方が局所推論を可能とする場合、B(または他のコード)がAについての理解を変えることはないし、その逆も同じことが言える。もし局所推論ができなければ、新しいコードを1行追加するごとに、既存のコード全体を再確認して何が変わったのかを理解する必要が生じる。コードは、サイズが大きくなり、相互作用(およびそれに伴うかもしれない挙動)の数が急増するにつれて、理解するのが指数関数的に難しくなる。局所推論は合成的であると言える。モジュールAによるモジュールB呼び出しを理解したいなら、AおよびB、そしてAがBに対して行う呼び出しの内容を理解するだけでよい。

数とレゴを合成の例として見てきたが、これらには共通する興味深い性質がある。それらを組み合わせる操作(たとえば、数の場合は加算や減算、レゴの場合は「ブロックをくっつける」操作)を行うと、操作前と同じ種類のものが得られるという点である。数に数を掛ければ結果は数になるし、ふたつのレゴをくっつけたものも依然としてレゴである。この性質は閉包性と呼ばれ、ものを組み合わせたときに同じ種類のものが得られることを意味する。閉包性があるということは、合成操作(時にはコンビネータとも呼ばれる)を任意の回数適用できることを意味する。どれだけ1を足しても結果は数であり、さらに加算や減算、乗算を行うことができる。モジュールAについて理解しており、Aによって提供されるコンビネータが閉包性を持っていれば、新しい概念を学ばなくとも、Aを使って非常に複雑な構造を作ることができる。これは、関数型プログラマがモナドのような抽象概念を好む理由のひとつでもある(単に難しい言葉が好きだからではない)。これらの抽象概念は、ひとつのメンタルモデルをさまざまなコンテキストで使うことを可能にしてくれる。

ある意味、局所推論と合成は同じコインの裏表のような関係にある。局所推論は合成的であり、合成は局所推論を可能にする。どちらもコードを理解しやすくしてくれる。

1.2.4 重要性のエビデンス

ここまで関数型プログラミングを支持する議論を行ってきたが、その考えに偏りがあることを認めよう。私はたしかに関数型プログラミングのほうが命令型プログラミングよりも優れたコード開発手法であると信じている。しかし、その主張を裏付けるエビデンスは存在するだろうか。関数型プログラミングの有効性についての研究はあまり行われていないが、静的型付けに関しては十分な研究がなされている。静的型付け、特に洗練された型システムを用いるケースは、関数型プログラミングのよい代替指標となると考えられるので、そのエビデンスについて見ていこう。

私がよく訪れるインターネットの片隅では、静的型付けが生産性に与える影響はほとんどないという声がよく聞かれた。しかし、調査してみたところ、静的型付けが生産性を向上させるという主張を支持する結果が多数見られることに驚いた。たとえばこの論文の文献レビュー(第2.3節、16〜19ページ)は、特に最新の研究を中心に静的型付けを支持する結果が多いことを示している。ただし、Dan Luu がレビューで言及しているとおり、これらの研究の多くは規模が小さく、比較的経験の浅い開発者を対象としている。私の考えでは、関数型プログラミングの真価は大規模なシステムで発揮される。また、プログラミング言語は他のツールと同様に、効果的に使用するためには熟練が必要である。経験の浅い開発者が言語間の有意差を示すのに十分なスキルをもっているかは疑わしい。

私が思うに、関数型プログラミングの有効性を示すもっとも使えそうなエビデンスは、ビジネスの現場で関数型プログラミングが広く採用されているという事実である。たとえば TypeScript や React の採用が広範囲に拡大を続けていることを考えてみるとよい。TypeScript や React によって体現されている関数型プログラミングが無価値だと言うなら、それらの技術へと移行した何千人もの JavaScript エンジニアが思い違いをしていると主張することになる。そのような主張はいずれ説得力を失うだろう。

これはもちろん、5年後に我々がみんな Haskell を使っているという意味ではない。むしろ、90年代のオブジェクト指向プログラミングへの移行に似たものとなる可能性が高い。Smalltalk はオブジェクト指向の典型例だったが、オブジェクト指向を開発現場の主流へと押し上げたのは、C++ や Java のような身近な言語だった。関数型プログラミングの場合、おそらく Scala、Swift、Kotlin、Rust などの言語や、JavaScript や Java のような主流言語が引き続き関数型の特徴を取り入れていくことになるだろう。

1.2.5 導入の最後に

ここまで、関数型プログラミングについて私見を述べてきた。私の考えでは、関数型プログラミングの本質的な目標は局所推論と合成であり、不変性などのプラクティスは目標を実現するための手段でしかない。この定義に異論を唱える人もいるかもしれないが、それはそれでかまわない。言葉はそれを用いるコミュニティによって定義され、意味は時とともに変化するものである。

関数型プログラミングが形式的な推論を重視していることにはいくつかの含意が伴う。それについて簡単に触れておきたい。

第一に、私の経験上、関数型プログラミングの価値がもっとも発揮されるのは、大規模なシステムにおいてである。小規模なシステムでは、詳細をすべて脳内に留めておくことができる。プログラムが大きくなり誰も全体を理解できなくなったときこそ、局所推論は真価を発揮する。小規模なプロジェクトに関数型プログラミングを使うべきではないと言っているのではない。プログラミングスタイルを命令型から関数型へ切り替えたとしても、おもちゃのような小規模プロジェクトではその利点を実感しにくいだろうということである。

関数型プログラミングの基礎にある形式モデルは、コードを体系的に組み立てることを可能にしてくれる。書かれたコードからその性質や振る舞いを抽出するのではなく、あるべき性質からコードを導くのだから、これはある意味で推論とは真逆の営みである。これは学術的に聞こえるかもしれないが実際にはとても実践的であり、私自身がコードを書く際に用いる手法でもある。

最後に、推論はコードを理解する唯一の方法ではない。推論の限界や、コードを理解するための他の手法、そして状況に応じたさまざまな戦略について理解することが重要である。

本書の最初のパートでは基盤となる戦略を構築していく。それ以降の部分は、基盤となるその戦略の上に構築され練り上げられていく。2章ではデータモデリングの主要な手段である代数的データ型について見ていく。3章では余データについて取り扱う。これは、代数的データの反対、別の言い方をすれば双対となるものである。型クラスについては4章で、そしてインタープリタの基礎については5章で議論する。以上4つの戦略はすべてコードの構造を記述するものである。たとえば、コードのある部分を指して、ここに書かれているのが代数的データ型であるとか型クラスであるとかとラベル付けすることができる。また、型に従うといった、コードを書く上で役立つが直接コードに反映されるわけではない戦略も登場する。

2 代数的データ型

この章では、プログラミング戦略の最初の例として代数的データ型(algebraic data type)を取り上げる。論理積と論理和を使って表現できるデータはすべて代数的データ型である。代数的データ型をただ認識するだけで、以下の三つが手に入る。

重要なのは、実装非依存のデータ表現から、そのデータを扱うための実装固有の興味深い部分の多くを自動的に導き出すことができるという点である。

まず、データの例をいくつか紹介し、そこから、代数的データ型を導く共通の構造を抽出する。その後、Scala2 と Scala3 での代数的データ型の表現を見ていく。次に、代数的データ型を変換するための構造的再帰に触れ、続いて、代数的データ型を構築するための構造的余再帰について説明する。最後に、代数的データ型の代数について考察する。これは本書の内容にとって必須ではないが、興味深いテーマである。

2.1 代数的データ型の構築

まず、いくつかの異なる分野からデータ例を挙げてみよう。これらは簡略化されてはいるが、いずれも実際のアプリケーションにおける典型と言えるものである。

ディスカッションフォーラムのユーザは通常、表示名、メールアドレス、そしてパスワードといった情報をもつ。また、ユーザには通常、特定のロールが割り当てられる。たとえば、一般ユーザ、モデレータ、管理者など。これを以下に整理しておく。

eコマースストアの製品は、在庫管理単位(製品の各バリエーションに対する一意の識別子)、名前、説明、価格、割引率といった情報をもっている。

二次元ベクターグラフィックスでは、図形をパスとして表現することが一般的に行われる。パスは、仮想ペンの一連の動きで構成される。よくあるものとして、直線、ベジエ曲線、または目に見える結果を伴わない移動が挙げられる。直線には終点があり(始点は暗黙的に決まる)、ベジエ曲線にはふたつの制御点と終点があり、移動には終点がある。

上記すべての例に共通しているのは、個々の要素、いわば「原子」が論理積または論理和で結びつけられているという点である。たとえば、ユーザは表示名およびメールアドレスおよびパスワードおよびロールから構成されている。2Dアクションは、直線またはベジエ曲線または移動である。これが代数的データ型の核となる考え方で、代数的データ型とは、論理積や論理和で結びつけられたデータのことをいう。逆に言えば、データを論理積や論理和で表現できる場合、それは代数的データ型であると言える。

2.1.1 直和と直積

関数型プログラマとしては、単純な概念にちょっと難しそうな専門用語をつけずにはいられないものである。

代数的データ型は直和型と直積型で構成される。

2.1.2 閉じた世界

代数的データ型は閉じた世界であり、一度定義されると、それが拡張されることはない。要素を追加または削除したい場合、代数的データ型を定義した元のソースコードを変更する必要がある。

この閉じた世界の性質は重要である。これによって通常は得られない保証を得ることができる。特に、代数的データ型を使用する際に、すべてのありうるケースを処理しているかどうかをコンパイラがチェック可能となる。これを網羅性チェックと呼ぶ。このように、関数型プログラミングは、拡張性などの特性よりも、コードに対する推論(この場合はコンパイラによる自動的な推論)を優先する傾向がある。網羅性チェックについては、この後詳しく学ぶ予定である。

2.2 Scala における代数的データ型

代数的データ型が何であるかを理解したところで、次にそれが Scala でどのように表現されるかを見ていこう。ここで重要なのは、Scala への翻訳が、データの構造によって完全に決定されるということである。特別なことを考える必要はない。そのため、取り組んでいる課題に最適なデータの構造を見つけることが主な作業となる。データ構造を考え出し、それに従ってコードを書けばよい。

代数的データ型は論理積と論理和で定義されるため、Scala で代数的データ型を表現するためには、これらふたつの概念がどのように表現されるかを知っておく必要がある。Scala3 では Scala2 に比べて代数的データ型の表現が簡素化されているため、それぞれの言語バージョンを別々に見ていく。

なお、代数的データ型を Scala で表現するために使用する言語機能についてはすでに知っているものとし、その説明は割愛する。

2.2.1 Scala3 における代数的データ型

Scala3 では、論理積(直積型)は final case class で表現される。直積型 AB C から成ると定義する場合、その Scala3 での表現は次のようになる。

final case class A(b: B, c: C)

こういった case class を final にしない人もいるが、するべきである。final でない case class は他のクラスによって拡張される可能性があり、これは代数的データ型の閉じた世界を壊してしまう。

論理和(直和型)は enum で表現される。直和型 AB または C である場合、その Scala3 での表現は次のようになる。

enum A {
  case B
  case C
}

注意すべき点がいくつかある。データが積の和である場合、たとえば次のようなデータであれば、

コード表現は次のようになる。

enum A {
  case B(d: D, e: E)
  case C(f: F, g: G)
}

つまり、enum の中には final case class とは書かない。また、enum の中に enum をネストすることもできない。ネストされた論理和は、論理積の論理和(これを選言標準形と呼ぶ)に書き換えることができるので、実用にあたってこれが制約になることはない。ただし、ネストされた論理和を表すことのできる Scala2 の表現は Scala3 でも引き続き利用可能である。

2.2.2 Scala2 における代数的データ型

論理積(直積型)は、Scala2 でも Scala3 でも同じうように表現される。直積型 AB C から成ると定義する場合、Scala2 での表現は以下のようになる。

final case class A(b: B, c: C)

論理和(直和型)は sealed abstract class で表現される。直和型 AB または C である場合、Scala2 での表現は以下のとおりである。

sealed abstract class A
final case class B() extends A
final case class C() extends A

Scala2 で代数的データ型を定義するにあたっては小さなテクニックがいくつかある。

まず、sealed abstract class の代わりに sealed trait を使用することができる。このふたつの間には大きな実用的差異はない。私の場合、初心者に教える際には sealed trait を用いることが多い。そうすることで abstract class を紹介する手間を避けることができる。sealed abstract class の方が若干パフォーマンスが良く、Java との互換性が高いと思われるが、テストをして確認したわけではない。また、sealed abstract class の方が意味的に直和型に近いとも考えられる。

コードをさらに洗練させるために、sealed abstract classProductSerializable を継承した型として定義することもできる。このちょっとした工夫がある場合とない場合で、推論される型を比較してみてほしい。

まずは ProductSerializable を拡張しないコードを見てみよう。

sealed abstract class A
final case class B() extends A
final case class C() extends A
val list = List(B(), C())
// list: List[A extends Product with Serializable] = List(B(), C())

list 変数の型に ProductSerializable が含まれている点に注目してほしい。

そこで AProductSerializable を継承させる。

sealed abstract class A extends Product with Serializable
final case class B() extends A
final case class C() extends A
val list = List(B(), C())
// list: List[A] = List(B(), C())

これで推論される型がシンプルに読みやすくなる。

この問題は Scala2 でのみ確認できる。Scala3 には、型推論で報告される型には反映されない transparent トレイトという概念がある。そのため、Scala3 では ProductSerializable を追加してもしなくても同じ結果が得られる。

最後に、ある型がデータをひとつも保持しない場合、case class の代わりに case object を使用することができる。たとえば、ターミナルのようなテキストストリームからの読み込みでは EOF(end-of-file) が返されることがあるが、これは次のようにモデリングできる。

sealed abstract class Result
final case class Character(value: Char) extends Result
case object Eof extends Result

ファイルの終端を示す Eof には何のデータも紐づかないので、case object として定義されている。オブジェクトは拡張できないので、case objectfinal としてマークする必要はない。

2.2.3

いくつか例を挙げて、ここまでの議論についてもっと具体的に考えてみよう。

2.2.3.1 Role と User

ディスカッションフォーラムの例では、ロールは一般ユーザ・モデレータ・管理者のいずれかであるとした。これは論理和であり、適切なパターンに当てはめることで機械的に Scala へと翻訳できる。Scala3 であれば次のようになる。

enum Role {
  case Normal
  case Moderator
  case Administrator
}

Scala2 の場合は以下のように書ける。

sealed abstract class Role extends Product with Serializable
case object Normal extends Role
case object Moderator extends Role
case object Administrator extends Role

各ロールはデータをひとつも保持しないため、Scala2 のコードでは case class ではなく case object を使っている。

ユーザは、表示名とメールアドレスとパスワードとロールから構成されると定義した。これは Scala3 と Scala2 のどちらにおいても次のようになる。

final case class User(
  screenName: String,
  emailAddress: String,
  password: String,
  role: Role
)

User が保持するデータを主に String 型で表現しているが、実際のコードでは各フィールドに対して個別の型を定義したほうがよいかもしれない。

2.2.3.2 パス

前述のデータ例で、パスを仮想ペンの一連の動作として定義した。それによると、可能な動作には、直線、ベジエ曲線、または目に見える出力を伴わない移動がある。また、直線には終点があり(始点は自動的に決まる)、ベジエ曲線にはふたつの制御点と終点があり、移動には終点がある。

これらはストレートに Scala での表現に置き換えることができる。Scala3 と Scala2、どちらにおいてもパスは次のように表現できる。

final case class Path(actions: Seq[Action])

ペンの動作は論理和なので、Scala3 と Scala2 で表現の仕方が異なる。Scala3 では次のように記述できる。

enum Action {
  case Line(end: Point)
  case Curve(cp1: Point, cp2: Point, end: Point)
  case Move(end: Point)
}

なお、ここで用いている Point は二次元座標を表現したクラスであるとする。

Scala2 ではもっと冗長な表現方法を用いる必要がある。

sealed abstract class Action extends Product with Serializable 
final case class Line(end: Point) extends Action
final case class Curve(cp1: Point, cp2: Point, end: Point)
  extends Action
final case class Move(end: Point) extends Action

2.2.4 Scala3 における代数的データ型の表現

Scala3 の enum を使用した代数的データ型の表現が、Scala2 のそれよりもコンパクトであることを見てきたが、Scala2 の表現は依然として使用可能である。では、Scala3 であえて Scala2 の表現を使うべき場面はあるのかというと、いくつか考慮すべきケースがある。

演習: 木構造

代数的データ型の定義に慣れるため、以下の記述を Scala のコードで表せ。なお、用いる Scala のバージョンは好きに選んでよい。両方を試してもかまわない。

A 型の要素をもつ Tree は以下のいずれかとして定義される。

このような二分木は機械的に Scala のコードに落とし込むことができる。以下は Scala3 で書いたコードである。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
}

Scala2 なら次のようになる。

sealed abstract class Tree[A] extends Product with Serializable
final case class Leaf[A](value: A) extends Tree[A]
final case class Node[A](left: Tree[A], right: Tree[A]) extends Tree[A]

2.3 構造的再帰

ふたつ目のプログラミング戦略は構造的再帰(structural recursion)である。代数的データ型は、特定の構造に基づいたデータをどのように定義するかを示してくれた。構造的再帰は代数的データ型を他の任意の型に変換する方法を教えてくれる。代数的データ型が与えられた場合、その変換は構造的再帰を使って実装することができる。

代数的データ型と同様、構造的再帰の概念と Scala における実装は区別して考える必要がある。そのことは、Scala には構造的再帰を実装する方法がふたつあることからもわかるだろう。そのふたつとは、パターンマッチングを使う方法と動的ディスパッチを使う方法である。これらを順に見ていこう。

2.3.1 パターンマッチング

Scala におけるパターンマッチングについては基本的な知識があることを前提としているので、ここではパターンマッチングを使って構造的再帰をどのように実装するかについてのみ解説する。代数的データ型には直和型(論理和)と直積型(論理積)の二種類があったことを思い出そう。構造的再帰をパターンマッチングで実装する際には、この二種類それぞれに対応するルールがある。

  1. 直和型の各分岐は、パターンマッチ内で別々の case となる
  2. case は直積型に対応し、通常の方法でパターンが書かれる

これをコードで見てみよう。以下のような直和型と直積型の両方を組み合わせた代数的データ型の例を用いる。

Scala3 ではこれは次のように表現される。

enum A {
  case B(d: D, e: E)
  case C(f: F, g: G)
}

先ほど示したルールに従うと、構造的再帰は以下のようになる。

anA match {
  case B(d, e) => ???
  case C(f, g) => ???
}

??? の部分をどう書くかは何をやりたいか次第であり、一般的な解決策を示すことはできないが、それらを実装するのに役立つ戦略についてはこの後に見ていく。

2.3.2 構造的再帰における再帰

この時点では、構造的再帰における「再帰」という言葉がどこから来ているのか疑問に思うかもしれない。再帰については追加のルールがある。データが再帰的である場合、メソッドも同じ箇所で再帰的になる、というものである。

これを実際のデータ型で見てみよう。

A 型の要素をもつリストは以下のように定義できる。

これは標準ライブラリにおける List の定義そのものである。直和型と直積型から構成された代数的データ型である点に注目してほしい。また、この構造は再帰的でもある。ペアのケースでは、末尾の部分自体がひとつのリストになっている。

すでに学んだ代数的データ型の戦略を使えば、これを直接コードに置き換えることができる。Scala3 では次のようになる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
}

試しに MyListmap を実装してみよう。まずは名前と型のみを定めたメソッドの骨組みからスタートする。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    ???
}

最初のステップは、map が構造的再帰を使って記述可能であると認識することである。MyList は代数的データ型であり、map はこの代数的データ型を変換するものなので、構造的再帰が適用できる。実際に構造的再帰の戦略を適用すると、次のようになる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => ???
      case Pair(head, tail) => ???
    }
}

再帰のルールについても考慮しておこう。データが再帰的構造をもつ場合、それと同じ場所でメソッドも再帰呼び出しされる。このデータは Pairtail の部分が再帰的なので、map もそこで再帰的になる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => ???
      case Pair(head, tail) => ??? tail.map(f)
    }
}

なお、??? は、その部分のコードがまだ完成していないことを示している。

骨組みが完成したら、取り組んでいる課題に固有の部分に進むことができる。ここで役に立つ三つの戦略がある。

  1. 各ケースを独立に考えること
  2. 再帰呼び出しの結果は正しいと仮定すること
  3. 型に従うこと

最初のふたつは構造的再帰に特有のもので、最後のひとつは多くの状況で使える一般的な戦略である。それぞれについて簡単に検討し、今回の例にどのように適用するか見ていこう。

最初の戦略は比較的シンプルである。パターンマッチングにおいて、ある case の右側にある問題固有のコードを考える際、他のケースにあるコードは無視してかまわない。たとえば、上の Empty のケースを考えるとき、Pair のケースを気にする必要はないし、逆も同じことが言える。

次の戦略はやや複雑で、再帰に関係している。構造的再帰の戦略は、再帰呼び出しをどこに配置すればよいか示してくれることを思い出してほしい。このとき、再帰呼び出しの処理内容について深く考える必要はない。代わりに、再帰呼び出しが正しく計算されると仮定し、再帰の結果をどう処理するかだけを考えればよい。再帰以外の部分が正確であれば、結果が正しいことは保証される。

上の例には、tail.map(f) という再帰呼び出しがある。この再帰呼び出しがリストの tail に対する map を正しく行ってくれると仮定し、残りのデータ、つまり head と再帰呼び出しの結果をどう扱うかだけを考えればよい。

各ケースを独立して考えることができるのはこの特性による。再帰呼び出しは異なるケースをつなぐ唯一のものであり、その書き方は構造的再帰戦略によって与えられる。

最後の戦略は、型に従うことである。これは構造的再帰に限らず、多くの状況で使えるため、別の戦略として扱おうと思う。核となる考え方は、入出力の型情報を活用して、ありうる実装を限定していくというものである。

それでは、これらの戦略を使って map の実装を完成させよう。すでに実装した部分を以下に再掲し、この続きを考えていく。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => ???
      case Pair(head, tail) => ??? tail.map(f)
    }
}

最初の戦略は、各ケースを独立して考えることである。まずは Empty のケースから始めよう。このケースには再帰呼び出しがないため、再帰について考える必要はない。代わりに型情報を利用しよう。Empty ケースにマッチしたということ以外に入力はないため、入力の型を使ってコードを制約することはできない。一方で、出力の型について考えてみると、mapMyList[B] を作成しようとしていることがわかる。MyList[B] を作成する方法は EmptyPair の二通りだけである。そして、Pair を作成するには、B 型 の head が必要だが、それに相当する情報は与えられていないので、Empty を使うしかない。これが書くことのできる唯一のコードである。型が十分に制約を与えているため、このケースで誤ったコードを書くことはできない。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => Empty()
      case Pair(head, tail) => ??? tail.map(f)
    }
}

次に Pair のケースに移ろう。ここでは構造的再帰の戦略と、型に従うこと、両方を適用することができる。以下に Pair のケースを再掲する。

case Pair(head, tail) => ??? tail.map(f)

他のケースとは独立に考えることができることを思い出してほしい。再帰呼び出しの結果は正しいと仮定すると、ここでは head をどう処理し、 tail.map(f) の結果とどう結合するかだけを考えればよい。あとは型に従ってコードを完成させよう。ゴールは MyList[B] 型の値を生成することで、そのために利用できる情報として以下のものがある。

すでに記述した Empty ケースと同じように、単に Empty を返すこともできる。これは型的には正しいが、再帰呼び出しの結果や head、関数 f をまったく使用していないし、正しい答えではないだろうと予想できる。

また、単に tail.map(f) を返すこともでき、これも型的には正しいが、head を使用していないので、やはり正しい答えではないだろう。

head に関数 f を適用して B 型の値を生成し、その値と再帰呼び出しの結果を Pair を使って組み合わせて MyList[B] 型の値を作成することができる。これが正しい解法である。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => Empty()
      case Pair(head, tail) => Pair(f(head), tail.map(f))
    }
}

この例題についてここまで読み進めてきたなら、三つの戦略を使って体系的に正しい実装を見つける方法について理解していただけたと思う。再帰戦略と型に従う戦略を交互に用いることで、Pair ケースの解法へと導かれたことに注目してほしい。また、ただ型に従うだけでも Pair ケースの実装方法が三つに絞り込まれた点にも留意してほしい。今回のコードでは、そして大抵の場合もそうだが、利用できるすべての入力を使用する実装が正しい解決策となる。

2.3.3 網羅性チェック

代数的データ型は閉じた世界であり、一旦定義されると拡張できないということを思い出そう。Scala コンパイラは、この性質を利用し、パターンマッチングにおいてすべてのケースが処理されているかどうかをチェックすることができる。ただし、パターンマッチをコンパイラが処理できる形式で記述する必要がある。これは網羅性チェックと呼ばれている。

以下にシンプルな例を挙げる。まずは素直に代数的データ型を定義するところから始める。

// CSSにおいて用いられる可能性のある長さの単位
enum CssLength {
  case Em(value: Double)
  case Rem(value: Double)
  case Pt(value: Double)
}

構造的再帰の戦略を使ってパターンマッチを書いていれば、ケースが欠けているときにコンパイラが警告を出してくれる。

import CssLength.*

CssLength.Em(2.0) match {
  case Em(value) => value
  case Rem(value) => value
}
// -- [E029] Pattern Match Exhaustivity Warning: ----------------------------------
// 1 |CssLength.Em(2.0) match {
//   |^^^^^^^^^^^^^^^^^
//   |match may not be exhaustive.
//   |
//   |It would fail on pattern case: CssLength.Pt(_)
//   |
//   | longer explanation available when compiling with `-explain`

網羅性チェックは非常に有用である。たとえば、代数的データ型に新しいケースを追加したり、ケースを削除したりしたときに、更新する必要のあるパターンマッチはどれかをコンパイラが教えてくれる。

2.3.4 動的ディスパッチ

構造的再帰の実装として動的ディスパッチを使用する方法は、オブジェクト指向プログラミングの経験がある人にとって、より自然に感じられる実装テクニックかもしれない。

動的ディスパッチのアプローチは、以下の手順で構成される。

  1. 代数的データ型のルートに抽象メソッドを定義する
  2. 代数的データ型の各バリアントでその抽象メソッドを実装する

この実装テクニックは、Scala2 における代数的データ型の表現方法を用いている場合にのみ利用可能である。

これを、先ほど使った MyList の例で見ていこう。最初のステップは MyList の定義を Scala2 のスタイルに書き換えることである。

sealed abstract class MyList[A] extends Product with Serializable
final case class Empty[A]() extends MyList[A]
final case class Pair[A](head: A, tail: MyList[A]) extends MyList[A]

次に、MyList に抽象メソッド map を定義する。

sealed abstract class MyList[A] extends Product with Serializable {
  def map[B](f: A => B): MyList[B]
}
final case class Empty[A]() extends MyList[A]
final case class Pair[A](head: A, tail: MyList[A]) extends MyList[A]

続いて、MyList を具象化した部分型 EmptyPairmap メソッドを実装する。

sealed abstract class MyList[A] extends Product with Serializable {
  def map[B](f: A => B): MyList[B]
}
final case class Empty[A]() extends MyList[A] {
  def map[B](f: A => B): MyList[B] = 
    Empty()
}
final case class Pair[A](head: A, tail: MyList[A]) extends MyList[A] {
  def map[B](f: A => B): MyList[B] =
    Pair(f(head), tail.map(f))
}

この map の実装を考える際には、パターンマッチングの場合とまったく同じ戦略を使用することができる。実装テクニックは違っても、基礎的な概念は同じである。

これらふたつの実装戦略のうち、どちらを使うべきだろうか。Scala3 で enum を使用している場合、選択肢はなく、パターンマッチングを使うしかない。他の状況では、どちらを使うか選択できる。私は、可能であればパターンマッチングを使うほうが好みである。そうすることでメソッド定義全体を一か所にまとめることができる。だが、特に Scala2 では、一部のパターンマッチで型推論に問題が生じることがあり、そのような場合には、動的ディスパッチを使用することもできる。これについては、一般化代数的データ型について見ていく際にさらに詳しく学ぶ予定である。

2.3.4.1 演習: Tree へのメソッド定義

前の演習では代数的データ型 Tree を作成した。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
}

Scala2 の表現方法であれば以下のとおり。

sealed abstract class Tree[A] extends Product with Serializable
final case class Leaf[A](value: A) extends Tree[A]
final case class Node[A](left: Tree[A], right: Tree[A]) extends Tree[A]

構造的再帰の練習として、この Tree に対して以下のメソッドを実装せよ。

実装にはパターンマッチングと動的ディスパッチどちらでも好きな方を使ってかまわない。

この解答では、直和型の表現として enum を使い、メソッドの実装にパターンマッチングを用いている。

まずは、ボディは空のままメソッドを宣言するところから始めよう。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def size: Int = 
    ???

  def contains(element: A): Boolean =
    ???
    
  def map[B](f: A => B): Tree[B] =
    ???
}

これらのメソッドはすべて代数的データ型を変換するので、構造的再帰を使って実装することができる。Tree に対する構造的再帰の骨組みを以下のように記述する。再帰呼び出しはデータが再帰的である場所で行われる、というルールも適用した。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def size: Int = 
    this match { 
      case Leaf(value)       => ???
      case Node(left, right) => left.size ??? right.size
    }

  def contains(element: A): Boolean =
    this match { 
      case Leaf(value)       => ???
      case Node(left, right) => left.contains(element) ??? right.contains(element)
    }
    
  def map[B](f: A => B): Tree[B] =
    this match { 
      case Leaf(value)       => ???
      case Node(left, right) => left.map(f) ??? right.map(f)
    }
}

ここまで書けば、他の推論テクニックを使ってメソッドの定義を完成させることができる。では size を実装していこう。

def size: Int = 
  this match { 
    case Leaf(value)       => 1
    case Node(left, right) => left.size ??? right.size
  }

ケースはそれぞれ独立に考えることができる。Leaf のサイズは、定義により常に 1 である。

def size: Int = 
  this match { 
    case Leaf(value)       => 1
    case Node(left, right) => left.size ??? right.size
  }

Node のケースには、再帰呼び出しの結果を正しいと仮定する考え方を利用することができる。結合された木のサイズは、左右の子のサイズの合計になるはずである。再帰呼び出しが左右の子のサイズを正しく計算していると仮定すれば、size の実装は以下のようになる。

def size: Int = 
  this match { 
    case Leaf(value)       => 1
    case Node(left, right) => left.size + right.size
  }

残りのふたつのメソッドも同じプロセスを使って実装できる。以下に完全な解答を示す。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def size: Int = 
    this match { 
      case Leaf(value)       => 1
      case Node(left, right) => left.size + right.size
    }

  def contains(element: A): Boolean =
    this match { 
      case Leaf(value)       => element == value
      case Node(left, right) => left.contains(element) || right.contains(element)
    }
    
  def map[B](f: A => B): Tree[B] =
    this match { 
      case Leaf(value)       => Leaf(f(value))
      case Node(left, right) => Node(left.map(f), right.map(f))
    }
}

2.3.5 構造的再帰としての畳み込み

最後に、構造的再帰を抽象化したものとして畳み込みメソッドについて見ていこう。先ほどの Tree の演習を行ったなら、同じパターンのコードを何度も書いたことに気付いたはずである。演習で作成したメソッドを以下に再掲する。パターンマッチの左側はすべて同じだし、右側も非常に似ていることに注目してほしい。

def size: Int = 
  this match { 
    case Leaf(value)       => 1
    case Node(left, right) => left.size + right.size
  }

def contains(element: A): Boolean =
  this match { 
    case Leaf(value)       => element == value
    case Node(left, right) => left.contains(element) || right.contains(element)
  }
  
def map[B](f: A => B): Tree[B] =
  this match { 
    case Leaf(value)       => Leaf(f(value))
    case Node(left, right) => Node(left.map(f), right.map(f))
  }

この類似性を認識し公式化することが構造的再帰の要点である。だが、プログラマとしては、この繰り返しを抽象化したくなるかもしれない。構造的再帰の中で変わらない部分をすべて抽出し、変わる部分については呼び出し側が引数として渡せるようなメソッドを作ることはできるだろうか。これは実は可能である。任意の代数的データ型に対して、そういうメソッドを少なくともひとつは定義できる。畳み込みと呼ばれるそのメソッドが構造的再帰の変わらない部分をすべてキャプチャし、やりたいことに応じて異なる部分を呼び出し側が指定できるようにしてくれる。

どのように定義されるのか MyList の例を使って見ていこう。MyList の定義を再掲する。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
}

MyList で使われる構造的再帰の骨組みは、すでに理解しているとおり、以下のようになる。

def doSomething[A](list: MyList[A]) =
  list match {
    case Empty()          => ???
    case Pair(head, tail) => ??? doSomething(tail)
  } 

MyList に畳み込みを実装するとは、次のような fold メソッドを定義するということである。

def fold[A, B](list: MyList[A]): B =
  list match {
    case Empty() => ???
    case Pair(head, tail) => ??? fold(tail)
  }

ここで B は、呼び出し元が生成したい値の型とする。

fold メソッドを完成させるには、個々の問題に固有の部分である ??? を埋めるために引数を加える必要がある。Empty のケースには B 型の値が必要である(型に従って考えていることに注目してほしい)。

def fold[A, B](list: MyList[A], empty: B): B =
  list match {
    case Empty() => empty
    case Pair(head, tail) => ??? fold(tail, empty)
  }

Pair のケースでは、 A 型の head と、 B 型の値を生成する再帰処理がすでにあるので、必要なのはそれらふたつを結合する関数ということになる。

def foldRight[A, B](list: MyList[A], empty: B, f: (A, B) => B): B =
  list match {
    case Empty() => empty
    case Pair(head, tail) => f(head, foldRight(tail, empty, f))
  }

これが foldRight メソッドである(この後に見せるもうひとつの解法と区別できるよう名前を変更した)。これを見て、もうひとつの有効な解があることに気付いたかもしれない。empty も再帰呼び出しも B の値を生成する。型に従って考えれば、次のような結論に至ることもできるだろう。

def foldLeft[A,B](list: MyList[A], empty: B, f: (A, B) => B): B =
  list match {
    case Empty() => empty
    case Pair(head, tail) => foldLeft(tail, f(head, empty), f)
  }

これが foldLeft メソッドで、リストに対する畳み込みの末尾再帰バージョンである。末尾再帰については後の章で解説する。

決まった手順をたどることで、任意の代数的データ型に対してその畳み込みメソッドを作成することができる。そのルールは以下のとおりである。

MyList に当てはめると次のようになる。

演習: Tree の畳み込み

以前定義した Tree に対して畳み込みを実装せよ。二分木の走査には、先行順、後行順、中間順などいくつかの方法があるが、もっとも簡単だと思うものを選んで実装してかまわない。

まずはボディのないメソッド宣言を追加することから始める。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def fold[B]: B =
    ???
}

次に、構造的再帰の骨組みを追加する。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def fold[B]: B =
    this match {
      case Leaf(value)       => ???
      case Node(left, right) => left.fold ??? right.fold
    }
}

これで、型に従ってメソッドにパラメータを追加する準備が整った。Leaf のケースには A => B 型の関数が必要であることがわかる。

enum Tree[A] {
  case Leaf(value: A => B)
  case Node(left: Tree[A], right: Tree[A])
  
  def fold[B](leaf: A => B): B =
    this match {
      case Leaf(value)       => leaf(value)
      case Node(left, right) => left.fold ??? right.fold
    }
}

Node のケースにはふたつの再帰呼び出しの結果を結合する関数が必要なので、追加するパラメータは (B, B) => B という型をもつことになる。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def fold[B](leaf: A => B)(node: (B, B) => B): B =
    this match {
      case Leaf(value)       => leaf(value)
      case Node(left, right) => node(left.fold(leaf)(node), right.fold(leaf)(node))
    }
}

演習: 畳み込みの利用

構造的再帰が畳み込みの呼び出しに置き換えられることを確認するため、Treesizecontainsmap を、fold だけを使って再定義せよ。

enum Tree[A] {
  case Leaf(value: A)
  case Node(left: Tree[A], right: Tree[A])
  
  def fold[B](leaf: A => B)(node: (B, B) => B): B =
    this match {
      case Leaf(value)       => leaf(value)
      case Node(left, right) => node(left.fold(leaf)(node), right.fold(leaf)(node))
    }
    
  def size: Int = 
    this.fold(_ => 1)(_ + _)

  def contains(element: A): Boolean =
    this.fold(_ == element)(_ || _)
    
  def map[B](f: A => B): Tree[B] =
    this.fold(v => Leaf(f(v)))((l, r) => Node(l, r))
}

2.4 構造的余再帰

構造的余再帰(structural corecursion)は、構造的再帰の反対、より正確な用語を使うなら双対(dual)である。構造的再帰が代数的データ型をどのように分解するかを教えてくれるのに対して、構造的余再帰は代数的データ型をどのように構築するかを教えてくれる。構造的再帰は、メソッドや関数の入力が代数的データ型である場合に使用できるが、構造的余再帰はメソッドや関数の出力が代数的データ型である場合に使用できる。

関数型プログラミングにおける双対性

ふたつの概念や構造が一対一で対応付けられる場合、それらを双対であると言う。双対性は本書の主要なテーマのひとつである。概念同士を双対として関連付けることで、ある領域の知識を別の領域に流用することができるようになる。

双対性は、しばしば構造や概念に co- という接頭辞を付けることで示される。たとえば、英語で corecursion と呼ばれる余再帰は再帰の双対であり、coproduct (余積)は積の双対である。

構造的再帰は、考えられるすべての入力(通常はパターンマッチングのパターンとして表現される)について、それぞれの入力ケースに対して何を行うかを決定することによって機能する。一方、構造的余再帰は、考えられるすべての出力、すなわち代数的データ型のコンストラクタについて考え、それぞれのコンストラクタを呼び出す条件を決定することによって機能する。

A 型の要素を持つリストの話に戻ろう。これは次のように定義することができた。

Scala3 では次のように書かれる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
}

MyList インスタンスを生成するメソッドを書きたいときに、構造的余再帰を利用することができる。そのよい例として map がある。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    ???
}

このメソッドの出力は代数的データ型である MyList である。MyList インスタンスを構築したいのだから、構造的余再帰を利用することができる。構造的余再帰の戦略では、まずすべてのコンストラクタを書き出し、それぞれのコンストラクタ呼び出しを引き起こす条件を考える。したがって、最初のステップは、ふたつのコンストラクタを書き出し、仮の条件を記述することである。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    if ??? then Empty()
    else Pair(???, ???)
}

データが再帰的である場所で再帰呼び出しを行うというルールを適用すると、メソッドは以下のようになる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    if ??? then Empty()
    else Pair(???, ???.map(f))
}

これまでに学んだ戦略を利用することで、if 式の条件部、あるいはパターンマッチングであればその case 部分を完成させることができる。

この手順に従えば、短時間で正しい解決策にたどり着くことができる。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
  
  def map[B](f: A => B): MyList[B] = 
    this match {
      case Empty() => Empty()
      case Pair(head, tail) => Pair(f(head), tail.map(f))
    }
}

ここには興味深いポイントがいくつかある。

まず、map は構造的再帰であり、同時に構造的余再帰でもあるということを認識しておく必要がある。これはいつも成り立つわけではない。たとえば、foldLeftfoldRight は、必ずしも代数的データ型を生成するとは限らないし、構造的余再帰ではない。

次に、map を構造的再帰として実装するプロセスを進めた際に、型に従う一環として、それと知らずに構造的余再帰のパターンを使用していたことに注目してほしい。我々は List を生成しようとしていることに気づき、List を生成する方法としてふたつのコンストラクタの存在を認識し、それぞれに適した条件を見出した。構造的余再帰をひとつの戦略として公式化することで、それを適用しているということを、より意識的に認識できるようになる。

最後に、map を定義していく過程で、if 式からパターンマッチ式に切り替えたことに留意してほしい。これはまったく問題ない。どちらの式でも同じ効果を得ることができる。ただし、パターンマッチングは網羅性チェックがあるのですこし安全である。また、もし if 式を使い続けるのであれば、EmptyPair を区別するためのメソッド(たとえば isEmpty)を定義しなくてはならないが、そのメソッドの実装にあたってパターンマッチングを使用する必要があるため、パターンマッチングの直接的な利用を避けても、それを別の場所に押し込めてしまうだけである。

2.4.1 構造的余再帰としての展開(unfold)

構造的再帰を fold として抽象化できたように、任意の代数的データ型に対して、構造的余再帰を unfold として抽象化することができる。unfoldfold ほど一般的に使われるものではないが、有用なツールである。

今回も MyList を例として使いながら、unfold を導き出すプロセスを見ていこう。

enum MyList[A] {
  case Empty()
  case Pair(head: A, tail: MyList[A])
}

構造的余再帰の骨組みは以下のとおりだった。

if ??? then MyList.Empty()
else MyList.Pair(???, recursion(???))

まず unfold の骨格を書き出すことから始める。すこし変わっているのは、seed というパラメータを追加していることである。これはデータを作成するために必要なものだが、戦略から導き出せるものではないため、初期の仮定としてここに追加されている。

def unfold[A, B](seed: A): MyList[B] =
  ???

ここからは、いつもの戦略を使って足りないピースを埋めていく。以下のコードでは、余再帰の骨格を用いるとともに、導出のステップを省くため、再帰呼び出しのルールの適用まで行っている。

def unfold[A, B](seed: A): MyList[B] =
  if ??? then MyList.Empty()
  else MyList.Pair(???, unfold(seed))

条件部は A => Boolean 型の関数を使って抽象化できる。

def unfold[A, B](seed: A, stop: A => Boolean): MyList[B] =
  if stop(seed) then MyList.Empty()
  else MyList.Pair(???, unfold(seed, stop))

次に必要なのは Pair を生成するケースのハンドリングである。 A 型の値として seed がすでにあるので、Pairhead 要素を作成するには A => B 型の関数があればよいだろう。

def unfold[A, B](seed: A, stop: A => Boolean, f: A => B): MyList[B] =
  if stop(seed) then MyList.Empty()
  else MyList.Pair(f(seed), unfold(???, stop, f))

最後に、現在の seed 値から、次の再帰呼び出しで使う値を導かなくてはならない。そのために A => A 型の関数が必要となる。

def unfold[A, B](seed: A, stop: A => Boolean, f: A => B, next: A => A): MyList[B] =
  if stop(seed) then MyList.Empty()
  else MyList.Pair(f(seed), unfold(next(seed), stop, f, next))

これで unfold の定義は完成である。unfold を使って他のメソッドを定義し、その便利さを味わってみよう。すでに見たように、map は構造的余再帰なので、これを unfold で定義しなおす。さらに、リストを構築するメソッドである filliterate も定義することにする。これらは、Scala 標準ライブラリの List にある同名のメソッドに対応している。

以下に示すコードでは、作業を簡単にするため、unfoldMyList のコンパニオンオブジェクトのメソッドとして宣言している。また、型推論を改善するために定義にすこし調整を加えている。Scala では、あるメソッドパラメータについて推論された型を、同じパラメータリスト内にある他のパラメータの型推論で使うことはできない。だが、異なるパラメータリストにおいては、先行するパラメータリストで推論された型を後続のパラメータリストで利用することができる。関数パラメータを seed とは別のパラメータリストに分けることで、seed から推論された A の型を、関数パラメータの入力型の推論に使用できるようにしている。

さらに、代数的データ型を分解するメソッドであるデストラクタ(destructor)もいくつか宣言した。今回の MyList では、headtail、および述語 isEmpty がデストラクタである。これらについては後ほど詳しく説明する。

以下のコードから始めよう。

enum MyList[A] {
  case Empty()
  case Pair(_head: A, _tail: MyList[A])

  def isEmpty: Boolean =
    this match {
      case Empty() => true
      case _       => false
    }
    
  def head: A =
    this match {
      case Pair(head, _) => head
    }
    
  def tail: MyList[A] =
    this match {
      case Pair(_, tail) => tail
    }
}
object MyList {
  def unfold[A, B](seed: A)(stop: A => Boolean, f: A => B, next: A => A): MyList[B] =
    if stop(seed) then MyList.Empty()
    else MyList.Pair(f(seed), unfold(next(seed))(stop, f, next))
}

まずはリストのコンストラクタである filliterate を、そしてその後に map を、unfold を使って定義する。コンストラクタのほうがシンプルなので、まずはそちらから取り組もうという意図である。

object MyList {
  def unfold[A, B](seed: A)(stop: A => Boolean, f: A => B, next: A => A): MyList[B] =
    if stop(seed) then MyList.Empty()
    else MyList.Pair(f(seed), unfold(next(seed))(stop, f, next))
    
  def fill[A](n: Int)(elem: => A): MyList[A] =
    ???
    
  def iterate[A](start: A, len: Int)(f: A => A): MyList[A] =
    ???
}

ここでは、List のドキュメントからそのまま取ってきたメソッドの骨格だけを追加している。これらのメソッドを実装するにあたっては、次のふたつの戦略のいずれかが利用可能である。

順に見ていこう。

unfold のパラメータが、Java などの言語で for ループを作る際に必要なものとほぼ一致していることに気づいた人もいるかもしれない。典型的な for(i = 0; i < n; i++) 形式の for ループには、次の4つの要素がある。

  1. ループカウンタの初期値
  2. ループの停止条件
  3. カウンタを進めるためのステートメント
  4. カウンタを使用するループの本体

これらはそれぞれ unfold のパラメータである seedstopnextf に対応している。

ループの変化条件と不変条件について意識することは、命令型ループが何を行っているのかを把握するための標準的な方法である。ループの処理内容を読み解く方法についてはすでに知っているものとし(ここで使っている用語には馴染みがないかもしれないが)、ここでは詳しく説明しない。代わりに、ふたつ目の推論戦略について説明する。それは、unfold の書き方を、我々がすでに学んだ構造的再帰と関連づける方法である。

最初のステップは、自然数(ここでは0以上の整数と定義する)が概念的には代数的データ型であるという点に着目することである。もっとも、Scala においては自然数は Int を使って表現されるし、代数的データ型とは言えないが。あるひとつの自然数は次のいずれかである。

これは直和型と直積型の両方からなるもっともシンプルな代数的データ型である。

これを理解すると、構造的再帰の推論ツールを使って、unfold に渡すパラメータを作成することができる。どういうことか fill の例で説明しよう。パラメータ n は作成するリストの要素数を示している。パラメータ elem はリストの要素を生成するため、各要素に対して一度ずつ呼び出される。ここでは、これを自然数に対する構造的再帰として考える。nseed とし、stop には x => x == 0 という関数を渡す。これらは自然数に対する構造的再帰における標準的な条件である。next はどうすればよいだろうか。自然数の定義に従い再帰的に1を引けばよいので、nextx => x - 1 となる。最後に f だが、これは fill がどのように動作すべきかという定義から導かれる。fillelem を使って値を作成するので、f はシンプルに _ => elem となる。

object MyList {
  def unfold[A, B](seed: A)(stop: A => Boolean, f: A => B, next: A => A): MyList[B] =
    if stop(seed) then MyList.Empty()
    else MyList.Pair(f(seed), unfold(next(seed))(stop, f, next))
    
  def fill[A](n: Int)(elem: => A): MyList[A] =
    unfold(n)(_ == 0, _ => elem, _ - 1)
    
  def iterate[A](start: A, len: Int)(f: A => A): MyList[A] =
    ???
}

この実装が意図どおりに動作するか確認しておこう。確認には List.fill と比較すればよい。

List.fill(5)(1)
// res22: List[Int] = List(1, 1, 1, 1, 1)
MyList.fill(5)(1)
// res23: MyList[Int] = MyList(1, 1, 1, 1, 1)

次に示すのは、状態をもつ関数を elem として用いて昇順の数値リストを作成する、すこし複雑な確認例である。まず状態を定義し、それを使用する関数を定義する。

var counter = 0
def getAndInc(): Int = {
  val temp = counter
  counter = counter + 1
  temp 
}

これを fill メソッドに渡せば、昇順の数値リストが得られるはずである。

List.fill(5)(getAndInc())
// res24: List[Int] = List(0, 1, 2, 3, 4)
counter = 0
MyList.fill(5)(getAndInc())
// res26: MyList[Int] = MyList(0, 1, 2, 3, 4)

演習: MyList への iterate の実装

fill の時と同じ考え方を使って iterate を実装せよ。iterate は、カウンタの値と型 A の値というふたつの情報を保持する必要があるため、fill よりもすこし複雑になる。

object MyList {
  def unfold[A, B](seed: A)(stop: A => Boolean, f: A => B, next: A => A): MyList[B] =
    if stop(seed) then MyList.Empty()
    else MyList.Pair(f(seed), unfold(next(seed))(stop, f, next))
    
  def fill[A](n: Int)(elem: => A): MyList[A] =
    unfold(n)(_ == 0)(_ => elem, _ - 1)
    
  def iterate[A](start: A, len: Int)(f: A => A): MyList[A] =
    unfold((len, start)){
      (len, _) => len == 0,
      (_, start) => start,
      (len, start) => (len - 1, f(start))
    }
}

解答は以上である。これも動作確認をしておこう。

List.iterate(0, 5)(x => x - 1)
// res27: List[Int] = List(0, -1, -2, -3, -4)
MyList.iterate(0, 5)(x => x - 1)
// res28: MyList[Int] = MyList(0, -1, -2, -3, -4)

演習: MyList への map の実装

mapunfold を用いて再実装せよ。なお、これを実装するにはデストラクタを利用する必要がある。

def map[B](f: A => B): MyList[B] =
  MyList.unfold(this)(
    _.isEmpty,
    pair => f(pair.head),
    pair => pair.tail
  )
List.iterate(0, 5)(x => x + 1).map(x => x * 2)
// res29: List[Int] = List(0, 2, 4, 6, 8)
MyList.iterate(0, 5)(x => x + 1).map(x => x * 2)
// res30: MyList[Int] = MyList(0, 2, 4, 6, 8)

ここで、デストラクタについて簡単に見ておこう。デストラクタはふたつのことを行う。

  1. 直和型のバリアントを区別する
  2. 区別したそれぞれの直積型からプロパティを取り出す

MyList の場合、最小限のデストラクタは、Pair から Empty を区別する isEmpty と、そして headtail である。プロパティを取り出すメソッド(extractor)は部分関数になる。これは Scala とは無関係な概念上の話である。それらは特定の直積型に対してだけ定義されており、それ以外のケースで用いると例外をスローする。先ほど fill に渡した関数が、まさに自然数に対するデストラクタであったことに気付いた人もいるかもしれない。

デストラクタは構造的再帰と構造的余再帰の間にある双対性のひとつの側面である。構造的再帰がやることは以下のとおりだが、

一方、構造的余再帰は以下のことを行う。

unfold の話を終える前にもうひとつだけ述べておきたい。一般的な unfold の定義を見ると、おそらく以下のような定義が見つかるだろう。

def unfold[A, B](in: A)(f: A => Option[(A, B)]): List[B]

これは、我々が用いた定義と等価だが、インターフェースはややコンパクトである。我々の定義は、メソッドの構造を明確にするために、より明示的なものとなっている。

2.5 代数的データ型における代数

ときどき挙がる質問に、代数的データ型はなぜ「代数」と名付けられているのか、というものがある。この点についてすこし触れ、代数的データ型で行える代数的な操作をいくつか紹介したい。

この「代数」という用語は、数学の一分野である抽象代数学の意味で使われている。抽象代数学は、代数的構造を扱う分野である。代数的構造は、ある値の集合、その集合上の操作、およびそれらの操作が満たすべき性質から構成される。

具体例として、整数の集合と、加法や乗法という操作、そして結合律 a + (b+c) = (a+b) + c などといった操作の性質が挙げられる。抽象代数学の「抽象」とは、整数のような具体的な集合を扱うのではなく、代わりに半群、モノイド、環といった奇妙な名前のついた抽象的な概念を扱うことを意味している(具体的であれば理解するのは極めて簡単なのだが)。上述の整数の話は環の一例である。これから、そのような概念をたくさん見ていくことになるだろう。

代数的データ型も、環と呼ばれる代数的構造に対応している。環はふたつの演算をもち、通常それらは + および × と記述される。これらがそれぞれ直和型と直積型に対応していると推測したなら、まさにそのとおりである。では、環におけるこれらの演算の性質はどのようなものだろうか。それらは、基本的な代数について我々が知っているものと似ている。

かなり抽象的な話になってしまったので、ここからは Scala による実例を使って具体化してみよう。

Remember the algebraic data types work with types, so the operations + and × take types as parameters. So Int × String is equivalent to

代数的データ型は型を取り扱うものなので、演算 +× も型を引数として受け取る。たとえば、Int × String は次のように表される。

final case class IntAndString(int: Int, string: String)

いちいち命名せずにタプルを使うという手もある。

type IntAndString = (Int, String)

+ という演算に対しても同じような表現が可能である。 Int + String は次のように表される。

enum IntOrString {
  case IsInt(int: Int)
  case IsString(string: String)
}

もしくは単に以下のように表現してもかまわない。

type IntOrString = Either[Int, String]

演習: 単位元

直積型における単位元 1 に対応する Scala の型は何か考察せよ。

直積型の単位元は Unit 型である。ある直積型に Unit 型のフィールドを追加しても、何も情報を追加したことにはならない。 Int 型がもつ情報量は、 Int × Unit 型、Scala のタプルとして書くなら (Int, Unit) 型と同じである。

また、直和型における単位元 0 に対応する Scala の型は何か考察せよ。

直和型の単位元は Nothing 型である。直積型のときと同様に Nothing 型のバリアントは何も情報を追加しない。この型の値を生成することすらできない。

代数的データ型における分配律はどのようなものだろうか。この法則を使うと、代数的データ型を操作して、等価だがより使いやすい表現を作成できることがある。その例として、ユーザを表すデータ型を考えてみよう。

final case class Person(name: String, permissions: Permissions)
enum Permissions {
  case User
  case Moderator
}

これを数学的に表記すると次のようになる。

Person = String × Permissions Permissions = User + Moderator

Permissions をその定義で置き換えると次のようになり、

Person = String × (User+Moderator)

さらに分配法則を適用すると結果は次のようになる。

Person = (String×User) + (String×Moderator)

これは Scala では次のように表現される。

enum Person {
  case User(name: String)
  case Moderator(name: String)
}

これが元の定義よりも有用かどうかは、データがどういうコンテキストで使われるのかわからないかぎり何とも言えないが、このような操作が可能かつ正しいものであり、役に立ちうることは間違いないだろう。

代数的データ型について言えることは他にもたくさんあるが、やや深入りしすぎてしまったようである。最後にいくつか興味深いトピックを簡単に紹介して締めくくりたい。

2.6 まとめ

この章では多くの内容を扱った。ここで重要なポイントを振り返っておこう。

代数的データ型を使えば、既存の型を論理積と論理和で組み合わせることによって別のデータの型を表現することができる。論理積は直積型を構築し、論理和は直和型を構築する。代数的データ型は Scala でデータを表現する主要な方法である。

構造的再帰は、与えられた代数的データ型を他の任意の型に変換するための骨組みを提供する。構造的再帰は fold メソッドとして抽象化することができる。

構造的再帰における問題固有の部分を完成させるのに役立つ考え方の原則がいくつかある。

  1. 各ケースを独立して推論すること
  2. 再帰呼び出しの結果は正しいと仮定すること
  3. 型に従うこと

型に従うことは他の多くの状況で利用できる極めて汎用的な戦略である。

構造的余再帰は、任意の型から代数的データ型を作成するための骨組みを提供する。構造的余再帰は unfold メソッドとして抽象化できる。構造的余再帰について推論する際は、命令型のループの処理手順を考えるのと同じように進めるか、あるいは入力が代数的データ型である場合は、構造的再帰についての推論の原則を利用することができる。

関数型プログラミングのふたつの主要なテーマである合成と推論がすでにはっきり認識できる形で示されている点に注目してほしい。代数的データ型は合成的である。直和型と直積型を使って合成することができる。この章では多くの推論原則も見てきた。

代数的データ型について知っておくべきすべてのことを説明したわけではない。これを完全に網羅すれば、それだけで一冊の本になるだろう。さらに掘り下げたい場合に役立つ参考文献や、著者の経歴に関するいくつかの補足を以下に示しておく。

代数的データ型は、関数型プログラミングの入門資料では標準的に取り扱われている。構造的再帰も関数型プログラミングで非常によく使われるが、ここで私が行ったように明確に定義されることは珍しいようである。私はこれらについて How to Design Programs [Felleisen et al. 2018] から学んだ。

代数的データ型や構造的再帰について、手軽にアクセスできつつも詳細に扱った資料は私の知るかぎり存在しない。これらの概念は、プログラミング言語研究の分野ではもはや前提知識とされているようである。最近の研究は数学的で難解な表記に覆われており、私にとっては読みづらいものが多い。悪名高い Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire [Meijer et al. 1991] はその一例である。両アイデアの核となる発想は、少なくとも計算可能性理論が登場した1930年代、デジタルコンピュータが存在するはるか以前にまで遡るのではないかと思われる。

構造的再帰に関して私が見つけたもっとも古い参考文献は、Proving Properties of Programs by Structural Induction [Burstall 1969] である。代数的データ型とパターンマッチングの発展は、1977年の NPL までは十分ではなかったようだが、その後すぐに、より影響力のある言語である Hope が登場し、この概念が他のプログラミング言語にも広まった。

余再帰については現代の文献にすこし詳しく記録されている。How to Design Co-Programs [Gibbons 2021] では、ここで取り上げた主なアイデアが説明されているし、[Gibbons and Jones 1998] では unfold の使い方について議論されている。

The Derivative of a Regular Type is its Type of One-Hole Contexts [McBride 2001] は代数的データ型の微分について記述している。

3 余データとしてのオブジェクト

この章では、代数的データ型の双対である余データについて見ていく。代数的データ型はモノがどのように構築されるかに焦点を当てているのに対して、余データはモノがどのように利用されるかに焦点を当てる。余データは、型に対して実行できる操作を指定することで定義される。これはオブジェクト指向プログラミングにおけるインターフェースの使い方と非常に似ており、これが余データに注目する最初の理由である。オブジェクト指向プログラミングは、余データという概念によって、我々が今議論している戦略と整合性のある概念的枠組みの中に位置づけられる。

しかし、余データに注目するのはオブジェクト指向プログラミングを捉えるためだけではない。余データには、代数的データにはない特性がある。たとえば、余データを使うことで、無限の要素を持つ構造、終わりのないリストや永遠に実行されるサーバーループのようなものを作成することができる。また、余データは拡張性の点でも代数的データとは異なる形をもっている。代数的データ型を別の何かへと変換する関数を新しく作成するのは簡単だが、代数的データ型に新しいバリアントを追加するには既存のコードを変更する必要がある。余データはその逆で、新しい実装を簡単に作成できるが、余データを変換する関数は余データが定義するインターフェースに制約される。

前章では、代数的データ型を使用してプログラムを記述するための戦略として、構造的再帰と構造的余再帰を取り上げた。余データについても同様で、余データを消費するプログラムを書く際には構造的再帰を、生成するプログラムを書く際には構造的余再帰を使用することができる。

まず、余データをより正確に定義し、いくつかの例を見ていこうと思う。次に、Scala における余データの表現方法とオブジェクト指向プログラミングとの関係について説明する。余データの作成方法を理解した後は、無限構造の例を使って、構造的再帰や構造的余再帰を用いた余データの扱い方を学ぶ。その後、代数的データ型と余データの相互変換について考察し、最後に拡張性の違いを検証する。

進める前に用語について簡単に触れておきたい。代数的データの双対としては「代数的余データ」という用語を期待されるかもしれないが、慣例的に単に「余データ」という用語が使われる。これは、データという言葉が、一般的に代数的データに限定されず、より広い意味をもつのに対して、余データはプログラミング言語理論の外では使われないからだと考えられる。この章では、簡潔さと対称性のために、代数的データ型を指す際に「データ」という用語を使用する。

3.1 データと余データ

データはある物が何であるのかを記述し、余データはある物が何をできるのかを記述する。

これまで、データはその型の各バリアントを生成するコンストラクタによって定義されることを見てきた。非常に単純な例として、BoolTrue または False のいずれかで、Scala では次のように表現できる。

enum Bool {
  case True
  case False
}

この定義は、Bool 型の値を構築する方法がふたつあることを示している。さらに、そうやって構築された値があるとき、たとえばパターンマッチを使用することで、どちらのケースであるのかを正確に判断することができる。同様に、たとえば List のようにインスタンス自体がデータを保持している場合、その中のすべてのデータを取り出すことが常に可能である。これもパターンマッチによって実現できる。

対照的に、余データは、その型の値に対して行える操作によって定義される。これらの操作は、デストラクタ(destructor)オブザベーション(observation)、またはエリミネータ(eliminator)と呼ばれることがある。デストラクタは前章にも登場した。余データの一般的な例として、集合のようなデータ構造がある。たとえば、 A 型の要素をもつ Set の操作は次のように定義できる。

Scala ではこの定義を次のように記述できる。

trait Set[A] {
  
  /** 与えられた要素がこの集合に含まれていれば真 */
  def contains(elt: A): Boolean
  
  /** この集合のすべての要素と与えられた要素を含む新しい集合を構築する */
  def insert(elt: A): Set[A]
  
  /** この集合と指定された集合の和集合を構築する */
  def union(that: Set[A]): Set[A]
}

この定義は、集合内の要素が内部的にどのように表現されているかついて何も述べていない。ハッシュテーブルが使われるかもしれないし、木構造やあるいはもっと特殊な何かが使われるかもしれない。だが、集合に対してどういう操作が可能であるかをこの定義は教えてくれる。たとえば、和集合をとることはできても積集合をとることはできない、ということはわかるのである。

オブジェクト指向の世界から来た人であれば、前述の余データの説明をインターフェースを利用したプログラミングと認識するかもしれない。ある意味、余データとはオブジェクト指向の世界から概念を取り入れ、それを関数型プログラミングのパラダイムと一貫した形で提示するものと言える。しかし、これはオブジェクト指向プログラミングのすべての機能を採用するという意味ではない。関数型プログラミングでは可変状態を使用しない。推論が難しくなるからである。同じ理由で実装の継承も使わない。関数型の世界で使われるオブジェクト指向プログラミングのサブセットでは、インターフェースを定義し(いくつかのメソッドにデフォルト実装をもたせてもよい)、そのインターフェースを実装する final クラスを定義する。興味深いことに、このようなオブジェクト指向プログラミングのサブセットは、オブジェクト指向プログラミングの支持者によってもしばしば勧められている。

それでは、余データをもうすこし正確に定義していこう。そうすることで、データと余データの双対性がより明確になるはずである。データの定義を思い出してほしい。データは直和型と直積型を使って定義される。どのようなデータでも「積の和」の形に変換することができる。この和の中の各積がコンストラクタであり、積を構成する各プロパティがコンストラクタの受け取るパラメータとなる。コンストラクタは任意の入力を受け取りデータの値を生成する関数と考えることができ、最終的な形は、任意の入力からデータを生成する関数の直和となる。

あるデータ型 A の値を構築するときは、複数あるコンストラクタのうちのひとつを呼び出す。

一方、余データは関数の積として定義される。これらの関数はデストラクタである。デストラクタへの入力には常に余データ型が含まれ、場合によって他のパラメータも存在する。出力は通常、余データ型ではない何かになる。したがって、余データ型 A の要素を構築するということは、以下のような定義を行うことを意味する。

これで、両者の双対性がより明確になることを期待したい。余データが何であるかは理解できたので、次に Scala における余データの表現に移ろう。

3.2 Scala における余データ

前節で見た余データの例を以下に再掲する。

trait Set[A] {
  
  def contains(elt: A): Boolean
  
  def insert(elt: A): Set[A]
  
  def union(that: Set[A]): Set[A]
}

関数の積として表されるこの抽象的な定義は、A 型の要素をもつ Set を次のような操作をもつものであると定義している。

どの関数も最初のパラメータの型は Set[A] である点に注目してほしい。

Scala に翻訳する方法は以下のとおり。

これにより、本節の最初に見せた Scala の表現が得られる。

Scala における余データの表現についての話はまだ終わりではない。続いて、先ほど定義したインターフェースを実際に実装する必要がある。実装のアプローチは三つある。

  1. final サブクラス。これは実装に名前を付けたい場合に用いる
  2. 匿名のサブクラス
  3. 稀なケースだが、object

final なサブクラスも匿名サブクラスもそれ以上拡張できないため、深い継承階層を作成することはできない。これにより、深い継承階層をもったコードを理解する際に生じる困難を回避できる。また、case class ではなく class を使用することで、コンストラクタ引数などの実装の詳細を公開せずにすむ。

いくつか例を挙げよう。ここでは、List を使って集合の要素を保持する Set 実装の単純な例を示す。

final class ListSet[A](elements: List[A]) extends Set[A] {

  def contains(elt: A): Boolean =
    elements.contains(elt)

  def insert(elt: A): Set[A] =
    ListSet(elt :: elements)

  def union(that: Set[A]): Set[A] =
    elements.foldLeft(that) { (set, elt) => set.insert(elt) }
}
object ListSet {
  def empty[A]: Set[A] = ListSet(List.empty)
}

この例ではひとつ目の実装アプローチとして紹介した final サブクラスを用いている。匿名サブクラスは、余データ型を返すメソッドを実装するときにもっとも便利である。union メソッドを例に考えてみよう。このメソッドは余データ自体の型である Set を返す。これは以下のように実装することができる。

trait Set[A] {
  
  def contains(elt: A): Boolean
  
  def insert(elt: A): Set[A]
  
  def union(that: Set[A]): Set[A] = {
    val self = this
    new Set[A] {
      def contains(elt: A): Boolean =
        self.contains(elt) || that.contains(elt)
        
      def insert(elt: A): Set[A] =
        // self と that どちらに insert してもよい
        self.insert(elt).union(that)
    }
  }
}

この例では、匿名サブクラスを使用して Set トレイトの union を実装している。この定義はすべてのサブクラスから利用できる。メソッドを final にしていないのは、サブクラスがもっと効率的な実装でそれをオーバーライドできるようにするためである。これにより、実装継承に伴う危険性が生じる。これは、実践がいつも原則どおりに行われるわけではないことを示す例である。原則的には実装継承は好まれないが、実際には最適化として有用である場合がある。

デストラクタのみを用いて定義されたユーティリティメソッドを実装することも有用である。たとえば、Iterable コレクション内のすべての要素を contains しているか確認する containsAll メソッドを Set に実装したいとする。

def containsAll(elements: Iterable[A]): Boolean

このメソッドは SetcontaintsIterableforall だけを使って実装できる。

trait Set[A] {
  
  def contains(elt: A): Boolean
  
  def insert(elt: A): Set[A]
  
  def union(that: Set[A]): Set[A]
  
  def containsAll(elements: Iterable[A]): Boolean =
    elements.forall(elt => this.contains(elt))
}

ここでも、メソッドを final にするという選択肢がある。このケースでは、より効率的な実装を想像するのは難しいため、final にすることは先ほどの例より正当であると言えるだろう。

データと余データは、Scala においては、クラスとオブジェクトというひとつの言語機能の別の側面として実現されている。これは、データと余データ両方の特性をもつ型を定義できることを意味する。実際、我々はすでにこれを行っている。データを定義する際には、データ内のフィールドの名前を定義しなければならず、それによってデストラクタが定義される。これは、データと余データの間に明確な区別を設けていないほとんどの言語と同じである。

クラスとオブジェクトの魅力の一端は、ひとつの言語構造を用いて非常に多くの概念的に異なる抽象を表現できる点にあると考えている。このことは、クラスやオブジェクトを表面的には単純に見せ、ひとつの抽象さえ学べば多くのコーディングに関する問題を解決できるかのように感じさせる。だが、この見かけ上の単純さは実際の複雑さを隠している。同じ言語構造がさまざまな用途に使われるせいで、コードから概念的な意図を逆解析する必要が生じるのである。

3.3 余データの構造的再帰と余再帰

この節では、遅延リストとしても知られているストリームを扱うライブラリを作成する。ストリームはリストの余データに相当する。リストの長さが有限でなくてはならないのに対して、ストリームは無限の長さをもっている。この例を用いて、余データに適用される構造的再帰と構造的余再帰について考察する。

まずは構造的再帰と構造的余再帰について復習しておこう。構造的再帰では入力の型を、構造的余再帰では出力の型をそれぞれ用いてメソッドの記述プロセスを進めるというのが、その基本となるアイデアである。これらがデータに対してどのように動作するのかはすでに見たとおりで、そこでは構造的再帰のほうがより重要であった。余データにおいては構造的余再帰のほうが頻繁に使用される。構造的余再帰を使用するためのステップは以下のとおりである。

  1. メソッドや関数の出力が余データであることを認識する
  2. 余データ型のインスタンスを構築する骨組みを記述する。通常は匿名サブクラスを使用する
  3. 構造的再帰などの戦略を用いるか、型に従いながら、メソッドの本体を完成させる

計算はすべてメソッド内で行われ、メソッド呼び出し時にのみ実行されることが重要である。ストリームを作成し始めると、この重要性が明確になるだろう。

構造的再帰の手順は以下のとおりである。

  1. メソッドや関数の入力が余データであることを認識する
  2. メソッドを書くにあたり、余データのデストラクタが値の供給源であることに留意する
  3. 型追従や構造的余再帰といった戦略を用いたり、上記で特定したデストラクタを使ったりして、メソッドを完成させる

最初のステップはストリーム型を定義することである。ストリームは余データなので、デストラクタの集まりとして定義される。A 型の要素をもつ Stream を定義するデストラクタは以下のとおりである。

これらが List のデストラクタとほぼ同じである点に注目しよう。デストラクタとして isEmpty を定義していないのは、ここで作成しようとしているストリームに終わりがなく、isEmpty があったとしてもそれは常に false を返すことになるからである。なお、Scala 標準ライブラリの LazyList など多くの実例においては isEmpty のようなメソッドが定義されており、有限と無限両方のリストを同じ構造で表現することが可能となっている。ここでは、余データをもっとも端的な形で定義したいので、シンプルさを優先し、そのようなことはしない。

上記の定義を Scala に翻訳すると、これまでにも見てきたように、次のようになる。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
}

これで、Stream インスタンスを作成できる。ここでは、永遠に 1 という数を返し続けるストリームを作成してみよう。以下の骨組みから始め、戦略をいくつか適用してコードを完成させる。

val ones: Stream[Int] = ???

最初の戦略は構造的余再帰である。余データのインスタンスを返したいのだから、 Stream インスタンス構築の骨組みを挿入すればよい。

val ones: Stream[Int] =
  new Stream[Int] {
    def head: Int = ???
    def tail: Stream[Int] = ???
  }

ここでは、匿名サブクラスのアプローチを使い、すべてのコードを一か所にまとめて書けるようにしている。

次のステップはメソッド本体を埋めることである。最初のメソッドである head は簡単で、定義により答えは 1 である。

val ones: Stream[Int] =
  new Stream[Int] {
    def head: Int = 1
    def tail: Stream[Int] = ???
  }

tail をどのように実装すればよいかは自明とは言えない。Stream[Int] 型の値を返したいのだから、もう一度構造的余再帰の戦略を適用することが考えられる。

val ones: Stream[Int] =
  new Stream[Int] {
    def head: Int = 1
    def tail: Stream[Int] =
      new Stream[Int] {
        def head: Int = 1
        def tail: Stream[Int] = ???
      }
  }

だが、このアプローチがうまく行くとは思えない。この方法でメソッドを正しく実装するには同じことを無限に書き続けなくてはならない。

代わりに、型に従って考えてみよう。Stream[Int] 型を返す必要があるが、スコープ内にはその型の変数である ones がすでに存在する。これこそがまさに、返すべき無限の 1 のストリームである。

val ones: Stream[Int] =
  new Stream[Int] {
    def head: Int = 1
    def tail: Stream[Int] = ones
  }

tailones への循環参照を見て驚くかもしれないが、これは問題ない。この参照はメソッド内にあり、メソッドが呼び出されたときにのみ評価される。原理上は無限でも、実際にはその限られた部分しか評価されない。この評価の遅延こそが、無限の要素を表現できる理由である。これは、データのように構築時にすべてが評価されるものとは根本的に異なる点である。

この ones の定義が確かに動作することを確認しておこう。無限のストリームからすべての要素を取り出すことはできない(少なくとも有限の時間内では)。したがって、現実的には有限の要素列を確認することに頼らざるを得ない。

ones.head
// res26: Int = 1
ones.tail.head
// res27: Int = 1
ones.tail.tail.head
// res28: Int = 1

これらはすべて正しいようである。動作確認にはこの方法を使うことが多いので、これをもっと簡単にしてくれるメソッド take を実装しよう。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
  
  def take(count: Int): List[A] =
    count match {
      case 0 => Nil
      case n => head :: tail.take(n - 1)
    }
}

take の実装には、データに対する構造的再帰か構造的余再帰いずれかの戦略を利用できる。その手順については既に詳しく説明しているので、ここでは触れない。重要なのは、take がデストラクタのみを通じて Stream とやり取りしているということである。

これで、実装が正しいことをもっと簡単に確認できるようになる。

ones.take(5)
// res30: List[Int] = List(1, 1, 1, 1, 1)

次の課題として map を実装する。Stream にメソッドを実装することで、余データにおける構造的再帰と構造的余再帰の両方を実際に確認することができる。いつものようにメソッドの骨組みを書き出すところから始める。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
  
  def map[B](f: A => B): Stream[B] = 
    ???
}

利用する戦略を決めなくてはならない。まだ構造的再帰を使っていないので、まずはそれを試してみよう。入力は余データ Stream である。構造的再帰戦略ではデストラクタを用いることを検討すべきとされているので、リマインドの意味でデストラクタ呼び出しを書き出しておこう。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
  
  def map[B](f: A => B): Stream[B] = {
    this.head ???
    this.tail ???
  }
}

さらに実装を進めるのに、型に従う手法か構造的余再帰を利用できる。構造的余再帰の使用例をもうひとつ見てみたいので、そちらを選択することにしよう。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
  
  def map[B](f: A => B): Stream[B] = {
    this.head ???
    this.tail ???
    
    new Stream[B] {
      def head: B = ???
      def tail: Stream[B] = ???
    }
  }
}

これで構造的再帰と構造的余再帰の戦略を使ったので、次は型に従う方法をすこし試してみよう。すぐに正しい解答にたどり着くことができる。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
  
  def map[B](f: A => B): Stream[B] = {
    val self = this 
    new Stream[B] {
      def head: B = f(self.head)
      def tail: Stream[B] = self.tail.map(f)
    }
  }
}

重要な点がふたつある。まず見てほしいのは、thisself という名前を与えたことである。作成しようとしている新しい Stream オブジェクトの中では this はその新しいストリーム自体を指すので、その中で元の Stream オブジェクトを参照するにはこれが必要となる。次に、self.headself.tail へのアクセスが、新しい Stream に定義されたメソッドの中で行われている点に注目してほしい。これによって、必要とされたときにのみ計算を行うという正しいセマンティクスが維持される。メソッド外で計算を行うと、必要以上に早く評価されてしまい、場合によっては無限ループにつながることもある。

フォーカスを Stream の構築に戻そう。最後の例として、汎用的なコンストラクタである unfold を実装する。seed パラメータについても考慮しつつ、まずは unfold の骨組みから始める。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
}
object Stream {
  def unfold[A, B](seed: A): Stream[B] =
    ???
}

構造的余再帰を適用して実装を進めるのが自然だろう。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
}
object Stream {
  def unfold[A, B](seed: A): Stream[B] =
    new Stream[B]{
      def head: B = ???
      def tail: Stream[B] = ???
    }
}

次に、型に従って、必要に応じたパラメータを追加する。これにより、メソッドは以下のとおり完成する。

trait Stream[A] {
  def head: A
  def tail: Stream[A]
}
object Stream {
  def unfold[A, B](seed: A, f: A => B, next: A => A): Stream[B] =
    new Stream[B]{
      def head: B = 
        f(seed)
      def tail: Stream[B] = 
        unfold(next(seed), f, next)
    }
}

これを使って、おもしろいストリームを実装できる。以下に示すのは 1-1 が交互に現れるストリームである。

val alternating = Stream.unfold(
  true, 
  x => if x then 1 else -1, 
  x => !x
)

動作を確認しておこう。

alternating.take(5)
// res37: List[Int] = List(1, -1, 1, -1, 1)

演習: Stream のコンビネータ実装

このあたりで、余データを用いた構造的再帰と構造的余再帰について練習をしておこう。Streamfilterzip、および scanLeft メソッドを実装せよ。いずれも List における同名のメソッドと同じセマンティクスをもっているものとし、シグネチャは以下のとおりとする。

trait Stream[A] {
  def head: A
  def tail: Stream[A]

  def filter(pred: A => Boolean): Stream[A]
  def zip[B](that: Stream[B]): Stream[(A, B)]
  def scanLeft[B](zero: B)(f: (B, A) => B): Stream[B]
}

いずれのメソッドの実装においても、構造的余再帰がもっとも自然なアプローチだろう。だが、構造的再帰から始めることも可能である。

filter の非効率さが気になるかもしれないが、それについては後ほどすこし説明する。

trait Stream[A] {
  def head: A
  def tail: Stream[A]

  def filter(pred: A => Boolean): Stream[A] = {
    val self = this
    new Stream[A] {
      def head: A = {
        def loop(stream: Stream[A]): A =
          if pred(stream.head) then stream.head
          else loop(stream.tail)
          
        loop(self)
      }
      
      def tail: Stream[A] = {
        def loop(stream: Stream[A]): Stream[A] =
          if pred(stream.head) then stream.tail.filter(pred)
          else loop(stream.tail)
          
        loop(self)
      }
    }
  }

  def zip[B](that: Stream[B]): Stream[(A, B)] = {
    val self = this 
    new Stream[(A, B)] {
      def head: (A, B) = (self.head, that.head)
      
      def tail: Stream[(A, B)] =
        self.tail.zip(that.tail)
    }
  }

  def scanLeft[B](zero: B)(f: (B, A) => B): Stream[B] = {
    val self = this
    new Stream[B] {
      def head: B = zero
      
      def tail: Stream[B] =
        self.tail.scanLeft(f(zero, self.head))(f)
    }
  }
}

上記で定義したメソッドを使うと、クールなことができる。たとえば、次に示すのは自然数のストリームである。

val naturals = Stream.ones.scanLeft(0)((b, a) => b + a)

いつもどおり、動作確認をしておこう。

naturals.take(5)
// res41: List[Int] = List(0, 1, 2, 3, 4)

unfold を用いて naturals を定義することもできる。もっと興味深いのは、naturals 自身を使って naturals を定義できることである。

val naturals: Stream[Int] =
  new Stream {
    def head = 1
    def tail = naturals.map(_ + 1)
  }

これはややこしいかもしれないが、もしそう思ったらすこし時間をとって考えてみてほしい。この実装はちゃんと動作するのである。

naturals.take(5)
// res43: List[Int] = List(1, 2, 3, 4, 5)

3.3.1 効率と副作用

上記の実装において、値を場合によっては何度も繰り返し再計算していることに気付いたかもしれない。filter の実装がそのよい例である。headtail は呼び出すたびに再計算を行うため、非常にコストの高い操作になる可能性がある。

def filter(pred: A => Boolean): Stream[A] = {
  val self = this
  new Stream[A] {
    def head: A = {
      def loop(stream: Stream[A]): A =
        if pred(stream.head) then stream.head
        else loop(stream.tail)
        
      loop(self)
    }
    
    def tail: Stream[A] = {
      def loop(stream: Stream[A]): Stream[A] =
        if pred(stream.head) then stream.tail.filter(pred)
        else loop(stream.tail)
        
      loop(self)
    }
  }
}

メソッドが呼び出されるまで計算を遅延することの重要性はすでに述べた。それによって無限の、そして自己参照的なデータを扱うことができるからである。だが、そのメソッドが連続して呼び出されるときに計算をやり直す必要はない。最初の呼び出し時に結果をキャッシュしておけば、次回からはそれを使用できる。Scala では lazy val を使うことでこれを簡単に実現できる。lazy val は、最初に呼び出されるまで評価されない val である。さらに、Scala は統一形式アクセスの原則を採用しており、引数をもたないメソッドは lazy val を使って実装することができる。以下はその使用方法を示す簡単な例である。

def always[A](elt: => A): Stream[A] =
  new Stream[A] {
    lazy val head: A = elt
    lazy val tail: Stream[A] = always(head)
  }
  
val twos = always(2)

いつもどおり動作確認をしておこう。

twos.take(5)
// res44: List[Int] = List(2, 2, 2, 2, 2)

メソッドと lazy val どちらを使っても同じ結果が得られる。可変状態に依存しない純粋な計算のみを扱っている想定だからである。そのような場合において、lazy val はメモリを追加で消費する代わりに時間を節約する役割を果たす。

必要になるたびに結果を再計算する方式は名前渡し(call by name)と呼ばれ、最初に計算された結果をキャッシュしておく方式は必要渡し(call by need)と呼ばれる。このふたつの異なる評価戦略は、今回のように個々の値に適用されることもあるし、プログラム全体にわたって適用されることもある。例として、Haskell では必要渡しが用いられており、すべての値はそれが必要となる最初のタイミングでのみ計算される。このアプローチは遅延評価と呼ばれることもある。値渡し(call by value)と呼ばれる別の方式では、結果が必要とされるのを待たず、定義された時点で計算される。Scala はデフォルトでこの方式をとる。

純粋でない計算を使って、名前渡しと必要渡しの違いを示すことができる。たとえば、乱数のストリームを定義してみよう。乱数生成器は内部状態に依存している。

以下は、名前渡しを用いた実装である。実装には本書でこれまでに定義したメソッドを用いている。

import scala.util.Random

val randoms: Stream[Double] = 
  Stream.unfold(Random, r => r.nextDouble(), r => r)

Stream の一部を take するたびに、異なる結果が得られることに注目してほしい。これらの結果は本来同じであることが期待される。

randoms.take(5)
// res45: List[Double] = List(
//   2.2924799822798825E-5,
//   0.03381951041268916,
//   0.2005697625333388,
//   0.6090609303314423,
//   0.8407442035068102
// )
randoms.take(5)
// res46: List[Double] = List(
//   0.44854748878414485,
//   0.6259186751000002,
//   0.8593839485682416,
//   0.980693092386352,
//   0.6800212819857074
// )

次に、lazy val を使い、必要渡しスタイルで同じストリームを定義してみよう。

val randomsByNeed: Stream[Double] =
  new Stream[Double] {
    lazy val head: Double = Random.nextDouble()
    lazy val tail: Stream[Double] = randomsByNeed
  }

今回は、ストリームの一部を take したときに得られる結果は同じになる。ただし、どの数もすべて同じになってしまう。

randomsByNeed.take(5)
// res47: List[Double] = List(
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994
// )
randomsByNeed.take(5)
// res48: List[Double] = List(
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994,
//   0.4960531325200994
// )

要素ごとに異なる乱数をもちつつ、それぞれの値は固定値であるようなストリームを作りたい場合、遅延評価を用いるように unfold を再定義する必要がある。

def unfoldByNeed[A, B](seed: A, f: A => B, next: A => A): Stream[B] =
  new Stream[B]{
    lazy val head: B = 
      f(seed)
    lazy val tail: Stream[B] = 
      unfoldByNeed(next(seed), f, next)
  }

unfoldByNeed を使って randomsByNeed を再定義することで、求めている結果を得ることができる。下記のとおり再定義を行い、動作を確認しよう。

val randomsByNeed2 =
  unfoldByNeed(Random, r => r.nextDouble(), r => r)
randomsByNeed2.take(5)
// res49: List[Double] = List(
//   0.37666168899861174,
//   0.9405825156798319,
//   0.5628726849362917,
//   0.0924808960507496,
//   0.6846216853892201
// )
randomsByNeed2.take(5)
// res50: List[Double] = List(
//   0.37666168899861174,
//   0.9405825156798319,
//   0.5628726849362917,
//   0.0924808960507496,
//   0.6846216853892201
// )

これらの微妙な問題は、関数型プログラマが可能なかぎり状態を使用しないよう努める理由のひとつである。

3.4 データと余データの関係

この節では、データと余データの関係、特にそれらの間の相互変換について探求する。まずは両者の表面的な関係を確認し、その後、fold を通じた深い結びつきを見ていく。

データは積の和で構成されており、その積の部分がコンストラクタとなることを思い出してほしい。コンストラクタは関数として見ることができるので、データは関数の和として捉えることができる。一方、余データは関数の積である。コンストラクタとしての関数と余データにおける関数の間には、直接的な対応関係を簡単に見出すことができる。では、和と積の違いはどうだろうか。関数の積があるとき、コードのある場所において呼び出すことのできる関数はどれかひとつだけである。つまり、論理和はどの関数を呼び出すかという選択のなかに存在する。

そのことを、データの代表例である List を使って見てみよう。List は代数的データ型として次のように定義できる。

enum List[A] {
  case Pair(head: A, tail: List[A])
  case Empty()
}

余データに相当するものは以下のとおりである。

trait List[A] {
  def pair(head: A, tail: List[A]): List[A]
  def empty: List[A]
}

この余データの定義では、コンストラクタを明示的にメソッドとして表現し、コンストラクタの選択を呼び出し側に委ねている。この関係性が役に立つ場面をすこし先の章で見ることになるが、今はその話題から離れ、次に進むことにする。

両者の関係を見るもうひとつの方法は fold を通じたつながりである。任意の代数的データ型に対して fold を導出する方法はすでに学んだ。たとえば、次のように定義される Bool について考えてみよう。

enum Bool {
  case True
  case False
}

fold メソッドは以下のように実装される。

enum Bool {
  case True
  case False
  
  def fold[A](t: A)(f: A): A =
    this match {
      case True => t
      case False => f
    }
}

知ってのとおり、fold には、どんなメソッドでもこれを用いて記述できるという汎用性がある。それゆえ fold は汎用デストラクタでもあり、データを余データとして扱うための鍵でもある。Bool において fold は、我々が普段は if 式と呼び、頻繁に使っているものである。

以下は、Bool の余データバージョンである。foldif にリネームした( Scala では、if のようなキーワードと同名のメソッドを定義できるが、キーワードを識別子として利用する際にはバッククォートで囲む必要がある)。

trait Bool {
  def `if`[A](t: A)(f: A): A
}

次に、Bool のふたつのインスタンスを純粋に余データとして定義する。

val True = new Bool {
  def `if`[A](t: A)(f: A): A = t
}

val False = new Bool {
  def `if`[A](t: A)(f: A): A = f
}

この if メソッドを実際に使ってみよう。if を用いて and を定義し、その使用例をいくつか示す。

def and(l: Bool, r: Bool): Bool =
  new Bool {
    def `if`[A](t: A)(f: A): A =
      l.`if`(r)(False).`if`(t)(f)
  }

以下がその例である。and は単純なので、真理値表のすべての組み合わせを試すことができる。

and(True, True).`if`("yes")("no")
// res15: String = "yes"
and(True, False).`if`("yes")("no")
// res16: String = "no"
and(False, True).`if`("yes")("no")
// res17: String = "no"
and(False, False).`if`("yes")("no")
// res18: String = "no"

演習: Bool 余データへの ornot の実装

Bool 余データについての理解度を確認するため、上記の and を実装したのと同じ方法で ornot を実装せよ。

and の構造に倣って実装することができる。

def or(l: Bool, r: Bool): Bool =
  new Bool {
    def `if`[A](t: A)(f: A): A =
      l.`if`(True)(r).`if`(t)(f)
  }

def not(b: Bool): Bool =
  new Bool {
    def `if`[A](t: A)(f: A): A =
      b.`if`(False)(True).`if`(t)(f)
  }

これらについても、真理値表のすべての組み合わせを確認しておこう。

or(True, True).`if`("yes")("no")
// res19: String = "yes"
or(True, False).`if`("yes")("no")
// res20: String = "yes"
or(False, True).`if`("yes")("no")
// res21: String = "yes"
or(False, False).`if`("yes")("no")
// res22: String = "no"

not(True).`if`("yes")("no")
// res23: String = "no"
not(False).`if`("yes")("no")
// res24: String = "yes"

ここでもやはり、計算は要求されたときにだけ実行されるという点に注目してほしい。これらの例では、if が実際に呼び出されるまで何も起こらない。その時点までは、実行したい処理の表現を組み立てているだけである。余データがいかにして無限のデータを扱うか、実際に必要な有限の量だけが計算される仕組みによってそれが実現されている、ということが、ここでも示されている。

データから余データに変換する際のルールを以下に挙げる。

  1. 余データを定義するインターフェース(trait)上に、fold と同シグネチャのメソッドを定義する
  2. データにおける積のそれぞれについて、上記インターフェースの実装を定義する。データのコンストラクタ引数は、余データの実装クラスにおけるコンストラクタ引数となる。Bool のようにコンストラクタが引数をもたない場合は、クラスの代わりに値を定義すればよい
  3. 実装クラスはそれぞれ、データにおける fold の対応するケースを実装する

もうすこし複雑な例として、これらのルールを List に適用してみよう。まずは List をデータとして定義し、そこに fold を実装するところから始める。ここでは foldRight を実装することにしたが、foldLeft を選んでも構わない。

enum List[A] {
  case Pair(head: A, tail: List[A])
  case Empty()
  
  def foldRight[B](empty: B)(f: (A, B) => B): B =
    this match { 
      case Pair(head, tail) => f(head, tail.foldRight(empty)(f))
      case Empty() => empty
    }
}

次にこれを余データとして実装しよう。fold メソッドをもったインターフェースを定義するところから始める。ただし、この fold は先ほど定義した foldRight をそのまま移植したものとなるので、ここでは foldRight と呼ぶことにする。

trait List[A] {
  def foldRight[B](empty: B)(f: (A, B) => B): B
}

次は実装クラスを定義する。データとしての List の定義がもつふたつのケースに従い、PairEmpty それぞれのための実装クラスが必要である。実装クラス Pair は、データ側の直和型 Pair に対応するコンストラクタ引数をもつことに留意してほしい。

final class Pair[A](head: A, tail: List[A]) extends List[A] {
  def foldRight[B](empty: B)(f: (A, B) => B): B =
    ???
}

final class Empty[A]() extends List[A] {
  def foldRight[B](empty: B)(f: (A, B) => B): B =
    ???
}

最後に、前段では実装しなかった foldRight のボディ部について見ていく。余データにおけるこの実装は、データにおける foldRight をそのまま移植したものなので、そのボディ部を導き出すのにも同じ戦略を用いることができる。具体的には、再帰におけるルール、ケース別の推論、型追従がそれにあたるが、それらの戦略の詳細はすでに見てきたのでここでは触れない。最終的なコードは以下のとおりである。

final class Pair[A](head: A, tail: List[A]) extends List[A] {
  def foldRight[B](empty: B)(f: (A, B) => B): B =
    f(head, tail.foldRight(empty)(f))
}

final class Empty[A]() extends List[A] {
  def foldRight[B](empty: B)(f: (A, B) => B): B =
    empty
}

このコードは、動的ディスパッチを学んだときの実装とほとんど同じである。これもやはり、余データとオブジェクト指向コードとの関係を示している。

データから余データへの変換は、再関数化(refunctionalization)チャーチエンコーディング(Church encoding)ボーム・ベラルドゥッチ・エンコーディング(Böhm-Berarducci encoding)などいくつかの名前で呼ばれる。うしろふたつの用語は、それぞれ型なしラムダ計算および型付きラムダ計算への変換を指している。ラムダ計算とは関数のみで構成されるシンプルな計算モデルであり、プログラミング言語である。ここですこし本題から脱線し、リストがたしかに関数だけを使った形にエンコード可能であることを確認したい。このことは、オブジェクトと関数が同等の表現力をもっていることを示している。

まず、型エイリアスを用いて、Listfold 関数型として定義する。この定義には Scala3 で導入された多相的関数型を用いる。型シグネチャを見れば、これが前述の foldRight と同じであることがわかるだろう。

type List[A, B] = (B, (A, B) => B) => B

さらに、PairEmpty を関数として定義することができる。コンストラクタ引数だったものが最初のパラメータリストになり、foldRight の引数がふたつ目のパラメータリストになる。

val Empty: [A, B] => () => List[A, B] = 
  [A, B] => () => (empty, f) => empty

val Pair: [A, B] => (A, List[A, B]) => List[A, B] =
  [A, B] => (head: A, tail: List[A, B]) => (empty, f) => 
    f(head, tail(empty, f))

最後に、これが動作することを示す使用例を見てみよう。まず、123 という要素が格納されたリストを定義する。多相的関数型における制約のため、無意味な空のパラメータを追加しなければならない。

val list: [B] => () => List[Int, B] = 
  [B] => () => Pair(1, Pair(2, Pair(3, Empty())))

そして次のように書けば、このリスト内にある要素すべての和と積を計算することができる。

val sum = list()(0, (a, b) => a + b)
// sum: Int = 6
val product = list()(1, (a, b) => a * b)
// product: Int = 6

この小さなデモの目的は、関数が(余データ的な意味で)単一のメソッドをもつオブジェクトにすぎないと示すことである。Scala ではそのことが明確で、関数は apply メソッドをもつオブジェクトとして実装されている。

データが余データに変換できることを見てきたが、その逆も可能である。それには、各メソッド呼び出しの全パターンについて結果を列挙すればよい。言い換えれば、データ表現とはメモ化であり、検索テーブルであり、またキャッシュのことである。

データと余データは相互に変換可能であるとはいえ、一方を選択するにあたってはそれなりの理由がある。余データを用いることで無限の構造を表現できるというのもそのひとつである。次節では、両者のもうひとつの違いとして、データと余データが許容する拡張性について見ていく。

3.5 データと余データの拡張性

余データが Stream のような無限個の要素をもつ型を表せることについてはすでに見た。これは、常に有限個の要素しかもたないデータとの大きな違いのひとつである。ここでは、もうひとつの違い、データと余データから得られる拡張性の特徴について見ていこう。これらの違いが、両者のうちいずれかを選択するにあたての指針となる。

最初に、拡張性とは何かを定義しておきたい。拡張性とは、既存コードを変更することなく新しい機能を追加できる能力のことを指す(もし既存コードの変更を認めるのであれば、どんな拡張だってできてしまう)。コードは、機能の追加とバリアントの追加というふたつの軸で拡張される。データでは機能を追加するのは簡単だが、バリアントを追加するには既存コードの変更が必要となる。一方、余データではバリアントの追加は簡単だが機能の追加は難しい。このように、データと余データは直交する拡張性をもっている。

それでは、データと余データそれぞれの具体例を見てみよう。データとしてはおなじみの List 型を使う。

enum List[A] {
  case Empty()
  case Pair(head: A, tail: List[A])
}

余データの例としては Set を使う。

trait Set[A] {
  def contains(elt: A): Boolean
  def insert(elt: A): Set[A]
  def union(that: Set[A]): Set[A]
}

標準ライブラリがそうであるように、この List にもたくさんのメソッドを定義することができる。どんなメソッドも構造的再帰を使って記述できることはすでに学んだとおりで、それらは既存コードの変更なしに書くことができる。

filterList に定義されていなかったと想像してほしい。これを実装するのは簡単である。

import List.*

def filter[A](list: List[A], pred: A => Boolean): List[A] = 
  list match {
    case Empty() => Empty()
    case Pair(head, tail) => 
      if pred(head) then Pair(head, filter(tail, pred))
      else filter(tail, pred)
  }

拡張メソッドとして定義し、それを普通のメソッドのように見せることだってできる。

extension [A](list: List[A]) {
  def filter(pred: A => Boolean): List[A] = 
    list match {
      case Empty() => Empty()
      case Pair(head, tail) => 
        if pred(head) then Pair(head, tail.filter(pred))
        else tail.filter(pred)
    }
}

これにより、データには新しい関数を問題なく追加できることがわかる。

データに新しいバリアントを追加する場合はどうだろうか。たとえば、単一要素のリストを最適化する特別なケースを追加したいとしよう。これを既存コードの変更なしに行うことはできない。enum に新しいバリアントを追加するには enum の定義を変更する必要がある。さらに、そのような新しいバリアントを追加すれば、既存のパターンマッチはすべて壊れてしまい修正が必要となる。したがって、データには新しい関数を追加することはできるが、新しいバリアントを追加することはできない。

次に余データを見てみよう。こちらは拡張性が逆である。ここにも両者の双対性が見てとれる。余データの場合、新しいバリアントの追加は簡単である。余データのインターフェースを定義している trait を実装するだけでよい。本書では ListSet を定義したときなどにこれを行っている。

final class ListSet[A](elements: List[A]) extends Set[A] {

  def contains(elt: A): Boolean =
    elements.contains(elt)

  def insert(elt: A): Set[A] =
    ListSet(elt :: elements)

  def union(that: Set[A]): Set[A] =
    elements.foldLeft(that) { (set, elt) => set.insert(elt) }
}
object ListSet {
  def empty[A]: Set[A] = ListSet(List.empty)
}

新しい機能の追加についてはどうだろうか。もしその機能が既存の機能を組み合わせて定義可能であれば問題はない。そのような機能は簡単に定義できるし、拡張メソッドとして定義し、ビルトインメソッドのように見せることもできる。だが、既存の機能を使って定義できない機能を追加するのは困難である。Set の要素を走査するある種のイテレータを定義したいとしよう。集合は無限個の要素を持つ可能性があるため、以前定義した Stream に相当する標準ライブラリの LazyList を使うかもしれない。だが、このような機能を実現するには Set の定義を変更しなければならず、すべての既存の実装を壊してしまう。Set のあらゆる実装について知ることは不可能なので、別の方法でこれを定義することもできない。

まとめると、余データには新しいバリアントを追加することは可能だが、新しい機能を追加することはできない。

これを表にまとめると、データと余データの拡張性が直交していることがはっきりとわかる。

拡張 データ 余データ
バリアントの追加 不可
機能の追加 不可

この拡張性の違いは、データと余データのいずれかを実装戦略として選択するにあたって、以前に述べた「有限か無限か」という区別に加えて、もうひとつの指針を与えてくれる。もし機能の拡張は必要でもバリアントの拡張が不要なのであれば、データを選ぶべきである。一方、固定されたインターフェースをもつが、実装がいくつ必要となるか分からない場合は余データを選ぶべきである。

両方の拡張性を同時にもつことはできるのか気になる人もいるかもしれない。これは 式の問題(expression problem) と呼ばれる。式の問題を解決する方法はいくつかある。後ほど、Scala において特に有効な解法を学ぶ。

3.6 演習: さまざまな集合

この追加演習では、これまでの例でも何度か使用した Set インターフェースについて詳しく見ていく。以下にその定義を再掲する。

trait Set[A] {
  
  /** 与えられた要素がこの集合に含まれていれば True */
  def contains(elt: A): Boolean
  
  /** この集合のすべての要素と与えられた要素を含む新しい集合を構築する */
  def insert(elt: A): Set[A]
  
  /** この集合と指定された集合の和集合を構築する */
  def union(that: Set[A]): Set[A]
}

集合の要素を List で管理するシンプルな実装についてもすでに見た。

final class ListSet[A](elements: List[A]) extends Set[A] {

  def contains(elt: A): Boolean =
    elements.contains(elt)

  def insert(elt: A): Set[A] =
    ListSet(elt :: elements)

  def union(that: Set[A]): Set[A] =
    elements.foldLeft(that) { (set, elt) => set.insert(elt) }
}
object ListSet {
  def empty[A]: Set[A] = ListSet(List.empty)
}

union の実装はあまり満足のいくものではない。コードを書くための戦略を全く利用していないからである。unioninsert はどちらも、あらゆる集合に対して汎用的に動作する方法で実装することができる(つまり、Set トレイト上に実装することができる)。また、その実装には、この章で学んだ戦略を活用することが可能である。それらを踏まえて、unioninsert を再実装せよ。

以下の解法では、これらのメソッドを実装するのに構造的余再帰を用いた。何を行っているのかが明確になるよう、サブクラスには名前を付けることとした。

trait Set[A] {
  
  def contains(elt: A): Boolean
  
  def insert(elt: A): Set[A] =
    InsertOneSet(elt, this)
  
  def union(that: Set[A]): Set[A] =
    UnionSet(this, that)
}

final class InsertOneSet[A](element: A, source: Set[A]) 
    extends Set[A] {

  def contains(elt: A): Boolean =
    elt == element || source.contains(elt)
}

final class UnionSet[A](first: Set[A], second: Set[A])
    extends Set[A] {

  def contains(elt: A): Boolean =
    first.contains(elt) || second.contains(elt)
}

続いて、すべての偶数からなる集合 Evens を実装せよ。これは Set[Int] 型で表現される。この集合は無限集合であり、要素すべてを直接列挙することはできない(32ビットの Int に含まれるすべての偶数を列挙することは実際には可能だが、膨大なメモリ空間を消費するため、ここではその方法を取らない)。

ここでは Evensobject として実装した。クラスとして実装した場合、そのインスタンスはどれも同じになる。インスタンスはひとつあればよい。

object Evens extends Set[Int] {

  def contains(elt: Int): Boolean =
    (elt % 2 == 0)
}

驚かれるかもしれないが、これで正しく動くのである。EvensListSet を用いてさらにいくつかの集合を定義してみよう。

val evensAndOne = Evens.insert(1)
val evensAndOthers = 
  Evens.union(ListSet.empty.insert(1).insert(3))

想定どおりに動くことを確認しておく。

evensAndOne.contains(1)
// res15: Boolean = true
evensAndOthers.contains(1)
// res16: Boolean = true
evensAndOne.contains(2)
// res17: Boolean = true
evensAndOthers.contains(2)
// res18: Boolean = true
evensAndOne.contains(3)
// res19: Boolean = false
evensAndOthers.contains(3)
// res20: Boolean = true

このアイデアを一般化して、集合を指示関数(indicator function)によって定義する方法を考えることができる。指示関数は A => Boolean 型の関数であり、入力が集合に属する場合に true を返す。指示関数をひとつパラメータとして受け取って構築されるクラス IndicatorSet を実装せよ。

final class IndicatorSet[A](indicator: A => Boolean)
    extends Set[A] {

  def contains(elt: A): Boolean =
    indicator(elt)
}

動作確認用に、すべての奇数からなる無限集合を定義してみよう。

val odds = IndicatorSet[Int](_ % 2 == 1)

次に、これが想定どおりに動くことを示す。

odds.contains(1)
// res21: Boolean = true
odds.contains(2)
// res22: Boolean = false
odds.contains(3)
// res23: Boolean = true

奇数と偶数の和集合をとれば、すべての整数を含む集合が得られる。

val integers = Evens.union(odds)

これは以下のとおり想定した挙動を示してくれる。

integers.contains(1)
// res24: Boolean = true
integers.contains(2)
// res25: Boolean = true
integers.contains(3)
// res26: Boolean = true

3.7 まとめ

この章では、データの双対である余データについて探求した。データが「それが何であるか」によって定義されるのに対し、余データは「それで何ができるか」というインターフェースによって定義される。より形式的にいえば、余データはデストラクタの積である。ここでいうデストラクタとは、余データ型(および必要に応じて他の入力)を受け取って、何らかの型を返す関数のことを指す。余データによって、オブジェクト指向プログラミングのうち、推論を困難にする可変状態や実装継承のような要素を避け、関数型プログラミングの戦略に合致する要素を取り込むことができる。Scala では、余データを trait として定義し、それを final class、匿名サブクラス、あるいはオブジェクトとして実装する。

余データを使ったメソッドの実装にはふたつの戦略がある。結果が余データである場合には構造的余再帰を使用し、入力が余データである場合には構造的再帰を使用する。構造的余再帰のほうが、実装するメソッドをより構造的にしてくれるので、有用である場合が多い。データの場合にはその逆が成り立つ。

データと余データは fold を介して結びつけられる。任意のデータは、そのデータの fold を単一のデストラクタとする余データとして実装することができる。逆も同様で、すべてのデストラクタの入力と出力のペアを列挙することで、余データをデータとして表現することができる。ただし、そのことはデータと余データが等価であることを意味しない。すべての偶数の集合やすべての自然数のストリームなど、余データが無限の構造を表現できる例をいくつも見てきた。また、データと余データが異なる拡張性を提供することも学んだ。データは新しい機能の追加が容易だが、新しいバリアントの追加には既存コードの変更を必要とする。一方、余データでは新しいバリアントを簡単に追加できるが、新しい機能を追加するには既存コードを変更する必要がある。

プログラミング言語における余データへの最も古い言及は、私が見つけたかぎりでは Hagino [1989] である。これは代数的データと比べてはるかに新しく、余データが比較的知られていないのはそのせいであるように思われる。余データに関する最近の優れた論文がいくつかある。まず Codata in Action [Downen et al. 2019] を強くお勧めしたい。この論文は本章の大部分にインスピレーションを与えてくれた。また、Exploring Codata: The Relation to Object-Orientation [Sullivan 2019] も読む価値がある。すこし古いが、How to Add Laziness to a Strict Language Without Even Being Odd [Wadler et al. 1998] はストリームの実装、特に彼らが odd と呼ぶ不完全な遅延評価実装と、彼らが even と呼んでいる、本章で見たスタイルとの違いを論じている。これらはそれぞれ Scala 標準ライブラリの StreamLazyList に対応する。Classical (Co)Recursion: Programming [Downen and Ariola 2021] は、さまざまな言語における余再帰についての興味深い調査であり、ここで扱った多くの例をカバーしている。最後に、データと余データの関係を徹底的に掘り下げたいなら、Beyond Church encoding: Boehm-Berarducci isomorphism of algebraic data types and polymorphic lambda-terms [Kiselyov 2005] をお勧めする。

4 コンテキスト抽象化

あらゆるプログラムは、特に単純なものを除けば、それが実行されるコンテキストに依存している。利用可能なCPUコア数は、コンピュータが提供するコンテキストの一例であり、プログラムはそれに応じてタスクの分配方法を調整するかもしれない。コンテキストの他の形式としては、ファイルや環境変数から読み取られる設定情報、そして、たとえばシリアライズ方式に関する情報のようにメソッドパラメータの型に応じてコンパイル時に生成される値などがある。後者については改めて詳しく述べる。

Scala はコンテキスト抽象化(contextual abstraction)のための機能を提供する数少ない言語のひとつである。その機能は Scala2 では implicit、Scala3 では given インスタンスとして知られている。Scala におけるこれらの機能は型と密接に関連している。利用可能な given インスタンスの中から必要なものを選択する作業や、given インスタンスの構築は、コンパイル時に型を使用して行われる。

多くの Scala プログラマは、コンテキスト抽象化の機能を他の言語機能ほど使いこなせていない場合が多く、他の言語から来たプログラマにとっては完全に新しい概念であることも多い。そのため、この章では、given インスタンスと using 句という、かつて implicit と呼ばれていた抽象について振り返ることから始める。そして、それらの主要な用途のひとつである型クラス4について見ていく。型クラスを使うと、従来の継承を使わず、元のソースコードを変更することもなく、既存の型を拡張し、新しい機能を追加することができる。型クラスは、本書の次のパートで学ぶことになる Cats の中心的な要素である。

4.1 コンテキスト抽象化のメカニズム

この節では、コンテキスト抽象化に関する Scala の主要な言語機能について説明する。コンテキスト抽象化のメカニズムをしっかり理解した上で、その具体的な使用方法へと進む。

コンテキスト抽象化に関する言語機能の名称は Scala2 から Scala3 で変更されているが、動作はほぼ同じである。以下の表に Scala3 の機能と、それに相当する Scala2 の機能を示した。Scala2 を使用している場合、givenimplicit val に、usingimplicit に置き換えるだけで多くのコードが動作するだろう。

Scala3 Scala2
given インスタンス implicit value
using 句 implicit パラメータ

次にこれらの言語機能がどのように動作するか説明しよう。

4.1.1 using 句

まずは using 句から始める。using 句とは using キーワードで始まるメソッドパラメータリストのことをいう。using 句内のパラメータのことはコンテキストパラメータという用語で呼ぶ。

def double(using x: Int) = x + x

using キーワードは、そのパラメータリスト内にあるすべてのパラメータに効果を及ぼす。次の add 関数では、xy の両方がコンテキストパラメータである。

def add(using x: Int, y: Int) = x + y

同じメソッドに普通のパラメータリストが並存していてもよいし、using 句が複数あってもかまわない。

def addAll(x: Int)(using y: Int)(using z: Int): Int =
  x + y + z

通常の方法で using 句にパラメータを渡すことはできない。以下に示すように、パラメータの前に using キーワードを付けなくてはならない。

double(using 1)
// res20: Int = 2
add(using 1, 2)
// res21: Int = 3
addAll(1)(using 2)(using 3)
// res22: Int = 6

だが、これは using 句にパラメータを渡す標準的な方法ではない。using 句にパラメータを明示的に渡すことは、実際にはほとんどない。通常は given インスタンスを使用する。次にそれについて説明する。

4.1.2 given インスタンス

given インスタンスは given キーワードを使って定義される値である。次に簡単な例を示す。

given theMagicNumber: Int = 3

given インスタンスは普通の値のように使うことができる。

theMagicNumber * 2

だが、using 句と組み合わせる使い方のほうがより一般的である。using 句をもつメソッドを、コンテキストパラメータを明示的に与えずに呼び出した場合、要求される型の given インスタンスをコンパイラが探してくれる。given インスタンスが見つかれば、それが自動的にパラメータとして使用され、メソッド呼び出しは完成する。

たとえば、すこし上で定義した、ひとつの Int 型コンテキストパラメータをもつ double 関数について考えよう。今しがた定義した theMagicNumber という given インスタンスも Int という型をもっている。この場合、コンテキストパラメータを与えずに double を呼び出すと、コンパイラは theMagicNumber をパラメータとして使用する。

double
// res24: Int = 6

using 句に同じ型のパラメータが複数ある場合でも、同じ given インスタンスが使用される。上述の add 関数がちょうどそのような定義をもっている。

add
// res25: Int = 6

以上が using 句と given インスタンスに関するもっとも重要なポイントである。次に、これらがどういう意味をもっているのかについて詳しく見ていこう。

4.1.3 given スコープとインポート

given インスタンスは通常、暗黙的に using 句へと渡される。これらの構文の存在意義は、この暗黙的な処理をコンパイラに指示することにある。これはコードをわかりづらいものにする可能性があるため、どの given インスタンスが using 句に供給される候補となるのかは、明確にしておく必要がある。この節では、コンパイラが given インスタンスを探す場所である given スコープと、given インスタンスをインポートするための特別な構文について見ていく。

given スコープについて知っておくべき最初のルールは、スコープの基準地点となるのが using 句をもつメソッドの定義場所ではなく、そのメソッドの呼び出し場所であるということである。次のコードでは、given インスタンス a は、メソッドの定義場所と同じスコープ内にはあるものの、呼び出し場所と同じスコープには存在しない。したがってこれはコンパイルできない。

object A {
  given a: Int = 1
  def whichInt(using int: Int): Int = int
}

A.whichInt
// error: 
// No given instance of type Int was found for parameter int of method whichInt in object A

ふたつ目のルールは、これまでの例でも常に使用してきたものであるが、given スコープには呼び出し場所のレキシカルスコープが含まれる、ということである。レキシカルスコープは通常、名前に関連付けられた値(メソッドパラメータ名や val 宣言など)を見つけるために使用される。次のコードは、a が呼び出し場所と同じスコープ内に定義されているので、正常にコンパイルされる。

object A {
  given a: Int = 1
  
  object B {
    C.whichInt 
  }
  
  object C {
    def whichInt(using int: Int): Int = int
  }
}

一方、同じ型をもつ複数の given インスタンスが同じスコープ内に存在する場合、コンパイラはどれかひとつを適当に選択したりはしない。代わりに、その選択が曖昧であることを知らせるエラーが発生する。

object A {
  given a: Int = 1
  given b: Int = 2
    
  def whichInt(using int: Int): Int = int
    
  whichInt
}
// error:
// Ambiguous given instances: both given instance a in object A and
// given instance b in object A match type Int of parameter int of 
// method whichInt in object A

given インスタンスを他のスコープからインポートすることもできる。通常のインポートと似ているが、given インスタンスをインポートする場合には、それを明示しなくてはならない。以下のコードは given インスタンスを明示的にインポートしていないのでコンパイルできない。

object A {
  given a: Int = 1

  def whichInt(using int: Int): Int = int
}
object B {
  import A.*
    
  whichInt
}
// error: 
// No given instance of type Int was found for parameter int of method whichInt in object A
// 
// Note: given instance a in object A was not considered because it was not imported with `import given`.

import A.given という表現を使って明示的にインポートすれば、このコードはコンパイルできる。

object A {
  given a: Int = 1

  def whichInt(using int: Int): Int = int
}
object B {
  import A.{given, *}
    
  whichInt
}

最後のルールとして、given スコープには、using 句の型に関わるすべての型のコンパニオンオブジェクトが含まれる。例を見るのが一番わかりやすいだろう。まず、型変数 A が発する音を表す型 Sound を定義し、その音にアクセスするためのメソッド soundOf を作成する。

trait Sound[A] {
  def sound: String
}

def soundOf[A](using s: Sound[A]): String =
  s.sound

次に given インスタンスをいくつか定義する。それらのインスタンスが、それぞれに対応するコンパニオンオブジェクト上で定義されていることに注目してほしい。

trait Cat
object Cat {
  given catSound: Sound[Cat] =
    new Sound[Cat]{
      def sound: String = "meow"
    }
}

trait Dog
object Dog {
  given dogSound: Sound[Dog] = 
    new Sound[Dog]{
      def sound: String = "woof"
    }
}

soundOf の呼び出しにあたって、インスタンスを明示的にスコープにもちこむ必要はない。それらは、使用する型(CatDog)のコンパニオンオブジェクト上に定義されていることにより、自動的に given スコープに含まれる。これらのインスタンスが Sound のコンパニオンオブジェクトに定義されていた場合もやはり、given スコープに含まれる。Sound[A] を探す際には、SoundA 両方のコンパニオンオブジェクトがスコープに含まれるためである。

soundOf[Cat]
// res32: String = "meow"
soundOf[Dog]
// res33: String = "woof"

ほとんどの場合、given インスタンスはコンパニオンオブジェクト上に定義されるべきである。このシンプルな整理方法により、メソッドの利用者は given インスタンスを明示的にインポートする必要がなくなるし、実装を確認したいときには簡単に見つけることが可能となる。

4.1.3.1 given インスタンスの優先順位

given インスタンスの選択はもっぱら型に基づいて行われる。soundOf には、値を何ひとつ渡していない。つまり、各型に対してインスタンスがそれぞれひとつしかない場合、given インスタンスは特に使いやすくなる。この場合、インスタンスを関連するコンパニオンオブジェクト上に置くだけで、すべてがうまく機能する。

しかし、これは常に可能とは限らない(もっとも、可能でないのは設計がよくないことを示唆しているケースが多い)。ある型に対して複数のインスタンスが必要な場合、その中からひとつを選択するためにインスタンスの優先順位ルールが使用される。以下では三つの重要なルールを見ていく。

第一のルールとして、明示的に渡されたインスタンスは他のどれよりも優先される。

given a: Int = 1
def whichInt(using int: Int): Int = int
whichInt(using a)
// res35: Int = 1

第二に、レキシカルスコープにあるインスタンスがコンパニオンオブジェクトに置かれたインスタンスよりも優先される。

trait Sound[A] {
  def sound: String
}
trait Cat
object Cat {
  given catSound: Sound[Cat] =
    new Sound[Cat]{
      def sound: String = "meow"
    }
}

def soundOf[A](using s: Sound[A]): String =
  s.sound
given purr: Sound[Cat]  =
  new Sound[Cat]{
    def sound: String = "purr"
  }

soundOf[Cat]
// res37: String = "purr"

最後に、より近くのレキシカルスコープ内にあるインスタンスが、遠くのものよりも優先される。

{
  given growl: Sound[Cat] =
   new Sound[Cat]{
     def sound: String = "growl"
   }
   
  {
    given mew: Sound[Cat] =
     new Sound[Cat]{
       def sound: String = "mew"
     }
     
    soundOf[Cat]
  }
}
// res38: String = "mew"

これで、given インスタンスと using 句の仕組みについての詳細をほぼ網羅した。だが、これらは技法レベルの説明にすぎない。そこから「どういう時にこれらの機能を使うのか?」という疑問が生じるのは当然のことだろう。次は、この疑問に応えるため、型クラスおよび Scala におけるその実装について見ていく。

4.2 型クラスの仕組み

ここからは、型クラスがどのように実装されるかを見ていこう。型クラスには三つの重要な構成要素がある。インターフェースを定義する型クラスそのもの、特定の型に対して型クラスを実装する型クラスインスタンス、そして型クラスを使用するメソッドである。以下の表は、それぞれの構成要素に対応する言語機能を示している。

型クラスの概念 言語機能
型クラス trait
型クラスインスタンス given インスタンス
型クラスの使用 using 句

これがどのように機能するか詳しく見ていこう。

4.2.1 型クラス

型クラスは、実装したい何らかの機能を表現するインターフェースもしくは API である。Scala において、型クラスは少なくともひとつの型パラメータをもつトレイトとして表現される。たとえば、汎用的な「JSON へのシリアライズ」機能を次のように表現できる。

// JSONのシンプルな抽象構文木を定義する
sealed trait Json
final case class JsObject(get: Map[String, Json]) extends Json
final case class JsString(get: String) extends Json
final case class JsNumber(get: Double) extends Json
case object JsNull extends Json

// このトレイトが「JSONへのシリアライズ」機能を表す
trait JsonWriter[A] {
  def write(value: A): Json
}

この例では、JsonWriter が型クラスである。Json とその部分型は、型クラスの使い方を見ていくために補助的に必要となるコードを提供している。JsonWriter のインスタンスを実装するにあたっては、型パラメータ A がシリアライズしたいデータの具体的な型となる。

4.2.2 型クラスインスタンス

型クラスインスタンスは、関心の対象となるある特定の型に対して型クラスの実装を提供する。ここでいう型には、Scala 標準ライブラリの型と独自のドメインモデルで定義された型の両方が含まれる。

Scala では、型クラスを実装した given インスタンスを定義することによって、型クラスインスタンスを作成する。

object JsonWriterInstances {
  given stringWriter: JsonWriter[String] =
    new JsonWriter[String] {
      def write(value: String): Json =
        JsString(value)
    }
  
  final case class Person(name: String, email: String)
  
  given JsonWriter[Person] with
    def write(value: Person): Json =
      JsObject(Map(
        "name" -> JsString(value.name),
        "email" -> JsString(value.email)
      ))
  
  // etc...
}

この例では、JsonWriter の型クラスインスタンスをふたつ定義している。ひとつは String 用で、もうひとつは Person 用である。String に対する定義では、前節で見たとおりの構文を使用し、Person に対する定義では、Scala3 で新たに導入されたふたつの構文を使用している。まず、given JsonWriter[Person] と書くことで、無名のgiven インスタンスが作成される。型だけを宣言すればよく、インスタンスに名前を付ける必要はない。通常 given インスタンスを名前で参照する必要はないので、これで問題ない。ふたつ目の構文は、new JsonWriter[Person] などと書き出す代わりに with を使用してトレイトを直接実装していることである。

実際の実装では、コンパニオンオブジェクト上にインスタンスを定義することが一般的に望ましい。String 用のインスタンスは JsonWriter のコンパニオンオブジェクトに(String のコンパニオンオブジェクトには定義できないため)、Person 用のインスタンスは Person のコンパニオンオブジェクトに定義するのである。しかし、ここではそれを行っていない。型とそのコンパニオンオブジェクトは同時に宣言しなければならず、JsonWriter の再定義が必要となるからである。

4.2.3 型クラスの利用

型クラスインスタンスを必要とする機能はすべて、型クラスを利用していると言うことができる。Scala においては、using 句の一部として型クラスのインスタンスを受け取るメソッドがこれに該当する。

これから、型クラスのふたつの利用パターンであるインターフェースオブジェクトインターフェース構文を紹介する。これらは Cats やその他のライブラリで見ることができる。

4.2.3.1 インターフェースオブジェクト

型クラス利用のインターフェースを作成するもっとも簡単な方法は、シングルトンオブジェクトの中にそれらのメソッドを配置するというものである。

object Json {
  def toJson[A](value: A)(using w: JsonWriter[A]): Json =
    w.write(value)
}

このオブジェクトを利用するときは、使いたい型クラスインスタンスをインポートし、該当メソッドを呼び出す。

import JsonWriterInstances.{*, given}
Json.toJson(Person("Dave", "dave@example.com"))
// res9: Json = JsObject(
//   get = Map(
//     "name" -> JsString(get = "Dave"),
//     "email" -> JsString(get = "dave@example.com")
//   )
// )

コンパイラは、toJson メソッドが given インスタンスなしで呼び出されたことを検出すると、関連する型の given インスタンスを探し、それを呼び出し箇所に挿入することで問題を解決しようとする。

4.2.3.2 インターフェース構文

拡張メソッドを使用することで、型クラスの利用インターフェースとなるメソッドを既存の型に追加することもできる5。Cats では、これは型クラスの構文(syntax)と呼ばれる。Scala2 には、拡張メソッドに相当するものとして暗黙クラス(implicit class)が存在する。

次に示すのは、拡張メソッドを定義することによって、JsonWriter インスタンスが提供されている任意の型に toJson メソッドを追加している例である。

object JsonSyntax {
  extension [A](value: A) {
    def toJson(using w: JsonWriter[A]): Json =
      w.write(value)
  }
}

必要としている型クラスインスタンスと一緒にこれをインポートすることで、インターフェース構文を利用できる。

import JsonWriterInstances.given
import JsonSyntax.*
Person("Dave", "dave@example.com").toJson
// res10: Json = JsObject(
//   get = Map(
//     "name" -> JsString(get = "Dave"),
//     "email" -> JsString(get = "dave@example.com")
//   )
// )

トレイトの拡張メソッド

Scala3 では、型クラスのトレイト上に直接拡張メソッドを定義することができる。先ほどの toJsonJsonWriterwrite メソッドを呼び出しているだけなので、代わりに toJson を直接 JsonWriter 上に定義することで、別途拡張メソッドを作成する必要がなくなる。

trait JsonWriter[A] {
  extension (value: A) def toJson: Json
}

object JsonWriter {
  given stringWriter: JsonWriter[String] =
    new JsonWriter[String] {
      extension (value: String) 
        def toJson: Json = JsString(value)
    }
  
  // etc...
}

とはいえ、このアプローチは推奨しない。Scala における拡張メソッドの探索方法には制約があるからである。以下のコードは、Scala が拡張メソッドを探索する際に String のコンパニオンオブジェクト内しか参照せず、JsonWriter のコンパニオンオブジェクト内のインスタンスに定義された拡張メソッドを見つけることができないため、失敗する。

"A string".toJson
// error: 
// value toJson is not a member of String

つまり、少なくとも組み込み型用のインスタンスに関しては、コンパニオンオブジェクトを変更できないことから、利用者は明示的にインポートを行わなくてはならない、ということになる。

import JsonWriter.given

"A string".toJson
// res13: Json = JsString(get = "A string")

一貫性の観点から、一部の拡張メソッドにだけ明示的なインポートが必要になるような書き方よりも、構文を型クラスインスタンスから分離して常に明示的にインポートすることを推奨する。

4.2.3.3 summon メソッド

Scala 標準ライブラリには summon というジェネリックな型クラスのインターフェースが用意されている。その定義は次のとおり、非常にシンプルである。

def summon[A](using value: A): A =
  value

summon を使えば、given スコープに存在する任意の値を呼び出すことができる。必要なのは、求めている値の型を指定することだけで、あとは summon がやってくれる。

summon[JsonWriter[String]]
// res14: JsonWriter[String] = repl.MdocSession$MdocApp11$JsonWriter$$anon$14@77fc4b75

Cats のほとんどの型クラスはインスタンスを入手するための方法を他にも提供しているが、summon はデバッグ目的で使える便利な代替手段である。コードの途中に summon 呼び出しを挿入することで、コンパイラが型クラスのインスタンスを見つけられるかどうか、また曖昧さのエラーがないかを確認することができる。

4.3 型クラスの合成

ここまで、コンパイラにメソッド呼び出しのコンテキストパラメータを供給させる手段としての型クラスを見てきた。これは便利だが、たくさんの新しい概念を導入したわりに得られるものが小さいようにも見える。型クラスの真の強みは、コンパイラが複数の given インスタンスを組み合わせて新しい given インスタンスを構築できる点にある。これは型クラスの合成(type class composition)として知られている。

型クラスの合成は、まだ本書で言及していない given インスタンスのある機能を利用することで実現される。実は given インスタンス自身もまたコンテキストパラメータをもつことができる。しかし、その詳細に入る前に、この仕組みの必要性を認識できるような例を見てみよう。

Option 用の JsonWriter を定義することを考える。アプリケーションで必要となるすべての型 A に対して、JsonWriter[Option[A]] を用意する必要がある。given インスタンスを集めたライブラリを作成するなど、強引に問題解決を試みることもできるだろう。

given optionIntWriter: JsonWriter[Option[Int]] =
  ???

given optionPersonWriter: JsonWriter[Option[Person]] =
  ???

// など……

だが、このアプローチがスケールしないのは明らかである。アプリケーション内の型それぞれについて、その型を A とすれば、A 用の given インスタンスと Option[A] 用の given インスタンスが必要になってしまう。

幸いなことに、Option[A] を JSON シリアライズするコードは、A 用の型クラスインスタンスに基づいた共通のコンストラクタとして抽象化することができる。

このアイデアを、パラメータ化された given インスタンスを用いてコード化したものを次に示す。

given optionWriter[A](using writer: JsonWriter[A]): JsonWriter[Option[A]] =
  new JsonWriter[Option[A]] {
    def write(option: Option[A]): Json =
      option match {
        case Some(aValue) => writer.write(aValue)
        case None         => JsNull
      }
  }

このメソッドは、型 A に固有のシリアライズ化ロジックをコンテキストパラメータとして受け取ることによって、Option[A] 用の JsonWriter を構築する。

Json.toJson(Option("A string"))

上記のような式を見つけると、コンパイラは JsonWriter[Option[String]] 型の given インスタンスを探し、発見したインスタンスをコンテキストパラメータとしてメソッドに渡す。

Json.toJson(Option("A string"))(using optionWriter[String])

続いて、optionWriter のコンテキストパラメータとして利用するため、再帰的に JsonWriter[String] を探す。

Json.toJson(Option("A string"))(using optionWriter(using stringWriter))

このように、given インスタンスの解決は、利用可能な given インスタンスの組み合わせを探索し、目指す型クラスインスタンスを作成する組み合わせを発見するプロセスとなる。

4.3.1 Scala2 における型クラスの合成

Scala2 では implicit メソッドと implicit パラメータを使うことで同じ効果を得ることができる。前述の optionWriter と同等のものを Scala2 で記述したコードを次に示す。

implicit def scala2OptionWriter[A]
    (implicit writer: JsonWriter[A]): JsonWriter[Option[A]] =
  new JsonWriter[Option[A]] {
    def write(option: Option[A]): Json =
      option match {
        case Some(aValue) => writer.write(aValue)
        case None         => JsNull
      }
  }

メソッドのパラメータを implicit にすることを忘れてはならない。そうしないと、暗黙の型変換(implicit conversion)を定義したことになってしまう。暗黙の型変換は古いプログラミングパターンであり、最近の Scala コードでは推奨されない。幸いなことに、もし間違ってしまっても、コンパイラはメソッドパラメータに implicit をつけるよう警告してくれる。

4.4 型クラスとは何か

ここまで型クラスの仕組みを見てきた。型クラスはトレイト、given インスタンス、using 句を組みわせることによって成り立っているわけだが、これは技法レベルの説明である。ここからは抽象度を上げ、型クラスを三つの異なる視点から考察したい。

最初の視点は、余データについて取り上げた3章に戻る。トレイトとして定義される型クラス本体は余データの例であり、余データが通常そうであるように、実装を簡単に追加できるという利点と、インターフェースを簡単に変更できないという欠点をもっている。given インスタンスと using 句があることで、コンテキストパラメータの型と given スコープ内のインスタンスに基づいて余データの実装を選択することが可能となる。複数の小さなパーツからインスタンスを合成することもできるようになる。

もうひとつ抽象度を上げれば、型クラスとは、適用対象の型から切り離して機能(型クラスインスタンス)を実装できるようにする仕組みであると言える。機能の実装は、対象となる型の宣言時ではなく、使用時つまり呼び出される場所で定義されていればよい。

さらに抽象度を上げよう。型クラスとはアドホック多相(ad-hoc polymorphism)を実現するものであると言うことができる。アドホック多相を理解するには、パラメトリック多相(parametric polymorphism)と対比するのがもっとも簡単だろう。パラメトリック多相は、型パラメータ(ジェネリック型とも呼ばれる)によって実現されるもので、すべての型を一様に扱うことを可能にする。たとえば、次の関数は、任意の型 A のリストの長さを計算する。

def length[A](list: List[A]): Int =
  list match {
    case Nil => 0
    case x :: xs => 1 + length(xs)
  }

length を実装できるのは、それがリストの要素である型 A の値に対して特に機能を必要としないからである。A 型の値に対してメソッドを呼び出すことはないし、そもそも、length が定義される時点では、A が具体的にどの型になるかがわからないため、メソッドを呼び出すこともできない6

アドホック多相により、ジェネリックな型の値に対してメソッドを呼び出すことが可能になる。呼び出せるメソッドは、型クラスによって定義されたものに限られる。たとえば、標準ライブラリの Numeric 型クラスを使えば、その型クラスを実装する任意の型の値同士を足し合わせるメソッドを作成することができる。

import scala.math.Numeric

def add[A](x: A, y: A)(using n: Numeric[A]): A = {
  n.plus(x, y)
}

したがって、パラメトリック多相が「任意の型」に対する多相性である一方、アドホック多相は「ある特定の機能を実装している任意の型」に対するものをいう。アドホック多相では、ある特定の機能を実装している具体型同士が特別な関係性をもっている必要はない。これに対し、オブジェクト指向スタイルの多相性(つまり、余データ)では、具体型はすべて、その機能を定義している型の部分型でなくてはならない。

4.5 演習: 表示ライブラリ

Scala は任意の値を String オブジェクトに変換するメソッド toString を提供している。このメソッドにはいくつか不便な点がある。

  1. toString は言語内のすべての型に対して実装されているが、データを表示可能にしたくない場合もある。 たとえば、パスワードのような機密情報はログに記録されないようにしたいかもしれない
  2. 自分たちのコントール外にある型に対して toString をカスタマイズすることはできない

これらの問題を解決するため、以下の詳細に従って Display 型クラスを定義せよ。

  1. 単一のメソッド display をもつ型クラス Display[A] を定義する。display は型 A の値を受け取り、String を返すメソッドとする
  2. Display コンパニオンオブジェクト上に、String および Int 用の Display インスタンスを作成する
  3. Display コンパニオンオブジェクト上に、次のふたつのジェネリックなインターフェースメソッドを作成する
    • display メソッド。型 A の値と、それに対応する Display インスタンスを受け取る。対応する Display を使って AString に変換する
    • print メソッド。display と同じパラメータを受け取り Unit を返す。display から得られる A の表示用文字列を println でコンソールに出力する

以下のステップは、今回の型クラスに関連した三つのコンポーネントを定義する。最初は、型クラスそのものである Display である。

trait Display[A] {
  def display(value: A): String
}

続いて、Display にいくつかデフォルトのインスタンスを定義する。これらは Display のコンパニオンオブジェクトに配置する。

object Display {
  given stringDisplay: Display[String] with {
    def display(input: String) = input
  }

  given intDisplay: Display[Int] with {
    def display(input: Int) = input.toString
  }
}

最後に、Display コンパニオンオブジェクトを拡張し、型クラスの利用窓口となる基本的なインターフェースを提供する。

object Display {
  given stringDisplay: Display[String] with {
    def display(input: String) = input
  }

  given intDisplay: Display[Int] with {
    def display(input: Int) = input.toString
  }

  def display[A](input: A)(using p: Display[A]): String =
    p.display(input)

  def print[A](input: A)(using Display[A]): Unit =
    println(display(input))
}

print メソッドのパラメータになっている Display インスタンスが無名であることに注目しよう。この書き方は Scala3 から導入された。このインスタンスは display メソッドに受け渡されるだけなので、名前がなくても問題ない。

4.5.1 表示ライブラリの利用

上記のコードは、さまざまなアプリケーションで使用できる汎用的な表示ライブラリを形成している。次の手順に従い、このライブラリを利用するアプリケーションを定義せよ。

まず、みんなおなじみのモフモフした動物を表すデータ型を定義する。

final case class Cat(name: String, age: Int, color: String)

次に Cat 用の Display 実装を作成する。この実装はデータを以下のフォーマットで返す。

NAME is a AGE year-old COLOR cat.

最後に、コンソールか簡単なデモアプリでこの型クラスを使う。Cat オブジェクトを作成し、これをコンソールに表示せよ。

// 猫オブジェクトを作成
val cat = Cat(/* ... */)

// ここで猫を表示!

これは型クラスパターンの標準的な使い方である。まずはこのアプリケーション用にデータ型を定義する。

final case class Cat(name: String, age: Int, color: String)

そして、そのデータ型のための型クラスインスタンスを定義する。定義場所は Cat のコンパニオンオブジェクトか、もしくは名前空間の役割をもった別のオブジェクトである。

given catDisplay: Display[Cat] = new Display[Cat] {
  def display(cat: Cat) = {
    val name  = Display.display(cat.name)
    val age   = Display.display(cat.age)
    val color = Display.display(cat.color)
    s"$name is a $age year-old $color cat."
  }
}

最後に、使いたい型クラスインスタンスをスコープにもちこみ、インターフェースオブジェクトもしくはインターフェース構文を用いて、型クラスを利用する。型クラスインスタンスをコンパニオンオブジェクトに定義したのであれば Scala は自動的にそれらをスコープに含めるが、そうでない場合は import を用いてアクセスする。

val cat = Cat("Garfield", 41, "ginger and black")
Display.print(cat)
// Garfield is a 41 year-old ginger and black cat.

4.5.2 表示ライブラリの構文をもっと便利にする

以下の手順に従って拡張メソッドを追加し、この表示ライブラリをもっと簡単に扱えるようにせよ。

  1. DisplaySyntax オブジェクトを作成する
  2. displayprint を拡張メソッドとして DisplaySyntax 上に定義する
  3. その拡張メソッドを使って、前の演習で作成した Cat オブジェクトを表示する

まず DisplaySyntax と必要な拡張メソッドを定義する。

object DisplaySyntax {
  extension [A](value: A)(using p: Display[A]) {
    def display: String = p.display(value)
    def print: Unit = Display.print(value)
  }
}

これで、Cat オブジェクトに対して print を呼び出せば、その猫に関する全情報を表示できる。

import DisplaySyntax.*

Cat("Garfield", 41, "ginger and black").print
// Garfield is a 41 year-old ginger and black cat.

Display インスタンスが定義されていない型に対して拡張メソッドを呼び出そうとすると、コンパイルエラーになる。

import java.util.Date
new Date().print
// error:
// value print is not a member of java.util.Date.
// An extension method was tried, but could not be fully constructed:
// 
//     repl.MdocSession.MdocApp4.DisplaySyntax.print[java.util.Date](
//       new java.util.Date())(
//       /* missing */summon[repl.MdocSession.MdocApp4.Display[java.util.Date]])
// 
//     failed with:
// 
//         No given instance of type repl.MdocSession.MdocApp4.Display[java.util.Date] was found for parameter p of method print in object DisplaySyntax
// new Date().print
// ^^^^^^^^^^^^^^^^

4.6 型クラスと変位

この節では、変位(variance)が型クラスのインスタンス選択にどのように影響するかを説明する。変位は Scala の型システムの中でも理解が難しい部分のひとつである。なので、まずは変位についておさらいし、それから型クラスとの相互作用について見ていく。

4.6.1 変位

変位は、ある型およびその部分型(subtype)に対して定義された型クラスインスタンス同士の関係性に関わる概念である。たとえば、SomeOption の部分型だが、JsonWriter[Option[Int]] 型のインスタンスが定義されているときに、Json.toJson(Some(1)) という式のコンテキストパラメータとしてそのインスタンスが選択されるだろうか。それを決めるのが変位である。

変位を説明するにあたっては、型コンストラクタと部分型付け(subtyping)というふたつの概念が必要となる。

変位はすべての型コンストラクタに適用される。型コンストラクタとは F[A] という型における F の部分を指す。たとえば、ListOptionJsonWriter はすべて型コンストラクタである。型コンストラクタは最低でもひとつ型パラメータをもっていなければならず、もっと多くもっていることもある。つまり、型パラメータをふたつもっている Either も型コンストラクタである。

部分型付けとは型同士の関係である。A 型の値を必要としている場所に B 型の値を使うことができるのであれば、BA の部分型であると言える。この関係を表すのに B <: A という記号を用いることがある。

変位は、AB の間に部分型関係がある場合の、F[A]F[B] との間の部分型関係がどのようなものであるかを表す。BA の部分型である場合、各変位は以下のように説明できる。

  1. F[B] <: F[A] であれば、FA に対して 共変(covariant) であるという
  2. F[B] >: F[A] であれば、FA に対して 反変(contravariant) であるという
  3. F[B]F[A] の間に部分型関係がなければ、FA に対して 非変(invariant) であるという

型コンストラクタを定義する際には、その型パラメータに変位アノテーションをつけることができる。たとえば、共変性は + 記号で表す。

trait F[+A] // "+" は共変を表す

変位アノテーションをつけなければ、その型パラメータは非変となる。次に、共変・反変・非変について詳しく見ていこう。

4.6.2 共変

共変とは、BA の部分型であるときに、F[B]F[A] の部分型となる関係をいう。共変は、ListOption などのコレクションをはじめとする多くの型をモデリングするのに使える。

trait List[+A]
trait Option[+A]

Scala のコレクションが共変であることにより、ある型のコレクションをその部分型のコレクションで置き換えることが可能である。たとえば、CircleShape の部分型であるならば List[Shape] が期待されるあらゆる場所で List[Circle] を使うことができる。

sealed trait Shape
final case class Circle(radius: Double) extends Shape
val circles: List[Circle] = ???
val shapes: List[Shape] = circles

一般的に言えば、共変は、List のようなコンテナ型から取り出すことのできるデータや、メソッドの戻り値など、出力となるデータの型に対して用いられる。

4.6.3 反変

反変パラメータをもつ型コンストラクタは - 記号を用いて次のように表記される。

trait F[-A]

ややこしいと思うかもしれないが、反変とは、AB の部分型であるときに、F[B]F[A] の部分型となる関係をいう。既出の JsonWriter 型クラスのように、入力となる型をモデリングするときに用いられる。

trait JsonWriter[-A] {
  def write(value: A): Json
}

もうすこし掘り下げてみよう。変位とは、ある型の値を別の型の値に置き換える能力に関する概念である。Shape 型の値と Circle 型の値、そして ShapeCircle それぞれの JsonWriter がある状況を考えてみるとよい。

val shape: Shape = ???
val circle: Circle = ???

val shapeWriter: JsonWriter[Shape] = ???
val circleWriter: JsonWriter[Circle] = ???
def format[A](value: A, writer: JsonWriter[A]): Json =
  writer.write(value)

format に渡せる値とライターの組み合わせはどれか、考えてみてほしい。Circle はすべて Shape でもあるため、どちらのライターを使っても Circlewrite できる。逆に、Shape はすべてが Circle というわけではないので、circleWriterShape を書き出すことはできない。

反変を使えば、このような関係性をモデリングすることができる。CircleShape の部分型であるため、JsonWriter[Shape]JsonWriter[Circle] の部分型となる。つまり、JsonWriter[Circle] が期待される場所であればどこでも、shapeWriter を使用することができる。

4.6.4 非変

非変性はもっともシンプルである。+- いずれも指定しなかった型パラメータは非変となる。

trait F[A]

これは、 AB との関係がいかなるものであっても、F[A]F[B] とが互いに部分型関係をもたないことを意味する。これが Scala の型コンストラクタにおけるデフォルトの変位である。

4.6.5 変位とインスタンス選択

コンパイラは、求めている型かもしくはその部分型にマッチする given インスタンスを探す。したがって、変位指定を行うことで、型クラスインスタンスの選択をある程度は制御することができる。

以下のような代数的データ型があると想像してほしい。

enum A {
  case B
  case C
}

たいていの場合、これについて考えるべきことはふたつある。

  1. 上位型に対して定義されたインスタンスがあれば、それが選択されるか。たとえば、A 用に定義されたインスタンスは B 型や C 型の値に対しても機能するのか。

  2. 部分型に対して定義されたインスタンスは上位型に対するものよりも優先的に選択されるか。たとえば、AB それぞれ用のインスタンスが定義されている状態で、B 型の値に対するインスタンスを必要とした場合、B 用のインスタンスが A 用のものよりも優先的に選択されるのか。

両方を同時に実現することはできない。各変位における挙動は次表のとおりとなる。

型クラスの変位指定 非変 共変 反変
上位型用インスタンスが使われるか 特化型用インスタンスが優先されるか いいえ いいえ いいえ はい はい いいえ

いくつか例を見てみよう。以下の型を用いて部分型関係を示す。

trait Animal
trait Cat extends Animal
trait DomesticShorthair extends Cat

今、三種類の変位に対応する三つの異なる型クラスを定義し、それぞれについて Cat 型用のインスタンスを定義する。

trait Inv[A] {
  def result: String
}
object Inv {
  given Inv[Cat] with
    def result = "Invariant"
    
  def apply[A](using instance: Inv[A]): String =
    instance.result
}

trait Co[+A] {
  def result: String
}
object Co {
  given Co[Cat] with
    def result = "Covariant"

  def apply[A](using instance: Co[A]): String =
    instance.result
}

trait Contra[-A] {
  def result: String
}
object Contra {
  given Contra[Cat] with
    def result = "Contravariant"

  def apply[A](using instance: Contra[A]): String =
    instance.result
}

まず正常に動作するケースを考えよう。選択されるのは常に Cat 用のインスタンスである。非変の場合、厳密に Cat 型を指定する必要がある。共変の場合は、Cat の上位型、反変の場合は、Cat の部分型を指定することができる。

Inv[Cat]
// res12: String = "Invariant"
Co[Animal]
// res13: String = "Covariant"
Co[Cat]
// res14: String = "Covariant"
Contra[DomesticShorthair]
// res15: String = "Contravariant"
Contra[Cat]
// res16: String = "Contravariant"

次は動作しないケースである。非変の場合、Cat 以外の型に対しては該当するインスタンスは見つからない。以下のような上位型用の型インスタンス検索は失敗するし、

Inv[Animal]
// error: 
// No given instance of type MdocApp2.this.Inv[MdocApp2.this.Animal] was found for parameter instance of method apply in object Inv

部分型用も同様に失敗する。

Inv[DomesticShorthair]
// error: 
// No given instance of type MdocApp2.this.Inv[MdocApp2.this.DomesticShorthair] was found for parameter instance of method apply in object Inv

共変の場合、インスタンスが定義されている型の部分型に対するインスタンス検索は失敗する。

Co[DomesticShorthair]
// error: 
// No given instance of type MdocApp2.this.Co[MdocApp2.this.DomesticShorthair] was found for parameter instance of method apply in object Co

反変の場合、インスタンスが定義されている型の上位型に対するインスタンス検索は失敗する。

Contra[Animal]
// error: 
// No given instance of type MdocApp2.this.Contra[MdocApp2.this.Animal] was found for parameter instance of method apply in object Contra

言うまでもなく、完全なシステムは存在しない。もっとも用いられるのは非変の型クラスで、その場合、必要ならば部分型に対してより具体的なインスタンスを指定することが可能となる。ただし、これはたとえば、Some[Int] 型の値に対して Option 用の型クラスインスタンスが使用されないことを意味する。この問題は、Some(1): Option[Int] のような型注釈を使うか、Option.applyOption.emptysomenone メソッドといった「スマートコンストラクタ」を使うことで解決できる。スマートコンストラクタについては6.3.3節で見た。

4.7 まとめ

この章では、型クラスについて紹介し、それが以下の要素から構成されることを学んだ。

型クラスが合成を通じて複数の要素から構成できることも見てきた。Scala においてこれはメタプログラミングの一種で、プログラムの型に基づいてコンパイラに作業をさせることができる。

型クラスは、余データと、型に基づいて実装を選択し合成するためのツールとを融合させたものとして捉えることができる。また、型クラスは実装を定義時点から呼び出し時点へと移行させるものとも言える。そして最後に、型クラスはアドホック多相を実現する仕組みとして、互いに関連のない型に共通の機能を定義することを可能にする。

型クラスは Kaes [1988] および Wadler and Blott [1989] で初めて言及された。Oliveira et al. [2010] は Scala2 における型クラスのエンコーディングについて詳述し、Scala と Haskell の型クラスに対するアプローチを比較している。型クラスは Haskell や Scala だけに限定されたものではない。たとえば Rust のトレイトは本質的に型クラスと同じものである。

これまで見てきたように、Scala における型クラスのサポートは、暗黙パラメータ( Scala3 では using 句として知られている)に基づいている。暗黙パラメータ [Lewis et al. 2000] は型クラスをより小さく独立した言語機能に分解するために導入されたが、他の用途にも役立つことが示されている。Křikava et al. [2019] では Scala における暗黙パラメータのさまざまな利用方法を調査している。 Oliveira and Gibbons [2010] に特に興味深い例が記載されているので参照してほしい。それらの異なる使用方法のいくつかについては、後の章で詳しく見ていこうと思う。

Scala3 がもっているコンテキスト抽象化に関連する言語機能の中には、本章で言及していないものもいくつかある。コンテキスト関数 [Odersky et al. 2017] は、関数が using 句をもつことを可能にする。それらの機能については、まだコミュニティで模索中であり、はっきりしたユースケースが定まっていない。ジェネリック導出(generic derivation)を使えば、型クラスインスタンスを生成するコードを記述できる。これは非常に有用だが、概念的にはかなり単純であり、本書で詳しく扱うほどの内容ではないと考えている。

5 インタープリタ

インタープリタ戦略は関数型プログラミングにおいてもっとも重要な戦略と言えるかもしれない。中心となるアイデアは記述と実行の分離である。インタープリタ戦略を用いたプログラムは、実行したい内容の記述と、その記述に従って実際にアクションを実行するインタープリタというふたつの部分から構成される。この章では、まずインタープリタの設計と実装について探ることから始める。特に代数的データ型を用いた実装に焦点を当てる。

記述と実行を区別する場面には必ずインタープリタが登場する。インタープリタは多大な開発労力を要する複雑なものと思われるかもしれないが、実際にはそうではないことを伝えたい。おそらく、皆さんは日頃のコーディングでそれと気付かずに多くのインタープリタを既に使用しているはずである。たとえば、Krop という Web フレームワークから抜き出した以下のコードを考えてみよう。

val route =
  Route(
    Request.get(Path.root / "user" / Param.int),
    Response.ok(Entity.text)
  ).handle(userId => s"You asked for the user ${userId.toString}")

このコードは、パス "/user/<int>" に対する GET リクエストにマッチし、テキストのボディをもった Ok レスポンスを返すルートを定義している。この種のルーティングライブラリは Web フレームワークにおいて普遍的で、簡単に実装できるものだが、実はインタープリタ戦略に必要な要素をすべて含んでいる。

インタープリタが重要なのは、それが副作用を許容しながらも合成と推論を可能にする鍵となるからである。たとえば、インタープリタ戦略を使ってグラフィックスライブラリを実装することを考えてほしい。プログラムは、描画したい内容をシンプルに記述するが、重要なのはその記述部が実際には何も描画しないということである。インタープリタがこの記述を受け取り、それに基づいて描画を行う。記述部は副作用を伴わないので自由に合成できる。たとえば、円を描画する記述と四角形を描画する記述があれば、それらを組み合わせて「四角形の横に円を描く」といった新たな記述を作り出すことができる。もし即座に描画が行われていたら合成は行えない。同様に、この仕組みの下では、プログラムは画面に何が表示されるかをそのまま記述しており、それ以前の描画による状態を気にする必要がないので、推論が容易である。

この章では、正規表現のための一連のインタープリタを構築することで、インタープリタ戦略を探求していく。正規表現を選んだのは、それが多くの人にとって馴染みのあるものであり、使い方もシンプルだからである。これなら、実現する機能(ここでは正規表現)固有の詳細にとらわれることなく、インタープリタ戦略の細部に集中することができるし、ついでに現実的で役に立つ結果を得ることもできる。

まずは、代数的データ型と構造的再帰を用いた基本的な実装戦略から始めよう。その後、スタックの利用を避けることでスタックオーバーフローの可能性を回避するバージョンへとインタープリタを変換する方法について見ていく。

5.1 正規表現

このケーススタディでは、まず正規表現の一般的なタスクであるテキストのマッチングについて簡単に記述し、次に、より理論的な観点から説明を加える。その後、実装へと進む。

正規表現は、ある文字列が特定のパターンに一致するかどうかを判定する技術として、もっとも広く用いられている。もっとも単純な正規表現は、ただひとつの文字列にのみマッチするものである。Scala では、String に対して r メソッドを呼び出すことで正規表現を作成できる。次の正規表現は、文字列 "Scala" に正確にマッチする。

val regexp = "Scala".r

これが "Scala" にだけマッチし、入力がそれより短くても長くてもマッチしないということを見ておこう。

regexp.matches("Scala")
// res22: Boolean = true
regexp.matches("Sca")
// res23: Boolean = false
regexp.matches("Scalaland")
// res24: Boolean = false

すでに記述と実行が分かれていることに注目してほしい。記述は r メソッド呼び出しによって作成される正規表現そのものであり、正規表現に対して matches メソッドを呼び出すことが実行にあたる。

正規表現を記述する文字列においては、特別な意味を持つ文字がいくつか存在する。たとえば、* という文字は、直前の文字の0回以上の繰り返しにマッチする。

val regexp = "Scala*".r
regexp.matches("Scal")
// res26: Boolean = true
regexp.matches("Scala")
// res27: Boolean = true
regexp.matches("Scalaaaa")
// res28: Boolean = true

括弧を使って文字の並びをグループ化することもできる。たとえば、"Scala""Scalala""Scalalala" といった文字列すべてにマッチさせたければ、次のような正規表現を用いればよい。

val regexp = "Scala(la)*".r

期待どおりにマッチするか確認しておこう。

regexp.matches("Scala")
// res30: Boolean = true
regexp.matches("Scalalalala")
// res31: Boolean = true

マッチしないケースも期待どおりであるか確認しておくほうがよいだろう。

regexp.matches("Sca")
// res32: Boolean = false
regexp.matches("Scalal")
// res33: Boolean = false
regexp.matches("Scalaland")
// res34: Boolean = false

Scala 組み込みの正規表現について述べるのは、ここまでにしておく。もっと詳しく知りたい場合は、オンラインにある多くのリソースを参照してほしい。たとえば、JDK の API 仕様では、正規表現の JVM 実装で利用できるすべての機能について詳述されている。

次に、正規表現を教科書のように学問的な記述で見てみよう。正規表現は以下のような構成要素から成り立っている。

  1. 何にもマッチしない空の正規表現
  2. ある文字列(空文字列も含む)に正確に一致する文字列
  3. ふたつの正規表現を連結したもの。最初の正規表現にマッチした後に二番目の正規表現にマッチする
  4. ふたつの正規表現の和集合。いずれかの正規表現にマッチする
  5. 正規表現の繰り返し。基礎となる表現の0回以上の繰り返しにマッチする

この種の記述は、慣れていないと抽象的に思えるかもしれないが、簡単に実装できる最小限の API を定義しており、今回の目的にとって非常に役に立つ。この記述を順に追いながら、各項目がどのようにコードで表現されるかを見ていこう。

空の正規表現は () => Regexp 型のコンストラクタを定義しており、これは Regexp 型の値に簡略化することができる。Scala ではコンストラクタをコンパニオンオブジェクトに配置するので、まず書くべきなのは次のようなコードである。

object Regexp {
  val empty: Regexp =
    ???
}

第二項からは、String => Regexp 型のコンストラクタが必要であることがわかる。

object Regexp {
  val empty: Regexp =
    ???

  def apply(string: String): Regexp =
    ???
}

残りの三項はいずれも正規表現から別の正規表現を生成する。Scala ではこれらは Regexp のインスタンスメソッドとして記述できる。ひとまずは Regexptrait で表現し、メソッドを定義することにしよう。

ひとつ目はふたつの正規表現を連結するメソッドである。Scala ではふたつのオブジェクトの連結メソッドは慣例的に ++ という名前をもつ。

trait Regexp {
  def ++(that: Regexp): Regexp
}

和集合を生成するメソッドは慣例に従って orElse と名付ける。

trait Regexp {
  def ++(that: Regexp): Regexp
  def orElse(that: Regexp): Regexp
}

繰り返しによって新しい Regexp を生成するメソッドは repeat と呼ぶことにし、標準的な正規表現での記法に倣って、* をそのエイリアスとして定義する。

trait Regexp {
  def ++(that: Regexp): Regexp
  def orElse(that: Regexp): Regexp
  def repeat: Regexp
  def `*`: Regexp = this.repeat
}

正規表現を実際に入力とマッチさせるメソッドも忘れてはならない。このメソッドは matches と呼ぶことにしよう。

trait Regexp {
  def ++(that: Regexp): Regexp
  def orElse(that: Regexp): Regexp
  def repeat: Regexp
  def `*`: Regexp = this.repeat
  
  def matches(input: String): Boolean
}

以上で API は完成である。次は実装に取り掛かる。Regexp を代数的データ型として表現し、Regexp 型の戻り値をもつ各メソッドにはこの代数的データ型のインスタンスを返却させる。代数的データ型を構成するバリアントはどのようなものにすべきだろうか。それぞれのメソッドに対してひとつバリアントが必要であり、メソッドに渡されるパラメータがそのままそのコンストラクタ引数となる。なお、暗黙的に渡される this もパラメータのひとつと考える

それらを反映したコードは以下のとおりである。

enum Regexp {
  def ++(that: Regexp): Regexp =
    Append(this, that)

  def orElse(that: Regexp): Regexp =
    OrElse(this, that)

  def repeat: Regexp =
    Repeat(this)

  def `*`: Regexp = this.repeat
  
  def matches(input: String): Boolean =
    ???
  
  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Empty
}
object Regexp {
  val empty: Regexp = Empty
  
  def apply(string: String): Regexp =
    Apply(string)
}

this について簡単に説明しておく。オブジェクトのすべてのメソッドは、そのオブジェクト自体を隠されたパラメータとして受け取っていると考えることができる。これが this である( Python を使用したことがあれば、これが self パラメータとして明示されるのを見たことがあるだろう)。this をメソッド呼び出しのパラメータと見なし、また実装戦略としてはすべてのメソッドパラメータをデータ構造に取り込むのだから、this が利用可能なのであればそれも取り込む必要がある。ただし、コンパニオンオブジェクトに定義されるコンストラクタについては、this は存在しないし、取り込む必要はない。

matches をまだ実装していないことを忘れてはならない。このメソッドの戻り値は Regexp 型ではないし、代数的データ型のバリアントを返すことはできない。どうすべきだろうか。Regexp は代数的データ型であり、matches は代数的データ型を Boolean に変換するのだから、構造的再帰を使用できる。骨組みを書き出し、データ構造が再帰的であるところに再帰呼び出しを配置してみよう。

def matches(input: String): Boolean =
  this match {
    case Append(left, right)   => left.matches(???) ??? right.matches(???)
    case OrElse(first, second) => first.matches(???) ??? second.matches(???)
    case Repeat(source)        => source.matches(???) ???
    case Apply(string)         => ???
    case Empty                 => ???
  }

これで、実装の完成に向けていつもの戦略を適用することができる。「ケースごとに独立して考える」を適用し、まずは Empty から考えていこう。このケースは常にマッチに失敗するので難しいことは何もない。ただ false を返すだけでよい。

def matches(input: String): Boolean =
  this match {
    case Append(left, right)   => left.matches(???) ??? right.matches(???)
    case OrElse(first, second) => first.matches(???) ??? second.matches(???)
    case Repeat(source)        => source.matches(???) ???
    case Apply(string)         => ???
    case Empty                 => false
  }

Append のケースに進もう。このケースにマッチするとは、正規表現 leftinput の先頭にマッチし、そのマッチが終了したところを開始位置として正規表現 right がマッチするということである。ここで新たな要件が明らかになった。どこからマッチを始めるべきかを示すインデックスを input と合わせて保持する必要がある。この追加情報を手元に置いておくには、ネストしたメソッドを使うのがもっとも簡単である。ここでは Option[Int] を返すネストメソッドを作成した。Int は新しいインデックスを表し、それが Option であることは正規表現にマッチしたかどうかを示す。

def matches(input: String): Boolean = {
  def loop(regexp: Regexp, idx: Int): Option[Int] =
    regexp match {
      case Append(left, right) =>
        loop(left, idx).flatMap(idx => loop(right, idx))
      case OrElse(first, second) => 
        loop(first, idx) ??? loop(second, ???)
      case Repeat(source) => 
        loop(source, idx) ???
      case Apply(string) => 
        ???
      case Empty =>
        None
    }

  // 入力全体がマッチしたかどうかを確認する
  loop(this, 0).map(idx => idx == input.size).getOrElse(false)
}

さらに先へ進み、実装を完了させよう。

def matches(input: String): Boolean = {
  def loop(regexp: Regexp, idx: Int): Option[Int] =
    regexp match {
      case Append(left, right) =>
        loop(left, idx).flatMap(i => loop(right, i))
      case OrElse(first, second) => 
        loop(first, idx).orElse(loop(second, idx))
      case Repeat(source) =>
        loop(source, idx)
          .flatMap(i => loop(regexp, i))
          .orElse(Some(idx))
      case Apply(string) =>
        Option.when(input.startsWith(string, idx))(idx + string.size)
    }

  // 入力全体がマッチしたかどうかを確認する
  loop(this, 0).map(idx => idx == input.size).getOrElse(false)
}

Repeat の実装はややトリッキーなので、コードを詳しく見ておこう。

case Repeat(source) =>
  loop(source, idx)
    .flatMap(i => loop(regexp, i))
    .orElse(Some(idx))

最初の行となる loop(source, index) では、入力が正規表現 source にマッチするかを確認している。 マッチした場合、再度ループを行うが、次は regexpRepeat(source) ) にマッチするかを調べる。これは、試行を無限に繰り返すためである。もしここで source に対して loop を呼び出したら、マッチするかどうかのチェックは二回だけで終わってしまう。Repeat が表すのは0回以上のマッチなので、loop によるマッチに失敗しても全体としては成功と見なされる。この条件は orElse 句によって実現されている。

最後に、実装が正しく動作するかをテストしておこう。

以下は、この章の冒頭に登場した正規表現の例を Regexp オブジェクトで表したものである。

val regexp = Regexp("Sca") ++ Regexp("la") ++ Regexp("la").repeat

以下はマッチに成功するケース、

regexp.matches("Scala")
// res36: Boolean = true
regexp.matches("Scalalalala")
// res37: Boolean = true

そして、以下は失敗すべきケースである。

regexp.matches("Sca")
// res38: Boolean = false
regexp.matches("Scalal")
// res39: Boolean = false
regexp.matches("Scalaland")
// res40: Boolean = false

これで実装は完了である。このライブラリにたくさんの拡張機能を追加することもできる。たとえば、正規表現には通常、1回以上の繰り返しにマッチするメソッド(通常 + で表される)や、0回もしくは1回の出現にマッチするメソッド(通常 ? で表される)がある。これらは便利だし、現実装の仕組みの延長で追加できるが、今は行わない。ここでの目標はインタープリタとその実装手法を完全に理解することにある。次節ではそれらについて詳しく見ていく。

正規表現の意味付けについて

今回の正規表現実装では、和集合の扱いが Scala の組み込み正規表現とは異なる。以下の例で両者の違いを比較してみよう。

val r1 = "(z|zxy)ab".r
val r2 = Regexp("z").orElse(Regexp("zxy")) ++ Regexp("ab")
r1.matches("zxyab")
// res41: Boolean = true
r2.matches("zxyab")
// res42: Boolean = false

今回の実装では、和集合を構成するパターンのうち先に入力の一部にマッチしたものが採用され、後続の入力に対してより長くマッチするパターンの存在を無視してしまう。それがこの違いの理由である。本来は両方のパターンを試すべきだが、それだと実装が複雑化してしまう。ここでは、学ぼうとしているプログラミング戦略の意義を知るための例として正規表現を用いているだけであり、その目的にとって正規表現の意味付けは本質的ではない。一般的な挙動の和集合を実装するための複雑さは利点を上回ると判断し、シンプルな実装にとどめた。だが、次章では正しい実装方法についても説明することになるだろう。

5.2 インタープリタとレイフィケーション

ここまでに書いた正規表現の実装では、ふたつの異なるプログラミング戦略が使われている。

  1. インタープリタ戦略
  2. レイフィケーションによるインタープリタの実装戦略

インタープリタ戦略の本質が記述と実行の分離であることを思い出してほしい。インタープリタ戦略を用いるにあたっては、少なくとも記述とインタープリタのふたつが必要である。記述はプログラムであり、我々が行いたいことを表している。インタープリタはそのプログラムを実行し、そこに記述された指示を実行に移す。

正規表現の例では Regexp オブジェクトがプログラムであり、文字列の中から探したいパターンを記述している。そして、matches メソッドがインタープリタである。記述された指示を実行に移し、パターンが入力全体にマッチするかどうか調べる。パターンが入力の一部にマッチするかどうかを判定するような別のインタープリタを作ることもできるだろう。

5.2.1 インタープリタの構造

インタープリタ戦略をに基づく実装では、メソッドには一定の分類や関係性があり、それが特定の構造をもっている。メソッドには以下の三種類がある。

  1. コンストラクタまたは導入形式(introduction form)A => Program という型をもつ。ここで A はプログラムではない任意の型で、Program は文字どおりプログラムを表す型である。コンストラクタは、Scala では慣習的に Program のコンパニオンオブジェクト上に定義される。正規表現の例では applyRegexp のコンストラクタのひとつだった。applyString => Regexp という型をもっており、コンストラクタがもつべき型である A => Program に該当している。もうひとつのコンストラクタである empty は単なる Regexp 型の値だったが、これは () => Regexp 型のメソッドと等価であり、やはりコンストラクタとしての型をもっている。

  2. コンビネータ。少なくともひとつのプログラムを入力として受け取り、プログラムを出力する。その型は Program => Program を基本とするが、追加のパラメータをもつことが往々にしてある。正規表現の例で見た ++orElse、および repeat はすべてコンビネータである。これらはすべて this パラメータとして Regexp 型の入力をもち、別の Regexp を生成する。++orElse は追加パラメータももっている。それらは Regexp 型だったが、コンビネータの追加パラメータが常にプログラム型であるというわけではない。コンビネータは通常 Program のメソッドとして定義される。

  3. デストラクタインタープリタ、または除去形式(elimination form)Program => A という型をもつ。正規表現の例ではインタープリタは matches のひとつだけだったが、追加するのは簡単である。たとえば、マッチした文字列の一部を抽出したり、入力の特定の位置でマッチする文字列を見つけたり、といったものが考えられる。

この構造は、関数型プログラミングの世界では代数コンビネータライブラリと呼ばれることがよくある。代数におけるコンストラクタやデストラクタは、代数的データ型のコンストラクタやデストラクタについてよりも抽象的なレベルで語られる。代数のコンストラクタとは、本書の分類でいうと理論レベルの抽象概念であり、その具体的実装として、技法レベルでは代数的データ型のコンストラクタという選択肢がある。実装方法は他にも存在するが、それについては改めて説明するつもりである。

5.2.2 レイフィケーションによるインタープリタ実装

インタープリタの構成要素について理解したので、今回使った実装戦略についてより明確に説明することが可能となった。使った戦略は、レイフィケーション(reification)脱関数化(defunctionalization)深い埋め込み(deep embedding)、または始代数(initial algebra)と呼ばれる。

具現化(reification)とは、一般的に言えば抽象的なものを実体のある形にすることを指す。プログラミングにおけるレイフィケーション(reification)とは、メソッドや関数をデータとして表現することを指す。インタープリタ戦略においては、解釈したいプログラムがもつ抽象的な構造を、Program 型を生成するすべての構成要素、すなわちコンストラクタやコンビネータへと落とし込むことを意味している。

以下にレイフィケーションのルールを挙げる。

  1. プログラムを表現する型を定義する。ここではそれを Program と呼ぶ
  2. Program を代数的データ型として実装する
  3. すべてのコンストラクタとコンビネータはそれぞれが代数的データ型 Program を構成する直積型となる
  4. 各直積型はコンストラクタもしくはコンビネータが受け取るパラメータと同じプロパティで構成される。コンビネータについては this パラメータもそこに含む

Program を代数的データ型として定義すれば、インタープリタは Program に対する構造的再帰となる。

演習: 算術式

ここでレイフィケーションの練習をしよう。課題は、算術式のインタープリタを実装することである。式は以下のいずれかであるとする。

以上の記述を Expression 型として具現化せよ。

ポイントは、テキストによる記述がコードとどのように対応するかを理解し、正しくレイフィケーションを適用することである。

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)
}
object Expression {
  def apply(value: Double): Expression =
    Literal(value)
}

続いて、Double 型の値を生成する eval インタープリタを実装せよ。インタープリタは一般的な算術規則に従って式を解釈することとする。

インタープリタは構造的再帰である。

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)
  
  def eval: Double =
    this match {
      case Literal(value)              => value
      case Addition(left, right)       => left.eval + right.eval
      case Subtraction(left, right)    => left.eval - right.eval
      case Multiplication(left, right) => left.eval * right.eval
      case Division(left, right)       => left.eval / right.eval
    }
}
object Expression {
  def apply(value: Double): Expression =
    Literal(value)
}

ライブラリの使い勝手を向上させるため +- などのメソッドを追加せよ。また、いくつかの式を記述し、このライブラリが期待どおりに動作することを確かめよ。

以下が完全なコードである。

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)

  def +(that: Expression): Expression =
    Addition(this, that)

  def -(that: Expression): Expression =
    Subtraction(this, that)

  def *(that: Expression): Expression =
    Multiplication(this, that)

  def /(that: Expression): Expression =
    Division(this, that)

  def eval: Double =
    this match {
      case Literal(value)              => value
      case Addition(left, right)       => left.eval + right.eval
      case Subtraction(left, right)    => left.eval - right.eval
      case Multiplication(left, right) => left.eval * right.eval
      case Division(left, right)       => left.eval / right.eval
    }
}
object Expression {
  def apply(value: Double): Expression =
    Literal(value)
}

また、以下は、その使い方およびコードが正しいことを示す例である。

val fortyTwo = ((Expression(15.0) + Expression(5.0)) * Expression(2.0) + Expression(2.0)) / Expression(1.0)
fortyTwo.eval
// res7: Double = 42.0

5.3 末尾再帰インタープリタ

構造的再帰は、これまでにも述べたとおりスタックを使用する。これが問題になることはあまりないが、特に深い再帰ではスタックオーバーフローを引き起こす可能性がある。ひとつの解決策は末尾再帰のプログラムを書くことである。末尾再帰のプログラムはスタック領域を使用しないので、スタックセーフであると表現されることもある。どのようなプログラムでも末尾再帰の形に変換することができる。

コールスタック

通常、メソッドや関数の呼び出しはコールスタックまたは単にスタックと呼ばれるメモリ領域を使用して実装される。メソッドや関数の呼び出しごとにスタックフレームと呼ばれる小さなメモリ領域がスタック上に確保される。メソッドや関数がリターンすると、このメモリは解放され、次の呼び出しで再利用可能になる。

しかし、多数のメソッド呼び出しがリターンすることなく積み重なっていくと、スタックが確保できる以上のフレームを要求されることがある。スタック上に利用可能なメモリがなくなった状態は、スタックをオーバーフローした、と表現される。Scala では、このような状況になると StackOverflowError が発生する。

この節では、末尾再帰、プログラムを末尾再帰の形に変換する方法、そして Scala ランタイムにおける制約とその回避策について議論する。

5.3.1 スタックの安全性に関する問題

まずは、どのような問題があるのかを見てみよう。Scala には、同じ文字列を繰り返し並べた新しい文字列を作る * メソッドがある。これを用いて String オブジェクトを作成する。

"a" * 4
// res12: String = "aaaa"

Regexprepeat メソッドを使えば、そのような文字列にマッチする正規表現を作ることができる。

Regexp("a").repeat.matches("a" * 4)
// res13: Boolean = true

だが、入力が極端に長くなると、インタープリタはスタックオーバーフローを発生させて失敗する。

Regexp("a").repeat.matches("a" * 20000)
// java.lang.StackOverflowError

こうなるのは、繰り返しの正規表現が入力にひとつマッチするたびに、インタープリタがリターンをはさむことなく loop を呼び出すからである。しかし、解決策がないわけではない。使用するスタック領域を一定に保つようにインタープリタを書き換えれば、どのような大きさの入力にも対応できるようになる。

5.3.2 末尾呼び出しと末尾位置

出発点となるのは 末尾呼び出し(tail call) である。末尾呼び出しは、新たなスタック領域を消費しないメソッド呼び出しである。末尾位置(tail position) にあるメソッド呼び出しのみが、末尾呼び出しに変換される候補となる。ただし、ランタイムの制約により、末尾位置にあるすべての呼び出しが末尾呼び出しに変換されるとは限らない7

あるメソッド呼び出しの戻り値が、そのままその呼び出し元の戻り値となる場合、その呼び出しは末尾位置にあるという。例を見てみよう。0から count までの整数を合計するメソッドについて二通りの実装を以下に示す。

def isntTailRecursive(count: Int): Int =
  count match {
    case 0 => 0
    case n => n + isntTailRecursive(n - 1)
  }

def isTailRecursive(count: Int): Int = {
  def loop(count: Int, accum: Int): Int =
    count match {
      case 0 => accum
      case n => loop(n - 1, accum + n)
    }
    
  loop(count, 0)
}

以下の isntTailRecursive 呼び出しは、戻り値が加算の中で用いられているため、末尾位置にない。

case n => n + isntTailRecursive(n - 1)

一方、以下の loop 呼び出しは、戻り値が即座にひとつ前の loop 呼び出しの戻り値として返却されるので、末尾位置にあると言える。

case n => loop(n - 1, accum + n)

同様に、以下の loop 呼び出しも末尾位置にある。

loop(count, 0)

末尾位置にあるメソッド呼び出しは末尾呼び出し最適化の候補である。Scala ランタイムの制約により、末尾位置にある呼び出しがすべて最適化されるわけではない。現在のところ、あるメソッドが末尾位置でそのメソッド自身を呼び出す場合のみ、それは最適化の対象となる。

先ほどの isTailRecursive の例で言えば、以下の部分には末尾呼び出し最適化が適用される。

case n => loop(n - 1, accum + n)

だが、以下の部分は isTailRecursive から loop を呼び出しているので、最適化されない。

loop(count, 0)

とはいえ、 これは一回の isTailRecursive につき一度しか発生しない呼び出しなので、スタック消費の観点で問題を起こすことはない。

ランタイムと末尾呼び出し

Scala は、JVM 、Scala.js を介した JavaScript、そして Scala Native を介したネイティブコードという三つの異なるプラットフォームをサポートし、プラットフォーム毎にランタイムを提供している。ランタイムとは、Scala コードの実行時にそれを支えるコードのことをいう。たとえばガベージコレクタはランタイムの一部である。

執筆時点では、Scala のどのランタイムも完全な末尾呼び出し最適化をサポートしていない。しかし、将来的には状況が変わる可能性がある。JVM においては、Project Loom によってようやく末尾呼び出し最適化がサポートされる見込みである。Scala Native では、継続の実装作業の一環として、近いうちにサポートされると考えられる。JavaScript においては、末尾呼び出し最適化はずっと以前から仕様に含まれているが、大半の JavaScript ランタイムはこれを実装していない。一方で、WebAssembly は末尾呼び出し最適化をサポートしている。中期的には Scala から JavaScript へのコンパイルは、WebAssembly へのコンパイルへと取って代わられるだろう。

メソッドに @tailrec アノテーションを付けることで、自己呼び出しがすべて末尾位置にあることを Scala コンパイラに確認させることができる。このアノテーションを付けたメソッド内で自己呼び出しが末尾位置にない場合、コンパイルエラーとなる。

import scala.annotation.tailrec

@tailrec
def isntTailRecursive(count: Int): Int =
  count match {
    case 0 => 0
    case n => n + isntTailRecursive(n - 1)
  }
// error:
// Cannot rewrite recursive call: it is not in tail position
//     case n => n + isntTailRecursive(n - 1)
//                   ^^^^^^^^^^^^^^^^^^^^^^^^

末尾再帰を使って書かれているコードが確かに末尾再帰になっているかは、大きな入力値を渡すことで確かめることができる。末尾再帰でないほうのコードはクラッシュする。

isntTailRecursive(100000)
// java.lang.StackOverflowError

末尾再帰版は正しく動作する。

isTailRecursive(100000)
// res16: Int = 705082704

5.3.3 継続渡しスタイル

これで末尾呼び出し最適化については把握できたが、これを利用する形に正規表現インタープリタを変換するにはどうすればよいだろうか。どのようなプログラムでも、継続渡しスタイル略して CPS と呼ばれる形に変換することで、すべての関数呼び出しを末尾位置にもってきた等価なプログラムを得ることができる。まずは継続について知ることが、CPS を理解するための最初のステップとなる。

継続とは「次に何が起きるのか」をカプセル化したものである。Regexp の例に戻ろう。以下にそのコード全体を再掲する。

enum Regexp {
  def ++(that: Regexp): Regexp =
    Append(this, that)

  def orElse(that: Regexp): Regexp =
    OrElse(this, that)

  def repeat: Regexp =
    Repeat(this)

  def `*` : Regexp = this.repeat

  def matches(input: String): Boolean = {
    def loop(regexp: Regexp, idx: Int): Option[Int] =
      regexp match {
        case Append(left, right) =>
          loop(left, idx).flatMap(i => loop(right, i))
        case OrElse(first, second) =>
          loop(first, idx).orElse(loop(second, idx))
        case Repeat(source) =>
          loop(source, idx)
            .flatMap(i => loop(regexp, i))
            .orElse(Some(idx))
        case Apply(string) =>
          Option.when(input.startsWith(string, idx))(idx + string.size)
        case Empty =>
          None
      }

    // 入力全体にマッチするかどうかチェック
    loop(this, 0).map(idx => idx == input.size).getOrElse(false)
  }

  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Empty
}
object Regexp {
  val empty: Regexp = Empty

  def apply(string: String): Regexp =
    Apply(string)
}

matches メソッドにおける Append のケースについて考えてみよう。

case Append(left, right) =>
  loop(left, idx).flatMap(i => loop(right, i))

loop(left, idx) 呼び出しに続いて起きることは何だろうか。loop の戻り値が result に格納されたとすると、実行されるのは result.flatMap(i => loop(right, i)) である。この処理は result を受け取る関数として次のように表現することができる。

(result: Option[Int]) => result.flatMap(i => loop(right, i))

これがまさに、データとして表現された継続である。

概念とその表現は往々にして異なるものである。継続という概念はコードの中に常に存在している。継続とは「次に何が起こるか」を指すものであり、言い換えればプログラムの制御フローそのものである。たとえあるプログラムがただ停止するだけであったとしても、そこには常に制御フローの概念が存在している。継続はコードの中で関数として表現することができる。これにより、継続という抽象的な概念はプログラム内の具体的な値へと変換、すなわちレイフィケーションされる。

継続およびそれが関数としてレイフィケーションされることについて理解したところで、次は継続渡しスタイル(CPS)の話に進もう。CPS では、その名前が示すように、継続を引数として受け渡す。具体的に言えば、各関数やメソッドは継続を追加のパラメータとして受け取り、値を返す代わりに、その値を渡して継続を呼び出す。これもまた双対性の一例で、今回のケースでは値を返すことと継続を呼び出すことが双対の関係にある。

Let’s see how this works. We’ll start with a simple example written in the normal style, also known as direct style.

CPS の仕組みを例で見てみよう。まずは通常のスタイルで書かれたシンプルな例からスタートする。このようなスタイルは直接スタイルと呼ばれる。

(1 + 2) * 3
// res17: Int = 9

これを CPS で書き換えるためには、 +* にパラメータとして継続を付け加えた関数を作成する必要がある。

type Continuation = Int => Int

def add(x: Int, y: Int, k: Continuation) = k(x + y)
def mul(x: Int, y: Int, k: Continuation) = k(x * y)

これで先ほどの式を CPS に書き換えることができる。(1 + 2)add(1, 2, k) となり、次にすべきことは加算の結果に 3 を掛けることなので、継続 ka => mul(a, 3, k2) となる。次の継続 k2 はどんな処理だろうか。プログラムはここで終了するので、恒等継続 b => b を用いてただ値を返せばよい。以上をまとめると次のプログラムが得られる。

add(1, 2, a => mul(a, 3, b => b))
// res18: Int = 9

CPS のコードでは継続の呼び出しがすべて末尾位置にあることに注目してほしい。つまり CPS で書かれたコードはスタック領域を消費せずに実行できる可能性をもっているのである。

Regexp におけるインタープリタループの話に戻ろう。これを CPS に書き換えるために、継続を受け取るパラメータを追加する。今回の場合、継続の引数と戻り値の型はどちらも、loop の結果型である Option[Int] となる。

def matches(input: String): Boolean = {
  // 継続を書きやすくするために型エイリアスを定義する
  type Continuation = Option[Int] => Option[Int]

  def loop(regexp: Regexp, idx: Int, cont: Continuation): Option[Int] =
  // etc...
}

続いて、各ケースを CPS へと書き換えていく。構築する継続は必ずその最終ステップとして cont を呼び出さなくてはならない。この変換作業は面倒でミスが発生しやすいため、適切なテストを用意することが重要である。

def matches(input: String): Boolean = {
  // 継続を書きやすくするために型エイリアスを定義する
  type Continuation = Option[Int] => Option[Int]

  def loop(
      regexp: Regexp,
      idx: Int,
      cont: Continuation
  ): Option[Int] =
    regexp match {
      case Append(left, right) =>
        val k: Continuation = _ match {
          case None    => cont(None)
          case Some(i) => loop(right, i, cont)
        }
        loop(left, idx, k)

      case OrElse(first, second) =>
        val k: Continuation = _ match {
          case None => loop(second, idx, cont)
          case some => cont(some)
        }
        loop(first, idx, k)

      case Repeat(source) =>
        val k: Continuation =
          _ match {
            case None    => cont(Some(idx))
            case Some(i) => loop(regexp, i, cont)
          }
        loop(source, idx, k)

      case Apply(string) =>
        cont(Option.when(input.startsWith(string, idx))(idx + string.size))
        
      case Empty =>
        cont(None)
    }

  // 入力全体にマッチするかどうかチェック
  loop(this, 0, identity).map(idx => idx == input.size).getOrElse(false)
}

Every call in this interpreter loop is in tail position. However Scala cannot convert these to tail calls because the calls go from loop to a continuation and vice versa. To make the interpreter fully stack safe we need to add trampolining.

このインタープリタでは loop 呼び出しはすべて末尾位置に置かれている。だが、loop から継続、そして継続から loop という相互呼び出しの形になっているため、Scala はこれらに対して末尾呼び出し最適化を行うことができない。このインタープリタを完全にスタックセーフにするにはトランポリン化を行う必要がある。

演習: CPS の算術式インタープリタ

前回の演習で作成した算術式のインタープリタを CPS へと書き換えよ。参考までに算術式の定義を再掲する。

この課題における継続の構造は、正規表現の例とはすこし異なる。正規表現の例では、継続が必要とする情報はすべて、継続のパラメータまたはパターンマッチによって抽出された値に含まれていた。一方、算術計算のコードで加算のような二項演算を行うには、ひとつ前の継続がもっている値をパラメータ以外の方法で受け取る必要がある。これを解決する方法は、継続を表すクロージャの環境内にそれらの値をキャプチャすることである。

type Continuation = Double => Double

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)

  def eval: Double = {
    def loop(expr: Expression, cont: Continuation): Double =
      expr match {
        case Literal(value) => cont(value)
        case Addition(left, right) =>
          loop(left, l => loop(right, r => cont(l + r)))
        case Subtraction(left, right) =>
          loop(left, l => loop(right, r => cont(l - r)))
        case Multiplication(left, right) =>
          loop(left, l => loop(right, r => cont(l * r)))
        case Division(left, right) =>
          loop(left, l => loop(right, r => cont(l / r)))
      }

    loop(this, identity)
  }
  
  def +(that: Expression): Expression =
    Addition(this, that)

  def -(that: Expression): Expression =
    Subtraction(this, that)

  def *(that: Expression): Expression =
    Multiplication(this, that)

  def /(that: Expression): Expression =
    Division(this, that)
}
object Expression {
  def apply(value: Double): Expression =
    Literal(value)
}

5.3.4 トランポリン化

先ほど述べたとおり、CPS は関数の呼び出しとリターンの双対性を利用し、値を返す代わりにその値を関数に渡して呼び出している。これにより、コードはすべての再帰呼び出しが末尾位置に来る形へと変換される。しかし、これでスタックセーフに関する課題が解決したわけではない。Scala ランタイムは末尾呼び出し最適化を完全にはサポートしておらず、継続からの loop 呼び出しや loop からの継続呼び出しにはスタックフレームが消費される。

この問題を解決するには、再び双対性を利用し、呼び出しを行う代わりに、行いたい呼び出しを表現する値を返せばよい。これがトランポリン化の基本的なアイデアである。具体的にどのように動作するのか見てみよう。そうすれば、ここで言わんとしていることが明確になるだろう。

最初のステップは、インタープリタループや継続によって行われるすべてのメソッド呼び出しをレイフィケーションすることである。ここで扱うケースは三つある。

type Continuation = Option[Int] => Call

enum Call {
  case Loop(regexp: Regexp, index: Int, continuation: Continuation)
  case Continue(index: Option[Int], continuation: Continuation)
  case Done(index: Option[Int])
}

次に、loop が呼び出しを直接行う代わりに Call インスタンスを返すよう変更する。

def loop(regexp: Regexp, idx: Int, cont: Continuation): Call =
  regexp match {
    case Append(left, right) =>
      val k: Continuation = _ match {
        case None    => Call.Continue(None, cont)
        case Some(i) => Call.Loop(right, i, cont)
      }
      Call.Loop(left, idx, k)

    case OrElse(first, second) =>
      val k: Continuation = _ match {
        case None => Call.Loop(second, idx, cont)
        case some => Call.Continue(some, cont)
      }
      Call.Loop(first, idx, k)

    case Repeat(source) =>
      val k: Continuation =
        _ match {
          case None    => Call.Continue(Some(idx), cont)
          case Some(i) => Call.Loop(regexp, i, cont)
        }
      Call.Loop(source, idx, k)

    case Apply(string) =>
      Call.Continue(
        Option.when(input.startsWith(string, idx))(idx + string.size),
        cont
      )

    case Empty =>
      Call.Continue(None, cont)
  }

これでインタープリタループは呼び出しを行う代わりに値を返す形となり、スタック領域を消費しない。しかし、これらの呼び出しはどこかの時点で実際に実行されなければならない。それをするのがトランポリンの役割である。トランポリンは、Done に到達するまで繰り返すシンプルな末尾再帰処理である。

def trampoline(next: Call): Option[Int] =
  next match {
    case Call.Loop(regexp, index, continuation) =>
      trampoline(loop(regexp, index, continuation))
    case Call.Continue(index, continuation) =>
      trampoline(continuation(index))
    case Call.Done(index) => index
  }

ループおよび継続の呼び出しはすべて即座にリターンするので、スタックの消費量は限定される。このインタープリタはメモリが許すかぎりどんなに大きなサイズの入力でも処理することができる。

以下に完全なコードを示す。

// 継続を書きやすくするために型エイリアスを定義する
type Continuation = Option[Int] => Call

enum Call {
  case Loop(regexp: Regexp, index: Int, continuation: Continuation)
  case Continue(index: Option[Int], continuation: Continuation)
  case Done(index: Option[Int])
}

enum Regexp {
  def ++(that: Regexp): Regexp =
    Append(this, that)

  def orElse(that: Regexp): Regexp =
    OrElse(this, that)

  def repeat: Regexp =
    Repeat(this)

  def `*` : Regexp = this.repeat

  def matches(input: String): Boolean = {
    def loop(regexp: Regexp, idx: Int, cont: Continuation): Call =
      regexp match {
        case Append(left, right) =>
          val k: Continuation = _ match {
            case None    => Call.Continue(None, cont)
            case Some(i) => Call.Loop(right, i, cont)
          }
          Call.Loop(left, idx, k)

        case OrElse(first, second) =>
          val k: Continuation = _ match {
            case None => Call.Loop(second, idx, cont)
            case some => Call.Continue(some, cont)
          }
          Call.Loop(first, idx, k)

        case Repeat(source) =>
          val k: Continuation =
            _ match {
              case None    => Call.Continue(Some(idx), cont)
              case Some(i) => Call.Loop(regexp, i, cont)
            }
          Call.Loop(source, idx, k)

        case Apply(string) =>
          Call.Continue(
            Option.when(input.startsWith(string, idx))(idx + string.size),
            cont
          )

        case Empty =>
          Call.Continue(None, cont)
      }

    def trampoline(next: Call): Option[Int] =
      next match {
        case Call.Loop(regexp, index, continuation) =>
          trampoline(loop(regexp, index, continuation))
        case Call.Continue(index, continuation) =>
          trampoline(continuation(index))
        case Call.Done(index) => index
      }

    // 入力全体にマッチするかどうかチェック
    trampoline(loop(this, 0, opt => Call.Done(opt)))
      .map(idx => idx == input.size)
      .getOrElse(false)
  }

  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Empty
}
object Regexp {
  val empty: Regexp = Empty

  def apply(string: String): Regexp =
    Apply(string)
}

演習: 算術式インタープリタのトランポリン化

CPS で記述された算術式インタープリタをトランポリン化せよ。

この解答コードを生み出すプロセスは正規表現の例とだいたい同じである。異なる種類の呼び出しをすべて特定し、それらをレイフィケーションすればよい。呼び出しの種類は正規表現の例と変わらない。

type Continuation = Double => Call

enum Call {
  case Continue(value: Double, k: Continuation)
  case Loop(expr: Expression, k: Continuation)
  case Done(result: Double)
}

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)

  def eval: Double = {
    def loop(expr: Expression, cont: Continuation): Call =
      expr match {
        case Literal(value) => Call.Continue(value, cont)
        case Addition(left, right) =>
          Call.Loop(
            left,
            l => Call.Loop(right, r => Call.Continue(l + r, cont))
          )
        case Subtraction(left, right) =>
          Call.Loop(
            left,
            l => Call.Loop(right, r => Call.Continue(l - r, cont))
          )
        case Multiplication(left, right) =>
          Call.Loop(
            left,
            l => Call.Loop(right, r => Call.Continue(l * r, cont))
          )
        case Division(left, right) =>
          Call.Loop(
            left,
            l => Call.Loop(right, r => Call.Continue(l / r, cont))
          )
      }

    def trampoline(call: Call): Double =
      call match {
        case Call.Continue(value, k) => trampoline(k(value))
        case Call.Loop(expr, k)      => trampoline(loop(expr, k))
        case Call.Done(result)       => result
      }

    trampoline(loop(this, x => Call.Done(x)))
  }

  def +(that: Expression): Expression =
    Addition(this, that)

  def -(that: Expression): Expression =
    Subtraction(this, that)

  def *(that: Expression): Expression =
    Multiplication(this, that)

  def /(that: Expression): Expression =
    Division(this, that)
}
object Expression {
  def apply(value: Double): Expression =
    Literal(value)
}

5.3.5 末尾再帰が容易なケース

完全な CPS への変換やトランポリン化にはかなり手間がかかることがある。しかし、それほど大きな変更を加えずに末尾再帰にすることができる場合もある。先ほど見た以下の例を思い出そう。

def isntTailRecursive(count: Int): Int =
  count match {
    case 0 => 0
    case n => n + isntTailRecursive(n - 1)
  }

def isTailRecursive(count: Int): Int = {
  def loop(count: Int, accum: Int): Int =
    count match {
      case 0 => accum
      case n => loop(n - 1, accum + n)
    }
    
  loop(count, 0)
}

この末尾再帰バージョンは、CPS のような複雑さを伴っていないように見える。これを、これまで学んだ内容に対してどのように位置付ければよいだろうか。また、どのような場合に CPS やトランポリンを用いる手間を省くことができるのだろうか。

それぞれのメソッドがどのようにスタックを使用するのか、小さな count 値を例とし、代入(substitution)を使って確認してみよう。

isntTailRecursive(2)
// expands to
(2 match {
  case 0 => 0
  case n => n + isntTailRecursive(n - 1)
})
// expands to
(2 + isntTailRecursive(1))
// expands to
(2 + (1 match {
        case 0 => 0
        case n => n + isntTailRecursive(n - 1)
      }))
// expands to
(2 + (1 + isntTailRecursive(n - 1)))
// expands to
(2 + (1 + (0 match {
             case 0 => 0
             case n => n + isntTailRecursive(n - 1)
           })))
// expands to
(2 + (1 + (0)))
// expands to
3

このコードにおいて、括弧は新しいメソッド呼び出しとそれによるスタックフレームの割り当てを表している。

同じことを isTailRecursive でも行ってみよう。

isTailRecursive(2)
// expands to
(loop(2, 0))
// expands to
(2 match {
   case 0 => 0
   case n => loop(n - 1, 0 + n)
 })
// expands to
(loop(1, 2))
// call to loop is a tail call, so no stack frame is allocated 
// expands to
(1 match {
   case 0 => 2
   case n => loop(n - 1, 2 + n)
 })
// expands to
(loop(0, 3))
// call to loop is a tail call, so no stack frame is allocated 
// expands to
(0 match {
   case 0 => 3
   case n => loop(n - 1, 3 + n)
 })
// expands to
(3)
// expands to
3

非末尾再帰の関数は (2 + (1 + (0))) という計算を行って結果を得る。よく見ると、末尾再帰バージョンでは (((2) + 1) + 0) を計算しており、単に結果を逆順に蓄積しているだけである。これがうまくいくのは、加算が結合律を満たしている、すなわち (a + b) + c == a + (b + c) が成り立つからである。結果を蓄積する演算が結合的であること、それが末尾再帰形への変換を簡単な方法で行うための第一の条件である。

第二の条件は、メモリ上に保持する必要のある情報を、計算の途中結果以外にはもたないことである。この条件が意味することはいくつかあるが、たとえば、途中で処理を停止しても利用可能な結果が得られること、各データに計算が適用されるのはそれぞれ一度だけということなどが挙げられる。正規表現の例ではこれが成り立たない。たとえば Append のケースを処理する以下のコードを考えてみよう。

case Append(left, right) =>
  loop(left, idx).flatMap(i => loop(right, i))

Append の結果を得るためには、leftright 両方の結果を計算し組み合わせる必要がある。right の結果を計算した時点で、left の結果も記憶している必要があるし、両方の結果が他でもない Append のルールに従って組み合わされるということも覚えていなければならない。この「覚えておく」という行為こそが、まさに継続が果たしている役割であり、そのようなケースでは、リストの要素を合計する際に用いた簡単な方法を適用することはできない。

まとめると、結合的な演算を各データに対して一度だけ適用する場合、以下の方法で簡単に末尾再帰のメソッドを記述できる。

  1. 途中結果(蓄積変数)をパラメータとして加えた構造的再帰のループを定義する
  2. 基本ケースでは蓄積変数をそのまま返す
  3. 再帰ケースでは蓄積変数を更新し、末尾位置でループを呼び出す

この方法をツリー構造のデータに適用する方法が気になるかもしれない。演算は結合的であることが前提なのだから、任意の演算のシーケンスはリスト状のシーケンスに変換することができる。たとえば (1 + 2) + (3 + 4) という順序で演算を適用するツリー構造の式は(ここでは演算を表す記号として + を用いる)、結合律に基づき (((1 + 2) + 3) + 4) という形に書き換えることができる。したがって、ツリーはリストに変換して上記の手順を適用すればよい。

case Append(left, right) =>
  loop(left, idx).flatMap(i => loop(right, i))

5.4 まとめ

この章では、インタープリタを構築する理由について議論し、その構築手法を見てきた。改めて述べると、インタープリタ戦略の核心は記述と実行の分離にある。記述はプログラムであり、インタープリタはそのプログラムを実行する。この分離によって、プログラムの合成および、プログラムの実行時まで作用を遅延させて管理することが可能となる。プログラムはコンストラクタとコンビネータで定義され、インタープリタはデストラクタとして定義される。この構造はしばしば代数と呼ばれる。戦略の名前はインタープリタに焦点を置いているが、プログラムはプログラマがシステムと対話するためのユーザインターフェースであり、その設計も同様に重要である。

最初の実装戦略は、代数が定義するコンストラクタとコンビネータを代数的データ型としてレイフィケーションすることだった。そうすれば、インタープリタはこの代数的データ型に対する構造的再帰となる。だが、単純な実装はスタックセーフでないことがわかり、それを解決するために末尾再帰と継続というアイデアを導入した。継続を関数としてレイフィケーションし、どのようなプログラムであってもすべてのメソッド呼び出しが末尾位置に来る継続渡しスタイルへと変換可能であることを見た。しかし、Scala ランタイムの制約により、末尾位置にある呼び出しがすべて末尾呼び出し最適化の対象となるわけではない。そこで、呼び出しとリターンをデータ構造としてレイフィケーションし、トランポリンと呼ばれる再帰ループで扱えるようにした。これらの戦略の根底には双対性の概念がある。レイフィケーションにあたっては関数とデータの双対性を活用し、継続やトランポリン化においては関数呼び出しとデータの返却との間の双対性を利用している。

スタックセーフなインタープリタは多くの場面で重要だが、基本的な構造的再帰とくらべてコードは読みにくくなる。基本的なインタープリタで十分なケースもあるだろう。

算術式の例のように単純な式ツリーを評価する場合、スタック領域が枯渇する可能性はほぼない。そのようなツリーの深さは要素数に対して対数的に増大するので、極端に大きなツリーでないかぎり、その深さがスタックセーフ性の問題へとつながることはない。一方で、正規表現の例では、スタック消費は正規表現ツリーの深さではなくマッチング対象となる入力の長さによって決まる。このようなケースではスタックセーフ性はより重要となる。単純な実装が許容される条件は他にもあるかもしれない。仮に、入力が小さいことを保証されているシチュエーションでのみライブラリが使用されるとわかっているのであれば、スタックセーフでない実装でも問題はない。常に、場面に応じて適切な技術を選択することが重要である。

これらのアイデアはプログラミング言語理論において古典的なものである。「Definitional Interpreters for Higher-Order Programming Languages [Reynolds 1972]」は、レイフィケーションのひとつの形態である脱関数化や、継続渡しスタイルについて詳述している。この論文を読むなら1998年の再組版バージョンを推奨する。元のタイプライター版よりもはるかに読みやすい。「Defunctionalization at Work [Danvy and Nielsen 2001]」はこれらのアイデアを発展させている。「Continuation-Passing Style, Defunctionalization, Accumulations, and Associativity [Gibbons 2022]」は、これらの変換における結合律の重要性にフォーカスした非常に読みやすく洗練された論文である。

{#sec:part:two}

第二部では具体的な型クラスに焦点を当てる。4章では、型クラスという仕組みがどのように実現されているかを見たが、ここでは実際に役立ついくつかの型クラスを取り上げる。これらは、日常のプログラミング作業に便利なだけでなく、プログラム設計の指針となる概念モデルとしても重要である。この部では主に、型クラスの実用的な活用方法について学び、ケーススタディではそれらが設計においてどのような役割を果たすかを掘り下げる。

6章では Cats ライブラリを紹介する。Cats は、本書で扱う型クラスの実装を提供している。それらを利用することで多くの時間とコードの記述を節約することができる。

原文のTODO: 記述を完成させる

6 Cats を使う

この章では Cats ライブラリの使い方について学ぶ。Cats が提供するものは主にふたつあり、ひとつは型クラスとそのインスタンス、もうひとつはいくつかの便利なデータ構造である。本書では主に型クラスにフォーカスを当てるが、必要に応じてデータ構造についても触れる。

6.1 クイックスタート

もっとも簡単かつ推奨されている Cats の導入方法は、以下のインポートを追加することである。

import cats.*
import cats.syntax.all.*

ひとつ目のインポートはすべての型クラスを追加する。コンパニオンオブジェクトに定義されている型クラスインスタンスも利用可能となる。ふたつ目のインポートはシンタックスヘルパーを追加する。これによって型クラスが更に扱いやすくなる。import cats.{*, given} のようなインポートは必要ない。執筆時点では、Cats は Scala2 スタイル、つまり implicit を使って書かれており、それらはワイルドカードによるインポートで取り込まれるからである。

Cats が提供するデータ構造を使用したい場合は、以下も追加する必要がある。

import cats.data.*

6.2 Cats を使う

cats.Show を例に、Cats の使い方を見ていこう。

Show は Cats が提供する型クラスで、4.5節で定義した Display に相当する。toString を使わずに開発者向けのコンソール出力を生成する仕組みを提供する。以下はその定義を簡略化したものである。

package cats

trait Show[A] {
  def show(value: A): String
}

Show を使うもっとも簡単な方法は、前述のワイルドカードインポートを利用することだが、直接 cats パッケージからインポートすることもできる。

import cats.Show

Cats の各型クラスのコンパニオンオブジェクトには、指定した型のインスタンスを見つける apply メソッドがある。

val showInt = Show.apply[Int]

インスタンスを手に入れたら、そのメソッドを呼び出せばよい。

showInt.show(42)
// res7: String = "42"

だが、import cats.syntax.all.* でインポートした構文や拡張メソッドを使う方が一般的である。Show に対しては、拡張メソッド show が定義されている。

42.show
// res8: String = "42"

何らかの理由で show についての構文だけが必要な場合、cats.syntax.show をインポートすることもできる。

import cats.syntax.show.*

6.2.1 独自インスタンスの定義

Show のインスタンスを定義するには、対象となる型に対してトレイトを実装するだけでよい。

import java.util.Date

given dateShow: Show[Date] with 
  def show(date: Date): String =
    s"${date.getTime}ms since the epoch."
new Date().show
// res9: String = "1748783366003ms since the epoch."

しかし、Cats にはこのプロセスを簡略化するための便利なメソッドもいくつか用意されている。Show のコンパニオンオブジェクトには、独自の型に対してインスタンスを定義するためのふたつの構築メソッドがある。

object Show {
  // 関数を `Show` インスタンスに変換する
  def show[A](f: A => String): Show[A] =
    ???

  // `toString` メソッドから `Show` インスタンスを作成する
  def fromToString[A]: Show[A] =
    ???
}

これらのメソッドを使えば、一から定義するよりも手間をかけずにインスタンスを構築できる。

given dateShow: Show[Date] =
  Show.show(date => s"${date.getTime}ms since the epoch.")

見てのとおり、構築メソッドを用いたコードは、そうでないコードよりもかなり簡潔である。Cats の多くの型クラスはこのようなヘルパーメソッドを提供しており、一からの、もしくは他の型用の既存インスタンスから変換することによるインスタンス構築をサポートしてくれる。

6.2.1.1 演習: Cat 用の Show インスタンス

4.5.1節の Cat アプリケーションを、Display の代わりに Show を用いて再実装せよ。

まずは必要なものを Cats からインポートしよう。

import cats.*
import cats.syntax.all.*

Cat の定義は次のとおり元のままでよい。

final case class Cat(name: String, age: Int, color: String)

コンパニオンオブジェクトでは、先述のヘルパーメソッドのひとつを使って Display インスタンスを Show インスタンスに置き換える。

given catShow: Show[Cat] = Show.show[Cat] { cat =>
  val name  = cat.name.show
  val age   = cat.age.show
  val color = cat.color.show
  s"$name is a $age year-old $color cat."
}

最後に、Show のインターフェース構文を用いて Cat インスタンスを出力する。

println(Cat("Garfield", 38, "ginger and black").show)
// Garfield is a 38 year-old ginger and black cat.

6.3 例題: Eq

この章の最後に、もうひとつの便利な型クラスである cats.Eq を見ていく。Eq型安全な等価性をサポートし、Scala 組み込みの == 演算子の使いづらさを解消するように設計されている。

ほとんどの Scala 開発者は、以下のようなコードを書いたことがあるだろう。

List(1, 2, 3).map(Option(_)).filter(item => item == 1)
// warning: Option[Int] and Int are unrelated: they will most likely never compare equal
// res: List[Option[Int]] = List()

もちろん、このような単純なミスを犯すことは少ないだろうが、言いたいことの要点は伝わるだろう。この filter 句の述語は、IntOption[Int] を比較しているため、常に false を返す。

これはプログラマのミスで、item1 ではなく Some(1) と比較されるべきだった。しかし、 == はどのような型のオブジェクト同士にも使えるので、形式的には型エラーではない。Eq は、型安全な等価性チェックを提供し、この問題を解決する。

6.3.1 自由、友愛、等価性

Eq を使えば、任意の型のインスタンス間で型安全な等価性を定義できる。

package cats

trait Eq[A] {
  def eqv(a: A, b: A): Boolean
  // ...eqvに基づくその他の具象メソッド
}

cats.syntax.eq で定義されているインターフェース構文では、スコープ内に Eq[A] インスタンスが存在する場合、等価性チェックを行うためのふたつのメソッドが提供される。

6.3.2 Int の比較

いくつか例を見てみよう。とりあえず型クラスをインポートしておく。

import cats.*

次に Int 用のインスタンスを手に入れる。

val eqInt = Eq[Int]

これで、eqInt を直接用いて等価性を調べることができる。

eqInt.eqv(123, 123)
// res21: Boolean = true
eqInt.eqv(123, 234)
// res22: Boolean = false

Scala の == メソッドと異なり、違う型のオブジェクトを eqv で比較しようとするとコンパイルエラーになる。

eqInt.eqv(123, "234")
// error:
// Found:    ("234" : String)
// Required: Int
// eqInt.eqv(123, "234")
//                ^^^^^

cats.syntax.eq からインターフェース構文をインポートし === および =!= を使うこともできる。

import cats.syntax.all.* // === と =!= をインポートする
123 === 123
// res24: Boolean = true
123 =!= 234
// res25: Boolean = true

こちらも、異なる型をもつ値同士を比較するとコンパイルエラーになる。

123 === "123"
// error:
// Found:    ("123" : String)
// Required: Int
// 123 === "123"
//         ^^^^^

6.3.3 Option の比較

ここで、Option[Int] に関するすこし興味深い例を見てみよう。

Some(1) === None
// error: 
// value === is not a member of Some[Int] - did you mean Some[Int].==?

このコードは、型が完全には一致していないため、エラーとなる。IntOption[Int]Eq インスタンスはスコープ内にあるが、比較している値は Some[Int] 型である。この問題を解決するには、引数を Option[Int] 型に明示的にキャストする必要がある。

(Some(1) : Option[Int]) === (None : Option[Int])
// res28: Boolean = false

標準ライブラリの Option.applyOption.empty メソッドを使えば、もっとわかりやすく書ける。

Option(1) === Option.empty[Int]
// res29: Boolean = false

あるいは、cats.syntax.option が提供する特別な構文を使って、以下のように書くこともできる。

1.some === none[Int]
// res30: Boolean = false
1.some =!= none[Int]
// res31: Boolean = true

6.3.4 独自型の比較

独自の Eq インスタンスを定義したいときは、Eq.instance メソッドを使えばよい。Eq.instance(A, A) => Boolean 型の関数を受け取って Eq[A] を返す。

import java.util.Date

given dateEq: Eq[Date] =
  Eq.instance[Date] { (date1, date2) =>
    date1.getTime === date2.getTime
  }
val x = new Date() // 現在日時
val y = new Date() // 現在より一瞬後の日時
x === x
// res32: Boolean = true
x === y
// res33: Boolean = true

6.3.4.1 演習: 自由、友愛、等にゃん性

Cat の例に対して Eq インスタンスを実装せよ。

final case class Cat(name: String, age: Int, color: String)

また、それを用いて、以下のオブジェクト同士の等価性と非等価性を確認せよ。

val cat1 = Cat("Garfield",   38, "orange and black")
val cat2 = Cat("Heathcliff", 33, "orange and black")

val optionCat1 = Option(cat1)
val optionCat2 = Option.empty[Cat]

まずは Cats のインポートを行う。この演習では Eq 型クラスと Eq のインターフェース構文を使用するので、それらのインポートから始める。

import cats.*
import cats.syntax.all.* 

Cat クラスはこれまでどおりである。

final case class Cat(name: String, age: Int, color: String)

Eq[Cat] の実装に必要となる IntStringEq インスタンスをスコープに入れる。

given catEqual: Eq[Cat] =
  Eq.instance[Cat] { (cat1, cat2) =>
    (cat1.name  === cat2.name ) &&
    (cat1.age   === cat2.age  ) &&
    (cat1.color === cat2.color)
  }

最後に、サンプルアプリケーションを作成しテストすれば完了である。

val cat1 = Cat("Garfield",   38, "orange and black")
// cat1: Cat = Cat(name = "Garfield", age = 38, color = "orange and black")
val cat2 = Cat("Heathcliff", 32, "orange and black")
// cat2: Cat = Cat(name = "Heathcliff", age = 32, color = "orange and black")

cat1 === cat2
// res35: Boolean = false
cat1 =!= cat2
// res36: Boolean = true

val optionCat1 = Option(cat1)
// optionCat1: Option[Cat] = Some(
//   value = Cat(name = "Garfield", age = 38, color = "orange and black")
// )
val optionCat2 = Option.empty[Cat]
// optionCat2: Option[Cat] = None

optionCat1 === optionCat2
// res37: Boolean = false
optionCat1 =!= optionCat2
// res38: Boolean = true

7 モノイドと半群

この節では、モノイド(monoid)および半群(semigroup)という型クラスについて見ていく。これらの型クラスは値同士の加算や結合に用いられる。IntStringListOption など、さまざまな型にインスタンスが用意されている。まずは、いくつかの簡単な型と操作について確認し、そこからどのような共通原則を抽出できるか見てみよう。

7.0.0.1 整数の加算

Int の加算は、閉じた二項演算である。閉じているとは、ふたつの Int を足した結果も常に Int になるということを意味する。

2 + 1
// res22: Int = 3

また、0 という単位元(identity element)が存在する。単位元 0 は、任意の Inta に対して a + 0 == 0 + a == a となる性質をもっている。

2 + 0
// res23: Int = 2

0 + 2
// res24: Int = 2

加算には他の性質もある。たとえば、どのような順序で値を足し合わせても、常に同じ結果が得られる。この性質は結合律(associativity)として知られる。

(1 + 2) + 3
// res25: Int = 6

1 + (2 + 3)
// res26: Int = 6

7.0.0.2 整数の乗算

単位元として 0 ではなく 1 を選べば、乗算にも加算と同じ性質が当てはまる。

1 * 3
// res27: Int = 3

3 * 1
// res28: Int = 3

乗算も加算と同様に結合律を満たす。

(1 * 2) * 3
// res29: Int = 6

1 * (2 * 3)
// res30: Int = 6

7.0.0.3 文字列の連結・シーケンスの連結

二項演算子として文字列の連結メソッドをとれば、String 同士も演算が可能である。

"One" ++ "two"
// res31: String = "Onetwo"

この演算に対しては、空文字が単位元として使える。

"" ++ "Hello"
// res32: String = "Hello"

"Hello" ++ ""
// res33: String = "Hello"

文字列の連結もまた結合律を満たす。

("One" ++ "Two") ++ "Three"
// res34: String = "OneTwoThree"

"One" ++ ("Two" ++ "Three")
// res35: String = "OneTwoThree"

ここでは、シーケンス(順序付きコレクション、つまり Scala における Seq のことを指す。以下同じ)との類似性を示すために、通常の + ではなく ++ を使っている。連結処理を二項演算とし、空のシーケンスを単位元とすれば、他のシーケンス型についても String と同じことが言える。

7.1 モノイドの定義

ここまで、結合律を満たす二項加法と単位元をもった「加算」の例をいくつか見てきた。そういうものがモノイドであると言われても驚きはないだろう。形式的には、型 A についてのモノイドは以下のふたつから構成される。

この定義は Scala コードにうまく翻訳できる。以下は、Cats における定義を簡略化したものである。

trait Monoid[A] {
  def combine(x: A, y: A): A
  def empty: A
}

モノイドは、combineempty という操作を提供するだけでなく、いくつかの法則を満たす必要がある。A 型の任意の値 x, y, z について、combine は結合律を満たし、empty は単位元でなければならない。

def associativeLaw[A](x: A, y: A, z: A)
      (using m: Monoid[A]): Boolean = {
  m.combine(x, m.combine(y, z)) ==
    m.combine(m.combine(x, y), z)
}

def identityLaw[A](x: A)
      (using m: Monoid[A]): Boolean = {
  (m.combine(x, m.empty) == x) &&
    (m.combine(m.empty, x) == x)
}

たとえば、整数の引き算は結合律を満たさないので、モノイドではない。

(1 - 2) - 3
// res36: Int = -4

1 - (2 - 3)
// res37: Int = 2

実際には、Monoid インスタンスを自作する場合にだけ、法則について考えればよい。法則を満たさないインスタンスは危険である。Cats の他の機能と組み合わせたときに予測不能な結果を生じる可能性がある。大抵の場合、Cats が提供するインスタンスは信頼できるし、ライブラリの作者は適切にそれらを実装してくれていると考えてよい。

7.2 半群の定義

半群とは、モノイドから empty 部を除いた、combine 部だけをもつものをいう。多くの半群はモノイドでもあるが、empty を定義できないデータ型も存在する。たとえば、シーケンスの連結や整数の加算がモノイドであることは見てきたが、非空のシーケンスや正の整数に限定すると、妥当な empty を定義することはできなくなる。Cats には NonEmptyList というデータ型がある。この型には Semigroup の実装はあるが、Monoid の実装はない。

Cats における Monoid のより正確な定義は以下のとおりである。ただし、これもやはりまだ簡略化されている。

trait Semigroup[A] {
  def combine(x: A, y: A): A
}

trait Monoid[A] extends Semigroup[A] {
  def empty: A
}

型クラスについて議論する際には、このような継承の形をよく目にするだろう。この設計により、モジュール性が生まれ、振る舞いを再利用できるようになる。型 A に対して Monoid を定義すれば、自動的に Semigroup が得られる。同様に、Semigroup[B] 型のパラメータを要求するメソッドに対して Monoid[B] を代わりに渡すこともできる。

7.2.0.1 演習: モノイドの真実

モノイドの例をいくつか見てきたが、モノイドとして認識していないものが他にも数多く存在する。Boolean 型について、いくつのモノイドを定義できるか考察せよ。また、それらの各モノイドに combineempty を定義し、モノイドの法則が成り立つことを確認せよ。なお、出発点として以下の定義を利用すること。

trait Semigroup[A] {
  def combine(x: A, y: A): A
}

trait Monoid[A] extends Semigroup[A] {
  def empty: A
}

object Monoid {
  def apply[A](implicit monoid: Monoid[A]) =
    monoid
}

Boolean には少なくとも4つのモノイドがある。まず、&& 演算子で表される論理積という二項演算と単位元 true である。

given booleanAndMonoid: Monoid[Boolean] with {
  def combine(a: Boolean, b: Boolean) = a && b
  def empty = true
}

ふたつ目に、|| 演算子で表される論理和と単位元 false

given booleanOrMonoid: Monoid[Boolean] with {
  def combine(a: Boolean, b: Boolean) = a || b
  def empty = false
}

三つ目に、排他的論理和と単位元 false

given booleanEitherMonoid: Monoid[Boolean] with {
  def combine(a: Boolean, b: Boolean) =
    (a && !b) || (!a && b)

  def empty = false
}

最後は、否定排他的論理和(排他的論理和の否定)と単位元 true である。

given booleanXnorMonoid: Monoid[Boolean] with {
  def combine(a: Boolean, b: Boolean) =
    (!a || b) && (a || !b)

  def empty = true
}

それぞれのケースについて単位元の法則が成り立つことは簡単に示せる。同様に、combine が結合律を満たすことについても、全パターンを列挙して示すことができる。

7.2.0.2 演習: All Set for Monoids

集合について、どのようなモノイドと半群があるか、考察せよ。

集合同士の和集合をとる演算は、空集合を単位元としてモノイドを形成する。

given setUnionMonoid[A]: Monoid[Set[A]] with {
  def combine(a: Set[A], b: Set[A]) = a.union(b)
  def empty = Set.empty[A]
}

setUnionMonoid は、型パラメータ A を受け取れるように、値ではなくメソッドとして定義する必要がある。この型パラメータによって、ひとつの定義から任意のデータ型の Set についてモノイドを呼び出せるようになる。

val intSetMonoid = Monoid[Set[Int]]
val strSetMonoid = Monoid[Set[String]]
intSetMonoid.combine(Set(1, 2), Set(2, 3))
// res40: Set[Int] = Set(1, 2, 3)
strSetMonoid.combine(Set("A", "B"), Set("B", "C"))
// res41: Set[String] = Set("A", "B", "C")

積集合をとる演算は半群を形成するが、単位元がないためモノイドにはならない。

given setIntersectionSemigroup[A]: Semigroup[Set[A]] with {
  def combine(a: Set[A], b: Set[A]) =
    a.intersect(b)
}

補集合と差集合は結合律を満たさないため、モノイドや半群にはなり得ない。しかし、対称差(和集合から積集合を除いたもの)をとる演算は、空集合を単位元としてモノイドを形成する。

given symDiffMonoid[A]: Monoid[Set[A]] with {
  def combine(a: Set[A], b: Set[A]): Set[A] =
    (a.diff(b)).union(b.diff(a))

  def empty: Set[A] = Set.empty
}

7.3 Cats におけるモノイド

ここまで、モノイドとは何かを見てきた。次に、Cats におけるモノイドの実装を見ていこう。ここでも、実装の三つの主要な側面である型クラスインスタンス、そして利用インターフェースに注目していく。

7.3.1 モノイド型クラス

モノイドを表す型クラスは cats.kernel.Monoid である。これには cats.Monoid としてエイリアスが定義されている。Monoidcats.kernel.Semigroupcats.Semigroup というエイリアスをもつ)を拡張している。Cats を使用するときは、通常 cats パッケージから型クラスをインポートする。

import cats.Monoid
import cats.Semigroup

あるいは単に次のようにインポートしてもよい。

import cats.*

Cats Kernelとは

Cats Kernel は Cats のサブプロジェクトで、Cats 全体の広範な機能群を必要としないライブラリ向けに、一部の型クラスだけを提供している。Cats のコアであるこれらの型クラスは形式上は cats.kernel パッケージに定義されているが、すべて cats パッケージにエイリアスされているため、通常は両者の違いを意識する必要はない。

本書で扱う Cats Kernel の型クラスは、EqSemigroup、および Monoid である。これ以外に本書で扱う型クラスはすべて Cats のメインとなるプロジェクトに属しており、cats パッケージに直接定義されている。

7.3.2 Monoid インスタンス

Monoid のユーザインターフェースは Cats の標準的なパターンに従っており、コンパニオンオブジェクトには、特定の型のための型クラスインスタンスを返す apply メソッドが用意されている。たとえば、String に対するモノイドインスタンスを取得したい場合、スコープ内に適切な given インスタンスがあれば、以下のように記述できる。

import cats.Monoid
Monoid[String].combine("Hi ", "there")
// res14: String = "Hi there"
Monoid[String].empty
// res15: String = ""

これは以下のように書くのと同義である。

Monoid.apply[String].combine("Hi ", "there")
// res16: String = "Hi there"
Monoid.apply[String].empty
// res17: String = ""

すでに学んだとおり、MonoidSemigroup を拡張している。empty を必要としない場合は、次のように書き換えることもできる。

import cats.Semigroup
Semigroup[String].combine("Hi ", "there")
// res18: String = "Hi there"

The standard type class instances for Monoid are all found on the appropriate companion objects, and so are automatically in the given scope with no further imports required.

Monoid の標準的な型クラスインスタンスはすべて、適切なコンパニオンオブジェクトに定義されており、追加のインポートをせずとも、自動的に given スコープに含まれている。

7.3.3 モノイドの構文

Catsは combine メソッド用の構文として |+| 演算子を提供している。combine は正確には Semigroup に定義されているメソッドなので、この構文は cats.syntax.semigroup からインポートして利用する。

import cats.syntax.semigroup.* // |+| をインポートする
val stringResult = "Hi " |+| "there" |+| Monoid[String].empty
// stringResult: String = "Hi there"

val intResult = 1 |+| 2 |+| Monoid[Int].empty
// intResult: Int = 3

As always, unless there is compelling reason not, we recommend importing all the syntax with

いつもと同様、特別な理由がないかぎりすべてのシンタックスをインポートすることが推奨される。

import cats.syntax.all.*

7.3.3.1 演習: すべてを加算する

SuperAdder v3.5a-32 は、数値の加算に関しては世界でも並ぶもののない、最先端ソフトウェアである。このプログラムの中核となるのは def add(items: List[Int]): Int というシグネチャをもった関数だが、不幸な事故でこのコードが削除されてしまった。メソッドを書き直して窮地を救え。

加算は 0+ 演算子を使った foldLeft として書ける。

def add(items: List[Int]): Int =
  items.foldLeft(0)(_ + _)

現時点では特段の必要性はないものの、Monoid を使ってこの畳み込み処理を実装することもできる。

import cats.Monoid
import cats.syntax.all.*

def add(items: List[Int]): Int =
  items.foldLeft(Monoid[Int].empty)(_ |+| _)

メソッドの復旧は成功したようだ。さて、SuperAdder の市場シェアは成長を続けており、新たな機能が求められている。今回ユーザが求めているのは List[Option[Int]] を加算する機能である。この要望に応えるため、add の改修を行え。SuperAdder のコードベースは最高品質をもっているため、コードの重複がないように注意すること。

ここで Monoid を使うべきユースケースが登場する。Int の加算と Option[Int] の加算を、ひとつのメソッドで行える必要がある。これは、暗黙の Monoid インスタンスをパラメータとして受け取るジェネリックメソッドとして記述できる。

import cats.Monoid
import cats.syntax.all.*

def add[A](items: List[A])(using monoid: Monoid[A]): A =
  items.foldLeft(monoid.empty)(_ |+| _)

Scala のコンテキスト境界(context bound)を用いて、同じ意味のコードをもっとシンプルに書くこともできる。

def add[A: Monoid](items: List[A]): A =
  items.foldLeft(Monoid[A].empty)(_ |+| _)

このコードを使えば、要望どおりに Int の加算と Option[Int] の加算を行うことができる。

add(List(1, 2, 3))
// res22: Int = 6
add(List(Some(1), None, Some(2), None, Some(3)))
// res23: Option[Int] = Some(value = 6)

Some だけで構成されたリストを足し合わせようとするとコンパイルエラーになることに注意してほしい。

add(List(Some(1), Some(2), Some(3)))
// error: 
// No given instance of type cats.kernel.Monoid[Some[Int]] was found for a context parameter of method add in object MdocApp8

これがエラーになるのは、リストの型が List[Some[Int]] であると推論されるのに対して、Cats が Option[Int] のための Monoid インスタンスしか生成しないからである。後ほど、この問題の回避方法について見ていく。

SuperAdder は POS 市場に参入しようとしており、下記のような Order 型で表される注文を合計する必要がでてきた。

case class Order(totalCost: Double, quantity: Double)

この機能はすぐにリリースする必要があり、add に変更を加える余裕はない。add に手を加えずにこの機能を実現せよ。

Order 用のモノイドインスタンスを定義するだけでよい。

given monoid: Monoid[Order] with {
  def combine(o1: Order, o2: Order) =
    Order(
      o1.totalCost + o2.totalCost,
      o1.quantity + o2.quantity
    )

  def empty = Order(0, 0)
}

7.4 モノイドの応用

モノイドが加算や結合の概念を抽象化したものであることは理解できた。だが、どのような場面で役立つのだろうか。ここでは、モノイドが重要な役割を果たす大きなアイデアをいくつか紹介する。これらについては、後の章でケーススタディとして詳細に取り上げる。

7.4.1 ビッグデータ

ビッグデータを扱う Spark や Flink のようなアプリケーションでは、データ分析を多数のマシンに分散させることで、耐障害性とスケーラビリティを実現する。つまり、各マシンがデータの一部を解析した結果を返した後、それらを結合して最終結果を得る必要がある。各マシンの出力結果とそれらを結合する演算は、多くの場合、モノイドと見なすことができる。

ウェブサイトの総訪問者数を計算したい場合、データの各部分に対して Int 型の値をひとつ出力するような計算が行われる。Cats によって提供されるデフォルトの Int モノイドインスタンスがもつ演算は加算であり、このユースケースにおける部分的な結果同士を結合するのに適している。

ユニーク訪問者数を計算したいのであれば、データの各部分に対して Set[User] オブジェクトを構築することになる。Cats によって提供される Set モノイドインスタンスがもつ演算は和集合であり、これらの部分的な結果同士を結合するのに適している。

サーバログから99%および95%応答時間を計算したい場合、QTree というデータ構造を利用できる。このデータ構造にはモノイドが存在する。

これでイメージがつかめたのではないかと思う。大規模データセットに対する分析はほぼすべてモノイドとして扱うことができるため、モノイドを中心に強力かつ汎用性の高い分析システムを構築することができる。これはまさに Twitter の Algebird や Summingbird プロジェクトが行ったことでもある。このアイデアについては、18節において MapReduce のケーススタディでさらに詳しく探求する。

7.4.2 分散システム

分散システムでは、マシンによってデータの見え方が異なる場合がある。たとえば、あるマシンがデータの更新を受信したが、他のマシンはそれを受け取っていないかもしれない。こうした異なる見え方を調整し、すべての更新が届いた後は、すべてのマシンが同じデータをもつようにしたい。これを結果整合性(eventual consistency)と呼ぶ。

ある種類のデータ型がこの調整をサポートする。それらのデータ型は、競合しない複製可能データ型(conflict-free replicated data type)略して CRDT と呼ばれる。CRDT の鍵となる操作は、ふたつのデータインスタンスをマージし、両方の情報をすべて含んだ結果を得ることである。この操作はモノイドインスタンスに依存している。このアイデアについては CRDT のケーススタディでさらに詳しく探る。

7.4.3 小規模なモノイドの活用

上記のふたつの例は、モノイドがシステム全体のアーキテクチャを形作る役割を果たしているケースだった。一方で、モノイドがあることによってちょっとしたコードを簡単に書ける場面も多く存在する。本書の残りの部分では、そうした例を多数紹介していく。

7.5 まとめ

この章で我々は大きなマイルストーンに到達した。はじめての型クラスとして、ファンシーな関数型プログラミング用語を名前にもつ型クラスについて学んだ8

SemigroupMonoid を使用するには、型クラス本体と、|+| 演算子を提供する Semigroup の構文をインポートすればよい。

import cats.Monoid
import cats.syntax.semigroup.* // |+| のインポート
"Scala" |+| " with " |+| "Cats"
// res7: String = "Scala with Cats"

スコープに適切なインスタンスがありさえすれば、あらゆるものを結合できるようになる。

Option(1) |+| Option(2)
// res8: Option[Int] = Some(value = 3)
val map1 = Map("a" -> 1, "b" -> 2)
val map2 = Map("b" -> 3, "d" -> 4)
map1 |+| map2
// res9: Map[String, Int] = Map("b" -> 5, "d" -> 4, "a" -> 1)
val tuple1 = ("hello", 123)
val tuple2 = ("world", 321)
tuple1 |+| tuple2
// res10: Tuple2[String, Int] = ("helloworld", 444)

また、Monoid インスタンスをもつ任意の型に対して動作する汎用的なコードを書くこともできる。

def addAll[A](values: List[A])
      (using monoid: Monoid[A]): A =
  values.foldRight(monoid.empty)(_ |+| _)
addAll(List(1, 2, 3))
// res11: Int = 6
addAll(List(None, Some(1), Some(2)))
// res12: Option[Int] = Some(value = 3)

Monoid は理解が容易く、それに使いやすい。Cats の世界への導入として絶好の素材である。だが、モノイドは Cats が可能にする抽象化のほんの一端にすぎない。次の章では、多くの人に愛されている map メソッドの型クラス版であるファンクター(functor)を取り上げる。関数型プログラミングの本当の楽しさはまさにここから始まると言っていいだろう。

8 ファンクター

この章ではファンクターについて探っていく。ファンクターは、ListOption や、その他無数に存在しうる様々なコンテキストの中で行われる操作の連なりを表現するための抽象概念である。ファンクター自体はあまり使い道をもたないが、モナドアプリカティブファンクターといったファンクターの特殊ケースはいずれも、もっともよく使用される抽象概念のひとつである。

8.1 ファンクターの例

一般的に、ファンクターとは map メソッドをもつものを指す。知ってのとおり、OptionListEither など、多くの型がこれに該当する。

通常、最初に map に出会うのは、List に対して繰り返し処理を行うときだろう。しかし、ファンクターを理解するためには、このメソッドを別の見方で捉える必要がある。map のことを、リストを走査するものではなく、内部のすべての値を一度に変換するものとして考えよう。適用する関数を指定すれば、map はそれを各要素に対して適用してくれる。値は変わるが、リストの構造(要素数や順序)は変わらない。

List(1, 2, 3).map(n => n + 1)
// res7: List[Int] = List(2, 3, 4)

同様に、Option に対して map を実行すると、SomeNone というコンテキストは変わらずに内部の値が変換される。この原則は Either とそのコンテキストである LeftRight についても言える。この一般化された変換の概念と、型シグネチャにおける共通のパターン(図1を参照)が、異なるデータ型における map の挙動を結びつけている。

list-option-either-map Created with Sketch. Either[E, A] map Either[E, B] A => B Option[A] map Option[B] A => B List[A] map List[B] A => B
Figure 1: Type chart: mapping over List, Option, and Either

map はコンテキストの構造を変えないので、最初のデータ構造の内容に対していくつもの計算を続けて呼び出すことができる。

List(1, 2, 3).
  map(n => n + 1).
  map(n => n * 2).
  map(n => s"${n}!")
// res8: List[String] = List("4!", "6!", "8!")

map は繰り返し処理ではなく、下記のようなデータ型固有の性質を無視して、値に対する計算を順につなげる方法だと考えるべきである。

8.2 さまざまなファンクター

ListOption、および Eithermap メソッドはいずれも、関数を即時に適用する。しかし、計算を順序付けてつなげるというアイデアは、もっと幅広い概念である。別の方法でこのパターンを適用する他のファンクターについて、その挙動を探ってみよう。

Future

Future はファンクターの一種で、非同期な計算を順に連結する。計算処理はキューに入れられ、前の計算が完了した後で適用される。Futuremap メソッドの型シグネチャは、図 2 に示すとおり、前述のシグネチャと同じ形をしている。しかし、その挙動は大きく異なる。

future-map Created with Sketch. Future[A] Future[B] A => B map
Figure 2: Type chart: mapping over a Future

Future を扱うとき、その内部状態については保証がない。ラップされている計算は進行中かもしれないし、完了しているか、あるいは失敗しているかもしれない。Future が完了している場合、マッピング関数はただちに呼び出される。しかし、そうでない場合、内部のスレッドプールが関数呼び出しをキューに入れ、後で改めて呼び出しを行う。関数がいつ呼び出されるかはわからないが、どの順序で呼び出されるかはわかる。このように、FutureListOption、そして Either で見られたのと同じ順序付けられた連結という振る舞いを提供している。

import scala.concurrent.{Future, Await}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._

val future: Future[String] =
  Future(123).
    map(n => n + 1).
    map(n => n * 2).
    map(n => s"${n}!")
Await.result(future, 1.second)
// res9: String = "248!"

Future と参照透過性

Scala の Future参照透過性をもっていないため、純粋関数型プログラミングのよい例とは言えない点には注意したい。Future は計算した結果を常にキャッシュする。その挙動を調整する手段は提供されていない。そのため、副作用を伴う計算を Future でラップすると、予想と異なる結果を引き起こす可能性がある。以下に例を挙げよう。

import scala.util.Random

val future1 = {
  // シードを固定して Random を初期化する
  val r = new Random(0L)

  // nextInt には、乱数シーケンスの中を次の乱数へと進める副作用がある
  val x = Future(r.nextInt())

  for {
    a <- x
    b <- x
  } yield (a, b)
}

val future2 = {
  val r = new Random(0L)

  for {
    a <- Future(r.nextInt())
    b <- Future(r.nextInt())
  } yield (a, b)
}
val result1 = Await.result(future1, 1.second)
// result1: Tuple2[Int, Int] = (-1155484576, -1155484576)
val result2 = Await.result(future2, 1.second)
// result2: Tuple2[Int, Int] = (-1155484576, -723955400)

理想的には result1result2 は等しくなることが望ましいが、両者は異なる結果となる。future1 の計算では nextInt が一度しか呼ばれないのに対して、future2 の計算では二度呼ばれる。そして二度目の nextInt は一度目とは異なる結果を返すからである。

このような不一致が起こるため、Future と副作用を扱うプログラムについてその挙動を推論するのは難しくなる。Future の振る舞いには他にも問題がある。たとえば、Future は常に計算を即座に開始し、プログラムの実行タイミングについてユーザが指定することを認めない。詳細については、Rob Norris が Reddit に投稿した素晴らしい回答を参照してほしい。

Cats Effect について学ぶと、IO 型がこれらの問題を解決していることがわかるだろう。

Future が参照透過性をもたないのであれば、別の参照透過な類似データ型を見てみるべきかもしれない。次にそれを紹介しよう。そのデータ型のことは誰もが知っているはずである。

関数(?!)

実は、単一引数の関数もファンクターである。これを理解するには、型をすこし調整する必要がある。関数 A => B は、引数の型 A と、戻り値の型 B というふたつの型パラメータをもっている。これをファンクターの形に強制的に当てはめるためには、引数の型を固定し、戻り値の型を可変にすればよい。

X => AMyFunc[A] という別名を付けると、その型について、この章の他の例で見たのと同じパターンが見えてくる。このことは図 3 でも確認できる。

function-map Created with Sketch. X => A X => B A => B map
Figure 3: Type chart: mapping over a Function1

言い換えると、Function1 に対する「マッピング」とは、関数の合成である。

import cats.syntax.all.*     // map をインポート

val func1: Int => Double =
  (x: Int) => x.toDouble

val func2: Double => Double =
  (y: Double) => y * 2
(func1.map(func2))(1)     // map を用いた合成
(func1.andThen(func2))(1) // andThen を用いた合成
// res10: Double = 2.0
func2(func1(1))           // 手書きで合成
// res11: Double = 2.0

これが操作の順序付けという一般パターンとどのように関連しているか考えてみよう。実は関数合成がまさに順序付けだといえる。ある単一の操作を実行する関数から始め、map を使うたびにチェーンのように別の操作が追加されていく。map を呼び出しても実際に操作が実行されるわけではないが、最終的に生成された関数に引数を渡せば、すべての操作が順番に実行される。これは Future と同じように操作をキューに入れて遅延させる仕組みだと考えることができる。

val func =
  ((x: Int) => x.toDouble).
    map(x => x + 1).
    map(x => x * 2).
    map(x => s"${x}!")
func(123)
// res12: String = "248.0!"

部分的ユニフィケーション

Scala 2.13 より前のバージョンで上記の例を動作させるためには、build.sbt に次のコンパイラオプションを追加する必要がある。

scalacOptions += "-Ypartial-unification"

これをしないとコンパイルエラーになる。

func1.map(func2)
// <console>: error: value map is not a member of Int => Double
//        func1.map(func2)
                ^

これがなぜ起きるのか、その詳細は8.8節で見ていく。

8.3 ファンクターの定義

これまでに見てきた例はすべてファンクター、つまり計算の順序付けをカプセル化するクラスである。形式的には、ファンクターは F[A] という型をもち、(A => B) => F[B] 型の操作 map を備えている。ファンクターの一般的な型チャートは図4に示したとおりである。

Figure 4: Type chart: generalised functor map

Cats では、ファンクターをを型クラス cats.Functor として提供しており、そのため、メソッドの形がこれまでの例とはすこし異なる。この型クラスは、初期値である F[A] を変換関数とともにパラメータとして受け取る。以下に簡略化した定義を示す。

package cats

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

F[_] のような構文を見るのは初めてかもしれない。ここですこし寄り道をして型コンストラクタ高カインド型について説明しておこう。

ファンクター則

ファンクターは、複数の小さな操作をひとつずつ map しても、それらをひとつの大きな関数にまとめてから map しても、同じ結果になることを保証する。これが確かなものであるためには、以下の法則が満たされる必要がある。

恒等則。恒等関数で map を行うのは、何もしていないのと同じである。

fa.map(a => a) == fa

合成則。ふたつの関数 fg を合成した関数で map するのは、まず fmap してから gmap するのと同じである。

fa.map(g(f(_))) == fa.map(f).map(g)

8.4 補足: 高カインド型と型コンストラクタ

カインドは型についての型のようなものであり、型がもつスロットの数を記述するものである。スロットをもたないのが普通の型で、型パラメータというスロットをもち、それを埋めることで型を生成するのが型コンストラクタである。

たとえば、List はひとつのスロットをもつ型コンストラクタであり、型パラメータを指定してそのスロットを埋めることで、List[Int]List[A] のような通常の型を作り出す。型コンストラクタとジェネリック型を混同しないよう注意しよう。List は型コンストラクタであり List[A] は型である。

List    // 型コンストラクタ。パラメータをひとつとる
List[A] // 型パラメータを指定することで生成される型

型コンストラクタと型との関係は、関数と値との関係と深い類似性がある。関数は「値コンストラクタ」であり、パラメータを与えると値を生成する。

math.abs    // これは関数で、パラメータをひとつ受け取る
math.abs(x) // 値パラメータを渡すことで生成される値

Scala では、アンダースコアを使って型コンストラクタを宣言する。これによって、型コンストラクタがいくつのスロットをもつかを指定する。しかし、実際に使用する際には名前だけで参照する。

// アンダースコアを用いてFを宣言する
def myMethod[F[_]] = {

  // アンダースコアなしのFという名前で参照する
  val functor = Functor.apply[F]

  // ...
}

これは、関数のパラメータ型を指定するのと似ている。パラメータを宣言する際には、その型も指定するが、実際に使用するときには名前だけを参照する。

// パラメータの型を指定して f を宣言する
def f(x: Int): Int = 
  // パラメータ x を参照するときに型は不要
  x * 2

型コンストラクタに関するこの知識があれば、Cats の Functor 定義によって、ListOptionFuture といった単一の型パラメータをもつ型コンストラクタや MyFunc のような型エイリアスのためのインスタンス作成が可能になる、ということが理解できるだろう。

言語機能のインポート

Scala 2.13 より前のバージョンでは、A[_] という構文で型コンストラクタを宣言する際には、コンパイラの警告を抑制するために高カインド型の言語機能を有効化する必要がある。これを行うには、次のように言語機能をインポートすればよい。

import scala.language.higherKinds

もしくは以下のような scalacOptions 指定を build.sbt に追加すればよい。

scalacOptions += "-language:higherKinds"

実際のところ、scalacOptions フラグを使う方が簡単であるように思われる。

8.5 Cats におけるファンクター

Cats におけるファンクターの実装を見ていこう。モノイドのときと同じく三つの側面、型クラスインスタンス構文に着目する。

8.5.1 ファンクターの型クラスとインスタンス

ファンクター型クラスは cats.Functor として定義されている。インスタンスの取得には、Cats の標準的な設計に従ってコンパニオンオブジェクトに定義された Functor.apply メソッドを使う。デフォルトのインスタンスは通常どおりコンパニオンオブジェクト上に置かれており、明示的にインポートする必要はない。

import cats.*
import cats.syntax.all.*
val list1 = List(1, 2, 3)
// list1: List[Int] = List(1, 2, 3)
val list2 = Functor[List].map(list1)(_ * 2)
// list2: List[Int] = List(2, 4, 6)

val option1 = Option(123)
// option1: Option[Int] = Some(value = 123)
val option2 = Functor[Option].map(option1)(_.toString)
// option2: Option[String] = Some(value = "123")

Functorlift というメソッドを提供している。これは、A => B 型の関数を、ファンクター内の値を操作する F[A] => F[B] 型関数に変換する。

val func = (x: Int) => x + 1
// func: Function1[Int, Int] = repl.MdocSession$MdocApp12$$$Lambda$20690/0x00000008053a9840@1dae5b5a

val liftedFunc = Functor[Option].lift(func)
// liftedFunc: Function1[Option[Int], Option[Int]] = cats.Functor$$Lambda$20679/0x00000008053a6040@7832548d

liftedFunc(Option(1))
// res13: Option[Int] = Some(value = 2)

もうひとつよく使うメソッドとして as がある。これはファンクター内の値を指定された値に置き換える。

Functor[List].as(list1, "As")
// res14: List[String] = List("As", "As", "As")

8.5.2 ファンクターの構文

Functor の構文によって提供される主なメソッドは map だが、その使い方を OptionList で実演するのは難しい。それらのクラスには元々 map メソッドが組み込まれており、そして Scala コンパイラは常に拡張メソッドよりも組み込みのメソッドを優先するからである。以下では、そのような問題をもたない例をふたつ挙げる。

まずは関数に対する変換を見てみよう。Scala の Function1 型には map メソッドが存在しない(同じ機能をもつメソッドは andThen と呼ばれる)ので、ファンクターの構文と名前が衝突することはない。

val func1 = (a: Int) => a + 1
val func2 = (a: Int) => a * 2
val func3 = (a: Int) => s"${a}!"
val func4 = func1.map(func2).map(func3)
func4(123)
// res15: String = "248!"

別の例を見てみよう。今回は、特定の具体的なファンクター型に依存しないように、ファンクター全般を抽象化して扱う。数値がどのようなファンクターのコンテキストに包まれていようと、それに対して計算処理を適用するメソッドを記述できる。

def doMath[F[_]](start: F[Int])
    (implicit functor: Functor[F]): F[Int] =
  start.map(n => n + 1 * 2)
doMath(Option(20))
// res16: Option[Int] = Some(value = 22)
doMath(List(1, 2, 3))
// res17: List[Int] = List(3, 4, 5)

To illustrate how this works, let’s take a look at the definition of the map method in cats.syntax.functor. Here’s a simplified version of the code:

これがどのように機能するかを示すために、cats.syntax.functor 内の map メソッドの定義を見てみよう。以下はそのコードを簡略化したものである。

implicit class FunctorOps[F[_], A](src: F[A]) {
  def map[B](func: A => B)
      (implicit functor: Functor[F]): F[B] =
    functor.map(src)(func)
}

ある型について、その型自体が map メソッドをもたなければ、コンパイラはこの拡張メソッド定義によって map メソッドを挿入できる。

foo.map(value => value + 1)

foo それ自体は map メソッドをもたないと仮定すると、コンパイラはこの記述がこのままではエラーになることを検出し、それを修正するために式を FunctorOps で包み込む。

new FunctorOps(foo).map(value => value + 1)

FunctorOpsmap メソッドは暗黙の Functor インスタンスをパラメータとして要求する。つまり、F のための Functor インスタンスがスコープ内に存在すれば、このコードはコンパイルできるが、そうでなければコンパイルエラーとなる。

final case class Box[A](value: A)

val box = Box[Int](123)
box.map(value => value + 1)
// error: 
// value map is not a member of repl.MdocSession.MdocApp2.Box[Int]

as メソッドも構文として利用できる。

List(1, 2, 3).as("As")
// res19: List[String] = List("As", "As", "As")

8.5.3 独自型のためのインスタンス

ファンクターを定義するのは簡単で、map メソッドを定義するだけでよい。Option 用の Functorcats.instances に既に存在するが、これを例として以下に示す。実装は単純で、Optionmap メソッドを呼び出すだけである。

implicit val optionFunctor: Functor[Option] =
  new Functor[Option] {
    def map[A, B](value: Option[A])(func: A => B): Option[B] =
      value.map(func)
  }

インスタンスに依存性を注入しなければならないことが時々ある。たとえば、Future のために独自の Functor を定義する場合(これも仮の例である。Cats はこれを cats.instances.future で提供している)、Futuremap メソッドが要求する ExecutionContext 型の暗黙パラメータについて考慮する必要がある。Functormap にパラメータを追加することはできないので、この依存性はインスタンスを作成するときに解決しなければならない。

import scala.concurrent.{Future, ExecutionContext}

implicit def futureFunctor
    (implicit ec: ExecutionContext): Functor[Future] =
  new Functor[Future] {
    def map[A, B](value: Future[A])(func: A => B): Future[B] =
      value.map(func)
  }

Future 用の Functor インスタンスを呼び出すと、コンパイラは暗黙の解決によって、まず futureFunctor 関数を発見する。次に、再帰的な解決により ExecutionContext インスタンスを呼び出し場所を基準とするスコープで探す。この振る舞いは、Functor.apply を直接使う場合でも、map 拡張メソッド経由で間接的に呼び出す場合でも変わらない。コンパイラによるこの展開は以下のようになるだろう。

// 実際に記述するコード
Functor[Future]

// コンパイラはまずこのように展開する
Functor[Future](futureFunctor)

// そしてこう
Functor[Future](futureFunctor(executionContext))

8.5.4 演習: ファンクターを二分木に適用する

以下の二分木データ型に対して Functor を作成し、期待どおりに動作するか BranchLeaf の両インスタンスについて確認せよ。

sealed trait Tree[+A]

final case class Branch[A](left: Tree[A], right: Tree[A])
  extends Tree[A]

final case class Leaf[A](value: A) extends Tree[A]

セマンティクスは List に対する Functor と似ている。データ構造を再帰的に走査し、見つかったすべての Leaf に関数を適用する。ファンクター則に基づき、BranchLeaf の構造はそのまま保たれる必要がある。

implicit val treeFunctor: Functor[Tree] =
  new Functor[Tree] {
    def map[A, B](tree: Tree[A])(func: A => B): Tree[B] =
      tree match {
        case Branch(left, right) =>
          Branch(map(left)(func), map(right)(func))
        case Leaf(value) =>
          Leaf(func(value))
      }
  }

この Functor を用いて Tree を変換してみよう。

Branch(Leaf(10), Leaf(20)).map(_ * 2)
// error:
// value map is not a member of repl.MdocSession.MdocApp2.Branch[Int]
// Branch(Leaf(10), Leaf(20)).map(_ * 2)
//                           ^

だがこのコードは 4.6.1 で議論した非変性の問題に引っかかる。コンパイラは Tree に対しては Functor インスタンスを見つけることができるが、BranchLeaf に対しては見つけられない。この問題を補うためにスマートコンストラクタを追加しよう。

object Tree {
  def branch[A](left: Tree[A], right: Tree[A]): Tree[A] =
    Branch(left, right)

  def leaf[A](value: A): Tree[A] =
    Leaf(value)
}

これで、この Functor は正常に利用可能となる。

Tree.leaf(100).map(_ * 2)
// res21: Tree[Int] = Leaf(value = 200)

Tree.branch(Tree.leaf(10), Tree.leaf(20)).map(_ * 2)
// res22: Tree[Int] = Branch(left = Leaf(value = 20), right = Leaf(value = 40))

8.6 反変ファンクターと非変ファンクター

これまで見てきたように、Functormap メソッドが行うのは、変換をチェーンに追加することだと考えることができる。ここでは、更にふたつの型クラスを見ていく。ひとつは操作をチェーンの先頭に挿入するもの、もうひとつは双方向の操作チェーンを構築するものである。これらはそれぞれ、反変ファンクター(contravariant functor)非変ファンクター(invariant functor)と呼ばれている。

この節は一旦スキップしてもよい。

本書におけるもっとも重要な型クラスで次章の中心的テーマとなるのがモナドだが、モナドを理解するのに反変ファンクターや不変ファンクターについて知っておく必要はない。しかし、反変ファンクターや不変ファンクターは、11章で SemigroupalApplicative を論じる際に役立つ。

今すぐモナドに進みたい場合は9章に飛んでかまわない。11章を読む前に、この節に戻ってくるとよい。

8.6.1 反変ファンクターと contramap メソッド

最初に紹介するのは反変ファンクターである。この型クラスは contramap という操作を提供する。このメソッドは、操作をチェーンの先頭に挿入する(prepending)。一般的な型シグネチャを図 5に示す。

generic-contramap Created with Sketch. F[B] F[A] A => B contramap
Figure 5: Type chart: the contramap method

contramap メソッドは、変換を表すデータ型に対してのみ意味をもつ。たとえば Option に対して contramap を定義することはできない。Option[B] に含まれる値を A => B という関数を通して逆方向に処理する方法がないためである。一方、4.5節で説明した Display 型クラスに対しては contramap を定義できる。

trait Display[A] {
  def display(value: A): String
}

Display[A]A から String への変換を表している。Displaycontramap メソッドは、B => A 型の関数 func を受け取り、新しい Display[B] を生成する。

trait Display[A] {
  def display(value: A): String

  def contramap[B](func: B => A): Display[B] =
    ???
}

def display[A](value: A)(using p: Display[A]): String =
  p.display(value)

8.6.1.1 演習: contramap で魅せる

上記の Displaycontramap メソッドを実装せよ。次のコードテンプレートから始め、??? を動作する実装に置き換えるとよい。

trait Display[A] {
  def display(value: A): String

  def contramap[B](func: B => A): Display[B] =
    new Display[B] {
      def display(value: B): String =
        ???
    }
}

行き詰まった場合は、型について考えるとよい。B という型の値 valueString に変換する必要がある。どのような関数やメソッドが利用可能で、それらをどういう順番で組み合わせるべきだろうか。

動作する実装を以下に示す。func を使って BA に変換し、その後、元の Display を使って AString に変換する。すこし巧妙なテクニックとして、self エイリアスを使い、外側と内側の Display を区別している。

trait Display[A] { self =>

  def display(value: A): String

  def contramap[B](func: B => A): Display[B] =
    new Display[B] {
      def display(value: B): String =
        self.display(func(value))
    }
}

def display[A](value: A)(using p: Display[A]): String =
  p.display(value)

テスト用に StringBoolean に対する Display インスタンスを定義しよう。

given stringDisplay: Display[String] with {
  def display(value: String): String =
    s"'${value}'"
}

given booleanDisplay: Display[Boolean] with {
  def display(value: Boolean): String =
    if value then "yes" else "no"
}
display("hello")
// res18: String = "'hello'"
display(true)
// res19: String = "yes"

次に、下記のような Box という case クラスに対して Display インスタンスを定義せよ。これは4.3節で言及した型クラスの合成の例である。

final case class Box[A](value: A)

new Display[Box] などとフルスクラッチで定義を書き出すのではなく、contramap を使って既存のインスタンスから求めているインスタンスを作成すること。

このインスタンスは次のように利用できる。

display(Box("hello world"))
// res20: String = "'hello world'"
display(Box(true))
// res21: String = "yes"

Box の中身の型に対して Display インスタンスが用意されていない場合、display 呼び出しはコンパイルに失敗する。

display(Box(123))
// error:
// No given instance of type repl.MdocSession.MdocApp8.Display[repl.MdocSession.MdocApp8.Box[Int]] was found for parameter p of method display in object MdocApp8.
// I found:
// 
//     repl.MdocSession.MdocApp8.boxDisplay[A](
//       /* missing */summon[repl.MdocSession.MdocApp8.Display[A]])
// 
// But no implicit values were found that match type repl.MdocSession.MdocApp8.Display[A].
// display(Box(123))
//                 ^

インスタンスがあらゆる型の Box に対して汎用的になるよう、Box の中身の型に対応する Display インスタンスをベースにする。以下のように完全な定義を手作業で書き出してもよいし、

given boxDisplay[A](
    using p: Display[A]
): Display[Box[A]] with {
  def display(box: Box[A]): String =
    p.display(box.value)
}

もしくは、using 句によって解決された Display インスタンスをベースに contramap を使って新しいインスタンスを定義することもできる。

given boxDisplay[A](using p: Display[A]): Display[Box[A]] =
  p.contramap[Box[A]](_.value)

contramap を使う方がはるかにシンプルである。また、 純粋関数型のコンビネータを用いてシンプルな部品を組み合わせることで解決策を構築するという、関数型プログラミングのアプローチをよく表現している。

8.6.2 非変ファンクターと imap メソッド

非変ファンクターは、imap というメソッドを実装している。これは大雑把に言えば mapcontramap を組み合わせたようなものである。map が関数をチェーンに追加することで、また contramap が操作をチェーンの先頭に挿入することで新しい型クラスインスタンスを生成するのに対して、imap は双方向の変換ペアを使ってインスタンスを生成する。

もっとも直感的な例は、Circe の Codec や Play JSON の Format のような、あるデータ型についてのエンコードとデコードを表現する型クラスである。Display に機能を追加して、String との間のエンコードとデコードをサポートすれば、独自の Codec を構築できる。

trait Codec[A] {
  def encode(value: A): String
  def decode(value: String): A
  def imap[B](dec: A => B, enc: B => A): Codec[B] = ???
}
def encode[A](value: A)(using c: Codec[A]): String =
  c.encode(value)

def decode[A](value: String)(using c: Codec[A]): A =
  c.decode(value)

imap の型チャートを図6に示す。Codec[A] と、A => B および B => A の関数のペアがあれば、imap メソッドは Codec[B] を生成する。

generic-imap Created with Sketch. F[A] F[B] A => B , B => A imap
Figure 6: Type chart: the imap method

使用例として、渡された値をそのまま返すだけの encodedecode をもつシンプルな Codec[String] を想像してみよう。

given stringCodec: Codec[String] with {
  def encode(value: String): String = value
  def decode(value: String): String = value
}

imap を使えば、この stringCodec をもとにして他の型に対する多くの有用な Codec を構築することができる。

given intCodec: Codec[Int] =
  stringCodec.imap(_.toInt, _.toString)

given booleanCodec: Codec[Boolean] =
  stringCodec.imap(_.toBoolean, _.toString)

エラーへの対処

ここで紹介した Codec 型クラスの decode メソッドは失敗を考慮していない。データ間のより洗練された関係をモデル化したい場合は、ファンクターにとどまらずレンズオプティクスを検討するとよい。

オプティクスはこの本では扱わない。深く知りたければ、Julien Truffaut のライブラリ Monocle がすばらしい出発点になってくれるだろう。

8.6.2.1 imap を使った変換的思考

上述の Codecimap を実装せよ。

正しく動作する実装を以下に示す。

trait Codec[A] { self =>
  def encode(value: A): String
  def decode(value: String): A

  def imap[B](dec: A => B, enc: B => A): Codec[B] = {
    new Codec[B] {
      def encode(value: B): String =
        self.encode(enc(value))

      def decode(value: String): B =
        dec(self.decode(value))
    }
  }
}

作成した imap メソッドが正しく動くことを、Double 型用の Codec を定義することで示せ。

stringCodecimap メソッドを使ってこれを実装できる。

given doubleCodec: Codec[Double] =
  stringCodec.imap[Double](_.toDouble, _.toString)

最後に、次の Box 型との相互変換を行う Codec を実装せよ。

final case class Box[A](value: A)

ここでは、任意の A について Box[A] との相互変換を行う汎用的な Codec が求められている。暗黙パラメータとしてスコープに導入される Codec[A] インスタンスの imap を用いることでこれを作成する。

given boxCodec[A](using c: Codec[A]): Codec[Box[A]] =
  c.imap[Box[A]](Box(_), _.value)

作成したインスタンスは次のように利用できるはずである。

encode(123.4)
// res27: String = "123.4"
decode[Double]("123.4")
// res28: Double = 123.4

encode(Box(123.4))
// res29: String = "123.4"
decode[Box[Double]]("123.4")
// res30: Box[Double] = Box(value = 123.4)

ファンクターの名前に込められた意味

これらの異なる種類のファンクターはどうして「反変」「不変」「共変」という名称で呼ばれているのだろうか。

4.6.1節を思い出してほしい。変位は部分型関係に影響を与える。部分型関係とは本質的には、ある型の値を、別の型の値が期待されている文脈でコードを破壊することなく使用可能か、という関係性のことを指す。

部分型関係は、変換可能性とみなすことができる。もし BA の部分型であれば、B は常に A に変換できる。

これは、B => A 型の関数が存在するとき BA の部分型である、と言い換えることができる。共変ファンクターはこの関係性を正確に捉えている。F が共変ファンクターであれば、B => A という変換があるときは常に F[B]F[A] に変換できる9

反変ファンクターはそれと逆のパターンを捉えている。F が反変ファンクターであれば、B => A という変換があるときは常に F[A]F[B] に変換できる。

最後に、不変ファンクターは、A => B 型関数を通じて F[A]F[B] に変換でき、B => A 型関数についてその逆がいえる、そういうケースを捉えている。

8.7 Cats の ContravariantInvariant

Cats における反変および非変ファンクターの実装を見ていこう。これらはそれぞれ cats.Contravariant および cats.Invariant 型クラスとして提供されている。その定義を簡略化して以下に示す。

trait Contravariant[F[_]] {
  def contramap[A, B](fa: F[A])(f: B => A): F[B]
}

trait Invariant[F[_]] {
  def imap[A, B](fa: F[A])(f: A => B)(g: B => A): F[B]
}

8.7.1 Cats の Contravariant

Contravariant インスタンスは Contravariant.apply メソッドを使って取得できる。Cats は EqShowFunction1 などパラメータを受け取るデータ型に対して Contravariant インスタンスを提供している。以下はその例である。

import cats.*

val showString = Show[String]

val showSymbol = Contravariant[Show].
  contramap(showString)((sym: Symbol) => s"'${sym.name}")
showSymbol.show(Symbol("dave"))
// res7: String = "'dave"

contramap 拡張メソッドを提供する cats.syntax.contravariant を使えば、もっと便利に書くことができる。

import cats.syntax.contravariant.* // contramap
showString
  .contramap[Symbol](sym => s"'${sym.name}")
  .show(Symbol("dave"))
// res8: String = "'dave"

8.7.2 Cats の Invariant

Cats が提供する Invariant インスタンスの中に、Monoid に対するインスタンスがある。これは、8.6.2節で紹介した Codec の例とはすこし異なる。Monoid は以下のような型クラスだった。

package cats

trait Monoid[A] {
  def empty: A
  def combine(x: A, y: A): A
}

Scala の Symbol 型に対して Monoid を作成したいとする。Cats は Symbol に対する Monoid インスタンスを提供していないが、それと似た型である String に対してなら提供している。ここで新たに定義する Monoid インスタンスの empty メソッドは空の String を用いて実装することができ、combine メソッドは以下のように動作する。

  1. ふたつの Symbol をパラメータとして受け取る
  2. ふたつの Symbol 型パラメータをそれぞれ String 値に変換する
  3. Monoid[String] を使ってふたつの String 値を結合する
  4. 得られた String 値を Symbol 値に戻す

imap を使用してこの combine を実装することができる。imap には String => Symbol 型と Symbol => String 型の関数をパラメータとして渡す。以下は、cats.syntax.invariant によって提供される imap 拡張メソッドを使って記述したコードである。

import cats.*
import cats.syntax.invariant.* // imap
import cats.syntax.semigroup.* // |+|

given symbolMonoid: Monoid[Symbol] =
  Monoid[String].imap(Symbol.apply)(_.name)
Monoid[Symbol].empty
// res9: Symbol = '

Symbol("a") |+| Symbol("few") |+| Symbol("words")
// res10: Symbol = 'afewwords

8.8 補足: 部分的ユニフィケーション

8.2節では Function1 に対するファンクターインスタンスについて見た。

import cats.*
import cats.syntax.functor.*     // map

val func1 = (x: Int)    => x.toDouble
val func2 = (y: Double) => y * 2
val func3 = func1.map(func2)
// func3: Function1[Int, Double] = cats.instances.Function1Instances0$$anon$11$$Lambda$20706/0x00000008053c6040@3ad82fdc

Function1 は引数をひとつと戻り値型、合わせてふたつの型パラメータをもっている。

trait Function1[-A, +B] {
  def apply(arg: A): B
}

だが、Functor が受け付けるのは単一のパラメータをもつ型コンストラクタである。

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(func: A => B): F[B]
}

Functor に渡すための適切なカインドをもつ型コンストラクタを作るためには、ふたつの型パラメータのうちのひとつを固定する必要がある。固定の仕方には以下のふたつの選択肢がある。

type F[A] = Int => A
type F[A] = A => Double

プログラマから見れば前者が正しい選択肢だとわかるが、コンパイラはコードの意味を理解していない。その代わり、コンパイラは単純な規則に従って「部分的ユニフィケーション」と呼ばれる処理を実行する。

Scala コンパイラの部分的ユニフィケーションは、型パラメータを左から右へと固定していくことによって行われる。上記の例だと、コンパイラは Int => Double という型のうち Int の部分を固定し、Int => ? 型の関数に対する Functor インスタンスを探す。

type F[A] = Int => A

val functor = Functor[F]

この左から右への型パラメータ除去は、Function1Either といった型に対する Functor インスタンスを含め、多くの一般的なシナリオに対して正しく動作する。

val either: Either[String, Int] = Right(123)
// either: Either[String, Int] = Right(value = 123)

either.map(_ + 1)
// res4: Either[String, Int] = Right(value = 124)

Scala 2.13 ではデフォルトで部分的ユニフィケーションが有効だが、それ以前のバージョンでは -Ypartial-unification コンパイラオプションを追加する必要がある。SBT では build.sbt に次のように追加する。

scalacOptions += "-Ypartial-unification"

この変更の背景は SI-2712 で議論されている。

8.8.1 部分的ユニフィケーションの限界

左から右へたどる型パラメータ除去が正しい選択とならない場合もある。ScalacticOr 型がその一例である。OrEither と同じ構造をもつが、左の値にバイアスがかけられている。

type PossibleResult = ActualResult Or Error

もうひとつの例は Function1 に対する Contravariant ファンクターである。

Function1 に対する共変ファンクターが andThen スタイルの、つまり左から右への関数合成を実装しているのに対して、反変ファンクターは右から左への compose スタイルの関数合成を実装している。言い換えると、下記の式はすべて等価である。

val func3a: Int => Double =
  a => func2(func1(a))

val func3b: Int => Double =
  func2.compose(func1)
// 仮の例。実際にはコンパイルできない
val func3c: Int => Double =
  func2.contramap(func1)

しかし、この contramap を用いた例は、実際にはコンパイルできない。

import cats.syntax.contravariant.* // contramap
val func3c = func2.contramap(func1)
// error: 
// value contramap is not a member of Double => Double.
// An extension method was tried, but could not be fully constructed:
// 
//     cats.syntax.contravariant.toContravariantOps[[R] =>> Double => R, A](this.func2)
//       (cats.Invariant.catsContravariantForFunction1[R])

Function1Contravariant 型クラスインスタンスを定義するには、戻り値の型を固定し引数の型を可変にする。これを型エイリアスで表すと以下のようになる。また、contramap の型チャートを図7に示す。

type F[A] = A => Double
function-contramap Created with Sketch. A => X B => X B => A contramap
Figure 7: Type chart: contramapping over a Function1

そのように定義された Contravariant 型クラスインスタンスを探すにあたって、コンパイラは型パラメータを右から左へと除去する必要がある。

コンパイラが失敗するのは、単純に、型パラメータの除去を左から右の順に行うというバイアスのせいである。このことは、Function1 のパラメータを反転する型エイリアスを定義すれば確かめられる。

type <=[B, A] = A => B
type F[A] = Double <= A

func2<= のインスタンスとして型注釈すれば、型パラメータ除去の順序が逆になり、望みどおりに contramap を呼び出すことが可能となる。

val func2b: Double <= Double = func2
val func3c = func2b.contramap(func1)
// func3c: Function1[Int, Double] = scala.Function1$$Lambda$20877/0x0000000805235840@62cbe106

func2func2b の違いは純粋に構文的なものである。どちらも参照している値は同じだし、型エイリアスには構文上の違いを除けば完全に互換性がある。しかし驚くべきことに、この簡単な書き換えだけで、問題を解決するために必要なヒントをコンパイラに与えることができる。

このような右から左への型パラメータ除去を行わなければならないことは稀である。ほとんどの多パラメータ型コンストラクタは右バイアスで設計されており、コンパイラが標準でサポートしている左から右への除去に適合する。しかし、上述のような奇妙なケースに遭遇した場合に備えて、この除去順序の特性を知っておくことは有益である。

8.9 まとめ

ファンクターは振る舞いの順序付けを表す。本章では三種類のファンクターを取り上げた。

通常の Functor はこれらの型クラスの中で圧倒的によく使われるが、それでも単独で使われることは稀である。ファンクターは、日常的に使われるもっと興味深い抽象概念の基礎となる。次章以降ではこれらの抽象概念のうちのふたつ、モナドアプリカティブファンクターを見ていく。

コレクションに対するファンクターは、それぞれの要素を他の要素に依存せずに変換できる点において、極めて重要である。これにより、大規模なコレクションに対する変換を並列化したり、分散させたりすることが可能になる。この技術は Hadoop のような MapReduce フレームワークで大いに活用されている。このアプローチについては、18節で取り組む MapReduce のケーススタディにおいて詳細に確認する。

ContravariantInvariant 型クラスは、適用範囲こそ狭いが、変換を表すデータ型を構築する際に有用である。これらについては、11章で Semigroupal 型クラスを議論するときに改めて取り上げる。

9 モナド

モナドは Scala でもっとも一般的な抽象概念のひとつである。Scala プログラマは、それと知らずに自然とモナドに慣れ親しむことも多い。

大雑把に言えば、モナドはコンストラクタと flatMap メソッドを備えたオブジェクトのことをいう。前章で見たファンクターは、OptionListFuture を含め、すべてモナドである。さらに、モナドをサポートする特別な構文として for 内包表記がある。しかし、概念は広く浸透しているにもかかわらず、Scala 標準ライブラリには「flatMap できるもの」全般を表す具体的な型が存在しない。

この章ではモナドについて深く掘り下げる。まずいくつかの例を用いてモナドの必要性を理解し、その後、形式的な定義へと進み、型クラスとして具体的な型を作成する方法を見ていく。その後 Cats における実装を確認する。最後に、馴染みは薄いかもしれないが興味深いモナドをいくつか紹介し、その使用例を示す。

9.1 モナドとは何か

この疑問は数多くのブログ記事で取り上げられ、猫や、メキシコ料理や、有毒な破棄物でいっぱいの宇宙服や、自己関手の圏におけるモノイド対象(意味はよくわからないが)など、さまざまな概念を使った説明やアナロジーが繰り広げられてきた。ここではシンプルに以下のように述べることで、この問題を一言で解決しよう。

モナドとは、計算を順に連結するための仕組みのことである。

この簡単な説明で問題は解決したと言いたいところだが、これでは、前章で述べたファンクターの仕組みとまったく同じである。どうやら、もうすこし議論が必要かもしれない。

8.1節で、ファンクターはコンテキストがもつ何らかの複雑さを無視して計算を順に連結することができると述べた。だが、ファンクターには制限がある。ファンクターは、一連の計算の最初に一度だけ、そのような複雑性を扱うことができる。各ステップで新たな複雑性が生じる場合には対応できない。

ここで登場するのがモナドである。モナドの flatMap メソッドでは、途中の処理で発生した複雑性も考慮して、次に何を行うか指定することができる。OptionflatMap メソッドは、途中で新たに生まれた Option を考慮するし、ListflatMap メソッドは途中で生まれた List を処理する。他のモナドでも同様である。どのケースでも、flatMap に渡される関数によって計算におけるアプリケーション固有の処理を指定し、そこで生じる複雑性を flatMap 自体が処理することで、続けて flatMap することを可能にする。いくつか例を見てイメージを具体化してみよう。

9.1.1 モナドとしての Option

Option は、値が返されるかどうかわからない計算を順序付けて連結することができる。以下にいくつかの例を示す。

def parseInt(str: String): Option[Int] =
  scala.util.Try(str.toInt).toOption

def divide(a: Int, b: Int): Option[Int] =
  if(b == 0) None else Some(a / b)

Each of these methods may “fail” by returning None. The flatMap method allows us to ignore this when we sequence operations:

これらのメソッドはいずれも失敗する可能性がある。失敗したときは None が返却される。flatMap では、操作を連結するにあたりこの失敗を無視できる。

def stringDivideBy(aStr: String, bStr: String): Option[Int] =
  parseInt(aStr).flatMap { aNum =>
    parseInt(bStr).flatMap { bNum =>
      divide(aNum, bNum)
    }
  }

このコードは以下のように翻訳できる。

各ステップにおいて flatMap は指定された関数を呼び出すかどうか判定を行い、連結された次の計算で利用する値を生成する。これを図8に示す。

option-flatmap Created with Sketch. Option[A] flatMap Option[B] A => Option[B]
Figure 8: Type chart: flatMap for Option

計算の結果は Option オブジェクトなので、それに対して再び flatMap を呼び出すことができ、一連の計算が続く。これによりフェイルファストなエラー処理が実現する。この仕組みでは、あるステップが None を返したら全体として結果が None になる。

stringDivideBy("6", "2")
// res9: Option[Int] = Some(value = 3)
stringDivideBy("6", "0")
// res10: Option[Int] = None
stringDivideBy("6", "foo")
// res11: Option[Int] = None
stringDivideBy("bar", "2")
// res12: Option[Int] = None

すべてのモナドはファンクターでもある(証明は後述)。したがって、flatMapmap の両方を使うことで、計算が新しいモナドを生成する場合もしない場合も、順に連結することができる。さらに、flatMapmap の両方があれば、for 内包表記を使って一連の処理をわかりやすく記述できる。

def stringDivideBy(aStr: String, bStr: String): Option[Int] =
  for {
    aNum <- parseInt(aStr)
    bNum <- parseInt(bStr)
    ans  <- divide(aNum, bNum)
  } yield ans

9.1.2 モナドとしての List

Scala を学び始めた頃に flatMap に出会うと、それを List に対する反復処理のひとつのパターンとして捉えがちである。この先入観は、for 内包表記の構文が命令形の for ループとよく似ていることと相まって一層強固なものとなる。

for {
  x <- (1 to 3).toList
  y <- (4 to 5).toList
} yield (x, y)
// res14: List[Tuple2[Int, Int]] = List(
//   (1, 4),
//   (1, 5),
//   (2, 4),
//   (2, 5),
//   (3, 4),
//   (3, 5)
// )

しかし、List のモナド的な振る舞いに着目した別のメンタルモデルもあり得る。List を計算の途中結果の集合として考えれば、flatMap は順列や組み合わせを計算する構造となる。

たとえば、上記の for 内包表記では、x の取りうる値は3つ、y は2つ存在する。その結果、(x, y) が取りうる組み合わせは6パターンとなる。flatMap は、一連の操作を記述したコードからこれらの組み合わせを生成している。

9.1.3 モナドとしての Future

Future は、計算を、それが非同期であるかどうかを気にすることなく順に連結することができるモナドである。

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def doSomethingLongRunning: Future[Int] = ???
def doSomethingElseLongRunning: Future[Int] = ???

def doSomethingVeryLongRunning: Future[Int] =
  for {
    result1 <- doSomethingLongRunning
    result2 <- doSomethingElseLongRunning
  } yield result1 + result2

ここでも、各ステップで実行するコードさえ指定すれば、背後に存在するスレッドプールやスケジューラといった恐ろしい複雑性はすべて flatMap が処理してくれる。

Future を何度も利用していると、上記のコードが各処理を順次実行していることに気付くだろう。for 内包表記を展開して、ネストした flatMap 呼び出しに置き換えると、そのことは一層明確になる。

def doSomethingVeryLongRunning: Future[Int] =
  doSomethingLongRunning.flatMap { result1 =>
    doSomethingElseLongRunning.map { result2 =>
      result1 + result2
    }
  }

一連の計算における各 Future は、ひとつ前の Future から結果を受け取る関数によって生成されている。言い換えると、計算の各ステップは、前のステップが終了して初めて開始できる。このことは、図9に示す flatMap の型チャートにおいて関数パラメータの型を A => Future[B] としていることからも見て取れる。

future-flatmap Created with Sketch. Future[A] Future[B] A => Future[B] flatMap
Figure 9: Type chart: flatMap for Future

もちろん、Future を並列実行することもできる。だが、それはまた別の話とし、別の機会に語ることにしよう。モナドは計算を順に連結することがすべてである。

9.1.4 モナドの定義

ここまで flatMap についてだけ述べてきたが、モナド的な振る舞いは、形式的には次のふたつの操作によって捉えられる。

pure はコンストラクタを抽象化したものであり、通常の値から新たなモナド的コンテキストを作成する手段を提供する。flatMap は、すでに見たように計算の順次処理のステップを提供するもので、コンテキストから値を取り出し、一連の計算における次のコンテキストを生成する。以下は、Cats における Monad 型クラスの定義を簡略化したものである。


trait Monad[F[_]] {
  def pure[A](value: A): F[A]

  def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]
}

モナド則

pureflatMap は、一連の操作を副作用や不具合なく自在につなげるために、いくつかの法則を満たす必要がある。

左単位律(left identity)pure を呼び出してその結果を func で変換することは、func を直接呼び出すことと同じである。

pure(a).flatMap(func) == func(a)

右単位律(right identity)pureflatMap に渡すことは何もしないのと同じである。

m.flatMap(pure) == m

結合律。ふたつの関数 fg で順次 flatMap することは、f を実行した結果を gflatMap するという処理で flatMap するのと同じである。

m.flatMap(f).flatMap(g) == m.flatMap(x => f(x).flatMap(g))

9.1.5 演習: Getting Func-y

すべてのモナドはファンクターでもある。どのモナドに対しても、既存のメソッドである flatMappure を使った同じ方法で map を定義することができる。


trait Monad[F[_]] {
  def pure[A](a: A): F[A]

  def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]

  def map[A, B](value: F[A])(func: A => B): F[B] =
    ???
}

map を定義せよ。

これは一見したところ難しそうに見える。だが、型に従って考えれば、解法はひとつしかないことがわかるだろう。map メソッドには F[A] 型の value を渡されている。利用できる手段を考えれば、できることはひとつしかない。flatMap を呼び出すことである。

trait Monad[F[_]] {
  def pure[A](value: A): F[A]

  def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]

  def map[A, B](value: F[A])(func: A => B): F[B] =
    flatMap(value)(a => ???)
}

flatMap のふたつ目のパラメータとして A => F[B] 型の関数が必要である。関数の実装に使うことのできる部品としては、A => B 型の func パラメータと、A => F[A] 型の pure 関数が手元にある。これらを組み合わせることで、目的の結果が得られる。

trait Monad[F[_]] {
  def pure[A](value: A): F[A]

  def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]

  def map[A, B](value: F[A])(func: A => B): F[B] =
    flatMap(value)(a => pure(func(a)))
}

9.2 Cats におけるモナド

次に、モナドについてもこれまでと同様に Cats を用いて解説を進める。いつもどおり、型クラス本体とインスタンスと構文について見ていこう。

9.2.1 モナド型クラス

モナド型クラスは cats.Monad として提供されている。Monad はふたつの型クラスを拡張している。そのうちのひとつ FlatMapflatMap メソッドを提供し、もうひとつの Applicativepure を提供する。Applicative はさらに Functor を拡張しており、これによってすべての Monadmap メソッドをもつ。これは前述の演習で見たとおりである。Applicative については11章で説明する。

以下は、型クラス自体がもつ pureflatMap、および map を直接使用する例である。

import cats.Monad
val opt1 = Monad[Option].pure(3)
// opt1: Option[Int] = Some(value = 3)
val opt2 = Monad[Option].flatMap(opt1)(a => Some(a + 2))
// opt2: Option[Int] = Some(value = 5)
val opt3 = Monad[Option].map(opt2)(a => 100 * a)
// opt3: Option[Int] = Some(value = 500)

val list1 = Monad[List].pure(3)
// list1: List[Int] = List(3)
val list2 = Monad[List].
  flatMap(List(1, 2, 3))(a => List(a, a*10))
// list2: List[Int] = List(1, 10, 2, 20, 3, 30)
val list3 = Monad[List].map(list2)(a => a + 123)
// list3: List[Int] = List(124, 133, 125, 143, 126, 153)

Monad は、Functor から継承されたメソッドも含めて、他にも多くのメソッドを提供している。詳しくは scaladoc を参照してほしい。

9.2.2 デフォルトのインスタンス

Cats は、標準ライブラリに存在するすべてのモナド(OptionListVector など)に対してインスタンスを提供している。さらに、Cats は Future 用の Monad インスタンスも提供している。ただし、Future クラス自体に定義されたメソッドとは異なり、モナドの pureflatMap メソッドは、暗黙的な ExecutionContext パラメータを受け取ることができない。Monad トレイトの定義にそのようなパラメータが含まれていないためである。この問題を回避するために、Cats では FutureMonad インスタンスを呼び出す際に ExecutionContext をスコープに含める必要がある。

import scala.concurrent.*
import scala.concurrent.duration.*
val fm = Monad[Future]
// error: 
// No given instance of type cats.Monad[scala.concurrent.Future] was found for parameter instance of method apply in object Monad.
// I found:
// 
//     cats.Invariant.catsInstancesForFuture(
//       /* missing */summon[scala.concurrent.ExecutionContext])
// 
// But no implicit values were found that match type scala.concurrent.ExecutionContext.

ExecutionContext をスコープに含めれば、インスタンスを呼び出すために必要な暗黙の解決が行われる。

import scala.concurrent.ExecutionContext.Implicits.global
val fm = Monad[Future]
// fm: Monad[[T >: Nothing <: Any] => Future[T]] = cats.instances.FutureInstances$$anon$1@630921e1

この Monad インスタンスは、暗黙的に渡された ExecutionContext を、この後に行われる pureflatMap 呼び出しで利用する。

val future = fm.flatMap(fm.pure(1))(x => fm.pure(x + 2))
Await.result(future, 1.second)
// res11: Int = 3

上記に加えて、Cats は標準ライブラリには存在しない多くの新しいモナドを提供している。それらのうちのいくつかをこれから学んでいこう。

9.2.3 モナドの構文

モナドのための構文は次の三つのパッケージで提供されている。

実際には、cats.syntax.all.** からすべての構文をまとめてインポートする方が簡単であることが多い。しかし、ここでは明確さを重視し、個別のインポートを使用する。

モナドインスタンスの構築には pure を使うことができる。求めているインスタンスがどれなのか、曖昧さをなくすために型パラメータの指定が必要となることが多い。

import cats.syntax.applicative.* // pure
1.pure[Option]
// res12: Option[Int] = Some(value = 1)
1.pure[List]
// res13: List[Int] = List(1)

OptionList など Scala が標準で提供しているモナドについて、Cats の構文としての flatMapmap メソッドを使って見せるのは難しい。それらのメソッドはモナド自体に明示的に定義されているためである。そこで、プログラマが指定したモナドに包まれたパラメータに対して計算を行うジェネリックな関数を作成することにする。

import cats.Monad
import cats.syntax.functor.* // map
import cats.syntax.flatMap.* // flatMap

def sumSquare[F[_]: Monad](a: F[Int], b: F[Int]): F[Int] =
  a.flatMap(x => b.map(y => x*x + y*y))
sumSquare(Option(3), Option(4))
// res14: Option[Int] = Some(value = 25)
sumSquare(List(1, 2, 3), List(4, 5))
// res15: List[Int] = List(17, 26, 20, 29, 25, 34)

このコードは for 内包表記を使って書き直すことができる。コンパイラは for 内包表記を flatMapmap を用いて書き換え、Monad を利用するための正しい変換を挿入してくれる。

def sumSquare[F[_]: Monad](a: F[Int], b: F[Int]): F[Int] =
  for {
    x <- a
    y <- b
  } yield x*x + y*y
sumSquare(Option(3), Option(4))
// res17: Option[Int] = Some(value = 25)
sumSquare(List(1, 2, 3), List(4, 5))
// res18: List[Int] = List(17, 26, 20, 29, 25, 34)

これで、Cats におけるモナドの一般的な内容についてはおおむねすべて説明した。次に、Scala の標準ライブラリには存在しない有用なモナドインスタンスをいくつか見てみよう。

9.3 Identity モナド

前節では、異なる種類のモナドを抽象化したメソッドを定義して、Cats が提供する flatMapmap の構文を使ってみせた。

import cats.Monad
import cats.syntax.functor.* // map
import cats.syntax.flatMap.* // flatMap

def sumSquare[F[_]: Monad](a: F[Int], b: F[Int]): F[Int] =
  for {
    x <- a
    y <- b
  } yield x*x + y*y

このメソッドは OptionList に対しては正しく動作するが、モナドに包まれていない通常の値を引数にして呼び出すことはできない。

sumSquare(3, 4)
// error: 
// Found:    (3 : Int)
// Required: ([_] =>> Any)[Int]
// Note that implicit conversions were not tried because the result of an implicit conversion
// must be more specific than ([_] =>> Any)[Int]
// error: 
// Found:    (4 : Int)
// Required: ([_] =>> Any)[Int]
// Note that implicit conversions were not tried because the result of an implicit conversion
// must be more specific than ([_] =>> Any)[Int]

もし、モナドに包まれているかどうかと関係なく sumSquare を使えるとしたら、非常に便利だろう。これは、モナド的なコードと非モナド的なコードを抽象化できるということである。ありがたいことに、Cats はこのギャップを埋めるための Id 型を提供している。

import cats.Id
sumSquare(3 : Id[Int], 4 : Id[Int])
// res13: Int = 25

Id を使えば、モナドに包まれていない値を渡してモナド用のメソッドを呼び出すことが可能となる。だが、その正確な意味はすこしわかりにくい。sumSquare に渡すパラメータを Id[Int] 型にキャストし、結果として Id[Int] 型の値を受け取ったことになる。

何が起こっているのだろうか。Id の定義は以下のとおりである。

package cats

type Id[A] = A

実は Id は、基本的な型を単一の型パラメータをもつ型コンストラクタとして書き表すための型エイリアスである。これにより、任意の型の値を、対応する Id 型へとキャストすることができる。

"Dave" : Id[String]
// res14: String = "Dave"
123 : Id[Int]
// res15: Int = 123
List(1, 2, 3) : Id[List[Int]]
// res16: List[Int] = List(1, 2, 3)

Cats は FunctorMonad を含むさまざまな型クラスについて Id 用のインスタンスを提供している。それらのおかげで、単なる値に対して mapflatMappure を呼び出すことができる。

val a = Monad[Id].pure(3)
// a: Int = 3
val b = Monad[Id].flatMap(a)(_ + 1)
// b: Int = 4
import cats.syntax.functor.* // map
import cats.syntax.flatMap.* // flatMap
for {
  x <- a
  y <- b
} yield x + y
// res17: Int = 7

モナド的なコードとそうでないコードを抽象化する能力は極めて強力である。たとえば、本番環境では Future を使って非同期に、テスト環境では Id を使って同期的にコードを実行できる。これについては17章の最初のケーススタディで見ていく。

9.3.1 演習: モナドの隠された正体

Id のための puremapflatMap を実装せよ。実装においてどのような興味深い発見があるだろうか。

まずはメソッドのシグネチャを定義しよう。

import cats.Id

def pure[A](value: A): Id[A] =
  ???

def map[A, B](initial: Id[A])(func: A => B): Id[B] =
  ???

def flatMap[A, B](initial: Id[A])(func: A => Id[B]): Id[B] =
  ???

次に、それぞれのメソッドを順に見ていく。pureA から Id[A] を生成する操作である。しかし AId[A] は同じ型なので、受け取った値を返すだけでよい。

def pure[A](value: A): Id[A] =
  value
pure(123)
// res19: Int = 123

map メソッドは Id[A] 型のパラメータを取り、A => B 型の関数を適用して Id[B] を返す。だが、Id[A]A で、Id[B]B なので、必要なのは関数を呼び出すことだけである。コンテキストに出し入れする必要はない。

def map[A, B](initial: Id[A])(func: A => B): Id[B] =
  func(initial)
map(123)(_ * 2)
// res20: Int = 246

最後に flatMap だが、Id 型コンストラクタを取り除けば flatMapmap は実は同じものである。

def flatMap[A, B](initial: Id[A])(func: A => Id[B]): Id[B] =
  func(initial)
flatMap(123)(_ * 2)
// res21: Int = 246

このことは、ファンクターとモナドがどちらも計算を順に連結するための型クラスであるという理解と一致する。これらの型クラスはいずれも、ある種の複雑性を無視した計算の連結を可能にしてくれる。だが、Id の場合は複雑性が存在しないため、mapflatMap は同じものになる。

また、上記のメソッド本体には型注釈を書く必要がないことに注目してほしい。コンパイラは文脈に基づいて A 型の値を Id[A] として解釈することができるし、その逆も同様である。

これに関する唯一の制約は、Scala が given インスタンスを検索する際に、型と型コンストラクタを同一視できないことである。そのため、この節の冒頭で sumSquare を呼び出す際に、Int 型の値を Id[Int] として型注釈する必要があった11

sumSquare(3 : Id[Int], 4 : Id[Int])

9.4 Either

もうひとつ有用なモナドを見てみよう。Scala 標準ライブラリの Either 型である。Scala 2.11 以前は、EithermapflatMap メソッドをもたなかったため、多くの人は Either をモナドとは考えていなかった。しかし、Scala 2.12 から Either右バイアス(right biased)となった。

9.4.1 左バイアスと右バイアス

Scala 2.11 では Either はデフォルトの mapflatMap メソッドをもっておらず、for 内包表記で使うには不便だった。各ジェネレーター句に .right 呼び出しを挿入する必要があったのである。

val either1: Either[String, Int] = Right(10)
val either2: Either[String, Int] = Right(32)
for {
  a <- either1.right
  b <- either2.right
} yield a + b

Scala 2.12 で Either は再設計された。現在の Either では、右側が成功ケースを表すという決定がなされ、それに伴い mapflatMap が直接サポートされている。これにより、for 内包表記における Either の利用は非常に快適になった。

for {
  a <- either1
  b <- either2
} yield a + b
// res26: Either[String, Int] = Right(value = 42)

Cats はこの動作を cats.syntax.either のインポートを通じて Scala 2.11 にバックポートし、サポートしているすべての Scala バージョンで右バイアスの Either を使用できるようにしている。Scala 2.12 以降では、このインポートを省略することもできるし、残しておいても不都合が生じることはない。

import cats.syntax.either.* // map と flatMap

for {
  a <- either1
  b <- either2
} yield a + b

9.4.2 インスタンスの作成

直接 LeftRight のインスタンスを作成する他に、cats.syntax.either から拡張メソッドの asLeftasRight をインポートして使用することもできる。

import cats.syntax.either.* // asRight
val a = 3.asRight[String]
// a: Either[String, Int] = Right(value = 3)
val b = 4.asRight[String]
// b: Either[String, Int] = Right(value = 4)

for {
  x <- a
  y <- b
} yield x*x + y*y
// res28: Either[String, Int] = Right(value = 25)

これらのスマートコンストラクタには Left.applyRight.apply にない利点がある。それは、これらが LeftRight 型ではなく、Either 型の結果を返すことである。これにより、過度に型を狭めることによって生じる型推論の問題を回避できる。どのような問題か、以下に例を挙げよう。

def countPositive(nums: List[Int]) =
  nums.foldLeft(Right(0)) { (accumulator, num) =>
    if(num > 0) {
      accumulator.map(_ + 1)
    } else {
      Left("Negative. Stopping!")
    }
  }
// error:
// Found:    Either[Nothing, Int]
// Required: Right[Nothing, Int]
//       accumulator.map(_ + 1)
//       ^^^^^^^^^^^^^^^^^^^^^^
// error:
// Found:    Left[String, Any]
// Required: Right[Nothing, Int]
//       Left("Negative. Stopping!")
//       ^^^^^^^^^^^^^^^^^^^^^^^^^^^

このコードはふたつの理由によりコンパイルに失敗する。

  1. コンパイラが蓄積変数の型を Either ではなく Right と推論してしまう
  2. Right.apply の型パラメータを指定していないため、コンパイラが左側のパラメータを Nothing と推論してしまう

asRight に切り替えることで、これらの問題を回避できる。asRightEither を戻り値型とし、型パラメータひとつだけで型を完全に指定することができる。

def countPositive(nums: List[Int]) =
  nums.foldLeft(0.asRight[String]) { (accumulator, num) =>
    if(num > 0) {
      accumulator.map(_ + 1)
    } else {
      Left("Negative. Stopping!")
    }
  }
countPositive(List(1, 2, 3))
// res30: Either[String, Int] = Right(value = 3)
countPositive(List(1, -2, 3))
// res31: Either[String, Int] = Left(value = "Negative. Stopping!")

cats.syntax.either は、Either コンパニオンオブジェクトに便利な拡張メソッドをいくつか追加する。catchOnlycatchNonFatal は、例外を Either インスタンスとして捕捉するために非常に役立つメソッドである。

Either.catchOnly[NumberFormatException]("foo".toInt)
// res32: Either[NumberFormatException, Int] = Left(
//   value = java.lang.NumberFormatException: For input string: "foo"
// )
Either.catchNonFatal(sys.error("Badness"))
// res33: Either[Throwable, Nothing] = Left(
//   value = java.lang.RuntimeException: Badness
// )

他のデータ型から Either インスタンスを作成するメソッドもある。

Either.fromTry(scala.util.Try("foo".toInt))
// res34: Either[Throwable, Int] = Left(
//   value = java.lang.NumberFormatException: For input string: "foo"
// )
Either.fromOption[String, Int](None, "Badness")
// res35: Either[String, Int] = Left(value = "Badness")

9.4.3 Either の変換

cats.syntax.eitherEither のインスタンスに対しても便利なメソッドをいくつか追加する。

Scala 2.11 または 2.12 の利用者は、右側の値を取り出すかもしくはデフォルト値を返す、という処理に orElsegetOrElse を使用することができる。

import cats.syntax.either.*
"Error".asLeft[Int].getOrElse(0)
// res36: Int = 0
"Error".asLeft[Int].orElse(2.asRight[String])
// res37: Either[String, Int] = Right(value = 2)

ensure メソッドを使えば、右側の値がある条件を満たすかどうかを確認することができる。

-1.asRight[String].ensure("Must be non-negative!")(_ > 0)
// res38: Either[String, Int] = Left(value = "Must be non-negative!")

recoverrecoverWith メソッドは、Future における同名のメソッドと同様のエラーハンドリングを提供する。

"error".asLeft[Int].recover {
  case _: String => -1
}
// res39: Either[String, Int] = Right(value = -1)

"error".asLeft[Int].recoverWith {
  case _: String => Right(-1)
}
// res40: Either[String, Int] = Right(value = -1)

map を補完する leftMapbimap メソッドも用意されている。

"foo".asLeft[Int].leftMap(_.reverse)
// res41: Either[String, Int] = Left(value = "oof")
6.asRight[String].bimap(_.reverse, _ * 7)
// res42: Either[String, Int] = Right(value = 42)
"bar".asLeft[Int].bimap(_.reverse, _ * 7)
// res43: Either[String, Int] = Left(value = "rab")

swap メソッドは左右を入れ替えてくれる。

123.asRight[String]
// res44: Either[String, Int] = Right(value = 123)
123.asRight[String].swap
// res45: Either[Int, String] = Left(value = 123)

また、Cats は toOptiontoListtoTrytoValidated などさまざまな変換メソッドを追加してくれる。

9.4.4 エラーハンドリング

Either は通常、フェイルファストなエラーハンドリングを実装するために使用される。計算はいつもどおり flatMap を使って順に連結する。そのどこかで計算が失敗した場合、それ以降の計算は実行されない。

for {
  a <- 1.asRight[String]
  b <- 0.asRight[String]
  c <- if(b == 0) "DIV0".asLeft[Int]
       else (a / b).asRight[String]
} yield c * 100
// res46: Either[String, Int] = Left(value = "DIV0")

Either を使ったエラーハンドリングでは、どの型を使用してエラーを表現するのかを決める必要がある。Throwable を使うこともできる。

type Result[A] = Either[Throwable, A]

これは scala.util.Try を使うのとほぼ同じ意味合いをもつ。問題は、Throwable が非常に広範な型であり、どのような種類のエラーが発生したのかほとんどわからないことである。

別のアプローチとして、代数的データ型を定義し、それによってプログラムで発生しうるエラーを表す方法がある。

enum LoginError {
  case UserNotFound(username: String)

  case PasswordIncorrect(username: String)

  case UnexpectedError 
}
case class User(username: String, password: String)

type LoginResult = Either[LoginError, User]

このアプローチなら Throwable が抱えていた問題を解決できる。想定されるエラーの種類を固定したセットとして定義し、それ以外の予期しないエラーについてもすべて捕捉することができる。また、パターンマッチングの際には網羅性チェックによる安全性を得ることができる。

import LoginError.*

// 型に応じてエラーのハンドリング内容を決定する
def handleError(error: LoginError): Unit =
  error match {
    case UserNotFound(u) =>
      println(s"User not found: $u")

    case PasswordIncorrect(u) =>
      println(s"Password incorrect: $u")

    case UnexpectedError =>
      println(s"Unexpected error")
  }
val result1: LoginResult = User("dave", "passw0rd").asRight
// result1: Either[LoginError, User] = Right(
//   value = User(username = "dave", password = "passw0rd")
// )
val result2: LoginResult = UserNotFound("dave").asLeft
// result2: Either[LoginError, User] = Left(
//   value = UserNotFound(username = "dave")
// )

result1.fold(handleError, println)
// User(dave,passw0rd)
result2.fold(handleError, println)
// User not found: dave

9.4.5 演習: 最適なのはどれか

前の例で示したエラーハンドリング戦略は、どのような目的にも適しているだろうか。エラーハンドリングには他にどのような機能が必要か考察せよ。

これはオープンクエスチョンである。また、一種のひっかけ問題でもある。答えは達成したい内容によって異なるからだ。以下に、よく考えるべきポイントをいくつか挙げよう。

  • 大規模なジョブを処理する際には、エラーからのリカバリが重要である。一日かけてジョブを実行して、最後の段階になって失敗したことが判明する、ということは避けたい。

  • エラーのレポートも同様に重要である。何かがうまくいかなかった、ではなく、具体的に何が失敗したのかを知る必要がある。

  • 最初に遭遇したエラーだけでなく、すべてのエラーを収集したいケースがよくある。典型的な例は Web フォームの検証である。ユーザがフォームを送信したとき、すべてのエラーを一度に報告する方が、ひとつずつ順番に報告するよりもはるかによいユーザ体験となる。

9.5 補足: エラーハンドリングと MonadError

Cats は MonadError と呼ばれる型クラスを付加的に提供している。これは、エラーハンドリングに使用される Either のようなデータ型を抽象化するためのものである。MonadError は、エラーを発生させたり処理したりするための追加の操作を提供する。

この節は任意である

複数種類のエラーハンドリング用モナドを抽象化したいのでないかぎり、MonadError を使用する必要はない。MonadError は、たとえば FutureTry あるいは EitherEitherT(10章に登場する)などを抽象化するときに使用する。

さしあたってそのような抽象化が必要ないのであれば、この節をスキップして9.6節に進んでも構わない。

9.5.1 MonadError 型クラス

以下に MonadError の定義を簡略化したものを示す。

package cats

trait MonadError[F[_], E] extends Monad[F] {
  // エラーを `F` のコンテキストへとリフトする
  def raiseError[A](e: E): F[A]

  // エラーをハンドリングする。エラーはリカバリ可能かもしれないしそうでないかもしれない
  def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
  
  // エラーをハンドリングし、必ずリカバリする
  def handleError[A](fa: F[A])(f: E => A): F[A]

  // `F` のインスタンスを検証し、条件を満たさなかった場合に失敗させる
  def ensure[A](fa: F[A])(e: E)(f: A => Boolean): F[A]
}

MonadError は次のふたつの型パラメータに基づいて定義される。

これらの型パラメータがどのように指定されるのかを示す。ここでは Either に対してこの型クラスをインスタンス化する例を見てみよう。

import cats.MonadError

type ErrorOr[A] = Either[String, A]

val monadError = MonadError[ErrorOr, String]

ApplicativeError

実際には MonadErrorApplicativeError という別の型クラスを拡張している。だが、Applicative は11章まで登場しない。このふたつの型クラスは意味的に同じなので、今のところ詳細は無視しても問題ない。

9.5.2 エラーの発生とハンドリング

MonadError のもっとも重要なメソッドは raiseErrorhandleErrorWith である。raiseError はモナドにおける pure メソッドと似ているが、作成するのは失敗を表すインスタンスである。

val success = monadError.pure(42)
// success: Either[String, Int] = Right(value = 42)
val failure = monadError.raiseError("Badness")
// failure: Either[String, Nothing] = Left(value = "Badness")

handleErrorWithraiseError を補完するメソッドで、エラーを処理し、場合によっては成功に変換することを可能にする。これは Futurerecover メソッドに似ている。

monadError.handleErrorWith(failure) {
  case "Badness" =>
    monadError.pure("It's ok")

  case _ =>
    monadError.raiseError("It's not ok")
}
// res12: Either[String, String] = Right(value = "It's ok")

起こり得るエラーをすべてハンドリングできるとわかっているのであれば、handleError を利用することができる。

monadError.handleError(failure) {
  case "Badness" => 42

  case _ => -1
}
// res13: Either[String, Int] = Right(value = 42)

また、filter のような振る舞いを実装した ensure と呼ばれる便利なメソッドもある。成功したモナドの値を述語でテストし、述語が false を返した場合に発生させるエラーを指定することができる。

monadError.ensure(success)("Number too low!")(_ > 1000)
// res14: Either[String, Int] = Left(value = "Number too low!")

Cats は raiseErrorhandleErrorWith に関する構文を cats.syntax.applicativeError で、そして ensure については cats.syntax.monadError で提供している。

import cats.syntax.applicative.*      // pure
import cats.syntax.applicativeError.* // raiseError など
import cats.syntax.monadError.*       // ensure
val success = 42.pure[ErrorOr]
// success: Either[String, Int] = Right(value = 42)
val failure = "Badness".raiseError[ErrorOr, Int]
// failure: Either[String, Int] = Left(value = "Badness")
failure.handleErrorWith{
  case "Badness" =>
    256.pure

  case _ =>
    ("It's not ok").raiseError
}
// res16: Either[String, Int] = Right(value = 256)
success.ensure("Number to low!")(_ > 1000)
// res17: Either[String, Int] = Left(value = "Number to low!")

これらのメソッドには他にも便利なバリエーションがある。詳しくは cats.MonadErrorcats.ApplicativeError のソースコードを参照してほしい。

9.5.3 MonadError のインスタンス

Cats は、EitherFutureTry など、さまざまなデータ型に対する MonadError のインスタンスを提供している。FutureTry のインスタンスはエラーを常に Throwable で表現するが、Either に対するインスタンスはエラー型をカスタマイズ可能である。

import scala.util.Try

val exn: Throwable =
  new RuntimeException("It's all gone wrong")
exn.raiseError[Try, Int]
// res18: Try[Int] = Failure(
//   exception = java.lang.RuntimeException: It's all gone wrong
// )

9.5.4 演習: 抽象化

次のシグネチャをもつメソッド validateAdult を実装せよ。

def validateAdult[F[_]](age: Int)(implicit me: MonadError[F, Throwable]): F[Int] =
  ???

渡された age が18歳以上の場合、その値を成功として返し、そうでない場合は IllegalArgumentException で表されるエラーを返すこと。

使い方の例を以下に示す。

validateAdult[Try](18)
// res19: Try[Int] = Success(value = 18)
validateAdult[Try](8)
// res20: Try[Int] = Failure(
//   exception = java.lang.IllegalArgumentException: Age must be greater than or equal to 18
// )
type ExceptionOr[A] = Either[Throwable, A]
validateAdult[ExceptionOr](-1)
// res21: Either[Throwable, Int] = Left(
//   value = java.lang.IllegalArgumentException: Age must be greater than or equal to 18
// )

この課題は pureraiseError を使用することで解決できる。型推論を助けるために、これらのメソッドに型パラメータを指定している点に注意してほしい。

def validateAdult[F[_]](age: Int)(implicit me: MonadError[F, Throwable]): F[Int] =
  if(age >= 18) age.pure[F]
  else new IllegalArgumentException("Age must be greater than or equal to 18").raiseError[F, Int]

9.6 Eval モナド

cats.Eval は、異なる評価モデルの抽象化を可能にするモナドである。一般的に、先行評価(eager)遅延評価(lazy)というふたつのモデルが議論される。それぞれ値呼び(call-by-value)および名前呼び(call-by-name)とも呼ばれる。さらに、Eval は結果をメモ化することも可能で、これにより必要呼び(call-by-need)と呼ばれる評価方式が提供される。

Eval はスタックセーフでもある。非常に深い再帰処理であってもスタックオーバーフローを起こすことなく用いることができる。

9.6.1 先行評価、遅延評価、メモ化

評価モデルに関するこれらの用語は何を意味しているのだろうか。いくつか例を見てみよう。

まずは Scala の val について見てみよう。目に見える副作用をともなう計算を用いることで評価モデルを確認できる。次の例では、x の値を計算するコードは、アクセス時ではなく x が定義された時点で実行される。x にアクセスすると、コードを再実行することなく、保存された値が取得される。

val x = {
  println("Computing X")
  math.random()
}
// Computing X
// x: Double = 0.8021359790672512

x // 最初のアクセス
// res26: Double = 0.8021359790672512
x // 二回目のアクセス
// res27: Double = 0.8021359790672512

これは値呼び評価の例である。

続いて def を使った例を見てみよう。y を計算する次のコードは、y を使うまで実行されず、そして使うたびに再実行される。

def y = {
  println("Computing Y")
  math.random()
}

y // 最初のアクセス
// Computing Y
// res28: Double = 0.7583133189237127
y // 二度目のアクセス
// Computing Y
// res29: Double = 0.11257899936254245

名前呼び評価は以下のような性質をもっている。

最後になるが重要なこととして、lazy val は必要呼び評価の例である。z を計算する次のコードは、最初に使用されるまで実行されない(遅延評価)。その結果はキャッシュされ、次回以降のアクセス時には再利用される(メモ化される)。

lazy val z = {
  println("Computing Z")
  math.random()
}

z // 最初のアクセス
// Computing Z
// res30: Double = 0.3328018132227585
z // 二度目のアクセス
// res31: Double = 0.3328018132227585

まとめよう。重要な特性の軸はふたつある。

これらの特性には三つの組み合わせがあり得る。

最後に残された「先行評価でメモ化なし」という組み合わせはあり得ない。

9.6.2 Eval の評価モデル

Eval には NowAlways、および Later という三つの部分型が存在し、それぞれ値呼び、名前呼び、必要呼びに対応している。これら三つのクラスのインスタンス生成用に三つのコンストラクタメソッドが用意されている。作成されたインスタンスは Eval 型として返される。

import cats.Eval
val now = Eval.now(math.random() + 1000)
// now: Eval[Double] = Now(value = 1000.5200580864193)
val always = Eval.always(math.random() + 3000)
// always: Eval[Double] = cats.Always@78328ccf
val later = Eval.later(math.random() + 2000)
// later: Eval[Double] = cats.Later@1efed4df

Eval インスタンスがもつ結果は value メソッドを使って展開できる。

now.value
// res32: Double = 1000.5200580864193
always.value
// res33: Double = 3000.7273318693724
later.value
// res34: Double = 2000.7581818483884

Eval のそれぞれの型は、前述の評価モデルのいずれかを使用して結果を計算する。Eval.now は、値を今すぐ取得するもので、そのセマンティクスは val に似ている。つまり、先行評価であり、メモ化される。

val x = Eval.now{
  println("Computing X")
  math.random()
}
// Computing X
// x: Eval[Double] = Now(value = 0.753369419273102)

x.value // 最初のアクセス
// res36: Double = 0.753369419273102
x.value // 二度目のアクセス
// res37: Double = 0.753369419273102

Eval.always は遅延評価される計算を表す。def のようなものである。

val y = Eval.always{
  println("Computing Y")
  math.random()
}
// y: Eval[Double] = cats.Always@4c394b24

y.value // 最初のアクセス
// Computing Y
// res38: Double = 0.5553221182505715
y.value // 二度目のアクセス
// Computing Y
// res39: Double = 0.14174714064377603

最後に、Eval.later は遅延評価されメモ化される計算を表す。lazy val のようなものである。

val z = Eval.later{
  println("Computing Z")
  math.random()
}
// z: Eval[Double] = cats.Later@1b64d4d

z.value // 最初のアクセス
// Computing Z
// res40: Double = 0.6033244113310103
z.value // 二度目のアクセス
// res41: Double = 0.6033244113310103

これら三つの振る舞いを下表にまとめる。

Scala Cats 特性
val def lazy val Now Always Later 先行評価・メモ化あり 遅延評価・メモ化なし 遅延評価・メモ化あり

### モナドとしての Eval

すべてのモナドと同様に、Evalmap および flatMap メソッドは計算をチェーンに追加する。しかし、この場合、チェーンは関数のリストとして明示的に保存される。Evalvalue メソッドを呼び出して結果を要求するまで、それらの関数は実行されない。

val greeting = Eval
  .always{ println("Step 1"); "Hello" }
  .map{ str => println("Step 2"); s"$str world" }
// greeting: Eval[String] = cats.Eval$$anon$4@ab97605

greeting.value
// Step 1
// Step 2
// res42: String = "Hello world"

元の Eval インスタンスのセマンティクスは保たれるものの、マッピング関数は常に必要に応じて遅延評価される(def のセマンティクスをもつ)という点に注意してほしい。

val ans = for {
  a <- Eval.now{ println("Calculating A"); 40 }
  b <- Eval.always{ println("Calculating B"); 2 }
} yield {
  println("Adding A and B")
  a + b
}
// Calculating A
// ans: Eval[Int] = cats.Eval$$anon$4@2ac977c3

ans.value // 最初のアクセス
// Calculating B
// Adding A and B
// res43: Int = 42
ans.value // 二度目のアクセス
// Calculating B
// Adding A and B
// res44: Int = 42

Eval は、計算のチェーンをメモ化できる memoize メソッドをもっている。memoize 呼び出しまでのチェーンの結果はキャッシュされるが、その後の計算は元のセマンティクスを保持する。

val saying = Eval
  .always{ println("Step 1"); "The cat" }
  .map{ str => println("Step 2"); s"$str sat on" }
  .memoize
  .map{ str => println("Step 3"); s"$str the mat" }
// saying: Eval[String] = cats.Eval$$anon$4@21e8a393

saying.value // 最初のアクセス
// Step 1
// Step 2
// Step 3
// res45: String = "The cat sat on the mat"
saying.value // 二度目のアクセス
// Step 3
// res46: String = "The cat sat on the mat"

9.6.3 トランポリン化と Eval.defer

Eval の便利な特性のひとつは map および flatMap メソッドがトランポリン化されることである。これは、スタックフレームを消費することなく、mapflatMap の呼び出しを任意の深さにネストできることを意味する。この特性はスタックセーフ(stack safety)と呼ばれる。

たとえば、階乗の計算を行う次のような関数を考えてみよう。

def factorial(n: BigInt): BigInt =
  if(n == 1) n else n * factorial(n - 1)

このメソッドは比較的簡単にスタックオーバーフローを引き起こす。

factorial(50000)
// java.lang.StackOverflowError
//   ...

Eval を使えば、このメソッドをスタックセーフに書き換えることができる。

def factorial(n: BigInt): Eval[BigInt] =
  if(n == 1) {
    Eval.now(n)
  } else {
    factorial(n - 1).map(_ * n)
  }
factorial(50000).value
// java.lang.StackOverflowError
//   ...

……はずだったが、またしてもスタックがあふれてしまった。これは、Evalmap メソッドを実行する前に、再帰的な factorial 呼び出しがすべて行われているためである。Eval.defer を使えば、この問題を回避できる。Eval.defer は既存の Eval インスタンスを引数に取り、その評価を遅延させる。defer メソッドも map および flatMap と同様にトランポリン化されるため、既存の操作をスタックセーフにするための簡単な方法として利用できる。

def factorial(n: BigInt): Eval[BigInt] =
  if(n == 1) {
    Eval.now(n)
  } else {
    Eval.defer(factorial(n - 1).map(_ * n))
  }
factorial(50000).value
// res: A very big value

Eval は、大規模な計算やデータ構造を扱う際にスタックセーフ性を確保するための有用なツールである。しかし、トランポリン化も無料ではないことを心に留めておかなくてはならない。スタックの消費を避ける代わりに、ヒープ上に関数オブジェクトのチェーンを作成する。そのため、計算のネストの深さには依然として限界があり、その限界はスタックサイズではなくヒープサイズに依存する。

9.6.4 演習: Eval を使ったより安全な畳み込み

以下の素朴な foldRight 実装はスタックセーフではない。Eval を使ってこれをスタックセーフにせよ。

def foldRight[A, B](as: List[A], acc: B)(fn: (A, B) => B): B =
  as match {
    case head :: tail =>
      fn(head, foldRight(tail, acc)(fn))
    case Nil =>
      acc
  }

この問題を解決するもっとも簡単な方法は、foldRightEval という補助メソッドを導入することである。これは基本的には元のメソッドと同じだが、すべての BEval[B] に置き換え、再帰呼び出しをスタックオーバーフローから守るために Eval.defer を用いる。

import cats.Eval

def foldRightEval[A, B](as: List[A], acc: Eval[B])
    (fn: (A, Eval[B]) => Eval[B]): Eval[B] =
  as match {
    case head :: tail =>
      Eval.defer(fn(head, foldRightEval(tail, acc)(fn)))
    case Nil =>
      acc
  }

あとは foldRightEval を使って foldRight を再定義すればスタックセーフなメソッドができあがる。

def foldRight[A, B](as: List[A], acc: B)(fn: (A, B) => B): B =
  foldRightEval(as, Eval.now(acc)) { (a, b) =>
    b.map(fn(a, _))
  }.value
foldRight((1 to 100000).toList, 0L)(_ + _)
// res50: Long = 5000050000L

9.7 Writer モナド

cats.data.Writer は、計算にログを付随させることのできるモナドである。これを使えば、計算に関するメッセージやエラー、追加データを記録し、最終的な計算結果とともにそれらのログを取り出すことができる。

Writer の一般的な用途のひとつは、マルチスレッド環境での計算におけるステップの記録である。通常の命令型のロギング技術では、異なるコンテキストからのメッセージが混在してしまう可能性があるが、Writer を使えば、計算のログは結果に結びつけられるので、ログを混在させることなく並行計算を行うことができる。

Cats のデータ型

Writer は、cats.data パッケージに属する中から紹介する最初のデータ型である。このパッケージは、便利なセマンティクスを生み出すさまざまな型クラスのインスタンスを提供している。cats.data に含まれる他の例として、次章で見るモナド変換子や、11章に登場する Validated 型がある。

9.7.1 Writer の作成と展開

Writer[W, A] はふたつの値をもつ。W 型のログA 型の結果である。以下のようにすれば、それぞれの型の値から Writer を作成することができる。

import cats.data.Writer
import cats.instances.vector._ // Monoid
Writer(Vector(
  "It was the best of times",
  "it was the worst of times"
), 1859)
// res15: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("It was the best of times", "it was the worst of times"), 1859)
// )

コンソールに表示される型が想定される Writer[Vector[String], Int] ではなく、実際には WriterT[Id, Vector[String], Int] であることに注意してほしい。コードの再利用の観点から、Cats は Writer を別の型 WriterT を使って実装している。WriterT は次章で新たに解説するモナド変換子という概念の一例である。

そういった詳細は今は無視しよう。WriterWriterT の型エイリアスであるため、WriterT[Id, W, A] という型を Writer[W, A] と読み替えてよい。

type Writer[W, A] = WriterT[Id, W, A]

簡便のため、Cats はログまたは結果のみを指定して Writer を作成する方法も提供している。結果だけから作成するのであれば、標準の pure 構文を使用できる。この場合、空のログを生成する方法について Cats が知る必要があるので、Monoid[W] がスコープ内になければならない。

import cats.instances.vector._   // Monoid
import cats.syntax.applicative._ // pure

type Logged[A] = Writer[Vector[String], A]
123.pure[Logged]
// res16: WriterT[Id, Vector[String], Int] = WriterT(run = (Vector(), 123))

手元にログしかない場合は、cats.syntax.writer が提供する tell 構文を使って Writer[Unit] インスタンスを作成することができる。

import cats.syntax.writer._ // tell
Vector("msg1", "msg2", "msg3").tell
// res17: WriterT[Id, Vector[String], Unit] = WriterT(
//   run = (Vector("msg1", "msg2", "msg3"), ())
// )

結果とログがどちらもある場合は、Writer.apply か、もしくは cats.syntax.writer が提供する writer 構文を使うことができる。

import cats.syntax.writer._ // writer
val a = Writer(Vector("msg1", "msg2", "msg3"), 123)
// a: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("msg1", "msg2", "msg3"), 123)
// )
val b = 123.writer(Vector("msg1", "msg2", "msg3"))
// b: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("msg1", "msg2", "msg3"), 123)
// )

Writervalue と written メソッドを使えば、それぞれ結果とログを取り出すことができる。

val aResult: Int =
  a.value
// aResult: Int = 123
val aLog: Vector[String] =
  a.written
// aLog: Vector[String] = Vector("msg1", "msg2", "msg3")

run メソッドを使って両方の値を同時に取り出すこともできる。

val (log, result) = b.run
// log: Vector[String] = Vector("msg1", "msg2", "msg3")
// result: Int = 123

9.7.2 Writer の合成と変換

Writer に格納されているログは、mapflatMap を行ってもそのまま保持される。flatMap は元の Writer のログと、指定された変換関数の結果から得られたログを結合する。そのため、Vector のように効率的な追加や連結操作ができる型をログに使用することが推奨される。

val writer1 = for {
  a <- 10.pure[Logged]
  _ <- Vector("a", "b", "c").tell
  b <- 32.writer(Vector("x", "y", "z"))
} yield a + b
// writer1: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("a", "b", "c", "x", "y", "z"), 42)
// )

writer1.run
// res18: Tuple2[Vector[String], Int] = (
//   Vector("a", "b", "c", "x", "y", "z"),
//   42
// )

mapflatMap を使って結果を変換するのに加えて、Writer 内のログを mapWritten メソッドで変換することもできる。

val writer2 = writer1.mapWritten(_.map(_.toUpperCase))
// writer2: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("A", "B", "C", "X", "Y", "Z"), 42)
// )

writer2.run
// res19: Tuple2[Vector[String], Int] = (
//   Vector("A", "B", "C", "X", "Y", "Z"),
//   42
// )

bimap または mapBoth を使えばログと結果を同時に変換できる。bimap はログ変換用と結果変換用のふたつの関数を引数にとり、mapBoth はログと結果のふたつのパラメータを受け取るひとつの関数を引数にとる。

val writer3 = writer1.bimap(
  log => log.map(_.toUpperCase),
  res => res * 100
)
// writer3: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("A", "B", "C", "X", "Y", "Z"), 4200)
// )

writer3.run
// res20: Tuple2[Vector[String], Int] = (
//   Vector("A", "B", "C", "X", "Y", "Z"),
//   4200
// )

val writer4 = writer1.mapBoth { (log, res) =>
  val log2 = log.map(_ + "!")
  val res2 = res * 1000
  (log2, res2)
}
// writer4: WriterT[Id, Vector[String], Int] = WriterT(
//   run = (Vector("a!", "b!", "c!", "x!", "y!", "z!"), 42000)
// )

writer4.run
// res21: Tuple2[Vector[String], Int] = (
//   Vector("a!", "b!", "c!", "x!", "y!", "z!"),
//   42000
// )

最後に、ログを消去することのできる reset メソッドと、ログと結果の入れ替えを行う swap メソッドを紹介する。

val writer5 = writer1.reset
// writer5: WriterT[Id, Vector[String], Int] = WriterT(run = (Vector(), 42))

writer5.run
// res22: Tuple2[Vector[String], Int] = (Vector(), 42)

val writer6 = writer1.swap
// writer6: WriterT[Id, Int, Vector[String]] = WriterT(
//   run = (42, Vector("a", "b", "c", "x", "y", "z"))
// )

writer6.run
// res23: Tuple2[Int, Vector[String]] = (
//   42,
//   Vector("a", "b", "c", "x", "y", "z")
// )

9.7.3 演習: Show Your Working

Writer はマルチスレッド環境でのロギング処理で役に立つ。いくつかの階乗の計算を並列実行し、そのログを記録することでこれを確認してみよう。

以下に示す factorial 関数は階乗を計算し、計算の途中ステップを出力する。この例はとても小さなものだが、実行に時間がかかるように slowly というヘルパー関数を用いている。

def slowly[A](body: => A) =
  try body finally Thread.sleep(100)

def factorial(n: Int): Int = {
  val ans = slowly(if(n == 0) 1 else n * factorial(n - 1))
  println(s"fact $n $ans")
  ans
}

結果は以下のとおりである。単調に増加する一連の値が出力される。

factorial(5)
// fact 0 1
// fact 1 1
// fact 2 2
// fact 3 6
// fact 4 24
// fact 5 120
// res24: Int = 120

階乗計算をいくつか並列して開始すると、標準出力に出力されるログメッセージが交じり合い、どれがどの計算からのメッセージなのか見分けるのが難しくなる。

import scala.concurrent._
import scala.concurrent.ExecutionContext.Implicits._
import scala.concurrent.duration._

Await.result(Future.sequence(Vector(
  Future(factorial(5)),
  Future(factorial(5))
)), 5.seconds)
// fact 0 1
// fact 0 1
// fact 1 1
// fact 1 1
// fact 2 2
// fact 2 2
// fact 3 6
// fact 3 6
// fact 4 24
// fact 4 24
// fact 5 120
// fact 5 120
// res: scala.collection.immutable.Vector[Int] =
//   Vector(120, 120)

ログメッセージが Writer に記録されるよう factorial を書き換えよ。それにより、並行計算におけるログを計算ごとに分離できることを示せ。

まずは、Writer に型エイリアスを定義し、pure 構文で扱えるようにするところから始めよう。

import cats.data.Writer
import cats.instances.vector._
import cats.syntax.applicative._ // pure

type Logged[A] = Writer[Vector[String], A]
42.pure[Logged]
// res26: WriterT[Id, Vector[String], Int] = WriterT(run = (Vector(), 42))

tell 構文もインポートしておく。

import cats.syntax.writer._ // tell
Vector("Message").tell
// res27: WriterT[Id, Vector[String], Unit] = WriterT(
//   run = (Vector("Message"), ())
// )

最後に Vector 用の Semigroup インスタンスもインポートしておこう。これは Logged に対して mapflatMap する際に必要となる。

import cats.instances.vector._ // Monoid
41.pure[Logged].map(_ + 1)
// res28: WriterT[Id, Vector[String], Int] = WriterT(run = (Vector(), 42))

これらをスコープに置いた上で、factorial の定義は以下のようになる。

def factorial(n: Int): Logged[Int] =
  for {
    ans <- if(n == 0) {
             1.pure[Logged]
           } else {
             slowly(factorial(n - 1).map(_ * n))
           }
    _   <- Vector(s"fact $n $ans").tell
  } yield ans

factorial を呼び出したら、次はその戻り値に対して run を実行し、ログと算出された階乗値を取り出す。

val (log, res) = factorial(5).run
// log: Vector[String] = Vector(
//   "fact 0 1",
//   "fact 1 1",
//   "fact 2 2",
//   "fact 3 6",
//   "fact 4 24",
//   "fact 5 120"
// )
// res: Int = 120

以下のように、ログが混ざってしまう心配なく複数の factorial を並列実行し、計算ごとにログを収集することができる。

Await.result(Future.sequence(Vector(
  Future(factorial(5)),
  Future(factorial(5))
)).map(_.map(_.written)), 5.seconds)
// res: scala.collection.immutable.Vector[cats.Id[Vector[String]]] = 
//   Vector(
//     Vector(fact 0 1, fact 1 1, fact 2 2, fact 3 6, fact 4 24, fact 5 120), 
//     Vector(fact 0 1, fact 1 1, fact 2 2, fact 3 6, fact 4 24, fact 5 120)
//   )

9.8 Reader モナド

cats.data.Reader は、何らかの入力に依存する操作を順に連結するためのモナドである。Reader のインスタンスは引数をひとつ取る関数をラップし、インスタンス同士を組み合わせるための便利なメソッドを提供する。

Reader の一般的な用途のひとつは、依存性注入である。複数の操作がすべて外部の設定に依存する場合、それらを Reader で連結してひとつの大きな操作にまとめ、設定をパラメータとして受け取り、指定された順序でプログラムを実行することができる。

9.8.1 Reader の作成と展開

Reader.apply コンストラクタを使って A => B 型の関数から Reader[A, B] を作成できる。

import cats.data.Reader
final case class Cat(name: String, favoriteFood: String)

val catName: Reader[Cat, String] =
  Reader(cat => cat.name)
// catName: Kleisli[Id, Cat, String] = Kleisli(
//   run = repl.MdocSession$MdocApp10$$$Lambda$22052/0x00000008056ef840@259eb96c
// )

Readerrun で再び関数を取り出すことができ、通常どおり apply で呼び出すことができる。

catName.run(Cat("Garfield", "lasagne"))
// res11: String = "Garfield"

今のところ非常にシンプルだが、関数そのままではなく Reader を使う利点は何だろうか。

9.8.2 Reader の合成

Reader の強力さは、異なる種類の関数の合成を表す map および flatMap メソッドにある。同じ型の設定を受け取る一連の Reader を作成し、mapflatMap でそれらを組み合わせて最後に run を呼び出して設定を注入する、というのがよくある使い方である。

map メソッドは、Reader の計算結果を指定関数に渡すことで、計算をシンプルに拡張する。

val greetKitty: Reader[Cat, String] =
  catName.map(name => s"Hello ${name}")
greetKitty.run(Cat("Heathcliff", "junk food"))
// res12: String = "Hello Heathcliff"

The flatMap method is more interesting. It allows us to combine readers that depend on the same input type. To illustrate this, let’s extend our greeting example to also feed the cat:

flatMap メソッドはもっとおもしろい働きをする。同じ入力型に依存する Reader を組み合わせることができる。これを示すために、あいさつの例を、つづけて猫の餌やりもするように拡張してみよう。

val feedKitty: Reader[Cat, String] =
  Reader(cat => s"Have a nice bowl of ${cat.favoriteFood}")

val greetAndFeed: Reader[Cat, String] =
  for {
    greet <- greetKitty
    feed  <- feedKitty
  } yield s"$greet. $feed."
greetAndFeed(Cat("Garfield", "lasagne"))
// res13: String = "Hello Garfield. Have a nice bowl of lasagne."
greetAndFeed(Cat("Heathcliff", "junk food"))
// res14: String = "Hello Heathcliff. Have a nice bowl of junk food."

9.8.3 演習: Reader をハックする

Reader のよくある使い方は、設定をパラメータとして受け取るプログラムを作成するというものである。ここでは、シンプルだがひととおりの機能を備えたログインシステムの例を用いて理解を深めよう。設定は、有効なユーザのリストとそのパスワードのリストというふたつのデータベースから構成される。

final case class Db(
  usernames: Map[Int, String],
  passwords: Map[String, String]
)

はじめに、Db を入力として受け取る Reader の型エイリアス DbReader を作成せよ。こうしておけば以降のコードを短くできる。

この型エイリアスは入力を Db 型に固定するが、結果型は指定可能なままにしておく。

type DbReader[A] = Reader[Db, A]

Int 型のユーザIDからユーザ名を検索する DbReader を生成するメソッドと、String 型のユーザ名からパスワードを検索する DbReader を生成するメソッドを、それぞれ作成せよ。型シグネチャは以下のとおりとする。

def findUsername(userId: Int): DbReader[Option[String]] =
  ???

def checkPassword(
      username: String,
      password: String): DbReader[Boolean] =
  ???

この考え方のもとでは設定の注入を行うのは一番最後なのだということを思い起こしてほしい。つまり、設定をパラメータとして受け取り、それを与えられた具体的なユーザー情報と照らし合わせる関数を用意する必要がある。

def findUsername(userId: Int): DbReader[Option[String]] =
  Reader(db => db.usernames.get(userId))

def checkPassword(
      username: String,
      password: String): DbReader[Boolean] =
  Reader(db => db.passwords.get(username).contains(password))

最後に、与えられたユーザIDのパスワードを確認する checkLogin メソッドを作成せよ。型シグネチャは以下のとおりとする。

def checkLogin(
      userId: Int,
      password: String): DbReader[Boolean] =
  ???

すでに予想されていたかもしれないが、ここでは flatMap を使って findUsernamecheckPassword を連結する。ユーザー名が見つからなかった場合は pure を使って BooleanDbReader[Boolean] にもち上げている。

import cats.syntax.applicative._ // pure

def checkLogin(
      userId: Int,
      password: String): DbReader[Boolean] =
  for {
    username   <- findUsername(userId)
    passwordOk <- username.map { username =>
                    checkPassword(username, password)
                  }.getOrElse {
                    false.pure[DbReader]
                  }
  } yield passwordOk

checkLogin は以下のように利用できる。

val users = Map(
  1 -> "dade",
  2 -> "kate",
  3 -> "margo"
)

val passwords = Map(
  "dade"  -> "zerocool",
  "kate"  -> "acidburn",
  "margo" -> "secret"
)

val db = Db(users, passwords)
checkLogin(1, "zerocool").run(db)
// res17: Boolean = true
checkLogin(4, "davinci").run(db)
// res18: Boolean = false

9.8.4 どんなときに Reader を使うか

Reader は依存性注入を行うためのツールを提供する。プログラムの各ステップを Reader インスタンスとして記述し、それらを mapflatMap で連結し、依存性を入力として受け取る関数を構築する。

Scala には、依存性注入を実装する方法が数多く存在する。メソッドに複数のパラメータリストを定義する単純な手法から、暗黙のパラメータや型クラス、そして Cake パターンや DI フレームワークのような複雑な技術まで様々である。

Reader は以下のような状況でもっともその真価を発揮する。

プログラムのステップを Reader として表現することで、純粋関数のように簡単にテストでき、さらに mapflatMap というコンビネータを利用できるようになる。

For more complicated problems where we have lots of dependencies, or where a program isn’t easily represented as a pure function, other dependency injection techniques tend to be more appropriate.

たくさんの依存関係をもっている、もしくはプログラムを純粋関数として表現しにくい複雑な問題の場合は、他の依存性注入の手法の方が適していることが多い。

クライスリ射

コンソール出力から気付いたかもしれないが、ReaderKleisli という別の型を使って実装されている。クライスリ射(Kleisli arrow) は、結果型の型コンストラクタを抽象化した Reader のより一般的な形式を表す。クライスリについては10章で再び取り上げる。

9.9 State モナド

cats.data.State は、計算の一部として状態を扱うことを可能にする。アトミックな状態操作を表す State インスタンスをいくつか定義し、それらを mapflatMap でつなぎ合わせることで計算全体を表現する。このようにして、実際の状態変化を伴わずに、純粋関数型の方法で可変状態をモデル化することができる。

9.9.1 State の作成と展開

突き詰めれば、State[S, A] のインスタンスは S => (S, A) 型の関数を表す。S は状態の型、A は結果の型である。

import cats.data.State
val a = State[Int, String]{ state =>
  (state, s"The state is $state")
}

言い換えれば、State インスタンスとは次のふたつの処理を行う関数である。

初期状態を与えれば State モナドを実行することができる。State には runrunSrunA という三つのメソッドがあり、それぞれ状態と結果の異なる組み合わせを返す。各メソッドが返すのは Eval インスタンスで、State はそれによってスタックセーフ性を保っている。最終的な結果を取り出すには、通常どおり value メソッドを呼び出す。

// 状態と結果を取得する
val (state, result) = a.run(10).value
// state: Int = 10
// result: String = "The state is 10"

// 結果を無視し、状態だけを取得する
val justTheState = a.runS(10).value
// justTheState: Int = 10

// 状態を無視し、結果だけを取得する
val justTheResult = a.runA(10).value
// justTheResult: String = "The state is 10"

9.9.2 State の合成と変換

ReaderWriter と同様に、State モナドの強力さはインスタンス同士を組み合わせる能力にある。mapflatMap があるインスタンスから別のインスタンスへと状態を受け渡していく。各インスタンスはひとつのアトミックな状態変換を表し、それらの組み合わせによって一連の完全な状態変更の流れが形成される。

val step1 = State[Int, String]{ num =>
  val ans = num + 1
  (ans, s"Result of step1: $ans")
}

val step2 = State[Int, String]{ num =>
  val ans = num * 2
  (ans, s"Result of step2: $ans")
}

val both = for {
  a <- step1
  b <- step2
} yield (a, b)
val (state, result) = both.run(20).value
// state: Int = 42
// result: Tuple2[String, String] = (
//   "Result of step1: 21",
//   "Result of step2: 42"
// )

見てのとおり、この例における最終的な状態はふたつの変換を順番に適用した結果である。for 内包表記では状態に直接関与していないにもかかわらず、状態はステップからステップへ受け渡されている。

State モナド利用の一般的なモデルは、計算の各ステップを State インスタンスとして表現し、標準的なモナド演算子を使ってそれらのステップを合成することである。Cats は、基本的な構成要素となるステップを作成するための便利なコンストラクタをいくつか提供している。

val getDemo = State.get[Int]
// getDemo: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, Int] = cats.data.IndexedStateT@5ecbf84c
getDemo.run(10).value
// res18: Tuple2[Int, Int] = (10, 10)

val setDemo = State.set[Int](30)
// setDemo: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, Unit] = cats.data.IndexedStateT@6d4de450
setDemo.run(10).value
// res19: Tuple2[Int, Unit] = (30, ())

val pureDemo = State.pure[Int, String]("Result")
// pureDemo: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, String] = cats.data.IndexedStateT@6676a75b
pureDemo.run(10).value
// res20: Tuple2[Int, String] = (10, "Result")

val inspectDemo = State.inspect[Int, String](x => s"${x}!")
// inspectDemo: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, String] = cats.data.IndexedStateT@5e3f59a1
inspectDemo.run(10).value
// res21: Tuple2[Int, String] = (10, "10!")

val modifyDemo = State.modify[Int](_ + 1)
// modifyDemo: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, Unit] = cats.data.IndexedStateT@2fc308af
modifyDemo.run(10).value
// res22: Tuple2[Int, Unit] = (11, ())

for 内包表記を使ってこれらの構成要素を組み立てることができる。中間ステップが状態の変換を表しているだけである場合、その結果は無視するのが一般的である。

import cats.data.State
import State._
val program: State[Int, (Int, Int, Int)] = for {
  a <- get[Int]
  _ <- set[Int](a + 1)
  b <- get[Int]
  _ <- modify[Int](_ + 1)
  c <- inspect[Int, Int](_ * 1000)
} yield (a, b, c)
// program: IndexedStateT[[A >: Nothing <: Any] => Eval[A], Int, Int, Tuple3[Int, Int, Int]] = cats.data.IndexedStateT@601801a9

val (state, result) = program.run(1).value
// state: Int = 3
// result: Tuple3[Int, Int, Int] = (1, 2, 3000)

9.9.3 演習: 後置記法の計算機

State モナドを使うことで、複雑な式に対するシンプルなインタープリタを実装できる。その実装では、可変レジスタの値を結果と一緒に受け渡しながら処理を進める。シンプルな例として、後置記法による整数の算術式計算機の実装について見ていこう。

後置記法について聞いたことがなくても心配いらない。これは、演算子をオペランドの後に記述する数学的表記法である。たとえば、1 + 2 と書く代わりに次のように書く。

1 2 +

後置記法の式は人間にとっては読みづらいが、コードで評価するのは簡単である。オペランドを入れるスタックを用意し、シンボルを左から右へと読み進めながら、次のように操作するだけでよい。

これにより、括弧を使わずに複雑な式を評価できる。たとえば (1 + 2) * 3 なら次のようになる。

1 2 + 3 * // 1 を見つけ、スタックにプッシュ
2 + 3 *   // 2 を見つけ、スタックにプッシュ
+ 3 *     // + を見つけ, 1 と 2 をスタックからポップし、
          //     足し合わせた結果である 3 をスタックにプッシュする
3 *       // 3 を見つけ、スタックにプッシュ
*         // * を見つけ, 3 と 3 をスタックからポップし、
          //     掛け合わせた結果である 9 をスタックにプッシュする

このような式を評価するインタープリタを書いてみよう。State インスタンスをスタック上での変換および中間結果を表すものとし、各シンボルを読み取ってこれに変換する。それらの State インスタンスを flatMap でつなげれば、任意のシンボルの連なりを処理するインタープリタを作成できる。

まずは、単一のシンボルを読み取って State インスタンスに変換する evalOne 関数を作成せよ。以下のコードをテンプレートとして使用すること。エラーハンドリングについては今は考慮しなくてよい。スタックが不正な状態にある場合、例外を投げてもかまわない。

import cats.data.State

type CalcState[A] = State[List[Int], A]

def evalOne(sym: String): CalcState[Int] = ???

もし難しく感じるなら、返却する State インスタンスの基本的な形について考えてみよう。各 State インスタンスは、スタックを受け取りスタックと結果のペアを返す関数的な変換を表している。広い文脈は無視して、そのひとつのステップに集中すればよい。

State[List[Int], Int] { oldStack =>
  val newStack = someTransformation(oldStack)
  val result   = someCalculation
  (newStack, result)
}

Stack インスタンスは、このような形式で自由に書いてもかまわないし、上で見た便利なコンストラクタのシーケンスとして記述してもよい。

見つかったのが演算子かオペランドかによって必要なスタック操作は異なる。わかりやすさのため、ケース毎にひとつずつヘルパー関数を用意し、それを使って evalOne を実装しよう。

def evalOne(sym: String): CalcState[Int] =
  sym match {
    case "+" => operator(_ + _)
    case "-" => operator(_ - _)
    case "*" => operator(_ * _)
    case "/" => operator(_ / _)
    case num => operand(num.toInt)
  }

まずは operand から見ていこう。必要なのは読み取ったオペランドをスタックに積むことだけである。同時に、中間結果としても、そのオペランドを用いる。

def operand(num: Int): CalcState[Int] =
  State[List[Int], Int] { stack =>
    (num :: stack, num)
  }

operator 関数はもうすこし複雑である。スタックからオペランドをふたつポップし、計算結果をスタックにプッシュしなければならない。スタックの一番上にあるのは二番目のオペランドだという点に気をつけること。スタック上にオペランドがふたつ以上ない場合、処理は失敗する。この演習では、失敗のハンドリング方法として、例外を投げることを認めている。

def operator(func: (Int, Int) => Int): CalcState[Int] =
  State[List[Int], Int] {
    case b :: a :: tail =>
      val ans = func(a, b)
      (ans :: tail, ans)

    case _ =>
      sys.error("Fail!")
  }

evalOne を使うと、次のように単一シンボルの式を評価できる。初期スタックとして Nil を渡して runA を呼び出し、結果として得られる Eval インスタンスを value を使って展開する。

evalOne("42").runA(Nil).value
// res27: Int = 42

もっと複雑なプログラムも evalOnemapflatMap を使うことで表現できる。処理のほとんどはスタック上で行われるため、evalOne("1")evalOne("2") といった中間ステップの結果は無視している。

val program = for {
  _   <- evalOne("1")
  _   <- evalOne("2")
  ans <- evalOne("+")
} yield ans
// program: IndexedStateT[[A >: Nothing <: Any] => Eval[A], List[Int], List[Int], Int] = cats.data.IndexedStateT@e733f32

program.runA(Nil).value
// res28: Int = 3

この例を一般化し、List[String] の結果を計算する evalAll メソッドを作成せよ。evalOne を使って各シンボルを処理し、結果として得られる State モナドを flatMap でつなげること。作成する関数のシグネチャは以下のとおりとする。

def evalAll(input: List[String]): CalcState[Int] =
  ???

evalAll の実装では入力に対して畳み込みを行う。0 をコンテキストに包んだだけの純粋な CalcState を初期状態とする。入力されたリストが空であれば 0 が返される。各ステップでは flatMap を行う。その際、中間結果は前の例で見たように無視する。

import cats.syntax.applicative._ // pure

def evalAll(input: List[String]): CalcState[Int] =
  input.foldLeft(0.pure[CalcState]) { (a, b) =>
    a.flatMap(_ => evalOne(b))
  }

evalAll を使えば、複数ステップからなる式を手軽に評価できる。

val multistageProgram = evalAll(List("1", "2", "+", "3", "*"))
// multistageProgram: IndexedStateT[[A >: Nothing <: Any] => Eval[A], List[Int], List[Int], Int] = cats.data.IndexedStateT@9df2e90

multistageProgram.runA(Nil).value
// res30: Int = 9

evalOneevalAll はどちらも State インスタンスを返すので、これらの結果を flatMap でつなげることができる。evalOne はスタックの単純な変換を、evalAll は複雑な変換を生成するが、複雑さにかかわらずどちらも純粋関数である。順序を問わずいくつでも連結が可能である。

val biggerProgram = for {
  _   <- evalAll(List("1", "2", "+"))
  _   <- evalAll(List("3", "4", "+"))
  ans <- evalOne("*")
} yield ans
// biggerProgram: IndexedStateT[[A >: Nothing <: Any] => Eval[A], List[Int], List[Int], Int] = cats.data.IndexedStateT@1ef0353d

biggerProgram.runA(Nil).value
// res31: Int = 21

evalInput 関数を実装し、この演習課題を完成させよ。この関数は、入力された文字列をシンボルに分割して evalAll を呼び出し、その結果に初期スタックを与えて実行するものとする。

難しい部分はすべて終えている。あとは入力をシンボルに分割し、runAvalue を呼び出して結果を展開するだけである。

def evalInput(input: String): Int =
  evalAll(input.split(" ").toList).runA(Nil).value
evalInput("1 2 + 3 4 + *")
// res32: Int = 21

9.10 独自のモナドを定義する

独自の型に対して Monad を定義するには、flatMappure、そしてこれまでは取り上げてこなかった tailRecM という三つのメソッドを実装すればよい。例として Option に対する Monad 実装を以下に示す。

import cats.Monad
import scala.annotation.tailrec

val optionMonad = new Monad[Option] {
  def flatMap[A, B](opt: Option[A])
      (fn: A => Option[B]): Option[B] =
    opt.flatMap(fn)

  def pure[A](opt: A): Option[A] =
    Some(opt)

  @tailrec
  def tailRecM[A, B](a: A)(fn: A => Option[Either[A, B]]): Option[B] = {
    fn(a) match {
      case None           => None
      case Some(Left(a1)) => tailRecM(a1)(fn)
      case Some(Right(b)) => Some(b)
    }
  }
}

tailRecM メソッドは、flatMap のネストされた呼び出しによって消費されるスタック領域を制限するために Cats で使われる最適化技法である。このテクニックは PureScript の作成者 Phil Freeman による2015年の論文に由来している。このメソッドは、fn の結果が Right を返すまで再帰的に自身を呼び出す必要がある。

これを利用することの必要性について、例を挙げて説明しよう。ある関数を、それが停止を指示するまで繰り返し呼び出すメソッドを書きたいとする。この関数はモナドのインスタンスを返す。なぜなら、モナドは計算の連なりを表すし、また多くのモナドには停止の概念が含まれているからである。

flatMap を用いてこのようなメソッドを記述できる。

import cats.syntax.flatMap._ // flatMap

def retry[F[_]: Monad, A](start: A)(f: A => F[A]): F[A] =
  f(start).flatMap{ a =>
    retry(a)(f)
  }

残念なことに、これはスタックセーフではない。このコードは入力が小さいときは正しく動作する。

import cats.instances.option._

retry(100)(a => if(a == 0) None else Some(a - 1))
// res9: Option[Int] = None

だが、大きな値を入力すると StackOverflowError が発生する。

retry(100000)(a => if(a == 0) None else Some(a - 1))
// KABLOOIE!!!!

このメソッドは tailRecM を用いて書き直すことができる。

import cats.syntax.functor._ // map

def retryTailRecM[F[_]: Monad, A](start: A)(f: A => F[A]): F[A] =
  Monad[F].tailRecM(start){ a =>
    f(a).map(a2 => Left(a2))
  }

これで、このメソッドはどれだけ多く再帰を繰り返しても正常に実行される。

retryTailRecM(100000)(a => if(a == 0) None else Some(a - 1))
// res10: Option[Int] = None

tailRecM を明示的に呼び出す必要があるということに留意してほしい。非末尾再帰のコードを tailRecM を使った末尾再帰のコードへと自動的に変換するような仕組みは存在しない。ただし、Monad 型クラスは、このようなメソッドをより簡単に書けるようにするいくつかのユーティリティを提供している。たとえば、retryiterateWhileM を使って書き直すことができ、その場合は tailRecM を直接呼び出さなくてもよい。

import cats.syntax.monad._ // iterateWhileM

def retryM[F[_]: Monad, A](start: A)(f: A => F[A]): F[A] =
  start.iterateWhileM(f)(a => true)
retryM(100000)(a => if(a == 0) None else Some(a - 1))
// res11: Option[Int] = None

12.1節では tailRecM を利用している他のメソッドについても見ていく。

Cats に組み込まれているモナドにはすべて tailRecM の末尾再帰の実装が備わっている。しかし、これから見ていくように、独自モナド用にこれを実装するのは難しい場合がある。

9.10.1 演習: モナドで広がる枝分かれ

前章で登場した Tree データ型に対して Monad を実装せよ。以下に Tree 型の定義を再掲する。

sealed trait Tree[+A]

final case class Branch[A](left: Tree[A], right: Tree[A])
  extends Tree[A]

final case class Leaf[A](value: A) extends Tree[A]

def branch[A](left: Tree[A], right: Tree[A]): Tree[A] =
  Branch(left, right)

def leaf[A](value: A): Tree[A] =
  Leaf(value)

作成したコードが Branch および Leaf のインスタンスに対して動作することを確認せよ。また、この MonadFunctor 的な振る舞いを自動的に提供してくれることを確認せよ。

また、Tree 自身が flatMapmap を直接実装していなくても、Monad がスコープにあれば for 内包表記が使えることも確認せよ。

tailRecM を末尾再帰にしなければならないと気負う必要はない。これを末尾再帰にするのは非常に難しい。解答には末尾再帰の実装とそうでない実装の両方を載せているので、自身の答えと照らし合わせてほしい。

flatMap のコードは map と似ている。map と同じように構造を辿りながら再帰し、各 Leaffunc を適用した結果を使って新しい Tree を構築する。

tailRecM のコードは、末尾再帰にするかどうかにかかわらず、かなり複雑である。

型に従って実装を進めると、自然と非末尾再帰の解答にたどり着く。

import cats.Monad

implicit val treeMonad: Monad[Tree] = new Monad[Tree] {
  def pure[A](value: A): Tree[A] =
    Leaf(value)

  def flatMap[A, B](tree: Tree[A])
      (func: A => Tree[B]): Tree[B] =
    tree match {
      case Branch(l, r) =>
        Branch(flatMap(l)(func), flatMap(r)(func))
      case Leaf(value)  =>
        func(value)
    }

  def tailRecM[A, B](a: A)(func: A => Tree[Either[A, B]]): Tree[B] = {
    flatMap(func(a)) {
      case Left(value) =>
        tailRecM(value)(func)
      case Right(value) =>
        Leaf(value)
    }
  }
}

上記の解答はこの演習においては文句なく正解だが、唯一の問題は Cats がスタックセーフ性を保証できないことである。

末尾再帰の解答を記述するのは、はるかに難しい。以下の解答は、Nazarii Bardiuk 氏による Stack Overflow への投稿から採用したものである。この解法では、ツリーを明示的に深さ優先で探索し、訪問すべきノードのリストである open と、ツリーを再構築するために使用するノードのリストである closed を管理する。

import cats.Monad
import scala.annotation.tailrec

implicit val treeMonad: Monad[Tree] = new Monad[Tree] {
  def pure[A](value: A): Tree[A] =
    Leaf(value)

  def flatMap[A, B](tree: Tree[A])
      (func: A => Tree[B]): Tree[B] =
    tree match {
      case Branch(l, r) =>
        Branch(flatMap(l)(func), flatMap(r)(func))
      case Leaf(value)  =>
        func(value)
    }

  def tailRecM[A, B](arg: A)
      (func: A => Tree[Either[A, B]]): Tree[B] = {
    @tailrec
    def loop(
          open: List[Tree[Either[A, B]]],
          closed: List[Option[Tree[B]]]): List[Tree[B]] =
      open match {
        case Branch(l, r) :: next =>
          loop(l :: r :: next, None :: closed)

        case Leaf(Left(value)) :: next =>
          loop(func(value) :: next, closed)

        case Leaf(Right(value)) :: next =>
          loop(next, Some(pure(value)) :: closed)

        case Nil =>
          closed.foldLeft(Nil: List[Tree[B]]) { (acc, maybeTree) =>
            maybeTree.map(_ :: acc).getOrElse {
              acc match {
                case left :: right :: tail => branch(left, right) :: tail
              }
            }
          }
      }

    loop(List(func(arg)), Nil).head
  }
}

どちらの tailRecM を定義したとしても、この Monad を用いれば Tree に対して flatMapmap を呼び出すことが可能である。

import cats.syntax.functor._ // map
import cats.syntax.flatMap._ // flatMap
branch(leaf(100), leaf(200)).
  flatMap(x => branch(leaf(x - 1), leaf(x + 1)))
// res13: Tree[Int] = Branch(
//   left = Branch(left = Leaf(value = 99), right = Leaf(value = 101)),
//   right = Branch(left = Leaf(value = 199), right = Leaf(value = 201))
// )

for 内包表記を用いて Tree を変換することもできる。

for {
  a <- branch(leaf(100), leaf(200))
  b <- branch(leaf(a - 10), leaf(a + 10))
  c <- branch(leaf(b - 1), leaf(b + 1))
} yield c
// res14: Tree[Int] = Branch(
//   left = Branch(
//     left = Branch(left = Leaf(value = 89), right = Leaf(value = 91)),
//     right = Branch(left = Leaf(value = 109), right = Leaf(value = 111))
//   ),
//   right = Branch(
//     left = Branch(left = Leaf(value = 189), right = Leaf(value = 191)),
//     right = Branch(left = Leaf(value = 209), right = Leaf(value = 211))
//   )
// )

Option のモナドはフェイルファスト、そして List のモナドは連結というセマンティクスをもっている。では二分木における flatMap がもつセマンティクスは何だろうか。ツリー内にあるノードはすべて、サブツリー全体に置き換えられる可能性をもっている。これにより、リストが水平方向だけでなく縦にも結合されるような成長や広がりといった挙動が生み出されるのである。

9.11 まとめ

この章ではモナドについて詳しく見てきた。flatMap は、複数の計算を順序付けてつなげる演算子と見ることができた。この観点から見ると、Option はエラーメッセージなしで失敗する可能性のある計算を、Either はメッセージ付きで失敗するかもしれない計算を、List は複数の結果をもつ可能性のある計算を、そして Future は値を将来のある時点で返すかもしれない計算を、それぞれ表している。

また、Cats が提供する独自の型やデータ構造についても学んだ。そこには IdReaderWriterState が含まれており、幅広いユースケースをカバーしている。

最後に、滅多にないとは思うが、独自のモナドを実装しなければならない時のために、tailRecM を実装して独自の Monad インスタンスを定義する方法を学んだ。tailRecM は特殊な仕組みで、デフォルトでスタックセーフな関数型プログラミングライブラリを構築するための妥協点として存在する。モナドを理解するために tailRecM の理解は必須ではないが、モナディックなコードを書く際にその恩恵を受けられるのはありがたいことである。

10 モナド変換子

モナドはブリトーのようなものだとよく言われる。一度その味を覚えれば、何度も手を伸ばしてしまうということである。しかし、これは問題がないわけではない。ブリトーが腰回りを太らせるように、モナドも入れ子になった for 内包表記によってコードベースを太らせてしまうことがある。

データベースとやり取りをする場面を想像してみよう。ユーザのレコードを取得したいが、そのユーザが存在するかどうかはわからない。そのため、結果は Option[User] として返すことになる。さらに、データベースとの通信はネットワークの問題や認証のトラブルなど、さまざまな理由で失敗する可能性があるため、その結果は Either に包まれる。最終的に得られる結果は Either[Error, Option[User]] という形になる。

この値を使うには flatMap 呼び出しを入れ子にするか、それと同じことを for 内包表記で行わなければならない。

def lookupUserName(id: Long): Either[Error, Option[String]] =
  for {
    optUser <- lookupUser(id)
  } yield {
    for { user <- optUser } yield user.name
  }

これはすぐに厄介な問題へと発展する。

10.1 演習: モナドの合成

ここでひとつの疑問が生じる。任意のふたつのモナドが与えられたとき、それらを何らかの方法で組み合わせてひとつのモナドにできるだろうか。つまり、モナドは合成できるだろうか。コードを書いてみるとすぐに問題に直面する。

import cats.syntax.applicative._ // pure
// 説明用。実際にはコンパイルされない
def compose[M1[_]: Monad, M2[_]: Monad] = {
  type Composed[A] = M1[M2[A]]

  new Monad[Composed] {
    def pure[A](a: A): Composed[A] =
      a.pure[M2].pure[M1]

    def flatMap[A, B](fa: Composed[A])
        (f: A => Composed[B]): Composed[B] =
      // 問題発生! flatMap をどう書けばいいのだろうか
      ???
  }
}

M1M2 のことを何も知らない状態で flatMap の一般的な定義を書くのは不可能である。しかし、どちらか一方のモナドについて知識があれば、コードを完成させることができる場合が多い。たとえば、上記の M2Option に固定すれば、flatMap の定義は明らかとなる。

def flatMap[A, B](fa: Composed[A])
    (f: A => Composed[B]): Composed[B] =
  fa.flatMap(_.fold[Composed[B]](None.pure[M1])(f))

上記の定義が None を使用していることに注意してほしい。これは Option 固有の概念であり、一般的な Monad インターフェースには現れない。Option を他のモナドと組み合わせるには、このような詳細情報が必要である。他のモナドについても同様で、それらを組み合わせた flatMap メソッドを書くのに役立つ固有の情報が存在する。これがモナド変換子(monad transformer)の背後にあるアイデアである。Cats はさまざまなモナドのための変換子を定義しており、それぞれがそのモナドを他と組み合わせるのに必要な付加知識を提供してくれる。いくつか例を見てみよう。

10.2 変換の例

Cats は多くのモナド用に変換子を提供しており、その名称にはそれぞれ T が接尾辞として付けられている。たとえば、EitherTEither を他のモナドと組み合わせ、OptionTOption を他と組み合わせる。

以下は OptionT を使って ListOption を組み合わせる例である。簡便のため ListOption[A] というエイリアスを使い、List[Option[A]] をひとつのモナドとして取り扱う。

import cats.data.OptionT

type ListOption[A] = OptionT[List, A]

ここで注目してほしいのは、ListOption を内側から外側に向かって組み立てている点である。OptionT は内側のモナドである Option 用の変換子であり、外側のモナドの型である List をパラメータとしてそこに渡している。

We can create instances of ListOption using the OptionT constructor, or more conveniently using pure:

ListOption のインスタンスを作成するには OptionT のコンストラクタを使う。もしくは pure を使えばもっと便利である。

import cats.instances.list._     // Monad
import cats.syntax.applicative._ // pure
val result1: ListOption[Int] = OptionT(List(Option(10)))
// result1: OptionT[List, Int] = OptionT(value = List(Some(value = 10)))

val result2: ListOption[Int] = 32.pure[ListOption]
// result2: OptionT[List, Int] = OptionT(value = List(Some(value = 32)))

mapflatMap メソッドは、ListOption がもつ同名のメソッド同士を組み合わせてひとつの操作にする。

result1.flatMap { (x: Int) =>
  result2.map { (y: Int) =>
    x + y
  }
}
// res18: OptionT[List, Int] = OptionT(value = List(Some(value = 42)))

これがすべてのモナド変換子の基礎である。組み合わされた mapflatMap メソッドのおかげで、計算の各段階で値を再帰的に取り出して包み直すようなことをしなくても、両方のモナドを扱うことができる。次に API をさらに詳しく見ていこう。

インポートの複雑さについて

上記コードサンプルのインポート文は、すべての要素がどのように結びつけられているかを示している。

まず、pure 構文を得るために cats.syntax.applicative をインポートしている。pureApplicative[ListOption] 型の暗黙パラメータを必要とする。まだ Applicative については学んでいないが、すべての MonadApplicative でもあるため、今はその違いを無視してかまわない。

Applicative[ListOption] を生成するには、ListOptionT それぞれの Applicative インスタンスが必要である。OptionT は Cats 独自のデータ型なので、そのインスタンスはコンパニオンオブジェクトによって提供される。List 用のインスタンスは cats.instances.list に置かれている。

cats.syntax.functorcats.syntax.flatMap をインポートしていないことにも注目してほしい。これは、OptionT が具体的なデータ型であり、独自の mapflatMap メソッドをもっているためである。これらの構文をインポートしても問題は生じないが、コンパイラは明示的なメソッドを優先するので、インポートされた構文は無視される。

今回こういった複雑さに直面しているのは、cats.implicits による包括的なインポートを意図的に避けているためである。このインポートを使用すれば、必要なすべてのインスタンスや構文がスコープに入り、すべてが簡単に動作する。

10.3 Cats におけるモナド変換子

モナド変換子はいずれもデータ型であり、cats.data に定義されている。モナド変換子は、モナドの積み重ねをラップして新しいモナドを作り出すことができる。ここで使用されるモナドは Cats の Monad 型クラスを通じて構築されたものである。モナド変換子を理解するためにカバーすべき主なポイントは以下のとおりである。

10.3.1 モナド変換子クラス

Cats では慣例的にモナド Foo に対して FooT という名前の変換子クラスが用意されている。実際、Cats の多くのモナドは、モナド変換子と Id モナドを組み合わせて定義されている。以下に、利用可能なインスタンスをいくつか具体的に挙げてみよう。

クライスリ射

9.8節で、Reader モナドが「クライスリ射」というさらに一般的な概念を特殊化したものであると述べた。クライスリ射は Cats では cats.data.Kleisli として表されている。

ようやく、KleisliReaderT が実は同じものであると明かすことができる。ReaderT は実際には Kleisli の型エイリアスとして定義されている。そのため、以前の章で Reader を作成したときに、コンソールには Kleisli と表示されていたのである。

10.3.2 モナドスタックの構築

これらのモナド変換子はすべて同じ慣例に従っている。変換子自体はスタック内の内側にあるモナドを表し、最初の型パラメータが外側のモナドを指定する。残りの型パラメータは、対応するモナドを形成するために使用される型である。

たとえば、先ほどの ListOption 型は OptionT[List, A] のエイリアスだが、これは実質的に List[Option[A]] と同じである。言い換えると、モナドスタックは内側から外側に向かって構築される。

type ListOption[A] = OptionT[List, A]

多くのモナドやすべての変換子はふたつ以上の型パラメータをもっているため、中間段階に対して型エイリアスを定義しなければならない場合がよくある。

たとえば、OptionEither で包みたいとする。Option がもっとも内側の型なので、OptionT モナド変換子を使うことになる。ここで Either を最初の型パラメータとしたいが、Either 自体にはふたつの型パラメータがある一方で、モナドはひとつしか型パラメータをもたない。この場合、型コンストラクタを必要な形状に合わせるため型エイリアスが必要となる。

// 型コンストラクタのパラメータがひとつになるように Either にエイリアスを定義
type ErrorOr[A] = Either[String, A]

// OptionT を使って最終的なモナドスタックを構築
type ErrorOrOption[A] = OptionT[ErrorOr, A]

ErrorOrOption は、ListOption がそうであるように、モナドである。通常どおり puremapflatMap を用いてインスタンスの作成や変換を行うことができる。

import cats.instances.either._ // Monad
val a = 10.pure[ErrorOrOption]
// a: OptionT[ErrorOr, Int] = OptionT(value = Right(value = Some(value = 10)))
val b = 32.pure[ErrorOrOption]
// b: OptionT[ErrorOr, Int] = OptionT(value = Right(value = Some(value = 32)))

val c = a.flatMap(x => b.map(y => x + y))
// c: OptionT[ErrorOr, Int] = OptionT(value = Right(value = Some(value = 42)))

三つ以上のモナドを積み重ねたい場合はさらにややこしくなる。

たとえば、OptionEither を包んだ Future を作りたいとする。ここでも内側から外側へと組み立てを行う。OptionTEitherT で包み、それをさらに Future 包む。しかし、EitherT は型パラメータを三つもっているため、これを一行で定義することはできない。

case class EitherT[F[_], E, A](stack: F[Either[E, A]]) {
  // etc...
}

ここで使われる三つの型パラメータは以下のとおり。

ここでは、FutureError を固定し、A を可変とする EitherT のエイリアスを作成する。

import scala.concurrent.Future
import cats.data.{EitherT, OptionT}

type FutureEither[A] = EitherT[Future, String, A]

type FutureEitherOption[A] = OptionT[FutureEither, A]

Our mammoth stack now composes three monads and our map and flatMap methods cut through three layers of abstraction:

これで、この巨大なスタックは三つのモナドを合成したものとなり、mapflatMap は三層の抽象化を突き抜けて動作する。

import cats.instances.future._ // Monad
import scala.concurrent.Await
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
val futureEitherOr: FutureEitherOption[Int] =
  for {
    a <- 10.pure[FutureEitherOption]
    b <- 32.pure[FutureEitherOption]
  } yield a + b

Kind Projector

モナドスタックを構築する際に、頻繁に複数の型エイリアスを定義しているなら、Kind Projector コンパイラプラグインを試してみるといいかもしれない。Kind Projector は Scala の型構文を拡張し、部分適用された型コンストラクタをより簡単に定義できるようにしてくれる。たとえば、次のように使うことができる。

import cats.instances.option._ // Monad

123.pure[EitherT[Option, String, _]]
// res20: EitherT[[A >: Nothing <: Any] => Option[A], String, Int] = EitherT(
//   value = Some(value = Right(value = 123))
// )

Kind Projector がすべての型宣言を一行に簡素化できるわけではないが、必要な中間型定義の数を減らしコードを読みやすく保つことができる。

10.3.3 インスタンスの構築と展開

すでに見たように、モナド変換子の apply メソッドやおなじみの pure 構文12を使うことで、ひとつのモナドとして扱えるように変換されたモナドスタックを作成することができる。

// apply を使ってインスタンス作成
val errorStack1 = OptionT[ErrorOr, Int](Right(Some(10)))
// errorStack1: OptionT[ErrorOr, Int] = OptionT(
//   value = Right(value = Some(value = 10))
// )

// pure を使ってインスタンス作成
val errorStack2 = 32.pure[ErrorOrOption]
// errorStack2: OptionT[ErrorOr, Int] = OptionT(
//   value = Right(value = Some(value = 32))
// )

モナド変換子スタックを用いた計算が終わった後は、value メソッドを使ってスタックを展開することができる。これにより、変換されていないスタックが返され、通常どおりに個別のモナドを操作できる。

// 変換されていないモナドスタックの抽出
errorStack1.value
// res21: Either[String, Option[Int]] = Right(value = Some(value = 10))

// スタック内の Either に対する map 操作
errorStack2.value.map(_.getOrElse(-1))
// res22: Either[String, Int] = Right(value = 32)

value の呼び出しごとにモナド変換子がひとつ展開される。大きなスタックを完全に展開するには複数回の呼び出しが必要になることもある。たとえば、前出の FutureEitherOption スタックを Await するには、value を二回呼び出す必要がある。

futureEitherOr
// res23: OptionT[FutureEither, Int] = OptionT(
//   value = EitherT(value = Future(Success(Right(Some(42)))))
// )

val intermediate = futureEitherOr.value
// intermediate: EitherT[[T >: Nothing <: Any] => Future[T], String, Option[Int]] = EitherT(
//   value = Future(Success(Right(Some(42))))
// )

val stack = intermediate.value
// stack: Future[Either[String, Option[Int]]] = Future(Success(Right(Some(42))))

Await.result(stack, 1.second)
// res24: Either[String, Option[Int]] = Right(value = Some(value = 42))

10.3.4 デフォルトインスタンス

Cats が提供する多くのモナドは、対応する変換子と Id モナドを用いて定義されている。この事実は、モナドと変換子の API が同一であることを裏付けており、ユーザに安心感を与えてくれる。ReaderWriter、そして State は、いずれもこの方法で定義されている。

type Reader[E, A] = ReaderT[Id, E, A] // = Kleisli[Id, E, A]
type Writer[W, A] = WriterT[Id, W, A]
type State[S, A]  = StateT[Id, S, A]

一方で、対応するモナドとは別々にモナド変換子が定義されることもある。このような場合、変換子のメソッドは、モナドのメソッドを模倣する傾向がある。たとえば、OptionT には getOrElse が定義されているし、EitherT には foldbimapswap などが定義されている。

10.3.5 利用パターン

変換子はあらかじめ定義された方法でモナドを融合するため、さまざまな場所で広範囲にモナド変換子を利用するのは、時に難しいことがある。考えなしに使用すると、モナド変換子を異なる文脈で扱う際に、一旦モナドスタックを展開して別の構成で再構築する必要が生じることもある。

対処方法はいくつかある。ひとつは、単一の「スーパー・スタック」を作成し、それをコードベース全体で一貫して使用するというアプローチである。この方法は、コードが単純で大部分が均一な性質をもっている場合にうまく機能する。たとえばウェブアプリケーションであれば、リクエストハンドラはすべて非同期であり、失敗時には同じ体系のHTTPエラーコードを返す、と決めてしまうことができる。この場合、エラーを表現する代数的データ型を設計し、FutureEither を融合させたものをコード全体で使用すればよい。

sealed abstract class HttpError
final case class NotFound(item: String) extends HttpError
final case class BadRequest(msg: String) extends HttpError
// etc...

type FutureEither[A] = EitherT[Future, HttpError, A]

この手法は、コードベースが大規模で、部分ごとの技術的特性の違いが大きい場合にはうまく機能しなくなる。そのような場面ではコンテキストごとに適しているスタックが異なる。そういったコンテキストに適しているもうひとつのデザインパターンが、モナド変換子を局所的な「接着コード」として使うアプローチである。モジュールの境界では変換されていないスタックを公開し、モジュール内部での操作のためにそれらを一時的に変換し、処理が終わったら再び変換を解除して次に渡す。この方法であれば、各モジュールはどのモナド変換子を使用するかを独自に決定できるようになる。

import cats.data.Writer

type Logged[A] = Writer[List[String], A]

// メソッドは変換されていないスタックを返す
def parseNumber(str: String): Logged[Option[Int]] =
  util.Try(str.toInt).toOption match {
    case Some(num) => Writer(List(s"Read $str"), Some(num))
    case None      => Writer(List(s"Failed on $str"), None)
  }

// 合成を単純化するため内部的にはモナド変換子を用いる
def addAll(a: String, b: String, c: String): Logged[Option[Int]] = {
  import cats.data.OptionT

  val result = for {
    a <- OptionT(parseNumber(a))
    b <- OptionT(parseNumber(b))
    c <- OptionT(parseNumber(c))
  } yield a + b + c

  result.value
}
// このアプローチではモジュールのユーザに OptionT を強制することはない
val result1 = addAll("1", "2", "3")
// result1: WriterT[Id, List[String], Option[Int]] = WriterT(
//   run = (List("Read 1", "Read 2", "Read 3"), Some(value = 6))
// )
val result2 = addAll("1", "a", "3")
// result2: WriterT[Id, List[String], Option[Int]] = WriterT(
//   run = (List("Read 1", "Failed on a"), None)
// )

残念ながら、モナド変換子の扱いに万能のアプローチは存在しない。チームの規模や経験、コードベースの複雑さなど、さまざまな要因によって、最適なアプローチは異なるだろう。モナド変換子が自分たちに適しているかどうかを判断するためには、試行錯誤し、同僚からのフィードバックを集める必要があるかもしれない。

10.4 演習: モナド戦士、トランスフォーム、出動!

変形して姿を隠すことで知られるオートボットたちは、戦闘中に仲間のパワーレベルを問い合わせるメッセージを頻繁に送信する。彼らはこの情報を使って戦略を立て、強力な攻撃を仕掛けるのである。メッセージ送信のメソッドは次のようになっている。

def getPowerLevel(autobot: String): Response[Int] =
  ???

地球の粘性の高い大気の中では通信に時間がかかる。衛星の故障ややっかいなディセプティコン13による妨害のためにメッセージが失われることもある。そこで、Response はモナドのスタックとして表現されている。

type Response[A] = Future[Either[String, A]]

コンボイは自分のニューラルマトリクス内でのネストされた for 内包表記にうんざりしている。モナド変換子を使って Response の型定義を書き直し、彼を助けよ。

このモナドスタックは比較的シンプルな組み合わせである。Future を外側に置き、Either を内側に配置したいので、Future を型パラメータとする EitherT を使って内側から外側に向けて構築する。

import cats.data.EitherT
import scala.concurrent.Future

type Response[A] = EitherT[Future, String, A]

架空の仲間たちからデータを取得する getPowerLevel 関数を実装し、Response の定義が適切であることをテストせよ。以下のデータを使用するものとする。

val powerLevels = Map(
  "Jazz"      -> 6,
  "Bumblebee" -> 8,
  "Hot Rod"   -> 10
)

オートボットが powerLevels のマップに存在しない場合は、アクセスできなかったことを報告するエラーメッセージを返すこと。また、有用性を高めるため、メッセージには name を含めること。

import cats.data.EitherT
import scala.concurrent.Future
val powerLevels = Map(
  "Jazz"      -> 6,
  "Bumblebee" -> 8,
  "Hot Rod"   -> 10
)
import cats.instances.future._ // Monad
import scala.concurrent.ExecutionContext.Implicits.global

type Response[A] = EitherT[Future, String, A]

def getPowerLevel(ally: String): Response[Int] = {
  powerLevels.get(ally) match {
    case Some(avg) => EitherT.right(Future(avg))
    case None      => EitherT.left(Future(s"$ally unreachable"))
  }
}

二体のオートボットは、パワーレベルの合計が15を超えると、必殺技の使用が可能となる。二体の仲間の名前を受け取り、必殺技が使えるかどうかを判定するメソッド canSpecialMove を作成せよ。指定した仲間のいずれかが見つからない場合は、適切なエラーメッセージとともに失敗させること。

def canSpecialMove(ally1: String, ally2: String): Response[Boolean] =
  ???

指定された仲間それぞれにパワーレベルを問い合わせ、得られた結果を mapflatMap で結合すればよい。

def canSpecialMove(ally1: String, ally2: String): Response[Boolean] =
  for {
    power1 <- getPowerLevel(ally1)
    power2 <- getPowerLevel(ally2)
  } yield (power1 + power2) > 15

最後に、二体の仲間の名前を受け取り、彼らに必殺技が使えるかどうかを記したメッセージを出力するメソッド tacticalReport を作成せよ。

def tacticalReport(ally1: String, ally2: String): String =
  ???

value メソッドを使ってモナドスタックを展開し、さらに AwaitfoldFutureEither を展開すればよい。

import scala.concurrent.Await
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._

def canSpecialMove(ally1: String, ally2: String): Response[Boolean] =
  for {
    power1 <- getPowerLevel(ally1)
    power2 <- getPowerLevel(ally2)
  } yield (power1 + power2) > 15

def tacticalReport(ally1: String, ally2: String): String = {
  val stack = canSpecialMove(ally1, ally2).value

  Await.result(stack, 1.second) match {
    case Left(msg) =>
      s"Comms error: $msg"
    case Right(true)  =>
      s"$ally1 and $ally2 are ready to roll out!"
    case Right(false) =>
      s"$ally1 and $ally2 need a recharge."
  }
}

これで、tacticalReport は以下のように使えるようになるはずである。

tacticalReport("Jazz", "Bumblebee")
// res30: String = "Jazz and Bumblebee need a recharge."
tacticalReport("Bumblebee", "Hot Rod")
// res31: String = "Bumblebee and Hot Rod are ready to roll out!"
tacticalReport("Jazz", "Ironhide")
// res32: String = "Comms error: Ironhide unreachable"

10.5 まとめ

この章ではモナド変換子を紹介した。モナド変換子を使えば、ネストしたモナドのスタックを扱う際のネストした for 内包表記やパターンマッチが不要になる。

FutureTOptionTEitherT などの各モナド変換子は、それぞれが対応するモナドを他のモナドと統合するためのコードを提供する。変換子はモナドスタックをラップするデータ構造であり、そのスタック全体を mapflatMap メソッドで展開し、再び組み立てる機能を備えている。

モナド変換子の型シグネチャは内側から外側に向けて書かれる。たとえば EitherT[Option, String, A] であれば、それは Option[Either[String, A]] をラップしたものである。深くネストしたモナドを扱う変換子の型を書く場合は、型エイリアスが役に立つことが多い。

モナド変換子について見たことで、モナドおよび flatMap を用いた計算の順序付けについて知るべきことはすべてカバーした。次の章では話題を変え、SemigroupalApplicative というふたつの型クラスについて新たに議論する。これらの型クラスは、コンテキストに包まれている互いに独立した値を zipping するといった、本書ではこれまで取り上げてこなかった新しいタイプの操作をサポートしてくれる。

11 SemigroupalApplicative

ここまで、ファンクターやモナドに対して mapflatMap を使用し、操作を順序付けて連結する方法について見てきた。ファンクターとモナドはどちらも極めて有用な抽象概念だが、これらでは表現できない種類のプログラムフローも存在する。

その一例がフォームのバリデーションである。フォームのバリデーションにおいては、最初に見つかったエラーで処理を中断するのではなく、すべてのエラーをユーザに返したい。これを Either のようなモナドでモデリングすると、最初のエラーが発生した時点で処理は終了してしまい、他のエラーは失われる。たとえば、以下のコードは最初の parseInt の呼び出しで失敗し、それ以降の処理は行われない。

import cats.syntax.either._ // catchOnly

def parseInt(str: String): Either[String, Int] =
  Either.catchOnly[NumberFormatException](str.toInt).
    leftMap(_ => s"Couldn't read $str")
for {
  a <- parseInt("a")
  b <- parseInt("b")
  c <- parseInt("c")
} yield (a + b + c)
// res2: Either[String, Int] = Left(value = "Couldn't read a")

もうひとつの例は Future の並行処理である。時間のかかる独立したタスクが複数ある場合に、それらを並行して実行するのは理にかなっている。しかし、モナド内包表記ではそれらを順次実行することしかできない。mapflatMap は、各計算が前の結果に依存していることを想定している。そのようなモデルでは並行処理に求められる動作を捉えることはできない。

// context2 は value1 に依存している
context1.flatMap(value1 => context2)

前述の parseIntFuture.apply 呼び出しは互いに独立しているが、mapflatMap はその事実を活用できない。ここで必要なのは計算順序を保証しないもっと制約の弱い構造である。この章では、このパターンをサポートする三つの型クラスを紹介する。

アプリカティブは、Cats で強調されている Semigroupal による定式化ではなく、関数適用の観点から定式化されることが多い。このもうひとつの定式化は、Scalaz や Haskell のような他のライブラリや言語との接点を提供してくれる。この章の終盤では、アプリカティブのさまざまな定式化について学ぶ。また、SemigroupalFunctorApplicativeMonad といった計算を連結する一連の型クラス同士の関係性についても合わせて見ていく。

11.1 Semigroupal

cats.Semigroupal はコンテキスト同士の結合を可能にする型クラスである14F[A] 型と F[B] 型のオブジェクトがひとつずつあるとき、Semigroupal[F] はそれらを結合して F[(A, B)] を形成することができる。Cats においては以下のように定義されている。

trait Semigroupal[F[_]] {
  def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}

この章の冒頭で述べたように、パラメータ fafb は互いに独立している。これらは、product に渡す前にどちらを先に計算してもかまわない。このことは、一連の flatMap 呼び出しにおいて計算の実行に厳密な順序が課されることとは対照的である。そのため、Monad よりも Semigroupal のインスタンスを定義する時のほうが自由度が高い。

11.1.1 ふたつのコンテキストの結合

Semigroup が値同士を結合するのに対して、Semigroupal はコンテキスト同士を結合することができる。例として、ふたつの Option 値を結合してみよう。

import cats.Semigroupal
import cats.instances.option._ // Semigroupal
Semigroupal[Option].product(Some(123), Some("abc"))
// res17: Option[Tuple2[Int, String]] = Some(value = (123, "abc"))

両方のパラメータが Some であれば、内部の値を組み合わせたタプルが得られる。一方、どちらかのパラメータが None の場合は、全体の結果も None となる。

Semigroupal[Option].product(None, Some("abc"))
// res18: Option[Tuple2[Nothing, String]] = None
Semigroupal[Option].product(Some(123), None)
// res19: Option[Tuple2[Int, Nothing]] = None

11.1.2 三つ以上のコンテキストの結合

Semigroupal のコンパニオンオブジェクトには product を元にした一連のメソッドが定義されている。たとえば、メソッド tuple2tuple22product をさまざまな引数の個数に対応させたものである。

import cats.instances.option._ // Semigroupal
Semigroupal.tuple3(Option(1), Option(2), Option(3))
// res20: Option[Tuple3[Int, Int, Int]] = Some(value = (1, 2, 3))
Semigroupal.tuple3(Option(1), Option(2), Option.empty[Int])
// res21: Option[Tuple3[Int, Int, Int]] = None

メソッド map2map22 は、コンテキストに包まれた値をそれぞれ2〜22個受け取り、それに指定の関数を適用する。

Semigroupal.map3(Option(1), Option(2), Option(3))(_ + _ + _)
// res22: Option[Int] = Some(value = 6)

Semigroupal.map2(Option(1), Option.empty[Int])(_ + _)
// res23: Option[Int] = None

メソッド contramap2contramap22 および imap2imap22 も存在する。これらはそれぞれ ContravariantInvariant のインスタンスを必要とする。

11.1.3 Semigroupal 則

Semigroupal が要求する法則はひとつしかない。product メソッドが結合律を満たすことである。

product(a, product(b, c)) == product(product(a, b), c)

11.2 apply 構文

Cats は、上述のメソッドを短縮形で呼び出せる便利な apply 構文を提供している。構文は cats.syntax.apply からインポートする。以下に例を示す。

import cats.instances.option._ // Semigroupal
import cats.syntax.apply._     // tupled および mapN

tupled メソッドは Option のタプルに暗黙的に追加される。このメソッドは Option 用の Semigroupal を使用して Option 内部の値を結合し、ひとつのタプルを内部に保持する単一の Option を生成する。

(Option(123), Option("abc")).tupled
// res24: Option[Tuple2[Int, String]] = Some(value = (123, "abc"))

最大で22個の値をもつタプルに対して同じ方法を用いることができる。Cats は引数の個数それぞれに対応する tupled メソッドを定義している。

(Option(123), Option("abc"), Option(true)).tupled
// res25: Option[Tuple3[Int, String, Boolean]] = Some(
//   value = (123, "abc", true)
// )

Cats の apply 構文は tupled の他に mapN というメソッドも提供している。このメソッドは、暗黙の Functor と、レシーバとなったタプルの値と同数の引数をもつ関数を受け取り、値を結合する。

final case class Cat(name: String, born: Int, color: String)
(
  Option("Garfield"),
  Option(1978),
  Option("Orange & black")
).mapN(Cat.apply)
// res26: Option[Cat] = Some(
//   value = Cat(name = "Garfield", born = 1978, color = "Orange & black")
// )

これらのメソッドの中では mapN がもっともよく用いられる。

mapN は内部的に Semigroupal を使用して Option から値を抽出し、Functor を使用して値を関数に適用する。

この構文が型安全である点も魅力的である。指定した関数の引数の個数や型が誤っている場合、コンパイルエラーが発生する。

val add: (Int, Int) => Int = (a, b) => a + b
// add: Function2[Int, Int, Int] = repl.MdocSession$MdocApp16$$$Lambda$18824/0x0000000804d1b840@f9b3e9
(Option(1), Option(2), Option(3)).mapN(add)
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// end of statement expected but '.' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// end of statement expected but '.' found
// error: 
// Found:    (repl.MdocSession.MdocApp3.add : (Int, Int) => Int)
// Required: (Int, Int, Int) => Any
(Option("cats"), Option(true)).mapN(add)
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// end of statement expected but '.' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// ':' expected, but '(' found
// error: 
// end of statement expected but '.' found
// error:
// Found:    (repl.MdocSession.MdocApp3.add : (Int, Int) => Int)
// Required: (String, Boolean) => Any
// (Option("cats"), Option(true)).mapN(add)
//                                     ^^^

11.2.1 ファンシーなファンクターと apply 構文

apply 構文は、反変ファンクターを受け取る contramapN と、非変ファンクターを受け取る imapN メソッドも提供している(これらのファンクターについては8.6節を参照)。たとえば Invariant を使って Monoid を結合することができる。以下に例を示す。

import cats.Monoid
import cats.instances.int._        // Monoid
import cats.instances.invariant._  // Semigroupal
import cats.instances.list._       // Monoid
import cats.instances.string._     // Monoid
import cats.syntax.apply._         // imapN

final case class Cat(
  name: String,
  yearOfBirth: Int,
  favoriteFoods: List[String]
)

val tupleToCat: (String, Int, List[String]) => Cat =
  Cat.apply _

val catToTuple: Cat => (String, Int, List[String]) =
  cat => (cat.name, cat.yearOfBirth, cat.favoriteFoods)

implicit val catMonoid: Monoid[Cat] = (
  Monoid[String],
  Monoid[Int],
  Monoid[List[String]]
).imapN(tupleToCat)(catToTuple)

この Monoid は空の Cat の作成と、7章で紹介した構文を用いた Cat 同士の結合を行うことができる。

import cats.syntax.semigroup._ // |+|

val garfield   = Cat("Garfield", 1978, List("Lasagne"))
val heathcliff = Cat("Heathcliff", 1988, List("Junk Food"))
garfield |+| heathcliff
// res30: Cat = Cat(
//   name = "GarfieldHeathcliff",
//   yearOfBirth = 3966,
//   favoriteFoods = List("Lasagne", "Junk Food")
// )

11.3 さまざまな型に対する Semigroupal

Semigroupal はいつも期待どおりの動作をしてくれるわけではない。特に、型に対して Monad インスタンスも同時に定義されている場合、挙動は期待どおりにはならない。すでに Option に対する Semigroupal の振る舞いを見てきたが、ここで他の型についての例も見てみよう。

Future

Future のセマンティクスは逐次実行の代わりに並列実行を提供する。

import cats.Semigroupal
import cats.instances.future._ // Semigroupal
import scala.concurrent._
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global

val futurePair = Semigroupal[Future].
  product(Future("Hello"), Future(123))
Await.result(futurePair, 1.second)
// res11: Tuple2[String, Int] = ("Hello", 123)

ふたつの Future は生成された瞬間から実行を開始し、product を呼び出す時点ですでに両方の結果の計算が進行している。apply 構文を使えば、所定の数の Future をひとつにまとめることができる。

import cats.syntax.apply._ // mapN

case class Cat(
  name: String,
  yearOfBirth: Int,
  favoriteFoods: List[String]
)

val futureCat = (
  Future("Garfield"),
  Future(1978),
  Future(List("Lasagne"))
).mapN(Cat.apply)
Await.result(futureCat, 1.second)
// res12: Cat = Cat(
//   name = "Garfield",
//   yearOfBirth = 1978,
//   favoriteFoods = List("Lasagne")
// )

List

SemigroupalList を結合して得られる結果は予想とは異なるかもしれない。次のコードを見てほしい。zip メソッドと同じような結果を期待するかもしれないが、実際には要素同士のデカルト積が得られる。

import cats.Semigroupal
import cats.instances.list._ // Semigroupal
Semigroupal[List].product(List(1, 2), List(3, 4))
// res13: List[Tuple2[Int, Int]] = List((1, 3), (1, 4), (2, 3), (2, 4))

これはおそらく意外に感じられるだろう。リストにおいては zip 操作のほうが一般的に思えるからである。なぜこのような挙動になるのかについては、すぐ後で説明する。

Either

この章の冒頭ではフェイルファストと蓄積型エラーハンドリングについて比較した。Eitherproduct を適用すると複数のエラーがひとつにまとめられることを期待するかもしれない。しかし、再び驚かされるかもしれないが、productflatMap と同じくフェイルファストな振る舞いを実装している。

import cats.instances.either._ // Semigroupal

type ErrorOr[A] = Either[Vector[String], A]
Semigroupal[ErrorOr].product(
  Left(Vector("Error 1")),
  Left(Vector("Error 2"))
)
// res14: Either[Vector[String], Tuple2[Nothing, Nothing]] = Left(
//   value = Vector("Error 1")
// )

この例では、product は最初の失敗を検出した時点で処理を中断する。ふたつ目のパラメータを調べ、そちらも失敗であると確認することは可能なはずだが、それが実行されることはない。

11.3.1 モナドに対する Semigroupal

ListEither に対して予想外の結果が得られる理由は、これらがどちらもモナドだからである。モナドに対して product は次のように実装される。

import cats.Monad
import cats.syntax.functor._ // map
import cats.syntax.flatMap._ // flatmap

def product[F[_]: Monad, A, B](fa: F[A], fb: F[B]): F[(A,B)] =
  fa.flatMap(a => 
    fb.map(b =>
      (a, b)
    )
  )

実装方法によって product のセマンティクスが異なるとしたらおかしな話である。そこで、一貫したセマンティクスを保証するために、Cats の MonadSemigroupal を拡張している)は、上記のように mapflatMap を用いて標準的な product の定義を提供している。

Future に対する結果も一種の錯覚である。flatMap が逐次的な順序付けを提供するのだから、product も同じ挙動を示す。並列実行しているように観察されるのは、product を呼び出す前に個々の Future が実行を開始しているからである。これは、以下に示すようなクラシックな「作成してから flatMap する」パターンと同じである。

val a = Future("Future 1")
val b = Future("Future 2")

for {
  x <- a
  y <- b
} yield (x, y)

では、なぜ Semigroupal にこだわる必要があるのだろうか。その答えは、Semigroupal(および Applicative)のインスタンスをもつが Monad インスタンスをもたない有用なデータ型を作成できる点にある。データ型がモナドでなければ product を異なる方法で実装するのも自由である。この点については、エラーハンドリングの代替データ型を見ていく際にさらに詳しく説明する。

11.3.1.1 演習: Listproduct

なぜ List に対する product はデカルト積を生成するのか考察せよ。上述した例を以下に再掲する。

Semigroupal[List].product(List(1, 2), List(3, 4))
// res16: List[Tuple2[Int, Int]] = List((1, 3), (1, 4), (2, 3), (2, 4))

tupled を用いて書くこともできる。

(List(1, 2), List(3, 4)).tupled
// res17: List[Tuple2[Int, Int]] = List((1, 3), (1, 4), (2, 3), (2, 4))

この演習は flatMapmap を用いた product の定義について理解度を確認するためのものである。

import cats.syntax.functor._ // map
import cats.syntax.flatMap._ // flatMap

def product[F[_]: Monad, A, B](x: F[A], y: F[B]): F[(A, B)] =
  x.flatMap(a => y.map(b => (a, b)))

これは for 内包表記を用いた以下のコードと等価である。

def product[F[_]: Monad, A, B](x: F[A], y: F[B]): F[(A, B)] =
  for {
    a <- x
    b <- y
  } yield (a, b)

flatMap のセマンティクスが、ListEither に対する product の挙動を生じさせる要因である。

import cats.instances.list._ // Semigroupal
product(List(1, 2), List(3, 4))
// res20: List[Tuple2[Int, Int]] = List((1, 3), (1, 4), (2, 3), (2, 4))

11.4 Parallel

前節では、Monad インスタンスをもつ型に対して product を呼び出すと、逐次的なセマンティクスが得られることを見た。これは flatMapmap を用いた product の実装と一貫性を保つという観点からは理にかなっている。しかし、この動作が常に望ましいわけではない。Parallel 型クラスとその関連構文を使用すれば、特定のモナドについて、別のセマンティクスにアクセスできるようになる。

Either における product メソッドが最初のエラーで処理を停止することはすでに見た。

import cats.Semigroupal
import cats.instances.either._ // Semigroupal

type ErrorOr[A] = Either[Vector[String], A]
val error1: ErrorOr[Int] = Left(Vector("Error 1"))
val error2: ErrorOr[Int] = Left(Vector("Error 2"))
Semigroupal[ErrorOr].product(error1, error2)
// res9: Either[Vector[String], Tuple2[Int, Int]] = Left(
//   value = Vector("Error 1")
// )

tupled を用いた短縮形で書くと以下のようになる。

import cats.syntax.apply._ // tupled
import cats.instances.vector._ // Semigroup on Vector
(error1, error2).tupled
// res10: Either[Vector[String], Tuple2[Int, Int]] = Left(
//   value = Vector("Error 1")
// )

この tupled を、その並列バージョンである parTupled に置き換えるだけで、すべてのエラーを収集することが可能となる。

import cats.syntax.parallel._ // parTupled
(error1, error2).parTupled
// res11: Either[Vector[String], Tuple2[Int, Int]] = Left(
//   value = Vector("Error 1", "Error 2")
// )

両方のエラーが返されることに注目してほしい。この振る舞いはエラー型として Vector を用いた場合に限らない。Semigroup インスタンスをもつ型であれば、どんな型でも動作する。たとえば List を用いる例を以下に示す。

import cats.instances.list._ // Semigroup on List

type ErrorOrList[A] = Either[List[String], A]
val errStr1: ErrorOrList[Int] = Left(List("error 1"))
val errStr2: ErrorOrList[Int] = Left(List("error 2"))
(errStr1, errStr2).parTupled
// res12: Either[List[String], Tuple2[Int, Int]] = Left(
//   value = List("error 1", "error 2")
// )

Parallel は、Semigroupal や関連する型のメソッドに対して多くの構文メソッドを提供している。もっともよく使われるのは parMapN である。以下に、エラーハンドリングの場面における parMapN の例を示す。

val success1: ErrorOr[Int] = Right(1)
val success2: ErrorOr[Int] = Right(2)
val addTwo = (x: Int, y: Int) => x + y
(error1, error2).parMapN(addTwo)
(success1, success2).parMapN(addTwo)
// res13: Either[Vector[String], Int] = Right(value = 3)

Parallel の動作原理を掘り下げてみよう。以下が Parallel の中核をなす定義である。

trait Parallel[M[_]] {
  type F[_]
  
  def applicative: Applicative[F]
  def monad: Monad[M]
  def parallel: ~>[M, F]
}

これによると、ある型コンストラクタ M に対して Parallel インスタンスが存在する場合、以下のことが成り立つ。

~> について本書ではまだ紹介していないが、これは FunctionK の型エイリアスで、M から F への変換を表す。通常の関数 A => BA 型の値を B 型の値に変換するが、MF は型そのものではなく型コンストラクタであることを思い出そう。M ~> F という FunctionK は、M[A] 型の値を F[A] 型の値に変換する関数である。簡単な例として、OptionList に変換する FunctionK を定義してみよう。

import cats.arrow.FunctionK

object optionToList extends FunctionK[Option, List] {
  def apply[A](fa: Option[A]): List[A] =
    fa match {
      case None    => List.empty[A]
      case Some(a) => List(a)
    }
}
optionToList(Some(1))
// res14: List[Int] = List(1)
optionToList(None)
// res15: List[Nothing] = List()

型パラメータ A はジェネリックであるため、FunctionK が型コンストラクタ M 内に含まれる値を参照することはできない。変換は、純粋に型コンストラクタ MF の構造に基づいて行われなければならない。上記の optionToList でも、その点を確認することができる。

まとめると、Parallel は、Monad インスタンスをもつ型を、Applicative(または Semigroupal)インスタンスをもつ何らかの関連型へと変換可能にしてくれる。変換後の型はモナドとは別の有用なセマンティクスを提供してくれる。先述の例では、Either の関連するアプリカティブが、フェイルファストではなく、エラーの蓄積を可能にするケースを見た。

では、Parallel を理解したところで、次はいよいよ Applicative について学んでいこう。

11.4.0.1 演習: ListParallel

ListParallel インスタンスをもつか、もつとしたらその Parallel インスタンスは何を行うか、確認せよ。

List には Parallel インスタンスが存在し、デカルト積を作成する代わりにリストを zip する。

簡単なコードを書くことでこれを確認できる。

import cats.instances.list._
(List(1, 2), List(3, 4)).tupled
(List(1, 2), List(3, 4)).parTupled
// res16: List[Tuple2[Int, Int]] = List((1, 3), (2, 4))

11.5 ApplyApplicative

Semigroupal は関数型プログラミングについての多くの文献ではあまり言及されない。Semigroupal が、アプリカティブファンクタ(略してアプリカティブ)と呼ばれる型クラスの機能の一部を提供するものだからである。

SemigroupalApplicative は実質的に、コンテキストの結合という同じアイデアを別々のやり方で表現したものである。これらふたつの表現は Conor McBride と Ross Paterson による2008年の論文で紹介されている15

Cats ではふたつの型クラスを用いてアプリカティブをモデリングしている。最初の型クラスである cats.Apply は、SemigroupalFunctor を拡張し、コンテキストの中で関数をパラメータに適用する ap メソッドを追加する。ふたつ目の型クラスである cats.Applicative は、Apply を拡張し、9章で紹介した pure メソッドを追加する。簡略化した定義を以下にコードで示す。

trait Apply[F[_]] extends Semigroupal[F] with Functor[F] {
  def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]

  def product[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
    ap(map(fa)(a => (b: B) => (a, b)))(fb)
}

trait Applicative[F[_]] extends Apply[F] {
  def pure[A](a: A): F[A]
}

この定義の中身を説明すると、ap メソッドは、コンテキスト F[_] の内部で関数 ff をパラメータ fa に適用する操作である。Semigroupalproduct メソッドは apmap を用いて定義されている。

product の実装についてあまり深く考える必要はない。理解しづらいのに加え、詳細はそれほど重要ではない。重要なのは、productapmap には密接な関係があり、これらはいずれも自分以外のふたつを用いて定義できるという点である。

Applicative では、更に pure メソッドが導入される。これは Monad で見たのと同じ pure である。ラップされていない値からアプリカティブ型のコンテキストに包まれた値を新しく構築する。この意味で、ApplicativeApply の関係は、MonoidSemigroup の関係に似ている。

11.5.1 計算を連結する型クラスの階層構造

ApplyApplicative を紹介したことで、計算をそれぞれの方法で連結する一連の型クラスについて全体像を見渡すことができるようになった。図10は、本書で扱った型クラス同士の関係を示している16

モナド型クラスの階層図
Figure 10: モナド型クラスの階層図

階層図に記載されている型クラスはそれぞれ特定の連結セマンティクスを表す。特徴的な一連のメソッドを導入し、それらのメソッドを用いて上位型の機能を定義する。

型クラス間の関係は法則に従うため、ある型クラスのすべてのインスタンスにおいて継承関係は一貫している。たとえば、Applyapmap を使って product を定義するし、MonadpureflatMap を使って productapmap を定義する。

これを説明するために、ふたつの架空のデータ型について考えてみよう。

これらふたつのデータ型について、実装の詳細を知らずに何が言えるだろうか?

Foo についての情報量は Bar よりも多い。なぜなら、MonadApplicative の部分型であり、Bar には保証できない特性である flatMap の存在を Foo は保証できるからである。 逆に、BarFoo よりも広範囲な振る舞いをもつ場合がある。Bar のほうが従うべき法則が少ないので(flatMap の存在を保証しなくてよい)、Foo には実現できない振る舞いを実装することができる。

このことは、数学的な意味でのパワーと、制約との間のよくあるトレードオフを示している。データ型に多くの制約を課すほど、その振る舞いについて強い保証が得られるが、モデリングできる振る舞いの幅は狭くなる。

モナドはこのトレードオフの中で絶妙な位置にある。幅広い振る舞いをモデリングできる柔軟性をもちながら、それらの振る舞いに強い保証を与える制約も、同時にもち合わせている。しかし、モナドが課題解決の正しい選択肢とは言えない場面も存在する。タイ料理が食べたい時にブリトーで満足することはできないのである。

モナドが計算に厳密な順序付けを課す一方で、アプリカティブや Semigroupal にはそのような制約がない。そういった特性の違いから、これらの型クラスはそれぞれ、階層の中に適したポジションをもっている。アプリカティブや Semigroupal には、モナドが扱うことのできない並列的で独立した計算を表現することができるのである。

データ構造を選べばセマンティクスも決まる。モナドを選べば、厳密な順序付けが得られる。アプリカティブを選べば、flatMap は使えなくなる。これが一貫性のある法則によって強いられるトレードオフである。だから、型の選択は慎重に行ってほしい。

11.6 まとめ

本書で扱った計算の連結を可能とするデータ型のうちモナドとファンクターがもっとも広く使用されるが、もっとも汎用的なのは Semigroupal とアプリカティブである。これらの型クラスは、値同士を結合しコンテキスト内で関数を適用するための汎用的なメカニズムを提供する。また、そこからモナドや他のさまざまなコンビネータを作り出すことができる。

SemigroupalApplicative は、バリデーションの結果など互いに独立した値同士を結合する手段として、もっとも一般的に使用される。Cats ライブラリは、特にこの目的のために Validated 型を提供し、バリデーションルールの結合を表現する便利な方法として apply 構文も用意している。

本書のテーマに掲げた関数型プログラミングの概念はこれでほぼ網羅した。次の章では、データ型間の変換を行う強力な型クラスである TraverseFoldable について説明する。その後、第1部で紹介したすべての概念を活用するいくつかのケーススタディを見ていく。

12 FoldableTraverse

この章ではコレクション走査を抽象化するふたつの型クラスについて説明する。

まずは Foldable から見ていこう。その後、畳み込みが複雑になり Traverse がその力を発揮するケースについて考察する。

12.1 Foldable

Foldable 型クラスは、ListVectorStream といったシーケンス(順序付きコレクションのことを指す。以下同じ)で使われる foldLeftfoldRight メソッドの特徴を捉えたものである。Foldable を使えば、さまざまなシーケンス型に対応する汎用的な畳み込み処理を書くことができる。また、新しいシーケンス型を作成してコードに組み込むことも可能である。Foldable は、MonoidEval モナドの実用的なユースケースを示してくれる。

12.1.1 fold 関数と畳み込み処理

まず、畳み込みの一般的な概念を簡単におさらいしよう。畳み込み処理では蓄積変数二項関数を与え、その二項関数によって蓄積変数とシーケンス内の各要素を順次組み合わせる。

def show[A](list: List[A]): String =
  list.foldLeft("nil")((accum, item) => s"$item then $accum")
show(Nil)
// res15: String = "nil"

show(List(1, 2, 3))
// res16: String = "3 then 2 then 1 then nil"

foldLeft メソッドは、シーケンスをたどりながら再帰的に動作する。二項関数は各要素に対して繰り返し呼び出され、その結果が次の繰り返しで使用される蓄積変数となる。シーケンスの終端に到達したときの最終的な蓄積変数が最終結果となる。

実行する操作によっては、どちらの順序で畳み込みを行うかが重要になる場合がある。そのため、畳み込みにはふたつの標準的なバリエーションがある。

図11に各方向の畳み込みの動作を示す。

fold Created with Sketch. 1 1 2 2 3 3 0 3 5 6 + + + 1 2 3 1 2 3 0 1 3 + + + 6
Figure 11: foldLeft と foldRight の処理イメージ

二項演算が結合律を満たす場合、foldLeftfoldRight は同じ結果を返す。たとえば、蓄積変数の初期値を 0 とし、加算を行う二項関数を用いれば、どちらの方向に畳み込んでも List[Int] の合計を求めることができる。

List(1, 2, 3).foldLeft(0)(_ + _)
// res17: Int = 6
List(1, 2, 3).foldRight(0)(_ + _)
// res18: Int = 6

結合的でない演算を用いた場合、評価順序によって結果は異なってくる。たとえば、減算を用いて畳み込みを行えば、処理の方向によって異なる結果が得られる。

List(1, 2, 3).foldLeft(0)(_ - _)
// res19: Int = -6
List(1, 2, 3).foldRight(0)(_ - _)
// res20: Int = 2

12.1.2 演習: 畳み込み処理の振り返り

蓄積変数の初期値に空のリストを、二項演算に :: を用いて foldLeftfoldRight を試せ。それぞれの場合でどのような結果が得られるだろうか。

左から右への畳み込みはリストを逆順にする。

List(1, 2, 3).foldLeft(List.empty[Int])((a, i) => i :: a)
// res21: List[Int] = List(3, 2, 1)

右から左への畳み込みはリストの順序を保ったまま複製する。

List(1, 2, 3).foldRight(List.empty[Int])((i, a) => i :: a)
// res22: List[Int] = List(1, 2, 3)

蓄積変数の型を正しく指定しないと型エラーになる点に注意が必要である。List.empty[Int] を使用することで、蓄積変数の型が Nil.typeList[Nothing] と推論されるのを避けている。

List(1, 2, 3).foldRight(Nil)(_ :: _)
// error: 
// Found:    List[Int]
// Required: scala.collection.immutable.Nil.type

12.1.3 演習: fold で他のメソッドを実装する

foldLeftfoldRight は非常に汎用的なメソッドであり、これらを使って他の多くの高レベルなシーケンス操作を実装することができる。foldRight を利用して、ListmapflatMapfilter、および sum メソッドを再実装し、このことを確かめよ。

解答例は以下のとおりである。

def map[A, B](list: List[A])(func: A => B): List[B] =
  list.foldRight(List.empty[B]) { (item, accum) =>
    func(item) :: accum
  }
map(List(1, 2, 3))(_ * 2)
// res24: List[Int] = List(2, 4, 6)
def flatMap[A, B](list: List[A])(func: A => List[B]): List[B] =
  list.foldRight(List.empty[B]) { (item, accum) =>
    func(item) ::: accum
  }
flatMap(List(1, 2, 3))(a => List(a, a * 10, a * 100))
// res25: List[Int] = List(1, 10, 100, 2, 20, 200, 3, 30, 300)
def filter[A](list: List[A])(func: A => Boolean): List[A] =
  list.foldRight(List.empty[A]) { (item, accum) =>
    if(func(item)) item :: accum else accum
  }
filter(List(1, 2, 3))(_ % 2 == 1)
// res26: List[Int] = List(1, 3)

sum についてはふたつの定義を示す。ひとつは scala.math.Numeric を使う方法で、これは Scala 組み込みの機能を正確に再現している。

import scala.math.Numeric

def sumWithNumeric[A](list: List[A])
      (implicit numeric: Numeric[A]): A =
  list.foldRight(numeric.zero)(numeric.plus)
sumWithNumeric(List(1, 2, 3))
// res27: Int = 6

もうひとつは cats.Monoid を使う。本書の内容としてはこちらのほうが適しているだろう。

import cats.Monoid

def sumWithMonoid[A](list: List[A])
      (implicit monoid: Monoid[A]): A =
  list.foldRight(monoid.empty)(monoid.combine)

import cats.instances.int._ // Monoid
sumWithMonoid(List(1, 2, 3))
// res28: Int = 6

12.1.4 Cats における Foldable

Cats の Foldable は、foldLeftfoldRight を型クラスとして抽象化している。Foldable インスタンスはこれらふたつのメソッドを定義し、多くの派生メソッドを継承する。Cats では、ListVectorLazyListOption といったいくつかの Scala 組み込みデータ型に対する Foldable インスタンスが、はじめから用意されている。

いつもと同じように Foldable.apply を使ってインスタンスを入手し、その foldLeft 実装を直接呼び出すことができる。以下に List を用いた例を示す。

import cats.Foldable
import cats.instances.list._ // Foldable

val ints = List(1, 2, 3)
Foldable[List].foldLeft(ints, 0)(_ + _)
// res15: Int = 6

Other sequences like Vector and LazyList work in the same way. Here is an example using Option, which is treated like a sequence of zero or one elements:

VectorLazyList のような他のシーケンスでも同じように動作する。以下は Option を使用する例である。Option はゼロ個またはひとつの要素をもったシーケンスとして扱われる。

import cats.instances.option._ // Foldable

val maybeInt = Option(123)
Foldable[Option].foldLeft(maybeInt, 10)(_ * _)
// res16: Int = 1230

12.1.4.1 右畳み込み

Foldable では、foldRightfoldLeft とは異なり Eval モナドを用いて定義されている。

def foldRight[A, B](fa: F[A], lb: Eval[B])
                     (f: (A, Eval[B]) => Eval[B]): Eval[B]

Eval を使うことで、常にスタックセーフな畳み込みを実現できる。コレクションがもつ foldRight のデフォルト定義がスタックセーフでなくても関係ない。たとえば、LazyList における foldRight のデフォルト実装はスタックセーフではなく、リストが長くなるほど畳み込みに必要なスタックも増大する。一定以上の長さの LazyListStackOverflowError を引き起こしてしまう。

import cats.Eval
import cats.Foldable

def bigData = (1 to 100000).to(LazyList)
bigData.foldRight(0L)(_ + _)
// java.lang.StackOverflowError ...

Foldable を使用することでスタックセーフな操作を強制されるため、このスタックオーバーフローの例外は解消される。

import cats.instances.lazyList._ // Foldable
val eval: Eval[Long] =
  Foldable[LazyList].
    foldRight(bigData, Eval.now(0L)) { (num, eval) =>
      eval.map(_ + num)
    }
eval.value
// res18: Long = 5000050000L

標準ライブラリのスタックセーフ性

標準ライブラリを使用する際、スタックセーフ性が問題になることは通常ない。ListVector といった使用頻度のもっとも高いコレクション型は、スタックセーフな foldRight 実装を提供している。

(1 to 100000).toList.foldRight(0L)(_ + _)
(1 to 100000).toVector.foldRight(0L)(_ + _)
// res19: Long = 5000050000L

Stream はこのルールの例外で、スタックセーフではないことに注意してほしい。とはいえ、どのデータ型を使用している場合でも、いざというときには Eval に頼れると知っていれば心強い。

12.1.4.2 モノイドを用いた畳み込み

FoldablefoldLeft を用いて定義された便利なメソッドを数多く提供している。その多くは、標準ライブラリのよく知られたメソッドを模倣したもので、findexistsforalltoListisEmptynonEmpty などが含まれる。

Foldable[Option].nonEmpty(Option(42))
// res20: Boolean = true

Foldable[List].find(List(1, 2, 3))(_ % 2 == 0)
// res21: Option[Int] = Some(value = 2)

なじみあるこれらのメソッドに加えて、Monoid を用いたふたつのメソッドを Cats は提供している。

たとえば combineAll を使って List[Int] の合計を求めることができる。

import cats.instances.int._ // Monoid
Foldable[List].combineAll(List(1, 2, 3))
// res22: Int = 6

あるいは、foldMap を使って各 IntString に変換し、それらを連結することもできる。

import cats.instances.string._ // Monoid
Foldable[List].foldMap(List(1, 2, 3))(_.toString)
// res23: String = "123"

最後に、Foldable 同士を合成すれば、入れ子になったシーケンスの深い走査も可能となる。

import cats.instances.vector._ // for Monoid

val ints = List(Vector(1, 2, 3), Vector(4, 5, 6))
(Foldable[List] compose Foldable[Vector]).combineAll(ints)
// res25: Int = 21

12.1.4.3 Foldable の構文

Foldable のメソッドはすべて cats.syntax.foldable を介して構文形式で利用できる。いずれも Foldable に定義されているメソッドの最初の引数がメソッド呼び出しのレシーバとなる。

import cats.syntax.foldable._ // combineAll と foldMap
List(1, 2, 3).combineAll
// res26: Int = 6

List(1, 2, 3).foldMap(_.toString)
// res27: String = "123"

暗黙より明示

Scala が Foldable インスタンスを使用するのは、呼び出そうとするメソッドがレシーバに明示的に定義されていない場合だけだということを覚えておこう。たとえば、次のコードでは List に定義された foldLeft が使用される。

List(1, 2, 3).foldLeft(0)(_ + _)
// res28: Int = 6

一方で次のジェネリックなコードでは Foldable が使用される。

def sum[F[_]: Foldable](values: F[Int]): Int =
  values.foldLeft(0)(_ + _)

通常、この区別を意識する必要はない。これは便利な仕組みである。呼び出したいメソッドを指定すれば、コンパイラは必要に応じて Foldable を使用し、コードを期待どおりに動かしてくれる。スタックセーフな foldRight 実装が必要な場合は、蓄積変数として Eval を使用するだけで、Cats のメソッドを選択するようコンパイラに強制できる。

12.2 Traverse

foldLeftfoldRight は繰り返し処理の柔軟な手段だが、蓄積変数や結合関数を定義するための手間が多い。Traverse 型クラスは、Applicative を活用することで、より便利で一貫性のある繰り返しパターンを提供する高水準のツールである。

12.2.1 Future のトラバース

Scala 標準ライブラリの Future.traverseFuture.sequence メソッドを実例として用い Traverse を解説していこう。これらのメソッドは、Future に特化した Traverse パターンの実装を提供している。たとえば、サーバのホスト名のリストと、あるホストの稼働時間を取得するメソッドがあるとする。

import scala.concurrent._
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global

val hostnames = List(
  "alpha.example.com",
  "beta.example.com",
  "gamma.demo.com"
)

def getUptime(hostname: String): Future[Int] =
  Future(hostname.length * 60) // デモ用の適当な計算

ここで、すべてのホストに問い合わせを行い、その稼働時間を収集したいとする。しかし、hostnames に対して map を行うだけでは、結果は複数の Future を含んだ List[Future[Int]] になってしまう。処理の完了を待機するには、すべての結果をひとつの Future にまとめる必要がある。まずは、畳み込みを使ってこれを自力で実装するところから始めてみよう。

val allUptimes: Future[List[Int]] =
  hostnames.foldLeft(Future(List.empty[Int])) {
    (accum, host) =>
      val uptime = getUptime(host)
      for {
        accum  <- accum
        uptime <- uptime
      } yield accum :+ uptime
  }
Await.result(allUptimes, 1.second)
// res20: List[Int] = List(1020, 960, 840)

一見すると、この処理は hostnames に対して繰り返しを行い、各要素に対して func を呼び出し、その結果をリストに追加しているだけである。これは単純なようだが、繰り返しの度に Future を生成して結合する必要があるため、コードはかなり煩雑になる。Future.traverse はこのようなパターンに特化しており、これを使うことでコードは大幅に改善される。

val allUptimes: Future[List[Int]] =
  Future.traverse(hostnames)(getUptime)
Await.result(allUptimes, 1.second)
// res22: List[Int] = List(1020, 960, 840)

このほうがはるかに明快である。これがどのように実現されているか見てみよう。CanBuildFromExecutionContext など説明に必要のない部分を無視すれば、標準ライブラリにおける Future.traverse の実装は以下のようになる。

def traverse[A, B](values: List[A])
    (func: A => Future[B]): Future[List[B]] =
  values.foldLeft(Future(List.empty[B])) { (accum, host) =>
    val item = func(host)
    for {
      accum <- accum
      item  <- item
    } yield accum :+ item
  }

これは先ほどの例で書いたコードとほぼ同じである。Future.traverse は、畳み込みとそれに必要な蓄積変数や結合関数を定義する煩わしさを抽象化によって取り除き、次のような処理を行うためのシンプルで高レベルなインターフェースを提供してくれる。

標準ライブラリには Future.sequence というメソッドも用意されている。こちらは、最初に List[Future[B]] が与えられることを前提としている。恒等関数を変換関数として traverse するのと同じことだが、利用者が恒等関数を提供する必要はない。

object Future {
  def sequence[B](futures: List[Future[B]]): Future[List[B]] =
    traverse(futures)(identity)

  // etc...
}

こちらのほうがずっとシンプルである。

Future.traverseFuture.sequence は、極めて具体的な問題を解決するメソッドである。これらを使うと、Future のシーケンスを反復し結果をまとめることができる。先述の簡略化された例では List しか受け付けてくれないが、実際の Future.traverseFuture.sequence は、任意の標準的な Scala コレクションで動作する。

Cats の Traverse 型クラスは、これらのパターンを一般化し、FutureOptionValidated など、あらゆる種類の Applicative で機能するようにしている。次節では Traverse を二段階に分けて説明する。まず Applicative について一般化を行い、次にシーケンスの型について一般化する。最終的に、シーケンスと他のデータ型を用いる多くの操作を極めてシンプルにしてくれる、非常に価値のあるツールを手に入れることになる。

12.2.2 アプリカティブのトラバース

注意深く考えれば、traverseApplicative を用いて書き換えられることに気づくだろう。上記の例における蓄積変数の初期値は以下のとおりだった。

Future(List.empty[Int])

これは以下のように Applicative.pure するのと同じである。

import cats.Applicative
import cats.instances.future._   // Applicative
import cats.syntax.applicative._ // pure

List.empty[Int].pure[Future]

そして、以下のような内容であった結合関数は、

def oldCombine(
  accum : Future[List[Int]],
  host  : String
): Future[List[Int]] = {
  val uptime = getUptime(host)
  for {
    accum  <- accum
    uptime <- uptime
  } yield accum :+ uptime
}

Semigroupal.combine と同じである。

import cats.syntax.apply._ // mapN

// アプリカティブを用いて蓄積変数とホスト名を結合する
def newCombine(accum: Future[List[Int]],
      host: String): Future[List[Int]] =
  (accum, getUptime(host)).mapN(_ :+ _)

これらのコード片を用いて traverse を定義しなおせば、任意の Applicative に対応できるよう一般化できる。


def listTraverse[F[_]: Applicative, A, B]
      (list: List[A])(func: A => F[B]): F[List[B]] =
  list.foldLeft(List.empty[B].pure[F]) { (accum, item) =>
    (accum, func(item)).mapN(_ :+ _)
  }

def listSequence[F[_]: Applicative, B]
      (list: List[F[B]]): F[List[B]] =
  listTraverse(list)(identity)

listTraverse を使えば、前述の稼働時間取得の例は以下のように実装しなおすことができる。

val totalUptime = listTraverse(hostnames)(getUptime)
Await.result(totalUptime, 1.second)
// res25: List[Int] = List(1020, 960, 840)

この関数を他の Applicative データ型に対して用いることもできる。以降の演習ではそれを実際に見ていく。

12.2.2.1 演習: Vector のトラバース

次のコードの実行結果はどうなるか考えよ。

import cats.instances.vector._ // Applicative

listSequence(List(Vector(1, 2), Vector(3, 4)))

引数の型が List[Vector[Int]] であることから、Vector に対する Applicative 型クラスインスタンスが使用され、返り値の型は Vector[List[Int]] となる。

Vector はモナドであるため、その Semigroupal の combine 関数は flatMap に基づく。結果として、List(1, 2)List(3, 4) のすべての組み合わせを表した ListVector が得られる。

listSequence(List(Vector(1, 2), Vector(3, 4)))
// res27: Vector[List[Int]] = Vector(
//   List(1, 3),
//   List(1, 4),
//   List(2, 3),
//   List(2, 4)
// )

三つの要素をもつリストについても、その結果がどうなるか考えよ。

listSequence(List(Vector(1, 2), Vector(3, 4), Vector(5, 6)))

入力リストに三つの要素がある場合、最初の要素からひとつ、次の要素からひとつ、そして最後の要素からひとつずつ選んだ三つの Int の組み合わせが得られる。

listSequence(List(Vector(1, 2), Vector(3, 4), Vector(5, 6)))
// res29: Vector[List[Int]] = Vector(
//   List(1, 3, 5),
//   List(1, 3, 6),
//   List(1, 4, 5),
//   List(1, 4, 6),
//   List(2, 3, 5),
//   List(2, 3, 6),
//   List(2, 4, 5),
//   List(2, 4, 6)
// )

12.2.2.2 演習: Option のトラバース

以下は Option を使った例である。

import cats.instances.option._ // Applicative

def process(inputs: List[Int]) =
  listTraverse(inputs)(n => if(n % 2 == 0) Some(n) else None)

このメソッドの戻り値型は何か。また以下の入力についてどのような結果が得られるだろうか。

process(List(2, 4, 6))
process(List(1, 2, 3))

listTraverse の引数の型が List[Int]Int => Option[Int] であることから、返り値の型は Option[List[Int]] となる。ここでも Option はモナドであるため、Semigroupal の combine 関数は flatMap に基づく。そのセマンティクスは、フェイルファストなエラーハンドリングである。すべての入力が偶数であれば出力としてリストが得られ、そうでない場合は None になる。

process(List(2, 4, 6))
// res32: Option[List[Int]] = Some(value = List(2, 4, 6))
process(List(1, 2, 3))
// res33: Option[List[Int]] = None

12.2.2.3 演習: Validated のトラバース

最後に Validated を用いた例を見ておこう。

import cats.data.Validated
import cats.instances.list._ // Monoid

type ErrorsOr[A] = Validated[List[String], A]

def process(inputs: List[Int]): ErrorsOr[List[Int]] =
  listTraverse(inputs) { n =>
    if(n % 2 == 0) {
      Validated.valid(n)
    } else {
      Validated.invalid(List(s"$n is not even"))
    }
  }

このメソッドは下記の入力に対してどのような結果を返すだろうか。

process(List(2, 4, 6))
process(List(1, 2, 3))

返り値の型である ErrorsOr[List[Int]]Validated[List[String], List[Int]] に展開される。Validated における Semigroupal の combine は、エラーハンドリングにおいてエラーを蓄積するセマンティクスをもつ。したがって、結果は偶数の Int のリスト、またはどの整数がテストに失敗したかを示すエラーのリストになる。

process(List(2, 4, 6))
// res37: Validated[List[String], List[Int]] = Valid(a = List(2, 4, 6))
process(List(1, 2, 3))
// res38: Validated[List[String], List[Int]] = Invalid(
//   e = List("1 is not even", "3 is not even")
// )

12.2.3 Cats における Traverse

前節で見た listTraverselistSequence メソッドは、任意の Applicative に対応しているが、シーケンスの型としては List にしか対応していない。型クラスを使えば、異なるシーケンス型を一般化することができる。ここで登場するのが Cats の Traverse である。簡略化した定義を以下に示す。

package cats

trait Traverse[F[_]] {
  def traverse[G[_]: Applicative, A, B]
      (inputs: F[A])(func: A => G[B]): G[F[B]]

  def sequence[G[_]: Applicative, B]
      (inputs: F[G[B]]): G[F[B]] =
    traverse(inputs)(identity)
}

Cats では、ListVectorStreamOptionEither など、さまざまな型に対する Traverse インスタンスが提供されている。通常どおり Traverse.apply を使ってインスタンスを取得し、前節で説明したように traversesequence メソッドを使用することができる。

import cats.Traverse
import cats.instances.future._ // for Applicative
import cats.instances.list._   // for Traverse

val totalUptime: Future[List[Int]] =
  Traverse[List].traverse(hostnames)(getUptime)
Await.result(totalUptime, 1.second)
// res5: List[Int] = List(1020, 960, 840)
val numbers = List(Future(1), Future(2), Future(3))

val numbers2: Future[List[Int]] =
  Traverse[List].sequence(numbers)
Await.result(numbers2, 1.second)
// res6: List[Int] = List(1, 2, 3)

メソッドの代わりに構文を利用することもできる。構文は cats.syntax.traverse からインポートできる。

import cats.syntax.traverse._ // sequence と traverse
val numbers3 = hostnames.traverse(getUptime)
// numbers3: Future[List[Int]] = Future(Success(List(1020, 960, 840)))
val numbers4 = numbers.sequence
// numbers4: Future[List[Int]] = Future(Success(List(1, 2, 3)))

Await.result(numbers3, 1.second)
// res7: List[Int] = List(1020, 960, 840)
Await.result(numbers4, 1.second)
// res8: List[Int] = List(1, 2, 3)

見てのとおり、この節のスタート地点であった foldLeft を用いたコードと比べると、はるかにコンパクトで読みやすい。

12.3 まとめ

この章ではシーケンスの反復処理を行うふたつの型クラス FoldableTraverse について学んだ。

Foldable は、標準ライブラリのコレクションでおなじみの foldLeftfoldRight を抽象化する。いくつかの追加データ型にスタックセーフな実装を提供し、便利なメソッドを数多く追加定義している。とはいえ、Foldable は既存の知識に新たな要素を大きく加えるものではない。

本当に強力なのは Traverse である。Traverse は、Future でおなじみの traversesequence メソッドを抽象化し一般化する。これらのメソッドを使えば、Traverse インスタンスをもつ任意の F と、Applicative インスタンスをもつ任意の G に対して、F[G[A]]G[F[A]] に変換できる。コード行数の削減に関しては、Traverse は本書でもっとも強力なパターンのひとつである。多くの行数を要する fold を、一行の foo.traverse にまで簡潔にできる。


これで、本書における理論の解説はすべて終了した。しかし、内容はまだまだ続く。第二部では、学んだすべてのことを実践する一連の詳細なケーススタディを見ていく。

13 インデックス付き型

この章ではインデックス付き型(indexed type)について見ていく。インデックス付き型は型コンストラクタであり、F[_] のような型と、その型パラメータを埋めることのできる型の集合からなる。この型の集合を仮に IntString、および Option[Double] としよう。この場合、型コンストラクタ F について、 F[Int]F[String] および F[Option[Double]] というインデックス付き型を構成できる。このとき、型 IntStringOption[Double]F[Int]F[String]F[Option[Double]] という型の集合における索引として機能するため、F はインデックス付き型と呼ばれる。型コンストラクタ F はデータと余データどちらもかまわない。

この説明は非常に抽象的であり、インデックス付き型がどのように役立つのかを理解する助けにはならない。そこで本章では、より多くの詳細や例を見ることになるが、まずはもっと役に立ちそうな高レベルの概観から始めよう。インデックス付き型は、ある型パラメータがインデックス集合の特定の要素に等しいという証明に基づいて動作するものと考えることができる。インデックス付きのデータでは、それを分解する際にこのエビデンスを提供し、インデックス付きの余データでは、メソッドを呼び出す際にこのエビデンスを必要とする。5.2節で示した代数の定義を思い出してほしい。そこでは、代数とはコンストラクタ、コンビネータ、インタープリタという三種類の異なるメソッドから構成されるものとした。インデックス付き型を用いると、次のふたつのことが可能になる。

インデックス付きデータは一般化代数的データ型(generalized algebraic data type; GADT)という名前で知られていることが多い。一方、インデックス付き余データは型状態(typestate)と呼ばれることがある。どちらも、いわゆるファントム型を使用することができる。実際、インデックス付きデータはファーストクラス・ファントム型と呼ばれていたこともある。予想されているかもしれないが、インデックス付きデータとインデックス付き余データは双対の関係にある。

13.1 ファントム型

ファントム型はインデックス付き型の基本的な構成要素である。まずはその例を示すことから始めよう。ファントム型とは単に、値と対応しない型パラメータのことを指す。以下の例では、型パラメータ A はその型の値が存在しないためファントム型である。一方、B は値の型として使われているのでファントム型ではない。

final case class PhantomExample[A, B](value: B)

ファントム型は、制約をコンパイル時に移すために用いられる。

単位系の例を考えてみよう。ほとんどの国では、メートルやリットルといった SI 単位を標準として用いるが、いくつかの国や一部の分野では、依然としてヤード・ポンド法などの異なる単位系が使用されている。単位系の違いは時に問題を引き起こすことがある。劇的な例としては火星探査機の損失が挙げられる。これは、ふたつのソフトウェアコンポーネントが異なる単位を使用していたことが原因だった(ひとつはメートル法、もうひとつは米国慣用単位を用いていた)。

ファントム型を使うことで、値に対して単位を注釈として追加でき、互換性のない単位の誤使用を防ぐことができる。ここでは、例として長さについてだけ考えてみよう。どのようなアイデアなのかを示すにはそれで十分である。まず、単位をファントム型として記録する長さの型を定義し、長さ同士を加算するためのメソッドを定義する。

final case class Length[Unit](value: Double) {
  def +(that: Length[Unit]): Length[Unit] =
    Length[Unit](this.value + that.value)
}

次に、このデータ型を使用するのに必要な単位型をいくつか定義し、それらの単位を用いた Length インスタンスを作成する。

trait Metres
trait Feet

val threeMetres = Length[Metres](3)
val threeFeetAndRising = Length[Feet](3)

Length 同士は、同じ単位をもっていれば加算できる。

threeMetres + threeMetres
// res3: Length[Metres] = Length(value = 6.0)

だが、異なる単位をもつ Length 同士を加算しようとすると、コードはコンパイルされない。

threeMetres + threeFeetAndRising
// error: 
// Found:    (MdocApp0.this.threeFeetAndRising : MdocApp0.this.Length[MdocApp0.this.Feet])
// Required: MdocApp0.this.Length[MdocApp0.this.Metres]

ファントム型自体には大きな問題がひとつある。ファントム型に格納された情報は、後の処理で利用することができない。たとえば、力と長さを掛け合わせるとトルク( SI 単位ではニュートン・メートル)になるが、ファントム型だけでは、UnitMetre の場合にのみ呼び出せる * メソッドを Length に定義することができない。同様に、Unit 型に応じた適切な結果を表示する toString メソッドを定義することもできない。これらの問題を解決するためにはインデックス付き余データが必要になる。次にそれを見ていこう。

13.2 インデックス付き余データ

インデックス付き余データの基本的な考え方は、型で表現された特定の条件が満たされている場合にのみメソッドを呼び出せるようにすることである。もっと具体的に言えば、メソッドは型の等式によってガードされており、呼び出す際にはその等式が満たされていることを証明する必要がある。Scala では、コンテキスト抽象化機能である given インスタンスと using 句を用いてこれを実現する。

インデックス付き余データの探求を始めよう。まずは非常にシンプルな例からである。オフの時にのみオンにでき、オンの時にのみオフにできるスイッチを定義していく。余データなので、まずやることはインターフェースの定義である。

trait Switch {
  def on: Switch
  def off: Switch
}

このインターフェースにはまだ何の制約も定義されていないため、スイッチがオンの状態でも on を呼び出すことができるし、逆も同様である。このような操作を制限を実装するための第一歩は Switch の状態を保持する型パラメータを追加することである。この型パラメータは Switch に格納されるいかなるデータにも対応していないため、ファントム型である。

trait Switch[A] {
  def on: Switch[A]
  def off: Switch[A]
}

次に、この型パラメータが特定の具体型である場合にのみメソッドを呼び出せるような制約を追加していく。この方法によって、インデックス付き余データはファントム型が単独でできることに加えて、型パラメータに指定された具体型をコンパイル時に検査し、その型に基づいて判断を行うことができるようになる。

この制約の実装手順にはふたつのステップがある。まずはオンとオフを表す型を定義する。

trait On
trait Off

次に、対象となる Switch のメソッドに制約を追加する。記述のしかたは以下のとおりである。

trait Switch[A] {
  def on(using ev: A =:= Off): Switch[On]
  def off(using ev: A =:= On): Switch[Off]
}

これが確かに正しく動作することを示す実装を作成しよう。

final case class SimpleSwitch[A]() extends Switch[A] {
  def on(using ev: A =:= Off): Switch[On] =
    SimpleSwitch()
  def off(using ev: A =:= On): Switch[Off] =
    SimpleSwitch()
}
object SimpleSwitch {
  val on: Switch[On] = SimpleSwitch()
  val off: Switch[Off] = SimpleSwitch()
}

以下はこれを正しく使用している例である。

SimpleSwitch.on.off
// res19: Switch[Off] = SimpleSwitch()
SimpleSwitch.off.on
// res20: Switch[On] = SimpleSwitch()

誤った使い方をするとコンパイルエラーになる。

SimpleSwitch.on.on
// error: 
// Cannot prove that MdocApp8.this.On =:= MdocApp8.this.Off.

この制約はふたつの部分から成り立っている。ひとつは4節で学んだ using 句、もうひとつは新たに登場した A =:= B 構文である。=:= は型の等価性を表す。もし A =:= B という型の given インスタンスが存在するならば、型 A は型 B と等しいと言える。お望みなら =:=[A, B] というおなじみのプレフィックス記法を使ってもよい。これらの given インスタンスは自分で作成するものではなく、コンパイラが自動的に生成する。on メソッドでは、コンパイラに A =:= Off インスタンスの構築を要求しているが、これは AOff である場合にのみ可能となる。つまり、SwitchOff の状態であるときのみこのメソッドを呼び出せる。状態を型へと持ち上げ、メソッド呼び出しを特定の状態にあるときのみに制限する。これがインデックス付き余データの核心をなすアイデアである。

これは型クラスとは異なるコンテキスト抽象化のもうひとつの使い方である。型クラスは操作を型に関連付ける。一方ここでは、ある型と別のもうひとつの型との何らかの関係性を証明している。もっと具体的に言えば、型パラメータがある特定の型と等しいことを証明している。この given インスタンスは、その関係性が正しいことをコンパイラが証明できる場合にのみ存在する。そのため、これらの given インスタンスはエビデンス(evidence)あるいはウィットネス(witness)と呼ばれることもある。この新たな視点は型クラスにも広げることができる。型クラスも、ある型が特定のインターフェースを実装していることの証拠として捉えることができる。

演習: トルク

13.1節ではファントム型を使用して単位を表現する方法を見た。また、ファントム型に指定された具体型を調べる方法がなく、それに基づいて判断を行うことができないという制限にも直面した。インデックス付き余データを使用すれば、この問題を解決できる。

ファントム型を学んだときに使用した Length の定義を以下に再掲する。

final case class Length[Unit](value: Double) {
  def +(that: Length[Unit]): Length[Unit] =
    Length[Unit](this.value + that.value)
}

課題は次のとおりである。

  1. 力の単位を表すファントム型でパラメータ化された型 Force を実装せよ。
  2. トルクの単位を表すファントム型でパラメータ化された型 Torque を実装せよ。
  3. 力を表現する SI 単位として Newtons および NewtonMetres を定義せよ。
  4. Force 型に、Length を受け取り Torque を返すメソッド * を実装せよ。これは、Force の単位が Newtons で、Length の単位が Metres の場合にのみ呼び出すことができ、結果である Torque の単位は NewtonMetres となる(トルクは力と長さの積である)。

ForceTorque、および単位型の定義は、例で見たパターンを繰り返すだけである。

trait Newtons
trait NewtonMetres

final case class Force[Unit](value: Double)
final case class Torque[Unit](value: Double)

Force* メソッドを定義するにあたっては、ForceUnit 型が Newtons であり、LengthUnit 型が Metres であるという制約が必要である。いずれも型の等価性を要求する制約なので、=:= を用いて表現できる。

final case class Force[Unit](value: Double) {
  def *[L](length: Length[L])(using Unit =:= Newtons, L =:= Metres): Torque[NewtonMetres] =
    Torque(this.value * length.value)
}

13.2.1 API プロトコル

API プロトコルはメソッドの呼び出し順序を定義する。メソッドはその順序で呼び出されなければならない。Switch におけるプロトコルは、on の後にのみ off を呼び出すことができ、off の後にのみ on を呼び出すことができる、というものである。このプロトコルは単純な有限状態機械である。これを図12に示す。多くの一般的な型もこれと似たプロトコルをもっている。たとえば、ファイルは開かれて初めて読み取り可能になり、閉じられた後は読み取ることができない。

Figure 12: The switch API protocol

インデックス付き余データを使えば、API プロトコルをコンパイル時に強制することができる。これらのプロトコルは有限状態機械であることが多い。Switch で行ったように、状態を表す単一の型パラメータで表現することができる。また、より便利な表現ができるのであれば複数の型パラメータを使用してもかまわない。

複数の型パラメータを使用する例を見てみよう。Web ページを定義する言語である HTML の小さなサブセットを表現する API を構築する。以下に HTML の例を示す。

<!DOCTYPE html>
<html>
  <head><title>Our Amazing Web Page</title></head>
  <body>
    <h1>This Is Our Amazing Web Page</h1>
    <p>Please be in awe of its <strong>amazingness</strong></p>
  </body>
</html>

HTML では、ページのコンテンツに対して <h1> のようなタグでマークアップすることで意味を与える。たとえば、<h1> は大見出しを、<p> は段落を意味する。開始タグは対応する終了タグによって閉じられる。<h1> に対しては </h1><p> に対しては </p> が終了タグとなる。

有効な HTML にはいくつかのルールがある17。ここでは以下のルールに焦点を当てる。

  1. html タグ内には、head タグと body タグだけがこの順番で配置されなければならない
  2. head タグ内には、title タグを必ずひとつだけ含まなければならず、それ以外の許可されたタグ(ここでは link のみをモデル化する)を好きな数だけ含むことができる
  3. body タグ内には、許可されたタグ(ここではh1pのみをモデル化する)を好きな数だけ含むことができる

ここではチャーチ表現を用いるので、タグはメソッド呼び出しによって作成される。図13は API プロトコルの有限状態機械で表現したものである。これは以下に記述したとおり正規表現として表記すると読みやすくなる。

head link* title link* body (h1 | p)*

Figure 13: The HTML API protocol

コードはかなり反復的であるため、まず全体を提示し、その後に重要な部分を説明する。以下がその実装である。

sealed trait StructureState
trait Empty extends StructureState
trait InHead extends StructureState
trait InBody extends StructureState

sealed trait TitleState
trait WithoutTitle extends TitleState
trait WithTitle extends TitleState

// 利用者が copy によって不変条件を破らないよう、case class は使わない
final class Html[S <: StructureState, T <: TitleState](
    head: Vector[String],
    body: Vector[String]
) {
  // Head タグ ---------------------------------------------

  def head(using S =:= Empty): Html[InHead, WithoutTitle] =
    Html(head, body)

  def title(
      text: String
  )(using S =:= InHead, T =:= WithoutTitle): Html[InHead, WithTitle] =
    Html(head :+ s"<title>$text</title>", this.body)

  def link(rel: String, href: String)(using S =:= InHead): Html[InHead, T] =
    Html(head :+ s"<link rel=\"$rel\" href=\"$href\"/>", body)

  // Body タグ ---------------------------------------------

  def body(using S =:= InHead, T =:= WithTitle): Html[InBody, WithTitle] =
    Html(head, body)

  def h1(text: String)(using S =:= InBody): Html[InBody, T] =
    Html(head, body :+ s"<h1>$text</h1>")

  def p(text: String)(using S =:= InBody): Html[InBody, T] =
    Html(head, body :+ s"<p>$text</p>")

  // インタープリタ ------------------------------------------

  override def toString(): String = {
    val h = head.mkString("  <head>\n    ", "\n    ", "\n  </head>")
    val b = body.mkString("  <body>\n    ", "\n    ", "\n  </body>")

    s"\n<html>\n$h\n$b\n</html>"
  }
}
object Html {
  val empty: Html[Empty, WithoutTitle] = Html(Vector.empty, Vector.empty)
}

状態をふたつの構成要素に分けている点に注目してほしい。StructureState は構造全体の中でどこにいるか(head 内、body 内、またはどちらでもないか)を表し、TitleStatehead 内において title 要素を定義済みかどうかを表している。これらの状態をひとつの型変数で表現することも可能だが、分割された表現の方が扱いやすく、他の開発者にも理解しやすいだろう。

使い方の例を以下に示す。

Html.empty.head
  .link("stylesheet", "styles.css")
  .title("Our Amazing Webpage")
  .body
  .h1("Where Amazing Exists")
  .p("Right here")
  .toString
// res23: String = """
// <html>
//   <head>
//     <link rel="stylesheet" href="styles.css"/>
//     <title>Our Amazing Webpage</title>
//   </head>
//   <body>
//     <h1>Where Amazing Exists</h1>
//     <p>Right here</p>
//   </body>
// </html>"""

Here’s an example of the type system preventing an invalid construction, in this case the lack of a title.

次に挙げるのは、型システムによって無効な構築を防ぐ例である。この例ではタイトルが欠落している。

Html.empty.head
  .link("stylesheet", "styles.css")
  .body
  .h1("This Shouldn't Work")
// error: 
// Cannot prove that MdocApp9.this.WithoutTitle =:= MdocApp9.this.WithTitle.

これらが出力するエラーメッセージはわかりやすいとはいえない。その点については16章で取り扱う。

これと同じ技法を用いることで、文脈自由文法あるいは文脈依存文法で表現されるような、より複雑なプロトコルも実装することができる。

演習: HTML APIデザイン

上で作成した HTML の API には修正したい点がある。平坦なメソッド呼び出しの構造が、作成している HTML のネスト構造と合っていないのである。以下のように書ける方が好ましい。

Html.empty
  .head(_.title("Our Amazing Webpage"))
  .body(_.h1("Where Amazing Happens").p("Right here"))
  .toString

headbody より先に指定される必要がある点は変わらないが、メソッド呼び出しのネストは構造のネストと一致している。引き続きチャーチ表現を用いていることに注目してほしい。

この実装方法を考えられるだろうか。インデックス付き余データを使用する必要があり、インスピレーションもすこし必要かもしれない。これは非常に自由度の高い問題である。解法に迷っても気にすることはない。

以下に実装の一例を示す。構造は元の実装と非常によく似ているが、状態を複数の型パラメータに分けたのと同様に、実装を複数の型に分割している。ヘッドとボディを構成するタグを蓄積するために HeadBody をどのように使っているのか、よく確認してほしい。ある部分ではインデックス付き余データを引き続き使用する必要があるが、避けることが可能な部分もある。たとえば head メソッドは単に Head[WithoutTitle] => Head[WithTitle] 型の関数を要求するだけである。

sealed trait StructureState
trait NeedsHead extends StructureState
trait NeedsBody extends StructureState
trait Complete extends StructureState

sealed trait TitleState
trait WithoutTitle extends TitleState
trait WithTitle extends TitleState

final class Head[S <: TitleState](contents: Vector[String]) {
  def title(text: String)(using S =:= WithoutTitle): Head[WithTitle] =
    Head(contents :+ s"<title>$text</title>")

  def link(rel: String, href: String): Head[S] =
    Head(contents :+ s"<link rel=\"$rel\" href=\"$href\"/>")

  override def toString(): String =
    contents.mkString("  <head>\n    ", "\n    ", "\n  </head>")
}
object Head {
  val empty: Head[WithoutTitle] = Head(Vector.empty)
}

final class Body(contents: Vector[String]) {
  def h1(text: String): Body =
    Body(contents :+ s"<h1>$text</h1>")

  def p(text: String): Body =
    Body(contents :+ s"<p>$text</p>")

  override def toString(): String =
    contents.mkString("  <body>\n    ", "\n    ", "\n  </body>")
}
object Body {
  val empty: Body = Body(Vector.empty)
}

final class Html[S <: StructureState](
    head: Head[?],
    body: Body
) {
  def head(f: Head[WithoutTitle] => Head[WithTitle])(using
      S =:= NeedsHead
  ): Html[NeedsBody] =
    Html(f(Head.empty), body)

  def body(f: Body => Body)(using S =:= NeedsBody): Html[Complete] =
    Html(head, f(Body.empty))

  override def toString(): String = {
    s"\n<html>\n${head.toString()}\n${body.toString()}\n</html>"

  }
}
object Html {
  val empty: Html[NeedsHead] = Html(Head.empty, Body.empty)
}

As always, we should show that is works. Here’s the output from the motivating example.

いつもどおり、この実装が正しく動作することを示しておこう。この演習の冒頭に挙げたサンプルコードからの出力は次のとおりである。

Html.empty
  .head(_.title("Our Amazing Webpage"))
  .body(_.h1("Where Amazing Happens").p("Right here"))
  .toString()
// res26: String = """
// <html>
//   <head>
//     <title>Our Amazing Webpage</title>
//   </head>
//   <body>
//     <h1>Where Amazing Happens</h1>
//     <p>Right here</p>
//   </body>
// </html>"""

13.2.2 等価制約を超えて

インデックス付きデータはすべて等価制約、つまりある型パラメータが特定の型に等しいことを証明するものである。しかし、コンテキスト抽象を用いれば等価性以外の制約を実現することもできる。部分型関係のエビデンスには <:< 、指定された given インスタンスが存在しないことのエビデンスには NotGiven を使うことができる。これを用いれば、たとえば型が等しくないことをテストすることができる。さらに言えば、任意の given インスタンスをエビデンスとして扱うことが可能である。

その有用性を確認するために、長さ・力・トルクの例に戻ろう。トルクを力と長さの積として定義した演習では、計算を SI 単位に固定した。以下にそのコードを再掲する。

final case class Force[Unit](value: Double) {
  def *[L](length: Length[L])(using Unit =:= Newtons, L =:= Metres): Torque[NewtonMetres] =
    Torque(this.value * length.value)
}

他の単位は非合理的なのでこれは妥当な判断であるが、世の中にはそんな単位を好むおかしな人も多い。演算の結果型を表す given インスタンスを作成することで、他の単位型にも対応することができる。ここでは、長さの単位と力の単位を掛け合わせた結果を表現したい。コードでは次のように書ける。

// 変な単位
trait Feet
trait Pounds
trait PoundsFeet

// A * B = C であればインスタンスが存在する
trait Multiply[A, B, C]
object Multiply {
  given Multiply[Metres, Newtons, NewtonMetres] = new Multiply {}
  given Multiply[Feet, Pounds, PoundsFeet] = new Multiply {}
}

そして、Multiply を利用して LengthForce* メソッドを定義する。

final case class Length[L](value: Double) {
  def *[F, T](that: Force[F])(using Multiply[L, F, T]): Torque[T] =
    Torque(this.value * that.value)
}

final case class Force[F](value: Double) {
  def *[L, T](that: Length[L])(using Multiply[F, L, T]): Torque[T] =
    Torque(this.value * that.value)
}

以下に、これが正しく動作することを示す例を挙げる。

Length[Metres](3) * Force[Newtons](4)
// res28: Torque[NewtonMetres] = Torque(value = 12.0)

Length[Feet](3) * Force[Pounds](4)
// res29: Torque[PoundsFeet] = Torque(value = 12.0)

Multiply は何のメソッドも提供しないので、これを型クラスと考えるのは無理がある。エビデンスと考えるのが妥当だろう。

演習: 交換律

上の例では、メートルとニュートンを掛け合わせるとニュートンメートルが得られることを表現するために Multiply 型クラスを定義した。掛け算は交換律を満たす。もし A × B = C なら、B × A = C である。しかし、現在の定義ではこれを表現できていないため、以下の例のようにニュートンに対してメートルを掛けると、コードは失敗する。

Force[Newtons](3) * Length[Metres](4)
// error: 
// No given instance of type MdocApp11.this.Multiply[MdocApp11.this.Newtons, MdocApp11.this.Metres, Any] was found for parameter x$2 of method * in class Force

Multiply[A, B, C] が存在するなら Multiply[B, A, C] も存在するように Multiply のエビデンスを追加し、それによってこの問題が解決することを示せ。

以下では commutative という given インスタンスを定義することによってこれを解決している。

// A * B = C であればインスタンスが存在する
trait Multiply[A, B, C]
object Multiply {
  given Multiply[Metres, Newtons, NewtonMetres] = new Multiply {}
  
  // A * B == B * A
  given commutative[A, B, C](using Multiply[A, B, C]): Multiply[B, A, C] =
    new Multiply {}
}

これで、先ほど失敗した例は想定どおり正しく動作する。

Force[Newtons](3) * Length[Metres](4)
// res32: Torque[NewtonMetres] = Torque(value = 12.0)

インデックス付き余データの話は以上となる。次は、その双対となる概念、インデックス付きデータについて見ていこう。

13.3 インデックス付きデータ

インデックス付きデータの中心となるアイデアは、データ内に型の等式をエンコードすることである。それらの等式はデータを検査する際(通常は構造的再帰を通じて)に発見され、それによって生成できる値が制限される。あらためて、余データとの双対性に注目しよう。インデックス付き余データは呼び出せるメソッドを制限し、インデックス付きデータは生成できる値を制限する。インデックス付きデータは一般化代数的データ型と呼ばれることが多いが、ここでは、インデックス付き余データとの関係を強調するために、より簡潔な「インデックス付きデータ」という用語を用いる。ついでに言えば、タイピングが簡単であることもこの用語を用いる理由である18

具体的には、Scala におけるインデックス付きデータは、次のような場合に現れる。

  1. 少なくともひとつの型パラメータをもつ直和型を定義し
  2. その型のバリアントが型パラメータを具体的な型に固定して定義されている場合

例をひとつ見てみよう。プログラミング言語を実装していると想像してほしい。その言語における値の表現がいくつか必要である。たとえば、対象の言語が文字列、整数、浮動小数点数をサポートするものとしよう。これらは Scala の対応する型を用いて表現することができる。これを標準的な代数的データ型として実装すると以下のようなコードになる。

enum Value {
  case VString(value: String)
  case VInt(value: Int)
  case VDouble(value: Double)
}

インデックス付きデータを用いれば、これを次のように実装することができる。

enum Value[A] {
  case VString(value: String) extends Value[String]
  case VInt(value: Int) extends Value[Int]
  case VDouble(value: Double) extends Value[Double]
}

これは上述の条件を満たしているので、インデックス付きデータである。型パラメータ A をもち、それがバリアント VStringVIntVDouble において具体的な型で固定されている。Scala でインデックス付きデータを用いるのはとても簡単で、それを特別なことだとは認識せずにこのようなコードを書くことも多い。これがどのように使えるのかという疑問が続いて生まれるが、それに答えるにはもうすこし手の込んだ例が必要になる。次に、インデックス付きデータをうまく利用している例を詳しく見ていこう。

13.3.1 確率モナド

このケーススタディでは確率モナドを構築する。これは確率分布を定義するための合成可能な抽象である。確率モナドには多くの用途があるが、多くの開発者にとってもっとも身近なのはプロパティベースのテストにおけるデータ生成である。ここではその用途に焦点をあてていく。確率モナドは統計的推論やジェネラティブアートの作成などにも利用できる。それらの使い方については本章のまとめである13.4節を参照してほしい。

まずはランダムデータを生成する例から始めよう。Doodle は Scala のグラフィックおよび可視化ライブラリである。このライブラリの中核のひとつが色の表現である。Doodle には RGB と OkLCH というふたつの色表現があり、それらのあいだに相互変換が定義されている。この変換にはやや込み入った数学が関係しており、その検証にはプロパティベースのテストが非常に有効である。たくさんのランダムな RGB カラーを生成できれば、それを OkLCH に変換して再び RGB に戻した結果が元の色になるかどうかを検証することで、変換の正しさを確かめることができる19

RGB カラーを生成するには三つの符号なしバイトが必要である。そこで、最初の課題としてランダムなバイトの精製方法を定義したい。Doodle には確率モナドの実装が用意されているので、それを利用していこう。

import cats.syntax.all.*
import doodle.core.Color
import doodle.core.UnsignedByte
import doodle.random.{*, given}

val randomByte: Random[UnsignedByte] = 
  Random.int(0, 255).map(UnsignedByte.clip)

ここにもインタープリタ戦略が登場している点に注目してほしい。Random[A] インスタンスは、実行するとランダムな A 型の値を生成するプログラムを表す値である。

ランダムな符号なしバイトが三つあれば、ランダムな RGB カラーを生成できる。

val randomRGB: Random[Color] =
  (randomByte, randomByte, randomByte)
    .mapN((r, g, b) => Color.rgb(r, g, b))

いくつかランダムな値を生成することで、コードが正しく動作することを確認しておこう。

randomRGB.replicateA(2).run
// res8: List[Color] = List(
//   Rgb(
//     r = UnsignedByte(value = 49),
//     g = UnsignedByte(value = 93),
//     b = UnsignedByte(value = 31),
//     a = Normalized(get = 1.0)
//   ),
//   Rgb(
//     r = UnsignedByte(value = 9),
//     g = UnsignedByte(value = -101),
//     b = UnsignedByte(value = 74),
//     a = Normalized(get = 1.0)
//   )
// )

うまく動いているようである。

ランダムデータの生成源が用意できたら、それを使ってテストを書く。プログラマが手作業で用意するには非現実的な量のデータを簡単に生成できるため、コードの正しさについてより高い確信を得ることができる。テストの具体的な書き方はここでは重要ではない。次へ進むことにしよう。

ここで見たのは確率モナドを使ってランダムデータを生成する例である。確率モナドは他のあらゆる代数と同じ仕組みで動作する。すなわち、コンストラクタ(Random.int)、コンビネータ(map および mapN)、インタープリタ(run)によって構成される。モナドであるということは、この代数が特定の構造をもっていることを意味する。たとえば、pureflatMap が定義されており、それらから mapN を導出できる。

以下に、確率モナドのインターフェースをそれっぽくスケッチしてみよう。

trait Random[A] {
  def flatMap[B](f: A => Random[B]): Random[B]
}
object Random {
  def pure[A](value: A): Random[A] = ???
  
  // 0以上1未満の、一様分布にしたがうランダムな Double を生成する
  val double: Random[Double] = ???
  
  // 一様分布にしたがうランダムな Int を生成する
  val int: Random[Int] = ???
}

このインターフェースは、モナドであるための最小限の要件と、いくつかのコンストラクタを備えている。実装を進めるには、5.2節で紹介したレイフィケーション戦略を適用すればよい。

enum Random[A] {
  def flatMap[B](f: A => Random[B]): Random[B] =
    RFlatMap(this, f)

  case RFlatMap[A, B](source: Random[A], f: A => Random[B])
      extends Random[B]
  case RPure(value: A)
  case RDouble extends Random[Double]
  case RInt extends Random[Int]
}
object Random {
  import Random.{RPure, RDouble, RInt}

  def pure[A](value: A): Random[A] = RPure(value)

  // 0以上1未満の、一様分布にしたがうランダムな Double を生成する
  val double: Random[Double] = RDouble

  // 一様分布にしたがうランダムな Int を生成する
  val int: Random[Int] = RInt
}

次のステップでは標準的な構造的再帰を用いてインタープリタを実装する。このインタープリタにはパラメータがひとつあり、ソースとなる乱数生成器を受け取る。

def run(rng: scala.util.Random = scala.util.Random): A =
  this match {
    case RFlatMap(source, f) => f(source.run(rng)).run(rng)
    case RPure(value)        => value
    case RDouble             => rng.nextDouble()
    case RInt                => rng.nextInt()
  }

RDoubleRInt のケースが型パラメータ A に対して具体的な型を提供していることから、このコードはインデックス付きデータの一例である。したがって、これらのケースではインタープリタがその具体的な型の値を生成できる。インデックス付きデータを使わなければ、生成できるのは常に型 A の値に限られる。しかも、その値はプログラマが事前に用意しなければならない。たとえば RPure のケースがこれにあたる。

この実装を仕上げるには Monad 型クラスを実装するとよい。そうすれば mapN やその他のメソッドを自動的に得ることができる。しかし、本節はインデックス付きデータにフォーカスしており、Monad の実装はこのケーススタディの範囲外である。練習が必要だと感じるなら自分で Monad を実装してみるとよいだろう。

インデックス付きデータでは、具体的な型とジェネリックな型を混在させることもできる。たとえば Randomproduct メソッドを追加することを考えてみよう。

enum Random[A] {
  // ...

  def product[B](that: Random[B]): Random[(A, B)] =
    RProduct(this, that)

  case RProduct[A, B](left: Random[A], right: Random[B]) extends Random[(A, B)]
  // .. other cases here
}

RProduct ケースでは、型パラメータが (A, B) に固定されており、具体的なタプル型とジェネリックな型 A および B とが混在している。

Scala2 ではインデックス付きデータを扱うためにいくつかの工夫が必要であり、Scala3 においても同様の工夫が役立つことがある。次に示すのは、確率モナドを Scala2 に移植した例である。このコードには using ディレクティブを含めてあるので、ファイルに貼り付けて Scala CLI で実行すれば Scala 2.13 の最新版が用いられる。

//> using scala 2.13

sealed trait Random[A] {
  import Random._

  def flatMap[B](f: A => Random[B]): Random[B] =
    RFlatMap(this, f)

  def product[B](that: Random[B]): Random[(A, B)] =
    RProduct(this, that)

  def run(rng: scala.util.Random = scala.util.Random): A =
    this match {
      case RFlatMap(source, f) => f(source.run(rng)).run(rng)
      case RProduct(l, r)      => (l.run(rng), r.run(rng))
      case RPure(value)        => value
      case RDouble             => rng.nextDouble()
      case RInt                => rng.nextInt()
    }

}
object Random {
  final case class RFlatMap[A, B](source: Random[A], f: A => Random[B])
      extends Random[B]
  final case class RProduct[A, B](left: Random[A], right: Random[B])
      extends Random[(A, B)]
  final case class RPure[A](value: A) extends Random[A]
  case object RDouble extends Random[Double]
  case object RInt extends Random[Int]

  def pure[A](value: A): Random[A] = RPure(value)

  // 0以上1未満の、一様分布にしたがうランダムな Double を生成する
  val double: Random[Double] = RDouble

  // 一様分布にしたがうランダムな Int を生成する
  val int: Random[Int] = RInt
}

Scala2 では以下のようなエラーが多数発生する。

[error] constructor cannot be instantiated to expected type;
[error]  found   : Random.RProduct[A(in class RProduct),B]
[error]  required: Random[A(in trait Random)]
[error]       case RProduct(l, r)      => (l.run(rng), r.run(rng))
[error]            ^^^^^^^^

この問題を解決するには、インタープリタの中で新たな型パラメータをもつネストされたメソッドを定義する必要がある。これを以下に示す。この変更を加えることで Scala2 の型推論が機能し、コードを正しくコンパイルできるようになる。

def run(rng: scala.util.Random = scala.util.Random): A = {
  def loop[A](random: Random[A]): A =
    random match {
      case RFlatMap(source, f)   => loop(f(loop(source)))
      case RProduct(left, right) => (loop(left), loop(right))
      case RPure(value)          => value
      case RDouble               => rng.nextDouble()
      case RInt                  => rng.nextInt()
    }

  loop(this)
}

もうひとつのテクニックは、型タグにマッチさせるパターンマッチを使いたい場合に必要となるものである。これは次のようなマッチング形式を使うケースを指している。

case r: RPure[A] => ???

以下のようなケースの話ではない。

case RPure(value) => ???

For cases like RProduct it is not clear how to write these pattern matches, as the type parameters A and B for RProduct don’t correspond to the type parameter A on Random. The solution is use lower case names from the type parameters. Concretely, this means we can write

RProduct のようなケースでは、こういったパターンマッチをどのように書けばよいのかが明確でない。RProduct に現れる型パラメータ A および B は、Random に現れる型パラメータ A には対応していないからである。解決策は、型パラメータとして小文字の名前を使うことである。具体的には、次のように書くことができる。

case r: RProduct[a, b] => ???

ここで使われている型パラメータ ab は存在型(existential type)である。何らかの型が存在することは分かっているが、どういう具体型なのかはわからない。このテクニックは Scala 2 ではときどき必要になるが、Scala 3 ではその必要はきわめてまれである。

13.4 まとめ

この章ではインデックス付きデータとインデックス付き余データを見てきた。インデックス付き型の核となるアイデアは、型パラメータが特定の型に等しいという関係、すなわち等価性制約を型に埋め込むことである。インデックス付きデータでは、これらの制約はデータの中にエンコードされ、データを分解するときに検知される。このように、インデックス付きデータは等価性の生成者となる。インデックス付き余データでは、メソッドを呼び出すときにそれらの制約が成り立つことを示さなければならない。したがって、インデックス付き余データは等価性の消費者である。また、given インスタンスの中に他の種類の制約をエンコードするというコンテキスト抽象化を用いることで、等価性制約の枠を超えた表現が可能であることも見た。

インデックス付き型はファントム型に基づいている。ファントム型に言及したもっとも古い資料は、私が見つけたかぎり [Leijen and Meijer 2000] である。型の等価性はそのすぐあとに導入され、現在、一般化代数的データ型(GADT)あるいはインデックス付きデータとして知られるものが形成された[Cheney and Hinze 2003; Xi et al. 2003; Sheard and Pasalic 2008]。一般化代数的データ型についての研究の多くは型推論アルゴリズムに関するものであり ([Peyton Jones et al. 2006] など)、実務的プログラマにはあまり関係がない。Lin and Sheard [2010] もその点では変わらないが、GADT のもっとも一般的な使い方について、特に明快な分類を提示している。

インデックス付き余データに対する関心は、ずっと最近[Thibodeau et al. 2016]になって現れたものである。これは、余データに対する関心がプログラミング言語研究において全般的に(少なくとも私が読んだ範囲において)乏しかったことを反映している。Scala はインデックス付き余データを非常にうまくサポートしているが、それでもなお、Scala におけるインデックス付きデータと余データのサポートには非対称性が見られる。インデックス付きデータは言語に組み込まれているのに対し、インデックス付き余データはコンテキスト抽象化を用いて自力で構築しなければならない。これは必ずしも悪いことではない。コンテキスト抽象化により、インデックス付きデータと余データの単純な等価性を超えた表現が可能となるからである。最近の研究はこの非対称性を解消しようとしている。たとえば Ostermann and Jabs [2018] は、インデックス付きデータとインデックス付き余データとを、API を定義する行列の転置によって関連付けられる存在として捉えている。また Zhang et al. [2022] は、Scala で実装されたシステムにおいてデータと余データのあいだの変換を実現している。

ケーススタディでは、メソッド呼び出しの順序に制約を設けるという API プロトコルを実装するためにインデックス付き余データを用いた。これは、多くのケーススタディで見てきた基本的な代数あるいはコンビネータライブラリの戦略を発展させたものと見ることができる。また、この考え方はオブジェクト指向プログラミング(OOP)コミュニティにおける研究とも関係している。この関連性を示すことには意義がある。これらの問題は異なるプログラミングコミュニティにまたがって存在し、それらのコミュニティが独自に似たような解決策を見出すことがある。

OOP の世界では、コンビネータライブラリは「流暢なインターフェース(fluent interface)」と呼ばれる。この用語を導入した論文は API プロトコルの必要性にも言及し、「流暢な操作を連鎖させるために何が必要かを考え、戻り値の型を選ぶこと」と述べている[Fowler 2005][Freeman and Pryce 2006; Hawick 2013; Dethlefs and Hawick 2017; Shrestha et al. 2021] など多くのケーススタディが流暢なインターフェースを探求しており、このスタイルのコードはますます人気を集めている[Nakamaru et al. 2020]。API プロトコルのエンコードには手間がかかるため、プロトコル定義からコードを生成するツールの開発も研究されている[Levy 2016; Nakamaru et al. 2017; Gil and Roth 2019; Vuković et al. 2023]Roth and Gil [2023] は、API プロトコルを再び関数型の世界へと翻訳し直し、Standard ML における多様なエンコーディングを紹介している。

本書で構築した確率モナドはデータのサンプリングに特化したもので、あり得る実装のひとつにすぎない。サンプリングとは、分布の近似表現を得る手段である。小さな離散分布であれば、正確に表現することもできる。Erwig and Kollmansberger [2006] は、そのような正確な表現の方法を、本書で用いたサンプリングのアプローチとともに示している。Kidd [2007] は、正確なアプローチとサンプリングによるアプローチとをモナド変換子スタックへと統合する手法を紹介している。Scibior et al. [2015] は、さまざまな統計的推論アルゴリズムを定義する基盤となる抽象として、確率モナドを用いている。これは、本書を通じて強調してきた「複数のインタープリタ」というアイデアの応用である。Scibior et al. [2018] はこのアイデアをさらに発展させ、推論アルゴリズムを、再利用可能な構成要素へと分解している。

確率モナドについてはプロパティベースのテストという文脈[Claessen and Hughes 2000]で紹介したが、ランダムにテストデータを生成することだけが唯一の方法ではない。Runciman et al. [2008] はデータ列挙の洗練された手法を紹介している。代数的データ型の列挙に特化したアプローチとしては Duregård et al. [2012] も参考にするとよい。さらに最近では、機械学習を利用する手法も探求されている。Reddy et al. [2020]Lemieux et al. [2023] を見てほしい。Goldstein et al. [2024] はプロパティベースのテストが実際にどのように使われているかを調査している。

確率モナドはジェネラティブアートにも応用できると述べた。ジェネラティブアートとは、広義にはアルゴリズム的な手続きによって生成された芸術である。これにはランダム性の要素が含まれることもある。ジェネラティブアートに関する [Boden and Edmonds 2009; Dorin et al. 2012] などの論文もあるし、その他の資料も多数存在するが、自分で実際に作ってみるのが一番楽しい。図14はジェネラティブアートの一例である。以下にコードを掲載する。いろいろなパラメータが調整可能になっているので、自分だけの作品を作ってみるとよい。@main アノテーションを cycloid メソッドに追加すれば Scala CLI からコードを実行できる。楽しんでほしい。

Figure 14: A set of cycloids showing five-fold symmetry
//> using dep org.creativescala::doodle:0.30.0

import cats.Monoid
import cats.syntax.all.*
import cats.effect.unsafe.implicits.global
import doodle.core.*
import doodle.core.format.{Pdf, Png}
import doodle.interact.syntax.interpolation.*
import doodle.random.{*, given}
import doodle.syntax.all.*
import doodle.java2d.*

def cycloid(): Unit = {
  given Monoid[Angle => Vec] with {
    def combine(a: Angle => Vec, b: Angle => Vec): Angle => Vec =
      angle => a(angle) + b(angle)

    val empty: Angle => Vec = angle => Vec.zero
  }

  /** Reverse the rolling direction of the cycloid. */
  val reverse: Angle => Angle = angle => -angle

  /** Multiply the angle by the given speed, which determines how rapidly the
    * cycloid rotates.
    */
  def speed(speed: Double): Angle => Angle =
    angle => angle * speed

  /** Increment the angle by the given amount. In other words move it out of
    * phase.
    */
  def phase(p: Angle): Angle => Angle =
    angle => p - angle

  /** Set the radius of the cycloid */
  def radius(r: Double): Angle => Vec =
    angle => Vec(r, angle)

  /** Cycloid is speed of rotation and radius (+ve or -ve) */
  def cycloid(v: Double, r: Double): Angle => Vec =
    speed(v).andThen(radius(r))

  /** Inspired by "Creating Symmetry" by Frank Farris. */
  def c1(amplitude: Double) =
    cycloid(1.0, amplitude) |+| cycloid(6.0, 0.5 * amplitude) |+| (speed(14.0)
      .andThen(phase(90.degrees))
      .andThen(radius(0.33 * amplitude)))

  val randomCycloid: Random[Double => Angle => Vec] =
    for {
      d <- Random.int(3, 25) // Degree of symmetry
      n <- Random.natural(d) // Offset from d
      m1 <- Random.int(1, 5)
      m2 <- Random.int(m1, m1 + 5)
    } yield amplitude =>
      cycloid(n, amplitude) |+| cycloid(
        m1 * d + n,
        0.5 * amplitude
      ) |+| phase(90.degrees).andThen(cycloid(m2 * d + n, 0.33 * amplitude))

  def drawCycloid(
      cycloid: Angle => Vec,
      start: Angle = 0.degrees,
      stop: Angle = 720.degrees,
      steps: Int = 1000
  ): Picture[Unit] =
    interpolatingSpline[Algebra](
      (start)
        .upTo(stop)
        .forSteps(steps)
        .map(angle => cycloid(angle).toPoint)
        .toList
    )

  /** Repeatedly draw a cycloid with increasing size and a slow turn */
  def drawCycloids(cycloid: Double => Angle => Vec): Picture[Unit] =
    (0.0)
      .upTo(1.0)
      .forSteps(30)
      .map { m =>
        drawCycloid(cycloid(350 * m + 100)).rotate(30.degrees * m)
      }
      .toList
      .allOn

  // val picture = drawCycloids(c1)
  val picture = randomCycloid.map(drawCycloids).run

  val frame = Frame.default

  picture.drawWithFrame(frame)
  picture.write[Png]("cycloid.png", frame)
  picture.write[Pdf]("cycloid.pdf", frame)
}

14 Tagless Final インタープリタ

本章では、インタプリタに対する余データ的アプローチを探求し、Tagless Final として知られる戦略へと至る道をたどる。その過程で、ターミナル操作用とユーザインターフェース用の、二種類のインタプリタを構築する。

データと余データの双対性については3章をはじめさまざまな箇所で見てきた。本章では、この双対性を応用して余データを用いたインタプリタを構築する。これは、5.2節で見たデータを用いるアプローチとは対照的である。この作業を通じて技法について描き出し、またこれを具体例とすることでこの技法の欠点について議論できるようにする。特に、拡張性が制限されるという欠点が明らかになるだろう。これは3.5節ですでに触れた問題である。

この拡張性の問題、すなわち式の問題の解決を追っていくと、Tagless Final という戦略にたどり着く。インタープリタという文脈において式の問題を解決するとは、作成するプログラムとそれを実行するインタープリタの両方について拡張性をもたせることを意味する。まずは Scala における Tagless Final の標準的なエンコーディングから始めるが、実際にはすこし使いにくいことがわかるだろう。そこで、もっと扱いやすい別のエンコーディングを開発する。式の問題を解決することで非常に表現力豊かなコードを書けるようになるが、代償として複雑性が増す。そこで最後に、Tagless Final が適している場合と、別の戦略を用いたほうがよい場合について述べる。

14.1 余データ的インタープリタ

本節では、ターミナルでの対話のための DSL を題材として余データ的インタープリタを探求する。ターミナルは多くのプログラマにとって馴染み深く、そこで使われる CLI アプリケーションは開発者向けのツールとして一般的である。ターミナルの機能はいわゆるエスケープシーケンスを書き込むことで制御されることがよくある。しかし、もっと高水準な抽象が提供されれば、アプリケーションの利便性は向上する。そこで、より使いやすいインターフェースを提供するテキストユーザインターフェース(TUI)ライブラリを作りたい20。本節で構築するライブラリは、余データ的インタープリタやモナド、そして合成と推論のための設計が果たす中心的な役割について見せてくれるはずである。

14.1.1 ターミナル

現在のターミナルは、1978年に登場した VT-100 に始まり[今日に至るまで][kitty-kp]機能が集積してできたものである。多くのターミナル機能は ANSI エスケープシーケンスの読み書きによって利用できる。エスケープシーケンスとは、エスケープ文字を先頭とする文字列のことをいう。ここでは、文字スタイルを変更するためのエスケープシーケンスだけを扱う。これにより、システムをシンプルに保ったまま、興味深い成果を得るとともに設計上の論点をひととおり明らかにできる。ここで示すアイデアは、Terminus ライブラリの中で、より本格的なシステムへと拡張されている。

以下に示すコードは、わずかな変更を加えるだけで、ファイルに貼りつけて Scala の最近のバージョンで scala <ファイル名> としてそのまま実行できるように書かれている。必要な変更とは go 関数の前に @main アノテーションを追加することである。つまり、

def go(): Unit =

@main def go(): Unit =

に変えればよい(これは、本書に記述されたコードをコンパイルするソフトウェア mdoc の制約による)。

例は過去40年ほどのターミナルであればどれでも動作するはずである。Windows 環境では Windows Terminal や WSL、あるいは WezTerm のような Windows 上で動作するターミナルを使えばよい。

14.1.2 カラーコード

ターミナルに直接カラーコードを書き込むところから始めよう。これによりターミナル制御の基本を学ぶことができ、同時に ANSI エスケープシーケンスを直接使用することの問題点が明らかとなる。以下が出発点となるコードである。

val csiString = "\u001b["

def printRed(): Unit =
  print(csiString)
  print("31")
  print("m")

def printReset(): Unit =
  print(csiString)
  print("0")
  print("m")

def go(): Unit =
  print("Normal text, ")
  printRed()
  print("now red text, ")
  printReset()
  println("and now back to normal.")

上述のコードを試してみよう。たとえば、go 関数に @main アノテーションを追加してファイル ColorCodes.scala に保存し、scala ColorCodes.scala を実行すればよい。ターミナルの通常のスタイルで表示されるテキストに続いて赤色のテキストが表示され、その後、通常のスタイルに戻ったテキストが続くのが見られるはずである。

色の変更はエスケープシーケンスの書き込みによって制御されている。そのシーケンスは ESC(文字 '\u001b')に '[' が続く文字列で、これが csiString の値である。CSI は Control Sequence Introducer を意味している。CSI の後に、使用したいテキストスタイルを指示する文字列を続け、最後に "m" を付ける。文字列 "\u001b[31m" はターミナルに対して文字色を赤にするよう指示するもので、文字列 "\u001b[0m" は、すべてのテキストスタイルをデフォルトに戻すよう指示するものである。

14.1.3 エスケープシーケンスの問題点

エスケープシーケンスは、ターミナルが処理するのは単純だが、それを生成するプログラマにとっては有用な構造を欠いている。上記のコードにはひとつの潜在的な問題が示されている。スタイル付きのテキストを出力し終えたら、忘れずに色をリセットしなければならないということである。この問題は、手動で確保したメモリを解放し忘れないようにする問題と何ら変わらない。そして、C言語プログラムのメモリ安全性問題に関する長い歴史が示すとおり、この種の作業を人間が確実にこなすことは期待できない。幸いなことに、エスケープシーケンスを忘れてもプログラムがクラッシュする可能性は低いが。

この問題を解決するために、次のような printRed 関数を書くことが考えられる。この関数は、文字列を赤色で出力し、その後スタイルをリセットする。

val csiString = "\u001b["
val redCode = s"${csiString}31m"
val resetCode = s"${csiString}0m"

def printRed(output: String): Unit =
  print(redCode)
  print(output)
  print(resetCode)

def go(): Unit =
  print("Normal text, ")
  printRed("now red text, ")
  println("and now back to normal.")

ターミナル出力のスタイリングは文字色を変えることだけではない。たとえば、文字を太字にすることもできる。上記の設計を引き継ぐとコードは次のようになる。

val csiString = "\u001b["
val redCode = s"${csiString}31m"
val resetCode = s"${csiString}0m"
val boldOnCode = s"${csiString}1m"
val boldOffCode = s"${csiString}22m"

def printRed(output: String): Unit =
  print(redCode)
  print(output)
  print(resetCode)

def printBold(output: String): Unit =
  print(boldOnCode)
  print(output)
  print(boldOffCode)

def go(): Unit =
  print("Normal text, ")
  printRed("now red text, ")
  printBold("and now bold.\n")

これでも動作はする。しかし、テキストを赤色かつ太字で出力したい場合はどうだろうか。現在の設計ではこれを表現する方法がなく、すべてのスタイルの組み合わせに対して個別の関数を作るしかない。具体的には、次のような関数を書く必要がある。

def printRedAndBold(output: String): Unit =
  print(redCode)
  print(boldOnCode)
  print(output)
  print(resetCode)

すべてのスタイルの組み合わせについてこのような実装を行うのは現実的でない。根本的な問題は、現在の設計が合成的でないことである。小さな部品を合成してスタイルの組み合わせを構築する手段が存在しない。

14.1.4 プログラムとインタープリタ

上記の問題を解決するには、printRedprintBold が、出力対象となる String ではなく実行するプログラムを受け取るようにする必要がある。それらのプログラムが具体的に何をするかを知る必要はない。それらを実行する手段があればよい。そうすれば、printRedprintBold といったコンビネータもまたプログラムを返すことができるようになる。これらのコンビネータが返すプログラムは、まず適切にスタイルを設定し、それから受け取ったプログラムを実行し、終了後にスタイルをリセットする。

プログラムを受け取りプログラムを返すことにより、これらのコンビネータは閉包性(closure)をもつ。入力(プログラム)の型と出力の型が同じということである。閉包性があることによって合成が可能となる。

プログラムはどのように表現すればよいだろうか。ここでは余データ、特にそのもっとも単純な形である関数を選ぶ。以下のコードでは型 Program[A]() => A という関数として定義している。インタープリタ、すなわちプログラムを実行するものは、単なる関数適用である。ここでは、プログラムを実行していることがより明確にわかるように、単に関数適用するだけの run メソッドを作成した。

type Program[A] = () => A

val csiString = "\u001b["
val redCode = s"${csiString}31m"
val resetCode = s"${csiString}0m"
val boldOnCode = s"${csiString}1m"
val boldOffCode = s"${csiString}22m"

def run[A](program: Program[A]): A = program()

def print(output: String): Program[Unit] =
  () => Console.print(output)

def printRed[A](output: Program[A]): Program[A] =
  () => {
    run(print(redCode))
    val result = run(output)
    run(print(resetCode))
    
    result
  }


def printBold[A](output: Program[A]): Program[A] = 
  () => {
    run(print(boldOnCode))
    val result = run(output)
    run(print(boldOffCode))
    
    result
  }


def go(): Unit =
  run(() => {
    run(print("Normal text, "))
    run(printRed(print("now red text, ")))
    run(printBold(print("and now bold ")))
    run(printBold(printRed(print("and now bold and red.\n"))))
  })

このコードには、5.2.1節で初めて見たような、代数的な構造が備わっていることに注目してほしい。

  1. print がコンストラクタ
  2. printRedprintBold がコンビネータ
  3. run がインタープリタ

先ほど用いた例においてこのコードは正しく動作するが、問題がふたつある。ひとつは合成、もうひとつは使い勝手である。私たちはここでまさに合成の問題を解決しようとしていたはずなので、そこに問題があるというのは意外に思えるかもしれない。確かに、ある側面ではこのシステムは合成的になった。だが、それでもなお正しく機能しないケースがある。たとえば、次のコードを見てほしい。

run(printBold(() => {
  run(print("ここは太字になるはずで、"))
  run(printBold(print("ここも太字のはず。")))
  run(print("ついでにここも太字。\n"))
}))

出力は次のようになる想定だが、

ここは太字になるはずで、ここも太字のはず。ついでにここも太字。

実際には次のようになる。

ここは太字になるはずで、ここも太字のはず。ついでにここも太字。

内側の printBold 呼び出しが終了時に太字スタイルをリセットしてしまい、それにより外側の printBold の効果がその後のステートメントに及ばなくなってしまう。

使い勝手の問題というのは、このコードを書くのが煩雑かつミスを招きやすいという点にある。run の呼び出しを正しい場所にちりばめる必要があり、この程度の小さな例であっても、筆者自身いくつか間違えた。実のところこれは合成に関するもうひとつの問題でもある。この問題はプログラムを組み合わせるためのメソッドが存在しないことに起因するからである。たとえば、上述のプログラムが三つのサブプログラムの逐次的合成であることを記述する方法がない。

最初の問題はターミナルの状態を追跡することで解決できる。たとえば、printBold がすでに太字出力中の状態で呼び出された場合には何もせず、そうでなければ太字スタイルが設定されたことを示すよう状態を更新すればよい。これは、プログラムの型が () => A から Terminal => (Terminal, A) に変わることを意味する。Terminal はターミナルの現在の状態を保持する型である。

もうひとつの問題を解決するには、プログラムを逐次的に合成する方法が求められる。プログラムは Terminal => (Terminal, A) という型をもち、状態を Terminal の中にいれて引き回すということを思い出そう。「逐次的に合成する」という言葉やこのような型を見て、そこにモナドが関わってくることを感じ取ったかもしれない。そのとおり、これは9.9節で見た状態モナドの一例である。

Cats を使えば Program[A] は以下のように定義できる。

import cats.data.State
type Program[A] = State[Terminal, A]

ただし、これは Terminal が適切に定義されていることを仮定している。まずはこの定義を受け入れ、Terminal の定義に焦点を移そう。

Terminal は、現在の太字設定と現在の文字色というふたつの状態をもっている。 実際のターミナルはもっと多くの状態をもつが、このふたつをその代表的なものと考えておく。他の状態をモデル化したとしても新しい概念が増えるわけではない。太字設定はオンとオフを切り替えられる単純なトグルでもよいが、実装の都合を考えると、ネストの深さを記録するカウンタとしたほうが取り扱いやすい。現在の文字色はスタックで表現する必要がある。文字色の変更はネストさせることができ、ネストから抜けるときには色を元に戻すべきだからである。具体的に言えば、以下のようなコードを記述でき、期待通りに青と赤を切り替えながら出力されるようにしたい。

printBlue(.... printRed(...) ...)

以上をふまえると Terminal は次のように定義できる。

final case class Terminal(bold: Int, color: List[String]) {
  def boldOn: Terminal = this.copy(bold = bold + 1)
  def boldOff: Terminal = this.copy(bold = bold - 1)
  def pushColor(c: String): Terminal = this.copy(color = c :: color)
  // 文字色が最低ひとつはスタックに積まれているときにのみ呼び出す
  def popColor: Terminal = this.copy(color = color.tail)
  def peekColor: Option[String] = this.color.headOption
}

ここではカラーコードのスタックを表現するのに List を使っている。状態モナドを用いることによって、プログラム全体に状態が適切に伝播することが保証されているため、可変スタックを使っても構わない。また、状態操作を簡潔にするために、補助メソッドもいくつか定義してある。

準備は整ったので、残りのコードを書いていこう。以下にそのコードを示す。前回のコードと比べて、メソッド名をいくつか短くし、エスケープシーケンスの抽象化を行っている。前述のとおり、このコードは scala コマンドでそのまま実行できる。ファイル(たとえば Terminal.scala)に貼りつけて go 関数に @main アノテーションを追加し、scala Terminal.scala を実行すればよい。

//> using dep org.typelevel::cats-core:2.13.0

import cats.data.State
import cats.syntax.all.*

object AnsiCodes {
  val csiString: String = "\u001b["

  def csi(arg: String, terminator: String): String =
    s"${csiString}${arg}${terminator}"

  // SGR は Select Graphic Rendition の略
  // スタイルを変更するエスケープシーケンスはすべて SGR である
  def sgr(arg: String): String =
    csi(arg, "m")

  val reset: String = sgr("0")
  val boldOn: String = sgr("1")
  val boldOff: String = sgr("22")
  val red: String = sgr("31")
  val blue: String = sgr("34")
}

final case class Terminal(bold: Int, color: List[String]) {
  def boldOn: Terminal = this.copy(bold = bold + 1)
  def boldOff: Terminal = this.copy(bold = bold - 1)
  def pushColor(c: String): Terminal = this.copy(color = c :: color)
  // 文字色が最低ひとつはスタックに積まれているときにのみ呼び出す
  def popColor: Terminal = this.copy(color = color.tail)
  def peekColor: Option[String] = this.color.headOption
}
object Terminal {
  val empty: Terminal = Terminal(0, List.empty)
}

type Program[A] = State[Terminal, A]
object Program {
  def print(output: String): Program[Unit] =
    State[Terminal, Unit](
      terminal => (terminal, Console.print(output))
    )

  def bold[A](program: Program[A]): Program[A] =
    for {
      _ <- State.modify[Terminal] { terminal =>
        if terminal.bold == 0 then Console.print(AnsiCodes.boldOn)
        terminal.boldOn
      }
      a <- program
      _ <- State.modify[Terminal] { terminal =>
        val newTerminal = terminal.boldOff
        if terminal.bold == 0 then Console.print(AnsiCodes.boldOff)
        newTerminal
      }
    } yield a

  // 文字色を取り扱うメソッドを構築するためのヘルパーメソッド
  def withColor[A](code: String)(program: Program[A]): Program[A] =
    for {
      _ <- State.modify[Terminal] { terminal =>
        Console.print(code)
        terminal.pushColor(code)
      }
      a <- program
      _ <- State.modify[Terminal] { terminal =>
        val newTerminal = terminal.popColor
        newTerminal.peekColor match {
          case None    => Console.print(AnsiCodes.reset)
          case Some(c) => Console.print(c)
        }
        newTerminal
      }
    } yield a

  def red[A](program: Program[A]): Program[A] =
    withColor(AnsiCodes.red)(program)

  def blue[A](program: Program[A]): Program[A] =
    withColor(AnsiCodes.blue)(program)

  def run[A](program: Program[A]): A =
    program.runA(Terminal.empty).value
}

def go(): Unit = {
  val program =
    Program.blue(
      Program.print("This is blue ") >>
        Program.red(Program.print("and this is red ")) >>
        Program.bold(Program.print("and this is blue and bold "))
    ) >>
      Program.print("and this is back to normal.\n")

  Program.run(program)
}

Terminal の構造を定義したので、残りのコードの大部分は Terminal の状態操作となる。Program オブジェクトに定義したメソッドの多くは、メインのプログラムを実行する前後に状態変更を行うという共通の構造をもっている。

ここで注目すべきなのは、flatMap>> といったコンビネータを自分で実装する必要がないという点である。これらは State モナドから自動的に得られる。これはモナドのような抽象を再利用することで得られる大きな利点のひとつである。追加の実装をしなくても豊富なメソッド群をすぐに使うことができる。

14.1.5 合成と推論

1.2.1節では関数型プログラミングの核心は推論と合成であると述べた。このふたつは本ケーススタディにおいても中心的な位置を占めている。ここでは推論を容易にするために DSL を明示的に設計した。制御コードをターミナルに直接吐き出すのではなく DSL を構築したのは、すべてまさにそのためである。その一例が、ネストされた呼び出しが期待どおりに動作するよう注意を払った点である。

合成はふたつのレベルで現れる。今回の設計と実装、いずれもが合成的である。設計における合成性についてはケーススタディの中ですでに論じた。実装においても、Program は状態モナドとその中に含まれる関数の合成である。状態モナドは Terminal 状態の逐次的な変化の流れを、関数はドメイン固有の動作を、それぞれ提供してくれる。

14.1.6 余データと拡張性

余データ的インタープリタを選んだのは一見すると恣意的に思えるかもしれない。ここでは、その選択の理由と含意について探っていきたい。

余データは「インターフェースに対してプログラムを書く」ものであると述べた。関数におけるインターフェースは基本的にひとつのメソッド、すなわち関数適用の能力である。これは、Program に対する解釈が、それを実行して内部に記述された効果を発動する、というひとつだけであったことと対応している。もし、Terminal の状態をログに記録したり、出力をバッファに保存したりといった複数の解釈をもたせたいのであれば、もっと豊かなインターフェースが必要となる。Scala においては、複数のメソッドを公開する traitclass がそれにあたる。

注意深い読者は、データと余データにおける拡張性のトレードオフを思い出すかもしれない。データでは、新しいインタープリタを追加するのは容易だが、新しい操作を追加するのは難しい。これに対して余データでは、新しい操作の追加は容易だが、新しいインタープリタを追加するのは難しい。そのことは今まさに実例で示されている。たとえば、新しい文字色のコンビネータを追加するのはごく簡単である。次のようにメソッドを定義すればよい。

def green[A](program: Program[A]): Program[A] =
  withColor(AnsiCodes.sgr("32"))(program)

しかし Program に複数の解釈を許すような変更を加える場合、既存のコードすべてを書き換える必要が生じる。

余データのもうひとつの利点は Scala の任意のコードを混ぜ込めることである。たとえば、次のように map を使うことができる。

Program.print("Hello").map(_ => 42)

プログラムを関数として表現することで、Scala の言語機能すべてがそのまま使える。データによる表現で同じことを行おうとすれば、サポートしたいすべての構文要素をレイフィケーションしなければならない。もっとも、これには Scala のセマンティクスが望むと望まざるとにかかわらずそのままついてくるという欠点もある。もし独自のセマンティクスをもった異質な言語を作りたいのであれば、余データによる表現は適切ではない。

インタープリタの分割のしかたはいろいろあるが、それでもなお余データ的インタープリタであることに変わりはない。たとえば、ターミナルへの書き出し用のメソッドを Terminal 型にもたせることもできる。こうすれば実装に柔軟性が生まれ、Terminal の実装を変更することで、出力先をネットワークソケットやブラウザ上の仮想ターミナルなどに切り替えることも可能になる。しかしそれでも、プログラムをディスクにシリアライズするといった、まったく異なる種類の解釈を行うことは、余データ的手法では難しい。この制限については、次節で Tagless Final という手法を紹介することで対処していく。

14.2 Tagless Final インタープリタ

ここでは、基本的な余データ的インタープリタを拡張した手法である Tagless Final を探っていく。ターミナル DSL のケーススタディでは、問題が見つかるたびに修正を加えるという場当たり的な方法で DSL を構築してきた。本節ではこれをより体系的に進め、どのように戦略を当てはめればコードを導出することができるのかを説明する。それによって、基本的な余データ的インタープリタを Tagless Final に変換する方法も明らかになるだろう。

最初に、余データ的インタープリタにおける各型の役割を明示しておく。5.2.1節で見たように、代数においては三種類のメソッドが存在する。

ターミナル DSL では、Program 型を以下のように定義した。

type Program[A] = State[Terminal, A]

コンストラクタは String => Program[Unit] 型の print ひとつだけである。出力スタイルを変える boldredblue といったメソッドはすべて Program[A] => Program[A] 型のコンビネータである。そして関数適用という唯一のインタープリタがあり、事実上 Program[A] => A という型をもっている。

余データ的インタープリタにおいて可能な解釈は、Program 型に対して利用可能なメソッドに限定される。ターミナル DSL はプログラムを関数として表現しているため、利用可能な解釈はただひとつだけである。この制限を回避するための Tagless Final の鍵となるアイデアは、Program 型をプログラム操作によってパラメータ化することにある。これがどういう意味なのかはやや分かりにくいので、Tagless Final の簡単な例を通じて説明しよう。

例として扱うのは算術式である。特に魅力的とは言えないが誰もがよく知っている題材なので、ドメインに気を取られることなく Tagless Final の詳細に集中できる。より実用的な例は後ほど取り上げる。

まずはデータによるインタープリタを定義し、それを余データ的インタープリタに変換し、その後 Tagless Final を適用する。以下に代数的データ型を用いて定義されたプログラム型を示す。コンストラクタは代数的データ型の一部として含まれるため、明示的な定義は不要である。

enum Expr {
  case Add(l: Expr, r: Expr)
  case Sub(l: Expr, r: Expr)
  case Mul(l: Expr, r: Expr)
  case Div(l: Expr, r: Expr)
  
  case Literal(value: Double)
}

続いて、ふたつのインタープリタを定義する。ひとつは Expr を評価して Double 値を算出し、もうひとつは ExprString として出力する。いずれも、構造的再帰を用いて実装される。

object EvalInterpreter {
  import Expr.*

  def eval(expr: Expr): Double =
    expr match {
      case Add(l, r) => eval(l) + eval(r)
      case Sub(l, r) => eval(l) - eval(r)
      case Mul(l, r) => eval(l) * eval(r)
      case Div(l, r) => eval(l) / eval(r)
      case Literal(value) => value
    }
}
object PrintInterpreter {
  import Expr.*

  def print(expr: Expr): String =
    expr match {
      case Add(l, r) => s"(${print(l)} + ${print(r)})"
      case Sub(l, r) => s"(${print(l)} - ${print(r)})"
      case Mul(l, r) => s"(${print(l)} * ${print(r)})"
      case Div(l, r) => s"(${print(l)} / ${print(r)})"
      case Literal(value) => value.toString
    }
}

簡単な使用例を見てみよう。まず式を定義する。ここでは 1 + 2 という式を考える。

val onePlusTwo = Expr.Add(Expr.Literal(1), Expr.Literal(2))

そうすると、この式を二種類の方法で解釈可能となる。

EvalInterpreter.eval(onePlusTwo)
// res10: Double = 3.0
PrintInterpreter.print(onePlusTwo)
// res11: String = "(1.0 + 2.0)"

ここにはおなじみのトレードオフがある。新しいインタープリタを追加するのは簡単だが、プログラム型に新しいオペレータを追加するのは難しい。

次に、これを余データ表現に変換してみよう。インタープリタは Expr 型のメソッドとして定義される。

trait Expr {
  def eval: Double
  def print: String
}

コンストラクタとコンビネータは Expr インスタンスを生成する。Expr の部分型を明示的に定義することもできたが、ここではコードをコンパクトに保つため無名の部分型を用いた。実装には構造的余再帰を用いている。

trait Expr {
  def eval: Double
  def print: String

  def +(that: Expr): Expr = {
    val self = this
    new Expr {
      def eval: Double = self.eval + that.eval
      def print: String = s"(${self.print} + ${that.print})"
    }
  }

  def -(that: Expr): Expr = {
    val self = this
    new Expr {
      def eval: Double = self.eval - that.eval
      def print: String = s"(${self.print} - ${that.print})"
    }
  }

  def *(that: Expr): Expr = {
    val self = this
    new Expr {
      def eval: Double = self.eval * that.eval
      def print: String = s"(${self.print} * ${that.print})"
    }
  }

  def /(that: Expr): Expr = {
    val self = this
    new Expr {
      def eval: Double = self.eval / that.eval
      def print: String = s"(${self.print} / ${that.print})"
    }
  }
}
object Expr {
  def literal(value: Double): Expr =
    new Expr {
      def eval: Double = value
      def print: String = value.toString
    }
}

これを使って先ほどの例と同じ式を記述できる。

val onePlusTwo = Expr.literal(1) + Expr.literal(2)

そして、先ほどと同じようにそれを解釈する。

onePlusTwo.eval
// res14: Double = 3.0
onePlusTwo.print
// res15: String = "(1.0 + 2.0)"

想定していたとおり、このコードは先ほどとは反対の拡張性をもっている。プログラムに新しい操作を追加するのは簡単にできる。たとえば以下では sin という操作を追加している。

def sin(expr: Expr): Expr = {
  new Expr {
    def eval: Double = Math.sin(expr.eval)
    def print: String = s"sin(${expr.print})"
  }
}

しかし一方で、Expr にすでに定義されている evalprint というふたつの解釈にしか対応できないという制約がある。

議論をより正確に進められるように、ここでいくつか用語を導入しておきたい。まず、プログラム代数(program algebras)という言葉を、コンストラクタとコンビネータの集まりを指すものとして用いることにする。これらはプログラムを作成するために用いられる代数の一部だからである。また、プログラムそのものとプログラム型(program type)を区別する必要がある。先ほどの例で言えば Expr がプログラム型で、プログラムとはプログラム型の値を生成する式のことを指す。

Tagless Final の核心は、次の二点にある。

  1. プログラム型によってパラメータ化されたプログラム代数を定義すること
  2. プログラムを、それが依存しているプログラム代数によってパラメータ化すること

先ほどの例におけるプログラム代数は次のように定義できる。

trait Arithmetic[Expr] {
  def +(l: Expr, r: Expr): Expr
  def -(l: Expr, r: Expr): Expr
  def *(l: Expr, r: Expr): Expr
  def /(l: Expr, r: Expr): Expr
  
  def literal(value: Double): Expr
}

プログラム型である Expr によってパラメータ化されている点に注目してほしい。この定義をもとにプログラムを作成できる。以下は、これまで見てきたのと同じ例を Tagless Final 形式で書き直したものである。

def onePlusTwo[Expr](arithmetic: Arithmetic[Expr]): Expr =
  arithmetic.+(arithmetic.literal(1.0), arithmetic.literal(2.0))

プログラムとプログラム型が区別されていることを意識しておくとよい。プログラムはプログラム型の値を作成するが、それ自体はプログラム型ではない。Tagless Final において、プログラムとはプログラム代数を受け取りプログラム型を返す関数である。

Arithmetic インスタンスを作成することでこの例は完成する。

object DoubleArithmetic extends Arithmetic[Double] {
  def +(l: Double, r: Double): Double =
    l + r
  def -(l: Double, r: Double): Double =
    l - r
  def *(l: Double, r: Double): Double = 
    l * r
  def /(l: Double, r: Double): Double = 
    l / r
  
  def literal(value: Double): Double =
    value
}

この例は以下のように書けば実行できる。

onePlusTwo(DoubleArithmetic)
// res17: Double = 3.0

Tagless Final は両方の軸について拡張性を提供してくれる。新しいインタープリタの追加は以下のように行うことができる。

object PrintArithmetic extends Arithmetic[String] {
  def +(l: String, r: String): String =
    s"($l + $r)"
  def -(l: String, r: String): String =
    s"($l - $r)"
  def *(l: String, r: String): String = 
    s"($l * $r)"
  def /(l: String, r: String): String = 
    s"($l / $r)"
  
  def literal(value: Double): String =
    value.toString
}

実行方法は先ほどと同じである。

onePlusTwo(PrintArithmetic)
// res18: String = "(1.0 + 2.0)"

新しいプログラム代数を定義することもできる。

trait Trigonometry[Expr] {
  def sin(expr: Expr): Expr
}

そして、プログラムでそれらを用いる。

def sinOnePlusTwo[Expr](
    arithmetic: Arithmetic[Expr],
    trigonometry: Trigonometry[Expr]
  ): Expr =
  trigonometry.sin(onePlusTwo(arithmetic))

ここで合成が用いられていることも重要なポイントである。プログラム sinOnePlusTwoonePlusTwo を再利用している。

次に進む前にいくつか補足しておきたい。

この例では、プログラム型とインタプリタの出力型が一致している。Double へと解釈したいときはプログラム型として Double を用いているし、String の場合も同様である。これはたまたま算術式という題材を用いているからであり、いつもそうとはかぎらない。算術式から最終的な結果を算出するのに追加情報が必要ないため、プログラム型と出力型が一致しているにすぎない。

また、Tagless Final はデータや余データによるインタープリタと比べて記法上の負荷が高い。これについては後ほど取り扱い、最終的には普通の Scala コードのような見た目の表現へと改善していく。だがその前に、もっと魅力的な例としてクロスプラットフォームのユーザインターフェースを取り上げよう。

14.3 代数的ユーザインターフェース

ターミナルプログラムの解釈を変更することは実用的というよりは理論的な問題である。テキストバッファへの保存や状態遷移の追跡といった異なる解釈にもニッチな用途があることは確かだが、大多数のケースではデフォルトの解釈が使われるだろう。それよりも、Tagless Final を適用する必要性をもっと強く感じることのできる例が、クロスプラットフォームなユーザインターフェースライブラリである。FlutterReact NativeCapacitor といったフレームワークは、ウェブとモバイルの両方で動作する単一のインターフェースを定義できるという点に大きな価値をもっている。ここで構築するのはそうしたライブラリに類するものである。ただし、実際に複数のプラットフォームに対応させようとは考えていない。ここではターミナル向けのバックエンドのみを作成し、それ以外のプラットフォーム向けバックエンドは読者自身の発想と努力に委ねたい。

一般的に、ユーザインターフェースには大きく分けて二種類ある。たとえばデジタル楽器を操作する場合には、ユーザインターフェースから継続的な値のストリームを受け取る必要がある。それに対して、フォーム入力のような場合は、フォームの送信時に一度だけ値を受け取ればよい。継続的な値のストリームをモデル化することももちろん可能である(関数的リアクティブプログラミングを参照)が、それは本質的でない複雑さを招く。そのためここでは、ユーザが一度だけ値を送信するタイプの、単純なインターフェースに限定して話を進める。

コンストラクタ、コンビネータ、インタープリタについて順に検討しよう。

コンストラクタは、このライブラリにおけるユーザインターフェースの基本単位を定義する。どの粒度で定義するかは、表現力と利便性とのトレードオフになる。もっとも低いレベルでは、頂点バッファなどを直接扱う設計も考えられる。その場合、ライブラリは汎用のグラフィックスライブラリになるが、このケーススタディには低レベルすぎて適さない。より高いレベルでは、ラベル、ボタン、入力フィールドといったユーザインターフェース要素を基本単位として考えることもできる。HTML が扱うのはこの水準の要素である。このレベルでは、ひとつの完結したコントロールを構成するのにも複数の要素を必要とすることが一般的である。たとえば HTML では、概念的にはひとつのフォームフィールドであっても、ラベル・入力コントロール・検証エラー表示といった別個の DOM 要素から構成され、さらにインタラクティブ性を得るための Javascript コードも必要になる。

ここではさらに高いレベルを目指す。ここで扱う基本要素は、たとえば複数の選択肢からひとつを選ばせるといった、必要とされる入力の種類を指定するものとする。それをプラットフォームのどのようなコントロールで描画するかはインタープリタの決定に委ねる。たとえば、多数からひとつを選択するための部品は、ラジオボタンで描画してもよいし、ドロップダウンでもよい。あるいは、選択肢の数に応じて両者を使い分けてもよい。各要素にはラベルと、必要に応じて検証ルールも加えることにする。その発想を具体的に示すために、ふたつの要素をモデル化してみよう。

type Validation[A] = A => Either[String, A]

// 常に成功する検証ルール
def succeed[A](value: A): Either[String, A] = Right(value)

trait Controls[Ui[_]] {
  def textInput(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Ui[String]

  def choice[A](label: String, options: Seq[(String, A)]): Ui[A]
}

ここで定義したコントロールは以下のふたつである。

モデリングにおける決定が表現力を制限していることに注目してほしい。たとえば、textInput にはプレースホルダ(ユーザが入力を始める前に表示されるテキスト)はあるが、デフォルト値はない。表現力を削ることで利便性が得られる。ユーザの要求がこのモデルに収まるならば、コントロールの作成は非常に簡単である。また、コントロールの外観を制御する手段がまったくないことにも注目してほしい。これは意図的な設計であり、その関心事をインタープリタに委ねているのである。

これらのコントロールはプログラム型 Ui の要素を生成する。バックエンドに対応する個々のインタープリタは、利用するユーザインターフェースツールキットの要件に応じて、Ui のための具体的な型を選択する。

これらふたつのコンストラクタだけでアイデアは十分に示せるので、つぎはコンビネータに移ることにしよう。ユーザインターフェースの文脈において、もっとも一般的なコンビネータは要素のレイアウトを指定するものである。コンストラクタの場合と同じくさまざまな設計方針が考えられる。HTML における CSS のように詳細なレイアウト制御を許してもよいし、あらかじめ定義されたレイアウトをいくつか用意するという方法もある。あるいはレイアウトそのものをインタープリタに委ねることもできる。先ほどのコンストラクタ設計方針および設計をシンプルに保ちたいという事情にしたがい、ここでは非常に高水準の設計を採用する。提供するコンビネータは and ひとつだけで、これはふたつの要素を並べることだけを指定する。それが画面上でどのように描画されるかはインタープリタに委ねられる。

trait Layout[Ui[_]] {
  def and[A, B](first: Ui[A], second: Ui[B]): Ui[(A, B)]
}

ここで定義している and は、11.1節で登場した Semigroupalproduct に他ならないと気づいた人もいるかもしれない。product も名前を除けばまったく同じシグネチャをもっており、ユーザインターフェースに対して適用されているのと同じ概念を表している。

ここまでですでに ControlsLayout というふたつのプログラム代数を定義し、コンストラクタとコンビネータの両方の例を示した。次のステップはインタープリタの作成である。極めてシンプルなインタープリタを作成し、それによってアイデアを具体化してみせ、プログラム代数を用いたプログラムの書き方を示す。もっと高機能なインタープリタを作ることももちろん可能だが、それによって新しい概念が導入されるわけでもなく、コードがかなり増えるだけである。

今回作成するインタープリタは、標準ライブラリのコンソール入出力機能を用いてユーザとの対話を行う。

import cats.syntax.all.*
import scala.io.StdIn
import scala.util.Try

type Program[A] = () => A

object Simple extends Controls[Program], Layout[Program] {
  def and[A, B](first: Program[A], second: Program[B]): Program[(A, B)] =
    // Cats の Function0 用 Semigroupal を用いる
    (first, second).tupled

  def textInput(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Program[String] =
    () => {
      def loop(): String = {
        println(s"$label (e.g. $placeholder):")
        val input = StdIn.readLine

        validation(input).fold(
          msg => {
            println(msg)
            loop()
          },
          value => value
        )
      }

      loop()
    }

  def choice[A](label: String, options: Seq[(String, A)]): Program[A] =
    () => {
      def loop(): A = {
        println(label)
        options.zipWithIndex.foreach { case ((desc, _), idx) =>
          println(s"$idx: $desc")
        }

        Try(StdIn.readInt).fold(
          _ => {
            println("Please enter a valid number.")
            loop()
          },
          idx => {
            if idx >= 0 && idx < options.size then options(idx)(1)
            else {
              println("Please enter a valid number.")
              loop()
            }
          }
        )
      }

      loop()
    }
}

これを使ってシンプルな例を実装してみよう。

def quiz[Ui[_]](
    controls: Controls[Ui],
    layout: Layout[Ui]
): Ui[(String, Int)] =
  layout.and(
    controls.textInput("What is your name?", "John Doe"),
    controls.choice(
      "Tagless final is the greatest thing ever",
      Seq(
        "Strongly disagree" -> 1,
        "Disagree" -> 2,
        "Neutral" -> 3,
        "Agree" -> 4,
        "Strongly agree" -> 5
      )
    )
  )

以下のようなコードを書けばこの例を実行できる。

val (name, rating) = quiz(Simple, Simple)()
println(s"Hello $name!")
println(s"You gave tagless final a rating of $rating.")

対話の様子は次のようになる。

What is your name? (e.g. John Doe):
Noel Welsh
Tagless final is the greatest thing ever
0: Strongly disagree
1: Disagree
2: Neutral
3: Agree
4: Strongly agree
4
Hello Noel Welsh!
You gave tagless final a rating of 5.

基本的な例は動作するようになったが、使い勝手はあまりよくない。Tagless Final 形式でコードを書く方法は、通常のコードと比べると非常に込み入っている。次節では、もっと快適な書き方をユーザに提供してくれる Tagless Final の別のエンコーディングを紹介する。

14.4 よりよいエンコーディング

Tagless Final の基本的な実装は、開発者体験としてはやや残念なものだった。先ほどの例をリファクタリングすることを考えてみよう。

def name[Ui[_]](controls: Controls[Ui]): Ui[String] =
  controls.textInput("What is your name?", "John Doe")
  
def rating[Ui[_]](controls: Controls[Ui]): Ui[Int] =
  controls.choice(
    "Tagless final is the greatest thing ever",
    Seq(
      "Strongly disagree" -> 1,
      "Disagree" -> 2,
      "Neutral" -> 3,
      "Agree" -> 4,
      "Strongly agree" -> 5
    )
  )
  
def quiz[Ui[_]](
    controls: Controls[Ui],
    layout: Layout[Ui]
): Ui[(String, Int)] =
  layout.and(name(controls), rating(controls))

このスタイルのコードはすぐに書くのが煩雑になってしまう。メソッドシグネチャはかなり込み入っており、プログラム代数をメソッド間で渡す作業は煩わしい手間でしかない。

これを改善する方法のひとつは、プログラム代数を given インスタンスとして定義することである。たとえば次のようなアクセサを定義する。

object Controls {
  def apply[Ui[_]](using controls: Controls[Ui]): Controls[Ui] =
    controls
}

object Layout {
  def apply[Ui[_]](using layout: Layout[Ui]): Layout[Ui] =
    layout
}

そうすると次のように書くことができる。

def name[Ui[_]: Controls]: Ui[String] =
  Controls[Ui].textInput("What is your name?", "John Doe")
  
def rating[Ui[_]: Controls]: Ui[Int] =
  Controls[Ui].choice(
    "Tagless final is the greatest thing ever",
    Seq(
      "Strongly disagree" -> 1,
      "Disagree" -> 2,
      "Neutral" -> 3,
      "Agree" -> 4,
      "Strongly agree" -> 5
    )
  )
  
def quiz[Ui[_]: Controls: Layout]: Ui[(String, Int)] =
  Layout[Ui].and(name, rating)

これは Scala コミュニティで一般的に用いられている Tagless Final のエンコーディングであるが、実際にこのコードを書く開発者にとっては、まだ記法上の負担が大きい。しかし Scala の言語機能を活用すれば、Tagless Final 形式のコーディングの負担を、通常のコードとほとんど変わらないレベルにまで減らすことができる。

そのために、次の5つの技法を組み合わせて用いる。

  1. プログラム代数の基底型を作ること
  2. プログラム型を抽象型メンバーとして定義すること
  3. プログラムを表す型を定義すること
  4. コンストラクタをコンパニオンオブジェクト上に定義すること
  5. コンビネータのために拡張メソッドを定義すること

やや込み入ってはいるが、各ステップ自体は比較的単純である。どのように機能するのかを見ていこう。

最初のステップは、代数の基底型を作ることである。これは次のような単なるトレイトである。

trait Algebra[Ui[_]]

プログラム代数はこのトレイトを継承する。

trait Controls[Ui[_]] extends Algebra[Ui[_]]{
  def textInput(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Ui[String]

  def choice[A](label: String, options: Seq[(String, A)]): Ui[A]
}

trait Layout[Ui[_]] extends Algebra[Ui[_]]{
  def and[A, B](first: Ui[A], second: Ui[B]): Ui[(A, B)]
}

次に、プログラム型を抽象型メンバーとして定義する。

trait Algebra {
  type Ui[_]
}

trait Controls extends Algebra {
  def textInput(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Ui[String]

  def choice[A](label: String, options: Seq[(String, A)]): Ui[A]
}

trait Layout extends Algebra {
  def and[A, B](first: Ui[A], second: Ui[B]): Ui[(A, B)]
}

この時点で例題プログラムにはすでに有意義な変化が生じる。当初の出発点は次のようなコードだった。

def quiz[Ui[_]: Controls: Layout](
    controls: Controls[Ui],
    layout: Layout[Ui]
): Ui[(String, Int)] =
  Layout[Ui].and(
    Controls[Ui].textInput("What is your name?", "John Doe"),
    Controls[Ui].choice(
      "Tagless final is the greatest thing ever",
      Seq(
        "Strongly disagree" -> 1,
        "Disagree" -> 2,
        "Neutral" -> 3,
        "Agree" -> 4,
        "Strongly agree" -> 5
      )
    )
  )

上述の変更により、このコードは以下のように書き直すことができる。

def quiz(using alg: Controls & Layout): alg.Ui[(String, Int)] =
  alg.and(
    alg.textInput("What is your name?", "John Doe"),
    alg.choice(
      "Tagless final is the greatest thing ever",
      Seq(
        "Strongly disagree" -> 1,
        "Disagree" -> 2,
        "Neutral" -> 3,
        "Agree" -> 4,
        "Strongly agree" -> 5
      )
    )
  )

主な変更点は以下のとおりである。

  1. プログラム代数が共通の基底型を継承しているため、メソッドの単一の引数として受け取れるようになった
  2. Ui 型が抽象型メンバーとして定義され、型パラメータが不要となった
  3. 戻り値の型を指定するために依存メソッド型(dependent method types)を使う必要が生じた

次のステップではプログラムを表す型を定義する。プログラムは概念的にはプログラム代数を受け取りプログラム型を返す関数である。そういう関数のような型を以下のように定義できる。

trait Program[-Alg <: Algebra, A] {
  def apply(alg: Alg): alg.Ui[A]
}

戻り値型が alg.Ui[A] であることに特に注意を払ってほしい。Program は戻り値に依存メソッド型を要求するので、普通の関数型としては定義することはできない。

これで例題プログラムは次のようになる。

val quiz =
  new Program[Controls & Layout, (String, Int)] {
    def apply(alg: Controls & Layout) =
      alg.and(
        alg.textInput("What is your name?", "John Doe"),
        alg.choice(
          "Tagless final is the greatest thing ever",
          Seq(
            "Strongly disagree" -> 1,
            "Disagree" -> 2,
            "Neutral" -> 3,
            "Agree" -> 4,
            "Strongly agree" -> 5
          )
        )
      )
  }

これでプログラムはメソッドではなく値として定義されるようになった。Program の最初の型パラメータには、そのプログラムが必要とするすべてのプログラム代数が指定されることに注目してほしい。

このコードを書くのはいまだにやや煩雑だが、単一抽象メソッド(single abstract method)というテクニックを用いることで、すこしシンプルにできる。これは、Program のように抽象メソッドをひとつだけもつトレイトを関数で実装できるという Scala の機能である。

val quiz: Program[Controls & Layout, (String, Int)] =
  (alg: Controls & Layout) =>
    alg.and(
      alg.textInput("What is your name?", "John Doe"),
      alg.choice(
        "Tagless final is the greatest thing ever",
        Seq(
          "Strongly disagree" -> 1,
          "Disagree" -> 2,
          "Neutral" -> 3,
          "Agree" -> 4,
          "Strongly agree" -> 5
        )
      )
    )

「値としてのプログラム」は、続くふたつの改善への扉を開く鍵となる。そのひとつ目はコンストラクタをコンパニオンオブジェクトのメソッドとして定義することである。

object Controls {
  def textInput(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Program[Controls, String] =
    alg => alg.textInput(label, placeholder, validation)

  def choice[A](
    label: String, 
    options: Seq[(String, A)]
  ): Program[Controls, A] =
    alg => alg.choice(label, options)
}

メソッドがプログラムを返せるようになったことで、このような書き方が可能となる。

ふたつ目の、そして全体として最後の改善は、コンビネータのための拡張メソッドを定義することである。今回はコンビネータが and ひとつしかないので、拡張メソッドもひとつだけ定義する。

extension [Alg <: Algebra, A](p: Program[Alg, A]) {
  def and[Alg2 <: Algebra, B](
    second: Program[Alg2, B]
  ): Program[Alg & Alg2 & Layout, (A, B)] =
    alg => alg.and(p(alg), second(alg))
}

この拡張メソッドにおける型の定義を特に注意深く見てほしい。この拡張はプログラム代数 Alg を要求する Program に対して定義されている。and メソッドに渡す引数はそれとは別の Program 値で、プログラム代数 Alg2 を要求する。その結果として得られるプログラムは代数 Alg & Alg2 & Layout を要求する。これはふたつのプログラムが要求する代数と Layout との交差型である。コンビネータは、プログラムが要求する代数をこのようにして組み立てる。

最終的な結果として、ユーザは次のようなコードを書くことができる。

val quiz  =
  Controls
    .textInput("What is your name?", "John Doe")
    .and(
      Controls.choice(
        "Tagless final is the greatest thing ever",
        Seq(
          "Strongly disagree" -> 1,
          "Disagree" -> 2,
          "Neutral" -> 3,
          "Agree" -> 4,
          "Strongly agree" -> 5
        )
      )
    )

これは見た目にも通常のコードと変わらない。quiz の型を見れば、必要なプログラム代数がすべて型推論によって正しく導出されていることがわかる。

quiz
// res2: Program[Controls & Layout, Tuple2[String, Int]] = repl.MdocSession$MdocApp1$$Lambda$22644/0x00000008057c9040@5531a9

このエンコーディングではライブラリ開発者側により多くの作業が必要となる。だがそれは一度きりのコストであり、その代わりとしてライブラリ利用者ははるかに簡潔なコードを書くことができる。Tagless Final のほとんどの用途において、このトレードオフは妥当なものだと考えている。

14.5 まとめ

本章では、余データ的インタープリタと、そこから Tagless Final へと至る道程について見てきた。Tagless Final が特に興味深いのは、それが式の問題を解決するからである。これにより、プログラムが実行できる操作と、そのプログラムの解釈の両方を拡張できるようになる。

Tagless Final の探求は1.1節で述べた理論と技法の違いをよく示している。Scala における Tagless Final のエンコーディングをふたつ(コンテキスト境界を別のエンコーディングと数えれば三つ)見てきた。いずれも理論的には Tagless Final であるものの、プログラマにとっての実装や使用の観点からは大きく異なる。標準的なエンコーディングは、ライブラリ作者にとって実装が比較的簡単だが、利用者にとっては記法が煩雑で混乱を招きやすい。一方で、改良されたエンコーディングを用いると、ライブラリ作者にとっての負担は大きいが、利用者は自然なスタイルでコードを書くことができる。

Tagless Final は非常に強力であり、あらゆるところで使いたくなるかもしれない。しかし、ここでひとつその衝動に釘を差しておきたい。Tagless Final は、ライブラリ作者にとっても利用者にとっても問題の原因になりうる。利用者の視点では、正しいコードを書いているうちはすべてがうまく動くが、ひとたび間違いがあると、エラーが非常にわかりにくくなる。次のコードを考えてみよう。このコードには and の引数が足りていない。

Controls.textInput("Name", "John Doe").and()
// error:
// missing argument for parameter second of method and in class MdocApp0: (second: MdocApp0.this.Program[Alg2, B]):
//   MdocApp0.this.Program[Alg & Alg2 & MdocApp0.this.Layout, (String, B)]
// Controls.textInput("Name", "John Doe").and()
//                                        ^^^

エラーメッセージはたしかに問題の所在を教えてくれる。だがそこには、利用者が普段は目にすることのない内部の仕組みが多く露出しており、おそらく理解に苦しむだろう。素直な実装をもつデータ的あるいは余データ的インタープリタではこのような問題は起こらない。

ライブラリ作者の視点からすると、Tagless Final のコードを書くのはかなりの労力を要する。また、使われるテクニックはほとんどの人にとって馴染みのないものであるため、新しい開発者を参加させるのも難しい。

いつものことだが、Tagless Final がうまく当てはまるかどうかは、それが使われる文脈による。拡張性が本当に必要とされる状況では強力な道具となるが、そうでない場合は不要な複雑さをもちこむだけである。

「式の問題」という用語は、Phil Wadler によるメール [Wadler 1998] の中ではじめて用いられた。しかし、同じ問題を論じたもっと古い文献も存在する。その一例が [Cook 1990] である。Tagless Final というアプローチは [Carette et al. 2009] においてはじめて提案され、[Kiselyov 2012] がそれを発展させた。これは、式の問題に対して提案されてきた多くの解法のひとつにすぎない。私は式の問題に対する解決法全般に詳しいわけではないが、これまでに読んだ論文の中では、オブジェクト代数 [Oliveira and Cook 2012] とデータ型アラカルト [Swierstra 2008] を特に紹介したい。オブジェクト代数は本質的には Tagless Final と同じものだが、関数型ではなくオブジェクト指向言語において発展してきたもので、ふたつの異なる(だが密接に関係した)研究分野における収斂進化の興味深い例と言える。また、このオブジェクト代数の論文は、本書で扱ってきた概念の背後にある理論について簡潔ながら形式的に論じており、読みごたえがある。データ型アラカルトは余データではなくデータ的なアプローチによって式の問題に取り組むもので、Tagless Final とは興味深い対比をなしている。ただ、私には Tagless Final のほうがずっと簡潔であるように思われるため、本書ではデータ型アラカルトのことは掘り下げなかった。注目すべきもうひとつの論文として [Gibbons and Wu 2014] がある。これはデータと余データの双対性および、その双対性が組み込み DSL においてどのような意味をもつのかについて論じている。

Tagless Final は Haskell を実装言語として導入された。Scala における標準的なエンコーディングは、Haskell における実装を直接的に翻訳したものである。一方、改善された Scala 向けエンコーディングは私自身が考案した。また、単一抽象メソッドを用いた簡略化手法は Jakub Kozłowski によって提案された。

15 インタープリタとコンパイラの最適化

以前の章で、インタープリタを関数型プログラミングにおける重要な戦略として紹介した。多くの場合、単純な構造的再帰によるインタープリタで十分でだが、いくつかのケースではそれ以上のパフォーマンスが必要になる。そこで、この章では最適化について取り組む。このテーマは非常に幅広く、ひとつの章ですべてを網羅することは望むべくもない。そこで、複雑な技法から取り出した重要なアイデアを利用したふたつの技法、代数的操作と仮想マシンへのコンパイルに焦点を当てる。

まず、以前に使用した正規表現の例を用いて、代数的操作について見ていく。その後、仮想マシンの話に移る。ここでは単純な算術インタープリタの例を使用する。コードをどのようにスタックマシンへとコンパイルするかを学び、仮想マシンを用いる際に適用することのできる最適化をいくつか見ていく。

15.1 代数的操作

プログラムのレイフィケーションとは、プログラムをデータ構造として表現することである。このデータ構造は書き換えが可能である。この書き換えとは、解釈されるプログラムを簡略化し最適化する方法であり、またインタープリタを実装する計算の一般的な形式でもある。この節では正規表現の例に戻り、これらふたつのタスクを実行するにあたって書き換えがどのように利用されるかを示す。

ここでは正規表現の微分と呼ばれる技法を使用する。正規表現の微分は、入力に対する正規表現のマッチングを簡単に行う方法を提供してくれる。この手法では、以前の章では扱わなかった、和集合に対する正しいセマンティクスも考慮される。

正規表現をある文字で微分した結果は、その文字にマッチした後に残る正規表現である。たとえば、文字列 "osprey" にマッチする正規表現があるとしよう。以前の章で作成したライブラリを使えば、これは Regexp("osprey") と表せる。この正規表現を文字 o で微分した結果は Regexp("sprey")、つまり文字列 "sprey" を探す正規表現である。一方、文字 a で微分した結果はどんな文字にもマッチしない正規表現で、Regexp.empty で表される。さらに複雑な例として、Regexp("cats").repeatc で微分した結果は Regexp("ats") ++ Regexp("cats").repeat となる。これは、文字列 "ats" とその後に続く "cats" の0回以上の繰り返しを探す正規表現である。

正規表現がある入力にマッチするかを判定するには、入力に含まれる一つひとつの文字で順に微分するだけでよい。最終的に得られた正規表現が空文字列にマッチすれば成功、それ以外の場合はマッチに失敗したことになる。

このアルゴリズムを実装するために必要なのは以下の三つである。

  1. 空文字列にマッチする正規表現の明示的な表現
  2. 正規表現が空文字列にマッチするかをテストするメソッド
  3. 正規表現を特定の文字で微分するメソッド

まずは以前の章で開発した基本的なインタープリタから始めよう。これがもっともシンプルなコードであり、ベースとして扱うのに都合がよい。

enum Regexp {
  def ++(that: Regexp): Regexp =
    Append(this, that)

  def orElse(that: Regexp): Regexp =
    OrElse(this, that)

  def repeat: Regexp =
    Repeat(this)

  def `*` : Regexp = this.repeat

  def matches(input: String): Boolean = {
    def loop(regexp: Regexp, idx: Int): Option[Int] =
      regexp match {
        case Append(left, right) =>
          loop(left, idx).flatMap(i => loop(right, i))
        case OrElse(first, second) =>
          loop(first, idx).orElse(loop(second, idx))
        case Repeat(source) =>
          loop(source, idx)
            .flatMap(i => loop(regexp, i))
            .orElse(Some(idx))
        case Apply(string) =>
          Option.when(input.startsWith(string, idx))(idx + string.size)
        case Empty =>
          None
      }

    // 入力全体にマッチしたかどうかをチェックする
    loop(this, 0).map(idx => idx == input.size).getOrElse(false)
  }

  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Empty
}
object Regexp {
  val empty: Regexp = Empty

  def apply(string: String): Regexp =
    Apply(string)
}

空文字列にマッチする正規表現を明示的に表現したい。これは、この後のアルゴリズムで重要な役割を果たす。これを実現するのは簡単である。レイフィケーションを行い、必要に応じてコンストラクタを調整するだけでよい。このケースのことを、文献で用いられている用語に合わせて「epsilon」と呼ぶことにする。

enum Regexp {
  // ...
  case Epsilon
}
object Regexp {
  val epsilon: Regexp = Epsilon

  def apply(string: String): Regexp =
    if string.isEmpty() then Epsilon
    else Apply(string)
}

次に、正規表現が空文字列にマッチするかどうかを判定する述語関数を作成する。そのような正規表現は「 nullable である」と表現される。コードは非常にシンプルなので、言葉で説明するよりもコードを読んだほうが理解しやすいだろう。

def nullable: Boolean =
  this match {
    case Append(left, right) => left.nullable && right.nullable
    case OrElse(first, second) => first.nullable || second.nullable
    case Repeat(source) => true
    case Apply(string) => false
    case Epsilon => true
    case Empty => false
  }

これで正規表現の微分の本体ロジックを実装できる。これはふたつの部分からなる。微分を行うメソッドと、nullable な正規表現を処理するメソッドで、前者は後者に依存している。どちらも比較的シンプルなので、まずコードを示し、その後、複雑な部分について説明することにする。

def delta: Regexp =
  if nullable then Epsilon else Empty

def derivative(ch: Char): Regexp =
  this match {
    case Append(left, right) =>
      (left.derivative(ch) ++ right).orElse(left.delta ++ right.derivative(ch))
    case OrElse(first, second) =>
      first.derivative(ch).orElse(second.derivative(ch))
    case Repeat(source) =>
      source.derivative(ch) ++ this
    case Apply(string) =>
      if string.size == 1 then
        if string.charAt(0) == ch then Epsilon
        else Empty
      else if string.charAt(0) == ch then Apply(string.tail)
      else Empty
    case Epsilon => Empty
    case Empty => Empty
  }

このコードは概ねわかりやすいが、OrElseAppend のケースはすこしわかりにくいかもしれない。OrElse のケースでは、両方の正規表現に同時にマッチを試みており、これにより前回の実装での問題が解消される21nullable の定義では、OrElse のどちらか一方が空文字列にマッチすれば OrElse 自体もマッチすることが保証されている。Append のケースでは、left 側がまだ文字を探している場合には left とのマッチを試み、そうでなければ right 側とのマッチを試みる。

これを用いて matches を次のように再定義する。

def matches(input: String): Boolean = {
  val r = input.foldLeft(this){ (regexp, ch) => regexp.derivative(ch) }
  r.nullable
}

このコードが期待どおり動作することを以下に示しておく。

val regexp = Regexp("Sca") ++ Regexp("la") ++ Regexp("la").repeat
regexp.matches("Scala")
// res13: Boolean = true
regexp.matches("Scalalalala")
// res14: Boolean = true
regexp.matches("Sca")
// res15: Boolean = false
regexp.matches("Scalal")
// res16: Boolean = false

今回の実装は、前回の実装が抱えていた課題も解決している。

Regexp("cat").orElse(Regexp("cats")).matches("cats")
// res17: Boolean = true

これは非常にシンプルなアルゴリズムとしてはよい結果だが、ひとつ問題がある。すでに気付いているかもしれないが、正規表現のマッチングが非常に遅くなる可能性がある。実際、以下のような簡単なマッチングを試みるだけでもヒープ領域が不足してしまうことがある。

Regexp("cats").repeat.matches("catscatscatscats")
// java.lang.OutOfMemoryError: Java heap space

これは、正規表現の微分結果が非常に大きくなることによって起こる。以下の例を見てほしい。ほんの数回微分しただけでこうなる。

Regexp("cats").repeat.derivative('c').derivative('a').derivative('t')
// res18: Regexp = OrElse(OrElse(Append(Apply(s),Repeat(Apply(cats))),Append(Empty,Append(Empty,Repeat(Apply(cats))))),OrElse(Append(Empty,Append(Empty,Repeat(Apply(cats)))),Append(Empty,OrElse(Append(Empty,Repeat(Apply(cats))),Append(Empty,Append(Empty,Repeat(Apply(cats))))))))

OrElse( OrElse( Append(Apply(s),Repeat(Apply(cats))), Append(Empty,Append(Empty,Repeat(Apply(cats)))) ),OrElse( Append(Empty,Append(Empty,Repeat(Apply(cats)))), Append( Empty, OrElse( Append(Empty,Repeat(Apply(cats))), Append(Empty,Append(Empty,Repeat(Apply(cats)))) ) ) ) )

根本的な原因は、AppendOrElse、および Repeat の微分ルールが、入力よりも大きな正規表現を生成する可能性がある点にある。しかし、この出力には冗長な情報が含まれている場合が多い。上記の例では Append(Empty, ...) が複数回出現しているが、これはただの Empty と等価である。これは算術演算でゼロを加えたり1を掛けたりするのと似ている。算術演算と同じような代数的簡略化を用いることで、こういった不要な要素を取り除くことができる。

この簡略化を実装する方法はふたつある。ひとつは既存の Regexp に適用する独立した簡略化メソッドを作成する方法、もうひとつは Regexp を構築する際に簡略化を行う方法である。ここでは後者を選び、++orElse および repeat を次のように修正する。

def ++(that: Regexp): Regexp = {
  (this, that) match {
    case (Epsilon, re2) => re2
    case (re1, Epsilon) => re1
    case (Empty, _) => Empty
    case (_, Empty) => Empty
    case _ => Append(this, that)
  }
}

def orElse(that: Regexp): Regexp = {
  (this, that) match {
    case (Empty, re) => re
    case (re, Empty) => re
    case _ => OrElse(this, that)
  }
}

def repeat: Regexp = {
  this match {
    case Repeat(source) => this
    case Epsilon => Epsilon
    case Empty => Empty
    case _ => Repeat(this)
  }
}

この小さな変更を加えることで、正規表現はどのような入力に対しても適切なサイズに保たれる。

Regexp("cats").repeat.derivative('c').derivative('a').derivative('t')
// res20: Regexp = Append(Apply(s),Repeat(Apply(cats)))

最終的なコードを以下に示す。

enum Regexp {
  def ++(that: Regexp): Regexp = {
    (this, that) match {
      case (Epsilon, re2) => re2
      case (re1, Epsilon) => re1
      case (Empty, _) => Empty
      case (_, Empty) => Empty
      case _ => Append(this, that)
    }
  }

  def orElse(that: Regexp): Regexp = {
    (this, that) match {
      case (Empty, re) => re
      case (re, Empty) => re
      case _ => OrElse(this, that)
    }
  }

  def repeat: Regexp = {
    this match {
      case Repeat(source) => this
      case Epsilon => Epsilon
      case Empty => Empty
      case _ => Repeat(this)
    }
  }

  def `*` : Regexp = this.repeat

  /** 正規表現が空文字にマッチするなら true */
  def nullable: Boolean =
    this match {
      case Append(left, right) => left.nullable && right.nullable
      case OrElse(first, second) => first.nullable || second.nullable
      case Repeat(source) => true
      case Apply(string) => false
      case Epsilon => true
      case Empty => false
    }

  def delta: Regexp =
    if nullable then Epsilon else Empty

  def derivative(ch: Char): Regexp =
    this match {
      case Append(left, right) =>
        (left.derivative(ch) ++ right).orElse(left.delta ++ right.derivative(ch))
      case OrElse(first, second) =>
        first.derivative(ch).orElse(second.derivative(ch))
      case Repeat(source) =>
        source.derivative(ch) ++ this
      case Apply(string) =>
        if string.size == 1 then
          if string.charAt(0) == ch then Epsilon
          else Empty
        else if string.charAt(0) == ch then Apply(string.tail)
        else Empty
      case Epsilon => Empty
      case Empty => Empty
    }

  def matches(input: String): Boolean = {
    val r = input.foldLeft(this){ (regexp, ch) => regexp.derivative(ch) }
    r.nullable
  }

  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Epsilon
  case Empty
}
object Regexp {
  val empty: Regexp = Empty

  val epsilon: Regexp = Epsilon

  def apply(string: String): Regexp =
    if string.isEmpty() then Epsilon
    else Apply(string)
}

この実装が末尾再帰である点に注目してほしい。唯一の「ループ」は、matches 内での末尾再帰的な foldLeft の呼び出しだが、ここでは継続渡しスタイルへの変換は不要である(正規表現を微分する部分は末尾再帰ではないが、これによってスタックオーバーフローが起きる可能性は非常に低い)。もし計算理論を学んだことがあれば、このことは驚きではないかもしれない。計算理論の重要な成果のひとつに正規表現と有限状態機械の等価性がある。それについて知っていれば、以前の実装でそもそもスタックを使う必要があったことにすこし驚いたかもしれない。

だがすこし待ってほしい。正規表現の微分についてじっくり考えれば、その結果が実は継続であることがわかる。継続とは「次に何をするか」を表しており、正規表現の微分結果が、ある正規表現とひと文字の入力に対して「次に何とマッチするか」を定義しているのとまさに一致している。つまり、我々のインタープリタは実際には継続渡しスタイルを使っているが、関数ではなく正規表現として具現化され、異なる手法によって導かれているのである。

継続はプログラムの制御フローをレイフィケーションしたものである。つまり、プログラム内で制御がどのように移っていくのかを明示的に表現する。これは、継続を異なる順序で適用することで制御フローを変更できることを意味する。具体的な例を考えてみよう。正規表現の微分結果は継続を表している。たとえば、非同期に到着するデータに対して正規表現とのマッチングを実行するとしよう。利用可能なデータに対してできるかぎりマッチングを行ったのち処理を中断し、新たなデータが到着したらマッチングを続行したい。この処理は簡単である。データがなくなったら、その時点での微分結果を記憶しておくだけでよい。新たなデータが到着したら、記憶しておいた微分結果を使って処理を再開する。以下にその例を示す。

まずは正規表現を定義しよう。

val cats = Regexp("cats").repeat

データの最初の断片を処理し、継続を保存する。

val next = "catsca".foldLeft(cats){ (regexp, ch) => regexp.derivative(ch) }

次のデータが届いたら処理を続行する。

"tscats".foldLeft(next){ (regexp, ch) => regexp.derivative(ch) }

正規表現を以前の状態に戻すことも簡単にできる点に注目してほしい。これによりバックトラッキングを実現できる。正規表現にバックトラッキングは必要ないが、より一般的なパーサには必要である。実際、継続を使用すれば、バックトラッキング検索、例外処理、協調スレッディングなど、任意の制御フローを定義することができる。

また、この節では書き換えの力を目の当たりにした。微分を用いた正規表現のマッチングは、正規表現の書き換えのみによって実現される。また、微分が起こすサイズの爆発を回避するために正規表現を簡略化する際も、書き換えを用いた。これらのメソッドの抽象型は Program => Program であり、一見するとコンビネータのように思えるかもしれない。しかし、実装は構造的再帰を使用しており、インタープリタの役割を果たしている。書き換えは、型情報だけでは誤解を招く可能性がある唯一のケースである。

正規表現の微分に面白さとちょっとした驚きを感じてもらえたなら嬉しい。私自身も初めて読んだときに驚いたことを覚えている。ここには本書全体を貫く重要なポイントがある。それは、ほとんどの問題には既に解決策が存在しており、それらを見つけることができれば大幅な時間の節約につながるということである。このアイデアを私は「文献を読む(read the literature)」というひとつの戦略として位置づけている。その理由はすぐに明らかになるだろう。多くの開発者は、たまにブログ記事を読んだり、カンファレンスに参加したりする。しかし、学術論文を読む人はかなり少ないのではないかと思う。これは残念なことである。原因の一部は学術サイドにある。学術論文は、ある程度の訓練なしでは読みづらいスタイルで書かれている。しかし、多くの開発者が学術的な文献を自分と無関係だと考えていることも原因だと思う。本書の目的のひとつは、学術研究と我々が向き合っている課題との関連性を示すことである。そのため各章のまとめでは主要なアイデアの発展について概説し、関連する論文へのリンクを提示している。

15.2 継続からスタックへ

前節では正規表現の微分について探求し、微分の結果が継続であることを見てきた。ただし、最初に継続渡しスタイルを扱ったときのように関数としてではなく、データ構造としてレイフィケーションされている点が特徴的であった。本節では、関数としての継続をデータとしてレイフィケーションする。この過程で、継続が暗黙的にスタック構造をエンコードしていることがわかるだろう。この構造を明示的にレイフィケーションすることは、スタックマシンの実装に向けたステップとなる。

まずは、以下に示すように、微分を使わない継続渡しスタイルによる正規表現インタープリタから始めよう。

enum Regexp {
  def ++(that: Regexp): Regexp =
    Append(this, that)

  def orElse(that: Regexp): Regexp =
    OrElse(this, that)

  def repeat: Regexp =
    Repeat(this)

  def `*` : Regexp = this.repeat

  def matches(input: String): Boolean = {
    // 継続を書きやすくするために型エイリアスを定義
    type Continuation = Option[Int] => Option[Int]

    def loop(regexp: Regexp, idx: Int, cont: Continuation): Option[Int] =
      regexp match {
        case Append(left, right) =>
          val k: Continuation = _ match {
            case None    => cont(None)
            case Some(i) => loop(right, i, cont)
          }
          loop(left, idx, k)

        case OrElse(first, second) =>
          val k: Continuation = _ match {
            case None => loop(second, idx, cont)
            case some => cont(some)
          }
          loop(first, idx, k)

        case Repeat(source) =>
          val k: Continuation =
            _ match {
              case None    => cont(Some(idx))
              case Some(i) => loop(regexp, i, cont)
            }
          loop(source, idx, k)

        case Apply(string) =>
          cont(Option.when(input.startsWith(string, idx))(idx + string.size))

        case Empty =>
          cont(None)
      }

    // 入力全体にマッチするかどうかチェック
    loop(this, 0, identity).map(idx => idx == input.size).getOrElse(false)
  }

  case Append(left: Regexp, right: Regexp)
  case OrElse(first: Regexp, second: Regexp)
  case Repeat(source: Regexp)
  case Apply(string: String)
  case Empty
}
object Regexp {
  val empty: Regexp = Empty

  def apply(string: String): Regexp =
    Apply(string)
}

継続のレイフィケーションにあたってはこれまでと同じ手順を適用することができる。すなわち、継続を構築するそれぞれの場面に対応するケースを作成する。これはインタープリタのループにおける AppendOrElseRepeat に該当する。また、最初に loop を呼び出す際には恒等関数を使用して継続を構築する。これはループが終了したときに呼び出す継続を表す。以上を踏まえると、次の4つのケースが得られる。

enum Continuation {
  case AppendK
  case OrElseK
  case RepeatK
  case DoneK
}

各ケースが次に保持するデータは何だろうか。継続渡しスタイルインタープリタ内のケース構造を見てみよう。Append の構造が典型的である。

case Append(left, right) =>
  val k: Cont = _ match {
    case None    => cont(None)
    case Some(i) => loop(right, i, cont)
  }
  loop(left, idx, k)

継続 k は、Regexrightloop 関数、および継続 cont を参照している。今やろうとしているレイフィケーションでは、これらと同じデータを保持することでこの構造を反映させる必要がある。すべてのケースについて同じように考えると、以下のような定義にたどり着く。apply メソッドを実装したことで、引き続き関数のように継続を呼び出せる点にも注目してほしい。

type Loop = (Regexp, Int, Continuation) => Option[Int]
enum Continuation {
  case AppendK(right: Regexp, loop: Loop, next: Continuation)
  case OrElseK(second: Regexp, index: Int, loop: Loop, next: Continuation)
  case RepeatK(regexp: Regexp, index: Int, loop: Loop, next: Continuation)
  case DoneK

  def apply(idx: Option[Int]): Option[Int] =
    this match {
      case AppendK(right, loop, next) =>
        idx match {
          case None    => next(None)
          case Some(i) => loop(right, i, next)
        }

      case OrElseK(second, index, loop, next) =>
        idx match {
          case None => loop(second, index, next)
          case some => next(some)
        }

      case RepeatK(regexp, index, loop, next) =>
        idx match {
          case None    => next(Some(index))
          case Some(i) => loop(regexp, i, next)
        }

      case DoneK =>
        idx
    }
}

これで、インタープリタのループを Continuation 型を用いて書き換えることが可能となる。

def matches(input: String): Boolean = {
  def loop(
      regexp: Regexp,
      idx: Int,
      cont: Continuation
  ): Option[Int] =
    regexp match {
      case Append(left, right) =>
        val k: Continuation = AppendK(right, loop, cont)
        loop(left, idx, k)

      case OrElse(first, second) =>
        val k: Continuation = OrElseK(second, idx, loop, cont)
        loop(first, idx, k)

      case Repeat(source) =>
        val k: Continuation = RepeatK(regexp, idx, loop, cont)
        loop(source, idx, k)

      case Apply(string) =>
        cont(Option.when(input.startsWith(string, idx))(idx + string.size))

      case Empty =>
        cont(None)
    }

  // 入力全体にマッチするかどうかチェック
  loop(this, 0, DoneK)
    .map(idx => idx == input.size)
    .getOrElse(false)
}

この構造の要点は、スタックをレイフィケーションしたことにある。スタックは各 Continuationnext フィールドとして明示的に表現されている。スタックは後入れ先出し(LIFO)のデータ構造であり、スタックに最後に追加した要素が最初に使用される(これは List の効率的な使用法とまったく同じである)。継続は既存の継続の先頭に要素を追加することで構築されるが、これはリストやスタックを構築する方法とまったく同じである。そして継続は先頭から順に使用される。つまり、継続は後入れ先出し(LIFO)である。このアクセスパターンはリストを効率的に使用するための正しい方法であり、同時にスタックを定義するアクセスパターンでもある。継続をデータとしてレイフィケーションすることで、スタックもレイフィケーションされたことになる。次節では、この事実を利用してスタックマシンをターゲットとするコンパイラを構築する。

15.3 コンパイラと仮想マシン

前節では、継続をレイフィケーションし、それがスタック構造をもつことを見てきた。各継続は次の継続への参照を含み、継続は後入れ先出しの順序で構築される。ここでは、その構造を再びレイフィケーションする。今回は明示的なスタックを作成し、コードを実行するスタックベースの仮想マシン(virtual machine)を構築する。さらに、コードをこの仮想マシン上で動作する一連の操作に変換するコンパイラを導入する。その後、この仮想マシンの最適化について考える。このコードには性能測定が必要なので、自分のコンピュータ上でベンチマークを実行できる関連リポジトリが用意されている。

15.3.1 仮想機械と抽象機械

仮想マシン(仮想機械)とは、ハードウェアではなくソフトウェアで実現された計算機である。仮想マシンは特定の命令セットで記述されたプログラムを実行する。たとえば、Java 仮想マシン(JVM)は Java バイトコードで記述されたプログラムを実行する。

これと密接に関連しているのが抽象機械(abstract machine)である。このふたつの用語は同じ意味で用いられることもあるが、本書では区別を設ける。仮想マシンはソフトウェアとして実装されたものとし、抽象機械は実装をもたない理論的なモデルとする。つまり、抽象機械は概念、仮想マシンは概念を実現したものと考えることができる。この区別は、本書の他の部分にわたって広く用いられる。

抽象機械としてのスタックマシンは、プッシュダウン・オートマトンや SECD マシンといったモデルによって表現される。抽象的なスタックマシンからは、まずスタックマシンという概念そのものが得られる。スタックの主要なふたつの操作は、値をスタックの一番上にプッシュすることと、一番上の値をポップすることである。関数の引数と戻り値はどちらもスタックを介して受け渡しされる。たとえば、加算のような二項演算では、スタックからふたつの値をポップし、それらを足し合わせ、その結果をスタックにプッシュする。抽象的なスタックマシンは、スタックをひとつしかもたないスタックマシンが汎用計算機ではないことも教えてくれる。言い換えると、そのようなスタックマシンはチューリングマシンと同等の計算能力をもたない。スタックをもうひとつ追加するか、あるいは別の形態の追加メモリがあれば、スタックマシンは汎用計算機となる。この考え方は、スタックマシンに基づいた仮想マシンの設計に影響を与える。

スタックマシンは仮想マシンとしても非常に一般的で、Java、.Net、WASM の仮想マシンはいずれもスタックマシンである。スタックマシンは実装が容易であり、コンパイラを作成するのも簡単である。インタープリタの実装がいかに容易であるかはすでに見たが、ではなぜスタックマシンや仮想マシン全般に関心をもつべきなのかといえば、よく言われる理由はパフォーマンスである。仮想マシンを実装することでインタープリタでは実現が難しい最適化への道が拓ける。仮想マシンは高い柔軟性も提供してくれる。仮想マシンの実行を追跡、調査するのは簡単でデバッグしやすい。さらに、異なるプラットフォームや言語に移植するのも簡単である。仮想マシンおよびその上で実行されるコードはいずれも非常にコンパクトであることが多く、組み込み機器に適している。

ここではパフォーマンスに焦点を当てる。コンパイラや仮想マシンの最適化は軽く一冊の本を費やせる分野であり詳細に踏み込むつもりはないが、その入り口に足を踏み入れ、中をのぞき込んでみたい。

15.3.2 コンパイル

まずはコンパイルについて簡単に説明しよう。コンパイラは、プログラムをある表現形式から別の表現形式へ変換するものである。本書の場合、レイフィケーションされたコンストラクタとコンビネータからなる代数的データ型として表現されたプログラムを、仮想マシン用の命令セットへと変換する。仮想マシン自体は、その命令セットのインタープリタである。計算は最終的にはかならずインタープリタによる実行に行き着く。ハードウェアの CPU もまた、機械語を解釈するインタープリタに過ぎない。

ここではプログラムの概念がふたつと、それに対応するふたつの命令セットがあることに注目してほしい。ひとつ目は、構造的に再帰的なインタープリタが実行するプログラムであり、その命令セットはレイフィケーションされたコンストラクタとコンビネータから成る。そしてもうひとつは、そのプログラムをスタックマシン用の命令セットにコンパイルして得られるプログラムである。本書では、これらをそれぞれ「インタープリタプログラムと命令セット」「スタックマシンプログラムと命令セット」と呼ぶことにする。

構造的に再帰的なインタープリタは、ツリーウォーク型インタープリタまたは抽象構文木(AST)インタープリタの一例である。一方、スタックマシンはバイトコードインタープリタの例である。

15.4 インタープリタからスタックマシンへ

インタープリタからスタックマシンへの変換は次の三つの部分から構成される。

  1. スタックマシンが実行する命令セットの作成
  2. インタープリタプログラムをスタックマシンプログラムに変換するコンパイラの作成
  3. 命令を実行するスタックマシンの実装

以前実装した算術インタープリタを振り返って、これを具体的に考えてみよう。

enum Expression {
  def +(that: Expression): Expression = Addition(this, that)
  def *(that: Expression): Expression = Multiplication(this, that)
  def -(that: Expression): Expression = Subtraction(this, that)
  def /(that: Expression): Expression = Division(this, that)

  def eval: Double =
    this match {
      case Literal(value)              => value
      case Addition(left, right)       => left.eval + right.eval
      case Subtraction(left, right)    => left.eval - right.eval
      case Multiplication(left, right) => left.eval * right.eval
      case Division(left, right)       => left.eval / right.eval
    }

  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)
}
object Expression {
  def literal(value: Double): Expression = Literal(value)
}

インタープリタプログラムはインタープリタ用の命令セットによって定義される。

enum Expression {
  case Literal(value: Double)
  case Addition(left: Expression, right: Expression)
  case Subtraction(left: Expression, right: Expression)
  case Multiplication(left: Expression, right: Expression)
  case Division(left: Expression, right: Expression)
}

インタープリタの命令セットをスタックマシンの命令セットに変換する際の対応関係は以下のとおりである。

算術インタープリタの命令セットに目を向けよう。Literal が唯一のコンストラクタであり、スタックマシンの命令セットにもこれに対応するものが必要だということがわかる。ここでは、インタープリタの命令セットを Op(“operation” の略)と名付け、インタープリタの命令セットとの区別を明確にするために LiteralLit に短縮している。

enum Op {
  case Lit(value: Double)
}

それ以外の命令はすべてコンビネータである。これらはすべて Expression 型の値しか保持していないので、それに対応する値はスタックマシンではスタック上に存在する。このことから、スタックマシンの完全な命令セットは次のように定義される。

enum Op {
  case Lit(value: Double)
  case Add
  case Sub
  case Mul
  case Div
}

これで最初のステップは完了である。次のステップではコンパイラを実装する。スタックマシン向けのコンパイルの秘訣は、命令を逆ポーランド記法(reverse polish notation, RPN)に変換することである。RPN では演算子がオペランドの後に来る。たとえば 1 + 21 2 + と表現する。これはスタックマシンの動作順序そのものである。1 + 2 を評価するには、まず 1 をスタックにプッシュし、次に 2 をプッシュし、最後にこれらの値を両方ポップして加算を行い、結果をスタックに戻す。RPN ではネストも必要ない。たとえば 1 + (2 + 3) を RPN で表現すると 2 3 + 1 + となる。括弧を除去できるため、スタックマシンプログラムはツリーではなく直線的な一連の命令として表現できる。具体的には List[Op] で表現することが可能である。

RPN への変換をどのように実装すればよいか考えよう。ここではインタープリタの命令セットは代数的データ型である。それに対して変換を行うのだから、構造的再帰を用いることができる。以下のコードはその一例である。リスト同士の連結は遅く、それを用いるこの実装は効率的ではないが、本書の目的にとって問題にはならない。

def compile: List[Op] =
  this match {
    case Literal(value) => List(Op.Lit(value))
    case Addition(left, right) =>
      left.compile ++ right.compile ++ List(Op.Add)
    case Subtraction(left, right) =>
      left.compile ++ right.compile ++ List(Op.Sub)
    case Multiplication(left, right) =>
      left.compile ++ right.compile ++ List(Op.Mul)
    case Division(left, right) =>
      left.compile ++ right.compile ++ List(Op.Div)
  }

これで残りはスタックマシンを実装するだけとなった。スタックマシンのインターフェースを大まかに描き出すところから始めよう。

final case class StackMachine(program: List[Op]) {
  def eval: Double = ???
}

この設計では StackMachine インスタンスごとにプログラムが固定されるが、そのプログラムは複数回実行することができる。

次に eval を実装する。この関数は代数的データ型に対する構造的再帰である。今回のケースでは List[Op] 型の program がその代数的データ型にあたる。ただし、スタックを実装する必要がある分だけ、これまで見てきた構造的再帰よりもすこし複雑である。スタックは List[Double] 型データとして表現し、値をプッシュおよびポップするメソッドを定義する。

final case class StackMachine(program: List[Op]) {
  def eval: Double = {
    def pop(stack: List[Double]): (Double, List[Double]) =
      stack match {
        case head :: next => (head, next)
        case Nil =>
          throw new IllegalStateException(
            s"The data stack does not have any elements."
          )
      }

    def push(value: Double, stack: List[Double]): List[Double] =
      value :: stack

    ???
  }
}

これで、スタックマシンのメインループを定義できる。このループは、プログラムとスタックをパラメータとして受け取り、プログラムに対する構造的再帰として動作する。

def eval: Double = {
  // ここに pop と push が定義されている

  def loop(stack: List[Double], program: List[Op]): Double =
    program match {
      case head :: next =>
        head match {
          case Op.Lit(value) => loop(push(value, stack), next)
          case Op.Add =>
            val (a, s1) = pop(stack)
            val (b, s2) = pop(s1)
            val s = push(a + b, s2)
            loop(s, next)
          case Op.Sub =>
            val (a, s1) = pop(stack)
            val (b, s2) = pop(s1)
            val s = push(a + b, s2)
            loop(s, next)
          case Op.Mul =>
            val (a, s1) = pop(stack)
            val (b, s2) = pop(s1)
            val s = push(a + b, s2)
            loop(s, next)
          case Op.Div =>
            val (a, s1) = pop(stack)
            val (b, s2) = pop(s1)
            val s = push(a + b, s2)
            loop(s, next)
        }

      case Nil => stack.head
    }

  loop(List.empty, program)
}

このコード用に簡単なベンチマーク(GitHubリポジトリを参照)を実装してみたところ、当初のインタープリタよりも約5倍遅いことがわかった。明らかに何らかの最適化が必要である。

15.4.1 副作用をもつインタープリタ

インタープリタ戦略を使用する理由のひとつは、状態や入出力のような副作用を分離するためである。インタープリタは副作用をもつことができるが、実行するプログラムについて論理的な推論や合成を行う能力がそれによって損なわれることはない。ときには、副作用そのものがインタープリタの主目的となる。プログラムがネットワークデータの解析や画面への描画といった副作用的なアクションを記述し、それをインタープリタが実行する場合がそうである。一方で、副作用が単なる最適化に過ぎない場合もある。今回の算術スタックマシンではこの形で副作用を使用する。

先ほど作成したスタックマシンには多くの非効率が存在する。List はスタックとプログラムいずれのデータ構造としても適切な選択とは言えない。固定されたサイズをもつ Array を用いれば、多くのポインタ追跡やメモリ割当を回避できる。プログラムのサイズは変化しないし、十分な大きさのスタックを確保しておけばリサイズが発生する可能性を極めて小さくできる。また、プッシュやポップのような間接的な操作を避け、スタック配列を直接操作することも可能である。

以下のコードはその単純な実装である。この実装は、私のベンチマークではツリーウォーク型インタープリタより約30%高速であることが確認された。

final case class StackMachine(program: Array[Op]) {
  // The data stack
  private val stack: Array[Double] = Array.ofDim[Double](256)

  def eval: Double = {
    // sp はスタック上の最初の空き領域を指すインデックス
    // stack(sp - 1) がスタックの一番上にあるデータ
    //
    // pc はプログラム内の現在の命令を指すインデックス
    def loop(sp: Int, pc: Int): Double =
      if (pc == program.size) stack(sp - 1)
      else
        program(pc) match {
          case Op.Lit(value) =>
            stack(sp) = value
            loop(sp + 1, pc + 1)
          case Op.Add =>
            val a = stack(sp - 1)
            val b = stack(sp - 2)
            stack(sp - 2) = (a + b)
            loop(sp - 1, pc + 1)
          case Op.Sub =>
            val a = stack(sp - 1)
            val b = stack(sp - 2)
            stack(sp - 2) = (a - b)
            loop(sp - 1, pc + 1)
          case Op.Mul =>
            val a = stack(sp - 1)
            val b = stack(sp - 2)
            stack(sp - 2) = (a * b)
            loop(sp - 1, pc + 1)
          case Op.Div =>
            val a = stack(sp - 1)
            val b = stack(sp - 2)
            stack(sp - 2) = (a / b)
            loop(sp - 1, pc + 1)
        }

    loop(0, 0)
  }
}

15.4.2 さらなる最適化

上記の最適化がもっとも明白で実装しやすいものと思われる。本節では、文献で紹介されているいくつかの手法に目を向け、最適化ををさらに進めてみることにする。速いコードに直結する道が常に存在するわけではないことがわかるだろう。

使用したベンチマークは単純な再帰的フィボナッチ計算である。控えめな大きさの n を選んだとしても、n 番目のフィボナッチ数の計算には大きな式が生成される。n = 25 なら式の要素数は100万を超える。注目すべきは、式が加算のみで構成され、使用されるリテラルが 01 だけだということで、これにより広範な入力に対する最適化の適用可能性は制限される。だが、目的は今回のケースに特化して最適化されたインタープリタを作成することではない。最適化の可能性、そして一般的にインタープリタの最適化を試みたときに生じる問題について議論することである。

ここでは4つの異なる最適化手法を取り上げる。いずれも先述の最適化されたスタックマシンを基盤としている。

以下は、AMD Ryzen 5 3600 と Apple M1 のベンチマーク結果である。どちらも JDK 21 を使用している。結果は秒あたりの操作回数で示されている。Baseline は構造的再帰を使用しているインタープリタで、Stack インタープリタはスタックとプログラムを List で表現している。Optimized Stack はスタックとプログラムを配列で表現しており、その他のインタープリタは Optimized Stack を基盤として上述の最適化を追加している。All インタープリタはすべての最適化を取り入れたものである。

Interpreter Ryzen 5 Speedup M1 Speedup
Baseline 2754.43 1 3932.93 1
Stack 676.43 0.25 1004.16 0.26
Optimized Stack 3631.19 1.32 2953.21 0.75
Algebraic Simplification 1630.93 0.59 4818.45 1.23
Byte Code 4057.11 1.47 3355.75 0.85
Stack Caching 3698.10 1.34 3237.17 0.82
Superinstructions 3706.10 1.35 4689.02 1.19
All 7612.45 2.76 7098.06 1.80

この結果から学べることはいくつかあるが、もっとも重要なのは性能は合成的ではないという点だと思う。ふたつの最適化を適用した結果は、それぞれの最適化を個別に適用した場合の性能向上の単純な合計になるわけではない。ほとんどの最適化は Optimized Stack インタープリタと比較して個別ではほとんど性能に影響を与えていない。しかし、それらを組み合わせると大幅な改善がもたらされる。

基本的な構造的再帰を用いる Baseline インタープリタは驚くほど高速で、Ryzen 5 では Optimized Stack インタープリタよりすこし遅いが、M1 では逆に高速である。スタックマシンはプロセッサの組み込みコールスタックをエミュレートする。ネイティブのコールスタックは極めて高速なので、それを使わないことには相応の理由が必要になる。

最適化では細部が非常に重要である。Stack と Optimized Stack の間に見られる大きな違いは、データ構造の選択によって生み出されたものである。Byte Code インタープリタの初期バージョンは Optimized Stack よりも性能が悪かった。リテラルをバイトコードと一緒に保存していたのと、Double 値を Array[Byte] から ByteBuffer を使用して読み込む処理が低速だったのが原因だと思われる。スーパーインストラクションは、どのような命令を統合したか、その選択に大きく依存する。スタック上の値にリテラルを加算するスーパーインストラクションは単体ではほとんど効果がなく、むしろ Ryzen 5 では性能が低下した。

コンパイラ、特に JIT コンパイラは理解が難しい。たとえば、Algebraic Simplification インタープリタが Ryzen 5 で非常に遅い理由は説明できない。このインタープリタが行う作業は Optimized Stack インタープリタと比べて明らかに少ない。今回実装したインタープリタ最適化と同様に、コンパイラの最適化はアルゴリズムによって認識される特定のケースでのみ機能する。アルゴリズムが期待するパターンにコードが合致しない場合、最適化は適用されず、奇妙な性能の急落を招くことがある。おそらく、今回の実装の何らかの部分がそのような問題に引っかかったのだろう。

最後に、プラットフォーム間の違いも重要である。性能の違いのうち、どの程度がコンピュータのアーキテクチャによるもので、どの程度が JVM の違いによるものなのかを知るのは難しい。いずれにせよ、大多数のユーザがどのプラットフォームでプログラムを実行することになるのかを意識し、あるプラットフォームでの性能が他のプラットフォームにそのまま適用されるとナイーブに想定しないようにすべきである。

15.5 まとめ

この章では、インタープリタの最適化におけるふたつの主要な手法である、プログラムの代数的簡略化と仮想マシンによる解釈について考察した。

正規表現の微分アルゴリズムは Regular-expression derivatives reexamined から採用した。今回は触れなかったが、性能を本当に重視するのであれば、正規表現の有限状態機械へのコンパイルを検討したほうがよい。有限状態機械はある種の仮想マシンである。正規表現の微分は実装が非常に簡単で、代数的簡略化の意義をよく示している。ただし、この手法では入力文字ごとに微分を再計算する必要がある。一方で、正規表現を事前に有限状態機械にコンパイルしておけば、入力の解析時に時間を節約できる。このアルゴリズムの詳細は論文に記載されている。

このアイデアは、1964年に発表された Derivatives of Regular Expressions に基づいている。計算機科学の理論的分野に精通した人ならすぐにこの論文のスタイルを認識できるだろう。「状態図の構築」などのアナクロな言葉が登場するのは、この研究が計算機科学の黎明期に行われた名残である。正規表現の微分は文脈自由文法にも拡張可能であり、パーサの実装にも利用できる。それについては Parsing with Derivatives で論じられている。

多くの研究によって、インタープリタをコンパイラや仮想マシンに体系的に変換する手法が探求されてきた。From Interpreter to Compiler and Virtual Machine: A Functional Derivation はその初期の例である。より最近の例としては Calculating Correct Compilers があり、後続の関連論文では、この手法をさまざまな方向に拡張している。

インタープリタとその最適化は極めて広大な研究分野である。私自身が強い興味をもっている分野であることから、この節では参考文献をやや多めに集めた。

本章では、代数的簡略化、バイトコード、スタックキャッシング、スーパーインストラクションという4つの最適化手法を見てきた。代数的簡略化は代数と同じくらい古い歴史をもち、中高校生にも馴染みのあるものである。コンパイラの分野では、代数的簡略化の別の側面が、定数畳み込み(constant folding)・定数伝播(constant propagation)・共通部分式除去(common subexpression elimination)として知られている。バイトコードはおそらくインタープリタと同じくらい古く、少なくとも1960年代の p-code の形にまで遡ることができる。Stack Caching for Interpreters ではスタックキャッシングのアイデアが紹介されており、本章で扱ったシンプルなシステムよりもかなり複雑な実現方法を示している。スーパーインストラクションは Optimizing an ANSI C interpreter with superoperators で紹介された。Towards Superinstructions for Java Interpreters は JVM のインタープリタ実行にスーパーインストラクションを適用した好例である。

次に命令ディスパッチについて話そう。これは最適化としては考慮しなかった領域である。命令ディスパッチとは、インタープリタが自らを構成する処理ロジックの中から特定の命令に対応するものを選んで実行するプロセスを指す。The Structure and Performance of Efficient Interpreters は、命令ディスパッチがインタープリタの実行時間の大部分を占めると主張している。本章で使用した方法は、文献では一般にスイッチディスパッチと呼ばれているものである。これにはいくつかの代替手法が存在する。そのひとつである直接スレッディングについては Threaded Code に記述されている。この手法では、命令はそれを実装する関数として表現される。これにはファーストクラス関数と末尾呼び出し最適化が必要であり、一般に最速のディスパッチ形式とされている。この手法はデータと関数の双対性に依存していることに注意が必要である。サブルーチンスレッディングは直接スレッディングに似ているが、末尾呼び出しの代わりに通常の呼び出しと戻りを使用する。Indirect Threaded Code で論じられている間接スレッデッドコードでは、実装関数を指すルックアップテーブルのインデックスがバイトコードとして用いられる。

インタープリタの実装に使われる仮想マシンはスタックマシンだけではない。レジスタマシンはもっとも一般的な代替手法である。たとえば Lua の仮想マシンはレジスタマシンとして実装されている。Virtual Machine Showdown: Stack Versus Registers は両者を比較し、レジスタマシンのほうが速いと結論付けている。ただし、実装はレジスタマシンのほうが複雑である。

汎用的なスタックベースの命令セットの設計に興味があるなら、Bringing the Web up to Speed with WebAssembly を読むとよいだろう。この論文では WebAssembly の設計とその設計が選択された背景について述べられている。また、WebAssembly 用のインタープリタについては A Fast In-Place Interpreter for WebAssembly で解説されている。その議論の中で末尾呼び出しについて頻繁に言及されていることにも注目してほしい。

16 Creating Usable Code

APIs are interfaces and should be designed as such.

scala.annotation.implicitNotFound and scala.annotation.implicitAmbiguous

17 ケーススタディ: 非同期処理のテスト

簡単なケーススタディから始めよう。非同期なコードを同期化することで単体テストをシンプルにする方法について考える。

12章で示した、サーバの稼働時間を計測する例に戻ろう。このコードに肉付けを行い、より完全な形へと近づける。ここではふたつのコンポーネントを作成する。ひとつは、リモートサーバから稼働時間を取得する UptimeClient である。

import scala.concurrent.Future

trait UptimeClient {
  def getUptime(hostname: String): Future[Int]
}

もうひとつは UptimeService で、サーバのリストを管理し、利用者がそれらの総稼働時間を取得できるようにする。

import cats.instances.future._ // Applicative
import cats.instances.list._   // Traverse
import cats.syntax.traverse._  // traverse
import scala.concurrent.ExecutionContext.Implicits.global

class UptimeService(client: UptimeClient) {
  def getTotalUptime(hostnames: List[String]): Future[Int] =
    hostnames.traverse(client.getUptime).map(_.sum)
}

UptimeClient をトレイトとしてモデリングしたのは、単体テストでスタブ化するためである。たとえば、次に示すようにダミーデータを提供するテストクライアントを作成することができる。

class TestUptimeClient(hosts: Map[String, Int]) extends UptimeClient {
  def getUptime(hostname: String): Future[Int] =
    Future.successful(hosts.getOrElse(hostname, 0))
}

ここで、UptimeService の単体テストを書くことを考える。このサービスが値をどのように取得しているかには触れず、それを合計する能力をテストしたい。以下に例を示す。

def testTotalUptime() = {
  val hosts    = Map("host1" -> 10, "host2" -> 6)
  val client   = new TestUptimeClient(hosts)
  val service  = new UptimeService(client)
  val actual   = service.getTotalUptime(hosts.keys.toList)
  val expected = hosts.values.sum
  assert(actual == expected)
}
// error: 
// Values of types scala.concurrent.Future[Int] and Int cannot be compared with == or !=

だが、このアプリケーションが非同期で実行されることを考慮していなかったというありがちなミス22のせいで、このコードはコンパイルできない。actualFuture[Int] 型で、expectedInt 型である。これらを直接比較することはできない。

これを解決する方法はいくつかある。テストコードを非同期処理に対応させることもできるが、別の選択肢もある。サービスクラスのコードを同期的にして先ほどのテストを変更することなく動作させてみよう。

17.1 型コンストラクタの抽象化

UptimeClient には、本番で使う非同期的なものと単体テストで使う同期的なもの、二種類の実装が必要となる。

trait RealUptimeClient extends UptimeClient {
  def getUptime(hostname: String): Future[Int]
}

trait TestUptimeClient extends UptimeClient {
  def getUptime(hostname: String): Int
}

問題は UptimeClient における抽象メソッドの戻り値型を何にするかである。Future[Int]Int を抽象化する必要がある。

trait UptimeClient {
  def getUptime(hostname: String): ???
}

両方の型がもつ Int の部分を保持しつつ、テストコードでは Future の部分を取り除きたいということである。一見これは難しく思われるかもしれないが、幸いなことに Cats は Id 型という解決策を提供している。Id については9.3節で取り上げた。これを使えば、型をその意味を変えることなく型コンストラクタでラップすることができる。

package cats

type Id[A] = A

Id を使えば UptimeClient の戻り値型を抽象化できる。これを以下のとおり実装せよ。

以下に実装例を示す。

import cats.Id

trait UptimeClient[F[_]] {
  def getUptime(hostname: String): F[Int]
}

trait RealUptimeClient extends UptimeClient[Future] {
  def getUptime(hostname: String): Future[Int]
}

trait TestUptimeClient extends UptimeClient[Id] {
  def getUptime(hostname: String): Id[Int]
}

Id[A] は単なる A のエイリアスにすぎない。そのため、TestUptimeClient において型を Id[Int] とする必要はなく、単に Int と書くことができる。

trait TestUptimeClient extends UptimeClient[Id] {
  def getUptime(hostname: String): Int
}

もちろん、厳密に言えば RealUptimeClientTestUptimeClientgetUptime を再定義する必要はないが、これらを書き出すことは技法の詳細を明らかにする助けとなるだろう。

これで、TestUptimeClient の定義を、以前と同じように Map[String, Int] を用いた完全なクラスへと肉付けすることができるはずである。これを実装せよ。

最終的なコードは元々の TestUptimeClient 実装と似たものとなる。ただし、Future.successful 呼び出しはもう必要ない。

class TestUptimeClient(hosts: Map[String, Int])
  extends UptimeClient[Id] {
  def getUptime(hostname: String): Int =
    hosts.getOrElse(hostname, 0)
}

17.2 モナドの抽象化

UptimeService に目を向けよう。二種類の UptimeClient を抽象化できるようにこれを書き直す必要がある。この作業をふたつのステップに分けて行う。まず、クラスとメソッドのシグネチャを書き換え、それからメソッドの本体を修正する。以下のとおりメソッドシグネチャを書き換えよ。

コードは以下のようになるはずである。

class UptimeService[F[_]](client: UptimeClient[F]) {
  def getTotalUptime(hostnames: List[String]): F[Int] =
    ???
    // hostnames.traverse(client.getUptime).map(_.sum)
}

続いて、getTotalUptime のボディ部のコメントアウトをもとに戻す。次のようなコンパイルエラーが出力されるはずである。

// <console>:28: error: could not find implicit value for
//               evidence parameter of type cats.Applicative[F]
//            hostnames.traverse(client.getUptime).map(_.sum)
//                              ^

問題は、traverseApplicative インスタンスをもつ値のシーケンスに対してしか使えないということである。元のコードでは List[Future[Int]] をトラバースしていた。Future には Applicative インスタンスが存在するので問題はなかった。しかし、今回のケースではトラバース対象が List[F[Int]] であるため、FApplicative をもっていることをコンパイラに証明する必要がある。UptimeService のコンストラクタに暗黙のパラメータを追加し、これを実現せよ。

これは暗黙パラメータを用いて以下のように書くことができる。

import cats.Applicative
import cats.syntax.functor._ // map

class UptimeService[F[_]](client: UptimeClient[F])
    (implicit a: Applicative[F]) {

  def getTotalUptime(hostnames: List[String]): F[Int] =
    hostnames.traverse(client.getUptime).map(_.sum)
}

もしくはコンテキスト境界を使えばもっと簡潔に記述できる。

class UptimeService[F[_]: Applicative]
    (client: UptimeClient[F]) {

  def getTotalUptime(hostnames: List[String]): F[Int] =
    hostnames.traverse(client.getUptime).map(_.sum)
}

cats.Applicative だけでなく cats.syntax.functor もインポートする必要がある点に注意してほしい。Futuremap メソッドの代わりに Cats が提供する拡張メソッドの map を使っているが、これが Functor 型の暗黙パラメータを必要とするからである。

最後に単体テストに目を向けよう。これまでに加えた修正によって、テストコードは何も変更しなくても意図したとおりに動作する。TestUptimeClient のインスタンスを作成し、それを UptimeService にラップすることで、FId にバインドされ、残りのコードはモナドやアプリカティブのことを気にせず同期的に動作できるようになる。

def testTotalUptime() = {
  val hosts    = Map("host1" -> 10, "host2" -> 6)
  val client   = new TestUptimeClient(hosts)
  val service  = new UptimeService(client)
  val actual   = service.getTotalUptime(hosts.keys.toList)
  val expected = hosts.values.sum
  assert(actual == expected)
}

testTotalUptime()

17.3 まとめ

このケーススタディで示したのは、Cats を用いて異なる計算シナリオを抽象化する例である。非同期コードと同期コードを抽象化するために Applicative 型クラスを使用した。関数型の抽象化を用いることで、実装の詳細を気にすることなく、実行したい一連の計算を記述することができる。

図10では、まさにこの種の抽象化のために設計された計算型クラスのスタックを図示している。FunctorApplicativeMonadTraverse といった型クラスは、マッピング、結合、順次実行、反復などのパターンの抽象的な実装を提供する。これらの型は、その数学的な法則によって、一貫したセマンティクスに基づく挙動を保証されている。

このケーススタディでは Applicative を使用した。この型クラスが、今回必要とする最低限の能力をもつものだったからである。もし flatMap が必要だったなら Applicative の代わりに Monad を使うこともできたし、異なるシーケンス型の抽象化を求められていたなら Traverse を使うこともできた。また、計算の成功だけでなく、失敗をモデリングする ApplicativeErrorMonadError のような型クラスも存在する。

次はもっと複雑なケーススタディに進もう。型クラスを活用して、並列処理のための MapReduce スタイルのフレームワークを作るという興味深い事例を取り上げる。

18 ケーススタディ: MapReduce

このケーススタディでは、MonoidFunctor およびその他さまざまな便利な機能を用いて、シンプルながら強力な並列処理フレームワークを実装する。

もし Hadoop を使ったことがある、もしくはビッグデータを取り扱う仕事をしたことがあるなら、MapReduce を聞いたことがあるだろう。MapReduce は、複数マシン(いわゆるノード)のクラスタ上で並列データ処理を行うためのプログラミングモデルである。その名前が示すように、このモデルは map フェーズと reduce フェーズを中心に構築されている23。ここでいう map とは Scala や Functor 型クラスでおなじみの map 関数と同じものであり、reduce フェーズは、Scala で通常 fold と呼ばれる操作を指す。

18.1 並列化された mapfold

すでに学んだとおり、map の一般的なシグネチャは A => B 型の関数を F[A] に適用して F[B] を返すというものである。

generic-map Created with Sketch. F[A] F[B] A => B map
Figure 15: Type chart: functor map

map はシーケンス内の各要素を個別に変換する。異なる要素に適用される変換処理の間には依存関係がないので、この処理は容易に並列化できる。型で表現されない副作用を用いないと仮定すれば、関数 A => B の型シグネチャがこの性質、すなわち変換処理の独立性を示している。

fold はどうだろうか。このステップは Foldable インスタンスを用いて実装できる。すべてのファンクターが Foldable インスタンスをもつわけではないが、これら両方の型クラスを備えたデータ型を基礎として MapReduce システムは構築される。Reduce ステップでは、分散処理された map の結果に対して foldLeft を適用することになる。

generic-foldleft Created with Sketch. F[A] B (B, A) => B B foldLeft ,
Figure 16: Type chart: fold

Reduce ステップを分散処理する場合、計算順序についてのコントロールは失われる。Reduce 処理全体としては必ずしも完全に左から右という順序にはならず、複数の部分シーケンスを左から右に Reduce してからその結果を結合する流れとなる。結果の正しさを保証するには、Reduce 処理は結合的でなくてはならない。

reduce(a1, reduce(a2, a3)) == reduce(reduce(a1, a2), a3)

結合律が満たされていれば、Reduce 処理を複数ノードに自由に分散することができる。ただし、各ノード内の部分シーケンスは元のデータセットにおける順序を保持する必要がある。

fold は計算の初期値として B 型の要素を必要とする。fold が任意の数の並列ステップに分割される可能性を考えると、初期値は計算結果に影響を与えてならない。このことは、初期値が単位元であることを要請する。

reduce(seed, a1) == reduce(a1, seed) == a1

まとめると、並列化された fold が正しい結果を得るには、以下の条件を満たす必要がある。

このパターン、どこかで聞き覚えがないだろうか。そのとおり、話は再び Monoid に戻る。これは本書で最初に紹介した型クラスである。モノイドの重要性はすでに多くの人に認識されており、MapReduce ジョブにおけるモノイド設計パターンは、Twitter の Summingbird のような最近のビッグデータシステムの中核となっている。

このプロジェクトでは、非常にシンプルな単一マシン上の MapReduce を実装する。データフローをモデル化するために、まず foldMap というメソッドを実装するところから始めよう。

18.2 foldMap の実装

foldMap については、以前 Foldable を扱った際に簡単に見た。これは foldLeftfoldRight の上に構築される派生操作のひとつである。ただし、今回は Foldable を使わず foldMap を自分たちで再実装する。そうすることで MapReduce の構造に関する有用な洞察を得ることができる。

まずは foldMap のシグネチャを記述せよ。なお、この関数は以下のパラメータを受け取る必要がある。

この型シグネチャを完成させるには、暗黙パラメータもしくはコンテキスト境界を追加する必要がある。

import cats.Monoid

/** シングルスレッドの MapReduce 関数。
  * `values`  `func` でマップし、`Monoid[B]` によって畳み込む。
  */
def foldMap[A, B: Monoid](values: Vector[A])(func: A => B): B =
  ???

つづいて foldMap の本体を実装せよ。必要な手順のガイドとして図17のフローチャートをもちいること。

  1. A の要素をもつシーケンスを用意する
  2. それを func でマップし、型 B の要素をもつシーケンスを生成する
  3. Monoid を用い、シーケンスを単一の B 型の値へと畳み込む
fold-map Created with Sketch. 4. Final result 3. Fold/reduce step 2. Map step 1. Initial data sequence
Figure 17: foldMap algorithm

参考までに、以下に出力のサンプルを示す。

import cats.instances.int._ // Monoid
foldMap(Vector(1, 2, 3))(identity)
// res21: Int = 6
import cats.instances.string._ // Monoid
// String へのマッピングでは連結モノイドが使われる。
foldMap(Vector(1, 2, 3))(_.toString + "! ")
// res22: String = "1! 2! 3! "

// 各文字を String へマッピングして再び String を作る。
foldMap("Hello world!".toVector)(_.toString.toUpperCase)
// res23: String = "HELLO WORLD!"

B に対する Monoid を受け取るために型シグネチャを変更する必要がある。そうすることで、7.3.3節で述べた Monoid.empty|+| 構文が使えるようになる。

import cats.Monoid
import cats.syntax.semigroup._ // |+|

def foldMap[A, B : Monoid](as: Vector[A])(func: A => B): B =
  as.map(func).foldLeft(Monoid[B].empty)(_ |+| _)

このコードをすこし書き換えて、すべてを一度に行うこともできる。

def foldMap[A, B : Monoid](as: Vector[A])(func: A => B): B =
  as.foldLeft(Monoid[B].empty)(_ |+| func(_))

18.3 foldMap の並列化

シングルスレッドの foldMap 実装が動作するようになったので、次はこれを並列実行するための分散処理に目を向けてみよう。ここでは、先ほどのシングルスレッド版 foldMap を構成要素として利用する。

これから実装するのは、複数の CPU を使って並列処理を行うバージョンである。これは、図18に示されている MapReduce クラスタにおける処理分散の仕組みを模している。

  1. 処理対象となるすべてのデータを最初に用意する
  2. データをいくつかのバッチに分割し、それぞれを各 CPU に送る
  3. Map フェーズを各 CPU がバッチ単位で並列に実行する
  4. Reduce フェーズを各 CPU がバッチ単位で並列に実行し、バッチごとの結果を得る
  5. 各バッチの結果をひとつに畳み込んで最終結果を得る
parallel-fold-map Created with Sketch. 6. Final result 5. Reduce the batches 4. Reduce each batch in parallel 3. Map over the batches in parallel 2. Divide into batches for each CPU 1. Initial data sequence
Figure 18: parallelFoldMap algorithm

Scala には、スレッド間で仕事を分配するための簡単なツールが用意されている。たとえば parallel collections library を使ってこの課題を実装することも可能である。しかし今回はもうすこし深く掘り下げ、Future を用いて自前でアルゴリズムを実装することに挑戦してみたい。

18.3.1 Future とスレッドプールと ExecutionContext

Future がモナド的であることについてはすでにかなり理解が進んでいる。それを簡単におさらいし、Scala の Future が裏側でどのようにスケジューリングされているのかを確認しよう。

Future はスレッドプール上で実行される。そのプールは暗黙の ExecutionContext パラメータによって決定される。Future.apply やその他のコンビネータによって Future を作成するときは必ず、スコープ内に暗黙の ExecutionContext が存在していなければならない。

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
val future1 = Future {
  (1 to 100).toList.foldLeft(0)(_ + _)
}
// future1: Future[Int] = Future(Success(5050))

val future2 = Future {
  (100 to 200).toList.foldLeft(0)(_ + _)
}
// future2: Future[Int] = Future(Success(15150))

この例では ExecutionContext.Implicits.global をインポートしている。このデフォルトのコンテキストでは、マシン上の CPU ごとにひとつスレッドを割り当てたスレッドプールが使用される。Future を作成すると、ExecutionContext がその実行をスケジューリングする。プール内に空いているスレッドがあれば、Future は即座に実行を開始する。現代の多くのマシンはすくなくともふたつの CPU を備えているため、上記の例における future1future2 は並列に実行される可能性が高い。

他の Future の結果に基づいて処理をスケジューリングする新たな Future を作成するようなコンビネータもある。たとえば mapflatMap は、入力値が計算され、かつ利用可能な CPU があれば、即座に計算を実行する。

val future3 = future1.map(_.toString)
// future3: Future[String] = Future(Success(5050))

val future4 = for {
  a <- future1
  b <- future2
} yield a + b
// future4: Future[Int] = Future(Success(20200))

12.2節で見たように、Future.sequence を使えば List[Future[A]]Future[List[A]] に変換できる。

Future.sequence(List(Future(1), Future(2), Future(3)))
// res26: Future[List[Int]] = Future(Success(List(1, 2, 3)))

あるいは Traverse インスタンスを使ってもよい。

import cats.instances.future._ // Applicative
import cats.instances.list._   // Traverse
import cats.syntax.traverse._  // sequence
List(Future(1), Future(2), Future(3)).sequence
// res27: Future[List[Int]] = Future(Success(List(1, 2, 3)))

いずれの場合も ExecutionContext インスタンスが必要である。最後に、Await.result を使えば Future の結果を得られるまで待機することができる。

import scala.concurrent._
import scala.concurrent.duration._
Await.result(Future(1), 1.second) // 結果を待つ
// res28: Int = 1

また、cats.instances.future では Future に対する Monad および Monoid 実装も提供されている。

import cats.{Monad, Monoid}
import cats.instances.int._    // Monoid[Int]
import cats.instances.future._ // Future 用の Monad と Monoid

Monad[Future].pure(42)

Monoid[Future[Int]].combine(Future(1), Future(2))

18.3.2 Dividing Work

Future の復習を終えたところで、次は作業をどのようにバッチに分割すればよいか見ていこう。マシン上の利用可能な CPU 数は、Java の標準ライブラリが提供する API を使って調べることができる。

Runtime.getRuntime.availableProcessors
// res31: Int = 4

grouped メソッドを使えばシーケンス(実際には Vector を実装している任意のコレクション)を分割できる。このメソッドを用いて、各 CPU に割り当てるバッチを切り出す。

(1 to 10).toList.grouped(3).toList
// res32: List[List[Int]] = List(
//   List(1, 2, 3),
//   List(4, 5, 6),
//   List(7, 8, 9),
//   List(10)
// )

18.3.3 parallelFoldMap の実装

foldMap の並列版、parallelFoldMap を実装せよ。以下をその型シグネチャとする。

def parallelFoldMap[A, B : Monoid]
      (values: Vector[A])
      (func: A => B): Future[B] = ???

前述のテクニックを用いて作業を複数のバッチに分割する。バッチは CPU ごとにひとつ、各バッチを並列スレッドで処理する。全体のアルゴリズムを確認したければ図18を改めて参照するとよい。

可能であれば、各 CPU のバッチ処理には上で定義した foldMap 実装を再利用してほしい。

コードをフェーズごとに分けてコメントをつけた解答例を以下に示す。

def parallelFoldMap[A, B: Monoid]
      (values: Vector[A])
      (func: A => B): Future[B] = {
  // 各 CPU に渡すデータ件数を算出
  val numCores  = Runtime.getRuntime.availableProcessors
  val groupSize = (1.0 * values.size / numCores).ceil.toInt

  // データを CPU ごとのグループに分割
  val groups: Iterator[Vector[A]] =
    values.grouped(groupSize)

  // 各グループを foldMap するための Future インスタンスを作成
  val futures: Iterator[Future[B]] =
    groups map { group =>
      Future {
        group.foldLeft(Monoid[B].empty)(_ |+| func(_))
      }
    }

  // グループごとの結果を最終結果へと畳み込む
  Future.sequence(futures) map { iterable =>
    iterable.foldLeft(Monoid[B].empty)(_ |+| _)
  }
}

val result: Future[Int] =
  parallelFoldMap((1 to 1000000).toVector)(identity)
Await.result(result, 1.second)
// res34: Int = 1784293664

上で定義した foldMap を再利用すれば、もっと簡潔な解答が得られる。図18のステップ3と4にあたるバッチ単位でのマップと畳み込みは、単一の foldMap 呼び出しによって実現できる。これにより、全体のアルゴリズムは以下のとおり簡略化される。

def parallelFoldMap[A, B: Monoid]
      (values: Vector[A])
      (func: A => B): Future[B] = {
  val numCores  = Runtime.getRuntime.availableProcessors
  val groupSize = (1.0 * values.size / numCores).ceil.toInt

  val groups: Iterator[Vector[A]] =
    values.grouped(groupSize)

  val futures: Iterator[Future[B]] =
    groups.map(group => Future(foldMap(group)(func)))

  Future.sequence(futures) map { iterable =>
    iterable.foldLeft(Monoid[B].empty)(_ |+| _)
  }
}

val result: Future[Int] =
  parallelFoldMap((1 to 1000000).toVector)(identity)
Await.result(result, 1.second)
// res36: Int = 1784293664

18.3.4 Cats をもっと活用した parallelFoldMap 実装

先ほどは foldMap を自前で実装したが、このメソッドは12.1節で紹介した Foldable 型クラスにも含まれており、利用することができる。

Cats の FoldableTraverseable 型クラスを用いて parallelFoldMap を再実装せよ。

完全を期すために、必要なすべてのインポート文を改めて記載する。

import cats.Monoid

import cats.instances.int._    // Monoid
import cats.instances.future._ // Applicative と Monad
import cats.instances.vector._ // Foldable と Traverse

import cats.syntax.foldable._  // combineAll と foldMap
import cats.syntax.traverse._  // traverse

import scala.concurrent._
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global

メソッド本体にできるかぎり Cats を利用した parallelFoldMap 実装を以下に示す。

def parallelFoldMap[A, B: Monoid]
      (values: Vector[A])
      (func: A => B): Future[B] = {
  val numCores  = Runtime.getRuntime.availableProcessors
  val groupSize = (1.0 * values.size / numCores).ceil.toInt

  values
    .grouped(groupSize)
    .toVector
    .traverse(group => Future(group.toVector.foldMap(func)))
    .map(_.combineAll)
}
val future: Future[Int] =
  parallelFoldMap((1 to 1000).toVector)(_ * 1000)
Await.result(future, 1.second)
// res38: Int = 500500000

vector.grouped 呼び出しは Iterable[Iterator[Int]] を返す。このデータを Cats が理解できる形式に戻すため、コードのいくつかの場所で toVector を呼び出している。traverse 呼び出しは、バッチごとにひとつの Int 値をもった Future[Vector[Int]] インスタンスを生成する。そして map 呼び出しは FoldablecombineAll メソッドを用いて各バッチの結果をひとつにまとめている。

18.4 まとめ

このケーススタディでは、本来クラスタ上で行われる MapReduce を模倣したシステムを実装した。今回のアルゴリズムは次の三ステップで構成されていた。

  1. データをバッチに分割し、各バッチをそれぞれの「ノード」に送る
  2. バッチごとに MapReduce を実行する
  3. モノイドによる加法を用いて結果を統合する

このおもちゃのようなシステムは、Hadoop のような実際の MapReduce システムにおける分散処理の挙動を模倣している。とはいえ現実にはすべての処理は単一マシン上で行われており、ノード間通信のコストは無視できる。厳密に言えば、リストの並列処理を効率的に行うためにデータをバッチに分割する必要はない。単に Functor でマッピングし、Monoid で畳み込めばよい。

バッチ分割の戦略を採用するかどうかにかかわらず、マッピングおよび Monoid を用いた畳み込みは、強力かつ汎用的な枠組みである。それは加算や文字列連結といった単純な処理にとどまらない。データサイエンティストが日々の分析で行っている多くのタスクは、モノイドとして扱える。以下に挙げたものいずれにもモノイドは存在する。

これらはほんの一例にすぎない。

19 Case Study: Data Validation

In this case study we will build a library for validation. What do we mean by validation? Almost all programs must check their input meets certain criteria. Usernames must not be blank, email addresses must be valid, and so on. This type of validation often occurs in web forms, but it could be performed on configuration files, on web service responses, and any other case where we have to deal with data that we can’t guarantee is correct. Authentication, for example, is just a specialised form of validation.

We want to build a library that performs these checks. What design goals should we have? For inspiration, let’s look at some examples of the types of checks we want to perform:

With these examples in mind we can state some goals:

These goals assume we’re checking a single piece of data. We will also need to combine checks across multiple pieces of data. For a login form, for example, we’ll need to combine the check results for the username and the password. This will turn out to be quite a small component of the library, so the majority of our time will focus on checking a single data item.

19.1 Sketching the Library Structure

Let’s start at the bottom, checking individual pieces of data. Before we start coding let’s try to develop a feel for what we’ll be building. We can use a graphical notation to help us. We’ll go through our goals one by one.

Providing error messages

Our first goal requires us to associate useful error messages with a check failure. The output of a check could be either the value being checked, if it passed the check, or some kind of error message. We can abstractly represent this as a value in a context, where the context is the possibility of an error message as shown in Figure 19.

result Created with Sketch. F[A]
Figure 19: A validation result

A check itself is therefore a function that transforms a value into a value in a context as shown in Figure 20.

check Created with Sketch. A => F[A]
Figure 20: A validation check

Combine checks

How do we combine smaller checks into larger ones? Is this an applicative or semigroupal as shown in Figure 21?

applicative Created with Sketch. A => F[A] A => F[A] A => F[(A, A)] , ).tupled (
Figure 21: Applicative combination of checks

Not really. With applicative combination, both checks are applied to the same value and result in a tuple with the value repeated. What we want feels more like a monoid as shown in Figure 22. We can define a sensible identity—a check that always passes—and two binary combination operators—and and or:

monoid Created with Sketch. A => F[A] A => F[A] A => F[A] |+|
Figure 22: Monoid combination of checks

We’ll probably be using and and or about equally often with our validation library and it will be annoying to continuously switch between two monoids for combining rules. We consequently won’t actually use the monoid API: we’ll use two separate methods, and and or, instead.

Accumulating errors as we check

Monoids also feel like a good mechanism for accumulating error messages. If we store messages as a List or NonEmptyList, we can even use a pre-existing monoid from inside Cats.

Transforming data as we check it

In addition to checking data, we also have the goal of transforming it. This seems like it should be a map or a flatMap depending on whether the transform can fail or not, so it seems we also want checks to be a monad as shown in Figure 23.

monad Created with Sketch. A => F[B] B => (A => F[C]) A => F[C] flatMap A => F[B] B => C A => F[C] map
Figure 23: Monadic combination of checks

We’ve now broken down our library into familiar abstractions and are in a good position to begin development.

19.2 The Check Datatype

Our design revolves around a Check, which we said was a function from a value to a value in a context. As soon as you see this description you should think of something like

type Check[A] = A => Either[String, A]

Here we’ve represented the error message as a String. This is probably not the best representation. We may want to accumulate messages in a List, for example, or even use a different representation that allows for internationalization or standard error codes.

We could attempt to build some kind of ErrorMessage type that holds all the information we can think of. However, we can’t predict the user’s requirements. Instead let’s let the user specify what they want. We can do this by adding a second type parameter to Check:

type Check[E, A] = A => Either[E, A]

We will probably want to add custom methods to Check so let’s declare it as a trait instead of a type alias:

trait Check[E, A] {
  def apply(value: A): Either[E, A]

  // other methods...
}

As we said in Essential Scala, there are two functional programming patterns that we should consider when defining a trait:

Type classes allow us to unify disparate data types with a common interface. This doesn’t seem like what we’re trying to do here. That leaves us with an algebraic data type. Let’s keep that thought in mind as we explore the design a bit further.

19.3 Basic Combinators

Let’s add some combinator methods to Check, starting with and. This method combines two checks into one, succeeding only if both checks succeed. Think about implementing this method now. You should hit some problems. Read on when you do!

trait Check[E, A] {
  def and(that: Check[E, A]): Check[E, A] =
    ???

  // other methods...
}

The problem is: what do you do when both checks fail? The correct thing to do is to return both errors, but we don’t currently have any way to combine Es. We need a type class that abstracts over the concept of “accumulating” errors as shown in Figure 24 What type class do we know that looks like this? What method or operator should we use to implement the ? operation?

error-semigroup Created with Sketch. E • E => E List[String] • List[String] => List[String]
Figure 24: Combining error messages

We need a Semigroup for E. Then we can combine values of E using the combine method or its associated |+| syntax:

import cats.Semigroup
import cats.instances.list._   // for Semigroup
import cats.syntax.semigroup._ // for |+|

val semigroup = Semigroup[List[String]]
// Combination using methods on Semigroup
semigroup.combine(List("Badness"), List("More badness"))
// res3: List[String] = List("Badness", "More badness")

// Combination using Semigroup syntax
List("Oh noes") |+| List("Fail happened")
// res4: List[String] = List("Oh noes", "Fail happened")

Note we don’t need a full Monoid because we don’t need the identity element. We should always try to keep our constraints as small as possible!

There is another semantic issue that will come up quite quickly: should and short-circuit if the first check fails. What do you think the most useful behaviour is?

We want to report all the errors we can, so we should prefer not short-circuiting whenever possible.

In the case of the and method, the two checks we’re combining are independent of one another. We can always run both rules and combine any errors we see.

Use this knowledge to implement and. Make sure you end up with the behaviour you expect!

There are at least two implementation strategies.

In the first we represent checks as functions. The Check data type becomes a simple wrapper for a function that provides our library of combinator methods. For the sake of disambiguation, we’ll call this implementation CheckF:

import cats.Semigroup
import cats.syntax.either._    // for asLeft and asRight
import cats.syntax.semigroup._ // for |+|
final case class CheckF[E, A](func: A => Either[E, A]) {
  def apply(a: A): Either[E, A] =
    func(a)

  def and(that: CheckF[E, A])
        (implicit s: Semigroup[E]): CheckF[E, A] =
    CheckF { a =>
      (this(a), that(a)) match {
        case (Left(e1),  Left(e2))  => (e1 |+| e2).asLeft
        case (Left(e),   Right(_))  => e.asLeft
        case (Right(_),  Left(e))   => e.asLeft
        case (Right(_), Right(_)) => a.asRight
      }
    }
}

Let’s test the behaviour we get. First we’ll setup some checks:

import cats.instances.list._ // for Semigroup

val a: CheckF[List[String], Int] =
  CheckF { v =>
    if(v > 2) v.asRight
    else List("Must be > 2").asLeft
  }

val b: CheckF[List[String], Int] =
  CheckF { v =>
    if(v < -2) v.asRight
    else List("Must be < -2").asLeft
  }

val check: CheckF[List[String], Int] =
  a and b

Now run the check with some data:

check(5)
// res5: Either[List[String], Int] = Left(value = List("Must be < -2"))
check(0)
// res6: Either[List[String], Int] = Left(
//   value = List("Must be > 2", "Must be < -2")
// )

Excellent! Everything works as expected! We’re running both checks and accumulating errors as required.

What happens if we try to create checks that fail with a type that we can’t accumulate? For example, there is no Semigroup instance for Nothing. What happens if we create instances of CheckF[Nothing, A]?

val a: CheckF[Nothing, Int] =
  CheckF(v => v.asRight)

val b: CheckF[Nothing, Int] =
  CheckF(v => v.asRight)

We can create checks just fine but when we come to combine them we get an error we might expect:

val check = a and b
// error: 
// No given instance of type cats.kernel.Semigroup[Nothing] was found for parameter s of method and in class CheckF

Now let’s see another implementation strategy. In this approach we model checks as an algebraic data type, with an explicit data type for each combinator. We’ll call this implementation Check:

sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Either[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)) match {
          case (Left(e1),  Left(e2))  => (e1 |+| e2).asLeft
          case (Left(e),   Right(_))  => e.asLeft
          case (Right(_),  Left(e))   => e.asLeft
          case (Right(_), Right(_)) => a.asRight
        }
    }
}
object Check {
  final case class And[E, A](
    left: Check[E, A],
    right: Check[E, A]) extends Check[E, A]
  
  final case class Pure[E, A](
    func: A => Either[E, A]) extends Check[E, A]
    
  def pure[E, A](f: A => Either[E, A]): Check[E, A] =
    Pure(f)
}

Let’s see an example:

val a: Check[List[String], Int] =
  Check.pure { v =>
    if(v > 2) v.asRight
    else List("Must be > 2").asLeft
  }

val b: Check[List[String], Int] =
  Check.pure { v =>
    if(v < -2) v.asRight
    else List("Must be < -2").asLeft
  }

val check: Check[List[String], Int] =
  a and b

While the ADT implementation is more verbose than the function wrapper implementation, it has the advantage of cleanly separating the structure of the computation (the ADT instance we create) from the process that gives it meaning (the apply method). From here we have a number of options:

  • inspect and refactor checks after they are created;
  • move the apply “interpreter” out into its own module;
  • implement alternative interpreters providing other functionality (for example visualizing checks).

Because of its flexibility, we will use the ADT implementation for the rest of this case study.

Strictly speaking, Either[E, A] is the wrong abstraction for the output of our check. Why is this the case? What other data type could we use instead? Switch your implementation over to this new data type.

The implementation of apply for And is using the pattern for applicative functors. Either has an Applicative instance, but it doesn’t have the semantics we want. It fails fast instead of accumulating errors.

If we want to accumulate errors Validated is a more appropriate abstraction. As a bonus, we get more code reuse because we can lean on the applicative instance of Validated in the implementation of apply.

Here’s the complete implementation:

import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._     // for mapN
sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)
    }
}
object Check {
  final case class And[E, A](
    left: Check[E, A],
    right: Check[E, A]) extends Check[E, A]
  
  final case class Pure[E, A](
    func: A => Validated[E, A]) extends Check[E, A]
}

Our implementation is looking pretty good now. Implement an or combinator to complement and.

This reuses the same technique for and. We have to do a bit more work in the apply method. Note that it’s OK to short-circuit in this case because the choice of rules is implicit in the semantics of “or”.

import cats.Semigroup
import cats.data.Validated
import cats.syntax.semigroup._ // for |+|
import cats.syntax.apply._     // for mapN
import cats.data.Validated._   // for Valid and Invalid
sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def or(that: Check[E, A]): Check[E, A] =
    Or(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)

      case Or(left, right) =>
        left(a) match {
          case Valid(a)    => Valid(a)
          case Invalid(e1) =>
            right(a) match {
              case Valid(a)    => Valid(a)
              case Invalid(e2) => Invalid(e1 |+| e2)
            }
        }
    }
}
object Check {
  final case class And[E, A](
    left: Check[E, A],
    right: Check[E, A]) extends Check[E, A]
  
  final case class Or[E, A](
    left: Check[E, A],
    right: Check[E, A]) extends Check[E, A]
  
  final case class Pure[E, A](
    func: A => Validated[E, A]) extends Check[E, A]
}

With and and or we can implement many of checks we’ll want in practice. However, we still have a few more methods to add. We’ll turn to map and related methods next.

19.4 Transforming Data

One of our requirements is the ability to transform data. This allows us to support additional scenarios like parsing input. In this section we’ll extend our check library with this additional functionality.

The obvious starting point is map. When we try to implement this, we immediately run into a wall. Our current definition of Check requires the input and output types to be the same:

type Check[E, A] = A => Either[E, A]

When we map over a check, what type do we assign to the result? It can’t be A and it can’t be B. We are at an impasse:

def map(check: Check[E, A])(func: A => B): Check[E, ???]

To implement map we need to change the definition of Check. Specifically, we need to a new type variable to separate the input type from the output:

type Check[E, A, B] = A => Either[E, B]

Checks can now represent operations like parsing a String as an Int:

val parseInt: Check[List[String], String, Int] =
  // etc...

However, splitting our input and output types raises another issue. Up until now we have operated under the assumption that a Check always returns its input when successful. We used this in and and or to ignore the output of the left and right rules and simply return the original input on success:

(this(a), that(a)) match {
  case And(left, right) =>
    (left(a), right(a))
      .mapN((result1, result2) => Right(a))

  // etc...
}

In our new formulation we can’t return Right(a) because its type is Either[E, A] not Either[E, B]. We’re forced to make an arbitrary choice between returning Right(result1) and Right(result2). The same is true of the or method. From this we can derive two things:

19.4.1 Predicates

We can make progress by pulling apart the concept of a predicate, which can be combined using logical operations such as and and or, and the concept of a check, which can transform data.

What we have called Check so far we will call Predicate. For Predicate we can state the following identity law encoding the notion that a predicate always returns its input if it succeeds:

For a predicate p of type Predicate[E, A] and elements a1 and a2 of type A, if p(a1) == Success(a2) then a1 == a2.

Making this change gives us the following code:

import cats.Semigroup
import cats.data.Validated
import cats.syntax.semigroup._ // for |+|
import cats.syntax.apply._     // for mapN
import cats.data.Validated._   // for Valid and Invalid
sealed trait Predicate[E, A] {
  def and(that: Predicate[E, A]): Predicate[E, A] =
    And(this, that)

  def or(that: Predicate[E, A]): Predicate[E, A] =
    Or(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)

      case Or(left, right) =>
        left(a) match {
          case Valid(_)   => Valid(a)
          case Invalid(e1) =>
            right(a) match {
              case Valid(_)   => Valid(a)
              case Invalid(e2) => Invalid(e1 |+| e2)
            }
        }
    }
}

final case class And[E, A](
  left: Predicate[E, A],
  right: Predicate[E, A]) extends Predicate[E, A]

final case class Or[E, A](
  left: Predicate[E, A],
  right: Predicate[E, A]) extends Predicate[E, A]

final case class Pure[E, A](
  func: A => Validated[E, A]) extends Predicate[E, A]

19.4.2 Checks

We’ll use Check to represent a structure we build from a Predicate that also allows transformation of its input. Implement Check with the following interface:

sealed trait Check[E, A, B] {
  def apply(a: A): Validated[E, B] =
    ???

  def map[C](func: B => C): Check[E, A, C] =
    ???
}

If you follow the same strategy as Predicate you should be able to create code similar to the below:

import cats.Semigroup
import cats.data.Validated
sealed trait Check[E, A, B] {
  import Check._

  def apply(in: A)(implicit s: Semigroup[E]): Validated[E, B]

  def map[C](f: B => C): Check[E, A, C] =
    Map[E, A, B, C](this, f)
}

object Check {
  final case class Map[E, A, B, C](
    check: Check[E, A, B],
    func: B => C) extends Check[E, A, C] {
  
    def apply(in: A)(implicit s: Semigroup[E]): Validated[E, C] =
      check(in).map(func)
  }
  
  final case class Pure[E, A](
    pred: Predicate[E, A]) extends Check[E, A, A] {
  
    def apply(in: A)(implicit s: Semigroup[E]): Validated[E, A] =
      pred(in)
  }

  def apply[E, A](pred: Predicate[E, A]): Check[E, A, A] =
    Pure(pred)
}

What about flatMap? The semantics are a bit unclear here. The method is simple enough to declare but it’s not so obvious what it means or how we should implement apply. The general shape of flatMap is shown in Figure 25.

generic-flatmap Created with Sketch. F[A] F[B] A => F[B] flatMap
Figure 25: Type chart for flatMap

How do we relate F in the figure to Check in our code? Check has three type variables while F only has one.

To unify the types we need to fix two of the type parameters. The idiomatic choices are the error type E and the input type A. This gives us the relationships shown in Figure 26. In other words, the semantics of applying a FlatMap are:

flatmap Created with Sketch. A => F[B] B => (A => F[C]) A => F[C] flatMap
Figure 26: Type chart for flatMap applied to Check

This is quite an odd method. We can implement it, but it is hard to find a use for it. Go ahead and implement flatMap for Check, and then we’ll see a more generally useful method.

It’s the same implementation strategy as before with one wrinkle: Validated doesn’t have a flatMap method. To implement flatMap we must momentarily switch to Either and then switch back to Validated. The withEither method on Validated does exactly this. From here we can just follow the types to implement apply.

import cats.Semigroup
import cats.data.Validated
sealed trait Check[E, A, B] {
  def apply(in: A)(implicit s: Semigroup[E]): Validated[E, B]

  def flatMap[C](f: B => Check[E, A, C]) =
    FlatMap[E, A, B, C](this, f)

  // other methods...
}

final case class FlatMap[E, A, B, C](
  check: Check[E, A, B],
  func: B => Check[E, A, C]) extends Check[E, A, C] {

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
    check(a).withEither(_.flatMap(b => func(b)(a).toEither))
}

// other data types...

We can write a more useful combinator that chains together two Checks. The output of the first check is connected to the input of the second. This is analogous to function composition using andThen:

val f: A => B = ???
val g: B => C = ???
val h: A => C = f andThen g

A Check is basically a function A => Validated[E, B] so we can define an analagous andThen method:

trait Check[E, A, B] {
  def andThen[C](that: Check[E, B, C]): Check[E, A, C]
}

Implement andThen now!

Here’s a minimal definition of andThen and its corresponding AndThen class:

sealed trait Check[E, A, B] {
  def apply(in: A)(implicit s: Semigroup[E]): Validated[E, B]

  def andThen[C](that: Check[E, B, C]): Check[E, A, C] =
    AndThen[E, A, B, C](this, that)
}

final case class AndThen[E, A, B, C](
  check1: Check[E, A, B],
  check2: Check[E, B, C]) extends Check[E, A, C] {

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
    check1(a).withEither(_.flatMap(b => check2(b).toEither))
}

19.4.3 Recap

We now have two algebraic data types, Predicate and Check, and a host of combinators with their associated case class implementations. Look at the following solution for a complete definition of each ADT.

Here’s our final implementaton, including some tidying and repackaging of the code:

import cats.Semigroup
import cats.data.Validated
import cats.data.Validated._   // for Valid and Invalid
import cats.syntax.semigroup._ // for |+|
import cats.syntax.apply._     // for mapN
import cats.syntax.validated._ // for valid and invalid

Here is our complete implementation of Predicate, including the and and or combinators and a Predicate.apply method to create a Predicate from a function:

sealed trait Predicate[E, A] {
  import Predicate._
  import Validated._

  def and(that: Predicate[E, A]): Predicate[E, A] =
    And(this, that)

  def or(that: Predicate[E, A]): Predicate[E, A] =
    Or(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)

      case Or(left, right) =>
        left(a) match {
          case Valid(_)   => Valid(a)
          case Invalid(e1) =>
            right(a) match {
              case Valid(_)   => Valid(a)
              case Invalid(e2) => Invalid(e1 |+| e2)
            }
        }
    }
}

object Predicate {
  final case class And[E, A](
    left: Predicate[E, A],
    right: Predicate[E, A]) extends Predicate[E, A]

  final case class Or[E, A](
    left: Predicate[E, A],
    right: Predicate[E, A]) extends Predicate[E, A]

  final case class Pure[E, A](
    func: A => Validated[E, A]) extends Predicate[E, A]

  def apply[E, A](f: A => Validated[E, A]): Predicate[E, A] =
    Pure(f)

  def lift[E, A](err: E, fn: A => Boolean): Predicate[E, A] =
    Pure(a => if(fn(a)) a.valid else err.invalid)
}

Here is a complete implementation of Check. Due to a type inference bug in Scala’s pattern matching, we’ve switched to implementing apply using inheritance:

import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._     // for mapN
import cats.syntax.validated._ // for valid and invalid
sealed trait Check[E, A, B] {
  import Check._

  def apply(in: A)(implicit s: Semigroup[E]): Validated[E, B]

  def map[C](f: B => C): Check[E, A, C] =
    Map[E, A, B, C](this, f)

  def flatMap[C](f: B => Check[E, A, C]) =
    FlatMap[E, A, B, C](this, f)

  def andThen[C](next: Check[E, B, C]): Check[E, A, C] =
    AndThen[E, A, B, C](this, next)
}

object Check {
  final case class Map[E, A, B, C](
    check: Check[E, A, B],
    func: B => C) extends Check[E, A, C] {

    def apply(a: A)
        (implicit s: Semigroup[E]): Validated[E, C] =
      check(a) map func
  }

  final case class FlatMap[E, A, B, C](
    check: Check[E, A, B],
    func: B => Check[E, A, C]) extends Check[E, A, C] {

    def apply(a: A)
        (implicit s: Semigroup[E]): Validated[E, C] =
      check(a).withEither(_.flatMap(b => func(b)(a).toEither))
  }

  final case class AndThen[E, A, B, C](
    check: Check[E, A, B],
    next: Check[E, B, C]) extends Check[E, A, C] {

    def apply(a: A)
        (implicit s: Semigroup[E]): Validated[E, C] =
      check(a).withEither(_.flatMap(b => next(b).toEither))
  }

  final case class Pure[E, A, B](
    func: A => Validated[E, B]) extends Check[E, A, B] {

    def apply(a: A)
        (implicit s: Semigroup[E]): Validated[E, B] =
      func(a)
  }

  final case class PurePredicate[E, A](
    pred: Predicate[E, A]) extends Check[E, A, A] {

    def apply(a: A)
        (implicit s: Semigroup[E]): Validated[E, A] =
      pred(a)
  }

  def apply[E, A](pred: Predicate[E, A]): Check[E, A, A] =
    PurePredicate(pred)

  def apply[E, A, B]
      (func: A => Validated[E, B]): Check[E, A, B] =
    Pure(func)
}

We have a complete implementation of Check and Predicate that do most of what we originally set out to do. However, we are not finished yet. You have probably recognised structure in Predicate and Check that we can abstract over: Predicate has a monoid and Check has a monad. Furthermore, in implementing Check you might have felt the implementation doesn’t do much—all we do is call through to underlying methods on Predicate and Validated.

There are a lot of ways this library could be cleaned up. However, let’s implement some examples to prove to ourselves that our library really does work, and then we’ll turn to improving it.

Implement checks for some of the examples given in the introduction:

You might find the following predicates useful:

import cats.data.{NonEmptyList, Validated}
type Errors = NonEmptyList[String]

def error(s: String): NonEmptyList[String] =
  NonEmptyList(s, Nil)

def longerThan(n: Int): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must be longer than $n characters"),
    str => str.size > n)

val alphanumeric: Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must be all alphanumeric characters"),
    str => str.forall(_.isLetterOrDigit))

def contains(char: Char): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must contain the character $char"),
    str => str.contains(char))

def containsOnce(char: Char): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must contain the character $char only once"),
    str => str.filter(c => c == char).size == 1)

Here’s our reference solution. Implementing this required more thought than we expected. Switching between Check and Predicate at appropriate places felt a bit like guesswork till we got the rule into our heads that Predicate doesn’t transform its input. With this rule in mind things went fairly smoothly. In later sections we’ll make some changes that make the library easier to use.

import cats.syntax.apply._     // for mapN
import cats.syntax.validated._ // for valid and invalid

Here’s the implementation of checkUsername:

// A username must contain at least four characters
// and consist entirely of alphanumeric characters

val checkUsername: Check[Errors, String, String] =
  Check(longerThan(3) and alphanumeric)

And here’s the implementation of checkEmail, built up from a number of smaller components:

// An email address must contain a single `@` sign.
// Split the string at the `@`.
// The string to the left must not be empty.
// The string to the right must be
// at least three characters long and contain a dot.

val splitEmail: Check[Errors, String, (String, String)] =
  Check(_.split('@') match {
    case Array(name, domain) =>
      (name, domain).validNel[String]

    case _ =>
      "Must contain a single @ character".
        invalidNel[(String, String)]
  })

val checkLeft: Check[Errors, String, String] =
  Check(longerThan(0))

val checkRight: Check[Errors, String, String] =
  Check(longerThan(3) and contains('.'))

val joinEmail: Check[Errors, (String, String), String] =
  Check { case (l, r) =>
    (checkLeft(l), checkRight(r)).mapN(_ + "@" + _)
  }

val checkEmail: Check[Errors, String, String] =
  splitEmail andThen joinEmail

Finally, here’s a check for a User that depends on checkUsername and checkEmail:

final case class User(username: String, email: String)

def createUser(
      username: String,
      email: String): Validated[Errors, User] =
  (checkUsername(username), checkEmail(email)).mapN(User.apply)

We can check our work by creating a couple of example users:

createUser("Noel", "noel@underscore.io")
// res5: Validated[Errors, User] = Valid(
//   a = User(username = "Noel", email = "noel@underscore.io")
// )
createUser("", "dave@underscore.io@io")
// res6: Validated[Errors, User] = Invalid(
//   e = NonEmptyList(
//     head = "Must be longer than 3 characters",
//     tail = List("Must contain a single @ character")
//   )
// )

One distinct disadvantage of our example is that it doesn’t tell us where the errors came from. We can either achieve that through judicious manipulation of error messages, or we can modify our library to track error locations as well as messages. Tracking error locations is outside the scope of this case study, so we’ll leave this as an exercise to the reader.

19.5 Kleislis

We’ll finish off this case study by cleaning up the implementation of Check. A justifiable criticism of our approach is that we’ve written a lot of code to do very little. A Predicate is essentially a function A => Validated[E, A], and a Check is basically a wrapper that lets us compose these functions.

We can abstract A => Validated[E, A] to A => F[B], which you’ll recognise as the type of function you pass to the flatMap method on a monad. Imagine we have the following sequence of operations:

We can illustrate this as shown in Figure 27. We can also write out this example using the monad API as follows:

val aToB: A => F[B] = ???
val bToC: B => F[C] = ???

def example[A, C](a: A): F[C] =
  aToB(a).flatMap(bToC)
kleisli Created with Sketch. A => F[A] flatMap flatMap A => F[B] B => F[C]
Figure 27: Sequencing monadic transforms

Recall that Check is, in the abstract, allowing us to compose functions of type A => F[B]. We can write the above in terms of andThen as:

val aToC = aToB andThen bToC

The result is a (wrapped) function aToC of type A => F[C] that we can subsequently apply to a value of type A.

We have achieved the same thing as the example method without having to reference an argument of type A. The andThen method on Check is analogous to function composition, but is composing function A => F[B] instead of A => B.

The abstract concept of composing functions of type A => F[B] has a name: a Kleisli.

Cats contains a data type cats.data.Kleisli that wraps a function just as Check does. Kleisli has all the methods of Check plus some additional ones. If Kleisli seems familiar to you, then congratulations. You’ve seen through its disguise and recognised it as another concept from earlier in the book: Kleisli is just another name for ReaderT.

Here is a simple example using Kleisli to transform an integer into a list of integers through three steps:

import cats.data.Kleisli
import cats.instances.list._ // for Monad

These steps each transform an input Int into an output of type List[Int]:

val step1: Kleisli[List, Int, Int] =
  Kleisli(x => List(x + 1, x - 1))

val step2: Kleisli[List, Int, Int] =
  Kleisli(x => List(x, -x))

val step3: Kleisli[List, Int, Int] =
  Kleisli(x => List(x * 2, x / 2))

We can combine the steps into a single pipeline that combines the underlying Lists using flatMap:

val pipeline = step1 andThen step2 andThen step3

The result is a function that consumes a single Int and returns eight outputs, each produced by a different combination of transformations from step1, step2, and step3:

pipeline.run(20)
// res0: List[Int] = List(42, 10, -42, -10, 38, 9, -38, -9)

The only notable difference between Kleisli and Check in terms of API is that Kleisli renames our apply method to run.

Let’s replace Check with Kleisli in our validation examples. To do so we need to make a few changes to Predicate. We must be able to convert a Predicate to a function, as Kleisli only works with functions. Somewhat more subtly, when we convert a Predicate to a function, it should have type A => Either[E, A] rather than A => Validated[E, A] because Kleisli relies on the wrapped function returning a monad.

Add a method to Predicate called run that returns a function of the correct type. Leave the rest of the code in Predicate the same.

Here’s an abbreviated definition of run. Like apply, the method must accept an implicit Semigroup:

import cats.Semigroup
import cats.data.Validated

sealed trait Predicate[E, A] {
  def run(implicit s: Semigroup[E]): A => Either[E, A] =
    (a: A) => this(a).toEither

  def apply(a: A): Validated[E, A] =
    ??? // etc...

  // other methods...
}

Now rewrite our username and email validation example in terms of Kleisli and Predicate. Here are few tips in case you get stuck:

First, remember that the run method on Predicate takes an implicit parameter. If you call aPredicate.run(a) it will try to pass the implicit parameter explicitly. If you want to create a function from a Predicate and immediately apply that function, use aPredicate.run.apply(a)

Second, type inference can be tricky in this exercise. We found that the following definitions helped us to write code with fewer type declarations.

type Result[A] = Either[Errors, A]

type Check[A, B] = Kleisli[Result, A, B]

// Create a check from a function:
def check[A, B](func: A => Result[B]): Check[A, B] =
  Kleisli(func)

// Create a check from a Predicate:
def checkPred[A](pred: Predicate[Errors, A]): Check[A, A] =
  Kleisli[Result, A, A](pred.run)

Working around limitations of type inference can be quite frustrating when writing this code, Working out when to convert between Predicates, functions, and Validated, and Either simplifies things, but the process is still complex:

import cats.data.{Kleisli, NonEmptyList}
import cats.instances.either._   // for Semigroupal

Here is the preamble we suggested in the main text of the case study:

type Errors = NonEmptyList[String]

def error(s: String): NonEmptyList[String] =
  NonEmptyList(s, Nil)

type Result[A] = Either[Errors, A]

type Check[A, B] = Kleisli[Result, A, B]

def check[A, B](func: A => Result[B]): Check[A, B] =
  Kleisli(func)

def checkPred[A](pred: Predicate[Errors, A]): Check[A, A] =
  Kleisli[Result, A, A](pred.run)

Our base predicate definitions are essenitally unchanged:

def longerThan(n: Int): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must be longer than $n characters"),
    str => str.size > n)

val alphanumeric: Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must be all alphanumeric characters"),
    str => str.forall(_.isLetterOrDigit))

def contains(char: Char): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must contain the character $char"),
    str => str.contains(char))

def containsOnce(char: Char): Predicate[Errors, String] =
  Predicate.lift(
    error(s"Must contain the character $char only once"),
    str => str.filter(c => c == char).size == 1)

Our username and email examples are slightly different in that we make use of check() and checkPred() in different situations:

val checkUsername: Check[String, String] =
  checkPred(longerThan(3) and alphanumeric)

val splitEmail: Check[String, (String, String)] =
  check(_.split('@') match {
    case Array(name, domain) =>
      Right((name, domain))

    case _ =>
      Left(error("Must contain a single @ character"))
  })

val checkLeft: Check[String, String] =
  checkPred(longerThan(0))

val checkRight: Check[String, String] =
  checkPred(longerThan(3) and contains('.'))

val joinEmail: Check[(String, String), String] =
  check {
    case (l, r) =>
      (checkLeft(l), checkRight(r)).mapN(_ + "@" + _)
  }

val checkEmail: Check[String, String] =
  splitEmail andThen joinEmail

Finally, we can see that our createUser example works as expected using Kleisli:

final case class User(username: String, email: String)

def createUser(
      username: String,
      email: String): Either[Errors, User] = (
  checkUsername.run(username),
  checkEmail.run(email)
).mapN(User.apply)
createUser("Noel", "noel@underscore.io")
// res2: Either[Errors, User] = Right(
//   value = User(username = "Noel", email = "noel@underscore.io")
// )
createUser("", "dave@underscore.io@io")
// res3: Either[Errors, User] = Left(
//   value = NonEmptyList(head = "Must be longer than 3 characters", tail = List())
// )

We have now written our code entirely in terms of Kleisli and Predicate, completely removing Check. This is a good first step to simplifying our library. There’s still plenty more to do, but we have a sophisticated building block from Cats to work with. We’ll leave further improvements up to the reader.

19.6 Summary

This case study has been an exercise in removing rather than building abstractions. We started with a fairly complex Check type. Once we realised we were conflating two concepts, we separated out Predicate leaving us with something that could be implemented with Kleisli.

We made several design choices above that reasonable developers may disagree with. Should the method that converts a Predicate to a function really be called run instead of, say, toFunction? Should Predicate be a subtype of Function to begin with? Many functional programmers prefer to avoid subtyping because it plays poorly with implicit resolution and type inference, but there could be an argument to use it here. As always the best decisions depend on the context in which the library will be used.

20 Case Study: CRDTs

In this case study we will explore Commutative Replicated Data Types (CRDTs), a family of data structures that can be used to reconcile eventually consistent data.

We’ll start by describing the utility and difficulty of eventually consistent systems, then show how we can use monoids and their extensions to solve the issues that arise. Finally, we will model the solutions in Scala.

Our goal here is to focus on the implementation in Scala of a particular type of CRDT. We’re not aiming at a comprehensive survey of all CRDTs. CRDTs are a fast-moving field and we advise you to read the literature to learn about more.

20.1 Eventual Consistency

As soon as a system scales beyond a single machine we have to make a fundamental choice about how we manage data.

One approach is to build a system that is consistent, meaning that all machines have the same view of data. For example, if a user changes their password then all machines that store a copy of that password must accept the change before we consider the operation to have completed successfully.

Consistent systems are easy to work with but they have their disadvantages. They tend to have high latency because a single change can result in many messages being sent between machines. They also tend to have relatively low uptime because outages can cut communications between machines creating a network partition. When there is a network partition, a consistent system may refuse further updates to prevent inconsistencies across machines.

An alternative approach is an eventually consistent system. This means that at any particular point in time machines are allowed to have differing views of data. However, if all machines can communicate and there are no further updates they will eventually all have the same view of data.

Eventually consistent systems require less communication between machines so latency can be lower. A partitioned machine can still accept updates and reconcile its changes when the network is fixed, so systems can also have better uptime.

The big question is: how do we do this reconciliation between machines? CRDTs provide one approach to the problem.

20.2 The GCounter

Let’s look at one particular CRDT implementation. Then we’ll attempt to generalise properties to see if we can find a general pattern.

The data structure we will look at is called a GCounter. It is a distributed increment-only counter that can be used, for example, to count the number of visitors to a web site where requests are served by many web servers.

20.2.1 Simple Counters

To see why a straightforward counter won’t work, imagine we have two servers storing a simple count of visitors. Let’s call the machines A and B. Each machine is storing an integer counter and the counters all start at zero as shown in Figure 28.

simple-counter1 Created with Sketch. Machine B Machine A 0 0
Figure 28: Simple counters: initial state

Now imagine we receive some web traffic. Our load balancer distributes five incoming requests to A and B, A serving three visitors and B two. The machines have inconsistent views of the system state that they need to reconcile to achieve consistency. One reconciliation strategy with simple counters is to exchange counts and add them as shown in Figure 29.

simple-counter3 Created with Sketch. 5 5 3 2 Machine B Machine A Add counters Incoming requests Incoming requests
Figure 29: Simple counters: first round of requests and reconciliation

So far so good, but things will start to fall apart shortly. Suppose A serves a single visitor, which means we’ve seen six visitors in total. The machines attempt to reconcile state again using addition leading to the answer shown in Figure 30.

simple-counter5 Created with Sketch. 11 11 6 5 Machine B Machine A Add counters Incorrect result! Incoming request
Figure 30: Simple counters: second round of requests and (incorrect) reconciliation

This is clearly wrong! The problem is that simple counters don’t give us enough information about the history of interactions between the machines. Fortunately we don’t need to store the complete history to get the correct answer—just a summary of it. Let’s look at how the GCounter solves this problem.

20.2.2 GCounters

The first clever idea in the GCounter is to have each machine storing a separate counter for every machine it knows about (including itself). In the previous example we had two machines, A and B. In this situation both machines would store a counter for A and a counter for B as shown in Figure 31.

g-counter1 Created with Sketch. Machine B Machine A A:0 B:0 A:0 B:0
Figure 31: GCounter: initial state

The rule with GCounters is that a given machine is only allowed to increment its own counter. If A serves three visitors and B serves two visitors the counters look as shown in Figure 32.

g-counter2 Created with Sketch. A:3 B:0 A:0 B:2 Machine B Machine A Incoming requests Incoming requests
Figure 32: GCounter: first round of web requests

When two machines reconcile their counters the rule is to take the largest value stored for each machine. In our example, the result of the first merge will be as shown in Figure 33.

g-counter3 Created with Sketch. A:3 B:2 A:3 B:2 A:3 B:0 A:0 B:2 Machine B Machine A Merge, take max Incoming requests Incoming requests
Figure 33: GCounter: first reconciliation

Subsequent incoming web requests are handled using the increment-own-counter rule and subsequent merges are handled using the take-maximum-value rule, producing the same correct values for each machine as shown in Figure 34.

g-counter5 Created with Sketch. A:4 B:2 A:4 B:2 A:4 B:2 A:3 B:2 Machine B Machine A Merge, take max Correct result! Incoming request
Figure 34: GCounter: second reconciliation

GCounters allow each machine to keep an accurate account of the state of the whole system without storing the complete history of interactions. If a machine wants to calculate the total traffic for the whole web site, it sums up all the per-machine counters. The result is accurate or near-accurate depending on how recently we performed a reconciliation. Eventually, regardless of network outages, the system will always converge on a consistent state.

20.2.3 Exercise: GCounter Implementation

We can implement a GCounter with the following interface, where we represent machine IDs as Strings.

final case class GCounter(counters: Map[String, Int]) {
  def increment(machine: String, amount: Int) =
    ???

  def merge(that: GCounter): GCounter =
    ???

  def total: Int =
    ???
}

Finish the implementation!

Hopefully the description above was clear enough that you can get to an implementation like the one below.

final case class GCounter(counters: Map[String, Int]) {
  def increment(machine: String, amount: Int) = {
    val value = amount + counters.getOrElse(machine, 0)
    GCounter(counters + (machine -> value))
  }

  def merge(that: GCounter): GCounter =
    GCounter(that.counters ++ this.counters.map {
      case (k, v) =>
        k -> (v max that.counters.getOrElse(k, 0))
    })

  def total: Int =
    counters.values.sum
}

20.3 Generalisation

We’ve now created a distributed, eventually consistent, increment-only counter. This is a useful achievement but we don’t want to stop here. In this section we will attempt to abstract the operations in the GCounter so it will work with more data types than just natural numbers.

The GCounter uses the following operations on natural numbers:

You can probably guess that there’s a monoid in here somewhere, but let’s look in more detail at the properties we’re relying on.

As a refresher, in Chapter 7 we saw that monoids must satisfy two laws. The binary operation + must be associative:

(a + b) + c == a + (b + c)

and the empty element must be an identity:

0 + a == a + 0 == a

We need an identity in increment to initialise the counter. We also rely on associativity to ensure the specific sequence of merges gives the correct value.

In total we implicitly rely on associativity and commutativity to ensure we get the correct value no matter what arbitrary order we choose to sum the per-machine counters. We also implicitly assume an identity, which allows us to skip machines for which we do not store a counter.

The properties of merge are a bit more interesting. We rely on commutativity to ensure that machine A merging with machine B yields the same result as machine B merging with machine A. We need associativity to ensure we obtain the correct result when three or more machines are merging data. We need an identity element to initialise empty counters. Finally, we need an additional property, called idempotency, to ensure that if two machines hold the same data in a per-machine counter, merging data will not lead to an incorrect result. Idempotent operations are ones that return the same result again and again if they are executed multiple times. Formally, a binary operation max is idempotent if the following relationship holds:

a max a = a

Written more compactly, we have:

Method Identity Commutative Associative Idempotent
increment Y N Y N
merge Y Y Y Y
total Y Y Y N

From this we can see that

Since increment and get both use the same binary operation (addition) it’s usual to require the same commutative monoid for both.

This investigation demonstrates the powers of thinking about properties or laws of abstractions. Now we have identified these properties we can substitute the natural numbers used in our GCounter with any data type with operations satisfying these properties. A simple example is a set, with the binary operation being union and the identity element the empty set. With this simple substitution of Int for Set[A] we can create a GSet type.

20.3.1 Implementation

Let’s implement this generalisation in code. Remember increment and total require a commutative monoid and merge requires a bounded semilattice (or idempotent commutative monoid).

Cats provides a type class for both Monoid and CommutativeMonoid, but doesn’t provide one for bounded semilattice24. That’s why we’re going to implement our own BoundedSemiLattice type class.

import cats.kernel.CommutativeMonoid

trait BoundedSemiLattice[A] extends CommutativeMonoid[A] {
  def combine(a1: A, a2: A): A
  def empty: A
}

In the implementation above, BoundedSemiLattice[A] extends CommutativeMonoid[A] because a bounded semilattice is a commutative monoid (a commutative idempotent one, to be exact).

20.3.2 Exercise: BoundedSemiLattice Instances

Implement BoundedSemiLattice type class instances for Ints and for Sets. The instance for Int will technically only hold for non-negative numbers, but you don’t need to model non-negativity explicitly in the types.

It’s common to place the instances in the companion object of BoundedSemiLattice so they are in the implicit scope without importing them.

Implementing the instance for Set provides good practice with implicit methods.

object wrapper {
  trait BoundedSemiLattice[A] extends CommutativeMonoid[A] {
    def combine(a1: A, a2: A): A
    def empty: A
  }

  object BoundedSemiLattice {
    implicit val intInstance: BoundedSemiLattice[Int] =
      new BoundedSemiLattice[Int] {
        def combine(a1: Int, a2: Int): Int =
          a1 max a2

        val empty: Int =
          0
      }

    implicit def setInstance[A]: BoundedSemiLattice[Set[A]] =
      new BoundedSemiLattice[Set[A]]{
        def combine(a1: Set[A], a2: Set[A]): Set[A] =
          a1 union a2

        val empty: Set[A] =
          Set.empty[A]
      }
  }
}; import wrapper._

20.3.3 Exercise: Generic GCounter

Using CommutativeMonoid and BoundedSemiLattice, generalise GCounter.

When you implement this, look for opportunities to use methods and syntax on Monoid to simplify your implementation. This is a good example of how type class abstractions work at multiple levels in our code. We’re using monoids to design a large component—our CRDTs—but they are also useful in the small, simplifying our code and making it shorter and clearer.

Here’s a working implementation. Note the use of |+| in the definition of merge, which significantly simplifies the process of merging and maximising counters:

import cats.instances.list._   // for Monoid
import cats.instances.map._    // for Monoid
import cats.syntax.semigroup._ // for |+|
import cats.syntax.foldable._  // for combineAll

final case class GCounter[A](counters: Map[String,A]) {
  def increment(machine: String, amount: A)
        (implicit m: CommutativeMonoid[A]): GCounter[A] = {
    val value = amount |+| counters.getOrElse(machine, m.empty)
    GCounter(counters + (machine -> value))
  }

  def merge(that: GCounter[A])
        (implicit b: BoundedSemiLattice[A]): GCounter[A] =
    GCounter(this.counters |+| that.counters)

  def total(implicit m: CommutativeMonoid[A]): A =
    this.counters.values.toList.combineAll
}

20.4 Abstracting GCounter to a Type Class

We’ve created a generic GCounter that works with any value that has instances of BoundedSemiLattice and CommutativeMonoid. However we’re still tied to a particular representation of the map from machine IDs to values. There is no need to have this restriction, and indeed it can be useful to abstract away from it. There are many key-value stores that we want to work with, from a simple Map to a relational database.

If we define a GCounter type class we can abstract over different concrete implementations. This allows us to, for example, seamlessly substitute an in-memory store for a persistent store when we want to change performance and durability tradeoffs.

There are a number of ways we can implement this. One approach is to define a GCounter type class with dependencies on CommutativeMonoid and BoundedSemiLattice. We define this as a type class that takes a type constructor with two type parameters represent the key and value types of the map abstraction.

trait GCounter[F[_,_],K, V] {
  def increment(f: F[K, V])(k: K, v: V)
        (implicit m: CommutativeMonoid[V]): F[K, V]

  def merge(f1: F[K, V], f2: F[K, V])
        (implicit b: BoundedSemiLattice[V]): F[K, V]

  def total(f: F[K, V])
        (implicit m: CommutativeMonoid[V]): V
}

object GCounter {
  def apply[F[_,_], K, V]
        (implicit counter: GCounter[F, K, V]) =
    counter
}

Try defining an instance of this type class for Map. You should be able to reuse your code from the case class version of GCounter with some minor modifications.

Here’s the complete code for the instance. Write this definition in the companion object for GCounter to place it in global implicit scope:

import cats.instances.list._   // for Monoid
import cats.instances.map._    // for Monoid
import cats.syntax.semigroup._ // for |+|
import cats.syntax.foldable._  // for combineAll

implicit def mapGCounterInstance[K, V]: GCounter[Map, K, V] =
  new GCounter[Map, K, V] {
    def increment(map: Map[K, V])(key: K, value: V)
          (implicit m: CommutativeMonoid[V]): Map[K, V] = {
      val total = map.getOrElse(key, m.empty) |+| value
      map + (key -> total)
    }

    def merge(map1: Map[K, V], map2: Map[K, V])
          (implicit b: BoundedSemiLattice[V]): Map[K, V] =
      map1 |+| map2

    def total(map: Map[K, V])
        (implicit m: CommutativeMonoid[V]): V =
      map.values.toList.combineAll
  }

You should be able to use your instance as follows:

import cats.instances.int._ // for Monoid

val g1 = Map("a" -> 7, "b" -> 3)
val g2 = Map("a" -> 2, "b" -> 5)

val counter = GCounter[Map, String, Int]
val merged = counter.merge(g1, g2)
// merged: Map[String, Int] = Map("a" -> 7, "b" -> 5)
val total  = counter.total(merged)
// total: Int = 12

The implementation strategy for the type class instance is a bit unsatisfying. Although the structure of the implementation will be the same for most instances we define, we won’t get any code reuse.

20.5 Abstracting a Key Value Store

One solution is to capture the idea of a key-value store within a type class, and then generate GCounter instances for any type that has a KeyValueStore instance. Here’s the code for such a type class:

trait KeyValueStore[F[_,_]] {
  def put[K, V](f: F[K, V])(k: K, v: V): F[K, V]

  def get[K, V](f: F[K, V])(k: K): Option[V]

  def getOrElse[K, V](f: F[K, V])(k: K, default: V): V =
    get(f)(k).getOrElse(default)

  def values[K, V](f: F[K, V]): List[V]
}

Implement your own instance for Map.

Here’s the code for the instance. Write the definition in the companion object for KeyValueStore to place it in global implicit scope:

implicit val mapKeyValueStoreInstance: KeyValueStore[Map] =
  new KeyValueStore[Map] {
    def put[K, V](f: Map[K, V])(k: K, v: V): Map[K, V] =
      f + (k -> v)

    def get[K, V](f: Map[K, V])(k: K): Option[V] =
      f.get(k)

    override def getOrElse[K, V](f: Map[K, V])
        (k: K, default: V): V =
      f.getOrElse(k, default)

    def values[K, V](f: Map[K, V]): List[V] =
      f.values.toList
  }

With our type class in place we can implement syntax to enhance data types for which we have instances:

implicit class KvsOps[F[_,_], K, V](f: F[K, V]) {
  def put(key: K, value: V)
        (implicit kvs: KeyValueStore[F]): F[K, V] =
    kvs.put(f)(key, value)

  def get(key: K)(implicit kvs: KeyValueStore[F]): Option[V] =
    kvs.get(f)(key)

  def getOrElse(key: K, default: V)
        (implicit kvs: KeyValueStore[F]): V =
    kvs.getOrElse(f)(key, default)

  def values(implicit kvs: KeyValueStore[F]): List[V] =
    kvs.values(f)
}

Now we can generate GCounter instances for any data type that has instances of KeyValueStore and CommutativeMonoid using an implicit def:

implicit def gcounterInstance[F[_,_], K, V]
    (implicit kvs: KeyValueStore[F], km: CommutativeMonoid[F[K, V]]): GCounter[F, K, V] =
  new GCounter[F, K, V] {
    def increment(f: F[K, V])(key: K, value: V)
          (implicit m: CommutativeMonoid[V]): F[K, V] = {
      val total = f.getOrElse(key, m.empty) |+| value
      f.put(key, total)
    }

    def merge(f1: F[K, V], f2: F[K, V])
          (implicit b: BoundedSemiLattice[V]): F[K, V] =
      f1 |+| f2

    def total(f: F[K, V])(implicit m: CommutativeMonoid[V]): V =
      f.values.combineAll
  }

The complete code for this case study is quite long, but most of it is boilerplate setting up syntax for operations on the type class. We can cut down on this using compiler plugins such as Simulacrum and Kind Projector.

20.6 Summary

In this case study we’ve seen how we can use type classes to model a simple CRDT, the GCounter, in Scala. Our implementation gives us a lot of flexibility and code reuse: we aren’t tied to the data type we “count”, nor to the data type that maps machine IDs to counters.

The focus in this case study has been on using the tools that Scala provides, not on exploring CRDTs. There are many other CRDTs, some of which operate in a similar manner to the GCounter, and some of which have very different implementations. A fairly recent survey gives a good overview of many of the basic CRDTs. However this is an active area of research and we encourage you to read the recent publications in the field if CRDTs and eventually consistency interest you.

21 Acknowledgements

No book is an island. This book wouldn’t exist without it’s predecessor, Scala with Cats, and everyone involved in creating that book implicitly played some part in this book’s creation. See below for that book’s acknowledgements, but in particular I want to highlight my coauthor, Dave “Lord of Types” Pereira-Gurnell, without whom that book would not exist and hence neither would this one. Thanks Dave!

Thanks also to Adam Rosien, who gave me low-key encouragement and put up with my bullshit. Also my wife and children, who put up with even more of my bullshit, and gave me the space to finish this project. The members of ScalaBridge London and attendees at various training courses acted as experimental subjects for a lot of the material here. Thank you for being willing test subjects; you greatly helped improve the content. Thanks for the members of the PLT research group who inspired me directly back in the day, and continue to provide inspiration from afar. Finally, thanks to the following who sponsored my work or contributed with corrections and suggestions:

Aleksandr Andreev, Charles Adetiloye, Johanna Odersky, Lunfu Zhong, Maciej Gorywoda , Mathieu Pichette, Murat Cetin , Olya Mazhara, Pavel Syvak, Philip Schwarz, Seth Tisue, Tim Eccleston (@combinatorist).

21.1 Acknowledgements from Scala with Cats

We’d like to thank our colleagues at Inner Product and Underscore, our friends at Typelevel, and everyone who helped contribute to this book. Special thanks to Jenny Clements for her fantastic artwork and Richard Dallaway for his proof reading expertise. Here is an alphabetical list of contributors:

Alessandro Marrella, Cody Koeninger, Connie Chen, Conor Fennell, Dani Rey, Daniela Sfregola, Danielle Ashley, David Castillo, David Piggott, Denis Zjukow, Dennis Hunziker, Deokhwan Kim, Edd Steel, Eduardo Obando Boschini, Eugene Yushin, Evgeny Veretennikov, Francis Devereux, Ghislain Vaillant, Gregor Ihmor, Hayato Iida, Henk-Jan Meijer, HigherKindedType, Janne Pelkonen, Joao Azevedo, Jason Scott, Javier Arrieta, Jenny Clements, Jérémie Jost, Joachim Hofer, Jonathon Ferguson, Lance Paine, Leif Wickland, ltbs, Lunfu Zhong, Marc Prud’hommeaux, Martin Carolan, mizuno, Mr-SD, Narayan Iyer, Niccolo’ Paravanti, niqdev, Noor Nashid, Pablo Francisco Pérez Hidalgo, Pawel Jurczenko, Phil Derome, Philip Schwarz, Riccardo Sirigu, Richard Dallaway, Robert Stoll, Rodney Jacobsen, Rodrigo B. de Oliveira, Rud Wangrungarun, Seoh Char, Sergio Magnacco, Shohei Shimomura, Tim McIver, Toby Weston, Victor Osolovskiy, and Yinka Erinle.

If you spot an error or potential improvement, please raise an issue or submit a PR on the book’s Github page.

Backers

We’d also like to extend very special thanks to our backers—fine people who helped fund the development of the book by buying a copy before we released it as open source. This book wouldn’t exist without you:

A battle-hardened technologist, Aaron Pritzlaff, Abhishek Srivastava, Aleksey “Daron” Terekhin, Algolia, Allen George (@allenageorge), Andrew Johnson, Andrew Kerr, Andy Dwelly, Anler, anthony@dribble.ai, Aravindh Sridaran, Araxis Ltd, ArtemK, Arthur Kushka (@arhelmus), Artur Zhurat, Arturas Smorgun, Attila Mravik, Axel Gschaider, Bamboo Le, bamine, Barry Kern, Ben Darfler (@bdarfler), Ben Letton, Benjamin Neil, Benoit Hericher, Bernt Andreas Langøien, Bill Leck, Blaze K, Boniface Kabaso, Brian Wongchaowart, Bryan Dragon, @cannedprimates, Ceschiatti (@6qat), Chris Gojlo, Chris Phelps, @CliffRedmond, Cody Koeninger, Constantin Gonciulea, Dadepo Aderemi, Damir Vandic, Damon Rolfs, Dan Todor, Daniel Arndt, Daniela Sfregola, David Greco, David Poltorak, Dennis Hunziker, Dennis Vriend, Derek Morr, Dimitrios Liapis, Don McNamara, Doug Clinton, Doug Lindholm (dlindhol), Edgar Mueller, Edward J Renauer Jr, Emiliano Martinez, esthom, Etienne Peiniau, Fede Silva, Filipe Azevedo, Franck Rasolo, Gary Coady, George Ball, Gerald Loeffler, Integrational, Giles Taylor, Guilherme Dantas (@gamsd), Harish Hurchurn, Hisham Ismail, Iurii Susuk, Ivan (SkyWriter) Kasatenko, Ivano Pagano, Jacob Baumbach, James Morris, Jan Vincent Liwanag, Javier Gonzalez, Jeff Gentry, Joel Chovanec, Jon Bates, Jorge Aliss (@jaliss), Juan Macias (@1macias1), Juan Ortega, Juan Pablo Romero Méndez, Jungsun Kim, Kaushik Chakraborty (@kaychaks), Keith Mannock, Ken Hoffman, Kevin Esler, Kevin Kyyro, kgillies, Klaus Rehm, Kostas Skourtis, Lance Linder, Liang, Guang Hua, Loïc Girault, Luke Tebbs, Makis A, Malcolm Robbins, Mansur Ashraf (@mansur_ashraf), Marcel Lüthi, Marek Prochera @hicolour, Marianudo (Mariano Navas), Mark Eibes, Mark van Rensburg, Martijn Blankestijn, Martin Studer, Matthew Edwards, Matthew Pflueger, mauropalsgraaf, mbarak, Mehitabel, Michael Pigg, Mikael Moghadam, Mike Gehard (@mikegehard), MonadicBind, arjun.mukherjee@gmail.com, Stephen Arbogast, Narayan Iyer, @natewave, Netanel Rabinowitz, Nick Peterson, Nicolas Sitbon, Oier Blasco Linares, Oliver Daff, Oliver Schrenk, Olly Shaw, P Villela, pandaforme, Patrick Garrity, Pawel Wlodarski from JUG Lodz, @peel, Peter Perhac, Phil Glover, Philipp Leser-Wolf, Rachel Bowyer, Radu Gancea (@radusw), Rajit Singh, Ramin Alidousti, Raymond Tay, Riccardo Sirigu, Richard (Yin-Wu) Chuo, Rob Vermazeren, Robert “Kemichal” Andersson, Robin Taylor (@badgermind), Rongcui Dong, Rui Morais, Rupert Bates, Rustem Suniev, Sanjiv Sahayam, Shane Delmore, Stefan Plantikow, Sundy Wiliam Yaputra, Tal Pressman, Tamas Neltz, theLXK, Tim Pigden, Tobias Lutz, Tom Duhourq, @tomzalt, Utz Westermann, Vadym Shalts, Val Akkapeddi, Vasanth Loka, Vladimir Bacvanski, Vladimir Bystrov aka udav_pit, William Benton, Wojciech Langiewicz, Yann Ollivier (@ya2o), Yoshiro Naito, zero323, and zeronone.

Boden, M.A. and Edmonds, E.A. 2009. What is generative art? Digital Creativity 1-2, 21–46.
Burstall, R.M. 1969. Proving Properties of Programs by Structural Induction. The Computer Journal 12, 1, 41–48.
Carette, J., Kiselyov, O., and Shan, C. 2009. Finally tagless, partially evaluated: Tagless staged interpreters for simpler typed languages. Journal of Functional Programming 5, 509–543.
Cheney, J. and Hinze, R. 2003. First-class phantom types. Cornell University.
Claessen, K. and Hughes, J. 2000. QuickCheck: A lightweight tool for random testing of haskell programs. Proceedings of the fifth ACM SIGPLAN international conference on functional programming, Association for Computing Machinery, 268–279.
Cook, W. 1990. Object-oriented programming versus abstract data types. Proceedings of the REX workshop/school on the foundations of object-oriented languages (FOOL), Springer-Verlag, 151–178.
Danvy, O. and Nielsen, L.R. 2001. Defunctionalization at work. Proceedings of the 3rd ACM SIGPLAN international conference on principles and practice of declarative programming, Association for Computing Machinery, 162–174.
Dethlefs, N. and Hawick, K. 2017. DEFIne: A fluent interface DSL for deep learning applications. Proceedings of the 2nd international workshop on real world domain specific languages, Association for Computing Machinery.
Dorin, A., McCabe, J., McCormack, J., Monro, G., and Whitelaw, M. 2012. A framework for understanding generative art. Digital Creativity 3-4, 239–259.
Downen, P. and Ariola, Z.M. 2021. Classical (co)recursion: programming. CoRR abs/2103.06913.
Downen, P., Sullivan, Z., Ariola, Z.M., and Peyton Jones, S. 2019. Codata in action. European symposium on programming, Springer International Publishing Cham, 119–146.
Duregård, J., Jansson, P., and Wang, M. 2012. Feat: Functional enumeration of algebraic types. SIGPLAN Not. 47, 12, 61–72.
Erwig, M. and Kollmansberger, S. 2006. FUNCTIONAL PEARLS: Probabilistic functional programming in haskell. Journal of Functional Programming 16, 1, 21–34.
Felleisen, M., Findler, R.B., Flatt, M., and Krishnamurthi, S. 2018. How to design programs, second edition: An introductino to programming and computing. The MIT Press.
Fowler, M. 2005. Fluent interface. https://www.martinfowler.com/bliki/FluentInterface.html.
Freeman, S. and Pryce, N. 2006. Evolving an embedded domain-specific language in java. Companion to the 21st ACM SIGPLAN symposium on object-oriented programming systems, languages, and applications, Association for Computing Machinery, 855–865.
Gibbons, J. 2021. How to design co-programs. Journal of Functional Programming 31, e15.
Gibbons, J. 2022. Continuation-passing style, defunctionalization, accumulations, and associativity. The Art, Science, and Engineering of Programming 6, 7, 2.
Gibbons, J. and Jones, G. 1998. The under-appreciated unfold. SIGPLAN Not. 34, 1, 273–279.
Gibbons, J. and Wu, N. 2014. Folding domain-specific languages: Deep and shallow embeddings (functional pearl). SIGPLAN Not. 49, 9, 339–347.
Gil, Y. and Roth, O. 2019. Fling - A Fluent API Generator. 33rd european conference on object-oriented programming (ECOOP 2019), Schloss Dagstuhl – Leibniz-Zentrum für Informatik, 13:1–13:25.
Goldstein, H., Cutler, J.W., Dickstein, D., Pierce, B.C., and Head, A. 2024. Property-based testing in practice. Proceedings of the IEEE/ACM 46th international conference on software engineering, Association for Computing Machinery.
Hagino, T. 1989. Codatatypes in ML. Journal of Symbolic Computation 8, 6, 629–650.
Hawick, K.A. 2013. Fluent interfaces and domain-specific languages for graph generation and network analysis calculations. Proc. Int. Conf. On software engineering (SE’13), Computer Science, Massey University; IASTED, 752–759.
Kaes, S. 1988. Parametric overloading in polymorphic programming languages. ESOP ’88, Springer Berlin Heidelberg, 131–144.
Kidd, E. 2007. Build your own probability monads. https://www.randomhacks.net/files/build-your-own-probability-monads.pdf.
Kiselyov, O. 2005. Beyond church encoding: Boehm-berarducci isomorphism of algebraic data types and polymorphic lambda-terms. https://okmij.org/ftp/tagless-final/course/Boehm-Berarducci.html.
Kiselyov, O. 2012. Typed tagless final interpreters. In: J. Gibbons, ed., Generic and indexed programming: International spring school, SSGIP 2010, oxford, UK, march 22-26, 2010, revised lectures. Springer Berlin Heidelberg, Berlin, Heidelberg, 130–174.
Křikava, F., Miller, H., and Vitek, J. 2019. Scala implicits are everywhere: A large-scale study of the use of scala implicits in the wild. Proc. ACM Program. Lang. 3, OOPSLA.
Leijen, D. and Meijer, E. 2000. Domain specific embedded compilers. Proceedings of the 2nd conference on domain-specific languages, Association for Computing Machinery, 109–122.
Lemieux, C., Inala, J.P., Lahiri, S.K., and Sen, S. 2023. CodaMosa: Escaping coverage plateaus in test generation with pre-trained large language models. 2023 IEEE/ACM 45th international conference on software engineering (ICSE), 919–931.
Levy, T. 2016. A fluent API for automatic generation of FLuent APIs in java.
Lewis, J.R., Launchbury, J., Meijer, E., and Shields, M.B. 2000. Implicit parameters: Dynamic scoping with static types. Proceedings of the 27th ACM SIGPLAN-SIGACT symposium on principles of programming languages, Association for Computing Machinery, 108–118.
Lin, C. and Sheard, T. 2010. Pointwise generalized algebraic data types. Proceedings of the 5th ACM SIGPLAN workshop on types in language design and implementation, Association for Computing Machinery, 51–62.
McBride, C. 2001. The derivative of a regular type is its type of one-hole contexts. http://strictlypositive.org/diff.pdf.
Meijer, E., Fokkinga, M., and Paterson, R. 1991. Functional programming with bananas, lenses, envelopes and barbed wire. Functional programming languages and computer architecture, Springer Berlin Heidelberg, 124–144.
Nakamaru, T., Ichikawa, K., Yamazaki, T., and Chiba, S. 2017. Silverchain: A fluent API generator. SIGPLAN Not. 52, 12, 199–211.
Nakamaru, T., Matsunaga, T., Yamazaki, T., Akiyama, S., and Chiba, S. 2020. An empirical study of method chaining in java. Proceedings of the 17th international conference on mining software repositories, Association for Computing Machinery, 93–102.
Odersky, M., Blanvillain, O., Liu, F., Biboudis, A., Miller, H., and Stucki, S. 2017. Simplicitly: Foundations and applications of implicit function types. Proc. ACM Program. Lang. 2, POPL.
Oliveira, B.C.d.S. and Cook, W.R. 2012. Extensibility for the masses: Practical extensibility with object algebras. Proceedings of the 26th european conference on object-oriented programming, Springer-Verlag, 2–27.
Oliveira, B.C.D.S. and Gibbons, J. 2010. Scala for generic programmers: Comparing haskell and scala support for generic programming. Journal of Functional Programming 20, 3–4, 303–352.
Oliveira, B.C.d.S., Moors, A., and Odersky, M. 2010. Type classes as objects and implicits. Proceedings of the ACM international conference on object oriented programming systems languages and applications, Association for Computing Machinery, 341–360.
Ostermann, K. and Jabs, J. 2018. Dualizing generalized algebraic data types by matrix transposition. Programming languages and systems, Springer International Publishing, 60–85.
Peyton Jones, S., Vytiniotis, D., Weirich, S., and Washburn, G. 2006. Simple unification-based type inference for GADTs. SIGPLAN Not. 41, 9, 50–61.
Reddy, S., Lemieux, C., Padhye, R., and Sen, K. 2020. Quickly generating diverse valid test inputs with reinforcement learning. 2020 IEEE/ACM 42nd international conference on software engineering (ICSE), 1410–1421.
Reynolds, J.C. 1972. Definitional interpreters for higher-order programming languages. Proceedings of the ACM annual conference - volume 2, Association for Computing Machinery, 717–740.
Roth, O. and Gil, Y. 2023. Fluent APIs in functional languages. Proceedings of the ACM on Programming Languages 7, OOPSLA1, 876–901.
Runciman, C., Naylor, M., and Lindblad, F. 2008. Smallcheck and lazy smallcheck: Automatic exhaustive testing for small values. Proceedings of the first ACM SIGPLAN symposium on haskell, Association for Computing Machinery, 37–48.
Scibior, A., Ghahramani, Z., and Gordon, A.D. 2015. Practical probabilistic programming with monads. Proceedings of the 2015 ACM SIGPLAN symposium on haskell, Association for Computing Machinery, 165–176.
Scibior, A., Kammar, O., and Ghahramani, Z. 2018. Functional programming for modular bayesian inference. Proc. ACM Program. Lang. 2, ICFP.
Sheard, T. and Pasalic, E. 2008. Meta-programming with built-in type equality. Electronic Notes in Theoretical Computer Science 199, 49–65.
Shrestha, N., Barik, T., and Parnin, C. 2021. Unravel: A fluent code explorer for data wrangling. The 34th annual ACM symposium on user interface software and technology, Association for Computing Machinery, 198–207.
Sullivan, Z. 2019. Exploring codata: The relation to object-orientation. University of Oregon, Computer; Information Sciences Department.
Swierstra, W. 2008. Data types à la carte. Journal of Functional Programming 18, 4, 423–436.
Thibodeau, D., Cave, A., and Pientka, B. 2016. Indexed codata types. SIGPLAN Not. 51, 9, 351–363.
Vuković, M., Vujović, V., Štaka, Z., and Milinković, S. 2023. Domain-specific language for modeling fluent API. 2023 15th international conference on electronics, computers and artificial intelligence (ECAI), 1–6.
Wadler, P. 1989. Theorems for free! Proceedings of the fourth international conference on functional programming languages and computer architecture, Association for Computing Machinery, 347–359.
Wadler, P. 1998. The expression problem. https://homepages.inf.ed.ac.uk/wadler/papers/expression/expression.txt.
Wadler, P. and Blott, S. 1989. How to make ad-hoc polymorphism less ad hoc. Proceedings of the 16th ACM SIGPLAN-SIGACT symposium on principles of programming languages, Association for Computing Machinery, 60–76.
Wadler, P., Taha, W., and MacQueen, D. 1998. How to add laziness to a strict language without even being odd. SML’98, the SML workshop.
Xi, H., Chen, C., and Chen, G. 2003. Guarded recursive datatype constructors. Proceedings of the 30th ACM SIGPLAN-SIGACT symposium on principles of programming languages, Association for Computing Machinery, 224–235.
Zhang, W., David, C., and Wang, M. 2022. Decomposition without regret. https://arxiv.org/abs/2204.10411.

  1. SBT 1.0.0 以降を使用していることを前提とする。↩︎

  2. 【訳注】原文のライセンス条件にしたがい、日本語訳も同様に CC BY-SA 4.0 が適用される。↩︎

  3. ここで挙げた例はかなり単純である。エスケープ解析を用いるコンパイラは、sum 外部からの total 参照が不可能であることと、それ故 sum は純粋(あるいは参照透過的)であることを認識することができる。エスケープ解析は十分に研究された技法である。しかし、一般的なケースについて考えると、この問題ははるかに難しくなる。値がプログラム内の複数箇所で同時に参照されていないため値の変更が他の場所で観測されることはない、ということを確認したい場合は往々にしてある。この性質は、たとえば蓄積変数をさまざまな処理ステージに渡す場合などに利用できるかもしれない。このような操作を実現するには、サブ構造型システム(substructual type system)と呼ばれる仕組みを持つプログラミング言語が必要である。Rust はアフィン型を用いたこのようなシステムを備えている。Haskell では線形型が開発中である。↩︎

  4. この「クラス」という言葉は、厳密には、Scala や Java における class を意味するものではない↩︎

  5. 拡張メソッドは「型の強化(type enrichment)」や「ピンピング(pimping)」と呼ばれることもあるが、これらは古い用語であり、現在は使用しない。↩︎

  6. パラメトリック多相は抽象化の境界を表しており、型の具体的な詳細へのアクセスを制約している。定義の時点では、A が具体的にどの型をとるかはわからず、具体的な型は、使用時にのみ判明する。ここでも定義場所と呼び出し場所の違いが表れているのは興味深いが、それはさておき、この抽象化の境界によって、自由定理(free theorems)[Wadler 1989]と呼ばれる推論の一種が可能になる。たとえば、A => A という型をもつ関数があれば、それは恒等関数であることがわかる。この型をもつ可能性のある関数はそれだけである。ただし、JVM ではパラメトリック多相によって導入された抽象化の境界を破ることができる。すべての値に対して equalshashCode などのメソッドを呼び出せる上、実行時の型情報が反映されたランタイムタグを調べることができてしまう。↩︎

  7. 【訳注】 「末尾呼び出し」は、単に「末尾位置にある関数呼び出し」を指して使われることが多いと思うが、ここでは「コンパイラやランタイムによってスタックフレームを消費しない形に最適化された関数呼び出し」のことを指しているように思われる。↩︎

  8. 【訳注】 本書の第一版とも言える Scala with Cats では Cats への導入として ShowEq を紹介する6章がなかった。そのため、MonoidSemigroup が本書で最初に触れる型クラスだった。↩︎

  9. 【訳注】 以下は訳者の勝手な解釈だが、おそらく、次のようなことを言いたいのだと思う。BA に変換できるということは、BA に必要とされるすべての情報をもっており(もしくは生み出すことができ)、A を代替できる。この関係を仮に、BA の「変換的部分型」であると呼ぶとしたら、AB の間の変換的部分型関係とファンクター F[A]F[B] の間の変換的部分型関係は、AB の部分型関係と高カインド型 F[A]F[B] の部分型関係のアナロジーで考えることができる。この発想により、変換関係に変位の概念をもち込むことができる。不変ファンクター F についてのみ更に補足すると、FA => B (もしくはその逆)という単方向の変換があっても F[A]F[B] の間に変換的部分型関係が成立しないから「不変」である。ただし、双方向の変換が存在する場合、AB は互いに変換的部分型であるということになり、それは AB が(変換的に)同じ型であることを意味する。ゆえに F[A]F[B] は相互に変換可能であるという一見「不変」という言葉と矛盾した性質を示す。↩︎

  10. プログラミングに関する文献や Haskell では、purepointreturnflatMapbind>>= と呼ばれることがある。これは単に用語の違いに過ぎない。ここでは、Cats や Scala 標準ライブラリに合わせて flatMap という用語を使用する。↩︎

  11. 【訳注】おそらく、given インスタンスの検索という文脈が問題なのではなく、Id[Int] を要求する箇所に Int 値を用いることは可能でも、F[Int] のように抽象化された型コンストラクタを要求する文脈において IntId[Int] へと暗黙変換することはできない、ということだと思われる。↩︎

  12. Cats は EitherT 用の MonadError インスタンスを提供しているので、raiseError を使っても pure と同じようにインスタンスを作成できる。↩︎

  13. オートボットのニューラルネットワークが Scala で実装されているのはよく知られた事実である。一方、ディセプティコンの頭脳はもちろん動的型付けである。↩︎

  14. この用語はUnderscoreの2017アワードにおいて、もっとも意味のわからない関数型プログラミング用語にも選ばれた。↩︎

  15. Semigroupalは、この論文ではモノイダル(monoidal)と呼ばれている。↩︎

  16. 完全な図については Rob Norris 氏の Cats Infographic を参照。↩︎

  17. HTML 仕様は、HTML の解析について非常に寛容である。たとえば head タグを定義しなくても通常は補完される。しかし、ここで作成する API ではそのような寛容さは許可しない。↩︎

  18. 【訳注】 英語で indexed data のほうが generalized algebraic data types よりも短くてタイピングが楽だと言っている。↩︎

  19. 数値的な問題により、色にわずかな差異が生じることがあるが、それは無視すべきである。↩︎

  20. TUI ライブラリに興味があるなら、Rust 向けにはラタトゥイユをもじったユーモラスな名前の ratatui、Haskell 向けには brick、Python 向けには Textual を見てみるとよいかもしれない。↩︎

  21. 【訳注】 前回の実装では、和集合においては最初にマッチしたパターンが常に採用され、それ以外のパターンを選んだほうが後続の入力についてより長くマッチするケースを考慮できていなかった。そのため、たとえば (z|zxy)ab という正規表現に対して zxyab という入力がマッチしない、という問題があった。↩︎

  22. 厳密には、これは警告であってエラーではない。ここでは scalac-Xfatal-warnings フラグを設定していることによりエラー扱いされている。↩︎

  23. Hadoop には shuffle フェーズも存在するが、ここでは無視する。↩︎

  24. A closely related library called Spire already provides that abstractions.↩︎