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
.
При проектировании системы, которая проверяет наши спецификаторы форматов, мы ставим перед собой три наиболее общие цели:
Прямо во время компиляции проверяем типы аргументов, передаваемых нашим диагностическим API, так, что допущенная ошибка выявляется как можно раньше.
Минимизируем изменения, вносимые в вызывателей диагностических API. Это делается, чтобы правильно сформированные вызовы сохраняли свою оригинальную структуру (и она не разрушалась также у тех вызовов, которые будут делаться в будущем).
Минимизируем изменения, вносимые в детали реализации вызываемой стороны. Мы не должны менять поведение диагностических процедур во время выполнения.
Разумеется, есть некоторые решения, введенные в позднейших стандартах 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 мы приобрели важный инструмент, обеспечивающий проверку во время компиляции: consteval
. consteval
– это семейство 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.
Все вместе
Сила вышеприведенного кода в том, что он служит нам инструментом, позволяющим дополнительно проверить безопасность, не затрагивая ни один из правильно сформулированных вызывателей. При помощи вышеприведенной техники мы применили проверку времени компиляции ко всем нашим процедурам сообщений error
, warning
и note
. Код, использованный в компиляторе, практически идентичен вышеприведенному коду fmt
, за исключением того, что аргументом для Checker
служит ErrorNumber
.
Всего нам удалось найти ~120 случаев, в которых либо передается неправильное количество аргументов диагностическому API, либо передается тип, неверный при работе с конкретным спецификатором формата. С годами накопился багаж багов, касающихся странного поведения компилятора при выдаче диагностических сообщений или прямых ICE (внутренних ошибок компилятора) – все дело было в том, что аргументы, искомые спецификаторами форматов, оказывались некорректными или не существовали. При помощи C++20 мы в основном искоренили возможности возникновения таких багов в будущем, но одновременно предоставили возможность без опаски рефакторить диагностические сообщения. Все это было сделано при помощи одного маленького ключевого слова: consteval
.
Комментарии (6)
ReadOnlySadUser
17.05.2022 00:37Задам я самый тупой вопрос во вселенной - зачем такие мегасложности в задаче, в которой можно вообще обойтись без спецификаторов формата? Чем вариант использования заглушек "{}" в стиле python не угодил? А уж если таки надо что-то отформатировать - почему не передавать форматирование аргументов отдельно от аргументов?)
Вместо этого - какой-то лютый мегакостыль :)
domix32
17.05.2022 12:05"'%1$T': is not a valid template argument for '%2$S'"
Порядок аргументов не очевидный, например. Кто гарантирует, что в ворнинг аргументы передали в нужном порядке - там же наверняка какой-нибудь Arg... торчит. Ну и ручками копипастить "многокода" про форматирование тоже не комильфо, если можно отдать все это в кодогенерацию, да ещё провалидировать. Не одному только Boost.Units надо уметь запрещать складывать метры с килограммами.
myxo
А где ссылка на оригинал?
devblogs.microsoft.com/cppblog/how-we-used-cpp20-to-eliminate-an-entire-class-of-runtime-bugs