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

scanner — จาก​ข้อความ​ดิบ​เป็น​ลำดับ token

interpreter ไม่​ได้​เริ่ม​จาก​การ​เข้าใจ​โปรแกรม มัน​เริ่ม​จาก​การ​ยอมรับ​ว่า​ตัวเอง​ยัง​ไม่​เข้าใจ​อะไร​เลย

สิ่ง​ที่​มัน​มี​ตอน​เริ่ม​คือ​ข้อความ​ก้อน​หนึ่ง ยาว​หลาย​พัน​อักขระ ไม่มี​โครงสร้าง ไม่มี​ความหมาย มี​แค่​ตัว​อักษร​เรียง​กัน

ด่าน​แรก​จึง​ไม่ใช่​การ​หาความหมาย แต่​คือ​การ​ตอบ​คำถาม​ที่​เล็ก​กว่า​นั้น​มาก — ตรง​ไหน​คือ “คำ” หนึ่ง​คำ

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

ทั้ง​คอร์ส​เขียน​ด้วย 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 ตลอด แต่​ถ้า​ไป​อ่าน​ตำรา​เล่ม​อื่น​แล้ว​เจอ​อีก​สอง​คำ ให้​รู้​ว่า​เป็น​ของ​อย่าง​เดียวกัน

หัวข้อ​นี้​แสดง​ว่า enum ของ Rust ทำให้ token เก็บ​ค่าที่​แปล​แล้ว​ไว้​ใน​ตัว​มัน​เอง​ได้ โดย​ไม่​ต้อง​มี​ฟิลด์กลาง

ตำรา​ที่​คน​อ่าน​กัน​มาก​ที่สุด​เขียน​ด้วย Java ซึ่ง​ไม่มี sum type จึง​ต้อง​เก็บ​ค่า​ของ literal ไว้​ใน​ฟิลด์ชนิด Object แล้ว cast ตอน​หยิบ​ออก​มา​ใช้

Rust ไม่​ต้อง​ทำ​แบบ​นั้น เพราะ​แต่ละ​กรณี​ของ enum พา​ข้อมูล​ของ​ตัวเอง​ติด​มา​ได้​เลย

src/scanner.rs · ชนิด​ของ token (ตัด​มา​บาง​ส่วน)
/// ชนิดของ 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 คือ​ค่าที่​แปล​จาก​มัน​แล้ว

src/scanner.rs · token หนึ่ง​ตัว
/// 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

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

src/scanner.rs · กิน​ตัว​ถัด​ไป​เมื่อ​ตรง​กับ​ที่​คาด
/// กินอักขระตัวถัดไปก็ต่อเมื่อมันตรงกับที่คาด — คือหัวใจของ 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
}

จุด​สำคัญ​คือ​คำ​ว่า “ก็​ต่อ​เมื่อ” ถ้า​ไม่​ตรง ตัว​ชี้​ต้อง​ไม่​ขยับ ไม่​งั้น​อักขระ​ตัว​นั้น​จะ​หาย​ไป​เฉยๆ

เอา​ไป​ใช้​กับ​สี่​เครื่องหมาย​ที่​มี​สองหน้าได้ตรงๆ

src/scanner.rs · สี่​เครื่องหมาย​ที่​มี​สอง​หน้า
// สี่ตัวนี้ต้องชะเง้อดูตัวถัดไปก่อน
'!' => {
let kind =
if self.match_char('=') { TokenKind::BangEqual } else { TokenKind::Bang };
self.push(kind);
}

หัวข้อ​ย่อย​นี้​อธิบาย​เหตุผล​ที่​คำ​สงวน​เขียน​เป็น match แทน HashMap และ​ข้อ​จำกัด​ที่​ติด​มา​ด้วย

scanner อ่าน​คำ​ว่า var ออก​มา​เป็น​ข้อความ​ก่อน แล้ว​ค่อย​ถาม​ว่า​มัน​เป็น​คำ​สงวน​หรือ​ชื่อ​ตัวแปร

src/scanner.rs · คำ​สงวน​ของ​ภาษา nok (ตัด​มา​บาง​ส่วน)
/// คำสงวนทั้งหมดของภาษา nok
///
/// เขียนเป็น `match` ไม่ใช่ `HashMap` — คอมไพเลอร์เปลี่ยนมันเป็นตารางให้อยู่แล้ว
/// และไม่ต้องสร้าง map ขึ้นมาใหม่ทุกครั้งที่สร้าง scanner
fn keyword(text: &str) -> Option<TokenKind> {
let kind = match text {
"and" => TokenKind::And,
"class" => TokenKind::Class,
"else" => TokenKind::Else,

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

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

src/scanner.rs · อ่าน​จน​จบ source แล้ว​คืน​ทั้ง token และ error (ตัด​มา​บาง​ส่วน)
/// อ่านจนจบ 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 ตัวอย่าง​ของ​บท​นี้​จงใจ​ใส่​อักขระ​ที่​ภาษา​ไม่รู้จัก​ไว้​ใน​บรรทัด​สุดท้าย

examples/01-scanner.nok
// บทที่ 1 — scanner รู้แค่ว่าอะไรเป็น "คำ" ยังไม่รู้ความหมายอะไรเลย
var count = 3;
print "ready";
count = count @ 1;

โหมด --tokens สั่ง​ให้ interpreter หยุด​หลัง​ด่าน​แรก แล้ว​พิมพ์​สิ่ง​ที่ scanner เห็น​ออก​มา

expected/01-scanner.txt
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&lt;Token&gt; · ลำดับ token"]
    B --> D["Vec&lt;ScanError&gt; · ทุกความผิดพลาด"]
    C --> E["parser · บทที่ 3"]
    D --> F["รายงานพร้อมเลขบรรทัด"]

คำ​บรรยาย​ภาพ: scanner คาย​ของ​ออก​มาสอง​ทาง​พร้อม​กัน คือ​ลำดับ token กับ​รายการ​ความ​ผิดพลาด ผู้​เรียก​เป็น​คน​ตัดสิน​ว่า​จะ​เดิน​ต่อ​หรือ​หยุด

หัวข้อ​นี้​บอกว่า​ทำไม​บท​แรก​ถึง​มี 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 ทุกชนิด