Раздел 23 · Rust

Базовые типы и поток управления: разбираемся без стресса

junior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Базовые типы и поток управления

Разберём скаляры и составные типы, изменяемость и shadowing, if как выражение и четыре вида циклов. После этого урока ты будешь уверенно читать и писать простой Rust‑код без страха перед синтаксисом. А тонкости владения разберём в следующий раз, уже с полным пониманием!

Идея

Давай начнём с двух важных идей: они даже важнее синтаксиса, который мы разберём по ходу дела.

Первая идея: Rust, это язык выражений. Почти всё в нём возвращает значение: блок кода, if, match и даже loop. Если ты работал с Haskell или Gleam, это покажется знакомым. Хотя Rust внешне похож на языки семейства C (фигурные скобки и точки с запятой на месте), внутри он устроен иначе: почти всё здесь является выражением. Например, if в Rust работает не просто как ветвление, а скорее как читаемый тернарный оператор.

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

Скаляры

Целые числа

Давай разберёмся с целыми типами в Rust. Имя типа состоит из двух частей:

  • буква i означает, что число со знаком (может быть отрицательным);
  • буква u означает число без знака (только положительные значения);
  • плюс цифра задаёт ширину в битах.

Вот какие варианты у нас есть:

ШиринаСо знакомБез знакаЧто можно хранить
8 битi8u8От -128 до 127 / от 0 до 255
16 битi16u16От -32 768 до 32 767 / от 0 до 65 535
32 битаi32u32От -2 млрд до 2 млрд / от 0 до 4 млрд
64 битаi64u64Очень большие числа (до ~1.8×10¹⁹)
128 битi128u128Ещё больше, хватит почти на всё
Зависит от платформыisizeusizeЗависит от архитектуры компьютера

По умолчанию Rust использует i32: это покрывает большинство случаев.

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

Литералы умеют разделители и системы счисления, а ещё суффикс типа прямо в числе:

let million = 1_000_000;      // i32 по умолчанию
let byte = 0xFF_u8;           // шестнадцатеричный литерал с суффиксом типа
let mask = 0b1010_0101;       // двоичный
let big = 12_345_678_901_i64; // суффикс, когда нужен тип шире умолчания

Один вопрос ты обязан задать прямо сейчас: а что будет, если u8 со значением 255 увеличить на единицу? Ответ у Rust двойной. В debug-сборке программа аварийно завершится с понятным сообщением: переполнение здесь считается багом, а не фокусом. В release-сборке проверка снимается ради скорости и значение молча завернётся в 0. Звучит тревожно, и в уроке про целые и дополнительный код мы разберём и причину, и инструменты: методы checked_add, wrapping_add и saturating_add позволяют выбрать поведение явно. Пока правило простое: переполнение является ошибкой логики, и debug-сборка её поймает.

Вещественные числа, bool и char

В Rust есть два типа для вещественных чисел: f32 и f64, оба соответствуют стандарту IEEE‑754. По умолчанию используется f64. Если ты уже сталкивался с особенностями вычислений вроде 0.1 + 0.2 ≠ 0.3, то в Rust всё работает так же. Подробнее про устройство чисел с плавающей точкой мы поговорим позже, в блоке про биты.

Тип bool очень прост: он может принимать всего два значения: true или false. В Rust нет неявного преобразования типов: нельзя использовать число в условии вместо булевого значения. Например, нельзя проверить «истинность» нуля или пустой строки, и это помогает избежать ошибок. Если ты раньше работал с JavaScript, такая строгость может показаться непривычной, но она делает код надёжнее.

А теперь сюрприз: тип char в Rust интереснее, чем может показаться на первый взгляд!

let letter = 'ы';
let crab = '🦀';

char представляет собой Unicode scalar value. Он занимает 4 байта и может хранить любой символ Unicode: от обычной латинской буквы до иероглифа или эмодзи.

Важные нюансы:

  • char записывается в одинарных кавычках;
  • двойные кавычки используются для строк.

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

Составные типы

В Rust есть два базовых составных типа: кортеж и массив. Оба имеют фиксированный размер и хранятся на стеке. Разберём каждый подробнее.

Кортеж

Кортеж позволяет объединить значения разных типов в одну структуру. Например:

let point: (f64, f64, &str) = (55.75, 37.61, "Москва");

С кортежем можно работать разными способами:

  • Деструктуризация: разбиваем кортеж на отдельные переменные:
    let (lat, lon, name) = point;
  • Доступ по номеру поля: получаем значение по его позиции:
    let latitude = point.0;

Если ты работал с JavaScript или Gleam, деструктуризация покажется тебе знакомой: она работает очень похоже.

Особый случай: кортеж нулевой длины (). Его называют unit (единица). Это «пустое» значение, которое возвращают функции, когда им нечего вернуть. По сути, это аналог void из других языков, но с важным отличием: unit является настоящим значением с настоящим типом. Позже ты увидишь, почему это бывает полезно!

