Работа со строками в С++. Часть 2: Форматирование

Операция составления единой строки, по предварительно заданному шаблону(формату), является едва ли не наиболее распространённой операцией, в приложениях любого размера. Вывод красивых строк в консоль, ведение журнала, отображение текста в зависимости от входных данных пользователя - всё это лишь малая толика того, как форматирование строк используется в современном ПО. Именно о способах форматирования строк в C++ и пойдёт речь в настоящей статье. Помимо стандартного подхода в форматировании строк, в статье будет, также, использована библиотека, которая не является частью стандарта C++: boost. Так как boost, это де-факто продолжение стандартной библиотеки C++, я считаю, что рассматривать C++ в отрыве от boost не целесообразно – boost должен быть в любом проекте, если в нём есть нужда. Это не вопрос выбора или предпочтений – boost это не стандартизированный стандарт.

Стандартная библиотека

Хотя в предыдущей статье я говорил, что C-строки мы рассматривать не будем, всё же, в настоящей статье я хочу обратиться к C-функциям по работе со строками, которые работают исключительно с C-строками. Почему? Причина проста – из этих функций выросли все остальные и их просто нельзя не упомянуть.

С-функции

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

#include <cstdio>
#include <iostream>

int main()
{
    int postalCode = 555555;
    char outString[50];//Какой размер должен быть?
    sprintf(outString, "Postal code is %d\n", postalCode);
    std::cout << outString;
    return 0;
}

Вывод:

Postal code is 555555

Хотя мы и добились своего – составили требуемую нам строку по шаблону, в коде выше довольно много проблем. Давайте разберём их одну за одной.

Размер выходной строки – какой должен быть размер буфера, чтобы вся строка поместилась в него после форматирования? Да, в нашем случае всё просто и мы могли бы это посчитать. Но есть случаи куда более сложные, где посчитать размер выходного буфера либо затруднительно, либо попросту невозможно. Представьте следующую ситуацию: мы, с клавиатуры, принимаем несколько чисел от пользователя, из которых, в последствии, формируем некую выходную строку. Мы не знаем как много цифр будет в каждом числе, поэтому мы не можем выделить буфер нужной длины. И никакой malloc нам в этом не поможет* – мы просто не можем вычислить длину. Таким образом, в старом коде(к сожалению и в новом тоже) можно очень часто встретить буферы размером, который выставляется от балды – 1024, к примеру, – авось хватит.

* Конечно, мы могли бы сначала преобразовать все числа в строки, посчитать их размер, а потом уже выделить необходимый буфер, но вы не находите. что это несколько сложновато?

Продолжая пример выше мы приходим к следующей проблеме: строка форматирования, которую принимает sprintf имеет довольно жёсткие правила. Например, в примере выше, мы использовали %d в строке форматирования, имея ввиду, что в дальнейшем мы предоставим целочисленный аргумент, который заменит этот спецификатор в выходной строке. Проблема в том, что мы связываем себя обязательством поставить именно целочисленный аргумент и именно определённого размера(int)! Что будет если мы изменим тип postalCode на float, или еще на что-нибудь в дальнейшем? sprintf просто проглотит новый аргумент не сказав ни слова, зато отобразив строку некорректно. Поэтому нужно быть очень внимательным, когда составляется строка форматирования – все типы должны быть указаны чётко и при их изменении, строка форматирования, также, должна быть изменена. Много кто об этом вспомнит? А если код использующий форматирование находится далеко от объявления переменной? Забыть или не знать, что спецификатор нужно изменить очень легко. Именно поэтому, подобные проблемы крайне не приятны.

Следующим недостатком является тот факт, что соответствие количества спецификаторов в строке форматирования и количества аргументов переданных в sprintf никак не проверяется. Чтобы понять в чем заключается проблема, нужно понимать как работает sprintf. В общих чертах алгоритм его работы можно описать следующим образом:

  1. Находим спецификатор, проверяя аргумент какого типа ожидается
  2. Извлекаем из стека нужное количество информации(4 байта для int, к примеру)
  3. Если не конец строки, то идём на шаг 1.

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

sprintf(outString, "Postal code is %d%d\n", postalCode);

то мы извлечём из стека дополнительное целое число и запишем его в нашу строку. Но откуда там возьмётся число, если мы его туда не клали? Ниоткуда, мы просто извлечём “мусорные” 4 байта, и, интерпретировав их как число, запишем в строку.

