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

Drop & RAII

RAII (Resource Acquisition Is Initialization) คือ pattern ที่ ownership model ทั้งหมดของ Rust ถูกสร้างขึ้นมาบน: ผูกอายุของ resource เข้ากับ scope ของค่าหนึ่งค่า แล้วปล่อยให้คอมไพเลอร์แทรกการเรียก cleanup อัตโนมัติเมื่อ scope นั้นจบ ไม่มี garbage collector ตัดสินใจว่า เมื่อไหร่ จะคืนหน่วยความจำ — คอมไพเลอร์ตัดสินใจว่า ตรงไหนพอดี ตอน compile time และฝังการเรียก drop ที่เจาะจงเข้าไปในโค้ดที่ generate ตรงจุดนั้นเป๊ะ

สองกฎที่ควบคุมเกือบทุกอย่างในบทนี้: local variable ถูก drop ตามลำดับ ย้อนกลับ ของการประกาศ และ field ของค่าหนึ่งถูก drop ตามลำดับ การประกาศ (ไปข้างหน้า ไม่ใช่ย้อนกลับ)

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
struct Pair {
first: Noisy,
second: Noisy,
}
fn main() {
let _a = Noisy("a");
let _b = Noisy("b");
let _pair = Pair { first: Noisy("pair.first"), second: Noisy("pair.second") };
println!("end of main");
}
end of main
dropping pair.first
dropping pair.second
dropping b
dropping a

_pair ถูกประกาศทีหลังสุด ดังนั้นมันจึงถูก drop ก่อน (ลำดับย้อนกลับในหมู่ local) — และข้างในมัน first ถูก drop ก่อน second (ลำดับไปข้างหน้าในหมู่ field) จากนั้น _b แล้วก็ _a นี่คือ stack เป๊ะ ๆ: สิ่งที่ push เข้าไปล่าสุดคือสิ่งที่ pop ออกก่อน และมันใช้กับทุกกรณีเหมือนกัน ไม่ว่าค่าจะเป็น local ธรรมดาหรืออยู่ซ้อนในกันใน struct

ค่าที่ไม่มีชื่อ — ผลลัพธ์ของ expression ที่ไม่ได้ผูกกับ let — คือ temporary และมันถูก drop ตอนจบ statement ที่มันปรากฏ ไม่ใช่ตอนจบ scope ที่ล้อมรอบมัน:

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
println!("temporary: {}", Noisy("temporary").0);
println!("this runs after the temporary is already gone");
}
temporary: temporary
dropping temporary
this runs after the temporary is already gone

Noisy("temporary") ไม่เคยถูกผูกกับตัวแปรเลย — มันมีอยู่แค่นานพอให้ .0 ถูกอ่านข้างใน call println! และมันถูก drop ทันทีที่ statement นั้นจบ ก่อน statement ถัดไป จะรันด้วยซ้ำ ไม่ว่า scope ที่ล้อมรอบจะมีโค้ดเหลืออีกเยอะแค่ไหนก็ตาม

ค่าที่ match หรือ if let กำลังจับคู่ด้วย (scrutinee) เองก็เป็น temporary ได้ — ที่พบบ่อยที่สุดคือ lock guard เช่นใน if let Some(x) = *some_rwlock.read().unwrap() { ... } else { ... } Rust 2021 กับ Rust 2024 ไม่เห็นตรงกันว่า drop ของ temporary นั้นเกิดขึ้นเมื่อไหร่เทียบกับ branch else และความต่างนั้นใหญ่พอที่จะเปลี่ยนโปรแกรมจากถูกต้องให้กลายเป็น deadlock กับตัวเอง ส่วน if let and Lock Guards (Rust 2024) ของโมดูล Concurrency ไล่ตัวอย่าง 2021-deadlock/2024-ไม่-deadlock นี้ด้วย lock จริง บทนี้แค่ต้องการให้คุณจำรูปร่างได้ — temporary ที่ถูกเงื่อนไข if let/match ยึดไว้ให้มีชีวิตอยู่ — ว่าเป็นจุดที่การเปลี่ยนเวลา drop ข้าม edition มีผล

