65.9K
CodeProject 正在变化。 阅读更多。
Home

极其高效的类型安全 printf 库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (22投票s)

2011年2月20日

CPOL

17分钟阅读

viewsIcon

111349

downloadIcon

609

介绍一个快速且类型安全的参数渲染库(类型安全 printf)

引言

C++ 类型安全字符串渲染和参数替换库的理念有着悠久的历史。历史上,C 语言一直提供灵活的 printf 函数,结合传递可变数量参数给函数的可能性,为该问题提供了一个不错的解决方案。但对于开发者来说,这里存在一个问题:根本无法检查传递参数的数量和类型的有效性,而在 sprintf 函数的情况下,更是完全没有输出缓冲区边界检查。

C++ 引入了流输入/输出库,解决了类型安全和参数数量问题,但引入了至少两个新问题:

  1. printf 对表示形式和数据的完美分离现在已经消失。现在你需要混合静态文本数据、格式化数据和数据本身。这也会给本地化带来问题,因为字符串静态部分和参数的顺序在不同语言之间会有所不同。
  2. 几乎所有字符串输入/输出库的实现性能都很差,甚至比 C 的 printf 还要差。

目前 C++ 存在几个类型安全的 printf 库。它们中的大多数成功解决了第一个引入的问题,但并未解决第二个问题,因为它们是在 stream 输入/输出库之上实现的。

该库的设计考虑了以下几点:

  1. 引入尽可能少的开销
  2. 提供丰富的渲染功能
  3. 完全类型安全
  4. 在需要时提供输出容器边界检查

当前库的功能列表是:

  1. 该库采用模块化设计——用户可以实现和替换格式化模块和输出模块。
  2. printf 语法的兼容性不在要求列表之列,因此默认支持另一种扩展格式。如果需要,可以实现标准 printf 语法作为可插入模块。
  3. 该库是基于模板的,目前不执行任何虚拟分派。
  4. 该库没有内存分配。唯一的内存分配是在格式字符串“编译”期间执行的。
  5. 该库拥有自己非常有效的整数和浮点渲染器实现。唯一的例外是(目前)日期/时间渲染,它通过调用 CRT 函数执行。

已知限制和局限性

  1. 仅提供非常有限的区域设置支持。区域设置相关数据用于打印小数点和千位分隔符。使用系统默认区域设置,并在首次使用时缓存。
  2. 浮点渲染不支持科学计数法。

要求

该库需要您的编译器具备以下功能:

  1. 支持 auto 关键字
  2. 支持 decltype 关键字
  3. 完全支持 **r 值引用** 和完美转发
  4. 完全支持 TR1 STL 扩展

该库已在 Microsoft Visual C++ 2010 上开发和测试,但应具有可移植性。库中唯一使用 windows.h 中声明的类型(FILETIME)的代码受到以下检查保护:

#if defined(_WIN32) and defined(_FILETIME_) ... #endif

C++0x 标准在开发时尚未完成,因此代码在将来的编译器或实现修订版本晚于 Visual C++ 2010 SP1(库开发所基于的版本)的编译器上可能会出现问题。

扩展格式说明

库格式字符串语法与标准 printf 语法不兼容。相反,它具有不同的语法。

格式字符串包含直接复制到输出的纯文本块和参数占位符。每个占位符具有以下语法:

{<param-index>[width-decl][alignment-decl][plus-decl]
  [precision-decl][base-decl][padding-decl][ellipsis-decl]
  [char-decl][locale-decl]}

占位符必须用花括号括起来。如果您需要在文本中使用左花括号,则需要将其复制两次以区别于占位符的开始。不需要转义右花括号,它将始终被正确解析。

参数声明以参数编号开头。这是唯一强制字段。参数从零开始编号。所有后续声明都是可选的。如果使用多个声明,它们的顺序无关紧要,并且它们之间不得有空格或其他分隔符。

宽度声明

