Продвинутые Types

В системе типов Rust есть некоторые возможности, которые мы уже упоминали, но ещё не обсуждали. Мы начнём с обсуждения newtypes в целом, чтобы понять, почему они полезны как типы. Затем мы перейдём к псевдонимам типов — возможности, похожей на newtypes, но с несколько иной семантикой. Мы также обсудим тип ! и типы с динамическим размером.

Типобезопасность и абстракция с помощью шаблона Newtype

Этот раздел предполагает, что вы прочитали предыдущий раздел «Реализация внешних трейтов с помощью шаблона Newtype». Шаблон newtype также полезен для задач, выходящих за рамки уже рассмотренных, включая статическое обеспечение того, что значения никогда не перепутываются, и указание единиц измерения значения. Вы видели пример использования newtypes для указания единиц измерения в листинге 20-16: вспомните, что структуры Millimeters и Meters оборачивали значения u32 в newtype. Если бы мы написали функцию с параметром типа Millimeters, мы не смогли бы скомпилировать программу, которая случайно попыталась бы вызвать эту функцию со значением типа Meters или простым u32.

Мы также можем использовать шаблон newtype для абстрагирования некоторых деталей реализации типа: новый тип может предоставлять публичный API, отличающийся от API приватного внутреннего типа.

Newtypes также могут скрывать внутреннюю реализацию. Например, мы могли бы предоставить тип People для обёртки HashMap<i32, String>, которая хранит ID человека, связанный с его именем. Код, использующий People, будет взаимодействовать только с предоставляемым нами публичным API, например, с методом для добавления строки имени в коллекцию People; этому коду не нужно будет знать, что мы внутренне назначаем ID i32 именам. Шаблон newtype — это лёгкий способ достижения инкапсуляции для сокрытия деталей реализации, что мы обсуждали в разделе «Инкапсуляция, скрывающая детали реализации» в главе 18.

Псевдонимы типов (Type Aliases)

Rust предоставляет возможность объявлять псевдонимы типов, чтобы дать существующему типу другое имя. Для этого мы используем ключевое слово type. Например, мы можем создать псевдоним Kilometers для i32 следующим образом:

#![allow(unused)]
fn main() {
type Kilometers = i32;
}

Теперь псевдоним Kilometers является синонимом для i32; в отличие от типов Millimeters и Meters, которые мы создали в листинге 20-16, Kilometers не является отдельным новым типом. Значения, имеющие тип Kilometers, будут обрабатываться так же, как значения типа i32:

#![allow(unused)]
fn main() {
type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);
}

Поскольку Kilometers и i32 — это один и тот же тип, мы можем складывать значения обоих типов и передавать значения Kilometers в функции, которые принимают параметры i32. Однако при использовании этого метода мы не получаем преимуществ проверки типов, которые даёт шаблон newtype, рассмотренный ранее. Другими словами, если мы где-то перепутаем значения Kilometers и i32, компилятор не выдаст нам ошибку.

Основной случай использования псевдонимов типов — сокращение повторений. Например, у нас может быть длинный тип, такой как:

#![allow(unused)]
fn main() {
Box<dyn Fn() + Send + 'static>
}

Написание этого длинного типа в сигнатурах функций и в аннотациях типов по всему коду может быть утомительным и подверженным ошибкам. Представьте проект, полный кода, как в листинге 20-25.

#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
    // --пропуск--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
    // --пропуск--
}
}

Листинг 20-25: Использование длинного типа во многих местах

Псевдоним типа делает этот код более управляемым за счёт сокращения повторений. В листинге 20-26 мы ввели псевдоним с именем Thunk для многословного типа и можем заменить все использования типа на более короткий Thunk.

#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
    // --пропуск--
}

fn returns_long_type() -> Thunk {
    // --пропуск--
}
}

Листинг 20-26: Введение псевдонима типа Thunk для сокращения повторений

Этот код намного легче читать и писать! Выбор содержательного имени для псевдонима типа также помогает передать ваши намерения (thunk — это слово для кода, который должен быть выполнен позже, поэтому это подходящее имя для замыкания, которое сохраняется).

Псевдонимы типов также часто используются с типом Result<T, E> для сокращения повторений. Рассмотрим модуль std::io в стандартной библиотеке. Операции ввода-вывода часто возвращают Result<T, E> для обработки ситуаций, когда операции не выполняются. Эта библиотека имеет структуру std::io::Error, которая представляет все возможные ошибки ввода-вывода. Многие функции в std::io возвращают Result<T, E>, где E — это std::io::Error, например, эти функции в трейте Write:

#![allow(unused)]
fn main() {
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 есть такое объявление псевдонима типа:

#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, std::io::Error>;
}

Поскольку это объявление находится в модуле std::io, мы можем использовать полностью квалифицированный псевдоним std::io::Result<T>; то есть Result<T, E> с E, заполненным как std::io::Error. Сигнатуры функций трейта Write в итоге выглядят так:

#![allow(unused)]
fn main() {
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>, с ним, а также специальный синтаксис, такой как оператор ?.

Тип Never, который никогда не возвращается

В Rust есть специальный тип !, известный в терминологии теории типов как пустой тип, поскольку у него нет значений. Мы предпочитаем называть его типом never, потому что он выступает в качестве типа возвращаемого значения, когда функция никогда не возвращается. Вот пример:

#![allow(unused)]
fn main() {
fn bar() -> ! {
    // --пропуск--
}
}

Этот код читается как «функция bar возвращает never». Функции, которые возвращают never, называются расходящимися функциями (diverging functions). Мы не можем создавать значения типа !, поэтому bar никогда не может вернуть значение.

Но какой смысл в типе, для которого нельзя создать значения? Вспомните код из листинга 2-5, части игры в угадывание числа; мы воспроизвели его фрагмент здесь в листинге 20-27.

#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};
}

