ข้าม​ไป​ยัง​เนื้อหา

AST — ต้นไม้​ที่​เป็น enum ไม่ใช่ Visitor pattern

scanner คาย​ลำดับ token ออก​มา​เป็น​เส้นตรง แต่​ความหมาย​ของ​โปรแกรม​ไม่​ได้​เรียง​เป็น​เส้น

1 + 2 * 3 มี token ห้า​ตัว​ต่อ​กัน และ​ไม่มี​ตัว​ไหน​บอกว่า * ต้อง​จับ​ก่อน + ความ​จริง​ข้อ​นั้น​อยู่​ใน​กฎ​ของ​ภาษา ไม่​ได้​อยู่​ใน​ลำดับ

ด่าน​ที่​สอง​จึง​ต้อง​เปลี่ยน​เส้นตรง​ให้​เป็นต้นไม้ บท​นี้​ออกแบบ​ต้นไม้​นั้น ส่วน​บท​ที่ 3 คือ​คน​สร้าง​มัน

หัวข้อ​นี้​บอกว่า​ทำไม​ความหมาย​ของ​โปรแกรม​ต้อง​อยู่​ใน​รูป​ต้นไม้ ไม่ใช่​ลำดับ​ที่​แบน​ราบ​แบบ token

ASTAbstract Syntax Tree (AST)โครงสร้าง​ต้นไม้​ที่​แทน​ความหมาย​ของ​โปรแกรม หลัง​จาก​ตัด​รายละเอียด​ที่​ไม่​จำเป็น​อย่าง​วงเล็บ​กับ​ช่องว่าง​ทิ้ง​ไป​แล้ว — ใน​คอร์ส​นี้​เขียน​เป็น `enum` ของ Rust ไม่ใช่​ลำดับ​ชั้น​คลาส คือ​รูป​ที่​เก็บ “อะไร​อยู่​ใต้​อะไร” ได้ ซึ่ง​เป็น​สิ่ง​ที่​ลำดับ​เก็บ​ไม่​ได้

ใน 1 + 2 * 3 การ​คูณ​ต้อง​เกิด​ก่อน วิธี​เขียน​ความ​จริง​ข้อ​นี้​ลง​ใน​โครงสร้าง​คือ​ให้การ​คูณ​เป็น ลูก ของ​การ​บวก ไม่ใช่​พี่น้อง​ที่​ยืน​เรียง​กัน

คำ​ว่า abstract ใน​ชื่อ​หมาย​ถึง​สิ่ง​ที่​ถูก​ทิ้ง — ช่องว่าง comment และ ; ไม่มี​ที่​ใน​ต้นไม้ เพราะ​หน้าที่​ของ​มัน​จบ​ไป​แล้ว​ตั้งแต่​ตอน​ที่​รูปร่าง​ถูก​กำหนด

flowchart TD
    A["Binary · op = Add"] --> B["Literal · 1"]
    A --> C["Binary · op = Mul"]
    C --> D["Literal · 2"]
    C --> E["Literal · 3"]

คำ​บรรยาย​ภาพ: ต้นไม้​ของ 1 + 2 * 3 · การ​คูณ​อยู่​ใต้​การ​บวก ลำดับ​ความ​สำคัญ​จึง​ไม่ใช่​กฎ​ที่​เขียน​แยก​ไว้​ที่ไหน มัน​คือ​ความ​ลึก​ของ node

หัวข้อ​นี้​อธิบาย​ว่า Visitor pattern มี​ไว้​ให้​เพิ่ม​การ​ทำงาน​ใหม่​บน​ต้นไม้​โดย​ไม่​ต้อง​ไล่​แก้ class ของ node ทุก​ตัว และ​ราคา​ของ​มัน​คือ double dispatch

ตำรา​ต้นทาง​ประกาศ base class ชื่อ Expr แล้ว​แตก class ลูก​ออก​ไป​หนึ่ง​ตัว​ต่อ​หนึ่ง​รูปแบบ​ของ​นิพจน์

