如何用一个接口定制所有C++容器的打印
C++语言中,几乎所有的基本类型都支持用 ostream 类的 << 操作符进行打印,比如常见的:
int x = 1;
cout << x;
char c = 'p';
cout << c;
double y = 3.1415926;
cout << y;
然而,除了 std::basic_string 以外,几乎所有的STL容器都不支持用 ostream 打印。如果写下面的代码,会报编译错误:
vector<int> v{1, 2, 3};
cout << v; // error: no match for 'operator<<' …
这给容器的使用带来了一些不便。一些其他的语言(比如 Python )支持直接将容器对象进行打印输出,如果希望C++也能实现类似功能,需要自己编写一些代码。
这种打印输出乍一看很简单——写个循环遍历元素不就行了?但是,考虑下面两个需求:
1. 容器可以任意嵌套
如果 std::vector 的元素类型是 std::tuple ,std::tuple 的某个成员类型是 std::set,是否能用一个接口完成所有内容打印?——熟悉模板编程和递归的程序员可能会觉得这也不是大问题。那么看看第二个需求:
2. 每个容器打印的格式都可以任意定制
数据集的打印有些常见格式:比如 JSON 数组的 [1,2,3] 这种格式。但是,用户并不总是希望用同一种格式打印所有数据。举个例子:对于一个二维数组,如果都用方括号和逗号的格式,打印出来会类似这样:
[[1,2,3,4],[5,6,7,8],[9,10,11,12]]
但是假如用户的这个二维数组表示的是一个矩阵,他恐怕不见得希望打印成这样,也许按矩阵的形式会更直观:
[1,2,3,4]
[5,6,7,8]
[9,10,11,12]
因此,如果允许每次打印都能指定想要的格式,在使用上会更加灵活。
这两个需求单独看,都不难实现。但是两个需求要同时支持就麻烦了。我们来看一个极端一点的例子:一个多层嵌套的容器类型:std::tuple<std::pair<long, char>, double, std::vector<std::pair<int, int>>>
这个容器如果用树的形式表示嵌套关系,是下图的样子:
在这个数据结构中,总共有4个容器需要能定制打印格式,那么如何传入和解析这4套格式参数?
问题到这儿似乎开始有点挑战了。不过我们还没提到,用户可能还有其他易用性需求:
- 定制格式的接口尽可能简单,一眼就能看懂,比如直接使用C++的大括号列表就能表达:{a, b, c},而不要额外调用其他的函数或者对象类型。
- 为了让用户正确传入格式参数,希望在编译期对格式参数的个数进行检查,确保用户传入的参数个数和数据结构需要的参数个数完全一致。(想想 printf 函数不检查参数个数害死过多少人)
好吧,下面让我们一起看看,使用哪些技术的组合可以实现这些需求。
本文的所有代码均基于C++17语言标准。
格式参数的设计
首先分析一下某一个容器的打印格式——它基本上可以看做是3个不同位置的字符串组合:
在上图的例子中,起始、分隔、结束分别对应"["、","(根据喜好可能逗号后有空格或者没有空格)、"]"3个字符串,那么这3个字符串组合在一起可以定义为一组格式参数。数据结构嵌套多层容器时,根据嵌套的结构会需要多组格式参数,每一组格式参数都包含3个字符串。
从使用者角度,采用下面的方式传入大括号列表是最简单直观的:
Put(x, {{"[", "\n", "]"}, {"[", ",", "]"}});
相应的接口定义就是二维字符串数组:
template<typename T, size_t N>
void Put(const T& value, const char* const (¶)[N][3]) {}
注意这里内层数组的大小是确定的(3个参数为1组),但外层的大小是根据传入的参数推导出来的(模板参数 N)。而且为了取得数组的大小,参数类型必须定义为数组的引用(左值引用或右值引用均可,这里因为不修改数组内容所以用 const 左值引用)。
把格式参数类型限定成 const char* 会带来一些使用不便——假如用户手头恰好有个 std::string,还得自己转换一下么?因此,我们希望利用模板定义,让格式参数能支持更多类型:
template<typename T, typename P, size_t N>
void Put(const T& value, const P (¶)[N][3]) {}
注意由于C++中大括号列表的特殊性,接口中的 para 必须是数组的引用类型。如果试图使用带有模板参数的 std::tuple、std::array、std::initializer_list 类型全部都会产生编译错误。
将格式参数定义为模板类型可带来很多好处,比如用户可以自定义一个类型,在打印的时候判断操作系统环境来决定是输出"\r\n"还是"\n"。
对类型树的深度优先遍历
当用户面对上图中那个复杂的嵌套数据结构时,他如何了解自己传入的每组格式参数对应哪一个容器?为此,我们需要约定格式参数与树中节点(容器)的对应关系。
要将树中的节点和线性的序号(参数数组的下标)对应起来,可以按广度优先(BFS)或者深度优先(DFS)方式对节点进行遍历。由于打印的实现采用了递归的方式,因此深度优先的约定更加适合于读取格式参数。在上面的图中,按深度优先遍历,则4组格式参数分别对应到图中标的1~4序号。
需要注意的是,不同类型的对象会采用不同的打印方式。我们会按照如下的规则判断:
- 如果是标量类型,即基本类型如 int、double 等,不使用格式参数,直接打印;
- 如果对象支持使用 for 遍历其内部元素(比如 std::vector),则消耗掉一组格式参数,用该组参数定制这个对象的打印。同时,对于对象内部的元素类型,重复进行该判断;
- 如果对象不符合规则2,但可以使用 std::get<N> 模板来取得对象的每个成员,则这个对象和规则2一样消耗掉一组格式参数。同时,对于其每个成员的元素类型,重复该判断。
规则2和规则3的区别在于规则2的内部元素都是同一种类型,因此不管有多少个元素,它们都采用同一组格式参数,这也是上图中 std::vector 在树中只有一个子节点的原因。但是规则3的成员可能是不同类型,那么它就需要能对不同的类型指定不同的格式参数,比如上图中同处树第2层的 std::pair 和 std::vector 打印格式可以是不同的。
注意规则2的优先级比规则3高,因为有些类型(比如 std::array)同时满足可用 for 遍历及可用 std::get<N> 取成员。由于这种对象的内部元素都是同一类型,从实践上来说更适合于规则2的处理方式。
编译期数组下标递增
有了上面的设计,如果我们不考虑编译期校验的易用性需求,已经可以进入代码实现了。但是要让参数数组大小在编译期被检查,还需要一些其他的C++技巧。
C++标准库中很多函数都用迭代器来定义输入输出,比如 std::find 的输入是两个迭代器,函数内部会将起始迭代器向后移动,直到找到指定元素(或者达到末尾),然后返回移动之后的迭代器。
我们借鉴这个思路,可以让每次的递归打印函数接收一个起始数组下标作为输入,然后返回处理之后的“终点”下标作为输出,这样我们就可以计算出整个处理使用了多少组参数。
但是递归是发生在运行时的,怎么可能在编译期就做到下标递增?
——编译期的计算依靠的是类型的推导。而C++提供了一种类型用来表示整数:std::integral_constant。使用它,我们就可以让函数通过输入输出的类型来体现下标的值:
template<typename T, typename P, size_t N, size_t Pos>
auto Print(const T& value, integral_constant<size_t, Pos> pos, const P (¶)[N][3])
{
cout << boolalpha << value;
return pos;
}
上面这段代码是打印标量类型的函数。由于标量类型不消耗格式参数,因此它的输入和输出是同一个 std::integral_constant 类型——即下标不发生移动。
如果是容器类型,函数会类似这样:(为了使用方便,自定义了一个 Size 类型用于取代 std::integral_constant 那冗长的名字。IsIterable 用于判断是否是一个可遍历的容器,其完整定义在最后的完整代码中)
template<size_t N> using Size = integral_constant<size_t, N>;
template<typename T, typename P, size_t N,
enable_if_t<decltype(IsIterable(declval<T>()))::value, size_t> Pos>
auto Print(const T& v, Size<Pos>, const P (¶)[N][3])
{
cout << para[Pos][0];
auto itBegin = std::begin(v);
auto itEnd = std::end(v);
if (itBegin != itEnd) { Print(*itBegin++, Size<Pos + 1>(), para); }
for (; itBegin != itEnd; ++itBegin) {
cout << para[Pos][1];
Print(*itBegin, Size<Pos + 1>(), para);
}
cout << para[Pos][2];
return Size<decltype(Print(*itBegin, Size<Pos + 1>(), para))::value>();
}
这里可看到打印过程中如何插入格式参数,以及如何递归的调用内层元素的打印。代码中有两行出现了 Pos + 1,即通过构造新的 integral_constant 把下标进行了移动,内层的元素打印时会使用下一组格式参数。
对于 std::tuple 这种类型,打印的实现要更复杂一点,因为其在类型树中不止一个“子节点”,那么它一方面需要记住自己正在使用哪个下标的格式参数,一方面还要在打印子节点对象的时候,持续的移动下标(深度优先遍历)。因此它需要两个下标作为入参:Pos 和 RPos。
template<typename T, typename P, size_t N, size_t Pos, size_t RPos, size_t Left>
auto PrintMember(Size<Left>, const T& v, Size<Pos> pos, Size<RPos>, const P (¶)[N][3])
{
if constexpr (Left == 0) {
cout << para[Pos][2];
return Size<RPos>();
} else {
if constexpr (Left == tuple_size_v<T>) { cout << para[Pos][0]; }
else { cout << para[Pos][1]; }
auto rpos = Print(get<tuple_size_v<T> - Left>(v), Size<RPos>(), para);
return PrintMember(Size<Left - 1>(), v, pos, rpos, para);
}
}
template<typename T, typename P, size_t N, size_t Pos,
enable_if_t<!decltype(IsIterable(declval<T>()))::value, size_t> Arity = tuple_size<T>::value>
auto Print(const T& v, Size<Pos>, const P (¶)[N][3])
{
return PrintMember(Size<Arity>(), v, Size<Pos>(), Size<Pos + 1>(), para);
}
当这些函数都能如期工作时,我们就可以通过 static_assert,使用最终的返回值来判断是否消耗了预期的参数个数:
template<typename T, typename P, size_t N>
void Put(const T& value, const P (¶)[N][3])
{
auto pos = Print(value, Size<0>(), para);
static_assert(pos.value == N, "Incorrect amount of parameters");
}
最终的完整代码
下面给出该打印工具类 Writer 的最终实现,有几点额外的说明:
- 由于实际业务中对 std::string 的打印常常是固定的格式,因此 std::string 类型被单独处理了。但是你也完全可以把 std::string 当成一个普通的可遍历容器,给它分配一组格式参数(比如通过传参定制要不要用引号括起来)。
- Put 和 PutRaw 为对外接口,Print 为内部实现函数。Print 的多个重载通过 SFINAE 来决定对什么类型调用哪个版本。
- 因为C++没法直接让数组引用参数指定默认值为空,因此 Put 提供一个重载版本用于不带格式参数的调用。
- 为了支持链式调用,Put 和 PutRaw 都返回 Writer 对象本身。
- Writer 构造时需传入 ostream 对象的引用,这使得打印可以输出到 stdout,也可以输出到字符串或者文件。
template<size_t N> using Size = integral_constant<size_t, N>;
class Writer {
public:
explicit Writer(ostream& out) : os(out) {}
template<typename T, typename P, size_t N>
Writer& Put(const T& value, const P (¶)[N][3])
{
auto pos = Print(value, Size<0>(), para);
static_assert(pos.value == N, "Incorrect amount of parameters");
return *this;
}
template<typename T> Writer& Put(const T& value)
{
Print(value, Size<0>());
return *this;
}
template<class T> Writer& PutRaw(const T& value)
{
os << value;
return *this;
}
private:
template <typename T>
static auto IsIterable(const T& value) -> decltype((void)std::begin(declval<T>()), true_type());
static false_type IsIterable(...);
template<typename T, enable_if_t<is_arithmetic_v<T>, size_t> Pos>
auto Print(const T& value, Size<Pos> pos, ...)
{
os << boolalpha << value;
return pos;
}
template<size_t Pos>
auto Print(const string& value, Size<Pos> pos, ...)
{
os << '\"' << value << '\"';
return pos;
}
template<typename T, typename P, size_t N,
enable_if_t<decltype(IsIterable(declval<T>()))::value && !is_same_v<T, string>, size_t> Pos>
auto Print(const T& v, Size<Pos>, const P (¶)[N][3])
{
PutRaw(para[Pos][0]);
auto itBegin = std::begin(v);
auto itEnd = std::end(v);
if (itBegin != itEnd) { Print(*itBegin++, Size<Pos + 1>(), para); }
for (; itBegin != itEnd; ++itBegin) {
PutRaw(para[Pos][1]);
Print(*itBegin, Size<Pos + 1>(), para);
}
PutRaw(para[Pos][2]);
return Size<decltype(Print(*itBegin, Size<Pos + 1>(), para))::value>();
}
template<typename T, typename P, size_t N, size_t Pos, size_t RPos, size_t Left>
auto PrintMember(Size<Left>, const T& v, Size<Pos> pos, Size<RPos>, const P (¶)[N][3])
{
if constexpr (Left == 0) {
PutRaw(para[Pos][2]);
return Size<RPos>();
} else {
if constexpr (Left == tuple_size_v<T>) { PutRaw(para[Pos][0]); }
else { PutRaw(para[Pos][1]); }
auto rpos = Print(get<tuple_size_v<T> - Left>(v), Size<RPos>(), para);
return PrintMember(Size<Left - 1>(), v, pos, rpos, para);
}
}
template<typename T, typename P, size_t N, size_t Pos,
enable_if_t<!decltype(IsIterable(declval<T>()))::value, size_t> Arity = tuple_size<T>::value>
auto Print(const T& v, Size<Pos>, const P (¶)[N][3])
{
return PrintMember(Size<Arity>(), v, Size<Pos>(), Size<Pos + 1>(), para);
}
ostream& os;
};
应用举例
由于 std::unordered_map 是一个可遍历的容器套上 std::pair 类型的元素,因此它对应两组格式参数。通过指定合适的格式参数,把一个C++容器打印成 JSON 字符串毫无问题——你甚至可以通过灵活搭配换行和缩进来让输出更美观:
int main()
{
unordered_map<string, vector<int>> m{{"Mary", {1,2}}, {"Tom", {10,20}}, {"Harry", {99}}};
Writer writer(cout);
writer.Put(m, {{"{\n", ",\n", "\n}"}, {" ", ":", ""}, {"[", ",", "]"}});
return 0;
}
打印结果为:
{
"Tom":[10,20],
"Harry":[99],
"Mary":[1,2]
}
这套工具还能支持自定义类型的打印,只要自定义的类型支持用 for 遍历(实现了 begin 和 end 接口),或者支持 std::get<N> 取成员,那么就可以打印出内容。
如果想将对象输出成二进制数据而不是文本,可以自己定制一个 ostream 的子类来决定 << 操作符的行为。用这种办法甚至可以完成对象序列化——比如实现 Protobuf 协议。
既然它能做序列化,那么反序列化是否也能类似实现呢?——当然可以用类似的思路:递归函数调用,格式(控制)参数传递。只不过反序列化可能面临更多使用场景特定的问题,比如怎样处理输入错误,怎样提升性能等等。
- 点赞
- 收藏
- 关注作者
评论(0)