Если ссылки схлопываются, значит, это кому-то нужно

Прокомментировать Просмотры: 9

В предыдущей публикации (Непослушный using) мы разобрали, как директива using вмешивается в разрешение имён и почему её поведение часто расходится с нашими ожиданиями. На этом цикл материалов о поиске имён (name lookup) можно временно закрыть.

Хотя особенности using ещё не раз потреплют вам нервы в реальных проектах, их проблемы изучены и легко диагностируются. Гораздо интереснее поговорить про auto и механизм вывода типов в шаблонах. Эта тема регулярно ставит в тупик даже опытных C++ разработчиков, особенно когда дело касается «распада» типов (type decay) и скрытых ловушек. Представьте набор переменных, которые визуально различаются: const int&, просто int, const int и int&&.

Интуитивно кажется, что компилятор обязан относиться к ним по-разному, учитывая ссылки, константность и rvalue-природу. И так оно и есть — ровно до тех пор, пока в дело не вступают auto или шаблонные параметры.

Я продолжаю работу над книгой: каркас уже полностью сформирован, а основная часть материалов собрана и оформлена в виде черновиков. Если вам любопытно взглянуть на результат, главы доступны на GitHub (на русском и английском языках) либо в виде статей. То, что пишется сейчас — это уже финальная полировка, а не поиск концепции. Полное оглавление и ссылки спрятаны под кат.


Будущее оглавление
  1. Overloads (Перегрузки) https://habr.com/ru/articles/980246/

  2. Ограничения https://habr.com/ru/articles/981042/

  3. История концептов https://habr.com/ru/articles/983706/

  4. Иерархия концептов https://habr.com/ru/articles/985688/

  5. И снова ограничения https://habr.com/ru/articles/988018/

  6. Обобщения (ч.1) https://habr.com/ru/articles/1007998/

  7. Обобщения (ч.2) https://habr.com/ru/articles/1011012/

  8. Важны ли компилятору имена https://habr.com/ru/articles/990816/

  9. Ночью все кошки серы, а using’и одинаковы https://habr.com/ru/articles/1015492/

  10. Компиляторы тоже путаются в именах https://habr.com/ru/articles/1023334/

  11. Простой поиск имён в C++ сложный https://habr.com/ru/articles/1032114/

  12. Непослушный using https://habr.com/ru/articles/1036104/

template 
void 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 = 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 
void 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 на самой ссылке:

template 
void 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&
template 
void 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&

Для контраста взглянем на шаблон с обычной очисткой типа:

template 
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 — это лишь заполнение пустых мест ради компилируемости паттерна.

Подобное поведение порождает неочевидные баги при проектировании интерфейсов, если вы по привычке лепите 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 — это вовсе не «умный инструмент чтения мыслей», а прямой билет к старым правилам очистки типов. Полагаясь на него вслепую, легко написать функцию, которая внешне выглядит как операция «на месте», но на уровне оптимизатора схлопывается в пустоту или вовсе выбрасывается из релизной сборки. Это хрестоматийный пример того, как попытка «просто избавиться от копирования» с помощью ссылок оборачивается потерей контроля над смыслом интерфейса — только теперь уже не на границе вызова, а глубоко внутри реализации.

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов