一个适配标准库容器及其嵌套,支持任意类型、任意数量输入,并且极简新增自定义类型适配的 print
目录
- 先看效果
- Print: 起源
- 强迫症:要做就做个各项目通用的 print 函数
- 第一步:解决"任意数量参数"——可变参数模板
- 第二步:类型分流——对标准库容器及数据结构的处理
- 第三步:类型分流——未知的类型怎么办
- 细节控的自我修养:那些"专门处理"的边角
- 第四步:性能优化——写都写了,不如写快点
- 1. 自定义缓冲流 AutoOStream
- 2. 整数查表,绕开 ostream
- 3. 浮点格式化用上了 Dragonbox
- 妥协与遗憾
- 结尾:大梦初醒
先看效果
我想要的 print,大概长这样(当然实现的效果也是这样):
std::vector<int>nums={1,2,3};intarr[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(constauto&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<classT,class...Args>voidprint(constT&arg,constArgs&...args){_priv::osInputFloatPrecision()=-1;_priv::StringOutBuf&sbuf=_priv::getPrintSbuf();// 线程本地的字符串缓冲sbuf.clear();std::ostream&os=_priv::getPrintOStream();os.clear();// 复位状态位,避免异常残留的 badbit 导致后续输出静默丢失_priv::AutoOStreamaos(&os);_priv::osInput(aos,arg);// 第一个参数单独打印inttmp[]={0,(aos.put(' '),_priv::osInput(aos,args),0)...};// 展开剩余参数,参数间以空格分隔(void)tmp;aos.put('\n');aos.flush();conststd::string&s=sbuf.str();std::cout.write(s.data(),static_cast<std::streamsize>(s.size()));// 整条输出一次性写入 coutstd::cout.flush();}
int tmp[] = {0, (aos.put(' '), _priv::osInput(aos, args), 0)...};这种写法,我第一次遇到时觉得很难理解。其实它的作用就是:把剩余参数(args…)展开,每个都调用一次osInput并写入分隔空格;第一个参数arg在展开之前单独处理。展开全部发生在编译期。
AutoOStream是我对ostream的一层封装(1.0.1 里它接的是线程本地的 ostream,其底层是StringOutBuf字符串缓冲),至于为什么要封装,涉及更后面的性能优化问题,先把它当成cout理解即可。另外注意,这个实现没有锁。1.0.0 版本用一把全局 mutex 保护输出;1.0.1 改用
thread_local缓冲:每个线程先把整条输出拼进自己的字符串,最后std::cout.write()一次性写入。这样既消除了锁竞争,又不会和其他线程的cout输出交错。
第二步:类型分流——对标准库容器及数据结构的处理
参数数量解决了,现在的问题是:怎么让标准库容器等分别打印?
最初的想法很直接:把标准库里所有容器的osInput重载都写一遍,在函数体内挨个打印元素。
但这又有个问题:嵌套容器怎么办?而且你不知道它嵌套了多深。遇到这种情况,我自然想到了递归——不直接用cout打印元素,而是递归调用osInput,递归下去总会到达一个基础类型。
以标准 STL 容器的重载为例:
// 添加对 STL 标准容器的支持// 虽然用了双层模板 CTer,但单层也可以达成目的// 这里的 enable_if 是编译期判断:类型是否具备标准的 begin()/end()template<template<classU,class...Us>classCTer,classT,class...Ts,typenamestd::enable_if<_priv::StdBeginEndChecker<constCTer<T,Ts...>>::value,int>::type>inlinevoidosInput(AutoOStream&aos,constCTer<T,Ts...>&cter){aos.put('{');for(autoit=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<classT,typenamestd::enable_if<type::StdCoutEachChecker<constT>::value&&!_priv::IsFuncOrMemFuncChecker<T>::value&&!std::is_pointer<T>::value&&!std::is_arithmetic<T>::value,int>::type>inlinevoidosInput(AutoOStream&aos,constT&arg){aos<<arg;}// 不支持 operator<< 的类型 → 打印 <ClassName: Address>template<classT,typenamestd::enable_if<!type::StdCoutEachChecker<constT>::value,int>::type>inlinevoidosInput(AutoOStream&aos,constT&arg){aos.put('<');conststd::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 字节的栈缓冲,小片段先攒着,填满了才一次性写入底层流:
classAutoOStream{public:voidwrite(constchar*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:charbuf_[512];// 栈上缓冲size_t pos_=0;std::ostream*os_;};1.0.1 里
AutoOStream接的是线程本地的ostream(底层StringOutBuf),拼完一整行后一次性写入cout——顺带解决了多线程输出交错的问题。
2. 整数查表,绕开 ostream
整数不经过ostream,自己写转换——一次处理两位数字,查"00"~"99"的静态表:
while(uval>=100){size_t pair=static_cast<size_t>(uval%100);uval=static_cast<UT>(uval/100);constchar*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的核心。现在我的库里这些都能用:
autos1=glily::str::toStr(std::vector<int>{1,-2,3});// "{1, -2, 3}"autos2=glily::str::toStr(std::map<int,std::string>{{1,"a"},{2,"b"}});// "{1:a, 2:b}"autos3=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~你的支持是我继续分享的动力!