Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

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

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

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

Newtype-типы также могут скрывать внутреннюю реализацию. Например, мы могли бы предоставить тип People, который оборачивает HashMap<i32, String>, хранящий идентификатор человека, связанный с его именем. Код, использующий People, взаимодействовал бы только с предоставленным нами публичным API, например с методом для добавления строки имени в коллекцию People; этому коду не нужно было бы знать, что внутри мы назначаем именам идентификаторы i32. Шаблон newtype — это легковесный способ добиться инкапсуляции, чтобы скрыть детали реализации; мы обсуждали это в разделе «Инкапсуляция, которая скрывает детали реализации» главы 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. Однако при использовании этого подхода мы не получаем преимуществ проверки типов, которые дает шаблон newtype, обсужденный ранее. Другими словами, если мы где-то перепутаем значения 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(|| ())
    }
}
Listing 20-25: Использование длинного типа во многих местах

Псевдоним типа делает этот код более удобным для сопровождения, уменьшая повторение. В листинге 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(|| ())
    }
}
Listing 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:

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 type, который никогда не возвращает управление

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

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

Этот код читается как «функция bar возвращает never». Функции, которые никогда не возвращают управление, называются расходящимися функциями (diverging functions). Мы не можем создавать значения типа !, поэтому 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;
            }
        }
    }
}
Listing 20-27: 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 решает, что тип guessu32.

Формальный способ описать это поведение состоит в том, что выражения типа ! могут быть приведены к любому другому типу. Нам разрешено завершить эту ветвь match с помощью continue, потому что continue не возвращает значение; вместо этого он передает управление обратно к началу цикла, поэтому в случае Err мы никогда не присваиваем значение guess.

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

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

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

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