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

parser — ไวยากรณ์​ที่​กลาย​เป็น function

บท​ที่ 2 ออกแบบ​ต้นไม้​ไว้​เสร็จ​แล้ว แต่​ยัง​ไม่มี​ใคร​ประกอบ​มัน​ขึ้น​มา​จาก​ลำดับ token

งาน​นั้น​เป็น​ของ parser และ​วิธี​ที่​คอร์ส​นี้​ใช้​ชื่อ​ว่า Recursive DescentRecursive Descentวิธี​เขียน parser ที่​แปลง​กฎ​ไวยากรณ์​แต่ละ​ข้อ​ให้​กลาย​เป็น​ฟังก์ชัน​หนึ่ง​ตัว แล้ว​ให้​ฟังก์ชัน​เรียก​กัน​ตาม​ที่​กฎ​อ้าง​ถึงกัน อ่าน code แล้ว​เห็น​ไว​ยากรณ์ตรงๆ

หลัก​ของ​มัน​สั้น​กว่า​ที่​ชื่อ​ทำให้​กลัว คือ​กฎ​ไวยากรณ์​หนึ่ง​ข้อ​เท่ากับ function หนึ่ง​ตัว แล้ว​ปล่อย​ให้ function เรียก​กัน​ตาม​ที่​กฎ​อ้าง​ถึงกัน

หัวข้อ​นี้​แสดง​ไวยากรณ์​ทั้ง​ชุด​ที่​เขียน​ไว้​ใน​หัว​ของ parser.rs พร้อม​วิธี​อ่าน​สัญลักษณ์​ใน​นั้น

GrammarGrammarชุด​กฎ​ที่​บอกว่า​ลำดับ token แบบ​ไหน​เป็น​โปรแกรม​ที่​ถูกต้อง เขียน​เป็น​ข้อๆ แล้ว​แปลง​เป็น​ฟังก์ชัน​ของ parser ได้​ตรง​ตัว คือ​ชุด​กฎ​ที่​บอกว่า​ลำดับ token แบบ​ไหน​นับ​เป็น​โปรแกรม​ที่​ถูกต้อง

src/parser.rs · ไวยากรณ์​ของ​ภาษา nok (ตัด​มา​บาง​ส่วน)
//! program → declaration* EOF
//! declaration → classDecl | funDecl | varDecl | statement
//! classDecl → "class" IDENTIFIER "{" function* "}"
//! funDecl → "fun" function
//! function → IDENTIFIER "(" parameters? ")" block
//! varDecl → "var" IDENTIFIER ( "=" expression )? ";"
//! statement → exprStmt | printStmt | block | ifStmt | whileStmt | forStmt | returnStmt
//! expression → assignment
//! assignment → ( call "." )? IDENTIFIER "=" assignment | logic_or
//! logic_or → logic_and ( "or" logic_and )*
//! logic_and → equality ( "and" equality )*
//! equality → comparison ( ( "!=" | "==" ) comparison )*
//! comparison → term ( ( ">" | ">=" | "<" | "<=" ) term )*
//! term → factor ( ( "-" | "+" ) factor )*
//! factor → unary ( ( "/" | "*" ) unary )*
//! unary → ( "!" | "-" ) unary | call
//! call → primary ( "(" arguments? ")" | "." IDENTIFIER )*
//! primary → NUMBER | STRING | "true" | "false" | "nil" | "this"
//! | "(" expression ")" | IDENTIFIER

สัญลักษณ์​ที่​ต้อง​อ่าน​ออก​มี​สี่​ตัว

  • อ่าน​ว่า “ประกอบ​ขึ้น​จาก”
  • | คือ​อย่าง​ใด​อย่าง​หนึ่ง
  • * คือ​ซ้ำ​กี่​ครั้ง​ก็ได้ รวม​ทั้ง​ศูนย์​ครั้ง
  • ? คือ​มี​หรือ​ไม่มี​ก็ได้

ชื่อ​ของ​กฎ​กับ​ชื่อ​ของ function ใน file เดียวกัน​ตรง​กัน​ทุก​ตัว อ่าน​ไวยากรณ์​จบ​แล้ว​เดา​โครง​ของ code ออก​ทันที

