2.6 模板与泛型编程
前面介绍运行时多态时,我们用一个共同的基类让不同通知器通过同一个接口工作。但如果具体类型在编译时已经知道,而且我们只要求它支持某些操作,就不一定需要继承。模板提供了另一种抽象方式:把类型作为参数交给编译器。
重复函数与函数模板
假设我们想求两个整数中的较大值:
int max(int left, int right) { return left > right ? left : right; }后来又需要处理 double:
double max(double left, double right) {
return left > right ? left : right;
}两个函数的逻辑完全相同,只有类型不同。我们知道,可以使用函数重载,两个都写,编译器会根据调用时的类型自动选取对应实现。但是这样的话,对于每一种要支持的类型组合,都要写一个实现,非常繁琐。
函数模板可以把这段共同逻辑写一次:
template <typename T> T max(T left, T right) {
return left > right ? left : right;
}T 是一个模板类型参数。调用时,编译器根据实参推导 T:
const auto integer_result = max(3, 5); // T 是 int
const auto real_result = max(2.5, 1.5); // T 是 double编译器会为实际使用到的类型生成相应的函数。模板不进入运行时,它通常在编译期产生具体代码。
Concept 概念
模板函数的函数体使用了 >,因此传入的类型必须支持比较:
template <typename T> T max(T left, T right) {
return left > right ? left : right;
// no match for 'operator>' (operand types are 'Point' and 'Point')
}
struct Point {
int x{};
};
int main() { max(Point{1}, Point{2}); }请你把这段代码复制粘贴到 IDE 中,或者用命令行编译。你会发现:这里的报错出现在 max 函数内的 left > right 上,而非 max(Point{1}, Point{2}) 这个调用上。我们提到,编译器根据传入的参数类型来实例化函数模板。编译器只有在尝试将具体类型代入模板并编译函数体时,才会发现这个类型是否支持模板内的操作。此例中,在遇到 max(Point{1}, Point{2}) 之后,编译器开始以 Point 为 T 尝试实例化模板,在 max 里的 > 上撞到了错误。
我们举的这个例子结构比较简单。对于标准库或第三方库而言,其具体实现可能定义得很深、模板内的操作可能很复杂。当调用方尝试用不支持的类型去调用它时,编译器往往会打出上百行错误信息,它们来自标准库的深处,抱怨着 no match for operator+ 或 operator<<。这会导致调试十分痛苦。
C++20 引入了 concept(概念
标准库提供了一些可以直接使用的 concept。例如:
#include <concepts>
#include <type_traits>
template <std::totally_ordered T> T max(T left, T right) {
return left > right ? left : right;
}
max(1, 2); // OK
max(Point{1}, Point{2});
// no matching function for call to 'max(Point, Point)'这里的 std::totally_ordered 就是标准库提供的 concept。它表达 T 应该支持完整的顺序比较,因此拒绝了 max(Point, Point) 形式的调用。约束一方面帮助编译器,另一方面也告诉调用者这个模板接受什么类型。
需要自定义规则时,也可以定义自己的 concept:
#include <concepts>
#include <iostream>
#include <string_view>
#include <type_traits>
// 始终满足的 concept
template <typename T>
concept AlwaysSatisfied = true;
// 组合现有的 concept,这里要求是整型并且有符号
template <typename T>
concept SignedIntegral = std::is_integral_v<T> && std::is_signed_v<T>;
// 通过 requires 验证表达式合法来约束,这里要求允许自身相加
template <typename T>
concept Addable = requires(T a, T b) { a + b; };
// 要求具有 send 方法并允许传入一个 std::string_view
template <typename T>
concept CanSendStringView =
requires(const T ¬ifier, std::string_view message) {
notifier.send(message);
};
// 进一步要求方法返回类型为 void
template <typename T>
concept NotifierLike =
requires(const T ¬ifier, std::string_view message) {
{ notifier.send(message) } -> std::same_as<void>;
};
template <NotifierLike T> void alert(const T ¬ifier) {
notifier.send("Server is down");
}NotifierLike 这个约束不要求 T 继承某个基类,只要求它能完成 send 这件事。这种按照类型所支持的操作来使用它的方式,称为泛型编程。与前一节通过共同基类建立接口的方式不同,泛型编程可以直观地理解为「看能力、不看出身
这里理顺一下逻辑:在定义 concept 时,模板修饰的不是函数,而是 concept,concept 内的 T 是模板类型参数。在定义了 concept 之后,concept 可以用于限制别的模板。
Concept 不仅可以用于模板语法里,还可以直接用于 auto 关键字前:
template <typename T>
concept UnsignedIntegral = std::is_integral_v<T> && !
std::is_signed_v<T>;
UnsignedIntegral auto foo = 1u; // OK
UnsignedIntegral auto bar = 1;
// deduced initializer does not satisfy placeholder constraints我们稍后再详细介绍模板与 auto 关键字的关系。
模板的几种形式与用法
多个类型参数
模板参数可以不只有一个。例如接收不同类型的输出函数:
template <typename T>
concept CanCout = requires(T a) { std::cout << a; };
template <CanCout U, CanCout V> void print_both(const U &x, const V &y) {
std::cout << x << ' ' << y << '\n';
}
print_both(1, "Hello"); // 1 Hello常量参数
模板参数不只有类型参数。也可以把常量作为参数:
#include <array>
template <typename T, std::size_t Size> struct FixedBuffer {
std::array<T, Size> values{};
};
FixedBuffer<int, 4> buffer;这里 T 决定元素类型,Size 决定容量。FixedBuffer<int, 4> 和 FixedBuffer<int, 8> 是两个不同的类型。
类模板
类与结构体也可以使用模板,实现把同一种设计应用到多种类型:
#include <string>
#include <utility>
template <typename T> class Box {
public:
explicit Box(T value) : value_{std::move(value)} {}
[[nodiscard]] const T &get() const noexcept { return value_; }
private:
T value_;
};
Box<int> integer_box{42};
Box<std::string> text_box{"hello"};标准库中的 std::vector<T>、std::optional<T> 和 std::unique_ptr<T> 都是类似的类模板。我们平时使用它们时,实际上是在给一个通用设计填入具体类型。
缩写函数模板与 auto 参数
函数参数位置的 auto 其实是一种缩写函数模板,lambda 的 auto 参数也会产生泛型调用运算符。
// 缩写函数模板
void print_1(auto x) { std::cout << x << '\n'; }
// 完整写法
template <typename T> void print_2(T x) { std::cout << x << '\n'; }
// lambda + auto
const auto print_3 = [](auto x) { std::cout << x << '\n'; };
// lambda + 模板
const auto print_4 = []<typename T>(T x) { std::cout << x << '\n'; };而且我们前面提过,concept 也可以修饰 auto。看起来模板似乎不如 auto 方便?但模板可以做到 auto 做不到的事。例如要求多个参数类型一致:
auto add_1(auto a, auto b) { return a + b; }
template <typename T> T add_2(T a, T b) { return a + b; }
add_1(1, 2); // OK
add_1(1, 2.0); // OK
add_2(1, 2); // OK
add_2(1, 2.0);
// no matching function for call to 'add_2(int, double)'在这个例子中,使用模板定义的 add_2 可以要求两个参数类型一致,而 auto 没有限制。
显式指定
函数模板通常可以推导类型:
template <typename T> T choose_first(const T &first, const T &second) {
return first;
}
const auto value = choose_first(1, 2); // T 推导为 int如果推导不出类型,或者希望明确指定,可以写出模板实参:
const auto result = max<double>(1, 2.5);模板也可以和普通函数重载放在一起。调用时,编译器会根据参数选择最合适的候选者。不要为了「泛型」而把所有函数都写成模板;当一个具体类型已经足够表达需求时,普通函数往往更简单。
模板函数与头文件
普通函数的声明可以放在头文件,定义放在源文件。但模板通常需要在实例化处看到完整定义,因此模板的定义一般也放在头文件中,或者放在被头文件包含的 .tpp 文件中。
这也是模板库常见「头文件很多」的原因。编译器需要看到模板正文,才能针对 int、std::string 等具体类型生成代码。
封闭的类型集合 variant
模板描述「对许多类型执行同一种操作std::variant 则描述「一个值可能是列出的几种类型之一
#include <iostream>
#include <string_view>
#include <variant>
struct EmailNotifier {
void send(std::string_view message) const {
std::cout << "Email: " << message << '\n';
}
};
struct SmsNotifier {
void send(std::string_view message) const {
std::cout << "SMS: " << message << '\n';
}
};
using Notifier = std::variant<EmailNotifier, SmsNotifier>;variant 在任意时刻只保存其中一种类型:
Notifier notifier{SmsNotifier{}};用 std::visit 对当前保存的值执行操作:
void alert(const Notifier ¬ifier) {
std::visit(
[](const auto &concrete_notifier) {
concrete_notifier.send("Server is down");
},
notifier);
}这组类型是封闭的:新增 PushNotifier 时,必须修改 Notifier 的类型列表,并检查相关代码。它的优点是对象可以直接按值保存,不必为每个值单独动态分配。
因此可以先这样选择:
| 问题 | 可以先考虑 |
|---|---|
| 类型在编译时已知,只要求它支持某些操作 | 函数模板与 concept |
| 类型列表固定,需要保存其中一种值 | std::variant |
| 类型在运行时决定,未来可扩展新类型 | 虚函数与抽象接口 |
| 只是复用实现或组合职责 | 成员、普通函数与组合 |
小结
- 模板把类型或常量作为参数,让编译器生成具体代码
- concept 可以把模板要求写进接口,并改善错误信息
- 泛型代码按「需要什么能力」工作,不要求类型共享基类
- 类模板是标准库容器和资源管理类型的基础
std::variant表达编译期确定的封闭类型集合- 模板、
variant和虚函数分别对应不同的变化方向 - 模板定义通常需要放在头文件中