Расширенные типы

Система типов Rust имеет некоторые возможности, которые мы упоминали в этой книге, но ещё не обсуждали. Мы начнём с обсуждения новых типов (newtypes) в целом, по мере изучения того, почему новые типы полезны в качестве типов. Затем мы перейдём к псевдонимам, возможности похожей на новые типы (newtypes), но с немного другой семантикой. Мы также обсудим тип ! и с динамическими типами (dynamically sized type).

Примечание. В следующем разделе предполагается, что вы прочитали предыдущий раздел "Использование шаблона нового типа (newtype) для реализации внешних типажей у внешних типов"

Использование Newtype шаблона для безопасности типов и реализации абстракций

Шаблон newtype полезен для задач помимо тех, которые мы обсуждали до сих пор, включая статическое обеспечение того, чтобы значения никогда не путались и указывали единицы значения. Вы видели пример использования newtype для обозначения единиц в листинге 19-15. Вспомним, что структуры Millimeters и Meters содержат обёрнутые значения u32 в newtype. Если бы мы написали функцию с параметром типа Millimeters, мы не смогли бы скомпилировать программу, которая случайно пыталась вызвать эту функция со значением типа Meters или обычным u32.

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

Варианты шаблона (Newtypes) также могут скрывать внутреннюю реализацию. Например, мы могли бы предоставить тип People для оборачивания типа HashMap<i32, String>, которой хранит идентификатор человека связанного с его именем. Код использующий People будет взаимодействовать только с предоставляемым нами открытым API, например метод добавления строки имени в коллекцию People; этому коду не понадобилось бы знать, что мы внутри присваиваем ID код типа i32 именам. Шаблон newtype - это лёгкий способ добиться инкапсуляции, скрыть детали реализации, которые мы обсуждали в разделе "Инкапсуляция, которая скрывает детали реализации" главы 17.

Создание синонимов типа с помощью псевдонимов типа

Наряду с шаблоном newtype, 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, обсуждавшемся ранее.

Синонимы в основном используются для уменьшения повторяемости. Например, мы у нас есть тип:

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 для "многословного" типа и теперь можем заменить все использования такого типа на более короткий псевдонимом 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 типаже:


#![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() {
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 в конечном итоге выглядят как:


#![allow(unused)]
fn main() {
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), потому что он стоит на месте возвращаемого типа, когда функция никогда не будет возвращаться. Вот пример:


#![allow(unused)]
fn main() {
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..101);

    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.

Формальным способом описания этого поведения является то, что выражения типа ! могу быть приведены (coerced) к любому другому типу. Нам разрешено закончить сопоставление этой 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 ");
    }
}

Здесь цикл никогда не заканчивается, так что ! (never type) является значением выражения. Тем не менее, это не будет правдой, если мы добавим в цикл break, потому что цикл мог бы завершится, когда дело дойдёт до break.

Динамические типы и Sized типаж

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

Давайте углубимся в детали динамического типа 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 является противоположностью ограничения типажа Sized : мы бы прочитали его как "Тип T может или не может быть Sized." Этот синтаксис доступен только для Sized и никаких других типажей.

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

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