Rust
Hello world!
Данное руководство основано на Comprehensive Rust - руководстве по Rust
от команды Android
в Google
и рассчитано на людей, которые уверенно владеют любым современным языком программирования. Еще раз: это руководство не рассчитано на тех, кто только начинает кодить 😉
Материалы для дальнейшего изучения Rust:
- Большая шпаргалка по Rust (на русском языке)
- Книга/учебник по Rust (на русском)
- rustlings
- Rust на примерах (на русском)
- Rust by practice
- Шикарный курс по 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, 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
зависит от места его использования: