云计算百科
云计算领域专业知识百科平台

基于 C++11 标准实现 Python 风格的 print 函数,打印任意数量任意类型

一个适配标准库容器及其嵌套,支持任意类型、任意数量输入,并且极简新增自定义类型适配的 print

目录

    • 先看效果
    • Print: 起源
    • 强迫症:要做就做个各项目通用的 print 函数
    • 第一步:解决"任意数量参数"——可变参数模板
    • 第二步:类型分流——对标准库容器及数据结构的处理
    • 第三步:类型分流——未知的类型怎么办
    • 细节控的自我修养:那些"专门处理"的边角
    • 第四步:性能优化——写都写了,不如写快点
      • 1. 自定义缓冲流 AutoOStream
      • 2. 整数查表,绕开 ostream
      • 3. 浮点格式化用上了 Dragonbox
    • 妥协与遗憾
    • 结尾:大梦初醒

先看效果

我想要的 print,大概长这样(当然实现的效果也是这样):

std::vector<int> nums = {1, 2, 3};
int arr[3] = {4, 5, 6};
std::vector<std::map<int, std::string>> nested = {{{1, "a"}, {2, "b"}}, {{3, "c"}}};
std::tuple<int, float, std::string> tp = {7, 8.12345, "Hello"};
MyStruct s; // 假设有个没适配 operator<< 的自定义类型

glily::io::print("nums:", nums, "arr:", arr, "nested:", nested, "tp:", tp, "pi:", 3.14, "Unknown:", s);

输出:

nums: {1, 2, 3} arr: {4, 5, 6} nested: {{1:a, 2:b}, {3:c}} tp: {7, 8.12345, Hello} pi: 3.14 Unknown: <MyStruct:0x7ffe06db1967>

一次调用,基本类型、容器、嵌套容器、连没适配过的自定义类型都能打。这就是我想要的体验。

由于实现会考虑所有标准库可遍历容器、C数组、tuple、C字符串、函数指针及其相互嵌套的全覆盖,并且针对性优化了性能。这些能力的实现代码量不小,因此难以一次性展示,源码详见BokuMeidoCpp。

Print: 起源

我大学期间只用 Python 做深度学习,工作后因为实际部署需要,才开始学 C++。刚上手时最让我难受的不是指针、不是内存管理,而是——没有 print。

在 Python 里,print(anything) 就完事了,list、dict 嵌套多少层都能打。而到了 C++,我想看一眼 std::vector 的内容,得写循环:

for (const auto& x : vec)
std::cout << x << " ";
std::cout << std::endl;

写一次两次还行,可调试时经常遇到类似的情况,甚至vector 里套 map、map 里套 vector,每次都要写循环、想格式,写多了快要张口翻白眼。

当时我只有一个念头:想把我常用的打印操作整合成一个 print 函数,像 Python 那样,什么东西都能往里扔——这就是开头"先看效果"里的样子。

强迫症:要做就做个各项目通用的 print 函数

其实给特定项目写个打印函数很容易,但我有个习惯——我只想写好一次,然后所有项目都能用,并且行为一致。

