Rust: язык программирования

Глава 1. Введение в язык Rust

Общий обзор языка программирования Rust. Установка

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

С приходом Rust, была решена проблема, которая преследовала всех разработчиков:

  • либо быстрый язык без сборщика мусора (C/C++), но с постоянным риском segfault’ов, use-after-free, data race’ов — то есть багов, которые всплывают в рантайме и стоят компаниям миллионов
  • либо безопасный язык (Java, Go, C#), но с GC-паузами и рантайм-накладными расходами

Rust придумал borrow checker — систему, которая проверяет владение памятью на этапе компиляции. Если безопасный Rust-код компилируется — у тебя гарантированно нет data race’ов и use-after-free, при этом нет никакого GC в рантайме. Скорость C, безопасность как у языков с GC. Это реально уникальное сочетание, которого раньше не было.

Для macOS и Linux используется команда:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

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

rustup update
rustc --version

Источник: https://metanit.com/rust/tutorial/1.1.php

Первая программа

Создадим файл main.rs. Функция main — точка входа, а println! — встроенный макрос вывода строки. Вызов макроса завершается точкой с запятой.

fn main() {
    println!("Hello METANIT.COM!");
}

На macOS и Linux команды отличаются путём запуска бинарного файла:

root@Eugene:~# cd rust
root@Eugene:~/rust# rustc main.rs
root@Eugene:~/rust# ./main
Hello METANIT.COM!
root@Eugene:~/rust#

Глава 2. Основы Rust

Структура программы

Программа состоит из инструкций и выражений. Инструкция выполняет действие и обычно заканчивается точкой с запятой. Несколько инструкций объединяются в блок { ... }. Выполнение бинарной программы начинается с функции main; функции объявляются ключевым словом fn.

println! — макрос, а не функция: об этом говорит ! после имени. Однострочный комментарий начинается с //, многострочный заключается между /* и */. Комментарии не попадают в исполняемый код.

fn main() {
    // Однострочный комментарий
    println!("Hello Rust!");
 
    /* Многострочный
       комментарий */
    {
        println!("Вложенный блок");
    }
}

Переменные

Переменная объявляется через let. Тип можно указать явно после двоеточия либо позволить компилятору вывести его из значения. Объявить переменную без значения допустимо, но прочитать её до гарантированной инициализации нельзя.

По умолчанию переменные неизменяемы. mut разрешает присваивать переменной новые значения того же типа. Затенение — новое объявление let с прежним именем — создаёт другую переменную и поэтому может одновременно изменить и значение, и тип.

fn main() {
    let age: u32;
    age = 36;
    println!("Age = {age}");
 
    let mut score = 10;
    score = 15;
 
    let value = " 42 ";
    let value: i32 = value.trim().parse().expect("ожидалось число");
    println!("score = {score}, value = {value}");
}

print! оставляет курсор в той же строке, а println! добавляет перевод строки. Современный формат допускает подстановку имени прямо в строку: println!("{age}").

Типы данных

Rust статически типизирован: тип каждого значения известен при компиляции и определяет допустимые операции и представление в памяти. Основные скалярные типы:

  • целые со знаком i8, i16, i32, i64, i128 и без знака u8, u16, u32, u64, u128;
  • isize и usize, размер которых совпадает с разрядностью целевой платформы; usize обычно используется для индексов и размеров;
  • числа с плавающей точкой f32 и f64;
  • логический bool со значениями true и false;
  • Unicode-символ char, занимающий 4 байта.

Целое без уточнения обычно получает тип i32, дробное — f64. Основание литерала задают префиксы 0b, 0o, 0x; тип — суффикс (42u8), а _ улучшает читаемость числа. Строковый литерал имеет тип &str, хранится в UTF-8 и не является char-массивом.

fn main() {
    let decimal = 1_000_000;
    let binary = 0b1101u8;
    let hexadecimal = 0xff_u16;
    let ratio: f32 = 2.5;
    let enabled = true;
    let letter = 'Ж';
    let greeting: &str = "Привет";
 
    println!("{decimal} {binary} {hexadecimal} {ratio} {enabled} {letter} {greeting}");
}

Байтовый литерал, например b'A', имеет тип u8 и допускает только ASCII.

Преобразование типов данных

Rust не выполняет неявное числовое преобразование даже между похожими типами: i16 нельзя непосредственно присвоить переменной i32. Оператор as делает явное приведение, однако оно может усечь дробную часть или старшие биты и потому требует понимания диапазонов.

Для проверяемых преобразований, способных завершиться ошибкой, предпочтительны From/Into и TryFrom/TryInto.

use std::convert::TryFrom;
 
fn main() {
    let small: i16 = 123;
    let wide: i32 = i32::from(small);
 
    let source: i16 = 258;
    let truncated = source as i8; // 2: старшие биты отброшены
    let checked = i8::try_from(source); // Err: значение не помещается
 
    let float = 3.14_f64;
    let integer = float as i32; // 3
 
    println!("{wide} {truncated} {checked:?} {integer}");
}

Арифметические операции

Базовые операции над числами: сложение +, вычитание -, умножение *, деление / и остаток %. Деление двух целых чисел возвращает целое: дробная часть отбрасывается. Составные присваивания +=, -=, *=, /= и %= изменяют левый операнд, поэтому он должен быть объявлен через mut.

fn main() {
    let sum = 12 + 6;
    let quotient = 29 / 6; // 4
    let remainder = 29 % 6; // 5
 
    let mut number = 12;
    number += 6;
    number *= 2;
 
    println!("{sum} {quotient} {remainder} {number}");
}

Операнды должны иметь совместимые типы. Для целых чисел также важно переполнение: в debug-сборке оно обычно приводит к panic, а нужную семантику можно выразить явно методами checked_*, wrapping_*, saturating_* или overflowing_*.

Поразрядные операции

Поразрядные операции применяются к целым числам. << и >> сдвигают биты, & выполняет битовое И, | — ИЛИ, ^ — исключающее ИЛИ, ! инвертирует биты. Есть составные варианты <<=, >>=, &=, |= и ^=.

fn main() {
    let left = 0b0010u8 << 2; // 0b1000, то есть 8
    let right = 0b1_0000u8 >> 3; // 0b0010, то есть 2
    let union = 0b0101u8 | 0b0010; // 0b0111
    let common = 0b0110u8 & 0b0010; // 0b0010
    let different = 0b0101u8 ^ 0b0010; // 0b0111
 
    println!("{left} {right} {union} {common} {different}");
}

Такие операции нужны для масок, флагов, сетевых протоколов и низкоуровневого представления данных.

Условные выражения

Сравнения ==, !=, <, >, <=, >= возвращают bool. Логическое отрицание записывается как !, конъюнкция — &&, дизъюнкция — ||. Операторы && и || вычисляются лениво: правый операнд не выполняется, если результат уже известен по левому.

fn main() {
    let age = 24;
    let has_access = true;
 
    let adult = age >= 18;
    let allowed = adult && has_access;
    let denied = !allowed;
 
    println!("adult={adult}, allowed={allowed}, denied={denied}");
}

Rust не приводит числа или строки к логическому типу: условие обязано иметь тип bool.

Конструкция if..else

if выполняет блок, когда условие равно true; else if проверяет дополнительные варианты, а else задаёт оставшуюся ветвь. Круглые скобки вокруг условия не обязательны, фигурные скобки вокруг ветвей обязательны.

if является выражением и может вернуть значение. Тогда все достижимые ветви должны возвращать совместимые типы.

fn main() {
    let temperature = 18;
 
    let description = if temperature < 0 {
        "мороз"
    } else if temperature < 20 {
        "прохладно"
    } else {
        "тепло"
    };
 
    println!("{description}");
}

Не следует путать выражение let result = if ... с конструкцией сопоставления if let, которая рассматривается отдельно.

Конструкция match

match сопоставляет значение с ветвями вида паттерн => выражение. Проверка идёт сверху вниз, выполняется первая подходящая ветвь. Сопоставление должно быть исчерпывающим; _ подходит к любому не обработанному значению.

Как и if, match возвращает значение, а результаты его ветвей должны иметь один тип.

fn main() {
    let number = 3;
 
    let word = match number {
        1 => "один",
        2 => "два",
        3 => "три",
        _ => "неизвестно",
    };
 
    println!("{word}");
}

Если ветвь содержит несколько инструкций, после => используется блок { ... }. Более мощные паттерны match рассматриваются в главе о составных типах.

Циклы

Rust предоставляет три основных цикла:

  • loop повторяет блок без условия;
  • while работает, пока условие истинно;
  • for перебирает значения объекта, реализующего итерацию.

break завершает цикл, continue переходит к следующей итерации. loop может вернуть значение через break значение. Метки вида 'outer: позволяют из вложенного цикла обратиться к конкретному внешнему циклу.

fn main() {
    let mut counter = 0;
    let doubled = loop {
        counter += 1;
        if counter == 4 {
            break counter * 2;
        }
    };
 
    for number in 1..=5 {
        if number == 3 {
            continue;
        }
        print!("{number} ");
    }
 
    let mut attempts = 2;
    while attempts > 0 {
        attempts -= 1;
    }
 
    println!("\ndoubled={doubled}");
}

Диапазон 1..5 не включает 5, а 1..=5 включает обе границы.

Функции

Именованная функция объявляется через fn имя() { ... }. Её тело не выполняется само по себе: функцию нужно вызвать по имени. Порядок объявлений в файле не важен, поэтому вспомогательная функция может находиться как до, так и после main.

fn main() {
    hello();
    hello();
}
 
fn hello() {
    println!("Hello Rust");
}

Функции разделяют программу на повторно используемые операции. Принятый стиль имён функций — snake_case.

Параметры функции

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

fn main() {
    show_user("Tom", 36);
    print_square(5);
}
 
fn show_user(name: &str, age: u8) {
    println!("Имя: {name}, возраст: {age}");
}
 
fn print_square(mut number: i32) {
    number *= number;
    println!("Квадрат: {number}");
}

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

Возвращение значения из функции

Тип результата указывается после ->. Обычно функция возвращает последнее выражение блока без точки с запятой. Точка с запятой превращает выражение в инструкцию со значением (), поэтому a + b; не может быть результатом функции, обещающей i32.

fn sum(a: i32, b: i32) -> i32 {
    a + b
}
 
fn normalized_age(age: u8) -> u8 {
    if age <= 110 { age } else { 25 }
}
 
fn main() {
    println!("{}", sum(2, 3));
    println!("{}", normalized_age(127));
}

Явный return значение; удобен для раннего выхода, но для последнего выражения обычно не нужен.

Константы

Константа объявляется через const, требует явного типа и значения, вычислимого при компиляции. Её нельзя сделать mut. По соглашению имена констант пишутся в SCREAMING_SNAKE_CASE. В отличие от локальной переменной, константу можно определить на уровне модуля.

const MAX_USERS: usize = 1_000;
const DEFAULT_PORT: u16 = if cfg!(debug_assertions) { 8080 } else { 80 };
 
fn main() {
    const RETRIES: u8 = 3;
    println!("{MAX_USERS} {DEFAULT_PORT} {RETRIES}");
}

Неизменяемая переменная let получает значение во время выполнения и допускает затенение; const обозначает именно compile-time-константу.

Анонимные функции и блоки кода

Анонимная функция, или замыкание, записывается как |параметры| выражение и может храниться в переменной. Типы параметров и результата часто выводятся из использования, но после вывода конкретное замыкание нельзя вызывать с несовместимыми типами.

Блок { ... } — тоже выражение: он выполняется сразу, а его значением становится последнее выражение без точки с запятой. Пустой блок или блок без итогового выражения возвращает unit ().

fn main() {
    let sum = |a: i32, b: i32| a + b;
 
    let value = {
        let base = 5;
        base * 2
    };
 
    println!("sum={}, value={value}", sum(4, 6));
}

Отличие принципиально: замыкание выполняется при вызове sum(...), а обычный блок — в момент вычисления содержащего его выражения.

Замыкания

Замыкание может обращаться к значениям из окружающей области — захватывать окружение. Компилятор выбирает минимально необходимый способ захвата: неизменяемое заимствование, изменяемое заимствование или перемещение значения.

fn main() {
    let greeting = String::from("Hello");
    let name = "Rust";
 
    let hello = || println!("{greeting}, {name}!");
    hello();
    hello();
 
    let mut count = 0;
    let mut increment = || {
        count += 1;
        count
    };
    println!("{} {}", increment(), increment());
}

Поскольку increment изменяет захваченную переменную, само связывание замыкания тоже объявлено mut. Подробная связь захвата с Fn, FnMut, FnOnce и владением рассматривается в главе 4.

Тип функции

Указатель на обычную функцию имеет тип fn(ТипыПараметров) -> ТипРезультата. Его можно сохранить в переменной и вызвать через неё. Замыкание без захваченного окружения также способно автоматически преобразоваться в указатель fn подходящей сигнатуры.

fn multiply(a: i32, b: i32) -> i32 {
    a * b
}
 
fn main() {
    let operation: fn(i32, i32) -> i32 = multiply;
    println!("{}", operation(5, 6));
 
    let add: fn(i32, i32) -> i32 = |a, b| a + b;
    println!("{}", add(5, 6));
}

Захватывающее окружение замыкание нельзя представить простым fn, потому что вместе с кодом ему требуется хранить захваченные данные.

Функция как параметр и результат другой функции

Тип fn(...) -> ... позволяет принимать и возвращать обычные функции. Это подходит, когда набор операций не захватывает окружение и все функции имеют одинаковую сигнатуру.

fn add(a: i32, b: i32) -> i32 { a + b }
fn multiply(a: i32, b: i32) -> i32 { a * b }
 
fn apply(a: i32, b: i32, operation: fn(i32, i32) -> i32) -> i32 {
    operation(a, b)
}
 
fn choose(multiply_numbers: bool) -> fn(i32, i32) -> i32 {
    if multiply_numbers { multiply } else { add }
}
 
fn main() {
    println!("{}", apply(6, 4, add));
    println!("{}", choose(true)(6, 4));
}

Для произвольных замыканий обычно используют обобщённые параметры с Fn, FnMut или FnOnce, а не указатель fn.

Материал главы 2 на Metanit

Глава 3. Составные типы данных

Кортежи

Кортеж группирует фиксированное количество значений, которые могут иметь разные типы. Его тип перечисляет тип каждого элемента, например (&str, u8, f32). Элементы доступны по индексам .0, .1 и так далее; у изменяемого кортежа их можно заменять значениями соответствующих типов.

Паттерн слева от let раскладывает кортеж на переменные. _ игнорирует ненужный элемент. Кортежи можно передавать в функции и возвращать из них.

fn default_point() -> (i32, i32) {
    (4, 25)
}
 
fn main() {
    let mut user: (&str, u8, f32) = ("Tom", 36, 1.78);
    user.0 = "Bob";
 
    let (name, age, _) = user;
    let (x, y) = default_point();
    println!("{name} {age}; point=({x}, {y})");
}

Кортеж реализует Copy, только если Copy реализуют все его элементы. Кортеж со String при присваивании обычно перемещается, а не копируется.

Массивы

Массив [T; N] содержит ровно N элементов одного типа T; длина является частью типа и после создания не меняется. Литерал [value; N] заполняет массив повторяющимся значением. Индексация начинается с нуля, len() возвращает длину, а выход за границы не даёт читать чужую память и завершается ошибкой компиляции либо panic во время выполнения.

fn main() {
    let mut numbers: [i32; 5] = [2; 5];
    numbers[1] = 8;
    numbers.sort();
 
    for number in numbers {
        print!("{number} ");
    }
 
    let matrix: [[i32; 3]; 2] = [
        [1, 2, 3],
        [4, 5, 6],
    ];
    println!("\n{}", matrix[1][2]);
}

Массив является Copy, когда Copy является тип элементов; иначе присваивание перемещает массив, а явную независимую копию можно получить через clone().

Структуры

Структура создаёт именованный составной тип с полями. Экземпляр должен инициализировать все поля. Если имя локальной переменной совпадает с полем, достаточно сокращённой записи field; синтаксис ..other берёт оставшиеся поля из другого экземпляра и может переместить из него значения, не реализующие Copy.

struct Person {
    name: String,
    age: u8,
    height: f32,
}
 
fn create_person(name: String, age: u8) -> Person {
    Person { name, age, height: 1.75 }
}
 
fn print_person(person: &Person) {
    println!("{}: {}, {}", person.name, person.age, person.height);
}
 
fn main() {
    let mut tom = create_person(String::from("Tom"), 36);
    tom.age = 37;
    print_person(&tom);
 
    let Person { name, age, height: _ } = tom;
    println!("{name} {age}");
}

Для изменения поля весь экземпляр должен быть mut. Передача структуры по значению может передать владение; &Person даёт функции временное заимствование.

Структуры-кортежи

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

struct Point(i32, i32);
struct Distance(Point, Point);
 
fn main() {
    let start = Point(2, 4);
    let end = Point(2, 7);
    let distance = Distance(start, end);
 
    println!(
        "start=({}, {}), end=({}, {})",
        distance.0.0,
        distance.0.1,
        distance.1.0,
        distance.1.1,
    );
}

Структура с одним полем, например struct UserId(u64);, называется newtype и не смешивается с обычным u64, хотя имеет такое же внутреннее представление.

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

enum задаёт закрытый набор вариантов одного типа. В отличие от перечислений многих языков, каждый вариант Rust может хранить собственные данные: без полей, как кортеж или как структура. Значение создаётся через ИмяEnum::Вариант, а исчерпывающий match обрабатывает состояния и извлекает данные.

enum Operation {
    Add(i32, i32),
    Negate(i32),
    Zero,
}
 
fn evaluate(operation: Operation) -> i32 {
    match operation {
        Operation::Add(a, b) => a + b,
        Operation::Negate(value) => -value,
        Operation::Zero => 0,
    }
}
 
fn main() {
    println!("{}", evaluate(Operation::Add(5, 6)));
    println!("{}", evaluate(Operation::Negate(7)));
    println!("{}", evaluate(Operation::Zero));
}

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

Последовательность Range

Диапазон start..end включает начало и исключает конец. Это значение структуры std::ops::Range, которое часто передают циклу for или используют при создании среза. Если начало больше либо равно концу, обычный возрастающий диапазон пуст.

fn main() {
    let numbers = 1..5;
    println!("start={}, end={}", numbers.start, numbers.end);
 
    for number in 1..5 {
        print!("{number} "); // 1 2 3 4
    }
 
    println!();
    for number in 1..=5 {
        print!("{number} "); // 1 2 3 4 5
    }
}

Форма ..=end создаёт включительный диапазон. Существуют и открытые формы ..end, start.. и .., контекст использования которых задаёт недостающую границу.

Паттерны и конструкция match

Паттерн описывает форму значения и одновременно может привязать его части к новым переменным. match проверяет ветви сверху вниз и обязан покрыть все варианты. В паттернах доступны:

  • альтернативы 1 | 2 и включительные диапазоны 3..=9;
  • деструктуризация кортежей, массивов, структур и вариантов enum;
  • _ для одного ненужного значения и .. для оставшихся полей или элементов;
  • привязка name @ pattern, сохраняющая совпавшее значение;
  • guard if условие, добавляющий проверку к уже совпавшему паттерну.
enum Message {
    Move { x: i32, y: i32 },
    Code(u8),
    Quit,
}
 
fn describe(message: Message) {
    match message {
        Message::Move { x, y: 0 } => println!("по оси X до {x}"),
        Message::Move { x, y } if x == y => println!("диагональ {x}"),
        Message::Move { x, y } => println!("точка ({x}, {y})"),
        Message::Code(code @ 1..=9) => println!("короткий код {code}"),
        Message::Code(_) => println!("другой код"),
        Message::Quit => println!("выход"),
    }
}
 
fn main() {
    describe(Message::Move { x: 5, y: 0 });
    describe(Message::Code(7));
    describe(Message::Quit);
 
    let values = [2, 3, 4, 5];
    match values {
        [first, .., last] => println!("{first} {last}"),
    }
}

Имя внутри паттерна обычно создаёт новую переменную, а не сравнивает значение с уже существующей одноимённой переменной.

Паттерны и конструкция if let

if let PATTERN = EXPRESSION выполняет блок только при совпадении паттерна и удобно заменяет match, когда интересен один вариант. Связанные паттерном переменные доступны внутри блока; else обрабатывает несовпадение.

enum DayTime {
    Morning(String),
    Evening(String),
}
 
fn main() {
    let time = DayTime::Morning(String::from("Доброе утро"));
 
    if let DayTime::Morning(message) = time {
        println!("{message}");
    } else {
        println!("Сейчас не утро");
    }
 
    let user = ("Tom", 21);
    if let ("Tom", age) = user {
        println!("Возраст Тома: {age}");
    }
 
    let _evening = DayTime::Evening(String::from("Добрый вечер"));
}

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

Материал главы 3 на Metanit

Глава 4. Ссылки и Ownership

Контекст/область видимости

Область видимости обычно ограничена фигурными скобками. Локальное связывание существует от места объявления до конца блока; вложенный блок видит значения внешнего блока, но внешний блок не видит локальные значения внутреннего. При выходе владельца из области Rust автоматически уничтожает принадлежащие ему данные.

На уровне модуля можно объявлять элементы, в том числе константы и статические значения, но обычный локальный let относится к телу функции или другому блоку.

const LIMIT: i32 = 100;
 
fn main() {
    let outer = 22;
 
    {
        let inner = 33;
        println!("{LIMIT} {outer} {inner}");
    }
 
    // println!("{inner}"); // ошибка: inner уже вне области видимости
    println!("{outer}");
}

Затенение во вложенном блоке не изменяет внешнее связывание: после окончания блока снова становится доступно прежнее значение.

Устройство памяти в Rust. Стек и куча

Стек хранит кадры вызовов и значения известного фиксированного размера. Он работает по принципу LIFO: данные последнего вызова освобождаются первыми. Выделение и освобождение стековой памяти очень дёшево.

Куча хранит данные, размер или время жизни которых требуют динамического размещения. Аллокатор находит область памяти и возвращает указатель. Например, значение String на стеке содержит указатель, длину и ёмкость, а его UTF-8-байты находятся в куче.

fn main() {
    let number: i32 = 10; // само число обычно находится на стеке
    let text = String::from("hello"); // метаданные на стеке, байты в куче
 
    println!("{number} {text}");
}

Это не означает, что любой кортеж или массив всегда физически лежит только на стеке: окончательное размещение может оптимизировать компилятор. Для модели владения важнее, кто отвечает за уничтожение ресурса.

Ownership

Владение связывает ресурс с ответственным за него значением:

  1. у каждого значения есть владелец;
  2. в конкретный момент у значения один владелец;
  3. когда владелец выходит из области видимости, вызывается Drop и ресурс освобождается.

Присваивание значения типа String обычно перемещает владение: прежнее имя использовать нельзя. Это предотвращает двойное освобождение одной области памяти. Типы с трейтом Copy — например, большинство чисел — вместо перемещения неявно копируются. Глубокая независимая копия создаётся явно через clone().

fn consume(value: String) {
    println!("{value}");
}
 
fn main() {
    let first = String::from("hello");
    let copy = first.clone();
    let moved = first;
 
    consume(moved);
    println!("{copy}");
 
    let number = 10;
    let another = number; // i32: Copy
    println!("{number} {another}");
 
    drop(copy); // явное досрочное уничтожение
}

Передача аргумента по значению и возврат значения подчиняются тем же правилам, что и присваивание.

Ссылки

Ссылка &T временно заимствует значение, не забирая владение. Владелец продолжает отвечать за уничтожение данных, а ссылка обязана перестать использоваться раньше, чем исчезнет объект. Borrow checker проверяет это при компиляции и не допускает висячих ссылок.

Неизменяемых ссылок на одно значение может быть несколько одновременно. *reference разыменовывает ссылку. Для строкового чтения функция обычно принимает &str, потому что этот тип работает и со строковыми литералами, и с заимствованным String.

fn length(text: &str) -> usize {
    text.len()
}
 
fn main() {
    let message = String::from("hello");
    let first = &message;
    let second = &message;
 
    println!("{} {} {}", first, second, length(&message));
 
    let number = 22;
    let reference = &number;
    println!("{}", *reference > 10);
}

Нельзя вернуть ссылку на локальный объект функции: объект будет уничтожен при выходе. В таком случае функция возвращает само владеющее значение, например String.

Изменяемые ссылки

&mut T даёт временный эксклюзивный доступ к изменению значения. Для его создания исходная переменная должна быть mut; тип параметра и аргумент также записываются с &mut.

В момент использования изменяемого заимствования нельзя одновременно использовать другие ссылки на те же данные. Разрешено либо сколько угодно активных &T, либо одна активная &mut T. Благодаря анализу последнего использования ссылки заимствование нередко заканчивается раньше фигурной скобки.

fn add_mark(message: &mut String) {
    message.push('!');
}
 
fn main() {
    let mut message = String::from("hello");
 
    let read = &message;
    println!("{read}"); // последнее использование read
 
    let edit = &mut message;
    edit.push('?');
    add_mark(&mut message);
 
    println!("{message}"); // hello?!
}

Эти ограничения предотвращают гонки данных и чтение значения в момент его изменения.

Владение и заимствование и замыкания

Замыкание захватывает только те внешние значения, которые использует, и компилятор выводит способ захвата из тела:

  • чтение создаёт неизменяемое заимствование и обычно позволяет реализовать Fn;
  • изменение создаёт изменяемое заимствование и требует FnMut;
  • перемещение значения из окружения делает вызов потребляющим и оставляет как минимум FnOnce.

Каждое замыкание, которое реализует Fn, также реализует FnMut и FnOnce; реализующее FnMut также реализует FnOnce. move принудительно захватывает используемые значения по значению, но само по себе не означает, что замыкание можно вызвать лишь один раз: это определяется тем, потребляет ли тело захваченное значение.

fn call_once<F>(action: F)
where
    F: FnOnce(),
{
    action();
}
 
fn main() {
    let greeting = String::from("hello");
    let read = || println!("{greeting}"); // Fn
    read();
    read();
 
    let mut count = 0;
    let mut increment = || count += 1; // FnMut
    increment();
    increment();
 
    let payload = String::from("data");
    let consume = move || drop(payload); // FnOnce
    call_once(consume);
 
    println!("count={count}");
}

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

Материал главы 4 на Metanit

Глава 5. Объектно-ориентированное программирование

В Rust нет классов и наследования классов. Состояние обычно хранится в структурах и перечислениях, поведение задаётся блоками impl, а общий интерфейс — трейтами. Полиморфизм строится на обобщениях или объектах трейтов.

Методы

Метод — функция, связанная с типом и принимающая первым параметром self. Блок impl можно определять для структуры или перечисления. Форма получателя показывает, что метод делает с объектом:

  • &self временно заимствует объект только для чтения;
  • &mut self изменяет его через исключительное заимствование;
  • self забирает владение и обычно завершает использование прежнего значения.

Остальные параметры и возвращаемый тип записываются как у обычной функции. При вызове object.method(...) аргумент для self передаётся автоматически.

struct Person {
    name: String,
    age: u8,
}
 
impl Person {
    fn display(&self) {
        println!("Name: {}  Age: {}", self.name, self.age);
    }
 
    fn change_age(&mut self, age: u8) {
        self.age = age;
    }
 
    fn is_older(&self, other: &Person) -> bool {
        self.age > other.age
    }
}
 
fn main() {
    let mut tom = Person { name: "Tom".into(), age: 36 };
    let bob = Person { name: "Bob".into(), age: 41 };
 
    tom.change_age(42);
    tom.display();
    println!("Tom старше Bob: {}", tom.is_older(&bob));
}

Ассоциированные функции

Ассоциированная функция тоже находится в impl, но не принимает self, поэтому относится к типу целиком. Её вызывают через Type::function(...). Частый сценарий — конструкторы new, from_* и фабричные функции. Self внутри реализации означает текущий тип и помогает не повторять его имя.

struct Person {
    name: String,
    age: u8,
}
 
impl Person {
    fn new(name: &str, age: u8) -> Self {
        Self { name: name.into(), age }
    }
}
 
fn main() {
    let tom = Person::new("Tom", 36);
    println!("{}: {}", tom.name, tom.age);
}

Trait

Трейт описывает поведение, которое тип обязуется предоставить. Это близко к интерфейсу: трейт может объявлять методы без тела и давать методы с реализацией по умолчанию. Конкретный тип подключает поведение через impl Trait for Type; обязательные методы без реализации нужно определить все.

trait Printer {
    fn preview(&self) -> String;
 
    fn print(&self) {
        println!("{}", self.preview());
    }
}
 
struct Person {
    name: String,
    age: u8,
}
 
impl Printer for Person {
    fn preview(&self) -> String {
        format!("Person {}; age: {}", self.name, self.age)
    }
}
 
fn main() {
    let tom = Person { name: "Tom".into(), age: 36 };
    tom.print();
}

Trait как параметр и результат функции

Параметр &impl Printer принимает ссылку на любой тип, реализующий Printer. Компилятор создаёт специализированный вариант функции для фактического типа — это статическая диспетчеризация. Эквивалентная, более явная запись через generic: fn display<T: Printer>(value: &T).

Возвращаемый impl Trait скрывает конкретный тип от вызывающего кода, но все ветви функции всё равно должны возвращать один и тот же конкретный тип. Для выбора между разными типами во время выполнения понадобится объект трейта, например Box<dyn Trait>.

trait Sender {
    fn send(&self);
}
 
struct TextMessage(String);
 
impl Sender for TextMessage {
    fn send(&self) {
        println!("Отправлено: {}", self.0);
    }
}
 
fn create_message(text: &str) -> impl Sender {
    TextMessage(text.into())
}
 
fn deliver(message: &impl Sender) {
    message.send();
}
 
fn main() {
    let message = create_message("Hello Rust");
    deliver(&message);
}

Generics. Обобщенные типы

Обобщённый тип содержит параметры типов в угловых скобках. Point<T> требует одинаковый T для обоих полей, а Point<T, U> разрешает разные типы. Обобщёнными могут быть структуры, перечисления и трейты; конкретные типы обычно выводятся из переданных значений.

struct Person<T> {
    id: T,
    name: String,
}
 
enum Value<T> {
    Present(T),
    Missing,
}
 
fn main() {
    let numeric = Person { id: 245_u32, name: "Tom".into() };
    let textual = Person { id: "fhe34u847".to_string(), name: "Bob".into() };
    let answer = Value::Present(42);
 
    println!("{}: {}", numeric.name, numeric.id);
    println!("{}: {}", textual.name, textual.id);
    if let Value::Present(value) = answer {
        println!("{value}");
    }
}

Параметр трейта может иметь тип по умолчанию: trait Drawable<T = Circle>. Реализация может использовать этот тип или явно выбрать другой.

Generics. Обобщенные функции и методы

У функции параметры типов записываются после имени: fn receive<T>(item: T) -> T. Для методов обобщённой структуры параметр объявляют и после impl, и после имени типа. Можно реализовать методы только для конкретной специализации, например impl Person<u32>, либо дать самому методу дополнительный параметр типа.

struct Person<T> {
    id: T,
    name: String,
}
 
impl<T> Person<T> {
    fn id(&self) -> &T {
        &self.id
    }
 
    fn with_id<U>(&self, id: U) -> Person<U> {
        Person { id, name: self.name.clone() }
    }
}
 
impl Person<u32> {
    fn has_id(&self, id: u32) -> bool {
        self.id == id
    }
}
 
fn main() {
    let tom = Person { id: 1_u32, name: "Tom".into() };
    println!("{}", tom.has_id(1));
    let external = tom.with_id("user-1");
    println!("{}", external.id());
}

Trait bound

Ограничение трейтом сообщает, какие операции разрешены для параметра типа. Записи T: Printer, impl Printer и where T: Printer выражают одну основную идею. Несколько требований объединяются через +; where удобнее, когда параметров и ограничений много.

trait Printer {
    fn print(&self);
}
 
trait Sender {
    fn send(&self);
}
 
fn process<T>(value: &T)
where
    T: Printer + Sender,
{
    value.print();
    value.send();
}

Bounds применяются не только к функциям, но и к структурам и блокам методов: struct Device<T: Sender> или impl<T: Sender + Printer> Device<T>. Код внутри получает доступ только к операциям, гарантированным указанными трейтами.

Глобальная реализация трейтов

Blanket implementation реализует трейт сразу для семейства типов. impl<T> Printer for T охватывает вообще все подходящие T, а impl<T: Printer> ConsolePrinter for T — только типы, уже реализующие Printer.

trait Printer {
    fn print(&self);
}
 
trait ConsolePrinter {
    fn console_print(&self);
}
 
impl<T: Printer> ConsolePrinter for T {
    fn console_print(&self) {
        println!("---");
        self.print();
    }
}

Такие реализации могут пересекаться с более узкими, поэтому Rust проверяет согласованность реализаций. Также действует orphan rule: реализацию impl Trait for Type можно объявить, только если текущему crate принадлежит либо трейт, либо тип.

Перегрузка операторов

Операторы связаны с трейтами из std::ops: например, + — с Add, += — с AddAssign, индексирование — с Index. Реализация задаёт тип правого операнда, ассоциированный тип результата и метод операции. Операнд, принятый как self, перемещается; если значения нужно сохранить, реализацию можно строить для ссылок.

use std::ops::Add;
 
#[derive(Debug, PartialEq)]
struct Counter(u32);
 
impl Add<u32> for Counter {
    type Output = Counter;
 
    fn add(self, rhs: u32) -> Self::Output {
        Counter(self.0 + rhs)
    }
}
 
fn main() {
    let result = Counter(6) + 11;
    assert_eq!(result, Counter(17));
}

Ассоциированные типы

Ассоциированный тип — место для типа внутри трейта. Трейт объявляет type Item, а каждая его реализация выбирает одно конкретное значение. В сигнатурах к нему обращаются как Self::Item. В отличие от параметра Trait<T>, вызывающему коду обычно не нужно каждый раз указывать этот тип.

trait Shape {
    type Unit;
    fn area(&self) -> Self::Unit;
}
 
struct Rectangle {
    width: u32,
    height: u32,
}
 
impl Shape for Rectangle {
    type Unit = u32;
 
    fn area(&self) -> Self::Unit {
        self.width * self.height
    }
}
 
fn main() {
    let rectangle = Rectangle { width: 10, height: 20 };
    println!("{}", rectangle.area());
}

На ассоциированный тип можно наложить ограничение: type Item: Display или type ShapeType: Shape.

Объекты трейтов

dyn Trait стирает конкретный тип и выбирает реализацию метода во время выполнения. Сам dyn Trait имеет неизвестный на этапе компиляции размер, поэтому используется за указателем: &dyn Trait, Box<dyn Trait>, Arc<dyn Trait>. Это позволяет хранить разные типы за единым интерфейсом, ценой косвенного вызова и некоторых ограничений object safety.

trait Shape {
    fn area(&self) -> f64;
}
 
struct Circle(f64);
struct Rectangle(f64, f64);
 
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
}
 