การ​วาง​ไวยากรณ์​ไว้​ใน file เดียว​กับ parser ไม่ใช่​เรื่อง​ความ​สวยงาม เอกสาร​ที่​อยู่​คนละ​ที่​กับ​สิ่ง​ที่​มัน​อธิบาย​จะ​เหลื่อม​กัน​ใน​ที่สุด และ​ชั้น​ที่​ผิด​คือ​ชั้น​ที่​คน​อ่าน​ก่อน

หัวข้อ​นี้​แสดง​ว่า​ลำดับ​ความ​สำคัญ​ถูก​เข้า​รหัส​ด้วย​ลำดับ​ที่ function เรียก​กัน ไม่ใช่​ด้วย​ตาราง​ที่​เขียน​แยก​ไว้

PrecedencePrecedenceลำดับ​ความ​สำคัญ​ของ​ตัว​ดำเนิน​การ ที่​ตัดสิน​ว่า `1 + 2 * 3` หมาย​ถึง​คูณ​ก่อน​บวก — ใน recursive descent มัน​ถูก​เข้า​รหัส​ด้วย​ลำดับ​การ​เรียก​ฟังก์ชัน ไม่ใช่​ตาราง ใน​ตำรา​ส่วน​ใหญ่​ถูก​อธิบาย​ด้วย​ตาราง​ที่​ไล่​จาก​ตัว​ที่​จับ​แน่น​ที่สุด​ลง​มา ใน recursive descent ตาราง​แบบ​นั้น​ไม่มี​อยู่​จริง

src/parser.rs · term กับ factor สอง​ระดับ​ที่​ติด​กัน
/// loop `while` ที่พอกต้นไม้ทับซ้ายเรื่อยๆ คือที่มาของ "ซ้ายไปขวา":
/// `1 - 2 - 3` กลายเป็น `(- (- 1 2) 3)` ไม่ใช่ `(- 1 (- 2 3))`
fn term(&mut self) -> PResult<Expr> {
let mut expr = self.factor()?;
while self.matches(&[TokenKind::Minus, TokenKind::Plus]) {
let tok = self.previous().clone();
let op = if tok.kind == TokenKind::Minus { BinaryOp::Sub } else { BinaryOp::Add };
let right = self.factor()?;
expr = binary(expr, op, tok.line, right);
}
Ok(expr)
}
fn factor(&mut self) -> PResult<Expr> {
let mut expr = self.unary()?;
while self.matches(&[TokenKind::Slash, TokenKind::Star]) {
let tok = self.previous().clone();
let op = if tok.kind == TokenKind::Slash { BinaryOp::Div } else { BinaryOp::Mul };
let right = self.unary()?;
expr = binary(expr, op, tok.line, right);
}
Ok(expr)
}

term เรียก factor ก่อน​เสมอ แปล​ว่า factor เก็บ * กับ / ไป​ครบ​แล้ว ก่อน​ที่ term จะ​ได้​เห็น + ตัว​แรก​ด้วย​ซ้ำ

ผล​คือ​การ​คูณ​กลาย​เป็น​ลูก​ของ​การ​บวก​โดย​อัตโนมัติ และ​ไม่มี​ใคร​เขียน​กฎ​ข้อ​นั้น​ไว้​ที่ไหน​เลย

flowchart TD
    A["expression"] --> B["assignment · ="]
    B --> C["or"]
    C --> D["and"]
    D --> E["equality · == !="]
    E --> F["comparison · &lt; &lt;= &gt; &gt;="]
    F --> G["term · + -"]
    G --> H["factor · * /"]
    H --> I["unary · ! -"]
    I --> J["call · () ."]
    J --> K["primary · ตัวเลข · ข้อความ · ชื่อ · วงเล็บ"]

คำ​บรรยาย​ภาพ: บันได​ของ​การ​เรียก · ยิ่ง​อยู่​ล่าง​ยิ่ง​จับ​แน่น เพราะ​มัน​ได้​เห็น token ก่อน · ลำดับ​ความ​สำคัญ​ทั้งหมด​ของ​ภาษา​อยู่​ใน​รูป​นี้ ไม่​ได้​อยู่​ใน​ตาราง​ที่ไหน

primary ที่​อยู่​ล่าง​สุด​คือ​ค่า​เดี่ยว​ที่​ไม่มี​อะไร​จับ​แน่น​กว่า​มัน​ได้​อีก ทุก​การ​เรียก​จึง​ต้อง​ลง​ไป​สุด​บันได​ก่อน แล้ว​ค่อย​พอก​กลับ​ขึ้น​มา

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

