Перейти к содержимому

Переполнение целых чисел в 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 до 255
i8: от -128 до 127
u16: от 0 до 65535
i32: от -2147483648 до 2147483647
u32: от 0 до 4294967295
i64: от -9223372036854775808 до 9223372036854775807
u64: от 0 до 18446744073709551615
u128: от 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 обращается с ним точно так же.

Когда ты запускаешь программу через 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 overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Попробуй сам

Поток main запаниковал в строке 2 с сообщением «попытка сложения с переполнением». Значения специально передаются через параметры функции. Если написать числа прямо рядом с +, компилятор заметит проблему ещё до запуска (это ты увидишь чуть ниже). Падение может показаться суровым, но это забота: ты находишь ошибку на своём компьютере, с точным номером строки, а не получаешь неправильный счёт в продакшене. Подробнее о панике — в уроке Паника.

Когда собираешь через cargo run --release, эти проверки отключаются, чтобы код работал чуть быстрее. Число прокручивается (wraps around), как одометр: 250 + 10 для u8 превращается в 4 (потому что 260 - 256 = 4).

Terminal window
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_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_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) прижимает значение к твоему собственному пределу.

Иногда прокрутка — именно то, что нужно. Счётчик талонов, который идёт 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_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, переполнение: true
60, переполнение: false
▶ Попробуй сам

Этим методом пользуются реже остальных. Если не знаешь, что выбрать, checked_ — отличный вариант по умолчанию.

Преобразование типов через 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 = 4294967295
300 не помещается в u8: out of range integral type conversion attempted
помещается: 200
▶ Попробуй сам

300 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 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.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, _> Преобразуешь значение, которое может не поместиться в меньший тип

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 overflow
note: 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.

Уровень заряда батареи — это 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) добавляет сверху твой собственный предел.

Напиши 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! = 120
20! = 2432902008176640000
21! слишком велик для 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