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

Блеск и нищета std::format

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров6.8K

Сравнительно недавно в stdlib плюсов появилось форматирование строк «как в питоне», а точнее, как в библиотеке fmt. И я, как смелый и отчаянный, решил этим воспользоваться. Возможно, аксакалы и настоящие разработчики скажут, что я всё делаю не так, и вообще не то, но я буду рад такой критике, если она поможет легче жить ;)

Итак, приступим. Вам понадобится свежий компилятор и стдлиб. На cppreference зеленеют красивые надписи (since C++20), круто! 2020-й уже давно прошёл, да и в мейкфайлах/vcxproj мы везде давно ставим --std=c++20, кто посмелее — даже больше. Статьи про std::format выходят уже несколько лет, и даже переводы на хабре есть [1], [2]. Значит, сейчас зафигачим маленький инклюдик, пройдёмся sed -e '...' по исходникам, и всё будет в шоколаде.

Не так быстро! Если, внезапно, микрософт впереди планеты всей, и format доступен как в свежей студии (2022), так в и в уже не свежей (2019), то если мы соберёмся собрать нашу программку, например, на убунту, тогда для разогрева нас сначала ждёт сайд-квест по установке самого нового gcc из ppa (или вообще из сорцов, если ваша система не самая модная и молодёжная, вы успеете сварить кофе, а если у вас нет кофеварки, то тоже успеете и заказать её, и получить, и сварить). Потому что фактически в GCC std::format появился совсем недавно. В шланге он есть якобы с 14й версии, но его даже не пробуйте, берите сразу 15+, потому что баги...

Ну что же, самым свежим компилятором и самым свежим stl мы обзавелись. (Кто-то скажет, надо было брать fmt и не выпендриваться, но мы же верим в светлое будущее!). Открываем, компилируем и запускаем Hello world! Работает! Но чувствуется привкус обмана... Во многих статьях упоминается, что это форматирование «почти как в питоне». Только данное «почти» слишком «почти». Возможно, такое сравнение прокатит для тех, кто никогда не видел питон, или какой-нибудь другой современный язык. Это совсем не f-strings из питона, и не interpolated string как в C#, и не template literals в JS/TS, и даже не костыль-макрос в Rust-е. C++ хоть и меняется семимильными шагами, но всё же, где ему угнаться до хипстерских языков. Ну штош, будем есть, что дают. К тому же, похожий формат применяется и в Zig, так что дальшейшие сравнения будут разве что с ним, а не с JS/C#/Rust (куда нам...).

Я что-то замечтался и отвлёкся, вернёмся к нашим баранам. У нас есть MSVC 14.36 и GCC 13.2, на них и будем пробовать. Встречайте, Hello world:

    std::cout << std::format("Hello {}!\n", "world");

Так работает, отлично! Нам обещали какие-то проверки валидности, давайте попробуем:

    std::cout << std::format("Hello {}!\n");
↓↓↓
/usr/local/gcc-13.2/include/c++/13.2.0/format:230:56: error: call to non-‘constexpr’ function ‘void std::__format::__invalid_arg_id_in_format_string()’
  230 |             __format::__invalid_arg_id_in_format_string();
      |             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~
/usr/local/gcc-13.2/include/c++/13.2.0/format:180:3: note: ‘void std::__format::__invalid_arg_id_in_format_string()’ declared here
  180 |   __invalid_arg_id_in_format_string()
      |   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

— Вжик, — сказала японская бензопила.
— Хм, — сказали суровые сибирские мужики.

    std::cout << std::format("Hello {!\n", "world");
↓↓↓
    /usr/local/gcc-13.2/include/c++/13.2.0/format:3468:68: error: call to non-‘constexpr’ function ‘void std::__format::__unmatched_left_brace_in_fomat_string()’
 3468 |                   __format::__unmatched_left_brace_in_format_string();
      |                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~
