
Hello world!
Представляю вашему вниманию вторую часть практического руководства по Rust.
Другой формат, который может показаться вам более удобным.
Руководство основано на Comprehensive Rust — руководстве по Rust от команды Android в Google и рассчитано на людей, которые уверенно владеют любым современным языком программирования. Еще раз: это руководство не рассчитано на тех, кто только начинает кодить 😉
В этой части мы рассмотрим следующие темы:
- сопоставление с образцом (pattern matching) — извлечение данных из структур
- методы — функции, ассоциированные с типами
- трейты (traits) — поведение, общее для нескольких типов
- дженерики (generics) — общие типы
- типы и трейты, предоставляемые стандартной библиотекой
Rust
Материалы для более глубокого изучения названных тем:
- Книга/учебник по Rust (на русском языке) — главы 6, 8, 10, 13, 17 и 18
- rustlings — упражнения 04-06, 09, 11, 12, 14 и 15
- Rust на примерах (на русском языке) — 5, 6, 14, 16, 19 и 20
- Rust by practice — упражнения 8-12 и 18
Также см. Большую шпаргалку по Rust.
Сопоставление с образцом
Деструктуризация
Как и кортежи (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 letwhile letmatch
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) } }
Это конец второй части руководства.
Материалы для более глубокого изучения рассмотренных тем:
- Книга/учебник по Rust (на русском языке) — главы 6, 8, 10, 13, 17 и 18
- rustlings — упражнения 04-06, 09, 11, 12, 14 и 15
- Rust на примерах (на русском языке) — 5, 6, 14, 16, 19 и 20
- Rust by practice — упражнения 8-12 и 18
Happy coding!

