Си++: форматированный вывод
Си++, похоже, язык для воспитания героев — создаём трудности на ровном месте и начинаем их преодолевать. В моём случае началось всё с разумной, как мне до сих пор кажется, идеи — заменить сишные безобразные строки char * на прекрасный класс std::string из Си++.
В процессе в комментариях мне написали, что вместо него надо использовать std::string_view, чтобы избежать копирования. Так я узнал, что за строки в Си++ отвечают не один класс, а четыре — есть ещё базовые классы, с приставкой base.
Заодно пришлось перейти на стандарт 2017 года, так как до строк с семантикой перемещения додумались только недавно.
Всё шло хорошо, пока я не дошёл до форматирования текста. В Си этим занимается функция printf, а Си++ есть метод std::format, но вот беда — он появился только в стандарте 2020 года и его ещё нет ни в одном из компиляторов, которые есть у меня под рукой.
Пошёл на поклон к ЧатГПТ, который выдал мне такую обёртку:
template<typename... Args>
std::string string_format(const std::string format, Args... args) {
int size_s = std::snprintf(nullptr, 0, format.data(), args ...) + 1; // Extra space for '\0'
if (size_s <= 0) {
throw std::runtime_error("Error during formatting.");
}
auto size = static_cast<size_t>( size_s );
std::unique_ptr<char[]> buf(new char[size]);
std::snprintf(buf.get(), size, format.data(), args ...);
return {buf.get(), buf.get() + size - 1}; // We don't want the '\0' inside
}
Это так называемый «шаблонная функция». Насколько я пока понимаю принцип, компилятор смотрит с каким типами параметров вызывается эта функция, и делает их столько штук, сколько есть комбинаций вызовов.
Так как файлы с кодом друг друга не видят, чтобы шаблонная функция «знала» с какими параметрами её вызывают, её надо помещать в заголовочный файл (🤦🏻♂️), либо указать в коде все необходимые прототипы вручную (🤦🏻♂️🤦🏻♂️🤦🏻♂️).
И хотя я осознаю, что со своим уставом в чужой монастырь не лезут, первое мне кажется противоестественным, а второе — неудобным.
На этом этапе я выяснил, что мой компилятор под «Виндоуз» ничего не знает про std::string_view, а компилятор под «Мак» не может скомпилировать код выше вот с такой прекрасной ошибкой:
comm/../utils.hpp:30:60: error: cannot pass object of non-trivial type 'std::string' through variadic function; call will abort at runtime [-Wnon-pod-varargs]
int size_s = std::snprintf(nullptr, 0, format.c_str(), std::forward<Args>(args)...) + 1;
Мне бы хотелось, чтобы код собирался под оба компилятора, поэтому я снова пошёл к ЧатГПТ и вернулся вот с таким прекрасным кодом:
template<typename... Args>
std::string string_format(const std::string& format, Args&&... args) {
auto size_s = std::snprintf(nullptr, 0, format.c_str(),
std::decay_t<Args>(args).c_str()...) + 1; // Extra space for '\0'
if (size_s <= 0) {
throw std::runtime_error("Error during formatting.");
}
auto size = static_cast<size_t>( size_s );
std::unique_ptr<char[]> buf(new char[size]);
std::snprintf(buf.get(), size, format.c_str(),
std::decay_t<Args>(args).c_str()...);
return {buf.get(), buf.get() + size - 1}; // We don't want the '\0' inside
}
Тут происходит какое-то чёрное кодунство в котором я ещё не разобрался. Но главное, что код заработал. Правда опять только под одним компилятором, второму не понравилось следующее:
comm/../utils.hpp:32:58: error: request for member 'c_str' in 'args#0', which is of non-class type 'std::decay_t<char*>' {aka 'char*'}
std::decay_t<Args>(args).c_str()...) + 1;
К счастью, решилось это просто — надо было всего лишь убрать .c_str().
По пути там пришлось решить ещё пару забавных мелочей, но они недостаточно отложились в памяти, чтобы я их мог воспроизвести без опасения в чём-то соврать.
Теперь я понимаю за что программистам на Си++ платят такие деньги — это доплата за нервную работу.
Жуть какая! :)
Рекомендую просто использовать fmt. За основу format в C++20 взят именно он.
Можно использовать его как в компилируемом виде (предпочтительно), так и чисто как header-only библиотеку.
https://fmt.dev
На fmt натыкался, посмотрю поближе, но пока обёртка как будто работает)
На широкие строки бы сразу еще перейти)
И присоединяюсь к комменту выше про fmt.
Еще есть boost, но он жирный, его интегрировать — будет из пушки по воробьям.
Про широкие строки расскажете подробнее, пожалуйста. В чём тут будет польза?
Ну что у меня стоит, тем и собираю ) А раз там двадцатый стандарт поддерживается не полностью, то значит и у остальных пользователей будут проблемы.
А можно подробнее? Потому что именно так сейчас и работает — в формате %s, в аргументах std::string.
На «широкие строки», которые тебе советуют, не ведись. Это зло, тупиковое направление и ошибка Microsoft.
Да, я как-то писал статью про Юникод, в курсе.
С UCS-2 и кривоватой (только на вывод) поддержкой UTF-8 в Виндоуз, я тоже уже успел столкнуться, мне не понравилось ))
Спасибо за развёрнутый ответ!
Использовать распоследний стандарт, который ещё толком не поддерживается, напоминает классическое «Рар используют козлы». (По той же причине.)
По широким строкам. Я смотрел на код — проект кроссплатформенный и непонятно куда дальше заведет его развитие. Мб появится GUI полноценный. Да и unicode-пафы еще есть.
Возможно, без них уже не получиться обойтись, если строить все на готовых компонентах(и не только на Windows, а и на MacOS с iOS). Но в этом случае, нврн, будет выгоднее делать конвертацию в моменте. Каким бы злом широкие строчки ни были, с ними приходится рано или поздно столкнуться.
Вся хитрость в части «std::decay_t<Args>(args).c_str()...». Если её оставить, то всё будет работать корректно, поскольку метод c_str() преобразует std::string в const char *. Но... аргументами такой функции могут быть только строки, int в неё не запихнёшь, поскольку у int нет метода c_str(). Так не интересно.
Если же вызов метода c_str() убрать, то в функцию snprintf будет передан аргумент в его изначальном виде. Если пришёл const char *, значит, const char *. Если int, значит, int. Если float, то... Ан нет, float будет преобразован в double (см. https://en.cppreference.com/w/cpp/language/variadic_arguments). Если std::string, то... «Only arithmetic, enumeration, pointer, pointer to member, and class type arguments (after conversion) are allowed. However, non-POD class types (until C++11)class types with an eligible non-trivial copy constructor, an eligible non-trivial move constructor, or a non-trivial destructor, together with scoped enumerations (since C++11), are conditionally-supported in potentially-evaluated calls with implementation-defined semantics (these types are always supported in unevaluated calls)». Если коротко, то передавать в variadic функции можно _некоторые_ C++ типы, и std::string к ним не относится.
После этого будет сделан call snprintf. А дальше функции snprintf придётся как-то разбираться, что ей передали и как это выводить. У компилятора, когда он компилировал функцию snprintf, данных о типах аргументов не было: это не шаблонная функция, а variadic function, она скомпилирована несколько лет назад и лежит где-то в libc. Единственное, на что может полагаться snprintf при разборе своих аргументов — это строка формата. А двоичное представление std::string далеко не всегда совпадает с const char *.
Вот же блинский блин! Как всё непросто. Я сегодня с утра попытался прикрутить fmt, выписал его как подмодуль в гите, подключил в make-файл вызов его компиляции, но не разобрался почему он не работает при статической компиляции и открутил всё обратно. Видимо придётся делать второй заход.
Ну или придумать что-то ещё.