Skip to main content
  1. Posts/

evalの中身を覗いてみた ─ Emacsが40年抱えてきた矛盾の話

·248 words·2 mins
Table of Contents

普段は README.org をいじって設定を壊しては直す、という生活をしているが、たまには自分が毎日呼び出している eval が実際どう動いているのか見てみたくなった。というわけでGNU Emacs本体のCソース、src/eval.c (4,700行弱)を読んでみた記録。読み終えて分かったのは、この評価器が「言語処理系としての正しさ」より「1985年から動いているコードを1行も壊さないこと」を一貫して優先し続けてきた、継ぎ足しの塊だということだった。

eval_subという名の交通整理係
#

Emacs Lispで何かを評価するとき、内部では eval_sub という関数がすべてのS式を捌いている。中身は驚くほど単純な分岐だ。

  • コンスセルでなければ、そのまま返す(数値や文字列の「自己評価」)
  • シンボルなら、変数として値を引く
  • コンスセルなら、関数呼び出しか特殊形式かをさらに判定する

コンスセルの場合の分岐がこの関数の本体で、先頭要素(関数名にあたる部分)の型によって以下のように処理が分かれる。

  • Cで書かれたプリミティブ(SUBR)ならC関数を直接呼ぶ
  • レキシカルクロージャやネイティブコンパイル関数なら apply_lambda に委譲
  • (macro . 関数) という形のコンスセルならマクロとして展開してから再評価
  • (lambda ...) という生のコンスセルなら、これも apply_lambda に委譲

面白いのは、Emacs Lispの「関数」が実際には少なくとも4通りの異なる内部表現(Cのポインタ、コンパイル済みクロージャ、生のconsラムダ、ネイティブコード)を持っているのに、呼び出し側からはその違いが完全に隠蔽されている点だ。標準関数を後からユーザーが純Lispで再定義しても、呼び出し規約はまったく同じまま動く。

特殊形式は「引数を評価しないC関数」でしかない
#

ifletwhile といった特殊形式は、UNEVALLED という印がついたSUBRとして定義されている。

DEFUN ("if", Fif, Sif, 2, UNEVALLED, 0, ...)
  (Lisp_Object args)
{
  Lisp_Object cond = eval_sub (XCAR (args));
  if (!NILP (cond))
    return eval_sub (Fcar (XCDR (args)));
  return Fprogn (Fcdr (XCDR (args)));
}

つまり (if cond then else) と書いたとき、Emacsは cond の部分だけを評価してから、Cのif文で thenelse のどちらを評価するかを自分で決めている。マクロ展開という別フェーズがあるわけではなく、評価器がその場その場で「これは何を評価すべきか」を判断しているだけ、というのが素朴で好きなところだ。ちなみに if の内部で else 節の評価を丸ごと Fprogn に投げているのを見ると、「ifprogn に依存して実装されている」という実装同士の依存関係も透けて見える。

nilが自分自身を返す理由
#

nil を評価すると nil が返るのは当たり前に思えるが、実はシンボルなので本来は「変数として値を引く」経路を通るはずだ。種を明かすと、nilt・キーワードシンボルは初期化時に「自分自身を値として持つ」よう仕込まれた上で、書き換え禁止フラグ(SYMBOL_NOWRITE)が立てられている。data.cset_internal を見ると、このフラグが立ったシンボルへの再代入は問答無用でエラーになる(キーワードが自分自身に再設定される場合だけ例外)。

見た目の「自己評価」と、C言語レベルでの実装はまったく別物というのが地味に面白かった。アトムの自己評価は「コンスでなければスルーする」だけの話で、nil/t/キーワードの自己評価は「シンボルの値セルが定数として固定されている」という、まったく別の仕組みで実現されている。

レキシカルスコープを動的束縛の上に建て増しした話
#

一番驚いたのはここだ。Emacs Lispは今でこそレキシカルスコープ(クロージャがちゃんと変数を閉じ込める方式)を使えるが、これは後付けの機能で、しかも*内部実装はダイナミックスコープの仕組みをそのまま流用*している。

具体的には、レキシカル環境そのものが Vinternal_interpreter_environment という、特殊変数(つまりダイナミックに束縛される変数)の中にalistとして格納されている。let でレキシカル変数を作るというのは、実は「環境alistというダイナミック変数に新しいコンスセルをconsして、それをまたダイナミックに束縛し直す」という操作に過ぎない。土台はダイナミックスコープのまま、その上にレキシカルスコープごっこを乗せているような設計だ。

そして、シンボルごとに「動的」か「レキシカル」かを切り替えるのが declared_special フラグだ。これは (defvar sym val) のように初期値つきで宣言すると立つ、シンボルに一生ついて回るグローバルな印になる。

さらに面白いのが (defvar foo) (初期値なし)の挙動だ。これは、そのシンボルを「このスコープでは動的扱いにする」という*一時的な*目印として、環境alistに*コンスセルではなく裸のシンボル*を突っ込む。

