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

คุณลักษณะของภาษาเชิงวัตถุ

ไม่มีฉันทามติในชุมชนการเขียนโปรแกรมเกี่ยวกับคุณสมบัติที่ภาษาหนึ่งต้องมีเพื่อจะถือว่าเป็นภาษาเชิงวัตถุ Rust ได้รับอิทธิพลจากพาราดิสม์ (paradigms) การเขียนโปรแกรมหลายแบบ รวมถึง OOP ตัวอย่างเช่น เราได้สำรวจคุณลักษณะที่มาจากฟังก์ชันนัลโปรแกรมมิ่งในบทที่ 13 ไปแล้ว อาจกล่าวได้ว่าภาษา OOP มีคุณลักษณะร่วมบางประการ—นั่นคือ ออบเจกต์ (objects), การซ่อนแคปซูล (encapsulation), และการสืบทอด (inheritance) มาดูกันว่าแต่ละคุณลักษณะหมายถึงอะไร และ Rust รองรับคุณลักษณะเหล่านั้นหรือไม่

ออบเจกต์บรรจุข้อมูลและพฤติกรรม

หนังสือ Design Patterns: Elements of Reusable Object-Oriented Software โดย Erich Gamma, Richard Helm, Ralph Johnson, และ John Vlissides (Addison-Wesley, 1994) ซึ่งมักถูกเรียกขานกันในภาษาพูดว่าหนังสือ The Gang of Four (หรือ GoF) เป็นคลังรวบรวมรูปแบบการออกแบบเชิงวัตถุ หนังสือเล่มนี้นิยาม OOP ไว้ดังนี้:

โปรแกรมเชิงวัตถุประกอบขึ้นจากออบเจกต์ ออบเจกต์ ทำหน้าที่รวบรวมทั้งข้อมูลและขั้นตอนการทำงาน (procedures) ที่กระทำต่อข้อมูลนั้นเข้าไว้ด้วยกัน ขั้นตอนการทำงานเหล่านั้นมักถูกเรียกว่า เมธอด (methods) หรือ ออเพอเรชัน (operations)

หากใช้นิยามนี้ ภาษา Rust ถือว่าเป็นเชิงวัตถุ: Structs และ enums มีข้อมูล และบล็อก impl มอบเมธอดให้กับ structs และ enums แม้ว่า structs และ enums ที่มีเมธอดจะไม่ถูก เรียกว่า ออบเจกต์ แต่พวกมันก็นำเสนอฟังก์ชันการทำงานแบบเดียวกัน ตามนิยามออบเจกต์ของ Gang of Four

การซ่อนแคปซูลเพื่อปกปิดรายละเอียดการทำงานเบื้องหลัง

อีกแง่มุมหนึ่งที่มักเชื่อมโยงกับ OOP คือแนวคิดเรื่อง การซ่อนแคปซูล (encapsulation) ซึ่งหมายความว่ารายละเอียดการทำงานเบื้องหลังของออบเจกต์จะไม่ถูกเข้าถึงโดยโค้ดที่ใช้ออบเจกต์นั้น ดังนั้น วิธีเดียวที่จะมีปฏิสัมพันธ์กับออบเจกต์คือผ่านสาธารณะ API (public API) ของมัน โค้ดที่ใช้ออบเจกต์ไม่ควรจะเจาะเข้าไปในกลไกภายในของออบเจกต์และเปลี่ยนแปลงข้อมูลหรือพฤติกรรมโดยตรงได้ สิ่งนี้ทำให้โปรแกรมเมอร์สามารถเปลี่ยนแปลงและปรับปรุงโครงสร้างภายใน (refactor) ของออบเจกต์ได้โดยไม่จำเป็นต้องแก้ไขโค้ดที่ใช้ออบเจกต์นั้น

เราได้พูดถึงวิธีควบคุมการซ่อนแคปซูลไปแล้วในบทที่ 7: เราสามารถใช้คีย์เวิร์ด pub เพื่อตัดสินใจว่าโมดูล ชนิดข้อมูล ฟังก์ชัน และเมธอดใดในโค้ดของเราควรเป็นสาธารณะ (public) และโดยค่านิยมเริ่มแรก สิ่งอื่นๆ ทั้งหมดจะเป็นส่วนตัว (private) ตัวอย่างเช่น เราสามารถนิยาม struct AveragedCollection ที่มีฟิลด์เก็บ vector ของค่า i32 นอกจากนี้ struct ยังสามารถมีฟิลด์ที่เก็บค่าเฉลี่ยของข้อมูลใน vector ซึ่งหมายความว่าค่าเฉลี่ยไม่จำเป็นต้องถูกคำนวณใหม่ตามคำขอทุกครั้งที่มีคนต้องการ กล่าวอีกนัยหนึ่ง AveragedCollection จะแคช (cache) ค่าเฉลี่ยที่คำนวณไว้แล้วให้เรา โค้ดตัวอย่างที่ 18-1 มีคำนิยามของ struct AveragedCollection

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

