那会儿还没有 C++11。
我们写的东西更像 “C with classes”。
也就是在 C 上面糊一层 class。
项目不大。
但麻烦一点都不少。
尤其是日志。
你总想写一个函数。
什么都能塞进去。
参数多少都行。
类型也别管。
反正先把现场打出来。
然后有一天。
线上啪一下。
报警把你叫醒。
你翻到最后一条日志。
它只写了一半。
再往后。
程序就没了。
你顺手打开 core。
看到一行熟悉到想骂人的代码。
#include <cstdio>
std::printf("%d\n", "hello");
它能不能当场炸。
看平台。
看实现。
也看你今天命硬不硬。
更烦的是。
它不是每次都炸。
所以你很难靠测试把它“测出来”。
当年第一招:宏,先把接口凑出来
最常见的写法是写个宏。
把 ... 交给预处理器。
接口是凑出来了。
预处理器你可以先当成。
“编译前做一遍纯文本替换”。
它不理解类型。
它只会复制、粘贴、拼字符串。
#include <cstdio>
#define LOG(fmt, ...) \
std::fprintf(stderr, "[LOG] " fmt "\n", __VA_ARGS__)
你会觉得很爽。 调用也很顺手。
LOG("x=%d", 42);
但它有股“纸糊的安全感”。 宏不是函数。 它不会重载,也很难调试。
更要命的是。 它对类型这件事基本是装聋作哑。 你把类型传错了,编译器经常只能当没看见。
当年还有一条路:重载,写到手软
也有人会说。
别搞 ...。
写重载。
这至少是类型安全的。
#include <cstdio>
void log(const char* msg) {
std::puts(msg);
}
void log(const char* msg, int x) {
std::printf("%s %d\n", msg, x);
}
void log(const char* msg, int x, int y) {
std::printf("%s %d %d\n", msg, x, y);
}
你会很快发现。
这根本不是“写三个函数”的问题。
重载你可以先理解成。
“同一个名字,写好几份版本”。
你得决定你到底要支持多少个参数。
你得为每种组合写一份。
一旦需求变了。
你就得改很多地方。
那几年很多库都是这么扛的。
写到第 10 个重载的时候。
你会开始怀疑人生。
更现实的历史:Boost 时代,大家用宏生成“第 1 到第 N 个版本”
如果你听过 Boost。
它更像一个大型试验场。
标准库未来要不要收的东西。
Boost 往往先做一版。
但那会儿没有可变参数模板。
tuple、bind、函数包装这类东西怎么办。
答案很朴素。
用预处理器把同一段代码复制 N 份。
N 通常是 10、20、50。
你在用的是“类型安全的接口”。
但作者写出来的代码。
常常是一堆宏把同一份模板糊成很多份。
你可以想象成这种味道。
#define GEN_LOG_1(T1) void log(T1 a1) {}
#define GEN_LOG_2(T1, T2) void log(T1 a1, T2 a2) {}
GEN_LOG_1(int)
GEN_LOG_2(int, int)
现实里当然更复杂。
Boost.Preprocessor 会帮你“循环”。
把第 1 份一直生成到第 N 份。
但本质就是。
在没有语言支持的时候。
大家只能靠宏把代码打印出来。
你看到的不是算法。
是“代码生成”。
你写的是库。
但调试体验像在跟宏搏斗。
当年第二招:va_list,更像函数了,但坑更深
后来大家会说。 别用宏。 写个真正的函数。
像 printf 那样。
#include <cstdarg>
#include <cstdio>
void logf(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
std::vfprintf(stderr, fmt, ap);
std::fputc('\n', stderr);
va_end(ap);
}
这里的 ... 是 C 的可变参数。
va_list 你可以理解成。
“我把后面的参数都装进一个袋子里”。
但这个袋子很怪。 它不带类型标签。 你取的时候只能靠格式串去猜。
于是你又回到了老问题。
logf("%d", "hello");
编译器不一定拦你。 运行时才可能翻车。
原因其实很土。
printf 看到 %d。
就会按“整数的方式”去读下一份参数。
但你塞进去的是一个字符串指针。
它读出来的就只能是乱七八糟。
还有一个细节更阴。
在 C 的可变参数里。
有些类型会被“悄悄升级”。
比如 char、short 会变成 int。
float 会变成 double。
所以你用 va_arg 取的时候。
还得按“升级后的类型”去取。
这也是一堆人第一次用 va_list 就踩坑的原因。
如果你不想用格式串。
你通常会自己传一个“参数个数”。
#include <cstdarg>
int sum(int n, ...) {
va_list ap;
va_start(ap, n);
int s = 0;
for (int i = 0; i < n; ++i) {
s += va_arg(ap, int);
}
va_end(ap);
return s;
}
你看。
它确实能跑。
但它也确实在跟类型系统“打游击”。
而且翻车方式很随机。
你今天能复现。
明天可能就没了。
这就是所谓的未定义行为。 意思差不多是“标准不保证任何事”。
老程序员还有一句损人的翻译。 “未定义行为”的意思是。 你可能召唤鼻妖把硬盘吃掉。
横向对比:宏、va_list、重载、可变参数模板
宏很好用。
但它不在语言里。
它更像“把源码剪碎再粘回去”。
所以它天然不讲类型。
也天然不好调试。
va_list 更像一个函数。
但它把类型信息丢掉了。
你得靠格式串和 va_arg 自己把类型“猜回来”。
重载是类型安全的。
但它会让接口长出一堆分身。
维护成本会慢慢超过功能本身。
可变参数模板是折中后的答案。
它保留“任意个参数”。
但不丢类型。
C++11 的思路:把“任意参数”搬进类型系统
可变参数模板做的事很朴素。 它不让你在运行时猜。 它让你在编译期就把类型写清楚。
一句话。
你不再是拿着一个不贴标签的袋子摸黑掏。
你是拿着一张清单。
清单上写着每一件东西的名字。
参数包:一个“带类型的袋子”
先认一个新词。 参数包。
template <class... Ts>
struct Pack;
Ts... 就是一袋类型。
你不知道里面有多少个。
但你知道每一个是什么。
这点很关键。
因为 C 的 ... 不告诉你里面装了啥。
你就只能靠约定和运气。
参数包的好处是。
它不需要你猜。
它从一开始就带着类型走。
再认一个动作:展开
你会在代码里看到 args...。
这不是“省略号很酷”。
它是一个动作。
叫展开。
void g(int a, int b, int c) {}
template <class... Args>
void f(Args... args) {
g(args...);
}
如果你传进去的是 (1, 2, 3)。
那 g(args...) 这行。
在你心里就可以当成 g(1, 2, 3)。
它不是运行时循环。
它更像编译器帮你“把同一招拆成三招”。
场景:我就想写个“不会把线上炸掉”的小日志
我们先不追求花哨。 先实现一个最小版本。
传什么我都打印。 打印完换行。
#include <iostream>
void print() {
std::cout << '\n';
}
template <class T, class... Ts>
void print(const T& x, const Ts&... xs) {
std::cout << x;
if (sizeof...(xs) != 0) std::cout << ' ';
print(xs...);
}
sizeof...(xs) 的意思是。
“这个参数包里还剩几个”。
先看那个空的 print()。
它不是“神秘仪式”。
它只是递归的停止点。
参数吃完了。 就落到这里。
再看 xs...。
它的意思是。
“把参数包里的东西一个个展开”。
你传进去 3 个参数。
编译器就把这段函数“复制”出 3 层调用链。
这就是它的一个核心味道。
它让“第 1 个版本、第 2 个版本……第 N 个版本”。
变成编译器的活。
你只写一份。
再往前一步:完美转发,让库作者少掉几根头发
你刚学 C 的时候。 可能觉得拷贝无所谓。 但库作者会很在意。
所以常见的写法是。
用转发引用接住参数。
再用 std::forward 原样传下去。
#include <utility>
template <class... Args>
void log(Args&&... args) {
print(std::forward<Args>(args)...);
}
Args&& 这里别急着背名词。
你先记住一个感觉。
它能把左值和右值都接住。
左值你可以先当成“有名字的变量”。
右值你可以先当成“临时的东西”。
它想做的是。 “别人给我什么形态,我就尽量原样交出去”。
std::forward 你可以先当成。
“我把它原封不动递下去”。
场景二:小工厂函数,写一次,什么构造都能转过去
你写一个小项目。
你想统一创建对象。
又不想把构造函数的参数抄一遍。
#include <utility>
template <class T, class... Args>
T make(Args&&... args) {
return T(std::forward<Args>(args)...);
}
这段代码的直觉是。
make 不知道 T 需要几个参数。
但它能把你给的东西原样转进去。
于是你可以这样用。
struct Point {
Point(int x, int y) : x(x), y(y) {}
int x;
int y;
};
auto p = make<Point>(1, 2);
你没有为 Point(int,int) 写一个专门的 makePoint。
也没有写第 1 个版本、第 2 个版本。
你只写了一份 make。
场景三:make_shared 的直觉:把构造参数交给 T(...)
你可能也见过这种接口。
(顺带一提:make_unique 是 C++14 才补上的。)
#include <memory>
struct Point {
Point(int x, int y) : x(x), y(y) {}
int x;
int y;
};
auto p = std::make_shared<Point>(1, 2);
它没有要求你先写 Point{1,2}。
它让你把参数直接交给 Point 的构造函数。
这背后也是同一套套路。
用参数包接住。
再展开 + 转发。
场景四:emplace 的直觉,其实就是“把构造参数直接塞进去”
你可能见过 push_back。
它需要一个“已经构造好的对象”。
#include <string>
#include <vector>
std::vector<std::string> v;
v.push_back(std::string(3, 'x'));
这里会先构造一个临时的 std::string。
再把它塞进 vector。
emplace_back 想做的事更直接。
它收的不是对象。
它收的是构造参数。
v.emplace_back(3, 'x');
这背后最关键的接口就是。
它得能接住“任意个参数”。
还得把这些参数转交给 std::string 的构造函数。
没有可变参数模板。
这个接口写不优雅。
场景五:std::thread 为什么能把参数一把塞进去
你可能见过这种写法。
#include <iostream>
#include <thread>
void worker(int id, const char* name) {
std::cout << id << ' ' << name << '\n';
}
int main() {
std::thread t(worker, 7, "db");
t.join();
}
你传给 std::thread 的不是一个字符串。
你传的是“构造 worker 调用所需的所有参数”。
线程库要做的事是。
把它们存起来。
等线程真正开始跑的时候再原样调用。
这类接口如果不用可变参数模板。
就只能继续回到“写 10 个重载”或者“用宏生成”。
什么时候该用它
如果你的接口天然就需要“任意个参数”。
比如日志。
比如工厂。
比如 emplace。
那可变参数模板通常是最干净的解。
如果你只是固定的 2、3 个参数。
那就老老实实写普通函数。
重载也行。
别为了酷把模板拉进来。
小洞见
可变参数模板最像“工程救火”的地方。 不是让你写更炫的模板。
而是让你把一堆本来只能靠宏、靠约定、靠人不犯错的接口。 变成编译器能帮你盯着的普通函数。
一句话。
把“靠记性”变成“靠类型”。