実例によるPureScript

ウェブのための関数型プログラミング

Phil Freeman, "PureScript by Example - Functional Programming for the Web"

3 関数とレコード

3.1 この章の目標

この章では、関数およびレコードというPureScriptプログラムのふたつの構成要素を導入します。さらに、どのようにPureScriptプログラムを構造化するのか、どのように型をプログラム開発に役立てるかを見ていきます。 

電話番号の一覧を管理する簡単​​な電話帳アプリケーションを作成していきます。このコード例により、PureScriptの構文からいくつかの新しい概念を導入します。

このアプリケーションのフロントエンドは対話式処理系 psci を使うようにしますが、JavaScriptでフロントエンドを書くこともできるでしょう。

3.2 プロジェクトの準備

この章のソースコードはsrc/data/ PhoneBook.purs というファイルに含まれています。このファイルは次のようなモジュール宣言とインポート一覧から始まります。

module Data.PhoneBook where

import Data.List
import Data.Maybe

import Control.Plus (empty)

ここでは purescript-listsパッケージで提供されている Data.List モジュールをインポートしています。purescript-listsパッケージはbowerを使用してインストールすることができ、連結リストを使うために必要ないくつかの関数が含まれています。

Data.Maybe モジュールは、値が存在したりしなかったりするような、オプショナルな値を扱うためのデータ型と関数を定義しています。

Control.Plus モジュールには後ほど使う em​​pty 値が定義されています。このモジュールのインポート内容は括弧内で明示的に列挙されていることに注意してください。明示的な列挙はインポート内容の衝突を避けるのに役に立つので、一般に良い習慣です。

3.3 単純な型

JavaScriptのプリミティブ型に対応する組み込みデータ型として、PureScriptでは数値型と文字列型、真偽型の3つが定義されています。すべてのモジュールに暗黙にインポートされる Primモジュールでこれらの型は定義されています。これらの型はそれぞれ NumberString、と Boolean と呼ばれ、psci:t コマンドを使用すると簡単な値の型を確認できます。

$ psci

> :t 1
Prim.Number

> :t "test"
Prim.String

> :t true
Prim.Boolean

PureScriptには他にも配列とレコード、関数の3つの組み込み型が定義されています。

配列はJavaScriptの配列に対応していますが、JavaScriptの配列とは異なり、PureScriptの配列のすべての要素は同じ型を持つ必要があります。

> :t [1, 2, 3]
[Prim.Number]

> :t [true, false]
[Prim.Boolean]

> :t [1, false]
Cannot unify Prim.Number with Prim.Boolean.

最後の例で起きているエラーは型検証器によって報告されたもので、配列の2つの要素の型を単一化(Unification)しようとして失敗したこと示しています。

レコードはJavaScriptのオブジェクトに対応しており、レコードリテラルはJavaScriptのオブジェクトリテラルと同じ構文になっています。

> let author = 
        { name: "Phil"
        , interests: ["Functional Programming", "JavaScript"] 
        }

> :t author
{ name :: Prim.String, interests :: [Prim.String] }

この型が示しているのは、オブジェクトauthorは、

というふたつのフィールド(field)を持っているということです。

レコードのフィールドは、ドットに続けて参照したいフィールドのラベルを書くと参照することができます。

> author.name
"Phil"

> author.interests
["Functional Programming","JavaScript"]

PureScriptの関数はJavaScriptのの関数に対応しています。PureScriptの標準ライブラリは多くの関数の例を提供しており、この章ではそれらをもう少し詳しく見ていきます。

> :t Prelude.flip
forall a b c. (a -> b -> c) -> b -> a -> c

> :t Prelude.const
forall a b. a -> b -> a

ファイルのトップレベルでは、等号の直前に引数を指定することで関数を定義することができます。

add :: Number -> Number -> Number
add x y = x + y

バックスラッシュにに続けて空白文字で区切られた引数名のリストを書くと、関数をインラインで定義することもできます。

> let 
    add :: Number -> Number -> Number
    add = \x y -> x + y

psciでこの関数が定義されていると、次のように関数の隣に2つの引数を空白で区切って書くことで、関数をこれらの引数に適用(apply)することができます。

> add 10 20
30

3.4 量化された型

前の節ではPreludeで定義された関数の型をいくつかの見てきました。たとえばflip関数は次のような型を持っていました。

> :t Prelude.flip
forall a b c. (a -> b -> c) -> b -> a -> c

このforallキーワードはflip全称量化された型(universally quantified type)を持っていることを示しています。これは、abcをどの型に置き換えても、flipはその型でうまく動作するという意味です。

