JavaScriptでLispインタープリターを作ろう(8) ~ローカルスコープの導入~
前回の記事で関数を記述する機能が導入できた。しかしながら、現状のインタープリターにはローカルスコープが無く、変数が関数内に閉じていないために課題も残った。今回は、上記の課題を解決するためにローカルスコープを導入する。
ローカルスコープとは
ローカルスコープを実現する前に、JavaScriptを例にローカルスコープの性質をおさらいしておこう。
var a = 1; var b = 2; function sample(c) { var b = 1; // シャドーイング return a + b + c; } console.log(sample(3)); // -> 5が出力される console.log(b); // -> 2が出力される
ここではsample
関数を作成している。sample
関数の中では、sample
関数の外で定義されたグローバル変数a
を参照している。
また、グローバル変数b
についてはsample
関数内で新規変数b
を作成しているため、いわゆる「シャドーイング」が発生し、b = 2
という変数のバインディングがsample
関数内では隠され、b = 1
というバインディングだけが見えるようになっている。
また、仮引数c
には実引数3
が渡され、関数内部だけで参照可能である。
sample(3)
の計算は1 + 1 + 3
の結果なので5
となる。また、sample
関数内でシャドーイングされていた変数b
は、関数の外ではシャドーイングされなくなり、変数b
の値は2
となる。
この例を用いてLispインタープリターをどのように変更するかを考えていくと、次のことが言える。
- 変数
a
や変数b
はグローバル変数領域に入っているので、今のインタープリターで言うglobalEnv
相当のHashMap
で管理するのが自然である。 - 変数
c
はsample
関数内部に閉じた変数領域に入っているので、今のインタープリターで言うglobalEnv
とは別の、sample
関数に閉じたHashMap
で管理する必要がある。ここでは便宜上そのHashMap
をenv1
と呼ぶ。 sample
関数の中で新規に定義された変数b
は、上記のenv1
で管理するのが自然である。また、シャドーイングの発生状況からして、globalEnv
ではb = 2
というバインディングを保持し、env1
ではb = 1
というバインディングを保持する必要がある。- sample関数の中では主に
env1
を見て変数の値を読み取るはずだが、env1
に存在しない変数a
のようなものは、より外側のHashMap
(ここではglobalEnv
)を探しに行くような挙動が必要になる。
JavaScriptのローカルスコープの仕様を元にローカルスコープの実現に必要そうな性質を洗い出してみると、上記のようになる。これを図示すると以下のようになる。
こう見てみると、今まで利用してきたglobalEnv
という変数の入れ物をどのように変更すればよいかが浮かび上がってくる。
ローカルスコープを実現するためのデータの入れ物
ここまでの考察の結果、ローカルスコープを実現するためのデータの入れ物として必要な要件は、以下のようになる。
globalEnv
や関数ローカルの領域は、それぞれ別のHashMap
とする。- それぞれの
HashMap
は、より上位の階層のHashMap
を知っている。別の言葉で言うと、「親」を知っている。 - 最上位の「親」は
globalEnv
である。 - 変数のバインディングを解決する際には、現在の
HashMap
からバインディングを探し、そこにバインディングが無ければより上位の階層のHashMap
へバインディングを探しに行く。そして最上位の親であるglobalEnv
までさかのぼって探しに行く。
これを図示すると以下のようになる。実のところ、これはJavaScriptで言う「スコープチェーン」の概念と合致したものでもある。
以下がこのようなデータ構造の実装である。以降、このデータ構造をenv
オブジェクトと呼ぶことにする。
var Env = function(binding, outerEnv) { return { binding: binding, outerEnv: outerEnv, findEnv: function(key) { return (key in this.binding || this.outerEnv === undefined) ? this : this.outerEnv.findEnv(key); } }; };
この新しいEnv
関数は、env
オブジェクトをリターンする。
Env
関数に渡す引数は2つある。1つ目のbinding
引数には、env
オブジェクトでバインディングを保持するHashMap
を渡す。もう1つのouterEnv
引数には、このenv
オブジェクトの親となるべきスコープを渡す。
Env
関数が戻すenv
オブジェクトは2つのフィールドと1つのメソッドを保持している。
binding
フィールドには、変数名とその値の結びつきを保持するHashMap
が入る。
outerEnv
フィールドは、より上位のenv
オブジェクトへの参照である。ただし、globalEnv
の場合はundefined
になる。
findEnv
メソッドでは、引数で与えられた変数名がどのenv
オブジェクトに入っているかを探し、そのenv
オブジェクトを返却する。もしどのenv
オブジェクトにもその変数名が保持されていなかった場合、globalEnv
が返却される。
このEnv
関数の挙動を試した例を以下に載せる。
この例では、以下の様なenv
のツリーを構築して、操作している。
たとえばenv1
で変数a
を探した場合、env1
自身がそのバインディングを持つため、findEnv
メソッドはenv1
オブジェクトを返却する。そのため、変数a
の値としては1
が取得できる。
一方、env2
で変数a
を探した場合、env2
自身にはそのようなバインディングが無いため、さかのぼってglobalEnv
を参照し、そこで変数a
のバインディングを見付ける。そのため、変数a
の値は0
となる。
変数の階層構造を導入したLispインタープリター
Lispインタープリターに、変数の階層構造をサポートするEnv
関数を導入すると次のようになる。
env
はソースコードの多くの箇所に登場するため、上記のコードは沢山の変更点を含んでいる。変更点をかいつまんで説明すると、以下のようになる。
- 関数呼び出しの際には、関数呼び出しのたびに新しいローカル変数領域である
newEnv
を作成するようにした。これにより、関数呼び出しのたびにローカルスコープが作成される。また、このローカルスコープの親のenv
は、関数の呼び出し元のenv
ではなく、関数が定義された際のenv
となっている事が重要である。親のenv
として、関数の呼び出し元のenv
を利用した場合、これは「ダイナミックスコープ」と呼ばれるスコープ解決方法となる。一方、関数が定義された際のenv
を親とする場合は「レキシカルスコープ」(もしくは静的スコープ)と呼ばれる。ほとんどのプログラマーにとって、レキシカルスコープの方が馴染みの深いスタイルである。ダイナミックスコープを採用している代表的な言語にはEmacs Lispがある。 - 変数の定義や代入を行う
def
やset!
の処理内容が新しくなっている。def
の挙動としては、常に「現在の」ローカルスコープに変数を定義している。一方で、set!
は該当の変数がどのenv
に存在するかを探し、該当のenv
の変数を書き換えようとする。もし該当の変数が無ければ、globalEnv
に新規変数を作成する。 - 変数の解決を行う処理が変更になり、
findEnv
メソッドを用いた方法へと書き換えられている。 globalEnv
の初期化方法が変わり、Env
関数を用いたものとなっている。
実行例1:シャドーイング
前出の以下のJavaScriptコードと同等の操作を、このLispインタープリターで試してみる。
var a = 1; var b = 2; function sample(c) { var b = 1; // シャドーイング return a + b + c; } console.log(sample(3)); // -> 5が出力される console.log(b); // -> 2が出力される
evaluate(['do', ['def', 'a', 1], ['def', 'b', 2], ['def', 'sample', ['fn', ['c'], ['def', 'b', 1], // シャドーイング ['+', ['+', 'a', 'b'], 'c']]], ['console.log', ['sample', 3]], // -> 5が出力される ['console.log', 'b']], // -> 2が出力される globalEnv);
ソースコードとしては、ほとんど1対1に対応するので理解して頂けると思う。この実行結果は、きちんとJavaScript版と同じ結果となる。
実行例2:クロージャー(閉包)
今回導入したローカルスコープは、レキシカルスコープであったため、驚くべきことにクロージャー(閉包)も同時に導入された状態になった。
たとえば以下の様なJavaScriptにおけるクロージャーの利用例と同等のコードは、このインタープリターでも実行可能だ。
var newseq = function(n) { return function() { n = 1 + n; return n; }; }; var seq1 = newseq(0); console.log(seq1()); // -> 1が出力される console.log(seq1()); // -> 2が出力される console.log(seq1()); // -> 3が出力される
これを、このLispインタープリター向けに書き直すと以下の通りとなる。
evaluate(['do', ['def', 'newseq', ['fn', ['n'], ['fn', [], ['set!', 'n', ['+', 1, 'n']], 'n']]], ['def', 'seq1', ['newseq', 0]], ['console.log', ['seq1']], // -> 1が出力される ['console.log', ['seq1']], // -> 2が出力される ['console.log', ['seq1']]], // -> 3が出力される globalEnv);
以上でローカルスコープの導入は完了だ。ローカルスコープの概念をあらためて紐解いたことに加えて、ローカルスコープの導入そのものが大きな変更を伴うものだったため、長文のエントリーとなってしまった。しかし、ローカルスコープを導入したことにより、プログラミング言語として必要な基本機能は揃ったと言えるだろう。是非このミニ言語で何が出来るか、色々試してみて欲しい。
さて、ようやく機能的には通常のプログラミング言語が持つレベルに近づいてきたこのLispインタープリターだが、今までソースコードをJavaScriptの配列を用いて記述してきた。このままでは、あまり大手を振って「これはプログラミング言語だ」とは言えないだろう。次回はこのLispインタープリターにパーサーを導入し、通常のプログラミング言語と同じように、文字列でソースコードを記述可能にする。