ことばを、定義できる言語

よみもの+手を動かす時間:およそ35分

預けてあった不便を、引き取りに行く

コース1のレッスン5で、あなたは「はなびら」という語を、にわ語の辞書に載せました。そして帰りぎわに、ひとつの不便を預かってもらいました——大きい花と小さい花を混ぜたいのに、「おおきい はなびら」のように語に値を添えて渡す書き方がない。

あの不便を、今日、あなた自身の手で解消します。あなたの言語の「語を定義する仕組み」には、最初から引数をつけるのです。

使う側で感じた不便を、作る側にまわって直す。机のこちら側とあちら側を往復するというこのコースの設計が、今日いちばんはっきり姿を見せます。

「とは」に、引数をつける

書きかたから見せます。育った言語に、話しかけてみてください。

① ことばの粒(トークン)
fn合図double名前((x名前)){{x名前*計算2}}double名前((21))
② 構造の木
  • プログラム
    • 関数double(x) の定義
      • かける ×
        • 名前x
        • 2
    • 呼び出しdouble(…)
      • 21
③ 評価された値
42

にわ語の「とは」にあたる合図が、fn です。ちがいは名前のあとの (x)——あの日なかった引数が、最初からそこにいます。

観察ポイントは2つあります。①の粒では、fn が「合図」として読まれていること。そして③には、42が1個だけ残っていることです。

fn double(x) { … } という定義の文は、③に値を残しません。「定義しただけでは、何も起きない」——にわ語で学んだあの決まりは、あなたの言語にもそのまま引き継がれています。42は、最後の行の呼び出し double(21) の返事です。

引数は、カンマで並べれば何個でも持てます。そして呼び出しはなので、呼び出しの中に呼び出しを入れることもできます。

① ことばの粒(トークン)
fn合図double名前((x名前)){{x名前*計算2}}fn合図add名前((a名前,,b名前)){{a名前+計算b名前}}add名前((double名前((3)),,4))double名前((add名前((1,,20))))
② 構造の木
  • プログラム
    • 関数double(x) の定義
      • かける ×
        • 名前x
        • 2
    • 関数add(a, b) の定義
      • たす +
        • 名前a
        • 名前b
    • 呼び出しadd(…)
      • 呼び出しdouble(…)
        • 3
      • 4
    • 呼び出しdouble(…)
      • 呼び出しadd(…)
        • 1
        • 20
③ 評価された値
1042

実験をひとつ。最後の行を 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で書くと、これだけです。

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
③ 評価された値

「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
③ 評価された値
120

階乗——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
③ 評価された値
10
ヒント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 = 0b = 1 から始めて while で n 回「2つの数を付け替えながら」進めても、fib は書けます(最後の行を a にして、値を残すのを忘れずに)。再帰版と while 版、どちらも正しい設計です。

止まる条件を n == 0n == 1 の2段に分けて書いた人も、かたちがこれと違っていても、fib(10) が 55 になっているなら、それがあなたの正解です。

このレッスンで分かったこと

  • fn で、あなたの言語は自分の語彙を増やせるようになった。コース1で預けた「引数がない」不便は、あなたの手で解消された
  • 引数の正体は、呼び出しのたびに手前に重ねる1冊。手前から探し、なければ親へ——名前の見える範囲(スコープ)はそこから生まれる
  • 評価器に、再帰のための仕掛けはひとつもない。再帰は、正しく作ったスコープの副産物として勝手に動く
  • return を置かず「最後に値を残した文の値」とするのも設計の選択(式指向、Rustと同じ系譜)