Skip to main content

Rust

Hello world!

Данное руководство основано на Comprehensive Rust - руководстве по Rust от команды Android в Google и рассчитано на людей, которые уверенно владеют любым современным языком программирования. Еще раз: это руководство не рассчитано на тех, кто только начинает кодить 😉

Материалы для дальнейшего изучения Rust:

Hello, World

Что такое Rust?

Rust - это новый язык программирования, релиз первой версии которого состоялся в 2015 году:

  • Rust - это статический компилируемый язык (как C++)
    • rustc (компилятор Rust) использует LLVM в качестве бэкэнда
  • Rust поддерживает множество платформ и архитектур
    • x86, ARM, WebAssembly...
    • Linux, Mac, Windows...
  • Rust используется для программирования широкого диапазона устройств
    • прошивки (firmware) и загрузчики (boot loaders)
    • умные телевизоры
    • мобильные телефоны
    • настольные компьютеры
    • серверы

Некоторые преимущества Rust:

  • высокая гибкость
  • высокий уровень контроля
  • может использоваться для программирования очень низкоуровневых устройств, таких как микроконтроллеры
  • не имеет среды выполнения или сборки мусора
  • фокус на надежности и безопасности без ущерба для производительности

Hello, World

Рассмотрим простейшую программу на Rust:

fn main() {
println!("Привет 🌍!");
}

Вот что мы здесь видим:

  • функции определяются с помощью fn
  • блоки кода выделяются фигурными скобками
  • функция main() - это входная точка программы
  • Rust имеет гигиенические макросы, такие как println!()
  • строки в Rust кодируются в UTF-8 и могут содержать любой символ Юникода

Ремарки:

  • Rust очень похож на такие языки, как C/C++/Java. Он является императивным и не изобретает "велосипеды" без крайней необходимости
  • Rust является современным: полностью поддерживает такие вещи, как Юникод (Unicode)
  • Rust использует макросы (macros) для ситуаций, когда функция принимает разное количество параметров (не путать с перегрузкой функции (function overloading))
  • макросы являются "гигиеническими" - они не перехватывают случайно идентификаторы из области видимости, в которой используются. На самом деле, макросы Rust только частично являются гигиеническими
  • Rust является мультипарадигменным языком. Он имеет мощные возможности ООП и включает перечень функциональных концепций

Преимущества Rust

Некоторые уникальные особенности Rust:

  • безопасность памяти во время компиляции - весь класс проблем с памятью предотвращается во время компиляции
    • неинициализированные переменные
    • двойное освобождение (double-frees)
    • использование после освобождения (use-after-free)
    • нулевые указатели (NULL pointers)
    • забытые заблокированные мьютексы (mutexes)
    • гонки данных между потоками (threads)
    • инвалидация итератора
  • отсутствие неопределенного поведения во время выполнения - то, что делает инструкция Rust, никогда не остается неопределенным
    • проверяются границы доступа (index boundaries) к массиву
    • переполнение (overflowing) целых чисел приводит к панике или оборачиванию (wrapping)
  • современные возможности - столь же выразительные и эргономичные, как в высокоуровневых языках
    • перечисления и сопоставление с образцом (matching)
    • дженерики (generics)
    • интерфейс внешних функций (foreign function interface, FFI) без накладных расходов
    • бесплатные абстракции
    • отличные ошибки компилятора
    • встроенное управление зависимостями
    • встроенная поддержка тестирования
    • превосходная поддержка протокола языкового сервера (Language Server Protocol)

Песочница

Песочница Rust предоставляет легкий способ быстро запускать короткие программы Rust.

Типы и значения

Переменные

Безопасность типов в Rust обеспечивается за счет статической типизации. Привязки переменных (variable bindings) выполняются с помощью let:

fn main() {
let x: i32 = 10;
println!("x: {x}");
// x = 20;
// println!("x: {x}");
}
  • Раскомментируйте x = 20, чтобы увидеть, что переменные по умолчанию являются иммутабельными (неизменными/неизменяемыми). Добавьте ключевое слово mut после let, чтобы сделать переменную мутабельной
  • i32 - это тип переменной. Тип переменной должен быть известен во время компиляции, но выведение типов (рассматриваемое позже) позволяет разработчикам опускать типы во многих случаях

Значения

Вот некоторые базовые встроенные типы и синтаксис литеральных значений каждого типа:

ТипыЛитералы
Целые числа со знакомi8, i16, i32, i64, i128, isize-10, 0, 1_000, 123_i64
Целые числа без знакаu8, u16, u32, u64, u128, usize0, 123, 10_u16
Числа с плавающей точкойf32, f643.14, -10.0e20, 2_f32
Скалярные значения Юникодаchar'a', 'α', '∞'
Логические значенияbooltrue,false

Типы имеют следующие размеры:

  • iN, uN и fN - N бит

  • isize и usize - размер указателя

  • char - 32 бита

  • bool - 8 бит

  • Нижние подчеркивания предназначены для улучшения читаемости, поэтому их можно не писать, т.е. 1_000 можно записать как 1000 (или 10_00), а 123_i64 можно записать как 123i64

Арифметика

fn interproduct(a: i32, b: i32, c: i32) -> i32 {
return a * b + b * c + c * a;
}

fn main() {
println!("результат: {}", interproduct(120, 100, 248));
}

В арифметике Rust нет ничего особенного по сравнению с другими языками программирования, за исключением определения поведения при переполнении целых чисел: при сборке для разработки программа запаникует, а при релизной сборке переполнение будет обернуто (wrapped). Кроме переполнения, существует также насыщение (saturating) и каррирование (carrying), которые обеспечиваются соответствующими методами, например, (a * b).saturating_add(b * c).saturating_add(c * a).

Строки

В Rust существует 2 типа для представления строк, оба будут подробно рассмотрены позже. Оба типа всегда хранят закодированные в UTF-8 строки.

  • String - модифицируемая, собственная (owned) строка
  • &str - строка, доступная только для чтения. Строковые литералы имеют этот тип
fn main() {
let greeting: &str = "Привет";
let planet: &str = "🪐";
let mut sentence = String::new();
sentence.push_str(greeting);
sentence.push_str(", ");
sentence.push_str(planet);
println!("итоговое предложение: {}", sentence);
println!("{:?}", &sentence[0..5]);
//println!("{:?}", &sentence[12..13]);
}

Ремарки:

  • поведение при наличии в строке невалидных символов UTF-8 в Rust является неопределенным, поэтому использование таких символов может привести к панике
  • String - это пользовательский тип с конструктором (::new()) и методами вроде push_str()
  • & в &str является индикатором того, что это ссылка. Мы поговорим о ссылках позже, пока думайте о &str как о строках, доступных только для чтения
  • закомментированная строка представляет собой индексирование строки по позициям байт. 12..13 не попадают в границы (boundaries) символа, поэтому программа паникует. Измените диапазон на основе сообщения об ошибке
  • сырые (raw) строки позволяют создавать &str с автоматическим экранированием специальных символов: r"\n" == "\\n". Двойные кавычки можно вставить, обернув строку в одинаковое количество # с обеих сторон:
fn main() {
// Сырая строка
println!(r#"<a href="link.html">ссылка</a>"#); // "<a href="link.html">ссылка</a>"
// Экранирование
println!("<a href=\"link.html\">ссылка</a>"); // <a href="link.html">ссылка</a>
}

Выведение типов

Для определения/выведения типа переменной Rust "смотрит" на то, как она используется:

fn takes_u32(x: u32) {
println!("u32: {x}");
}

fn takes_i8(y: i8) {
println!("i8: {y}");
}

fn main() {
let x = 10;
let y = 20;

takes_u32(x);
takes_i8(y);
// takes_u32(y);
}

Дефолтным целочисленным типом является i32 ({integer} в сообщениях об ошибках), а дефолтным "плавающим" типом - f64 ({float} в сообщениях об ошибках).

fn main() {
let x = 3.14;
let y = 20;
assert_eq!(x, y);
// ERROR: no implementation for `{float} == {integer}`
// Целые числа и числа с плавающей точкой по умолчанию сравнивать между собой нельзя
}

Упражнение: Фибоначчи

Первое и второе числа Фибоначчи - 1. Для n > 2 nth (итое) число Фибоначчи вычисляется рекурсивно как сумма n - 1 и n - 2 чисел Фибоначчи.

Напишите функцию fib(n), которая вычисляет nth-число Фибоначчи.

fn fib(n: u32) -> u32 {
if n <= 2 {
// Базовый случай
todo!("реализуй меня")
} else {
// Рекурсия
todo!("реализуй меня")
}
}

fn main() {
let n = 20;
println!("fib(n) = {}", fib(n));
// Макрос для проверки двух выражений на равенство.
// Неравенство вызывает панику
assert_eq!(fib(n), 6765);
}
Решение:
fn fib(n: u32) -> u32 {
if n <= 2 {
return 1;
} else {
return fib(n - 1) + fib(n - 2);
}
}

fn main() {
let n = 20;
println!("fib(n) = {}", fib(n));
assert_eq!(fib(n), 6765);
}

Поток управления

Условия

Большая часть синтаксиса потока управления Rust похожа на C, C++ или Java:

  • блоки разделяются фигурными скобками
  • строчные комментарии начинаются с //, блочные - разделяются /* ... */
  • ключевые слова if и while работают, как ожидается
  • значения переменным присваиваются с помощью =, сравнения выполняются с помощью ==

Выражения if

Выражения if используются в точности, как в других языках:

fn main() {
let x = 10;
if x < 20 {
println!("маленькое");
} else if x < 100 {
println!("больше");
} else {
println!("огромное");
}
}

Кроме того, if можно использовать как выражение, возвращающее значение. Последнее выражение каждого блока становится значением выражения if:

fn main() {
let x = 10;
let size = if x < 20 { "маленькое" } else { "большое" };
println!("размер числа: {}", size);
}

Поскольку if является выражением и должно иметь определенный тип, значения обоих блоков должны быть одного типа. Попробуйте добавить ; после маленькое во втором примере.

При использовании if в качестве выражения, оно должно заканчиваться ; для его отделения от следующей инструкции. Попробуйте удалить ; перед println!().

Циклы

Rust предоставляет 3 ключевых слова для создания циклов: while, loop и for.

while

Ключевое слово while работает, как в других языках - тело цикла выполняется, пока условие является истинным:

fn main() {
let mut x = 200;
while x >= 10 {
x = x / 2;
}
println!("итоговое значение x: {x}");
}

for

Цикл for перебирает диапазон значений:

fn main() {
for x in 1..5 {
println!("x: {x}");
}
}

loop

Цикл loop продолжается до прерывания с помощью break:

fn main() {
let mut i = 0;
loop {
i += 1;
println!("{i}");
if i > 100 {
break;
}
}
}
  • Мы подробно обсудим итераторы позже
  • обратите внимание, что цикл for итерируется до 4. Для "включающего" диапазона используется синтаксис 1..=5

break и continue

Ключевое слово break используется для раннего выхода (early exit) из цикла. Для loop break может принимать опциональное выражение, которое становится значением выражения loop.

Для незамедлительного перехода к следующей итерации используется ключевое слово continue.

fn main() {
let (mut a, mut b) = (100, 52);
let result = loop {
if a == b {
break a;
}
if a < b {
b -= a;
} else {
a -= b;
}
};
println!("{result}");
}

continue и break могут помечаться метками (labels):

fn main() {
'outer: for x in 1..5 {
println!("x: {x}");
let mut i = 0;
while i < x {
println!("x: {x}, i: {i}");
i += 1;
if i == 3 {
break 'outer;
}
}
}
}

