JavaScriptユーザのための関数型プログラミング(前編)

私が関数型プログラミングについて度々耳にするようになったのは、数カ月前からです。でも当時は、それが何なのか見当もつかず、単なるバズワードだと思っていました。皆さんの中にも、そのような方は多いでしょう。それ以来、私は関数型プログラミングについて深く学び、この言葉を日々聞いてはいるものの内容を理解していない初心者の方のために、分かりやすく説明しようと思い立ちました。

関数型プログラミング言語の話になると、「HaskellLispどちらが優れているのか」という議論白熱する傾向にあります。HaskellとLispはどちらも関数型言語ですが、実際には大きな違いがあって、それぞれに長所と短所があります。その具体的な内容については、この記事を読み終える頃には深く理解していただけると思います。この2つの言語には、それぞれから派生した言語があります。その中で恐らく皆さんが耳にしたことがあるのが、 ElmClojureScriptでしょう。どちらもコンパイルするとJavaScriptになります。これらの言語の詳細については、後ほど説明しましょう。この記事の一番の目的は、関数型プログラミングの主要概念とパターンをしっかりとお伝えすることです。

この先は複雑な内容になりますので、お手元にコーヒーなどを用意しておいてください。

純粋関数

関数型プログラミングの要となるのが、ラムダ計算というロジックを記述するための計算体系です。数学者はよくデータ変換という形でプログラムを記述して、主要概念である純粋関数に帰着させます。純粋関数には副作用がありません。つまり、純粋関数は入力値にのみ依存するので、入力値が同じであれば必ず結果は等しくなります。では以下の例を見てみましょう。

  1. // pure function
  2. const add10 = (a) => a + 10
  3. // impure function due to external non-constants
  4. let x = 10
  5. const addx = (a) => a + x
  6. // also impure due to side-effect
  7. const setx = (v) => x = v

この非純粋関数は間接的にxに依存するので、を変化させると入力値が同じであってもaddxの出力結果は変わります。これにより、コンパイル時の静的解析やプログラム最適化がしづらくなります。しかし実際には、JavaScript開発者がプログラミングをする際の認知的負荷は、純粋関数により抑制されます。純粋関数の作成時は関数の中身だけに専念すればよいので、addx関数を作成しながら、もし何かの要因でxが変化したら…、などと外部要因を気にする必要はありません。

関数の合成

純粋関数の利点の1つは、合成して新しい関数を作成できることです。composeは、ラムダ計算でプログラムを記述する際に使う特別な演算子の1つです。composeは2つの関数を引数に取り、それらを”合成して“新しい関数を作ります。では以下を見てみましょう。

  1. const add1 = (a) => a + 1
  2. const times2 = (a) => a * 2
  3. const compose = (a, b) => (c) => a(b(c))
  4. const add1OfTimes2 = compose(add1, times2)
  5. add1OfTimes2(5) // => 11

composeは、前置詞の”of(~について)”に似ています。引数の順番と計算方法を見ると、「2倍した値について1を加算する」というように2つ目の関数を先に計算しているのが分かります。このcomposeと逆の動きをするのがUNIXでおなじみのpipeで、こちらの方が直感的に理解しやすいでしょう。pipeは以下のように関数の配列を引数に取ることもできます。

  1. const pipe = (fns) => (x) => fns.reduce((v, f) => f(v), x)
  2. const times2add1 = pipe([times2, add1])
  3. times2add1(5) // => 11

関数の合成によって小さな関数を統合(合成)すれば、より複雑なデータ変換を実装できます。関数を合成して、データを分かりやすく簡潔に処理する方法については、こちらの記事の例をぜひご覧ください。

実際に、合成はオブジェクト指向の継承に勝る代替手段だと言えます。信じられないという方もいると思うので、実例を紹介しましょう。以下のように、ユーザへのあいさつ文を作成する状況を想定してみてください。

  1. const greeting = (name) => `Hello ${name}`

とてもシンプルな純粋関数ですね。ではここでプロジェクトマネージャから、ユーザのデータをさらに渡され、名前の前に敬称を付けるよう言われたとしましょう。そして以下のコードを書いたとします。

  1. const greeting = (name, male=false, female=false) =>
  2. `Hello ${male ? ‘Mr. ‘ : female ? ‘Ms. ‘ : ‘’} ${name}`

それほどひどいコードではありません。でも、「Dr.」や「Sir」など他の分類についても、どんどんブール値を追加していったらどうなるでしょう? 名前の後ろに「MD」や「PhD」も付けて、「こんにちは」の代わりに「やあ」とカジュアルなあいさつを表示しようとしたら、一体どうなると思いますか? もう、手に負えなくなるはずです。

このようにブール値を関数に追加するのは、厳密にはオブジェクト指向の継承ではありません。とはいえ、これはオブジェクト内の属性やメソッドが、継承時に拡張またはオーバーライドされるケースと似ています。ではブール値を追加する代わりに、関数を合成してみましょう。

  1. const formalGreeting = (name) => `Hello ${name}`
  2. const casualGreeting = (name) => `Sup ${name}`
  3. const male = (name) => `Mr. ${name}`
  4. const female = (name) => `Mrs. ${name}`
  5. const doctor = (name) => `Dr. ${name}`
  6. const phd = (name) => `${name} PhD`
  7. const md = (name) => `${name} M.D.`
  8. formalGreeting(male(phd("Chet"))) // => "Hello Mr. Chet PhD"