Vinternal_interpreter_environment
  = Fcons (sym, Vinternal_interpreter_environment);

変数参照コードは Fassq (コンスセルのcarを探す関数)を使うので、裸のシンボルには絶対にヒットしない。一方 let 側は Fmemq (リストの要素そのものが eq で一致するか探す関数)でこの裸シンボルを検出し、レキシカル束縛をやめて動的束縛に切り替える。

  • 実際の束縛エントリ (var . val) はコンスセルなので、var そのものとは eq にならない → Fmemq には引っかからない
  • 裸のシンボル varvar そのものなので → Fmemq に引っかかる

*同じ1本のリストの中に、意味の違う2種類のエントリを型の違いだけで共存させている*わけで、なるほどLispらしいと唸った。README.org の中で (defvar my-var) とだけ書いて後から setq する、というのをよく見かけるが、あれはまさにこの仕組みで「レキシカルスコープのファイル内でも、このシンボルだけは意図的に動的変数として扱わせる」というテクニックだったことになる。

関数呼び出しの中身: apply_lambdaとfuncall_lambda
#

関数呼び出し(SUBRでもマクロでもない、通常のクロージャやラムダ呼び出し)は apply_lambdafuncall_lambda という2段構えになっている。

apply_lambda は未評価の引数リストを評価してCの配列に詰めるだけの前処理で、本体は funcall_lambda にある。ここでは関数の型によって処理が4通りに分かれる。

  • 生の (lambda (args) body) 形式のコンスセル → 動的束縛前提
  • バイトコンパイル済みのレキシカルクロージャ → 引数束縛すら経ずに exec_byte_code へ直行
  • インタプリタ実行のクロージャ → コンスタントスロットからレキシカル環境を取得
  • ネイティブコンパイル関数 → lambda_list から引数名を取得

ここでも「レキシカルバイトコードだけは引数束縛ループを一切通らずバイトコードエンジンに丸投げする」という最適化があって、バイトコード自身がスタックから引数を取る命令を持っているから、C側での束縛処理がそもそも不要という理屈だ。

そのあとの &optional~/~&rest を処理する引数束縛ループも中身は素朴で、仮引数リストを1つずつ辿りながら &rest なら残り全部をリスト化、&optional 中で引数が足りなければ nil を補う、という具合。そして最後に束縛するとき、let のときとまったく同じロジック(lexenv が非nilならconsしてレキシカル、そうでなければ specbind で動的)が繰り返される。つまり「引数の束縛」と「let の束縛」は、Emacsの内部では同じ1つの判断ロジックのコピーなのだ。

specpdlという名の何でも屋スタック
#

動的束縛(specbind)がどう元に戻るのかを追うと、specpdl という単一のスタックに行き着く。lisp.hspecbind_tag 列挙型を見ると、このスタックが担っているのは変数の巻き戻しだけではないことが分かる。

  • 動的束縛(通常のletバインディングと、バッファローカル変数用のバリエーション)
  • unwind-protect のクリーンアップ関数
  • バックトレース情報
  • モジュール(動的ロード拡張)のランタイムや環境

性質の異なるものを1本のスタックに統一し、unbind_to を1回呼ぶだけで「変数を元に戻す」「クリーンアップ関数を呼ぶ」「バックトレースを巻き戻す」が同時に処理される。C言語には例外機構がないので、condition-caseunwind-protect の巻き戻しは setjmp~/~longjmp とこの単一スタックの組み合わせだけで実現されている。

autoloadは「関数の中身」に仕込まれた偽物
#

意外だったのが autoload の実装だ。シンボルの関数セルに (autoload FILE ...) というプレースホルダーが入っていると、eval_sub はファイルをロードしてから同じ評価を goto retry でやり直す。

関数を呼ぶたびに「これは本物の関数か、それとも遅延ロードの目印か」を毎回チェックする設計は、標準ライブラリを起動時に全部ロードしないための仕組みで、Emacsの起動速度を支える地味な根幹になっている。straight.el 経由で入れているパッケージの大半は、初回呼び出しまでこの autoload プレースホルダーのまま待機している。

40年分の後方互換性という重み
#

こうして読んでいくと、Emacsが「正しい言語設計」より「既存コードを壊さないこと」を一貫して優先してきたのがよく分かる。1985年当時のダイナミックスコープ前提のコードを今も動かしながら、レキシカルスコープもネイティブコンパイルもバイトコードエンジンも生やしていく。1つの変数が「シンボル単位で動的かレキシカルかを選べる」というのも、1つのalistが「型によって2つの意味を持つ」というのも、本来なら言語設計としては褒められたものではないかもしれない。しかし、後方互換性という制約の中でここまで機能を積み増してきた継ぎ足しの跡が、そのまま設計として残っているのを見るのは素直に楽しい。

次はバイトコードエンジン(bytecode.c)か、condition-case/catch-throw の非局所脱出まわりを読んでみようと思っている。

Related