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

class · instance · method — และ​การ​ประกอบ​ทั้ง​คอร์ส​เข้า​ด้วย​กัน

บท​ที่ 7 จบ​ลง​ตรง​ที่​ภาษา nok มี closure ที่​จำ environment ของ​ตัวเอง​ได้ และ​มี resolver ที่​ตัดสิน​ล่วงหน้า​ว่า​ชื่อ​แต่ละ​ตัว​หมาย​ถึง​ตัวแปร​ตัว​ไหน

บท​สุดท้าย​นี้​เติม​ของ​ที่​ค้าง​อยู่​ชิ้น​เดียว คือ class

เรื่อง​ที่​น่า​สนใจ​ของ​มัน​ไม่ใช่​ว่า​มัน​เพิ่ม​กลไก​อะไร​เข้า​มา แต่​คือ​มัน​แทบ​ไม่​เพิ่ม​อะไร​เลย

this ทำงาน​ด้วย​กลไก​ของ closure จาก​บท​ที่ 7 ทั้งดุ้น ส่วน​ที่​เก็บ property ที่​แก้​ได้​ทั้ง​ที่​ถูก​ถือ​ร่วม​กัน​อยู่​หลาย​ที่ ก็​ใช้​เหตุผล​เดียว​กับ Environment ใน​บท​ที่ 5 เป๊ะๆ

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

บท​นี้​คัด code จาก scripts/rust-interpreter/src/class.rs เป็น​หลัก และ​ยก​จาก src/main.rs เพิ่ม​ใน​ส่วน capstone

รัน​ตาม​ได้​ด้วย cargo build ใน scripts/rust-interpreter แล้ว​สั่ง ./target/debug/nok examples/08-classes.nok ผล​ที่​ออก​มา​คือ​สิ่ง​เดียว​กับ​ที่​หัวข้อ​สุดท้าย​ของ​บท​แสดง​ไว้​ที​ละ byte

หัวข้อ​นี้​แสดง​ว่า class ของ nok เก็บ​อะไร​ไว้​บ้าง และ​ทำไม​การ​เรียก​มัน​ถึง​กลาย​เป็นการ​สร้าง​วัตถุ

src/class.rs · ตัว class ทั้งหมด
pub struct Class {
pub name: String,
/// ตาราง method · ไม่มีสาย superclass ให้ไล่ต่อ (ดูหมายเหตุหัว file)
pub methods: HashMap<String, Rc<Function>>,
}
impl Class {
pub fn find_method(&self, name: &str) -> Option<Rc<Function>> {
self.methods.get(name).cloned()
}
}

หน้าตา​ของ​มัน​คือ​ชื่อ​กับ​ตาราง​ที่​แมป MethodMethodฟังก์ชัน​ที่​ผูก​อยู่​กับ class และ​เรียก​ผ่าน instance ได้ โดย​รู้จัก​ตัว instance ที่​เรียก​มัน​ผ่าน​ชื่อ​พิเศษ ไป​หา​ตัว function ของ​มัน ไม่มี​อะไร​มากกว่า​นั้น

find_method ดู​ตาราง​ของ class ตัวเอง​แล้ว​จบ​ใน​บรรทัด​เดียว ไม่มี​ที่​ให้​ไล่​ต่อ

class ที่​ประกาศ​แล้ว​ถูก​เก็บ​ลง environment เหมือน​ตัวแปร​ทั่วไป มัน​จึง​ส่ง​เข้า function ได้ และ​พิมพ์​ออก​มา​ดู​ได้

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

src/interpreter.rs · เรียก class คือ​การ​สร้าง instance (ตัด​มา​บาง​ส่วน)
Value::Class(c) => {
let instance = Rc::new(Instance::new(Rc::clone(&c)));
match c.find_method("init") {
Some(init) => {
check_arity(&c.name, init.arity(), args.len(), line)?;
init.bind(&instance).call(self, args)?;
}
None => check_arity(&c.name, 0, args.len(), line)?,
}
Ok(Value::Instance(instance))
}

init ไม่ใช่​คำ​สงวน​ของ​ภาษา มัน​เป็น​ชื่อ​ธรรมดา​ที่ code หา​ด้วย​ข้อความตรงๆ ตอน​สร้าง instance

