Avatar ·

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

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

Что такое точки следования (sequence points)?

Точка следования — это момент выполнения программы, в котором гарантируется, что все побочные эффекты предыдущих вычислений уже завершены, и никакие побочные эффекты последующих вычислений ещё не начались. Простыми словами: это "контрольная точка", где состояние программы полностью определено.

В C++ (до C++11) точки следования определяли, когда можно безопасно изменять одну переменную несколько раз. В современном C++ (C++11 и новее) концепция была заменена на более строгую модель "до/после" (sequenced before/sequenced after), но для понимания UB старые правила всё ещё полезны.

Почему ваш пример вызывает UB?

Рассмотрим код:

int i = 0;
int result = i++ + i++;

Здесь i++ — это выражение, которое одновременно возвращает старое значение i и изменяет i (побочный эффект). Проблема в том, что между двумя вызовами i++ нет точки следования. Компилятор имеет право:

  • Вычислить первый i++ (прочитать 0, записать 1)
  • Потом второй i++ (прочитать 1, записать 2) → результат 0+1=1
  • Или наоборот: сначала второй, потом первый
  • Или даже прочитать оба значения до инкремента (0 и 0), а потом дважды записать 1 и 1 → результат 0+0=0

Компилятор может выбрать любой вариант, потому что стандарт не требует определённого порядка между побочными эффектами в одном выражении без точек следования. Это и есть неопределённое поведение.

Второй пример — ещё более коварный

int arr[5] = {0};
int i = 0;
arr[i] = i++;

Здесь две операции конкурируют: чтение i для индекса и изменение i через i++. Возможные варианты:

  • Сначала вычислить индекс (i=0), потом инкремент (i=1), потом запись в arr[0] = 0
  • Сначала инкремент (i=1), потом индекс (i=1), потом запись в arr[1] = 1

Результат может быть arr[0]=0 или arr[1]=1 — и то, и другое легально с точки зрения компилятора. Это UB.

Какие операции создают точки следования?

  • Точка с запятой ; — конец каждого выражения
  • Логические операторы &&, || — левая часть вычисляется до правой
  • Тернарный оператор ?: — условие вычисляется до выбора ветки
  • Оператор запятая , — левая часть вычисляется до правой
  • Вызов функции — все аргументы вычисляются до входа в функцию, но порядок вычисления аргументов друг относительно друга не определён

Как избежать UB?

Золотое правило: не изменяйте переменную более одного раза между двумя точками следования. Если нужно инкрементировать несколько раз — используйте отдельные выражения:

int i = 0;
int a = i++;  // a = 0, i = 1
int b = i++;  // b = 1, i = 2
int result = a + b;  // result = 1

Или современный C++ стиль — избегайте постфиксного инкремента в сложных выражениях:

int i = 0;
int result = i + (i + 1);  // явно, без UB
i += 2;

Для второго примера — разделите на две строки:

int idx = i;
arr[idx] = idx;
i++;

Или используйте префиксный инкремент, если порядок не важен:

arr[i] = ++i;  // тоже UB! Не делайте так!

Почему компилятор не предупреждает?

Многие современные компиляторы (GCC, Clang) предупреждают с флагом -Wsequence-point или -Wall. Например, GCC выдаст:

warning: operation on 'i' may be undefined

Но не все случаи UB можно обнаружить статически — компилятор не может отследить все возможные пути выполнения.

В C++11 и новее концепция точек следования заменена на "sequenced before" отношения, но суть осталась той же: если два побочных эффекта на одну переменную не упорядочены относительно друг друга — это UB. Всегда пишите код так, чтобы порядок операций был очевиден и однозначен.

<<>>

Дополнение: Современный C++ и модель памяти

В C++11 и C++14 концепция точек следования была заменена на более строгую модель "sequenced before". Это не просто переименование — новая модель лучше формализует отношения между выражениями.

Ключевое отличие: в современном C++ есть три типа отношений между вычислениями:

  • Sequenced before — одно вычисление гарантированно происходит до другого (например, в точке с запятой)
  • Sequenced after — обратное отношение
  • Unsequenced — порядок не определён (это и есть UB при изменении одной переменной)

Важно: даже в C++17 и C++20 некоторые операции остаются неупорядоченными. Например, порядок вычисления аргументов функции всё ещё не определён:

int f(int a, int b) { return a + b; }
int i = 0;
int result = f(i++, i++);  // Всё ещё UB!

Однако есть хорошие новости: начиная с C++17, порядок вычисления операндов операторов присваивания был изменён — теперь правая часть гарантированно вычисляется до левой. Но это не спасает от всех случаев UB.

Мой совет: если вы используете C++11 или новее, просто запомните правило — не изменяйте переменную более одного раза в одном выражении, если только эти изменения не разделены точкой следования. Для сложных случаев используйте std::atomic с соответствующими барьерами памяти, но это уже тема для отдельного обсуждения.

И главное: включайте все предупреждения компилятора (-Wall -Wextra -Wpedantic) и воспринимайте их серьёзно. Компилятор — ваш лучший друг в борьбе с UB.