Поделиться через


Май 2015 г.

Том 30, выпуск 5


Windows и C++ - Добавление в printf проверки типов при компиляции

Kenny Kerr | Май 2015

Кенни КеррВ своей рубрике за март 2015 года (msdn.magazine.com/magazine/dn91318) я исследовал некоторые способы, позволяющие сделать printf более удобной для использования с современным C++. Я показал, как преобразовывать аргументы, используя вариативный шаблон (variadic template), чтобы заполнить прогал между официальным C++-классом string и допотопной функцией printf. К чему все эти хлопоты? Дело в том, что printf работает очень быстро, а решение для форматированного вывода, способное задействовать преимущества этой функции и в то же время позволяющее разработчикам писать более безопасный, более высокоуровневый код, весьма желательно. Результатом стал вариативный шаблон функции 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));

Затем при компиляции шаблон функции Argument был бы убран, и остались бы только необходимые функции-аксессоры:

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

Хотя это удобный мостик между современным C++ и традиционной функцией printf, он никак не устраняет трудности написания корректного кода с использованием printf. Функция printf по-прежнему остается printf, и я полагаюсь на отнюдь не всеведущие компилятор и библиотеку в том, что они обнаружат любые несогласованности между спецификаторами конверсии (conversion specifiers) и реальными аргументами, переданными вызывающим кодом.

Безусловно, современный C++ способен на большее. И многие разработчики пытались сделать лучше. Беда в том, что у разных разработчиков разные требования или приоритеты. Некоторых устраивает небольшое падение производительности, и они просто полагаются на <iostream> в проверке типов и расширяемости. Другие придумали хитроумные библиотеки, предоставляющие интересные средства, которые требуют дополнительных структур и операций выделения памяти для отслеживания состояния форматирования. Лично я не удовлетворен решениями, которые вводят издержки, влияющие на скорость выполнения того, что должно быть фундаментальной и быстрой операцией. Если она компактна и быстра в C, то должна быть компактна, быстра и проста для корректного использования в C++. «Медленнее» не должно попадать в это уравнение.

Что же можно сделать? Ну, небольшая абстракция вполне приемлема, если только эта абстракция после компиляции убирается и оставляет нечто очень близкое к выражению printf, написанному вручную. Главное — осознать, что такие спецификаторы конверсии, как %d и %s, на самом деле являются просто полями для подстановки аргументов и значений. Проблема в том, что эти поля подстановки (placeholders) делают предположения в отношении типов соответствующих аргументов, не имея возможности узнать о том, какие типы корректны. Вместо того чтобы пытаться добавлять проверку типов в период выполнения, которая будет подтверждать эти предположения, давайте отбросим эту информацию о псевдотипах и позволим компилятору логически распознавать типы аргументов. Поэтому вместо написания:

printf("%s %d\n", "Hello", 2015);

я должен ограничить форматирующую строку (format string) реальными символами, которые должны выводиться, и любыми полями подстановки, подлежащими раскрытию. Я мог бы даже использовать метасимвол в качестве поля подстановки:

Write("% %\n", "Hello", 2015);

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

Я также не хочу ограничивать вывод только консолью. Как насчет форматирования в строку? Или куда-то еще? Одна из трудных задач в работе с printf состоит в том, что, хотя она поддерживает вывод в разные мишени, делается это через отдельные функции, которые не являются перегрузками и которые с трудом поддаются обобщению. Справедливости ради, язык C не поддерживает перегрузки или обобщенное программирование. Тем не менее, давайте не будем повторять историю. Я хотел бы, чтобы была возможность выводить в консоль столь же легко и универсально, как в строку или файл. Что я имею в виду, показано на рис. 1.

Рис. 1. Обобщенный вывод с логическим распознаванием типов

Write(printf, "Hello %\n", 2015);

FILE * file = // открываем файл
Write(file, "Hello %\n", 2015);

std::string text;
Write(text, "Hello %\n", 2015);

С точки зрения проектирования или реализации, должен быть один шаблон функции Write, выступающий в роли драйвера, и любое количество мишеней или адаптеров мишеней, которые связываются универсальным образом на основе мишени, указанной вызвавшим кодом. Затем разработчик должен иметь возможность легко добавлять дополнительные мишени по мере необходимости. Как это могло бы работать? Один из вариантов — сделать мишень параметром шаблона. Нечто в таком духе:

template <typename Target, typename ... Args>
void Write(Target & target,
  char const * const format, Args const & ... args)
{
  target(format, args ...);
}

int main()
{
  Write(printf, "Hello %d\n", 2015);
}

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

struct StringAdapter
{
  std::string & Target;