В примере мы прерываем внешний цикл после 3 итераций внутреннего цикла.

Обратите внимание, что только loop может возвращать значения. Это связано с тем, что цикл loop гарантировано выполняется хотя бы раз (в отличие от циклов while и for).

Блоки и области видимости

Блоки

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

fn main() {
let z = 13;
let x = {
let y = 10;
println!("y: {y}");
z - y
};
println!("x: {x}");
}

Если последнее выражение заканчивается ;, результирующим значением и типом является () (пустой тип/кортеж - unit type).

Области видимости и затенение

Областью видимости (scope) переменной является ближайший к ней блок.

Переменные можно затенять/переопределять (shadow), как внешние, так и из той же области видимости:

fn main() {
let a = 10;
println!("перед: {a}");
{
let a = "привет";
println!("внутренняя область видимости: {a}");

let a = true;
println!("затенение во внутренней области видимости: {a}");
}

println!("после: {a}");
}
  • Для того, чтобы убедиться в том, что область видимости переменной ограничена фигурными скобками, добавьте переменную b во внутреннюю область видимости и попробуйте получить к ней доступ во внешней области видимости
  • затенение отличается от мутации, поскольку после затенения обе локации памяти переменной существуют в одно время. Обе доступны под одним названием в зависимости от использования в коде
  • затеняемая переменная может иметь другой тип
  • поначалу затенение выглядит неясным, но оно удобно для сохранения значений после unwrap() (распаковки)

Функции

fn gcd(a: u32, b: u32) -> u32 {
if b > 0 {
gcd(b, a % b)
} else {
a
}
}

fn main() {
println!("наибольший общий делитель: {}", gcd(143, 52));
}
  • Типы определяются как для параметров, так и для возвращаемого значения
  • последнее выражение в теле функции становится возвращаемым значением (после него не должно быть ;). Для раннего возврата может использоваться ключевое слово return
  • дефолтным типом, возвращаемым функцией, является () (это справедливо также для функций, которые ничего не возвращают явно)
  • перегрузка функций в Rust не поддерживается
    • число параметров всегда является фиксированным. Параметры по умолчанию не поддерживаются. Для создания функций с переменным количеством параметров используются макросы (macros)
    • параметры имеют типы. Эти типы могут быть общими (дженериками - generics). Мы обсудим это позже

Макросы

Макросы раскрываются (expanded) в коде в процессе компиляции и могут принимать переменное количество параметров. Они обозначаются с помощью ! в конце. Стандартная библиотека Rust включает несколько полезных макросов:

  • println!(format, ..) - печатает строку в стандартный вывод, применяя форматирование, описанное в std::fmt
  • format!(format, ..) - работает как println!(), но возвращает строку
  • dbg!(expression) - выводит значение выражения в терминал и возвращает его
  • todo!() - помечает код как еще не реализованный. Выполнение этого кода приводит к панике программы
  • unreachable!() - помечает код как недостижимый. Выполнение этого кода приводит к панике программы
fn factorial(n: u32) -> u32 {
let mut product = 1;
for i in 1..=n {
product *= dbg!(i);
}
product
}

fn fizzbuzz(n: u32) -> u32 {
todo!("реализуй меня")
}

fn main() {
let n = 13;
println!("{n}! = {}", factorial(4));
}

Упражнение: гипотеза Коллатца

Для объяснения сути гипотезы Коллатца рассмотрим следующую последовательность чисел, называемую сиракузской последовательностью. Берем любое натуральное число n. Если оно четное, то делим его на 2, а если нечетное, то умножаем на 3 и прибавляем 1 (получаем 3n + 1). Над полученным числом выполняем те же самые действия, и так далее. Последовательность прерывается на ni, если ni равняется 1.

Например, для числа 3 получаем:

  • 3 - нечетное, 3*3 + 1 = 10
  • 10 - четное, 10:2 = 5
  • 5 - нечетное, 5*3 + 1 = 16
  • 16 - четное, 16/2 = 8
  • 8 - четное, 8/2 = 4
  • 4 - четное, 4/2 = 2
  • 2 - четное, 2/2 = 1
  • 1 - нечетное (последовательность прерывается, n равняется 8)

Напишите функцию для вычисления сиракузской последовательности для указанного числа n.

fn collatz_length(mut n: i32) -> u32 {
todo!("реализуй меня")
}

fn main() {
println!("длина последовательности: {}", collatz_length(11));
assert_eq!(collatz_length(11), 15);
}
Решение:
fn collatz_length(mut n: i32) -> u32 {
let mut len = 1;
while n > 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
len += 1;
}
len
}

fn main() {
println!("длина последовательности: {}", collatz_length(11));
assert_eq!(collatz_length(11), 15);
}

Кортежи и массивы

Кортежи и массивы

Кортежи (tuples) и массивы (arrays) - первые "составные" (compound) типы, которые мы изучим. Все элементы массива должны быть одного типа, элементы кортежа могут быть разных типов. И массивы, и кортежи имеют фиксированный размер.

ТипыЛитералы
Массивы[T; N][20, 30, 40], [0; 3]
Кортежи(), (T,), (T1, T2)(), ('x',), ('x', 1.2)

Определение массива и доступ к его элементам:

fn main() {
let mut a: [i8; 10] = [42; 10];
a[5] = 0;
println!("a: {a:?}");
}

Определение кортежа и доступ к его элементам:

fn main() {
let t: (i8, bool) = (7, true);
println!("t.0: {}", t.0);
println!("t.1: {}", t.1);
}

Массивы:

  • значением массива типа [T; N] является N (константа времени компиляции) элементов типа T. Обратите внимание, что длина массива является частью его типа, поэтому [u8; 3] и [u8; 4] считаются двумя разными типами. Срезы (slices), длина которых определяется во время выполнения, мы рассмотрим позже
  • попробуйте получить доступ к элементу за пределами границ массива. Доступ к элементам массива проверяется во время выполнения. Rust обычно выполняет различные оптимизации такой проверки, а в небезопасном Rust ее можно отключить
  • для присвоения значения массиву можно использовать литералы
  • поскольку массивы имеют реализацию только отладочного вывода, они форматируются с помощью {:?} или {:#?}

Кортежи:

  • как и массивы, кортежи имеют фиксированный размер
  • кортежи группируют значения разных типов в один составной тип
  • доступ к полям кортежа можно получить с помощью точки и индекса, например, t.0, t.1
  • пустой кортеж () также называется "единичным/пустым типом" (unit type). Это и тип, и его единственное валидное значение. Пустой тип является индикатором того, что функция или выражение ничего не возвращают (в этом смысле пустой тип похож на void в других языках)

Перебор массива

Для перебора массива (но не кортежа) может использоваться цикл for:

fn main() {
let primes = [2, 3, 5, 7, 11, 13, 17, 19];
for prime in primes {
for i in 2..prime {
assert_ne!(prime % i, 0);
}
}
}

Возможность перебора массива в цикле for обеспечивается трейтом IntoIterator, о котором мы поговорим позже.

В примере мы видим новый макрос assert_ne!. Существуют также макросы assert_eq! и assert!. Эти макросы проверяются всегда, в отличие от их аналогов для отладки debug_assert! и др., которые удаляются из производственной сборки.

Сопоставление с образцом

Ключевое слово match позволяет сопоставлять значение с одним или более паттернами/шаблонами. Сравнение выполняется сверху вниз, побеждает первое совпадение.

match похож на switch из других языков:

#[rustfmt::skip]
fn main() {
let input = 'x';
match input {
'q' => println!("выход"),
'a' | 's' | 'w' | 'd' => println!("движение"),
'0'..='9' => println!("число"),
key if key.is_lowercase() => println!("буква в нижнем регистре: {key}"),
_ => println!("другое"),
}
}

Паттерн _ - это шаблон подстановочного знака (wildcard pattern), который соответствует любому значению. Сопоставления должны быть исчерпывающими, т.е. охватывать все возможные случаи, поэтому _ часто используется как финальный перехватчик.

Сопоставление может использоваться как выражение. Как и в случае с if, блоки match должны иметь одинаковый тип. Типом является последнее выражение в блоке, если таковое имеется. В примере типом является ().

Переменная в паттерне (key в примере) создает привязку, которая может использоваться в блоке.

Защитник сопоставления (match guard - if ...) допускает совпадение только при удовлетворении условия.

Ремарки:

  • вы могли заметить некоторые специальные символы, которые используются в шаблонах:
    • | - это or (или)
    • .. - распаковка значения
    • 1..=5 - включающий диапазон
    • _ - подстановочный знак
  • защита сопоставления важна и необходима, когда мы хотим кратко выразить более сложные идеи, чем позволяют одни только шаблоны
  • защита сопоставление и использование if внутри блока match - разные вещи
  • условие, определенное в защитнике сопоставления, применяется ко всем выражениям паттерна, определенного с помощью |

Деструктуризация

Деструктуризация - это способ извлечения данных из структуры данных с помощью шаблона, совпадающего со структурой данных. Это способ привязки к субкомпонентам (subcomponents) структуры данных.

Кортежи

fn main() {
describe_point((1, 0));
}

fn describe_point(point: (i32, i32)) {
match point {
(0, _) => println!("на оси Y"),
(_, 0) => println!("на оси X"),
(x, _) if x < 0 => println!("слева от оси Y"),
(_, y) if y < 0 => println!("ниже оси X"),
_ => println!("первый квадрант"),
}
}

Массивы

#[rustfmt::skip]
fn main() {
let triple = [0, -2, 3];
println!("расскажи мне о {triple:?}");
match triple {
[0, y, z] => println!("первый элемент - это 0, y = {y} и z = {z}"),
[1, ..] => println!("первый элемент - это 1, остальные элементы игнорируются"),
_ => println!("все элементы игнорируются"),
}
}
  • Создайте новый шаблон массива, используя _ для представления элемента
  • добавьте в массив больше значений
  • обратите внимание, как .. расширяется (expand) до разного количества элементов
  • покажите сопоставление с хвостом (tail) с помощью шаблонов [.., b] и [a@.., b]

Упражнение: вложенные массивы

Массивы могут содержать другие массивы:

let matrix3x3 = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

Каков тип этой переменной?

Напишите функцию transpose(), которая транспонирует матрицу 3х3 (превращает строки в колонки).

fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
todo!("реализуй меня")
}

