Rust Основы Rust: типы, переменные, управление потоком, функции
0%

Основы Rust: типы, переменные, управление потоком, функции

Основы Rust: типы, переменные, управление потоком, функции

В предыдущей статье мы поставили rustup, собрали первый крейт и научились запускать cargo run. Теперь нужно понять, из чего вообще состоит программа на Rust.

Соблазн велик: пробежаться по синтаксису — «вот let, вот if, вот fn» — и объявить основы усвоенными. Так делать не стоит, и вот почему. Синтаксис Rust на 80% узнаваем: фигурные скобки, if, for, return. А поведение — нет. Программист, пришедший из C, споткнётся о неизменяемость по умолчанию. Пришедший из Python — о то, что if 0 не компилируется. Пришедший из JavaScript — о то, что "привет"[0] вообще не существует как операция. Каждое из этих отличий — не каприз, а следствие одного и того же решения: компилятор должен уметь доказать безопасность программы, не запуская её и не приставляя к ней сборщик мусора.

Поэтому разбирать основы мы будем через две сквозные модели.

  1. Модель размещения: у каждого значения есть тип, у типа известен размер, у значения — конкретное место. Отсюда всё, что связано с типами, строками и коллекциями.
  2. Модель выражений: почти всё в Rust вычисляется в значение, включая if, match и блок { ... }. Отсюда весь синтаксис управления потоком и большая часть ошибок новичка.

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

Программа целиком: с чего всё начинается

// src/main.rs — точка входа исполняемого крейта
fn main() {
    let name = "мир";
    println!("Привет, {name}!"); // подстановка имени прямо в строку формата
}

Три наблюдения, каждое из которых мы развернём.

  • fn main() не объявляет тип возврата — значит, возвращает (), «единичный тип», который несёт ноль информации и занимает ноль байт. Это не void из C: () — полноценный тип, его можно положить в переменную и передать в дженерик.
  • let name = "мир"; — тип не написан, но он есть и жёстко зафиксирован: &'static str. Вывод типов в Rust локальный и полный: компилятор выведет тип, но никогда не «подберёт удобный» на ходу.
  • println! — не функция, а макрос (об этом кричит !). Он разбирает строку формата на этапе компиляции: опечатка в имени переменной внутри {} — ошибка сборки, а не пустая строка в логе в три часа ночи.

main может возвращать и Result, что превращает ? в рабочий инструмент прямо в точке входа — подробности в статье про обработку ошибок.

Модель первая: у всего есть размер, и он известен заранее

В языке со сборщиком мусора вопрос «сколько байт занимает переменная» обычно не имеет смысла для программиста: почти всё — ссылка на объект в куче. В Rust этот вопрос центральный, потому что от ответа зависит, где значение лежит и кто его освободит.

Правило простое: на стеке (в кадре функции) лежит только то, чей размер компилятор знает на этапе компиляции. Всё, что растёт во время исполнения, живёт за указателем — в куче.

Раскладка значений: что лежит в переменной, а что за указателем

Проверить размеры можно самому — это отличная привычка:

use std::mem::size_of;

fn main() {
    println!("{}", size_of::<i32>());          // 4
    println!("{}", size_of::<char>());         // 4  — код символа Unicode, не байт
    println!("{}", size_of::<bool>());         // 1
    println!("{}", size_of::<()>());           // 0  — единичный тип бесплатен
    println!("{}", size_of::<&str>());         // 16 — указатель + длина
    println!("{}", size_of::<String>());       // 24 — указатель + длина + ёмкость
    println!("{}", size_of::<Vec<i32>>());     // 24 — то же самое устройство
    println!("{}", size_of::<Option<i32>>());  // 8  — 4 байта данных + тег + выравнивание
    println!("{}", size_of::<Option<&str>>()); // 16 — тега нет: None прячется в нулевой указатель
}

Последние две строки стоят отдельного разговора. Option<i32> вынужден хранить тег «есть значение / нет значения» — отсюда рост с 4 до 8 байт. А Option<&str> не растёт вообще: ссылка в Rust никогда не бывает нулевой, значит нулевой указатель — свободная комбинация битов, «ниша», куда и записывается None. Это называется niche-оптимизацией и означает, что абстракция «может отсутствовать» здесь стоит ровно ноль байт и ноль инструкций.

Если тема раскладки памяти вам нова, полезно параллельно читать память и указатели и управление памятью в ОС: Rust не изобретает новую машину, он лишь строго описывает старую.

Переменные: неизменяемость по умолчанию