  template <typename ... Args>
  void operator()(char const * const format,
    Args const & ... args)
  {
    // Дописываем текст
  }
};

Тогда это можно использовать с шаблоном функции Write:

std::string target;
Write(StringAdapter{ target }, "Hello %d", 2015);
assert(target == "Hello 2015");

Поначалу это может показаться довольно элегантным и даже гибким решением. Можно писать любые виды функций-адаптеров или объектов-функций. Но на практике это быстро станет довольно утомительным. Было бы гораздо предпочтительнее просто передавать строку напрямую как мишень и перекладывать на шаблон функции Write заботы об адаптации под тип мишени. Поэтому пусть шаблон функции Write соответствует запрошенной мишени парой перегруженных функций для добавления или форматирования вывода. Важную роль играет небольшая абстракция на этапе компиляции. Понимая, что большая часть вывода будет простым текстом безо всяких полей подстановки, я добавлю не одну, а пару перегрузок. Первая просто добавляет текст. Вот функция Append для строк:

void Append(std::string & target,
  char const * const value, size_t const size)
{
  target.append(value, size);
}

Тогда я могу предоставить перегрузку Append для printf:

template <typename P>
void Append(P target, char const * const value,
  size_t const size)
{
  target("%.*s", size, value);
}

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

void Append(FILE * target,
  char const * const value, size_t const size)
{
  fprintf(target, "%.*s", size, value);
}

Конечно, форматированный вывод по-прежнему важен. Вот функция AppendFormat для строк:

template <typename ... Args>
void AppendFormat(std::string & target,
  char const * const format, Args ... args)
{
  int const back = target.size();
  int const size = snprintf(nullptr, 0, format, args ...);
  target.resize(back + size);
  snprintf(&target[back], size + 1, format, args ...);
}

Сначала она определяет, сколько потребуется дополнительного пространства перед изменением размера мишени и форматированным выводом текста напрямую в строку. Есть соблазн попытаться избежать двух вызовов snprintf, проверяя, достаточно ли места в буфере. Причина, по которой я склонен всегда дважды вызывать snprintf, связана с тем, что, как выяснилось при неформальном тестировании, два вызова обычно обходятся дешевле, чем изменение размера. Хотя операция выделения не нужна, эти дополнительные символы обнуляются, что ведет к увеличению издержек. Однако это весьма субъективно, зависит от конкретных данных и того, насколько часто происходит повторное использование целевой строки. И вот одна из них для printf:

template <typename P, typename ... Args>
void AppendFormat(P target, char const * const format,
  Args ... args)
{
  target(format, args ...);
}

Перегрузка для вывода в файл столь же прямолинейна:

template <typename ... Args>
void AppendFormat(FILE * target,
  char const * const format, Args ... args)
{
  fprintf(target, format, args ...);
}

Теперь у меня есть один из строительных блоков для функции-драйвера Write. Другой необходимый строительный блок — универсальный способ обработки форматирования аргументов. Хотя метод, продемонстрированный в моей рубрике за март 2015 г., прост и элегантен, он не позволяет работать со значениями, которые нельзя напрямую сопоставить с типами, поддерживаемыми printf. Он также не способен иметь дело с раскрытием аргументов или сложными значениями аргументов, таких как пользовательские типы. И вновь набор перегруженных функций может весьма элегантно решить эту проблему. Допустим, что функция-драйвер Write будет передавать каждый аргумент в функцию WriteArgument. Вот одна из них для строк:

template <typename Target>
void WriteArgument(Target & target, std::string const & value)
{
  Append(target, value.c_str(), value.size());
}

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

template <typename Target>
void WriteArgument(Target & target, int const value)
{
  AppendFormat(target, "%d", value);
}

В этом случае функция WriteArgument ожидает функции AppendFormat, соответствующей мишени. Как и в случае с перегрузками Append и AppendFormat, написать дополнительные функции WriteArgument достаточно просто. Изящество этого подхода в том, что адаптеры аргументов не должны возвращать некое значение вверх по стеку для функции printf, как это делалось в версии за март 2015 г. Вместо этого перегрузки WriteArgument устанавливают такую область видимости вывода, что запись в мишень происходит немедленно. А это означает, что в качестве аргументов применимы сложные типы и что в форматировании их текстового представления можно полагаться даже на временное хранилище. Ниже показана перегрузка WriteArgument для GUID:

template <typename Target>
void WriteArgument(Target & target, GUID const & value)
{
  wchar_t buffer[39];
  StringFromGUID2(value, buffer, _countof(buffer));
  AppendFormat(target, "%.*ls", 36, buffer + 1);
}