“Мусорные” потому, что мы не клали их туда в рамках данного вызова и не можем знать, что там лежит, если не смотреть окрестности вызова.

С помощью подобной “техники”, кстати, можно посмотреть содержимое стека(если вдруг отладчика нет под рукой).

Ну и последний недостаток заключается в следующем: sprintf никак не проверяет, что выходной буфер имеет достаточный размер, для хранения результирующей строки. Таким образом, мы можем так составить строку, что при записи её в буфер, мы перезапишем данные за пределы границ буфера(buffer overflow). Это в свою очередь испортит данные которые нам не принадлежат. Если со спецификатором %d это выглядит более-менее невинно, то это приобретает угрожающие тона с использование спецификатора %s. Представьте, что мы принимаем от пользователя некую строку, которую затем используем для вывода ему же на экран терминала:

// Мы хитрые, мы сделали большой буфер для ввода
// Тепеь нас точно никто не хакнет
char password[1000000];
//...
// Где-то тут происходит ввод пароля
//...
char outString[1024];// Сто процентов хватит, у кого есть пароль длиннее?
sprintf(outString, "You entered the following password: %s\n", password);
std::cout << outString;

Предположим, что буфер для хранения введённого пароля не может быть перезаписан т.к. используются некий метод, предотвращающий это. Сосредоточимся на вызове sprintf – что если пользователь ввёл пароль достаточной длины, чтобы результирующая строка получилась длиннее 1023 байтов? Тогда мы перезапишем часть стека, который расположен сразу за outString. Насколько это плохо? Это очень плохо – в лучшем случае приложение использующее подобный код упадёт, в худшем случае, если такой код выполняется в привилегированном режиме, злоумышленник может получить доступ к главному(god) пользователю системы. Я не буду расписывать принципы атаки, статья не об этом. Важно лишь помнит тот факт, что такая перезапись может произойти. Правда, в отличии от двух других недостатков, третий решается с помощью функции snprintf, которая является частью стандарта.

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

У sprintf есть также и обратная версия, которая разбирает строку и записывает данные в аргументы, согласно строке спецификатору – её имя sscanf. Мы не будем останавливаться на ней, т.к. самостоятельного интереса она более не представляет, а историю мы рассмотрели на примере sprintf.

Дедовский способ

Самым простым способом составления строк по заранее заданному формату, является слияние строк. Где в качестве шаблона выступает то, как вы расположите слияние в коде. Так, пример, который был представлен для sprintf может быть следующим образом записан с использованием слияния:

#include <string>
#include <iostream>

int main()
{
    std::string password;
    //...
    // Где-то тут происходит ввод пароля
    //...
    std::string outString{"You've entered the following password:"};
    outString += " " + password + "\n";
    std::cout << outString;
    return 0;
}

Все проблемы, которые были рассмотрены в параграфе о sprintf, этому коду не присущи. Таким образом, простое слияние строк полностью переигрывает sprintf в плане надёжности и корректности. С другой стороны, в общем случае, простое слияние будет куда более многословным и будет иметь куда меньше гибкости, если нам понадобится изменить шаблон. Ведь одно дело изменить строку, поменяв, к примеру, элементы(спецификаторы) местами и совсем другое дело исправлять несколько строк структурированного кода. Более того, если нам требуется слияние строки с интегральным типом, то многословность слияние сразу же возрастает. Давайте запишем первый пример из предыдущего параграфа в стиле слияния строк:

#include <string>
#include <iostream>

int main()
{
    int postalCode = 555555;
    std::string outString{"Postal code is"};
    outString += " " + std::to_string(postalCode) + "\n";
    std::cout << outString;
    return 0;
}

Если с одним целочисленным аргументом это выглядит худо-бедно приемлемо, то представьте, что будет, когда их будет с десяток?

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

#include <string>
#include <iostream>

class Contact
{
public:
    Contact(const std::string& name,
        const std::string& lastName):
        m_Name(name),
        m_LastName(lastName)
    {}
    std::string name() const
    {
        return m_Name;
    }
    std::string lastName() const
    {
        return m_LastName;
    }
private:
    std::string m_Name;
    std::string m_LastName;
};

std::string toString(const Contact& contact)
{
    return contact.name() + " " + contact.lastName();
}

int main()
{
    Contact dynamiter{"Robert", "Jordan"};
    std::string outString{"The dynamiter name is"};
    outString += " " + toString(dynamiter) + "\n";
    std::cout << outString;
    return 0;
}