/usr/local/gcc-13.2/include/c++/13.2.0/format:165:3: note: ‘void std::__format::__unmatched_left_brace_in_format_string()’ declared here
  165 |   __unmatched_left_brace_in_format_string()
      |   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

— Вж-жик, — сказала японская бензопила.
— Хммм... — сказали суровые сибирские мужики.

    std::cout << std::format("Hello {}!\n", "cruel", "world");
↓↓↓
# ./a.out
Hello cruel!

— Дзинь, — сказала японская бензопила.
— Агааа! — сказали суровые сибирские мужики.

Да, какие-то проверки есть, и даже форматную строку оно проверяет! Но такой простой проверки, как количество аргументов нет. (Голосом Михалкова) Вас ки-ну-ли. Придётся всё самим, всё самим. Вот, например, как с этим справляются в одном из логгеров: binlog/create_source_and_event.hpp.

Конечно, форматную строку придётся распарсить заново. А ещё вопрос, почему во времена c++20 мы всё ещё используем макросы? Ага, это отличный вопрос, сейчас не будем углубляться, но хорошо, что заметили ;) Очевидно, что count_placeholders по ссылке не выдерживает никакой критики, поэтому я напишу свой лунап^w count_placeholders:

constexpr std::size_t count_placeholders(const char* str) {
    std::size_t result = 0;
    for (std::size_t i = 0; str[i] != 0; ++i) {
        assert(str[i] != '%');
        if (str[i] == '{' && str[i + 1] != '{') {
            ++i;
            for (; str[i] != 0; ++i) {
                if (str[i] == '}' && str[i + 1] != '}') {
                    ++result;
                    break;
                }
            }
        }
    }
    return result;
}

Вот такого крокодила я написал для дополнительной проверки форматной строки. Эта конструкция не поддерживает пронумерованные аргументы вида {2} {3} {100500}, но в нашем случае (портировании printf) это и не требуется. Когда потребуется — можно будет расширить (например, выдавая в результат максимальный найденный индекс). На 4-й строке можно заметить ещё одну фишку — этот ассерт временный, он нужен на время миграции старого (супер-старого) кода на базе xxprintf, чтобы точно быть уверенным, что все старые форматы из кода выкосили и ничего не пропустили. В продакшене так делать, конечно, не стоит.

О боги ц++, спасибо, что есть constexpr и даже можно писать в нём сколько-то нетривиальные функции. Теперь обернём нашу проверку и std::format вместе:

template <class... Args>
void Log(int level, const char* fmt, Args&&... args) {
    static_assert(count_placeholders(fmt) == sizeof...(Args));
    std::cout << std::format(fmt, args...);
}

Хорошо я придумал, а, а?

error C2131: expression did not evaluate to a constant
ну или
error: non-constant condition for static assertion