Я мог бы даже заменить Windows-функцию StringFromGUID2 и форматировать напрямую, возможно, увеличив производительность или портируемость, но это ясно показывает мощь и гибкость данного подхода. Добавив перегрузку WriteArgument, можно легко обеспечить поддержку пользовательских типов. Я называю их здесь перегрузками, но, строго говоря, они не обязаны быть таковыми. Библиотека вывода, безусловно, может предоставлять набор перегрузок для распространенных мишеней и аргументов, однако функция-драйвер Write не должна предполагать, что функции-адаптеры являются перегрузками, и вместо этого интерпретировать их как функции-нечлены begin и end, определенные в стандартной библиотеке C++. Функции-нечлены begin и end являются расширяемыми и адаптируемыми ко всем видам стандартных и нестандартных контейнеров именно потому, что их не нужно размещать в пространстве имен std — они должны быть локальными в пространстве имен соответствующего типа. Точно так же эти функции — адаптеры аргументов и мишеней должны быть способны к размещению в других пространствах имен для поддержки мишеней и пользовательских аргументов, создаваемых разработчиком. Как же выглядит функция-драйвер Write? Начнем с того, что существует только одна функция Write:

template <typename Target, unsigned Count, typename ... Args>
void Write(Target & target,
  char const (&format)[Count], Args const & ... args)
{
  assert(Internal::CountPlaceholders(format) ==
    sizeof ... (args));
  Internal::Write(target, format, Count - 1, args ...);
}

Первым делом надо определить, равно ли количество полей подстановки в форматирующей строке числу аргументов в вариативном пакете параметров. Здесь я использую проверку в период выполнения, но на самом деле это следовало бы сделать как static_assert, который проверяет форматирующую строку при компиляции. К сожалению, Visual C++ пока не умеет этого. Тем не менее, я могу написать код так, чтобы его можно было легко изменять для проверки форматирующей строки при компиляции. Как таковая, внутренняя функция CountPlaceholders должна быть помечена как constexpr:

constexpr unsigned CountPlaceholders(char const * const format)
{
  return (*format == '%') +
    (*format == '\0' ? 0 : CountPlaceholders(format + 1));
}

Когда Visual C++ будет полностью удовлетворять стандарту C++14, по крайней мере в отношении constexpr, вы сможете просто заменить assert внутри функции Write на static_assert. Тогда раскладка специфичного для аргументов вывода при компиляции возлагается на внутреннюю перегруженную функцию Write. В данном случае я могу положиться на компилятор в генерации и вызове необходимых перегруженных версий внутренней функции Write, чтобы раскрыть вариативный пакет параметров:

template <typename Target, typename First, typename ... Rest>
void Write(Target & target, char const * const value,
  size_t const size, First const & first,
  Rest const & ... rest)
{
  // Вся магия творится здесь
}

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

template <typename Target>
void Write(Target & target, char const * const value,
  size_t const size)
{
  Append(target, value, size);
}

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

size_t placeholder = 0;

while (value[placeholder] != '%')
{
  ++placeholder;
}

Только потом можно записывать ведущие символы:

assert(value[placeholder] == '%');
Append(target, value, placeholder);

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

WriteArgument(target, first);
Write(target, value + placeholder + 1,
  size - placeholder - 1, rest ...);

Теперь я могу поддерживать обобщенный вывод на рис. 1. Я могу даже преобразовать GUID в строку:

std::string text;
Write(text, "{%}", __uuidof(IUnknown));
assert(text == "{00000000-0000-0000-C000-000000000046}");

А как насчет чего-нибудь поинтереснее? Например, визуализировать вектор?

std::vector<int> const numbers{ 1, 2, 3, 4, 5, 6 };
std::string text;
Write(text, "{ % }", numbers);
assert(text == "{ 1, 2, 3, 4, 5, 6 }");

Для этого надо просто написать шаблон функции WriteArgument, который принимает вектор в качестве аргумента (рис. 2).

Рис. 2. Визуализация вектора

template <typename Target, typename Value>
void WriteArgument(Target & target,
  std::vector<Value> const & values)
{
  for (size_t i = 0; i != values.size(); ++i)
  {
    if (i != 0)
    {
      WriteArgument(target, ", ");
    }
    WriteArgument(target, values[i]);
  }
}

Заметьте, что я не указываю явно тип элементов в векторе. То есть ту же реализацию можно использовать для визуализации вектора строк:

std::vector<std::string> const names{ "Jim", "Jane", "June" };
std::string text;
Write(text, "{ % }", names);
assert(text == "{ Jim, Jane, June }");