ปัญหา​โผล่​ตอน​จะ​เพิ่ม​การ​ทำงาน​ใหม่​บน​ต้นไม้ คอร์ส​นี้​เพิ่ม​สาม​อย่าง คือ pretty-printer ใน​บท​นี้ evaluator ใน​บท​ที่ 4 และ resolver ใน​บท​ที่ 7 ถ้า​แต่ละ​อย่าง​เป็น method ก็​ต้อง​ไป​เติม​ใน class ลูก​ทุก​ตัว

Visitor pattern ย้าย​การ​ทำงาน​ออก​มา​ไว้​ข้าง​นอก — หนึ่ง​การ​ทำงาน​เท่ากับ​หนึ่ง class ใหม่ ส่วน class ของ node เติม method accept ไว้ตัว​เดียว​แล้ว​ไม่​ต้อง​แก้​อีก

ราคา​คือ double dispatch ผู้​อ่าน​ต้อง​เดิน​สอง​ต่อ​ทุก​ครั้ง จาก accept ไปหา visit กว่า​จะ​ถึง​บรรทัด​ที่​ทำงาน​จริง

Java วัน​นี้​ไม่​เหมือน Java ใน​ตำรา

ตั้งแต่ Java 21 เป็นต้น​มา sealed interface คู่​กับ pattern matching ใน switch ทำให้​คอมไพเลอร์​ตรวจ​ความ​ครบ​ของ​กรณี​ให้​ได้ โดย​ไม่​ต้อง​มี Visitor เข้า​มา​คั่น​กลาง

ตำรา​ต้นทาง​เขียน​ก่อนหน้า​นั้น ข้อ​จำกัด​ที่​มัน​เดิน​อ้อม​จึง​เป็น​ข้อ​จำกัด​ของ Java ใน​เวลา​นั้น ไม่ใช่​ของ Java ตลอด​กาล

หัวข้อ​นี้​แสดง​ว่า enum ของ Rust เขียน “เป็น​หนึ่ง​ใน​รูปแบบ​เหล่า​นี้​เท่านั้น” ได้ตรงๆ จึง​ไม่​ต้อง​มี​ลำดับ​ชั้น​ของ class

เริ่ม​จาก​ตัว​ที่​เล็ก​ที่สุด​ใน file คือ​ค่า​คงที่​ที่​คน​เขียน​พิมพ์ลงไปตรงๆ

src/ast.rs · ค่า​คงที่​สี่​แบบ​ของ​ภาษา
/// ค่าคงที่ที่เขียนตรงๆ ใน source
#[derive(Debug, Clone, PartialEq)]
pub enum Literal {
Nil,
Bool(bool),
Number(f64),
Str(String),
}

สี่​กรณี​ข้าง​ใน​นี้​พูด​สิ่ง​ที่​ลำดับ​ชั้น​ของ class พูด​ไม่​ได้​ใน​ประโยค​เดียว คือ literal เป็น​หนึ่ง​ใน​สี่​แบบ​นี้​เท่านั้น ไม่มี​แบบ​ที่​ห้า

ต้นไม้​ของ​นิพจน์​ใช้​หลัก​เดียวกัน แค่​ยาว​กว่า

src/ast.rs · ต้นไม้​ของ​นิพจน์​ทั้ง​ต้น
/// นิพจน์ — ทุกอย่างที่ให้ค่ากลับมา
///
/// `Box` โผล่ตรงที่ต้นไม้ซ้อนตัวเอง เพราะ Rust ต้องรู้ขนาดของ `Expr` ตอนคอมไพล์
/// และ enum ที่มีตัวเองอยู่ข้างในโดยไม่ผ่าน pointer จะมีขนาดไม่สิ้นสุด
///
/// ฟิลด์ `id` อยู่บน `Variable` · `Assign` · `This` เท่านั้น — สามอย่างนี้คือสิ่งที่
/// resolver ในบทที่ 7 ต้องจดคำตอบไว้ · Java ใช้ตัว object เองเป็น key ของ map ได้
/// เพราะทุก object มี identity ติดตัว แต่ Rust ไม่มี identity ให้ยืม เราจึงติดเลขให้
/// ตั้งแต่ตอน parse (ดู `parser::parse` ที่รับ `first_id` เข้าไป)
#[derive(Debug, Clone)]
pub enum Expr {
Literal(Literal),
Grouping(Box<Expr>),
Unary { op: UnaryOp, line: usize, right: Box<Expr> },
Binary { left: Box<Expr>, op: BinaryOp, line: usize, right: Box<Expr> },
Logical { left: Box<Expr>, op: LogicalOp, right: Box<Expr> },
Variable { name: String, line: usize, id: usize },
Assign { name: String, line: usize, id: usize, value: Box<Expr> },
Call { callee: Box<Expr>, line: usize, args: Vec<Expr> },
Get { object: Box<Expr>, name: String, line: usize },
Set { object: Box<Expr>, name: String, line: usize, value: Box<Expr> },
This { line: usize, id: usize },
}

