Если ссылки схлопываются, значит, это кому-то нужно
В предыдущей публикации (Непослушный using) мы разобрали, как директива using вмешивается в разрешение имён и почему её поведение часто расходится с нашими ожиданиями. На этом цикл материалов о поиске имён (name lookup) можно временно закрыть.
Хотя особенности using ещё не раз потреплют вам нервы в реальных проектах, их проблемы изучены и легко диагностируются. Гораздо интереснее поговорить про auto и механизм вывода типов в шаблонах. Эта тема регулярно ставит в тупик даже опытных C++ разработчиков, особенно когда дело касается «распада» типов (type decay) и скрытых ловушек. Представьте набор переменных, которые визуально различаются: const int&, просто int, const int и int&&.
Интуитивно кажется, что компилятор обязан относиться к ним по-разному, учитывая ссылки, константность и rvalue-природу. И так оно и есть — ровно до тех пор, пока в дело не вступают auto или шаблонные параметры.
Я продолжаю работу над книгой: каркас уже полностью сформирован, а основная часть материалов собрана и оформлена в виде черновиков. Если вам любопытно взглянуть на результат, главы доступны на GitHub (на русском и английском языках) либо в виде статей. То, что пишется сейчас — это уже финальная полировка, а не поиск концепции. Полное оглавление и ссылки спрятаны под кат.
Будущее оглавление


