Форматирование текста на 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::forma
t этот вариант не сработает: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)
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? Возможно ли такое через потоки?Без иронии, сарказма и подвоха. Действительно интересно.
gudvinr
22.09.2023 13:32+7Возможно ли такое через потоки?
https://en.cppreference.com/w/cpp/io/manip/setfill и вообще iomanip
SpiderEkb
22.09.2023 13:32+1Спасибо. Давно не работал с подобными вещами на С++. Потому и интересно чего такое стоить будет.
domix32
22.09.2023 13:32Учитывая что там статичный текст форматируют я думаю там над нормальным кодом автор особо не задумывался, не говоря уже про вывод нормальных результатов.
Karopka
22.09.2023 13:32+3Эх, а я-то уж подумал, что в статье речь о новом революционном методе форматирования C++ исходников !
billyevans
22.09.2023 13:32По-моему чисто с точки зрения чтения идея форматировать операцией двоичного сдвига чудовищная.
Torvald3d
22.09.2023 13:32Интересно было бы ещё сравнить форматирование с Qt, понятно что это уже сторонняя либа, но всё же.
domix32
22.09.2023 13:32Это там где
"%1 %2 %3 %1" % (1,2,3)
? Так оно ж супер неудобное. Скорость будет на уровне printf, только памяти в некоторых случаях будет тратить несколько больше.Torvald3d
22.09.2023 13:32Да, но не только. Там ещё есть
asprintf
vasprintf
QTextStream
ну и arg
Вполне возможно, что это просто обёртки над std со своими доработками. Но вот это как раз и было бы интересно проверить, на сколько медленнее (быстрее) по сравнению с std
domix32
22.09.2023 13:32asprintf и vasprintf платформозависимые функции из Си, не С++.
QTextStream в целом по поведению похожа на стандартные стримы, врядли там будет что-то особенное, разве что от тонкой настройки что-то меняется, но тут уже экспоненциальный взрыв вариативности.
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));
s-a-u-r-o-n
22.09.2023 13:32Попытка проделать то же самое с помощью std::format снова провалится, поскольку эта команда не знает, как форматировать массив:
Автор точно переводчик? Это правильно переводится как «потерпит неудачу».
SalazarMAX
Иронично, в статье про форматирование текста куски кода вообще не отформатированы, из-за чего практически нечитаемы (молчу про горизонтальную прокрутку).
Bright_Translate Автор
Спасибо. Сейчас поправим.