Пишу, по большей части, про историю, свою жизнь и немного про программирование.

Си++: форматированный вывод

Си++, похоже, язык для воспитания героев — создаём трудности на ровном месте и начинаем их преодолевать. В моём случае началось всё с разумной, как мне до сих пор кажется, идеи — заменить сишные безобразные строки 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().

По пути там пришлось решить ещё пару забавных мелочей, но они недостаточно отложились в памяти, чтобы я их мог воспроизвести без опасения в чём-то соврать.

Теперь я понимаю за что программистам на Си++ платят такие деньги — это доплата за нервную работу.

7 комментариев
deadem 9 мес

Жуть какая! :)
Рекомендую просто использовать fmt. За основу format в C++20 взят именно он.
Можно использовать его как в компилируемом виде (предпочтительно), так и чисто как header-only библиотеку.
https://fmt.dev

Евгений Степанищев 9 мес

На fmt натыкался, посмотрю поближе, но пока обёртка как будто работает)

kartaris 9 мес

На широкие строки бы сразу еще перейти)
И присоединяюсь к комменту выше про fmt.
Еще есть boost, но он жирный, его интегрировать — будет из пушки по воробьям.

Евгений Степанищев 9 мес

Про широкие строки расскажете подробнее, пожалуйста. В чём тут будет польза?

Сергей Чебан 9 мес
  1. Основная проблема «решения» от ChatGPT в том, что оно небезопасно. Если в format указать «%s», а в args передать std::string, получится undefined behavior. Там есть и другие мелкие проблемы, но это основная и принципиальная.
  2. Классический способ форматировать данные в C++ — с использованием потоков ввода-вывода. Основной недостаток — при этом способе нет строки формата, которую можно было бы отдать переводчикам, а потом передать в функцию локализованную версию. Но если не нужно ничего кроме английского языка, то этим способом вполне можно пользоваться.
  3. Современный способ — таки std::format. В 2023 году жаловаться на отсутствие компиляторов — грех: стандарту уже три года, а поддержка появилась задолго до выхода стандарта. К сожалению, проблему с локализацией std::format всё равно полностью не решает, и всякие «выбрано 1 файлов» неизбежны. Но уже жить можно.
Евгений Степанищев 9 мес

Ну что у меня стоит, тем и собираю ) А раз там двадцатый стандарт поддерживается не полностью, то значит и у остальных пользователей будут проблемы.

Основная проблема «решения» от ChatGPT в том, что оно небезопасно. Если в format указать «%s», а в args передать std::string, получится undefined behavior.

А можно подробнее? Потому что именно так сейчас и работает — в формате %s, в аргументах std::string.

Сергей Чебан 9 мес

На «широкие строки», которые тебе советуют, не ведись. Это зло, тупиковое направление и ошибка Microsoft.

  1. Когда-то компьютеры были большими и умели только в английский язык. Была кодировка ASCII, её хватало.
  2. Потом появилась потребность поддерживать другие языки. С большинством языков проблем не возникло: их буквы можно было запихать в верхнюю половину кодовой таблицы. Так появился зоопарк разных кодировок: только для русского языка были KOI8-R, CP866, CP1251 и пр.
  3. Потом вспомнили про китайский язык, у которого «букв» намного больше 256. Возник Unicode, который решил собрать все-все символы всех-всех языков. В первой версии их таблиц был всего 7161 символ (но не было китайского языка), во следующей 28359 символов (см. https://ru.wikipedia.org/wiki/%D0%AE%D0%BD%D0%B8%D0%BA%D0%BE%D0%B4#%D0%92%D0%B5%D1%80%D1%81%D0%B8%D0%B8_%D0%AE%D0%BD%D0%B8%D0%BA%D0%BE%D0%B4%D0%B0).
  4. В это время Microsoft решил, что уж 16 битов точно хватит всем. И добавил в Windows API поддержку 16-битных символов Unicode. И на тот момент это был прорыв.
  5. В марте 2001 года вышла версия Unicode 3.1, в которой было добавлено ещё 42 тысячи китайских иероглифов, а общее количество символов достигло 94205. 16 битов не хватило, «широкие строки» превратились в чемодан без ручки. UTF-16 со своими surrogate pairs — это уже совсем не то.
  6. Юниксовый мир в это время шёл в совсем другом направлении, и в результате пришёл к кодировке UTF-8, которая позволила добавить поддержку unicode без изменения API.
  7. Сейчас и Microsoft уже пытается переходить на UTF-8: у них есть «экспериментальная» (но доступная пользователям) настройка, позволяющая использовать эту кодировку в качестве основной. Но — «рад бы в рай, да грехи не пускают»: слишком много существует софта под Windows, который такого не ожидает и начинает рисовать кракозябры.
Евгений Степанищев 9 мес

Да, я как-то писал статью про Юникод, в курсе.

С UCS-2 и кривоватой (только на вывод) поддержкой UTF-8 в Виндоуз, я тоже уже успел столкнуться, мне не понравилось ))

Спасибо за развёрнутый ответ!

Креведко Медведев 9 мес

Использовать распоследний стандарт, который ещё толком не поддерживается, напоминает классическое «Рар используют козлы». (По той же причине.)

kartaris 9 мес

По широким строкам. Я смотрел на код — проект кроссплатформенный и непонятно куда дальше заведет его развитие. Мб появится GUI полноценный. Да и unicode-пафы еще есть.
Возможно, без них уже не получиться обойтись, если строить все на готовых компонентах(и не только на Windows, а и на MacOS с iOS). Но в этом случае, нврн, будет выгоднее делать конвертацию в моменте. Каким бы злом широкие строчки ни были, с ними приходится рано или поздно столкнуться.

Сергей Чебан 9 мес

А можно подробнее? Потому что именно так сейчас и работает — в формате %s, в аргументах std::string

Вся хитрость в части «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 *.

Евгений Степанищев 9 мес

Вот же блинский блин! Как всё непросто. Я сегодня с утра попытался прикрутить fmt, выписал его как подмодуль в гите, подключил в make-файл вызов его компиляции, но не разобрался почему он не работает при статической компиляции и открутил всё обратно. Видимо придётся делать второй заход.

Ну или придумать что-то ещё.