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

พอลิมอร์ฟิซึมผ่าน Deref

คำอธิบาย

การนำ trait Deref ไปใช้อย่างผิดวัตถุประสงค์เพื่อเลียนแบบการสืบทอดคุณสมบัติ (inheritance) ระหว่าง struct ด้วยเป้าหมายที่จะนำเมท็อดกลับมาใช้ซ้ำ

ตัวอย่าง

ในบางครั้ง เราอาจต้องการเลียนแบบแพตเทิร์นยอดนิยมจากภาษาเชิงวัตถุ (Object-Oriented) อย่าง Java ดังนี้:

class Foo {
    void m() { ... }
}

class Bar extends Foo {}

public static void main(String[] args) {
    Bar b = new Bar();
    b.m();
}

เราสามารถใช้แอนติแพตเทิร์นการทำพอลิมอร์ฟิซึมผ่าน Deref เพื่อจำลองพฤติกรรมดังกล่าวได้ดังนี้:

use std::ops::Deref;

struct Foo {}

impl Foo {
    fn m(&self) {
        //..
    }
}

struct Bar {
    f: Foo,
}

impl Deref for Bar {
    type Target = Foo;
    fn deref(&self) -> &Foo {
        &self.f
    }
}

fn main() {
    let b = Bar { f: Foo {} };
    b.m();
}

