一个适配标准库容器及其嵌套,支持任意类型、任意数量输入,并且极简新增自定义类型适配的 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 函数
其实给特定项目写个打印函数很容易,但我有个习惯——我只想写好一次,然后所有项目都能用,并且行为一致。
所以给自己定了三个目标——这些目标前后花了两年才在功能层面实现:
第 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<<——总之不遗漏特殊情况。
妥协与遗憾
有几个地方是我没处理(或刻意没处理)的,写出来给大家避坑:
结尾:大梦初醒
费了千辛万苦,随着对 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~你的支持是我继续分享的动力!
本篇文章基于我的真实开发经历写成,实现细节见我的库内注释与单元测试。
网硕互联帮助中心




评论前必须登录!
注册