Продвинутые типы
Система типов Rust имеет некоторые особенности, о которых мы уже упоминали, но ещё не обсуждали. Мы начнём с общего обзора newtypes, а затем разберёмся, чем они могут пригодиться в качестве типов. Далее мы перейдём к псевдонимам типов - возможности, похожей на newtypes, но с несколько иной семантикой. Мы также обсудим тип !
и типы с динамическим размером.
Использование паттерна Newtype для обеспечения безопасности типов и создания абстракций
Примечание: В этом разделе предполагается, что вы прочитали предыдущий раздел "Использование паттерна Newtype для реализации внешних трейтов для внешних типов."
Паттерн newtype полезен и для других задач, помимо тех, которые мы обсуждали до сих пор, в частности, для статического обеспечения того, чтобы значения никогда не путались, а также для указания единиц измерения значения. Пример использования newtypes для указания единиц измерения вы видели в листинге 19-15: вспомните, как структуры Millimeters
и Meters
обернули значения u32
в newtype. Если бы мы написали функцию с параметром типа Millimeters
, мы не смогли бы скомпилировать программу, которая случайно попыталась бы вызвать эту функцию со значением типа Meters
или обычным u32
.
Мы также можем использовать паттерн newtype для абстрагирования от некоторых деталей реализации типа: новый тип может предоставлять публичный API, который отличается от API скрытого внутри типа.
Newtypes также позволяют скрыть внутреннюю реализацию. Например, мы можем создать тип People
, который обернёт HashMap<i32, String>
, хранящий ID человека, связанный с его именем. Код, использующий People
, будет взаимодействовать только с публичным API, который мы предоставляем, например, метод добавления имени в коллекцию People
; этому коду не нужно будет знать, что внутри мы присваиваем i32
ID именам. Паттерн newtype - это лёгкий способ достижения инкапсуляции для скрытия деталей реализации, который мы обсуждали в разделе "Инкапсуляция, скрывающая детали реализации" главы 17.
Создание синонимов типа с помощью псевдонимов типа
Rust предоставляет возможность объявить псевдоним типа чтобы дать существующему типу другое имя. Для этого мы используем ключевое слово type
. Например, мы можем создать псевдоним типа Kilometers
для i32
следующим образом:
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/no-listing-04-kilometers-alias/src/main.rs:here}} }
Теперь псевдоним Kilometers
является синонимом для i32
; в отличие от типов Millimeters
и Meters
, которые мы создали в листинге 19-15, Kilometers
не является отдельным, новым типом. Значения, имеющие тип Kilometers
, будут обрабатываться так же, как и значения типа i32
:
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/no-listing-04-kilometers-alias/src/main.rs:there}} }
Поскольку Kilometers
и i32
являются одним и тем же типом, мы можем добавлять значения обоих типов и передавать значения Kilometers
функциям, принимающим параметры i32
. Однако, используя этот метод, мы не получаем тех преимуществ проверки типов, которые мы получаем от паттерна newtype, рассмотренного ранее. Другими словами, если мы где-то перепутаем значения Kilometers
и i32
, компилятор не выдаст нам ошибку.
Синонимы в основном используются для сокращения повторений. Например, у нас может быть такой многословный тип:
Box<dyn Fn() + Send + 'static>
Написание таких длинных типов в сигнатурах функций и в виде аннотаций типов по всему коду может быть утомительным и чреватым ошибками. Представьте себе проект, наполненный таким кодом, как в листинге 19-24.
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-24/src/main.rs:here}} }
Псевдоним типа делает этот код более удобным для работы, сокращая количество повторений. В листинге 19-25 мы ввели псевдоним Thunk
для типа verbose и можем заменить все использования этого типа более коротким псевдонимом Thunk
.
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-25/src/main.rs:here}} }
Такой код гораздо легче читать и писать! Выбор осмысленного имени для псевдонима типа также может помочь прояснить ваши намерения (thunk - название для кода, который будет вычисляться позднее, поэтому это подходящее имя для сохраняемого замыкания).
Псевдонимы типов также часто используются с типом Result<T, E>
для сокращения повторений. Рассмотрим модуль std::io
в стандартной библиотеке. Операции ввода-вывода часто возвращают Result<T, E>
для обработки ситуаций, когда эти операции не удаются. В данной библиотеке есть структура std::io::Error
, которая отражает все возможные ошибки ввода/вывода. Многие функции в std::io
будут возвращать Result<T, E>
, где E
- это std::io::Error
, например, эти функции в трейте Write
:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-05-write-trait/src/lib.rs}}
Result<..., Error>
часто повторяется. Поэтому std::io
содержит такое объявление псевдонима типа:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-06-result-alias/src/lib.rs:here}}
Поскольку это объявление находится в модуле std::io
, мы можем использовать полный псевдоним std::io::Result<T>
; это и есть Result<T, E>
, где в качестве E
выступает std::io::Error
. Сигнатуры функций трейта Write
в итоге выглядят следующим образом:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-06-result-alias/src/lib.rs:there}}
Псевдоним типа помогает двумя способами: он облегчает написание кода и даёт нам согласованный интерфейс для всего из std::io
. Поскольку это псевдоним, то это просто ещё один тип Result<T, E>
, что означает, что с ним мы можем использовать любые методы, которые работают с Result<T, E>
, а также специальный синтаксис вроде ?
оператора.
Тип Never, который никогда не возвращается
В Rust есть специальный тип !
, который на жаргоне теории типов известен как empty type (пустой тип), потому что он не содержит никаких значений. Мы предпочитаем называть его never type (никакой тип), потому что он используется в качестве возвращаемого типа, когда функция ничего не возвращает. Вот пример:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-07-never-type/src/lib.rs:here}}
Этот код читается как "функция bar
ничего не возвращает". Функции, которые ничего не возвращают, называются рассеивающими функциями (diverging functions). Мы не можем производить значения типа !
, поэтому bar
никогда ничего не вернёт.
Но для чего нужен тип, для которого вы никогда не сможете создать значения? Напомним код из листинга 2-5, фрагмента "игры в загадки"; мы воспроизвели его часть здесь в листинге 19-26.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
В то время мы опустили некоторые детали в этом коде. В главе 6 раздела "Оператор управления потоком match
" мы обсуждали, что все ветви match
должны возвращать одинаковый тип. Например, следующий код не работает:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-08-match-arms-different-types/src/main.rs:here}}
Тип guess
в этом коде должен быть целым и строкой, а Rust требует, чтобы guess
имел только один тип. Так что же возвращает continue
? Как нам позволили вернуть u32
из одной ветви и при этом иметь другую ветвь, которая оканчивается continue
в листинге 19-26?
Как вы уже возможно догадались, continue
имеет значение !
. То есть, когда Rust вычисляет тип guess
, он смотрит на обе сопоставляемые ветки, первая со значением u32
и последняя со значением !
. Так как !
никогда не может иметь значение, то Rust решает что типом guess
является тип u32
.
Формальный подход к описанию такого поведения заключается в том, что выражения типа !
могут быть преобразованы в любой другой тип. Нам позволяется завершить этот match
с помощью continue
, потому что continue
не возвращает никакого значения; вместо этого он передаёт управление обратно в начало цикла, поэтому в случае Err
мы никогда не присваиваем значение guess
.
Тип never полезен также для макроса panic!
. Вспомните функцию unwrap
, которую мы вызываем для значений Option<T>
, чтобы создать значение или вызвать панику с этим определением:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-09-unwrap-definition/src/lib.rs:here}}
В этом коде происходит то же самое, что и в match
в листинге 19-26: Rust видит, что val
имеет тип T
, а panic!
имеет тип !
, поэтому результатом общего выражения match
является T
. Этот код работает, потому что panic!
не производит никакого значения; он завершает программу. В случае None
мы не будем возвращать значение из unwrap
, поэтому этот код работает.
Последнее выражение, которое имеет тип !
это loop
:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-10-loop-returns-never/src/main.rs:here}}
В данном случае цикл никогда не завершится, поэтому !
является значением выражения. Но это не будет так, если мы добавим break
, так как цикл завершит свою работу, когда дойдёт до break
.
Типы с динамическим размером и трейт Sized
Rust необходимо знать некоторые детали о типах, например, сколько места нужно выделить для значения определённого типа. Из-за этого один из аспектов системы типов поначалу вызывает некоторое недоумение: концепция типов с динамическим размером. Иногда называемые DST или безразмерные типы, эти типы позволяют нам писать код, используя значения, размер которых мы можем узнать только во время выполнения.
Давайте углубимся в детали динамического типа str
, который мы использовали на протяжении всей книги. Все верно, не типа &str
, а типа str
самого по себе, который является DST. Мы не можем знать, какой длины строка до момента времени выполнения, то есть мы не можем создать переменную типа str
и не можем принять аргумент типа str
. Рассмотрим следующий код, который не работает:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-11-cant-create-str/src/main.rs:here}}
Rust должен знать, сколько памяти выделить для любого значения конкретного типа и все значения типа должны использовать одинаковый объем памяти. Если Rust позволил бы нам написать такой код, то эти два значения str
должны были бы занимать одинаковое количество памяти. Но они имеют разную длину: s1
нужно 12 байтов памяти, а для s2
нужно 15. Вот почему невозможно создать переменную имеющую тип динамического размера.
Так что же нам делать? В этом случае вы уже знаете ответ: мы преобразуем типы s1
и s2
в &str
, а не в str
. Вспомните из раздела "Строковые срезы" главы 4, что структура данных среза просто хранит начальную позицию и длину среза. Так, в отличие от &T
, который содержит только одно значение - адрес памяти, где находится T
, в &str
хранятся два значения - адрес str
и его длина. Таким образом, мы можем узнать размер значения &str
во время компиляции: он вдвое больше длины usize
. То есть, мы всегда знаем размер &str
, независимо от длины строки, на которую оно ссылается. В целом, именно так в Rust используются типы динамического размера: они содержат дополнительный бит метаданных, который хранит размер динамической информации. Золотое правило динамически размерных типов заключается в том, что мы всегда должны помещать значения таких типов за каким-либо указателем.
Мы можем комбинировать str
со всеми видами указателей: например, Box<str>
или Rc<str>
. На самом деле, вы уже видели это раньше, но с другим динамически размерным типом: трейтами. Каждый трейт - это динамически размерный тип, на который мы можем ссылаться, используя имя трейта. В главе 17 в разделе "Использование трейт-объектов, допускающих значения разных типов" мы упоминали, что для использования трейтов в качестве трейт-объектов мы должны поместить их за указателем, например &dyn Trait
или Box<dyn Trait>
(Rc<dyn Trait>
тоже подойдёт).
Для работы с DST Rust использует трейт Sized
чтобы решить, будет ли размер типа известен на стадии компиляции. Этот трейт автоматически реализуется для всего, чей размер известен к моменту компиляции. Кроме того, Rust неявно добавляет ограничение на Sized
к каждой универсальной функции. То есть, определение универсальной функции, такое как:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-12-generic-fn-definition/src/lib.rs}}
на самом деле рассматривается как если бы мы написали её в виде:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-13-generic-implicit-sized-bound/src/lib.rs}}
По умолчанию обобщённые функции будут работать только с типами чей размер известен во время компиляции. Тем не менее, можно использовать следующий специальный синтаксис, чтобы ослабить это ограничение:
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-14-generic-maybe-sized/src/lib.rs}}
Ограничение трейта ?Sized
означает «T
может или не может быть Sized
», эта нотация отменяет стандартное правило, согласно которому универсальные типы должны иметь известный размер во время компиляции. Использовать синтаксис ?Trait
в таком качестве можно только для Sized
, и ни для каких других трейтов.
Также обратите внимание, что мы поменяли тип параметра t
с T
на &T
. Поскольку тип мог бы не быть Sized
, мы должны использовать его за каким-либо указателем. В данном случае мы выбрали ссылку.
Далее мы поговорим о функциях и замыканиях!