Массив

Массив ([T; N]) хранит N элементов одного типа, причём длина массива «зашита» прямо в его типе. Например:

let days: [u8; 7] = [31, 28, 31, 30, 31, 30, 31];
let zeros = [0_u32; 1024];  // массив из 1024 нулей

Обращение к элементам происходит по индексу:

let first = days[0];

Важное преимущество Rust: выход за границы массива здесь невозможен. Что происходит:

  • Если индекс известен на этапе компиляции (как в примере выше), ошибка будет обнаружена ещё до запуска программы.
  • Если индекс вычисляется во время выполнения, программа проверит границы и при нарушении аварийно завершится через panic.

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

Да, фиксированная длина массива может показаться негибкой. Позже мы познакомимся с динамическим Vec<T>, который станет нашим основным инструментом для работы с коллекциями. Но фиксированный [T; N] тоже останется полезным: он пригодится для буферов, таблиц, регистров эмулятора и других подобных задач.

Изменяемость и shadowing

Ты уже видел, что переменные в Rust объявляются с помощью let. Давай разберём важное отличие Rust от языков семейства C.

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

let attempts = 3;
attempts = 4;  // Ошибка компиляции: нельзя присвоить новое значение

Хочешь изменить значение? Нужно явно указать это с помощью ключевого слова mut:

let mut attempts = 3;
attempts = 4;  // Теперь всё в порядке: переменная изменяемая

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

Почему это полезно?

  • Компилятор следит за тем, чтобы ты не менял то, что не должен.
  • Другие разработчики (и ты сам через месяц) сразу видят: если есть mut, значит значение будет меняться, это своего рода подсказка в коде.
  • Если ты объявил переменную с mut, но ни разу её не поменял, компилятор или линтер (например, Clippy) предупредит тебя об этом: возможно, mut здесь и не нужен.

Что такое shadowing?

В Rust есть ещё один интересный механизм: shadowing (затенение). Это не то же самое, что изменяемость (mut). Разберём на примере:

let input = "  42  ";
let input = input.trim();          // Убираем пробелы
let input: i32 = input.parse().expect("ожидалось число");  // Преобразуем в число

Что здесь происходит:

  1. Сначала input является строкой с пробелами.
  2. Затем мы создаём новую переменную с тем же именем input, в которой уже хранится очищенная строка (без пробелов).
  3. Наконец, создаём ещё одну переменную input, но уже типа i32, в ней хранится число.

Ключевые моменты:

  • Каждый раз создаётся новая переменная, старое значение остаётся неизменным.
  • Новое связывание «затеняет» старое: старое имя теперь ссылается на новое значение.
  • Можно даже менять тип переменной на каждом шаге!
  • В отличие от mut, здесь нет мутации: мы не меняем старое значение, а создаём новое.

Когда это удобно?

  • Когда нужно последовательно преобразовать данные (например, очистить строку, а потом превратить её в число).
  • Не нужно придумывать новые имена для промежуточных значений (input_trimmed, input_parsed и т. д.).
  • Логически это всё ещё «входные данные» (input), просто на разных этапах обработки.

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

Выражение против инструкции

Давай разберём одну из ключевых особенностей Rust: различие между выражениями и инструкциями. Это может показаться непривычным, но после объяснения станет понятнее.

В Rust блок кода в фигурных скобках является выражением. Его значение определяется последним выражением внутри блока, если оно записано без точки с запятой:

let total = {
    let unit_price = 250;
    let quantity = 4;
    unit_price * quantity  // Без точки с запятой: это значение блока
};

Что делает точка с запятой? Она превращает выражение в инструкцию и «выбрасывает» его значение. Если добавить точку с запятой после unit_price * quantity, блок будет возвращать () (unit), то есть пустой кортеж. Компилятор сразу сообщит об ошибке: () нельзя присвоить переменной типа i32.

На первых порах такая ошибка будет встречаться часто, и теперь ты знаешь, в чём её причина!

if как выражение

В Rust конструкция if тоже является выражением. Это значит, что нам не нужен тернарный оператор (как в других языках): if сам может возвращать значение:

let access = if age >= 18 { "полный" } else { "детский" };

Важные нюансы:

  • Обе ветви (if и else) обязаны возвращать значение одного типа.
  • Блок else при таком использовании обязателен: компилятор не позволит выражению иногда не иметь значения.
  • Внутри ветвей if могут быть целые блоки кода с промежуточными вычислениями: это делает конструкцию гораздо мощнее тернарного оператора.

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

То же правило работает и с функциями: тело функции является блоком, а значит, значением функции становится последнее выражение в теле:

fn fahrenheit_to_celsius(f: f64) -> f64 {
    (f - 32.0) * 5.0 / 9.0
}

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

Обрати внимание на сигнатуру функции:

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

Циклы

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

Цикл loop