Drop::drop รับ &mut self ซึ่งจะให้คุณเรียกมันได้ในขณะที่ค่ายังมีชีวิตและใช้งานได้อยู่ — แล้วให้มันถูกเรียก อีกครั้ง อัตโนมัติเมื่อค่านั้นออกจาก scope จริง ๆ กลายเป็น double-drop คอมไพเลอร์ห้ามสิ่งนี้ทั้งหมด แทนที่จะพยายามตรวจจับกรณีที่ไม่ปลอดภัย:

// @expect-error E0040
struct Resource;
impl Drop for Resource {
fn drop(&mut self) {
println!("releasing resource");
}
}
fn main() {
let r = Resource;
r.drop();
}
error[E0040]: explicit use of destructor method
--> src/main.rs:11:7
|
11 | r.drop();
| ^^^^ explicit destructor calls not allowed
|
help: consider using `drop` function
|
11 - r.drop();
11 + drop(r);
|
error: aborting due to 1 previous error

ทางแก้ที่ rustc แนะนำ, drop(r), คือ std::mem::drop — free function ที่ implementation ทั้งหมดของมันคือ fn drop<T>(_x: T) {} มันทำงานด้วยการ move r เข้าไปในตัวมันเองแล้วปล่อยให้มันหลุดออกจาก scope ตอนจบ function เปล่า ๆ นั้น ซึ่ง trigger การเรียก destructor ที่ compiler แทรกให้ครั้งเดียวตามปกติ มันเป็น function ไม่ใช่ method พอดีเพื่อให้มันรับ ownership แบบ by value แทนที่จะ borrow — คุณเรียกมันบนสิ่งที่คุณไม่ได้เป็นเจ้าของโดยไม่ตั้งใจไม่ได้ และคุณเรียกมันสองครั้งบนค่าเดียวกันไม่ได้ (การเรียกครั้งที่สองจะ compile ไม่ผ่าน — ค่าถูก move ไปแล้ว)

Move ระงับ Drop ที่ตำแหน่ง เดิม ไม่ใช่ระงับทั้งหมด

หัวข้อที่มีชื่อว่า “Move ระงับ Drop ที่ตำแหน่ง เดิม ไม่ใช่ระงับทั้งหมด”

การ move ค่าไม่ได้ข้าม destructor ของมัน — มันแค่หมายความว่า destructor รันที่ไหนก็ตามที่ owner ของค่า ตอนนี้ อยู่เมื่อ scope ของ owner นั้นจบ ไม่จำเป็นต้องเป็นที่ที่ค่านั้นถูกสร้างครั้งแรก:

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn consume(n: Noisy) {
println!("consume received {}", n.0);
} // n is dropped here, at the end of consume -- not back in main
fn main() {
let a = Noisy("a");
consume(a); // ownership moves into consume; main no longer owns it
println!("back in main");
}
consume received a
dropping a
back in main

a ถูก drop ข้างใน consume ก่อนที่ "back in main" จะ print เลยด้วยซ้ำ — main สละ ownership ไปแล้วตอน call consume(a) ดังนั้นตอน scope ของ main เองจบทีหลังจึงไม่มีอะไรเหลือให้ drop นี่ก็เป็นเหตุผลว่าทำไมการ move ค่าออกจากตัวแปรแล้วพยายามใช้ตัวแปรเดิมถึงเป็น compile error (E0382 จากโมดูล Ownership & Borrowing) แทนที่จะเป็น double-drop เงียบ ๆ ตอน runtime: คอมไพเลอร์ track แบบ static ว่าค่าแต่ละตัวจะถูก drop ที่ไหนที่เดียวเป๊ะ ๆ

std::mem::ManuallyDrop<T> ห่อค่าหนึ่งไว้และระงับการเรียก Drop อัตโนมัติของมันทั้งหมด — destructor ของค่าที่ห่อไว้ไม่มีวันรัน เว้นแต่คุณจะ opt back in ด้วย ManuallyDrop::drop (unsafe เพราะการเรียกมันมากกว่าหนึ่งครั้งคือ double-drop จริง ๆ ที่คอมไพเลอร์ป้องกันให้คุณไม่ได้อีกต่อไป)

