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

Но как сделать это оптимально и кроссплатформенно?

Стойте, стойте, но у нас ведь все для этого есть

Казалось бы, если мы желаем вывести форматированный текст на экран, в C++20 у нас есть много способов это сделать: с помощью расово верных потоков (std::cout и компания), классического C API для форматированного вывода (std::printf и его братья), а также используя std::format в сочетании с функциями наподобие std::fputs. Но все они обладают своими недостатками.

Рассмотрим следующий код:

std::cout << "The answer is " << 42 << ".\n";

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

  push rbx
  mov rbx, qword ptr [rip + std::cout@GOTPCREL]
  lea rsi, [rip + .L.str.1]
  mov edx, 14
  mov rdi, rbx
  call std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@PLT
  mov rdi, rbx
  mov esi, 42
  call std::basic_ostream<char, std::char_traits<char> >::operator<<(int)@PLT
  lea rsi, [rip + .L.str.2]
  mov edx, 2
  mov rdi, rax
  pop rbx
  jmp std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@PLT

Казалось бы, лаконичный и эффективный код может быть получен с использованием std::printf:

std::printf("The answer is %d.\n", 42);
  lea rdi, [rip + .L.str]
  mov esi, 42
  xor eax, eax
  jmp printf@PLT

Однако он все так же безальтернативно зависим от текущей локали и, кроме того, плохо дружит с современной, окружающей нас, реальностью. Рассмотрим следующий пример:

void greet(std::string_view name) {
  std::printf("Hello, %s!", name.data());
}

В общем случае этот код приводит к неопределенному поведению, так как %s спецификатор требует нуль-терминированную строку, тогда как строка, переданная через std::string_view, нуль-терминированной быть не обязана.

И, наконец, рассмотрим последний случай:

auto msg = std::format("The answer is {}.\n", 42);
std::fputs(msg.c_str(), stdout);

Будучи лишенным недостатков как потоков, так и std::printf, он все же конструирует не нужную нам временную строку, требует вызовов c_str и отдельной I/O функции. Кажется, будто можно сделать лучше.

Кроме того, говоря исключительно о проблемах производительности и совместимости, мы совершенно упустили из виду очень важную проблему, форматирование Unicode строк:

std::cout << "Привет, κόσμος!";

Если на большинстве Linux и MacOS систем вышеприведенный код выведет ровно то, что мы от него и ожидаем, то на Windows мы обречены увидеть кракозябры, например: ╨ƒ╤Ç╨╕╨▓╨╡╤é, ╬║╧î╧â╬╝╬┐╧é!, как бы мы этого не пытались избежать различными флагами компиляции.

Так что же, неужели в современных плюсах нет способа вывести форматированный текст без лишних накладных расходов так, чтобы он хотя бы на самых популярных современных системах (Linux, MacOS, Windows) отображался корректно? Даже Python так может, а мы не можем?

Спешу вас обрадовать, с C++23 мы можем не хуже, чем Python

Ведь у нас появился std::print, полностью дружащий с современными плюсами, абсолютно типобезопасный, выводящий UTF-8 корректно на всех системах, его поддерживающих, и при этом не менее эффективный, чем std::printf:

std::print("The answer is {}", 42);
  sub rsp, 24
  mov qword ptr [rsp], 42
  lea rdi, [rip + .L.str]
  mov rcx, rsp
  mov esi, 18
  mov edx, 1
  call fmt::v10::vprint(fmt::v10::basic_string_view<char>, fmt::v10::basic_format_args<fmt::v10::basic_format_context<fmt::v10::appender, char> >)@PLT
  add rsp, 24
  ret

Да, код, генерируемый std::printf продолжает оставаться самым маленьким по размеру, однако давайте рассмотрим бенчмарк, сравнивающий эталонную реализацию print, предоставляемую библиотекой libfmt, c printf и ostream:

#include <cstdio>
#include <iostream>

#include <benchmark/benchmark.h>
#include <fmt/ostream.h>

