ใช้ Trait Object เพื่อนามธรรมพฤติกรรมร่วม
ในบทที่ 8 เรากล่าวว่าข้อจำกัดหนึ่งของ vector คือพวกมันเก็บ element
ของเพียงหนึ่ง type ได้ เราสร้างวิธีแก้ใน Listing 8-9 ที่เรานิยาม
enum SpreadsheetCell ที่มี variant เพื่อเก็บ integer, float และ
text นี่หมายความว่าเราเก็บ type ต่างกันของข้อมูลในแต่ละ cell และยัง
มี vector ที่แทน row ของ cell ได้ นี่เป็นวิธีแก้ดีสมบูรณ์เมื่อ item
แลกเปลี่ยนได้ของเราคือชุด type คงที่ที่เรารู้เมื่อโค้ดของเราถูก
คอมไพล์
อย่างไรก็ตาม บางครั้งเราต้องการให้ user ของ library ของเราขยายชุด
type ที่ valid ในสถานการณ์เฉพาะ เพื่อแสดงว่าเราบรรลุนี้ยังไงได้ เรา
จะสร้างเครื่องมือ GUI ตัวอย่างที่ iterate ผ่าน list ของ item เรียก
เมธอด draw บนแต่ละตัวเพื่อ draw มันบนหน้าจอ — เทคนิคทั่วไปสำหรับ
เครื่องมือ GUI เราจะสร้าง library crate ชื่อ gui ที่บรรจุโครงสร้าง
ของ library GUI crate นี้อาจรวม type บางตัวให้คนใช้ เช่น Button
หรือ TextField นอกจากนี้ user gui จะต้องการสร้าง type ของพวก
เขาเองที่ draw ได้ — ตัวอย่างเช่น programmer หนึ่งอาจเพิ่ม Image
และอีกคนอาจเพิ่ม SelectBox
ตอนเขียน library เรารู้และนิยาม type ทั้งหมดที่ programmer อื่นอาจ
ต้องการสร้างไม่ได้ แต่เรารู้ว่า gui ต้องตามค่าหลายค่าของ type
ต่างกัน และมันต้องเรียกเมธอด draw บนแต่ละค่าที่ type ต่างเหล่านี้
มันไม่ต้องรู้แน่นอนว่าจะเกิดอะไรเมื่อเราเรียกเมธอด draw เพียงว่า
ค่าจะมีเมธอดนั้นใช้ได้สำหรับเราเรียก
เพื่อทำสิ่งนี้ในภาษากับ inheritance เราอาจนิยาม class ชื่อ
Component ที่มีเมธอดชื่อ draw บนมัน class อื่น เช่น Button,
Image และ SelectBox จะสืบทอดจาก Component และดังนั้นสืบทอด
เมธอด draw พวกมันแต่ละตัว override เมธอด draw เพื่อนิยามพฤติกรรม
custom ของพวกมันได้ แต่ framework จะปฏิบัติกับ type ทั้งหมดเหมือน
พวกมันเป็น instance Component และเรียก draw บนพวกมัน แต่เพราะ
Rust ไม่มี inheritance เราต้องการอีกวิธีในการจัดโครงสร้าง library
gui เพื่ออนุญาตให้ user สร้าง type ใหม่ที่เข้ากันได้กับ library
นิยาม Trait สำหรับพฤติกรรมร่วม
เพื่อ implement พฤติกรรมที่เราต้องการให้ gui มี เราจะนิยาม trait
ชื่อ Draw ที่จะมีหนึ่งเมธอดชื่อ draw จากนั้น เรานิยาม vector
ที่รับ trait object ได้ trait object ชี้ไปยังทั้ง instance ของ
type ที่ implement trait ที่เราระบุและ table ที่ใช้ค้นหาเมธอด
trait บน type นั้นที่ runtime เราสร้าง trait object โดยระบุ
pointer บางประเภท เช่น reference หรือ smart pointer Box<T>
แล้ว keyword dyn แล้วระบุ trait ที่เกี่ยวข้อง (เราจะพูดถึงเหตุผล
ที่ trait object ต้องใช้ pointer ใน
“Type ขนาด Dynamic และ Trait Sized”
ในบทที่ 20) เราใช้ trait object ในที่ของ generic หรือ type
คอนกรีต ที่ใดที่เราใช้ trait object ระบบ type ของ Rust จะรับ
ประกันที่ compile time ว่าค่าใดที่ใช้ใน context นั้นจะ implement
trait ของ trait object ผลคือ เราไม่ต้องรู้ type ทั้งหมดที่เป็นไป
ได้ที่ compile time
เรากล่าวว่าใน Rust เราละเว้นจากการเรียก struct และ enum “object”
เพื่อแยกพวกมันจาก object ของภาษาอื่น ใน struct หรือ enum ข้อมูล
ใน field struct และพฤติกรรมใน block impl ถูกแยก ในขณะที่ในภาษา
อื่น ข้อมูลและพฤติกรรมที่รวมเป็นแนวคิดเดียวมักถูก label เป็น object
Trait object ต่างจาก object ในภาษาอื่นในที่เราเพิ่มข้อมูลให้ trait
object ไม่ได้ Trait object ไม่มีประโยชน์ทั่วไปเท่า object ในภาษา
อื่น — จุดประสงค์เฉพาะของพวกมันคือการอนุญาต abstraction ข้ามพฤติกรรม
ร่วม
Listing 18-3 แสดงวิธีนิยาม trait ชื่อ Draw ที่มีหนึ่งเมธอดชื่อ
draw
pub trait Draw {
fn draw(&self);
}
Drawsyntax นี้ควรดูคุ้นเคยจากการพูดคุยของเราเกี่ยวกับวิธีนิยาม trait
ในบทที่ 10 ถัดมาคือ syntax ใหม่ — Listing 18-4 นิยาม struct ชื่อ
Screen ที่เก็บ vector ชื่อ components vector นี้เป็น type
Box<dyn Draw> ซึ่งคือ trait object — มันเป็นตัวยืนแทนสำหรับ type
ใดภายใน Box ที่ implement trait Draw
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen ที่มี field components เก็บ vector ของ trait object ที่ implement trait Drawบน struct Screen เราจะนิยามเมธอดชื่อ run ที่จะเรียกเมธอด
draw บนแต่ละ components ของมัน ดังที่แสดงใน Listing 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();
}
}
}
run บน Screen ที่เรียกเมธอด draw บนแต่ละ componentนี่ทำงานต่างจากการนิยาม struct ที่ใช้ generic type parameter กับ
trait bound generic type parameter ถูกทดแทนด้วยเพียงหนึ่ง type
คอนกรีตในเวลาเดียวกัน ในขณะที่ trait object อนุญาตให้หลาย type
คอนกรีตเติมแทน trait object ที่ runtime ตัวอย่างเช่น เรานิยาม
struct Screen โดยใช้ generic type และ trait bound ดังใน Listing
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 และเมธอด run ของมันโดยใช้ generic และ trait boundนี่จำกัดเราที่ instance Screen ที่มี list ของ component ทั้งหมด
ของ type Button หรือทั้งหมดของ type TextField ถ้าคุณจะมี
collection homogeneous เท่านั้น การใช้ generic และ trait bound
ดีกว่าเพราะนิยามจะถูก monomorphize ที่ compile time เพื่อใช้ type
คอนกรีต
ในทางตรงกันข้าม ด้วยวิธีที่ใช้ trait object instance Screen หนึ่ง
เก็บ Vec<T> ที่บรรจุ Box<Button> รวมถึง Box<TextField> ได้
มาดูว่านี่ทำงานยังไง แล้วเราจะพูดถึงผลกระทบ performance ที่ runtime
Implement Trait
ตอนนี้เราจะเพิ่ม type บางตัวที่ implement trait Draw เราจะให้ type
Button อีกครั้ง การ implement library GUI จริง ๆ อยู่นอก scope
ของหนังสือเล่มนี้ ดังนั้นเมธอด draw จะไม่มี implementation ที่
มีประโยชน์ใน body ของมัน เพื่อจินตนาการว่า implementation อาจดู
ยังไง struct Button อาจมี field สำหรับ width, height และ
label ดังที่แสดงใน Listing 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
}
}
Button ที่ implement trait Drawfield width, height และ label บน Button จะต่างจาก field บน
component อื่น — ตัวอย่างเช่น type TextField อาจมี field
เดียวกันเหล่านั้นบวก field placeholder แต่ละ type ที่เราต้องการ
draw บนหน้าจอจะ implement trait Draw แต่จะใช้โค้ดต่างกันในเมธอด
draw เพื่อนิยามว่าจะ draw type นั้นยังไง เช่นที่ Button มีที่
นี่ (โดยไม่มีโค้ด GUI จริง ดังที่กล่าว) type Button ตัวอย่างเช่น
อาจมี block impl เพิ่มที่บรรจุเมธอดที่เกี่ยวกับสิ่งที่เกิดเมื่อ
user click ปุ่ม เมธอดประเภทเหล่านี้จะไม่ใช้กับ type เช่น
TextField
ถ้าใครที่ใช้ library ของเราตัดสินใจ implement struct SelectBox
ที่มี field width, height และ options พวกเขาจะ implement
trait Draw บน type SelectBox ด้วย ดังที่แสดงใน Listing 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() {}
gui และ implement trait Draw บน struct SelectBoxuser ของ library เรา ตอนนี้เขียนฟังก์ชัน main ของพวกเขาเพื่อสร้าง
instance Screen ได้ ให้ instance Screen พวกเขาเพิ่ม
SelectBox และ Button โดยใส่แต่ละตัวใน Box<T> เพื่อกลายเป็น
trait object ได้ พวกเขาจากนั้นเรียกเมธอด run บน instance
Screen ซึ่งจะเรียก draw บนแต่ละ component Listing 18-9 แสดง
implementation นี้
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();
}
เมื่อเราเขียน library เราไม่รู้ว่าใครอาจเพิ่ม type SelectBox แต่
implementation Screen ของเราสามารถ operate บน type ใหม่และ draw
มันได้เพราะ SelectBox implement trait Draw ซึ่งหมายความว่ามัน
implement เมธอด draw
แนวคิดนี้ — ของการเป็นกังวลเพียงกับข้อความที่ค่าตอบสนองไม่ใช่ type
คอนกรีตของค่า — คล้ายกับแนวคิดของ duck typing ในภาษาที่ type
แบบ dynamic — ถ้ามันเดินเหมือนเป็ดและร้องก๊าบเหมือนเป็ด มันต้องเป็น
เป็ด! ใน implementation ของ run บน Screen ใน Listing 18-5
run ไม่ต้องรู้ว่า type คอนกรีตของแต่ละ component คืออะไร มันไม่
ตรวจสอบว่า component เป็น instance ของ Button หรือ SelectBox
มันเพียงเรียกเมธอด draw บน component โดยระบุ Box<dyn Draw> เป็น
type ของค่าใน vector components เรานิยาม Screen ให้ต้องการค่า
ที่เราเรียกเมธอด draw บนได้
ข้อดีของการใช้ trait object และระบบ type ของ Rust ในการเขียนโค้ด คล้ายกับโค้ดที่ใช้ duck typing คือเราไม่เคยต้องตรวจสอบว่าค่า implement เมธอดเฉพาะที่ runtime หรือกังวลเกี่ยวกับการได้ error ถ้าค่าไม่ implement เมธอดแต่เราเรียกมันต่อ Rust จะไม่คอมไพล์โค้ด ของเราถ้าค่าไม่ implement trait ที่ trait object ต้องการ
ตัวอย่างเช่น Listing 18-10 แสดงสิ่งที่เกิดถ้าเราพยายามสร้าง
Screen กับ String เป็น component
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
เราจะได้ error นี้เพราะ String ไม่ implement 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`
= 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
error นี้ให้เรารู้ว่าเรากำลังส่งอะไรให้ Screen ที่เราไม่ได้ตั้งใจ
ส่ง และดังนั้นควรส่ง type ต่างกัน หรือเราควร implement Draw บน
String เพื่อให้ Screen เรียก draw บนมันได้
ทำ Dynamic Dispatch
จำได้ใน “Performance ของโค้ดที่ใช้ Generic” ในบทที่ 10 การพูดคุยของเราเกี่ยวกับกระบวนการ monomorphization ที่ ทำบน generic โดย compiler — compiler สร้าง implementation ที่ไม่ใช่ generic ของฟังก์ชันและเมธอดสำหรับแต่ละ type คอนกรีตที่เราใช้แทน generic type parameter โค้ดที่ได้จาก monomorphization กำลังทำ static dispatch ซึ่งคือเมื่อ compiler รู้ว่าเมธอดไหนที่คุณกำลัง เรียกที่ compile time นี่ตรงข้ามกับ dynamic dispatch ซึ่งคือเมื่อ compiler บอกที่ compile time ไม่ได้ว่าเมธอดไหนที่คุณกำลังเรียก ใน กรณี dynamic dispatch compiler ปล่อยโค้ดที่ที่ runtime จะรู้ว่า เมธอดไหนที่จะเรียก
เมื่อเราใช้ trait object Rust ต้องใช้ dynamic dispatch compiler ไม่รู้ type ทั้งหมดที่อาจถูกใช้กับโค้ดที่ใช้ trait object ดังนั้น มันไม่รู้ว่าเมธอดที่ implement บน type ไหนที่จะเรียก แทน ที่ runtime Rust ใช้ pointer ภายใน trait object เพื่อรู้ว่าเมธอดไหน ที่จะเรียก การค้นหานี้มีค่าใช้จ่ายที่ runtime ที่ไม่เกิดกับ static dispatch Dynamic dispatch ยังป้องกัน compiler จากการเลือก inline โค้ดของ เมธอด ซึ่งในทางกลับป้องกัน optimization บางอย่าง และ Rust มีกฎบาง ตัวเกี่ยวกับที่ที่คุณใช้ dynamic dispatch ได้และใช้ไม่ได้ เรียก dyn compatibility กฎเหล่านั้นอยู่นอก scope ของการพูดคุยนี้ แต่ คุณอ่านเพิ่มเกี่ยวกับพวกมันได้ ใน reference อย่างไรก็ตาม เรา ได้ความยืดหยุ่นเพิ่มในโค้ดที่เราเขียนใน Listing 18-5 และสามารถ สนับสนุนใน Listing 18-9 ดังนั้นมันเป็น trade-off ที่จะพิจารณา