C++之旅——模板
C++之旅——模板
模板是 C++ 泛型编程的基石。借助模板,我们可以用类型或编译期的值作为参数,定义一族类、函数或变量。它表达的思路很直观:先把共同的逻辑写出来,再代入具体参数,得到所需的类型或操作。这一篇就从语言机制入手,聊聊 C++ 中的模板。笔记主要整理自 Bjarne Stroustrup 的《A Tour of C++》。
1. 将类参数化
1.1 模板参数和实例化
在声明前加上 template<typename T>,就引入了一个类型模板形参 T,后面的类或函数定义可以使用它。
模板是一种编译期机制,参数化本身不需要额外的运行时类型判断。给模板指定一组实参,就能确定一个特化,例如 Vector<int>;编译器在需要时,根据模板定义生成相应的类或函数定义,这个过程叫作实例化。“特化”指具体版本,“实例化”则强调生成它的过程,二者并不是同义词。
还是拿上一篇的 Vector 来说。原先它只能保存 double,现在把元素类型换成模板参数 T,就能复用同一套容器逻辑。下面只列出拷贝和移动操作的声明,资源管理的思路沿用上一篇:
template<typename T>
class Vector {
private:
T* elem; // elem 指向一个包含 sz 个 T 类型元素的数组
int sz;
public:
using value_type = T;
Vector() : Vector(0) {}
explicit Vector(int s); // 构造函数:建立不变式,获取资源
~Vector() { delete[] elem; } // 析构函数:释放资源
Vector(const Vector&);
Vector& operator=(const Vector&);
Vector(Vector&&) noexcept;
Vector& operator=(Vector&&) noexcept;
T& operator[](int i); // 用于非 const Vector
const T& operator[](int i) const; // 用于 const Vector
int size() const { return sz; }
T* begin() { return elem; }
T* end() { return elem + sz; }
const T* begin() const { return elem; }
const T* end() const { return elem + sz; }
};
相应地,成员函数可以这样定义:
template<typename T>
Vector<T>::Vector(int s)
{
if (s < 0)
throw std::length_error("Vector constructor: negative size");
elem = new T[s]{};
sz = s;
}
template<typename T>
T& Vector<T>::operator[](int i)
{
if (i < 0 || size() <= i)
throw std::out_of_range("Vector::operator[]");
return elem[i];
}
template<typename T>
const T& Vector<T>::operator[](int i) const
{
if (i < 0 || size() <= i)
throw std::out_of_range("Vector::operator[]");
return elem[i];
}
有了这个类模板,就可以创建不同元素类型的容器:
Vector<char> vc(200); // 200 个字符
Vector<std::string> vs(17); // 17 个字符串
Vector<std::list<int>> vli(45); // 45 个整数链表
函数也可以接受某个具体的模板特化类型:
void write(const Vector<std::string>& vs)
{
for (int i = 0; i != vs.size(); ++i)
std::cout << vs[i] << '\n';
}
对 Vector<double> 来说,元素类型在编译期就已经确定,不需要在每次访问时再判断类型。这也是模板能够兼顾通用性与效率的原因。
1.2 受约束的模板参数
模板通常只对满足特定条件的实参有意义。比如,上面的 Vector 要求元素能够默认构造和销毁;如果使用它的拷贝操作,元素还需要支持相应的拷贝操作。C++20 的概念(concept)可以把这类要求写进模板接口。假定已经定义了一个描述元素基本要求的概念 Element,就可以这样使用:
template<Element T>
class Vector {
private:
T* elem; // elem 指向一个包含 sz 个 T 类型元素的数组
int sz;
// ...
};
Element 是一个编译期条件,用来检查 T 是否满足指定要求。这种写法中的 T 是受约束的模板形参,含有约束的模板称为受约束模板。不满足约束时,编译器会在使用模板的地方给出诊断,而不必等到深入实现内部才发现问题。概念的具体写法,下一篇再展开。
概念检查的是声明出来的要求,模板内部依赖参数的代码仍需要在实例化时检查。C++20 之前,也可以借助 static_assert、SFINAE 等方式表达限制,只是写起来和读起来都不够直接。概念让这些约定更清楚,而且不会引入运行时检查。
1.3 值模板参数
除了类型参数,模板还可以接受值参数,也常称为非类型模板参数。例如,下面的 Buffer 把元素数量 N 作为编译期参数,数组直接保存在对象内部,不需要另外动态分配:
template<typename T, int N>
struct Buffer {
constexpr int size() const { return N; }
T elem[N];
// ...
};
这里的 N 需要在编译期确定,并且数组长度应为正数。
字符串的情况稍微特殊一些:字符串字面量不能直接作为指针类型的模板实参,但可以先定义一个具有静态存储期的字符数组,再把它的地址传给模板:
template<const char* s>
void outs() { std::cout << s; }
constexpr char arr[] = "Weird workaround!";
void use()
{
outs<"straightforward use">(); // 错误:不能用字符串字面量作指针模板实参
outs<arr>(); // 输出:Weird workaround!
}
从 C++20 开始,还可以用满足结构化类型要求的定长字符串包装类,把字符串内容作为模板参数。
1.4 模板参数推导
每次都显式写出模板实参,难免有些繁琐。从 C++17 开始,类模板实参推导(CTAD)允许编译器根据构造时的实参推导类型:
template<typename T>
class Vector {
public:
explicit Vector(int);
Vector(std::initializer_list<T>); // 在前面的 Vector 中加入此构造函数
// ...
};
Vector v1 {1, 2, 3}; // 从初始化器元素类型推导 v1 的元素类型:int
Vector v2 = v1; // 从 v1 的元素类型推导 v2 的元素类型:int
auto p = new Vector{1, 2, 3}; // p 是一个 Vector<int>*
Vector<int> v3(1); // 此处需显式写出元素类型(没有可供推导的元素类型信息)
推导遵循实参本来的类型,并不会猜测我们想要什么。比如,字符串字面量并不等于 std::string:
using namespace std::string_literals; // 启用 "..."s 字符串字面量
Vector<std::string> vs {"Hello", "World"}; // OK:Vector<std::string>
Vector vs1 {"Hello", "World"}; // OK:推导为 Vector<const char*>(奇怪!)
Vector vs2 {"Hello"s, "World"s}; // OK:推导为 Vector<std::string>
Vector vs3 {"Hello"s, "World"}; // 错误:初始化列表的类型不统一
Vector<std::string> vs4 {"Hello"s, "World"}; // OK:元素类型是显式指定的
这里的构造函数接收 std::initializer_list<T>,所有元素都要为同一个 T 提供一致的推导结果。vs3 因而会推导失败,不会自动寻找一个“公共类型”;显式指定 std::string 后,vs4 中的字符串字面量就可以转换成该类型。
2. 将操作参数化
要让一段操作适用于不同类型,或者接收不同的行为,可以借助下面三种工具:
- 函数模板
- 函数对象:一个可以携带数据并像函数一样被调用的对象
- lambda 表达式:就地编写函数对象的简洁方式
2.1 函数模板
函数模板既可以独立定义,也可以作为成员模板,但成员函数模板不能声明为虚函数。它会根据模板实参生成不同的函数版本,而虚函数需要固定的虚调用接口。不过,类模板中的普通成员函数仍然可以是虚函数。
比如,我们可以为支持范围 for 的序列编写一个求和函数,只要元素能累加到初始值上即可:
template<typename Sequence, typename Value>
Value sum(const Sequence& s, Value v)
{
for (const auto& x : s)
v += x;
return v;
}
Value 和参数 v 让调用者决定累加器的类型与初始值。同一个整数容器,既可以累加到 int,也可以累加到 double:
void user(Vector<int>& vi, std::list<double>& ld,
std::vector<std::complex<double>>& vc)
{
int x = sum(vi, 0); // int 型 vector 的和(累加器为 int)
double d = sum(vi, 0.0); // int 型 vector 的和(累加器为 double)
double dd = sum(ld, 0.0); // double 型 list 的和
auto z = sum(vc, std::complex{0.0, 0.0}); // complex<double> 型 vector 的和
}
这些调用都没有显式写出 sum<Sequence, Value>:编译器从传入的序列和初始值推导出了两个模板实参。前面的 Vector 提供了 begin() 和 end(),因此也能参与这里的范围 for。
2.2 函数对象
函数对象(有时称为 functor)是可以像函数一样调用的对象。它不一定要用模板定义,但模板能让同一套调用逻辑适用于不同类型。与普通函数相比,函数对象:
- 可以把调用需要的状态保存在成员中,例如比较时使用的阈值;
- 传给算法模板时,具体类型和调用运算符的实现对编译器可见,便于进行内联等优化。
函数对象的核心在于重载函数调用运算符:
template<typename T>
class Less_than {
const T val; // 要比较的值
public:
Less_than(const T& v) : val(v) { }
bool operator()(const T& x) const { return x < val; } // 调用运算符
};
函数对象很适合作为算法的参数。比如,让调用方提供一个判断条件,我们就能统计序列中满足条件的元素个数:
template<typename C, typename P>
int count(const C& c, P pred) // 假设 C 是容器,P 是谓词
{
int cnt = 0;
for (const auto& x : c)
if (pred(x))
++cnt;
return cnt;
}
count() 不需要知道具体的比较规则,只需要逐个调用 pred(x),再根据结果决定是否计数。
谓词是调用结果可以用作布尔条件的函数或函数对象。例如:
void f(const Vector<int>& vec, const std::list<std::string>& lst, int x, const std::string& s) { std::cout << "number of values less than " << x << ": " << count(vec, Less_than{x}) << '\n'; std::cout << "number of values less than " << s << ": " << count(lst, Less_than{s}) << '\n'; }这里,
Less_than{x}通过类模板实参推导得到Less_than<int>,保存阈值x;Less_than{s}则得到Less_than<std::string>,保存阈值s。真正参与比较的元素,在调用operator()时传入。
2.3 lambda 表达式
如果一个判断条件只在这里用一次,专门为它起名、定义一个类,多少有些麻烦。lambda 表达式让我们能在使用处直接写出这个函数对象:
void f(const Vector<int>& vec, const std::list<std::string>& lst,
int x, const std::string& s)
{
std::cout << "number of values less than " << x << ": "
<< count(vec, [&](int a) { return a < x; }) << '\n';
std::cout << "number of values less than " << s << ": "
<< count(lst, [&](const std::string& a) { return a < s; }) << '\n';
}
[&](int a) { return a < x; } 就是一个 lambda 表达式,表达的比较规则与 Less_than<int>(x) 相同。不过,Less_than 保存阈值的副本,这里的 lambda 则通过引用读取 x。
方括号中的内容叫作捕获列表,决定 lambda 如何访问外围的局部变量。[&x] 按引用捕获 x,[x] 保存 x 的副本;[] 不捕获变量,[&] 和 [=] 则分别默认按引用、按值捕获所需的局部变量。也可以只列出需要的几个对象,例如 [x, &s]。
在成员函数中,[this] 捕获的是 this 指针的副本,因此访问的仍是原对象;[*this](C++17)才会复制当前对象本身。
从 C++14 开始,lambda 还可以使用 auto 形参,成为泛型 lambda:
template<class S>
void rotate_and_draw(std::vector<S>& v, int r)
{
std::for_each(v.begin(), v.end(),
[r](auto& s) { s->rotate(r); s->draw(); });
}
这里,auto& 让参数类型根据调用实参推导,编译器会为生成的函数对象提供一个调用运算符模板。[r] 则把旋转角度保存进函数对象。这个例子中的元素需要是指针或智能指针,指向支持 rotate() 和 draw() 的对象。
3. 模板机制
想把模板写得清楚、好用,还需要一些配套机制:
- 定义依赖模板参数的值:变量模板
- 为类型与模板起别名:别名模板
- 编译期分支:
if constexpr - 在编译期检查类型与表达式是否满足要求:
requires表达式(C++20)
此外,constexpr 函数和 static_assert 也常常参与模板的设计。下面先看看其中几种常用工具。
3.1 变量模板
有时,需要随类型变化的不只是类或函数,还有常量本身。C++14 引入的变量模板就能表达这件事。下面是一个来自流体动力学仿真的例子:
template <class T>
constexpr T viscosity = 0.4;
template <class T>
constexpr space_vector<T> external_acceleration = { T(), T{-9.8}, T() };
auto vis2 = 2 * viscosity<double>;
auto acc = external_acceleration<float>;
这里,space_vector<T> 表示三维向量。viscosity<double> 是 double 类型的常量,external_acceleration<float> 则是元素类型为 float 的三维加速度向量。
3.2 别名
类模板经常会为与模板参数相关的类型提供别名。例如,可以给容器的元素类型起一个统一的名字:
template<typename T>
class Vector {
public:
using value_type = T;
// ...
};
标准序列容器都用 value_type 表示元素类型。遵循这个约定,就可以在泛型代码中用统一的方式取得元素类型:
template<typename C>
using Value_type = typename C::value_type; // C 的元素类型
template<typename Container>
void algo(Container& c)
{
Vector<Value_type<Container>> vec; // 在此保存结果
// ...
}
别名模板还可以固定一部分模板实参,给常用的组合起个短名字:
template<typename Key, typename Value>
class Map {
// ...
};
template<typename Value>
using String_map = Map<std::string, Value>;
String_map<int> m; // m 的类型是 Map<std::string, int>
3.3 编译期 if
有些实现方式是否适用,要看具体的模板实参。C++17 的 if constexpr 允许我们在编译期选择分支:
template<typename T>
void update(T& target)
{
if constexpr (std::is_trivially_copyable_v<T>)
simple_and_fast(target); // 用于可平凡复制的类型
else
slow_and_safe(target); // 用于更复杂的类型
}
std::is_trivially_copyable_v<T> 是标准库 <type_traits> 中的类型特征,用来判断 T 是否可平凡复制,具体含义留到后面的篇目再展开。这里用它来选择两种不同的更新方式。
关键在于:模板实例化时,条件确定后,未选中的分支不会被实例化,因此两个分支可以使用不同的、依赖于 T 的操作。两个分支仍会被解析,与模板参数无关的错误也仍会被检查。
参考资料
- Bjarne Stroustrup:《A Tour of C++》。
- C++ 标准草案:模板实例化、值模板实参、lambda 捕获、
if constexpr。
浙公网安备 33010602011771号