Привет, Хабр!

for (auto& item : getConfig().sections()) — строчка живёт в проектах годами, и криминала в ней никто не видит. Потом она начинает падать: то после смены компилятора, то после включения оптимизации, то просто в пятницу под нагрузкой.

Отладочная сборка тут не спасает, скорее наоборот. На -O0 программа честно печатает мусор из освобождённой памяти, а на -O2 тело цикла у меня не исполнилось ни разу: сразу напечаталась строка после цикла, как будто списка и не было.

Правило, которое эта строчка нарушает, старше самого цикла. Временный объект живёт до конца полного выражения, где он создан, а инициализатор диапазона — полное выражение. getConfig() вернул временный объект, sections() вернула ссылку внутрь него, временный умер, ссылка осталась. В C++23 это починили, и с тех пор одна и та же строчка означает разное в зависимости от того, чем вы собираете.

Почему ссылка висит именно здесь

Цикл по диапазону разворачивается компилятором примерно в такой блок:

{
    auto&& __range = инициализатор_диапазона;
    auto __begin = begin(__range), __end = end(__range);
    for (; __begin != __end; ++__begin) {
        объявление = *__begin;
        тело;
    }
}

Всё решает первая строка. __range объявлен ссылкой. Если инициализатор дал временный объект, эта ссылка его продлевает: временное значение, привязанное к ссылке, живёт столько же, сколько сама ссылка. Поэтому цикл по функции, возвращающей контейнер по значению, всегда был безопасен.

Проверяем на объекте, который считает себя живым:

struct Vec {
    std::vector<int> d{7, 8, 9};
    Vec()  { ++alive; }
    ~Vec() { --alive; }
    auto begin() const { return d.begin(); }
    auto end()   const { return d.end(); }
};
Vec makeVec() { return Vec{}; }

for (int x : makeVec())
    printf("значение %d, объект-диапазон %s\n", x, alive ? "жив" : "УНИЧТОЖЕН");

На всех четырёх компиляторах и в обоих режимах стандарта объект жив все три итерации. А вот продление работает только для того временного объекта, который привязался к __range напрямую или через обращение к полю. Стоит вызвать функцию, и цепочка рвётся:

for (int x : make().data)      // продлевается, обращение к полю
for (int x : make().items())   // не продлевается, вызов метода вернул ссылку

Разница выглядит несерьёзной, а для компилятора это два совершенно разных выражения. В первом случае range привязывается к подобъекту временного объекта, и продление распространяется на весь объект. Во втором items() вернула const std::vector<int>&, компилятор видит только ссылку и не знает, откуда она пришла. Временный Holder умирает на точке с запятой, а begin и __end уже указывают в его вектор.

Вот что печатает программа, где деструктор говорит о себе:

  __cpp_range_based_for = 201603
  ~Holder
  тело цикла: 1506803744
  тело цикла: 5
  тело цикла: 94890286
  цикл закончился

Деструктор отработал раньше первой итерации. На месте десятки, двадцатки и тридцатки лежит содержимое чужого стека. Второе значение стабильно читается как 5 от запуска к запуску, и именно такая стабильность заставляет людей верить, что код исправен.

C++23 продлил жизнь всем временным в инициализаторе

Правку внесли три документа, P2644R1, следом уточняющий формулировки P2718R0 и дефект ядра CWG2659, а смысл всей этой работы сводится к одной фразе: все временные объекты, созданные в инициализаторе диапазона, живут до конца цикла, а не до конца инициализатора.

Продлевается и тот объект, ссылку на который вернули, и временные значения, переданные в вызовы аргументами:

const std::vector<int>& pick(const Holder& h) { return h.data; }

for (int x : pick(make())) ...

До C++23 такой код читает мусор, после — работает. Проверяется наличие правки макросом __cpp_range_based_for: значение 201603 означает старое поведение, 202211 — новое. Что получилось:

g++ 13.3    -std=c++20   201603      -std=c++23   201603
g++ 14.2    -std=c++20   201603      -std=c++23   201603
clang 18.1  -std=c++20   201603      -std=c++23   201603
clang 20.1  -std=c++20   201603      -std=c++23   202211

Одна комбинация из восьми ведёт себя по новым правилам. Остальные семь собирают тот же исходник и молча оставляют висячую ссылку, включая те, которым передали -std=c++23. Ни один не сказал, что в C++23 этот код означает другое: про сам режим и его несовпадение с кодом диагностики нет.

