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

Система типов 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 следующим образом:

fn main() {
    type Kilometers = i32;

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

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

Теперь псевдоним Kilometers является синонимом для i32; в отличие от типов Millimeters и Meters, которые мы создали в листинге 19-15, 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. Однако, используя этот метод, мы не получаем тех преимуществ проверки типов, которые мы получаем от паттерна newtype, рассмотренного ранее. Другими словами, если мы где-то перепутаем значения Kilometers и i32, компилятор не выдаст нам ошибку.

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

Box<dyn Fn() + Send + 'static>

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

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(|| ())
    }
}

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

Псевдоним типа делает этот код более удобным для работы, сокращая количество повторений. В листинге 19-25 мы ввели псевдоним Thunk для типа verbose и можем заменить все использования этого типа более коротким псевдонимом 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(|| ())
    }
}

Листинг 19-25: Представление псевдонима 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>, а также специальный синтаксис вроде ? оператора.

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

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

fn bar() -> ! {
    // --snip--
    panic!();
}

Этот код читается как "функция 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;
            }
        }
    }
}

Листинг 19-26: Сопоставление match с веткой, которая заканчивается continue

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

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

Тип 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>, чтобы создать значение или вызвать панику с этим определением:

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 в листинге 19-26: Rust видит, что val имеет тип T, а panic! имеет тип !, поэтому результатом общего выражения match является T. Этот код работает, потому что panic! не производит никакого значения; он завершает программу. В случае None мы не будем возвращать значение из unwrap, поэтому этот код работает.

Последнее выражение, которое имеет тип ! это loop:

fn main() {
    print!("forever ");

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

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

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

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

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

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 хранятся два значения - адрес str и его длина. Таким образом, мы можем узнать размер значения &str во время компиляции: он вдвое больше длины usize. То есть, мы всегда знаем размер &str, независимо от длины строки, на которую оно ссылается. В целом, именно так в Rust используются типы динамического размера: они содержат дополнительный бит метаданных, который хранит размер динамической информации. Золотое правило динамически размерных типов заключается в том, что мы всегда должны помещать значения таких типов за каким-либо указателем.

Мы можем комбинировать str со всеми видами указателей: например, Box<str> или Rc<str>. На самом деле, вы уже видели это раньше, но с другим динамически размерным типом: трейтами. Каждый трейт - это динамически размерный тип, на который мы можем ссылаться, используя имя трейта. В главе 17 в разделе "Использование трейт-объектов, допускающих значения разных типов" мы упоминали, что для использования трейтов в качестве трейт-объектов мы должны поместить их за указателем, например &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, мы должны использовать его за каким-либо указателем. В данном случае мы выбрали ссылку.

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