Avatar ·

Что такое точки следования (sequence points) и как они связаны с неопределённым поведением в C++?

Всем привет! Я начал изучать C++ и столкнулся с очень странной ситуацией. Пишу простой код: ```cpp int i = 0; int result = i++ + i++; ``` Я ожидал, что result будет равен 1 (0 + 1), но компилятор выдаёт то 0, то 1, то вообще какое-то другое число. А если написать `i = i++ + 1`, то результат вообще непредсказуемый. Мне сказали, что это связано с "точками следования" (sequence points) и "неопределённым поведением" (undefined behavior). Но я не понимаю, что это такое. Можете объяснить простыми словами? Почему компилятор не может просто выполнить операции слева направо? И как понять, в каких случаях код безопасен, а в каких — нет? Приведите, пожалуйста, примеры правильного и неправильного кода. <<>>

Что такое точки следования?

Точки следования (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 или временные переменные для сложных операций.