fn main() {
    let x = 5;
    println!("x = {x}");
    x = 6; // ошибка компиляции
}
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("x = {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

Заметьте: компилятор не просто отказал — он показал место первого присваивания, место конфликта и готовую правку с точным указанием, куда вставить mut. К такому уровню диалога придётся привыкать, и это лучшее, что есть в языке.

Почему неизменяемость выбрана значением по умолчанию — три причины, по возрастанию важности.

  1. Чтение кода. Увидев let без mut, вы знаете: дальше по функции это значение не поменяется. Не «наверное», а гарантированно.
  2. Оптимизация. Компилятор и LLVM свободнее переставляют и кэшируют то, что не меняется.
  3. Фундамент для проверки заимствований. Правило «либо одна изменяемая ссылка, либо сколько угодно неизменяемых» имеет смысл только там, где изменяемость — явно помеченное, редкое свойство. Об этом — в следующей статье.

mut, затенение, const, static — четыре разные вещи

fn main() {
    // 1. mut: та же переменная, то же место в памяти, тот же тип
    let mut counter = 0;
    counter += 1;

    // 2. Затенение (shadowing): НОВАЯ переменная с тем же именем.
    //    Тип менять можно — это законный приём, а не хак.
    let input = "  42  ";           // &str
    let input = input.trim();       // &str, но уже без пробелов
    let input: u32 = input.parse().unwrap(); // теперь u32
    println!("{}", input + counter); // 43

    // 3. Затенение живёт в своей области видимости
    let scoped = 10;
    {
        let scoped = scoped * 2;
        println!("внутри: {scoped}"); // 20
    }
    println!("снаружи: {scoped}");    // 10 — внешняя привязка не тронута

    // 4. Отложенная инициализация: компилятор доказывает,
    //    что к моменту чтения значение точно присвоено
    let verdict;
    if counter > 0 {
        verdict = "были события";
    } else {
        verdict = "тишина";
    }
    println!("{verdict}");
}
const MAX_SENSORS: usize = 64;              // подставляется в каждое место использования
static BUILD_TAG: &str = "2026-07-16";      // одно место в памяти на всю программу

// const-функцию можно вызывать в контексте константы
const fn buffer_size(items: usize) -> usize {
    items * 32
}
const BUFFER: usize = buffer_size(MAX_SENSORS); // вычислено на этапе компиляции

Разница между const и static тонкая, но важная: у const нет адреса — это как макрос подстановки значения; у static адрес есть, и он один. Практическое следствие: ссылку на static брать осмысленно, на const — это ссылка на временную копию. Изменяемый static mut требует unsafe и в редакции 2024 дополнительно ругается отдельным линтом — правильный ответ почти всегда OnceLock, Mutex или atomic (см. конкурентность).

Скалярные типы и арифметика без сюрпризов

Целые: i8i128 и u8u128, плюс usize/isize размером с указатель. По умолчанию вывод типов даёт i32 для целых и f64 для дробных — не потому что «так исторически», а потому что это разумный компромисс между диапазоном и скоростью на современных процессорах.

usize — не «просто большое число». Это тип длин и индексов: Vec::len(), индексация среза, String::len() возвращают именно его. Отсюда самая частая арифметическая ловушка в Rust:

fn main() {
    let items: Vec<i32> = Vec::new();
    // Классика: обход "до предпоследнего элемента"
    for i in 0..items.len() - 1 {   // len() == 0, а 0usize - 1 не существует
        println!("{}", items[i]);
    }
}
thread 'main' panicked at src/main.rs:4:17:
attempt to subtract with overflow

В debug-сборке это честная паника с точным указанием строки. В release — по умолчанию overflow-checks = false, и 0usize - 1 тихо превратится в 18 446 744 073 709 551 615, после чего программа упадёт уже на индексации. Заметьте: это не undefined behavior, в отличие от C (про UB — отдельная статья соседнего трека). Заворачивание по модулю определено спецификацией. Просто оно почти никогда не то, что вы имели в виду.

fn main() {
    let a: u8 = 250;
    let b: u8 = 10;

    println!("{:?}", a.checked_add(b));     // None      — переполнение обнаружено
    println!("{}",   a.wrapping_add(b));    // 4         — 260 по модулю 256
    println!("{}",   a.saturating_add(b));  // 255       — прижали к границе
    println!("{:?}", a.overflowing_add(b)); // (4, true) — значение и признак
}

Практическое правило прода: в коде, где переполнение — реальный сценарий (счётчики, размеры, разбор внешних данных), пишите явный метод. Ещё лучше — включить overflow-checks = true и в release-профиле: цена обычно единицы процентов, а класс ошибок исчезает. Настраивается в Cargo.toml:

[profile.release]
overflow-checks = true

Преобразования типов: as режет молча

В Rust нет неявных числовых преобразований — вообще. u8 не станет u32 сам по себе, и это ловит целый класс ошибок:

fn main() {
    let len: usize = 10;
    let offset: i32 = 3;
    let total = len + offset; // не соберётся
}
error[E0308]: mismatched types
 --> src/main.rs:4:23
  |
4 |     let total = len + offset;
  |                       ^^^^^^ expected `usize`, found `i32`
  |
help: you can convert an `i32` to a `usize` and panic if the converted value doesn't fit
  |
4 |     let total = len + offset.try_into().unwrap();
  |                             ++++++++++++++++++++

Явное преобразование делает оператор as — но он обрезает без предупреждения, и это единственное место в базовом Rust, где язык позволяет молча потерять данные:

fn main() {
    let big: i64 = 300;
    println!("{}", big as u8);          // 44 — 300 по модулю 256

    let huge: f64 = 1e10;
    println!("{}", huge as i32);        // 2147483647 — насыщение, не UB (с Rust 1.45)

    // Безопасный путь: From для расширяющих, TryFrom для сужающих
    let widened: i64 = i64::from(42u8);            // всегда корректно
    let narrowed: Result<u8, _> = u8::try_from(300i64);
    println!("{:?}", narrowed);         // Err(TryFromIntError(()))
}

Правило: From/Into — когда преобразование не может потерять данные. TryFrom/TryInto — когда может. as — только для чисел, где вы сознательно хотите обрезание, и для приведения между usize и указателями. Clippy подсветит подозрительные as линтами cast_possible_truncation и cast_sign_loss.

Плавающая точка и bool

f32/f64 — обычный IEEE 754 со всеми знакомыми свойствами: 0.1 + 0.2 != 0.3, NaN != NaN. Специфика Rust в том, что система типов честно об этом сообщает: f64 реализует PartialOrd, но не Ord, поэтому vec.sort() для Vec<f64> не скомпилируется. Правильный ответ:

fn main() {
    let mut temps = vec![36.6, f64::NAN, -3.5, 12.0];
    temps.sort_by(|a, b| a.total_cmp(b)); // тотальный порядок, NaN получает своё место
    println!("{temps:?}");                // [-3.5, 12.0, 36.6, NaN]
}

bool — ровно true и false, никакой «истинности» чисел и строк. if count при count: i32 — ошибка типов, а не «ноль значит ложь». Это скучно ровно до первого багa вида if (str = "") в другом языке.

Строки: s[0] не существует, и это правильно

String владеет буфером в куче и умеет расти. &str — окно в чужие байты: в литерал из .rodata, в String, в буфер из сети. Оба обязаны содержать корректный UTF-8: это инвариант типа, а не пожелание.

fn main() {
    let s = String::from("Привет!");
    println!("{}", s.len());           // 13 — БАЙТ
    println!("{}", s.chars().count()); // 7  — символов, проход O(n)

    let first = &s[0]; // не соберётся
}
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:6:19
  |
6 |     let first = &s[0];
  |                   ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book:
          <https://doc.rust-lang.org/book/ch08-02-strings.html>
  = note: required for `String` to implement `Index<{integer}>`

Причина отказа — честность. s[0] должен вернуть либо байт (тогда это не символ), либо символ (тогда операция не O(1), потому что символы переменной длины). Rust отказывается делать вид, что выбор очевиден, и заставляет вас его сделать.

Строка в байтах и символах: где проходят границы среза

fn main() {
    let s = "Привет!";

    for (byte_index, ch) in s.char_indices().take(3) {
        println!("{byte_index}: {ch}"); // 0: П   2: р   4: и
    }

    println!("{}", &s[0..4]);            // "Пр" — обе границы между символами
    println!("{:?}", s.chars().nth(2));  // Some('и') — O(n), не индексация
    println!("{}", s.to_uppercase());    // ПРИВЕТ!
    println!("{}", s.as_bytes()[3]);     // 128 — байты индексируются свободно

    // println!("{}", &s[0..3]);         // паника: индекс 3 внутри символа 'р'
}

Важный нюанс: даже chars() не даёт «символы, как их видит человек». Флаг-эмодзи или буква с диакритикой — несколько char подряд. За графемами идите в крейт unicode-segmentation.

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

fn greet(name: &str) -> String {  // принимает и литерал, и &String, и срез
    format!("Привет, {name}!")
}

Составные типы: моделируем задачу, а не «складываем поля»

// Кортеж: быстрая склейка разнородных значений, деструктуризация в let
let (width, height, label) = (1920u32, 1080u32, "FullHD");
let ratio = width as f64 / height as f64;

// Массив: длина — часть типа, живёт на стеке
let sensors: [u16; 4] = [101, 102, 103, 104];
let zeroed = [0u8; 1024];      // тысяча нулей, без цикла
println!("{}", sensors.len()); // 4 — известно на этапе компиляции

// Срез: окно в массив, длину знает только во время исполнения
fn average(values: &[u16]) -> f64 {
    if values.is_empty() {
        return 0.0;
    }
    values.iter().map(|&v| v as f64).sum::<f64>() / values.len() as f64
}
println!("{}", average(&sensors[1..3])); // 102.5

Обратите внимание на average(&[u16]): функция принимает срез, а не массив и не Vec. Одна сигнатура — и она работает с массивом на стеке, с Vec в куче, с частью любого из них. Это идиома, которую стоит усвоить сразу.

Структуры и перечисления

#[derive(Debug, Clone, PartialEq)]
struct Sensor {
    id: u32,
    name: String,
    calibrated: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct Celsius(f64); // кортежная структура: новый ТИП поверх f64, а не псевдоним

struct Marker;       // единичная структура: ноль байт, нужна только как метка типа

impl Celsius {
    // Ассоциированная функция: нет self, вызывается через тип. Обычно конструктор.
    fn from_fahrenheit(f: f64) -> Self {
        Celsius((f - 32.0) * 5.0 / 9.0)
    }

    // Метод: &self — читаем, не забирая владение
    fn is_freezing(&self) -> bool {
        self.0 <= 0.0
    }
}

fn main() {
    let base = Sensor { id: 1, name: String::from("вход"), calibrated: false };
    // Синтаксис обновления структуры: остальные поля берём из base.
    // ВНИМАНИЕ: String из base ПЕРЕМЕЩАЕТСЯ, base дальше частично недоступна.
    let calibrated = Sensor { calibrated: true, ..base.clone() };

    let t = Celsius::from_fahrenheit(20.0);
    println!("{:?} мороз? {}", t, t.is_freezing()); // Celsius(-6.666...) мороз? true
    println!("{}", calibrated.name);
}

Celsius(f64) — не украшение. Это newtype: функция fn set_target(t: Celsius) физически не примет ни Фаренгейт, ни «просто число». Ошибка, стоившая NASA аппарата Mars Climate Orbiter, в такой системе типов не компилируется.

Перечисления в Rust — это алгебраические типы: вариант несёт данные, а не просто номер.

#[derive(Debug)]
enum Reading {
    Ok { celsius: f64 },   // вариант с именованными полями
    OutOfRange(f64),       // вариант с данными
    Disconnected,          // вариант без данных
}

fn describe(r: &Reading) -> String {
    match r {
        Reading::Ok { celsius } if *celsius < 0.0 => format!("мороз: {celsius:.1} °C"),
        Reading::Ok { celsius }                   => format!("норма: {celsius:.1} °C"),
        Reading::OutOfRange(v)                    => format!("вне диапазона: {v}"),
        Reading::Disconnected                     => "датчик недоступен".to_string(),
    }
}

Добавьте в Reading вариант Calibrating — и код перестанет собираться:

error[E0004]: non-exhaustive patterns: `Reading::Calibrating` not covered
  --> src/main.rs:9:11
   |
9  |     match r {
   |           ^ pattern `Reading::Calibrating` not covered
   |
note: `Reading` defined here
  --> src/main.rs:5:5
   |
5  |     Calibrating,
   |     ----------- not covered
   = note: the matched value is of type `&Reading`
help: ensure the match is exhaustive by adding a match arm with a wildcard pattern
      or an explicit pattern as shown

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

Option вместо null

Ноль-указателя в безопасном Rust не существует. «Значения может не быть» выражается типом:

fn find_sensor(all: &[Sensor], id: u32) -> Option<&Sensor> {
    for s in all {
        if s.id == id {
            return Some(s);
        }
    }
    None
}

fn main() {
    let all = vec![Sensor { id: 1, name: "вход".into(), calibrated: true }];

    match find_sensor(&all, 7) {
        Some(s) => println!("нашли {}", s.name),
        None    => println!("датчика нет"),
    }

    // Идиоматичные короткие формы вместо ручного match:
    let name = find_sensor(&all, 1).map(|s| s.name.as_str()).unwrap_or("неизвестен");
    println!("{name}"); // вход
}

Компилятор не даст обратиться к содержимому, не разобрав вариант None. Это ровно та «ошибка на миллиард долларов», в которой публично покаялся Тони Хоар, придумавший null-ссылки в 1965 году. Цена решения — Option<T> для ссылок и Box не занимает ни одного лишнего байта благодаря niche-оптимизации.

Модель вторая: почти всё — выражение

В Rust блок { ... } вычисляется в значение. Значение блока — это его последнее выражение без точки с запятой. Поставили точку с запятой — получили инструкцию, и блок вычисляется в ().

fn main() {
    // if — выражение, а не инструкция
    let score = 82;
    let grade = if score >= 90 {
        "отлично"
    } else if score >= 75 {
        "хорошо"
    } else {
        "нужно доработать"
    };
    println!("{grade}"); // хорошо

    // Обычный блок тоже возвращает значение
    let normalized = {
        let raw = score as f64;
        raw / 100.0        // хвостовое выражение — без точки с запятой
    };
    println!("{normalized}"); // 0.82
}

Две ошибки, которые ловят на этом абсолютно всех:

let flag = true;
let x = if flag { 5 } else { "пять" }; // ветви разного типа
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:3:34
  |
3 |     let x = if flag { 5 } else { "пять" };
  |                       -          ^^^^^^ expected integer, found `&str`
  |                       |
  |                       expected because of this
let count = 3;
if count { println!("есть"); } // условие обязано быть bool
error[E0308]: mismatched types
 --> src/main.rs:3:8
  |
3 |     if count { println!("есть"); }
  |        ^^^^^ expected `bool`, found integer

Циклы: три вида, и только один возвращает значение

fn main() {
    // loop — бесконечный, но умеет отдать значение через break
    let mut n = 1;
    let first_over_1000 = loop {
        n *= 3;
        if n > 1000 {
            break n; // значение выходит наружу
        }
    };
    println!("{first_over_1000}"); // 2187

    // while — пока условие истинно
    let mut countdown = 3;
    while countdown > 0 {
        print!("{countdown} ");
        countdown -= 1;
    }
    println!("пуск!"); // 3 2 1 пуск!

    // for — по любому итератору; диапазоны и коллекции
    for i in (0..10).step_by(3) { print!("{i} "); }   // 0 3 6 9
    println!();
    for i in (1..=3).rev()      { print!("{i} "); }   // 3 2 1
    println!();

    // while let — разбираем, пока получается
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        print!("{top} ");                              // 3 2 1
    }
    println!();

    // Метки: выходим сразу из нескольких уровней
    let grid = [[1, 2, 3], [4, 5, 0], [7, 8, 9]];
    let mut zero_at = None;
    'outer: for (r, row) in grid.iter().enumerate() {
        for (c, &value) in row.iter().enumerate() {
            if value == 0 {
                zero_at = Some((r, c));
                break 'outer;
            }
        }
    }
    println!("{zero_at:?}"); // Some((1, 2))
}

Индексного цикла for i in 0..v.len() в идиоматичном Rust почти не встретишь: он и медленнее (каждая индексация — проверка границ), и опаснее (len() - 1 на пустой коллекции). Итераторы разбираются в статье про коллекции и итераторы, там же — почему они не стоят ничего по сравнению с ручным циклом.

match: не switch, а разбор структуры значения

fn classify(status: u16) -> &'static str {
    match status {
        100..=199              => "информационный",
        200 | 204              => "успех",
        code @ 300..=399 if code != 304 => "перенаправление", // связывание + охрана
        304                    => "не изменялось",
        400..=499              => "ошибка клиента",
        500..=599              => "ошибка сервера",
        _                      => "нестандартный код",
    }
}