impl Shape for Rectangle {
    fn area(&self) -> f64 { self.0 * self.1 }
}
 
fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle(5.0)),
        Box::new(Rectangle(10.0, 20.0)),
    ];
 
    for shape in shapes {
        println!("{}", shape.area());
    }
}

Обобщения и impl Trait дают статическую диспетчеризацию; dyn Trait нужен для неоднородной коллекции или выбора реализации в runtime.

Условное соответствие трейтов

Трейт можно реализовать не для всех возможных сочетаний типов, а лишь для разрешённых. У обобщённого трейта разные параметры образуют разные реализации, поэтому система типов не даст выполнить преобразование, для которого нет impl.

trait ConvertTo<T> {
    fn convert(&self) -> T;
}
 
struct Celsius(f64);
struct Fahrenheit(f64);
 
impl ConvertTo<Fahrenheit> for Celsius {
    fn convert(&self) -> Fahrenheit {
        Fahrenheit(self.0 * 1.8 + 32.0)
    }
}
 
impl ConvertTo<Celsius> for Fahrenheit {
    fn convert(&self) -> Celsius {
        Celsius((self.0 - 32.0) / 1.8)
    }
}
 
fn main() {
    let fahrenheit: Fahrenheit = Celsius(100.0).convert();
    println!("{:.2}", fahrenheit.0);
}

