|
皆さんも私と同じように感じているのではないでしょうか。「コンパイラ」という言葉を見ると、とても高度なもののように思えますが、同時に、少しばかりの「恐怖」が心の中に湧き上がってきます。 コンパイラは非常に複雑で、私のような初心者には理解できないものだと常々思ってきました。そして、コンパイラについて学ぶというと、すぐに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を引く )) これをトークン配列に分割すると次のようになります。 [ コードロジック: 文字列を解析する必要があるため、現在解析している場所を識別するのに役立つポインタ/カーソルのようなものが必要です。そのため、0 から始まる `current` 変数が必要になります。最終的な目標はトークン配列を取得することなので、最初に空の配列 `tokens` を初期化します。 `current` 変数はカーソルのように機能し、コード内の位置を追跡できるようにします。 文字列を解析するので、当然ながら反復処理が必要になります。ここでは、while ループを使用して現在の文字列を解析します。 // ループ内で、`current` 変数を必要な値まで増分できます。 文字列から1文字を取得するにはどうすればよいでしょうか。答えは、配列で使用されるものと同様の角括弧を使用することです。 var char = str [ 0 ] ここで理解すべき新しいポイントがあります。JavaScript では、String クラスのインスタンスは配列のようなものです。次の例をご覧ください。 これまでは charAt を使用して文字列から 1 文字を取得していたかもしれません。これは String 型のメソッドだからです。 どちらの方法も目的の効果が得られますが、違いがあります。インデックスが存在しない場合、`str[index]` は `undefined` を返しますが、`str.charAt(index)` は `""`(空文字列)を返します。 カーソルを移動して文字列から文字を取得すると、現在の文字列を段階的に解析できます。 構文解析はこれらの側面からも考察できます。`(add 2 (subtract 4 2))` を例に挙げると、(左括弧、文字列、スペース、数値、) 右括弧が出現します。異なる型を個別に処理するには、異なる if 条件を使用する必要があります。
// 左括弧があるかどうかを確認します: 文字列と数値は意味が異なるため、処理は比較的複雑です。まずは数値から見ていきましょう。数値の長さは一定ではないため、すべての数値文字列を取得するには、最初に出現した数値から始めて、最初に出現した非数値文字まで走査し、その数値を格納する必要があります。 // (123 456 を追加) 実際のシナリオにより適した文字列操作(例えば、(concat "foo" "bar") など)がサポートされています。この場合、括弧内の文字列は数値の場合と同様に再度解析する必要があり、文字列全体を取得してから保存するための反復処理が必要になります。 // 開き二重引用符をチェックすることから始めます: 最後のタグの種類は名前です。これは数字ではなく文字の並びで、Lisp構文における関数名に相当します。 // (2 4 を加算) これでトークンの配列ができました。次のステップは、抽象構文木(AST)を構築することです。ASTは次のようになります。 {コードロジック: 同様に、現在操作しているオブジェクトを識別するためにカーソル/ポインターが必要です。次に、Program というルート ノードを持つ AST ツリーを事前に作成します。 let current = 0; let ast = { type: 'Program', body: [],}; 先ほど取得したトークン配列をもう一度見てみましょう。 [ `(add 2 (subtract 4 2))` のようなネストされた文字列の場合、この配列は非常に「フラット」で、ネスト関係を明確に表現できません。しかし、この AST 構造ではネスト関係を明確に表現できます。上記の配列の場合、各タグを反復処理し、`CallExpression` であるパラメータを見つけて、閉じ括弧に遭遇するまで処理する必要があります。したがって、再帰が最適なアプローチです。そこで、`walk` という再帰メソッドを作成します。このメソッドはノードを返し、それを `ast.body` 配列に格納します。 関数ウォーク(){ それでは、`walk` メソッドを実装してみましょう。このメソッドは `tokens` 配列の情報を正しく解析する必要があるため、まずは異なる `type` 値を区別する必要があります。 まず、「値」を操作します。これは親ノードではないため、最も単純な操作です。値は数値(subtract 4 2)または文字列(concat "foo" "bar")のいずれかになることは既に学習しました。あとは、値とその型を一致させるだけです。 まず、「number」タグがあるかどうかを確認します。 次に、呼び出される式を見つける必要があります。対応する左括弧ごとに、次の式の名前を取得します。このプロセスは右括弧に遭遇するまで再帰的に繰り返され、木構造が拡張されます。右括弧に遭遇すると再帰は停止し、ループは終了します。これはコードを見ればより直感的に理解できます。 もし( コンパイラの次の段階は変換です。これはASTを取得し、それを変更することです。ASTを同じ言語で操作することも、全く新しい言語に翻訳することもできます。 では、AST をどのように変換するのでしょうか? AST内の要素が非常に似ていることにお気づきかもしれません。これらの要素はすべて `type` 属性を持ち、ASTノードと呼ばれます。これらのノードには、ASTに関する情報を記述するために使用できるいくつかの属性が含まれています。 // 「NumberLiteral」ノードを持つことができます: ASTの変換は、属性の追加、削除、置換といったノード操作のみで行えます。また、ノードを追加または削除したり、既存のAST構造を変更せずに既存のASTをベースに全く新しいASTを作成したりすることも可能です。 私たちの目標は新しい言語なので、この特定の言語で動作する完全に新しい AST の作成に重点を置きます。 これらすべてのノードにアクセスするには、深さ優先探索(DFS)アプローチを使用してそれらを走査する必要があります。上記で得られたAST走査プロセスは次のようになります。 別個のASTを作成せずにこのASTを直接操作すると、様々な抽象化が導入される可能性があります。しかし、ツリー内の各ノードにアクセスするだけでも、多くのことが可能になります。 (「訪問」という用語は、オブジェクトの構造内の要素に対して実行される操作を表すパターンであるため使用されます。) したがって、さまざまなノード タイプを受け入れるメソッドを持つビジター オブジェクトを作成します。 var 訪問者= { AST をトラバースするときに、型に一致するノードに遭遇すると、ビジター内のメソッドを呼び出すことができます。 一般に、これらのメソッドの使いやすさを向上させるために、親ノードもパラメーターとして渡します。 var 訪問者= { もちろん、深さ優先探索から分かるように、最深のサブノードまで探索する際には「戻る」必要があります。これは現在のノードから「出る」ことを指します。これは、下に行くということはノードに「入る」ということであり、上に行くということはノードから「出る」ということであるとも理解できます。 これをサポートするために、「visitor」の最終的な形式は次のようになります。 var 訪問者= { トラバーサル まず、ASTとビジターを受け取るトラバーサー関数を定義しました。各ノードの型に応じて異なるビジターメソッドを呼び出す必要があるため、ルートノードから現在のノードとその親ノードを入力として受け取る`traverseNode`メソッドを定義しました。ルートノードには親ノードがないため、nullを渡すことができます。 関数トラバーサー( ast 、 ビジター) { Program型とCallExpression型には子ノードが含まれる可能性があるため、これらの潜在的な子ノードの配列に対してさらに処理が必要です。そこで、反復処理のためにtraverseArrayというメソッドが作成されました。 `traverseArray` 関数は配列を反復処理し、`traverseNode` 関数を呼び出します。 変換 次のステップは、前の AST ツリーを期待する JavaScript AST ツリーにさらに変換することです。 JavaScript の抽象構文ツリー (AST) 解析にあまり詳しくない場合は、オンライン ツールを使用して、どのような AST ツリーになるかを理解し、より複雑なシナリオに適用することができます。 前回と同様の新しい AST ツリーを、プログラム ノードを使用して作成します。 関数トランスフォーマー( ast ) { ビジターオブジェクトを改良してみましょう。ノードの種類ごとに、enterメソッドとexitメソッドを定義できます(ノードにアクセスして処理するだけでよいため、exitメソッドは必要ありません)。 {CallExpression はネストされたコンテンツを含む可能性があるため、少し複雑です。 呼び出し式: { 3.3 コード生成コンパイラの最終段階はコード生成です。この段階は変換と重複する場合もありますが、コード生成の主要部分は依然として抽象構文木(AST)に基づくコード出力です。コード生成にはいくつかの異なる方法があり、以前に生成されたトークンを再利用するコンパイラもあれば、線形コード出力のために独立したコード表現を作成するコンパイラもあります。しかし、ここでは以前に生成されたASTの使用に焦点を当てます。 前の手順に基づいて、新しい AST ツリーを取得しました。 次に、コード ジェネレーターが呼び出され、それ自体を再帰的に呼び出してツリーの各ノードを出力し、最後に文字列を出力します。 関数コードジェネレータ( ノード){ コード生成後、次の 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変換は非常に強力で広く利用されており、一般的なユースケースには以下のようなものがあります。
抽象構文木(AST)は、コンパイラ、IDE、コードの最適化と縮小、そしてVueやReactといったフロントエンドフレームワークで広く利用されています。ASTを直接操作することはあまりありませんが、常に私たちの身近に存在しています。 もちろんです!記事を読んだからといって、必ずしも本当に理解できるとは限りません。学習には実践的な練習が不可欠であり、その過程で様々な洞察が得られるかもしれません。方法はとても簡単です。ブラウザの「開発者モード」を開き、コンソールに入り、コードをコピー&ペーストするだけで、すぐに実行して結果を確認できます。 この記事はこれで終わりです。この記事ではコンパイラの概要を簡単に紹介しただけです。ご意見が異なる場合は、コメント欄にお気軽にコメントを残していただき、交流しながら一緒に学びましょう! |