ลำดับ​ความ​สำคัญ​ไม่มี​ที่​ให้​อ่าน​รวด​เดียว ต้อง​ไล่​ตาม​สาย​การ​เรียก​ที​ละ​ขั้น​ถึง​จะ​เห็น​ภาพ​รวม ไวยากรณ์​ใน​หัว file จึง​มี​ไว้​ชดเชย​ข้อ​นี้​โดยตรง

การ​เพิ่ม​ระดับ​ใหม่​หมาย​ถึง​เขียน function ใหม่​หนึ่ง​ตัว​แล้ว​แก้​เพื่อนบ้าน​ทั้ง​สอง​ข้าง​ให้​เรียก​ผ่าน​มัน ลืม​ข้าง​ใด​ข้าง​หนึ่ง​แล้ว​ระดับ​นั้น​หลุด​ออก​จาก​สาย​ไป​เงียบๆ โดยที่​คอมไพเลอร์​ไม่มี​ทาง​รู้

จุด​นี้​ต่าง​จาก enum ใน​บท​ที่ 2 อย่าง​ชัดเจน ที่​นั่น​การ​ลืม​กรณี​คือ​คอมไพล์​ไม่​ผ่าน ส่วน​ที่​นี่​การ​ลืม​เชื่อม​สาย​คือ code ที่​รัน​ได้​แต่​ให้​ต้นไม้​ผิด​รูป

ทุก expression ต้อง​เดิน​ลง​บันได​ครบ​ทุก​ขั้น​ก่อน​จะ​ถึง​ค่า​เดี่ยว แม้แต่​ตัวเลข​โดดๆ ตัว​เดียว​ก็​ผ่าน function ทุก​ตัว​ใน​สาย

กฎ​ที่​เขียน​แบบ​เรียก​ตัวเอง​ทาง​ซ้าย​ไม่​ได้​เลย

ถ้า​เขียน​กฎ​เป็น term → term ( "-" | "+" ) factor ตาม​ที่​ตำรา​คณิตศาสตร์​นิยม​เขียน​กัน function term จะ​เรียก​ตัวเอง​เป็น​อย่าง​แรก​โดย​ยัง​ไม่​ได้​กิน token สัก​ตัว แล้ว​วน​ไม่​จบ

ข้อ​จำกัด​นี้​เรียก​ว่า left recursion และ​เป็น​เหตุผล​ที่​ไวยากรณ์​ข้าง​บน​เขียน​กฎ​เดียวกัน​นี้​เป็น term → factor ( ( "-" | "+" ) factor )* แทน คือ​กิน​ตัว​ซ้าย​ก่อน​แล้ว​ค่อย​วน

recursive descent แลก​ความ​อ่าน​ง่าย​มา​ด้วย​การ​ที่​ไวยากรณ์​ต้อง​ถูก​จัด​รูป​ให้​เข้า​กับ​มัน​ก่อน

หัวข้อ​นี้​แสดง​ว่า​รูปแบบ​ของ code ตัดสิน​ทิศ​ที่​ตัว​ดำเนิน​การ​ระดับ​เดียวกัน​จับ​คู่​กัน คือ loop ให้​ซ้าย​ไป​ขวา ส่วน​การ​เรียก​ตัวเอง​ให้​ขวา​ไป​ซ้าย

AssociativityAssociativityทิศ​ที่​ตัว​ดำเนิน​การ​ระดับ​เดียวกัน​จับ​คู่​กัน — `1 - 2 - 3` จับ​จาก​ซ้าย​จึง​เป็น `(1 - 2) - 3` ส่วน​การ​กำหนด​ค่า​จับ​จาก​ขวา ตอบ​คำถาม​คนละ​ข้อ​กับ​ลำดับ​ความ​สำคัญ

ใน 1 - 2 - 3 เครื่องหมาย​ลบ​สอง​ตัว​อยู่​ระดับ​เดียวกัน คำถาม​คือ​ตัว​ไหน​จับ​คู่​ก่อน และ​คำ​ตอบ​เปลี่ยน​ค่าที่ได้จริงๆ เพราะ​การ​ลบ​ไม่​ได้​ให้​ผล​เท่า​กัน​ทั้ง​สอง​ทาง

