การควบคุมทิศทาง และ function ที่เป็นค่าตัวหนึ่ง
จนถึงตอนนี้โปรแกรมของภาษา nok เดินเป็นเส้นตรงจากบนลงล่าง คำสั่งละหนึ่งครั้ง แล้วจบ
บทนี้เติมสองอย่างที่ทำให้มันเลิกเป็นเส้นตรง — ทางแยกกับการวนซ้ำ และชื่อที่เรียกซ้ำได้
ของทั้งสองอย่างวางอยู่บนกลไกที่มีอยู่แล้วเกือบทั้งหมด สิ่งที่ต้องเขียนใหม่จริงๆ มีน้อยกว่าที่คิด
สามคำสั่งควบคุมทิศทาง แต่ต้นไม้รู้จักแค่สองอัน
หัวข้อที่มีชื่อว่า “สามคำสั่งควบคุมทิศทาง แต่ต้นไม้รู้จักแค่สองอัน”หัวข้อนี้แสดงว่า
ifกับwhileเขียนตรงๆ ลงใน evaluator ได้ ส่วนforถูกคลี่เป็นwhileไปแล้วตั้งแต่ตอน parse
if กับ while แทบไม่ต้องออกแรงคิด เพราะ Rust มีทั้งสองอย่างอยู่แล้ว และความหมายของมันตรงกัน
Stmt::If { cond, then_branch, else_branch } => { if self.evaluate(cond)?.is_truthy() { self.execute(then_branch)?; } else if let Some(alt) = else_branch { self.execute(alt)?; } } Stmt::While { cond, body } => { while self.evaluate(cond)?.is_truthy() { self.execute(body)?; } }while ของ nok คือ while ของ Rust จริงๆ ไม่ใช่การจำลอง ตัวที่ควบคุมการวนคือ is_truthy
ซึ่งเป็นกฎที่บทที่ 4 ตรึงไว้แล้วว่ามีแค่ nil กับ false เท่านั้นที่เป็นเท็จ
ส่วน for ไม่มี node ของตัวเองในต้นไม้เลยสักตัว มันถูกประกอบขึ้นจากของที่มีอยู่แล้วตั้งแต่ตอน parse
let mut body = self.statement()?; if let Some(incr) = incr { body = Stmt::Block(vec![body, Stmt::Expression(incr)]); } body = Stmt::While { cond, body: Box::new(body) }; if let Some(init) = init { body = Stmt::Block(vec![init, body]); } Ok(body)อ่านจากล่างขึ้นบนแล้วเห็นรูปร่างที่ได้ — ส่วนเริ่มต้นถูกห่อไว้ชั้นนอกสุด ถัดเข้ามาคือ while
และส่วนเพิ่มค่าถูกต่อท้าย body ให้กลายเป็นคำสั่งสุดท้ายของแต่ละรอบ
เทคนิคนี้เรียกว่า desugaring และกำไรของมันชัดเจน คือ evaluator ไม่ต้องรู้จัก for เลยสักบรรทัด
สิ่งที่ผู้เขียนพิมพ์ กับสิ่งที่เครื่องมือหลังจากนี้เห็น ไม่ใช่ของอย่างเดียวกันอีกต่อไป
pretty-printer ของบทที่ 2 และ resolver ของบทที่ 7 เดินบนต้นไม้ที่คลี่แล้ว จึงพูดถึง for ไม่ได้
เพราะมันไม่มีอยู่ตรงนั้น
และถ้าวันหนึ่งภาษาอยากมี continue การคลี่แบบนี้จะกลายเป็นปัญหาทันที — continue ต้องไปทำ
ส่วนเพิ่มค่าก่อนแล้วค่อยวนรอบใหม่ แต่ในต้นไม้ที่คลี่แล้ว ส่วนเพิ่มค่าเป็นแค่คำสั่งธรรมดา
ตัวหนึ่งใน block ไม่ใช่ส่วนหัวของการวนที่กระโดดข้ามไปหาได้
and กับ or ที่คืนค่าเดิม ไม่ใช่ boolean
หัวข้อที่มีชื่อว่า “and กับ or ที่คืนค่าเดิม ไม่ใช่ boolean”หัวข้อนี้อธิบายว่า operator สองตัวนี้ตัดสินจากฝั่งซ้ายก่อน และค่าที่คืนคือค่าเดิมที่ยังไม่ถูกแปลง
// `and` / `or` ลัดวงจร: ถ้าฝั่งซ้ายตัดสินผลได้แล้ว ฝั่งขวาไม่ถูกประเมินเลย // และค่าที่คืนคือ *ค่าเดิม* ไม่ใช่ boolean ที่แปลงแล้ว Expr::Logical { left, op, right } => { let l = self.evaluate(left)?; let decided = match op { LogicalOp::Or => l.is_truthy(), LogicalOp::And => !l.is_truthy(), }; if decided { return Ok(l); } self.evaluate(right) }จุดที่ต้องอ่านช้าๆ คือ return Ok(l) มันคืน l ตัวเดิม ไม่ได้คืน Value::Bool ที่แปลงแล้ว
ผลคือ and กับ or ใช้เลือกค่าได้ ไม่ใช่แค่ถามจริงเท็จ ตัวอย่างของบทนี้ใช้ nil or "ค่าสำรอง"
เป็นค่าสำรองเวลาฝั่งซ้ายเป็น nil ซึ่งเป็นสำนวนที่คนเขียน JavaScript คุ้นอยู่แล้ว
ราคาของมันคือความประหลาดใจตอนอ่าน code คนอื่น ค่าที่ออกมาจาก or อาจเป็นชนิดใดก็ได้
คนที่คิดว่าได้ boolean แน่ๆ แล้วเอาไปต่อ จะเจอชนิดที่ไม่ได้คาดตอนรัน ไม่ใช่ตอนคอมไพล์
function เป็นค่าตัวหนึ่ง ไม่ใช่ชื่อที่แปะไว้เฉยๆ
หัวข้อที่มีชื่อว่า “function เป็นค่าตัวหนึ่ง ไม่ใช่ชื่อที่แปะไว้เฉยๆ”หัวข้อนี้แสดงโครงสร้างที่เก็บ function ที่ผู้ใช้เขียน และบอกว่าสามฟิลด์ในนั้นทำหน้าที่อะไร
ในภาษา nok function เป็น First-Class FunctionFirst-Class Functionฟังก์ชันที่ถูกปฏิบัติเหมือนค่าอื่นๆ — เก็บใส่ตัวแปร ส่งเป็นอาร์กิวเมนต์ และคืนออกจากฟังก์ชันได้ จริงๆ คือมันเป็นค่าตัวหนึ่งเหมือนตัวเลขหรือข้อความ
/// function ที่ผู้ใช้เขียนเองpub struct Function { pub decl: Rc<FunctionDecl>, /// environment ที่ function นี้ถูก *ประกาศ* ไว้ — ไม่ใช่ที่มันถูกเรียก /// /// ฟิลด์เดียวนี้คือทั้งหมดของคำว่า closure · และมันคือเหตุผลที่ `Environment` /// ต้องเป็น `Rc<RefCell<…>>`: function ถือ environment นั้นไว้ต่อ แม้ scope /// ที่สร้างมันจะจบไปแล้ว pub closure: EnvRef, /// `true` เฉพาะ method ชื่อ `init` — บทที่ 8 ใช้บังคับให้มันคืน `this` เสมอ pub is_initializer: bool,}decl คือ node ของต้นไม้ที่ parser สร้างไว้ ส่วน is_initializer เป็นเรื่องของบทที่ 8
ฟิลด์กลางคือ closure และบทที่ 7 ทั้งบทมีไว้อธิบายมันฟิลด์เดียว บทนี้ขอแค่ให้สังเกตว่ามันมีอยู่
ผลของการที่ function เป็นค่า คือมันถูกพิมพ์ออกมาได้เหมือนค่าอื่น ตัวอย่างของบทนี้พิมพ์
<fn greet> และ <native fn clock> ออกมาโดยไม่ต้องเรียกมันสักครั้ง
ราคาที่จ่ายไปแล้วอยู่ที่บทที่ 5 — เมื่อ function เป็นค่าที่รอดออกมาจาก scope ที่สร้างมันได้
environment ก็ต้องมีเจ้าของได้หลายคน นั่นคือเหตุผลที่มันเป็น Rc<RefCell<…>> ไม่ใช่ Box
เรียกหนึ่งครั้ง เกิดอะไรขึ้นบ้าง
หัวข้อที่มีชื่อว่า “เรียกหนึ่งครั้ง เกิดอะไรขึ้นบ้าง”หัวข้อนี้ไล่ลำดับของการเรียกหนึ่งครั้ง ตั้งแต่สร้าง scope ใหม่ ผูก parameter จนถึงการรับค่าที่
returnออกมา
/// เรียก function /// /// environment ของการเรียกครั้งนี้เป็นลูกของ `closure` ไม่ใช่ลูกของ scope /// ที่กำลังเรียกอยู่ — สลับสองอย่างนี้เมื่อไหร่ ได้ dynamic scope แทน /// lexical scope ทันที และผลลัพธ์จะขึ้นกับว่า *ใคร* เรียก ไม่ใช่ว่า code เขียนไว้ที่ไหน pub fn call( &self, interpreter: &mut Interpreter, args: Vec<Value>, ) -> Result<Value, RuntimeError> { let scope = Environment::child(&self.closure); for (param, arg) in self.decl.params.iter().zip(args) { scope.borrow_mut().define(param.name.clone(), arg); }
match interpreter.execute_block(&self.decl.body, scope) { Ok(()) => Ok(self.default_result()), Err(Flow::Return(value)) => { if self.is_initializer { Ok(self.default_result()) } else { Ok(value) } } Err(Flow::Error(e)) => Err(e), } }บรรทัดแรกของ body คือทั้งหมดของ lexical scope — scope ใหม่เป็นลูกของ closure
ไม่ใช่ลูกของที่ที่กำลังเรียกอยู่
parameter ถูกผูกด้วย zip ซึ่งจะหยุดที่ฝั่งที่สั้นกว่า และนั่นคือเหตุผลที่ต้องมีการตรวจอีกชั้นก่อนถึงตรงนี้
Flow::Return เดินทางกลับมาเป็น Err ทั้งที่ไม่มีอะไรผิด เพราะทั้ง return และความผิดพลาด
ต่างก็เป็น “การจบก่อนกำหนด” เหมือนกัน ต่างกันแค่ปลายทางเป็นค่าหรือเป็น error
ตรวจจำนวน argument ก่อนเข้า body
หัวข้อที่มีชื่อว่า “ตรวจจำนวน argument ก่อนเข้า body”หัวข้อย่อยนี้อธิบายว่าการตรวจ ArityArityจำนวนอาร์กิวเมนต์ที่ฟังก์ชันตัวหนึ่งรับ — ตรวจตอนเรียกเพื่อให้ error บอกได้ว่าคาดกี่ตัวแต่ได้กี่ตัว เกิดขึ้นก่อนการเรียกจริง จึงทำให้ error ชี้ไปที่จุดเรียก
/// จำนวน parameter · ตรวจก่อนเรียกทุกครั้ง (ดู `Interpreter::call`) pub fn arity(&self) -> usize { self.decl.params.len() }ตัวเลขนั้นถูกเอาไปเทียบกับจำนวนที่ผู้เรียกส่งมาจริง ก่อนที่ body จะได้ทำงานสักบรรทัด
/// ตรวจจำนวน argument ก่อนเรียกเสมอ////// ภาษาที่ไม่ตรวจ arity จะเงียบแล้วส่ง `nil` เข้าไปแทนตัวที่ขาด ซึ่งกลายเป็น/// error คนละที่คนละบรรทัดกับจุดที่ผิดจริงfn check_arity(name: &str, want: usize, got: usize, line: usize) -> Result<(), RuntimeError> { if want == got { return Ok(()); } Err(RuntimeError { line, message: format!("{name}() รับ argument {want} ตัว แต่ได้รับ {got} ตัว") })}line ที่ส่งเข้ามาคือบรรทัดของ จุดเรียก ไม่ใช่บรรทัดที่ประกาศ function ข้อความที่ได้จึงชี้ไปที่
ที่ที่ผู้เขียนพิมพ์ผิดจริง
ราคาที่ติดมากับความเข้มงวดนี้คือภาษาที่ไม่มี parameter ที่ใส่หรือไม่ใส่ก็ได้ และไม่มี function ที่รับได้ไม่จำกัดจำนวน การเติม parameter ให้ function หนึ่งตัว จึงทำให้ทุกจุดที่เรียกมันพังพร้อมกัน
flowchart TD
A["Expr::Call · เจอวงเล็บเปิดหลังค่า"] --> B["ประเมินตัวที่ถูกเรียก"]
B --> C["ประเมิน argument ทีละตัว ซ้ายไปขวา"]
C --> D{"check_arity · จำนวนตรงไหม"}
D -->|ไม่ตรง| E["RuntimeError ที่บรรทัดของจุดเรียก"]
D -->|ตรง| F{"ค่านี้เป็นอะไร"}
F -->|Function| G["scope ใหม่เป็นลูกของ closure · ผูก parameter · รัน body"]
F -->|Native| H["เรียก fn pointer ของ Rust ตรงๆ"]
คำบรรยายภาพ: เส้นทางของการเรียกหนึ่งครั้ง · การตรวจ arity ยืนอยู่ก่อนทางแยกสุดท้าย ทั้ง function ที่ผู้ใช้เขียนและ native function จึงถูกตรวจด้วยกฎเดียวกัน
ด่านเดียวที่ภาษานี้ออกไปข้างนอกได้
หัวข้อที่มีชื่อว่า “ด่านเดียวที่ภาษานี้ออกไปข้างนอกได้”หัวข้อนี้แสดงว่า Native FunctionNative Functionฟังก์ชันที่ภาษาเรียกได้แต่ตัวมันเขียนด้วยภาษาที่ใช้สร้าง interpreter ไม่ได้เขียนด้วยภาษานั้นเอง — เป็นสะพานออกไปหาโลกภายนอก คืออะไร เขียนด้วยอะไร และการเพิ่มตัวใหม่แตะ code ส่วนไหนบ้าง
ภาษา nok ที่มีแค่ print ทำอะไรกับโลกภายนอกไม่ได้เลย มันบวกเลขเก่งแต่ดูนาฬิกาไม่เป็น
ทางออกคือ function ที่ เรียกจาก nok ได้ แต่ ตัวมันเขียนด้วย Rust
/// function ที่เขียนด้วย Rust ไม่ใช่ด้วย nok////// `func` เป็น function pointer ธรรมดา ไม่ใช่ `Box<dyn Fn>` เพราะ native ทุกตัว/// ของเราไม่ต้องจับตัวแปรอะไรจากรอบข้าง · pointer เปล่าจึงพอ และ `Value`/// ยังคง `Clone` ได้ราคาถูกอยู่pub struct NativeFn { pub name: &'static str, pub arity: usize, pub func: fn(&[Value]) -> Result<Value, String>,}
/// ติดตั้ง native function ลง global scope////// นี่คือด่านเดียวที่ภาษา nok ติดต่อโลกภายนอกได้ · อยากให้ภาษาทำอะไรได้เพิ่ม/// ก็เพิ่มที่นี่ ไม่ต้องแตะ scanner · parser · หรือ interpreter เลยสักบรรทัดpub fn install_natives(globals: &EnvRef) { let mut scope = globals.borrow_mut(); for native in [ NativeFn { name: "clock", arity: 0, func: native_clock }, NativeFn { name: "sqrt", arity: 1, func: native_sqrt }, ] { scope.define(native.name, Value::Native(Rc::new(native))); }}native ถูก define ลง global scope ตอนสร้าง interpreter มันจึงเป็นชื่อธรรมดาที่ผู้ใช้เขียนทับได้
และไม่ใช่คำสงวนของภาษา
func เป็น function pointer เปล่าๆ ไม่ใช่ Box<dyn Fn> ราคาของทางเลือกนั้นคือ native
จับค่าจากรอบข้างไม่ได้เลยสักตัว
ถ้าวันหนึ่งอยากได้ native ที่ถือ handle ของ file หรือ connection ไว้ ชนิดของฟิลด์นี้ต้องเปลี่ยน เป็นชนิดที่จับค่าได้ ซึ่งลากเรื่องอายุของค่าที่มันจับไว้เข้ามาทั้งชุด
clock คืนเวลาปัจจุบัน ผลของมันจึงเปลี่ยนทุกครั้งที่รัน
ผลรันของทั้งคอร์สถูกเทียบทีละ byte กับ expected/ ค่าที่เปลี่ยนตลอดเวลาจึงเข้าไปอยู่ที่นั่นไม่ได้
ตัวอย่างของบทนี้เลยพิมพ์แค่ตัว function ออกมาดู ไม่เรียกมัน
ลองรันจริง
หัวข้อที่มีชื่อว่า “ลองรันจริง”หัวข้อนี้เอาทุกอย่างข้างบนมารันใน file เดียว แล้วอ่านผลที่ได้ทีละจุด
// บทที่ 6 — if · while · for · function · arity · native functionvar n = 5;if (n > 3) print "n มากกว่า 3"; else print "n ไม่เกิน 3";
var i = 0;while (i < 3) { print i; i = i + 1;}
for (var j = 0; j < 3; j = j + 1) print j;
// and / or ลัดวงจร — ฝั่งขวาไม่ถูกประเมินเลย function ที่ไม่มีอยู่จริงจึงไม่ทำให้พังprint false and neverCalled();print nil or "ค่าสำรอง";
fun fib(n) { if (n < 2) return n; return fib(n - 2) + fib(n - 1);}print fib(10);
fun greet(who) { print "hello, " + who;}greet("nok");
// function เป็นค่าตัวหนึ่ง พิมพ์มันออกมาได้เหมือนค่าอื่นprint greet;print clock;print sqrt(144);
// จำนวน argument ผิดคือ error ที่จุดเรียก ไม่ใช่ nil ที่โผล่ไปพังที่อื่นทีหลังsqrt(1, 2);n มากกว่า 3012012falseค่าสำรอง55hello, nok<fn greet><native fn clock>12[บรรทัด 34] ผิดพลาดตอนรัน: sqrt() รับ argument 1 ตัว แต่ได้รับ 2 ตัวอ่านผลนี้แล้วเห็นสี่อย่างที่หัวข้อก่อนหน้าพูดถึง
whileกับforให้ผลชุดเดียวกันเป๊ะ — เลขสามตัวเดิมซ้ำสองรอบ ซึ่งเป็นสิ่งที่ต้องเป็น เพราะทั้งคู่กลายเป็นต้นไม้รูปเดียวกันตั้งแต่ตอน parseneverCalledไม่มีอยู่จริง แต่ไม่มี error สักบรรทัด —false andตัดจบตั้งแต่ฝั่งซ้าย ฝั่งขวาจึงไม่เคยถูกประเมินprint greet;ให้<fn greet>ไม่ใช่ผลของการเรียก — เพราะไม่มีวงเล็บ มันจึงเป็นแค่การอ่านค่า ของตัวแปรตัวหนึ่งออกมาพิมพ์- บรรทัดสุดท้ายพังที่จุดเรียก ไม่ใช่ข้างใน
sqrt— ข้อความบอกทั้งชื่อ จำนวนที่คาด และจำนวนที่ได้รับ ครบพอที่จะแก้โดยไม่ต้องเปิดไปดู code ของsqrt
fib(10) ที่ให้ 55 เป็นหลักฐานของอีกเรื่องหนึ่ง คือ function เรียกตัวเองได้ ซึ่งไม่ใช่ของฟรี —
มันมาจากการที่ชื่อของ function ถูกประกาศลง environment ก่อน ที่ body จะถูกเดินเข้าไป
- Crafting Interpreters — Robert Nystrom, บทที่ 9 “Control Flow”
(ตรวจแล้ว 2026-08-12) — ที่มาของการคลี่
forเป็นwhileตั้งแต่ตอน parse และของกฎที่ว่าandกับorคืนค่าเดิมแทนที่จะแปลงเป็น boolean - Crafting Interpreters — Robert Nystrom, บทที่ 10 “Functions”
(ตรวจแล้ว 2026-08-12) — โครงของการเรียก การตรวจ arity และแนวคิด native function
ตัวหนังสือใช้ interface ชื่อ
LoxCallableส่วนคอร์สนี้matchบนenumแทน จึงไม่มี trait เลย - The Rust Programming Language — บทที่ 19 “Advanced Functions and Closures”
(ตรวจแล้ว 2026-08-12) — ความต่างระหว่าง
fnที่เป็น pointer เปล่ากับBox<dyn Fn>ซึ่งเป็นทางเลือกที่NativeFnตัดสินใจไว้
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3หลัง parser อ่าน for (var j = 0; j < 3; j = j + 1) print j; จบ ต้นไม้ที่ได้มีหน้าตาอย่างไร