Так или иначе, но нам нужно добавлять некую функцию, которая преобразует наш объект в строку, и которую никак нельзя опустить из нашего шаблона. Придется постоянно её вызывать. Принимая во внимание тот факт, что большинство составления строк всё таки используют либо интегральные типы, либо объекты классов приходится признать, что использование слияния для составления подобных строк вариант далеко не лучший. Благо в C++ есть другой вариант, который устраняет эту проблему весьма элегантно.

Потоки

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

#include <sstream>
#include <string>
#include <iostream>

int main()
{
    std::stringstream stream;
    std::string str{"String"};
    stream << "Some " << str;
    std::cout << stream.str() << "\n";
    return 0;
}

Вывод:

Some String

Как вы можете видеть, пример выше ничем, принципиально, не отличается от примеров, рассмотренных в параграфе о слиянии. Только вместо строки, теперь мы используем stringstream и вместо ‘+’ мы используем ‘<<’. Конечно, с точки зрения процесса, который происходит при записи в буфер и слияния срок,- они совершенно разные. Но я не буду вдаваться в подробности работы потоков(это потянет на отдельную статью, да и не на одну). В настоящий момент нас интересует только удобство использования и из примера выше совершенно не очевидно, почему использование потоков предпочтительнее простого слияния.

Использованный в коде stringstream является специализацией std::basic_stringstream для типа char. Как и в случае со string, мы будет говорить о нём как об отдельном классе, подразумевая, естественно, basic_stringstream

Чтобы понять преимущество потоков, нужно рассмотреть пример с postalCode:

#include <sstream>
#include <string>
#include <iostream>

int main()
{
    int postalCode = 555555;
    std::stringstream stream{"Postal code is "};
    stream.seekp(0, std::ios_base::end);
    stream << postalCode;
    std::cout << stream.str() << "\n";
    return 0;
}

Если вас пугает 9-я строчка, то мы можем переписать код следующим образом:

int postalCode = 555555;
std::stringstream stream;
stream << "Postal code is " << postalCode;
std::cout << stream.str() << "\n";

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

Как видно из примера, для записи postalCode в поток нам не пришлось преобразовывать int в string перед тем как передать его в поток. И вот здесь кроется неоспоримое преимущество потоков, над слиянием строк – запись в поток может быть сделана универсальной для любого типа. Это позволит нам писать любой объект в поток с помощью оператора <<. Как эта магия работает? Очень просто: чтобы можно было так просто записать int в поток, где-то должна существовать функция operator<< перегруженная как раз для такого случая. Она может выглядеть следующим образом:

std::basic_ostream<char>& operator<<(std::basic_ostream<char>& stream, 
    int value)
{
    stream << std::to_string(value);
    return stream;
}

Как вы видите, в определении оператора мы использовали std::basic_ostream<char>, что позволяет выводить в любой поток вывода, а не только в строковый. Кроме всего прочего, это позволяет использовать эту же функцию для вывода в стандартный поток вывода, представленный глобальным объектом std::cout.

Теперь, зная как писать собственный оператор <<, давайте переделаем пример с классом-контактом, из предыдущего параграфа, для работы с потоками(для краткости я приведу только новый код):

//...
std::basic_ostream<char>& operator<<(std::basic_ostream<char>& stream,
    const Contact& contact)
{
    stream << toString(contact);
    return stream;
}

int main()
{
    Contact dynamiter{"Robert", "Jordan"};
    std::stringstream stream;
    stream << "The dynamiter name is " << dynamiter;
    std::cout << stream.str() << "\n";
    return 0;
}

Вывод, разумеется, будет идентичен тому, что мы имели в прошлом параграфе. Как вы можете видеть, мы опять использовали функцию toString(), которую мы написали ранее. Мы могли бы избавится от неё вообще и перенести её реализацию в оператор <<, но я решил оставить как есть.

Кроме записи в поток, пользователь может, также, читать из потока, заполняя объекты данными из строки, преобразованными согласно переопределенному оператору >> для объекта. Как и в случае со sscanf – мы не будем подробно останавливаться на потоках ввода, в рамках данной статьи они нам не интересны.

