Форматирование текста на C++ можно реализовать несколькими способами:

  • потоками ввода-вывода. В частности, через std::stringstream с помощью потоковых операций (таких как operator <<);
  • функциями printf, в частности sprintf;
  • с помощью библиотеки форматирования C++20, в частности std::format / std::format_to;
  • с помощью сторонней библиотеки, в частности {fmt} (основа новой стандартной библиотеки форматирования).

Первые два варианта представляют старые способы. Библиотека форматирования, очевидно, является новым. Но какой из них лучше в плане производительности? Это я и решил выяснить.

▍ Примеры


Для начала разберём простые примеры форматирования текста. Предположим, нам нужно отформатировать текст в виде "severity=1,error=42,reason=access denied". Это можно сделать так:

• с помощью потоков:

int severity = 1;
unsigned error = 42;
reason = "access denied";

std::stringstream ss;
ss << "severity=" << severity
   << ",error=" << error
   << ",reason=" << reason;

std::string text = ss.str();

• с помощью printf:

int severity = 1;
unsigned error = 42;
reason = "access denied";

std::string text(50, '\0');
sprintf(text.data(), "severity=%d,error=%u,reason=%s", severity, error, reason);

• с помощью format:

int severity = 1;
unsigned error = 42;
reason = "access denied";

std::string text = std::format("severity={},error={},reason={}", severity, error, reason);

// либо

std::string text;
std::format_to(std::back_inserter(text), "severity={},error={},reason={}", severity, error, reason);

Вариант с std::format во многом похож на printf, хотя здесь вам не нужно указывать спецификаторы типов, такие как %d, %u, %s, только плейсхолдер аргумента {}. Естественно, спецификаторы типов доступны, и о них можно почитать тут, но эта тема не относится к сути статьи.

Вариант с std::format_to полезен для добавления текста, поскольку производит запись в выходной буфер через итератор. Это позволяет нам присоединять текст условно, как в примере ниже, где reason записывается в сообщение, только если содержит что-либо:

std::string text = std::format("severity={},error={}", severity, error);

if(!reason.empty())
  std::format_to(std::back_inserter(text), ",reason=", reason);


▍ Сравнение производительности


При всех этих вариантах возникает вопрос, а какой из них лучше? Как правило, потоковые операции медленные, в то время как {fmt} — отличается высокой скоростью. Но не все случаи равнозначны, и обычно, когда вы хотите внести оптимизацию, то должны оценить ситуацию, а не опираться на общее понимание.

Недавно я задал себе этот вопрос, когда заметил в своём текущем проекте обширное использование std::stringstream для форматирования сообщений журнала. В большинстве случаев там присутствует от одного до трёх аргументов. Вот пример:

std::stringstream ss;
ss << "component id: " << id;

std::string msg = ss.str();

// либо

std::stringstream ss;
ss << "source: " << source << "|code=" << code;

std::string msg = ss.str();

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

  • форматирует текст в виде "Number 42 is great!";
  • сравнивает std::stringstream, sprintf, std::format и std::format_to;
  • выполняет переменное число итераций, от 1 до 1000000, и определяет среднее время одной итерации.

int main()
{
   {
      std::stringstream ss;
      ss << 42;
   }

   using namespace std::chrono_literals;

   std::random_device rd{};
   auto mtgen = std::mt19937{ rd() };
   auto ud = std::uniform_int_distribution<>{ -1000000, 1000000 };

   std::vector<int> iterations{ 1, 2, 5, 10, 100, 1000, 10000, 100000, 1000000 };

   std::println("{:>10} {:>12} {:>7} {:>9} {:>6}", "iterations", "stringstream", "sprintf", "format_to", "format");
   std::println("{:>10} {:>12} {:>7} {:>9} {:>6}", "----------", "------------", "-------", "---------", "------");

   for (int count : iterations)
   {
      std::vector<int> numbers(count);
      for (std::size_t i = 0; i < numbers.size(); ++i)
      {
         numbers[i] = ud(mtgen);
      }

      long long t1, t2, t3, t4;

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::stringstream ss;
            ss << "Number " << numbers[i] << " is great!";
            std::string s = ss.str();
         }

         auto end = std::chrono::high_resolution_clock::now();
         t1 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string str(100, '\0');
            std::sprintf(str.data(), "Number %d is great!", numbers[i]);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t2 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string s;
            std::format_to(std::back_inserter(s), "Number {} is great!", numbers[i]);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t3 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string s = std::format("Number {} is great!", numbers[i]);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t4 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      std::println("{:<10} {:<12.2f} {:<7.2f} {:<9.2f} {:<7.2f}", count, t1/1000.0 / count, t2 / 1000.0 / count, t3 / 1000.0 / count, t4 / 1000.0 / count);
   }
}

