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

evaluator — เดิน​บน​ต้นไม้​แล้ว​ได้​ค่า​กลับ​มา

สาม​บท​ที่​ผ่าน​มาสร้าง​ต้นไม้​ขึ้น​มา​ได้ แต่​ต้นไม้​นั้น​ยัง​ไม่​เคย​ตอบ​อะไร​ใคร​เลย

(+ 1 (* 2 3)) เป็น​รูปร่าง​ที่​ถูกต้อง ไม่ใช่​คำ​ตอบ ยัง​ไม่มี​ใคร​ถาม​มัน​ว่า​เท่ากับ​เท่าไร

บท​นี้​เพิ่ม​ด่าน​ที่​ถาม และ​คำถาม​นั้น​เล็ก​กว่า​ที่​คิด — ประเมิน node ที​ละ​ตัว แล้ว​ให้ node พ่อ เอา​ผล​ไป​ใช้​ต่อ

📦 code ของ​บท​นี้

ทุก block ของ code ใน​บทเรียน​นี้​ถูก​คัด​มา​จาก scripts/rust-interpreter/src/ ทีละ byte และ​มี​เทสต์เทียบ​ไว้ ถ้า​แก้​ใน​บทเรียน​อย่าง​เดียว​โดย​ไม่​แก้​ที่ source เทสต์จะ​แดง​ทันที

ผล​รัน​ทุก​บรรทัด​ที่​บท​นี้​แสดง คัด​มา​จาก expected/04-eval.txt ซึ่ง​เป็น stdout ที่​รัน​จริง ไม่ใช่​ผล​ที่​พิมพ์​ขึ้น​มา​เอง

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

