泛型编程
前面我们提过,宏定义尽管很方便,效率比较高。但由于它对语言本身不甚理解,会导致代码可读性太差,并且很难调试。因此,在 C++
中引入了泛型编程的思想,并增加了一系列工具。这些工具提供了比文本替换更强大且更智能的操作,方便我们进行泛型程序设计。
模板
模板是 C++
中的一种泛型编程工具,它允许我们编写通用代码,以便在不同的数据类型上使用。模板的基本思想是将类型参数化,使得我们可以在编译前指定类型,并由编译器自动生成实际代码。
模版的定义为 template <typename T>
,其中 typename
是关键字,T
是类型参数。我们可以在函数、类、结构体中使用模板的类型参数,以实现泛型编程。
函数模板
函数模版是通用的函数描述,使用泛型来定义函数。我们可以像普通函数一样调用函数模版,在通常情况下,编译器会为我们补全类型相关的信息。
template <typename T>
void swap(T &a, T &b) {
T temp = a;
a = b;
b = temp;
}
int main() {
int a = 1, b = 2;
swap(a, b);
std::cout << a << " " << b; // 输出为 “2 1”
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
如果我们希望有时避免编译器自动推断类型,或是指定在某些特定类型上模版的小变化,我们可以使用显式具体化的技术。
template <typename T>
void swap(T &, T &); // 原始模版函数
template <> void swap<Job>(Job &, Job &); // 显式具体化
2
3
4
当我们传入 swap
函数的参数是 Job
类型时,编译器会优先选择显式具体化的函数,否则,编译器会默认选择原始模版函数。
类模板
类模版和函数模版类似,可以用来生成通用的类声明。同样,我们需要在类声明之前加上 template <typename T>
,并在类中使用 T
作为希望被替换的类型参数。
下面的 ArrayList.h
定义了一个简单的可以自动扩容的数组类,我们可以在其中存储任意类型的数据。
ArrayList.h
的代码实现
#ifndef ARRAYLIST_H
#define ARRAYLIST_H
#include <optional>
#include <cstring>
#include <iostream>
#include <bits/stdc++.h>
template<typename T>
struct ArrayList {
T *array;
int capacity; // allocated size
int size; // actual element occupation
int elem_size; // size of each element
ArrayList() {
size = 0;
capacity = 10;
elem_size = sizeof(T);
array = static_cast<T *>(malloc(capacity * elem_size));
}
void add(T x) {
if (size == capacity) {
capacity = capacity + (capacity >> 1);
array = static_cast<T *>(realloc(array, capacity * elem_size));
}
array[size] = x;
size++;
}
void remove(T x) {
int pos = -1;
for (int i = 0; i < size; i++) {
if (array[i] == x) {
pos = i;
break;
}
}
if (pos == -1) return;
for (int i = pos; i < size - 1; i++) {
array[i] = array[i + 1];
}
size--;
}
std::optional<T> get(int index) {
if (index >= size || index < 0) {
return std::nullopt;
}
return array[index];
}
int getSize() {
return size;
}
int getCapacity() {
return capacity;
}
};
#endif // ARRAYLIST_H
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
在创建类(实例化)时,我们可以指定 ArrayList
类的类型参数,以实现泛型编程。例如,我们要新建一个元素类型为 int
的 ArrayList
对象并对其简单操作,可以这样写:
ArrayList<int> list;
list.add(1);
list.add(2);
std::cout << list.get(0).value() << std::endl; // 输出为 “1”
2
3
4
Concept C++20
C++20
引入了 Concept
的概念,它是对模板的一种约束,可以限制模板的类型参数。这一限制可以提高代码的可读性,加快编译速度,并生成更易懂的错误信息。例如,有些时候我们希望模版类型需要满足可加性,这时我们可以使用 Concept
来约束模版类型。
显然,在前文的 ArrayList
的例子中,模版的类型参数应当可以按值拷贝并比较相等性。 因此,我们对前文的 ArrayList
进行修改,在 template<typename T>
后面添加一个 Concept
约束。
concept ArrayListCompatible =
std::is_copy_assignable_v<T> &&
requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
};
template<typename T> requires ArrayListCompatible<T>
2
3
4
5
6
7
这时,当我们传入一个自定义的类型作为参数,如果该类型不满足 ArrayListCompatible
的约束,编译器会报错。例如
struct Job {
int id;
std::string name;
};
2
3
4
当传入 Job
类型时,可以编译通过;当传入 const Job
类型时,编译器会报错。
Concept
的语法
在声明类模版的 Concept
时,我们可以先定义一个 Concept
,然后在模版声明时使用 requires
关键字来约束模版的类型参数。一般的范式如下:
template<typename T>
concept ConceptName =
// 约束条件, 可以用逻辑运算符连接多个约束条件
requires(T a, T b) {
// 约束条件
};
template<typename T> requires ConceptName<T>
// 类模版声明
2
3
4
5
6
7
8
9
template<typename T> requires ConceptName<T>
还可以简写为 template<ConceptName T>
。
当声明函数模版时,与之类似,我们只需在声明函数时使用 requires
关键字即可,如
template<typename T>
void func(T a) requires ConceptName<T> {
// 函数体
}
2
3
4
Concept
的约束条件
至于约束条件,可以在 CPPReference 上的相关页面查看,下面列出一些常用的约束条件:
Concept 名称 | 定义 | 典型应用场景 |
---|---|---|
std::same_as<T, U> | T 和 U 是完全相同的类型(包括 const/volatile 修饰符) | 确保模板参数是特定类型,例如:检查两个参数类型相同 |
std::integral<T> | T 是一个整数类型(如 int , long ) | 适用于整数运算,整数专用函数 |
std::floating_point<T> | T 是浮点数类型(如 float , double ) | 限制模板参数为浮点数,应用于计算浮点数函数 |
std::signed_integral<T> | T 是有符号整数类型(如 int , long ) | 有符号整数运算 |
std::unsigned_integral<T> | T 是无符号整数类型(如 unsigned int , unsigned long ) | 限制模板为无符号整数,应用于二进制运算 |
std::default_initializable<T> | T 可以用默认构造函数构造 | 用于需要默认构造对象的容器或数据结构 |
std::destructible<T> | T 类型是可销毁的(具有析构函数) | 需要动态创建和销毁对象的情况 |
std::constructible_from<T, Args...> | T 类型可以用 Args... 构造参数构造 | 限制模板类型可以用特定参数构造 |
std::assignable_from<T, U> | T 可以从 U 进行赋值操作 | 用于对象赋值,例如容器的赋值操作 |
std::copy_constructible<T> | T 类型支持拷贝构造函数 | 需要复制对象的容器、数据结构 |
std::move_constructible<T> | T 类型支持移动构造函数 | 适用于移动语义,减少不必要的拷贝 |
std::swappable<T> | T 类型支持 std::swap 或自定义的 swap 函数 | 在需要交换元素的容器中(如排序算法) |
std::equality_comparable<T> | T 类型支持 == 和 != 操作符 | 用于相等比较,如查找元素、集合操作 |
std::totally_ordered<T> | T 类型支持 < , <= , > , >= 操作符 | 用于有序数据结构(如排序算法、排序容器) |
std::ranges::range<T> | T 是一个范围(range)类型,可以用于基于范围的算法 | 适用于范围操作的函数和算法 |
std::invocable<F, Args...> | F 类型可以用 Args... 参数调用 | 用于函数对象、回调函数和策略设计模式 |
std::predicate<F, Args...> | F 是一个谓词函数,且 F(Args...) 可以转换为 bool | 适用于谓词判断的算法,如 std::find_if |
std::is_arithmetic<T>::value | T 是一个算术类型(包括整数和浮点数) | 常用于计算类模板、加减乘除运算 |
std::is_standard_layout<T>::value | T 是标准布局类型 | 适用于需要直接操作内存布局的结构体 |
std::is_trivial<T>::value | T 是平凡类型,可以用 memcpy 等操作进行复制 | 数据序列化、内存拷贝的操作 |
std::is_copy_assignable<T>::value | T 类型支持拷贝赋值操作 | 需要拷贝赋值的场景(如集合中的元素替换) |
std::is_move_assignable<T>::value | T 类型支持移动赋值操作 | 适用于优化复制性能的容器 |
一个例子:C++
库中的模版参数限制
在使用 stack
库或者 vector
库时,使用形如以下的定义会报错:
std::stack<MyClass &> s;
std::vector<MyClass &> v;
2
这是因为 stack
和 vector
的模版参数要求类型 T
必须是是可拷贝构造和可拷贝赋值的,而 MyClass &
是一个引用类型。在 C++
中,引用类型是不可拷贝赋值的,因此会导致编译错误。
如果我们自己实现相似的库的话,我们就可以使用 Concept
来限制模版参数的类型,以提前避免这种错误。
元编程 Meta Programming
元编程是利用模版来进行编译时静态生成代码的技术。某种意义上可以理解为增强版的 inline
和宏定义。利用元编程在编译时生成代码,可以提高代码的性能和可读性。
例如,我们可以生成这样的计算阶乘的代码:
template <int N>
struct factorial {
enum { value = N * factorial<N - 1>::value };
};
template <>
struct factorial<0> {
enum { value = 1 };
};
2
3
4
5
6
7
8
9
这当中,factorial
是一个递归的模版,它会在编译时展开,生成一个阶乘的计算过程。我们可以在编译时直接得到 factorial<5>::value
的值。
在上面的例子中,我们还使用了模版特化的技术。模版特化是指我们可以为特定的类型参数提供特定的实现,以覆盖原有的模版实现,它的标志是 template <>
,之后紧接着的是我们希望特化的类型(要在尖括号中指定),然后是我们的特化实现。
此外,你可能还注意到了 ::
运算符。在 C++
中,::
运算符用于访问类的静态成员,也可以用于访问模版的静态成员。换句话说,::
是全局的解析,不关联具体的对象;而相对的,.
是某个对象的成员访问。
我们还可以使用 constexpr
关键字来声明一个编译时常量,同样可以用于元编程。例如,我们可以这样定义一个编译时计算阶乘的函数:
constexpr int factorial(int n) {
return n == 0 ? 1 : n * factorial(n - 1);
}
int main() {
constexpr int x = factorial(5);
return 0;
}
2
3
4
5
6
7
8
可以看出,使用 constexpr
关键字更加像是在定义一个普通的函数,但是它的返回值是一个编译时常量。这样的函数在调用时,其参数应当是一个常量,其返回值也只能传递给一个常量。这就是 C++
中的字面类型。
利用函数指针
前文中我们介绍了模版这一强大的工具。但尽管模版提供了很强的泛型能力,似乎在某些方面自定义的能力还不够强。例如,如果我们想实现一个排序算法,我们经常需要自定义比较函数。一种想法是,使用操作符重载,这种方法我们会在
下面我们给出一个冒泡排序的函数,作为对照,我们同时给出原始版:
void MySort(void *base, unsigned width, unsigned num, int (*compare)(const void *elem1, const void *elem2)) {
char *A = (char *) base;
char *tmp = new char[width];
for (unsigned i = 1; i < num; i++) {
for (unsigned j = 0; i < num - i; i++)
if (compare(A + j * width, A + (j + 1) * width) > 0) {
memcpy(tmp, A + j * width, width);
memcpy(A + j * width, A + (j + 1) * width, width);
memcpy(A + (j + 1) * width, tmp, width);
}
}
delete tmp;
}
2
3
4
5
6
7
8
9
10
11
12
13
void MySort(int *A, int n) {
for (int i = 1; i < n; i++) {
for (int j = 0; j < n - i; j++)
if (A[j] > A[j + 1]) {
int tmp = A[j];
A[j] = A[j + 1];
A[j + 1] = tmp;
}
}
}
2
3
4
5
6
7
8
9
10
我们可以看到,在泛型版中,我们使用了一个函数指针 int (*compare)(const void *elem1, const void *elem2)
作为比较函数的参数;在函数体的比较部分,我们可以直接调用 compare
函数来进行比较。这样,我们可以在调用 MySort
函数时,传入一个自定义的比较函数,来实现不同的排序方式。
有些时候,我们传入的比较函数比较简单,可以考虑使用下面介绍的
C++ 11
C++ 11
引入。其主要语法如下:
[capture_list] (parameter_list) specifiers exception -> type {function body}
其中:
capture_list
:指定 表达式内部如何访问其外部作用域的变量。[=]
:以值捕获的方式捕获所有外部变量。[&]
:以引用捕获的方式捕获所有外部变量。[this]
:捕获当前类的this
指针。[a, &b]
:对变量a
值捕获,对变量b
引用捕获。
parameter_list
:定义 表达式的参数,可以省略。specifiers
:指定 表达式的属性,例如multable
、static
等。exception
:指定 表达式是否抛出异常,若不抛出,可以指定noexception
。
下面是几个
#include <iostream>
int main() {
auto add = [](int a, int b) { return a + b; };
std::cout << "3 + 4 = " << add(3, 4) << std::endl;
return 0;
}
2
3
4
5
6
7
#include <iostream>
int main() {
int x = 10;
int y = 20;
// 以值捕获x,以引用捕获y
auto lambda = [x, &y]() {
// x的值被捕获,因此在lambda表达式中x是不可修改的
// y被引用捕获,因此可以修改y的值
std::cout << "x = " << x << ", y = " << y << std::endl;
y = 30;
};
lambda();
std::cout << "y after lambda = " << y << std::endl;
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
:::
除了这种带有比较函数的排序算法可以用到函数指针,我们还可以在一些模版类中要求传入一个函数指针,以实现一些自定义的功能。例如,我们可以在前文的 ArrayList
模版中传入一个自定义的打印函数,这样就可以新增一个成员函数,来打印整个数组。下面是在 ArrayList
中添加一个打印函数的例子:
template<typename T>
struct ArrayList {
T *array;
int capacity; // allocated size
int size; // actual element occupation
int elem_size; // size of each element
void (*print)(T); // print function
ArrayList() {
ArrayList(void (*print)(T)) {
size = 0;
capacity = 10;
elem_size = sizeof(T);
array = static_cast<T *>(malloc(capacity * elem_size));
this->print = print;
}
void add(T x) {
...... // the same
}
void remove(T x) {
...... // the same
}
std::optional<T> get(int index) {
...... // the same
}
int getSize() {
return size;
}
int getCapacity() {
return capacity;
}
void printArrayList(){
for(int i = 0; i < size; i++){
print(array[i]);
cout<< " ";
}
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这样,我们就可以在创建 ArrayList
对象时,传入一个打印函数,来实现自定义的打印功能。