Заодно этот замер объясняет, почему жалобам «у нас всё работает» верить нельзя. Возьмём std::string_view над временной строкой, тоже классическую висячую ссылку до C++23:

for (char c : std::string_view(name()).substr(0, 4))

g++ 14.2 в режиме C++20 печатает корректные символы, clang 20.1 в том же режиме C++20 печатает мусор, clang 20.1 в режиме C++23 снова печатает корректные. Код одинаково неопределён в обоих случаях, а печатает разное потому, что кто-то успел затоптать освобождённый буфер до чтения, а кто-то не успел. Первый компилятор оставил строку лежать на месте, второй положил туда что-то своё. Право на это было у обоих.

Где правка уже есть, а где ещё нет

Разброс тут больше, чем обычно бывает у языковых правок. В Clang поддержка появилась в 19-й версии, а 19.1.0 вышел 17 сентября 2024 года. В GCC — только в 15-й, и это апрель 2025 года: GCC 15.1 выпущен 25 апреля. MSVC заявляет поддержку с 19.51.

Дальше начинается арифметика дистрибутивов. Ubuntu 24.04 по умолчанию даёт g++ 13. Из репозитория доступен 14. Правки нет ни в одном. То есть сборочный образ, который вы завели два года назад и с тех пор не трогали, почти наверняка собирает ваш код по старым правилам, сколько бы ни стояло в CMAKE_CXX_STANDARD.

GCC выпускает мажорную версию раз в год весной: 15.1 в апреле 2025, 16.1 — 30 апреля 2026. Стабилизирующие выпуски идут отдельно, 15.2 вышел 8 августа 2025, а 15.3 — уже 12 июня 2026, то есть ветка живёт долго и в неё продолжают попадать люди. Clang за это же время дошёл до 22.1.0, выпущенного 24 февраля 2026. Между двумя компиляторами получился разрыв примерно в 7 месяцев по этой конкретной правке, а между версией в дистрибутиве и версией в апстриме — года два.

В общем, полагаться надо не на номер стандарта в настройках проекта, а на макрос. Если код должен работать одинаково на всех сборочных агентах, проверку стоит сделать явной:

#if __cpp_range_based_for < 202211L
#  error "Компилятор без P2718R0, цикл по временному объекту тут небезопасен"
#endif

Цена правки: деструкторы переехали за цикл

Пока разговор шёл про код, который и так был сломан, правка выглядит чистым улучшением. Но временные объекты в инициализаторе бывают не только источниками данных. Иногда они что-то держат, и тогда продление их жизни меняет поведение кода, который был полностью корректен.

Вот пример, где висячих ссылок нет вовсе: диапазон — глобальный вектор, а временный объект в инициализаторе захватывает мьютекс.

std::mutex mx;
std::vector<int> data{1, 2, 3};

struct Guard {
    Guard()  { mx.lock();   puts("захватили мьютекс"); }
    ~Guard() { mx.unlock(); puts("отпустили мьютекс"); }
};

const std::vector<int>& pass(const Guard&, const std::vector<int>& v) { return v; }

for (int x : pass(Guard{}, data))
    printf("тело цикла %d, мьютекс %s\n", x,
           mx.try_lock() ? (mx.unlock(), "свободен") : "занят");

Один и тот же clang 20.1, разница только в -std:

-std=c++20                        -std=c++23
захватили мьютекс                 захватили мьютекс
отпустили мьютекс                 тело цикла 1, мьютекс занят
тело цикла 1, мьютекс свободен    тело цикла 2, мьютекс занят
тело цикла 2, мьютекс свободен    тело цикла 3, мьютекс занят
тело цикла 3, мьютекс свободен    отпустили мьютекс
после цикла                       после цикла

Оба варианта определены стандартом. Каждый соответствует своей версии языка, а поведение выходит противоположное. Проверил и крайний случай: если тело цикла само берёт этот мьютекс, а не пробует через try_lock, то на C++20 замок берётся все три итерации, а на C++23 процесс висит на первой и за 5 секунд не завершается.

Случай с мьютексом выдуманный. Та же схема с чем-то более обыденным встречается вполне часто. Временный объект в инициализаторе может держать соединение из пула, открытый файл, транзакцию, снимок состояния под блокировкой чтения. Раньше всё это освобождалось до входа в тело, теперь живёт до последней итерации.

