Cpp std::format 学习笔记
已经2024年了,C++20标准正式收编fmtlib
得到的格式化方案std::format
已经被三大编译器支持得很好了,
虽然部分特性在后续的标准中仍然在改进,但是值得好好学习整理一下了。
std::format
在形式上和Python的字符串格式化非常类似,对用户很友好。当然由于Python自身是动态的,f-string
可以玩得花样更多,写起来更方便,这是C++无论如何也比不了的。
简单示例
从HelloWorld开始 1
2
3
4
5
6
7
8
int main() {
std::string str = std::format("Hello, {}!", "World");
std::cout << str << std::endl;
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
27void 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 << std::endl;
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
26void 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 << std::endl;
return;
}
虽然总体的代码量没有明显的减少,但是记忆并使用各种io的flag例如std::setw(8)
和std::setprecision(2)
,而且格式化设置和内容交错在一起,明显没有直接写一个完整的std::format
来的简单明了。前者很可能在某处漏掉了什么,却只能通过输出来检查。
std::format
看起来像是又回到了printf
的%d
,%.12f
的那一套,但是两者具有本质上的不同,例如它可以自动获取数据类型,进行编译期处理。
基本使用
std::format
使用{}
作为占位符,例如
1
2
3
4
5std::cout << std::format( "The answer is {}", 42 );
/*
The answer is 42
*/
对于花括号的输入则需要重复以转义,例如 1
2
3
4
5std::cout << std::format("The answer is {{ }}");
/*
The answer is { }
*/
对于多个占位符,依次填入即可 1
2
3
4
5std::cout << std::format( "{} + {} = {}", 1, 2, 3);
/*
1 + 2 = 3
*/
对于多个输入,可以在占位符中加入数字来改变顺序,例如
1
2
3
4
5std::cout << std::format( "I'd rather be {1} than {0}", "right", "happy" );
/*
I'd rather be happy than right
*/
这里必须所有的占位符都加上索引,并且索引必须从0开始,在明确索引之后还支持参数复用
1
2
3
4
5std::cout << std::format("{0} {1}, {0} {1}", "hello","world");
/*
hello world, hello world
*/
如果占位符的个数(或者占位符的最大索引)少于提供的实际参数,会自动忽略多的参数。
1
2
3
4
5std::cout << std::format("{} {} {}\n",1,2,3,4);
/*
1 2 3
*/
如果占位符多于提供的实际参数,则会触发编译错误。 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
11std::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);
/*
0.3333333333333333
1.4142135623730951
3.14
3.1415926
*/
格式控制
现在我们关注输出格式的具体控制,占位符支持的完整形式为
1
{[argument position][:[[[fill]align][sign][#][0][width][.precision][type]]]}
其中:
- 冒号
:
之前视作占位符的索引,冒号之后的部分视作格式控制。 - 必须加上冒号
:
才能继续设置格式控制,必须加上.
才能继续设置精度和类型 - 几乎所有的格式控制都是独立的可选参数,但是设置填充字符时必须也设置对齐方式。
下面依次介绍。
总宽度
<width>
决定显示部分的最小宽度,宽度可以设置或通过{}
传入,例如
1
2
3
4
5
6
7
8
9std::cout << std::format("The answer is:") << std::endl;
std::cout << std::format("{:5}", 42) << std::endl;
std::cout << std::format("{:{}}", 42, 5) << std::endl;
/*
The answer is:
42
42
*/
这里的宽度是最小宽度,实际宽度不足时,会填充空格或0或其它指定的填充字符;实际宽度超过最小宽度,则会自动扩容,例如
1
2
3
4
5std::cout << std::format("{:5}", 123456789) << std::endl;
/*
123456789
*/
对齐与填充
[<fill>]<align>
参数用于设置输出的对齐行为和填充行为,支持右对齐,左对齐和居中对齐(默认右对齐)
<
: left>
: right^
: center
例如 1
2
3
4
5
6
7
8
9std::cout << std::format( "|{:<10}|\n", "left");
std::cout << std::format( "|{:>10}|\n", "right");
std::cout << std::format( "|{:^10}|\n", "centered");
/*
|left |
| right|
| centered |
*/
在指定对齐时,如果设置宽度超过实际宽度,默认会补充空格,可以提供填充字符来更改,例如
1
2
3
4
5
6
7
8
9std::cout << std::format("|{:^20}|\n", "centered"); // 用空格填充
std::cout << std::format("|{:-^20}|\n", "centered"); // 用-填充
std::cout << std::format("|{:_^20}|\n", "centered"); // 用_填充
/*
| centered |
|------centered------|
|______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
24std::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);
/*
|20|-20|
|20|-20|
|+20|-20|
| 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);
/*
|3.14|-3.14|
|3.14|-3.14|
|+3.14|-3.14|
| 3.14|-3.14|
*/
类型与精度
<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
18const 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); // 左侧补0
std::cout << std::format("|{:.4f}|\n", pi); // 定点数的小数部分截断
std::cout << std::format("|{:.14f}|\n", pi); // 定点数的小数部分补0
std::cout << std::format("|{:.4e}|\n", pi); // 浮点数的小数部分截断
std::cout << std::format("|{:.14e}|\n", pi); // 浮点数的小数部分补0
/*
|3.1415926|
| 3.1415926|
|03.1415926|
|3.1416|
|3.14159260000000|
|3.1416e+00|
|3.14159260000000e+00|
*/
注:
g/G
以及默认行为下,会自动根据数据的特点选择定点数或科学计数法表示(以确保可读性),当数的绝对值大小在一定范围内使用定点输出,数值非常小或非常大时使用科学计数法输出。- 浮点数在输出时不会受到总宽度的限制而截断,宽度不足它会自动扩容的,但会受到
<precision>
的控制而截断。
格式化涉及到的位数设置都可以通过{}
传递整数来提供,例如下面几个用法是一样的
1
2
3
4
5
6
7
8
9
10
11
12
13
14const 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);
/*
| 3.14|
| 3.14|
| 3.14|
| 3.14|
*/
其它选项
[#]
选项指明启用数据的替用格式:
- 对于整数,这表明增加基数前缀(0b 或 0x);
- 对于浮点数,这表明始终包含小数点,即便不需要。
[0]
选项指明填充前导0,不应和对齐参数一起使用(那样相当于和把填充字符指定为0)。
这两个选项没有测试,实践中几乎不需要使用。
进阶使用
时间格式化
除了通常的字符和数值类型,std::format
还支持一些C++标准库类型,比较重要的是时间的格式化,
使用std::chrono
和std::format
可以让时间戳的生成非常简洁
1
2
3
4
5
6
7
8
9
int main() {
std::cout << std::format("{:%Y-%m-%d %H:%M:%S}.\n",
std::chrono::system_clock::now());
return 0;
}
支持自定义类型
直接从例子开始,自定义一个颜色类型 1
2
3
4
5struct 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
10template <>
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
9template <>
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
5std::cout << std::format("color {}\n", MyColor{100, 200, 255});
/*
color (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
23template <>
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; // expect `}` at this position, otherwise,
// it's error! exception!
}
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
9std::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});
/*
color (100, 200, 255)
color #64c8ff
color #64c8ff
*/