実例によるPureScript
このリポジトリには、Phil FreemanによるPureScript by Exampleのコミュニティフォークが含まれます。 同書は "The PureScript book" としても知られています。 このバージョンはコードと演習が最近のバージョンのコンパイラ、ライブラリ、ツールで動くように更新されています。 PureScriptのエコシステムの最新の機能を紹介すべく書き直された章もあります。
本書をお楽しみいただき、お役立ちいただけましたら、Leanpubの原書の購入をご検討ください。
翻訳:日本語(本訳)
現状
本書は言語の進化に伴って継続的に更新されているため、内容に関して発見したどんな問題でもご報告ください。 より初心者にやさしくできそうな分かりづらい節を指摘するような単純なものであれ、共有いただいたどんなフィードバックにも感謝します。
各章には単体テストも加えられているので、演習への自分の回答が正しいかどうか確かめることができます。 テストの最新の状態については#79を見てください。
本書について
PureScriptは、表現力のある型を持つ、小さくて強力で静的に型付けされたプログラミング言語です。 Haskellで書かれ、またこの言語から着想を得ています。 そしてJavaScriptにコンパイルされます。
JavaScriptでの関数型プログラミングは最近かなりの人気を誇るようになりましたが、コードを書く上で統制された環境が欠けていることが大規模なアプリケーション開発の妨げとなっています。 PureScriptは、強力に型付けされた関数型プログラミングの力をJavaScriptでの開発の世界に持ち込むことにより、この問題の解決を目指しています。
本書は、基礎(開発環境の立ち上げ)から応用に至るまでの、PureScriptプログラミング言語の始め方を示します。
各章は特定の課題により動機付けられており、その問題を解いていく過程において、新しい関数型プログラミングの道具と技法が導入されていきます。 以下は本書で解いていく課題の幾つかの例です。
- マップと畳み込みを使ったデータ構造の変換
- アプリカティブ関手を使ったフォームフィールドの検証
- QuickCheckによるコードの検査
- Canvasの使用
- 領域特化言語の実装
- DOMの取り回し
- JavaScriptの相互運用性
- 並列非同期実行
使用許諾
Copyright (c) 2014-2017 Phil Freeman.
The text of this book is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License: https://creativecommons.org/licenses/by-nc-sa/3.0/deed.en_US.
Some text is derived from the PureScript Documentation Repo, which uses the same license, and is copyright various contributors.
The exercises are licensed under the MIT license.
Copyright (C) 2015-2018 aratama.
Copyright (C) 2022, 2023 gemmaro.
この翻訳はaratama氏による翻訳を元に改変を加えています。
同氏の翻訳リポジトリはaratama/purescript-book-ja
に、Webサイトは実例によるPureScriptにあります。
aratama氏訳の使用許諾は以下の通りです。
This book is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
本書はクリエイティブコモンズ 表示 - 非営利 - 継承 3.0 非移植ライセンスでライセンスされています。
本翻訳も原文と原翻訳にしたがい、 Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported Licenseの下に使用が許諾されます。
導入
関数型JavaScript
関数型プログラミングの手法がJavaScriptに姿を現しはじめてからしばらく経ちます。
-
UnderscoreJSなどのライブラリがあれば、開発者は
map
やfilter
、reduce
といった実績のある関数を活用して、小さいプログラムを組み合わせることで大きなプログラムを作ることができます。var sumOfPrimes = _.chain(_.range(1000)) .filter(isPrime) .reduce(function(x, y) { return x + y; }) .value();
-
NodeJSにおける非同期プログラミングでは、第一級の値としての関数をコールバックを定義するために多用しています。
import { readFile, writeFile } from 'fs' readFile(sourceFile, function (error, data) { if (!error) { writeFile(destFile, data, function (error) { if (!error) { console.log("File copied"); } }); } });
-
Reactやvirtual-domなどのライブラリは、アプリケーションの状態についての純粋な関数としてその外観をモデル化しています
関数は大幅な生産性の向上を齎しうる単純な抽象化を可能にします。 しかし、JavaScriptでの関数型プログラミングには欠点があります。 JavaScriptは冗長で、型付けされず、強力な抽象化の形式を欠いているのです。 また、野放図なJavaScriptコードは等式推論がとても困難です。
PureScriptはこうした課題への対処を目指すプログラミング言語です。 PureScriptは軽量な構文を備えていますが、この構文によりとても表現力豊かでありながら分かりやすく読みやすいコードが書けるのです。 強力な抽象化を支援する豊かな型システムも採用しています。 また、JavaScriptやJavaScriptへとコンパイルされる他の言語と相互運用するときに重要な、高速で理解しやすいコードを生成します。 概してPureScriptとは、純粋関数型プログラミングの理論的な強力さと、JavaScriptのお手軽で緩いプログラミングスタイルとの、とても現実的なバランスを狙った言語だということを理解して頂けたらと思います。
なお、PureScriptはJavaScriptのみならず他のバックエンドを対象にできますが、本書ではwebブラウザとnode環境に焦点を絞ります。
型と型推論
動的型付けの言語と静的型付けの言語をめぐる議論については充分に文書化されています。 PureScriptは静的型付けの言語、つまり正しいプログラムはコンパイラによって型を与えられる言語です。 またこの型は、その動作を示すものです。 逆に言えば、型を与えることができないプログラムは誤ったプログラムであり、コンパイラによって拒否されます。 動的型付けの言語とは異なり、PureScriptでは型はコンパイル時にのみ存在し、実行時には一切その表現がありません。
多くの点で、PureScriptの型とこれまでJavaやC#のような他の言語で見てきたであろう型が異なっていることにも、注意することが大切です。
大まかに言えばPureScriptの型はJavaやC#と同じ目的を持っているものの、PureScriptの型はMLやHaskellのような言語に影響を受けています。
PureScriptの型は表現力豊かであり、開発者はプログラムについての強い主張を表明できます。
最も重要なのはPureScriptの型システムが型推論に対応していることです。
型推論があれば他の言語より明示的な型注釈が遥かに少なく済み、型システムを厄介者ではなく道具にしてくれます。
単純な一例として、次のコードは数を定義していますが、Number
型への言及はコードのどこにもありません。
iAmANumber =
let square x = x * x
in square 42.0
より込み入った次の例では、コンパイラにとって未知の型が存在します。 それでも、型注釈なく型の正しさを確かめられていることを示しています。
iterate f 0 x = x
iterate f n x = iterate f (n - 1) (f x)
ここで
x
の型は不明ですが、x
がどんな型を持っているかにかかわらず、iterate
が型システムの規則に従っていることをコンパイラは検証できます。
本書で納得していただきたい(または既にお持ちの信条に寄り添って改めて断言したい)ことは、静的型が単にプログラムの正しさに自信を持つためだけのものではなく、それ自体の正しさによって開発の手助けになるものでもあるということです。JavaScriptではごく単純な抽象化を施すのでも大規模なコードのリファクタリングをすることは難しいですが、型検証器のある表現力豊かな型システムは、リファクタリングさえ楽しく対話的な体験にしてくれます。
加えて、型システムによって提供されるこの安全網は、より高度な抽象化を可能にします。 実際に、根本的に型駆動な抽象化の強力な形式である型クラスをPureScriptは提供しています。 この型クラスとは、関数型プログラミング言語Haskellによって有名になりました。
多言語webプログラミング
関数型プログラミングは成功を収めてきました。 特に成功している応用例を挙げると、データ解析、構文解析、コンパイラの実装、ジェネリックプログラミング、並列処理といった具合に、枚挙に暇がありません。
PureScriptのような関数型言語でアプリケーション開発の最初から最後までを実施できるでしょう。 値や関数の型を提供することで既存のJavaScriptコードをインポートし、通常のPureScriptコードからこれらの関数を使用する機能をPureScriptは提供しています。 この手法については本書の後半で見ていくことになります。
しかし、PureScriptの強みの1つは、JavaScriptを対象とする他の言語との相互運用性にあります。 アプリケーションの開発の一部にだけPureScriptを使用し、JavaScriptの残りの部分を記述するのに1つ以上の他の言語を使用するという方法もあります。
幾つかの例を示します。
- 中核となる処理はPureScriptで記述し、ユーザーインターフェイスはJavaScriptで記述する
- JavaScriptや、他のJavaScriptにコンパイルする言語でアプリケーションを書き、PureScriptでそのテストを書く
- 既存のアプリケーションのユーザインターフェースのテストを自動化するためにPureScriptを使用する
本書では小規模な課題をPureScriptで解決することに焦点を当てます。 ここで学ぶ手法は大規模なアプリケーションに組み込むこともできますが、JavaScriptからPureScriptコードを呼び出す方法、及びその逆についても見ていきます。
ソフトウェア要件
本書のソフトウェア要件は最小限です。 第1章では開発環境の構築を一から案内します。 これから使用するツールは、ほとんどの現代のオペレーティングシステムの標準リポジトリで使用できるものです。
PureScriptコンパイラ自体はバイナリの配布物としてもダウンロードできますし、最新のGHC Haskellコンパイラが動く任意のシステム上でソースからのビルドもできます。 次の章ではこの手順を進めていきます。
本書のこのバージョンのコードは0.15.*
バージョンのPureScriptコンパイラと互換性があります。
読者について
読者はJavaScriptの基本を既に理解しているものと仮定します。 既にNPMやBowerのようなJavaScriptのエコシステムでの経験があれば、自身の好みに応じて標準設定をカスタマイズしたい場合などに役に立ちます。 ですがそのような知識は必要ありません。
関数型プログラミングの事前知識は必要ありませんが、あっても決して害にはならないでしょう。 新しい考えかたは実例と共に登場するため、これから使っていく関数型プログラミングからこうした概念に対する直感が形成されることでしょう。
PureScriptはプログラミング言語Haskellに強く影響を受けているため、Haskellに通じている読者は本書で提示された概念や構文の多くに見覚えがあるでしょう。 しかし、PureScriptとHaskellの間には数多くの重要な違いがあることも理解しておくと良いでしょう。 ここで紹介する概念の多くはHaskellでも同じように解釈できるとはいえ、どちらかの言語での考え方を他方の言語でそのまま応用しようとすることは、必ずしも適切ではありません。
本書の読み進めかた
本書のほとんどの章が各章毎に完結しています。 しかし、関数型プログラミングの経験がほとんどない初心者の方は、各章を順番に進めていくのが賢明です。 最初の数章は本書の後半の内容を理解するのに必要な下地作りです。 関数型プログラミングの考え方に充分通じた読者(特にMLやHaskellのような強く型付けされた言語での経験を持つ読者)なら、本書の前半の章を読まなくても、後半の章のコードの大まかな理解を得ることが恐らく可能でしょう。
各章では1つの実用的な例に焦点を当て、新しい考え方を導入するための動機を与えます。 各章のコードは本書のGitHubのリポジトリから入手できます。 該当の章のソースコードから抜粋したコード片が含まれる章もありますが、本書の内容に沿ってリポジトリのソースコードを読まれると良いでしょう。 長めの節には、理解を確かめられるように対話式モードのPSCiで実行できる短めのコード片が含まれます。
コード例は次のように等幅フォントで示されます。
module Example where
import Effect.Console (log)
main = log "Hello, World!"
先頭にドル記号がついた行は、コマンドラインに入力されたコマンドです。
$ spago build
通常、これらのコマンドはLinuxやMac OSの利用者に合わせたものになっています。 そのためWindowsの利用者は小さな変更を加える必要があるかもしれません。 ファイル区切り文字を変更したり、シェルの組み込み機能をWindowsの相当するものに置き換えるなどです。
PSCi対話式モードプロンプトに入力するコマンドは、行の先頭に山括弧が付けられています。
> 1 + 2
3
各章には演習が含まれており、難易度も示されています。 内容を完全に理解するために、各章の演習に取り組むことを強くお勧めします。
本書は初心者にPureScriptへの導入を提供することを目的としており、課題に対するお決まりの解決策の一覧を提供するような類の本ではありません。 初心者にとっては楽しい挑戦になるはずです。 内容を読んで演習に挑戦すれば得るものがあることでしょう。 そして何よりも大切なのは、自分自身でコードを書いてみることです。
困ったときには
もしどこかでつまずいたときには、PureScriptを学べるオンラインで利用可能な資料が沢山あります。
- PureScriptのDiscordサーバは抱えている問題についてチャットするのに良い場所です。 こちらのサーバはPureScriptについてのチャット専用です。
- PureScriptのDiscourseフォーラムもよくある問題への解決策を探すのに良い場所です。
- PureScript: Jordan's Referenceは別のかなり深く踏み込んだ学習資料です。 本書中の概念で理解しにくいものがあったら、そちらの参考書の対応する節を読むとよいでしょう。
- PursuitはPureScriptの型と関数を検索できるデータベースです。 Pursuitのヘルプページを読むとどのような種類の検索ができるのかがわかります。
- 非公式のPureScript Cookbookは「Xするにはどうするの」といった類の質問にコードを混じえて答えを提供します。
- PureScriptドキュメントリポジトリには、PureScriptの開発者や利用者が書いた幅広い話題の記事と例が集まっています。
- PureScriptのwebサイトには幾つかの学習資料へのリンクがあります。 コード例、映像、他の初心者向け資料などです。
- Try PureScript!は利用者がwebブラウザでPureScriptのコードをコンパイルできるwebサイトです。 幾つかの簡単なコードの例もあります。
もし例を読んで学ぶ方が好きでしたら、GitHubのpurescript、purescript-node、purescript-contrib組織にはPureScriptコードの例が沢山あります。
著者について
私はPureScriptコンパイラの最初の開発者です。 カリフォルニア州ロサンゼルスを拠点にしており、8ビットパーソナルコンピュータであるAmstrad CPC上のBASICでまだ幼い時にプログラミングを始めました。 それ以来、私は幾つものプログラミング言語(JavaやScala、C#、F#、Haskell、そしてPureScript)で専門的に業務に携わってきました。
プロとしての経歴が始まって間もなく、私は関数型プログラミングと数学の関係を理解するようになり、そしてプログラミング言語Haskellを使って関数型の概念の学習を楽しみました。
JavaScriptでの経験をもとに、私はPureScriptコンパイラの開発を始めることにしました。 気が付くとHaskellのような言語から取り上げた関数型プログラミングの手法を使っていましたが、それを応用するためのもっと理に適った環境を求めていました。 そのとき検討した案のなかには、Haskellからその意味論を維持しながらJavaScriptへとコンパイルするいろいろな試み(Fay、Haste、GHCJS)もありました。 しかし私が興味を持っていたのは、この問題へ別の切り口からアプローチすると、どの程度うまくいくのかということでした。 そのアプローチとは、JavaScriptの意味論を維持しつつ、Haskellのような言語の構文と型システムを楽しむことなのです。
私はブログを運営しており、Twitterで連絡をとることもできます。
謝辞
現在に至るまでPureScriptに手を貸してくださった多くの協力者に感謝したいと思います。 コンパイラ、ツール、ライブラリ、ドキュメント、テストでの、巨大で組織的な尽力なくしては、プロジェクトは間違いなく失敗していたことでしょう。
本書の表紙に示されたPureScriptのロゴはGareth Hughesによって作成されたもので、Creative Commons Attribution 4.0 licenseの条件の下で再利用させて頂いています 。
最後に、本書の内容に関する反応や訂正をくださった全ての方に、心より感謝したいと思います。
はじめよう
この章の目標
本章では実際のPureScriptの開発環境を立ち上げ、幾つかの演習を解き、本書で提供されているテストを使って答えを確認します。 もし映像を見る学習の仕方が合っているようでしたら、本章を通しで進めるビデオが役に立つでしょう。
環境構築
最初にドキュメンテーションリポジトリにあるこのはじめの手引きを通しで進め、環境の構築と言語の基礎を学んでください。Project Eulerの解答例にあるコードがわかりにくかったり見慣れない構文を含んでいたとしても心配要りません。来たる章でこの全ての内容をとても丁寧に押さえていきます。
エディタの対応
PureScriptを書く上で(例えば本書の演習を解くなど)お好みのエディタを使えます。 エディタの対応についてのドキュメントを参照してください。
なお、完全なIDE対応のため、開いたプロジェクトのルートに
spago.dhall
があることを期待するエディタもあります。 例えば本章の演習に取り組む場合、chapter2
ディレクトリを開くとよいでしょう。VS Codeを使っている場合、提供されているワークスペースを使って全ての章を同時に開くことができます。
演習を解く
ここまでで必要な開発ツールをインストールできているので、本書のリポジトリをクローンしてください。
git clone https://github.com/purescript-contrib/purescript-book.git
本書のリポジトリには各章に付属してPureScriptのコード例と演習のための単体テストが含まれます。
演習の解法を白紙に戻すために必要な初期設定があり、この設定をすることで解く準備ができます。
この工程はprepareExercises.sh
スクリプトを使えば簡単にできます。
cd purescript-book
./scripts/prepareExercises.sh
git add .
git commit --all --message "Exercises ready to be solved"
それではこの章のテストを走らせましょう。
cd exercises/chapter2
spago test
以下の成功した旨のテスト出力が出るでしょう。
→ Suite: Euler - Sum of Multiples
✓ Passed: below 10
✓ Passed: below 1000
All 2 tests passed! 🎉
なお、(src/Euler.purs
にある)answer
関数は任意の整数以下の3と5の倍数を見付けるように変更されています。
(test/Main.purs
にある)このanswer
関数のためのテストスートははじめの手引きの冒頭にあるテストよりも網羅的です。
前の方の章を読んでいる間はこのテストフレームワークの仕組みを理解しようと思い詰めなくて大丈夫です。
本書の残りの部分には多くの演習が含まれます。
Test.MySolutions
モジュール (test/MySolutions.purs
)
に自分の解法を書けば、提供されているテストスートを使って確認できます。
テスト駆動開発でこの次の演習を一緒に進めてみましょう。
演習
- (普通)直角三角形の対角線(あるいは斜辺)の長さを他の2つの辺の長さを使って計算する
diagonal
関数を書いてください。
解法
この演習のテストを有効にするところから始めます。
以下に示すようにブロックコメントの開始を数行下に下げてください。
ブロックコメントは{-
から始まり-}
で終わります。
suite "diagonal" do
test "3 4 5" do
Assert.equal 5.0 (diagonal 3.0 4.0)
test "5 12 13" do
Assert.equal 13.0 (diagonal 5.0 12.0)
{- Move this block comment starting point to enable more tests
ここでテストを走らせようとすると、コンパイルエラーに直面します。
なぜならdiagonal
関数をまだ実装していないからです。
$ spago test
Error found:
in module Test.Main
at test/Main.purs:21:27 - 21:35 (line 21, column 27 - line 21, column 35)
Unknown value diagonal
まずはこの関数に欠陥があるときに何が起こるのか見てみましょう。
以下のコードをtest/MySolutions.purs
に追加してください。
import Data.Number (sqrt)
diagonal w h = sqrt (w * w + h)
そしてspago test
を走らせて確認してください。
→ Suite: diagonal
☠ Failed: 3 4 5 because expected 5.0, got 3.605551275463989
☠ Failed: 5 12 13 because expected 13.0, got 6.082762530298219
2 tests failed:
あーあ、全然正しくありませんでした。 ピタゴラスの定理を正しく適用して修正しましょう。 関数を以下のように変えます。
diagonal w h = sqrt (w * w + h * h)
ここでもう一度spago test
としてみると全てのテストが通っています。
→ Suite: Euler - Sum of Multiples
✓ Passed: below 10
✓ Passed: below 1000
→ Suite: diagonal
✓ Passed: 3 4 5
✓ Passed: 5 12 13
All 4 tests passed! 🎉
成功です。 これで次の演習を自力で解くための準備ができました。
演習
- (簡単)指定された半径の円の面積を計算する関数
circleArea
を書いてみましょう。Numbers
モジュールで定義されているpi
定数を使用してください。 手掛かり:import Data.Number
文を修正して、pi
をインポートすることを忘れないようにしましょう。 - (普通)
Int
を取って100
で割ったあとの余りを返す関数leftoverCents
を書いてみましょう。rem
関数を使ってください。Pursuitでこの関数を検索して、使用法とどのモジュールからインポートしてくるか調べましょう。補足:自動補完の提案を有効にしていたら、IDE側でこの関数の自動的なインポートに対応しているかもしれません。
まとめ
この章ではPureScriptコンパイラとSpagoツールをインストールしました。 演習の解答の書き方と正しさの確認方法も学びました。
この先の章にはもっと沢山の演習があり、それらに取り組むうちに内容を学ぶ助けになっているでしょう。 演習のどこかでお手上げになったら、本書の困ったときはの節に挙げられているコミュニティの資料のどれかに手を伸ばしたり、本書のリポジトリでイシューを報告したりできます。 こうした演習の敷居を下げることに繋がる読者のフィードバックのお陰で本書が改善されています。
章の全ての演習を解いたら、no-peeking/Solutions.purs
にあるものと解答とを比べられます。
カンニングはせず、演習を誠実に自力で解く労力を割いてください。
そしてたとえ行き詰まったにしても、まずはコミュニティメンバーに尋ねてみるようにしてください。
ネタバレをするよりも小さな手掛かりをあげたいからです。
もっとエレガントな解法(とはいえ本書で押さえられている知識のみで済むもの)を見つけたときはPRを送ってください。
リポジトリは継続して改訂されているため、それぞれの新しい章を始める前に更新を確認するようにしてください。
関数とレコード
この章の目標
この章では、関数及びレコードというPureScriptプログラムの2つの構成要素を導入します。更に、どのようにPureScriptプログラムを構造化するのか、どのように型をプログラム開発に役立てるかを見ていきます。
連絡先のリストを管理する簡単な住所録アプリケーションを作成していきます。 このコード例により、PureScriptの構文から幾つかの新しい概念を導入します。
このアプリケーションのフロントエンドは対話式モードであるPSCiを使うようにしていますが、このコードを土台にJavaScriptでフロントエンドを書くこともできるでしょう。 実際に後の章で、フォームの検証と保存及び復元の機能を追加します。
プロジェクトの準備
この章のソースコードは src/Data/AddressBook.purs
というファイルに含まれています。
このファイルは次のようなモジュール宣言とインポート一覧から始まります。
module Data.AddressBook where
import Prelude
import Control.Plus (empty)
import Data.List (List(..), filter, head)
import Data.Maybe (Maybe)
ここでは、幾つかのモジュールをインポートします。
Prelude
モジュールには標準的な定義と関数の小さな集合が含まれます。purescript-prelude
ライブラリから多くの基礎的なモジュールを再エクスポートしているのです。Control.Plus
モジュールにはempty
値が定義されています。Data.List
モジュールはlists
パッケージで提供されています。 またこのパッケージはSpagoを使ってインストールできます。 モジュールには連結リストを使うために必要な幾つかの関数が含まれています。Data.Maybe
モジュールは、省略可能な値を扱うためのデータ型と関数を定義しています。
訳者注:2つのドット (..
) を使用すると、
指定された型構築子の全てのデータ構築子をインポートできます。
これらのモジュールのインポート内容が括弧内で明示的に列挙されていることに注目してください(Prelude
は除きます。これは一括インポートされるのが普通です)。
明示的な列挙はインポート内容の衝突を避けるのに役に立つので、一般に良い習慣です。
ソースコードリポジトリをクローンしたと仮定すると、この章のプロジェクトは次のコマンドでSpagoを使用して構築できます。
$ cd chapter3
$ spago build
単純な型
JavaScriptの原始型に対応する組み込みデータ型として、PureScriptでは数値型と文字列型、真偽型の3つが定義されています。
これらはPrim
モジュールで定義されており、全てのモジュールに暗黙にインポートされます。
それぞれNumber
、String
、Boolean
と呼ばれており、PSCiで:type
コマンドを使うと簡単な値の型を表示させて確認できます。
$ spago repl
> :type 1.0
Number
> :type "test"
String
> :type true
Boolean
PureScriptには他にも、整数、文字、配列、レコード、関数といった組み込み型が定義されています。
小数点以下を省くと整数になり、型 Number
の浮動小数点数の値と区別されます。
> :type 1
Int
二重引用符を使用する文字列直値とは異なり、文字直値は一重引用符で囲みます。
> :type 'a'
Char
配列はJavaScriptの配列に対応していますが、JavaScriptの配列とは異なり、PureScriptの配列の全ての要素は同じ型を持つ必要があります。
> :type [1, 2, 3]
Array Int
> :type [true, false]
Array Boolean
> :type [1, false]
Could not match type Int with type Boolean.
最後の例は型検証器によるエラーを示しています。 配列の2つの要素の型を単一化(つまり等価にする意)するのに失敗したのです。
レコードはJavaScriptのオブジェクトに対応しており、レコード直値はJavaScriptのオブジェクト直値と同じ構文になっています。
> author = { name: "Phil", interests: ["Functional Programming", "JavaScript"] }
> :type author
{ name :: String
, interests :: Array String
}
この型が示しているのは指定されたオブジェクトが2つのフィールドを持っているということです。
String
型のフィールドname
とArray String
型のフィールドinterests
です。
後者はつまりString
の配列です。
ドットに続けて参照したいフィールドのラベルを書くとレコードのフィールドを参照できます。
> author.name
"Phil"
> author.interests
["Functional Programming","JavaScript"]
PureScriptの関数はJavaScriptの関数に対応します。 関数はファイルの最上位で定義でき、等号の前に引数を指定します。
import Prelude -- (+) 演算子をスコープに持ち込みます
add :: Int -> Int -> Int
add x y = x + y
代えて、バックスラッシュ文字に続けて空白文字で区切られた引数名のリストを書くことで、関数をインラインでも定義できます。
PSCiで複数行の宣言を入力するには、:paste
コマンドを使用して「貼り付けモード」に入ります。
このモードでは、Control-Dキーシーケンスを使って宣言を終了します。
> import Prelude
> :paste
… add :: Int -> Int -> Int
… add = \x y -> x + y
… ^D
PSCiでこの関数が定義されていると、次のように関数の隣に2つの引数を空白で区切って書くことで、関数をこれらの引数に適用 (apply) できます。
> add 10 20
30
字下げについての注意
PureScriptのコードは字下げの大きさに意味があります。ちょうどHaskellと同じで、JavaScriptとは異なります。コード内の空白の多寡は無意味ではなく、Cのような言語で中括弧によってコードのまとまりを示しているように、PureScriptでは空白がコードのまとまりを示すために使われているということです。
宣言が複数行に亙る場合、最初の行以外は最初の行の字下げより深くしなければなりません。
したがって、次は正しいPureScriptコードです。
add x y z = x +
y + z
しかし、次は正しいコードではありません。
add x y z = x +
y + z
後者では、PureScriptコンパイラはそれぞれの行毎に1つ、つまり2つの宣言であると構文解析します。
一般に、同じブロック内で定義された宣言は同じ深さで字下げする必要があります。 例えばPSCiでlet文の宣言は同じ深さで字下げしなければなりません。 次は正しいコードです。
> :paste
… x = 1
… y = 2
… ^D
しかし、これは正しくありません。
> :paste
… x = 1
… y = 2
… ^D
PureScriptの幾つかのキーワードは新たなコードのまとまりを導入します。 その中での宣言はそれより深く字下げされなければなりません。
example x y z =
let
foo = x * y
bar = y * z
in
foo + bar
これはコンパイルされません。
example x y z =
let
foo = x * y
bar = y * z
in
foo + bar
より多くを学びたければ(あるいは何か問題に遭遇したら)構文のドキュメントを参照してください。
独自の型の定義
PureScriptで新たな問題に取り組むときは、まずはこれから扱おうとする値の型の定義を書くことから始めるのがよいでしょう。最初に、住所録に含まれるレコードの型を定義してみます。
type Entry =
{ firstName :: String
, lastName :: String
, address :: Address
}
これはEntry
という型同義語を定義しています。
型Entry
は等号の右辺と等価ということです。
レコードの型はfirstName
、lastName
、phone
という3つのフィールドからなります。
2つの名前のフィールドは型String
を持ち、address
は以下で定義された型Address
を持ちます。
type Address =
{ street :: String
, city :: String
, state :: String
}
なお、レコードには他のレコードを含めることができます。
それでは、住所録のデータ構造として3つめの型同義語も定義してみましょう。 単に項目の連結リストとして表すことにします。
type AddressBook = List Entry
なお、List Entry
は Array Entry
とは同じではありません。
後者は項目の配列を表しています。
型構築子と種
List
は型構築子の一例になっています。
List
そのものは型ではなく、何らかの型 a
があるとき List a
が型になっています。
つまり、 List
は型引数a
を取り、新たな型 List a
を構築するのです。
なお、ちょうど関数適用と同じように、型構築子は他の型に並置するだけで適用されます。
実際、型List Entry
は型構築子List
が型Entry
に適用されたもので、項目のリストを表しています。
もし間違って(型注釈演算子 ::
を使って)型 List
の値を定義しようとすると、今まで見たことのない種類のエラーが表示されるでしょう。
> import Data.List
> Nil :: List
In a type-annotated expression x :: t, the type t must have kind Type
これは種エラーです。値がその型で区別されるのと同じように、型はその種によって区別されます。間違った型の値が型エラーになるように、間違った種の型は種エラーを引き起こします。
Number
や String
のような、値を持つ全ての型の種を表す Type
と呼ばれる特別な種があります。
型構築子にも種があります。
例えば種 Type -> Type
はちょうど List
のような型から型への関数を表しています。
ここでエラーが発生したのは、値が種 Type
であるような型を持つと期待されていたのに、 List
は種 Type -> Type
を持っているためです。
PSCiで型の種を調べるには、 :kind
命令を使用します。例えば次のようになります。
> :kind Number
Type
> import Data.List
> :kind List
Type -> Type
> :kind List String
Type
PureScriptの 種システム は他にも面白い種に対応していますが、それらについては本書の他の部分で見ていくことになるでしょう。
量化された型
説明しやすくするため、任意の2つの引数を取り最初のものを返す原始的な関数を定義しましょう。
> :paste
… constantlyFirst :: forall a b. a -> b -> a
… constantlyFirst = \a b -> a
… ^D
なお、
:type
を使ってconstantlyFirst
の型について尋ねた場合、もっと冗長になります。: type constantlyFirst forall (a :: Type) (b :: Type). a -> b -> a
型シグネチャには追加で種の情報が含まれます。
a
とb
が具体的な型であることが明記されています。
このforall
キーワードは、constantlyFirst
が全称量化された型を持つことを示しています。
つまりa
やb
をどの型に置き換えても良く、constantlyFirst
はその型で動作するのです。
例えば、a
をInt
、b
をString
と選んだとします。
その場合、constantlyFirst
の型を次のように特殊化できます。
Int -> String -> Int
量化された型を特殊化したいということをコードで示す必要はありません。
特殊化は自動的に行われます。
例えば、あたかも既にその型に備わっていたかの如くconstantlyFirst
を使えます。
> constantlyFirst 3 "ignored"
3
a
とb
にはどんな型でも選べますが、constantlyFirst
が返す型は最初の引数の型と同じでなければなりません(両方とも同じa
に「紐付く」からです)。
:type constantlyFirst true "ignored"
Boolean
:type constantlyFirst "keep" 3
String
住所録の項目の表示
それでは最初に、文字列で住所録の項目を表現する関数を書いてみましょう。
まずは関数に型を与えることから始めます。
型の定義は省略できますが、ドキュメントとしても役立つので型を書いておくようにすると良いでしょう。
実際、最上位の宣言に型註釈が含まれていないと、PureScriptコンパイラが警告を出します。
型宣言は関数の名前とその型を ::
記号で区切るようにして書きます。
showEntry :: Entry -> String
この型シグネチャが言っているのは、showEntry
は引数としてEntry
を取りString
を返す関数であるということです。
以下はshowEntry
のコードです。
showEntry entry = entry.lastName <> ", " <>
entry.firstName <> ": " <>
showAddress entry.address
この関数はEntry
レコードの3つのフィールドを連結し、単一の文字列にします。
ここで使用されるshowAddress
関数はaddress
フィールド中のレコードを文字列に変えます。
showAddress
の定義は次の通りです。
showAddress :: Address -> String
showAddress addr = addr.street <> ", " <>
addr.city <> ", " <>
addr.state
関数定義は関数の名前で始まり、引数名のリストが続きます。関数の結果は等号の後ろに定義します。フィールドはドットに続けてフィールド名を書くことで参照できます。PureScriptでは、文字列連結はJavaScriptのような単一のプラス記号ではなく、ダイアモンド演算子(<>
)を使用します。
はやめにテスト、たびたびテスト
PSCi対話式モードでは反応を即座に得られるので、素早い試作開発に向いています。 それではこの最初の関数が正しく動作するかをPSCiを使用して確認してみましょう。
まず、これまでに書いたコードをビルドします。
$ spago build
次に、PSCiを起動し、この新しいモジュールをインポートするために import
命令を使います。
$ spago repl
> import Data.AddressBook
レコード直値を使うと、住所録の項目を作成できます。レコード直値はJavaScriptの無名オブジェクトと同じような構文で名前に束縛します。
> address = { street: "123 Fake St.", city: "Faketown", state: "CA" }
それでは、この例に関数を適用してみてください。
> showAddress address
"123 Fake St., Faketown, CA"
showEntry
も、住所の例を含む住所録項目レコードを作って試しましょう。
> entry = { firstName: "John", lastName: "Smith", address: address }
> showEntry entry
"Smith, John: 123 Fake St., Faketown, CA"
住所録の作成
今度は住所録を扱う補助関数を幾つか書いてみましょう。 空の住所録を表す値が必要ですが、これは空のリストです。
emptyBook :: AddressBook
emptyBook = empty
既存の住所録に値を挿入する関数も必要でしょう。この関数を insertEntry
と呼ぶことにします。関数の型を与えることから始めましょう。
insertEntry :: Entry -> AddressBook -> AddressBook
この型シグネチャに書かれているのは、最初の引数としてEntry
、第2引数としてAddressBook
を取り、新しいAddressBook
を返すということです。
既存のAddressBook
を直接変更することはしません。
代わりに、同じデータが含まれている新しいAddressBook
を返します。
このようにAddressBook
は不変データ構造の一例となっています。
これはPureScriptにおける重要な考え方です。
変更はコードの副作用であり、効率良く挙動を探る上で妨げになります。
そのため可能な限り純粋関数や不変なデータにする方が好ましいのです。
insertEntry
を実装するのにData.List
のCons
関数が使えます。
この関数の型を見るには、PSCiを起動し :type
コマンドを使います。
$ spago repl
> import Data.List
> :type Cons
forall (a :: Type). a -> List a -> List a
この型シグネチャで書かれているのは、Cons
が何らかの型a
の値と型a
の要素のリストを取り、同じ型の項目を持つ新しいリストを返すということです。
a
をEntry
型として特殊化してみましょう。
Entry -> List Entry -> List Entry
しかし、 List Entry
はまさに AddressBook
ですから、次と同じになります。
Entry -> AddressBook -> AddressBook
今回の場合、既に適切な入力があります。
Entry
と AddressBook
に Cons
を適用すると、新しい AddressBook
を得ることができます。
これこそがまさに求めていた関数です。
insertEntry
の実装は次のようになります。
insertEntry entry book = Cons entry book
こうすると、等号の左側にある2つの引数entry
とbook
がスコープに導入されます。
それからCons
関数を適用し、結果を作成しています。
カリー化された関数
PureScriptの関数はきっかり1つの引数を取ります。
insertEntry
関数は2つの引数を取るように見えますが、カリー化された関数の一例なのです。
PureScriptでは全ての関数はカリー化されたものと見做されます。
カリー化が意味するのは複数の引数を取る関数を1度に1つ取る関数に変換することです。 関数を呼ぶときに1つの引数を渡し、これまた1つの引数を取る別の関数を返し、といったことを全ての引数が渡されるまで続けます。
例えばadd
に5
に渡すと別の関数が得られます。
その関数は整数を取り、5を足し、合計を結果として返します。
add :: Int -> Int -> Int
add x y = x + y
addFive :: Int -> Int
addFive = add 5
addFive
は部分適用の結果です。
つまり複数の引数を取る関数に、引数の全個数より少ない数だけ渡すのです。
試してみましょう。
なお、お済みでなければ
add
関数を定義しなくてはなりません。> import Prelude > :paste … add :: Int -> Int -> Int … add x y = x + y … ^D
> :paste
… addFive :: Int -> Int
… addFive = add 5
… ^D
> addFive 1
6
> add 5 1
6
カリー化と部分適用をもっと理解するには、例にあったadd
とは別の関数を2、3作ってみてください。
そしてそれができたらinsertEntry
に戻りましょう。
insertEntry :: Entry -> AddressBook -> AddressBook
(型シグネチャ中の)->
演算子は右結合です。
つまりコンパイラは型を次のように解釈します。
Entry -> (AddressBook -> AddressBook)
insertEntry
は単一の引数Entry
を取り、新しい関数を返します。
そして今度はその関数が単一の引数AddressBook
を取り、新しいAddressBook
を返します。
これはつまり、最初の引数だけを与えてinsertEntry
を部分適用できたりするということです。
PSCiで結果の型が見られます。
> :type insertEntry entry
AddressBook -> AddressBook
期待した通り、戻り値の型は関数になっていました。 この結果の関数に2つ目の引数も適用できます。
> :type (insertEntry entry) emptyBook
AddressBook
ただし、ここでの括弧は不要です。 以下は等価です。
> :type insertEntry entry emptyBook
AddressBook
これは関数適用が左に結合するためで、空白で区切った引数を次々に関数に指定するだけでいい理由もこれで分かります。
関数の型の->
演算子は関数の型構築子です。
この演算子は2つの型引数を取ります。
左右の被演算子はそれぞれ関数の引数の型と返り値の型です。
本書では今後、「2引数の関数」というように表現することがあることに注意してください。 しかしそれはカリー化された関数を意味していると考えるべきで、その関数は最初の引数を取り2つ目の引数を取る別の関数を返すのです。
今度は insertEntry
の定義について考えてみます。
insertEntry :: Entry -> AddressBook -> AddressBook
insertEntry entry book = Cons entry book
もし式の右辺に明示的に括弧をつけるなら、(Cons entry) book
となります。
つまりinsertEntry entry
はその引数が単に関数(Cons entry)
に渡されるような関数だということです。
ところがこの2つの関数はどんな入力についても同じ結果を返すので、となると同じ関数ではないですか。
よって、両辺から引数book
を削除できます。
insertEntry :: Entry -> AddressBook -> AddressBook
insertEntry entry = Cons entry
しかし今や同様の議論により、両辺から entry
も削除できます。
insertEntry :: Entry -> AddressBook -> AddressBook
insertEntry = Cons
この処理はイータ変換と呼ばれ、(その他の技法を併用して)ポイントフリー形式へと関数を書き換えるのに使えます。 つまり、引数を参照せずに関数を定義できるのです。
insertEntry
の場合、イータ変換によって「insertEntry
は単にリストにおけるconsだ」となり、とても明快な関数の定義になりました。
しかし、一般にポイントフリー形式のほうがいいのかどうかには議論の余地があります。
プロパティ取得子
よくあるパターンの1つとして、レコード中の個別のフィールド(または「プロパティ」)を取得することがあります。
Entry
からAddress
を取り出すインライン関数は次のように書けます。
\entry -> entry.address
PureScriptではプロパティ取得子という略記が使えます。この略記では下線文字は無名関数の引数として振舞うため、上記のインライン関数は次と等価です。
_.address
これは何段階のプロパティでも動くため、Entry
に関連する街を取り出す関数は次のように書けます。
_.address.city
以下は一例です。
> address = { street: "123 Fake St.", city: "Faketown", state: "CA" }
> entry = { firstName: "John", lastName: "Smith", address: address }
> _.lastName entry
"Smith"
> _.address.city entry
"Faketown"
住所録に問い合わせる
最小限の住所録アプリケーションの実装で必要になる最後の関数は、名前で人を検索し適切なEntry
を返すものです。
これは小さな関数を組み合わせることでプログラムを構築するという、関数型プログラミングで鍵となる考え方のよい応用例になるでしょう。
住所録を絞り込めば該当する姓名を持つ項目だけを保持するようにできます。 そうすれば結果のリストの先頭(つまり最初)の要素を返せます。
この大まかな道筋の仕様があれば関数の型を計算できます。
まずPSCiを開いてfilter
関数とhead
関数の型を探してみましょう。
$ spago repl
> import Data.List
> :type filter
forall (a :: Type). (a -> Boolean) -> List a -> List a
> :type head
forall (a :: Type). List a -> Maybe a
型の意味を理解するために、これらの2つの型の一部を取り出してみましょう。
filter
は2引数のカリー化された関数です。
最初の引数は関数で、リストの要素を取りBoolean
値を返します。
第2引数は要素のリストで、返り値は別のリストです。
head
は引数としてリストを取り、 Maybe a
という今までに見たことがない型を返します。
Maybe a
は型a
の省略可能な値を表しており、JavaScriptのような言語で値がないことを示すためのnull
を使う代わりとなる、型安全な代替を提供します。
後の章で改めて詳しく見ていきます。
filter
と head
の全称量化された型は、PureScriptコンパイラによって次のように 特殊化 (specialized)
されます。
filter :: (Entry -> Boolean) -> AddressBook -> AddressBook
head :: AddressBook -> Maybe Entry
関数の引数として姓名を渡す必要があるだろうということは分かっています。
filter
に渡す関数も必要になることもわかります。この関数を filterEntry
と呼ぶことにしましょう。 filterEntry
は Entry -> Boolean
という型を持っています。 filter filterEntry
という関数適用の式は、 AddressBook -> AddressBook
という型を持つでしょう。もしこの関数の結果を head
関数に渡すと、型 Maybe Entry
の結果を得ることになります。
これまでのことを纏めると、関数の妥当な型シグネチャは次のようになります。findEntry
と呼ぶことにしましょう。
findEntry :: String -> String -> AddressBook -> Maybe Entry
この型シグネチャで書かれているのは、findEntry
が姓と名前の2つの文字列及びAddressBook
を引数に取り、省略可能なEntry
を返すということです。
省略可能な結果は名前が住所録に見付かった場合にのみ値を持ちます。
そして、 findEntry
の定義は次のようになります。
findEntry firstName lastName book = head (filter filterEntry book)
where
filterEntry :: Entry -> Boolean
filterEntry entry = entry.firstName == firstName && entry.lastName == lastName
一歩ずつこのコードを調べてみましょう。
findEntry
は、どちらも文字列型である firstName
と lastName
、AddressBook
型の
book
という3つの名前をスコープに導入します。
定義の右辺ではfilter
関数とhead
関数が組み合わさっています。
まず項目のリストを絞り込み、その結果にhead
関数を適用しています。
真偽型を返す関数 filterEntry
は where
節の内部で補助的な関数として定義されています。
このため、 filterEntry
関数はこの定義の内部では使用できますが、外部では使用できません。
また、filterEntry
はそれを包む関数の引数に依存でき、 filterEntry
は指定された Entry
を絞り込むために引数
firstName
と lastName
を使用しているので、 filterEntry
が
findEntry
の内部にあることは必須になっています。
なお、最上位での宣言と同じように、必ずしもfilterEntry
の型シグネチャを指定しなくても構いません。
ただし、ドキュメントの一形態として指定しておくことが推奨されます。
中置の関数適用
これまでお話しした関数のほとんどは前置関数適用でした。
関数名が引数の前に置かれていたということです。
例えばinsertEntry
関数を使ってEntry
(john
) を空のAddressBook
に追加する場合、以下のように書けます。
> book1 = insertEntry john emptyBook
しかし本章には中置2引数演算子の例も含まれています。
filterEntry
の定義中の==
演算子がそうで、2つの引数の間に置かれています。
PureScriptのソースコードでこうした中置演算子は隠れた前置の実装への中置別称として定義されています。
例えば==
は以下の行により前置のeq
関数の中置別称として定義されています。
infix 4 eq as ==
したがってfilterEntry
中のentry.firstName == firstName
はeq entry.firstName firstName
で置き換えられます。
この節の後のほうで中置演算子を定義する例にもう少し触れます。
前置関数を演算子としての中置の位置に置くと、より読みやすいコードになる場面があります。
その一例がmod
関数です。
> mod 8 3
2
上の用例でも充分動作しますが、読みにくいです。 より馴染みのある表現の仕方は「8 mod 3」です。 バックスラッシュ (`) の中に前置関数を包むとそのように書けます。
> 8 `mod` 3
2
同様に、insertEntry
をバックスラッシュで包むと中置演算子に変わります。
例えば以下のbook1
とbook2
は等価です。
book1 = insertEntry john emptyBook
book2 = john `insertEntry` emptyBook
複数回insertEntry
を適用することで複数の項目があるAddressBook
を作ることができますが、以下のように前置関数
(book3
) として適用するか中置演算子 (book4
) として適用するかの2択があります。
book3 = insertEntry john (insertEntry peggy (insertEntry ned emptyBook))
book4 = john `insertEntry` (peggy `insertEntry` (ned `insertEntry` emptyBook))
insertEntry
には中置演算子別称(または同義語)も定義できます。
この演算子の名前に適当に++
を選び、優先度を5
にし、そしてinfixr
を使って右結合とします。
infixr 5 insertEntry as ++
この新しい演算子で上のbook4
の例を次のように書き直せます。
book5 = john ++ (peggy ++ (ned ++ emptyBook))
新しい++
演算子の右結合性により、意味を変えずに括弧を除去できます。
book6 = john ++ peggy ++ ned ++ emptyBook
括弧を消去する他のよくある技法は、いつもの前置関数と一緒にapply
の中置演算子$
を使うというものです。
例えば前のbook3
の例は以下のように書き直せます。
book7 = insertEntry john $ insertEntry peggy $ insertEntry ned emptyBook
括弧を$
で置き換えるのは大抵入力しやすくなりますし(議論の余地がありますが)読みやすくなります。
この記号の意味を覚えるための記憶術として、ドル記号を2つの括弧に打ち消し線が引かれたものと見ることで、これで括弧が不必要になったのだと推測できるという方法があります。
なお、($)
は言語にハードコードされた特別な構文ではありません。
単にapply
という名前の普通の関数のための中置演算子であって、Data.Function
で以下のように定義されています。
apply :: forall a b. (a -> b) -> a -> b
apply f x = f x
infixr 0 apply as $
apply
関数は、他の関数(型は(a -> b)
)を最初の引数に、値(型はa
)を2つ目の引数に取って、その値に対して関数を呼びます。
この関数が何ら意味のあることをしていないようだと思ったら、全くもって正しいです。
この関数がなくてもプログラムは論理的に同一です(参照透過性も見てください)。
この関数の構文的な利便性はその中置演算子に割り当てられた特別な性質からきています。
$
は右結合 (infixr
) で低い優先度 (0
) の演算子ですが、これにより深い入れ子になった適用から括弧の束を削除できるのです。
さらなる$
演算子を使った括弧退治のチャンスは以前のfindEntry
関数にあります。
findEntry firstName lastName book = head $ filter filterEntry book
この行をより簡潔に書き換える方法を次節の「関数合成」で見ていきます。
名前の短い中置演算子を前置関数として使いたければ括弧で囲むことができます。
> 8 + 3
11
> (+) 8 3
11
その代わりの手段として演算子は部分適用でき、これには式を括弧で囲んで演算子節中の引数として_
を使います。これは簡単な無名関数を作るより便利な方法として考えることができます(以下の例ではそこから無名関数を名前に束縛しているので、もはや別に無名とも言えなくなっていますが)。
> add3 = (3 + _)
> add3 2
5
纏めると、以下は引数に5
を加える関数の等価な定義です。
add5 x = 5 + x
add5 x = add 5 x
add5 x = (+) 5 x
add5 x = 5 `add` x
add5 = add 5
add5 = \x -> 5 + x
add5 = (5 + _)
add5 x = 5 `(+)` x -- よおポチ、中置に目がないっていうから、中置の中に中置を入れといたぜ
関数合成
イータ変換を使うと insertEntry
関数を簡略化できたのと同じように、引数をよく考察すると findEntry
の定義を簡略化できます。
なお、引数 book
は関数 filter filterEntry
に渡され、この適用の結果が head
に渡されます。これは言いかたを変えれば、
filter filterEntry
と head
の 合成 (composition) に book
が渡されるということです。
PureScriptの関数合成演算子は <<<
と >>>
です。前者は「逆方向の合成」であり、後者は「順方向の合成」です。
何れかの演算子を使用して findEntry
の右辺を書き換えることができます。
逆順の合成を使用すると、右辺は次のようになります。
(head <<< filter filterEntry) book
この形式なら最初の定義にイータ変換の技を適用でき、 findEntry
は最終的に次のような形式に到達します。
findEntry firstName lastName = head <<< filter filterEntry
where
...
右辺を次のようにしても同じく妥当です。
filter filterEntry >>> head
どちらにしても、これは「findEntry
は絞り込み関数とhead
関数の合成である」という
findEntry
関数のわかりやすい定義を与えます。
どちらの定義のほうが分かりやすいかの判断はお任せしますが、このように関数を部品として捉えるとしばしば有用です。 各関数は1つの役目をこなすようにし、解法を関数合成を使って組み立てるのです。
演習
- (簡単)
findEntry
関数の定義の主な部分式の型を書き下し、findEntry
関数についてよく理解しているか試してみましょう。 例えばfindEntry
の定義の中にあるhead
関数の型はAddressBook -> Maybe Entry
と特殊化されています。 補足:この問題にはテストがありません。 - (普通)関数
findEntryByStreet :: String -> AddressBook -> Maybe Entry
を書いてください。 この関数は与えられた通りの住所からEntry
を見付け出します。 手掛かり:findEntry
にある既存のコードを再利用してください。 実装した関数をPSCiとspago test
を走らせてテストしてください。 - (普通)
filterEntry
を(<<<
や>>>
を使った)合成で置き換えて、findEntryByStreet
を書き直してください。 合成の対象は、プロパティ取得子(_.
記法を使います)と、与えられた文字列引数が与えられた通りの住所に等しいかを判定する関数です。 - (普通)名前が
AddressBook
に存在するかどうかを調べて真偽値で返す関数isInBook
を書いてみましょう。 手掛かり:PSCiを使ってData.List.null
関数の型を見付けてください。 この関数はリストが空かどうかを調べます。 - (難しい)「重複」している住所録の項目を削除する関数
removeDuplicates
を書いてみましょう。 項目が同じ姓名を共有していればaddress
フィールドに関係なく、項目が重複していると考えます。 手掛かり:Data.List.nubByEq
関数の型をPSCiを使って調べましょう。 この関数は等価性の述語に基づいてリストから重複要素を削除します。 なお、それぞれの重複する項目の集合において最初の要素(リストの先頭に最も近い)が保持する項目です。
まとめ
この章では関数型プログラミングの新しい概念を幾つか押さえ、以下の方法を学びました。
- 対話的モードのPSCiを使用して、関数で実験したり思いついたことを試したりする。
- 正確さのための道具として、また実装のための道具として型を使う。
- 多引数の関数を表現するためにカリー化された関数を使う。
- 合成により小さな部品からプログラムを作る。
where
式を使ってコードを手際良く構造化する。Maybe
型を使用してnull値を回避する。- イータ変換や関数合成のような技法を使ってより分かりやすい仕様にリファクタする。
次の章からは、これらの考えかたに基づいて進めていきます。
パターン照合
一時的な注意事項:本章に取り組まれているようでしたら、2023年11月に第4章と第5章とが入れ替わっていることにお気を付けください。
この章の目標
この章では、代数的データ型とパターン照合という、2つの新しい概念を導入します。 また、行多相というPureScriptの型システムの興味深い機能についても簡単に取り扱います。
パターン照合は関数型プログラミングにおける一般的な手法であり、開発者が簡潔に関数を書けるようになります。 関数の実装を複数の場合に分解することにより、水面下の複雑なアイディアが表現されるのです。
代数的データ型はPureScriptの型システムの機能であり、型のある言語において同等の水準の表現力を可能にしています。 パターン照合とも密接に関連しています。
この章の目的は、代数的データ型やパターン照合を使用して、単純なベクターグラフィックスを記述し操作するためのライブラリを書くことです。
プロジェクトの準備
この章のソースコードはファイル src/Data/Picture.purs
で定義されています。
Data.Picture
モジュールは簡単な図形を表すデータ型Shape
やその図形の集合である型Picture
を定義します。
また、これらの型を扱うための関数もあります。
このモジュールでは、データ構造を畳込む関数を提供するData.Foldable
モジュールもインポートします。
module Data.Picture where
import Prelude
import Data.Foldable (foldl)
import Data.Number (infinity)
Data.Picture
モジュールはNumber
モジュールもインポートしますが、こちらはas
キーワードを使います。
import Data.Number as Number
こうすると型や関数をモジュール内で使用できるようになりますが、
Number.max
のように修飾名を使ったときに限定されます。
重複したインポートを避けたり、どのモジュールからインポートされたのかを明らかにするのに役立ちます。
補足:元のモジュールと同じモジュール名を修飾名に使用する必要はありません。
import Math as M
などのより短い名前にできますし、かなりよく見掛けます。
単純なパターン照合
例を見ることから始めましょう。 パターン照合を使用して2つの整数の最大公約数を計算する関数は、次のようになります。
gcd :: Int -> Int -> Int
gcd n 0 = n
gcd 0 m = m
gcd n m = if n > m
then gcd (n - m) m
else gcd n (m - n)
このアルゴリズムはユークリッドの互除法と呼ばれています。 その定義をオンラインで検索すると、恐らく上記のコードによく似た数学の方程式が見つかるでしょう。 パターン照合の利点の1つは、コードを場合分けして定義でき、数学関数の仕様に似た単純で宣言型なコードを定義できることです。
パターン照合を使用して書かれた関数は、条件と結果の組み合わせによって動作します。 この定義の各行は選択肢や場合と呼ばれています。 等号の左辺の式はパターンと呼ばれており、それぞれの場合は空白で区切られた1つ以上のパターンで構成されています。 場合の集まりは、等号の右側の式が評価され値が返される前に、引数が満たさなければならない条件を表現しています。 それぞれの場合は上からこの順番に試されていき、最初にパターンが入力に照合した場合が返り値を決定します。
例えばgcd
関数は次の手順で評価されます。
- まず最初の場合が試されます。
第2引数がゼロの場合、関数は
n
(最初の引数)を返します。 - そうでなければ、2番目の場合が試されます。
最初の引数がゼロの場合、関数は
m
(第2引数)を返します。 - それ以外の場合、関数は最後の行の式を評価して返します。
なお、パターンでは値を名前に束縛できます。
この例の各行では n
やm
という名前の何れかまたは両方に入力された値を束縛しています。
様々な種類のパターンについて学んでいくうちに、それぞれの種類のパターンが入力の引数から名前を選ぶ様々な方法に対応することがわかるでしょう。
単純なパターン
上記のコード例では、2種類のパターンを示しました。
Int
型の値が正確に一致する場合にのみ照合する、整数直値パターン- 引数を名前に束縛する、変数パターン
単純なパターンには他にも種類があります。
Number
、String
、Char
、そしてBoolean
といった直値- どんな引数とも照合するが名前に束縛はしない、アンダースコア (
_
) で表されるワイルドカードパターン
これらの単純なパターンを使用する実演として、もう2つの例が以下です。
fromString :: String -> Boolean
fromString "true" = true
fromString _ = false
toString :: Boolean -> String
toString true = "true"
toString false = "false"
PSCiでこれらの関数を試してみてください。
ガード
ユークリッドの互除法の例では、m > n
のときとm <= n
のときの2つの選択肢の間を切り替えるためにif .. then .. else
式を使いました。
こういうときにはガードを使うという他の選択肢もあります。
ガードとは、パターンにより課された制約に加えて満たされなくてはいけない真偽値の式です。 ガードを使用してユークリッドのアルゴリズムを書き直すと、次のようになります。
gcdV2 :: Int -> Int -> Int
gcdV2 n 0 = n
gcdV2 0 n = n
gcdV2 n m | n > m = gcdV2 (n - m) m
| otherwise = gcdV2 n (m - n)
この場合、3行目ではガードを使用して、最初の引数が第2引数よりも厳密に大きいという条件を課しています。
最後の行でのガードは式otherwise
を使っています。
これはキーワードのようにも見えますが、実際はただのPrelude
にある普通の束縛です。
> :type otherwise
Boolean
> otherwise
true
この例が示すように、ガードは等号の左側に現れ、パイプ文字 (|
) でパターンのリストと区切られています。
演習
- (簡単)パターン照合を使用して、階乗関数
factorial
を書いてみましょう。 手掛かり:入力がゼロのときとゼロでないときの、2つの特殊な場合を考えてみてください。 補足:これは前の章の例の反復ですが、ここでは自力で書き直せるかやってみてください。 - (普通)\( (1 + x) ^ n \)を多項式展開した式にある\( x ^ k
\)の項の係数を求める関数
binomial
を書いてください。 これはn
要素の集合からk
要素の部分集合を選ぶ方法の数と同じです。 数式\( n! / k! (n - k)! \)を使ってください。 ここで \( ! \) は前に書いた階乗関数です。 手掛かり:パターン照合を使って特殊な場合を取り扱ってください。 長い時間が掛かったりコールスタックのエラーでクラッシュしたりしたら、特殊な場合を更に追加してみてください。 - (普通)パスカルの法則を使って前の演習と同じ2項係数を計算する関数
pascal
を書いてください。
配列パターン
配列直値パターンは、固定長の配列に対して照合する方法を提供します。
例えば空の配列であるか判定する関数isEmpty
を書きたいとします。
最初の選択肢に空の配列パターン ([]
) を用いるとこれを実現できます。
isEmpty :: forall a. Array a -> Boolean
isEmpty [] = true
isEmpty _ = false
次の関数では、長さ5の配列と照合し、配列の5つの要素をそれぞれ違った方法で束縛しています。
takeFive :: Array Int -> Int
takeFive [0, 1, a, b, _] = a * b
takeFive _ = 0
最初のパターンは、第1要素と第2要素がそれぞれ0と1であるような、5要素の配列にのみ照合します。 その場合、関数は第3要素と第4要素の積を返します。 それ以外の場合は、関数は0を返します。 例えばPSCiで試してみると次のようになります。
> :paste
… takeFive [0, 1, a, b, _] = a * b
… takeFive _ = 0
… ^D
> takeFive [0, 1, 2, 3, 4]
6
> takeFive [1, 2, 3, 4, 5]
0
> takeFive []
0
配列の直値パターンでは、固定長の配列と一致させることはできます。
しかしPureScriptは不特定の長さの配列を照合させる手段は全く提供していません。
そのような類の方法で不変な配列を分解すると、実行速度が低下する可能性があるためです。
このように照合できるデータ構造が必要な場合は、Data.List
を使うことをお勧めします。
その他の操作について、より優れた漸近性能を提供するデータ構造も存在します。
レコードパターンと行多相
レコードパターンは(ご想像の通り)レコードに照合します。
レコードパターンはレコード直値にほぼ見た目が似ていますが、コロンの右に値を置くのではなく、それぞれのフィールドで束縛子を指定します。
例えば次のパターンはfirst
とlast
という名前のフィールドが含まれた任意のレコードに照合し、これらのフィールドの値はそれぞれ x
と
y
という名前に束縛されます。
showPerson :: { first :: String, last :: String } -> String
showPerson { first: x, last: y } = y <> ", " <> x
レコードパターンはPureScriptの型システムの興味深い機能である行多相の良い例となっています。
もし上のshowPerson
を型シグネチャなしで定義していたとすると、この型はどのように推論されるのでしょうか。
面白いことに、推論される型は上で与えた型とは同じではありません。
> showPerson { first: x, last: y } = y <> ", " <> x
> :type showPerson
forall (r :: Row Type). { first :: String, last :: String | r } -> String
この型変数 r
は何でしょうか。
PSCiでshowPerson
を使ってみると、面白いことがわかります。
> showPerson { first: "Phil", last: "Freeman" }
"Freeman, Phil"
> showPerson { first: "Phil", last: "Freeman", location: "Los Angeles" }
"Freeman, Phil"
レコードにそれ以外のフィールドが追加されていても、showPerson
関数はそのまま動作するのです。
レコードに少なくとも型がString
であるようなフィールドfirst
とlast
が含まれていれば、関数適用は正しく型付けされます。
しかし、フィールドが不足していると、showPerson
の呼び出しは不正となります。
> showPerson { first: "Phil" }
Type of expression lacks required label "last"
showPerson
の新しい型シグネチャを読むと、「String
なfirst
とlast
フィールド と他のフィールドを何でも
持つあらゆるレコードを取り、String
を返す」となります。なお、この挙動は元のshowPerson
のものとは異なります。行変数r
がなければshowPerson
は
厳密に first
とlast
フィールドしかないレコードのみを受け付けます。
なお、次のように書くこともできます。
> showPerson p = p.last <> ", " <> p.first
そしてPSCiは同じ型を推論することでしょう。
レコード同名利用
showPerson
関数は引数内のレコードと照合し、first
とlast
フィールドをx
とy
という名前の値に束縛していたのでした。
別の方法として、フィールド名自体を再利用してこのような類のパターン照合を次のように単純化できます。
showPersonV2 :: { first :: String, last :: String } -> String
showPersonV2 { first, last } = last <> ", " <> first
ここでは、プロパティの名前のみを指定し、名前に導入したい値を指定する必要はありません。これは レコード同名利用 (record pun) と呼ばれます。
レコード同名利用はレコードの構築にも使用できます。
例えば、スコープに first
と last
という名前の値があれば、{ first, last }
を使って人物レコードを作ることができます。
unknownPerson :: { first :: String, last :: String }
unknownPerson = { first, last }
where
first = "Jane"
last = "Doe"
こうすると、状況によってはコードの可読性が向上します。
入れ子になったパターン
配列パターンとレコードパターンはどちらも小さなパターンを組み合わせることで大きなパターンを構築しています。 これまでのほとんどの例では配列パターンとレコードパターンの内部で単純なパターンを使用していました。 しかし特筆すべきこととして、パターンは自由に入れ子にできます。 これにより潜在的に複雑なデータ型についての条件を使って関数を定義できます。
例えばこのコードは2つのレコードパターンを組み合わせています。
type Address = { street :: String, city :: String }
type Person = { name :: String, address :: Address }
livesInLA :: Person -> Boolean
livesInLA { address: { city: "Los Angeles" } } = true
livesInLA _ = false
名前付きパターン
入れ子のパターンを使う場合、パターンには名前を付けてスコープに名前を追加で持ち込むことができます。
任意のパターンに名前を付けるには、 @
記号を使います。
例えば次の関数は2要素配列を整列するもので、2つの要素に名前を付けていますが、配列自身にも名前を付けています。
sortPair :: Array Int -> Array Int
sortPair arr@[x, y]
| x <= y = arr
| otherwise = [y, x]
sortPair arr = arr
このようにすれば対が既に整列されているときに新しい配列を割り当てなくて済みます。 なお、もし入力の配列が厳密に2つの要素を含んでいなければ、たとえ整列されていなかったとしても、この関数は単に元のまま変えずに返します。
演習
- (簡単)レコードパターンを使って、2つの
Person
レコードが同じ都市にいるか調べる関数sameCity
を定義してみましょう。 - (普通)行多相を考慮すると、
sameCity
関数の最も一般的な型は何でしょうか。 先ほど定義したlivesInLA
関数についてはどうでしょうか。 補足:この演習にテストはありません。 - (普通)配列直値パターンを使って、1要素の配列の唯一のメンバーを抽出する関数
fromSingleton
を書いてみましょう。 1要素だけを持つ配列でない場合、関数は与えられた既定値を返します。 この関数はforall a. a -> Array a -> a
という型を持ちます。
case式
パターンが現れるのは最上位にある関数宣言だけではありません。
case
式を使う計算中の途中の値に対してパターン照合を使えます。
case式には無名関数に似た便利さがあります。
関数に名前を与えることがいつも望ましいわけではないように、パターンを使いたいためだけに関数に名前をつけるようなことを避けられるようになります。
例を示しましょう。 次の関数は、配列の「最長ゼロ末尾」(和がゼロであるような、最も長い配列の末尾)を計算します。
import Data.Array (tail)
import Data.Foldable (sum)
import Data.Maybe (fromMaybe)
lzs :: Array Int -> Array Int
lzs [] = []
lzs xs = case sum xs of
0 -> xs
_ -> lzs (fromMaybe [] $ tail xs)
以下は一例です。
> lzs [1, 2, 3, 4]
[]
> lzs [1, -1, -2, 3]
[-1, -2, 3]
この関数は場合毎の分析によって動作します。
もし配列が空なら、唯一の選択肢は空の配列を返すことです。
配列が空でない場合は、更に2つの場合に分けるためにまずcase
式を使用します。
配列の合計がゼロであれば、配列全体を返します。
そうでなければ、配列の残りに対して再帰します。
パターン照合の失敗と部分関数
case式のパターンを順番に照合していって、どの選択肢の場合も入力が照合しなかった時はどうなるのでしょう。 この場合、パターン照合失敗によって、case式は実行時に失敗します。
簡単な例でこの動作を見てみましょう。
import Partial.Unsafe (unsafePartial)
partialFunction :: Boolean -> Boolean
partialFunction = unsafePartial \true -> true
この関数は単一の場合しか含んでいません。
そしてその場合は単一の入力であるtrue
にのみ照合します。
このファイルをコンパイルしてPSCiでそれ以外の値を与えて試すと実行時エラーが発生します。
> partialFunction false
Failed pattern match
どんな入力の組み合わせに対しても値を返すような関数は全関数と呼ばれ、そうでない関数は部分的であると呼ばれます。
一般的には、可能な限り全関数として定義したほうが良いと考えられています。
もし関数が何らかの妥当な入力の集合について結果を返さないことがわかっているなら、大抵は失敗であることを示すことができる値を返すほうがよいでしょう。
例えば何らかのa
についての型Maybe a
で、妥当な結果を返せないときはNothing
を使います。
この方法なら、型安全な方法で値の有無を示すことができます。
PureScriptコンパイラは、パターン照合が不完全であるために関数が全関数ではないことが検出されると、エラーを出します。
unsafePartial
関数を使うとこうしたエラーを抑制できます(ただしその部分関数が安全だと言い切れるなら)。
もし上記のunsafePartial
関数の呼び出しを取り除くと、コンパイラは次のエラーを出します。
A case expression could not be determined to cover all inputs.
The following additional cases are required to cover all inputs:
false
これは値false
が、定義されたどのパターンとも一致しないことを示しています。
一般にこれらの警告には、複数の不一致な場合が含まれることがあります。
上記の型シグネチャも省略した場合は、次のようになります。
partialFunction true = true
このとき、PSCiは興味深い型を推論します。
> :type partialFunction
Partial => Boolean -> Boolean
本書では以降、=>
記号が絡む(型クラスに関連する)型をもっと見ていきます。
しかし現時点では、PureScriptは型システムを使って部分関数を把握していることと、安全な場合に型検証器に明示する必要があることを確認すれば充分です。
コンパイラは、冗長な場合を検出したとき(つまり、その場合より前の方に定義された場合にのみ一致するとき)などにも警告を出します。
redundantCase :: Boolean -> Boolean
redundantCase true = true
redundantCase false = false
redundantCase false = false
このとき、最後の場合は冗長であると正しく検出されます。
A case expression contains unreachable cases:
false
補足:PSCiは警告を表示しません。 そのため、この例を再現するには、この関数をファイルとして保存し、
spago build
を使ってコンパイルします。
代数的データ型
この節では 代数的データ型 (algebraic data type, ADT) と呼ばれる、PureScriptの型システムの機能を導入します。この機能はパターン照合と地続きの関係があります。
しかしまずは切り口となる例について考えていきます。この例では単純なベクターグラフィックスライブラリの実装というこの章の課題を解決する基礎を与えます。
直線、矩形、円、テキストなどの単純な図形の種類を表現する型を定義したいとします。
オブジェクト指向言語では、恐らくインターフェースもしくは抽象クラス
Shape
を定義し、使いたいそれぞれの図形について具体的なサブクラスを定義するでしょう。
しかし、この方針は大きな欠点を1つ抱えています。
Shape
を抽象的に扱うためには、実行したいと思う可能性のある全ての操作を事前に把握し、Shape
インターフェースに定義する必要があるのです。
モジュール性を壊さずに新しい操作を追加することが難しくなります。
もし図形の種類が事前にわかっているなら、代数的データ型はこうした問題を解決する型安全な方法を提供します。
モジュール性のある方法で Shape
に新たな操作を定義しつつ、型安全性を維持できます。
代数的データ型としてどのようにShape
が表現されるかを次に示します。
data Shape
= Circle Point Number
| Rectangle Point Number Number
| Line Point Point
| Text Point String
type Point =
{ x :: Number
, y :: Number
}
この宣言ではShape
をそれぞれの構築子の直和として定義しており、各構築子では含まれるデータを指定します。
Shape
は、中央 Point
と半径(数値)を持つ Circle
か、Rectangle
、 Line
、 Text
の何れかです。
他にShape
型の値を構築する方法はありません。
代数的データ型 (algebraic data type; ADT)
の定義はキーワードdata
から始まり、それに新しい型の名前と任意個の型引数が続きます。
その型の構築子(これをデータ構築子と言います)は等号の後に定義され、パイプ文字 (|
) で区切られます。
ADTの構築子が持つデータは原始型に限りません。
構築子にはレコード、配列、また他のADTさえも含められます。
それではPureScriptの標準ライブラリから別の例を見てみましょう。
省略可能な値を定義するのに使われる Maybe
型を本書の冒頭で扱いました。
maybe
パッケージでは Maybe
を次のように定義しています。
data Maybe a = Nothing | Just a
この例では型引数 a
の使用方法を示しています。パイプ文字を「または」と読むことにすると、この定義は「Maybe a
型の値は、無い
(Nothing
) か、ただの (Just
) 型 a
の値だ」とほぼ英語のように読むことができます。
なお、データ定義のどこにも構文forall a
を使っていません。
forall
構文は関数には必須ですが、data
によるADTやtype
での型別称を定義するときは使われません。
データ構築子は再帰的なデータ構造を定義するためにも使用できます。更に例を挙げると、要素が型
a
の単方向連結リストのデータ型の定義はこのようになります。
data List a = Nil | Cons a (List a)
この例は lists
パッケージから持ってきました。
ここで Nil
構築子は空のリストを表しており、Cons
は先頭となる要素と尾鰭から空でないリストを作成するために使われます。
Cons
の2つ目のフィールドでデータ型 List a
を使用しており、再帰的なデータ型になっていることに注目してください。
ADTの使用
代数的データ型の構築子を使用して値を構築するのはとても簡単です。 対応する構築子に含まれるデータに応じた引数を用意し、その構築子を単に関数のように適用するだけです。
例えば、上で定義した Line
構築子は2つの Point
を必要としていますので、Line
構築子を使って Shape
を構築するには、型
Point
の2つの引数を与えなければなりません。
exampleLine :: Shape
exampleLine = Line p1 p2
where
p1 :: Point
p1 = { x: 0.0, y: 0.0 }
p2 :: Point
p2 = { x: 100.0, y: 50.0 }
さて、代数的データ型で値を構築することは簡単ですが、これをどうやって使ったらよいのでしょうか。 ここで代数的データ型とパターン照合との重要な接点が見えてきます。 代数的データ型の値を消費する唯一の方法は構築子に照合するパターンを使うことです。
例を見てみましょう。
Shape
を String
に変換したいとします。
Shape
を構築するのにどの構築子が使用されたかを調べるには、パターン照合を使用しなければなりません。
これには次のようにします。
showShape :: Shape -> String
showShape (Circle c r) =
"Circle [center: " <> showPoint c <> ", radius: " <> show r <> "]"
showShape (Rectangle c w h) =
"Rectangle [center: " <> showPoint c <> ", width: " <> show w <> ", height: " <> show h <> "]"
showShape (Line start end) =
"Line [start: " <> showPoint start <> ", end: " <> showPoint end <> "]"
showShape (Text loc text) =
"Text [location: " <> showPoint loc <> ", text: " <> show text <> "]"
showPoint :: Point -> String
showPoint { x, y } =
"(" <> show x <> ", " <> show y <> ")"
各構築子はパターンとして使用でき、構築子への引数はそのパターンで束縛できます。
showShape
の最初の場合を考えてみましょう。
もし Shape
が Circle
構築子に照合した場合、2つの変数パターン c
と
r
を使ってCircle
の引数(中心と半径)がスコープに導入されます。
その他の場合も同様です。
演習
- (簡単)
Circle
(型はShape
)を構築する関数circleAtOrigin
を書いてください。 中心は原点にあり、半径は10.0
です。 - (普通)原点を中心として
Shape
の大きさを2.0
倍に拡大する関数doubleScaleAndCenter
を書いてみましょう。 - (普通)
Shape
からテキストを抽出する関数shapeText
を書いてください。 この関数はMaybe String
を返しますが、もし入力がText
を使用して構築されたのでなければ、返り値にはNothing
構築子を使ってください。
newtype
代数的データ型の特殊な場合として、 newtype と呼ばれるものがあります。newtypeはキーワード data
の代わりにキーワード
newtype
を使用して導入します。
newtype宣言では過不足なく1つだけの構築子を定義しなければならず、その構築子は過不足なく1つだけの引数を取る必要があります。 つまり、newtype宣言は既存の型に新しい名前を与えるものなのです。 実際、newtypeの値は、元の型と同じ実行時表現を持ってるので、実行時性能のオーバーヘッドがありません。 しかし、これらは型システムの観点から区別されます。 型安全性に追加の層を与えるのです。
例として、ボルト、アンペア、オームのような単位を表現するために、Number
の型レベルの別名を定義したくなる場合があるかもしれません。
newtype Volt = Volt Number
newtype Ohm = Ohm Number
newtype Amp = Amp Number
それからこれらの型を使う関数と値を定義します。
calculateCurrent :: Volt -> Ohm -> Amp
calculateCurrent (Volt v) (Ohm r) = Amp (v / r)
battery :: Volt
battery = Volt 1.5
lightbulb :: Ohm
lightbulb = Ohm 500.0
current :: Amp
current = calculateCurrent battery lightbulb
これによりつまらないミスを防ぐことができます。 例えば電源なしに2つの電球により生み出される電流を計算しようとするなどです。
current :: Amp
current = calculateCurrent lightbulb lightbulb
{-
TypesDoNotUnify:
current = calculateCurrent lightbulb lightbulb
^^^^^^^^^
Could not match type
Ohm
with type
Volt
-}
もしnewtype
なしに単なるNumber
を使っていたら、コンパイラはこのミスを捕捉できません。
-- これもコンパイルできますが、型安全ではありません。
calculateCurrent :: Number -> Number -> Number
calculateCurrent v r = v / r
battery :: Number
battery = 1.5
lightbulb :: Number
lightbulb = 500.0
current :: Number
current = calculateCurrent lightbulb lightbulb -- 捕捉されないミス
なお、newtypeは単一の構築子しかとれず、構築子は単一の値でなくてはなりませんが、newtypeは任意の数の型変数を取ることができます。
例えば以下のnewtypeは妥当な定義です(err
とa
は型変数で、CouldError
構築子は型Either err a
の単一の値を期待します)。
newtype CouldError err a = CouldError (Either err a)
また、newtypeの構築子はよくnewtype自身と同じ名前を持つことがあります。 ただこれは必須ではありません。 例えば別個の名前であっても正しいものです。
newtype Coulomb = MakeCoulomb Number
この場合Coulomb
は(引数ゼロの)型構築子で、MakeCoulomb
はデータ構築子です。
これらの構築子は異なる名前空間に属しており、Volt
の例でそうだったように、名前には一意性があります。
これは全てのADTについて言えることです。
なお、型構築子とデータ構築子には異なる名前を付けられますが、実際には同じ名前を共有するのが普通です。
前述のAmp
とVolt
の場合がこれです。
newtypeの別の応用は、実行時表現を変えることなく、既存の型に異なる 挙動 を加えることです。その利用例については次章で 型クラス をお話しするときに押さえます。
演習
- (簡単)
Watt
をNumber
のnewtype
として定義してください。それからこの新しいWatt
型と前述のAmp
とVolt
の定義を使ってcalculateWattage
関数を定義してください。
calculateWattage :: Amp -> Volt -> Watt
Watt
中のワット数は与えられたAmp
中の電流と与えられたVolt
の電圧の積で計算できます。
ベクターグラフィックスライブラリ
これまで定義してきたデータ型を使って、ベクターグラフィックスを扱う簡単なライブラリを作成していきましょう。
Picture
という型同義語を定義しておきます。
これはただのShape
の配列です。
type Picture = Array Shape
不具合を修正しているとPicture
をString
として表示できるようにしたくなることもあるでしょう。
これはパターン照合を使用して定義されたshowPicture
関数でできます。
showPicture :: Picture -> Array String
showPicture = map showShape
試してみましょう。
モジュールを spago build
でコンパイルし、 spago repl
でPSCiを開きます。
$ spago build
$ spago repl
> import Data.Picture
> showPicture [ Line { x: 0.0, y: 0.0 } { x: 1.0, y: 1.0 } ]
["Line [start: (0.0, 0.0), end: (1.0, 1.0)]"]
外接矩形の算出
このモジュールのコード例には、 Picture
の最小外接矩形を計算する関数 bounds
が含まれています。
Bounds
型は外接矩形を定義します。
type Bounds =
{ top :: Number
, left :: Number
, bottom :: Number
, right :: Number
}
Picture
内の Shape
の配列を走査し、最小の外接矩形を累算するため、bounds
には Data.Foldable
の
foldl
関数を使用しています。
bounds :: Picture -> Bounds
bounds = foldl combine emptyBounds
where
combine :: Bounds -> Shape -> Bounds
combine b shape = union (shapeBounds shape) b
基底の場合では、空の
Picture
の最小外接矩形を求める必要がありますが、emptyBounds
で定義される空の外接矩形がその条件を満たしています。
累算関数combine
はwhere
ブロックで定義されています。
combine
はfoldl
の再帰呼び出しで計算された外接矩形と、配列内の次の
Shape
を引数に取り、ユーザ定義の演算子union
を使って2つの外接矩形の和を計算しています。
shapeBounds
関数は、パターン照合を使用して、単一の図形の外接矩形を計算します。
演習
- (普通)ベクターグラフィックライブラリを拡張し、
Shape
の面積を計算する新しい操作area
を追加してください。 この演習の目的上は、線分やテキストの面積は0であるものとしてください。 - (難しい)
Shape
型を新しいデータ構築子Clipped
で拡張してください。Clipped
は他のPicture
を矩形に切り抜きます。 切り抜かれた図形の境界を計算できるよう、shapeBounds
関数を拡張してください。 なお、これによりShape
は再帰的なデータ型になります。 手掛かり :コンパイラは必要に応じて他の関数を拡張するのに付き添ってくれるでしょう。
まとめ
この章では、関数型プログラミングから基本的ながら強力なテクニックであるパターン照合を扱いました。複雑なデータ構造の一部分と照合するために、簡単なパターンの使い方だけではなく、配列パターンやレコードパターンを使った深さのあるデータ構造の一部分との照合方法を見てきました。
また、この章ではパターン照合に密接に関連する代数的データ型も紹介しました。 代数的データ型のおかげでデータ構造を簡潔に記述でき、新たな操作でデータ型を拡張する上で、モジュール性のある方法が得られるのでした。
最後に行多相を扱いました。 これは強力な抽象化をする型であり、これにより多くの既存のJavaScript関数に型を与えられます。
本書では今後も代数的データ型とパターン照合をいろんなところで使用するので、今のうちにこれらに習熟しておくと後で実を結ぶことでしょう。これ以外にも独自の代数的データ型を作成し、パターン照合を使用してそれらの型を使う関数を書いてみてください。
再帰、マップ、畳み込み
一時的な注意事項:本章に取り組まれているようでしたら、2023年11月に第4章と第5章とが入れ替わっていることにお気を付けください。
この章の目標
この章では、アルゴリズムを構造化するときに再帰関数をどのように使うかについて見ていきましょう。 再帰は関数型プログラミングの基本的な手法であり、本書全体に亙って使われます。
また、PureScriptの標準ライブラリから標準的な関数を幾つか取り扱います。
map
やfold
といった関数だけでなく、filter
やconcatMap
といった特別な場合において便利なものについても見ていきます。
この章では、仮想的なファイルシステムを操作する関数のライブラリを動機付けに用います。 この章で学ぶ技術を応用し、ファイルシステムのモデルにより表現されるファイルのプロパティを計算する関数を書きます。
プロジェクトの準備
この章のソースコードはsrc/Data/Path.purs
とtest/Examples.purs
に含まれています。
Data.Path
モジュールは仮想ファイルシステムのモデルを含みます。
このモジュールの内容を変更する必要はありません。
演習への解答はTest.MySolutions
モジュールに実装してください。
それぞれの演習を完了させつつ都度Test.Main
モジュールにある対応するテストを有効にし、spago test
を走らせることで解答を確認してください。
このプロジェクトには以下の依存関係があります。
maybe
:Maybe
型構築子が定義されています。arrays
: 配列を扱うための関数が定義されています。strings
: JavaScriptの文字列を扱うための関数が定義されています。foldable-traversable
: 配列やその他のデータ構造を畳み込む関数が定義されています。console
: コンソールへの出力を扱うための関数が定義されています。
導入
再帰は一般のプログラミングでも重要な手法ですが、特に純粋関数型プログラミングでは当たり前のように用いられます。この章で見ていくように、再帰はプログラムの変更可能な状態を減らすために役立つからです。
再帰は分割統治戦略と密接な関係があります。 分割統治とはすなわち、何らかの入力としての問題を解くにあたり、入力を小さな部分に分割してそれぞれの部分について問題を解き、部分毎の答えから最終的な答えを組み立てるということです。
それでは、PureScriptにおける再帰の簡単な例を幾つか見てみましょう。
次に示すのは階乗関数のありふれた例です。
factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)
このように、問題を部分問題へ分割することによって階乗関数の計算方法が見てとれます。 より小さい数の階乗を計算していくということです。 ゼロに到達すると、答えは直ちに求まります。
次は、フィボナッチ関数を計算するという、これまたよくある例です。
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
やはり、部分問題の解決策を考えることで全体を解決していることがわかります。
このとき、fib (n - 1)
とfib (n - 2)
という式に対応した、2つの部分問題があります。
これらの2つの部分問題が解決されていれば、この部分的な答えを加算することで、全体の答えを組み立てることができます。
配列上での再帰
再帰関数の定義はInt
型だけに限定されるものではありません。
本書の後半でパターン照合を扱うときに、いろいろなデータ型の上での再帰関数について見ていきますが、ここでは数と配列に限っておきます。
入力がゼロでないかどうかについて分岐するのと同じように、配列の場合も、配列が空でないかどうかについて分岐していきます。再帰を使用して配列の長さを計算する次の関数を考えてみます。
import Prelude
import Data.Array (null, tail)
import Data.Maybe (fromMaybe)
length :: forall a. Array a -> Int
length [] = 0
length arr = 1 + (length $ fromMaybe [] $ tail arr)
この関数では、配列が空かどうかに基づいて分岐しています。
null
関数は、空配列についてはtrue
を返します。
空配列は長さ0を、非空配列は尾鰭の長さより1大きい長さを持ちます。
tail
関数は与えられた配列から最初の要素を除いたものをMaybe
に包んで返します。
配列が空であれば(つまり尾鰭がなければ)Nothing
が返ります。
fromMaybe
関数は既定値とMaybe
値を取ります。
後者がNothing
であれば既定値を返し、そうでなければJust
に包まれた値を返します。
JavaScriptで配列の長さを調べるのには、この例はどう見ても実用的な方法とはいえませんが、次の演習を完遂するための手掛かりとしては充分でしょう。
演習
- (簡単)入力が偶数であるとき、かつそのときに限り
true
に返す再帰関数isEven
を書いてみましょう。 - (普通)配列内の偶数の整数を数える再帰関数
countEven
を書いてみましょう。 手掛かり:head
関数(これもData.Array
モジュールから手に入ります)を使うと、空でない配列の最初の要素を見つけられます。
マップ
map
関数は配列に対する再帰関数の一例です。
配列の各要素に順番に関数を適用し、配列の要素を変換するのに使われます。
そのため、配列の内容は変更されますが、その形状(ここでは「長さ」)は保存されます。
本書の後半で型クラスの内容を押さえるとき、map
関数が形状を保存する関数のより一般的な様式の一例であることを見ていきます。
この関数は関手と呼ばれる型構築子のクラスを変換するものです。
それでは、PSCiでmap
関数を試してみましょう。
$ spago repl
> import Prelude
> map (\n -> n + 1) [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
map
がどのように使われているかに注目してください。
最初の引数には配列上で「写す」関数、第2引数には配列そのものを渡します。
中置演算子
バッククォートで関数名を囲むと、写す関数と配列の間に、map
関数を書くことができます。
> (\n -> n + 1) `map` [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
この構文は 中置関数適用 と呼ばれ、どんな関数でもこのように中置できます。普通は2引数の関数に対して使うのが最適でしょう。
配列を扱う際はmap
関数と等価な<$>
という演算子が存在します。
> (\n -> n + 1) <$> [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
それではmap
の型を見てみましょう。
> :type map
forall (f :: Type -> Type) (a :: Type) (b :: Type). Functor f => (a -> b) -> f a -> f b
実はmap
の型は、この章で必要とされているものよりも一般的な型になっています。今回の目的では、map
は次のようなもっと具体的な型であるかのように考えるとよいでしょう。
forall (a :: Type) (b :: Type). (a -> b) -> Array a -> Array b
この型では、map
関数に適用するときにはa
とb
という2つの型を自由に選ぶことができる、ということも示されています。
a
は元の配列の要素の型で、b
は目的の配列の要素の型です。
もっと言えば、map
が配列の要素の型を保存する必要があるわけではありません。
例えばmap
を使用すると数値を文字列に変換できます。
> show <$> [1, 2, 3, 4, 5]
["1","2","3","4","5"]
中置演算子<$>
は特別な構文のように見えるかもしれませんが、実はPureScriptの普通の関数の別称です。
中置構文を使用した単なる適用にすぎません。
実際、括弧でその名前を囲むと、この関数を通常の関数のように使用できます。
これは、map
代わりに、括弧で囲まれた(<$>)
という名前が使えるということです。
> (<$>) show [1, 2, 3, 4, 5]
["1","2","3","4","5"]
中置関数は既存の関数名の別称として定義されます。
例えばData.Array
モジュールでは次のようにrange
関数の同義語として中置演算子(..)
を定義しています。
infix 8 range as ..
この演算子は次のように使うことができます。
> import Data.Array
> 1 .. 5
[1, 2, 3, 4, 5]
> show <$> (1 .. 5)
["1","2","3","4","5"]
補足:独自の中置演算子は、自然な構文を備える領域特化言語を定義する上で優れた手段になりえます。ただし、乱用すると初心者が読めないコードになることがありますから、新たな演算子の定義には慎重になるのが賢明です。
上記の例では、1 .. 5
という式は括弧で囲まれていましたが、実際にはこれは必要ありません。
なぜなら、Data.Array
モジュールは、<$>
に割り当てられた優先順位より高い優先順位を..
演算子に割り当てているからです。
上の例では、..
の優先順位は、キーワードinfix
のあとに書かれた数の8
と定義されていました。
ここでは<$>
の優先順位よりも高い優先順位を..
に割り当てており、このため括弧を付け加える必要がないということです。
> show <$> 1 .. 5
["1","2","3","4","5"]
中置演算子に(左または右の)結合性を与えたい場合は、代わりにキーワードinfixl
とinfixr
を使います。
infix
を使うと何ら結合性は割り当てられず、同じ演算子を複数回使ったり複数の同じ優先度の演算子を使ったりするときに、式を括弧で囲まなければいけなくなります。
配列の絞り込み
Data.Array
モジュールでは他にも、よくmap
と一緒に使われる関数filter
も提供しています。
この関数は、述語関数に照合する要素のみを残し、既存の配列から新しい配列を作成する機能を提供します。
例えば1から10までの数で、偶数であるような数の配列を計算したいとします。 これは次のようにできます。
> import Data.Array
> filter (\n -> n `mod` 2 == 0) (1 .. 10)
[2,4,6,8,10]
演習
- (簡単)
map
関数や<$>
関数を使用して、 配列に格納された数のそれぞれの平方を計算する関数squared
を書いてみましょう。 手掛かり:map
や<$>
といった関数を使ってください。 - (簡単)
filter
関数を使用して、数の配列から負の数を取り除く関数keepNonNegative
を書いてみましょう。 手掛かり:filter
関数を使ってください。 - (普通)
filter
の中置同義語<$?>
を定義してください。 補足:中置同義語はREPLでは定義できないかもしれませんが、ファイルでは定義できます。- 関数
keepNonNegativeRewrite
を書いてください。この関数はfilter
を独自の新しい中置演算子<$?>
で置き換えたところ以外、keepNonNegative
と同じです。 - PSCiで独自の演算子の優先度合いと結合性を試してください。 補足:この問題のための単体試験はありません。
配列の平坦化
配列に関する標準的な関数としてData.Array
で定義されているものには、concat
関数もあります。concat
は配列の配列を1つの配列へと平坦化します。
> import Data.Array
> :type concat
forall (a :: Type). Array (Array a) -> Array a
> concat [[1, 2, 3], [4, 5], [6]]
[1, 2, 3, 4, 5, 6]
関連する関数として、concat
とmap
を組み合わせたconcatMap
と呼ばれる関数もあります。
map
は(相異なる型の可能性がある)値からの値への関数を引数に取りますが、それに対してconcatMap
は値から値の配列への関数を取ります。
実際に動かして見てみましょう。
> import Data.Array
> :type concatMap
forall (a :: Type) (b :: Type). (a -> Array b) -> Array a -> Array b
> concatMap (\n -> [n, n * n]) (1 .. 5)
[1,1,2,4,3,9,4,16,5,25]
ここでは、数をその数とその数の平方の2つの要素からなる配列に写す関数\n -> [n, n * n]
を引数にconcatMap
を呼び出しています。
結果は10個の整数の配列です。
配列は1から5の数とそのそれぞれの数の平方からなります。
concatMap
がどのように結果を連結しているのかに注目してください。渡された関数を元の配列のそれぞれの要素について一度ずつ呼び出し、その関数はそれぞれ配列を生成します。最後にそれらの配列を単一の配列に押し潰したものが結果となります。
map
とfilter
、concatMap
は、「配列内包表記」(array comprehensions)
と呼ばれる、配列に関するあらゆる関数の基盤を形成します。
配列内包表記
数n
の2つの因数を見つけたいとしましょう。
こうするための簡単な方法としては、総当りで調べる方法があります。
つまり、1
からn
の数の全ての組み合わせを生成し、それを乗算してみるわけです。
もしその積がn
なら、n
の因数の組み合わせを見つけたということになります。
配列内包表記を使用するとこれを計算できます。 PSCiを対話式の開発環境として使用し、1つずつこの手順を進めていきましょう。
最初の工程ではn
以下の数の組み合わせの配列を生成しますが、これにはconcatMap
を使えばよいです。
1 .. n
のそれぞれの数を配列1 .. n
へと対応付けるところから始めましょう。
> pairs n = concatMap (\i -> 1 .. n) (1 .. n)
この関数をテストしてみましょう。
> pairs 3
[1,2,3,1,2,3,1,2,3]
これは求めているものとは全然違います。
単にそれぞれの組み合わせの2つ目の要素を返すのではなく、対全体を保持できるように、内側の1 .. n
の複製について関数を対応付ける必要があります。
> :paste
… pairs' n =
… concatMap (\i ->
… map (\j -> [i, j]) (1 .. n)
… ) (1 .. n)
… ^D
> pairs' 3
[[1,1],[1,2],[1,3],[2,1],[2,2],[2,3],[3,1],[3,2],[3,3]]
いい感じになってきました。
しかし、[1, 2]
と[2, 1]
の両方があるように、重複した組み合わせが生成されています。
j
をi
からn
の範囲に限定することで、2つ目の場合を取り除くことができます。
> :paste
… pairs'' n =
… concatMap (\i ->
… map (\j -> [i, j]) (i .. n)
… ) (1 .. n)
… ^D
> pairs'' 3
[[1,1],[1,2],[1,3],[2,2],[2,3],[3,3]]
すばらしいです。
因数の候補の全ての組み合わせを手に入れたので、filter
を使えば、その積がn
であるような組み合わせを選び出すことができます。
> import Data.Foldable
> factors n = filter (\pair -> product pair == n) (pairs'' n)
> factors 10
[[1,10],[2,5]]
このコードでは、foldable-traversable
ライブラリのData.Foldable
モジュールにあるproduct
関数を使っています。
うまくいきました。 因数の組み合わせの正しい集合を重複なく見つけることができました。
do記法
しかし、このコードの可読性は大幅に向上できます。map
やconcatMap
は基本的な関数であり、 do記法 (do notation)
と呼ばれる特別な構文の基礎になっています(もっと厳密にいえば、それらの一般化であるmap
とbind
が基礎をなしています)。
補足:
map
とconcatMap
があることで配列内包表記を書けるように、もっと一般的な演算子であるmap
とbind
があることでモナド内包表記と呼ばれているものが書けます。 本書の後半ではモナドの例をたっぷり見ていくことになりますが、この章では配列のみを考えます。
do記法を使うと、先ほどのfactors
関数を次のように書き直すことができます。
factors :: Int -> Array (Array Int)
factors n = filter (\xs -> product xs == n) do
i <- 1 .. n
j <- i .. n
pure [ i, j ]
キーワードdo
はdo記法を使うコードのブロックを導入します。
このブロックは幾つかの種類の式で構成されています。
- 配列の要素を名前に束縛する式。
これは後ろ向きの矢印
<-
で示されており、左側には名前が、右側には配列の型を持つ式があります。 - 名前に配列の要素を束縛しない式。
do
の結果はこの種類の式の一例であり、最後の行のpure [i, j]
に示されています。 let
キーワードを使用し、式に名前を与える式。
この新しい記法を使うと、アルゴリズムの構造がわかりやすくなることがあります。
頭の中で<-
を「選ぶ」という単語に置き換えるとすると、「1からnの間の要素i
を選び、それからiからnの間の要素j
を選び、[i, j]
を返す」というように読むことができるでしょう。
最後の行では、pure
関数を使っています。この関数はPSCiで評価できますが、型を明示する必要があります。
> pure [1, 2] :: Array (Array Int)
[[1, 2]]
配列の場合、pure
は単に1要素の配列を作成します。
factors
関数を変更して、pure
の代わりにこの形式も使うようにできます。
factorsV2 :: Int -> Array (Array Int)
factorsV2 n = filter (\xs -> product xs == n) do
i <- 1 .. n
j <- i .. n
[ [ i, j ] ]
そして、結果は同じになります。
ガード
factors
関数を更に改良する方法としては、このフィルタを配列内包表記の内側に移動するというものがあります。
これはcontrol
ライブラリにあるControl.Alternative
モジュールのguard
関数を使用することで可能になります。
import Control.Alternative (guard)
factorsV3 :: Int -> Array (Array Int)
factorsV3 n = do
i <- 1 .. n
j <- i .. n
guard $ i * j == n
pure [ i, j ]
pure
と同じように、どのように動作するかを理解するために、PSCiでguard
関数を適用して調べてみましょう。
guard
関数の型は、ここで必要とされるものよりもっと一般的な型になっています。
> import Control.Alternative
> :type guard
forall (m :: Type -> Type). Alternative m => Boolean -> m Unit
Unit
型は何ら計算する内容の無い値を表現します。 すなわち具体的で意味のある値が存在しないということです。
Unit
はよく型構築子に「包んで」計算の返却型として使います。 その計算は具体的な値のためではなく、計算の作用(ないし結果の「形状」)のみに関心があるのです。例えば、
main
関数は型Effect Unit
を持ちます。 この関数はプロジェクトへの入口であり、直接呼ぶものではありません。型シグネチャ中の
m
の意味については第6章で説明します。
今回の場合は、PSCiは次の型を報告するものと考えてください。
Boolean -> Array Unit
目的からすると、次の計算の結果から配列におけるguard
関数について今知りたいことは全てわかります。
> import Data.Array
> length $ guard true
1
> length $ guard false
0
つまり、guard
がtrue
に評価される式を渡された場合、単一の要素を持つ配列を返すのです。
もし式がfalse
と評価された場合は、その結果は空です。
ガードが失敗した場合、配列内包表記の現在の分岐は、結果なしで早めに終了されることを意味します。
これは、guard
の呼び出しが、途中の配列に対してfilter
を使用するのと同じだということです。
実践での場面にもよりますが、filter
の代わりにguard
を使いたいことは多いでしょう。
これらが同じ結果になることを確認するために、factors
の2つの定義を試してみてください。
演習
- (簡単)関数
isPrime
を書いてください。 この関数は整数の引数が素数であるかを調べます。 手掛かり:factors
関数を使ってください。 - (普通)do記法を使い、2つの配列の直積集合を見つけるための関数
cartesianProduct
を書いてみましょう。 直積集合とは、要素a
、b
の全ての組み合わせの集合のことです。 ここでa
は最初の配列の要素、b
は2つ目の配列の要素です。 - (普通)関数
triples :: Int -> Array (Array Int)
を書いてください。 この関数は数値 \( n \) を取り、構成要素(値 \( a \)、 \( b \)、 \( c \))がそれぞれ \( n \) 以下であるような全てのピタゴラスの3つ組 (pythagorean triples) を返します。 ピタゴラスの3つ組は \( a ^ 2 + b ^ 2 = c ^ 2 \) であるような数値の配列 \( [ a, b, c ] \) です。 手掛かり:配列内包表記でguard
関数を使ってください。 - (難しい)
factors
関数を使用して、n
の素因数分解を求める関数primeFactors
を定義してみましょう。n
の素因数分解とは、積がn
であるような素数の配列のことです。 手掛かり:1より大きい整数について、問題を2つの部分問題に分解してください。 最初の因数を探し、それから残りの因数を探すのです。
畳み込み
配列における左右の畳み込みは、再帰を用いて実装できる別の興味深い一揃いの関数を提供します。
PSCiを使って、Data.Foldable
モジュールをインポートし、foldl
とfoldr
関数の型を調べることから始めましょう。
> import Data.Foldable
> :type foldl
forall (f :: Type -> Type) (a :: Type) (b :: Type). Foldable f => (b -> a -> b) -> b -> f a -> b
> :type foldr
forall (f :: Type -> Type) (a :: Type) (b :: Type). Foldable f => (a -> b -> b) -> b -> f a -> b
これらの型は、現在興味があるものよりも一般化されています。 この章では単純化して以下の(より具体的な)型シグネチャと見て構いません。
-- foldl
forall a b. (b -> a -> b) -> b -> Array a -> b
-- foldr
forall a b. (a -> b -> b) -> b -> Array a -> b
どちらの場合でも、型a
は配列の要素の型に対応しています。
型b
は「累算器」の型として考えることができます。
累算器とは配列を走査しつつ結果を累算するものです。
foldl
関数とfoldr
関数の違いは走査の方向です。
foldr
が「右から」配列を畳み込むのに対して、foldl
は「左から」配列を畳み込みます。
実際にこれらの関数の動きを見てみましょう。
foldl
を使用して数の配列の和を求めてみます。
型a
はInt
になり、結果の型b
もInt
として選択できます。
ここでは3つの引数を与える必要があります。
1つ目は次の要素を累算器に加算するInt -> Int -> Int
という型の関数です。
2つ目は累算器のInt
型の初期値です。
3つ目は和を求めたいInt
の配列です。
最初の引数としては、加算演算子を使用できますし、累算器の初期値はゼロになります。
> foldl (+) 0 (1 .. 5)
15
この場合では、引数が逆になっていても(+)
関数は同じ結果を返すので、foldl
とfoldr
のどちらでも問題ありません。
> foldr (+) 0 (1 .. 5)
15
違いを説明するために、畳み込み関数の選択が大事になってくる例も書きましょう。 加算関数の代わりに、文字列連結を使用して文字列を構築しましょう。
> foldl (\acc n -> acc <> show n) "" [1,2,3,4,5]
"12345"
> foldr (\n acc -> acc <> show n) "" [1,2,3,4,5]
"54321"
これは、2つの関数の違いを示しています。左畳み込み式は、以下の関数適用と同等です。
((((("" <> show 1) <> show 2) <> show 3) <> show 4) <> show 5)
それに対し、右畳み込みは以下と等価です。
((((("" <> show 5) <> show 4) <> show 3) <> show 2) <> show 1)
末尾再帰
再帰はアルゴリズムを指定する強力な手法ですが、問題も抱えています。 JavaScriptで再帰関数を評価するとき、入力が大き過ぎるとスタックオーバーフローでエラーを起こす可能性があるのです。
PSCiで次のコードを入力すると、この問題を簡単に検証できます。
> :paste
… f n =
… if n == 0
… then 0
… else 1 + f (n - 1)
… ^D
> f 10
10
> f 100000
RangeError: Maximum call stack size exceeded
これは問題です。 関数型プログラミングの標準的な手法として再帰を採用しようとするなら、境界がない再帰がありうるときでも扱える方法が必要です。
PureScriptは末尾再帰最適化の形でこの問題に対する部分的な解決策を提供しています。
補足:この問題へのより完全な解決策としては、いわゆるトランポリンを使用するライブラリで実装できますが、それはこの章で扱う範囲を超えています。 興味のある読者は
free
やtailrec
パッケージのドキュメントをあたると良いでしょう。
末尾再帰最適化を可能にする上で鍵となる観点は以下となります。
末尾位置にある関数の再帰的な呼び出しはジャンプに置き換えられます。
このジャンプではスタックフレームが確保されません。
関数が戻るより前の最後の呼び出しであるとき、呼び出しが末尾位置にあるといいます。
先の例でスタックオーバーフローが見られたのはこれが理由です。
f
の再帰呼び出しが末尾位置でなかったからです。
実際には、PureScriptコンパイラは再帰呼び出しをジャンプに置き換えるのではなく、再帰的な関数全体を whileループ に置き換えます。
以下は全ての再帰呼び出しが末尾位置にある再帰関数の例です。
factorialTailRec :: Int -> Int -> Int
factorialTailRec 0 acc = acc
factorialTailRec n acc = factorialTailRec (n - 1) (acc * n)
factorialTailRec
への再帰呼び出しがこの関数の最後にある点に注目してください。
つまり末尾位置にあるのです。
累算器
末尾再帰ではない関数を末尾再帰関数に変える一般的な方法は、累算器引数を使用することです。 累算器引数は関数に追加される余剰の引数で、返り値を累算するものです。 これは結果を累算するために返り値を使うのとは対照的です。
例えば章の初めに示したlength
関数を再考しましょう。
length :: forall a. Array a -> Int
length [] = 0
length arr = 1 + (length $ fromMaybe [] $ tail arr)
この実装は末尾再帰ではないので、大きな入力配列に対して実行されると、生成されたJavaScriptはスタックオーバーフローを発生させるでしょう。 しかし代わりに、結果を蓄積するための2つ目の引数を関数に導入することで、これを末尾再帰に変えることができます。
lengthTailRec :: forall a. Array a -> Int
lengthTailRec arr = length' arr 0
where
length' :: Array a -> Int -> Int
length' [] acc = acc
length' arr' acc = length' (fromMaybe [] $ tail arr') (acc + 1)
ここでは補助関数length'
に委譲しています。
この関数は末尾再帰です。
その唯一の再帰呼び出しは、最後の場合の末尾位置にあります。
つまり、生成されるコードはwhileループとなり、大きな入力でもスタックが溢れません。
lengthTailRec
の実装を理解する上では、補助関数length'
が基本的に累算器引数を使って追加の状態を保持していることに注目してください。
追加の状態とは、部分的な結果です。
0から始まり、入力の配列中の全ての各要素について1ずつ足されて大きくなっていきます。
なお、累算器を「状態」と考えることもできますが、直接には変更されていません。
明示的な再帰より畳み込みを選ぼう
末尾再帰を使用して再帰関数を記述できれば末尾再帰最適化の恩恵を受けられるので、全ての関数をこの形で書こうとする誘惑に駆られます。
しかし、多くの関数は配列やそれに似たデータ構造に対する折り畳みとして直接書くことができることを忘れがちです。
map
やfold
のような組み合わせの部品を使って直接アルゴリズムを書くことには、コードの単純さという利点があります。
これらの部品はよく知られており、だからこそ明示的な再帰よりもアルゴリズムの意図がより良く伝わるのです。
例えばfoldr
を使って配列を反転できます。
> import Data.Foldable
> :paste
… reverse :: forall a. Array a -> Array a
… reverse = foldr (\x xs -> xs <> [x]) []
… ^D
> reverse [1, 2, 3]
[3,2,1]
foldl
を使ってreverse
を書くことは、読者への課題として残しておきます。
演習
- (簡単)
foldl
を使って真偽値配列の値が全て真か検査する関数allTrue
を書いてください。 - (普通。テストなし)関数
foldl (==) false xs
が真を返すような配列xs
とはどのようなものか説明してください。 言い換えると、「関数はxs
が……を含むときにtrue
を返す」という文を完成させることになります。 - (普通)末尾再帰の形式を取っていること以外は
fib
と同じような関数fibTailRec
を書いてください。 手掛かり:累算器引数を使ってください。 - (普通)
foldl
を使ってreverse
を書いてみましょう。
仮想ファイルシステム
この節ではこれまで学んだことを応用してファイルシステムのモデルを扱う関数を書きます。 事前に定義されたAPIを扱う上でマップ、畳み込み、及びフィルタを使用します。
Data.Path
モジュールでは、次のように仮想ファイルシステムのAPIが定義されています。
- ファイルシステム内のパスを表す型
Path
があります。 - ルートディレクトリを表すパス
root
があります。 ls
関数はディレクトリ内のファイルを列挙します。filename
関数はPath
のファイル名を返します。size
関数はファイルを表すPath
のファイルの大きさを返します。isDirectory
関数はファイルかディレクトリかを調べます。
型について言うと、次のような型定義があります。
root :: Path
ls :: Path -> Array Path
filename :: Path -> String
size :: Path -> Maybe Int
isDirectory :: Path -> Boolean
PSCiでこのAPIを試してみましょう。
$ spago repl
> import Data.Path
> root
/
> isDirectory root
true
> ls root
[/bin/,/etc/,/home/]
Test.Examples
モジュールではData.Path
APIを使用する関数を定義しています。
Data.Path
モジュールを変更したり定義を理解したりする必要はありません。
全てTest.Examples
モジュールだけで作業します。
全てのファイルの一覧
それでは、ディレクトリの中身を含めた全てのファイルを深く列挙する関数を書いてみましょう。 この関数は以下のような型を持つでしょう。
allFiles :: Path -> Array Path
再帰を使ってこの関数を定義できます。
ls
を使うとディレクトリ直下の子が列挙されます。
それぞれの子について再帰的にallFiles
を適用すると、それぞれパスの配列が返ります。
concatMap
を使うと、allFiles
を適用して平坦化するまでを一度にできます。
最後に、cons演算子:
を使って現在のファイルも含めます。
allFiles file = file : concatMap allFiles (ls file)
補足:実はcons演算子
:
は、不変な配列に対して効率性が悪いので、一般的には推奨されません。 連結リストやシーケンスなどの他のデータ構造を使用すると、効率性を向上させられます。
それではPSCiでこの関数を試してみましょう。
> import Test.Examples
> import Data.Path
> allFiles root
[/,/bin/,/bin/cp,/bin/ls,/bin/mv,/etc/,/etc/hosts, ...]
すばらしいです。 do記法で配列内包表記を使ってもこの関数を書くことができるので見ていきましょう。
逆向きの矢印は配列から要素を選択するのに相当することを思い出してください。
最初の工程は引数の直接の子から要素を選択することです。
それからそのファイルに対して再帰関数を呼び出します。
do記法を使用しているのでconcatMap
が暗黙に呼び出されています。
この関数は再帰的な結果を全て連結します。
新しいバージョンは次のようになります。
allFiles' :: Path -> Array Path
allFiles' file = file : do
child <- ls file
allFiles' child
PSCiで新しいコードを試してみてください。 同じ結果が返ってくるはずです。 どちらのほうがわかりやすいかの選定はお任せします。
演習
-
(簡単)ディレクトリの全てのサブディレクトリの中にある(ディレクトリを除く)全てのファイルを返すような関数
onlyFiles
を書いてみてください。 -
(普通)ファイルを名前で検索する関数
whereIs
を書いてください。 この関数は型Maybe Path
の値を返すものとします。 この値が存在するなら、そのファイルがそのディレクトリに含まれているということを表します。 この関数は次のように振る舞う必要があります。> whereIs root "ls" Just (/bin/) > whereIs root "cat" Nothing
手掛かり:do記法を使う配列内包表記で、この関数を書いてみましょう。
-
(難しい)関数
largestSmallest
を書いてください。Path
を1つ取り、そのPath
中の最大のファイルと最小のファイルが1つずつ含まれる配列を返します。 補足:Path
のファイル数がゼロや1個の場合も考慮してください。 それぞれ、空配列や1要素の配列を返すとよいでしょう。
まとめ
この章ではアルゴリズムを簡潔に表現するためにPureScriptでの再帰の基本を押さえました。 また、独自の中置演算子や、マップ、絞り込みや畳み込みなどの配列に対する標準関数、及びこれらの概念を組み合わせた配列内包表記を導入しました。 最後に、スタックオーバーフローエラーを回避するために末尾再帰を使用することの重要性、累算器引数を使用して末尾再帰形に関数を変換する方法を示しました。
型クラス
この章の目標
この章では、PureScriptの型システムにより可能になっている強力な抽象化の形式を導入します。 そう、型クラスです。
データ構造をハッシュ化するためのライブラリを本章の動機付けの例とします。 データ自体の構造について直接考えることなく複雑な構造のデータのハッシュ値を求める上で、型クラスの仕組みがどのように働くかを見ていきます。
また、PureScriptのPreludeや標準ライブラリに含まれる、標準的な型クラスも見ていきます。PureScriptのコードは概念を簡潔に表現するために型クラスの強力さに大きく依存しているので、これらのクラスに慣れておくと役に立つでしょう。
オブジェクト指向の方面から入って来た方は、「クラス」という単語がそれまで馴染みのあるものとこの文脈とでは かなり 異なるものを意味していることに注意してください。
プロジェクトの準備
この章のソースコードは、ファイル src/data/Hashable.purs
で定義されています。
このプロジェクトには以下の依存関係があります。
maybe
: 省略可能な値を表すMaybe
データ型が定義されています。tuples
: 値の組を表すTuple
データ型が定義されています。either
: 非交和を表すEither
データ型が定義されています。strings
: 文字列を操作する関数が定義されています。functions
: PureScriptの関数を定義するための補助関数が定義されています。
モジュール Data.Hashable
では、これらのパッケージによって提供されるモジュールの幾つかをインポートしています。
見せて!
型クラスの最初の簡単な例は、既に何回か見たことがある関数で提供されています。
show
は何らかの値を取り、文字列として表示する関数です。
show
は Prelude
モジュールの Show
と呼ばれる型クラスで次のように定義されています。
class Show a where
show :: a -> String
このコードでは、型変数a
を引数に取るShow
という新しい型クラスを宣言しています。
型クラス インスタンス には、型クラスで定義された関数の、その型に特殊化された実装が含まれています。
例えば、Preludeにある Boolean
値に対する Show
型クラスインスタンスの定義は次の通りです。
instance Show Boolean where
show true = "true"
show false = "false"
このコードは型クラスのインスタンスを宣言します。
Boolean
型は Show
型クラスに属すもの としています。
ピンとこなければ、生成されるJSのコードは以下のようになります。
var showBoolean = { show: function (v) { if (v) { return "true"; }; if (!v) { return "false"; }; throw new Error("Failed pattern match at ..."); } };
生成される名前が気に入らなければ、型クラスインスタンスに名前を与えられます。 例えば次のようにします。
instance myShowBoolean :: Show Boolean where show true = "true" show false = "false"
var myShowBoolean = { show: function (v) { if (v) { return "true"; }; if (!v) { return "false"; }; throw new Error("Failed pattern match at ..."); } };
PSCiでいろいろな型の値をShow
型クラスを使って表示してみましょう。
> import Prelude
> show true
"true"
> show 1.0
"1.0"
> show "Hello World"
"\"Hello World\""
この例では様々な原始型の値を show
しましたが、もっと複雑な型を持つ値もshow
できます。
> import Data.Tuple
> show (Tuple 1 true)
"(Tuple 1 true)"
> import Data.Maybe
> show (Just "testing")
"(Just \"testing\")"
show
の出力は、REPLに(あるいは.purs
ファイルに)貼り戻せば、表示されたものを再作成できるような文字列であるべきです。
以下ではlogShow
を使っていますが、これは単にshow
とlog
を順に呼び出すものであり、引用符なしに文字列が表示されます。
unit
の表示は無視してください。
第8章でlog
のようなEffect
を調べるときに押さえます。
> import Effect.Console
> logShow (Tuple 1 true)
(Tuple 1 true)
unit
> logShow (Just "testing")
(Just "testing")
unit
型 Data.Either
の値を表示しようとすると、興味深いエラー文言が表示されます。
> import Data.Either
> show (Left 10)
The inferred type
forall a. Show a => String
has type variables which are not mentioned in the body of the type. Consider adding a type annotation.
ここでの問題は show
しようとしている型に対する
Show
インスタンスが存在しないということではなく、PSCiがこの型を推論できなかったということです。
これは推論された型で未知の型a
とされていることが示しています。
::
演算子を使って式に註釈を付けてPSCiが正しい型クラスインスタンスを選べるようにできます。
> show (Left 10 :: Either Int String)
"(Left 10)"
Show
インスタンスを全く持っていない型もあります。
関数の型 ->
がその一例です。
Int
から Int
への関数を show
しようとすると、型検証器によってその旨のエラー文言が表示されます。
> import Prelude
> show $ \n -> n + 1
No type class instance was found for
Data.Show.Show (Int -> Int)
型クラスインスタンスは次の2つのうち何れかの形で定義されます。
型クラスが定義されている同じモジュールで定義するか、型クラスに「属して」いる型と同じモジュールで定義するかです。
これらとは別の場所で定義されるインスタンスは「孤立インスタンス」と呼ばれ、PureScriptコンパイラでは許されていません。
この章の演習の幾つかでは、その型の型クラスインスタンスを定義できるように、型の定義を自分のMySolutions
モジュールに複製する必要があります。
演習
-
(簡単)
Show
インスタンスをPoint
に定義してください。 前の章のshowPoint
関数と同じ出力に一致するようにしてください。 補足:Point
はここでは(type
同義語ではなく)newtype
です。 そのためshow
の仕方を変えられます。 こうでもしないとレコードへの既定のShow
インスタンスから逃れられません。newtype Point = Point { x :: Number , y :: Number }
よく見かける型クラス
この節では、Preludeや標準ライブラリで定義されている標準的な型クラスを幾つか見ていきましょう。 これらの型クラスはPureScript特有の抽象化をする上で多くのよくあるパターンの基礎を形成しています。 そのため、これらの関数の基本についてよく理解しておくことを強くお勧めします。
Eq
Eq
型クラスはeq
関数を定義しています。
この関数は2つの値について等値性を調べます。
実は==
演算子はeq
の別名です。
class Eq a where
eq :: a -> a -> Boolean
何れにせよ、2つの引数は同じ型を持つ必要があります。 異なる型の2つの値を等値性に関して比較しても意味がありません。
PSCiで Eq
型クラスを試してみましょう。
> 1 == 2
false
> "Test" == "Test"
true
Ord
Ord
型クラスはcompare
関数を定義します。
この関数は2つの値を比較するのに使えるもので、その値の型は順序付けに対応したものです。
比較演算子<
、>
と厳密な大小比較ではない<=
、>=
はcompare
を用いて定義されます。
補足:
以下の例ではクラスシグネチャに<=
が含まれています。
この文脈での<=
の使われ方は、Eq
がOrd
の上位クラスであり、比較演算子としての<=
の用途を表す意図はありません。
後述の上位クラスの節を参照してください。
data Ordering = LT | EQ | GT
class Eq a <= Ord a where
compare :: a -> a -> Ordering
compare
関数は2つの値を比較してOrdering
を返します。
これには3つ選択肢があります。
LT
- 最初の引数が2番目の値より小さいとき。EQ
- 最初の引数が2番目の値と等しいとき。GT
- 最初の引数が2番目の値より大きいとき。
ここでもcompare
関数についてPSCiで試してみましょう。
> compare 1 2
LT
> compare "A" "Z"
LT
Field
Field
型クラスは加算、減算、乗算、除算などの数値演算子に対応した型を示します。
これらの演算子を抽象化して提供されているので、適切な場合に再利用できるのです。
補足:型クラス
Eq
ないしOrd
とちょうど同じように、Field
型クラスはPureScriptコンパイラで特別に扱われ、1 + 2 * 3
のような単純な式は単純なJavaScriptへと変換されます。 型クラスの実装に基いて呼び出される関数呼び出しとは対照的です。
class EuclideanRing a <= Field a
Field
型クラスは、幾つかのより抽象的な上位クラスが組み合わさってできています。
このため、Field
の操作の全てを提供しているわけではないが、その一部を提供する型について抽象的に説明できます。
例えば、自然数の型は加算及び乗算については閉じていますが、減算については必ずしも閉じていません。
そのため、この型はSemiring
クラス(これはNum
の上位クラスです)のインスタンスですが、Ring
やField
のインスタンスではありません。
上位クラスについては、この章の後半で詳しく説明します。
しかし、全ての数値型クラスの階層(チートシート)について述べるのはこの章の目的から外れています。
この内容に興味のある読者はprelude
内の Field
に関するドキュメントを参照してください。
半群とモノイド
Semigroup
(半群)型クラスは、2つの値を連結する演算子 append
を提供する型を示します。
class Semigroup a where
append :: a -> a -> a
文字列は普通の文字列連結について半群をなし、配列も同様です。
その他の標準的なインスタンスはprelude
パッケージで提供されています。
以前に見た <>
連結演算子は、 append
の別名として提供されています。
(prelude
パッケージで提供されている)Monoid
型クラスにはmempty
という名前の空の値の概念があり、Semigroup
型クラスを拡張します。
class Semigroup m <= Monoid m where
mempty :: m
ここでも文字列や配列はモノイドの簡単な例になっています。
ある型にとってのMonoid
型クラスインスタンスとは、「空」の値から始めて新たな結果に組み合わせ、その型を持つ結果を累算する方法を記述するものです。
例えば、畳み込みを使って何らかのモノイドの値の配列を連結する関数を書けます。
PSCiで以下の通りです。
> import Prelude
> import Data.Monoid
> import Data.Foldable
> foldl append mempty ["Hello", " ", "World"]
"Hello World"
> foldl append mempty [[1, 2, 3], [4, 5], [6]]
[1,2,3,4,5,6]
prelude
パッケージにはモノイドと半群の多くの例を提供しており、以降もこれらを本書で扱っていきます。
Foldable
Monoid
型クラスが畳み込みの結果になるような型を示す一方、Foldable
型クラスは畳み込みの元のデータとして使えるような型構築子を示しています。
また、 Foldable
型クラスは配列や
Maybe
などの幾つかの標準的なコンテナのインスタンスを含むfoldable-traversable
パッケージで提供されています。
Foldable
クラスに属する関数の型シグネチャは、これまで見てきたものよりも少し複雑です。
class Foldable f where
foldr :: forall a b. (a -> b -> b) -> b -> f a -> b
foldl :: forall a b. (b -> a -> b) -> b -> f a -> b
foldMap :: forall a m. Monoid m => (a -> m) -> f a -> m
f
を配列の型構築子として特殊化すると分かりやすいです。
この場合、任意のa
についてf a
をArray a
に置き換えられますが、そうするとfoldl
とfoldr
の型が、最初に配列に対する畳み込みで見た型になると気付きます。
foldMap
についてはどうでしょうか。
これは forall a m. Monoid m => (a -> m) -> Array a -> m
になります。
この型シグネチャでは、型m
がMonoid
型クラスのインスタンスであれば、返り値の型として任意に選べると書かれています。
配列の要素をそのモノイドの値へと変える関数を与えられれば、そのモノイドの構造を利用して配列上で累算し、1つの値にして返せます。
それではPSCiで foldMap
を試してみましょう。
> import Data.Foldable
> foldMap show [1, 2, 3, 4, 5]
"12345"
ここでは文字列用のモノイドとshow
関数を選んでいます。
前者は文字列を連結するもので、後者はInt
を文字列として書き出すものです。
そうして数の配列を渡すと、それぞれの数をshow
して1つの文字列へと連結した結果を得ました。
しかし畳み込み可能な型は配列だけではありません。
foldable-traversable
ではMaybe
やTuple
のような型にもFoldable
インスタンスが定義されており、lists
のような他のライブラリでは、各々のデータ型に対してFoldable
インスタンスが定義されています。
Foldable
は順序付きコンテナの概念を見据えたものなのです。
関手と型クラス則
PureScriptでは、副作用を伴う関数型プログラミングのスタイルを可能にするための型クラスの集まりも定義されています。
Functor
やApplicative
、Monad
といったものです。
これらの抽象化については後ほど本書で扱いますが、ここではFunctor
型クラスの定義を見てみましょう。
既にmap
関数の形で見たものです。
class Functor f where
map :: forall a b. (a -> b) -> f a -> f b
map
関数(別名<$>
)は関数をそのデータ構造まで「持ち上げる」(lift) ことができます。
ここで「持ち上げ」という言葉の具体的な定義は問題のデータ構造に依りますが、既に幾つかの単純な型についてその動作を見てきました。
> import Prelude
> map (\n -> n < 3) [1, 2, 3, 4, 5]
[true, true, false, false, false]
> import Data.Maybe
> import Data.String (length)
> map length (Just "testing")
(Just 7)
map
演算子は様々な構造の上でそれぞれ異なる挙動をしますが、 map
演算子の意味はどのように理解すればいいのでしょうか。
直感的には、 map
演算子はコンテナのそれぞれの要素へ関数を適用し、その結果から元のデータと同じ形状を持った新しいコンテナを構築するものとできます。
しかし、この着想を精密にするにはどうしたらいいでしょうか。
Functor
の型クラスのインスタンスは、関手則と呼ばれる法則を順守するものと期待されています。
map identity xs = xs
map g (map f xs) = map (g <<< f) xs
最初の法則は 恒等射律 (identity law) です。これは、恒等関数(引数を変えずに返す関数)をその構造まで持ち上げると、元の構造をそのまま返すという意味です。恒等関数は入力を変更しませんから、これは理にかなっています。
第2の法則は合成律です。 構造を1つの関数で写してから2つめの関数で写すのは、2つの関数の合成で構造を写すのと同じだ、と言っています。
「持ち上げ」の一般的な意味が何であれ、データ構造に対する持ち上げ関数の正しい定義はこれらの法則に従っていなければなりません。
標準の型クラスの多くには、このような法則が付随しています。 一般に、型クラスに与えられた法則は、型クラスの関数に構造を与え、普遍的にインスタンスについて調べられるようにします。 興味のある読者は、既に見てきた標準の型クラスに属する法則について調べてみてもよいでしょう。
インスタンスの導出
インスタンスを手作業で描く代わりに、ほとんどの作業をコンパイラにさせることができます。 この型クラス導出手引きを見てください。 そちらの情報が以下の演習を解く手助けになることでしょう。
演習
(簡単)次のnewtypeは複素数を表します。
newtype Complex
= Complex
{ real :: Number
, imaginary :: Number
}
-
(簡単)
Complex
にShow
インスタンスを定義してください。 出力の形式はテストで期待される形式と一致させてください(例:1.2+3.4i
、5.6-7.7i
など)。 -
(簡単)
Eq
インスタンスをComplex
に導出してください。 補足:代わりにこのインスタンスを手作業で書いてもよいですが、しなくていいのになぜすることがありましょう。 -
(普通)
Semiring
インタンスをComplex
に定義してください。 補足:Data.Newtype
のwrap
とover2
を使ってより簡潔な解答を作れます。 もしそうするのでしたら、Data.Newtype
からclass Newtype
をインポートしたり、Newtype
インスタンスをComplex
に導出したりする必要も出てくるでしょう。 -
(簡単)(
newtype
を介して)Ring
インスタンスをComplex
に導出してください。 補足:代わりにこのインスタンスを手作業で書くこともできますが、そう手軽にはできません。以下は前章からの
Shape
のADTです。data Shape = Circle Point Number | Rectangle Point Number Number | Line Point Point | Text Point String
-
(普通)(
Generic
を介して)Show
インスタンスをShape
に導出してください。 コードの量はどのくらいになりましたか。 また、前の章のshowShape
と比較してString
の出力はどうなりましたか。 手掛かり:型クラス導出手引きのGeneric
から導出する節を見てください。
型クラス制約
型クラスを使うと、関数の型に制約を加えられます。
例を示しましょう。
Eq
型クラスインスタンスを使って定義された等値性を使って、3つの値が等しいかどうかを調べる関数を書きたいとします。
threeAreEqual :: forall a. Eq a => a -> a -> a -> Boolean
threeAreEqual a1 a2 a3 = a1 == a2 && a2 == a3
この型宣言は forall
を使って定義された通常の多相型のようにも見えます。
しかし、二重線矢印 =>
で型の残りの部分から区切られた、型クラス制約 (type class constraint) Eq a
があります。
インポートされたモジュールのどれかに a
に対する Eq
インスタンスが存在するなら、どんな型 a
を選んでも
threeAsEqual
を呼び出すことができる、とこの型は言っています。
制約された型には複数の型クラスインスタンスを含めることができますし、インスタンスの型は単純な型変数に限定されません。 Ord
と
Show
のインスタンスを使って2つの値を比較する例を次に示します。
showCompare :: forall a. Ord a => Show a => a -> a -> String
showCompare a1 a2 | a1 < a2 =
show a1 <> " is less than " <> show a2
showCompare a1 a2 | a1 > a2 =
show a1 <> " is greater than " <> show a2
showCompare a1 a2 =
show a1 <> " is equal to " <> show a2
=>
シンボルを複数回使って複数の制約を指定できることに注意してください。
複数の引数のカリー化された関数を定義するのと同様です。
しかし、2つの記号を混同しないように注意してください。
a -> b
は 型a
から 型b
への関数の型を表します。- 一方で、
a => b
は 制約a
を型b
に適用します。
PureScriptコンパイラは、型の注釈が提供されていない場合、制約付きの型を推論しようとします。これは、関数に対してできる限り最も一般的な型を使用したい場合に便利です。
PSCiで Semiring
のような標準の型クラスの何れかを使って、このことを試してみましょう。
> import Prelude
> :type \x -> x + x
forall (a :: Type). Semiring a => a -> a
ここで、この関数にInt -> Int
またはNumber -> Number
と註釈を付けることはできます。
しかし、PSCiでは最も一般的な型がSemiring
で動作することが示されています。
こうするとInt
とNumber
の両方で関数を使えます。
インスタンスの依存関係
制約された型を使うと関数の実装が型クラスインスタンスに依存できるように、型クラスインスタンスの実装は他の型クラスインスタンスに依存できます。これにより、型を使ってプログラムの実装を推論するという、プログラム推論の強力な形式を提供します。
Show
型クラスを例に考えてみましょう。
それぞれの要素を show
する方法がある限り、その要素の配列を show
する型クラスインスタンスを書くことができます。
instance Show a => Show (Array a) where
...
型クラスインスタンスが複数の他のインスタンスに依存する場合、括弧で囲んでそれらのインスタンスをコンマで区切り、それを=>
シンボルの左側に置くことになります。
instance (Show a, Show b) => Show (Either a b) where
...
これらの2つの型クラスインスタンスは prelude
ライブラリにあります。
プログラムがコンパイルされると、Show
の正しい型クラスのインスタンスは show
の引数の推論された型に基づいて選ばれます。
選択されたインスタンスが沢山のそうしたインスタンスの関係に依存しているかもしれませんが、このあたりの複雑さに開発者が関与することはありません。
演習
-
(簡単)以下の宣言では型
a
の要素の空でない配列の型を定義しています。data NonEmpty a = NonEmpty a (Array a)
Eq a
とEq (Array a)
のインスタンスを再利用し、型NonEmpty
にEq
インスタンスを書いてください。 補足:代わりにEq
インスタンスを導出できます。 -
(普通)
Array
のSemigroup
インスタンスを再利用して、NonEmpty
へのSemigroup
インスタンスを書いてください。 -
(普通)
NonEmpty
にFunctor
インスタンスを書いてください。 -
(普通)
Ord
のインスタンス付きの任意の型a
が与えられているとすると、新しくそれ以外のどんな値よりも大きい「無限の」値を付け加えられます。data Extended a = Infinite | Finite a
a
へのOrd
インスタンスを再利用して、Extended a
にOrd
インスタンスを書いてください。 -
(難しい)
NonEmpty
にFoldable
インスタンスを書いてください。 手掛かり:配列へのFoldable
インスタンスを再利用してください。 -
(難しい)順序付きコンテナを定義する(そして
Foldable
のインスタンスを持っている)ような型構築子f
が与えられたとき、追加の要素を先頭に含める新たなコンテナ型を作れます。data OneMore f a = OneMore a (f a)
このコンテナ
OneMore f
もまた順序を持っています。 ここで、新しい要素は任意のf
の要素よりも前にきます。 このOneMore f
のFoldable
インスタンスを書いてみましょう。instance Foldable f => Foldable (OneMore f) where ...
-
(普通)
nubEq
関数を使い、配列から重複するShape
を削除するdedupShapes :: Array Shape -> Array Shape
関数を書いてください。 -
(普通)
dedupShapesFast
関数を書いてください。dedupShapes
とほぼ同じですが、より効率の良いnub
関数を使います。
多変数型クラス
型クラスが引数として1つの型だけを取れるのかというと、そうではありません。 その場合がほとんどですが、型クラスはゼロ個以上の型変数を持てます。
それでは2つの型引数を持つ型クラスの例を見てみましょう。
module Stream where
import Data.Array as Array
import Data.Maybe (Maybe)
import Data.String.CodeUnits as String
class Stream stream element where
uncons :: stream -> Maybe { head :: element, tail :: stream }
instance Stream (Array a) a where
uncons = Array.uncons
instance Stream String Char where
uncons = String.uncons
この Stream
モジュールでは、要素のストリームのような型を示すクラス Stream
が定義されています。
uncons
関数を使ってストリームの先頭から要素を取り出すことができます。
Stream
型クラスは、ストリーム自身の型だけでなくその要素の型も型変数として持っていることに注意してください。これによって、ストリームの型が同じでも要素の型について異なる型クラスインスタンスを定義できます。
このモジュールでは2つの型クラスインスタンスが定義されています。
uncons
がパターン照合で配列の先頭の要素を取り除くような配列のインスタンスと、文字列から最初の文字を取り除くような文字列のインスタンスです。
任意のストリーム上で動作する関数を記述できます。
例えば、ストリームの要素に基づいて Monoid
に結果を累算する関数は次のようになります。
import Prelude
import Data.Monoid (class Monoid, mempty)
foldStream :: forall l e m. Stream l e => Monoid m => (e -> m) -> l -> m
foldStream f list =
case uncons list of
Nothing -> mempty
Just cons -> f cons.head <> foldStream f cons.tail
PSCiで使って、異なる Stream
の型や異なる Monoid
の型について foldStream
を呼び出してみましょう。
関数従属性
多変数型クラスは非常に便利ですが、紛らわしい型や型推論の問題にも繋がります。
単純な例として、上記で与えられたStream
クラスを使い、ストリームに対して汎用的なtail
関数を書くことを考えてみましょう。
genericTail xs = map _.tail (uncons xs)
これはやや複雑なエラー文言を出力します。
The inferred type
forall stream a. Stream stream a => stream -> Maybe stream
has type variables which are not mentioned in the body of the type. Consider adding a type annotation.
エラーは、 genericTail
関数が Stream
型クラスの定義で言及された
element
型を使用しないので、その型は未解決のままであることを指しています。
更に残念なことに、特定の型のストリームにgenericTail
を適用できません。
> map _.tail (uncons "testing")
The inferred type
forall a. Stream String a => Maybe String
has type variables which are not mentioned in the body of the type. Consider adding a type annotation.
ここでは、コンパイラが streamString
インスタンスを選択することを期待しています。
結局のところ、 String
は Char
のストリームであり、他の型のストリームであってはなりません。
コンパイラは自動的にそう推論できず、streamString
インスタンスに目が向きません。
しかし、型クラス定義に手掛かりを追加すると、コンパイラを補助できます。
class Stream stream element | stream -> element where
uncons :: stream -> Maybe { head :: element, tail :: stream }
ここで、 stream -> element
は 関数従属性 (functional dependency) と呼ばれます。関数従属性は、多変数型クラスの型引数間の関数関係を宣言します。この関数の依存関係は、ストリーム型から(一意の)要素型への関数があることをコンパイラに伝えるので、コンパイラがストリーム型を知っていれば要素の型を割り当てられます。
この手掛かりがあれば、コンパイラが上記の汎用的な尾鰭関数の正しい型を推論するのに充分です。
> :type genericTail
forall (stream :: Type) (element :: Type). Stream stream element => stream -> Maybe stream
> genericTail "testing"
(Just "esting")
多変数の型クラスを使用して何らかのAPIを設計する場合、関数従属性が便利なことがあります。
型変数のない型クラス
ゼロ個の型変数を持つ型クラスさえも定義できます。 これらは関数に対するコンパイル時の表明に対応しており、型システム内においてそのコードの大域的な性質を把握できます。
重要な一例として、前に部分関数についてお話しした際に見たPartial
クラスがあります。
Data.Array.Partial
に定義されている関数head
とtail
を例に取りましょう。
この関数は配列の先頭と尾鰭をMaybe
に包むことなく取得できます。
そのため配列が空のときに失敗する可能性があります。
head :: forall a. Partial => Array a -> a
tail :: forall a. Partial => Array a -> Array a
Partial
モジュールの Partial
型クラスのインスタンスを定義していないことに注意してください。
こうすると目的を達成できます。
このままの定義では head
関数を使用しようとすると型エラーになるのです。
> head [1, 2, 3]
No type class instance was found for
Prim.Partial
代わりに、これらの部分関数を利用する全ての関数で Partial
制約を再発行できます。
secondElement :: forall a. Partial => Array a -> a
secondElement xs = head (tail xs)
前章で見たunsafePartial
関数を使用し、部分関数を通常の関数として(不用心に)扱うことができます。この関数は
Partial.Unsafe
モジュールで定義されています。
unsafePartial :: forall a. (Partial => a) -> a
Partial
制約は関数の矢印の左側の括弧の中に現れますが、外側の forall
では現れません。
つまり、 unsafePartial
は部分的な値から通常の値への関数です。
> unsafePartial head [1, 2, 3]
1
> unsafePartial secondElement [1, 2, 3]
2
上位クラス
インスタンスを別のインスタンスに依存させることによって型クラスのインスタンス間の関係を表現できるように、いわゆる上位クラスを使って型クラス間の関係を表現できます。
あるクラスのどんなインスタンスも、別のクラスのインスタンスである必要があるとき、後者の型クラスは前者の型クラスの上位クラスであるといいます。
そしてクラス定義で逆向きの太い矢印 (<=
) を使って上位クラス関係を示します。
既に上位クラスの関係の例を目にしました。
Eq
クラスはOrd
の上位クラスですし、Semigroup
クラスはMonoid
の上位クラスです。
Ord
クラスの全ての型クラスインスタンスについて、その同じ型に対応する Eq
インスタンスが存在しなければなりません。
これは理に適っています。
多くの場合、compare
関数が2つの値の大小を付けられないと報告した時は、同値であるかを判定するためにEq
クラスを使うことでしょう。
一般に、下位クラスの法則が上位クラスの構成要素に言及しているとき、上位クラス関係を定義するのは筋が通っています。
例えば、任意のOrd
とEq
のインスタンスの対について、もし2つの値がEq
インスタンスの下で同値であるなら、compare
関数はEQ
を返すはずだと推定するのは理に適っています。
言い換えれば、a == b
が真であるのはcompare a b
が厳密にEQ
に評価されるときなのです。
法則のレベルでのこの関係は、Eq
とOrd
の間の上位クラス関係の正当性を示します。
上位クラス関係を定義する別の理由となるのは、この2つのクラスの間に明白な "is-a" の関係があることです。 下位クラスの全ての構成要素は、上位クラスの構成要素でもあるということです。
演習
-
(普通)部分関数
unsafeMaximum :: Partial => Array Int -> Int
を定義してください。 この関数は空でない整数の配列の最大値を求めます。unsafePartial
を使ってPSCiで関数を試してください。 手掛かり:Data.Foldable
のmaximum
関数を使います。 -
(普通)次の
Action
クラスは、ある型の別の型での動作を定義する、多変数型クラスです。class Monoid m <= Action m a where act :: m -> a -> a
動作とは、他の型の値を変更する方法を決めるために使われるモノイドな値を記述する関数です。
Action
型クラスには2つの法則があります。act mempty a = a
act (m1 <> m2) a = act m1 (act m2 a)
空の動作を提供しても何も起こりません。 そして2つの動作を連続で適用することは結合した動作を適用することと同じです。 つまり、動作は
Monoid
クラスで定義される操作に倣っています。例えば自然数は乗算のもとでモノイドを形成します。
newtype Multiply = Multiply Int instance Semigroup Multiply where append (Multiply n) (Multiply m) = Multiply (n * m) instance Monoid Multiply where mempty = Multiply 1
この動作を実装するインスタンスを書いてください。
instance Action Multiply Int where ...
インスタンスが上で挙げた法則を見たさなくてはならないことを思い出してください。
-
(難しい)
Action Multiply Int
のインスタンスを実装するには複数の方法があります。 どれだけ思い付きますか。 PureScriptは同じインスタンスの複数の実装を許さないため、元の実装を置き換える必要があるでしょう。 補足:テストでは4つの実装を押さえています。 -
(普通)入力の文字列を何回か繰り返す
Action
インスタンスを書いてください。instance Action Multiply String where ...
手掛かり:Pursuitでシグネチャが
String -> Int -> String
の補助関数を検索してください。 なお、String
は(Monoid
のような)より汎用的な型として現れます。このインスタンスは上に挙げた法則を満たすでしょうか。
-
(普通)インスタンス
Action m a => Action m (Array a)
を書いてみましょう。 ここで、 配列上の動作はそれぞれの要素を独立に実行するものとして定義されます。 -
(難しい)以下のnewtypeが与えらえているとき、
Action m (Self m)
のインスタンスを書いてください。 ここでモノイドm
はそれ自体が持つappend
を用いて動作します。newtype Self m = Self m
補足:テストフレームワークでは
Self
とMultiply
型にShow
とEq
インスタンスが必要になります。 手作業でこれらのインスタンスを書いてもよいですし、derive newtype instance
と書くだけでコンパイラに取り仕切ってもらうこともできます。 -
(難しい)多変数型のクラス
Action
の引数は、何らかの関数従属性によって関連づけられるべきですか。 なぜそうすべき、あるいはそうすべきでないでしょうか。 補足:この演習にはテストがありません。
ハッシュの型クラス
この最後の節では、章の残りを使ってデータ構造をハッシュ化するライブラリを作ります。
なお、このライブラリは説明だけを目的としており、堅牢なハッシュ化の仕組みの提供は意図していません。
ハッシュ関数に期待される性質とはどのようなものでしょうか。
- ハッシュ関数は決定的でなくてはなりません。 つまり、同じ値は同じハッシュコードに写さなければなりません。
- ハッシュ関数はいろいろなハッシュ値の集合で結果が一様に分布しなければなりません。
最初の性質はちゃんとした型クラスの法則のように見えます。 その一方で、2番目の性質はよりくだけた規約の条項のようなもので、PureScriptの型システムによって確実に強制できるようなものではなさそうです。 しかし、これは型クラスから次のような直感が得られるでしょう。
newtype HashCode = HashCode Int
instance Eq HashCode where
eq (HashCode a) (HashCode b) = a == b
hashCode :: Int -> HashCode
hashCode h = HashCode (h `mod` 65535)
class Eq a <= Hashable a where
hash :: a -> HashCode
これに、 a == b
ならば hash a == hash b
を示唆するという関係性の法則が付随しています。
この節の残りの部分を費やして、Hashable
型クラスに関連付けられているインスタンスと関数のライブラリを構築していきます。
決定的な方法でハッシュ値を結合する方法が必要になります。
combineHashes :: HashCode -> HashCode -> HashCode
combineHashes (HashCode h1) (HashCode h2) = hashCode (73 * h1 + 51 * h2)
combineHashes
関数は2つのハッシュ値を混ぜて結果を0-65535の間に分布します。
それでは、Hashable
制約を使って入力の種類を制限する関数を書いてみましょう。
ハッシュ関数を必要とするよくある目的としては、2つの値が同じハッシュコードにハッシュ化されるかどうかを判定することです。
hashEqual
関係はそのような機能を提供します。
hashEqual :: forall a. Hashable a => a -> a -> Boolean
hashEqual = eq `on` hash
この関数はハッシュコードの等値性を利用したハッシュ同値性を定義するためにData.Function
の
on
関数を使っていますが、これはハッシュ同値性の宣言的な定義として読めるはずです。
つまり、それぞれの値が hash
関数に渡されたあとで2つの値が等しいなら、それらの値は「ハッシュ同値」です。
原始型の Hashable
インスタンスを幾つか書いてみましょう。
まずは整数のインスタンスです。
HashCode
は実際には単なる梱包された整数なので、単純です。
hashCode
補助関数を使えます。
instance Hashable Int where
hash = hashCode
パターン照合を使うと、Boolean
値の単純なインスタンスも定義できます。
instance Hashable Boolean where
hash false = hashCode 0
hash true = hashCode 1
整数のインスタンスでは、Data.Char
の toCharCode
関数を使うとChar
をハッシュ化するインスタンスを作成できます。
instance Hashable Char where
hash = hash <<< toCharCode
(要素型が Hashable
のインスタンスでもあるならば)配列の要素に hash
関数を
map
してから、combineHashes
関数を使って結果のハッシュを左側に畳み込むことで、配列のインスタンスを定義します。
instance Hashable a => Hashable (Array a) where
hash = foldl combineHashes (hashCode 0) <<< map hash
既に書いたものより単純なインスタンスを使用して、新たなインスタンスを構築しているやり方に注目してください。
String
をChar
の配列に変換し、この新たなArray
インスタンスを使ってString
のインスタンスを定義しましょう。
instance Hashable String where
hash = hash <<< toCharArray
これらの Hashable
インスタンスが先ほどの型クラスの法則を満たしていることを証明するにはどうしたらいいでしょうか。
同じ値が等しいハッシュコードを持っていることを確認する必要があります。
Int
、Char
、String
、Boolean
のような場合は単純です。
Eq
の意味では同じ値でも厳密には同じではない、というような型の値は存在しないからです。
もっと面白い型についてはどうでしょうか。
Array
インスタンスの型クラスの法則を証明するにあたっては、配列の長さに関する帰納を使えます。
長さゼロの唯一の配列は []
です。
配列の Eq
の定義により、任意の2つの空でない配列は、それらの先頭の要素が同じで配列の残りの部分が等しいとき、またその時に限り等しくなります。
この帰納的な仮定により、配列の残りの部分は同じハッシュ値を持ちますし、もし Hashable a
インスタンスがこの法則を満たすなら、先頭の要素も同じハッシュ値を持つことがわかります。
したがって、2つの配列は同じハッシュ値を持ち、Hashable (Array a)
も同様に型クラス法則に従います。
この章のソースコードには、 Maybe
と Tuple
型のインスタンスなど、他にも Hashable
インスタンスの例が含まれています。
演習
-
(簡単)PSCiを使って、定義した各インスタンスのハッシュ関数をテストしてください。 補足:この演習には単体試験がありません。
-
(普通)関数
arrayHasDuplicates
を書いてください。 この関数はハッシュと値の同値性に基づいて配列が重複する要素を持っているかどうかを調べます。 まずハッシュ同値性をhashEqual
関数で確認し、それからもし重複するハッシュの対が見付かったら==
で値の同値性を確認してください。 手掛かり:Data.Array
のnubByEq
関数はこの問題をずっと簡単にしてくれるでしょう。 -
(普通)型クラスの法則を満たす、次のnewtypeの
Hashable
インスタンスを書いてください。newtype Hour = Hour Int instance Eq Hour where eq (Hour n) (Hour m) = mod n 12 == mod m 12
newtypeの Hour
とその Eq
インスタンスは、12を法とする整数の型を表します。
したがって、例えば1と13は等しいと見なされます。
そのインスタンスが型クラスの法則を満たしていることを証明してください。
- (難しい)
Maybe
、Either
そしてTuple
へのHashable
インスタンスについて型クラスの法則を証明してください。 補足:この演習にテストはありません。
まとめ
この章では型クラスを導入しました。 型クラスは型に基づく抽象化で、コードの再利用のために強力な形式化ができます。 PureScriptの標準ライブラリから標準の型クラスを幾つか見てきました。 また、ハッシュ値を計算するための型クラスに基づく独自のライブラリを定義しました。
この章では型クラス法則も導入しましたが、これは抽象化に型クラスを使うコードについての性質を証明する手法でした。 型クラス法則は等式推論と呼ばれる、より大きな分野の一部です。 そちらではプログラミング言語の性質と型システムがプログラムを論理的に追究するために使われています。 これは重要な考え方で、本書では今後あらゆる箇所で立ち返る話題となるでしょう。
アプリカティブによる検証
この章の目標
この章では重要な抽象化と新たに出会うことになります。
Applicative
型クラスによって表現されるアプリカティブ関手です。
名前が難しそうに思えても心配しないでください。
フォームデータの検証という実用的な例を使ってこの概念の動機付けをします。
アプリカティブ関手の技法があることにより、通常であれば大量の決まり文句の検証を伴うようなコードを、簡潔で宣言的なフォームの記述へと変えられます。
また、巡回可能関手を表現するTraversable
という別の型クラスにも出会います。現実の問題への解決策からこの概念が自然に生じることがわかるでしょう。
この章のコードでは第3章に引き続き住所録を例とします。 今回は住所録のデータ型を拡張し、これらの型の値を検証する関数を書きます。 これらの関数は、例えばwebユーザインターフェースで使えることが分かります。 データ入力フォームの一部として、使用者へエラーを表示するのに使われます。
プロジェクトの準備
この章のソースコードは、2つのファイルsrc/Data/AddressBook.purs
、及びsrc/Data/AddressBook/Validation.purs
で定義されています。
このプロジェクトには多くの依存関係がありますが、その大半は既に見てきたものです。 新しい依存関係は2つです。
control
-Applicative
のような、型クラスを使用して制御フローを抽象化する関数が定義されています。validation
- この章の主題である アプリカティブによる検証 のための関手が定義されています。
Data.AddressBook
モジュールにはこのプロジェクトのデータ型とそれらの型に対するShow
インスタンスが定義されています。
また、Data.AddressBook.Validation
モジュールにはそれらの型の検証規則が含まれています。
関数適用の一般化
アプリカティブ関手 の概念を理解するために、以前扱った型構築子Maybe
について考えてみましょう。
このモジュールのソースコードでは、次の型を持つaddress
関数が定義されています。
address :: String -> String -> String -> Address
この関数は、通りの名前、市、州という3つの文字列から型Address
の値を構築するために使います。
この関数は簡単に適用できますので、PSCiでどうなるか見てみましょう。
> import Data.AddressBook
> address "123 Fake St." "Faketown" "CA"
{ street: "123 Fake St.", city: "Faketown", state: "CA" }
しかし、通り、市、州の3つ全てが必ずしも入力されないものとすると、3つの場合がそれぞれ省略可能であることを示すためにMaybe
型を使用したくなります。
考えられる場合としては、市が省略されている場合があるでしょう。
もしaddress
関数を直接適用しようとすると、型検証器からエラーが表示されます。
> import Data.Maybe
> address (Just "123 Fake St.") Nothing (Just "CA")
Could not match type
Maybe String
with type
String
勿論、これは期待通り型エラーになります。
address
はMaybe String
型の値ではなく、文字列を引数として取るためです。
しかし、もしaddress
関数を「持ち上げる」ことができれば、Maybe
型で示される省略可能な値を扱うことができるはずだという予想は理に適っています。実際それは可能で、Control.Apply
で提供されている関数lift3
が、まさに求めているものです。
> import Control.Apply
> lift3 address (Just "123 Fake St.") Nothing (Just "CA")
Nothing
このとき、引数の1つ(市)が欠落していたので、結果はNothing
になります。
もし3つの引数全てにJust
構築子を使ったものが与えられたら、結果は値を含むことになります。
> lift3 address (Just "123 Fake St.") (Just "Faketown") (Just "CA")
Just ({ street: "123 Fake St.", city: "Faketown", state: "CA" })
lift3
という関数の名前は、3引数の関数を持ち上げるために使えることを示しています。関数を持ち上げる同様の関数で、引数の数が異なるものがControl.Apply
で定義されています。
任意個の引数を持つ関数の持ち上げ
これで、lift2
やlift3
のような関数を使えば、引数が2個や3個の関数を持ち上げることができるのはわかりました。
でも、これを任意個の引数の関数へと一般化できるのでしょうか。
lift3
の型を見てみるとわかりやすいでしょう。
> :type lift3
forall (a :: Type) (b :: Type) (c :: Type) (d :: Type) (f :: Type -> Type). Apply f => (a -> b -> c -> d) -> f a -> f b -> f c -> f d
上のMaybe
の例では型構築子f
はMaybe
ですから、lift3
は次のように特殊化されます。
forall a b c d. (a -> b -> c -> d) -> Maybe a -> Maybe b -> Maybe c -> Maybe d
この型で書かれているのは、3引数の任意の関数を取り、その関数を引数と返り値がMaybe
で包まれた新しい関数へと持ち上げられる、ということです。
勿論、どんな型構築子f
についても持ち上げができるわけではないのですが、それではMaybe
型を持ち上げができるようにしているものは何なのでしょうか。
さて、先ほどの型の特殊化では、f
に対する型クラス制約からApply
型クラスを取り除いていました。
Apply
はPreludeで次のように定義されています。
class Functor f where
map :: forall a b. (a -> b) -> f a -> f b
class Functor f <= Apply f where
apply :: forall a b. f (a -> b) -> f a -> f b
Apply
型クラスはFunctor
の下位クラスであり、追加の関数apply
を定義しています。<$>
がmap
の別名として定義されているように、Prelude
モジュールでは<*>
をapply
の別名として定義しています。これから見ていきますが、これら2つの演算子はよく一緒に使われます。
なお、このapply
はData.Function
のapply
(中置で$
)とは異なります。
幸いにも後者はほぼ常に中置記法として使われるので、名前の衝突については心配ご無用です。
apply
の型はmap
の型と実によく似ています。
map
とapply
の違いは、map
がただの関数を引数に取るのに対し、apply
の最初の引数は型構築子f
で包まれているという点です。
これをどのように使うのかはこれからすぐに見ていきますが、その前にまずMaybe
型についてApply
型クラスをどう実装するのかを見ていきましょう。
instance Functor Maybe where
map f (Just a) = Just (f a)
map f Nothing = Nothing
instance Apply Maybe where
apply (Just f) (Just x) = Just (f x)
apply _ _ = Nothing
この型クラスのインスタンスで書かれているのは、任意の省略可能な値に省略可能な関数を適用でき、その両方が定義されている時に限り結果も定義される、ということです。
それでは、map
とapply
を一緒に使い、引数が任意個の関数を持ち上げる方法を見ていきましょう。
1引数の関数については、map
をそのまま使うだけです。
2引数関数については、型a -> b -> c
のカリー化された関数g
があるとします。これは型a -> (b -> c)
と同じですから、Functor
インスタンス付きのあらゆる型構築子f
について、map
をf
に適用すると型f a -> f (b -> c)
の新たな関数を得ることになります。持ち上げられた(型f a
の)最初の引数にその関数を部分適用すると、型f (b -> c)
の新たな包まれた関数が得られます。f
にApply
インスタンスもあるなら、そこから、2番目の持ち上げられた(型f b
の)引数へapply
を適用でき、型f c
の最終的な値を得ます。
纏めると、x :: f a
とy :: f b
があるとき、式(g <$> x) <*> y
の型はf c
になります(この式はapply (map g x) y
と同じ意味だということを思い出しましょう)。Preludeで定義された優先順位の規則に従うと、g <$> x <*> y
というように括弧を外すことができます。
一般的には、最初の引数に<$>
を使い、残りの引数に対しては<*>
を使います。lift3
で説明すると次のようになります。
lift3 :: forall a b c d f
. Apply f
=> (a -> b -> c -> d)
-> f a
-> f b
-> f c
-> f d
lift3 f x y z = f <$> x <*> y <*> z
この式に関する型の検証は、読者への演習として残しておきます。
例として、<$>
と<*>
をそのまま使うと、Maybe
上にaddress
関数を持ち上げることができます。
> address <$> Just "123 Fake St." <*> Just "Faketown" <*> Just "CA"
Just ({ street: "123 Fake St.", city: "Faketown", state: "CA" })
> address <$> Just "123 Fake St." <*> Nothing <*> Just "CA"
Nothing
同様にして、引数が異なる他のいろいろな関数をMaybe
上に持ち上げてみてください。
この代わりに、お馴染のdo記法に似た見た目のアプリカティブdo記法が同じ目的で使えます。
以下ではlift3
にアプリカティブdo記法を使っています。
なお、ado
がdo
の代わりに使われており、生み出された値を示すために最後の行でin
が使われています。
lift3 :: forall a b c d f
. Apply f
=> (a -> b -> c -> d)
-> f a
-> f b
-> f c
-> f d
lift3 f x y z = ado
a <- x
b <- y
c <- z
in f a b c
アプリカティブ型クラス
関連するApplicative
という型クラスが存在しており、次のように定義されています。
class Apply f <= Applicative f where
pure :: forall a. a -> f a
Applicative
はApply
の下位クラスであり、pure
関数が定義されています。
pure
は値を取り、その型の型構築子f
で包まれた値を返します。
Maybe
についてのApplicative
インスタンスは次のようになります。
instance Applicative Maybe where
pure x = Just x
アプリカティブ関手は関数を持ち上げることを可能にする関手だと考えるとすると、pure
は引数のない関数の持ち上げだというように考えられます。
アプリカティブに対する直感的理解
PureScriptの関数は純粋であり、副作用は持っていません。Applicative関手は、関手f
によって表現されるある種の副作用を提供するような、より大きな「プログラミング言語」を扱えるようにします。
例えば関手Maybe
は欠けている可能性がある値の副作用を表現しています。
その他の例としては、型err
のエラーの可能性の副作用を表すEither err
や、大域的な構成を読み取る副作用を表すArrow関手 (arrow functor) r ->
があります。
ここではMaybe
関手についてのみ考えることにします。
もし関手f
が作用を持つ、より大きなプログラミング言語を表すとすると、Apply
とApplicative
インスタンスは小さなプログラミング言語
(PureScript) から新しい大きな言語へと値や関数を持ち上げることを可能にします。
pure
は純粋な(副作用がない)値をより大きな言語へと持ち上げますし、関数については上で述べた通りmap
とapply
を使えます。
ここで疑問が生まれます。
もしPureScriptの関数と値を新たな言語へ埋め込むのにApplicative
が使えるなら、どうやって新たな言語は大きくなっているというのでしょうか。
この答えは関手f
に依存します。
もしなんらかのx
についてpure x
で表せないような型f a
の式を見つけたなら、その式はそのより大きな言語だけに存在する項を表しているということです。
f
がMaybe
のときは、式Nothing
がその例になっています。
どんなx
があってもNothing
をpure x
というように書くことはできません。
したがって、PureScriptは値の欠落を表す新しい項Nothing
を含むように拡大されたと考えることができます。
もっと作用を
様々なApplicative
関手へと関数を持ち上げる例をもっと見ていきましょう。
以下は、PSCiで定義された3つの名前を結合して完全な名前を作る簡単な関数の例です。
> import Prelude
> fullName first middle last = last <> ", " <> first <> " " <> middle
> fullName "Phillip" "A" "Freeman"
Freeman, Phillip A
この関数が、クエリ引数として与えられた3つの引数を持つ、(とっても簡単な)webサービスの実装を形成しているとしましょう。
使用者が3つの各引数を与えたことを確かめたいので、引数が存在するかどうかを表すMaybe
型を使うことになるでしょう。
fullName
をMaybe
の上へ持ち上げると、欠けている引数を検査するwebサービスの実装を作成できます。
> import Data.Maybe
> fullName <$> Just "Phillip" <*> Just "A" <*> Just "Freeman"
Just ("Freeman, Phillip A")
> fullName <$> Just "Phillip" <*> Nothing <*> Just "Freeman"
Nothing
またはアプリカティブdoで次のようにします。
> import Data.Maybe
> :paste…
… ado
… f <- Just "Phillip"
… m <- Just "A"
… l <- Just "Freeman"
… in fullName f m l
… ^D
(Just "Freeman, Phillip A")
… ado
… f <- Just "Phillip"
… m <- Nothing
… l <- Just "Freeman"
… in fullName f m l
… ^D
Nothing
この持ち上げた関数は、引数の何れかがNothing
ならNothing
を返すことに注意してください。
引数が不正のときにwebサービスからエラー応答を送り返せるのは良いことです。 しかし、どのフィールドが不正確なのかを応答で示せると、もっと良くなるでしょう。
Meybe
上へ持ち上げる代わりにEither String
上へ持ち上げるようにすると、エラー文言を返せるようになります。
まずはEither String
を使い、省略可能な入力からエラーを発信できる計算に変換する演算子を書きましょう。
> import Data.Either
> :paste
… withError Nothing err = Left err
… withError (Just a) _ = Right a
… ^D
補足:Either err
アプリカティブ関手において、Left
構築子は失敗を表しており、Right
構築子は成功を表しています。
これでEither String
上へ持ち上げることで、それぞれの引数について適切なエラー文言を提供できるようになります。
> :paste
… fullNameEither first middle last =
… fullName <$> (first `withError` "First name was missing")
… <*> (middle `withError` "Middle name was missing")
… <*> (last `withError` "Last name was missing")
… ^D
またはアプリカティブdoで次のようにします。
> :paste
… fullNameEither first middle last = ado
… f <- first `withError` "First name was missing"
… m <- middle `withError` "Middle name was missing"
… l <- last `withError` "Last name was missing"
… in fullName f m l
… ^D
> :type fullNameEither
Maybe String -> Maybe String -> Maybe String -> Either String String
これでこの関数はMaybe
を使う3つの省略可能な引数を取り、String
のエラー文言かString
の結果のどちらかを返します。
いろいろな入力でこの関数を試してみましょう。
> fullNameEither (Just "Phillip") (Just "A") (Just "Freeman")
(Right "Freeman, Phillip A")
> fullNameEither (Just "Phillip") Nothing (Just "Freeman")
(Left "Middle name was missing")
> fullNameEither (Just "Phillip") (Just "A") Nothing
(Left "Last name was missing")
このとき、全てのフィールドが与えられば成功の結果が表示され、そうでなければ省略されたフィールドのうち最初のものに対応するエラー文言が表示されます。 しかし、もし複数の入力が省略されているとき、最初のエラーしか見られません。
> fullNameEither Nothing Nothing Nothing
(Left "First name was missing")
これでも充分なときもありますが、エラー時に全ての省略されたフィールドの一覧がほしいときは、Either String
よりも強力なものが必要です。この章の後半で解決策を見ていきます。
作用の結合
抽象的にアプリカティブ関手を扱う例として、この節ではアプリカティブ関手f
によって表現された副作用を一般的に組み合わせる関数を書く方法を示します。
これはどういう意味でしょうか。
何らかのa
について型f a
で包まれた引数のリストがあるとしましょう。
それは型List (f a)
のリストがあるということです。
直感的には、これはf
によって追跡される副作用を持つ、返り値の型がa
の計算のリストを表しています。
これらの計算の全てを順番に実行できれば、List a
型の結果のリストを得るでしょう。
しかし、まだf
によって追跡される副作用が残ります。
つまり、元のリストの中の作用を「結合する」ことにより、型List (f a)
の何かを型f (List a)
の何かへと変換できると考えられます。
任意の固定長リストの長さn
について、n
引数からその引数を要素に持つ長さn
のリストを構築する関数が存在します。
例えばもしn
が3
なら、関数は\x y z -> x : y : z : Nil
です。
この関数は型a -> a -> a -> List a
を持ちます。
Applicative
インスタンスを使うと、この関数をf
の上へ持ち上げられ、関数型f a -> f a -> f a -> f (List a)
が得られます。
しかし、いかなるn
についてもこれが可能なので、いかなる引数のリストについても同じように持ち上げられることが確かめられます。
したがって、次のような関数を書くことができるはずです。
combineList :: forall f a. Applicative f => List (f a) -> f (List a)
この関数は副作用を持つかもしれない引数のリストを取り、それぞれの副作用を適用することで、f
に包まれた単一のリストを返します。
この関数を書くためには、引数のリストの長さについて考えます。
リストが空の場合はどんな作用も実行する必要がありませんから、pure
を使用して単に空のリストを返すことができます。
combineList Nil = pure Nil
実際のところ、これが唯一できることです。
入力のリストが空でないならば、型f a
の包まれた引数である先頭要素と、型List (f a)
の尾鰭について考えます。
また、再帰的にリストの残りを結合すると、型f (List a)
の結果が得られます。
それから<$>
と<*>
を使うと、Cons
構築子を先頭と新しい尾鰭の上に持ち上げることができます。
combineList (Cons x xs) = Cons <$> x <*> combineList xs
繰り返しになりますが、これは与えられた型に基づいている唯一の妥当な実装です。
Maybe
型構築子を例にとって、PSCiでこの関数を試してみましょう。
> import Data.List
> import Data.Maybe
> combineList (fromFoldable [Just 1, Just 2, Just 3])
(Just (Cons 1 (Cons 2 (Cons 3 Nil))))
> combineList (fromFoldable [Just 1, Nothing, Just 2])
Nothing
Meybe
へ特殊化すると、リストの全ての要素がJust
であるときに限りこの関数はJust
を返しますし、そうでなければNothing
を返します。
これは省略可能な値に対応する、より大きな言語に取り組む上での直感と一貫しています。
省略可能な結果を生む計算のリストは、全ての計算が結果を持っているならばそれ自身の結果のみを持つのです。
ところがcombineList
関数はどんなApplicative
に対しても機能するのです。
Either err
を使ってエラーを発信する可能性を持たせたり、r ->
を使って大域的な構成を読み取る計算を組み合わせるためにも使えます。
combineList
関数については、後ほどTraversable
関手について考えるときに再訪します。
演習
- (普通)数値演算子
+
、-
、*
、/
の別のバージョンを書いてください。 ただし省略可能な引数(つまりMaybe
に包まれた引数)を扱ってMaybe
に包まれた値を返します。 これらの関数にはaddMaybe
、subMaybe
、mulMaybe
、divMaybe
と名前を付けてください。 手掛かり:lift2
を使ってください。 - (普通)上の演習を(
Maybe
だけでなく)全てのApply
型で動くように拡張してください。 これらの新しい関数にはaddApply
、subApply
、mulApply
、divApply
と名前を付けます。 - (難しい)型
forall a f. Applicative f => Maybe (f a) -> f (Maybe a)
を持つ関数combineMaybe
を書いてください。 この関数は副作用を持つ省略可能な計算を取り、省略可能な結果を持つ副作用のある計算を返します。
アプリカティブによる検証
この章のソースコードでは住所録アプリケーションで使うであろう幾つかのデータ型が定義されています。
詳細はここでは割愛しますが、Data.AddressBook
モジュールからエクスポートされる鍵となる関数は次のような型を持ちます。
address :: String -> String -> String -> Address
phoneNumber :: PhoneType -> String -> PhoneNumber
person :: String -> String -> Address -> Array PhoneNumber -> Person
ここで、PhoneType
は次のような代数的データ型として定義されています。
data PhoneType
= HomePhone
| WorkPhone
| CellPhone
| OtherPhone
これらの関数は住所録の項目を表すPerson
を構築できます。
例えば、Data.AddressBook
では以下の値が定義されています。
examplePerson :: Person
examplePerson =
person "John" "Smith"
(address "123 Fake St." "FakeTown" "CA")
[ phoneNumber HomePhone "555-555-5555"
, phoneNumber CellPhone "555-555-0000"
]
PSCiでこれらの値を試してみましょう(結果は整形されています)。
> import Data.AddressBook
> examplePerson
{ firstName: "John"
, lastName: "Smith"
, homeAddress:
{ street: "123 Fake St."
, city: "FakeTown"
, state: "CA"
}
, phones:
[ { type: HomePhone
, number: "555-555-5555"
}
, { type: CellPhone
, number: "555-555-0000"
}
]
}
前の章では型Person
のデータ構造を検証する上でEither String
関手の使い方を見ました。例えば、データ構造の2つの名前を検証する関数が与えられたとき、データ構造全体を次のように検証できます。
nonEmpty1 :: String -> Either String String
nonEmpty1 "" = Left "Field cannot be empty"
nonEmpty1 value = Right value
validatePerson1 :: Person -> Either String Person
validatePerson1 p =
person <$> nonEmpty1 p.firstName
<*> nonEmpty1 p.lastName
<*> pure p.homeAddress
<*> pure p.phones
またはアプリカティブdoで次のようにします。
validatePerson1Ado :: Person -> Either String Person
validatePerson1Ado p = ado
f <- nonEmpty1 p.firstName
l <- nonEmpty1 p.lastName
in person f l p.homeAddress p.phones
最初の2行ではnonEmpty1
関数を使って空文字列でないことを検証しています。
もし入力が空ならnonEmpty1
はLeft
構築子で示されるエラーを返します。
そうでなければRight
構築子で包まれた値を返します。
最後の2行では何の検証も実行せず、単にaddress
フィールドとphones
フィールドを残りの引数としてperson
関数へと提供しています。
この関数はPSCiでうまく動作するように見えますが、以前見たような制限があります。
> validatePerson $ person "" "" (address "" "" "") []
(Left "Field cannot be empty")
Either String
アプリカティブ関手は最初に遭遇したエラーだけを返します。
仮にこの入力だったとすると、2つのエラーが分かったほうが良いでしょう。
1つは名前の不足で、2つ目は姓の不足です。
validation
ライブラリでは別のアプリカティブ関手も提供されています。
これはV
という名前で、何らかの半群でエラーを返せます。
例えばV (Array String)
を使うと、新しいエラーを配列の最後に連結していき、String
の配列をエラーとして返せます。
Data.Validation
モジュールはData.AddressBook
モジュールのデータ構造を検証するためにV (Array String)
アプリカティブ関手を使っています。
Data.AddressBook.Validation
モジュールから取材した検証器の例は次のようになります。
type Errors
= Array String
nonEmpty :: String -> String -> V Errors String
nonEmpty field "" = invalid [ "Field '" <> field <> "' cannot be empty" ]
nonEmpty _ value = pure value
lengthIs :: String -> Int -> String -> V Errors String
lengthIs field len value | length value /= len =
invalid [ "Field '" <> field <> "' must have length " <> show len ]
lengthIs _ _ value = pure value
validateAddress :: Address -> V Errors Address
validateAddress a =
address <$> nonEmpty "Street" a.street
<*> nonEmpty "City" a.city
<*> lengthIs "State" 2 a.state
またはアプリカティブdoで次のようにします。
validateAddressAdo :: Address -> V Errors Address
validateAddressAdo a = ado
street <- nonEmpty "Street" a.street
city <- nonEmpty "City" a.city
state <- lengthIs "State" 2 a.state
in address street city state
validateAddress
はAddress
の構造を検証します。
street
とcity
が空でないかどうか、state
の文字列の長さが2であるかどうかを検証します。
nonEmpty
とlengthIs
の2つの検証関数が何れも、Data.Validation
モジュールで提供されているinvalid
関数をエラーを示すために使っているところに注目してください。
Array String
半群を扱っているので、invalid
は引数として文字列の配列を取ります。
PSCiでこの関数を試しましょう。
> import Data.AddressBook
> import Data.AddressBook.Validation
> validateAddress $ address "" "" ""
(invalid [ "Field 'Street' cannot be empty"
, "Field 'City' cannot be empty"
, "Field 'State' must have length 2"
])
> validateAddress $ address "" "" "CA"
(invalid [ "Field 'Street' cannot be empty"
, "Field 'City' cannot be empty"
])
これで、全ての検証エラーの配列を受け取ることができるようになりました。
正規表現検証器
validatePhoneNumber
関数では引数の形式を検証するために正規表現を使っています。重要なのはmatches
検証関数で、この関数はData.String.Regex
モジュールで定義されているRegex
を使って入力を検証しています。
matches :: String -> Regex -> String -> V Errors String
matches _ regex value | test regex value
= pure value
matches field _ _ = invalid [ "Field '" <> field <> "' did not match the required format" ]
繰り返しになりますが、pure
は常に成功する検証を表しており、エラーの配列の伝達にはinvalid
が使われています。
これまでと同様に、validatePhoneNumber
はmatches
関数から構築されています。
validatePhoneNumber :: PhoneNumber -> V Errors PhoneNumber
validatePhoneNumber pn =
phoneNumber <$> pure pn."type"
<*> matches "Number" phoneNumberRegex pn.number
またはアプリカティブdoで次のようにします。
validatePhoneNumberAdo :: PhoneNumber -> V Errors PhoneNumber
validatePhoneNumberAdo pn = ado
tpe <- pure pn."type"
number <- matches "Number" phoneNumberRegex pn.number
in phoneNumber tpe number
また、PSCiでいろいろな有効な入力や無効な入力に対して、この検証器を実行してみてください。
> validatePhoneNumber $ phoneNumber HomePhone "555-555-5555"
pure ({ type: HomePhone, number: "555-555-5555" })
> validatePhoneNumber $ phoneNumber HomePhone "555.555.5555"
invalid (["Field 'Number' did not match the required format"])
演習
- (簡単)正規表現
stateRegex :: Regex
を書いて文字列が2文字のアルファベットであることを確かめてください。 手掛かり:phoneNumberRegex
のソースコードを参照してみましょう。 - (普通)文字列全体が空白でないことを検査する正規表現
nonEmptyRegex :: Regex
を書いてください。 手掛かり:この正規表現を開発するのに手助けが必要なら、RegExrをご確認ください。 素晴しい早見表と対話的なお試し環境があります。 - (普通)
validateAddress
に似ていますが、上のstateRegex
を使ってstate
フィールドを検証し、nonEmptyRegex
を使ってstreet
とcity
フィールドを検証する関数validateAddressImproved
を書いてください。 手掛かり:matches
の用例についてはvalidatePhoneNumber
のソースを見てください。
巡回可能関手
残った検証器はvalidatePerson
です。
これはこれまで見てきた検証器と以下の新しいvalidatePhoneNumbers
関数を組み合わせてPerson
全体を検証するものです。
validatePhoneNumbers :: String -> Array PhoneNumber -> V Errors (Array PhoneNumber)
validatePhoneNumbers field [] =
invalid [ "Field '" <> field <> "' must contain at least one value" ]
validatePhoneNumbers _ phones =
traverse validatePhoneNumber phones
validatePerson :: Person -> V Errors Person
validatePerson p =
person <$> nonEmpty "First Name" p.firstName
<*> nonEmpty "Last Name" p.lastName
<*> validateAddress p.homeAddress
<*> validatePhoneNumbers "Phone Numbers" p.phones
またはアプリカティブdoで次のようにします。
validatePersonAdo :: Person -> V Errors Person
validatePersonAdo p = ado
firstName <- nonEmpty "First Name" p.firstName
lastName <- nonEmpty "Last Name" p.lastName
address <- validateAddress p.homeAddress
numbers <- validatePhoneNumbers "Phone Numbers" p.phones
in person firstName lastName address numbers
validatePhoneNumbers
はこれまでに見たことのない新しい関数であるtraverse
を使っています。
traverse
はData.Traversable
モジュールのTraversable
型クラスで定義されています。
class (Functor t, Foldable t) <= Traversable t where
traverse :: forall a b m. Applicative m => (a -> m b) -> t a -> m (t b)
sequence :: forall a m. Applicative m => t (m a) -> m (t a)
Traversable
は 巡回可能関手
の型クラスを定義します。これらの関数の型は少し難しそうに見えるかもしれませんが、validatePerson
は良いきっかけとなる例です。
全ての巡回可能関手はFunctor
とFoldable
のどちらでもあります(畳み込み可能関手は畳み込み操作に対応する型構築子であったことを思い出してください。
畳み込みとは構造を1つの値へと簡約するものでした)。
それに加えて、巡回可能関手はその構造に依存した副作用の集まりを組み合わせられます。
複雑そうに聞こえるかもしれませんが、配列の場合に特殊化して簡単にした上で考えてみましょう。配列型構築子はTraversable
であり、つまりは次のような関数が存在するということです。
traverse :: forall a b m. Applicative m => (a -> m b) -> Array a -> m (Array b)
直感的にはこうです。
任意のアプリカティブ関手m
と、型a
の値を取って型b
の値を返す(f
で追跡される副作用を持つ)関数が与えられたとします。
このとき、その関数を型Array a
の配列のそれぞれの要素に適用して型Array b
の(f
で追跡される副作用を持つ)結果を得ることができます。
まだよくわからないでしょうか。それでは更に、f
を上記のV Errors
アプリカティブ関手に特殊化して考えてみましょう。これで次の型を持つ関数が得られます。
traverse :: forall a b. (a -> V Errors b) -> Array a -> V Errors (Array b)
この型シグネチャでは、型a
についての検証関数m
があれば、traverse m
は型Array a
の配列についての検証関数であると書かれています。
ところがこれは正にPerson
データ構造体のphones
フィールドを検証できるようにするのに必要なものです。
各要素が成功するかを検証する検証関数を作るために、validatePhoneNumber
をtraverse
へ渡しています。
一般に、traverse
はデータ構造の要素を1つずつ辿っていき、副作用を伴いつつ計算し、結果を累算します。
Traversable
のもう1つの関数、sequence
の型シグネチャには見覚えがあるかもしれません。
sequence :: forall a m. Applicative m => t (m a) -> m (t a)
実際、先ほど書いたcombineList
関数はTraversable
型クラスのsequence
関数の特別な場合に過ぎません。
t
を型構築子List
だとすると、combineList
関数の型が復元されます。
combineList :: forall f a. Applicative f => List (f a) -> f (List a)
巡回可能関手はデータ構造走査の考え方を見据えたものです。
これにより作用のある計算の集合を集めてその作用を結合します。
実際、sequence
とtraversable
はTraversable
を定義する上でどちらも同じくらい重要です。
これらはお互いがお互いを利用して実装できます。
これについては興味ある読者への演習として残しておきます。
Data.List
で与えられているリストのTraversable
インスタンスは次の通り。
instance Traversable List where
-- traverse :: forall a b m. Applicative m => (a -> m b) -> List a -> m (List b)
traverse _ Nil = pure Nil
traverse f (Cons x xs) = Cons <$> f x <*> traverse f xs
(実際の定義は後にスタック安全性を向上するために変更されました。その変更についてより詳しくはこちらで読むことができます)
入力が空のリストのときには、pure
を使って空のリストを返せます。
リストが空でないときは、関数f
を使うと先頭の要素から型f b
の計算を作成できます。
また、尾鰭に対してtraverse
を再帰的に呼び出せます。
最後に、アプリカティブ関手m
までCons
構築子を持ち上げて、2つの結果を組み合わせられます。
巡回可能関手の例はただの配列やリスト以外にもあります。
以前に見たMaybe
型構築子もTraversable
のインスタンスを持っています。
PSCiで試してみましょう。
> import Data.Maybe
> import Data.Traversable
> import Data.AddressBook.Validation
> traverse (nonEmpty "Example") Nothing
pure (Nothing)
> traverse (nonEmpty "Example") (Just "")
invalid (["Field 'Example' cannot be empty"])
> traverse (nonEmpty "Example") (Just "Testing")
pure ((Just "Testing"))
これらの例では、Nothing
の値の走査は検証なしでNothing
の値を返し、Just x
を走査するとx
を検証するのに検証関数が使われるということを示しています。
要は、traverse
は型a
についての検証関数を取り、Maybe a
についての検証関数、つまり型a
の省略可能な値についての検証関数を返すのです。
他の巡回可能関手には、任意の型a
についてのArray a
、Tuple a
、Either a
が含まれます。
一般に、「容器」のようなほとんどのデータ型構築子はTraversable
インスタンスを持っています。
一例として、演習には二分木の型のTraversable
インスタンスを書くことが含まれます。
演習
-
(簡単)
Eq
とShow
インスタンスを以下の2分木データ構造に対して書いてください。data Tree a = Leaf | Branch (Tree a) a (Tree a)
これらのインスタンスを手作業で書くこともできますし、コンパイラに導出してもらうこともできることを前の章から思い起こしてください。
Show
の出力には多くの「正しい」書式の選択肢があります。 この演習のテストでは以下の空白スタイルを期待しています。 これは一般化されたshowの既定の書式と合致しているため、このインスタンスを手作業で書くつもりのときだけ、このことを念頭に置いておいてください。(Branch (Branch Leaf 8 Leaf) 42 Leaf)
-
(普通)
Traversable
インスタンスをTree a
に対して書いてください。 これは副作用を左から右に結合するものです。 手掛かり:Traversable
に定義する必要のある追加のインスタンス依存関係が幾つかあります。 -
(普通)行き掛け順に木を巡回する関数
traversePreOrder :: forall a m b. Applicative m => (a -> m b) -> Tree a -> m (Tree b)
を書いてください。 つまり作用の実行は根左右と行われ、以前の通り掛け順の巡回の演習でしたような左根右ではありません。 手掛かり:追加でインスタンスを定義する必要はありませんし、前に定義した関数は何も呼ぶ必要はありません。 アプリカティブdo記法 (ado
) はこの関数を書く最も簡単な方法です。 -
(普通)木を帰り掛け順に巡回する関数
traversePostOrder
を書いてください。作用は左右根と実行されます。 -
(普通)
homeAddress
フィールドが省略可能(Maybe
を使用)な新しい版のPerson
型をつくってください。 それからこの新しいPerson
を検証する新しい版のvalidatePerson
(validatePersonOptionalAddress
と改名します)を書いてください。 手掛かり:traverse
を使って型Maybe a
のフィールドを検証してください。 -
(難しい)
sequence
のように振る舞う関数sequenceUsingTraverse
を書いてください。 ただしtraverse
を使ってください。 -
(難しい)
traverse
のように振る舞う関数traverseUsingSequence
を書いてください。 ただしsequence
を使ってください。
アプリカティブ関手による並列処理
これまでの議論では、アプリカティブ関手がどのように「副作用を結合」させるかを説明するときに、「結合」(combine) という単語を選びました。
しかし、これらの全ての例において、アプリカティブ関手は作用を「連鎖」(sequence) させる、というように言っても同じく妥当です。
巡回可能関手がデータ構造に従って作用を順番に結合させるsequence
関数を提供していることと、この直感的理解とは一致するでしょう。
しかし一般には、アプリカティブ関手はこれよりももっと一般的です。 アプリカティブ関手の規則は、その計算の副作用にどんな順序付けも強制しません。 実際、並列に副作用を実行するアプリカティブ関手は妥当でしょう。
例えばV
検証関手はエラーの配列を返しますが、その代わりにSet
半群を選んだとしてもやはり正常に動き、このときどんな順序で各検証器を実行しても問題はありません。
データ構造に対して並列にこれの実行さえできるのです。
2つ目の例として、parallel
パッケージは並列計算に対応するParallel
型クラスを提供します。
Parallel
は関数parallel
を提供しており、何らかのApplicative
関手を使って入力の計算の結果を並列に計算します。
f <$> parallel computation1
<*> parallel computation2
この計算はcomputation1
とcomputation2
を非同期に使って値の計算を始めるでしょう。そして両方の結果の計算が終わった時に、関数f
を使って1つの結果へと結合するでしょう。
この考え方の詳細は、本書の後半で コールバック地獄 の問題に対してアプリカティブ関手を応用するときに見ていきます。
アプリカティブ関手は並列に結合できる副作用を捉える自然な方法です。
まとめ
この章では新しい考え方を沢山扱いました。
- アプリカティブ関手の概念を導入しました。 これは、関数適用の概念から副作用の観念を捉えた型構築子へと一般化するものです。
- データ構造の検証という課題をアプリカティブ関手やその切り替えで解く方法を見てきました。 単一のエラーの報告からデータ構造を横断する全てのエラーの報告へ変更できました。
Traversable
型クラスに出会いました。巡回可能関手の考え方を内包するものであり、要素が副作用を持つ値の結合に使うことができる容れ物でした。
アプリカティブ関手は多くの問題に対して優れた解決策を与える興味深い抽象化です。 本書を通じて何度も見ることになるでしょう。 今回の場合、アプリカティブ関手は宣言的な流儀で書く手段を提供していましたが、これにより検証器がどうやって検証を実施するかではなく、何を検証すべきなのかを定義できました。 一般にアプリカティブ関手が領域特化言語を設計する上で便利な道具になることを見ていきます。
次の章では、これに関連する考え方であるモナドクラスを見て、住所録の例をブラウザで実行させられるように拡張しましょう。
作用モナド
この章の目標
前章では、副作用を扱うのに使う抽象化であるアプリカティブ関手を導入しました。 副作用とは省略可能な値、エラー文言、検証などです。 この章では、副作用を扱うためのより表現力の高い別の抽象化であるモナドを導入します。
この章の目的は、なぜモナドが便利な抽象化なのかということと、do記法との関係を説明することです。
プロジェクトの準備
このプロジェクトでは、以下の依存関係が追加されています。
effect
: 章の後半の主題であるEffect
モナドを定義しています。 この依存関係は全てのプロジェクトで始めから入っているものなので(これまでの全ての章でも依存関係にありました)、明示的にインストールしなければいけないことは稀です。react-basic-hooks
: 住所録アプリに使うwebフレームワークです。
モナドとdo記法
do記法は配列内包表記を扱うときに初めて導入されました。
配列内包表記はData.Array
モジュールのconcatMap
関数の構文糖として提供されています。
次の例を考えてみましょう。2つのサイコロを振って出た目を数え、出た目の合計が
n
のときそれを得点とすることを考えます。次のような非決定的なアルゴリズムを使うとこれを実現できます。
- 最初の投擲で値
x
を 選択 します。 - 2回目の投擲で値
y
を 選択 します。 - もし
x
とy
の和がn
なら組[x, y]
を返し、そうでなければ失敗します。
配列内包表記を使うと、この非決定的アルゴリズムを自然に書けます。
import Prelude
import Control.Plus (empty)
import Data.Array ((..))
countThrows :: Int -> Array (Array Int)
countThrows n = do
x <- 1 .. 6
y <- 1 .. 6
if x + y == n
then pure [ x, y ]
else empty
PSCiでこの関数の動作を見てみましょう。
> import Test.Examples
> countThrows 10
[[4,6],[5,5],[6,4]]
> countThrows 12
[[6,6]]
前の章では、省略可能な値に対応したより大きなプログラミング言語へとPureScriptの関数を埋め込む、Maybe
アプリカティブ関手についての直感的理解を養いました。
同様に配列モナドについても、非決定選択に対応したより大きなプログラミング言語へPureScriptの関数を埋め込む、というような直感的理解を得ることができます。
一般に、ある型構築子m
のモナドは、型m a
の値を持つdo記法を使う手段を提供します。
上の配列内包表記に注意すると、何らかの型a
について全行に型Array a
の計算が含まれています。
一般に、do記法ブロックの全行は、何らかの型a
とモナドm
について、型m a
の計算を含みます。
モナドm
は全行で同じでなければなりません(つまり副作用は固定)が、型a
は異なることもあります(つまり個々の計算は異なる型の結果にできる)。
以下はdo記法の別の例です。
今回は型構築子 Maybe
に適用されています。
XMLノードを表す型 XML
と次の関数があるとします。
child :: XML -> String -> Maybe XML
この関数はノードの子の要素を探し、もしそのような要素が存在しなければ Nothing
を返します。
この場合、do記法を使うと深い入れ子になった要素を検索できます。 XML文書としてエンコードされた利用者情報から、利用者の住んでいる市町村を読み取りたいとします。
userCity :: XML -> Maybe XML
userCity root = do
prof <- child root "profile"
addr <- child prof "address"
city <- child addr "city"
pure city
userCity
関数は子のprofile
要素、profile
要素の中にあるaddress
要素、最後にaddress
要素の中にあるcity
要素を探します。
これらの要素の何れかが欠落している場合、返り値はNothing
になります。
そうでなければ、返り値はcity
ノードからJust
を使って構築されます。
最後の行にあるpure
関数は、全てのApplicative
関手について定義されているのでした。
Maybe
のApplicative
関手のpure
関数はJust
として定義されており、最後の行を Just city
へ変更しても同じように正しく動きます。
モナド型クラス
Monad
型クラスは次のように定義されています。
class Apply m <= Bind m where
bind :: forall a b. m a -> (a -> m b) -> m b
class (Applicative m, Bind m) <= Monad m
ここで鍵となる関数は Bind
型クラスで定義されている演算子 bind
で、Functor
及び Apply
型クラスにある <$>
や <*>
などの演算子と同様に、Prelude
では >>=
として bind
の中置の別名が定義されています。
Monad
型クラスは、既に見てきたApplicative
型クラスの操作でBind
を拡張します。
Bind
型クラスの例を幾つか見てみるのがわかりやすいでしょう。
配列についての Bind
の妥当な定義は次のようになります。
instance Bind Array where
bind xs f = concatMap f xs
これは以前に仄めかした、配列内包表記と concatMap
関数の関係を説明しています。
Maybe
型構築子についての Bind
の実装は次のようになります。
instance Bind Maybe where
bind Nothing _ = Nothing
bind (Just a) f = f a
この定義は欠落した値がdo記法ブロックを通じて伝播するという直感的理解を裏付けるものです。
Bind
型クラスとdo記法がどのように関係しているかを見て行きましょう。
最初に、何らかの計算結果からの値の束縛から始まる、単純なdo記法ブロックについて考えてみましょう。
do value <- someComputation
whatToDoNext
PureScriptコンパイラはこのようなパターンを見つけるたびにコードを次にように置き換えます。
bind someComputation \value -> whatToDoNext
あるいは中置で書くと以下です。
someComputation >>= \value -> whatToDoNext
この計算 whatToDoNext
は value
に依存できます。
複数の束縛が関係している場合、この規則は先頭のほうから複数回適用されます。例えば、先ほど見た userCity
の例では次のように脱糖されます。
userCity :: XML -> Maybe XML
userCity root =
child root "profile" >>= \prof ->
child prof "address" >>= \addr ->
child addr "city" >>= \city ->
pure city
do記法を使って表現されたコードは、>>=
演算子を使う等価なコードより遥かに読みやすくなることがよくあることも特筆すべき点です。
しかしながら、明示的に>>=
を使って束縛を書くと、ポイントフリー形式でコードが書けるようになることがよくあります。
ただし、読みやすさにはやはり注意が要ります。
モナド則
Monad
型クラスはモナド則と呼ばれる3つの規則を持っています。これらは
Monad
型クラスの合理的な実装から何を期待できるかを教えてくれます。
do記法を使用してこれらの規則を説明していくのが最も簡単でしょう。
単位元律
右単位元則 (right-identity law)
が3つの規則の中で最も簡単です。この規則はdo記法ブロックの最後の式であれば、pure
の呼び出しを排除できると言っています。
do
x <- expr
pure x
右単位元則は、この式は単なる expr
と同じだと言っています。
左単位元則 (left-identity law)
は、もしそれがdo記法ブロックの最初の式であれば、pure
の呼び出しを除去できると述べています。
do
x <- pure y
next
このコードはnext
の名前x
を式y
で置き換えたものと同じです。
最後の規則は 結合則 (associativity law) です。これは入れ子になったdo記法ブロックをどう扱うのかについて教えてくれます。この規則が述べているのは以下のコード片のことです。
c1 = do
y <- do
x <- m1
m2
m3
上記のコード片は、次のコードと同じです。
c2 = do
x <- m1
y <- m2
m3
これらの各計算には3つのモナドの式m1
、m2
、m3
が含まれています。
どちらの場合でもm1
の結果は結局は名前x
に束縛され、m2
の結果は名前y
に束縛されます。
c1
では2つの式m1
とm2
が各do記法ブロック内にグループ化されています。
c2
ではm1
、m2
、m3
の3つ全ての式が同じdo記法ブロックに現れています。
結合法則は入れ子になったdo記法ブロックをこのように単純化しても問題ないことを言っています。
補足:do記法をbind
の呼び出しへと脱糖する定義により、 c1
と c2
は何れも次のコードと同じです。
c3 = do
x <- m1
do
y <- m2
m3
モナドで畳み込む
抽象的にモナドを扱う例として、この節では Monad
型クラス中の任意の型構築子で機能する関数を紹介していきます。
これはモナドによるコードが副作用を伴う「より大きな言語」でのプログラミングと対応しているという直感的理解を補強しますし、モナドによるプログラミングが齎す一般性も示しています。
これから書いていく関数はfoldM
という名前です。
以前見たfoldl
関数をモナドの文脈へと一般化するものです。
型シグネチャは以下です。
foldM :: forall m a b. Monad m => (a -> b -> m a) -> a -> List b -> m a
foldl :: forall a b. (a -> b -> a) -> a -> List b -> a
モナド m
が現れている点を除いて、 foldl
の型と同じであることに注意しましょう。
直感的には、foldM
は様々な副作用の組み合わせに対応した文脈で配列を畳み込むものと捉えられます。
例としてm
としてMaybe
を選ぶとすると、各段階でNothing
を返すことでこの畳み込みを失敗させられます。
各段階では省略可能な結果を返しますから、それ故畳み込みの結果も省略可能になります。
もしm
として型構築子Array
を選ぶとすると、畳み込みの各段階で0以上の結果を返せるため、畳み込みは各結果に対して独立に次の手順を継続します。
最後に、結果の集まりは可能な経路の全ての畳み込みから構成されることになります。
これはグラフの走査と対応していますね。
foldM
を書くには、単に入力のリストについて場合分けをするだけです。
リストが空なら、型 a
の結果を生成するための選択肢は1つしかありません。第2引数を返します。
foldM _ a Nil = pure a
なお、a
をモナド m
まで持ち上げるために pure
を使わなくてはいけません。
リストが空でない場合はどうでしょうか。
その場合、型 a
の値、型 b
の値、型 a -> b -> m a
の関数があります。
もしこの関数を適用すると、型 m a
のモナドの結果を手に入れることになります。
この計算の結果を逆向きの矢印 <-
で束縛できます。
あとはリストの残りに対して再帰するだけです。実装は簡単です。
foldM f a (b : bs) = do
a' <- f a b
foldM f a' bs
なお、この実装はリストに対するfoldl
の実装とほとんど同じです。
ただしdo記法である点を除きます。
PSCiでこの関数を定義して試せます。
以下は一例です。
整数の「安全な除算」関数を定義するとします。
0による除算かを確認し、失敗を示すために Maybe
型構築子を使うのです。
safeDivide :: Int -> Int -> Maybe Int
safeDivide _ 0 = Nothing
safeDivide a b = Just (a / b)
これで、 foldM
で安全な除算の繰り返しを表現できます。
> import Test.Examples
> import Data.List (fromFoldable)
> foldM safeDivide 100 (fromFoldable [5, 2, 2])
(Just 5)
> foldM safeDivide 100 (fromFoldable [2, 0, 4])
Nothing
もし何れかの時点で0による除算が試みられたら、foldM safeDivide
関数はNothing
を返します。
そうでなければ、累算値を繰り返し除算した結果をJust
構築子に包んで返します。
モナドとアプリカティブ
クラス間に上位クラス関係の効能があるため、Monad
型クラスの全てのインスタンスは Apply
型クラスのインスタンスでもあります。
しかし、あらゆるMonad
のインスタンスに「無料で」ついてくるApply
型クラスの実装もあります。これはap
関数により与えられます。
ap :: forall m a b. Monad m => m (a -> b) -> m a -> m b
ap mf ma = do
f <- mf
a <- ma
pure (f a)
もしm
にMonad
型クラスの法則の縛りがあれば、ap
で与えられるm
について妥当な Apply
インスタンスが存在します。
興味のある読者はこれまで登場したモナドについてこのap
がapply
として充足することを確かめてみてください。
モナドはArray
、Maybe
、Either e
といったものです。
もし全てのモナドがアプリカティブ関手でもあるなら、アプリカティブ関手についての直感的理解を全てのモナドについても適用できるはずです。
特に、モナドが更なる副作用の組み合わせで増強された「より大きな言語」でのプログラミングといろいろな意味で一致することを予想するのはもっともです。
map
と apply
を使って、引数が任意個の関数をこの新しい言語へと持ち上げることができるはずです。
しかし、モナドはアプリカティブ関手でできること以上ができ、重要な違いはdo記法の構文で強調されています。
userCity
の例についてもう一度考えてみましょう。
利用者情報をエンコードしたXML文書から利用者の市町村を検索するものでした。
userCity :: XML -> Maybe XML
userCity root = do
prof <- child root "profile"
addr <- child prof "address"
city <- child addr "city"
pure city
do記法では2番目の計算が最初の結果 prof
に依存し、3番目の計算が2番目の計算の結果addr
に依存するというようなことができます。
Applicative
型クラスのインターフェイスだけを使うのでは、このように以前の値へ依存できません。
pure
と apply
だけを使って userCity
を書こうとしてみれば、これが不可能であることがわかるでしょう。
アプリカティブ関手ができるのは関数の互いに独立した引数を持ち上げることだけですが、モナドはもっと興味深いデータの依存関係に関わる計算を書くことを可能にします。
前の章ではApplicative
型クラスは並列処理を表現できることを見ました。
持ち上げられた関数の引数は互いに独立していますから、これはまさにその通りです。
Monad
型クラスは計算が前の計算の結果に依存できるようになっており、同じようにはなりません。
つまりモナドは副作用を順番に組み合わせなければならないのです。
演習
-
(簡単)3つ以上の要素がある配列の3つ目の要素を返す関数
third
を書いてください。 関数は適切なMaybe
型で返します。 手掛かり:arrays
パッケージのData.Array
モジュールからhead
とtail
関数の型を見つけ出してください。 これらの関数を組み合わせるにはMaybe
モナドと共にdo記法を使ってください。 -
(普通)一掴みの硬貨を使ってできる可能な全ての合計を決定する関数
possibleSums
を、foldM
を使って書いてみましょう。 入力の硬貨は、硬貨の価値の配列として与えられます。この関数は次のような結果にならなくてはいけません。> possibleSums [] [0] > possibleSums [1, 2, 10] [0,1,2,3,10,11,12,13]
手掛かり:
foldM
を使うと1行でこの関数を書けます。 重複を取り除いたり、結果を並び替えたりするのに、nub
関数やsort
関数を使うことでしょう。 -
(普通)
ap
関数とapply
演算子がMaybe
モナドを充足することを確かめてください。 補足:この演習にはテストがありません。 -
(普通)
Maybe
型についてのMonad
インスタンスが、モナド則を満たしていることを検証してください。 このインスタンスはmaybe
パッケージで定義されています。 補足:この演習にはテストがありません。 -
(普通)リスト上の
filter
の関数を一般化した関数filterM
を書いてください。 この関数は次の型シグネチャを持ちます。filterM :: forall m a. Monad m => (a -> m Boolean) -> List a -> m (List a)
-
(難しい)全てのモナドには次で与えられるような既定の
Functor
インスタンスがあります。map f a = do x <- a pure (f x)
モナド則を使って、全てのモナドが次を満たすことを証明してください。
lift2 f (pure a) (pure b) = pure (f a b)
ここで、
Applly
インスタンスは上で定義されたap
関数を使用しています。lift2
が次のように定義されていたことを思い出してください。lift2 :: forall f a b c. Apply f => (a -> b -> c) -> f a -> f b -> f c lift2 f a b = f <$> a <*> b
補足:この演習にはテストがありません。
ネイティブな作用
ここではPureScriptで中心的な重要性のあるモナドの1つ、Effect
モナドについて見ていきます。
Effect
モナドは Effect
モジュールで定義されています。かつてはいわゆる ネイティブ
副作用を管理していました。Haskellに馴染みがあれば、これはIO
モナドと同等のものです。
ネイティブな副作用とは何でしょうか。 この副作用はPureScript特有の式とJavaScriptの式とを2分するものです。 PureScriptの式は概して副作用とは無縁なのです。 ネイティブな作用の例を以下に示します。
- コンソール入出力
- 乱数生成
- 例外
- 変更可能な状態の読み書き
また、ブラウザでは次のようなものがあります。
- DOM操作
- XMLHttpRequest / AJAX呼び出し
- WebSocketによる相互作用
- Local Storageの読み書き
既に「ネイティブでない」副作用の例については数多く見てきています。
Maybe
データ型で表現される省略可能な値Either
データ型で表現されるエラー- 配列やリストで表現される多値関数
これらの区別はわかりにくいので注意してください。
例えば、エラー文言は例外の形でJavaScriptの式の副作用となることがあると言えます。
その意味では例外はネイティブな副作用を表していて、Effect
を使用して表現できます。
しかし、Either
を使用して実装されたエラー文言はJavaScript実行時の副作用ではなく、Effect
を使うスタイルでエラー文言を実装するのは不適切です。
そのため、ネイティブなのは作用自体というより、実行時にどのように実装されているかです。
副作用と純粋性
PureScriptのような純粋な言語では、ある疑問が浮かんできます。 副作用がないなら、どうやって役に立つ実際のコードを書くことができるのでしょうか。
その答えはPureScriptの目的は副作用を排除することではないということです。 純粋な計算と副作用のある計算とを、型システムにおいて区別できるような方法で表現します。 この意味で、言語はあくまで純粋なのです。
副作用のある値は、純粋な値とは異なる型を持っています。 そういうわけで、例えば副作用のある引数を関数に渡すことはできず、予期せず副作用を持つようなことが起こらなくなります。
Effect
モナドで管理された副作用を現す手段は、型Effect a
の計算をJavaScriptから実行することです。
Spagoビルドツール(や他のツール)は早道を用意しており、アプリケーションの起動時にmain
計算を呼び出すための追加のJavaScriptコードを生成します。
main
はEffect
モナドでの計算であることが要求されます。
作用モナド
Effect
は副作用のある計算を充分に型付けするAPIを提供すると同時に、効率的なJavaScriptを生成します。
馴染みのあるlog
関数から返る型を見てみましょう。
Effect
はこの関数がネイティブな作用を生み出すことを示しており、この場合はコンソールIOです。
Unit
はいかなる意味のあるデータも返らないことを示しています。
Unit
はC、Javaなど他の言語でのvoid
キーワードと似たものとして考えられます。
log :: String -> Effect Unit
余談 :より一般的な(そしてより込み入った型を持つ)
Effect.Class.Console
のlog
関数をIDEから提案されるかもしれません。 これは基本的なEffect
モナドを扱う際はEffect.Console
からの関数と交換可能です。 より一般的なバージョンがあることの理由は「モナドな冒険」章の「モナド変換子」について読んだあとにより明らかになっていることでしょう。 好奇心のある(そしてせっかちな)読者のために言うと、これはEffect
にMonadEffect
インスタンスがあるから機能するのです。log :: forall m. MonadEffect m => String -> m Unit
それでは意味のあるデータを返すEffect
を考えましょう。
Effect.Random
のrandom
関数は乱択されたNumber
を生み出します。
random :: Effect Number
以下は完全なプログラムの例です(この章の演習フォルダのtest/Random.purs
にあります)。
module Test.Random where
import Prelude
import Effect (Effect)
import Effect.Random (random)
import Effect.Console (logShow)
main :: Effect Unit
main = do
n <- random
logShow n
Effect
はモナドなので、do記法を使って含まれるデータを開封し、それからこのデータを作用のあるlogShow
関数に渡します。
気分転換に、以下はbind
演算子を使って書かれた同等なコードです。
main :: Effect Unit
main = random >>= logShow
これを手元で走らせてみてください。
spago run --main Test.Random
コンソールに出力 0.0
と 1.0
の間で無作為に選ばれた数が表示されるでしょう。
余談:
spago run
は既定でmain
関数をMain
モジュールの中から探索します。--main
フラグで代替のモジュールを入口として指定することも可能で、上の例ではそうしています。 この代替のモジュールにもmain
関数が含まれているようにはしてください。
なお、不浄な作用付きのコードに訴えることなく、「乱択された」(技術的には疑似乱択された)データも生成できます。 この技法は「テストを生成する」章で押さえます。
以前言及したようにEffect
モナドはPureScriptで核心的な重要さがあります。
なぜ核心かというと、それはPureScriptの外部関数インターフェース
とやり取りする上での常套手段だからです。
外部関数インターフェース
はプログラムを実行したり副作用を発生させたりする仕組みを提供します。
外部関数インターフェース
を使うことは避けるのが望ましいのですが、どのように動作しどう使うのか理解することもまた極めて大事なことですので、実際にPureScriptで何か動かす前にその章を読まれることをお勧めします。
要はEffect
モナドは結構単純なのです。
幾つかの補助関数がありますが、副作用を内包すること以外には大したことはしません。
例外
2つのネイティブな副作用が絡むnode-fs
パッケージの関数を調べましょう。
ここでの副作用は可変状態の読み取りと例外です。
readTextFile :: Encoding -> String -> Effect String
もし存在しないファイルを読もうとすると……
import Node.Encoding (Encoding(..))
import Node.FS.Sync (readTextFile)
main :: Effect Unit
main = do
lines <- readTextFile UTF8 "iDoNotExist.md"
log lines
以下の例外に遭遇します。
throw err;
^
Error: ENOENT: no such file or directory, open 'iDoNotExist.md'
...
errno: -2,
syscall: 'open',
code: 'ENOENT',
path: 'iDoNotExist.md'
この例外をうまく管理するには、潜在的に問題があるコードをtry
に包めばどのような出力でも制御できます。
main :: Effect Unit
main = do
result <- try $ readTextFile UTF8 "iDoNotExist.md"
case result of
Right lines -> log $ "Contents: \n" <> lines
Left error -> log $ "Couldn't open file. Error was: " <> message error
try
はEffect
を走らせて起こりうる例外をLeft
値として返します。
もし計算が成功すれば結果はRight
に包まれます。
try :: forall a. Effect a -> Effect (Either Error a)
独自の例外も生成できます。
以下はData.List.head
の代替実装で、Maybe
の値のNothing
を返す代わりにリストが空のとき例外を投げます。
exceptionHead :: List Int -> Effect Int
exceptionHead l = case l of
x : _ -> pure x
Nil -> throwException $ error "empty list"
ただしexceptionHead
関数はどこかしら非実用的な例です。
というのも、PureScriptのコードで例外を生成するのは避け、代わりにEither
やMaybe
のようなネイティブでない作用でエラーや欠けた値を使うのが一番だからです。
可変状態
中核ライブラリには ST
作用という、これまた別の作用も定義されています。
ST
作用は変更可能な状態を操作するために使われます。
純粋関数プログラミングを知っているなら、共有される変更可能な状態は問題を引き起こしやすいということも知っているでしょう。
しかし、ST
作用は型システムを使って安全で局所的な状態変化を可能にし、状態の共有を制限するのです。
ST
作用は Control.Monad.ST
モジュールで定義されています。
この挙動を確認するには、その動作の型を見る必要があります。
new :: forall a r. a -> ST r (STRef r a)
read :: forall a r. STRef r a -> ST r a
write :: forall a r. a -> STRef r a -> ST r a
modify :: forall r a. (a -> a) -> STRef r a -> ST r a
new
は型STRef r a
の可変参照領域を新規作成するのに使われます。
この領域はread
動作を使って読み取ったり、write
動作やmodify
動作で状態を変更するのに使えます。
型a
は領域に格納された値の型を、型r
はメモリ領域(またはヒープ)を、それぞれ型システムで表しています。
例を示します。 重力に従って落下する粒子の落下の動きをシミュレートしたいとしましょう。 これには小さな時間刻みで簡単な更新関数の実行を何度も繰り返します。
粒子の位置と速度を保持する変更可能な参照領域を作成し、領域に格納された値を更新するのにforループを使うことでこれを実現できます。
import Prelude
import Control.Monad.ST.Ref (modify, new, read)
import Control.Monad.ST (ST, for, run)
simulate :: forall r. Number -> Number -> Int -> ST r Number
simulate x0 v0 time = do
ref <- new { x: x0, v: v0 }
for 0 (time * 1000) \_ ->
modify
( \o ->
{ v: o.v - 9.81 * 0.001
, x: o.x + o.v * 0.001
}
)
ref
final <- read ref
pure final.x
計算の最後では、参照領域の最終的な値を読み取り、粒子の位置を返しています。
なお、この関数が変更可能な状態を使っていても、その参照領域ref
がプログラムの他の部分での使用が許されない限り、これは純粋な関数のままです。
このことが正にST
作用が禁止するものであることを見ていきます。
ST
作用付きで計算するには、run
関数を使用する必要があります。
run :: forall a. (forall r. ST r a) -> a
ここで注目して欲しいのは、領域型 r
が関数矢印の左辺にある括弧の内側で量化されているということです。
run
に渡したどんな動作でも、任意の領域r
が何であれ動作するということを意味しています。
しかし、ひとたび参照領域がnew
によって作成されると、その領域の型は既に固定されており、run
によって限定されたコードの外側で参照領域を使おうとしても型エラーになるでしょう。
run
が安全にST
作用を除去でき、simulate
を純粋関数にできるのはこれが理由なのです。
simulate' :: Number -> Number -> Int -> Number
simulate' x0 v0 time = run (simulate x0 v0 time)
PSCiでもこの関数を実行してみることができます。
> import Main
> simulate' 100.0 0.0 0
100.00
> simulate' 100.0 0.0 1
95.10
> simulate' 100.0 0.0 2
80.39
> simulate' 100.0 0.0 3
55.87
> simulate' 100.0 0.0 4
21.54
実は、もし simulate
の定義を run
の呼び出しのところへ埋め込むとすると、次のようになります。
simulate :: Number -> Number -> Int -> Number
simulate x0 v0 time =
run do
ref <- new { x: x0, v: v0 }
for 0 (time * 1000) \_ ->
modify
( \o ->
{ v: o.v - 9.81 * 0.001
, x: o.x + o.v * 0.001
}
)
ref
final <- read ref
pure final.x
そうして、参照領域はそのスコープから逃れられないことと、安全にref
をvar
に変換できることにコンパイラが気付きます。
run
が埋め込まれたsimulate
に対して生成されたJavaScriptは次のようになります。
var simulate = function (x0) {
return function (v0) {
return function (time) {
return (function __do() {
var ref = { value: { x: x0, v: v0 } };
Control_Monad_ST_Internal["for"](0)(time * 1000 | 0)(function (v) {
return Control_Monad_ST_Internal.modify(function (o) {
return {
v: o.v - 9.81 * 1.0e-3,
x: o.x + o.v * 1.0e-3
};
})(ref);
})();
return ref.value.x;
})();
};
};
};
なお、この結果として得られたJavaScriptは最適化の余地があります。 詳細はこちらの課題を参照してください。 上記の抜粋はそちらの課題が解決されたら更新されるでしょう。
比較としてこちらが埋め込まれていない形式で生成されたJavaScriptです。
var simulate = function (x0) {
return function (v0) {
return function (time) {
return function __do() {
var ref = Control_Monad_ST_Internal["new"]({ x: x0, v: v0 })();
Control_Monad_ST_Internal["for"](0)(time * 1000 | 0)(function (v) {
return Control_Monad_ST_Internal.modify(function (o) {
return {
v: o.v - 9.81 * 1.0e-3,
x: o.x + o.v * 1.0e-3
};
})(ref);
})();
var $$final = Control_Monad_ST_Internal.read(ref)();
return $$final.x;
};
};
};
};
局所的な変更可能状態を扱うとき、ST
作用は短いJavaScriptを生成する良い方法となります。
作用を持つ繰り返しを生成するfor
、foreach
、while
のような動作を一緒に使うときは特にそうです。
演習
- (普通)
safeDivide
関数を書き直し、もし分母がゼロならthrowException
を使って文言"div zero"
の例外を投げるようにしたものをexceptionDivide
としてください。 - (普通)関数
estimatePi :: Int -> Number
を書いてください。 この関数はn
項Gregory Seriesを使ってpi
の近似を計算するものです。 手掛かり:解答は上記のsimulate
の定義に倣うことができます。 またData.Int
のtoNumber :: Int -> Number
を使って、Int
をNumber
に変換する必要があるかもしれません。 - (普通)
n
番目のフィボナッチ数を計算する関数fibonacci :: Int -> Int
を書いてください。ST
を使って前2つのフィボナッチ数の値を把握します。 PSCiを使い、ST
に基づく新しい実装の実行速度を第5章の再帰による実装と比較してください。
DOM作用
この章の最後の節では、Effect
モナドでの作用についてこれまで学んだことを、実際のDOM操作の問題に応用します。
DOMを直接扱ったり、オープンソースのDOMライブラリを扱ったりするPureScriptパッケージが沢山あります。 例えば以下です。
web-dom
はW3CのDOM規格に向けた型定義と低水準インターフェース実装を提供します。web-html
はW3CのHTML5規格に向けた型定義と低水準インターフェース実装を提供します。jquery
はjQueryライブラリのバインディングの集まりです。
上記のライブラリを土台に抽象化を進めたPureScriptライブラリもあります。 以下のようなものです。
thermite
はreact
を土台に構築されています。react-basic-hooks
はreact-basic
を土台に構築されています。halogen
は独自の仮想DOMライブラリを土台とする型安全な一揃いの抽象化を提供します。
この章では
react-basic-hooks
ライブラリを使用し、住所簿アプリケーションにユーザーインターフェイスを追加しますが、興味のあるユーザは異なるアプローチで進めることをお勧めします。
住所録のユーザーインターフェース
react-basic-hooks
ライブラリを使い、アプリケーションをReactコンポーネントとして定義していきます。ReactコンポーネントはHTML要素を純粋なデータ構造としてコードで記述します。それからこのデータ構造は効率的にDOMへ描画されます。加えてコンポーネントはボタンクリックのようなイベントに応答できます。react-basic-hooks
ライブラリはEffect
モナドを使ってこれらのイベントの制御方法を記述します。
Reactライブラリの完全な入門はこの章の範囲をはるかに超えていますが、読者は必要に応じて説明書を参照することをお勧めします。
目的に応じて、Reactは Effect
モナドの実用的な例を提供してくれます。
利用者が住所録に新しい項目を追加できるフォームを構築することにしましょう。 フォームには、様々なフィールド(姓、名、市町村、州など)のテキストボックス、及び検証エラーが表示される領域が含まれます。 テキストボックスに利用者がテキストを入力する度に、検証エラーが更新されます。
簡潔さを保つために、フォームは固定の形状とします。電話番号は種類(自宅、携帯電話、仕事、その他)ごとに別々のテキストボックスへ分けることにします。
exercises/chapter8
ディレクトリから以下のコマンドでwebアプリを立ち上げることができます。
$ npm install
$ npx spago build
$ npx parcel src/index.html --open
もしspago
やparcel
のような開発ツールが大域的にインストールされていれば、npx
の前置は省けるでしょう。
恐らく既にspago
をnpm i -g spago
で大域的にインストールしていますし、parcel
についても同じことができるでしょう。
parcel
は「住所録」アプリのブラウザ窓を立ち上げます。
parcel
の端末を開いたままにし、他の端末でspago
で再構築すると、最新の編集を含むページが自動的に再読み込みされるでしょう。
また、purs ide
に対応していたりpscid
を走らせていたりするエディタを使っていれば、ファイルを保存したときに自動的にページが再構築される(そして自動的にページが再読み込みされる)ように設定できます。
この住所録アプリでフォームフィールドにいろいろな値を入力すると、ページ上で出力された検証エラーが見られます。
動作の仕組みを散策しましょう。
src/index.html
ファイルは最小限です。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Address Book</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" crossorigin="anonymous">
</head>
<body>
<div id="container"></div>
<script type="module" src="./index.js"></script>
</body>
</html>
<script
の行にJavaScriptの入口が含まれており、index.js
にはこの実質1行だけが含まれています。
import { main } from "../output/Main/index.js";
main();
module Main
(src/main.purs
) のmain
関数と等価な、生成したJavaScriptを呼び出しています。
spago build
は生成された全てのJavaScriptをoutput
ディレクトリに置くことを思い出してください。
main
関数はDOMとHTML APIを使い、index.html
に定義したcontainer
要素の中に住所録コンポーネントを描画します。
main :: Effect Unit
main = do
log "Rendering address book component"
-- Get window object
w <- window
-- Get window's HTML document
doc <- document w
-- Get "container" element in HTML
ctr <- getElementById "container" $ toNonElementParentNode doc
case ctr of
Nothing -> throw "Container element not found."
Just c -> do
-- Create AddressBook react component
addressBookApp <- mkAddressBookApp
let
-- Create JSX node from react component. Pass-in empty props
app = element addressBookApp {}
-- Render AddressBook JSX node in DOM "container" element
D.render app c
これら3行に注目してください。
w <- window
doc <- document w
ctr <- getElementById "container" $ toNonElementParentNode doc
これは次のように統合できます。
doc <- document =<< window
ctr <- getElementById "container" $ toNonElementParentNode doc
あるいは更なる統合さえできます。
ctr <- getElementById "container" <<< toNonElementParentNode =<< document =<< window
-- or, equivalently:
ctr <- window >>= document >>= toNonElementParentNode >>> getElementById "container"
途中のw
やdoc
変数が読みやすさの助けになるかは主観的な好みの問題です。
AddressBookのreactComponent
を深堀りしましょう。
単純化されたコンポーネントから始め、それからMain.purs
で実際のコードに構築していきます。
以下の最小限のコンポーネントをご覧ください。 遠慮なく全体のコンポーネントをこれに置き換えて実行の様子を見てみましょう。
mkAddressBookApp :: Effect (ReactComponent {})
mkAddressBookApp =
reactComponent
"AddressBookApp"
(\props -> pure $ D.text "Hi! I'm an address book")
reactComponent
にはこのような威圧的なシグネチャがあります。
reactComponent ::
forall hooks props.
Lacks "children" props =>
Lacks "key" props =>
Lacks "ref" props =>
String ->
({ | props } -> Render Unit hooks JSX) ->
Effect (ReactComponent { | props })
重要な注意点は全ての型クラス制約の後の引数にあります。
String
(任意のコンポーネント名)、props
を描画されたJSX
に変換する方法を記述する関数を取り、そしてEffect
に包まれたReactComponent
を返します。
propsからJSXへの関数は単にこうです。
\props -> pure $ D.text "Hi! I'm an address book"
props
は無視されており、D.text
はJSX
を返し、そしてpure
は描画されたJSXに持ち上げます。
これでcomponent
にはReactComponent
を生成するのに必要な全てがあります。
次に、完全な住所録コンポーネントにある幾つかの複雑な事柄を調べていきます。
これらは完全なコンポーネントの最初の数行です。
mkAddressBookApp :: Effect (ReactComponent {})
mkAddressBookApp = do
reactComponent "AddressBookApp" \props -> R.do
Tuple person setPerson <- useState examplePerson
person
をuseState
フックの状態の一部として追跡します。
Tuple person setPerson <- useState examplePerson
なお、複数回useState
を呼び出すことで、コンポーネントの状態を複数の状態の部品に分解することが自在にできます。
例えばPerson
の各レコードフィールドについて分離した状態の部品を使って、このアプリを書き直すことができるでしょう。
しかしこの場合にそうすると僅かに利便性を損なうアーキテクチャになってしまいます。
他の例ではTuple
用の/\
中置演算子に出喰わすかもしれません。
これは先の行と等しいものです。
firstName /\ setFirstName <- useState p.firstName
useState
は、既定の初期値を取って現在の値と値を更新する方法を取ります。
useState
の型を確認すれば型person
とsetPerson
についてより深い洞察が得られます。
useState ::
forall state.
state ->
Hook (UseState state) (Tuple state ((state -> state) -> Effect Unit))
結果の値の梱包Hook (UseState state)
は取り去ることができますが、それはuseState
がR.do
ブロックの中で呼ばれているからです。
R.do
は後で詳述します。
さてこれで以下のシグネチャを観察できます。
person :: state
setPerson :: (state -> state) -> Effect Unit
state
の限定された型は初期の既定値によって決定されます。
これはexamplePerson
の型なのでこの場合はPerson
Record
です。
person
は各再描画の時点で現在の状態にアクセスする方法です。
setPerson
は状態を更新する方法です。
単に現在の状態を新しい状態に変形する方法を記述する関数を提供します。
state
の型が偶然Record
のときは、レコード更新構文がこれにぴったり合います。
例えば以下。
setPerson (\currentPerson -> currentPerson {firstName = "NewName"})
あるいは短かく以下です。
setPerson _ {firstName = "NewName"}
Record
でない状態もまた、この更新パターンに従います。
ベストプラクティスについて、より詳しいことはこの手引きを参照してください。
useState
がR.do
ブロックの中で使われていることを思い出しましょう。
R.do
はdo
の特別なreactフックの派生です。
R.
の前置はこれがReact.Basic.Hooks
から来たものとして「限定する」もので、R.do
ブロックの中でフック互換版のbind
を使うことを意味しています。
これは「限定されたdo」として知られています。
Hook (UseState state)
の梱包を無視し、内部の値のTuple
と変数に束縛してくれます。
他の状態管理戦略として挙げられるのはuseReducer
ですが、それはこの章の範疇外です。
以下ではJSX
の描画が行われています。
pure
$ D.div
{ className: "container"
, children:
renderValidationErrors errors
<> [ D.div
{ className: "row"
, children:
[ D.form_
$ [ D.h3_ [ D.text "Basic Information" ]
, formField "First Name" "First Name" person.firstName \s ->
setPerson _ { firstName = s }
, formField "Last Name" "Last Name" person.lastName \s ->
setPerson _ { lastName = s }
, D.h3_ [ D.text "Address" ]
, formField "Street" "Street" person.homeAddress.street \s ->
setPerson _ { homeAddress { street = s } }
, formField "City" "City" person.homeAddress.city \s ->
setPerson _ { homeAddress { city = s } }
, formField "State" "State" person.homeAddress.state \s ->
setPerson _ { homeAddress { state = s } }
, D.h3_ [ D.text "Contact Information" ]
]
<> renderPhoneNumbers
]
}
]
}
ここでDOMの意図した状態を表現するJSX
を生成しています。
このJSXは単一のHTML要素を作るHTMLタグ(例:div
、form
、h3
、li
、ul
、label
、input
)に対応する関数を適用することで作られるのが普通です。
これらのHTML要素はそれ自体がReactコンポーネントであり、JSXに変換されます。
通常これらの関数にはそれぞれ3つの種類があります。
div_
: 子要素の配列を受け付けます。 既定の属性を使います。div
: 属性のRecord
を受け付けます。 子要素の配列をこのレコードのchildren
フィールドに渡すことができます。div'
:div
と同じですが、JSX
に変換する前にReactComponent
を返します。
検証エラーをフォームの一番上に(もしあれば)表示するため、Errors
構造体をJSXの配列に変えるrenderValidationErrors
補助関数を作ります。この配列はフォームの残り部分の手前に付けます。
renderValidationErrors :: Errors -> Array R.JSX
renderValidationErrors [] = []
renderValidationErrors xs =
let
renderError :: String -> R.JSX
renderError err = D.li_ [ D.text err ]
in
[ D.div
{ className: "alert alert-danger row"
, children: [ D.ul_ (map renderError xs) ]
}
]
なお、ここでは単に通常のデータ構造体を操作しているので、map
のような関数を使ってもっと面白い要素を構築できます。
children: [ D.ul_ (map renderError xs)]
className
プロパティを使ってCSSスタイルのクラスを定義します。
このプロジェクトではBootstrapのstylesheet
を使っており、これはindex.html
でインポートされています。
例えばフォーム中のアイテムはrow
として配置されてほしいですし、検証エラーはalert-danger
の装飾で強調されていてほしいです。
className: "alert alert-danger row"
2番目の補助関数は formField
です。
これは、単一フォームフィールドのテキスト入力を作ります。
formField :: String -> String -> String -> (String -> Effect Unit) -> R.JSX
formField name placeholder value setValue =
D.div
{ className: "form-group row"
, children:
[ D.label
{ className: "col-sm col-form-label"
, htmlFor: name
, children: [ D.text name ]
}
, D.div
{ className: "col-sm"
, children:
[ D.input
{ className: "form-control"
, id: name
, placeholder
, value
, onChange:
let
handleValue :: Maybe String -> Effect Unit
handleValue (Just v) = setValue v
handleValue Nothing = pure unit
in
handler targetValue handleValue
}
]
}
]
}
input
を置いてlabel
の中にtext
を表示すると、スクリーンリーダーのアクセシビリティの助けになります。
onChange
属性があれば利用者の入力に応答する方法を記述できます。handler
関数を使いますが、これは以下の型を持ちます。
handler :: forall a. EventFn SyntheticEvent a -> (a -> Effect Unit) -> EventHandler
handler
への最初の引数にはtargetValue
を使いますが、これはHTMLのinput
要素中のテキストの値を提供します。
この場合は型変数a
がMaybe String
で、handler
が期待するシグネチャに合致しています。
targetValue :: EventFn SyntheticEvent (Maybe String)
JavaScriptではinput
要素のonChange
イベントにはString
値が伴います。
しかし、JavaScriptの文字列はnullになり得るので、安全のためにMaybe
が使われています。
したがって(a -> Effect Unit)
のhandler
への2つ目の引数は、このシグネチャを持ちます。
Maybe String -> Effect Unit
この関数はMaybe String
値を求める作用に変換する方法を記述します。
この目的のために以下のように独自のhandleValue
関数を定義してhandler
を渡します。
onChange:
let
handleValue :: Maybe String -> Effect Unit
handleValue (Just v) = setValue v
handleValue Nothing = pure unit
in
handler targetValue handleValue
setValue
はformField
の各呼び出しに与えた関数で、文字列を取りsetPerson
フックに適切なレコード更新呼び出しを実施します。
なお、handleValue
は以下のようにも置き換えられます。
onChange: handler targetValue $ traverse_ setValue
traverse_
の定義を調査して、両方の形式が確かに等価であることをご確認ください。
これでコンポーネント実装の基本を押さえました。 しかし、コンポーネントの仕組みを完全に理解するためには、この章に付随するソースをお読みください。
明らかに、このユーザーインターフェースには改善すべき点が沢山あります。 演習ではアプリケーションがより使いやすくなるような方法を追究していきます。
演習
以下の演習ではsrc/Main.purs
を変更してください。
これらの演習には単体試験はありません。
-
(簡単)このアプリケーションを変更し、職場の電話番号を入力できるテキストボックスを追加してください。
-
(普通)現時点でアプリケーションは検証エラーを単一の「pink-alert」背景に集めて表示させています。 空行で分離することにより、各検証エラーにpink-alert背景を持たせるように変更してください。
手掛かり:リスト中の検証エラーを表示するのに
ul
要素を使う代わりに、コードを変更し、各エラーにalert
とalert-danger
装飾を持つdiv
を作ってください。 -
(難しい、発展)このユーザーインターフェイスの問題の1つは、検証エラーがその発生源であるフォームフィールドの隣に表示されていないことです。 コードを変更してこの問題を解決してください。
手掛かり:検証器によって返されるエラーの型を、エラーの原因となっているフィールドを示すために拡張するべきです。 以下の変更されたエラー型を使うと良いでしょう。
data Field = FirstNameField | LastNameField | StreetField | CityField | StateField | PhoneField PhoneType data ValidationError = ValidationError String Field type Errors = Array ValidationError
Error
構造体から特定のField
のための検証エラーを取り出す関数を書く必要があるでしょう。
まとめ
この章ではPureScriptでの副作用の扱いについての多くの考え方を導入しました。
Monad
型クラスとdo記法との関係性を見ました。- モナド則を導入し、do記法を使って書かれたコードを変換する方法を見ました。
- 異なる副作用を扱うコードを書く上で、モナドを抽象的に使う方法を見ました。
- モナドがアプリカティブ関手の一例であること、両者がどのように副作用のある計算を可能にするのかということ、そして2つの手法の違いを説明しました。
- ネイティブな作用の概念を定義し、
Effect
モナドを見ました。 これはネイティブな副作用を扱うものでした。 - 乱数生成、例外、コンソール入出力、変更可能な状態、及びReactを使ったDOM操作といった、様々な作用を扱うために
Effect
モナドを使いました。
Effect
モナドは実際のPureScriptコードにおける基本的なツールです。本書ではこのあとも、多くの場面で副作用を処理するために使っていきます。
非同期作用
この章の目標
この章ではAff
モナドに集中します。
これはEffect
モナドに似ていますが、非同期な副作用を表現するものです。
非同期にファイルシステムとやり取りしたりHTTPリクエストしたりする例を実演していきます。
また非同期作用の直列ないし並列な実行の管理方法も押さえます。
プロジェクトの準備
この章で導入する新しいPureScriptライブラリは以下です。
aff
-Aff
モナドを定義します。node-fs-aff
-Aff
を使った非同期のファイルシステム操作。affjax
- AJAXとAff
を使ったHTTPリクエスト。parallel
-Aff
の並列実行。
(Node.js環境のような)ブラウザ外で実行する場合、affjax
ライブラリにはxhr2
NPMモジュールが必要です。
このモジュールはこの章のpackage.json
中の依存関係に挙げられています。
以下を走らせてインストールします。
$ npm install
非同期なJavaScript
JavaScriptで非同期なコードに取り組む上で便利な手段はasync
とawait
です。
非同期なJavaScriptに関するこの記事を見るとより背景情報がわかります。
以下は、この技法を使ってあるファイルの内容を別のファイルに複製する例です。
import { promises as fsPromises } from 'fs'
async function copyFile(file1, file2) {
let data = await fsPromises.readFile(file1, { encoding: 'utf-8' });
fsPromises.writeFile(file2, data, { encoding: 'utf-8' });
}
copyFile('file1.txt', 'file2.txt')
.catch(e => {
console.log('There was a problem with copyFile: ' + e.message);
});
コールバックや同期関数を使うことも可能ですが、以下の理由から望ましくありません。
- コールバックは過剰な入れ子に繋がります。これは「コールバック地獄」や「悪夢のピラミッド」として知られています。
- 同期関数はアプリ中の他のコードの実行を堰き止めてしまいます。
非同期なPureScript
PureScriptでのAff
モナドはJavaScriptのasync
/await
構文に似た人間工学を供します。以下は前と同じcopyFile
の例ですが、Aff
を使ってPureScriptで書き換えられています。
import Prelude
import Data.Either (Either(..))
import Effect.Aff (Aff, attempt, message, launchAff_)
import Effect (Effect)
import Effect.Class.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Aff (readTextFile, writeTextFile)
import Node.Path (FilePath)
main :: Effect Unit
main = launchAff_ program
program :: Aff Unit
program = do
result <- attempt $ copyFile "file1.txt" "file2.txt"
case result of
Left e -> log $ "There was a problem with copyFile: " <> message e
_ -> pure unit
copyFile :: FilePath -> FilePath -> Aff Unit
copyFile file1 file2 = do
my_data <- readTextFile UTF8 file1
writeTextFile UTF8 file2 my_data
なお、main
はEffect Unit
でなければならないので、launchAff_
を使ってAff
からEffect
へと変換せねばなりません。
上のコード片をコールバックや同期関数を使って書き換えることも可能です(例えばNode.FS.Async
やNode.FS.Sync
をそれぞれ使います)。
しかし、JavaScriptで前にお話ししたのと同じ短所がここでも通用するため、それらのコーディング形式は推奨されません。
Aff
を扱う文法はEffect
を扱うものと大変似ています。
どちらもモナドですし、したがってdo記法で書けます。
例えばreadTextFile
のシグネチャを見れば、これがファイルの内容をString
とし、Aff
に包んで返していることがわかります。
readTextFile :: Encoding -> FilePath -> Aff String
do記法中では束縛矢印 (<-
) で返却された文字列を「開封」できます。
my_data <- readTextFile UTF8 file1
それからwriteTextFile
に文字列引数として渡します。
writeTextFile :: Encoding -> FilePath -> String -> Aff Unit
上の例で他に目を引くAff
固有の特徴はattempt
のみです。これはAff
のコードの実行中に遭遇したエラーや例外を捕捉してEither
内に保管するものです。
attempt :: forall a. Aff a -> Aff (Either Error a)
読者ならきっと、前の章から概念の知識を引き出し、その知識と上のcopyFile
の例で学んだ新しいAff
パターンを組み合わせることで、以下の演習に挑戦できるでしょう。
演習
-
(簡単)2つのテキストファイルを連結する関数
concatenateFiles
を書いてください。 -
(普通)複数のテキストファイルを連結する関数
concatenateMany
を書いてください。 入力ファイル名の配列と出力ファイル名が与えられます。 手掛かり:traverse
を使ってください。 -
(普通)ファイル中の文字数を返すか、エラーがあればそれを返す関数
countCharacters :: FilePath -> Aff (Either Error Int)
を書いてください。
更なるAffの資料
もしまだ公式のAffの手引きを見ていなければ、今ざっと目を通してください。 この章の残りの演習を完了する上で事前に直接必要なことではありませんが、Pursuitで何らかの関数を見付けだす助けになるかもしれません。
以下の補足資料についてもあたってみるとよいでしょう。しかし繰り返しになりますがこの章の演習はこれらの内容に依りません。
HTTPクライアント
affjax
ライブラリはAff
で非同期なAJAXのHTTP要求をする上での便利な手段を提供します。
対象としている環境が何であるかによって、purescript-affjax-webまたはpurescript-affjax-nodeのどちらかのライブラリを使う必要があります。
この章の以降ではnodeを対象としていくので、purescript-affjax-node
を使います。
より詳しい使用上の情報はaffjaxのドキュメントにあたってください。
以下は与えられたURLに向けてHTTPのGET要求をして、応答本文ないしエラー文言を返す例です。
import Prelude
import Affjax.Node as AN
import Affjax.ResponseFormat as ResponseFormat
import Data.Either (Either(..))
import Effect.Aff (Aff)
getUrl :: String -> Aff String
getUrl url = do
result <- AN.get ResponseFormat.string url
pure case result of
Left err -> "GET /api response failed to decode: " <> AN.printError err
Right response -> response.body
これをREPLで呼び出す際は、launchAff_
でAff
からREPLに互換性のあるEffect
へと変換する必要があります。
$ spago repl
> :pa
… import Prelude
… import Effect.Aff (launchAff_)
… import Effect.Class.Console (log)
… import Test.HTTP (getUrl)
…
… launchAff_ do
… str <- getUrl "https://reqres.in/api/users/1"
… log str
…
unit
{"data":{"id":1,"email":"george.bluth@reqres.in","first_name":"George","last_name":"Bluth", ...}}
演習
- (簡単)与えられたURLにHTTPの
GET
を要求し、応答本文をファイルに書き込む関数writeGet
を書いてください。
並列計算
Aff
モナドとdo記法を使って、非同期計算を順番に実行されるように合成する方法を見てきました。
非同期計算を並列にも合成できたら便利でしょう。
Aff
があれば2つの計算を次々に開始するだけで並列に計算できます。
parallel
パッケージはAff
のようなモナドのための型クラスParallel
を定義しており、並列実行に対応しています。
以前に本書でアプリカティブ関手に出会ったとき、並列計算を合成するときにアプリカティブ関手がどれほど便利なのかを見ました。
実はParallel
のインスタンスは、(Aff
のような)モナドm
と、並列に計算を組み合わせるために使えるアプリカティブ関手f
との対応関係を定義しているのです。
class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
sequential :: forall a. f a -> m a
parallel :: forall a. m a -> f a
このクラスは2つの関数を定義しています。
parallel
:モナドm
中の計算を取り、アプリカティブ関手f
中の計算に変えます。sequential
:反対方向に変換します。
aff
ライブラリはAff
モナドのParallel
インスタンスを提供します。
これは、2つの継続のどちらが呼び出されたかを把握することによって、変更可能な参照を使用して並列にAff
動作を組み合わせます。
両方の結果が返されたら、最終結果を計算してメインの継続に渡せます。
アプリカティブ関手では任意個の引数の関数の持ち上げができるので、このアプリカティブコンビネータを使ってより多くの計算を並列に実行できます。
traverse
やsequence
といった、アプリカティブ関手を扱う全ての標準ライブラリ関数から恩恵を受けることもできます。
直列的なコードの一部と並列計算を組み合わせることもできます。
それにはdo記法ブロック中でアプリカティブコンビネータを使います。
その逆も然りで、必要に応じてparralel
とsequential
を使って型構築子を変更すれば良いのです。
直列実行と並列実行の間の違いを実演するために、100個の10ミリ秒の遅延からなる配列をつくり、それからその遅延を両方の手法で実行します。REPLで試すとseqDelay
がparDelay
より遥かに遅いことに気付くでしょう。並列実行がsequence_
をparSequence_
で置き換えるだけで有効になるところに注目です。
import Prelude
import Control.Parallel (parSequence_)
import Data.Array (replicate)
import Data.Foldable (sequence_)
import Effect (Effect)
import Effect.Aff (Aff, Milliseconds(..), delay, launchAff_)
delayArray :: Array (Aff Unit)
delayArray = replicate 100 $ delay $ Milliseconds 10.0
seqDelay :: Effect Unit
seqDelay = launchAff_ $ sequence_ delayArray
parDelay :: Effect Unit
parDelay = launchAff_ $ parSequence_ delayArray
$ spago repl
> import Test.ParallelDelay
> seqDelay -- This is slow
unit
> parDelay -- This is fast
unit
以下は並列で複数回HTTP要求する、より現実味のある例です。
getUrl
関数を再利用して2人の利用者から並列で情報を取得します。
この場合ではparTarverse
(traverse
の並列版)が使われていますね。
この例は代わりにtraverse
でも問題なく動きますがより遅くなるでしょう。
import Prelude
import Control.Parallel (parTraverse)
import Effect (Effect)
import Effect.Aff (launchAff_)
import Effect.Class.Console (logShow)
import Test.HTTP (getUrl)
fetchPar :: Effect Unit
fetchPar =
launchAff_ do
let
urls = map (\n -> "https://reqres.in/api/users/" <> show n) [ 1, 2 ]
res <- parTraverse getUrl urls
logShow res
$ spago repl
> import Test.ParallelFetch
> fetchPar
unit
["{\"data\":{\"id\":1,\"email\":\"george.bluth@reqres.in\", ... }"
,"{\"data\":{\"id\":2,\"email\":\"janet.weaver@reqres.in\", ... }"
]
利用できる並列関数の完全な一覧はPursuitのparallel
のドキュメントにあります。parallelのaffのドキュメントの節にもより多くの例が含まれています。
演習
-
(簡単)前の
concatenateMany
関数と同じシグネチャを持つconcatenateManyParallel
関数を書いてください。 ただし全ての入力ファイルを並列に読むようにしてください。 -
(普通)与えられたURLへHTTP
GET
を要求して以下の何れかを返すgetWithTimeout :: Number -> String -> Aff (Maybe String)
関数を書いてください。Nothing
: 要求してから与えられた時間制限(ミリ秒単位)より長く掛かった場合。- 文字列の応答:時間制限を越える前に要求が成功した場合。
-
(難しい)「根」のファイルを取り、そのファイルの中の全てのパスの一覧(そして一覧にあるファイルの中の一覧も)の配列を返す
recurseFiles
関数を書いてください。 一覧にあるファイルを並列に読んでください。 パスはそのファイルが現れたディレクトリから相対的なものです。 手掛かり:node-path
モジュールにはディレクトリを扱う上で便利な関数があります。
例えば次のようなroot.txt
ファイルから始まるとします。
$ cat root.txt
a.txt
b/a.txt
c/a/a.txt
$ cat a.txt
b/b.txt
$ cat b/b.txt
c/a.txt
$ cat b/c/a.txt
$ cat b/a.txt
$ cat c/a/a.txt
期待される出力は次の通り。
["root.txt","a.txt","b/a.txt","b/b.txt","b/c/a.txt","c/a/a.txt"]
まとめ
この章では非同期作用と以下の方法を押さえました。
aff
ライブラリを使ってAff
モナド中で非同期コードを走らせる。affjax
ライブラリを使って非同期にHTTPリクエストする。parallel
ライブラリを使って並列に非同期コードを走らせる。
外部関数インターフェース
この章の目標
本章ではPureScriptの外部関数インターフェース (foreign function interface; FFI) を紹介します。 これによりPureScriptコードからJavaScriptコードへの呼び出し、及びその逆が可能になります。 以下の方法を押さえていきます。
- 純粋で、作用のある、非同期なJavaScript関数をPureScriptから呼び出す。
- 型付けされていないデータを扱う。
argonaut
パッケージを使ってJSONにエンコードしたりJSONを構文解析したりする。
この章の終わりにかけて、住所録の例に立ち返ります。 この章の目的は、FFIを使ってアプリケーションに次の新しい機能を追加することです。
- 利用者にポップアップ通知で警告する。
- フォームのデータを直列化してブラウザのローカルストレージに保存し、アプリケーションが再起動したときにそれを再読み込みする
さらに一般にはそこまで重用されない幾つかの追加の話題を押さえた補遺もあります。 ご自由にこれらの節を読んで構いませんが、学習目標にあまり関係しなければ、本書の残りを読み進める妨げにならないようにしてください。
- 実行時のPureScriptの値の表現を理解する。
- JavaScriptからPureScriptを呼び出す。
プロジェクトの準備
このモジュールのソースコードは、第3章、第7章及び第8章の続きになります。 そうしたわけでソースツリーにはこれらの章からの適切なソースファイルが含まれています。
この章はargonaut
ライブラリを依存関係として導入しています。
このライブラリはJSONにエンコードしたりJSONをデコードしたりするために使います。
この章の演習はtest/MySolutions.purs
に書き、spago test
を走らせることによってtest/Main.purs
中の単体試験について確認できます。
住所録アプリはparcel src/index.html --open
で立ち上げることができます。8章と同じ作業の流れになっているので、より詳しい説明についてはそちらの章を参照してください。
免責事項
JavaScriptの扱いをできる限り単純にするため、PureScriptは直感的な外部関数インターフェースを提供しています。 しかし、FFIはこの言語の応用的な機能であることには心に留めておかれると良いでしょう。 安全かつ効率的に使用するには、扱うつもりであるデータの実行時の表現について理解していなければなりません。 この章では、PureScriptの標準ライブラリのコードに付いて回るそのような理解を伝授することを目指します。
PureScriptのFFIはとても柔軟に設計されています。 実際には、外部関数にとても単純な型を与えるか、型システムを利用して外部のコードの誤った使い方を防ぐようにするか、開発者が選べるようになっています。 標準ライブラリのコードは、後者の手法を採る傾向にあります。
簡単な例としては、JavaScriptの関数で戻り値が null
にならないことは保証できません。
実のところ、JavaScriptらしさのあるコードはかなり頻繁に null
を返します。
しかし、大抵PureScriptの型にnull値が巣喰うことはありません。
そのため、FFIを使ってJavaScriptコードのインターフェイスを設計するとき、これらの特殊な場合を適切に処理するのは開発者の責任です。
PureScriptからJavaScriptを呼び出す
PureScriptからJavaScriptコードを使用する最も簡単な方法は、 外部インポート宣言 (foreign import declaration) を使用し、既存のJavaScriptの値に型を与えることです。 外部インポート宣言には 外部JavaScriptモジュール (foreign JavaScript module) から エクスポートされた 対応するJavaScriptでの宣言がなくてはなりません。
例えば特殊文字をエスケープすることによりURIのコンポーネントをエンコードするJavaScriptの
encodeURIComponent
関数について考えてみます。
$ node
node> encodeURIComponent('Hello World')
'Hello%20World'
この関数は関数の型String -> String
について適切な実行時表現を持っています。
null
でない文字列を取ってnull
でない文字列にするもので、副作用を持たないからです。
次のような外部インポート宣言を使うと、この関数に型を割り当てることができます。
module Test.URI where
foreign import _encodeURIComponent :: String -> String
インポートしてくるための外部JavaScriptモジュールを書く必要もあります。
対応する外部JavaScriptモジュールは、同名で拡張子が.purs
から.js
に変わったものです。
上のPureScriptモジュールがURI.purs
として保存されているなら、外部JavaScriptモジュールをURI.js
として保存します。
encodeURIComponent
は既に定義されているので、_encodeURIComponent
としてエクスポートせねばなりません。
"use strict";
export const _encodeURIComponent = encodeURIComponent;
バージョン0.15からPureScriptはJavaScriptと通訳する際にESモジュールシステムを使います。
ESモジュールではオブジェクトにexport
キーワードを与えることで関数と値がモジュールからエクスポートされます。
これら2つの部品を使うことで、PureScriptで書かれた関数のように、PureScriptからencodeURIComponent
関数を使うことができます。
例えばPSCiで上記の計算を再現できます。
$ spago repl
> import Test.URI
> _encodeURIComponent "Hello World"
"Hello%20World"
外部モジュールには独自の関数も定義できます。
以下はNumber
を平方する独自のJavaScript関数を作って呼び出す方法の一例です。
test/Examples.js
:
"use strict";
export const square = function (n) {
return n * n;
};
test/Examples.purs
:
module Test.Examples where
foreign import square :: Number -> Number
$ spago repl
> import Test.Examples
> square 5.0
25.0
多変数関数
第2章のdiagonal
関数を外部モジュールで書き直してみましょう。
この関数は直角三角形の対角線を計算します。
foreign import diagonal :: Number -> Number -> Number
PureScriptの関数はカリー化されていることを思い出してください。
diagonal
はNumber
を取って関数を返す関数です。
そして返された関数はNumber
を取ってNumber
を返します。
export const diagonal = function (w) {
return function (h) {
return Math.sqrt(w * w + h * h);
};
};
もしくはES6の矢印構文ではこうです(後述するES6についての補足を参照してください)。
export const diagonalArrow = w => h =>
Math.sqrt(w * w + h * h);
foreign import diagonalArrow :: Number -> Number -> Number
$ spago repl
> import Test.Examples
> diagonal 3.0 4.0
5.0
> diagonalArrow 3.0 4.0
5.0
カリー化されていない関数
JavaScriptでカリー化された関数を書くことは、ただでさえJavaScriptらしいものではない上に、常に可能というわけでもありません。 よくある多変数なJavaScriptの関数は カリー化されていない 形式を取るでしょう。
export const diagonalUncurried = function (w, h) {
return Math.sqrt(w * w + h * h);
};
モジュールData.Function.Uncurried
は梱包型とカリー化されていない関数を取り扱う関数をエクスポートします。
foreign import diagonalUncurried :: Fn2 Number Number Number
型構築子Fn2
を調べると以下です。
$ spago repl
> import Data.Function.Uncurried
> :kind Fn2
Type -> Type -> Type -> Type
Fn2
は3つの型引数を取ります。
Fn2 a b c
は、型a
とb
の2つの引数、返り値の型c
を持つカリー化されていない関数の型を表現しています。
これを使って外部モジュールからdiagonalUncurried
をインポートしました。
そうしてrunFn2
を使って呼び出せます。
これはカリー化されていない関数と引数を取るものです。
$ spago repl
> import Test.Examples
> import Data.Function.Uncurried
> runFn2 diagonalUncurried 3.0 4.0
5.0
functions
パッケージでは0引数から10引数までの関数について同様の型構築子が定義されています。
カリー化されていない関数についての補足
PureScriptのカリー化された関数には勿論利点があります。 部分的に関数を適用でき、関数型に型クラスインスタンスを与えられるのです。 しかし効率上の代償も付いてきます。 効率性が決定的に重要なコードでは時々、多変数を受け付けるカリー化されていないJavaScript関数を定義する必要があります。
PureScriptでカリー化されていない関数を作ることもできます。
2引数の関数についてはmkFn2
関数が使えます。
uncurriedAdd :: Fn2 Int Int Int
uncurriedAdd = mkFn2 \n m -> m + n
前と同様にrunFn2
関数を使うと、カリー化されていない2引数の関数を適用できます。
uncurriedSum :: Int
uncurriedSum = runFn2 uncurriedAdd 3 10
ここで重要なのは、引数が全て適用されるなら、コンパイラは mkFn2
関数や runFn2
関数をインライン化するということです。
そのため、生成されるコードはとても簡潔になります。
var uncurriedAdd = function (n, m) {
return m + n | 0;
};
var uncurriedSum = uncurriedAdd(3, 10);
対照的に、こちらがこれまでのカリー化された関数です。
curriedAdd :: Int -> Int -> Int
curriedAdd n m = m + n
curriedSum :: Int
curriedSum = curriedAdd 3 10
そして生成結果のコードが以下です。 入れ子の関数のため比較的簡潔ではありません。
var curriedAdd = function (n) {
return function (m) {
return m + n | 0;
};
};
var curriedSum = curriedAdd(3)(10);
現代的なJavaScriptの構文についての補足
前に見た矢印関数構文はES6の機能であり、そのため幾つかの古いブラウザ(名指しすればIE11)と互換性がありません。 執筆時点でwebブラウザをまだ更新していない6%の利用者が矢印関数を使うことができないと推計されています。
ほとんどの利用者にとって互換性があるようにするため、PureScriptコンパイラによって生成されるJavaScriptコードは矢印関数を使っていません。 また、同じ理由で公開するライブラリでも矢印関数を避けることが推奨されます。
それでも自分のFFIコードで矢印関数を使うこともできますが、デプロイの作業工程でES5に互換性のある関数へ変換するためにBabelなどのツールを含めると良いでしょう。
ES6の矢印関数がより読みやすく感じたらLebabのようなツールを使ってコンパイラのoutput
ディレクトリにJavaScriptのコードを変換できます。
npm i -g lebab
lebab --replace output/ --transform arrow,arrow-return
この操作により上のcurriedAdd
関数は以下に変換されます。
var curriedAdd = n => m =>
m + n | 0;
本書の残りの例では入れ子の関数の代わりに矢印関数を使います。
演習
- (普通)
Test.MySolutions
モジュールの中に箱の体積を求めるJavaScriptの関数volumeFn
を書いてください。Data.Function.Uncurried
の梱包Fn
を使ってください。 - (普通)
volumeFn
を矢印関数を使って書き直し、volumeArrow
としてください。
単純な型を渡す
以下のデータ型はPureScriptとJavaScriptの間でそのまま渡し合うことができます。
PureScript | JavaScript |
---|---|
Boolean | Boolean |
String | String |
Int, Number | Number |
Array | Array |
Record | Object |
String
とNumber
という原始型の例は既に見てきました。
ここからArray
やRecord
(JavaScriptではObject
)といった構造的な型を眺めていきます。
Array
を渡すところを実演するために、以下にInt
のArray
を取って別の配列として累計の和を返すJavaScriptの関数の呼び出し方を示します。
前にありましたが、JavaScriptはInt
のための分離した型を持たないため、PureScriptでのInt
とNumber
は両方共JavaScriptでのNumber
に翻訳されます。
foreign import cumulativeSums :: Array Int -> Array Int
export const cumulativeSums = arr => {
let sum = 0
let sums = []
arr.forEach(x => {
sum += x;
sums.push(sum);
});
return sums;
};
$ spago repl
> import Test.Examples
> cumulativeSums [1, 2, 3]
[1,3,6]
Record
を渡すところを実演するために、以下に2つのComplex
な数をレコードとして取り、和を別のレコードとして返すJavaScriptの呼び出し方を示します。
PureScriptでのRecord
がJavaScriptではObject
として表現されることに注意してください。
type Complex = {
real :: Number,
imag :: Number
}
foreign import addComplex :: Complex -> Complex -> Complex
export const addComplex = a => b => {
return {
real: a.real + b.real,
imag: a.imag + b.imag
}
};
$ spago repl
> import Test.Examples
> addComplex { real: 1.0, imag: 2.0 } { real: 3.0, imag: 4.0 }
{ imag: 6.0, real: 4.0 }
なお、上の手法にはJavaScriptが期待通りの型を返すことを信用する必要があります。 PureScriptはJavaScriptのコードに型検査を適用できないからです。 この型安全性の配慮について後のJSONの節でより詳しく解説していきます。 型の不整合から身を守る手法についても押さえます。
演習
- (普通)
Complex
な数の配列を取って別の複素数の配列として累計の和を返すJavaScriptの関数cumulativeSumsComplex
(と対応するPureScriptの外部インポート)を書いてください。
単純な型を越えて
String
、Number
、Array
、そしてRecord
といった、JavaScript固有の表現を持つ型をFFI越しに送ったり受け取ったりする方法を数例見てきました。
ここからMaybe
のようなPureScriptで使える幾つかの他の型の使い方を押さえていきます。
外部宣言を使用して、配列についての head
関数を改めて作成したいとしましょう。
JavaScriptでは次のような関数を書くことになるでしょう。
export const head = arr =>
arr[0];
この関数をどう型付けましょうか。
型 forall a. Array a -> a
を与えようとしても、空の配列に対してこの関数は undefined
を返します。
したがって型forall a. Array a -> a
は正しくこの実装を表現していないのです。
代わりにこの特殊な場合を扱うためにMaybe
値を返したいところです。
foreign import maybeHead :: forall a. Array a -> Maybe a
しかしどうやってMaybe
を返しましょうか。
つい以下のように書きたくなります。
// こうしないでください
import Data_Maybe from '../Data.Maybe'
export const maybeHead = arr => {
if (arr.length) {
return Data_Maybe.Just.create(arr[0]);
} else {
return Data_Maybe.Nothing.value;
}
}
外部モジュールで直接Data.Maybe
モジュールをインポートして使うことはお勧めしません。というのもコードがコード生成器の変化に対して脆くなるからです。create
やvalue
は公開のAPIではありません。加えて、このようにすると、不要なコードを消去するpurs bundle
を使う際に問題を引き起こす可能性があります。
推奨されるやり方はFFIで定義された関数に余剰の引数を加えて必要な関数を受け付けることです。
export const maybeHeadImpl = just => nothing => arr => {
if (arr.length) {
return just(arr[0]);
} else {
return nothing;
}
};
foreign import maybeHeadImpl :: forall a. (forall x. x -> Maybe x) -> (forall x. Maybe x) -> Array a -> Maybe a
maybeHead :: forall a. Array a -> Maybe a
maybeHead arr = maybeHeadImpl Just Nothing arr
ただし、次のように書きますが、
forall a. (forall x. x -> Maybe x) -> (forall x. Maybe x) -> Array a -> Maybe a
以下ではないことに注意です。
forall a. (a -> Maybe a) -> Maybe a -> Array a -> Maybe a
どちらの形式でも動きますが、後者はJust
とNothing
の場所での招かれざる入力に対してより脆弱です。
例えば、比較的脆い方では、以下のように呼び出せるでしょう。
maybeHeadImpl (\_ -> Just 1000) (Just 1000) [1,2,3]
これは如何なる配列の入力に対してもJust 1000
を返します。
この脆弱性では、a
がInt
のときに(これは入力の配列に基づきます)(\_ -> Just 1000)
とJust 1000
がシグネチャ(a -> Maybe a)
とMaybe a
にそれぞれ照合するために許容されてしまっています。
より安全な型シグネチャでは、入力の配列に基づいてa
がInt
に決定されたとしても、forall x
に絡むシグネチャに合致する妥当な関数を提供する必要があります。(forall x. Maybe x)
の 唯一 の選択肢はNothing
ですが、それはJust
値がx
の型を前提にしてしまうと、もはや全てのx
については妥当でなくなってしまうからです。(forall x. x -> Maybe x)
の唯一の選択肢はJust
(望まれている引数)と(\_ -> Nothing)
であり、後者は唯一残っている脆弱性になるのです。
外部型の定義
Maybe a
を返す代わりにarr[0]
を返したいのだとしましょう。
型a
ないしundefined
値(ただしnull
ではありません)の何れかの値を表現する型がほしいです。
この型をUndefined a
と呼びましょう。
外部インポート宣言 を使うと、外部型 (foreign type) を定義できます。構文は外部関数を定義するのと似ています。
foreign import data Undefined :: Type -> Type
このキーワードdata
は型を定義していることを表しています。
値ではありせん。
型シグネチャの代わりに、新しい型の種を与えます。
この場合はUndefined
の種が Type -> Type
であると宣言しています。
言い換えればUndefined
は型構築子です。
これで元のhead
の定義を再利用できます。
export const undefinedHead = arr =>
arr[0];
PureScriptモジュールには以下を追加します。
foreign import undefinedHead :: forall a. Array a -> Undefined a
undefinedHead
関数の本体はundefined
かもしれないarr[0]
を返します。
そしてこの型シグネチャはその事実を正しく反映しています。
この関数はその型の適切な実行時表現を持っていますが、型Undefined a
の値を使用する方法がないので、全く役に立ちません。
いや、言い過ぎました。
別のFFIでこの型を使えますからね。
値が未定義かどうかを教えてくれる関数を書くことができます。
foreign import isUndefined :: forall a. Undefined a -> Boolean
外部JavaScriptモジュールで次のように定義できます。
export const isUndefined = value =>
value === undefined;
これでPureScriptで isUndefined
と undefinedHead
を一緒に使用すると、便利な関数を定義できます。
isEmpty :: forall a. Array a -> Boolean
isEmpty = isUndefined <<< undefinedHead
このように、定義したこの外部関数はとても単純です。 つまりPureScriptの型検査器を使うことによる利益が最大限得られるのです。 一般に、外部関数は可能な限り小さく保ち、できるだけアプリケーションの処理はPureScriptコードへ移動しておくことをお勧めします。
例外
他の選択肢としては、空の配列の場合に例外を投げる方法があります。 厳密に言えば、純粋な関数は例外を投げるべきではありませんが、そうする柔軟さはあります。 安全性に欠けていることを関数名で示します。
foreign import unsafeHead :: forall a. Array a -> a
JavaScriptモジュールでは、unsafeHead
を以下のように定義できます。
export const unsafeHead = arr => {
if (arr.length) {
return arr[0];
} else {
throw new Error('unsafeHead: empty array');
}
};
演習
-
(普通)二次多項式 \( a x ^ 2 + b x + c = 0 \) を表現するレコードが与えられているとします。
type Quadratic = { a :: Number, b :: Number, c :: Number }
二次多項式を使ってこの多項式の根を求めるJavaScriptの関数
quadraticRootsImpl
とその梱包のquadraticRoots :: Quadratic -> Pair Complex
を書いてください。 2つの根をComplex
の数のPair
として返してください。 手掛かり:梱包quadraticRoots
を使ってPair
の構築子をquadraticRootsImpl
に渡してください。 -
(普通)関数
toMaybe :: forall a. Undefined a -> Maybe a
を書いてください。 この関数はundefined
をNothing
に、a
の値をJust a
に変換します。 -
(難しい)
toMaybe
が準備できたら、maybeHead
を以下に書き換えられます。maybeHead :: forall a. Array a -> Maybe a maybeHead = toMaybe <<< undefinedHead
これは前の実装よりも良いやり方なのでしょうか。 補足:この演習のための単体試験はありません。
型クラスメンバー関数を使う
つい先程までFFI越しにMaybe
の構築子を渡す手引きをしましたが、今回はJavaScriptを呼び出すPureScriptを書く別の場合です。
JavaScriptの呼び出しでも続けざまにPureScriptの関数を呼び出します。
ここでは型クラスのメンバー関数をFFI越しに渡す方法を探ります。
型x
に合う適切なshow
のインスタンスを期待する外部JavaScript関数を書くことから始めます。
export const boldImpl = show => x =>
show(x).toUpperCase() + "!!!";
それから対応するシグネチャを書きます。
foreign import boldImpl :: forall a. (a -> String) -> a -> String
そしてshow
の正しいインスタンスを渡す梱包関数も書きます。
bold :: forall a. Show a => a -> String
bold x = boldImpl show x
代えてポイントフリー形式だとこうです。
bold :: forall a. Show a => a -> String
bold = boldImpl show
そうして梱包を呼び出すことができます。
$ spago repl
> import Test.Examples
> import Data.Tuple
> bold (Tuple 1 "Hat")
"(TUPLE 1 \"HAT\")!!!"
以下は複数の関数を渡す別の実演例です。
これらの関数には複数引数の関数 (eq
) が含まれます。
export const showEqualityImpl = eq => show => a => b => {
if (eq(a)(b)) {
return "Equivalent";
} else {
return show(a) + " is not equal to " + show(b);
}
}
foreign import showEqualityImpl :: forall a. (a -> a -> Boolean) -> (a -> String) -> a -> a -> String
showEquality :: forall a. Eq a => Show a => a -> a -> String
showEquality = showEqualityImpl eq show
$ spago repl
> import Test.Examples
> import Data.Maybe
> showEquality Nothing (Just 5)
"Nothing is not equal to (Just 5)"
作用のある関数
bold
関数を拡張してコンソールにログ出力するようにしましょう。
ログ出力はEffect
であり、Effect
はJavaScriptにおいて無引数関数として表現されます。
つまり()
と矢印記法だとこうです。
export const yellImpl = show => x => () =>
console.log(show(x).toUpperCase() + "!!!");
新しくなった外部インポートは、返る型がString
からEffect Unit
に変わった点以外は以前と同じです。
foreign import yellImpl :: forall a. (a -> String) -> a -> Effect Unit
yell :: forall a. Show a => a -> Effect Unit
yell = yellImpl show
REPLで試すと文字列が(引用符で囲まれず)直接コンソールに印字されunit
値が返ることがわかります。
$ spago repl
> import Test.Examples
> import Data.Tuple
> yell (Tuple 1 "Hat")
(TUPLE 1 "HAT")!!!
unit
Effect.Uncurried
に梱包EffectFn
というものもあります。
これらは既に見たData.Function.Uncurried
の梱包Fn
に似ています。
これらの梱包があればカリー化されていない作用のある関数をPureScriptで呼び出すことができます。
一般的にこれらを使うのは、こうしたAPIをカリー化された関数に包むのではなく、既存のJavaScriptライブラリのAPIを直接呼び出したいときぐらいです。
したがってカリー化していないyell
の例を見せてもあまり意味がありません。
というのもJavaScriptがPureScriptの型クラスのメンバーに依っているからで、更にそれは既存のJavaScriptのエコシステムにそのメンバーが見付からないためです。
翻って以前のdiagonal
の例を変更し、結果を返すことに加えてログ出力を含めるとこうなります。
export const diagonalLog = function(w, h) {
let result = Math.sqrt(w * w + h * h);
console.log("Diagonal is " + result);
return result;
};
foreign import diagonalLog :: EffectFn2 Number Number Number
$ spago repl
> import Test.Examples
> import Effect.Uncurried
> runEffectFn2 diagonalLog 3.0 4.0
Diagonal is 5
5.0
非同期関数
aff-promise
ライブラリの助けを借りるとJavaScriptのプロミスは直接PureScriptの非同期作用に翻訳されます。
詳細についてはライブラリのドキュメントをあたってください。
ここでは幾つかの例に触れるだけとします。
JavaScriptのwait
プロミス(または非同期関数)をPureScriptのプロジェクトで使いたいとします。
ms
ミリ秒分だけ送らせて実行させるのに使うことができます。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
単にEffect
(無引数関数)に包んで公開するだけで大丈夫です。
export const sleepImpl = ms => () =>
wait(ms);
そして以下のようにインポートします。
foreign import sleepImpl :: Int -> Effect (Promise Unit)
sleep :: Int -> Aff Unit
sleep = sleepImpl >>> toAffE
そうしてAff
ブロック中でこのPromise
を以下のように走らせることができます。
$ spago repl
> import Prelude
> import Test.Examples
> import Effect.Class.Console
> import Effect.Aff
> :pa
… launchAff_ do
… log "waiting"
… sleep 300
… log "done waiting"
…
waiting
unit
done waiting
REPLでの非同期ログ出力はブロック全体が実行を終了するまで印字を待機する点に注意しましょう。
このコードをspago test
で走らせた場合、印字の合間に僅かな遅延があり、より予測に近い挙動をします。
他にプロミスから値を返す例を見てみましょう。
この関数はasync
とawait
を使って書かれていますが、これはプロミスの糖衣構文に過ぎません。
async function diagonalWait(delay, w, h) {
await wait(delay);
return Math.sqrt(w * w + h * h);
}
export const diagonalAsyncImpl = delay => w => h => () =>
diagonalWait(delay, w, h);
Number
を返すため、この型をPromise
とAff
の梱包の中で表現します。
foreign import diagonalAsyncImpl :: Int -> Number -> Number -> Effect (Promise Number)
diagonalAsync :: Int -> Number -> Number -> Aff Number
diagonalAsync i x y = toAffE $ diagonalAsyncImpl i x y
$ spago repl
import Prelude
import Test.Examples
import Effect.Class.Console
import Effect.Aff
> :pa
… launchAff_ do
… res <- diagonalAsync 300 3.0 4.0
… logShow res
…
unit
5.0
演習
上の節の演習はまだやるべきことの一覧にあります。 もし何か良い演習の考えがあればご提案ください。
JSON
アプリケーションでJSONを使うことには多くの理由があります。 例えばwebのAPIと疎通するよくある手段であるためです。 この節では他の用例についてもお話ししましょう。 構造的なデータをFFI越しに渡す場合に型安全性を向上させる手法から始めます。
少し前のFFI関数cumulativeSums
とaddComplex
を再訪し、それぞれに1つバグを混入させてみましょう。
export const cumulativeSumsBroken = arr => {
let sum = 0
let sums = []
arr.forEach(x => {
sum += x;
sums.push(sum);
});
sums.push("Broken"); // Bug
return sums;
};
export const addComplexBroken = a => b => {
return {
real: a.real + b.real,
broken: a.imag + b.imag // Bug
}
};
実際は返る型が正しくないのですが、元々の型シグネチャを使うことができ、依然としてコードはコンパイルされます。
foreign import cumulativeSumsBroken :: Array Int -> Array Int
foreign import addComplexBroken :: Complex -> Complex -> Complex
コードの実行さえ可能で、そうすると予期しない結果を生み出すか実行時エラーになります。
$ spago repl
> import Test.Examples
> import Data.Foldable (sum)
> sums = cumulativeSumsBroken [1, 2, 3]
> sums
[1,3,6,Broken]
> sum sums
0
> complex = addComplexBroken { real: 1.0, imag: 2.0 } { real: 3.0, imag: 4.0 }
> complex.real
4.0
> complex.imag + 1.0
NaN
> complex.imag
var str = n.toString();
^
TypeError: Cannot read property 'toString' of undefined
例えば結果のsums
はもはや正しいArray Int
ではありませんが、それはString
が配列に含まれているからです。
そして更なる操作は即時のエラーではなく予期しない挙動を生み出します。
というのもこれらのsums
のsum
は10
ではなく0
だからです。
これでは捜索の難しいバグになりかねませんね。
同様にaddComplexBroken
を呼び出すときは1つもエラーが出ません。
しかし、Complex
の結果のimag
フィールドにアクセスすると予期しない挙動(7.0
ではなくNan
を返すため)やはっきりしない実行時エラーを生じることでしょう。
PureScriptのコードにバグ一匹通さないようにするため、JavaScriptのコードでJSONを使いましょう。
argonaut
ライブラリにはこのために必要なJSONのデコードとエンコードの機能が備わっています。
このライブラリには素晴らしいドキュメントがあるので、本書では基本的な用法だけを押さえます。
返る型をJson
として定義するようにして、代わりとなる外部インポートを作るとこうなります。
foreign import cumulativeSumsJson :: Array Int -> Json
foreign import addComplexJson :: Complex -> Complex -> Json
単純に既存の壊れた関数を指している点に注意します。
export const cumulativeSumsJson = cumulativeSumsBroken
export const addComplexJson = addComplexBroken
そして返されたJson
の値をデコードする梱包を書きます。
cumulativeSumsDecoded :: Array Int -> Either JsonDecodeError (Array Int)
cumulativeSumsDecoded arr = decodeJson $ cumulativeSumsJson arr
addComplexDecoded :: Complex -> Complex -> Either JsonDecodeError Complex
addComplexDecoded a b = decodeJson $ addComplexJson a b
そうすると返る型へのデコードが成功しなかったどんな値もLeft
のString
なエラーとして表れます。
$ spago repl
> import Test.Examples
> cumulativeSumsDecoded [1, 2, 3]
(Left "Couldn't decode Array (Failed at index 3): Value is not a Number")
> addComplexDecoded { real: 1.0, imag: 2.0 } { real: 3.0, imag: 4.0 }
(Left "JSON was missing expected field: imag")
正常に動作するバージョンで呼び出すとRight
の値が返ります。
次のREPLブロックを走らせる前に、正常に動作するバージョンを指すように、test/Examples.js
へ以下の変更を加えて、手元で試してみましょう。
export const cumulativeSumsJson = cumulativeSums
export const addComplexJson = addComplex
$ spago repl
> import Test.Examples
> cumulativeSumsDecoded [1, 2, 3]
(Right [1,3,6])
> addComplexDecoded { real: 1.0, imag: 2.0 } { real: 3.0, imag: 4.0 }
(Right { imag: 6.0, real: 4.0 })
JSONを使うのは、Map
やSet
のようなその他の構造的な型をFFI越しに渡す、最も簡単な方法でもあります。
JSONは真偽値、数値、文字列、配列、そして他のJSONの値からなるオブジェクトのみから構成されるため、JSONでは直接Map
やSet
を書けません。
しかしこれらの構造を配列としては表現でき(キーとバリューもまたJSONで表現されているとします)、それからMap
やSet
に復元できるのです。
以下はString
のキーとInt
のバリューからなるMap
を変更する外部関数シグネチャと、それに伴うJSONのエンコードとデコードを扱う梱包関数の例です。
foreign import mapSetFooJson :: Json -> Json
mapSetFoo :: Map String Int -> Either JsonDecodeError (Map String Int)
mapSetFoo json = decodeJson $ mapSetFooJson $ encodeJson json
関数合成の絶好の用例になっていますね。 以下の代案は両方とも上のものと等価です。
mapSetFoo :: Map String Int -> Either JsonDecodeError (Map String Int)
mapSetFoo = decodeJson <<< mapSetFooJson <<< encodeJson
mapSetFoo :: Map String Int -> Either JsonDecodeError (Map String Int)
mapSetFoo = encodeJson >>> mapSetFooJson >>> decodeJson
以下はJavaScriptでの実装です。
なお、Array.from
の工程は、JavaScriptのMap
をJSONに親和性のある形式に変換し、デコードでPureScriptのMap
に変換し直すために必須です。
export const mapSetFooJson = j => {
let m = new Map(j);
m.set("Foo", 42);
return Array.from(m);
};
これでMap
をFFI越しに送ったり受け取ったりできます。
$ spago repl
> import Test.Examples
> import Data.Map
> import Data.Tuple
> myMap = fromFoldable [ Tuple "hat" 1, Tuple "cat" 2 ]
> :type myMap
Map String Int
> myMap
(fromFoldable [(Tuple "cat" 2),(Tuple "hat" 1)])
> mapSetFoo myMap
(Right (fromFoldable [(Tuple "Foo" 42),(Tuple "cat" 2),(Tuple "hat" 1)]))
演習
-
(普通)
Map
中の全てのバリューのSet
を返すJavaScriptの関数とPureScriptの梱包valuesOfMap :: Map String Int -> Either JsonDecodeError (Set Int)
を書いてください。 -
(簡単)より広い種類のマップに関して動作するよう、前のJavaScriptの関数の新しい梱包を書いてください。 シグネチャは
valuesOfMapGeneric :: forall k v. Map k v -> Either JsonDecodeError (Set v)
です。 なお、k
とv
に幾つかの型クラス制約を加える必要があるでしょう。 コンパイラが導いてくれます。 -
(普通)少し前の
quadraticRoots
関数を書き換えてquadraticRootSet
としてください。 この関数はComplex
の根をJSONを介して(Pair
の代わりに)Set
として返します。 -
(難しい)少し前の
quadraticRoots
関数を書き換えてquadraticRootsSafe
としてください。 この関数はJSONを使ってComplex
の根のPair
をFFI越しに渡します。 JavaScriptではPair
構築子を使わないでください。 その代わり、デコーダーに互換性のある形式で対を返すだけにしてください。 手掛かり:DecodeJson
インタンスをPair
用に書く必要があるでしょう。 独自のデコードインスタンスを書く上での説明についてはargonautのドキュメントをあたってください。 decodeJsonTupleインスタンスも参考になるかもしれません。 「孤立インスタンス」を作ることを避けるために、Pair
にnewtype
の梱包が必要になる点に注意してください。 -
(普通)2次元配列を含むJSON文字列を構文解析してデコードする
parseAndDecodeArray2D :: String -> Either String (Array (Array Int))
関数を書いてください。 例えば"[[1, 2, 3], [4, 5], [6]]"
です。 手掛かり:デコードの前にjsonParser
を使ってString
をJson
に変換する必要があるでしょう。 -
(普通)以下のデータ型は値が葉にある二分木を表現します。
data Tree a = Leaf a | Branch (Tree a) (Tree a)
汎化された
EncodeJson
及びDecodeJson
インスタンスをTree
型用に導出してください。 このやり方についての説明はargonautのドキュメントをあたってください。 なお、この演習の単体試験を有効にするには、汎化されたShow
及びEq
インスタンスも必要になります。 しかしJSONのインスタンスと格闘したあとでは、これらの実装は直感的に進むことでしょう。 -
(難しい)以下の
data
型は整数か文字列かによってJSONで異なって表現されます。data IntOrString = IntOrString_Int Int | IntOrString_String String
この挙動を実装する
IntOrString
データ型に、EncodeJson
及びDecodeJson
インスタンスを書いてください。 手掛かり:Control.Alt
のalt
演算子が役立つかもしれません。
住所録
この節では新しく獲得したFFIとJSONの知識を応用して、第8章の住所録の例を構築していきたいと思います。 以下の機能を加えていきます。
- 保存ボタンをフォームの一番下に配置し、クリックしたときにフォームの状態をJSONに直列化してローカルストレージに保存します。
- ページの再読み込み時にローカルストレージからJSON文書を自動的に取得します。 フォームのフィールドにはこの文書の内容を入れます。
- フォームの状態を保存したり読み込んだりするのに問題があればポップアップの警告を出します。
Effect.Storage
モジュールに以下のwebストレージAPIのためのFFIの梱包を作ることから始めていきます。
setItem
はキーと値(両方とも文字列)を受け取り、指定されたキーでローカルストレージに値を格納する計算を返します。getItem
はキーを取り、ローカルストレージから関連付けられたバリューの取得を試みます。 しかしwindow.localStorage
のgetItem
メソッドはnull
を返しうるので、返る型はString
ではなくJson
です。
foreign import setItem :: String -> String -> Effect Unit
foreign import getItem :: String -> Effect Json
以下はこれらの関数に対応するJavaScriptの実装で、Effect/Storage.js
にあります。
export const setItem = key => value => () =>
window.localStorage.setItem(key, value);
export const getItem = key => () =>
window.localStorage.getItem(key);
以下のように保存ボタンを作ります。
saveButton :: R.JSX
saveButton =
D.label
{ className: "form-group row col-form-label"
, children:
[ D.button
{ className: "btn-primary btn"
, onClick: handler_ validateAndSave
, children: [ D.text "Save" ]
}
]
}
そしてvalidateAndSave
関数中では、検証されたperson
をJSON文字列とし、setItem
を使って書き込みます。
validateAndSave :: Effect Unit
validateAndSave = do
log "Running validators"
case validatePerson' person of
Left errs -> log $ "There are " <> show (length errs) <> " validation errors."
Right validPerson -> do
setItem "person" $ stringify $ encodeJson validPerson
log "Saved"
なお、この段階でコンパイルしようとすると以下のエラーに遭遇します。
No type class instance was found for
Data.Argonaut.Encode.Class.EncodeJson PhoneType
これはなぜかというとPerson
レコード中のPhoneType
がEncodeJson
インスタンスを必要としているからです。
また、ついでに汎用のエンコードインスタンスとデコードインスタンスを導出していきます。
この仕組みについての詳細情報はargonautのドキュメントにあります。
import Data.Argonaut (class DecodeJson, class EncodeJson)
import Data.Argonaut.Decode.Generic (genericDecodeJson)
import Data.Argonaut.Encode.Generic (genericEncodeJson)
import Data.Generic.Rep (class Generic)
derive instance Generic PhoneType _
instance EncodeJson PhoneType where encodeJson = genericEncodeJson
instance DecodeJson PhoneType where decodeJson = genericDecodeJson
これでperson
をローカルストレージに保存できます。
しかしデータを取得できない限りあまり便利ではありません。
次はそれに取り掛かりましょう。
ローカルストレージから「person」文字列で取得することから始めましょう。
item <- getItem "person"
そうしてローカルストレージ由来の文字列からPerson
レコードへ変換する補助関数を作ります。
なお、このストレージ中の文字列はnull
かもしれないので、正常にString
としてデコードされるまでは外部のJson
として表現します。
道中には他にも多くの変換工程があり、それぞれでEither
の値を返します。
そのためこれらをまとめてdo
ブロックの中に纏めるのは理に適っています。
processItem :: Json -> Either String Person
processItem item = do
jsonString <- decodeJson item
j <- jsonParser jsonString
decodeJson j
そうしてこの結果が成功しているかどうか調べます。
もし失敗していればエラーをログ出力し、既定のexamplePerson
を使います。
そうでなければローカルストレージから取得した人物を使います。
initialPerson <- case processItem item of
Left err -> do
log $ "Error: " <> err <> ". Loading examplePerson"
pure examplePerson
Right p -> pure p
最後にこのinitialPerson
をprops
レコードを介してコンポーネントに渡します。
-- reactコンポーネントからJSXノードを作成します。
app = element addressBookApp { initialPerson }
そして状態フックで使うために別の箇所で拾い上げます。
mkAddressBookApp :: Effect (ReactComponent { initialPerson :: Person })
mkAddressBookApp =
reactComponent "AddressBookApp" \props -> R.do
Tuple person setPerson <- useState props.initialPerson
仕上げとして、各Left
値のString
にlmap
を使って前置し、エラー文言の質を向上させます。
processItem :: Json -> Either String Person
processItem item = do
jsonString <- lmap ("No string in local storage: " <> _) $ decodeJson item
j <- lmap ("Cannot parse JSON string: " <> _) $ jsonParser jsonString
lmap ("Cannot decode Person: " <> _) $ decodeJson j
最初のエラーのみがこのアプリの通常の操作内で起こります。 他のエラーはwebブラウザの開発ツールを開いてローカルストレージ中に保存された「person」文字列を編集し、そのページを参照することで引き起こせます。 どのようにJSON文字列を変更したかが、どのエラーを引き起こすかを決定します。 各エラーを引き起こせるかご確認ください。
これでローカルストレージについては押さえました。
次にalert
動作を実装していきます。
この動作はEffect.Console
モジュールのlog
動作に似ています。
唯一の相違点はalert
動作がwindow.alert
メソッドを使うことで、対してlog
動作はconsole.log
メソッドを使っています。
そういうわけでalert
はwindow.alert
が定義された環境でのみ使うことができます。
webブラウザなどです。
foreign import alert :: String -> Effect Unit
export const alert = msg => () =>
window.alert(msg);
この警告が次の何れかの場合に現れるようにしたいです。
- 利用者が検証エラーを含むフォームを保存しようと試みている。
- 状態がローカルストレージから取得できない。
以上は単に以下の行でlog
をalert
に置き換えるだけで達成できます。
Left errs -> alert $ "There are " <> show (length errs) <> " validation errors."
alert $ "Error: " <> err <> ". Loading examplePerson"
演習
- (普通)
localStorage
オブジェクトのremoveItem
メソッドの梱包を書き、Effect.Storage
モジュールに外部関数を追加してください - (普通)「リセット」ボタンを追加してください。
このボタンをクリックすると新しく作った
removeItem
関数を呼び出してローカルストレージから「人物」の項目を削除します。 - (簡単)JavaScriptの
Window
オブジェクトのconfirm
メソッドの梱包を書き、Effect.Alert
モジュールにその外部関数を追加してください。 - (普通)利用者が「リセット」ボタンをクリックしたときにこの
confirm
関数を呼び出し、本当に住所録を白紙にしたいか尋ねるようにしてください。
まとめ
この章では、PureScriptから外部のJavaScriptコードを扱う方法を学びました。 また、FFIを使用して信頼できるコードを書く時に生じる問題について見てきました。
- 外部関数が正しい表現を持っていることを確かめる重要性を見てきました。
- 外部型や
Json
データ型を使用することによって、null値やJavaScriptの他の型のデータのような特殊な場合に対処する方法を学びました。 - 安全にJSONデータを直列化・直列化復元する方法を見ました。
より多くの例については、GitHubのpurescript
組織、purescript-contrib
組織、及びpurescript-node
組織が、FFIを使用するライブラリの例を多数提供しています。
残りの章では、型安全な方法で現実世界の問題を解決するために使うライブラリを幾つか見ていきます。
補遺
JavaScriptからPureScriptを呼び出す
少なくとも単純な型を持つ関数については、JavaScriptからPureScript関数を呼び出すのはとても簡単です。
例として以下のような簡単なモジュールを見てみましょう。
module Test where
gcd :: Int -> Int -> Int
gcd 0 m = m
gcd n 0 = n
gcd n m
| n > m = gcd (n - m) m
| otherwise = gcd (m – n) n
この関数は、減算を繰り返すことによって2つの数の最大公約数を見つけます。 PureScriptでパターン照合と再帰を使用してこの関数を定義するのは簡単で、実装する開発者は型検証器の恩恵を受けることができます。 そういうわけで関数を定義するのにPureScriptを使いたくなるかもしれない良い例となっていますが、JavaScriptからそれを呼び出すためには条件があります。
この関数をJavaScriptから呼び出す方法を理解する上で重要なのは、PureScriptの関数は常に引数が1つのJavaScript関数へと変換され、引数へは次のように1つずつ適用していかなければならないということです。
import Test from 'Test.js';
Test.gcd(15)(20);
ここではspago build
でコンパイルされていることを前提としています。
SpagoはPureScriptモジュールをESモジュールにコンパイルするものです。
そのため、import
を使ってTest
モジュールをインポートした後、Test
オブジェクトのgcd
関数を参照できました。
spago bundle-app
やspago bundle-module
コマンドを使って生成されたJavaScriptを単一のファイルにまとめることもできます。
詳細な情報についてはドキュメントをあたってください。
名前の生成を理解する
PureScriptはコード生成時にできるだけ名前を保持することを目指します。 とりわけ、PureScriptやJavaScriptのキーワードでなければほとんどの識別子が保存されることが期待できます。 少なくとも最上位で宣言される名前についてはそうです。
識別子としてJavaScriptのキーワードを使う場合は、名前は2重のドル記号でエスケープされます。 例えば次のPureScriptコードを考えてみます。
null = []
これは以下のJavaScriptを生成します。
var $$null = [];
また、識別子に特殊文字を使用したい場合は、単一のドル記号を使用してエスケープされます。 例えばこのPureScriptコードを考えます。
example' = 100
これは以下のJavaScriptを生成します。
var example$prime = 100;
コンパイルされたPureScriptコードがJavaScriptから呼び出されることを意図している場合、識別子は英数字のみを使用し、JavaScriptのキーワードを避けることをお勧めします。 ユーザ定義演算子がPureScriptコードでの使用のために提供される場合、JavaScriptから使うための英数字の名前を持つ代替関数を提供しておくことをお勧めします。
実行時のデータ表現
型はプログラムがある意味で「正しい」ことをコンパイル時に論証できるようにします。 つまり、その点については壊れることがありません。 しかし、これは何を意味するのでしょうか。 PureScriptでは、式の型は実行時の表現と互換性があることを意味します。
そのため、PureScriptとJavaScriptコードを一緒に効率的に使用できるように、実行時のデータ表現について理解することが重要です。 これはつまり、与えられた任意のPureScriptの式について、その値が実行時にどのように評価されるかという挙動を理解できるべきだということです。
幸いにもPureScriptの式はとりわけ実行時に単純な表現を持っています。 型を考慮すれば式の実行時のデータ表現を把握することが常に可能です。
単純な型については、対応関係はほとんど自明です。
例えば式が型 Boolean
を持っていれば、実行時のその値 v
は typeof v === 'boolean'
を満たします。
つまり、型 Boolean
の式は true
もしくは false
のどちらか一方の(JavaScriptの)値へと評価されます。
特にnull
や undefined
に評価される型Boolean
なPureScriptの式はありません。
Int
やNumber
やString
の型の式についても似た法則が成り立ちます。
Int
やNumber
型の式はnullでないJavaScriptの数へと評価されますし、String
型の式はnullでないJavaScriptの文字列へと評価されます。
typeof
を使った場合に型Number
の値と見分けがつかなくなるにせよ、型Int
の式は実行時に整数に評価されます。
Unit
についてはどうでしょうか。
Unit
には現住 (unit
) が1つのみで値が観測できないため、実のところ実行時に何で表現されるかは重要ではありません。
古いコードは{}
を使って表現する傾向がありました。
しかし比較的新しいコードではundefined
を使う傾向にあります。
なので、Unit
を表現するのに使うものは何であれ差し支えありませんが、undefined
を使うことが推奨されます(関数から何も返さないときもundefined
を返します)。
もっと複雑な型についてはどうでしょうか。
既に見てきたように、PureScriptの関数は引数が1つのJavaScriptの関数に対応しています。
厳密に言えばこうなります。
ある型a
とb
について、式f
の型がa -> b
で、式x
が型a
についての適切な実行時表現の値へと評価されるとします。
このときf
はJavaScriptの関数へと評価されますが、この関数はx
を評価した結果にf
を適用すると型b
の適切な実行時表現を持ちます。
単純な例としては、String -> String
型の式は、nullでないJavaScript文字列からnullでないJavaScript文字列への関数へと評価されます。
ご想像の通り、PureScriptの配列はJavaScriptの配列に対応しています。
しかし、PureScriptの配列は均質である、つまり全ての要素が同じ型を持っていることは覚えておいてください。
具体的には、もしPureScriptの式e
が何らかの型a
について型Array a
を持つなら、e
は(nullでない)JavaScript配列へと評価されます。
この配列の全ての要素は型a
の適切な実行時表現を持ちます。
PureScriptのレコードがJavaScriptのオブジェクトへと評価されることは既に見てきました。 関数や配列の場合のように、そのラベルに関連付けられている型を考慮すれば、レコードのフィールド中のデータの実行時の表現について論証できます。 勿論、レコードのフィールドは、同じ型である必要はありません。
ADTの表現
代数的データ型の全ての構築子について、PureScriptコンパイラは関数を定義することで新たなJavaScriptオブジェクト型を作成します。 これらの構築子はプロトタイプに基づいて新しいJavaScriptオブジェクトを作成する関数に対応しています。
例えば次のような単純なADTを考えてみましょう。
data ZeroOrOne a = Zero | One a
PureScriptコンパイラは、次のようなコードを生成します。
function One(value0) {
this.value0 = value0;
};
One.create = function (value0) {
return new One(value0);
};
function Zero() {
};
Zero.value = new Zero();
ここで2つのJavaScriptオブジェクト型Zero
とOne
を見てください。
JavaScriptのキーワードnew
を使用すると、各型の値を作成できます。
引数を持つ構築子については、コンパイラはvalue0
、value1
などという名前のフィールドに、対応するデータを格納します。
PureScriptコンパイラは補助関数も生成します。
引数のない構築子については、コンパイラは構築子が使われるたびに new
演算子を使うのではなく、データを再利用できるように
value
プロパティを生成します。
1つ以上の引数を持つ構築子では、コンパイラは適切な表現を持つ引数を取り適切な構築子を適用する create
関数を生成します。
1引数より多く取る構築子についてはどうでしょうか。 その場合でも、PureScriptコンパイラは新しいオブジェクト型と補助関数を作成します。 ただしこの場合、補助関数は2引数のカリー化された関数です。 例えば次のような代数的データ型を考えます。
data Two a b = Two a b
このコードからは、次のようなJavaScriptコードが生成されます。
function Two(value0, value1) {
this.value0 = value0;
this.value1 = value1;
};
Two.create = function (value0) {
return function (value1) {
return new Two(value0, value1);
};
};
ここで、オブジェクト型Two
の値はキーワードnew
またはTwo.create
関数を使用すると作成できます。
newtypeの場合はまた少し異なります。 newtypeは代数的データ型のようなもので、単一の引数を取る単一の構築子を持つよう制限されていたことを思い出してください。 この場合、newtypeの実行時表現は、その引数の型と同じになります。
例えば、以下の電話番号を表すnewtypeは実行時にJavaScriptの文字列として表現されます。
newtype PhoneNumber = PhoneNumber String
newtypeは、関数呼び出しによる実行時のオーバーヘッドなく更なる型安全性のための層を提供するため、ライブラリを設計するのに便利です。
量化された型の表現
量化された型(多相型)の式は、実行時は制限された表現になっています。 実際、所与の量化された型を持つ式がより少なくなりますが、それによりかなり効率的に推論できるのです。
例えば、次の多相型を考えてみます。
forall a. a -> a
この型を持っている関数にはどんなものがあるでしょうか。 実は少なくとも1つ、この型を持つ関数が存在します。
identity :: forall a. a -> a
identity a = a
なお、
Prelude
に定義された実際のidentity
関数は僅かに違った型を持ちます。
実のところ、identity
関数はこの型の唯一の(全)関数です。
これは確かに間違いなさそうに思えますが(この型を持った id
とは明らかに異なる式を書こうとしてみてください)、確かめるにはどうしたらいいでしょうか。
型の実行時表現を考えることによって確かめられます。
量化された型 forall a. t
の実行時表現はどうなっているのでしょうか。さて、この型の実行時表現を持つ任意の式は、型 a
をどのように選んでも型 t
の適切な実行時表現を持っていなければなりません。上の例では、型 forall a. a -> a
の関数は、 String -> String
、 Number -> Number
、 Array Boolean -> Array Boolean
などといった型について、適切な実行時表現を持っていなければなりません。 これらは、文字列から文字列、数から数の関数でなくてはなりません。
しかし、それだけでは充分ではありません。 量化された型の実行時表現は、これよりも更に厳しいものです。 任意の式がパラメトリック多相的であることを要求しています。 つまり、その実装において、引数の型についてのどんな情報も使うことができないのです。 この追加の条件は、以下のJavaScriptの関数のような問題のある実装が多相型に現住することを防止します。
function invalid(a) {
if (typeof a === 'string') {
return "Argument was a string.";
} else {
return a;
}
}
確かにこの関数は文字列を取って文字列を返し、数を取って数を返す、といったものです。
しかしこの関数は追加条件を満たしていません。
引数の実行時の型を調べており、型forall a. a -> a
の正しい現住にはならないからです。
関数の引数の実行時の型を検査できなければ、唯一の選択肢は引数をそのまま返すことだけです。
したがってid
は確かにforall a. a -> a
の唯一の現住なのです。
パラメトリック多相とパラメトリック性についての詳しい議論は本書の範囲を超えています。 ただ、PureScriptの型は実行時に消去されており、PureScriptの多相関数は(FFIを使わない限り)引数の実行時表現を検査できないため、この多相的なデータの表現が適切になっているという点にはご留意ください。
制約のある型の表現
型クラス制約を持つ関数は、実行時に面白い表現を持っています。 関数の挙動はコンパイラによって選ばれた型クラスのインスタンスに依存する可能性があるため、関数には型クラス辞書と呼ばれる追加の引数が与えられます。 この辞書には選ばれたインスタンスから提供される型クラスの関数の実装が含まれます。
例えば以下は、Show
型クラスを使う制約付きの型を持つ、単純なPureScript関数です。
shout :: forall a. Show a => a -> String
shout a = show a <> "!!!"
生成されるJavaScriptは次のようになります。
var shout = function (dict) {
return function (a) {
return show(dict)(a) + "!!!";
};
};
shout
は1引数ではなく、2引数の(カリー化された)関数にコンパイルされていることに注意してください。最初の引数 dict
は
Show
制約の型クラス辞書です。 dict
には型 a
の show
関数の実装が含まれています。
最初の引数として明示的にData.Show
の型クラス辞書を渡すと、JavaScriptからこの関数を呼び出すことができます。
import { showNumber } from 'Data.Show'
shout(showNumber)(42);
演習
-
(簡単)これらの型の実行時の表現は何でしょうか。
forall a. a forall a. a -> a -> a forall a. Ord a => Array a -> Boolean
これらの型を持つ式についてわかることは何でしょうか。
-
(普通)
spago build
を使ってコンパイルし、NodeJSのimport
機能を使ってモジュールをインポートすることで、JavaScriptからarrays
ライブラリの関数を使ってみてください。 手掛かり:生成されたCommonJSモジュールがNodeJSモジュールのパスで使用できるように、出力パスを設定する必要があります。
副作用の表現
Effect
モナドも外部型として定義されています。
その実行時表現はとても単純です。
型Effect a
の式は引数なしのJavaScript関数へと評価されます。
この関数はあらゆる副作用を実行し、型a
の適切な実行時表現を持つ値を返します。
Effect
型構築子の定義は、 Effect
モジュールで次のように与えられています。
foreign import data Effect :: Type -> Type
簡単な例として、 random
パッケージで定義される random
関数を考えてみてください。その型は次のようなものでした。
foreign import random :: Effect Number
random
関数の定義は次のように与えられます。
export const random = Math.random;
random
関数は実行時には引数なしの関数として表現されていることに注目してください。
この関数は乱数生成という副作用を実行して返しますが、返り値はNumber
型の実行時表現と一致します。
Number
型はnullでないJavaScriptの数です。
もう少し興味深い例として、console
パッケージ中のEffect.Console
モジュールで定義された log
関数を考えてみましょう。
log
関数は次の型を持っています。
foreign import log :: String -> Effect Unit
この定義は次のようになっています。
export const log = function (s) {
return function () {
console.log(s);
};
};
実行時の log
の表現は、単一の引数のJavaScript関数で、引数なしの関数を返します。
内側の関数はコンソールに文言を書き込むという副作用を実行します。
Effect a
型の式は、通常のJavaScriptのメソッドのようにJavaScriptから呼び出すことができます。例えば、この
main
関数は何らかの型a
についてEffect a
という型でなければならないので、次のように実行できます。
import { main } from 'Main'
main();
spago bundle-app --to
またはspago run
を使用する場合、Main
モジュールが定義されている場合は常に、このmain
の呼び出しを自動的に生成できます。
モナドな冒険
この章の目標
この章の目標はモナド変換子について学ぶことです。 モナド変換子は異なるモナドから提供された副作用を合成する方法を提供します。 動機付けとする例は、NodeJSのコンソール上で遊ぶことができるテキストアドベンチャーゲームです。 ゲームの様々な副作用(ロギング、状態、及び構成)が全てモナド変換子スタックによって提供されます。
プロジェクトの準備
このモジュールのプロジェクトでは以下の新しい依存関係が導入されます。
ordered-collections
は不変のマップと集合のためのデータ型を提供しますtransformers
は標準的なモナド変換子の実装を提供しますnode-readline
はNodeJSが提供するreadline
インターフェイスへのFFIバインディングを提供しますoptparse
はコマンドライン引数を処理するアプリカティブ構文解析器を提供します
ゲームの遊びかた
プロジェクトを走らせるにはspago run
を使います。
既定では使い方の文言が表示されます。
Monadic Adventures! A game to learn monad transformers
Usage: run.js (-p|--player <player name>) [-d|--debug]
Play the game as <player name>
Available options:
-p,--player <player name>
The player's name <String>
-d,--debug Use debug mode
-h,--help Show this help text
コマンドライン引数を与えるためには、追加の引数を直接アプリケーションに渡す-a
オプション付きでspago run
を呼び出すか、spago bundle-app
とすればよいです。
2つ目の方法ではnode
で直接走らせられるindex.jsファイルが作られます。
例えば-p
オプションを使ってプレイヤー名を与えるには次のようにします。
$ spago run -a "-p Phil"
>
$ spago bundle-app
$ node index.js -p Phil
>
プロンプトからは、look
、inventory
、take
、use
、north
、south
、east
、west
などのコマンドを入力できます。
debug
コマンドもあり、--debug
コマンドラインオプションを与えられていた場合に、ゲームの状態を出力できます。
ゲームは2次元の碁盤の目の上が舞台で、コマンドnorth
、south
、east
、west
を発行することによってプレイヤーが移動します。
ゲームにはアイテムの集まりがあり、プレイヤーの所有物であったり、ゲームの盤上の特定の位置にあったりします。
take
コマンドを使うと、プレイヤーはアイテムを拾い上げられます。
参考までに、このゲームのひと通りの流れは次のようになります。
$ spago run -a "-p Phil"
> look
You are at (0, 0)
You are in a dark forest. You see a path to the north.
You can see the Matches.
> take Matches
You now have the Matches
> north
> look
You are at (0, 1)
You are in a clearing.
You can see the Candle.
> take Candle
You now have the Candle
> inventory
You have the Candle.
You have the Matches.
> use Matches
You light the candle.
Congratulations, Phil!
You win!
このゲームはとても単純ですが、この章の目的はtransformers
パッケージを使用してこのような種類のゲームを素早く開発できるようにするライブラリを構築することです。
Stateモナド
transformers
パッケージで提供されている幾つかのモナドを眺めることから始めましょう。
最初の例はState
モナドです。
これは純粋なコードで変更可能状態をモデル化する手段を提供します。
既にEffect
モナドによって提供される変更可能状態の手法について見てきました。
State
はその代替を提供します。
State
型構築子は、状態の型s
、及び返り値の型a
という2種類の引数を取ります。
「State
モナド」というように説明はしていますが、実際にはMonad
型クラスのインスタンスが任意の型s
についてのState s
型構築子に対して提供されています。
Control.Monad.State
モジュールは以下のAPIを提供しています。
get :: forall s. State s s
gets :: forall s. (s -> a) -> State s a
put :: forall s. s -> State s Unit
modify :: forall s. (s -> s) -> State s s
modify_ :: forall s. (s -> s) -> State s Unit
なお、ここではこれらのAPIシグネチャはState
型構築子を使った、単純化された形式で表されています。
実際のAPIは本章の後にある「型クラス」節で押さえるMonadState
が関わってきます。
ですからIDEのツールチップやPursuitで違うシグネチャを見たとしても心配しないでください。
例を見てみましょう。
State
モナドの使い方の1つとしては、整数の配列中の値を現在の状態に加えるものが考えられます。
状態の型s
としてInt
を選択し、配列の走査にtraverse_
を使い、配列の各要素についてmodify
を呼び出すと、これを実現できます。
import Data.Foldable (traverse_)
import Control.Monad.State
import Control.Monad.State.Class
sumArray :: Array Int -> State Int Unit
sumArray = traverse_ \n -> modify \sum -> sum + n
Control.Monad.State
モジュールは、State
モナドで計算するための次の3つの関数を提供します。
evalState :: forall s a. State s a -> s -> a
execState :: forall s a. State s a -> s -> s
runState :: forall s a. State s a -> s -> Tuple a s
3つの各関数は型s
の初期状態と型State s a
の計算を引数に取ります。
evalState
は返り値だけを返し、execState
は最終的な状態だけを返し、runState
はTuple a s
型の値として表現された両方を返します。
先ほどの sumArray
関数が与えられているとき、PSCiで execState
を使うと次のように複数の配列内の数字を合計できます。
> :paste
… execState (do
… sumArray [1, 2, 3]
… sumArray [4, 5]
… sumArray [6]) 0
… ^D
21
演習
-
(簡単)上の例で、
execState
をrunState
やevalState
で置き換えると結果はどうなるでしょうか。 -
(普通)括弧からなる文字列が平衡しているのは、0個以上のより短い平衡した文字列を連結したものか、より短い平衡した文字列を一対の括弧で囲んだものかの何れかです。
State
モナドとtraverse_
関数を使用して、次のような関数を書いてください。testParens :: String -> Boolean
これは、まだ閉じられていない開括弧の数を把握することで、括弧の
String
が平衡しているかどうかを調べる関数です。 この関数は次のように動作します。> testParens "" true > testParens "(()(())())" true > testParens ")" false > testParens "(()()" false
手掛かり:入力の文字列を文字の配列に変換するのに、
Data.String.CodeUnits
モジュールのtoCharArray
関数を使うと良いでしょう。
Readerモナド
transformers
パッケージでは Reader
というモナドも提供されています。
このモナドは大域的な設定を読み取る機能を提供します。
State
モナドが1つの可変状態を読み書きする機能を提供するのに対し、 Reader
モナドは1つのデータの読み取り機能だけを提供します。
Reader
型構築子は、設定の型を表す型 r
、及び戻り値の型 a
の2つの型引数を取ります。
Control.Monad.Reader
モジュールは以下のAPIを提供します。
ask :: forall r. Reader r r
local :: forall r a. (r -> r) -> Reader r a -> Reader r a
ask
動作は現在の設定を読み取るために使い、local
動作は変更された設定で計算するために使います。
例えば、権限で制御するアプリケーションを開発しており、現在の利用者の権限オブジェクトを保持するのに Reader
モナドを使いたいとしましょう。
型 r
を次のようなAPIを備えた型 Permission
として選択できます。
hasPermission :: String -> Permissions -> Boolean
addPermission :: String -> Permissions -> Permissions
利用者が特定の権限を持っているかどうかを確認したいときは、 ask
を使って現在の権限オブジェクトを取得すればいつでも調べることができます。
例えば管理者だけが新しい利用者の作成を許可されているとしましょう。
createUser :: Reader Permissions (Maybe User)
createUser = do
permissions <- ask
if hasPermission "admin" permissions
then map Just newUser
else pure Nothing
local
動作を使うと、計算の実行中に Permissions
オブジェクトを変更し、ユーザーの権限を昇格させることもできます。
runAsAdmin :: forall a. Reader Permissions a -> Reader Permissions a
runAsAdmin = local (addPermission "admin")
こうすると、利用者が admin
権限を持っていなかった場合であっても新しい利用者を作成できるような関数を書くことができます。
createUserAsAdmin :: Reader Permissions (Maybe User)
createUserAsAdmin = runAsAdmin createUser
Reader
モナドを計算するには、大域的な設定を与えるrunReader
関数を使います。
runReader :: forall r a. Reader r a -> r -> a
演習
以下の演習では、 Reader
モナドを使って、字下げのついた文書を出力するための小さなライブラリを作っていきます。
「大域的な設定」は、現在の字下げの深さを示す数になります。
type Level = Int
type Doc = Reader Level String
-
(簡単)現在の字下げの深さで関数を書き出す関数
line
を書いてください。 関数は以下の型を持ちます。line :: String -> Doc
手掛かり:現在の字下げの深さを読み取るためには
ask
関数を使用します。Data.Monoid
のpower
関数も役に立つかもしれません。 -
(普通)
local
関数を使用して次の関数を書いてください。indent :: Doc -> Doc
この関数はコードブロックの字下げを深くします。
-
(普通)
Data.Traversable
で定義されたsequence
関数を使用して、次の関数を書いてください。cat :: Array Doc -> Doc
この関数は文書の集まりを改行で区切って連結します。
-
(普通)
runReader
関数を使用して次の関数を書いてください。render :: Doc -> String
この関数は文書を文字列として出力します。
これでライブラリを使って以下のような単純な文書を書けるようになりました。
render $ cat
[ line "Here is some indented text:"
, indent $ cat
[ line "I am indented"
, line "So am I"
, indent $ line "I am even more indented"
]
]
Writerモナド
Writer
モナドでは、計算の返り値に加えてもう1つの値を累算できます。
よくある使い方としては型String
もしくはArray String
でログを累算していくというものなどがありますが、Writer
モナドはこれよりもっと一般的なものです。
累算するのに任意のモノイドの値を使うことができるので、Additive Int
モノイドを使って整数の合計を追跡するのに使ったり、Disj Boolean
モノイドを使って途中のBoolean
値の何れかが真であるかどうかを追跡するのに使えます。
Writer
型構築子は2つの型引数を取ります。
Monoid
型クラスのインスタンスである型w
と返り値の型a
です。
Writer
のAPIで重要なのは tell
関数です。
tell :: forall w a. Monoid w => w -> Writer w Unit
tell
動作は与えられた値を現在の累算結果に付け加えます。
例として、Array String
モノイドを使用して、既存の関数にログを追加してみましょう。
最大公約数関数の以前の実装を考えてみます。
gcd :: Int -> Int -> Int
gcd n 0 = n
gcd 0 m = m
gcd n m = if n > m
then gcd (n - m) m
else gcd n (m - n)
Writer (Array String) Int
へと返り値の型を変更することで、この関数にログ機能を追加できます。
import Control.Monad.Writer
import Control.Monad.Writer.Class
gcdLog :: Int -> Int -> Writer (Array String) Int
各手順での2つの入力を記録するためには、少し関数を変更する必要があります。
gcdLog n 0 = pure n
gcdLog 0 m = pure m
gcdLog n m = do
tell ["gcdLog " <> show n <> " " <> show m]
if n > m
then gcdLog (n - m) m
else gcdLog n (m - n)
Writer
モナドを計算するには、execWriter
関数またはrunWriter
関数の何れかを使います。
execWriter :: forall w a. Writer w a -> w
runWriter :: forall w a. Writer w a -> Tuple a w
ちょうど State
モナドの場合と同じように、 execWriter
が累算されたログだけを返すのに対して、
runWriter
は累算されたログと結果の両方を返します。
PSCiで改変した関数を試してみましょう。
> import Control.Monad.Writer
> import Control.Monad.Writer.Class
> runWriter (gcdLog 21 15)
Tuple 3 ["gcdLog 21 15","gcdLog 6 15","gcdLog 6 9","gcdLog 6 3","gcdLog 3 3"]
演習
-
(普通)
Writer
モナドとmonoid
パッケージのAdditive Int
モノイドを使うように、上のsumArray
関数を書き換えてください。 -
(普通)コラッツ関数は自然数 \( n \) 上で定義され、 \( n \) が偶数なら \( n / 2 \)、 \( n \) が奇数なら \( 3 n + 1 \)です。 例えば \( 10 \) で始まるコラッツ数列は以下です。
10, 5, 16, 8, 4, 2, 1, ...
コラッツ関数の有限回の適用を繰り返すと、コラッツ数列は必ず最終的に \( 1 \) になるということが予想されています。
再帰を使い、数列が \( 1 \) に到達するまでに何回のコラッツ関数の適用が必要かを計算する関数を書いてください。
Writer
モナドを使用してコラッツ関数の各適用の経過を記録するように、関数を変更してください。
モナド変換子
上の3つのモナド、State
、Reader
、Writer
は、何れもいわゆるモナド変換子の例となっています。
対応する各モナド変換子はそれぞれStateT
、ReaderT
、WriterT
という名前です。
モナド変換子とは何でしょうか。
さて、これまで見てきたように、モナドはPureScriptのコードを何らかの種類の副作用で拡張するものでした。
このモナドはPureScriptで適切な制御子(runState
、 runReader
、runWriter
など)を使って解釈できます。
使用する必要がある副作用が1つだけなら、これで問題ありません。
しかし、同時に複数の副作用を使用できると便利なことがよくあります。
例えば、 Maybe
とReader
を一緒に使用すると、ある大域的な設定の文脈で省略可能な結果を表現できます。
もしくは、 Either
モナドの純粋なエラー追跡機能と、 State
モナドが提供する変更可能な状態が同時に欲しくなるかもしれません。
この問題を解決するのがモナド変換子です。
ただしEffect
モナドがこの問題を部分的に解決することは既に見ました。
モナド変換子はまた違った解決策を提供しますが、これらの各手法には利点と制約があります。
モナド変換子は型と別の型構築子を引数に取る型構築子です。 モナドを1つ取り、独自の様々な副作用を追加した別のモナドへと変換します。
例を見てみましょう。State
のモナド変換子版はControl.Monad.State.Trans
モジュールで定義されているStateT
です。
PSCiを使って StateT
の種を見てみましょう。
> import Control.Monad.State.Trans
> :kind StateT
Type -> (Type -> Type) -> Type -> Type
とても読みにくそうに見えるかもしれませんが、使い方を理解するために、StateT
に1つ引数を与えてみましょう。
State
の場合、最初の型引数は使いたい状態の型です。
それでは型String
を与えてみましょう。
> :kind StateT String
(Type -> Type) -> Type -> Type
次の引数は種 Type -> Type
の型構築子です。
これは StateT
の機能を追加したい元のモナドを表します。
例として、 Either String
モナドを選んでみます。
> :kind StateT String (Either String)
Type -> Type
型構築子が残りました。
最後の引数は戻り値の型を表しており、例えばそれをNumber
にできます。
> :kind StateT String (Either String) Number
Type
最後に種Type
の何かが残りました。
つまりこの型の値を探してみることができます。
構築したモナドStateT String (Either String)
は、エラーで失敗する可能性があり、変更可能な状態を使える計算を表しています。
外側のStateT String
モナドの動作(get
、put
、modify
)は直接使えますが、梱包されているモナド (Either String
) の作用を使うためには、これらの関数をモナド変換子まで「持ち上げ」る必要があります。
Control.Monad.Trans
モジュールはMonadTrans
型クラスを定義しています。
これはモナド変換子であるそうした型構築子を捕捉します。
class MonadTrans t where
lift :: forall m a. Monad m => m a -> t m a
このクラスは単一のメンバーlift
を含みます。
これは通底する任意のモナドm
の計算を取り、梱包されたモナドt m
へと持ち上げるものです。
今回の場合、型構築子t
はStateT String
で、m
はEither String
モナドとなるので、lift
は型Either String a
の計算を、型StateT String (Either String) a
の計算へと持ち上げる方法を提供することになります。
つまり、型Either String a
の計算を使う場合に毎回lift
を使うのであれば、StateT String
とEither String
の作用を使えます。
例えば、次の計算は通底する状態を読み、状態が空文字列であればエラーを投げます。
import Data.String (drop, take)
split :: StateT String (Either String) String
split = do
s <- get
case s of
"" -> lift $ Left "Empty string"
_ -> do
put (drop 1 s)
pure (take 1 s)
状態が空でなければ、この計算はput
を使って状態をdrop 1 s
(つまりs
から最初の文字を取り除いたもの)へと更新し、take 1 s
(s
の最初の文字)を返します。
それではPSCiでこれを試してみましょう。
> runStateT split "test"
Right (Tuple "t" "est")
> runStateT split ""
Left "Empty string"
これはStateT
を使わなくても実装できるので、さほど驚くようなことはありません。
しかし、モナドの中で扱っているので、do記法やアプリカティブコンビネータを使って、小さな計算から大きな計算を構築できます。
例えば、2回split
を適用すると、文字列から最初の2文字を読めます。
> runStateT ((<>) <$> split <*> split) "test"
(Right (Tuple "te" "st"))
split
関数とその他沢山の動作を使えば基本的な構文解析ライブラリを構築できます。
実際、これはparsing
ライブラリで採用されている手法です。
これがモナド変換子の力なのです。
必要な副作用を選択し、do記法とアプリカティブコンビネータで表現力を維持しながら、様々な問題のための特注のモナドを作成できるのです。
ExceptTモナド変換子
transformers
パッケージではExceptT e
モナド変換子も定義されています。
これはEither e
モナドに対応するもので、以下のAPIを提供します。
class MonadError e m where
throwError :: forall a. e -> m a
catchError :: forall a. m a -> (e -> m a) -> m a
instance Monad m => MonadError e (ExceptT e m)
runExceptT :: forall e m a. ExceptT e m a -> m (Either e a)
MonadError
クラスはe
型のエラーを投げたり捕えたりに対応するモナドを捕捉し、ExceptT e
モナド変換子のインスタンスが提供されます。
Either e
モナドのLeft
と同じように、throwError
動作では失敗を示せます。
catchError
動作ではthrowError
を使ってエラーが投げられた後に処理を継続できます。
runExceptT
制御子を使うと、型 ExceptT e m a
を計算できます。
このAPIは exceptions
パッケージの Exception
作用によって提供されているものと似ています。
しかし、幾つかの重要な違いがあります。
Exception
が実際のJavaScriptの例外を使っているのに対してExceptT
モデルは代数的データ型を使っています。Exception
作用がJavaScriptのError
型という1つの例外の型だけを扱うのに対して、ExceptT
はError
型クラスのどんな型のエラーでも扱います。つまり、ExceptT
では新たなエラー型を自由に定義できます。
試しにExceptT
を使ってWriter
モナドを包んでみましょう。
ここでもモナド変換子ExceptT e
の動作を直接使うことも自由にできますが、Writer
モナドの計算はlift
を使って持ち上げるべきです。
import Control.Monad.Except
import Control.Monad.Writer
writerAndExceptT :: ExceptT String (Writer (Array String)) String
writerAndExceptT = do
lift $ tell ["Before the error"]
_ <- throwError "Error!"
lift $ tell ["After the error"]
pure "Return value"
PSCiでこの関数を試すと、ログの蓄積とエラーの送出という2つの作用がどのように相互作用しているのかを見ることができます。
まず、 runExceptT
を使って外側のExceptT
を計算し、型 Writer (Array String) (Either String String)
の結果を残します。
それから、 runWriter
で内側のWriter
を計算します。
> runWriter $ runExceptT writerAndExceptT
Tuple (Left "Error!") ["Before the error"]
なお、エラーが投げられる前に書き出されるログ文言だけがログに追記されます。
モナド変換子スタック
これまで見てきたように、モナド変換子を使って既存のモナドを土台に新しいモナドを構築できます。
何かのモナド変換子t1
とモナドm
について、その適用t1 m
もまたモナドになります。
つまり、2つめのモナド変換子t2
を結果t1 m
に適用すると、3つ目のモナドt2 (t1 m)
を作れます。
このようにしてモナド変換子のスタックを構築できます。
これは構成されるモナドによって提供される副作用を組み合わせるものです。
実際には、通底するモナドm
は、ネイティブの副作用が必要ならEffect
モナド、さもなくばData.Identity
モジュールで定義されているIdentity
モナドになります。
Identity
モナドは何の新しい副作用も追加しませんから、Identity
モナドの変換はモナド変換子の作用だけを提供します。
State
、Reader
、Writer
モナドは、Identity
モナドをそれぞれStateT
、ReaderT
、WriterT
で変換することによって実装されています。
3つの副作用が組み合わっている例を見てみましょう。
Identity
モナドをスタックの底にして、StateT
、WriterT
、ExceptT
作用を使います。
このモナド変換子スタックは、可変状態、ログの蓄積、そして純粋なエラーの副作用を提供します。
このモナド変換子スタックを使うと、ロギングの機能が追加された split
アクションに作り変えられます。
type Errors = Array String
type Log = Array String
type Parser = StateT String (WriterT Log (ExceptT Errors Identity))
split :: Parser String
split = do
s <- get
lift $ tell ["The state is " <> s]
case s of
"" -> lift $ lift $ throwError ["Empty string"]
_ -> do
put (drop 1 s)
pure (take 1 s)
この計算をPSCiで試してみると、 split
が実行されるたびに状態がログに追加されることがわかります。
なお、モナド変換子スタックに現れる順序で副作用を取り除いていかなければなりません。
最初にStateT
型構築子を取り除くためにrunStateT
を、それからruntWriteT
、runExceptT
を使います。
最後にunwrap
を使用してIdentity
モナド中で計算します。
> runParser p s = unwrap $ runExceptT $ runWriterT $ runStateT p s
> runParser split "test"
(Right (Tuple (Tuple "t" "est") ["The state is test"]))
> runParser ((<>) <$> split <*> split) "test"
(Right (Tuple (Tuple "te" "st") ["The state is test", "The state is est"]))
しかし、状態が空であることが理由で解析が失敗した場合、ログは全く出力されません。
> runParser split ""
(Left ["Empty string"])
これは、ExceptT
モナド変換子が提供する副作用とWriterT
モナド変換子が提供する副作用との関係によるものです。
これはモナド変換子スタックが構成されている順序を変更することで対処できます。
スタックの最上部にExceptT
変換子を移動すると、先ほどWriter
をExceptT
に変換したときに見たように、最初のエラーまでに書かれた全ての文言がログに含まれるようになります。
このコードの問題の1つは、複数のモナド変換子の上まで計算を持ち上げるために、lift
関数を複数回使わなければならないということです。
例えばthrowError
の呼び出しは、1回目はWriteT
へ、2回目はStateT
へ、と2回持ちあげなければなりません。
小さなモナド変換子スタックならなんとかなりますが、そのうちすぐに不便になるでしょう。
幸いなことに、これから見るような型クラス推論によって提供されるコードの自動生成を使うと、ほとんどの「重労働」を任せられます。
演習
-
(簡単)
Identity
関手を土台とするExceptT
モナド変換子を使って、2つの数の商を求める関数safeDivide
を書いてください。 この関数は分母がゼロの場合に(文字列「Divide by zero!」の)エラーを投げます。 -
(普通)次のような構文解析関数を書いてください。
string :: String -> Parser String
これは現在の状態が接頭辞に照合するか、もしくはエラー文言とともに失敗します。
この構文解析器は次のように動作します。
> runParser (string "abc") "abcdef" (Right (Tuple (Tuple "abc" "def") ["The state is abcdef"]))
手掛かり:出発点として
split
の実装が使えます。stripPrefix
関数も役に立つかもしれません。 -
(難しい)文書表示ライブラリを、
ReaderT
とWriterT
モナド変換子を使用して再実装してください。 以前Reader
モナドを使用して書いたものです。文字列を出力する
line
や文字列を連結するcat
を使うのではなく、WriteT
モナド変換子と一緒にArray String
モノイドを使い、結果へ行を追加するのにtell
を使ってください。 アポストロフィ ('
) を付ける以外は元の実装と同じ名前を使ってください。
型クラスが助けに来たぞっ
本章の最初で扱った State
モナドを見てみると、 State
モナドの動作には次のような型が与えられていました。
get :: forall s. State s s
put :: forall s. s -> State s Unit
modify :: forall s. (s -> s) -> State s Unit
Control.Monad.State.Class
モジュールで与えられている型は、実際にはこれよりもっと一般的です。
get :: forall m s. MonadState s m => m s
put :: forall m s. MonadState s m => s -> m Unit
modify :: forall m s. MonadState s m => (s -> s) -> m Unit
Control.Monad.State.Class
モジュールにはMonadState
(多変数)型クラスが定義されています。
この型クラスは「変更可能な状態を提供する純粋なモナド」への抽象化を可能にします。
予想できると思いますが、 State s
型構築子は MonadState s
型クラスのインスタンスになっており、このクラスには他にも興味深いインスタンスが数多くあります。
特に、transformers
パッケージではモナド変換子WriterT
、ReaderT
、ExceptT
についてのMonadState
のインスタンスがあります。
通底するMonad
がMonadState
インスタンスを持つなら常に、これらもインスタンスを持ちます。
つまり実際には、StateT
がモナド変換子スタックのどこかに現れ、StateT
より上の全てがMonadState
のインスタンスであれば、lift
を使う必要なくget
やput
やmodify
を直接自由に使用できます。
当然ですが、これまで扱ってきたReaderT
、WriterT
、ExceptT
変換子についても、同じことが言えます。
transformers
では主な各変換子について型クラスが定義されています。
これによりそれらの操作に対応するモナドの上に抽象化できるのです。
上のsplit
関数の場合、構築したモナドスタックは各型クラスMonadState
、MonadWriter
、MonadError
のインスタンスです。
つまりlift
は全く呼び出す必要がないのです。
まるでモナドスタック自体に定義されていたかのように、動作get
、put
、tell
、throwError
をそのまま使用できます。
split :: Parser String
split = do
s <- get
tell ["The state is " <> show s]
case s of
"" -> throwError ["Empty string"]
_ -> do
put (drop 1 s)
pure (take 1 s)
この計算は、独自のプログラミング言語を拡張し、可変状態、ロギング、エラー処理という3つの新しい副作用に対応したように見えます。 しかし、内部的には全てはあくまで純粋な関数と不変のデータを使って実装されているのです。
代替
control
パッケージでは失敗しうる計算を扱う抽象化が数多く定義されています。
その1つはAlternative
型クラスです。
class Functor f <= Alt f where
alt :: forall a. f a -> f a -> f a
class Alt f <= Plus f where
empty :: forall a. f a
class (Applicative f, Plus f) <= Alternative f
Alternative
は2つの新しいコンビネータを提供しています。
1つは失敗しうる計算の雛形を提供するempty
値で、もう1つはエラーが起きたときに代替の計算へ戻ってやり直す機能を提供するalt
関数(そしてその別名<|>
)です。
Data.Array
モジュールでは Alternative
型クラスで型構築子を操作する2
つの便利な関数を提供します。
many :: forall f a. Alternative f => Lazy (f (Array a)) => f a -> f (Array a)
some :: forall f a. Alternative f => Lazy (f (Array a)) => f a -> f (Array a)
Data.List
にも等価なmany
とsome
があります。
many
コンビネータは計算をゼロ回以上繰り返し実行するためにAlternative
型クラスを使います。
some
コンビネータも似ていますが、最低1回は計算が成功する必要があります。
Parser
モナド変換子スタックの場合は、ExceptT
コンポーネントによるAlternative
のインスタンスがあります。
このコンポーネントでは異なる分枝のエラーにMonoid
インスタンスを使って組み合わせることによって対応しています(だからErrors
型にArray String
を選ぶ必要があったんですね)。
これは、構文解析器を複数回実行するのにmany
関数とsome
関数を使うことができることを意味します。
> import Data.Array (many)
> runParser (many split) "test"
(Right (Tuple (Tuple ["t", "e", "s", "t"] "")
[ "The state is \"test\""
, "The state is \"est\""
, "The state is \"st\""
, "The state is \"t\""
]))
ここでは入力文字列 "test"
は、1文字からなる文字列4つの配列を返すように繰り返し分割されています。
残った状態は空で、ログは split
コンビネータが4回適用されたことを示しています。
モナド内包表記
Control.MonadPlus
モジュールには MonadPlus
と呼ばれるAlternative
型クラスの副クラスが定義されています。
MonadPlus
はモナドとAlternative
両方のインスタンスである型構築子を取ります。
class (Monad m, Alternative m) <= MonadPlus m
実際、Parser
モナドは MonadPlus
のインスタンスです。
以前本書中で配列内包表記を押さえたとき、guard
関数を導入しました。
これは欲しくない結果を取り除けるのに使えました。
実際にはguard
関数はもっと一般的で、MonadPlus
のインスタンスである任意のモナドに対して使えます。
guard :: forall m. Alternative m => Boolean -> m Unit
<|>
演算子は失敗時にバックトラッキングできるようにします。
これがどのように役立つかを見るために、大文字だけに照合するsplit
コンビネータの亜種を定義してみましょう。
upper :: Parser String
upper = do
s <- split
guard $ toUpper s == s
pure s
ここで、文字列が大文字でない場合に失敗するよう、guard
を使用しています。
なお、このコードは前に見た配列内包表記とよく似ています。
このようにMonadPlus
を使うことは、モナド内包表記の構築と呼ばれることがあります。
バックトラッキング
<|>
演算子を使うと、失敗したときに別の代替計算へとバックトラックできます。
これを確かめるために、小文字に一致するもう1つの構文解析器を定義してみましょう。
lower :: Parser String
lower = do
s <- split
guard $ toLower s == s
pure s
これにより、まずもし最初の文字が大文字なら複数の大文字に照合し、さもなくばもし最初の文字が小文字なら複数の小文字に照合する、という構文解析器を定義できます。
> upperOrLower = some upper <|> some lower
この構文解析器は、大文字と小文字が切り替わるまで、文字に照合し続けます。
> runParser upperOrLower "abcDEF"
(Right (Tuple (Tuple ["a","b","c"] ("DEF"))
[ "The state is \"abcDEF\""
, "The state is \"bcDEF\""
, "The state is \"cDEF\""
]))
また、many
を使うと文字列を小文字と大文字の要素に完全に分割できます。
> components = many upperOrLower
> runParser components "abCDeFgh"
(Right (Tuple (Tuple [["a","b"],["C","D"],["e"],["F"],["g","h"]] "")
[ "The state is \"abCDeFgh\""
, "The state is \"bCDeFgh\""
, "The state is \"CDeFgh\""
, "The state is \"DeFgh\""
, "The state is \"eFgh\""
, "The state is \"Fgh\""
, "The state is \"gh\""
, "The state is \"h\""
]))
繰り返しになりますが、これはモナド変換子が齎す再利用性の威力を示しています。 標準的な抽象化を再利用することで、宣言型スタイルのバックトラック構文解析器を、ほんの数行のコードで書くことができました。
演習
-
(簡単)
string
構文解析器の実装からlift
関数の呼び出しを取り除いてください。 新しい実装の型検査が通ることを確認し、そうなることを納得するまで確かめましょう。 -
(普通)
string
構文解析器とsome
コンビネータを使って構文解析器asFollowedByBs
を書いてください。 これは文字列"a"
の連続と、それに続く文字列"b"
の連続からなる文字列を認識するものです。 -
(普通)
<|>
演算子を使って構文解析器asOrBs
を書いてください。 これは文字a
と文字b
が任意の順序で現れる文字列を認識します。 -
(難しい)
Parser
モナドを次のようにも定義できます。type Parser = ExceptT Errors (StateT String (WriterT Log Identity))
このように変更すると、構文解析関数にどのような影響を与えるでしょうか。
RWSモナド
モナド変換子のとある特定の組み合わせは頻出なので、transformers
パッケージ内の単一のモナド変換子として提供されています。
Reader
、Writer
、State
のモナドは、Reader-Writer-Stateモナドに組み合わさり、より単純にRWS
モナドともされます。
このモナドはRWST
モナド変換子という名前の、対応するモナド変換子を持ちます。
ここでは RWS
モナドを使ってテキストアドベンチャーゲームの処理を設計していきます。
RWS
モナドは(戻り値の型に加えて)3つの型変数を使って定義されています。
type RWS r w s = RWST r w s Identity
なお、RWS
モナドは基底のモナドをIdentity
に設定することで独自のモナド変換子として定義されています。
Identity
は副作用を提供しないのでした。
最初の型引数r
は大域的な構成の型を表します。
2つ目のw
はログを蓄積するために使用するモノイドを表します。
3つ目のs
は可変状態の型です。
このゲームの場合には、大域的な設定は
Data.GameEnvironment
モジュールのGameEnvironment
という名前の型で定義されています。
type PlayerName = String
newtype GameEnvironment = GameEnvironment
{ playerName :: PlayerName
, debugMode :: Boolean
}
プレイヤー名と、ゲームがデバッグモードで動作しているか否かを示すフラグが定義されています。 モナド変換子を実行するとなると、これらのオプションがコマンドラインから設定されます。
可変状態は Data.GameState
モジュールの GameState
と呼ばれる型で定義されています。
import Data.Map as M
import Data.Set as S
newtype GameState = GameState
{ items :: M.Map Coords (S.Set GameItem)
, player :: Coords
, inventory :: S.Set GameItem
}
Coords
データ型は2次元平面の点を表し、 GameItem
データ型はゲーム内のアイテムの列挙です。
data GameItem = Candle | Matches
GameState
型は2つの新しいデータ構造を使っています。
Map
とSet
はそれぞれ整列されたマップと整列された集合を表します。
items
属性は、ゲーム平面上の座標からその位置にあるゲームアイテムの集合への対応付けです。
player
属性はプレイヤーの現在の座標を格納し、inventory
属性は現在プレイヤーが保有するゲームアイテムの集合を格納します。
Map
とSet
のデータ構造はキーによって整列され、このキーにはOrd
型クラスの任意の型を使えます。
つまりデータ構造中のキーは完全に順序付けされます。
ゲームの動作を書く上でMap
とSet
構造をどのように使っていくのかを見ていきます。
ログとしては List String
モノイドを使います。
Game
モナド用の型同義語を定義し、RWS
を使って実装できます。
type Log = L.List String
type Game = RWS GameEnvironment Log GameState
ゲームロジックの実装
Reader
、Writer
、State
モナドの動作を再利用することで、Game
モナドで定義されている単純な動作を組み合わせてゲームを構築していきます。
このアプリケーションの最上位ではGame
モナドで純粋に計算しており、Effect
モナドは結果からコンソールにテキストを出力するような観測可能な副作用へと変換するために使っています。
このゲームで最も簡単な動作の1つは has
動作です。
この動作はプレイヤーの持ち物に特定のゲームアイテムが含まれているかどうかを調べます。
これは次のように定義されます。
has :: GameItem -> Game Boolean
has item = do
GameState state <- get
pure $ item `S.member` state.inventory
この関数は、現在のゲームの状態を読み取るためにMonadState
型クラスで定義されているget
動作を使っています。
それから指定したGameItem
が持ち物のアイテムのSet
に出現するかどうかを調べるためにData.Set
で定義されているmember
関数を使っています。
他にもpickUp
動作があります。
現在の位置にゲームアイテムがある場合、プレイヤーの持ち物にそのアイテムを追加します。
これにはMonadWriter
とMonadState
型クラスの動作を使っています。
一番最初に現在のゲームの状態を読み取ります。
pickUp :: GameItem -> Game Unit
pickUp item = do
GameState state <- get
次に pickUp
は現在の位置にあるアイテムの集合を検索します。
これはData.Map
で定義された lookup
関数を使って行います。
case state.player `M.lookup` state.items of
lookup
関数はMaybe
型構築子で示される省略可能な結果を返します。
lookup
関数は、キーがマップにない場合はNothing
を返します。
それ以外の場合はJust
構築子で対応する値を返します。
関心があるのは、指定されたゲームアイテムが対応するアイテムの集合に含まれている場合です。
ここでもmember
関数を使うとこれを調べることができます。
Just items | item `S.member` items -> do
この場合、put
を使ってゲームの状態を更新し、tell
を使ってログに文言を追加できます。
let newItems = M.update (Just <<< S.delete item) state.player state.items
newInventory = S.insert item state.inventory
put $ GameState state { items = newItems
, inventory = newInventory
}
tell (L.singleton ("You now have the " <> show item))
ここで2つの計算のどちらもlift
が必要ないことに注意してください。
なぜならMonadState
とMonadWriter
の両方についてGame
モナド変換子スタック用の適切なインスタンスが存在するからです。
put
への引数では、レコード更新を使ってゲームの状態のitems
及びinventory
フィールドを変更しています。
また、特定のキーの値を変更するData.Map
のupdate
関数を使っています。
今回の場合、プレイヤーの現在の位置にあるアイテムの集合を変更するのに、delete
関数を使って指定したアイテムを集合から取り除いています。
insert
を使って新しいアイテムをプレイヤーの持ち物の集合に加えるときにも、inventory
は更新されます。
最後に、pickUp
関数はtell
を使ってユーザに通知することにより、残りの場合を処理します。
_ -> tell (L.singleton "I don't see that item here.")
Reader
モナドを使う例として、 debug
コマンドのコードを見てみましょう。
ゲームがデバッグモードで実行されている場合、このコマンドを使うとユーザは実行時にゲームの状態を調べることができます。
GameEnvironment env <- ask
if env.debugMode
then do
state :: GameState <- get
tell (L.singleton (show state))
else tell (L.singleton "Not running in debug mode.")
ここでは、ゲームの構成を読み込むためにask
動作を使用しています。
繰り返しますが、どの計算でもlift
する必要がなく、同じdo記法ブロック内でMonadState
、MonadReader
、MonadWriter
型クラスで定義されている動作を使える点に注意してください。
debugMode
フラグが設定されている場合、tell
動作を使うとログに状態が書き込まれます。
そうでなければ、エラー文言が追加されます。
Game
モジュールの残りの部分では同様の動作の集合が定義されています。
各動作はMonadState
、MonadReader
、MonadWriter
型クラスにより定義された動作のみを使っています。
計算の実行
このゲームロジックはRWS
モナドで動くため、ユーザのコマンドに応答するために計算する必要があります。
このゲームのフロントエンドは2つのパッケージで構成されています。
アプリカティブなコマンドライン構文解析を提供するoptparse
と、対話的なコンソールベースのアプリケーションを書くことを可能にする、NodeJSの
readline
モジュールを梱包する node-readline
パッケージです。
このゲームロジックへのインターフェースは Game
モジュール内の関数game
によって提供されます。
game :: Array String -> Game Unit
これを計算するには、ユーザが入力した単語のリストを文字列の配列として渡してから、runRWS
を使って結果のRWS
を計算します。
data RWSResult state result writer = RWSResult state result writer
runRWS :: forall r w s a. RWS r w s a -> r -> s -> RWSResult s a w
runRWS
はrunReader
、runWriter
、runState
を組み合わせたように見えます。
引数として大域的な構成及び初期状態を取り、ログ、結果、最終的な状態を含むデータ構造を返します。
このアプリケーションのフロントエンドは、次の型シグネチャを持つ関数runGame
によって定義されます。
runGame :: GameEnvironment -> Effect Unit
この関数は(node-readline
とconsole
パッケージを使って)コンソールを介してユーザとやり取りします。
runGame
は関数の引数としてのゲームの設定を取ります。
node-readline
パッケージではLineHandler
型が提供されています。
これは端末からのユーザ入力を扱うEffect
モナドの動作を表します。
対応するAPIは次の通りです。
type LineHandler a = String -> Effect a
foreign import setLineHandler
:: forall a
. Interface
-> LineHandler a
-> Effect Unit
Interface
型はコンソールの制御子を表しており、コンソールとやり取りする関数への引数として渡されます。
createConsoleInterface
関数を使用するとInterface
を作成できます。
import Node.ReadLine as RL
runGame env = do
interface <- RL.createConsoleInterface RL.noCompletion
最初の工程はコンソールにプロンプトを設定することです。
interface
制御対象を渡し、プロンプト文字列と字下げレベルを与えます。
RL.setPrompt "> " interface
今回は行制御関数を実装することに関心があります。
ここでの行制御はlet
宣言内の補助関数を使って次のように定義されています。
lineHandler :: GameState -> String -> Effect Unit
lineHandler currentState input = do
case runRWS (game (split (wrap " ") input)) env currentState of
RWSResult state _ written -> do
for_ written log
RL.setLineHandler (lineHandler state) $ interface
RL.prompt interface
pure unit
let
束縛がenv
という名前のゲーム構成やinterface
という名前のコンソール制御対象を包み込んでいます。
この制御子は追加の最初の引数としてゲームの状態を取ります。
ゲームのロジックを実行するためにrunRWS
にゲームの状態を渡さなければならないので、これは必要となっています。
この動作が最初に行うことは、Data.String
モジュールの split
関数を使用して、ユーザーの入力を単語に分割することです。
それから、ゲームの環境と現在のゲームの状態を渡し、 runRWS
を使用して(RWS
モナドで)game
動作を実行しています。
純粋な計算であるゲームロジックを実行するには、画面に全てのログ文言を出力して、ユーザに次のコマンドのためのプロンプトを表示する必要があります。
for_
動作が(List String
型の)ログを走査し、コンソールにその内容を出力するために使われています。
最後にsetLineHandler
を使って行制御関数を更新することでゲームの状態を更新し、prompt
動作を使ってプロンプトを再び表示しています。
runGame
関数は最終的にコンソールインターフェイスに最初の行制御子を取り付けて、初期プロンプトを表示します。
RL.setLineHandler (lineHandler initialGameState) interface
RL.prompt interface
演習
-
(普通)ゲームの格子上にある全てのゲームアイテムをユーザの持ちものに移動する新しいコマンド
cheat
を実装してください。 関数cheat :: Game Unit
をGame
モジュールに作り、この関数をgame
から使ってください。 -
(難しい)
RWS
モナドのWriter
コンポーネントは、エラー文言とお知らせ文言の2つの種類の文言のために使われています。 このため、コードの幾つかの箇所では、エラーの場合を扱うためにcase式を使用しています。コードをリファクタリングしてください。 エラー文言を扱うのに
ExceptT
モナド変換子を使い、お知らせ文言を扱うのにRWS
を使います。 補足:この演習にはテストがありません。
コマンドラインオプションの扱い
このアプリケーションの最後の部品には、コマンドラインオプションの解析とGameEnvironment
設定レコードを作成する役目があります。
このためにはoptparse
パッケージを使用します。
optparse
はアプリカティブなコマンドラインオプション構文解析器の一例です。
アプリカティブ関手を使うと、いろいろな副作用の型を表す型構築子まで任意個数の引数の関数を持ち上げられることを思い出してください。
optparse
パッケージの場合には、コマンドラインオプションからの読み取りの副作用を追加するParser
関手(optparseのモジュールOptions.Applicative
からインポートされたもの。Split
モジュールで定義したParser
と混同しないように)が興味深い関手になっています。
これは次のような制御子を提供しています。
customExecParser :: forall a. ParserPrefs -> ParserInfo a -> Effect a
実例を見るのが一番です。
このアプリケーションの main
関数はcustomExecParser
を使って次のように定義されています。
main = OP.customExecParser prefs argParser >>= runGame
最初の引数はoptparse
ライブラリを設定するために使用されます。
今回の場合、アプリケーションが引数なしで走らされたときは、(「missing argument」エラーを表示する代わりに)OP.prefs OP.showHelpOnEmpty
を使って使用方法の文言を表示するように設定していますが、Options.Applicative.Builder
モジュールには他にも幾つかの選択肢を提供しています。
2つ目の引数は解析プログラムの完全な説明です。
argParser :: OP.ParserInfo GameEnvironment
argParser = OP.info (env <**> OP.helper) parserOptions
parserOptions = fold
[ OP.fullDesc
, OP.progDesc "Play the game as <player name>"
, OP.header "Monadic Adventures! A game to learn monad transformers"
]
ここでOP.info
は、Parser
をヘルプ文言の書式方法のためのオプションの集合と組み合わせます。
env <**> OP.helper
はenv
と名付けられた任意のコマンドライン引数Parser
を取り、自動的に--help
オプションを加えます。
ヘルプ文言用のオプションは型がInfoMod
であり、これはモノイドなので、fold
関数を使って複数のオプションを一緒に追加できます。
解析器の面白い部分はGameEnvironment
の構築にあります。
env :: OP.Parser GameEnvironment
env = gameEnvironment <$> player <*> debug
player :: OP.Parser String
player = OP.strOption $ fold
[ OP.long "player"
, OP.short 'p'
, OP.metavar "<player name>"
, OP.help "The player's name <String>"
]
debug :: OP.Parser Boolean
debug = OP.switch $ fold
[ OP.long "debug"
, OP.short 'd'
, OP.help "Use debug mode"
]
player
とdebug
は両方ともParser
なので、アプリカティブ演算子<$>
と<*>
を使ってgameEnvironment
関数を持ち上げられます。
この関数はParser
上で型PlayerName -> Boolean -> GameEnvironment
を持ちます。
OP.strOption
は文字列値を期待するコマンドラインオプションを構築し、一緒に畳み込まれたMod
の集まりを介して構成されています。
OP.flag
は似たような動作をしますが、関連付けられた値は期待しません。
optparse
は多様なコマンドライン解析器を構築するために使える様々な修飾子について、大部のドキュメントを提供しています。
アプリカティブ演算子による記法を使うことで、コマンドラインインターフェイスの簡潔で宣言的な仕様を与えられた点に注目です。
加えて、新しいコマンドライン引数を追加するのは単純で、runGame
に新しい関数引数を追加し、env
の定義中で<*>
を使って追加の引数までrunGame
を持ち上げるだけでできます。
演習
- (普通)
GameEnvironment
レコードに新しい真偽値のプロパティcheatMode
を追加してください。 また、optparse
の構成に、チートモードを有効にする新しいコマンドラインフラグ-c
を追加してください。 チートモードが有効になっていない場合、前の演習のcheat
コマンドは禁止されます。
まとめ
この章ではこれまで学んできた技術を実践的に実演しました。
モナド変換子を使用したゲームの純粋な仕様の構築、コンソールを使用したフロントエンドを構築するためのEffect
モナドがそれです。
ユーザインターフェイスからの実装を分離したので、ゲームの別のフロントエンドも作成できるでしょう。
例えば、Effect
モナドでCanvas APIやDOMを使用して、ブラウザでゲームを描画するようなことができるでしょう。
モナド変換子によって命令型のスタイルで安全なコードを書けることが分かりました。
このスタイルでは型システムによって作用が追跡されています。
加えて、型クラスはモナドが提供する動作へと抽象化する強力な方法を提供し、これによりコードの再利用が可能になりました。
標準的なモナド変換子を組み合わせることにより、Alternative
やMonadPlus
のような標準的な抽象化を使用して、役に立つモナドを構築できました。
モナド変換子は表現力の高いコードの優れた実演となっています。 これは高階多相や多変数型クラスなどの高度な型システムの機能を利用することによって記述できるものです。
Canvasグラフィックス
この章の目標
この章はcanvas
パッケージに焦点を当てる発展的な例となります。
このパッケージはPureScriptでHTML5のCanvas APIを使用して2Dグラフィックスを生成する手段を提供します。
プロジェクトの準備
このモジュールのプロジェクトでは以下の新しい依存関係が導入されます。
canvas
はHTML5のCanvas APIメソッドの型を与えます。refs
は 大域的な変更可能領域への参照 を使うための副作用を提供します。
この章の各ソースコードは、main
メソッドが定義されているモジュールの集合へと分割されています。
この章の各節の内容は個別のファイルで実装されており、各時点での適切なファイルのmain
メソッドを実行できるように、Spagoビルドコマンドを変更することで、Main
モジュールを合わせられるようになっています。
HTMLファイルhtml/index.html
には、各例で使用される単一のcanvas
要素、及びコンパイルされたPureScriptコードを読み込むscript
要素が含まれています。
各節のコードを試すにはブラウザでHTMLファイルを開きます。
ほとんどの演習はブラウザを対象にしているので、この章には単体試験はありません。
単純な図形
Example/Rectangle.purs
ファイルには簡単な導入例が含まれています。
この例ではキャンバスの中心に青い四角形を1つ描画します。
このモジュールへは、Effect
モジュールからのEffect
型と、Canvas
APIを扱うためのEffect
モナドの動作を含むGraphics.Canvas
モジュールをインポートします。
他のモジュールでも同様ですが、main
動作は最初にgetCanvasElementById
動作を使ってキャンバスオブジェクトへの参照を取得し、getContext2D
動作を使ってキャンバスの2D描画文脈にアクセスします。
void
関数は関手を取り値をUnit
で置き換えます。
例ではmain
がシグネチャに沿うようにするために使われています。
main :: Effect Unit
main = void $ unsafePartial do
Just canvas <- getCanvasElementById "canvas"
ctx <- getContext2D canvas
補足:このunsafePartial
の呼び出しは必須です。
これはgetCanvasElementById
の結果のパターン照合部分で、Just
値構築子のみと照合するためです。
ここではこれで問題ありませんが、恐らく実際の製品のコードではNothing
値構築子と照合させ、適切なエラー文言を提供したほうがよいでしょう。
これらの動作の型はPSCiを使うかドキュメントを見ると確認できます。
getCanvasElementById :: String -> Effect (Maybe CanvasElement)
getContext2D :: CanvasElement -> Effect Context2D
CanvasElement
と Context2D
は Graphics.Canvas
モジュールで定義されている型です。
このモジュールではCanvas
作用も定義されており、モジュール内の全てのアクションで使用されています。
グラフィックス文脈ctx
はキャンバスの状態を管理し、原始的な図形を描画したり、スタイルや色を設定したり、座標変換を適用したりするための手段を提供します。
話を進めると、setFillStyle
動作を使うことで塗り潰しスタイルを濃い青に設定できます。
より長い16進数記法の#0000FF
も青には使えますが、単純な色については略記法がより簡単です。
setFillStyle ctx "#00F"
setFillStyle
動作がグラフィックス文脈を引数として取っていることに注意してください。
これはGraphics.Canvas
ではよくあるパターンです。
最後に、fillPath
動作を使用して矩形を塗り潰しています。
fillPath
は次のような型を持っています。
fillPath :: forall a. Context2D -> Effect a -> Effect a
fillPath
はグラフィックスの文脈と描画するパスを構築する他の動作を引数に取ります。
rect
動作を使うとパスを構築できます。
rect
はグラフィックスの文脈と矩形の位置及びサイズを格納するレコードを取ります。
fillPath ctx $ rect ctx
{ x: 250.0
, y: 250.0
, width: 100.0
, height: 100.0
}
mainモジュールの名前としてExample.Rectangle
を与えてこの長方形のコード例をビルドしましょう。
$ spago bundle-app --main Example.Rectangle --to dist/Main.js
それでは html/index.html
ファイルを開き、このコードによってキャンバスの中央に青い四角形が描画されていることを確認してみましょう。
行多相を利用する
パスを描画する方法は他にもあります。
arc
関数は円弧を描画します。
moveTo
関数、lineTo
関数、closePath
関数は断片的な線分のパスを描画できます。
Shapes.purs
ファイルでは長方形と円弧と三角形の、3つの図形を描画しています。
rect
関数は引数としてレコードをとることを見てきました。
実際には、長方形のプロパティは型同義語で定義されています。
type Rectangle =
{ x :: Number
, y :: Number
, width :: Number
, height :: Number
}
x
とy
プロパティは左上隅の位置を表しており、width
とheight
のプロパティはそれぞれ幅と高さを表しています。
arc
関数に以下のような型を持つレコードを渡して呼び出すと、円弧を描画できます。
type Arc =
{ x :: Number
, y :: Number
, radius :: Number
, start :: Number
, end :: Number
}
ここで、x
とy
プロパティは弧の中心、radius
は半径、start
とend
は弧の両端の角度を弧度法で表しています。
例えばこのコードは中心が(300, 300)
に中心があり半径50
の円弧を塗り潰します。
弧は1回転のうち2/3ラジアン分あります。
単位円が上下逆様になっている点に注意してください。
これはy軸がキャンバスの下向きに伸びるためです。
fillPath ctx $ arc ctx
{ x : 300.0
, y : 300.0
, radius : 50.0
, start : 0.0
, end : Math.tau * 2.0 / 3.0
}
Rectangle
レコード型とArc
レコード型の両方共、Number
型のx
とy
というプロパティを含んでいますね。
どちらの場合でもこの組は点を表しています。
つまり、何れのレコード型にも作用する行多相な関数を書けます。
例えばShapes
モジュールではx
とy
のプロパティを変更し図形を並行移動するtranslate
関数が定義されています。
translate
:: forall r
. Number
-> Number
-> { x :: Number, y :: Number | r }
-> { x :: Number, y :: Number | r }
translate dx dy shape = shape
{ x = shape.x + dx
, y = shape.y + dy
}
この行多相型に注目してください。
translate
が x
と
y
というプロパティと、それに加えて他の任意のプロパティを持つどんなレコードでも受け入れ、同じ型のレコードを返すと書かれています。
x
フィールドと y
フィールドは更新されますが、残りのフィールドは変更されません。
これはレコード更新構文の例です。
shape { ... }
という式は、shape
を元にして、括弧の中で指定された値で更新されたフィールドを持つ新たなレコードを作ります。
なお、波括弧の中の式はレコード直値のようなコロンではなく、等号でラベルと式を区切って書きます。
Shapes
の例からわかるように、translate
関数はRectangle
レコードとArc
レコード双方に対して使えます。
Shape
の例で描画される3つ目の型は線分の断片からなるパスです。
対応するコードは次のようになります。
setFillStyle ctx "#F00"
fillPath ctx $ do
moveTo ctx 300.0 260.0
lineTo ctx 260.0 340.0
lineTo ctx 340.0 340.0
closePath ctx
ここでは3つの関数が使われています。
moveTo
はパスの現在地を指定された座標に移動します。lineTo
は現在地と指定された座標の間の線分を描画し、現在地を更新します。closePath
は現在地と開始地点とを結ぶ線分を描画してパスを完結します。
このコード片の結果は二等辺三角形の塗り潰しになります。
mainモジュールとしてExample.Shapes
を指定して、この例をビルドしましょう。
$ spago bundle-app --main Example.Shapes --to dist/Main.js
そしてもう一度html/index.html
を開き、結果を確認してください。
キャンバスに3つの異なる図形が描画されるはずです。
演習
-
(簡単)これまでの各例について、
strokePath
関数やsetStrokeStyle
関数を使ってみましょう。 -
(簡単)関数の引数の内部でdo記法ブロックを使うと、
fillPath
関数とstrokePath
関数は共通のスタイルを持つ複雑なパスを描画できます。 同じfillPath
呼び出しを使って隣り合う2つの矩形を描画するように、Rectangle
の例を変更してみてください。 線分と円弧の組み合わせを使って、扇形を描画してみてください。 -
(普通)次のような2次元の点を表すレコードが与えられたとします。
type Point = { x :: Number, y :: Number }
これは2次元の点を表現しています。 多数の点からなる閉じたパスを線描きする関数
renderPath
を書いてください。renderPath :: Context2D -> Array Point -> Effect Unit
次のような関数を考えます。
f :: Number -> Point
この関数は引数として
1
から0
の間のNumber
を取り、Point
を返します。renderPath
関数を使い、関数f
のグラフを描く動作を書いてください。 その動作では有限個の点でf
を標本化することによって近似しなければなりません。関数
f
を変更し、様々なパスが描画されることを確かめてください。
無作為に円を描く
Example/Random.purs
ファイルには、Effect
モナドを使って2種類の副作用を綴じ合わせる例が含まれています。
1つの副作用は乱数生成で、もう1つはキャンバスの操作です。
この例では無作為に生成された円をキャンバスに100個描画します。
main
動作ではこれまでのようにグラフィックス文脈への参照を取得し、線描きと塗り潰しのスタイルを設定します。
setFillStyle ctx "#F00"
setStrokeStyle ctx "#000"
次のコードではfor_
動作を使って0
から100
までの整数について反復しています。
for_ (1 .. 100) \_ -> do
各繰り返しで、do記法ブロックは0
と1
の間に分布する3つの乱数を生成することから始まります。
これらの数はそれぞれx
座標、y
座標、半径r
を表しています。
x <- random
y <- random
r <- random
次のコードでは各円について、これらの変数に基づいてArc
を作成し、最後に現在のスタイルに従って円弧を塗り潰し、線描きします。
let path = arc ctx
{ x : x * 600.0
, y : y * 600.0
, radius : r * 50.0
, start : 0.0
, end : Number.tau
, useCounterClockwise: false
}
fillPath ctx path
strokePath ctx path
mainモジュールとしてExample.Random
を指定して、この例をビルドしましょう。
$ spago bundle-app --main Example.Random --to dist/Main.js
html/index.html
を開いて、結果を確認してみましょう。
座標変換
キャンバスは簡単な図形を描画するだけのものではありません。 キャンバスは座標変換を管理しており、描画の前に図形を変形するのに使えます。 図形は平行移動、回転、拡大縮小、及び斜めに変形できます。
canvas
ライブラリではこれらの変換を以下の関数で提供しています。
translate :: Context2D
-> TranslateTransform
-> Effect Context2D
rotate :: Context2D
-> Number
-> Effect Context2D
scale :: Context2D
-> ScaleTransform
-> Effect Context2D
transform :: Context2D
-> Transform
-> Effect Context2D
translate
動作はTranslateTransform
レコードのプロパティで指定した大きさだけ平行移動します。
rotate
動作は最初の引数で指定されたラジアンの数値に応じて、原点を中心として回転します。
scale
動作は原点を中心として拡大縮小します。
ScaleTransform
レコードはx
軸とy
軸に沿った拡大率を指定するのに使います。
最後の transform
はこの4つのうちで最も一般化された動作です。
この動作では行列に従ってアフィン変換します。
これらの動作が呼び出された後に描画される図形は、自動的に適切な座標変換が適用されます。
実際には、これらの関数の各作用は、文脈の現在の変換行列に対して変換行列を右から乗算していきます。 つまり、もしある作用の変換をしていくと、その作用は実際には逆順に適用されていきます。
transformations ctx = do
translate ctx { translateX: 10.0, translateY: 10.0 }
scale ctx { scaleX: 2.0, scaleY: 2.0 }
rotate ctx (Math.tau / 4.0)
renderScene
この一連の動作の作用では、まずシーンが回転され、それから拡大縮小され、最後に平行移動されます。
文脈の保存
座標変換を使ってシーンの一部を描画し、それからその変換を元に戻す、という使い方はよくあります。
Canvas APIにはキャンバスの状態のスタックを操作するsave
とrestore
メソッドが備わっています。
canvas
ではこの機能を次のような関数で梱包しています。
save
:: Context2D
-> Effect Context2D
restore
:: Context2D
-> Effect Context2D
save
動作は現在の文脈の状態(現在の変換行列や描画スタイル)をスタックにプッシュし、restore
動作はスタックの一番上の状態をポップし、文脈の状態を復元します。
これらの動作により、現在の状態を保存し、いろいろなスタイルや変換を適用してから原始的な図形を描画し、最後に元の変換と状態を復元できます。 例えば次の関数は幾つかのキャンバス動作を実行しますが、その前に回転を適用し、その後に変換を復元します。
rotated ctx render = do
save ctx
rotate (Math.tau / 3.0) ctx
render
restore ctx
こういったよくある高階関数の使われ方の抽象化として、canvas
ライブラリでは元の文脈状態を保存しつつ幾つかのキャンバス動作を実行するwithContext
関数が提供されています。
withContext
:: Context2D
-> Effect a
-> Effect a
withContext
を使うと、先ほどの rotated
関数を次のように書き換えることができます。
rotated ctx render =
withContext ctx do
rotate (Math.tau / 3.0) ctx
render
大域的な変更可能状態
この節では refs
パッケージを使って Effect
モナドの別の作用について実演してみます。
Effect.Ref
モジュールでは、大域的に変更可能な参照のための型構築子、及びそれに紐付く作用を提供します。
> import Effect.Ref
> :kind Ref
Type -> Type
型Ref a
の値は型a
の値を含む可変参照セルであり、大域的な変更を追跡するのに使われます。
そういったわけでこれは少しだけ使う分に留めておくべきです。
Example/Refs.purs
ファイルには canvas
要素上のマウスクリックを追跡するのに Ref
を使う例が含まれます。
このコードでは最初にnew
動作を使って値0
を含む新しい参照を作成しています。
clickCount <- Ref.new 0
クリックイベント制御子の内部では、modify
動作を使用してクリック数を更新し、更新された値が返されています。
count <- Ref.modify (\count -> count + 1) clickCount
render
関数ではクリック数に応じた変換を矩形に適用しています。
withContext ctx do
let scaleX = Number.sin (toNumber count * Number.tau / 8.0) + 1.5
let scaleY = Number.sin (toNumber count * Number.tau / 12.0) + 1.5
translate ctx { translateX: 300.0, translateY: 300.0 }
rotate ctx (toNumber count * Number.tau / 36.0)
scale ctx { scaleX: scaleX, scaleY: scaleY }
translate ctx { translateX: -100.0, translateY: -100.0 }
fillPath ctx $ rect ctx
{ x: 0.0
, y: 0.0
, width: 200.0
, height: 200.0
}
この動作では元の変換を保存するためにwithContext
を使用しており、それから一連の変換を適用しています(変換が下から上に適用されることを思い出してください)。
- 矩形が
(-100, -100)
だけ平行移動し、中心が原点に来ます。 - 矩形が原点を中心に拡大されます。
- 矩形が原点を中心に
10
の倍数分の角度で回転します。 - 矩形が
(300, 300)
だけ平行移動し、中心がキャンバスの中心に来ます。
このコード例をビルドしてみましょう。
$ spago bundle-app --main Example.Refs --to dist/Main.js
html/index.html
ファイルを開いてみましょう。
緑の四角形が表示され、何度かキャンバスをクリックするとキャンバスの中心の周りで回転するはずです。
演習
- (簡単)パスの線描と塗り潰しを同時に行う高階関数を書いてください。
その関数を使用して
Random.purs
の例を書き直してください。 - (普通)
Random
作用とDom
作用を使用して、マウスがクリックされたときに、キャンバスに無作為な位置、色、半径の円を描画するアプリケーションを作成してください。 - (普通)指定された座標の点を中心として回転させることでシーンを変換する関数を書いてください。 手掛かり:変換を使い、最初にシーンを原点まで平行移動しましょう。
L-System
この章の最後の例として、 canvas
パッケージを使用してL-system(またの名をLindenmayer
system)を描画する関数を記述します。
1つのL-Systemはアルファベット、つまりアルファベット由来の文字の初期の並びと、生成規則の集合で定義されます。 各生成規則は、アルファベットの文字を取り、それを置き換える文字の並びを返します。 この処理は文字の初期の並びから始まり、複数回繰り返されます。
もしアルファベットの各文字がキャンバス上で実行される命令と対応付けられていれば、その指示に順番に従うことでL-Systemを描画できます。
例えばアルファベットが文字L
(左回転)、R
(右回転)、F
(前進)で構成されているとします。
次のような生成規則を定義できます。
L -> L
R -> R
F -> FLFRRFLF
配列 "FRRFRRFRR" から始めて処理を繰り返すと、次のような経過を辿ります。
FRRFRRFRR
FLFRRFLFRRFLFRRFLFRRFLFRRFLFRR
FLFRRFLFLFLFRRFLFRRFLFRRFLFLFLFRRFLFRRFLFRRFLF...
というように続きます。 この命令群に対応する線分パスをプロットすると、コッホ曲線に近似されます。 反復回数を増やすと、曲線の解像度が増していきます。
それでは型と関数のある言語へとこれを翻訳してみましょう。
アルファベットの文字は以下のADTで表現できます。
data Letter = L | R | F
このデータ型では、アルファベットの文字ごとに1つずつデータ構築子が定義されています。
文字の初期配列はどのように表したらいいでしょうか。
単なるアルファベットの配列でいいでしょう。
これを Sentence
と呼ぶことにします。
type Sentence = Array Letter
initial :: Sentence
initial = [F, R, R, F, R, R, F, R, R]
生成規則は以下のようにLetter
から Sentence
への関数として表すことができます。
productions :: Letter -> Sentence
productions L = [L]
productions R = [R]
productions F = [F, L, F, R, R, F, L, F]
これはまさに上記の仕様をそのまま書き写したものです。
これで、この形式の仕様を受け取ってキャンバスに描画する関数lsystem
を実装できます。
lsystem
はどのような型を持っているべきでしょうか。
initial
やproductions
のような値だけでなく、アルファベットの文字をキャンバスに描画できる関数を引数に取る必要があります。
lsystem
の型の最初の大まかな設計は以下です。
Sentence
-> (Letter -> Sentence)
-> (Letter -> Effect Unit)
-> Int
-> Effect Unit
最初の2つの引数の型は、値 initial
と productions
に対応しています。
3番目の引数は、アルファベットの文字を取り、キャンバス上の幾つかの動作を実行することによって解釈する関数を表します。
この例では、文字L
は左回転、文字R
で右回転、文字F
は前進を意味します。
最後の引数は、実行したい生成規則の繰り返し回数を表す数です。
最初に気付くことは、このlsystem
関数は1つの型Letter
に対してのみ動作するのですが、どんなアルファベットについても機能すべきですから、この型はもっと一般化されるべきです。
それでは、量子化された型変数 a
について、Letter
と Sentence
を a
と Array a
で置き換えましょう。
forall a. Array a
-> (a -> Array a)
-> (a -> Effect Unit)
-> Int
-> Effect Unit
次に気付くこととしては、「左回転」と「右回転」のような命令を実装するためには、幾つかの状態を管理する必要があります。
具体的に言えば、その時点でパスが動いている方向を状態として持たなければなりません。
計算を通じて状態を渡すように関数を変更する必要があります。
ここでもlsystem
関数は状態がどんな型でも動作したほうがよいので、型変数s
を使用してそれを表しています。
型 s
を追加する必要があるのは3箇所で、次のようになります。
forall a s. Array a
-> (a -> Array a)
-> (s -> a -> Effect s)
-> Int
-> s
-> Effect s
まず追加の引数の型として lsystem
に型 s
が追加されています。
この引数はL-Systemの初期状態を表しています。
型
s
は引数にも現れますが、解釈関数(lsystem
の第3引数)の返り値の型としても現れます。解釈関数は今のところ、引数としてL-Systemの現在の状態を受け取り、返り値として更新された新しい状態を返します。
この例の場合では、次のような型を使って状態を表す型を定義できます。
type State =
{ x :: Number
, y :: Number
, theta :: Number
}
プロパティ x
と y
はパスの現在の位置を表しています。
プロパティtheta
はパスの現在の向きを表しており、ラジアンで表された水平線に対するパスの角度として指定されています。
システムの初期状態は次のように指定されます。
initialState :: State
initialState = { x: 120.0, y: 200.0, theta: 0.0 }
それでは、 lsystem
関数を実装してみます。定義はとても単純であることがわかるでしょう。
lsystem
は第4引数の値(型はInt
)に応じて再帰するのが良さそうです。
再帰の各ステップでは、生成規則に従って状態が更新され、現在の文が変化していきます。
このことを念頭に置きつつ、まずは関数の引数の名前を導入して、補助関数に処理を移譲することから始めましょう。
lsystem :: forall a s
. Array a
-> (a -> Array a)
-> (s -> a -> Effect s)
-> Int
-> s
-> Effect s
lsystem init prod interpret n state = go init n
where
go
関数は第2引数について再帰することで動作します。
場合分けは2つであり、n
がゼロであるときとn
がゼロでないときです。
1つ目の場合は再帰は完了し、解釈関数に応じて現在の文を解釈します。
型Array a
の文、型s
の状態、型s -> a -> Effect s
の関数があります。
以前定義したfoldM
の出番のようです。
この関数はcontrol
パッケージで手に入ります。
go s 0 = foldM interpret state s
ゼロでない場合ではどうでしょうか。
その場合は、単に生成規則を現在の文のそれぞれの文字に適用して、その結果を連結し、そして再帰的にgo
を呼び出すことによって繰り返します。
go s i = go (concatMap prod s) (i - 1)
これだけです。
foldM
やconcatMap
のような高階関数を使うと、アイデアを簡潔に表現できるのです。
しかし、話はこれで終わりではありません。
ここで与えた型は、実際はまだ特殊化されすぎています。
この定義ではキャンバスの操作が実装のどこにも使われていないことに注目してください。
それに、全くEffecta
モナドの構造を利用していません。
実際には、この関数はどんなモナドm
についても動作します。
この章に添付されたソースコードで指定されているlsystem
の型はもっと一般的になっています。
lsystem :: forall a m s
. Monad m
=> Array a
-> (a -> Array a)
-> (s -> a -> m s)
-> Int
-> s
-> m s
この型で書かれていることは、この解釈関数はモナドm
が持つ任意の副作用を完全に自由に持つことができる、ということだと理解できます。
キャンバスに描画したり、またはコンソールに情報を出力したりするかもしれませんし、失敗や複数の戻り値に対応しているかもしれません。
こういった様々な型の副作用を使ったL-Systemを記述してみることを読者にお勧めします。
この関数は実装からデータを分離することの威力を示す良い例となっています。
この手法の利点は、複数の異なる方法でデータを解釈できることです。
さらにlsystem
を2つの小さな関数へと分解できます。
1つ目はconcatMap
の適用の繰り返しを使って文を構築するもの、2つ目はfoldM
を使って文を解釈するものです。
これは読者の演習として残しておきます。
それでは解釈関数を実装して、この章の例を完成させましょう。
lsystem
の型が教えてくれているのは、型シグネチャが、何らかの型 a
と s
、型構築子 m
について、 s -> a -> m s
でなければならないということです。
a
を Letter
、 s
を State
、モナド m
を Effect
というように選びたいということがわかっています。
これにより次のような型になります。
interpret :: State -> Letter -> Effect State
この関数を実装するには、 Letter
型の3つのデータ構築子それぞれについて処理する必要があります。文字 L
(左回転)と
R
(右回転)の解釈では、theta
を適切な角度へ変更するように状態を更新するだけです。
interpret state L = pure $ state { theta = state.theta - Number.tau / 6.0 }
interpret state R = pure $ state { theta = state.theta + Number.tau / 6.0 }
文字F
(前進)を解釈するには、次のようにパスの新しい位置を計算し、線分を描画し、状態を更新します。
interpret state F = do
let x = state.x + Number.cos state.theta * 1.5
y = state.y + Number.sin state.theta * 1.5
moveTo ctx state.x state.y
lineTo ctx x y
pure { x, y, theta: state.theta }
なお、この章のソースコードでは、名前 ctx
がスコープに入るように、interpret
関数は main
関数内で
let
束縛を使用して定義されています。
State
型が文脈を持つように変更できるでしょうが、それはこのシステムの状態の変化する部分ではないので不適切でしょう。
このL-Systemを描画するには、次のようなstrokePath
動作を使用するだけです。
strokePath ctx $ lsystem initial productions interpret 5 initialState
次のコマンドを使ってL-Systemをコンパイルします。
$ spago bundle-app --main Example.LSystem --to dist/Main.js
html/index.html
を開いてみましょう。
キャンバスにコッホ曲線が描画されるのがわかると思います。
演習
-
(簡単)
strokePath
の代わりにfillPath
を使用するように、上のL-Systemの例を変更してください。 手掛かり:closePath
の呼び出しを含め、moveTo
の呼び出しをinterpret
関数の外側に移動する必要があります。 -
(簡単)描画システムへの影響を理解するために、コード中の様々な数値の定数を変更してみてください。
-
(普通)
lsystem
関数を2つの小さな関数に分割してください。 1つ目はconcatMap
の適用の繰り返しを使用して最終的な文を構築するもので、2つ目はfoldM
を使用して結果を解釈するものでなくてはなりません。 -
(普通)
setShadowOffsetX
、setShadowOffsetY
、setShadowBlur
、setShadowColor
動作を使い、塗りつぶされた図形にドロップシャドウを追加してください。 手掛かり:PSCiを使って、これらの関数の型を調べてみましょう。 -
(普通)向きを変えるときの角度の大きさは今のところ一定 \( tau / 6 \) です。 これに代えて、
Letter
データ型の中に角度を移動させ、生成規則によって変更できるようにしてください。type Angle = Number data Letter = L Angle | R Angle | F
この新しい情報を生成規則でどう使うと、面白い図形を作ることができるでしょうか。
-
(難しい)4つの文字からなるアルファベットでL-Systemが与えられたとします。 それぞれ
L
(60度左回転)、R
(60度右回転)、F
(前進)、M
(これも前進)です。このシステムの文の初期状態は、単一の文字
M
です。このシステムの生成規則は次のように指定されています。
L -> L R -> R F -> FLMLFRMRFRMRFLMLF M -> MRFRMLFLMLFLMRFRM
このL-Systemを描画してください。 補足:最後の文のサイズは反復回数に従って指数関数的に増大するので、生成規則の繰り返しの回数を削減する必要があります。
ここで、生成規則における
L
とM
の間の対称性に注目してください。2つの「前進」命令は、次のようなアルファベット型を使用すると、Boolean
値を使って区別できます。data Letter = L | R | F Boolean
このアルファベットの表現を使用して、もう一度このL-Systemを実装してください。
-
(難しい)解釈関数で別のモナド
m
を使ってみましょう。Effect.Console
作用を利用してコンソール上にL-Systemを出力したり、Random
作用を利用して状態の型に無作為の「突然変異」を適用したりしてみてください。
まとめ
この章では、canvas
ライブラリを使用することにより、PureScriptからHTML5 Canvas APIを使う方法について学びました。
また、これまで学んできた多くの手法からなる実用的な実演を見ました。
マップや畳み込み、レコードと行多相、副作用を扱うためのEffect
モナドです。
この章の例では、高階関数の威力を示すとともに、 実装からのデータの分離 も実演してみせました。これは例えば、代数データ型を使用してこれらの概念を次のように拡張し、描画関数からシーンの表現を完全に分離できるようになります。
data Scene
= Rect Rectangle
| Arc Arc
| PiecewiseLinear (Array Point)
| Transformed Transform Scene
| Clipped Rectangle Scene
| ...
この手法はdrawing
パッケージで採られており、描画前に様々な方法でシーンをデータとして操作できる柔軟性を齎しています。
キャンバスに描画されるゲームの例についてはcookbookの「Behavior」と「Signal」のレシピを見てください。
テストの自動生成
この章の目標
この章では、テスティングの問題に対する、型クラスの特に洗練された応用について示します。 どのようにテストするのかをコンパイラに教えるのではなく、コードがどのような性質を持っているべきかを教えることでテストします。 型クラスを使って無作為データ生成のための紋切り型なコードを書かずして、テスト項目を仕様から無作為に生成できます。 これは生成的テスティング(generative testing、またはproperty-based testing)と呼ばれ、HaskellのQuickCheckライブラリによって普及した手法です。
quickcheck
パッケージはHaskellのQuickCheckライブラリをPureScriptにポーティングしたもので、型や構文はもとのライブラリとほとんど同じようになっています。
quickcheck
を使って簡単なライブラリをテストし、Spagoでテストスイートを自動化されたビルドに統合する方法を見ていきます。
プロジェクトの準備
この章のプロジェクトには依存関係として quickcheck
が追加されます。
Spagoプロジェクトでは、テストソースは test
ディレクトリに置かれ、テストスイートのメインモジュールは
Test.Main
と名づけられます。 テストスイートは、 spago test
コマンドを使用して実行できます。
性質を書く
Merge
モジュールでは簡単な関数 merge
が実装されています。
これをquickcheck
ライブラリの機能を実演するために使っていきます。
merge :: Array Int -> Array Int -> Array Int
merge
は2つの整列された整数の配列を取って、結果が整列されるように要素を統合します。
例えば次のようになります。
> import Merge
> merge [1, 3, 5] [2, 4, 5]
[1, 2, 3, 4, 5, 5]
典型的なテストスイートでは、手作業でこのような小さなテスト項目を幾つも作成し、結果が正しい値と等しいことを確認することでテストを実施します。
しかし、merge
関数について知る必要があるものは全て、この性質に要約できます。
xs
とys
が整列済みなら、merge xs ys
は両方の配列が一緒に結合されて整列された結果になります。
quickcheck
では、無作為なテスト項目を生成することで、直接この性質をテストできます。
コードが持つべき性質を関数として述べるだけです。
この場合は1つの性質があります。
main = do
quickCheck \xs ys ->
eq (merge (sort xs) (sort ys)) (sort $ xs <> ys)
このコードを実行すると、quickcheck
は無作為な入力xs
とys
を生成してこの関数に渡すことで、主張した性質を反証しようとします。
何らかの入力に対して関数がfalse
を返した場合、性質は正しくなく、ライブラリはエラーを発生させます。
幸いなことに、次のように100個の無作為なテスト項目を生成しても、ライブラリはこの性質を反証できません。
$ spago test
Installation complete.
Build succeeded.
100/100 test(s) passed.
...
Tests succeeded.
もし
merge
関数に意図的にバグを混入した場合(例えば、大なりのチェックを小なりのチェックへと変更するなど)、最初に失敗したテスト項目の後で例外が実行時に投げられます。
Error: Test 1 failed:
Test returned false
見ての通りこのエラー文言ではあまり役に立ちませんが、少し工夫するだけで改良できます。
エラー文言の改善
テスト項目が失敗した時に同時にエラー文言を提供する上で、quickcheck
は<?>
演算子を提供しています。
次のように性質の定義とエラー文言を<?>
で区切って書くだけです。
quickCheck \xs ys ->
let
result = merge (sort xs) (sort ys)
expected = sort $ xs <> ys
in
eq result expected <?> "Result:\n" <> show result <> "\nnot equal to expected:\n" <> show expected
このとき、もしバグを混入するようにコードを変更すると、最初のテスト項目が失敗したときに改良されたエラー文言が表示されます。
Error: Test 1 (seed 534161891) failed:
Result:
[-822215,-196136,-116841,618343,887447,-888285]
not equal to expected:
[-888285,-822215,-196136,-116841,618343,887447]
入力 xs
が無作為に選ばれた数の配列として生成されていることに注目してください。
演習
- (簡単)配列に空の配列を統合しても元の配列は変更されないことを確かめる性質を書いてください。 補足:この新しい性質は冗長です。 というのもこの状況は既に既存の性質で押さえられているからです。 ここでは読者がQuickCheckを使う練習のための簡単なやり方を示そうとしているだけです。
- (簡単)
merge
の残りの性質に対して、適切なエラー文言を追加してください。
多相的なコードのテスト
Merge
モジュールでは、数の配列だけでなく、 Ord
型クラスに属するどんな型の配列に対しても動作する、 merge
関数を一般化した
mergePoly
という関数が定義されています。
mergePoly :: forall a. Ord a => Array a -> Array a -> Array a
merge
の代わりに mergePoly
を使うように元のテストを変更すると、次のようなエラー文言が表示されます。
No type class instance was found for
Test.QuickCheck.Arbitrary.Arbitrary t0
The instance head contains unknown type variables.
Consider adding a type annotation.
このエラー文言は、配列に持たせたい要素の型が何なのかわからないので、コンパイラが無作為なテスト項目を生成できなかったということを示しています。
このような場合、型註釈を使ってコンパイラが特定の型を推論するように強制できます。
例えばArray Int
などです。
quickCheck \xs ys ->
eq (mergePoly (sort xs) (sort ys) :: Array Int) (sort $ xs <> ys)
代替案として型を指定する補助関数を使うこともできます。
こうするとより見通しのよいコードになることがあります。
例えば同値関数の同義語として関数ints
を定義したとしましょう。
ints :: Array Int -> Array Int
ints = id
それから、コンパイラが引数の2つの配列の型 Array Int
を推論するように、テストを変更します。
quickCheck \xs ys ->
eq (ints $ mergePoly (sort xs) (sort ys)) (sort $ xs <> ys)
ここで、ints
関数が不明な型の曖昧さを解消するために使われているため、xs
とys
は型Array Int
を持っています。
演習
- (簡単)
xs
とys
の型をArray Boolean
に強制する関数bools
を書き、mergePoly
をその型でテストする性質を追加してください。 - (普通)標準関数から(例えば
arrays
パッケージから)1つ関数を選び、適切なエラー文言を含めてQuickCheckの性質を書いてください。 その性質は、補助関数を使って多相型引数をInt
かBoolean
のどちらかに固定しなければいけません。
任意のデータの生成
それではquickcheck
ライブラリが性質に対するテスト項目をどのように無作為に生成できているのかを見ていきます。
無作為に値を生成できるような型は、次のような型クラス Arbitary
のインスタンスを持っています。
class Arbitrary t where
arbitrary :: Gen t
Gen
型構築子は決定的無作為データ生成の副作用を表しています。
決定的無作為データ生成は、擬似乱数生成器を使って、シード値から決定的無作為関数の引数を生成します。
Test.QuickCheck.Gen
モジュールは、生成器を構築するための幾つかの有用なコンビネータを定義しています。
Gen
はモナドでもアプリカティブ関手でもあるので、
Arbitary
型クラスの新しいインスタンスを作成するのに、いつも使っているようなコンビネータを自由に使うことができます。
例えば、quickcheck
ライブラリで提供されているInt
型用のArbitrary
インスタンスを使い、256個のバイト値上の分布を作れます。
これにはGen
用のFunctor
インスタンスを使い、整数からバイトへの関数を任意の整数値に写します。
newtype Byte = Byte Int
instance Arbitrary Byte where
arbitrary = map intToByte arbitrary
where
intToByte n | n >= 0 = Byte (n `mod` 256)
| otherwise = intToByte (-n)
ここでは、0から255までの間の整数値であるような型Byte
を定義しています。
Arbitrary
インスタンスはmap
演算子を使って、intToByte
関数をarbitrary
動作まで持ち上げています。
arbitrary
動作内部の型はGen Int
と推論されます。
この考え方を merge
用のテストに使うこともできます。
quickCheck \xs ys ->
eq (numbers $ mergePoly (sort xs) (sort ys)) (sort $ xs <> ys)
このテストでは、任意の配列xs
とys
を生成しますが、merge
は整列済みの入力を期待しているので、これらを整列しておかなければなりません。
一方で、整列された配列を表すnewtypeを作成し、整列されたデータを生成するArbitrary
インスタンスを書くこともできます。
newtype Sorted a = Sorted (Array a)
sorted :: forall a. Sorted a -> Array a
sorted (Sorted xs) = xs
instance (Arbitrary a, Ord a) => Arbitrary (Sorted a) where
arbitrary = map (Sorted <<< sort) arbitrary
この型構築子を使うと、テストを次のように変更できます。
quickCheck \xs ys ->
eq (ints $ mergePoly (sorted xs) (sorted ys)) (sort $ sorted xs <> sorted ys)
これは些細な変更に見えるかもしれませんが、xs
とys
の型はただのArray Int
からSorted Int
へと変更されています。
これにより、mergePoly
関数は整列済みの入力を取る、という意図をわかりやすく示すことができます。
理想的には、mergePoly
関数自体の型がSorted
型構築子を使うようにするといいでしょう。
より興味深い例として、 Tree
モジュールでは枝の値で整列された二分木の型が定義されています。
data Tree a
= Leaf
| Branch (Tree a) a (Tree a)
Tree
モジュールでは次のAPIが定義されています。
insert :: forall a. Ord a => a -> Tree a -> Tree a
member :: forall a. Ord a => a -> Tree a -> Boolean
fromArray :: forall a. Ord a => Array a -> Tree a
toArray :: forall a. Tree a -> Array a
insert
関数は新しい要素を整列済みの木に挿入し、member
関数は特定の値について木に問い合わせます。
例えば次のようになります。
> import Tree
> member 2 $ insert 1 $ insert 2 Leaf
true
> member 1 Leaf
false
toArray
関数とfromArray
関数は、整列された木と配列を相互に変換できます。
fromArray
を使うと、木についてのArbitrary
インスタンスを書けます。
instance (Arbitrary a, Ord a) => Arbitrary (Tree a) where
arbitrary = map fromArray arbitrary
型a
用に使えるArbitary
インスタンスがあるなら、テストする性質の引数の型としてTree a
を使えます。
例えば、member
による木の確認については、値を挿入した後は常にtrue
を返すことをテストできます。
quickCheck \t a ->
member a $ insert a $ treeOfInt t
ここでは、引数 t
は Tree Number
型の無作為に生成された木です。
型引数は、同値関数 treeOfInt
によって明確にされています。
演習
- (普通)
a-z
の範囲から無作為に選ばれた文字の集まりを生成するArbitrary
インスタンスを持つ、String
のnewtypeを作ってください。 手掛かり:Test.QuickCheck.Gen
モジュールからelements
とarrayOf
関数を使います。 - (難しい)木に挿入された値は、どれだけ沢山の挿入があった後でも、その木の構成要素であることを主張する性質を書いてください。
高階関数のテスト
Merge
モジュールはmerge
関数の別の一般化も定義しています。
mergeWith
関数は追加の関数を引数として取り、統合される要素の順序を判定します。
つまりmergeWith
は高階関数です。
例えばlength
関数を最初の引数として渡し、既に長さの昇順になっている2つの配列を統合できます。
その結果もまた長さの昇順になっているでしょう。
> import Data.String
> mergeWith length
["", "ab", "abcd"]
["x", "xyz"]
["","x","ab","xyz","abcd"]
このような関数をテストするにはどうしたらいいでしょうか。 理想的には、関数である最初の引数を含めた3つの引数全てについて、値を生成したいところです。
無作為に生成された関数を作れるようにする、2つ目の型クラスがあります。
Coarbitrary
という名前で次のように定義されています。
class Coarbitrary t where
coarbitrary :: forall r. t -> Gen r -> Gen r
coarbitrary
関数は、型t
の関数の引数と、型r
の関数の結果の乱数生成器を取ります。
この関数引数を使って乱数生成器をかき乱します。
つまり、関数の引数を使って乱数生成器の無作為な出力を変更し、結果としているのです。
また、もし関数の定義域がCoarbitrary
で値域がArbitrary
なら、Arbitrary
の関数を与える型クラスインスタンスが存在します。
instance (Coarbitrary a, Arbitrary b) => Arbitrary (a -> b)
実際のところ、引数として関数を取るような性質を記述できます。
mergeWith
関数の場合では、新しい引数を考慮するようにテストを修正すると、最初の引数を無作為に生成できます。
結果が整列されていることは保証できません。
Ord
インスタンスを持っているとさえ限らないのです。
しかし、引数として渡す関数f
に従って結果が整列されていることは期待されます。
更に、2つの入力配列がf
に従って整列されている必要がありますので、sortBy
関数を使って関数f
が適用されたあとの比較に基づいてxs
とys
を整列します。
quickCheck \xs ys f ->
let
result =
map f $
mergeWith (intToBool f)
(sortBy (compare `on` f) xs)
(sortBy (compare `on` f) ys)
expected =
map f $
sortBy (compare `on` f) $ xs <> ys
in
eq result expected
ここでは、関数 f
の型を明確にするために、関数 intToBool
を使用しています。
intToBool :: (Int -> Boolean) -> Int -> Boolean
intToBool = id
関数は Arbitrary
であるだけでなく Coarbitrary
でもあります。
instance (Arbitrary a, Coarbitrary b) => Coarbitrary (a -> b)
つまり値や関数だけに制限されません。 高階関数や、引数が高階関数であるような関数やその他諸々もまた、無作為に生成できるのです。
Coarbitraryのインスタンスを書く
Gen
の Monad
や Applicative
インスタンスを使って独自のデータ型に対して
Arbitrary
インスタンスを書くことができるのとちょうど同じように、独自の Coarbitrary
インスタンスを書くこともできます。
これにより、無作為に生成される関数の定義域として、独自のデータ型を使うことができるようになります。
Tree
型の Coarbitrary
インスタンスを書いてみましょう。
枝に格納されている要素の型に Coarbitrary
インスタンスが必要になります。
instance Coarbitrary a => Coarbitrary (Tree a) where
型Tree a
の値が与えられたときに、乱数発生器をかき乱す関数を記述する必要があります。
入力値がLeaf
であれば、そのままにしておく生成器を返します。
coarbitrary Leaf = id
もし木が Branch
なら、左の部分木、値、右の部分木を使って生成器をかき乱します。
関数合成を使って独自のかき乱し関数を作ります。
coarbitrary (Branch l a r) =
coarbitrary l <<<
coarbitrary a <<<
coarbitrary r
これで、木を引数にとるような関数を引数に含む性質を自由に書くことができるようになりました。
例えばTree
モジュールでは関数anywhere
が定義されています。
これは述語が引数のどんな部分木についても満たされるかを調べます。
anywhere :: forall a. (Tree a -> Boolean) -> Tree a -> Boolean
今や述語関数を無作為に生成できます。
例えば、anywhere
関数は選言の法則を満たすことが期待されます。
quickCheck \f g t ->
anywhere (\s -> f s || g s) t ==
anywhere f (treeOfInt t) || anywhere g t
ここで、 treeOfInt
関数は木に含まれる値の型を型 Int
に固定するために使われています。
treeOfInt :: Tree Int -> Tree Int
treeOfInt = id
副作用のないテスト
通常、テストの目的ではテストスイートのmain
動作にquickCheck
関数の呼び出しが含まれています。
しかしquickCheck
関数には亜種があり、quickCheckPure
という名前です。
副作用を使わない代わりに、入力として乱数の種を取ってテスト結果の配列を返す純粋な関数です。
PSCiを使用して quickCheckPure
を試せます。
ここでは merge
操作が結合法則を満たすことをテストします。
> import Prelude
> import Merge
> import Test.QuickCheck
> import Test.QuickCheck.LCG (mkSeed)
> :paste
… quickCheckPure (mkSeed 12345) 10 \xs ys zs ->
… ((xs `merge` ys) `merge` zs) ==
… (xs `merge` (ys `merge` zs))
… ^D
Success : Success : ...
quickCheckPure
は乱数の種、生成するテスト項目数、テストする性質の3つの引数を取ります。
もし全てのテスト項目が成功したら、Success
データ構築子の配列がコンソールに出力されます。
quickCheckPure
は、性能ベンチマークの入力データ生成や、webアプリケーションのフォームデータ例を無作為に生成するというような状況で便利かもしれません。
演習
-
(簡単)
Byte
とSorted
型構築子についてのCoarbitrary
インスタンスを書いてください。 -
(普通)任意の関数
f
について、mergeWith f
関数の結合性を主張する(高階)性質を書いてください。quickCheckPure
を使ってPSCiでその性質をテストしてください。 -
(普通)次のデータ型の
Arbitrary
とCoarbitrary
インスタンスを書いてください。data OneTwoThree a = One a | Two a a | Three a a a
手掛かり:
Test.QuickCheck.Gen
で定義されたoneOf
関数を使ってArbitrary
インスタンスを定義してください。 -
(普通)
all
を使ってquickCheckPure
関数の結果を単純化してください。 この新しい関数は型List Result -> Boolean
を持ち、全てのテストが通ればtrue
を、そうでなければfalse
を返します。 -
(普通)
quickCheckPure
の結果を単純にする別の手法として、関数squashResults :: List Result -> Result
を書いてみてください。Data.Maybe.First
のFirst
モノイドと共にfoldMap
関数を使うことで、失敗した場合の最初のエラーを保持することを検討してください。
まとめ
この章ではquickcheck
パッケージに出会いました。
これを使うと生成的テスティングのパラダイムを使って、宣言的な方法でテストを書くことができました。具体的には以下です。
spago test
を使ってQuickCheckのテストを自動化する方法を見ました。- 性質を関数として書く方法とエラー文言を改良する
<?>
演算子の使い方を説明しました。 Arbitrary
とCoarbitrary
型クラスによって定型的なテストコードの自動生成を可能にする方法や、高階な性質のテストを可能にする方法を見ました。- 独自のデータ型に対して
Arbitrary
とCoarbitrary
インスタンスを実装する方法を見ました。
領域特化言語
この章の目標
この章では多数の標準的な手法を使い、PureScriptにおける領域特化言語(またはDSL)の実装について探求していきます。
領域特化言語とは、特定の問題領域での開発に適した言語のことです。 構文及び機能は、その領域内の考え方を表現するに使われるコードの読みやすさを最大化すべく選択されます。 本書の中では、既に領域特化言語の例を幾つか見てきています。
- 第11章で開発された
Game
モナドと関連する動作は、テキストアドベンチャーゲーム開発という領域に対しての領域特化言語を構成しています。 - 第13章で扱った
quickcheck
パッケージは、生成的テスティングの領域に向けた領域特化言語です。 このコンビネータはテストの性質に対して特に表現力の高い記法を可能にします。
この章では、領域特化言語の実装において、幾つかの標準的な技法にについて構造的な手法を取ります。 この話題の完全な解説では決してありませんが、目的に合う実践的なDSLを構築するのに充分な知識は得られるでしょう。
ここでの実行例はHTML文書を作成するための領域特化言語です。 正しいHTML文書を記述するための型安全な言語を開発することが目的で、素朴な実装を徐々に改善しつつ進めていきます。
プロジェクトの準備
この章に付随するプロジェクトには新しい依存性が1つ追加されます。
これから使う道具の1つであるFreeモナドが定義されているfree
ライブラリです。
このプロジェクトをPSCiを使って試していきます。
HTMLデータ型
このHTMLライブラリの最も基本的なバージョンは
Data.DOM.Simple
モジュールで定義されています。このモジュールには次の型定義が含まれています。
newtype Element = Element
{ name :: String
, attribs :: Array Attribute
, content :: Maybe (Array Content)
}
data Content
= TextContent String
| ElementContent Element
newtype Attribute = Attribute
{ key :: String
, value :: String
}
Element
型はHTMLの要素を表します。
各要素は要素名、属性の対の配列と、内容で構成されます。
内容のプロパティにはMaybe
型を適切に使い、要素が開いている(他の要素やテキストを含む)か閉じているかを示します。
このライブラリの鍵となる機能は次の関数です。
render :: Element -> String
この関数はHTML要素をHTML文字列として出力します。 PSCiで明示的に適当な型の値を構築し、ライブラリのこのバージョンを試してみましょう。
$ spago repl
> import Prelude
> import Data.DOM.Simple
> import Data.Maybe
> import Effect.Console
> :paste
… log $ render $ Element
… { name: "p"
… , attribs: [
… Attribute
… { key: "class"
… , value: "main"
… }
… ]
… , content: Just [
… TextContent "Hello World!"
… ]
… }
… ^D
<p class="main">Hello World!</p>
unit
現状のライブラリには幾つもの問題があります。
- HTML文書の作成に手が掛かります。 全ての新しい要素に少なくとも1つのレコードと1つのデータ構築子が必要です。
- 無効な文書を表現できてしまいます。
- 開発者が要素名の入力を間違えるかもしれません
- 開発者が属性を間違った要素に関連付けることができてしまいます
- 開いた要素が正しい場合に開発者が閉じた要素を使えてしまいます
残りの章ではとある手法を用いてこれらの問題を解決し、このライブラリーをHTML文書を作成するために使える領域特化言語にしていきます。
スマート構築子
最初に導入する手法は単純ですがとても効果的です。
モジュールの使用者にデータの表現を露出する代わりに、モジュールエクスポートリストを使ってデータ構築子Element
、Content
、Attribute
を隠蔽します。
そして正しいことが分かっているデータを構築する、いわゆるスマート構築子だけをエクスポートします。
例を示しましょう。まず、HTML要素を作成するための便利な関数を提供します。
element :: String -> Array Attribute -> Maybe (Array Content) -> Element
element name attribs content = Element
{ name: name
, attribs: attribs
, content: content
}
次にHTML要素のためのスマート構築子を作成します。
この要素は利用者がelement
関数を適用して作成できるようになってほしいものです。
a :: Array Attribute -> Array Content -> Element
a attribs content = element "a" attribs (Just content)
p :: Array Attribute -> Array Content -> Element
p attribs content = element "p" attribs (Just content)
img :: Array Attribute -> Element
img attribs = element "img" attribs Nothing
最後に、正しいデータ構造だけが構築されることがわかっているこれらの関数をエクスポートするように、モジュールエクスポートリストを更新します。
module Data.DOM.Smart
( Element
, Attribute(..)
, Content(..)
, a
, p
, img
, render
) where
モジュールエクスポートリストはモジュール名の直後の括弧内に書きます。 各モジュールのエクスポートは次の3種類の何れかになります。
- 値(ないし関数)。その値の名前により指定されます。
- 型クラス。クラス名により指定されます。
- 型構築子とそれに紐付くデータ構築子。 型名とそれに続くエクスポートされるデータ構築子の括弧で囲まれたリストで指定されます。
ここでは、Element
の型をエクスポートしていますが、データ構築子はエクスポートしていません。
もしデータ構築子をエクスポートすると、使用者が不正なHTML要素を構築できてしまいます。
Attribute
と Content
型についてはデータ構築子を全てエクスポートしています(エクスポートリストの記号 ..
で示されています)。
すぐ後で、これらの型にもスマート構築子の手法を適用していきます。
既にライブラリに幾つもの大きな改良が加わっていることに注目です。
- 不正な名前を持つHTML要素は表現できません(勿論ライブラリが提供する要素名に制限されています)。
- 閉じた要素は構築するときに内容を含められません。
Content
型にとても簡単にこの手法を適用できます。
単にエクスポートリストからContent
型のデータ構築子を取り除き、次のスマート構築子を提供します。
text :: String -> Content
text = TextContent
elem :: Element -> Content
elem = ElementContent
Attribute
型にも同じ手法を適用してみましょう。
まず、属性のための汎用のスマート構築子を用意します。
以下は最初の試行です。
attribute :: String -> String -> Attribute
attribute key value = Attribute
{ key: key
, value: value
}
infix 4 attribute as :=
この定義では元のElement
型と同じ問題に直面しています。
存在しなかったり、名前が間違って入力された属性を表現できます。
この問題を解決するために、属性名を表すnewtypeを作成します。
newtype AttributeKey = AttributeKey String
これを使えば演算子を次のように変更できます。
attribute :: AttributeKey -> String -> Attribute
attribute (AttributeKey key) value = Attribute
{ key: key
, value: value
}
AttributeKey
データ構築子をエクスポートしなければ、明示的にエクスポートされた次のような関数を使う以外に、使用者が型
AttributeKey
の値を構築する方法はありません。
以下に幾つかの例を示します。
href :: AttributeKey
href = AttributeKey "href"
_class :: AttributeKey
_class = AttributeKey "class"
src :: AttributeKey
src = AttributeKey "src"
width :: AttributeKey
width = AttributeKey "width"
height :: AttributeKey
height = AttributeKey "height"
新しいモジュールの最終的なエクスポートリストは次のようになります。 最早どのデータ構築子も直接エクスポートしていない点に注目です。
module Data.DOM.Smart
( Element
, Attribute
, Content
, AttributeKey
, a
, p
, img
, href
, _class
, src
, width
, height
, attribute, (:=)
, text
, elem
, render
) where
PSCiでこの新しいモジュールを試してみると、既にコードの簡潔さにおいて大幅な向上が見て取れます。
$ spago repl
> import Prelude
> import Data.DOM.Smart
> import Effect.Console
> log $ render $ p [ _class := "main" ] [ text "Hello World!" ]
<p class="main">Hello World!</p>
unit
しかし、基盤をなすデータ表現は全く変更されなかったので、render
関数を変更する必要はなかったことにも注目してください。
これはスマート構築子による手法の利点のひとつです。
外部APIの使用者によって認識される表現から、モジュールの内部データ表現を分離できるのです。
演習
-
(簡単)
Data.DOM.Smart
モジュールでrender
を使った新しいHTML文書の作成を試してみましょう。 -
(普通)
checked
やdisabled
といったHTML属性は値を要求せず、空の属性として書き出せます。<input disabled>
空の属性を扱えるように
Attribute
の表現を変更してください。 要素に空の属性を追加するためのattribute
または:=
の代わりに使える関数を記述してください。
幻影型
次の手法の動機付けとして、以下のコードを考えます。
> log $ render $ img
[ src := "cat.jpg"
, width := "foo"
, height := "bar"
]
<img src="cat.jpg" width="foo" height="bar" />
unit
ここでの問題は、 width
属性とheight
属性に文字列値を提供しているということです。
ここで与えることができるのはピクセル単位ないしパーセントの数値だけであるべきです。
AttributeKey
型にいわゆる 幻影型 (phantom type) 引数を導入すると、この問題を解決できます。
newtype AttributeKey a = AttributeKey String
定義の右辺に対応する型 a
の値が存在しないので、この型変数 a
は幻影型と呼ばれています。
この型 a
はコンパイル時に追加の情報を提供するためだけに存在しています。
型AttributeKey a
の任意の値は実行時には単なる文字列ですが、コンパイル時はその値の型により、このキーに関連する値で求められる型がわかります。
attribute
関数の型を次のように変更すれば、AttributeKey
の新しい形式を考慮するようにできます。
attribute :: forall a. IsValue a => AttributeKey a -> a -> Attribute
attribute (AttributeKey key) value = Attribute
{ key: key
, value: toValue value
}
ここで、幻影型の引数 a
は、属性キーと属性値が照応する型を持っていることを確認するために使われます。
使用者は AttributeKey a
の型の値を直接作成できないので(ライブラリで提供されている定数を介してのみ得られます)、全ての属性が構築により正しくなります。
なお、IsValue
制約はキーに関連付けられた値の型が何であれその値を文字列に変換し、生成したHTML内に出力できることを保証します。
IsValue
型クラスは次のように定義されています。
class IsValue a where
toValue :: a -> String
String
と Int
型についての型クラスインスタンスも提供しておきます。
instance IsValue String where
toValue = id
instance IsValue Int where
toValue = show
また、これらの型が新しい型変数を反映するように、 AttributeKey
定数を更新しなければいけません。
href :: AttributeKey String
href = AttributeKey "href"
_class :: AttributeKey String
_class = AttributeKey "class"
src :: AttributeKey String
src = AttributeKey "src"
width :: AttributeKey Int
width = AttributeKey "width"
height :: AttributeKey Int
height = AttributeKey "height"
これで、不正なHTML文書を表現することが不可能になっていることがわかります。
また、width
と height
属性を表現するのに文字列ではなく数を使うことが強制されていることがわかります。
> import Prelude
> import Data.DOM.Phantom
> import Effect.Console
> :paste
… log $ render $ img
… [ src := "cat.jpg"
… , width := 100
… , height := 200
… ]
… ^D
<img src="cat.jpg" width="100" height="200" />
unit
演習
-
(簡単)ピクセルまたはパーセントの何れかの長さを表すデータ型を作成してください。 その型について
IsValue
のインスタンスを書いてください。 この新しい型を使うようにwidth
とheight
属性を変更してください。 -
(難しい)真偽値
true
、false
用の最上位の表現を定義することで、幻影型を使ってAttributeKey
がdisabled
やchecked
のような空の属性を表現しているかどうかをエンコードできます。data True data False
幻影型を使って、使用者が
attribute
演算子を空の属性に対して使うことを防ぐように、前の演習の解答を変更してください。
Freeモナド
APIに施す最後の変更では、Content
型をモナドにしてdo記法を使えるようにするために、Freeモナドと呼ばれる構造を使っていきます。
これによって入れ子になった要素がわかりやすくなるような形式でHTML文書を構造化できます。
以下の代わりに……
p [ _class := "main" ]
[ elem $ img
[ src := "cat.jpg"
, width := 100
, height := 200
]
, text "A cat"
]
このように書くことができるようになります。
p [ _class := "main" ] $ do
elem $ img
[ src := "cat.jpg"
, width := 100
, height := 200
]
text "A cat"
しかし、do記法だけがFreeモナドの恩恵ではありません。 Freeモナドがあれば、モナドの動作の表現をその解釈から分離し、同じ動作に複数の解釈を持たせることさえできます。
Free
モナドはfree
ライブラリのControl.Monad.Free
モジュールで定義されています。
PSCiを使うと、次のようにFreeモナドについての基本的な情報を見ることができます。
> import Control.Monad.Free
> :kind Free
(Type -> Type) -> Type -> Type
Free
の種は、引数として型構築子を取り、別の型構築子を返すことを示しています。
実はなんと、Free
モナドを使えば任意のFunctor
をMonad
にできるのです。
モナドの動作の表現の定義から始めます。
これには対応したい各モナド動作について、1つのデータ構築子を持つFunctor
を作成する必要があります。
今回の場合、2つのモナドの動作はelem
とtext
になります。
Content
型を次のように変更するだけでできます。
data ContentF a
= TextContent String a
| ElementContent Element a
instance Functor ContentF where
map f (TextContent s x) = TextContent s (f x)
map f (ElementContent e x) = ElementContent e (f x)
ここで、このContentF
型構築子は以前のContent
データ型とよく似ています。
しかし、ここでは型引数a
を取り、それぞれのデータ構築子は型a
の値を追加の引数として取るように変更されています。
Functor
インスタンスでは、単に各データ構築子で型a
の値に関数f
を適用します。
これにより、新しいContent
モナドをFree
モナド用の型同義語として定義できます。
これは最初の型引数として ContentF
型構築子を使うことで構築されます。
type Content = Free ContentF
型同義語の代わりにnewtype
を使用して、使用者に対してライブラリの内部表現を露出することを避けられます。
Content
データ構築子を隠すことで、提供しているモナドの動作だけを使うことを使用者に制限しています。
ContentF
は Functor
なので、 Free ContentF
用のMonad
インスタンスが自動的に手に入ります。
Content
の新しい型引数を考慮するようにElement
データ型を僅かに変更する必要があります。
モナドの計算の戻り値の型が Unit
であることだけが必要です。
newtype Element = Element
{ name :: String
, attribs :: Array Attribute
, content :: Maybe (Content Unit)
}
また、Content
モナドについての新しいモナドの動作になるよう、elem
とtext
関数を変更する必要があります。
これにはControl.Monad.Free
モジュールで提供されている liftF
関数が使えます。
以下がその型です。
liftF :: forall f a. f a -> Free f a
liftF
により、何らかの型a
について、型f a
の値からFreeモナドの動作を構築できるようになります。
今回の場合、ContentF
型構築子のデータ構築子をそのまま使えます。
text :: String -> Content Unit
text s = liftF $ TextContent s unit
elem :: Element -> Content Unit
elem e = liftF $ ElementContent e unit
他にも同じようなコードの変更はありますが、興味深い変更は render
関数にあります。ここでは、このFreeモナドを 解釈
しなければいけません。
モナドの解釈
Control.Monad.Free
モジュールでは、Freeモナドで計算を解釈するための多数の関数が提供されています。
runFree
:: forall f a
. Functor f
=> (f (Free f a) -> Free f a)
-> Free f a
-> a
runFreeM
:: forall f m a
. (Functor f, MonadRec m)
=> (f (Free f a) -> m (Free f a))
-> Free f a
-> m a
runFree
関数は、純粋な結果を計算するために使用されます。
runFreeM
関数があればFreeモナドの動作を解釈するためにモナドが使えます。
補足:厳密には、より強いMonadRec
制約を満たすモナドm
に制限されています。
実際、らm
は安全な末尾再帰モナドに対応してため、スタックオーバーフローを心配する必要はありません。
まず、動作を解釈できるモナドを選ばなければなりません。
Writer String
モナドを使って、結果のHTML文字列を累算することにします。
新しいrender
メソッドが開始すると、補助関数
renderElement
に移譲し、execWriter
を使ってWriter
モナドで計算します。
render :: Element -> String
render = execWriter <<< renderElement
renderElement
はwhereブロックで定義されます。
where
renderElement :: Element -> Writer String Unit
renderElement (Element e) = do
renderElement
の定義は直感的で、複数の小さな文字列を累算するためにWriter
モナドのtell
動作を使っています。
tell "<"
tell e.name
for_ e.attribs $ \x -> do
tell " "
renderAttribute x
renderContent e.content
次に、renderAttribute
関数を定義します。
こちらも同じくらい単純です。
where
renderAttribute :: Attribute -> Writer String Unit
renderAttribute (Attribute x) = do
tell x.key
tell "=\""
tell x.value
tell "\""
renderContent
関数は、もっと興味深いものです。
ここではrunFreeM
関数を使い、Freeモナドの内部で計算を解釈しています。
計算は補助関数 renderContentItem
に移譲しています。
renderContent :: Maybe (Content Unit) -> Writer String Unit
renderContent Nothing = tell " />"
renderContent (Just content) = do
tell ">"
runFreeM renderContentItem content
tell "</"
tell e.name
tell ">"
renderContentItem
の型は runFreeM
の型シグネチャから推測できます。
関手 f
は型構築子 ContentF
で、モナド m
は解釈している計算のモナド、つまり Writer String
です。
これにより renderContentItem
は次の型シグネチャだとわかります。
renderContentItem :: ContentF (Content Unit) -> Writer String (Content Unit)
ContentF
の2つのデータ構築子でパターン照合すればこの関数を実装できます。
renderContentItem (TextContent s rest) = do
tell s
pure rest
renderContentItem (ElementContent e rest) = do
renderElement e
pure rest
それぞれの場合において、式rest
は型Content Unit
を持っており、解釈された計算の残りを表しています。
rest
動作を返すことでそれぞれの場合を完成できます。
できました。 PSCiで、次のようにすれば新しいモナドのAPIを試すことができます。
> import Prelude
> import Data.DOM.Free
> import Effect.Console
> :paste
… log $ render $ p [] $ do
… elem $ img [ src := "cat.jpg" ]
… text "A cat"
… ^D
<p><img src="cat.jpg" />A cat</p>
unit
演習
- (普通)
ContentF
型に新しいデータ構築子を追加して、生成されたHTMLにコメントを出力する新しい動作comment
に対応してください。liftF
を使ってこの新しい動作を実装してください。 新しい構築子を適切に解釈するように、解釈renderContentItem
を更新してください。
言語の拡張
全動作が型Unit
の何かを返すようなモナドは、さほど興味深いものではありません。
実際のところ、概ね良くなったと思われる構文は別として、このモナドはMonoid
以上の機能を何ら追加していません。
非自明な結果を返す新しいモナド動作でこの言語を拡張することで、Freeモナドを構築する威力をお見せしましょう。
アンカーを使用して文書の様々な節へのハイパーリンクが含まれているHTML文書を生成したいとします。 手作業でアンカーの名前を生成して文書中で少なくとも2回それらを含めれば、これは達成できます。 1つはアンカーの定義自身に、もう1つは各ハイパーリンクにあります。 しかし、この手法には基本的な問題が幾つかあります。
- 開発者が一意なアンカー名の生成をし損なうかもしれません。
- 開発者がアンカー名を1つ以上の箇所で打ち間違うかもしれません。
開発者が誤ちを犯すことを防ぐために、アンカー名を表す新しい型を導入し、新しい一意な名前を生成するためのモナド動作を提供できます。
最初の工程は名前の型を新しく追加することです。
newtype Name = Name String
runName :: Name -> String
runName (Name n) = n
繰り返しになりますが、Name
は
String
のnewtypeとして定義しているものの、モジュールのエクスポートリスト内でデータ構築子をエクスポートしないように注意する必要があります。
次に、属性値にName
を使えるよう、新しい型にIsValue
型クラスのインスタンスを定義します。
instance IsValue Name where
toValue (Name n) = n
また、次のように a
要素に現れるハイパーリンク用の新しいデータ型を定義します。
data Href
= URLHref String
| AnchorHref Name
instance IsValue Href where
toValue (URLHref url) = url
toValue (AnchorHref (Name nm)) = "#" <> nm
この新しい型により、href
属性の型の値を変更して、利用者にこの新しい Href
型の使用を強制できます。
また、新しいname
属性も作成でき、要素をアンカーに変換するのに使えます。
href :: AttributeKey Href
href = AttributeKey "href"
name :: AttributeKey Name
name = AttributeKey "name"
残っている問題は、現在モジュールの使用者が新しい名前を生成する方法がないということです。
Content
モナドでこの機能を提供できます。まず、 ContentF
型構築子に新しいデータ構築子を追加する必要があります。
data ContentF a
= TextContent String a
| ElementContent Element a
| NewName (Name -> a)
NewName
データ構築子は型Name
の値を返す動作に対応しています。
データ構築子の引数としてName
を要求するのではなく、型Name -> a
の関数を提供するように使用者に要求していることに注意してください。
型a
は計算の残りを表していることを思い出すと、この関数は、型Name
の値が返されたあとで、計算を継続する方法を提供しているのだとわかります。
新しいデータ構築子を考慮するよう、次のようにContentF
用のFunctor
インスタンスを更新する必要もあります。
instance Functor ContentF where
map f (TextContent s x) = TextContent s (f x)
map f (ElementContent e x) = ElementContent e (f x)
map f (NewName k) = NewName (f <<< k)
これで、以前と同じようにliftF
関数を使って新しい動作を構築できます。
newName :: Content Name
newName = liftF $ NewName id
id
関数を継続として提供していることに注意してください。
つまり型Name
の結果を変更せずに返しています。
最後に、新しい動作を解釈させるように解釈関数を更新する必要があります。
以前は計算を解釈するためにWriter String
モナドを使っていましたが、このモナドは新しい名前を生成できないので、何か他のものに切り替えなければなりません。
WriterT
モナド変換子をState
モナドと一緒に使うと、必要な作用を組み合わせられます。
型注釈が短く保たれるよう、この解釈モナドを型同義語として定義できます。
type Interp = WriterT String (State Int)
ここで、Int
型の状態は増加していくカウンタとして振舞い、一意な名前を生成するのに使われます。
Writer
とWriterT
モナドはそれらの動作を抽象化するのに同じ型クラスの構成要素を使うので、どの動作も変更する必要がありません。
必要なのは、Writer String
への参照全てをInterp
で置き換えることだけです。
しかし、計算に使われる制御子は変更する必要があります。
単なるexecWriter
の代わりに、evalState
も使う必要があります。
render :: Element -> String
render e = evalState (execWriterT (renderElement e)) 0
また、新しい NewName
データ構築子を解釈するために、 renderContentItem
に新しい場合を追加しなければいけません。
renderContentItem (NewName k) = do
n <- get
let fresh = Name $ "name" <> show n
put $ n + 1
pure (k fresh)
ここで、型 Name -> Content a
の継続 k
が与えられているので、型 Content a
の解釈を構築しなければいけません。
この解釈は単純です。
get
を使って状態を読み、その状態を使って一意な名前を生成し、それから put
で状態に1だけ足すのです。
最後に、継続にこの新しい名前を渡して、計算を完了します。
以上をもって、この新しい機能をPSCiで試せます。
これにはContent
モナドの内部で一意な名前を生成し、要素の名前とハイパーリンクのリンク先の両方として使います。
> import Prelude
> import Data.DOM.Name
> import Effect.Console
> :paste
… render $ p [ ] $ do
… top <- newName
… elem $ a [ name := top ] $
… text "Top"
… elem $ a [ href := AnchorHref top ] $
… text "Back to top"
… ^D
<p><a name="name0">Top</a><a href="#name0">Back to top</a></p>
unit
複数回のnewName
の呼び出しの結果が、実際に一意な名前になっていることも確かめられます。
演習
-
(普通)使用者から
Element
型を隠蔽すると、更にAPIを簡素にできます。 次の手順に従って、これらの変更を加えてください。p
やimg
のような(返る型がElement
の)関数をelem
動作と結合して、型Content Unit
を返す新しい動作を作ってください。Element
の代わりに型Content Unit
の引数を受け付けるようにrender
関数を変更してください。
-
(普通)型同義語の代わりに
newtype
を使ってContent
モナドの実装を隠してください。newtype
用のデータ構築子はエクスポートすべきではありません。 -
(難しい)
ContentF
型を変更して以下の新しい動作に対応してください。isMobile :: Content Boolean
この動作は、この文書がモバイルデバイス上での表示のために描画されているかどうかを示す真偽値を返します。
手掛かり:
ask
動作とReaderT
モナド変換子を使って、この動作を解釈してください。 あるいは、RWS
モナドを使うほうが好みの人もいるかもしれません。
まとめ
この章では、幾つかの標準的な技術を使って、素朴な実装を段階的に改善することにより、HTML文書を作成するための領域特化言語を開発しました。
- スマート構築子を使ってデータ表現の詳細を隠し、利用者には構築により正しい文書だけを作ることを許しました。
- 独自に定義された中置2引数演算子を使い、言語の構文を改善しました。
- 幻影型を使ってデータの型の中に追加の情報を折り込みました。 これにより利用者が誤った型の属性値を与えることを防いでいます。
- Freeモナドを使って内容の集まりの配列表現をdo記法に対応したモナドな表現に変えました。 それからこの表現を新しいモナド動作に対応するよう拡張し、標準的なモナド変換子を使ってモナドの計算を解釈しました。
これらの手法は全て、使用者が間違いを犯すのを防いだり領域特化言語の構文を改良したりするために、PureScriptのモジュールと型システムを活用しています。
関数型プログラミング言語による領域特化言語の実装は活発に研究されている分野です。 それでも、幾つかの単純な技法に対して役に立つ導入を提供し、表現力豊かな型を持つ言語で作業することの威力を示すことができていれば幸いです。