การใช้ Trait Objects เพื่อสร้างนามธรรมเหนือพฤติกรรมที่ใช้ร่วมกัน
ในบทที่ 8 เราได้กล่าวไว้ว่าข้อจำกัดประการหนึ่งของ vector คือมันสามารถเก็บองค์ประกอบได้เพียงชนิดข้อมูลเดียว เราได้สร้างแนวทางแก้ไขในโค้ดตัวอย่างที่ 8-9 โดยการนิยาม enum SpreadsheetCell ซึ่งมีแวเรียนต์สำหรับเก็บจำนวนเต็ม ทศนิยม และข้อความ สิ่งนี้หมายความว่าเราสามารถเก็บชนิดข้อมูลที่แตกต่างกันในแต่ละเซลล์ และยังคงมี vector ที่แทนแถวของเซลล์ได้ ซึ่งนี่เป็นทางออกที่ดีเยี่ยมเมื่อรายการสับเปลี่ยนของเราเป็นชุดชนิดข้อมูลที่คงที่และเรารู้ล่วงหน้าขณะคอมไพล์โค้ด
อย่างไรก็ตาม ในบางครั้งเราต้องการให้ผู้ใช้ไลบรารีของเราสามารถขยายชุดของชนิดข้อมูลที่ใช้งานได้ในสถานการณ์เฉพาะ เพื่อแสดงว่าเราบรรลุสิ่งนี้ได้อย่างไร เราจะสร้างตัวอย่างเครื่องมืออินเทอร์เฟซผู้ใช้แบบกราฟิก (GUI) ที่วนลูปผ่านรายการข้อมูล โดยเรียกเมธอด draw บนแต่ละรายการเพื่อวาดมันลงบนหน้าจอ—ซึ่งเป็นเทคนิคทั่วไปสำหรับเครื่องมือ GUI เราจะสร้าง crate ไลบรารีชื่อ gui ที่บรรจุโครงสร้างของไลบรารี GUI ซึ่ง crate นี้อาจรวมชนิดข้อมูลบางอย่างเพื่อให้ผู้คนนำไปใช้ เช่น Button หรือ TextField นอกจากนี้ ผู้ใช้ gui จะต้องการสร้างชนิดข้อมูลของตนเองที่สามารถวาดได้ เช่น โปรแกรมเมอร์คนหนึ่งอาจเพิ่ม Image และอีกคนหนึ่งอาจเพิ่ม SelectBox
ในขณะที่เขียนไลบรารี เราไม่สามารถรู้และนิยามทุกชนิดข้อมูลที่โปรแกรมเมอร์คนอื่นอาจต้องการสร้างขึ้นได้ แต่เรารู้ว่า gui จำเป็นต้องติดตามค่าจำนวนมากที่มีชนิดข้อมูลแตกต่างกัน และจำเป็นต้องเรียกเมธอด draw บนค่าแต่ละค่าที่มีชนิดข้อมูลต่างกันเหล่านี้ มันไม่จำเป็นต้องรู้ลึกว่าเกิดอะไรขึ้นเมื่อเราเรียกเมธอด draw เพียงแค่รู้ว่าค่านั้นมีเมธอดดังกล่าวพร้อมให้เราเรียกใช้งาน
หากจะทำสิ่งนี้ในภาษาที่มีการสืบทอด (inheritance) เราอาจนิยามคลาสชื่อ Component ที่มีเมธอดชื่อ draw อยู่ภายใน คลาสอื่นๆ เช่น Button, Image, และ SelectBox จะสืบทอดมาจาก Component และสืบทอดเมธอด draw มาด้วย พวกมันแต่ละคลาสสามารถแทนที่ (override) เมธอด draw เพื่อนิยามพฤติกรรมที่กำหนดเอง แต่ตัวเฟรมเวิร์กสามารถปฏิบัติต่อชนิดข้อมูลทั้งหมดเหมือนกับว่าเป็นอินสแตนซ์ของ Component และเรียกใช้ draw บนพวกมันได้ แต่เนื่องจาก Rust ไม่มีระบบการสืบทอด เราจึงต้องการวิธีอื่นในการจัดโครงสร้างไลบรารี gui เพื่ออนุญาตให้ผู้ใช้สร้างชนิดข้อมูลใหม่ที่เข้ากันได้กับไลบรารี
การนิยาม Trait สำหรับพฤติกรรมร่วม
เพื่อที่จะนำพฤติกรรมที่เราต้องการให้ gui มีไปใช้งาน เราจะนิยาม trait ชื่อ Draw ซึ่งจะมีเมธอดเดียวชื่อ draw จากนั้นเราสามารถนิยาม vector ที่รับ trait object ได้ โดย trait object จะชี้ไปยังทั้งอินสแตนซ์ของชนิดข้อมูลที่นำ trait ที่ระบุไปใช้งาน และตารางที่ใช้สำหรับค้นหาเมธอดของ trait บนชนิดข้อมูลนั้นในขณะรัน เราสร้าง trait object โดยการระบุพอยน์เตอร์บางประเภท เช่น การอ้างอิง (reference) หรือสมาร์ตพอยน์เตอร์ Box<T> ตามด้วยคีย์เวิร์ด dyn แล้วระบุ trait ที่เกี่ยวข้อง (เราจะพูดถึงเหตุผลที่ trait object ต้องใช้พอยน์เตอร์ในส่วน “Dynamically Sized Types and the Sized Trait” ในบทที่ 20) เราสามารถใช้ trait object แทนชนิดข้อมูลเจเนอริกหรือชนิดข้อมูลรูปธรรมได้ ไม่ว่าเราจะใช้ trait object ที่ใด ระบบชนิดข้อมูลของ Rust จะทำให้แน่ใจในขณะคอมไพล์ว่าค่าใดๆ ที่ใช้ในบริบทนั้นได้นำ trait ของ trait object นั้นไปใช้งานแล้ว ผลก็คือ เราไม่จำเป็นต้องรู้ชนิดข้อมูลที่เป็นไปได้ทั้งหมดในขณะคอมไพล์
เราได้กล่าวไปแล้วว่าใน Rust เราหลีกเลี่ยงที่จะเรียก structs และ enums ว่า “ออบเจกต์” เพื่อแยกแยะออกจากออบเจกต์ในภาษาอื่นๆ ใน struct หรือ enum ข้อมูลในฟิลด์ของ struct และพฤติกรรมในบล็อก impl จะถูกแยกออกจากกัน ในขณะที่ในภาษาอื่น ข้อมูลและพฤติกรรมที่รวมกันเข้าเป็นแนวคิดเดียวมักถูกเรียกว่าออบเจกต์ Trait objects มีความแตกต่างจากออบเจกต์ในภาษาอื่นตรงที่เราไม่สามารถเพิ่มข้อมูลลงใน trait object ได้ Trait objects ไม่ได้มีประโยชน์ทั่วไปเหมือนกับออบเจกต์ในภาษาอื่น: วัตถุประสงค์เฉพาะของพวกมันคือเปิดโอกาสให้สร้างนามธรรมครอบคลุมพฤติกรรมร่วมกัน
โค้ดตัวอย่างที่ 18-3 แสดงวิธีนิยาม trait ชื่อ Draw ที่มีเมธอดเดียวชื่อ draw
pub trait Draw {
fn draw(&self);
}
ไวยากรณ์นี้น่าจะดูคุ้นเคยจากการอภิปรายของเราเกี่ยวกับการนิยาม trait ในบทที่ 10 ถัดมาคือไวยากรณ์ใหม่: โค้ดตัวอย่างที่ 18-4 นิยาม struct ชื่อ Screen ที่เก็บ vector ชื่อ components โดย vector นี้มีชนิดข้อมูลเป็น Box<dyn Draw> ซึ่งเป็น trait object มันคือตัวแทนสำหรับชนิดข้อมูลใดๆ ภายใน Box ที่นำ trait Draw ไปใช้งาน
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
บน struct Screen เราจะนิยามเมธอดชื่อ run ซึ่งจะเรียกเมธอด draw บนแต่ละรายการใน components ของมัน ดังแสดงในโค้ดตัวอย่างที่ 18-5
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
สิ่งนี้ทำงานแตกต่างจากการนิยาม struct ที่ใช้พารามิเตอร์ชนิดข้อมูลเจเนอริก (generic type parameter) ร่วมกับ trait bounds พารามิเตอร์ชนิดข้อมูลเจเนอริกสามารถแทนที่ด้วยชนิดข้อมูลรูปธรรมได้เพียงชนิดเดียวในแต่ละครั้ง ในขณะที่ trait objects อนุญาตให้ชนิดข้อมูลรูปธรรมหลายประเภทเข้ามาแทนที่ trait object ในขณะรันได้ ตัวอย่างเช่น เราอาจนิยาม struct Screen โดยใช้ชนิดข้อมูลเจเนอริกและ trait bound ดังแสดงในโค้ดตัวอย่างที่ 18-6
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
วิธีนี้จะจำกัดให้เรามีอินสแตนซ์ของ Screen ที่มีรายการของคอมโพเนนต์เป็นชนิดข้อมูล Button ทั้งหมด หรือชนิดข้อมูล TextField ทั้งหมด หากคุณจะมีเฉพาะคอลเลกชันที่เป็นชนิดข้อมูลเดียวกันเสมอ (homogeneous collections) การใช้เจเนอริกและ trait bounds จะเหมาะสมกว่า เนื่องจากนิยามจะถูกเปลี่ยนรูปแปลงร่าง (monomorphized) ในขณะคอมไพล์เพื่อใช้ชนิดข้อมูลรูปธรรม
ในทางกลับกัน วิธีการที่ใช้ trait objects อนุญาตให้หนึ่งอินสแตนซ์ของ Screen เก็บ Vec<T> ที่บรรจุทั้ง Box<Button> และ Box<TextField> ร่วมกันได้ เรามาดูกันว่าสิ่งนี้ทำงานอย่างไร จากนั้นเราจะพูดถึงผลกระทบต่อประสิทธิภาพขณะรัน
การนำ Trait ไปใช้งาน
ตอนนี้เราจะเพิ่มชนิดข้อมูลบางอย่างที่นำ trait Draw ไปใช้งาน เราจะจัดเตรียมชนิดข้อมูล Button อีกครั้ง การสร้างไลบรารี GUI จริงๆ นั้นเกินขอบเขตของหนังสือเล่มนี้ ดังนั้นเมธอด draw จึงยังไม่มีการทำงานที่มีประโยชน์จริงภายในบอร์ดี้ ในการจินตนาการว่าการทำงานเป็นอย่างไร struct Button อาจมีฟิลด์สำหรับ width, height, และ label ดังแสดงในโค้ดตัวอย่างที่ 18-7
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
ฟิลด์ width, height, และ label บน Button จะแตกต่างจากฟิลด์ในคอมโพเนนต์อื่น ตัวอย่างเช่น ชนิดข้อมูล TextField อาจมีฟิลด์เหล่านั้นเหมือนกัน พร้อมด้วยฟิลด์ placeholder เพิ่มเติม แต่ละชนิดข้อมูลที่เราต้องการวาดบนหน้าจอจะนำ trait Draw ไปใช้งาน แต่จะใช้โค้ดที่แตกต่างกันในเมธอด draw เพื่อกำหนดวิธีวาดชนิดข้อมูลเฉพาะนั้น ดังที่ Button ทำในที่นี้ (โดยไม่มีโค้ด GUI จริงตามที่ระบุ) ยกตัวอย่างเช่น ชนิดข้อมูล Button อาจมีบล็อก impl เพิ่มเติมที่บรรจุเมธอดเกี่ยวกับสิ่งที่เกิดขึ้นเมื่อผู้ใช้คลิกปุ่ม ซึ่งเมธอดประเภทนี้จะไม่ประยุกต์ใช้กับชนิดข้อมูลอย่าง TextField
หากผู้ที่ใช้ไลบรารีของเราตัดสินใจสร้าง struct SelectBox ที่มีฟิลด์ width, height, และ options พวกเขาก็จะนำ trait Draw ไปใช้งานกับชนิดข้อมูล SelectBox ด้วยเช่นกัน ดังแสดงในโค้ดตัวอย่างที่ 18-8
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
ผู้ใช้ไลบรารีของเราสามารถเขียนฟังก์ชัน main เพื่อสร้างอินสแตนซ์ของ Screen ได้แล้ว พวกเขาสามารถเพิ่ม SelectBox และ Button ลงในอินสแตนซ์ของ Screen โดยการนำแต่ละตัวใส่ไว้ใน Box<T> เพื่อกลายเป็น trait object จากนั้นพวกเขาสามารถเรียกใช้เมธอด run บนอินสแตนซ์ของ Screen ซึ่งจะเรียกเมธอด draw บนแต่ละคอมโพเนนต์ โค้ดตัวอย่างที่ 18-9 แสดงการทำงานส่วนนี้
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
เมื่อตอนที่เราเขียนไลบรารี เราไม่รู้เลยว่าจะมีคนเพิ่มชนิดข้อมูล SelectBox แต่การสร้าง Screen ของเราสามารถทำงานกับชนิดข้อมูลใหม่และวาดมันขึ้นมาได้ เพราะว่า SelectBox ได้นำ trait Draw ไปใช้งาน ซึ่งหมายความว่ามันมีเมธอด draw ให้เรียกใช้งาน
แนวคิดนี้—ซึ่งใส่ใจเฉพาะข้อความ (messages) ที่ค่าหนึ่งตอบสนอง มากกว่าการสนใจชนิดข้อมูลรูปธรรมของค่านั้น—คล้ายคลึงกับแนวคิดเรื่อง duck typing ในภาษาแบบไดนามิก: หากมันเดินเหมือนเป็ดและร้องก๊ากๆ เหมือนเป็ด มันก็คงเป็นเป็ด! ในการนำเมธอด run บน Screen ไปใช้งานในโค้ดตัวอย่างที่ 18-5 ตัวเมธอด run ไม่จำเป็นต้องรู้ว่าชนิดข้อมูลรูปธรรมของแต่ละคอมโพเนนต์คืออะไร มันไม่ได้ตรวจสอบว่าคอมโพเนนต์นั้นเป็นอินสแตนซ์ของ Button หรือ SelectBox มันเพียงแค่เรียกใช้เมธอด draw บนคอมโพเนนต์นั้น โดยการระบุ Box<dyn Draw> เป็นชนิดข้อมูลของค่าใน vector components เราได้กำหนดให้ Screen ต้องการค่าที่เราสามารถเรียกใช้เมธอด draw บนพวกมันได้
ข้อดีของการใช้ trait objects และระบบชนิดข้อมูลของ Rust เพื่อเขียนโค้ดที่คล้ายกับการใช้ duck typing คือ เราไม่จำเป็นต้องตรวจสอบในขณะรันว่าค่านั้นนำเมธอดเฉพาะไปใช้งานหรือไม่ หรือต้องกังวลว่าจะมีข้อผิดพลาดเกิดขึ้นหากค่านั้นไม่ได้นำเมธอดไปใช้งานแต่เราเรียกมัน Rust จะคอมไพล์โค้ดไม่ผ่านหากค่านั้นไม่ได้นำ trait ที่ trait objects ต้องการไปใช้งาน
ตัวอย่างเช่น โค้ดตัวอย่างที่ 18-10 แสดงสิ่งที่เกิดขึ้นหากเราพยายามสร้าง Screen โดยมี String เป็นคอมโพเนนต์
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
เราจะได้รับข้อผิดพลาดนี้เนื่องจาก Stringไม่ได้นำ trait Draw ไปใช้งาน:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
help: the trait `Draw` is implemented for `Button`
--> src/lib.rs:23:1
|
23 | impl Draw for Button {
| ^^^^^^^^^^^^^^^^^^^^
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
ข้อผิดพลาดนี้ทำให้เรารู้ว่า เราอาจกำลังส่งบางอย่างไปยัง Screen โดยไม่ได้ตั้งใจ และควรส่งชนิดข้อมูลอื่นแทน หรือเราควรนำ trait Draw ไปใช้งานบน String เพื่อให้ Screen สามารถเรียกใช้ draw บนมันได้
การดำเนินการ Dynamic Dispatch
ย้อนกลับไปในส่วน “Performance of Code Using Generics” ในบทที่ 10 ที่พูดถึงกระบวนการเปลี่ยนรูปแปลงร่าง (monomorphization) ที่กระทำกับเจเนอริกโดยคอมไพเลอร์: คอมไพเลอร์จะสร้างโค้ดการทำงานแบบนอนเจเนอริก (nongeneric) ของฟังก์ชันและเมธอดสำหรับแต่ละชนิดข้อมูลรูปธรรมที่เราใช้แทนที่พารามิเตอร์ชนิดข้อมูลเจเนอริก โค้ดที่เป็นผลลัพธ์จากการเปลี่ยนรูปแปลงร่างกำลังทำ static dispatch ซึ่งเกิดขึ้นเมื่อคอมไพเลอร์รู้ว่าคุณกำลังเรียกใช้เมธอดใดในขณะคอมไพล์ สิ่งนี้ตรงกันข้ามกับ dynamic dispatch ซึ่งเป็นกรณีที่คอมไพเลอร์ไม่สามารถบอกในขณะคอมไพล์ได้ว่าคุณกำลังเรียกใช้เมธอดใด ในกรณีของ dynamic dispatch คอมไพเลอร์จะปล่อยโค้ดที่ในขณะรันจะรู้ว่าต้องเรียกใช้เมธอดใด
เมื่อเราใช้ trait objects ภาษา Rust จะต้องใช้ dynamic dispatch คอมไพเลอร์ไม่ทราบทุกชนิดข้อมูลที่อาจถูกนำมาใช้กับโค้ดที่ใช้ trait objects ดังนั้นมันจึงไม่ทราบว่าต้องเรียกใช้เมธอดที่ถูกนำไปใช้งานบนชนิดข้อมูลใด แต่ในขณะรัน Rust จะใช้พอยน์เตอร์ภายใน trait object เพื่อดูว่าต้องเรียกใช้เมธอดใด การค้นหาตำแหน่งนี้ทำให้เกิดต้นทุนทางประสิทธิภาพในขณะรัน (runtime cost) ซึ่งจะไม่เกิดขึ้นกับ static dispatch นอกจากนี้ dynamic dispatch ยังขัดขวางไม่ให้คอมไพเลอร์เลือกรวมโค้ดของเมธอดเข้าด้วยกัน (inline) ซึ่งจะส่งผลให้ไม่สามารถทำ optimization บางอย่างได้ และ Rust มีกฎเกณฑ์บางอย่างเกี่ยวกับสถานที่ที่คุณสามารถและไม่สามารถใช้ dynamic dispatch ได้ ซึ่งเรียกว่า dyn compatibility กฎเหล่านั้นเกินขอบเขตของการอภิปรายนี้ แต่คุณสามารถอ่านเพิ่มเติมได้ ในเอกสารอ้างอิง อย่างไรก็ตาม เราได้รับความยืดหยุ่นเพิ่มเติมในโค้ดที่เราเขียนในโค้ดตัวอย่างที่ 18-5 และได้รับการรองรับในโค้ดตัวอย่างที่ 18-9 ดังนั้นนี่จึงเป็นข้อแลกเปลี่ยนที่ต้องพิจารณา