Как при помощи С++20 мы искоренили целый класс багов, возникавших во время выполнения

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

C++20 давно в ходу и поддерживается компилятором MSVC с версии 16.11, но в этой статье я расскажу не о том, как его использовать, а как с его помощью нам удалось устранить целый класс багов времени выполнения, подвесив специальную проверку во время компиляции. Давайте разберемся с этим подробнее!

Скромное начало

Одна из первых вещей, которые нужно предусмотреть в дизайне компилятора – сообщить программисту, что у него в исходном коде есть ошибка, либо предупредить программиста, что его код может действовать не так, как ожидается. В MSVC инфраструктура ошибки выглядит примерно так:

enum ErrorNumber {
    C2000,
    C2001,
    C2002,
    ...
};
void error(ErrorNumber, ...);

Ошибка error работает так: у каждого ErrorNumber есть соответствующая строковая запись, и эта строка представляет собой текст, который мы хотим вывести пользователю. Эти текстовые строки могут иметь любой вид от C2056 -> "illegal expression" до C7627 -> "'%1$T': is not a valid template argument for '%2$S'" но что собой представляют эти %1$T и %2$S? Это специальные спецификаторы формата, предусмотренные в компиляторе для отображения определенных типов структур пользователю в удобочитаемом виде.

Обоюдоострая суть спецификаторов формата

Спецификаторы формата обеспечивают значительную гибкость в работе разработчику компилятора. Спецификаторы формата позволяют явственнее проиллюстрировать, почему именно было выдано диагностическое сообщение, предоставляют пользователю контекст с более подробной характеристикой проблемы. Загвоздка со спецификаторами форматов в том, что при вызове error они не проходят проверку типов, поэтому, если тип аргумента у нас случайно окажется неправильным, либо мы вообще не передадим аргумент,  то почти наверняка получим ошибку времени выполнения, с которой позже столкнется пользователь. Другие проблемы возникнут, если вы захотите отрефакторить диагностическое сообщение, придав ему более явную форму, но для этого вам потребовалось бы запросить каждого вызывателя данного диагностического сообщения и убедиться, что предложенный рефакторинг согласуется с аргументами, передаваемыми error.

При проектировании системы, которая проверяет наши спецификаторы форматов, мы ставим перед собой три наиболее общие цели:

  1. Прямо во время компиляции проверяем типы аргументов, передаваемых нашим диагностическим API, так, что допущенная ошибка выявляется как можно раньше. 

  2. Минимизируем изменения, вносимые в вызывателей диагностических API. Это делается, чтобы правильно сформированные вызовы сохраняли свою оригинальную структуру (и она не разрушалась также у тех вызовов, которые будут делаться в будущем).

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

Разумеется, есть некоторые решения, введенные в позднейших стандартах C++, при помощи которых также можно было бы попытаться сгладить эту проблему. Во-первых, когда в языке были введены шаблоны с переменным количеством аргументов, появилась возможность попробовать метапрограммирование шаблонов и проверять типы у вызовов к error, но для этого нам понадобилась бы отдельная таблица поиска, поскольку возможности constexpr и шаблонов ограничены. В C++14/17 было введено множество улучшений в constexpr и шаблонные аргументы-константы. Отлично сработало бы нечто такое:

constexpr ErrorToMessage error_to_message[] = {
    { C2000, fetch_message(C2000) },
    { C2001, fetch_message(C2001) },
    ...
};
template <typename... Ts>
constexpr bool are_arguments_valid(ErrorNumber n) {
/* 1. выбрать сообщение
2. разобрать спецификаторы
3. сверить каждый спецификатор с набором параметров T... */
return result;
}

Итак, у нас наконец-то появились инструменты, позволяющие проверять спецификаторы формата во время компиляции. Но проблема не устранена: по-прежнему нет возможности тихо проверить все имеющиеся вызовы к error, и это означает, что нам придется добавить лишний уровень косвенности между точками вызова error, чтобы гарантировать, что ErrorNumber смогла бы выбрать строку во время компиляции и сверить с ним типы аргументов. В C++17 следующий код бы не работал:

template <typename... Ts>
void error(ErrorNumber n, Ts&&... ts) {
    assert(are_arguments_valid<Ts...>(n));
    /* работа над ошибками */
}

И мы не смогли бы превратить error как таковой в constexpr, который делает множество вещей, недружелюбных к constexpr. Кроме того, скорректировать все точки вызова в духе error<C2000>(a, b, c), чтобы можно было проверять номер ошибки как выражение времени выполнения – нехорошо, поскольку это спровоцировало бы в компиляторе множество ненужного брожений.