使用此声明来限制渲染参数的最小和/或最大长度(以字符为单位)。声明的语法是以下之一:

  • w<最小宽度>,<最大宽度>
  • w<最小宽度>
  • w,<最大宽度>

最小宽度最大宽度都必须是十进制整数,如果指定,最大宽度必须大于最小宽度

对齐声明

使用此声明设置参数对齐。除非还使用了宽度声明,否则此声明将被忽略。使用以下之一:

  • al – 左对齐(默认)
  • ar – 右对齐
  • ac – 居中对齐

正数符号声明

强制为正数渲染加号。语法:

  • +

精度声明

使用此声明指定小数点后要显示的位数。仅用于浮点类型。如果未指定,则使用默认值(BELT_TSP_DEFAULT_FP_PRECISION = 6)。您可以通过在包含库头文件之前定义 BELT_TSP_DEFAULT_FP_PRECISION 来覆盖它。

  • p<数字>

基数声明

为整数指定基数。如果将 10 以外的任何基数与浮点类型一起使用,则仅渲染整数部分。仅支持 2、8、10 和 16 的基数。可以使用小写或大写十六进制。

  • b2 – 二进制
  • b8 – 八进制
  • b10 – 十进制(默认)
  • b[0]16[x] – 小写十六进制。如果使用前缀“0”,库会在数字前添加“0x”。
  • b[0]16X – 大写十六进制。如果使用前缀“0”,库会在数字前添加“0X”。

填充声明

设置当设置了最小宽度(参见上面的宽度声明)时用于填充空间的字符。默认字符为空格。

  • f<字符>

省略号声明

截断输出时添加省略号。它与居中对齐不兼容(将作为左对齐)。请注意,使用单个 UNICODE 字符 **…** (U+2026) 作为省略号。

  • e

字符声明

将传递的 charwchar_t 参数视为字符而不是整数。

  • c

区域设置声明

使用默认用户区域设置的千位分隔符分隔千位。仅适用于基数 10。

  • l

时间声明

将传递的参数解释为日期、时间或日期+时间,并根据格式字符串显示。格式与 CRT strftime 函数中的相同。

  • t(格式字符串)

使用库

该库是仅标头文件;您无需链接任何二进制文件即可使用它。首先,包含主库的标头文件:

#include <printf/printf.h> 

所有库的标识符都声明在 ts_printf 命名空间中。本文档后面的所有示例都假定以下 using namespace 指令:

using namespace ts_printf;

下一步是包含与您将使用的输出适配器相对应的文件。目前,该库支持三种输出适配器。您可以使用任意数量的适配器:

// std::basic_string adaptor
#include <printf/basic_string_adaptor.h>
// Output iterator adaptor
#include <printf/output_iterator_adaptor.h>
// C-style character array and std::array<char_type> adaptor
#include <printf/array_adaptor.h>

稍后我们将讨论所有可用的适配器及其选项。

以下伪代码向您展示了如何使用该库将格式字符串和一组参数转换为字符数组并将其发送到输出适配器:

(ret-type | void) printf(format-object,
            parameter-object[, adaptor-specific-parameters]);

返回类型适配器特定参数取决于您选择的输出适配器,并在下面进行描述。

格式对象

该库引入了格式对象,用于存储“已编译”的格式字符串。将其与渲染分开的原因是,一旦编译,该对象就可以多次用于不同的参数集。

注意: 从不同的线程使用相同的格式对象是安全的。库不要求或执行任何同步。printf 函数始终以常量引用形式接受格式对象。

您可以使用库的 format 函数来构造格式对象。它接受不同类型的格式字符串:

编译时常量字符数组

format(L"File size: {0}");
const char fmt[] = "File size: {0}";
format(fmt);

对于编译时常量字符数组,库会跳过 strlen 的调用,因为它能够在编译时得知格式字符串的大小。请注意,在这种情况下,格式字符串必须填满整个数组,并且不得包含您不希望输出的任何字符,例如‘\0’。

