Доброго времени суток!
В этой статье я хотел бы рассказать о существующих возможностях строкового форматирования в современном C++, показать свои наработки, которые я уже несколько лет использую в реальных проектах, а также сравнить производительность различных подходов к строковому форматированию.
Строковое форматирование — это операция, позволяющая получить результирующую строку из строки-шаблона и набора аргументов. Строка-шаблон содержит текст, в который включены местозаполнители (placeholders), вместо которых подставляются аргументы.
Для наглядности небольшой пример:
int apples = 5;
int oranges = 7;
std::string str = format("I have %d apples and %d oranges, so I have %d fruits", apples, oranges, apples + oranges);
std::cout << str << std::endl;
Здесь:
Строка-шаблон: I have %d apples and %d oranges, so I have %d fruits
Местозаполнители: %d, %d, %d
Аргументы: apples, oranges, apples + oranges
При выполнении примера, получаем результирующую строку
I have 5 apples and 7 oranges, so I have 12 fruits
Теперь посмотрим, что же нам предоставляет C++ для строкового форматирования.
Наследие C
Строковое форматирование в C осуществляется с помощью семейства функций Xprintf. С тем же успехом, мы можем воспользоваться этими функциями и в C++:
char buf[100];
int res = snprintf(buf, sizeof(buf), "I have %d apples and %d oranges, so I have %d fruits", apples, oranges, apples + oranges);
std::string str = "error!";
if (res >= 0 && res < sizeof(buf))
str = buf;
std::cout << str << std::endl;
Это довольно неплохой способ форматирования, несмотря на кажущуюся неуклюжесть:
- это самый быстрый способ строкового форматирования
- этот способ работает практически на всех версиях компиляторов, не требуя поддержки новых стандартов
Но, конечно, не обошлось и без недостатков:
- нужно знать заранее сколько памяти потребуется для результирующей строки, что не всегда возможно определить
- соответствие количества и типа аргументов и местозаполнителей не проверяется при передаче параметров извне (как в обертке над vsnprintf, реализованной ниже), что может привести к ошибкам при выполнении программы
Функция std::to_string()
Начиная с C++11 в стандартной библиотеке появилась функция std::to_string(), которая позволяет преобразовать передаваемое значение в строку. Функция работает не со всеми типами аргументов, а только со следующими:
- int
- long
- long long
- unsinged int
- unsinged long
- unsigned long long
- float
- double
- long double
Пример использования:
std::string str = "I have " + std::to_string(apples) + " apples and " + std::to_string(oranges) + " oranges, so I have " + std::to_string(apples + oranges) + " fruits";
std::cout << str << std::endl;
Класс std::stringstream
Класс std::stringstream — это основной способ строкового форматирования, который нам предоставляет C++:
std::stringstream ss;
ss << "I have " << apples << " apples and " << oranges << " oranges, so I have " << apples + oranges << " fruits";
std::string str = ss.str();
std::cout << str << std::endl;
Строго говоря, использование std::stringstream не является в полной мере строковым форматированием, так как вместо местозаполнителей мы вставляем в строку-шаблон аргументы. Это допустимо в простейших случаях, но в более сложных существенно ухудшает читаемость кода:
ss << "A[" << i1 << ", " << j1 << "] + A[" << i2 << ", " << j2 << "] = " << A[i1][j1] + A[i2][j2];
сравните с:
std::string str = format("A[%d, %d] + A[%d, %d] = %d", i1, j1, i2, j2, A[i1][j1] + A[i2][j2]);
Объект std::sringstream позволяет реализовать несколько интересных оберток, которые могут понадобится в дальнейшем.
Преобразование "чего угодно" в строку:
template<typename T> std::string to_string(const T &t)
{
std::stringstream ss;
ss << t;
return ss.str();
}
std::string str = to_string("5");
Преобразование строки во "что угодно":
template<typename T> T from_string(const std::string &str)
{
std::stringstream ss(str);
T t;
ss >> t;
return t;
}
int x = from_string<int>("5");
Преобразование строки во "что угодно" с проверкой:
template<typename T> T from_string(const std::string &str, bool &ok)
{
std::stringstream ss(str);
T t;
ss >> t;
ok = !ss.fail();
return t;
}
bool ok = false;
int x = from_string<int>("x5", ok);
if (!ok) ...
Также, можно написать пару оберток для удобного использования std::stringstream в одну строку.
Использование объекта std::stringstream для каждого аргумента:
class fstr final : public std::string
{
public:
fstr(const std::string &str = "")
{
*this += str;
}
template<typename T> fstr &operator<<(const T &t)
{
*this += to_string(t);
return *this;
}
};
std::string str = fstr() << "I have " << apples << " apples and " << oranges << " oranges, so I have " << apples + oranges << " fruits";
Использование одного объекта std::stringstream для всей строки:
class sstr final
{
public:
sstr(const std::string &str = "")
: ss_(str)
{
}
template<typename T> sstr &operator<<(const T &t)
{
ss_ << t;
return *this;
}
operator std::string() const
{
return ss_.str();
}
private:
std::stringstream ss_;
};
std::string str = sstr() << "I have " << apples << " apples and " << oranges << " oranges, so I have " << apples + oranges << " fruits";
Забегая вперед, оказывается, что производительность std::to_string в 3-4 раза выше, чем у to_string, реализованной с помощью std::stringstream. Поэтому, логично будет использовать std::to_string для подходящих типов, а для всех остальных использовать шаблонную to_string:
std::string to_string(int x) { return std::to_string(x); }
std::string to_string(unsigned int x) { return std::to_string(x); }
std::string to_string(long x) { return std::to_string(x); }
std::string to_string(unsigned long x) { return std::to_string(x); }
std::string to_string(long long x) { return std::to_string(x); }
std::string to_string(unsigned long long x) { return std::to_string(x); }
std::string to_string(float x) { return std::to_string(x); }
std::string to_string(double x) { return std::to_string(x); }
std::string to_string(long double x) { return std::to_string(x); }
std::string to_string(const char *x) { return std::string(x); }
std::string to_string(const std::string &x) { return x; }
template<typename T> std::string to_string(const T &t)
{
std::stringstream ss;
ss << t;
return ss.str();
}
Библиотека boost::format
Набор библиотек boost является мощным средством, отлично дополняющим средства языка C++ и стандартной библиотеки. Строковое форматирование представлено библиотекой boost::format.
Поддерживается указание как типовых местозаполнителей:
std::string str = (boost::format("I have %d apples and %d oranges, so I have %d fruits") % apples % oranges % (apples + oranges)).str();
так и порядковых:
std::string str = (boost::format("I have %1% apples and %2% oranges, so I have %3% fruits") % apples % oranges % (apples + oranges)).str();
Единственный недостаток boost::format — низкая производительность, это самый медленный способ строкового форматирования. Также этот способ неприменим, если в проекте нельзя использовать сторонние библиотеки.
Итак, получается, что C++ и стандартная библиотека не предоставляют нам удобных средств строкового форматирования, поэтому будем писать что-то свое.
Обертка над vsnprintf
Попробуем написать обертку над Xprintf функцией, выделяя достаточно памяти и передавая произвольное количество параметров.
Для выделения памяти будем использовать следующую стратегию:
- сначала выделяем такое количество памяти, которого будет достаточно в большинстве случаев
- пробуем вызвать функцию форматирования
- если вызов закончился неудачей, выделим больше памяти и повторим предыдущий шаг
Для передачи параметров будем использовать механизм stdarg и функцию vsnprintf.
std::string format(const char *fmt, ...)
{
va_list args;
va_start(args, fmt);
std::vector<char> v(1024);
while (true)
{
va_list args2;
va_copy(args2, args);
int res = vsnprintf(v.data(), v.size(), fmt, args2);
if ((res >= 0) && (res < static_cast<int>(v.size())))
{
va_end(args);
va_end(args2);
return std::string(v.data());
}
size_t size;
if (res < 0)
size = v.size() * 2;
else
size = static_cast<size_t>(res) + 1;
v.clear();
v.resize(size);
}
}
std::string str = format("I have %d apples and %d oranges, so I have %d fruits", apples, oranges, apples + oranges);
Здесь стоит разъяснить пару нюансов. Возвращаемое значение функций Xprintf зависит от платформы, на некоторых платформах, в случае неуспеха, возвращается -1, в этом случае мы увеличиваем буфер в два раза. На других платформах возвращается длина результирующей строки (без учета нулевого символа), в этом случае мы сразу можем выделить столько памяти, сколько необходимо. Более подробно о поведении функций Xprintf на различных платформах можно почитать здесь. Также, на некоторых платформах, vsnprintf() "портит" список аргументов, поэтому копируем его перед вызовом.
Я начал использовать эту функцию еще до появления C++11 и с небольшими изменениями продолжаю использовать по сегодняшний день. Основное неудобство при использовании — отсутствие поддержки std::string в качестве аргументов, поэтому нужно не забывать добавлять .c_str() ко всем строковым аргументам:
std::string country = "Great Britain";
std::string capital = "London";
std::cout << format("%s is a capital of %s", capital.c_str(), country.c_str()) << std::endl;
Шаблон с переменным количеством аргументов (Variadic Template)
В C++ начиная с C++11 появилась возможность использовать шаблоны с переменным количеством аргументов (Variadic Templates).
Такие шаблоны можно использовать при передаче аргументов в функцию форматирования. Также, нам больше не нужно заботиться о типах аргументов, так как мы можем использовать шаблонную to_string, которая была реализована ранее. Поэтому будем использовать порядковые местозаполнители.
Для получения всех аргументов отделяем первый аргумент, преобразуем его в строку, запоминаем и рекурсивно повторяем эту операцию. В случае отсутствия аргументов или при их окончании (конечная точка рекурсии) выполняем разбор строки-шаблона, подстановку аргументов и получаем результирующую строку.
Таким образом, у нас есть все, чтобы полностью реализовать функцию форматирования: парсинг строки-шаблона, сбор и преобразование в строку всех параметров, подстановку параметров в строку-шаблон и получение результирующей строки:
std::string vtformat_impl(const std::string &fmt, const std::vector<std::string> &strs)
{
static const char FORMAT_SYMBOL = '%';
std::string res;
std::string buf;
bool arg = false;
for (int i = 0; i <= static_cast<int>(fmt.size()); ++i)
{
bool last = i == static_cast<int>(fmt.size());
char ch = fmt[i];
if (arg)
{
if (ch >= '0' && ch <= '9')
{
buf += ch;
}
else
{
int num = 0;
if (!buf.empty() && buf.length() < 10)
num = atoi(buf.c_str());
if (num >= 1 && num <= static_cast<int>(strs.size()))
res += strs[num - 1];
else
res += FORMAT_SYMBOL + buf;
buf.clear();
if (ch != FORMAT_SYMBOL)
{
if (!last)
res += ch;
arg = false;
}
}
}
else
{
if (ch == FORMAT_SYMBOL)
{
arg = true;
}
else
{
if (!last)
res += ch;
}
}
}
return res;
}
template<typename Arg, typename ... Args> std::string vtformat_impl(const std::string &fmt, std::vector<std::string> &strs, Arg arg, Args ... args)
{
strs.push_back(to_string(arg));
return vtformat_impl(fmt, strs, args ...);
}
std::string vtformat(const std::string &fmt)
{
return fmt;
}
template<typename Arg, typename ... Args> std::string vtformat(const std::string &fmt, Arg arg, Args ... args)
{
std::vector<std::string> strs;
return vtformat_impl(fmt, strs, arg, args ...);
}
Алгоритм получился достаточно эффективным, работает за один проход по строке форматирования. В случае, если вместо местозаполнителя не удается вставить аргумент, он остается без изменений, исключений не генерируется.
Примеры использования:
std::cout << vtformat("I have %1 apples and %2 oranges, so I have %3 fruits", apples, oranges, apples + oranges) << std::endl;
I have 5 apples and 7 oranges, so I have 12 fruits
std::cout << vtformat("%1 + %2 = %3", 2, 3, 2 + 3) << std::endl;
2 + 3 = 5
std::cout << vtformat("%3 = %2 + %1", 2, 3, 2 + 3) << std::endl;
5 = 3 + 2
std::cout << vtformat("%2 = %1 + %1 + %1", 2, 2 + 2 + 2) << std::endl;
6 = 2 + 2 + 2
std::cout << vtformat("%0 %1 %2 %3 %4 %5", 1, 2, 3, 4) << std::endl;
%0 1 2 3 4 %5
std::cout << vtformat("%1 + 1% = %2", 54, 54 * 1.01) << std::endl;
54 + 1% = 54.540000
std::string country = "Russia";
const char *capital = "Moscow";
std::cout << vtformat("%1 is a capital of %2", capital, country) << std::endl;
Moscow is a capital of Russia
template<typename T> std::ostream &operator<<(std::ostream &os, const std::vector<T> &v)
{
os << "[";
bool first = true;
for (const auto &x : v)
{
if (first)
first = false;
else
os << ", ";
os << x;
}
os << "]";
return os;
}
std::vector<int> v = {1, 4, 5, 2, 7, 9};
std::cout << vtformat("v = %1", v) << std::endl;
v = [1, 4, 5, 2, 7, 9]
Сравнение производительности
Сравнение производительности to_string и std::to_string, миллисекунд на миллион вызовов
int, мс | long long, мс | double, мс | |
---|---|---|---|
to_string | 681 | 704 | 1109 |
std::to_string | 130 | 201 | 291 |
Сравнение производительности функций форматирования, миллисекунд на миллион вызовов
мс | |
---|---|
fstr | 1308 |
sstr | 1243 |
format | 788 |
boost::format | 2554 |
vtformat | 2022 |
Спасибо за внимание.
Замечания и дополнения приветствуются.
Автор: 5nw