sqleibnizlà một công cụ phân tích tĩnh nhằm kiểm tra cú pháp SQL theo phương ngữ SQLite, sự tồn tại của bảng/cột/hàm và các điều kiện runtime; vì vậy token hóa và parsing là các bước cốt lõimacro_rules!của Rust giúp tạo cấu trúc node AST, phần triển khai traitNodevà test dựa trên bảng kiểu Go mà không cần lặp lại- Dùng các mẫu
matches!vàmatchcó thể chuyển các nhánh ngữ pháp như literal số của SQLite, identifier, symbol,EXPLAIN QUERY PLANsang mã gần như trực tiếp is_some_and,map,map_orcủaOptionvà toán tử?giúp xử lý input/luồng token một cách gọn gàng: kiểm tra sự tồn tại của giá trị, biến đổi, giá trị mặc định và lan truyền lỗi- Iterator của Rust được dùng để loại bỏ
_trong literal số, kiểm tra ký tự hex bên trong blob và tính vị trí lỗi, qua đó giúp mã token hóa/parsing dễ đọc hơn
Luồng phân tích của sqleibniz
sqleibnizlà một công cụ phân tích SQL đang được viết cho phương ngữ SQLite- Với input SQL, công cụ nhắm tới việc thực hiện kiểm tra cú pháp, xác minh sự tồn tại của bảng/cột/hàm và kiểm tra điều kiện kết hợp với runtime SQLite tích hợp
- Thông báo lỗi hướng tới việc cung cấp ngữ cảnh và mô tả, đồng thời cho phép bỏ qua một số chẩn đoán cụ thể
- Luồng phân tích bắt đầu từ phân tích từ vựng/token hóa, sau đó parsing SQL theo tài liệu SQLite và phân tích cấu trúc kết quả
- Sau khi hoàn thành phần phân tích tĩnh, tác giả cũng dự định viết LSP server cho SQL
Loại bỏ lặp lại cho node AST bằng macro
- Node AST của
sqleibnizlà các struct chứaToken, và mọi node đều phải triển khai traitNode - Trait
Nodedùngstd::fmt::Debuglàm supertrait, nên chỉ các kiểu thỏa mãnDebugmới có thể triển khaiNode - Để tránh lặp lại định nghĩa struct và phần triển khai
fn token(&self) -> &Tokencho từng node, macronode!sẽ tạo chúng- Tên node được nhận bằng metavariable
ident - Chuỗi tài liệu được nhận bằng metavariable
literal - Các trường bổ sung được xử lý lặp lại dưới dạng
$($field_name:ident:$field_type:ty),*
- Tên node được nhận bằng metavariable
- Node
Literalchỉ có trường token, còn nodeExplaincó thêm trườngchild: Option<Box<dyn Node>> - Chú thích tài liệu được truyền cho compiler dưới dạng
#[doc = $documentation]qua đối số macro, thay vì dùng///
Triển khai test dựa trên bảng kiểu Go bằng macro Rust
- Giống test dựa trên bảng của Go, cách chạy qua mảng các case đầu vào và thực thi từng case như một test độc lập được tái hiện bằng macro Rust
- Test lexer dùng các macro
test_group_pass_assert!vàtest_group_fail!- Test pass đưa input vào
Lexerrồi so sánh danh sách kiểu token từ kết quảLexer.run()với giá trị kỳ vọng - Test fail kiểm tra vector token kết quả rỗng và
Lexer.errorscó ít nhất một lỗi - Khi chạy
cargo test, mỗi case đưa ra phản hồiokhoặcfailnhư một hàm test riêng
- Test pass đưa input vào
- Test parser cũng theo cùng cấu trúc, nhưng sau khi chạy lexer thì khởi tạo
Parservà kiểm tra kết quảparse()EXPLAIN VACUUM;vàEXPLAIN QUERY PLAN VACUUM;là các case passEXPLAIN;vàEXPLAIN QUERY PLAN;là các case fail- Case fail kiểm tra điều kiện trong ngữ pháp
sql-stmtcủa SQLite rằng sauEXPLAINcần có một câu lệnh
Những điểm bất tiện của macro_rules!
- Bên trong
macro_rules!, hỗ trợ củarust-analyzerbị hạn chế- Không có IntelliSense thực sự
- Không có điều hướng tới định nghĩa
- Không có hover cho literal và chữ ký của cấu trúc ngôn ngữ
cargo fmtkhông format hay thụt lề bên trongmacro_rules!và tại nơi gọi macrotreesittervàchromathỉnh thoảng gặp khó khăn với tô sáng cú phápmacro_rules!- Tài liệu về macro cũng tương đối thiếu
matches! và match tỏa sáng trong việc khớp ký tự
- So sánh ký tự của lexer là nền tảng cho các xử lý khác, và macro
matches!cùng mẫumatchcủa Rust làm phần này trở nên gọn gàng - Nhận diện số của SQLite được viết bằng
matches!+,-_.a..=f,A..=F0..=9
- Nhận diện identifier cũng được biểu diễn như
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') - Vòng lặp chính của lexer chia ký tự hiện tại bằng
match- Ký tự whitespace được bỏ qua
*,;,,,%v.v. lần lượt tạo token tương ứng- Ví dụ xử lý symbol không xác định được lược bỏ và biểu thị bằng
panic!("whoops")
Xử lý cấu trúc ngữ pháp SQL bằng khớp token
- Lexer chuyển luồng ký tự thành luồng struct
Tokencó thông tin vị trí và kiểu, còn parser tiêu thụ luồng đó để tạo AST - Enum
TypegồmKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eofv.v. sql_stmt_prefixlà hàm parser xử lý câu lệnhEXPLAINtrong tài liệu SQLite- Nếu token hiện tại là
Type::Keyword(Keyword::EXPLAIN), tạo nodeExplainvà tiêu thụEXPLAIN - Nếu token tiếp theo là
QUERY, tiêu thụ liên tiếpQUERYvàPLAN - Sau đó parse câu lệnh SQL thực tế vào
child - Nếu không phải
EXPLAIN, gọi xử lýsql_stmtthông thường
- Nếu token hiện tại là
literal_valuetạo nodeLiteralcho chuỗi, số, blob, boolean, cùng các keyword literal nhưNULL,CURRENT_TIME,CURRENT_DATE,CURRENT_TIMESTAMP
Hiển thị lỗi và sử dụng Option
- Lexer và parser hiển thị lỗi cho người dùng trong các trường hợp như thiếu dấu chấm phẩy ở cuối câu lệnh SQL
- Toán tử
?của Rust được dùng để xử lý và lan truyền lỗi Option::is_some_andđược dùng khi kiểm tra xem ký tự tiếp theo hoặc ký tự hiện tại có tồn tại và thỏa mãn điều kiện hay khôngself.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapđược dùng để chuyển byte tiếp theo từ inputVec<u8>thànhcharOption::map_orđược dùng theo cách chỉ so sánh kiểu khi có token hiện tại hoặc token tiếp theo, còn nếu không có thì trả vềfalse
Xử lý số và blob bằng iterator
- Parsing số của SQLite cho phép
_, nhưng parsing số của Rust không cho phép_, nên lexer tiêu thụ cả_rồi loại bỏ trước khi parse - Xử lý này được viết bằng chuỗi iterator
- Lấy slice byte
- Chuyển từng byte thành
char - Chỉ lọc các ký tự không phải
_ - Thu thập thành
String
- Trong tình huống này,
unwrap_or_default()được dùng, nhưng chuỗi rỗng không hợp lệ làm số nên parser dù sao cũng sẽ thất bại - Trong Go, để xử lý tương tự, cần duyệt danh sách ký tự, ghi byte vào
strings.Builder, rồi tạo lại chuỗi - Blob của SQLite cho phép dữ liệu hex dạng
x'<hex>', nên lexer duyệt từng ký tự của chuỗi bằngchars().enumerate()và kiểm trais_ascii_hexdigit()enumerateđược dùng để lấy thông tin vị trí của ký tự sai nhằm hiển thị lỗi- Khi gặp ký tự hex không hợp lệ, lexer tạo lỗi rồi dừng xử lý
1 bình luận
Các ý kiến trên Hacker News
Chỉ hai tháng trước thôi có lẽ tôi cũng nghĩ giống tác giả, nhưng tôi liên tục va vào ranh giới cứng nhắc của Rust là borrow checker
Các kiểu dữ liệu đại số, chẳng hạn Enum và pattern matching, thì thật sự rất hay, nhưng vì borrow checker và các cân nhắc bộ nhớ cấp thấp, tôi lại mất nhiều thời gian vật lộn với borrow checker hơn là với vấn đề ngôn ngữ lập trình vốn là cốt lõi của dự án
Vì vậy tokenization và parsing thì ổn, nhưng interpreter và type checking trở thành cực hình; trong lúc tìm một ngôn ngữ phù hợp hơn, sau khi xem xét F#, Zig/C và Go, tôi đã tìm ra OCaml
Cú pháp của nó giống một Haskell thân thiện, trông như Rust không có lifetime, nên tôi bị thuyết phục; trình biên dịch Rust đầu tiên cũng được viết bằng OCaml và nó rất nổi tiếng trong lĩnh vực ngôn ngữ lập trình
Tôi vẫn đang học nên khó đánh giá công bằng, nhưng cho đến giờ nó gần đúng với thứ tôi đang tìm
Nó thực dụng, nhanh, không hẳn là cấp thấp thật sự, biên dịch cũng nhanh, và hơn hết là rất phổ biến nên thư viện gì cũng có, vì thế có vẻ như mình nên dùng
Nhưng bản thân ngôn ngữ này khiến tôi ghét gần như phi lý, và tôi cảm thấy mọi khía cạnh của nó đều xấu xí
Đây là ngôn ngữ do những người phía C tạo ra vào năm 2009, nhưng ngay cả theo tiêu chuẩn lúc đó, họ dường như không biết đến những điều thú vị trong thiết kế ngôn ngữ lập trình suốt 20 năm trước đó
Ngay cả PHP của năm 2009 cũng là một ngôn ngữ hiện đại hơn và được thiết kế tốt hơn Go, và tôi không thể gạt bỏ cảm giác rằng Go về sau cũng không tiến bộ đáng kể
Thay vào đó nên dùng thứ có thể cheap clone và interning như thư viện chuỗi tĩnh, còn vị trí văn bản thì chỉ nên dùng chỉ số
Nếu có thể, tuyệt đối nên tránh lưu reference
Càng giữ nhiều thứ có thể cheap/free clone, bạn càng ít phải vật lộn với borrow checker, và khi cần thì có thể chuyển sang clone
Ở phần interpreter thực tế, các thư viện giúp quản lý bộ nhớ theo kiểu arena khá hữu ích
Đây là một lĩnh vực rất chuyên biệt, nhưng nó đem lại cả hiệu năng lẫn tính dễ dùng, và các dự án như Ruffle cũng dùng nhiều pattern kiểu này
Tuy nhiên OCaml và Haskell làm những việc này “miễn phí” nhờ reference counting và garbage collection tích hợp, dù vậy tôi vẫn thích ý tưởng làm mọi thứ cực nhanh bằng Rust
Go gần với một C được hiện đại hóa, và mô hình mà nó cung cấp rất đơn giản
Nếu đến từ C#, chính sự đơn giản đó lại khiến nó khó học hơn; ưu điểm là gánh nặng khái niệm thấp, và nó phù hợp với các ứng dụng nhỏ, tập trung, nơi bạn chấp nhận đánh đổi bằng sự dài dòng
Nếu phải khuyên, tôi nghĩ F#, hoặc C# hiện đại, cũng ổn
Dù Microsoft có liên quan, nhưng nếu muốn sống trong một thế giới không dùng bất cứ thứ gì do các tập đoàn lớn xấu xa tạo ra thì sẽ rất khó
Java, Go, Python, TypeScript/JavaScript, Swift cũng đều vướng vào đây, và khi đó gần như chẳng còn lựa chọn nào
Tôi tò mò sau khoảng một năm dùng OCaml bạn sẽ nghĩ gì
Các ngôn ngữ kiểu Haskell thì thú vị, nhưng bản thân Haskell đối với tôi không đem lại lợi ích xứng với đường cong học tập, và Rust cũng tương tự
Với C# tôi đã đào rất sâu vào hệ thống kiểu và thành thạo nó, nhưng tôi không có thời gian để đào sâu đến mức đó với Rust
Dĩ nhiên nó thêm khoảng 10–20MB vào kích thước binary và mức dùng bộ nhớ, nhưng theo tiêu chuẩn ngày nay thì gần như không đáng kể
Ví dụ, Tailscale có vẻ dùng Go làm lớp WireGuard đa nền tảng trong ứng dụng mobile và desktop, và có vẻ hoạt động tốt
Tôi sẽ không tạo UI native bằng Go, nhưng nó rất tuyệt cho các tác vụ cấp thấp
TinyGo cũng cho phép viết Go cho vi điều khiển hoặc WebAssembly; nhiều thứ chưa được hỗ trợ, nhưng vẫn dùng được phần đáng kể của thư viện chuẩn
Ví dụ trình biên dịch Go cũng được viết bằng Go
Nhờ cross-compile và binary tương đối nhỏ, việc triển khai rất dễ
Tuy nhiên đúng là nó thiếu cú pháp đường, và không hợp lắm với pattern matching theo phong cách hàm
Cách tiếp cận parsing này trông hơi lạ, và tôi có ấn tượng rằng tác giả tương đối chưa quen với Rust cũng như các khái niệm ngôn ngữ lập trình làm nền tảng cho nó
Xem vài điểm thì, AST có lẽ sẽ đơn giản hơn nhiều nếu được định nghĩa bằng kiểu dữ liệu đại số
Tôi không nghĩ cú pháp sqlite sẽ bỗng mở rộng theo kiểu sinh ra hàng loạt node mới đến mức cần một encoding phức tạp
Encoding hiện tại trông giống thứ mà một người quen với hướng đối tượng nhưng không quen với kiểu dữ liệu đại số sẽ nghĩ ra
Câu “macro hoạt động khác nhau trong hầu hết ngôn ngữ, nhưng lý do chính là loại bỏ trùng lặp mã và giảm lặp lại” cũng có thể nói về mọi cơ chế trừu tượng hóa như hàm
Đặc điểm định nghĩa macro là nó được thực thi ở thời gian biên dịch
Nếu muốn xem cách cấu trúc parser cho gọn gàng, nghiên cứu về parser combinator có thể là điểm khởi đầu tốt
Tiêu đề blog cũng là “Why I love ...”, và bản thân góp ý có vẻ đúng, nhưng chỉ ra sự thiếu kinh nghiệm thì có vẻ không thật sự cần thiết
Việc ai đó yêu thích lập trình là điều tốt, rồi kinh nghiệm sẽ đến
Việc này không thể làm bằng hàm
Từ góc nhìn của người từng viết một parser nhỏ [0] cho ký pháp cờ vua Forsyth-Edwards, mình thấy Haskell vượt trội về tính đơn giản và dễ đọc
Nó đọc gần như BNF và hầu như không có các thủ tục mang tính nghi thức kỹ thuật, nên có thể tập trung vào ngữ pháp thực sự cần parse
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
Mình tò mò liệu có lý do rõ ràng nào khiến Rust không thể dùng cách tiếp cận tương tự không
Ví dụ winnow [1] có vẻ cung cấp phong cách đủ khai báo, và Rust cũng có nhiều thư viện parser combinator khác
[1]: https://docs.rs/winnow/latest/winnow/
Vì nó có thể được triển khai bằng một hàm đơn giản với một vòng lặp duy nhất
Vài ngày trước mình đã viết một “parser” FEN cho một triển khai quad-bitboard thử nghiệm, và nó gần như tự viết ra
Nói thêm, mình là tác giả của chessIO trên Hackage
Mình đã viết một disassembler eBPF và một emulator làm dở bằng Rust, và Rust là ngôn ngữ khá dễ chịu cho các công việc kiểu parsing
Tuy nhiên, việc tác giả cần đến macro khi còn chưa đi qua 1/6 case study dường như làm lập luận của chính họ yếu đi
Macro không hẳn là sinh mã hoàn chỉnh, nhưng cũng không tạo cảm giác quá mạnh rằng ta đang làm việc theo lối idiomatic trong ngôn ngữ
Không phải để chê trách; mình nghĩ Rust thực sự khá mạnh ở mảng này
Ví dụ quy tắc phi ngữ cảnh
S ::= abc|aabbcc|aaabbbccc|...có thể parse hiệu quảa^Nb^Nc^N, và đây là một ví dụ về ngữ pháp phụ thuộc ngữ cảnhĐây là ví dụ đơn giản, nhưng thực tế cũng có những thứ tương tự; một trường hợp là khi ngôn ngữ cho phép định nghĩa toán tử
Rust xử lý những thứ như vậy ra sao
Nghe có vẻ rất hay
Theo kinh nghiệm viết parser và lexer bằng Ragel, rồi dùng Go, Java, C++, C, chỉ cần có một trình tạo mã mẫu ở mức nào đó thì C thuần cũng tốt ngang đoạn mã Rust mà tác giả mô tả
Thậm chí có thể tốt hơn nhờ tính đơn giản
Ví dụ phần lớn mã cần cho parser JSON chỉ ở mức này
https://github.com/gritzko/librdx/blob/master/JSON.lex
Thực ra eBNF đó chỉ tạo lexer, phần parser cũng không ấn tượng lắm, dài 120 dòng và khá lặp lại
https://github.com/gritzko/librdx/blob/master/JSON.c
Cuối cùng, hạ tầng parser sẽ tiến hóa đến điểm chỉ cần eBNF là tạo được parser, và mình nghĩ đó là điểm bão hòa
Mình cảm thấy kiểu dữ liệu đại số của Rust khiến việc xử lý cây cú pháp được sinh ra dễ hơn nhiều
Tuy vậy, mình đồng ý rằng một chút sinh mã hoặc phép màu macro có thể khiến C dễ dùng hơn đáng kể
Nhưng đoạn mã ở đây
https://github.com/gritzko/librdx/blob/master/JSON.lex
chẳng phải sẽ chấp nhận
[là JSON hợp lệ saodelimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;Có vẻ như trong JSON có thể chọn chỉ một delimiter, còn mọi thứ khác đều có thể chọn 0 lần
Thường thì mình bắt đầu bằng cách xem RFC trước
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
Mình cũng không chắc có thể triển khai JSON bằng Ragel hay không
Theo mình biết, Ragel chỉ xử lý ngôn ngữ chính quy, còn JSON là ngôn ngữ phi ngữ cảnh
https://en.wikipedia.org/wiki/Cloudbleed
Liên quan đến chủ đề này, tôi thích bài nói của Rob Pike về quét từ vựng trong Go
Một cách tiếp cận mang tính giáo dục và tao nhã
https://www.youtube.com/watch?v=HxaD_trXwRE
Hình như lý do là overhead lập lịch goroutine hoặc mẫu cấp phát bộ nhớ kém hiệu quả
Thảo luận hay nhất tôi tìm được là [1]
Một bài nói xuất sắc khác về việc tạo lexer và parser hiệu quả là “Practical Data Oriented Design” của Andrew Kelley [2]
Tóm lại, bài này giải thích nhiều chiến lược để tăng throughput bằng cách giảm lượng bộ nhớ chương trình dùng và làm cho nó thân thiện với cache hơn
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
Với tôi từng có một trải nghiệm đáng ngạc nhiên
Tôi có thể dùng nguyên thư viện parser combinator vốn dùng cho parser trình biên dịch cấp cao trong môi trường no-std, biên dịch cho vi điều khiển, rồi triển khai thành parser giao thức hiệu năng cao trong môi trường embedded
Vẫn là dùng nguyên cùng một thư viện
Khác biệt chỉ ở mức giảm dùng String và dùng
&'static strnhiều hơnVì vậy việc nghịch với trình biên dịch chuyển hóa khá tốt thành năng lực xây dựng parser giao thức embedded
Điều khó khi viết toàn bộ parser AST bằng Rust là biểu diễn hệ phân cấp của các kiểu AST cụ thể, bao gồm cả upcasting và downcasting
Tôi đã tìm được cách, nhưng cần những trò kiểu kỳ lạ như
PhantomDatavà macroỞ đây hình như cũng cần macro khá quá mức
Tôi tò mò các công trình trước đó liên quan trông như thế nào
Cho đến khi đụng đến upcasting/downcasting
Tôi chưa đủ kinh nghiệm với Rust nên không biết có cách xử lý tốt hay không
Dynamic trait có thể khả thi
Những đoạn mã macro như thế này thì debug ra sao, hoặc người mới vào codebase sẽ hiểu bằng cách nào
Nhìn vào nơi dùng macro
node!và định nghĩa macro, có vẻ khó nắm được thực tế mã nào được sinh raTôi tò mò liệu có phải chạy ví dụ rồi xem các type hint hiện ra, hover trong IDE có xem được phiên bản đã expand không, hay muốn chắc chắn thì phải tham chiếu đến mã đã biên dịch
Vì tôi chỉ làm với JS/TS và không đụng đến macro, nên tôi tò mò về workflow kiểu này
$ cargo expandsẽ xem được mã kết quảRust thực ra gần như là nhiều ngôn ngữ: Rust “vanilla”, macro khai báo và macro thủ tục, mỗi thứ có năng lực và phương ngữ hơi khác nhau
Theo thời gian sẽ quen với cách xử lý từng loại
Unit test cũng là một không gian thử nghiệm tốt để hiểu tác động của việc sửa macro
Không quá tệ, nhưng trong codebase thì càng ít macro thủ tục càng tốt
Macro khai báo dễ hiểu hơn một chút, còn bảo trì và kiểm thử thì dễ hơn nhiều
Tôi cũng có cảm giác tương tự với việc sinh mã thiếu minh bạch trong các ngôn ngữ khác
Chúc may mắn với việc parse cú pháp sqlite
Vài năm trước, trong công việc tôi phải viết parser cho một tập con khá nhỏ của sqlite
Tôi rất thích sqlite và nó luôn là nguồn cảm hứng
Sơ đồ đường sắt cực kỳ hữu ích
https://www.sqlite.org/syntaxdiagrams.html
Tôi nghĩ trình sinh parser lemon chưa được ghi nhận đúng mức
https://sqlite.org/src/doc/trunk/doc/lemon.html
Xét về lựa chọn ngôn ngữ, bất cứ ngôn ngữ nào có kiểu dữ liệu đại số đều rất phù hợp
Ngay cả TypeScript cũng có thể rất tuyệt cho mục đích này
Trước đây tôi cũng từng viết một bài nhập môn nhỏ về việc tự viết parser bằng Rust
https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...