Более общий вариант условной реализации — impl<T: RequiredTrait> AnotherTrait for Wrapper<T>: новый трейт появляется у Wrapper<T> только при выполнении bound.

Программирование на уровне типа

Часть правил предметной области можно выразить типами, bounds и ассоциированными типами. Тогда недопустимая комбинация отвергается компилятором, а корректный путь не требует runtime-проверки. В примере тип персонажа однозначно задаёт разрешённое оружие.

trait Weapon {
    fn attack(&self);
}
 
trait Character {
    type WeaponType: Weapon;
    fn create_weapon() -> Self::WeaponType;
}
 
struct Warrior;
struct Sword;
 
impl Weapon for Sword {
    fn attack(&self) { println!("Атакуем мечом"); }
}
 
impl Character for Warrior {
    type WeaponType = Sword;
    fn create_weapon() -> Self::WeaponType { Sword }
}
 
fn attack<C: Character>() {
    C::create_weapon().attack();
}
 
fn main() {
    attack::<Warrior>();
}

Трейты Debug и fmt::Display

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

Display задаёт пользовательское представление для {}. Его реализуют вручную через fmt, записывая результат в переданный форматтер.

use std::fmt;
 
#[derive(Debug)]
struct Person {
    name: String,
    age: u8,
}
 