如果您仍然将格式字符串保存在编译时常量字符数组中,但希望库调用 strlen,请在将格式字符串传递给 format 函数之前将其强制转换为 (const char_type *)

指向常量空终止字符串的指针

const wchar_t *fmt = GetFormatString();
format(fmt);

库会调用 strlen 函数来获取字符串长度,并将指向传入格式字符串的开头和结尾的指针存储在返回的格式对象中。

您必须确保内存指针比返回的格式对象存活更长。

注意: 禁止传递指向非常量格式字符串的指针。

对 std::basic_string 的常量引用

std::wstring fmt(L"File size: {0}");
format(fmt);

库内部存储对传入的 std::basic_string 的引用。您必须确保格式字符串比返回的格式对象存活更长。

注意:禁止传递非常量左值引用。

临时 std::basic_string

std::wstring get_fmt()
{
    return L"File size: {0}";
}
format(get_fmt());

库将“移动”或“获取所有权”传入的对象,并将其内部存储在格式对象中。简而言之,从临时 std::basic_string 创建格式对象是安全的。

指向 Boost 字符范围的常量引用

std::wstring fmt(L"##File size: {0}##");
auto subrange = boost::make_iterator_range(fmt, 2, -2);
format(subrange);

与“指向 std::basic_string 的常量引用”相同的注意事项。

临时 Boost 字符范围

std::wstring fmt(L"##File size: {0}##");
format(boost::make_iterator_range(fmt, 2, -2));

与“临时 std::basic_string”相同的注意事项。

重要提示: 对于每种类型的格式 string 参数,返回的格式对象的类型都不同。如果您打算长时间存储返回的对象,请使用 autodecltype 关键字。

class A
{
    decltype(format(L"")) MyFormat;
    decltype(format(std::wstring())) MySecondFormat;

public:
    A(std::wstring &&fmt)
    {
        MyFormat = format(L"{0}");
        MySecondFormat = format(std::move(fmt));
    }
};

格式字符串始终作为第一个参数传递给 printf 函数。正如您所见,用于构造 format 对象的 format 函数始终接受单个参数。因此,为了简单起见,当您只构造要传递给 printf 的临时 format 对象时,可以省略调用 format 函数。以下两行是等效的:

printf(format(L"File size: {0}"), ...);
printf(L"File size: {0}", ...);

如果您省略调用 format 函数,printf 将为您调用它。这没有性能损失。但是,如果您多次重建同一个 format 对象,则会有性能损失。因此,与其编写:

printf(L"size = {0}", params(100));
printf(L"size = {0}", params(101));
printf(L"size = {0}", params(102));
printf(L"size = {0}", params(103));

不如使用:

auto fmt = format(L"size = {0}");

printf(fmt, params(100));
printf(fmt, params(101));
printf(fmt, params(102));
printf(fmt, params(103));

如果指定了不正确的格式字符串,库将抛出 bad_extended_format_string 异常。

参数对象

printf 函数的第二个参数是 parameter 对象。您可以通过调用辅助函数 params 来构造 parameter 对象。辅助函数接收可变数量的参数,并内部存储对每个传入参数的常量引用。尽管如此,您也可以指定即时值;它足够智能,可以复制它们。

请始终记住,parameter 对象存储对 parameter 的常量引用。确保 parameter parameter 对象存活得更长。

参数的最大数量通过 BELT_TSP_MAX_PARAMS 预处理器常量控制,该常量默认为 10

parameter 对象具有 operator(),您可以使用它来向其添加更多 parameter。它有两个重载。第一个重载允许您将单个 parameter 添加到现有的 parameter 对象:

auto p1 = params(100);
auto p2 = p1(200);

printf(fmt, p2);

第二个重载允许您将两个 parameter 对象连接在一起:

auto p1 = params(100);
auto p2 = params(200, 300);
auto p3 = p1(p2);

printf(L"{0}", p3);