use std::mem::ManuallyDrop;
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let wrapped = ManuallyDrop::new(Noisy("wrapped"));
println!("wrapped.0 = {}", wrapped.0);
println!("end of main -- wrapped's Drop never runs, ManuallyDrop suppresses it");
}
wrapped.0 = wrapped
end of main -- wrapped's Drop never runs, ManuallyDrop suppresses it

ManuallyDrop<T> มีไว้สำหรับโค้ดระดับต่ำที่ต้องควบคุมด้วยมือว่าเมื่อไหร่ (หรือจะรันไหม) destructor ของค่าหนึ่งจะรัน — implement data structure ที่ย้ายค่าไปมาด้วย raw pointer โดยไม่ trigger destructor ของมันระหว่างทำงาน หรือส่ง ownership ของค่าให้โค้ดภายนอกข้าม FFI boundary การหยิบมันมาใช้ในโค้ด application ธรรมดาเป็นสัญญาณแรง ๆ ว่ามีบางอย่างใน design ที่ต้องมองอีกครั้ง

std::mem::forget(value) รับ ownership ของ value แล้วไม่ทำอะไรกับมันเลย — ไม่มีการเรียก destructor เลย ตลอดชีวิตที่เหลือของโปรแกรม มันเป็น function ที่ safe โดยตั้งใจ: การ leak หน่วยความจำไม่ได้ละเมิดการรับประกันความปลอดภัยของหน่วยความจำของ Rust ข้อไหนเลย (ไม่มี use-after-free, ไม่มี double-free, ไม่มี data race) มันแค่สิ้นเปลือง เหมือนกับวงจร Rc จากบทที่แล้วเป๊ะ

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let a = Noisy("a");
std::mem::forget(a); // leaks a -- its Drop will never run
println!("end of main -- \"dropping a\" never printed, a's memory is never reclaimed");
}
end of main -- "dropping a" never printed, a's memory is never reclaimed

mem::forget มีการใช้งานที่ชอบธรรม — ส่ง ownership ของค่าให้โค้ด C ที่จะ free มันเอง หรือ implementation ภายในของ concurrent data structure บางแบบ — แต่การใช้งานที่ชอบธรรมทุกแบบคือการตั้งใจโอนความรับผิดชอบเรื่อง cleanup ไปที่อื่น ไม่ใช่การเลี่ยง cleanup ไปเฉย ๆ

mem::forget ข้างบน leak ค่าเดียวเป๊ะ ๆ โดยตั้งใจ มองเห็นได้ ที่ call site เดียว — ตรวจสอบง่าย วงจร Rc reference ของบทที่แล้ว leak แบบเงียบ ๆ ไม่มี call ไหนเลยที่ flag มันไว้ตรง ๆ และมันขยายตัวได้: node เพิ่มเติมทุกตัวที่ถูกดึงเข้าไปในวงจร (ตรง ๆ หรือทางอ้อม) leak ไปด้วย ตลอดชีวิตของโปรแกรม โดยไม่มีอะไรใน source code ทำเครื่องหมายว่า leak เกิดขึ้นตรงไหน กลับไปดู Smart Pointers ถ้ากลไกนี้ไม่สดใหม่ในหัว — ประเด็นตรงนี้คือความต่าง: mem::forget คือ leak ที่ตั้งใจ ตรวจสอบได้; วงจร reference คือ leak แบบ บังเอิญ ที่โตขึ้นเงียบ ๆ ได้เมื่อ object graph ของโปรแกรมโตขึ้น

panic! ปกติจะ unwind: คอมไพเลอร์เรียก destructor ของทุกค่าที่ยังมีชีวิตอยู่ ทีละ scope ย้อนกลับขึ้นไปตาม call stack เหมือนกับว่าแต่ละ scope นั้น return ตามปกติเป๊ะ — ซึ่งเป็นเหตุผลตรง ๆ ว่าทำไม Drop impl ที่ปล่อย lock หรือปิดไฟล์ถึงยังรันถูกต้องแม้โค้ดจะ panic แทนที่จะ return ปกติ มีสองวิธีที่ safety net นี้เองล้มเหลว

