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 полезен и для других задач, кроме тех, что мы обсуждали, — например, чтобы статически гарантировать, что значения никогда не перепутаются, и указывать единицы измерения значения. Пример с единицами измерения вы видели в листинге 20-16: вспомните, что структуры Millimeters и Meters оборачивали значения u32 в newtype. Если написать функцию с параметром типа Millimeters, программа, в которой эту функцию случайно вызывают со значением типа Meters или голым u32, просто не скомпилируется.

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

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

Синонимы и псевдонимы типов

В Rust можно объявить псевдоним типа (type alias) — дать существующему типу другое имя. Для этого используется ключевое слово 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>) {
        // --пропуск--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --пропуск--
        Box::new(|| ())
    }
}
Листинг 20-25: Длинный тип во многих местах

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

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 {
        // --пропуск--
        Box::new(|| ())
    }
}
Листинг 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, который никогда не возвращается

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

fn bar() -> ! {
    // --пропуск--
    panic!();
}

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

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

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --пропуск--

        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}");

        // --пропуск--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Листинг 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? Как в листинге 20-27 одна ветка могла вернуть u32, а другая — закончиться continue?

Как вы, наверное, догадались, значение continue — типа !. То есть, вычисляя тип guess, Rust смотрит на обе ветки: в первой значение 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 из листинга 20-27: Rust видит, что у val тип T, а у panic! — тип !, поэтому результат всего выражения match — T. Код работает, потому что panic! не выдаёт значения, а завершает программу. В случае None мы не возвращаем значение из unwrap, так что код корректен.

И последнее выражение типа ! — цикл:

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

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

Здесь цикл никогда не заканчивается, так что значение выражения — !. Но если бы в цикле был 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 и его длина. Поэтому размер значения строкового среза известен при компиляции: он равен удвоенному размеру 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) {
    // --пропуск--
}

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

fn generic<T: Sized>(t: T) {
    // --пропуск--
}

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

fn generic<T: ?Sized>(t: &T) {
    // --пропуск--
}

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

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

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