Что такое точки следования в C++ и при чем тут неопределенное поведение?

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Секретное оружие отладчика: что программисты 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 изменяется дважды:

  1. Постфиксный инкремент i++
  2. Префиксный инкремент ++i
  3. Присваивание 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, но

Блоги

Рекомендаций по книгам