panic = "abort" (ค่า config ใน Cargo.toml/rustc profile) ข้าม unwinding ไปทั้งหมดและจบ process ทันทีเมื่อมี panic ใด ๆ โดยไม่มี destructor รันเลย — binary เล็กลงและไม่มี overhead ของ unwinding table แลกกับ resource ที่ไม่ถูก clean up จริง ๆ บน path นั้น หัวข้อ panic = "abort" ของโมดูล Error Handling ครอบคลุมว่าตอนไหน trade นี้คุ้มค่า

โหมดล้มเหลวที่สองไม่ต้อง config อะไรเลย: ถ้า Drop impl เองก็ panic ในขณะที่ panic อีกตัวกำลัง unwind ผ่านมันอยู่ Rust unwind สองครั้งพร้อมกันไม่ได้ ดังนั้น process จึง abort ทันที ไม่มีเงื่อนไข:

// @expect-panic
struct BadDrop;
impl Drop for BadDrop {
fn drop(&mut self) {
panic!("panic during drop"); // this drop runs while already unwinding from main's panic
}
}
fn main() {
let _guard = BadDrop;
panic!("first panic"); // unwinding starts, which runs _guard's Drop
}
thread 'main' (<id>) panicked at src/main.rs:11:5:
first panic
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' (<id>) panicked at src/main.rs:5:9:
panic during drop
stack backtrace:
(backtrace elided -- a panic-during-unwind always prints one regardless of RUST_BACKTRACE, since the process is about to abort)
thread 'main' (<id>) panicked at /rustc/<hash>/library/core/src/panicking.rs:233:5:
panic in a destructor during cleanup
thread caused non-unwinding panic. aborting.

Panic แรกเริ่ม unwind ซึ่งเรียก Drop ของ _guard; Drop นั้นเอง panic ในขณะที่ unwind แรกยังดำเนินอยู่ และ Rust ไม่มีทางแสดง “unwind ในขณะที่กำลัง unwind อยู่” ได้ จึง abort process ทั้งหมดทันทีแทน กฎเชิงปฏิบัติที่นี่รองรับคือ: Drop implementation ควรถือว่าการ panic เป็นทางเลือกสุดท้าย และไม่ควรสมมติว่าปลอดภัยที่จะ .unwrap() หรือทำให้ panic ได้ในทางอื่นข้างในมัน เพราะเวลาที่มันสำคัญที่สุด (cleanup ระหว่าง panic ที่มีอยู่แล้ว) คือเวลาเดียวกับที่ panic ที่สองตรงนั้นมีต้นทุนสูงที่สุด

Early return ของ operator ? ไม่ใช่กรณีพิเศษสำหรับ Drop — มันคือ early return ธรรมดา และ destructor ของทุก local ที่ยังมีชีวิตอยู่รันตอนออกไป เหมือนกับ return ปกติหรือการรันจนจบ function เป๊ะ:

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn parse(input: &str) -> Result<i32, std::num::ParseIntError> {
let _guard = Noisy("guard"); // dropped no matter which path we take out of this function
let n: i32 = input.parse()?; // early return on Err still runs _guard's Drop first
Ok(n * 2)
}
fn main() {
println!("parse(\"21\") = {:?}", parse("21"));
println!("parse(\"oops\") = {:?}", parse("oops"));
}
dropping guard
parse("21") = Ok(42)
dropping guard
parse("oops") = Err(ParseIntError { kind: InvalidDigit })

_guard ถูก drop ก่อนที่ println! ทั้งสองตัวจะเห็นผลลัพธ์ ทั้งบน success path และ path ที่ ? trigger early-return — นี่คือเหตุผลตรง ๆ ว่าทำไม RAII guard (MutexGuard, wrapper ของ file handle, span ในโค้ด tracing) ถึงปลอดภัยที่จะใช้ใน function ที่เต็มไปด้วย ?: ไม่มี path ไหนออกจาก function เลย ไม่ว่าจะ error หรือไม่ ที่ข้าม destructor ไปได้

struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
struct Pair {
first: Noisy,
second: Noisy,
}
fn consume(n: Noisy) {
println!("consume received {}", n.0);
}
fn main() {
let _a = Noisy("a");
let _b = Noisy("b");
let _pair = Pair { first: Noisy("pair.first"), second: Noisy("pair.second") };
println!("temporary: {}", Noisy("temporary").0);
let c = Noisy("c");
consume(c); // dropped inside consume, not here
std::mem::forget(Noisy("forgotten")); // never dropped at all
println!("end of main");
}

ลองนึกภาพว่าทุก scope คือ stack ของตั๋วเคลม หนึ่งใบต่อค่าหนึ่งค่าที่เป็นเจ้าของสิ่งที่ต้อง drop ได้ ถูก push เข้าไปตามลำดับการประกาศ การออกจาก scope คือการ pop แล้วแลกตั๋วจากบนลงล่าง — ประกาศทีหลังสุด, drop ก่อน — และค่าที่มี field ก็เป็น stack ตั๋วขนาดจิ๋วของตัวมันเอง แลกตามลำดับที่ประกาศไว้ข้างใน move ไม่ได้ทำลายตั๋ว มันแค่ส่งตั๋วให้ stack อีกอัน (ของ callee) เพื่อให้แลกที่นั่นแทน เมื่อไหร่ก็ตามที่ stack นั้น unwind mem::forget ฉีกตั๋วทิ้งโดยไม่แลก โดยตั้งใจ ครั้งเดียว วงจร Rc คือตั๋วสองใบที่แต่ละใบต้องการให้อีกใบถูกแลกก่อน — ไม่มีใบไหนแลกได้เลย ทั้งคู่จึงนอนอยู่ในลิ้นชักตลอดไป

การ drop แบบ deterministic ที่ถูกแทรกตอน compile time ให้เวลาการ cleanup ที่แม่นยำ คาดเดาได้ ด้วยต้นทุนการสแกนตอน runtime เป็นศูนย์ — คุณรู้เสมอว่าบรรทัดไหนเป๊ะ ๆ ที่ปล่อย lock หรือปิดไฟล์ ต่างจาก “สักวันหนึ่ง ในที่สุด” ของ GC ต้นทุนคือ อะไรก็ตามที่หลีกเลี่ยงความ deterministic นั้น (ManuallyDrop, mem::forget, วงจร reference) จะ opt out จากการรับประกันนั้นทั้งหมดและผลักความถูกต้องกลับมาให้คุณ ไม่ว่าจะตั้งใจ (การส่งต่อผ่าน FFI) หรือบังเอิญ (วงจรที่คุณไม่ทันสังเกตว่าสร้างขึ้นมา)

  • “การ move ค่าออกจากตัวแปรข้าม Drop impl ของมันไป” มันไม่ได้ข้าม — มันแค่ย้าย ที่ ที่ drop เกิดขึ้น ไปยังที่ที่ scope ของ owner ใหม่จบ วิธีเดียวที่จะข้าม destructor ของค่าจริง ๆ คือ ManuallyDrop, mem::forget หรือการ leak (เช่นวงจร reference)
  • “Function ที่ panic ทิ้ง resource ค้างไว้เพราะมันไม่ได้ return ตามปกติ” Unwinding เรียก destructor ของทุกอย่างที่ยังมีชีวิตอยู่ตอนขึ้นไปตาม call stack เหมือน return ปกติเป๊ะ — นี่คือเหตุผลตรง ๆ ว่าทำไม RAII guard ถึงปลอดภัยข้าม ? และแม้แต่ข้าม panic! ธรรมดา (ที่ไม่ panic ใน drop)
  • “mem::forget เป็น unsafe เพราะมันป้องกัน cleanup” มันเป็น function ที่ safe — การ leak ไม่ใช่การละเมิดความปลอดภัยของหน่วยความจำในความหมายของ Rust (ไม่มี use-after-free, ไม่มี data race) แค่เสียทรัพยากรไปเปล่า ๆ สิ่งที่มันเสียไปแบบเงียบ ๆ คือ cleanup ในโลกจริง (ปิดไฟล์, ปล่อย lock) ที่ Drop ของ type นั้นควรจะทำ

