そのJavaScriptの関数は本当に純粋関数?

JavaScriptにおいて”純粋関数”とはどういう意味でしょうか? 一般的なプログラムの用語では、純粋性というのは”参照透過性”として知られています。カッコよく言うと、「式や関数の呼び出しをその結果と置き換えたとしても、プログラムの振る舞いが決して変わらない」こと、また別の言い方をするなら、「同じ入力値を渡すたび、決まって同じ出力値が得られる」ということです。

これなら直感的に理解できそうに聞こえますし、x => x * 10などの関数は純粋に見えます。これに数字の3を引数として渡したら必ず、出力値として30が得られますからね。では、ある関数が純粋で別の関数が純粋でないと、どうしたら分かるのでしょう? コードを読むだけで十分でしょうか?

一般的にどう思われているかを見てみましょう。昨日、Twitter Pollsで「これは純粋か、純粋でないか?」というアンケートを出しました。選択肢は以下の3つです。

  • 純粋
  • 純粋でない
  • どちらとも言えない

下記のコードを見て投票してもらいました。

function sum(arr) {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

投票結果は下記のとおりです。

  • 純粋だと思った人が大多数で74%
  • 純粋でないと思った人は18%
  • どちらとも言えないと答えた人が8%

ほとんどの人が純粋だと考えた理由は理解できます。内部で変数が変更されてはいますが、引数として値[1, 2, 3]の配列が与えられた場合、常に出力として6が得られ、毎回、必ず同じ出力になるからです。

しかし18%の人が純粋でないと思った理由も分かります。関数の本体が、副作用を持つ純粋でない式や文を使っているためです。とはいえ、私は「これは純粋か、純粋でないか」と聞いたのであり、「この関数は純粋か」と聞いたのではありません。

驚かれるでしょうが、どちらと答えた人も間違っています。どちらとも言えないと答えた8%の人が正解で、実行時の振る舞いによるのです。関数を読むだけでは、どちらとも言えないわけです。実際、sumが純粋でないケースがありますから、純粋でないと考えた18%の人は、純粋だと考えた74%の人よりも”正解に近い”です。

このコードは人に間違いを起こさせるくらいシンプルで、私たち人間はこれを読んでいる間に、自然と憶測を立ててしまいます。おそらく無意識のうちに想定してしまいそうなことを、いくつか列挙します。

  • sumは、この関数が数を合計するものであることを意味している(Suppress Universal Macro(他を抑える普遍的なマクロ)の頭文字ではないと言い切れますか?)
  • arrは”配列”を意味している( “arrow(矢)”や”arrivals(到着)”を意味している可能性はありませんか?)
  • arrは実際に配列である
  • arrはnull(空の文字列)やundefined(定義されていない値)ではない
  • 配列内の要素は数値である
  • 配列内の要素には、valueOf関数を改ざんされたものはない

問題は、これらの想定が全て破られる可能性があるにも関わらず、上記のコードはそれを教えてくれないということです。関数を壊して純粋でない状態にする方法を、下記にいくつか列挙します。

sum(); // TypeError: Cannot read property 'length' of undefined
var arr = [{}, {}, {}];
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr); // 2.393660612848899
sum(arr); // 2.3418339292845998
sum(arr); // 2.15048094452324
// Same input, different outputs!

なるほど。sumは純粋ではないということでしょうか。

いいえ、そう焦ってはいけません。JavaScriptの全てのfunctionは実際”プロシージャ”です。純粋なfunctionは単に数学関数の振る舞いをする”プロシージャ”であり、唯一本当に純粋な関数です。ですから、functionと”関数”では違うのです。私たちに言えるのは、「この場合は、私のJavaScriptの関数は数学関数のような振る舞いをしている」ということだけです。

私が何を言っているのか、皆さん予想がついているような気がしますが、一応ヒントを出しておきましょう。数学関数はセットを超えて定義された関係であり、他のセットへのマッピングです。例えば、sumは数値の配列の中でのみ機能しなければならないと言うことができます。オブジェクトの配列ではダメなのです。

さて、話をJavaScriptに戻しますが、function sumは使い方によって数学関数のような振る舞いをします。プログラム全体が下記のようなものだったらどうでしょう。

