借助 C++ 进行 Windows 开发

将编译时类型检查添加到 Printf

Kenny Kerr

Kenny Kerr在我 2015 年 3 月的专栏中,我探讨了一些技术,这些技术可以使 printf 更方便地与现代 C++ 配合使用 (msdn.microsoft.com/magazine/dn913181)。我说明了如何使用可变参数模板来转换参数,以便弥合正式的 C++ 字符串类与过时的 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,我只能依靠编译器和库来找出转换说明符与调用方提供的实际参数之间的任何不一致,只是编译器和库并非总是万能的。

当然,现代 C++ 可以更好地执行这一操作。许多开发人员也试图更好地执行这一操作。问题在于,不同的开发人员具有不同的要求或优先级。有些开发人员愿意牺牲一小部分性能,简单地依靠 <iostream> 实现类型检查和扩展性。其他的开发人员设计了完善的库,提供需要其他结构和分配来跟踪格式设置状态的相关功能。就个人而言,我并不满意那些将性能和运行时开销引入到本应为基本快速操作之中的解决方案。如果一个解决方案在 C 中小巧快捷,那么它在 C++ 中也应该小巧快捷,并易于正确使用。不应产生“变慢”这种情况。

那么,能做些什么呢?只要能编译好这些抽象,少许抽象是为了留下非常接近手写 printf 语句的内容。关键是要意识到,类似于 %d 和 %s 的转换说明符实际上就是所跟参数或值的占位符。问题在于,这些占位符会对相应参数的类型做出假设,而没有任何办法来确定这些类型是否正确。让我们抛开这种伪类型信息,让编译器推断这些参数的类型,就像它们自然而然地解析参数类型一样,而不尝试添加将会确认这些假设的运行时类型检查。因此,与其编写:

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

还不如将格式字符串限制为要扩展的输出和任何占位符的实际字符。我甚至可以使用相同的元字符作为占位符:

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

相比前面的 printf 示例,此版本中的类型信息也并不少。需要将 printf 嵌入该额外类型信息的唯一原因在于,C 编程语言不具备可变参数模板。后者也更加明确。如果不需要继续查找各种 printf 格式说明符来确保我已经使用了恰当的说明符,我当然会很高兴。

我也不想将输出仅限制为控制台。将格式转换为字符串,怎么样?其他目标如何?使用 printf 的一大挑战就是,虽然它支持到各种目标的输出,但它是通过不重载且通常难以使用的不同函数实现这一功能的。实际上,C 编程语言不支持重载或泛型编程。尽管如此,不要让历史重演了。我希望可以输出到控制台,就如同我通常可以轻松地输出到字符串或文件一样。我希望实现的功能如图 1 中所示。

图 1 带有类型推断的泛型输出

Write(printf, "Hello %\n", 2015);
FILE * file = // Open 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)
  {
    // Append text
  }
};

然后,我就可以将它与 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);
}

然后,我可以为 printf 提供一个 Append 重载:

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

我原本可以使用匹配 printf 签名的函数指针来避免使用此模板,但这更具灵活性,因为其他函数可能会绑定到这一相同实现,并且指针间接寻址不以任何方式妨碍此编译器。我还可以为文件或流输出提供一个重载:

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 的原因在于,非正式测试表明两次调用 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 年 3 月的专栏中介绍的方法非常简便,但这一方法还无法处理任何这样的值,这些值不直接映射到受 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 函数非常简单。此方法的优点在于,参数适配器无需像它们在 2015 年 3 月版本中所操作的那样,将堆栈上方的某些值返回到 printf 函数。而是,WriteArgument 重载实际上将输出纳入作用范围,以便立即编写目标。这意味着可以将复杂类型用作参数,并可以依赖临时存储来格式化其文本表示形式。下面是 GUID 的 WriteArgument 重载:

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 驱动程序函数不应假设适配器函数为重载,而是,应将它们看作是由标准 C++ 库定义的非成员 begin 和 end 函数。非成员 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 而言,您应该能够只使用 static_assert 来替换 Write 函数内的断言。然后,指望内部重载 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)
{
  // Magic goes here
}

稍后,我将重点介绍这一神奇功能。最后,编译器将用尽所有参数,并需要一个非可变参数重载来完成此操作:

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,但它在调整输出方面不灵活。假设我需要定义某个应用程序配色方案的调色板,并且已经设置了主要颜色和辅助颜色:

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);
}

请注意,这一操作似乎颠倒了。不是将值传递到目标,而是将目标传递到值。通过这种方式,我可以提供绑定函数作为参数,而不是仅提供一个值。我可以附加一些用户定义行为,而不仅仅附加用户定义值。下面是执行我期望操作的 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"));

暂时先不考虑这函数无法编译这一事实,反正它也不会为您提供事件的正确顺序。如果这能正常工作,则在打开 <颜色> 标记之前就会打印颜色。显然,这些颜色应该按照出现的顺序作为参数进行调用。而这正是新的 WriteArgument 函数模板所允许的。我只需绑定 WriteColors 调用,以便在后面的阶段调用它们。为了让使用 Write 驱动程序函数的开发人员更方便地采用这一函数模板,我可以提供非常方便的绑定包装:

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 twitter.com/kennykerr 关注他。

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McNellis