while ใน term พอก​ต้นไม้​ทับ​ซ้าย​ไป​เรื่อยๆ รอบ​ที่​สอง​รับ​ต้นไม้​ของ​รอบ​แรก​มา​เป็น​ลูก​ฝั่ง​ซ้าย ผล​จึง​เป็น​ซ้าย​ไป​ขวา​โดย​ไม่​ต้อง​ประกาศ​อะไร​เพิ่ม

ตรง​ข้าม​กัน​คือ unary ที่​เรียก​ตัวเอง​แทนที่​จะ​วน

src/parser.rs · unary เรียก​ตัวเอง จึง​จับ​จาก​ขวา
fn unary(&mut self) -> PResult<Expr> {
if self.matches(&[TokenKind::Bang, TokenKind::Minus]) {
let tok = self.previous().clone();
let op = if tok.kind == TokenKind::Bang { UnaryOp::Not } else { UnaryOp::Neg };
// เรียกตัวเอง ไม่ใช่ loop — `- - 3` จึงจับขวาไปซ้าย
let right = self.unary()?;
return Ok(Expr::Unary { op, line: tok.line, right: Box::new(right) });
}
self.call()
}

assignment ใช้​ท่า​เดียวกัน มัน​เรียก​ตัวเอง​ที่​ฝั่ง​ขวา​ของ = ผล​รัน​ของ​บท​นี้​จึง​มี (= a (= b 3)) ที่​ซ้อน​เข้าไป​ทาง​ขวา ไม่ใช่​ทาง​ซ้าย

flowchart TD
    subgraph LEFT["while · ซ้ายไปขวา · ได้ (- (- 1 2) 3)"]
        A1["ลบ ตัวนอก"] --> A2["ลบ ตัวใน"]
        A1 --> A3["3"]
        A2 --> A4["1"]
        A2 --> A5["2"]
    end
    subgraph RIGHT["เรียกตัวเอง · ขวาไปซ้าย · ได้ (- 1 (- 2 3))"]
        B1["ลบ ตัวนอก"] --> B2["1"]
        B1 --> B3["ลบ ตัวใน"]
        B3 --> B4["2"]
        B3 --> B5["3"]
    end

คำ​บรรยาย​ภาพ: ต้นไม้​ของ 1 - 2 - 3 สอง​แบบ · ฝั่ง​ซ้าย​คือ​สิ่ง​ที่ loop ให้ ฝั่ง​ขวา​คือ​สิ่ง​ที่​การ​เรียก​ตัวเอง​ให้ · ทั้ง​สอง​รูป​นี้​คำนวณ​ออก​มา​ได้​คนละ​ค่า

หัวข้อ​นี้​อธิบาย​กลไก​ที่​ทำให้ parser รายงาน​จุด​พัง​ได้​หลาย​จุด​ใน​รอบ​เดียว และ​ราคา​ที่​ติด​มา​กับ​กลไก​นั้น

parser ที่​หยุด​ที่ error แรก​บังคับ​ให้​คน​แก้​ที​ละ​จุด​แล้ว​รัน​ใหม่ วน​อยู่​อย่าง​นั้น​จนกว่า​จะ​หมด

src/parser.rs · จุด​เดียว​ใน file ที่​กู้​ตัว​จาก error
/// จุดเดียวใน file นี้ที่ *กู้* จาก error
///
/// ถ้า parser ยอมแพ้ที่ error แรก ผู้เรียนจะเห็นทีละหนึ่งข้อ แก้แล้วรันใหม่ วนไป
/// `synchronize()` จึงทิ้ง token จนถึงจุดที่น่าจะเป็นต้นคำสั่งถัดไป แล้วอ่านต่อ
/// ผลคือ file ที่พังสองจุดรายงานสองข้อในรอบเดียว
fn declaration(&mut self) -> Option<Stmt> {
let result = if self.matches(&[TokenKind::Class]) {
self.class_declaration()
} else if self.matches(&[TokenKind::Fun]) {
self.function("function").map(Stmt::Function)
} else if self.matches(&[TokenKind::Var]) {
self.var_declaration()
} else {
self.statement()
};
match result {
Ok(stmt) => Some(stmt),
Err(e) => {
self.errors.push(e);
self.synchronize();
None
}
}
}

