В каждом языке программирования содержатся инструменты, которые эффективно отрабатывают концепции дублирования. В Rust одним из таких инструментов являются generic-типы: абстрактные дублеры для конкретных типов или других свойств. Мы можем выразить поведение generics, или то, как относятся к другим generics, не зная, что будет на их месте при компиляции и запуске кода.
Примечание: на мой взгляд generic-типы чем-то перекликаются с шаблонами на языке C++ [5]. Причем используется похожий синтаксис - угловые скобки для параметров абстрактного типа. Шаблоны C++ также призваны устранять дублирование кода, когда компилятор сам генерирует код для различных вариантов типов данных, с которыми был вызван шаблон.
Функции могут принимать параметры некоторого generic-типа вместо конкретного типа наподобие i32 или String, таким же образом как функция принимает параметры неизвестного значения, чтобы запустить один и тот же код на нескольких конкретных значениях. Фактически мы уже использовали generic-и в главе 6 вместе с Option< T> (см. [2]), в главе 8 вместе с Vec< T> и HashMap< K, V> (см. [3]), а также в главе 9 вместе с Result< T, E> (см. [4]). В этой главе мы рассмотрим, как определить свои собственные типы, функции и методы с использованием generics.
Сначала мы рассмотрим, как извлечь функцию для уменьшения дублирования кода. Затем будем использовать ту же технику для создания универсальной generic-функции из двух функций, которые отличаются только типами своих параметров. Также мы объясним, как использовать generic-типы в определениях структур (struct) и перечислений (enum).
Затем вы научитесь использовать трейты (traits) для определения поведения универсальным (generic) способом. Вы сможете также комбинировать трейты с generic-типами, чтобы ограничить generic-тип принятием только тех типов, у которых специфическое поведение, в противоположность просто любому типу.
И наконец, мы обсудим понятие времен жизни (lifetimes): различные generic-и дают компилятору информацию о том, как ссылки соотносятся друг с другом. Lifetimes позволяют нам дать компилятору достаточно информации о заимствованных (borrowed) значениях, чтобы он мог гарантировать, что ссылки будут достоверны в большем количестве ситуаций, чем он смог бы без нашей помощи.
[Формирование функции как способ устранения дублирования кода]
Generic-и позволяют вам заменить определенные типы некой сущностью, которая представляет сразу несколько типов. Все это для того, чтобы устранить дублирование кода, что было всегда проклятием программирования. Прежде чем углубляться в generic-синтаксис, давайте сначала посмотрим, как удалить дублирование таким образом, чтобы не включать generic-типы, путем извлечения функции, которая заменяет определенные значения некой сущностью, представляющей несколько значений. Затем мы применим ту же технику, чтобы извлечь generic-функцию. Посмотрев на то, как распознать дублированный код, который вы можете извлечь в функцию, вы начнете распознавать дублированный код, который может использовать generic-и.
Мы начнем с короткой программы в листинге 10-1, которая ищет самое большое число в списке.
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("Самое большое число {largest}");
}
Листинг 10-1. Поиск самого большого числа в списке чисел.
Мы сохранили список целых чисел в переменную number_list, и поместили ссылку на первое число в списке в переменную largest. Затем мы перебираем все числа в списке, и если текущее число больше, чем число, сохраненное в largest, то заменяем ссылку в этой переменной. Однако если текущее число меньше или равно самому большому числу, встретившемуся до сих пор, то переменная largest не меняется. После рассмотрения всех чисел в списке самое переменная largest должна ссылаться на самое большое число, которое в этом примере 100.
Теперь у нас задача найти самое большое число в двух разных списках чисел. Для этого мы выбрали дублировать код в листинге 10-1 и использовать одну и ту же логику в разных местах программы, как показано в листинге 10-2.
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("Самое большое число {largest}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("Самое большое число {largest}");
}
Листинг 10-2. Код для нахождения самого большого числа в двух списках чисел.
Хотя этот код работает, дублирование кода это утомительно и подвержено ошибкам. Также есть необходимость помнить, что следует обновить код в нескольких местах, когда мы хотим изменить его.
Чтобы устранить это дублирование, мы создадим абстракцию путем определения функции, которая работает на любом списке, переданном в качестве параметра. Это решение делает наш код более понятным, и позволяет нам абстрактно выразить концепцию поиска наибольшего числа в списке.
В листинге 10-3 мы выделили код поиска самого большого числа в функцию largest. Затем мы вызвали функцию два раза чтобы найти самое большое число в каждом из двух списков.
fn largest(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("Самое большое число {result}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let result = largest(&number_list);
println!("Самое большое число {result}");
}
Листинг 10-3. Абстрактный код для нахождения самого большого числа в двух списках.
У функции largest есть параметр list, который представляет собой конкретный слайс значений i32, его можно будет передать в функцию. В результате, когда мы вызываем функцию, этот код будет работать с определенным набором переданных значений.
Суммарно для перехода от листинга 10-2 к листингу 10-3 были предприняты следующие шаги:
1. Идентификация дублированного кода. 2. Извлечение дублированного кода и создание из него тела функции. Определение для функции параметров и возвращаемого значения и определение этого в сигнатуре функции. 3. Обновление мест, где был дублированный код, путем его замена на вызов функции.
Далее мы будет использовать те же самые шаги с generic-ами для уменьшения дублирования кода. Точно так же, как тело функции может работать с абстрактными, заранее не известными значениями, generic-и позволяют коду работать с абстрактными типами.
Например, скажем у нас есть 2 функции: одна из них находит самый большой элемент в слайсе значений i32, а другая ищет самый большой элемент в слайсе значений char. Как мы бы устранили это дублирование? Давайте узнаем!
[Generic-типы данных]
Мы используем generic-и для создания определений элементов наподобие сигнатур функций или структур, которые мы можем использовать с многими разными конкретными типами. Сначала посмотрим, как определить функции, структуры, перечисления и методы с использованием generic-ов. Затем обсудим, как generic-и влияют на производительность кода.
Generic-и в определении функции. Когда мы определяем функцию, которая использует generic-и, мы помещаем generic-и в сигнатуру функции, где мы должны обычно указать типы данных параметров и возвращаемого значения. Поступая таким образом, мы делаем наш код более гибким и функциональным для кода, вызывающего нашу функцию, и при этом снижается дублирование кода.
Если продолжить с нашей функцией largest, то в листинге 10-4 мы увидим 2 функции. Каждая из них ищет самое большое значение в слайсе разных типов. Мы объединим их в одну функцию, которая использует generic-и.
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("Самое большое число {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("Самый большой символ {result}");
}
Листинг 10-4. Две функции, которые отличаются только типами, которые они обрабатывают (сигнатурами функции).
Функция largest_i32 ищет самое большое значение i32 в слайсе. Функция largest_char ищет самое большое значение char в слайсе. У обоих функций код тела одинаковый, поэтому давайте устраним дублирование кода путем введения параметра generic-типа в одной функции, которая заменит эти обе.
Чтобы параметризировать типы в новой одной функции нам нужно имя для типа параметра, точно так же, как мы это делаем для значения параметров функции. Мы можем использовать любой идентификатор в качестве имени типа параметра. Если параметр один, то обычно используют в качестве имени букву T. По соглашению имена типа параметров в Rust короткие, часто только однобуквенные, и представлены либо заглавной буквой, либо составляются по принципу UpperCamelCase. Сокращение T от "type" служит выбором по умолчанию для большинства программистов Rust.
Когда мы используем параметр в теле функции, мы должны декларировать имя параметра в сигнатуре, чтобы компилятор знал, что означает это имя. Точно так же, когда мы используем имя типа параметра, мы должны декларировать имя типа параметра перед его использованием. Чтобы определить generic-функцию largest, мы помещаем декларации имен типа внутри угловых скобок < >, между именем функции и списком параметров, примерно так:
fn largest< T>(list: &[T]) -> &T {
Эту декларацию мы читаем следующим образом: функция largest generic-типа, работающая на каком-то типе T. У этой функции один параметр list, который является слайсом значений типа T. Функция largest возвратит ссылку на значение того же самого типа T.
Листинг 10-5 покажет комбинированное определение функции largest, использующую generic-тип данных в своей сигнатуре. Листинг также показывает, как мы можем вызвать эту функцию либо со слайсом значений i32, либо со слайсом значений char. Этот код пока не скомпилируется, что мы исправим дальше в этой главе.
fn largest< T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("Самое большое число {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("Самый большой символ {result}");
}
Листинг 10-5. Функция largest, использующая параметры generic-типа; этот код пока не скомпилируется.
Если мы скомпилируем этот код прямо сейчас, то получим следующую ошибку:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T`
|
1 | fn largest< T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Текст help упоминает про std::cmp::PartialOrd, который является трейтом, и мы поговорим про трейты в следующей секции. Пока что знайте, что эта ошибка фиксирует, что тело функции largest не будет работать для всех возможных типов, которым мог бы быть тип T. Поскольку мы хотим сравнивать значения типа T в теле функции, то мы можем использовать только те типы, которые можно упорядочить. Чтобы разрешить сравнения, стандартная библиотека содержит трейт std::cmp::PartialOrd, который вы можете реализовать на типах (см. приложение Appendix C для дополнительной информации про этот трейт). Следуя рекомендации текста help сообщения об ошибке, мы ограничим допустимые типы для T, чтобы они были только те, где реализован PartialOrd, и тогда этот пример скомпилируется, потому что что стандартная библиотека реализует PartialOrd и для i32, и для char.
Generic-и в определении структуры. Мы также можем определить структуры для использования параметра generic-типа в одном или большем количестве полей с использованием синтаксиса < >. Листинг 10-6 определяет структуру Point< T> для хранения x и y значений координат любого типа.
struct Point< T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
Листинг 10-6. Структура Point< T>, которая хранит в себе значения x и y типа T.
Синтаксис для использования generic-ов в определениях структуры подобен синтаксису, используемому в определениях функций. Сначала мы декларируем имя типа параметра внутри угловых скобок, сразу после имени структуры. Затем мы используем generic-тип в определении структуры, где иначе мы должны указывать конкретные типы данных.
Обратите внимание, что потому что мы используем только один generic-тип для определения Point< T>, то это определение говорит, что структура Point< T> является generic по некоторому типу T, и поля x и y оба того же типа, каким бы ни был этот тип. Если мы создадим Point< T>, у которого значения разных типов, как в листинге 10-7, то наш код не скомпилируется.
struct Point< T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
Листинг 10-7. Поля x и y должны быть одинакового типа, потому что у них оба один и тот же generic-тип данных T.
В этом примере, когда назначаем целое значение 5 для x, мы говорим компилятору, что generic-тип T будет целочисленным для экземпляра Point< T>. Затем мы указываем 4.0 для y, который мы определили, что у него такой же тип, что и x, и в результате получим ошибку несоответствия типа наподобие следующей:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Чтобы определить структуру Point, где x и y оба generic-и, но могли бы иметь разные типы параметров, мы можем использовать несколько параметров generic-типа. Например, в листинге 10-8, мы поменяли определение Point для generic через типы T и U, где x типа T, а y типа U.
struct Point< T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
Листинг 10-8. Point< T, U> generic по двум типам, так что x и y могут быть значениями разных типов.
Теперь все показанные экземпляры Point стали допустимыми! Вы можете использовать множество параметров generic-типа в определении, как пожелаете, однако большее количество, чем несколько, делает код слишком сложным для чтения. Если вы обнаружили, что нужно применить довольно много generic-типов в вашем коде, то вероятно это показывает, что код требует реструктуризации с разбиением на несколько небольших частей.
Generic-и в определении перечисления. Как мы делали со структурами, также мы можем определить перечисления для хранения generic-типов данных в своих вариантах. Давайте посмотрим для примера на перечисление Option< T> из стандартной библиотеки, которое мы использовали в главе 6 (см. [2]):
enum Option< T> {
Some(T),
None,
}
Теперь это определение должно стать для вас понятнее. Как вы можете видеть, перечисление Option< T> является generic-перечислением по типу T, и у него 2 варианта: Some, где хранится одно значение типа T, и вариант None, который не хранит никакое значение. Путем использования перечисления Option< T> мы можем выразить абстрактную концепцию опциональным значением, а поскольку Option< T> это generic, мы можем использовать эту абстракцию независимо от типа опционального значения.
Перечисления также могут использовать несколько generic-типов. Определение перечисления Result, которое мы использовали в главе 9 (см. [4]), это один из примеров:
enum Result< T, E> {
Ok(T),
Err(E),
}
Перечисление Result является generic по двум типам, T и E, и в нем 2 варианта: Ok, которое хранит значение типа T, и Err, которое хранит значение типа E. Это определение удобно для использования Result в любом месте, где операция может быть успешной (тогда возвращаемое значение будет некоторого типа T), или неудачной (будет возвращена ошибка некоторого типа E). Фактически это то, что мы использовали для открытия файла в листинге 9-3, где T заполнялся типом std::fs::File, когда файл был успешно открыт, и E заполнялось типом std::io::Error, когда происходили проблемы с открытием файла.
Когда мы встречаемся с ситуациями в коде, в которых несколько определений структур или перечислений отличаются только типами значений, которые они хранят, вы можете избежать дублирования путем использования для них generic-типов.
Generic-и в определении метода. Мы можем реализовать методы на структурах и перечислениях (как мы делали в главе 5, см. [6]) и тоже использовать generic-типы в их определениях. Листинг 10-9 показывает структуру Point< T>, которую мы определили в листинге 10-6, с методом x, реализованном на ней.
struct Point< T> {
x: T,
y: T,
}
impl< T> Point< T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
Листинг 10-9. Реализация метода x на структуре Point< T>, который возвращает ссылку на поле x типа T.
Здесь мы определили метод с именем x на Point< T>, который возвращает ссылку на данные в поле x.
Обратите внимание, что мы должны декларировать T сразу после impl, чтобы мы мы могли использовать T для указания, что реализуем методы для типа Point< T>. Если объявить T как generic-тип после impl, то Rust может определить, что этот тип в угловых скобках Point это generic-тип, а не конкретный тип. Мы могли бы выбрать другое имя для этого generic-параметра, отличающееся от generic-параметра в определении структуры, однако использование такого же имени является обычным. Методы, написанные в impl, который декларирует generic-тип, будет определен на любом экземпляре типа, независимо от того, какой конкретный тип в конечном итоге заменяет generic-тип.
Мы также можем указать ограничения на generic-типах, когда определяем методы на типе. Мы могли бы, например, реализовать методы только на экземплярах Point< f32> вместо экземпляров Point< T> любого generic-типа. В листинге 10-10 мы использовали тип f32, что означает, мы не декларируем любые типы после impl.
impl Point< f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
Листинг 10-10. Блок impl, который только применяется к структуре определенного конкретного типа для параметра T generic-типа.
Этот код означает, что тип Point< f32> будет иметь метод distance_from_origin; другие экземпляры Point< T>, где T не тип f32, не будут иметь определенным этот метод. Этот метод измеряет, как далеко наша точка находится от точки с координатой (0.0, 0.0), и использует математические операции, доступные только для типов плавающей точки.
Параметры generic-типа в определении структуры не всегда совпадают с параметрами, используемыми в сигнатурах методов той же структуры. Листинг 10-11 использует generic-типы X1 и Y1 для структуры Point, и X2, Y2 для сигнатуры метода mixup, чтобы сделать пример понятнее. Метод создает новый экземпляр Point со значением x из self Point (типа X1) и значение y из переданной в него Point (типа Y2).
struct Point< X1, Y1> {
x: X1,
y: Y1,
}
impl< X1, Y1> Point< X1, Y1> {
fn mixup< X2, Y2>(self, other: Point< X2, Y2>) -> Point< X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Листинг 10-11. Метод, который использует generic-типы, отличающиеся от его определения структуры.
В main мы определили Point, у которой i32 для x (со значением 5), и f64 для y (со значением 10.4). Переменная p2 это структура Point, у которой есть слайс строки для x (со значением "Hello") и char для y (со значением c). Вызов mixup на p1 с аргументом p2 дает нам p3, которая имеет i32 для x, потому что x пришло от p1. Переменная p3 будет иметь char для y, потому что y пришло от p2. Вызов макроса println! напечатает p3.x = 5, p3.y = c.
Назначение этого примера - продемонстрировать ситуацию, в которой некоторые generic-параметры, декларированные с impl, и некоторые, декларированные с определением метода. Здесь generic-параметры X1 и Y1 декларированы после impl, потому что они поступили с определением структуры. Generic-параметры X2 и Y2 декларированы после fn mixup, потому что они относятся к методу.
Производительность кода, использующего generic-и. Вам может быть интересно, какую цену в ущерб производительности мы платим за применение параметров универсального типа. Хорошей новостью является то, что использование generic-типов не приводит к замедлению вашей программы по сравнению с использованием конкретных типов.
Rust достигает этого путем выполнения мономорфизации кода, использующего generic-и, производимой во время компиляции. Мономорфизация это процесс преобразования generic-кода в специфический код путем заполнения его конкретными типами, которые использовались при компиляции. В этом процессе компилятор делает противоположные шаги тем, что мы использовали при создании generic-фунции в листинге 10-5: компилятор находит все места, где вызывается generic-код, и генерирует код для конкретных типов, с которыми вызывается generic-код.
Давайте посмотрим, как это работает при использовании generic-перечисления Option< T> из стандартной библиотеки:
let integer = Some(5); let float = Some(5.0);
Когда Rust компилирует этот код, он выполняет мономорфизацию. Во время этого процесса компилятор читает значения, которые использовались в экземплярах Option< T>, и идентифицирует два вида Option< T>: один i32, а другой f64. Таким образом, он расширяет generic-определение Option< T> на две специализации для i32 и f64, тем самым заменяя общее generic-определение на конкретные.
Мономорфизированная версия кода выглядит подобно следующему (компилятор использует другие имена, которые мы использовали здесь для иллюстрации):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Generic-перечисление Option< T> заменяется на специфические определения, созданные компилятором. Поскольку Rust компилирует generic-код в код, который указывает тип для каждого экземпляра, то мы не платим за это runtime производительностью для использования generic. Когда код запускается, он работает точно так же, как если бы мы дублировали каждое определение вручную. Процесс мономорфизации делает Rust generic экстремально эффективным runtime.
[Трейты: определение общего поведения]
Трейт (trait) определяет функциональность, которой обладает определенный тип, и которую можно использовать совместно с другими типами. Мы можем использовать трейты для абстрактного определения общего поведения. Мы можем использовать границы трейтов (trait bounds) чтобы указать, что generic-тип может быть любым типом, имеющим определенное поведение.
Примечание: трейты подобны функционалу, часто называемому интерфейсом на других языках программирования, хотя существуют некоторые отличия.
Определение трейта. Поведение типа состоит из методов, которые мы можем вызвать на этом типе. Различные типы имеют одинаковое поведение, если мы можем вызвать одни и те же методы для всеэ этих типов. Определения трейта это способ группирования сигнатур методов вместе, чтобы определить набор поведений, необходимых для достижения определенной цели.
Например предположим, что у нас есть несколько структур, которые содержат различные виды и объемы текста: структура NewsArticle, где хранится поле новостей в определенном месте, и Tweet, у которой может быть как минимум 280 символов с метаданными, показывающими что это был новый твит, ре-твит, или ответ на другой твит.
Мы хотим создать крейт библиотеки медиа-агрегатора с именем aggregator, который может отображать данные, которые могут быть сохранены в экземпляре NewsArticle или экземпляре Tweet. Чтобы это сделать, нам нужна сводка (summary) из каждого типа, и мы будем запрашивать эту сводоку вызовом метода summarize на каждом экземпляре. Листинг 10-12 показвыает определение публичного трейта Summary, который выражает это поведение.
pub trait Summary {
fn summarize(&self) -> String;
}
Листинг 10-12. Трейт Summary, который содержит поведение, предоставляемое методом summarize (файл src/lib.rs).
Здесь мы декларируем трейт с помощью ключевого слова trait, и затем указываем имя трейта, в нашем случае Summary. Такжt мы декларируем трейт как pub, чтобы крейты, зависящие от этого крейта, могли также использовать этот трейт, как мы увидим в нескольких примерах. Внутри фигурных скобок мы декларируем сигнатуры методов, которые описывают поведения типов, реализуемых этим трейтом, в нашем случае это fn summarize(&self) -> String.
После метода signature, вместо предоставления реализации в фигурных скобок, мы используем точку с запятой. Каждый тип, реализующий этот трейт, должен предоставить его собственную пользовательскую версию поведения для телам метода. Компилятор обеспечит, что любой тип, в котором есть трейт Summary, будет иметь метод summarize именно с такой сигнатурой.
Трейт может иметь несколько методов в своем теле: сигнатуры методов перечисляются по одному в каждой строке, и каждая строка заканчивается точкой с запятой.
Реализация трейта на типе. Теперь, когда мы определили желаемые сигнатуры методов трейта Summary, можно реализовать его на типах в нашем медиа-агрегаторе. Листинг 10-13 показывает реализацию трейта Summary на структуре NewsArticle, в которой есть поля заголовка headline, автора author и места расположения location, чтобы создать возвращаемое значение summarize. Для структуры Tweet мы определяем summarize как имя пользователя username, за которым идет весь текст твита, подразумевая что содержимое твита находится в поле content, ограниченном по длине в 280 символов.
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 Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Листинг 10-13. Реализация трейта Summary на типах NewsArticle и Tweet (файл src/lib.rs).
Реализация трейта на типе подобна реализации обычных методов. Отличие в том, что после impl мы помещаем имя трейта, который хотим реализовать, затем используем ключевое слово for, и затем указываем имя типа, для которого хотим реализовать трейт. Внутри блока impl мы помещаем сигнатуры методов, которые определены для трейта. Вместо добавления точки с запятой после каждой сигнатуры мы используем фигурные скобки и заполняем тело метода определенным поведением, которое мы хотим, чтобы оно было в трейте для определенного типа.
Теперь, когда эта библиотека имеет реализованный трейт Summary на типах NewsArticle и Tweet, пользователи крейта могут вызвать методы трейта на экземплярах NewsArticle и Tweet таким же способом, как мы вызываем обычные методы. Отличие только в том, что пользователь должен привести трейт в область действия, как это делается и для типов. Вот пример, как двоичный крейт мог бы использовать наш библиотечный крейт aggregator:
use aggregator::{Summary, Tweet};
fn main() {
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"конечно, как вы возможно знаете, люди",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
}
Этот код напечатает "1 new tweet: horse_ebooks: конечно, как вы возможно знаете, люди".
Другие крейты, которые зависят от крейта aggregator, также могут привести трейт Summary в область действия, чтобы реализовать Summary в своих собственных типах. Следует отметить одно ограничение: мы можем реализовать трейт для типа только если как минимум один трейт или тип локальный для нашего крейта. Например, мы можем реализовать трейты стандартной библиотеки наподобие Display на пользовательском типе наподобие Tweet как часть функционала нашего крейта aggregator, потом что тип Tweet локальный для нашего крейта aggregator. Мы можем также реализовать Summary на Vec< T> в нашем крейте aggregator, потому что трейт Summary локальный для нашего крейта aggregator.
Но мы не можем реализовать внешние трейты на внешних типах. Например, мы не можем реализовать трейт Display на Vec< T> в нашем крейте aggregator, потому что Display и Vec< T> оба определены в стандартной библиотеке и не являются локальными на нашем крейте aggregator. Это ограничение являются частью свойства, которое называется когерентностью (coherence), и более конкретно правила orphan (переводится как "брошенный, сирота"). Это правило названо так потому, что родительский тип отсутствует. Это правило гарантирует, что код других людей не может нарушить наш код, и наоборот. Без этого правила два крейта могли бы реализовать один и тот же трейт для одного и того же типа, и Rust не сможет узнать, какую реализацию использовать.
Реализации по умолчанию. Иногда полезно иметь поведение по умолчанию для некоторых или всех методов в трейте вместо того, чтобы требовать реализаций всех методов на каждом типе. Затем, поскольку мы реализуем трейт на определенном типе, то мы можем сохранить или переназначить поведение по умолчанию каждого метода.
В листинге 10-14 мы указываем строку по умолчанию для метода summarize трейта Summary вместо того, чтобы только определить сигнатуру метода, как мы сделали в листинге 10-12.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
Листинг 10-14. Определение трейта Summary с реализацией по умолчанию метода summarize (файл src/lib.rs).
Для использования реализации по умолчанию для summarize на экземплярах NewsArticle, мы указываем пустой блок impl вместе с impl Summary для NewsArticle {}.
Несмотря на то, что мы больше не определяем метод summarize непосредственно в NewsArticle, мы предоставили реализацию по умолчанию, и указали, что NewsArticle реализует трейт Summary. В результате мы все еще можем вызывать метод summarize на экземпляре NewsArticle, как показано ниже:
let article = NewsArticle {
headline: String::from("Penguins выиграли чемпионат Stanley Cup!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"Pittsburgh Penguins снова самая лучшая \ хоккейная команда в NHL.",
),
}; println!("New article available! {}", article.summarize());
Этот код напечатает "New article available! (Read more...)".
Создание реализации по умолчанию не требует от нас ничего менять в реализации Summary на Tweet в листинге 10-13. Причина заключается в том, что синтаксис переопределения реализации по умолчанию совпадает с синтаксисом реализации метода трейта, который не имеет реализации по умолчанию.
Реализации по умолчанию могут вызвать другие методы в том же трейте, даже если эти другие методы не имеют реализации по умолчанию. Таким образом, трейт может предоставить много полезного функционала и только требовать от реализаторов указания небольшой его части. Например, мы могли бы реализовать трейт Summary, чтобы иметь метод summarize_author, реализация которого требуется, и затем определить метод summarize, у которого есть реализация по умолчанию, которая вызывает метод summarize_author:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
Для использования этой версии Summary нам только надо определить summarize_author, когда реализуем трейт на типе:
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
После того, как мы определили summarize_author, мы можем вызывать summarize на экземплярах структуры Tweet, и реализация по умолчанию summarize будет вызывать определение summarize_author, которое мы предоставили. Поскольку мы реализовали summarize_author, трейт Summary дал нам поведение метода summarize не требуя от нас написания дополнительного кода.
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"конечно, как вы возможно знаете, люди",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
Этот код напечатает "1 new tweet: (Read more from @horse_ebooks...)".
Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределяющей реализации того же метода.
Трейты в качестве параметров. Теперь, когда вы знаете, как определить и реализовать трейты, мы можем рассмотреть, как использовать трейты для определения функций, которые могут принять множество разных типов. Мы будем использовать трейт Summary, который мы реализовали на типах NewsArticle и Tweet в листинге 10-13, чтобы определить функцию notify, которая вызывает метод summarize на её параметре item, что является некоторым типом, который реализует трейт Summary. Для этого мы использует синтаксис impl Trait, примерно так:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
Вместо конкретного типа для параметра item, мы указываем ключевое слово impl и имя трейта. Этот параметр принимает любой тип, который реализует указанный трейт. В теле notify мы можем вызвать любые методы на item, которые приходят из трейта Summary, такие как summarize. Мы можем вызывать notify и передать в неё любой экземпляр NewsArticle или Tweet. Код, который вызывает функцию с любым другим типом, таким как String или i32, не скомпилируется, потому что эти типы не реализовали Summary.
Синтаксис Trait Bound. Синтаксис impl Trait работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной формы, известной как trait bound; это выглядит так:
pub fn notify< T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Эта более длинная форма эквивалентна примеру в предыдущей секции, но она более многословна. Мы помещаем trait bounds с декларацией параметра generic-типа после двоеточия и внутри угловых скобок.
Синтаксис impl Trait удобен и делает более лаконичным код в простых случаях, в то время как более полный синтаксис trait bound может выразить большую сложность в других случаях. Например, мы можем иметь два параметра, реализующие Summary. При этом синтаксис impl Trait выглядит следующим образом:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Использование impl Trait подходит, если мы хотим этой функции позволить item1 и item2 быть разными типами (пока они оба реализуют Summary). Если мы хотим заставить оба параметра быть одинаковыми типами, то должны использовать trait bound, примерно так:
pub fn notify< T: Summary>(item1: &T, item2: &T) {
Generic-тип T, указанный как тип параметров item1 и item2, ограничивает функцию таким образом, что должен быть одинаковым конкретный тип значения, передаваемого в качестве аргумента item1 и item2.
Указание нескольких Trait Bounds с помощью синтаксиса +. Мы можем также указать больше одного trait bound. Например мы хотим, чтобы notify использовало форматирование отображения, как и summarize на item: мы указываем в определении notify, что item должен быть реализован и в Display, и в Summary. Мы это можно сделать с использованием + синтаксиса:
pub fn notify(item: &(impl Summary + Display)) {
Синтаксис + также допустим с trait bound-ами на generic-типах:
pub fn notify< T: Summary + Display>(item: &T) {
С указанными двумя trait bound тело notify может вызывать summarize и использовать {} для форматирования item.
Более ясное использование Trait Bounds вместе с дополнением where. Использование слишком большого количества trait bound имеет свои недостатки. Каждый generic имеет свои собственные trait bounds, поэтому функции с несколькими параметрами generic-типа могут содержать много информации trait bound между именем функции и её списком параметров, что затрудняет чтение сигнатуры функции. По этой причине в Rust есть альтернативный синтаксис для указания trait bound внутри пояснения where после сигнатуры функции. Вместо того, чтобы написать:
fn some_function< T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
... мы можем использовать пояснение where, примерно так:
fn some_function< T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
Сигнатура этой функции не такая загроможденная: имя функции, список параметров и возвращаемое значение находятся близко друг к другу, подобно функции без многих trait bounds.
Возврат типов, которые реализуют трейты. Мы также используем синтаксис impl Trait в позиции возврата, чтобы возвратить значение некоторого типа, который реализует трейт, как показано ниже:
fn returns_summarizable() -> impl Summary {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"конечно, как вы возможно знаете, люди",
),
reply: false,
retweet: false,
}
}
Использованием impl Summary для возвращаемого типа мы указываем, что функция returns_summarizable возвратит некоторый тип, который реализует трейт Summary без именования конкретного типа. В этом случае returns_summarizable возвратит Tweet, но коду, вызвавшему эту функцию, не нужно этого знать.
Возможность указать возвращаемый тип только по реализуемому им трейту очень полезна в контексте closures и iterators, что мы рассмотрим в главе 13. Closures и iterators создают типы, о которых знает только компилятор, или типы, которые слишком длинные для указания. Синтаксис impl Trait позволит вам лаконично указать, что функция возвращает какой-то тип, который реализует трейт Iterator без необходимости писать очень длинный тип.
Однако impl Trait можно использовать только в том случае, если возвращается один тип. Например, этот код, который возвращает либо NewsArticle, либо Tweet с возвращаемым типом, указанным как impl Summary, не сработает:
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins выиграли чемпионат Stanley Cup!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"Pittsburgh Penguins снова самая лучшая \ хоккейная команда в NHL.",
),
}
} else {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"конечно, как вы возможно знаете, люди",
),
reply: false,
retweet: false,
}
}
}
Возвращать либо NewsArticle, либо Tweet, не разрешается, из-за ограничений вокруг того, как в компиляторе реализован синтаксис impl Trait. Мы рассмотрим, как написать функцию с этим поведением, в секции "Using Trait Objects That Allow for Values of Different Types" главы 17.
Использование Trait Bounds для условной реализации методов. Путем использования trait bound с блоком impl, который использует параметры generic-типа, мы можем реализовать условно реализовать методы для типов, которые реализуют указанные трейты. Например, тип Pair< T> в листинге 10-15 всегда реализует функцию new для возврата нового экземпляра Pair< T> (вспомните секцию "Определение методов" главы 5 [6], где Self это псевдоним для типа блока impl, в нашем случае это Pair< T>). Однако следующий блок impl, Pair< T> только реализует метод cmp_display, если его внутренний тип T реализует трейт PartialOrd, который позволяет сравнение и трейт Display, чтобы позволить печать на экране.
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!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Листинг 10-15. Условная реализация методов на generic-типе в зависимости от trait bounds (файл src/lib.rs).
Мы можем также условно реализовать трейт для любого типа, который реализует другой трейт. Реализации трейта на любом типе, который удовлетворяет trait bounds, называется офсетными реализациями (blanket implementations) и это широко используется в стандартной библиотеке Rust. Например, стандартная библиотека реализует трейт ToString на любом типе, который реализует трейт Display. Блок impl в стандартной библиотеке выглядит подобно следующему коду:
impl< T: Display> ToString for T {
// -- вырезано --
}
Поскольку стандартная библиотека имеет эту blanket-реализацию, мы можем вызвать метод to_string, определенный трейтом ToString, на любом типе, который реализует трейт Display. Например, мы можем превратить целые числа в их соответствующие значения String наподобие этого, потому что целые числа реализуют Display:
Blanket-реализации появляются в документации для трейта в секции "Implementors".
Трейты и trait bounds позволяют нам писать код, который использует параметры generic-типа для уменьшения дублирования кода, однако также указывая для компилятора, что мы хотим определенного поведения для generic-типа. Компилятор может тогда использовать информацию trait bound, чтобы проверить, что все конкретные типы, используемые нашим кодом, предоставляют корректное поведение. В динамически типизованных языках мы получим runtime-ошибку, если вызовем метод на типе, который не определил метод. Но Rust перемещает возникновение этих ошибок на момент компиляции, позволяя исправлять потенциальные проблемы до того, как они появятся в работающем коде. Дополнительно мы не должны писать код, который делает проверки поведения runtime, потому что эти проверки уже были сделаны во время компиляции. Это повышает производительность без отказа от гибкости generic.
[Проверка ссылок с помощью времени жизни]
Время жизни (lifetime) это другой вид generic, который мы уже использовали. Вместо того, чтобы гарантировать, что что тип обладает необходимым поведением, lifetime гарантирует, что ссылки достоверны, пока мы нуждаемся в их существовании.
Одна подробность, которую мы еще не обсуждали в секции "Ссылки и заимствования" главы 4 (см. [7]), так это то, что каждая ссылка в Rust имеет время жизни (lifetime), это не что иное как область действия, в которой ссылка правильная и достоверная (valid). В большинстве случаев lifetime работает неявно и выводится компилятором таким же образом, как им выводятся типы. Мы должны только помечать типы аннотациями, когда возможны в определенном месте несколько типов. Подобным образом мы должны помечать аннотациями lifetime, когда lifetimes ссылок могли бы срабатывать несколькими разными способами. Rust требует от нас помечать аннотациями взаимоотношения в коде, используя lifetime для generic-параметров для гарантии, что ссылки в релизе, используемые runtime, были определенно достоверными.
Аннотации для lifetime не используется как концепция в большинстве других языков программирования, так что поначалу надо будет привыкнуть. Хотя в этой главе мы не будем полностью освещать lifetimes, будут рассмотрен общий синтаксис lifetime, с каким вам придется иметь дело, чтобы вы чувствовали себя более-менее комфортно с этой концепцией.
Предотвращение зависших ссылок с помощью lifetimes. Основная цель lifetimes - не допускать появления зависших ссылок, которые приводят к тому, что программа начинает обращаться к тому-то, к чему она обращаться не должна. Рассмотрим программу в листинге 10-16, где есть внешняя область действия и внутренняя область действия.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
Листинг 10-16. Попытка использовать ссылку, у которой значение вышло из области действия (этот код не скомпилируется).
Примечание: примеры в листингах 10-16, 10-17 и 10-23 декларирую переменные без присваивания им начального значения, поэтому имя переменной существует во внешней области. На первый взгляд это может показаться противоречием правила Rust, что не может быть null-значений. Однако если мы попытаемся использовать переменную перед присваиванием её значения, то получим ошибку компиляции, что показывает, что Rust на самом деле не допускает значений null.
Внешняя область действия декларирует переменную r без начального значения, а внутренняя область декларирует переменную x, у которой начальное значение 5. Внутри внутренней области действия мы попытались установить значение r как ссылки на x. Затем внешняя область заканчивается, и мы пытаемся напечатать значение в r. Этот код не скомпилируется, потому что значение в r ссылается на то, что исчезло при выходе из внутренней области действия, прежде чем была сделана попытка его использования. Сообщение об ошибке при компиляции этого кода:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {}", r);
| - borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Переменная x "не живет" достаточно долго. Причина в том, область действия x закончилась на строке 7. Однако r все еще остается в своей внешней области действия; поскольку её область действия больше, то мы говорим, что она "живет дольше". Если бы Rust разрешил этому коду работать, то r ссылалась бы на память, которая была освобождена при выходе x из области действия, и все, что бы мы попытались делать с переменной r, не работало бы корректно. Как же Rust определяет, что этот код недопустимый? Для этого он использует систему проверки заимствования (borrow checker).
Borrow Checker. В компиляторе Rust встроен borrow checker, который сравнивает области действия, чтобы определить, достоверны ли все заимствования. Листинг 10-17 показывает тот же самый код из листинга 10-16, но с пометками, показывающими времена жизни переменных.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
Листинг 10-17. Аннотации для lifetime переменных r и x, помеченные как 'a и 'b соответственно.
Здесь мы пометили lifetime переменной r как 'a, и lifetime переменной x как 'b. Как вы можете видеть, внутренний lifetime-блок 'b намного меньше внешнего lifetime-блока 'a. Во время компиляции Rust сравнивает размеры двух lifetime-блоков и видит, что у r есть lifetime 'a, но она ссылается на память, у которой lifetime 'b. Такая программа не допускается, потому что 'b короче, чем 'a: субъект ссылки не живет так же долго, как ссылка.
В листинге 10-18 исправлен код с зависшей ссылкой, и он скомпилируется без каких-либо ошибок.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
Листинг 10-18. Здесь достоверна ссылка r, потому что у её данных время жизни больше, чем время жизни ссылки.
Здесь у переменной x lifetime 'b, которое больше чем 'a. Это значит, что r может ссылаться на x, потому что Rust знает, что r будет всегда достоверна, когда достоверна x.
Теперь, когда вы знаете, что такое lifetime ссылки, и как Rust анализирует lifetime, чтобы гарантировать достоверность всех ссылок, давайте рассмотрим времена жизни параметров и возвращаемых значений в контексте функций.
Generic Lifetimes в функциях. Мы напишем функцию longest, которая возвратит более длинный из двух слайсов строк. Эта функция примет 2 слайса строки, и возвратит один слайс строки. После того, как мы реализовали функцию longest, код в листинге 10-19 должен напечатать самую длинную строку abcd.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
Листинг 10-19. Функция main, которая вызывает функцию longest, чтобы найти самый длинный из двух слайсов строк.
Обратите внимание, что здесь мы захотели использовать слайсы строк, потому что они являются ссылками, вместо того чтобы использовать строки напрямую, потому что мы не хотим, чтобы функция longest брала во владение (ownership) свои параметры. См. секцию "Слайсы String в качестве параметров" в конце главы 4 [7] для дополнительного обсуждения, почему параметры, которые мы используем в листинге 10-19 это именно то, что мы хотим.
Если мы попытаемся реализовать функцию longest, как показано в листинге 10-20, то это не скомпилируется.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Листинг 10-20. Реализация функции longest, которая возвращает самую длинный слайс строки, но она пока не компилируется.
Мы получим следующую ошибку компиляции, которая сообщает об lifetimes:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed
from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest< 'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Текст help раскрывает причину: возвращаемое значение требует generic lifetime параметр на нем, потому что Rust не может сказать, какая из ссылок должна быть возвращена, x или y. Фактически мы сами этого не знаем, потому что блок тела if этой функции вернет ссылку на x, а блок else возвратит ссылку на y!
В момент определения этой функции, мы не знаем конкретные значения, которые будут переданы в эту функцию, так что не знаем какой из блоков выполнится, if или else. Мы также не знаем конкретные времена жизни ссылок, которые передаем в функцию, так что не можем оценить области действия, как это делали в листингах 10-17 и 10-18, чтобы определить, какая из ссылок, которую мы возвращаем, будет всегда достоверной. Borrow checker также не может определить это, потому что он не знает, как время жизни x и y связаны с временем жизни возвращаемого значения. Чтобы исправить эту ошибку, мы добавим generic lifetime параметры, которые определят взаимосвязь между ссылками, чтобы borrow checker мог выполнить свой анализ.
Синтаксис lifetime-аннотации. Аннотации lifetime не изменяют, как долго живет любая из ссылок. Скорее они описывают взаимосвязи времен жизни нескольких ссылок по отношению друг к другу, не влияя на lifetimes. Также, как функция может принимать любой тип, когда её сигнатура указывает параметр generic-типа, функция может принимать ссылки с любым временем жизни, указанным через параметр generic lifetime.
Аннотации lifetime имеют немного необычный синтаксис: имена параметров lifetime должны начинаться с апострофа ('), и обычно должны состоять из всех букв в нижнем регистре и быть очень короткими, наподобие имен generic-типов. Большинство программистов используют имя 'a для первой аннотации lifetime. Мы помещаем параметр аннотации lifetime после & ссылки, используя пробел для отделения аннотации от типа ссылки.
Вот некоторые примеры: ссылка на i32 без параметра lifetime, ссылка на i32, у которой указан lifetime-параметр с именем 'a, и мутируемая ссылка на i32, у которой также lifetime-параметр 'a.
&i32 // ссылка &'a i32 // ссылка с явным временем жизни &'a mut i32 // мутируемая ссылка с явным временем жизни
Одна lifetime-аннотация сама по себе не имеет большого значения, потому что аннотации предназначены для того, чтобы указать Rust, как generic-параметры lifetime нескольких ссылок связаны друг с другом. Давайте рассмотрим, как lifetime-аннотации соотносятся друг с другом в контексте функции longest.
Lifetime-аннотации в сигнатурах функций. Для использования lifetime-аннотаций в сигнатурах функций нам нужно декларировать generic lifetime-параметры внутри угловых скобок между именем функции и списком параметров, точно так как мы это делали с параметрами generic-типа.
Мы хотим, чтобы сигнатура функции выражала следующее ограничение: возвращаемая ссылка будет достоверна, пока достоверны оба параметра. Эта взаимосвязь между временами жизни параметров и возвращаемым значением. Мы назовем время жизни 'a и затем добавим его к каждой ссылке, как показано в листинге 10-21.
fn longest< 'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
Листинг 10-21. Определение функции longest, в котором сигнатура задает для ссылок одинаковое время жизни 'a.
Этот код должен скомпилироваться, и выдать желаемый результат, когда мы его используем в функции main листинга 10-19.
Теперь сигнатура функции говорит для Rust, что существует некоторое время жизни 'a, функция берет два параметра, оба они являются слайсами строки, и оба живут как минимум столько же времени, сколько у lifetime 'a. Сигнатура функции также говорит Rust, что слайс строки, возвращаемый из функции, будет жить ака минимум lifetime 'a. На практике это означает, что время жизни возвращаемой ссылки функции longest такое же, как самое маленькое из времен жизни значений, на которые ссылаются аргументы функции. Эти взаимосвязи именно то, что мы хотим, чтобы Rust использовал при анализе этого кода.
Помните, что когда мы указываем lifetime-параметры в этой сигнатуре функции, мы не меняем времена жизни любого значения, переданного в функцию или возвращаемого. Вместо этого мы указываем, что borrow checker должен отклонять любые значения, которые не соответствуют этим ограничениям. Обратите внимание, что функция longest не должна точно знать, как долго живут x и y, а должна знать только то, что некоторая область видимости может быть заменена на 'a, которая будет удовлетворять этой сигнатуре.
При создании lifetime-аннотаций в функциях эти аннотации появляются в сигнатуре функции, но не в теле функции. Lifetime-аннотации становятся частью контракта функции, подобно типам в сигнатуре. Наличие сигнатур функции, содержащих lifetime-контракт означает, что может быть проще анализ, который делает компилятор Rust. Если есть проблема в том, как функция аннотируется или как вызывается, то ошибки компилятора могут более точно указать на часть нашего кода и ограничения. Если бы вместо этого компилятор Rust сделал бы больше выводов о том, какими мы бы предполагали взаимоотношения времен жизни, то компилятор смог бы только указать на использование нашего кода на расстоянии многих шагов от причины проблемы.
Когда мы передаем конкретные значения в функцию longest, конкретное время жизни заменяется на 'a, она является частью области действия x, которая перекрывается областью действия y. Другими словами, generic lifetime 'a будет иметь конкретное время жизни, равное самому маленькому из времен жизни x и y. Поскольку мы аннотировали возвращаемую ссылку таким же lifetime-параметром 'a, то возвращаемая ссылка также будет достоверна в протяжении самого короткого из времен жизни x и y.
Давайте рассмотрим, как lifetime-аннотации ограничивают функцию longest, путем передачи в неё ссылок с разными конкретными временами жизни. Прямой пример приведен в листинге 10-22.
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
Листинг 10-22. Использование функции longest со ссылками на значения String, у которых разные конкретные времена жизни.
В этом примере string1 достоверна до конца внешней области действия, string2 достоверна до конца внутренней области действия, и result ссылается на что-то что достоверно до конца внутренней области действия. Запустите этот код, и вы увидите, что его borrow checker допускает; код скомпилируется, и напечатает самую длинную строку "long string is long".
Далее попробуем пример, который покажет, что время жизни ссылки в result должно быть меньше времен жизни аргументов. Перенесем декларацию переменной result наружу из внутренней области действия, но оставим присваивание значения переменной result внутри области действия string2. Затем мы перенесем println!, где используется result, из внутренней области действия во внешнюю, после завершения внутренней области действия. В результате получится код в листинге 10-23, который не скомпилируется.
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
Листинг 10-23. Попытка использовать result после string2, которая вышла из области действия.
Когда мы попытаемся скомпилировать этот код, получим ошибку:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Ошибка показывает, что чтобы ссылка result была достоверной для оператора println!, ссылка string2 должна быть также достоверной до конца внешней области действия. Rust знает об этом, потому что мы аннотировали времена жизни значений параметров функции и возвращаемого значения одним и тем же lifetime-параметром 'a.
Мы как люди можем посмотреть на этот код и увидеть, что время жизни string1 больше, чем время жизни string2, и поэтому результат в ссылке result будет содержать достоверное значение ссылки на string1. Поскольку string1 пока не вышла из области действия, ссылка на string1 останется достоверной в операторе println!. Однако компилятор не может знать, что ссылка останется достоверной для этого случая. Мы указали для Rust, что lifetime возвращаемой ссылки из функции longest такое же, как самое малое из времен жизни любой из ссылок, переданных в функцию через параметры. Таким образом, borrow checker запретит код в листинге 10-23, потому что он допускает использование недопустимой ссылки.
Попробуйте поэкспериментировать с изменениями значений и времен жизни ссылок, передаваемых в функцию longest, а также то, как используется возвращаемая ссылка. Перед запуском компиляции сделайте предположения о том, пройдут ли ваши эксперименты проверку заимствования (borrow checker); и затем проверьте, были ли вы правы!
Мышление с точки зрения времени жизни. Способ, которым мы должны указать lifetime-параметры, зависит от того, что делает наша функция. Например, если мы поменяем реализацию функции longest так, чтобы она всегда возвращала первый параметры вместо самого длинного слайса строки, то нам не нужно будет указывать время жизни параметра y. Следующий код скомпилируется:
fn longest< 'a>(x: &'a str, y: &str) -> &'a str {
x
}
Мы указали lifetime-параметр 'a для параметра x и возвращаемого значения, но не для параметра y, потому что время жизни y никак не связано с временем жизни x или возвращаемого значения.
Когда возвращается ссылка из функции, lifetime-параметр для возвращаемого типа должен соответствовать lifetime-параметру одного из параметров. Если возвращаемая ссылка не относится к одному из параметров, то она должна ссылаться на значение, созданное внутри этой функции. Однако это создало бы зависшую ссылку (dangling reference), потому что её значение выйдет из области действия по окончании функции. Рассмотрим эту попытку реализации функции longest, которая не скомпилируется:
fn longest< 'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
Здесь, несмотря на то, что мы указали lifetime-параметр 'a для возвращаемого типа, эта реализация не скомпилируется, потому что время жизни возвращаемого значения никак не связано с временем жизни параметров. Вот сообщение об ошибке, которое мы получим:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Проблема в том, что result выходит из области действия при завершении функции longest. Мы также пытаемся вернуть ссылку на result из функции. Мы не можем указать параметры времени жизни, которые изменили бы зависшую ссылку, и Rust не позволит создать зависшую ссылку. В этом случае лучшим решением было бы вернуть собственный тип данных вместо ссылки, чтобы вызывающая функция отвечала бы за очистку возвращаемого значения.
В конечном счете синтаксис lifetime касается соединения времени жизни различных параметров и возвращаемых значений из функций. Как только они соединены, Rust обладает достаточной информацией, чтобы обеспечить безопасные для работы с памятью операции, и запретить операции, которые могут создать зависшие ссылки или иным образом нарушить безопасность содержимого памяти.
Lifetime-аннотации в определениях структур. До сих пор мы определяли структуры, которые сами владели всеми своими типами. Мы можем определить структуры, которые хранят ссылки, но в этом случае нам нужно будет добавить lifetime-аннотацию на каждой ссылке в определении структуры. Листинг 10-24 содержит структуру ImportantExcerpt, которая хранит слайс строки.
struct ImportantExcerpt< 'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Позвони мне, Ishmael. Столько лет прошло...");
let first_sentence = novel.split('.').next().expect("Не могу найти '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}
Листинг 10-24. Здесь структура содержит в своем поле ссылку, что требует применения lifetime-аннотации.
В этой структуре есть одно поле part, где сохраняется слайс строки, являющийся ссылкой. Как и с generic-типами данных, мы декларируем имя для generic lifetime-параметра внутри угловых скобок после имени структуры, так что мы можем использовать этот lifetime-параметр в теле определения структуры. Эта аннотация означает, что экземпляр структуры ImportantExcerpt не может жить дольше, чем ссылка, находящаяся в поле part.
Здесь функция main создает экземпляр структуры ImportantExcerpt, который хранит ссылку на первое предложение строки String, взятое во владение переменной novel. Данные в novel существуют перед созданием экземпляра ImportantExcerpt. Дополнительно novel не выходит из области действия, пока ImportantExcerpt не выйдет из области действия, так что ссылка в экземпляре ImportantExcerpt остается достоверной.
Lifetime Elision. Мы теперь знаем, что каждая ссылка имеет время жизни, и что нужно указывать параметры времени жизни для функций или структур, которые используют ссылки. Однако в главе 4 (см. [7]) у нас была функция в листинге 10-25, которая компилировалась без lifetime-аннотаций.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
Листинг 10-25. Определенная нами функция в листинге 4-9, которая компилируется без lifetime-аннотаций, даже когда параметр и возвращаемое значение являются ссылками (файл src/lib.rs).
Причина, по которой эта функция компилируется без lifetime-аннотаций, историческая: к ранних версиях (pre-1.0) языка Rust этот код не скомпилировался бы, потому что для каждой ссылки требовалось бы явное lifetime. В то время сигнатура функции выглядела бы следующим образом:
fn first_word< 'a>(s: &'a str) -> &'a str {
По мере написания большого количества кода команда Rust заметила, что программисты Rust вводят снова и снова одни и те же lifetime-аннотации в определенных ситуациях. Эти ситуации оказались предсказуемыми, и соответствовали некоторым детерминированным моделям. Разработчики Rust запрограммировали эти модели в код компилятора, чтобы borrow checker мог вывести времена жизни в этих ситуациях, и не требовал для них явных аннотаций.
Эта часть истории Rust актуальна, потому что возможно в компилятор будут добавлены больше детерминированных шаблонов. В будущем может потребоваться еще меньше lifetime-аннотаций.
Модели, запрограммированные в анализе ссылок Rust, называются правилами lifetime elision. Это не те правила, которым должны следовать программисты; они описывают набор конкретных случаев, которые рассмотрит компилятор, и если ваш код подходит под эти случаи то в нем не надо явно вписывать времена жизни для ссылок.
Elision-правила не дают полного вывода. Если Rust детерминировано применяет правила, однако все еще существует неясность в отношении того, сколько времени жизни имеют ссылки, и компилятор не сможет угадать каким должен быть срок службы оставшихся ссылок. Вместо того, чтобы угадывать, компилятор выдаст вам ошибку, которую вы сможете устранить, добавив lifetime-аннотации для ссылок.
Времена жизни параметров функции или метода называются input lifetime, и времена жизни возвращаемых значений называются output lifetime.
Компилятор использует 3 правила, чтобы выяснить время жизни ссылок, когда нет для них явных аннотаций. Первое правило применяется к input lifetime, а второе и третье правило применяется к output lifetime. Если компилятор до конца трех правил, и все еще есть ссылки, для которых он не смог выяснить время жизни, то компилятор остановится и выдаст ошибку. Эти правила действуют как для определений fn, так и для блоков impl.
Первое правило заключается в том, что компилятор назначает lifetime-параметр каждому параметру, который является ссылкой. Другими словами функция с одним параметром получить один lifetime-параметр: fn foo< 'a>(x: &'a i32); функция с двумя параметрами получит два отдельных lifetime-параметра: fn foo< 'a, 'b>(x: &'a i32, y: &'b i32); и так далее.
Второе правило заключается в том, что при наличии ровно одного input lifetime параметра, это lifetime назначается всем output lifetime параметрам: fn foo< 'a>(x: &'a i32) -> &'a i32.
Третье правило заключается в том, что если существует несколько input lifetime параметров, но одним из них является &self или &mut self, потому что это метод, то время жизни self назначается всем output lifetime параметрам. Это третье правило делает методы намного приятнее для чтения и написания, потому что требуется меньше символов.
Давайте представим, что мы компилятор. Мы будем применять эти правила, чтобы выяснить времена жизни ссылок в сигнатуре функции first_word из листинга 10-25. Сигнатура начинается без каких-либо lifetime, связанных со ссылками:
fn first_word(s: &str) -> &str {
Затем компилятор применит первое правило, которое указывает, что каждый параметр получает свое время жизни. Мы назовем его как обычно 'a, так что получится такая сигнатура:
fn first_word< 'a>(s: &'a str) -> &str {
Второе правило применяется, потому что здесь только один параметр input lifetime. Второе правило указывает, что lifetime одного входного параметра назначается для output lifetime, так что теперь сигнатура будет следующая:
fn first_word< 'a>(s: &'a str) -> &'a str {
После этого все ссылки в этой функции получили времена жизни, и компилятор может продолжить свой анализ без того, чтобы заставлять программиста вставлять аннотации времен жизни в сигнатуру этой функции.
Давайте разберем другой пример, на этот раз мы используем функцию longest без lifetime-параметров, когда начинаем работать с ней в листинге 10-20:
fn longest(x: &str, y: &str) -> &str {
Попробуем применить первое правило: каждому параметру назначается свое собственное lifetime. Здесь у нас 2 параметра вместо одного, так что будут 2 времени жизни, 'a и 'b:
fn longest< 'a, 'b>(x: &'a str, y: &'b str) -> &str {
Как вы видите, второе правило не может быть применено, потому что здесь больше одного input lifetime. Третье правило также не применяется, потому что longest это функция, а не метод, так что ни один из параметров не является self. Проработав все 3 правила, мы так и не выяснили, каково время жизни возвращаемого типа. Вот почему мы получили ошибку, когда попытались скомпилировать этот код в листинге 10-20: компилятор проработал все три правила lifetime elision, но все еще не смог выяснить все времена жизни ссылок сигнатуры функции.
Поскольку третье правило фактически применимо только к сигнатурам методов, мы рассмотрим времена жизни в этом контексте, чтобы увидеть, почему третье правило означает, что нам не нужно часто аннотировать времена жизни в сигнатурах метода.
Lifetime-аннотации в определениях методов. Когда мы реализуем методы на структуре с временами жизни, используется тот же синтаксис, как и для параметров generic-типа, что показано в листинге 10-11. То, где мы объявляем и используем lifetime-параметры, зависит от того, связаны ли они с полями структуры или параметрами метода и возвращаемыми значениями.
Lifetime-имена для полей структуры всегда нужно декларировать после ключевого слова impl, и затем использовать после имени структуры, потому что эти времена жизни являются частью типа структуры.
В сигнатурах метода внутри блока impl, ссылки могут быть привязаны к lifetime ссылок в полях структуры, или могут быть независимыми. Дополнительно правила lifetime elision часто делают так, что в сигнатурах метода нет необходимости в lifetime-аннотациях. Давайте рассмотрим некоторые примеры, используя структуру ImportantExcerpt, которую мы определили в листинге 10-24.
Сначала мы будем использовать метод level, у которого только один параметр, являющийся ссылкой на self, и который возвращает значение типа i32, не ссылающееся ни на что:
impl< 'a> ImportantExcerpt< 'a> {
fn level(&self) -> i32 {
3
}
}
Требуется декларация lifetime-параметра после impl и его использование после имени типа, но мы не обязаны аннотировать время жизни для self из-за первого правила lifetime elision.
Вот пример, где применяется третье правило lifetime elision:
impl< 'a> ImportantExcerpt< 'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
Здесь 2 входных параметра времени жизни, так что Rust применяет первое правило lifetime elision, и дает для обоих параметров &self и announcement свои индивидуальные времена жизни. Затем, поскольку один из параметров &self, возвращаемый тип получает lifetime от &self, и все времена жизни учтены.
Static Lifetime. Нужно обсудить одно специальное время жизни, это 'static, которое обозначает, что если оно применено к ссылке, то эта ссылка имеет глобальное время жизни, в течение всего времени работы программы. У всех строковых литералов 'static время жизни, которое мы можем аннотировать следующим образом:
let s: &'static str = "У меня static lifetime.";
Текст в этой строке сохраняется напрямую в двоичном коде программы, так что эти данные доступны всегда. Таким образом, lifetime всех литералов строк соответствует 'static.
В сообщениях об ошибках могут содержаться рекомендации использовать 'static время жизни. Однако перед тем, как применить 'static в качестве времени жизни для ссылки, подумайте: действительно ли ссылка, которая у вас есть, живет в течение всего времени работы программы, или нет, и хотите ли вы этого. В большинстве случаев сообщение об ошибке, советующее 'static lifetime, получилось в результате попытки создания висящей ссылки или из-за несоответствия доступных имен жизни. В таких случаях лучше исправить эти проблемы, а не применять 'static lifetime.
Еще раз про generic-параметры, trait bounds и lifetimes. Давайте пробежимся по синтаксису, указывающему параметры generic-типа, trait bounds и времена жизни, и все это в одной функции!
use std::fmt::Display;
fn longest_with_an_announcement< 'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() {
x
} else {
y
}
}
Это функция longest из листинга 10-21, которая возвращает самый длинный из двух слайсов строк. Но теперь она имеет дополнительный параметр ann, у которого generic-тип T. И этот тип может быть заполнен любым типом, который реализует трейт Display, как указано в пояснении where. Этот дополнительный параметр типа T будет напечатан с помощью {}, по этой причине необходимо наличие привязки к этому типу трейта Display. Поскольку Because времена жизни типа generic, декларации lifetime-параметра 'a и параметра generic-типа T появляются в одном и том же списке внутри угловых скобок после имени функции.
[Общие выводы]
Мы многое рассмотрели в этой главе. Теперь вы знаете, что такое параметры generic-типа, трейты (traits) и trait bounds, а также параметры generic lifetime, и поэтому готовы писать код без повторений, который сможет работать во многих различных ситуациях. Параметры generic-типа позволят вам применить свой код к различным типам. Трейты и trait bounds гарантируют, что даже если типы являются generic-типами, они будут иметь то поведение, какое требует код. Вы узнали как использовать lifetime-аннотации, чтобы гарантировать, что гибкий код не будет содержать висящих ссылок. И весь этот анализ происходит во время компиляции, что не влияет на runtime-производительность!
Однако по этим темам можно еще многое узнать: в главе 17 обсуждаются trait-объекты, которые представляют другой способ использования трейтов. Есть также более сложные сценарии включающие lifetime-аннотации, которые вам понадобятся только в очень продвинутых сценариях; для этого вы должны прочитать справочник по ссылкам Rust [8]. В следующей главе мы научимся, как писать тесты на Rust, чтобы вы могли убедиться, что ваш код работает так, как должен.
[Ссылки]
1. Rust Generic Types, Traits, and Lifetimes site:rust-lang.org. 2. Rust: перечисления (enum) и совпадение шаблонов (match). 3. Rust: коллекции стандартной библиотеки. 4. Rust: обработка ошибок. 5. Программирование шаблонов C++ для идиотов, часть 1. 6. Rust: использование структуры для взаимосвязанных данных. 7. Rust: что такое Ownership. 8. Introduction Rust Reference site:rust-lang.org. |