scanner — จากข้อความดิบเป็นลำดับ token
interpreter ไม่ได้เริ่มจากการเข้าใจโปรแกรม มันเริ่มจากการยอมรับว่าตัวเองยังไม่เข้าใจอะไรเลย
สิ่งที่มันมีตอนเริ่มคือข้อความก้อนหนึ่ง ยาวหลายพันอักขระ ไม่มีโครงสร้าง ไม่มีความหมาย มีแค่ตัวอักษรเรียงกัน
ด่านแรกจึงไม่ใช่การหาความหมาย แต่คือการตอบคำถามที่เล็กกว่านั้นมาก — ตรงไหนคือ “คำ” หนึ่งคำ
ทั้งคอร์สเขียนด้วย Rust standard library ล้วน ไม่มี external crate สักตัว
ตรวจได้จาก scripts/rust-interpreter/Cargo.toml ที่ไม่มีหัวข้อ [dependencies] อยู่เลย
ทุกบล็อก code ในบทเรียนนี้ถูกคัดมาจาก scripts/rust-interpreter/src/ ทีละ byte
และมีเทสต์เทียบไว้ ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ซอร์ส เทสต์จะแดงทันที
ทำไมต้องแบ่งคำก่อน
หัวข้อที่มีชื่อว่า “ทำไมต้องแบ่งคำก่อน”หัวข้อนี้บอกว่าการแบ่งคำแยกออกมาเป็นด่านของตัวเอง เพราะมันตอบคำถามคนละข้อกับการหาความหมาย
ลองดูบรรทัดนี้ count != 3; ถ้าอ่านทีละอักขระ เครื่องหมาย ! กับ = คือสองตัว
แต่ในภาษา มันคือของชิ้นเดียวชื่อ != การตัดสินว่าสองอักขระที่ติดกันเป็นของชิ้นเดียวหรือสองชิ้น
เป็นงานที่ไม่ต้องรู้ความหมายของโปรแกรมเลยสักนิด
งานนี้จึงถูกแยกออกมาเป็นด่านแรกที่เรียกว่า scanner ผลของมันคือลำดับ TokenTokenหน่วยย่อยที่สุดที่ภาษาเข้าใจ — ผลของการแบ่งข้อความต้นฉบับออกเป็นชิ้น เช่น ชื่อตัวแปร เครื่องหมาย หรือตัวเลข แต่ละชิ้นพกชนิดของตัวเองกับตำแหน่งบรรทัดที่มันอยู่มาด้วย ซึ่งด่านถัดไปจะรับไปหาโครงสร้างต่อ
ของชิ้นนี้มีสามชื่อที่เจอบ่อยพอกัน คือ scanner, tokenizer และ lexer
คอร์สนี้ใช้คำว่า scanner ตลอด แต่ถ้าไปอ่านตำราเล่มอื่นแล้วเจออีกสองคำ ให้รู้ว่าเป็นของอย่างเดียวกัน
token พก payload มาเอง — จุดแรกที่ Rust แยกทางจากตำรา
หัวข้อที่มีชื่อว่า “token พก payload มาเอง — จุดแรกที่ Rust แยกทางจากตำรา”หัวข้อนี้แสดงว่า
enumของ Rust ทำให้ token เก็บค่าที่แปลแล้วไว้ในตัวมันเองได้ โดยไม่ต้องมีฟิลด์กลาง
ตำราที่คนอ่านกันมากที่สุดเขียนด้วย Java ซึ่งไม่มี sum type จึงต้องเก็บค่าของ literal
ไว้ในฟิลด์ชนิด Object แล้ว cast ตอนหยิบออกมาใช้
Rust ไม่ต้องทำแบบนั้น เพราะแต่ละกรณีของ enum พาข้อมูลของตัวเองติดมาได้เลย
/// ชนิดของ token · literal พก payload ติดตัวมาด้วย ไม่ต้องมีฟิลด์ `Object` แยก#[derive(Debug, Clone, PartialEq)]pub enum TokenKind { // เครื่องหมายตัวเดียว LeftParen, RightParen, LeftBrace, RightBrace, Comma, Dot, Minus, Plus, Semicolon, Slash, Star,
// หนึ่งหรือสองตัวอักษร — ต้องมองตัวถัดไปก่อนถึงจะตัดสินได้ Bang, BangEqual, Equal, EqualEqual, Greater, GreaterEqual, Less, LessEqual,
// literal Identifier(String), Str(String), Number(f64),Number(f64) กับ Str(String) คือหัวใจของความต่างนี้ ค่าที่แปลแล้วอยู่ในตัว token เอง
ผลที่ตามมามีสองอย่าง อย่างแรกคือ match ดึงค่าออกมาได้โดยไม่ต้อง cast
อย่างที่สองสำคัญกว่า — กรณี “ชนิดผิด” หายไปจากโปรแกรมตั้งแต่ตอนคอมไพล์ ไม่ใช่ตอนรัน
ราคาที่จ่ายคือขนาด enum ของ Rust เท่ากับ variant ที่ใหญ่ที่สุดเสมอ
Semicolon ที่ไม่พกอะไรเลยจึงกินพื้นที่เท่ากับ Str(String) ที่พกข้อความทั้งก้อน
สำหรับ scanner ราคานี้รับได้ เพราะ token ถูกสร้างแล้วเดินต่อไปหาด่านถัดไปทันที แต่ถ้าเป็นโครงสร้างที่ต้องถือของหลายล้านชิ้นพร้อมกัน ตัวเลือกนี้ต้องคิดใหม่
lexeme กับค่าที่แปลแล้ว ไม่ใช่ของอย่างเดียวกัน
หัวข้อที่มีชื่อว่า “lexeme กับค่าที่แปลแล้ว ไม่ใช่ของอย่างเดียวกัน”หัวข้อย่อยนี้แยกข้อความดิบออกจากค่าที่แปลแล้ว ซึ่งเป็นคนละอย่างกันแต่ถูกสับสนกันบ่อย
LexemeLexemeข้อความดิบของ token ตามที่ปรากฏใน file ต้นฉบับจริงๆ ต่างจากค่าที่ตีความแล้ว — `"123"` เป็น lexeme ส่วนเลข 123 เป็นค่าที่ได้จากมัน คือข้อความดิบตามที่ปรากฏใน source จริงๆ ส่วน payload คือค่าที่แปลจากมันแล้ว
/// token หนึ่งตัว: ชนิด · ข้อความดิบที่มันกินไป (lexeme) · บรรทัดที่พบ////// `lexeme` กับ payload ใน `kind` ไม่ใช่ของอย่างเดียวกัน — `3` (lexeme) เทียบกับ/// `Number(3.0)` (ค่าที่แปลแล้ว) และ `"ready"` (lexeme มีเครื่องหมายคำพูดติดมา)/// เทียบกับ `Str("ready")` (payload ไม่มี)#[derive(Debug, Clone, PartialEq)]pub struct Token { pub kind: TokenKind, pub lexeme: String, pub line: usize,}ฟิลด์ line ไม่ได้มีไว้สวยงาม มันคือสิ่งเดียวที่ทำให้ข้อความ error บอกได้ว่าปัญหาอยู่บรรทัดไหน
ถ้าไม่เก็บตั้งแต่ตอนนี้ ด่านหลังจะไม่มีทางรู้เลย เพราะกว่าจะถึงตอนนั้นข้อความต้นฉบับหายไปแล้ว
ชะเง้อดูตัวถัดไปก่อนตัดสิน
หัวข้อที่มีชื่อว่า “ชะเง้อดูตัวถัดไปก่อนตัดสิน”หัวข้อนี้แสดงกลไกเดียวที่ทำให้ token สองอักขระเป็นไปได้ คือการมองล่วงหน้าหนึ่งตัว
เมื่อ scanner เจอ ! มันยังตัดสินไม่ได้ว่านี่คือ Bang หรือครึ่งแรกของ BangEqual
คำตอบอยู่ที่อักขระตัวถัดไป และวิธีถามคือกินมันก็ต่อเมื่อมันตรงกับที่คาดไว้
/// กินอักขระตัวถัดไปก็ต่อเมื่อมันตรงกับที่คาด — คือหัวใจของ token สองตัวอักษร fn match_char(&mut self, expected: char) -> bool { if self.is_at_end() || self.chars[self.current] != expected { return false; } self.current += 1; true }จุดสำคัญคือคำว่า “ก็ต่อเมื่อ” ถ้าไม่ตรง ตัวชี้ต้องไม่ขยับ ไม่งั้นอักขระตัวนั้นจะหายไปเฉยๆ
เอาไปใช้กับสี่เครื่องหมายที่มีสองหน้าได้ตรงๆ
// สี่ตัวนี้ต้องชะเง้อดูตัวถัดไปก่อน '!' => { let kind = if self.match_char('=') { TokenKind::BangEqual } else { TokenKind::Bang }; self.push(kind); }คำสงวนเขียนเป็น match ไม่ใช่ HashMap
หัวข้อที่มีชื่อว่า “คำสงวนเขียนเป็น match ไม่ใช่ HashMap”หัวข้อย่อยนี้อธิบายเหตุผลที่คำสงวนเขียนเป็น
matchแทนHashMapและข้อจำกัดที่ติดมาด้วย
scanner อ่านคำว่า var ออกมาเป็นข้อความก่อน แล้วค่อยถามว่ามันเป็นคำสงวนหรือชื่อตัวแปร
/// คำสงวนทั้งหมดของภาษา nok////// เขียนเป็น `match` ไม่ใช่ `HashMap` — คอมไพเลอร์เปลี่ยนมันเป็นตารางให้อยู่แล้ว/// และไม่ต้องสร้าง map ขึ้นมาใหม่ทุกครั้งที่สร้าง scannerfn keyword(text: &str) -> Option<TokenKind> { let kind = match text { "and" => TokenKind::And, "class" => TokenKind::Class, "else" => TokenKind::Else,ข้อจำกัดที่ติดมากับทางเลือกนี้คือ ถ้าวันหนึ่งเติมคำสงวนใหม่เข้า TokenKind
แล้วลืมเติมที่นี่ ก็ยังคอมไพล์ผ่าน คอมไพเลอร์ไม่มีทางรู้ว่าสองที่นี้ต้องตรงกัน
error ที่บอกบรรทัด และ scanner ที่ไม่ยอมแพ้
หัวข้อที่มีชื่อว่า “error ที่บอกบรรทัด และ scanner ที่ไม่ยอมแพ้”หัวข้อนี้อธิบายว่าทำไม scanner ถึงเก็บความผิดพลาดไว้ทั้งหมดแทนที่จะหยุดที่ตัวแรก
/// อ่านจนจบ file แล้วคืน token ทั้งหมด *พร้อมกับ* error ทั้งหมด /// /// ไม่ใช่ `Result` เพราะ scanner ที่เจอ `@` หนึ่งตัวแล้วยอมแพ้ทันทีจะซ่อน error /// ที่เหลือไว้ ผู้เรียนต้องรันซ้ำทีละรอบ · เก็บทั้งสองอย่างแล้วให้ผู้เรียกตัดสินใจ pub fn scan_tokens(mut self) -> (Vec<Token>, Vec<ScanError>) {การคืนคู่แบบนี้แทน Result เป็นการตัดสินใจเชิงออกแบบ ไม่ใช่ความมักง่าย
Result บังคับให้เลือกอย่างใดอย่างหนึ่ง คือได้ผลลัพธ์หรือได้ความผิดพลาด
แต่ scanner ที่ดีให้ได้ทั้งสองอย่างพร้อมกัน
scanner ที่เดินต่อหลังเจอความผิดพลาด อาจรายงาน error ลวงตามมาเป็นพรวนได้
ในบทนี้ยังไม่เห็นผลนั้นเพราะ scanner แค่ข้ามอักขระที่ไม่รู้จักไปหนึ่งตัว แต่ตอนถึง parser ในบทที่ 3 ปัญหานี้จะกลายเป็นเรื่องใหญ่ และต้องมีกลไกแยกมาจัดการโดยเฉพาะ
ลองรันจริง
หัวข้อที่มีชื่อว่า “ลองรันจริง”หัวข้อนี้แสดงผลรันจริงของ scanner บน script ที่มีความผิดพลาดฝังไว้หนึ่งจุด
script ตัวอย่างของบทนี้จงใจใส่อักขระที่ภาษาไม่รู้จักไว้ในบรรทัดสุดท้าย
// บทที่ 1 — scanner รู้แค่ว่าอะไรเป็น "คำ" ยังไม่รู้ความหมายอะไรเลยvar count = 3;print "ready";count = count @ 1;โหมด --tokens สั่งให้ interpreter หยุดหลังด่านแรก แล้วพิมพ์สิ่งที่ scanner เห็นออกมา
2 Var "var" 2 Identifier("count") "count" 2 Equal "=" 2 Number(3.0) "3" 2 Semicolon ";" 3 Print "print" 3 Str("ready") "\"ready\"" 3 Semicolon ";" 4 Identifier("count") "count" 4 Equal "=" 4 Identifier("count") "count" 4 Number(1.0) "1" 4 Semicolon ";" 5 Eof ""[บรรทัด 4] ผิดพลาดตอนแบ่งคำ: อักขระที่ไม่รู้จัก '@'อ่านผลนี้แล้วเห็นสามอย่างที่หัวข้อก่อนหน้าพูดถึง
- บรรทัดที่ 3 แยก lexeme ออกจาก payload ให้เห็นชัด —
Str("ready")ไม่มีเครื่องหมายคำพูด ส่วน lexeme ทางขวามี @ไม่ได้ทำให้ทุกอย่างหยุด token หลังจากนั้นยังถูกอ่านครบ และ error โผล่ตอนท้ายพร้อมเลขบรรทัดEofอยู่บรรทัดที่ 5 ทั้งที่ script มีสี่บรรทัด เพราะบรรทัดสุดท้ายจบด้วยการขึ้นบรรทัดใหม่
flowchart LR
A["source · ข้อความดิบ"] --> B["scanner · เดินทีละอักขระ"]
B --> C["Vec<Token> · ลำดับ token"]
B --> D["Vec<ScanError> · ทุกความผิดพลาด"]
C --> E["parser · บทที่ 3"]
D --> F["รายงานพร้อมเลขบรรทัด"]
คำบรรยายภาพ: scanner คายของออกมาสองทางพร้อมกัน คือลำดับ token กับรายการความผิดพลาด ผู้เรียกเป็นคนตัดสินว่าจะเดินต่อหรือหยุด
แล้ว REPL อยู่ตรงไหน
หัวข้อที่มีชื่อว่า “แล้ว REPL อยู่ตรงไหน”หัวข้อนี้บอกว่าทำไมบทแรกถึงมี REPLRead-Eval-Print Loop (REPL)วงรอบ อ่าน–ประเมิน–พิมพ์ ที่รับคำสั่งทีละบรรทัดแล้วตอบผลทันที เป็นหน้าตาแรกที่ผู้ใช้ภาษาเจอ ทั้งที่ยังประเมินค่าอะไรไม่ได้
REPLRead-Eval-Print Loop (REPL)วงรอบ อ่าน–ประเมิน–พิมพ์ ที่รับคำสั่งทีละบรรทัดแล้วตอบผลทันที เป็นหน้าตาแรกที่ผู้ใช้ภาษาเจอ ของภาษายังทำอะไรไม่ได้มากในบทนี้ เพราะยังไม่มีตัวประเมินค่า
แต่มันมีตั้งแต่บทแรกด้วยเหตุผลเดียว คือทำให้ผู้เรียนพิมพ์อะไรเข้าไปแล้วเห็นผลทันที แทนที่จะต้องรอถึงบทที่ 4 ถึงจะได้ลองของที่เขียนมาสามบท
สิ่งที่มันตอบตอนนี้คือลำดับ token ของสิ่งที่พิมพ์เข้าไป ซึ่งพอสำหรับการเห็นว่าภาษามองข้อความอย่างไร
บทที่ 2 จะรับลำดับ token นี้ไปประกอบเป็นต้นไม้ และที่นั่นคือจุดที่ enum ของ Rust
จะแสดงให้เห็นว่ามันแทนที่ pattern ทั้ง pattern ของตำราต้นทางได้อย่างไร
- Crafting Interpreters — Robert Nystrom, บทที่ 4 “Scanning” (ตรวจแล้ว 2026-08-12) — โครงของด่านแรกที่คอร์สนี้เดินตาม ทั้งการมองล่วงหน้าหนึ่งอักขระ และการเก็บความผิดพลาดไว้ทั้งหมดแทนการหยุดที่ตัวแรก ตัว code ในตำราเขียนด้วย Java ส่วนคอร์สนี้ออกแบบใหม่ให้เป็น Rust ทั้งหมด
- The Rust Programming Language — บทที่ 6 “Enums and Pattern Matching”
(ตรวจแล้ว 2026-08-12) — ที่มาของ
enumที่แต่ละกรณีพาข้อมูลของตัวเองติดมาได้ ซึ่งเป็นเหตุผลที่TokenKindไม่ต้องมีฟิลด์กลางแบบที่ตำราต้นทางใช้
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ทำไม TokenKind ถึงเก็บค่าไว้ในตัวมันเองเป็น Number(f64) กับ Str(String) แทนที่จะมีฟิลด์กลางชนิดเดียวเก็บค่าของ literal ทุกชนิด