こちらの方がはるかに扱いやすく、判別しやすいですね。各関数は1つの処理だけを行うので、関数の合成は簡単です。では全てのケースを扱うために、便利なpipe関数を使ってみましょう。

  1. const identity = (x) => x
  2. const greet = (name, options) => {
  3. return pipe([
  4. // greeting
  5. options.formal ? formalGreeting :
  6. casualGreeting,
  7. // prefix
  8. options.doctor ? doctor :
  9. options.male ? male :
  10. options.female ? female :
  11. identity,
  12. // suffix
  13. options.phd ? phd :
  14. options.md ?md :
  15. identity
  16. ])(name)
  17. }

純粋関数や関数の合成のもう1つの利点は、エラーを追いやすいという点です。エラーが発生した時にはいつでも、バグの発生源まで、全ての関数のスタックトレースを確認することができるはずです。オブジェクト指向プログラミングでは、バグを引き起こす残りのオブジェクトの状態が分かるというわけではないため、しばしば混乱を引き起こします。

関数のカリー化

関数のカリー化は、Haskell を開発したのと同じ人物によって考案されました。その人物とはHaskell Curryです(訂正:Haskell Curryの名にちなんで名づけられました)。関数のカリー化は、少ない引数で関数を呼び出した場合、残りの引数を取るために、その呼び出された関数が別の関数を返すような関数にすることを指します。詳細については、この記事で分かりやすく説明されています。ただし、ここではRamda.js curry function.を使った簡単な例が紹介されています。

以下の例では、2つの引数を取るカリー化された関数”add”を生成しました。引数を1つ渡す場合、1つの引数だけを取る”add1″と名付けた部分的に適用された関数が得られます。

  1. const add = R.curry((a, b) => a + b)
  2. add(1, 2) // => 3
  3. const add1 = add(1)
  4. add1(2) // => 3
  5. add1(10) // => 11

Haskellでは全ての関数が自動的にカリー化されます。任意やデフォルトの引数は存在しません。

実際、map関数や合成関数、pipe関数を使用する場合、関数のカリー化はとても便利です。以下がその例です。

  1. const users = [{name: 'chet', age:25}, {name:'joe', age:24}]
  2. R.pipe(
  3. R.sortBy(R.prop('age')), // sort user by the age property
  4. R.map(R.prop('name')), // get each name property
  5. R.join(', '), // join the names with a comma
  6. )(users)
  7. // => "joe, chet"

上記を見るとデータ処理がとても宣言的なのが分かります。まるでコメントのようにコードが読めることに気づくでしょう。

モナドと関手と装飾的な言語

モナド関手は、すでに知っている関数に対する装飾的な言葉です。もっとしっかり理解したい場合は、こちらの記事を読むことを強くお勧めします。分かりやすい図を使って、よく説明されています。実際にはここまで複雑ではありません。

  1. // monad
  2. list = [-1,0,1]
  3. list.map(inc) // => [0,1,2]
  4. list.map(isZero) // => [false, true, false]

モナドや関手について重要なことは、数学者たちが圏論でこの構造を研究しているということです。これによって、プログラムを理解するためのフレームワークだけでなく、コンパイルする時の静的解析やコードの最適化に使うことのできる代数定理や証明をもたらしてくれるのです。これがHaskell の主な利点でもあります。Glasgow Haskell コンパイラは人類が作りだした傑作です。

圏論で表されるあらゆる定理や恒等式があります。以下は簡単な恒等式の例です。

  1. list.map(inc).map(isZero) // => [true, false, false]
  2. list.map(compose(isZero, inc)) // => [true, false, false]

mapをコンパイルする場合、効果的なWhileループを用います。一般的に、O(n) operation (線形時間)ですが、リストで次のアイテムに対するポインタの増加に関連したオーバーヘッドがまだ存在します。そこで2番目の場合では、実に2倍の効率で、コンパイル時にHaskellがコード処理を明らかに速くするための変換があります。これにはちょっとしたイカすコツがあるのですが、後で説明します。

もう少しモナドについて触れると、Maybe モナドと呼ばれる面白いモナドがあります(Swiftでは、たまにOptionOptionalなどと呼ばれています)。Haskellでは、nullundefinedといった概念がありません。そこで潜在的にnullであることを表現する場合は、Haskellコンパイラが何をすべきか分かるように、モナドでラップする必要があります。

Maybe モナドはNothingJust somethingかのどちらかの共用体型です。HaskellではMaybeを以下のように定義付けできます。

  1. type Maybe = Nothing | Just x

小文字のxはあらゆる型を示します。