struct ถูกทำเครื่องหมายเป็น pub เพื่อให้โค้ดอื่นสามารถนำไปใช้งานได้ แต่ฟิลด์ภายใน struct ยังคงเป็นส่วนตัว (private) สิ่งนี้สำคัญในกรณีนี้เพราะเราต้องการเน้นย้ำให้แน่ใจว่า เมื่อใดก็ตามที่มีการเพิ่มหรือลบค่าออกจากรายการ ค่าเฉลี่ยจะถูกอัปเดตตามไปด้วย เราทำสิ่งนี้โดยการนำเมธอด add, remove, และ average ไปใช้งานบน struct ดังแสดงในโค้ดตัวอย่างที่ 18-2

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

เมธอดสาธารณะ add, remove, และ average เป็นช่องทางเดียวในการเข้าถึงหรือแก้ไขข้อมูลในอินสแตนซ์ของ AveragedCollection เมื่อมีการเพิ่มรายการลงใน list โดยใช้เมธอด add หรือลบออกโดยใช้เมธอด remove การทำงานภายในของแต่ละเมธอดจะเรียกเมธอดส่วนตัว update_average ซึ่งรับหน้าที่จัดการอัปเดตฟิลด์ average ด้วยเช่นกัน

เราละฟิลด์ list และ average ให้เป็นส่วนตัว เพื่อไม่ให้โค้ดภายนอกสามารถเพิ่มหรือลบรายการออกจากฟิลด์ list โดยตรงได้ มิฉะนั้น ฟิลด์ average อาจทำงานไม่สอดคล้องกับข้อมูลที่เปลี่ยนไปใน list เมธอด average จะคืนค่าที่อยู่ในฟิลด์ average อนุญาตให้โค้ดภายนอกอ่านค่า average ได้แต่ไม่สามารถแก้ไขได้

เนื่องจากเราได้ซ่อนแคปซูลรายละเอียดการทำงานภายในของ struct AveragedCollection ไว้ เราจึงสามารถเปลี่ยนแปลงองค์ประกอบบางอย่าง เช่น โครงสร้างข้อมูล ได้อย่างง่ายดายในอนาคต ตัวอย่างเช่น เราสามารถใช้ HashSet<i32> แทน Vec<i32> สำหรับฟิลด์ list ได้ ตราบใดที่ลายเซ็น (signatures) ของเมธอดสาธารณะ add, remove, และ average ยังคงเดิม โค้ดที่ใช้ AveragedCollection ก็ไม่จำเป็นต้องแก้ไข แต่หากเราเปิดฟิลด์ list ให้เป็นสาธารณะแทน สถานการณ์อาจไม่เป็นเช่นนั้น: HashSet<i32> และ Vec<i32> มีเมธอดที่แตกต่างกันสำหรับการเพิ่มและลบรายการ ดังนั้นโค้ดภายนอกจึงน่าจะต้องแก้ไขหากมีการปรับเปลี่ยน list โดยตรง

หากการซ่อนแคปซูลเป็นคุณลักษณะบังคับสำหรับภาษาที่จะถูกพิจารณาว่าเป็นภาษาเชิงวัตถุ Rust ก็จัดว่าผ่านเกณฑ์นั้น ตัวเลือกในการใช้หรือไม่ใช้ pub สำหรับส่วนต่างๆ ของโค้ด ช่วยให้เกิดการซ่อนแคปซูลรายละเอียดการทำงานได้อย่างสมบูรณ์

การสืบทอดในฐานะระบบชนิดข้อมูลและการแบ่งปันโค้ด

การสืบทอด (Inheritance) เป็นกลไกที่ออบเจกต์หนึ่งสามารถสืบทอดองค์ประกอบจากนิยามของอีกออบเจกต์หนึ่งได้ ทำให้ได้รับข้อมูลและพฤติกรรมของออบเจกต์แม่ (parent object) โดยที่คุณไม่ต้องกำหนดขึ้นใหม่ซ้ำอีก

หากภาษาโปรแกรมต้องมีระบบการสืบทอดจึงจะถือว่าเป็นภาษาเชิงวัตถุ Rust ย่อมไม่ได้เป็นภาษานั้น ไม่มีวิธีในการนิยาม struct ให้สืบทอดฟิลด์และการนำเมธอดไปใช้งานของ struct แม่โดยไม่ใช้แมโครได้

อย่างไรก็ตาม หากคุณคุ้นเคยกับการใช้การสืบทอดในชุดเครื่องมือการเขียนโปรแกรม คุณสามารถใช้แนวทางแก้ไขอื่นใน Rust ได้ ขึ้นอยู่กับเหตุผลของคุณในการเลือกใช้การสืบทอดตั้งแต่แรก

