Привет, меня зовут Алексей и я разработчик программного обеспечения с почти 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 может быть нулем, и предложить им проверить этот случай, и один ученик именно это и предложил, тем самым кратно умножив степень моего удивления после полученного результата.
