Skip to content

骗你的,还有下一关:类模板 ​

在上一篇呢,我们终于费劲巴拉的,学会了函数模板,当然这样说显得太没成就感了。我用这个方式带您复习一下:我们成功的让我们的函数走向了泛型编程的时代,我们终于有能力写出来接收任意类型参数,甚至返回任意类型参数的函数了。

问题在于,程序设计,不只是算法,还有数据结构呢!咱们要是想要一个泛化的"数据结构"呢?比如一个栈,它的 push、pop、top 操作对各种类型来说逻辑完全一样,但栈内部需要存储一组同类型的元素,这个"类型"在编写类的时候就决定了。C++ 标准库之所以能提供 std::vector<int>、std::vector<std::string> 这样灵活的容器,靠的就是类模板(class template)。这也是本篇的主角!它让咱们把类型参数化到整个类的层面:成员变量、成员函数、甚至嵌套类型都可以使用模板参数。

类模板的基本语法 ​

咱们定义类模板从 template <typename T> 开始,后面紧跟类的定义。所有出现 T 的地方在实例化时都会被替换为实际类型,包括成员变量、成员函数参数、返回类型,甚至友元声明。

C++
template <typename T>
class Stack
{
public:
    void push(const T& value);
    void pop();
    T& top();
    const T& top() const;
    bool empty() const;
    std::size_t size() const;

private:
    std::vector<T> data_;
};

咱们看 data_ 是 std::vector<T> 类型——模板里面套模板,这在 C++ 中非常常见。实例化时,Stack<int> 的 data_ 就是 std::vector<int>,Stack<std::string> 的 data_ 就是 std::vector<std::string>。

咱们使用类模板时,必须提供具体的模板参数(C++17 的 CTAD 场景稍后讨论):

C++
Stack<int> int_stack;           // T = int
Stack<double> double_stack;     // T = double
Stack<std::string> str_stack;   // T = std::string

敲桌子孩子们。这里和函数模板的一个重要区别:函数模板的参数类型通常可以通过调用参数推导出来,但类模板不行——实例化对象时编译器没法从构造函数推导出 T(C++17 之前),您必须老老实实写明 Stack<int>。

成员函数的类内与类外定义 ​

类模板的成员函数可以在类体内直接定义,也可以在类体外定义。类体内定义和普通类一样,没什么特别的。但咱们类体外定义就需要注意了:每一个在类体外定义的成员函数,都必须带上完整的模板头部。

咱们把简单的成员函数直接写在类体内即可,这也是最常见的做法:

C++
template <typename T>
class Stack
{
public:
    bool empty() const { return data_.empty(); }
    std::size_t size() const { return data_.size(); }

private:
    std::vector<T> data_;
};

咱们类外定义时,需要用 Stack<T>:: 来限定成员函数所属的类,而且函数前面必须加上模板头部 template <typename T>。每一个在类外定义的成员函数都需要这样做,一个都不能漏:

展开代码收起代码共 32 行
C++
template <typename T>
void Stack<T>::push(const T& value)
{
    data_.push_back(value);
}

template <typename T>
void Stack<T>::pop()
{
    if (data_.empty()) {
        throw std::out_of_range("Stack<>::pop(): empty stack");
    }
    data_.pop_back();
}

template <typename T>
T& Stack<T>::top()
{
    if (data_.empty()) {
        throw std::out_of_range("Stack<>::top(): empty stack");
    }
    return data_.back();
}

template <typename T>
const T& Stack<T>::top() const
{
    if (data_.empty()) {
        throw std::out_of_range("Stack<>::top(): empty stack");
    }
    return data_.back();
}

Stack<T>:: 中的 <T> 不能省:因为 Stack 本身是一个模板,只有 Stack<T> 才是具体的类。咱们要是有多个模板参数,比如 template <typename T, typename Alloc>,类外定义就要写 Stack<T, Alloc>::,模板头部也要完整带上。

模板参数的三种形式 ​

C++ 的模板体系支持三种参数:类型参数、非类型参数和模板模板参数。这一节咱们看前两种。

类型参数——您一直在用的形式 ​

typename T(或 class T)就是类型参数,可以有多个:

C++
template <typename Key, typename Value>
class Dictionary
{
    // ...
};

std::map<Key, Value> 就是这种模式。

非类型参数——编译期的常量 ​

咱们看非类型模板参数(non-type template parameter)是一个编译期常量值,而不是类型。最常见的用法是指定容器容量:

C++
template <typename T, std::size_t kCapacity>
class RingBuffer
{
public:
    void push(const T& value)
    {
        buffer_[write_index_] = value;
        write_index_ = (write_index_ + 1) % kCapacity;
    }

    // ...

private:
    std::array<T, kCapacity> buffer_;
    std::size_t write_index_ = 0;
};