impl fmt::Display for Person {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} (age: {})", self.name, self.age)
    }
}
 
fn main() {
    let tom = Person { name: "Tom".into(), age: 40 };
    println!("Debug: {tom:?}");
    println!("Display: {tom}");
    dbg!(&tom);
}

Материалы главы: https://metanit.com/rust/tutorial/5.1.php

Глава 6. Время жизни ссылки

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

Аннотации и время жизни ссылки

Ссылка не может пережить значение, на которое указывает. Обычно borrow checker выводит это из областей видимости. Явная аннотация начинается с апострофа и записывается между & и типом: &'a T, &'a mut T. Имя 'a выбирает программист; оно описывает отношение ссылок, а не конкретное число секунд или строк кода.

fn choose<'a>(value: &'a str) -> &'a str {
    value
}
 
fn main() {
    let source = String::from("hello");
    let result = choose(&source);
    println!("{result}");
}

Нельзя вернуть ссылку на локальную переменную функции: она будет уничтожена при выходе. В таком случае возвращают владеющий String. Ссылка на строковый литерал допустима, потому что литерал имеет статическое время жизни.

Аннотации ссылок в функциях

Если результат заимствован из аргумента, сигнатура должна показать, из какого именно. Одинаковая 'a означает: возвращаемая ссылка действительна не дольше общего допустимого периода переданных ссылок.

fn choose<'a>(name: &'a str, default: &'a str) -> &'a str {
    if name == "admin" { default } else { name }
}

Во многих сигнатурах аннотации опускаются по правилам lifetime elision:

  1. каждому входному ссылочному параметру назначается отдельный lifetime;
  2. при единственной входной ссылке её lifetime назначается всем выходным ссылкам;
  3. в методе с &self или &mut self результат по умолчанию связывается с self.

При нескольких входных ссылках без self компилятор не может угадать источник результата, и связь указывают явно. Разные аннотации нужны, если результат зависит только от одного аргумента:

fn first<'a, 'b>(primary: &'a str, _comment: &'b str) -> &'a str {
    primary
}

Аннотации ссылок в структурах

Структура с полем-ссылкой должна объявить lifetime. Person<'a> означает, что экземпляр нельзя использовать дольше значения, заимствованного полем name. Структура не становится владельцем строки.

struct Person<'a> {
    name: &'a str,
}
 
fn main() {
    let username = String::from("Tom");
    let tom = Person { name: &username };
    println!("{}", tom.name);
}

Если структура должна жить независимо от внешней строки, поле лучше сделать владеющим: name: String. Lifetime-параметр нужен именно тогда, когда заимствование является частью состояния.

Аннотации ссылок в определениях методов

Для структуры Person<'a> блок методов записывается как impl<'a> Person<'a>. Lifetime метода может быть отдельным от lifetime структуры. По третьему правилу elision возвращаемая ссылка метода обычно связывается с &self; если метод возвращает другой аргумент, связь нужно выразить явно.

struct Person<'a> {
    name: &'a str,
}
 
impl<'a> Person<'a> {
    fn name(&self) -> &str {
        self.name
    }
 
    fn repeat<'b>(&self, words: &'b str) -> &'b str {
        println!("{} говорит: {words}", self.name);
        words
    }
}
 
fn main() {
    let tom = Person { name: "Tom" };
    println!("{}: {}", tom.name(), tom.repeat("Hello"));
}

Статическое время жизни

'static означает, что ссылка может быть действительна до конца работы программы. Строковые литералы встроены в бинарный файл и имеют тип &'static str.

fn get_message() -> &'static str {
    "hello"
}
 
fn main() {
    let message = get_message();
    println!("{message}");
}

Требование T: 'static не обязательно означает, что само значение существует вечно: оно означает, что тип не содержит короткоживущих заимствований. Не стоит исправлять lifetime-ошибки механическим добавлением 'static; чаще нужно вернуть владеющее значение или правильно связать входную и выходную ссылки.

Материалы главы: https://metanit.com/rust/tutorial/6.1.php

Глава 7. Коллекции

Стандартные коллекции владеют элементами и обычно размещают буфер в куче. Выбор коллекции определяется операциями: последовательность — Vec, ключ и значение — HashMap, уникальные значения — HashSet, строка UTF-8 — String.

Вектор

Vec<T> — изменяемая последовательность однотипных элементов. Пустой вектор создаётся через Vec::new(), заполненный — через vec![...], повторяющиеся значения — через vec![value; count]. Для push, pop, remove и изменения элементов переменная должна быть mut.