declaration เป็น​จุด​เดียว​ใน file ที่​ดัก error ไว้​แทนที่​จะ​ส่ง​ต่อ​ขึ้น​ไป มัน​จด​ข้อความ​ไว้ เรียก​กลไก​ที่​เรียก​ว่า SynchronizationSynchronizationการพา parser กลับ​สู่​จุด​ที่​มั่นใจ​ได้​หลัง​เจอ error เพื่อ​ให้​รายงาน​ความ​ผิดพลาด​ที่​เหลือ​ใน file ต่อ​ได้ แทนที่​จะ​พ่น​ความ​ผิดพลาด​ลวง​เป็น​พรวน แล้ว​คืน None เพื่อ​บอกว่า​รอบ​นี้​ไม่​ได้​ต้นไม้

src/parser.rs · ทิ้ง token จนถึง​จุด​ที่​น่า​จะ​เริ่ม​ใหม่​ได้
/// ทิ้ง token จนถึงจุดที่น่าจะเป็นต้นคำสั่งถัดไป
///
/// "น่าจะ" คือคำที่ถูกต้อง — มันเดาเอา ไม่ได้รู้จริง จุดตัดคือหลัง `;`
/// หรือก่อนคำสงวนที่ขึ้นต้นคำสั่งได้ ผลที่ตามมาคือบางครั้งรายงาน error ปลอม
/// ตามหลังของจริง ซึ่งเป็นราคาที่ยอมจ่ายเพื่อให้รายงานได้หลายข้อในรอบเดียว
fn synchronize(&mut self) {
if !self.is_at_end() {
self.advance();
}
while !self.is_at_end() {
if self.previous().kind == TokenKind::Semicolon {
return;
}
match self.peek().kind {
TokenKind::Class
| TokenKind::Fun
| TokenKind::Var
| TokenKind::For
| TokenKind::If
| TokenKind::While
| TokenKind::Print
| TokenKind::Return => return,
_ => {
self.advance();
}
}
}
}

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

ราคา​ของ​การ​เดา และ​ราคา​ของ​การ​ไม่​กู้​เลย

เดา​ผิด​ได้​สอง​ทาง ทาง​หนึ่ง​คือ​ทิ้ง code ที่​ยัง​ดี​อยู่​ไป​ด้วย อีก​ทาง​คือ​หยุด​เร็ว​เกิน​จน​พ่น error ปลอม​ตาม​หลัง​ของ​จริง

แต่​การ​ไม่​กู้​เลย​แย่​กว่า​นั้น ถ้า​เปลี่ยน synchronize เป็นการ​ข้าม​ไป​หนึ่ง token แล้ว​อ่าน​ต่อ​ทันที ตัวอย่าง​ของ​บท​นี้​จะ​พ่น error ชุด​ใหม่​เพิ่ม​ขึ้น​มา​ทั้ง​ที่​จุด​พัง​จริง​เท่า​เดิม

และ​แย่​กว่า​นั้น​อีก​คือ​เศษ​ของ​คำ​สั่ง​ที่​พัง​ไป​แล้ว​จะ​ถูก​อ่าน​ต่อ​เป็น​คำ​สั่ง​ใหม่ แล้ว​โผล่​ออก​มา​เป็นต้นไม้​ปลอมปน​อยู่​กับ​ต้นไม้​จริง

หัวข้อ​นี้​อ่าน​ผล​ของ parser บน​ตัวอย่าง​ที่​จงใจ​พัง​หลาย​จุด แล้ว​ชี้​ว่า​อะไร​พิสูจน์​อะไร

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

examples/03-parser.nok
// บทที่ 3 — ไวยากรณ์กลายเป็น function และ parser ที่พังต้องกู้ตัวเองให้ได้
1 + 2 * 3 - 4 / 2;
1 - 2 - 3;
- - 3;
1 < 2 == true;
a = b = 3;
// ห้าจุดข้างล่างพังคนละแบบ · parser ต้องรายงานให้ครบในรอบเดียว ไม่ใช่หยุดที่จุดแรก
1 +* 2;
var = 9;
h(1 2);
"parser กู้ตัวกลับมาอ่านต่อได้" + "!";
fun f(1) {}
fun g(a b) {}
// พังที่ท้าย file พอดี — ไม่มี token ให้ชี้แล้ว ตัวชี้ตำแหน่งจึงเป็น EOF
print 1 +

