Cách tổ chức một bữa tiệc Luau: Mở rộng cú pháp của Lua bằng các kiểu dữ liệu

Trong một thời gian rất dài, Lua 5.1 là ngôn ngữ được Roblox lựa chọn. Khi chúng tôi phát triển, nhu cầu về hỗ trợ công cụ tốt hơn cũng như một máy ảo (VM) hiệu suất cao hơn cũng tăng lên. Để giải quyết vấn đề này, chúng tôi đã khởi xướng dự án tái xây dựng hệ thống Lua của mình mang tên “Luau” (phát âm là /lu-wow/), với mục tiêu tích hợp các tính năng mà các lập trình viên mong đợi từ một ngôn ngữ hiện đại – bao gồm trình kiểm tra kiểu, khung công cụ linter mới và trình thông dịch nhanh hơn, chỉ để kể vài ví dụ.
Để thực hiện điều đó, chúng tôi phải viết lại hầu hết hệ thống từ đầu. Vấn đề là trình phân tích cú pháp Lua 5.1 được tích hợp chặt chẽ với quá trình tạo bytecode, và điều đó không đủ cho nhu cầu của chúng tôi. Chúng tôi muốn có thể duyệt qua cây cú pháp (AST) để phân tích sâu hơn, vì vậy chúng tôi cần một trình phân tích cú pháp có thể tạo ra cây cú pháp đó. Từ đó, chúng tôi có thể thực hiện bất kỳ thao tác nào mong muốn trên cây cú pháp đó.
May mắn thay, đã có sẵn một trình phân tích cú pháp Lua 5.1 trong Studio chỉ được sử dụng cho quá trình kiểm tra lint cơ bản. Điều này giúp chúng tôi dễ dàng áp dụng trình phân tích cú pháp đó và mở rộng nó để nhận diện cú pháp đặc thù của Luau, từ đó giảm thiểu rủi ro tiềm ẩn khi thay đổi kết quả phân tích theo cách tinh vi. Đây là chi tiết quan trọng vì một trong những giá trị cốt lõi của Roblox là tính tương thích ngược. Chúng tôi đã có hàng triệu dòng mã Lua được viết sẵn và chúng tôi cam kết đảm bảo rằng chúng sẽ tiếp tục hoạt động mãi mãi.
Vì vậy, với những yếu tố này, các yêu cầu là rõ ràng. Chúng tôi cần:
- tránh các điểm bất thường về ngữ pháp đòi hỏi phải quay lại
- có một trình phân tích cú pháp hiệu quả
- duy trì cú pháp tương thích về phía trước
- duy trì khả năng tương thích ngược với Lua 5.1
Nghe có vẻ đơn giản, phải không?
Cách thức mà công cụ suy luận kiểu dữ liệu ảnh hưởng đến các lựa chọn cú pháp
Để bắt đầu, chúng ta cần hiểu một số bối cảnh về cách chúng ta đi đến tình huống này. Chúng tôi chọn các cú pháp này vì chúng đã quen thuộc ngay lập tức với đa số các lập trình viên, và trên thực tế là tiêu chuẩn của ngành. Bạn không cần phải học bất cứ điều gì mới.
Có một số nơi mà Luau cho phép bạn viết các chú thích kiểu như vậy:
- local foo: string
- function add(x: number, y: number): number ... end
- type Foo = (number, number) -> number
- local foo = bar as string
Việc thêm cú pháp để chú thích các ràng buộc của bạn là rất quan trọng để công cụ suy luận kiểu hiểu rõ hơn các kiểu được dự định. Lua là một ngôn ngữ rất mạnh mẽ, cho phép bạn quá tải hầu như mọi toán tử trong ngôn ngữ. Nếu không có cách nào để chú thích các đối tượng là gì, chúng ta thậm chí không thể tự tin nói rằng biểu thức x + y sẽ tạo ra một số!
Biểu thức chuyển đổi kiểu
Một điều chúng tôi thực sự thích ở TypeScript là cái mà họ gọi là khẳng định kiểu. Về cơ bản, đó là một cách để thêm thông tin kiểu bổ sung vào chương trình để trình kiểm tra xác minh. Trong TypeScript, cú pháp là:
bar as string
Thật không may, khi chúng tôi thử nghiệm điều này, chúng tôi đã gặp phải một bất ngờ không hay: điều này làm hỏng mã hiện có! Một trong những trò chơi của người dùng chúng tôi có một hàm được đặt tên là as. Do đó, các tập lệnh của họ bao gồm các đoạn mã như:
local x = y
as(w, z) -- Expected ‘->’ when parsing function type, got <eof>
Chúng tôi có thể đã làm cho điều này hoạt động, nếu không có một vấn đề phức tạp khác: chúng tôi muốn trình phân tích cú pháp của mình chỉ sử dụng một token dự đoán. Hiệu suất là điều quan trọng đối với chúng tôi, và một phần của việc viết một trình phân tích cú pháp có hiệu suất cao là giảm thiểu lượng thao tác quay lại mà nó phải thực hiện. Sẽ không hiệu quả nếu trình phân tích cú pháp của chúng tôi phải quét tiến và lùi một cách tùy ý để xác định ý nghĩa thực sự của một biểu thức.
Hơn nữa, TypeScript có thể cảm ơn quy tắc chèn dấu chấm phẩy tự động của JavaScript đã giúp điều này hoạt động mà không tốn thêm công sức. Khi bạn viết đoạn mã này trong TypeScript/JavaScript, nó sẽ chèn dấu chấm phẩy vào mỗi dòng, khiến nó được phân tích cú pháp thành hai câu lệnh riêng biệt. Trong khi đó, nếu nó nằm trên một dòng duy nhất, nó sẽ là lỗi cú pháp tại token "as" trong JavaScript, nhưng lại là biểu thức khẳng định kiểu hợp lệ trong TypeScript. Vì Lua không làm điều này và cũng không bắt buộc sử dụng dấu chấm phẩy, nó phải cố gắng phân tích cú pháp cho mỗi câu lệnh dài nhất có thể, ngay cả khi chúng trải dài qua nhiều dòng.
let x = y
as(w, z)Biểu thức chuyển đổi kiểu ban đầu của Luau không tương thích ngược mặc dù nó có hiệu suất như chúng tôi mong muốn. Đáng tiếc là điều này đã phá vỡ lời hứa của chúng tôi rằng Luau sẽ là một siêu tập hợp của Lua 5.1, vì vậy chúng tôi không thể thực hiện điều đó mà không có một số ràng buộc bổ sung, chẳng hạn như yêu cầu phải có dấu ngoặc đơn trong một số ngữ cảnh nhất định!
Tham số kiểu trong các lệnh gọi hàm
Một chi tiết đáng tiếc khác trong ngữ pháp của Lua ngăn cản chúng tôi thêm các đối số kiểu vào các lệnh gọi hàm mà không gây ra sự mơ hồ khác:
return someFunction<A, B>(c)
Nó có thể có hai nghĩa khác nhau:
- đánh giá
someFunction < A and B > cvà trả về kết quả - gọi và trả về someFunction với hai đối số kiểu A và B, và một đối số c
Sự mơ hồ này chỉ xảy ra trong bối cảnh của một danh sách biểu thức. Điều này không thực sự là một vấn đề lớn trong TypeScript và C# vì cả hai đều có lợi thế là biên dịch trước. Do đó, cả hai đều có thể dành một số chu kỳ để cố gắng làm rõ biểu thức này thành một trong hai tùy chọn.
Mặc dù có vẻ như chúng ta có thể làm điều tương tự, chẳng hạn như áp dụng các thuật toán heuristic trong quá trình phân tích cú pháp hoặc kiểm tra kiểu, nhưng thực tế là chúng ta không thể. Lua 5.1 có khả năng chèn các biến toàn cục vào bất kỳ môi trường nào một cách động, và điều đó có thể phá vỡ thuật toán heuristic này. Chúng ta cũng hoàn toàn không có lợi thế đó vì chúng ta phải có khả năng tạo mã byte càng nhanh càng tốt để tất cả các máy khách bắt đầu giải thích.
Câu lệnh bí danh kiểu
Việc phân tích cú pháp câu lệnh bí danh kiểu này không phải là một thay đổi lớn vì nó vốn đã là cú pháp Lua không hợp lệ:
type Foo = number
Những gì chúng tôi làm rất đơn giản. Chúng tôi phân tích cú pháp một biểu thức chính mà cuối cùng chỉ phân tích đến kiểu, sau đó chúng tôi quyết định phải làm gì dựa trên kết quả phân tích cú pháp của biểu thức đó:
- Nếu đó là một lệnh gọi hàm, hãy ngừng cố gắng phân tích cú pháp cho phần còn lại của biểu thức này dưới dạng câu lệnh.
- Nếu không, nếu mã thông báo tiếp theo là dấu phẩy hoặc dấu bằng, hãy phân tích cú pháp câu lệnh gán giá trị.
Điều còn thiếu ở trên là rất rõ ràng. Nó không có nhánh nào mà một định danh có thể được dẫn đầu bởi một định danh khác. Tất cả những gì chúng ta phải làm là so khớp mẫu trên biểu thức:
- Đó có phải là một định danh không?
- Tên của định danh đó có bằng “type” không?
- Token tiếp theo có phải là một định danh tùy ý nào đó không?
Vậy là xong, bạn đã có cú pháp tương thích ngược với từ khóa nhạy cảm với ngữ cảnh.
type Foo = number -- type alias
type(x) -- function call
type = {x = 1} -- assignment
type.x = 2 -- assignmentNhư một đoạn mã bổ sung, đoạn mã này vẫn được phân tích cú pháp theo cách hoàn toàn giống với Lua 5.1 vì chúng ta không phân tích từ ngữ cảnh của một câu lệnh:
local foo = type
bar = 1Bài học rút ra
Điểm mấu chốt ở đây, có vẻ như, là chúng ta sẽ phải thiết kế cú pháp cho Luau sao cho tương thích về phía trước và có ít đường dẫn phân tích cú pháp nhạy cảm với ngữ cảnh nhất. Điều này loại bỏ sự cần thiết phải suy đoán lại, điều này yêu cầu trình phân tích cú pháp phải quay lại và thử một cái gì đó khác từ điểm thất bại đó. Không chỉ mang lại lợi ích của một trình phân tích cú pháp nhanh chóng để tiếp tục đến cuối mã nguồn, mà còn có thể trả về AST mà không cần các giai đoạn khác để giải quyết sự mơ hồ.
Điều này cũng có nghĩa là chúng ta cần thận trọng khi thêm cú pháp mới nói chung, điều này không hẳn là điều xấu. Một ngôn ngữ được thiết kế kỹ lưỡng đòi hỏi các nhà thiết kế phải có tầm nhìn dài hạn.
Cả Roblox Corporation lẫn blog này đều không ủng hộ hay hỗ trợ bất kỳ công ty hay dịch vụ nào. Ngoài ra, không có bất kỳ đảm bảo hay cam kết nào về tính chính xác, độ tin cậy hoặc tính đầy đủ của thông tin trong blog này.
Bài đăng trên blog này ban đầu được xuất bản trên Roblox Tech Blog.


