Секретное оружие отладчика: что программисты C++ упускают из виду
Вы когда-нибудь писали что-то вроде i = i++ + ++i; и получали разные результаты на разных компиляторах? Или ловили странный баг, который воспроизводился только на сервере, но не на вашем ноутбуке? Знакомьтесь: undefined behavior (неопределенное поведение) — один из самых коварных врагов C++ разработчика. А в центре этого хаоса стоят точки следования (sequence points).
В этой статье мы разберем, что такое sequence points, как они связаны с порядком вычислений в C++, и — самое главное — как не наступать на эти грабли. Материал основан на культовом вопросе с Stack Overflow, который набрал тысячи просмотров. Поехали!
Что такое точки следования (sequence points)?
Представьте, что вы дирижер оркестра. Каждый музыкант (инструкция процессора) должен вступить в строго определенный момент. Точка следования — это момент времени, когда все предыдущие «побочные эффекты» (изменения переменных, вызовы функций) уже гарантированно завершены, а следующие — еще не начались.
В C++ стандарт 98/03 использовал термин sequence point. В C++11 его заменили на более строгие «отношения sequenced before / sequenced after», но суть осталась: между двумя точками следования компилятор имеет полное право переставлять операции местами, если это не меняет результат (согласно правилу «as-if»).
Проще говоря: если вы пытаетесь изменить одну и ту же переменную дважды между двумя точками следования — вы получаете undefined behavior. Компилятор может сделать что угодно: от корректного результата до форматирования вашего жесткого диска (шутка, но почти правда).
Классические примеры точек следования
- Точка с запятой
;— конец выражения - Запятая
,как оператор (но не в аргументах функции!) - Конец каждого выражения в
for(после инициализации, проверки и шага) - Логические операторы
&&и|| - Тернарный оператор
?: - Вызов функции (перед тем, как тело функции начнет выполняться)
Как точки следования порождают неопределенное поведение?
Вот классический пример, который должен знать каждый C++ разработчик:
int i = 0;
i = i++ + ++i; // UB! Неопределенное поведение
Почему это UB? Потому что между двумя точками следования (в данном случае — между началом и концом выражения) переменная i изменяется дважды:
- Постфиксный инкремент
i++ - Префиксный инкремент
++i - Присваивание
i = ...
Компилятор не знает, в каком порядке выполнять эти операции. Результат? Один компилятор выдаст 2, другой — 3, третий — 0. А в режиме оптимизации может вообще выкинуть этот код.
Еще один хитрый случай: аргументы функции
int foo(int a, int b) { return a + b; }
int x = 5;
int result = foo(++x, ++x); // UB!
Здесь запятая между аргументами — это не оператор запятая, а разделитель. Точки следования между вычислением аргументов ++x нет! Оба инкремента выполняются «параллельно» (с точки зрения стандарта), и мы снова получаем undefined behavior.
Практические примеры: как выглядит UB в реальном коде
Пример 1: Неочевидный баг с индексацией
int arr[5] = {0, 10, 20, 30, 40};
int idx = 1;
arr[idx] = idx++; // UB!
На первый взгляд все просто: хотим записать 1 в arr[1], а потом увеличить idx. Но стандарт не гарантирует порядок вычисления индекса idx и правой части idx++. Результат? Может записаться в arr[1], а может в arr[2].
Пример 2: Сложный вызов функции
int g = 0;
int func1() { return g++; }
int func2() { return g += 5; }
int result = func1() + func2(); // Неопределенный порядок вызова!
Хотя + — это точка следования для операндов, порядок вызова func1() и func2() не определен. Если func1() вызовется первой, g станет 1, потом func2() сделает g = 6, результат = 7. Если наоборот — сначала func2() (g=5), потом func1() (g=6), результат = 11. Оба варианта корректны с точки зрения стандарта!
Как избежать неопределенного поведения?
Хорошая новость: современные компиляторы (GCC, Clang, MSVC) с флагами -Wall -Wextra предупредят вас о большинстве таких случаев. Но лучше следовать простым правилам:
- Не изменяйте переменную более одного раза между двумя точками следования.
- Не читайте и не пишите одну переменную в одном выражении, если чтение не используется для вычисления нового значения (например,
i = i + 1— ок, аi = i++— нет). - Используйте отдельные операторы вместо сложных выражений с побочными эффектами.
- Включайте предупреждения компилятора и воспринимайте их серьезно.
Правильный код: как должно быть
// Вместо: i = i++ + ++i;
int i = 0;
int temp1 = i++; // Шаг 1
int temp2 = ++i; // Шаг 2
i = temp1 + temp2; // Шаг 3 — все четко
// Вместо: foo(++x, ++x);
int x = 5;
int a = ++x;
int b = ++x;
int result = foo(a, b);
Что изменилось в C++11 и новее?
В C++11 концепция sequence points была заменена на более формальные sequenced before / sequenced after отношения. Практически это означает:
- Постфиксный инкремент
x++теперь sequenced before присваивания вx = x++? Нет, все равно UB. - Добавились новые правила для выражений с запятой — теперь левая часть всегда sequenced before правой.
- Появились атомарные операции и memory ordering, но