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

この記事の前編はこちら:JavaScriptユーザのための関数型プログラミング(前編)

遅延評価

遅延評価は、サンクジェネレータなどのもっと具体的な概念をカバーする一般的な用語の一種です。遅延評価は、その言葉が表すとおりのことを行います。つまり、値が必要になるまで評価しません。可能な限りずるずると、先延ばしにします。例えば、洗わなければならない食器が大量に、もしかすると無限にあるとします。食器を全て流しに置いて一度に洗うのではなく、ゆっくり、一度に1つずつ取って洗うのに似ています。

遅延評価の本質を少しでも理解しやすくするために、Haskellを使って説明したいと思います。まず、プログラムがどのように評価を行うかを理解する必要があります。皆さんが慣れているほとんど全ての言語は、最内簡約を用いています。最内簡約とは、次のようなものです。

square(3 + 4)
square(7) // evaluated the innermost expression
7 * 7
49

これはプログラムを評価する健全かつ合理的な方法です。しかし、ここで、最外簡約について考えてみましょう。

square(3 + 4)
(3 + 4) * (3 + 4) // evaluated the outermost expression
7 * (3 + 4)
7 * 7
49

最外簡約は明らかにあまり効率的ではありません。3 + 4を2回計算しなければならないので、このプログラムは、4ステップではなく5ステップ必要です。効率が良くないですね。しかし、Haskellは、それぞれの式への参照を保持し、共有しながら、最外簡約によってその参照を親の式に渡します。従って、3 + 4が最初に評価された時、この式への参照は式7を指します。ですから、重複するステップはスキップされます。

square(3 + 4)
(3 + 4) * (3 + 4) // evaluated the outermost expression
7 * 7 // both reduced at the same time due to reference sharing
49

基本的に、遅延評価は、参照を共有しながら最外評価を行います。

Haskellはプログラムの内部でこのような処理を全て行います。つまり、無限リストのようなものを定義することができるということです。例えば、自分自身の先頭に1という要素を追加するonesという無限リストを再帰的に定義することができます。

ones = 1 : ones

リストの先頭からn個の要素を取り出すtake(n, list)という関数があるとします。リストは無限なので、最内簡約を用いた場合、リストを永遠に再帰的に評価し続けるでしょう。しかし、最内簡約ではなく、最外簡約を用いた場合は、必要な数だけリストonesを遅延評価します!

とはいえ、JavaScriptや他のほとんどのプログラミング言語は、最内簡約を採用しています。従って、先程と全く同じ構成要素を生成する唯一の方法は、配列を関数として扱うことです。以下にその例を挙げます。

const makeOnes = () => {next: () => 1}
ones = makeOnes()
ones.next() // => 1
ones.next() // => 1

これで同じ再帰定義に基づく遅延評価の無限リストが効率良く作成できました。では、自然数の無限リストを作成しましょう。

