
В прошлой статье (Непослушный using ) я разобрал, как using вмешивается в поиск имён и почему его поведение часто расходится с тем, что от него ждет программист, и на этом ветку статей про поиск имен (name lookup) можно временно закрыть.
Using'и попортят вам в проектах еще немало крови, но в целом их проблемы известны и легко ловятся, а теперь давайте поговорим обauto и выводе типов в шаблонах, который регулярно удивляет даже опытных программистов на C++, когда речь идёт о распаде типов (type decay) и неявных ловушках при работе с ним. Представьте, что у нас есть несколько переменных, которые выглядят разными: const int&, просто int, const int и int&&.
По наивной интуиции можно подумать, что компилятор должен относиться к ним по‑разному: и где‑то ссылка, где‑то константность, где‑то rvalue, но это будет работать только до тех пор, пока мы не используем auto или шаблон.
Потихоньку дописываю книгу, в целом каркас уже сложился и большая часть материала уже собрана и обработана в черновике, если вам интересно, что получилось, то главы выложены на гитхабе в русском и английском вариантах или тут, если вам больше нравится в виде статей, а то, что ещё дописывается уже больше шлифовка, чем поиск нужной формы. Оглавление и ссылки будут под катом.
Будущее оглавление


Overloads (Перегрузки) https://habr.com/ru/articles/980246/
Ограничения https://habr.com/ru/articles/981042/
История концептов https://habr.com/ru/articles/983706/
Иерархия концептов https://habr.com/ru/articles/985688/
И снова ограничения https://habr.com/ru/articles/988018/
Обобщения (ч.1) https://habr.com/ru/articles/1007998/
Обобщения (ч.2) https://habr.com/ru/articles/1011012/
Важны ли компилятору имена https://habr.com/ru/articles/990816/
Ночью все кошки серы, а using'и одинаковы https://habr.com/ru/articles/1015492/
Компиляторы тоже путаются в именах https://habr.com/ru/articles/1023334/
Простой поиск имён в C++ сложный https://habr.com/ru/articles/1032114/
Непослушный using https://habr.com/ru/articles/1036104/
template <typename T> void show() { // прим. для MSVC: заменить макрос на __FUNCSIG__ std::cout << __PRETTY_FUNCTION__ << '\n'; } template <typename T> void probe(T x) { show<T>(); } int main() { int i = 10; const int& A = i; // const int& int B = i; // int const int C = i; // const int int&& D = 20; // int&& probe(A); probe(B); probe(C); probe(D); auto a = A; auto b = B; auto c = C; auto d = D; show<decltype(a)>(); show<decltype(b)>(); show<decltype(c)>(); show<decltype(d)>(); }
Если вы скомпилируете и запустите этот код, вывод (в упрощённом виде) будет выглядеть примерно так:
T = int T = int T = int T = int decltype(a) = int decltype(b) = int decltype(c) = int decltype(d) = int
Переменные A, B, C и D действительно имеют разные типы, и это реальные различия, которые влияют на время жизни объектов, на возможность модификации, на правила привязки ссылок. Всё это правда… пока вы работаете с ними напрямую, то вы даете компилятору четкие указания (в контексте) как работать с этими сущностями, но как только вы передаёте их в шаблонный параметр по значению T x или используете auto без уточнений, включается механизм стандартного вывода типов, который удаляет ссылки и отбрасывает верхнеуровневые const и volatile квалификаторы, что приводит нас к общему типу int во всех четырёх случаях.
Тут надо подчеркнуть, что это не особенность auto, а фундаментальное правило, существующее со времён шаблонов еще в раннем C++, и auto просто использует этот механизм в явном виде, регулярно ставя разработчика в тупик.
template <typename T> void foo(const T& x);
Может показаться, что эта функция всегда принимает «константную ссылку на неконстантное значение», мол, объект у меня обычный, изменяемый, я просто обещаю его не трогать и избегаю копии.
const T& это уже не «голый» T, а уточнённый тип‑паттерн и вывод типов здесь не «очищает» аргумент до value type так же, как в probe(T x), но и обратная крайность «раз аргумент был const, то T станет const int» тоже неверна, сложно... понимаю.
Если у нас есть
const int& a = /* ... */; foo(a);
то T будет выведен как int, а параметр функции станет обычным const int&. Никакого const const int& здесь не возникает и const аргумента закрывается тем const, который уже записан в паттерне параметра. Проверим рядом соседний паттерн уже без const на ссылке:
template <typename T> void bar(T& x); const int& a = /* ... */; bar(a); // T = const int, параметр: const int&
Вот здесь const уже некуда «пристроить» снаружи, и он оказывается внутри T. Разница тонкая, но она ломает интуицию разработчика, заставляя читать const T& как «ссылка на точно константный T», а язык читает это как «подобрать такой T, чтобы выражение const T& стало компилируемым».
Это поняли очень давно, и довольно пространное, но очень емкое объяснение вы можете встретить на лекциях Александреску, когда он жонглирует шаблонами:
Sh… happens because the moment the parameter type becomes qualified, template argument deduction stops behaving like a cleanup pass and starts behaving like template completion with holes. As long as the parameter is just T, deduction removes things: references disappear, top‑level const and volatile are stripped away. The goal is to recover a plain value type, but the instant the parameter is written as a refined type — for example const T& — deduction no longer erases information. Instead, the compiler treats the parameter as a type pattern with missing pieces and tries to make it identical to the argument type.
If the argument carries a const qualifier and the pattern contains a place where that qualifier can fit, it will be placed there. Nothing is discarded. Conversely, if the pattern already contains a qualifier and the argument does not, that qualifier may be added in order to make the two types match. At this point deduction is no longer subtractive. It becomes conservative and propagative. Qualifiers are preserved, nested, and forwarded inward rather than erased.
This is why const T& does not mean “a reference to a non‑const object that I promise not to modify”. It means “whatever type makes this expression well‑formed”. Once you understand deduction as unifying two type shapes by filling in the blanks, the behavior stops being surprising. It becomes inevitable.
Если выкинуть приколы и шуточки Александреску и упростить этот текст, то получится примерно следующее:
«это происходит потому, что как только тип параметра уточняется, вывод типов начинает работать как заполнение шаблона с „пустыми местами“, и если аргумент содержит const, а в шаблоне есть позиция, куда его можно подставить, он туда подставляется. Если квалификатор есть в шаблоне, но отсутствует в аргументе, он может быть добавлен, и в результате вывод типов становится не „очисткой“, а сохранением и распространением квалификаторов». И тут у многих ломается интуиция про шаблоны и auto в C++.
Допустим:
const int& a = 42; // a : const int& template <typename T> void foo(const T& x);
Паттерн параметра:
const [ T ] &
Тип аргумента (после привычных для deduction правил работы со ссылочным выражением):
const int
Совмещаем формы:
Параметр: const T & Аргумент: const int (lvalue) ^ | подставить сюда T = int
Подстановка:
const T& → const int&
Не T = const int и не const const int&, для сравнения тот же аргумент и T&:
Параметр: T & Аргумент: const int ^ T = const int → параметр const int&
А вот volatile в const T& уже может уйти внутрь T, потому что в паттерне нет готового volatile:
const T& → const volatile int&
Для сравнения смотрим шаблон с очисткой:
template <typename T> void baz(T x); baz(a); Аргумент: const int& ↓ убрать ссылку const int ↓ убрать top-level const int T = int, параметр: x : int
И во что это все превращается в итоге:
1) T x → очистка ------------------------------- const int& → T = int 2) const T& x → сопоставление формы ------------------------------- const int& → T = int, параметр const int& (const аргумента закрыт const паттерна) 3) T& x → сопоставление формы ------------------------------- const int& → T = const int, параметр const int& (const ушёл внутрь T)
Это все пока звучит очень запутанно, но сводится к тому, что пока T используется напрямую, компилятор очищает тип, но как только T становится частью более сложного типа (const T&, T&&, std::vector<T>), вывод типов превращается в сопоставление формы, а не в очистку и наша «константная ссылка на неконстантное значение» будет ссылка на что угодно, и все CV‑квалификаторы аргумента будут сохранены и протащены внутрь T. Но «сопоставление» не значит «всегда протащить все cv внутрь T» а только, «заполнить дырки так, чтобы паттерн скомпилировался».
Что приводит нас к очень странному поведению и коварным ошибкам при проектировании интерфейсов, если вы по привычке «пишете const T& где надо и где не надо». Многие делают это автоматически, потому что словили разные баги в детстве и считают, что таким образом избегают копирования и работают с обычным, изменяемым объектом, но это не так.
Когда вы пишете параметр вида const T&, вы не запрещаете передавать не const‑объекты, а наоборот, вы разрешаете их и, более того, если аргумент уже был const, этот const не исчезнет, а будет сохранён и протащен внутрь T. В результате функция может внезапно начать работать с объектами, которые «действительно» нельзя изменять, даже если автор функции этого не ожидал, что может полностью изменить смысл функции.
struct GpuMemory { mutable bool cache_ready = false; mutable int cache = 0; bool cache_initialized() const { return cache_ready; } void build_cache() const { cache = 42; cache_ready = true; } int& get_cache() const { return cache; } }; template <typename T> auto& get_cache(const T& obj) { if (!obj.cache_initialized()) { obj.build_cache(); } return obj.get_cache(); } int main() { GpuMemory f; << этот объект изменяем, мы его создали в обычной памяти int a = get_cache(f); // работает const GpuMemory cf; << этот объект константен и лежит в памяти видеокарты, << обращение и измениение его в лучшем случаем даст артифакт на экране << в худшем скрашит игру return get_cache(cf); // и это работает }
Что реально происходит при выводе:
Аргумент: GpuMemory / const GpuMemory Параметр: const T& Вывод: T = GpuMemory / T = GpuMemory Итог: const GpuMemory& / const GpuMemory&
const НЕ оказывается внутри T и сигнатура после подстановки в обоих случаях будет работой с const GruMemory&, и если автор хотел «не копировать», то получил интерфейс, который одинаково рад и обычному, и «настоящему» const‑объекту. Если же внутри типа есть mutable (или ещё хуже const_cast), то функция начнёт менять состояние даже у объекта, который с точки зрения железа трогать нельзя, например там сторит read_only флаг и память пейджирована. Это не ошибка компилятора и не «баг шаблонов», потому что сам язык разрешает это делать, но смысл интерфейса при этом нарушен.
Странице памяти можно явно права доступа (gpu[cpu] read/write, read‑only, no‑access). Запись в cpu read‑only страницу, это больше не про «логический const C++», а нарушение защиты памяти, который ведет к page fault и часто крашу. А для страниц, которые ещё и разделяется с GPU, получим затирание данных или полуразрушенную структуру, которая дает краш в другом потоке или вообще через несколько кадров без понятного стека.
Пейджированая память довольно частое дело на консолях, но мы своим поведением сломали эту гарантию и в лучшем случае получими артефакты на экране, в худшем будет краш игры. Если вы думаете, что это выдуманная проблема, то вот вам статистика таких багов в игре Deathloop, за 4 года разработки игры таких протечек, не только по gpu объектам, было выловлено больше двухсот (200+!) мест. Т.е. каждый месяц делали как минимум один баг этого типа, который приводил либо к крашу, либо к другим ошибкам, и это только выловленные места.
Важно не перепутать диагноз, поведение здесь ломает не «засунул const в T», а связка «const& принимает всё const‑совместимое» + мутация через mutable/дыры в const‑модели«. Тот же баг получится и без шаблонов, просто шаблон лишь делает привычку const T& ещё менее осознанной и кажется, что вы «контролируете T», а на деле вы подписались на самый широкий const‑совместимый контракт.»
В большинстве реальных багов все это прячется еще глубже, уходя куда‑то в глубины STL, и в лучшем случае вы получите лишние копии и шум в профайлере, а в худшем редкие артефакты и «неповторяемое» поведение неписей. А все существующие виды анализаторы такое ловят плохо, и на данный момент это ловится только «глазками», «ручками» и «ж.....».
struct Vec3 { float x, y, z; }; float length(const Vec3& v) { return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); } template <typename T> void normalize(T& v) { auto n = v; const float len = length(n); n.x /= len; n.y /= len; n.z /= len; assert(std::abs(length(n) - 1.f) < 1e-5f); } ... // update Vec3 aim { 0.f, 0.f, 30.f }; // куда смотрит непись normalize(aim);
Функция называется normalize, вызывается там, где надо и компилируется без единого варнинга и не делает... ничего. Самое обидное, что assert внутри неё тоже честно отрабатывает, ведь копию-то мы нормализовали, и такие вещи не ловятся ни тестами на саму функцию, ни ассертами внутри неё, а репортами QA «неписи мажут, на большой дистанции». Здесь вы видите причину, потому что весь код рядом, но в реальном проекте эта функция погребена под сотнями других, и придавлена сверху логикой поведения. Так что да, мажут... вот из-за таких мелочей. Лечится это все одной строчкой:
auto& n = v; // работаем с оригиналом decltype(auto) n = v; // сохранить ровно то, что пришло // или не заводить n вообще и трогать v напрямую
Еще в дикой природе то же самое встречается вот так:
for (auto e : entities) // копия, а не сущность e.hp -= damage;
Тот же auto и опять без &, тот же результат, урон уходит в копии.
Т.е. это та же история, что и с шаблонным decay в начале главы и auto не «умный вывод намерений», а явный вход в старое правило очистки и полагаясь на него, легко написать функцию, которая выглядит как in-place операция, а на деле схлопывается в ничего оптимизатором и не вызывается в релизе. Это классический пример того, как попытка «просто избежать копирования» через ссылки оборачивается потерей контроля над смыслом интерфейса, только теперь уже не на границе вызова, а внутри самой функции.
Комментарии (5)