src/interpreter.rs · หัว​ของ evaluator (ตัด​มา​บาง​ส่วน)
pub fn evaluate(&mut self, expr: &Expr) -> Result<Value, RuntimeError> {
match expr {
Expr::Literal(Literal::Nil) => Ok(Value::Nil),
Expr::Literal(Literal::Bool(b)) => Ok(Value::Bool(*b)),
Expr::Literal(Literal::Number(n)) => Ok(Value::Number(*n)),
Expr::Literal(Literal::Str(s)) => Ok(Value::str(s)),
Expr::Grouping(inner) => self.evaluate(inner),

Expr::Grouping(inner) => self.evaluate(inner) คือ​ทั้งหมด​ที่​วงเล็บ​ทำ มัน​ไม่มี​ความหมาย​ของ​ตัวเอง มัน​แค่​เปลี่ยน​รูปร่าง​ของ​ต้นไม้​ไป​แล้ว​ตั้งแต่​ตอน parse

นี่​คือ​สิ่ง​ที่​เรียก​ว่า Tree-Walking InterpreterTree-Walking Interpreterinterpreter ที่​รัน​โปรแกรม​ด้วย​การ​เดิน​บน AST แล้ว​ประเมิน​ค่า​ทีละโหนด ตรง​ข้าม​กับ​การ​คอมไพล์​ลง bytecode ก่อน — เข้าใจ​ง่าย​กว่า​และ​ช้า​กว่า​โดย​ธรรมชาติ — ไม่มี​ขั้น​แปลง​เป็น​รูปแบบ​กลาง​ก่อน ต้นไม้​ที่ parser คาย​ออก​มา​คือ​สิ่ง​ที่​ถูก​รันตรงๆ

ตำรา​ต้นทาง​ต้อง​เขียน interface Visitor กับ method accept บน​ทุก class ของ node เพราะ Java แยก​กรณี​เอง​ไม่​ได้ ที่​นี่ match ทำงาน​นั้น​ใน​ที่​เดียว และ​ลืม​กรณี​ไหน​คือ​คอมไพล์​ไม่​ผ่าน

ราคา​ของ​การ​เดิน​ต้นไม้​มี​สอง​ข้อ ข้อ​แรก​คือ​ทุก node เท่ากับ​การ​เรียก function หนึ่ง​ครั้ง ต้นไม้​ที่​ซ้อน​ลึก​จึง​กิน​กอง​ซ้อน​ลึก​ตาม​ไป​ด้วย

ข้อ​ที่​สอง​สำคัญ​กว่า — ต้นไม้​ก้อน​เดิม​ถูก​เดิน​ซ้ำ​ทุก​ครั้ง​ที่​ไหล​ผ่าน งาน​ตีความ​รูปร่าง​จึง​เกิด​ใหม่​ทุกรอบ ไม่​ได้​ถูก​ทำ​ทิ้ง​ไว้​ล่วงหน้า​เหมือน​ภาษา​ที่​คอมไพล์​ลง​รูปแบบ​อื่น​ก่อน

flowchart TD
    A["Binary · + · คืน 7"] --> B["Literal · 1 · คืน 1"]
    A --> C["Binary · * · คืน 6"]
    C --> D["Literal · 2 · คืน 2"]
    C --> E["Literal · 3 · คืน 3"]

คำ​บรรยาย​ภาพ: evaluate ถูก​เรียก​ลง​ไป​ที่​ลูก​ก่อน​เสมอ ค่า​จึง​ไหล​กลับ​ขึ้น​จาก​ใบ​ไป​หา​ราก · เลข 6 คือ​ค่า​กลาง​ที่​ไม่มี​ใคร​เห็น มัน​มี​ชีวิต​อยู่​แค่​ระหว่าง​ที่ node คูณ​คืน​ค่า​ให้ node บวก

หัวข้อ​นี้​อธิบาย​ว่า​ทำไม​ภาษา​ถึง​มี enum สอง​ตัว​สำหรับ​สิ่ง​ที่​ดูเหมือน​เป็น​เรื่อง​เดียวกัน

src/interpreter.rs · ค่า​ทุก​ชนิด​ที่​ภาษา​รู้จัก
/// ค่าทุกชนิดที่ภาษา nok รู้จัก
///
/// `Rc` โผล่ตรงชนิดที่ถูกคัดลอกบ่อยและก้อนใหญ่ (function · class · instance)
/// การ clone `Value` จึงเป็นการเพิ่มตัวนับ ไม่ใช่การคัดลอกทั้งก้อน
#[derive(Clone)]
pub enum Value {
Nil,
Bool(bool),
Number(f64),
Str(Rc<str>),
Function(Rc<Function>),
Native(Rc<NativeFn>),
Class(Rc<Class>),
Instance(Rc<Instance>),
}

Literal ใน​ต้นไม้​เก็บ​สิ่ง​ที่ เขียน​ลง​ไป​ใน source ได้ ส่วน Value เก็บ​สิ่ง​ที่ มี​อยู่​ได้​ตอน​รัน สอง​ชุด​นี้​ไม่​เท่า​กัน

ไม่มี​ใคร​พิมพ์ function ลง​ไป​เป็น​ค่า​คงที่​ใน source ได้ แต่​ตอน​รัน​มัน​เป็น​ค่า​ได้​จริง สี่​กรณี​ท้าย​จึง​ไม่มี​คู่​ใน​ต้นไม้​เลย

การ​รวม​สอง enum เป็น​ตัว​เดียว​จะ​ได้​ชนิด​ที่​มี​กรณี​ซึ่ง​เป็น​ไป​ไม่​ได้​ใน​ครึ่ง​หนึ่ง​ของ​โปรแกรม แล้ว match ทุก​ที่​ก็​ต้อง​เขียน​กรณี​ที่​ไม่มี​วัน​เกิด​ทิ้ง​ไว้

ราคา​ที่​จ่าย​คือ​มี​จุด​แปลง​ชนิด​คั่น​อยู่​ตรง​กลาง และ Value::str(s) จอง​ที่​ก้อน​ใหม่​ทุก​ครั้ง​ที่ node literal ตัว​นั้น​ถูก​ประเมิน ข้อความ​เดิม​ใน source จึง​ถูก​คัด​ลอก​ซ้ำ​ทุกรอบ​ที่​โปรแกรม​ไหล​ผ่าน​มัน

หัวข้อ​นี้​ประกาศ​กฎ​ว่า​ค่า​แบบ​ไหน​นับ​เป็น​จริง​ใน​ภาษา​นี้ และ​บอกว่า​กฎ​ที่​จำ​ง่าย​แลก​อะไร​มา

if กับ ! ต้อง​ตอบ​ให้​ได้​ว่า​ค่าที่​ไม่ใช่ boolean นับ​เป็น​จริง​หรือ​เท็จ ทุก​ภาษา​เลือก​คำ​ตอบ​นี้​เอง และ​ไม่มี​คำ​ตอบ​ไหน​ถูก​โดย​ธรรมชาติ

src/interpreter.rs · กฎ truthiness ของ​ภาษา
/// กฎ truthiness ของ nok: มีแค่ `nil` กับ `false` ที่เป็นเท็จ
///
/// `0` กับ `""` เป็นจริง — ต่างจาก Python และ JavaScript โดยตั้งใจ
/// กฎที่จำง่ายกว่าคือกฎที่คนอ่าน code คนอื่นแล้วเดาถูก
pub fn is_truthy(&self) -> bool {
!matches!(self, Value::Nil | Value::Bool(false))
}

TruthinessTruthinessกฎ​ที่​ตัดสิน​ว่า​ค่าที่​ไม่ใช่ boolean จะ​ถือ​เป็น​จริง​หรือ​เท็จ​เมื่อ​เอา​ไป​วาง​ใน​เงื่อนไข — ทุก​ภาษา​เลือก​กฎ​นี้​เอง และ​ต้อง​ประกาศ​ให้​ชัด ของ​ภาษา​นี้​จึง​สั้น​พอที่​จะ​จำ​ได้​ทั้ง​ประโยค คือ​มี​สอง​ค่า​เท่านั้น​ที่​เป็น​เท็จ นอก​นั้น​เป็น​จริง​หมด

สังเกต​ว่า is_truthy คืน bool เปล่าๆ ไม่ใช่ Result — มัน​ตอบ​ได้​เสมอ ไม่มี​ค่า​ไหน​ที่​ทำให้​คำถาม​นี้​ผิดพลาด

ราคา​ของ​กฎ​ที่​สั้น​คือ​มัน​ขัด​กับ​นิสัย​ที่​คน​พก​มา​จาก​ภาษา​อื่น คน​ที่มา​จาก Python จะ​เดา​ว่า ข้อความ​ว่าง​เป็น​เท็จ แล้ว​เดา​ผิด

กฎ​นี้​ไม่​ได้​ดี​กว่า มัน​แค่​ชัด​กว่า

ภาษา​ที่​นับ 0 กับ​ข้อความ​ว่าง​เป็น​เท็จ ทำให้​เขียน​เงื่อนไข​สั้น​ลง​ได้​จริง

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

หัวข้อ​นี้​แสดง​ว่า​ความ​ผิดพลาด​ตอน​รัน​เป็น​ค่าที่​ถูก​ส่ง​กลับ ซึ่ง​เปลี่ยน​ว่า​ใคร​เป็น​คน​รับผิดชอบ​มัน

ชนิด​ของ evaluate ประกาศ​ไว้​ตั้งแต่​บรรทัด​แรก​แล้ว​ว่า​มัน​พัง​ได้ ผู้​เรียก​จึง​หลบ​ไม่​ได้

src/interpreter.rs · ความ​ผิดพลาด​ตอน​รัน​เป็น​ชนิด​ข้อมูล​ธรรมดา
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeError {
pub line: usize,
pub message: String,
}
impl fmt::Display for RuntimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[บรรทัด {}] ผิดพลาดตอนรัน: {}", self.line, self.message)
}
}

