C++之旅——模板

C++之旅——模板

模板是 C++ 泛型编程的基石。借助模板,我们可以用类型或编译期的值作为参数,定义一族类、函数或变量。它表达的思路很直观:先把共同的逻辑写出来,再代入具体参数,得到所需的类型或操作。这一篇就从语言机制入手,聊聊 C++ 中的模板。笔记主要整理自 Bjarne Stroustrup 的《A Tour of C++》。

1. 将类参数化

1.1 模板参数和实例化

在声明前加上 template<typename T>,就引入了一个类型模板形参 T,后面的类或函数定义可以使用它。

模板是一种编译期机制,参数化本身不需要额外的运行时类型判断。给模板指定一组实参,就能确定一个特化,例如 Vector<int>;编译器在需要时,根据模板定义生成相应的类或函数定义,这个过程叫作实例化。“特化”指具体版本,“实例化”则强调生成它的过程,二者并不是同义词。

还是拿上一篇的 Vector 来说。原先它只能保存 double,现在把元素类型换成模板参数 T,就能复用同一套容器逻辑。下面只列出拷贝和移动操作的声明,资源管理的思路沿用上一篇:

template<typename T>
class Vector {
private:
    T* elem;   // elem 指向一个包含 sz 个 T 类型元素的数组
    int sz;
public:
    using value_type = T;

    Vector() : Vector(0) {}
    explicit Vector(int s);                // 构造函数:建立不变式,获取资源
    ~Vector() { delete[] elem; }           // 析构函数:释放资源
    Vector(const Vector&);
    Vector& operator=(const Vector&);
    Vector(Vector&&) noexcept;
    Vector& operator=(Vector&&) noexcept;

    T& operator[](int i);                  // 用于非 const Vector
    const T& operator[](int i) const;      // 用于 const Vector
    int size() const { return sz; }

    T* begin() { return elem; }
    T* end() { return elem + sz; }
    const T* begin() const { return elem; }
    const T* end() const { return elem + sz; }
};

相应地,成员函数可以这样定义:

template<typename T>
Vector<T>::Vector(int s)
{
    if (s < 0)
        throw std::length_error("Vector constructor: negative size");
    elem = new T[s]{};
    sz = s;
}

template<typename T>
T& Vector<T>::operator[](int i)
{
    if (i < 0 || size() <= i)
        throw std::out_of_range("Vector::operator[]");
    return elem[i];
}

template<typename T>
const T& Vector<T>::operator[](int i) const
{
    if (i < 0 || size() <= i)
        throw std::out_of_range("Vector::operator[]");
    return elem[i];
}

有了这个类模板,就可以创建不同元素类型的容器:

Vector<char> vc(200);                     // 200 个字符
Vector<std::string> vs(17);               // 17 个字符串
Vector<std::list<int>> vli(45);           // 45 个整数链表

函数也可以接受某个具体的模板特化类型:

void write(const Vector<std::string>& vs)
{
    for (int i = 0; i != vs.size(); ++i)
        std::cout << vs[i] << '\n';
}

对 Vector<double> 来说,元素类型在编译期就已经确定,不需要在每次访问时再判断类型。这也是模板能够兼顾通用性与效率的原因。

1.2 受约束的模板参数

模板通常只对满足特定条件的实参有意义。比如,上面的 Vector 要求元素能够默认构造和销毁;如果使用它的拷贝操作,元素还需要支持相应的拷贝操作。C++20 的概念(concept)可以把这类要求写进模板接口。假定已经定义了一个描述元素基本要求的概念 Element,就可以这样使用:

template<Element T>
class Vector {
private:
    T* elem;   // elem 指向一个包含 sz 个 T 类型元素的数组
    int sz;
    // ...
};

Element 是一个编译期条件,用来检查 T 是否满足指定要求。这种写法中的 T 是受约束的模板形参,含有约束的模板称为受约束模板。不满足约束时,编译器会在使用模板的地方给出诊断,而不必等到深入实现内部才发现问题。概念的具体写法,下一篇再展开。

概念检查的是声明出来的要求,模板内部依赖参数的代码仍需要在实例化时检查。C++20 之前,也可以借助 static_assert、SFINAE 等方式表达限制,只是写起来和读起来都不够直接。概念让这些约定更清楚,而且不会引入运行时检查。

1.3 值模板参数

除了类型参数,模板还可以接受值参数,也常称为非类型模板参数。例如,下面的 Buffer 把元素数量 N 作为编译期参数,数组直接保存在对象内部,不需要另外动态分配:

template<typename T, int N>
struct Buffer {
    constexpr int size() const { return N; }
    T elem[N];
    // ...
};

这里的 N 需要在编译期确定,并且数组长度应为正数。

字符串的情况稍微特殊一些:字符串字面量不能直接作为指针类型的模板实参,但可以先定义一个具有静态存储期的字符数组,再把它的地址传给模板:

template<const char* s>
void outs() { std::cout << s; }

