実例による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などのライブラリがあれば、開発者はmapfilterreduceといった実績のある関数を活用して、小さいプログラムを組み合わせることで大きなプログラムを作ることができます。

    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");
          }
        });
      }
    });
    
  • Reactvirtual-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を学べるオンラインで利用可能な資料が沢山あります。

もし例を読んで学ぶ方が好きでしたら、GitHubのpurescriptpurescript-nodepurescript-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) に自分の解法を書けば、提供されているテストスートを使って確認できます。

テスト駆動開発でこの次の演習を一緒に進めてみましょう。

演習

  1. (普通)直角三角形の対角線(あるいは斜辺)の長さを他の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! 🎉

成功です。 これで次の演習を自力で解くための準備ができました。

演習

  1. (簡単)指定された半径の円の面積を計算する関数circleAreaを書いてみましょう。 Numbersモジュールで定義されている pi定数を使用してください。 手掛かりimport Data.Number文を修正して、 piをインポートすることを忘れないようにしましょう。
  2. (普通)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モジュールで定義されており、全てのモジュールに暗黙にインポートされます。 それぞれNumberStringBooleanと呼ばれており、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型のフィールドnameArray 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は等号の右辺と等価ということです。 レコードの型はfirstNamelastNamephoneという3つのフィールドからなります。 2つの名前のフィールドは型Stringを持ち、addressは以下で定義された型Addressを持ちます。

type Address =
  { street :: String
  , city   :: String
  , state  :: String
  }

なお、レコードには他のレコードを含めることができます。

それでは、住所録のデータ構造として3つめの型同義語も定義してみましょう。 単に項目の連結リストとして表すことにします。

type AddressBook = List Entry

なお、List EntryArray 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

これは種エラーです。値がそので区別されるのと同じように、型はそのによって区別されます。間違った型の値が型エラーになるように、間違った種の型は種エラーを引き起こします。

NumberStringのような、値を持つ全ての型の種を表す 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

型シグネチャには追加で種の情報が含まれます。 abが具体的な型であることが明記されています。

このforallキーワードは、constantlyFirst全称量化された型を持つことを示しています。 つまりabをどの型に置き換えても良く、constantlyFirstはその型で動作するのです。

例えば、aIntbStringと選んだとします。 その場合、constantlyFirstの型を次のように特殊化できます。

Int -> String -> Int

量化された型を特殊化したいということをコードで示す必要はありません。 特殊化は自動的に行われます。 例えば、あたかも既にその型に備わっていたかの如くconstantlyFirstを使えます。

> constantlyFirst 3 "ignored"

3

abにはどんな型でも選べますが、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.ListCons関数が使えます。 この関数の型を見るには、PSCiを起動し :typeコマンドを使います。

$ spago repl

> import Data.List
> :type Cons

forall (a :: Type). a -> List a -> List a

この型シグネチャで書かれているのは、Consが何らかの型aの値と型aの要素のリストを取り、同じ型の項目を持つ新しいリストを返すということです。 aEntry型として特殊化してみましょう。

Entry -> List Entry -> List Entry

しかし、 List Entryはまさに AddressBookですから、次と同じになります。

Entry -> AddressBook -> AddressBook

今回の場合、既に適切な入力があります。 EntryAddressBookConsを適用すると、新しい AddressBookを得ることができます。 これこそがまさに求めていた関数です。

insertEntryの実装は次のようになります。

insertEntry entry book = Cons entry book

こうすると、等号の左側にある2つの引数entrybookがスコープに導入されます。 それからCons関数を適用し、結果を作成しています。

カリー化された関数

PureScriptの関数はきっかり1つの引数を取ります。 insertEntry関数は2つの引数を取るように見えますが、カリー化された関数の一例なのです。 PureScriptでは全ての関数はカリー化されたものと見做されます。

カリー化が意味するのは複数の引数を取る関数を1度に1つ取る関数に変換することです。 関数を呼ぶときに1つの引数を渡し、これまた1つの引数を取る別の関数を返し、といったことを全ての引数が渡されるまで続けます。

例えばadd5に渡すと別の関数が得られます。 その関数は整数を取り、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を使う代わりとなる、型安全な代替を提供します。 後の章で改めて詳しく見ていきます。

filterheadの全称量化された型は、PureScriptコンパイラによって次のように 特殊化 (specialized) されます。

filter :: (Entry -> Boolean) -> AddressBook -> AddressBook

head :: AddressBook -> Maybe Entry

関数の引数として姓名を渡す必要があるだろうということは分かっています。

filterに渡す関数も必要になることもわかります。この関数を filterEntryと呼ぶことにしましょう。 filterEntryEntry -> 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は、どちらも文字列型である firstNamelastNameAddressBook型の bookという3つの名前をスコープに導入します。

定義の右辺ではfilter関数とhead関数が組み合わさっています。 まず項目のリストを絞り込み、その結果にhead関数を適用しています。

真偽型を返す関数 filterEntrywhere節の内部で補助的な関数として定義されています。 このため、 filterEntry関数はこの定義の内部では使用できますが、外部では使用できません。 また、filterEntryはそれを包む関数の引数に依存でき、 filterEntryは指定された Entryを絞り込むために引数 firstNamelastNameを使用しているので、 filterEntryfindEntryの内部にあることは必須になっています。

なお、最上位での宣言と同じように、必ずしもfilterEntryの型シグネチャを指定しなくても構いません。 ただし、ドキュメントの一形態として指定しておくことが推奨されます。

中置の関数適用

これまでお話しした関数のほとんどは前置関数適用でした。 関数名が引数のに置かれていたということです。 例えばinsertEntry関数を使ってEntry (john) を空のAddressBookに追加する場合、以下のように書けます。

> book1 = insertEntry john emptyBook

しかし本章には中置2引数演算子の例も含まれています。 filterEntryの定義中の==演算子がそうで、2つの引数のに置かれています。 PureScriptのソースコードでこうした中置演算子は隠れた前置の実装への中置別称として定義されています。 例えば==は以下の行により前置のeq関数の中置別称として定義されています。

infix 4 eq as ==

したがってfilterEntry中のentry.firstName == firstNameeq entry.firstName firstNameで置き換えられます。 この節の後のほうで中置演算子を定義する例にもう少し触れます。

前置関数を演算子としての中置の位置に置くと、より読みやすいコードになる場面があります。 その一例がmod関数です。

> mod 8 3
2

