การรวมชนิดข้อมูลเข้าสู่ตัวห่อหุ้ม (Type Consolidation into Wrappers)
คำอธิบาย
แพตเทิร์นนี้ได้รับการออกแบบมาเพื่อให้สามารถจัดการกับชนิดข้อมูลที่เกี่ยวข้องกันหลายชนิดได้อย่างราบรื่นและงดงาม ขณะเดียวกันก็ช่วยลดพื้นที่เสี่ยงของความไม่ปลอดภัยด้านหน่วยความจำ (memory unsafety surface area) ให้เหลือน้อยที่สุด
หนึ่งในเสาหลักสำคัญของกฎการตั้งชื่ออ้างอิงซ้ำ (aliasing rules) ในภาษา Rust คือระบบช่วงชีวิต (lifetimes) ซึ่งช่วยรับประกันว่ารูปแบบการเข้าถึงข้อมูลระหว่างชนิดข้อมูลต่างๆ จะมีความปลอดภัยต่อหน่วยความจำอย่างแท้จริง รวมถึงความปลอดภัยจากการแย่งใช้ข้อมูลพร้อมกัน (data race safety)
อย่างไรก็ตาม เมื่อชนิดข้อมูลของ Rust ถูกส่งออกไปให้ภาษาภายนอกใช้งาน ชนิดข้อมูลเหล่านั้นมักจะถูกแปลงสภาพให้อยู่ในรูปของพอยน์เตอร์ ในโลกของ Rust พอยน์เตอร์มีความหมายว่า “ผู้ใช้งานภายนอกต้องเป็นผู้รับผิดชอบช่วงชีวิตของข้อมูลที่ถูกชี้ด้วยตนเอง” และเป็นภาระหน้าที่ของพวกเขาในการหลีกเลี่ยงความไม่ปลอดภัยในหน่วยความจำ
ดังนั้น เราจึงจำเป็นต้องวางใจในโค้ดของผู้ใช้ระดับหนึ่ง โดยเฉพาะประเด็นเรื่อง use-after-free (การเข้าถึงหน่วยความจำที่ถูกคืนไปแล้ว) ซึ่งคอมไพเลอร์ของ Rust ไม่สามารถเข้าไปช่วยตรวจสอบให้ได้ อย่างไรก็ดี การออกแบบ API ในบางลักษณะจะสร้างภาระในการระมัดระวังให้กับโค้ดฝั่งภาษาภายนอกสูงกว่าแบบอื่นๆ
รูปแบบ API ที่มีความเสี่ยงต่ำที่สุดคือ “ตัวห่อหุ้มแบบรวมศูนย์ (Consolidated Wrapper)” ซึ่งการดำเนินการและการมีปฏิสัมพันธ์ทั้งหมดที่อาจเกิดขึ้นกับออบเจกต์ จะถูกรวบเข้าไว้ใน “ชนิดข้อมูลตัวห่อหุ้ม” เพียงตัวเดียว โดยยังคงรักษาความสะอาดเรียบร้อยของ API ฝั่ง Rust เอาไว้ได้
ตัวอย่างโค้ด
เพื่อให้เข้าใจได้ชัดเจนยิ่งขึ้น ลองพิจารณาตัวอย่างคลาสสิกของ API ที่ต้องส่งออกไปภายนอก นั่นคือ: การวนซ้ำเพื่ออ่านข้อมูลในคอลเลกชัน
รูปแบบของ API มีลักษณะดังนี้:
- เริ่มต้น iterator ด้วยคำสั่ง
first_key - ทุกครั้งที่เรียก
next_keyตัว iterator จะขยับเลื่อนไปยังตำแหน่งถัดไป - หากเรียก
next_keyในขณะที่ iterator อยู่ที่จุดสิ้นสุดแล้ว จะไม่มีอะไรเกิดขึ้น - ตามที่กล่าวไว้ข้างต้น ตัว iterator จะถูก “ห่อหุ้มรวมเข้าไปใน” คอลเลกชัน (ซึ่งต่างจาก API ปกติของภาษา Rust)
หากตัว iterator มีการอิมพลีเมนต์เมท็อด nth() อย่างมีประสิทธิภาพ เราก็สามารถทำให้ iterator คงอยู่เพียงชั่วคราวตลอดช่วงเวลาของการเรียกใช้ฟังก์ชันแต่ละครั้งได้:
struct MySetWrapper {
myset: MySet,
iter_next: usize,
}
impl MySetWrapper {
pub fn first_key(&mut self) -> Option<&Key> {
self.iter_next = 0;
self.next_key()
}
pub fn next_key(&mut self) -> Option<&Key> {
if let Some(next) = self.myset.keys().nth(self.iter_next) {
self.iter_next += 1;
Some(next)
} else {
None
}
}
}
ผลลัพธ์ที่ได้คือ ตัวห่อหุ้มมีความเรียบง่าย ชัดเจน และปราศจากโค้ด unsafe โดยสิ้นเชิง
ข้อดี
แนวทางนี้ช่วยให้ API มีความปลอดภัยต่อการเรียกใช้งานสูงมาก และหลีกเลี่ยงปัญหาเรื่องความขัดแย้งของช่วงชีวิต (lifetimes) ข้ามชนิดข้อมูล สามารถดูรายละเอียดเพิ่มเติมเกี่ยวกับข้อดีและหลุมพรางที่แนวทางนี้ช่วยป้องกันได้ใน API แบบอิงออบเจกต์
ข้อเสีย
ในทางปฏิบัติ การห่อหุ้มชนิดข้อมูลมักเป็นเรื่องที่ซับซ้อนและท้าทาย และในบางครั้ง การยอมประนีประนอมในฝั่ง API ของ Rust อาจช่วยให้ทุกอย่างง่ายขึ้น
ตัวอย่างเช่น หาก iterator ไม่ได้อิมพลีเมนต์ nth() อย่างมีประสิทธิภาพ การเพิ่มตรรกะเฉพาะเพื่อให้ออบเจกต์จัดการการวนซ้ำภายในตนเอง หรือการปรับโครงสร้างเพื่อรองรับรูปแบบการเข้าถึงข้อมูลเฉพาะสำหรับ Foreign Function API ย่อมเป็นเรื่องที่คุ้มค่ากว่า
ความพยายามในการห่อหุ้ม Iterator (และล้มเหลว)
หากต้องการห่อหุ้ม iterator ทั่วไปเข้าไปใน API ให้ถูกต้องสมบูรณ์ ตัวห่อหุ้มจะต้องทำสิ่งที่โค้ดภาษา C ทำ นั่นคือ: ลบล้างข้อมูลช่วงชีวิต (lifetime) ของ iterator ออก แล้วจัดการควบคุมหน่วยความจำด้วยตนเอง
หากจะพูดอย่างตรงไปตรงมา การทำเช่นนี้ถือว่า ยากอย่างยิ่งยวด
นี่คือตัวอย่างภาพสะท้อนของหลุมพรางอันตรายเพียง หนึ่ง ประการ:
เวอร์ชันแรกของ MySetWrapper อาจมีหน้าตาดังนี้:
struct MySetWrapper {
myset: MySet,
iter_next: usize,
// created from a transmuted Box<KeysIter + 'self>
iterator: Option<NonNull<KeysIter<'static>>>,
}
การนำ transmute มาใช้เพื่อยืด lifetime และการใช้ raw pointer เพื่อซ่อนข้อมูลนั้น เป็นโค้ดที่ไม่น่าดูอยู่แล้ว แต่สิ่งที่เลวร้ายยิ่งกว่าคือ: การกระทำอื่นๆ ใดๆ ก็ตามบนโครงสร้างนี้สามารถก่อให้เกิด Undefined Behavior (UB) ใน Rust ได้ทันที
ลองพิจารณาว่า MySet ที่อยู่ภายในตัวห่อหุ้มอาจถูกฟังก์ชันอื่นเรียกเข้ามาแก้ไขข้อมูลระหว่างที่มีการวนซ้ำอยู่ เช่น การบันทึกค่าใหม่ลงในคีย์ที่กำลังถูกวนซ้ำ ซึ่งตัว API ไม่ได้มีกลไกป้องกันเรื่องนี้ และในความเป็นจริง ไลบรารีในภาษา C จำนวนมากก็คาดหวังให้ทำเช่นนั้นได้
การเขียนฟังก์ชัน myset_store แบบตรงไปตรงมาอาจมีลักษณะดังนี้:
pub mod unsafe_module {
// other module content
pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
// DO NOT USE THIS CODE. IT IS UNSAFE TO DEMONSTRATE A PROBLEM.
let myset: &mut MySet = unsafe {
// SAFETY: whoops, UB occurs in here!
&mut (*myset).myset
};
/* ...check and cast key and value data... */
match myset.store(casted_key, casted_value) {
Ok(_) => 0,
Err(e) => e.into(),
}
}
}
หากตัว iterator ยังคงมีตัวตนอยู่ในขณะที่ฟังก์ชันนี้ถูกเรียกใช้งาน เท่ากับว่าเราได้ละเมิดกฎ aliasing rules ของ Rust ทันที เพราะตามกฎของ Rust การอ้างอิงแบบเปลี่ยนแปลงค่าได้ (&mut) จะต้องมีสิทธิ์เข้าถึงออบเจกต์แต่เพียง เอกสิทธิ์ผู้เดียว (exclusive access) เท่านั้น ทว่าหาก iterator ยังคงอยู่ การเข้าถึงย่อมไม่ใช่เอกสิทธิ์อีกต่อไป และนำไปสู่ undefined behaviour ทันที! 1
เพื่อหลีกเลี่ยงปัญหานี้ เราจำเป็นต้องมีกลไกในการรับประกันว่าการอ้างอิงแบบ &mut นั้นมีเอกสิทธิ์อย่างแท้จริง ซึ่งหมายถึงการต้องลบล้าง shared reference ของ iterator ทิ้งไปก่อน แล้วค่อยสร้างขึ้นมาใหม่หลังจากเสร็จสิ้นการทำงาน ซึ่งในกรณีส่วนใหญ่ วิธีการนี้จะมีประสิทธิภาพด้อยกว่าโค้ดภาษา C อย่างเห็นได้ชัด
หลายคนอาจสงสัยว่า แล้วทำไมภาษา C ถึงทำเรื่องนี้ได้อย่างมีประสิทธิภาพมากกว่า? คำตอบแบบตรงไปตรงมาก็คือ ภาษา C ใช้วิธี “โกง” — กฎ aliasing ของ Rust คืออุปสรรคในเรื่องนี้ และภาษา C เพียงแค่เพิกเฉยต่อกฎเหล่านั้นสำหรับพอยน์เตอร์ของตน สิ่งที่ต้องแลกมาคือ เรามักจะเห็นโค้ดที่ระบุไว้ในคู่มือว่า “ไม่ปลอดภัยต่อการทำงานแบบหลายเธรด (not thread safe)” ภายใต้สถานการณ์บางอย่างหรือทั้งหมด ในความเป็นจริง ไลบรารี GNU C ถึงกับมีชุดคำศัพท์เฉพาะที่อุทิศให้กับพฤติกรรมการทำงานแบบพร้อมกัน (concurrent behavior) โดยเฉพาะ!
สำหรับภาษา Rust ยึดมั่นในการสร้างความปลอดภัยด้านหน่วยความจำตลอดเวลา ทั้งเพื่อความถูกต้องปลอดภัยและเพื่อเปิดทางให้คอมไพเลอร์สามารถทำการปรับแต่งโค้ด (optimization) ขั้นสูงที่ภาษา C ไม่สามารถทำได้ การถูกปิดกั้นไม่ให้ใช้ทางลัดอันตรายบางอย่างจึงเป็นราคาที่โปรแกรมเมอร์ Rust จำเป็นต้องจ่าย
-
สำหรับโปรแกรมเมอร์สาย C ที่อาจกำลังสงสัย: ตัว iterator ไม่จำเป็นต้องถูกอ่านค่า ในระหว่าง ที่โค้ดส่วนนี้ทำงานเพื่อก่อให้เกิด UB เพราะกฎการเข้าถึงแบบเอกสิทธิ์จะเปิดทางให้คอมไพเลอร์ทำ optimization (เช่น การนำค่าไปพักไว้บนสแตก หรือการสลับลำดับคำสั่งเพื่อเพิ่มความเร็ว) ซึ่งการสังเกตข้อมูลผ่าน shared reference ของ iterator ที่ไม่สอดคล้องกันอาจเกิดขึ้นได้ ณ เวลาใดๆ ก็ตามหลังจาก การอ้างอิงแบบ
&mutถูกสร้างขึ้น ↩