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

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

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

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

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

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

kartaris 10 мес

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

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

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

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

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

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

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

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

На «широкие строки», которые тебе советуют, не ведись. Это зло, тупиковое направление и ошибка 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, который такого не ожидает и начинает рисовать кракозябры.
Евгений Степанищев 10 мес

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

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

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

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

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

kartaris 10 мес

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

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

А можно подробнее? Потому что именно так сейчас и работает — в формате %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 *.

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

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

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