- โปรเจ็กต์ที่นำ บทเรียน ‘Let’s Build a Compiler’ ของ Jack Crenshaw ซึ่งเผยแพร่ในช่วงปี 1988~1995 มาสร้างใหม่ด้วย Python และ WebAssembly
- ต้นฉบับใช้ Pascal และแอสเซมบลี Motorola 68000 แต่ในการทำครั้งนี้ได้แปลงให้เป็นโค้ดที่รันได้ในสภาพแวดล้อมสมัยใหม่
- แกนสำคัญของบทเรียนคือการลงมือสร้าง recursive-descent parser ด้วยตนเอง และใช้แนวทางที่สร้างโค้ดแอสเซมบลีจริงตั้งแต่ช่วงเริ่มต้น
- วิธีของ Crenshaw ใช้ syntax-directed translation แต่เริ่มเห็นข้อจำกัดเมื่อเข้าสู่ขั้นตอนจัดการชนิดข้อมูล
- การสร้างใหม่ครั้งนี้มีความสำคัญตรงที่เป็นการ ฟื้นฟูบทเรียนคลาสสิกให้อยู่ในรูปแบบที่นักพัฒนายุคใหม่สามารถรันและทดลองได้ด้วยตนเอง
ที่มาของบทเรียนคลาสสิก
- ‘Let’s Build a Compiler’ ของ Jack Crenshaw เป็นหนังสือแนะนำการสร้างคอมไพเลอร์ที่เผยแพร่ระหว่างปี 1988~1995 และยังคงถูกอ้างถึงอยู่บ่อยครั้งจนถึงปัจจุบัน
- ต้นฉบับเขียนด้วย Pascal และสร้างผลลัพธ์เป็น โค้ดแอสเซมบลี Motorola 68000
- แม้ผ่านไป 35 ปีแล้ว ในปี 2025 ก็ยัง ถูกพูดถึงอย่างต่อเนื่องใน Hacker News และที่อื่น ๆ
- ผู้เขียนบทความได้พบกับบทเรียนนี้ครั้งแรกในปี 2003 และประทับใจมาก ก่อนจะนำกลับมาทำใหม่ด้วย Python และ WebAssembly ในปี 2025
การสร้างใหม่ด้วย Python และ WebAssembly
- ไม่ได้หยุดแค่อ่าน แต่ยัง แปลคอมไพเลอร์ในบทเรียนให้เป็น Python และทำให้สามารถสร้างผลลัพธ์เป็น WebAssembly (WASM) ได้
- ผลลัพธ์ถูกเผยแพร่ไว้ใน GitHub repository (eliben/letsbuildacompiler)
- ไฟล์
TUTORIAL.md อธิบายว่าแต่ละส่วนของต้นฉบับถูกแมปมาเป็นโค้ด Python อย่างไร
- ผู้ใช้สามารถอ่านบทเรียนต้นฉบับไปพร้อมกับทดลอง โค้ดที่รันได้จริง ด้วยตนเอง
ตัวอย่างภาษา KISS และผลลัพธ์ WASM
- มีการยกตัวอย่างโปรแกรมใน ภาษา KISS ที่ Crenshaw ออกแบบไว้ แล้วคอมไพล์ด้วยคอมไพเลอร์เวอร์ชัน Python
- ตัวอย่างมีทั้ง
procedure, ลูป while, และการ ส่งค่าพารามิเตอร์แบบ by-value และ by-reference
- WASM text ที่ได้มีตรรกะสำหรับจัดการ พารามิเตอร์อ้างอิง (by-reference parameter) รวมอยู่ด้วย และแทบไม่มีการปรับแต่งประสิทธิภาพ
- ส่วนที่ตัวแปรโกลบอล
X ถูกคืนค่าโดยนัยจากฟังก์ชัน main เป็น กลไกเพื่อความสะดวกในการทดสอบ
- โค้ด WASM ที่สร้างขึ้นถูกนำไปรันจริงเพื่อใช้ใน การทดสอบยืนยันว่าผลลัพธ์ตรงตามที่คาดไว้
จุดเด่นของบทเรียน
- บทเรียนของ Crenshaw ค่อย ๆ สร้าง recursive-descent parser แบบเป็นขั้นตอน และเน้น การสร้างโค้ดที่ทำงานได้จริง มากกว่าทฤษฎีที่ซับซ้อน
- ในยุคนั้น
lex และ yacc ถูกมองว่าเป็นมาตรฐาน แต่ ความเรียบง่ายและชัดเจนของ parser ที่เขียนเอง สร้างอิทธิพลอย่างมาก
- หลังจากนั้นอีก 20 ปี ผู้เขียนก็ยังคงชอบใช้ manual recursive-descent parser
- นอกจากนี้ ในยุคที่บทเรียนส่วนใหญ่เน้นการวิเคราะห์ไวยากรณ์เป็นหลัก Crenshaw กลับพูดถึง การสร้างโค้ดแอสเซมบลีตั้งแต่ระยะเริ่มต้น
ข้อจำกัดและโอกาสในการปรับปรุง
- บทเรียนนี้ใช้แนวทาง syntax-directed translation ที่สร้างโค้ดทันทีระหว่างการ parse
- วิธีนี้มีประโยชน์ต่อการเรียนรู้ แต่ก็มี ข้อจำกัดตรงที่ไม่รู้ข้อมูลชนิดล่วงหน้า ทำให้ปรับแต่งประสิทธิภาพได้ยาก
- โดยเฉพาะหลังส่วนที่ 14 ซึ่งเริ่มมีระบบชนิดข้อมูล จึงเห็นชัดว่าต้องการ แนวทางเชิงโครงสร้างที่ใช้ IR หรือ AST
- ไม่มีการระบุชัดเจนว่าทำไม Crenshaw จึงหยุดบทเรียนไว้หลังส่วนที่ 14 แต่ก็มีการกล่าวถึงว่า อาจเกี่ยวข้องกับข้อจำกัดนี้
- ผู้เขียนมองว่า การใช้ส่วนที่ 14 เป็นจุดเปลี่ยน แล้วสร้าง AST ก่อนทำ type checking และ code generation น่าจะเหมาะสมกว่า
บทสรุป
- บทเรียนต้นฉบับยังคงมี ความอ่านง่ายและใช้งานได้จริงยอดเยี่ยมในฐานะหนังสือแนะนำการสร้างคอมไพเลอร์
- เวอร์ชัน Python·WASM ครั้งนี้เป็น การเติมเต็มให้เหมาะกับนักพัฒนาสมัยใหม่ที่ต้องการเรียนรู้โดยไม่ต้องพึ่งเทคโนโลยีล้าสมัย
- ทุกคนสามารถทดลองได้ผ่าน GitHub repository และถือเป็น การตีความใหม่แบบร่วมสมัยที่เปิดโอกาสให้สัมผัสโครงสร้างของคอมไพเลอร์ได้โดยตรง
1 ความคิดเห็น
ความเห็นจาก Hacker News
ชอบที่บทสอนนี้ไม่จมอยู่กับรายละเอียดของฟรอนต์เอนด์ แต่เริ่มจากการสร้าง โค้ดแอสเซมบลีที่ใช้งานได้จริง ตั้งแต่ต้นเลย
แนวทางแบบ Ghuloum ที่ค่อย ๆ สร้างระบบทั้งหมดขึ้นมาจากส่วนเล็กมากของภาษาแล้วขยายเพิ่มทีละนิดนั้นน่าสนใจเป็นพิเศษ
หนังสือดี ๆ ที่เพิ่งเจอไม่นานนี้คือ ลิงก์นี้ ซึ่งแม้ C และ x86 จะไม่ใช่เป้าหมายที่เหมาะที่สุดสำหรับผู้เริ่มต้น แต่ก็เป็นแหล่งความรู้ชั้นยอดสำหรับการสร้างคอมไพเลอร์ตัวแรก
อ้างอิงเพิ่มเติม วิทยานิพนธ์ของ Ghuloum อยู่ที่นี่
ในอดีตการพาร์สเป็นส่วนที่สำคัญที่สุด หลักสูตรจึงถูกออกแบบมาแบบนั้น แต่ทุกวันนี้สิ่งที่สำคัญกว่าคือจะทำฟีเจอร์ของภาษาให้ทำงานอย่างไร
ต่อให้จะสร้างคอมไพเลอร์เอง เดี๋ยวนี้ก็เป็นยุคที่พูดว่า “Claude, ช่วยสร้าง recursive descent parser ด้วยไวยากรณ์นี้ให้หน่อย” แล้วแทบจะได้ผลลัพธ์ในครั้งเดียว
ฝั่งวิชาการมักเข้าหาแบบ layer ส่วนฝั่งปฏิบัติมักเข้าหาแบบ pipe
วิธีแบบ layer ให้โค้ดที่ build ได้ แต่รันยาก ส่วนวิธีแบบ pipe รันง่ายแต่โครงสร้างไม่ค่อยแข็งแรง
สุดท้ายแล้วหัวใจสำคัญคือการแบ่งงานพัฒนาออกเป็นหน่วยย่อยที่สุดที่ยังรันได้
คิดว่าบทความนี้จับประเด็นสำคัญได้ดีมาก
ผมสนใจคอมไพเลอร์ตั้งแต่ก่อนเข้ามหาวิทยาลัย และเอกสารชิ้นนี้คือบทนำที่เข้าถึงง่ายที่สุดเท่าที่เคยเจอ
ตอนอายุ 17 ผมลองทำ recursive descent parser เอง และได้ตระหนักว่าปัญหาที่ดูซับซ้อนก็กลายเป็นเรื่องง่ายได้ถ้าแยกมันออกเป็น primitive พื้นฐานที่เหมาะสม
มีข้อมูลเกี่ยวกับอัลกอริทึมอยู่มากมาย แต่ข้อมูลที่พูดถึงวิธีเข้าหาปัญหาด้วย primitive กลับมีน้อย
เมื่อก่อน table-driven parser อย่าง yacc เคยเป็นกระแสหลัก แต่ทุกวันนี้ recursive descent ใช้งานได้จริงและยืดหยุ่นกว่า
ด้วยพลังของการสร้างโค้ดโดย LLM วิธีนี้จึงยิ่งเข้าถึงง่ายขึ้น
สำหรับ toy compiler เพื่อการเรียนรู้ การข้าม AST, IR และการ optimize แล้วไปสร้างโค้ดโดยตรงเลยก็ยังมีประโยชน์มากพอ
เมื่อก่อนตอนทำเครื่องมือทดสอบในบริษัทที่รองรับ subset ของ C เราให้ parser คืนค่าเป็น โครงสร้างข้อมูลที่รันได้ แทนที่จะสร้างโค้ดออกมา
ตัวอย่างเช่นคลาส ForLoopStatement จะมี testExpression อยู่ภายใน และเรียกมันโดยตรงจากเมธอด Execute()
เป็นบทความสำคัญที่ถูกกล่าวถึงใน The Mythical Man-Month ของ Fred Brooks ด้วย
มันเป็นแนวคิดที่เป็นธรรมชาติและสะอาดมากในเชิงโครงสร้าง
คำอธิบายที่ว่าบทสอนของ Jack Crenshaw ใช้วิธี syntax-directed translation นั้นน่าสนใจมาก
ผมสงสัยว่านี่หมายถึงแค่ single-pass compiler หรือเป็นแนวคิดที่เฉพาะเจาะจงกว่านั้น
ผมพอเข้าใจว่าในภาษาที่มี static type จะทำ optimization ตามชนิดข้อมูลได้ยาก แต่ก็อยากรู้ว่ามันจำกัดไปถึงระดับของการตรวจชนิดข้อมูลด้วยหรือเปล่า
แต่ถ้าไม่มี IR ก็จะทำ optimization ขั้นสูงอย่าง LICM, CSE, GVN ไม่ได้
โดยส่วนตัวผมชอบ การคอมไพล์แบบ 2 pass ระดับฟังก์ชัน — pass แรกสร้าง IR แล้วสร้างโค้ดทันทีหลังจากนั้นและทิ้งสถานะไป ซึ่งให้ผลลัพธ์ที่เร็วและมีประสิทธิภาพ
ต่อให้เป็น single-pass compiler ก็ยังอาจแยกขั้นตอนภายในออกจากกัน แล้วค่อยสร้างโค้ดตอนท้ายสุดเพียงอย่างเดียวก็ได้
บทสอนของ Crenshaw อาจยังไม่ถึงระดับใช้งานจริง แต่ถ้าขยายด้วยเทคนิค precedence climbing ก็จะมีประโยชน์มากขึ้นมาก
บทความที่เกี่ยวข้องสรุปไว้ดีที่ ลิงก์นี้
ลองกลับไปดูเธรดเกี่ยวกับ “Let’s Build a Compiler” อีกครั้ง
มี ประวัติการขึ้น HN หลายครั้ง ตั้งแต่ปี 2007 ถึง 2023
โดยเฉพาะ บทสัมภาษณ์ Jack Crenshaw ที่แม้จะไม่มีคอมเมนต์ แต่ก็เป็นเรื่องอ่านที่น่าสนใจมาก
ขอแนะนำพร้อมโปรโมตโปรเจกต์ส่วนตัว: cwerg.org
ใช้ Python และ recursive descent parsing พร้อมแยกฟรอนต์เอนด์กับแบ็กเอนด์ออกจากกันด้วย IR
สร้าง ELF binary สำหรับ x86 และ ARM และตั้งเป้าให้ใช้งานจริงได้
แต่ค่อนข้างซับซ้อนและไม่ใช่สไตล์บทสอน
มีความทรงจำดี ๆ กับการเรียนบทสอนของ Jack Crenshaw จากกลุ่มข่าว comp.compilers บน USENET ในอดีต
อ้างอิงเพิ่มเติม กลุ่มนั้นยังคงเปิดใช้งานอยู่
ถ้าจะมองหาแนวทางคอมไพเลอร์สมัยใหม่ ขอแนะนำบทความบล็อกของ Cornell นี้
ทุกครั้งที่ใช้มันให้ความรู้สึกเหมือนแค่วางหินเพิ่มอีกไม่กี่ก้อนบนยอดพีระมิด
ภาษาที่ใช้ LLVM เป็นแบ็กเอนด์ต่างก็มีปัญหาร่วมกันคือ คอมไพล์ช้า
Zig มองเห็นเรื่องนี้และกำลังพัฒนาแบ็กเอนด์ของตัวเอง ส่วน Rust ก็ยังติดอยู่กับปัญหาการคอมไพล์ช้า
ผมคิดว่า LLVM ทำให้คนเข้าใจผิดว่าความซับซ้อนคือหลักประกันของคุณภาพ และเป็นเทคโนโลยีที่ ลดทอนความเป็นอิสระของนักพัฒนา
มันคล้ายกับเทคโนโลยียอดนิยมอื่นบางอย่างด้วย (ที่มีชื่อย่อคล้ายกัน)
ด้วยเหตุผลคล้ายกัน ผมก็เลยเขียนบทสอนชื่อ tiny-optimizing-compiler ขึ้นมา
เป็นตัวอย่างแบบกระชับที่ครอบคลุมเฉพาะ ขั้นตอนกลางและแบ็กเอนด์ ของคอมไพเลอร์
ลิงก์ GitHub
ตอนเด็ก ๆ ผมเคยพิมพ์บทสอนนี้ออกมาแล้วพกติดตัวไว้อ่าน
การได้เห็นคอมไพเลอร์ถูกสร้างเสร็จในเวลาไม่นานนั้นยอดเยี่ยมมากจริง ๆ
เอกสารอย่าง Beej’s Guide แบบนี้คือหนึ่งใน เอกสารที่มีคุณค่าที่สุดสำหรับการศึกษาด้าน CS แต่กลับยังไม่ได้รับการยกย่องอย่างที่ควรได้