Низзя! Нельзя так делать: constexpr может потреблять строки (в виде const char* / std::string / std::string_view), но передавать ни в какие вызовы не может. В стандартах что-то обсуждается на этот счёт, и даже есть какие-то костыльные библиотеки. Но самый простой способ — зафигачить старый добрый макрос! Вот, — скажете вы, — причина присутствия макросов в логгере. Но, я вас огорчу, это не единственная причина их использования. Если в данном примере закомментить static_assert, компилятор нас всё равно обломает :(

Хорошо, хорошо, я понял, макросы так макросы. Не жили хорошо, нечего и начинать:

#define Log(level, fmt, ...)                                                                                \
do {                                                                                                        \
    static_assert(count_placeholders(fmt) == std::tuple_size<decltype(std::make_tuple(__VA_ARGS__))>::value,\
        "Number of {} placeholders in format string must match number of arugments");                       \
    /* тут всякая логика логирования, проверка уровня, буферов, формат заголовка итд */                     \
    std::cout << std::format(fmt, __VA_ARGS__) << std::endl;                                                \
    /* конечно, никто в продакшене в stdout не пишет, но мы же только учимся... */                          \
} while (false)

Считать количество аргументов в макросе не так прикольно, как в темплейте, но это самое не-вырвиглазное, что я смог найти. Ура, оно наконец-то компилируется! И даже логи выводит, и проверки работают. Можно приступать к конвертированию всех вызовов, а потом уже как-нибудь разберёмся с выводом в правильное время и правильное место. Борьбу с особо креативными форматами вида "value = %3.14lgbtq++" я оставлю за скобками, всё же это скорее проблема printf, от которой мы хотим поскорее избавиться. Тут меня ждало несколько приятных моментов: благодаря compile-time проверкам были пойманы строчки с кривыми форматами, и даже одно место редко случающегося segfault-а. Так что переходить на std::format однозначно полезно ???

Все места вызова портировали, отлично. Пора нарастить мясо на скелет логгера. Для начала надо выяснить, как форматировать в предоставленный буфер. К счастью, о нас подумали, и сделали сразу две функции format_to и format_to_n, последняя из которых тут же уютно обосновалась в нашем макросе:

#define Log(level, fmt, ...)                                                                    \
    /* ... */                                                                                   \
    auto _l_count = std::format_to_n(_l_buff, _l_bufSize, fmt, __VA_ARGS__).size;               \
    /* ... */                                                                                   \

Обратите внимание на префикс локальных переменных, он нужен чтобы избежать конфликта с вызывающим кодом. Функция format_to_n ещё очень удобна тем, что в её возвращаемом значении есть желаемый размер буфера, на случай если с первого раза не хватило. Вот теперь всё хорошо и статья на этом могла бы закончиться (но вы же видите, где скролл?), если бы я не посмотрел на размер получившегося бинарника: O_o упс! Файл из 1.2 МБайта распух до 6! (шести, Карл!) Хотя в наше время чат-клиентов, занимающих сотни мегабайт на диске (и не меньше — в ram), негоже стесняться каких-то 6МБ, я решил, что так не пойдёт. Мы должны бороться со злом, а не примкнуть к нему! К тому же, это плохо скажется на icache.

К гадалке не ходи, ясно что код распух из-за инлайна самого макроса и мелких методов, в нём вызывающихся. В старом printf-based логгере я решил этот вопрос вынесением основного тела логгера в функцию, помеченную __declspec(noinline) (или __attribute__ ((noinline)), каждому компилятору своё). Давайте попробуем провернуть то же самое и с нашим новым кодом:

template <class... Args>
FIX_NOINLINE void LogWorker(int level, const char* fmt, Args&&... args) {
    // ...
    auto _l_count = std::format_to_n(_l_buff, _l_bufSize, fmt, args...).size;
    // ...
}
#define Log(level, fmt, ...)                                                                                \
do {                                                                                                        \
    static_assert(count_placeholders(fmt) == std::tuple_size<decltype(std::make_tuple(__VA_ARGS__))>::value,\
        "Number of {} placeholders in format string must match number of arugments");                       \
    LogWorker(level, fmt, __VA_ARGS__);                                                                    \
} while (false)
    
    ↓↓↓
    
1>Logger.hpp(193,71): error C7595: 'std::basic_format_string<char>::basic_format_string': call to immediate function is not a constant expression
1>C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.36.32532\include\format(3384,63): message : failure was caused by a read of a variable outside its lifetime
1>C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.36.32532\include\format(3384,63): message : see usage of 'fmt'
... ещё пара сотен строк шаблонных ругательств от компилятора ...

В смысле?! Ах да, это же та самая проблема, где нельзя передать дальше константную форматную строку, из-за которой мы и городили макрос. Но как же оно внутри работает и не держит при этом 4 копии одного и того же кода? Тут есть 2 пути: нажать F12 и провалиться в метод, или пойти читать документацию. И тут и там мы увидим методы vformat***, братьев-близнецов тех, которые мы уже используем. Плюсы: им можно передавать любой формат, минусы: они его не валидируют, им пофиг и нет функции vformat_to_n, хотя в оригинале её таки сделали. Где логика?™

Для статической проверки форматной строки требуется const std::format_string<Args...> fmt. И вот его нет в несвежей студии, потому что в стандарте он появился не сразу, так что снова придётся городить макрос:

#define fmt_get get()
#ifdef _MSC_VER
#  if _MSC_VER <= 1935
#    define format_string _Fmt_string
#    undef fmt_get
#    define fmt_get _Str
#  endif
#endif

Отлично, почти всё что требуется мы уже закостылили! Но продолжаем ныть дальше: если вы пользуетесь только MS Visual Studio, то готовьтесь к абсолютно невменяемым текстам ошибок. Начиная с call to immediate function is not a constant expression по любому поводу (и без), и заканчивая километровыми портянками, в которых STL срыгивает на вас своё нутро. Я уверен, в аду припасён отдельный котёл для разработчиков STL, в котором они будут читать сообщения об ошибках в темплейтах до скончания веков... И ещё один по соседству — для boost-а. В gcc ошибки почти такие же длинные (шаблоны же), но чуть более адекватные.

Bonus track

Естественно, после решения всех насущных проблем захотелось большего: в частности, форматирования кастомных типов. Статей на эту тему тоже полно, поэтому тут подвоха я не ожидал. Первый попавшийся пример — полноценный форматтер из двух методов: parse и
format. На каждый мелкий енум городить такое не хочется, поэтому скроллим дальше и находим пример с унаследованным форматтером:

template<> struct std::formatter<MyEnum> : formatter<const char*> {
    auto format(auto v, auto& ctx) { return formatter<const char*>::format(MyEnumToString(v), ctx); }
};

И он даже сразу заработал! Аллилуйя! ...Ха-ха, у gcc на этот счёт свои соображения:

/usr/local/gcc-13.2/include/c++/13.2.0/format:3270:38: error: no matching function for call to ‘std::basic_format_arg<std::basic_format_context<std::__format::_Sink_iter<char>, char> >::basic_format_arg(MyEnum&)’
 3270 |           basic_format_arg<_Context> __arg(__v);
      |                                      ^~~~~
... ещё 3 километра текста ошибок ...

(ノಠ益ಠ)ノ彡┻━┻ Что? Что ему опять надо??? К счастью, на этот раз ответ легко загуглить, gcc не хватает совсем чуть-чуть. Найдите 6 отличий:

template<> struct std::formatter<MyEnum> : formatter<const char*> {
    auto format(auto v, auto& ctx) const { return formatter<const char*>::format(MyEnumToString(v), ctx); }
};

И последнее, про генерируемый код. Я, конечно, ожидал что будет что-то похожее на то, как делает Zig, ну или хотя-бы частично так. Ведь в этом весь прикол заранее известной форматной строки. Вот что генерирует зиг для Hello world:

zig decompiled
zig decompiled

И это не просто весь код main, а почти весь код бинарника, который весит 5кб и не имеет импортов! Ладно, от C++ я такого не жду, но хотя-бы побить строку на части можно было?

gcc decompiled
gcc decompiled

А шиш там! Мы ещё и форматную строку будем каждый раз в рантайме парсить. Причём gcc (на скрине выше) генерирует самый приличный код из «большой троицы» (не считая сотен функций в бинарнике и ещё десятки импортированных).

Выводы

Несмотря на все эти неприятности, однозначно стоит переходить из каменного века (printf) и тёмных веков средневековья (iostreams). Даже в таком не самом оптимальном виде std::format ни капли не медленнее printf и сильно быстрее iostream. Плюс мы получаем проверки формата во время компиляции, и даже можем дополнительно написать свои.

И в случае если вы не можете просто так обновить свой компилятор, советую попробовать библиотеку fmtlib, из которой собственно std::format и вырос. Она работает начиная с C++11 и используется во многих крупных проектах. Так что рекомендую!

А где-нибудь к C++30 мы получим форматирование как в питоне и других современных языках, я надеюсь ;)

Теги:
Хабы:
Всего голосов 25: ↑23 и ↓2+25
Комментарии32

Публикации