欢迎大家来到我们的 C++ 泛型(模板)编程分享。
为什么我们需要模板
在C++中,模板是一种强大的机制,它允许你编写泛型代码,即可以处理多种数据类型的代码。通过使用模板,你可以编写灵活且可重用的函数和类,而不需要为每种数据类型都重复相同的逻辑。
在学习模板之前,如果你要比较多种类型变量的大小,你可能要写出这样的代码
#include <iostream>
#include <string>
using std::string;
using std::cout;
using std::endl;
// 交换两个 int 型变量的值
void Swap(int& a, int& b) {
int tmp = a;
a = b;
b = tmp;
}
// 交换两个 double 型变量的值
void Swap(double& a, double& b) {
double tmp = a;
a = b;
b = tmp;
}
// 交换两个 string 型变量的值
void Swap(string& a, string& b) {
string tmp = a;
a = b;
b = tmp;
}
int main() {
int a = 1, b = 2;
double da = 1.0, db = 2.0;
string sa = "sa", sb = "sb"; // 注意这里变量名由sc改为sb,以匹配Swap函数的调用
cout << "Before swapping ints: a = " << a << ", b = " << b << endl;
Swap(a, b);
cout << "After swapping ints: a = " << a << ", b = " << b << endl << endl;
cout << "Before swapping doubles: da = " << da << ", db = " << db << endl;
Swap(da, db);
cout << "After swapping doubles: da = " << da << ", db = " << db << endl << endl;
cout << "Before swapping strings: sa = \"" << sa << "\", sb = \"" << sb << "\"" << endl;
Swap(sa, sb);
cout << "After swapping strings: sa = \"" << sa << "\", sb = \"" << sb << "\"" << endl;
return 0;
}
或者为了压缩代码,使用宏函数来简化
#include <iostream>
#include <string>
// 定义 typedef
typedef int IntType;
typedef double DoubleType;
typedef std::string StringType;
// 宏定义 Swap
#define SWAP(Type, a, b) do { \
Type tmp = (a); \
(a) = (b); \
(b) = tmp; \
} while(0)
int main() {
IntType a = 10, b = 20;
DoubleType c = 10.5, d = 20.5;
StringType s1 = "hello", s2 = "world";
// 交换整型变量
SWAP(IntType, a, b);
std::cout << "After swapping, a = " << a << ", b = " << b << std::endl;
// 交换双精度浮点型变量
SWAP(DoubleType, c, d);
std::cout << "After swapping, c = " << c << ", d = " << d << std::endl;
// 交换字符串变量
SWAP(StringType, s1, s2);
std::cout << "After swapping, s1 = " << s1 << ", s2 = " << s2 << std::endl;
return 0;
}
但是 众所周知,宏不具备 类型安全性,我们尝试在宏定义版本中添加一个错误的示例,看看会发生什么:
// 尝试交换不同的类型
SWAP(a, s1);
当尝试编译这段代码时,编译器不会报错,因为宏定义在预处理阶段被展开,而编译器在那时还没有进行类型检查。这可能会导致运行时错误。
我们可以使用 visual studio 提供的 工具来演示 这一过程 (工具 --》命令行 --》开发者工具)
cl /P /Fi"preprocessed_output.cpp" source_file.cpp
这里:
/P
指示预处理器输出经过预处理的源代码。/Fi"preprocessed_output.c"
指定预处理后的输出文件名。
cl /c /Fo"compiled_object.obj" preprocessed_output.cpp
这里:
/c
表示只编译,不链接。/Fo"compiled_object.obj"
指定输出的目标文件名。
link /OUT:executable_name.exe compiled_object.obj other_objects.obj
这里:
/OUT:executable_name.exe
指定输出的可执行文件名。compiled_object.obj other_objects.obj
列出了要链接的对象文件。
并且 使用宏函数编程 也不利于 调试,由于宏定义在预处理阶段被展开,所以在调试时,宏定义的位置信息会被抹去,这使得定位错误变得困难。
但在现代 C++ 中,模板函数提供了更好的安全性、可读性和维护性。
#include <iostream>
#include <string>
// 模板函数 swap
template<typename T>
void swap(typename T& a, typename T& b) {
T temp = a;
a = b;
b = temp;
}
int main() {
int a = 10, b = 20;
double c = 10.5, d = 20.5;
std::string s1 = "hello", s2 = "world";
// 交换整型变量
swap(a, b);
std::cout << "After swapping, a = " << a << ", b = " << b << std::endl;
// 交换双精度浮点型变量
swap(c, d);
std::cout << "After swapping, c = " << c << ", d = " << d << std::endl;
// 交换字符串变量
swap(s1, s2);
std::cout << "After swapping, s1 = " << s1 << ", s2 = " << s2 << std::endl;
return 0;
}
函数模板的结构
以一个大小比较的模板函数为例
template<typename T>
typename T max(typename T a, typename T b) {
return b < a ? a : b;
}
-
typename T
定义了一个类型参数T
,它是泛型的占位符,表示在调用模板函数时将被替换为实际类型的变量 ( 如 int char double )。 -
typename
关键字用来声明类型参数。在某些上下文中,可以省略typename
关键字 。
例如上文的函数模板可以简写为:
template<typename T>
T max(T a, T b)
{
return b < a ? a: b;
}
- 类型参数的约束. 类型参数
T
必须支持<
运算符,因为max
函数使用了这个运算符来比较a
和b
。- 对于基本类型如
int
或double
,这是默认支持的;而对于自定义类型,你需要显式地提供这样的运算符。
- 对于基本类型如
比如 自定义一个类型,使用我们的函数模板
#include <iostream>
// 自定义类 Point
class Point {
public:
int x, y;
Point(int x, int y) : x(x), y(y) {}
// 重载 < 运算符
bool operator<(const Point& other) const {
if (x == other.x) {
return y < other.y;
}
return x < other.x;
}
};
// 模板函数 max
template<typename T>
T max(T a, T b) {
return b < a ? a : b;
}
int main() {
Point p1(1, 2);
Point p2(3, 4);
// 使用 max 函数模板比较 Point 类型的对象
Point maxPoint = max(p1, p2);
std::cout << "The maximum point is (" << maxPoint.x << ", " << maxPoint.y << ")" << std::endl;
return 0;
}
在C++17之前,模板参数类型T
通常需要满足可复制的要求,这是因为模板中的函数如果返回T
类型的值,那么这个值就需要能够被复制。但是,有时候我们可能想要使用不可复制的类型作为模板参数,例如std::unique_ptr
这样的独占所有权智能指针。
为了支持不可复制类型的使用,C++17引入了结构化绑定和std::optional
、std::variant
等新特性,同时也引入了std::move
和std::forward
等机制来更好地处理右值引用和移动语义。
结构化绑定允许你直接从一个容器或者元组中解构数据到多个变量中,而不需要显式地调用std::tie
。这对于处理返回非复制类型的情况非常有用,因为你可以在不复制数据的情况下直接获取这些值。
std::optional<T>
:可以表示一个可能不存在的值,它要么包含一个T
类型的值,要么什么也不包含。std::variant
:可以表示几种不同类型的值之一,它只存储其中一个类型的数据。
这些类型可以帮助我们在不复制数据的情况下处理模板参数类型。
std::move
和std::forward
可以用来有效地移动数据,而不是复制它们。这在处理不可复制类型时非常重要。
下面是一个示例,展示了如何在C++17中返回一个不可复制的类型:
#include <memory>
#include <utility> // for std::forward
template<typename T>
T process(T&& t) {
// 假设这里有一些处理逻辑
return std::forward<T>(t); // 返回一个完美转发的T
}
int main() {
auto p = std::make_unique<int>(42);
auto result = process(std::move(p)); // 不复制 p,而是移动它
return 0;
}
在这个例子中,process
函数接受一个通用引用T&&
,并且通过std::forward<T>(t)
返回该值。这样,即使T
是不可复制的类型(如std::unique_ptr
),也可以通过移动构造而非复制构造来返回。
示例:使用std::optional
假设我们有一个函数,它可能会返回一个值,也可能不返回任何东西。我们可以使用std::optional
来包装这个值:
#include <optional>
template<typename T>
std::optional<T> processOptional(T&& t) {
if (/* some condition */) {
return std::forward<T>(t);
} else {
return std::nullopt;
}
}
int main() {
auto p = std::make_unique<int>(42);
auto result = processOptional(std::move(p));
if (result) {
// 使用 *result
}
return 0;
}
在这个例子中,processOptional
函数返回一个std::optional<T>
,它可以包含一个T
类型的值或者为空。这样就可以安全地处理不可复制的类型,同时提供一种清晰的方式来表达可能不存在的结果。
模板的实例化
这是一个程序运行的流程图,可见 模板实例化 是在编译过程中发生的,具体来说是在编译的早期阶段,即模板的解析和实例化阶段。这个阶段通常发生在预处理之后,但在整个编译流程完成之前。
- Source Code (源代码): 用户编写的原始程序代码。
- Preprocessing (预处理): 包括宏替换、头文件包含等。
- Macro Expansion (宏扩展): 宏定义被其定义的内容所替代。
- Compilation (编译): 将预处理后的源代码转换为汇编代码,包括语法和类型检查。
- Template Instantiation (模板实例化): 为模板定义创建具体的实例。
- Assembly (汇编): 将编译后的汇编代码转换为机器语言的目标代码。
- Linking (链接): 将多个目标文件组合成一个可执行文件。
- Executable (可执行文件): 包含所有必需的机器代码和资源的文件。
- Runtime Environment (运行时环境): 加载并执行可执行文件。
对于模板 (它发生在),它允许你在编译时根据不同的类型生成不同的代码实例。
#include <iostream>
// 模板函数
template<typename T>
T max(T a, T b) {
return b < a ? a : b;
}
int main() {
int x = 10, y = 20;
double d1 = 10.5, d2 = 20.5;
int maxInt = max(x, y);
double maxDouble = max(d1, d2);
std::cout << "Max of " << x << " and " << y << " is: " << maxInt << std::endl;
std::cout << "Max of " << d1 << " and " << d2 << " is: " << maxDouble << std::endl;
return 0;
}
在这个示例中,当我们调用 max
函数时,编译器会根据传递的类型自动实例化相应的函数版本。例如,对于整数,它会生成一个整数版本的 max
函数;对于双精度浮点数,它会生成一个双精度浮点数版本的 max
函数。
对于 宏替换 而言,MAX
宏定义只是一个简单的文本替换。无论你在哪里使用 MAX(a, b)
,预处理器都会将其替换为 ((a) > (b) ? (a) : (b))
。这意味着对于所有类型,宏定义都会执行相同的替换,不考虑类型的安全性(宏定义在预处理阶段展开,因此在调试时很难追踪到原始的宏定义位置)。
#include <iostream>
// 宏定义
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main() {
int x = 10, y = 20;
double d1 = 10.5, d2 = 20.5;
int maxInt = MAX(x, y);
double maxDouble = MAX(d1, d2);
std::cout << "Max of " << x << " and " << y << " is: " << maxInt << std::endl;
std::cout << "Max of " << d1 << " and " << d2 << " is: " << maxDouble << std::endl;
return 0;
}
两阶段实例化
-
解析阶段 (Parsing):在这个阶段,编译器读取源代码并构建抽象语法树(AST)。在这个过程中,模板定义的语法正确性会被检查,但是模板的具体实例还没有被创建。这意味着在这个阶段,编译器无法评估依赖于模板参数的表达式的值。
-
实例化阶段 (Instantiation):当模板被用来创建特定类型的实例时,这个过程被称为模板实例化。在这个阶段,编译器会创建具体的函数或类,并且能够评估模板参数的实际值。模板实例化时,所有
依赖于模板参数的表达式都会被评估
。
下图是一个 C++ 模板实例化两阶段的流程图
Template Declaration
表示模板声明。Resolution Phase
表示解析阶段,在这里进行类型和表达式的检查。Template Definition Checked
表示模板定义已经过检查。Template Usage
决策节点询问是否有模板的使用。- 如果没有使用,则
Compilation Ends
,编译结束。 - 如果有使用,则进入
Instantiation Phase
(实例化阶段)。 Parameter Deduction
表示根据模板使用情况推导出模板参数。Code Generation for Specific Types
表示为特定类型生成代码。Specific Code Compiled
表示特定类型的代码已经被编译。
可以使用 https://cppinsights.io/ 网站,从编译器视角查看 实例化的过程。
当模板被用来创建具体的类型时,我们可以使用一个 静态断言 对模板参数 类型进行评估
#include <iostream>
#include <type_traits>
template<typename T>
void print(T value)
{
static_assert(std::is_integral<T>::value, "T must be an integral type");
std::cout << value << '\n';
}
int main()
{
print(5); // 实例化为 int 类型,正常工作
print(3.14f); // 实例化时失败,因为 float 不满足 is_integral 断言
return 0;
}
总结一下:
本节内容主要介绍了:
- 我们为什么需要模板
- 模板和宏替换的区别
- 函数模板的结构
- 模板两阶段实例化
下一节我们将介绍 C++ 模板的其他内容:
- 模板参数的类型推导
- 多类型的模板参数