fn main() {
    // Деструктуризация кортежей и срезов прямо в шаблоне
    let point = (0, -3);
    let where_is = match point {
        (0, 0)             => "начало координат".to_string(),
        (x, 0)             => format!("на оси X, x = {x}"),
        (0, y)             => format!("на оси Y, y = {y}"),
        (x, y) if x == y   => "на диагонали".to_string(),
        _                  => "где-то ещё".to_string(),
    };
    println!("{where_is}"); // на оси Y, y = -3

    let data = [1, 2, 3, 4];
    match data.as_slice() {
        []                  => println!("пусто"),
        [only]              => println!("один элемент: {only}"),
        [first, .., last]   => println!("от {first} до {last}"), // от 1 до 4
    }
}

Ветки проверяются сверху вниз, и match обязан быть исчерпывающим. Ветка 304 после 300..=399 не считается недостижимой именно потому, что у предыдущей есть охрана if code != 304, — компилятор это отслеживает.

Когда интересен ровно один вариант, полноценный match избыточен:

fn parse_port(raw: &str) -> u16 {
    // let ... else: либо разобрали, либо ветка обязана разойтись
    // (return, break, continue, panic!) — компилятор это проверяет
    let Ok(port) = raw.trim().parse::<u16>() else {
        eprintln!("некорректный порт {raw:?}, беру 8080");
        return 8080;
    };
    port
}