Индексирование values[index] при неверном индексе вызывает panic; get(index) безопасно возвращает Option<&T>. Перебор for item in &values заимствует вектор, &mut values даёт изменяемые ссылки, а передача values перемещает элементы и сам вектор больше недоступен.

fn main() {
    let mut users = vec!["Tom", "Sam", "Bob"];
    users.push("Alice");
 
    if let Some(second) = users.get(1) {
        println!("Второй: {second}");
    }
 
    for user in &users {
        println!("{user}");
    }
 
    let last = users.pop();
    println!("Удалён: {last:?}; осталось: {}", users.len());
}

Вектор хранит один тип. Неоднородные предметные значения представляют общим enum или, если требуется динамический полиморфизм, указателями на dyn Trait.

String

String владеет изменяемым буфером UTF-8 в куче, а &str — заимствованное строковое представление. Строку создают через String::new(), String::from(...) или .to_string(), расширяют с помощью push(char) и push_str(&str).

len() у строки считает байты, а chars().count() — Unicode scalar values; ни то ни другое в общем случае не равно числу воспринимаемых пользователем символов. Индексирование text[0] запрещено. Срез &text[a..b] использует байтовые границы и вызовет panic, если граница попадает внутрь UTF-8 символа.

fn main() {
    let mut message = String::from("Привет");
    message.push(',');
    message.push_str(" Rust");
 
    println!("байт: {}, char: {}", message.len(), message.chars().count());
 
    let language = String::from("Rust");
    let formatted = format!("{message}: {language}");
    println!("{formatted}");
}

Оператор left + &right забирает владение left. format! удобнее для нескольких частей и не забирает владение аргументами только ради форматирования.

HashMap

HashMap<K, V> хранит пары ключ—значение и импортируется из std::collections. Ключи должны поддерживать равенство и хеширование. Порядок обхода не гарантирован.

insert добавляет или заменяет значение. Индексирование по отсутствующему ключу вызывает panic, поэтому обычно применяют get, возвращающий Option<&V>. API entry позволяет вставить значение только при отсутствии ключа или изменить существующее без двойного поиска.

use std::collections::HashMap;
 
fn main() {
    let mut ages = HashMap::from([("Tom", 39), ("Alice", 35)]);
    ages.insert("Tom", 40);
    *ages.entry("Bob").or_insert(0) += 1;
 
    if let Some(age) = ages.get("Alice") {
        println!("Alice: {age}");
    }
 
    for (name, age) in &ages {
        println!("{name}: {age}");
    }
}

Значения с Copy копируются в карту, остальные обычно перемещаются. Если карта хранит ссылки, исходные значения обязаны жить дольше самой карты.

HashSet

HashSet<T> — множество уникальных значений из std::collections. insert возвращает true, если элемент действительно добавлен; contains проверяет наличие, remove удаляет, clear очищает множество. Как и у HashMap, порядок элементов не определён.

Над множествами доступны ленивые операции union, intersection, difference и symmetric_difference; их результат можно перебрать или собрать через collect.

use std::collections::HashSet;
 
fn main() {
    let first = HashSet::from(["Tom", "Bob", "Alice"]);
    let second = HashSet::from(["Sam", "Kate", "Bob"]);
 
    let common: HashSet<_> = first.intersection(&second).copied().collect();
    let only_first: HashSet<_> = first.difference(&second).copied().collect();
 
    println!("общие: {common:?}");
    println!("только первые: {only_first:?}");
}

Slice

Срез &[T] — заимствованное представление непрерывной части массива, вектора или другого среза. Он хранит указатель и длину, но не владеет элементами. Диапазон start..end включает начало и не включает конец; .., start.., ..end сокращают запись.

fn sum(values: &[i32]) -> i32 {
    values.iter().sum()
}
 
fn main() {
    let mut numbers = [1, 2, 3, 4, 5, 6];
    let middle = &numbers[1..4];
    println!("{middle:?}; сумма={}", sum(middle));
 
    let tail = &mut numbers[3..];
    tail[0] = 40;
    println!("{numbers:?}");
}

Неверные границы вызывают panic. В параметрах функций &[T] обычно гибче, чем &Vec<T>, поскольку принимает и массив, и вектор, и уже готовый срез.

Итераторы

Iterator генерирует последовательность значений. Его основной метод — next(&mut self) -> Option<Self::Item>: Some(item) содержит следующий элемент, None означает конец. Цикл for работает с типами, реализующими IntoIterator.

struct Counter {
    current: u32,
    end: u32,
}
 
impl Iterator for Counter {
    type Item = u32;
 
    fn next(&mut self) -> Option<Self::Item> {
        if self.current < self.end {
            self.current += 1;
            Some(self.current)
        } else {
            None
        }
    }
}
 
fn main() {
    for number in (Counter { current: 0, end: 3 }) {
        println!("{number}");
    }
}

Для коллекций iter() выдаёт ссылки, iter_mut() — изменяемые ссылки, into_iter() — элементы по значению и обычно потребляет коллекцию. Адаптеры итератора ленивы: работа начинается, когда вызывается потребляющая операция вроде collect, sum, for_each или цикл for.

Управление коллекциями

Цепочки итераторов позволяют декларативно отбирать и преобразовывать данные без промежуточных коллекций. Основные адаптеры: filter, map, enumerate, skip, take; потребители: collect, find, nth, fold, sum, for_each. find лучше выражает поиск первого совпадения, чем filter(...).nth(0).

#[derive(Debug)]
struct Person {
    name: String,
    age: u8,
}
 
fn main() {
    let people = vec![
        Person { name: "Tom".into(), age: 38 },
        Person { name: "Kate".into(), age: 31 },
        Person { name: "Alice".into(), age: 34 },
    ];
 
    let names: Vec<&str> = people
        .iter()
        .filter(|person| person.age > 33)
        .map(|person| person.name.as_str())
        .take(2)
        .collect();
 
    println!("{names:?}");
}

Пока не вызван потребитель, цепочка только описывает вычисление. Это позволяет обрабатывать элементы по одному и даже работать с бесконечными итераторами, если ограничить их через take.

Материалы главы: https://metanit.com/rust/tutorial/7.1.php

Глава 8. Модули

Модульная система объединяет элементы в пространства имён и задаёт границы видимости. Модули образуют дерево внутри crate; путь к элементу записывается через ::.

Определение модулей. Приватность и публичность

Модуль объявляется через mod. Его элементы по умолчанию приватны для внешнего кода; pub открывает выбранный элемент. Публичность проверяется на каждом участке пути: чтобы вызвать функцию вложенного модуля извне, публичными должны быть и модуль, и функция.

У структуры отдельно контролируется видимость самого типа и каждого поля. Публичный тип с приватными полями обычно создают через публичную ассоциированную функцию. Варианты публичного enum доступны без отдельного pub.

mod users {
    pub struct Person {
        name: String,
    }
 
    impl Person {
        pub fn new(name: &str) -> Self {
            Self { name: name.into() }
        }
 
        pub fn display(&self) {
            println!("{}", self.name);
        }
    }
}
 
fn main() {
    let tom = users::Person::new("Tom");
    tom.display();
}

Вложенные модули и ключевое слово super

Модуль может содержать дочерние модули. Абсолютный путь начинается с crate, относительный — с имени в текущем контексте, self обозначает текущий модуль, а super — родительский. Дочерний модуль может обращаться к приватным элементам предков, но внешний код видит только публичный API.

mod parent {
    fn hello() {
        println!("Hello");
    }
 
    pub mod child {
        pub fn greet() {
            super::hello();
        }
    }
}
 
fn main() {
    crate::parent::child::greet();
}

Оператор use и подключение модулей

use вводит путь или элемент в текущую область видимости и избавляет от повторения полного имени. Можно импортировать один элемент, группу {a, b}, сам модуль через {self, ...} или задать псевдоним через as. Glob-импорт * доступен, но в обычном коде явные импорты лучше показывают зависимости и избегают конфликтов имён.

mod messages {
    pub fn hello() { println!("Hello"); }
    pub fn bye() { println!("Good bye"); }
}
 
use crate::messages::{bye, hello as greet};
 
fn main() {
    greet();
    bye();
}

use не копирует код и не подключает файл: он лишь создаёт короткое имя для уже доступного пути. pub use дополнительно переэкспортирует элемент как часть публичного API текущего модуля.

Определение модуля во внешнем файле

Объявление mod messages; сообщает компилятору, что тело модуля находится отдельно. Для модуля первого уровня используется messages.rs либо старый совместимый вариант messages/mod.rs. В современном проекте обычно предпочитают messages.rs, а его подмодули размещают в messages/.

src/
├── main.rs
├── messages.rs
└── messages/
    └── hello.rs

src/main.rs:

mod messages;
 
fn main() {
    messages::hello::print_ru();
    messages::print_message("Hello World");
}

src/messages.rs:

pub mod hello;
 
pub fn print_message(message: &str) {
    println!("{message}");
}

src/messages/hello.rs:

pub fn print_ru() {
    println!("Привет");
}

Объявление модуля пишется один раз в родителе; в самом файле messages.rs повторно оборачивать содержимое в mod messages { ... } не нужно. Файлы определяют расположение кода, а видимость по-прежнему задаётся pub и путями модульного дерева.

Материалы главы: https://metanit.com/rust/tutorial/8.1.php

Глава 9. Обработка ошибок

Макрос panic!

Ошибки в Rust условно делят на восстанавливаемые и невосстанавливаемые. Ожидаемую проблему — например, отсутствие файла или неверный ввод — обычно возвращают как Result<T, E>. panic! используют, когда продолжать выполнение нельзя: нарушен внутренний инвариант, обнаружен баг или программа попала в состояние, для которого нет разумного восстановления.

Паника выводит сообщение и завершает текущий поток. По умолчанию Rust разматывает стек и вызывает деструкторы локальных значений; в профиле сборки можно выбрать немедленное завершение процесса. Некоторые заведомо ошибочные обращения компилятор отклоняет заранее, а ошибки, зависящие от данных времени выполнения, приводят к панике уже при запуске.

fn create_age(age: u8) -> u8 {
    if age > 110 {
        panic!("Некорректный возраст: {age}");
    }
    age
}
 
fn main() {
    println!("Возраст: {}", create_age(36));
}

Для диагностики можно включить стек вызовов:

RUST_BACKTRACE=1 cargo run

Тип Result

Result<T, E> — перечисление стандартной библиотеки с вариантами Ok(T) и Err(E). Тип T описывает успешный результат, а E — ошибку. Вызывающий код обязан явно рассмотреть оба исхода или осознанно преобразовать результат другим методом.

