Основы Rust: типы, переменные, управление потоком, функции
В предыдущей статье мы поставили rustup, собрали первый крейт и научились запускать cargo run. Теперь нужно понять, из чего вообще состоит программа на Rust.
Соблазн велик: пробежаться по синтаксису — «вот let, вот if, вот fn» — и объявить основы усвоенными. Так делать не стоит, и вот почему. Синтаксис Rust на 80% узнаваем: фигурные скобки, if, for, return. А поведение — нет. Программист, пришедший из C, споткнётся о неизменяемость по умолчанию. Пришедший из Python — о то, что if 0 не компилируется. Пришедший из JavaScript — о то, что "привет"[0] вообще не существует как операция. Каждое из этих отличий — не каприз, а следствие одного и того же решения: компилятор должен уметь доказать безопасность программы, не запуская её и не приставляя к ней сборщик мусора.
Поэтому разбирать основы мы будем через две сквозные модели.
- Модель размещения: у каждого значения есть тип, у типа известен размер, у значения — конкретное место. Отсюда всё, что связано с типами, строками и коллекциями.
- Модель выражений: почти всё в 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. К такому уровню диалога придётся привыкать, и это лучшее, что есть в языке.
Почему неизменяемость выбрана значением по умолчанию — три причины, по возрастанию важности.
- Чтение кода. Увидев
letбезmut, вы знаете: дальше по функции это значение не поменяется. Не «наверное», а гарантированно. - Оптимизация. Компилятор и LLVM свободнее переставляют и кэшируют то, что не меняется.
- Фундамент для проверки заимствований. Правило «либо одна изменяемая ссылка, либо сколько угодно неизменяемых» имеет смысл только там, где изменяемость — явно помеченное, редкое свойство. Об этом — в следующей статье.
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 (см. конкурентность).
Скалярные типы и арифметика без сюрпризов
Целые: i8…i128 и u8…u128, плюс 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 — отдельная статья соседнего трека). Заворачивание по модулю определено спецификацией. Просто оно почти никогда не то, что вы имели в виду.
с точным файлом и строкой"] B -->|"release: overflow-checks = false"| D["Заворачивание по модулю 2^N
поведение определено, но почти наверняка ошибочно"] A --> E{"Нужен явный контроль?"} E -->|"хочу узнать о переполнении"| F["checked_add даёт Option"] E -->|"нужна арифметика по модулю"| G["wrapping_add"] E -->|"нужен зажим по границе типа"| H["saturating_add"] E -->|"нужны и значение, и флаг"| I["overflowing_add даёт пару"]
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 блок { ... } вычисляется в значение. Значение блока — это его последнее выражение без точки с запятой. Поставили точку с запятой — получили инструкцию, и блок вычисляется в ().
заканчивается точкой с запятой?"} B -->|"да — это инструкция"| C["Значение блока = () , единичный тип"] B -->|"нет — это хвостовое выражение"| D["Значение блока = значение этого выражения"] C --> E{"Ожидался ли тип, отличный от () ?"} D --> F["Значение уходит в let, return,
аргумент или тело функции"] E -->|"да"| G["error[E0308]: mismatched types
expected i32, found ()"] E -->|"нет"| H["Всё корректно: блок-инструкция"]
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}> |
полная сборка — минуты. Держитесь check.
Практический минимум:
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, ни одной возможности прочитать освобождённую память. При этом бинарник — машинный код без рантайма и сборщика мусора.
Типичные грабли именно этого языка
- Лишняя точка с запятой в конце функции —
expected i32, found (). Самая частая ошибка первой недели. len() - 1на пустой коллекции.usizeне бывает отрицательным: паника в debug, гигантское число в release.- Забытый
mut— и наоборот,mutтам, где он не нужен: получитеwarning: variable does not need to be mutable. &Stringв сигнатуре вместо&str,&Vec<T>вместо&[T]. Сужает круг вызывающих без всякой выгоды..clone()как средство успокоить компилятор. На этом этапе это ещё простительно, но каждыйclone— реальное копирование. После следующей статьи большая их часть станет ненужной.unwrap()в продовом коде. В примерах и тестах — нормально, в сервисе — источник паники в три часа ночи. Разбор в статье про обработку ошибок.- Срез строки по произвольному индексу.
&s[0..3]на кириллице — паника, а не «обрежется как получится». asвместоtry_into()при сужении типа: молчаливое обрезание данных.- Целочисленное деление там, где ожидали дробное:
7 / 2 == 3, а-7 % 2 == -1(остаток берёт знак делимого; нужен неотрицательный —rem_euclid). - Сравнение
f64на равенство и попыткаsort()дляVec<f64>: нуженtotal_cmpлибо допуск. - Затенение по невнимательности — второй
let xв той же области выглядит как присваивание, но создаёт новую переменную, и первая остаётся нетронутой. if letбезelseмолча проглатывает второй вариант. Иногда это то, что нужно, но чаще — забытая ветка.- Ожидание перегрузки функций и аргументов по умолчанию. Их нет; ищите билдер или структуру настроек.
- Попытка написать связный список или граф «как в C». На этом этапе — просто не надо: возьмите
Vecс индексами вместо указателей. Почему так, станет ясно после статей про владение и времена жизни. - Борьба с компилятором вместо чтения его сообщения. 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 — и какие именно классы багов эта модель делает невозможными по построению.