fn main() {
let matrix = [
[101, 102, 103], // <-- комментарий не дает `rustfmt` форматировать `matrix` в одну строку
[201, 202, 203],
[301, 302, 303],
];
let transposed = transpose(matrix);
println!("транспонированная матрица: {:#?}", transposed);
assert_eq!(
transposed,
[
[101, 201, 301], //
[102, 202, 302],
[103, 203, 303],
]
);
}
Решение:
fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
let mut result = [[0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
result[j][i] = matrix[i][j];
}
}
result
}

Ссылки

Общие ссылки

Ссылка (reference) - это способ получить доступ к значению без принятия его во владение, т.е. без заимствования (borrowing) этого значения. Общие/распределенные (shared) ссылки доступны только для чтения: ссылочные данные не могут модифицироваться.

fn main() {
let a = 'A';
let b = 'B';
let mut r: &char = &a;
println!("r: {}", *r);
r = &b;
println!("r: {}", *r);
}

Общая ссылка на тип T имеет тип &T. Оператор & указывает на то, что это ссылка. Оператор * используется для разыменования (dereferencing) ссылки - получения ссылочного значения.

Rust запрещает висящие ссылки (dangling references):

fn x_axis(x: i32) -> &(i32, i32) {
let point = (x, 0);
return &point;
}

Ремарки:

  • ссылка "заимствует" значение, на которое она ссылается. Код может использовать ссылку для доступа к значению, но его "владельцем" (owner) будет оригинальная переменная. Мы подробно поговорим о владении в 3 части
  • ссылки реализованы как указатели (pointers) в C или C++, ключевым преимуществом которых является то, что они могут быть намного меньше, чем вещи, на которые они указывают. Позже мы будем говорить о том, как Rust обеспечивает безопасную работу с памятью, предотвращая баги, связанные с сырыми (raw) указателями
  • Rust не создает ссылки автоматически
  • в некоторых случаях Rust выполняет разыменование автоматически, например, при вызове методов (r.count_ones())
  • в первом примере переменная r является мутабельной, поэтому ее значение можно менять (r = &b). Это повторно привязывает r, теперь она указывает на что-то другое. Это отличается от C++, где присвоение значения ссылке меняет ссылочное значение
  • общая ссылка не позволяет модифицировать значение, на которое она ссылается, даже если это значение является мутабельным (попробуйте *r = 'X')
  • Rust отслеживает времена жизни (lifetimes) всех ссылок, чтобы убедиться, что они живут достаточно долго. В безопасном Rust не может быть висящих ссылок (dangling pointers). x_axis() возвращает ссылку на point, но point уничтожается (выделенная память освобождается - deallocate) после выполнения кода функции, и код не компилируется

Эксклюзивные ссылки

Эксклюзивные ссылки (exclusive references), также известные как мутабельные ссылки (mutable references), позволяют менять значение, на которое они ссылаются. Они имеют тип &mut T:

fn main() {
let mut point = (1, 2);
let x_coord = &mut point.0;
*x_coord = 20;
println!("point: {point:?}");
}

Ремарки:

  • "эксклюзивный" означает, что только эта ссылка может использоваться для доступа к значению. Других ссылок (общих или эксклюзивных) существовать не должно. Ссылочное значение недоступно, пока существует эксклюзивная ссылка. Попробуйте получить доступ к &point.0 или изменить point.0, пока жива x_coord
  • убедитесь в том, что понимаете разницу между let mut x_coord: &i32 и let x_coord: &mut i32. Первая переменная - это общая ссылка, которая может быть привязана к разным значениям, вторая - эксклюзивная ссылка на мутабельную переменную

Упражнение: геометрия

Ваша задача - создать несколько вспомогательных функций для трехмерной геометрии, представляющей точку как [f64; 3].

// Функция для вычисления магнитуды вектора: суммируем квадраты координат вектора
// и извлекаем из этой суммы квадратный корень.
// Метод для извлечения квадратного корня - `sqrt()` (`v.sqrt()`)
fn magnitude(...) -> f64 {
todo!("реализуй меня")
}

// Функция нормализации вектора: вычисляем магнитуду вектора
// и делим на нее все координаты вектора
fn normalize(...) {
todo!("реализуй меня")
}

fn main() {
println!("магнитуда единичного вектора: {}", magnitude(&[0.0, 1.0, 0.0]));

let mut v = [1.0, 2.0, 9.0];
println!("магнитуда {v:?}: {}", magnitude(&v));
normalize(&mut v);
println!("магнитуда {v:?} после нормализации: {}", magnitude(&v));
}
Решение:
fn magnitude(vector: &[f64; 3]) -> f64 {
let mut mag_squared = 0.0;
for coord in vector {
mag_squared += coord * coord;
}
mag_squared.sqrt()
}

fn normalize(vector: &mut [f64; 3]) {
let mag = magnitude(vector);
vector[0] /= mag;
vector[1] /= mag;
vector[2] /= mag;
}

Пользовательские типы

Именованные структуры

Rust поддерживает кастомные структуры:

struct Person {
name: String,
age: u8,
}

fn describe(person: &Person) {
println!("{} is {} years old", person.name, person.age);
}

fn main() {
let mut peter = Person { name: String::from("Peter"), age: 27 };
describe(&peter);

peter.age = 28;
describe(&peter);

let name = String::from("Avery");
let age = 39;
let avery = Person { name, age };
describe(&avery);

let jackie = Person { name: String::from("Jackie"), ..avery };
describe(&jackie);
}

Ремарки:

  • тип структуры отдельно определять не нужно
  • структуры не могут наследовать друг другу
  • для реализации трейта на типе, в котором не нужно хранить никаких значений, можно использовать структуру нулевого размера (zero-sized), например, struct Foo;
  • если название переменной совпадает с названием поля, то, например, name: name можно сократить до name
  • синтаксис ..avery позволяет копировать большую часть полей старой структуры в новую структуру. Он должен быть последним элементом

Кортежные структуры

Если названия полей неважны, можно использовать кортежную структуру:

struct Point(i32, i32);

fn main() {
let p = Point(17, 23);
println!("({}, {})", p.0, p.1);
}

Это часто используется для оберток единичных полей (single-field wrappers), которые называются newtypes (новыми типами):

struct PoundsOfForce(f64);
struct Newtons(f64);

fn compute_thruster_force() -> PoundsOfForce {
todo!("Ask a rocket scientist at NASA")
}

fn set_thruster_force(force: Newtons) {
// ...
}

fn main() {
let force = compute_thruster_force();
set_thruster_force(force);
}

Ремарки:

  • newtype - отличный способ закодировать дополнительную информацию о значении в примитивном типе, например:
    • число измеряется в определенных единицах (Newtons)
    • при создании значение проходит определенную валидацию, которую не нужно каждый раз выполнять вручную: PhoneNumber(String) или OddNumber(u32)
  • пример является тонкой отсылкой к провалу Mars Climate Orbiter

Перечисления

Ключевое слово enum позволяет создать тип, который имеет несколько вариантов:

#[derive(Debug)]
enum Direction {
Left,
Right,
}

#[derive(Debug)]
enum PlayerMove {
Pass, // простой вариант
Run(Direction), // кортежный вариант
Teleport { x: u32, y: u32 }, // структурный вариант
}

fn main() {
let m: PlayerMove = PlayerMove::Run(Direction::Left);
println!("On this turn: {:?}", m);
}

Ремарки:

  • перечисление позволяет собрать набор значений в один тип
  • Direction - это тип с двумя вариантами: Direction::Left и Direction::Right
  • PlayerMove - это тип с тремя вариантами. В дополнение к полезным нагрузкам (payloads) Rust будет хранить дискриминант, чтобы во время выполнения знать, какой вариант находится в значении PlayerMove
  • Rust использует минимальное пространство для хранения дискриминанта
    • при необходимости сохраняется целое число наименьшего требуемого размера
    • если разрешенные значения варианта не охватывают все битовые комбинации, для кодирования дискриминанта будут использоваться недопустимые битовые комбинации ("нишевые оптимизации" (niche optimization)). Например, Option<&u8> хранит либо указатель на целое число, либо NULL для варианта None
    • при необходимости дискриминантом можно управлять (например, для обеспечения совместимости с C):
#[repr(u32)]
enum Bar {
A, // 0
B = 10000,
C, // 10001
}

fn main() {
println!("A: {}", Bar::A as u32);
println!("B: {}", Bar::B as u32);
println!("C: {}", Bar::C as u32);
}

Без repr тип дискриминанта занимает 2 байта, поскольку 10001 соответствует двум байтам.

Статики и константы

Статичные (static) и константные (constant) переменные - это 2 способа создания значений с глобальной областью видимости, которые не могут быть перемещены или перераспределены при выполнении программы.

const

Константные значения оцениваются во время компиляции и их значения встраиваются при использовании (inlined upon use):

const DIGEST_SIZE: usize = 3;
const ZERO: Option<u8> = Some(42);

fn compute_digest(text: &str) -> [u8; DIGEST_SIZE] {
let mut digest = [ZERO.unwrap_or(0); DIGEST_SIZE];
for (idx, &b) in text.as_bytes().iter().enumerate() {
digest[idx % DIGEST_SIZE] = digest[idx % DIGEST_SIZE].wrapping_add(b);
}
digest
}

fn main() {
let digest = compute_digest("hello");
println!("digest: {digest:?}");
}