Тут же становится понятно, почему компиляторы прячут правку за -std=c++23 и не включают её в старых режимах, хотя она чинит неопределённое поведение. Формально можно было бы объявить прежнюю трактовку дефектом и применить исправление ко всем режимам сразу. Но пример с мьютексом показывает, что правка меняет наблюдаемое поведение корректных программ, а не только сломанных. Компилятор не умеет отличить одно от другого: и там и тут он просто продлевает время жизни. Поэтому изменение привязали к версии стандарта, как и положено изменению языка, а не к номеру релиза, как исправление ошибки.

Правка кончается на границе инициализатора

Продление работает только внутри инициализатора диапазона. Стоит вынести временный объект в отдельную строку, и правка не применяется, потому что объекта в инициализаторе больше нет:

std::string_view sv = name();   // временная строка умирает здесь
for (char c : sv) ...           // читаем освобождённую память

Это висячая ссылка во всех версиях языка и во всех компиляторах. Правка C++23 её не касается. Макрос тут тоже не поможет.

Похожая история выходит с любым представлением, сохранённым в переменную, будь это std::span над временным вектором, std::string_view над результатом substr или итератор, взятый у временного контейнера, и общее у всех этих случаев одно: если временный объект не стоит прямо в инициализаторе цикла, продлевать его нечему.

Ну и к тому же пока часть агентов на GCC 14, а часть на GCC 15, один и тот же файл в одной ветке означает разные вещи, и падать он будет только на половине машин. Отлаживать такое чисто из-за того, что воспроизводимость зависит от того, куда попала задача.

Ловят это два компилятора, и каждый находит своё

Оба основных компилятора умеют предупреждать, и ловят они разное. GCC с 13-й версии выдаёт -Wdangling-reference, причём включён он прямо в -Wall:

warn.cpp:12:51: warning: possibly dangling reference to a temporary [-Wdangling-reference]
warn.cpp:12:50: note: the temporary was destroyed at the end of the full expression 'make()().Holder::items()'
warn.cpp:15:49: warning: possibly dangling reference to a temporary [-Wdangling-reference]
warn.cpp:15:42: note: the temporary was destroyed at the end of the full expression 'pick(make()())'

Обе проблемные строки найдены. Указано даже то самое полное выражение, на конце которого умер объект. А вот случай со string_view в переменной GCC пропускает даже с -Wall -Wextra.

Clang ведёт себя наоборот. Он молчит про оба вызова в цикле и находит именно то, что пропустил GCC, причём предупреждение работает без единого флага:

warn.cpp:13:47: warning: object backing the pointer will be destroyed
                at the end of the full-expression [-Wdangling-gsl]

Складывается это в то, что одного компилятора для проверки недостаточно. Если в CI собирается только GCC, стоит добавить проход clang хотя бы на анализ, и наоборот. А под сомнительный цикл надёжнее всего запустить сборку с -fsanitize=address. Она превращает молчаливое чтение чужого стека в отчёт про stack-use-after-scope, где верхние кадры уходят в stl_iterator.h, а нижние показывают вашу строку с циклом. Читать такой отчёт поначалу неудобно: ошибка формально происходит в конструкторе итератора из заголовка стандартной библиотеки. Смотреть надо на кадр main. Дальше на строку в конце отчёта, где указано, в чьём кадре лежал адрес.

Ещё GCC попутно выдаёт -Wdangling-pointer= из того же stl_iterator.h, с примечанием, указывающим на безымянный временный объект в вашем файле. Предупреждение верное, но выглядит как шум из стандартной библиотеки, и в проекте, где заголовки шумят и без того, его легко пролистать.

Мысль отдать это clang-tidy, наверное, приходит первой. Толку от неё нет. Прогнал набор bugprone-* и clang-analyzer-* по тому же файлу: нашлась одна строка, та самая со string_view, то есть про неё clang и сам предупреждает. Оба цикла с висячей ссылкой clang-tidy пропускает.

Зато сложение двух компиляторов даёт полное покрытие. На файле с тремя проблемными местами g++ 14 выдал 4 предупреждения по двум строкам, clang 20 — одно по третьей, а вместе они накрыли все три. Поэтому в CI стоит собирать обоими, хотя бы один из проходов на анализ, и складывать вывод.

