DUICUO

初心者向けのオープンソースコンパイラ

皆さんも私と同じように感じているのではないでしょうか。「コンパイラ」という言葉を見ると、とても高度なもののように思えますが、同時に、少しばかりの「恐怖」が心の中に湧き上がってきます。

コンパイラは非常に複雑で、私のような初心者には理解できないものだと常々思ってきました。そして、コンパイラについて学ぶというと、すぐに500ページ以上の分厚い本を思い浮かべてしまいます。

オープンソースプロジェクトの宝庫、超小型コンパイラを発見するまでは、そう思っていました。これは、わずか1000行ほどのミニコンパイラで、コードの80%はコメントで、実際のコードはわずか200行しかありませんでした。小さいながらも完成度が高く、コンパイラに必要な基本機能をすべて実装しています。コード、コメント、そして解説を通して、オープンソースプロジェクトを通じてコン​​パイラを使い始めることができます。

アドレス: https://github.com/jamiebuilds/the-super-tiny-compiler

中国語: https://github.com/521xueweihan/OneFile/blob/main/src/javascript/the-super-tiny-compiler.js

以下では、まずコンパイラとは何かを紹介し、前述のプロジェクトをサンプルコードとして用いてコンパイルプロセスを詳しく説明します。これにより、コンパイラ初心者にとって入門のハードルがさらに下がります。コンパイラについて全く知識のない方でも、この記事を読めばコンパイラの機能とその背後にある原理について基本的な理解が得られます。

強くなる準備はできましたか? さあ、始めましょう!

I. コンパイラとは何ですか?

簡単に言うと:

コンパイラは、ある言語 (通常は高級言語) を別の言語 (通常は低級言語) に変換するプログラムです。

現代のプログラマーにとって、JavaScriptやJavaといった最も馴染みのある言語は高水準言語であり、プログラマーにとって記述、読解、理解、保守が容易です。一方、低水準言語は、コンピューターが直接解釈して実行できる言語です。

コンパイラは、これら2つの言語間の「橋渡し」と捉えることができます。コンパイラが存在するのは、コンピュータのCPUが何百万もの小さな演算を実行するためです。これらの演算は非常に「小さい」ため、手動で記述することはまず望ましくありません。そこで、機械語として理解されるバイナリコードが登場しました。当然のことながら、バイナリコードは理解しにくく、記述も煩雑です。そのため、CPUアーキテクチャは、バイナリ演算をより読みやすい言語、つまりアセンブリ言語にマッピングすることをサポートしています。

アセンブリ言語は非常に低水準ですが、バイナリコードに変換できます。この変換は主に「アセンブラ」によって行われます。アセンブリ言語は非常に低水準であるため、高い効率を求めるプログラマーには受け入れられません。そこで登場したのが高水準言語であり、ほとんどのプログラマーが使い慣れているプログラミング言語です。これらの抽象プログラミング言語は機械の動作に直接変換することはできませんが、アセンブリ言語よりも理解しやすく、効率的に使用できます。したがって、必要なのは、これらの複雑な言語を理解し、正しく低水準コードに変換できるツール、つまりコンパイラです。

初心者にとっては、これで大まかな理解を得るのに十分だと思います。次に分析する例は非常にシンプルですが、ほとんどのシナリオをカバーしており、この「大敵」であるコンパイラに、最も現実的かつ直接的な視点から立ち向かうことになります。

II. 「ミニ」コンパイラ

以下では、 the-super-tiny-compiler をサンプルコードとして使用し、コンパイラの簡単な概要を示します。

コンパイラによって段階は異なりますが、基本的にはすべて、解析、変換、コード生成という 3 つの主要コンポーネントで構成されます。

実際、この「ミニ」コンパイラ オープンソース プロジェクトの目的は次のとおりです。

  • 現実世界のコンパイラが主に何を行うかを証明します。
  • コンパイラを構築する価値があるほど複雑なことを実行します。
  • 最も単純なコードを使用してコンパイラの主な機能を説明すると、初心者が圧倒されることがなくなります。

これがこのオープンソースプロジェクトの重要性を説明しています。コンパイラに強い関心があり、徹底的に学びたいのであれば、間違いなく多くの文献を読み、研究する必要があります。しかし、コンパイラの機能を理解したいだけなら、この記事をお見逃しなく!

