Ứng Dụng Hàm Tinh Khiết Trong Thiết Kế Trình Biên Dịch Nhỏ Nhất Thế Giới

Trình biên dịch the-super-tiny-compiler là một minh chứng sống động cho sức mạnh của lập trình hàm trong các hệ thống thực tế — đặc biệt là vai trò then chốt của hàm tinh khiết (pure function) trong từng giai đoạn xử lý: phân tích từ vựng, xây dựng cây cú pháp trừu tượng (AST), và sinh mã.

Dự án chỉ gồm khoảng 200 dòng mã JavaScript nhưng đầy đủ ba bước cốt lõi của mọi trình biên dịch hiện đại. Mỗi bước được triển khai như một hàm độc lập, không phụ thuộc vào trạng thái bên ngoài, và hoàn toàn dựa vào đầu vào để tạo ra đầu ra — đúng chuẩn định nghĩa của hàm tinh khiết.

Đặc điểm cốt lõi của hàm tinh khiết

  • Tính xác định: Với cùng một đầu vào, luôn trả về cùng một kết quả — không có biến thể do thời gian, thứ tự gọi hay trạng thái toàn cục.
  • Không có tác dụng phụ: Không thay đổi biến toàn cục, không ghi log, không gọi API, không thay đổi đối số đầu vào (immutable input).
  • Không phụ thuộc vào môi trường: Không đọc giá trị từ biến ngoài phạm vi hàm, không dùng Math.random(), Date.now(), hay process.env.

Phân tích từ vựng: Hàm scanTokens

Giai đoạn đầu tiên chuyển chuỗi mã nguồn thành mảng các token. Thay vì dùng biến trạng thái toàn cục như current trong phiên bản gốc, ta có thể tái cấu trúc hàm dưới dạng đệ quy đuôi (tail-recursive style) để làm rõ tính thuần túy:

function scanTokens(source) {
  const tokens = [];
  
  function scanAt(index) {
    if (index >= source.length) return tokens;

    const char = source[index];
    
    if (char === '(' || char === ')') {
      tokens.push({ type: 'delimiter', value: char });
      return scanAt(index + 1);
    }

    if (/\d/.test(char)) {
      let numStr = '';
      while (index < source.length && /\d/.test(source[index])) {
        numStr += source[index++];
      }
      tokens.push({ type: 'numeric', value: Number(numStr) });
      return scanAt(index);
    }

    if (/[a-z]/i.test(char)) {
      let ident = '';
      while (index < source.length && /[a-z0-9]/i.test(source[index])) {
        ident += source[index++];
      }
      tokens.push({ type: 'identifier', value: ident });
      return scanAt(index);
    }

    return scanAt(index + 1);
  }

  scanAt(0);
  return tokens;
}

Hàm này không giữ bất kỳ trạng thái nào giữa các lần gọi — mọi thông tin cần thiết đều được truyền tường minh qua tham số index. Kết quả hoàn toàn phụ thuộc vào source, đảm bảo khả năng kiểm thử cao và dễ dàng song song hóa.

Xây dựng AST: Hàm buildAst với đệ quy thuần túy

Giai đoạn thứ hai nhận mảng token và trả về cây cú pháp. Phiên bản cải tiến loại bỏ biến current bằng cách truyền vị trí hiện tại như một tham số, đồng thời trả về cả node và vị trí tiếp theo — giúp hàm trở nên hoàn toàn thuần túy và dễ kiểm soát luồng điều khiển:

function buildAst(tokens) {
  function parseNode(pos) {
    if (pos >= tokens.length) return { node: null, nextPos: pos };

    const token = tokens[pos];

    if (token.type === 'delimiter' && token.value === '(') {
      const callNode = { type: 'CallExpression', callee: null, arguments: [] };
      let next = pos + 1;

      // Lấy tên hàm
      if (tokens[next] && tokens[next].type === 'identifier') {
        callNode.callee = tokens[next].value;
        next++;
      }

      // Phân tích các tham số
      while (next < tokens.length && tokens[next].value !== ')') {
        const { node: argNode, nextPos } = parseNode(next);
        if (argNode) callNode.arguments.push(argNode);
        next = nextPos;
      }

      return { node: callNode, nextPos: next + 1 };
    }

    if (token.type === 'numeric') {
      return { node: { type: 'NumberLiteral', value: token.value }, nextPos: pos + 1 };
    }

    return { node: null, nextPos: pos };
  }

  const ast = { type: 'Program', body: [] };
  let cursor = 0;

  while (cursor < tokens.length) {
    const { node, nextPos } = parseNode(cursor);
    if (node) ast.body.push(node);
    cursor = nextPos;
  }

  return ast;
}

Sinh mã: Hàm emitCode không trạng thái

Giai đoạn cuối cùng chuyển AST sang chuỗi mã đích. Hàm sau minh họa rõ ràng nguyên tắc "không trạng thái": mỗi nhánh switch chỉ dựa vào kiểu node và nội dung của nó — không lưu cache, không cập nhật biến toàn cục, không phụ thuộc vào thứ tự duyệt:

function emitCode(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(emitCode).join(';\n') + ';';

    case 'NumberLiteral':
      return String(node.value);

    case 'CallExpression':
      const args = node.arguments.map(emitCode).join(', ');
      return `${node.callee}(${args})`;

    case 'Identifier':
      return node.name;

    default:
      throw new Error(`Unsupported node type: ${node.type}`);
  }
}

Kết quả sinh mã hoàn toàn có thể được memoized hoặc chạy song song trên các nhánh AST khác nhau — nhờ đặc tính idempotent và không phụ thuộc lẫn nhau của từng lời gọi.

Lợi ích thực tiễn từ thiết kế thuần túy

  • Khả năng kiểm thử tuyệt đối: Mỗi hàm có thể được kiểm tra bằng bộ test case cố định, không cần mock hay giả lập môi trường.
  • Debug dễ dàng: Khi đầu ra sai, chỉ cần kiểm tra đầu vào của hàm tương ứng — không cần truy vết toàn bộ luồng thực thi.
  • Tái sử dụng linh hoạt: Các hàm như scanTokens hoặc emitCode có thể tích hợp vào công cụ phân tích mã, formatter, hay IDE plugin mà không cần điều chỉnh.
  • Hỗ trợ tối ưu hóa: Trình biên dịch có thể áp dụng kỹ thuật như common subexpression elimination hoặc inlining an toàn vì hành vi hàm luôn xác định.

Thẻ: JavaScript functional-programming pure-function compiler-design AST

Đăng vào ngày 24 tháng 6 lúc 01:07