C++ 泛型(auto, 函数/类模板, decltype) 码农论坛

C++ 泛型(auto, 函数/类模板, decltype) 码农论坛

bilibili.com/video/BV1et4y1u75A/

1. 自动类型推导(auto

1.1 背景与演变

  • C 和 C++98: auto 关键字曾用作存储类型指示符,用于修饰局部变量(表示变量具有自动存储期)。由于局部变量默认即为自动存储期,此用法在实际开发中极少使用。
  • C++11 及以后: C++11 赋予了 auto 全新的含义。它废弃了原有的存储类型指示功能,转而作为一个类型指示符,指示编译器在编译期间根据初始化表达式自动推导变量的数据类型。
  • 编译环境: 在 Linux 平台下,若使用较老的编译器,编译时需要添加 -std=c++11(或更高版本)参数。现代编译器(如较新的 GCC/Clang)通常默认已支持 C++14 或更高标准。

1.2 使用限制与注意事项⚠️

  • 必须初始化: auto 声明的变量在定义时必须进行初始化。初始化的右值可以是具体的数值、表达式或函数的返回值。

  • 不能直接声明数组: 例如 auto arr[] = {1, 2, 3}; 在 C++ 中是非法的编译器行为。

  • 不能定义类的非静态成员变量: 类的非静态成员变量必须具备确定的类型,不能在类定义时使用 auto 推导。

  • 函数形参的限制(有版本差异): 在 C++11/14/17 中,普通函数的形参不能使用 auto

    • 扩展: C++14 允许在 Lambda 表达式的参数中使用 auto(泛型 Lambda);而到了 C++20,普通函数的参数也允许使用 auto(即简写函数模板)。

1.3 核心用途

以下是 auto 的高频且推荐的用途:

  1. 代替冗长复杂的变量声明: 尤其在处理 STL 容器时,避免手写极长的类型名(例如用 auto it = map.begin(); 代替 std::map<int, std::vector<std::string>>::const_iterator it = map.begin();)。

  2. 在模板编程中: 用于声明类型严重依赖模板参数的中间变量,简化代码逻辑。

  3. 函数返回值类型推导: 配合尾置返回类型(C++11 的 auto func() -> decltype(...)),或直接让编译器推导返回值(C++14 及以后)。

  4. 用于 Lambda 表达式: 用于接收 Lambda 表达式生成的匿名闭包对象,或者在 C++14 中用于声明泛型 Lambda 的形参。

2. 函数模板

2.1 函数模板的基本概念

函数模板 (Function Template) 是通用的函数描述,它使用泛型(任意数据类型)来定义函数,从而实现代码复用。

在编译阶段,编译器会根据传入实参的数据类型结合函数模板,自动推导并生成对应特定类型的函数定义。这个将模板生成具体函数的过程,被称为模板的实例化 (Instantiation)

代码示例:创建一个交换变量的函数模板

template <typename T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp;
}

int main() 
{
    int x = 10, y = 20;
    
    // 1. 自动类型推导 (隐式实例化)
    Swap(x, y); 
    
    // 2. 显式指定类型 (显式实例化)
    Swap<int>(x, y); 
    
    return 0;
}
  • 关于 typenameclass
    • 在 C++98 标准引入 typename 之前,C++ 使用 class 关键字来声明模板类型(如 template <class T>)。
    • 虽然在声明模板参数时两者等价且均兼容,但现代 C++ 推荐使用 typename。因为它能更直观地表达 T 可以是任何数据类型(包括 intdouble 等基础类型),而不仅限于类(Class)。

2.2 函数模板的注意事项