function sum(arr) {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

var arr = [1, 2, 3];
var x = sum(arr);
var y = sum(arr);
var z = sum(arr);

console.log(x, y, z);

これなら、もちろんsumは純粋です。数学関数のような振る舞いをしました。別の状況で使ったら、数学関数のような振る舞いをしないでしょう。

ですから、正解は「場合による」です。つまり、どんなJavaScriptの関数を与えられても、ほとんどの場合、単にコードを読んだだけではそれが純粋かそうでないかは判断できないということです。その関数がどのように呼び出され、その引数が何なのかを知る必要があります。

純粋そうなx => x * 10を思い出しましたか? 残ながら、これさえも、純粋だとは言い切れません。

var a = {}; a.valueOf = Math.random;
var fn = x => x * 10;
fn(a); // 5.107926817373938
fn(a); // 3.4100775757245416
fn(a); // 5.1903831613695095
// Same input, different outputs!

何ということでしょう。JavaScriptには純粋なものは何もないのでしょうか? 皆さんから聞こえてきそうなコメントとしては、「どっちでもいいよ。君がここで書いたような斬新で特殊なケースは実践では出てこない」などでしょうか。たしかに、valueOfが改ざんされてMath.randomになるようなことはないでしょう。

でも、ある日起こるかもしれません。時々とても危険なバグと格闘することがありませんか? このJavaScriptのうしろには黒魔術でも使われているのだろうか、とさえ思うかもしれません。神秘的な何かが進行しているのです。こうした神秘的なケースが起こるのは決まって、予想もしない何かとても特殊なことが発生した時です。どうでしょう、経験したことがあるという気がしませんか?

私たちは呪われているのでしょうか? x => x * 10はとてもかわいらしく簡単に使えるのに、いつも純粋というわけではないのです。JavaScriptには純粋なものはあるのでしょうか? そもそもJavaScriptにおいて純粋であることは可能なのでしょうか? JavaScriptは全くもって純粋でないのでしょうか?

いいえ、違います。以下のようにすれば、sumを毎回、数学関数のように振る舞わせることができます。

function sum(arr) {
  if (!arr) return void 0;
  if (typeof arr !== 'object') return void 0;
  if (!Array.isArray(arr)) return void 0;
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    if (typeof arr[i] !== 'number') {
      return void 0;
    }
    z += arr[i];
  }
  return z;
}

誰かがArray.isArrayを改ざんしたとしたら、どうなるでしょう?

Array.isArray = (arr) => Math.random() < 0.5;

では、少し待ってくださいね。

 function sum(arr) {
   if (!arr) return void 0;
   if (typeof arr !== 'object') return void 0;
+  if (Array.isArray.toString() !== 'function isArray() { [native code] }') {
+    return void 0;
+  }
   if (!Array.isArray(arr)) return void 0;
   var z = 0;
   for (var i = 0; i < arr.length; i++) {
     if (typeof arr[i] !== 'number') {
       return void 0;
     }
     z += arr[i];
   }
   return z;
 }

最初のsumを純粋関数にするために、基本的に入力値に関して考えられる想定をリスト化しました。それでもまだ、”純粋”なsumを壊せる賢い方法をだれかが見つけてしまうのではないかと心配です。想定のリスト化は退屈ですし、コードを読みにくくします。おそらく皆さんも、共通の無効な入力値のために、このようなコードを書いたことがあるのではないでしょうか? しかし、私と同じように、簡単ではないと感じているはずです。めったに発生しないような厄介なケースや、予想される全ての状況をカバーできている確信はありますか? 関数は、常に数学関数のように振る舞っているでしょうか?

関数型プログラミング言語がどれほど純粋かをお見せしましょう。関数に関する想定のリスト化を簡単にしてくれます。

TypeScriptでは、想定をシグネチャの中に書くことができます。

function sum(arr: Array<number>): number

関数の本体はJavaScriptと同じです。

function sum(arr: Array<number>): number {
  var z = 0;
  for (var i = 0; i < arr.length; i++) {
    z += arr[i];
  }
  return z;
}

この関数を下の関数と一緒に使ったとしましょう。