例えば、aNumberbStringcStringというように選んでみたとします。この場合、flipの型を次のように特殊化(specialize)することができます。

(Number -> String -> String) -> String -> Number -> String

量化された型を特殊化したいということをコードで示す必要はありません。特殊化は自動的に行われます。たとえば、すでにその型のflipを持っていたかのように、次のように単にflipを使用することができます。

> flip (\n s -> show n ++ s) "Ten" 10
  
"10Ten"

abcの型はどんな型でも選ぶことができるといっても、型の不整合は生じないようにしなければなりません。flip に渡す関数の型は、他の引数の型と整合性がなくてはなりません。第2引数として文字列"Ten"、第3引数として数10を渡したのはそれが理由です。もし引数が逆になっているとうまくいかないでしょう。

> flip (\n s -> show n ++ s) 10 "Ten"

Error in value 10:
Value does not have type Prim.String

3.5 字下げについての注意

JavaScriptとは異なり、PureScriptのコードは字下げの大きさに影響されます(indentation-sensitive)。これはHaskellと同じようになっています。コード内の空白の多寡は無意味ではなく、Cのような言語で中括弧によってコードのまとまりを示しているように、PureScriptでは空白がコードのまとまりを示すのに使われているということです。

宣言が複数行にわたる場合は、2つめの行は最初の行の字下げより深く字下げしなければなりません。

したがって、次は正しいPureScriptコードです。

add x y z = x +
  y + z

しかし、次は正しいコードではありません。

add x y z = x +
y + z

後者では、PureScriptコンパイラはそれぞれの行ごとにひとつ、つまり2つの宣言であると構文解析します。

一般に、同じブロック内で定義された宣言は同じ深さで字下げする必要があります。例えばpsciでlet文の宣言は同じ深さで字下げしなければなりません。次は正しいコードです。

> let x = 1
      y = 2

しかし、これは正しくありません。

> let x = 1
       y = 2

PureScriptのいくつかの予約語(例えば whereoflet)は新たなコードのまとまりを導入しますが、そのコードのまとまり内の宣言はそれより深く字下げされている必要があります。

example x y z = foo + bar
  where
  foo = x * y
  bar = y * z

ここでfoobarの宣言はexample の宣言より深く字下げされていることに注意してください。

ただし、ソースファイルの先頭、最初の module宣言における予約語whereだけは、この規則の唯一の例外になっています。

3.6 独自の型の定義

PureScriptで新たな問題に取り組むときは、まずはこれから扱おうとする値の型の定義を書くことから始めるのがよいでしょう。最初に、電話帳に含まれるレコードの型を定義してみます。

type Entry = { firstName :: String, lastName :: String, phone :: String }

これはEntryという型同義語(type synonym、型シノニム)を定義しています。 型Entryは等号の右辺と同じ型ということです。レコードの型はいずれも文字列であるfirstNamelastNamephoneという3つのフィールドからなります。

それでは、2つめの型別名も定義してみましょう。電話帳のデータ構造として、単に項目の連結リストとして格納することにします。

type PhoneBook = List Entry

List Entry[Entry]とは同じではないということに注意してください。[Entry]は電話帳の項目の配列を意味しています。

3.7 型構築子と種

List型構築子(type constructor、型コンストラクタ)の一例になっています。Listそのものは型ではなく、何らかの型aがあるときList aが型になっています。つまり、 List型引数(type argument) aをとり、新たな型List aを構築するのです。

ちょうど関数適用と同じように、型構築子は他の型に並べることで適用されることに注意してください。型List Entryは実は型構築子Listが型Entry適用されたものです。これは電話帳項目のリストを表しています。

(型注釈演算子 :: を使って)もし型Listの値を間違って定義しようとすると、今まで見たことのないような種類のエラーが表示されるでしょう。

> :i Data.List
> Nil :: List
Expected type of kind *, was * -> *

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

NumberStringのような、値を持つすべての型の種を表す *と呼ばれる特別な種があります。

型構築子にも種があります。たとえば、種 * -> *はちょうどListのような型から型への関数を表しています。ここでエラーが発生したのは、値が種 *であるような型を持つと期待されていたのに、Listは種 * -> *を持っているためです。

psciで型の種を調べるには、:k命令を使用します。例えば次のようになります。

> :k Number
*

> :i Data.List
> :k List
* -> *

> :k List String
*

PureScriptの種システムは他にも面白い種に対応していますが、それらについては本書の他の部分で見ていくことになるでしょう。

3.8 電話帳の項目の表示