ここからはプロジェクト自体についてさらに詳しく見ていきましょう。その前に、いくつか背景情報が必要です。このプロジェクトは主に、LISP言語を馴染みのあるJavaScript言語にコンパイルするものです。

では、なぜ LISP 言語を使用するのでしょうか?

LISPは、長い歴史を持つコンピュータプログラミング言語ファミリーであり、独自の括弧付きプレフィックス記法を採用しています。1958年に誕生したLISPは、世界で2番目に古く、現在でも広く使用されている高水準プログラミング言語です。

まず、LISPはCやJavaScriptといった私たちがよく知る言語とは大きく異なります。他の言語にも強力なコンパイラはありますが、LISPよりもはるかに複雑です。LISPは非常にシンプルな構文解析ツールであり、次のように他の構文に簡単に変換できます。

さて、ここまで読んで、これから何をするのかお分かりいただけたでしょうか?では、具体的にどのように「翻訳」(コンパイル)するのか、詳しく見ていきましょう。

III. コンパイルプロセス

前述したように、ほとんどのコンパイラは主に次の 3 つのことを行います。

  • 分析
  • 変換
  • コード生成

ここで、超小型コンパイラのコードを分解し、行ごとに分析します。

これら 3 つの段階で実際に何が起こるかを理解するために、一緒にコードを見てみましょう。

3.1 分析

解析には通常、字句解析と構文解析の 2 つの段階が含まれます。

字句解析:生のコードは、トークナイザー(または字句解析器)と呼ばれるものを用いてトークンに分解されます。トークンとは、単一の構文を記述する配列です。これらのトークンには、数値、ラベル、句読点、演算子などが含まれます。

構文解析:このプロセスでは、以前に生成されたトークンを、コードの各セグメントとそれらの関係を記述する抽象的な表現に変換します。これは中間表現または抽象構文木(AST)と呼ばれます。ASTは深くネストされたオブジェクトであり、コード自体をより扱いやすい方法で表現し、より多くの情報を提供します。

たとえば、次の構文:

 ( 2 を足す( 4引いて2を引く ))

これをトークン配列に分割すると次のようになります。

 [
{ : '括弧': '(' },
{ タイプ: '名前': '追加' },
{ : '数値': '2' },
{ : '括弧': '(' },
{ : '名前': '減算' },
{ : '数値': '4' },
{ : '数値': '2' },
{ : '括弧': ')' },
{ : '括弧': ')' },
]

コードロジック:

文字列を解析する必要があるため、現在解析している場所を識別するのに役立つポインタ/カーソルのようなものが必要です。そのため、0 から始まる `current` 変数が必要になります。最終的な目標はトークン配列を取得することなので、最初に空の配列 `tokens` を初期化します。

 `current` 変数はカーソルのように機能し、コード内の位置を追跡できるようにします。
電流0 します

// `tokens`配列はトークンを格納するために使用されます
トークン[] とします

文字列を解析するので、当然ながら反復処理が必要になります。ここでは、while ループを使用して現在の文字列を解析します。

 // ループ内で、`current` 変数を必要な値まで増分できます。
while ( 現在値< 入力値. 長さ) {
// `current` 文字も `input` に保存します。
char = 入力[ 現在の値] とします
}

文字列から1文字を取得するにはどうすればよいでしょうか。答えは、配列で使用されるものと同様の角括弧を使用することです。

 var char = str [ 0 ]

ここで理解すべき新しいポイントがあります。JavaScript では、String クラスのインスタンスは配列のようなものです。次の例をご覧ください。

これまでは charAt を使用して文字列から 1 文字を取得していたかもしれません。これは String 型のメソッドだからです。

どちらの方法も目的の効果が得られますが、違いがあります。インデックスが存在しない場合、`str[index]` は `undefined` を返しますが、`str.charAt(index)` は `""`(空文字列)を返します。

カーソルを移動して文字列から文字を取得すると、現在の文字列を段階的に解析できます。