ฟิลด์ line คือ​ของ​ที่​บท​ที่ 1 เก็บ​ไว้​ตั้งแต่​ตอน​แบ่ง​คำ แล้ว parser พา​ติด​มา​กับ​ต้นไม้ กว่า​จะ​ได้​ใช้​จริง​ก็​ตอน​นี้

Runtime ErrorRuntime Errorความ​ผิดพลาด​ที่​พบ​ตอน​รัน ไม่ใช่​ตอน​อ่าน​ไวยากรณ์ เช่น เอา​ตัวเลข​บวก​กับ​ข้อความ — ใน​คอร์ส​นี้​เดินทาง​กลับ​ด้วย `Result` ไม่ใช่ exception ต่าง​จาก​ความ​ผิดพลาด​ของ​ไวยากรณ์​ตรง​ที่​มัน​โผล่​ตอน​รัน​เท่านั้น -"abc" เป็น​รูป​ประโยค​ที่​ถูกต้อง​สมบูรณ์ ปัญหา​ของ​มัน​คือ​ชนิด​ของ​ค่า ซึ่ง​รู้​ได้​เมื่อ​มี​ค่า​จริง​แล้ว

src/interpreter.rs · operator หน้า​เดียว และ​จุด​ที่​คืน Err
Expr::Unary { op, line, right } => {
let value = self.evaluate(right)?;
match op {
UnaryOp::Not => Ok(Value::Bool(!value.is_truthy())),
UnaryOp::Neg => match value {
Value::Number(n) => Ok(Value::Number(-n)),
other => Err(RuntimeError {
line: *line,
message: format!("'-' ใช้กับ number เท่านั้น แต่ได้ {}", other.type_name()),
}),
},
}
}

