Windows и C++

Использование printf с современным C++

Kenny Kerr

Кенни КеррЧто понадобилось бы для модернизации printf? Этот вопрос может показаться странным многим разработчикам, считающим, что C++ уже предоставляет современную замену printf. Хотя C++ Standard Library, несомненно, есть чем похвалиться, в том числе превосходной Standard Template Library (STL), она также включает библиотеку ввода-вывода на основе потоков данных (streams), которая совершенно непохожа на STL и не реализует ни одного из принципов STL, относящихся к эффективности.

Согласно определению Александра Степанова и Дэниэля Роуза (Daniel Rose) в их книге «From Mathematics to Generic Programming» (Addison-Wesley Professional, 2015), «обобщенное программирование — это подход к разработке, который фокусируется на проектировании алгоритмов и структур данных, способных работать в наиболее универсальных условиях без потери эффективности».

Если честно, ни printf, ни cout ни в коей мере не отражают современный C++. Функция printf является примером вариативной функции (т. е. функции с переменным количеством аргументов) (variadic function) и одним из немногих хороших применений этой весьма хрупкой функциональности, унаследованной от языка программирования C. Такие функции предшествовали появлению вариативных шаблонов (variadic templates). Последние предлагают по-настоящему современный и надежный механизм для поддержки варьируемого количества типов или аргументов. В противоположность этому в cout вообще не применяется такая вариативность, но интенсивно используются вызовы виртуальных функций, с которыми компилятор не может сделать ничего особенного для их оптимизации. Развитие архитектур процессоров пошло так, что printf явно получает преимущества и мало что делается для повышения производительности полиморфического подхода, заложенного в cout. Поэтому, если вам нужны производительность и эффективность, лучше выбрать printf. Кроме того, ее применение дает более четкий код. Вот пример:

#include <stdio.h>
int main()
{
  printf("%f\n", 123.456);
}

Спецификатор преобразования %f сообщает printf, что здесь ожидается число с плавающей точкой, которое следует преобразовать в десятичную нотацию. Символ \n — обычный символ новой строки, который можно дополнить символом перевода строки. Преобразование значений с плавающей точкой предполагает точность до шести знаков после десятичной точки. Таким образом, в этом примере будут выведены следующие знаки с последующей новой строкой:

123.456000

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

#include <iostream>
int main()
{
  std::cout << 123.456 << std::endl;
}

Здесь cout полагается на оператор перегрузки, чтобы направить или послать число с плавающей точкой в поток вывода. Мне не нравится такое использование оператора перегрузки, но соглашусь, что это дело персонального стиля программирования. Наконец, endl завершает вставкой новой строки в поток вывода. Однако это не совсем то, что в примере с printf, и дает вывод с иной точностью после десятичной точки:

123.456

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

printf("%.2f\n", 123.456);

Теперь printf будет округлять число до двух знаков после десятичной точки:

123.46

Чтобы получить тот же результат в случае cout, потребуется набрать несколько больше кода:

#include <iomanip> // требуется для setprecision
std::cout << std::fixed << std::setprecision(2)
          << 123.456 << std::endl;

Даже если вы не против многословия всего этого и вам нравится гибкость или выразительность такого варианта, учитывайте, что эта абстракция имеет свою цену. Прежде всего манипуляторы fixed и setprecision сохраняют состояние (stateful), а значит, их влияние остается до тех пор, пока вы не обратите их или не сбросите. В противоположность этому спецификатор преобразования в printf включает все, что нужно для одного конкретного преобразования, не влияя ни на какой другой код. Другие издержки могут не иметь никакого значения в большинстве случаев вывода, но в один прекрасный день вы вдруг заметите, что чужие программы способны выводить данные многократно быстрее, чем ваши. Помимо издержек от вызовов виртуальных функций, endl делает больше, чем вы, возможно, ожидали. Он не только отправляет знак новой строки в вывод, но и заставляет нижележащий поток сбрасывать свой вывод. При написании кода для любой разновидности ввода-вывода, будь то в консоль, файл на диске, сетевое соединение или даже в графическом конвейере, сброс обычно обходится очень дорого, а повторяющиеся сбросы, несомненно, приведут к падению производительности.