構文解析はこれらの側面からも考察できます。`(add 2 (subtract 4 2))` を例に挙げると、(左括弧、文字列、スペース、数値、) 右括弧が出現します。異なる型を個別に処理するには、異なる if 条件を使用する必要があります。

  • 左括弧と右括弧を合わせると全体が表されます。対応する括弧が見つかったら、それをマークするだけです。
  • スペースは文字区切りを表すため、トークン配列に追加する必要はありません。次のスペース以外の文字にジャンプしてループを続行するだけです。
 // 左括弧があるかどうかを確認します:
if ( char === '(' ) {

// そうであれば、`paren` タイプの新しいタグを配列に格納し、その値を左括弧に設定します。
トークン.push ( {
タイプ: '括弧'
価値'('
});

// `current` をインクリメントする
現在の++ ;

// 次のループに進みます。
続く;
}

// 次に、上記と同様に右括弧をチェックします。
if ( char === ')' ) {
トークン.push ( {
タイプ: '括弧'
価値')'
});
現在の++ ;
続く;
}

// 次に、スペースの有無を確認します。これは文字の区切りを決定するためのものですが、タグとして保存する必要はありません。

// スペースがあるかどうかを確認します。スペースがある場合は、ループの次の反復に進み、マーカー配列へのデータ保存以外のすべての操作を実行します。
WHITESPACE = /\s/ とします
if ( WHITESPACE . test ( char )) {
現在の++ ;
続く;
}

文字列と数値は意味が異なるため、処理は比較的複雑です。まずは数値から見ていきましょう。数値の長さは一定ではないため、すべての数値文字列を取得するには、最初に出現した数値から始めて、最初に出現した非数値文字まで走査し、その数値を格納する必要があります。

 // (123 456 を追加)
// ^^^ ^^^
// 2つの別々のタグのみ
//
// したがって、シーケンスの最初の数字に遭遇したときに開始します。
NUMBERS = /[0-9]/ とします
if ( NUMBERS . test ( char )) {

// `value` 文字列を作成し、そこに文字をプッシュします。
値を' ' とします

// 次に、数字以外の文字に遭遇するまで、シーケンス内の各文字を反復処理します。
// 数字である各文字を `value` にプッシュし、進むにつれて `current` を増分します。
// この方法により、単なる 1 2 3 4 5 6 ではなく、上記の 123 や 456 などの完全な数字の文字列を取得できます。
while ( NUMBERS . test ( char )) {
+= char ;
char = 入力[ ++ 現在の値];
}

次に、数値型を使用して数字を記述および区別し、ラベル配列に格納します。
トークン.push ({ type : 'number' , value });

// 外側のループの次の繰り返しに進みます
続く;
}

実際のシナリオにより適した文字列操作(例えば、(concat "foo" "bar") など)がサポートされています。この場合、括弧内の文字列は数値の場合と同様に再度解析する必要があり、文字列全体を取得してから保存するための反復処理が必要になります。

 // 開き二重引用符をチェックすることから始めます:
if ( char === '"' ) {
// 文字列トークンを構築するために `value` 変数を予約します。
値を' ' とします

// 編集の先頭の二重引用符はスキップします。
char = 入力[ ++ 現在の値];

// 次に、別の二重引用符に達するまで各文字を反復処理します。
while ( char !== '"' ) {
+= char ;
char = 入力[ ++ 現在の値];
}

// 対応する閉じ二重引用符をスキップします。
char = 入力[ ++ 現在の値];

// 文字列トークンをトークン配列に追加します
トークン.push ({ type : 'string' , value });

続く;
}

最後のタグの種類は名前です。これは数字ではなく文字の並びで、Lisp構文における関数名に相当します。

 // (2 4 を加算)
// ^^^
// 名前タグ
//
LETTERS = /[az]/i とします
if ( 文字. テスト( 文字)) {
値を' ' とします

同様に、それらすべてを反復処理し、すべてを `value` 変数に格納します。
while ( LETTERS . test ( char )) {
+= char ;
char = 入力[ ++ 現在の値];
}

// このタイプの名前タグをタグ配列に格納し、ループを続行します。
トークン.push ({ type : 'name' , value });

続く;
}

これでトークンの配列ができました。次のステップは、抽象構文木(AST)を構築することです。ASTは次のようになります。

 {
タイプ: 'プログラム'
: [{
タイプ: 'CallExpression'
名前: '追加'
パラメータ: [{
タイプ: 'NumberLiteral'
: '2'
}, {
タイプ: 'CallExpression'
名前: 「減算」
パラメータ: [{
タイプ: 'NumberLiteral'
: '4'
}, {
タイプ: 'NumberLiteral'
: '2'
}]
}]
}]
}