支持的参数类型

  • 所有整数、字符和浮点值或它们的常量引用。
  • int p1 = 10;
    ... params(100, 4.5, 't', p1) ...
  • 编译时字符数组。对于编译时字符数组,库不会调用 strlen 函数,而是从数组大小在编译时获取 string 的长度。如果您的 string 只占数组的一部分并以零字符终止,请将数组强制转换为 const char_type *
  • ... params(L"first string", L"second string")...
  • 指向常量零终止字符串的指针。库会调用 strlen 函数来确定 string 的长度。
  • const char *val = "first string";
    ... params(val)...
  • std::basic_string 对象的常量引用。如果您传递临时对象,请确保它们存活足够长!
  • std::wstring str1(L"first string");
    ... params(str1, std::wstring(L"second string")) ...
  • 指向 Boost 字符范围的常量引用。
  • std::wstring str1(L"first string");
    ... params(boost::make_iterator_range(str1, 2, 0)) ...
  • std::tm 结构的常量引用。
  • ts_printf:time_t 值或对其的常量引用(使用辅助函数 time 从 std::time_t 构建)。
  • 指向 FILETIME 结构的常量引用(**仅限 Windows**)。
  • ts_printf::no_value 常量。传递此常量表示库将忽略此参数。如果格式 string 中找到相应的占位符,则什么也不会渲染。

不支持其他参数类型。

输出适配器

该库支持输出适配器的概念。输出适配器是一个接收渲染输出并将其转发到输出的组件。目前,该库有三种不同的适配器,我们在下面进行描述。

输出迭代器适配器

此适配器是最通用的。如果其他库提供的适配器不适合您的需求,并且您不想创建自己的适配器,请使用此适配器。此适配器不提供任何边界检查。为了使用此适配器,请在项目中添加以下 include:

#include <printf/output_iterator_adaptor.h> 

库提供以下 printf 函数重载:

template<class OutputIterator,other_unspecified_arguments>
unspecified_return_type printf<policy>
	(format_object, params_object, OutputIterator begin);

其中

  • format_object
  • format 对象的常量引用。您也可以直接传递值来构造一个 format 对象,它将自动构造。

  • params_object
  • Parameter 对象。使用辅助函数 params 来构造它。

  • begin
  • 指向输出序列开头的输出迭代器。

  • 策略
  • 可选返回策略。如果省略(带有尖括号),函数将返回指向最后一个写入字符之后位置的更新后的输出迭代器。如果您传递 return_range 策略,函数将返回创建的序列的 Boost 范围。

该函数根据策略返回输出迭代器或 Boost 迭代器范围。此函数不执行任何边界检查。您必须确保在调用它之前有足够的存储空间。

基本字符串适配器

此适配器允许您将 printf 函数的结果作为标准 string 对象接收,或将渲染的字符 stream 附加到现有的 string 对象。为了使用此适配器,请在项目中添加以下 include:

#include <printf/basic_string_adaptor.h>

它提供了 printf 函数的两个重载。第一个重载渲染结果流,创建一个 string 对象,并返回它:

template<unspecified_arguments>
std::basic_string<unspecified_arguments>
           printf(format_object, params_object);

其中

  • format_object
  • format 对象的常量引用。您也可以直接传递值来构造一个 format 对象,它将自动构造。

  • params_object
  • Parameter 对象。使用辅助函数 params 来构造它。

返回的 string 具有与 format_object 相同的字符类型和默认分配器。

第二个重载允许您将数据附加到现有的 string

template<unspecified_arguments>
std::basic_string<unspecified_arguments> &(format_object,
    params_object, std::basic_string<unspecified_arguments> &result);

其中

  • format_object
  • 对格式对象的常量引用。您也可以直接传递值来构造一个 format 对象,它将自动构造。

  • params_object
  • Parameter 对象。使用辅助函数 params 来构造它。

  • 结果
  • 要将输出附加到的 string 的引用。

函数返回与最后一个参数中传递的 string 相同的引用。

字符数组适配器

