🚀

JavaScriptで作る!ミニ言語のインタプリタ(5)〜JSONでプログラムを書く〜

2024/10/15に公開

はじめに

前回の記事では、組み込み関数の実装を行い、インタプリタが標準出力などを扱えるように拡張しました。今回は前回予告していた通り、JSON形式のプログラムを読み込んで実行できるようにします。

これまではJavaScriptで直に抽象構文木を記述していたため、「自作言語のプログラム」を外部に記述することは(evalなどを使わない限り)できませんでした。しかし、今回の拡張によってJSONとしてプログラムを記述しておき、必要に応じて読み込むことができるようになります。

プログラムの記述例

まず、前回の記事で扱ったプログラムを再掲します。

const program = new Program(
  // function print_add(x, y) {
  //   print(add(mul(x, y), 3)
  // }
  // print_add(5, 3);
  [
    new FunDef("print_add", ["x", "y"],
      new FunCall("print", 
        new FunCall("add", 
          new FunCall("mul", new VarRef("x"), new VarRef("y")),
          new Num(3)
        )
      )
    )
  ],
  new FunCall("print_add", new Num(5), new Num(3))
);

これをJSONで記述したものが以下です。JSONで表現するための方法はいくつもありますが、簡潔さを重視して、LispのS式に似た形式で記述しています。

const program = `{
  "functions": [
    ["print_add", ["x", "y"], 
      ["call", "print", 
        ["call", "add",
          ["call", "mul", ["ref", "x"], ["ref", "y"]],
          3]]]
  ],
  "body": ["call", "print_add", 5, 3]
}`;

プログラム全体を見ると、

  • プログラム全体が一つのオブジェクトになる
  • 関数定義の配列は、キーfunctionsで取り出せる
  • プログラム本体は、キーbodyで取り出せる

であることがわかります。また、プログラム本体は["call", ...]が関数呼び出しを表し、["ref", ...]が変数参照を表すといった具合で、配列の最初の要素が構文の名前を表現し、それ以降の要素が構文の実体を表現しています。これはまさにLispのS式と同じ方式です。

JSONを変換する

JSONであらわされたプログラムを読み込んでインタプリタで実行するためには、まずJSONパーザーを呼び出して、それを内部的なデータ構造に変換する必要があります。この役割を担うのが translateProgramtraslateExprの2つの関数です。それでは、それぞれの動作を説明し、プログラム全体を見ていきましょう。

translateProgram

translateProgram関数は、JSON形式で記述されたプログラムを内部構造に変換します。以下に関数の定義を示します。

function translateProgram(jsonProgram) {
  const defs = jsonProgram['functions'].map(f => 
    new FunDef(f[0], f[1], translateExpr(f[2]))
  );
  const body = translateExpr(jsonProgram['body']);
  return new Program(defs, body);
}

この関数では、まずJSONオブジェクトからfunctionsキーで関数定義を取り出し、それぞれを FunDefクラスのオブジェクトに変換します。プログラムのbodyも同様にtranslateExpr関数を使って内部表現に変換します。このようにして、JSON形式で記述されたプログラムをインタプリタで評価可能な形式に変換するのです。

translateExpr

translateExpr関数は、JSONで記述された各式を対応する内部オブジェクトに変換します。この関数は、式が配列形式で表現されているかどうかを確認し、その内容に基づいて適切なオブジェクトを生成します。以下が関数の定義です:

function translateExpr(jsonObject) {
  if(Array.isArray(jsonObject)) {
    const operator = jsonObject[0];
    switch(operator) {
      case "+":
        return BinExpr("+", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "-":
        return BinExpr("-", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "*":
        return BinExpr("*", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "/":
        return BinExpr("/", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "<":
        return BinExpr("<", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case ">":
        return BinExpr(">", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "<=":
        return BinExpr("<=", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case ">=":
        return BinExpr(">=", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "==":
        return BinExpr("==", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "!=":
        return BinExpr("!=", translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "seq":
        return new Seq(...jsonObject.slice(1).map(translateExpr));
      case "if":
        return new If(translateExpr(jsonObject[1]), translateExpr(jsonObject[2]), translateExpr(jsonObject[3]));
      case "while":
        return new tWhile(translateExpr(jsonObject[1]), translateExpr(jsonObject[2]));
      case "<-":
        return new Assignment(jsonObject[1], translateExpr(jsonObject[2]));
      case "ref":
        return new VarRef(jsonObject[1]);
      case "call":
        return new FunCall(jsonObject[1], ...jsonObject.slice(2).map(translateExpr));
    }
  }
  switch (typeof jsonObject) {
    case "number":
      return new Num(jsonObject);
  }
  throw new Error("Not implemented for: " + JSON.stringify(jsonObject));
}

この関数は、JSONオブジェクトの形式に応じて様々なクラスを使って内部表現に変換します。例えば、二項演算子であればBinExprクラスを使用し、条件分岐であればIfクラスを使用します。

プログラムの実行

以下のevalJsonProgram関数はJSON文字列を受け取って実行する関数です:

function evalJsonProgram(jsonString) {
    const jsonProgram = JSON.parse(jsonString);
    const program = translateProgram(jsonProgram);
    return evalProgram(program);
}

内部はとても単純です。まず、JSON.parse()を呼び出してJSON文字列をオブジェクトに変換し、次に、translateProgramを呼び出すことで内部形式に変換しています。最後は、evalProgramを呼び出し、内部形式に変換されたプログラムを解釈しています。

evalProgramは定義済みの関数(インタプリタ)です。つまり、今回のプログラムがやっていることは、JSON文字列を受け取って適切な内部形式に変換する作業だと言えます。

プログラム全体の実行例

それでは、いよいよプログラム全体を見ていきましょう:

const program = `{
  "functions": [
    ["print_add", ["x", "y"], 
      ["call", "print", 
        ["call", "add",
          ["call", "mul", ["ref", "x"], ["ref", "y"]],
          3]]]
  ],
  "body": ["call", "print_add", 5, 3]
}`;
evalJsonProgram(program);

このプログラムは次のように読めます。

  1. functionsは関数定義の並びに対応しており、その中ではprint_addという関数を定義しています。この関数は引数xyを受け取り、xyの積に3を足した結果をprint関数で出力します。
  2. bodyはプログラム本体に対応しており、print_add関数を引数53で呼び出しています。

evalJsonProgramが呼び出されると、次の手順で上記のプログラムが評価されます:

  1. JSON文字列がオブジェクトに変換される(JSON.parse())。
  2. オブジェクトが内部表現に変換される(translateProgmram())。
  3. 内部表現が実行され、コンソールに計算結果が出力される(evalProgram())。

この例では、5 * 3 + 3 = 18が計算され、結果として18が出力されます。

ファイルからプログラムを読み込む

先のprogramに格納されているのは単なるJSON文字列ですから、これを外部ファイルに格納しても構いません。その場合、program.jsonを以下の内容で作成します:

{
  "functions": [
    ["print_add", ["x", "y"], 
      ["call", "print", 
        ["call", "add",
          ["call", "mul", ["ref", "x"], ["ref", "y"]],
          3]]]
  ],
  "body": ["call", "print_add", 5, 3]
}

次に、先ほどの実行例を次のように修正します:

const fs = require('fs');

try {
  const program = fs.readFileSync('program.json', 'utf8');
  evalJsonProgram(program);
} catch (err) {
  console.error('Error reading the file:', err);
}

同じように18が出力されていれば成功です。

まとめ

今回の記事では、JSONでプログラムを記述し、インタプリタで読み込んで評価する手法を紹介しました。これにより、これまで内部でのみ記述していたプログラムを外部から読み込み、実行することができるようになりました。次回の記事では、静的型を付け加えるための前準備として、インタプリタを拡張して、数以外のデータ型(真偽値、文字列など)を扱えるようにします。

お楽しみに!

nextbeat Tech Blog

Discussion