欢迎光临
我们一直在努力

C++ Templates 03:手写一个Stack看透模板核心机制

C++ Templates 03:手写一个Stack看透模板核心机制

  • Bilibili 同步视频
  • 一、实现基础版 Stack 类模板
    • 模板声明小细节
    • 完整头文件实现 stack1.hpp
  • 二、使用类模板:神奇的惰性实例化😴
    • ✨惰性实例化(Lazy Instantiation)重点!
  • 三、全特化:给特定类型开小灶🍳
  • 四、局部特化(偏特化):部分参数固定,保留泛型🤔
  • 五、模板默认实参:给模板参数设置默认值🎁
  • 六、知识点总结📝

导语:提到 C++ 泛型编程,模板绝对是绕不开的重头戏。函数模板解决了函数逻辑复用,而类模板则把复用能力带到了类与容器层面。我们日常使用的std::vector、std::map全都是类模板的杰作。今天我们就从手写一个栈容器Stack出发,刨析类模板的声明、实例化、特化、偏特化、默认模板参数这些核心知识点,帮你打通泛型编程的任督二脉。

在没有模板的年代,如果我们想要实现 int 栈、string 栈、指针栈…… 怎么办?只能复制粘贴一份类代码,把里面的元素类型挨个手动替换。写三套就已经让人头大,类型一多直接就是灾难现场,重复代码满天飞,改一个 bug 就要同步修改 N 份文件🤯。

类模板的出现完美解决了这个痛点:我们只写一份类的逻辑,不指定具体元素类型;等到使用的时候再告诉编译器实际类型,编译器自动帮我们生成对应版本的类代码。下面就以经典的 Stack 栈结构为例子,一步步拆解类模板的全部玩法。

Bilibili 同步视频

C++ Templates 03:手写一个Stack看透模板核心机制

一、实现基础版 Stack 类模板

栈遵循后进先出 (LIFO) 规则,核心接口:push入栈、pop出栈、top获取栈顶、empty判断是否为空。我们直接复用标准库std::vector作为底层存储,就不用自己重复造轮子管理内存、拷贝逻辑,专注模板语法本身。

模板声明小细节

写类模板开头必须带上template<…>,typename和class两个关键字对于模板参数来说完全等价。

// 两种写法效果一模一样
template <typename T>
class Stack;

template <class T>
class Stack;

💡小提示:业界习惯,参数声明优先用typename,语义上更突出 “这是一个类型”;class只是历史遗留兼容写法。

在类的内部,T就像普通已知类型一样,可以定义成员变量、函数参数、返回值。 注意一个容易踩坑的语法点:

  • 拷贝构造、赋值运算符这类地方,完整类型是Stack<T>,必须带上模板参数;

  • 构造函数、析构函数写类名的时候,直接写Stack就够,不需要加<T>。

template <typename T>
class Stack{
public:
Stack(Stack<T> const&); // 拷贝构造,完整类型Stack<T>
Stack<T>& operator=(Stack<T> const&); // 赋值重载
};

完整头文件实现 stack1.hpp

📌重要知识点:类模板的声明和定义通常都放在头文件。编译器需要看到完整源码,才能在实例化的时候生成对应代码。如果把实现丢到 cpp,很容易报链接错误。

#include <vector>
#include <stdexcept>

template <typename T>
class Stack {
private:
std::vector<T> elems; // 底层存储容器
public:
void push(T const& elem); // 入栈:拷贝元素压入栈顶
void pop(); // 出栈:删除栈顶,不返回元素
T top() const; // 获取栈顶元素拷贝
bool empty() const { // 判断栈是否为空,类内实现默认inline
return elems.empty();
}
};

// 类外实现成员函数,必须重写template<typename T>,限定符 Stack<T>::
template <typename T>
void Stack<T>::push(T const& elem)
{
elems.push_back(elem);
}

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

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

