Привет, меня зовут Алексей и я разработчик программного обеспечения с почти 30-летним стажем. Иногда, по случаю, веду уроки программирования в 5 классе собственной частной школы — и, признаться, это один из немногих видов деятельности, где опыт не всегда спасает от сюрпризов.

Предыстория: программа для объяснения детям деления на ноль.

Задача была простой и педагогически понятной. Нужно показать детям, что деление на ноль — это ошибка. Не абстрактное правило из учебника, а настоящая, живая ошибка, которую компьютер выдаёт прямо на экране. Программа должна была быть элементарной:

#include <iostream>
int main()
{
    int x;
    std::cin >> x;
    std::cout << x / x << std::endl;
}

Ожидаемый результат: падение программы при введении 0 с ошибкой времени выполнения. Дети видят — компьютер не может делить на ноль, программа аварийно завершается. Всё наглядно.

Фактический результат:

1

Программа спокойно вывела 1 и завершилась без единой ошибки. Вместо наглядного краша — тихий и совершенно неправильный ответ. Удивлены были не только лишь дети… Имея почти 30 лет стажа программирования за спиной, все что я смог сказать в слух - ой, а про себя - "какого $$$??" “Почему???”


Что сделал компилятор

На первый взгляд кажется, что это баг компилятора или хитрая оптимизация, которую можно отключить флагом -O0. Но -O0 не помог. Дело не в оптимизации в привычном смысле слова.

Компилятор применил константную свёртку (constant folding) — одно из базовых преобразований, которое работает даже на нулевом уровне оптимизации. Он увидел выражение x / x, где x — целочисленная переменная, и… сократил.

С точки зрения компилятора рассуждение выглядит так:

Выражение x / x — это «любое число, делённое на себя». В математике это равно 1. Подставим 1.

Деления в сгенерированном машинном коде нет вообще. Есть просто константа 1.

Примечательно, что это поведение не универсально для всех компиляторов. MSVC при той же программе генерирует исключение — то самое ожидаемое падение с ошибкой времени выполнения. GCC же молча выдаёт 1. Оба компилятора формально правы: стандарт не предписывает конкретного поведения при UB(а деление на ноль стандартом С++ определено как Undefined Behavior), и каждый волен поступать по-своему.


Математическая ошибка в рассуждении компилятора

И вот здесь компилятор воспроизвёл классическую математическую ошибку, которую делают школьники — и за которую снижают оценки.

В алгебре существует фундаментальное правило:

Сокращение x в числителе и знаменателе допустимо тогда и только тогда, когда явно оговорено, что x ≠ 0.

Это не формальность и не бюрократия. В детстве, я много раз возмущался - за что мне снизили оценку?! Но нет, это граница применимости алгебраического тождества. Запись x/x = 1 — это не универсальный закон природы, это утверждение, справедливое только при условии x ≠ 0.

Классический пример нарушения этого правила - школьное «доказательства» того, что 1 = 2:

Пусть a = b
Тогда a² = ab
a² - b² = ab - b²
(a-b)(a+b) = b(a-b)
a+b = b          ← здесь разделили обе части на (a-b)
b+b = b          ← подставили a = b
2b = b
2 = 1            ← ???

Ошибка — в шаге деления на (a-b). Поскольку a = b, то (a-b) = 0, и деление на ноль маскируется под законное алгебраическое сокращение. Результат — математически абсурдный вывод.

Компилятор сделал ровно то же самое: сократил x/x, не проверив, что x ≠ 0.


Undefined Behavior и почему компилятор «имеет право»

Строго говоря, компилятор C++ не нарушил стандарт. Деление целого числа на ноль — это Undefined Behavior (UB, неопределённое поведение). Стандарт C++ не предписывает, что должно произойти в этом случае. Компилятор вправе:

  • сгенерировать инструкцию деления (→ SIGFPE на x86);

  • выкинуть всё выражение;

  • подставить произвольную константу;

  • вообще удалить ветку кода, считая её недостижимой.

Компилятор рассуждает по принципу «UB никогда не происходит»: раз деление на ноль — это UB, значит компилятор вправе считать, что x никогда не равен нулю. А раз x ≠ 0, то x/x = 1. Логика замкнулась.

Это не баг — это архитектурное следствие того, как устроена модель UB в C++. MSVC выбрал первый путь из списка выше — сгенерировал честную инструкцию деления, получил аппаратное исключение и бросил его наверх. GCC выбрал путь константной свёртки. Оба решения укладываются в стандарт.


Как воспроизвести честный краш

Рабочий способ — volatile, запрещающий компилятору делать предположения о значении переменной:

#include <iostream>
int main()
{
    int tmp;
    std::cin >> tmp;
    volatile int x = tmp;
    std::cout << x / x << std::endl;
}

История с учебной программой — хорошая иллюстрация сразу трех уроков.

Для преподователя: Проверяй программы перед тем как дать их детям!

Для детей, урок обернувшийся провалом: делить на ноль нельзя — компьютер это подтверждает, если разработчик компилятора выбрал строго математический путь.

Урок для разработчика: компилятор применяет математические тождества без проверки областей применимости — ровно как невнимательный ученик. Разница в том, что компилятор делает это молча, быстро и с полного одобрения стандарта. Undefined Behavior — это не «что-то пойдёт не так». Это «что-то пойдёт не так, а как - не оговорено, и с высокой долей вероятности ты об этом даже не узнаешь».

Но здесь стоит задать неудобный вопрос: а правильно ли сам стандарт обработал эту ситуацию? Категория UB даёт компилятору карт-бланш — делай что хочешь, ты не нарушаешь стандарт. Это удобно для авторов компиляторов и открывает возможности для агрессивных оптимизаций. Но с точки зрения математики и здравого смысла — деление на ноль это не «неопределённое поведение», это определённо неверная операция, которая должна детерминированно сигнализировать об ошибке. MSVC, генерируя исключение, ведёт себя математически честнее, gcc оставаясь в рамках стандарта приводит к абсолютно ошибчному поведению.

Возможно, именно здесь кроется более глубокая проблема: стандарт C++ в ряде мест оптимизирован под производительность компилятора, а не под предсказуемость поведения программы. И пока это так — программист обязан знать границы UB наизусть, потому что компилятор за него думать не будет. Но именно тут, как мне кажется, компилятор мог бы стать помощником, а не сущностью плодящей ошибки.

Призываю всех не равнодушных в комментрии для обсуждения.

UPD: Для тех кто пишет о странности примера, а именно x/x - одной из целей дать детям такое задание, было именно желание услышать от них предложение сделать оптимизацию - x/x =1 и отказать им в этом, сославшись на то, что x может быть нулем, и предложить им проверить этот случай, и один ученик именно это и предложил, тем самым кратно умножив степень моего удивления после полученного результата.