Итак, с помощью потоков мы решили проблему с единообразием записи любых объектов в поток, получив, таким образом, решение, которое нас полностью удовлетворят. Или нет? Нет, всё таки – нет. Лично я вижу в использовании потоков, для формирования строки по шаблону, следующие недостатки:

  • Если нам необходимо изменить шаблон выходной строки, то нам придётся менять код. Это крайне не удобно. Шаблон, который может быть задан с помощью строки и спецификаторов, на мой взгляд, куда более удобен.
  • Для составления строки нам нужно выполнить как минимум 3 операции: создать поток, записать в поток и получить строку из потока. Три операции на одно действии заставляют программиста сопротивляться использованию подобных средств – они слишком многословны. Можно, конечно, записать все три операции в одну строку, но, в таком случае, строка получается довольно длинной.

К сожалению, потоки это лучший механизм, с помощью которого мы можем формировать строки с использованием стандартного С++. Чтобы найти что-то лучше, нам необходимо обратиться за помощью в boost.

Boost

Наиболее известной частью библиотеки boost, в части формирования строк по шаблону, является boost.format. Это довольно старая библиотека, которая призвана заменить небезопасный sprintf, сохранив удобства его использования. Вот как мы можем записать пример с postalCode, использую boost:

#include <iostream>
#include <boost/format.hpp>

int main()
{
    int postalCode = 555555;
    boost::format outFormat{"Postal code is %d\n"};
    outFormat % postalCode;
    std::cout << outFormat.str();
    return 0;
}

Мы уже использовали ‘+’, ’<<’ теперь будем использовать еще и ’%’?! Почему бы не придерживаться единого стиля?

Как мы можем видеть из кода, у нас получился некий симбиоз из потоков и sprintf’а, только с процентами вместо ‘<<’. Получается, что мы решили первую, из упомянутых выше, проблему потоков. Правда я использовал спецификатор %d выше, который добавляет нам проблему указанную в параграфе повествующем о sprintf. Но, к счастью, нет никакой нужды использовать этот спецификатор, он реализован в boost.format только в качестве поддержки старых строк форматирования! Новый же синтаксис отличается в лучшую сторону:

int main()
{
    int postalCode = 555555;
    std::cout << (boost::format("Postal code is %1%\n") % postalCode).str();
    return 0;
}

Заметьте, что вместо %d мы использовали %1%. В новом синтаксисе каждый %N% в результирующей строке будет заменён на объект, который будет скормлен объекту format N-ным по счёту:

int main()
{
    std::cout << (boost::format("%3% %2% %1%\n") % 1 % 2 % 3).str();
    return 0;
}

Вывод:

3 2 1

Почему аргументы считаются с единицы, а не с нуля? Это вносит сумятицу. На мой взгляд авторы приняли не верное решение.

При этом format защищает нас от некорректного числа “скормленных” аргументов, проверяя, чтобы все спецификаторы, указанные в строке форматирования, получили свой аргумент перед тем, как будет запрошена результирующая строка с помощью метода format::str(). Если будет найдено несоответствие, тогда будет брошено исключение, которое сообщит нам если мы “перекормили” или “недокормили” format. Также, исключение будет брошено если строка форматирования составлена не верно. К примеру, если мы описались и забыли один символ процента:

std::cout << (boost::format("%1\n") % 1).str();

В этом случае мы, также, получим исключение. Таким образом получается, что format решает все проблемы, которые были связаны со строками форматирования в старом-добром C. Но что делать с объектами классов, как их выводить в строку? Тут, на самом деле, всё просто. Но мы сделаем небольшое отступление и, в общих чертах, разберёмся как работает format.

Я не зря упомянул, что использование format схоже с потоками,- format устроен так же как они. Единственное его отличие заключается в том, что он использует строку форматирования и, следовательно, проверяет, что и как записывается во внутренний буфер. А раз это, по сути, лишь обёртка над потоком, то и для того, чтобы записать пользовательский объект в format, нужно всего лишь переопределить функцию operator<<(). Так как мы уже сделали это для класса Contact мы можем “скормить” его format:

int main()
{
    Contact dynamiter{"Robert", "Jordan"};
    std::cout << (boost::format("The dynamiter name is %1%\n") % dynamiter).str();
    return 0;
}

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

Хотя boost.format решает все проблемы, которые ранее были упомянуты, всё же остается ещё одна проблема о которой я не упоминал ранее – расположенность к локализации. boost.format совершенно ничего не знает о локализации, он для этого просто не предназначен, поэтому если вы планируете локализовывать строку форматирования, то есть хорошие шансы, что с помощью boost.format у вас это выйдет не лучшим образом.

Locale.Format

Для базового использования, boost.locale.format практически не отличается от boost.format, просто вместо процентов в строке форматирования используется фигурные скобки “{}”, à la C#. Так, наш последний пример, с применением locale.format, будет выглядеть следующим образом:

#include <boost/locale/format.hpp>

...

int main()
{
    Contact dynamiter{"Robert", "Jordan"};
    std::cout << (boost::locale::format("The dynamiter name is {1}\n")
        % dynamiter).str();
    return 0;
}

Конечно, отличия у этих двух механизмов простираются дальше, чем замена процентов на фигурные скобки. Подробнее о том, чем отличается boost.locale.format от простого boost.format можно посмотреть тут. Так как локализация это огромный раздел, который никак не вместить в рамки данной статьи, то и рассматривать отличия здесь мы не станем. Нам достаточно того факта, то в boost существует 2 альтернативы, с помощью которых можно безопасно сформировать строку, по заранее заданному шаблону.

На мой взгляд, синтаксис с фигурными скобками лучше, чем синтаксис с процентами и я бы всегда использовал boost.locale.format если бы не одно но – boost.format, это библиотека, которая состоит из одних заголовков, тогда как boost.locale нужно собирать. Кроме того, boost.locale тянет за собой и другие библиотеки boost. Да, если boost интенсивно используется в проекте, то это не беда. Если же только ради boost.locale.format придётся добавлять линковку с библиотеками boost, а в целом нужды в boost.locale нет, то это контрпродуктивно, на мой взгляд.

Итак, если вы были внимательны, то могли заметить, что изо всех перечисленных мною проблем, одна, всё же, остается не решённой – слишком много писанины, чтобы сформировать строку. Да, однострочный boost::[locale::]format выглядит лучше, чем его трёхстрочный вариант, но всё это “скармливание” аргументов с помощью ‘%’, с последующим получением строки лично у меня вызывает отторжение. К сожалению, более приятного синтаксиса в C++ и boost не существует, поэтому придётся изобретать велосипед.

Эталонная синтаксис

В качестве эталонного синтаксиса, предлагаю взять string.FormatString из C#. Таким образом мы должны получить нечто похожее на следующий код:

string.Format("Fibonacci numbers are {0}, {1}, {2}, {3}, {4} ...",
    1, 1, 2, 3, 5);

Для нашего велосипеда понадобятся следующие вспомогательные функции:

template<typename First, typename... Args>
std::string format(const std::string& formatString, 
    First&& firstArg, Args&&... arg)
{
    boost::format formatter{formatString};
    boost::format* list[] = {&(formatter % firstArg), &(formatter % arg)...};
    (void)list;
    return formatter.str();
}

std::string format(const std::string& string)
{
    return string;
}

std::string format()
{
    BOOST_ASSERT_MSG(false, "Format may not be used without arguments");
    return {};
}

Если вы не понимаете, что происходит в первой функции, то рекомендую статью к прочтению. Если же и после прочтения статьи всё равно непонятно, то напишите, пожалуйста, в комментариях.

Как вы можете видеть, мы написали несколько вспомогательных функций, которые формируют результирующую строку с помощью boost.format. В принципе, их можно использовать без добавления каких либо других сущностей(если вы сторонник Оккама). Я же предпочитаю обернуть это всё в единый макрос:

#define FORMAT_MSG(...) format(__VA_ARGS__)

И вот как теперь  мы можем это использовать:

int main()
{
    auto msg = FORMAT_MSG("Fibonacci numbers are %1%, %2%, %3%, %4%, %5% ...",
        1, 1, 2, 3, 5);
    std::cout << msg << "\n";
    return 0;
}

Вывод:

Fibonacci numbers are 1, 1, 2, 3, 5 ...

Можно было бы ещё сократить имя макроса, но тут уже по желанию. Можно вообще его обозвать как ‘_’ и ждать, что не будет коллизий. В целом, я считаю, что данная запись является наиболее удобной при формировании строк по шаблону. Конечно, тут всё ещё остаются, мною не любимые, проценты, но с ними уже я бороться не стал. Можно использовать такую же обёртку для boost.locale.format – она ничем не будет отличаться, или же, использовать boost.format, но подменять символы в функции-помощнике. Это, безусловно, снизит производительность. Но если она не критична, то можно поступить именно так.

Сравнительные характеристики и выводы

Завершить статью не измерив производительность разных подходов было бы кощунством, поэтому мы всё таки измерим и посмотрим, какой подход победит в гонке. Для тестирования производительности использовался следующий код:

#include <boost/format.hpp>
#include <boost/locale/format.hpp>
#include <string>
#include <iostream>
#include <vector>
#include <numeric>
#include <chrono>
#include <cstdio>

using namespace std;
using namespace std::chrono;

int main()
{
    // Заготавливаем данные
    vector<int> ints(90000);
    iota(ints.begin(), ints.end(), 0);
    srand(static_cast<unsigned>(time(0)));
    random_shuffle(begin(ints), end(ints));
    vector<double> doubles(ints.size());
    transform(ints.begin(), ints.end(), 
        ints.rbegin(), doubles.begin(), 
        [](int first, int second)
        {
            return second != 0 ? double(first)/second : first;
        });
    random_shuffle(begin(doubles), end(doubles));
    string str{"I'm just a string!"};
    // boost.format
    auto start = high_resolution_clock::now();
    vector<string> strings(ints.size()*5);
    for(size_t i = 0; i < ints.size(); ++i)
    {
        auto fmtStr = (boost::format("%1% %2% %3% %4% %5%")
            % ints[i] % doubles[i] % str % ints[i] % doubles[i]).str();
        strings.push_back(fmtStr);
    }
    auto boostFmtElapsed = (high_resolution_clock::now() - start).count();
    // boost.locale.format
    start = high_resolution_clock::now();
    for(size_t i = 0; i < ints.size(); ++i)
    {
        auto fmtStr = (boost::locale::format("{1} {2} {3} {4} {5}")
            % ints[i] % doubles[i] % str % ints[i] % doubles[i]).str();
        strings.push_back(fmtStr);
    }
    auto localeFmtElapsed = (high_resolution_clock::now() - start).count();
    // sprintf
    vector<char*> cstrings(ints.size());
    start = high_resolution_clock::now();
    for(size_t i = 0; i < ints.size(); ++i)
    {
        char buffer[1024] = {};
        sprintf(buffer, "%d %f %s %d %f", ints[i], doubles[i], str.c_str(),
            ints[i], doubles[i]);
        cstrings.push_back(buffer);
    }
    auto sprintfFmtElapsed = (high_resolution_clock::now() - start).count();
    // C++ потоки
    start = high_resolution_clock::now();
    for(size_t i = 0; i < ints.size(); ++i)
    {
        stringstream stream;
        stream << ints[i] << " " << doubles[i] << " " << str << " "
            << ints[i] << " " << doubles[i];
        strings.push_back(stream.str());
    }
    auto streamFmtElapsed = (high_resolution_clock::now() - start).count();
    // Простое склеивание
    start = high_resolution_clock::now();
    for(size_t i = 0; i < ints.size(); ++i)
    {
        string outString;
        outString += to_string(ints[i]) + " " +
            to_string(doubles[i]) + " " + str + " " +
            to_string(ints[i]) + " " + to_string(doubles[i]);
        strings.push_back(outString);
    }
    auto concatElapsed = (high_resolution_clock::now() - start).count();

    cout << "boost.format: " << boostFmtElapsed << "\n";
    cout << "boost.locale.format: " << localeFmtElapsed << "\n";
    cout << "sprintf: " << sprintfFmtElapsed << "\n";
    cout << "C++ streams: " << streamFmtElapsed << "\n";
    cout << "Concatenation: " << concatElapsed << "\n";
    return 0;
}

Как можно видеть из кода выше я сравнил все 5 рассмотренных в статье подходов. Тест я запускал 8 раз на MSVC 2013 Update 3, после чего получил следующие усреднённые результаты:

Метод

Время

Процентное соотношение

boost.format

5150132

438%

boost.locale.format

5575144

474%

sprintf

1174050

100%

Потоки

3204587

272%

Слияние

2470686

210%

В целом, результаты ни для кого не должны быть неожиданностью. Хотя форматирование представленное библиотекой boost наиболее безопасное и удобное, при этом, оно ещё и наиболее медленное. Разница между boost.locale.format и boost.format не существенная и, в целом, может быть проигно��ирована. С другой стороны, если строка составляется в критичном для производительности месте, то очевидно, что стоит поступиться удобством и отказаться от boost. Для большинства же случаев, производительность составления строки не играет решающей роли и, таким образом, не является узким местом. Поэтому использование boost[.locale].format, в большинстве случаев, на мой взгляд, оправданно и целесообразно.