evaluator — เดินบนต้นไม้แล้วได้ค่ากลับมา
สามบทที่ผ่านมาสร้างต้นไม้ขึ้นมาได้ แต่ต้นไม้นั้นยังไม่เคยตอบอะไรใครเลย
(+ 1 (* 2 3)) เป็นรูปร่างที่ถูกต้อง ไม่ใช่คำตอบ ยังไม่มีใครถามมันว่าเท่ากับเท่าไร
บทนี้เพิ่มด่านที่ถาม และคำถามนั้นเล็กกว่าที่คิด — ประเมิน node ทีละตัว แล้วให้ node พ่อ เอาผลไปใช้ต่อ
ทุก block ของ code ในบทเรียนนี้ถูกคัดมาจาก scripts/rust-interpreter/src/ ทีละ byte
และมีเทสต์เทียบไว้ ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ source เทสต์จะแดงทันที
ผลรันทุกบรรทัดที่บทนี้แสดง คัดมาจาก expected/04-eval.txt ซึ่งเป็น stdout ที่รันจริง
ไม่ใช่ผลที่พิมพ์ขึ้นมาเอง
เดินบนต้นไม้ คือ match ที่เรียกตัวเอง
หัวข้อที่มีชื่อว่า “เดินบนต้นไม้ คือ match ที่เรียกตัวเอง”หัวข้อนี้แสดงว่า evaluator ทั้งตัวเป็น function เดียวที่
matchบนต้นไม้ แล้วเรียกตัวเองลงไปที่ลูก
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 คนละตัวกับ literal ในต้นไม้
หัวข้อที่มีชื่อว่า “ค่าตอนรันเป็น enum คนละตัวกับ literal ในต้นไม้”หัวข้อนี้อธิบายว่าทำไมภาษาถึงมี
enumสองตัวสำหรับสิ่งที่ดูเหมือนเป็นเรื่องเดียวกัน
/// ค่าทุกชนิดที่ภาษา 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 จึงถูกคัดลอกซ้ำทุกรอบที่โปรแกรมไหลผ่านมัน
Truthiness — กฎที่ทุกภาษาต้องเลือกเอง
หัวข้อที่มีชื่อว่า “Truthiness — กฎที่ทุกภาษาต้องเลือกเอง”หัวข้อนี้ประกาศกฎว่าค่าแบบไหนนับเป็นจริงในภาษานี้ และบอกว่ากฎที่จำง่ายแลกอะไรมา
if กับ ! ต้องตอบให้ได้ว่าค่าที่ไม่ใช่ boolean นับเป็นจริงหรือเท็จ ทุกภาษาเลือกคำตอบนี้เอง
และไม่มีคำตอบไหนถูกโดยธรรมชาติ
/// กฎ 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 ต้องจำรายการค่าที่เป็นเท็จให้ครบ และรายการนั้นยาวขึ้นทุกครั้งที่ภาษาเพิ่มชนิดใหม่
error เดินทางกลับด้วย Result ไม่ใช่ exception
หัวข้อที่มีชื่อว่า “error เดินทางกลับด้วย Result ไม่ใช่ exception”หัวข้อนี้แสดงว่าความผิดพลาดตอนรันเป็นค่าที่ถูกส่งกลับ ซึ่งเปลี่ยนว่าใครเป็นคนรับผิดชอบมัน
ชนิดของ evaluate ประกาศไว้ตั้งแต่บรรทัดแรกแล้วว่ามันพังได้ ผู้เรียกจึงหลบไม่ได้
#[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" เป็นรูปประโยคที่ถูกต้องสมบูรณ์ ปัญหาของมันคือชนิดของค่า ซึ่งรู้ได้เมื่อมีค่าจริงแล้ว
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 หนึ่งตัวรับชนิดคู่ไหนบ้าง
// `+` เป็นตัวเดียวที่รับได้สองแบบ: บวกเลข หรือ ต่อ 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 ซ้อนกัน
กรณี _ ครอบทุกคู่ที่เหลือ รวมทั้งเลขบวกกับข้อความ ซึ่งเป็นจุดที่ภาษาอื่นชอบเดาให้แทนที่จะถาม
ราคาของการไม่เดาคือผู้เขียนต้องแปลงชนิดเอง ภาษาที่เดาให้จะเขียนสั้นกว่าตรงนี้
แลกกับการที่ความหมายของ + ขึ้นกับค่าที่ไหลมาถึงตอนรัน
ลองรันจริง — และทำไมบทนี้ต้องใช้โหมด —eval
หัวข้อที่มีชื่อว่า “ลองรันจริง — และทำไมบทนี้ต้องใช้โหมด —eval”หัวข้อนี้แสดงผลรันจริงของบทนี้ และอธิบายว่าทำไมมันถึงยังรัน file ตรงๆ ไม่ได้
// บทที่ 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 ไม่ทำ
คือสะท้อนค่าของนิพจน์กลับมา และไม่หยุดเมื่อคำสั่งหนึ่งพัง
793.5"nok lang"truefalsetruefalse[บรรทัด 10] ผิดพลาดตอนรัน: '-' ใช้กับ number เท่านั้น แต่ได้ string"บรรทัดบนพังแล้ว แต่ตัวถัดไปยังได้รัน"อ่านผลนี้แล้วเห็นสี่อย่างที่หัวข้อก่อนหน้าพูดถึง
7 / 2ได้3.5ไม่ใช่3ภาษานี้มีชนิดตัวเลขเดียวคือf64จึงไม่มีการหารแบบจำนวนเต็ม ให้เผลอเจอ และเลขที่ลงตัวถูกพิมพ์โดยตัดจุดทศนิยมทิ้ง2 == "2"ได้falseไม่ใช่ error การเทียบเท่าตอบได้เสมอ เพราะค่าคนละชนิดไม่เท่ากันเสมอ ต่างจาก<ที่ขอตัวเลขสองตัวและพังถ้าไม่ได้!0ได้falseเพราะ0เป็นจริงตามกฎที่ประกาศไว้ข้างบน คนที่มาจาก Python จะเดาข้อนี้ผิดเป็นข้อแรก- บรรทัดสุดท้ายยังได้รัน ทั้งที่บรรทัดก่อนหน้าพัง เพราะ error เป็นค่าที่ผู้เรียกรับไปแล้วตัดสินใจเอง ว่าจะเดินต่อหรือหยุด และ REPL เลือกเดินต่อ
ข้อความที่ถูกสะท้อนกลับมามีเครื่องหมายคำพูดติดมาด้วย ทั้งที่ตัวข้อความจริงไม่มี นี่คือรูปแบบสำหรับ REPL โดยเฉพาะ มันมีไว้ให้แยกออกว่าค่านั้นเป็นข้อความ ไม่ใช่ตัวเลขที่บังเอิญพิมพ์เหมือนกัน
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; ให้ผลอะไร และเพราะอะไร