所以给自己定了三个目标——这些目标前后花了两年才在功能层面实现:

  • 支持任意类型、任意数量的参数,像 Python 一样
  • 标准库容器直接打印,嵌套容器也能打
  • 实在打印不了的,输出 <ClassName: Address>,而不是编译报错
  • 第 3 点是很重要的。Python 的 print 就是这样——什么都能打,打不了也不崩。我希望 C++ 也能给我这种感觉:调试的时候,不用担心有某个类型导致编译错误。

    第一步:解决"任意数量参数"——可变参数模板

    C++ 要怎么实现输入不固定数量的参数?这是让 print 的输入方式开始像 Python 的地方,也是我接触模板编程的开始。

    任意数量的参数如何依次处理呢?我的方法是用一个初始化列表展开参数包,让每个参数进入专门的处理函数 osInput:

    template <class T, class... Args>
    void print(const T& arg, const Args&... args)
    {
    _priv::osInputFloatPrecision() = 1;
    _priv::AutoOStream aos(&std::cout);
    {
    std::lock_guard<std::mutex> lk(_priv::immutableGetPrintlock());
    _priv::osInput(aos, arg); // 第一个参数单独打印
    int tmp[] = {0, (aos.write(" ", 1), _priv::osInput(aos, args), 0)...}; // 展开剩余参数,参数间以空格分隔
    (void)tmp;
    }
    aos.flush();
    std::cout << std::endl;
    }

    int tmp[] = {0, (aos.write(" ", 1), _priv::osInput(aos, args), 0)…}; 这种写法,我第一次遇到时觉得很难理解。其实它的作用就是:把剩余参数(args…)展开,每个都调用一次 osInput 并写入分隔空格;第一个参数 arg 在展开之前单独处理。展开全部发生在编译期。

    AutoOStream 是我对 ostream(在这里也就是 cout)的一层封装,至于为什么要封装,涉及更后面的性能优化问题,先把它当成 cout 理解即可。

    第二步:类型分流——对标准库容器及数据结构的处理

    参数数量解决了,现在的问题是:怎么让标准库容器等分别打印?

    最初的想法很直接:把标准库里所有容器的 osInput 重载都写一遍,在函数体内挨个打印元素。

    但这又有个问题:嵌套容器怎么办?而且你不知道它嵌套了多深。遇到这种情况,我自然想到了递归——不直接用 cout 打印元素,而是递归调用 osInput,递归下去总会到达一个基础类型。

    以标准 STL 容器的重载为例:

    // 添加对 STL 标准容器的支持
    // 虽然用了双层模板 CTer,但单层也可以达成目的
    // 这里的 enable_if 是编译期判断:类型是否具备标准的 begin()/end()
    template <template <class U, class... Us> class CTer, class T, class... Ts,
    typename std::enable_if<_priv::StdBeginEndChecker<const CTer<T, Ts...>>::value, int>::type>
    inline void osInput(AutoOStream& aos, const CTer<T, Ts...>& cter)
    {
    aos.put('{');
    for (auto it = cter.begin(); it != cter.end();)
    {
    _priv::osInput(aos, *it);
    it++;
    if (it != cter.end())
    aos.write(", ", 2);
    }
    aos.put('}');
    }

    StdBeginEndChecker 是什么?暂时不用细讲,只要知道它可以判断一个类型是否具有标准的 begin() 和 end() 方法。

    现在,对于基本类型、标准库容器及其他数据结构,对应的打印逻辑如下:

    • 算术类型 → 走整数和浮点数各自的快速转字符串算法,字符类型直接打印
    • 指针 → 除了字符串指针外,直接输出指针地址(0x12345678)
    • 函数 → 输出函数类型名(如 void (*)(int))
    • 标准库容器等数据结构 → 输出 {1, 2, 3} 格式,递归处理元素
    • 数组 → 类似标准库容器,递归打印
    • map 系列 → 输出 {key:value, key:value} 格式,键值对分别递归打印

    第三步:类型分流——未知的类型怎么办

    就算我能重载所有标准库的数据类型,也不可能穷举所有第三方类型。难道遇到未知的类型,我就报错吗?这样的话,print 也只是勉强能用,功能并不完备。

    怎么处理未知类型呢?仔细想想,这些类型无非就两种:可以被 cout 打印的,和不能被 cout 打印的。

    于是我用一个模板来区分这两种情况:

    // 支持 operator<< 的类型 → 直接打印
    // (这一长串 enable_if 条件是在编译期做类型分流,细节不用深究)
    template <class T, typename std::enable_if<
    type::StdCoutEachChecker<const T>::value &&
    !(std::is_function<typename std::remove_pointer<const T>::type>::value
    || std::is_member_function_pointer<const T>::value) &&
    !std::is_pointer<T>::value && !std::is_arithmetic<T>::value, int>::type>
    inline void osInput(AutoOStream& aos, const T& arg)
    {
    aos << arg;
    }

    // 不支持 operator<< 的类型 → 打印 <ClassName: Address>
    template <class T, typename std::enable_if<!type::StdCoutEachChecker<const T>::value, int>::type>
    inline void osInput(AutoOStream& aos, const T& arg)
    {
    aos.put('<');
    const std::string& type_name = type::getTypeName<T>();
    aos.write(type_name.c_str(), type_name.size());
    aos.put(':');
    aos << std::showbase << std::hex << uintptr_t(&arg) << std::dec;
    aos.put('>');
    }

    同样的,StdCoutEachChecker 及后续复杂的模板判断细节暂时不用深究,只要知道这些模板区分了类型 T 是否支持 cout << 操作。

    现在,对于其他类型就只有这两种情况了:

    • 支持 operator<< 的类型 → 直接打印
    • 都不支持的 → 打印 <ClassName: Address>

    顺理成章地,想要适配其他类型:无需看懂 osInput 的实现,无需自行添加模板特化,无需学习任何新概念——只需要为你想要打印的类型重载 operator<< 即可。这也是我选择 cout 路径的原因:这套设计适配新类型极为方便,能降低心智成本。

    顺带一提,这个适配会在我的 format 和 toStr 函数中同步生效——它们的底层与 print 共用 osInput。

    细节控的自我修养:那些"专门处理"的边角

    写完主体后,我花了很多时间处理边角情况。这些细节没啥技术含量,但决定了一个工具好不好用:

    • char 系列:char、signed char、unsigned char 本质是整数,但打印时应该当字符,不能打出数字
    • char* 字符串指针:打印字符串本身而不是地址;volatile char* 也要逐字符打出来
    • 其他指针:打印十六进制地址,带 0x 前缀
    • C 数组:char 数组当字符串打,其他数组打 {…}
    • tuple:需要编译期索引递归取值,用 std::integral_constant<bool> 在编译期判断递归终止
    • 函数指针 / 成员函数:没法打印"值",那就打印函数类型名——总比报错强。

    第四步:性能优化——写都写了,不如写快点

    功能齐全之后,强迫症又犯了:性能。

    虽然 print 主要是调试用的,但性能能省则省,更何况 format 等函数对性能也有要求。于是做了三件事:

    1. 自定义缓冲流 AutoOStream

    直接往 std::cout 打,每次都是一次虚函数调用。我包了一层 512 字节的栈缓冲,小片段先攒着,填满了才一次性写入底层流:

    class AutoOStream
    {
    public:
    void write(const char* s, size_t len)
    {
    if (pos_ + len > sizeof(buf_)) // 缓冲放不下
    {
    flush();
    if (len > sizeof(buf_) / 2) // 长字符串直接写,避免拷贝
    {
    os_->rdbuf()->sputn(s, len);
    return;
    }
    }
    memcpy(buf_ + pos_, s, len);
    pos_ += len;
    }
    private:
    char buf_[512]; // 栈上缓冲
    size_t pos_ = 0;
    std::ostream* os_;
    };

    2. 整数查表,绕开 ostream

    整数不经过 ostream,自己写转换——一次处理两位数字,查"00"~"99"的静态表:

    while (uval >= 100)
    {
    size_t pair = static_cast<size_t>(uval % 100);
    uval = static_cast<UT>(uval / 100);
    const char* d = digits2(pair); // 两位数字查表
    buf[pos] = d[1];
    buf[pos] = d[0];
    }

    3. 浮点格式化用上了 Dragonbox

    浮点格式化是个深坑。标准库的 to_chars 是 C++17 才有的,C++11 下只能用 snprintf,慢。后来我找到了 Dragonbox——目前浮点转字符串最快的算法之一,把它嵌了进来。

    满足 IEEE 754 的浮点走 Dragonbox;不满足的(比如某些平台上的 long double)回退到 snprintf;输出结果超出缓冲区的,再走 ostream 的 operator<<——总之不遗漏特殊情况。

    妥协与遗憾

    有几个地方是我没处理(或刻意没处理)的,写出来给大家避坑:

  • 宽字符 / 宽字符串:直接按整型打印。cout 本身不支持宽字符,我也没有打算造一套自己的宽字符体系。
  • print 不可重入:自定义类型的 operator<< 里不能调用 print 本身,会出问题。
  • 冗余的 CTer 双层模板:早期为了匹配 STL 容器写的 template <template <class U, class… Us> class CTer, …>,后来发现单层模板也能做到。但因为它是编译期逻辑、没有运行时开销,就一直没简化——既然没问题还看着很厉害,就不动了。
  • 结尾:大梦初醒

    费了千辛万苦,随着对 C++ 的理解越来越深,我实现的 print 函数也越来越接近 Python 的体验——那我可一定要狠狠使用它!

    然而,等我写完这个 print,我发现已经完全习惯了 C++,反而不太需要 print 函数了。(悲)

    但这个结果也不赖:至少我技术是学会了,实现 print 时积累的 osInput 类型分流体系,后来成了我实现 format、toStr 的核心。现在我的库里这些都能用:

    auto s1 = glily::str::toStr(std::vector<int>{1, 2, 3}); // "{1, -2, 3}"
    auto s2 = glily::str::toStr(std::map<int, std::string>{{1, "a"}, {2, "b"}});
    // "{1:a, 2:b}"
    auto s3 = glily::str::format("{} × {} = {}", 6, 7, 6 * 7); // "6 × 7 = 42"

    而且浮点格式化实测与 {fmt} 在同一量级、部分场景还略快一点(Dragonbox 的功劳)。

    这段经历让我明白了一件事:"想偷懒"可能是最好的学习动力。 如果不是为了不写循环,我可能到现在还停留在"能实现项目需求就行"的阶段,不会去碰模板编程,也不会理解 SFINAE、类型约束这些东西。

    如果你也刚学 C++,或者也想要一个能打任何东西的 print——完整实现就是我开头提到的那个轻量纯头文件库:BokuMeidoCpp,零依赖,#include "bokumeido/core.hpp" 就能用,format、log、线程池都在里面。

    后续文章:format/toStr 如何与 print 共用一套类型分流、log 的同步/异步设计取舍、以及如何让模板错误出现在调用处——感兴趣可以关注我,我们下篇见。

    如果这篇文章对你有帮助,欢迎点赞、收藏、关注、star~你的支持是我继续分享的动力!


    本篇文章基于我的真实开发经历写成,实现细节见我的库内注释与单元测试。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 基于 C++11 标准实现 Python 风格的 print 函数,打印任意数量任意类型
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!