自作言語をダイナミック・スコープで実装していた話

342, 2021-11-10

目次

自作言語をダイナミック・スコープで実装していた話

Padという自作言語をここ5年ほど開発している。
インタプリタだ。

インタプリタ言語のスコープには2種類ある。
1つが「ダイナミック・スコープ」だ。
もう1つが「レキシカル・スコープ」。

ダイナミック・スコープは関数を呼び出したときに新規にスコープをプッシュする形式のスコープだ。
実行時に関数を呼び出すと、その関数用のスコープをプッシュする。
そしてそのスコープ内で変数を定義する。

レキシカル・スコープは関数の定義時にスコープを作成する形式のスコープだ。
実行時に関数が現れたら、その関数用のスコープを作成する。

コードで見た場合↓はレキシカル・スコープの挙動だ。

let n = 1

function f1() {
    console.log(n)  // 1
}

function f2() {
    let n = 2
    f1()  // 1を出力する
}

この挙動は最近の言語に慣れている人には当たり前に見えると思う。
ダイナミック・スコープでは↓のような挙動になる。

n = 1

def f1():
    console.log(n)  // 2
end

def f2():
    n = 2
    f1()  // 2を出力する
end

↑の挙動は奇妙に見えるかもしれない。
ダイナミック・スコープではf2を呼び出したときにスコープがプッシュされる。
そしてf2内でf1を呼び出したらまたスコープがプッシュされる。
つまりf1内で変数nを参照すると、最初にf1内のスコープで検索を行う。
見つからなかったら1つ前のスコープ、つまりf2のスコープから変数を検索する。
そのためグローバルなnまで検索は行われず、f2内のnが参照されることになる。

多くのプログラミング言語、たとえばRuby, Python, JavaScriptなどではレキシカル・スコープが採用されている。
ダイナミック・スコープはPerlやEmacs Lispなどで採用されている。

Padのスコープはどちらなのかというと、ダイナミック・スコープで実装していた。
なぜダイナミック・スコープで実装したのか?
それは私がダイナミック・スコープしか知らなかったからである。
最近になってレキシカル・スコープの存在を知った。

どうも変だと思っていたのだ。
PythonやJavaScriptを参考にして言語を開発していたのに、どうも挙動がちょっと違う。
変数の検索の解決が奇妙だ。なんだこのバグは(バグではなくダイナミック・スコープの仕様だった)。

5年も開発していてかなり今更な話である。

ほんとね

Padには機能としてクロージャとポインタを実装する予定でいる。
今回、ダイナミック・スコープでそれらの機能を実装していたが、どうもこれらの機能はダイナミック・スコープとは相性がわるいらしい。
ポインタはかなり相性がわるい。

なぜかというと、ポインタは関数内の変数の参照を関数外に持ち出せるからだ。
しかしダイナミック・スコープではスコープは関数が終わったときにポップされる。
そのためその変数の参照は不正なものになる。
正確にはガーベジコレクションで変数の寿命が参照によって延長されるのでアクセスはできるのだが、変数を収めているオブジェクト・マップがスコープのポップと同時に破棄されてしまう。
そのためインプレース演算などで変数を再セットしようとすると、すでに破棄されたオブジェクト・マップにアクセスすることになる。
そうすると、そのオブジェクトでは+=や-=などのインプレース演算は使えなくなってしまう。
使うと解放済みのメモリ領域にアクセスしてプログラムがクラッシュする場合がある。

このような問題に直面したため、Padのスコープをレキシカル・スコープに変更する作業を行っている。

ちなみにPerlは両方のスコープを使えるらしい。
myとlocalでスコープが異なるとのこと。
Padも両方のスコープを使えるようにするべきだろうか?
しかし、ダイナミック・スコープの挙動は最近の言語ではあまりなじみがうすい挙動である。

こういった大幅な仕様変更の時になにより頼りになるのがテストだ。
テストがあるおかげでこのような大規模変更を行うことができる。
変更してバグが発生しても、テストがあれば修正できるからだ。
書いてて良かったテストケース。