ในภาษา Rust นั้นไม่มีระบบการสืบทอด (inheritance) สำหรับ struct เราจึงใช้วิธีประกอบออบเจกต์ (composition) โดยนำอินสแตนซ์ของ Foo มาบรรจุไว้ใน Bar แทน (เนื่องจากฟิลด์ดังกล่าวเก็บค่าโดยตรง จึงถูกจัดเก็บบนหน่วยความจำแบบอินไลน์ ดังนั้นหากมีฟิลด์ข้อมูล เลย์เอาต์ในหน่วยความจำก็จะเหมือนกับเวอร์ชันของ Java (โดยประมาณ ซึ่งหากต้องการความแน่นอน 100% ควรระบุ #[repr(C)] กำกับไว้))

และเพื่อให้การเรียกเมท็อดทำงานได้ เราจึงอิมพลีเมนต์ Deref ให้กับ Bar โดยกำหนดให้ Foo เป็น target (พร้อมคืนค่าฟิลด์ Foo ที่ฝังอยู่ภายใน) นั่นหมายความว่าเมื่อใดก็ตามที่เราทำ dereference กับตัวแปร Bar (เช่น การใช้โอเปอเรเตอร์ *) เราจะได้ค่า Foo กลับมา ซึ่งถือว่าแปลกประหลาดมาก เพราะโดยปกติแล้วการ dereference จะเป็นการดึงค่า T ออกมาจากเรฟเฟอเรนซ์ของ T แต่ในกรณีนี้เรากลับได้ชนิดข้อมูลสองชนิดที่ไม่เกี่ยวข้องกันเลย อย่างไรก็ดี เนื่องจากโอเปอเรเตอร์จุด (dot operator .) มีกลไกการทำ dereference โดยอัตโนมัติ (implicit dereferencing) จึงส่งผลให้การเรียกใช้เมท็อดผ่านจุดจะวิ่งไปค้นหาเมท็อดของ Foo นอกเหนือจากเมท็อดของ Bar ด้วย

ข้อดี

ช่วยลดโค้ด boilerplate ลงได้เล็กน้อย ตัวอย่างเช่น ไม่ต้องเขียนส่งต่อเมท็อดเองแบบนี้:

impl Bar {
    fn m(&self) {
        self.f.m()
    }
}

ข้อเสีย

สิ่งที่สำคัญที่สุดคือ นี่เป็นแนวทางปฏิบัติที่สร้างความประหลาดใจ (surprising idiom) โปรแกรมเมอร์คนอื่นที่เข้ามาอ่านโค้ดนี้ในภายหลังจะคาดไม่ถึงว่าโค้ดทำงานเช่นนี้ เพราะเรากำลังใช้ trait Deref ผิดวัตถุประสงค์อย่างสิ้นเชิง แทนที่จะใช้ตามเจตนารมณ์ที่แท้จริง (และตามที่ระบุไว้ในคู่มือ) อีกทั้งกลไกการทำงานทั้งหมดยังเกิดขึ้นอย่างซ่อนเร้นโดยอัตโนมัติ (implicit)

นอกจากนี้ แพตเทิร์นนี้ไม่ได้สร้างความสัมพันธ์แบบชนิดข้อมูลย่อย (subtyping) ระหว่าง Foo กับ Bar เหมือนกับการสืบทอดใน Java หรือ C++ ยิ่งไปกว่านั้น trait ต่างๆ ที่ Foo อิมพลีเมนต์ไว้ ก็จะไม่ได้รับการอิมพลีเมนต์ให้กับ Bar โดยอัตโนมัติ ทำให้แพตเทิร์นนี้ทำงานร่วมกับการตรวจสอบขอบเขตชนิดข้อมูล (trait bounds) และการเขียนโปรแกรมเชิงเจเนอริก (generic programming) ได้แย่มาก

การใช้แพตเทิร์นนี้ยังให้ความหมายเชิงพฤติกรรมต่อ self ที่ผิดแผกไปจากภาษาเชิงวัตถุส่วนใหญ่อยู่เล็กน้อย เพราะตามปกติแล้ว self จะยังคงเป็นเรฟเฟอเรนซ์ที่ชี้ไปยังคลาสย่อย (sub-class) เสมอ แต่ในแพตเทิร์นนี้ self กลับกลายเป็น ‘คลาส’ ต้นทางที่เมท็อดนั้นถูกนิยามไว้แทน

ประการสุดท้าย แพตเทิร์นนี้รองรับเพียงการสืบทอดชั้นเดียว (single inheritance) เท่านั้น และไม่มีแนวคิดเรื่อง interface, การควบคุมระดับการเข้าถึงตามคลาส (class-based access control / privacy) หรือคุณสมบัติอื่นๆ ของการสืบทอดเลย จึงสร้างความสับสนและประหลาดใจให้กับโปรแกรมเมอร์ที่คุ้นเคยกับระบบ inheritance ใน Java เป็นอย่างมาก

เจาะลึกรายละเอียด

ในปัจจุบันยังไม่มีทางเลือกอื่นที่ดีเพียงทางเดียวอย่างสมบูรณ์ ทั้งนี้ขึ้นอยู่กับบริบทของงาน บางกรณีอาจเหมาะสมกว่าที่จะออกแบบใหม่โดยใช้ trait เข้ามาช่วย หรือเขียนเมท็อดครอบ (facade method) เพื่อส่งต่อการทำงานไปยัง Foo ด้วยตนเอง แม้ว่าทีมพัฒนา Rust จะมีความตั้งใจที่จะเพิ่มกลไกช่วยเรื่อง inheritance ในลักษณะนี้ แต่ก็คงต้องใช้เวลาอีกพอสมควรกว่าที่ฟีเจอร์ดังกล่าวจะเข้าสู่เวอร์ชัน stable สามารถอ่านรายละเอียดเพิ่มเติมได้จากบล็อก บทความเหล่านี้ และ RFC issue นี้

trait Deref ถูกออกแบบมาเพื่อการอิมพลีเมนต์สมาร์ตพอยน์เตอร์ที่กำหนดขึ้นเอง โดยมีเจตนารมณ์เพื่อแปลงจาก pointer-to-T ไปสู่ค่า T ไม่ใช่การแปลงข้ามชนิดข้อมูลที่ต่างกันอย่างสิ้นเชิง แต่น่าเสียดายที่ตัวนิยามของ trait ไม่สามารถบังคับกฎนี้ได้ (และอาจเป็นไปไม่ได้เลยที่จะบังคับในระดับภาษา)

ภาษา Rust พยายามรักษาสมดุลระหว่างกลไกแบบชัดเจน (explicit) และแบบอัตโนมัติ (implicit) โดยจะให้น้ำหนักกับการแปลงชนิดข้อมูลแบบชัดเจนเป็นหลัก การทำ dereference อัตโนมัติผ่านโอเปอเรเตอร์จุดเป็นกรณีพิเศษที่คำนึงถึงความสะดวกในการเขียนโค้ด (ergonomics) อย่างยิ่ง แต่เจตนารมณ์ดั้งเดิมก็จำกัดไว้เฉพาะการลดทอนระดับของการอ้างอิงทางอ้อม (indirection) เท่านั้น ไม่ใช่เพื่อแปลงชนิดข้อมูลใดๆ ตามอำเภอใจ

ดูเพิ่มเติม