上の用例でも充分動作しますが、読みにくいです。 より馴染みのある表現の仕方は「8 mod 3」です。 バックスラッシュ (`) の中に前置関数を包むとそのように書けます。

> 8 `mod` 3
2

同様に、insertEntryをバックスラッシュで包むと中置演算子に変わります。 例えば以下のbook1book2は等価です。

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 filterEntryhead合成 (composition) に bookが渡されるということです。

PureScriptの関数合成演算子は <<<>>>です。前者は「逆方向の合成」であり、後者は「順方向の合成」です。

何れかの演算子を使用して findEntryの右辺を書き換えることができます。 逆順の合成を使用すると、右辺は次のようになります。

(head <<< filter filterEntry) book

この形式なら最初の定義にイータ変換の技を適用でき、 findEntryは最終的に次のような形式に到達します。

findEntry firstName lastName = head <<< filter filterEntry
  where
    ...

右辺を次のようにしても同じく妥当です。

filter filterEntry >>> head

どちらにしても、これは「findEntryは絞り込み関数とhead関数の合成である」という findEntry関数のわかりやすい定義を与えます。

どちらの定義のほうが分かりやすいかの判断はお任せしますが、このように関数を部品として捉えるとしばしば有用です。 各関数は1つの役目をこなすようにし、解法を関数合成を使って組み立てるのです。

演習

  1. (簡単)findEntry関数の定義の主な部分式の型を書き下し、 findEntry関数についてよく理解しているか試してみましょう。 例えばfindEntryの定義の中にある head関数の型は AddressBook -> Maybe Entryと特殊化されています。 補足:この問題にはテストがありません。
  2. (普通)関数findEntryByStreet :: String -> AddressBook -> Maybe Entryを書いてください。 この関数は与えられた通りの住所からEntryを見付け出します。 手掛かりfindEntryにある既存のコードを再利用してください。 実装した関数をPSCiとspago testを走らせてテストしてください。
  3. (普通)filterEntryを(<<<>>>を使った)合成で置き換えて、findEntryByStreetを書き直してください。 合成の対象は、プロパティ取得子(_.記法を使います)と、与えられた文字列引数が与えられた通りの住所に等しいかを判定する関数です。
  4. (普通)名前がAddressBookに存在するかどうかを調べて真偽値で返す関数isInBookを書いてみましょう。 手掛かり:PSCiを使ってData.List.null関数の型を見付けてください。 この関数はリストが空かどうかを調べます。
  5. (難しい)「重複」している住所録の項目を削除する関数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引数)を返します。
  • それ以外の場合、関数は最後の行の式を評価して返します。

なお、パターンでは値を名前に束縛できます。 この例の各行では nmという名前の何れかまたは両方に入力された値を束縛しています。 様々な種類のパターンについて学んでいくうちに、それぞれの種類のパターンが入力の引数から名前を選ぶ様々な方法に対応することがわかるでしょう。

単純なパターン

上記のコード例では、2種類のパターンを示しました。

  • Int型の値が正確に一致する場合にのみ照合する、整数直値パターン
  • 引数を名前に束縛する、変数パターン

単純なパターンには他にも種類があります。

  • NumberStringChar、そして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

この例が示すように、ガードは等号の左側に現れ、パイプ文字 (|) でパターンのリストと区切られています。

演習

  1. (簡単)パターン照合を使用して、階乗関数factorialを書いてみましょう。 手掛かり:入力がゼロのときとゼロでないときの、2つの特殊な場合を考えてみてください。 補足:これは前の章の例の反復ですが、ここでは自力で書き直せるかやってみてください。
  2. (普通)\( (1 + x) ^ n \)を多項式展開した式にある\( x ^ k \)の項の係数を求める関数binomialを書いてください。 これはn要素の集合からk要素の部分集合を選ぶ方法の数と同じです。 数式\( n! / k! (n - k)! \)を使ってください。 ここで \( ! \) は前に書いた階乗関数です。 手掛かり:パターン照合を使って特殊な場合を取り扱ってください。 長い時間が掛かったりコールスタックのエラーでクラッシュしたりしたら、特殊な場合を更に追加してみてください。
  3. (普通)パスカルの法則を使って前の演習と同じ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を使うことをお勧めします。 その他の操作について、より優れた漸近性能を提供するデータ構造も存在します。

レコードパターンと行多相

レコードパターンは(ご想像の通り)レコードに照合します。

レコードパターンはレコード直値にほぼ見た目が似ていますが、コロンの右に値を置くのではなく、それぞれのフィールドで束縛子を指定します。

例えば次のパターンはfirstlastという名前のフィールドが含まれた任意のレコードに照合し、これらのフィールドの値はそれぞれ xyという名前に束縛されます。

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であるようなフィールドfirstlastが含まれていれば、関数適用は正しく型付けされます。 しかし、フィールドが不足していると、showPersonの呼び出しは不正となります。

> showPerson { first: "Phil" }

Type of expression lacks required label "last"

showPersonの新しい型シグネチャを読むと、「Stringfirstlastフィールド と他のフィールドを何でも 持つあらゆるレコードを取り、Stringを返す」となります。なお、この挙動は元のshowPersonのものとは異なります。行変数rがなければshowPerson厳密に firstlastフィールドしかないレコードのみを受け付けます。

なお、次のように書くこともできます。

> showPerson p = p.last <> ", " <> p.first

そしてPSCiは同じ型を推論することでしょう。

レコード同名利用

showPerson関数は引数内のレコードと照合し、firstlastフィールドをxyという名前の値に束縛していたのでした。 別の方法として、フィールド名自体を再利用してこのような類のパターン照合を次のように単純化できます。

showPersonV2 :: { first :: String, last :: String } -> String
showPersonV2 { first, last } = last <> ", " <> first

ここでは、プロパティの名前のみを指定し、名前に導入したい値を指定する必要はありません。これは レコード同名利用 (record pun) と呼ばれます。

レコード同名利用はレコードの構築にも使用できます。 例えば、スコープに firstlastという名前の値があれば、{ 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つの要素を含んでいなければ、たとえ整列されていなかったとしても、この関数は単に元のまま変えずに返します。

演習

  1. (簡単)レコードパターンを使って、2つの Personレコードが同じ都市にいるか調べる関数 sameCityを定義してみましょう。
  2. (普通)行多相を考慮すると、 sameCity関数の最も一般的な型は何でしょうか。 先ほど定義したlivesInLA関数についてはどうでしょうか。 補足:この演習にテストはありません。
  3. (普通)配列直値パターンを使って、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か、RectangleLineTextの何れかです。 他に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 }

さて、代数的データ型で値を構築することは簡単ですが、これをどうやって使ったらよいのでしょうか。 ここで代数的データ型とパターン照合との重要な接点が見えてきます。 代数的データ型の値を消費する唯一の方法は構築子に照合するパターンを使うことです。

例を見てみましょう。 ShapeStringに変換したいとします。 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の最初の場合を考えてみましょう。 もし ShapeCircle構築子に照合した場合、2つの変数パターン crを使ってCircleの引数(中心と半径)がスコープに導入されます。 その他の場合も同様です。

演習

  1. (簡単)Circle(型はShape)を構築する関数circleAtOriginを書いてください。 中心は原点にあり、半径は10.0です。
  2. (普通)原点を中心としてShapeの大きさを2.0倍に拡大する関数doubleScaleAndCenterを書いてみましょう。
  3. (普通)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は妥当な定義です(erraは型変数で、CouldError構築子は型Either err a単一の値を期待します)。

newtype CouldError err a = CouldError (Either err a)

また、newtypeの構築子はよくnewtype自身と同じ名前を持つことがあります。 ただこれは必須ではありません。 例えば別個の名前であっても正しいものです。

newtype Coulomb = MakeCoulomb Number

この場合Coulombは(引数ゼロの)型構築子で、MakeCoulombデータ構築子です。 これらの構築子は異なる名前空間に属しており、Voltの例でそうだったように、名前には一意性があります。 これは全てのADTについて言えることです。 なお、型構築子とデータ構築子には異なる名前を付けられますが、実際には同じ名前を共有するのが普通です。 前述のAmpVoltの場合がこれです。

newtypeの別の応用は、実行時表現を変えることなく、既存の型に異なる 挙動 を加えることです。その利用例については次章で 型クラス をお話しするときに押さえます。

演習

  1. (簡単)WattNumbernewtypeとして定義してください。それからこの新しいWatt型と前述のAmpVoltの定義を使ってcalculateWattage関数を定義してください。
calculateWattage :: Amp -> Volt -> Watt

Watt中のワット数は与えられたAmp中の電流と与えられたVoltの電圧の積で計算できます。

ベクターグラフィックスライブラリ

これまで定義してきたデータ型を使って、ベクターグラフィックスを扱う簡単なライブラリを作成していきましょう。

Pictureという型同義語を定義しておきます。 これはただのShapeの配列です。

type Picture = Array Shape

不具合を修正しているとPictureStringとして表示できるようにしたくなることもあるでしょう。 これはパターン照合を使用して定義された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.Foldablefoldl関数を使用しています。

bounds :: Picture -> Bounds
bounds = foldl combine emptyBounds
  where
  combine :: Bounds -> Shape -> Bounds
  combine b shape = union (shapeBounds shape) b

基底の場合では、空の Pictureの最小外接矩形を求める必要がありますが、emptyBoundsで定義される空の外接矩形がその条件を満たしています。

累算関数combinewhereブロックで定義されています。 combinefoldlの再帰呼び出しで計算された外接矩形と、配列内の次の Shapeを引数に取り、ユーザ定義の演算子unionを使って2つの外接矩形の和を計算しています。 shapeBounds関数は、パターン照合を使用して、単一の図形の外接矩形を計算します。

演習

  1. (普通)ベクターグラフィックライブラリを拡張し、Shapeの面積を計算する新しい操作 areaを追加してください。 この演習の目的上は、線分やテキストの面積は0であるものとしてください。
  2. (難しい)Shape型を新しいデータ構築子 Clippedで拡張してください。 Clippedは他の Pictureを矩形に切り抜きます。 切り抜かれた図形の境界を計算できるよう、shapeBounds関数を拡張してください。 なお、これによりShapeは再帰的なデータ型になります。 手掛かり :コンパイラは必要に応じて他の関数を拡張するのに付き添ってくれるでしょう。

まとめ

この章では、関数型プログラミングから基本的ながら強力なテクニックであるパターン照合を扱いました。複雑なデータ構造の一部分と照合するために、簡単なパターンの使い方だけではなく、配列パターンやレコードパターンを使った深さのあるデータ構造の一部分との照合方法を見てきました。

また、この章ではパターン照合に密接に関連する代数的データ型も紹介しました。 代数的データ型のおかげでデータ構造を簡潔に記述でき、新たな操作でデータ型を拡張する上で、モジュール性のある方法が得られるのでした。

最後に行多相を扱いました。 これは強力な抽象化をする型であり、これにより多くの既存のJavaScript関数に型を与えられます。

本書では今後も代数的データ型とパターン照合をいろんなところで使用するので、今のうちにこれらに習熟しておくと後で実を結ぶことでしょう。これ以外にも独自の代数的データ型を作成し、パターン照合を使用してそれらの型を使う関数を書いてみてください。

再帰、マップ、畳み込み

一時的な注意事項:本章に取り組まれているようでしたら、2023年11月に第4章と第5章とが入れ替わっていることにお気を付けください。

この章の目標

この章では、アルゴリズムを構造化するときに再帰関数をどのように使うかについて見ていきましょう。 再帰は関数型プログラミングの基本的な手法であり、本書全体に亙って使われます。

また、PureScriptの標準ライブラリから標準的な関数を幾つか取り扱います。 mapfoldといった関数だけでなく、filterconcatMapといった特別な場合において便利なものについても見ていきます。

この章では、仮想的なファイルシステムを操作する関数のライブラリを動機付けに用います。 この章で学ぶ技術を応用し、ファイルシステムのモデルにより表現されるファイルのプロパティを計算する関数を書きます。

プロジェクトの準備

この章のソースコードはsrc/Data/Path.purstest/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で配列の長さを調べるのには、この例はどう見ても実用的な方法とはいえませんが、次の演習を完遂するための手掛かりとしては充分でしょう。

演習

  1. (簡単)入力が偶数であるとき、かつそのときに限りtrueに返す再帰関数isEvenを書いてみましょう。
  2. (普通)配列内の偶数の整数を数える再帰関数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関数に適用するときにはabという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"]

中置演算子に(左または右の)結合性を与えたい場合は、代わりにキーワードinfixlinfixrを使います。 infixを使うと何ら結合性は割り当てられず、同じ演算子を複数回使ったり複数の同じ優先度の演算子を使ったりするときに、式を括弧で囲まなければいけなくなります。

配列の絞り込み

Data.Arrayモジュールでは他にも、よくmapと一緒に使われる関数filterも提供しています。 この関数は、述語関数に照合する要素のみを残し、既存の配列から新しい配列を作成する機能を提供します。

例えば1から10までの数で、偶数であるような数の配列を計算したいとします。 これは次のようにできます。

> import Data.Array

> filter (\n -> n `mod` 2 == 0) (1 .. 10)
[2,4,6,8,10]

演習

  1. (簡単)map関数や<$>関数を使用して、 配列に格納された数のそれぞれの平方を計算する関数squaredを書いてみましょう。 手掛かりmap<$>といった関数を使ってください。
  2. (簡単)filter関数を使用して、数の配列から負の数を取り除く関数keepNonNegativeを書いてみましょう。 手掛かりfilter関数を使ってください。
  3. (普通)
    • 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]

関連する関数として、concatmapを組み合わせた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がどのように結果を連結しているのかに注目してください。渡された関数を元の配列のそれぞれの要素について一度ずつ呼び出し、その関数はそれぞれ配列を生成します。最後にそれらの配列を単一の配列に押し潰したものが結果となります。

mapfilterconcatMapは、「配列内包表記」(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]の両方があるように、重複した組み合わせが生成されています。 jiから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記法

しかし、このコードの可読性は大幅に向上できます。mapconcatMapは基本的な関数であり、 do記法 (do notation) と呼ばれる特別な構文の基礎になっています(もっと厳密にいえば、それらの一般化であるmapbindが基礎をなしています)。

補足mapconcatMapがあることで配列内包表記を書けるように、もっと一般的な演算子であるmapbindがあることでモナド内包表記と呼ばれているものが書けます。 本書の後半ではモナドの例をたっぷり見ていくことになりますが、この章では配列のみを考えます。

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

つまり、guardtrueに評価される式を渡された場合、単一の要素を持つ配列を返すのです。 もし式がfalseと評価された場合は、その結果は空です。

ガードが失敗した場合、配列内包表記の現在の分岐は、結果なしで早めに終了されることを意味します。 これは、guardの呼び出しが、途中の配列に対してfilterを使用するのと同じだということです。 実践での場面にもよりますが、filterの代わりにguardを使いたいことは多いでしょう。 これらが同じ結果になることを確認するために、factorsの2つの定義を試してみてください。

演習

  1. (簡単)関数isPrimeを書いてください。 この関数は整数の引数が素数であるかを調べます。 手掛かりfactors関数を使ってください。
  2. (普通)do記法を使い、2つの配列の直積集合を見つけるための関数cartesianProductを書いてみましょう。 直積集合とは、要素abの全ての組み合わせの集合のことです。 ここでaは最初の配列の要素、bは2つ目の配列の要素です。
  3. (普通)関数triples :: Int -> Array (Array Int)を書いてください。 この関数は数値 \( n \) を取り、構成要素(値 \( a \)、 \( b \)、 \( c \))がそれぞれ \( n \) 以下であるような全てのピタゴラスの3つ組 (pythagorean triples) を返します。 ピタゴラスの3つ組は \( a ^ 2 + b ^ 2 = c ^ 2 \) であるような数値の配列 \( [ a, b, c ] \) です。 手掛かり:配列内包表記でguard関数を使ってください。
  4. (難しい)factors関数を使用して、n素因数分解を求める関数primeFactorsを定義してみましょう。 nの素因数分解とは、積がnであるような素数の配列のことです。 手掛かり:1より大きい整数について、問題を2つの部分問題に分解してください。 最初の因数を探し、それから残りの因数を探すのです。

畳み込み

配列における左右の畳み込みは、再帰を用いて実装できる別の興味深い一揃いの関数を提供します。

PSCiを使って、Data.Foldableモジュールをインポートし、foldlfoldr関数の型を調べることから始めましょう。

> 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を使用して数の配列の和を求めてみます。 型aIntになり、結果の型bIntとして選択できます。 ここでは3つの引数を与える必要があります。 1つ目は次の要素を累算器に加算するInt -> Int -> Intという型の関数です。 2つ目は累算器のInt型の初期値です。 3つ目は和を求めたいIntの配列です。 最初の引数としては、加算演算子を使用できますし、累算器の初期値はゼロになります。

> foldl (+) 0 (1 .. 5)
15

この場合では、引数が逆になっていても(+)関数は同じ結果を返すので、foldlfoldrのどちらでも問題ありません。

> 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は末尾再帰最適化の形でこの問題に対する部分的な解決策を提供しています。

補足:この問題へのより完全な解決策としては、いわゆるトランポリンを使用するライブラリで実装できますが、それはこの章で扱う範囲を超えています。 興味のある読者はfreetailrecパッケージのドキュメントをあたると良いでしょう。

末尾再帰最適化を可能にする上で鍵となる観点は以下となります。 末尾位置にある関数の再帰的な呼び出しはジャンプに置き換えられます。 このジャンプではスタックフレームが確保されません。 関数が戻るより前の最後の呼び出しであるとき、呼び出しが末尾位置にあるといいます。 先の例でスタックオーバーフローが見られたのはこれが理由です。 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ずつ足されて大きくなっていきます。

なお、累算器を「状態」と考えることもできますが、直接には変更されていません。

明示的な再帰より畳み込みを選ぼう

末尾再帰を使用して再帰関数を記述できれば末尾再帰最適化の恩恵を受けられるので、全ての関数をこの形で書こうとする誘惑に駆られます。 しかし、多くの関数は配列やそれに似たデータ構造に対する折り畳みとして直接書くことができることを忘れがちです。 mapfoldのような組み合わせの部品を使って直接アルゴリズムを書くことには、コードの単純さという利点があります。 これらの部品はよく知られており、だからこそ明示的な再帰よりもアルゴリズムの意図がより良く伝わるのです。

例えば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を書くことは、読者への課題として残しておきます。

演習

  1. (簡単)foldlを使って真偽値配列の値が全て真か検査する関数allTrueを書いてください。
  2. (普通。テストなし)関数foldl (==) false xsが真を返すような配列xsとはどのようなものか説明してください。 言い換えると、「関数はxsが……を含むときにtrueを返す」という文を完成させることになります。
  3. (普通)末尾再帰の形式を取っていること以外はfibと同じような関数fibTailRecを書いてください。 手掛かり:累算器引数を使ってください。
  4. (普通)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で新しいコードを試してみてください。 同じ結果が返ってくるはずです。 どちらのほうがわかりやすいかの選定はお任せします。

演習

  1. (簡単)ディレクトリの全てのサブディレクトリの中にある(ディレクトリを除く)全てのファイルを返すような関数onlyFilesを書いてみてください。

  2. (普通)ファイルを名前で検索する関数whereIsを書いてください。 この関数は型Maybe Pathの値を返すものとします。 この値が存在するなら、そのファイルがそのディレクトリに含まれているということを表します。 この関数は次のように振る舞う必要があります。

    > whereIs root "ls"
    Just (/bin/)
    
    > whereIs root "cat"
    Nothing
    

    手掛かり:この関数をdo記法を使った配列内包表記で書いてみましょう。

  3. (難しい)Path中の最大のファイルと最小のファイルを1つずつ含む配列を返す関数largestSmallestを書いてください。 補足:空配列や1要素の配列を返すことで、Pathにそれぞれゼロか1個のファイルがある場合についても考慮してください。

まとめ

この章ではアルゴリズムを簡潔に表現するためにPureScriptでの再帰の基本を押さえました。 また、独自の中置演算子や、マップ、絞り込みや畳み込みなどの配列に対する標準関数、及びこれらの概念を組み合わせた配列内包表記を導入しました。 最後に、スタックオーバーフローエラーを回避するために末尾再帰を使用することの重要性、累算器引数を使用して末尾再帰形に関数を変換する方法を示しました。

型クラス

この章の目標

この章では、PureScriptの型システムにより可能になっている強力な抽象化の形式を導入します。 そう、型クラスです。

データ構造をハッシュ化するためのライブラリを本章の動機付けの例とします。 データ自体の構造について直接考えることなく複雑な構造のデータのハッシュ値を求める上で、型クラスの仕組みがどのように働くかを見ていきます。

また、PureScriptのPreludeや標準ライブラリに含まれる、標準的な型クラスも見ていきます。PureScriptのコードは概念を簡潔に表現するために型クラスの強力さに大きく依存しているので、これらのクラスに慣れておくと役に立つでしょう。

オブジェクト指向の方面から入って来た方は、「クラス」という単語がそれまで馴染みのあるものとこの文脈とでは かなり 異なるものを意味していることに注意してください。

プロジェクトの準備

この章のソースコードは、ファイル src/data/Hashable.pursで定義されています。

このプロジェクトには以下の依存関係があります。

  • maybe: 省略可能な値を表す Maybeデータ型が定義されています。
  • tuples: 値の組を表す Tupleデータ型が定義されています。
  • either: 非交和を表す Eitherデータ型が定義されています。
  • strings: 文字列を操作する関数が定義されています。
  • functions: PureScriptの関数を定義するための補助関数が定義されています。

モジュール Data.Hashableでは、これらのパッケージによって提供されるモジュールの幾つかをインポートしています。

見せて!

型クラスの最初の簡単な例は、既に何回か見たことがある関数で提供されています。 showは何らかの値を取り、文字列として表示する関数です。

showPreludeモジュールの 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を使っていますが、これは単にshowlogを順に呼び出すものであり、引用符なしに文字列が表示されます。 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モジュールに複製する必要があります。

演習

  1. (簡単)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を用いて定義されます。

補足: 以下の例ではクラスシグネチャに<=が含まれています。 この文脈での<=の使われ方は、EqOrdの上位クラスであり、比較演算子としての<=の用途を表す意図はありません。 後述の上位クラスの節を参照してください。

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の上位クラスです)のインスタンスですが、RingFieldのインスタンスではありません。

上位クラスについては、この章の後半で詳しく説明します。 しかし、全ての数値型クラスの階層チートシート)について述べるのはこの章の目的から外れています。 この内容に興味のある読者は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 aArray aに置き換えられますが、そうするとfoldlfoldrの型が、最初に配列に対する畳み込みで見た型になると気付きます。

foldMapについてはどうでしょうか。 これは forall a m. Monoid m => (a -> m) -> Array a -> mになります。 この型シグネチャでは、型mMonoid型クラスのインスタンスであれば、返り値の型として任意に選べると書かれています。 配列の要素をそのモノイドの値へと変える関数を与えられれば、そのモノイドの構造を利用して配列上で累算し、1つの値にして返せます。

それではPSCiで foldMapを試してみましょう。

> import Data.Foldable

> foldMap show [1, 2, 3, 4, 5]
"12345"

ここでは文字列用のモノイドとshow関数を選んでいます。 前者は文字列を連結するもので、後者はIntを文字列として書き出すものです。 そうして数の配列を渡すと、それぞれの数をshowして1つの文字列へと連結した結果を得ました。

しかし畳み込み可能な型は配列だけではありません。 foldable-traversableではMaybeTupleのような型にもFoldableインスタンスが定義されており、listsのような他のライブラリでは、各々のデータ型に対してFoldableインスタンスが定義されています。 Foldable順序付きコンテナの概念を見据えたものなのです。

関手と型クラス則

PureScriptでは、副作用を伴う関数型プログラミングのスタイルを可能にするための型クラスの集まりも定義されています。 FunctorApplicativeMonadといったものです。 これらの抽象化については後ほど本書で扱いますが、ここでは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
  }
  1. (簡単)ComplexShowインスタンスを定義してください。 出力の形式はテストで期待される形式と一致させてください(例:1.2+3.4i5.6-7.7iなど)。

  2. (簡単)EqインスタンスをComplexに導出してください。 補足:代わりにこのインスタンスを手作業で書いてもよいですが、しなくていいのになぜすることがありましょう。

  3. (普通)SemiringインタンスをComplexに定義してください。 補足Data.Newtypewrapover2を使ってより簡潔な解答を作れます。 もしそうするのでしたら、Data.Newtypeからclass Newtypeをインポートしたり、NewtypeインスタンスをComplexに導出したりする必要も出てくるでしょう。

  4. (簡単)(newtypeを介して)RingインスタンスをComplexに導出してください。 補足:代わりにこのインスタンスを手作業で書くこともできますが、そう手軽にはできません。

    以下は前章からのShapeのADTです。

    data Shape
      = Circle Point Number
      | Rectangle Point Number Number
      | Line Point Point
      | Text Point String
    
  5. (普通)(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を呼び出すことができる、とこの型は言っています。

制約された型には複数の型クラスインスタンスを含めることができますし、インスタンスの型は単純な型変数に限定されません。 OrdShowのインスタンスを使って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で動作することが示されています。 こうするとIntNumberの両方で関数を使えます。

インスタンスの依存関係

制約された型を使うと関数の実装が型クラスインスタンスに依存できるように、型クラスインスタンスの実装は他の型クラスインスタンスに依存できます。これにより、型を使ってプログラムの実装を推論するという、プログラム推論の強力な形式を提供します。

Show型クラスを例に考えてみましょう。 それぞれの要素を showする方法がある限り、その要素の配列を showする型クラスインスタンスを書くことができます。

instance Show a => Show (Array a) where
  ...

型クラスインスタンスが複数の他のインスタンスに依存する場合、括弧で囲んでそれらのインスタンスをコンマで区切り、それを=>シンボルの左側に置くことになります。

instance (Show a, Show b) => Show (Either a b) where
  ...

これらの2つの型クラスインスタンスは preludeライブラリにあります。

プログラムがコンパイルされると、Showの正しい型クラスのインスタンスは showの引数の推論された型に基づいて選ばれます。 選択されたインスタンスが沢山のそうしたインスタンスの関係に依存しているかもしれませんが、このあたりの複雑さに開発者が関与することはありません。

演習

  1. (簡単)以下の宣言では型 aの要素の空でない配列の型を定義しています。

    data NonEmpty a = NonEmpty a (Array a)
    

    Eq aEq (Array a)のインスタンスを再利用し、型NonEmptyEqインスタンスを書いてください。 補足:代わりにEqインスタンスを導出できます。

  2. (普通)ArraySemigroupインスタンスを再利用して、NonEmptyへのSemigroupインスタンスを書いてください。

  3. (普通)NonEmptyFunctorインスタンスを書いてください。

  4. (普通)Ordのインスタンス付きの任意の型aが与えられているとすると、新しくそれ以外のどんな値よりも大きい「無限の」値を付け加えられます。

    data Extended a = Infinite | Finite a 
    

    aへのOrdインスタンスを再利用して、Extended aOrdインスタンスを書いてください。

  5. (難しい)NonEmptyFoldableインスタンスを書いてください。 手掛かり:配列へのFoldableインスタンスを再利用してください。

  6. (難しい)順序付きコンテナを定義する(そして Foldableのインスタンスを持っている)ような型構築子 fが与えられたとき、追加の要素を先頭に含める新たなコンテナ型を作れます。

    data OneMore f a = OneMore a (f a)
    

    このコンテナ OneMore fもまた順序を持っています。 ここで、新しい要素は任意の fの要素よりも前にきます。 この OneMore fFoldableインスタンスを書いてみましょう。

    instance Foldable f => Foldable (OneMore f) where
      ...
    
  7. (普通)nubEq関数を使い、配列から重複するShapeを削除するdedupShapes :: Array Shape -> Array Shape関数を書いてください。

  8. (普通)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インスタンスを選択することを期待しています。 結局のところ、 StringCharのストリームであり、他の型のストリームであってはなりません。

コンパイラは自動的にそう推論できず、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に定義されている関数headtailを例に取りましょう。 この関数は配列の先頭と尾鰭を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クラスを使うことでしょう。

一般に、下位クラスの法則が上位クラスの構成要素に言及しているとき、上位クラス関係を定義するのは筋が通っています。 例えば、任意のOrdEqのインスタンスの対について、もし2つの値がEqインスタンスの下で同値であるなら、compare関数はEQを返すはずだと推定するのは理に適っています。 言い換えれば、a == bが真であるのはcompare a bが厳密にEQに評価されるときなのです。 法則のレベルでのこの関係は、EqOrdの間の上位クラス関係の正当性を示します。

上位クラス関係を定義する別の理由となるのは、この2つのクラスの間に明白な "is-a" の関係があることです。 下位クラスの全ての構成要素は、上位クラスの構成要素でもあるということです。

演習

  1. (普通)部分関数unsafeMaximum :: Partial => Array Int -> Intを定義してください。 この関数は空でない整数の配列の最大値を求めます。 unsafePartialを使ってPSCiで関数を試してください。 手掛かりData.Foldablemaximum関数を使います。

  2. (普通)次の 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
      ...
    

    インスタンスが上で挙げた法則を見たさなくてはならないことを思い出してください。

  3. (難しい)Action Multiply Intのインスタンスを実装するには複数の方法があります。 どれだけ思い付きますか。 PureScriptは同じインスタンスの複数の実装を許さないため、元の実装を置き換える必要があるでしょう。 補足:テストでは4つの実装を押さえています。

  4. (普通)入力の文字列を何回か繰り返すActionインスタンスを書いてください。

    instance Action Multiply String where
      ...
    

    手掛かり:PursuitでシグネチャがString -> Int -> Stringの補助関数を検索してください。 なお、Stringは(Monoidのような)より汎用的な型として現れます。

    このインスタンスは上に挙げた法則を満たすでしょうか。

  5. (普通)インスタンス Action m a => Action m (Array a)を書いてみましょう。 ここで、 配列上の動作はそれぞれの要素を独立に実行するものとして定義されます。

  6. (難しい)以下のnewtypeが与えらえているとき、Action m (Self m)のインスタンスを書いてください。 ここでモノイドmはそれ自体が持つappendを用いて動作します。

    newtype Self m = Self m
    

    補足:テストフレームワークではSelfMultiply型にShowEqインスタンスが必要になります。 手作業でこれらのインスタンスを書いてもよいですし、derive newtype instanceと書くだけでコンパイラに取り仕切ってもらうこともできます。

  7. (難しい)多変数型のクラス 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.Functionon関数を使っていますが、これはハッシュ同値性の宣言的な定義として読めるはずです。 つまり、それぞれの値が 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.ChartoCharCode関数を使うと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

既に書いたものより単純なインスタンスを使用して、新たなインスタンスを構築しているやり方に注目してください。 StringCharの配列に変換し、この新たなArrayインスタンスを使ってStringのインスタンスを定義しましょう。

instance Hashable String where
  hash = hash <<< toCharArray

これらの Hashableインスタンスが先ほどの型クラスの法則を満たしていることを証明するにはどうしたらいいでしょうか。 同じ値が等しいハッシュコードを持っていることを確認する必要があります。 IntCharStringBooleanのような場合は単純です。 Eqの意味では同じ値でも厳密には同じではない、というような型の値は存在しないからです。

もっと面白い型についてはどうでしょうか。 Arrayインスタンスの型クラスの法則を証明するにあたっては、配列の長さに関する帰納を使えます。 長さゼロの唯一の配列は []です。 配列の Eqの定義により、任意の2つの空でない配列は、それらの先頭の要素が同じで配列の残りの部分が等しいとき、またその時に限り等しくなります。 この帰納的な仮定により、配列の残りの部分は同じハッシュ値を持ちますし、もし Hashable aインスタンスがこの法則を満たすなら、先頭の要素も同じハッシュ値を持つことがわかります。 したがって、2つの配列は同じハッシュ値を持ち、Hashable (Array a)も同様に型クラス法則に従います。

この章のソースコードには、 MaybeTuple型のインスタンスなど、他にも Hashableインスタンスの例が含まれています。

演習

  1. (簡単)PSCiを使って、定義した各インスタンスのハッシュ関数をテストしてください。 補足:この演習には単体試験がありません。

  2. (普通)関数arrayHasDuplicatesを書いてください。 この関数はハッシュと値の同値性に基づいて配列が重複する要素を持っているかどうかを調べます。 まずハッシュ同値性をhashEqual関数で確認し、それからもし重複するハッシュの対が見付かったら==で値の同値性を確認してください。 手掛かりData.ArraynubByEq関数はこの問題をずっと簡単にしてくれるでしょう。

  3. (普通)型クラスの法則を満たす、次の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は等しいと見なされます。 そのインスタンスが型クラスの法則を満たしていることを証明してください。

  1. (難しい)MaybeEitherそして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

勿論、これは期待通り型エラーになります。 addressMaybe 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で定義されています。

任意個の引数を持つ関数の持ち上げ

これで、lift2lift3のような関数を使えば、引数が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の例では型構築子fMaybeですから、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つの演算子はよく一緒に使われます。

なお、このapplyData.Functionapply(中置で$)とは異なります。 幸いにも後者はほぼ常に中置記法として使われるので、名前の衝突については心配ご無用です。

applyの型はmapの型と実によく似ています。 mapapplyの違いは、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

この型クラスのインスタンスで書かれているのは、任意の省略可能な値に省略可能な関数を適用でき、その両方が定義されている時に限り結果も定義される、ということです。

それでは、mapapplyを一緒に使い、引数が任意個の関数を持ち上げる方法を見ていきましょう。

1引数の関数については、mapをそのまま使うだけです。

2引数関数については、型a -> b -> cのカリー化された関数gがあるとします。これは型a -> (b -> c)と同じですから、Functorインスタンス付きのあらゆる型構築子fについて、mapfに適用すると型f a -> f (b -> c)の新たな関数を得ることになります。持ち上げられた(型f aの)最初の引数にその関数を部分適用すると、型f (b -> c)の新たな包まれた関数が得られます。fApplyインスタンスもあるなら、そこから、2番目の持ち上げられた(型f bの)引数へapplyを適用でき、型f cの最終的な値を得ます。

纏めると、x :: f ay :: 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記法を使っています。 なお、adodoの代わりに使われており、生み出された値を示すために最後の行で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

ApplicativeApplyの下位クラスであり、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が作用を持つ、より大きなプログラミング言語を表すとすると、ApplyApplicativeインスタンスは小さなプログラミング言語 (PureScript) から新しい大きな言語へと値や関数を持ち上げることを可能にします。

pureは純粋な(副作用がない)値をより大きな言語へと持ち上げますし、関数については上で述べた通りmapapplyを使えます。

ここで疑問が生まれます。 もしPureScriptの関数と値を新たな言語へ埋め込むのにApplicativeが使えるなら、どうやって新たな言語は大きくなっているというのでしょうか。 この答えは関手fに依存します。 もしなんらかのxについてpure xで表せないような型f aの式を見つけたなら、その式はそのより大きな言語だけに存在する項を表しているということです。

fMaybeのときは、式Nothingがその例になっています。 どんなxがあってもNothingpure 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型を使うことになるでしょう。 fullNameMaybeの上へ持ち上げると、欠けている引数を検査する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のリストを構築する関数が存在します。 例えばもしn3なら、関数は\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関手について考えるときに再訪します。

演習

  1. (普通)数値演算子+-*/の別のバージョンを書いてください。 ただし省略可能な引数(つまりMaybeに包まれた引数)を扱ってMaybeに包まれた値を返します。 これらの関数にはaddMaybesubMaybemulMaybedivMaybeと名前を付けてください。 手掛かりlift2を使ってください。
  2. (普通)上の演習を(Maybeだけでなく)全てのApply型で動くように拡張してください。 これらの新しい関数にはaddApplysubApplymulApplydivApplyと名前を付けます。
  3. (難しい)型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関数を使って空文字列でないことを検証しています。 もし入力が空ならnonEmpty1Left構築子で示されるエラーを返します。 そうでなければ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

validateAddressAddressの構造を検証します。 streetcityが空でないかどうか、stateの文字列の長さが2であるかどうかを検証します。

nonEmptylengthIsの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が使われています。

これまでと同様に、validatePhoneNumbermatches関数から構築されています。

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"])

演習

  1. (簡単)正規表現stateRegex :: Regexを書いて文字列が2文字のアルファベットであることを確かめてください。 手掛かりphoneNumberRegexのソースコードを参照してみましょう。
  2. (普通)文字列全体が空白でないことを検査する正規表現nonEmptyRegex :: Regexを書いてください。 手掛かり:この正規表現を開発するのに手助けが必要なら、RegExrをご確認ください。 素晴しい早見表と対話的なお試し環境があります。
  3. (普通)validateAddressに似ていますが、上のstateRegexを使ってstateフィールドを検証し、nonEmptyRegexを使ってstreetcityフィールドを検証する関数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を使っています。

traverseData.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は良いきっかけとなる例です。

全ての巡回可能関手はFunctorFoldableのどちらでもあります(畳み込み可能関手は畳み込み操作に対応する型構築子であったことを思い出してください。 畳み込みとは構造を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フィールドを検証できるようにするのに必要なものです。 各要素が成功するかを検証する検証関数を作るために、validatePhoneNumbertraverseへ渡しています。

一般に、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)

巡回可能関手はデータ構造走査の考え方を見据えたものです。 これにより作用のある計算の集合を集めてその作用を結合します。 実際、sequencetraversableTraversableを定義する上でどちらも同じくらい重要です。 これらはお互いがお互いを利用して実装できます。 これについては興味ある読者への演習として残しておきます。

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 aTuple aEither aが含まれます。 一般に、「容器」のようなほとんどのデータ型構築子はTraversableインスタンスを持っています。 一例として、演習には二分木の型のTraversableインスタンスを書くことが含まれます。

演習

  1. (簡単)EqShowインスタンスを以下の2分木データ構造に対して書いてください。

    data Tree a = Leaf | Branch (Tree a) a (Tree a)
    

    これらのインスタンスを手作業で書くこともできますし、コンパイラに導出してもらうこともできることを前の章から思い起こしてください。

    Showの出力には多くの「正しい」書式の選択肢があります。 この演習のテストでは以下の空白スタイルを期待しています。 これは一般化されたshowの既定の書式と合致しているため、このインスタンスを手作業で書くつもりのときだけ、このことを念頭に置いておいてください。

    (Branch (Branch Leaf 8 Leaf) 42 Leaf)
    
  2. (普通)TraversableインスタンスをTree aに対して書いてください。 これは副作用を左から右に結合するものです。 手掛かりTraversableに定義する必要のある追加のインスタンス依存関係が幾つかあります。

  3. (普通)行き掛け順に木を巡回する関数traversePreOrder :: forall a m b. Applicative m => (a -> m b) -> Tree a -> m (Tree b)を書いてください。 つまり作用の実行は根左右と行われ、以前の通り掛け順の巡回の演習でしたような左根右ではありません。 手掛かり:追加でインスタンスを定義する必要はありませんし、前に定義した関数は何も呼ぶ必要はありません。 アプリカティブdo記法 (ado) はこの関数を書く最も簡単な方法です。

  4. (普通)木を帰り掛け順に巡回する関数traversePostOrderを書いてください。作用は左右根と実行されます。

  5. (普通)homeAddressフィールドが省略可能(Maybeを使用)な新しい版のPerson型をつくってください。 それからこの新しいPersonを検証する新しい版のvalidatePersonvalidatePersonOptionalAddressと改名します)を書いてください。 手掛かりtraverseを使って型Maybe aのフィールドを検証してください。

  6. (難しい)sequenceのように振る舞う関数sequenceUsingTraverseを書いてください。 ただしtraverseを使ってください。

  7. (難しい)traverseのように振る舞う関数traverseUsingSequenceを書いてください。 ただしsequenceを使ってください。

アプリカティブ関手による並列処理

これまでの議論では、アプリカティブ関手がどのように「副作用を結合」させるかを説明するときに、「結合」(combine) という単語を選びました。 しかし、これらの全ての例において、アプリカティブ関手は作用を「連鎖」(sequence) させる、というように言っても同じく妥当です。 巡回可能関手がデータ構造に従って作用を順番に結合させるsequence関数を提供していることと、この直感的理解とは一致するでしょう。

しかし一般には、アプリカティブ関手はこれよりももっと一般的です。 アプリカティブ関手の規則は、その計算の副作用にどんな順序付けも強制しません。 実際、並列に副作用を実行するアプリカティブ関手は妥当でしょう。

例えばV検証関手はエラーの配列を返しますが、その代わりにSet半群を選んだとしてもやはり正常に動き、このときどんな順序で各検証器を実行しても問題はありません。 データ構造に対して並列にこれの実行さえできるのです。

2つ目の例として、parallelパッケージは並列計算に対応するParallel型クラスを提供します。 Parallelは関数parallelを提供しており、何らかのApplicative関手を使って入力の計算の結果を並列に計算します。

f <$> parallel computation1
  <*> parallel computation2

この計算はcomputation1computation2を非同期に使って値の計算を始めるでしょう。そして両方の結果の計算が終わった時に、関数fを使って1つの結果へと結合するでしょう。

この考え方の詳細は、本書の後半で コールバック地獄 の問題に対してアプリカティブ関手を応用するときに見ていきます。

アプリカティブ関手は並列に結合できる副作用を捉える自然な方法です。

まとめ

この章では新しい考え方を沢山扱いました。

  • アプリカティブ関手の概念を導入しました。 これは、関数適用の概念から副作用の観念を捉えた型構築子へと一般化するものです。
  • データ構造の検証という課題をアプリカティブ関手やその切り替えで解く方法を見てきました。 単一のエラーの報告からデータ構造を横断する全てのエラーの報告へ変更できました。
  • Traversable型クラスに出会いました。巡回可能関手の考え方を内包するものであり、要素が副作用を持つ値の結合に使うことができる容れ物でした。

アプリカティブ関手は多くの問題に対して優れた解決策を与える興味深い抽象化です。 本書を通じて何度も見ることになるでしょう。 今回の場合、アプリカティブ関手は宣言的な流儀で書く手段を提供していましたが、これにより検証器がどうやって検証を実施するかではなく、何を検証すべきなのかを定義できました。 一般にアプリカティブ関手が領域特化言語を設計する上で便利な道具になることを見ていきます。

次の章では、これに関連する考え方であるモナドクラスを見て、住所録の例をブラウザで実行させられるように拡張しましょう。

作用モナド

この章の目標

前章では、副作用を扱うのに使う抽象化であるアプリカティブ関手を導入しました。 副作用とは省略可能な値、エラー文言、検証などです。 この章では、副作用を扱うためのより表現力の高い別の抽象化であるモナドを導入します。

この章の目的は、なぜモナドが便利な抽象化なのかということと、do記法との関係を説明することです。

プロジェクトの準備

このプロジェクトでは、以下の依存関係が追加されています。

  • effect: 章の後半の主題であるEffectモナドを定義しています。 この依存関係は全てのプロジェクトで始めから入っているものなので(これまでの全ての章でも依存関係にありました)、明示的にインストールしなければいけないことは稀です。
  • react-basic-hooks: 住所録アプリに使うwebフレームワークです。

モナドとdo記法

do記法は配列内包表記を扱うときに初めて導入されました。 配列内包表記はData.ArrayモジュールのconcatMap関数の構文糖として提供されています。

次の例を考えてみましょう。2つのサイコロを振って出た目を数え、出た目の合計が nのときそれを得点とすることを考えます。次のような非決定的なアルゴリズムを使うとこれを実現できます。

  • 最初の投擲で値 x選択 します。
  • 2回目の投擲で値 y選択 します。
  • もしxyの和が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関手について定義されているのでした。 MaybeApplicative関手の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

この計算 whatToDoNextvalueに依存できます。

複数の束縛が関係している場合、この規則は先頭のほうから複数回適用されます。例えば、先ほど見た 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つのモナドの式m1m2m3が含まれています。 どちらの場合でもm1の結果は結局は名前xに束縛され、m2の結果は名前yに束縛されます。

c1では2つの式m1m2が各do記法ブロック内にグループ化されています。

c2ではm1m2m3の3つ全ての式が同じdo記法ブロックに現れています。

結合法則は入れ子になったdo記法ブロックをこのように単純化しても問題ないことを言っています。

補足:do記法をbindの呼び出しへと脱糖する定義により、 c1c2は何れも次のコードと同じです。

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)

もしmMonad型クラスの法則の縛りがあれば、apで与えられるmについて妥当な Applyインスタンスが存在します。

興味のある読者はこれまで登場したモナドについてこのapapplyとして充足することを確かめてみてください。 モナドはArrayMaybeEither eといったものです。

もし全てのモナドがアプリカティブ関手でもあるなら、アプリカティブ関手についての直感的理解を全てのモナドについても適用できるはずです。 特に、モナドが更なる副作用の組み合わせで増強された「より大きな言語」でのプログラミングといろいろな意味で一致することを予想するのはもっともです。 mapapplyを使って、引数が任意個の関数をこの新しい言語へと持ち上げることができるはずです。

しかし、モナドはアプリカティブ関手でできること以上ができ、重要な違いは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型クラスのインターフェイスだけを使うのでは、このように以前の値へ依存できません。

pureapplyだけを使って userCityを書こうとしてみれば、これが不可能であることがわかるでしょう。 アプリカティブ関手ができるのは関数の互いに独立した引数を持ち上げることだけですが、モナドはもっと興味深いデータの依存関係に関わる計算を書くことを可能にします。

前の章ではApplicative型クラスは並列処理を表現できることを見ました。 持ち上げられた関数の引数は互いに独立していますから、これはまさにその通りです。 Monad型クラスは計算が前の計算の結果に依存できるようになっており、同じようにはなりません。 つまりモナドは副作用を順番に組み合わせなければならないのです。

演習

  1. (簡単)3つ以上の要素がある配列の3つ目の要素を返す関数thirdを書いてください。 関数は適切なMaybe型で返します。 手掛かりarraysパッケージのData.Arrayモジュールからheadtail関数の型を見つけ出してください。 これらの関数を組み合わせるにはMaybeモナドと共にdo記法を使ってください。

  2. (普通)一掴みの硬貨を使ってできる可能な全ての合計を決定する関数 possibleSumsを、 foldMを使って書いてみましょう。 入力の硬貨は、硬貨の価値の配列として与えられます。この関数は次のような結果にならなくてはいけません。

    > possibleSums []
    [0]
    
    > possibleSums [1, 2, 10]
    [0,1,2,3,10,11,12,13]
    

    手掛かりfoldMを使うと1行でこの関数を書けます。 重複を取り除いたり、結果を並び替えたりするのに、nub関数やsort関数を使うことでしょう。

  3. (普通)ap関数とapply演算子がMaybeモナドを充足することを確かめてください。 補足:この演習にはテストがありません。

  4. (普通)Maybe型についてのMonadインスタンスが、モナド則を満たしていることを検証してください。 このインスタンスはmaybeパッケージで定義されています。 補足:この演習にはテストがありません。

  5. (普通)リスト上のfilterの関数を一般化した関数filterMを書いてください。 この関数は次の型シグネチャを持ちます。

    filterM :: forall m a. Monad m => (a -> m Boolean) -> List a -> m (List a)
    
  6. (難しい)全てのモナドには次で与えられるような既定の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コードを生成します。 mainEffectモナドでの計算であることが要求されます。

作用モナド

Effectは副作用のある計算を充分に型付けするAPIを提供すると同時に、効率的なJavaScriptを生成します。

馴染みのあるlog関数から返る型を見てみましょう。 Effectはこの関数がネイティブな作用を生み出すことを示しており、この場合はコンソールIOです。

Unitはいかなる意味のあるデータも返らないことを示しています。 UnitはC、Javaなど他の言語でのvoidキーワードと似たものとして考えられます。

log :: String -> Effect Unit

余談 :より一般的な(そしてより込み入った型を持つ)Effect.Class.Consolelog関数をIDEから提案されるかもしれません。 これは基本的なEffectモナドを扱う際はEffect.Consoleからの関数と交換可能です。 より一般的なバージョンがあることの理由は「モナドな冒険」章の「モナド変換子」について読んだあとにより明らかになっていることでしょう。 好奇心のある(そしてせっかちな)読者のために言うと、これはEffectMonadEffectインスタンスがあるから機能するのです。

log :: forall m. MonadEffect m => String -> m Unit

それでは意味のあるデータを返すEffectを考えましょう。 Effect.Randomrandom関数は乱択された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.01.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

tryEffectを走らせて起こりうる例外を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のコードで例外を生成するのは避け、代わりにEitherMaybeのようなネイティブでない作用でエラーや欠けた値を使うのが一番だからです。

可変状態

中核ライブラリには 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

そうして、参照領域はそのスコープから逃れられないことと、安全にrefvarに変換できることにコンパイラが気付きます。 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を生成する良い方法となります。 作用を持つ繰り返しを生成するforforeachwhileのような動作を一緒に使うときは特にそうです。

演習

  1. (普通)safeDivide関数を書き直し、もし分母がゼロならthrowExceptionを使って文言"div zero"の例外を投げるようにしたものをexceptionDivideとしてください。
  2. (普通)関数estimatePi :: Int -> Numberを書いてください。 この関数はnGregory Seriesを使ってpiの近似を計算するものです。 手掛かり:解答は上記のsimulateの定義に倣うことができます。 またData.InttoNumber :: Int -> Numberを使って、IntNumberに変換する必要があるかもしれません。
  3. (普通)n番目のフィボナッチ数を計算する関数fibonacci :: Int -> Intを書いてください。 STを使って前2つのフィボナッチ数の値を把握します。 PSCiを使い、STに基づく新しい実装の実行速度を第5章の再帰による実装と比較してください。

DOM作用

この章の最後の節では、Effectモナドでの作用についてこれまで学んだことを、実際のDOM操作の問題に応用します。

DOMを直接扱ったり、オープンソースのDOMライブラリを扱ったりするPureScriptパッケージが沢山あります。 例えば以下です。

  • web-domはW3CのDOM規格に向けた型定義と低水準インターフェース実装を提供します。
  • web-htmlはW3CのHTML5規格に向けた型定義と低水準インターフェース実装を提供します。
  • jqueryjQueryライブラリのバインディングの集まりです。

上記のライブラリを土台に抽象化を進めたPureScriptライブラリもあります。 以下のようなものです。

  • thermitereactを土台に構築されています。
  • react-basic-hooksreact-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

もしspagoparcelのような開発ツールが大域的にインストールされていれば、npxの前置は省けるでしょう。 恐らく既にspagonpm 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"

途中のwdoc変数が読みやすさの助けになるかは主観的な好みの問題です。

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.textJSXを返し、そしてpureは描画されたJSXに持ち上げます。 これでcomponentにはReactComponentを生成するのに必要な全てがあります。

次に、完全な住所録コンポーネントにある幾つかの複雑な事柄を調べていきます。

これらは完全なコンポーネントの最初の数行です。

mkAddressBookApp :: Effect (ReactComponent {})
mkAddressBookApp = do
  reactComponent "AddressBookApp" \props -> R.do
    Tuple person setPerson <- useState examplePerson

personuseStateフックの状態の一部として追跡します。

Tuple person setPerson <- useState examplePerson

なお、複数回useStateを呼び出すことで、コンポーネントの状態を複数の状態の部品に分解することが自在にできます。 例えばPersonの各レコードフィールドについて分離した状態の部品を使って、このアプリを書き直すことができるでしょう。 しかしこの場合にそうすると僅かに利便性を損なうアーキテクチャになってしまいます。

他の例ではTuple用の/\中置演算子に出喰わすかもしれません。 これは先の行と等しいものです。

firstName /\ setFirstName <- useState p.firstName

useStateは、既定の初期値を取って現在の値と値を更新する方法を取ります。 useStateの型を確認すれば型personsetPersonについてより深い洞察が得られます。

useState ::
  forall state.
  state ->
  Hook (UseState state) (Tuple state ((state -> state) -> Effect Unit))

結果の値の梱包Hook (UseState state)は取り去ることができますが、それはuseStateR.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でない状態もまた、この更新パターンに従います。 ベストプラクティスについて、より詳しいことはこの手引きを参照してください。

useStateR.doブロックの中で使われていることを思い出しましょう。 R.dodoの特別な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タグ(例:divformh3liullabelinput)に対応する関数を適用することで作られるのが普通です。 これらの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スタイルのクラスを定義します。 このプロジェクトではBootstrapstylesheetを使っており、これは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要素中のテキストの値を提供します。 この場合は型変数aMaybe 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

setValueformFieldの各呼び出しに与えた関数で、文字列を取りsetPersonフックに適切なレコード更新呼び出しを実施します。

なお、handleValueは以下のようにも置き換えられます。

onChange: handler targetValue $ traverse_ setValue

traverse_の定義を調査して、両方の形式が確かに等価であることをご確認ください。

これでコンポーネント実装の基本を押さえました。 しかし、コンポーネントの仕組みを完全に理解するためには、この章に付随するソースをお読みください。

明らかに、このユーザーインターフェースには改善すべき点が沢山あります。 演習ではアプリケーションがより使いやすくなるような方法を追究していきます。

演習

以下の演習ではsrc/Main.pursを変更してください。 これらの演習には単体試験はありません。

  1. (簡単)このアプリケーションを変更し、職場の電話番号を入力できるテキストボックスを追加してください。

  2. (普通)現時点でアプリケーションは検証エラーを単一の「pink-alert」背景に集めて表示させています。 空行で分離することにより、各検証エラーにpink-alert背景を持たせるように変更してください。

    手掛かり:リスト中の検証エラーを表示するのにul要素を使う代わりに、コードを変更し、各エラーにalertalert-danger装飾を持つdivを作ってください。

  3. (難しい、発展)このユーザーインターフェイスの問題の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ライブラリにはxhr2NPMモジュールが必要です。 このモジュールはこの章のpackage.json中の依存関係に挙げられています。 以下を走らせてインストールします。

$ npm install

非同期なJavaScript

JavaScriptで非同期なコードに取り組む上で便利な手段はasyncawaitです。 非同期な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

なお、mainEffect Unitでなければならないので、launchAff_を使ってAffからEffectへと変換せねばなりません。

上のコード片をコールバックや同期関数を使って書き換えることも可能です(例えばNode.FS.AsyncNode.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パターンを組み合わせることで、以下の演習に挑戦できるでしょう。

演習

  1. (簡単)2つのテキストファイルを連結する関数concatenateFilesを書いてください。

  2. (普通)複数のテキストファイルを連結する関数concatenateManyを書いてください。 入力ファイル名の配列と出力ファイル名が与えられます。 手掛かりtraverseを使ってください。

  3. (普通)ファイル中の文字数を返すか、エラーがあればそれを返す関数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", ...}}

演習

  1. (簡単)与えられた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動作を組み合わせます。 両方の結果が返されたら、最終結果を計算してメインの継続に渡せます。

アプリカティブ関手では任意個の引数の関数の持ち上げができるので、このアプリカティブコンビネータを使ってより多くの計算を並列に実行できます。 traversesequenceといった、アプリカティブ関手を扱う全ての標準ライブラリ関数から恩恵を受けることもできます。

直列的なコードの一部と並列計算を組み合わせることもできます。 それにはdo記法ブロック中でアプリカティブコンビネータを使います。 その逆も然りで、必要に応じてparralelsequentialを使って型構築子を変更すれば良いのです。

直列実行と並列実行の間の違いを実演するために、100個の10ミリ秒の遅延からなる配列をつくり、それからその遅延を両方の手法で実行します。REPLで試すとseqDelayparDelayより遥かに遅いことに気付くでしょう。並列実行が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人の利用者から並列で情報を取得します。 この場合ではparTarversetraverseの並列版)が使われていますね。 この例は代わりに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のドキュメントの節にもより多くの例が含まれています。

演習

  1. (簡単)前のconcatenateMany関数と同じシグネチャを持つconcatenateManyParallel関数を書いてください。 ただし全ての入力ファイルを並列に読むようにしてください。

  2. (普通)与えられたURLへHTTP GETを要求して以下の何れかを返すgetWithTimeout :: Number -> String -> Aff (Maybe String)関数を書いてください。

    • Nothing: 要求してから与えられた時間制限(ミリ秒単位)より長く掛かった場合。
    • 文字列の応答:時間制限を越える前に要求が成功した場合。
  3. (難しい)「根」のファイルを取り、そのファイルの中の全てのパスの一覧(そして一覧にあるファイルの中の一覧も)の配列を返す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の関数はカリー化されていることを思い出してください。 diagonalNumberを取って関数を返す関数です。 そして返された関数は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は、型abの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;

本書の残りの例では入れ子の関数の代わりに矢印関数を使います。

演習

  1. (普通)Test.MySolutionsモジュールの中に箱の体積を求めるJavaScriptの関数volumeFnを書いてください。 Data.Function.Uncurriedの梱包Fnを使ってください。
  2. (普通)volumeFnを矢印関数を使って書き直し、volumeArrowとしてください。

単純な型を渡す

以下のデータ型はPureScriptとJavaScriptの間でそのまま渡し合うことができます。

PureScriptJavaScript
BooleanBoolean
StringString
Int, NumberNumber
ArrayArray
RecordObject

StringNumberという原始型の例は既に見てきました。 ここからArrayRecord(JavaScriptではObject)といった構造的な型を眺めていきます。

Arrayを渡すところを実演するために、以下にIntArrayを取って別の配列として累計の和を返すJavaScriptの関数の呼び出し方を示します。 前にありましたが、JavaScriptはIntのための分離した型を持たないため、PureScriptでのIntNumberは両方共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の節でより詳しく解説していきます。 型の不整合から身を守る手法についても押さえます。

演習

  1. (普通)Complexな数の配列を取って別の複素数の配列として累計の和を返すJavaScriptの関数cumulativeSumsComplex(と対応するPureScriptの外部インポート)を書いてください。

単純な型を越えて

StringNumberArray、そして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モジュールをインポートして使うことはお勧めしません。というのもコードがコード生成器の変化に対して脆くなるからです。createvalueは公開の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

どちらの形式でも動きますが、後者はJustNothingの場所での招かれざる入力に対してより脆弱です。

例えば、比較的脆い方では、以下のように呼び出せるでしょう。

maybeHeadImpl (\_ -> Just 1000) (Just 1000) [1,2,3]

これは如何なる配列の入力に対してもJust 1000を返します。

この脆弱性では、aIntのときに(これは入力の配列に基づきます)(\_ -> Just 1000)Just 1000がシグネチャ(a -> Maybe a)Maybe aにそれぞれ照合するために許容されてしまっています。

より安全な型シグネチャでは、入力の配列に基づいてaIntに決定されたとしても、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で isUndefinedundefinedHeadを一緒に使用すると、便利な関数を定義できます。

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');
  }
};

演習

  1. (普通)二次多項式 \( 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に渡してください。

  2. (普通)関数toMaybe :: forall a. Undefined a -> Maybe aを書いてください。 この関数はundefinedNothingに、aの値をJust aに変換します。

  3. (難しい)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で走らせた場合、印字の合間に僅かな遅延があり、より予測に近い挙動をします。

他にプロミスから値を返す例を見てみましょう。 この関数はasyncawaitを使って書かれていますが、これはプロミスの糖衣構文に過ぎません。

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を返すため、この型をPromiseAffの梱包の中で表現します。

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関数cumulativeSumsaddComplexを再訪し、それぞれに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が配列に含まれているからです。 そして更なる操作は即時のエラーではなく予期しない挙動を生み出します。 というのもこれらのsumssum10ではなく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

そうすると返る型へのデコードが成功しなかったどんな値もLeftStringなエラーとして表れます。

$ 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を使うのは、MapSetのようなその他の構造的な型をFFI越しに渡す、最も簡単な方法でもあります。 JSONは真偽値、数値、文字列、配列、そして他のJSONの値からなるオブジェクトのみから構成されるため、JSONでは直接MapSetを書けません。 しかしこれらの構造を配列としては表現でき(キーとバリューもまたJSONで表現されているとします)、それからMapSetに復元できるのです。

以下は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)]))

演習

  1. (普通)Map中の全てのバリューのSetを返すJavaScriptの関数とPureScriptの梱包valuesOfMap :: Map String Int -> Either JsonDecodeError (Set Int)を書いてください。

  2. (簡単)より広い種類のマップに関して動作するよう、前のJavaScriptの関数の新しい梱包を書いてください。 シグネチャはvaluesOfMapGeneric :: forall k v. Map k v -> Either JsonDecodeError (Set v)です。 なお、kvに幾つかの型クラス制約を加える必要があるでしょう。 コンパイラが導いてくれます。

  3. (普通)少し前のquadraticRoots関数を書き換えてquadraticRootSetとしてください。 この関数はComplexの根をJSONを介して(Pairの代わりに)Setとして返します。

  4. (難しい)少し前のquadraticRoots関数を書き換えてquadraticRootsSafeとしてください。 この関数はJSONを使ってComplexの根のPairをFFI越しに渡します。 JavaScriptではPair構築子を使わないでください。 その代わり、デコーダーに互換性のある形式で対を返すだけにしてください。 手掛かりDecodeJsonインタンスをPair用に書く必要があるでしょう。 独自のデコードインスタンスを書く上での説明についてはargonautのドキュメントをあたってください。 decodeJsonTupleインスタンスも参考になるかもしれません。 「孤立インスタンス」を作ることを避けるために、Pairnewtypeの梱包が必要になる点に注意してください。

  5. (普通)2次元配列を含むJSON文字列を構文解析してデコードするparseAndDecodeArray2D :: String -> Either String (Array (Array Int))関数を書いてください。 例えば"[[1, 2, 3], [4, 5], [6]]"です。 手掛かり:デコードの前にjsonParserを使ってStringJsonに変換する必要があるでしょう。

  6. (普通)以下のデータ型は値が葉にある二分木を表現します。

    data Tree a
      = Leaf a
      | Branch (Tree a) (Tree a)
    

    汎化されたEncodeJson及びDecodeJsonインスタンスをTree型用に導出してください。 このやり方についての説明はargonautのドキュメントをあたってください。 なお、この演習の単体試験を有効にするには、汎化されたShow及びEqインスタンスも必要になります。 しかしJSONのインスタンスと格闘したあとでは、これらの実装は直感的に進むことでしょう。

  7. (難しい)以下のdata型は整数か文字列かによってJSONで異なって表現されます。

    data IntOrString
      = IntOrString_Int Int
      | IntOrString_String String
    

    この挙動を実装するIntOrStringデータ型に、EncodeJson及びDecodeJsonインスタンスを書いてください。 手掛かりControl.Altalt演算子が役立つかもしれません。

住所録

この節では新しく獲得したFFIとJSONの知識を応用して、第8章の住所録の例を構築していきたいと思います。 以下の機能を加えていきます。

  • 保存ボタンをフォームの一番下に配置し、クリックしたときにフォームの状態をJSONに直列化してローカルストレージに保存します。
  • ページの再読み込み時にローカルストレージからJSON文書を自動的に取得します。 フォームのフィールドにはこの文書の内容を入れます。
  • フォームの状態を保存したり読み込んだりするのに問題があればポップアップの警告を出します。

Effect.Storageモジュールに以下のwebストレージAPIのためのFFIの梱包を作ることから始めていきます。

  • setItemはキーと値(両方とも文字列)を受け取り、指定されたキーでローカルストレージに値を格納する計算を返します。
  • getItemはキーを取り、ローカルストレージから関連付けられたバリューの取得を試みます。 しかしwindow.localStoragegetItemメソッドは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レコード中のPhoneTypeEncodeJsonインスタンスを必要としているからです。 また、ついでに汎用のエンコードインスタンスとデコードインスタンスを導出していきます。 この仕組みについての詳細情報は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

最後にこのinitialPersonpropsレコードを介してコンポーネントに渡します。

-- reactコンポーネントからJSXノードを作成します。
app = element addressBookApp { initialPerson }

そして状態フックで使うために別の箇所で拾い上げます。

mkAddressBookApp :: Effect (ReactComponent { initialPerson :: Person })
mkAddressBookApp =
  reactComponent "AddressBookApp" \props -> R.do
    Tuple person setPerson <- useState props.initialPerson

仕上げとして、各Left値のStringlmapを使って前置し、エラー文言の質を向上させます。

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メソッドを使っています。 そういうわけでalertwindow.alertが定義された環境でのみ使うことができます。 webブラウザなどです。

foreign import alert :: String -> Effect Unit
export const alert = msg => () =>
  window.alert(msg);

この警告が次の何れかの場合に現れるようにしたいです。

  • 利用者が検証エラーを含むフォームを保存しようと試みている。
  • 状態がローカルストレージから取得できない。

以上は単に以下の行でlogalertに置き換えるだけで達成できます。

Left errs -> alert $ "There are " <> show (length errs) <> " validation errors."

alert $ "Error: " <> err <> ". Loading examplePerson"

演習

  1. (普通)localStorageオブジェクトの removeItemメソッドの梱包を書き、 Effect.Storageモジュールに外部関数を追加してください
  2. (普通)「リセット」ボタンを追加してください。 このボタンをクリックすると新しく作ったremoveItem関数を呼び出してローカルストレージから「人物」の項目を削除します。
  3. (簡単)JavaScriptの Windowオブジェクトの confirmメソッドの梱包を書き、 Effect.Alertモジュールにその外部関数を追加してください。
  4. (普通)利用者が「リセット」ボタンをクリックしたときにこの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-appspago 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を持っていれば、実行時のその値 vtypeof v === 'boolean'を満たします。 つまり、型 Booleanの式は trueもしくは falseのどちらか一方の(JavaScriptの)値へと評価されます。 特にnullundefinedに評価される型BooleanなPureScriptの式はありません。

IntNumberStringの型の式についても似た法則が成り立ちます。 IntNumber型の式はnullでないJavaScriptの数へと評価されますし、String型の式はnullでないJavaScriptの文字列へと評価されます。 typeofを使った場合に型Numberの値と見分けがつかなくなるにせよ、型Intの式は実行時に整数に評価されます。

Unitについてはどうでしょうか。 Unitには現住 (unit) が1つのみで値が観測できないため、実のところ実行時に何で表現されるかは重要ではありません。 古いコードは{}を使って表現する傾向がありました。 しかし比較的新しいコードではundefinedを使う傾向にあります。 なので、Unitを表現するのに使うものは何であれ差し支えありませんが、undefinedを使うことが推奨されます(関数から何も返さないときもundefinedを返します)。

もっと複雑な型についてはどうでしょうか。

既に見てきたように、PureScriptの関数は引数が1つのJavaScriptの関数に対応しています。 厳密に言えばこうなります。 ある型abについて、式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オブジェクト型ZeroOneを見てください。 JavaScriptのキーワードnewを使用すると、各型の値を作成できます。 引数を持つ構築子については、コンパイラはvalue0value1などという名前のフィールドに、対応するデータを格納します。

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 -> StringNumber -> NumberArray 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引数の(カリー化された)関数にコンパイルされていることに注意してください。最初の引数 dictShow制約の型クラス辞書です。 dictには型 ashow関数の実装が含まれています。

最初の引数として明示的にData.Showの型クラス辞書を渡すと、JavaScriptからこの関数を呼び出すことができます。

import { showNumber } from 'Data.Show'

shout(showNumber)(42);

演習

  1. (簡単)これらの型の実行時の表現は何でしょうか。

    forall a. a
    forall a. a -> a -> a
    forall a. Ord a => Array a -> Boolean
    

    これらの型を持つ式についてわかることは何でしょうか。

  2. (普通)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
>

プロンプトからは、lookinventorytakeusenorthsoutheastwestなどのコマンドを入力できます。 debugコマンドもあり、--debugコマンドラインオプションを与えられていた場合に、ゲームの状態を出力できます。

ゲームは2次元の碁盤の目の上が舞台で、コマンドnorthsoutheastwestを発行することによってプレイヤーが移動します。 ゲームにはアイテムの集まりがあり、プレイヤーの所有物であったり、ゲームの盤上の特定の位置にあったりします。 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は最終的な状態だけを返し、runStateTuple a s型の値として表現された両方を返します。

先ほどの sumArray関数が与えられているとき、PSCiで execStateを使うと次のように複数の配列内の数字を合計できます。

> :paste
… execState (do
…   sumArray [1, 2, 3]
…   sumArray [4, 5]
…   sumArray [6]) 0
… ^D
21

演習

  1. (簡単)上の例で、execStaterunStateevalStateで置き換えると結果はどうなるでしょうか。

  2. (普通)括弧からなる文字列が平衡しているのは、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
  1. (簡単)現在の字下げの深さで関数を書き出す関数lineを書いてください。 関数は以下の型を持ちます。

    line :: String -> Doc
    

    手掛かり:現在の字下げの深さを読み取るためには ask関数を使用します。 Data.Monoidpower関数も役に立つかもしれません。

  2. (普通)local関数を使用して次の関数を書いてください。

    indent :: Doc -> Doc
    

    この関数はコードブロックの字下げを深くします。

  3. (普通)Data.Traversableで定義された sequence関数を使用して、次の関数を書いてください。

    cat :: Array Doc -> Doc
    

    この関数は文書の集まりを改行で区切って連結します。

  4. (普通)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"]

演習

  1. (普通)Writerモナドと monoidパッケージの Additive Intモノイドを使うように、上の sumArray関数を書き換えてください。

  2. (普通)コラッツ関数は自然数 \( n \) 上で定義され、 \( n \) が偶数なら \( n / 2 \)、 \( n \) が奇数なら \( 3 n + 1 \)です。 例えば \( 10 \) で始まるコラッツ数列は以下です。

    10, 5, 16, 8, 4, 2, 1, ...
    

    コラッツ関数の有限回の適用を繰り返すと、コラッツ数列は必ず最終的に \( 1 \) になるということが予想されています。

    再帰を使い、数列が \( 1 \) に到達するまでに何回のコラッツ関数の適用が必要かを計算する関数を書いてください。

    Writerモナドを使用してコラッツ関数の各適用の経過を記録するように、関数を変更してください。

モナド変換子

上の3つのモナド、StateReaderWriterは、何れもいわゆるモナド変換子の例となっています。 対応する各モナド変換子はそれぞれStateTReaderTWriterTという名前です。

モナド変換子とは何でしょうか。 さて、これまで見てきたように、モナドはPureScriptのコードを何らかの種類の副作用で拡張するものでした。 このモナドはPureScriptで適切な制御子(runStaterunReaderrunWriterなど)を使って解釈できます。 使用する必要がある副作用が1つだけなら、これで問題ありません。 しかし、同時に複数の副作用を使用できると便利なことがよくあります。 例えば、 MaybeReaderを一緒に使用すると、ある大域的な設定の文脈で省略可能な結果を表現できます。 もしくは、 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モナドの動作(getputmodify)は直接使えますが、梱包されているモナド (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へと持ち上げるものです。 今回の場合、型構築子tStateT Stringで、mEither Stringモナドとなるので、liftは型Either String aの計算を、型StateT String (Either String) aの計算へと持ち上げる方法を提供することになります。 つまり、型Either String aの計算を使う場合に毎回liftを使うのであれば、StateT StringEither 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 ssの最初の文字)を返します。

それでは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つの例外の型だけを扱うのに対して、ExceptTError型クラスのどんな型のエラーでも扱います。つまり、 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モナドの変換はモナド変換子の作用だけを提供します。 StateReaderWriterモナドは、IdentityモナドをそれぞれStateTReaderTWriterTで変換することによって実装されています。

3つの副作用が組み合わっている例を見てみましょう。 Identityモナドをスタックの底にして、StateTWriterTExceptT作用を使います。 このモナド変換子スタックは、可変状態、ログの蓄積、そして純粋なエラーの副作用を提供します。

このモナド変換子スタックを使うと、ロギングの機能が追加された 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を、それからruntWriteTrunExceptTを使います。 最後に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変換子を移動すると、先ほどWriterExceptTに変換したときに見たように、最初のエラーまでに書かれた全ての文言がログに含まれるようになります。

このコードの問題の1つは、複数のモナド変換子の上まで計算を持ち上げるために、lift関数を複数回使わなければならないということです。 例えばthrowErrorの呼び出しは、1回目はWriteTへ、2回目はStateTへ、と2回持ちあげなければなりません。 小さなモナド変換子スタックならなんとかなりますが、そのうちすぐに不便になるでしょう。

幸いなことに、これから見るような型クラス推論によって提供されるコードの自動生成を使うと、ほとんどの「重労働」を任せられます。

演習

  1. (簡単)Identity関手を土台とするExceptTモナド変換子を使って、2つの数の商を求める関数 safeDivideを書いてください。 この関数は分母がゼロの場合に(文字列「Divide by zero!」の)エラーを投げます。

  2. (普通)次のような構文解析関数を書いてください。

    string :: String -> Parser String
    

    これは現在の状態が接頭辞に照合するか、もしくはエラー文言とともに失敗します。

    この構文解析器は次のように動作します。

    > runParser (string "abc") "abcdef"
    (Right (Tuple (Tuple "abc" "def") ["The state is abcdef"]))
    

    手掛かり:出発点としてsplitの実装が使えます。 stripPrefix関数も役に立つかもしれません。

  3. (難しい)文書表示ライブラリを、ReaderTWriterTモナド変換子を使用して再実装してください。 以前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パッケージではモナド変換子WriterTReaderTExceptTについてのMonadStateのインスタンスがあります。 通底するMonadMonadStateインスタンスを持つなら常に、これらもインスタンスを持ちます。 つまり実際には、StateTがモナド変換子スタックのどこかに現れ、StateTより上の全てがMonadStateのインスタンスであれば、liftを使う必要なくgetputmodifyを直接自由に使用できます。

当然ですが、これまで扱ってきたReaderTWriterTExceptT変換子についても、同じことが言えます。 transformersでは主な各変換子について型クラスが定義されています。 これによりそれらの操作に対応するモナドの上に抽象化できるのです。

上のsplit関数の場合、構築したモナドスタックは各型クラスMonadStateMonadWriterMonadErrorのインスタンスです。 つまりliftは全く呼び出す必要がないのです。 まるでモナドスタック自体に定義されていたかのように、動作getputtellthrowErrorをそのまま使用できます。

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にも等価なmanysomeがあります。

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\""
              ]))

繰り返しになりますが、これはモナド変換子が齎す再利用性の威力を示しています。 標準的な抽象化を再利用することで、宣言型スタイルのバックトラック構文解析器を、ほんの数行のコードで書くことができました。

演習

  1. (簡単)string構文解析器の実装からlift関数の呼び出しを取り除いてください。 新しい実装の型検査が通ることを確認し、そうなることを納得するまで確かめましょう。

  2. (普通)string構文解析器とsomeコンビネータを使って構文解析器asFollowedByBsを書いてください。 これは文字列"a"の連続と、それに続く文字列"b"の連続からなる文字列を認識するものです。

  3. (普通)<|>演算子を使って構文解析器asOrBsを書いてください。 これは文字aと文字bが任意の順序で現れる文字列を認識します。

  4. (難しい)Parserモナドを次のようにも定義できます。

    type Parser = ExceptT Errors (StateT String (WriterT Log Identity))
    

    このように変更すると、構文解析関数にどのような影響を与えるでしょうか。

RWSモナド

モナド変換子のとある特定の組み合わせは頻出なので、transformersパッケージ内の単一のモナド変換子として提供されています。 ReaderWriterStateのモナドは、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つの新しいデータ構造を使っています。 MapSetはそれぞれ整列されたマップと整列された集合を表します。 items属性は、ゲーム平面上の座標からその位置にあるゲームアイテムの集合への対応付けです。 player属性はプレイヤーの現在の座標を格納し、inventory属性は現在プレイヤーが保有するゲームアイテムの集合を格納します。

MapSetのデータ構造はキーによって整列され、このキーにはOrd型クラスの任意の型を使えます。 つまりデータ構造中のキーは完全に順序付けされます。

ゲームの動作を書く上でMapSet構造をどのように使っていくのかを見ていきます。

ログとしては List Stringモノイドを使います。 Gameモナド用の型同義語を定義し、RWSを使って実装できます。

type Log = L.List String

type Game = RWS GameEnvironment Log GameState

ゲームロジックの実装

ReaderWriterStateモナドの動作を再利用することで、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動作があります。 現在の位置にゲームアイテムがある場合、プレイヤーの持ち物にそのアイテムを追加します。 これにはMonadWriterMonadState型クラスの動作を使っています。 一番最初に現在のゲームの状態を読み取ります。

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が必要ないことに注意してください。 なぜならMonadStateMonadWriterの両方についてGameモナド変換子スタック用の適切なインスタンスが存在するからです。

putへの引数では、レコード更新を使ってゲームの状態のitems及びinventoryフィールドを変更しています。 また、特定のキーの値を変更するData.Mapupdate関数を使っています。 今回の場合、プレイヤーの現在の位置にあるアイテムの集合を変更するのに、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記法ブロック内でMonadStateMonadReaderMonadWriter型クラスで定義されている動作を使える点に注意してください。

debugModeフラグが設定されている場合、tell動作を使うとログに状態が書き込まれます。 そうでなければ、エラー文言が追加されます。

Gameモジュールの残りの部分では同様の動作の集合が定義されています。 各動作はMonadStateMonadReaderMonadWriter型クラスにより定義された動作のみを使っています。

計算の実行

このゲームロジックは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

runRWSrunReaderrunWriterrunStateを組み合わせたように見えます。 引数として大域的な構成及び初期状態を取り、ログ、結果、最終的な状態を含むデータ構造を返します。

このアプリケーションのフロントエンドは、次の型シグネチャを持つ関数runGameによって定義されます。

runGame :: GameEnvironment -> Effect Unit

この関数は(node-readlineconsoleパッケージを使って)コンソールを介してユーザとやり取りします。 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

演習

  1. (普通)ゲームの格子上にある全てのゲームアイテムをユーザの持ちものに移動する新しいコマンド cheatを実装してください。 関数cheat :: Game UnitGameモジュールに作り、この関数をgameから使ってください。

  2. (難しい)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.helperenvと名付けられた任意のコマンドライン引数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"
    ]

playerdebugは両方ともParserなので、アプリカティブ演算子<$><*>を使ってgameEnvironment関数を持ち上げられます。 この関数はParser上で型PlayerName -> Boolean -> GameEnvironmentを持ちます。 OP.strOptionは文字列値を期待するコマンドラインオプションを構築し、一緒に畳み込まれたModの集まりを介して構成されています。 OP.flagは似たような動作をしますが、関連付けられた値は期待しません。 optparseは多様なコマンドライン解析器を構築するために使える様々な修飾子について、大部のドキュメントを提供しています。

アプリカティブ演算子による記法を使うことで、コマンドラインインターフェイスの簡潔で宣言的な仕様を与えられた点に注目です。 加えて、新しいコマンドライン引数を追加するのは単純で、runGameに新しい関数引数を追加し、envの定義中で<*>を使って追加の引数までrunGameを持ち上げるだけでできます。

演習

  1. (普通)GameEnvironmentレコードに新しい真偽値のプロパティcheatModeを追加してください。 また、optparseの構成に、チートモードを有効にする新しいコマンドラインフラグ-cを追加してください。 チートモードが有効になっていない場合、前の演習のcheatコマンドは禁止されます。

まとめ

この章ではこれまで学んできた技術を実践的に実演しました。 モナド変換子を使用したゲームの純粋な仕様の構築、コンソールを使用したフロントエンドを構築するためのEffectモナドがそれです。

ユーザインターフェイスからの実装を分離したので、ゲームの別のフロントエンドも作成できるでしょう。 例えば、EffectモナドでCanvas APIやDOMを使用して、ブラウザでゲームを描画するようなことができるでしょう。

モナド変換子によって命令型のスタイルで安全なコードを書けることが分かりました。 このスタイルでは型システムによって作用が追跡されています。 加えて、型クラスはモナドが提供する動作へと抽象化する強力な方法を提供し、これによりコードの再利用が可能になりました。 標準的なモナド変換子を組み合わせることにより、AlternativeMonadPlusのような標準的な抽象化を使用して、役に立つモナドを構築できました。

モナド変換子は表現力の高いコードの優れた実演となっています。 これは高階多相や多変数型クラスなどの高度な型システムの機能を利用することによって記述できるものです。

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

CanvasElementContext2DGraphics.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
  }

xyプロパティは左上隅の位置を表しており、widthheightのプロパティはそれぞれ幅と高さを表しています。

arc関数に以下のような型を持つレコードを渡して呼び出すと、円弧を描画できます。

type Arc =
  { x      :: Number
  , y      :: Number
  , radius :: Number
  , start  :: Number
  , end    :: Number
  }

ここで、xyプロパティは弧の中心、radiusは半径、startendは弧の両端の角度を弧度法で表しています。

例えばこのコードは中心が(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型のxyというプロパティを含んでいますね。 どちらの場合でもこの組は点を表しています。 つまり、何れのレコード型にも作用する行多相な関数を書けます。

例えばShapesモジュールではxyのプロパティを変更し図形を並行移動する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
  }

この行多相型に注目してください。 translatexyというプロパティと、それに加えて他の任意のプロパティを持つどんなレコードでも受け入れ、同じ型のレコードを返すと書かれています。 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つの異なる図形が描画されるはずです。

演習

  1. (簡単)これまでの各例について、strokePath関数やsetStrokeStyle関数を使ってみましょう。

  2. (簡単)関数の引数の内部でdo記法ブロックを使うと、fillPath関数とstrokePath関数は共通のスタイルを持つ複雑なパスを描画できます。 同じfillPath呼び出しを使って隣り合う2つの矩形を描画するように、Rectangleの例を変更してみてください。 線分と円弧の組み合わせを使って、扇形を描画してみてください。

  3. (普通)次のような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記法ブロックは01の間に分布する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にはキャンバスの状態のスタックを操作するsaverestoreメソッドが備わっています。 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ファイルを開いてみましょう。 緑の四角形が表示され、何度かキャンバスをクリックするとキャンバスの中心の周りで回転するはずです。

演習

  1. (簡単)パスの線描と塗り潰しを同時に行う高階関数を書いてください。 その関数を使用してRandom.pursの例を書き直してください。
  2. (普通)Random作用とDom作用を使用して、マウスがクリックされたときに、キャンバスに無作為な位置、色、半径の円を描画するアプリケーションを作成してください。
  3. (普通)指定された座標の点を中心として回転させることでシーンを変換する関数を書いてください。 手掛かり:変換を使い、最初にシーンを原点まで平行移動しましょう。

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はどのような型を持っているべきでしょうか。 initialproductionsのような値だけでなく、アルファベットの文字をキャンバスに描画できる関数を引数に取る必要があります。

lsystemの型の最初の大まかな設計は以下です。

Sentence
-> (Letter -> Sentence)
-> (Letter -> Effect Unit)
-> Int
-> Effect Unit

最初の2つの引数の型は、値 initialproductionsに対応しています。

3番目の引数は、アルファベットの文字を取り、キャンバス上の幾つかの動作を実行することによって解釈する関数を表します。 この例では、文字Lは左回転、文字Rで右回転、文字Fは前進を意味します。

最後の引数は、実行したい生成規則の繰り返し回数を表す数です。

最初に気付くことは、このlsystem関数は1つの型Letterに対してのみ動作するのですが、どんなアルファベットについても機能すべきですから、この型はもっと一般化されるべきです。 それでは、量子化された型変数 aについて、LetterSentenceaArray 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
  }

プロパティ xyはパスの現在の位置を表しています。 プロパティ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)

これだけです。 foldMconcatMapのような高階関数を使うと、アイデアを簡潔に表現できるのです。

しかし、話はこれで終わりではありません。 ここで与えた型は、実際はまだ特殊化されすぎています。 この定義ではキャンバスの操作が実装のどこにも使われていないことに注目してください。 それに、全く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の型が教えてくれているのは、型シグネチャが、何らかの型 as、型構築子 mについて、 s -> a -> m sでなければならないということです。 aLettersState、モナド mEffectというように選びたいということがわかっています。 これにより次のような型になります。

    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を開いてみましょう。 キャンバスにコッホ曲線が描画されるのがわかると思います。

演習

  1. (簡単)strokePathの代わりに fillPathを使用するように、上のL-Systemの例を変更してください。 手掛かりclosePathの呼び出しを含め、 moveToの呼び出しを interpret関数の外側に移動する必要があります。

  2. (簡単)描画システムへの影響を理解するために、コード中の様々な数値の定数を変更してみてください。

  3. (普通)lsystem関数を2つの小さな関数に分割してください。 1つ目はconcatMapの適用の繰り返しを使用して最終的な文を構築するもので、2つ目は foldMを使用して結果を解釈するものでなくてはなりません。

  4. (普通)setShadowOffsetXsetShadowOffsetYsetShadowBlursetShadowColor動作を使い、塗りつぶされた図形にドロップシャドウを追加してください。 手掛かり:PSCiを使って、これらの関数の型を調べてみましょう。

  5. (普通)向きを変えるときの角度の大きさは今のところ一定 \( tau / 6 \) です。 これに代えて、Letterデータ型の中に角度を移動させ、生成規則によって変更できるようにしてください。

    type Angle = Number
    
    data Letter = L Angle | R Angle | F
    

    この新しい情報を生成規則でどう使うと、面白い図形を作ることができるでしょうか。

  6. (難しい)4つの文字からなるアルファベットでL-Systemが与えられたとします。 それぞれL(60度左回転)、R(60度右回転)、F(前進)、M(これも前進)です。

    このシステムの文の初期状態は、単一の文字 Mです。

    このシステムの生成規則は次のように指定されています。

    L -> L
    R -> R
    F -> FLMLFRMRFRMRFLMLF
    M -> MRFRMLFLMLFLMRFRM
    

    このL-Systemを描画してください。 補足:最後の文のサイズは反復回数に従って指数関数的に増大するので、生成規則の繰り返しの回数を削減する必要があります。

    ここで、生成規則における LMの間の対称性に注目してください。2つの「前進」命令は、次のようなアルファベット型を使用すると、Boolean値を使って区別できます。

    data Letter = L | R | F Boolean
    

    このアルファベットの表現を使用して、もう一度このL-Systemを実装してください。

  7. (難しい)解釈関数で別のモナド 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関数について知る必要があるものは全て、この性質に要約できます。

  • xsysが整列済みなら、merge xs ysは両方の配列が一緒に結合されて整列された結果になります。

quickcheckでは、無作為なテスト項目を生成することで、直接この性質をテストできます。 コードが持つべき性質を関数として述べるだけです。 この場合は1つの性質があります。

main = do
  quickCheck \xs ys ->
    eq (merge (sort xs) (sort ys)) (sort $ xs <> ys)

このコードを実行すると、quickcheckは無作為な入力xsysを生成してこの関数に渡すことで、主張した性質を反証しようとします。 何らかの入力に対して関数が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が無作為に選ばれた数の配列として生成されていることに注目してください。

演習

  1. (簡単)配列に空の配列を統合しても元の配列は変更されないことを確かめる性質を書いてください。 補足:この新しい性質は冗長です。 というのもこの状況は既に既存の性質で押さえられているからです。 ここでは読者がQuickCheckを使う練習のための簡単なやり方を示そうとしているだけです。
  2. (簡単)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関数が不明な型の曖昧さを解消するために使われているため、xsysは型Array Intを持っています。

演習

  1. (簡単)xsysの型をArray Booleanに強制する関数boolsを書き、mergePolyをその型でテストする性質を追加してください。
  2. (普通)標準関数から(例えばarraysパッケージから)1つ関数を選び、適切なエラー文言を含めてQuickCheckの性質を書いてください。 その性質は、補助関数を使って多相型引数を IntBooleanのどちらかに固定しなければいけません。

任意のデータの生成

それでは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)

このテストでは、任意の配列xsysを生成しますが、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)

これは些細な変更に見えるかもしれませんが、xsysの型はただの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

ここでは、引数 tTree Number型の無作為に生成された木です。 型引数は、同値関数 treeOfIntによって明確にされています。

演習

  1. (普通)a-zの範囲から無作為に選ばれた文字の集まりを生成する Arbitraryインスタンスを持つ、Stringのnewtypeを作ってください。 手掛かりTest.QuickCheck.Genモジュールから elementsarrayOf関数を使います。
  2. (難しい)木に挿入された値は、どれだけ沢山の挿入があった後でも、その木の構成要素であることを主張する性質を書いてください。

高階関数のテスト

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が適用されたあとの比較に基づいてxsysを整列します。

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のインスタンスを書く

GenMonadApplicativeインスタンスを使って独自のデータ型に対して 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アプリケーションのフォームデータ例を無作為に生成するというような状況で便利かもしれません。

演習

  1. (簡単)ByteSorted型構築子についての Coarbitraryインスタンスを書いてください。

  2. (普通)任意の関数 fについて、 mergeWith f関数の結合性を主張する(高階)性質を書いてください。 quickCheckPureを使ってPSCiでその性質をテストしてください。

  3. (普通)次のデータ型のArbitraryCoarbitraryインスタンスを書いてください。

    data OneTwoThree a = One a | Two a a | Three a a a
    

    手掛かりTest.QuickCheck.Genで定義されたoneOf関数を使ってArbitraryインスタンスを定義してください。

  4. (普通)allを使ってquickCheckPure関数の結果を単純化してください。 この新しい関数は型List Result -> Booleanを持ち、全てのテストが通ればtrueを、そうでなければfalseを返します。

  5. (普通)quickCheckPureの結果を単純にする別の手法として、関数squashResults :: List Result -> Resultを書いてみてください。 Data.Maybe.FirstFirstモノイドと共にfoldMap関数を使うことで、失敗した場合の最初のエラーを保持することを検討してください。

まとめ

この章ではquickcheckパッケージに出会いました。 これを使うと生成的テスティングのパラダイムを使って、宣言的な方法でテストを書くことができました。具体的には以下です。

  • spago testを使ってQuickCheckのテストを自動化する方法を見ました。
  • 性質を関数として書く方法とエラー文言を改良する<?>演算子の使い方を説明しました。
  • ArbitraryCoarbitrary型クラスによって定型的なテストコードの自動生成を可能にする方法や、高階な性質のテストを可能にする方法を見ました。
  • 独自のデータ型に対して ArbitraryCoarbitraryインスタンスを実装する方法を見ました。

領域特化言語

この章の目標

この章では多数の標準的な手法を使い、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文書を作成するために使える領域特化言語にしていきます。

スマート構築子

最初に導入する手法は単純ですがとても効果的です。 モジュールの使用者にデータの表現を露出する代わりに、モジュールエクスポートリストを使ってデータ構築子ElementContentAttributeを隠蔽します。 そして正しいことが分かっているデータを構築する、いわゆるスマート構築子だけをエクスポートします。

例を示しましょう。まず、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要素を構築できてしまいます。

AttributeContent型についてはデータ構築子を全てエクスポートしています(エクスポートリストの記号 ..で示されています)。 すぐ後で、これらの型にもスマート構築子の手法を適用していきます。

既にライブラリに幾つもの大きな改良が加わっていることに注目です。

  • 不正な名前を持つ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の使用者によって認識される表現から、モジュールの内部データ表現を分離できるのです。

演習

  1. (簡単)Data.DOM.Smartモジュールで renderを使った新しいHTML文書の作成を試してみましょう。

  2. (普通)checkeddisabledといった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

StringInt型についての型クラスインスタンスも提供しておきます。

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文書を表現することが不可能になっていることがわかります。 また、widthheight属性を表現するのに文字列ではなく数を使うことが強制されていることがわかります。

> 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

演習

  1. (簡単)ピクセルまたはパーセントの何れかの長さを表すデータ型を作成してください。 その型についてIsValueのインスタンスを書いてください。 この新しい型を使うようにwidthheight属性を変更してください。

  2. (難しい)真偽値truefalse用の最上位の表現を定義することで、幻影型を使ってAttributeKeydisabledcheckedのような空の属性を表現しているかどうかをエンコードできます。

    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モナドを使えば任意のFunctorMonadにできるのです。

モナドの動作の表現の定義から始めます。 これには対応したい各モナド動作について、1つのデータ構築子を持つFunctorを作成する必要があります。 今回の場合、2つのモナドの動作はelemtextになります。 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データ構築子を隠すことで、提供しているモナドの動作だけを使うことを使用者に制限しています。

ContentFFunctorなので、 Free ContentF用のMonadインスタンスが自動的に手に入ります。

Contentの新しい型引数を考慮するようにElementデータ型を僅かに変更する必要があります。 モナドの計算の戻り値の型が Unitであることだけが必要です。

newtype Element = Element
  { name         :: String
  , attribs      :: Array Attribute
  , content      :: Maybe (Content Unit)
  }

また、Contentモナドについての新しいモナドの動作になるよう、elemtext関数を変更する必要があります。 これには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

演習

  1. (普通)ContentF型に新しいデータ構築子を追加して、生成されたHTMLにコメントを出力する新しい動作commentに対応してください。 liftFを使ってこの新しい動作を実装してください。 新しい構築子を適切に解釈するように、解釈renderContentItemを更新してください。

言語の拡張

全動作が型Unitの何かを返すようなモナドは、さほど興味深いものではありません。 実際のところ、概ね良くなったと思われる構文は別として、このモナドはMonoid以上の機能を何ら追加していません。

非自明な結果を返す新しいモナド動作でこの言語を拡張することで、Freeモナドを構築する威力をお見せしましょう。

アンカーを使用して文書の様々な節へのハイパーリンクが含まれているHTML文書を生成したいとします。 手作業でアンカーの名前を生成して文書中で少なくとも2回それらを含めれば、これは達成できます。 1つはアンカーの定義自身に、もう1つは各ハイパーリンクにあります。 しかし、この手法には基本的な問題が幾つかあります。

  • 開発者が一意なアンカー名の生成をし損なうかもしれません。
  • 開発者がアンカー名を1つ以上の箇所で打ち間違うかもしれません。

開発者が誤ちを犯すことを防ぐために、アンカー名を表す新しい型を導入し、新しい一意な名前を生成するためのモナド動作を提供できます。

最初の工程は名前の型を新しく追加することです。

newtype Name = Name String

runName :: Name -> String
runName (Name n) = n

繰り返しになりますが、NameStringの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型の状態は増加していくカウンタとして振舞い、一意な名前を生成するのに使われます。

WriterWriterTモナドはそれらの動作を抽象化するのに同じ型クラスの構成要素を使うので、どの動作も変更する必要がありません。 必要なのは、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の呼び出しの結果が、実際に一意な名前になっていることも確かめられます。

演習

  1. (普通)使用者から Element型を隠蔽すると、更にAPIを簡素にできます。 次の手順に従って、これらの変更を加えてください。

    • pimgのような(返る型がElementの)関数をelem動作と結合して、型Content Unitを返す新しい動作を作ってください。
    • Elementの代わりに型Content Unitの引数を受け付けるようにrender関数を変更してください。
  2. (普通)型同義語の代わりにnewtypeを使ってContentモナドの実装を隠してください。 newtype用のデータ構築子はエクスポートすべきではありません。

  3. (難しい)ContentF型を変更して以下の新しい動作に対応してください。

    isMobile :: Content Boolean
    

    この動作は、この文書がモバイルデバイス上での表示のために描画されているかどうかを示す真偽値を返します。

    手掛かりask動作とReaderTモナド変換子を使って、この動作を解釈してください。 あるいは、RWSモナドを使うほうが好みの人もいるかもしれません。

まとめ

この章では、幾つかの標準的な技術を使って、素朴な実装を段階的に改善することにより、HTML文書を作成するための領域特化言語を開発しました。

  • スマート構築子を使ってデータ表現の詳細を隠し、利用者には構築により正しい文書だけを作ることを許しました。
  • 独自に定義された中置2引数演算子を使い、言語の構文を改善しました。
  • 幻影型を使ってデータの型の中に追加の情報を折り込みました。 これにより利用者が誤った型の属性値を与えることを防いでいます。
  • Freeモナドを使って内容の集まりの配列表現をdo記法に対応したモナドな表現に変えました。 それからこの表現を新しいモナド動作に対応するよう拡張し、標準的なモナド変換子を使ってモナドの計算を解釈しました。

これらの手法は全て、使用者が間違いを犯すのを防いだり領域特化言語の構文を改良したりするために、PureScriptのモジュールと型システムを活用しています。

関数型プログラミング言語による領域特化言語の実装は活発に研究されている分野です。 それでも、幾つかの単純な技法に対して役に立つ導入を提供し、表現力豊かな型を持つ言語で作業することの威力を示すことができていれば幸いです。