Ownership, การยืม และการย้าย — model หลักของ Rust
ในโลก C#/.NET คุณเขียน var b = a; บน object แล้วได้ 2 reference ชี้ก้อนเดียวกัน — ทั้ง a และ b ใช้ต่อได้ ใครแก้ก็เห็นทั้งคู่ และ GC จะคืนหน่วยความจำให้เองตอนไหนก็ไม่รู้ Rust มองเรื่องนี้ต่างออกไปตั้งแต่ราก: ทุกค่ามี เจ้าของเดียว และคำสั่ง assign ที่หน้าตาเหมือนกันเป๊ะกลับมีความหมายไม่เหมือนกัน นี่คือ ownershipownershipกฎเจ้าของเดี่ยว: ทุกค่ามีเจ้าของหนึ่งเดียว ค่าถูก drop เมื่อเจ้าของหมด scope — วิธีคืนหน่วยความจำแบบไม่มี GC — ไอเดียเดียวที่ยากจริงของ Rust และเป็นฐานที่ทุกอย่างในคอร์สนี้ตั้งอยู่บนมัน อย่ารีบผ่านบทนี้
บทที่แล้วเราสัญญาว่า compiler คือครู บทนี้คือคาบเรียนแรกที่ครูจะเริ่มโยน error กลับมา — และทุก error ที่เห็นในบทนี้ (E0382, E0499, E0502, E0106) คือ bug หน่วยความจำทั้งชนชั้นที่ถูกกันไว้ตั้งแต่ตอน compile ไม่ใช่ตอน production เราจะเดินแบบ red-then-green ตลอด: ดู code ที่ compile ไม่ผ่านก่อน เข้าใจว่ามันกันอะไร แล้วค่อยแก้ให้ผ่าน
code ลงมือของคอร์สนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — CLI ตัวจัดการงาน to-do ที่เก็บสถานะเป็น JSON ที่เราไล่สร้างยาว 8 บท ตัวอย่างในบทนี้ยังเป็น drill สั้นๆ ที่โฟกัสกลไก ownership ล้วนๆ — ชนิดข้อมูลจริงของ taskcli (Task, Command) จะ model ในบท3 แต่กฎที่ฝึกในบทนี้จะติดตัวไปใช้ทุกบรรทัดหลังจากนี้ ทุก snippet ผ่าน cargo check บน rustc 1.97.1 (edition 2024) โดยไม่มี warning
กฎ ownership สามข้อ — และทำไมถึงไม่ต้องมี GC
หัวข้อที่มีชื่อว่า “กฎ ownership สามข้อ — และทำไมถึงไม่ต้องมี GC”หนังสือ The Rust Programming Language (ch4.1) วางกฎไว้สามข้อสั้นๆ ที่คุ้มค่าจะจำขึ้นใจ:
- ทุกค่ามี เจ้าของ (owner) หนึ่งตัว
- มีเจ้าของได้ครั้งละ หนึ่งเดียว เท่านั้น
- เมื่อเจ้าของหมด scope ค่านั้นจะถูก drop (คืนหน่วยความจำ) ทันที
ข้อสามคือหัวใจ: การคืนหน่วยความจำเกิดขึ้น แน่นอนและคาดเดาได้ ณ จุดที่เจ้าของหมด scope — ไม่ใช่ “ตอนไหนก็ได้ที่ GC ว่าง” นี่คือ RAII แบบเดียวกับ using/IDisposable ใน C# แต่ อัตโนมัติและครอบทุกอย่าง ไม่ใช่แค่ของที่คุณ implement IDisposable ไว้เอง ผลคือไม่มี GC pause ไม่มี finalizer สุ่มจังหวะ — แลกกับการที่คุณต้อง ตัดสินใจให้ชัดว่าใครเป็นเจ้าของอะไร
move — assign ที่ย้ายความเป็นเจ้าของ
หัวข้อที่มีชื่อว่า “move — assign ที่ย้ายความเป็นเจ้าของ”นี่คือจุดที่สัญชาตญาณจาก C# จะสะดุดครั้งแรก ดู code ที่ compile ไม่ผ่าน:
fn main() { let s1 = String::from("hello"); let s2 = s1; // ❌ move: ความเป็นเจ้าของย้ายจาก s1 ไป s2 println!("s1 = {s1}, s2 = {s2}"); // ใช้ s1 ต่อไม่ได้แล้ว}error[E0382]: borrow of moved value: `s1`String เก็บข้อมูลจริงไว้บน heap และเก็บส่วนหัว (pointer, length, capacity) ไว้บน stack เมื่อเขียน let s2 = s1; Rust ไม่ ได้ copy buffer บน heap และ ไม่ ได้สร้าง reference ตัวที่สอง — มันคัดลอกเฉพาะส่วนหัวบน stack แล้ว ทำให้ s1 ตายไป นี่คือ movemoveการย้ายความเป็นเจ้าของ; ตัวแปรเดิมใช้ต่อไม่ได้ (ต่างจากการ copy อ้างอิงใน C#) เหตุผลตรงไปตรงมา: ถ้าปล่อยให้ทั้ง s1 และ s2 เป็นเจ้าของ buffer เดียวกัน พอทั้งคู่หมด scope Rust จะพยายาม free buffer เดิม สองครั้ง — นั่นคือ double-free bug ความปลอดภัยคลาสสิกที่ E0382 กันให้เราตั้งแต่ compile
flowchart TB
subgraph CS["C#: var s2 = s1 — alias 2 ref ชี้ก้อนเดียว"]
direction TB
CS1["s1 (reference)"] --> HEAP1["heap: hello"]
CS2["s2 (reference)"] --> HEAP1
end
subgraph RS["Rust: let s2 = s1 — move เจ้าของเดียว"]
direction TB
RS1["s1 ❌ moved ใช้ต่อไม่ได้"]
RS2["s2 (owner)"] --> HEAP2["heap: hello"]
end
classDef live fill:#fde68a,stroke:#92400e,color:#451a03;
classDef dead fill:#e5e7eb,stroke:#9ca3af,color:#6b7280;
classDef mem fill:#fed7aa,stroke:#7c2d12,color:#431407;
class CS1,CS2,RS2 live;
class RS1 dead;
class HEAP1,HEAP2 mem;
คำบรรยายภาพ: ย้าย (move) ไม่ใช่ copy อ้างอิง — ใน C# หลัง var s2 = s1; มี2 reference ชี้ heap ก้อนเดียว แต่ใน Rust หลัง let s2 = s1; เหลือเจ้าของเดียว (s2) ส่วน s1 ถูกทำให้ใช้ต่อไม่ได้ กัน double-free ตั้งแต่ compile
แก้ทางที่หนึ่ง: clone (กำปั้นทุบดิน)
หัวข้อที่มีชื่อว่า “แก้ทางที่หนึ่ง: clone (กำปั้นทุบดิน)”ถ้าคุณ อยาก ได้สองก้อนที่แยกกันจริงๆ สั่ง .clone() ให้ deep-copy buffer บน heap:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); // deep-copy: s1 ได้ buffer ของตัวเอง s2 ได้อีก buffer println!("s1 = {s1}, s2 = {s2}");}clone() ผ่านฉลุย แต่เรียกมันว่าแบบ กำปั้นทุบดิน เพราะมันแก้ปัญหาด้วยการจ่ายค่า allocation + copy เต็มก้อนทุกครั้ง version ดิบ ❌ ที่โปรยแต่ .clone() ทุกที่ที่ compiler บ่นจะรันได้ก็จริง แต่คุณกำลังจ่ายราคาหน่วยความจำและความเร็วโดยไม่จำเป็น — บ่อยครั้งสิ่งที่คุณต้องการจริงๆ ไม่ใช่ “ก้อนที่สอง” แต่คือแค่ “ขอ ดู ก้อนเดิมหน่อย” ซึ่งนำเราไปสู่การยืม
แก้ทางที่สอง: ยืม (แนะนำ)
หัวข้อที่มีชื่อว่า “แก้ทางที่สอง: ยืม (แนะนำ)”fn calculate_length(s: &String) -> usize { s.len()}fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); // ยืม s1 ไม่ได้ย้าย println!("The length of '{s1}' is {len}."); // s1 ยังใช้ได้}&s1 ส่ง borrowingborrowingการยืมค่าด้วย `&` (อ่าน) หรือ `&mut` (แก้ไข) โดยไม่ย้ายความเป็นเจ้าของ (การยืม) เข้าไปใน function แทนที่จะย้ายความเป็นเจ้าของ function ได้ ดู s1 แต่ไม่ได้เป็นเจ้าของมัน พอ function จบ ไม่มีอะไรถูก drop (เพราะไม่ได้เป็นเจ้าของ) และ s1 กลับมาใช้ได้ต่อในบรรทัดถัดไป นี่คือทางเลือกที่ถูกต้องเกือบตลอดเวลา
Copy — ชนิดบน stack ล้วนที่ copy แทนที่จะ move
หัวข้อที่มีชื่อว่า “Copy — ชนิดบน stack ล้วนที่ copy แทนที่จะ move”แล้วทำไม code นี้ถึงไม่ error ทั้งที่หน้าตาเหมือน move เป๊ะ?
fn main() { let x = 5; let y = x; // i32 เป็น Copy — copy ไม่ move println!("x = {x}, y = {y}"); // x ยังใช้ได้สบาย}เพราะ i32 implement trait ชื่อ CopyCopytrait ของชนิดที่อยู่บน stack ล้วน (int, bool, char, float) — assign แล้ว copy ไม่ย้าย ชนิดที่เป็น Copy คือชนิดที่อยู่ บน stack ล้วน ไม่มีส่วน heap ให้ต้อง free — i32, bool, char, ชนิด float, และ tuple/array ที่สมาชิกทุกตัวเป็น Copy การ assign จึงแค่คัดลอก bit บน stack ทั้งก้อน ต้นฉบับยังใช้ได้ ไม่มีประเด็น double-free ให้กังวล นี่คือเส้นแบ่งเดียวกับ value type ใน C# (int, struct) ที่ copy ตอน assign — ต่างกันตรง Rust ต้อง opt-in และชนิดจะเป็น Copy ได้ก็ต่อเมื่อไม่มี field ไหนที่ต้อง Drop (เช่นถ้ามี String อยู่ข้างในก็เป็น Copy ไม่ได้)
การยืม: & กับ &mut และกฎเหล็กข้อเดียว
หัวข้อที่มีชื่อว่า “การยืม: & กับ &mut และกฎเหล็กข้อเดียว”การยืมมีสองแบบ และความต่างคือทั้งหมดของเรื่อง:
&s— shared borrow (ยืมแบบอ่าน) มีได้พร้อมกันกี่ตัวก็ได้&mut s— exclusive borrow (ยืมแบบแก้ไข) ต้องรักษาความ ผูกขาด ไว้
กฎการยืม (Book ch4.2) มีข้อเดียวแต่เด็ดขาด: ณ ขณะใดขณะหนึ่ง คุณมีได้ &mut หนึ่งตัว หรือ & กี่ตัวก็ได้ — แต่ไม่ใช่ทั้งสองพร้อมกัน (one &mut XOR any number of &)
สอง &mut พร้อมกันจะโดน E0499:
// ❌ compile ไม่ผ่านlet mut s = String::from("hello");let r1 = &mut s;let r2 = &mut s; // error[E0499]: cannot borrow `s` as mutable more than onceprintln!("{r1} {r2}");มี & อยู่แล้วยังไปขอ &mut จะโดน E0502:
// ❌ compile ไม่ผ่านlet mut s = String::from("hello");let r1 = &s;let r2 = &mut s; // error[E0502]: cannot borrow `s` as mutable ... also borrowed as immutableprintln!("{r1} {r2}");ทำไมกฎนี้ถึงสำคัญขนาดนั้น? เพราะ aliasing XOR mutation — “จะแก้ได้ต้องผูกขาด จะ share ได้ต้องอ่านอย่างเดียว” — คือนิยามของการ ไม่มี data race พอดีเป๊ะ data race เกิดเมื่อมีสองทางเข้าถึงข้อมูลเดียวกันพร้อมกันโดยอย่างน้อยหนึ่งทางเป็นการเขียน กฎการยืมของ Rust ห้ามสภาพนั้นตั้งแต่ compile ใน code single-thread ธรรมดาๆ — แล้วในบท8 เราจะเห็นว่ากฎเดียวกันนี้แหละที่ทำให้ Rust กล้าเรียก concurrency ของมันว่า fearless: data race กลายเป็น compile error ไม่ใช่ bug ที่ต้องไปไล่จับตอน runtime
non-lexical lifetime — การยืมจบตอนใช้ครั้งสุดท้าย
หัวข้อที่มีชื่อว่า “non-lexical lifetime — การยืมจบตอนใช้ครั้งสุดท้าย”ถ้ากฎเข้มขนาดนั้น code นี้ควรจะพังไหม? มันมีทั้ง & และ &mut บนตัวเดียวกัน:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{r1} and {r2}"); // r1, r2 ถูกใช้ครั้งสุดท้ายตรงนี้ — shared borrow จบ let r3 = &mut s; // ตอนนี้เหลือ exclusive borrow ตัวเดียว: OK r3.push_str(", world"); println!("{r3}");}code นี้ compile ผ่าน เพราะขอบเขตของการยืมไม่ได้ยาวถึงปีกกาปิด scope แต่จบที่ การใช้ครั้งสุดท้าย ต่างหาก นี่คือ non-lexical lifetime (NLL): พอ println! บรรทัดแรกใช้ r1/r2 เสร็จ shared borrow ก็ตายไปทันที ทำให้บรรทัดถัดมาขอ &mut ได้โดยไม่ชนกฎ compiler ฉลาดพอจะดูว่า borrow ถูกใช้จริงถึงเมื่อไหร่ ไม่ใช่แค่ดูว่าตัวแปรอยู่ใน scope หรือเปล่า
slice — มุมมองยืมที่ไม่เป็นเจ้าของอะไร
หัวข้อที่มีชื่อว่า “slice — มุมมองยืมที่ไม่เป็นเจ้าของอะไร”slicesliceมุมมองยืม (ptr,len) เข้าไปในข้อมูลต่อเนื่องที่คนอื่นเป็นเจ้าของ เช่น `&str`, `&[T]` คือการยืมชนิดพิเศษ: มันคือ (pointer, length) ที่ชี้เข้าไปดู ส่วนหนึ่ง ของข้อมูลต่อเนื่องที่คนอื่นเป็นเจ้าของ โดยตัวมันเองไม่ได้เป็นเจ้าของอะไรเลย สองตัวที่เจอบ่อยสุดคือ &[T] (slice ของ Vec/array) และ &str&strstring แบบยืม อ่านอย่างเดียว UTF-8 (คู่กับ `String` ที่เป็นเจ้าของ heap) (slice ของ String)
ประเด็นสำคัญเชิงปฏิบัติคือ deref coercion: &String จะถูก แปลงให้อัตโนมัติ เป็น &str เมื่อ function ต้องการ &str ดังนั้นถ้าคุณรับ parameter เป็น &str function ของคุณจะรับได้ทั้ง string literal และ &String — กว้างกว่าการรับ &String ที่รับได้แค่ String เท่านั้น
fn print_len(s: &str) { // รับ &str = รับได้กว้างสุด println!("{} bytes", s.len());}fn main() { let owned = String::from("hello"); print_len(&owned); // &String -> &str อัตโนมัติ (deref coercion) print_len("literal"); // string literal เป็น &str อยู่แล้ว}นี่คือที่มาของสำนวน “รับ &str, คืน/เก็บ String” ที่เราจะย้ำอีกทีในบท5 — ในบทนี้ตัวอย่างก่อนหน้ายังรับ &String เพื่อให้ตรงกับลำดับของหนังสือ ch4 แต่จากนี้ให้ติดนิสัยมอง &str เป็น default ของ parameter string
Span<T>/ReadOnlySpan<T> ของ C# คือ ref struct ที่อยู่บน stack เท่านั้น มีกฎห้าม escape คล้าย lifetime ของ Rust — เป็น ที่เดียว ที่ C# รับ model แบบ Rust เข้ามา ต่างกันตรง C# ใช้กฎนี้เฉพาะกับ span ไม่กี่ชนิด แต่ Rust ใช้กับ ทุก reference ในภาษา
lifetime ตัวแรกแบบใจดี — 'a อธิบายความสัมพันธ์ ไม่ได้ยืดอายุ
หัวข้อที่มีชื่อว่า “lifetime ตัวแรกแบบใจดี — 'a อธิบายความสัมพันธ์ ไม่ได้ยืดอายุ”มาถึงคำที่หลายคนกลัวที่สุด: lifetimelifetimeคำอธิบายความสัมพันธ์ของอายุ reference เพื่อกันการชี้ค้าง (dangling); ส่วนใหญ่ถูก elide ลองเขียน function คืน reference ตัวที่ยาวกว่าจากสองตัว — เขียนแบบ ไม่ใส่ lifetime ก่อน มันจะพัง:
// ❌ compile ไม่ผ่านfn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y }}error[E0106]: missing lifetime specifiercompiler บอกว่ามันไม่รู้ว่า reference ที่คืนออกไปผูกอายุกับ x หรือ y — และมันต้องรู้เพื่อจะรับประกันว่า reference ที่คืนไปจะไม่ชี้ค้าง (dangling) หลังจากข้อมูลต้นทางตายไปแล้ว ใส่ 'a เข้าไปเพื่อ บอกความสัมพันธ์:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y }}'a อ่านว่า “reference ที่คืนออกไปจะมีชีวิตอยู่ได้ ไม่เกิน ตัวที่สั้นกว่าระหว่าง x กับ y” หัวใจที่ต้องเข้าใจ (Book ch10.3): 'a ไม่ได้ ยืด อายุของอะไรทั้งนั้น มันแค่ อธิบายความสัมพันธ์ ของอายุที่มีอยู่แล้ว ให้ compiler มีข้อมูลพอจะปฏิเสธ code ที่จะคืน reference อายุยืนกว่าข้อมูลจริง lifetime เป็นเครื่องมือ บรรยาย ไม่ใช่เครื่องมือ ควบคุม อายุ
dangling ref และทางแก้ที่ตรงที่สุด: คืน String ที่เป็นเจ้าของ
หัวข้อที่มีชื่อว่า “dangling ref และทางแก้ที่ตรงที่สุด: คืน String ที่เป็นเจ้าของ”ทีนี้ดูกรณีที่ lifetime ช่วยอะไรไม่ได้ เพราะมันผิดที่รากเลย — function พยายามคืน reference ไปยังค่า local ของตัวเอง:
// ❌ compile ไม่ผ่านfn make_greeting() -> &String { let s = String::from("hello"); &s // s จะถูก drop ตอน function จบ}error[E0106]: missing lifetime specifiers เป็นเจ้าของ buffer และมันจะถูก drop ทันทีที่ function จบ — reference ที่คืนออกไปจะชี้ไปยังหน่วยความจำที่ถูกคืนไปแล้ว นั่นคือ dangling pointer ที่ในภาษาไม่มี borrow checker คือ use-after-free ทางแก้ ไม่ใช่ การหา lifetime มายัด แต่คือการคืน ค่าที่เป็นเจ้าของ ออกไปตรงๆ ให้ผู้เรียกรับความเป็นเจ้าของไป:
fn make_greeting() -> String { // คืน String ที่เป็นเจ้าของ ไม่ใช่ reference let s = String::from("hello"); s // move ความเป็นเจ้าของออกไปให้ผู้เรียก}fn main() { let g = make_greeting(); // g เป็นเจ้าของ buffer ต่อ println!("{g}");}ความเป็นเจ้าของถูก ย้าย ออกจาก function ไปให้ผู้เรียก ไม่มีอะไรถูก drop ก่อนเวลา และไม่ต้องมี lifetime สักตัว นี่คือสำนวนที่คุณจะใช้บ่อยกว่า lifetime มาก: เมื่อสงสัย ให้คืนของที่เป็นเจ้าของ — เก็บ lifetime ไว้ใช้ตอนที่คุณ จำเป็น ต้องคืน reference จริงๆ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้คือฐานของทั้งคอร์ส: กฎ ownership สามข้อทำให้ค่าถูก drop แบบ deterministic โดยไม่ต้องมี GC; let s2 = s1; บน String เป็น move ไม่ใช่ alias — ใช้ s1 ต่อไม่ได้ (E0382) เพราะมันกัน double-free แก้ได้ด้วย clone (กำปั้นทุบดิน) หรือยืมด้วย & (ฉลาดกว่า); ชนิด stack ล้วนเป็น Copy จึง copy แทนที่จะ move; กฎการยืม one &mut XOR any & (E0499/E0502) คือ aliasing-XOR-mutation ที่ทำให้ data race เป็นไปไม่ได้ — ฐานของ fearless concurrency ในบท8; NLL ทำให้ borrow จบตอนใช้ครั้งสุดท้าย ไม่ใช่สุด scope; slice (&str/&[T]) คือมุมมองยืมที่ไม่เป็นเจ้าของอะไร และ &String deref-coerce เป็น &str ให้เอง; และ lifetime 'a แค่อธิบายความสัมพันธ์ของอายุ ไม่ได้ยืดอายุ (E0106) — ส่วน dangling ref แก้ตรงที่สุดด้วยการคืน String ที่เป็นเจ้าของ
บท3 เราจะเอา ownership ที่ฝึกมานี้ไป model ข้อมูลจริง: struct, enum, pattern matching และ Option — ระบบชนิดที่ทำให้ “ไม่มีค่า” (null) กลายเป็นสิ่งที่ แทนไม่ได้ เว้นแต่คุณจะประกาศมันออกมาตรงๆ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch4.1 “What Is Ownership?” (เข้าถึง 2026-07-24) — กฎ ownership สามข้อ, move บน
String,clone, และ deterministic drop (RAII) - The Rust Programming Language — ch4.2 “References and Borrowing” (เข้าถึง 2026-07-24) —
&/&mut, กฎ one&mutXOR any&, non-lexical lifetime, และการันตี reference ชี้ค่าที่ valid เสมอ - The Rust Programming Language — ch10.3 “Validating References with Lifetimes” (เข้าถึง 2026-07-24) —
'aในlongestอธิบายความสัมพันธ์ของอายุ ไม่ได้ยืดอายุ - rustc Error Codes Index — E0382 / E0499 / E0502 / E0106 (เข้าถึง 2026-07-24) — คำอธิบายทางการของ error แต่ละตัวที่บทนี้จงใจ trigger
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3หลังบรรทัด `let s2 = s1;` เมื่อ `s1` เป็น `String` เกิดอะไรขึ้น และทำไมการใช้ `s1` ต่อจึงได้ error E0382?