แต่​ชื่อ​นี้​ก็​ไม่​ได้​ธรรมดา​เสียที​เดียว method ที่​ชื่อ init ถูก​ทำ​เครื่องหมาย​ไว้​ให้​คืน this เสมอ และ resolver ใน​บท​ที่ 7 ก็​ห้าม​ไม่​ให้​มัน return ค่า​อื่น​ออก​มา

class ที่​ไม่มี init จึง​รับ argument ไม่​ได้​เลย เพราะ​กรณี​ที่​หา​ชื่อ​นั้น​ไม่​เจอ บังคับ arity เป็น​ศูนย์

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

method ที่​ตั้งใจ​ให้​เป็น init แต่​พิมพ์​เป็น innit จะ​กลาย​เป็น method ธรรมดา​ที่​ไม่มี​ใคร​เรียก แล้ว​ความ​ผิดพลาด​จะ​ไป​โผล่​ตอน​สร้าง instance ใน​รูป​ของ arity ที่​ไม่​ตรง ซึ่ง​ชี้​ไป​คนละ​ที่​กับ​ต้นเหตุ

inheritance ถูก​ตัด​ออก​จาก​ภาษา และ code พูด​เรื่อง​นั้น​เอง

หัวข้อ​ที่​มีชื่อ​ว่า “inheritance ถูก​ตัด​ออก​จาก​ภาษา และ code พูด​เรื่อง​นั้น​เอง”

หัวข้อ​นี้​บอกตรงๆ ว่า​คอร์ส​ไม่​สอน inheritance และ​ไล่​ว่า​ต้อง​เติม​อะไร​บ้าง​ถ้า​จะ​ทำ​ต่อ​เอง

src/class.rs · หมายเหตุ​หัว file ที่​ประกาศ​ขอบเขต​ไว้
//! บทที่ 8 — class · instance · method · `this`
//!
//! **inheritance อยู่นอกขอบเขตของคอร์สนี้** และ file นี้ก็ไม่มีมันจริงๆ:
//! ไม่มีฟิลด์ `superclass` ไม่มีการไล่หา method ขึ้นไปตามสายเลือด
//! `find_method` ดูตารางของ class ตัวเองแล้วจบ
//!
//! `this` ทำงานด้วยกลไกเดียวกับ closure ในบทที่ 7 ทั้งหมด ไม่มีอะไรใหม่:
//! ตอนหยิบ method ออกจาก instance เราสร้าง environment เล็กๆ ที่มี `this`
//! อยู่ตัวเดียว แล้วให้ method ตัวใหม่ปิดทับ environment นั้น · ผลคือ method
//! ที่ถูกหยิบออกมาเก็บใส่ตัวแปร ยังจำ instance ของมันได้ แม้ instance เดิม
//! จะไม่มีใครอ้างถึงแล้ว

การ​ตัด​ครั้ง​นี้​ไม่ใช่​การ​ลืม และ​ไม่ใช่​การ​เก็บ​ไว้​เป็น​บท​ที่​เก้า มัน​คือ​ขอบเขต​ที่​เจ้าของ​งาน​เคาะ​ไว้​ตั้งแต่​ต้น

ถ้า​จะ​เติม inheritance เข้าไป​เอง​หลัง​จบ​คอร์ส สิ่ง​ที่​ต้อง​แตะ​ไม่​ได้​อยู่​ที่ class.rs ที่​เดียว

  • scanner ต้อง​รู้จัก​คำ​สงวน​ตัว​ใหม่​สำหรับ​อ้าง​ถึง class แม่ ตอน​นี้​ตาราง keyword ยัง​ไม่มี​คำ​นั้น
  • ไวยากรณ์​กับ AST ต้อง​รับ​รูป​ประโยค​ที่​บอกว่า class หนึ่ง​สืบทอด​จาก​อีก class และ​เพิ่ม​รูป expression ตัว​ใหม่
  • Class ต้อง​มี​ฟิลด์ที่​ชี้​ไป​หา class แม่ และ find_method ต้อง​ไล่​ขึ้น​ไป​ต่อ​เมื่อ​หา​ใน​ตาราง​ตัวเอง​ไม่​เจอ
  • resolver ต้อง​มี scope พิเศษ​อีก​ชั้น​สำหรับ​ชื่อ​ที่​อ้าง​ถึง class แม่ แบบ​เดียว​กับ​ที่​ตอน​นี้​ทำ​กับ this

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

