Однажды мне поручили доработать утилиту, которая тестирует набор сервисов, отправляя им запросы по сети и замеряя время их обработки.
Я быстро добавил нужный код, запустил утилиту и... программа тут же упала с ошибкой доступа к памяти. В проекте давно существовал собственный бинарный протокол сообщений, аналогичный 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++, требующая особой внимательности и дисциплины разработчика.
Этот баг оказался самым хитрым и запутанным в моей жизни. Я потратил на него несколько дней, но радость от победы стоила потраченных усилий.
Комментарии (3)
lazy_val
19.05.2025 12:01бинарный протокол сообщений ... со своим генератором C++ кода
А зачем вообще в проекте на C++ кодогенерация? Шаблонов не хватило?
Как только я убрал тело базового шаблона и оставил лишь декларацию, компилятор тут же подсказал ошибкой, что специализации не хватает. Подключив правильный заголовок, я пересобрал код — и всё заработало!
А какой именно в итоге оказался "правильным"?
eao197
19.05.2025 12:01Статье очень сильно не хватает примеров кода, которые бы иллюстрировали происходящее.
Jijiki
c шаблонами и нюансами надо быть аккуратным это точно, я тоже переписывая математику по началу решил сделать многое на шаблонах, и по началу было круто, а потом началось веселье (это то о чем я не знал), без включения каких либо библиотек всё работало гуд, как только включалось ASSIMP, плюс я пользуюсь vector/array из std и вот есть нюанс, при компиляции всего этого шаблонная математика не запускалась потомучто пересекалась с ассимп и с выделением памяти ) вобщем было весело, я сразу вспомнил цену С) и теперь даже не знаю что лучше С или С++ ), вобщем тысячи строк перекинул обратно в явные вещи, и теперь сразу скаканул до 3д анимаций вот теперь интерполяция ) и коррекция на изучении ) по ходу даже с инверс 3-4 матрицами разобрался и двойные кватернионы отдебажил, но чувствую вся математика может разбиться об интерполяцию и коррекцию )