environment — ตัวแปร scope และรูปทรงที่ ownership บังคับ
ภาษาในบทที่แล้วคิดเลขได้ แต่ลืมทุกอย่างทันทีที่คิดเสร็จ
ตัวแปรตัวแรกดูเหมือนของเล็ก — แค่หาที่เก็บชื่อกับค่าไว้สักที่หนึ่ง
แต่นี่คือบทที่ยากที่สุดของคอร์ส และความยากไม่ได้อยู่ที่ทฤษฎีภาษา มันอยู่ตรงที่ Rust จะปฏิเสธโครงสร้างที่ตำราต้นทางวาดไว้ สองครั้งติดกัน ด้วยเหตุผลคนละข้อ
ทุก block ของ code ในบทเรียนนี้ถูกคัดมาจาก scripts/rust-interpreter/src/ ทีละ byte
และมีเทสต์เทียบไว้ ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ source เทสต์จะแดงทันที
ผลรันทุกบรรทัดที่บทนี้แสดง คัดมาจาก expected/05-statements.txt ซึ่งเป็น stdout ที่รันจริง
จากนิพจน์ที่ทิ้งค่า มาเป็นคำสั่งที่ทิ้งร่องรอย
หัวข้อที่มีชื่อว่า “จากนิพจน์ที่ทิ้งค่า มาเป็นคำสั่งที่ทิ้งร่องรอย”หัวข้อนี้แยกคำสั่งออกจากนิพจน์ ด้วยเส้นแบ่งว่าอะไรคืนค่าและอะไรเปลี่ยนสถานะ
นิพจน์ให้ค่ากลับมาแล้วจบ ส่วนคำสั่งไม่ให้ค่าอะไรเลย มันมีไว้เปลี่ยนสถานะของโปรแกรม
var greeting = "สวัสดี"; ไม่ได้มีค่าเป็นอะไรทั้งนั้น สิ่งที่มันทำคือทำให้ชื่อ greeting
มีความหมายตั้งแต่บรรทัดนั้นเป็นต้นไป
Stmt::Var { name, init, .. } => { let value = match init { Some(e) => self.evaluate(e)?, None => Value::Nil, }; self.env.borrow_mut().define(name.clone(), value); } Stmt::Block(stmts) => { let scope = Environment::child(&self.env); self.execute_block(stmts, scope)?; }init เป็น Option เพราะ var x; ที่ไม่ให้ค่าเริ่มต้นเป็นรูปที่ถูกต้อง และตัวแปรนั้นได้ nil ไป
scope ทั้งเรื่องคือสองบรรทัดใน Stmt::Block — สร้างชั้นใหม่ รันคำสั่งข้างในบนชั้นนั้น
แล้วทิ้งมัน
environment ซ้อนกันเป็นชั้น ชั้นในเห็นชั้นนอก
หัวข้อที่มีชื่อว่า “environment ซ้อนกันเป็นชั้น ชั้นในเห็นชั้นนอก”หัวข้อนี้อธิบายกฎการหาชื่อ ว่ามันเริ่มจากชั้นในสุดแล้วไล่ออก ไม่ใช่ไล่เข้า
EnvironmentEnvironmentที่เก็บว่าชื่อไหนผูกกับค่าอะไรอยู่ตอนนี้ ซ้อนกันเป็นชั้นตาม block — ชั้นในมองเห็นชั้นนอก แต่ชั้นนอกไม่เห็นชั้นใน คือที่เก็บว่าชื่อไหนผูกกับค่าอะไรอยู่ตอนนี้ และมันไม่ได้มีก้อนเดียว แต่ละ block สร้างชั้นใหม่ทับลงไป
กฎมีข้อเดียว คือชั้นในมองเห็นชั้นนอก แต่ชั้นนอกไม่มีทางมองเห็นชื่อที่เกิดในชั้นใน
flowchart TD
L["หาชื่อหนึ่งชื่อ · เริ่มจากชั้นในสุดเสมอ"] --> B["scope ของ block · มี greeting ของตัวเอง"]
B -->|"ไม่เจอ จึงไล่ตาม enclosing ออกไป"| G["global · มี greeting กับ count"]
G -->|"ไม่เจอที่นี่อีก"| X["ความผิดพลาดตอนรัน · ไม่รู้จักชื่อนั้น"]
คำบรรยายภาพ: การหาชื่อเริ่มที่ชั้นในสุดแล้วไล่ออกทีละชั้น ชื่อที่ประกาศในชั้นในจึงบังชื่อเดียวกันของชั้นนอกไว้ · เส้นทางนี้เดินทางเดียว ไม่มีขั้นตอนไหนเดินย้อนกลับเข้าไปในชั้นที่ลึกกว่า
นี่คือ Lexical ScopeLexical Scopeกฎที่ตัดสินว่าชื่อหนึ่งหมายถึงตัวแปรตัวไหน โดยดูจาก **ตำแหน่งใน code** ไม่ใช่จากลำดับการเรียกตอนรัน — ชื่อหนึ่งหมายถึงตัวแปรตัวไหน ตัดสินจากตำแหน่งใน code ที่เขียนไว้ ไม่ใช่จากลำดับการเรียกตอนรัน
ทำไมโครงที่ตรงกว่าถึงเขียนใน Rust ไม่ได้
หัวข้อที่มีชื่อว่า “ทำไมโครงที่ตรงกว่าถึงเขียนใน Rust ไม่ได้”หัวข้อนี้เดินตามทางที่ตรงที่สุดก่อน แล้วแสดงว่ามันถูกปฏิเสธที่ไหนและด้วยเหตุผลอะไร
ตำราต้นทางเขียนด้วย Java และในนั้นชั้นที่ครอบอยู่คือฟิลด์ธรรมดาหนึ่งฟิลด์ ทำนองว่า
Environment enclosing; แล้วจบ
Java ให้ทุกอย่างเป็น reference ที่ชี้ร่วมกันได้อยู่แล้ว และมี garbage collector คอยเก็บกวาดข้างหลัง ประโยคเดียวนั้นจึงพอ
Rust ไม่ให้อะไรฟรีสักอย่างในสองข้อนั้น
ครั้งที่หนึ่ง — เจ้าของคนเดียวไม่พอ
หัวข้อที่มีชื่อว่า “ครั้งที่หนึ่ง — เจ้าของคนเดียวไม่พอ”หัวข้อย่อยนี้แสดงว่าทำไม
Boxซึ่งเป็นทางที่ตรงที่สุด ถึงใช้ไม่ได้ตั้งแต่ต้น
ทางที่ตรงที่สุดคือให้ชั้นลูกเป็นเจ้าของชั้นแม่ผ่าน Option<Box<Environment>> ซึ่งอ่านแล้วเข้าใจทันที
ปัญหาโผล่ตอนที่ environment ก้อนเดียวต้องถูกถือโดยสองที่พร้อมกัน — scope ลูกถือมันไว้ในฐานะชั้นแม่ และ closure ในบทที่ 7 ก็ถือมันไว้ในฐานะที่ที่มันเกิด
Box มีเจ้าของได้คนเดียว การส่งมันให้ที่ที่สองจึงเป็นการย้ายเจ้าของ ไม่ใช่การแบ่งกัน
คอมไพเลอร์ปฏิเสธด้วย E0382 คือใช้ค่าที่ถูกย้ายไปแล้ว
นี่ไม่ใช่ความจู้จี้ของภาษา มันคือคำถามที่ Java ไม่เคยต้องตอบเพราะ garbage collector ตอบให้เงียบๆ อยู่แล้ว — ใครเป็นคนคืนหน่วยความจำก้อนนี้
ครั้งที่สอง — ถือร่วมกันได้ แต่แก้ไม่ได้
หัวข้อที่มีชื่อว่า “ครั้งที่สอง — ถือร่วมกันได้ แต่แก้ไม่ได้”หัวข้อย่อยนี้แสดงว่า
Rcแก้ปัญหาเจ้าของหลายคนได้ แต่เปิดปัญหาใหม่ทันที
คำตอบของ “เจ้าของหลายคน” คือ Rc ซึ่งนับจำนวนผู้ถือ และคืนหน่วยความจำเมื่อตัวนับถึงศูนย์
แต่ Rc ให้สิทธิ์แค่อ่าน เพราะถ้ามีคนถืออยู่หลายคนแล้วยังแก้ได้อีก กฎการยืมของ Rust ก็ล่มทันที
count = count + 1; ต้องแก้ค่าใน environment ที่มีผู้ถืออยู่หลายคน เขียนตรงๆ แล้วคอมไพเลอร์
ปฏิเสธด้วย E0596 คือยืมของที่อยู่ใน Rc มาแก้ไม่ได้
ทางออกที่เหลืออยู่ทางเดียวคือ Interior MutabilityInterior Mutabilityรูปแบบของ Rust ที่ยอมให้แก้ค่าข้างในได้แม้ถืออยู่แบบอ่านอย่างเดียว โดยย้ายการตรวจกฎการยืมไปตอนรัน — `RefCell` คือตัวที่คอร์สนี้ใช้กับ environment — ยอมให้แก้ข้างในได้แม้ถือแบบอ่านอย่างเดียว โดยย้ายการตรวจกฎการยืมจากตอนคอมไพล์ไปตอนรัน
Rc RefCell คือคำตอบที่ ownership เลือกให้ ไม่ใช่รสนิยม
หัวข้อที่มีชื่อว่า “Rc RefCell คือคำตอบที่ ownership เลือกให้ ไม่ใช่รสนิยม”หัวข้อนี้แสดงรูปทรงที่เหลืออยู่หลังจากตัดทางที่เขียนไม่ได้ออกไปหมดแล้ว
/// ชื่อย่อที่ใช้ทั้งโปรเจกต์ · เขียน `Rc<RefCell<Environment>>` ซ้ำทุกที่แล้วอ่านยากpub type EnvRef = Rc<RefCell<Environment>>;
pub struct Environment { values: HashMap<String, Value>, /// scope ที่ครอบอยู่ · `None` แปลว่านี่คือ scope นอกสุด (global) enclosing: Option<EnvRef>,}อ่านชนิดนี้จากนอกเข้าใน แล้วมันเล่าเหตุผลของตัวเอง — Rc ตอบข้อแรก คือมีเจ้าของหลายคนได้
RefCell ตอบข้อสอง คือแก้ได้ทั้งที่ถูกถือร่วมกัน
enclosing เป็น Option เพราะชั้น global ไม่มีชั้นที่ครอบมันอยู่ ค่า None จึงเป็นจุดสิ้นสุด
ของการไล่ออก ไม่ใช่กรณีพิเศษที่ต้องดักแยก
pub fn child(parent: &EnvRef) -> EnvRef { Rc::new(RefCell::new(Environment { values: HashMap::new(), enclosing: Some(Rc::clone(parent)), })) }Rc::clone(parent) คือบรรทัดที่ผู้ถือคนที่สองเกิดขึ้นจริง มันไม่ได้คัดลอก environment
มันเพิ่มตัวนับแล้วชี้ไปที่ก้อนเดิม
ชื่อ clone ทำให้คนอ่านเผลอคิดว่ามีการคัดลอกของ ซึ่งเป็นกับดักของชื่อ ไม่ใช่ของกลไก
เดินออกไปหาชื่อ ทีละชั้น
หัวข้อที่มีชื่อว่า “เดินออกไปหาชื่อ ทีละชั้น”หัวข้อนี้แสดงการไล่หาชื่อออกไปข้างนอก และเหตุผลที่มันเขียนแบบนี้แทนที่จะรับ
&self
/// หาชื่อจาก scope นี้ไล่ออกไปข้างนอกจนถึง global /// /// รับ `&EnvRef` ไม่ใช่ `&self` เพราะต้องเดินขึ้นไปหา scope แม่ ซึ่งอยู่หลัง `Rc` /// อีกชั้น · เขียนเป็น associated function ทำให้การยืม `RefCell` แต่ละชั้น /// เริ่มและจบภายใน block เดียว ไม่มีการยืมค้างข้ามชั้นให้ panic ตอนรัน pub fn get(env: &EnvRef, name: &str) -> Option<Value> { let parent = { let scope = env.borrow(); if let Some(v) = scope.values.get(name) { return Some(v.clone()); } scope.enclosing.clone()? }; Environment::get(&parent, name) }รูปแบบ let parent = { … }; ไม่ใช่การจัดหน้าให้สวย มันคือกลไก — การยืม RefCell ของชั้นนี้
เกิดและตายภายในวงเล็บปีกกานั้น ก่อนที่การเรียกซ้ำจะเริ่มยืมชั้นถัดไป
ถ้าปล่อยให้การยืมค้างไว้แล้วเรียกซ้ำทั้งอย่างนั้น คอมไพเลอร์จะไม่ทักเลย เพราะ RefCell
ย้ายการตรวจไปตอนรันแล้ว
มันจะรอดตราบใดที่ชั้นที่ไล่ไปเจอไม่ใช่ชั้นเดิม ซึ่งเป็นเงื่อนไขที่จริงอยู่ตอนนี้ แต่ไม่มี code บรรทัดไหนค้ำไว้ · การเขียนให้การยืมจบก่อนเรียกซ้ำ แปลว่าไม่ต้องพึ่งเงื่อนไขนั้นเลย
/// กำหนดค่าให้ชื่อที่ *มีอยู่แล้ว* · คืน `false` ถ้าไม่เจอชื่อนั้นเลย /// /// ต่างจาก `define` ตรงที่มันไม่สร้างชื่อใหม่ — `x = 1` โดยไม่เคย `var x` คือ error pub fn assign(env: &EnvRef, name: &str, value: Value) -> bool { let parent = { let mut scope = env.borrow_mut(); if scope.values.contains_key(name) { scope.values.insert(name.to_string(), value); return true; } match &scope.enclosing { Some(p) => Rc::clone(p), None => return false, } }; Environment::assign(&parent, name, value) }ความต่างระหว่าง define กับ assign คือเส้นแบ่งของภาษา ไม่ใช่รายละเอียดของการเขียน
define สร้างชื่อใหม่ในชั้นปัจจุบันเสมอ ส่วน assign ไม่สร้างอะไรเลย มันไล่หาชื่อที่มีอยู่
แล้วคืน false ถ้าไม่เจอ ซึ่งกลายเป็นความผิดพลาดตอนรัน
ผลของเส้นแบ่งนี้คือ count = count + 10; ที่อยู่ใน block ไม่ได้สร้าง count ตัวใหม่ในชั้นนั้น
มันเดินออกไปแก้ตัวข้างนอก และค่านั้นยังอยู่หลัง block จบ
ราคาที่จ่ายคือการพิมพ์ชื่อผิดในการกำหนดค่ากลายเป็นความผิดพลาดตอนรัน ไม่ใช่ตอนคอมไพล์ ภาษาที่ไม่มีเส้นแบ่งนี้จะสร้างตัวแปรใหม่ให้เงียบๆ แล้วโปรแกรมเดินต่อบนชื่อที่ไม่มีใครอ่าน
block จบแล้ว scope ต้องหายไปด้วย
หัวข้อที่มีชื่อว่า “block จบแล้ว scope ต้องหายไปด้วย”หัวข้อนี้แสดงว่าการคืน environment เดิมต้องเกิดขึ้นแม้ตอนที่คำสั่งข้างในพัง
/// รันคำสั่งชุดหนึ่งใน environment ที่กำหนด แล้ว *คืน* environment เดิมเสมอ /// /// จุดที่ต้องระวัง: ถ้าออกจากตรงนี้ตอน `Err` โดยไม่คืนค่า `self.env` /// scope จะรั่วออกไปนอก block · Java ใช้ `try { … } finally { … }` /// ที่นี่เก็บ error ไว้ในตัวแปรก่อน คืน environment แล้วค่อยส่ง error ออกไป pub fn execute_block(&mut self, stmts: &[Stmt], scope: EnvRef) -> Result<(), Flow> { let previous = std::mem::replace(&mut self.env, scope); let mut outcome = Ok(()); for s in stmts { if let Err(flow) = self.execute(s) { outcome = Err(flow); break; } } self.env = previous; outcome }สังเกตว่าที่นี่ไม่มี ? สักตัว ทั้งที่บทที่แล้วใช้มันทุกบรรทัด
เหตุผลคือ ? จะออกจาก function ทันที และการออกทันทีตรงนี้แปลว่า self.env ค้างอยู่ที่ scope
ของ block ที่พังไปแล้ว คำสั่งถัดไปทั้งหมดจะทำงานบนชั้นที่ควรตายไปแล้ว
error จึงถูกเก็บใส่ outcome ไว้ก่อน แล้วคืน environment เดิม แล้วค่อยส่ง error ออกไป
เป็นลำดับเดียวกับที่ finally ของ Java รับประกันให้
ลองรันจริง
หัวข้อที่มีชื่อว่า “ลองรันจริง”หัวข้อนี้แสดงผลรันจริงของ script ที่มีทั้งการบังชื่อและการแก้ค่าข้ามชั้น
// บทที่ 5 — ตัวแปร · block scope · และ environment ที่ซ้อนกันเป็นชั้นvar greeting = "สวัสดี";print greeting;
var count = 1;count = count + 1;print count;
{ // ชื่อเดียวกัน แต่เป็นคนละตัวแปร — ตัวนี้เกิดใน scope นี้แล้วตายไปพร้อมกัน var greeting = "อยู่ใน block"; print greeting;
// ส่วนตัวนี้ไม่ได้ประกาศใหม่ มันจึงเดินออกไปหา count ตัวข้างนอก count = count + 10;}
print greeting;print count;บทนี้รัน script ตรงๆ ไม่ต้องพึ่งโหมด --eval แบบบทที่แล้ว เพราะตอนนี้ภาษามีคำสั่ง print แล้ว
สวัสดี2อยู่ใน blockสวัสดี12ผลนี้ตอบสองคำถามที่ต่างกันคนละขั้ว ทั้งที่ script ใช้ท่าที่หน้าตาคล้ายกันมาก
greetingกลับมาเป็นสวัสดีหลัง block จบ เพราะvarข้างในสร้างตัวใหม่ในชั้นใหม่ ตัวข้างนอกไม่เคยถูกแตะเลย มันแค่ถูกบังไว้ชั่วคราวcountกลายเป็น12และค่านั้นอยู่ต่อ เพราะการกำหนดค่าโดยไม่มีvarไม่สร้างชื่อใหม่ มันเดินออกไปหาตัวเดิมแล้วแก้ที่นั่นprintไม่มีเครื่องหมายคำพูดรอบข้อความ ต่างจากค่าที่ REPL สะท้อนกลับในบทที่แล้ว เพราะการพิมพ์ออกจอเป็นของผู้ใช้ ส่วนการสะท้อนค่าเป็นของคนที่กำลังสำรวจภาษา
สองบรรทัดที่ทำให้ผลออกมาคนละแบบ ต่างกันแค่คำว่า var คำเดียว
นี่คือราคาของภาษาที่แยก define ออกจาก assign — เส้นแบ่งชัดสำหรับคนที่รู้ แต่มองไม่เห็น
สำหรับคนที่ยังไม่รู้ และไม่มี error ไหนมาเตือนเลยเพราะทั้งสองแบบถูกต้องทั้งคู่
ราคาสองข้อของ Rc RefCell
หัวข้อที่มีชื่อว่า “ราคาสองข้อของ Rc RefCell”หัวข้อนี้บอกสิ่งที่ทางเลือกนี้แลกไป ทั้งข้อที่โผล่ตอนรันและข้อที่ไม่โผล่เลย
ราคาข้อแรกคือกฎการยืมยังอยู่ครบทุกข้อ แค่ย้ายเวลาตรวจไปตอนรัน borrow_mut ที่ทับกับ
borrow ที่ยังไม่หมดอายุ ทำให้โปรแกรม panic ทันที
ความผิดพลาดที่คอมไพเลอร์เคยจับให้ฟรี จึงกลายเป็นความผิดพลาดที่ต้องรันถึงจะเจอ และเจอเฉพาะเส้นทางที่ถูกรันจริงเท่านั้น
นี่คือเหตุผลที่ get กับ assign เขียนให้การยืมแต่ละชั้นเริ่มและจบในวงเล็บปีกกาเดียว
มันเป็นวินัยที่ต้องรักษาด้วยมือ เพราะไม่มีด่านไหนบังคับให้แล้ว
ราคาข้อที่สองไม่โผล่ให้เห็นเลย — Rc เก็บวงแหวนไม่เป็น
function ที่ถูกประกาศใน environment แล้วเก็บ environment นั้นไว้ในตัวเอง ทำให้สองก้อนชี้หากันเป็นวง ตัวนับของทั้งคู่จึงไม่มีวันถึงศูนย์ และหน่วยความจำก้อนนั้นไม่ถูกคืนจนกว่าโปรแกรมจะจบ
คอร์สนี้ไม่มี garbage collector และไม่เติม Weak เข้ามาแก้วงแหวน เพราะการทำแบบนั้น
ต้องตัดสินใจให้ได้ก่อนว่าทิศไหนคือทิศที่ไม่ควรถือความเป็นเจ้าของ ซึ่งเป็นหัวข้อของคอร์สอื่น
สิ่งที่คอร์สนี้ทำได้คือพูดออกมาให้ครบ ว่ามันเป็นข้อจำกัดที่รู้อยู่ ไม่ใช่จุดที่มองข้าม บทที่ 7 จะพาไปเจอกรณีที่วงแหวนนี้เกิดขึ้นจริง ตอนที่ closure จับ environment ของตัวเองไว้
สิ่งที่ยังทำไม่ได้
หัวข้อที่มีชื่อว่า “สิ่งที่ยังทำไม่ได้”หัวข้อนี้ชี้ช่องว่างที่เหลือหลังจบบทนี้ ซึ่งเป็นงานของบทถัดไป
ภาษาตอนนี้จำค่าไว้ได้ มี scope ที่ซ้อนกัน และแยกการประกาศออกจากการกำหนดค่าได้แล้ว
สิ่งที่ยังไม่มีคือการเลือกทางและการวนซ้ำ ทุกคำสั่งยังถูกรันเรียงกันจากบนลงล่างเสมอ ไม่มีทางข้ามหรือย้อนกลับ
บทที่ 6 เติม if while และการเรียก function เข้าไป และโครง environment ที่สร้างในบทนี้
คือสิ่งที่ทำให้การเรียก function มีที่เก็บของตัวเองได้โดยไม่ต้องเพิ่มกลไกใหม่
- Crafting Interpreters — Robert Nystrom, บทที่ 8 “Statements and State”
(ตรวจแล้ว 2026-08-12) — โครงของ environment ที่ซ้อนกันเป็นชั้น การแยก
defineออกจากassignและการคืน scope เดิมหลังจบ block ตัว code ในตำราเขียนด้วย Java ซึ่งไม่ต้องตอบ คำถามเรื่องความเป็นเจ้าของเลย จึงไม่มีเนื้อหาส่วนที่บทนี้ใช้เวลาไปมากที่สุด - The Rust Programming Language — บทที่ 15.4 “Rc<T>, the Reference-Counted Smart Pointer”
(ตรวจแล้ว 2026-08-12) — ที่มาของการมีเจ้าของหลายคน และเหตุผลที่
Rcให้สิทธิ์แค่อ่าน คือถ้าให้แก้ได้ด้วยทั้งที่มีผู้ถือหลายคน กฎการยืมก็ไม่เหลืออะไรให้บังคับ - The Rust Programming Language — บทที่ 15.5 “RefCell<T> and the Interior Mutability Pattern” (ตรวจแล้ว 2026-08-12) — ที่มาของการย้ายการตรวจกฎการยืมไปตอนรัน หน้านี้บอกตรงๆ ว่า การละเมิดกฎกลายเป็น panic แทนที่จะเป็นความผิดพลาดตอนคอมไพล์
- The Rust Programming Language — บทที่ 15.6 “Reference Cycles Can Leak Memory”
(ตรวจแล้ว 2026-08-12) — ที่มาของราคาข้อที่สอง คือตัวนับของก้อนที่ชี้หากันเป็นวงไม่มีวันถึงศูนย์
พร้อมทางแก้ด้วย
Weakที่คอร์สนี้เลือกไม่ใช้ - Rust error index — E0596
(ตรวจแล้ว 2026-08-12) — รหัสของการปฏิเสธที่ได้จริงเมื่อพยายามยืมของที่ถืออยู่แบบอ่านอย่างเดียว
มาแก้ ซึ่งเป็นสิ่งที่เกิดขึ้นเมื่อใช้
Rcโดยไม่มีRefCell
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ใน script ของบทนี้ ชื่อ greeting กลับมาเป็นค่าเดิมหลังจบ block ส่วน count ไม่กลับ ทั้งที่ทั้งคู่ถูกเขียนถึงใน block เดียวกัน อะไรคือสาเหตุ