Только функции, помеченные с помощью const, могут вызываться во время компиляции для генерации значений const. Но такие функции могут вызываться и во время выполнения.

static

Статичные переменные живут на протяжении всего жизненного цикла программы и не могут перемещаться:

static BANNER: &str = "welcome";

fn main() {
println!("{BANNER}");
}

Значения статичных переменных не встраиваются при использовании и имеют фиксированные локации в памяти. Это может быть полезным для небезопасного и встроенного кода (FFI), но для создания глобальных переменных рекомендуется использовать const.

Ремарки:

  • static обеспечивает идентичность объекта (object identity): адрес в памяти и состояние, как того требуют типы с внутренней изменчивостью, такие как Mutex<T>
  • константы, которые оцениваются во время выполнения, требуются нечасто, но иногда они могут оказаться полезными, и их использование безопаснее, чем использование статик

Синонимы типов

Синоним типа (type alias) создает название для другого типа. Два типа могут использоваться взаимозаменяемо:

enum CarryableConcreteItem {
Left,
Right,
}

type Item = CarryableConcreteItem;

// Синонимы особенно полезны для длинных, сложных типов
use std::cell::RefCell;
use std::sync::{Arc, RwLock};
type PlayerInventory = RwLock<Vec<Arc<RefCell<Item>>>>;

Упражнение: события в лифте

Ваша задача состоит в том, чтобы создать структуру данных для представления событий в системе управления лифтом. Вам необходимо определить типы и функции для создания различных событий. Используйте #[derive(Debug)], чтобы разрешить форматирование типов с помощью {:?}.

Это упражнение требует только создания и заполнения структур данных, чтобы функция main() работала без ошибок.

#[derive(Debug)]
/// Событие, на которое должен реагировать контроллер
enum Event {
todo!("Добавить необходимые варианты")
}

/// Направление движения
#[derive(Debug)]
enum Direction {
Up,
Down,
}

/// Лифт прибыл на определенный этаж
fn car_arrived(floor: i32) -> Event {
todo!("реализуй меня")
}

/// Двери лифта открылись
fn car_door_opened() -> Event {
todo!("реализуй меня")
}

/// Двери лифта закрылись
fn car_door_closed() -> Event {
todo!("реализуй меня")
}

/// В вестибюле лифта на определенном этаже была нажата кнопка направления
fn lobby_call_button_pressed(floor: i32, dir: Direction) -> Event {
todo!("реализуй меня")
}

/// В кабине лифта была нажата кнопка этажа
fn car_floor_button_pressed(floor: i32) -> Event {
todo!("реализуй меня")
}

fn main() {
println!(
"Пассажир первого этажа нажал кнопку вверх: {:?}",
lobby_call_button_pressed(0, Direction::Up)
);
println!("Лифт прибыл на первый этаж: {:?}", car_arrived(0));
println!("Двери лифта открылись: {:?}", car_door_opened());
println!(
"Пассажир нажал на кнопку третьего этажа: {:?}",
car_floor_button_pressed(3)
);
println!("Двери лифта закрылись: {:?}", car_door_closed());
println!("Лифт прибыл на третий этаж: {:?}", car_arrived(3));
}
Решение:
#[derive(Debug)]
enum Event {
/// Была нажата кнопка
ButtonPressed(Button),
/// Лифт прибыл на определенный этаж
CarArrived(Floor),
/// Двери лифта открылись
CarDoorOpened,
/// Двери лифта закрылись
CarDoorClosed,
}

/// Этаж представлен целым числом
type Floor = i32;

#[derive(Debug)]
enum Direction {
Up,
Down,
}

/// Доступная пользователю кнопка
#[derive(Debug)]
enum Button {
/// Кнопка вызова/направления в вестибюле лифта на определенном этаже
LobbyCall(Direction, Floor),
/// Кнопка этажа в кабине лифта
CarFloor(Floor),
}

fn car_arrived(floor: i32) -> Event {
Event::CarArrived(floor)
}

fn car_door_opened() -> Event {
Event::CarDoorOpened
}

fn car_door_closed() -> Event {
Event::CarDoorClosed
}

fn lobby_call_button_pressed(floor: i32, dir: Direction) -> Event {
Event::ButtonPressed(Button::LobbyCall(dir, floor))
}

fn car_floor_button_pressed(floor: i32) -> Event {
Event::ButtonPressed(Button::CarFloor(floor))
}

Сопоставление с образцом

Деструктуризация

Как и кортежи (tuples), структуры (structs) и перечисления (enums) также могут деструктурироваться (destructure) сопоставлением:

Структуры

struct Foo {
x: (u32, u32),
y: u32,
}

// Запрещаем форматирование
#[rustfmt::skip]
fn main() {
let foo = Foo { x: (1, 2), y: 3 };
match foo {
Foo { x: (1, b), y } => println!("x.0 = 1, b = {b}, y = {y}"),
Foo { y: 2, x: i } => println!("y = 2, x = {i:?}"),
Foo { y, .. } => println!("y = {y}, другие поля игнорируются"),
}
}

Перечисления

Шаблоны (patterns) могут использоваться для привязки переменных к частям значений. Это, помимо прочего, позволяет исследовать структуру типов. Начнем с определения простого enum:

enum Result {
Ok(i32),
Err(String),
}

fn divide_in_two(n: i32) -> Result {
if n % 2 == 0 {
Result::Ok(n / 2)
} else {
Result::Err(format!("нельзя разделить {n} на 2 равные части"))
}
}

fn main() {
let n = 100;
match divide_in_two(n) {
Result::Ok(half) => println!("{n}, деленное на 2: {half}"),
Result::Err(msg) => println!("возникла ошибка: {msg}"),
}
}

Здесь для деструктуризации Result используется 2 блока (руки/рукава - arms). В первом блоке half привязывается к значению внутри варианта Ok. Во втором блоке msg привязывается к сообщению об ошибке (внутри варианта Err).

Структуры:

  • измените литеральные значения в foo для совпадения с другими шаблонами
  • добавьте новое поле в Foo и модифицируйте шаблон соответствующим образом

Перечисления:

  • выражение if-else возвращает перечисление, которое распаковывается с помощью match
  • добавьте третий вариант в перечисление и изучите сообщение об ошибке
  • доступ к значениям в вариантах перечисления возможен только после сопоставления с шаблоном
  • изучите ошибки, связанные с тем, что сопоставление не является исчерпывающим

Поток управления let

Rust предоставляет несколько конструкций управления потоком выполнения программы, которых нет в других языках программирования и которые используются для сопоставления с шаблоном:

  • if let
  • while let
  • match

if let

Выражение if-let позволяет выполнять код в зависимости от совпадения значения с шаблоном:

fn sleep_for(secs: f32) {
let dur = if let Ok(dur) = std::time::Duration::try_from_secs_f32(secs) {
dur
} else {
std::time::Duration::from_millis(500)
};
std::thread::sleep(dur);
println!("спал в течение {:?}", dur);
}

fn main() {
// Выполнится код блока `else`
sleep_for(-10.0);
// Выполнится код блока `if`
sleep_for(0.8);
}

let-else

Для обычного случая сопоставления с шаблоном и возврата из функции следует использовать let-else. Код блока else должен прерывать поток выполнения программы (return, break, panic! и т.п.).

fn hex_or_die_trying(maybe_string: Option<String>) -> Result<u32, String> {
let s = if let Some(s) = maybe_string {
s
} else {
return Err(String::from("получено `None`"));
};

let first_byte_char = if let Some(first_byte_char) = s.chars().next() {
first_byte_char
} else {
return Err(String::from("получена пустая строка"));
};

if let Some(digit) = first_byte_char.to_digit(16) {
Ok(digit)
} else {
Err(String::from("не шестнадцатеричное число"))
}
}

fn main() {
println!("результат: {:?}", hex_or_die_trying(Some(String::from("foo")))); // 15 - байтовое представление символа `f`
}

Выражение while-let повторно проверяет соответствие значения шаблону:

fn main() {
let mut name = String::from("Comprehensive Rust 🦀");
while let Some(c) = name.pop() {
println!("символ: {c}");
}
// Существуют более эффективные способы 😉
}

Здесь String::pop() возвращает Some(c) до тех пор, пока строка не окажется пустой, после чего возвращается None. while-let позволяет перебирать все элементы.

if-let:

  • в отличие от match, if-let не должно охватывать все случаи. Поэтому его использование может быть менее многословным, чем использование match
  • обычным способом использования if-let является обработка Some при работе с Option
  • в отличие от match, if-let не поддерживает защитников сопоставления (match guards)

let-else:

  • let-else поддерживает распаковку (flattening) вложенного кода. Перепишем пример следующим образом:
fn hex_or_die_trying(maybe_string: Option<String>) -> Result<u32, String> {
let Some(s) = maybe_string else {
return Err(String::from("получено `None`"));
};

let Some(first_byte_char) = s.chars().next() else {
return Err(String::from("получена пустая строка"));
};

let Some(digit) = first_byte_char.to_digit(16) else {
return Err(String::from("не шестнадцатеричное число"));
};

return Ok(digit);
}

while-let:

  • цикл while-let повторяется, пока значение совпадает с шаблоном
  • цикл while-let в примере можно сделать бесконечным с инструкцией if внутри, которая прерывает цикл, когда name.pop() ничего не возвращает

Упражнение: оценка выражения

Напишем простой рекурсивный вычислитель арифметических выражений.

Тип Box представляет собой умный указатель (smart pointer), который мы подробно рассмотрим позже. Выражение можно "упаковать" с помощью Box::new(), как показано в тестах. Для вычисления упакованного выражения, следует использовать оператор разыменования (*): eval(*boxed_expr).

Некоторые выражения не могут быть вычислены и возвращают ошибку. Стандартный тип Result<Value, String> - это перечисление, которое представляет успешное значение (Ok(Value)) или ошибку (Err(String)). Мы подробно рассмотрим этот тип позже.

// Операция, выполняемая над двумя подвыражениями
#[derive(Debug)]
enum Operation {
Add,
Sub,
Mul,
Div,
}

// Выражение в форме дерева
#[derive(Debug)]
enum Expression {
// Операция над двумя подвыражениями
Op {
op: Operation,
left: Box<Expression>,
right: Box<Expression>,
},

// Литеральное значение
Value(i64),
}

// Рекурсивный вычислитель арифметических выражений
fn eval(e: Expression) -> Result<i64, String> {
todo!("реализуй меня")
}