void printf(benchmark::State& s) {
  while (s.KeepRunning())
    std::printf("The answer is %d.\n", 42);
}
BENCHMARK(printf);

void ostream(benchmark::State& s) {
  std::ios::sync_with_stdio(false);
  while (s.KeepRunning())
    std::cout << "The answer is " << 42 << ".\n";
}
BENCHMARK(ostream);

void print(benchmark::State& s) {
  while (s.KeepRunning())
    fmt::print("The answer is {}.\n", 42);
}
BENCHMARK(print);

void print_cout(benchmark::State& s) {
  std::ios::sync_with_stdio(false);
  while (s.KeepRunning())
    fmt::print(std::cout, "The answer is {}.\n", 42);
}
BENCHMARK(print_cout);

void print_cout_sync(benchmark::State& s) {
  std::ios::sync_with_stdio(true);
  while (s.KeepRunning())
    fmt::print(std::cout, "The answer is {}.\n", 42);
}
BENCHMARK(print_cout_sync);

BENCHMARK_MAIN();

При компиляции с помощью apple clang 11.0.0 c флагами -O3 -DNDEBUG и запуске на MacOS 10.15.4 мы получаем следующие результаты:

Run on (8 X 2800 MHz CPU s)
CPU Caches:
  L1 Data 32K (x4)
  L1 Instruction 32K (x4)
  L2 Unified 262K (x4)
  L3 Unified 8388K (x1)
Load Average: 1.83, 1.88, 1.82
-----------------------------------------------------------
Benchmark                Time             CPU   Iterations
-----------------------------------------------------------
printf                87.0 ns         86.9 ns      7834009
ostream                255 ns          255 ns      2746434
print                 78.4 ns         78.3 ns      9095989
print_cout            89.4 ns         89.4 ns      7702973
print_cout_sync       91.5 ns         91.4 ns      7903889

print оказывается лидером: мало того, что код, генерируемый им, оказывается в несколько раз меньше, чем код, генерируемый потоками, так он еще при выводе в stdout оказывается на 14% быстрее, чем printf (который также по умолчанию выполняет вывод в stdout).

Как оптимально и кроссплатформенно вывести форматированный текст на экран в C++? Используйте std::print!

Незначительные нюансы

Правда, если вы используете отличные от Windows системы, вам сперва придется дождаться поддержки std::print в libc++ и libstdc++. По состоянию на 17.07.2023, он не поддерживается ни там, ни там.

Если же вам не хочется ждать, вы всегда можете использовать fmtlib.

