การทำขั้นสุดท้ายในเดสทรัคเตอร์ (Finalisation in Destructors)
คำอธิบาย
ภาษา Rust ไม่มีโครงสร้างบล็อก finally โดยตรงเหมือนในบางภาษา (ซึ่งหมายถึงบล็อกโค้ดที่จะทำงานเสมอไม่ว่าฟังก์ชันจะจบการทำงานด้วยกรณีใดก็ตาม) แต่เราสามารถใช้ประโยชน์จากเดสทรัคเตอร์ (Destructor) ของออบเจกต์ผ่าน trait Drop มารับหน้าที่รันโค้ด Cleanup ที่จำเป็นต้องทำงานก่อนออกจากฟังก์ชันได้
ตัวอย่าง
fn baz() -> Result<(), ()> {
// some code
}
fn bar() -> Result<(), ()> {
// These don't need to be defined inside the function.
struct Foo;
// Implement a destructor for Foo.
impl Drop for Foo {
fn drop(&mut self) {
println!("exit");
}
}
// The destructor of _exit will run however the function `bar` is exited.
let _exit = Foo;
// Implicit return with `?` operator.
baz()?;
// Normal return.
Ok(())
}
ที่มาและเหตุผล
หากฟังก์ชันใดมีจุดส่งคืนค่า (Return points) หลายจุด การเขียนโค้ดเพื่อคืนทรัพยากรหรือบันทึกสถานะก่อนออกจากฟังก์ชันจะกลายเป็นเรื่องยุ่งยาก ซ้ำซ้อน และเสี่ยงต่อการเกิดข้อผิดพลาด (Bugs) ได้ง่าย โดยเฉพาะอย่างยิ่งในกรณีที่มีการส่งค่ากลับโดยอัตโนมัติผ่านมาโครหรือโอเปอเรเตอร์ ? (ซึ่งจะ return ออกจากฟังก์ชันทันทีหากได้ผลลัพธ์เป็น Err แต่จะทำงานต่อหากเป็น Ok) แม้ว่า ? จะทำหน้าที่คล้ายกลไกการจัดการข้อยกเว้น แต่ต่างจาก Java (ที่มีบล็อก finally) ตรงที่ Rust ไม่มีคำสั่งสำเร็จรูปสำหรับสั่งให้โค้ดทำงานครอบคลุมทั้งในกรณีปกติและกรณีเกิดข้อผิดพลาด นอกจากนี้ การเกิด panic ก็ส่งผลให้ฟังก์ชันยุติการทำงานก่อนเวลาได้เช่นเดียวกัน
ข้อดี
โค้ดที่บรรจุอยู่ในเดสทรัคเตอร์จะได้รับการการันตีว่า (เกือบ) จะถูกเรียกใช้งานเสมอ ไม่ว่าจะเกิดเหตุการณ์ panic, มีการ return ออกก่อนเวลา หรือจบฟังก์ชันตามปกติ
ข้อเสีย
ไม่มีการรับประกันแบบ 100% ว่าเดสทรัคเตอร์จะถูกรันในทุกสถานการณ์ ตัวอย่างเช่น หากฟังก์ชันเกิดลูปไม่รู้จบ (Infinite loop) หรือโปรเซสของโปรแกรมเกิดแครชอย่างกะทันหันก่อนออกจากฟังก์ชัน นอกจากนี้ เดสทรัคเตอร์จะไม่ทำงานในกรณีที่เกิด panic ซ้ำซ้อนในเธรดที่กำลัง panic อยู่แล้ว ดังนั้น เราจึงไม่ควรพึ่งพาเดสทรัคเตอร์เป็น Finalizer สำหรับกรณีที่การ Cleanup มีความสำคัญยิ่งยวดระดับคอขาดบาดตาย
แพตเทิร์นนี้เพิ่มโค้ดที่ทำงานโดยอัตโนมัติ (Implicit) ซึ่งอาจสังเกตเห็นได้ยาก การอ่านโค้ดของฟังก์ชันอาจไม่ได้บอกอย่างชัดเจนในทันทีว่าจะมีเดสทรัคเตอร์ใดบ้างที่ทำงานเมื่อจบฟังก์ชัน ซึ่งอาจทำให้การดีบักมีความซับซ้อนขึ้น
การต้องสร้าง struct ใหม่และเขียนบล็อก impl Drop เพียงเพื่อทำหน้าที่ Cleanup อาจทำให้เกิดโค้ด boilerplate มากเกินความจำเป็น
เจาะลึกรายละเอียด
มีข้อควรระวังสำคัญเกี่ยวกับการจัดเก็บออบเจกต์ที่ทำหน้าที่เป็น Finalizer: ออบเจกต์นั้นจะต้องคงอยู่จนกระทั่งจบขอบเขตของฟังก์ชันแล้วจึงค่อยถูกทำลาย ดังนั้นออบเจกต์ดังกล่าวจะต้องถือครองค่าโดยตรง หรือเป็นสมาร์ตพอยน์เตอร์ที่มีความเป็นเจ้าของแต่เพียงผู้เดียว (Unique Ownership เช่น Box<Foo>) หากเรานำสมาร์ตพอยน์เตอร์แบบแบ่งปันความเป็นเจ้าของ (Shared Ownership เช่น Rc) มาใช้ ตัว Finalizer อาจมีอายุยืนยาวเกินกว่าขอบเขตของฟังก์ชันได้ ด้วยเหตุผลเดียวกันนี้ ตัว Finalizer จึงต้องไม่ถูกย้าย (Move) หรือถูก return ออกไปนอกฟังก์ชัน
Finalizer จะต้องถูกผูกเข้ากับตัวแปรเสมอ มิฉะนั้นมันจะถูกทำลายทิ้งทันทีในบรรทัดนั้นแทนที่จะรอจนกระทั่งหลุดออกจาก scope และชื่อตัวแปรควรขึ้นต้นด้วยเครื่องหมายขีดล่าง _ (เช่น _exit) หากตัวแปรนั้นไม่ได้ถูกนำไปเรียกใช้งานที่อื่น เพื่อป้องกันไม่ให้คอมไพเลอร์แจ้งเตือนคำเตือนตัวแปรไม่ได้ใช้งาน อย่างไรก็ดี ห้ามตั้งชื่อตัวแปรเป็น _ โดดๆ โดยไม่มีตัวอักษรอื่นต่อท้าย เพราะในภาษา Rust ตัวแปร _ จะถือเป็นการละทิ้งค่าและทำให้ออบเจกต์ถูกทำลาย (Drop) ทันที
ในภาษา Rust เดสทรัคเตอร์จะทำงานเมื่อออบเจกต์หลุดออกจากขอบเขต (Scope) ไม่ว่าจะเป็นการสิ้นสุดบล็อกโค้ด, มีการ return ก่อนเวลา หรือโปรแกรมเกิด panic โดยเมื่อเกิด panic ขึ้น Rust จะคลายสแตก (Stack Unwinding) เพื่อตามเรียกเดสทรัคเตอร์ของทุกออบเจกต์ในแต่ละ Stack Frame ดังนั้นเดสทรัคเตอร์จึงยังคงถูกเรียกใช้แม้ว่าการ panic จะเกิดขึ้นลึกลงไปในฟังก์ชันลูกที่ถูกเรียกก็ตาม
แต่หากเดสทรัคเตอร์ดันเกิด panic ซ้ำขึ้นมาอีกในระหว่างกระบวนการคลายสแตก Rust จะถือว่าเป็นสถานะที่ไม่ปลอดภัยและจะสั่งระงับการทำงานของเธรดนั้นทันที (Abort) โดยไม่เรียกเดสทรัคเตอร์ตัวอื่นๆ ต่อไปอีก ซึ่งย้ำเตือนว่าคุณต้องใช้ความระมัดระวังเป็นพิเศษไม่ให้โค้ดภายในบล็อก drop เกิด panic ขึ้นได้ เพราะอาจทำให้ทรัพยากรตกค้างอยู่ในสถานะที่ไม่พึงประสงค์