const makeNumbers = () => {
  let n = 0
  return {next: () => {
    n += 1
    return n
  }
}
numbers = makeNumbers()
numbers.next() // 1
numbers.next() // 2
numbers.next() // 3

ES2015には、実はこのための標準機能があり、ジェネレータ関数と呼ばれています。

function* numbers() {
  let n = 0
  while(true) {
    n += 1
    yield n 
  }
}

遅延によって飛躍的にパフォーマンスの向上を図ることができます。例えば、Underscore及びLodashと比較したLazy.jsの1秒当たりのオペレーション数を見てください。


なぜ遅延が優れているのかを示す良い例が(Lazy.jsのウェブサイトに)あります。peopleという非常に大きな配列があって、その配列を少し変換するとします。

const results = _.chain(people)
  .pluck('lastName')
  .filter((name) => name.startsWith('Smith'))
  .take(5)
  .value()

これを素直に行う方法は、全てのlastNameを抜き出し、配列全体をフィルタにかけ、先頭の5要素だけを取り出すことです。Underscore.jsや他のほとんどのライブラリはこの方法で行います。しかし、ジェネレータを使うと、”Smith”で始まるlastNameが5件見つかるまで、一度に1件ずつ処理することによって式を遅延評価することができます。

Haskellで驚くべき点は、これが全て、最外簡約と共有を使って言語に織り込まれていることです。基本的に、リストは全て遅延です。たぶんJavaScriptではLazy.jsを使うだけで可能ですが、何かしらこうした物を自作したければ、上記の各ステップが新しいジェネレータを返すということを理解する必要があります。ジェネレータから値を取り出すためには.next()を呼ぶ必要があります。chainメソッドによってpeople配列がジェネレータに変換され、それぞれの変換はジェネレータを受け取り、更に別のジェネレータを返します。その後.value()が呼び出されると、値が無くなるまで.next()を単に繰り返し呼び出します。更に.take(5)で、これ以上処理する値が無いことを確認します。

前に述べた定理を思い出してください。

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

遅延評価は本質的に、こうした種類の最適化を行ってくれます。

Clojureのパターンと特徴

ここまでずいぶんとHaskellについて話したので、こうした全てのことに関して、Clojureの場合はどうなるのかを説明したいと思います。Clojureには参照透過性があり、データ型は不変で、atomと呼ばれる特殊なトランザクションの型を除いて、変数をインプレースで変更することはできません。これはHaskellに比べると、非常に便利な場合があります。Haskellでは他の場所で再度呼び出す連想配列内に値を単に格納しますが、そのためにストリームに対して値をスキャンしなければなりません。またClojureには、強い型システムや、Glasgow Haskell Compilerのように非常に強力なコンパイラがありません。更にClojureにはnullのようなものがありません。つまり、Clojureでは関数型パターンを使うように強く推奨されていて、使わずにいる方が難しいのです。

Clojureには際立った2つの特徴があるように思われます。その1つは、Clojureでは全てがプリミティブデータ型であるということです。これはEDNと呼ばれ、JSONのClojure版と言えます。オブジェクトや型を持つのではなく、とにかく全てが単なるプリミティブなデータ構造で、好きなように解釈できます。例えば、JavaScriptにはネイティブなDateオブジェクトがありますが、データをJSONにシリアライズしようとするとどうなるでしょうか。自分用にカスタマイズしたシリアライザやデシリアライザを作らなければなません。しかしClojureでは、タイムスタンプやタイムゾーンを使った連想配列として日付を表せます(Javaの実装を使っていなければの話ですが)。どんな文字列フォーマット関数も、同じデータ構造を前提としているので、Clojureではデータやデータ変換、データ処理が重視されているのです。あらゆるものがデータなのです。

Clojureのもう1つの素晴らしい点は、コードがデータであるということです。Clojureはリストの処理を特徴とするLispです。この言語は、最初の項目が関数で残りの項目が引数であるリストを解釈するだけです。そのため、Lispはあらゆる言語に存在すると言われることがよくあります。Lispは、非常に強力なマクロを作成することができるという優れた特徴があります。多くの人になじみ深いマクロといえば、文字列テンプレートのようなものを使ってコードを生成するテキスト置換のマクロです。JavaScriptには、こうしたマクロを生成できるSweetjsという優れたライブラリがあります。しかしClojureでは、コード自体が単なるリストなので、コンパイル時にコードをリストとして調べ、変換し、評価できます。繰り返し使用するボイラープレートを作成する際に実に便利ですし、何かを表現したい場合、基本的にどんな構文でも作成できます。JavaScriptで同じことをするには、Babel PluginsとJavaScriptの抽象構文木(AST)を読み込んで、自分で独自のトランスパイラを作成しなければなりません。しかしClojureでは、抽象構文木は単なるリストなのです。

Clojureの大きな特徴の1つに非同期通信を処理するためのcore.asyncライブラリがあります。このライブラリを利用すると、マクロをうまく使うことができます。以下の例では、チャネルを作成していますが、go関数は実際にはマクロです。

(def echo-chan (chan))
(go (println (<! echo-chan)))
(>!! echo-chan "ketchup")
; prints ketchup

驚くべきことに、goは実際に引数をリストとして解釈し、誰も書きたがらない多くの厄介な非同期コードを生成しています。また、基本的にチャネルをサブスクライブしていることを示す記号である<!を探しています。それから、ジョブを行うコードをいくつか生成します。書いたり扱ったりする必要がないコードですが、この厄介なコードを全て見てみましょう。

user=> (macroexpand ‘(go (println (<! echo-chan))))
(let* [c__6888__auto__ (clojure.core.async/chan 1) captured-bindings__6889__auto__ (clojure.lang.Var/getThreadBindingFrame)] (clojure.core.async.impl.dispatch/run (clojure.core/fn [] (clojure.core/let [f__6890__auto__ (clojure.core/fn state-machine__6712__auto__ ([] (clojure.core.async.impl.ioc-macros/aset-all! (java.util.concurrent.atomic.AtomicReferenceArray. 8) 0 state-machine__6712__auto__ 1 1)) ([state_8650] (clojure.core/let [old-frame__6713__auto__ (clojure.lang.Var/getThreadBindingFrame) ret-value__6714__auto__ (try (clojure.lang.Var/resetThreadBindingFrame (clojure.core.async.impl.ioc-macros/aget-object state_8650 3)) (clojure.core/loop [] (clojure.core/let [result__6715__auto__ (clojure.core/case (clojure.core/int (clojure.core.async.impl.ioc-macros/aget-object state_8650 1)) 1 (clojure.core/let [inst_8644 println inst_8645 echo-chan state_8650 (clojure.core.async.impl.ioc-macros/aset-all! state_8650 7 inst_8644)] (clojure.core.async.impl.ioc-macros/take! state_8650 2 inst_8645)) 2 (clojure.core/let [inst_8644 (clojure.core.async.impl.ioc-macros/aget-object state_8650 7) inst_8647 (clojure.core.async.impl.ioc-macros/aget-object state_8650 2) inst_8648 (inst_8644 inst_8647)] (clojure.core.async.impl.ioc-macros/return-chan state_8650 inst_8648)))] (if (clojure.core/identical? result__6715__auto__ :recur) (recur) result__6715__auto__))) (catch java.lang.Throwable ex__6716__auto__ (clojure.core.async.impl.ioc-macros/aset-all! state_8650 clojure.core.async.impl.ioc-macros/CURRENT-EXCEPTION ex__6716__auto__) (clojure.core.async.impl.ioc-macros/process-exception state_8650) :recur) (finally (clojure.lang.Var/resetThreadBindingFrame old-frame__6713__auto__)))] (if (clojure.core/identical? ret-value__6714__auto__ :recur) (recur state_8650) ret-value__6714__auto__)))) state__6891__auto__ (clojure.core/-> (f__6890__auto__) (clojure.core.async.impl.ioc-macros/aset-all! clojure.core.async.impl.ioc-macros/USER-START-IDX c__6888__auto__ clojure.core.async.impl.ioc-macros/BINDINGS-IDX captured-bindings__6889__auto__))] (clojure.core.async.impl.ioc-macros/run-state-machine-wrapped state__6891__auto__)))) c__6888__auto__)

結び

私がここ数カ月で関数型プログラミングについて学んだことは、基本的に以上です。この記事によって、特にJavaScriptコミュニティの人々が、より良いコードを書き、必然的に更に素晴らしいものを作成できるようになれば、うれしく思います。

HaskellとClojureを比較する果てしない議論に関しては、その2つは異なるので、どちらが優れているか判断するのは不可能だと思います。Haskellは関数型プログラミングの原理です。Haskellを使う人々は自らを文字どおりプログラミング原理主義者と呼びます。Haskellは、厳格で具体的であり、堅固で、とんでもなく高速でコンパクトです。Clojureは、柔軟で抽象的であり、力を与えてくれます。ClojureはJVM上に書かれているので、何でもできます(Javaでほとんど何でもできます)。Clojureでは、テスト済みのJavaアルゴリズムで数十年にもわたる作業を構築することができます。また、Clojureの背後には創造的なプログラマ独自の文化があり、OvertoneQuillのような実に素晴らしいライブラリがあります。

JavaScriptの世界に限って言えば、もっと純粋関数の領域に入り込んでいるものを見たいと思っています。もう二度とthisを見たくありません。可変のvarletよりconst型だけを使う習慣を身につけましょう。

私が本当に好きなJavaScriptライブラリは、RamdaFlydの2つです。しかし、Ramdaは遅延評価ではなく、Immutable.jsでうまく動作しません。カリー化され、遅延評価された構成可能なユーティリティ関数と永続的/共有/不変データ構造といった概念を全て兼ね備えたライブラリを見てみたいものです。

また、記述するためのより一貫性のある言語を使ったライブラリも見てみたいです。例えば、ES2015のPromiseのAPIでは、Promiseが完全にモナドだとしても、mapの対になるものとして.thenを使います。R.mapは動作しないので、ネイティブpromise内でデータを処理するためにRamdaを使用することはできないということです。適切な名前がつけられたFantasyland仕様は立派な理想だと思います。その仕様は全てのプログラミングデータ構造で使われる言語を統一しようとしているからです。Ramda、Lodash、Lazy.js、Immutable.jsのようなライブラリ全てと、promiseのようなネイティブデータのプリミティブがこの共通言語を使用する場合、より多くのコードの再利用方法を使用できます。RamdaやLazy.jsで使用しているデータ処理コードを全て書き換える必要なく、Immutable.jsリスト向けのネイティブJavaScript配列を交換できるでしょう。

とにかく、この記事を楽しく読んでくれるとうれしいです。理解できなかった点があるか、非常に納得がいく点があったら、お知らせください。ccorcos@gmail.comにご連絡いただければ、文章や嫌な部分を改善することができます。

Happy Hacking