fn main() {
let expr = Expression::Op {
op: Operation::Sub,
left: Box::new(Expression::Value(20)),
right: Box::new(Expression::Value(10)),
};
println!("выражение: {:?}", expr);
println!("результат: {:?}", eval(expr));
}

// Модуль с тестами - код компилируется только при запуске тестов с помощью команды `cargo test`
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_value() {
assert_eq!(eval(Expression::Value(19)), Ok(19));
}

#[test]
fn test_sum() {
assert_eq!(
eval(Expression::Op {
op: Operation::Add,
left: Box::new(Expression::Value(10)),
right: Box::new(Expression::Value(20)),
}),
Ok(30)
);
}

#[test]
fn test_recursion() {
let term1 = Expression::Op {
op: Operation::Mul,
left: Box::new(Expression::Value(10)),
right: Box::new(Expression::Value(9)),
};
let term2 = Expression::Op {
op: Operation::Mul,
left: Box::new(Expression::Op {
op: Operation::Sub,
left: Box::new(Expression::Value(3)),
right: Box::new(Expression::Value(4)),
}),
right: Box::new(Expression::Value(5)),
};
assert_eq!(
eval(Expression::Op {
op: Operation::Add,
left: Box::new(term1),
right: Box::new(term2),
}),
Ok(85)
);
}

#[test]
fn test_error() {
assert_eq!(
eval(Expression::Op {
op: Operation::Div,
left: Box::new(Expression::Value(99)),
right: Box::new(Expression::Value(0)),
}),
Err(String::from("деление на ноль"))
);
}
}
Решение:
fn eval(e: Expression) -> Result<i64, String> {
// Определяем вариант
match e {
// Операция.
// Деструктуризация
Expression::Op { op, left, right } => {
// Рекурсивно вычисляем левое подвыражение
let left = match eval(*left) {
Ok(v) => v,
Err(e) => return e,
};
// Рекурсивно вычисляем правое подвыражение
let right = match eval(*right) {
Ok(v) => v,
Err(e) => return e,
};
// Возвращаем результат, упакованный в `Ok`
Ok(
// Определяем тип операции
match op {
Operation::Add => left + right,
Operation::Sub => left - right,
Operation::Mul => left * right,
Operation::Div => {
// Если правый операнд равняется 0
if right == 0 {
// Возвращаем вызывающему (caller) сообщение об ошибке, обернутое в `Err`.
// Мы распространяем (propagate) ошибку, поэтому она не оборачивается в `Ok`
return Err(String::from("деление на ноль"));
} else {
left / right
}
}
}
)
}
// Значение.
// Просто возвращаем значение, упакованное в `Ok`
Expression::Value(v) => Ok(v),
}
}

Методы и трейты

Методы

Rust позволяет привязывать функции к типам (такие функции называются ассоциированными - методы экземпляров в других языках). Это делается с помощью блока impl:

#[derive(Debug)]
struct Race {
name: String,
laps: Vec<i32>,
}

impl Race {
// Нет получателя, статичный метод
fn new(name: &str) -> Self {
Self { name: String::from(name), laps: Vec::new() }
}

// Эксклюзивное заимствование (exclusive borrowing), допускающее чтение и запись в `self`
fn add_lap(&mut self, lap: i32) {
self.laps.push(lap);
}

// Общее, доступное только для чтение заимствование (shared borrowing) `self`
fn print_laps(&self) {
println!("Записано время {} кругов для {}:", self.laps.len(), self.name);
for (idx, lap) in self.laps.iter().enumerate() {
println!("Круг {idx}: {lap} секунд");
}
}

// Эксклюзивное владение (exclusive ownership) `self`
fn finish(self) {
let total: i32 = self.laps.iter().sum();
println!("Гонка {} закончена, общее время: {}", self.name, total);
}
}

fn main() {
let mut race = Race::new("Monaco Grand Prix");
race.add_lap(70);
race.add_lap(68);
race.print_laps();
race.add_lap(71);
race.print_laps();
race.finish();
// race.add_lap(42);
}

Аргументы self определяют "получателя" (receiver) - объект, на котором реализуется метод. Получатели могут быть следующими:

  • &self - заимствует объект у вызывающего с помощью общей иммутабельной ссылки. После этого объект может быть повторно использован
  • &mut self - заимствует объект у вызывающего с помощью уникальной мутабельной ссылки. После этого объект может быть повторно использован
  • self - принимает владение объектом и перемещает его от вызывающего. Метод становится владельцем объекта. Объект удаляется (освобождается) после того, как метод вернул значение. Полное владение не означает автоматической мутабельности
  • mut self - аналогично self, но метод может модифицировать объект
  • нет получателя - такой метод становится статичным. Обычно используется для создания конструкторов, которые по соглашению вызываются с помощью new()

Ремарки:

  • методы отличаются от функций следующим:
    • методы вызываются на экземпляре типа (такого как структура или перечисление), их первый параметр - сам экземпляр (self)
    • методы позволяют держать код реализации функционала в одном месте, что способствует лучшей организации кода
  • особенности использования ключевого слова self:
    • self является сокращением для self: Self, вместо Self может использоваться название структуры, например, Race
    • таким образом, Self - это синоним реализуемого (impl) типа и может быть использован в любом месте внутри блока
    • self используется как другие структуры, для доступа к его отдельным полям может использоваться точечная нотация
    • для демонстрации разницы между &self и self попробуйте запустить finish() дважды
    • существуют также специальные обертки типов, которые могут использоваться в качестве типов получателя, например, Box<Self>

Трейты

Rust позволяет создавать абстрактные типы с помощью трейтов (traits). Они похожи на интерфейсы в других языках программирования:

struct Dog {
name: String,
age: i8,
}
struct Cat {
lives: i8,
}

trait Pet {
fn talk(&self) -> String;

fn greet(&self) {
println!("Какая милаха! Как тебя зовут? {}", self.talk());
}
}

impl Pet for Dog {
fn talk(&self) -> String {
format!("Гав, меня зовут {}!", self.name)
}
}

impl Pet for Cat {
fn talk(&self) -> String {
String::from("Мау!")
}
}

fn main() {
let captain_floof = Cat { lives: 9 };
let fido = Dog { name: String::from("Фидо"), age: 5 };

captain_floof.greet();
fido.greet();
}

Ремарки:

  • трейт определяет методы, которые должен предоставлять тип для реализации этого трейта
  • трейты реализуются в блоке impl <trait> for <type> { .. }
  • трейты могут определять как дефолтные методы, так и методы, которые пользователь должен реализовать самостоятельно. Дефолтные методы могут полагаться на пользовательские: greet() имеет реализацию по умолчанию и зависит от talk()

Автоматическая реализация трейтов

Встроенные/стандартные трейты могут быть реализованы на кастомных типах автоматически:

#[derive(Debug, Clone, Default)]
struct Player {
name: String,
strength: u8,
hit_points: u8,
}

fn main() {
let p1 = Player::default(); // трейт `Default` добавляет конструктор `default()`.
let mut p2 = p1.clone(); // трейт `Clone` добавляет метод `clone()`
p2.name = String::from("EldurScrollz");
// Трейт `Debug` добавляет поддержку вывода в терминал с помощью `{:?}`.
println!("{:?} vs. {:?}", p1, p2);
}

Автоматическая реализация выполняется с помощью макросов, многие крейты предоставляют макросы для добавления полезного функционала. Например, крейт serde предоставляет автоматическую реализацию сериализации с помощью #[derive(Serialize)].

Трейт-объекты

Трейт-объекты (trait objects) позволяют хранить значения разных типов, например, в коллекции:

struct Dog {
name: String,
age: i8,
}
struct Cat {
lives: i8,
}

trait Pet {
fn talk(&self) -> String;
}

impl Pet for Dog {
fn talk(&self) -> String {
format!("Гав, меня зовут {}!", self.name)
}
}

impl Pet for Cat {
fn talk(&self) -> String {
String::from("Мау!")
}
}

fn main() {
// Трейт-объект, который может содержать значение любого типа, реализующего трейт `Pet`
let pets: Vec<Box<dyn Pet>> = vec![
Box::new(Cat { lives: 9 }),
Box::new(Dog { name: String::from("Фидо"), age: 5 }),
];
for pet in pets {
println!("Привет, кто ты? {}", pet.talk());
}
}

Память после выделения pets:


Ремарки:

  • типы, реализующие определенный трейт, могут иметь разный размер. Это делает возможным такие вещи, как Vec<dyn Pet> в примере
  • dyn Pet - это способ сообщить компилятору о типе динамического размера, который реализует Pet
  • в примере pets выделяются в стеке (stack), а вектор - в куче (heap). 2 элемента вектора являются жирными указателями (fat pointers):
    • жирный указатель - это указатель двойной ширины. Он состоит из двух компонентов: указателя на реальный объект и указателя на таблицу виртуальных методов (vtable) для реализации Pet этого конкретного объекта
    • данными для Dog являются name и age. Cat имеет поле lives
  • сравните эти выводы:
println!("{} {}", std::mem::size_of::<Dog>(), std::mem::size_of::<Cat>());
println!("{} {}", std::mem::size_of::<&Dog>(), std::mem::size_of::<&Cat>());
println!("{}", std::mem::size_of::<&dyn Pet>());
println!("{}", std::mem::size_of::<Box<dyn Pet>>());

Упражнение: библиотека GUI

Спроектируем классическую библиотеку GUI (graphical user interface - графический пользовательский интерфейс). Для простоты реализуем только его рисование - вывод в терминал в виде текста.

В нашей библиотеке будет несколько виджетов:

  • Window - имеет title и содержит другие виджеты
  • Button - имеет label. В реальной библиотеке кнопка также будет принимать обработчик ее нажатия
  • Label - имеет label

Виджеты реализуют трейт Widget.

Напишите методы draw_into() для реализации трейта Widget.

pub trait Widget {
// Натуральная ширина `self`.
fn width(&self) -> usize;

// Рисуем/записываем виджет в буфер
fn draw_into(&self, buffer: &mut dyn std::fmt::Write);

// Рисуем виджет в стандартный вывод
fn draw(&self) {
let mut buffer = String::new();
self.draw_into(&mut buffer);
println!("{buffer}");
}
}

// Подпись может состоять из нескольких строк
pub struct Label {
label: String,
}

impl Label {
// Конструктор подписи
fn new(label: &str) -> Label {
Label { label: label.to_owned() }
}
}

pub struct Button {
label: Label,
}

impl Button {
// Конструктор кнопки
fn new(label: &str) -> Button {
Button { label: Label::new(label) }
}
}

