函数签名
1 | //namepsace std |
其中前两个在
<print>
中被定义,第三个在<ostream>
中被定义。
std::print相当于补全了现代C++在输出内容上的一大缺憾。
C/C++传统的输入/输出至今已经历了三代:以<stdio.h>
为代表的传统C样式I/O、以<iostream>
为代表的C++98时代的I/O及如今<print>
打头的Modern
C++ I/O。
<stdio.h>
作为经典的输入/输出手段永不过时,其两大特性——高效、便捷堪称遥遥领先。printf
提供了高可读性的format方案——%-2d
就是输出整型左对齐2格,%.3f
就是输出浮点加三位小数,即写即用。其一大缺点是作为C时代的产物,不能支持自定义类型的输出(相当于无法实现一个统一的interface,必须另起炉灶);另一个缺点是C遗留的va_args是一个不成熟的变参数解决方案(比如没有对format-specifier和参数数量统一的校验,给极大量的segament fault和奇奇怪怪的bug留下了空间),且其并不能完美地传递C++的类/object。<iostream>
则代表着一代经典C++。其将C++区别于C的特性发挥到了极致——通过继承树提供了I/O的统一接口,同时令用户可以通过重载operator<<
来便捷地自定义类型(这点甚至远胜于print
的formatter
特化),在OOP上则让I/O流成为对象且支持调用各种方法来对之进行操作。但其冗余过多的副作用就是输出效率远不如<stdio.h>
——频繁的函数调用(每一个<<
都算调用一次函数,需要不断地将std::ostream&
操作和返回,inline
亦很难对之有很大改观);同时格式化输出的便捷性和可读性之差一直是其痛点之一。比较一下三代C/C++格式化输出一个简单的信息:1
2
3
4
5
6
7
8
9
10
11void output(double temp,int speed)
{
//<stdio.h>
printf("Temperature: %.1f ℃. Speed: %d km/h.\n",temp,speed);
//<iostream>
std::cout << "Temperature: " << std::setpercision(1) << temp << " ℃. Speed: " << speed << "km/h." << std::endl;
//<print>
std::println("Temperature: {:.1f} ℃. Speed: {} km/h.",temp,speed);
}<print>
则是C++23加入的新成员,其基本继承了既有的较成功库<fmtlib>
的实践。
trivial-type的格式化语法上和python的format几乎无二,相当于保留了printf
的高可读性和高效率的同时,融合了Modern C++的template元素,并由此支持自定义类型格式化和类型推导。
函数签名显然非常的straightforward,第一个重载为基础,后两个只是指定了输出流(可以传FILE*
或std::ostream&
)。
std::formatter的特化
客观来说,<format>
和<print>
的自定义实际上门槛相较于<iostream>
的重载operator<<
还是有些高。
自定义类型需要通过特化std::formatter
来实现自定义输出。
但其优点在于开发复用上限极高,可以将任意的自定义类的format自由度提至与trivial-types相并论。
关于std::formatter的一切,可以移步天书般的cppreference。
std::formatter特化用法精选:先从下面的代码出发,自行体会。
1 | class CR |
回顾一下:今我们希望将一个对象转化为字符串输出,该任务可以分为两步:
解析格式串,将format的“需求”存储起来;
根据这些“需求”,读取对象信息并将之格式化。
而std::formatter的
parse
和format
函数就分别负责执行这两个任务。
- 第一个函数必须具有签名
constexpr auto parse(std::format_parse_context&);
(auto类型由decltype(std::format_parse_context::begin())
确定)。
该函数必须声明为constexpr,否则编译不能通过(对于直接的调用std::format/print,C++23要求必须在编译期处理之,同时引出的就是目前格式串必须是string-constant;动态格式串另有他法)。
该函数接收的std::format_parse_context&
实际上是表征format-specifier区间的一个view,调用fpc.begin()
会返回一个指向当前处理域':'的下一个字符的迭代器。
插播一下“处理域”的定义:每个框或可为全空
{}
,或应形如{A:B}
,其中A为一部分,':B'为一部分,可以理解为两侧分别都是可选的,用法见下:A是index,若不指定A,则该格式字符串采用的是“自动索引”(auto-indexing),即每遇到一个框就把下一个参数丢进去;
否则为手动索引(manual-indexing),指定每个框所用参数的index(可以复用),如
std::println("{0} {1} {0}",1,2);
输出1 2 1
。不允许使用混合索引(即要么全自动,要么全手动)。
索引不允许越界,比如
std::println("{} {}",1)
不能通过编译。:B是format-specifier,形式和python/C的类似,每个框都可以自定义,不再赘述。
应用实例:将一个浮点数输出两次,精度分别为4位、2位:
std::println("{0:.4f} -> {0:.2f}",some_float);
。
简而言之,该函数的任务是通过移动该迭代器来收集format的需求,并将之存储于这个struct的成员变量中,以供后续处理。
该函数的返回值必须同样是一个迭代器,且该迭代器必须指向'}'(也就是format-specifier的末尾的下一个字符),否则会抛出std::format_error!
- 第二个函数必须具有签名
auto format(const Ty&,std::format_context&) const;
(auto类型由decltype(std::format_context::out())
确定)。
该函数必须被声明为常成员函数(后缀const
不要漏);对于trivial-types,const Ty&可以被改为Ty(更改原则和其它函数的const int&/int一类相同)。
该函数接收的std::format_context&
是一个缓存输出格式串的字符串,调用fc.out()
会返回一个back insert iterator。
back insert iterator是一类简单的迭代器,其仅定义了以下的
public
成员函数:
operator*()
,返回*this
;operator++()
(含operator++(int)
),返回*this
且什么也不做;operator=(const Ty&)
(含operator=(Ty&&)
),将给定元素copy(或move)追加到容器末尾(相当于调用了push_back(const Ty&)
/push_back(Ty&&)
)。事实上这些iterator的
operator=
有四个重载,还有两个是类的基本成员函数(即copy/move assignment operator)在C++ Standard library中,调用std::back_inserter(some_container)可以获得一个back insert iterator,some_container应具有
push_back
成员函数。
该函数应当通过这个fc.out()
来向缓冲字符串中写入字符,并返回fc.out()
。
写入方法相对就比较五花八门了:
最“老土”的方式无疑是直接对这个iterator逐个字符地赋值(注意右操作数只能是char
);
现代C++中同样有很多函数可以高效地进行此项任务,对于潜在自由度较高的object,可以先建立一个string缓存并操作之,最后再copy过去。
两个比较常用的、更为简洁的方法为复用std::ranges::copy
和std::format_to
1 | //方法1:利用ranges库,集复制返回于一体 |
奇技淫巧:既有Formatter的复用
对于很多较简单的class/object,一视同仁start-from-scratch显然不是明智之举,此时对formatter的复用可令你在format间游走自如。
复用方式有二:继承复用/成员复用
还是直接看代码: 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
28
29
30
31
32//还是以上文的CR类为例
namespace inheritance
{
template<>
struct std::formatter<CR> : std::formatter<int>
{
auto format(const CR& c,std::format_context& fc) const
{
return std::formatter<int>::format(c.speed,fc);
}
}; //相当于用std::formatter<int>部分覆写了自身,parse遵循int的format-specifier
//而format时需要调用父类的format方法
}
namespace member
{
template<>
struct std::formatter<CR>
{
std::formatter<int> fmt{};
constexpr auto parse(std::format_parse_context& fpc)
{
return fmt.parse(fpc);
}
auto format(const CR& c,std::format_context& fc) const
{
return fmt.format(c.speed,fc);
}
};
}
显然inheritance对于较小的类(最好是只有一个需要格式化的量)来说比较友好,格式化进行更高效;
且若std::convertible_to<const A&, B>
(A为待格式化的类,B为继承类的format
函数第一参数)为true
,则可以完全继承之,比如自定义许多容器的format。
member法的灵活度会更高些,甚至可以在parse
中让迭代器在format_parse_context
里游走来实现更高的自由度,适合于每个参数的输出格式都希望独立控制的类,如时间。
下面再附上一例应用了concepts、std::span
等来实现std::vector
的通用格式化方法:
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
28
29
30
31
32
33
34
35
36
37
38
39
template<std::default_formattable<char> Ty>
struct std::formatter<std::span<Ty>>
{
constexpr auto parse(std::format_parse_context& fpc)
{
return fpc.begin();
}
auto format(std::span<Ty> sp,std::format_context& fc) const
{
//我们希望实现一个类似python的list的效果,即[1,2,3]这样,此处尚未加入自定义分隔符等功能
std::string buf{"["};
for(const auto& i : sp)
{
buf += std::format("{},",i);
}
buf.back() = ']';
return std::ranges::copy(buf,fc.out()).out;
}
};
template<std::default_formattable<char> Ty>
struct std::formatter<std::vector<Ty>> : std::formatter<std::span<Ty>>
{
};
//现在可以直接格式化所有(元素类型可以被默认格式化的)vector
int main()
{
std::vector<int> vc{1,2,3};
std::println("{}",vc); //[1,2,3]
return 0;
}