Вы наверно знаете что высший пилотаж он же Высокий Слог С++ это шаблоны и метапрограммирование. Вы обязательно должны нагородить кучу несовместимых типов и самозабвенно искать для них универсальный алгоритм.
Вот очередная статья о том что метапрограммирование и код с шаблонами не такие уж и плохие.
Но я хочу рассказать вам историю одного детективного расследования в недрах крупного OpenBMC-проекта (экосистема серверных платформ swtSyst). Это история о том, как безудержное желание перенести всё в compile-time (constexpr), помноженное на ультрасовременный синтаксис C++20, породило идеальный «молчаливый баг» (silent failure). Он мог бы годами жить в продакшене, успешно компилировался, не выдавал ни одного ворнинга, но полностью ломал логику работы приложения.
Если вы любите метапрограммирование, шаблоны, операторы свёртки (fold expressions) и тонкости работы с памятью в C++ — устраивайтесь поудобнее. Мы отправляемся в шаблонный ад.
Предыстория: как работает подписка в D-Bus
В инфраструктуре OpenBMC общение между демонами (они же сервисы или даже микросервисы, все должно быть максимально модно!) происходит через шину D-Bus. Наше приложение занимается мониторингом «здоровья» железа внутренностей серверов. Чтобы не захлебнуться в терабайтах системных сообщений, приложение использует фильтрацию: на этапе инициализации оно вычисляет наибольший общий префикс пути для всех интересующих нас устройств на D-Bus шине, а затем оформляет подписку на системную шину D-Bus (через механизм path_namespace).
Если приложению например, нужны датчики:
/xyz/openbmc_project/sensors/temperature/cpu/xyz/openbmc_project/sensors/fan/tach
То общий префикс очевиден: /xyz/openbmc_project/sensors/. Шина будет присылать только их, экономя ресурсы CPU.
Шедевр метапрограммирования
Современные программисты уже не могут жить без метапрограммирования, поэтому авторы этого кода решили, что вычислять этот префикс при старте приложения в рантайме — это прошлый век и потеря наносекунд. Было решено перенести всё на этап компиляции.
Для этого они быстренько и не особо напрягаясь сотворили целый стек compile-time конструкций с использованием кортежей, лямбд C++20 с явными шаблонными параметрами и операторов свёртки:
// Список всех поддерживаемых типов элементов в системе using ItemTypeList = std::tuple<SensorItem, FanRedundancyItem, SoftwareControlItem, StorageRootItem, DriveItem, DriveBayItem>; // Магические мета-трансформации: добавляем базовый класс и превращаем в регистры using ItemTypeListEx = utils::tupleAppend<ItemTypeList, InventoryItem>; using Registries = utils::tupleTransform<ItemTypeListEx, ItemRegistry>; // Функция поиска несовпадения двух строк на базе C++20 Ranges constexpr std::string_view commonPrefixOfTwo(std::string_view a, std::string_view b) { auto [first, last] = std::ranges::mismatch(a, b); return {a.begin(), first}; // Запомните эту строчку! } // Оператор свёртки (Fold Expression) для обхода всех типов в пакете параметров template <typename First, typename... Rest> constexpr std::string_view commonPrefixForTypes() { auto result = First::ItemType::itemPathPrefix(); if constexpr (sizeof...(Rest) > 0) { ((result = commonPrefixOfTwo(result, Rest::ItemType::itemPathPrefix())), ...); } return result; } // Главная точка входа, разворачивающая tuple в вариативный пакет template <typename Tuple> constexpr std::string_view findCommonPrefix() { return [&]<std::size_t... Is>(std::index_sequence<Is...>) { return commonPrefixForTypes<std::tuple_element_t<Is, Tuple>...>(); }(std::make_index_sequence<std::tuple_size_v<Tuple>>{}); }
Код выглядит «дорого-богато». Компилятор молча собирал этот шедевр, приложение запускалось и… подписывалось на префикс /xyz/openbmc_project/sensors/.
Появление парадокса
Я думаю изначально все было хорошо, но в один прекрасный момент в систему добавили новый класс StorageRootItem (корень хранилища от swtSyst). И у этого класса префикс пути был жестко задан как:
constexpr auto storageRootPathPrefix = "/com/swtSyst";
И вот я наткнулся на этот шедевр и решил все таки проверить логику зарытую и запутанную в шаблонах, а главное проверить результат ее работы! Мне пришлось воспользоваться базовой логикой и математикой. Функция commonPrefixForTypes берёт префикс первого элемента (SensorItem = /xyz/openbmc_project/sensors/) и начинает последовательно сравнивать его через commonPrefixOfTwo со всеми остальными.
Когда очередь доходит до StorageRootItem, должны сравниться строки:
a="/xyz/openbmc_project/sensors/"b="/com/swtSyst"
Несовпадение происходит на первом же символе после слэша ('x' != 'c'). Функция commonPrefixOfTwo была обязана обрезать строку и вернуть "/" (или пустую строку, если слэши не совпали бы).
Если общий префикс сбрасывается до "/", приложение подписывается на корень всей шины. Но в рантайме логгер упорно продолжал выводить посчитанный префикс таким:
PREF=/xyz/openbmc_project/sensors/
При этом логи обработчиков кричали, что внутрь колбэков каким-то чудом пролезают объекты инвентаря системы с путями /com/swtSyst/mctp/....
Как? Если D-Bus фильтр равен /xyz/..., сообщения из ветки /com/... физически не могут прийти напрямую от шины! (Спойлер: они пролезали «паровозиком» через механизм D-Bus Ассоциаций ObjectMapper-а, но это тема для отдельной статьи).
Главный вопрос был в другом: Почему математика C++ сломалась, и префикс не сбросился до "/"?
Снимаем заклинание constexpr
Пытаться отлаживать constexpr-функции, развернутые через три слоя шаблонов — это занятие для мазохистов. Компилятор либо соглашается с кодом, либо выплевывает терабайты нечитаемого выхлопа шаблонов. Я, естественно, не долго думая скормил эту ошибку и код шаблонов первому попавшемуся универсальному ИИ-агенту и он упорно предлагал мне все новые и новые шаблонные конструкции для всяких проверок и отладки, отладка становилась все страшнее.
Поэтому мной было принято единственно верное инженерное решение: «Хватит с нас constexpr-шенов, наелись!».
Мы (с агентом) моим волевым решением убираем ключевое слово constexpr у всей цепочки функций, превращая compile-time магию в обычный рантайм-код, и вставляем туда пошаговое логирование каждого чиха через lg2 (аналог системного журнала в OpenBMC):
template <typename First, typename... Rest> std::string_view commonPrefixForTypes() // <-- Прощай, constexpr { std::string_view result = First::ItemType::itemPathPrefix(); lg2::info("--- СТАРТ: Начальный префикс: {PFX}", "PFX", result); if constexpr (sizeof...(Rest) > 0) { auto logAndCompare = [&](std::string_view className, std::string_view nextPrefix) { std::string_view oldResult = result; result = commonPrefixOfTwo(result, nextPrefix); lg2::info("--- ШАГ: Сравниваем [{OLD}] с [{NEXT}] (класс: {CLS}) -> Получили: [{RES}]", "OLD", oldResult, "NEXT", nextPrefix, "CLS", className, "RES", result); return 0; }; ((logAndCompare(typeid(typename Rest::ItemType).name(), Rest::ItemType::itemPathPrefix())), ...); } return result; }
Компилируем, заливаем свежий бинарник на железку, запускаем и смотрим на этот потрясающий бред в консоли:
<6> --- СТАРТ: Начальный префикс от первого класса (10SensorItem): /xyz/openbmc_project/sensors/ <6> --- ШАГ: Сравниваем [/xyz/openbmc_project/sensors/] с [/xyz/openbmc_project/control/] -> Получили: [/xyz/openbmc_project/sensors/] <6> --- ШАГ: Сравниваем [/xyz/openbmc_project/sensors/] с [/com/swtSyst] (класс: 15StorageRootItem) -> Получили: [/xyz/openbmc_project/sensors/] <6> --- ИТОГ: Финальный общий префикс для шины: [/xyz/openbmc_project/sensors/]
Агент говорит мне: «Посмотрите на этот шаг внимательно:
Сравниваем [/xyz/openbmc_project/sensors/] с [/com/swtSyst] -> Получили: [/xyz/openbmc_project/sensors/]
Шаблоны отработали честно. Оператор свёртки развернулся идеально. Он последовательно вызвал функцию для всех 7 классов. Но функция сравнения строк сошла с ума.»
Король багов: коварный string_view
Мы локализовали проблему. Виновник — «простая» функция commonPrefixOfTwo. Давайте вчитаемся в неё ещё раз:
constexpr std::string_view commonPrefixOfTwo(std::string_view a, std::string_view b) { auto [first, last] = std::ranges::mismatch(a, b); return {a.begin(), first}; // Вот она, мина замедленного действия! }
Что делает автор? Он находит первый несовпадающий символ. std::ranges::mismatch возвращает пару итераторов. Переменная first — это итератор, указывающий на символ несовпадения (на букву 'x' в строке "/xyz...").
Автор хочет вернуть срез строки от её начала (a.begin()) до места несовпадения (first). Он пишет фигурные скобки: return {a.begin(), first};.
Вектор или классическая строка от двух таких аргументов построили бы диапазон (range) от начала до конца. Но std::string_view устроен иначе!
У std::string_view исторически нет конструктора, принимающего два итератора (begin, end). Его конструктор от двух аргументов принимает (указатель на начало, РАЗМЕР строки), где размер имеет тип size_t.
Что сделал компилятор, когда увидел {a.begin(), first}?
a.begin()— это указатель на начало строки (char*). Тут всё ок.first— это тоже итератор (указатель). Компилятор не нашел подходящего конструктора и применил неявное приведение типов, превратив указательfirstв число типаsize_t.
А что такое указатель, приведенный к числу в 64-битной Linux-системе? Это огромный адрес в оперативной памяти (например, 0x7fff12345678).
В итоге std::string_view подумал, что от него требуют: «Возьми строку "a" и отмерь от её начала кусок длиной в 140 триллионов символов».
Но std::string_view — штука не глупая. Внутри она хранит исходную длину строки a (длину пути сенсоров). Поняв, что у нее просят длину, превышающую длину контента в наличии, она просто защитила себя (и нас) от UB (Undefined Behavior), обрезав этот запрос по своему максимально доступному размеру и… тупо вернув строку a целиком!
Функция commonPrefixOfTwo из-за кривого конструктора превратилась в заглушку, которая всегда возвращала первый аргумент, полностью игнорируя то, с чем его сравнивают.
Автор шаблонов настолько был увлечен шаблонами, что просто не придавал значения обычной логике, которая лежит в основе всего, для которой любое, сколько угодно продвинутое метапрограммирование является, всего лишь, вычурным оформлением в большинстве случаев.
Почему я не стал это чинить
Исправить этот баг — дело одной строки. Нужно просто по-человечески вычислить расстояние (длину) между итераторами через std::distance и взять нормальный подстрочный срез:
constexpr std::string_view commonPrefixOfTwo(std::string_view a, std::string_view b) { auto [first, last] = std::ranges::mismatch(a, b); size_t length = std::distance(a.begin(), first); return a.substr(0, length); }
Если применить этот фикс, функция честно выдаст префикс "/". Проект подпишется на корень шины, и демон мониторинга здоровья «ляжет» под нагрузкой от миллиарда системных сообщений, на обработку которых он не рассчитан. Архитектурно типы инвентаря и сенсоров просто нельзя было мешать в один общий котёл, их нужно было разносить на разные инстансы подписок, но кто же это мог заметить под слоями шаблонного кода?
Конечно я не стал отправлять этот патч в репозиторий, потому что это не единственная ошибка. Это такая ошибка из-за которой все и работает. Ошибка которая компенсирует другие ошибки. И очень здорово что шаблоны, constexpr-ешены так замечательно позволяют маскировать нетривиальные методы выправления других нетривиальных ошибок. (Или нет? Не здорово?)
Кстати, за месяц пока у меня дошли руки до этой статьи этот код уже переписали, но, конечно, слои шаблонов с constexpr-ешенами никто не посмел трогать. Они так и сидят там и затравленно смотрят на всех: «Попробуй тронь меня! Пятнами пойдешь и от тебя все отвернутся!»
Выводы для инженеров
Меньше магии, больше рантайма. Вычисление префикса строки один раз при старте демона занимает наносекунды. Попытка сэкономить их в
constexprчерез тонны шаблонов привела бы возможно к неделям отладки, пока разработчики смогли бы преодолеть благоговейный ужас и попробовать зайти ниже границы шаблонов. Читаемость кода и простота его поддержки всегда важнее.Опасайтесь неявных приведений.
std::string_view— прекрасный инструмент, но отсутствие конструктора(iterator, iterator)в сочетании со всеядными фигурными скобками{}может сыграть злую шутку. То есть при работе с любым более менее сложным инструментом надо обращать внимание на нюансы работы с ним и не надеяться что вы точно знаете-понимаете-помните как он работает. Надо проверять себя.Лонглайв рантайм-логи. Если код ведет себя как черная магия или просто генерирует бред — отключайте
constexpr, выкидывайте шаблоны, пишите старый добрый последовательный код и смотрите в логи. Они никогда не врут.
Но мое мнение совершенно субъективное, вряд ли не него стоит ориентироваться в рамках вашего коллектива. Не отрывайтесь от коллектива!