それでは最初に、文字列で電話帳の項目を表現するような関数を書いてみましょう。まずは関数に型を与えることから始めます。型の定義は省略することも可能ですが、ドキュメントとしても役立つので型を書いておくようにすると良いでしょう。型宣言は関数の名前とその型を::記号で区切るようにして書きます。

showEntry :: Entry -> String

showEntryは引数としてEntryを取り stringを返す関数であるということを、この型シグネチャは言っています。 showEntryの定義は次のとおりです。

showEntry entry = entry.lastName ++ ", " ++ 
                  entry.firstName ++ ": " ++ 
                  entry.phone

この関数はEntryレコードの3つのフィールドを連結し、単一の文字列にします。

関数定義は関数の名前で始まり、引数名のリストが続きます。関数の結果は等号の後ろに定義します。フィールドはドットに続けてフィールド名を書くことで参照することができます。PureScriptでは、文字列連結はJavaScriptのような単一のプラス記号ではなく、ダブルプラス演算子(++)を使用します。

3.9 はやめにテスト、たびたびテスト

psci 対話式処理系では反応を即座に得られるので、試行錯誤を繰り返したいときに向いています。それではこの最初の関数が正しく動作するかをpsciを使用して確認してみましょう。

まず、これまで書かれたコードをビルドします。

$ grunt

次に、psciを起動し、この新しいモジュールをインポートするために:i命令を使います。

$ psci

> :i Data.PhoneBook

レコードリテラルを使うと、電話帳の項目を作成することができます。レコードリテラルはJavaScriptの無名オブジェクトと同じような構文です。これをlet式で名前に束縛します。

> let example = { firstName: "John", lastName: "Smith", phone: "555-555-5555" }

(Ctrl+ Dで式をを終了することを忘れないようにしましょう)​​。それでは、この関数をexampleに適用してみてください。

> showEntry example

"Smith, John: 555-555-5555"

おめでとうございます!PureScriptで初めて関数を書き、それを実行することができました。

3.10 電話帳の作成

今度は電話帳の操作を支援する関数をいくつか書いてみましょう。空の電話帳を表す値として、空のリストを使います。

emptyBook :: PhoneBook
emptyBook = empty

既存の電話帳に値を挿入する関数も必要でしょう。この関数を insertEntryと呼ぶことにします。関数の型を与えることから始めましょう。

insertEntry :: Entry -> PhoneBook -> PhoneBook

insertEntryは、最初の引数としてEntry、第二引数としてPhoneBookを取り、新しいPhoneBookを返すということを、この型シグネチャは言っています。

既存の PhoneBookを直接変更することはしません。その代わりに、同じデータが含まれている新しい PhoneBookを返すようにします。このように、 PhoneBook永続データ構造(persistent data structure)の一例となっています。これはPureScriptにおける重要な考え方です。変更はコードの副作用であり、コードの振る舞いについての判断するのを難しくします。そのため、我々は可能な限り純粋な関数や不変のデータを好むのです。

Data.ListCons関数を使用するとinsertEntryを実装できます。psciを起動し:tコマンドを使って、この関数の型を見てみましょう。

$ psci

> :t Data.List.Cons

forall a. a -> List a -> List a

Consは、なんらかの型aの値と、型 aを要素に持つリストを引数にとり、同じ型の要素を持つ新しいリストを返すということを、この型シグネチャは言っています。aEntry型として特殊化してみましょう。

Entry -> List Entry -> List Entry

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

Entry -> PhoneBook -> PhoneBook

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

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

insertEntry entry book = Cons entry book

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

3.11 カリー化された関数

PureScriptでは、関数は常にひとつの引数だけを取ります。insertEntry関数は2つの引数を取るように見えますが、これは実際にはカリー化された関数(curried function)の一例となっています。

insertEntryの型に含まれる -> は右結合の演算子であり、つまりこの型はコンパイラによって次のように解釈されます。

Entry -> (PhoneBook -> PhoneBook)

すなわち、insertEntryは関数を返す関数である、ということです!この関数は単一の引数 Entryを取り、それから単一の引数PhoneBookを取り新しい PhoneBookを返す新しい関数を返すのです。

これは例えば、最初の引数だけを与えるとinsertEntry部分適用(partial application)できることを意味します。psciでこの結果の型を見てみましょう。

> :t insertEntry example

PhoneBook -> PhoneBook

期待したとおり、戻り値の型は関数になっていました。この結果の関数に、ふたつめの引数を適用することもできます。

> :t (insertEntry example) emptyBook
PhoneBook

ここで括弧は不要であることにも注意してください。次の式は同等です。

> :t insertEntry example emptyBook
PhoneBook

