Definiowanie wspólnego zachowania za pomocą cech
Cecha (trait) definiuje funkcjonalność, którą posiada dany typ i którą może dzielić z innymi typami. Możemy używać cech do definiowania wspólnego zachowania w sposób abstrakcyjny. Możemy używać ograniczeń cech (trait bounds) do określania, że typ generyczny może być dowolnym typem, który ma określone zachowanie.
Uwaga: Cechy są podobne do funkcji często nazywanych interfejsami w innych językach, choć z pewnymi różnicami.
Definiowanie cechy
Zachowanie typu składa się z metod, które możemy wywołać dla tego typu. Różne typy dzielą to samo zachowanie, jeśli możemy wywołać te same metody dla wszystkich tych typów. Definicje cech to sposób grupowania sygnatur metod w celu zdefiniowania zestawu zachowań niezbędnych do osiągnięcia pewnego celu.
Na przykład, powiedzmy, że mamy wiele struktur, które przechowują różne rodzaje i ilości tekstu: struktura NewsArticle, która przechowuje artykuł informacyjny z określonej lokalizacji, i SocialPost, która może mieć co najwyżej 280 znaków wraz z metadanymi wskazującymi, czy był to nowy post, repost, czy odpowiedź na inny post.
Chcemy stworzyć skrzynkę biblioteczną agregującą media o nazwie aggregator, która będzie mogła wyświetlać podsumowania danych przechowywanych w instancji NewsArticle lub SocialPost. Aby to zrobić, potrzebujemy podsumowania z każdego typu, a to podsumowanie uzyskamy, wywołując metodę summarize na instancji. Listing 10-12 pokazuje definicję publicznej cechy Summary, która wyraża to zachowanie.
pub trait Summary {
fn summarize(&self) -> String;
}
Tutaj deklarujemy cechę za pomocą słowa kluczowego trait, a następnie nazwę cechy, która w tym przypadku to Summary. Deklarujemy również cechę jako pub, aby skrzynki zależne od tej skrzynki mogły również korzystać z tej cechy, jak zobaczymy w kilku przykładach. W nawiasach klamrowych deklarujemy sygnatury metod, które opisują zachowania typów implementujących tę cechę, co w tym przypadku jest fn summarize(&self) -> String.
Po sygnaturze metody, zamiast dostarczać implementacji w nawiasach klamrowych, używamy średnika. Każdy typ implementujący tę cechę musi dostarczyć własne, niestandardowe zachowanie dla ciała metody. Kompilator wymusi, aby każdy typ posiadający cechę Summary miał metodę summarize zdefiniowaną dokładnie z tą sygnaturą.
Cecha może mieć wiele metod w swoim ciele: sygnatury metod są wymienione jedna na linię, a każda linia kończy się średnikiem.
Implementowanie cechy na typie
Teraz, gdy zdefiniowaliśmy pożądane sygnatury metod cechy Summary, możemy zaimplementować ją na typach w naszym agregatorze mediów. Listing 10-13 pokazuje implementację cechy Summary na strukturze NewsArticle, która używa nagłówka, autora i lokalizacji do utworzenia wartości zwracanej przez summarize. Dla struktury SocialPost definiujemy summarize jako nazwę użytkownika, a następnie cały tekst posta, zakładając, że zawartość posta jest już ograniczona do 280 znaków.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Implementowanie cechy na typie jest podobne do implementowania zwykłych metod. Różnica polega na tym, że po impl umieszczamy nazwę cechy, którą chcemy zaimplementować, następnie używamy słowa kluczowego for, a następnie określamy nazwę typu, dla którego chcemy zaimplementować cechę. W bloku impl umieszczamy sygnatury metod, które zdefiniowano w definicji cechy. Zamiast dodawać średnik po każdej sygnaturze, używamy nawiasów klamrowych i wypełniamy ciało metody konkretnym zachowaniem, które chcemy, aby metody cechy miały dla danego typu.
Teraz, gdy biblioteka zaimplementowała cechę Summary dla NewsArticle i SocialPost, użytkownicy skrzynki mogą wywoływać metody cech na instancjach NewsArticle i SocialPost w taki sam sposób, jak wywołujemy zwykłe metody. Jedyną różnicą jest to, że użytkownik musi wprowadzić cechę do zasięgu, a także typy. Oto przykład, jak skrzynka binarna mogłaby użyć naszej skrzynki bibliotecznej aggregator:
use aggregator::{SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"oczywiście, jak zapewne już wiesz, ludzie",
),
reply: false,
repost: false,
};
println!("1 nowy post: {}", post.summarize());
}
Ten kod drukuje 1 nowy post: horse_ebooks: oczywiście, jak zapewne już wiesz, ludzie.
Inne skrzynki zależne od skrzynki aggregator mogą również wprowadzić cechę Summary do zasięgu, aby zaimplementować Summary na swoich własnych typach. Jednym z ograniczeń jest to, że możemy zaimplementować cechę na typie tylko wtedy, gdy cecha lub typ, albo oba, są lokalne dla naszej skrzynki. Na przykład, możemy zaimplementować cechy standardowej biblioteki, takie jak Display, na niestandardowym typie, takim jak SocialPost, jako część funkcjonalności naszej skrzynki aggregator, ponieważ typ SocialPost jest lokalny dla naszej skrzynki aggregator. Możemy również zaimplementować Summary na Vec<T> w naszej skrzynce aggregator, ponieważ cecha Summary jest lokalna dla naszej skrzynki aggregator.
Ale nie możemy implementować zewnętrznych cech na zewnętrznych typach. Na przykład, nie możemy implementować cechy Display na Vec<T> w naszej skrzynce aggregator, ponieważ Display i Vec<T> są zdefiniowane w standardowej bibliotece i nie są lokalne dla naszej skrzynki aggregator. To ograniczenie jest częścią właściwości zwanej spójnością, a dokładniej zasadą sieroty (orphan rule), nazwaną tak, ponieważ typ nadrzędny nie jest obecny. Ta zasada zapewnia, że kod innych ludzi nie może zepsuć twojego kodu i vice versa. Bez tej zasady, dwie skrzynki mogłyby zaimplementować tę samą cechę dla tego samego typu, a Rust nie wiedziałby, której implementacji użyć.
Używanie domyślnych implementacji
Czasami przydatne jest posiadanie domyślnego zachowania dla niektórych lub wszystkich metod w cesze, zamiast wymagać implementacji dla wszystkich metod na każdym typie. Następnie, implementując cechę na konkretnym typie, możemy zachować lub nadpisać domyślne zachowanie każdej metody.
W Listing 10-14 określamy domyślny ciąg znaków dla metody summarize cechy Summary, zamiast definiować tylko sygnaturę metody, jak to zrobiliśmy w Listing 10-12.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Czytaj więcej...)")
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Aby użyć domyślnej implementacji do podsumowywania instancji NewsArticle, określamy pusty blok impl za pomocą impl Summary for NewsArticle {}.
Chociaż nie definiujemy już bezpośrednio metody summarize w NewsArticle, dostarczyliśmy domyślną implementację i określiliśmy, że NewsArticle implementuje cechę Summary. W rezultacie nadal możemy wywołać metodę summarize na instancji NewsArticle, tak jak poniżej:
use aggregator::{self, NewsArticle, Summary};
fn main() {
let article = NewsArticle {
headline: String::from("Pingwiny wygrywają Puchar Stanleya!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"Pittsburgh Penguins po raz kolejny są najlepszą \
drużyną hokejową w NHL.",
),
};
println!("Nowy artykuł dostępny! {}", article.summarize());
}
Ten kod drukuje Nowy artykuł dostępny! (Czytaj więcej...).
Stworzenie domyślnej implementacji nie wymaga od nas zmiany czegokolwiek w implementacji Summary na SocialPost w Listing 10-13. Powodem jest to, że składnia nadpisywania domyślnej implementacji jest taka sama jak składnia implementacji metody cechy, która nie ma domyślnej implementacji.
Domyślne implementacje mogą wywoływać inne metody w tej samej cesze, nawet jeśli te inne metody nie mają domyślnej implementacji. W ten sposób cecha może dostarczyć wiele użytecznej funkcjonalności i wymagać od implementatorów jedynie określenia jej małej części. Na przykład, moglibyśmy zdefiniować cechę Summary tak, aby miała metodę summarize_author, której implementacja jest wymagana, a następnie zdefiniować metodę summarize, która ma domyślną implementację, która wywołuje metodę summarize_author:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Czytaj więcej od {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Aby użyć tej wersji Summary, wystarczy zdefiniować summarize_author podczas implementacji cechy na typie:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Czytaj więcej od {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Po zdefiniowaniu summarize_author możemy wywołać summarize na instancjach struktury SocialPost, a domyślna implementacja summarize wywoła zdefiniowaną przez nas summarize_author. Ponieważ zaimplementowaliśmy summarize_author, cecha Summary dała nam zachowanie metody summarize bez konieczności pisania dodatkowego kodu. Oto jak to wygląda:
use aggregator::{self, SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"oczywiście, jak zapewne już wiesz, ludzie",
),
reply: false,
repost: false,
};
println!("1 nowy post: {}", post.summarize());
}
Ten kod drukuje 1 nowy post: (Czytaj więcej od @horse_ebooks...).
Zauważ, że nie jest możliwe wywołanie domyślnej implementacji z nadpisującej implementacji tej samej metody.
Używanie cech jako parametrów
Teraz, gdy wiesz, jak definiować i implementować cechy, możemy zbadać, jak używać cech do definiowania funkcji, które akceptują wiele różnych typów. Użyjemy cechy Summary, którą zaimplementowaliśmy na typach NewsArticle i SocialPost w Listing 10-13, aby zdefiniować funkcję notify, która wywołuje metodę summarize na swoim parametrze item, który jest jakiegoś typu implementującego cechę Summary. Aby to zrobić, używamy składni impl Trait, tak jak poniżej:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
pub fn notify(item: &impl Summary) {
println!("Najświeższe wiadomości! {}", item.summarize());
}
Zamiast konkretnego typu dla parametru item, określamy słowo kluczowe impl i nazwę cechy. Ten parametr akceptuje dowolny typ, który implementuje określoną cechę. W ciele notify możemy wywołać dowolne metody na item, które pochodzą z cechy Summary, takie jak summarize. Możemy wywołać notify i przekazać dowolną instancję NewsArticle lub SocialPost. Kod, który wywołuje funkcję z dowolnym innym typem, takim jak String lub i32, nie skompiluje się, ponieważ te typy nie implementują Summary.
Składnia ograniczeń cech
Składnia impl Trait działa w prostych przypadkach, ale w rzeczywistości jest to cukier syntaktyczny dla dłuższej formy znanej jako ograniczenie cechy (trait bound); wygląda to tak:
pub fn notify<T: Summary>(item: &T) {
println!("Najświeższe wiadomości! {}", item.summarize());
}
Ta dłuższa forma jest równoważna przykładowi z poprzedniej sekcji, ale jest bardziej rozwlekła. Ograniczenia cech umieszczamy wraz z deklaracją generycznego parametru typu po dwukropku i w nawiasach ostrych.
Składnia impl Trait jest wygodna i sprawia, że kod jest bardziej zwięzły w prostych przypadkach, podczas gdy pełniejsza składnia ograniczeń cech może wyrażać większą złożoność w innych przypadkach. Na przykład, możemy mieć dwa parametry, które implementują Summary. Użycie składni impl Trait wygląda tak:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Użycie impl Trait jest odpowiednie, jeśli chcemy, aby ta funkcja pozwalała item1 i item2 na posiadanie różnych typów (o ile oba typy implementują Summary). Jeśli jednak chcemy wymusić, aby oba parametry miały ten sam typ, musimy użyć ograniczenia cechy, tak jak poniżej:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Generyczny typ T określony jako typ parametrów item1 i item2 ogranicza funkcję tak, że konkretny typ wartości przekazanej jako argument dla item1 i item2 musi być taki sam.
Wiele ograniczeń cech za pomocą składni +
Możemy również określić więcej niż jedno ograniczenie cechy. Powiedzmy, że chcieliśmy, aby notify używało formatowania wyświetlania, a także summarize na item: określamy w definicji notify, że item musi implementować zarówno Display, jak i Summary. Możemy to zrobić za pomocą składni +:
pub fn notify(item: &(impl Summary + Display)) {
Składnia + jest również prawidłowa z ograniczeniami cech na typach generycznych:
pub fn notify<T: Summary + Display>(item: &T) {
Dzięki dwóm określonym ograniczeniom cech, ciało notify może wywoływać summarize i używać {} do formatowania item.
Jaśniejsze ograniczenia cech za pomocą klauzul where
Zbyt wiele ograniczeń cech ma swoje wady. Każdy typ generyczny ma swoje własne ograniczenia cech, więc funkcje z wieloma generycznymi parametrami typu mogą zawierać wiele informacji o ograniczeniach cech między nazwą funkcji a listą jej parametrów, co utrudnia czytanie sygnatury funkcji. Z tego powodu Rust ma alternatywną składnię do określania ograniczeń cech w klauzuli where po sygnaturze funkcji. Tak więc, zamiast pisać tak:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
możemy użyć klauzuli where, tak jak poniżej:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
Sygnatura tej funkcji jest mniej zaśmiecona: nazwa funkcji, lista parametrów i typ zwracany są blisko siebie, podobnie jak w funkcji bez wielu ograniczeń cech.
Zwracanie typów, które implementują cechy
Możemy również użyć składni impl Trait w pozycji zwracanej, aby zwrócić wartość jakiegoś typu, który implementuje cechę, jak pokazano tutaj:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable() -> impl Summary {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"oczywiście, jak zapewne już wiesz, ludzie",
),
reply: false,
repost: false,
}
}
Używając impl Summary jako typu zwracanego, określamy, że funkcja returns_summarizable zwraca pewien typ, który implementuje cechę Summary, bez nazywania konkretnego typu. W tym przypadku returns_summarizable zwraca SocialPost, ale kod wywołujący tę funkcję nie musi o tym wiedzieć.
Możliwość określenia typu zwracanego tylko przez cechę, którą implementuje, jest szczególnie przydatna w kontekście domknięć i iteratorów, które omówimy w Rozdziale 13. Domknięcia i iteratory tworzą typy, które zna tylko kompilator, lub typy, które są bardzo długie do określenia. Składnia impl Trait pozwala zwięźle określić, że funkcja zwraca jakiś typ, który implementuje cechę Iterator, bez konieczności wypisywania bardzo długiego typu.
Możesz jednak używać impl Trait tylko wtedy, gdy zwracasz pojedynczy typ. Na przykład, ten kod, który zwraca NewsArticle lub SocialPost z typem zwracanym określonym jako impl Summary, nie zadziała:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Pingwiny wygrywają Puchar Stanleya!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"Pittsburgh Penguins po raz kolejny są najlepszą \
drużyną hokejową w NHL.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"oczywiście, jak zapewne już wiesz, ludzie",
),
reply: false,
repost: false,
}
}
}
Zwracanie NewsArticle lub SocialPost nie jest dozwolone z powodu ograniczeń związanych z implementacją składni impl Trait w kompilatorze. Omówimy, jak napisać funkcję o takim zachowaniu w sekcji „Używanie obiektów cech do abstrakcji wspólnego zachowania” w Rozdziale 18.
Używanie ograniczeń cech do warunkowej implementacji metod
Używając ograniczenia cechy z blokiem impl, który używa generycznych parametrów typu, możemy warunkowo implementować metody dla typów, które implementują określone cechy. Na przykład, typ Pair<T> w Listing 10-15 zawsze implementuje funkcję new, aby zwrócić nową instancję Pair<T> (przypomnij sobie z sekcji „Składnia metody” w Rozdziale 5, że Self jest aliasem typu dla typu bloku impl, który w tym przypadku to Pair<T>). Ale w następnym bloku impl, Pair<T> implementuje metodę cmp_display tylko wtedy, gdy jej wewnętrzny typ T implementuje cechę PartialOrd, która umożliwia porównanie i cechę Display, która umożliwia drukowanie.
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("Największy element to x = {}", self.x);
} else {
println!("Największy element to y = {}", self.y);
}
}
}
Możemy również warunkowo implementować cechę dla dowolnego typu, który implementuje inną cechę. Implementacje cechy na dowolnym typie, który spełnia ograniczenia cechy, nazywane są implementacjami ogólnymi (blanket implementations) i są szeroko stosowane w standardowej bibliotece Rusta. Na przykład, standardowa biblioteka implementuje cechę ToString dla każdego typu, który implementuje cechę Display. Blok impl w standardowej bibliotece wygląda podobnie do tego kodu:
impl<T: Display> ToString for T {
// --snip--
}
Ponieważ standardowa biblioteka ma tę ogólną implementację, możemy wywołać metodę to_string zdefiniowaną przez cechę ToString na dowolnym typie, który implementuje cechę Display. Na przykład, możemy zamienić liczby całkowite na odpowiadające im wartości String w ten sposób, ponieważ liczby całkowite implementują Display:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
Implementacje ogólne pojawiają się w dokumentacji cechy w sekcji „Implementacje”.
Cechy i ograniczenia cech pozwalają nam pisać kod, który używa generycznych parametrów typu, aby zmniejszyć duplikację, ale także określać kompilatorowi, że chcemy, aby generyczny typ miał określone zachowanie. Kompilator może następnie użyć informacji o ograniczeniach cech, aby sprawdzić, czy wszystkie konkretne typy używane z naszym kodem zapewniają prawidłowe zachowanie. W językach z dynamicznym typowaniem otrzymalibyśmy błąd w czasie wykonywania, gdybyśmy wywołali metodę na typie, który nie definiuje tej metody. Ale Rust przenosi te błędy do czasu kompilacji, tak abyśmy byli zmuszeni do naprawienia problemów, zanim nasz kod w ogóle będzie mógł działać. Dodatkowo, nie musimy pisać kodu, który sprawdza zachowanie w czasie wykonywania, ponieważ sprawdziliśmy to już w czasie kompilacji. Robienie tego poprawia wydajność bez konieczności rezygnacji z elastyczności generyków.