Worked Review 1: CLI Text Processor
นี่คือรีวิวเต็มบทแรกจากสี่บท แต่ละบทเป็นโปรแกรมเล็ก ๆ ที่สมบูรณ์ น่าเชื่อถือ พร้อม bug ที่ปลูกไว้หลายตัว รีวิวด้วยวิธีที่โมดูลนี้สอนมาตลอด: อ่าน type ก่อน รัน ladder แล้วค่อยตัดสินใจว่าจะแก้อะไร ลองรีวิวโปรแกรมด้วยตัวเองก่อนเปิด <details> block ไหนเลย
โปรแกรม
หัวข้อที่มีชื่อว่า “โปรแกรม”ตัวนับความถี่คำเล็ก ๆ: นับว่าแต่ละคำปรากฏกี่ครั้งในข้อความ แล้ว print คำที่พบบ่อยที่สุดพร้อม preview สั้น ๆ ของแต่ละคำ
use std::collections::HashMap;
/// Counts how many times each word appears in `text`.fn count_words(text: &str) -> HashMap<String, u32> { let mut counts: HashMap<String, u32> = HashMap::new(); let words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect(); let words_for_counting = words.clone(); for word in words_for_counting { let entry = counts.entry(word).or_insert(0); *entry += 1; } counts}
/// Returns how many whitespace-separated words are in `text`.fn total_words(text: &str) -> usize { let words: Vec<&str> = text.split_whitespace().collect(); let mut total = 0; for i in 0..words.len() { let _ = words[i]; total += 1; } total}
/// A short preview of a word, used in the report below.fn preview(word: &str) -> &str { if word.len() >= 2 { &word[0..2] } else { word }}
/// Parses the "how many rows to show" argument.fn parse_limit(raw: &str) -> usize { raw.parse().unwrap()}
fn main() { let text = "the quick brown fox jumps over the lazy dog the fox runs the dog barks"; let counts = count_words(text); let total = total_words(text); let limit = parse_limit("3");
let mut entries: Vec<(&String, &u32)> = counts.iter().collect(); entries.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
println!("total words: {total}"); println!("top {limit} words:"); for (word, count) in entries.iter().take(limit) { println!(" {} ({}x)", preview(word), count); }}Compiling…
โค้ดนี้ compile ผ่าน รันได้ และ print รายงานที่ดูสมเหตุสมผล:
total words: 15top 3 words: th (4x) do (2x) fo (2x)มัน (อย่างน้อย) bug ที่ปลูกไว้สี่ตัว ไม่มีตัวไหนมองเห็นได้จากการรันครั้งนี้เลย
Findings
หัวข้อที่มีชื่อว่า “Findings”Bug 1: clone ที่ไม่จำเป็นใน count_words
อาการ: ไม่มีอะไรมองเห็นได้ที่ขนาด input นี้ — แค่งานที่เสียเปล่า
หลักฐาน:
warning: redundant clone --> src/main.rs:7:35 |7 | let words_for_counting = words.clone(); // unnecessary clone -- `words` is never read again | ^^^^^^^^ help: remove this |note: this value is dropped without further use --> src/main.rs:7:30 |7 | let words_for_counting = words.clone(); | ^^^^^(cargo clippy -- -W clippy::redundant_clone)
ทางแก้: words ถูกสร้าง, clone แล้ว, แล้วมีแค่ตัว clone เท่านั้นที่ถูกใช้เลย — Vec<String> กลางไม่จำเป็นเลย iterate text.split_whitespace() ตรง ๆ แทนที่จะ collect ก่อน:
fn count_words(text: &str) -> std::collections::HashMap<String, u32> { let mut counts = std::collections::HashMap::new(); for word in text.split_whitespace() { *counts.entry(word.to_string()).or_insert(0) += 1; } counts}Bug 2: index loop ใน total_words
อาการ: ไม่มีอะไรมองเห็นได้ — loop variable ถูกใช้แค่ index Vec เดียวกันที่มันมาจาก
หลักฐาน:
warning: the loop variable `i` is only used to index `words` --> src/main.rs:19:14 |19 | for i in 0..words.len() { | ^^^^^^^^^^^^^^ | = note: `#[warn(clippy::needless_range_loop)]` (implied by -W here, on by default in the full course lint set)help: consider using an iterator(cargo clippy — needless_range_loop เปิดเป็น default)
ทางแก้: ทั้งฟังก์ชันย่อลงเหลือบรรทัดเดียวทันทีที่ index loop หายไป — words.split_whitespace() รู้อยู่แล้วว่ามันสร้างกี่ item:
fn total_words(text: &str) -> usize { text.split_whitespace().count()}Bug 3: unwrap() บน parse_limit
อาการ: ไม่มีใน run นี้ — parse_limit("3") เป็น string ที่ hardcode และ valid เสมอ bug นี้แฝงอยู่: ทันทีที่มันกลายเป็น CLI argument หรือ config value จริง input ที่ไม่ใช่ตัวเลขจะทำให้ทั้งโปรแกรม panic
หลักฐาน:
warning: used `unwrap()` on a `Result` value --> src/main.rs:37:5 |37 | raw.parse().unwrap() | ^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic(cargo clippy -- -W clippy::unwrap_used)
ทางแก้: return Result แล้วให้ผู้เรียกตัดสินใจว่า limit ที่ผิดหมายความว่าอะไร (ตรงนี้ fallback ไปที่ default ที่สมเหตุสมผล):
fn parse_limit(raw: &str) -> Result<usize, std::num::ParseIntError> { raw.parse()}// at the call site:let limit = parse_limit("3").unwrap_or(5);Bug 4: preview สมมติว่า 2 ไบต์คือ 2 ตัวอักษร
อาการ: ไม่มีเลยกับ sample text ภาษาอังกฤษล้วนนี้ ที่ทุกคำเป็น ASCII และ 1 ไบต์ต่อตัวอักษร ทันทีที่คำที่ไม่ใช่ภาษาอังกฤษปรากฏใน input นี้จะ panic
หลักฐาน — ฟังก์ชันเดียวกัน เรียกตรง ๆ บนคำภาษาไทยแทนคำภาษาอังกฤษ:
// @expect-panicfn preview(word: &str) -> &str { if word.len() >= 2 { &word[0..2] } else { word }}
fn main() { println!("{}", preview("สวัสดี"));}thread 'main' (<id>) panicked at src/main.rs:3:10:end byte index 2 is not a char boundary; it is inside 'ส' (bytes 0..3 of string)ทางแก้: นับตัวอักษร ไม่ใช่ไบต์ แล้ว slice ที่ byte offset ที่ character boundary จริง ๆ ตกอยู่:
fn preview(word: &str) -> &str { match word.char_indices().nth(2) { Some((byte_idx, _)) => &word[..byte_idx], None => word, }}
fn main() { println!("{}", preview("สวัสดี")); // สว}เวอร์ชันที่แก้แล้ว
หัวข้อที่มีชื่อว่า “เวอร์ชันที่แก้แล้ว”use std::collections::HashMap;
/// Counts how many times each word appears in `text`.fn count_words(text: &str) -> HashMap<String, u32> { let mut counts: HashMap<String, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word.to_string()).or_insert(0) += 1; } counts}
/// Returns how many whitespace-separated words are in `text`.fn total_words(text: &str) -> usize { text.split_whitespace().count()}
/// A short preview of a word, used in the report below.fn preview(word: &str) -> &str { match word.char_indices().nth(2) { Some((byte_idx, _)) => &word[..byte_idx], None => word, }}
/// Parses the "how many rows to show" argument.fn parse_limit(raw: &str) -> Result<usize, std::num::ParseIntError> { raw.parse()}
fn main() { let text = "the quick brown fox jumps over the lazy dog the fox runs the dog barks"; let counts = count_words(text); let total = total_words(text); let limit = parse_limit("3").unwrap_or(5);
let mut entries: Vec<(&String, &u32)> = counts.iter().collect(); entries.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
println!("total words: {total}"); println!("top {limit} words:"); for (word, count) in entries.iter().take(limit) { println!(" {} ({}x)", preview(word), count); }}Compiling…
total words: 15top 3 words: th (4x) do (2x) fo (2x)Output เหมือนเวอร์ชัน bug เป๊ะสำหรับ input นี้ — ทุกการแก้ตรงนี้เปลี่ยนความทนทานและความชัดเจน ไม่ใช่ผลลัพธ์ของ happy path ซึ่งเป็นเหตุผลว่าทำไม bug ทั้งสี่ตัวไม่มีตัวไหนมองเห็นได้จาก run เดิมเลย
เครื่องมือจับอะไรได้บ้าง
หัวข้อที่มีชื่อว่า “เครื่องมือจับอะไรได้บ้าง”การรัน cargo clippy จริงหนึ่งครั้งบนไฟล์ bug เดิม พร้อม lint ที่เกี่ยวข้องสามตัวเปิดใช้งาน:
$ cargo clippy --bin wr1_buggy -- -W clippy::redundant_clone -W clippy::needless_range_loop -W clippy::unwrap_usedwarning: redundant clone --> src/bin/wr1_buggy.rs:7:35 |7 | let words_for_counting = words.clone(); | ^^^^^^^^ help: remove this
warning: the loop variable `i` is only used to index `words` --> src/bin/wr1_buggy.rs:19:14 |19 | for i in 0..words.len() { | ^^^^^^^^^^^^^^
warning: used `unwrap()` on a `Result` value --> src/bin/wr1_buggy.rs:37:5 |37 | raw.parse().unwrap() | ^^^^^^^^^^^^^^^^^^^^
warning: `scratch` (bin "wr1_buggy") generated 3 warningsสามจากสี่ bug จากคำสั่งเดียว Bug 4 (byte-slice panic) มองไม่เห็นจาก cargo clippy และ cargo check เลย — ไม่มีอะไรผิด syntax เกี่ยวกับ &word[0..2] และไม่มี lint แบบ default หรือ off-by-default ตัวไหน flag การ slice byte-count คงที่บน &str โดยทั่วไป (มันถูกต้องเมื่อรู้ว่า input เป็น ASCII) การรัน preview เวอร์ชันแก้แล้วกับ input ภาษาไทยเดียวกันยืนยันทางแก้ ไม่ใช่ tool อัตโนมัติตัวไหน:
สวMental Model
หัวข้อที่มีชื่อว่า “Mental Model”bug ทั้งสี่ตัวนี้มีรูปร่างเดียวกัน: ถูกต้องสำหรับ input ที่ผู้เขียนลอง ผิดสำหรับ input ที่ผู้เขียนไม่ได้ลอง clone ที่เสียเปล่าแต่ไม่เป็นอันตราย, index loop ที่บังเอิญเยี่ยม element ทุกตัว, unwrap() บน literal ที่บังเอิญ parse ผ่านเสมอ, byte-slice ที่บังเอิญตกบน boundary สำหรับข้อความภาษาอังกฤษ — ไม่มีตัวไหนโผล่มาจากการรันโปรแกรมครั้งเดียวด้วย input ที่เป็นมิตร นั่นแหละคือเหตุผลว่าทำไม “มันทำงานตอนที่ฉันรัน” ถึงเป็นหลักฐานที่อ่อนแอมากสำหรับโค้ดที่ AI สร้างโดยเฉพาะ: input ที่ prompt การสร้างสมมติไว้โดยไม่พูดออกมา (ภาษาอังกฤษ, format ถูกต้อง, เล็ก) แทบไม่เคยถูกระบุ และโค้ดมักจะถูกต้องพอดีสำหรับข้อสมมตินั้นที่ไม่ได้พูดออกมา แล้วผิดทันทีที่มันถูกละเมิด
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”การแก้ bug ทั้งสี่ตัวตรงนี้ไม่มีต้นทุนด้าน performance เลย แถมทำให้โค้ดสั้นลงด้วยซ้ำ (total_words ย่อจาก 6 บรรทัดเหลือ 1) นั่นเป็นเรื่องปกติแต่ไม่ใช่สากล — pattern ทั่วไปใน catalog นี้คือการแก้ ownership กับ iterator มักจะฟรีหรือดีขึ้นสุทธิต่อความชัดเจน ในขณะที่ parse_limit ที่ return Result เพิ่มหนึ่งบรรทัดในทุก call site ที่ตอนนี้ต้องตัดสินใจว่า “input ผิด” หมายความว่าอะไร นั่นเป็นต้นทุนจริง และเป็นสิ่งที่ถูกต้องที่จะจ่ายทันทีที่ "3" เลิกเป็น literal ที่ hardcode
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ถ้า sample run ใน PR description ทำงาน แปลว่าโค้ดโอเค” bug ทั้งสี่ตัวตรงนี้เงียบบน input ตัวอย่างที่ผู้เขียน (หรือ AI) เลือก งานของผู้รีวิวรวมถึงการถามว่าโค้ดไม่ได้ถูกลองกับ input อะไรบ้าง — ข้อความไม่ใช่ภาษาอังกฤษ, ตัวเลขที่ format ผิด, string ว่างเปล่า
- “clippy จะจับ byte-slice bug ได้ถ้าฉันแค่เปิด lint เพิ่ม” ไม่มี lint ตัวไหนใน set ของ clippy — default, pedantic, restriction, หรืออะไรก็ตาม — flag การ slice
&strแบบ byte-count คงที่โดยทั่วไป เพราะมันถูกต้องเมื่อรู้ว่า input เป็น ASCII bug ประเภทนี้ถูกจับด้วยการรีวิวหรือ test ที่มี input ไม่ใช่ ASCII จริง ไม่ใช่ด้วย compiler flag
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”รูปร่างเดียวกัน — ถูกต้องสำหรับกรณีที่ไม่ได้ทดสอบ ผิดสำหรับกรณีที่ไม่ได้ลอง — โผล่มาเดี่ยว ๆ บ่อยพอ ๆ กับที่มันรวมอยู่ในโปรแกรมใหญ่กว่า
🐛 หา bug ในโค้ดนี้
fn initials(full_name: &str) -> String { full_name .split_whitespace() .map(|part| &part[0..1]) .collect::<Vec<_>>() .join("")}
fn main() { println!("{}", initials("Ada Lovelace"));}เฉลย
โค้ดนี้ compile ผ่านและ print AL — ถูกต้อง และเป็น result แบบที่ทำให้ quick test ผ่านได้พอดี bug คืออันเดียวกับ Bug 4 ข้างบน: &part[0..1] เอาไบต์แรกของแต่ละคำ ไม่ใช่ตัวอักษรแรก สำหรับชื่อที่มีตัวอักษรแรกไม่ใช่ ASCII — มีสระเสียง, ไทย, Cyrillic, อะไรก็ตามนอกเหนือ single-byte UTF-8 — โค้ดนี้ panic ด้วย error “not a char boundary” เดียวกัน แทนที่จะให้ initial ทางแก้เป็น pattern เดียวกันด้วย: part.chars().next() เอาตัวอักษรแรกได้ไม่ว่ามันจะใช้กี่ไบต์ในการ encode