-
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/
templatevoid show() { // прим. для MSVC: заменить макрос на __FUNCSIG__ std::cout << __PRETTY_FUNCTION__ << '\n'; } template
void probe(T x) { show (); } 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 = intdecltype(a) = int decltype(b) = int decltype(c) = int decltype(d) = int
Переменные A, B, C и D обладают совершенно разными типами, что напрямую влияет на время их жизни, модифицируемость и правила привязки ссылок. Всё это верно до тех пор, пока мы оперируем ими напрямую, задавая контекст компилятору. Однако стоит передать их в шаблонный параметр по значению (вроде
T x) или задействовать немодифицированныйauto, как активируется стандартный механизм дедукции типов. Он безжалостно отбрасывает ссылки и верхнеуровневые спецификаторыconstиvolatile, приводя все четыре варианта к банальномуint.Важно подчеркнуть: это не прихоть ключевого слова
auto, а фундаментальное правило шаблонов, уходящее корнями ещё в ранние версии C++.autoпросто задействует этот механизм в явном виде, регулярно ставя разработчиков в тупик.templatevoid foo(const T& x); Может показаться, что эта функция принимает «константную ссылку на изменяемое значение» — мол, объект у нас обычный, мы лишь обещаем его не портить и избавляемся от лишнего копирования.
Но
const T&— это уже не «голый»T, а составной шаблонный паттерн. Механизм вывода типов здесь не сводит аргумент к чистому типу значения (value type), как это было вprobe(T x). При этом и обратная крайность неверна: рассуждение в духе «раз аргумент был константным, значитTпревратится вconst int» тоже ошибочно. Сложно? Понимаю.Если мы имеем дело с таким вызовом:
const int& a = / ... /; foo(a);То
Tвыводится как обычныйint, а параметр функции обретает типconst int&. Никакого монструозногоconst const int&не возникает: константность аргумента благополучно покрывается темconst, который уже зашит в паттерн параметра. Для наглядности проверим соседний паттерн — безconstна самой ссылке:templatevoid bar(T& x); const int& a = / ... /; bar(a); // T = const int, параметр: const int& Здесь
constуже некуда вынести наружу, поэтому он проникает непосредственно внутрьT. Разница неуловима, но она напрочь ломает привычную логику. Программист читаетconst T&как «ссылка на гарантированно неконстантныйT», в то время как язык интерпретирует это как «подобрать такой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.
Если отбросить фирменный юмор Александреску и перевести цитату на человеческий язык, получится следующее: «всё дело в том, что при уточнении типа параметра дедукция перестает просто очищать типы и начинает заполнять «пробелы» в шаблоне. Если аргумент содержит константность, а в паттерне есть подходящее для неё место, она туда и отправляется. Если же квалификатор присутствует в паттерне, но отсутствует в аргументе, он может быть добавлен. В результате вывод типов превращается не в зачистку, а в сохранение и протаскивание квалификаторов». Именно в этот момент у многих разработчиков окончательно рушится интуитивное понимание шаблонов и
autoв C++.Допустим:
const int& a = 42; // a : const int& templatevoid foo(const T& x); Паттерн параметра выглядит так:
const [ T ] &А тип аргумента (после применения стандартных правил для ссылочных выражений):
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, поскольку в самом паттерне готового места под него нет:const T& → const const int&Для контраста взглянем на шаблон с обычной очисткой типа:
templatevoid 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— это лишь заполнение пустых мест ради компилируемости паттерна.Подобное поведение порождает неочевидные баги при проектировании интерфейсов, если вы по привычке лепите
const T&везде подряд. Многие делают это автоматически, полагая, что таким образом они и от копирования спасаются, и с обычным модифицируемым объектом работают. Но реальность устроена иначе.Используя параметр
const T&, вы не запрещаете передавать константные объекты — напротив, вы их приветствуете. Более того, если переданный аргумент изначально был константным, этот статус никуда не денется. В результате функция может неожиданно начать работать с тем, что изменять категорически нельзя, даже если разработчик этого совершенно не планировал, что способно в корне исказить логику программы.struct GruMemory { 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
auto& get_cache(const T& obj)
{
if (!obj.cache_initialized()) {
obj.build_cache();
}
return obj.get_cache();
}int main() {
GruMemory f; << этот объект изменяем, мы его создали в обычной памяти
int a = get_cache(f); // работаетconst GruMemory cf; << этот объект константен и лежит в памяти видеоркарты, << обращение и измениение его в лучшем случаем даст артифакт на экране << в худшем скрашит игру return get_cache(cf); // и это работает}
Что происходит на этапе вывода типов:
Аргумент: GruMemory / const GruMemory Параметр: const T& Вывод: T = GruMemory / T = GruMemory Итог: const GruMemory& / const GruMemory&Квалификатор
constне прячется внутрьT, и в обоих случаях итоговая сигнатура превращается вconst GruMemory&. Если автор задумывал функцию лишь как способ избежать копирования, он получил интерфейс, одинаково лояльный и к обычным, и к «настоящим» константным объектам. Наличие внутри структуры полей с модификаторомmutable(или, упаси боже,const_cast) приводит к тому, что код начинает модифицировать состояние объекта, который на уровне «железа» трогать запрещено (например, если память страниц защищена).Это не баг компилятора и не изъян шаблонов — сам язык допускает подобное. Проблема в том, что контракт интерфейса оказывается нарушен. Страницам памяти можно явно задавать права доступа (read/write, read-only или no-access для CPU и GPU). Попытка записи в CPU read-only страницу — это уже не про абстрактный «логический const в C++», а прямое нарушение защиты памяти, которое провоцирует page fault и немедленный краш. Если же речь идет о памяти, разделяемой с GPU, мы гарантированно получаем порчу данных или разрушение структур, что выльется в падение приложения в другом потоке или спустя несколько кадров, без внятного стека вызовов.
Пейджированная память — стандартная история для консолей, но подобным небрежным подходом мы ломаем все аппаратные гарантии. В лучшем случае отделываемся графическими артефактами, в худшем — крашем игры. Если вам кажется, что это надуманная проблема, просто взгляните на статистику разработки Deathloop: за четыре года было выявлено свыше двухсот (200+) подобных протечек, причем не только с GPU-ресурсами. То есть в среднем команда совершала как минимум один баг такого рода каждый месяц — и это только то, что удалось отловить.
Важно поставить правильный диагноз. Корень зла здесь кроется вовсе не в подстановке
constвT, а в гремучей смеси связки «const&принимает всё константно-совместимое» и мутации черезmutableлибо обходы модели константности. Ровно тот же дефект возникнет и в обычном коде без всяких шаблонов; просто шаблон делает привычку писатьconst T&совсем бездумной, создавая иллюзию контроля над типомT, хотя на деле вы подписываетесь на максимально широкий константный контракт.В большинстве реальных проектов подобные дефекты запрятаны глубоко в недрах STL. В лучшем случае это выражается в лишних копиях и шуме в профайлере, в худшем — в редких визуальных артефактах и «плавающем» поведении NPC. Статические анализаторы справляются с такими вещами посредственно, так что до сих пор всё держится исключительно на бдительности разработчиков, ревью и тестировании.
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
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без амперсанда, и снова урон улетает в никуда — в бессмысленную копию.Перед нами ровно та же история с шаблонным декеем, что и в начале статьи. Ключевое слово
auto— это вовсе не «умный инструмент чтения мыслей», а прямой билет к старым правилам очистки типов. Полагаясь на него вслепую, легко написать функцию, которая внешне выглядит как операция «на месте», но на уровне оптимизатора схлопывается в пустоту или вовсе выбрасывается из релизной сборки. Это хрестоматийный пример того, как попытка «просто избавиться от копирования» с помощью ссылок оборачивается потерей контроля над смыслом интерфейса — только теперь уже не на границе вызова, а глубоко внутри реализации.
Сборка ПК для GTA 6 в 2026 году: мощно и недорого
Unreal Engine 6: чего ждать от нового движка
«Алёнкины истории» вышли на мобильных устройствах
Эталонное внутримировое снаряжение
Mount & Blade 2: Bannerlord в 2026 году: ключевые изменения и стоит ли играть заново?
В Throne and Liberty запустили партнерскую программу для авторов
Главные игровые новости к 24 июля: оценки Halo: Campaign Evolved, апдейт Steam и релиз «Принца Джериана»
Как ИИ спас от забвения еще одну безвестную игру. Даже текстовую