#[derive(Debug)]
struct Person {
    name: String,
    age: u8,
}
 
fn create_person(name: &str, age: u8) -> Result<Person, String> {
    if age <= 110 {
        Ok(Person { name: name.into(), age })
    } else {
        Err("возраст должен быть не больше 110".into())
    }
}
 
fn main() {
    match create_person("Tom", 36) {
        Ok(person) => println!("{}: {}", person.name, person.age),
        Err(error) => eprintln!("Ошибка: {error}"),
    }
}

В отличие от исключений, возможная ошибка видна прямо в сигнатуре функции и распространяется как обычное значение.

Методы unwrap и expect типа Result

unwrap() возвращает значение из Ok, но вызывает панику при Err. expect(message) делает то же самое, добавляя к панике поясняющий контекст. Оба метода удобны в коротких примерах и тестах, а в прикладном коде подходят только тогда, когда ошибка действительно невозможна или должна немедленно остановить программу.

fn main() {
    let port: u16 = "8080".parse().expect("порт должен быть числом");
    println!("{port}");
}

Если нужен запасной результат, есть unwrap_or(default), unwrap_or_default() и unwrap_or_else(|error| ...). Последний вычисляет значение лениво и получает саму ошибку:

fn parse_port(value: &str) -> u16 {
    value.parse().unwrap_or_else(|error| {
        eprintln!("Не удалось разобрать порт: {error}; используется 8080");
        8080
    })
}

Для обычной обработки лучше использовать match, комбинаторы map/map_err или оператор ?, сохраняя ошибку в типе результата.

Обработка нескольких типов ошибок

Разные причины отказа удобно объединять в собственное перечисление. Каждый вариант содержит относящиеся к нему данные, а match заставляет обработать все варианты. Так вызывающий код может отличить неверное имя от неверного возраста, не анализируя текст сообщения.

#[derive(Debug)]
enum PersonError {
    InvalidName,
    InvalidAge(u8),
}
 
struct Person {
    name: String,
    age: u8,
}
 
fn create_person(name: &str, age: u8) -> Result<Person, PersonError> {
    if name.chars().count() < 3 {
        Err(PersonError::InvalidName)
    } else if age > 110 {
        Err(PersonError::InvalidAge(age))
    } else {
        Ok(Person { name: name.into(), age })
    }
}
 
fn main() {
    match create_person("Bo", 136) {
        Ok(person) => println!("{}: {}", person.name, person.age),
        Err(PersonError::InvalidName) => eprintln!("Слишком короткое имя"),
        Err(PersonError::InvalidAge(age)) => eprintln!("Некорректный возраст: {age}"),
    }
}

В библиотечном коде тип ошибки обычно также реализует Display и std::error::Error, чтобы его можно было удобно показывать пользователю и объединять с другими ошибками.

Оператор ?

Оператор ? сокращает ранний возврат ошибки. Для Result он извлекает значение из Ok; если получен Err, выполнение текущей функции прекращается и ошибка возвращается вызывающему коду. Поэтому функция с ? сама должна возвращать совместимый Result.

use std::num::ParseIntError;
 
fn double_number(text: &str) -> Result<i32, ParseIntError> {
    let number: i32 = text.trim().parse()?;
    Ok(number * 2)
}
 
fn main() -> Result<(), ParseIntError> {
    println!("{}", double_number("21")?);
    Ok(())
}

Если типы ошибок различаются, ? может выполнить преобразование через трейт From. Сам оператор не скрывает ошибку и не вызывает панику: он лишь реализует краткий вариант match с ранним return Err(...).

Источник главы: https://metanit.com/rust/tutorial/10.1.php

Глава 10. Cargo

Создание проекта с помощью Cargo

Cargo — стандартный инструмент сборки и менеджер пакетов Rust. Он создаёт структуру проекта, запускает компилятор, тесты и документацию, управляет зависимостями и профилями сборки.

cargo --version
cargo new hello
cd hello
cargo check
cargo run

cargo new создаёт Cargo.toml, src/main.rs и служебные файлы Git. Cargo.toml описывает пакет, его версию, edition и зависимости:

[package]
name = "hello"
version = "0.1.0"
edition = "2024"
 
[dependencies]

Основные команды:

cargo build             # target/debug/hello
./target/debug/hello
cargo run               # собрать и сразу запустить
cargo check             # быстро проверить типы без финальной сборки
cargo build --release   # оптимизированный target/release/hello

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

Загрузка и использование внешних зависимостей

Публичные крейты размещаются на crates.io. Зависимость можно записать вручную в [dependencies] или добавить командой Cargo. При сборке Cargo разрешит совместимые версии, загрузит исходники и зафиксирует точное дерево в Cargo.lock.

cargo add rand
cargo build

Эквивалентная запись в манифесте выглядит так:

[dependencies]
rand = "0.9"

После этого элементы крейта доступны через пути модуля:

use rand::Rng;
 
fn main() {
    let mut rng = rand::rng();
    let number = rng.random_range(1..=10);
    println!("Случайное число: {number}");
}

Версия вида "0.9" разрешает совместимые обновления по правилам Cargo. Cargo.toml выражает допустимый диапазон, а Cargo.lock обеспечивает воспроизводимую сборку конкретного приложения. Для обновления зафиксированных версий применяется cargo update.

Крейты и пакеты

Крейт — единица компиляции Rust. Бинарный крейт имеет main и создаёт исполняемый файл; библиотечный крейт экспортирует переиспользуемый API и обычно начинается с src/lib.rs. Корневой файл крейта образует корневой модуль, к которому подключаются остальные модули.

Пакет — набор связанных крейтов с одним Cargo.toml. По соглашениям Cargo:

  • src/main.rs — основной бинарный крейт;
  • src/lib.rs — единственный библиотечный крейт пакета;
  • каждый файл в src/bin/ — отдельный бинарный крейт;
  • пакет может содержать несколько бинарных целей, но не более одной библиотечной.

Пример запуска дополнительной программы src/bin/admin.rs:

cargo run --bin admin

Несколько пакетов можно объединить в workspace: у них будут общий Cargo.lock и каталог target, но отдельные манифесты и границы API.

Источник главы: https://metanit.com/rust/tutorial/9.1.php

Глава 11. Ввод и вывод

Ввод с клавиатуры

Стандартный ввод возвращает std::io::Stdin. Метод read_line(&mut String) добавляет введённые UTF-8-данные в изменяемый буфер и возвращает io::Result<usize> с количеством прочитанных байтов. Перевод строки остаётся в буфере, поэтому перед разбором числа обычно вызывают trim().

use std::io::{self, Write};
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    print!("Введите возраст: ");
    io::stdout().flush()?;
 
    let mut input = String::new();
    let bytes = io::stdin().read_line(&mut input)?;
    if bytes == 0 {
        println!("Достигнут конец ввода");
        return Ok(());
    }
 
    let age: u8 = input.trim().parse()?;
    println!("Возраст: {age}");
    Ok(())
}

Ok(0) означает EOF. Невалидный UTF-8 приводит к ошибке чтения, а преобразование строки в число может вернуть отдельную ошибку разбора; в примере оба типа поднимаются через ? как Box<dyn Error>.

Источник главы: https://metanit.com/rust/tutorial/11.1.php

Глава 12. Указатели

Unsafe-контекст и указатели

unsafe не отключает borrow checker целиком. Он разрешает пять дополнительных действий: разыменовывать сырые указатели, вызывать unsafe-функции, обращаться к изменяемым статическим данным, реализовывать unsafe-трейты и читать поля union. Все остальные проверки языка продолжают действовать.

Сырые указатели имеют типы *const T и *mut T. Создать их можно в безопасном коде, но разыменование требует unsafe, потому что компилятор не доказывает, что адрес ненулевой, выровнен, указывает на живой объект и не нарушает правила алиасинга.

fn main() {
    let mut number = 5;
    let read_ptr: *const i32 = &number;
    let write_ptr: *mut i32 = &mut number;
 
    println!("адрес: {read_ptr:p}");
    unsafe {
        println!("значение: {}", *read_ptr);
        *write_ptr = 29;
    }
    println!("number: {number}");
}

Unsafe-функция объявляет контракт, который вызывающий код обязан обеспечить:

unsafe fn double_at(pointer: *mut u32) {
    unsafe {
        *pointer *= 2;
    }
}

Методы вроде slice.as_mut_ptr() дают указатель на первый элемент; смещение pointer.add(index) допустимо только внутри того же выделенного объекта и в его границах. Изменяемый static mut также требует unsafe и почти всегда должен быть заменён атомарным типом или блокировкой, иначе параллельный доступ создаст data race.

union хранит несколько представлений в одной области памяти. Записывать поле можно безопасно, но читать — только с доказательством, что активно совместимое представление:

union Symbol {
    letter: char,
    code: u32,
}
 
fn main() {
    let symbol = Symbol { letter: 'A' };
    let code = unsafe { symbol.code };
    println!("{code}");
}

Внешние функции объявляются через unsafe extern "C" и вызываются в unsafe-блоке; подробно FFI рассматривается в главе 17.

В актуальной edition 2024 тело unsafe fn не делает все операции автоматически безопасными: опасное разыменование всё равно лучше явно заключать в unsafe-блок. Нельзя превращать произвольное число в указатель и разыменовывать его без доказательства корректности адреса. Unsafe-код держат минимальным, документируют его предусловия и закрывают безопасной обёрткой.

Смарт-указатели

Смарт-указатель владеет значением или управляет доступом к нему и обычно реализует Deref и Drop.

Box<T> размещает значение в куче, оставаясь его единственным владельцем. Он полезен для больших значений, trait objects и рекурсивных типов, размер которых иначе нельзя вычислить:

#[derive(Debug)]
enum List {
    Node(i32, Box<List>),
    Nil,
}
 
fn main() {
    let list = List::Node(1, Box::new(List::Node(2, Box::new(List::Nil))));
    println!("{list:?}");
}

Rc<T> предоставляет разделяемое неизменяемое владение в одном потоке. Rc::clone не копирует данные, а увеличивает счётчик сильных ссылок; значение удаляется после исчезновения последней ссылки. Для многопоточного случая предназначен Arc<T>.

RefCell<T> реализует внутреннюю изменяемость: правила «много неизменяемых ссылок или одна изменяемая» проверяются во время выполнения. Нарушение приводит к панике, поэтому области жизни Ref и RefMut должны быть короткими.

use std::cell::RefCell;
use std::rc::Rc;
 
fn main() {
    let data = Rc::new(RefCell::new(vec![1, 2, 3]));
    let shared = Rc::clone(&data);
    shared.borrow_mut().push(4);
    println!("{:?}", data.borrow());
}

