Однажды мне поручили доработать утилиту, которая тестирует набор сервисов, отправляя им запросы по сети и замеряя время их обработки.
Я быстро добавил нужный код, запустил утилиту и... программа тут же упала с ошибкой доступа к памяти. В проекте давно существовал собственный бинарный протокол сообщений, аналогичный protobuf, со своим генератором C++ кода и механизмами кодирования и декодирования. Эта часть кода была старая, и никто не хотел её трогать.
Отладчик показал, что ошибка происходит внутри кода, который парсил сообщения. Я этот код не менял, но на всякий случай сгенерировал его заново — не помогло.
Первая мысль была: возможно, мой новый код где-то портит память. Чтобы найти ошибку, я решил собрать проект с Address Sanitizer. Спросив у коллег, использовали ли они его раньше, я услышал, что попытки были, но безуспешные. Запасшись терпением, через полдня я получил сборку. К сожалению, санитайзер ничего не обнаружил.
Странность была в том, что сообщение, на котором падала утилита, спокойно передавалось между сервисами и успешно парсилось там. Сервисы и утилита использовали абсолютно одинаковый код парсинга — одну и ту же статическую библиотеку. Почему же тогда сервисы работали, а утилита нет?
Пришлось разбираться в «страшном» коде вручную. Выглядел он примерно так:
Parse_MessageName(const void* buffer, size_t size) {
MessageName_Model model(buffer, size);
MessageName message;
model.Parse(message);
// ...
}
Ошибка происходила на вызове метода Parse
. Сама модель устроена как матрешка: содержит в себе подмодели на каждый член сообщение. В конструкторе корневая модель создаёт буфер и передаёт его по ссылке в подмодели. В рабочем варианте конструктор основной модели создавал подмодели правильно, а в утилите — нет.
Пришлось взглянуть на ассемблерный код. Хотя я не эксперт по ассемблеру, мне стало ясно, что в утилите конструктор основной модели был подозрительно коротким и не вызывал конструкторы подмоделей. В результате память подмоделей не инициалировалась. В сервисе же конструктор выглядел нормальным, полным, и память заполнялась корректно.
Откуда такая разница? Может, дело во флагах компиляции? Проверил — нет, флаги были одинаковыми. Может, баг в компиляторе? Проверил, собрав код с GCC — и утилита заработала! Выходило, что виноват компилятор MSVC, но почему?
Внимательно присмотревшись к конструктору, я заметил, что он полностью находился в заголовочном файле. А значит, разные файлы могли видеть его по-разному и по-разному компилировать. Я перенёс его в .cpp-файл, переделал генератор кода, сгенерировал файлы заново и начал сборку.
Сервисы собрались успешно, но утилита — нет. Линкер сообщил, что этот конструктор определён одновременно и в библиотеке, и в файле утилиты (назовём его test.cpp). Как так? Я удалил объектные файлы, пересобрал проект — не помогло. Откуда конструктор взялся в test.cpp?
Чтобы найти причину, я закомментировал test.cpp целиком. Ошибка линкера пропала. Затем начал постепенно раскомментировать код и, наконец, обнаружил виновника: при вызове шаблонной функции foo, специализированной под тип MessageName
, появлялся и этот конструктор. Оказалось, что внутри функции foo создавалась переменная типа MessageName_Model
.
И тут стала ясна настоящая причина. MessageName_Model
был специализацией шаблона Model
. Общий шаблон класса был определён в главном заголовке proto-библиотеки, а его специализации — в отдельных заголовочных файлах. Особенность C++ состоит в том, что если компилятор не видит существующей специализации шаблона, он молча инстанцирует базовую версию шаблона. В моём случае test.cpp не видел нужную специализацию и инстанцировал базовый шаблон, у которого не было необходимых подмоделей.
Как только я убрал тело базового шаблона и оставил лишь декларацию, компилятор тут же подсказал ошибкой, что специализации не хватает. Подключив правильный заголовок, я пересобрал код — и всё заработало!
Какой вывод можно сделать из этой истории? Не стоит допускать возможность случайного инстанцирования базового шаблона, так как это легко приводит к трудно диагностируемым ошибкам. Если же такой подход неизбежен, потому что базовый шаблон покрывает большинство случаев, то безопаснее всего держать все специализации в одном месте. Свободная специализация шаблонов пользователем — это распространённая и серьёзная проблема в C++, требующая особой внимательности и дисциплины разработчика.
Этот баг оказался самым хитрым и запутанным в моей жизни. Я потратил на него несколько дней, но радость от победы стоила потраченных усилий.
Комментарии (9)
lazy_val
19.05.2025 12:01бинарный протокол сообщений ... со своим генератором C++ кода
А зачем вообще в проекте на C++ кодогенерация? Шаблонов не хватило?
Как только я убрал тело базового шаблона и оставил лишь декларацию, компилятор тут же подсказал ошибкой, что специализации не хватает. Подключив правильный заголовок, я пересобрал код — и всё заработало!
А какой именно в итоге оказался "правильным"?
ZirakZigil
19.05.2025 12:01А зачем вообще в проекте на C++ кодогенерация? Шаблонов не хватило?
Раз уж это протокол, то, подозреваю, общаются не только плюсы с плюсами. Тогда там есть какой-то свой язык, из которого генерируется код. Раз в посте говорится, что как в протобоафе, то что-то такое и есть, наверное, с их proto.
eao197
19.05.2025 12:01Статье очень сильно не хватает примеров кода, которые бы иллюстрировали происходящее.
Lockal
19.05.2025 12:01// base.hpp (included) template<typename T> void test(T value) { puts("default"); }; // specialized.hpp (forgot to include) template<> void test(int value) { puts("specialized"); }; // main.cpp #include <base.hpp> int main(void) { test(1); } // default
Ну да, не очень тянет на "breaking news", но я могу представить проект, где это встретится. Аналогично с перегрузками (а это уже исправлял сам). Только если с перегрузками иногда можно поймать implicit double conversion флажками компилятора, то с шаблонами уже надо учиться писать fool-proof код лично.
verls
19.05.2025 12:01Всегда ставлю
static_assert
в базовый шаблон для отладочной версии приложения и не надо 3 дня думать над проблемой.
firehacker
19.05.2025 12:01В Си можно легко выстрелить себе в ногу — твердят плюсовики. При этом сами так заигрались с метапрограммированием, что приходится дизасмить код и смотреть, что же там не так нагенерировалось...
Jijiki
c шаблонами и нюансами надо быть аккуратным это точно, я тоже переписывая математику по началу решил сделать многое на шаблонах, и по началу было круто, а потом началось веселье (это то о чем я не знал), без включения каких либо библиотек всё работало гуд, как только включалось ASSIMP, плюс я пользуюсь vector/array из std и вот есть нюанс, при компиляции всего этого шаблонная математика не запускалась потомучто пересекалась с ассимп и с выделением памяти ) вобщем было весело, я сразу вспомнил цену С) и теперь даже не знаю что лучше С или С++ ), вобщем тысячи строк перекинул обратно в явные вещи, и теперь сразу скаканул до 3д анимаций вот теперь интерполяция ) и коррекция на изучении ) по ходу даже с инверс 3-4 матрицами разобрался и двойные кватернионы отдебажил, но чувствую вся математика может разбиться об интерполяцию и коррекцию )
rukhi7
так на С++ вроде можно и без шаблонов писать или нет? Как минимум не придется например какой нибудь unlock() вручную расставлять на все выходы из блока кода. Стандартные решения на шаблонах из библиотек вполне себе адекватны, не надо свои городить в библиотечном стиле когда вы пишите НЕ-библиотеку. В чем смысл шаблона если к нему надо писать спецификацию не совсем понятно, ведь спецификация по сути это отмена шаблонности.