ข้อดี​ที่​ได้​กลับ​มา​จาก​การ​ไม่มี​สายเลือด​คือ​ความ​ล้มเหลว​ตรง​ไป​ตรง​มา — หา method ไม่​เจอ​ใน​ตาราง​เดียว​คือ​จบ ไม่มี​กรณี​ที่ method ถูก​หยิบ​มา​จาก​ที่​ที่​คน​อ่าน code ไม่​ได้​มอง

หัวข้อ​นี้​อธิบาย​ว่า​ทำไม​ที่​เก็บ property ของ instance ต้อง​อยู่​หลัง RefCell และ​ราคา​ของ​ทาง​เลือก​นั้น

src/class.rs · วัตถุ​หนึ่ง​ตัว​ที่​สร้าง​จาก class
/// วัตถุหนึ่งตัวที่สร้างจาก class
///
/// `fields` เป็น `RefCell` ไม่ใช่ฟิลด์ธรรมดา เพราะ `obj.x = 1` ต้องแก้ instance
/// ที่กำลังถูกถือร่วมกันอยู่หลายที่ (ตัวแปร · `this` ใน method · closure ที่ผูกไว้)
/// เหตุผลเดียวกับ `Environment` ในบทที่ 5 เป๊ะๆ
pub struct Instance {
pub class: Rc<Class>,
fields: RefCell<HashMap<String, Value>>,
}

InstanceInstanceวัตถุ​ที่​ถูก​สร้าง​จาก class หนึ่ง​ตัว มี​ที่​เก็บ property ของ​ตัวเอง​แยก​จาก​ตัว​อื่น ตัว​หนึ่ง​ถือ class ของ​มัน​ไว้​ด้วย Rc และ​ถือ​ที่​เก็บ property ของ​ตัวเอง​แยก​จาก instance ตัว​อื่น

จุด​ที่​ต้อง​คิด​คือ​คำ​ว่า RefCell

คำ​สั่ง c.value = 1; แก้​ค่า​ใน instance ที่​ตอน​นั้น​มี​คน​ถือ​อยู่​หลาย​คน​พร้อม​กัน ทั้ง​ตัวแปร​ที่​ผู้​ใช้​ประกาศ และ environment ของ method ที่​กำลัง​ทำงาน​อยู่

Rust ห้าม​ยืม​แบบ​แก้ไข​ได้​ขณะ​ที่​ยัง​มี​การ​ยืม​แบบ​อ่าน​ค้าง​อยู่ ตั้งแต่​ตอน​คอมไพล์ ทางออก​จึง​เป็น​ตัว​เดียว​กับ​บท​ที่ 5

src/class.rs · ตั้ง​ค่า property
pub fn set(&self, name: &str, value: Value) {
self.fields.borrow_mut().insert(name.to_string(), value);
}

&self ตัว​นี้​อ่าน​แล้ว​ขัด​สายตา เพราะ​มัน​แก้​ของ​ทั้ง​ที่​รับ​มา​แบบ​อ่าน​อย่าง​เดียว

นั่น​คือ​ทั้งหมด​ของ​คำ​ว่า interior mutability กฎ​การ​ยืม​ไม่​ได้​หาย​ไป มัน​ย้าย​ไป​ตรวจ​ตอน​รัน​แทน

ราคา​ที่​จ่าย​จึง​ชัด โปรแกรม​ที่​ยืม​ซ้อน​กัน​ผิด​จังหวะ​จะ​พัง​ตอน​รัน แทนที่​จะ​คอมไพล์​ไม่​ผ่าน​ตั้งแต่​แรก

หัวข้อ​ย่อย​นี้​แสดง​ลำดับ​การ​หา property และ​ผล​ที่​ตาม​มา​เมื่อ​ชื่อ​สอง​ฝั่ง​ชน​กัน

src/class.rs · หา property ตาม​ลำดับ​ที่​เลือก​ไว้
/// หา property: field ก่อน แล้วค่อย method
///
/// ลำดับนี้แปลว่า field ที่ชื่อชนกับ method จะบัง method ไว้ ซึ่งเป็นกฎที่
/// ต้องเลือกข้างให้ชัด · nok เลือกให้ field ชนะ เพราะมันคือสิ่งที่โปรแกรมเมอร์
/// เพิ่งเขียนลงไปเอง
///
/// รับ `self: &Rc<Self>` ไม่ใช่ `&self` เพราะการผูก `this` ต้องเก็บ `Rc`
/// ของ instance ตัวนี้ไว้ในนั้น — จาก `&self` เปล่าๆ หา `Rc` กลับไม่ได้
pub fn get(self: &Rc<Self>, name: &str) -> Option<Value> {
if let Some(v) = self.fields.borrow().get(name) {
return Some(v.clone());
}
let method = self.class.find_method(name)?;
Some(Value::Function(Rc::new(method.bind(self))))
}

ลำดับ​นี้​ตัดสิน​คำถาม​ที่​ต้อง​มี​คำ​ตอบ​เดียว คือ​ถ้า field กับ method ชื่อ​เดียวกัน ใคร​ชนะ

nok ให้ field ชนะ เหตุผล​คือ field คือ​สิ่ง​ที่​โปรแกรมเมอร์​เพิ่ง​เขียน​ลง​ไป​เอง ส่วน method มา​จาก​ตอน​ประกาศ class

ราคา​ของ​กฎ​นี้​คือ field ที่​ตั้ง​ชื่อ​ชน​กับ method จะ​บัง method นั้น​ไป​เงียบๆ ไม่มี​คำ​เตือน​สัก​บรรทัด

อีก​จุด​ที่​ต้อง​อ่าน​ช้าๆ คือ self: &Rc<Self> ที่​รับ​มา​แทน &self เปล่าๆ

การ​ผูก this ต้อง​เก็บ​ตัว​ชี้​ของ instance ตัว​นี้​ไว้​ใน​นั้น และ​จาก &self ธรรมดา​หา Rc ที่​ครอบ​มัน​อยู่​กลับ​ไม่​ได้

หัวข้อ​นี้​แสดง​ว่าการ​ผูก this ใช้​กลไก​เดียว​กับ closure ทุก​ประการ ไม่มี​เครื่อง​มือใหม่​เข้า​มา​เลย

src/class.rs · ผูก method เข้า​กับ instance หนึ่ง​ตัว
impl Function {
/// ผูก method เข้ากับ instance หนึ่งตัว แล้วคืน function *ตัวใหม่*
///
/// ไม่ได้แก้ของเดิม — method บน class ยังเป็นตัวที่ไม่ผูกกับใคร ทุกครั้งที่
/// หยิบมันออกจาก instance จะได้ function ที่ผูก `this` ไว้แล้วคนละตัว
///
/// นี่คือจุดที่ `Rc` สร้างวงแหวนได้: instance ถือ class · class ถือ method ·
/// method ที่ผูกแล้วถือ environment · environment ถือ instance กลับมา
/// หน่วยความจำก้อนนี้จึงไม่ถูกคืน · nok ไม่มี GC และรับข้อแลกนี้ไว้โดยตั้งใจ
pub fn bind(&self, instance: &Rc<Instance>) -> Function {
let scope = Environment::child(&self.closure);
scope.borrow_mut().define("this", Value::Instance(Rc::clone(instance)));
Function::new(Rc::clone(&self.decl), scope, self.is_initializer)
}
}

สาม​บรรทัด​ใน​ตัว function นี้​คือ​ทั้งหมด​ของ​คำ​ว่า this

บรรทัด​แรก​สร้าง environment ลูก​ของ environment ที่ method ตัว​นั้น​ถูก​ประกาศ​ไว้

บรรทัด​ที่​สอง​ใส่​ชื่อ​เดียว​ลง​ไป​ใน​นั้น ส่วน​บรรทัด​ที่​สาม​สร้าง function ตัว​ใหม่​ที่​ปิด​ทับ environment ก้อน​นั้น

