显然,人们并不总是将类型为double的元素保存在动态数组中。动态数组是一个独立于浮点数的通用概念,因此,动态数组的元素类型也应该被独立地表示,而不应该和动态数组绑定在一起。模板是一个类或函数,其中包含参数化的类型或值。模板可用于表示那些与具体类型无关的通用概念,然后通过传入具体的类型或值作为实参,将其具体化(实例化)为普通的类或函数,以用于创建对象或调用执行。
C++中的模板主要涉及语言机制、编程技术和标准库,三个方面。
在之前编写的,存储double型元素的,动态数组的基础上,略加修改,将其中的具体类型double,替换为一个参数化的类型T,并在template关键字的后面,借助typename指明该类型参数,即可得到一个泛化的,可存储任意类型元素的动态数组模板类,及与之相关的模板函数。例如:
xxxxxxxxxx
1821import std;
2
3using namespace std;
4
5template<typename T>
6class Container {
7public:
8 virtual ~Container() {}; // 析构函数
9 virtual T& operator[](int i) = 0; // 通过下标访问特定的元素
10 virtual const T& operator[](int i) const = 0; // 通过下标访问特定的元素
11 virtual int size() const = 0; // 获取元素的数量
12 virtual void push_back(const T& t) = 0; // 在序列末尾添加一个新元素
13};
14
15template<typename T>
16class Vector : public Container<T> {
17public:
18 // 接受元素数量的构造函数
19 explicit Vector(int size) {
20 if (size < 0)
21 throw length_error("Vector constructor: negative size");
22
23 m_elem = new T[size]; // 分配内存
24 m_size = size;
25
26 // 初始化元素
27 for (int i = 0; i < m_size; ++i)
28 m_elem[i] = T{};
29 }
30
31 // 接受初始值列表的构造函数
32 Vector(const initializer_list<T>& lst)
33 : m_elem{ new T[lst.size()] }
34 , m_size{ static_cast<int>(lst.size()) } {
35 int i = 0;
36 for (const T& t : lst)
37 m_elem[i++] = t;
38 }
39
40 // 拷贝构造函数
41 Vector(const Vector<T>& vec)
42 : m_elem{ new T[vec.m_size] } // 分配资源
43 , m_size{ vec.m_size } {
44 for (int i = 0; i < m_size; ++i) // 复制内容
45 m_elem[i] = vec.m_elem[i];
46 }
47
48 // 拷贝赋值操作符函数
49 Vector<T>& operator=(const Vector<T>& vec) {
50 if (&vec != this) {
51 Vector<T> dup{ vec }; // 调用拷贝构造函数得到源对象的局部副本
52 swap(m_elem, dup.m_elem); // 与局部副本对象交换成员变量,成为副本
53 swap(m_size, dup.m_size); // 同时令局部副本对象持有目标对象的资源
54 } // 在局部副本的析构过程中释放原有旧资源
55
56 return *this;
57 }
58
59 // 转移构造函数
60 Vector(Vector<T>&& vec)
61 : m_elem(vec.m_elem) // 转移资源
62 , m_size(vec.m_size) {
63 vec.m_elem = nullptr; // 不再持有
64 vec.m_size = 0;
65 }
66
67 // 转移赋值操作符函数
68 Vector<T>& operator=(Vector<T>&& vec) {
69 if (&vec != this) {
70 Vector<T> dup{ move(vec) }; // 调用转移构造函数得到源对象的局部副本
71 swap(m_elem, dup.m_elem); // 与局部副本对象交换成员变量,成为副本
72 swap(m_size, dup.m_size); // 同时令局部副本对象持有目标对象的资源
73 } // 在局部副本的析构过程中释放原有旧资源
74
75 return *this;
76 }
77
78 // 析构函数
79 ~Vector() override {
80 delete[] m_elem; // 释放内存
81 }
82
83 // 通过下标访问特定的元素
84 T& operator[](int i) override {
85 if (i < 0 || m_size <= i)
86 throw out_of_range("Vector::operator[]");
87
88 return m_elem[i];
89 }
90
91 // 通过下标访问特定的元素
92 const T& operator[](int i) const override {
93 return const_cast<Vector&>(*this)[i];
94 }
95
96 // 获取元素的数量
97 int size() const override {
98 return m_size;
99 }
100
101 // 在序列末尾添加一个新元素
102 void push_back(const T& t) override {
103 int size = m_size + 1;
104 T* elem = new T[size];
105
106 for (int i = 0; i < m_size; ++i)
107 elem[i] = m_elem[i];
108 elem[size - 1] = t;
109
110 delete[] m_elem;
111
112 m_elem = elem;
113 m_size = size;
114 }
115
116private:
117 T* m_elem = nullptr; // 指向元素序列的指针
118 int m_size = 0; // 元素的数量
119};
120
121template<typename T>
122ostream& operator<<(ostream& os, const Container<T>& con) {
123 for (int i = 0; i < con.size(); ++i)
124 os << '(' << con[i] << ')';
125
126 return os;
127}
128
129template<typename T>
130Vector<T> operator+(const Vector<T>& va, const Vector<T>& vb) {
131 if (va.size() != vb.size())
132 throw length_error("Vector operator+: the sizes of two operands are not same");
133
134 int size = va.size();
135 Vector<T> vc(size);
136
137 for (int i = 0; i < size; ++i)
138 vc[i] = va[i] + vb[i];
139
140 return vc;
141}
142
143void testCopy() {
144 Vector<int> v1{ 1, 2, 3, 4, 5 }, v2{ v1 };
145
146 v1[1] = 20;
147 v2[3] = 40;
148
149 cout << v1 << endl; // (1)(20)(3)(4)(5)
150 cout << v2 << endl; // (1)(2)(3)(40)(5)
151
152 Vector<int> v3{ 1, 2, 3, 4, 5 }, v4{ 6, 7, 8, 9 };
153 v4 = v3;
154
155 v3[1] = 20;
156 v4[3] = 40;
157
158 cout << v3 << endl; // (1)(20)(3)(4)(5)
159 cout << v4 << endl; // (1)(2)(3)(40)(5)
160}
161
162void testMove() {
163 Vector<double> v1{ 0.1, 0.2, 0.3 }, v2{ 0.4, 0.5, 0.6 }, v3{ 0.7, 0.8, 0.9 };
164
165 Vector<double> v4 = v1 + v2 + v3;
166 cout << v4 << endl;
167
168 v1 = v2 + v3;
169 cout << v1 << endl;
170}
171
172void testString() {
173 Vector<string> v1{ "张", "关", "赵" }, v2{ "翼德", "云长", "子龙" };
174 auto v3 = v1 + v2;
175 cout << v3 << endl;
176}
177
178int main() {
179 testCopy();
180 testMove();
181 testString();
182}
模板类和模板函数的前面,都带有一个形如“template<typename T>”的前缀,它声明T是一个类型形参,它是数学上“
如果将模板类成员函数的定义,放到模板类的外部,可以写成这样:
xxxxxxxxxx
131// 接受元素数量的构造函数
2template<typename T>
3Vector<T>::Vector(int size) {
4 if (size < 0)
5 throw length_error("Vector constructor: negative size");
6
7 m_elem = new T[size]; // 分配内存
8 m_size = size;
9
10 // 初始化元素
11 for (int i = 0; i < m_size; ++i)
12 m_elem[i] = T{};
13}
有了这些定义,就可以如下方式创建存储不同类型元素的动态数组:
xxxxxxxxxx
31Vector<char> vc(200); // 200个字符组成的动态数组
2Vector<string> vs(10); // 10个字符串组成的动态数组
3Vector<Vector<int>> vv(3); // 3个整型动态数组组成的动态数组
“Vector<Vector<int>>”中的“>>”是嵌套模板实参的结束尖括号,并非错用的流提取操作符。
Vector的使用代码可能会是这样:
xxxxxxxxxx
41void write(const Vector<string>& vs) {
2 for (int i = 0; i < vs.size(); ++i)
3 cout << vs[i] << endl;
4}
为了让一个容器支持基于范围的for循环,需要为之定义适当的begin和end函数。例如:
xxxxxxxxxx
191template<typename T>
2T* begin(Vector<T>& vec) {
3 return &vec[0]; // 返回指向第一个元素的指针
4}
5
6template<typename T>
7const T* begin(const Vector<T>& vec) {
8 return begin(const_cast<Vector<T>&>(vec));
9}
10
11template<typename T>
12T* end(Vector<T>& vec) {
13 return begin(vec) + vec.size(); // 返回指向最后一个元素下一个位置的指针
14}
15
16template<typename T>
17const T* end(const Vector<T>& vec) {
18 return end(const_cast<Vector<T>&>(vec));
19}
在此基础上,可以写出如下代码:
xxxxxxxxxx
51Vector<int> vec{ 1, 2, 3, 4, 5, 6, 7, 8, 9 };
2
3for (const auto& elem : vec)
4 cout << elem << ' ';
5cout << endl;
事实上,每个标准库容器,如vector、list、map、unordered_map等,都是带有参数化类型的类模板,可用于存储不同类型的元素。
模板是一种编译时机制,因此与手工编码相比,它并不会产生任何额外的运行时开销。这里的Vector<double>和之前编写的Vector相比,在性能上没有差别。标准库的vector<double>,生成的代码会更优,它包含更多优化。
模板化的类或函数结合模板实参变成具体类或函数的过程,称为模板的实例化或特例化。模板的实例化在编译阶段完成。编译器会为模板的每个实例生成一份独立的代码。
虽然在语法上,模板声明中的“template<typename T>”可被解释为“
xxxxxxxxxx
11template<Element T> class Vector { ... };
这里的“template<Element T>”表示“
为了强调Vector中的元素必须是可复制的,这里的Element可以取标准库预定义的概念copyable。在实例化模板时,为其提供任何不满足概念要求的类型实参,将导致编译错误。例如:
xxxxxxxxxx
21Vector<int> vi; // 正确,int类型满足copyable概念的要求,即该类型的对象可以复制
2Vector<thread> vt; // 错误,thread类型不满足copyable概念的要求,即该类型的对象无法复制
因此,概念允许编译器在实例化模板时执行类型检查,并给出更好的错误信息。C++在C++20之前并没有官方支持概念,因此旧代码只能以文档或注释的形式,将对类型参数的限制告知模板用户。一旦用户在实例化模板时,使用了违反这些限制的类型实参,编译器给出的错误信息可能会非常复杂,甚至难以理解。
与模板的实例化过程一样,基于概念的类型检查也在编译阶段完成,产生的代码与非受限模板一样好。
除了类型参数外,模板还可以带有数值形式的参数。例如:
xxxxxxxxxx
101template<typename T, int N>
2class Buffer {
3 ...
4 constexpr int size() const {
5 return N;
6 }
7 ...
8 T m_array[N];
9 ...
10};
值参数在很多场合都非常有用。例如Buffer允许创建任意大小的缓冲区,而不需要动态内存分配。例如:
xxxxxxxxxx
21Buffer<int, 10> bi;
2Buffer<char, 1024> bc;
不幸的是,由于隐晦的技术原因,字符串字面量不能作为模板的值参数,但使用字符数组表示的字符串是可以的。例如:
xxxxxxxxxx
11template<char* s> foo() { ... }
xxxxxxxxxx
51foo<"Hello World">(); // 到目前为止,这样会导致报错
2...
3char arr[] = "Hello World"; // 保存字符串的字符数组
4...
5foo<arr>(); // 诡异的间接解决方案
在C++中通常会有间接的解决方案,因此不需要对所有情况都提供直接支持。
将一个类模板实例化为一个类时,应该提供模板实参。例如:
xxxxxxxxxx
11pair<int, double> p{ 1, 2.34 };
但也可以在初始化时,由构造函数自动推导出模板的参数。例如:
xxxxxxxxxx
11pair p{ 1, 2.34 } // 推导出p的类型为pair<int, double>
又如:
xxxxxxxxxx
141Vector v1{ 1, 2, 3 }; // Vector(const initializer_list<T>&), T:int
2cout << v1 << endl; // (1)(2)(3)
3
4Vector v2 = v1; // Vector(const Vector<T>&), T:int
5cout << v2 << endl; // (1)(2)(3)
6
7auto v3 = new Vector{ 4, 5, 6 }; // Vector(const initializer_list<T>&), T:int
8cout << *v3 << endl; // (4)(5)(6)
9
10Vector v4(7); // Vector(const initializer_list<T>&), T:int
11cout << v4 << endl; // (7)
12
13Vector<int> v5(7); // Vector(int)
14cout << v5 << endl; // (0)(0)(0)(0)(0)(0)(0)
注意v4和v5的区别。v4没有指定元素类型,构造函数匹配接受初始值列表的版本,7是列表中唯一的元素,类型参数T被推导为int。v5显式指定了元素类型,无需推导类型参数,构造函数匹配接受元素数量的版本,7个元素均被初始化为整数0。
显然,基于类型推导的类模板实例化,写法更简单,更有助于消除因重复输入模板的类型参数而导致的拼写错误。然而,象其它许多强有力的机制一样,类型推导的结果并不总是尽如人意。例如:
xxxxxxxxxx
51Vector<string> v1{ "hello", "world" }; // 正确,无需推导,容器元素的类型就是string
2Vector v2{ "hello"s, "world"s } // 正确,容器元素的类型被推导为string
3Vector v3{ "hello", "world" }; // 正确,容器元素的类型被推导为const char*,符合预期吗?
4Vector v4{ "hello"s, "world" }; // 错误,初始值列表中的元素类型不一致,容器元素的类型被推导为string还是const char*?
5Vector<string> v5{ "hello"s, "world" }; // 正确,无需推导,初始值列表中的元素类型不一致也没关系,反正都会转成string
注意,C风格字符串字面量的数据类型是const char*。若希望容器元素的类型为string,要么显式指定类型实参:
xxxxxxxxxx
11Vector<string> v1{ "hello", "world" };
要么使用string类型的字符串字面量,填充初始值表:
xxxxxxxxxx
11Vector v2{ "hello"s, "world"s };
如果初始值表中的元素类型不一致,就无法推导出唯一的元素类型,编译器会报告二义性错误。
注意,以下两种初始化形式的区别:
xxxxxxxxxx
21Vector v1(7); // Vector(const initializer_list<T>&), T:int
2cout << v1 << endl; // (7)
xxxxxxxxxx
21Vector v2{ 7 }; // Vector(const initializer_list<T>&), T:int
2cout << v2 << endl; // (7)
使用圆括号初始化语法,构造函数倾向于选择非初始值列表的版本,即“Vector(int)”,但使用该构造函数无法推导模板参数的类型,因此编译器只能选择初始值列表的版本,即“Vector(const initializer_list<T>&)”,并将7作为列表中唯一的元素,类型参数T被推导为int。
使用花括号初始化语法,构造函数倾向于选择初始值列表的版本,即“Vector(const initializer_list<T>&)”,除非没有这样的构造函数,再寻找其它可用版本。
如果为类模板的构造函数指定了类型推导指引,则可根据该指引完成模板参数的类型推导。例如:
xxxxxxxxxx
11Vector(int)->Vector<typename double>;
xxxxxxxxxx
51Vector v1(7); // Vector(int), T:double
2cout << v1 << endl; // (0)(0)(0)(0)(0)(0)(0)
3
4Vector v2{ 7 }; // Vector(const initializer_list<T>&), T:int
5cout << v2 << endl; // (7)
这时,非初始值列表版本的构造函数“Vector(int)”,也具备了对模板参数的类型推导能力。因此,使用圆括号初始化语法,优先匹配该构造函数,并将模板的类型参数T按照指引推导为double,7个元素均被初始化为双精度数0。
类型推导指引的效果有时候会很微妙,如果能避免使用,最好还是尽量避免使用。
类模板的模板参数推导,可被缩写为CTAD。
C++17:类模板参数的类型推导
除了参数化容器元素的数据类型以外,模板还有其它用途。比如它们被广泛用于参数化标准库中的类型与算法。
要想将一个操作过程中的类型或值参数化,通常有三种方法:
模板函数:带有类型及值参数的,模板形式的函数
函数对象:既能携带数据,又能象函数一样被调用的类对象
匿名函数:函数对象的简略形式
可以定义一个对任何容器求元素之和的函数,只要该容器支持基于范围的for循环即可。例如:
xxxxxxxxxx
71template<typename Container, typename Value>
2Value sum(const Container& con, Value val) {
3 for (const auto& elem : con)
4 val += elem;
5
6 return val;
7}
模板参数Value和调用参数val分别表示累加和的类型和初值。任何标准库容器,包括前面定义的Vector容器,都可以使用该函数计算元素之和,仅仅因为它们都支持基于范围的for循环。例如:
xxxxxxxxxx
81Vector vn{ 1, 2, 3 };
2cout << sum(vn, 0) << endl; // 6
3
4vector vd{ .1, .2, .3 };
5cout << sum(vd, .0) << endl; // 0.6
6
7list<complex<double>> lc{ { .1, .3 }, { .2, .2 }, { .3, .1 } };
8cout << sum(lc, complex<double>{ .0, .0 }) << endl; // (0.6,0.6)
请注意,sum<Container,Value>模板函数是如何根据调用参数的类型,推导出模板参数的类型的。这里的sum函数可以看作是标准库accumulate函数的简化版本。
模板函数也可以作为类的成员函数,但不能同时也是虚函数。编译器不可能知道模板型成员函数的所有实例,因此也就不可能将其入口地址填入类的虚函数表(vtbl)。
函数对象,亦称仿函数,是一种特殊的模板类,其特殊性在于,它所创建的对象可以象函数一样被调用,而之所以能做到这一点,是因为它提供了对小括号形式的,函数调用操作符的重载定义。例如:
xxxxxxxxxx
141template<typename T>
2class LessThan {
3public:
4 LessThan(const T& val) : m_val(val) {
5 }
6
7 // 函数调用操作符函数
8 bool operator()(const T& val) const {
9 return val < m_val;
10 }
11
12private:
13 const T m_val; // 待比较的值
14};
名为“operator()”的成员函数,实现了小括号形式的函数调用操作符。LessThan模板类的任何实例化对象,都可以象函数一样被调用,而实际被调用的就是这个函数调用操作符函数。例如:
xxxxxxxxxx
81LessThan lt1{ 42 };
2cout << boolalpha << lt1(41) << ' ' << lt1(43) << endl;
3
4LessThan lt2{ "abc"s };
5cout << lt2("ab") << ' ' << lt2("abcd") << endl;
6
7LessThan<string> lt3{ "abc" };
8cout << lt2("ab") << ' ' << lt2("abcd") << endl;
可以通过任何支持小于号(<)操作符的类型实例化该模板类,然后象调用函数一样调用所得到的对象。
函数对象被广泛用作算法的参数。例如,统计容器中满足谓词(令其为true)的元素个数:
xxxxxxxxxx
101template<typename C, typename P> // C表示容器,P表示谓词
2int count(const C& con, P pred) {
3 int cn = 0;
4
5 for (const auto& elem : con)
6 if (pred(elem))
7 ++cn;
8
9 return cn;
10}
这其实是标准库count_if函数的简化版本。可以象下面这样统计容器中满足谓词条件的元素数:
xxxxxxxxxx
51Vector vec{ 1, 9, 2, 8, 3, 7, 4, 6, 5 };
2cout << count(vec, LessThan{ 5 }) << endl; // 4
3
4list lst{ "a"s, "abcde"s, "ab"s, "abcd"s, "abc"s };
5cout << count(lst, LessThan{ "abc"s }) << endl; // 2
“LessThan{ 5 }”创建了一个“LessThan<int>”类型的对象,count函数通过“pred(elem)”调用了该对象的“operator()”函数,将vec中的每个元素与5进行比较,若其小于5则“++cn”,最后返回vec中比5小的元素数4。类似地,“LessThan{ "abc"s }”创建了一个“LessThan<string>”类型的对象,count函数通过“pred(elem)”调用了该对象的“operator()”函数,将lst中的每个元素与“abc”进行比较,若其小于“abc”则“++cn”,最后返回lst中比“abc”小的元素数2。
函数对象的迷人之处在于它携带了用于比较的值(5或“abc”),且其类型是参数化的(int或string),不需要为每种类型的每个值编写一个独立的函数,也不需要借助令人不快的全局变量来减少函数调用的参数,相当于把任意类型的某个参数(5或“abc”)提前绑定到函数内部。另外,象LessThan这样的简单函数对象,在编译时很容易实现内联,因此对LessThan对象的调用效率非常高。既能携带参数化类型的数据,又能表现出足够的运行效率,这使得函数对象非常适合作为标准库算法的参数。
函数对象通常用于实现通用算法的核心逻辑,因此也叫做策略对象。
函数对象和普通函数共同的问题是,使用函数的代码与实现函数的代码彼此分离。如果希望在使用函数的同时给出函数的定义,可以使用匿名函数。匿名函数,亦称Lambda表达式,其本质也是一个函数对象,即可被当做函数调用的对象,不同之处在于,这里既不需要定义实现小括号操作符的类,也不需要显式地创建对象,编译器会隐式地完成这些工作。例如:
xxxxxxxxxx
51Vector vec{ 1, 9, 2, 8, 3, 7, 4, 6, 5 };
2cout << count(vec, [](int n) { return n < 5; }) << endl; // 4
3
4list lst{ "a"s, "abcde"s, "ab"s, "abcd"s, "abc"s };
5cout << count(lst, [](const string& s) { return s < "abc"; }) << endl; // 2
其中,“[](int n) { return n < 5; }”和“[](const string& s) { return s < "abc"; }”即为匿名函数(Lambda表达式),它们会被编译器自动处理为类似“LessThan{ 5 }”和“LessThan{ "abc"s }”的对象。
匿名函数中的“[]”称为捕获列表,用于指定哪些局部变量可被匿名函数内部的代码访问:
“[]”表示不捕获任何局部变量
“[&]”表示按引用捕获所有局部变量
“[=]”表示按值捕获所有局部变量
要捕获多个指定的局部变量,可在捕获列表中依次写出,中间以逗号分隔
“[&r,v]”表示按引用捕获局部变量r,按值捕获局部变量v
“[&,v]”表示按引用捕获除v以外的所有局部变量,按值捕获局部变量v
“[=,&r]”表示按值捕获除r以外的所有局部变量,按引用捕获局部变量r
匿名函数所在函数的参数,包括类成员函数中的this指针,也属于局部变量,可被匿名函数捕获
如果匿名函数出现在类的成员函数内部,且捕获了this指针,那么该匿名函数可以直接访问类的成员变量,调用类的成员函数。这里所说的成员变量和成员函数,也包括从基类中继承的成员变量和成员函数
类成员函数中的匿名函数,可以两种方式显式捕获this指针:
“[this]”表示捕获this指针本身,即当前对象
“[*this]”表示捕获this指针的目标,即当前对象的副本
任何时候,对全局变量和全局函数,总是可以直接访问和调用的,不需要依赖捕获列表
C++11:匿名函数
C++20:通过“[*this]”按值捕获当前对象
使用匿名函数方便又简洁,但有时也略显晦涩。对于复杂操作(比如多于一行代码的函数体)而言,还是更倾向于使用普通的有名函数,这样可以更清晰地表达出它的意图,并能在程序的多个地方调用它。
回顾之前编写的代码:
xxxxxxxxxx
151// 移动一组形状
2
3void moveShapes(vector<unique_ptr<Shape>>& shapes, Point offset) {
4 for (auto& shape : shapes)
5 shape->move(offset);
6}
7
8// 绘制一组形状
9
10void drawShapes(vector<unique_ptr<Shape>>& shapes) {
11 for (auto& shape : shapes) {
12 shape->draw();
13 cout << endl;
14 }
15}
xxxxxxxxxx
51vector<unique_ptr<Shape>> shapes;
2...
3drawShapes(shapes);
4moveShapes(shapes, { 100, 100 });
5drawShapes(shapes);
这些代码有一个共同的特征,就是遍历容器中的每一个元素,并对其执行某种操作,移动或绘制。类似这样的逻辑,借助函数对象,甚而匿名函数,可以将遍历的过程,与需要执行的操作分开,进而获得统一的形式。例如:
xxxxxxxxxx
71// 对容器中的每个元素执行某种操作
2
3template<typename C, typename O> // C表示容器,O表示操作
4void forEach(C& con, O oper) {
5 for (auto& elem : con)
6 oper(elem);
7}
这里的forEach函数可以看作是标准库for_each函数的简化版本,其第二个参数是一个函数对象。例如:
xxxxxxxxxx
111vector<unique_ptr<Shape>> shapes;
2...
3forEach(shapes, [](unique_ptr<Shape>& shape) {
4 shape->draw();
5 cout << endl;
6 });
7forEach(shapes, [](unique_ptr<Shape>& shape) { shape->move({ 100, 100 }); });
8forEach(shapes, [](unique_ptr<Shape>& shape) {
9 shape->draw();
10 cout << endl;
11 });
使用“unique_ptr<Shape>”型的引用作为匿名函数的参数,巧妙地规避了对象的生命周期问题。甚至可以将匿名函数写成泛型形式。例如:
xxxxxxxxxx
51vector<unique_ptr<Shape>> shapes;
2...
3forEach(shapes, [](auto& shape) { shape->draw(); cout << endl; });
4forEach(shapes, [](auto& shape) { shape->move({ 100, 100 }); });
5forEach(shapes, [](auto& shape) { shape->draw(); cout << endl; });
或者将这部分与具体类型无关的代码封装成一个模板函数。例如:
xxxxxxxxxx
61template<typename S>
2void moveAndDraw(S& shapes, Point offset) {
3 forEach(shapes, [](auto& shape) { shape->draw(); cout << endl; });
4 forEach(shapes, [&](auto& shape) { shape->move(offset); });
5 forEach(shapes, [](auto& shape) { shape->draw(); cout << endl; });
6}
这里的auto表示匿名函数可以接受任意类型的参数。含有auto型参数的匿名函数也是模板,称为泛型匿名函数。如果需要,也可以借助概念,为这样的参数增加一个约束条件。例如,可以定义PointerToClass概念,表示它需要支持解引用(*)和间接成员访问(->)操作:
xxxxxxxxxx
61template<typename S>
2void moveAndDraw(S& shapes, Point offset) {
3 forEach(shapes, [](PointerToClass auto& shape) { shape->draw(); cout << endl; });
4 forEach(shapes, [&](PointerToClass auto& shape) { shape->move(offset); });
5 forEach(shapes, [](PointerToClass auto& shape) { shape->draw(); cout << endl; });
6}
任何提供draw和move等成员函数的对象的“指针”所组成任意类型的集合,都可作为调用moveAndDraw函数的参数。例如:
xxxxxxxxxx
31vector<unique_ptr<Shape>> shapes;
2...
3moveAndDraw(shapes, { 100, 100 });
如果需要更严格的类型检查,甚至可以定义PointerToShape概念,作为匿名函数参数的约束条件,以强调其目标对象必须提供draw和move等成员函数。即使不是Shape类的派生类,只要满足此约束,亦可用于此匿名函数。
C++14:泛型匿名函数
借助匿名函数,可以将任何语句变成表达式。最常见的场合,是将某种操作作为传递给函数的参数,或从函数中返回的值,同时捕获当前上下文中的数据。此外,它还有另一个作用,就是优化初始化逻辑。下面的代码包含了一个复杂的初始化过程:
xxxxxxxxxx
11enum class InitMode { zero, seq, cpy, patrn }; // 初始化方案
xxxxxxxxxx
221void foo(InitMode m, int n, const vector<int>& arg, Iterator p, Interator q) {
2 vector<int> v;
3
4 // 混乱的初始化代码
5
6 switch (m) {
7 case zero:
8 v = vector<int>(n); // 将n个元素初始化为0
9 break;
10
11 case cpy:
12 v = arg; // 复制arg
13 break;
14 }
15
16 ...
17
18 if (m == seq)
19 v.assign(p, q); // 从序列[p:q)中拷贝
20
21 ...
22}
这是一个风格化的例子,不幸的是,它并非特例。函数实现者希望从一系列初始化方案中,选择一种,用于初始化局部变量v。不同的方案代表了不同的初始化策略。这种代码经常会写得一团糟,哪怕只提供基本的功能,也难免会出现一堆Bug:
变量可能在获得有效初值前即被使用
初始化代码与其它代码混在一起,难以理解和维护
处理各种初始化方案的代码分支,很可能覆盖不全
这里所做的其实不是初始化操作,而是赋值操作
下面是借助匿名函数,实现变量初始化的改进版本:
xxxxxxxxxx
11enum class InitMode { zero, seq, cpy, patrn }; // 初始化方案
xxxxxxxxxx
161void foo(InitMode m, int n, const vector<int>& arg, Iterator p, Interator q) {
2 vector<int> v = [&] {
3 switch (m) {
4 case zero:
5 return vector<int>(n); // 将n个元素初始化为0
6
7 case cpy:
8 return arg; // 复制arg
9
10 case seq:
11 return vector<int>{ p, q }; // 从序列[p:q)中拷贝
12 }
13 }();
14
15 ...
16}
很明显,改进后的版本将采用各种方案的初始化过程集中在一个匿名函数中,调用该函数,并用该函数的返回值初始化局部变量v。这比上一个版本,以散落于纷繁代码中的赋值语句,为变量设定初值的做法高明得多。与上一个版本相同,这里依然遗漏了一个关于patrn初始化方案的case分支,但这个问题很容易被发现。在很多情况下,编译器可以发现大部分问题并给出警告。
析构函数提供了一种通用的解决方案,用于在作用域结束时,隐式释放所有域内分配的动态资源。但如果需要释放的动态资源与析构函数无关,比如那些游离于任何对象的动态资源,又当如何呢?定义一个专门负责清理工作的匿名函数,并令其在控制流离开作用域时被执行,也许是个不错的方法。例如:
xxxxxxxxxx
51void foo(int n) {
2 void* p = malloc(n * sizeof(int)); // 游离于任何对象的动态资源
3 auto fa = finally([&] { free(p); }); // 控制流离开作用域时执行该匿名函数
4 ...
5}
这样做总比在作用域的所有出口分支,手动执行“free(p)”,要好得多。实现finally函数的方法很简单。例如:
xxxxxxxxxx
41template<typename F>
2[[nodiscard]] auto finally(F f) {
3 return FinalAction{ f };
4}
这里使用了“[[nodiscard]]”属性修饰,强制finally函数的调用者必须保存该函数的返回值,因为这是该函数实现其功能的关键,而FinalAction则是一个类,它的定义可能类似下面这个样子:
xxxxxxxxxx
131template<typename F>
2class FinalAction {
3public:
4 explicit FinalAction(F f) : m_f(f) {
5 }
6
7 ~FinalAction() {
8 m_f();
9 }
10
11private:
12 F m_f;
13};
在C++ Core Guidelines支持库(GSL)中提供了一份finally函数的实现,同时包含了针对标准库的提案,其中描述了更精巧的scope_exit机制。
要想设计出好的模板,还需要以下一些支撑性的语言基础设施:
依赖类型的值:参数模板
类型与模板的别名:别名模板
编译时选择机制:if constexpr
编译时查询值与表达式属性的机制:requires表达式
此外,constexpr函数和static_asserts断言,也经常出现在模板的设计和使用中。
这些语言机制是构建通用型基础抽象的主要工具。
任何具体类型,都可用于常量的定义。例如:
xxxxxxxxxx
21constexpr double viscosity = 0.4;
2constexpr SpaceVector<float> externalAcceleration = { float{}, float{ -9.8 }, float{} };
xxxxxxxxxx
21auto vis2 = 2 * viscosity;
2auto acc = externalAcceleration;
能否象模板那样,将这些常量(如viscosity和externalAcceleration)的类型中的具体类型(如double和float)参数化呢?这就要用到模板变量语法。例如:
xxxxxxxxxx
51template<typename T>
2constexpr T viscosity = 0.4;
3
4template<typename T>
5constexpr SpaceVector<T> externalAcceleration = { T{}, T{ -9.8 }, T{} };
xxxxxxxxxx
21auto vis2 = 2 * viscosity<double>;
2auto acc = externalAcceleration<float>;
这里的viscosity和externalAcceleration虽然都是常量,但它们依然称为模板变量。
一般而言,可以用任何类型符合的表达式作为模板变量的初始值。例如:
xxxxxxxxxx
21template<typename T1, typename T2>
2constexpr bool assignable = is_assignable<T1&, T2>::value;
以此判断能否将后面类型的数据赋值给前面类型的变量。例如:
xxxxxxxxxx
11cout << boolalpha << assignable<int, char> << ' ' << assignable<int, char*> << endl; // true false
这个想法最终成为概念定义的核心。
标准库通过模板变量定义了很多数学常数,如pi、log2e等。
C++14:模板变量
给类型或者模板定义一个别名常常非常有用。例如标准库头文件<cstddef>就为unsigned int类型定义了别名size_t,类似下面这样:
xxxxxxxxxx
11using size_t = unsigned int; // size_t是unsigned int的别名
名为size_t的实际类型属于实现定义,有些C++实现可能会将其定义为“unsigned long”。类型别名有助于编写可移植的C++代码。
在有参类型(比如模板)的内部,通常会为类型参数定义别名。例如:
xxxxxxxxxx
61template<typename T>
2class Vector {
3public:
4 using value_type = T;
5 ...
6};
事实上,标准库中所有表示容器的模板类,都无一例外地用value_type作为其元素类型的别名。这就允许程序员编写出,适用于所有符合该约定的容器的通用代码。例如:
xxxxxxxxxx
21template<typename C>
2using ValueType = C::value_type; // 容器中元素的类型
xxxxxxxxxx
51template<typename C>
2void foo(const C& con) {
3 Vector<ValueType<C>> vec;
4 ... 将con中的元素复制到vec中 ...
5}
这里的ValueType是标准库range_value_t的简化版本。利用别名机制,甚至可以通过绑定模板的部分或全部参数,定义新模板或类。例如:
xxxxxxxxxx
41template<typename Key, typename Value>
2class Map {
3 ...
4};
xxxxxxxxxx
21template<typename Value>
2using StringMap = Map<string, Value>;
xxxxxxxxxx
11StringMap<int> msi; // msi是Map<string, int>类型的对象
C++11:为类型和模板定义别名
假定针对某个操作可以有两种不同的实现,一个安全但耗时较长,另一个速度虽快但安全性略差。传统的做法是将其封装为两个独立的函数,前者名为slowSafe,而后者名为simpleFast。也可以将前者定义为基类中的虚函数,而在派生类中以后者的实现覆盖之。除此而外,还可以有第三种做法,就是使用编译时if。例如:
xxxxxxxxxx
91template<typename T>
2void update(const T& arg) {
3 ...
4 if constexpr(is_trivially_copyable_v<T>) // 如果是轻拷贝类型
5 simpleFast(arg);
6 else
7 slowSafe(arg);
8 ...
9}
其中is_trivially_copyable_v是一个类型谓词,用于判断某种类型是否支持低代价的拷贝。
在编译阶段,由编译器执行对该类型谓词的判断,并选择性地编译if或else分支。这个方案在提供最佳性能的同时,保证了最佳的局部性。
与传统意义上的条件编译不同,“if constexpr”并非文本处理机制,因此不能用于打破语法、类型和作用域的正常规则。例如下面的写法是不会通过编译的:
xxxxxxxxxx
191template<typename T>
2void bad(const T& arg) {
3 ...
4
5 if constexpr(!is_trivially_copyable_v<T>) {
6 try {
7 }
8
9 ... 操作arg对象 ...
10
11 if constexpr(!is_trivially_copyable_v<T>) {
12 }
13 catch(...) {
14 ...
15 }
16 }
17
18 ...
19}
源自条件编译的黑魔法终将淡出人们的视线。不会令作用域失效的,更清晰的做法,源自朴素的编译时if。例如:
xxxxxxxxxx
181template<typename T>
2void good(const T& arg) {
3 ...
4
5 if constexpr(!is_trivially_copyable_v<T>) {
6 try {
7 ... 操作arg对象 ...
8 }
9 catch(...) {
10 ...
11 }
12 }
13 else {
14 ... 操作arg对象 ...
15 }
16
17 ...
18}
C++17:编译时if
用模板表达那些可用于多种类型数据的算法
用模板实现容器
用模板提升代码的抽象层次
模板的类型安全的,但对于无约束模板,类型检查的时机过于滞后
只要有可能,尽量让模板函数或者模板类的构造函数推断出模板参数的具体类型
将函数对象作为算法的参数
对于一次性调用的简单函数,最好使用匿名函数
模板类和普通类一样也可以定义虚函数,但无论如何,虚函数本身都不能再是模板函数
借助finally函数,为不带析构函数且需要清理操作的类型提供RAII
利用模板别名简化符号并隐藏细节
将编译时if,即“if constexpr”,作为条件编译的替代方案,不会增加运行时开销