Ловим переполнение unsigned int: C++ детектив с нуля и до профи

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Ловушка для невнимательных: почему unsigned int — это бомба замедленного действия?

Каждый C++ разработчик рано или поздно сталкивается с ситуацией, когда числа ведут себя как злые кролики — бесконечно прыгают из плюса в ноль. Речь идет о переполнении unsigned int. В отличие от своего подписанного собрата (signed int), переполнение unsigned в C++ строго определено стандартом — это не UB (undefined behavior). Число просто оборачивается по модулю 2N. Но это не делает ошибку менее фатальной.

Представьте: вы считаете баланс на счете, количество элементов в массиве или время работы таймера. Одно случайное переполнение — и логика приложения рушится. Вопрос из заголовка "How do I detect unsigned integer overflow?" собрал тысячи просмотров на Stack Overflow не просто так. Давайте разберем три подхода к детекции переполнения: от наивного (но работающего) до профессионального.

Почему стандартные проверки не работают?

Первое, что приходит в голову новичку:

unsigned int a = UINT_MAX;
unsigned int b = 1;
if (a + b < a) // Наивная проверка
    std::cout << "Переполнение!";

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

Подход №1: Арифметика до операции (Pre-check)

Самый надежный способ — проверить возможность переполнения до выполнения операции. Для сложения двух беззнаковых чисел условие простое:

Если a + b может переполниться, то a > (UINT_MAX - b).

#include <climits>
#include <iostream>

bool will_add_overflow(unsigned int a, unsigned int b) {
    return a > UINT_MAX - b;
}

int main() {
    unsigned int x = 4000000000;
    unsigned int y = 500000000;
    
    if (will_add_overflow(x, y)) {
        std::cerr << "Ошибка: переполнение при сложении!\n";
    } else {
        std::cout << "Результат: " << x + y << "\n";
    }
    return 0;
}

Умножение — сложнее

Для умножения проверка чуть хитрее: a > UINT_MAX / b (и не забудьте про деление на ноль).

bool will_mult_overflow(unsigned int a, unsigned int b) {
    if (b == 0) return false; // Умножение на ноль — безопасно
    return a > UINT_MAX / b;
}

Плюсы: Кроссплатформенность, полный контроль, никаких скрытых зависимостей.
Минусы: Нужно писать отдельные функции для каждой операции. Код становится громоздким.

Подход №2: Проверка после операции (Post-check)

Стандарт C++ гарантирует, что для unsigned типов результат операции — это (a + b) mod 2^N. Поэтому мы можем выполнить операцию, а потом проверить, не "обернулось" ли значение.

#include <iostream>
#include <limits>

template<typename T>
bool add_overflow(T a, T b, T& result) {
    result = a + b;
    return result < a; // Ключевой трюк
}

int main() {
    unsigned int res;
    if (add_overflow(4000000000u, 500000000u, res)) {
        std::cout << "Переполнение зафиксировано!\n";
    } else {
        std::cout << "OK: " << res << "\n";
    }
    return 0;
}

Важно: Этот трюк работает, только если a и b одного беззнакового типа. Для uint32_t — отлично. Для смеси unsigned int и int — провал.

Ловушка для вычитания

Для вычитания условие обратное: result > a.

template<typename T>
bool sub_overflow(T a, T b, T& result) {
    result = a - b;
    return result > a;
}

Плюсы: Простота, один проход.
Минусы: Не сработает для умножения (там нужно проверять до, либо использовать больший тип). Также есть риск, что оптимизатор сломает логику при использовании volatile или constexpr.

Подход №3: Современный C++ — <limits> и встроенные функции компилятора

Начиная с C++11, мы можем использовать std::numeric_limits для написания универсальных шаблонов. Но настоящий прорыв — это built-in функции компилятора. Они работают на уровне машинных инструкций и дают максимальную производительность.

GCC и Clang: __builtin_add_overflow

Эти компиляторы предоставляют семейство функций:

  • __builtin_add_overflow(a, b, &result)
  • __builtin_sub_overflow(a, b, &result)
  • __builtin_mul_overflow(a, b, &result)

Они возвращают bool (true, если переполнение произошло) и работают с любыми целочисленными типами, включая signed.

#include <iostream>
#include <cstdint>

int main() {
    uint32_t a = 4000000000;
    uint32_t b = 500000000;
    uint32_t result;
    
    if (__builtin_add_overflow(a, b, &result)) {
        std::cerr << "Переполнение!\n";
    } else {
        std::cout << "Результат: " << result << "\n";
    }
    return 0;
}

MSVC: SafeInt или _addcarry_u32

В Visual Studio есть свои интринсики, но для кроссплатформенности рекомендую библиотеку SafeInt (от Microsoft, открытый исходный код). Она делает ровно то же самое, но с красивым C++ интерфейсом.

#include <SafeInt.hpp>

int main() {
    int x = 2147483647;
    int y = 1;
    try {
        int result = SafeInt<int>(x) + y; // Бросит исключение при переполнении
    } catch (const SafeIntException& e) {
        std::cerr << "SafeInt поймал переполнение!\n";
    }
    return 0;
}

Блоги

Рекомендаций по книгам