コードロジック:

同様に、現在操作しているオブジェクトを識別するためにカーソル/ポインターが必要です。次に、Program というルート ノードを持つ AST ツリーを事前に作成します。

let current = 0; let ast = { type: 'Program', body: [],}; 先ほど取得したトークン配列をもう一度見てみましょう。

 [
{ : '括弧': '(' },
{ タイプ: '名前': '追加' },
{ : '数値': '2' },
{ : '括弧': '(' },
{ : '名前': '減算' },
{ : '数値': '4' },
{ : '数値': '2' },
{ : '括弧': ')' },
{ : '括弧': ')' },
]

`(add 2 (subtract 4 2))` のようなネストされた文字列の場合、この配列は非常に「フラット」で、ネスト関係を明確に表現できません。しかし、この AST 構造ではネスト関係を明確に表現できます。上記の配列の場合、各タグを反復処理し、`CallExpression` であるパラメータを見つけて、閉じ括弧に遭遇するまで処理する必要があります。したがって、再帰が最適なアプローチです。そこで、`walk` という再帰メソッドを作成します。このメソッドはノードを返し、それを `ast.body` 配列に格納します。

 関数ウォーク(){
// walk 関数内で、最初に `current` フラグを取得します。
let token = tokens [ 現在の];
}

while ( 現在値< トークン数. 長さ) {
ast.body.push ( walk ( )) ;
}

それでは、`walk` メソッドを実装してみましょう。このメソッドは `tokens` 配列の情報を正しく解析する必要があるため、まずは異なる `type` 値を区別する必要があります。

まず、「値」を操作します。これは親ノードではないため、最も単純な操作です。値は数値(subtract 4 2)または文字列(concat "foo" "bar")のいずれかになることは既に学習しました。あとは、値とその型を一致させるだけです。

 まず、「number」タグがあるかどうかを確認します。
if ( トークン. === '数値' ) {

// 見つかった場合は、`current` を増分します。
現在の++ ;

// 次に、`NumberLiteral` という新しい AST ノードを返して、値を割り当てることができます。
戻る{
タイプ: 'NumberLiteral'
: トークン.値
};
}

// 文字列の場合も、上記の数値と同じ操作が適用されます。新しい `StringLiteral` ノードを追加します。
if ( トークン. type === '文字列' ) {
現在の++ ;

戻る{
タイプ: 'StringLiteral'
: トークン.値
};
}

次に、呼び出される式を見つける必要があります。対応する左括弧ごとに、次の式の名前を取得します。このプロセスは右括弧に遭遇するまで再帰的に繰り返され、木構造が拡張されます。右括弧に遭遇すると再帰は停止し、ループは終了します。これはコードを見ればより直感的に理解できます。

 もし
token.type === ' 括弧' &&
トークン値=== ' ('
){

// AST ツリーではこの括弧は考慮しないため、この括弧をスキップするために `current` を追加します。
token = トークン[ ++ 現在の値];

// 「CallExpression」タイプのベースノードを作成し、現在のタグの値を名前フィールドに設定します。
// 左括弧の後のマーカーがこの関数の名前だからです。
ノード= {
タイプ: 'CallExpression'
名前: トークン.値
パラメータ: [],
};

// この名前タグをスキップするには、`current` の追加を続けます。
token = トークン[ ++ 現在の値];

// ここで、各タグを反復処理し、閉じ括弧に遭遇するまで、`CallExpression` である `params` を見つける必要があります。
// ネストされた `walk` 関数を使用して、ネストされた `CallExpression` を超えて `current` 変数を増分します。
// そこで、タイプが 'paren' で値が閉じ括弧であるトークンに遭遇するまで継続する while ループを作成します。
その間
( トークン. type !== 'paren' ) ||
( トークン. === '括弧' && トークン. !== ')' )
){
// このノードを `node.params` に保存します。
ノード.params.push ( walk ());
token = トークン[ 現在の];
}

// 閉じ括弧をスキップするために、最後にもう一度 `current` 変数を追加します。
現在の++ ;

// ノードを返す
ノードを返します
}