使用此适配器指示库将输出写入指定的字符数组,并可选边界检查。它适用于标准的 C 风格字符数组以及 std::array<char_type,N> 类对象。为了使用此适配器,请在项目中添加以下 include:

#include <printf/array_adaptor.h>

策略

printf 函数的每个重载都接受一个可选策略。您使用宏 BELT_TSP_CAA_POL 来构造策略。此宏接受两个参数,第一个用于溢出控制策略,第二个用于返回策略。通过组合这两个参数,您可以调整函数的行为。

使用以下关键字之一来指定溢出策略:

  • truncate
  • 如果输出到达数组的末尾,则会被静默丢弃。在这种情况下,输出将被截断。

  • throw
  • 如果输出到达数组的末尾,将抛出类型为 index_out_of_bounds_exception 的异常。

  • ignore
  • 不执行边界检查(甚至不为此生成代码)。仅当您绝对确定输出不会到达数组末尾时才使用。

  • debug
  • 如果输出到达数组的末尾,将抛出调试断言。此策略仅在定义了 _DEBUG 预处理器常量时可用。

使用以下关键字之一来指定返回策略:

  • iterator
  • printf 将返回指向最后一个写入字符之后位置的迭代器。

  • range
  • printf 将返回存储写入序列的开始和结束的 Boost 范围。

如果您未指定策略,则使用默认策略。您可以通过在包含适配器标头文件之前定义 BELT_TSP_DEFAULT_CAA_POLICY 预处理器常量来覆盖默认策略。如果未覆盖,对于调试版本,溢出策略为 debug,返回策略为 iterator;对于发布版本,溢出策略为 ignore,返回策略为 iterator

这是一个伪代码,展示了此适配器提供的所有 printf 重载的原型:

template<class policy,unspecified_arguments>
unspecified_return_type printf(format_object,
       params_object, array_reference, begin_iterator);

其中

  • 策略
  • 可选策略对象(见上文)。如果您使用的是默认策略,请省略此参数(带有尖括号)。

  • format_object
  • 对格式对象的常量引用。您也可以直接传递值来构造一个格式对象,它将自动构造。

  • params_object
  • 参数对象。使用辅助函数 params 来构造它。

  • array_reference
  • 对(可修改的)C 风格字符数组或 std::array 对象的引用。

  • begin_iterator
  • 一个可选的迭代器,指向(在传入的数组内)开始写入输出的位置。您可以省略此参数,在这种情况下,渲染的输出将从数组的开头开始写入。

该函数根据请求的返回策略,返回指向最后一个写入字符之后位置的迭代器或 Boost 迭代器范围。

扩展库:编写您自己的输出适配器

该库使创建自己的输出适配器并在渲染期间使用它变得非常容易。首先,您需要将以下指令添加到代码中:

#include <printf/details/basic_adaptor.h>

然后,您需要将输出适配器放入 ts_printf 命名空间,并将适配器类从 ts_printf::_details::basic_adaptor 类派生。

您的输出适配器类必须实现两个方法函数:

template<class Range>
void write(Range &&range);

template<class char_type>
void write(char_type ch,size_t count);

库将 Boost 字符范围传递给第一个重载。您需要将范围复制到您的输出。第二个重载接收单个字符和计数。您需要将字符 count 次写入您的输出。

最后一步是在 ts_printf 命名空间中创建一个自由函数。它应接收一个格式对象、一个参数对象以及实例化您的输出适配器所需的任何内容。建议不要将自由函数命名为 printf,否则可能会导致编译错误。如果您不打算使用其他输出适配器,仍然可以将其命名为 printf。作为奖励,您将获得自动格式对象构造。

在此自由函数的主体内,您将构造一个输出迭代器实例,并将其传递给格式对象公开的 render 函数。

请参阅以下实现示例:

template<class Format, class ParamsHolder, class OutputIterator>
void printf(const Format &format, const ParamsHolder &params, OutputIterator it)
{
    _details::output_iterator_adaptor<OutputIterator> adaptor(it);
    format.render(params, adaptor);
}