Связка Rc<RefCell<T>> удобна для однопоточных графов и UI-моделей, но циклы из сильных Rc приводят к утечке; обратные связи обычно хранят как Weak<T>.

Источник главы: https://metanit.com/rust/tutorial/12.1.php

Глава 13. Многопоточность

Создание потоков

std::thread::spawn запускает замыкание в новом системном потоке и возвращает JoinHandle<T>. Главный поток не ждёт дочерние автоматически, поэтому join() используют, чтобы дождаться результата и получить панику дочернего потока как Result.

use std::thread;
 
fn main() {
    let data = String::from("Hello");
    let worker = thread::spawn(move || {
        println!("{data} из потока");
        42
    });
 
    let result = worker.join().expect("поток завершился с паникой");
    println!("Результат: {result}");
}

move переносит захваченные значения в замыкание. Это обычно необходимо, потому что поток может жить дольше области, где он был создан. Для нескольких задач дескрипторы сохраняют в коллекции и затем вызывают join у каждого. Порядок выполнения и вывода между потоками не гарантируется.

Смарт-указатель Arc

Arc<T> — атомарный счётчик ссылок для разделяемого владения между потоками. Arc::clone создаёт нового владельца тех же данных, а не глубокую копию. Сам Arc не делает внутреннее значение изменяемым: для записи его сочетают с Mutex, RwLock или атомарным типом.

use std::{sync::Arc, thread};
 
fn main() {
    let numbers = Arc::new(vec![1, 2, 3, 4, 5]);
    let first = Arc::clone(&numbers);
    let second = Arc::clone(&numbers);
 
    let sum = thread::spawn(move || first.iter().sum::<i32>());
    let len = thread::spawn(move || second.len());
 
    println!("sum={}, len={}", sum.join().unwrap(), len.join().unwrap());
}

Rc<T> дешевле, но не является потокобезопасным и не может быть отправлен в другой поток. Arc<T> решает только вопрос времени жизни и совместного владения; доступ к T всё равно должен соответствовать ограничениям Send и Sync.

Мьютексы

Mutex<T> разрешает только одному потоку одновременно получить доступ к T. lock() блокирует поток до получения MutexGuard; guard разыменовывается в значение и автоматически снимает блокировку при выходе из области видимости.

use std::{sync::{Arc, Mutex}, thread};
 
fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut workers = Vec::new();
 
    for _ in 0..4 {
        let counter = Arc::clone(&counter);
        workers.push(thread::spawn(move || {
            *counter.lock().unwrap() += 1;
        }));
    }
 
    for worker in workers {
        worker.join().unwrap();
    }
    println!("{}", *counter.lock().unwrap());
}

Если поток паникует, удерживая lock, мьютекс становится poisoned, поэтому lock() возвращает Result. В реальном коде следует решить, допустимо ли восстановить данные, а не безусловно вызывать unwrap. Критическую секцию держат короткой и не выполняют внутри неё медленный ввод-вывод.

Взаимоблокировки мьютексов

Deadlock возникает, когда потоки циклически ждут удерживаемые друг другом блокировки. Типичный случай: первый поток захватывает A, затем ждёт B, а второй захватывает B и ждёт A.

Основная защита — единый порядок захвата ресурсов во всём приложении. Также полезны объединение связанных данных под одним мьютексом, ранний drop(guard) и отказ от вызова внешнего кода под блокировкой. try_lock() не ждёт, а сразу возвращает ошибку WouldBlock, позволяя повторить попытку или выполнить другой путь.

use std::sync::Mutex;
 
fn update(a: &Mutex<i32>, b: &Mutex<i32>) {
    // Везде захватываем сначала a, затем b.
    let mut a_guard = a.lock().unwrap();
    let mut b_guard = b.lock().unwrap();
    *a_guard += 1;
    *b_guard += 1;
}

try_lock помогает не зависнуть, но сам по себе не гарантирует прогресс: бесконечные повторы могут привести к livelock или starvation.

RwLock

RwLock<T> допускает несколько одновременных читателей либо одного эксклюзивного писателя. read() возвращает guard чтения, write() — изменяемый guard записи. Он полезен при частом чтении и редких изменениях; при другой нагрузке обычный Mutex может оказаться проще и быстрее.

use std::sync::{Arc, RwLock};
use std::thread;
 
fn main() {
    let value = Arc::new(RwLock::new(0));
 
    let writer_value = Arc::clone(&value);
    let writer = thread::spawn(move || *writer_value.write().unwrap() += 1);
    writer.join().unwrap();
 
    let mut readers = Vec::new();
    for _ in 0..3 {
        let value = Arc::clone(&value);
        readers.push(thread::spawn(move || *value.read().unwrap()));
    }
 
    for reader in readers {
        println!("{}", reader.join().unwrap());
    }
}

Guard можно освободить раньше конца функции с помощью drop(guard). Политика приоритета читателей и писателей зависит от реализации ОС, поэтому нельзя полагаться на конкретный порядок пробуждения.

Управление взаимоблокировками RwLock

RwLock также может участвовать во взаимоблокировках: поток удерживает write-lock одного ресурса и ждёт read- или write-lock другого, пока другой поток делает обратное. Кроме единого порядка захвата, доступны неблокирующие try_read() и try_write().

use std::sync::RwLock;
 
fn try_increment(value: &RwLock<i32>) -> bool {
    match value.try_write() {
        Ok(mut guard) => {
            *guard += 1;
            true
        }
        Err(_) => false,
    }
}

Перед запросом другой блокировки часто лучше явно освободить первую:

use std::sync::RwLock;
 
fn main() {
    let first = RwLock::new(1);
    let second = RwLock::new(2);
    let snapshot = {
        let guard = first.read().unwrap();
        *guard
    }; // guard уже освобождён
 
    *second.write().unwrap() += snapshot;
}

Неблокирующая попытка должна иметь осмысленную стратегию: пропуск работы, ограниченное число повторов или задержку. Бесконечный busy loop только расходует CPU.

Межпотоковое взаимодействие через каналы

std::sync::mpsc реализует канал с несколькими отправителями и одним получателем. channel() возвращает (Sender<T>, Receiver<T>); Sender можно клонировать для нескольких producer-потоков. send обычно передаёт владение сообщением, поэтому отправитель больше не использует его.

use std::sync::mpsc;
use std::thread;
 
fn main() {
    let (tx, rx) = mpsc::channel();
    let mut workers = Vec::new();
 
    for id in 1..=3 {
        let tx = tx.clone();
        workers.push(thread::spawn(move || tx.send(format!("worker {id}")).unwrap()));
    }
    drop(tx); // закрываем последний sender в главном потоке
 
    for message in rx {
        println!("Получено: {message}");
    }
    for worker in workers {
        worker.join().unwrap();
    }
}

recv() ждёт сообщение, try_recv() возвращается сразу, recv_timeout() ждёт ограниченное время. Итерация по Receiver заканчивается, когда удалены все отправители. Отключение другой стороны отражается в Result, а порядок сообщений между разными отправителями заранее не определён.

Atomic

Атомарные типы из std::sync::atomic выполняют неделимые операции без мьютекса. Они подходят для счётчиков, флагов и небольших состояний, но не заменяют блокировку для составного инварианта из нескольких значений.

use std::sync::{atomic::{AtomicUsize, Ordering}, Arc};
use std::thread;
 
fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    let workers: Vec<_> = (0..5)
        .map(|_| {
            let counter = Arc::clone(&counter);
            thread::spawn(move || {
                counter.fetch_add(1, Ordering::Relaxed);
            })
        })
        .collect();
 
    for worker in workers {
        worker.join().unwrap();
    }
    println!("{}", counter.load(Ordering::Relaxed));
}

Relaxed гарантирует атомарность конкретной переменной, но не задаёт порядок других обращений к памяти. Для публикации и чтения связанных данных применяют Release/Acquire, а SeqCst задаёт наиболее строгий глобальный порядок. Выбор ordering — часть алгоритма; без доказательства корректности лучше использовать более высокий примитив синхронизации.

Барьеры

Barrier синхронизирует фиксированное число потоков в определённой точке. Каждый вызов wait() блокируется, пока до того же барьера не дойдут все участники; затем они продолжают работу. Барьер можно использовать повторно.

use std::sync::{Arc, Barrier};
use std::thread;
 
fn main() {
    let barrier = Arc::new(Barrier::new(3));
    let workers: Vec<_> = (0..3)
        .map(|id| {
            let barrier = Arc::clone(&barrier);
            thread::spawn(move || {
                println!("{id}: подготовка");
                barrier.wait();
                println!("{id}: общий этап");
            })
        })
        .collect();
 
    for worker in workers {
        worker.join().unwrap();
    }
}

Число участников должно соответствовать реальным вызовам wait: если один поток завершится раньше или забудет дойти до барьера, остальные останутся ждать.

Thread Local Storage

Thread Local Storage хранит отдельный экземпляр значения для каждого системного потока. Макрос thread_local! объявляет ключ, а метод with даёт доступ к значению текущего потока. Синхронизация между потоками не нужна, потому что данные не общие.

use std::cell::Cell;
use std::thread;
 
thread_local! {
    static REQUESTS: Cell<u32> = const { Cell::new(0) };
}
 
fn bump() -> u32 {
    REQUESTS.with(|value| {
        value.set(value.get() + 1);
        value.get()
    })
}
 
fn main() {
    println!("main: {}", bump());
    let worker = thread::spawn(|| {
        println!("worker: {}", bump());
        println!("worker: {}", bump());
    });
    worker.join().unwrap();
    println!("main: {}", bump());
}

TLS подходит для локальных счётчиков и контекста потока, но усложняет тестирование и не переносит данные между потоками. В async-коде задача может мигрировать между worker-потоками, поэтому системный TLS не заменяет task-local context.

Источник главы: https://metanit.com/rust/tutorial/13.1.php

Глава 14. Файловая система

Работа с каталогами

Функции каталога находятся в std::fs, а пути представлены Path и PathBuf. create_dir создаёт один каталог и требует существующего родителя; create_dir_all создаёт всю недостающую цепочку. remove_dir удаляет пустой каталог, remove_dir_all — дерево, поэтому последняя операция требует особой осторожности.

use std::fs;
use std::io;
use std::path::Path;
 
fn main() -> io::Result<()> {
    let path = Path::new("hello/test");
    fs::create_dir_all(path)?;
 
    for entry in fs::read_dir("hello")? {
        let entry = entry?;
        let kind = entry.file_type()?;
        println!(
            "{} — {}",
            entry.file_name().to_string_lossy(),
            if kind.is_dir() { "каталог" } else { "файл" },
        );
    }
    Ok(())
}

