我第一次嫌 C++ 啰嗦。
不是因为语法多。
是因为我在写一段代码的时候。
经常要把“同一个类型信息”写两遍。
你刚学 C++ 的时候。
大概率会卡在这里。
因为你明明看得见右边的构造参数。
却还要在左边再抄一次模板参数。
先把几个词翻译成你能用的句子
先别背全名。
我们把它翻译成人话。
类模板。
就是“带类型参数的结构体/类”。
template <class T>
struct Box {
T value;
};
Box<int> 是一个具体类型。
Box<T> 只是一个模板。
模板参数推导。
就是“你不写 <...>,编译器从你给的参数里猜出来”。
你在函数模板里其实已经见过它了。
template <class T>
T id(T x) { return x; }
auto a = id(42); // T 推导成 int
CTAD。
就是把这件事搬到类模板上。
你构造一个对象的时候。
不写 <...>。
编译器从“你调用了哪个构造函数”去推导模板参数。
没有 CTAD 的世界:模板参数得手写
最典型的例子是 std::pair。
#include <utility>
std::pair<int, int> p{1, 2};
你可能会问。
右边 {1, 2} 都是 int 了。
为啥左边还要写 <int, int>?
因为在 C++17 之前。
类模板不会像函数模板那样自动推导。
你必须把模板参数写明白。
旧办法 1:用 make_xxx 让“函数模板”替你推导
标准库很早就知道这是个痛点。
所以它给了很多 make_xxx。
比如 std::make_pair。
#include <utility>
auto p = std::make_pair(1, 2);
这能工作。
因为 make_pair 是函数模板。
函数模板天生就支持从实参推导 T。
但它也有代价。
你得记住“有没有对应的 make”。
你自己写的模板类型,库也帮不上。
你还得自己再造一套 make_my_xxx。
旧办法 2:手写工厂函数(你会写到怀疑人生)
比如你自己有一个盒子类型。
template <class T>
struct Box {
Box(T v) : value(v) {}
T value;
};
没有 CTAD 的年代。
你要么写。
Box<int> b(42);
要么你去补一个工厂函数。
template <class T>
Box<T> make_box(T v) { return Box<T>(v); }
auto b = make_box(42);
这看起来还行。
但只要你有三四个模板类型。
你就知道“make 的规模化”有多烦。
C++17:CTAD 让你“构造时推导”
CTAD 的感觉很像 C++11 的 auto。
把重复劳动交出去。
但类型信息仍然在编译期。
最小例子是这样。
#include <utility>
std::pair p{1, 2};
这里 p 的类型是 std::pair<int, int>。
你没写 <int, int>。
但推导发生了。
CTAD 到底是怎么推的:把“构造函数”当成推导入口
如果你只学过 C。
你可以把它理解成。
编译器在背后假装有一堆“推导用的函数签名”。
你一构造对象。
它就用你传进去的参数去匹配这些签名。
最小例子我们用自己的 Box。
template <class T>
struct Box {
Box(T) {}
};
Box b(42);
这里会推导成 Box<int>。
因为构造函数形参里出现了 T。
T 可以被 42 的类型填进去。
你可以把这句话记住。
如果构造参数里看不见 T。
那 CTAD 就无从下手。
第一个你会问的问题:那 auto 和 CTAD 是一回事吗?
它们解决的是同一类痛。
但入口不一样。
auto 是“从右值推导变量类型”。
CTAD 是“从构造参数推导类模板参数”。
你可以用最小例子感受一下差别。
Box<int> b1(42);
auto b2 = Box<int>(42);
Box b3(42);
b2 省掉的是变量类型。
b3 省掉的是模板参数。
这两者经常搭配用。
但不是同一个机制。
另一个常见例子:锁
这个例子很适合新手。
因为它不会涉及太多模板细节。
旧写法。
#include <mutex>
std::mutex m;
std::lock_guard<std::mutex> lk(m);
CTAD 之后。
std::lock_guard lk(m);
你一眼就只看到“我在加锁”。
不会被 std::mutex 那串尖括号打断。
这就是 CTAD 最实用的价值。
把噪音降下来。
把注意力留给逻辑。
横向对比:C 里我们怎么“泛型”,代价是什么
如果你只写过 C。
你可能会觉得。
“少写模板参数”这种事,有必要当成一门语言特性吗?
把它放到 C 的世界里,你就能看见差别。
旧办法 A:宏,看起来泛型,实际很容易踩副作用
宏的“泛型”,说白了就是文本替换。
它不懂类型。
更不懂你传进去的表达式会不会被求值两次。
比如这样。
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 0;
int x = MAX(i++, 10);
问题在这。
i++ 可能会执行两次。
你得到的不是“最大值”。
你得到的是一个隐藏的副作用。
换成 C++,我更愿意写成函数模板。
至少它只求值一次,还能做类型检查。
旧办法 B:void*,把类型藏起来,错误更晚爆
void* 的思路是:先把类型擦掉,后面再强转回来。
听起来挺灵活。
但出错也更隐蔽。
比如这样。
#include <stdio.h>
void print_int(const void* p) {
printf("%d\n", *(const int*)p);
}
double d = 3.14;
print_int(&d);
这段代码在很多平台上能编译。
但它在读 double 的内存,却按 int 去解释。
行为未定义。
也就是说,错误从编译期拖到了运行时。
回到 CTAD。
C++ 模板本来就是想把“泛型”做成编译期、强类型。
CTAD 则是更进一步。
在不牺牲强类型的前提下,把那串重复的尖括号省掉。
发展历史:推导先在“函数模板”里活了很久
你在 make_pair 里见到的推导。
不是 C++17 才发明的。
函数模板早就能从实参推导 T。
标准库用 make_xxx 兜了很多年。
CTAD 做的事是把这条路变成语言规则。
让“类模板的构造”也能走同一套推导逻辑。
来源借鉴:Java 的 diamond operator(以及 C++ 为啥没法照抄)
如果你学过 Java。
你可能见过 diamond operator。
import java.util.*;
List<Integer> xs = new ArrayList<Integer>();
List<Integer> ys = new ArrayList<>();
<> 这件事,Java 叫 diamond operator。
它是从左边的目标类型,把右边 new ArrayList<> 里的参数补齐。
在 Java 里这能做得比较彻底。
因为 Java 的泛型是“类型擦除”。
它不需要像 C++ 模板那样把所有类型信息都搬进编译期的实例化里。
这和 CTAD 很像:都在省掉一段重复的类型。
但 C++ 没法完全照抄。
CTAD 主要是从构造参数推导模板参数。
而不是从“赋值目标类型”反推。
所以你经常看到它配合 auto。
auto v = std::vector{1, 2, 3};
如果 C++ 也硬搞“从目标类型反推”。
老代码兼容性会更难保。
错误信息和重载解析也会更复杂。
概念拆解:CTAD 的结果取决于你传了什么
CTAD 不会猜你的意图。
它只会按你给的实参类型去推。
比如这样。
#include <utility>
std::pair p{1, 2.0};
这里推导结果是 std::pair<int, double>。
因为 1 是 int,2.0 是 double。
如果你真正想要的是 pair<double, double>。
那就写明白。
std::pair<double, double> p{1, 2.0};
坑 1:{} 触发 initializer_list 优先级,语义可能直接换轨
这个坑跟 CTAD 有关,但也不完全是它的锅。
关键是:() 和 {} 在 C++ 里不是一回事。
#include <vector>
std::vector v1(10, 1);
std::vector v2{10, 1};
v1 是 10 个 1。
v2 更像是两个元素:10 和 1。
你想要“数量 + 初值”。
就用 ()。
想要“给我一串元素”。
就用 {}。
坑 2:字符串字面量常常把你带到 const char*
CTAD 只看参数类型。
字符串字面量本身不是 std::string。
template <class T>
struct Box { Box(T) {} };
Box a(42);
Box b("hi");
a 是 Box<int>。
b 则是 Box<const char*>。
如果你想要的是 std::string,就写出来。
#include <string>
Box<std::string> b("hi");
deduction guide:当你想把“默认语义”教给编译器
如果你不想每次都写 Box<std::string>。
你可以把这条规则教给编译器。
这就叫 deduction guide。
#include <string>
template <class T>
struct Box { Box(T) {} };
Box(const char*) -> Box<std::string>;
有了这条 guide。
Box b("hi"); 就会推导成 Box<std::string>。
当然,这也意味着:你在定义“默认语义”。
写之前想清楚。
不然以后别人会被你坑到。
关键结论
CTAD 省的是尖括号。
它省不了歧义。
小结:写得更短,但要更清楚
如果推导结果能代表你的意图。
就用 CTAD。
如果推导结果可能把语义带偏。
就写明白类型。
你把重复交给编译器。
把意图留在代码里。