resolver ใน​บท​ที่ 7 นับ​ชั้น scope ให้​ตรง​กับ environment ชุด​นี้​เป๊ะๆ สอง​ฝั่ง​จึง​ต้อง​แก้​พร้อม​กัน​เสมอ

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

หัวข้อ​ย่อย​นี้​ระบุ​สิ่ง​ที่​ต้อง​จ่าย​ให้​กับ​การ​ที่ bind คืน function ตัว​ใหม่​เสมอ

ของ​ฟรี​ไม่มี และ bind คิด​ราคา​สอง​ทาง

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

ทาง​ที่​สอง​หนัก​กว่า และ code เขียน​บอกไว้ตรงๆ ใน​หมายเหตุ​ของ bind ว่า​ใคร​ถือ​ใคร​อยู่

  • instance ถือ class ของ​มัน
  • class ถือ​ตาราง method
  • method ที่​ผูก​แล้ว​ถือ environment ที่ bind เพิ่ง​สร้าง
  • environment ก้อน​นั้น​ถือ instance กลับ​มา​ที่​จุด​เริ่ม

วงแหวน​แบบ​นี้​ทำให้​ตัว​นับ​ของ Rc ไม่มี​วัน​ถึง​ศูนย์ หน่วย​ความ​จำ​ก้อน​นั้น​จึง​ไม่​ถูก​คืน​จนกว่า​โปรแกรม​จะ​จบ

nok ไม่มี garbage collector และ​รับ​ข้อ​แลก​นี้​ไว้​โดย​ตั้งใจ ภาษา​ที่​ทำงาน​จริง​ต้อง​แก้​เรื่อง​นี้ ส่วน​ภาษา​สำหรับ​เรียน​ไม่​ต้อง

หัวข้อ​นี้​แสดง function ที่​รัน script ทั้ง file และ​ชี้​ว่า​แต่ละ​ด่าน​ใน​นั้น​มา​จาก​บท​ไหน

src/main.rs · รัน script ทั้ง file เป็น​โปรแกรม​เดียว
fn run_file(path: &str) -> u8 {
let source = match read_source(path) {
Ok(s) => s,
Err(code) => return code,
};
let (tokens, scan_errors) = Scanner::new(&source).scan_tokens();
if !scan_errors.is_empty() {
for e in &scan_errors {
println!("{e}");
}
return EXIT_STATIC;
}
let parsed = parser::parse(tokens, 0);
if !parsed.errors.is_empty() {
for e in &parsed.errors {
println!("{e}");
}
return EXIT_STATIC;
}
let resolved = resolver::resolve(&parsed.stmts);
if !resolved.errors.is_empty() {
for e in &resolved.errors {
println!("{e}");
}
return EXIT_STATIC;
}
let mut interpreter = Interpreter::new();
interpreter.add_locals(resolved.locals);
match interpreter.interpret(&parsed.stmts) {
Ok(()) => 0,
Err(e) => {
println!("{e}");
EXIT_RUNTIME
}
}
}

อ่าน​จาก​บน​ลง​ล่าง​แล้ว​จะ​เห็น​คอร์ส​ทั้ง​ใบ​เรียง​กัน​อยู่

  • scanner จาก​บท​ที่ 1 แปลง​ข้อความ​เป็น​ลำดับ token
  • parser จาก​บท​ที่ 3 ประกอบ token เป็นต้นไม้​ตาม​รูป​ที่​บท​ที่ 2 ออกแบบ​ไว้
  • resolver จาก​บท​ที่ 7 ตอบ​ล่วงหน้า​ว่า​ชื่อ​แต่ละ​ตัว​อยู่ scope ชั้น​ไหน
  • interpreter จาก​บท​ที่ 4 ถึง 6 และ​บท​นี้ เดิน​ต้นไม้​นั้น​แล้ว​ลงมือ​รัน
flowchart TD
    A["ข้อความจาก file .nok"] --> B["scanner · บทที่ 1"]
    B -->|"แบ่งคำไม่ผ่าน"| X["พิมพ์ทุกความผิดพลาด แล้วจบด้วย exit code 65"]
    B --> C["parser · บทที่ 2 และ 3"]
    C -->|"ไวยากรณ์ไม่ผ่าน"| X
    C --> D["resolver · บทที่ 7"]
    D -->|"ตรวจ scope ไม่ผ่าน"| X
    D --> E["interpreter · บทที่ 4 ถึง 6 และบทที่ 8"]
    E -->|"พังกลางคัน"| Y["พิมพ์ความผิดพลาด แล้วจบด้วย exit code 70"]
    E --> F["รันครบทุกคำสั่ง แล้วจบด้วย exit code 0"]

