Как стать автором
Обновить

Ошибка компилятора или неожиданный эффект шаблонов в C++?

Уровень сложностиСложный
Время на прочтение3 мин
Количество просмотров3.9K

Однажды мне поручили доработать утилиту, которая тестирует набор сервисов, отправляя им запросы по сети и замеряя время их обработки.

Я быстро добавил нужный код, запустил утилиту и... программа тут же упала с ошибкой доступа к памяти. В проекте давно существовал собственный бинарный протокол сообщений, аналогичный 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++, требующая особой внимательности и дисциплины разработчика.

Этот баг оказался самым хитрым и запутанным в моей жизни. Я потратил на него несколько дней, но радость от победы стоила потраченных усилий.

Теги:
Хабы:
+28
Комментарии9

Публикации

Работа

QT разработчик
8 вакансий
Программист C++
101 вакансия

Ближайшие события