constexpr char arr[] = "Weird workaround!";
void use()
{
    outs<"straightforward use">();    // 错误:不能用字符串字面量作指针模板实参
    outs<arr>();                      // 输出:Weird workaround!
}

从 C++20 开始,还可以用满足结构化类型要求的定长字符串包装类,把字符串内容作为模板参数。

1.4 模板参数推导

每次都显式写出模板实参,难免有些繁琐。从 C++17 开始,类模板实参推导(CTAD)允许编译器根据构造时的实参推导类型:

template<typename T>
class Vector {
public:
    explicit Vector(int);
    Vector(std::initializer_list<T>);   // 在前面的 Vector 中加入此构造函数
    // ...
};

Vector v1 {1, 2, 3};               // 从初始化器元素类型推导 v1 的元素类型:int
Vector v2 = v1;                    // 从 v1 的元素类型推导 v2 的元素类型:int
auto p = new Vector{1, 2, 3};      // p 是一个 Vector<int>*
Vector<int> v3(1);                 // 此处需显式写出元素类型(没有可供推导的元素类型信息)

推导遵循实参本来的类型,并不会猜测我们想要什么。比如,字符串字面量并不等于 std::string:

using namespace std::string_literals;       // 启用 "..."s 字符串字面量

Vector<std::string> vs {"Hello", "World"};   // OK:Vector<std::string>
Vector vs1 {"Hello", "World"};               // OK:推导为 Vector<const char*>(奇怪!)
Vector vs2 {"Hello"s, "World"s};             // OK:推导为 Vector<std::string>
Vector vs3 {"Hello"s, "World"};              // 错误:初始化列表的类型不统一
Vector<std::string> vs4 {"Hello"s, "World"}; // OK:元素类型是显式指定的

这里的构造函数接收 std::initializer_list<T>,所有元素都要为同一个 T 提供一致的推导结果。vs3 因而会推导失败,不会自动寻找一个“公共类型”;显式指定 std::string 后,vs4 中的字符串字面量就可以转换成该类型。

2. 将操作参数化

要让一段操作适用于不同类型,或者接收不同的行为,可以借助下面三种工具:

  • 函数模板
  • 函数对象:一个可以携带数据并像函数一样被调用的对象
  • lambda 表达式:就地编写函数对象的简洁方式

2.1 函数模板

函数模板既可以独立定义,也可以作为成员模板,但成员函数模板不能声明为虚函数。它会根据模板实参生成不同的函数版本,而虚函数需要固定的虚调用接口。不过,类模板中的普通成员函数仍然可以是虚函数。

比如,我们可以为支持范围 for 的序列编写一个求和函数,只要元素能累加到初始值上即可:

template<typename Sequence, typename Value>
Value sum(const Sequence& s, Value v)
{
    for (const auto& x : s)
        v += x;
    return v;
}

Value 和参数 v 让调用者决定累加器的类型与初始值。同一个整数容器,既可以累加到 int,也可以累加到 double:

void user(Vector<int>& vi, std::list<double>& ld,
          std::vector<std::complex<double>>& vc)
{
    int x = sum(vi, 0);                       // int 型 vector 的和(累加器为 int)
    double d = sum(vi, 0.0);                  // int 型 vector 的和(累加器为 double)
    double dd = sum(ld, 0.0);                 // double 型 list 的和
    auto z = sum(vc, std::complex{0.0, 0.0}); // complex<double> 型 vector 的和
}

这些调用都没有显式写出 sum<Sequence, Value>:编译器从传入的序列和初始值推导出了两个模板实参。前面的 Vector 提供了 begin() 和 end(),因此也能参与这里的范围 for。

2.2 函数对象

函数对象(有时称为 functor)是可以像函数一样调用的对象。它不一定要用模板定义,但模板能让同一套调用逻辑适用于不同类型。与普通函数相比,函数对象:

  • 可以把调用需要的状态保存在成员中,例如比较时使用的阈值;
  • 传给算法模板时,具体类型和调用运算符的实现对编译器可见,便于进行内联等优化。

函数对象的核心在于重载函数调用运算符:

template<typename T>
class Less_than {
    const T val;   // 要比较的值
public:
    Less_than(const T& v) : val(v) { }
    bool operator()(const T& x) const { return x < val; }   // 调用运算符
};

函数对象很适合作为算法的参数。比如,让调用方提供一个判断条件,我们就能统计序列中满足条件的元素个数:

template<typename C, typename P>
int count(const C& c, P pred)   // 假设 C 是容器,P 是谓词
{
    int cnt = 0;
    for (const auto& x : c)
        if (pred(x))
            ++cnt;
    return cnt;
}

count() 不需要知道具体的比较规则,只需要逐个调用 pred(x),再根据结果决定是否计数。

谓词是调用结果可以用作布尔条件的函数或函数对象。例如:

