Vượt xa khỏi ‘phép thuật’: Tại sao nên xây dựng một Trình thông dịch?
Với hầu hết các lập trình viên, cách máy tính biến một chuỗi như x = 10 + 5 thành một giá trị thực tế trong bộ nhớ thường giống như phép thuật. Nhưng việc mổ xẻ quá trình đó là một trong những cách nhanh nhất để nâng tầm kỹ năng kỹ thuật của bạn. Khi bạn tự mình triển khai scope, closure và variable shadowing, bạn sẽ không còn phải đoán cách các ngôn ngữ yêu thích hoạt động bên dưới lớp vỏ. Bạn bắt đầu nhận ra các pattern thay vì chỉ nhìn vào cú pháp.
Go là công cụ hoàn hảo cho dự án này. Nó cung cấp tốc độ thô của một ngôn ngữ biên dịch nhưng vẫn dễ đọc như Python. Hệ thống kiểu dữ liệu (type system) chặt chẽ của Go giúp phát hiện các lỗi logic trong Abstract Syntax Tree (AST) trước khi chúng trở thành nỗi ác mộng lúc thực thi (runtime). Trong một dự án trước đây của tôi, tôi đã thay thế một logic engine cồng kềnh từ bên thứ ba bằng một DSL tùy chỉnh dài 600 dòng viết bằng Go. Thay đổi này đã giúp giảm 35% thời gian thực thi quy tắc và loại bỏ được ba dependency nặng nề.
Một trình thông dịch tiêu chuẩn tuân theo một pipeline rõ ràng: Lexer, Parser và Evaluator. Chúng ta sẽ xây dựng một trình thông dịch kiểu tree-walking. Đây là cách trực tiếp nhất để học các khái niệm này trước khi đi sâu vào bytecode phức tạp hay JIT compilation.
Thiết lập: Chuẩn bị Workspace Go
Đầu tiên, hãy đảm bảo bạn đã cài đặt Go 1.21 hoặc mới hơn. Mặc dù chúng ta sẽ không dùng quá nhiều tính năng mới nhất, nhưng việc sở hữu toolchain mới nhất sẽ giúp theo dõi hiệu năng tốt hơn sau này. Chúng ta sẽ tổ chức dự án thành các package riêng biệt để giữ cho logic được tách biệt và dễ kiểm thử.
mkdir go-interpreter && cd go-interpreter
go mod init go-interpreter
mkdir -p token lexer ast parser evaluator repl
Cấu trúc thư mục này phản ánh luồng dữ liệu đi qua hệ thống. Văn bản đi vào Lexer và đi ra dưới dạng Token. Parser tiêu thụ các Token đó để xây dựng AST. Cuối cùng, Evaluator duyệt cây đó để đưa ra kết quả. Tôi luôn bắt đầu với package token; nó định nghĩa bộ từ vựng cơ bản mà ngôn ngữ của chúng ta có thể hiểu.
Các thành phần cốt lõi: Từ văn bản đến thực thi
1. Phân loại mã nguồn: Tokens
Lexer cần biết chính xác nó đang nhìn thấy cái gì. Mọi ký tự phải được xếp vào một danh mục. Trong token/token.go, chúng ta định nghĩa các hằng số. Đây đóng vai trò là bản thiết kế cho toàn bộ ngôn ngữ của chúng ta.
package token
type TokenType string
const (
ILLEGAL = "ILLEGAL"
EOF = "EOF"
IDENT = "IDENT"
INT = "INT"
ASSIGN = "="
PLUS = "+"
COMMA = ","
SEMICOLON = ";"
FUNCTION = "FUNCTION"
LET = "LET"
)
type Token struct {
Type TokenType
Literal string
}
2. Lexer: Trình quét đầu vào
Lexer là một máy trạng thái (state machine) đơn giản. Nó quét mã nguồn theo từng ký tự, bỏ qua khoảng trắng và nhóm các ký tự thành những token mà chúng ta vừa định nghĩa. Nó chưa quan tâm đến logic; nó chỉ quan tâm đến việc biến let thành token LET và 5 thành token INT.
func (l *Lexer) NextToken() token.Token {
l.skipWhitespace()
var tok token.Token
switch l.ch {
case '=':
tok = token.Token{Type: token.ASSIGN, Literal: string(l.ch)}
case '+':
tok = token.Token{Type: token.PLUS, Literal: string(l.ch)}
case 0:
tok.Type = token.EOF
tok.Literal = ""
default:
if isLetter(l.ch) {
tok.Literal = l.readIdentifier()
tok.Type = token.LookupIdent(tok.Literal)
return tok
}
// Thêm xử lý số nguyên tại đây...
}
l.readChar()
return tok
}
3. Parser: Logic cấu trúc
Đây là nơi các công việc nặng nhọc diễn ra. Chúng ta sẽ sử dụng một **Pratt Parser**. Không giống như các trình phân tích cú pháp đệ quy xuống (recursive descent parser) truyền thống thường trở nên lộn xộn với các phép toán, Pratt Parser sử dụng một bảng tra cứu cho độ ưu tiên của toán tử. Điều này giúp việc xử lý các biểu thức như 2 + 3 * 4 trở nên sạch sẽ và dễ debug hơn. Mục tiêu là tạo ra một cây mà mỗi node đại diện cho một phần cấu trúc của chương trình.
Mỗi node phải triển khai một interface đơn giản để Evaluator biết cách xử lý:
type Node interface {
TokenLiteral() string
}
type Statement interface {
Node
statementNode()
}
type Expression interface {
Node
expressionNode()
}
4. Evaluator: Chạy mã nguồn
Evaluator chính là động cơ. Nó đệ quy “duyệt” (walk) qua AST. Nếu gặp một IntegerLiteral, nó trả về giá trị đó. Nếu gặp một InfixExpression như 10 + 20, nó đánh giá cả hai vế và áp dụng toán tử +. Nó thực chất là một câu lệnh switch khổng lồ xử lý cây logic của bạn.
func Eval(node ast.Node) object.Object {
switch node := node.(type) {
case *ast.Program:
return evalStatements(node.Statements)
case *ast.IntegerLiteral:
return &object.Integer{Value: node.Value}
case *ast.InfixExpression:
left := Eval(node.Left)
right := Eval(node.Right)
return evalInfixExpression(node.Operator, left, right)
}
return nil
}
Xác thực: REPL và Benchmarking
Bạn cần một cách để “trò chuyện” với ngôn ngữ mới của mình. Một REPL (Read-Eval-Print Loop) cho phép bạn nhập mã và xem kết quả ngay lập tức. Nó nhanh hơn nhiều so với việc chạy toàn bộ bộ test mỗi khi bạn muốn kiểm tra một toán tử duy nhất. Trong Go, bạn có thể xây dựng một REPL cơ bản bằng bufio.Scanner với chưa đầy 50 dòng code.
Kiểm thử một parser đòi hỏi sự chính xác. Tôi khuyên bạn nên sử dụng table-driven tests. Bằng cách định nghĩa một danh sách các đầu vào và kết quả mong đợi, bạn có thể xác minh hàng chục trường hợp biên (edge case) trong vài giây. Điều này giúp ngăn chặn các “lỗi hồi quy” (regression bugs) – ví dụ khi thêm tính năng mới như hỗ trợ chuỗi nhưng lại vô tình làm hỏng phần toán số nguyên.
Khi mọi thứ đã chạy, hãy sử dụng go test -bench để tìm các nút thắt cổ chai. Tree-walking rất dễ hiểu nhưng có thể chậm nếu bạn cấp phát (allocate) quá nhiều object. Nếu trình thông dịch của bạn ngốn hơn 50MB RAM cho các script đơn giản, hãy kiểm tra lại package object của mình. Việc tái sử dụng các object cho các giá trị phổ biến như true, false, và các số nguyên nhỏ (0-100) có thể làm giảm đáng kể lượng bộ nhớ sử dụng. Cách tiếp cận modular này đảm bảo ngôn ngữ của bạn có thể phát triển từ một máy tính đơn giản thành một DSL đầy đủ tính năng.

