Съдържание на курса
Урок 5 — Trait-ове, generic типове и lifetime-ове
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 2 × 45 мин.
Какво изграждаш: trait-ът Revisor и една generic функция, която го използва, в отделни програми (истинският revisor не декларира никакъв trait).
Какво научаваш: trait-ове и методи по подразбиране, generic типове с ограничения, lifetime-ове и 'a, което плаши.
След края ще можеш да
- Дефинираш trait, да имплементираш договора му за два типа и да използваш метод по подразбиране.
- Разграничаваш присъща имплементация (
impl Тип, inherent impl) от имплементация на trait (impl Trait for Тип). - Напишеш generic функция с ограничения и да обясниш кога Rust генерира специализиран код.
- Избираш между generic параметър и
dyn Traitспоред това дали трябва да решиш типа при компилация или при изпълнение. - Четеш сигнатура с
'aи да обясниш кои референции са свързани от този lifetime. - Разпознаеш грешка на lifetime или на trait bound, да намериш причината ѝ и да поправиш дизайна, без да използваш ненужни копия.
Защо, преди как
До урок 4 revisor вече има полезен модел. Може да представя Servicio, да съхранява списък във Vec, да различава резултатите с Estado, да зарежда конфигурация и да докладва грешки. Все пак остава един въпрос от дизайна, който се появява всеки път, когато програмата расте: как отделяш това, което програмата трябва да направи, от конкретния начин, по който се прави?
revisor трябва да получи Estado за всяко Servicio. Днес реалната имплементация прави HTTP запитване с reqwest::Client; утре може да поискаш имплементация, която чете файл, пита база данни, измерва локален процес или симулира отговори за тест. Останалата част от програмата не бива да се налага да познава всички тези подробности. Трябва само да може да поиска: „провери тази услуга и върни състоянието ѝ“.
В Go този договор се изразява с интерфейс. Един тип удовлетворява интерфейс неявно: ако има изискваните методи, вече отговаря. Това решение прави много лесно адаптирането на съществуващи типове, но може и да скрие важни връзки. Един тип може да завърши като изпълнява интерфейс по случайност и когато четеш дефиницията му, не винаги знаеш в какви договори участва в други пакети.
Rust използва trait-ове, за да реши същия клас проблем, но изисква връзката да се декларира изрично. Един trait описва възможности; после impl Revisor for RevisorHttp декларира, че този тип отговаря на тази възможност. Това е един ред повече, но е ред, който документира архитектура. Когато го прочетеш, знаеш, че RevisorHttp не само има метод, наречен revisar: той се е ангажирал с договора Revisor.
Trait-овете не заместват struct-овете, нито enum-ите. Всеки инструмент отговаря на различен въпрос. struct казва кои данни образуват една вещ; enum казва какви валидни алтернативи съществуват; trait казва какви операции може да предлага един тип. Servicio от урок 3 си остава struct, защото моделира данни. Estado си остава enum, защото една услуга може да е здрава, бавна, да се провали или да не е била питана. Revisor е trait, защото описва операцията, която произвежда състояние.
Generic типовете правят възможно да се напише функция, която работи със семейство от типове, без да губи информация за това кой конкретен тип е получила. Функцията revisar_todos от този урок може да приеме всяко R, което имплементира Revisor. Не се нуждае от if за всяка имплементация, нито да превръща всичко в текст. Компилаторът познава конкретния тип на R при компилирането на всяко извикване и може да провери, че съществува правилният метод.
Lifetime-овете допълват този модел, когато работиш с референции. Ownership вече установи, че всяка стойност има собственик и че една референция е заемане. Един lifetime не създава друга форма на собственост и не удължава една стойност. Той е анотация, която помага на компилатора да докаже, че едно заемане ще остане валидно при всяка възможна употреба. Появява се най-вече когато една функция получава референции и връща референция или когато един struct съхранява референции.
Означението 'a стряска, защото прилича на загадъчна променлива, но се чете по-добре като етикет. Ако една функция получава две референции, означени с 'a, и връща друга, означена с 'a, тя декларира: „изходната референция зависи от тези входове и не може да се използва, след като най-краткотрайната референция престане да е валидна“. Не казва колко трае 'a; това зависи от всяко извикване. Не резервира памет и не прави събиране на боклук.
Този урок съответства на глава 10 на The Rust Book. Преди да продължиш, прочети секциите ѝ за generic типове, trait-ове и валидиране на референции и направи упражненията generics, traits и lifetimes на Rustlings. Целта не е да запаметиш всички възможни синтаксиси на bound-ове и lifetime-ове. Целта е да се научиш да разпознаваш три въпроса: какво поведение трябва на програмата, кои типове могат да го предложат и откъде идват референциите, които оцеляват след една функция.
Понятията
Trait-ове: изрични договори и методи по подразбиране
Един trait събира сигнатури на методи, които представят една възможност. Сигнатурата казва какво получава методът и какво връща, без да решава как ще свърши работата. Всеки тип, който иска да отговаря на trait-а, пише своя собствена имплементация. Затова trait прилича на интерфейс на Go, но връзката му с типа е изрична.
Фигурата дефинира Revisor с два метода. revisar няма тяло: всяка имплементация трябва да реши как да проверява една услуга. nombre има тяло и връща "revisor". Това е метод по подразбиране. Една имплементация може да го приеме такъв, какъвто е, както RevisorHttp, или да го замести, както RevisorFalso.
Фиг. 5.1 | Trait с метод по подразбиране и два типа, които го изпълняват.
// fig05_01.rs
use std::fmt;
struct Servicio {
nombre: String,
}
enum Estado {
Ok { ms: u64 },
Falla(String),
}
impl fmt::Display for Estado {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Estado::Ok { ms } => write!(f, "OK en {ms}ms"),
Estado::Falla(motivo) => write!(f, "FALLA: {motivo}"),
}
}
}
trait Revisor {
fn revisar(&self, s: &Servicio) -> Estado;
fn nombre(&self) -> String {
"revisor".to_string()
}
}
struct RevisorHttp { timeout_ms: u64 }
impl Revisor for RevisorHttp {
fn revisar(&self, s: &Servicio) -> Estado {
Estado::Falla(format!("{}: sin red en este ejemplo (límite {} ms)", s.nombre, self.timeout_ms))
}
}
struct RevisorFalso;
impl Revisor for RevisorFalso {
fn revisar(&self, _s: &Servicio) -> Estado {
Estado::Ok { ms: 1 }
}
fn nombre(&self) -> String {
"falso".to_string()
}
}
fn main() {
let s = Servicio { nombre: "catalogo".to_string() };
let http = RevisorHttp { timeout_ms: 2000 };
println!("{} -> {}", http.nombre(), http.revisar(&s));
println!("{} -> {}", RevisorFalso.nombre(), RevisorFalso.revisar(&s));
}
$ rustc --edition 2024 fig05_01.rs && ./fig05_01
revisor -> FALLA: catalogo: sin red en este ejemplo (límite 2000 ms)
falso -> OK en 1ms
impl Revisor for RevisorHttp се чете отляво надясно: „имплементира trait-а Revisor за типа RevisorHttp“. Вътре в този блок Rust изисква да се имплементира всеки метод без тяло, който trait-ът изисква. Ако пропуснеш revisar, програмата не се компилира. Ако пропуснеш nombre, се компилира, защото trait-ът вече е предоставил имплементация по подразбиране.
Методът получава &self, както методите на struct-овете от урок 3. Не консумира revisor-а и не го променя; само го заема, за да консултира данните му. revisar получава и &Servicio, защото запитването към една услуга не бива да я консумира. Резултатът, напротив, се връща по стойност: всяка проверка създава нов Estado и този, който извиква, получава неговата собственост.
Trait-ът Display от фигурата идва от стандартната библиотека. impl fmt::Display for Estado позволява да се използва {} в println!. Имплементацията решава представяне, насочено към човек: OK en 1ms или FALLA: .... Това е различно от Debug, който обикновено се получава с #[derive(Debug)] и се отпечатва с {:?} за диагностика. Ако докладът е част от интерфейса на програмата, дефинирането на Display те задължава да помислиш кой стабилен текст заслужава да вижда този, който я изпълнява.
Trait-ът не е базов клас. Не съхранява полета, не строи обекти и не наследява имплементация от родител. Може да предоставя поведение по подразбиране, но всеки тип запазва собствените си данни. RevisorHttp има timeout_ms; RevisorFalso не се нуждае от никакво поле. И двата отговарят на един и същ договор, защото и двата могат да отговарят на revisar(&Servicio).
Rust прилага правилото за кохерентност (coherence), наричано още правило за сираците (orphan rule). Можеш да имплементираш свой trait за чужд тип, например impl Revisor for String, ако имаше смисъл. Можеш и да имплементираш чужд trait за свой тип, като impl Display for Estado. Което не можеш да направиш, е да имплементираш чужд trait за чужд тип: не можеш от своя crate да решиш как трябва да имплементира Display един Vec<String>. Правилото не позволява две различни зависимости да дефинират несъвместими имплементации на един и същ договор.
Истинският revisor не декларира никакъв trait за HTTP запитванията си. Неговата функция revisar получава конкретен reqwest::Client, а интеграционните му тестове използват локален HTTP сървър вместо тестов дубликат (test double). Това е съзнателно решение: trait, който би имал една-единствена реална имплементация, още не решава никакъв проблем. Trait-ът Revisor от този урок живее в отделни програми, за да упражниш формата; проектът би се нуждаел от него в деня, в който съществуват два различни начина за проверка на една услуга. Междувременно програмата наистина използва impl, за да групира собствени методи на типовете от домейна:
impl Estado {
/// `true` si el servicio contestó bien (aunque haya sido lento).
pub fn esta_bien(&self) -> bool {
matches!(self, Estado::Ok { .. } | Estado::Lento { .. })
}
}
Този блок е присъща имплементация (inherent impl): impl Estado, без for, добавя метод, който принадлежи директно на Estado. Не имплементира trait. Разграничаването на двете форми избягва честа бъркотия: всяка имплементация на trait използва impl, но не всеки impl имплементира trait.
Един trait би бил полезен в revisor, ако приложението трябваше да разменя източника на проверките в същия дизайн. Например, един unit тест би могъл да използва фалшив revisor без мрежа. Не бива да създаваш trait само защото Rust го предлага. Абстракцията има цена на четимост: добавя договор, имплементации и решения за това как да се инжектират. Сегашният проект тества HTTP чрез локален сървър именно защото иска да провери реалното поведение на HTTP слоя.
Generic типове и ограничения: преизползване, без да се изтрива типът
Generic параметърът е променлива на тип. В fn revisar_todos<R: Revisor>(...) R не означава „каквато и да е стойност без правила“; означава „всеки тип, който имплементира Revisor“. Частта след двоеточието е ограничение (restriction), наричано още ограничение на trait (trait bound). Благодарение на него тялото на функцията може да извика r.revisar(s): компилаторът има гаранцията, че всяко допуснато R предоставя този метод.
Фиг. 5.2 | Generic функция с ограничения.
// fig05_02.rs
use std::fmt;
struct Servicio {
nombre: String,
}
enum Estado {
Ok { ms: u64 },
}
impl fmt::Display for Estado {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Estado::Ok { ms } => write!(f, "OK en {ms}ms"),
}
}
}
trait Revisor {
fn revisar(&self, s: &Servicio) -> Estado;
}
struct RevisorFalso;
impl Revisor for RevisorFalso {
fn revisar(&self, s: &Servicio) -> Estado {
Estado::Ok { ms: s.nombre.len() as u64 }
}
}
fn revisar_todos<R: Revisor>(r: &R, servicios: &[Servicio]) -> Vec<Estado> {
servicios.iter().map(|s| r.revisar(s)).collect()
}
fn imprimir<T: std::fmt::Display + Clone>(x: T) { println!("{x}"); }
fn main() {
let servicios = vec![
Servicio { nombre: "catalogo".to_string() },
Servicio { nombre: "pagos".to_string() },
];
for estado in revisar_todos(&RevisorFalso, &servicios) {
imprimir(estado.to_string());
}
}
$ rustc --edition 2024 fig05_02.rs && ./fig05_02
OK en 8ms
OK en 5ms
Функцията получава &R, не R. Това запазва собствеността на revisor-а у този, който извиква, и позволява да се използва за всички услуги. servicios: &[Servicio] е зает slice, както slice-овете, видяни при обхождането на колекции: функцията може да чете услугите, но не ги консумира и не се нуждае от копие на Vec.
map получава всяко &Servicio, извиква r.revisar(s) и произвежда итератор от състояния. collect() събира тези състояния във Vec<Estado>, защото типът на връщане го иска. Функцията е generic по отношение на revisor-а, но не по отношение на състоянието: договорът Revisor фиксира, че всяка имплементация връща Estado. Този избор е правилен, когато домейнът се нуждае от едно-единствено кохерентно представяне на резултатите.
Формата T: Display + Clone показва няколко ограничения, свързани с +. Въпреки това imprimir използва само Display; не извиква clone. Ограничението Clone е там, за да покаже синтаксиса, не защото е необходимо. В продукционен код трябва да искаш само възможностите, от които тялото се нуждае. Излишен bound изключва валидни типове и кара API-то да изглежда по-взискателно, отколкото е в действителност.
Когато bound-овете нарастват, Rust позволява да се пишат с where. Например, една дълга сигнатура може да завърши с where R: Revisor, E: std::error::Error. Това не променя поведението, нито проверката; само поставя ограниченията там, където се четат по-добре. Започни с краткия вид и използвай where, когато сигнатурата престане да е ясна.
Generic типовете на Rust обикновено се разрешават чрез мономорфизация. Ако извикаш revisar_todos с RevisorFalso и после с друг тип RevisorArchivo, компилаторът генерира специализирани версии за тези конкретни типове. По време на изпълнение не му се налага да търси метода в таблица за тези извиквания. Това се нарича статично диспечериране (static dispatch). Ползата е предвидима производителност и по-точни проверки; цената е, че всяка комбинация от типове може да увеличи компилирания код.
impl Revisor в параметър е кратък начин да се напише generic вход. Сигнатура като fn ejecutar(r: impl Revisor) е еквивалентна, за този прост случай, на fn ejecutar<R: Revisor>(r: R). Формата с <R: Revisor> е за предпочитане, когато трябва да използваш същия generic тип повече от веднъж в сигнатурата, да го върнеш или да добавиш връзки между няколко параметъра.
Когато решението за типа трябва да се вземе при изпълнение, се появява trait обектът (trait object): Box<dyn Revisor>. Един Vec<Box<dyn Revisor>> може да съхрани в същата колекция един RevisorHttp, един RevisorFalso и други revisor-и с различни размери. В замяна всяко извикване минава през индирекция, а стойността обикновено живее зад указател като Box, & или Arc. Това е най-близкият еквивалент на интерфейс на Go по време на изпълнение (динамично диспечериране).
Няма универсално по-добра опция. Използвай generic типове, когато конкретният тип е известен там, където се компилира извикването, и искаш да запазиш тази информация. Използвай dyn Trait, когато програмата трябва да избира или комбинира имплементации по време на изпълнение. В Go интерфейсите обикновено носят динамично диспечериране по обичайния си дизайн; в Rust избираш изрично между двата модела.
Реалният проект използва и generic типове от стандартната библиотека, макар да не декларира собствена функция с <T>. Option<T> изразява, че може да има или да няма стойност от какъвто и да е тип, и тук се специализира като Option<u16> за един HTTP код:
#[derive(Serialize)]
pub struct EstadoJson {
pub servicio: String,
pub codigo: Option<u16>,
pub ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
Option<u16> и Option<String> са различни употреби на един и същ generic тип. Първият позволява да се представи, че една повреда не е имала HTTP код; вторият позволява да се пропусне полето за грешка, когато една услуга е отговорила добре. Generic типовете не са само техника за сложни библиотеки: Vec<T>, Option<T>, Result<T, E> и HashMap<K, V> са част от ежедневната работа в Rust.
Lifetime-ове: описание на заемания, които се свързват
Един lifetime е област на валидност на една референция. Почти винаги Rust го извежда, както извежда много локални типове. Трябва да напишеш анотация, когато сигнатурата би могла да допусне няколко връзки между референции и компилаторът не може да знае коя гарантира дизайнът.
Фигурата връща една от две референции. Без анотация сигнатурата не може да съобщи дали резултатът идва от a, от b или от друго място. Като означиш трите референции с 'a, декларираш, че резултатът ще е валиден в период, който не може да надвишава този на нито един избран вход.
Фиг. 5.3 | Lifetime, който свързва изхода с двата входа.
// fig05_03.rs
fn mas_largo<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
println!("{}", mas_largo("catalogo", "pagos"));
}
$ rustc --edition 2024 fig05_03.rs && ./fig05_03
catalogo
'a не означава „живее вечно“, нито „живее точно толкова, колкото двата входа“. То е име за една връзка. При конкретно извикване Rust изчислява lifetime, който се побира във валидните заемания. Ако a трае десет реда, а b трае три, резултатът ще може да се използва само през трите съвместими реда. Сигнатурата не позволява някой да запази референция към резултата и после да унищожи стойността, от която произлиза.
Функцията не избира кой низ трае по-дълго. Това зависи от областите на видимост (scope) на този, който извиква, не от броя на буквите, нито от стойност, съхранена в паметта. mas_largo сравнява дължини, за да избере съдържание, но lifetime-ът говори за валидност на референции. Това са два различни въпроса, които случайно се появяват в една и съща функция.
Rust има правила за елизия (elision), които правят невидими много lifetime-ове. Например fn nombre(s: &str) -> &str се компилира, без да се пише 'a, защото има само една входна референция и Rust може да свърже изхода с нея. Обикновено извежда и lifetime-ове на методи, които получават &self. Когато има два възможни входа, както в mas_largo, вече не е безопасно да се налучква и трябва да опишеш връзката.
Не добавяй 'a към всичко, което изглежда сложно. Анотацията не поправя невалидна референция; само декларира връзка, която компилаторът ще провери. Ако се опиташ да върнеш референция към локален String, не съществува lifetime, който да направи това заемане валидно. String се унищожава при края на функцията. Решението е да върнеш String по стойност, да получиш референция, която принадлежи на този, който извиква, или да преработиш кой притежава данната.
Специалният lifetime 'static изисква внимание. Референция &'static str обикновено сочи към литерален текст, включен в бинарния файл, като "OK" или "FALLA". Не означава „използвай 'static, за да махнеш грешките“. Налагането на 'static върху данна, която в действителност живее кратко, не я кара да трае по-дълго; компилаторът ще го отхвърли. Използвай 'static само когато стойността наистина живее през цялото изпълнение.
Истинският revisor използва lifetime-ове там, където наистина трябват: във временен ред, който заема едно Servicio и съответния му Estado, за да се подреди докладът. Не копира тези стойности само за да ги подреди. Строи референции, съхранява ги в локален вектор и оставя компилатора да провери, че векторът не оцелява след източниците си.
pub type Fila<'a> = (&'a Servicio, &'a Estado);
fn ordenadas<'a>(servicios: &'a [Servicio], estados: &'a [Estado]) -> Vec<Fila<'a>> {
let mut filas: Vec<Fila<'a>> = servicios.iter().zip(estados).collect();
filas.sort_by(|a, b| a.0.nombre.cmp(&b.0.nombre));
filas
}
Fila<'a> е псевдоним на кортеж от две референции. Не притежава Servicio нито Estado; само ги заема. ordenadas получава два slice-а със същия анотиран lifetime и връща редове, които също носят този lifetime. Следователно никой не може да запази редовете, след като оригиналните вектори изчезнат. Векторът от редове може да променя реда си, защото е собственик на вектора, но не може да променя услугите, нито състоянията, защото само ги заема.
Същата функция показва практична причина да предпочиташ референции: избягва клонирането на информация само за да се покаже подредена. Клонирането би било валидно, ако ти трябваше независима колекция, която да оцелее след доклада, но тук не е нужно. Докладът приключва с filas, преди да приключат servicios и estados, така че заеманията изразяват точно модела на данните.
Когато един lifetime се появява в struct или псевдоним, не бива да го четеш като церемониален синтаксис. Попитай: „този тип съхранява ли референция?“ Ако отговорът е да, анотацията свързва типа с продължителността на заетата стойност. Ако отговорът е не, вероятно типът трябва да притежава String, Vec<T> или друга стойност и не се нуждае от изричен lifetime.
Грешката, която ще видиш
E0515: връщане на референция към локална стойност
Тази грешка се появява, когато една функция се опита да заеме нещо, което престава да съществува при връщането. Следващата фигура се проваля нарочно. Lifetime-ът 'a на сигнатурата не може да спаси nombre: този String е собственост на devolver и се унищожава при затварянето на функцията.
Фиг. 5.4 | Заемане, което се опитва да избяга от стойността, която го притежава.
// fig05_04.rs
fn devolver<'a>() -> &'a str {
let nombre = String::from("catalogo");
&nombre
}
fn main() {
println!("{}", devolver());
}
$ rustc --edition 2024 fig05_04.rs
error[E0515]: cannot return reference to local variable `nombre`
--> fig05_04.rs:4:5
|
4 | &nombre
| ^^^^^^^ returns a reference to data owned by the current function
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0515`.
Съобщението сочи точно референцията, която се опитва да избяга. Не добавяй друг lifetime и не използвай &'static str: никое от двете не променя кой притежава nombre. Ако функцията трябва да създаде текста, поправката е да върне String. Ако трябва да върне гледка към текст, който вече е съществувал, получавай &str като параметър и свържи изходния lifetime с този вход.
E0277: типът не отговаря на поисканото ограничение
Trait bound е също проверим договор. На тази фигура imprimir иска тип, който имплементира Display, но Vec<&str> няма тази имплементация. Rust не го превръща в текст неявно, защото не съществува едно-единствено правилно представяне за всички колекции.
Фиг. 5.5 | Аргумент, който не отговаря на trait bound.
// fig05_05.rs
use std::fmt::Display;
fn imprimir<T: Display>(valor: T) {
println!("{valor}");
}
fn main() {
imprimir(vec!["catalogo"]);
}
$ rustc --edition 2024 fig05_05.rs
error[E0277]: `Vec<&str>` doesn't implement `std::fmt::Display`
--> fig05_05.rs:9:14
|
9 | imprimir(vec!["catalogo"]);
| -------- ^^^^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `Vec<&str>`
| |
| required by a bound introduced by this call
|
note: required by a bound in `imprimir`
--> fig05_05.rs:4:16
|
4 | fn imprimir<T: Display>(valor: T) {
| ^^^^^^^ required by this bound in `imprimir`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
E0277 казва, че не е изпълнено ограничение на trait. Бележката те води до сигнатурата, която е въвела изискването. Поправката зависи от намерението: можеш да отпечаташ с {:?}, ако искаш представяне за отстраняване на грешки и типът имплементира Debug; можеш да обходиш вектора и да отпечаташ всеки елемент; или можеш да го превърнеш изрично в String с формата, който е нужен на твоя доклад. Не имплементирай Display за чужд тип само за да заглушиш грешката: правилото за сираците ще го попречи и освен това би било глобално решение, трудно за оправдаване.
Какво се прави погрешно
Създаване на trait за всеки struct
Един trait трябва да представя споделена възможност, не да повтаря името на един тип. Ако съществува само една имплементация и няма конкретна причина да се разменя, присъщият метод обикновено е по-ясен. impl Estado { fn esta_bien(...) } изразява, че операцията принадлежи естествено на Estado. Създаването на trait EstadoConsultable за една-единствена функция само добавя имена и файлове, без да отделя реална зависимост.
Започни със struct-ове, enum-и и директни функции. Извлечи trait, когато няколко имплементации трябва да изпълняват един и същ договор, когато трябва да получиш възможност вместо конкретен тип или когато граница за тестове наистина го оправдава.
Добавяне на bound-ове „за всеки случай“
Обичайно е да копираш сигнатура като T: Clone + Debug + Display и да запазиш всички bound-ове, макар тялото да използва само Display. Всеки bound ограничава типовете, които могат да извикат функцията. Освен това всяка възможност, обещана от една сигнатура, става част от API-то, което другите трябва да разбират.
Искай Clone само ако тялото извиква clone, Ord само ако подрежда и Send само ако премества данни към друга нишка. Малкият bound е по-гъвкава абстракция и описва по-добре реалната нужда. Фигура 5.2 запазва Clone, за да покаже, че ограниченията могат да се комбинират, но не е модел за буквално копиране.
Използване на Box<dyn Trait> по навик
Trait обектът решава реален проблем: да се съхраняват или избират различни имплементации по време на изпълнение. Не е задължителният начин за използване на trait-ове. Ако типът е известен при компилация, generic параметърът обикновено е по-прост, избягва ненужни заделяния в хийпа и позволява статично диспечериране.
Полезният въпрос не е „trait-ове или generic типове?“. Един trait описва възможност; после избираш дали да я получаваш с generic типове, като impl Trait, чрез референция &dyn Trait или зад Box<dyn Trait>. Изборът зависи от собствеността, размера и момента, в който познаваш типа.
Клониране за заглушаване на грешки на borrow checker
Ако една референция не живее достатъчно дълго, копирането на String с .clone() може да накара програмата да се компилира, но не винаги решава правилния дизайн. Понякога само скрива, че една функция трябва да върне референция, че един тип трябва да притежава данните си или че едно заемане трае повече от необходимото.
Първо направи диагностиката: идентифицирай собственика, идентифицирай кой има нужда да използва данната после и реши дали ти трябва гледка или независимо копие. Клонирай, когато двама законни собственици трябва да запазят отделни стойности. Векторът от редове на revisor не клонира услуги нито състояния, защото само трябва да ги подреди, докато собствениците им са живи.
Четене на 'a като конкретна продължителност
'a не означава една секунда, фиксирана област на видимост, нито променлива, създадена в началото на програмата. То е етикет, който Rust замества с валидна област при всяко извикване. Две функции могат да използват името 'a, без да споделят абсолютно нищо; името има значение само в собствената си сигнатура.
Грешка е и да мислиш, че повече анотации са по-безопасни. Анотациите трябва да отразяват откъде идва една референция. Ако не можеш да обясниш коя входна референция подкрепя изхода, вероятно функцията трябва да върне собствена стойност вместо референция.
Упражнения
Упражнение 1 — Фалшив revisor с име по подразбиране
Дефинирай trait Revisor с revisar(&self, servicio: &Servicio) -> Estado и метод по подразбиране nombre() -> String. Създай RevisorFalso, който връща Estado::Ok { ms: 1 }, без да заменя nombre. Провери, че отпечатва revisor -> OK en 1ms.
После добави RevisorArchivo, който замества nombre с "archivo" и връща детерминирана повреда. Обясни с едно изречение защо двата типа могат да се използват там, където се очаква Revisor.
Упражнение 2 — Преброяване на здравите резултати по generic начин
Използвай trait-а от фигура 5.1 и напиши функция contar_sanos<R: Revisor>. Трябва да получава revisor и slice от услуги, да ги проверява и да връща колко състояния са Ok. Изпробвай функцията с три услуги и RevisorFalso.
Преди да програмираш, реши какво трябва да притежава всяка част: функцията не бива да консумира revisor-а нито вектора с услуги. Използвай &R и &[Servicio], не клонирай.
Упражнение 3 — Прочети lifetime-а на доклада
Отвори programas/revisor/src/reporte.rs и намери Fila<'a> и ordenadas<'a>. Напиши със свои думи какво притежава Vec<Fila<'a>>, какво взема назаем и какво би станало, ако се опиташ да върнеш тези редове, след като унищожиш servicios или estados.
После напиши функция primero<'a>, която получава &'a str и връща &'a str. Сравни я с devolver от фигура 5.4 и обясни защо едната се компилира, а другата не.
Решения
Решение 1
Имплементацията, която приема метода по подразбиране, не пише nombre; trait-ът предоставя тялото му. Втората имплементация го замества, защото се нуждае от друг етикет.
trait Revisor {
fn revisar(&self, servicio: &Servicio) -> Estado;
fn nombre(&self) -> String {
"revisor".to_string()
}
}
struct RevisorFalso;
struct RevisorArchivo;
impl Revisor for RevisorFalso {
fn revisar(&self, _servicio: &Servicio) -> Estado {
Estado::Ok { ms: 1 }
}
}
impl Revisor for RevisorArchivo {
fn revisar(&self, servicio: &Servicio) -> Estado {
Estado::Falla(format!("{}: no existe el archivo", servicio.nombre))
}
fn nombre(&self) -> String {
"archivo".to_string()
}
}
И двата типа могат да се използват там, където се очаква Revisor, защото и двата са написали impl Revisor for ... и предоставят задължителния метод revisar. Разликата между техните данни и техния алгоритъм остава капсулирана във всяка имплементация.
Решение 2
Функцията взема заемания, защото само трябва да консултира данните. Всеки резултат е временен: брои се и се изхвърля. Няма причина да се съхранява Vec<Estado> нито да се клонира revisor-ът или услугите.
fn contar_sanos<R: Revisor>(revisor: &R, servicios: &[Servicio]) -> usize {
servicios
.iter()
.filter(|servicio| matches!(revisor.revisar(servicio), Estado::Ok { .. }))
.count()
}
iter() произвежда &Servicio; closure-ът получава всяко заемане и извиква trait-а чрез &R. matches! решава дали състоянието принадлежи на варианта Ok, а count() връща общия брой. Ако Estado имаше и Lento, трябва да решиш изрично дали се брои за здраво; истинският revisor отговаря на този въпрос с Estado::esta_bien().
Решение 3
Vec<Fila<'a>> притежава вектора и реда на елементите си, но не притежава услугите нито състоянията. Всеки елемент съдържа две референции. Затова редовете му могат да живеят само докато са живи slice-овете, заети на ordenadas. Опитът да ги върнеш, за да ги използваш, след като оригиналните колекции са унищожени, би произвел грешка на borrow checker: биха били висящи референции.
Правилната функция връща референция, която принадлежи на този, който извиква:
fn primero<'a>(texto: &'a str) -> &'a str {
texto
}
primero не създава текста и не се опитва да го заеме, след като го унищожи. Само връща същото заемане, което е получила. Напротив, devolver създава локален String, е негов собственик и го унищожава при излизане; затова референцията от фигура 5.4 не може да избяга.
Как да разбера, че съм успял
rustc --edition 2024 fig05_01.rs && ./fig05_01отпечатва двата документирани реда, включително етикета по подразбиране наRevisorHttp.rustc --edition 2024 fig05_02.rs && ./fig05_02отпечатваOK en 8msиOK en 5ms.rustc --edition 2024 fig05_03.rs && ./fig05_03отпечатваcatalogo.rustc --edition 2024 fig05_04.rsсе проваля сerror[E0515]; можеш да обясниш защо смяната на'aне поправя локалното заемане.rustc --edition 2024 fig05_05.rsсе проваля сerror[E0277]; можеш да намериш както неправилното извикване, така и bound-а, който го е отхвърлил.- Можеш да посочиш
Fila<'a>вprogramas/revisor/src/reporte.rsи да обясниш, че заема услуги и състояния вместо да ги клонира. - Завърши упражненията
generics,traitsиlifetimesна Rustlings.
За по-нататъшно четене
- The Rust Programming Language, глава 10.1: синтаксис на generic типове, консултирано на 2 октомври 2026 г.
- The Rust Programming Language, глава 10.2: trait-ове, консултирано на 2 октомври 2026 г.
- The Rust Programming Language, глава 10.3: валидиране на референции с lifetime-ове, консултирано на 2 октомври 2026 г.
- Rustlings: упражнения за generic типове, trait-ове и lifetime-ове, консултирано на 2 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx