Вы наверно знаете что высший пилотаж он же Высокий Слог С++ это шаблоны и метапрограммирование. Вы обязательно должны нагородить кучу несовместимых типов и самозабвенно искать для них универсальный алгоритм.

Вот очередная статья о том что метапрограммирование и код с шаблонами не такие уж и плохие.

Но я хочу рассказать вам историю одного детективного расследования в недрах крупного OpenBMC-проекта (экосистема серверных платформ swtSyst). Это история о том, как безудержное желание перенести всё в compile-time (constexpr), помноженное на ультрасовременный синтаксис C++20, породило идеальный «молчаливый баг» (silent failure). Он мог бы годами жить в продакшене, успешно компилировался, не выдавал ни одного ворнинга, но полностью ломал логику работы приложения.

Если вы любите метапрограммирование, шаблоны, операторы свёртки (fold expressions) и тонкости работы с памятью в C++ — устраивайтесь поудобнее. Мы отправляемся в шаблонный ад.


Предыстория: как работает подписка в D-Bus

В инфраструктуре OpenBMC общение между демонами (они же сервисы или даже микросервисы, все должно быть максимально модно!) происходит через шину D-Bus. Наше приложение занимается мониторингом «здоровья» железа внутренностей серверов. Чтобы не захлебнуться в терабайтах системных сообщений, приложение использует фильтрацию: на этапе инициализации оно вычисляет наибольший общий префикс пути для всех интересующих нас устройств на D-Bus шине, а затем оформляет подписку на системную шину D-Bus (через механизм path_namespace).

Если приложению например, нужны датчики:

  1. /xyz/openbmc_project/sensors/temperature/cpu

  2. /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}?

  1. a.begin() — это указатель на начало строки (char*). Тут всё ок.

  2. 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-ешенами никто не посмел трогать. Они так и сидят там и затравленно смотрят на всех: «Попробуй тронь меня! Пятнами пойдешь и от тебя все отвернутся!»

Выводы для инженеров

  1. Меньше магии, больше рантайма. Вычисление префикса строки один раз при старте демона занимает наносекунды. Попытка сэкономить их в constexpr через тонны шаблонов привела бы возможно к неделям отладки, пока разработчики смогли бы преодолеть благоговейный ужас и попробовать зайти ниже границы шаблонов. Читаемость кода и простота его поддержки всегда важнее.

  2. Опасайтесь неявных приведений. std::string_view — прекрасный инструмент, но отсутствие конструктора (iterator, iterator) в сочетании со всеядными фигурными скобками {} может сыграть злую шутку. То есть при работе с любым более менее сложным инструментом надо обращать внимание на нюансы работы с ним и не надеяться что вы точно знаете-понимаете-помните как он работает. Надо проверять себя.

  3. Лонглайв рантайм-логи. Если код ведет себя как черная магия или просто генерирует бред — отключайте constexpr, выкидывайте шаблоны, пишите старый добрый последовательный код и смотрите в логи. Они никогда не врут.

Но мое мнение совершенно субъективное, вряд ли не него стоит ориентироваться в рамках вашего коллектива. Не отрывайтесь от коллектива!