Комментарии (10)


  1. lrrr11
    17.07.2023 11:45
    -1

    я согласен, что с привычными способами вывода мягко говоря не все так (кстати еще можно скомпилировать

    #include <iostream>
    int main() {}
    

    с -static-libstdc++ и посмотреть на размер получившегося бинарника)
    Однако и std::print не оптимальное решение проблемы. Советую посмотреть хотя бы на https://github.com/cppfastio/fast_io. А в общем случае видимо придется иметь дело с direct io, uring и вот этим всем.


    1. eoanermine Автор
      17.07.2023 11:45

      Спасибо, очень интересно было почитать документацию к fast_io. Однако это едва-ли можно назвать general purpose решением: прикручивать форматирование собственных классов в эту библиотеку, видится, очень больно (например, вот так реализуется поддержка std::complex).


  1. SpiderEkb
    17.07.2023 11:45
    -1

    Вы меня, конечно, извините, но

    fmt::print(std::cout, "The answer is {}.\n", 42);

    Это не вывод форматированного текста. Форматированный текс выглядит как у вас показано:

    Run on (8 X 2800 MHz CPU s)
    CPU Caches:
      L1 Data 32K (x4)
      L1 Instruction 32K (x4)
      L2 Unified 262K (x4)
      L3 Unified 8388K (x1)
    Load Average: 1.83, 1.88, 1.82
    -----------------------------------------------------------
    Benchmark                Time             CPU   Iterations
    -----------------------------------------------------------
    printf                87.0 ns         86.9 ns      7834009
    ostream                255 ns          255 ns      2746434
    print                 78.4 ns         78.3 ns      9095989
    print_cout            89.4 ns         89.4 ns      7702973
    print_cout_sync       91.5 ns         91.4 ns      7903889

    Т.е. кроме содержимого необходимо еще указывать формат для каждого поля - выравнивание, размер поля, чем заполнять и т.п.

    Строго говоря, оно даже вот так должно выглядеть:

    Run on (8 X 2800 MHz CPU s)
    CPU Caches:
      L1 Data 32K (x4)
      L1 Instruction 32K (x4)
      L2 Unified 262K (x4)
      L3 Unified 8388K (x1)
    Load Average: 1.83, 1.88, 1.82
    -----------------------------------------------------------
    Benchmark                Time             CPU   Iterations
    -----------------------------------------------------------
    printf                87.0 ns         86.9 ns      7834009
    ostream              255.0 ns        255.0 ns      2746434
    print                 78.4 ns         78.3 ns      9095989
    print_cout            89.4 ns         89.4 ns      7702973
    print_cout_sync       91.5 ns         91.4 ns      7903889

    Т.е. целое число 255 должно "добиться" до 255.0 и выровняться по правому краю. А все флоаты должны округлится (или хотя бы обрезаться) до указанного количества десятичных знаков. И тоже выровняться.

    Вот это будет вывод форматированного текста.

    В целом, все это есть в printf. Но там с локалями проблемы.

    Если это есть в srd::format или std::print - ну так и надо все это расписать.

    В противном случае заголовок статьи не соответствует содержанию.


  1. cher-nov
    17.07.2023 11:45
    -1

    Вы серьёзно утверждаете, что разбор форматной строки в run-time предпочтительнее compile-time?


    1. eoanermine Автор
      17.07.2023 11:45
      +2

      Только при использовании std::format, std::print, ... разбор форматной строки, если я правильно понял, что вы имеете ввиду, происходит в compile-time (см. std::basic_format_string)


      1. cher-nov
        17.07.2023 11:45
        -1

        А, хорошо. Я не специалист в Modern C++, увы. Просто меня смутил вот этот пассаж в статье:

        Ведь у нас появился std::print, <...> не менее эффективный, чем std::printf

        И при этом ни одного упоминания о разнице между временем исполнения, хотя это едва ли не главная причина, почему вывод текста через std::cout вообще изначально появился.


  1. CoolCmd
    17.07.2023 11:45
    +1

    кстати, по поводу размера. в visual c++ вызов std::format() одним махом увеличивает размер exe-файла на 200 KB. и в DLL со стандартной С++ библиотекой эта шаблонная магия лезть отказывается, в отличие от std::printf(). std::print() пока не завезли, но по идее размер уменьшится не должен.


  1. buldo
    17.07.2023 11:45

    А что по поводу кросс платформенных переносом строк? Как это красиво делать на C++?


  1. kekoz
    17.07.2023 11:45
    +1

    Дочитал до этого места.

    Если на большинстве Linux и MacOS систем вышеприведенный код выведет ровно то, что мы от него и ожидаем, то на Windows мы обречены увидеть кракозябры, например: ╨ƒ╤Ç╨╕╨▓╨╡╤é, ╬║╧î╧â╬╝╬┐╧é!, как бы мы этого не пытались избежать различными флагами компиляции.

    Автор, ЧЯДНТ?

    — Что-то с руками?    — Скорее, с головой...
    — Что-то с руками?
    — Скорее, с головой...

    Если в любой unicode-aware OS (и Windows входит в их число начиная примерно с NT 3.1) от этой программы вы увидите “кракозябры”, то проблема совершенно точно не в ключах компиляции...


    1. Quarc
      17.07.2023 11:45
      +1

      В консоли по умолчанию получатся "кракозябры"
      В консоли по умолчанию получатся "кракозябры"

      Исходник в UTF-8, консоль в CP866, "кракозябры" обязаны быть. И проблема определенно не в ключах компиляции, и вообще не имеет отношения ни к компилятору, ни к языку.