ことばを、定義できる言語
預けてあった不便を、引き取りに行く
コース1のレッスン5で、あなたは「はなびら」という語を、にわ語の辞書に載せました。そして帰りぎわに、ひとつの不便を預かってもらいました——大きい花と小さい花を混ぜたいのに、「おおきい はなびら」のように語に値を添えて渡す書き方がない。
あの不便を、今日、あなた自身の手で解消します。あなたの言語の「語を定義する仕組み」には、最初から引数をつけるのです。
使う側で感じた不便を、作る側にまわって直す。机のこちら側とあちら側を往復するというこのコースの設計が、今日いちばんはっきり姿を見せます。
「とは」に、引数をつける
書きかたから見せます。育った言語に、話しかけてみてください。
- プログラム
- 関数double(x) の定義
- 式かける ×
- 名前x
- 数2
- 式かける ×
- 呼び出しdouble(…)
- 数21
- 関数double(x) の定義
にわ語の「とは」にあたる合図が、fn です。ちがいは名前のあとの (x)——あの日なかった引数が、最初からそこにいます。
観察ポイントは2つあります。①の粒では、fn が「合図」として読まれていること。そして③には、42が1個だけ残っていることです。
fn double(x) { … } という定義の文は、③に値を残しません。「定義しただけでは、何も起きない」——にわ語で学んだあの決まりは、あなたの言語にもそのまま引き継がれています。42は、最後の行の呼び出し double(21) の返事です。
引数は、カンマで並べれば何個でも持てます。そして呼び出しは式なので、呼び出しの中に呼び出しを入れることもできます。
- プログラム
- 関数double(x) の定義
- 式かける ×
- 名前x
- 数2
- 式かける ×
- 関数add(a, b) の定義
- 式たす +
- 名前a
- 名前b
- 式たす +
- 呼び出しadd(…)
- 呼び出しdouble(…)
- 数3
- 数4
- 呼び出しdouble(…)
- 呼び出しdouble(…)
- 呼び出しadd(…)
- 数1
- 数20
- 呼び出しadd(…)
- 関数double(x) の定義
実験をひとつ。最後の行を add(3) に変えると、「『add』の引数は2個ですが、1個が渡されました。」と返ってきます。何個欲しくて何個来たかまで言う——レッスン6から続けてきたエラー設計の、新しい1行です。
引数の正体は、重ねた1冊
ここから、今日の難所に入ります。先に正直に言っておくと、スコープと、このあとの再帰は、プログラミング全体でも名うてのつまずき所です。ゆっくり進みます。
レッスン6で、言語の記憶の正体を知りました。名前と値の対応表——環境——が、1冊あるだけでした。関数の実装は、そこに規則をひとつ書き足します——呼び出しのたびに、あたらしい1冊を手前に重ねる。
double(21) が呼ばれた瞬間、評価器がやることは3つだけです。この実験室の評価器から、その3行を(すこし整えて)抜き出します。
const local = makeEnv(fn.env); // ① あたらしい1冊を作る(親つき)local.set("x", evaluate(arg)); // ② 引数を、その1冊に書きこむconst result = runBlock(fn.body, local); // ③ その1冊を手前に、本体を畳むそして名前を引くときの規則は、ひとつだけ。手前の1冊から探し、なければ親へさかのぼる。 JavaScriptで書くと、これだけです。
// 名前を引く規則:手前の1冊から探し、なければ親へ
function lookup(env, name) {
if (env.table.has(name)) return env.table.get(name);
if (env.parent !== null) return lookup(env.parent, name);
return undefined;
}
const outerEnv = { table: new Map(), parent: null };
outerEnv.table.set("x", 100); // 外の世界には x = 100 がある
// double(21) を呼んだ瞬間、評価器が作るもの
const localEnv = { table: new Map(), parent: outerEnv };
localEnv.table.set("n", 21); // 引数を、あたらしい1冊に書きこむ
console.log(lookup(localEnv, "n")); // 手前の1冊で見つかる
console.log(lookup(localEnv, "x")); // なければ、親へさかのぼる
console.log(lookup(outerEnv, "n")); // 外の1冊から、引数は見えないCtrl+Enter でも実行できます
最後の行が肝心です。呼び出しの中からは外の x が見えるのに、外から引数の n は見えない。あなたの言語でも確かめましょう。
- プログラム
- 関数f(a) の定義
- 名前a
- 呼び出しf(…)
- 数10
- 名前a
- 関数f(a) の定義
「a」という名前を、まだ知りません。先に「a = 値」と書いてください。
f(10) という呼び出しは成立しているのに、③には答えの10ではなく「『a』という名前を、まだ知りません。」が返ります。引数 a は、呼び出しのあいだだけ存在する1冊に書かれていて、呼び出しが終わればその1冊ごと捨てられるからです。
名前には、見える範囲がある——これをスコープと呼びます。
逆向きの実験もどうぞ。1行目に a = 100 を書き足すと、③は 100・10・100 になります。外の a と引数の a は同名の別人で、関数が外の a を上書きすることはありません。
なぜこの仕様にするのか。理由は、にわ語のレッスン5にすでにありました。語を定義した人は、その語を使う人の名前事情を知りません——中で使った名前が外に漏れないからこそ、語は安心して配り、組み合わせられるのです。
再帰は、勝手に動く
道具はそろいました。今日いちばんの見せ場です。
- プログラム
- 関数fact(n) の定義
- 文もしも
- 条件
- 式くらべる <
- 名前n
- 数2
- 式くらべる <
- そのとき
- 数1
- ちがえば
- 式かける ×
- 名前n
- 呼び出しfact(…)
- 式ひく −
- 名前n
- 数1
- 式ひく −
- 式かける ×
- 条件
- 文もしも
- 呼び出しfact(…)
- 数5
- 関数fact(n) の定義
階乗——5 × 4 × 3 × 2 × 1 = 120 を計算する関数です。②の木を見てください。fact(n) の定義 の枝の中に、fact(…) という呼び出しの枝がいます。
自分の定義の中で、自分を呼ぶ。これが再帰です。レッスン3で evaluate が自分自身を呼ぶのを見ました(目が回るのが普通だ、と言った場所です)——あれは処理系の中の再帰でしたが、今日のは、あなたの言語の使い手が書ける再帰です。
読みかたは fact の定義文のとおりです——「nが2より小さければ、1。ちがえば、n × fact(n - 1)」。fact(5) は fact(4) を呼び、fact(4) は fact(3) を呼び……と下りていき、fact(1) が 1 を返した瞬間に折り返して、掛け算が積み上がります。
さて、ここで評価器の中身を思い出してください。再帰のための特別な仕掛けを、なにか書いたでしょうか。なにも書いていません——あるのは、さっきの「呼び出しのたびに1冊重ねる」の3行だけです。
それでも、fact(5) の1冊には n = 5、その手前の fact(4) の1冊には n = 4。5冊が静かに重なって、どの n もけっして混ざらない。再帰は実装する機能ではなく、正しく作ったスコープの副産物だった——地味な3行から立ち上がる、言語を作る旅でいちばん美しい景色のひとつです。
止まらないときの備えも見ておきます。fact(n - 1) を、うっかり fact(n + 1) と書きまちがえて実行してみてください。「関数が関数を呼びすぎて、終わらなくなりました。」と止められ、再帰に止まる条件はあるかと問い返されます。
この言語は、重ねる1冊を200冊までと決めています。止まらないくりかえしに安全弁をつけたのと、同じ思想です。
return を書かない、という選択
fact の本体のどこにも、「これを返せ」という命令がありません。この言語の決まりはこうです——本体の中で、最後に値を残した文の値が、呼び出しの値になる。
if は選ばれた側の枝が残した値を残すので、fact の本体は if ひとつでそのまま答えになります。多くの言語はここに return という命令を置きますが、置かない設計もあります。Rust という言語も「ブロックの最後の式が値」という同じ系譜で、式指向と呼ばれます——これもまた、設計の選択です。
では、最後まで値が残らなかったら。ためしに fact の else { … } を丸ごと消して実行すると、「『fact』は値を返しませんでした。」——条件が成り立たず、else もなく、本体が値を残さずに終わったからです。困りごとに「本体の最後を、値になる式に」という次の一手を添えるのも、見慣れたエラー設計の続きです。
⟡ よりみち:「函数」——入れたら出てくる、函
関数は、もとは「函数」と書きました。函は、はこ。値を入れると、値が出てくる函です(中国語では、いまもこう書きます)。この庭は「変数は箱」というたとえを避けてきましたが、関数にかぎっては、函の字がよく似合います。値をしまいこむ保存の箱ではなく、double に 21 を入れると 42 が出てくる——入り口と出口のある、通り道としての函だからです。
✎ 演習:フィボナッチを、再帰で
フィボナッチ数列は 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …——直前の2つの和が次になる数列です。0番目を0、1番目を1として、n番目を返す fib(n) を定義し、fib(10) が 55 になることを確かめてください。下の fib はまだ、n をそのまま返すだけの仮の姿です。
- プログラム
- 関数fib(n) の定義
- 名前n
- 呼び出しfib(…)
- 数10
- 関数fib(n) の定義
ヒント1(考え方)
fact と同じ骨格です。「これ以上さかのぼれないとき、答えを直接返す」場合と、「自分を呼んで組み立てる」場合に分けます。直前の2つを足すので、さかのぼれないのは n が 0 と 1 のとき——そしてどちらも、答えは n 自身です。
ヒント2(かたち)
本体は if n < 2 { n } else { fib(n - 1) + fib(n - 2) } のかたちです(実験室では改行を入れて書いてください)。else の中で、自分を2回呼びます。
こたえ(の一例)
仮の本体をヒント2のかたちに置きかえれば、fib(10) は 55 になります。途中も見たければ fib(7) は 13。fact は自分を1回しか呼びませんでしたが、fib は2回呼ぶ——それでも評価器は、呼ばれるたびに1冊重ねるだけで、何も特別なことをしていません。
発展をひとつ。再帰を使わず、a = 0 と b = 1 から始めて while で n 回「2つの数を付け替えながら」進めても、fib は書けます(最後の行を a にして、値を残すのを忘れずに)。再帰版と while 版、どちらも正しい設計です。
止まる条件を n == 0 と n == 1 の2段に分けて書いた人も、かたちがこれと違っていても、fib(10) が 55 になっているなら、それがあなたの正解です。
このレッスンで分かったこと
fnで、あなたの言語は自分の語彙を増やせるようになった。コース1で預けた「引数がない」不便は、あなたの手で解消された- 引数の正体は、呼び出しのたびに手前に重ねる1冊。手前から探し、なければ親へ——名前の見える範囲(スコープ)はそこから生まれる
- 評価器に、再帰のための仕掛けはひとつもない。再帰は、正しく作ったスコープの副産物として勝手に動く
returnを置かず「最後に値を残した文の値」とするのも設計の選択(式指向、Rustと同じ系譜)