Что такое точки следования (sequence points) и как они связаны с неопределённым поведением в C++?
Что такое точки следования?
Точки следования (sequence points) — это моменты в выполнении программы, когда все побочные эффекты предыдущих вычислений гарантированно завершены, а последующие ещё не начались. Простыми словами: это "контрольные точки", где состояние программы определено однозначно.
В C++ (до C++11) и в C существовали такие точки следования:
- После каждого полного выражения (точка с запятой);
- После логических операторов && и ||;
- После тернарного оператора ?:;
- После оператора запятая (не путать с аргументами функции);
- В конце инициализатора в цикле for.
Начиная с C++11, концепция изменилась на "отношения sequenced before / sequenced after", но суть осталась та же.
Почему ваш код вызывает неопределённое поведение?
Рассмотрим пример:
int i = 0;
int result = i++ + i++;
Здесь i++ — это постфиксный инкремент. Он возвращает старое значение i, но при этом изменяет i (побочный эффект). Проблема в том, что между двумя вычислениями i++ нет точки следования. Компилятор может:
- Сначала вычислить левый
i++(вернуть 0, i=1), потом правый (вернуть 1, i=2) → результат 1; - Сначала вычислить правый (вернуть 0, i=1), потом левый (вернуть 1, i=2) → результат 1;
- Или вообще сделать что-то другое (например, сохранить оба значения до инкремента, а потом применить оба инкремента) → результат 0.
Любой из этих вариантов формально допустим, так как стандарт не определяет порядок. Более того, сам факт изменения одной переменной дважды между точками следования — это неопределённое поведение.
Как избежать неопределённого поведения?
Главное правило: не изменяйте переменную более одного раза между двумя точками следования. Или, проще: не используйте переменную с побочным эффектом (инкремент/декремент/присваивание) дважды в одном выражении.
Неправильно (неопределённое поведение):
i = i++ + 1; // Изменение i + чтение i + изменение i
arr[i] = i++; // Чтение i + изменение i (порядок не определён)
func(i++, i++); // Порядок вычисления аргументов не определён
Правильно (определённое поведение):
i++; // Одно изменение
j = i + 1; // Только чтение
int a = i++; // Одно изменение, одно присваивание (но не чтение того же i)
// Или используйте отдельные операции:
int temp = i;
i++;
int result = temp + i;
Исключения: безопасные конструкции
Некоторые операторы гарантируют порядок вычислений:
&&и||— левая часть вычисляется первой, правая — только если нужно;?:— сначала условие, затем только одна из ветвей;a, b(оператор запятая) — сначала a, потом b.
Пример безопасного кода:
int i = 0;
if (i++ && i) { } // Безопасно: левая часть (i++) вычисляется первой
Начиная с C++17, многие случаи стали определены лучше (например, порядок вычисления постфиксного выражения перед аргументами функции), но правило "не изменяйте одну переменную дважды в одном выражении" остаётся золотым стандартом.
<<Дополнение: как это работает в C++11 и новее
В C++11 концепцию "точек следования" заменили на отношения sequenced before (упорядочено до) и sequenced after (упорядочено после). Если два действия не связаны этими отношениями, их порядок не определён, и это может привести к неопределённому поведению.
Практически это означает то же самое, но с более строгой формализацией. Например:
int x = 0;
x = x++; // C++17: теперь это определённое поведение!
// Сначала вычисляется x++ (возвращает 0, x становится 1),
// потом присваивание (x = 0).
В C++17 для постфиксного инкремента/декремента гарантируется, что вычисление значения происходит до присваивания. Однако это не значит, что можно писать любой код:
int i = 0;
i = i++ + i; // Всё ещё неопределённое поведение! (даже в C++17)
Почему? Потому что в правой части i читается дважды, а i++ изменяет i — порядок этих операций не определён.
Совет: Вместо того чтобы запоминать все тонкие правила, просто пишите код так, чтобы каждое изменение переменной происходило в отдельном выражении. Это сделает код понятнее и безопаснее. Используйте std::exchange или временные переменные для сложных операций.
Ответов пока нет.
Войдите чтобы оставить ответ