Практика с продвинутыми типами Rust

RustRustBeginner
Практиковаться сейчас

This tutorial is from open-source community. Access the source code

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

Добро пожаловать в Advanced Types. Этот практикум является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этом практикуме мы обсудим newtypes, псевдонимы типов, тип ! и динамически размерные типы в системе типов Rust.

Advanced Types

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

Использование нового типа для обеспечения безопасности типов и абстракции

Примечание: В этом разделе предполагается, что вы прочитали предыдущий раздел "Использование нового типа для реализации внешних трейтов".

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

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

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

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

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

type Kilometers = i32;

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

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>

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

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

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

Псевдоним типа делает этот код более управляемым, уменьшая повторения. В Листинге 19-25 мы ввели синоним под названием Thunk для длинного типа и можем заменить все использования типа на более короткий синоним Thunk.

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

Листинг 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 есть такое объявление псевдонима типа:

type Result<T> = std::result::Result<T, std::io::Error>;

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

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

Этот код читается как "функция bar возвращает никогда". Функции, которые возвращают никогда, называются дивергирующими функциями. Мы не можем создавать значения типа !, поэтому bar никогда не может вернуть значение.

Но для чего нужен тип, для которого вы никогда не можете создать значения? Назовем код из Листинга 2-5, который является частью игры в угадывание числа; мы здесь воспроизводим его немного в Листинге 19-26.

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

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

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

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

Тип guess в этом коде должен быть целым числом и строкой, а Rust требует, чтобы guess имел только один тип. Так что чем возвращает continue? Как мы могли вернуть u32 из одной ветки и иметь другую ветку, которая заканчивается на continue в Листинге 19-26?

Как вы, вероятно, догадались, continue имеет значение !. То есть, когда Rust вычисляет тип guess, он смотрит на обе ветки match, одну с значением u32, а другую с значением !. Поскольку ! никогда не может иметь значение, Rust решает, что тип guess - это u32.

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

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

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:

print!("forever ");

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

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

#Динамически размерные типы и трейт Sized

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

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

let s1: str = "Hello there!";
let s2: str = "How's it going?";

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

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

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

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

Резюме

Поздравляем! Вы завершили лабораторную работу по продвинутым типам. Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.