❗为什么pop()不直接返回删除的元素? 这里涉及异常安全。假设 pop 同时做 “取出元素 + 删除元素”,如果拷贝返回元素的过程抛出异常,元素已经从容器删掉,但返回值没交付给调用方 —— 直接造成数据丢失。C++ 标准库的设计哲学:pop只管删,top负责拿元素,把职责拆分,规避异常灾难。

当然如果你业务场景完全不在乎异常安全,也可以自己写一个返回元素的 pop,只是不推荐作为通用库接口。

二、使用类模板:神奇的惰性实例化😴

写好了模板,它本身不是一个实实在在的类,只是一份 “代码蓝图”。只有我们指定模板实参,编译器才会根据蓝图生成真正可用的类。

使用语法:Stack<目标类型>,显式把类型填进去。

#include<iostream>
#include<string>
#include<cstdlib>
#include"stack1.hpp"

int main()
{
try
{
Stack<int> int_stack; // int类型栈
Stack<std::string> str_stack; // string类型栈

int_stack.push(666);
std::cout << int_stack.top() << "n";

str_stack.push("Hello Template!");
std::cout << str_stack.top() << "n";

str_stack.pop();
str_stack.pop(); // 空栈pop,抛出异常
}
catch (std::exception const& e)
{
std::cerr << "捕获异常:" << e.what() << std::endl;
return EXIT_FAILURE;
}
}

✨惰性实例化(Lazy Instantiation)重点!