ฟิลด์ line ติด​อยู่​กับ node ที่​ทำให้​เกิด​ความ​ผิดพลาด​ตอน​รัน​ได้ เพราะ​ข้อความ error ต้อง​บอก​บรรทัด​ได้​แม้​ตอน​นั้น​ข้อความ​ต้นฉบับ​หาย​ไป​แล้ว

Grouping เก็บ​วงเล็บ​ที่​คน​เขียน​ใส่​ไว้ ทั้ง​ที่​รูปร่าง​ของ​ต้นไม้​บอก​ลำดับ​อยู่​แล้ว หัวข้อ​ผล​รัน​ข้าง​ล่าง​จะ​กลับ​มา​ที่​จุด​นี้​อีก​ครั้ง

หัวข้อ​ย่อย​นี้​อธิบาย​ว่า​ทำไม node ที่​มี​ลูก​ต้อง​ห่อ​ลูก​ด้วย Box และ​ราคา​ที่​ติด​มา​กับ​มัน

Rust ต้อง​รู้​ขนาด​ของ​ทุก​ชนิด​ตอน​คอมไพล์ ถ้า Expr มี Expr อยู่​ข้างในตรงๆ ขนาด​ของ​มัน​จะ​นิยาม​ตัวเอง​ไม่​จบ

Box ตัด​วง​นั้น​ด้วย​การ​ย้าย​ลูก​ไป​ไว้​บน heap แล้ว​ให้​ตัว node ถือ​แค่ pointer ขนาด​ของ Expr จึง​คงที่ ไม่​ว่า​ต้นไม้​จะ​ลึก​แค่​ไหน

ราคา​คือ​ทุก node ที่​มี​ลูก​ต้อง​จอง​หน่วย​ความ​จำ​แยก​ก้อน และ​การ​เดิน​ต้นไม้​กลาย​เป็นการ​ไล่​ตาม pointer ที​ละ​ต่อ นี่​เป็น​ข้อ​แลก​ที่ tree-walking interpreter รับ​ไว้​ตั้งแต่​ต้น และ​คอร์ส​นี้​ไม่​ได้​จับ​เวลา จึง​ไม่​พูด​ว่า​มัน​แพง​เท่าไร

หัวข้อ​นี้​แสดง pretty-printer ที่​เป็น function ธรรมดา​ตัว​เดียว และ​เหตุผล​ที่​คอมไพเลอร์​กัน​การ​ลืม​กรณี​ให้​ได้

