文章目录
C++:模板 C++:模板 C++:模板
template 提供:
- 参数化类型:用类型参数生成具体类型
- 参数化操作:函数模板、函数对象、Lambda
- 编译期实例化
- 类型检查(特别是有 C++ 20 引入的 Concept 之后)
- 零运行时抽象成本:template 不会因为泛化带来运行时开销(因为 template 是编译期机制)
template 当中的参数
类型参数 template
无需多言
值参数 template<typename T, int N>
模板参数可以是编译期值,而不只是类型
template<typename T, int N>
struct Buffer {
T elem[N];
}
Buffer<int, 10> b;
注意和构造函数当中的参数区分开来。
std::vector<int> v(10, 0);
这里的 (10, 0) 是构造函数的参数,而不是模板参数
注意 C 风格字符串的类型,即 const char* 类型,不能直接作为 typename 的值。具体原因不必深究,要使用字符串作为类型的时候,使用 std::string 即可。
参数推导:编译器如何得到 T / N
如果每次使用模板时都要写类型,会比较 tedious。好消息是编译器有参数推导机制,让编译器自己推导类型。
可以从构造函数推导:
pair p = {1, 1.89};
vector v = {1, 2, 3, 4};
这里的 pair 会被推导为 pair<int, double>; vector 被推导为 vector
但是有时推导会导致歧义,以下是一个由 vector 的两种构造函数导致的歧义(initialization-list constructor 和 iterator-pair constructor 两种)
std::vector<int> src{1, 2, 3, 4, 5};
// 获得两个迭代器
auto b = src.begin();
auto e = b + 2;
std::vector<int> v1(b, e); // 被看作 iterator-pair,得到 {1, 2}
std::vector<int> v2{b, e}; // 被看作迭代器类型的 initializer-list,得到包含两个迭代器的 vector
使用 {} 的时候,如果存在可行的 initializer_list 构造函数,列表初始化会优先选择它;否则才考虑其它构造函数。
参数化操作
上面讲的都是如何在一个容器或者自定义类型当中使用参数,使得它们承载的具体类型可以在编译期推导。而这里参数化操作讲的是如何把参数用于函数当中,让同一算法适用于不同类型。
函数模板
函数的形式参数的类型模板化,具体类型根据实际参数进行推导。以下这个函数允许用 double 累加 int,避免溢出。因为 int 的范围大约 -2.1x10^9 ~ 2.1x10^9,而 double 最大约 1.8x10^308,double 的范围远远大于 int,用 double 作为累加的基础,能累加的数字的范围更大。
template<typename Seq, typename Val>
Val sum(Seq& seq, Val init) {
for (auto x: seq) {
init += x;
}
return init;
}
sum(v, 0); // 这样这个函数处理与返回 int
sum(v, 0.0) // 这样这个函数处理与返回 double
函数模板不能是 virtual 成员函数,因为
函数对象
函数对象本质上是定义了 operator() 的对象。因为它定义了括号 () 这个 operator,所以它可以像函数一样调用。因为它是对象,所以它可以携带状态。
函数对象(以及下面要讲的 Lambda)之所以属于参数化操作,是因为它们把 “具体操作” 作为参数传给另一个通用算法。这一点在 Lambda 上体现最明显,因为 Lambda 经常被作为谓词传给 STL 算法。
struct Square {
int count = 0;
int operator() (int x) {
count++;
return x * x;
}
};
Square f;
f(3); // 返回 9
f(4); // 返回 16
std::cout << f.count; // 输出 2
函数对象作为算法谓词参数。谓词对象的本质上是一个 “可调用的对象”。换句话说,函数对象和 lambda 之所以可以当作谓词使用,是因为他们本质上还是对象。
谓词是一个较广泛的概念,表示能判断条件的可调用实体。为了简化理解,我们认为 “一般的谓词” 指的就是一个普通布尔函数;“谓词对象” 指的是函数对象和 Lambda,因为它们的本质是对象。
struct IsPositive {
bool operator() (int x) {
return x > 0;
}
}
std::find_if(v.begin(), v.end(), is_positive{});
一般是这样做的,在需要谓词的地方使用 {} 调用构造函数,临时构造一个对象。注意不要调用成 () 了,因为 () 操作符意味着立刻调用 operator(),这不是谓词的需求。
把 “谓词对象” 的概念推广,可以得到 policy object,表示 “提供某种可替换的行为策略” 的对象。这包括比较策略、内存分配策略、重试策略、日志策略等。正式、可复用的策略一般用一般的函数对象;一次性的策略一般用 lambda。但是这只是惯例而已。
Lambda
Lambda 其实是函数对象的简写。因此它也可看作 “会保存状态的函数”。
Lambda 的 [] 用来写要从环境中捕获的对象。这个对象实质上是函数对象所保存的状态。() 用来写每次调用时需要传入的参数。[] 与 () 的区别在于,前者当作的对象在创建 lambda 的时候就已经被保存为状态了,无需每次传入;而后者当作的参数,在每次调用时都需要传入。
但是 lambda 最常见的用途还是作为 “当场定义,当场调用” 的简易函数,用在 STL 算法当中。很多时候不需要从环境中捕获状态,因此 [] 为空。lambda 并不需要显式规定返回类型,实际返回类型由编译期推导,效果类似 auto。
std::sort(v.begin(), v.end(), [](int a, int b) { return a < b; });
必须从环境中捕获状态的例子:
int threshold = 10;
auto it = std::find_if(v.begin(), v.end(), [threshold](int x) { return x > threshold; });
上述这样在 [] 当中直接写明要捕获的对象名 x,实际上是按值捕获。除此之外还可以:
0. 仍然用 [x] 这样捕获,但是加上 mutable 允许修改副本:auto f = x mutable {x++;};
- [&x] 表示按照引用捕获环境中的 x,可以在 lambda 内直接修改原对象
- [=] 表示按值捕获 lambda 需要用到的所有外部变量,effectively 相当于按值捕获所有外部变量
- [&] 表示按引用捕获 lambda 需要用到的所有外部变量
- [this] 表示获取指向当前对象的指针
- [*this] 表示把当前整个对象复制一份进 lambda
前面在函数模板当中讲过函数模板的参数类型由调用场景决定,从而让函数算法适用于不同具体类型,与lambda 也可做到这一点,这称为泛型 lambda:
[](auto& x) { x->do_something(); };
这样同一个 lambda 可以被多种操作使用,只要涉及的 x 支持 ->do_something()
lambda 本身没有名字,但是可以赋值给一个对象。在赋值给一个对象之后,完全可以像使用普通函数对象那样使用这个对象。
auto cmp = [](int a, int b){ return a < b;};
std::sort(v.begin(), v.end(), cmp);
的效果与下面的代码的效果完全一致
struct Cmp {
bool operator() (int a, int b) { return a < b; };
}
Cmp cmp{};
std::sort(v.begin(), v.end(), cmp);
编译期选择与组合
变量模板
这里与 std::vector / std::list 那一套模板的不同之处在于,std::vector / std::list 是把类/容器里的元素类型参数化,这些叫类模板;而此处讨论的变量模板是把某个变量的类型或值参数化。
把类型做成模板,而不把值做成模板,则要求选定的值对于多种类型都有效。最简单的例子莫过于选定 0:
template<typename T>
T zero = T{0};
int int_zero = zero<int>;
double double_zero = zero<double>;
if constexpr
if constexpr 在编译期选择分支
template<typename T>
void f(T x) {
if constexpr (std::is_pointer(x)) {
// 在编译期判断,如果是一个指针的话做某些操作
}
else {
// 如果不是指针的话做某些操作
}
}