← Назад к списку тем

01. Основы Rust

Синтаксис, переменные, типы данных, срезы, управляющие конструкции, cargo new/run, форматированный вывод.

Установка и первый проект: cargo new / cargo run

В отличие от Go или Python, где сборкой часто занимаются сторонние инструменты, в Rust инструмент сборки и пакетный менеджер — cargo — часть экосистемы с первого дня. Он ставится вместе с компилятором rustc через rustup и покрывает практически весь цикл: создание проекта, сборку, тесты, форматирование, публикацию пакетов.

$ cargo new hello_rust
$ cd hello_rust
$ cargo run
   Compiling hello_rust v0.1.0
    Finished dev [unoptimized + debuginfo] target(s)
     Running `target/debug/hello_rust`
Hello, world!

Команда cargo new создаёт минимальный скелет: файл Cargo.toml с метаданными и зависимостями, и src/main.rs с точкой входа. Компиляция в debug-режиме быстрая, но код не оптимизирован — для продакшн-сборки используется cargo build --release, включающий LLVM-оптимизации, инлайнинг и удаление отладочной информации.

КомандаНазначение
cargo buildКомпиляция без запуска (debug-профиль)
cargo runКомпиляция + запуск
cargo build --releaseОптимизированная сборка
cargo checkТолько проверка типов/borrow-checker без кодогенерации — быстрее build
cargo testЗапуск тестов
cargo clippyРасширенный линтер
cargo fmtФорматирование кода
🔑 Привычка Senior-разработчика: В процессе разработки используйте cargo check вместо cargo build — он не генерирует машинный код, а только проверяет типы и заимствования, что в разы быстрее на крупных проектах.

Переменные: let, mut, shadowing

По умолчанию все переменные в Rust неизменяемы (immutable) — это осознанный выбор дизайна языка, который сразу отсекает целый класс ошибок, связанных со случайной мутацией состояния. Для изменяемости требуется явное ключевое слово mut.

fn main() {
    let x = 5;
    // x = 6; // ошибка компиляции: cannot assign twice to immutable variable

    let mut y = 5;
    y = 6; // ок, y объявлена как mut

    // Shadowing: новая переменная с тем же именем "затеняет" старую
    let z = 5;
    let z = z * 2; // z теперь 10, старое значение недоступно
    let z = z.to_string(); // можно даже сменить тип!

    println!("{z}");
}

Shadowing — не то же самое, что мутация. Каждый let создаёт новую переменную (возможно, другого типа), а не изменяет существующую ячейку памяти. Это удобно для последовательных трансформаций одного логического значения без выдумывания новых имён вроде x_str, x_trimmed.

Константы

Константы (const) отличаются от immutable-переменных: они всегда должны иметь явную аннотацию типа, вычисляются на этапе компиляции и не могут быть mut в принципе.

const MAX_POINTS: u32 = 100_000;

Скалярные типы данных

Rust — статически типизированный язык со строгим выводом типов (type inference), но, в отличие от Python и во многом схоже с Go, требует, чтобы каждое значение имело известный на этапе компиляции тип.

КатегорияТипыПример
Целые со знакомi8, i16, i32, i64, i128, isizelet x: i32 = -42;
Целые без знакаu8, u16, u32, u64, u128, usizelet x: u8 = 255;
С плавающей точкойf32, f64let x: f64 = 2.0;
Булевboollet x: bool = true;
Символchar (4 байта, Unicode scalar)let x: char = 'z';

isize/usize — типы размером в машинное слово (4 байта на 32-битных платформах, 8 на 64-битных), используются прежде всего для индексации коллекций. По умолчанию нетипизированный целочисленный литерал становится i32, а литерал с плавающей точкой — f64.

fn main() {
    let sum = 5 + 10;          // i32 по умолчанию
    let pi = 3.14;             // f64 по умолчанию
    let byte: u8 = 255;

    // Переполнение: в debug-сборке — panic, в release — wrapping (обёртывание)
    let overflowed = byte.wrapping_add(1); // явный wrapping = 0
    let checked = byte.checked_add(1);  // Option<u8> = None

    println!("{sum} {pi} {overflowed:?} {checked:?}");
}
⚠️ Переполнение целых: В debug-сборке арифметическое переполнение вызывает panic (для отлова багов на раннем этапе), а в release — молча "оборачивается" (wrapping). Для явного контроля используйте checked_*, saturating_* или wrapping_* методы вместо обычных операторов.

Составные типы: кортежи и массивы

Кортеж (tuple) группирует значения разных типов фиксированной длины. Массив (array) хранит значения одного типа, также фиксированной длины, и располагается на стеке целиком.

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
    let (x, y, z) = tup; // деструктуризация
    let five_hundred = tup.0;    // доступ по индексу

    let arr: [i32; 5] = [1, 2, 3, 4, 5];
    let zeros = [0; 10];       // [0, 0, ..., 0] — 10 элементов
    let first = arr[0];

    // Выход за границы массива — panic во время выполнения (bounds check)
    // let bad = arr[10]; // panicked at 'index out of bounds'

    println!("{x} {y} {z} {five_hundred} {first}");
}

