Using C++20 Formatting Library
新年第一篇,好久没写Modern C++主题了,这次来说说C++20的格式化库。
该标准库来自开源库fmtlib,作者为Victor Zverovich,提案为P0645R10。
目前为止,仍旧只有MSVC16.10+对该库支持稍微完整,因此可以先使用 fmtlib。
格式化函数
C++20提供了三个格式化函数,std::format()
,std::format_to()
和 std::format_to_n()
。
通过一个简单的例子来了解其用法:
// format
std::cout << std::format("HAPPY NYE {} EVERYONE!", 2022) << '\n';
// format_to
std::string buffer;
std::format_to(
std::back_inserter(buffer),
"HAPPY NYE {} EVERYONE!", 2022
);
std::cout << buffer << '\n';
// format_to_n
buffer.clear();
std::format_to_n(
std::back_inserter(buffer), 6,
"HAPPY NYE {} EVERYONE!", 2022
);
std::cout << buffer << '\n';
输出如下:
HAPPY NYE 2022 EVERYONE!
HAPPY NYE 2022 EVERYONE!
HAPPY
format系列函数都包含一个格式化串参数,其中用 {}
表示占位,具体参数在该参数之后依次指定。
std::format
会返回一个 std::string
,所以可以通过 cout
直接输出格式化之后的字符串。
而 std::format_to
和 std::format_to_n
则需要指定格式化之后字符串的输出位置,后者还需指定截取的字符长度。
例子中指定了输出位置为 std::string
,截取长度为 6,所以有了如上输出。
在 std::format
和 std::format_to
内部则使用了 std::vformat
和 std::vformat_to
,实现如下:
template <class... _Types>
string format(const string_view _Fmt, const _Types&... _Args) {
return vformat(_Fmt, make_format_args(_Args...));
}
template <output_iterator<const char&> _OutputIt, class... _Types>
_OutputIt format_to(_OutputIt _Out, const string_view _Fmt, const _Types&... _Args) {
_Fmt_iterator_buffer<_OutputIt, char> _Buf(move(_Out));
vformat_to(_Fmt_it{_Buf}, _Fmt, make_format_args(_Args...));
return _Buf._Out();
}
因而在前者无法使用的情况下,可以使用后者代替前者。[见后文]
格式化语法规范
可以在格式串参数占位符 {}
中指定更多的规则,以产生更强大的字符串格式化能力,本节展示一些常用的语法。
总的语法规范官方是这样写的:
fill-and-align(optional) sign(optional) #(optional) 0(optional) width(optional) precision(optional) L(optional) type(optional)
因此,本节就按照这个顺序分成几点来介绍。
基本语法
上节的例子还可以这样写:
std::cout << std::format("{} {} {} {}!\n", "HAPPY", "NYE", 2022, "EVERYONE");
std::cout << std::format("{} {} {} {}!\n", "HAPPY", "NYE", 2022, "EVERYONE", "unused");
std::cout << std::format("{2} {1} {3} {0}!\n", "EVERYONE", "NYE", "HAPPY", 2022);
此处有几个注意点。首先,面对不同类型,占位符无需指定具体的类型,会自动识别。其次,若实际参数个数多于占位符个数,则会忽略多余的参数。最后,默认参数 ID 是从0依次增加,可以通过显式指定参数 ID 来改变默认的参数顺序。
填充与对齐
其实这个语法很简单,<
、>
、^
三个符号分别表示左对齐、右对齐和居中对齐。整数和浮点数默认是右对齐,非整数和浮点数默认是左对齐。
看如下例子:
int NYE = 2022;
std::cout << std::format("{:10}", NYE) << '\n';
std::cout << std::format("{:10}", ":)") << '\n';
std::cout << std::format("{:*<10}", ":)") << '\n';
std::cout << std::format("{:*>10}", ":)") << '\n';
std::cout << std::format("{:*^10}", ":)") << '\n';
std::cout << std::format("{:10}", true) << '\n';
将会输出:
2022
:)
:)********
********:)
****:)****
true
其中,用 :
表示后面的是可选参数,10
表示宽度,*
表示填充的字符。是不是感觉有点像是在写正则表达式了呀哈哈~
sign、# 和 0
这三个可选规则是针对数值的。
sign
用于指定正负数的符号,+
指定在格式化后正数前面加 +
号,-
指定负数前面加 -
号。如果是空格,则格式化后,正数前面会留个空格,负数前面则是 -
号。
#可以指定一些可替换的形式,主要是针对进制数的,如指定十六进制,则格式化后会在数值前面加 0x
,二进制加 0b
。
0
则会在数值前面加 0
,如 123
可能会变成 00123
。
例子如下:
std::cout << std::format("{0:},{0:+},{0:-},{0: }", NYE) << '\n';
std::cout << std::format("{0:},{0:+},{0:-},{0: }", -NYE) << '\n';
std::cout << std::format("{:#010d}", NYE) << '\n'; // 十进制
std::cout << std::format("{:#010b}", NYE) << '\n'; // 二进制
std::cout << std::format("{:#010o}", NYE) << '\n'; // 八进制
std::cout << std::format("{:#010x}", NYE) << '\n'; // 十六进制
std::cout << std::format("{:<010}", NYE) << '\n'; // 指定对齐,则补0忽略
将会输出:
2022,+2022,2022, 2022
-2022,-2022,-2022,-2022
0000002022
0b11111100110
0000003746
0x000007e6
2022
值得一提的是,对齐与补 0 不能共存,当同时指定时,补 0 将会被忽略。
宽度与精度
宽度与精度主要是针对浮点数的,直接看例子:
float NYED = 20.22f;
std::cout << std::format("{:10f}\n", NYED);
std::cout << std::format("{:{}f}\n", NYED, 10);
std::cout << std::format("{:.5f}\n", NYED);
std::cout << std::format("{:.{}f}\n", NYED, 5);
std::cout << std::format("{:10.5f}\n", NYED);
std::cout << std::format("{:{}.{}f}\n", NYED, 10, 5);
输出如下:
20.219999
20.219999
20.22000
20.22000
20.22000
20.22000
例子中的 10
是指定的宽度,.5
表示精度。可以直接在格式串中指定,也可以通过一个称为「内嵌替换域」的方式在参数后面指定,语法就是再格式串内容再嵌入 {}
。
自定义类型
std::format
并不支持所有类型的格式化操作,如何为其增加新的类型?便需要借助自定义类型。
自定义类型需要偏特化 std::formatter
,然后重写 parse()
和 format()
函数。
简而言之,自定义类型需要完成两部分工作,一是解析规则,二是格式输出。
规则就是前面写的 {:}
此类语法,由于需要自己编写解析函数,所以其实可以自定义规则。格式输出就是自己决定自定义类型输出的形式,自己指定输出哪些成员变量,添加、替换或删除哪些字符等等。
这里将提供的例子来自于 fmtlib 的示例,我将它用 C++20 标准的写法进行了改写。用此示例,是因为这个例子逻辑清晰,结构简明,很适合用来学习。
示例代码如下:
struct Point {
double x, y;
};
template<>
struct std::formatter<Point> {
constexpr auto parse(format_parse_context& ctx) {
auto it = ctx.begin(), end = ctx.end();
if (it != end && (*it == 'f' || *it == 'e')) presentation = *it++;
if (it != end && *it != '}') throw std::format_error("invalid format");
return it;
}
template<typename FormatContext>
auto format(const Point& p, FormatContext& ctx) {
return presentation == 'f'
? std::format_to(ctx.out(), "({:.1f}, {:.1f})", p.x, p.y)
: std::format_to(ctx.out(), "({:.1e}, {:.1e})", p.x, p.y);
}
char presentation = 'f';
};
这个代码完全正确,但MSVC编译不过,会报:C2039"resize": 不是 "std::_Fmt_buffer<char>"的成员
错误,这是MSVC的BUG,目前还没有修复。但是,可以通过使用 std::vformat_to
来代替 std::format_to
,从而避免该错误。于是将 format()
实现更改如下:
template<typename FormatContext>
auto format(const Point& p, FormatContext& ctx) {
return presentation == 'f'
? std::vformat_to(ctx.out(), "({:.1f}, {:.1f})", std::make_format_args(p.x, p.y))
: std::vformat_to(ctx.out(), "({:.1e}, {:.1e})", std::make_format_args(p.x, p.y));
}
现在来说 parse
函数,在这里解析规则。由于 Point
是浮点数,所以这里自定义规则为浮点表示和科学计数法表示两种形式。也就是说,规则可以为 {:f}
或 {:e}
。
parse_parse_context
是解析的上下文语境,其 begin()
指向 {:
之后的字符,end()
指向 }
。我们需要完成的工作就是解析其间的自定义规则。
在例子中,正确的规则只能是 {:f}
或 {:e}
,因此判断了第一个字符是否为其中之一。迭代器向后走一位,就是 }
,如果不是则表示规则错误,于是抛出异常。
format()
的工作就是根据解析出来的规则,使用 std::vformat_to
将自定义类型欲输出内容输出到 FormatContext 中。这样就可以格式化自定义类型的输出形式。
完成上述操作,现在便可以使用 std::format
格式化自定义类型:
Point x{ 1, 2 };
std::cout << std::format("{:f}\n", x);
std::cout << std::format("{:e}\n", x);
输出将为:
(1.0, 2.0)
(1.0e+00, 2.0e+00)
通过这种方式,你可以为任何自定义类型编写合适的格式化形式。
总结
本篇介绍了 C++20 格式化库的基本使用方式,这个东西其实非常强大,能够以强大的语法规则轻松实现各种各样的格式化形式,也可以为自定义类型装配格式化功能,可以说是 C++20 中比较常用的一个组件。