#[non_exhaustive] และฟิลด์แบบ private เพื่อการขยายต่อในอนาคต
คำอธิบาย
มีสถานการณ์จำนวนหนึ่งที่ผู้พัฒนาไลบรารีต้องการเพิ่มฟิลด์แบบ public ให้แก่ struct สาธารณะ หรือเพิ่ม variant ใหม่ๆ ให้แก่ enum ในอนาคต โดยไม่ทำให้โค้ดของผู้เรียกใช้งานเดิมเกิดข้อผิดพลาดขึ้น (รักษาสภาพความเข้ากันได้ย้อนหลัง หรือ Backwards Compatibility)
ภาษา Rust มีทางออกสำหรับโจทย์ข้อนี้ 2 แนวทาง:
-
กำหนดแอตทริบิวต์
#[non_exhaustive]บนstruct,enumหรือบนแต่ละ variant ของenum(สามารถอ่านเอกสารฉบับละเอียดเกี่ยวกับทุกจุดที่รองรับ#[non_exhaustive]ได้ที่ เอกสารประกอบ) -
เพิ่มฟิลด์แบบ private (Private field) ไว้ใน struct เพื่อป้องกันไม่ให้ผู้เรียกใช้งานสร้างอินสแตนซ์ผ่าน struct literal โดยตรง หรือเขียน Pattern Matching ครอบคลุมทุกฟิลด์ (ดูรายละเอียดในหัวข้อทางเลือก)
ตัวอย่าง
#![allow(unused)]
fn main() {
mod a {
// Public struct.
#[non_exhaustive]
pub struct S {
pub foo: i32,
}
#[non_exhaustive]
pub enum AdmitMoreVariants {
VariantA,
VariantB,
#[non_exhaustive]
VariantC {
a: String,
},
}
}
fn print_matched_variants(s: a::S) {
// Because S is `#[non_exhaustive]`, it cannot be named here and
// we must use `..` in the pattern.
let a::S { foo: _, .. } = s;
let some_enum = a::AdmitMoreVariants::VariantA;
match some_enum {
a::AdmitMoreVariants::VariantA => println!("it's an A"),
a::AdmitMoreVariants::VariantB => println!("it's a b"),
// .. required because this variant is non-exhaustive as well
a::AdmitMoreVariants::VariantC { a, .. } => println!("it's a c"),
// The wildcard match is required because more variants may be
// added in the future
_ => println!("it's a new variant"),
}
}
}
ทางเลือก: Private fields สำหรับ struct
#[non_exhaustive] จะทำงานเฉพาะการข้ามขอบเขตระหว่าง crate เท่านั้น ส่วนการทำงานภายใน crate เดียวกัน คุณสามารถใช้วิธีเพิ่มฟิลด์แบบ private เข้ามาช่วยแทนได้
ตามปกติแล้ว การเพิ่มฟิลด์ใหม่เข้าไปใน struct ถือเป็นการเปลี่ยนแปลงที่เข้ากันได้ย้อนหลังเป็นส่วนใหญ่ อย่างไรก็ดี หากผู้เรียกใช้งานเขียนโค้ด Pattern Matching แบบแยกส่วนประกอบ (Destructure) โดยระบุชื่อฟิลด์ไว้ครบทุกฟิลด์ การที่เราเพิ่มฟิลด์ใหม่เข้าไปจะส่งผลให้โค้ดของพวกเขาคอมไพล์ไม่ผ่านทันที แต่หากผู้เรียกใช้งานระบุชื่อเฉพาะฟิลด์ที่สนใจและใส่ .. ต่อท้าย การเพิ่มฟิลด์ใหม่ก็จะไม่กระทบต่อโค้ดเดิมเลย ดังนั้น การกำหนดให้มีฟิลด์แบบ private อย่างน้อยหนึ่งฟิลด์ใน struct จะบีบให้ผู้เรียกใช้งานต้องใช้รูปแบบ .. เสมอ ซึ่งช่วยรับประกันว่า struct ของเราจะรองรับการเปลี่ยนแปลงในอนาคตได้อย่างปลอดภัย
ข้อเสียของแนวทางนี้คือ คุณอาจจำเป็นต้องเพิ่มฟิลด์ที่ไม่ได้ใช้งานจริงเข้ามาใน struct แต่คุณสามารถเลือกใช้ Unit Type () เพื่อไม่ให้มีภาระด้านหน่วยความจำตอนรันไทม์ (Zero runtime overhead) และใส่เครื่องหมาย _ นำหน้าชื่อฟิลด์เพื่อปิดคำเตือนตัวแปรไม่ได้ใช้งานของคอมไพเลอร์ได้
#![allow(unused)]
fn main() {
pub struct S {
pub a: i32,
// Because `b` is private, you cannot match on `S` without using `..` and `S`
// cannot be directly instantiated or matched against
_b: (),
}
}
เจาะลึกรายละเอียด
สำหรับ struct แล้ว การใส่ #[non_exhaustive] ช่วยให้เราสามารถเพิ่มฟิลด์ใหม่ๆ ในอนาคตได้อย่างปลอดภัยโดยไม่ทำลายความเข้ากันได้ย้อนหลัง และยังช่วยป้องกันไม่ให้ผู้เรียกใช้งานสร้างออบเจกต์ผ่าน struct literal ตรงๆ แม้ว่าทุกฟิลด์จะเป็น public ก็ตาม สิ่งนี้อาจมีประโยชน์ แต่ก็ควรพิจารณาให้รอบคอบว่าคุณต้องการให้ผู้ใช้พบข้อผิดพลาดจากคอมไพเลอร์ทันทีเมื่อมีการเพิ่มฟิลด์ใหม่ หรือต้องการให้โค้ดทำงานผ่านไปอย่างเงียบๆ
นอกจากนี้ เรายังสามารถใส่ #[non_exhaustive] ให้กับแต่ละ variant ของ enum ได้เช่นกัน ซึ่งพฤติกรรมของ variant ดังกล่าวจะเหมือนกับ struct ที่เป็น #[non_exhaustive] ทุกประการ
ขอแนะนำให้ใช้เครื่องมือนี้อย่างตั้งใจและระมัดระวัง: บ่อยครั้งที่การปรับเพิ่มเลขเวอร์ชันหลัก (Major Version) เมื่อมีการเพิ่มฟิลด์หรือ variant ใหม่ มักจะเป็นทางเลือกที่ชัดเจนและตรงไปตรงมากว่า #[non_exhaustive] อาจเหมาะสมอย่างยิ่งในสถานการณ์ที่คุณกำลังจำลองข้อมูลจากแหล่งภายนอกที่อาจมีการเปลี่ยนแปลงตลอดเวลาโดยไม่ขึ้นกับวงรอบการปล่อยไลบรารีของคุณ แต่มันไม่ได้ถูกออกแบบมาเพื่อเป็นเครื่องมืออเนกประสงค์สำหรับทุกกรณี
ข้อเสีย
การใช้ #[non_exhaustive] อาจทำให้โค้ดฝั่งผู้เรียกใช้งานมีความสะดวกสบาย (Ergonomics) ลดลงอย่างมาก โดยเฉพาะเมื่อถูกบีบให้ต้องเขียนโค้ดดักจับ enum variant ที่ตนเองไม่รู้จัก ดังนั้นจึงควรเลือกใช้เฉพาะเมื่อคุณมีความจำเป็นอย่างยิ่งที่จะต้องพัฒนาฟีเจอร์เพิ่มเติมโดยไม่ต้องการปรับเพิ่มเลขเวอร์ชันหลักเท่านั้น
เมื่อนำ #[non_exhaustive] ไปใช้กับ enum มันจะบังคับให้ผู้เรียกใช้งานทุกคนต้องเขียนกรณี Wildcard (_ => ...) เสมอ และหากในทางปฏิบัติไม่มีการกระทำใดที่สมเหตุสมผลสำหรับกรณีที่ไม่รู้จักนั้น ก็อาจนำไปสู่การเขียนโค้ดที่อึดอัด หรือโค้ดที่ไม่มีวันถูกเรียกใช้งานจริง และหากผู้ใช้เลือกที่จะใส่ panic!() ดักไว้ในกรณีนั้น การแจ้งเตือนเป็น Compile Error ตั้งแต่แรกก็น่าจะเป็นทางเลือกที่ดีกว่าอย่างแน่นอน