これは関数適用が左結合であるためで、なぜ単に空白で区切るだけで関数に引数を与えることができるのかも説明にもなっています。

本書では今後、「2引数の関数」というように表現することがあることに注意してください。これはあくまで、最初の引数を取り別の関数を返す、カリー化された関数を意味していると考えてください。

今度はinsertEntry の定義について考えてみます。

insertEntry :: Entry -> PhoneBook -> PhoneBook
insertEntry entry book = Cons entry book

もし式の右辺に明示的に括弧をつけるなら、 (Cons entry)book となります。insertEntry entry はその引数が単に関数(Cons entry)に渡されるような関数だということです。この2つの関数はどんな入力についても同じ結果を返しますから、つまりこれらは同じ関数です!よって、両辺から引数 bookを削除できます。

insertEntry :: Entry -> PhoneBook -> PhoneBook
insertEntry entry = Cons entry

そして、同様の理由で両辺からentry も削除することができます。

insertEntry :: Entry -> PhoneBook -> PhoneBook
insertEntry = Cons

この処理はイータ変換(eta conversion)と呼ばれ、引数を参照することなく関数を定義するポイントフリー形式(point-free form)へと関数を書き換えるのに使うことができます。

insertEntryの場合には、イータ変換によって「insertEntryは単にリストに対するconsだ」と関数の定義はとても明確になりました。しかしながら、常にポイントフリー形式のほうがいいのかどうかには議論の余地があります。

3.12 あなたの電話番号は?

最小限の電話帳アプリケーションの実装で必要になる最後の関数は、名前で人を検索し適切なEntryを返すものです。これは小さな関数を組み合わせることでプログラムを構築するという、関数型プログラミングで鍵となる考え方のよい応用例になるでしょう。

まずは電話帳をフィルタリングし、該当する姓名を持つ項目だけを保持するようにするのがいいでしょう。それから、結果のリストの先頭の(head)要素を返すだけです。

この大まかな仕様に従って、この関数の型を計算することができます。まず psciを起動し、filter関数とhead関数の型を見てみましょう。

$ psci

> :t Data.List.filter

forall a. (a -> Boolean) -> List a -> List a

:t Data.List.head

forall a. List a -> Maybe a

型の意味を理解するために、これらの2つの型の一部を取り出してみましょう。

filterはカリー化された2引数の関数です。最初の引数は、リストの要素を取りBoolean値を結果として返す関数です。第2引数は要素のリストで、返り値は別のリストです。

headは引数としてリストをとり、Maybe aという今まで見たことがないような型を返します。Maybe aは型aのオプショナルな値、つまりaの値を持つか持たないかのどちらかの値を示しており、JavaScriptのような言語で値がないことを示すために使われるnullの型安全な代替手段を提供します。これについては後の章で詳しく扱います。

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

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

head :: PhoneBook -> Maybe Entry

検索する関数の引数として姓と名前をを渡す必要があるのもわかっています。

filterに渡す関数も必要になることもわかります。この関数を filterEntryと呼ぶことにしましょう。filterEntryEntry -> Booleanという型を持っています。filter filterEntryという関数適用の式は、PhoneBook -> PhoneBookという型を持つでしょう。もしこの関数の結果をhead関数に渡すと、型Maybe Entryの結果を得ることになります。

これまでのことをまとめると、このfindEntry関数の妥当な型シグネチャは次のようになります。

findEntry :: String -> String -> PhoneBook -> Maybe Entry

findEntry は、姓と名前の2つの文字列、およびPhoneBookを引数にとり、Maybe Entryという型の値を結果として返すということを、この型シグネチャは言っています。結果のMaybe Entryという型は、名前が電話帳で発見された場合にのみEntryの値を持ちます。

そして、findEntry の定義は次のようになります。

findEntry firstName lastName book = head $ filter filterEntry book
  where
  filterEntry :: Entry -> Boolean
  filterEntry entry = entry.firstName == firstName && entry.lastName == lastName

一歩づつこのコードの動きを調べてみましょう。

findEntryは、どちらも文字列型であるfirstNamelastNamePhoneBook型のbookという3つの名前をスコープに導入します

定義の右辺ではfilter関数とhead関数が組み合わされています。まず項目のリストをフィルタリングし、その結果にhead関数を適用しています。

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

最上位での宣言と同じように、必ずしもfilterEntryの型シグネチャを指定しなくてもよいことに注意してください。ただし、ドキュメントとしても役に立つので型シグネチャを書くことは推奨されています。

3.13 中置の関数適用