В отличие от C/C++, где выход за границы массива — undefined behavior, Rust всегда проверяет индекс во время выполнения (если он не вычислен на этапе компиляции) и вызывает panic. Это часть гарантий memory safety языка, оплачиваемая небольшим рантайм-оверхедом, который компилятор часто может убрать при доказуемой безопасности индекса.

Срезы (slices)

Срез — это "вид" (view) на непрерывный участок памяти без владения им. Срез — fat pointer: указатель на начало данных + длина.

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let slice: &[i32] = &arr[1..3]; // [2, 3], элементы с индексами 1 и 2

    let s = String::from("hello world");
    let hello: &str = &s[0..5];
    let world: &str = &s[6..];  // до конца строки

    println!("{slice:?} {hello} {world}");
}

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

Взяв срез &str, компилятор через borrow-checker гарантирует, что исходная строка не будет изменена или удалена, пока срез жив — в отличие от C, где указатель на подстроку легко превращается в dangling pointer после реаллокации буфера.

Управляющие конструкции: if, loop, while, for

if в Rust — выражение, а не оператор: оно возвращает значение, поэтому его можно использовать в правой части let.

fn main() {
    let number = 6;
    let description = if number % 4 == 0 {
        "divisible by 4"
    } else if number % 3 == 0 {
        "divisible by 3"
    } else {
        "not divisible"
    };
    println!("{description}");

    // loop — бесконечный цикл, может возвращать значение через break
    let mut counter = 0;
    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2; // значение loop-выражения
        }
    };

    // while — обычный условный цикл
    let mut n = 3;
    while n != 0 {
        println!("{n}!");
        n -= 1;
    }

    // for — идиоматичный обход итераторов, безопаснее и быстрее ручного индексирования
    let arr = [10, 20, 30];
    for element in arr.iter() {
        println!("value: {element}");
    }
    for i in (1..4).rev() {
        println!("{i}");
    }

    // Метки циклов для break/continue во вложенных циклах
    'outer: for x in 0..5 {
        for y in 0..5 {
            if x * y > 6 {
                break 'outer;
            }
        }
    }
}
✅ Идиоматичный стиль: Предпочитайте for x in collection.iter() ручной индексации по счётчику — компилятор часто устраняет проверки границ (bounds check elimination) при итерации, что делает такой код не медленнее, а порой быстрее C-style циклов.

Форматированный вывод: println! и макросы форматирования

println! — не функция, а макрос (отсюда !): форматная строка проверяется на этапе компиляции, а не в рантайме, как в C с printf. Ошибка в спецификаторе формата — ошибка компиляции, а не потенциальный краш в проде.

fn main() {
    let name = "Rust";
    let version = 1.75;

    println!("Hello, {name}! Version: {version}");   // захват переменных из области видимости
    println!("{} loves {}", "Ferris", name);      // позиционные аргументы
    println!("{0} again {0}, then {1}", "A", "B");

    // Debug (:?) и Pretty-Debug (:#?) для составных типов
    let v = vec![1, 2, 3];
    println!("{v:?}");   // [1, 2, 3]
    println!("{v:#?}");  // многострочный вывод

    // Числовое форматирование
    println!("{:5}", 42);      // выравнивание по ширине
    println!("{:05}", 42);     // дополнение нулями: 00042
    println!("{:.2}", 3.14159); // точность: 3.14
    println!("{:x} {:b} {:o}", 255, 255, 255); // hex, binary, octal

    // eprintln! — вывод в stderr; format! — сборка String без вывода
    eprintln!("error: something failed");
    let s: String = format!("{name} v{version}");
}

Чтобы кастомный тип поддерживал {:?}, достаточно вывести (derive) трейт Debug; для {} нужно вручную реализовать трейт Display — это заставляет явно решать, как тип должен выглядеть для пользователя, в отличие от Python, где __repr__/__str__ легко перепутать.

Функции как выражения

Тело функции — блок, последнее выражение которого (без ;) становится возвращаемым значением. Это единообразно работает и для блоков if, match, loop, вложенных {}.

fn plus_one(x: i32) -> i32 {
    x + 1 // без точки с запятой — это возвращаемое значение
}

fn absolute(x: i32) -> i32 {
    if x < 0 { -x } else { x }
}

fn main() {
    println!("{} {}", plus_one(5), absolute(-7));
}
🚫 Частая ошибка новичков: Случайно поставленная точка с запятой в конце последнего выражения превращает его в оператор со значением () (unit), и функция перестаёт возвращать ожидаемый тип — компилятор сообщит об ошибке несоответствия типов, но новичка это часто сбивает с толку.
← Предыдущая