โค้ดที่ AI สร้างบางครั้งเอื้อมไปหยิบ mem::forget เพื่อ “ป้องกัน double-free” ที่มันคิดขึ้นเองจากความเข้าใจ ownership ผิด ๆ — move semantics ของ Rust รับประกันอยู่แล้วว่าค่าหนึ่งมีเจ้าของเดียวในแต่ละครั้ง ดังนั้นไม่เคยมีความเสี่ยง double-drop ตั้งแต่แรก และการเรียก forget ก็แค่ leak resource แทน

🐛 หา bug ในโค้ดนี้

struct FileHandle {
name: &'static str,
}
impl Drop for FileHandle {
fn drop(&mut self) {
println!("closing file: {}", self.name);
}
}
fn process(handle: FileHandle) {
println!("processing {}", handle.name);
// "forget it here so the caller's copy doesn't also try to close it" -- AI-added comment
std::mem::forget(handle);
}
fn main() {
let handle = FileHandle { name: "report.csv" };
process(handle);
println!("end of main");
}
เฉลย

โค้ดนี้ compile ผ่านและรันได้ พิมพ์ processing report.csv แล้ว end of main — และ closing file: report.csv ไม่ถูก print เลย สมมติฐานใน comment นั้นผิดล้วน ๆ: process(handle) move handle เข้าไปใน process; main ไม่มี “สำเนา” เหลือให้ต้องกังวลเลย เพราะ Rust ไม่เคยมีมันตั้งแต่แรก — ownership ถูกโอนครั้งเดียว สมบูรณ์ ตอน call นั้น ไม่เคยมีความเสี่ยง double-close ให้ต้องป้องกัน ดังนั้นการเรียก mem::forget จึงไม่มีจุดประสงค์อะไรเลยนอกจากไประงับการเรียก Drop ตัวเดียวที่ควรจะรัน ทำให้ resource จริงที่ FileHandle แทนอยู่ leak ไป (ในที่นี้คือ file descriptor ที่ไม่เคยถูกปิด) ทางแก้คือลบบรรทัด mem::forget ทิ้งไปเลย — handle ถูก drop อัตโนมัติ ครั้งเดียวเป๊ะ ตอนจบ process ซึ่งเป็นพฤติกรรมที่ถูกต้องเป๊ะโดยไม่ต้องมีโค้ดเพิ่มเลย

โค้ดนี้ print อะไร?
struct N(&'static str);
impl Drop for N {
    fn drop(&mut self) { println!("drop {}", self.0); }
}
fn main() {
    let _a = N("a");
    let _b = N("b");
}
ทำไมโค้ดนี้ compile ไม่ผ่าน?
struct R;
impl Drop for R {
    fn drop(&mut self) { println!("bye"); }
}
fn main() {
    let r = R;
    r.drop();
}
ค่าหนึ่งถูก move จากตัวแปร a ใน function หนึ่งเข้าไปเป็น parameter ของอีก function หนึ่งชื่อ consume destructor ของมันรันที่ไหน?
std::mem::forget คืออะไร และทำไมมันถึงเป็น function ที่ safe ทั้งที่มันป้องกัน cleanup?
จะเกิดอะไรขึ้นถ้า Drop implementation เอง panic ในขณะที่มันกำลังรันเป็นส่วนหนึ่งของการ unwind panic ก่อนหน้า?
_guard ของ function นี้ถูก drop ไหมถ้า ? trigger early return?
fn parse(s: &str) -> Result<i32, std::num::ParseIntError> {
    let _guard = Noisy("guard");
    let n: i32 = s.parse()?;
    Ok(n * 2)
}