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

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

导语:提到 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两个关键字对于模板参数来说完全等价。

cpp 复制代码
// 两种写法效果一模一样
template <typename T>
class Stack;

template <class T>
class Stack;

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

在类的内部,T就像普通已知类型一样,可以定义成员变量、函数参数、返回值。

注意一个容易踩坑的语法点:

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

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

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

完整头文件实现 stack1.hpp

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

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<目标类型>,显式把类型填进去。

cpp 复制代码
#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)重点!

类模板的成员函数,只有代码里调用到它的时候,编译器才会生成对应实例代码,没调用的函数不会生成机器码。

这个特性带来两大红利:

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

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

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

其他花式用法:

cpp 复制代码
// 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<>,尖括号为空,代表所有模板参数都被完全指定。

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

cpp 复制代码
#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 作为底层的原因。

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

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

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

cpp 复制代码
//基础主模板
template<typename T1, typename T2>
class MyClass{};

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

cpp 复制代码
// 偏特化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*>{};

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

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

⚠️大坑:偏特化二义性!

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

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

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

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

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

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

cpp 复制代码
#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();
}

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

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

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

六、知识点总结📝

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

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

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

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

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

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

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

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

相关推荐
hetao17338371 小时前
2026-10-03 hetao1733837 的刷题记录
c++·算法
kaixin_learn_qt_ing1 小时前
Qt&C++软件开发工程师
c++
(Charon)1 小时前
【C/C++面试】手写内存池:从空闲链表到内存申请与释放
java·开发语言·windows
UIU1141 小时前
-7 / 2 在 C 里是 -3,在 Python 里是 -4,谁算错了?
c++·学习·c#
BenedictHook1 小时前
技能商店 SkillHub:Windows端 AI Skill 管理工具,支持搜索、查看与安装
c++·agent·桌面应用·skill·windows开发·skillhub
派小心.1 小时前
页面关闭前埋点丢失:sendBeacon验收
开发语言·php
无名猿1 小时前
指针算术与数组退化:数组名到底是不是指针
c++·内存管理·标准库·语法基础
汉克老师1 小时前
GESP2026年9月认证C++七级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
Java后端的Ai之路1 小时前
Python 进阶探索25 - difflib模块之文本对比
开发语言·python·django·哈希算法·difflib