loop создаёт бесконечный цикл: он будет выполняться вечно, пока его не остановит команда break.

У него есть интересная особенность: break может возвращать значение. Тогда весь цикл становится выражением, которое возвращает это значение:

let mut attempt = 0;
let delay = loop {
    attempt += 1;
    let backoff = 100 * 2_u32.pow(attempt);
    if backoff > 1000 {
        break backoff;  // Возвращаем значение и выходим из цикла
    }
};

Такой подход удобен, когда нужно что‑то найти: цикл крутится, пока не найдёт нужное, а затем возвращает результат.

Цикл while

while создаёт классический цикл с условием. Он выполняется, пока условие истинно:

let mut countdown = 5;
while countdown > 0 {
    println!("{countdown}");
    countdown -= 1;
}

Очень просто и понятно: пока countdown больше нуля, печатаем его значение и уменьшаем на единицу.

Цикл for

В Rust цикл for работает только с итерируемыми значениями: например, с диапазонами или коллекциями. В отличие от C, здесь нет цикла с тремя секциями (for (i=0; i<n; i++)).

Примеры использования:

// Перебираем диапазон чисел (0..5 не включает 5)
for i in 0..5 {
    println!("{i}");  // Выведет: 0, 1, 2, 3, 4
}

// Перебираем элементы массива
for day in days {
    println!("{day}");
}

Важные нюансы про диапазоны:

  • 0..5: диапазон от 0 до 4 (правая граница не включается);
  • 0..=5: диапазон от 0 до 5 (правая граница включается).

Почему for часто лучше ручной индексации?

  • Безопаснее: компилятор сам следит за границами, исключая ошибки выхода за пределы массива.
  • Быстрее: не нужно проверять границы на каждом шаге.
  • Удобнее: код становится чище и понятнее.

Под капотом for работает с итераторами: это один из ключевых механизмов языка. Подробнее мы поговорим об этом в отдельном уроке.

Метки для вложенных циклов

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

'search: for row in 0..height {
    for col in 0..width {
        if grid[row][col] == target {
            break 'search;  // Выходим сразу из внешнего цикла с меткой 'search
        }
    }
}

Одна команда break 'search заменяет целую систему флагов и проверок, и код становится короче и понятнее.

Небольшой тизер: конструкция match

Полное знакомство с pattern matching (сопоставлением с образцом) ждёт нас в уроке про перечисления, но одну важную конструкцию мы разберём уже сейчас: она пригодится для выполнения домашнего задания.

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

let label = match score {
    0 => "ноль",
    1..=49 => "мало",
    50..=99 => "почти",
    _ => "сотня или больше",
};

Как это работает?

  • match является выражением и возвращает значение (в нашем примере строку);
  • ветви проверяются сверху вниз: как только находится подходящий шаблон, выполняется соответствующий код;
  • символ _ служит «заглушкой», которая ловит все оставшиеся случаи (аналог default в switch).

Важные особенности:

  • Компилятор проверяет, что все возможные случаи учтены (это называется исчерпываемость). Если какой‑то случай пропущен, код не скомпилируется.
  • Конструкция очень мощная и гибкая: она может работать не только с числами, но и с другими типами данных.

Если ты работал с Gleam или Haskell, синтаксис покажется тебе знакомым. В следующих уроках мы разберём match подробнее и узнаем, на что он ещё способен!

Практика

Теория хороша, но настоящий прогресс начинается, когда ты пробуешь всё на практике! Перед тобой серия из семи задач, которые помогут закрепить всё, что мы сегодня изучили. Решай их прямо в браузере: начнём с лёгкой разминки и постепенно перейдём к более сложным заданиям.

1. Разминка: if как выражение
Потренируйся использовать if в качестве выражения: это поможет лучше понять одну из ключевых особенностей Rust.

2. Границы целых типов
Вспомни таблицу с типами чисел и проверь, как ведут себя границы целых типов на практике.

3. Циклы и арифметика
Примени на практике всё, что узнал о циклах: от loop до for.

4. Работа с shadowing
Потренируйся использовать механизм shadowing для последовательного преобразования данных.

5. Факториал
Реши классическую задачу с помощью циклов: это поможет закрепить навыки работы с арифметическими операциями.

6. Последовательность Коллатца
Поработай с алгоритмом последовательности Коллатца: здесь пригодятся и циклы, и условные конструкции.

7. Преобразование типов (as) и биты
Финальное задание: разберись, что именно делает оператор as с битами. Сначала попробуй предсказать результат работы каждой функции, а затем проверь себя с помощью тестов.

ДЗ

Дальше

Отлично поработал! Теперь ты освоил базовые типы и поток управления в Rust. В уроке 03 мы погрузимся в сердце языка и разберём владение (ownership): что такое move и copy, как работают стек и куча, что такое drop и RAII, и почему строка из первого урока «переезжала», а число на её месте скопировалось бы молча.