API แบบอิงออบเจกต์ (Object-Based API)
คำอธิบาย
เมื่อออกแบบ API ในภาษา Rust เพื่อเปิดเผยให้ภาษาภายนอกเรียกใช้งาน มีหลักการออกแบบสำคัญบางประการที่สวนทางกับการออกแบบ API สำหรับภาษา Rust โดยทั่วไป:
- ชนิดข้อมูลที่ห่อหุ้มสถานะภายใน (Encapsulated type) ทั้งหมดควรมี Rust เป็น เจ้าของ (owned by Rust) มีผู้ใช้งานภายนอกเป็นผู้ ควบคุมวงจรชีวิต (managed by user) และต้องเป็นชนิดข้อมูลที่ ซ่อนรายละเอียดภายใน (opaque type)
- ชนิดข้อมูลสำหรับแลกเปลี่ยนข้อมูล (Transactional type) ทั้งหมดควรมีผู้ใช้งานภายนอกเป็น เจ้าของ (owned by user) และต้องเป็นชนิดข้อมูลที่ เปิดเผยโครงสร้างชัดเจน (transparent type)
- พฤติกรรมทั้งหมดของไลบรารีควรถูกสร้างเป็นฟังก์ชันที่ทำงานอยู่บนชนิดข้อมูลประเภท Encapsulated
- พฤติกรรมทั้งหมดของไลบรารีควรถูกจัดกลุ่มและห่อหุ้มเข้าเป็นชนิดข้อมูลโดยพิจารณาจาก แหล่งกำเนิดและช่วงชีวิต (provenance / lifetime) เป็นหลัก ไม่ใช่พิจารณาตามโครงสร้างข้อมูล
แรงจูงใจ
ภาษา Rust รองรับการทำ FFI ร่วมกับภาษาภายนอกมาในตัว โดยเปิดโอกาสให้ผู้พัฒนา crate สามารถเปิดเผย API ที่เข้ากันได้กับภาษา C ผ่าน ABI มาตรฐานต่างๆ (แม้ว่ารายละเอียดส่วนนั้นจะไม่ใช่ประเด็นหลักของแนวปฏิบัตินี้ก็ตาม)
FFI ของ Rust ที่ได้รับการออกแบบมาอย่างยอดเยี่ยมจะต้องปฏิบัติตามหลักการออกแบบ API ของภาษา C ได้อย่างแนบเนียน ในขณะเดียวกันก็ต้องกระทบต่อแบบแผนความปลอดภัยฝั่ง Rust ให้น้อยที่สุด โดยเป้าหมายหลัก 3 ประการสำหรับ API ภาษาภายนอก ได้แก่:
- ใช้งานง่ายและเป็นธรรมชาติในภาษาปลายทาง
- ลดการบังคับใช้โค้ด unsafe ภายในฝั่ง Rust ให้เหลือน้อยที่สุดเท่าที่จะทำได้
- ควบคุมและลดความเสี่ยงต่อปัญหาความไม่ปลอดภัยในหน่วยความจำ ตลอดจนการเกิด Undefined Behavior (UB) ใน Rust ให้น้อยที่สุด
โค้ดฝั่ง Rust จำเป็นต้องไว้วางใจในความปลอดภัยด้านหน่วยความจำของภาษาภายนอกในระดับหนึ่ง อย่างไรก็ตาม โค้ด unsafe ทุกบรรทัดในฝั่ง Rust ล้วนเป็นช่องทางให้เกิดบั๊ก หรืออาจทวีความรุนแรงของ UB ให้ลุกลามได้
ตัวอย่างเช่น หากพอยน์เตอร์มีที่มาที่ไม่ถูกต้อง อาจก่อให้เกิดข้อผิดพลาด segmentation fault (segfault) จากการพยายามเข้าถึงตำแหน่งหน่วยความจำที่ไม่อนุญาต แต่หากพอยน์เตอร์นั้นถูกจัดการผ่านบล็อกคำสั่ง unsafe มันอาจลุกลามกลายเป็นการทำลายโครงสร้างฮีป (heap corruption) ได้อย่างสมบูรณ์
การออกแบบ API ในสไตล์แบบอิงออบเจกต์ (Object-Based API) ช่วยให้เราสามารถเขียนโค้ดตัวเชื่อม (shim) ที่มีคุณสมบัติด้านความปลอดภัยของหน่วยความจำที่ดีเยี่ยม และสร้างเส้นแบ่งขอบเขตที่ชัดเจนระหว่างส่วนที่ปลอดภัย (safe) กับส่วนที่เป็นอันตราย (unsafe)
ตัวอย่างโค้ด
มาตรฐาน POSIX ได้นิยาม API สำหรับการเข้าถึงฐานข้อมูลแบบไฟล์ที่รู้จักกันในชื่อ DBM ซึ่งถือเป็นตัวอย่างชั้นยอดของ API ในสไตล์ “อิงออบเจกต์”
โค้ดด้านล่างนี้คือนิยามในภาษา C ซึ่งอ่านเข้าใจได้ง่ายสำหรับผู้ที่คุ้นเคยกับ FFI โดยคำอธิบายด้านล่างจะช่วยชี้แจงประเด็นที่ละเอียดอ่อนให้เห็นภาพชัดเจนขึ้น:
struct DBM;
typedef struct { void *dptr, size_t dsize } datum;
int dbm_clearerr(DBM *);
void dbm_close(DBM *);
int dbm_delete(DBM *, datum);
int dbm_error(DBM *);
datum dbm_fetch(DBM *, datum);
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
DBM *dbm_open(const char *, int, mode_t);
int dbm_store(DBM *, datum, datum, int);
API ชุดนี้นิยามชนิดข้อมูลไว้ 2 ชนิด ได้แก่ DBM และ datum
ชนิดข้อมูล DBM คือตัวแทนของชนิดข้อมูลแบบ “Encapsulated” ตามที่กล่าวไว้ข้างต้น โดยถูกออกแบบมาเพื่อเก็บรักษาสถานะภายใน และทำหน้าที่เป็นประตูทางเข้าหลักสำหรับเรียกใช้พฤติกรรมต่างๆ ของไลบรารี
ตัวโครงสร้างของมันจะถูกซ่อนไว้โดยสมบูรณ์จากผู้ใช้งาน (completely opaque) ผู้ใช้ภายนอกจะไม่สามารถสร้างอินสแตนซ์ของ DBM ขึ้นมาเองได้เนื่องจากไม่ทราบขนาดหรือเลย์เอาต์หน่วยความจำภายใน ผู้ใช้ต้องเรียกใช้งานผ่านฟังก์ชัน dbm_open ซึ่งจะส่งคืนค่ากลับมาเป็น พอยน์เตอร์ที่ชี้ไปยังออบเจกต์ เท่านั้น
สิ่งนี้หมายความว่า DBM ทั้งหมดจะถูก “เป็นเจ้าของ” โดยไลบรารีในมุมมองของ Rust โดยสถานะภายในที่ไม่ทราบขนาดจะถูกจัดเก็บไว้ในหน่วยความจำที่ควบคุมโดยไลบรารี ไม่ใช่โดยผู้ใช้ ผู้ใช้ภายนอกทำได้เพียงจัดการวงจรชีวิตของมันผ่านคำสั่ง open และ close และเรียกใช้งานผ่านฟังก์ชันอื่นๆ ของไลบรารีเท่านั้น
ส่วนชนิดข้อมูล datum คือตัวแทนของชนิดข้อมูลแบบ “Transactional” ที่ออกแบบมาเพื่ออำนวยความสะดวกในการแลกเปลี่ยนข้อมูลระหว่างไลบรารีกับผู้ใช้ภายนอก
ตัวฐานข้อมูลถูกออกแบบมาเพื่อจัดเก็บ “ข้อมูลไร้โครงสร้างตายตัว” (unstructured data) โดยไม่มีการกำหนดความยาวหรือความหมายไว้ล่วงหน้า ผลลัพธ์คือ datum มีค่าเทียบเท่ากับสไลซ์ (&[u8]) ของ Rust ในโลกของภาษา C กล่าวคือ ประกอบด้วยกลุ่มของไบต์และตัวนับจำนวนไบต์ ความแตกต่างหลักคือไม่มีข้อมูลระบุชนิด ซึ่งแสดงผ่านการใช้ void
พึงระลึกไว้ว่าไฟล์เฮดเดอร์นี้ถูกเขียนขึ้นจากมุมมองของฝั่งไลบรารี ผู้ใช้อาจมีชนิดข้อมูลเฉพาะที่ตนเองใช้งานอยู่ซึ่งมีขนาดที่แน่นอน แต่ไลบรารีไม่จำเป็นต้องรับรู้ และตามกฎการแปลงชนิดข้อมูลของภาษา C พอยน์เตอร์ใดๆ ก็ตามสามารถถูกแปลงชนิดไปเป็น void ได้เสมอ
ดังที่กล่าวไว้ในตอนต้น ชนิดข้อมูลนี้ถือว่า เปิดเผยโครงสร้างชัดเจน (transparent) ต่อผู้ใช้ และนอกจากนี้ ชนิดข้อมูลนี้ยัง เป็นเจ้าของ โดยผู้ใช้ภายนอกอีกด้วย ซึ่งส่งผลกระทบที่ละเอียดอ่อนอย่างยิ่งอันเนื่องมาจากพอยน์เตอร์ที่อยู่ภายใน คำถามสำคัญคือ ใครเป็นเจ้าของหน่วยความจำที่พอยน์เตอร์ตัวนั้นชี้ไป?
คำตอบที่มอบความปลอดภัยสูงสุดต่อหน่วยความจำคือ “ผู้ใช้ภายนอก” ทว่าในบางกรณี เช่น การดึงค่าข้อมูลออกมาจากฐานข้อมูล ผู้ใช้จะไม่ทราบขนาดล่วงหน้าจึงไม่สามารถจัดสรรหน่วยความจำล่วงหน้าได้อย่างถูกต้อง ในสถานการณ์เช่นนี้ โค้ดฝั่งไลบรารีจะเป็นผู้จัดสรรหน่วยความจำบนฮีปที่ผู้ใช้สามารถเข้าถึงได้ (เช่น การใช้ malloc และ free ของภาษา C) จากนั้นจึงทำการ ถ่ายโอนความเป็นเจ้าของ (transfer ownership) ในความหมายของ Rust ให้แก่ผู้ใช้
สิ่งเหล่านี้สะท้อนความหมายที่แท้จริงของพอยน์เตอร์ในภาษา C ซึ่งมีความหมายเช่นเดียวกับใน Rust นั่นคือ “ช่วงชีวิตที่ผู้ใช้ต้องควบคุมดูแลเอง (user-defined lifetime)” ผู้ใช้งานไลบรารีจำเป็นต้องอ่านเอกสารประกอบอย่างละเอียดเพื่อใช้งานได้อย่างถูกต้อง ถึงกระนั้นก็ตาม การตัดสินใจออกแบบ API ที่ดีจะสามารถช่วยบรรเทาความผิดพลาดของผู้ใช้ลงได้ และกุญแจสำคัญที่สุดคือ การถ่ายโอนความเป็นเจ้าของให้กับทุกสิ่งที่เป็นชนิดข้อมูลแบบ transparent
ข้อดี
แนวทางนี้ช่วยลดภาระและข้อกำหนดด้านความปลอดภัยของหน่วยความจำที่ผู้ใช้ภายนอกต้องคอยระวัง ให้เหลือเพียงข้อกำหนดจำนวนน้อย:
- ห้ามเรียกฟังก์ชันใดๆ ด้วยพอยน์เตอร์ที่ไม่ได้ส่งคืนมาจาก
dbm_open(เพื่อป้องกันการเข้าถึงหน่วยความจำที่ไม่ถูกต้องหรือข้อมูลเสียหาย) - ห้ามเรียกฟังก์ชันใดๆ บนพอยน์เตอร์หลังจากสั่งปิดด้วย
closeไปแล้ว (use after free) - ค่า
dptrบนdatumใดๆ จะต้องเป็นNULLหรือชี้ไปยังช่วงหน่วยความจำที่ถูกต้องและมีความยาวตรงตามที่ระบุไว้เสมอ
นอกจากนี้ การออกแบบเช่นนี้ยังช่วยหลีกเลี่ยงปัญหาเรื่อง pointer provenance (ประวัติที่มาและความถูกต้องของพอยน์เตอร์) และ pointer aliasing ได้อย่างมาก เพื่อให้เห็นภาพชัดเจน ลองพิจารณาทางเลือกอื่นสำหรับการวนซ้ำคีย์ข้อมูล (key iteration):
ภาษา Rust มีชื่อเสียงโดดเด่นในเรื่อง Iterator เมื่อต้องพัฒนา iterator ขึ้นมาสักตัว โปรแกรมเมอร์จะสร้างชนิดข้อมูลแยกต่างหากที่มี lifetime ผูกติดกับเจ้าของข้อมูล และนำ Iterator trait ไปอิมพลีเมนต์
นี่คือรูปแบบการวนซ้ำในภาษา Rust สำหรับ DBM:
struct Dbm { ... }
impl Dbm {
/* ... */
pub fn keys<'it>(&'it self) -> DbmKeysIter<'it> { ... }
/* ... */
}
struct DbmKeysIter<'it> {
owner: &'it Dbm,
}
impl<'it> Iterator for DbmKeysIter<'it> { ... }
โค้ดชุดนี้มีความสะอาด เป็นสำนวนที่ถูกต้อง และปลอดภัยอย่างยิ่งด้วยการรับประกันของ Rust ทว่าลองพิจารณาดูว่าหากเราแปล API นี้ไปเป็น FFI ในภาษา C แบบตรงตัว มันจะมีหน้าตาอย่างไร:
#[no_mangle]
pub extern "C" fn dbm_iter_new(owner: *const Dbm) -> *mut DbmKeysIter {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
iter: *mut DbmKeysIter,
key_out: *const datum
) -> libc::c_int {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
API ชุดนี้ได้ทำลายข้อมูลสำคัญชิ้นหนึ่งไป นั่นคือ: ช่วงชีวิต (lifetime) ของ iterator จะต้องไม่คงอยู่ยาวนานกว่าช่วงชีวิตของออบเจกต์ Dbm ที่เป็นเจ้าของมัน ผู้ใช้งานไลบรารีในภาษา C อาจเขียนโค้ดในลักษณะที่ทำให้อิเทอเรเตอร์มีอายุยืนยาวกว่าข้อมูลที่มันกำลังวนซ้ำอยู่ ส่งผลให้เกิดการอ่านหน่วยความจำที่ไม่ได้จัดสรรหรือถูกคืนไปแล้ว
ตัวอย่างโค้ดในภาษา C ด้านล่างนี้มีบั๊กซ่อนอยู่:
int count_key_sizes(DBM *db) {
// DO NOT USE THIS FUNCTION. IT HAS A SUBTLE BUT SERIOUS BUG!
datum key;
int len = 0;
if (!dbm_iter_new(db)) {
dbm_close(db);
return -1;
}
int l;
while ((l = dbm_iter_next(owner, &key)) >= 0) { // an error is indicated by -1
free(key.dptr);
len += key.dsize;
if (l == 0) { // end of the iterator
dbm_close(owner);
}
}
if l >= 0 {
return -1;
} else {
return len;
}
}
นี่คือบั๊กสุดคลาสสิก สิ่งที่เกิดขึ้นเมื่อ iterator ส่งสัญญาณแจ้งสิ้นสุดการวนซ้ำคือ:
- เงื่อนไขของลูปกำหนดให้
lมีค่าเป็น 0 และโปรแกรมเข้าทำงานในลูปเนื่องจาก0 >= 0 - มีการบวกค่าความยาวเพิ่มขึ้น (ในกรณีนี้คือบวกด้วย 0)
- เงื่อนไข
ifเป็นจริง ส่งผลให้ฐานข้อมูลถูกปิดลง ทั้งที่ความจริงควรมีคำสั่งbreakตรงจุดนี้ - เงื่อนไขของลูปวนกลับมาตรวจสอบอีกครั้ง ทำให้เกิดการเรียกใช้
nextบนออบเจกต์ฐานข้อมูลที่ถูกปิดไปแล้ว!
ความร้ายกาจที่สุดของบั๊กประเภทนี้คือ หากโค้ดฝั่ง Rust เขียนขึ้นมาอย่างระมัดระวัง โค้ดชุดนี้จะทำงานผ่านได้ในเกือบทุกครั้ง! ตราบใดที่หน่วยความจำของ Dbm ยังไม่ถูกจัดสรรใหม่ทันที การตรวจสอบภายในจะตรวจพบข้อผิดพลาดและส่งค่า -1 กลับมา แต่ในบางจังหวะ มันอาจนำไปสู่ segmentation fault หรือร้ายแรงที่สุดคือการทำลายข้อมูลในหน่วยความจำโดยไม่รู้ตัว!
ความผิดพลาดเหล่านี้เป็นสิ่งที่ภาษา Rust ไม่สามารถช่วยป้องกันได้เลย เพราะในมุมมองของ Rust มันได้วางออบเจกต์เหล่านั้นไว้บนฮีป ส่งพอยน์เตอร์ออกไป และสละการควบคุมช่วงชีวิตของข้อมูลไปแล้ว โค้ดภาษา C จึงต้องดูแลตนเองอย่างเคร่งครัด
โปรแกรมเมอร์จึงจำเป็นต้องทำความเข้าใจเอกสาร API อย่างถ่องแท้ และการออกแบบ API ที่ชาญฉลาดจะช่วยตัดความเสี่ยงเหล่านี้ตั้งแต่ต้นลม API มาตรฐาน POSIX ของ DBM แก้ปัญหานี้ด้วยการ รวมความเป็นเจ้าของ (consolidating ownership) ของ iterator เข้าไว้กับตัวออบเจกต์หลักโดยตรง:
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
ด้วยวิธีนี้ ช่วงชีวิตของข้อมูลทั้งหมดจึงถูกผูกโยงเป็นหนึ่งเดียว และป้องกันปัญหาความไม่ปลอดภัยดังกล่าวได้อย่างสมบูรณ์
ข้อเสีย
อย่างไรก็ดี การตัดสินใจออกแบบเช่นนี้ก็มีข้อแลกเปลี่ยนและข้อจำกัดบางประการที่ควรพิจารณา:
ประการแรก ตัว API จะมีความยืดหยุ่นในการแสดงออก (expressiveness) ลดลง สำหรับ POSIX DBM จะมี iterator ได้เพียงตัวเดียวต่อหนึ่งออบเจกต์ และทุกการเรียกจะส่งผลเปลี่ยนแปลงสถานะภายในของออบเจกต์นั้น ซึ่งจำกัดกว่า iterator ทั่วไปในภาษาโปรแกรมส่วนใหญ่ สำหรับระบบที่มีความสัมพันธ์ของออบเจกต์ซับซ้อนและไม่ได้เรียงลำดับชั้นอย่างชัดเจน ข้อจำกัดนี้อาจสร้างภาระมากกว่าความปลอดภัยที่ได้รับ
ประการที่สอง การออกแบบระบบเช่นนี้อาจต้องใช้เวลาและความพยายามในการวางโครงสร้าง API สูงขึ้น ทั้งนี้ จุดออกแบบที่สำคัญสามารถศึกษาเพิ่มเติมได้จากแพตเทิร์นที่เกี่ยวข้อง:
-
การรวมชนิดข้อมูลเข้าสู่ตัวห่อหุ้ม (Type Consolidation into Wrappers) — จัดกลุ่มชนิดข้อมูล Rust หลายตัวเข้าด้วยกันให้อยู่ในรูปของ “ออบเจกต์” ที่ซ่อนรายละเอียดภายใน
-
การส่งต่อรหัสข้อผิดพลาดใน FFI — อธิบายการจัดการข้อผิดพลาดด้วยรหัสจำนวนเต็มและค่าส่งกลับพิเศษ (เช่น พอยน์เตอร์
NULL) -
การรับสตริงจากภาษาภายนอก — แนวทางการรับสตริงโดยใช้โค้ด unsafe ให้น้อยที่สุด ซึ่งทำได้ง่ายและปลอดภัยกว่า การส่งสตริงไปยังฟังก์ชัน FFI
อย่างไรก็ตาม ไม่ใช่ทุก API ที่จะสามารถนำแนวทางนี้ไปปรับใช้ได้ โปรแกรมเมอร์จึงต้องใช้ดุลยพินิจที่ดีที่สุดโดยคำนึงถึงบริบทและกลุ่มผู้ใช้งานเป็นสำคัญ