โหมด --ast พิมพ์​ต้นไม้​ที่​ประกอบ​สำเร็จ​ออก​มา​ก่อน​ทั้งหมด แล้ว​ค่อย​ตาม​ด้วย error ทั้งหมด

expected/03-parser.txt
(- (+ 1 (* 2 3)) (/ 4 2))
(- (- 1 2) 3)
(- (- 3))
(== (< 1 2) true)
(= a (= b 3))
(+ "parser กู้ตัวกลับมาอ่านต่อได้" "!")
[บรรทัด 9] ผิดพลาดตอนแยกไวยากรณ์ ที่ '*': ต้องการ expression
[บรรทัด 10] ผิดพลาดตอนแยกไวยากรณ์ ที่ '=': ต้องมีชื่อตัวแปรตามหลัง 'var'
[บรรทัด 11] ผิดพลาดตอนแยกไวยากรณ์ ที่ '2': ต้องมี ')' ปิดรายการ argument
[บรรทัด 13] ผิดพลาดตอนแยกไวยากรณ์ ที่ '1': ต้องมีชื่อ parameter
[บรรทัด 14] ผิดพลาดตอนแยกไวยากรณ์ ที่ 'b': ต้องมี ')' ปิดรายการ parameter
[บรรทัด 18] ผิดพลาดตอนแยกไวยากรณ์ ที่ EOF: ต้องการ expression

ผล​นี้​พิสูจน์​สี่​อย่าง​ที่​หัวข้อ​ก่อนหน้า​อ้าง​ไว้

  • ลำดับ​ความ​สำคัญ​กลาย​เป็น​ความ​ลึก​จริง 1 + 2 * 3 - 4 / 2; ออก​มา​เป็น (- (+ 1 (* 2 3)) (/ 4 2)) ทั้ง​การ​คูณ​และ​การ​หาร​จม​อยู่​ใต้​เครื่องหมาย​บวก​ลบ
  • สอง​ทิศ​ของ​การ​จับ​คู่​เห็น​ได้​ใน​บรรทัด​ติด​กัน 1 - 2 - 3; ให้ (- (- 1 2) 3) ซึ่ง​ซ้อน​ไป​ทาง​ซ้าย ส่วน - - 3; ให้ (- (- 3)) ซึ่ง​มา​จาก function ที่​เรียก​ตัวเอง
  • synchronize ทำงาน​จริง จุด​พัง​หก​จุด​ถูก​รายงาน​ครบ​ใน​รอบ​เดียว และ​บรรทัด 12 ที่นั่ง​อยู่​ระหว่าง​จุด​พัง ยัง​ถูก​ประกอบ​เป็น (+ "parser กู้ตัวกลับมาอ่านต่อได้" "!") ออก​มา​ได้​ตาม​ปกติ
  • สอง​เส้นทาง​ของ​การ​กู้​ตัว​ถูก​ใช้​คนละ​ที่ จุด​พัง​บน​บรรทัด 11 จบ​ด้วย ; parser จึง​กลับ​มา​ที่​บรรทัด​ถัด​ไป​ได้ ส่วน​จุด​พัง​บน​บรรทัด 13 ไม่มี ; ให้​เกาะ มัน​จึง​เดิน​ต่อ​ไป​จน​เจอ​คำ​สงวน fun ของ​บรรทัด​ถัด​ไป​แล้ว​เริ่ม​ใหม่​ตรง​นั้น

หัวข้อ​ย่อย​นี้​อธิบาย​ว่า​ทำไม error สุดท้าย​ถึง​ชี้​ไป​ที่ EOF แทนที่​จะ​ชี้​ไป​ที่​เครื่องหมาย​สัก​ตัว

src/parser.rs · ข้อความ​ที่​บอกว่า​พัง​ตรง token ไหน
/// ข้อความสั้นๆ ที่บอกว่าพังตรง token ไหน
fn describe(tok: &Token) -> String {
if tok.kind == TokenKind::Eof { "EOF".to_string() } else { format!("'{}'", tok.lexeme) }
}

token ทุก​ตัว​มี lexeme ให้​หยิบ​มา​ใส่​ใน​ข้อความ ยกเว้น​ตัว​เดียว​คือ Eof ซึ่ง​ไม่​ได้​กิน​อักขระ​อะไร​เลย