モナドであれば、含まれている値を変えるのにMaybe上で.map()を使えます。Maybe上でmapした場合、もしそれがJust型だったら、関数に値を入れ、新しい値とともに新しいJustを返します。またMaybe モナドがNothing型だったら、Nothingを返します。Haskellでは構文はほとんど無駄がなく、パターンマッチングを使えますが、JavaScriptではMaybe モナドを使う場合は次のようにする必要があるでしょう。

  1. const x = Maybe.Just(10)
  2. const n = x.map(inc)
  3. n.isJust() // true
  4. n.value() // 11
  5. const x= Maybe.Nothing
  6. const n = x.map(inc) // no error!
  7. n.isNothing // true

このモナドはJavaScriptのコードに関して、あまり役に立たないように思われるかもしれません。しかし、モナドがどうしてHaskellでそれほど有益なのかを見ておくのは興味深いことです。Haskellは、プログラムにおける全てのエッジケースで行うべきことを定義するよう要求します。もし定義しなければ、コンパイルはできません。HTTPのリクエストを実行する時には、リクエストが失敗して何も返ってこないことがあるかもしれないので、Maybe型が返されます。もしリクエストが失敗するような事例を扱わなければ、プログラムはコンパイルできません。このことは基本的に、ランタイムエラーが検出できないことを意味します。おそらく、プログラムは間違った動きをするでしょう。しかし、JavaScriptで実行されがちなことと同じように、ただ魔法のように実行を中断することもありません。

これはElmを使う上での重要なセールスポイントです。タイプシステムとコンパイラは、プログラムがランタイムエラーを発生させずに動くことを、強要するのです。

モナドと代数構造に照らしてコードを考えることは、問題を構造化された方法で定義し理解するのに役立つでしょう。例えばMaybeの面白い拡張は、エラーハンドリングのための鉄道指向プログラミングの概念です。また注目すべき動向は非同期イベントをうまく扱うモナドなのです。

あらゆる種類のモナドや多くの用語を、私自身も完全に理解しているわけではありません。しかし、すべての専門用語の不変性を保つために、Fantasy Land型クラスペディアといった仕様書があります。その仕様書は、慣用的で機能的なコードを書くことを目的として、圏論における異なった概念を統一しようとしています。

参照の透明性と不変性

このような圏論とラムダ計算式の全てを利用するもう一つの意味合いは、参照の透明性です。数学者にとって、同一の2つの事柄が、お互いに等しくはない時、論理プログラムを解析するのはとても大変です。これはJavaScriptのいたるところに存在する問題です。

  1. {} == {} // false
  2. [] == [] // false
  3. [1,2] == [1,2] // false

さて、参照の透明性の無い世界で数学を学ばなければならないと想像してください。「空配列は空配列と同じものである」という証明を書くことはできないでしょう。重要視すべきことは、配列のであって、配列の参照ポインタではありません。そこで関数型プログラミング言語では値を比較するために、deep-equalを用います。しかし、これはとても効率が良いというわけではありません。そこには、参照を利用するこの比較をより速くするための巧妙なトリックがあります。

先に進む前に、1つはっきりさせておきたいことがあります。関数型プログラミングにおいて、その参照を変えることなく、変数を変えることはできません。そうしなければ、変更処理を実行する関数は非純粋関数になるでしょう。このように、「2つの変数が参照に関して等しいならば、その値も同様に等しくなければならない」ということが保証できます。また、適切な場所で変数の変更処理をすることができないので、変数を変換したいと思うたびに、新しい記憶場所に値をコピーしなければなりません。これは途方もないパフォーマンスの損失で、ガベージスラッシングという結果を招きます。そしてその解決策が永続データ構造なのです。

永続データ構造の簡単な例は、連結リストです。リストの最後まで参照し続けるだけだと思ってください。2つのリストを比較する場合は、その最後が参照に関して等しいかどうかを最初に見ることができます。もし2つが等しいならば、これは素晴らしい近道です。この確認が終われば、2つのリストは同じものです。もしそうでなければ、各リストのアイテムについてくまなく、繰り返し値が同じかどうかの確認を始めなければいけません。このリストに効率よく新しい値を加えるには、メモリの新しいセットにそのリストを丸ごとコピーする代わりに、新しいノードのリンクを単に追加して、新しい先端の参照を追跡すればいいのです。このことにより、新しいデータ構造の中の以前のデータ構造と、新しい参照とを構造的に共有して、以前のデータ構造も持ち続けることができます。このような不変データを変換させるためのデータ構造を一般化したものをハッシュ配列マップトライ(HAMT)と呼んでいます。これはまさにImmutable.jsMori.jsで行うことです。ClojureScriptとHaskellではコンパイラに組み込み済みですが、もうElmに実装されたどうかについては確信が持てません。

不変データ構造を用いれば、パフォーマンスの向上が得られ、整合性の維持に役立ちます。Reactは「propsとstateは常に不変なので、不必要なレンダリングをするより先に、以前のpropsとstateが参照に関して次のpropsとstateと等しいかどうかという照合が効率的に可能である」と仮定しています。また他の状況では、不変データを用いることで、知らないうちに値が変わらないことを保証するのに単純に役立ちます。