OB 003: กับดักของ read_line ในลูป (The Infinite Append & Borrowing)
การรับ Input จากผู้ใช้ผ่าน Console เป็นเรื่องพื้นฐาน แต่ใน Rust การใช้ read_line ภายในลูป (loop, while) มีหลุมพรางซ่อนอยู่ 2 ชั้น ได้แก่ “บั๊กเงียบที่โปรแกรมไม่พังแต่ทำงานผิด” และ “Error จาก Borrow Checker”
บทความนี้จะพาไปดูสาเหตุ เบื้องหลังการออกแบบ และวิธีจัดการให้ถูกต้องครับ
ทำไม read_line ถึงออกแบบให้รับ Buffer เข้ามา?
ก่อนจะเข้าเรื่องกับดัก เรามาทำความเข้าใจก่อนว่า ทำไม read_line ถึงไม่ได้ออกแบบให้ return ค่า กลับมาตรงๆ แบบนี้
#![allow(unused)]
fn main() {
// ❌ แบบนี้ไม่มีจริง แค่สมมติ
let input = io::stdin().read_line().unwrap();
}
แต่กลับออกแบบให้ รับ Buffer เข้าไปแก้ไขแทน:
#![allow(unused)]
fn main() {
// ✅ แบบที่ Rust ใช้จริง
let mut buf = String::new();
io::stdin().read_line(&mut buf).unwrap();
}
เหตุผลหลักคือ Performance — เมื่อเราเป็นผู้ถือ Buffer เอง เราสามารถ นำ Buffer กลับมาใช้ซ้ำ ได้ในรอบถัดไป โดยไม่ต้อง Allocate หน่วยความจำใหม่ทุกครั้ง ซึ่งในงานที่ต้องอ่านข้อมูลจำนวนมาก (เช่น อ่านไฟล์หลายล้านบรรทัด) ความต่างนี้มีผลอย่างมาก
นี่คือ Pattern ดั้งเดิมที่ใช้กันมาตั้งแต่ C โดยมีข้อดี 3 ประการ:
- ลด Allocation — ใช้ Buffer เดิมซ้ำได้ ไม่ต้องจองหน่วยความจำใหม่ทุกรอบ
- แยกความรับผิดชอบ — ตัว I/O ทำหน้าที่แค่อ่าน ส่วนการจัดการ Memory เป็นเรื่องของผู้เรียก
- ยืดหยุ่นเรื่อง Lifetime — Buffer และ Stream มี Lifetime เป็นอิสระต่อกัน
แต่การออกแบบนี้ก็แลกมาด้วย “กับดัก” ที่เราจะพูดถึงต่อไป
1. กับดักที่ 1: “บั๊กเงียบ” (The Phantom Input)
ลองดูโค้ดตัวอย่างที่มือใหม่ (และมือเก๋าที่เผลอ) มักเขียน:
use std::io;
fn main() {
let mut input = String::new(); // สร้าง buffer นอกลูป
loop {
println!("Type something:");
// อ่านค่าใส่ buffer ตัวเดิม
io::stdin().read_line(&mut input).expect("Failed to read");
println!("You typed: {:?}", input.trim()); // ใช้ {:?} เพื่อดูค่าจริงๆ
if input.trim() == "exit" { break; }
}
}
ผลลัพธ์ที่ได้ (ความหายนะ):
| รอบ | พิมพ์ | ค่าจริงใน buffer | .trim() ได้ | ตรวจ == "exit"? |
|---|---|---|---|---|
| 1 | A | "A\n" | "A" | ❌ |
| 2 | B | "A\nB\n" | "A\nB" | ❌ |
| 3 | exit | "A\nB\nexit\n" | "A\nB\nexit" | ❌ ตลอดกาล! |
สาเหตุ:
ฟังก์ชัน read_line ถูกออกแบบมาให้ Append (ต่อท้าย) ข้อมูลลงใน String buffer เสมอ ไม่ใช่ Overwrite (เขียนทับ) — เอกสารของ Rust ระบุชัดเจนว่า:
“Previous content of the buffer will be preserved. To avoid appending to the buffer, you need to
clearit first.”
นอกจากนี้ read_line ยัง รวมตัวอักษร \n (newline) เข้ามาใน buffer ด้วย ทำให้เกิดปัญหาซ้อนกัน 2 ชั้น:
- ค่าเก่าพอกพูน — ข้อมูลจากรอบก่อนๆ ไม่หายไปไหน
- เงื่อนไขออกจากลูปพังไปด้วย —
input.trim()จะไม่มีทางเท่ากับ"exit"อีกเลยหลังรอบแรก เพราะมีข้อมูลเก่าค้างอยู่ข้างหน้า ทำให้เกิด Infinite Loop โดยไม่รู้ตัว
💡 สังเกต:
.trim()ตัดแค่ Whitespace ที่ หัว และ ท้าย เท่านั้น ไม่ได้ตัด\nที่อยู่ ตรงกลาง ของ String
วิธีแก้ (Solution A): ล้าง Buffer ทุกรอบ
ต้องสั่ง .clear() ก่อนอ่านค่าใหม่ในแต่ละรอบ:
#![allow(unused)]
fn main() {
loop {
input.clear(); // ✅ ล้างค่าเก่าทิ้งก่อนอ่านใหม่
println!("Type something:");
io::stdin().read_line(&mut input).unwrap();
println!("You typed: {}", input.trim());
if input.trim() == "exit" { break; } // ✅ ทำงานถูกต้องแล้ว
}
}
2. กับดักที่ 2: Borrow Checker Error (E0502)
ปัญหานี้จะเกิดขึ้นเมื่อเราพยายาม “ยืมค่า” จาก input มาถือไว้ แล้ววนลูปกลับไปเรียก read_line ใหม่ โดยที่ Reference เก่ายังไม่ถูกคืน
ตัวอย่างโค้ดที่ Compile ไม่ผ่าน:
fn main() {
let mut input = String::new();
let mut history = Vec::new(); // เราอยากเก็บประวัติทุกอย่างที่ผู้ใช้พิมพ์
loop {
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim(); // ได้ &str ที่ชี้ไปยังข้อมูลใน input
history.push(trimmed); // ❌ Error! เก็บ reference ของ input ใส่ vector
// จบรอบ -> วนกลับไป read_line(&mut input)
// 💥 BOOM: cannot borrow `input` as mutable because it is also borrowed as immutable
}
}
ทำไมถึง Compile ไม่ผ่าน?
input.trim()คืนค่าเป็น&strซึ่งเป็น Immutable Reference ที่ชี้ไปยังข้อมูลภายในinputhistory.push(trimmed)ทำให้historyถือ Reference นั้นค้างไว้ — มันยังไม่ถูก Drop- เมื่อวนลูปกลับไป
read_line(&mut input)ต้องการยืมinputแบบ Mutable เพื่อแก้ไขค่า - กฎเหล็กของ Rust: ณ เวลาใดเวลาหนึ่ง ถ้ามีใครถือ Immutable Reference (
&) อยู่ จะไม่อนุญาตให้เกิด Mutable Borrow (&mut) ซ้อนขึ้นมาอีก - เนื่องจาก
historyยังคงถือ&strที่ชี้มาที่inputอยู่ Rust จึงปฏิเสธไม่ให้read_lineแก้ไขinput
📝 หมายเหตุ: Error นี้คือ E0502 (cannot borrow as mutable because it is also borrowed as immutable) ไม่ใช่ E0499 ซึ่งเป็น error ของการยืม Mutable ซ้ำ 2 ครั้ง
วิธีแก้ (Solution B): สร้างค่าใหม่ที่เป็น Owned Type
ถ้าต้องการเก็บค่าข้ามรอบลูป เราเก็บ Reference ไม่ได้ ต้อง สร้าง String ใหม่ ที่เป็นเจ้าของข้อมูลเอง:
#![allow(unused)]
fn main() {
// เปลี่ยน history ให้เก็บ String (Owned) แทน &str (Borrowed)
let mut history: Vec<String> = Vec::new();
loop {
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
history.push(input.trim().to_string()); // ✅ .to_string() สร้าง String ใหม่ที่เป็นอิสระจาก input
// ...
}
}
.to_string() (หรือ String::from(...)) จะ Copy ข้อมูลออกมาเป็น String ใหม่ ทำให้ history ไม่ได้ถือ Reference ที่ชี้ไปยัง input อีกต่อไป — Borrow Checker จึงพอใจ
3. แนวทางการเขียนลูปรับ Input (Best Practices)
มี 3 แนวทางหลัก ขึ้นอยู่กับว่าคุณให้ความสำคัญกับ Performance หรือ Convenience มากกว่ากัน
ท่าที่ 1: Buffer Reuse (เน้น Performance)
เหมาะกับงานที่ต้องอ่านข้อมูลจำนวนมหาศาล (เช่น อ่านไฟล์หลายล้านบรรทัด) เพราะจอง Memory เพียงครั้งเดียวแล้วใช้ซ้ำ:
#![allow(unused)]
fn main() {
let mut input = String::new(); // Allocate ครั้งเดียว
loop {
input.clear(); // Reset length เป็น 0 แต่ไม่คืน capacity (เร็วมาก)
std::io::stdin().read_line(&mut input)?;
// ประมวลผล input...
}
}
🔍 ทำไมเร็วกว่า? —
String::clear()แค่ตั้ง length กลับเป็น 0 โดยไม่ Deallocate หน่วยความจำที่จองไว้ ดังนั้นread_lineในรอบถัดไปไม่ต้อง Allocate ใหม่ ยกเว้นบรรทัดนั้นยาวกว่า capacity เดิม
ท่าที่ 2: Fresh Scope (เน้นความปลอดภัยและความชัดเจน)
เหมาะกับ CLI ทั่วไป เขียนง่าย ปลอดภัย ไม่ต้องกลัวลืม clear() และไม่มีปัญหา Borrow Checker ข้ามรอบ:
#![allow(unused)]
fn main() {
loop {
let mut input = String::new(); // สร้างใหม่ทุกรอบ (Scope อยู่แค่ในลูป)
std::io::stdin().read_line(&mut input)?;
// ประมวลผล input...
// input ถูก Drop เมื่อจบรอบ — ไม่มีทาง state ค้าง
}
}
Trade-off: ท่านี้ Allocate ใหม่ทุกรอบ ช้ากว่าท่าที่ 1 เล็กน้อย แต่สำหรับ Interactive CLI ที่ผู้ใช้พิมพ์ทีละบรรทัด ความต่างนี้ไม่มีผลในทางปฏิบัติเลย
ท่าที่ 3: Iterator — The Rustacean Way
ใช้ lines() Iterator ซึ่งจัดการเรื่อง Buffer, Newline และ Error ให้เราอย่างสวยงาม:
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
// lock() ล็อก stdin ไว้ครั้งเดียว ไม่ต้อง lock/unlock ทุกรอบ
for line in stdin.lock().lines() {
let content = line.unwrap(); // ได้ String ใหม่ (ไม่มี \n ติดมา)
println!("You typed: {}", content);
if content == "exit" { break; }
}
}
สิ่งที่
lines()ทำให้อัตโนมัติ:
- ตัด
\nออกให้เรียบร้อย- คืน
Stringใหม่ (Owned) ทุกบรรทัด — ไม่มีปัญหา Borrow ข้ามรอบ- Wrap ใน
Resultเพื่อจัดการ I/O Error ได้ข้อควรรู้:
lines()สร้างStringใหม่ทุกบรรทัดเช่นเดียวกับท่าที่ 2 จึงช้ากว่าท่าที่ 1 (Buffer Reuse) ในงานที่ต้องอ่านจำนวนมาก แต่สำหรับงานทั่วไปถือว่าเหมาะสมที่สุด
Checklist สรุป
read_line= Append เสมอ — ถ้าใช้ Buffer เดิมซ้ำ ต้องสั่ง.clear()ก่อนอ่านทุกครั้ง ไม่งั้นข้อมูลเก่าจะค้างและเงื่อนไขต่างๆ จะพังread_lineรวม\nมาด้วย — ใช้.trim()เพื่อตัดออก แต่ระวังว่า.trim()ตัดแค่หัวท้าย ไม่ตัดตรงกลาง- ระวัง Reference ค้างข้ามรอบ — ถ้าประกาศ Buffer นอกลูป ห้ามเก็บ
&strที่ชี้ไปยัง Buffer นั้นข้ามรอบลูป ให้ใช้.to_string()สร้างค่าใหม่แทน - เลือกท่าให้เหมาะกับงาน:
- เน้นเร็ว → Buffer Reuse +
.clear() - เน้นชัวร์/เขียนง่าย → ประกาศ
String::new()ในลูป - เน้นสไตล์ Idiomatic Rust → ใช้
stdin().lock().lines()
- เน้นเร็ว → Buffer Reuse +