closure ที่พก scope ติดตัว และ resolver ที่ปิดรูของมัน
บทที่แล้วทิ้งฟิลด์หนึ่งไว้โดยไม่อธิบาย คือ closure ในโครงสร้าง Function
บทนี้ทั้งบทมีไว้อธิบายฟิลด์นั้นฟิลด์เดียว และรูที่มันเปิดทิ้งไว้โดยที่คนส่วนใหญ่ไม่ทันเห็น
สิ่งที่ทำให้เรื่องนี้ไม่ใช่แค่รายละเอียดปลีกย่อย คือรูนั้นทำให้โปรแกรมที่ถูกต้องให้คำตอบผิด โดยไม่มี error สักบรรทัด
closure คือฟิลด์เดียว ไม่ใช่กลไกพิเศษ
หัวข้อที่มีชื่อว่า “closure คือฟิลด์เดียว ไม่ใช่กลไกพิเศษ”หัวข้อนี้แสดงว่า ClosureClosureฟังก์ชันที่พกสภาพแวดล้อมตอนที่มันถูกสร้างติดตัวไปด้วย จึงยังอ่านตัวแปรของ scope นั้นได้แม้ scope นั้นจบไปแล้ว ในภาษา nok เกิดจากการเก็บ environment ปัจจุบันไว้ตอนประกาศ ไม่ได้ใช้กลไกอื่นเพิ่ม
ตอนที่ interpreter เจอคำสั่งประกาศ function มันทำสองอย่าง คือสร้างค่าขึ้นมาแล้วผูกกับชื่อ
Stmt::Function(decl) => { // closure คือ environment *ตอนที่ประกาศ* ไม่ใช่ตอนที่เรียก — บทที่ 7 let function = Function::new(Rc::clone(decl), Rc::clone(&self.env), false); self.env.borrow_mut().define(decl.name.clone(), Value::Function(Rc::new(function))); }Rc::clone(&self.env) คือทั้งหมดของเรื่อง มันเพิ่มตัวนับผู้ถือของ environment ที่กำลังทำงานอยู่ตอนนั้น
แล้วเก็บติดไปกับตัว function
ผลตามมาคือ environment ก้อนนั้นจะไม่หายไปแม้ scope ที่สร้างมันจบไปแล้ว เพราะยังมีผู้ถืออยู่หนึ่งราย
ตัวอย่างของบทนี้ใช้ makeCounter ที่คืน function ตัวในออกมา ตัวแปร count อยู่ใน scope
ที่จบไปตั้งแต่ makeCounter คืนค่า แต่ค่ายังเดินหน้าจาก 1 ไป 2 ได้ เพราะ scope นั้นยังมีชีวิต
การเรียก makeCounter ครั้งที่สองสร้าง scope ใหม่ทั้งก้อน counter ตัวที่สองจึงเริ่มนับจาก 1 ใหม่
ไม่ใช่นับต่อจากตัวแรก
แล้ว bug ก็โผล่ตรงที่ไม่มีใครมอง
หัวข้อที่มีชื่อว่า “แล้ว bug ก็โผล่ตรงที่ไม่มีใครมอง”หัวข้อนี้อธิบายว่าการไล่หาชื่อตอนรัน ทำให้ function ตัวเดิมให้คำตอบต่างกันสองครั้งได้
รูปแบบที่เปิดรูนี้เขียนสั้นมาก — ประกาศ function ใน block ก่อน แล้วค่อยประกาศตัวแปรชื่อซ้ำทีหลัง ในระหว่างที่เรียก function นั้นคั่นอยู่
//! ถ้า interpreter ไล่หาชื่อ `a` ตอนรัน การเรียกครั้งที่สองจะเจอ `a` ตัวใหม่//! ที่เพิ่งประกาศ แล้วพิมพ์ `block` · แต่ `showA` ถูกเขียนไว้ *ก่อน* `a` ตัวนั้นมีอยู่//! มันจึงไม่ควรเห็นเลย · closure ที่เปลี่ยนความหมายตามเวลาไม่ใช่ closure//!//! ทางแก้คือเดินต้นไม้อีกรอบก่อนรัน แล้วตอบคำถามเดียว: ชื่อนี้อยู่ห่างจากจุดที่ใช้//! กี่ชั้น · คำตอบถูกจดไว้ด้วย `id` ของ node แล้วส่งให้ interpreter ใช้ตอนรัน//! เมื่อคำตอบถูกล็อกไว้ตั้งแต่ก่อนรัน มันจึงเปลี่ยนตามลำดับการรันไม่ได้อีก//!//! เดินรอบนี้ยังเป็นที่ที่ error เชิงโครงสร้างถูกจับได้ก่อนโปรแกรมเริ่มทำงาน://! `return` นอก function · `this` นอก class · ประกาศชื่อซ้ำใน scope เดียวกันประโยคที่ต้องอ่านซ้ำคือประโยคปิดของย่อหน้าแรก — closure ที่เปลี่ยนความหมายตามเวลาไม่ใช่ closure
lexical scope ที่บทที่ 5 วางกฎไว้ สัญญาว่าชื่อหนึ่งหมายถึงตัวไหน ตัดสินจากตำแหน่งใน code ไม่ใช่จากลำดับการรัน การไล่หาตอนรันผิดสัญญาข้อนั้น
ไม่มี error ไม่มี warning ไม่มีอะไรผิดปกติให้เห็น มีแต่คำตอบที่ต่างจากที่ควรเป็น
โปรแกรมที่พังบอกตัวเองว่าพัง ส่วนโปรแกรมที่เข้าใจชื่อผิดจะเดินต่อไปเรื่อยๆ แล้วสะสม ผลที่ผิดไปเงียบๆ จนถึงจุดที่ไม่มีใครโยงกลับมาถึงต้นเหตุได้แล้ว
resolver ตอบล่วงหน้าแทนที่จะไล่หาตอนรัน
หัวข้อที่มีชื่อว่า “resolver ตอบล่วงหน้าแทนที่จะไล่หาตอนรัน”หัวข้อนี้แสดงกลไกของ ResolverResolverรอบเดินบน AST ที่ทำก่อนรันจริง เพื่อตัดสินล่วงหน้าว่าชื่อแต่ละตัวหมายถึงตัวแปรใน scope ชั้นที่เท่าไร — เป็นตัวปิดบั๊ก scope ที่ closure ทำให้เกิด คือการนับชั้นไว้ก่อนรัน แล้วให้ interpreter ใช้คำตอบนั้นแทนการไล่หาเอง
คำถามเดียวที่ resolver ตอบคือ ชื่อนี้อยู่ห่างจากจุดที่ใช้กี่ชั้น
/// นับว่าชื่อนี้อยู่ห่างออกไปกี่ชั้น แล้วจดคำตอบไว้ /// /// ไม่เจอในชั้นไหนเลย = เป็น global · ไม่จดอะไร แล้วปล่อยให้ interpreter /// ไปหาที่ global ตอนรัน ซึ่งเป็นพฤติกรรมที่ถูกต้องสำหรับ global พอดี fn resolve_local(&mut self, name: &str, id: usize) { for (depth, scope) in self.scopes.iter().rev().enumerate() { if scope.contains_key(name) { self.locals.insert(id, depth); return; } } }คำตอบถูกจดด้วย id ของ node ในต้นไม้ ไม่ใช่ด้วยชื่อ — ชื่อเดียวกันที่ปรากฏคนละที่ในโปรแกรม
จึงมีคำตอบของตัวเองแยกกัน
ฝั่ง interpreter รับคำตอบนั้นไปใช้ตรงๆ
/// หาค่าของชื่อ · ใช้คำตอบของ resolver ถ้ามี ไม่มีก็ถือว่าเป็น global fn look_up(&self, name: &str, id: usize, line: usize) -> Result<Value, RuntimeError> { let found = match self.locals.get(&id) { Some(&depth) => Environment::get_at(&self.env, depth, name), None => Environment::get(&self.globals, name), }; found.ok_or_else(|| RuntimeError { line, message: format!("ไม่รู้จักชื่อ '{name}'") }) }ความต่างอยู่ที่สองบรรทัดกลาง เส้นทางที่มีคำตอบจดไว้ใช้ get_at ข้ามขึ้นไปตามจำนวนชั้นนั้น
แล้วหยิบจากชั้นเดียว
เส้นทางที่ไม่มีคำตอบก็ไม่ได้ไล่หาจากชั้นในออกไปเช่นกัน มันถาม globals ตรงๆ ที่เดียว
เพราะชื่อที่ resolver ไม่จดไว้ มีที่อยู่ได้ที่เดียวคือที่นั่น
เมื่อจำนวนชั้นถูกล็อกไว้ตั้งแต่ก่อนโปรแกรมเริ่มทำงาน ตัวแปรที่ประกาศทีหลังจึงเข้ามาแทรกไม่ได้อีก
flowchart TD
A["AST จาก parser"] --> B["resolver · เดินต้นไม้รอบที่หนึ่ง"]
B --> C["locals · id ของ node คู่กับ จำนวนชั้น"]
B --> D["ResolveError · จับได้ตั้งแต่ก่อนรัน"]
A --> E["interpreter · เดินต้นไม้รอบที่สอง"]
C --> E
E --> F{"node นี้อยู่ใน locals ไหม"}
F -->|อยู่| G["get_at · ข้ามขึ้นไปตามจำนวนชั้นที่จดไว้"]
F -->|ไม่อยู่| H["ไล่หาที่ global ตอนรัน"]
คำบรรยายภาพ: ต้นไม้ก้อนเดียวถูกเดินสองรอบ · รอบแรกไม่รันอะไรเลย มันแค่ตอบคำถามเรื่องชื่อล่วงหน้า แล้วส่งคำตอบให้รอบที่สองใช้
global ถูกกันออกจากการนับชั้นโดยตั้งใจ
หัวข้อที่มีชื่อว่า “global ถูกกันออกจากการนับชั้นโดยตั้งใจ”หัวข้อย่อยนี้บอกว่า scope นอกสุดไม่ถูกนับชั้น และเหตุผลที่ resolver ตอบเรื่องมันไม่ได้
struct Resolver { /// scope ซ้อนกัน · `true` แปลว่าประกาศเสร็จแล้ว `false` แปลว่ากำลังประกาศอยู่ /// /// scope ระดับ global ไม่อยู่ในนี้โดยตั้งใจ — ตัวแปร global ถูกประกาศตอนไหน /// ก็ได้ (REPL ประกาศทีละบรรทัด) resolver จึงตอบเรื่องมันไม่ได้ และปล่อยให้ /// interpreter ไล่หาตอนรันแทน scopes: Vec<HashMap<String, bool>>, locals: HashMap<usize, usize>, errors: Vec<ResolveError>, current_fn: FunctionKind, current_class: ClassKind,}เหตุผลอยู่ที่ REPL — ผู้ใช้พิมพ์ var a = 1; บรรทัดหนึ่ง แล้วพิมพ์ print a; อีกบรรทัดถัดมาได้
resolver ที่เห็นทีละบรรทัดจึงตัดสินเรื่องชื่อระดับบนสุดล่วงหน้าไม่ได้เลย
ผลที่ตามมาต้องพูดออกมาตรงๆ เพราะมันคือข้อแลก — รูปแบบเดียวกันที่ถูกปิดสนิทเมื่ออยู่ใน block ยังเปิดอยู่เมื่ออยู่ที่ระดับบนสุด ชื่อ global ยังถูกไล่หาตอนรัน จึงยังเห็นตัวที่ประกาศทีหลังได้
current_fn กับ current_class เป็นอีกหน้าที่หนึ่งของการเดินรอบนี้ มันจำว่าตอนนี้อยู่ในอะไรอยู่
ซึ่งเป็นสิ่งเดียวที่ทำให้ตอบได้ว่า return ตัวนี้อยู่นอก function หรือเปล่า
รอบเดียวกันจับ error เชิงโครงสร้างไปด้วย
หัวข้อที่มีชื่อว่า “รอบเดียวกันจับ error เชิงโครงสร้างไปด้วย”หัวข้อนี้แสดงว่าการเดินต้นไม้รอบเดียวกันนั้นตรวจลำดับการประกาศและชื่อซ้ำได้ด้วย
การประกาศตัวแปรถูกทำเป็นสองจังหวะ ไม่ใช่จังหวะเดียว
Stmt::Var { name, line, init } => { // ประกาศ → resolve ค่าเริ่มต้น → ค่อยนับว่าใช้ได้ // ลำดับนี้คือสิ่งที่ทำให้ `var a = a;` เป็น error แทนที่จะเงียบๆ ได้ nil self.declare(name, *line); if let Some(e) = init { self.expr(e); } self.define(name); }ระหว่าง declare กับ define ชื่อนั้นมีอยู่แต่ยังใช้ไม่ได้ ค่าเริ่มต้นที่อ้างถึงตัวเองจึงถูกจับได้
แทนที่จะได้ค่าว่างมาเงียบๆ
จังหวะแรกยังเป็นที่ที่ชื่อซ้ำถูกจับด้วย
fn declare(&mut self, name: &str, line: usize) { let Some(scope) = self.scopes.last_mut() else { return; // global — ปล่อยให้ตอนรันจัดการ }; if scope.contains_key(name) { self.error(line, &format!("มีชื่อ '{name}' ประกาศไว้ใน scope นี้แล้ว")); return; } scope.insert(name.to_string(), false); }ประโยค let … else ที่หัวของ body คือ global ที่พูดถึงไปแล้ว ไม่มี scope ให้ประกาศลง
ก็เลยไม่มีอะไรให้ตรวจ
นั่นคือเหตุผลที่ประกาศ var ชื่อเดิมซ้ำสองครั้งที่ระดับบนสุดไม่เป็น error แต่ทำแบบเดียวกัน
ใน block กลับเป็น error — กฎเดียวกัน คนละที่ ให้ผลคนละแบบ
ลองรันจริง — ทั้งฝั่งที่ถูกและฝั่งที่ถูกจับได้
หัวข้อที่มีชื่อว่า “ลองรันจริง — ทั้งฝั่งที่ถูกและฝั่งที่ถูกจับได้”หัวข้อนี้รันตัวอย่างที่มีทั้ง closure ที่ทำงานถูกและ error ที่ resolver จับได้ อยู่ใน file เดียวกัน
// บทที่ 7 — closure จับ environment ตอนถูกประกาศ ไม่ใช่ตอนถูกเรียกfun makeCounter() { var count = 0; fun tick() { count = count + 1; return count; } return tick;}
var next = makeCounter();next();next();
// counter ตัวที่สองมี environment ของตัวเอง ไม่ใช้ count ร่วมกับตัวแรกvar other = makeCounter();other();
// bug scope ที่คนส่วนใหญ่มองข้าม// showA ถูกเขียนไว้ก่อน a ตัวใน block จะมีอยู่ มันจึงต้องเห็น a ตัวนอกทั้งสองครั้งvar a = "global";{ fun showA() { print a; } showA(); var a = "block"; showA();}
// การเดินต้นไม้รอบเดียวกันนั้นยังจับ error เชิงโครงสร้างได้ตั้งแต่ก่อนโปรแกรมเริ่มรันreturn 1;{ var dup = 1; var dup = 2;}this;
print "ตรวจ scope จบแล้ว คำสั่งที่เหลือยังได้รัน";ตัวอย่างนี้รันด้วยโหมด --eval ซึ่งป้อนคำสั่งเข้า interpreter ทีละคำสั่งแบบเดียวกับที่ REPL ทำ
สองอย่างที่ตามมาจากโหมดนี้อธิบายผลข้างล่างได้ทั้งหมด
- ค่าของคำสั่งที่เป็นนิพจน์ระดับบนสุดถูกสะท้อนกลับมา —
next()จึงพิมพ์ตัวเลขออกมาทั้งที่ไม่มีprint - คำสั่งที่พังไม่หยุดคำสั่งถัดไป — ตรงข้ามกับการรันทั้ง file เป็นโปรแกรมเดียว ซึ่งจะหยุด ตั้งแต่ยังไม่ได้รันอะไรเลยถ้าตรวจ scope ไม่ผ่าน
121globalglobal[บรรทัด 32] ผิดพลาดตอนตรวจ scope: ใช้ 'return' นอก function ไม่ได้[บรรทัด 35] ผิดพลาดตอนตรวจ scope: มีชื่อ 'dup' ประกาศไว้ใน scope นี้แล้ว[บรรทัด 37] ผิดพลาดตอนตรวจ scope: ใช้ 'this' นอก class ไม่ได้ตรวจ scope จบแล้ว คำสั่งที่เหลือยังได้รันสามบรรทัดแรกคือ closure ที่ทำงานถูก — counter ตัวแรกเดินจาก 1 ไป 2 ส่วนตัวที่สองเริ่มจาก 1
เพราะถือ environment คนละก้อน
สองบรรทัดถัดมาคือหัวใจของบทนี้ showA พิมพ์ global ทั้งสองครั้ง ทั้งที่ครั้งที่สองถูกเรียกหลังจาก
มีตัวแปรชื่อ a ตัวใหม่ประกาศอยู่ใน block เดียวกันแล้ว
สามบรรทัดที่เป็น error ไม่ได้เกิดตอนรัน มันเกิดตอนตรวจ ข้อความบอกไว้ตรงๆ ว่า
ผิดพลาดตอนตรวจ scope ซึ่งเป็นคนละด่านกับความผิดพลาดตอนรันในบทที่ 4
บรรทัดสุดท้ายยังได้รัน แม้จะมี error สามข้อมาก่อนหน้า สัญญาณสำหรับเครื่องอ่านจึงไม่ได้อยู่ในผลที่พิมพ์ แต่อยู่ที่ exit code
ป้าย provenance ที่หัวของ expected/07-closures.txt บันทึกค่านั้นไว้เป็น 65 ซึ่งแปลว่าผิดพลาด
ตั้งแต่ก่อนรัน ไม่ใช่ 70 ที่แปลว่าผิดพลาดระหว่างรัน
ราคาที่คอร์สนี้รับไว้โดยตั้งใจ
หัวข้อที่มีชื่อว่า “ราคาที่คอร์สนี้รับไว้โดยตั้งใจ”หัวข้อนี้บอกว่า
Rcกับ closure ทำให้เกิด reference cycle ได้จริง และภาษานี้ไม่มีอะไรมาเก็บกวาด
Rc คืนหน่วยความจำเมื่อตัวนับผู้ถือลงถึงศูนย์ ซึ่งแปลว่าของที่อ้างถึงกันเป็นวงจะไม่มีวันถึงศูนย์
และรูปแบบนั้นเกิดขึ้นจริงในภาษานี้ — function ถือ environment ที่มันถูกประกาศไว้ ส่วน environment ก้อนนั้นก็ถือ function ตัวนั้นไว้ในฐานะค่าของชื่อหนึ่ง
นี่ไม่ใช่ความกังวลเชิงทฤษฎี มันทิ้งรอยไว้ในที่ที่ไม่มีใครคาด
/// เขียน `Debug` เองแทนที่จะ derive////// `derive(Debug)` จะไล่พิมพ์ environment ที่อยู่ใน closure ต่อไปเรื่อยๆ และเพราะ/// closure กับ environment อ้างถึงกันเป็นวงได้ (ดู `environment.rs`) มันจะวนไม่รู้จบ/// จนกองซ้อนล้น · การพิมพ์แบบเดียวกับที่ผู้ใช้เห็นจึงทั้งปลอดภัยกว่าและมีประโยชน์กว่าimpl fmt::Debug for Value { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.repr()) }}ภาษาจริงแก้เรื่องนี้ด้วย garbage collector ที่ตามเก็บของที่ไม่มีใครเข้าถึงได้แล้ว ไม่ว่ามันจะอ้างถึงกันเองอย่างไร
nok ไม่มี และคอร์สนี้ไม่ทำ เพราะ garbage collector เป็นเรื่องใหญ่พอที่จะเป็นคอร์สของตัวเอง
ผลที่ตามมาอ่านออกได้จากรูปร่างของ code ที่เขียน — ทุกครั้งที่เรียก makeCounter scope ที่ได้กับ
function ข้างในถือกันไว้เป็นวง เรียกซ้ำกี่ครั้ง หน่วยความจำก็ค้างไว้เท่านั้นก้อน จนกว่ากระบวนการจะจบ
ทางออกครึ่งทางที่ Rust มีให้คือ Weak ซึ่งเป็นตัวชี้ที่ไม่นับเป็นเจ้าของ แต่การเลือกว่า
เส้นไหนควรเป็น Weak ต้องรู้ทิศทางของวงแหวนล่วงหน้า ซึ่งใน interpreter ทั่วไปไม่มีใครรู้
- Crafting Interpreters — Robert Nystrom, บทที่ 11 “Resolving and Binding”
(ตรวจแล้ว 2026-08-12) — ที่มาของการเดินต้นไม้รอบที่สองก่อนรัน ทั้งตัวอย่างที่เปิดรูให้เห็น
และวิธีจดจำนวนชั้นไว้ล่วงหน้า ตัวหนังสือเก็บคำตอบด้วยตัว node เอง ส่วนคอร์สนี้ใช้
idเป็นกุญแจ - The Rust Programming Language — บทที่ 15.6 “Reference Cycles Can Leak Memory”
(ตรวจแล้ว 2026-08-12) — ที่มาของข้อแลกที่บทนี้พูดถึงตอนท้าย ทั้งกลไกที่ทำให้
Rcเก็บวงแหวนไม่ได้ และบทบาทของWeakในการตัดวง
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ในตัวอย่างของบทนี้ showA พิมพ์คำว่า global ออกมาทั้งสองครั้ง ถ้าย้าย code รูปแบบเดียวกันนั้นออกมาไว้ที่ระดับบนสุดแทนที่จะอยู่ใน block ผลจะเป็นอย่างไร