คุณจะเลือกใช้การสืบทอดด้วยเหตุผลหลักสองประการ ประการแรกคือเพื่อการใช้โค้ดซ้ำ (reuse of code): คุณสามารถนำพฤติกรรมเฉพาะไปใช้งานกับชนิดข้อมูลหนึ่ง และการสืบทอดช่วยให้คุณนำการทำงานนั้นไปใช้ซ้ำกับชนิดข้อมูลอื่นได้ คุณสามารถทำสิ่งนี้ในรูปแบบจำกัดได้ในโค้ด Rust โดยใช้การนำเมธอดเริ่มต้นของ trait ไปใช้งาน (default trait method implementations) ซึ่งคุณได้เห็นในโค้ดตัวอย่างที่ 10-14 เมื่อเราเพิ่มการนำเมธอด summarize แบบเริ่มต้นไปใช้งานบน trait Summary ชนิดข้อมูลใดๆ ที่นำ trait Summary ไปใช้งานจะมีเมธอด summarize พร้อมให้ใช้งานโดยไม่ต้องเขียนโค้ดเพิ่ม สิ่งนี้คล้ายกับคลาสแม่ที่มีการนำเมธอดไปใช้งาน และคลาสลูกที่สืบทอดมาก็มีการนำเมธอดนั้นไปใช้งานด้วย นอกจากนี้ เรายังสามารถแทนที่ (override) การนำเมธอด summarize แบบเริ่มต้นไปใช้งานเมื่อเรานำ trait Summary ไปใช้งาน ซึ่งคล้ายกับคลาสลูกที่ override การนำเมธอดไปใช้งานที่สืบทอดมาจากคลาสแม่

เหตุผลอีกประการในการใช้การสืบทอดเกี่ยวข้องกับระบบชนิดข้อมูล: เพื่อเปิดโอกาสให้ชนิดข้อมูลลูก (child type) ถูกใช้ในตำแหน่งเดียวกับชนิดข้อมูลแม่ได้ สิ่งนี้ถูกเรียกว่า พหุสัณฐาน (polymorphism) ซึ่งหมายความว่าคุณสามารถใช้ออบเจกต์หลายรูปแบบทดแทนกันได้ในขณะรัน หากพวกมันมีคุณลักษณะบางประการร่วมกัน

พหุสัณฐาน (Polymorphism)

สำหรับคนจำนวนมาก พหุสัณฐานมักถูกเข้าใจว่าเป็นเรื่องเดียวกับการสืบทอด แต่จริง ๆ แล้วมันเป็นแนวคิดที่ครอบคลุมกว้างกว่านั้น ซึ่งอ้างถึงโค้ดที่สามารถทำงานร่วมกับข้อมูลหลายชนิดข้อมูลได้ สำหรับการสืบทอด ชนิดข้อมูลเหล่านั้นมักจะเป็นคลาสย่อย (subclasses)

ในทางกลับกัน Rust ใช้เจเนอริก (generics) ในการสร้างนามธรรมครอบคลุมชนิดข้อมูลที่เป็นไปได้ต่างๆ และใช้ trait bounds ในการกำหนดข้อจำกัดว่าชนิดข้อมูลเหล่านั้นต้องจัดเตรียมอะไรบ้าง บางครั้งสิ่งนี้ถูกเรียกว่า bounded parametric polymorphism

Rust เลือกชุดข้อแลกเปลี่ยนที่แตกต่างออกไปโดยไม่นำเสนอการสืบทอด การสืบทอดมักมีความเสี่ยงในการแบ่งปันโค้ดมากเกินความจำเป็น คลาสย่อยไม่จำเป็นต้องแชร์คุณลักษณะทั้งหมดของคลาสแม่เสมอไป แต่การสืบทอดจะบังคับให้ทำเช่นนั้น สิ่งนี้อาจทำให้การออกแบบโปรแกรมมีความยืดหยุ่นน้อยลง นอกจากนี้ยังสร้างโอกาสในการเรียกใช้เมธอดบนคลาสย่อยที่ไม่มีความหมายหรือไม่สมเหตุสมผล หรือทำให้เกิดข้อผิดพลาดเนื่องจากเมธอดไม่ได้ประยุกต์ใช้กับคลาสย่อยนั้น นอกจากนี้ บางภาษายังอนุญาตเฉพาะ การสืบทอดเดี่ยว (single inheritance) (หมายความว่าคลาสย่อยสามารถสืบทอดได้จากคลาสเดียวเท่านั้น) ซึ่งยิ่งจำกัดความยืดหยุ่นในการออกแบบโปรแกรมลงไปอีก

ด้วยเหตุผลเหล่านี้ Rust จึงใช้แนวทางที่แตกต่างออกไปโดยใช้ trait objects แทนการสืบทอดเพื่อให้บรรลุผลพหุสัณฐาน (polymorphism) ในขณะรัน เรามาดูกันว่า trait objects ทำงานอย่างไร