Значения и типы
Ферапонт · кладовщик, хранитель Книги выдачи
Горн у тебя горит, это хорошо. Теперь уголь. Выдаю его корзинами, а корзин бывает сколько угодно, только не минус одна. Сколько их у горна, когда ты сожжёшь три из двух? Ответь мне числом, которое я смогу записать.
Чтобы ответить Ферапонту, мастерской нужно помнить число и менять его по командам. Значит, пора разобраться, какие числа бывают в Rust, что случается на их границах, почему переменная по умолчанию не меняется и как блок кода сам становится значением. Это словарь, без которого не написать ни одной команды.
Идея
Начнём с двух идей: они важнее синтаксиса, который разберём по ходу дела.
Первая идея: Rust это язык выражений. Почти всё в нём возвращает значение: блок кода, if, а в следующем уроке ещё match и даже loop. Если ты работал с Haskell или Gleam, это покажется знакомым. Внешне Rust похож на языки семейства C (фигурные скобки и точки с запятой на месте), но внутри устроен иначе: почти всё здесь выражение. Например, if в Rust не только ветвление, но и читаемый тернарный оператор.
Вторая идея: в Rust нет «просто числа». Вместо одного универсального типа есть отдельные i32, u8, f64 и другие. Название уже говорит, сколько байт занимает число, есть ли у него знак, какая у него точность. Это не педантизм: ты уже знаешь из урока про биты, что у каждого числа своё представление в памяти, и от него зависят переполнение, точность и расход памяти. Rust говорит об этом прямо, в имени типа.
Скаляры
Целые числа
Имя целого типа в Rust складывается из буквы и цифры:
- буква
iозначает, что число со знаком (может быть отрицательным); - буква
uозначает число без знака (ноль и положительные значения); - плюс цифра задаёт ширину в битах.
Вот какие варианты у нас есть:
| Ширина | Со знаком | Без знака | Что можно хранить |
|---|---|---|---|
| 8 бит | i8 | u8 | От -128 до 127 / от 0 до 255 |
| 16 бит | i16 | u16 | От -32 768 до 32 767 / от 0 до 65 535 |
| 32 бита | i32 | u32 | От -2 млрд до 2 млрд / от 0 до 4 млрд |
| 64 бита | i64 | u64 | Очень большие числа (до ~1.8×10¹⁹) |
| 128 бит | i128 | u128 | Ещё больше, хватит почти на всё |
| Зависит от платформы | isize | usize | Зависит от архитектуры компьютера |
По умолчанию 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("ожидалось число"); // Преобразуем в число
Что здесь происходит:
- Сначала
inputэто строка с пробелами. - Затем мы создаём новую переменную с тем же именем
input, в которой уже хранится очищенная строка (без пробелов). - Наконец, создаём ещё одну переменную
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 по диапазонам назовёт цвет раскалённого металла.