fn main() {
    let config: Option<&str> = Some("9090");
    if let Some(raw) = config {
        println!("{}", parse_port(raw)); // 9090
    }
    println!("{}", parse_port("не число")); // 8080 + сообщение в stderr
}

let ... else (стабилен с Rust 1.65) убирает «лестницу» вложенности: счастливый путь остаётся на нулевом уровне отступа, а все отказы уходят вверх и вбок.

Функции: контракт, записанный в сигнатуре

/// Площадь прямоугольника. Типы параметров и возврата — обязательны:
/// сигнатура функции никогда не выводится, только тела и локальные переменные.
fn area(width: u32, height: u32) -> u32 {
    width * height // хвостовое выражение — это и есть возврат
}

/// Расходящаяся функция: никогда не возвращает управление.
/// Тип `!` позволяет использовать её в любой ветке любого выражения.
fn fatal(msg: &str) -> ! {
    eprintln!("критическая ошибка: {msg}");
    std::process::exit(1);
}

fn main() {
    println!("{}", area(3, 4)); // 12

    let mode = "boom";
    let level = match mode {
        "quiet" => 0,
        "loud"  => 9,
        _       => fatal("неизвестный режим"), // ветка типа ! подходит под i32
    };
    println!("{level}");
}

Самая частая ошибка первой недели — лишняя точка с запятой:

fn plus_one(x: i32) -> i32 {
    x + 1;
}
error[E0308]: mismatched types
 --> src/main.rs:1:24
  |
1 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail expression
2 |     x + 1;
  |          - help: remove this semicolon to return this value

Чего в Rust нет и что с этим делать:

Нет Почему Идиоматичная замена
Перегрузки функций Вывод типов должен оставаться однозначным Разные имена, либо один обобщённый параметр с трейт-границей
Аргументов по умолчанию Та же причина плюс явность вызова Option<T> в параметре, либо билдер, либо Default для структуры настроек
Именованных аргументов Структура-параметр: fn connect(cfg: ConnectConfig)
Исключений Управление потоком должно быть видно в сигнатуре Result<T, E> и оператор ?

Обобщённые функции (fn largest<T: PartialOrd>(items: &[T]) -> &T) и замыкания мы только упомянем — им посвящены статьи про типы и трейты и итераторы.

Про передачу параметров сейчас достаточно знать одно правило-заготовку: если функции нужно только посмотреть — принимайте ссылку (&T, &str, &[T]); если нужно забрать себе — принимайте значение. Почему это не стилистика, а вопрос компилируемости — разберём в следующей статье.

Как читать компилятор

Rust — язык, где чтение диагностики является отдельным навыком, и осваивать его надо сразу. Хорошая новость: сообщения rustc — вероятно, лучшие в индустрии, и устроены они по жёсткой схеме, одинаковой для всех кодов ошибок:

Часть сообщения Что отвечает Пример из E0308 выше
error[EXXXX]: <текст> Класс ошибки. Код устойчив между версиями error[E0308]: mismatched types
--> файл:строка:столбец Где. Кликабельно в терминале и IDE --> src/main.rs:1:24
Первичный спан ^^^ Что именно отвергнуто и какой тип ожидался expected i32, found ()
Вторичный спан --- Почему ожидался именно этот тип. Часто в другой строке implicitly returns () as its body has no tail expression
help: Готовая правка, нередко машинно-применимая remove this semicolon to return this value
note: Контекст: какое правило или трейт-требование нарушено required for String to implement Index<{integer}>

Практический минимум:

  • cargo check — на каждом сохранении. Он делает разбор, вывод типов и проверку заимствований, но не запускает кодогенерацию LLVM. Это в разы быстрее cargo build.
  • Чините первую ошибку и пересобирайте. В Rust каскад из двадцати сообщений почти всегда порождён одним несовпадением типов.
  • rustc --explain E0308 или Rust Error Index — когда класс ошибки непонятен, а не когда непонятна конкретная строка.
  • cargo clippy --all-targets -- -D warnings в CI. Clippy знает больше 700 линтов и учит идиоматике эффективнее любой книги.
  • cargo fix --allow-dirty применяет машинно-исполнимые подсказки автоматически.
  • Предупреждения не игнорируйте. warning: unused variable и warning: unused Result that must be used — это почти всегда настоящие баги, а не косметика.