void f(const Vector<int>& vec, const std::list<std::string>& lst,
       int x, const std::string& s)
{
    std::cout << "number of values less than " << x << ": "
         << count(vec, Less_than{x}) << '\n';
    std::cout << "number of values less than " << s << ": "
         << count(lst, Less_than{s}) << '\n';
}

这里,Less_than{x} 通过类模板实参推导得到 Less_than<int>,保存阈值 x;Less_than{s} 则得到 Less_than<std::string>,保存阈值 s。真正参与比较的元素,在调用 operator() 时传入。

2.3 lambda 表达式

如果一个判断条件只在这里用一次,专门为它起名、定义一个类,多少有些麻烦。lambda 表达式让我们能在使用处直接写出这个函数对象:

void f(const Vector<int>& vec, const std::list<std::string>& lst,
       int x, const std::string& s)
{
    std::cout << "number of values less than " << x << ": "
         << count(vec, [&](int a) { return a < x; }) << '\n';
    std::cout << "number of values less than " << s << ": "
         << count(lst, [&](const std::string& a) { return a < s; }) << '\n';
}

[&](int a) { return a < x; } 就是一个 lambda 表达式,表达的比较规则与 Less_than<int>(x) 相同。不过,Less_than 保存阈值的副本,这里的 lambda 则通过引用读取 x。

方括号中的内容叫作捕获列表,决定 lambda 如何访问外围的局部变量。[&x] 按引用捕获 x,[x] 保存 x 的副本;[] 不捕获变量,[&] 和 [=] 则分别默认按引用、按值捕获所需的局部变量。也可以只列出需要的几个对象,例如 [x, &s]。

在成员函数中,[this] 捕获的是 this 指针的副本,因此访问的仍是原对象;[*this](C++17)才会复制当前对象本身。

从 C++14 开始,lambda 还可以使用 auto 形参,成为泛型 lambda:

template<class S>
void rotate_and_draw(std::vector<S>& v, int r)
{
    std::for_each(v.begin(), v.end(),
                  [r](auto& s) { s->rotate(r); s->draw(); });
}

这里,auto& 让参数类型根据调用实参推导,编译器会为生成的函数对象提供一个调用运算符模板。[r] 则把旋转角度保存进函数对象。这个例子中的元素需要是指针或智能指针,指向支持 rotate() 和 draw() 的对象。

3. 模板机制

想把模板写得清楚、好用,还需要一些配套机制:

  • 定义依赖模板参数的值:变量模板
  • 为类型与模板起别名:别名模板
  • 编译期分支:if constexpr
  • 在编译期检查类型与表达式是否满足要求:requires 表达式(C++20)

此外,constexpr 函数和 static_assert 也常常参与模板的设计。下面先看看其中几种常用工具。

3.1 变量模板

有时,需要随类型变化的不只是类或函数,还有常量本身。C++14 引入的变量模板就能表达这件事。下面是一个来自流体动力学仿真的例子:

template <class T>
constexpr T viscosity = 0.4;

template <class T>
constexpr space_vector<T> external_acceleration = { T(), T{-9.8}, T() };

auto vis2 = 2 * viscosity<double>;
auto acc = external_acceleration<float>;

这里,space_vector<T> 表示三维向量。viscosity<double> 是 double 类型的常量,external_acceleration<float> 则是元素类型为 float 的三维加速度向量。

3.2 别名

类模板经常会为与模板参数相关的类型提供别名。例如,可以给容器的元素类型起一个统一的名字:

template<typename T>
class Vector {
public:
    using value_type = T;
    // ...
};

标准序列容器都用 value_type 表示元素类型。遵循这个约定,就可以在泛型代码中用统一的方式取得元素类型:

template<typename C>
using Value_type = typename C::value_type;   // C 的元素类型

template<typename Container>
void algo(Container& c)
{
    Vector<Value_type<Container>> vec;       // 在此保存结果
    // ...
}

别名模板还可以固定一部分模板实参,给常用的组合起个短名字:

template<typename Key, typename Value>
class Map {
    // ...
};

template<typename Value>
using String_map = Map<std::string, Value>;

String_map<int> m;   // m 的类型是 Map<std::string, int>

3.3 编译期 if

有些实现方式是否适用,要看具体的模板实参。C++17 的 if constexpr 允许我们在编译期选择分支:

template<typename T>
void update(T& target)
{
    if constexpr (std::is_trivially_copyable_v<T>)
        simple_and_fast(target);   // 用于可平凡复制的类型
    else
        slow_and_safe(target);     // 用于更复杂的类型
}

std::is_trivially_copyable_v<T> 是标准库 <type_traits> 中的类型特征,用来判断 T 是否可平凡复制,具体含义留到后面的篇目再展开。这里用它来选择两种不同的更新方式。

关键在于:模板实例化时,条件确定后,未选中的分支不会被实例化,因此两个分支可以使用不同的、依赖于 T 的操作。两个分支仍会被解析,与模板参数无关的错误也仍会被检查。

参考资料

posted @ 2026-10-29 22:23  JaaaLmg  阅读(3)  评论(0)    收藏  举报