src/ast.rs · พิมพ์​ต้นไม้​กลับ​เป็น​ข้อความ (ตัด​มา​บาง​ส่วน)
/// พิมพ์ต้นไม้กลับเป็นข้อความแบบ S-expression
///
/// นี่คือหลักฐานว่าต้นไม้ถูกจริง: `1 + 2 * 3` ต้องออกมาเป็น `(+ 1 (* 2 3))`
/// ไม่ใช่ `(* (+ 1 2) 3)` · ลำดับความสำคัญไม่ใช่กฎที่เขียนไว้ที่ไหน มันคือ *รูปร่าง*
/// ของต้นไม้ที่ parser สร้าง
pub fn print_expr(expr: &Expr) -> String {
match expr {
Expr::Literal(Literal::Nil) => "nil".to_string(),
Expr::Literal(Literal::Bool(b)) => b.to_string(),
Expr::Literal(Literal::Number(n)) => format_number(*n),
Expr::Literal(Literal::Str(s)) => format!("\"{s}\""),
Expr::Grouping(inner) => format!("(group {})", print_expr(inner)),
Expr::Unary { op, right, .. } => format!("({} {})", op.symbol(), print_expr(right)),

ไม่มี interface ชื่อ Visitor ไม่มี method accept การ​ทำงาน​ใหม่​บน​ต้นไม้​คือ function ใหม่​หนึ่ง​ตัว​ที่ match ต้นไม้ แล้ว​จบ​ตรง​นั้น

Pattern MatchingPattern Matchingการ​แยก​กรณี​ของ​ค่า​พร้อม​ดึง​ข้อมูล​ข้าง​ใน​ออก​มา​ใน​คราว​เดียว​ด้วย `match` ของ Rust — เป็น​สิ่ง​ที่​ทำให้ AST แบบ `enum` ไม่​ต้อง​พึ่ง Visitor pattern ทำ​สอง​อย่าง​ใน​จังหวะ​เดียว คือ​เลือก​กรณี​และ​ดึง​ข้อมูล​ข้าง​ใน​ออก​มา​พร้อม​กัน บรรทัด​ของ Unary ตั้ง​ชื่อ​ให้ op กับ right ใน​จังหวะ​เดียว​กับ​ที่​มัน​บอกว่า​นี่​คือ​กรณี​ไหน

.. ที่​ท้าย​แปล​ว่า “ฟิลด์ที่​เหลือ​ไม่​ใช้” pretty-printer ไม่​สนใจ line แต่ evaluator ใน​บท​ที่ 4 สนใจ เพราะ​มัน​คือ​ตัว​ที่​ต้อง​รายงาน​ความ​ผิดพลาด​ตอน​รัน

สิ่ง​ที่​คอมไพเลอร์​รับประกัน​คือ​ความ​ครบ ลบ​กรณี​ใด​กรณี​หนึ่ง​ออก​จาก match นี้​แล้ว​คอมไพล์​ไม่​ผ่าน ไม่ใช่​ผ่าน​แล้วไป​พัง​ตอน​รัน​กับ input ที่​ไม่มี​ใคร​ทดสอบ

หัวข้อ​นี้​บอกว่าการ​เลือก enum ทำให้​อะไร​ถูก​ลง​และ​ทำให้​อะไร​แพง​ขึ้น เทียบ​กับ​ลำดับ​ชั้น​ของ class และ​กับ Visitor pattern

ปัญหา​นี้​มีชื่อ​ของ​มัน​เอง คือ expression problem และ​ตาราง​ของ​มัน​มี​สอง​แกน แกน​หนึ่ง​คือ​ชนิด​ของ node อีก​แกน​คือ​การ​ทำงาน​ที่​เดิน​บน node

ลำดับ​ชั้น​ของ class ธรรมดา​ทำให้​แกน​ชนิด​ถูก เพิ่ม class ลูก​ใหม่​หนึ่ง​ตัว​ได้​โดย​ไม่​แตะ code เดิม​เลย แลก​กับ​การ​ที่​การ​ทำงาน​ใหม่​ต้อง​ไป​เติม​ที​ละ class

Visitor pattern สลับ​ด้าน​ให้ การ​ทำงาน​ใหม่​กลาย​เป็น class ใหม่​หนึ่ง​ตัว แลก​กับ​การ​ที่ node ชนิด​ใหม่​บังคับ​ให้​แก้ interface Visitor แล้ว​ไล่​แก้ visitor ทุก​ตัว​ที่​มี​อยู่

enum กับ match ยืน​อยู่​ฝั่ง​เดียว​กับ Visitor พอดี เพิ่ม​การ​ทำงาน​ใหม่​คือ​เพิ่ม function หนึ่ง​ตัว ส่วน​การ​เพิ่ม variant ใหม่​ทำให้ match ทุก​อัน​ที่​มี​อยู่​คอมไพล์​ไม่​ผ่าน

สิ่ง​ที่ enum ทำ​ไม่​ได้​เลย ไม่ใช่​แค่​ทำ​ยาก

enum เป็น​ชุด​ปิด ไม่มี​ใคร​นอก crate นี้​เพิ่ม variant ให้ Expr ได้ ไม่​ว่า​จะ​พยายาม​แค่​ไหน

ถ้า​ต้นไม้​เป็น API สาธารณะ การ​เติม​กรณี​ใหม่​คือ breaking change ของ​ทุก​คน​ที่ match มัน​อยู่

ทาง​เลือก​ฝั่ง​ตรง​ข้าม​ใน Rust คือ trait object ซึ่ง​เปิด​ให้​เพิ่ม​ชนิด​ใหม่​จาก​นอก crate ได้ แลก​กับ​การ​ที่​การ​ทำงาน​ใหม่​ต้อง​เติม method ลง trait แล้ว​ไล่​แก้​ทุก​ชนิด​ที่ implement ไว้

ข้อ​ต่าง​ระหว่าง enum กับ Visitor จึง​ไม่​ได้​อยู่​ที่​ว่า​ต้อง​แก้​กี่​ที่ แต่​อยู่​ที่​ว่า​ใคร​เป็น​คน​หา​ที่​เหล่า​นั้น คอมไพเลอร์​ฟ้อง​ทุก​จุด​ที่ match บน Expr พร้อม​ชื่อ file และ​เลข​บรรทัด

คอร์ส​นี้​เลือก enum เพราะ​ชนิด​ของ node ใน​ภาษา​หนึ่ง​เปลี่ยน​ไม่​บ่อย ส่วน​การ​ทำงาน​บน​ต้นไม้​เพิ่ม​ขึ้น​แทบ​ทุก​บท — เลือก​ให้​แกน​ที่​ขยับ​บ่อย​เป็น​แกน​ที่​ถูก

หัวข้อ​นี้​อ่าน​ผล​ของ pretty-printer บน script ตัวอย่าง แล้ว​ชี้​ว่า​รูปร่าง​ของ​ต้นไม้​บอก​อะไร​บ้าง

script ตัวอย่าง​ของ​บท​นี้​ไม่มี​อะไร​พัง​สัก​บรรทัด ทุก​บรรทัด​มี​ไว้​ให้​เห็น​รูปร่าง​ที่​ต่าง​กัน

examples/02-ast.nok
// บทที่ 2 — ลำดับความสำคัญไม่ใช่กฎที่เขียนไว้ที่ไหน มันคือรูปร่างของต้นไม้
1 + 2;
1 + 2 * 3;
(1 + 2) * 3;
-5 + 1;
!true;
"a" + "b";
nil;

โหมด --ast สั่ง​ให้ interpreter หยุด​หลัง​ด่าน​ที่​สอง แล้ว​พิมพ์​ต้นไม้​ที่​ได้​กลับ​ออก​มา​เป็น​ข้อความ

คำ​สั่ง​ระดับ​บน​สุด​ถูก​พิมพ์​ผ่าน function อีก​ตัว​ที่ match บน Stmt แทนที่​จะ​เป็น Expr

src/ast.rs · พิมพ์​คำ​สั่ง​หนึ่ง​คำ​สั่ง (ตัด​มา​บาง​ส่วน)
/// พิมพ์คำสั่งหนึ่งคำสั่ง
///
/// `Stmt::Expression` พิมพ์เป็นต้นไม้ของมันเปล่าๆ ไม่ห่อวงเล็บอะไรเพิ่ม —
/// expression statement คือ expression ที่ทิ้งค่า ไม่มีอะไรให้พิมพ์มากกว่านั้น
pub fn print_stmt(stmt: &Stmt) -> String {
match stmt {
Stmt::Expression(e) => print_expr(e),
Stmt::Print(e) => format!("(print {})", print_expr(e)),
expected/02-ast.txt
(+ 1 2)
(+ 1 (* 2 3))
(* (group (+ 1 2)) 3)
(+ (- 5) 1)
(! true)
(+ "a" "b")
nil

อ่าน​ผล​นี้​แล้ว​เห็น​สี่​อย่าง​ที่​หัวข้อ​ก่อนหน้า​พูด​ถึง

  • 1 + 2 * 3 ออก​มา​เป็น (+ 1 (* 2 3)) ไม่ใช่ (* (+ 1 2) 3) — ลำดับ​ความ​สำคัญ​กลาย​เป็น ความ​ลึก​ไป​แล้ว และ​ไม่มี​ตาราง​กฎ​อยู่​ที่ไหน​ใน​ต้นไม้​เลย
  • วงเล็บ​ที่​คน​เขียน​ใส่​เอง​ไม่​ได้​หาย​ไป​เฉยๆ (1 + 2) * 3; ให้ (* (group (+ 1 2)) 3) ซึ่ง​มี node Grouping คั่น​อยู่ ทั้ง​ที่​รูปร่าง​ข้าง​ใน​บอก​ลำดับ​ไว้​ครบ​แล้ว
  • -5 + 1 ให้ (+ (- 5) 1) เครื่องหมาย​ลบ​หน้า​เดียว​จับ​แน่น​กว่า​การ​บวก มัน​จึง​ห่อ​แค่ 5 ไม่​ได้​ห่อ​ทั้ง​ก้อน
  • script มี​แปด​บรรทัด แต่​ผล​รัน​มี​เจ็ด​บรรทัด comment บรรทัด​แรก​ถูก​ทิ้ง​ไป​ตั้งแต่​ด่าน scanner ตอน​ถึงที่​นี่​มัน​ไม่​เคย​มี​ตัวตน​ให้​พิมพ์

หัวข้อ​นี้​บอกว่า​บท​นี้​จบ​ลง​ที่​รูปร่าง​ของ​ต้นไม้ ส่วนตัว​ที่​แปลง token เป็นต้นไม้​ยัง​ไม่มี

ทั้ง​บท​พูด​ถึง​รูปร่าง​ของ​ต้นไม้ กับ function ที่​เดิน​บน​มัน แต่​ยัง​ไม่มี​ที่ไหน​เลย​ที่​ประกอบ​ต้นไม้​ขึ้น​มา​จาก​ลำดับ token

ตัว​ที่​ทำงาน​นั้น​คือ parser ใน​บท​ที่ 3 และ​วิธี​ของ​มัน​คือ​แปลง​กฎ​ไวยากรณ์​แต่ละ​ข้อ​ให้​กลาย​เป็น function หนึ่ง​ตัว

ลำดับ​ความ​สำคัญ​ที่​บท​นี้​แสดง​เป็น​ความ​ลึก​ของ node จะ​กลาย​เป็น​ลำดับ​การ​เรียก​ของ function พวก​นั้น


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Crafting Interpreters — Robert Nystrom, บท​ที่ 5 “Representing Code” (ตรวจ​แล้ว 2026-08-12) — ที่มา​ของ​โครง​ต้นไม้​ที่​คอร์ส​นี้​ใช้ และ​เป็น​ที่​ที่ expression problem ถูก​อธิบาย​เป็น​ตาราง​สอง​แกน พร้อม​เหตุผล​ว่า​ทำไม Visitor pattern ถึง​ทำให้​แกน​การ​ทำงาน​ถูก โดย​แลก​กับ​แกน​ชนิด
  • The Rust Programming Language — บท​ที่ 15 “Using Box<T> to Point to Data on the Heap” (ตรวจ​แล้ว 2026-08-12) — หัวข้อ “Enabling Recursive Types with Boxes” อธิบาย​ว่า​ทำไม ชนิด​ที่​ซ้อน​ตัวเอง​โดย​ไม่​ผ่าน pointer ถึง​คอมไพล์​ไม่​ผ่าน ซึ่ง​เป็น​เหตุผล​เดียว​กับ​ที่ Expr ต้อง​ใช้ Box
  • Oracle — Pattern Matching for switch (Java 21) (ตรวจ​แล้ว 2026-08-12) — หลักฐาน​ว่า​คอมไพเลอร์​ของ Java ตรวจ​ความ​ครบ​ของ​กรณี​ให้​ได้​แล้ว เมื่อ​ชนิด​ที่​รับ​มา​เป็น sealed ซึ่ง​เป็น​สิ่ง​ที่​ยัง​ไม่มี​ตอน​ตำรา​ต้นทาง​ถูก​เขียน

เช็กความเข้าใจ — บทที่ 2

ข้อ 1 / 3

ถ้าเติม variant ใหม่ลงใน enum Expr แล้วไม่ไปแก้อะไรอีกเลย แล้วสั่งคอมไพล์ จะเกิดอะไรขึ้น