Всё вместе: маленький, но полный пример

/// Калькулятор в обратной польской записи.
/// Использует всё из этой главы: перечисление, match, Option, срезы,
/// циклы, функции, обработку невозможных состояний через типы.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Token {
    Number(f64),
    Op(char),
}

fn tokenize(input: &str) -> Vec<Token> {
    input
        .split_whitespace()
        .map(|piece| match piece.parse::<f64>() {
            Ok(n)  => Token::Number(n),
            Err(_) => Token::Op(piece.chars().next().unwrap_or('?')),
        })
        .collect()
}

fn eval(tokens: &[Token]) -> Option<f64> {
    let mut stack: Vec<f64> = Vec::new();

    // `for &token` разыменовывает ссылку сразу: Token реализует Copy
    for &token in tokens {
        match token {
            Token::Number(n) => stack.push(n),
            Token::Op(op) => {
                let rhs = stack.pop()?; // стек пуст — вся функция вернёт None
                let lhs = stack.pop()?;
                let value = match op {
                    '+' => lhs + rhs,
                    '-' => lhs - rhs,
                    '*' => lhs * rhs,
                    '/' if rhs != 0.0 => lhs / rhs,
                    _ => return None,   // деление на ноль или неизвестная операция
                };
                stack.push(value);
            }
        }
    }

    // Корректное выражение оставляет ровно одно значение — проверяем шаблоном
    match stack.as_slice() {
        [result] => Some(*result),
        _        => None,
    }
}

fn main() {
    for expr in ["3 4 + 2 *", "10 2 /", "1 +", "5 0 /"] {
        match eval(&tokenize(expr)) {
            Some(value) => println!("{expr:>10} = {value}"),
            None        => println!("{expr:>10} = ошибка выражения"),
        }
    }
}
 3 4 + 2 * = 14
    10 2 / = 5
       1 + = ошибка выражения
     5 0 / = ошибка выражения

Сорок строк, и ни одной проверки на null, ни одного try/catch, ни одной возможности прочитать освобождённую память. При этом бинарник — машинный код без рантайма и сборщика мусора.

Типичные грабли именно этого языка

  1. Лишняя точка с запятой в конце функцииexpected i32, found (). Самая частая ошибка первой недели.
  2. len() - 1 на пустой коллекции. usize не бывает отрицательным: паника в debug, гигантское число в release.
  3. Забытый mut — и наоборот, mut там, где он не нужен: получите warning: variable does not need to be mutable.
  4. &String в сигнатуре вместо &str, &Vec<T> вместо &[T]. Сужает круг вызывающих без всякой выгоды.
  5. .clone() как средство успокоить компилятор. На этом этапе это ещё простительно, но каждый clone — реальное копирование. После следующей статьи большая их часть станет ненужной.
  6. unwrap() в продовом коде. В примерах и тестах — нормально, в сервисе — источник паники в три часа ночи. Разбор в статье про обработку ошибок.
  7. Срез строки по произвольному индексу. &s[0..3] на кириллице — паника, а не «обрежется как получится».
  8. as вместо try_into() при сужении типа: молчаливое обрезание данных.
  9. Целочисленное деление там, где ожидали дробное: 7 / 2 == 3, а -7 % 2 == -1 (остаток берёт знак делимого; нужен неотрицательный — rem_euclid).
  10. Сравнение f64 на равенство и попытка sort() для Vec<f64>: нужен total_cmp либо допуск.
  11. Затенение по невнимательности — второй let x в той же области выглядит как присваивание, но создаёт новую переменную, и первая остаётся нетронутой.
  12. if let без else молча проглатывает второй вариант. Иногда это то, что нужно, но чаще — забытая ветка.
  13. Ожидание перегрузки функций и аргументов по умолчанию. Их нет; ищите билдер или структуру настроек.
  14. Попытка написать связный список или граф «как в C». На этом этапе — просто не надо: возьмите Vec с индексами вместо указателей. Почему так, станет ясно после статей про владение и времена жизни.
  15. Борьба с компилятором вместо чтения его сообщения. Rust почти всегда пишет, что именно сделать; медленное чтение диагностики экономит часы.

Честно про цену