C++20 в помощь!

В C++20 мы приобрели важный инструмент, обеспечивающий проверку во время компиляции: constevalconsteval – это семейство constexpr, но в их случае язык гарантирует, что функция, снабженная consteval, будет вычисляться во время компиляции. Хорошо известная библиотека под названием fmtlib вводит проверку времени компиляции в рамках core API, причем, это делается без каких-либо изменений в точках вызова – соответственно, предполагается, что форма точки вызова правильно сформирована для работы с библиотекой. Представьте себе упрощенную версию fmt:

template <typename T>
void fmt(const char* format, T);

int main() {
    fmt("valid", 10);    // компилируется
    fmt("oops", 10);     // компилируется?
    fmt("valid", "foo"); // компилируется?
}

В данном случае задумано, чтобы format всегда был равен "valid", а T должен представлять собой int. В данном случае код main имеет неправильную форму относительно библиотеки, но не предусмотрено ничего, что проверяло бы этот момент во время компиляции. В fmtlib проверка во время компиляции достигается при помощи небольшого фокуса с типами, определяемыми пользователем:

#include <string_view>
#include <type_traits>

// Только показываем
#define FAIL_CONSTEVAL throw

template <typename T>
struct Checker {
    consteval Checker(const char* fmt) {
        if (fmt != std::string_view{ "valid" }) // #1
            FAIL_CONSTEVAL;
        // T должно быть int
        if (!std::is_same_v<T, int>)            // #2
            FAIL_CONSTEVAL;
    }
};

template <typename T>
void fmt(std::type_identity_t<Checker<T>> checked, T);

int main() {
    fmt("valid", 10);    // компилируется
    fmt("oops", 10);     // не проходит в #1
    fmt("valid", "foo"); // не проходит в #2
}

Обратите внимание: необходимо выполнить фокус с std::type_identity_t, чтобы checked не участвовала в выводе типов. Мы хотим только лишь, чтобы она участвовала в выводе остальных аргументов, а выведенные типы этих аргументов будем использовать как шаблонные аргументы для Checker.

Можете сами повозиться с этим примером, открыв его в Compiler Explorer.

Все вместе

Сила вышеприведенного кода в том, что он служит нам инструментом, позволяющим дополнительно проверить безопасность, не затрагивая ни один из правильно сформулированных вызывателей. При помощи вышеприведенной техники мы применили проверку времени компиляции ко всем нашим процедурам сообщений errorwarning и note. Код, использованный в компиляторе, практически идентичен вышеприведенному коду fmt, за исключением того, что аргументом для Checker служит ErrorNumber.

Всего нам удалось найти ~120 случаев, в которых либо передается неправильное количество аргументов диагностическому API, либо передается тип, неверный при работе с конкретным спецификатором формата. С годами накопился багаж багов, касающихся странного поведения компилятора при выдаче диагностических сообщений или прямых ICE (внутренних ошибок компилятора) – все дело было в том, что аргументы, искомые спецификаторами форматов, оказывались некорректными или не существовали. При помощи C++20 мы в основном искоренили возможности возникновения таких багов в будущем, но одновременно предоставили возможность без опаски рефакторить диагностические сообщения. Все это было сделано при помощи одного маленького ключевого слова:  consteval

Источник: https://habr.com/ru/company/piter/blog/665966/


Интересные статьи

Интересные статьи

В этой статье расскажем, как совместили Youtrack и покер планирования.Однажды мы решили улучшить процесс оценки задач. В других подразделениях компании уже был успешный опыт применения покер планирова...
В этой статье мы рассмотрим, что такое классификатор, поговорим о мультиклассовой классификации с помощью нейронных сетей. Затем, ознакомившись с контекстом перейдем к основному топик...
Анализ безопасности хранения и хеширования паролей при помощи алгоритма MD5 С появлением компьютерных технологий стало более продуктивным хранить информацию в базах данны...
Реплика калькулятора Sinclair Scientific демонстрирует, как заставить дешёвый чип творить чудеса Был ли калькулятор Sinclair Scientific элегантным? Он определённо стал хитом, и п...
Эта статья посвящена одному из способов сделать в 1с-Битрикс форму в всплывающем окне. Достоинства метода: - можно использовать любые формы 1с-Битрикс, которые выводятся компонентом. Например, добавле...