コンパイラの次の段階は変換です。これはASTを取得し、それを変更することです。ASTを同じ言語で操作することも、全く新しい言語に翻訳することもできます。

では、AST をどのように変換するのでしょうか?

AST内の要素が非常に似ていることにお気づきかもしれません。これらの要素はすべて `type` 属性を持ち、ASTノードと呼ばれます。これらのノードには、ASTに関する情報を記述するために使用できるいくつかの属性が含まれています。

 // 「NumberLiteral」ノードを持つことができます:
{
タイプ: 'NumberLiteral'
: '2'
}

// または、「CallExpression」のノードである可能性もあります。
{
タイプ: 'CallExpression'
名前: 「減算」
params : [ ... ネストされたノードはここに配置されます... ],
}

ASTの変換は、属性の追加、削除、置換といったノード操作のみで行えます。また、ノードを追加または削除したり、既存のAST構造を変更せずに既存のASTをベースに全く新しいASTを作成したりすることも可能です。

私たちの目標は新しい言語なので、この特定の言語で動作する完全に新しい AST の作成に重点を置きます。

これらすべてのノードにアクセスするには、深さ優先探索(DFS)アプローチを使用してそれらを走査する必要があります。上記で得られたAST走査プロセスは次のようになります。

別個のASTを作成せずにこのASTを直接操作すると、様々な抽象化が導入される可能性があります。しかし、ツリー内の各ノードにアクセスするだけでも、多くのことが可能になります。

(「訪問」という用語は、オブジェクトの構造内の要素に対して実行される操作を表すパターンであるため使用されます。)

したがって、さまざまなノード タイプを受け入れるメソッドを持つビジター オブジェクトを作成します。

 var 訪問者= {
数値リテラル() {},
呼び出し式() {},
};

AST をトラバースするときに、型に一致するノードに遭遇すると、ビジター内のメソッドを呼び出すことができます。

一般に、これらのメソッドの使いやすさを向上させるために、親ノードもパラメーターとして渡します。

 var 訪問者= {
NumberLiteral ( ノード) {},
CallExpression ( ノード) {},
};

もちろん、深さ優先探索から分かるように、最深のサブノードまで探索する際には「戻る」必要があります。これは現在のノードから「出る」ことを指します。これは、下に行くということはノードに「入る」ということであり、上に行くということはノードから「出る」ということであるとも理解できます。

これをサポートするために、「visitor」の最終的な形式は次のようになります。

 var 訪問者= {
プログラム: {
enter ( ノード) {},
exit ( ノード) {},
},
数値リテラル: {
enter ( ノード) {},
exit ( ノード) {},
},
呼び出し式: {
enter ( ノード) {},
exit ( ノード) {},
},
...
};

トラバーサル

まず、ASTとビジターを受け取るトラバーサー関数を定義しました。各ノードの型に応じて異なるビジターメソッドを呼び出す必要があるため、ルートノードから現在のノードとその親ノードを入力として受け取る`traverseNode`メソッドを定義しました。ルートノードには親ノードがないため、nullを渡すことができます。

 関数トラバーサー( astビジター) {
`traverseNode` 関数は、ノードとその親ノードの 2 つの引数を受け入れます。
// この方法では、両方をビジター メソッドに渡すことができます。
関数traverseNode ( ノード, ) {

まず、`type` を照合してビジターメソッドが存在するかどうかを確認します。ビジターメソッドは (enter と exit) です。
メソッドvisitor [ ノード. type ] とします

// このノード タイプに `enter` メソッドがある場合は、現在のノードとその親ノードを渡してこの関数を呼び出します。
if ( メソッド&& メソッド. enter ) {
メソッド.enter ( ノード);
}

// 次に、子ノード配列の走査と各子ノードの処理を容易にするために、現在のタイプに応じてノードを分割します。
スイッチ( ノードタイプ) {
// まずは最上位の `Program` ノードから始めましょう。これは、`Program` ノードに `body` というプロパティがあり、そこにノードの配列が含まれているためです。
// 下方向にトラバースするには `traverseArray` を呼び出します。
//
// `traverseArray` は `traverseNode` を順番に呼び出すため、ツリーが再帰的に走査されることを覚えておいてください。
ケース'プログラム' :
traverseArray ( ノード. body , ノード);
壊す;

// 次に、`CallExpression` でも同じことを実行し、`params` プロパティを反復処理します。
ケース'CallExpression' :
traverseArray ( ノード. パラメータ, ノード);
壊す;

// `NumberLiteral` と `StringLiteral` の場合、子ノードがないため、単純に break することができます。
ケース'NumberLiteral' :
ケース'StringLiteral' :
壊す;

// 次に、上記のノード タイプに一致するものが見つからない場合は、例外エラーをスローします。
デフォルト
新しいTypeError をスローします( node . type );
}

// このノード タイプに `exit` メソッドがある場合は、現在のノードとその親ノードを渡してそれを呼び出します。
if ( メソッド&& メソッド. exit ) {
メソッド. exit ( node , parent );
}
}

// `traverseNode` を呼び出してトラバーサルを開始し、前の AST ツリーを渡します。AST ツリーは最初から始まっているので…
// ポイントには親ノードがないので、null を渡すだけです。
traverseNode ( astnull );
}

