Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API แบบอิงออบเจกต์ (Object-Based API)

คำอธิบาย

เมื่อออกแบบ API ในภาษา Rust เพื่อเปิดเผยให้ภาษาภายนอกเรียกใช้งาน มีหลักการออกแบบสำคัญบางประการที่สวนทางกับการออกแบบ API สำหรับภาษา Rust โดยทั่วไป:

  1. ชนิดข้อมูลที่ห่อหุ้มสถานะภายใน (Encapsulated type) ทั้งหมดควรมี Rust เป็น เจ้าของ (owned by Rust) มีผู้ใช้งานภายนอกเป็นผู้ ควบคุมวงจรชีวิต (managed by user) และต้องเป็นชนิดข้อมูลที่ ซ่อนรายละเอียดภายใน (opaque type)
  2. ชนิดข้อมูลสำหรับแลกเปลี่ยนข้อมูล (Transactional type) ทั้งหมดควรมีผู้ใช้งานภายนอกเป็น เจ้าของ (owned by user) และต้องเป็นชนิดข้อมูลที่ เปิดเผยโครงสร้างชัดเจน (transparent type)
  3. พฤติกรรมทั้งหมดของไลบรารีควรถูกสร้างเป็นฟังก์ชันที่ทำงานอยู่บนชนิดข้อมูลประเภท Encapsulated
  4. พฤติกรรมทั้งหมดของไลบรารีควรถูกจัดกลุ่มและห่อหุ้มเข้าเป็นชนิดข้อมูลโดยพิจารณาจาก แหล่งกำเนิดและช่วงชีวิต (provenance / lifetime) เป็นหลัก ไม่ใช่พิจารณาตามโครงสร้างข้อมูล

แรงจูงใจ

ภาษา Rust รองรับการทำ FFI ร่วมกับภาษาภายนอกมาในตัว โดยเปิดโอกาสให้ผู้พัฒนา crate สามารถเปิดเผย API ที่เข้ากันได้กับภาษา C ผ่าน ABI มาตรฐานต่างๆ (แม้ว่ารายละเอียดส่วนนั้นจะไม่ใช่ประเด็นหลักของแนวปฏิบัตินี้ก็ตาม)

FFI ของ Rust ที่ได้รับการออกแบบมาอย่างยอดเยี่ยมจะต้องปฏิบัติตามหลักการออกแบบ API ของภาษา C ได้อย่างแนบเนียน ในขณะเดียวกันก็ต้องกระทบต่อแบบแผนความปลอดภัยฝั่ง Rust ให้น้อยที่สุด โดยเป้าหมายหลัก 3 ประการสำหรับ API ภาษาภายนอก ได้แก่:

  1. ใช้งานง่ายและเป็นธรรมชาติในภาษาปลายทาง
  2. ลดการบังคับใช้โค้ด unsafe ภายในฝั่ง Rust ให้เหลือน้อยที่สุดเท่าที่จะทำได้
  3. ควบคุมและลดความเสี่ยงต่อปัญหาความไม่ปลอดภัยในหน่วยความจำ ตลอดจนการเกิด 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

ข้อดี

แนวทางนี้ช่วยลดภาระและข้อกำหนดด้านความปลอดภัยของหน่วยความจำที่ผู้ใช้ภายนอกต้องคอยระวัง ให้เหลือเพียงข้อกำหนดจำนวนน้อย:

  1. ห้ามเรียกฟังก์ชันใดๆ ด้วยพอยน์เตอร์ที่ไม่ได้ส่งคืนมาจาก dbm_open (เพื่อป้องกันการเข้าถึงหน่วยความจำที่ไม่ถูกต้องหรือข้อมูลเสียหาย)
  2. ห้ามเรียกฟังก์ชันใดๆ บนพอยน์เตอร์หลังจากสั่งปิดด้วย close ไปแล้ว (use after free)
  3. ค่า 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 ส่งสัญญาณแจ้งสิ้นสุดการวนซ้ำคือ:

  1. เงื่อนไขของลูปกำหนดให้ l มีค่าเป็น 0 และโปรแกรมเข้าทำงานในลูปเนื่องจาก 0 >= 0
  2. มีการบวกค่าความยาวเพิ่มขึ้น (ในกรณีนี้คือบวกด้วย 0)
  3. เงื่อนไข if เป็นจริง ส่งผลให้ฐานข้อมูลถูกปิดลง ทั้งที่ความจริงควรมีคำสั่ง break ตรงจุดนี้
  4. เงื่อนไขของลูปวนกลับมาตรวจสอบอีกครั้ง ทำให้เกิดการเรียกใช้ 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 สูงขึ้น ทั้งนี้ จุดออกแบบที่สำคัญสามารถศึกษาเพิ่มเติมได้จากแพตเทิร์นที่เกี่ยวข้อง:

อย่างไรก็ตาม ไม่ใช่ทุก API ที่จะสามารถนำแนวทางนี้ไปปรับใช้ได้ โปรแกรมเมอร์จึงต้องใช้ดุลยพินิจที่ดีที่สุดโดยคำนึงถึงบริบทและกลุ่มผู้ใช้งานเป็นสำคัญ