Теперь, когда я немного исследовал и сравнил printf и cout, пора вернуться к изначальному вопросу: что понадобилось бы для модернизации printf? Разумеется, с появлением современного C++, примером чего является C++11 и более поздние стандарты, я могу повысить продуктивность труда и надежность printf, не жертвуя производительностью. Другой отчасти посторонний член C++ Standard Library — официальный класс string языка. Хотя репутация этого класса за прошедшие годы тоже была опорочена, он все же обеспечивает превосходную производительность. Хоть и не безгрешный, он предоставляет очень полезный способ обработки строк в C++. Поэтому любая модернизация printf должна на практике хорошо уживаться со string и wstring. Давайте посмотрим, что можно сделать. Сначала позвольте мне устранить то, что я считаю самой досадной проблемой printf:

std::string value = "Hello";
printf("%s\n", value);

На самом деле это должно было бы работать, но, уверен, вы отчетливо понимаете, что вместо этого результатом будет то, что ласково называют «неопределенным поведением». Как вам известно, вся суть printf в выводе текста, а C++-класс string является главным воплощением текста в языке C++. Мне нужно обернуть printf так, чтобы это просто работало. Я не хочу постоянно выдергивать завершаемый нулем символьный массив строки таким образом:

printf("%s\n", value.c_str());

Это просто утомительно, поэтому я намерен исправить это обертыванием printf. Традиционно при этом писали другую вариативную функцию. Например, нечто в таком духе:

void Print(char const * const format, ...)
{
  va_list args;
  va_start(args, format);
  vprintf(format, args);
  va_end(args);
}

Увы, это ничего не дает мне. Возможно, было бы удобно обернуть некую разновидность printf, чтобы записывать в какой-то другой буфер, но в данном случае я не получил бы ничего полезного. Я не хочу возвращаться к вариативным функциям в стиле C. Вместо этого я стремлюсь к использованию возможностей современного C++. К счастью, благодаря вариативным шаблонам в C++11 мне никогда больше не придется писать вариативную функцию. Вместо обертывания printf в другую вариативную функцию ее можно обернуть в вариативный шаблон:

template <typename ... Args>
void Print(char const * const format,
           Args const & ... args) noexcept
{
  printf(format, args ...);
}

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

Print("%d %d\n", 123, 456);

это привело бы к раскрытию пакета аргументов args, состоящего из 123 и 456, внутри тела вариативного шаблона, словно я написал просто:

printf("%d %d\n", 123, 456);

Так что же я выиграл? Конечно, я вызываю printf, а не vprintf, и мне не нужно управлять va_list и связанными макросами, крутящими стек, но, тем не менее, я просто пересылаю аргументы. Однако пусть вас не обманывает простота этого решения. Компилятор вновь будет распаковывать аргументы шаблона функции так, будто я напрямую вызвал printf, а значит, в обертывании printf таким способом не будет никаких издержек. Это также означает, что она по-прежнему остается полноправным элементом C++ и что я могу задействовать мощные методы метапрограммирования в этом языке для встраивания любого необходимого кода, причем с полным обобщением. Вместо простого раскрытия пакета параметров args можно обернуть каждый аргумент, чтобы добавить к нему любую настроечную информацию, необходимую printf. Рассмотрим следующий шаблон функции:

template <typename T>
T Argument(T value) noexcept
{
  return value;
}

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

template <typename ... Args>
void Print(char const * const format,
           Args const & ... args) noexcept
{
  printf(format, Argument(args) ...);
}

Функцию Print можно вызывать прежним образом:

Print("%d %d\n", 123, 456);

Но теперь это приводит к следующему раскрытию:

printf("%d %d\n", Argument(123), Argument(456));

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

template <typename T>
T const * Argument(std::basic_string<T> const & value) noexcept
{
  return value.c_str();
}

Далее я просто вызываю функцию Print с какими-нибудь строками:

int main()
{
  std::string const hello = "Hello";
  std::wstring const world = L"World";
  Print("%d %s %ls\n", 123, hello, world);
}

Компилятор в конечном счете раскроет внутреннюю функцию printf следующим образом:

printf("%d %s %ls\n",
  Argument(123), Argument(hello), Argument(world));

Это гарантирует, что завершаемый нулем массив символов каждой строки передается в printf и обеспечивает четко определенное поведение:

123 Hello World