คำ​บรรยาย​ภาพ: สาม​ด่าน​แรก​หยุด​ก่อน​ที่​โปรแกรม​จะ​ได้​รัน​สัก​คำ​สั่ง มี​แต่​ด่าน​สุดท้าย​เท่านั้น​ที่​ความ​ผิดพลาด​เกิด​ขึ้น​หลัง​จาก​บาง​ส่วน​ของ​โปรแกรม​ทำงาน​ไป​แล้ว

ความ​ต่าง​ระหว่าง​สอง​แบบ​นั้น​ถูก​บันทึก​ไว้​ใน exit code

src/main.rs · exit code ของ​โปรแกรม
/// ใช้คำสั่งผิดรูป (EX_USAGE)
const EXIT_USAGE: u8 = 64;
/// อ่าน file ไม่ได้ (EX_NOINPUT)
const EXIT_NO_INPUT: u8 = 66;
/// ผิดพลาดตั้งแต่ก่อนรัน — แบ่งคำ · ไวยากรณ์ · ตรวจ scope (EX_DATAERR)
const EXIT_STATIC: u8 = 65;
/// ผิดพลาดระหว่างรัน (EX_SOFTWARE)
const EXIT_RUNTIME: u8 = 70;

การ​แยก​สอง​กรณี​นี้​ไม่​ได้​มี​ไว้​ให้​คน​อ่าน คน​อ่าน​เห็น​ข้อความ​ความ​ผิดพลาด​อยู่​แล้ว

มัน​มี​ไว้​ให้​เครื่อง​อ่าน script ที่​เรียก nok ต่อ​อีก​ที​จะ​ได้​แยก​ออกว่า​โปรแกรม​ไม่​ได้​เริ่ม กับ​โปรแกรม​เริ่ม​แล้ว​พัง​กลาง​ทาง

หัวข้อ​ย่อย​นี้​ชี้​จุด​เดียว​ใน REPL ที่​พลาด​แล้ว​ทำให้​คำ​ตอบ​ของ resolver จาก​บรรทัด​ก่อน​ใช้​ไม่​ได้