Где Rust выигрывает уже на уровне основ. Ошибки, которые в C и C++ становятся уязвимостями, здесь просто не компилируются: по данным MSRC и команды безопасности Chromium, около 70% критичных CVE в больших C/C++-кодовых базах связаны с безопасностью памяти. Исчерпывающий match превращает эволюцию модели данных в механическую задачу. Отсутствие рантайма и GC даёт предсказуемую латентность — важно для производительности хвостовых перцентилей и для встраиваемых систем. Нулевая стоимость абстракций реальна: Option<&T> не занимает лишних байт, итератор компилируется в тот же цикл, что и ручной.

Чем вы за это платите. Первое — время сборки. cargo check на маленьком крейте — секунды, но полная release-сборка сервиса с сотней зависимостей легко занимает минуты, и это ощущается ежедневно. Второе — кривая обучения: до уверенной продуктивности обычно уходит от двух недель до пары месяцев, причём самая крутая часть кривой начинается в следующей статье. Третье — многословность: там, где в Python одна строка, в Rust будут три, потому что вы обязаны сказать, что делать при ошибке и кто владеет данными.

Куда Rust тащить не стоит. Разовый скрипт, прототип на выброс, аналитическая записная книжка, склейка чужих API, где всё время уходит на ожидание сети, — здесь плата за время сборки и проектирование типов не окупается ничем. Если задача решается за двадцать строк на Python и живёт неделю, пишите на Python. Rust оправдан там, где код живёт годами, цена ошибки высока, а производительность и предсказуемость измеримы: системные утилиты, парсеры и протоколы, сетевые сервисы под нагрузкой, встраиваемые устройства, WASM, замена «горячих» участков в других языках. Обзор соседних вариантов — в статье современные альтернативы C.

Мини-итог

  • У каждого значения есть тип с известным на этапе компиляции размером. Что известно — на стеке, что растёт — за указателем. Из этого следуют String против &str, Vec против [T; N], &[T] в сигнатурах.
  • Неизменяемость по умолчанию — не строгость ради строгости, а фундамент для правил заимствования и подсказка читателю.
  • Затенение (let поверх let) — законный приём смены типа при том же имени; mut — изменение того же значения. Это разные вещи.
  • Неявных числовых преобразований нет. From/TryFrom — безопасный путь, as режет молча. Переполнение целых: паника в debug, заворачивание в release, явные checked_/wrapping_/saturating_ — когда это важно.
  • Строка — байты UTF-8. len() считает байты, chars() — коды символов, графемы — задача отдельного крейта. s[0] не компилируется намеренно.
  • Перечисления с данными плюс исчерпывающий match превращают «забытый случай» из runtime-бага в ошибку сборки. Option заменяет null бесплатно.
  • Почти всё — выражение: if, match, loop, блок. Точка с запятой превращает значение в () — отсюда классическая E0308.
  • Сигнатура функции — контракт: типы обязательны, перегрузки и аргументов по умолчанию нет, ошибки едут в Result.
  • Умение читать error[EXXXX] — половина обучения Rust. Первичный спан отвечает «где», вторичный — «почему», help — «как починить».

Источники

  • The Rust Programming Language — главы 3 «Common Programming Concepts», 5 «Structs», 6 «Enums and Pattern Matching», 8 «Common Collections».
  • Rust by Example — те же темы в формате запускаемых сниппетов.
  • The Rust Reference — формальное описание выражений, шаблонов и типов; сюда идут за точным ответом.
  • Документация std — в частности std::mem::size_of, primitive.str, Option.
  • Rust Error Index — расшифровка всех кодов EXXXX.
  • Rustlings — небольшие упражнения ровно по темам этой статьи; лучший способ закрепить материал руками.
  • Rust Playground — проверить гипотезу за пять секунд, включая просмотр ассемблера и MIR.
  • Clippy Lints — каталог с объяснением каждого линта.
  • The Edition Guide — что менялось между редакциями 2015, 2018, 2021 и 2024.
  • Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — самое подробное изложение системы типов и раскладки значений.
  • Jon Gjengset, «Rust for Rustaceans» — следующая ступень, когда основы уже не вызывают вопросов.
  • Nicholas Nethercote, The Rust Performance Book — про размеры типов, Option-оптимизации и цену абстракций.

Что дальше

Владение и заимствование: модель памяти без сборщика мусора — главная и самая непривычная идея языка. Разберём, почему значение имеет ровно одного владельца, что такое перемещение и почему .clone() перестанет быть вашим костылём, как читать ошибки E0382 и E0502 — и какие именно классы багов эта модель делает невозможными по построению.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов