Поглиблено про типи

Система типів Rust має певні особливості, про які ми вже згадували, але не обговорювали. Почнемо з обговорення нових типів у цілому, розбираючи, чому нові типи корисні як типи. Тоді ми перейдемо до псевдонімів типів, функціоналу, подібному до нових типів, але з трохи іншою семантикою. Також ми обговоримо тип ! і типи з динамічним розміром.

Використання паттерну "новий тип" для безпеки і абстракції типів

Примітка: цей підрозділ передбачає, що ви вже прочитали попередній підрозділ “Використання паттерну "новий тип" для реалізації зовнішніх трейтів на зовнішніх типах.”

Паттерн "новий тип" також корисний для задач поза тими, які ми досі обговорили, включно зі статичним гарантуванням, що значення не переплутаються, і вказанням одиниць значення. Ви бачили приклад використання нових типів для позначення типів у Блоці коду 19-15: згадайте структури Millimeters і Meters, що обгортали значення u32 у новий тип. Якщо ми напишемо функцію з параметром типу Millimeters, то не зможемо скомпілювати програму, де випадково спробуємо викликати цю функцію зі значенням типу Meters або просто u32.

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

Нові типи також можуть приховувати внутрішню реалізацію. Наприклад, ми могли б надати тип People для того, щоб загорнути HashMap<i32, String>, що зберігає ID людини, пов'язаний з її ім'ям. Код, що використовує People, взаємодіятиме лише з наданим нами публічним API, таким як метод, щоб додати ім'я - стрічку до колекції People; тому коду не треба знати, що внутрішньо ми присвоюємо іменам ID типу i32. Паттерн "новий тип" є простим способом досягти інкапсуляції, щоб приховати деталі реалізації, про яку ми говорили у підрозділі “Інкапсуляція, яка приховує деталі реалізації” Розділу 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. Однак, за допомогою цього методу ми не отримуємо переваг перевірки типів, які ми отримали від паттерну "новий тип", про який говорили раніше. Іншими словами, якщо ми десь переплутаємо значення 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-25: використання довгого типу в багатьох місцях

Псевдонім типу робить цей код більш керованим шляхом зменшення повторень. У Блоці коду 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:

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>, а також особливий синтаксис на кшталт оператора ?.

Тип "ніколи", що ніколи не повертається

Rust має спеціальний тип, що зветься !, також відомий у термінології теорії типів як empty type, бо він не має значень. Ми радше називаємо його тип "ніколи", бо він стоїть замість типу, що повертається, коли функція ніколи не повертає значення. Ось приклад:

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

Цей код читається як "функція bar ніколи не повертає." Функції, що ніколи не повертають, звуться розбіжними функціями. Ми не можемо створювати значень типу !, тож 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, він перевіряє обидва рукави match, перший зі значенням u32 і другий зі значенням !. Оскільки ! ніколи не має значення, Rust вирішує, що типом guess є u32.

Формальним ця поведінка описується так: вираз типу ! може бути приведений до будь-якого іншого типу. Ми можемо поставитиcontinue в кінці рукава match, бо continue не повертає значення; натомість, він передає управління назад на початок циклу, тож у випадку Err ми ніколи не присвоїмо значення guess.

Тип "ніколи" також використовується у макросі 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 (dymamically sized 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 означає “T може бути чи не бути Sized” і цей запис знімає обмеження за замовчуванням, що узагальнені типи мусять мати відомий розмір під час компіляції. Синтаксис ?Trait із цим значенням можна застосовувати лише для Sized, але не для решти трейтів.

Також зауважте, що ми змінили тип параметра t з T на &T. Оскільки тип може не бути Sized, ми маємо використати його, сховавши за якогось роду вказівником. У цьому випадку ми обрали посилання.

Далі ми поговоримо про функції та замикання! ch17-01-what-is-oo.html#encapsulation-that-hides-implementation-details ch06-02-match.html#the-match-control-flow-operator ch17-02-trait-objects.html#using-trait-objects-that-allow-for-values-of-different-types