2.2.1 函数模板的适用范围与类型推导规则

  1. 适用范围

    可以为类的普通成员函数构造函数创建模板,但不能是虚函数(Virtual)和析构函数

    • 虚函数:虚函数表(vtable)的大小在编译期需要确定,而模板会导致产生的函数数量不确定,无法在虚表中预先分配空间。
    • 析构函数:析构函数没有参数和返回值,不涉及参数类型的泛化。
  2. 类型必须能明确推导

    使用函数模板时,编译器必须能够明确推断出所有的模板参数类型

  • 如果通过实参无法推断(例如模板类型仅用于返回值,或者没有参数),则必须在调用时显式指定
  • e.g. template<typename T> void func() {},调用时必须写 func<int>();,不能只写 func();
  1. 操作兼容性:

    推导出的数据类型必须支持模板内部的所有操作。

  • e.g. 如果模板内部有 a > b 的比较,当传入自定义类的对象时,该类就必须重载 > 运算符,否则编译会报错。(注:C++20 引入了 Concepts(概念),可以用来显式限制传入类型必须满足的条件。
  1. 隐式类型转换规则

    • 自动类型推导:不会发生隐式类型转换,要求实参类型与模板参数严格匹配。

      • log(inum, dnum); // ❌ 报错,编译器无法确定 Tint 还是 double
    • 显式指定类型:在显式指定模板参数后,允许进行隐式类型转换。

      • log<int>(inum, dnum); // ✅ 成功,double 类型的 dnum 会被隐式转换为 int
    #include <iostream>
    
    template <typename T>
    void log(T t1, T t2)
    {
        std::cout << "t1 = " << t1 << ", "
                  << "t2 = " << t2 << std::endl;
    }
    
    int main()
    {
        int inum = 10;
        double dnum = 21.1;
        // log(inum, dnum); // 报错:自动推导阶段不允许隐式转换
        log<int>(inum, dnum); // 输出:t1 = 10, t2 = 21(dnum被转换为int)
        return 0;
    }
    
  2. 支持多参数与混合参数

    函数模板支持多个不同的通用数据类型参数(如 template <typename T1, typename T2>),也支持泛型与非泛型(普通数据类型)混合使用。

2.2.2 函数模板与重载函数的调用匹配优先级

当普通函数和函数模板同名且均满足调用条件时,遵循以下优先级原则

  1. 完全匹配的普通函数优先:如果普通函数与实参类型完全匹配(无需隐式转换),编译器优先调用普通函数,而不会去实例化模板。

  2. 模板产生更优匹配:如果模板实例化的版本比普通函数更匹配实参类型(例如普通函数需要隐式转换,而模板可以精确推导),则优先使用模板。

  3. 强制使用模板:如果想跳过普通函数,可以使用空模板参数列表 Swap<>(a, b); 显式强制调用模板。

#include <iostream>

void Swap(int& a, int& b)
{
    int tmp = a;
    a = b;
    b = tmp;
    std::cout << "normal Swap(int&, int&)" << std::endl;
}

template <typename T>
void Swap(T& a, T& b)
{
    T tmp = a;
    a = b;
    b = tmp;
    std::cout << "template Swap(T&, T&)" << std::endl;
}

int main()
{
    int num1 = 10, num2 = 20;
    Swap(num1, num2);   // 调用普通函数:匹配度高且效率优
    Swap<>(num1, num2); // 强制调用模板:使用空参数列表
    return 0;
}

2.2.3 函数模板重载时的最佳匹配原则

当存在多个模板重载版本时,编译器会选择“最匹配”的一个。

  • case1: 只有一个模板时,显式指定部分参数后的类型降级匹配

    • 当只有一个 T 的模板版本,且显式指定了 Tint 时,所有参数都会被强制视为 int
    #include <iostream>
    
    void log(int a, int b)
    {
        std::cout << "normal log(int, int): " << a << ", " << b << std::endl;
    }
    
    template <typename T>
    void log(T a, T b)
    {
        std::cout << "template log(T, T): " << a << ", " << b << std::endl;
    }
    
    int main()
    {
        int num = 10;
        char ch = 'a';
        // 此时函数模板只有: log(T, T)
        log<int>(num, ch); 
        // 分析:显式指定 T 为 int,模板实例化为 void log(int, int)
        // char 类型的 'a' 被隐式转换为 int 类型的 97
        // 输出: template log(T, T): 10, 97
        return 0;
    }
    
  • case2: 多参数模板提供的精确匹配

    • 当存在支持多类型参数的模板 log(T1, T2) 时,编译器能为不同类型的实参提供更精确的实例化版本,从而避免隐式转换。
    #include <iostream>
    
    void log(int a, int b)
    {
        std::cout << "normal log(int, int): " << a << ", " << b << std::endl;
    }
    
    template <typename T>
    void log(T a, T b)
    {
        std::cout << "template log(T, T): " << a << ", " << b << std::endl;
    }
    
    template <typename T1, typename T2>
    void log(T1 a, T2 b)
    {
        std::cout << "template log(T1, T2): " << a << ", " << b << std::endl;
    }
    
    int main()
    {
        int num = 10;
        char ch = 'a';
        log<int>(num, ch); 
        // 分析:虽然显式指定了第一个参数 T1 为 int,但第二个参数 T2 仍可推导为 char
        // log(T1, T2) 实例化为 log(int, char),无需对 'a' 进行转换
        // 相比 log(T, T) 实例化的 log(int, int),此版本匹配度更高
        // 输出: template log(T1, T2): 10, a
        return 0;
    }
    

2.3 函数模板的显示特化/特例化/具体化(Function Template Specialization)

可以为特定类型提供一个具体的函数定义。当编译器找到与函数调用完全匹配的特化定义时,将优先使用该特化版本,而不再尝试从主模板中进行实例化。

在 C++ 中,函数模板的显式特化(Explicit Specialization)必须基于一个已声明的主模板(Primary Template)。如果不提供主模板的声明,编译器就没有“特化”的依据,会将其视为普通函数声明或抛出语法错误。

// 0. 已声明的主模板(特化前必须存在)
template <typename T...>
返回类型 函数模板名(参数列表);

// 方式1. 函数模板特化:声明与定义写在一起
template <> 
返回类型 函数模板名<特定数据类型>(参数列表)
{ 
    // 函数体 
}

// 方式2. 函数模板特化:声明与定义分开
// 声明(通常放在头文件中)
template <> 
返回类型 函数模板名<特定数据类型>(参数列表);

// 定义(通常放在源文件中,此时可以省略 <特定数据类型>,由编译器推导)
template <> 
返回类型 函数模板名(参数列表)
{ 
    // 函数体 
}

单文件示例代码:含自定义类Person

#include <iostream>
#include <string>

// 自定义类 Person
class Person
{
public:
    Person(const char* name, int age, double height);

    double get_height() const;
    std::string get_name() const;
    
private:
    std::string _name;
    int _age;
    double _height;
};

// ==========================================
// 主模板的声明与定义(必须存在)
// ==========================================
template <typename T>
T& bigger(T& item1, T& item2)
{
    return item1 > item2 ? item1 : item2;
}

// ==========================================
// 函数模板特化的声明
// ==========================================
template <> Person& bigger(Person& p1, Person& p2);

int main()
{
    Person p1{ "nameless", 17, 180 }, p2{ "Tom", 20, 170 };
    std::cout << bigger(p1, p2).get_name() << std::endl;
    // 输出:nameless
    
    return 0;
}

// Person 类的成员函数实现
Person::Person(const char* name, int age, double height)
    : _name(name), _age(age), _height(height) {}

double Person::get_height() const { return _height; }

std::string Person::get_name() const { return _name; }

// ==========================================
// 函数模板特化的定义
// ==========================================
template <> Person& bigger(Person& p1, Person& p2)
{
    return p1.get_height() > p2.get_height() ? p1 : p2;
}

2.4 函数模板的多文件组织结构

函数模板本身只是一种生成函数的“图纸”,并没有具体的实体。因此,创建主模板的代码(包括声明和定义)都应当放在头文件中。

与之相对,函数模板的特化版本是存在具体实体的,其编译原理与普通函数完全一致。为了符合 C++ 的单一定义规则(ODR),特化版本的声明应放在头文件中,而定义应放在源文件中。

文件拆分规范

按照 C++ 的标准工业开发规范,通常会将上述2.2.4的单文件代码应当被拆分为 5 个文件(两个头文件和三个源文件):

  • 普通类 (Person):声明放在头文件 (.h),实现放在源文件 (.cpp)。
  • 主模板 (bigger):声明和实现必须都放在头文件中。因为编译器在不同源文件中实例化模板时,必须能够看到主模板的具体实现逻辑。
  • 模板全特化 (bigger<Person>):特化版本的声明必须放在头文件中,定义(实现)必须放在.cpp文件中(或者加上 inline 关键字留在头文件中,但拆分到 .cpp 是更正规且推荐的做法)。
    • 如果将定义也放在头文件中,当多个 .cpp 文件包含该头文件时,会导致“多重定义错误(ODR 违例)”。

多文件拆分代码的示例

  1. Person.h(类的声明)

    只包含类的结构和成员函数的声明

    #ifndef PERSON_H
    #define PERSON_H
    
    #include <string>
    
    // 要使用的自定义类
    class Person
    {
    public:
        Person(const char* name, int age, double height);
    
        double get_height() const;
        std::string get_name() const;
    private:
        std::string _name;
        int _age;
        double _height;
    };
    
    #endif // PERSON_H
    
  2. Person.cpp(类的实现)

    包含类成员函数的具体实现(逻辑)

    #include "Person.h"
    
    Person::Person(const char* name, int age, double height)
        : _name(name), _age(age), _height(height) {}
    
    double Person::get_height() const 
    { 
        return _height; 
    }
    
    std::string Person::get_name() const 
    { 
        return _name; 
    }
    
  3. Bigger.h (主模板的声明+定义 与 特化的声明)

    #ifndef BIGGER_H
    #define BIGGER_H
    
    #include "Person.h"
    
    // 主模板的声明和定义(必须在头文件中)
    template <typename T>
    T& bigger(T& item1, T& item2)
    {
        return item1 > item2 ? item1 : item2;
    }
    
    // 函数模板特例化的声明(告诉编译器有这个特例存在)
    template <>
    Person& bigger(Person& p1, Person& p2);
    
    #endif // BIGGER_H
    
  4. Bigger.cpp(模板特化的实现)

    #include "Bigger.h"
    
    // 函数模板特例化的定义
    template <>
    Person& bigger(Person& p1, Person& p2)
    {
        return p1.get_height() > p2.get_height() ? p1 : p2;
    }
    
  5. test.cpp(程序入口)

    只包含 main 逻辑,按需引入头文件。

    #include <iostream>
    #include "Person.h"
    #include "Bigger.h"
    
    int main()
    {
        Person p1{ "nameless", 17, 180 }, p2{ "Tom", 5, 50 };
        std::cout << bigger(p1, p2).get_name() << std::endl;
        // 输出:nameless
        
        return 0;
    }
    

2.5 函数模板的高级技巧

0. 补充内容:关键字与数据类型的归属关系

核心判断标准:该关键字是否改变了变量的类型标识(Type Identity)

0.1 属于类型的关键字 (cv-限定符)
  • 关键字constvolatile

  • 本质:修饰并衍生出全新、独立的类型(intconst int)。

  • 核心影响(以 const 为例)

    1. 操作权限:直接决定对象是否可写,常量对象严禁调用非 const 成员函数。

    2. 函数重载(含const变量)

      • 可重载:参数为指针或引用的底层 const(如 f(int&)f(const int&))。

      • 不可重载:按值传递的顶层 const(如 void f(int)void f(const int)),编译器在传值时会忽略顶层属性,视为同一函数。

    3. 成员函数整体为const:函数末尾的 const 本质是修饰隐藏参数 this 指针的底层 const,因此 void func()void func() const 构成重载。

0.2 不属于类型系统的关键字 (Storage & Linkage)

此类关键字仅描述变量的“位置”和“行为”,不改变其“身份”

  1. 存储类说明符 (Storage Class)

    • 关键字staticexternthread_localmutable
    • 作用:定义变量的存储期(生命周期)和链接性(可见性)。
    • 特征:不参与类型推导(decltype 会忽略它们),不能用于函数重载。
  2. 链接与优化说明符

    • 关键字inlineregister (C++17 弃用)
    • 作用:对编译器的建议或处理指示。例如 inline 允许在多个翻译单元中定义同一符号而不违反 ODR(单一定义原则),与数据类型完全无关。
1. decltype关键字(C++11)

在 C++11 中,decltype 操作符用于查询表达式的数据类型。

语法:deceltype(expression) 变量名;

decltype 仅在编译期分析表达式并推导出它的类型,不会计算或执行该表达式。因此,如果表达式是一个函数调用,不必担心函数会被真实执行。

decltype推导规则:

decltype(expression)推导出的结果不是expression一样的类型,就是expression的引用

  1. 表达式为没有用额外括号括起来的标识符(变量、类成员等),这些情况下,使用decltype推导出的类型和表达式的类型是一致的。

    注: “完全一致”意味着它会完整保留引用 &、指针 * 以及 cv-限定符(如 const)。

    class Test
    {
    public:
    	std::string text;
    	static const int value;
    };
    
    int main()
    {
    	int x = 99;
    	const double& y = 21.1;
    
    	decltype(x) var1;		// var1 -> int
    	decltype(y) var2 = y;	// var2 -> const double&
    	decltype(Test::value) var3 = 0; // var3 -> const int
    	Test t;
    	decltype(t.text) var4 = "hello"; // var4 -> std::string
    }
    
  2. 如果表达式是函数调用,类型将与函数的返回值类型相同(返回值不能是 void,但可以是 void*)。

    • 注意decltype(函数名)decltype(函数名())的区别:
      • decltype(func()):代表一次函数调用表达式,推导出的类型是函数的返回值类型。但此处不会执行函数调用!
      • decltype(func):代表函数本身,推导出的类型是函数类型(例如 double())。注意:C++ 不允许直接实例化“函数类型”的变量,但你可以用它来定义函数指针或函数引用。
    void func1() {}
    void* func2() { return nullptr; }
    
    double func3()
    {
    	std::cout << __func__ << std::endl;
    	return 3.14;
    }
    
    int main()
    {
    //1. decltype(函数名()) 推导出函数返回类型
    	// decltype(func1()) var1; // 报错:不能声明void类型的变量
    	decltype(func2()) var2; // var2 -> void*
    	decltype(func3()) var3; // var3 -> double
    	// 注意:控制台不会输出任何内容,因为 func3() 并未真正执行
    
    //2. decltype(函数名) 推导出函数类型
    	// 错误用法:定义函数变量只能声明,无法初始化,没用
    	decltype(func3) var4; // 等价于函数声明 double var4()
    	// decltype(func3) var5 = func3; // 报错:不能像初始化一个变量那样初始化一个函数
    
    	// 正确用法:定义指向函数的指针/引用,从而可以初始化
    	decltype(func3)* var6 = func3; // var6 -> double(*)(), 函数指针
    }
    
  3. 如果表达式是左值(要排除第一种情况),或者用额外括号括起来的标识符decltype((expression))

    那么,推导出的类型是该表达式的左值引用(即 T&)。

    int a = 10, b = 21;
    decltype(++a) var1 = b; // var1 -> int&
    decltype((a)) var2 = b; // var2 -> int&
    // note: decltype(a + b) 是纯右值
    
  4. 如果以上条件都不满足,var的类型与expression的类型相同。

2. C++11的函数后置返回类型:auto + 尾随返回类型

在 C++11 中,如果函数模板的返回类型依赖于参数,我们可以将返回类型放于函数声明的后面。

语法:auto func() -> int; (等同于 int func();

  • 也可以将其用于函数定义:

    auto func() -> int
    { 
        // 函数体 
    }
    

配合 decltype,它完美解决了返回值类型依赖模板参数的问题:

template <typename T1, typename T2>
auto get_sum(T1 x, T2 y) -> decltype(x + y) // 必须加上 -> 尾随返回类型
{
	decltype(x + y) sum = x + y;
	return sum;
}

int main()
{
	std::cout << get_sum(0.1, 'a') << std::endl;
	// 输出: 97.1
}
3. C++14 的返回类型推导:直接返回 auto

C++14 进一步放宽了限制,函数可以直接使用 auto 作为返回类型,编译器会通过 return 语句自动推导,不再强制要求写尾随返回类型

template <typename T1, typename T2>
auto get_sum(T1 x, T2 y)
{
	decltype(x + y) sum = x + y;
	return sum;
}

int main()
{
	std::cout << get_sum(0.1, 'a') << std::endl;
	// 输出: 97.1
}

3. 类模板

3.1 类模板的基本概念

类模板是通用类的描述(相当于一张“蓝图”),它使用任意类型(泛型)来定义类中的成员变量和成员函数。

当我们在使用类模板时,需要指定具体的数据类型,编译器会根据传入的类型,自动生成该特定类型的类定义(这个过程称为模板实例化)。

语法:

template <class T1, class T2 ...>
class 类模板名
{
    // 类模板的定义
};

提示: 声明模板参数时,classtypename 作用基本相同。通常习惯上:函数模板建议用 typename类模板建议用 class

3.1.1 类模板的核心注意事项:

  1. 必须显式指定类型: 在创建对象时,通常必须在尖括号 < > 中指明具体的数据类型

    • Note:C++17 引入了类模板参数推导 (CTAD),但在学习阶段建议严格遵守显式指定的原则。而且CTAD无法隐式类型转换!
  2. 缺省数据类型: 类模板可以为通用数据类型指定默认类型

    • 缺省参数 = 默认参数:在函数中,void func(int num = 10)num的缺省参数(默认参数)是10
    • 缺省数据类型 = 默认数据类型:在模板中,template<class T1, class T2 = int>T2的缺省数据类型(默认数据类型)是int
  3. 类型参数和非类型参数都可以接受template<class T, int len>

    • T就是类型参数:是一个类型占位符,实例化时由用户提供具体类型(如 intdouble、自定义类)。
    • 变量len就是非类型参数:是一个编译期常量值,类型已明确指定(这里是 int),实例化时必须传入一个常量表达式(如字面量 10constexpr 变量)。
  4. 类外实现: 模板类的成员函数可以在类外实现,但每次实现都需要带上模板头 template <class T...>,并且作用域要写成 类名<T...>::函数名

  5. 延迟实例化(惰性生成): 模板类的成员函数只有在程序中实际被调用到时,编译器才会为其生成具体的代码。

3.1.2 示例代码:Entity 类模板

#include <iostream>
#include <string>

template <class T1, class T2 = std::string>
class Entity 
{
public:
	Entity() = default;
	// _t1 和 _t2 是复制了 t1、t2 内容的新变量,它们不共用同一个内存空间
	  // const 引用是可以绑定到右值(字面量或临时对象)的,能兼容左右值的传入
	Entity(const T1& t1,const T2& t2) : _t1(t1), _t2(t2) {}

	bool set_t1(const T1& t1);
	T1 get_t1() const;
private:
	T1 _t1;
	T2 _t2;
};

// 类外实现成员函数:
template <class T1, class T2>
bool Entity<T1, T2>::set_t1(const T1& t1)
{
	this->_t1 = t1;
	return true;
}

template <class T1, class T2>
T1 Entity<T1, T2>::get_t1() const { return this->_t1; }

int main()
{
	// 1. 正常指定所有类型
	Entity<int, std::string> e1{ 17, "nameless" };

	// 2. 使用缺省数据类型(默认的数据类型)
	Entity<int> e2{ 10, "Tom" };

	// 3. 使用 new 创建模板类对象
	  // 注意指针类型和 new 后面都要带上模板参数
	Entity<int>* e3 = new Entity<int>{ 8, "Jerry" };

	std::cout << e1.get_t1() << std::endl; // 17
	delete e3;
}

3.2 类模板的实战与深入

3.2.1避坑指南:类模板的声明和实现不能分开编译

在编写普通类时,我们习惯把声明放在 .h 文件,实现放在 .cpp 文件。但是,类模板如果这么做,大概率会导致链接错误 (Linker Error)。

  • 普通类(非模板): 编译时,即使 .cpp 文件被单独编译成 .obj(机器码),最后链接器也能把测试代码的 .obj 和类的 .obj 连到一起,找到函数的定义。
  • 类模板: 模板本身不是真正的代码,而是一张“蓝图”。只有当用具体类型(如 Stack<std::string>)去使用它时,编译器才会实例化生成真正的代码。
    • 如果声明和实现分开,编译器在编译 test.cpp 时只看到了模板声明,无法生成完整代码。
    • 而在编译 Stack.cpp 时,又不知道你需要什么具体类型,因此什么也没生成。
    • 最终链接器两手空空,报错找不到函数定义。

解决方案: 将类模板的声明和实现全部写在同一个头文件中(很多开源库习惯将这种文件后缀命名为 .hpp,以提示这是一个包含了实现的模板头文件)。

3.2.2 实现自己的 Stack 类模板

Stack.hpp 文件(包含声明 + 定义):

#ifndef STACK_HPP
#define STACK_HPP

#include <iostream>

template <class T>
class Stack
{
public:
    Stack(int size = 3);
    virtual ~Stack();

    bool full() const;
    bool empty() const;
    bool push(const T& item);
    bool pop();
    bool pop(T& item);
    
private:
    T* _items;      // 动态分配的栈数组
    int _size;      // 栈容量
    int _top;       // 栈顶指针(指向下一个将要存入的数据的位置)
};

template <class T>
Stack<T>::Stack(int size)
    : _size(size), _top(0)
{
    _items = new T[_size];
}

template <class T>
Stack<T>::~Stack()
{
    delete[] _items;
    _items = nullptr;
}

template <class T>
bool Stack<T>::full() const
{
    // _top 指向下一个空位,当 _top == _size 时说明没有空位了
    return _top == _size;
}

template <class T>
bool Stack<T>::empty() const
{
    return _top == 0;
}

template <class T>
bool Stack<T>::push(const T& item)
{
    if (this->full()) return false;

    // _top 始终指向“未填充位置”
    // 所以 push 时:先在 _top 位置存入元素,然后 _top 自增
    _items[_top++] = item;
    return true;
}

template <class T>
bool Stack<T>::pop()
{
    if (this->empty()) return false;

    // 出栈时:先让 _top 自减,定位到有实际元素的栈顶,再处理
    // (注:标准库的 pop 通常不返回元素,这里保留打印逻辑)
    std::cout << _items[--_top] << " has been popped!\n";
    return true;
}

template <class T>
bool Stack<T>::pop(T& item) // 取出元素,元素出栈
{
    if (_top == 0) return false;
    item = _items[--_top];
    return true;
}

#endif // STACK_HPP

test.cpp文件,包含main函数:

#include <iostream>
#include <string>
#include "Stack.hpp" // 包含 .hpp 文件

int main()
{
    // 实例化一个存放 std::string 的栈,容量为 5
    Stack<std::string> ss(5);

    ss.push("nameless");
    ss.push("Tom");
    ss.push("Jerry");
    ss.push("Alice");
    ss.push("Lisa");

    // 尝试 push 第 6 个元素会失败,因为栈满了
    if (!ss.push("Extra"))
        std::cout << "Stack is full, cannot push 'Extra'." << std::endl;

    // 依次出栈 (后进先出 LIFO)
    for (int i = 0; i < 5; ++i)
    {
        ss.pop();
    }

    return 0;
}

/* 输出:
Stack is full, cannot push 'Extra'.
Lisa has been popped!
Alice has been popped!
Jerry has been popped!
Tom has been popped!
nameless has been popped!
*/

3.2.3 实现自己的定长数组Array模板(忽略边界检查)

Array.hpp 文件:

#ifndef ARRAY_HPP
#define ARRAY_HPP

#include <iostream>

// 模板可以接受类型参数 T,也可以接受非类型参数 len
template <class T, int len>
class Array
{
public:
    Array() = default;
    ~Array() = default;

    // 自定义的简易 Array,忽略边界检查
    T& operator[](int idx) { return _items[idx]; }
    const T& operator[](int idx) const { return _items[idx]; }
    
private:
    T _items[len];
};

#endif // ARRAY_HPP

test.cpp

#include <iostream>
#include <string>
#include "Array.hpp"

int main()
{
	Array<std::string, 5> aa;
	//aa[0] = 5; aa[1] = 5; aa[2] = 5; aa[3] = 5; aa[4] = 5;
	aa[0] = "nameless";
	aa[1] = "nameless";
	aa[2] = "nameless";
	aa[3] = "nameless";
	aa[4] = "nameless";
	
	for (int i = 0; i < 5; i++)
		std::cout << "aa[" << i << "]: "
		<< aa[i] << std::endl;
}

3.2.4实现自己的变长数组Vector模板(忽略边界检查)

Vector.hpp 文件:

#ifndef VECTOR_HPP
#define VECTOR_HPP

template <class T>
class Vector
{
public:
    Vector(int size = 2) : _size(size)
    {
        _items = new T[size];
    }

    ~Vector()
    {
        delete[] _items;
        _items = nullptr;
    }

    void resize(int size)
    {
        if (size <= _size) return; // 只能扩大,不能缩小

        T* tmp = new T[size];
        // 🚨 注意:这里直接赋值,如果 T 是复杂对象且未正确处理深拷贝,会出现浅拷贝风险!
        for (int i = 0; i < _size; i++)
            tmp[i] = _items[i];

        delete[] _items;
        _items = tmp;
        _size = size;
    }

    int size() const
    {
        return _size;
    }

    // 支持越界自动扩容(忽略左边界越界检查)
    T& operator[](int idx) 
    {
        if (idx >= _size)
        {
            // 持续翻倍,直到容量足以容纳 idx
            int new_size = _size * 2;
            while (idx >= new_size) {
                new_size *= 2;
            }
            this->resize(new_size);
        }
        return _items[idx];
    }

    const T& operator[](int idx) const { return _items[idx]; }
    
private:
    int _size;
    T* _items;
};

#endif // VECTOR_HPP

test.cpp文件:

#include <iostream>
#include <string>
#include "Vector.hpp"

int main()
{
	Vector<std::string> vv{5};
	//vv[0] = 5; vv[1] = 5; vv[2] = 5; vv[3] = 5; vv[4] = 5;
	vv[0] = "nameless";
	vv[1] = "nameless";
	vv[2] = "nameless";
	vv[3] = "nameless";
	vv[4] = "nameless";
	
	for (int i = 0; i < 5; i++)
		std::cout << "vv[" << i << "]: "
		<< vv[i] << std::endl;
}

3.2.4 补充:Array 和 Vector 对比

标准库中的容器 std::array<T, len>std::vector<T> 也满足以下优缺点对比:

  • Array 容器的优点:在栈上分配内存,容易维护。内存连续性好,极少产生内存碎片,执行速度快,适合已知固定大小的小型数组。
  • Array 的缺点:在程序中,不同的非类型参数(这里的 len)将导致编译器生成不同类的定义。这会导致程序的二进制代码膨胀,占用更多的代码段空间。
// 以下代码会促使编译器生成 3 个完全不同的类的定义!
Array<std::string, 10> aa1;
Array<std::string, 11> aa2;
Array<std::string, 12> aa3;
  • Vector 容器的特点:底层始终是在堆区动态申请内存。它的存取效率不如原生定长数组,但更加灵活通用,可以自动扩容。并且无论容量多大,它们都属于同一个类类型(如都是 Vector<std::string>),不会存在 Array 容器那种生成多个类定义导致代码膨胀的问题。

3.3 嵌套和递归使用类模板

3.3.1 嵌套使用类模板 + 浅拷贝问题

使用上面3.2中的自定义容器类:

#include <iostream>
#include <string>
#include "Vector.hpp"
#include "Stack.hpp"

int main()
{
    // 自定义的Vector容器大小缺省值为2,Stack容器则为3
    // 创建 Vector 容器,容器中的元素是 Stack<std::string>
    Vector< Stack<std::string> > vec_stk; // C++11 之前,>> 之间要加空格,否则报错
    
    // 手动往容器中插入数据
    vec_stk[0].push("tom1"); vec_stk[0].push("tom2"); vec_stk[0].push("tom3");
    vec_stk[1].push("jerry1"); vec_stk[1].push("jerry2"); vec_stk[1].push("jerry3");
    
    // !!!如果 Stack 没有实现深拷贝,这里插入元素会导致程序崩溃!!!
    // vec_stk[2].push("nameless"); 

    // 用嵌套循环,将 vec_stk 容器中的数据显示出来
    for(int i = 0; i < vec_stk.size(); i++) 
    {
        while (!vec_stk[i].empty()) 
        {
            std::string item;
            vec_stk[i].pop(item);
            std::cout << "item = " << item << std::endl;
        }
    }
}

崩溃原因分析:浅拷贝

上述代码中执行 vec_stk[2].push("nameless"); 时,Vector 容器检测到越界并触发 resize 扩容。

  • 如果这里复制的是 C++ 的内置类型,没有任何问题。
  • 但这里复制的是 Stack 类,由于 Stack 内部管理了动态堆内存(_items 指针),如果没有提供自定义的拷贝赋值逻辑,编译器默认的浅拷贝会导致新旧两个 Stack 对象的指针指向同一块堆内存。析构时,同一块内存被 delete 两次(Double Free),直接导致程序崩溃!
	void resize(int size)
	{
		if (size <= _size) return; // 只能扩大,不能变小

		T* tmp = new T[size];
		// 把原数组的内容,复制到新数组——注意这里可能出现浅拷贝!(如果是类类型变量,且没有重写拷贝构造)
		for (int i = 0; i < _size; i++)
			tmp[i] = _items[i];

		delete[] _items;
		_items = tmp;
		tmp = nullptr;
		_size = size;
	}

解决方案:对Stack重写拷贝构造函数,从而实现深拷贝:

  • Stack.hpp中加入以下成员函数,main中使用vec_stk[2].push("nameless"); 则不再报错
template <class T>
Stack<T>& Stack<T>::operator=(const Stack<T>& other)
{
    if (this == &other) return *this;
    
	delete[] _items; // 释放原内存
	_size = other._size;
	_top = other._top;

	_items = new T[_size];
	for (int i = 0; i < _size; i++)
		_items[i] = other._items[i];

	return *this;
}

对于使用了堆区内存的类,一定要遵守三/五法则:

  1. C++98:三法则 (Rule of Three) —— 必须同时实现:拷贝构造、析构函数、拷贝赋值

  2. C++11:五法则 (Rule of Five) —— 在三法则基础上增加:移动构造、移动赋值(注:通常在现代工程实践中,建议直接使用智能指针,不要自己手动管理堆内存!)

  3. 通常在工程实践中——使用智能指针,不要自己管理堆内存!

3.3.2 递归使用模板类:自己嵌套自己

当容器具备了完整的深拷贝机制后,完全可以实现容器嵌套容器。

Vector自己嵌套自己(在补全了 拷贝构造、拷贝赋值等等)::

	// 递归使用类模板
	// 创建Vector容器,容器中的元素用Vector<std::stirng>
	Vector<Vector<std::string>> vec_vec;

	vec_vec[0][0] = "tom1"; vec_vec[0][1] = "tom2";
	vec_vec[1][0] = "jerry1"; vec_vec[1][1] = "jerry2";
	vec_vec[2][0] = "nameless1"; vec_vec[2][1] = "nameless2"; vec_vec[2][2] = "nameless3"; vec_vec[2][3] = "nameless4";
	vec_vec[3][0] = "alice1"; vec_vec[3][1] = "alice2";
	
	for (int i = 0; i < vec_vec.size(); i++)
	{
		for (int j = 0; j < vec_vec[i].size(); j++)
			std::cout << vec_vec[i][j] << " ";
		std::cout << std::endl;
	}
	/*
	输出:
	tom1 tom2
	jerry1 jerry2
	nameless1 nameless2 nameless3 nameless4
	alice1 alice2
	*/

3.3.3 改到最后的自定义容器类Stack和Vector代码存档记录

  • 为解决浅拷贝隐患,以下 StackVector 代码均补全了“拷贝构造函数”和“拷贝赋值运算符”
  • Array未使用堆区内存,不用管

Stack.hpp:

#ifndef STACK_HPP
#define STACK_HPP

#include <iostream>

template <class T>
class Stack
{
public:
    Stack(int size = 3);
    // 遵循三法则:补全拷贝构造函数
    Stack(const Stack<T>& other);
    virtual ~Stack();

    bool full() const;
    bool empty() const;
    bool push(const T& item);
    bool pop();
    bool pop(T& item);
    
    // 遵循三法则:重载拷贝赋值运算符
    Stack<T>& operator=(const Stack<T>& other);
    
private:
    T* _items;      
    int _size;  
    int _top;       
};

template <class T>
Stack<T>::Stack(int size) : _size(size), _top(0) {
    _items = new T[_size];
}

// 深拷贝:拷贝构造函数
template <class T>
Stack<T>::Stack(const Stack<T>& other) : _size(other._size), _top(other._top) {
    _items = new T[_size];
    for (int i = 0; i < _size; i++)
        _items[i] = other._items[i];
}

template <class T>
Stack<T>::~Stack() {
    delete[] _items;
    _items = nullptr;
}

// 深拷贝:赋值运算符
template <class T>
Stack<T>& Stack<T>::operator=(const Stack<T>& other) {
    if (this == &other) return *this;

    delete[] _items; // 释放原内存
    _size = other._size;
    _top = other._top;

    _items = new T[_size];
    for (int i = 0; i < _size; i++)
        _items[i] = other._items[i];

    return *this;
}

template <class T>
bool Stack<T>::full() const { return _top == _size; }

template <class T>
bool Stack<T>::empty() const { return _top == 0; }

template <class T>
bool Stack<T>::push(const T& item) {
    if (this->full()) return false;
    _items[_top++] = item;
    return true;
}

template <class T>
bool Stack<T>::pop() {
    if (this->empty()) return false;
    std::cout << _items[--_top] << " has been popped!\n";
    return true;
}

template <class T>
bool Stack<T>::pop(T& item) {
    if (_top == 0) return false;
    item = _items[--_top];
    return true;
}

#endif // STACK_HPP

Vector.hpp:

#ifndef VECTOR_HPP
#define VECTOR_HPP

template <class T>
class Vector
{
public:
    Vector(int size = 2) : _size(size) {
        _items = new T[size];
    }

    // 深拷贝:拷贝构造函数
    Vector(const Vector<T>& other) : _size(other._size) {
        _items = new T[_size];
        for (int i = 0; i < _size; i++)
            _items[i] = other._items[i];
    }

    ~Vector() {
        delete[] _items;
        _items = nullptr;
    }

    void resize(int size) {
        if (size <= _size) return; 

        T* tmp = new T[size];
        for (int i = 0; i < _size; i++)
            tmp[i] = _items[i]; // 此处依赖类型 T 的深拷贝赋值运算符

        delete[] _items;
        _items = tmp;
        _size = size;
    }

    int size() const { return _size; }

    T& operator[](int idx) {
        if (idx >= _size) {
            int new_size = _size * 2;
            while (idx >= new_size) {
                new_size *= 2;
            }
            this->resize(new_size);
        }
        return _items[idx];
    }

    const T& operator[](int idx) const { return _items[idx]; }

    // 深拷贝:赋值运算符
    Vector<T>& operator=(const Vector<T>& other) {
        if (this == &other) return *this;

        delete[] _items;
        _size = other._size;
        _items = new T[_size];

        for (int i = 0; i < _size; i++)
            _items[i] = other._items[i];

        return *this;
    }
    
private:
    int _size;
    T* _items;
};

#endif // VECTOR_HPP

3.4 类模板的特化/特例化/具体化(Class Template Specialization)

类模板的特化是指为特定的模板参数提供特殊的实现版本。当通用的泛型代码无法满足某些特定类型(或组合)的需求时,我们可以通过特化来“定制”行为。主要分为两种形式:

  1. 显式特化/完全特化/全特化(Explicit / Full Specialization)
  2. 部分特化/偏特化(Partial Specialization)

note:只有类模板和变量模板(C++14 起)支持偏特化,函数模板不支持部分特化(函数模板的重载规则替代了偏特化的需求)。

3.4.1 显示/完全特化(Explicit/Full Specialization)

显式/完全特化是指为模板提供所有模板实参的特定版本。特化后,模板不再具有泛型性质,因此模板参数列表为空(使用 template <>),不再包含任何未知的模板参数。

示例:MyClassA.hpp - 单个模板参数下的全特化

// 1. 主模板 (Primary Template)
template <class T>
class MyClassA
{
public:
    MyClassA(const T& x) : _x(x)
    { std::cout << "MyClassA<T> constructor\n"; }
private:
    T _x;
};

// 2. 显式/完全特化:专门针对 int 类型的定制版本
template <>
class MyClassA<int>
{
public:
    MyClassA(const int& x) : _x(x)
    { std::cout << "MyClassA<int> constructor\n"; }
private:
    int _x;
};

3.4.2 部分/偏特化(Partial Specialization)

部分/偏特化只固定了部分模板参数,或者对参数施加了更具体的约束(如指针、引用、常量、特定的类模板等)。因为还没有穷尽所有参数,偏特化版本的 template <...> 列表中仍然保留至少一个模板参数

1. 多个模板参数下的偏特化(固定部分参数)

当主模板有多个参数时,我们可以只指定其中几个为具体类型。

示例:MyClassB.hpp

// 1. 主模板(2个模板参数)
template <class T1, class T2>
class MyClassB
{
public:
    MyClassB(const T1& x, const T2& y) : _x(x), _y(y)
    { std::cout << "MyClassB<T1, T2> constructor\n"; }
private:
    T1 _x;
    T2 _y;
};

// 2. 偏特化:将第一个参数固定为 int,第二个参数保留泛型
template <class T>
class MyClassB<int, T>
{
public:
    MyClassB(const int& x, const T& y) : _x(x), _y(y)
    { std::cout << "MyClassB<int, T> constructor\n"; }
private:
    int _x;
    T _y;
};

2. 单个模板参数的偏特化(施加具体约束)

主模板即使只有一个参数,也可以进行偏特化。此时不是固定类型,而是限制类型的“特征”,例如要求它必须是一个指针。

示例:MyClassC.hpp

// 1. 主模板(单参数)
template <class T>
class MyClassC
{
public:
    MyClassC(const T& x) : _x(x)
    { std::cout << "MyClassC<T> constructor\n"; }
private:
    T _x;
};

// 2. 偏特化:针对任何指针类型 T*
template <class T>
class MyClassC<T*>
{
public:
    MyClassC(T* x) : _x(x)
    { std::cout << "MyClassC<T*> constructor\n"; }
private:
    T* _x;
};

3. 针对特定类模板的偏特化(单参数的“模式匹配”与参数拆分)

类模板偏特化的强大之处不仅在于约束类型,还在于它能在编译期进行模式匹配类型提取(解构)

即使主模板只有一个参数 T,我们也可以利用偏特化将其“拆分”成多个内部类型。例如,我们希望当传入的类型是一个 std::pair 时,提供一种专门的实现:

示例续集:在 MyClassC.hpp 中添加以下偏特化

#include <utility>

// 3. 偏特化:针对 std::pair<T1, T2> 类型的模式匹配
template <class T1, class T2>
class MyClassC<std::pair<T1, T2>>
{
public:
    MyClassC(const std::pair<T1, T2>& x) : _x(x)
    { std::cout << "MyClassC<std::pair<T1, T2>> constructor\n"; }
    
    // 我们可以自由地单独使用提取出来的 T1 和 T2
    T1 getFirst() const { return _x.first; }
    T2 getSecond() const { return _x.second; }
    
private:
    std::pair<T1, T2> _x;
};

原理与Why:

  • 声明变量 vs. 匹配模式: template <class T1, class T2> 是我们在偏特化中声明的新占位符(变量)。而类名后面的 <std::pair<T1, T2>> 是告诉编译器:请拿这个模式去匹配主模板的那个唯一的参数 <T>

  • 参数提取(拆分): 当用户实例化 MyClassC<std::pair<int, double>> 时,编译器发现它符合偏特化的模式。此时,编译器不仅选择了这个特化版本,还通过模式匹配成功推导(提取)出 T1 = intT2 = double

  • 为什么需要这样做: 这种技术允许我们在类的内部独立获取并使用复合类型(如 std::pairstd::vector 等)的底层元素类型。这是现代 C++ 模板元编程和类型特征(Type Traits)实现的基础。

3.4.3 类模板特化的匹配优先级

当编译器面临模板实例化时,它总是选择特化程度最高(most specialized) 的版本。优先级规则简述如下:

  1. 显示特化(完全特化/全特化)的优先级最高,因为它最具体。
  2. 部分特化(偏特化)优先于主模板。如果存在多个偏特化版本,编译器会选择约束最严格、最匹配当前参数的那一个。
  3. 主模板(Primary Template)的优先级最低,作为最后的兜底方案。

注意: 如果编译器发现有两个偏特化版本的匹配程度相同(即产生歧义),将会报编译错误。因此在设计偏特化时应避免条件重叠。

3.5 类模板与继承

3.5.1 类模板 继承 普通类(常见)

  • 特点:和普通继承几乎一样,没有额外语法负担。

  • 用途:通常用于将模板中与“类型参数”无关的代码(如通用的成员函数或数据)提取到基类中,从而减少模板实例化带来的代码膨胀(Code Bloat)。

示例代码:

// 基类:普通的类,存放与模板参数无关的通用逻辑
class Base
{
public:
	Base(int x1) : _x1(x1) {}
private:
	int _x1;
};

// 派生类:类模板,继承自普通类
template <class T>
class Derived : public Base
{
public:
	Derived(int x1, const T& x2) : Base(x1), _x2(x2) {}
private:
	T _x2;
};

3.5.2 普通类 继承 模板类的实例化版本(常见)

  • 特点:和普通继承几乎一样。

  • 说明:继承时必须明确给出基类的模板实参。此时基类在编译器眼里已经变成了一个具体的、确定类型的普通类。

示例代码:

// 基类:类模板
template <class T>
class Base
{
public:
	Base(const T& x1) : _x1(x1) {}
private:
	T _x1;
};

// 派生类:普通类,继承时必须把基类的 T 写死(比如这里指定为 char)
class Derived : public Base<char>
{
public:
	Derived(char x1, int x2) : Base<char>(x1), _x2(x2) {}
private:
	int _x2;
};

3.5.3 类模板 继承 类模板(派生类“不新增”模板参数)

  • 特点:派生类本身也是一个类模板,但它仅用于接收并向基类透传参数。派生类自身不额外声明新的模板参数,派生类自己独有的成员大多使用的是普通数据类型。
    • 可以理解为“普通类”继承了模板类,然后这个“普通类”也就变成了模板类。

代码示例

// 基类:类模板
template <class T>
class Base
{
public:
	Base(const T& x1) : _x1(x1) {}
private:
	T _x1;
};

// 派生类:类模板
//  派生类头上的 template <class T> 仅仅是为了传给 Base<T>
template <class T>
class Derived : public Base<T>
{
public:
	Derived(const T& x1, int x2) : Base<T>(x1), _x2(x2) {}
private:
	int _x2;
};

3.5.4 类模板 继承 类模板(派生类“新增”模板参数)

  • 特点:派生类不仅要接收传给基类的参数,自身还额外增加了新的类型参数,用于定义派生类独有的成员或逻辑。

示例代码:

// 基类:类模板
template <class T>
class Base
{
public:
	Base(const T& x1) : _x1(x1) {}
private:
	T _x1;
};

// // 在派生类中增加基类没有的类型参数 T2
template <class T1, class T2>
class Derived : public Base<T1>
{
public:
	Derived<T1, T2>(const T1& x1, const T2& x2) : Base<T1>(x1), _x2(x2) {}
private:
	T2 _x2;
};

补充:类模板 继承 类模板中的「 依赖型名称访问」(Dependent Name Lookup) 问题

核心概念

类模板继承类模板的场景下,基类被称为 依赖型基类 (Dependent Base Class)(因为它依赖于模板参数)。

核心规则

派生类内部调用基类的成员函数成员变量时,不能直接写名字,必须显式地告诉编译器这是一个依赖于模板参数的名称。通常有两种做法:

  1. 加上 this-> 前缀(推荐,最自然)。

  2. 使用 Base<T>:: 显式指定作用域。

template<typename T>
class Base 
{
protected:
    void log_base() { std::cout << "loa_base()" << std::endl; }
};

template<typename T1, typename T2>
class Derived : public Base<T1> 
{
public:
    void test() 
    {
        //log_base();           // ❌ GCC/Clang/新版MSVC 报错:找不到该标识符
        this->log_base();       // ✅ 正确:告诉编译器去依赖的基类中找
        Base<T1>::log_base();   // ✅ 正确:显式指定作用域
    }
};

深入原因:两阶段名字查找 (Two-phase name lookup)

这是 C++ 标准规定的模板解析机制:

  • 第一阶段(模板定义时): 编译器解析模板本身的语法。此时它不知道具体的模板参数是什么,因此不会去依赖型基类中查找那些“非依赖型名称”(即没有 this->Base<T>:: 限定的普通名字)。
  • 第二阶段(模板实例化时): 填入具体类型,此时才会去基类里查找依赖型名称。 如果直接写名字,编译器在第一阶段就会将其当作全局或外部作用域的名字去查找,找不到就会直接报错。

⚠️ 跨编译器踩坑指南 在进行跨编译器验证时,经常会在这里遇到差异:

  • GCC / Clang: 严格遵循 C++ 标准,执行两阶段名字查找。如果不加 this->,必定报找不到标识符的错误。
  • MSVC (早期): 过去对标准要求不严,经常采用延迟解析策略。直接写名字通常也能编译通过,这掩盖了代码的非标准性。(注:在新版 MSVC 中开启 /permissive- 选项后,也会严格执行两阶段名字查找并报错)。

3.5.5 类模板 继承 其模板参数(Mixin 模式)

  • 语法template <class T> class Derived : public T { ... };

  • 特点:传入的参数 T 可以是任何合法的类类型,当然也包括已经实例化的模板类(例如 Derived< std::vector<int> > 是完全合法的)。

  • 用途:这种用法在 C++ 中被称为 Mixin(混入)模式,用于在编译期灵活地为未知的基类“挂载”或“注入”新功能,实现功能的自由组合。

示例代码:

// 假设有两个完全不相干的类
class FlyClass
{
public:
	void fly() { std::cout << "I can fly!\n"; }
};

class SwimClass
{
public:
	void swim() { std::cout << "I can swim!\n"; }
};

// 派生类直接继承自它的模板参数 T (Mixin模式)
// 作用:无论你传什么类进来,我都在它的基础上,再加一个 shoot 功能
template <class T>
class Armed : public T
{
public:
	void shoot() { std::cout << "Pew pew!\n"; }
};

3.6 类模板与函数

当类模板作为函数的参数和返回值时,通常有三种不同的编写形式。这三种形式在泛化程度和编译器调用优先级上各有不同:

  1. 普通函数:参数和返回值是类模板的实例化版本(如 MyClass<int, string>)。
  2. 特定类模板的函数模板:参数和返回值限定为某个具体的类模板(如 MyClass<T1, T2>)。
  3. 完全泛化的函数模板:参数和返回值是任意类型 T。这是最符合 C++ 泛型编程思想、最规范的写法。

基础准备:定义一个测试类模板MyClass

template <class T1, class T2>
class MyClass 
{
public:
    MyClass(const T1& x, const T2& y) : _x(x), _y(y) {}

    void log() const {
        std::cout << "log():  _x = " << _x 
            << ", _y = " << _y << std::endl;
    }
private:
    T1 _x;
    T2 _y;
};

3.6.1 普通函数(具体化模板类型)

这是最基础的写法,直接将类模板实例化为一个具体的类型(例如指定 T1intT2std::string)。

  • 特点:本质上就是一个普通的函数,与模板的泛化特性关系不大。
  • 调用优先级最高。当传入参数精确匹配 MyClass<int, std::string> 时,编译器会优先调用此普通函数,而不是去推导模板。
// 形式 1:普通函数
MyClass<int, std::string> func(MyClass<int, std::string>& mc) 
{
    std::cout << "call normal func(MyClass<int, std::string>& mc): ";
    mc.log();
    return mc;
}

3.6.2 特定类模板的函数模板(部分泛化)

这种写法将函数写成了模板,但限制了参数必须是 MyClass 这个特定的类模板。

  • 特点:具有一定的泛用性,但局限在 MyClass 这一种类中。并没有完全体现出模板的精髓。
  • 调用优先级第二。当传入 MyClass<double, char>未特化的组合时,编译器会调用这个特化的模板函数。
// 形式 2:限定类模板的函数模板
template <typename T1, typename T2>
MyClass<T1, T2> func(MyClass<T1, T2>& mc) 
{
    std::cout << "call template func(MyClass<T1, T2>& mc): ";
    mc.log();
    return mc;
}

3.6.3 完全泛化的函数模板(最高度泛化)

这种写法不关心传入的到底是不是 MyClass,只要传入的类型 T 支持函数体内的操作(例如支持 .log() 或者 () 调用),就可以编译通过。

  • 特点:最规范、最普遍的泛型写法,泛用性极强。
  • 调用优先级最低。只有在前面两种更具体的函数都无法匹配时,编译器才会使用这个最宽泛的模板。
// 形式 3:完全泛化的函数模板
template <typename T>
T func(T& item)
{
    std::cout << "call template func(T& item): ";
    item.log();
    return item;
}

补充:为什么完全泛化的函数模板是最规范的

之所以推荐第三种完全泛化的写法,是因为它打破了类型的限制

第二种写法(MyClass<T1, T2>死死绑定MyClass。如果你明天写了一个新的类 YourClass,你就必须为 YourClass 再写一个函数。

而第三种写法(T item)体现了 C++ 模板中的“鸭子类型(Duck Typing)”思想:只要这个对象“走起来像鸭子”(只要它有对应的成员函数),就可以传进来。它不仅支持所有的类模板、普通类,甚至可以用于普通函数指针和仿函数(Functor)。

强大的泛用性示例:

// 一个普通的全局函数
void log_func() { std::cout << "call log_func()\n"; }

// 一个重载了 () 运算符的普通类(仿函数)
class MyFunctor 
{
public:
    void operator()() { std::cout << "call MyFunctor::operator()()\n"; }
};

// 完全泛化的函数模板:它可以接收任何支持 operator() 的可调用对象
template <typename T>
void execute(T item) 
{
    item(); // 无论是普通函数,还是重载了()的类对象,都可以完美运行
}

int main() 
{
    execute(log_func);       // 传入普通函数指针
    MyFunctor obj;
    execute(obj);             // 传入仿函数对象
}
// 输出结果:
//call log_func()
//call MyFunctor::operator()()

3.7 类模板和友元函数

在类模板中定义友元函数,核心目的是为了让外部函数能够访问类模板实例的私有成员。根据“授权范围”的不同,类模板的友元可以分为三大类。

3.7.1 非模板友元 (Non-Template Friend)

核心概念: 友元函数本身不是模板,它是随着类模板的实例化,由编译器“顺带”生成的一个普通函数

  • 工作原理: 当你实例化 MyClass<int, int> 时,编译器会为你生成一个确切的普通函数 void log(MyClass<int, int>&) 并将其声明为友元。
  • 适用场景: 逻辑简单、通常直接在类内部实现(inline)的函数,例如最常见的 operator<< 重载。
  • 致命陷阱: 因为它不是模板函数,所以绝对不能进行模板特化。如果在类外定义,极易引发“函数重定义”错误(正如你之前遇到的)。

规范写法(强烈建议直接在类内实现):

template <class T1, class T2>
class MyClass 
{
public:
    MyClass(const T1& x, const T2& y) : _x(x), _y(y) {}

    // 规范写法:直接在类内部写出函数体
    // 编译器实例化类时,会自动在外部命名空间生成这个普通函数
    friend void log(MyClass<T1, T2>& item) 
    {
        std::cout << "_x: " << item._x << ", _y: " << item._y << std::endl;
    }

private:
    T1 _x;
    T2 _y;
};

int main() 
{
    MyClass<int, int> mc{ 1, 2 };
    log(mc); // 成功调用编译器自动生成的普通函数
    // 输出:_x: 1, _y: 2
}

3.7.2 约束模板友元 (Bound Template Friend)

核心概念: 友元函数本身一个函数模板,但类模板只与它发生一对一的绑定

换句话说,MyClass<int> 的私有成员,只有 log<int> 能访问,log<double> 绝对访问不了。

  • 工作原理: 需要严格的前置声明,告诉编译器“这是一个独立的函数模板”。在类中声明友元时,必须带上 <>,明确指出“我只把特定实例当作我的友元”。

  • 优点:

    1. 它是真正的模板函数,支持模板特化(解决你的预期需求)。
    2. 访问权限控制极其精确(一对一),安全性高。
    3. 同一个函数模板,可以成为多个不同类模板的友元。
  • 缺点: 语法非常繁琐,需要严格遵循“三步走”声明。

// 【第一步】:前置声明类模板和函数模板
template <class T1, class T2> class MyClass;
template <typename T> void log(T& item);

template <class T1, class T2>
class MyClass 
{
public:
    MyClass(const T1& x, const T2& y) : _x(x), _y(y) {}

    // 【第二步】:在类中声明友元,必须加 <>!
    // 意思是:只有以当前 MyClass<T1, T2> 作为参数实例化的 log 函数,才是我的友元
    friend void log<>(MyClass<T1, T2>& item);

private:
    T1 _x;
    T2 _y;
};

// 【第三步】:在类外提供主模板的定义
template <typename T>
void log(T& item) 
{
    std::cout << "general log(T): _x = " 
        << item._x << ", _y = " << item._y << std::endl;
}

// 附加:因为它是个真正的函数模板,所以你可以自由地对它进行特化
template <>
void log<MyClass<int, int>>(MyClass<int, int>& item) 
{
    std::cout << "specialization log<int, int>: _x = " 
        << item._x << ", _y = " << item._y << std::endl;
}

int main() 
{
    MyClass<int, char> mc1{ 10, 'a' };
    log(mc1); // 调用主模板

    MyClass<int, int> mc2{ 10, 20 };
    log(mc2); // 调用特化版本
}

3.7.3 非约束模板友元 (Unbound Template Friend)——几乎不使用

1. 核心概念:什么是“非约束”?

在类模板中,将一个带有完全独立的模板参数的函数模板,声明为该类的友元。

它产生的是“多对多”的映射关系:类模板的任意实例化版本,都会将该函数模板的所有实例化版本视为友元。

  • 在第二种(约束模板友元)中,友元关系是“一对一”**绑定的(比如处理 int 的函数,只配拥有 MyClass<int> 的钥匙)。
  • 而“非约束模板友元”,发放的是“万能钥匙”。它意味着:*任意*类型实例化的友元,都能访问**任意类型实例化的当前类。

2. 语法特征及实现 标志性特征:在类模板的声明内部,友元函数自带了一个与类模板毫无关联的新模板参数(例如下面代码中的 T,完全独立于类的 T1, T2)。

template <class T1, class T2>
class MyClass 
{
public:
    MyClass(const T1& x, const T2& y) : _x(x), _y(y) {}

    // 【非约束模板友元】声明
    // 注意:类本身的参数是 T1, T2,但友元硬生生掏出了一个全新的参数 T
    template <typename T> friend void log(T& item);
private:
    T1 _x;
    T2 _y;
};

// 友元函数模板的具体实现
template <typename T>
void log(T& item) 
{
    std::cout << "Unbound friend log(T&): "
        << "_x = " << item._x
        << ", _y = " << item._y << std::endl;
}

int main() 
{
    MyClass<int, char> mc{ 10, 'a' };
    log(mc); // 隐式推导 T 为 MyClass<int, char>,成功调用
}

3.8 类模板的成员模板(Member Templates)

在 C++ 中,类(或类模板)的内部不仅可以定义普通的成员函数和成员类,还可以嵌套定义函数模板类模板。这种嵌套在类内部的模板统称为成员模板(Member Templates)。

3.8.1 核心概念与作用域

  • 独立的模板参数:成员模板拥有自己独立的模板参数列表。尽管其内部也可以使用外层类模板的参数,但自身的模板参数与外层是毫无关系的。
  • 参数名命名:内部模板的参数名可以与外层相同(这会导致外层名称被隐藏),但为了代码清晰,通常建议使用不同的参数名以作区分。

3.8.2 类外定义的核心规则(易错点)

当我们在类外去定义“成员函数模板”或“内部类模板的成员函数”时,绝不能将内外层的模板参数合并在一个 template 标签中

必须遵循从外到内逐层声明的语法规则,先写外层类模板的 template 头,再写内层成员模板的 template 头:

// 正确写法:逐层声明
template <class T1, class T2> // 第一层:外层类模板参数
template <typename T>         // 第二层:内层成员模板参数
void MyClass<T1, T2>::log(T item) 
{
    // ...
}

// 错误写法: template <class T1, class T2, typename T>

3.8.3 完整代码示例

以下示例展示了如何在 MyClass 类模板中定义一个内部类模板 InnerClass 和一个成员函数模板 log,并分别演示了它们的类外定义方式。

#include <iostream>
#include <string>

template <class T1, class T2>
class MyClass
{
public:
    MyClass(const T1& x, const T2& y) : _x(x), _y(y) {}

    void log() 
    { 
        std::cout << "_x = " << _x << ", _y = " << _y << std::endl; 
    }
    
    // ------------------------------------
    // 1. 定义内部的类模板
    // ------------------------------------
    template <class T> 
    class InnerClass
    {
    public:
        InnerClass() = default;

        // 声明内部类模板的成员函数(类外定义)
        void log();

        // 内部类模板可以直接使用外层类模板的参数 T1
        void set(const T& a, const T1& b)
        {
            _a = a;
            _b = b;
        }
    private:
        T _a;
        T1 _b;
    };

    // ------------------------------------
    // 2. 声明内部的成员函数模板
    // ------------------------------------
    // 这里的 T 是该函数模板专属的,与上面 InnerClass 的 T 作用域不同
    template <typename T>
    void log(T item);

    // 外层类调用内部类模板的接口
    void set_ic(const std::string& a, const T1& b)
    {
        _ic.set(a, b);
    }

private:
    T1 _x;
    T2 _y;
    // 实例化一个内部类模板对象作为成员变量
    InnerClass<std::string> _ic; 
};

// ==========================================
// 类外定义区
// ==========================================

// 类外定义:类模板内部的【类模板的成员函数】
// 注意:必须提供两个 template 头部标签
template <class T1, class T2>
template <typename T>
void MyClass<T1, T2>::InnerClass<T>::log()
{
    std::cout << "_a = " << _a << ", _b = " << _b << std::endl;
}

// 类外定义:类模板内部的【成员函数模板】
// 同样注意:必须提供两个 template 头部标签
template <class T1, class T2>
template <typename T>
void MyClass<T1, T2>::log(T item)
{
    std::cout << "item = " << item << std::endl;
    std::cout << "_x = " << _x << ", _y = " << _y << std::endl;
    _ic.log();
}

// ==========================================
// 测试用例
// ==========================================
int main()
{
    MyClass<int, char> mc{ 1, 'a' };
    
    // 1. 调用外层类普通的成员函数
    mc.log(); 
    
    // 2. 调用外层成员函数,内部会操作 InnerClass 对象
    mc.set_ic("Hello", 1); 
    
    // 3. 调用外层类的成员函数模板,显式指定函数模板参数 T 为 int
    mc.log<int>(99); 
    
    return 0;
}

3.9 将类模板用于模板参数(模板的模板)

模板的核心目的就是代码重用。C++ 支持“模板的模板”,意思是把模板名当成一种特殊的数据类型。在实例化对象的时候,可以直接用模板名作为模板参数,传递给另一个模板。

3.9.1 核心应用场景与易错点

  • 应用场景:这种将类模板用于模板参数的方式,主要用于底层数据结构的抽象封装中。其他业务层面的代码基本不需要这么复杂的设计,否则会过度增加代码的认知负担。
  • 关于函数模板(重要纠偏):很多人说“函数模板不支持用模板作为模板参数”。需要特别注意,这是一个常见的误区。在 C++ 语法上,函数模板是完全支持模板的模板参数的。但因为函数模板通常可以依靠“模板参数推导”机制自动推断出容器和内部类型,所以实际开发中极少显式地这么写,导致很多人误以为不支持。

3.9.2 核心语法声明拆解(重点)

在定义高层模板时,如何声明它接收一个模板作为参数?以下面 Linearlist 的头部声明为例进行详细拆解:

template <template<class, int> class T1, class T2, int len>
  • template<class, int> class T1:这表示 T1 不是一个普通的模板参数,而是模板作为模板参数传递进来
    • 这意味着,将来实例化时,T1 的位置要填的是一个模板名(比如 ArrayLinklist)。
    • 同时,它对传递进来的模板提出了严格要求:这个传递进来的模板必须恰好有 2 个参数(一个是 class 类型参数,一个是 int 非类型参数)。
  • T2:代表线性表实际存放的数据类型(普通的类型模板参数)。
  • len:代表线性表的长度或大小(普通的非类型模板参数)。

补充:尖括号 template<> 里的内容能直接说是“参数”吗? 答案是可以的! 它们在 C++ 标准中被统称为模板参数(Template Parameters)。其中用 class/typename 声明的叫“类型模板参数”,像 int 声明的叫“非类型模板参数”。

3.9.3 完整代码示例

为了演示,我们先提供两个「自定义的、不规范的」底层数据结构 LinklistArray。为了能作为 Linearlist 的参数,强行使它们的参数列表逻辑一致(即都有两个模板参数)。

1. Linklist.hpp (底层数据结构 1):

// 不规范的自定义链表,方法无实现,仅测试用
template <class T, int len>
class Linklist
{
public:
    void insert() { std::cout << "Linklist insert\n"; }
    void ddelete() { std::cout << "Linklist ddelete\n"; }
    void update() { std::cout << "Linklist update\n"; }
private:
    T* _head;
    int _len(); // 链表不需要长度,这里仅为了演示方便,迎合外层模板参数
};

2. Array.hpp (底层数据结构 2):

template <class T, int len>
class Array 
{
public:
    void insert() { std::cout << "Array insert\n"; }
    void ddelete() { std::cout << "Array ddelete\n"; }
    void update() { std::cout << "Array update\n"; }
private:
    T* _data;
    int _len;
};

3. Linearlist.hpp (高层封装类):

// 自定义的线性表模板类
// T1-代表线性表底层的结构类型(需填入模板名)
// T2-代表线性表存放的数据类型
// len-代表长度
template <template<class, int> class T1, class T2, int len>
class Linearlist
{
public:
    void insert() { _table.insert(); }
    void ddelete() { _table.ddelete(); }
    void update() { _table.update(); }

    void oper() // 按业务要求操作线性表
    {
        std::cout << __func__ << ": \n";
        _table.insert();
        _table.update();
    }
private:
    // T1 是传入的模板名,用 T2 和 len 实例化出具体的表对象
    T1<T2, len> _table; 
};

4. test.cpp (测试代码):

#include <iostream>
#include <string>

int main()
{
    std::cout << "========================\n";
    // T1 填入 Linklist,T2 填入 char,len 填入 10
    Linearlist<Linklist, char, 10> lla;
    lla.insert();
    lla.ddelete();
    lla.update();
    std::cout << "========================\n\n";

    std::cout << "========================\n";
    // T1 填入 Array,T2 填入 string,len 填入 5
    Linearlist<Array, std::string, 5> llb;
    llb.insert();
    llb.ddelete();
    llb.update();
    std::cout << "========================\n";
    
    return 0;
}

// 输出:
//========================
//Linklist insert
//Linklist ddelete
//Linklist update
//========================
//
//========================
//Array insert
//Array ddelete
//Array update
//========================
posted @ 2026-05-04 17:08  学习笔记草稿存放账号  阅读(31)  评论(0)    收藏  举报