kCapacity 直接参与数组声明,咱们实例化时必须提供编译期已知的值:

C++
RingBuffer<int, 16> buffer;        // 容量为 16 的 int 环形缓冲区
RingBuffer<double, 256> big_buf;   // 容量为 256 的 double 环形缓冲区

非类型参数只能是整型、枚举、指针、引用,或 C++20 起的浮点数和类类型。大多数情况下咱们用整型就足够了。

默认模板参数——从右向左 ​

咱们的模板参数也支持默认值,从右向左连续提供:

C++
template <typename T, typename Container = std::vector<T>>
class Stack
{
public:
    void push(const T& value) { data_.push_back(value); }
    // ...

private:
    Container data_;
};

Stack<int> s1;                                  // Container 默认为 std::vector<int>
Stack<int, std::deque<int>> s2;                 // Container 显式指定为 std::deque<int>

咱们看标准库的 std::stack 就是这种设计:第二参数默认为 std::deque<T>,可以换成 std::vector<T> 或 std::list<T>。

快速了解 CTAD——让编译器推导模板参数(C++17) ​

C++17 引入了 CTAD(Class Template Argument Deduction),让编译器根据构造函数参数自动推导模板参数类型。最常见的例子:std::vector v = {1, 2, 3} 会被推导为 std::vector<int>,std::pair p(1, 2.5) 会被推导为 std::pair<int, double>。对于咱们自己写的类模板,如果构造函数参数能唯一确定模板参数类型,CTAD 也能工作。不过 CTAD 的推导规则比较复杂,有时结果和预期不一样。咱们初学阶段了解这个特性就行,不确定时老老实实写明模板参数。

上号——实现一个完整的泛型栈 ​

现在咱们把前面的内容综合起来,实现一个完整的泛型栈。底层用 std::vector<T> 存储,提供 push、pop、top、empty、size 五个操作。所有代码写在一个头文件中——模板代码必须放在头文件里,原因稍后解释。

展开代码收起代码共 55 行
C++
// stack.hpp
// 编译: g++ -Wall -Wextra -std=c++17 stack_demo.cpp -o stack_demo
#pragma once

#include <stdexcept>
#include <vector>

/// @brief 泛型栈,底层使用 std::vector 存储
/// @tparam T 元素类型
template <typename T>
class Stack
{
public:
    /// @brief 将元素压入栈顶
    void push(const T& value) { data_.push_back(value); }

    /// @brief 弹出栈顶元素
    /// @throws std::out_of_range 栈为空时抛出异常
    void pop()
    {
        if (data_.empty()) {
            throw std::out_of_range("Stack::pop(): stack is empty");
        }
        data_.pop_back();
    }

    /// @brief 访问栈顶元素(可修改)
    /// @throws std::out_of_range 栈为空时抛出异常
    T& top()
    {
        if (data_.empty()) {
            throw std::out_of_range("Stack::top(): stack is empty");
        }
        return data_.back();
    }

    /// @brief 访问栈顶元素(只读)
    /// @throws std::out_of_range 栈为空时抛出异常
    const T& top() const
    {
        if (data_.empty()) {
            throw std::out_of_range("Stack::top(): stack is empty");
        }
        return data_.back();
    }

    /// @brief 判断栈是否为空
    bool empty() const { return data_.empty(); }

    /// @brief 返回栈中元素数量
    std::size_t size() const { return data_.size(); }

private:
    std::vector<T> data_;
};

所有操作都委托给内部的 std::vector<T> 来完成。pop 和 top 在栈为空时抛出 std::out_of_range 异常,这和标准库 std::stack 的行为不同——标准库在空栈上是未定义行为(UB)。咱们选择抛异常是为了让错误更容易被发现。

接下来咱们写测试程序,用三种不同类型来实例化 Stack:

展开代码收起代码共 52 行
C++
// stack_demo.cpp
#include <iostream>
#include <string>
#include "stack.hpp"

int main()
{
    // --- Stack<int> ---
    std::cout << "=== Stack<int> ===\n";
    Stack<int> int_stack;
    int_stack.push(10);
    int_stack.push(20);
    int_stack.push(30);
    std::cout << "size: " << int_stack.size() << "\n";
    std::cout << "top:  " << int_stack.top() << "\n";
    int_stack.pop();
    std::cout << "after pop, top: " << int_stack.top() << "\n";
    std::cout << "empty: " << std::boolalpha << int_stack.empty()
              << "\n";

    // --- Stack<double> ---
    std::cout << "\n=== Stack<double> ===\n";
    Stack<double> dbl_stack;
    dbl_stack.push(3.14);
    dbl_stack.push(2.718);
    std::cout << "size: " << dbl_stack.size() << "\n";
    std::cout << "top:  " << dbl_stack.top() << "\n";
    dbl_stack.pop();
    std::cout << "after pop, top: " << dbl_stack.top() << "\n";

    // --- Stack<std::string> ---
    std::cout << "\n=== Stack<std::string> ===\n";
    Stack<std::string> str_stack;
    str_stack.push("hello");
    str_stack.push("world");
    str_stack.push("template");
    std::cout << "size: " << str_stack.size() << "\n";
    std::cout << "top:  " << str_stack.top() << "\n";
    str_stack.pop();
    std::cout << "after pop, top: " << str_stack.top() << "\n";

    // --- 异常测试 ---
    std::cout << "\n=== Exception test ===\n";
    Stack<int> empty_stack;
    try {
        empty_stack.pop();
    } catch (const std::out_of_range& e) {
        std::cout << "caught: " << e.what() << "\n";
    }

    return 0;
}