เครื่องหมาย ? ตรง self.evaluate(right)? คือ​ทั้งหมด​ที่​การ​ส่ง error ขึ้น​ไป​ข้าง​บน​ต้อง​เขียน ถ้า​ลูก​คืน Err มา function นี้​จบ​ทันที​ด้วย Err ตัว​เดียวกัน

ความ​ต่าง​จาก exception ไม่​ได้​อยู่​ที่​ผลลัพธ์ มัน​อยู่​ที่​ว่า​ใคร​ถูก​บังคับ​ให้​รู้ — throw ข้าม​ชั้น​กลาง​ไป​ได้​เงียบๆ ส่วน ? ต้อง​เขียน​ไว้​ที่ชั้นนั้นจริงๆ ทุก​ชั้น

ราคา​ที่​จ่าย​คือ​เสียง​รบกวน ? โผล่​ทุก​บรรทัด​ที่​มี​โอกาส​พัง และ​ชั้น​กลาง​ที่​ไม่​ได้​อยาก​ยุ่ง​กับ error ก็​ยัง​ต้อง​เขียน​มัน​อยู่ดี

ใน​แขน UnaryOp::Not ไม่มี ? เพิ่ม​อีก​เลย เพราะ is_truthy ไม่มี​ทาง​พัง ส่วน UnaryOp::Neg มี​ทาง​พัง​ทาง​เดียว คือ​ได้​ค่าที่​ไม่ใช่​ตัวเลข และ​ข้อความ error บอก​ด้วย​ว่า​ได้​ชนิด​อะไร​มา​แทน

หัวข้อ​ย่อย​นี้​แสดง​ว่า match บน​คู่​ของ​ค่า​ทำให้​เห็น​ได้​ใน​ที่​เดียว​ว่า operator หนึ่ง​ตัว​รับ​ชนิด​คู่​ไหน​บ้าง

src/interpreter.rs · เครื่องหมาย​บวก​ที่​รับ​สอง​ความหมาย
// `+` เป็นตัวเดียวที่รับได้สองแบบ: บวกเลข หรือ ต่อ string
Add => match (&l, &r) {
(Value::Number(a), Value::Number(b)) => return Ok(Value::Number(a + b)),
(Value::Str(a), Value::Str(b)) => return Ok(Value::str(format!("{a}{b}"))),
_ => {
return Err(RuntimeError {
line,
message: format!(
"'+' ใช้ได้กับ number สองตัวหรือ string สองตัวเท่านั้น แต่ได้ {} กับ {}",
l.type_name(),
r.type_name()
),
});
}
},

match บน​คู่ (&l, &r) ทำให้​รายการ​ชนิด​ที่​ยอมรับ​อ่าน​ได้​เป็น​ตาราง ไม่​ต้อง​ไล่​อ่าน if ซ้อน​กัน

กรณี _ ครอบ​ทุก​คู่​ที่​เหลือ รวม​ทั้ง​เลข​บวก​กับ​ข้อความ ซึ่ง​เป็น​จุด​ที่​ภาษา​อื่น​ชอบ​เดา​ให้​แทนที่​จะ​ถาม

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

หัวข้อ​นี้​แสดง​ผล​รัน​จริง​ของ​บท​นี้ และ​อธิบาย​ว่า​ทำไม​มัน​ถึง​ยัง​รัน file ตรงๆ ไม่​ได้

examples/04-eval.nok
// บทที่ 4 — ต้นไม้ถูกประเมินเป็นค่า และ error เป็นค่าที่ส่งกลับ ไม่ใช่ข้อยกเว้นที่โยนทิ้ง
1 + 2 * 3;
(1 + 2) * 3;
7 / 2;
"nok" + " " + "lang";
1 < 2;
2 == "2";
!nil;
!0;
-"abc";
"บรรทัดบนพังแล้ว แต่ตัวถัดไปยังได้รัน";

ทุก​บรรทัด​ใน​นี้​เป็น​นิพจน์​ล้วน ไม่มี​คำ​สั่ง print สัก​ตัว เพราะ​ภาษา​ยัง​ไม่มี​คำ​สั่ง​อะไร​เลย​จนถึง​บท​หน้า

การ​รัน file แบบ​ปกติ​จะ​ประเมิน​นิพจน์​เหล่า​นี้​แล้ว​ทิ้ง​ค่า​ไป​เงียบๆ จอ​จึง​ว่างเปล่า

