Что такое точки следования (sequence points) и как они связаны с неопределённым поведением в C++?
Что такое точки следования (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.
Ответов пока нет.
Войдите чтобы оставить ответ
Блоги