Nemoumbra
31.07.2026 06:34Я много статей этого автора читал и в его компетентности не сомневаюсь, но что-то от текстов в последнее время начало тянуть ИИшкой. Не в обиду конкретно имитации интеллекта - я про качество говорю, которое и у безиишных копирайтеров бывает плохим.
В результате функция может внезапно начать работать с объектами, которые «действительно» нельзя изменять, даже если автор функции этого не ожидал, что может полностью изменить смысл функции.
Ну что это за гптшные кавычки ни к селу ни к городу? Люди так не пишут!
Что приводит нас к очень странному поведению и коварным ошибкам при проектировании интерфейсов, если вы по привычке «пишете const T& где надо и где не надо». Многие делают это автоматически, потому что словили разные баги в детстве и считают, что таким образом избегают копирования и работают с обычным, изменяемым объектом, но это не так.
Просто ШТО... Я в ауте немного... Как можно написать const T& и считать, что работаешь с изменяемым объектом? Типа, такое заблуждение тут же развеивается CE при попытке сделать неконстантную операцию. Или IDE красным подсветит... Ничего не понимаю!
Ну и тут по тексту на несколько абзацев размазана одна и та же мысль в разных формулировках. Просто читаешь, и как об кочку - бам! - про это уже было... И так несколько раз. Опять же, такое я видел в ИИшных текстах.
eao197
На этом рассуждения про “проблемы” с шаблонами и жутью про защищенные страницы можно было бы и завершить. Потому что шаблоны здесь не при чем от слова совсем.
dalerank Автор
Технически верно, я об этом написал, шаблон этот баг не создаёт, он в принципе не про шаблоны и дыра целиком на совести const& + mutable. Но статья не о том, что шаблоны опасны сами по себе, но что вывод типов через шаблон меняет, как читается const T& и разработчик перестаёт держать в голове вопрос “а что мне реально прилетело”.
Обычная функция с явной сигнатурой const GruMemory& тот же баг пропустит, но сигнатура написана руками и типы видны сразу и шанс заметить проблему на кодревью выше. Шаблон здесь выступает усилителем непрозрачности. Если там сделать T& то мы можем внутри проверять конст объект пришел или не конст, а с концептами и вовсе запретить такие подлые функции
https://godbolt.org/z/qY7xjhfxK
eao197
Возможно я понимаю ваши намерения. Но не согласен с вашей точкой зрения.
До этого примера материал в статье не вызывал вопросов. Но когда этот пример появился, возникло ощущение, что вы только зря “тень на плетень” наводите. Что он не иллюстрирует тот момент, который вы хотели показать, а только усложняет восприятие и запутывает читателя.
Собственно, как и следующий пример с
auto n = v;в функцииnormalize. Те же самые грабли есть и в обычных, не шаблонных, функциях.feelamee
мне кажется или как раз с
const&шаблон ничего не меняет? Тут мне всегда прилетает константная ссылка. А что там в объекте заmutableиconst_cast- это его проблемы. Он должен сам свои инварианты соблюдать.А вот с
T&вы правы. Не совсем очевидно что тут может прилететь константная ссылка. Но, с другой стороны, - это имеет какое-то значение? Кроме как для чтения кода. Ведь если кто-то передастconst int&, то код просто не скомпилируется (если и правда обращается к неконстантным методам).