Остаётся третий класс: циклы, где временный объект что-то держит и потому изменит поведение после обновления компилятора. Их не найти по предупреждениям, зато можно перечислить по форме через clang-query:

clang-query-18 -c='match cxxForRangeStmt(hasRangeInit(
    expr(hasDescendant(materializeTemporaryExpr()))))' файл.cpp -- -std=c++20

Запрос ловит все циклы, где в инициализаторе диапазона материализуется временный объект, то есть в точности то множество, чьё поведение зависит от __cpp_range_based_for, и на контрольном файле он выдал три совпадения из четырёх циклов, пропустив как раз тот, где диапазоном был обычный глобальный вектор. Вот эти три, сведённые коротко:

find.cpp:15 | for (int x : make().items())          опасно
find.cpp:16 | for (int x : byValue())               безопасно
find.cpp:18 | for (int x : pass(Guard{}, global))    поведение изменится

Временный объект сам и есть диапазон, как во второй строке, значит всё было безопасно и останется. Из него вытащили ссылку, как в первой, значит до C++23 там висячая ссылка. Он что-то держит, как в третьей, значит код был корректен, а после обновления станет держать до конца цикла.

Как с этим жить

Правка полезная, ей больше года. Толку от неё нет, пока не обновлён компилятор, а обновлённый меняет момент вызова деструкторов в коде, который никогда не был сломан.

Дешевле всего не полагаться на продление вовсе. Временный объект, из которого вы берёте диапазон, стоит просто назвать: одна лишняя строка с переменной делает время жизни явным и работает в любом стандарте и в любом компиляторе. Там, где такой переписи слишком много, помогает #error по макросу: пусть сборка падает на агенте со старым GCC, а не в проде.

Обновляться всё равно стоит. Перед переходом на GCC 15 или Clang 19 полезно поискать по проекту циклы, где в инициализаторе стоит конструктор объекта с нетривиальным деструктором, и посмотреть, что этот деструктор делает. Если он что-то отпускает, поведение изменится, и компилятор об этом не предупредит: с его точки зрения всё стало только правильнее.

Разобраться в тонкостях C++ проще на конкретных примерах — с разбором того, как язык управляет памятью, временем жизни объектов и выполнением кода. На бесплатных уроках можно посмотреть, как опытные разработчики подходят к сложным задачам: от организации взаимодействия потоков до переноса вычислений на этап компиляции.

  • 1 октября в 20:00. «Паттерн многопоточного программирования Producer — Consumer». Записаться

  • 20 октября в 20:00. «Магия чисел в C++: как заставить компилятор считать за нас». Записаться

Комментарии (2)


  1. nickolaym
    30.09.2026 17:23

    быстрофикс для 20 стандарта - запихать цикл в лямбду

    // было
    for (some elem : expression()) { ... }
    
    // стало
    [&](auto&& _r_) { for (some elem : _r_) { ... }; } (expression());

    Поскольку эта переделка выглядит как-то громоздко, с перестановкой фрагментов кода, то можно сделать так

    FOR(some elem, expression()) { ... } ; // нужна концевая ;
    
    // где макрос
    #define FOR(elem_decl, range_expr) forloop(range_expr) >> [&](elem_decl)
    
    // раскрывается в
    forloop(expression()) >> [&](some elem) { ... } ;
    
    // где клеевой объект
    template<class R> struct forloop {
      R r;
      void operator >> (auto&& body) {
        for (auto&& elem : r) body(std::forward<decltype(elem)>(elem));
      }
    };
    template<class R> forloop(R&&) -> forloop<R>;


  1. Sap_ru
    30.09.2026 17:23

    const std::vector<int>& pick(const Holder& h) { return h.data; }
    for (int x : pick(make())) ...

    А как они это реализуют в данном случае? Временный объект согдан внутри функции, которая ничего ни про какие циклы не знает, правильно?
    Тогда откуда компилятор в цикле узнает о временном поведении изменит поведение функции?
    Нет, я понимаю, что если функция короткая и включена оптимизация, то она будет заинлайнена и всё может сработать. Но в общем случае функция может быть же и в другом модуле. И при -O0 это тоже не должно работать.
    Вы уверены, что этот пример соотвествует стандарту? Там же только про вызовы непосредственно в выражениях цикла.