上でみた findEntryのコードでは、少し異なる形式の関数適用が使用されています。head関数は中置の $演算子を使って式 filter filterEntry bookに適用されています。

これはhead (filter filterEntry book)という通常の関数適用と同じ意味です。

($)はPreludeで定義されている通常の関数です。($)は次のように定義されています。

($) :: forall a b. (a -> b) -> a -> b
($) f x = f x

つまり、 ($)は関数と値をとり、その値にその関数を適用します。

しかし、なぜ通常の関数適用の代わりに $を使ったのでしょうか?その理由は $は右結合で優先順位の低い演算子だということにあります。これは、深い入れ子になった関数適用のための括弧を、$を使うと取り除くことができることを意味します。

たとえば、ある従業員の上司の住所がある道路を見つける、次の入れ子になった関数適用を考えてみましょう。

street (address (boss employee))

これは$を使用して表現すればずっと簡単になります。

street $ address $ boss employee

3.14 関数合成

イータ変換を使うと 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関数のわかりやすい定義を与えます。

どちらの定義のほうがわかりやすいかの判断はお任せしますが、このように関数を部品として捉え、関数はひとつの役目だけをこなし、機能を関数合成で組み立てるというように考えると有用なことがよくあります。

3.15 テスト、テスト、テスト……

これでこのアプリケーションの中核部分が完成しましたので、 psciを使って試してみましょう。

$ psci

> :i Data.PhoneBook 

まずは空の電話帳から項目を検索してみましょう(これは明らかに空の結果が返ってくることが期待されます)。

> findEntry "John" "Smith" emptyBook

Error in declaration main
No instance found for Prelude.Show (Data.Maybe.Maybe Data.PhoneBook.Entry<>)

エラーです!でも心配しないでください。これは単に 型Entryの値を文字列として出力する方法をpsciが知らないという意味のエラーです。

findEntryの返り値の型は Maybe Entryですが、これは手作業で文字列に変換することができます。

showEntry関数はEntry型の引数を期待していますが、今あるのはMaybe Entry 型の値です。この関数はEntry 型のオプショナルな値を返すことを忘れないでください。行う必要があるのは、オプショナルな値の中に項目の値が存在すれば showEntry関数を適用し、そうでなければ存在しないという値をそのまま伝播することです。

幸いなことに、Preludeモジュールはこれを行う方法を提供しています。<$>演算子はMaybe のような適切な型構築子まで関数を「持ち上げる」ことができます(この本の後半で関手について説明するときに、この関数やそれに類似する他のものについて詳しく見ていきます)。

> showEntry <$> findEntry "John" "Smith" emptyBook

Nothing

今度はうまくいきました。この返り値 Nothingは、オプショナルな返り値に値が含まれていないことを示しています。期待していたとおりです。

もっと使いやすくするために、Entryを文字列として出力するような関数を定義し、毎回showEntryを使わなくてもいいようにすることもできます。

> let printEntry firstName lastName book = showEntry <$> findEntry firstName lastName book

それでは空でない電話帳を作成してもう一度試してみましょう。先ほどの項目の例を再利用します。

> let john = { firstName: "John", lastName: "Smith", phone: "555-555-5555" }

> let book1 = insertEntry john emptyBook

> printEntry "John" "Smith" book1

Just ("Smith, John: 555-555-5555")

今度は結果が正しい値を含んでいました。book1 に別の名前で項目を挿入して、ふたつの名前がある電話帳book2を定義し、それぞれの項目を名前で検索してみてください。

演習 

  1. (簡単)findEntry関数の定義の主な部分式の型を書き下し、findEntry関数についてよく理解しているか試してみましょう。たとえば、findEntryの定義のなかにあるhead関数の型はList Entry -> Maybe Entryと特殊化されています。

  2. (簡単) findEntryの既存のコードを再利用し、与えられた電話番号から Entry を検索する関数を書いてみましょう。また、psci で実装した関数をテストしてみましょう。

  3. (やや難しい) 指定された名前がPhoneBookに存在するかどうかを調べて真偽値で返す関数を書いてみましょう。ヒント: リストが空かどうかを調べるData.List.null関数の型をpsciで調べてみてみましょう。

  4. (難しい) 姓名が重複している項目を電話帳から削除する関数 removeDuplicates を書いてみましょう。ヒント: 値どうしの等価性を定義する述語関数に基づいてリストから重複要素を削除する関数 List.nubByの型を、psciを使用して調べてみましょう。

3.16 まとめ

この章では、関数型プログラミングの新しい概念をいくつか導入しました。

次の章からは、これらの考えかたに基づいて進めていきます。