Продвинутые типы
Система типов Rust обладает некоторыми возможностями, о которых мы упоминали ранее, но ещё не обсуждали. Мы начнём с рассмотрения новых типов (newtypes) в целом, чтобы понять, почему они полезны как типы. Затем перейдём к псевдонимам типов — функции, похожей на новые типы, но с немного другой семантикой. Мы также обсудим тип ! и динамически размещённые типы.
Использование паттерна нового типа для безопасности типов и абстракции
Этот раздел предполагает, что вы ознакомились с предыдущим разделом «Использование паттерна нового типа для реализации внешних типажей на внешних типах». Паттерн нового типа также полезен для задач, выходящих за рамки уже рассмотренных, включая статическое обеспечение того, чтобы значения никогда не путались, и указание единиц измерения значения. Вы видели пример использования новых типов для указания единиц в Листинге 20-16: вспомните, что структуры Millimeters и Meters оборачивали значения u32 в новый тип. Если бы мы написали функцию с параметром типа Millimeters, мы не смогли бы скомпилировать программу, которая случайно пытается вызвать эту функцию со значением типа Meters или простым u32.
Мы также можем использовать паттерн нового типа для абстрагирования некоторых деталей реализации типа: новый тип может предоставлять публичный API, отличающийся от API приватного внутреннего типа.
Новые типы также могут скрывать внутреннюю реализацию. Например, мы могли бы предоставить тип People для обёртки HashMap<i32, String>, который хранит идентификатор человека, связанный с его именем. Код, использующий People, взаимодействовал бы только с публичным API, который мы предоставляем, например, методом для добавления строки имени в коллекцию People; этому коду не нужно было бы знать, что мы внутренне назначаем идентификатор i32 именам. Паттерн нового типа — это лёгкий способ достичь инкапсуляции для скрытия деталей реализации, о чём мы говорили в разделе «Инкапсуляция, скрывающая детали реализации» в главе 18.
Создание синонимов типов с помощью псевдонимов типов
Rust предоставляет возможность объявлять псевдоним типа, чтобы дать существующему типу другое имя. Для этого используется ключевое слово type. Например, мы можем создать псевдоним Kilometers для i32 следующим образом:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Теперь псевдоним Kilometers является синонимом для i32; в отличие от типов Millimeters и Meters, которые мы создали в Листинге 20-16, Kilometers не является отдельным новым типом. Значения, имеющие тип Kilometers, будут обрабатываться так же, как значения типа i32:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Поскольку Kilometers и i32 — это один и тот же тип, мы можем складывать значения обоих типов и передавать значения Kilometers в функции, которые принимают параметры i32. Однако, используя этот метод, мы не получаем преимуществ проверки типов, которые даёт паттерн нового типа, рассмотренный ранее. Другими словами, если мы где-то перепутаем значения Kilometers и i32, компилятор не выдаст нам ошибку.
Основной вариант использования псевдонимов типов — уменьшение повторений. Например, у нас может быть громоздкий тип вроде этого:
Box<dyn Fn() + Send + 'static>
Писать этот громоздкий тип в сигнатурах функций и в виде аннотаций типов по всему коду может быть утомительно и чревато ошибками. Представьте проект, полный кода, подобного Листингу 20-25.
fn main() { let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi")); fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) { // --snip-- } fn returns_long_type() -> Box<dyn Fn() + Send + 'static> { // --snip-- Box::new(|| ()) } }
Псевдоним типа делает этот код более управляемым, уменьшая повторения. В Листинге 20-26 мы ввели псевдоним Thunk для многословного типа и можем заменить все использования типа на более короткий псевдоним Thunk.
fn main() { type Thunk = Box<dyn Fn() + Send + 'static>; let f: Thunk = Box::new(|| println!("hi")); fn takes_long_type(f: Thunk) { // --snip-- } fn returns_long_type() -> Thunk { // --snip-- Box::new(|| ()) } }
Thunk для уменьшения повторенийЭтот код гораздо легче читать и писать! Выбор осмысленного имени для псевдонима типа также может помочь передать ваше намерение (thunk — это слово для кода, который будет вычислен позже, поэтому это подходящее имя для замыкания, которое сохраняется).
Псевдонимы типов также часто используются с типом Result<T, E> для уменьшения повторений. Рассмотрим модуль std::io в стандартной библиотеке. Операции ввода-вывода часто возвращают Result<T, E> для обработки ситуаций, когда операции не удаются. В этой библиотеке есть структура std::io::Error, представляющая все возможные ошибки ввода-вывода. Многие функции в std::io будут возвращать Result<T, E>, где E — это std::io::Error, например, эти функции в типаже Write:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Result<..., Error> повторяется много раз. Поэтому std::io имеет это объявление псевдонима типа:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Поскольку это объявление находится в модуле std::io, мы можем использовать полностью квалифицированный псевдоним std::io::Result<T>; то есть Result<T, E> с E, заполненным как std::io::Error. Сигнатуры функций типажа Write в итоге выглядят так:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Псевдоним типа помогает в двух аспектах: он облегчает написание кода и даёт нам единообразный интерфейс во всём std::io. Поскольку это псевдоним, это просто ещё один Result<T, E>, что означает, что мы можем использовать с ним любые методы, работающие с Result<T, E>, а также специальный синтаксис, такой как оператор ?.
Тип «никогда», который никогда не возвращается
Rust имеет специальный тип с именем !, известный в терминах теории типов как пустой тип, потому что у него нет значений. Мы предпочитаем называть его типом «никогда», потому что он занимает место возвращаемого типа, когда функция никогда не вернётся. Вот пример:
fn bar() -> ! {
// --snip--
panic!();
}
Этот код читается как «функция bar возвращает никогда». Функции, которые возвращают никогда, называются расходящимися функциями. Мы не можем создавать значения типа !, поэтому bar никогда не сможет вернуться.
Но какова польза от типа, для которого вы никогда не можете создавать значения? Вспомните код из Листинга 2-5, часть игры по угадыванию чисел; мы воспроизвели его немного здесь в Листинге 20-27.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
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;
}
}
}
}
match с ветвью, заканчивающейся на continueВ то время мы пропустили некоторые детали в этом коде. В разделе «Оператор управления потоком match» в главе 6 мы обсудили, что все ветви match должны возвращать один и тот же тип. Так, например, следующий код не работает:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
Тип guess в этом коде должен был бы быть целым числом и строкой, а Rust требует, чтобы guess имел только один тип. Так что же возвращает continue? Как нам разрешили возвращать u32 из одной ветви и иметь другую ветвь, заканчивающуюся на continue в Листинге 20-27?
Как вы могли догадаться, continue имеет значение !. То есть, когда Rust вычисляет тип guess, он смотрит на обе ветви match, первая со значением u32, а вторая со значением !. Поскольку ! никогда не может иметь значение, Rust решает, что тип guess — это u32.
Формальным способом описания этого поведения является то, что выражения типа ! могут быть приведены к любому другому типу. Нам разрешено заканчивать эту ветвь match на continue, потому что continue не возвращает значение; вместо этого оно передаёт управление обратно в начало цикла, поэтому в случае Err мы никогда не присваиваем значение guess.
Тип «никогда» также полезен с макросом panic!. Вспомните функцию unwrap, которую мы вызываем для значений Option<T>, чтобы получить значение или вызвать панику с этим определением:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
В этом коде происходит то же самое, что и в match в Листинге 20-27: Rust видит, что val имеет тип T, а panic! имеет тип !, поэтому результат всего выражения match — это T. Этот код работает, потому что panic! не производит значение; он завершает программу. В случае None мы не будем возвращать значение из unwrap, поэтому этот код корректен.
Последнее выражение, имеющее тип !, — это loop:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
Здесь цикл никогда не заканчивается, поэтому ! — это значение выражения. Однако это не было бы верно, если бы мы включили break, потому что цикл завершился бы, когда достиг бы break.
Динамически размещённые типы и типаж Sized
Rust должен знать определённые детали о своих типах, например, сколько памяти выделить для значения particular типа. Это оставляет один уголок его системы типов немного запутанным на первый взгляд: концепцию динамически размещённых типов. Иногда называемых DST или неразмерёнными типами, эти типы позволяют нам писать код, используя значения, размер которых мы можем узнать только во время выполнения.
Давайте углубимся в детали динамически размещённого типа под названием str, который мы использовали на протяжении всей книги. Правильно, не &str, а str сам по себе, является DST. Мы не можем знать, насколько длинна строка, до времени выполнения, что означает, что мы не можем создать переменную типа str и не можем принимать аргумент типа str. Рассмотрим следующий код, который не работает:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust должен знать, сколько памяти выделить для любого значения particular типа, и все значения типа должны использовать одинаковое количество памяти. Если бы Rust позволил нам написать этот код, эти два значения str должны были бы занимать одинаковое количество места. Но они имеют разную длину: s1 требует 12 байт хранения, а s2 — 15. Вот почему невозможно создать переменную, содержащую динамически размещённый тип.
Так что же мы делаем? В этом случае вы уже знаете ответ: мы делаем типы s1 и s2 &str, а не str. Вспомните из раздела «Срезы строк» в главе 4, что структура среза просто хранит начальную позицию и длину среза. Так что, хотя &T — это одно значение, хранящее адрес памяти, где находится T, &str — это два значения: адрес str и его длина. Таким образом, мы всегда можем знать размер значения &str на этапе компиляции: это вдвое больше длины usize. То есть мы всегда знаем размер &str, независимо от того, насколько длинна строка, на которую он ссылается. Вообще, это способ, которым динамически размещённые типы используются в Rust: они имеют дополнительный бит метаданных, который хранит размер динамической информации. Золотое правило динамически размещённых типов заключается в том, что мы всегда должны помещать значения динамически размещённых типов за указатель некоторого рода.
Мы можем комбинировать str со всеми видами указателей: например, Box<str> или Rc<str>. На самом деле, вы видели это раньше, но с другим динамически размещённым типом: типажами. Каждый типаж является динамически размещённым типом, на который мы можем ссылаться, используя имя типажа. В разделе «Использование объектов типажей, которые позволяют значениям разных типов» в главе 18 мы упомянули, что для использования типажей как объектов типажей мы должны помещать их за указатель, такой как &dyn Trait или Box<dyn Trait> (Rc<dyn Trait> тоже сработает).
Для работы с DST Rust предоставляет типаж Sized для определения, известен ли размер типа на этапе компиляции. Этот типаж автоматически реализуется для всего, чей размер известен на этапе компиляции. Кроме того, Rust неявно добавляет ограничение на Sized к каждой универсальной функции. То есть определение универсальной функции вроде этого:
fn generic<T>(t: T) {
// --snip--
}
фактически обрабатывается так, как если бы мы написали это:
fn generic<T: Sized>(t: T) {
// --snip--
}
По умолчанию универсальные функции будут работать только с типами, имеющими известный размер на этапе компиляции. Однако вы можете использовать следующий специальный синтаксис, чтобы ослабить это ограничение:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
Ограничение типажа на ?Sized означает «T может быть Sized, а может и не быть», и эта нотация переопределяет значение по умолчанию, что универсальные типы должны иметь известный размер на этапе компиляции. Синтаксис ?Trait с этим значением доступен только для Sized, а не для любых других типажей.
Также обратите внимание, что мы изменили тип параметра t с T на &T. Поскольку тип может не быть Sized, нам нужно использовать его за каким-то указателем. В этом случае мы выбрали ссылку.
Далее мы поговорим о функциях и замыканиях!