sqleibnizเป็น เครื่องมือวิเคราะห์แบบสถิต ที่ต้องการตรวจสอบไวยากรณ์ของ SQL dialect แบบ SQLite การมีอยู่ของตาราง·คอลัมน์·ฟังก์ชัน และเงื่อนไขขณะรันไทม์ ดังนั้นการทำโทเคไนซ์และการพาร์สจึงเป็นขั้นตอนสำคัญmacro_rules!ของ Rust ช่วยสร้างโครงสร้าง AST node, การ implement traitNode, และ table-driven tests แบบ Go ได้โดยไม่ต้องเขียนซ้ำ- เมื่อใช้แพตเทิร์น
matches!และmatchก็สามารถถ่ายทอด แขนงไวยากรณ์ อย่าง numeric literal ของ SQLite, identifier, symbol และEXPLAIN QUERY PLANให้ใกล้เคียงกับโค้ดได้ Optionอย่างis_some_and,map,map_orและตัวดำเนินการ?ช่วยให้การจัดการอินพุต·สตรีมโทเคนในเรื่องการมีอยู่ของค่า การแปลงค่า ค่าเริ่มต้น และการส่งต่อข้อผิดพลาด กระชับขึ้น- iterator ของ Rust ถูกนำมาใช้กับการลบ
_ออกจาก numeric literal, การตรวจสอบอักขระฐานสิบหกภายใน blob, และการคำนวณตำแหน่งข้อผิดพลาด ซึ่งส่งผลให้โค้ดสำหรับโทเคไนซ์·พาร์สอ่านง่ายขึ้น
โฟลว์การวิเคราะห์ของ sqleibniz
sqleibnizเป็นเครื่องมือวิเคราะห์ SQL ที่กำลังพัฒนาสำหรับ SQLite dialect- ตั้งใจให้ทำ การตรวจสอบไวยากรณ์ กับอินพุต SQL, ตรวจว่ามีตาราง·คอลัมน์·ฟังก์ชันอยู่จริงหรือไม่, และตรวจเงื่อนไขโดยเชื่อมกับ SQLite runtime ในตัว
- ข้อความผิดพลาดมีเป้าหมายให้ให้ทั้งบริบทและคำอธิบาย พร้อมรูปแบบที่สามารถเพิกเฉยต่อการวินิจฉัยบางรายการได้
- โฟลว์การวิเคราะห์เริ่มจาก lexing/tokenizing แล้วตามด้วยการพาร์ส SQL ตามเอกสารของ SQLite และการวิเคราะห์โครงสร้างผลลัพธ์
- หลังจากทำส่วน static analysis เสร็จแล้ว ยังมีแผนจะเขียน LSP server สำหรับ SQL ด้วย
ลดการเขียนซ้ำของ AST node ด้วยแมโคร
- AST node ของ
sqleibnizเป็น struct ที่เก็บTokenและทุก node ต้อง implement traitNode - trait
Nodeใช้std::fmt::Debugเป็น supertrait ทำให้มีเพียง type ที่ตรงตามDebugเท่านั้นจึงจะ implementNodeได้ - เพื่อไม่ให้ต้องเขียนซ้ำทั้งการประกาศ struct และ
fn token(&self) -> &Tokenสำหรับแต่ละ node แมโครnode!จึงถูกใช้สร้างสิ่งเหล่านี้- ชื่อ node รับผ่าน metavariable แบบ
ident - สตริงเอกสารรับผ่าน metavariable แบบ
literal - ฟิลด์เพิ่มเติมประมวลผลซ้ำในรูปแบบ
$($field_name:ident:$field_type:ty),*
- ชื่อ node รับผ่าน metavariable แบบ
- node
Literalมีเพียงฟิลด์ token ส่วนExplainเพิ่มฟิลด์child: Option<Box<dyn Node>> - คอมเมนต์เอกสารถูกส่งให้คอมไพเลอร์ผ่านรูปแบบ
#[doc = $documentation]แทน///
ทำ table-driven tests แบบ Go ด้วยแมโครของ Rust
- แนวทางแบบ table-driven test ของ Go ที่วนอาร์เรย์ของเคสอินพุตและรันแต่ละเคสเป็นการทดสอบแยกกัน ถูกทำซ้ำใน Rust ด้วยแมโคร
- การทดสอบ lexer ใช้แมโคร
test_group_pass_assert!และtest_group_fail!- การทดสอบที่ต้องผ่านจะป้อนอินพุตเข้า
Lexerแล้วเปรียบเทียบรายการ type ของโทเคนจากผลLexer.run()กับค่าที่คาดหวัง - การทดสอบที่ต้องล้มเหลวจะตรวจว่าเวกเตอร์โทเคนผลลัพธ์ว่างเปล่า และ
Lexer.errorsมีข้อผิดพลาดอย่างน้อยหนึ่งรายการ - เมื่อรัน
cargo testแต่ละเคสจะแสดงผลตอบกลับokหรือfailราวกับเป็นฟังก์ชันทดสอบแยกกัน
- การทดสอบที่ต้องผ่านจะป้อนอินพุตเข้า
- การทดสอบ parser ใช้โครงสร้างเดียวกัน แต่จะ initialize
Parserหลังรัน lexer แล้วตรวจผลของparse()EXPLAIN VACUUM;และEXPLAIN QUERY PLAN VACUUM;เป็นเคสที่ผ่านEXPLAIN;และEXPLAIN QUERY PLAN;เป็นเคสที่ล้มเหลว- เคสที่ล้มเหลวใช้ตรวจเงื่อนไขตามไวยากรณ์
sql-stmtของ SQLite ว่าหลังEXPLAINต้องมี statement ตามมา
จุดที่ไม่สะดวกของ macro_rules!
- ภายใน
macro_rules!การรองรับของrust-analyzerยังมีจำกัด- ไม่มี IntelliSense จริง
- ไม่มี go to definition
- ไม่มี hover ของ literal และ signature ของโครงสร้างภาษา
cargo fmtจะไม่จัดรูปแบบหรือย่อหน้าให้ทั้งภายในmacro_rules!และจุดที่เรียกใช้แมโครtreesitterและchromaบางครั้งมีปัญหากับ syntax highlighting ของmacro_rules!- เอกสารของแมโครก็ค่อนข้างมีน้อย
matches! และ match ที่โดดเด่นในการจับคู่อักขระ
- การเปรียบเทียบอักขระใน lexer เป็นพื้นฐานของการประมวลผลอื่น ๆ และแมโคร
matches!กับแพตเทิร์นmatchของ Rust ทำให้ส่วนนี้กระชับขึ้น - การตัดสิน numeric ของ SQLite เขียนด้วย
matches!+,-_.a..=f,A..=F0..=9
- การตัดสิน identifier ก็เขียนได้ในรูปแบบอย่าง
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') - ลูปหลักของ lexer แยกการทำงานตามอักขระปัจจุบันด้วย
match- ข้ามอักขระ whitespace
*,;,,,%เป็นต้น จะสร้างโทเคนที่สอดคล้องกันทีละตัว- ตัวอย่างการจัดการ symbol ที่ไม่รู้จักถูกละไว้และแสดงเป็น
panic!("whoops")
จัดการโครงสร้างไวยากรณ์ SQL ด้วยการจับคู่โทเคน
- lexer เปลี่ยนสตรีมอักขระเป็นสตรีมของ struct
Tokenที่มีข้อมูลตำแหน่งและชนิด จากนั้น parser จะนำไปใช้สร้าง AST - enum
TypeรวมถึงKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eofเป็นต้น sql_stmt_prefixเป็นฟังก์ชัน parser ที่จัดการ statementEXPLAINตามเอกสารของ SQLite- หากโทเคนปัจจุบันคือ
Type::Keyword(Keyword::EXPLAIN)จะสร้าง nodeExplainและ consumeEXPLAIN - ถ้าโทเคนถัดไปคือ
QUERYก็จะ consumeQUERYและPLANต่อเนื่องกัน - หลังจากนั้นจึงพาร์ส SQL statement จริงเป็น
child - ถ้าไม่ใช่
EXPLAINก็จะเรียกการจัดการsql_stmtปกติ
- หากโทเคนปัจจุบันคือ
literal_valueสร้าง nodeLiteralจาก keyword literal เช่นNULL,CURRENT_TIME,CURRENT_DATE,CURRENT_TIMESTAMPรวมถึง string, number, blob, boolean
การแสดงข้อผิดพลาดและการใช้ Option
- lexer และ parser แสดงข้อผิดพลาดให้ผู้ใช้เห็นในกรณีอย่างการลืมใส่ semicolon ท้าย statement ของ SQL
- ตัวดำเนินการ
?ของ Rust ใช้สำหรับการจัดการและส่งต่อข้อผิดพลาด Option::is_some_andใช้ตรวจว่ามีอักขระถัดไปหรืออักขระปัจจุบันอยู่ และตรงตามเงื่อนไขหรือไม่self.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapถูกใช้เพื่อแปลงไบต์ถัดไปจากอินพุตVec<u8>ให้เป็นcharOption::map_orใช้ในรูปแบบที่เปรียบเทียบ type เฉพาะเมื่อมีโทเคนปัจจุบันหรือโทเคนถัดไปอยู่เท่านั้น และถ้าไม่มีก็คืนค่าfalse
จัดการตัวเลขและ blob ด้วย iterator
- การพาร์สตัวเลขของ SQLite อนุญาตให้มี
_ได้ แต่การพาร์สตัวเลขของ Rust ไม่อนุญาต_ดังนั้น lexer จึง consume ทั้งหมดรวม_ไปก่อน แล้วค่อยลบออกก่อนพาร์ส - กระบวนการนี้เขียนเป็น iterator chain
- นำ byte slice มาใช้
- แปลงแต่ละไบต์เป็น
char - filter เฉพาะอักขระที่ไม่ใช่
_ - collect กลับเป็น
String
- ในกรณีนี้ใช้
unwrap_or_default()แต่ถึงเป็นสตริงว่างก็ไม่ถือว่าเป็นตัวเลขที่ถูกต้องอยู่ดี ดังนั้น parser ก็จะล้มเหลวอยู่แล้ว - ใน Go หากจะทำแบบเดียวกัน ต้องวนรายการอักขระ เขียนไบต์ลง
strings.Builderแล้วค่อยสร้างสตริงกลับมาอีกครั้ง - SQLite blob รองรับข้อมูลฐานสิบหกในรูปแบบ
x'<hex>'ดังนั้นจึงวนแต่ละอักขระของสตริงด้วยchars().enumerate()เพื่อตรวจว่าเป็นis_ascii_hexdigit()หรือไม่enumerateถูกใช้เพื่อดึงข้อมูลตำแหน่งของอักขระที่ผิดสำหรับการแสดงข้อผิดพลาด- เมื่อพบอักขระฐานสิบหกที่ไม่ถูกต้อง ก็จะสร้างข้อผิดพลาดแล้วหยุดการประมวลผล
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ถ้าเป็นเมื่อสองเดือนก่อนก็คงคิดเหมือนผู้เขียน แต่กลับชนกับขอบเขตอันแข็งกระด้างของ Rust อย่าง borrow checker อยู่เรื่อย ๆ
ชนิดข้อมูลเชิงพีชคณิต เช่น Enum และ pattern matching นั้นยอดเยี่ยมจริง ๆ แต่เพราะ borrow checker และการต้องคำนึงถึงหน่วยความจำระดับต่ำ ทำให้ใช้เวลาต่อสู้กับ borrow checker มากกว่าปัญหาภาษาโปรแกรมมิงซึ่งเป็นแกนหลักของโปรเจกต์
ดังนั้นการทำ tokenization และ parsing ยังพอไหว แต่ตัว interpreter และ type checking กลายเป็นความทรมาน และระหว่างมองหาภาษาที่เหมาะกว่า หลังจากพิจารณา F#, Zig/C, Go แล้วก็ได้พบกับ OCaml
ไวยากรณ์ดูเหมือน Haskell ที่เป็นมิตร และดูเหมือน Rust ที่ไม่มี lifetime จึงทำให้เชื่อมั่น อีกทั้งคอมไพเลอร์ Rust ตัวแรกก็เขียนด้วย OCaml และเป็นที่รู้จักกันดีในสายภาษาโปรแกรมมิง
ยังอยู่ระหว่างเรียนจึงประเมินอย่างเป็นธรรมได้ยาก แต่จนถึงตอนนี้ก็ใกล้เคียงกับสิ่งที่ตามหาอยู่พอดี
มันใช้งานได้จริง เร็ว ไม่ได้ระดับต่ำจริง ๆ คอมไพล์ก็เร็ว และเหนือสิ่งอื่นใดคือได้รับความนิยมมากจนมีไลบรารีครบ เลยรู้สึกว่าควรใช้
แต่ตัวภาษาเองกลับไม่ชอบอย่างแทบไร้เหตุผล และรู้สึกว่าทุกด้านดูน่าเกลียด
เป็นภาษาที่คนสาย C สร้างขึ้นในปี 2009 แต่แม้เทียบกับมาตรฐานตอนนั้น ก็เหมือนจะไม่รู้จักสิ่งน่าสนใจที่เกิดขึ้นในวงการออกแบบภาษาโปรแกรมมิงตลอด 20 ปีก่อนหน้า
แม้แต่ PHP ในปี 2009 ยังเป็นภาษาที่ทันสมัยกว่าและออกแบบดีกว่า Go และก็สลัดความรู้สึกไม่ได้ว่า Go หลังจากนั้นก็ไม่ได้ดีขึ้นมากนัก
ให้ใช้ของที่ clone ได้ถูกและทำ interning ได้แทน เหมือนกับไลบรารีสตริงแบบสแตติก และสำหรับตำแหน่งในข้อความก็ควรใช้แค่ดัชนี
ถ้าเป็นไปได้ควรหลีกเลี่ยงการเก็บ reference โดยเด็ดขาด
ยิ่งเก็บของที่ clone ได้แบบ cheap/free มากเท่าไร ก็ยิ่งต้องสู้กับ borrow checker น้อยลง และเมื่อจำเป็นก็เปลี่ยนไปใช้ clone ได้
ในส่วน interpreter จริง ๆ ไลบรารีที่ช่วยจัดการหน่วยความจำแบบ arena ค่อนข้างมีประโยชน์
เป็นโดเมนที่เฉพาะทางมาก แต่ให้ทั้งประสิทธิภาพและความใช้งานง่าย และโปรเจกต์อย่าง Ruffle ก็ใช้แพตเทิร์นแบบนี้มาก
อย่างไรก็ตาม OCaml และ Haskell ทำสิ่งเหล่านี้ให้ “ฟรี” ด้วย reference counting และ garbage collection ในตัว แต่ถึงอย่างนั้นก็ยังชอบไอเดียที่จะไปได้เร็วมากด้วย Rust
Go ใกล้เคียงกับ C ที่ทำให้ทันสมัยขึ้น และโมเดลที่ให้มาก็เรียบง่ายมาก
ถ้ามาจาก C# ความเรียบง่ายนั้นกลับทำให้เรียนยากขึ้นเสียอีก จุดแข็งคือภาระด้านแนวคิดต่ำ และเหมาะกับแอปพลิเคชันเล็ก ๆ ที่โฟกัสชัดเจน ซึ่งยอมรับการแลกเปลี่ยนด้วยความยืดยาวได้
ถ้าจะแนะนำ ผมว่า F# หรือ C# สมัยใหม่ก็ใช้ได้
แม้ Microsoft จะเกี่ยวข้องอยู่ แต่ถ้าจะใช้ชีวิตในโลกที่ไม่แตะของที่บริษัทเทคยักษ์ร้าย ๆ สร้างเลย ก็จะลำบาก
Java, Go, Python, TypeScript/JavaScript, Swift ก็เข้าข่ายนี้ทั้งหมด แล้วทางเลือกแทบจะไม่เหลือ
อยากรู้ว่าหลังจากใช้ OCaml สัก 1 ปีแล้วจะคิดอย่างไร
สาย Haskell น่าสนใจ แต่สำหรับผม Haskell เองให้ผลตอบแทนไม่คุ้มกับ learning curve และ Rust ก็คล้ายกัน
C# ผมขุดลึกและชำนาญระบบชนิดข้อมูลแล้ว แต่ไม่มีเวลาจะเจาะ Rust ให้ลึกระดับนั้น
แน่นอนว่ามันเพิ่มขนาดไบนารีและการใช้หน่วยความจำราว 10–20MB แต่ตามมาตรฐานทุกวันนี้แทบไม่ใช่อะไรเลย
ตัวอย่างเช่น Tailscale ดูเหมือนจะใช้ Go เป็นเลเยอร์ WireGuard ข้ามแพลตฟอร์มในแอปมือถือและเดสก์ท็อป และดูเหมือนทำงานได้ดี
คงไม่ใช้ Go สร้าง native UI แต่สำหรับงานระดับต่ำถือว่ายอดเยี่ยม
TinyGo ยังทำให้เขียน Go สำหรับไมโครคอนโทรลเลอร์หรือ WebAssembly ได้ด้วย แม้มีหลายอย่างที่ยังไม่รองรับ แต่ก็ใช้ไลบรารีมาตรฐานได้ส่วนใหญ่
ตัวอย่างเช่น คอมไพเลอร์ Go เองก็เขียนด้วย Go
ด้วยการ cross-compile และไบนารีที่ค่อนข้างเล็ก ทำให้ deployment ง่ายมาก
แต่ก็จริงที่ขาด syntactic sugar และไม่ค่อยเหมาะกับ pattern matching แบบ functional style
มองดูแล้ววิธีเข้าหา parsing ค่อนข้างแปลก และรู้สึกว่าผู้เขียนยังไม่ค่อยคุ้นกับ Rust และ แนวคิดภาษาโปรแกรมมิง ที่เป็นพื้นฐานของมันเท่าไร
ลองดูบางจุด AST น่าจะง่ายขึ้นมากถ้านิยามเป็นชนิดข้อมูลเชิงพีชคณิต
ไวยากรณ์ของ sqlite ไม่น่าจะขยายแบบอยู่ ๆ ก็เพิ่มโหนดใหม่จำนวนมากจนต้องเข้ารหัสซับซ้อน
การเข้ารหัสปัจจุบันดูเหมือนรูปแบบที่คนคุ้นกับ object-oriented แต่มักไม่คุ้นกับชนิดข้อมูลเชิงพีชคณิตจะนึกออก
คำพูดที่ว่า “macro ทำงานต่างกันไปในภาษาส่วนใหญ่ แต่เหตุผลหลักคือการกำจัดโค้ดซ้ำและลดการทำซ้ำ” ก็พูดแบบเดียวกันได้กับกลไก abstraction ทุกอย่างอย่างฟังก์ชัน
คุณลักษณะที่นิยาม macro คือมันถูกรันใน เวลา compile
ถ้าอยากดูวิธีจัดโครงสร้าง parser ให้สะอาด งานวิจัยเรื่อง parser combinator อาจเป็นจุดเริ่มต้นที่ดี
ชื่อบล็อกก็เป็น “Why I love ...” และแม้คำท้วงติงจะดูมีเหตุผล แต่การชี้ว่าขาดประสบการณ์ดูไม่จำเป็นนัก
การที่ใครสักคนชอบการเขียนโปรแกรมเป็นเรื่องดี และประสบการณ์จะตามมาเอง
เรื่องนี้ทำด้วย ฟังก์ชัน ไม่ได้
จากมุมมองของคนที่เคยเขียนพาร์เซอร์เล็ก ๆ สำหรับสัญกรณ์หมากรุก Forsyth-Edwards [0] ผมคิดว่าในแง่ความเรียบง่ายและความอ่านง่าย Haskell เหนือกว่าอย่างชัดเจน
มันอ่านแทบจะเหมือน BNF และแทบไม่มีพิธีกรรมทางเทคนิค ทำให้โฟกัสกับไวยากรณ์ที่ต้องการพาร์สจริง ๆ ได้
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
ผมสงสัยว่ามีเหตุผลชัดเจนอะไรที่ทำให้ใช้แนวทางคล้ายกันใน Rust ไม่ได้
ตัวอย่างเช่น winnow [1] ดูเหมือนจะให้สไตล์เชิงประกาศได้ดีพอ และ Rust ก็มีไลบรารี parser combinator อื่น ๆ อีกหลายตัว
[1]: https://docs.rs/winnow/latest/winnow/
เพราะสามารถทำได้ด้วยฟังก์ชันง่าย ๆ ที่มีลูปเดียว
ไม่กี่วันก่อนผมเขียน “พาร์เซอร์” FEN สำหรับการทำ quad-bitboard แบบทดลอง และมันแทบเขียนออกมาเอง
เพิ่มเติมคือ ผมเป็นผู้เขียน chessIO บน Hackage
ผมเคยเขียน eBPF disassembler และ emulator ที่ทำไปได้ครึ่งหนึ่งด้วย Rust และ Rust เป็นภาษาที่ให้ความรู้สึกค่อนข้างดีสำหรับงานจำพวกพาร์ส
แต่เมื่อผู้เขียนยังไปไม่ถึง 1/6 ของกรณีศึกษาแล้วก็ต้องใช้ macro ผมรู้สึกว่ามันทำให้ข้อโต้แย้งของเขาอ่อนลง
macro ไม่ใช่การสร้างโค้ดเต็มรูปแบบก็จริง แต่ก็ไม่ได้ให้ความรู้สึกว่าเป็นการทำงานอย่างเป็น idiom ภายในภาษามากนัก
ไม่ได้จะตำหนิอะไร และผมก็คิดว่า Rust แข็งแกร่งจริง ๆ ในด้านนี้
เช่น กฎ context-free
S ::= abc|aabbcc|aaabbbccc|...สามารถพาร์สa^Nb^Nc^Nได้อย่างมีประสิทธิภาพ และนี่เป็นตัวอย่างของ context-sensitive grammarเป็นตัวอย่างง่าย ๆ แต่ในของจริงก็เห็นสิ่งคล้ายกันได้ เช่น กรณีที่ภาษายอมให้กำหนด operator ได้
Rust จัดการเรื่องแบบนี้อย่างไร
ดูเท่ดี
จากประสบการณ์ที่เคยเขียนพาร์เซอร์และ lexer ด้วย Ragel และใช้ Go, Java, C++, C ถ้ามี ตัวสร้าง boilerplate ในระดับหนึ่ง C ล้วน ๆ ก็พอ ๆ กับโค้ด Rust ที่ผู้เขียนอธิบายไว้
อาจดีกว่าด้วยซ้ำเพราะความเรียบง่าย
ตัวอย่างเช่น โค้ดที่จำเป็นสำหรับ JSON parser ส่วนใหญ่มีประมาณนี้
https://github.com/gritzko/librdx/blob/master/JSON.lex
จริง ๆ แล้ว eBNF นั้นสร้างแค่ lexer และส่วน parser ก็ไม่ได้หวือหวาเท่าไร มี 120 บรรทัดและค่อนข้างซ้ำ ๆ
https://github.com/gritzko/librdx/blob/master/JSON.c
สุดท้ายผมคิดว่าโครงสร้างพื้นฐานของพาร์เซอร์วิวัฒน์ไปถึงจุดที่แค่มี eBNF ก็สร้างพาร์เซอร์ได้ และนั่นคือจุดอิ่มตัว
ผมรู้สึกว่า algebraic data types ของ Rust ทำให้การจัดการ syntax tree ที่สร้างขึ้นมาง่ายกว่ามาก
แต่ก็เห็นด้วยว่า code generation หรือเวทมนตร์ macro เล็กน้อยสามารถทำให้ C ใช้งานได้ดีขึ้นมาก
แต่โค้ดตรงนี้
https://github.com/gritzko/librdx/blob/master/JSON.lex
ไม่ได้ยอมรับ
[เป็น JSON ที่ถูกต้องหรือdelimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;ดูเหมือนว่าใน JSON จะเลือก delimiter ได้ตัวเดียว แล้วที่เหลือเลือกเป็น 0 ตัวได้ทั้งหมด
ปกติผมเริ่มจากดู RFC ก่อน
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
ผมก็ไม่แน่ใจด้วยว่า Ragel สามารถ implement JSON ได้หรือไม่
เท่าที่รู้ Ragel จัดการได้เฉพาะ regular language ส่วน JSON เป็น context-free language
https://en.wikipedia.org/wiki/Cloudbleed
เกี่ยวกับเรื่องนี้ ผมชอบทอล์กของ Rob Pike เรื่อง lexical scanning ใน Go
เป็นแนวทางที่ให้ความรู้และสง่างาม
https://www.youtube.com/watch?v=HxaD_trXwRE
เหตุผลน่าจะเป็นเพราะ overhead ของการ scheduling goroutine หรือรูปแบบการจัดสรรหน่วยความจำที่ไม่มีประสิทธิภาพ
การถกเถียงที่ดีที่สุดเท่าที่พบคือ [1]
อีกทอล์กที่ยอดเยี่ยมเกี่ยวกับการสร้าง lexer และ parser ที่มีประสิทธิภาพ คือ “Practical Data Oriented Design” ของ Andrew Kelley [2]
โดยสรุปคือ อธิบายกลยุทธ์หลายอย่างในการลดการใช้หน่วยความจำของโปรแกรม และทำให้เป็นมิตรต่อแคชเพื่อเพิ่ม throughput
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
สำหรับผม มีประสบการณ์หนึ่งที่น่าประหลาดใจ
ผมสามารถนำ parser combinator library ที่ใช้กับ parser ของคอมไพเลอร์ระดับสูงไปใช้แบบเดิมในสภาพแวดล้อม no-std, คอมไพล์สำหรับไมโครคอนโทรลเลอร์ แล้ว deploy เป็น parser โปรโตคอลประสิทธิภาพสูงใน embedded environment ได้
ใช้ไลบรารีเดียวกันแบบเดิมเลย
ความต่างก็แค่ลดการใช้ String และใช้
&'static strมากขึ้นเท่านั้นดังนั้นการลองเล่นกับคอมไพเลอร์จึงต่อยอดไปเป็นความสามารถในการสร้าง parser โปรโตคอลฝังตัวได้ค่อนข้างดี
สิ่งที่ยากตอนเขียน AST parser ทั้งหมดด้วย Rust คือการแสดงลำดับชั้นของชนิด AST แบบ concrete รวมถึงการ upcast และ downcast
ก็หาวิธีได้อยู่ แต่ต้องใช้การเล่นชนิดแปลก ๆ อย่าง
PhantomDataกับ macroตรงนี้ก็น่าจะต้องใช้ macro ค่อนข้างเกินพอดีเหมือนกัน
อยากรู้ว่างานก่อนหน้าที่เกี่ยวข้องมีหน้าตาเป็นอย่างไร
จนกว่าจะไปถึง upcast/downcast นั่นแหละ
ผมยังมีประสบการณ์กับ Rust ไม่พอ เลยไม่รู้ว่ามีวิธีจัดการที่ดีหรือไม่
dynamic trait อาจเป็นไปได้
macro code แบบนี้ debug กันอย่างไร หรือคนที่เพิ่งเข้ามาใน codebase จะเข้าใจได้อย่างไร
ต่อให้ดูจุดที่ใช้ macro
node!และ definition ของ macro ก็ดูเหมือนจะยากที่จะรู้ว่า code จริง ๆ ที่ถูก generate ออกมาคืออะไรสงสัยว่าควรรันตัวอย่างแล้วดูว่า type hint อะไรออกมา, hover ใน IDE แล้วดูเวอร์ชันที่ expand แล้วได้ไหม, หรือถ้าจะให้แน่ใจต้องอ้างอิงโค้ดที่ compile แล้ว
เพราะทำงานแต่กับ JS/TS และไม่ได้แตะ macro เลย เลยสงสัย workflow แบบนี้
$ cargo expandแล้วจะดูโค้ดผลลัพธ์ได้จริง ๆ Rust ค่อนข้างใกล้เคียงกับการเป็นหลายภาษา โดย “vanilla” Rust, declarative macro และ procedural macro ต่างก็มีความสามารถและ dialect ที่ต่างกันเล็กน้อย
เมื่อเวลาผ่านไปก็จะคุ้นเคยกับการจัดการแต่ละแบบ
unit test ก็เป็นพื้นที่ทดลองที่ดีในการทำความเข้าใจผลกระทบของการแก้ macro
ไม่ได้แย่มาก แต่ใน codebase ยิ่งมี procedural macro น้อยเท่าไรก็ยิ่งดี
declarative macro จะเข้าใจง่ายกว่าเล็กน้อย และบำรุงรักษากับทดสอบได้ง่ายกว่ามาก
กับ code generation ที่ทึบในภาษาอื่น ๆ ก็รู้สึกคล้ายกัน
การ parse ไวยากรณ์ sqlite ก็ขอให้โชคดี
เมื่อไม่กี่ปีก่อน ผมต้องเขียน parser สำหรับ subset ที่ค่อนข้างเล็กของ sqlite ในงาน
ผมชอบ sqlite มาก และมันเป็นแหล่งแรงบันดาลใจเสมอ
railroad diagram มีประโยชน์มหาศาล
https://www.sqlite.org/syntaxdiagrams.html
ผมคิดว่า lemon parser generator ยังไม่ได้รับการยอมรับเท่าที่ควร
https://sqlite.org/src/doc/trunk/doc/lemon.html
ในแง่การเลือกภาษา ภาษาอะไรก็ได้ที่มีชนิดข้อมูลเชิงพีชคณิตก็เหมาะดี
แม้แต่ TypeScript ก็อาจยอดเยี่ยมสำหรับงานนี้ได้
เมื่อก่อนผมเคยเขียนบทความเกริ่นนำเล็ก ๆ เกี่ยวกับการเขียน parser ด้วยมือใน Rust ด้วย
https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...