Reduce repetitive code with Rust procedural macros

ใช้ procedural macros จากโค้ดซ้ำซ้อนสิบกว่าบรรทัด เหลือบรรทัดเดียว

เมื่อก่อนคิดว่าภาษาใหม่ๆ ไม่ควรมี macros เพราะมันเพิ่มความซับซ้อนและ indirection จนทำให้โค้ดมันหาบัคยาก แต่พอมาทำโปรเจคนี้คือช่วยชีวิตเลย มันดีมากถ้าสร้างอย่างระวังและแค่เท่าที่จำเป็น

โปรเจคของเรา เรามีเคสที่ต้องใช้ enum variants with fields เช่น Foo(u16, u16), Bar ซึ่งแต่ละ enum variant ก็จะมีช่องต่างกัน ปัญหาคือ Rust มันไม่สามารถอ่านข้อมูลพวกนี้ใน compile time ได้เลย ทำได้แค่เขียน pattern matching มาในโค้ดเท่านั้น

คราวนี้พอเราทำงานกับ enum เราก็มีหลายอย่างที่ต้องทำ เช่นหาจำนวนช่อง ว่า variant นี้ เก็บข้อมูลกี่ช่อง (arity), variant นี้อยู่อันดับที่เท่าไหร่ (index), ดึงข้อมูลช่องทั้งหมดออกมาเก็บใน array (field values), ยัดข้อมูลใส่เข้าไปใน fields แต่ละช่อง (with arguments)

ซึ่ง Rust บอกว่าให้ไปเขียน pattern matching เอาเอง โค้ดก็โคตร verbose อย่างที่เห็นด้านซ้าย เวลาจะเพิ่ม variant อันนึง ก็แก้ไปเลยสี่ห้าที่ มันวุ่นวายจนก่อนหน้านี้ไม่อยากเพิ่ม instruction เข้าไปแล้ว ลำบากเกิน

สุดท้ายเลยลองเขียน procedural macros เอา เอาวะ ปรากฎว่าจริงๆ เขียนไม่ยากอย่างที่คิด เราก็อ่าน abstract syntax tree (AST) แล้วก็เอาข้อมูลตรงนั้นมาสร้าง match arms แล้วสร้างเป็น impl method ใหม่

กลายเป็นว่าโค้ดสั้นลงสุดๆ แก้ 4 ปัญหาด้วยโค้ด 4 บรรทัด เวลามี variant ใหม่ก็เพิ่มที่เดียวแล้วใช้ได้ทั้งหมดเลย แฮปปี้สุดๆ

ข้อเสียตอนเขียนคือมัน debug ยากอ่ะ ต้องใช้วิธีเรียก cargo expand บน unit tests และบนโค้ดของเรา แล้วดูว่ามัน expand มาถูกมั้ย เวลามี error ทีนี่ปวดหัวสุดๆ เพราะมันไม่ยอมโชว์ expanded code ว่ามันไปบึ้มตรงจุดไหน นั่งงมกันไป ต้องเขียนเทสต์ไม่งั้นไม่รอด


Addendum: Alternatives

ถ้าเกิดว่าเราไม่เก็บค่าเลยบน enum variants ก็ทำได้ จริงๆ จะทำเป็น flat vector เก็บค่าเหมือนที่อยู่ใน memory จริงๆ เลยก็ได้ แต่ส่วนตัวรู้สึกว่ามันต้องมาเช็คตอน runtime ว่าเราใส่ค่าถูกมั้ย เลยอยากจะใช้ enum fields เก็บค่ามากกว่า เพราะในโค้ดของ virtual machine จะใช้อะไรแบบ Op::Push(1), Op::Pop, Op::Call(0xFF) เวลาใช้งาน เลยอยากให้มัน maintain structure / safety ได้

ถ้ายังจะใช้ enum fields ต่อ ปัญหาคือเราไม่สามารถ construct enum variants จากการที่มี enum variant มาได้ครับ และเรา inspect arity ตามปกติไม่ได้ด้วย

ถ้าจะใช้ท่าแบบ snippet 2 จะไปจบที่การเขียน match arm / macros สองครั้งอยู่ดีครับ (define arity for each variants, then another matcher for filling the variant fields with value)

โจทย์ของ from คืออยาก get enum variant index (u16) จาก instruction เลยเปลี่ยนท่าจากการใช้ static hashmap lookup แบบตอนแรก เป็นการ generate static match arms ไปเลย เพราะมัน efficient กว่า

ที่ต้อง zero-fill args เพราะ partial equality มันจะเทียบโดยใช้ 0 เป็น default

Day 9 of From Opcodes to Algorithms in Rust October 8. Post on Facebook.