已经2024年了,C++20标准正式收编fmtlib得到的格式化方案std::format已经被三大编译器支持得很好了,
虽然部分特性在后续的标准中仍然在改进,但是值得好好学习整理一下了。
std::format在形式上和Python的字符串格式化非常类似,对用户很友好。当然由于Python自身是动态的,f-string可以玩得花样更多,写起来更方便,这是C++无论如何也比不了的。
简单示例
从HelloWorld开始
1 2 3 4 5 6 7 8
| #include <format> #include <iostream>
int main() { std::string str = std::format("Hello, {}!", "World"); std::cout << str << '\n'; return 0; }
|
主要是用来测试编译器支持的,编译器版本不能太低,并且还需要加入-std=c++20之类的选项,确保编译器可以顺利编译。
当前采用的编译器为:msvc14(VS2022),gcc13和clang18。
对比示例
我们看一个例子,这个例子也是我决定立刻马上好好学一下std::format的直接原因,格式化输出一个表格,有很多浮点数,并且对于浮点数的格式要求不是统一的。
在使用std::format之前,为了调整格式,我需要不断地通过<<传递flag,非常繁琐。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| void print_error_table(std::ostream &out, const std::vector<int> &nlist, const std::vector<double> &error_l1, const std::vector<double> &error_l2, const std::vector<double> &error_linf, const std::vector<double> &order_l1, const std::vector<double> &order_l2, const std::vector<double> &order_linf, char delimiter) { out << std::setw(5) << "n" << delimiter << std::setw(12) << "error" << delimiter << std::setw(8) << "order" << delimiter << std::setw(12) << "error" << delimiter << std::setw(8) << "order" << delimiter << std::setw(12) << "error" << delimiter << std::setw(8) << "order" << "\n";
for (size_t i = 0; i < nlist.size(); ++i) { out << std::setw(5) << nlist[i] << delimiter << std::scientific << std::setw(12) << std::setprecision(2) << error_l1[i] << delimiter << std::fixed << std::setw(8) << std::setprecision(2) << order_l1[i] << delimiter << std::scientific << std::setw(12) << std::setprecision(2) << error_l2[i] << delimiter << std::fixed << std::setw(8) << std::setprecision(2) << order_l2[i] << delimiter << std::scientific << std::setw(12) << std::setprecision(2) << error_linf[i] << delimiter << std::fixed << std::setw(8) << std::setprecision(2) << order_linf[i] << "\n"; } out << '\n'; return; }
|
在使用了std::format之后,就和其它语言例如Python一样,我可以一次性把格式化的需要写完,然后依次填入即可。(这里实现的格式比上面的更复杂,每一项都居中)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| void print_error_table(std::ostream &out, const std::vector<int> &nlist, const std::vector<double> &error_l1, const std::vector<double> &error_l2, const std::vector<double> &error_linf, const std::vector<double> &order_l1, const std::vector<double> &order_l2, const std::vector<double> &order_linf, char delimiter) { auto header = std::format("{:^5} {:c} {:^12} {:c} {:^8} {:c} {:^12} {:c} " "{:^8} {:c} {:^12} {:c} {:^8}\n", "n", delimiter, "error", delimiter, "order", delimiter, "error", delimiter, "order", delimiter, "error", delimiter, "order"); out << header;
for (size_t i = 0; i < nlist.size(); ++i) { auto row = std::format( "{:^5} {:c} {:^12.2e} {:c} {:^8.2f} {:c} {:^12.2e} {:c} " "{:^8.2f} {:c} {:^12.2e} {:c} {:^8.2f}\n", nlist[i], delimiter, error_l1[i], delimiter, order_l1[i], delimiter, error_l2[i], delimiter, order_l2[i], delimiter, error_linf[i], delimiter, order_linf[i]); out << row; } out << '\n'; return; }
|
虽然总体的代码量没有明显的减少,但是记忆并使用各种io的flag例如std::setw(8)和std::setprecision(2),而且格式化设置和内容交错在一起,明显没有直接写一个完整的std::format来的简单明了。前者很可能在某处漏掉了什么,却只能通过输出来检查。
std::format看起来像是又回到了printf的%d,%.12f的那一套,但是两者具有本质上的不同,例如它可以自动获取数据类型,进行编译期处理。
基本使用
std::format使用{}作为占位符,例如
1 2 3 4 5
| std::cout << std::format( "The answer is {}", 42 );
|
对于花括号的输入则需要重复以转义,例如
1 2 3 4 5
| std::cout << std::format("The answer is {{ }}");
|
对于多个占位符,依次填入即可
1 2 3 4 5
| std::cout << std::format( "{} + {} = {}", 1, 2, 3);
|
对于多个输入,可以在占位符中加入数字来改变顺序,例如
1 2 3 4 5
| std::cout << std::format( "I'd rather be {1} than {0}", "right", "happy" );
|
这里必须所有的占位符都加上索引,并且索引必须从0开始,在明确索引之后还支持参数复用
1 2 3 4 5
| std::cout << std::format("{0} {1}, {0} {1}", "hello","world");
|
如果占位符的个数(或者占位符的最大索引)少于提供的实际参数,会自动忽略多的参数。
1 2 3 4 5
| std::cout << std::format("{} {} {}\n",1,2,3,4);
|
如果占位符多于提供的实际参数,则会触发编译错误。
1
| std::cout << std::format("{} {} {}\n",1,2);
|
目前主要考虑的都是合法的使用,对于不合法的使用,具体会产生什么样的效果:编译错误,抛异常,或者忽略错误,暂时不会关注,这可能与具体的编译器版本有关,例如很多参考资料说,如果格式说明符设置错误,将抛出std::format_error异常,但是在实测中(clang 18.1.2)发现会直接出现编译错误
1
| std::cout << std::format("An interger: {:.}", 5);
|
与printf和cout默认在输出浮点数时存在固定精度不同,std::format在处理浮点数输出时,默认会尽可能提供一个完整的数据,
即可能输出十几位数的数据,也可能只有几位数据(因为此时多加的位数没有意义)。例如
1 2 3 4 5 6 7 8 9 10 11
| std::cout << std::format("{}\n",1.0/3); std::cout << std::format("{}\n",sqrt(2)); std::cout << std::format("{}\n",3.14); std::cout << std::format("{}\n",3.1415926);
|
格式控制
现在我们关注输出格式的具体控制,占位符支持的完整形式为
1
| {[argument position][:[[[fill]align][sign][#][0][width][.precision][type]]]}
|
其中:
- 冒号
:之前视作占位符的索引,冒号之后的部分视作格式控制。
- 必须加上冒号
:才能继续设置格式控制,必须加上.才能继续设置精度和类型
- 几乎所有的格式控制都是独立的可选参数,但是设置填充字符时必须也设置对齐方式。
下面依次介绍。
总宽度
<width>决定显示部分的最小宽度,宽度可以设置或通过{}传入,例如
1 2 3 4 5 6 7 8 9
| std::cout << std::format("The answer is:\n"); std::cout << std::format("{:5}\n", 42); std::cout << std::format("{:{}}\n", 42, 5);
|
这里的宽度是最小宽度,实际宽度不足时,会填充空格或0或其它指定的填充字符;实际宽度超过最小宽度,则会自动扩容,例如
1 2 3 4 5
| std::cout << std::format("{:5}\n", 123456789);
|
对齐与填充
[<fill>]<align>参数用于设置输出的对齐行为和填充行为,支持右对齐,左对齐和居中对齐(默认右对齐)
<: left
>: right
^: center
例如
1 2 3 4 5 6 7 8 9
| std::cout << std::format( "|{:<10}|\n", "left"); std::cout << std::format( "|{:>10}|\n", "right"); std::cout << std::format( "|{:^10}|\n", "centered");
|
在指定对齐时,如果设置宽度超过实际宽度,默认会补充空格,可以提供填充字符来更改,例如
1 2 3 4 5 6 7 8 9
| std::cout << std::format("|{:^20}|\n", "centered"); std::cout << std::format("|{:-^20}|\n", "centered"); std::cout << std::format("|{:_^20}|\n", "centered");
|
注意:要设置填充字符必须也设置对齐。
符号位
<sign>决定整数和浮点数的符号位显示策略,支持如下参数:
-: 只有负数显示符号位,例如负数-2,正数2,这是默认行为
+: 全部显示符号位,例如负数-2,正数+2
: 负数显示符号位,正数保留空格,例如负数-2,正数 2
例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| std::cout << std::format("|{}|{}|\n", 20, -20); std::cout << std::format("|{:-}|{:-}|\n", 20, -20); std::cout << std::format("|{:+}|{:+}|\n", 20, -20); std::cout << std::format("|{: }|{: }|\n", 20, -20);
double s = 3.14; std::cout << std::format("|{}|{}|\n", s, -s); std::cout << std::format("|{:-}|{:-}|\n", s, -s); std::cout << std::format("|{:+}|{:+}|\n", s, -s); std::cout << std::format("|{: }|{: }|\n", s, -s);
|
类型与精度
<type>参数支持的取值包括:
- 对于整数:
b、o 和 x 分别指定以二进制、八进制和十六进制输出,输出的前缀和十六进制数位a-f部分为小写;B和X将输出的字母替换为大写
- 对于浮点数:
f 指定使用定点数输出,e 指定使用科学计数法输出,输出以字母e分隔底数和指数,g指定自动格式;G 和 E 将输出的字母替换为大写(包括 inf 和 nan 的大小写)
<precision>用于指定浮点数输出的有效位数:
- 对于定点数表示,指定小数后位数
- 对于科学计数法表示,指定底数的小数后位数。
例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const double pi{3.1415926}; std::cout << std::format("|{:5}|\n", pi); std::cout << std::format("|{:10}|\n", pi); std::cout << std::format("|{:010}|\n", pi); std::cout << std::format("|{:.4f}|\n", pi); std::cout << std::format("|{:.14f}|\n", pi); std::cout << std::format("|{:.4e}|\n", pi); std::cout << std::format("|{:.14e}|\n", pi);
|
注:
g/G以及默认行为下,会自动根据数据的特点选择定点数或科学计数法表示(以确保可读性),当数的绝对值大小在一定范围内使用定点输出,数值非常小或非常大时使用科学计数法输出。
- 浮点数在输出时不会受到总宽度的限制而截断,宽度不足它会自动扩容的,但会受到
<precision>的控制而截断。
格式化涉及到的位数设置都可以通过{}传递整数来提供,例如下面几个用法是一样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const double pi{3.1415926}; const int precision{2}; const int width{12}; std::cout << std::format("|{:12.2f}|\n", pi); std::cout << std::format("|{:12.{}f}|\n", pi, precision); std::cout << std::format("|{:{}.{}f}|\n", pi, width, precision); std::cout << std::format("|{0:{1}.{2}f}|\n", pi, width, precision);
|
其它选项
[#]选项指明启用数据的替用格式:
- 对于整数,这表明增加基数前缀(0b 或 0x);
- 对于浮点数,这表明始终包含小数点,即便不需要。
[0]选项指明填充前导0,不应和对齐参数一起使用(那样相当于和把填充字符指定为0)。
这两个选项没有测试,实践中几乎不需要使用。
进阶使用
时间格式化
除了通常的字符和数值类型,std::format还支持一些C++标准库类型,比较重要的是时间的格式化,
使用std::chrono和std::format可以让时间戳的生成非常简洁
1 2 3 4 5 6 7 8 9
| #include <chrono> #include <format> #include <iostream>
int main() { std::cout << std::format("{:%Y-%m-%d %H:%M:%S}.\n", std::chrono::system_clock::now()); return 0; }
|
支持自定义类型
std::format对于自定义类型的支持还是比较复杂的,“提供自定义类型到char *或者std::string的转换函数”这种自然的想法是远远不够的。
直接从例子开始,自定义一个颜色类型
1 2 3 4 5
| struct MyColor { uint8_t r{0}; uint8_t g{0}; uint8_t b{0}; };
|
为了让std::format支持这个类型的格式化,我们需要实例化std::formatter<MyColor>,并具体实现它的两个方法:
parse:负责解析占位符,用来支持一些自定义的格式化模式,没有特殊需求时可以如下的简单实现,尽可能使用constexpr在编译期优化
format:负责生成具体内容字符串,通常基于std::format_to或者std::format_to_n实现
例如可以通过std::format_to实现format,对于parse可以如下简单实现
1 2 3 4 5 6 7 8 9 10
| template <> struct std::formatter<MyColor> { static constexpr auto parse(std::format_parse_context &ctx) { return ctx.begin(); }
static auto format(const MyColor &col, std::format_context &ctx) { return std::format_to(ctx.out(), "({}, {}, {})", col.r, col.g, col.b); } };
|
也可以基于std::formatter<string_view>进行实现(此时可以省略实现parse)
1 2 3 4 5 6 7 8 9
| template <> struct std::formatter<MyColor> : std::formatter<string_view> { auto format(const MyColor& col, std::format_context& ctx) const { std::string temp; std::format_to(std::back_inserter(temp), "({}, {}, {})", col.r, col.g, col.b); return std::formatter<string_view>::format(temp, ctx); } };
|
然后就可以使用std::format进行格式化
1 2 3 4 5
| std::cout << std::format("color {}\n", MyColor{100, 200, 255});
|
我们已经实现了一个简单的demo,但是这里对自定义类只能使用{},而不支持类似{:5}的格式控制。
为了让自定义类支持格式控制,需要更改parse的实现,让parse解析格式控制的标记并把信息保存在成员变量中,在format方法中基于这些信息进行不同的格式化输出。
接下来让自定义的颜色类型MyColor支持两个格式化模式:
{}:默认模式,输出(100, 200, 255)
{:h}或{:H}:十六进制模式,输出#64c8ff
代码实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| template <> struct std::formatter<MyColor> { constexpr auto parse(std::format_parse_context &ctx) { auto pos = ctx.begin(); while (pos != ctx.end() && *pos != '}') { if (*pos == 'h' || *pos == 'H') isHex = true; ++pos; } return pos; }
auto format(const MyColor &col, std::format_context &ctx) const { if (isHex) { uint32_t val = (col.r << 16) | (col.g << 8) | col.b; return std::format_to(ctx.out(), "#{:x}", val); }
return std::format_to(ctx.out(), "({}, {}, {})", col.r, col.g, col.b); }
bool isHex{false}; };
|
然后就可以使用std::format进行不同模式的格式化
1 2 3 4 5 6 7 8 9
| std::cout << std::format("color {}\n", MyColor{100, 200, 255}); std::cout << std::format("color {:H}\n", MyColor{100, 200, 255}); std::cout << std::format("color {:h}\n", MyColor{100, 200, 255});
|