TT Lab
はじめる
学ぶ 学習パス コース

コンパイラ — 小さな言語を最初から最後まで作る

宣言時の環境を持ち歩く — クロージャ

TT Labで続きを見る

一言でいうと

木をたどるインタプリターは、ASTのノードごとに「このノードに出会ったら何を計算するか」を書いた関数1つです。名前は環境(名前から値への辞書が、外側の環境を指していく連鎖)で探し、関数の値は宣言されたときの環境をいっしょに持ち歩きます。これがクロージャです。

なぜ必要なのか

モジュール6のバイトコードVMとモジュール8の機械語は速いのですが、意味を定義するには遠い存在です。「&&の左が偽なら右を計算しない」「ブロックが終わると内側の名前が消える」「関数は自分が宣言された場所の変数を見る」のような規則を、いちばんまっすぐ書ける場所が、木をたどるインタプリターです。そのため、このコースはインタプリターを基準にします。あとのVMとネイティブコードは、同じプログラムを動かしたときにインタプリターと同じ行を出力して、はじめて正しいことになります。

ただし、「まっすぐ書ける」ことが「簡単」という意味ではありません。インタプリターをPythonで書くと、Pythonの意味がこっそり混ざり込みます。

意味を定義する側がこうしたものを見逃すと、あとのすべての実装が、間違った基準に合わせられてしまいます。

どう動くのか

環境は連鎖です。ブロックに入るたび、関数を呼ぶたびに新しい環境を作り、親を指すようにします。名前の検索は連鎖をたどって上がっていき、代入は、その名前が存在する環境の欄を書き換えます。今の環境に新しく作るのではありません。

fn counter(start) {           counter(10) 을 부르면
  let n = start;                [호출 환경 {start:10, n:10}] → [전역 {counter, …}]
  fn next() { n = n + 1;        next 를 선언하는 순간, 함수 값 = (next 의 선언, 지금 환경)
              return n; }
  return next;                 counter 가 끝나도 호출 환경은 next 가 붙잡고 있어 살아남는다
}
let a = counter(10);           a() → 새 환경 {} → 부모는 붙잡아 둔 호출 환경 → n 을 11 로
print a(); print a();          11, 12 — 같은 칸을 바꾸기 때문에 이어진다

関数の値を呼ぶときの環境ではなく、宣言されたときの環境を親にすることが核心です(静的スコープ)。呼ぶときの環境を親にすると(動的スコープ)、a()はcounterのnを見つけられず、グローバルに同じ名前があれば、見当違いにそれを書き換えます。クロージャが環境をコピーして持っていくのも間違いです。同じ環境をつかんでいる2つの関数(値を上げる関数と読む関数)が、別々の欄を見ることになるからです。

returnは複数の層を一度に抜けます。whileの中のifの中のreturnは、呼び出し元までまっすぐ戻る必要があります。Pythonでは、例外を1つ(ReturnSignal)投げて、呼び出し元で捕まえるのがいちばん簡単です。呼び出しが終わったら呼び出しの深さを必ず戻す必要がありますが、例外で抜ける経路で忘れやすくなります(finallyが必要な場所です)。

実行エラーは演算子の位置で知らせます。a / bでbが0なら、/の行:桁で「runtime error: division by zero」を出します。意味解析が知らずに通した型(引数として入ってきたtrue)も、ここで引っかかります。同時に生きている呼び出しが200個を超えたら、「stack overflow」で止まります。Python自身の再帰の上限に先に達すると、ユーザーにPythonのエラーを見せることになります。

現場での姿

次のラボですること

interp.pyに、値の規則(64ビットの折り返し、0方向への除算、boolとintの区別)、環境の連鎖、式の計算(短絡評価と実行中の型検査)、文とブロック、if・while、関数とクロージャ・return・呼び出しの深さ、そして構文解析から実行までをつなぐrun_sourceを作ります。採点ツールは、ランダムな数の組数百個、固定プログラム、ランダムなプログラム150個で出力した行と実行エラーを、基準と突き合わせます。