Розширені типи
Система типів Rust має деякі можливості, які ми досі згадували, але ще не обговорювали. Ми почнемо з обговорення newtypes загалом, коли розглядатимемо, чому вони корисні як типи. Потім ми перейдемо до псевдонімів типів, можливості, подібної до newtypes, але з трохи іншою семантикою. Ми також обговоримо тип ! і динамічно розмірні типи.
Безпека типів і абстракція за допомогою шаблону newtype
У цьому розділі припускається, що ви прочитали попередній розділ “Implementing External
Traits with the Newtype Pattern”. Шаблон newtype
також корисний для завдань, окрім тих, які ми обговорювали досі, включно з
статичним забезпеченням того, що значення ніколи не плутаються, і позначенням одиниць
значення. Ви бачили приклад використання newtypes для позначення одиниць у Listing
20-16: Згадайте, що структури Millimeters і Meters обгортали значення u32
у newtype. Якби ми написали функцію з параметром типу Millimeters, ми
не змогли б скомпілювати програму, яка випадково спробувала б викликати цю
функцію зі значенням типу Meters або звичайним u32.
Ми також можемо використовувати шаблон newtype, щоб абстрагувати деякі деталі реалізації типу: Новий тип може відкривати публічний API, який відрізняється від API приватного внутрішнього типу.
Newtypes також можуть приховувати внутрішню реалізацію. Наприклад, ми могли б надати
тип People, щоб обгорнути HashMap<i32, String>, який зберігає ID людини,
пов’язаний з її ім’ям. Код, що використовує People, взаємодіяв би лише з
публічним API, який ми надаємо, наприклад із методом для додавання рядка імені до
колекції People; цьому коду не потрібно було б знати, що ми внутрішньо призначаємо
іменам ID i32. Шаблон newtype — це легковагий спосіб досягти інкапсуляції,
щоб приховати деталі реалізації, яку ми обговорювали в розділі “Encapsulation that
Hides Implementation
Details”
у Chapter 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, які ми створили в Listing 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>
Писати цей довгий тип у сигнатурах функцій і як анотації типів по всьому коду може бути виснажливо й схильним до помилок. Уявіть собі проєкт, повний коду на кшталт того, що в Listing 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-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(|| ())
}
}
Цей код набагато легше читати і писати! Вибір змістовної назви для псевдоніма типу також може допомогти передати ваш намір (thunk — це слово для коду, який буде обчислено пізніше, тож це відповідна назва для замикання, яке зберігається).
Псевдоніми типів також часто використовуються з типом Result<T, E> для зменшення
повторення. Розгляньте модуль std::io у стандартній бібліотеці. Операції I/O
часто повертають Result<T, E>, щоб обробляти ситуації, коли операції
не вдається виконати. Ця бібліотека має структуру std::io::Error, яка представляє всі
можливі помилки I/O. Багато функцій у 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 має спеціальний тип з назвою !, який у термінах теорії типів відомий як
порожній тип , тому що він не має значень. Ми віддаємо перевагу називати його типом never
оскільки він стоїть на місці типу повернення, коли функція ніколи не
повертає значення. Ось приклад:
fn bar() -> ! {
// --snip--
panic!();
}
Цей код читається як “функція bar повертає never”. Функції, що повертають
never, називаються функціями, що розходяться. Ми не можемо створити значення типу !,
тож bar у принципі не може повернути значення.
Але яка користь від типу, для якого ви ніколи не можете створити значення? Згадайте код із Listing 2-5, частину гри в угадай число; ми відтворили його трохи тут у Listing 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;
}
}
}
}
Тоді ми пропустили деякі деталі в цьому коді. У розділі “The match
Control Flow Construct”
Chapter 6 ми обговорювали, що всі гілки match мають повертати один і той самий
тип. Тож, наприклад, наведений нижче код не працює:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
Тип guess у цьому коді мав би бути і цілим числом, і рядком, а Rust вимагає, щоб guess
мав лише один тип. Отже, що повертає continue? Як нам було дозволено повернути u32
з однієї гілки й мати іншу гілку, яка закінчується continue, у Listing 20-27?
Як ви могли здогадатися, continue має значення !. Тобто, коли Rust
обчислює тип guess, він дивиться на обидві гілки match, першу зі
значенням u32 і другу зі значенням !. Оскільки ! ніколи не може мати
значення, Rust вирішує, що тип guess — це u32.
Формальний спосіб описати цю поведінку полягає в тому, що вирази типу ! можуть
бути приведені до будь-якого іншого типу. Нам дозволено завершувати цю гілку match
за допомогою continue, тому що continue не повертає значення; натомість він передає керування
назад на початок циклу, тож у випадку Err ми ніколи не присвоюємо значення
guess.
Тип never також корисний із макросом panic!. Згадайте функцію unwrap,
яку ми викликаємо на значеннях Option<T>, щоб отримати значення або викликати panic
з таким визначенням:
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 у Listing 20-27: Rust
бачить, що val має тип T, а panic! має тип !, тож результат
усього виразу match — це T. Цей код працює, тому що 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. Ось чому неможливо створити змінну,
що містить динамічно розмірний тип.
Отже, що нам робити? У цьому випадку ви вже знаєте відповідь: Ми робимо тип
str для s1 і s2 рядковим зрізом (&str), а не str. Згадайте з розділу
“String Slices” у Chapter 4, що структура
даних зрізу зберігає лише початкову позицію та довжину зрізу. Отже,
хоча &T — це одне значення, яке зберігає адресу пам’яті, де розташовано T,
рядковий зріз — це два значення: адреса str і його довжина. Таким чином,
ми можемо знати розмір значення рядкового зрізу під час компіляції: Він удвічі
більший за довжину usize. Тобто ми завжди знаємо розмір рядкового зрізу,
незалежно від того, якої довжини рядок, на який він посилається. Загалом це
той спосіб, у який динамічно розмірні типи використовуються в Rust:
Вони мають додатковий біт метаданих, який зберігає розмір динамічної
інформації. Золоте правило динамічно розмірних типів полягає в тому, що ми завжди
маємо розміщувати значення динамічно розмірних типів за якимось
вказівником.
Ми можемо поєднувати str з усіма видами вказівників: наприклад, Box<str> або
Rc<str>. Насправді ви вже бачили це раніше, але з іншим динамічно
розмірним типом: трейтами. Кожен трейт — це динамічно розмірний тип, на який ми можемо посилатися, використовуючи
назву трейта. У розділі “Using Trait Objects to Abstract over
Shared Behavior” у Chapter 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, нам потрібно використовувати його за якимось
вказівником. У цьому випадку ми обрали посилання.
Далі ми поговоримо про функції та замикання!