pub struct Window {
title: String,
widgets: Vec<Box<dyn Widget>>,
}

impl Window {
// Конструктор окна
fn new(title: &str) -> Window {
Window { title: title.to_owned(), widgets: Vec::new() }
}

// Метод добавления виджета
fn add_widget(&mut self, widget: Box<dyn Widget>) {
self.widgets.push(widget);
}

// Метод получения максимальной ширины
fn inner_width(&self) -> usize {
std::cmp::max(
self.title.chars().count(),
self.widgets.iter().map(|w| w.width()).max().unwrap_or(0),
)
}
}

impl Widget for Window {
todo!("реализуй меня")
}
impl Widget for Button {
todo!("реализуй меня")
}
impl Widget for Label {
todo!("реализуй меня")
}

fn main() {
let mut window = Window::new("Rust GUI Demo 1.23");
window.add_widget(Box::new(Label::new("This is a small text GUI demo.")));
window.add_widget(Box::new(Button::new("Click me!")));
window.draw();
}

Вывод программы может быть очень простым:

========
Rust GUI Demo 1.23
========

This is a small text GUI demo.

| Click me! |

Или же можно воспользоваться операторами форматирования заполнения/выравнивания для выравнивания текста. Вот как можно управлять выравниванием текста с помощью разных символов (например, /):

fn main() {
let width = 10;
println!("слева: |{:/<width$}|", "foo");
println!("по центру: |{:/^width$}|", "foo");
println!("справа: |{:/>width$}|", "foo");
}

Эти приемы позволяют сделать вывод программы таким:

+--------------------------------+
| Rust GUI Demo 1.23 |
+================================+
| This is a small text GUI demo. |
| +-------------+ |
| | Click me! | |
| +-------------+ |
+--------------------------------+
Решение:
impl Widget for Window {
fn width(&self) -> usize {
// Добавляем к максимальной ширине 4 для отступов и границ
// (по одному отступу и границе с каждой стороны)
self.inner_width() + 4
}

fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
let mut inner = String::new();
for widget in &self.widgets {
widget.draw_into(&mut inner);
}

let inner_width = self.inner_width();

// TODO: после изучения обработки ошибок, можно сделать так,
// чтобы метод `draw_into()` возвращал `Result<(), std::fmt::Error>`
// и использовать здесь оператор ? вместо `unwrap()`
writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
writeln!(buffer, "| {:^inner_width$} |", &self.title).unwrap();
writeln!(buffer, "+={:=<inner_width$}=+", "").unwrap();
for line in inner.lines() {
writeln!(buffer, "| {:inner_width$} |", line).unwrap();
}
writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
}
}

impl Widget for Button {
fn width(&self) -> usize {
self.label.width() + 4 // добавляем немного отступов (по 2 с каждой стороны)
}

fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
let width = self.width();
let mut label = String::new();
self.label.draw_into(&mut label);

writeln!(buffer, "+{:-<width$}+", "").unwrap();
for line in label.lines() {
writeln!(buffer, "|{:^width$}|", &line).unwrap();
}
writeln!(buffer, "+{:-<width$}+", "").unwrap();
}
}

impl Widget for Label {
fn width(&self) -> usize {
self.label.lines().map(|line| line.chars().count()).max().unwrap_or(0)
}

fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
writeln!(buffer, "{}", &self.label).unwrap();
}
}

Дженерики

Общие функции

Rust поддерживает дженерики (generics), которые позволяют абстрагировать алгоритмы или структуры данных (например, сортировку или двоичное дерево) по используемым или хранимым типам:

// Функция возвращает `even` или `odd` в зависимости от значения `n`.
// Здесь `T` - это параметр типа (type parameter), индикатор дженерика
fn pick<T>(n: i32, even: T, odd: T) -> T {
if n % 2 == 0 {
even
} else {
odd
}
}

fn main() {
println!("возвращенное число: {:?}", pick(97, 222, 333));
println!("возвращенный кортеж: {:?}", pick(28, ("dog", 1), ("cat", 2)));
}

Ремарки:

  • Rust выводит типы для T на основе типов аргументов и типа возвращаемого значения
  • это похоже на шаблоны (templates) C++, но Rust частично компилирует универсальную функцию сразу, поэтому эта функция должна быть допустимой для всех типов, соответствующих ограничениям. Например, попробуйте изменить функцию pick() так, чтобы она возвращала even + odd, если n == 0. Даже если используется только реализация pick() с целыми числами, Rust все равно считает ее недействительной. C++ позволит вам это сделать
  • общий код преобразуется в обычный (необобщенный) код на основе того, как код вызывается. Это абстракция с нулевой стоимостью: мы получаем точно такой же результат, как если бы вручную закодировали структуры данных без абстракции

Общие структуры

Дженерики могут использоваться для абстрагирования типов полей структур:

#[derive(Debug)]
struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn coords(&self) -> (&T, &T) {
(&self.x, &self.y)
}

// fn set_x(&mut self, x: T)
}

fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
println!("{integer:?} и {float:?}");
println!("координаты: {:?}", integer.coords());
}
  • Почему T определен дважды в impl<T> Point<T>? Потому что:
    • это общая реализация общего типа - разные дженерики
    • эти методы определяются для любого T
    • можно написать impl Point<u32>, тогда:
      • Point по-прежнему будет дженериком, и мы сможем использовать Point<f64>, но методы в этом блоке будут доступны только для Point<u32>
  • определите новую переменную let p = Point { x: 5, y: 10.0 }; и обновите код, чтобы он работал с разными типами - для этого потребуется 2 переменные типа, например, T и U

Ограничение трейтом

При работе с дженериками часто требуется, чтобы типы реализовывали какой-то трейт, чтобы можно было вызывать его методы.

Это делается с помощью T: Trait или impl Trait:

fn duplicate<T: Clone>(a: T) -> (T, T) {
(a.clone(), a.clone())
}

// struct NotClonable;

fn main() {
let foo = String::from("foo");
let pair = duplicate(foo);
println!("{pair:?}");
}
  • Попробуйте создать NotClonable и передать ее в duplicate()
  • для реализации нескольких трейтов можно использовать + для их объединения
  • третьим вариантом реализации трейта является использование ключевого слова where:
fn duplicate<T>(a: T) -> (T, T)
where
T: Clone,
{
(a.clone(), a.clone())
}
  • where "очищает" сигнатуру функции, если у нее много параметров.
  • where предоставляет дополнительные функции, что делает его более мощным:
    • тип слева от : может быть опциональным (Option<T>)
  • обратите внимание, что Rust (пока) не поддерживает специализацию (перегрузку функции). Например, учитывая исходную duplicate(), невозможно добавить специализированную duplicate(a: u32)

impl Trait

По аналогии с ограничением типа трейтом, синтаксис impl Trait можно использовать в параметрах и возвращаемом значении функции:

// Синтаксический сахар для:
// fn add_42_millions<T: Into<i32>>(x: T) -> i32 {
fn add_42_millions(x: impl Into<i32>) -> i32 {
x.into() + 42_000_000
}

fn pair_of(x: u32) -> impl std::fmt::Debug {
(x + 1, x - 1)
}

fn main() {
let many = add_42_millions(42_i8);
println!("{many}");
let many_more = add_42_millions(10_000_000);
println!("{many_more}");
let debuggable = pair_of(27);
println!("отлаживаемый: {debuggable:?}");
}
  • impl Trait позволяет работать с безымянными типами. Значение impl Trait зависит от места его использования:
    • для параметра impl Trait похож на анонимный общий параметр с ограничением трейтом
    • для возвращаемого типа это означает, что он - это некий конкретный тип, реализующий признак, без указания типа. Это может быть полезным, если мы не хотим раскрывать конкретный тип в общедоступном API
  • каков тип debuggable? Напишите let debuggable: () = .. и изучите сообщение об ошибке

Упражнение: определение минимального значение с помощью дженерика

В этом небольшом упражнении мы с помощью трейта LessThan реализуем общую функцию min(), которая определяет наименьшее из двух значений.

trait LessThan {
// Возвращаем `true`, если `self` меньше чем `other`
fn less_than(&self, other: &Self) -> bool;
}

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
struct Citation {
author: &'static str,
year: u32,
}

impl LessThan for Citation {
fn less_than(&self, other: &Self) -> bool {
if self.author < other.author {
true
} else if self.author > other.author {
false
} else {
self.year < other.year
}
}
}

fn min() {
todo!("реализуй меня")
}

fn main() {
let cit1 = Citation { author: "Shapiro", year: 2011 };
let cit2 = Citation { author: "Baumann", year: 2010 };
let cit3 = Citation { author: "Baumann", year: 2019 };
// Отладочная версия `assert_eq!`, которая удаляется из производственных сборок
debug_assert_eq!(min(cit1, cit2), cit2);
debug_assert_eq!(min(cit2, cit3), cit2);
debug_assert_eq!(min(cit1, cit3), cit3);
}
Решение:
fn min<T: LessThan>(l: T, r: T) -> T {
if l.less_than(&r) {
l
} else {
r
}
}

Типы, предоставляемые стандартной библиотекой Rust

Rust поставляется со стандартной библиотекой, которая помогает определить набор общих типов, используемых библиотеками и программами Rust. Таким образом, две библиотеки могут беспрепятственно работать вместе, поскольку обе они используют один и тот же тип String, например.

На самом деле Rust содержит несколько слоев стандартной библиотеки: core, alloc и std:

  • core содержит самые основные типы и функции, которые не зависят от libc, распределителя (allocator) или даже наличия операционной системы
  • alloc включает типы, для которых требуется глобальный распределитель кучи, например Vec, Box и Arc
  • встраиваемые приложения, написанные на Rust, часто используют только core и иногда alloc

Документация

Rust предоставляет замечательную документацию, например:

  • описание всех подробностей циклов
  • описание примитивных типов, вроде u8
  • описание типов стандартной библиотеки, таких как Option или BinaryHeap

Мы можем документировать собственный код:

/// Функция определяет, можно ли первый аргумент делить на второй
///
/// Если вторым аргументом является 0, результатом является `false`
fn is_divisible_by(lhs: u32, rhs: u32) -> bool {
if rhs == 0 {
return false;
}
lhs % rhs == 0
}

Содержимое рассматривается как Markdown. Все опубликованные библиотечные крейты (crates) Rust автоматически документируются на docs.rs с помощью инструмента rusdoc.

