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

Collections, strings, iterators และ closures

สาม​บท​ที่​ผ่าน​มา​เรา​วาง ownership, model ข้อมูล (struct/enum/Option) และ​การ​จัดการ error ไป​แล้ว บท​นี้​เป็น​เครื่องมือ​ทำงาน​ประจำ​วัน — สิ่ง​ที่​ใน​ฝั่ง C#/.NET คุณ​เรียก​ว่า List<T>, Dictionary<TKey,TValue>, string และ LINQ กับ lambda Rust มี​ของ​ทุก​ชิ้น​นี้​ครบ แต่​แต่ละ​ชิ้น​คือ สะพาน ไม่ใช่​ของ​เหมือนกันเป๊ะ — และ​จุด​ที่​ต่าง​คือ​จุด​ที่ compiler-ครู​สอน​เรื่อง​ใหม่​ให้​เรา

📦 kaen-taskcli

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-taskcli (code ตัวอย่าง​กำลัง​จัด​ทำ) — เรา​สร้าง CLI ตัว​เดียว​ชื่อ taskcli ไล่​ยาว 8 บท​ด้วย​ภาษา​แกน + std ล้วน บท​นี้​เป็น​ชิ้น​ส่วน​ที่ taskcli ใช้จริงในบทถัดๆ ไป: เก็บ title ของ​งาน​เป็น String, นับ​ความถี่​ของ​คำ/สถานะ​ด้วย HashMap, และ​แปลง​ข้อความ​จาก file เป็น​รายการ​งาน​ด้วย iterator chain ที่ collect เป็น Result ทุก snippet ใน​บท​นี้​ผ่าน cargo check บน edition 2024 แล้ว

ใน 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 ถ้าตกกลางอักขระ)
`.len()` = bytes เสมอ

อย่า​เผลอ​เอา String::len() ไป​ใช้​แทน “จำนวน​ตัว​อักษร” — มัน​คือ​จำนวน bytes ของ UTF-8 สำหรับ ASCII ล้วน​ตัวเลข​บังเอิญ​ตรง​กัน แต่​พอ​เป็น​ภาษา​ไทย/อี​โมจิ​จะ​เพี้ยน​ทันที ต้อง​นับ​ตัว​อักษร​ใช้ .chars().count(); ต้อง​หั่น string ใช้ .char_indices() หรือ method ที่​รู้​ขอบเขต​อักขระ ไม่ใช่ byte range สุ่ม

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 เฉพาะ​ทาง​ที่​ต้อง​ไป​ขุด

หัวใจ​ของ​บท​นี้: 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: 21
for (i, n) in nums.iter().enumerate() { // consumer: for + enumerate
println!("#{i} = {n}");
}

โจทย์​จริง​ของ 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 ตัว​เดียว​สร้าง​ได้​หลาย​ชนิด​ปลาย​ทางผ่าน 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

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 ต้อง​เป็น mut
  • FnOnceย้าย ค่าที่​จับ ออก​ไป จาก​ตัว​มัน เรียก​ได้​ครั้ง​เดียว
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 เป็น 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 ทั้งหมด​โดย​ปริยาย

อย่า​เขียน `+ '_` เกิน​ความ​จำเป็น​ใน crate edition 2024

ถ้า​คุณ​เรียน​จาก​ทิว​ทอ​เรี​ยล​เก่า (edition 2021) จะ​เห็น -> impl Iterator<Item = ...> + '_ เต็ม​ไป​หมด ใน crate edition 2024 ส่วน​ใหญ่ ไม่​ต้อง​เขียน + '_ แล้ว — มัน​เป็น​นิสัย​จาก 2021 ที่​ตอน​นี้​ซ้ำซ้อน (ยกเว้น​กรณี​ที่​คุณ จงใจ อยาก​จำกัด capture ให้​แคบ​ลง) จำ edition = "2024" ใน Cargo.toml ไว้​ว่า​เป็น​ตัว​กำหนด​พฤติกรรม​นี้ ไม่ใช่ version compiler

เกร็ด: ทำไม `evens` ที่​คืน `Item = &i32` ถึง​ผ่าน​ทั้ง2 edition

ถ้า​ชนิด 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 โดย moveFnOnce; และ​การ​คืน impl Iterator กับ​กับดัก + '_ ของ edition 2021 vs 2024

บท​หน้า​เรา​จะ​เปิด​กล่อง trait และ generics เต็มๆ — สิ่ง​ที่​อยู่​เบื้องหลัง impl Fn, impl Iterator, FromIterator และ ? (ที่​บท4 บอกว่า “แปลง error ให้​อัตโนมัติ” — บท​หน้า​จะ​เฉลย​ว่า​นั่น​คือ trait From) เรา​จะ​เห็น​ว่า​ทำไม generic ของ Rust ถึง​เร็ว​เท่า​เขียน​มือ และ dyn Trait ต่าง​จาก interface ของ C# อย่างไร


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

`"สวัสดี".len()` คืนค่าอะไร และทำไม index `s[0]` ถึง compile ไม่ผ่าน?