Результаты каждого выполнения немного отличаются и на разных машинах тоже будут разными. На моей 64-битная версия программы выдаёт следующие показатели (время в мкс):
Количество итераций stringstream sprintf format_to format
1 29.60 11.80 1.80 0.60
2 10.00 4.20 0.55 0.50
5 1.56 0.56 0.34 0.26
10 1.61 1.15 0.26 0.31
100 1.15 0.28 0.22 0.26
1000 1.17 0.30 0.24 0.26
10 000 1.29 0.28 0.23 0.24
100 000 0.87 0.18 0.15 0.16
1 000 000 0.74 0.18 0.15 0.16
Если прогнать цикл один раз, то sprintf, как правило, оказывается в 2-3 раза быстрее std::stringstream. При этом std::format/std::format_to опережают std::stringstream в 20-30 раз, оказываясь быстрее sprintf в 5-20 раз. При увеличении количества итераций эти показатели изменяются, но std::format всё равно остаётся примерно в 5 раз быстрее std::stringstream и чаще всего наравне с sprintf. Поскольку в моём случае генерация сообщений журнала не выполняется в цикле, я могу заключить, что ускорение может составить 20-30 крат.

В случае когда в выходной текст записываются 2 аргумента, показатели оказываются схожи. Для генерации текста в виде "Numbers 42 and 43 are great!" программа отличается лишь немного:

int main()
{
   {
      std::stringstream ss;
      ss << 42;
   }

   using namespace std::chrono_literals;

   std::random_device rd{};
   auto mtgen = std::mt19937{ rd() };
   auto ud = std::uniform_int_distribution<>{ -1000000, 1000000 };

   std::vector<int> iterations{ 1, 2, 5, 10, 100, 1000, 10000, 100000, 1000000 };

   std::println("{:>10} {:>12} {:>7} {:>9} {:>6}", "iterations", "stringstream", "sprintf", "format_to", "format");
   std::println("{:>10} {:>12} {:>7} {:>9} {:>6}", "----------", "------------", "-------", "---------", "------");

   for (int count : iterations)
   {
      std::vector<int> numbers(count);
      for (std::size_t i = 0; i < numbers.size(); ++i)
      {
         numbers[i] = ud(mtgen);
      }

      long long t1, t2, t3, t4;

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::stringstream ss;
            ss << "Numbers " << numbers[i] << " and " << numbers[i] + 1 << " are great!";
            std::string s = ss.str();
         }

         auto end = std::chrono::high_resolution_clock::now();
         t1 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string str(100, '\0');
            sprintf(str.data(), "Numbers %d and %d are great!", numbers[i], numbers[i] + 1);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t2 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string s;
            std::format_to(std::back_inserter(s), "Numbers {} and {} are great!", numbers[i], numbers[i] + 1);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t3 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      {
         auto start = std::chrono::high_resolution_clock::now();

         for (std::size_t i = 0; i < numbers.size(); ++i)
         {
            std::string s = std::format("Numbers {} and {} are great!", numbers[i], numbers[i] + 1);
         }

         auto end = std::chrono::high_resolution_clock::now();
         t4 = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
      }

      std::println("{:<10} {:<12.2} {:<7.2} {:<9.2} {:<7.2}", count, t1 / 1000.0 / count, t2 / 1000.0 / count, t3 / 1000.0 / count, t4 / 1000.0 / count);
   }
}

Результаты оказываются в том же диапазоне, что и прежде. Хотя, опять же, от выполнения к выполнению отличаются:
Количество итераций stringstream sprintf format_to format
1 27 4.7 5.8 0.8
2 8.1 1.4 0.9 0.75
5 3.4 0.8 0.62 0.46
10 4.3 0.82 0.44 0.38
100 1.9 0.45 0.31 0.33
1000 1.9 0.46 0.37 0.35
10 000 1.8 0.38 0.29 0.31
100 000 1.3 0.26 0.22 0.24
1 000 000 1.2 0.27 0.23 0.25

▍ Совместимость


Несмотря на то, что в большинстве случаев перейти с std::stringstream на std::format легко, существуют определённые отличия, требующие дополнительной работы. К примерам можно отнести форматирование указателей и массивов беззнаковых символов.

Можно легко записать значение указателя в буфер вывода следующим образом:

int a = 42;

std::stringstream ss;
ss << "address=" << &a;
std::string text = ss.str();

Итоговый текст будет иметь вид "address=00000004D4DAE218". Но с std::format этот вариант не сработает:

int a = 42;

std::string text = std::format("address={}", &a); // ошибка; не знает, как форматировать

Данный фрагмент кода выдаст ошибки (отличающиеся в зависимости от компилятора), поскольку не знает, как форматировать указатель. Вы можете получить те же результаты, что и прежде, рассматривая указатель как значение std::size_t и используя спецификатор форматирования, такой как :016X (16 шестнадцатеричных цифр с ведущими нулями):

std::string text = std::format("address={:016X}", reinterpret_cast<std::size_t>(&a));

Теперь результат будет одинаковым (хотя нужно помнить, что для 32-битных указателей используется лишь 8 шестнадцатеричных цифр).

Вот ещё один пример с массивами беззнаковых символов, которые std::stringstream при записи в буфер вывода преобразует в char:

unsigned char str[]{3,4,5,6,0};

std::stringstream ss;
ss << "str=" << str;
std::string text = ss.str();

Содержимым текста будет "str=♥♦♣♠".