ถ้า​ปล่อย​ให้​มัน​พิมพ์ lexeme ของ​ตัวเอง ผู้​อ่าน​จะ​ได้​ข้อความ​ที่​มี​เครื่องหมาย​คำ​พูด​ว่างเปล่า​อยู่​ตรง​กลาง แล้ว​ต้อง​เดา​เอง​ว่า​หมาย​ถึง​อะไร

บรรทัด​สุดท้าย​ของ​ผล​รัน​จึง​เป็น [บรรทัด 18] ผิดพลาดตอนแยกไวยากรณ์ ที่ EOF: ต้องการ expression ซึ่ง​บอก​ทั้ง​ว่า​พัง​เพราะ​อะไร และ​บอกว่า​ไม่มี token เหลือ​ให้​ชี้​แล้ว

เทียบ​กับ [บรรทัด 10] ผิดพลาดตอนแยกไวยากรณ์ ที่ '=': ต้องมีชื่อตัวแปรตามหลัง 'var' ที่​ยัง​มี token ให้​ชี้ ข้อความ​จึง​ยก​เครื่องหมาย​ตัว​นั้น​มา​แสดงตรงๆ

ต้นไม้​พร้อม​แล้ว แต่​ยัง​ไม่มี​ใคร​อ่าน​ค่า​ออก​มา

หัวข้อ​ที่​มีชื่อ​ว่า “ต้นไม้​พร้อม​แล้ว แต่​ยัง​ไม่มี​ใคร​อ่าน​ค่า​ออก​มา”

หัวข้อ​นี้​บอกว่า​บท​นี้​จบ​ที่​ต้นไม้​ที่​ถูกต้อง ส่วน​การ​หา​ว่า​ต้นไม้​แต่ละ​ต้น​มี​ค่า​เท่าไร​เป็น​เรื่อง​ของ​บท​ถัด​ไป

สิ่ง​ที่​ได้​จาก​บท​นี้​คือ​ต้นไม้​ที่​มี​รูปร่าง​ถูกต้อง และ​รายการ​จุด​พัง​ที่​ครบ​ใน​รอบ​เดียว

แต่​ยัง​ไม่มี​ที่ไหน​เลย​ที่​ถาม​ว่า​ต้นไม้​ต้น​หนึ่ง​มี​ค่า​เท่าไร โหมด --ast หยุด​ก่อน​ถึง​คำถาม​นั้น​พอดี

บท​ที่ 4 จะ​เดิน​บน​ต้นไม้​แล้ว​ประเมิน​ค่า​ออก​มา และ​ที่​นั่น match ตัว​ใหม่​บน​ต้นไม้​ตัว​เดิม จะ​ทำงาน​ที่​ตำรา​ต้นทาง​ต้อง​สร้าง visitor class ขึ้น​มา​อีก​ใบ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Crafting Interpreters — Robert Nystrom, บท​ที่ 6 “Parsing Expressions” (ตรวจ​แล้ว 2026-08-12) — ที่มา​ของ​ทั้ง​สี่​เรื่อง​ใน​บท​นี้ คือ recursive descent ที่​กฎ​หนึ่ง​ข้อ เท่ากับ function หนึ่ง​ตัว การ​แบ่ง​ไวยากรณ์​เป็น​ชั้น​ตาม​ลำดับ​ความ​สำคัญ ความ​ต่าง​ระหว่าง การ​จับ​จาก​ซ้าย​กับ​จาก​ขวา และ panic mode ที่​เป็น​ชื่อ​เดิม​ของ​กลไก synchronize ตัว code ใน​ตำรา​เขียน​ด้วย Java ส่วน​คอร์ส​นี้​เขียน Rust ใหม่​ทั้งหมด
  • The Rust Programming Language — บท​ที่ 6 “Enums and Pattern Matching” (ตรวจ​แล้ว 2026-08-12) — ที่มา​ของ Option ที่ declaration ใช้​คืน​ค่า และ​ของ match ที่ synchronize ใช้​แยก​คำ​สงวน​ออก​จาก token อื่น

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

ข้อ 1 / 3

ในไวยากรณ์ของ nok ไม่มีตารางลำดับความสำคัญอยู่เลยสักที่ ถ้าสลับสายการเรียกให้ factor เป็นตัวที่เรียก term แทนที่จะเป็น term เรียก factor แล้วให้ parser อ่านบรรทัด 1 + 2 * 3; จะได้ต้นไม้แบบไหน