Листинг 20-27: Выражение match с веткой, которая заканчивается на continue

В свое время мы упустили некоторые детали в этом коде. В разделе «Конструкция управления потоком match» в главе 6 мы обсуждали, что все ветки match должны возвращать один и тот же тип. Так, например, следующий код не работает:

#![allow(unused)]
fn main() {
// [Этот код не компилируется!]
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.

Тип never также полезен с макросом panic!. Вспомните функцию unwrap, которую мы вызываем для значений Option<T>, чтобы получить значение или вызвать панику с таким определением:

#![allow(unused)]
fn main() {
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! имеет тип !, поэтому результат всего выражения matchT. Этот код работает, потому что panic! не производит значение; он завершает программу. В случае None мы не будем возвращать значение из unwrap, поэтому этот код корректен.

Ещё одно выражение, которое имеет тип ! — это loop:

#![allow(unused)]
fn main() {
print!("forever ");

loop {
    print!("and ever ");
}
}

Здесь цикл никогда не заканчивается, поэтому ! является значением выражения. Однако это было бы неверно, если бы мы включили break, потому что цикл завершился бы при достижении break.

Типы с динамическим размером и трейт Sized

Rust необходимо знать определённые детали о своих типах, например, сколько памяти выделить для значения конкретного типа. Это создаёт некоторую путаницу в одной из областей системы типов: концепция типов с динамическим размером. Иногда называемые DST или unsized types, эти типы позволяют нам писать код, используя значения, размер которых мы можем узнать только во время выполнения.

Давайте углубимся в детали типа с динамическим размером под названием str, который мы использовали на протяжении всей книги. Да, именно так, не &str, а сам str является DST. Во многих случаях, например, при хранении текста, введённого пользователем, мы не можем узнать длину строки до момента выполнения. Это означает, что мы не можем создать переменную типа str и не можем принимать аргумент типа str. Рассмотрим следующий код, который не работает:

#![allow(unused)]
fn main() {
// [Этот код не компилируется!]
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}

Rust необходимо знать, сколько памяти выделить для любого значения конкретного типа, и все значения типа должны использовать одинаковый объём памяти. Если бы Rust разрешил нам написать этот код, эти два значения str должны были бы занимать одинаковое место. Но они имеют разную длину: s1 требует 12 байт памяти, а s2 — 15. Вот почему невозможно создать переменную, содержащую тип с динамическим размером.

Так что же нам делать? В этом случае вы уже знаете ответ: мы делаем тип s1 и s2 строковым срезом (&str), а не str. Вспомните из раздела «Строковые срезы» в главе 4, что структура данных среза хранит только начальную позицию и длину среза. Таким образом, хотя &T — это единственное значение, хранящее адрес памяти, где находится T, строковый срез — это два значения: адрес str и его длина. Как следствие, мы можем узнать размер значения строкового среза во время компиляции: это удвоенная длина usize. То есть мы всегда знаем размер строкового среза, независимо от длины строки, на которую он ссылается. В общем случае, именно так типы с динамическим размером используются в Rust: у них есть дополнительный бит метаданных, хранящий размер динамической информации. Золотое правило типов с динамическим размером заключается в том, что мы всегда должны помещать значения таких типов за указателем какого-либо вида.

Мы можем комбинировать str с различными видами указателей: например, Box<str> или Rc<str>. На самом деле, вы уже видели это ранее, но с другим типом с динамическим размером: трейтами. Каждый трейт — это тип с динамическим размером, на который мы можем ссылаться, используя имя трейта. В разделе «Использование трейт-объектов для абстрагирования общего поведения» в главе 18 мы упоминали, что для использования трейтов в качестве трейт-объектов мы должны помещать их за указателем, таким как &dyn Trait или Box<dyn Trait> (Rc<dyn Trait> тоже подойдёт).

Для работы с DST Rust предоставляет трейт Sized для определения, известен ли размер типа во время компиляции. Этот трейт автоматически реализуется для всего, чей размер известен во время компиляции. Кроме того, Rust неявно добавляет ограничение Sized для каждой обобщённой функции. То есть, определение обобщённой функции like this:

#![allow(unused)]
fn main() {
fn generic<T>(t: T) {
    // --пропуск--
}
}

фактически обрабатывается так, как если бы мы написали:

#![allow(unused)]
fn main() {
fn generic<T: Sized>(t: T) {
    // --пропуск--
}
}

По умолчанию обобщённые функции будут работать только с типами, имеющими известный размер во время компиляции. Однако вы можете использовать следующий специальный синтаксис, чтобы ослабить это ограничение:

#![allow(unused)]
fn main() {
fn generic<T: ?Sized>(t: &T) {
    // --пропуск--
}
}

Ограничение трейта ?Sized означает «T может быть Sized или нет», и эта нотация переопределяет значение по умолчанию, согласно которому обобщённые типы должны иметь известный размер во время компиляции. Синтаксис ?Trait с таким значением доступен только для Sized, а не для любых других трейтов.

Также обратите внимание, что мы изменили тип параметра t с T на &T. Поскольку тип может не быть Sized, нам нужно использовать его за каким-либо указателем. В данном случае мы выбрали ссылку.

Далее мы поговорим о функциях и замыканиях!