用户定义参数对象

从版本 3 开始,该库支持用户定义参数对象。标准的参数对象(如上所述)是编译时静态的。它在编译时“知道”每个参数的类型。这是一个常见的操作,但有时您有一个值列表要显示,并且在编译时不知道每个值的类型。这可能是 COM VARIANTboost::variantboost::any,或者任何用户定义的变体类型。

为了能够将这些值与 printf 库一起使用,请将一个特殊对象作为第二个参数传递给任何 printf 重载(而不是标准的参数对象)。默认情况下,库期望 operator () 按以下方式定义:

struct UserDefinedParameterObject
{
    // ...
    template<class Helper>
    void operator ()(size_t parameter_index, const Helper &h) const
    {
        // determine the type of the object 
        // (need to convert runtime type to compile-time type):
        // for example, using switch:
        switch (get_parameter_type(parameter_index))
        {
            case tpInteger:
                h(convert_parameter_to_integer(parameter_index));	// calls h(int)
                break;
            case tpFloatingPoint:
                h(convert_parameter_to_floating_point(parameter_index));	// calls h(double)
                break;
        }
    }
};

一旦用户定义类型确定了指定参数的类型,它就应该使用一个值转换为库支持的类型之一(请参阅上文)来调用传递的辅助对象的 operator ()

库还允许您自定义使用辅助对象的方式。如果您觉得这样定义 operator () 不方便,可以覆盖 ts_printf::_details::render_user_parameter_helper 函数:

namespace ts_printf { namespace _details {
template<class Params, class Helper>
inline void render_user_parameter_helper
	(const Params &ms, size_t index, const Helper &helper)
{
	// implement custom dispatching
}
} }

其中

  • params - 您的参数对象的常量引用
  • index - 要渲染的参数索引
  • helper - 辅助对象的常量引用(如上所述)

性能测试

我曾被要求在评论中提供一些性能比较。因此,源代码已更新,包含一个 perftest 项目,该项目使用以下方法渲染一个整数、一个 double 和一个 string

  1. sprintf 写入堆栈上的字符数组
  2. ts_printf 写入同一个数组
  3. ts_printf 写入 basic_string,在每次迭代时构造
  4. ts_printf 写入字符数组(如情况 2),但这次格式对象不被缓存,而在每次迭代时重新创建
  5. 使用 ostringstream 构造 string

每个测试运行 100 万次,并计算平均迭代时间。测试应用程序被编译为本机 64 位,具有 Visual C++ 2010 SP1 提供的完整优化设置,并在两台计算机(均运行 Windows 7 64 位)上运行。**注意:** 测试是一个单线程应用程序,因此不使用多核,原始处理器速度更重要。实现了两种执行时间测量方法;下面的结果是使用性能计数器获得的。另一个度量器给出了相似的数字。

Core2 Quad Q9400 2.66GHz

sprintf:0.8865 microseconds / iteration
ts_printf to array:0.1777 microseconds / iteration
ts_printf to std::string:0.4293 microseconds / iteration
ts_printf to array (no fmt cache):0.3912 microseconds / iteration
std::ostringstream:3.3936 microseconds / iteration

Core i5 750 2.66GHz

sprintf:0,7017 microseconds / iteration
ts_printf to array:0,1283 microseconds / iteration
ts_printf to std::string:0,3371 microseconds / iteration
ts_printf to array (no fmt cache):0,2710 microseconds / iteration
std::ostringstream:2,2100 microseconds / iteration

变更历史

2011/08/16 - Version 4
  • 修复了 timedata_renderer.h 中的非编译错误(::time_t 而不是 std::time_t)。
2011/08/15 - Version 3
  • 多项错误修复,包括可能在完全优化时出现的代码生成错误
  • 不生成 4 级警告
  • 添加了用户定义参数对象支持
  • 为十六进制数字添加了自动“0x”或“0X”前缀支持
2011/02/24 - Version 2
  • 多项错误修复
© . All rights reserved.