О строковом форматировании в современном C++

Доброго времени суток!


В этой статье я хотел бы рассказать о существующих возможностях строкового форматирования в современном 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 std::string to_string(const T &t)
{
    std::stringstream ss;
    ss << t;
    return ss.str();
}

std::string str = to_string("5");

Преобразование строки во «что угодно»:


template T from_string(const std::string &str)
{   
    std::stringstream ss(str);
    T t;
    ss >> t;
    return t;
}

int x = from_string("5");

Преобразование строки во «что угодно» с проверкой:


template 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("x5", ok);
if (!ok) ...

Также, можно написать пару оберток для удобного использования std: stringstream в одну строку.


Использование объекта std: stringstream для каждого аргумента:


class fstr final : public std::string
{
public:
    fstr(const std::string &str = "")
    {
        *this += str;
    }
    template 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 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 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 функцией, выделяя достаточно памяти и передавая произвольное количество параметров.


Для выделения памяти будем использовать следующую стратегию:


  1. сначала выделяем такое количество памяти, которого будет достаточно в большинстве случаев
  2. пробуем вызвать функцию форматирования
  3. если вызов закончился неудачей, выделим больше памяти и повторим предыдущий шаг

Для передачи параметров будем использовать механизм stdarg и функцию vsnprintf.


std::string format(const char *fmt, ...)
{
    va_list args;
    va_start(args, fmt);
    std::vector 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(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(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 &strs)
{
    static const char FORMAT_SYMBOL = '%';
    std::string res;
    std::string buf;
    bool arg = false;
    for (int i = 0; i <= static_cast(fmt.size()); ++i)
    {
        bool last = i == static_cast(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(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 std::string vtformat_impl(const std::string &fmt, std::vector &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 std::string vtformat(const std::string &fmt, Arg arg, Args ... args)
{
    std::vector 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 std::ostream &operator<<(std::ostream &os, const std::vector &v)
{
    os << "[";
    bool first = true;
    for (const auto &x : v)
    {
        if (first)
            first = false;
        else
            os << ", ";
        os << x;
    }
    os << "]";
    return os;
}
std::vector 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

image


Сравнение производительности функций форматирования, миллисекунд на миллион вызовов


мс
fstr 1308
sstr 1243
format 788
boost: format 2554
vtformat 2022

image


Спасибо за внимание.
Замечания и дополнения приветствуются.

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

  • 7 января 2017 в 05:36

    0

    Было бы интереснее взглянуть на реализацию форматирования строк на шаблонах и constexpr, которая будет сама переключаться между compile-time и runtime форматированием когда нужно.
  • 7 января 2017 в 08:21

    0

    В наследии C самая крутая фишка не в том, что значения аргументов вставляются в указанные места строки формата (хотя это тоже, как вы правильно отметили, немаловажно). А в том, что эти значения форматируются в соответствии с указанными спецификациями.

    На сколько я вижу, подобной функциональностью обладает только вариант «Обертка над vsnprintf». Но в нём нет контроля соответствия спецификации формата и типа аргумента.

    • 7 января 2017 в 11:08

      0

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

© Habrahabr.ru