Program型とCallExpression型には子ノードが含まれる可能性があるため、これらの潜在的な子ノードの配列に対してさらに処理が必要です。そこで、反復処理のためにtraverseArrayというメソッドが作成されました。

 `traverseArray` 関数は配列を反復処理し、`traverseNode` 関数を呼び出します。
関数traverseArray ( 配列, ) {
配列.forEach ( => {
traverseNode ( ) ;
});
}

変換

次のステップは、前の AST ツリーを期待する JavaScript AST ツリーにさらに変換することです。

JavaScript の抽象構文ツリー (AST) 解析にあまり詳しくない場合は、オンライン ツールを使用して、どのような AST ツリーになるかを理解し、より複雑なシナリオに適用することができます。

前回と同様の新しい AST ツリーを、プログラム ノードを使用して作成します。

 関数トランスフォーマー( ast ) {

newAst = {
タイプ: 'プログラム'
: []、
};

// ここにハックがあります: このコンテキスト プロパティは、古い AST と新しい AST を比較するためにのみ使用されます。
// 通常はこれよりも優れた抽象化メソッドがありますが、このメソッドは私たちの目標に対しては比較的単純です。
ast . _context = newAst . body ;

// ここで、イテレータ関数を呼び出し、古い AST ツリーとビジター メソッドを渡します。
トラバーサー( ast 、 { ... }};

// コンバーター メソッドの最後に、作成した新しい AST ツリーを返すことができます。
newAst を返します
}

ビジターオブジェクトを改良してみましょう。ノードの種類ごとに、enterメソッドとexitメソッドを定義できます(ノードにアクセスして処理するだけでよいため、exitメソッドは必要ありません)。

 {
// 最初のビジターメソッドはNumberLiteralです
数値リテラル: {
enter ( ノード) {
// `NumberLiteral` という名前の新しいノードを作成し、親ノードのコンテキストに配置します。
親._context.push ( {
タイプ: 'NumberLiteral'
: ノード.値
});
},
},

// 次は `StringLiteral`
文字列リテラル: {
enter ( ノード) {
親._context.push ( {
タイプ: 'StringLiteral'
: ノード.値
});
},
},

呼び出し式: { ... }
}

CallExpression はネストされたコンテンツを含む可能性があるため、少し複雑です。

 呼び出し式: {
enter ( ノード) {

まず、ネストを示す識別子「Identifier」を持つ「CallExpression」というノードを作成します。
let = {
タイプ: 'CallExpression'
呼び出し先: {
タイプ: '識別子'
名前: ノード名
},
引数: [],
};

// ここで、元の `CallExpression` ノードに新しいコンテキストを定義します。
// これは式内の引数配列への参照であり、ここにパラメータを入れることができます。
ノード._context = . 引数;

// 次に、親ノードが `CallExpression` タイプであるかどうかを確認します。
// そうでない場合
if ( . type !== 'CallExpression' ) {

// `CallExpression` ノードを `ExpressionStatement` でラップします。
これは、スタンドアロンの `CallExpressions` を JavaScript の宣言ステートメントとして扱うこともできるためです。
//
たとえば、`var a = foo()` と `foo()` の場合、後者は変数に値を割り当てる式として使用できます。
// 独立したステートメントとしても存在できる
= {
タイプ: 'ExpressionStatement'
:
};
}

// 最後に、`CallExpression` (ラップされている可能性があります) を親ノードのコンテキストに配置します。
._context.push ( );
},
}

