Skip to content

2.6 模板与泛型编程 ​

前面介绍运行时多态时,我们用一个共同的基类让不同通知器通过同一个接口工作。但如果具体类型在编译时已经知道,而且我们只要求它支持某些操作,就不一定需要继承。模板提供了另一种抽象方式:把类型作为参数交给编译器。

重复函数与函数模板 ​

假设我们想求两个整数中的较大值:

cpp
int max(int left, int right) { return left > right ? left : right; }

后来又需要处理 double:

cpp
double max(double left, double right) {
    return left > right ? left : right;
}

两个函数的逻辑完全相同,只有类型不同。我们知道,可以使用函数重载,两个都写,编译器会根据调用时的类型自动选取对应实现。但是这样的话,对于每一种要支持的类型组合,都要写一个实现,非常繁琐。

函数模板可以把这段共同逻辑写一次:

cpp
template <typename T> T max(T left, T right) {
    return left > right ? left : right;
}

T 是一个模板类型参数。调用时,编译器根据实参推导 T:

cpp
const auto integer_result = max(3, 5);  // T 是 int
const auto real_result = max(2.5, 1.5); // T 是 double

编译器会为实际使用到的类型生成相应的函数。模板不进入运行时,它通常在编译期产生具体代码。

Concept 概念 ​

模板函数的函数体使用了 >,因此传入的类型必须支持比较:

cpp
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。例如:

cpp
#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:

cpp
#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 &notifier, std::string_view message) {
        notifier.send(message);
    };

// 进一步要求方法返回类型为 void
template <typename T>
concept NotifierLike =
    requires(const T &notifier, std::string_view message) {
        { notifier.send(message) } -> std::same_as<void>;
    };

template <NotifierLike T> void alert(const T &notifier) {
    notifier.send("Server is down");
}

NotifierLike 这个约束不要求 T 继承某个基类,只要求它能完成 send 这件事。这种按照类型所支持的操作来使用它的方式,称为泛型编程。与前一节通过共同基类建立接口的方式不同,泛型编程可以直观地理解为「看能力、不看出身」。

这里理顺一下逻辑:在定义 concept 时,模板修饰的不是函数,而是 concept,concept 内的 T 是模板类型参数。在定义了 concept 之后,concept 可以用于限制别的模板。

Concept 不仅可以用于模板语法里,还可以直接用于 auto 关键字前:

cpp
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 关键字的关系。

模板的几种形式与用法 ​

多个类型参数 ​

模板参数可以不只有一个。例如接收不同类型的输出函数:

cpp
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

常量参数 ​

模板参数不只有类型参数。也可以把常量作为参数:

cpp
#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> 是两个不同的类型。

类模板 ​

类与结构体也可以使用模板,实现把同一种设计应用到多种类型:

cpp
#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 参数也会产生泛型调用运算符。

cpp
// 缩写函数模板
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 做不到的事。例如要求多个参数类型一致:

cpp
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 没有限制。

显式指定 ​

函数模板通常可以推导类型:

cpp
template <typename T> T choose_first(const T &first, const T &second) {
    return first;
}

const auto value = choose_first(1, 2); // T 推导为 int

如果推导不出类型,或者希望明确指定,可以写出模板实参:

cpp
const auto result = max<double>(1, 2.5);

模板也可以和普通函数重载放在一起。调用时,编译器会根据参数选择最合适的候选者。不要为了「泛型」而把所有函数都写成模板;当一个具体类型已经足够表达需求时,普通函数往往更简单。

模板函数与头文件 ​

普通函数的声明可以放在头文件,定义放在源文件。但模板通常需要在实例化处看到完整定义,因此模板的定义一般也放在头文件中,或者放在被头文件包含的 .tpp 文件中。

这也是模板库常见「头文件很多」的原因。编译器需要看到模板正文,才能针对 int、std::string 等具体类型生成代码。

封闭的类型集合 variant ​

模板描述「对许多类型执行同一种操作」;std::variant 则描述「一个值可能是列出的几种类型之一」,限制更加严格,有强烈的「选项」含义。

cpp
#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 在任意时刻只保存其中一种类型:

cpp
Notifier notifier{SmsNotifier{}};

用 std::visit 对当前保存的值执行操作:

cpp
void alert(const Notifier &notifier) {
    std::visit(
        [](const auto &concrete_notifier) {
            concrete_notifier.send("Server is down");
        },
        notifier);
}

这组类型是封闭的:新增 PushNotifier 时,必须修改 Notifier 的类型列表,并检查相关代码。它的优点是对象可以直接按值保存,不必为每个值单独动态分配。

因此可以先这样选择:

问题可以先考虑
类型在编译时已知,只要求它支持某些操作函数模板与 concept
类型列表固定,需要保存其中一种值std::variant
类型在运行时决定,未来可扩展新类型虚函数与抽象接口
只是复用实现或组合职责成员、普通函数与组合

小结 ​

  • 模板把类型或常量作为参数,让编译器生成具体代码
  • concept 可以把模板要求写进接口,并改善错误信息
  • 泛型代码按「需要什么能力」工作,不要求类型共享基类
  • 类模板是标准库容器和资源管理类型的基础
  • std::variant 表达编译期确定的封闭类型集合
  • 模板、variant 和虚函数分别对应不同的变化方向
  • 模板定义通常需要放在头文件中