Переполнение целых чисел в Rust
У каждого целочисленного типа в Rust фиксированный размер. В u8 помещаются числа от 0 до 255 — и больше ничего. Так что будет, если взять 255 и прибавить 1? Ответ не помещается. Это называется переполнением целого числа (integer overflow).
Представь одометр старой машины, у которого всего три цифры. После 999 километров он снова показывает 000. Для игрушки это нормально, а для банковского счёта — ужасно. Rust помогает здесь двумя способами. Во время разработки он громко сообщает о проблеме. И даёт простые методы, чтобы точно сказать, что должно происходить вместо этого. Не переживай: их всего четыре, и все устроены одинаково. Эта страница продолжает уроки Типы данных и Операторы.
Какого размера каждый целочисленный тип?
Заголовок раздела «Какого размера каждый целочисленный тип?»У каждого типа есть наименьшее значение (MIN) и наибольшее (MAX). Их можно вывести с помощью констант вроде u8::MAX или i32::MIN:
fn main() { println!("u8: от {} до {}", u8::MIN, u8::MAX); println!("i8: от {} до {}", i8::MIN, i8::MAX); println!("u16: от {} до {}", u16::MIN, u16::MAX); println!("i32: от {} до {}", i32::MIN, i32::MAX); println!("u32: от {} до {}", u32::MIN, u32::MAX); println!("i64: от {} до {}", i64::MIN, i64::MAX); println!("u64: от {} до {}", u64::MIN, u64::MAX); println!("u128: от {} до {}", u128::MIN, u128::MAX);}Вывод:
u8: от 0 до 255i8: от -128 до 127u16: от 0 до 65535i32: от -2147483648 до 2147483647u32: от 0 до 4294967295i64: от -9223372036854775808 до 9223372036854775807u64: от 0 до 18446744073709551615u128: от 0 до 340282366920938463463374607431768211455Всё семейство в одной таблице:
| Тип | Бит | Наименьшее | Наибольшее |
|---|---|---|---|
i8 / u8 |
8 | -128 / 0 | 127 / 255 |
i16 / u16 |
16 | -32 768 / 0 | 32 767 / 65 535 |
i32 / u32 |
32 | около -2,1 млрд / 0 | около 2,1 млрд / 4,3 млрд |
i64 / u64 |
64 | около -9,2 × 10^18 / 0 | около 9,2 × 10^18 / 1,8 × 10^19 |
i128 / u128 |
128 | около -1,7 × 10^38 / 0 | около 1,7 × 10^38 / 3,4 × 10^38 |
isize / usize |
64 на большинстве компьютеров | как у i64 / 0 |
как у i64 / u64 |
Тип со знаком (i) тратит половину диапазона на отрицательные числа. Тип без знака (u) начинается с 0, поэтому его наибольшее значение примерно вдвое больше. Всё, что выходит за эти пределы, — переполнение, и выход ниже MIN (например, 0 - 1 для u8) тоже считается. Иногда это называют «антипереполнением» (underflow), но Rust обращается с ним точно так же.
Что делает Rust при переполнении
Заголовок раздела «Что делает Rust при переполнении»В отладочной сборке — паника
Заголовок раздела «В отладочной сборке — паника»Когда ты запускаешь программу через cargo run (отладочная сборка, debug), Rust проверяет каждое +, - и *. Если ответ не помещается, программа останавливается с понятным сообщением. Здесь игра прибавляет бонусные очки к счёту типа u8:
fn add_points(score: u8, bonus: u8) -> u8 { score + bonus}
fn main() { let total = add_points(250, 10); println!("итого: {total}");}При запуске ты получишь:
thread 'main' (12345) panicked at src/main.rs:2:5:attempt to add with overflownote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceПоток main запаниковал в строке 2 с сообщением «попытка сложения с переполнением». Значения специально передаются через параметры функции. Если написать числа прямо рядом с +, компилятор заметит проблему ещё до запуска (это ты увидишь чуть ниже). Падение может показаться суровым, но это забота: ты находишь ошибку на своём компьютере, с точным номером строки, а не получаешь неправильный счёт в продакшене. Подробнее о панике — в уроке Паника.
В release-сборке — «прокрутка»
Заголовок раздела «В release-сборке — «прокрутка»»Когда собираешь через cargo run --release, эти проверки отключаются, чтобы код работал чуть быстрее. Число прокручивается (wraps around), как одометр: 250 + 10 для u8 превращается в 4 (потому что 260 - 256 = 4).
cargo run --releaseитого: 4Ни паники, ни предупреждения — просто тихо неправильное число. Поэтому не стоит полагаться на переполнение, которое случается «само». Если тебе нужна прокрутка, скажи об этом явно через wrapping_add (см. ниже). Кнопки «Попробуй сам» на этом сайте запускают отладочные сборки, так что увидеть такое поведение там можно тоже через wrapping_add:
fn main() { let score: u8 = 250; println!("{}", score.wrapping_add(10));}Вывод:
4Если хочешь, чтобы release-сборки тоже паниковали, проверки можно включить обратно в Cargo.toml:
[profile.release]overflow-checks = trueС обычными числами код даже не скомпилируется
Заголовок раздела «С обычными числами код даже не скомпилируется»Когда компилятор видит всё вычисление целиком, он отказывается его собирать:
fn main() { let level: u8 = 255 + 1; println!("{level}");}error: this arithmetic operation will overflow --> src/main.rs:2:21 |2 | let level: u8 = 255 + 1; | ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow | = note: `#[deny(arithmetic_overflow)]` on by defaultКомпилятор сообщает, что эта арифметическая операция переполнится: u8::MAX + 1_u8 не помещается. То же самое с литералом, который просто слишком велик, например let level: u8 = 256; — получишь literal out of range for u8 (литерал вне диапазона u8).
Выбираем поведение сами
Заголовок раздела «Выбираем поведение сами»Вместо того чтобы надеяться, что переполнения никогда не случится, можно выбрать, что должно произойти. У каждого целочисленного типа для этого есть четыре семейства методов. Они одинаково работают для u8, i32, u64 и остальных.
checked_: «скажи, если не поместилось»
Заголовок раздела «checked_: «скажи, если не поместилось»»checked_add, checked_sub и checked_mul возвращают Option: Some(ответ), если он помещается, и None, если нет. Это самый безопасный выбор везде, где неправильное число — настоящая проблема, например в деньгах. Баланс на счёте не может быть меньше 0, поэтому u64 вместе с checked_sub сам откажет в снятии:
fn withdraw(balance: u64, amount: u64) -> Option<u64> { balance.checked_sub(amount)}
fn order_total(price: u32, quantity: u32, shipping: u32) -> Option<u32> { price.checked_mul(quantity)?.checked_add(shipping)}
fn main() { let balance: u64 = 100;
for amount in [30, 250] { match withdraw(balance, amount) { Some(left) => println!("Сняли {amount}, на счёте осталось {left}"), None => println!("Нельзя снять {amount}: не хватает денег"), } }
println!("{:?}", order_total(20, 3, 5)); println!("{:?}", order_total(2_000_000_000, 3, 5));}Вывод:
Сняли 30, на счёте осталось 70Нельзя снять 250: не хватает денегSome(65)NoneРаз результат — это Option, работает и оператор ?. В order_total, если умножение переполняется, функция сразу возвращает None.
saturating_: «остановись на краю»
Заголовок раздела «saturating_: «остановись на краю»»saturating_add и saturating_sub никогда не выходят за MIN или MAX. Они просто останавливаются на границе, как полный стакан. Это идеально для того, у чего есть естественный пол или потолок: полоска здоровья, ручка громкости, товары на складе.
fn main() { let health: u8 = 20; let after_hit = health.saturating_sub(35); println!("здоровье после сильного удара: {after_hit}");
let volume: u8 = 90; let louder = volume.saturating_add(25).min(100); println!("громкость после прибавления: {louder}");
let stock: u32 = 3; let left = stock.saturating_sub(5); println!("осталось на складе: {left}");}Вывод:
здоровье после сильного удара: 0громкость после прибавления: 100осталось на складе: 0Строке с громкостью нужен ещё один шаг. saturating_add останавливается только на пределе самого типа — 255 для u8. А твоя ручка останавливается на 100, поэтому .min(100) прижимает значение к твоему собственному пределу.
wrapping_: «прокрутись намеренно»
Заголовок раздела «wrapping_: «прокрутись намеренно»»Иногда прокрутка — именно то, что нужно. Счётчик талонов, который идёт 254, 255, 0, 1. Или простая хеш-функция, где числа и должны перемешиваться. wrapping_add и wrapping_mul делают это в любой сборке, отладочной или release. Строки, от которых считается хеш, здесь на английском: хеш зависит от байтов, а у кириллицы их вдвое больше.
fn simple_hash(text: &str) -> u32 { let mut hash: u32 = 5381; for byte in text.bytes() { hash = hash.wrapping_mul(33).wrapping_add(byte as u32); } hash}
fn main() { let mut ticket: u8 = 254; for _ in 0..4 { println!("талон {ticket}"); ticket = ticket.wrapping_add(1); }
println!("хеш от \"hello\": {}", simple_hash("hello")); println!("хеш от \"a much longer sentence\": {}", simple_hash("a much longer sentence"));}Вывод:
талон 254талон 255талон 0талон 1хеш от "hello": 261238937хеш от "a much longer sentence": 3373490575С обычными * и + длинное предложение много раз переполнило бы u32. С методами wrapping_ всё в порядке: хеш так и задуман.
overflowing_: «дай мне и то и другое»
Заголовок раздела «overflowing_: «дай мне и то и другое»»overflowing_add возвращает кортеж: прокрученный ответ и bool, который говорит, было ли переполнение. Это удобно, когда нужно прокрученное значение, но хочется знать, что прокрутка случилась:
fn main() { let (value, overflowed) = 250u8.overflowing_add(10); println!("{value}, переполнение: {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10); println!("{value}, переполнение: {overflowed}");}Вывод:
4, переполнение: true60, переполнение: falseЭтим методом пользуются реже остальных. Если не знаешь, что выбрать, checked_ — отличный вариант по умолчанию.
Переполнение при приведении через as
Заголовок раздела «Переполнение при приведении через as»Преобразование типов через as никогда не паникует, даже в отладочной сборке. Если значение не помещается, оно молча обрезается. Многих это удивляет, так что будь с ним осторожен:
fn main() { let big: i32 = 300; let negative: i32 = -1;
println!("300 as u8 = {}", big as u8); println!("-1 as u32 = {}", negative as u32);
match u8::try_from(big) { Ok(small) => println!("помещается: {small}"), Err(e) => println!("300 не помещается в u8: {e}"), }
match u8::try_from(200) { Ok(small) => println!("помещается: {small}"), Err(e) => println!("200 не помещается в u8: {e}"), }}Вывод:
300 as u8 = 44-1 as u32 = 4294967295300 не помещается в u8: out of range integral type conversion attemptedпомещается: 200300 as u8 оставляет только младшие 8 бит, то есть 44. -1 as u32 превращается в наибольшее u32. Ни то ни другое не предупреждает. u8::try_from сначала проверяет и возвращает Result, так что решать, что делать, тебе. Текст ошибки из стандартной библиотеки всегда на английском: «out of range integral type conversion attempted» — попытка преобразования целого числа вне диапазона. Хорошая привычка: используй as, только когда знаешь, что значение помещается, и try_from, когда оно приходит извне программы. (Дроби ведут себя иначе: 300.7 as u8 останавливается на 255, а не прокручивается.) Подробнее — в уроках Приведение типов и Преобразования.
Другие сюрпризы с числами
Заголовок раздела «Другие сюрпризы с числами»Ещё несколько вещей, на которых спотыкаются, — всё в одном месте:
fn main() { println!("7 / 2 = {}", 7 / 2); println!("-7 / 2 = {}", -7 / 2);
println!("i32::MIN.checked_abs() = {:?}", i32::MIN.checked_abs()); println!("i32::MIN.unsigned_abs() = {}", i32::MIN.unsigned_abs());
println!("10u32.checked_pow(9) = {:?}", 10u32.checked_pow(9)); println!("10u32.checked_pow(10) = {:?}", 10u32.checked_pow(10));}Вывод:
7 / 2 = 3-7 / 2 = -3i32::MIN.checked_abs() = Nonei32::MIN.unsigned_abs() = 214748364810u32.checked_pow(9) = Some(1000000000)10u32.checked_pow(10) = None- Целочисленное деление отбрасывает остаток. Оно не округляет, а отсекает в сторону нуля, поэтому
-7 / 2равно-3. Для остатка используй%, а если нужны дроби — числа с плавающей точкой. abs()может переполниться.i32опускается до -2147483648, но поднимается только до 2147483647, так что положительная версияi32::MINне помещается.i32::MIN.abs()в отладочной сборке паникует сattempt to negate with overflow. Используйchecked_abs()илиunsigned_abs(), который возвращаетu32, где значение помещается.powрастёт быстро. 10 в десятой степени уже не помещается вu32. Есть ещёchecked_pow,saturating_powиwrapping_pow.
Если значение должно всегда прокручиваться или всегда останавливаться на краю, его можно обернуть в std::num::Wrapping или std::num::Saturating. Тогда обычные + и - делают правильную вещь, и забыть невозможно:
use std::num::{Saturating, Wrapping};
fn main() { let mut ticket = Wrapping(254u8); ticket += 1; ticket += 1; ticket += 1; println!("талон: {ticket}");
let mut battery = Saturating(10u8); battery -= 25; println!("батарея: {battery}");}Вывод:
талон: 1батарея: 0Какой метод выбрать?
Заголовок раздела «Какой метод выбрать?»| Семейство методов | Возвращает | Когда использовать |
|---|---|---|
+ - * |
Число (паника в debug, прокрутка в release) | Ты уверен, что переполнения быть не может |
checked_add и компания |
Option<T> |
Неправильное число было бы ошибкой: деньги, размеры, ID |
saturating_add и компания |
Число, застрявшее на MIN или MAX |
Есть естественный пол или потолок: здоровье, громкость, склад |
wrapping_add и компания |
Прокрученное число | Прокрутка и нужна: хеши, счётчики по кругу |
overflowing_add и компания |
(T, bool) |
Нужно прокрученное значение и знать, что прокрутка была |
T::try_from(x) |
Result<T, _> |
Преобразуешь значение, которое может не поместиться в меньший тип |
Частые ошибки
Заголовок раздела «Частые ошибки»Вычитание 1 из длины пустого списка
Заголовок раздела «Вычитание 1 из длины пустого списка»len() возвращает usize, а он не бывает отрицательным. У пустого списка len() - 1 было бы -1, поэтому происходит переполнение:
fn last_index(tasks: &[String]) -> usize { tasks.len() - 1}
fn main() { let tasks: Vec<String> = Vec::new(); println!("последняя задача по индексу {}", last_index(&tasks));}При запуске ты получишь:
thread 'main' (12345) panicked at src/main.rs:2:5:attempt to subtract with overflownote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceПаника в строке 2: «попытка вычитания с переполнением». На этом хотя бы раз попадаются почти все. Исправление — позволить типу сказать, что список может быть пустым: возвращай tasks.len().checked_sub(1), то есть Option<usize>. А если нужен просто последний элемент, tasks.last() ещё проще.
Сумма маленьких чисел в маленьком типе
Заголовок раздела «Сумма маленьких чисел в маленьком типе»Три оценки 90, 85 и 100 по отдельности помещаются в u8. Их сумма, 275, — нет. Поэтому let total: u8 = scores.iter().sum(); паникует с attempt to add with overflow. Паника указывает куда-то внутрь стандартной библиотеки, и это может сбить с толку, но причина — твой u8. Перед сложением преобразуй каждое значение в больший тип:
fn main() { let scores: Vec<u8> = vec![90, 85, 100]; let total: u32 = scores.iter().map(|&s| s as u32).sum(); println!("сумма: {total}, среднее: {}", total / 3);}Вывод:
сумма: 275, среднее: 91Переход от u8 к u32 через as всегда безопасен: любое u8 помещается в u32.
Практика
Заголовок раздела «Практика»1. Батарея телефона
Заголовок раздела «1. Батарея телефона»Уровень заряда батареи — это u8 от 0 до 100. Напиши charge(level: u8, amount: u8) -> u8 и drain(level: u8, amount: u8) -> u8, которые никогда не поднимаются выше 100 и не опускаются ниже 0. Начни с 80, заряди на 30, затем разряди на 130 и выводи уровень после каждого шага. Подсказка: для зарядки — saturating_add плюс .min(100), для разрядки — saturating_sub.
Показать решение
fn charge(level: u8, amount: u8) -> u8 { level.saturating_add(amount).min(100)}
fn drain(level: u8, amount: u8) -> u8 { level.saturating_sub(amount)}
fn main() { let mut level: u8 = 80; println!("в начале: {level}%");
level = charge(level, 30); println!("после зарядки: {level}%");
level = drain(level, 130); println!("после долгого дня: {level}%");}Вывод:
в начале: 80%после зарядки: 100%после долгого дня: 0%saturating_sub сам останавливается на 0. Для зарядки предел типа — 255, поэтому .min(100) добавляет сверху твой собственный предел.
2. Факториал, который знает свои пределы
Заголовок раздела «2. Факториал, который знает свои пределы»Напиши fn factorial(n: u64) -> Option<u64>, которая перемножает 1 × 2 × … × n и возвращает None, если ответ становится слишком большим для u64. Выведи результат для 5, 20 и 21. Подсказка: начни с let mut result: u64 = 1; и используй result.checked_mul(i)? внутри цикла for.
Показать решение
fn factorial(n: u64) -> Option<u64> { let mut result: u64 = 1; for i in 1..=n { result = result.checked_mul(i)?; } Some(result)}
fn main() { for n in [5, 20, 21] { match factorial(n) { Some(value) => println!("{n}! = {value}"), None => println!("{n}! слишком велик для u64"), } }}Вывод:
5! = 12020! = 243290200817664000021! слишком велик для u64? возвращает None из всей функции в тот момент, когда одно из умножений не помещается. 20! — наибольший факториал, который может хранить u64.
- У каждого целочисленного типа фиксированный диапазон, от
T::MINдоT::MAX. Выход за него — переполнение. - Отладочные сборки паникуют с
attempt to add with overflow. Release-сборки молча прокручивают значение. Переполнение, которое видит компилятор, — ошибка компиляции. - Выбирай поведение сам:
checked_даётOption,saturating_останавливается на краю,wrapping_прокручивает,overflowing_даёт значение иbool. asничего не проверяет и может незаметно изменить число. ИспользуйT::try_from, когда значение может не поместиться.- Будь внимателен с
len() - 1на пустых списках, суммами в маленьких типах,abs()отMINиpow.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Что произойдёт с 250u8 + 10 в отладочной сборке, если значения берутся из переменных?
2. Что вернёт 100u64.checked_sub(250)?
3. Полоска здоровья никогда не должна опускаться ниже 0. Какой метод подходит лучше всего?
4. Чему равно 300_i32 as u8?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Типы данных — книга «Язык программирования Rust»: в разделе о целочисленных типах есть официальное объяснение паники в debug, прокрутки в release и четырёх семейств методов (перевод на русский).
u8— документация стандартной библиотеки (на английском): все методыchecked_,saturating_,wrapping_иoverflowing_, у каждого — крошечный пример.i32— документация стандартной библиотеки (на английском): те же методы для чисел со знаком, а такжеchecked_abs,unsigned_absиchecked_pow.- Overflow — The Rust Reference (на английском): точные правила о том, какие операции могут переполниться и что тогда происходит.
- Приведение типов — Rust на примерах: примеры приведения через
as, которые прокручивают, обрезают и насыщают значения (перевод на русский).