Раздел 23 · Rust

Значения и типы: числа, кортежи, mut и выражения

junior~30 мин

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

Значения и типы

Ферапонт · кладовщик, хранитель Книги выдачи

Горн у тебя горит, это хорошо. Теперь уголь. Выдаю его корзинами, а корзин бывает сколько угодно, только не минус одна. Сколько их у горна, когда ты сожжёшь три из двух? Ответь мне числом, которое я смогу записать.

Чтобы ответить Ферапонту, мастерской нужно помнить число и менять его по командам. Значит, пора разобраться, какие числа бывают в 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 нет неявного преобразования типов: число в условии вместо булевого значения не пройдёт. Например, нельзя проверить «истинность» нуля или пустой строки, и случайный 0 в условии не превратится в false. Если ты раньше работал с JavaScript, такая строгость покажется непривычной.

А теперь сюрприз: char в Rust шире, чем байт из C.

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

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

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

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

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

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

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

Кортеж

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

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;  // Ошибка компиляции: нельзя присвоить новое значение

Записка Ферапонта · E0384

«cannot assign twice to immutable variable attempts». Записал на полку число три, а потом решил, что там четыре? Без пометки mut я полку не открываю. Хочешь менять, так и скажи заранее: let mut attempts = 3;.

Хочешь изменить значение? Скажи это явно ключевым словом 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), то есть пустой кортеж. Сам let скомпилируется: у total нет аннотации, и он молча получит тип (). Ошибка всплывёт там, где ты попробуешь работать с total как с числом: сложить его или передать в функцию, которая ждёт i32. Если же записать тип явно, let total: i32 = { ... };, компилятор сразу скажет: ожидался 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.

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

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

Мастерская, этап 2

Мастерская forge из урока Зачем Rust и первая программа учится слушать команды. Состояние горна живёт в структуре State (структуры подробно разберём позже, пока хватит того, что у неё есть поля), а твоя функция handle получает строку команды и возвращает ответ. Главное в этом этапе: уголь не уходит в минус и не переполняется, потому что вычитание и сложение идут через checked_sub и checked_add. Код прошлого этапа переедет сюда сам.

Доска заказов

На доске Гильдии висит заказ без подсказок: счётчик угля Вадима.

ДЗ

Дальше

Значения разобраны: числа знают свою ширину, переменные меняются только с mut, а блок и if сами становятся значениями. Ферапонт записал первый уголь. В следующем уроке горн начнёт работать тактами: циклы loop, while и for, а match по диапазонам назовёт цвет раскалённого металла.