已经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 << 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
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 << 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
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 << std::endl;
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 );

/*
The answer is 42
*/

对于花括号的输入则需要重复以转义,例如

1
2
3
4
5
std::cout << std::format("The answer is {{ }}");

/*
The answer is { }
*/

对于多个占位符,依次填入即可

1
2
3
4
5
std::cout << std::format( "{} + {} = {}", 1, 2, 3);

/*
1 + 2 = 3
*/

对于多个输入,可以在占位符中加入数字来改变顺序,例如

1
2
3
4
5
std::cout << std::format( "I'd rather be {1} than {0}", "right", "happy" );

/*
I'd rather be happy than right
*/

这里必须所有的占位符都加上索引,并且索引必须从0开始,在明确索引之后还支持参数复用

1
2
3
4
5
std::cout << std::format("{0} {1}, {0} {1}", "hello","world");

/*
hello world, hello world
*/

如果占位符的个数(或者占位符的最大索引)少于提供的实际参数,会自动忽略多的参数。

1
2
3
4
5
std::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);

printfcout默认在输出浮点数时存在固定精度不同,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);

/*
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
9
std::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
5
std::cout << std::format("{:5}", 123456789) << std::endl;

/*
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");

/*
|left |
| right|
| 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"); // 用_填充

/*
| 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
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);

/*
|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>参数支持的取值包括:

  • 对于整数:box 分别指定以二进制、八进制和十六进制输出,输出的前缀和十六进制数位a-f部分为小写;BX将输出的字母替换为大写
  • 对于浮点数:f 指定使用定点数输出,e 指定使用科学计数法输出,输出以字母e分隔底数和指数,g指定自动格式;GE 将输出的字母替换为大写(包括 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); // 左侧补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
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);

/*
| 3.14|
| 3.14|
| 3.14|
| 3.14|
*/

其它选项

[#]选项指明启用数据的替用格式:

  • 对于整数,这表明增加基数前缀(0b 或 0x);
  • 对于浮点数,这表明始终包含小数点,即便不需要。

[0]选项指明填充前导0,不应和对齐参数一起使用(那样相当于和把填充字符指定为0)。

这两个选项没有测试,实践中几乎不需要使用。

进阶使用

时间格式化

除了通常的字符和数值类型,std::format还支持一些C++标准库类型,比较重要的是时间的格式化, 使用std::chronostd::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;
}

支持自定义类型

直接从例子开始,自定义一个颜色类型

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

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

/*
color (100, 200, 255)
color #64c8ff
color #64c8ff
*/