read_dir возвращает итератор, и каждый DirEntry тоже может содержать ошибку. Порядок элементов не гарантирован. Для платформенно-независимого построения пути используют Path::join, а не ручную конкатенацию строк с /.

Работа с файлами

File::open открывает файл для чтения, File::create создаёт или обнуляет файл для записи. Дескриптор закрывается автоматически по Drop. Трейты Read, Write и BufRead добавляют операции чтения, записи и буферизации.

use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
 
fn main() -> io::Result<()> {
    let mut file = File::create("hello.txt")?;
    file.write_all(b"first\nsecond\n")?;
    drop(file);
 
    let file = File::open("hello.txt")?;
    for line in BufReader::new(file).lines() {
        println!("{}", line?);
    }
 
    OpenOptions::new()
        .append(true)
        .open("hello.txt")?
        .write_all(b"third\n")?;
 
    println!("размер: {} байт", fs::metadata("hello.txt")?.len());
    fs::rename("hello.txt", "result.txt")?;
    Ok(())
}

Для небольшого текста удобна fs::read_to_string, для бинарных данных — fs::read или Read::read_to_end. BufReader::lines читает текст построчно. fs::rename переименовывает или перемещает файл в пределах возможностей файловой системы, fs::remove_file удаляет его, metadata возвращает размер, тип, временные метки и права. Почти все операции возвращают io::Result, потому что файл может отсутствовать, права могут быть недостаточны, а устройство — дать ошибку.

Источник главы: https://metanit.com/rust/tutorial/14.1.php

Глава 15. Юнит-тестирование

Введение в юнит-тесты

Rust имеет встроенный test harness. Обычная функция становится тестом после атрибута #[test]; тест успешен, если завершился без паники. Проверки выражают макросами assert!, assert_eq! и assert_ne!, которым можно передать собственное сообщение.

fn add(a: i32, b: i32) -> i32 {
    a + b
}
 
#[test]
fn adds_two_numbers() {
    // Arrange
    let left = 2;
    let right = 8;
    // Act
    let result = add(left, right);
    // Assert
    assert_eq!(result, 10, "сложение выполнено неверно");
}

Шаблон Arrange–Act–Assert отделяет подготовку, действие и проверку. Атрибут #[ignore] исключает долгий или временно отключённый тест из обычного запуска; выполнить такие тесты можно отдельно. Неудачная проверка вызывает панику, а Cargo показывает имя теста, ожидаемое и фактическое значения.

Определение и запуск юнит-тестов

Все тесты проекта запускаются командой:

cargo test

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

pub fn is_even(number: i32) -> bool {
    number % 2 == 0
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn detects_even_number() {
        assert!(is_even(4));
        assert!(!is_even(3));
    }
}

Фильтр после cargo test запускает тесты, чьи полные имена содержат строку. Аргументы после -- передаются test harness:

cargo test detects_even
cargo test tests::detects_even_number -- --exact
cargo test -- --ignored
cargo test -- --nocapture

#[cfg(test)] не включает вспомогательный тестовый код в обычную сборку. Общую подготовку окружения обычно выносят в функцию или специальный fixture-тип, чей Drop освобождает ресурс.

Тестирование по условию

Conditional compilation позволяет включать тесты только при заданной возможности Cargo или на определённой платформе. Feature сначала объявляют в Cargo.toml:

[features]
filesystem-tests = []

Затем связывают с модулем или тестом через cfg:

#[cfg(all(test, feature = "filesystem-tests", unix))]
mod filesystem_tests {
    #[test]
    fn unix_path_is_absolute() {
        assert!(std::path::Path::new("/tmp").is_absolute());
    }
}

Запуск с включённой возможностью:

cargo test --features filesystem-tests

cfg(test) означает тестовую сборку, feature = "..." — выбранную Cargo feature, а unix, target_os = "linux" или target_os = "macos" — свойства target-платформы. Feature не следует использовать как секретный runtime-переключатель: она меняет состав программы на этапе компиляции.

Тестирование ошибок

Для функции, возвращающей Result, нужно проверять и успешный, и ошибочный сценарий. Методы is_ok/is_err проверяют вариант, а pattern matching или unwrap_err позволяют проверить содержимое ошибки.

fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        Err(format!("cannot divide {dividend} by zero"))
    } else {
        Ok(dividend / divisor)
    }
}
 
#[test]
fn divides_numbers() {
    assert_eq!(divide(10, 2), Ok(5));
}
 
#[test]
fn reports_division_by_zero() {
    let error = divide(10, 0).unwrap_err();
    assert_eq!(error, "cannot divide 10 by zero");
}

Если ожидается именно паника, применяют #[should_panic], при необходимости с фрагментом сообщения:

#[test]
#[should_panic(expected = "index out of bounds")]
fn invalid_index_panics() {
    let values = [1, 2, 3];
    let index = std::hint::black_box(10);
    let _ = values[index];
}

Для проверяемых бизнес-ошибок предпочтительнее Result: он позволяет сравнить типизированную причину без зависимости от текста паники.

Источник главы: https://metanit.com/rust/tutorial/15.1.php

Глава 16. Макросы

Процедурные макросы

Процедурный макрос получает поток токенов Rust во время компиляции и возвращает другой поток токенов, который становится частью программы. Такой макрос обязан находиться в отдельном библиотечном крейте с типом proc-macro.

Есть три формы:

  • function-like macro вызывается как name!(...) и помечается #[proc_macro];
  • attribute macro применяется как #[name(...)] и помечается #[proc_macro_attribute];
  • derive macro добавляет реализацию через #[derive(Name)] и объявляется #[proc_macro_derive(Name)].

Минимальный крейт функционального макроса:

[package]
name = "macroapp"
version = "0.1.0"
edition = "2024"
 
[lib]
proc-macro = true
use proc_macro::TokenStream;
 
#[proc_macro]
pub fn empty(_input: TokenStream) -> TokenStream {
    TokenStream::new()
}

Потребитель подключает крейт как зависимость и вызывает empty!(любые токены);. На практике токены обычно разбирают крейтом syn, генерируют через quote, а имена и пути формируют аккуратно из-за гигиены макросов. Ошибку входа следует превращать в compile_error! с понятным span, а не паниковать внутри компилятора.

Источник главы: https://metanit.com/rust/tutorial/16.1.php

Глава 17. FFI. Взаимодействие с нативным кодом на C/C++ и ассемблере

Подключение библиотек C/C++ в код на Rust

FFI связывает Rust с кодом, использующим совместимый ABI. Для C-функции объявления обеих сторон должны совпадать по именам, типам, соглашению вызова и владению памятью. Компилятор обычно не способен проверить этот контракт, поэтому вызов внешней функции является unsafe.

Файл add.c:

int add(int x, int y) {
    return x + y;
}

Сборка статической библиотеки на Linux/macOS:

cc -c add.c -o add.o
ar rcs libadd.a add.o
cargo new ffi_app
cd ffi_app
mkdir -p native
mv ../libadd.a native/

build.rs сообщает Cargo, где искать библиотеку и как её линковать:

fn main() {
    println!("cargo:rustc-link-search=native=native");
    println!("cargo:rustc-link-lib=static=add");
    println!("cargo:rerun-if-changed=native/libadd.a");
}

src/main.rs:

use std::ffi::c_int;
 
unsafe extern "C" {
    fn add(x: c_int, y: c_int) -> c_int;
}
 
fn main() {
    let result = unsafe { add(16, 6) };
    println!("{result}");
}

Имя в rustc-link-lib указывается без префикса lib и расширения. Динамическую библиотеку нужно дополнительно сделать доступной загрузчику на целевой машине. C++-функции обычно экспортируют через extern "C", чтобы отключить C++ name mangling, либо используют отдельную C-совместимую обёртку.

Совместная компиляция кода Rust и C/C++

Исходник C можно собирать вместе с Rust-проектом через build.rs. Скрипт выполняется Cargo до компиляции крейта, получает каталог OUT_DIR, создаёт там нативную библиотеку и печатает инструкции линковщику.

Структура проекта:

ffi_app/
├── Cargo.toml
├── build.rs
└── src/
    ├── hello.c
    └── main.rs

src/hello.c:

#include <stdio.h>
 
void hello(void) {
    puts("Hello from C");
}

Надёжнее использовать кроссплатформенный build dependency cc, который сам выбирает подходящий компилятор и передаёт Cargo параметры линковки:

[build-dependencies]
cc = "1"
fn main() {
    println!("cargo:rerun-if-changed=src/hello.c");
    cc::Build::new().file("src/hello.c").compile("hello");
}
unsafe extern "C" {
    fn hello();
}
 
fn main() {
    unsafe { hello() };
}
cargo run

Можно запускать cc и ar вручную через std::process::Command, как в исходном примере, но тогда нужно самому проверять exit status, корректно обрабатывать пути и различия toolchain. Крейt cc уменьшает эту платформенную логику.

Структуры в Rust и C/C++

Rust не гарантирует C-совместимое расположение полей по умолчанию. Атрибут #[repr(C)] задаёт совместимый layout, но типы и их семантика всё равно должны совпадать. Для C-чисел используют типы из std::ffi, для строк — CString при передаче в C и CStr при чтении нуль-терминированной строки.

C-сторона:

#include <stdint.h>
#include <stdio.h>
 
typedef struct {
    uint64_t age;
    const char *name;
} Person;
 
void print_person(const Person *person) {
    if (person && person->name) {
        printf("Name: %s, age: %llu\n",
               person->name,
               (unsigned long long)person->age);
    }
}

Rust-сторона:

use std::ffi::{c_char, CString};
 
#[repr(C)]
struct Person {
    age: u64,
    name: *const c_char,
}
 
unsafe extern "C" {
    fn print_person(person: *const Person);
}
 
fn main() {
    let name = CString::new("Tom").expect("строка не должна содержать NUL");
    let person = Person { age: 40, name: name.as_ptr() };
    unsafe { print_person(&person) };
}

CString должна жить до завершения C-вызова: as_ptr() не передаёт владение буфером. Указатель из C проверяют на null до разыменования. Если C выделяет память, освобождать её должна парная функция той же библиотеки; безопасная Rust-обёртка хранит сырой указатель и вызывает эту функцию в Drop. Нельзя освобождать C-буфер через Box::from_raw, если он не был создан совместимым Rust allocator и контракт явно этого не разрешает.

FFI-граница не должна пропускать Rust-панику в C или C++ и C++-исключение в Rust. Снаружи лучше экспортировать минимальный C ABI с простыми типами, кодами ошибок и ясно описанными правилами владения.

Источник главы: https://metanit.com/rust/tutorial/17.1.php