Tiến Hóa Compiler Mini: Hướng Dẫn Mở Rộng Tính Năng Ngữ Pháp Mới
Bạn đã bao giờ muốn thêm cú pháp tùy chỉnh vào trình biên dịch nhưng không biết bắt đầu từ đâu? Bài viết này sẽ trình bày cách mở rộng trình biên dịch mini để hỗ trợ các tính năng cú pháp mới thông qua ba bước đơn giản, không cần kiến thức sâu về nguyên lý biên dịch. Sau khi đọc xong bài viết này, bạn sẽ có thể: hiểu luồng làm việc cốt lõi của trình biên dịch, nắm vững cách triển khai cú pháp mới, và học cách kiểm tra các chức năng mở rộng.
Nguyên Lý Hoạt Động Của Trình Biên Dịch
Trước khi mở rộng cú pháp mới, chúng ta cần hiểu kiến trúc cốt lõi của trình biên dịch mini. Trình biên dịch này tuân theo luồng ba giai đoạn kinh điển: Phân Tích (Parsing) → Biến Đổi (Transformation) → Sinh Mã (Code Generation), toàn bộ quy trình được thực hiện thông qua bốn hàm cốt lõi sau:
- Bộ Tách Từ (Tokenizer): Chuyển đổi chuỗi mã nguồn thành mảng các từ (Tokens), ví dụ chuyển đổi
(add 2 3)thành chuỗi các từ chứa các loạiparen,name,number. - Bộ Phân Tích (Parser): Chuyển đổi mảng từ thành cây cú pháp trừu tượng (AST), ví dụ chuyển đổi các từ trên thành cấu trúc cây chứa nút
CallExpression. - Bộ Biến Đổi (Transformer): Thực hiện biến đổi AST, có thể sửa đổi các nút hiện có hoặc thêm nút mới, chẳng hạn chuyển đổi AST theo phong cách LISP sang phong cách C.
- Bộ Sinh Mã (Code Generator): Duyệt qua AST cuối cùng một cách đệ quy và tạo ra chuỗi mã đích.
Bước Một: Mở Rộng Bộ Tách Từ Để Hỗ Trợ Loại Từ Mới
Giả sử chúng ta muốn thêm hỗ trợ cho cú pháp khai báo biến (var x 10), trước hết cần nhận biết từ khóa var mới trong bộ tách từ. Mở the-super-tiny-compiler.js, tìm khối mã xử lý loại name (khoảng dòng 514):
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = '';
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'name', value });
continue;
}
Sửa đổi thành:
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = '';
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
// Thêm kiểm tra từ khóa
if (value === 'var') {
tokens.push({ type: 'keyword', value });
} else {
tokens.push({ type: 'name', value });
}
continue;
}
Khi đó, khi bộ tách từ gặp var, nó sẽ tạo ra token { type: 'keyword', value: 'var' } thay vì token name thông thường.
Bước Hai: Chỉnh Sửa Bộ Phân Tích Để Tạo Nút AST Mới
Tiếp theo, cần xử lý token keyword mới trong bộ phân tích để tạo nút AST tương ứng. Trong hàm parser của the-super-tiny-compiler.js (khoảng dòng 555), tìm khối mã xử lý CallExpression, thêm điều kiện kiểm tra cho từ khóa var:
if (
token.type === 'paren' &&
token.value === '('
) {
token = tokens[++current];
// Kiểm tra có phải là khai báo var không
if (token.type === 'keyword' && token.value === 'var') {
// Tạo nút VariableDeclaration
let node = {
type: 'VariableDeclaration',
identifier: null,
value: null
};
// Phân tích tên biến
token = tokens[++current];
node.identifier = {
type: 'Identifier',
name: token.value
};
current++;
// Phân tích biểu thức gán (đệ quy gọi walk để xử lý bất kỳ biểu thức nào)
node.value = walk();
// Tiêu thụ closing paren
current++;
return node;
} else {
// Logic xử lý CallExpression hiện có
let node = {
type: 'CallExpression',
name: token.value,
params: [],
};
// ... Bỏ qua mã tiếp theo ...
}
}
Lúc này, việc phân tích (var x 10) sẽ tạo ra nút AST sau:
{
type: 'VariableDeclaration',
identifier: { type: 'Identifier', name: 'x' },
value: { type: 'NumberLiteral', value: '10' }
}
Bước Ba: Cập Nhật Bộ Biến Đổi Và Bộ Sinh Mã
Cuối cùng, cần đảm bảo bộ biến đổi có thể xử lý nút mới một cách chính xác và để bộ sinh mã tạo ra mã JavaScript tương ứng. Đầu tiên, thêm xử lý cho VariableDeclaration trong bộ biến đổi:
// Thêm trong visitor của hàm transformer
VariableDeclaration: {
enter(node, parent) {
// Tạo nút ExpressionStatement tương ứng
let declaration = {
type: 'VariableDeclaration',
identifier: {
type: 'Identifier',
name: node.identifier.name
},
init: traverseNode(node.value, visitor)
};
parent._context.push(declaration);
}
}
Sau đó, thêm logic sinh mã trong bộ sinh mã:
function codeGenerator(node) {
switch (node.type) {
// ... Xử lý nút hiện có ...
case 'VariableDeclaration':
return `var ${node.identifier.name} = ${codeGenerator(node.init)};`;
// ...
}
}
Bây giờ trình biên dịch có thể chuyển đổi (var x (add 5 5)) thành var x = add(5, 5);.
Kiểm Tra Chức Năng Mở Rộng
Sửa đổi test.js để thêm trường hợp kiểm tra mới, xác minh mở rộng có hiệu quả:
const varInput = '(var x (add 5 5))';
const varOutput = 'var x = add(5, 5);';
assert.deepStrictEqual(compiler(varInput), varOutput, 'Should support variable declaration');
Chạy lệnh kiểm tra để xác minh tất cả chức năng hoạt động bình thường:
node test.js
Nếu thấy thông báo All Passed!, nghĩa là việc mở rộng cú pháp thành công.
Các Vấn Đề Thường Gặp Và Giải Pháp
- Xung đột cú pháp: Từ khóa mới có thể xung đột với
namehiện có, có thể giải quyết bằng cách tăng mức ưu tiên kiểm tra từ khóa. - Thiết kế cấu trúc AST: Đối với cú pháp phức tạp, nên tham khảo quy chuẩn ESTree để thiết kế cấu trúc nút.
- Bao phủ kiểm tra: Phải thêm kiểm tra end-to-end cho cú pháp mới, có thể tham khảo mẫu kiểm tra trong test.js.
Thông qua các bước trên, chúng ta đã thành công mở rộng trình biên dịch để hỗ trợ cú pháp khai báo biến. Phương pháp này cũng áp dụng được khi thêm các tính năng cú pháp khác như câu lệnh điều kiện, cấu trúc vòng lặp, v.v. Quan trọng là hiểu rõ trách nhiệm của từng giai đoạn trình biên dịch, dần dần sửa đổi các mô-đun tương ứng và đảm bảo bao phủ kiểm tra.