Чтобы документировать элемент внутри другого элемента (например, внутри модуля), используйте //! или /*! .. */, называемые "внутренними комментариями документа":

//! Этот модель содержит функционал, связанный с делением целых чисел
  • Взгляните на документацию крейта rand

Option

Мы уже несколько раз встречались с Option. Он хранит либо некоторое значение Some(T), либо индикатор отсутствия значения None. Например, String::find() возвращает Option<usize>:

fn main() {
let name = "Löwe 老虎 Léopard Gepardi";
let mut position: Option<usize> = name.find('é');
println!("find вернул {position:?}");
assert_eq!(position.unwrap(), 14);
position = name.find('Z');
println!("find вернул {position:?}");
assert_eq!(position.expect("символ не найден"), 0);
}

Ремарки:

  • Option широко используется, не только в стандартной библиотеке
  • unwrap() либо возвращает значение Some, либо паникует. expect() похож на unwrap(), но принимает сообщение об ошибке
    • мы можем паниковать на None, но мы также можем "случайно" забыть проверить None
    • unwrap()/expect() обычно используются для распаковки Some в местах, где мы относительно уверены в корректной работе кода. Как правило, в реальных программах None обрабатывается лучшим способом
  • оптимизация ниши (niche optimization) означает, что Option<T> часто занимает столько же памяти, сколько T

Result

Result похож на Option, но является индикатором успеха или провала операции, каждый со своим типом. В дженерике Result<T, E> T используется в варианте Ok, а E - в варианте Err.

use std::fs::File;
use std::io::Read;

fn main() {
let file: Result<File, std::io::Error> = File::open("diary.txt");
match file {
Ok(mut file) => {
let mut contents = String::new();
if let Ok(bytes) = file.read_to_string(&mut contents) {
println!("{contents}\n({bytes} байт)");
} else {
println!("Невозможно прочитать файл");
}
}
Err(err) => {
println!("Невозможно открыть дневник: {err}");
}
}
}

Ремарки:

  • как и в случае с Option, значение Result может быть извлечено с помощью unwrap()/expect()
  • Result содержит большое количество полезных методов, поэтому рекомендуется ознакомиться с его документацией
  • Result - это стандартный способ обработки ошибок, о чем мы поговорим в третьей части руководства
  • при работе с вводом/выводом тип Result<T, std::io::Error> является настолько распространенным, что std::io предоставляет специальный Result, позволяющий указывать только тип значения Ok:
use std::fs::File;
use std::io::{Read, Result};

// `main()` тоже может возвращать `Result`
fn main() -> Result<()> {
// Оператор `?` либо распаковывает значение `Ok`, либо распространяет ошибку (возвращает ее вызывающему)
let mut file = File::open("diary.txt")?;
let mut contents = String::new();
let bytes = file.read_to_string(&mut contents)?;
println!("{contents}\n({bytes} байт)");
Ok(())
}

String

String - это стандартный выделяемый в куче (heap-allocated) расширяемый (growable) UTF-8 строковый буфер:

fn main() {
let mut s1 = String::new();
s1.push_str("привет");
println!("s1: длина = {}, емкость = {}", s1.len(), s1.capacity());

let mut s2 = String::with_capacity(s1.len() + 1);
s2.push_str(&s1);
s2.push('!');
println!("s2: длина = {}, емкость = {}", s2.len(), s2.capacity());

let s3 = String::from("🇨🇭");
println!("s3: длина = {}, количество символов = {}", s3.len(), s3.chars().count());
}

String реализует Deref<Target = str>: мы можем вызывать все методы str на String.

Ремарки:

  • String::new() возвращает новую пустую строку. Когда заранее известен размер строки, можно использовать String::with_capacity()
  • String::len() возвращает размер String в байтах (который может отличаться от количества символов)
  • String::chars() возвращает итератор по настоящим символам. Обратите внимание, что char может отличаться от того, что мы привыкли считать "символом", согласно кластерам графем (grapheme clusters)
  • когда мы говорим о строках, мы говорим о &str или String
  • когда тип реализует Deref<Target = T>, компилятор позволяет прозрачно вызывать методы T
    • String реализует Deref<Target = str>, что предоставляет ей доступ к методам str
    • напишите и сравните let s3 = s1.deref(); и let s3 = &*s1;
  • String реализован как обертка над вектором байт, многие методы вектора поддерживаются String, но с некоторыми ограничениями (гарантиями)
  • сравните разные способы индексирования String:
    • извлечение символа с помощью s3.chars().nth(i).unwrap(), где i находится в границах строки и за их пределами
    • извлечение подстроки (среза - slice) с помощью s3[0..4], где диапазон находится в границах символов (character boundaries) и за их пределами

Vec

Vec - это стандартный расширяемый (resizable) буфер, выделяемый в куче:

fn main() {
let mut v1 = Vec::new();
v1.push(42);
println!("v1: длина = {}, емкость = {}", v1.len(), v1.capacity());

let mut v2 = Vec::with_capacity(v1.len() + 1);
v2.extend(v1.iter());
v2.push(9999);
println!("v2: длина = {}, емкость = {}", v2.len(), v2.capacity());

// Канонический макрос для инициализации вектора с элементами
let mut v3 = vec![0, 0, 1, 2, 3, 4];

// Сохраняем только четные элементы
v3.retain(|x| x % 2 == 0);
println!("{v3:?}");

// Удаляем последовательные дубликаты
v3.dedup();
println!("{v3:?}");
}

Vec реализует Deref<Target = [T]>: мы можем вызывать методы срезов на Vec.

Ремарки:

  • Vec - это тип коллекции, наряду с String и HashMap. Данные, которые он содержит, хранятся в куче. Это означает, что размер данных может быть неизвестен во время компиляции. Он может увеличиваться и уменьшаться во время выполнения
  • обратите внимание, что Vec<T> - это дженерик, но нам не нужно явно определять T. Rust самостоятельно выводит тип вектора после первого вызова push()
  • vec![..] - это канонический макрос, позволяющий создавать векторы по аналогии с Vec::new(), но с начальными элементами
  • для индексации вектора можно использовать [], но при выходе за пределы вектора, программа запаникует. Более безопасным доступом к элементам вектора является get(), возвращающий Option. Метод pop() удаляет последний элемент вектора
  • Vec имеет доступ ко всем методов срезов, о которых мы поговорим в третьей части руководства

HashMap

Стандартная хеш-карта с защитой от HashDoS-атак:

use std::collections::HashMap;

fn main() {
let mut page_counts = HashMap::new();
page_counts.insert("Adventures of Huckleberry Finn".to_string(), 207);
page_counts.insert("Grimms' Fairy Tales".to_string(), 751);
page_counts.insert("Pride and Prejudice".to_string(), 303);

if !page_counts.contains_key("Les Misérables") {
println!(
"We know about {} books, but not Les Misérables.",
page_counts.len()
);
}

for book in ["Pride and Prejudice", "Alice's Adventure in Wonderland"] {
match page_counts.get(book) {
Some(count) => println!("{book}: {count} pages"),
None => println!("{book} is unknown."),
}
}

// Метод `entry()` позволяет вставлять значения отсутствующих ключей
for book in ["Pride and Prejudice", "Alice's Adventure in Wonderland"] {
let page_count: &mut i32 = page_counts.entry(book.to_string()).or_insert(0);
*page_count += 1;
}

println!("{page_counts:#?}");
}

Ремарки:

  • HashMap не содержится в прелюдии (prelude) и должна импортироваться явно
  • попробуйте следующий код. Первая строка проверяет, содержится ли книга в карте и возвращает альтернативное значение при ее отсутствии. Вторая строка вставляет альтернативное значение, если книга не найдена в карте:
let pc1 = page_counts
.get("Harry Potter and the Sorcerer's Stone")
.unwrap_or(&336);
let pc2 = page_counts
.entry("The Hunger Games".to_string())
.or_insert(374);
  • в отличие от vec!, Rust, к сожалению, не предоставляет макрос hashmap!
    • однако, начиная с Rust 1.56, HashMap реализует From<[(K, V); N]>, позволяющий инициализировать хэш-карту с помощью литерального массива:
let page_counts = HashMap::from([
("Harry Potter and the Sorcerer's Stone".to_string(), 336),
("The Hunger Games".to_string(), 374),
]);
  • HashMap может создаваться из любого Iterator, возвращающего кортежи (ключ, значение)
  • в примерах мы избегаем использования &str в качестве ключей хэш-карт для простоты. Это возможно, но может привести к проблемам с заимствованием
  • рекомендуется внимательно ознакомиться с документацией HashMap

Упражнение: счетчик

В этом упражнении мы возьмем очень простую структуру данных и сделаем ее универсальной. Она использует HashMap для отслеживания того, какие значения были просмотрены и сколько раз появлялось каждое из них.

Первоначальная версия Counter жестко запрограммирована для работы только со значениями u32. Сделайте структуру и ее методы универсальными для типа отслеживаемого значения, чтобы Counter мог работать с любым типом.

Если задание покажется вам слишком легким и вы быстро с ним справитесь, попробуйте использовать метод entry(), чтобы вдвое сократить количество поисков хеша, необходимых для реализации метода подсчета.

use std::collections::HashMap;

// `Counter` считает, сколько раз встретилось каждое значение типа `T`
struct Counter {
values: HashMap<u32, u64>,
}

impl Counter {
// Статичный метод создания нового `Counter`
fn new() -> Self {
Counter {
values: HashMap::new(),
}
}

// Метод подсчета появлений определенного значения
fn count(&mut self, value: u32) {
if self.values.contains_key(&value) {
*self.values.get_mut(&value).unwrap() += 1;
} else {
self.values.insert(value, 1);
}
}

// Метод возврата количества появлений определенного значения
fn times_seen(&self, value: u32) -> u64 {
self.values.get(&value).copied().unwrap_or_default()
}
}

fn main() {
let mut ctr = Counter::new();
ctr.count(13);
ctr.count(14);
ctr.count(16);
ctr.count(14);
ctr.count(14);
ctr.count(11);

for i in 10..20 {
println!("saw {} values equal to {}", ctr.times_seen(i), i);
}

let mut strctr = Counter::new();
strctr.count("apple");
strctr.count("orange");
strctr.count("apple");
println!("got {} apples", strctr.times_seen("apple"));
}

Подсказки:

  • общим должен быть только тип ключа
  • приступите к реализации struct Counter<T> и внимательно изучите подсказку компилятора
  • общий тип должен реализовывать 2 встроенных типа: один из прелюдии, другой из std::hash
