Collections, strings, iterators และ closures
สามบทที่ผ่านมาเราวาง ownership, model ข้อมูล (struct/enum/Option) และการจัดการ error ไปแล้ว บทนี้เป็นเครื่องมือทำงานประจำวัน — สิ่งที่ในฝั่ง C#/.NET คุณเรียกว่า List<T>, Dictionary<TKey,TValue>, string และ LINQ กับ lambda Rust มีของทุกชิ้นนี้ครบ แต่แต่ละชิ้นคือ สะพาน ไม่ใช่ของเหมือนกันเป๊ะ — และจุดที่ต่างคือจุดที่ compiler-ครูสอนเรื่องใหม่ให้เรา
code ลงมือของคอร์สนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — เราสร้าง CLI ตัวเดียวชื่อ taskcli ไล่ยาว 8 บทด้วยภาษาแกน + std ล้วน บทนี้เป็นชิ้นส่วนที่ taskcli ใช้จริงในบทถัดๆ ไป: เก็บ title ของงานเป็น String, นับความถี่ของคำ/สถานะด้วย HashMap, และแปลงข้อความจาก file เป็นรายการงานด้วย iterator chain ที่ collect เป็น Result ทุก snippet ในบทนี้ผ่าน cargo check บน edition 2024 แล้ว
String กับ &str — ทำไมต้องมีสองชนิด
หัวข้อที่มีชื่อว่า “String กับ &str — ทำไมต้องมีสองชนิด”ใน C# มี string ชนิดเดียว: string เป็น UTF-16, immutable, GC ดูแลให้ และคุณ index s[0] ได้ตรงๆ Rust จงใจแยกเป็นสองชนิดตามกฎ ownership:
String— string ที่ เป็นเจ้าของ buffer บน heap แก้ไข/ต่อความยาวได้ (คล้ายStringBuilder+stringรวมกัน)- &str&strstring แบบยืม อ่านอย่างเดียว UTF-8 (คู่กับ `String` ที่เป็นเจ้าของ heap) — string แบบ ยืม (borrowed) อ่านอย่างเดียว เป็น slicesliceมุมมองยืม (ptr,len) เข้าไปในข้อมูลต่อเนื่องที่คนอื่นเป็นเจ้าของ เช่น `&str`, `&[T]` ชนิดหนึ่ง คือมุมมอง
(ptr, len)เข้าไปในข้อมูล UTF-8 ที่คนอื่นเป็นเจ้าของ — string literal อย่าง"hello"ก็เป็น&str
กฎนิ้วโป้งที่ยึดทั้งคอร์ส: รับ &str เป็น parameter, เก็บ/คืน String เพราะ &str รับ caller ได้กว้างที่สุด — &String จะ deref-coerce เป็น &str ให้อัตโนมัติ ดังนั้น function ที่รับ &str เรียกได้ทั้งจาก String และจาก literal
fn print_len(s: &str) { println!("{} bytes", s.len()); }
fn main() { let literal: &str = "สวัสดี"; let mut owned: String = String::from(literal); owned.push_str(" world"); print_len(&owned); // &String -> &str ด้วย deref coercion print_len(literal); println!("{}", "abc".len()); // 3 println!("{}", "สวัสดี".len()); // 18 (bytes ไม่ใช่ตัวอักษร) println!("{}", "สวัสดี".chars().count()); // 6 (Unicode scalar values)}นี่คือจุดที่สะพานหักจาก C# ชัดที่สุด: .len() ของ Rust นับ bytes ไม่ใช่จำนวนตัวอักษร เพราะ String/&str เก็บเป็น UTF-8 คำว่า "สวัสดี" กินพื้นที่ 18 bytes (ตัวไทยตัวละ 3 bytes) แต่มี Unicode scalar value แค่ 6 ตัว ต้องการจำนวน “ตัวอักษร” ต้องเรียก .chars().count() เอง — และเพราะ index หนึ่งตำแหน่งอาจตกกลาง byte ของอักขระ Rust จึง ห้าม index string ด้วยตัวเลขตรงๆ:
let s = String::from("hello");let c = s[0]; // ❌ version ดิบ — compile ไม่ผ่านcode นี้ล้ม compile ด้วย E0277 (String ไม่ได้ impl trait Index<{integer}>) — ในภาษาที่ compile ผ่าน (เช่น C#) s[0] จะคืน UTF-16 code unit หนึ่งตัวเงียบๆ ซึ่งอาจไม่ตรงกับ “ตัวอักษรตัวแรก” ที่คุณคิดสำหรับข้อความหลายภาษา Rust กันความเข้าใจผิดนั้นตั้งแต่ compile บอกให้คุณเลือกให้ชัดว่าจะเอา อะไร:
let s = String::from("สวัสดี");let first_char = s.chars().next(); // Option<char> = Some('ส')let first_byte = s.as_bytes()[0]; // u8 = byte ตัวแรกlet head = &s[0..3]; // &str ของ byte 0..3 (panic ถ้าตกกลางอักขระ)อย่าเผลอเอา String::len() ไปใช้แทน “จำนวนตัวอักษร” — มันคือจำนวน bytes ของ UTF-8 สำหรับ ASCII ล้วนตัวเลขบังเอิญตรงกัน แต่พอเป็นภาษาไทย/อีโมจิจะเพี้ยนทันที ต้องนับตัวอักษรใช้ .chars().count(); ต้องหั่น string ใช้ .char_indices() หรือ method ที่รู้ขอบเขตอักขระ ไม่ใช่ byte range สุ่ม
HashMap กับสำนวน entry().or_insert()
หัวข้อที่มีชื่อว่า “HashMap กับสำนวน entry().or_insert()”Vec<T> คือ array ปรับขนาดได้บน heap (เทียบ List<T>) และ HashMapHashMapตาราง hash key→value; ใช้ `entry(k).or_insert(v)` เป็นสำนวนอัปเดตด้วย lookup เดียว<K, V> คือตาราง hash key→value (เทียบ Dictionary<TKey,TValue>) ต่างกันตรงรายละเอียดที่สำคัญ: HashMap ไม่อยู่ใน prelude ต้อง use std::collections::HashMap; เอง และลำดับการวนถูกสุ่มด้วย SipHash โดยตั้งใจ (กัน DoS จาก key ที่จงใจชนกัน) — อย่าพึ่งพาลำดับ
รูปแบบที่คุณจะใช้บ่อยที่สุดคือ “อ่านค่าเดิม แล้วเขียนทับ” ใน C# มักเขียนเป็น TryGetValue แล้วตามด้วยเขียน — 2 lookup Rust มี entry(k).or_insert(v) ที่คืน &mut V ให้ทำทั้งสองอย่างใน lookup เดียว:
use std::collections::HashMap;use std::{fs, io};
fn word_counts(path: &str) -> io::Result<HashMap<String, u32>> { let text = fs::read_to_string(path)?; let mut counts: HashMap<String, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word.to_lowercase()).or_insert(0) += 1; } Ok(counts)}บรรทัด *counts.entry(k).or_insert(0) += 1 อ่านว่า: หา key k, ถ้าไม่มีให้ใส่ 0, แล้วคืน &mut ไปที่ค่านั้นเพื่อ += 1 — ป้อน "the the cat" ได้ {the: 2, cat: 1} สำนวนนี้ตรงกับ CollectionsMarshal.GetValueRefOrAddDefault ของ C# ที่สุด แต่เป็นของกลางในภาษา ไม่ใช่ API เฉพาะทางที่ต้องไปขุด
Iterator ที่ ขี้เกียจ — adapter กับ consumer
หัวข้อที่มีชื่อว่า “Iterator ที่ ขี้เกียจ — adapter กับ consumer”หัวใจของบทนี้: IteratorIteratortrait ที่มีเมท็อดจำเป็นเดียว `next()`; adapter ทุกตัวขี้เกียจ (lazy) จนกว่าจะ consume ของ Rust ขี้เกียจ (lazy) เหมือน LINQ — method อย่าง map, filter, enumerate, take เป็น adapter ที่แค่ประกอบ iterator ตัวใหม่ขึ้นมา ยังไม่ทำงานจริง จนกว่าจะมี consumer อย่าง collect, sum, count, หรือ for มาดึง จุดที่ Rust ต่างจาก C#: iterator เป็น #[must_use] — ถ้าคุณสร้าง chain แล้วไม่เคย consume compiler จะ เตือน ว่า code นั้นไม่มีผลอะไร ส่วน LINQ ใน C# จะเงียบๆ ไม่ทำอะไรเลยโดยไม่บอก
trait Iterator เองมี method ที่ บังคับ ให้ implement แค่ตัวเดียว — fn next(&mut self) -> Option<Self::Item> — adapter ทั้งหมดต่อยอดจาก next ตัวนี้ (เทียบกับ C# ที่ IEnumerator แยกเป็น MoveNext() + Current สองชิ้น Rust หลอมเป็น next() ที่คืน Option ชิ้นเดียว)
let nums = vec![1, 2, 3, 4, 5, 6];
// adapter อย่างเดียว — แค่ประกอบ iterator ใหม่ไว้ ยังไม่ทำงาน// (ปล่อยทิ้งเป็น statement เฉย ๆ เมื่อไร compiler จะเตือน must_use ทันที)let _lazy = nums.iter().map(|n| n * 2).filter(|n| n > &4);
// พอ consume ด้วย collect ถึงทำงานจริงlet doubled_evens: Vec<i32> = nums.iter().map(|n| n * 2).filter(|n| n > &4).collect();// [6, 8, 10, 12]
let total: i32 = nums.iter().sum(); // consumer: 21for (i, n) in nums.iter().enumerate() { // consumer: for + enumerate println!("#{i} = {n}");}Chain ศูนย์กลาง — collect เป็น Result แล้ว short-circuit
หัวข้อที่มีชื่อว่า “Chain ศูนย์กลาง — collect เป็น Result แล้ว short-circuit”โจทย์จริงของ taskcli: อ่าน file ที่มีตัวเลขบรรทัดละตัว (อาจมีบรรทัดว่างและ comment ขึ้นต้นด้วย #) แปลงเป็น Vec<i64> — และถ้ามีบรรทัดไหน parse ไม่ได้ ให้ ล้มทั้งงานทันที ไม่ใช่แอบข้าม นี่คือ chain ที่รวมทุกอย่างของบทนี้:
use std::fs;fn main() -> Result<(), Box<dyn std::error::Error>> { let text = fs::read_to_string("numbers.txt")?; let numbers: Vec<i64> = text .lines() .map(|line| line.trim()) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(|line| line.parse::<i64>()) .collect::<Result<Vec<_>, _>>()?; // หยุดที่ Err ตัวแรก let sum: i64 = numbers.iter().sum(); println!("{} numbers, sum = {sum}", numbers.len()); Ok(())}กุญแจอยู่ที่บรรทัด collect::<Result<Vec<_>, _>>() — map(parse) ทำให้แต่ละบรรทัดกลายเป็น Result<i64, ParseIntError> และ collect ตัวนี้ ฉลาด: ถ้าทุกตัวเป็น Ok มันคืน Ok(Vec<i64>); แต่พอเจอ Err ตัวแรก มัน หยุดทันที (short-circuit) แล้วคืน Err ตัวนั้น — ป้อน "12\nabc\n7" จะได้ Err ทันทีโดยไม่ parse บรรทัด 7 เลย จากนั้น ? ส่ง error นั้นออกจาก main ต่อ นี่คือสำนวนที่แทน “loop + try/catch + break” ทั้งก้อนของ C# ด้วยบรรทัดเดียว
flowchart LR
L["lines()<br/>อ่านทีละบรรทัด"] --> T["map(trim)<br/>ตัดช่องว่างหัวท้าย"]
T --> F["filter<br/>ทิ้งบรรทัดว่าง + comment"]
F --> P["map(parse)<br/>แต่ละบรรทัดคืน Result"]
P --> C{"collect<br/>วาล์วที่ดึงทั้งสายให้ทำงาน"}
C -->|"ทุกบรรทัดเป็น Ok"| OK["Ok — ได้ Vec ครบทั้งชุด"]
C -->|"เจอ Err ตัวแรก"| ER["Err — หยุดทันที (short-circuit)"]
classDef adapt fill:#fde68a,stroke:#92400e,color:#451a03;
classDef ok fill:#bbf7d0,stroke:#166534,color:#052e16;
classDef err fill:#fecaca,stroke:#991b1b,color:#450a0a;
class L,T,F,P,C adapt;
class OK ok;
class ER err;
คำบรรยายภาพ: iterator ขี้เกียจ — adapter (lines/map/filter) แค่ต่อท่อไว้เฉยๆ ไม่มีอะไรทำงานจนกว่า collect (วาล์วปลายสาย) จะดึง และเมื่อ collect เป็น Result<Vec<_>, _> มันจะหยุดทันทีที่เจอ Err ตัวแรกแล้วคืน error นั้นออกไป
collect เป็น polymorphic — ปลายทางเป็นตัวกำหนด
หัวข้อที่มีชื่อว่า “collect เป็น polymorphic — ปลายทางเป็นตัวกำหนด”collect ตัวเดียวสร้างได้หลายชนิดปลายทางผ่าน trait FromIterator — มันดูว่าคุณ ประกาศชนิดผลลัพธ์ เป็นอะไรแล้วสร้างชนิดนั้นให้ ในตัวอย่างข้างบนเราขอ Result<Vec<_>, _> เลยได้พฤติกรรม short-circuit แต่เปลี่ยน annotation ก็ได้ของคนละแบบ:
use std::collections::HashMap;
let words = ["a", "bb", "ccc"];
let lengths: Vec<usize> = words.iter().map(|w| w.len()).collect();let joined: String = words.iter().copied().collect(); // "abbccc"let by_len: HashMap<&str, usize> = words.iter().map(|&w| (w, w.len())).collect();เพราะ collect อ่านชนิดปลายทางไม่ได้จาก argument บางครั้งต้องบอกมันด้วย turbofish collect::<Vec<_>>() หรือ annotation ที่ตัวแปร — นี่คือเหตุผลที่คุณเห็น ::<...> บ่อยใน code Rust
Closures — Fn / FnMut / FnOnce
หัวข้อที่มีชื่อว่า “Closures — Fn / FnMut / FnOnce”closure คือ function นิรนามที่ จับ (capture) ตัวแปรรอบข้าง — เทียบ lambda ของ C# แต่ต่างกันสองจุดสำคัญ ใน C# lambda จับ by reference เสมอ (ตัวแปรถูกยกขึ้น heap เป็น closure class) ส่วน Rust อนุมานโหมด capture ที่แคบที่สุดที่พอใช้ และ encode ความสามารถในการเรียก ไว้ใน type ผ่าน3 trait:
- closureclosurefunction นิรนามที่จับตัวแปรรอบข้าง; ชนิดเป็น `Fn`/`FnMut`/`FnOnce` ตามการใช้ค่า เป็น
Fn— จับแบบ borrow อ่านอย่างเดียว (&) เรียกซ้ำได้ไม่จำกัด FnMut— จับแบบ borrow ที่แก้ไขได้ (&mut) เรียกซ้ำได้ แต่ binding ต้องเป็นmutFnOnce— ย้าย ค่าที่จับ ออกไป จากตัวมัน เรียกได้ครั้งเดียว
fn main() { let factor = 3; let scale = |x: i32| x * factor; // Fn — แค่อ่าน factor println!("{} {}", scale(10), scale(20));
let mut total = 0; let mut add = |x: i32| total += x; // FnMut — แก้ total (binding ต้อง mut) add(5); add(7); println!("{total}"); // 12
let name = String::from("Kaen"); let consume = move || name; // FnOnce — ย้าย name ออกไป let owned: String = consume(); // consume(); // ❌ เรียกซ้ำไม่ได้ — name ถูก move ออกไปแล้ว (E0382) println!("{owned}");}จุดที่คนพลาดบ่อย: move ไม่ได้แปลว่า FnOnce keyword move แค่บังคับให้ จับ by-value (จำเป็นเมื่อ closure ต้องอยู่รอด นานกว่าตัวแปรเดิม เช่นตอนส่งเข้า thread ในบท8) แต่ closure จะเป็น FnOnce ก็ต่อเมื่อมัน ย้ายค่าที่จับออกไป จากตัวมันเท่านั้น — closure ที่ move เข้ามาแล้วแค่ อ่าน ค่ายังเป็น Fn อยู่ ตรงข้ามกับ reflex ของ C# ที่ lambda ไม่เคยต้องเลือกอะไรแบบนี้เพราะ GC อุ้มทุกอย่างไว้
รับ closure และ คืน iterator ด้วย impl Trait
หัวข้อที่มีชื่อว่า “รับ closure และ คืน iterator ด้วย impl Trait”รับ closure เป็น parameter ใช้ generic bound impl Fn(...) ได้ static dispatch (monomorphized, ต้นทุน dispatch = ศูนย์ — เราลงลึก trait/generics บทหน้า):
fn apply_twice(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(f(x)) }// apply_twice(|x| x + 1, 3) == 5และ คืน iterator ก็ใช้ impl Iterator<Item = ...> ได้เหมือนกัน แต่ตรงนี้มีกับดักเรื่อง edition ที่ต้องรู้ function ที่คืน iterator ซึ่ง ยืม argument เข้ามา (แต่ชนิด Item ไม่ได้โชว์การยืมนั้น) จะ compile ต่างกันระหว่าง edition:
// edition 2024: compile ผ่าน — RPIT จับ lifetime ของ nums ให้อัตโนมัติfn evens(nums: &[i32]) -> impl Iterator<Item = i32> { nums.iter().copied().filter(|n| n % 2 == 0)}code ชิ้นนี้ compile ผ่านบน edition 2024 โดยไม่ต้องเขียนอะไรเพิ่ม แต่บน edition 2021 code เดิมเป๊ะจะล้มด้วย E0700 (hidden type ยืม nums แต่ lifetime ไม่ปรากฏใน bound) และต้องเติม + '_ เป็น impl Iterator<Item = i32> + '_ เอง สาเหตุคือ edition 2024 เปลี่ยนกฎ RPIT (return-position impl Trait) ให้จับ lifetime ที่อยู่ใน scope ทั้งหมดโดยปริยาย
ถ้าคุณเรียนจากทิวทอเรียลเก่า (edition 2021) จะเห็น -> impl Iterator<Item = ...> + '_ เต็มไปหมด ใน crate edition 2024 ส่วนใหญ่ ไม่ต้องเขียน + '_ แล้ว — มันเป็นนิสัยจาก 2021 ที่ตอนนี้ซ้ำซ้อน (ยกเว้นกรณีที่คุณ จงใจ อยากจำกัด capture ให้แคบลง) จำ edition = "2024" ใน Cargo.toml ไว้ว่าเป็นตัวกำหนดพฤติกรรมนี้ ไม่ใช่ version compiler
ถ้าชนิด Item โชว์ การยืมอยู่แล้ว เช่น -> impl Iterator<Item = &i32> (คืน reference ตรงๆ ไม่ copied()) code จะผ่านทั้ง edition 2021 และ 2024 โดยไม่ต้อง + '_ เพราะ lifetime ปรากฏชัดในตัว &i32 แล้ว กับดัก E0700 เกิดเฉพาะตอน iterator ยืมข้างใน แต่ Item เป็นชนิดที่เป็นเจ้าของ (เช่น i32) จน lifetime “หายไป” จากลายเซ็นเท่านั้น
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เติมกล่องเครื่องมือประจำวัน: String (เจ้าของ heap) กับ &str (ยืม, UTF-8) — รับ &str เก็บ String, และ .len() นับ bytes ไม่ใช่ตัวอักษร จน index s[0] ไม่ได้ (E0277); HashMap กับสำนวน entry(k).or_insert(v) ที่อัปเดตด้วย lookup เดียว; iterator ที่ ขี้เกียจ — adapter (map/filter/enumerate) ประกอบท่อไว้ ยังไม่ทำงานจนกว่า consumer (collect/sum/for) จะดึง และ collect::<Result<Vec<_>, _>>() ที่ short-circuit ที่ Err ตัวแรก; collect เป็น polymorphic ผ่าน FromIterator โดยชนิดปลายทางเป็นตัวกำหนด; closure สามแบบ Fn/FnMut/FnOnce โดย move ≠ FnOnce; และการคืน impl Iterator กับกับดัก + '_ ของ edition 2021 vs 2024
บทหน้าเราจะเปิดกล่อง trait และ generics เต็มๆ — สิ่งที่อยู่เบื้องหลัง impl Fn, impl Iterator, FromIterator และ ? (ที่บท4 บอกว่า “แปลง error ให้อัตโนมัติ” — บทหน้าจะเฉลยว่านั่นคือ trait From) เราจะเห็นว่าทำไม generic ของ Rust ถึงเร็วเท่าเขียนมือ และ dyn Trait ต่างจาก interface ของ C# อย่างไร
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch08 Common Collections (Vec, String, HashMap) (เข้าถึง 2026-07-24) —
Vec,Stringเป็น UTF-8, index string ไม่ได้, และentry().or_insert() - The Rust Programming Language — ch13 Iterators and Closures (เข้าถึง 2026-07-24) — iterator ขี้เกียจ,
Fn/FnMut/FnOnce,move - std::iter::Iterator (มาตรฐาน trait Iterator +
next) (เข้าถึง 2026-07-24) — method บังคับตัวเดียวnext()และ adapter/consumer - Rust Edition Guide — RPIT lifetime capture ใน Rust 2024 (เข้าถึง 2026-07-24) — เหตุผลที่
impl Iteratorที่ยืม argument ไม่ต้อง+ '_บน edition 2024 (E0700 บน 2021) - rustc Error Codes Index — E0277 (เข้าถึง 2026-07-24) — unsatisfied trait bound (ที่มาของ
s[0]index string ไม่ได้)
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3`"สวัสดี".len()` คืนค่าอะไร และทำไม index `s[0]` ถึง compile ไม่ผ่าน?