3.3 コード生成

コンパイラの最終段階はコード生成です。この段階は変換と重複する場合もありますが、コード生成の主要部分は依然として抽象構文木(AST)に基づくコード出力です。コード生成にはいくつかの異なる方法があり、以前に生成されたトークンを再利用するコンパイラもあれば、線形コード出力のために独立したコード表現を作成するコンパイラもあります。しかし、ここでは以前に生成されたASTの使用に焦点を当てます。

前の手順に基づいて、新しい AST ツリーを取得しました。

次に、コード ジェネレーターが呼び出され、それ自体を再帰的に呼び出してツリーの各ノードを出力し、最後に文字列を出力します。

 関数コードジェネレータノード){

// ノード タイプに応じて分解操作を実行します。
スイッチ( ノードタイプ) {

// `Program` ノードがある場合は、各ノードを `body` にマップします。
// コード ジェネレーターを使用して、改行で接続しながら実行します。
ケース'プログラム' :
ノード. ボディ. マップ( コードジェネレータ) を返す
. join ( '\n' );

// `ExpressionStatement` の場合、ネストされた式に対してコード ジェネレーターを呼び出し、セミコロンを追加します。
case '式ステートメント' :
戻る
codeGenerator ( ノード. ) +
';' // << これはコードの一貫性を維持する(コードを正しく記述する)ためです。
);

// `CallExpression` の場合、`callee` を出力し、左括弧を追加します。
// 次に、各ノードを `arguments` 配列にマップし、コード ジェネレーターを使用して実行し、各ノードの実行が完了した後にコンマを追加します。
// 最後に右括弧を追加します
ケース'CallExpression' :
戻る
codeGenerator ( ノード. 呼び出し先) +
'(' +
ノード. 引数. マップ( コードジェネレータ)
. join ( ', ' ) +
')'
);

`Identifier` の場合、単に `node` の名前を返します。
ケース'識別子' :
node.name を返します

`NumberLiteral` の場合は、単に `node` の値を返します。
ケース'NumberLiteral' :
node.value を返します

// `StringLiteral` の場合、`node` の値を二重引用符で囲みます。
ケース'StringLiteral' :
'"' + ノード. + '"' を返します

// 一致するノード タイプが見つからない場合は例外をスローします。
デフォルト
新しいTypeError をスローします( node . type );
}
}

コード生成後、次の JS 文字列が生成されます: add(2, subtract(4, 2)); これは、コンパイル プロセスが成功したことを意味します。

IV. 結論

上記は、LISPからJavaScriptへのコンパイラを作成するための完全なプロセスです。行ごとの中国語コメント付きの完全なコードは、こちらでご覧いただけます。

https://github.com/521xueweihan/OneFile/blob/main/src/javascript/the-super-tiny-compiler.js

では、今日学んだことをどこで応用するのでしょうか?VueとReactは構文こそ異なりますが、究極的には抽象構文(AST)を活用するフロントエンドフレームワークとして共通しています。最も重要なのはAST変換です。AST変換は非常に強力で広く利用されており、一般的なユースケースには以下のようなものがあります。

  • IDE のエラー メッセージやコードの強調表示は、コードの自動補完などの機能を実装するのにも役立ちます。
  • 一般的なWebpackとRollupのバンドル(圧縮)方法

抽象構文木(AST)は、コンパイラ、IDE、コードの最適化と縮小、そしてVueやReactといったフロントエンドフレームワークで広く利用されています。ASTを直接操作することはあまりありませんが、常に私たちの身近に存在しています。

もちろんです!記事を読んだからといって、必ずしも本当に理解できるとは限りません。学習には実践的な練習が不可欠であり、その過程で様々な洞察が得られるかもしれません。方法はとても簡単です。ブラウザの「開発者モード」を開き、コンソールに入り、コードをコピー&ペーストするだけで、すぐに実行して結果を確認できます。

この記事はこれで終わりです。この記事ではコンパイラの概要を簡単に紹介しただけです。ご意見が異なる場合は、コメント欄にお気軽にコメントを残していただき、交流しながら一緒に学びましょう!