验证运行 ​

这份完整代码就在下面,点「动手试一试」直接跑,不用开终端(在线版把 stack.hpp 和 stack_demo.cpp 合成了一个文件):

Compiler Explorer

实战演练:stack_demo.cpp

在线运行泛型栈 Stack,对照下面的核对点。试着再压几个元素,或者故意对空栈调一次 top,看看异常长什么样。

code/examples/vol1/18_class_templates.cpp

咱们核对关键结果:Stack<int> 压入三个元素后 top 是 30(最后压入的),pop 一次后 top 变成 20,正确。Stack<double> 和 Stack<std::string> 的行为也符合 LIFO(后进先出)预期。空栈调用 pop 时正确抛出 std::out_of_range 异常。

模板的三个暗坑 ​

写类模板时,有三个坑几乎是所有 C++ 程序员都踩过的。咱们逐个拆解。

模板声明和实现必须放在头文件中。 您可能注意到了,咱们把 Stack 的声明和实现全部放在了 stack.hpp 头文件中,没有分成 .hpp 和 .cpp。这不是偷懒,这是由 C++ 的编译模型决定的。每个 .cpp 文件独立编译,编译器在处理一个编译单元时只需要看到声明就能编译通过,具体实现留到链接阶段再解决。但模板不同——模板本身不是代码,它是"代码配方"。编译器必须看到模板的完整定义才能实例化出具体的代码。如果把声明放在 .h、实现放在 .cpp,其他编译单元在实例化 Stack<int> 时只能看到声明、找不到实现,链接时就会报 undefined reference 错误。最常见的做法就是把所有代码写在头文件里。您要是确实想分离声明和实现,可以使用显式实例化:在 .cpp 文件中写 template class Stack<int>;,强制编译器在这个编译单元内生成 Stack<int> 的所有成员函数。但这样一来,模板就只能支持咱们显式列出的那些类型了,失去了泛型的灵活性。

模板错误信息又臭又长。 因为模板实例化发生在编译期,如果模板代码内部有错误,编译器会把模板展开后的完整上下文都塞进错误信息里。一个简单的类型不匹配可能产生上百行的报错。C++20 的 Concepts 在很大程度上改善了这个问题——它让咱们在模板参数上添加约束,错误信息会直接告诉咱们"哪个约束不满足"而不是"在这一大坨实例化链中某个操作符不匹配"。不过 Concepts 咱们后面才会讲到,现阶段遇到模板报错,先看最后一行,找到咱们自己的调用代码,然后往回推导类型。

代码膨胀(code bloat)。 咱们要是用 10 种不同的类型实例化了 Stack,编译器就会生成 10 份完整的代码,每份都包含 push、pop、top、empty、size 的完整实现。对于小型类模板这通常不是问题,但对于大型模板或者嵌入式平台来说,代码体积的增长可能是不可接受的。缓解策略包括:将不依赖模板参数的代码提取到非模板基类中、使用 if constexpr 在编译期分支以减少冗余实例化、以及在库级别通过显式实例化控制哪些版本被编译。

练习 ​

练习 1:实现 Pair<T, U> ​

请您实现一个泛型的 Pair 类模板,存储两个不同类型的值。要求提供 first() 和 second() 访问器(const 和非 const 版本),以及一个 swap(Pair& other) 成员函数用来交换两个 Pair 对象的内容。用 Pair<int, std::string> 和 Pair<double, char> 分别测试。提示:类模板可以接受多个类型参数,写法是 template <typename T, typename U>。

练习 2:实现 RingBuffer<T, N> ​

请您实现一个环形缓冲区类模板,使用非类型模板参数 std::size_t kCapacity 指定容量。要求提供 push(const T&) 写入元素、pop() 读取并移除最早写入的元素、full() const 和 empty() const 判断状态、以及 size() const 返回当前元素数量。底层使用 std::array<T, kCapacity> 存储,用两个索引(读和写)追踪位置。环形缓冲区的核心思路是用取模运算 % kCapacity 让索引在数组末尾回绕到头部。

pdf-latest-4-g85128cc · 85128cc · 2026-10-05