src/main.rs · REPL ต้อง​เดิน​เลข​ต่อ​จาก​บรรทัด​ก่อน (ตัด​มา​บาง​ส่วน)
fn run_repl() -> u8 {
let mut interpreter = Interpreter::new();
let stdin = io::stdin();
// เลข id ของ node เดินหน้าต่อไปเรื่อยๆ ข้ามบรรทัด · ถ้ารีเซ็ตเป็น 0 ทุกบรรทัด
// คำตอบของ resolver จากบรรทัดก่อนจะถูกทับ แล้ว closure เก่าจะหาชื่อผิดตัว
let mut next_id = 0usize;

ตัว​รัน file เห็น​โปรแกรม​ทั้ง​ก้อน​ใน​คราว​เดียว ส่วน REPL เห็น​ที​ละ​บรรทัด​และ​ต้อง​ต่อ​ของ​เก่า​ให้​ติด

resolver ตอบ​คำถาม​เรื่อง scope โดย​อ้าง​ถึง​แต่ละ​จุด​ใน​ต้นไม้​ด้วย​เลข​ประจำ​ตัว

ถ้า​เลข​นั้น​เริ่ม​นับ​ใหม่​จาก​ศูนย์​ทุก​บรรทัด คำ​ตอบ​ของ​บรรทัด​ก่อน​จะ​ถูก​ทับ​ด้วย​คำ​ตอบ​ของ​บรรทัด​ใหม่​ที่​บังเอิญ​ได้​เลข​เดียวกัน

ผล​คือ closure ที่​สร้าง​ไว้​เมื่อ​บรรทัด​ก่อน​จะ​ไป​หา​ชื่อ​ผิด​ตัว โดย​ไม่มี​อะไร​ฟ้อง

หัวข้อ​นี้​แสดง script ตัวอย่าง​ของ​บท​และ​ผล​รัน​จริง​ของ​มัน พร้อม​อ่าน​ผล​ที​ละ​บรรทัด

examples/08-classes.nok
// บทที่ 8 — class · instance · method · this
// inheritance ไม่อยู่ในภาษา nok โดยตั้งใจ ไม่มี superclass ให้เขียนตรงไหนเลย
class Counter {
init(start) {
this.value = start;
}
bump() {
this.value = this.value + 1;
// คืน this ทำให้เรียกต่อกันเป็นสายได้
return this;
}
show() {
print this.value;
}
}
fun bumpAll(counter, times) {
for (var i = 0; i < times; i = i + 1) counter.bump();
return counter;
}
var c = Counter(0);
bumpAll(c, 3).show();
c.bump().bump().show();
class Greeter {
init(name) {
this.name = name;
}
greet() {
return "สวัสดี " + this.name;
}
}
var g = Greeter("nok");
print g.greet();
// หยิบ method ออกมาเก็บใส่ตัวแปร — this ถูกผูกไว้ตั้งแต่ตอนหยิบ
var bound = g.greet;
g = nil;
print bound();
print Counter;
print c;
// อ่าน property ที่ไม่มีคือ error ตอนรัน ไม่ใช่ nil เงียบๆ
print c.missing;

script นี้​ไม่​ได้​ใช้​แค่​ของ​ใน​บท​นี้ มัน​ใช้​ของ​จาก​ทุก​บท​พร้อม​กัน ทั้ง​ตัวแปร ทั้ง loop ทั้ง function ที่​รับ instance เข้าไป

expected/08-classes.txt
3
5
สวัสดี nok
สวัสดี nok
<class Counter>
<instance Counter>
[บรรทัด 50] ผิดพลาดตอนรัน: ไม่มี property ชื่อ 'missing' บน instance ของ Counter

อ่าน​ผล​นี้​แล้ว​เห็น​สี่​อย่าง

  • สอง​บรรทัด​แรก​คือ counter ตัว​เดียวกัน ค่า​เดิน​จาก​ศูนย์​ขึ้น​ไป​เรื่อยๆ ตลอด script เพราะ function ที่​รับ instance เข้าไป​แก้​ของ​ตัว​จริง ไม่ใช่​สำเนา
  • ข้อความ​ทักทาย​ออก​มาสองครั้ง​เหมือนกันเป๊ะ ครั้ง​ที่​สอง​มา​จาก method ที่​ถูก​หยิบ​เก็บ​ใส่​ตัวแปร​ไว้​ก่อน แล้ว​ค่อย​เรียก​หลัง​จาก​ตัวแปร​เดิม​ถูก​ทับ​ด้วย nil ไป​แล้ว
  • <class Counter> กับ <instance Counter> เป็น​คนละ​อย่าง​กัน ตัว​แรก​คือ​แบบพิมพ์ ตัว​หลัง​คือ​ของ​ที่​ปั๊ม​ออก​มา​จาก​แบบ
  • อ่าน property ที่​ไม่มี​คือ​ความ​ผิดพลาด​ตอน​รัน ไม่ใช่ nil เงียบๆ ข้อความ​ที่​ได้​คือ [บรรทัด 50] ผิดพลาดตอนรัน: ไม่มี property ชื่อ 'missing' บน instance ของ Counter แล้ว​โปรแกรม​หยุด​ตรง​นั้น

การ​เลือก​ให้​มัน​เป็น​ความ​ผิดพลาด​แทน​การ​คืน nil เงียบๆ คือ​การ​เลือก​ข้าง และ​ราคา​ของ​มัน​คือ​ความ​ยืดหยุ่น​ที่​หาย​ไป

ภาษา​ที่​คืน nil ให้ property ที่​ไม่มี เขียน code แบบ​เดา​ไป​ก่อน​ได้ ส่วน nok บังคับ​ให้​ตั้ง​ชื่อ​ให้​ถูก​ตั้งแต่​แรก

หัวข้อ​นี้​สรุป​ว่า​แปดบท​ประกอบ​กัน​เป็น​อะไร และ​บอกว่า​อะไร​อยู่​นอก​คอร์ส​นี้จริงๆ

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

แต่ละ​ชั้น​ทำงาน​เดียว​และ​ตอบ​คำถาม​เดียว ชั้น​ที่​ตอบ​ไม่​ได้​ก็​ส่ง​ความ​ผิดพลาด​ขึ้น​มา​พร้อม​เลข​บรรทัด แทนที่​จะ​เดา

ที่​สำคัญ​กว่า​นั้น​คือ​ทุก​ชั้น​ถูก​ออกแบบ​ใหม่​ให้​เป็น Rust ไม่ใช่​แปล​จาก​ตำรา​ที่​เขียน​ด้วย Java มาตรงๆ

  • enum กับ match ทำงาน​แทน Visitor pattern ได้​ทั้ง​ชุด
  • Result ทำงาน​แทน exception และ​คอมไพเลอร์​บังคับ​ให้​ทุก​คน​ที่​รับ​มัน​ต้อง​ตัดสิน​ใจ
  • ownership เป็น​ตัว​บังคับ​ให้ environment ต้อง​เป็น Rc<RefCell<…>> ไม่ใช่​รสนิยม​ของ​คน​เขียน

ขั้น​ต่อ​ไป​ไม่ใช่​บท​ที่​เก้า ทาง​ที่​คน​ถาม​ถึง​บ่อย​ที่สุด​คือ bytecode virtual machine ซึ่ง​เป็น​คนละ​เรื่อง​กับ​สิ่ง​ที่​ทำ​มา​ทั้ง​คอร์ส

tree-walking interpreter เดิน​บน​ต้นไม้​โดยตรง ส่วน bytecode VM คอมไพล์​ต้นไม้​ลง​เป็น​ชุด​คำ​สั่ง​ของ​เครื่อง​สมมติ​ก่อน​แล้ว​ค่อย​รัน นั่น​คือ interpreter คนละ​ตัว ไม่ใช่​การ​ต่อยอด​จาก​ตัว​นี้

คอร์ส​นี้​ไม่​เคย​อ้าง​ตัวเลข​ความเร็ว​สัก​จุด​เดียว จึง​ไม่มี​ข้อ​อ้าง​ไหน​ต้อง​ถอน​คืน​ตรง​นี้ มี​แต่​ขอบเขต​ที่​ต้อง​บอก​ให้​ครบ

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

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Crafting Interpreters — Robert Nystrom, บท​ที่ 12 “Classes” (ตรวจ​แล้ว 2026-08-12) — โครง​ของ class ที่​คอร์ส​นี้​เดิน​ตาม ทั้ง​ลำดับ​ที่​หา field ก่อน method และ​การ​ผูก this ด้วย environment เล็กๆ หนึ่ง​ชั้น ตัว code ใน​ตำรา​เขียน​ด้วย Java และ​มี inheritance อยู่​ใน​บท​ถัด​ไป ซึ่ง​คอร์ส​นี้​ตัด​ออก
  • The Rust Programming Language — บท​ที่ 15.5 “RefCell and the Interior Mutability Pattern” (ตรวจ​แล้ว 2026-08-12) — ที่มา​ของ​การ​ย้าย​การ​ตรวจ​กฎ​การ​ยืม​ไป​ทำ​ตอน​รัน ซึ่ง​เป็น​เหตุผล​ที่​ที่​เก็บ property ของ instance อยู่​หลัง RefCell
  • The Rust Programming Language — บท​ที่ 15.6 “Reference Cycles Can Leak Memory” (ตรวจ​แล้ว 2026-08-12) — คำ​อธิบาย​ของ​วงแหวน​ที่ bind สร้าง​ขึ้น และ​เหตุผล​ที่​ตัว​นับ​ของ Rc ไม่มี​วัน​ถึง​ศูนย์​เมื่อ​ของ​สอง​ก้อน​อ้าง​ถึงกันเอง

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

ข้อ 1 / 3

ภาษา nok รัน script ที่ประกาศ class Box โดยให้ init ตั้ง this.size = 1 และมี method ชื่อ size ที่คืนค่า 99 จากนั้นเขียน var b = Box(); print b.size; ผลที่พิมพ์ออกมาคืออะไร