Używanie Obiektów Trait do Abstrakcji nad Wspólnym Zachowaniem
W Rozdziale 8 wspomnieliśmy, że jednym z ograniczeń wektorów jest to, że mogą przechowywać elementy tylko jednego typu. Stworzyliśmy obejście w Liście 8-9, gdzie zdefiniowaliśmy wyliczenie SpreadsheetCell z wariantami do przechowywania liczb całkowitych, zmiennoprzecinkowych i tekstu. Oznaczało to, że mogliśmy przechowywać różne typy danych w każdej komórce i nadal mieć wektor reprezentujący wiersz komórek. Jest to doskonale dobre rozwiązanie, gdy nasze wymienne elementy to stały zestaw typów, które znamy w momencie kompilacji kodu.
Jednak czasami chcemy, aby użytkownik naszej biblioteki mógł rozszerzyć zestaw typów, które są prawidłowe w danej sytuacji. Aby pokazać, jak to osiągnąć, stworzymy przykład narzędzia graficznego interfejsu użytkownika (GUI), które iteruje przez listę elementów, wywołując metodę draw na każdym z nich, aby narysować go na ekranie — powszechna technika w narzędziach GUI. Stworzymy bibliotekę gui, która będzie zawierać strukturę biblioteki GUI. Ta biblioteka może zawierać typy dla użytkowników, takie jak Button lub TextField. Ponadto użytkownicy gui będą chcieli tworzyć własne typy, które można rysować: na przykład jeden programista może dodać Image, a inny SelectBox.
W momencie pisania biblioteki nie możemy znać i zdefiniować wszystkich typów, które inni programiści mogą chcieć stworzyć. Wiemy jednak, że gui musi śledzić wiele wartości różnych typów i musi wywoływać metodę draw na każdej z tych wartości o różnych typach. Nie musi wiedzieć dokładnie, co się stanie, gdy wywołamy metodę draw, tylko tyle, że wartość będzie miała tę metodę dostępną do wywołania.
Aby to zrobić w języku z dziedziczeniem, moglibyśmy zdefiniować klasę o nazwie Component, która miałaby metodę draw. Inne klasy, takie jak Button, Image i SelectBox, dziedziczyłyby po Component i w ten sposób dziedziczyłyby metodę draw. Każda z nich mogłaby nadpisać metodę draw, aby zdefiniować swoje niestandardowe zachowanie, ale framework mógłby traktować wszystkie typy tak, jakby były instancjami Component i wywoływać na nich draw. Ale ponieważ Rust nie ma dziedziczenia, potrzebujemy innego sposobu na zbudowanie biblioteki gui, aby umożliwić użytkownikom tworzenie nowych typów zgodnych z biblioteką.
Definiowanie Traitu dla Wspólnego Zachowania
Aby zaimplementować zachowanie, które chcemy, aby gui miało, zdefiniujemy cechę Draw, która będzie miała jedną metodę draw. Następnie możemy zdefiniować wektor, który przyjmuje obiekt cechy. Obiekt cechy wskazuje zarówno instancję typu implementującego naszą określoną cechę, jak i tabelę używaną do wyszukiwania metod cech na tym typie w czasie wykonania. Tworzymy obiekt cechy, określając jakiś rodzaj wskaźnika, taki jak referencja lub inteligentny wskaźnik Box<T>, następnie słowo kluczowe dyn, a następnie określając odpowiednią cechę. (O powodzie, dla którego obiekty cech muszą używać wskaźnika, porozmawiamy w sekcji „Typy o dynamicznym rozmiarze i cecha Sized” w Rozdziale 20.) Możemy używać obiektów cech zamiast typu generycznego lub konkretnego. Wszędzie, gdzie używamy obiektu cechy, system typów Rust zapewni w czasie kompilacji, że każda wartość użyta w tym kontekście będzie implementować cechę obiektu cechy. W konsekwencji nie musimy znać wszystkich możliwych typów w czasie kompilacji.
Wspomnieliśmy, że w Rust powstrzymujemy się od nazywania struktur i wyliczeń „obiektami”, aby odróżnić je od obiektów z innych języków. W strukturze lub wyliczeniu dane w polach struktury i zachowanie w blokach impl są oddzielone, podczas gdy w innych językach dane i zachowanie połączone w jedną koncepcję są często nazywane obiektem. Obiekty cech różnią się od obiektów w innych językach tym, że nie możemy dodawać danych do obiektu cechy. Obiekty cech nie są tak ogólnie użyteczne jak obiekty w innych językach: ich specyficznym celem jest umożliwienie abstrakcji nad wspólnym zachowaniem.
Lista 18-3 pokazuje, jak zdefiniować cechę Draw z jedną metodą draw.
pub trait Draw {
fn draw(&self);
}
DrawTa składnia powinna być znana z naszych dyskusji na temat definiowania cech w Rozdziale 10. Dalej pojawia się nowa składnia: Lista 18-4 definiuje strukturę o nazwie Screen, która zawiera wektor o nazwie components. Ten wektor jest typu Box<dyn Draw>, czyli obiektu cechy; jest to zastępstwo dla dowolnego typu wewnątrz Box, który implementuje cechę Draw.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen z polem components zawierającym wektor obiektów cech, które implementują cechę DrawW strukturze Screen zdefiniujemy metodę run, która wywoła metodę draw na każdym z jej components, jak pokazano na Liście 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 w Screen, która wywołuje metodę draw na każdym komponencieDziała to inaczej niż definiowanie struktury, która używa generycznego parametru typu z ograniczeniami cech. Generyczny parametr typu może być podstawiony tylko jednym konkretnym typem na raz, podczas gdy obiekty cech pozwalają na wypełnienie obiektu cechy wieloma konkretnymi typami w czasie wykonania. Na przykład, moglibyśmy zdefiniować strukturę Screen używając generycznego typu i ograniczenia cech, jak na Liście 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 i jej metody run za pomocą generyków i ograniczeń cechTo ogranicza nas do instancji Screen, która ma listę komponentów wszystkich typu Button lub wszystkich typu TextField. Jeśli zawsze będziesz mieć tylko jednorodne kolekcje, użycie generyków i ograniczeń cech jest preferowane, ponieważ definicje zostaną zmonomorfizowane w czasie kompilacji, aby używać konkretnych typów.
Z drugiej strony, w metodzie używającej obiektów cech, jedna instancja Screen może przechowywać Vec<T>, który zawiera Box<Button> oraz Box<TextField>. Przyjrzyjmy się, jak to działa, a następnie omówimy implikacje dla wydajności w czasie wykonania.
Implementowanie Traitu
Teraz dodamy kilka typów, które implementują cechę Draw. Zapewnimy typ Button. Ponownie, faktyczne zaimplementowanie biblioteki GUI wykracza poza zakres tej książki, więc metoda draw nie będzie miała żadnej użytecznej implementacji w swoim ciele. Aby wyobrazić sobie, jak mogłaby wyglądać implementacja, struktura Button mogłaby mieć pola width, height i label, jak pokazano na Liście 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, która implementuje cechę DrawPola width, height i label w Button będą się różnić od pól w innych komponentach; na przykład, typ TextField mógłby mieć te same pola plus pole placeholder. Każdy z typów, które chcemy narysować na ekranie, będzie implementował cechę Draw, ale użyje innego kodu w metodzie draw, aby zdefiniować, jak narysować dany typ, jak to ma miejsce w Button (bez faktycznego kodu GUI, jak wspomniano). Typ Button, na przykład, mógłby mieć dodatkowy blok impl zawierający metody związane z tym, co dzieje się, gdy użytkownik kliknie przycisk. Tego rodzaju metody nie będą miały zastosowania do typów takich jak TextField.
Jeśli ktoś używający naszej biblioteki zdecyduje się zaimplementować strukturę SelectBox, która ma pola width, height i options, zaimplementuje również cechę Draw dla typu SelectBox, jak pokazano na Liście 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 i implementujący cechę Draw na strukturze SelectBoxUżytkownik naszej biblioteki może teraz napisać swoją funkcję main, aby utworzyć instancję Screen. Do instancji Screen mogą dodać SelectBox i Button, umieszczając każdy w Box<T>, aby stał się obiektem cechy. Następnie mogą wywołać metodę run na instancji Screen, która wywoła draw na każdym z komponentów. Lista 18-9 pokazuje tę implementację.
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();
}
Kiedy pisaliśmy bibliotekę, nie wiedzieliśmy, że ktoś może dodać typ SelectBox, ale nasza implementacja Screen była w stanie operować na nowym typie i go rysować, ponieważ SelectBox implementuje cechę Draw, co oznacza, że implementuje metodę draw.
Ta koncepcja — zajmowanie się tylko wiadomościami, na które wartość odpowiada, a nie konkretnym typem wartości — jest podobna do koncepcji duck typing w językach z dynamicznym typowaniem: jeśli chodzi jak kaczka i kwacze jak kaczka, to musi być kaczka! W implementacji run na Screen na Liście 18-5, run nie musi wiedzieć, jaki jest konkretny typ każdego komponentu. Nie sprawdza, czy komponent jest instancją Button czy SelectBox, po prostu wywołuje metodę draw na komponencie. Poprzez określenie Box<dyn Draw> jako typu wartości w wektorze components, zdefiniowaliśmy, że Screen potrzebuje wartości, na których możemy wywołać metodę draw.
Zaletą używania obiektów cech i systemu typów Rust do pisania kodu podobnego do kodu używającego duck typingu jest to, że nigdy nie musimy sprawdzać, czy wartość implementuje konkretną metodę w czasie wykonania, ani martwić się o błędy, jeśli wartość nie implementuje metody, ale mimo to ją wywołujemy. Rust nie skompiluje naszego kodu, jeśli wartości nie implementują cech, których potrzebują obiekty cech.
Na przykład, Lista 18-10 pokazuje, co się dzieje, gdy próbujemy stworzyć Screen ze String jako komponentem.
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
Otrzymamy ten błąd, ponieważ String nie implementuje cechy 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
Ten błąd informuje nas, że albo przekazujemy coś do Screen, czego nie zamierzaliśmy przekazać i dlatego powinniśmy przekazać inny typ, albo powinniśmy zaimplementować Draw na String, aby Screen był w stanie wywołać na nim draw.
Wykonywanie Dynamicznego Wysyłania
Przypomnijmy sobie dyskusję w sekcji „Wydajność kodu używającego generyków” w Rozdziale 10 na temat procesu monomorfizacji wykonywanego przez kompilator dla generyków: kompilator generuje niegeneryczne implementacje funkcji i metod dla każdego konkretnego typu, który używamy w miejsce generycznego parametru typu. Kod, który wynika z monomorfizacji, wykonuje statyczne wysyłanie, czyli sytuację, w której kompilator wie, którą metodę wywołujesz w czasie kompilacji. Jest to przeciwieństwo dynamicznego wysyłania, czyli sytuacji, w której kompilator nie może w czasie kompilacji określić, którą metodę wywołujesz. W przypadkach dynamicznego wysyłania kompilator emituje kod, który w czasie wykonania będzie wiedział, którą metodę wywołać.
Kiedy używamy obiektów cech, Rust musi użyć dynamicznego wysyłania. Kompilator nie zna wszystkich typów, które mogą być użyte z kodem używającym obiektów cech, więc nie wie, która metoda zaimplementowana na którym typie ma być wywołana. Zamiast tego, w czasie wykonania, Rust używa wskaźników w obiekcie cechy, aby wiedzieć, którą metodę wywołać. To wyszukiwanie wiąże się z kosztem wykonania, który nie występuje przy statycznym wysyłaniu. Dynamiczne wysyłanie uniemożliwia również kompilatorowi wstawienie kodu metody, co z kolei uniemożliwia niektóre optymalizacje, a Rust ma pewne zasady dotyczące tego, gdzie można, a gdzie nie można używać dynamicznego wysyłania, zwane kompatybilnością dyn. Te zasady wykraczają poza zakres tej dyskusji, ale możesz przeczytać o nich więcej w referencji. Jednakże uzyskaliśmy dodatkową elastyczność w kodzie, który napisaliśmy na Liście 18-5 i byliśmy w stanie obsługiwać na Liście 18-9, więc jest to kompromis do rozważenia.