Попытка проделать то же самое с помощью std::format снова провалится, поскольку эта команда не знает, как форматировать массив:

std::string text = std::format("str={}", str); // ошибка; не знает, как форматировать

Можно записать содержимое массива с помощью цикла так:

std::string text = "str=";
for (auto c : str)
   std::format_to(std::back_inserter(text), "{}", c);

Содержимым текста будет "str=34560", потому что каждый unsigned char записывается в буфер вывода как есть без приведения. Чтобы получить те же результаты, что и прежде, необходимо выполнить приведение явно:

std::string text = "str=";
for (auto c : str)
   std::format_to(std::back_inserter(text), "{}", static_cast<char>(c));

▍ Кстати


Если вы форматируете текст для вывода в консоль и используете результат std::format / std::format_to через std::cout (или другие альтернативы), то в С++23, где появились std::print и std::println, для этого нет необходимости:

int severity = 1;
unsigned error = 42;
reason = "access denied";

std::println("severity={},error={},reason={}", severity, error, reason);

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ????️

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


  1. SalazarMAX
    22.09.2023 13:32
    +5

    Иронично, в статье про форматирование текста куски кода вообще не отформатированы, из-за чего практически нечитаемы (молчу про горизонтальную прокрутку).


    1. Bright_Translate Автор
      22.09.2023 13:32

      Спасибо. Сейчас поправим.


  1. SpiderEkb
    22.09.2023 13:32
    +4

    А можно все тоже самое, но действительно с форматированием?

    Ну, например, когда нужно вывести легко читаемую табличку типа

    ============ VPN ============ 
    Count          : 131184     
    
    Fill                        
    Average File   :    10.542871  
    Average Idx    :     4.629779   
    Minimum File   :     7.000000   
    Minimum Idx    :     0.000000    
    Maximum File   :   783.000000 
    Maximum Idx    :  2584.000000
    SQE File       :     5.472801   
    SQE Idx        :    20.577609  
    Dispersion File:    29.951783  
    Dispersion Idx :   423.441226 
    
    Search                      
    Average File   :     7.602459  
    Average Idx    :     3.674952  
    Minimum File   :     0.000000   
    Minimum Idx    :     0.000000   
    Maximum File   :   135.000000
    Maximum Idx    :   128.000000
    SQE File       :     2.360294  
    SQE Idx        :     4.086529  
    Dispersion File:     5.571032  
    Dispersion Idx :    16.699851

    Т.е. дополнение нулями, выравнивание по десятичному разделителю и т.п.
    Насколько для таких операций fmt будет лучше/хуже printf? Возможно ли такое через потоки?

    Без иронии, сарказма и подвоха. Действительно интересно.


    1. gudvinr
      22.09.2023 13:32
      +7

      Возможно ли такое через потоки?

      https://en.cppreference.com/w/cpp/io/manip/setfill и вообще iomanip


      1. SpiderEkb
        22.09.2023 13:32
        +1

        Спасибо. Давно не работал с подобными вещами на С++. Потому и интересно чего такое стоить будет.


    1. domix32
      22.09.2023 13:32

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


  1. Karopka
    22.09.2023 13:32
    +3

    Эх, а я-то уж подумал, что в статье речь о новом революционном методе форматирования C++ исходников !


  1. billyevans
    22.09.2023 13:32

    По-моему чисто с точки зрения чтения идея форматировать операцией двоичного сдвига чудовищная.


  1. Torvald3d
    22.09.2023 13:32

    Интересно было бы ещё сравнить форматирование с Qt, понятно что это уже сторонняя либа, но всё же.


    1. domix32
      22.09.2023 13:32

      Это там где "%1 %2 %3 %1" % (1,2,3)? Так оно ж супер неудобное. Скорость будет на уровне printf, только памяти в некоторых случаях будет тратить несколько больше.


      1. Torvald3d
        22.09.2023 13:32

        Да, но не только. Там ещё есть

        • asprintf

        • vasprintf

        • QTextStream

        • ну и arg

        Вполне возможно, что это просто обёртки над std со своими доработками. Но вот это как раз и было бы интересно проверить, на сколько медленнее (быстрее) по сравнению с std


        1. domix32
          22.09.2023 13:32

          asprintf и vasprintf платформозависимые функции из Си, не С++.

          QTextStream в целом по поведению похожа на стандартные стримы, врядли там будет что-то особенное, разве что от тонкой настройки что-то меняется, но тут уже экспоненциальный взрыв вариативности.


  1. sergio_nsk
    22.09.2023 13:32
    +1

    Итоговый текст будет иметь вид "address=00000004D4DAE218". Но с std::format этот вариант не сработает

    Чтобы сработало, нужно привести аргумент к void* (если я не общаюсь с std, это точно работает с библиотекой fmt):

    int a = 42;
    
    std::string text = std::format("address={}", static_cast<void*>(&a));


  1. s-a-u-r-o-n
    22.09.2023 13:32

    Попытка проделать то же самое с помощью std::format снова провалится, поскольку эта команда не знает, как форматировать массив:

    Автор точно переводчик? Это правильно переводится как «потерпит неудачу».