Конечно, это вызывает вопрос: как быть, если нужно далее раскрыть поле подстановки? Разумеется, можно написать WriteArgument для контейнера, но это не обеспечивает гибкость в настройке вывода. Вообразим, что мне нужно определить палитру для цветовой схемы приложения и что у меня есть основные (primary) и дополнительные (secondary) цвета:

std::vector<std::string> const primary =
  { "Red", "Green", "Blue" };
std::vector<std::string> const secondary =
  { "Cyan", "Yellow" };

Функция Write спокойно отформатирует это:

Write(printf,
  "<Colors>%%</Colors>",
  primary,
  secondary);

Однако вывод будет не совсем таким, каким я хотел видеть:

<Colors>Red, Green, BlueCyan, Yellow</Colors>

Очевидно, что это неправильно. Вместо этого я хотел бы разметить цвета так, чтобы знать, какие из них основные, а какие — дополнительные. Возможно, подойдет нечто вроде:

<Colors>
  <Primary>Red</Primary>
  <Primary>Green</Primary>
  <Primary>Blue</Primary>
  <Secondary>Cyan</Secondary>
  <Secondary>Yellow</Secondary>
</Colors>

Давайте добавим еще одну функцию WriteArgument, которая может обеспечить этот уровень расширяемости:

template <typename Target, typename Arg>
void WriteArgument(Target & target, Arg const & value)
{
  value(target);
}

Заметьте, что операция выглядит перевернутой. Вместо передачи значения мишени происходит передача мишени значению. Тем самым я могу предоставить связующую функцию (bound function) как аргумент вместо значения. Это позволяет подключить какое-либо пользовательское поведение, а не только пользовательское значение. Вот функция WriteColors, которая делает то, что я хочу:

void WriteColors(int (*target)(char const *, ...),
  std::vector<std::string> const & colors,
  std::string const & tag)
{
  for (std::string const & color : colors)
  {
    Write(target, "<%>%</%>", tag, color, tag);
  }
}

Обратите внимание: это не шаблон функции и, по сути, мне пришлось «зашить» его в код для единственной мишени. Это адаптация, специфичная для конкретной мишени, но она демонстрирует, что возможно, даже когда требуется выйти из логического распознавания обобщенного типа, напрямую обеспечиваемого функцией-драйвером Write. Но как включить это в более крупную операцию записи? У вас может возникнуть соблазн написать следующее:

Write(printf,
  "<Colors>\n%%</Colors>\n",
  WriteColors(printf, primary, "Primary"),
  WriteColors(printf, secondary, "Secondary"));

Если на минутку отбросить тот факт, что такой код не будет компилироваться, в любом случае он не даст вам правильной последовательности событий. Если бы это работало, цвета выводились бы до открытия тега <Colors>. Очевидно, их следовало бы вызывать так, будто это аргументы, в том порядке, в каком они появляются. И это как раз то, что позволяет новый шаблон функции WriteArgument. Мне нужно лишь связать вызовы WriteColors так, чтобы их можно было вызывать на более поздней стадии. Чтобы еще больше упростить использование функции-драйвера Write, я могу предложить удобную оболочку связывания (Bind):

template <typename F, typename ... Args>
auto Bind(F call, Args && ... args)
{
  return std::bind(call, std::placeholders::_1,
    std::forward<Args>(args) ...);
}

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

Write(printf,
  "<Colors>%%</Colors>\n",
  Bind(WriteColors, std::ref(primary), "Primary"),
  Bind(WriteColors, std::ref(secondary), "Secondary"));

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

Не убедил? Возможности безграничны. Вы можете обрабатывать строковые аргументы как для «широких», так и для обычных символов:

template <typename Target, unsigned Count>
void WriteArgument(Target & target, char const (&value)[Count])
{
  Append(target, value, Count - 1);
}

template <typename Target, unsigned Count>
void WriteArgument(Target & target,
  wchar_t const (&value)[Count])
{
  AppendFormat(target, "%.*ls", Count - 1, value);
}

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

Write(printf, "% %", "Hello", L"World");

Как быть, если вам изначально не требуется записывать в вывод, а нужно просто вычислить, сколько пространства может понадобиться? Нет проблем, можно создать новую мишень, которая просуммирует общее пространство:

void Append(size_t & target, char const *, size_t const size)
{
  target += size;
}

template <typename ... Args>
void AppendFormat(size_t & target,
  char const * const format, Args ... args)
{
  target += snprintf(nullptr, 0, format, args ...);
}

Теперь можно легко подсчитать требуемый размер:

size_t count = 0;
Write(count, "Hello %", 2015);
assert(count == strlen("Hello 2015"));

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


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

Выражаю благодарность за рецензирование статьи эксперту Microsoft Джеймсу Макнеллису (James McNellis).