Наряду с шаблоном функции Print я также использую ряд перегруженных версий для неформатированного вывода. Это обычно безопаснее и избавляет printf от случайной интерпретации произвольных строк как содержащих спецификаторы преобразования. Эти функции перечислены на рис. 1.

Рис. 1. Отображение неформатированного вывода

inline void Print(char const * const value) noexcept
{
  Print("%s", value);
}
inline void Print(wchar_t const * const value) noexcept
{
  Print("%ls", value);
}
template <typename T>
void Print(std::basic_string<T> const & value) noexcept
{
  Print(value.c_str());
}

Первые две перегруженные версии просто форматируют обычный и «широкосимвольный» массивы соответственно. Заключительный шаблон функции перенаправляет в соответствующую перегруженную версию в зависимости от типа аргумента: string или wstring. Благодаря этим функциям можно безопасно выводить некоторые спецификаторы преобразования в буквальном виде:

Print("%d %s %ls\n");

Это снимает самую распространенную проблему с printf, безопасно и прозрачно обрабатывая строковый вывод. А как насчет форматирования самих строк? C++ Standard Library предоставляет различные вариации printf для записи в буферы строк символов. Из них я нахожу наиболее эффективными snprintf и swprintf. Эти две функции обрабатывают символьный и широкосимвольный вывод соответственно. Они позволяют указывать максимальное количество символов, которое может быть записано, и возвращают значение, с помощью которого можно вычислить, какое пространство понадобится, если исходный буфер окажется недостаточно большим. Тем не менее, сами по себе они подвержены ошибкам и весьма утомительны в использовании. Пора внести в них кое-что из современного C++.

C не поддерживает перегрузку функций, но в C++ использовать ее весьма удобно, и это открывает дверь в обобщенное программирование, поэтому я начну с обертывания snprintf и swprintf как функций, вызываемых StringPrint. Кроме того, я задействую шаблоны вариативных функций, чтобы использовать преимущества безопасного раскрытия аргументов, ранее примененного для функции Print. На рис. 2 приведен код для обеих функций. Эти функции также проверяют, что результат не равен –1, а именно такое значение возвращают нижележащие функции, когда возникает какая-либо поправимая проблема при разборе форматирующей строки. Я использую контрольное выражение (assertion), так как просто предполагаю, что это ошибка и что ее надо исправить до распространения производственного кода. Возможно, вы захотите заменить его на исключение, но учитывайте, что надежного способа преобразования всех ошибок в исключения нет, поскольку все равно можно передать недопустимые аргументы, которые приведут к неопределенному поведению. Современный C++ не является защищенным от дураков C++.

Рис. 2. Низкоуровневые функции форматирования строки

template <typename ... Args>
int StringPrint(char * const buffer,
                size_t const bufferCount,
                char const * const format,
                Args const & ... args) noexcept
{
  int const result = snprintf(buffer,
                              bufferCount,
                              format,
                              Argument(args) ...);
  ASSERT(-1 != result);
  return result;
}
template <typename ... Args>
int StringPrint(wchar_t * const buffer,
                size_t const bufferCount,
                wchar_t const * const format,
                Args const & ... args) noexcept
{
  int const result = swprintf(buffer,
                              bufferCount,
                              format,
                              Argument(args) ...);
  ASSERT(-1 != result);
  return result;
}

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

std::string result;
Format(result, "%d %s %ls", 123, hello, world);
ASSERT("123 Hello World" == result);

Здесь нет видимого управления буферами. Мне не нужно выяснять, насколько большой буфер требуется создать. Я лишь прошу функцию Format логически присвоить отформатированный вывод объекту string. Как обычно, Format может быть шаблоном функции, а именно вариативным:

template <typename T, typename ... Args>
void Format(std::basic_string<T> & buffer,
            T const * const format,
            Args const & ... args)
{
}

Реализовать эту функцию можно самыми разными способами. Немного экспериментов и хорошей дозы профилирования вполне достаточно. Простой, но наивный подход — предположить, что строка либо пуста, либо слишком мала, чтобы хранить форматированный вывод. В этом случае я начал бы с определения необходимого размера с помощью StringPrint и подгонки буфера под этот размер, а затем снова вызвал бы StringPrint с правильным буфером. Нечто вроде этого:

size_t const size = StringPrint(nullptr, 0, format, args ...);
buffer.resize(size);
StringPrint(&buffer[0], buffer.size() + 1, format, args ...);

Приращение на 1 необходимо, так как и snprintf, и swprintf предполагают, что сообщаемый размер буфера включает место для завершающего нулевого символа. Это работает достаточно хорошо, но должно быть очевидно, что с производительностью здесь все очень плохо. Подход, обеспечивающий гораздо более высокую производительность в большинстве случаев, — предполагать, что строка достаточно велика, чтобы хранить форматированный вывод и выполнять подгонку размера буфера только при необходимости. Это почти выворачивает предыдущий код наизнанку, но в этом случае код весьма надежен. Я начинаю с попытки отформатировать строку непосредственно в буфере:

size_t const size = StringPrint(&buffer[0],
                                buffer.size() + 1,
                                format,
                                args ...);

Если строка изначально пуста или недостаточно велика, полученный размер окажется больше размера строки, и я буду знать, что размер строки надо изменить до повторного вызова StringPrint:

if (size > buffer.size())
{
  buffer.resize(size);
  StringPrint(&buffer[0], buffer.size() + 1, format, args ...);
}

Если полученный размер меньше размера строки, значит, форматирование успешно выполнено, но буфер нужно отсечь под этот размер:

else if (size < buffer.size())
{
  buffer.resize(size);
}

Наконец, если размеры совпадают, ничего делать не надо и функция Format может просто вернуть управление. Полный шаблон функции Format представлен на рис. 3. Если вы знакомы с классом string, то, возможно, вспомните, что он также сообщает свою вместимость (capacity), и у вас может возникнуть соблазн присвоить размеру строки его вместимость до первого вызова StringPrint, полагая, что это улучшит ваши шансы на корректное форматирование строки с первого захода. Вопрос в том, а можно ли изменить размер объекта string быстрее, чем printf сумеет разобрать его форматирующую строку и вычислить необходимый размер буфера. Основываясь на своих неформальных тестах, могу дать ответ: когда как. Видите ли, изменение размера string для соответствия его вместимости требует несколько большего простого изменения сообщаемого размера. Нужно очистить любые дополнительные символы, а это требует времени. Окажется ли это быстрее, чем printf разберет форматирующую строку, зависит от того, сколько символов придется очистить и насколько сложным должно быть форматирование. Я применяю даже более скоростной алгоритм для вывода больших объемов строковых данных, но нахожу, что функция Format на рис. 3 обеспечивает хорошую производительность в большинстве сценариев.

Рис. 3. Форматирование строк

template <typename T, typename ... Args>
void Format(std::basic_string<T> & buffer,
            T const * const format,
            Args const & ... args)
{
  size_t const size = StringPrint(&buffer[0],
                                  buffer.size() + 1,
                                  format,
                                  args ...);
  if (size > buffer.size())
  {
    buffer.resize(size);
    StringPrint(&buffer[0], buffer.size() + 1, format, args ...);
  }
  else if (size < buffer.size())
  {
    buffer.resize(size);
  }
}

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

inline std::string ToString(wchar_t const * value)
{
  std::string result;
  Format(result, "%ls", value);
  return result;
}
ASSERT("hello" == ToString(L"hello"));

Или отформатировать числа с плавающей точкой:

inline std::string ToString(double const value,
                            unsigned const precision = 6)
{
  std::string result;
  Format(result, "%.*f", precision, value);
  return result;
}
ASSERT("123.46" == ToString(123.456, 2));

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

Это просто набор полезных функций из моей библиотеки вывода для современного C++. Надеюсь, они в какой-то мере подскажут вам, как использовать современный C++ для обновления старых методов программирования на C и C++. Кстати, в моей библиотеке вывода определены функции Argument, а также низкоуровневые функции StringPrint во вложенном пространстве имен Internal. Это помогает держать библиотеку легко читаемой и простой для понимания, но вы можете упорядочить свою реализацию как пожелаете.


Kenny Kerrвысококвалифицированный программист. Живет в Канаде. Автор учебных курсов для Pluralsight, обладатель звания Microsoft MVP. Ведет блог kennykerr.ca Кроме того, читайте его заметки в twitter.com/kennykerr.