Rust
Hello world!
Данное руководство основано на Comprehensive Rust - руководстве по Rust
от команды Android
в Google
и рассчитано на людей, которые уверенно владеют любым современным языком программирования. Еще раз: это руководство не рассчитано на тех, кто только начинает кодить 😉
Материалы для дальнейшего изучения Rust:
- Большая шпаргалка п о Rust (на русском языке)
- Книга/учебник по Rust (на русском)
- rustlings
- Rust на примерах (на русском)
- Rust by practice
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, usize | 0, 123, 10_u16 |
Числа с плавающей точкой | f32, f64 | 3.14, -10.0e20, 2_f32 |
Скалярные значения Юникода | char | 'a', 'α', '∞' |
Логические значения | bool | true,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::fmtformat!(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
- жирный указатель - это указатель двойной ширины. Он состоит из двух компонентов: указателя на реальный объект и указателя на таблицу виртуальных методов (vtable) для реализации
- сравните эти выводы:
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 };
let mut result = String::new();
rot.read_to_string(&mut result).unwrap();
assert_eq!(&result, "To get to the other side!");
}
#[test]
fn binary() {
let input: Vec<u8> = (0..=255u8).collect();
let mut rot = RotDecoder::<&[u8]> { input: input.as_ref(), rot: 13 };
let mut buf = [0u8; 256];
assert_eq!(rot.read(&mut buf).unwrap(), 256);
for i in 0..=255 {
if input[i] != buf[i] {
assert!(input[i].is_ascii_alphabetic());
assert!(buf[i].is_ascii_alphabetic());
}
}
}
}
Решение:
impl<R: Read> Read for RotDecoder<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Читаем данные в буфер
let size = self.input.read(buf)?;
// Перебираем байты
for b in &mut buf[..size] {
// Только буквы алфавита
if b.is_ascii_alphabetic() {
// База
let base = if b.is_ascii_uppercase() { 'A' } else { 'a' } as u8;
// Сдвигаем на `rot` в пределах 26 (количество букв в английском алфавите)
*b = (*b - base + self.rot) % 26 + base;
}
}
// Возвращаем "сдвинутые" байты
Ok(size)
}
}
Управление памятью
Обзор памяти программы
Программы выделяют (allocate) память двумя способами:
- стек (stack): непрерывная область памяти для локальных переменных
- значения имеют фиксированный размер, известный во время компиляции
- очень быстрый: просто перемещаем указатель стека (stack pointer)
- легко управлять: следуем за вызовами функций
- отличная локализованность памяти (память находится в одном месте)
- куча (heap): хранилище значений за пределами вызовов функций
- значения имеют динамический размер, определяемый во время выполнения
- немного медленнее, чем стек: имеются некоторые накладные расходы
- нет гарантии локализованности памяти
Пример
Создание String
помещает метаданные фиксированного размера в стек и данные динамического размера (настоящую строку) в кучу:
fn main() {
let s1 = String::from("Привет");
}
Мы можем исследовать память, но это совершенно небезопасно:
fn main() {
let mut s1 = String::from("Привет");
s1.push(' ');
s1.push_str("всем");
// Только для целей обучения.
// Это может привести к непредсказуемому поведению
unsafe {
let (ptr, capacity, len): (usize, usize, usize) = std::mem::transmute(s1);
println!("ptr = {ptr:#x}, len = {len}, capacity = {capacity}");
}
}
Подходы к управлению памятью
Традиционно, языки программирования делятся на 2 категории:
- полный контроль через ручное управление памятью: C, C++, Pascal и др.
- когда выделять и освобождать память в куче решает программист
- программист определяет, указывает ли указатель на валидную память
- опыт показывает, что программисты совершают ошибки
- полная безопасность через автоматическое управление памятью во время выполнения:
- система обеспечивает, что память не освобождается до тех пор, пока на нее имеются ссылки
- обычно реализуется с помощью подсчета ссылок (reference counting), сборку мусора (garbage collection) или RAII
Rust
предлагает новый подход - полный контроль и безопасность во время компиляции обеспечивают правильное управление памятью.
Это делается с помощью владения (ownership).
- в
C
управление памятью осуществляется с помощью функцийmalloc
иfree
. Часто ошибки заключаются в не вызовеfree
, ее многократном вызове или разыменовании указателя на освобожденный ресурс C++
предоставляет инструменты, такие как умные указатели (unique_ptr
,shared_ptr
), которые автоматически вызывают деструкторы для освобождения памяти после возврата значения из функции. Однако это решает далеко не все проблемыC
Java
,Go
,Python
,JavaScript
и др. полагаются на сборщик мусора (garbage collector) в определении неиспользуемой памяти и ее освобождении. Это позволяет избежать багов, связанных с разыменованием указателей на освобожденные ресурсы и т.п. Однако GC имеет свою цену времени выполнения и его сложно настраивать
Модель владения и заимствования Rust
позволяет добиться производительности C
без свойственных ему проблем с безопасностью памяти. Rust
также предоставляет умные указатели, похожие на умные указатели C++
. Доступны и другие варианты, такие как подсчет ссылок. Существуют даже сторонние крейты, поддерживающие сборку мусора во время выполнения (мы не будем их рассматривать).
Владение
Все привязки переменных имеют свою область видимости (scope). Попытка использовать переменную за пределами ее области видимости приводит к ошибке:
struct Point(i32, i32);
fn main() {
{
let p = Point(3, 4);
println!("x: {}", p.0);
}
println!("y: {}", p.1);
}
Мы говорим, что переменная владеет (own) значением. Каждое значение может иметь только одного владельца.
В конце области видимости переменная уничтожается (dropped), а память освобождается (freed). Здесь может запускаться деструктор для освобождения ресурсов.
GC начинает с корневых узлов (roots) для обнаружения всех достижимых (reachable) объектов. Это похоже на принцип "одного владельца" в Rust
.
Перемещение
Присвоение перемещает (move) владение значения между переменными:
fn main() {
let s1: String = String::from("Привет");
let s2: String = s1;
println!("s2: {s2}");
// println!("s1: {s1}");
}
- Присвоение
s1
s2
перемещает владение строкой"Привет"
- когда
s1
выходит за пределы области видимости, ничего не происходит, потому что эта переменная больше ничем не владеет - когда
s2
выходит за пределы области видимости, данные строки освобождаются
Перед перемещением владения:
После:
Когда мы передаем значение в функцию, оно присваивается ее параметру, происходит перемещение владения:
fn say_hello(name: String) {
println!("Привет {name}")
}
fn main() {
let name = String::from("Алиса");
// Владение перемещается в `say_hello`
say_hello(name);
// say_hello(name);
}
- Вызывая функцию
say_hello
, функцияmain
передает ей владение значениемname
. После этогоname
больше не может использоваться вmain
- память, выделенная в куче для
name
, будет освобождена после вызоваsay_hello
main
может сохранить владение значениемname
, если передаст вsay_hello
ссылку на него (&name
), и параметром, принимаемымsay_hello
, будет ссылка (name: &String
)- вторым вариантом является передача
say_hello
копии/клонаname
(name.clone()
) - в
Rust
, в отличие отC++
, копии значений чаще всего приходится создавать явно
Clone
Иногда нам нужно создать копию значения. Для этого предназначен трейт Clone
:
#[derive(Default)]
struct Backends {
hostnames: Vec<String>,
weights: Vec<f64>,
}
impl Backends {
fn set_hostnames(&mut self, hostnames: &Vec<String>) {
// Вектор реализует трейт `Clone` по умолчанию
self.hostnames = hostnames.clone();
self.weights = hostnames.iter().map(|_| 1.0).collect();
}
}
Идея Clone
заключается в том, чтобы облегчить определение места распределения кучи.
Клонирование часто используется для быстрого решения проблем, связанных с владением и заимствованием, с последующей оптимизацией за счет их удаления.
Копируемые типы
Хотя семантика перемещения используется по умолчанию, некоторые типы по умолчанию копируются:
fn main() {
let x = 42;
let y = x;
println!("x: {x}"); // переменная `x` не была бы доступной без копирования
println!("y: {y}");
}
Такие типы реализуют трейт Copy
.
Мы можем сделать так, чтобы наши типы использовали семантику копирования:
#[derive(Copy, Clone, Debug)]
struct Point(i32, i32);
fn main() {
let p1 = Point(3, 4);
let p2 = p1;
println!("p1: {p1:?}");
println!("p2: {p2:?}");
}
- После присвоения
p1
иp2
владеют собственными данными - мы также можем использовать
p1.clone()
для явного копирования данных
Копирование и клонирование - это разные вещи:
- копирование относится к побитовому копированию областей памяти и не работает с произвольными объектами
- копирование не позволяет использовать собственную логику (в отличие от конструкторов копирования в
C++
) - клонирование - это более общая операция, которая также позволяет настраивать поведение путем реализации трейта
Clone
- копирование не работает на типах, которые реализуют трейт
Drop
Попробуйте сделать следующее в примере:
- добавьте поле
String
в структуруPoint
. Пример не будет компилироваться, посколькуString
не является копируемым типом - удалите
Copy
из атрибутаderive
. При попытке вывести значениеp1
в терминал возникнет ошибка - попробуйте клонировать
p1
явно
Трейт Drop
Значения, реализующие трейт Drop
, могут определять код, который запускается при их выходе за пределы области видимости:
struct Droppable {
name: &'static str,
}
impl Drop for Droppable {
fn drop(&mut self) {
println!("уничтожение {}", self.name);
}
}
fn main() {
let a = Droppable { name: "a" };
{
let b = Droppable { name: "b" };
{
let c = Droppable { name: "c" };
let d = Droppable { name: "d" };
println!("выход из блока B");
}
println!("выход из блока A");
}
drop(a);
println!("выход из main");
}
Ремарки:
-
обратите внимание, что
std::mem::drop
иstd::ops::Drop::drop
- это разные вещи -
значения автоматически уничтожаются при выходе за пределы их области видимости
-
после уничтожения значения, вызывается его реализация
Drop::drop
, если значение реализуетstd::ops::Drop
-
все поля структуры также уничтожаются, независимо от того, реализуют они
Drop
или нет -
std::mem::drop
- это пустая функция, не принимающая никаких значений. Важно то, что она принимает владение значением, которое уничтожается после ее вызова. С помощью этой функции можно уничтожать значения до того, как они выйдут за пределы их области видимости- это может быть полезным для объектов, которые выполняют какую-то работу при уничтожении: снятие блокировки (releasing lock), закрытие файла (дескриптора) и др.
-
Почему
Drop::drop
не принимаетself
?- Короткий ответ: в этом случае
std::mem::drop
будет вызвана в конце блока, что приведет к другому вызовуDrop::drop
и переполнению стека!
- Короткий ответ: в этом случае
-
Попробуйте заменить
drop(a)
наa.drop()
Упражнение: тип "Строитель"
В этом упражнении мы реализуем сложный тип, который владеет всеми своими данными. Мы будем использовать "шаблон построителя" (builder patterm) для поэтапного построения нового значения с использованием удобных функций.
#[derive(Debug)]
enum Language {
Rust,
Java,
Perl,
}
#[derive(Clone, Debug)]
struct Dependency {
name: String,
version_expression: String,
}
// Представление пакета ПО
#[derive(Debug)]
struct Package {
name: String,
version: String,
authors: Vec<String>,
dependencies: Vec<Dependency>,
// Это поле является опциональным
language: Option<Language>,
}
impl Package {
// Метод для возврата представления пакета как зависимости
// для использования в создании других пакетов
fn as_dependency(&self) -> Dependency {
todo!("1")
}
}
// Строитель пакета. Для создания `Package` используется метод `build`
struct PackageBuilder(Package);
impl PackageBuilder {
fn new(name: impl Into<String>) -> Self {
todo!("2")
}
// Метод установки версии пакета
fn version(mut self, version: impl Into<String>) -> Self {
self.0.version = version.into();
self
}
// Метод установки автора пакета
fn authors(mut self, authors: Vec<String>) -> Self {
todo!("3")
}
// Метод добавления дополнительной зависимости
fn dependency(mut self, dependency: Dependency) -> Self {
todo!("4")
}
// Метод установки языка. Если не установлен, по умолчанию имеет значение `None`
fn language(mut self, language: Language) -> Self {
todo!("5")
}
fn build(self) -> Package {
self.0
}
}
fn main() {
let base64 = PackageBuilder::new("base64").version("0.13").build();
println!("base64: {base64:?}");
let log =
PackageBuilder::new("log").version("0.4").language(Language::Rust).build();
println!("log: {log:?}");
let serde = PackageBuilder::new("serde")
.authors(vec!["djmitche".into()])
.version(String::from("4.0"))
.dependency(base64.as_dependency())
.dependency(log.as_dependency())
.build();
println!("serde: {serde:?}");
}
Решение:
impl Package {
fn as_dependency(&self) -> Dependency {
Dependency {
name: self.name.clone(),
version_expression: self.version.clone(),
}
}
}
impl PackageBuilder {
fn new(name: impl Into<String>) -> Self {
Self(Package {
name: name.into(),
version: "0.1".into(),
authors: vec![],
dependencies: vec![],
language: None,
})
}
fn version(mut self, version: impl Into<String>) -> Self {
self.0.version = version.into();
self
}
fn authors(mut self, authors: Vec<String>) -> Self {
self.0.authors = authors;
self
}
fn dependency(mut self, dependency: Dependency) -> Self {
self.0.dependencies.push(dependency);
self
}
fn language(mut self, language: Language) -> Self {
self.0.language = Some(language);
self
}
fn build(self) -> Package {
self.0
}
}