Решение:
// ...
use std::hash::Hash;

struct Counter<T: Eq + Hash> {
values: HashMap<T, u64>,
}

impl<T: Eq + Hash> Counter<T> {
// ...

fn count(&mut self, value: T) {
// Дополнительное задание.
// Здесь также можно использовать `or_insert(0)`
*self.values.entry(value).or_default() += 1;
}

fn times_seen(&self, value: T) -> u64 {
self.values.get(&value).copied().unwrap_or_default()
}
}

Трейты, предоставляемые стандартной библиотекой Rust

Рекомендуется внимательно ознакомиться с документацией каждого трейта.

Сравнения

Эти трейты поддерживают сравнение между значениями. Они могут реализовываться на типах, содержащих поля, которые реализуют эти трейты.

PartialEq и Eq

PartialEq - это отношение частичной эквивалентности (partial equivalence relation), с требуемым методом eq() и предоставляемым методом ne(). Эти методы вызываются операторами == и !=.

struct Key {
id: u32,
metadata: Option<String>,
}

impl PartialEq for Key {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}

Eq - это отношение полной эквивалентности (рефлексивное, симметричное и транзитивное), реализующее PartialEq. Функции, требующие полную эквивалентность, используют Eq как ограничивающий трейт (trait bound).

PartialEq может быть реализован для разных типов, а Eq нет, поскольку он является рефлексивным:

struct Key {
id: u32,
metadata: Option<String>,
}

impl PartialEq<u32> for Key {
fn eq(&self, other: &u32) -> bool {
self.id == *other
}
}

PartialOrd и Ord

PartialOrd определяет частичный порядок (partial ordering), с методом partial_cmp(). Этот метод используется для реализации операторов <, <=, >= и >.

use std::cmp::Ordering;

#[derive(Eq, PartialEq)]
struct Citation {
author: String,
year: u32,
}

impl PartialOrd for Citation {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.author.partial_cmp(&other.author) {
Some(Ordering::Equal) => self.year.partial_cmp(&other.year),
author_ord => author_ord,
}
}
}

Ord - это тотальный (total) порядок, с методом cmp(), возвращающим Ordering.

На практике эти трейты чаще реализуются автоматически (derive), чем вручную.

Операторы

Перегрузка операторов реализуется с помощью трейта std::ops:

#[derive(Debug, Copy, Clone)]
struct Point {
x: i32,
y: i32,
}

impl std::ops::Add for Point {
type Output = Self;

fn add(self, other: Self) -> Self {
Self { x: self.x + other.x, y: self.y + other.y }
}
}

fn main() {
let p1 = Point { x: 10, y: 20 };
let p2 = Point { x: 100, y: 200 };
println!("{:?} + {:?} = {:?}", p1, p2, p1 + p2);
}
  • Мы можем реализовать Add для &Point. В каких случаях это может быть полезным?
    • Ответ: Add::add() потребляет self. Если тип T, для которого перегружается оператор, не является Copy (копируемым), мы должны реализовать перегрузку оператора для &T. Это позволяет избежать необходимости явного клонирования при вызове
  • Почему Output является ассоциированным типом? Можем ли мы сделать его параметром типа или метода?
    • Короткий ответ: параметры типа функции контролируются вызывающим, а ассоциированные типы (Output) - тем, кто реализует трейт
  • Мы можем реализовать Add для двух разных типов, например, impl Add<(i32, i32)> for Point добавит кортеж в Point

From и Into

Типы, реализующие трейты From и Into, могут преобразовываться в другие типы:

fn main() {
let s = String::from("hello");
let addr = std::net::Ipv4Addr::from([127, 0, 0, 1]);
let one = i16::from(true);
let bigger = i32::from(123_i16);
println!("{s}, {addr}, {one}, {bigger}");
}

Into автоматически реализуется при реализации From:

fn main() {
let s: String = "hello".into();
let addr: std::net::Ipv4Addr = [127, 0, 0, 1].into();
let one: i16 = true.into();
let bigger: i32 = 123_i16.into();
println!("{s}, {addr}, {one}, {bigger}");
}

Приведение типов

Rust поддерживает как неявное приведение (преобразование) типов (casting), так и явное с помощью as:

fn main() {
let value: i64 = 1000;
println!("as u16: {}", value as u16);
println!("as i16: {}", value as i16);
println!("as u8: {}", value as u8);
}

Результаты as всегда определяются в Rust, поэтому являются согласованными на разных платформах. Это может не соответствовать нашему интуитивному мнению об изменении знака или приведении к меньшему типу.

Приведение типов с помощью as - это относительно сложный инструмент, который легко использовать неправильно и который может стать источником мелких ошибок, поскольку используемые типы или диапазоны значений в них могут легко измениться. Приведение лучше всего использовать тогда, когда целью является указать безусловное усечение (unconditional truncation) (например, выбор нижних 32 битов u64 с помощью as u32, независимо от того, что было в старших битах).

Для приведения, которое всегда можно выполнить успешно (например, из u32 в u64), предпочтительнее использовать From или Into. Для приведения, которое в некоторых случаях выполнить невозможно, доступны TryFrom и TryInto, которые позволяют по-разному обрабатывать случаи возможности и невозможности приведения одного типа к другому.

Read и Write

Read и BufRead позволяют абстрагироваться от источников (sources) u8:

use std::io::{BufRead, BufReader, Read, Result};

fn count_lines<R: Read>(reader: R) -> usize {
let buf_reader = BufReader::new(reader);
buf_reader.lines().count()
}

// Здесь `Result<T>` из `std::io` == `Result<T, std::io::Error>`
fn main() -> Result<()> {
let slice: &[u8] = b"foo\nbar\nbaz\n";
println!("строк в срезе: {}", count_lines(slice));

let file = std::fs::File::open(std::env::current_exe()?)?;
println!("строк в файле: {}", count_lines(file));
Ok(())
}

Write, в свою очередь, позволяет абстрагироваться от приемников (sinks) u8:

use std::io::{Result, Write};

fn log<W: Write>(writer: &mut W, msg: &str) -> Result<()> {
writer.write_all(msg.as_bytes())?;
writer.write_all("\n".as_bytes())
}

fn main() -> Result<()> {
let mut buffer = Vec::new();
log(&mut buffer, "Hello")?;
log(&mut buffer, "World")?;
println!("{:?}", buffer);
Ok(())
}

Трейт Default

Трейт Default генерирует дефолтное значение типа:

#[derive(Debug, Default)]
struct Derived {
x: u32,
y: String,
z: Implemented,
}

#[derive(Debug)]
struct Implemented(String);

impl Default for Implemented {
fn default() -> Self {
Self("Иван Петров".into())
}
}

fn main() {
let default_struct = Derived::default();
println!("{default_struct:#?}");

let almost_default_struct =
Derived { y: "Y установлена!".into(), ..Derived::default() };
println!("{almost_default_struct:#?}");

let nothing: Option<Derived> = None;
println!("{:#?}", nothing.unwrap_or_default());
}

Ремарки:

  • Default может быть реализован как вручную, так и с помощью derive
  • автоматическая реализация создает значение, в котором для всех полей установлены значения по умолчанию
    • это означает, что все поля структуры также должны реализовывать Default
  • стандартные типы Rust часто реализуют Default с разумными значениями (0, "" и т.д.)
  • частичная инициализация структуры хорошо работает с Default
  • стандартная библиотека Rust знает, что типы могут реализовывать Default, и предоставляет удобные методы, которые его используют
  • синтаксис .. называется синтаксисом обновления структуры

Замыкания

Замыкания (closures) или лямбда-выражения имеют типы, которым нельзя дать имя. Однако они реализуют специальные трейты Fn, FnMut и FnOnce:

fn apply_with_log(func: impl FnOnce(i32) -> i32, input: i32) -> i32 {
println!("вызов функции на {input}");
func(input)
}

fn main() {
let add_3 = |x| x + 3;
println!("add_3: {}", apply_with_log(add_3, 10));
println!("add_3: {}", apply_with_log(add_3, 20));

let mut v = Vec::new();
let mut accumulate = |x: i32| {
v.push(x);
v.iter().sum::<i32>()
};
println!("accumulate: {}", apply_with_log(&mut accumulate, 4));
println!("accumulate: {}", apply_with_log(&mut accumulate, 5));

let multiply_sum = |x| x * v.into_iter().sum::<i32>();
println!("multiply_sum: {}", apply_with_log(multiply_sum, 3));
}

Ремарки:

  • Fn (например, add_3) не потребляет и не изменяет захваченные значения или, возможно, вообще ничего не захватывает. Ее можно вызывать несколько раз одновременно
  • FnMut (например, accumulate) может менять захваченные значения. Ее можно вызывать несколько раз, но не одновременно
  • FnOnce (например, multiply_sum) можно вызвать только один раз. Она может потреблять захваченные значения
  • FnMut - это подтип (подтрейт - subtrait) FnOnce. Fn - это подтип FnMut и FnOnce. Это означает, что мы можем использовать FnMut там, где ожидается FnOnce, и Fn там, где ожидается FnMut или FnOnce
  • при определении функции, принимающей замыкание, мы должны сначала брать FnOnce, затем FnMut и в конце Fn как наиболее гибкий тип
  • напротив, при определении замыкания мы начинаем с Fn
  • по умолчанию замыкание захватывают значение по ссылке. Ключевое слово move позволяет замыканию захватывать само значение
fn make_greeter(prefix: String) -> impl Fn(&str) {
return move |name| println!("{} {}", prefix, name);
}

fn main() {
let hi = make_greeter("привет".to_string());
hi("всем");
}

Упражнение: ROT13

В этом упражнении мы реализуем классический шифр "ROT13".

Меняйте только алфавитные символы ASCII, чтобы результат оставался валидным UTF-8.

use std::io::Read;

struct RotDecoder<R: Read> {
input: R,
rot: u8,
}

impl<R: Read> Read for RotDecoder<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
todo!("реализуй меня")
}
}

fn main() {
let mut rot =
RotDecoder { input: "Gb trg gb gur bgure fvqr!".as_bytes(), rot: 13 };
let mut result = String::new();
// `read_to_string()` вызывает `read()` под капотом и преобразует его результат в строку
rot.read_to_string(&mut result).unwrap();
println!("{}", result);
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn joke() {
let mut rot =
RotDecoder { input: "Gb trg gb gur bgure fvqr!".as_bytes(), rot: 13