Tập 25: Khi parser bắt đầu nhận ra thế giới

Bộ phân tích cú pháp (parser) ở những ngày đầu tiên của dự án giống như một đứa trẻ đang học gọi tên các đồ vật xung quanh. Nó nhìn thấy các con số, phép cộng trừ, khai báo biến và định nghĩa hàm. Thế giới của nó còn vô cùng phẳng và đơn điệu. Mọi mã nguồn bắt buộc phải nằm gọn gàng trong một file duy nhất, dữ liệu hoặc là thuộc hẳn về chương trình hoặc chưa hề có khái niệm quyền sở hữu, và thất bại thực thi thường chỉ là một nhánh rẽ điều khiển if/else được lập trình viên viết tay thủ công.

Sprint 07 đến Sprint 09 làm cho thế giới ngữ nghĩa ấy rộng mở ra theo ba chiều kích quan trọng: từ khóa view[T] (mượn tạm), cơ chế modules (liên kết nhiều file), và cặp kiểu dữ liệu đại số Result/Option.

Từ khóa view[T] là dấu hiệu vật lý đầu tiên cho thấy cú pháp của DUY bắt đầu mang theo một ý niệm tĩnh về quản lý bộ nhớ. Một view không tuyên bố quyền sở hữu đối với vùng nhớ dữ liệu. Nó nói với trình biên dịch: "Tôi chỉ cần mượn tạm để đọc và sử dụng vùng dữ liệu này trong một phạm vi thời gian ngắn, còn trách nhiệm giải phóng nó khi hết vòng đời nằm ở một chủ sở hữu (owner) khác".

fn sum(xs: view[i32]) -> i32

Để parse thành công dòng chữ này, bộ parser chỉ cần nhận diện đúng từ khóa view, dấu ngoặc vuông và kiểu dữ liệu bên trong. Nhưng để compiler thực sự hiểu ý nghĩa của nó là một bài toán khó hơn gấp vạn lần: nguồn dữ liệu gốc của view xuất phát từ đâu, view này có bị lén lút lưu lại vào một struct toàn cục không, có sống lâu hơn owner gốc không, và quyền ghi dữ liệu được phép đến đâu. Sprint 07 mới chỉ là bản dự thảo cú pháp và chỉ reject được một vài trường hợp vi phạm quyền sở hữu đơn giản nhất. Chữ "dự thảo" (draft) ấy bắt buộc phải được giữ nguyên trong câu chuyện kể của dự án. Khả năng parse được cú pháp tuyệt đối không được phép đánh tráo với khả năng kiểm tra an toàn bộ nhớ tĩnh hoàn chỉnh.

Modules mở ra chiều không gian liên kết. Khi có dòng lệnh import math, chương trình không còn là một cây AST đơn độc và biệt lập nữa. Compiler bắt đầu phải đi tìm kiếm file trên đĩa cứng, giải quyết bài toán import vòng (circular imports), xây dựng symbol table liên kết nhiều file và quyết định xem định danh nào được phép hiển thị ra bên ngoài (public) và định danh nào bắt buộc phải giấu kín (private).

Lần đầu tiên compiler mở thành công file thứ hai trên đĩa cứng mang lại một cảm giác rất bình thường và giản dị. Nhưng ngay phía sau cánh cửa ấy, hàng loạt câu hỏi kiến trúc hệ thống build xuất hiện: đường dẫn module được giải phân giải tương đối với file gọi hay tương đối với thư mục gốc của dự án, hai module trùng tên ở các thư mục khác nhau xử lý thế nào, đồ thị phụ thuộc module (dependency graph) được cache ra sao để tăng tốc biên dịch, lỗi biên dịch ở file import có giữ được chính xác tên file và tọa độ dòng cột gốc không? Hệ thống module chính là nơi một compiler nhỏ bắt đầu phải đối mặt với các bài toán phức tạp của một build system thực thụ.

Cặp kiểu Result[T, E]Option[T] mở rộng chiều sâu ngữ nghĩa của sự thất bại. DUY kiên quyết không giấu lỗi thực thi đằng sau cơ chế Exceptions ném lỗi ngầm. Một phép tính hoặc tác vụ có khả năng thất bại bắt buộc phải trả về kết quả kiểu Result, biểu diễn tường minh cả hai khả năng: thành công (Ok) hoặc thất bại (Err). Một giá trị có khả năng vắng mặt bắt buộc phải sử dụng kiểu Option, không được phép sử dụng một giá trị ma thuật ẩn (như null hay undefined) bắt lập trình viên phải ghi nhớ bằng trí nhớ ngắn hạn.

fn divide(a: i32, b: i32) -> Result[i32, MathError]

Ở Sprint 09, đây vẫn chủ yếu là các bản vẽ thiết kế cú pháp cùng sự hỗ trợ tối thiểu của bộ parser. Toán tử khớp mẫu match và toán tử lan truyền lỗi ? mới chỉ nằm trên bản vẽ thiết kế, chưa phải toàn bộ ngữ nghĩa và bộ sinh mã đã hoàn tất trọn vẹn. Điều này phản ánh một quy trình làm việc lành mạnh: trước khi bạn bắt tay vào sinh mã máy cho một tính năng thay đổi luồng thực thi phức tạp, hãy viết ra rõ ràng câu chuyện kiểu dữ liệu và mô hình xử lý thất bại của nó trên giấy trước.

Ba tính năng mới nhìn qua có vẻ không liên quan đến nhau: view nói về mượn dữ liệu, module nói về cấu trúc file, Result nói về xử lý lỗi. Nhưng chúng cùng ép buộc cây AST phải đi xa hơn hình dạng câu chữ nguyên thủy. Cây AST bắt buộc phải lưu giữ thông tin type constructor, mối quan hệ import chéo và các biểu thức variant để các phase biên dịch sau có thể hiểu chính xác ý định của người viết.

Đây cũng là lúc tôi nhận ra bộ parser rất dễ được người ngoài khen ngợi quá lời. Nó có thể dễ dàng chấp nhận hàng trăm cú pháp phức tạp mà hoàn toàn im lặng trước câu hỏi chương trình nào đúng logic. Một parser “nuốt trôi” tệp nguồn chỉ chứng minh ngữ pháp ngôn ngữ không làm cho nó bị nghẹn. Giá trị kỹ thuật thực sự bắt đầu xuất hiện khi resolver và checker sử dụng cây AST đó để thực thi các luật lệ an toàn.

Trợ lý AI đặc biệt xuất sắc trong việc mở rộng cú pháp parser. Thêm một từ khóa mới, khai báo một loại nút AST mới và viết vài nhánh rẽ phân tích cú pháp diễn ra vô cùng nhanh chóng. Nhưng mỗi nút AST mới được thêm vào cú pháp là một khoản nợ ngữ nghĩa gửi tới bộ định dạng code, LSP, checker, IR, backend sinh mã và self-host compiler sau này. Cú pháp có lãi suất của nó. Nó càng dễ thêm vào hôm nay, bạn càng phải tỉnh táo suy nghĩ kỹ trước khi ký tên đồng ý.

Tập này không mang lại tệp nhị phân chạy ra kết quả mới ngoạn mục. Nó mang lại một sự chuyển dịch tinh tế và quan trọng hơn: DUY bắt đầu có các từ vựng chính thức để nói về những thứ mà một ngôn ngữ hệ thống hiện đại không thể né tránh mãi: quyền sử dụng bộ nhớ, ranh giới tệp tin và khả năng thất bại của hệ thống.


Bình luận (0)

Đang tải bình luận...