4 ヶ月ぶりの更新#
実際には、後続の再帰ステップは、JS の参照データ型の特性を利用して、C 言語のポインタのような機能を模倣しているだけです 😐 つまり、単純にポインタを渡し、リンクリストのようなデータ構造を生成し、データを埋めて最終的に目標コードを得るだけです... それだけです..
最初のころ#
the-super-tiny-compiler は、GitHub 上で JS で書かれたコンパイラで、コードのコメントでは、最小のコンパイラかもしれないと言われており、Lisp スタイルの言語を C スタイルの言語に変換することができます。
このコンパイラプロジェクトは、小さながらも完全なものです。
しかし、私はソースコードを読んでいる間に、新しい AST を生成するプロセスで疑問を抱きました。
それで、今からソースコードを分析してみます。
コンパイラの原理は、次のように簡単に説明できます。
レキシカルアナライザ
構文解析(AST の生成)
oldAst -> newAst への変換
最後に生成された newAst をターゲット言語の構文に変換して出力する
(add 2 (subtract 4 2))
を入力として、得られる AST の構造は次のようになります。
const ast = {
type: "Program",
body: [
{
type: "CallExpression",
name: "add",
params: [
{
type: "NumberLiteral",
value: "2",
},
{
type: "CallExpression",
name: "subtract",
params: [
{
type: "NumberLiteral",
value: "4",
},
{
type: "NumberLiteral",
value: "2",
},
],
},
],
},
],
};
この構造を得た後、次の関数が実行されます。
function transformer(ast) {
let newAst = {
type: "Program",
body: []
}
ast._context = newAst.body
}
ast オブジェクトに新しいプロパティを追加し、そのプロパティを newAst の body プロパティに指定します。
その後、次のように進行します。
function transformer(ast) {
let newAst = {
type: "Program",
body: []
}
// ここでastのプロパティを変更すると、newAstに直接影響します
ast._context = newAst.body
traverser(ast, {
// 数値の処理
NumberLiteral: {
enter(node, parent) {
parent._context.push({
type: 'NumberLiteral',
value: node.value
})
}
},
// 文字列
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
CallExpression: {
enter(node, parent) {
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name
},
arguments: []
}
node._context = expression.arguments;
if (parent.type !== "CallExpression") {
expression = {
type: 'ExpressionStatement',//式の文
expression
}
}
parent._context.push(expression)
}
}
})
return newAst//newAstを返す
}
わかりますが、newAst のデータの変化は、traverser 関数を 1 回だけ実行するだけで完了します。この関数は、ast を引数として受け取り、visitor オブジェクトに基づいて newAst の body に対して異なるタイプのコピーを行います。
この関数の内部は次のようになっています。
function traverser(ast, visitor) {
function traverseArray(array, parent) {
}
function traverseNode(node, parent) {
// 渡されたノードに対応するプロパティがあるかどうかを判断します
let methods = visitor[node.type]
// 存在する場合、親ノードのbodyに値を追加します
if (methods && methods.enter) {
methods.enter(node, parent)
}
// visitorに含まれていないプロパティを個別に処理します
switch (node.type) {
// 最初に最も外側のノードのbodyをトラバースします
case "Program":
traverseArray(node.body, node);
break;
case "CallExpression":
traverseArray(node.params, node)
break;
case "NumberLiteral":
case "StringLiteral":
break;
default:
throw new TypeError(node.type)
}
}
traverseNode(ast, null)
}
実行すると、traverser -> traverseNode -> のメインフローになります。
ast が node パラメータとして渡され、この関数の実行プロセスは再帰呼び出しになります。
最初の実行では、methods は undefined になり、switch 文に入ります。
最初の ast の type は Program であり、ast の body を引数として渡して break します。
ここで、メインフローはすでに完了しています。
その後の実行フローは、ast.body に対して疑似的な再帰またはネスト呼び出しを行います。関数は、tree パラメータに基づいて、式、パラメータに応じて新しい ast を生成するかどうかを判断します。
最後に、生成された新しい ast をターゲット言語に変換して出力するだけです。これについては特に説明することはありません。再帰的に文字列を生成するだけです。
まとめると、見かけはシンプルな小さなプロジェクトですが、自分で実装する場合、どれだけの細部を考慮する必要があるかわかりません。ですから、フロントエンドの深い道は、重要で長い道のりです!