この記事を読むと、次の内容が得られます。
- コンパイラの基本的なワークフローと原理をより体系的に理解する
- デザインパターン - 訪問者のパターンを理解する
序文
日常のフロントエンド開発では、ES6+ 構文がよく使用されますが、ユーザーが使用するブラウザーが異なるため、新しい構文は古いブラウザーではサポートされません。現時点では、通常、babel を使用して、より広くサポートされている構文に変換します。 ES5 の構文では、この「認識できない言語を認識可能な言語に変換する」プロセスを「コンパイル」と呼び、使用されるツールがコンパイラーです。一般的なコンパイラには、babel の他に gcc などがあります。
コンパイラがどのように機能するかを理解するために babel のソース コードを直接見ると、多くの人が落胆することになると思いますが、幸いなことに、babel のメンテナの 1 人である James Kyle が、最小のコンパイラである-super-tiny-compiler. これまでのところ、 21.5,000 個以上のスターが付いています。プロジェクトから削除されたコードは約 200 行で、小さいコードですが、コンパイラの重要なポイントが数多く示されており、このプロジェクトを学習することで、コンパイルの原理をより体系的に理解することができます。
このコンパイラの機能は、Lisp 言語スタイルの関数呼び出しをC 言語スタイル (すべての文法は含まない) に変換することです。たとえば、add
とsubtract
2 つの関数があるとします。 2 つの言語のスタイルは次のとおりです:
Lisp スタイル | Cスタイル | |
---|---|---|
2+2 | (2 2 を追加) | 追加(2, 2) |
4 - 2 | (4 2 を引く) | 減算(4, 2) |
2 + (4 - 2) | (2 を加算 (4 2 を引く)) | 加算(2, 減算(4, 2)) |
作業過程
ほとんどのコンパイラのプロセスは、解析、変換、コード生成の 3 つのフェーズに分類できます。
- 解析: 元のコードを高度に抽象的な表現 (通常は抽象構文ツリー (AST)) に変換します。
- 変換: 高度に抽象的な表現を処理し、コンパイラーが最終的に提示したい表現に変換します。
- コード生成: 処理された高度に抽象的な表現を新しいコードに変換します。
分析する
通常、解析には字句解析と構文解析という 2 つの手順が必要です。
- 字句解析: 元のコードをトークン (トークン) に分割します。トークンは通常、数値、ラベル、句読点、演算子などの表現を含むコード言語で構成されます。セグメンテーションツールは一般に字句解析器(Tokenizer)と呼ばれます。
- 構文分析: 字句分析のトークンを高度に抽象的な表現 (抽象構文ツリー、AST など) に変換します。これにより、コード ステートメント内の各フラグメントとそれらの間の関係が記述されます。変換ツールは一般にパーサー(Parser)と呼ばれます。
(add 2 (subtract 4 2))
次に例として挙げます。
字句解析器
字句アナライザーの出力はおおよそ次のとおりです。
[
{
type: 'paren', value: '(' },
{
type: 'name', value: 'add' },
{
type: 'number', value: '2' },
{
type: 'paren', value: '(' },
{
type: 'name', value: 'subtract' },
{
type: 'number', value: '4' },
{
type: 'number', value: '2' },
{
type: 'paren', value: ')' },
{
type: 'paren', value: ')' },
]
このような結果を得るには、入力を分割して一致させる必要があります。
/**
* 词法分析器
* @param input 代码字符串
* @returns token列表
*/
function tokenizer(input) {
// 输入字符串处理的索引
let current = 0;
// token列表
let tokens = [];
// 遍历字符串,解析token
while (current < input.length) {
let char = input[current];
// 匹配左括号
if (char === '(') {
// type 为 'paren',value 为左圆括号的对象
tokens.push({
type: 'paren',
value: '(',
});
// current 自增
current++;
// 结束本次循环,进入下一次循环
continue;
}
// 匹配右括号
if (char === ')') {
// type 为 'paren',value 为右圆括号的对象
tokens.push({
type: 'paren',
value: ')',
});
current++;
continue;
}
let WHITESPACE = /\s/;
// 正则匹配空白字符,跳过空白字符
if (WHITESPACE.test(char)) {
current++;
continue;
}
// 匹配如下数字
// (add 123 456)
// ^^^ ^^^
let NUMBERS = /[0-9]/;
// 正则匹配数字
if (NUMBERS.test(char)) {
let value = '';
// 匹配连续数字,作为value
while (NUMBERS.test(char)) {
value += char;
char = input[++current];
}
// type 为 'number',value 为数字字符串
tokens.push({
type: 'number',
value,
});
continue;
}
// 匹配如下字符串,以""包裹
// (concat "foo" "bar")
// ^^^ ^^^
if (char === '"') {
let value = '';
// 跳过左双引号
char = input[++current];
// 获取双引号之间的所有字符串
while (char !== '"') {
value += char;
char = input[++current];
}
// 跳过右双引号
char = input[++current];
// type 为 'string',value 为字符串参数
tokens.push({
type: 'string',
value,
});
continue;
}
// 匹配函数名
// (add 2 4)
// ^^^
let LETTERS = /[a-z]/i;
// 只包含小写字母
if (LETTERS.test(char)) {
let value = '';
// 获取连续字符
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
// type 为 'name',value 为函数名
tokens.push({
type: 'name',
value,
});
continue;
}
// 无法识别的字符,抛出错误提示
throw new TypeError(`I dont know what this character is: ${
char}`);
}
// 返回词法分析器token列表
return tokens;
}
パーサー
字句解析が完了したら、トークン リストを次の抽象構文ツリー (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',
},
],
},
],
},
],
}
構文アナライザーの実装ロジックは次のとおりです。
/**
* 语法分析器
* @param {*} tokens token列表
* @returns 抽象语法树 AST
*/
function parser(tokens) {
// token列表索引
let current = 0;
// 采用递归的方式遍历token列表
function walk() {
// 获取当前 token
let token = tokens[current];
// 数字类token
if (token.type === 'number') {
current++;
// 生成 NumberLiteral 节点
return {
type: 'NumberLiteral',
value: token.value,
};
}
// 字符串类token
if (token.type === 'string') {
current++;
// 生成 StringLiteral 节点
return {
type: 'StringLiteral',
value: token.value,
};
}
// 函数名
if (token.type === 'paren' && token.value === '(') {
// 跳过左括号,获取下一个 token 作为函数名
token = tokens[++current];
let node = {
type: 'CallExpression',
name: token.value,
params: [],
};
token = tokens[++current];
// 以前面的词法分析结果为例,有两个右圆括号,表示有嵌套的函数
//
// [
// { type: 'paren', value: '(' },
// { type: 'name', value: 'add' },
// { type: 'number', value: '2' },
// { type: 'paren', value: '(' },
// { type: 'name', value: 'subtract' },
// { type: 'number', value: '4' },
// { type: 'number', value: '2' },
// { type: 'paren', value: ')' }, <<< 右圆括号
// { type: 'paren', value: ')' } <<< 右圆括号
// ]
//
// 遇到嵌套的 `CallExpressions` 时,我们使用 `walk` 函数来增加 `current` 变量
//
// 即右圆括号前的内容就是参数
while (token.type !== 'paren' || (token.type === 'paren' && token.value !== ')')) {
// 递归遍历参数
node.params.push(walk());
token = tokens[current];
}
// 跳过右括号
current++;
return node;
}
// 无法识别的字符,抛出错误提示
throw new TypeError(token.type);
}
// AST的根节点
let ast = {
type: 'Program',
body: [],
};
// 填充ast.body
while (current < tokens.length) {
ast.body.push(walk());
}
// 返回AST
return ast;
}
変換
上記の例からわかるように、AST には同様のタイプのノードが多数あり、これらのノードには、AST の他の情報を記述するための他の属性がいくつか含まれています。AST を変換する場合、元の AST 上でこれらのノードを直接追加、移動、置換することも (同じ言語での操作)、または元の AST に基づいて新しい AST (別の言語) を生成することもできます。
このコンパイラの目的は 2 つの言語スタイル間で変換することであるため、新しい AST を生成する必要があります。
トラバーサー
AST などの「ツリー状」構造の場合、深さ優先の方法でトラバースできます。上記の AST を例にとると、トラバーサル プロセスは次のようになります。
- プログラム タイプ - AST のルート ノードから開始
- CallExpression (add) - Program ノードの body 属性の最初の子要素を入力します。
- NumberLiteral (2) - CallExpression (追加) ノードの params 属性の最初の子要素に入ります。
- CallExpression (subtract) - CallExpression (add) ノードの params 属性の 2 番目の子要素に入ります。
- NumberLiteral (4) - CallExpression (減算) ノードの params 属性の最初の子要素に入ります。
- NumberLiteral (2) - CallExpression (減算) ノードの params 属性の 2 番目の子要素を入力します。
このコンパイラの場合、上記のノード タイプで十分です。つまり、「ビジター」が必要とする機能は十分です。
訪問者オブジェクト
ビジター モードを使用すると、動作とデータを適切に分離して分離を実現できます。このコンパイラでは、次のような「ビジター」オブジェクトを作成できます。これにより、さまざまなデータ型にアクセスするためのメソッドが提供されます。
var visitor = {
NumberLiteral() {
},
CallExpression() {
},
}
AST をトラバースするときに、一致が特定のタイプのノードに「入る」と、訪問者によって提供されたメソッドが呼び出されます。同時に、訪問者が現在のノード情報を確実に取得できるようにするために、現在のノードと親ノードを渡す必要があります。
var visitor = {
NumberLiteral(node, parent) {
},
CallExpression(node, parent) {
},
}
ただし、終了する必要がある状況や、上記の AST を例として挙げる必要がある状況もあります。
- Program
- CallExpression
- NumberLiteral
- CallExpression
- NumberLiteral
- NumberLiteral
深くトラバースする場合、リーフ ノードに入る可能性がありますが、この時点では、このブランチを「終了」(終了)する必要があります。ツリーの深さをたどるとき、各ノードには 2 つの操作があり、1 つは「入る」、もう 1 つは「出る」です。
-> Program (enter)
-> CallExpression (enter)
-> Number Literal (enter)
<- Number Literal (exit)
-> Call Expression (enter)
-> Number Literal (enter)
<- Number Literal (exit)
-> Number Literal (enter)
<- Number Literal (exit)
<- CallExpression (exit)
<- CallExpression (exit)
<- Program (exit)
このような操作に対応するには、訪問者オブジェクトを変更し続ける必要があり、最終的にはおおよそ次のようになります。
const visitor = {
NumberLiteral: {
enter(node, parent) {
},
exit(node, parent) {
},
},
CallExpression: {
enter(node, parent) {
},
exit(node, parent) {
},
},
};
コンバータ
トラバーサー オブジェクトと訪問者オブジェクトに関する上記の説明と組み合わせると、変換関数はおおよそ次のようになります。
/**
* 遍历器
* @param {*} ast 语法抽象树
* @param {*} visitor 访问者对象
*/
function traverser(ast, visitor) {
// 遍历数组中的节点
function traverseArray(array, parent) {
array.forEach(child => {
traverseNode(child, parent);
});
}
// 遍历节点,参数为当前节点及其父节点
function traverseNode(node, parent) {
// 获取访问者对象上对应的方法
let methods = visitor[node.type];
// 执行访问者的 enter 方法
if (methods && methods.enter) {
methods.enter(node, parent);
}
switch (node.type) {
// 根节点
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);
}
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
// 开始遍历
traverseNode(ast, null);
}
/**
* 转换器
* @param {*} ast 抽象语法树
* @returns 新AST
*/
function transformer(ast) {
// 创建一个新 AST
let newAst = {
type: 'Program',
body: [],
};
// 通过 _context 引用,更新新旧节点
ast._context = newAst.body;
// 使用遍历器遍历原始 AST
traverser(ast, {
// 数字节点,直接原样插入新AST
NumberLiteral: {
enter(node, parent) {
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
// 字符串节点,直接原样插入新AST
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
// 函数调用
CallExpression: {
enter(node, parent) {
// 创建不同的AST节点
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
// 同样通过 _context 引用参数,供子节点使用
node._context = expression.arguments;
// 顶层函数调用本质上是一个语句,写成特殊节点 `ExpressionStatement`
if (parent.type !== 'CallExpression') {
expression = {
type: 'ExpressionStatement',
expression,
};
}
parent._context.push(expression);
},
},
});
return newAst;
}
(add 2 (subtract 4 2))
AST がコンバーターを通過すると、次の新しい AST に変換されます。
{
type: 'Program',
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'add',
},
arguments: [
{
type: 'NumberLiteral',
value: '2',
},
{
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'subtract',
},
arguments: [
{
type: 'NumberLiteral',
value: '4',
},
{
type: 'NumberLiteral',
value: '2',
},
],
},
],
},
},
],
};
コード生成
この段階の作業は変換段階と重複する場合もありますが、一般的には、対応するコードは主に AST に従って出力されます。
コード生成にはいくつかの異なる方法があり、以前のトークンを再利用するコンパイラもあれば、線形出力コードを容易にするために反対のコード表現を作成するコンパイラもあります。
コード ジェネレーターは、AST 内のすべてのタイプのノードを「出力」する方法を知っている必要があり、その後、AST が走査されてすべてのコードが文字列に変換されるまで、コード ジェネレーター自体を再帰的に呼び出します。
/**
* 代码生成器
* @param {*} node AST 中的 body 节点
* @returns 代码字符串
*/
function codeGenerator(node) {
// 判断节点类型
switch (node.type) {
// 根节点,递归 body 节点列表
case 'Program':
return node.body.map(codeGenerator).join('\n');
// 表达式,处理表达式内容,以分好结尾
case 'ExpressionStatement':
return `${
codeGenerator(node.expression)};`;
// 函数调用,添加左右括号,参数用逗号隔开
case 'CallExpression':
return `${
codeGenerator(node.callee)}(${
node.arguments.map(codeGenerator).join(', ')})`;
// 标识符,数值,直接输出
case 'Identifier':
return node.name;
case 'NumberLiteral':
return node.value;
// 字符串,用双引号包起来
case 'StringLiteral':
return `"${
node.value}"`;
// 无法识别的字符,抛出错误提示
default:
throw new TypeError(node.type);
}
}
翻訳者
上記のプロセスは、次のようなコンパイラ動作の 3 つの基本ステップです。
- 入力文字→字句解析→トークン→構文解析→抽象構文木(AST)
- 抽象構文ツリー (AST) -> トランスフォーマー -> 新しい AST
- 新しいAST -> コードジェネレータ -> 出力文字
/**
* 编译器
* @param {*} input 代码字符串
* @returns 代码字符串
*/
function compiler(input) {
let tokens = tokenizer(input);
let ast = parser(tokens);
let newAst = transformer(ast);
let output = codeGenerator(newAst);
return output;
}
コンパイラによって目的が異なり、手順が若干異なりますが、すべて同じです。基本的に、上記の内容により、読者はコンパイラをより体系的に理解することができます。
拡大
ポリフィル
Babel はコンパイラです。デフォルトでは、JS 構文の変換にのみ使用され、Promise、Generator などの新しい構文によって提供される API は変換されません。現時点では、これらの新しい構文と互換性を持たせるために、polyfill を使用する必要があります。その動作原理はおおよそ次のとおりです。
(function (window) {
if (window.incompatibleFeature) {
return window.incompatibleFeature;
} else {
window.incompatibleFeature = function () {
// 兼容代码
};
}
})(window);
訪問者のパターン
意味
オブジェクト構造の要素に対して実行される操作を表します。これにより、クラスを変更せずに要素に対する新しい操作を定義できます。本質は行動をデータから切り離すことであり、訪問者によって、表示される行動も異なります。
- Visitor:各要素を訪問する動作を定義するインターフェイスまたは抽象クラス。そのパラメータは訪問する要素です。そのメソッドの数は理論的には要素の数と同じです。したがって、訪問者パターンには要素のタイプが必要です要素クラスの追加や削除が頻繁に行われると、必然的に Visitor インターフェースも頻繁に変更されることになり、これが発生する場合は、Visitor モードが適切ではないことを意味します。
- ConcreteVisitor:特定の訪問者。各要素クラスにアクセスするときに特定の動作を行う必要があります。
- 要素:要素インターフェイスまたは抽象クラス。訪問者を受け入れる (accept) メソッドを定義します。これは、訪問者が各要素にアクセスできる必要があることを意味します。
- ElementA、ElementB:アクセスを受信するための特定の実装を提供する特定の要素クラス。この特定の実装は通常、要素クラスにアクセスするために訪問者によって提供されたメソッドを使用します。
- ObjectStructure:定義で言及されているオブジェクト構造。オブジェクト構造は抽象式であり、要素のコレクションを内部で管理し、これらの要素を反復して訪問者アクセスを提供できます。
例
「ビジター オブジェクト」はコンパイラで使用されます。以下では例として「ビジター クラス」を使用します。
- デバイスのセットを定義する
class Keyboard {
accept(computerPartVisitor) {
computerPartVisitor.visit(this);
}
}
class Monitor {
accept(computerPartVisitor) {
computerPartVisitor.visit(this);
}
}
class Mouse {
accept(computerPartVisitor) {
computerPartVisitor.visit(this);
}
}
- コンピューターを他のデバイスと統合するデバイスとして定義する
class Computer {
constructor(){
this.parts = [new Mouse(), new Keyboard(), new Monitor()];
}
accept(computerPartVisitor) {
for (let i = 0; i < this.parts.length; i++) {
this.parts[i].accept(computerPartVisitor);
}
computerPartVisitor.visit(this);
}
}
- 訪問者インターフェースを定義する
class ComputerPartDisplayVisitor{
visit(device) {
console.log(`Displaying ${
device.constructor.name}.`);
}
}
- 使用中は、デバイスを使用して新しい訪問者を受け入れるだけで、対応する訪問者の機能を実現できます。
const computer = new Computer();
computer.accept(new ComputerPartDisplayVisitor());
/**
* output:
* Displaying Mouse.
* Displaying Keyboard.
* Displaying Monitor.
* Displaying Computer.
*/
アドバンテージ
- 単一責任の原則に従う: ビジター パターンが適用されるどのシナリオでも、ビジターにカプセル化する必要がある要素クラスの操作は、要素クラス自体とは無関係であり、揮発性の操作である必要があります。一方では単一責任原則に準拠しますが、他方では、カプセル化された操作は通常変更可能であるため、変更が発生した場合、要素クラス自体を変更することなく変更部分の拡張を実現できます。
- 優れたスケーラビリティ: さまざまな訪問者を受け入れることで、要素クラスをさまざまな操作に拡張できます。
- データ構造は構造上の操作から切り離されているため、一連の操作を独立して変更できます。
該当する状況
- オブジェクト構造は比較的安定していますが、多くの場合、このオブジェクト構造に対して新しい操作を定義する必要があります。
- オブジェクト構造内のオブジェクトに対して無関係なさまざまな操作を実行する必要があり、これらの操作によってオブジェクトのクラスが「汚染」されることを避ける必要があり、新しい操作を追加するときにこれらのクラスを変更したくない場合があります。
参照文書
https://github.com/jamiebuilds/the-super-tiny-compiler
史上最小のコンパイラソースコード解析
https://developer.51cto.com/art/202106/668215.htm
ビジターモード 1記事で十分