sum();

これはコンパイルもされません。つまり、プログラムは”振る舞い”すらしないということです。また、下のコードもコンパイルされません。

var arr = [{}, {}, {}];
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr);
sum(arr);
sum(arr);

しかし、賢明なプログラマであれば、私のTypeScriptのsumも壊せることに気づくでしょう。

sum(null);

コンパイルされますが、「TypeError: Cannot read property ‘length’ of null(nullのlengthプロパティが読み取れません)」というエラーが返ってきます。これは、TypeScript 2.0より前のバージョンではnullを含むArray<number>も受け入れるためです。TypeScript 2.1を使っていたとしても、型キャストを使えば、うまく誤魔化せます。

var arr = [{}, {}, {}] as Array<number>; // trust me, compiler!
arr[0].valueOf = arr[1].valueOf = arr[2].valueOf = Math.random;
sum(arr); // 2.393660612848899
sum(arr); // 2.3418339292845998
sum(arr); // 2.15048094452324

これはコンパイルされますが、sum(arr)の出力値は異なってきます。

つまり、TypeScriptも絶望的だということでしょうか? ある意味そう言えます。ただし、JavaScriptよりはずっとマシです。TypeScriptの型付けはコードに想定を加えるため、めったに発生しないような厄介なケースへの対応が、そのままのコードよりも可能になります。だから、私はTypeScriptが好きです。TypeScriptは、難しいという感覚を少し和らげてくれます。

私たちは、見るだけで関数が純粋であると確信できるでしょうか? これは、PureScriptやHaskellなどのような関数プログラミング言語でしたら可能です。

sum :: [Int] -> Int
sum = foldl (+) 0

構文が分からない場合は、[Int] -> Intが重要なポイントになります。これはIntのリストを用いて、Intを1つだけ返す関数です。リストを定義しないことも、nullとすることもできません。そしてJavaScriptの時のように、数字を改ざんすることはできないでしょう。また、大量の想定がIntには組み込まれています。この型なら、Num(数字)、Ord(順序となる整数)、Eq===で比較される整数)、Show(可読性のある整数のフォーマットを作る)など、数多くの型クラスに対応できます。これらの想定すべてが、数多くある、めったに発生しないような厄介なケースに対応します。おそらくHaskellにも、ランタイムエラーや、安全ではない操作もいくつかあるでしょう。しかし、重要なのは、Haskellは数学関数のように振る舞うすばらしいコードを書くのに向いているという点です。

まとめ

確かにHaskell関数は純粋で、コードを見ればそれが分かります。しかし、本稿のタイトルはJavaScriptについてでしたよね。

私は、「RxJSのscan演算子は純粋か?」という議論の中で、純粋だという主張をしてきたこともあり、ここしばらくJavaScriptの純粋性について考えてきました。私の主張は間違っていました。正確には間違っていないのですが、状況によります。上位Observableのコンテキストの外側で使った場合は、Elm(HaskellやPureScriptと同類の、実際の関数プログラミング言語)で使った時と同じように、純粋な関数となります。数学関数のように振る舞います。しかし、上位Observableの中でscanを使う場合は、数学関数のように振る舞わなくなる可能性が大きくなります。

なぜ、これが問題なのでしょう?それは、私が「純粋か?」という議論から離れたいと考えているからです。

このfunctionは、私のプログラムの中で起こり得るどんな状況でも、数学関数のように振る舞うだろうか?

文が長くなりましたね。この答えを簡単に見つけられる状況など、ほとんどないでしょう。しかし、JavaScriptの純粋性について私たちができるのは、これだけです。コードを見て、それが純粋だと言い切ることはできません。おそらく数多くの想定を見落としています。ですから、代わりに「この特定の状況の中で数学関数のように振る舞うか?」と考えていきましょう。

私の母国語であるポルトガル語では、「a função não é pura, a função está pura」という表現で、この2つの異なる”状況”を言い表すことができます。つまり、「(一般論として)関数は純粋ではなく、(まさに今使われている、この)関数は純粋だ」ということです。

本稿を気に入っていただけた場合は、リンクをツイートして、皆さんのフォロワーにシェアして頂ければ幸いです。