类模板的成员函数,只有代码里调用到它的时候,编译器才会生成对应实例代码,没调用的函数不会生成机器码。 这个特性带来两大红利:

  • 节省编译体积:假如你的模板有 10 个成员,业务只用到 3 个,就只会生成 3 份函数代码,不会把全套代码塞进二进制。

  • 类型兼容性提升:假设模板里面有一个排序函数依赖operator<,只要你永远不去调用这个排序接口,就算传入的自定义类型没有实现小于运算符,模板依旧可以正常编译运行!

  • 举个例子:上面代码中,str_stack调用了pop(),于是生成Stack<std::string>::pop();但int_stack没有调用 pop,就不会产生 int 版本 pop 的实例。

    其他花式用法:

    // typedef给模板实例起别名,简化书写,只是别名,不是新类型
    typedef Stack<int> IntStack;
    IntStack arr[10]; // 10个int栈的数组

    // 模板参数可以是指针,甚至是另一个模板实例(栈套栈)
    Stack<float*> ptr_stack;
    Stack<Stack<int>> stack_of_stack;
    // ⚠️C++11之前 >>必须加空格!老编译器会把>>识别成右移运算符 >>

    三、全特化:给特定类型开小灶🍳

    有时候通用模板逻辑对某一个特定类型并不合适。比如针对std::string,我不想用 vector,我想换deque作为底层容器,做一套完全不一样的实现。这时候就轮到全特化出场。

    全特化语法标记 template<>,尖括号为空,代表所有模板参数都被完全指定。

    重要提醒:一旦做类的全特化,这个类的所有成员函数都必须自己重新实现一遍,不再复用主模板任何代码。特化版本是一份完全独立的类。

    #include <deque>
    #include <string>
    #include <stdexcept>

    // 针对std::string全特化Stack
    template<>
    class Stack<std::string>
    {
    private:
    std::deque<std::string> elems; // 换deque作为底层容器
    public:
    void push(std::string const& elem);
    void pop();
    std::string top() const;
    bool empty() const { return elems.empty(); }
    };

    // 全特化类的成员函数,不需要再写template<typename T>
    void Stack<std::string>::push(std::string const& elem)
    {
    elems.push_back(elem);
    }

    void Stack<std::string>::pop()
    {
    if(elems.empty()){
    throw std::out_of_range("Stack<std::string>::pop(): empty stack");
    }
    elems.pop_back();
    }

    std::string Stack<std::string>::top() const
    {
    if(elems.empty()){
    throw std::out_of_range("Stack<std::string>::top(): empty stack");
    }
    return elems.back();
    }

    这里底层容器换成 deque,并不是随便玩的:deque 删除元素可以主动释放内存,扩容的时候不需要移动全部元素,这也是标准库std::stack默认选择 deque 作为底层的原因。

    四、局部特化(偏特化):部分参数固定,保留泛型🤔

    全特化是把所有参数写死;而局部特化,只固定一部分模板参数,剩下的依旧保留模板可变参数。

    拿一个双参数主模板举例:

    //基础主模板
    template<typename T1, typename T2>
    class MyClass{};

    我们可以写出多种偏特化版本:

    // 偏特化1:两个模板参数是完全相同的类型
    template<typename T>
    class MyClass<T, T>{};

    // 偏特化2:第二个参数固定为int,第一个参数依旧随便传
    template<typename T>
    class MyClass<T, int>{};

    // 偏特化3:两个参数都必须是指针类型
    template<typename T1, typename T2>
    class MyClass<T1*, T2*>{};

    编译器会根据传入的类型,去匹配最合适的特化版本:

    MyClass<int, float> m1; // 匹配原始主模板
    MyClass<float, float> m2;// 匹配 MyClass<T,T>
    MyClass<float, int> m3; // 匹配 MyClass<T,int>
    MyClass<int*,float*>m4; // 匹配双指针偏特化

    ⚠️大坑:偏特化二义性! 如果传入的类型同时满足两个偏特化条件,编译器直接报二义性编译错误。

    MyClass<int, int> obj;
    // 既匹配MyClass<T,T>,又匹配MyClass<T,int>,编译器不知道选哪个!

    解决办法:再写一个更加精准的偏特化版本消除歧义。

    五、模板默认实参:给模板参数设置默认值🎁

    类模板支持给模板参数提供默认类型,而且默认参数还可以引用前面已经定义过的模板参数。

    我们改造 Stack,把底层容器抽成第二个模板参数,默认使用std::vector<T>。

    #include <vector>
    #include <stdexcept>

    // CONT是容器类型,拥有默认实参 std::vector<T>
    template <typename T, typename CONT = std::vector<T>>
    class Stack
    {
    private:
    CONT elems;
    public:
    void push(T const& elem);
    void pop();
    T top() const;
    bool empty() const { return elems.empty(); }
    };

    // 类外实现,两个模板参数都必须写全 template<typename T, typename CONT>
    template<typename T, typename CONT>
    void Stack<T,CONT>::push(T const& elem)
    {
    elems.push_back(elem);
    }

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

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

    使用的时候非常灵活,可以偷懒用默认容器,也可以手动替换底层容器:

    Stack<int> st1; // 省略第二个参数,自动使用 vector<int>
    // 手动指定底层容器为deque
    Stack<double, std::deque<double>> st2;

    看到这里,你就懂标准库std::stack<T,Container=deque<T>>的设计思路了。

    六、知识点总结📝

  • 类模板本质是蓝图,不是真实类:使用时传入模板实参,编译器才实例化生成真正的类代码。

  • 惰性实例化:只有被调用的成员函数才会生成实例,既省体积,也放宽类型约束。

  • 全特化 template<>:针对某一个确定类型,完整重写整个类,所有成员全部重新实现。

  • 局部偏特化:固定部分模板参数,剩余保持泛型;要警惕多版本匹配带来的二义性编译错误。

  • 模板缺省实参:类模板参数可以设置默认类型,默认参数可以依赖前面的模板参数。

  • 历史语法坑:C++11 之前嵌套模板的>>中间必须打空格,避免被解析成右移运算符。

  • 绝大多数场景,类模板的声明与实现写在头文件;分离编译的方案属于进阶技巧。

  • C++ Templates 03:手写一个Stack看透模板核心机制

    写在最后:泛型编程是 C++ 的一大魅力,类模板让我们摆脱大量重复容器代码。但是模板也不是银弹,复杂的特化、偏特化很容易制造晦涩难懂代码,工程实践中适度使用,才是王道。

    赞(0)
    未经允许不得转载:171主机测评 » C++ Templates 03:手写一个Stack看透模板核心机制
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址