โหมด --eval ป้อน file เข้า REPL ที​ละ​คำ​สั่ง​แทน ซึ่ง​ทำ​สอง​อย่าง​ที่​การ​รัน file ไม่​ทำ คือ​สะท้อน​ค่า​ของ​นิพจน์​กลับ​มา และ​ไม่​หยุด​เมื่อ​คำ​สั่ง​หนึ่ง​พัง

expected/04-eval.txt
7
9
3.5
"nok lang"
true
false
true
false
[บรรทัด 10] ผิดพลาดตอนรัน: '-' ใช้กับ number เท่านั้น แต่ได้ string
"บรรทัดบนพังแล้ว แต่ตัวถัดไปยังได้รัน"

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

  • 7 / 2 ได้ 3.5 ไม่ใช่ 3 ภาษา​นี้​มี​ชนิด​ตัวเลข​เดียว​คือ f64 จึง​ไม่มี​การ​หาร​แบบ​จำนวนเต็ม ให้​เผลอ​เจอ และ​เลข​ที่​ลงตัว​ถูก​พิมพ์​โดย​ตัด​จุดทศนิยม​ทิ้ง
  • 2 == "2" ได้ false ไม่ใช่ error การ​เทียบเท่า​ตอบ​ได้​เสมอ เพราะ​ค่า​คนละ​ชนิด​ไม่​เท่า​กัน​เสมอ ต่าง​จาก < ที่​ขอตัวเลข​สอง​ตัว​และ​พัง​ถ้า​ไม่​ได้
  • !0 ได้ false เพราะ 0 เป็น​จริง​ตาม​กฎ​ที่​ประกาศ​ไว้​ข้าง​บน คน​ที่มา​จาก Python จะ​เดา​ข้อ​นี้​ผิด​เป็น​ข้อ​แรก
  • บรรทัด​สุดท้าย​ยัง​ได้​รัน ทั้ง​ที่​บรรทัด​ก่อนหน้า​พัง เพราะ error เป็น​ค่าที่​ผู้​เรียก​รับ​ไป​แล้ว​ตัดสิน​ใจ​เอง ว่า​จะ​เดิน​ต่อ​หรือ​หยุด และ REPL เลือก​เดิน​ต่อ

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

การ​เดิน​ต่อ​หลัง error เป็นการ​ตัดสิน​ใจ​ของ​ผู้​เรียก ไม่ใช่​ของ evaluator

evaluate ไม่​เคย​ตัดสิน​ใจ​แทน​ใคร มัน​แค่​คืน Err แล้ว​จบ​หน้าที่

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

หัวข้อ​นี้​ชี้​ช่องว่าง​ที่​เหลือ​หลัง​จบ​บท​นี้ ซึ่ง​เป็น​งาน​ของ​บท​ถัด​ไป

ภาษา​ตอน​นี้​คิด​เลข​ได้ ต่อ​ข้อความ​ได้ เทียบ​ค่า​ได้ และ​รายงาน​ความ​ผิดพลาด​พร้อม​เลข​บรรทัด​ได้

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

บท​ที่ 5 เติม​คำ​สั่ง​กับ​ตัวแปร​เข้าไป และ​ที่​นั่น​คือ​จุด​ที่ ownership ของ Rust จะ​เข้า​มาบอกว่า โครงสร้าง​ที่​ตำรา​ต้นทาง​วาด​ไว้ เขียนตรงๆ ใน​ภาษา​นี้​ไม่​ได้


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Crafting Interpreters — Robert Nystrom, บท​ที่ 7 “Evaluating Expressions” (ตรวจ​แล้ว 2026-08-12) — โครง​ของ​ด่าน​ประเมิน​ค่าที่​บท​นี้​เดิน​ตาม ทั้ง​การ​แยก​กฎ truthiness ออก​มา​ประกาศ​ให้​ชัด และ​การ​ให้ operator ตรวจ​ชนิด​ตอน​รัน ตัว code ใน​ตำรา​ใช้ Java และ​ใช้ exception ส่ง error ส่วน​ที่​นี่​เป็น Result ที่​คอมไพเลอร์​บังคับ​ให้​ทุก​ชั้น​รับรู้
  • The Rust Programming Language — บท​ที่ 9.2 “Recoverable Errors with Result” (ตรวจ​แล้ว 2026-08-12) — ที่มา​ของ ? และ​เหตุผล​ที่ Rust เลือก​ให้​ความ​ผิดพลาด​เป็น​ค่าที่​คืน​กลับ แทนที่​จะ​เป็น​กลไก​กระโดด​ข้าม​ชั้น

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

ข้อ 1 / 3

ในภาษา nok คำสั่ง !0; ให้ผลอะไร และเพราะอะไร