如何用一个接口定制所有C++容器的打印

举报
飞得乐 发表于 2023/03/08 16:27:26 2023/03/08
【摘要】 C++语言中,几乎所有的基本类型都支持用 ostream 类的 << 操作符进行打印,比如常见的:int x = 1;cout << x;char c = 'p';cout << c;double y = 3.1415926;cout << y;然而,除了 std::basic_string 以外,几乎所有的STL容器都不支持用 ostream 打印。如果写下面的代码,会报编译错误:vect...

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>>>

这个容器如果用树的形式表示嵌套关系,是下图的样子:

1.png

在这个数据结构中,总共有4个容器需要能定制打印格式,那么如何传入和解析这4套格式参数?

问题到这儿似乎开始有点挑战了。不过我们还没提到,用户可能还有其他易用性需求:

  1. 定制格式的接口尽可能简单,一眼就能看懂,比如直接使用C++的大括号列表就能表达:{a, b, c},而不要额外调用其他的函数或者对象类型。
  2. 为了让用户正确传入格式参数,希望在编译期对格式参数的个数进行检查,确保用户传入的参数个数和数据结构需要的参数个数完全一致。(想想 printf 函数不检查参数个数害死过多少人)

好吧,下面让我们一起看看,使用哪些技术的组合可以实现这些需求。

本文的所有代码均基于C++17语言标准。

格式参数的设计

首先分析一下某一个容器的打印格式——它基本上可以看做是3个不同位置的字符串组合:

2.png

在上图的例子中,起始分隔结束分别对应"["、","(根据喜好可能逗号后有空格或者没有空格)、"]"3个字符串,那么这3个字符串组合在一起可以定义为一组格式参数。数据结构嵌套多层容器时,根据嵌套的结构会需要多组格式参数,每一组格式参数都包含3个字符串。

从使用者角度,采用下面的方式传入大括号列表是最简单直观的:

Put(x, {{"[", "\n", "]"}, {"[", ",", "]"}});

相应的接口定义就是二维字符串数组:

template<typename T, size_t N>
void Put(const T& value, const char* const (&para)[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 (&para)[N][3]) {}

注意由于C++中大括号列表的特殊性,接口中的 para 必须是数组的引用类型。如果试图使用带有模板参数的 std::tuple、std::array、std::initializer_list 类型全部都会产生编译错误。

将格式参数定义为模板类型可带来很多好处,比如用户可以自定义一个类型,在打印的时候判断操作系统环境来决定是输出"\r\n"还是"\n"。

对类型树的深度优先遍历

当用户面对上图中那个复杂的嵌套数据结构时,他如何了解自己传入的每组格式参数对应哪一个容器?为此,我们需要约定格式参数与树中节点(容器)的对应关系。

要将树中的节点和线性的序号(参数数组的下标)对应起来,可以按广度优先(BFS)或者深度优先(DFS)方式对节点进行遍历。由于打印的实现采用了递归的方式,因此深度优先的约定更加适合于读取格式参数。在上面的图中,按深度优先遍历,则4组格式参数分别对应到图中标的1~4序号。

需要注意的是,不同类型的对象会采用不同的打印方式。我们会按照如下的规则判断:

  1. 如果是标量类型,即基本类型如 int、double 等,不使用格式参数,直接打印;
  2. 如果对象支持使用 for 遍历其内部元素(比如 std::vector),则消耗掉一组格式参数,用该组参数定制这个对象的打印。同时,对于对象内部的元素类型,重复进行该判断;
  3. 如果对象不符合规则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 (&para)[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 (&para)[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 (&para)[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 (&para)[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 (&para)[N][3])
{
    auto pos = Print(value, Size<0>(), para);
    static_assert(pos.value == N, "Incorrect amount of parameters");
}

最终的完整代码

下面给出该打印工具类 Writer 的最终实现,有几点额外的说明:

  1. 由于实际业务中对 std::string 的打印常常是固定的格式,因此 std::string 类型被单独处理了。但是你也完全可以把 std::string 当成一个普通的可遍历容器,给它分配一组格式参数(比如通过传参定制要不要用引号括起来)。
  2. Put 和 PutRaw 为对外接口,Print 为内部实现函数。Print 的多个重载通过 SFINAE 来决定对什么类型调用哪个版本。
  3. 因为C++没法直接让数组引用参数指定默认值为空,因此 Put 提供一个重载版本用于不带格式参数的调用。
  4. 为了支持链式调用,Put 和 PutRaw 都返回 Writer 对象本身。
  5. 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 (&para)[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 (&para)[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 (&para)[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 (&para)[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 协议。

既然它能做序列化,那么反序列化是否也能类似实现呢?——当然可以用类似的思路:递归函数调用,格式(控制)参数传递。只不过反序列化可能面临更多使用场景特定的问题,比如怎样处理输入错误,怎样提升性能等等。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。