C++ ——— 仿函数的使用、继承方式、赋值转换规则、菱形继承与虚继承

目录

仿函数

一、仿函数(函数对象)的核心定义

[二、仿函数的核心特性(对比普通函数 / 函数指针)](#二、仿函数的核心特性(对比普通函数 / 函数指针))

三、代码中仿函数的完整使用流程

四、仿函数的典型应用场景

总结
模板的进阶

非类型模板参数

一、非类型模板参数的核心定义

[二、非类型模板参数的核心作用(结合 Stack 模板拆解)](#二、非类型模板参数的核心作用(结合 Stack 模板拆解))

三、非类型模板参数的关键使用规则

[四、非类型模板参数 vs 传统常量方案(核心对比)](#四、非类型模板参数 vs 传统常量方案(核心对比))

总结

类模板的特化

一、类模板特化的核心定义

[二、全特化(Full Specialization):所有模板参数 "完全固定"](#二、全特化(Full Specialization):所有模板参数 “完全固定”)

[三、偏特化(Partial Specialization):部分模板参数 "固定 / 加约束"](#三、偏特化(Partial Specialization):部分模板参数 “固定 / 加约束”)

四、特化的匹配优先级(核心规则)

五、类模板特化的典型用法场景

六、特化的关键注意事项

总结

继承方式和访问限定符(public、protected、private)
继承

继承的基本使用

一、继承的核心定义

二、继承的基本语法(结合代码拆解)

三、公有继承(public)的核心访问规则(代码重点)

关键规则拆解:

四、代码中继承的完整使用逻辑

五、继承的核心价值

[六、补充:继承方式的对比(理解 public 的必要性)](#六、补充:继承方式的对比(理解 public 的必要性))

总结

基类和派生类对象赋值转换

一、基类与派生类的赋值转换规则

[二、切片(切割)的概念与本质(结合代码场景 3)](#二、切片(切割)的概念与本质(结合代码场景 3))

三、为什么只有public继承才能发生切片?

[四、切片与 "临时对象转换" 的对比(代码场景 1/2 vs 场景 3)](#四、切片与 “临时对象转换” 的对比(代码场景 1/2 vs 场景 3))

五、总结

基类与派生类的六大成员函数

一、基类与派生类的默认成员函数核心规则

[二、构造函数:先父后子的执行顺序 + 自动调用父类默认构造的规则](#二、构造函数:先父后子的执行顺序 + 自动调用父类默认构造的规则)

[三、拷贝构造与赋值运算符重载:自动调用的条件 + 显式调用的必要性](#三、拷贝构造与赋值运算符重载:自动调用的条件 + 显式调用的必要性)

[四、析构函数:先子后父的执行顺序 + 显式调用父类析构的风险](#四、析构函数:先子后父的执行顺序 + 显式调用父类析构的风险)

五、总结
菱形继承与菱形虚拟继承

菱形继承带来的二义性和数据冗余问题

[virtual 菱形虚拟继承](#virtual 菱形虚拟继承)

一、先明确:普通菱形继承的致命问题(虚继承的诞生背景)

[二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象](#二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象)

三、virtual关键字的位置要求:必须加在菱形的腰部

四、虚继承的内存布局:偏移量表(虚基类表)的工作原理

五、总结:虚继承解决问题的本质


仿函数

复制代码
// 定义仿函数(函数对象)类Less:用于比较两个整数的大小(判断x是否小于y)
// 仿函数(Function Object):重载了()运算符的类/结构体实例,兼具类的特性(可存储状态)和函数的调用形式
class Less
{
public:
    // 重载()运算符:仿函数的核心,使类的实例可以像函数一样被调用
    // 参数 x/y:待比较的两个整数
    // 返回值:bool类型,x < y时返回true(1),否则返回false(0)
    bool operator()(int x, int y)
    {
        return x < y;  // 核心逻辑:比较x和y的大小,返回x小于y的结果
    }
};

int main()
{
    // 创建Less类的实例(仿函数对象)less
    // 此时less是一个对象,但因重载了(),可以像函数一样调用
    Less less;

    // 方式1:隐式调用仿函数的operator()运算符(推荐写法,像调用普通函数一样)
    // 传入参数2和3,调用less.operator()(2,3),返回true(输出1)
    cout << less(2, 3) << endl;

    // 方式2:显式调用operator()运算符(等价于方式1,更直观体现仿函数的本质)
    // 直接调用重载的()运算符,逻辑和结果与方式1完全一致
    cout << less.operator()(2, 3) << endl;

    return 0;
}

一、仿函数(函数对象)的核心定义

仿函数(Function Object),也叫函数对象 ,是 C++ 中一种特殊的类实例 ------ 它是 "重载了()运算符(函数调用运算符)的类 / 结构体的实例"。

其核心本质是:看似函数,实则对象

  • 调用形式:可以像普通函数一样用对象名(参数)的方式调用;
  • 底层本质:是类的实例,因此具备类的所有特性(可存储状态、可继承、可定制化);
  • 核心价值:兼具 "普通函数的易用性" 和 "类的灵活性",是 STL 算法(如sortfind)中定制逻辑的核心工具。

二、仿函数的核心特性(对比普通函数 / 函数指针)

特性 仿函数(函数对象) 普通函数 函数指针
调用形式 对象名(参数),与普通函数完全一致,直观 函数名(参数) 指针名(参数)
状态存储 支持(通过类的成员变量存储状态,无副作用) 不支持(需全局变量,有副作用) 不支持
类型明确性 类型唯一,模板推导高效(STL 算法首选) 类型抽象 类型抽象
编译器优化 易内联优化,执行效率高 易优化 难优化,效率低
定制化能力 支持继承、多态、重载不同operator()版本 仅能重载

关键特性拆解:

1. 调用形式 "像函数":重载operator()是核心

仿函数的核心是重载()运算符,这让类实例可以像普通函数一样被调用:

  • 语法要求:必须在类内定义operator(),参数 / 返回值按需设计(如代码中bool operator()(int x, int y));
  • 调用方式:
    • 隐式调用(推荐):less(2, 3) → 编译器自动转换为less.operator()(2, 3)
    • 显式调用:less.operator()(2, 3) → 等价于隐式调用,更直观体现 "重载运算符" 的本质。

2. 本质是 "对象":可存储状态(核心优势)

这是仿函数与普通函数最核心的区别 ------ 普通函数无法存储状态(除非用全局变量,易引发副作用),而仿函数作为类实例,可通过成员变量存储 "上下文状态":

复制代码
// 拓展:带状态的仿函数(记录比较调用次数)
class LessWithCount
{
private:
    int _callCount = 0; // 成员变量存储状态:记录调用次数
public:
    bool operator()(int x, int y)
    {
        _callCount++; // 每次调用更新状态
        return x < y;
    }
    // 获取调用次数(暴露状态)
    int GetCount() const { return _callCount; }
};

int main() {
    LessWithCount lessCount;
    cout << lessCount(2, 3) << endl;  // 调用1次,返回true(1)
    cout << lessCount(5, 1) << endl;  // 调用2次,返回false(0)
    cout << "调用次数:" << lessCount.GetCount() << endl; // 输出2
    return 0;
}

上述代码中,lessCount对象通过_callCount记录了operator()的调用次数 ------ 普通函数无法做到这一点(除非用全局变量,而全局变量会被所有调用共享,无法区分不同 "函数实例" 的状态)。

3. 类型明确:适配模板 / STL 算法更高效

仿函数是具体的类类型(如LessLessWithCount),而函数指针是抽象的 "函数地址类型"。STL 算法(如sort)接收仿函数作为参数时,编译器能精准推导类型,更容易做内联优化,执行效率远高于函数指针:

复制代码
#include <algorithm>
#include <vector>

int main() {
    vector<int> v = {3,1,4,2};
    // 仿函数作为sort的比较规则(类型明确,高效)
    sort(v.begin(), v.end(), Less()); // 直接创建Less临时对象,定制sort的升序规则
    for (int num : v) cout << num << " "; // 输出:1 2 3 4
    return 0;
}

4. 可定制化:支持重载 / 继承,灵活适配不同场景

可通过重载不同版本的operator(),让仿函数适配不同参数类型;也可通过继承扩展逻辑:

复制代码
// 重载不同版本的operator(),适配int/double
class LessAll
{
public:
    // int版本
    bool operator()(int x, int y) { return x < y; }
    // double版本
    bool operator()(double x, double y) { return x < y; }
};

int main() {
    LessAll less;
    cout << less(2, 3) << endl;     // 调用int版本,返回true
    cout << less(2.5, 1.8) << endl; // 调用double版本,返回false
    return 0;
}

三、代码中仿函数的完整使用流程

1. 定义仿函数类:重载operator()

复制代码
class Less
{
public:
    // 核心:重载()运算符,定义仿函数的逻辑
    bool operator()(int x, int y)
    {
        return x < y; // 比较逻辑:x < y返回true
    }
};
  • 类名(Less)通常体现仿函数的功能(此处是 "小于比较");
  • operator()的参数 / 返回值按需设计(此处接收两个 int,返回 bool)。

2. 实例化仿函数对象

复制代码
Less less; // 像普通类一样实例化,创建仿函数对象
  • 仿函数对象的创建和普通类实例化完全一致,可创建栈对象、堆对象(new Less())。

3. 调用仿函数(两种方式)

复制代码
// 方式1:隐式调用(推荐),像调用普通函数一样
cout << less(2, 3) << endl; // 输出1(true)

// 方式2:显式调用operator(),等价于方式1
cout << less.operator()(2, 3) << endl; // 输出1(true)
  • 两种方式逻辑完全一致,隐式调用更符合 "函数" 的使用习惯,是实际开发中的首选。

四、仿函数的典型应用场景

  1. STL 算法的谓词参数 :定制sort(排序规则)、find_if(查找条件)、count_if(计数条件)等算法的逻辑;
  2. 自定义回调逻辑:替代函数指针,实现带状态的回调(如异步任务的回调、事件处理);
  3. 模板元编程:在编译期计算、类型推导等场景中,仿函数是核心工具;
  4. 策略模式实现:将不同的业务策略(如不同的比较规则、计算规则)封装为仿函数,动态切换。

总结

仿函数的核心是 "以对象之形,行函数之事":

  1. 语法上:重载operator(),让对象可像函数一样调用;
  2. 特性上:兼具函数的易用性和类的状态存储 / 定制化能力;
  3. 效率上:类型明确,易被编译器优化,优于函数指针;
  4. 场景上:是 STL 算法定制逻辑的首选,也是实现 "灵活且高效的可调用逻辑" 的核心方案。

简单来说,当你需要 "函数的调用形式",但又需要 "对象的状态 / 定制化" 时,仿函数是最优选择 ------ 它既避免了普通函数的局限性,又解决了函数指针的效率问题。


模板的进阶

非类型模板参数

复制代码
// 定义模板类Stack:实现基于静态数组的固定大小栈
// 模板参数详解:
// 1. T:类型模板参数(Type Template Parameter)------ 代表栈中存储元素的类型(如int、double等任意类型)
// 2. N:非类型模板参数(Non-Type Template Parameter)------ 代表栈的容量(数组大小),要求是「编译期可确定的常量值」
template<class T, size_t N>  // class可替换为typename;size_t是非类型模板参数的典型类型(无符号整数)
class Stack
{
public:
	// 可补充栈的核心操作(示例中省略,仅展示模板参数核心逻辑)
	// 例如:
	// void push(const T& val) { /* 入栈:将元素压入栈顶 */ }
	// void pop() { /* 出栈:移除栈顶元素 */ }
	// T& top() { return _a[_top]; }  // 获取栈顶元素

private:
	T _a[N];  // 静态数组存储栈元素:
	          // - 元素类型由类型模板参数T指定(如int、double)
	          // - 数组大小由非类型模板参数N指定(如10、10000),编译期确定内存大小
	size_t _top = 0;  // 栈顶指针(初始为0,标识下一个可入栈的位置)
};

int main()
{
	// 实例化Stack模板类:
	// 1. st1:类型参数T=int(存储int),非类型参数N=10(栈容量10)
	//    N=10是字面量常量,编译期可确定,符合非类型模板参数要求
	Stack<int, 10> st1;

	// 2. st2:类型参数T=double(存储double),非类型参数N=10000(栈容量10000)
	//    不同的N会生成不同的模板实例(Stack<double,10000>是独立类型)
	Stack<double, 10000> st2;

	return 0;
}

一、非类型模板参数的核心定义

非类型模板参数(Non-Type Template Parameter)是 C++ 模板参数的一种特殊类型 ------ 它不是 "类型占位符"(如class T),而是 "值占位符",要求传入的是「编译期可确定的常量值」(如字面量、const 常量、枚举值等)。

与 "类型模板参数(class T)定制元素类型" 不同,非类型模板参数的核心是定制 "编译期固定的常量值"(如数组大小、容量、阈值等),编译期会将该值代入模板,生成对应版本的代码。

代码中template<class T, size_t N>里:

  • T:类型模板参数 → 定制栈元素的类型(int/double 等);
  • N:非类型模板参数 → 定制栈的容量(数组大小),且N必须是编译期常量。

二、非类型模板参数的核心作用(结合 Stack 模板拆解)

1. 编译期确定常量值,实现 "类型级别的容量定制"

这是最核心的作用 ------ 解决 "静态数组大小必须编译期确定" 的问题,同时避免传统方案的缺陷:

  • 传统方案的局限性
    • 若用#define N 10(预处理宏):无类型检查(传负数、浮点数不会报错),且全局生效,无法为不同栈定制不同容量;
    • 若用const size_t N = 10(类内 const 成员):N是运行期常量,无法作为静态数组大小(C++ 要求静态数组大小必须是编译期常量);
    • 若用构造函数传N:只能用动态数组(new T[N]),引入堆内存分配开销,且运行期才确定大小,无法编译期优化。
  • 非类型模板参数的优势 :代码中Stack<int, 10> st1Stack<double, 10000> st2
    • N=10/10000是编译期字面量常量,满足 "静态数组大小必须编译期确定" 的要求;
    • 编译器会为不同的N生成不同的静态数组:st1_aint[10]st2_adouble[10000],实现 "一个模板,多容量定制"。

2. 编译期优化,消除运行期开销

非类型模板参数的值在编译期确定,编译器可做极致优化:

  • 静态数组T _a[N]分配在栈上(或类的内存布局中),无动态内存分配(new/delete)的开销;
  • 编译器可直接内联栈的操作(如push/pop),无需运行期判断 "当前容量是否合法"(因为N是编译期固定值);
  • 对比 "构造函数传 N + 动态数组":动态数组需运行期分配 / 释放,且需额外存储_n(容量),非类型模板参数完全消除这些开销。

3. 生成独立的模板实例,实现类型隔离

不同的非类型模板参数值,会生成完全独立的模板类(类型隔离,无混淆风险):

  • Stack<int, 10>Stack<int, 20>是两个不同的类,各自有独立的内存布局、成员函数;
  • 编译器为每个N生成专属代码,避免 "不同容量的栈共用一套代码导致的运行期分支判断"(如if (_size > N)),类型更安全,执行效率更高。

4. 替代预处理宏,兼顾类型安全和常量定制

预处理宏(#define)是 "无类型的文本替换",而非类型模板参数有明确的类型约束(如代码中size_t N是无符号整数):

  • 若传入非法值(如负数Stack<int, -10>),编译器直接报错(类型检查);
  • 若传入非编译期值(如int n=10; Stack<int, n> st;),编译器也会报错(要求N是编译期常量);
  • #define N -10不会报错,只会在运行期触发数组大小非法的未定义行为,非类型模板参数从编译期规避了这类错误。

三、非类型模板参数的关键使用规则

1. 类型限制(仅支持以下类型)

非类型模板参数的类型必须是 "编译期可求值的常量类型",常见的有:

  • 整数类型:intsize_tboolcharlong等(代码中size_t N是典型用法);
  • 枚举类型:enum E { A=10 }; Stack<int, A> st;
  • 指针 / 引用:指向编译期常量的指针(如const char* str = "hello"; Stack<int, (size_t)str> st;);
  • ❌ 不支持:浮点数(double N)、类类型(string N)、运行期变量(int n=10)。

2. 值要求:必须是编译期常量

传入的参数值必须在编译期就能确定,以下写法合法 / 非法:

复制代码
// 合法:字面量常量(编译期确定)
Stack<int, 10> st1;
// 合法:const常量(编译期确定)
const size_t N = 20;
Stack<int, N> st2;
// 非法:运行期变量(编译期无法确定)
int n = 30;
Stack<int, n> st3; // 编译报错!

3. 不同值生成不同实例

Stack<int, 10>Stack<int, 20>是两个完全独立的类,就像intdouble是不同类型一样:

  • 无法将Stack<int, 10>的对象赋值给Stack<int, 20>的对象(类型不匹配);
  • 各自的成员函数(如push)是独立的,编译器为每个N生成专属代码。

四、非类型模板参数 vs 传统常量方案(核心对比)

方案 非类型模板参数 #define 宏 类内 const 成员 构造函数传参
类型安全 有(编译期类型检查) 无(文本替换) 有,但无法做数组大小 有,但运行期确定
编译期确定 是(预处理阶段) 否(运行期) 否(运行期)
内存开销 无(静态数组) 无,但全局生效 有(动态数组) 有(动态数组 + 成员)
定制化能力 多实例,多容量 全局单一值 单一容量 多容量,但类型相同

总结

非类型模板参数的核心是 "编译期常量值的占位符",其核心作用可概括为:

  1. 让模板不仅能定制 "类型"(T),还能定制 "编译期固定的常量值"(N),实现 "类型 + 值" 的双重定制;
  2. 编译期确定常量值,支持静态数组、编译期优化,消除动态内存分配开销;
  3. 类型安全,替代预处理宏,从编译期规避非法值风险;
  4. 生成独立模板实例,实现类型隔离,提升代码安全性和执行效率。

非类型模板参数尤其适合 "需要编译期固定大小 / 常量" 的场景(如静态数组容器、固定容量栈 / 队列、编译期计算等),是 C++ 实现 "编译期定制化" 的核心工具,也是 STL 容器(如array<T, N>)的底层核心逻辑(std::array就是用非类型模板参数N指定数组大小)。


类模板的特化

复制代码
// 1. 模板类的基础定义(泛化版本)
// 模板参数:T1、T2为类型模板参数,代表任意类型
// 泛化版本:适用于所有未被特化的模板参数组合
template<class T1, class T2>
class Data
{
public:
	// 泛化版本的构造函数:实例化时打印泛化标识
	Data()
	{
		cout << "Data<T1, T2>" << endl;
	}

private:
	T1 _d1;  // 存储T1类型的成员变量
	T2 _d2;  // 存储T2类型的成员变量
};

// 2. 全特化(Full Specialization)
// 语法:template<> 标识全特化,无模板参数(所有模板参数都被固定)
// 作用:针对模板参数为<int, double>的组合,提供专属的实现版本
template<>  
class Data<int, double>
{
public:
	// 全特化版本的构造函数:实例化Data<int, double>时触发
	Data()
	{
		cout << "Data<int, double>" << endl;
	}

private:
	int _d1;     // 固定为int类型
	double _d2;  // 固定为double类型
};

// 3. 偏特化(Partial Specialization)也叫局部特化
// 语法:保留部分模板参数(T1仍为泛化),固定部分模板参数(T2固定为double)
// 作用:针对第二个模板参数为double的所有组合(如Data<int,double>、Data<double,double>等),提供专属实现
// 注:Data<int,double>会优先匹配全特化版本,而非偏特化(匹配优先级:全特化 > 偏特化 > 泛化)
template<class T1>  
class Data<T1, double>
{
public:
	// 偏特化版本的构造函数:实例化Data<任意类型, double>且无全特化时触发
	Data()
	{
		cout << "Data<T1, double>" << endl;
	}

private:
	T1 _d1;      // 仍为泛化的T1类型
	double _d2;  // 固定为double类型
};

// 4. 同样也是偏特化,只要 T1 和 T2 的类型是指针时,就会调用此类
template<class T1, class T2>  //偏特化
class Data<T1*, T2*>
{
public:
	Data()
	{
		cout << "Data<T1*, T2*>" << endl;
	}

private:
	T1 _d1;
	double _d2;
};

int main()
{
	// 实例化1:Data<int, int>
	// 无对应的全特化/偏特化版本,匹配泛化版本 → 打印 "Data<T1, T2>"
	Data<int, int>();

	// 实例化2:Data<int, double>
	// 有全特化版本,优先匹配全特化 → 打印 "Data<int, double>"
	Data<int, double>();
	
	// 实例化3:Data<double, double>
	// 无全特化版本,但匹配偏特化(T2=double) → 打印 "Data<T1, double>"
	Data<double, double>();

    // 实例化4:Data<T1*, T2*>
    Data<int*, double*>();

	return 0;
}

一、类模板特化的核心定义

类模板特化(Specialization)是 C++ 为特定模板参数组合提供 "专属实现版本" 的语法机制 ------ 泛化模板(普通模板)是 "一套代码适配所有类型",而特化允许我们为 "某一组 / 某一类特定参数" 定制逻辑,突破泛化模板的通用性限制,解决 "特殊类型 / 参数组合需要特殊处理" 的问题。

特化的本质是:模板的 "定制化扩展",编译器实例化模板时,会优先匹配特化版本;无特化版本时,才使用泛化版本。

类模板特化分为两类:全特化 (所有参数固定)和偏特化(部分参数固定 / 参数加约束)。

二、全特化(Full Specialization):所有模板参数 "完全固定"

1. 核心定义

全特化是将类模板的所有模板参数都固定为具体类型,无任何泛化参数残留 ------ 针对 "唯一的参数组合" 提供专属实现。

2. 语法规则(结合代码)

复制代码
// 泛化版本(基础模板)
template<class T1, class T2>
class Data { ... };

// 全特化版本:template<> 标识全特化(无模板参数),类名后指定所有具体参数<int, double>
template<>  
class Data<int, double> { ... };
  • 必须以 template<> 开头(表示 "无泛化模板参数");
  • 类名后用 <具体类型> 固定所有模板参数(如 <int, double>);
  • 全特化版本的类成员(变量 / 函数)需重新定义,完全独立于泛化版本。

3. 作用与匹配逻辑

  • 作用:针对 "唯一的参数组合(如 Data<int, double>)" 定制实现(比如特殊的成员变量、不同的函数逻辑);
  • 匹配逻辑:实例化 Data<int, double> 时,编译器优先选择全特化版本,而非泛化 / 偏特化版本。

代码中实例化 2:Data<int, double>(); → 匹配全特化版本,打印 Data<int, double>

三、偏特化(Partial Specialization):部分模板参数 "固定 / 加约束"

偏特化也叫 "局部特化",分为两种常见形式(代码中是第一种):

  1. 参数数量偏特化:保留部分泛化参数,固定另一部分(如固定 T2=double,保留 T1 泛化);
  2. 参数性质偏特化 :对参数加约束(如固定 T 为指针 / 引用类型,如Data<T*>)。

1. 核心定义

偏特化是将类模板的部分模板参数固定(或对参数增加约束),保留剩余参数的泛化特性 ------ 针对 "一类参数组合" 提供专属实现。

2. 语法规则(结合代码)

复制代码
// 偏特化版本:template<class T1> 保留泛化参数T1,类名后固定T2=double
template<class T1>  
class Data<T1, double> { ... };
  • 必须保留 template<保留的泛化参数>(如template<class T1>);
  • 类名后用 <泛化参数, 固定参数> 标识偏特化规则(如 <T1, double>);
  • 偏特化版本的成员可复用泛化逻辑,也可完全重定义。

3. 作用与匹配逻辑

  • 作用:针对 "一类参数组合(如所有 T2=double 的 Data<T1, double>)" 定制实现(比如所有第二个参数是 double 的 Data,都用同一套逻辑);
  • 匹配优先级:低于全特化,高于泛化版本 ------ 仅当无全特化版本,但匹配偏特化规则时生效。

代码中实例化 3:Data<double, double>(); → 无全特化版本,但匹配偏特化(T2=double),打印 Data<T1, double>

四、特化的匹配优先级(核心规则)

编译器实例化类模板时,按以下优先级选择版本:全特化版本 > 偏特化版本 > 泛化版本

代码中三个实例化的匹配过程:

实例化代码 匹配规则 最终版本 打印内容
Data<int, int>() 无全特化 / 偏特化匹配 泛化版本 Data<T1, T2>
Data<int, double>() 匹配全特化(<int,double>) 全特化版本 Data<int, double>
Data<double, double>() 无全特化,但匹配偏特化(T2=double) 偏特化版本 Data<T1, double>

五、类模板特化的典型用法场景

1. 特殊类型的定制逻辑

泛化模板的逻辑对某些类型不适用(如指针类型、字符串类型),需特化处理:

复制代码
// 泛化版本:处理普通类型
template<class T>
class Data {
public:
    void print() { cout << "普通类型:" << _d << endl; }
private:
    T _d;
};

// 偏特化:处理指针类型(T*)
template<class T>
class Data<T*> {
public:
    void print() { cout << "指针类型,指向值:" << *_d << endl; }
private:
    T* _d;
};

// 实例化测试
Data<int> d1; d1.print();       // 泛化版本:普通类型:0
Data<int*> d2(new int(10)); d2.print(); // 偏特化版本:指针类型,指向值:10

2. 高频参数组合的性能优化

对项目中高频使用的参数组合(如Data<int, string>),特化版本可优化逻辑(如减少冗余计算、改用更高效的存储方式)。

3. 规避泛化模板的语法限制

某些类型不支持泛化模板的默认操作(如泛化模板用+运算符,但自定义类型未重载+),特化版本可替换为适配该类型的逻辑。

六、特化的关键注意事项

  1. 特化必须基于泛化模板:先定义泛化模板,才能定义其特化版本(编译器需先识别基础模板);
  2. 特化版本的类名必须与泛化版本一致 :如泛化是Data,特化也必须是Data(只是参数组合不同);
  3. 偏特化不是 "函数重载":偏特化是模板的特化,而非类的重载,仅针对模板参数做约束,而非参数个数 / 类型;
  4. 特化版本需重新定义成员:特化版本的成员(变量 / 函数)不会继承泛化版本,需按需重新实现。

总结

类模板特化是 "通用模板 + 定制化扩展" 的核心机制:

  1. 全特化:针对 "唯一的参数组合" 定制实现,所有参数固定,匹配优先级最高;
  2. 偏特化:针对 "一类参数组合" 定制实现,部分参数固定 / 加约束,匹配优先级次之;
  3. 核心价值:既保留泛化模板的代码复用,又解决 "特殊类型 / 参数组合需要特殊逻辑" 的问题;
  4. 匹配规则:全特化 > 偏特化 > 泛化,编译器自动选择最匹配的版本。

特化是 STL(如std::vectorbool类型的特化)、泛型编程中处理 "特殊场景" 的核心手段,让模板既通用又灵活。


继承方式和访问限定符(public、protected、private)

类成员 / 继承方式 public 继承 protected 继承 private 继承
基类的 public 成员 派生类的 public 成员 派生类的 protected 成员 派生类的 private 成员
基类的 protected 成员 派生类的 protected 成员 派生类的 protected 成员 派生类的 private 成员
基类的 private 成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

总结:

  1. 基类的private成员:无论采用哪种继承方式,在派生类中均不可见。
  2. 基类的public/protected成员,在派生类中的权限随继承方式变化:
    • public继承:基类public→派生类public,基类protected→派生类protected(权限基本保留);
    • protected继承:基类public→派生类protected,基类protected→派生类protected(权限统一降为 protected);
    • private继承:基类public/protected→均变为派生类private(权限统一降为 private)。

继承

继承的基本使用

复制代码
// 基类(父类/超类)Person:封装了人的通用属性和行为
// 作为被继承的类,提供可复用的成员(属性:_name/_age;行为:Print())
class Person
{
public:
    // 成员函数:打印人的姓名和年龄(通用行为,可被派生类继承使用)
    void Print()
    {
        cout << "name is: " << _name << endl;
        cout << "age is: " << _age << endl;
    }

private:
    // 私有成员:基类的核心属性,仅能在Person类内部访问(派生类无法直接访问)
    string _name = "your name";  // 姓名,默认初始化
    int _age = 18;               // 年龄,默认初始化
};

// 派生类(子类)Student:公有继承(public)自Person类
// 继承规则:
// 1. 公有继承下,基类的public成员在派生类中仍为public(如Print()可被Student对象调用);
// 2. 基类的private成员不可被派生类直接访问,但可通过基类的public成员函数(如Print())间接访问;
// 3. Student新增专属成员_StudentID,体现派生类的扩展特性
class Student : public Person
{
public:

private:
    string _StudentID;  // 派生类新增的专属属性:学号(学生特有的特征)
};

// 派生类(子类)Teacher:公有继承(public)自Person类
// 与Student类似,继承Person的通用属性/行为,同时扩展专属成员_EmployeeID
class Teacher : public Person
{
public:

private:
    string _EmployeeID;  // 派生类新增的专属属性:工号(教师特有的特征)
};

int main()
{
    // 创建Student类对象s
    // Student对象包含两部分:继承自Person的成员(_name/_age/Print()) + 自身新增的_StudentID
    Student s;
    // 创建Teacher类对象t
    // Teacher对象包含:继承自Person的成员 + 自身新增的_EmployeeID
    Teacher t;

    // 调用继承自Person的Print()成员函数
    // 公有继承下,基类的public成员可被派生类对象直接调用
    s.Print();
    t.Print();

    return 0;
}

一、继承的核心定义

继承是 C++ 面向对象三大核心特性(封装、继承、多态)之一,其核心目标是代码复用 + 层次化设计

  • 把多个类的通用属性 / 行为抽象到「基类(父类 / 超类)」中;
  • 「派生类(子类)」通过继承基类,直接复用基类的成员(属性 / 函数),同时可新增专属的属性 / 行为,实现 "通用逻辑复用,特殊逻辑扩展"。

简单说:继承让派生类 "站在基类的肩膀上",避免重复编写通用代码,同时能体现 "特殊化"(如学生 / 教师都是人,但有专属特征)。

二、继承的基本语法(结合代码拆解)

1. 派生类的定义语法

复制代码
class 派生类名 : 继承方式 基类名 {
    // 派生类专属成员(属性/函数)
};

代码中核心示例:

复制代码
// Student是派生类,public是继承方式,Person是基类
class Student : public Person { ... };
// Teacher是派生类,public继承Person
class Teacher : public Person { ... };
  • 继承方式:决定基类成员在派生类中的访问权限(代码中用public,是最常用的方式);
  • 基类:被继承的类(Person),封装通用特征;
  • 派生类:继承基类的类(Student/Teacher),复用 + 扩展特征。

2. 派生类的内存布局

派生类对象的内存由两部分组成:

  • 「继承自基类的所有成员」:即使是基类的私有成员(如_name/_age),也会占用派生类对象的内存(只是派生类无法直接访问);
  • 「派生类新增的专属成员」:如 Student 的_StudentID、Teacher 的_EmployeeID

代码中:

复制代码
Student s; // s的内存 = Person的_name + _age + Student的_StudentID
Teacher t; // t的内存 = Person的_name + _age + Teacher的_EmployeeID

三、公有继承(public)的核心访问规则(代码重点)

继承方式决定 "基类成员在派生类中的访问权限",代码中使用public继承(最常用,保留基类的接口可访问性),规则如下:

基类成员类型 基类中访问权限 公有继承后派生类中访问权限 派生类对象能否直接调用
public 类内 / 类外均可 仍为public 能(如s.Print()
private 仅类内可访问 不可直接访问(被隐藏) 不能(需通过基类 public 函数间接访问)
关键规则拆解:
  1. 基类public成员(如Print()):公有继承后,在派生类中仍为public,因此派生类对象(s/t)可直接调用Print()------ 这是复用基类行为的核心方式。

  2. 基类private成员(如_name/_age):派生类无法直接访问 (比如在 Student 类中写cout << _name;会编译报错),但可通过基类的public成员函数(如Print())间接访问 ------ 因为Print()是基类的成员函数,能访问基类的私有成员。

    代码中s.Print()的执行逻辑:

    • 调用继承自 Person 的Print()函数;
    • Print()在 Person 类内执行,能直接访问_name/_age,最终打印出这两个私有成员的值。

四、代码中继承的完整使用逻辑

1. 基类 Person:抽象通用特征

复制代码
class Person {
public:
    void Print() { ... } // 通用行为:打印姓名/年龄
private:
    string _name = "your name"; // 通用属性:姓名
    int _age = 18;              // 通用属性:年龄
};
  • 封装:_name/_age设为私有,仅通过Print()对外暴露,保证数据安全;
  • 复用:Print()_name/_age无需在 Student/Teacher 中重复定义。

2. 派生类 Student/Teacher:复用 + 扩展

复制代码
class Student : public Person {
private:
    string _StudentID; // 新增专属属性:学号(学生特有的特征)
};

class Teacher : public Person {
private:
    string _EmployeeID; // 新增专属属性:工号(教师特有的特征)
};
  • 复用:继承 Person 的Print()_name/_age,无需重新写;
  • 扩展:新增各自的专属成员,体现 "学生≠教师" 的特殊化。

3. main 函数:使用派生类对象

复制代码
Student s;
Teacher t;
s.Print(); // 调用继承的Print(),打印通用属性
t.Print(); // 同上
  • 派生类对象可直接使用基类的 public 成员,体现继承的 "复用价值";
  • 派生类的专属成员(_StudentID/_EmployeeID)可在类内扩展函数(如SetStudentID())来访问,实现完整的业务逻辑。

五、继承的核心价值

  1. 代码复用:通用属性 / 行为抽象到基类,派生类直接继承,避免重复编码(比如 Person 的 Print () 无需在 Student 和 Teacher 中各写一遍);
  2. 层次化设计:符合 "抽象→具体" 的逻辑(人是抽象概念,学生 / 教师是具体的人),代码结构更清晰;
  3. 可扩展性:新增 "角色类"(如 Worker)时,只需继承 Person 并新增专属成员,无需修改基类,符合 "开闭原则";
  4. 为多态铺路:继承是多态的前提(后续可通过虚函数实现 "一个接口,多种行为")。

六、补充:继承方式的对比(理解 public 的必要性)

除了public,还有protectedprivate继承,三者核心差异是 "基类 public 成员在派生类中的权限":

继承方式 基类 public 成员在派生类中的权限 派生类对象能否调用基类 public 成员 适用场景
public public 绝大多数场景(保留接口)
protected protected 不能 仅派生类内部复用基类接口
private private 不能 完全隐藏基类接口

代码中用public继承,正是为了让派生类对象(s/t)能直接调用基类的Print(),体现 "人都能打印姓名年龄" 的通用逻辑。

总结

继承的基本使用可概括为:

  1. 定义 :基类封装通用特征,派生类通过class 子类 : 继承方式 父类继承,新增专属特征;
  2. 访问:公有继承下,基类 public 成员可被派生类对象直接调用,基类 private 成员需通过基类 public 函数间接访问;
  3. 核心:复用通用代码,扩展特殊逻辑,实现面向对象的层次化设计。

基类和派生类对象赋值转换

复制代码
// 基类Person:封装人的通用属性和行为
class Person
{
public:
    void Print()
    {
        cout << "name is: " << _name << endl;
        cout << "age is: " << _age << endl;
    }

private:
    string _name = "your name";  // 私有成员:姓名
    int _age = 18;               // 私有成员:年龄
};

// 派生类Student:公有继承自Person,体现is-a关系(学生是一个人)
class Student : public Person
{
public:

private:
    string _StudentID;  // 派生类专属成员:学号
};

int main()
{
    // 场景1:int类型变量绑定到double类型的常量引用
    int a = 1;
    // 此处会产生临时对象:int类型的a无法直接绑定到double&,编译器会创建一个临时的double对象(值为1.0)
    // 临时对象具有「常性」(不可被修改),因此必须使用const修饰引用,否则编译报错
    const double& d = a;

    // 场景2:字符串字面量绑定到string类型的常量引用
    string str1 = "xxxxxx";  // 字符串字面量"xxxxxx"是const char*类型,赋值给string会调用构造函数创建str1对象
    // 此处会产生临时对象:const char*的字面量无法直接绑定到string&,编译器会创建临时的string对象
    // 临时对象具有常性,因此必须使用const修饰引用,否则编译报错
    const string& str2 = "xxxxxx";

    // 场景3:子类对象绑定到父类的普通引用(无const)
    Student s;  // 创建子类Student对象,包含Person基类部分和Student自身部分
    // 此处不会产生临时对象,而是发生「切片/切割」:父类引用rp仅指向子类对象s中的Person基类部分
    // 切片是直接引用子类对象的基类子对象,无临时对象产生,因此无需const修饰
    Person& rp = s;

    return 0;
}

一、基类与派生类的赋值转换规则

在 C++ 的继承体系中,仅支持 "派生类向基类的向上转换" (派生类→基类),这是继承的核心规则之一,也被称为 "子类型转换"。这种转换主要包含三种形式 ,其本质都是对派生类对象中 "基类部分" 的操作,核心表现为切片(切割) 现象。

赋值转换的三种合法形式

假设Derived是派生类(公有继承Base基类),Derived d;是派生类对象:

  1. 派生类对象赋值给基类对象 Base b = d; → 编译器将d继承自Base的成员 拷贝到b中,d的专属成员被 "切掉"(切片)。
  2. 基类指针指向派生类对象 Base* pb = &d; → 指针pb仅指向d基类部分的起始地址 ,无法访问d的专属成员(切片)。
  3. 基类引用绑定到派生类对象 Base& rb = d; → 引用rb直接绑定到d基类部分,与指针逻辑一致(代码场景 3 的核心)。

关键限制基类无法向派生类向下转换 (如Derived d = b;Derived* pd = &b;均编译报错)------ 因为基类对象没有派生类的专属成员,无法完成 "补全",强行转换会导致未定义行为。

二、切片(切割)的概念与本质(结合代码场景 3)

1. 切片的定义

当派生类对象赋值 / 赋值给基类对象、基类指针 / 引用指向 / 绑定派生类对象时,基类只能访问派生类对象中 "继承自基类的成员部分",派生类的专属成员会被编译器 "切掉" ,这个过程称为切片(Slicing)

2. 代码场景 3 的切片逻辑拆解

复制代码
Student s; // 派生类对象:包含Person基类部分(_name/_age/Print()) + Student专属部分(_StudentID)
Person& rp = s; // 基类引用绑定派生类对象,发生切片
  • 无临时对象产生:与代码中 "int→double""const char*→string" 的类型转换不同(这些会生成临时对象),StudentPerson子类型 (is-a 关系:学生是一个人),编译器直接让基类引用rp指向s对象中的Person基类部分,无需创建临时对象。
  • 切片的表现:通过rp只能调用Person的成员(如rp.Print()),无法访问s_StudentID(被切片切掉,基类引用 "看不到" 派生类专属成员)。

3. 切片的价值

切片是 C++ 实现 "基类接口复用" 的基础 ------ 无论派生类如何扩展,基类指针 / 引用都能通过统一的基类接口操作所有派生类对象(为后续多态铺路)。

三、为什么只有public继承才能发生切片?

切片的前提是派生类能被编译器识别为 "基类的子类型"(即满足 is-a 关系),而继承方式直接决定了这种子类型关系是否成立:

1. 继承方式与 "子类型关系" 的绑定

C++ 规定:

  • public继承 :体现is-a(是一个) 关系(如Student public Person → 学生是一个人)。派生类对外暴露基类的接口,编译器将其视为基类的子类型,允许派生类向基类的赋值转换,从而发生切片。
  • protected/private继承 :体现has-a(有一个) 关系(如Student private Person → 学生 "包含" 人的属性,但不是人的子类型)。编译器不认为派生类是基类的子类型,因此禁止派生类向基类的赋值转换,切片无法发生。

2. 访问权限的约束(底层原因)

继承方式不仅决定基类成员在派生类中的访问权限,还决定了 "基类的外部接口能否通过派生类对象被访问":

  • public继承 :基类的public成员在派生类中仍为public,派生类对象可直接调用基类的public成员(如s.Print()),基类的外部接口对派生类可见,满足子类型的接口一致性。
  • private继承 :基类的public成员在派生类中变为private,派生类对象无法直接调用基类的public成员(如s.Print()会编译报错),基类的外部接口被隐藏,编译器无法将其识别为基类的子类型。
  • protected继承 :基类的public成员在派生类中变为protected,仅派生类内部可访问,外部无法调用,同样不满足子类型的接口一致性。

3. 代码验证:非 public 继承的编译错误

若将代码中的public继承改为private

复制代码
// 私有继承Person
class Student : private Person { ... };

int main() {
    Student s;
    // 编译报错!private继承下,Student不是Person的子类型,禁止赋值转换
    Person& rp = s; 
    return 0;
}

编译器会直接拒绝这种赋值转换,因为private继承破坏了 is-a 的子类型关系,切片也就无从谈起。

四、切片与 "临时对象转换" 的对比(代码场景 1/2 vs 场景 3)

代码中场景 1(int→double)、场景 2(const char*→string)与场景 3(Student→Person)的核心差异,本质是 **"子类型转换" 与 "普通类型转换" 的区别 **:

特征 场景 1/2(普通类型转换) 场景 3(派生类→基类)
转换本质 不同类型的隐式转换,生成临时对象 子类型转换,直接绑定 / 指向基类部分(切片)
临时对象 产生(如 double 临时对象、string 临时对象) 不产生,直接操作原对象的基类部分
常性要求 临时对象是右值,具有常性,需const引用 操作原对象,无常性,普通引用即可
关系基础 编译器内置的类型转换规则 is-a 的继承关系(仅 public 继承)

例如场景 1 中const double& d = a;intdouble生成临时的double对象,临时对象无法被普通非const引用绑定(C++ 禁止普通引用绑定右值),因此必须加const;而场景 3 中Person& rp = s;无临时对象,直接绑定s的基类部分,因此普通引用即可。

五、总结

  1. 基类与派生类的赋值转换 :仅支持 "派生类→基类" 的向上转换,核心表现为切片------ 基类只能访问派生类对象的基类部分,派生类专属成员被 "切掉"。
  2. 切片的本质:基类指针 / 引用 / 对象仅能操作派生类的基类成员,是 C++ 实现 "基类接口统一操作派生类" 的基础。
  3. public 继承是切片的前提
    • public继承体现 is-a 关系,派生类被视为基类的子类型,编译器允许赋值转换和切片;
    • protected/private继承体现 has-a 关系,派生类不被视为基类的子类型,禁止赋值转换和切片。
  4. 切片与普通类型转换的区别 :切片无临时对象,无需const;普通类型转换生成临时对象,临时对象有常性,需const引用绑定。

切片是继承中 "通用接口操作具体派生类" 的核心机制,也是后续学习多态(虚函数) 的重要前提 ------ 而public继承则是保证这种机制合法的基础。


基类与派生类的六大成员函数

复制代码
// 基类Person:封装人的通用属性(姓名)和各类特殊成员函数(构造、拷贝构造、赋值重载、析构)
class Person
{
public:
	// 普通构造函数:带默认参数的构造函数,初始化姓名成员
	// 参数name:姓名,默认值为"XXX"
	Person(const string& name = "XXX")
		:_name(name)  // 初始化列表:初始化基类的_name成员
	{
		cout << "Person(const string& name = \"XXX\")" << endl;
	}

	// 拷贝构造函数:用已存在的Person对象初始化新的Person对象
	// 规则:当用同类对象初始化新对象时调用,参数必须是const引用(避免无限递归+支持const对象)
	// 参数p:被拷贝的Person对象(const引用)
	Person(const Person& p)
		: _name(p._name)  // 拷贝被拷贝对象的_name成员
	{
		cout << "Person(const Person& p)" << endl;
	}

	// 赋值运算符重载函数:两个已存在的Person对象之间的赋值
	// 规则:左值对象(*this)和右值对象(p)都是已初始化的,需先判断自赋值,再拷贝成员
	// 参数p:被赋值的Person对象(const引用)
	// 返回值:*this的引用(支持连续赋值,如a = b = c)
	Person& operator=(const Person& p)
	{
		if (&p != this)  // 防止自赋值(如a = a),避免无意义操作和内存错误
		{
			_name = p._name;  // 拷贝姓名成员
		}

		cout << "Person& operator=(const Person& p)" << endl;

		return *this;  // 返回自身引用,支持连续赋值
	}

	// 析构函数:对象销毁时自动调用,用于清理资源(此处无动态资源,仅打印日志)
	~Person()
	{
		cout << "~Person()" << endl;
	}

protected:  // 保护成员:基类私有,派生类内部可访问,外部不可访问
	string _name;  // 姓名
};

// 派生类Student:公有继承自Person,继承基类的属性和行为,同时扩展专属属性(学号_id)
class Student : public Person
{
public:
	
    // 【注释掉的代码说明】子类默认构造的隐式父类构造调用
	// 如果子类构造函数的初始化列表中未显式调用父类构造函数,编译器会自动调用父类的默认构造函数(无参/带默认参数)
	// 调用顺序:先执行父类构造,再执行子类构造(先父后子)
	/*
	Student(string id = "123456")
		: _id(id)  // 仅初始化子类自身成员,编译器自动补全Person()的调用
	{
		cout << "Student(string id = \"123456\")" << endl;
		cout << _name << endl;  // 可访问基类的protected成员_name
	}
	*/

	// 子类普通构造函数:显式调用父类的带参构造函数
	// 参数name:学生姓名(传递给父类),默认值"张三";参数id:学号,默认值"123456"
	// 初始化列表规则:先调用父类构造函数(Person(name)),再初始化子类自身成员(_id(id))
	Student(string name = "张三", string id = "123456")
		: Person(name)  // 显式调用父类的带参构造函数,初始化基类部分
		, _id(id)       // 初始化子类自身的学号成员
	{
		cout << "Student(string name = \"张三\", string id = \"123456\")" << endl;
	}

	// 子类拷贝构造函数:用已存在的Student对象初始化新的Student对象
	// 参数s:被拷贝的Student对象(const引用)
	// 初始化列表规则:先调用父类的拷贝构造函数(Person(s)),再初始化子类自身成员(_id(s._id))
	// 关键:Person(s)中会发生「切片/切割」------将子类对象s切割为基类Person的部分,传递给父类拷贝构造
	Student(const Student& s)
		: Person(s)  // 切片:取s的Person基类部分,调用父类拷贝构造初始化基类子对象
		, _id(s._id) // 拷贝被拷贝对象的学号成员
	{
		cout << "Student(const Student& s)" << endl;
	}

	// 子类赋值运算符重载函数:两个已存在的Student对象之间的赋值
	// 规则:需先调用父类的赋值重载函数完成基类部分的赋值,再处理子类自身成员的赋值
	// 参数s:被赋值的Student对象(const引用)
	// 返回值:*this的引用(支持连续赋值)
	Student& operator=(const Student& s)
	{
		if (&s != this)  // 防止自赋值
		{
			// 显式调用父类的赋值重载函数,完成基类部分(_name)的赋值
			// 若不显式调用,父类部分的成员不会被赋值(编译器不会自动补全)
			Person::operator=(s);  // 切片:将s切割为Person部分传递给父类赋值重载
			_id = s._id;           // 赋值子类自身的学号成员
		}

		cout << "Student& operator=(const Student& s)" << endl;

		return *this;
	}

	// 子类析构函数:对象销毁时调用,清理子类专属资源(此处无动态资源,仅打印日志)
	// 关键规则1:析构函数的「隐藏」------由于多态机制,编译器会将所有析构函数统一处理为名为`destructor`的函数,
	//          因此子类析构函数会隐藏父类的析构函数(同名覆盖)。
	// 关键规则2:析构顺序------先执行子类析构,再自动执行父类析构(先子后父),无需显式调用父类析构。
	~Student()
	{
		cout << "~Student()" << endl;
	}

	// 成员函数:打印学生的姓名(继承自父类)和学号(自身成员)
	void Print()
	{
		cout << "name is: " << _name << endl;  // 访问基类的protected成员
		cout << "id is: " << _id << endl;      // 访问自身的private成员
	}

private:
	string _id;  // 子类专属私有成员:学号
};

int main()
{
	// 1. 创建Student对象s1:调用父类带参构造 → 调用子类带参构造
	// 传递参数:姓名"李四",学号"111111"
	Student s1("李四", "111111");
	cout << "-------------------------" << endl;

	// 2. 用s1拷贝构造Student对象s2:调用父类拷贝构造 → 调用子类拷贝构造
	// s2的姓名和学号与s1一致
	Student s2(s1);
	cout << "-------------------------" << endl;

	// 3. 创建Student对象s3:调用父类带参构造 → 调用子类带参构造
	// 传递参数:姓名"王五",学号"222222"
	Student s3("王五", "222222");
	// 4. 将s1赋值给s3:调用父类赋值重载 → 调用子类赋值重载
	// s3的姓名变为"李四",学号变为"111111"
	s3 = s1;
	cout << "-------------------------" << endl;

	// 5. 程序结束,对象销毁:按构造的逆序销毁(s3 → s2 → s1),每个对象先析构子类,再析构父类
	return 0;
}

一、基类与派生类的默认成员函数核心规则

在 C++ 继承体系中,派生类的默认成员函数(构造、拷贝构造、赋值重载、析构) 并非孤立存在,而是与基类的对应成员函数深度关联:

  • 若派生类未显式定义 某类默认成员函数,编译器会自动生成合成版本 ,且合成版本会自动调用基类的对应成员函数,完成基类部分的初始化 / 拷贝 / 赋值 / 清理;
  • 若派生类显式定义 了某类默认成员函数,编译器不会自动调用基类的对应成员,需程序员手动显式调用,否则基类部分的成员将无法被正确初始化 / 拷贝 / 赋值。

派生类的默认成员函数需处理两部分内容:基类继承的成员 + 派生类新增的成员,其中基类部分必须通过基类的成员函数处理(封装特性决定派生类无法直接操作基类私有 / 保护成员的底层初始化)。

二、构造函数:先父后子的执行顺序 + 自动调用父类默认构造的规则

1. 构造函数的调用规则

  • 显式调用 :若派生类构造函数的初始化列表 中显式写了基类名(参数),则调用基类的对应带参构造(如代码中Student(name, id)Person(name));
  • 自动调用 :若派生类构造函数的初始化列表中未显式调用基类构造 ,编译器会自动补全对基类默认构造函数(无参 / 全缺省)的调用 (如代码中注释掉的Student(string id)会自动调用Person())。

2. 为什么构造必须 "先父后子"?

派生类的成员(尤其是新增成员)可能依赖基类的成员,若基类未先初始化,派生类的初始化会因基类成员未定义而产生未定义行为。具体原因:

  • 封装性约束 :派生类无法直接初始化基类的私有 / 保护成员(如_name),必须通过基类的构造函数完成初始化;
  • 依赖关系 :假设基类有一个_age成员,派生类Student_grade(年级)需要根据_age计算,若基类未先初始化_age_grade的计算会基于随机值;
  • 内存布局 :派生类对象的内存中,基类部分位于派生类新增成员之前(如Person_nameStudent_id之前),内存的初始化必须遵循 "先分配先初始化" 的顺序。

代码中的执行示例:

复制代码
Student s1("李四", "111111");
// 执行顺序:
// 1. 调用Person("李四")(父类构造)→ 初始化_name为"李四";
// 2. 调用Student的构造函数 → 初始化_id为"111111";
// 控制台打印:
// Person(const string& name = "XXX")
// Student(string name = "张三", string id = "123456")

3. 未显式调用父类构造的后果

若基类没有默认构造函数(无参 / 全缺省),且派生类构造函数未显式调用基类的带参构造,编译器会直接报错。例如:

复制代码
// 基类无默认构造
class Person {
public:
    Person(const string& name) : _name(name) {} // 仅带参构造,无默认构造
};
// 派生类未显式调用基类构造
class Student : public Person {
public:
    Student(string id) : _id(id) {} // 编译报错!编译器无法自动调用Person的默认构造
};

这是因为编译器无法 "猜" 出基类构造的参数,必须由程序员显式指定。

三、拷贝构造与赋值运算符重载:自动调用的条件 + 显式调用的必要性

1. 拷贝构造函数

  • 自动调用基类拷贝构造的情况 :若派生类未显式定义拷贝构造 ,编译器生成的合成拷贝构造会自动调用基类的拷贝构造(通过切片将派生类对象转为基类部分),再拷贝派生类新增成员;
  • 显式定义时的要求 :若派生类显式定义拷贝构造,必须在初始化列表中手动调用基类的拷贝构造 (如代码中Student(const Student& s)Person(s)),否则基类部分会被默认构造(而非拷贝),导致基类成员初始化错误。

代码中的执行示例:

复制代码
Student s2(s1);
// 执行顺序:
// 1. 调用Person(const Person& p)(父类拷贝构造)→ 切片取s1的Person部分,拷贝_name为"李四";
// 2. 调用Student的拷贝构造 → 拷贝_id为"111111";
// 控制台打印:
// Person(const Person& p)
// Student(const Student& s)

2. 赋值运算符重载

  • 自动调用基类赋值重载的情况 :若派生类未显式定义赋值重载 ,编译器生成的合成赋值重载会自动调用基类的赋值重载(切片传递参数),再赋值派生类新增成员;
  • 显式定义时的要求 :若派生类显式定义赋值重载,必须手动调用基类的赋值重载 (如代码中Person::operator=(s)),否则基类部分的成员不会被赋值(仅派生类新增成员被赋值),导致基类成员数据不一致。

代码中的执行示例:

复制代码
s3 = s1;
// 执行顺序:
// 1. 调用Person& operator=(const Person& p)(父类赋值重载)→ 切片取s1的Person部分,将s3的_name改为"李四";
// 2. 执行Student的赋值逻辑 → 将s3的_id改为"111111";
// 控制台打印:
// Person& operator=(const Person& p)
// Student& operator=(const Student& s)

核心原因:切片机制

无论是拷贝构造还是赋值重载,传递给基类的参数都是派生类对象的基类部分(切片),这是因为基类的成员函数只能处理基类类型的对象,无法直接识别派生类的新增成员。

四、析构函数:先子后父的执行顺序 + 显式调用父类析构的风险

1. 析构函数的调用规则

  • 派生类析构函数无需显式调用基类析构 ,编译器会在派生类析构函数执行完毕后,自动调用基类的析构函数
  • 析构函数的隐藏特性 :由于多态机制,编译器会将所有析构函数统一处理为名为destructor的函数,因此派生类析构函数会 "隐藏" 基类的析构函数(同名覆盖),无法通过派生类对象直接调用基类析构。

2. 为什么析构必须 "先子后父"?

派生类的新增成员可能依赖基类的资源,若先析构基类,派生类析构时会访问已被释放的基类资源,导致未定义行为。具体原因:

  • 资源依赖 :假设基类有一个动态分配的char* _buf,派生类Student_studentInfo存储在_buf中,若先析构基类(释放_buf),派生类析构时访问_studentInfo会触发野指针错误;
  • 内存释放顺序:与构造的 "先分配先初始化" 对应,析构遵循 "后分配先释放" 的原则(派生类新增成员在基类之后分配,因此先释放);
  • 封装性约束:派生类无法直接清理基类的资源,必须由基类析构函数完成,而基类资源的清理需在派生类不再使用之后。

代码中的执行示例(程序结束时对象销毁):

复制代码
// 销毁顺序:s3 → s2 → s1(栈对象按创建逆序销毁),每个对象的析构顺序:
// 1. 调用~Student()(子类析构);
// 2. 自动调用~Person()(父类析构);
// 控制台打印(以s1为例):
// ~Student()
// ~Person()

3. 显式调用父类析构的风险

若在派生类析构函数中显式调用父类析构 (如Person::~Person();),会导致基类部分被多次析构,引发严重的内存错误,具体风险包括:

  • 重复析构基类资源 :派生类析构时显式调用一次父类析构,编译器在派生类析构结束后又自动调用一次父类析构,基类的动态资源(如new[]分配的数组)会被delete[]两次,触发程序崩溃;
  • 对象布局破坏:析构后的对象内存会被标记为无效,多次析构会导致内存布局混乱,可能被其他程序覆盖,引发未定义行为;
  • 多态场景下的双重释放:若基类析构是虚函数,通过基类指针指向派生类对象时,显式调用父类析构会与多态的自动析构冲突,加剧资源重复释放的问题。

例如,若在Student析构中显式调用父类析构:

复制代码
~Student()
{
    cout << "~Student()" << endl;
    Person::~Person(); // 显式调用父类析构
}

程序运行时,Student对象销毁会打印:

复制代码
~Student()
~Person() // 显式调用的析构
~Person() // 编译器自动调用的析构

基类的_name(虽无动态资源)会被两次析构,若基类有动态资源,直接导致程序崩溃。

五、总结

  1. 默认成员函数的关联规则

    • 派生类未显式定义默认成员函数时,编译器生成的合成版本会自动调用基类的对应成员,处理基类部分;
    • 派生类显式定义时,需手动调用基类的对应成员(构造、拷贝构造、赋值重载),否则基类部分无法被正确初始化 / 拷贝 / 赋值。
  2. 构造顺序:先父后子:派生类依赖基类的成员,必须先初始化基类才能保证派生类初始化的合法性,同时符合内存 "先分配先初始化" 的规则。

  3. 析构顺序:先子后父:派生类可能依赖基类的资源,先析构派生类可避免访问已释放的基类资源,符合内存 "后分配先释放" 的规则。

  4. 显式调用父类析构的风险 :会导致基类部分被多次析构,引发资源重复释放、内存布局破坏等未定义行为,绝对禁止在派生类析构中显式调用父类析构

继承体系中默认成员函数的这些规则,本质是为了保证基类与派生类的成员都能被正确初始化和清理,同时遵循 C++ 的封装和内存管理原则。


菱形继承与菱形虚拟继承

菱形继承带来的二义性和数据冗余问题

复制代码
// 基类Person:封装人的通用属性(姓名、年龄),作为Student和Teacher的共同基类
// 该类是菱形继承的「顶层基类」,后续会被Student和Teacher分别继承,最终Assistant会间接继承两份Person成员
class Person
{
public:
	string _name;  // 姓名(公共成员,可被派生类直接访问)
	int _age;      // 年龄(公共成员,可被派生类直接访问)
};

// 派生类Student:公有继承自Person,代表学生类
// 继承Person的_name和_age,同时新增学生专属属性_StudentID(学号)
class Student : public Person
{
protected:
	string _StudentID;  // 学号(保护成员,派生类可访问,外部不可访问)
};

// 派生类Teacher:公有继承自Person,代表教师类
// 继承Person的_name和_age,同时新增教师专属属性_EmployeeID(工号)
class Teacher : public Person
{
protected:
	string _EmployeeID;  // 工号(保护成员,派生类可访问,外部不可访问)
};

// 派生类Assistant:公有继承自Student和Teacher,代表助教类(既是学生也是教师)
// 此处形成「菱形继承(钻石继承)」:
// Person是顶层基类 → Student和Teacher是中间派生类 → Assistant是最终派生类
// 问题:Assistant会间接继承**两份Person的成员**(一份来自Student,一份来自Teacher),导致二义性和数据冗余
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;  // 助教的主修课程(保护成员)
};

int main()
{
	Assistant a;  // 创建助教对象a,其内存中包含两份Person子对象(Student::Person和Teacher::Person)
	
	// 【错误代码】直接访问_name会触发编译错误,原因是二义性:
	// a的_name有两个来源(Student继承的Person::_name 和 Teacher继承的Person::_name),编译器无法确定访问哪一个
	// a._name = "wangfang";  // error: reference to '_name' is ambiguous

	// 【解决二义性的方法】通过「作用域限定符::」明确指定访问哪一份Person的成员
	// 但该方法仅解决了二义性,**未解决数据冗余问题**(a中仍有两份_name和_age,分别赋值会存储两个不同的值)
	a.Student::_name = "Studentwang";  // 给Student继承的Person::_name赋值
	a.Teacher::_name = "TeacherWang";  // 给Teacher继承的Person::_name赋值

	return 0;
}

virtual 菱形虚拟继承

复制代码
// 基类Person:封装人的通用属性(姓名、年龄),是菱形继承的顶层基类
// 后续会被Student和Teacher通过虚继承方式继承,最终Assistant只会拥有一份Person成员
class Person
{
public:
	string _name;  // 姓名(公共成员,可被派生类直接访问)
	int _age;      // 年龄(公共成员,可被派生类直接访问)
};

// 派生类Student:**虚继承**自Person类(virtual关键字)
// 虚继承的作用:让Student不再直接存储独立的Person子对象,而是通过虚基类表间接指向共享的Person基类对象
// 最终Assistant继承Student时,不会再复制一份Person成员,而是共享同一份
class Student : virtual public Person
{
protected:
	string _StudentID;  // 学生专属属性:学号(保护成员)
};

// 派生类Teacher:**虚继承**自Person类(virtual关键字)
// 与Student同理,Teacher也不会存储独立的Person子对象,而是指向共享的Person基类对象
class Teacher : virtual public Person
{
protected:
	string _EmployeeID;  // 教师专属属性:工号(保护成员)
};

// 派生类Assistant:公有继承自Student和Teacher,代表助教类(既是学生也是教师)
// 由于Student和Teacher都是虚继承Person,Assistant中只会拥有**唯一的一份**Person基类成员
// 彻底解决了普通菱形继承的「二义性」和「数据冗余」问题
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;  // 助教专属属性:主修课程(保护成员)
};

int main()
{
	Assistant a;  // 创建助教对象a,其内存中仅包含**一份**Person子对象(由Student和Teacher共享)

	// 【看似指定作用域,实则修改同一份Person::_name】
	// 由于虚继承的共享机制,Student::_name和Teacher::_name都指向唯一的Person::_name
	// 此处赋值会覆盖前一次的结果,而非修改两份不同的成员
	a.Student::_name = "Studentwang";  // 给共享的Person::_name赋值为"Studentwang"
	a.Teacher::_name = "Teachertwang"; // 覆盖为"Teachertwang"(仍修改的是同一份_name)

	// 【直接访问无歧义】
	// 虚继承后,Assistant中仅存在一份Person::_name,因此可直接访问,无二义性
	// 此赋值会覆盖之前的"Teachertwang",最终_name的值为"wangfang"
	a._name = "wangfang";

	return 0;
}

一、先明确:普通菱形继承的致命问题(虚继承的诞生背景)

在未使用虚继承的普通菱形继承中(Student 和 Teacher 直接 public 继承 Person,Assistant 继承 Student 和 Teacher),会产生两个核心问题:

1. 数据冗余

Assistant对象的内存中会包含两份 Person 基类子对象 :一份来自 Student,一份来自 Teacher。这意味着_name_ageAssistant中存储了两次,造成内存浪费。

2. 二义性

当访问Assistant的 Person 成员时(如a._name),编译器无法确定是访问Student继承的_name,还是Teacher继承的_name,直接编译报错;即使通过作用域指定(a.Student::_name),修改的也是两份不同的_name,逻辑上不符合 "助教是一个人(仅一个姓名)" 的现实意义。

虚继承的核心目标就是彻底解决这两个问题 ,其本质是将顶层基类Person从 "被腰部类复制" 改为 "被所有派生类共享"。

二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象

虚继承并非让StudentTeacher"不继承 Person",而是改变了继承的内存布局方式

  • 普通继承Student/Teacher的内存中直接包含独立的 Person 子对象 (副本),Assistant会继承这两个副本,导致冗余和二义性;
  • 虚继承 :将顶层基类Person抽离为一个独立的、全局共享的虚基类子对象 ,放在Assistant内存的专属区域;StudentTeacher不再持有 Person 的副本,而是通过偏移量间接指向这个共享的 Person 对象。

最终Assistant的内存中只有一份 Person 子对象 ,所有对Student::_nameTeacher::_name_name的访问,都会指向这同一个 Person 的成员,既解决了数据冗余(仅一份数据),又解决了二义性(只有一个访问目标)。

三、virtual关键字的位置要求:必须加在菱形的腰部

虚继承的virtual关键字必须声明在 "菱形腰部" 的类继承顶层基类时 (即StudentTeacher继承Person时加virtual),而非在Assistant继承Student/Teacher时加。原因如下:

  1. 菱形继承的结构分层

    • 顶层:Person(被共享的基类);
    • 腰部:StudentTeacher(直接继承顶层基类的类);
    • 底部:Assistant(继承腰部类的最终派生类)。虚继承的核心是让腰部类放弃持有顶层基类的副本 ,改为引用共享的顶层基类对象。如果仅在底部Assistant继承Student/Teacher时加virtual,腰部的Student/Teacher已经持有了 Person 的副本,依然会导致Assistant中有两份 Person,无法解决问题。
  2. 语法规则验证 :若错误地将virtual加在底部(而非腰部):

    复制代码
    // 错误写法:腰部未加virtual,底部加virtual无意义
    class Student : public Person {}; // 普通继承,持有Person副本
    class Teacher : public Person {}; // 普通继承,持有Person副本
    class Assistant : virtual public Student, virtual public Teacher {}; 

    此时Assistant中依然有两份 Person 子对象(来自 Student 和 Teacher),二义性和冗余问题仍然存在。

结论 :只要是菱形继承,所有腰部类继承顶层基类时都必须加virtual(无论腰部有多少个类),才能让最终的底部类共享唯一的顶层基类对象。

四、虚继承的内存布局:偏移量表(虚基类表)的工作原理

虚继承的核心是通过虚基类表指针(vbtptr)虚基类表(vbtable) 实现对共享 Person 对象的间接访问,而非直接存储 Person 副本。我们以Assistant a的内存布局为例拆解:

1. Student/Teacher的内存组成(虚继承后)

虚继承的Student/Teacher对象中,不再包含独立的 Person 子对象,而是新增了一个虚基类表指针(vbtptr) (占 4/8 字节,取决于系统位数),该指针指向一份虚基类表(vbtable)(存储在只读数据区的全局表)。

  • 虚基类表指针(vbtptr) :是Student/Teacher对象的第一个成员,指向虚基类表的起始地址;
  • 虚基类表(vbtable) :本质是一个数组,存储的核心数据是当前类(Student/Teacher)到共享 Person 对象的偏移量 (即从Student对象的起始地址,到共享 Person 对象起始地址的字节数)。

2. Assistant的内存布局(最终形态)

Assistant对象的内存分为三部分:

  1. Student的成员:包含vbtptr(Student 的虚基类表指针) + _StudentID
  2. Teacher的成员:包含vbtptr(Teacher 的虚基类表指针) + _EmployeeID
  3. 共享的 Person 子对象 :独立存储在Assistant内存的末尾,包含_name + _age
  4. Assistant的新增成员:_majorCourse

3. 访问 Person 成员的具体流程(以a.Student::_name为例)

当代码执行a.Student::_name = "Studentwang"时,编译器会按以下步骤找到共享的 Person 对象:

  1. Assistant对象a中,定位到Student部分的虚基类表指针(vbtptr)
  2. 通过vbtptr找到Student虚基类表(vbtable)
  3. 从虚基类表中读取Student到共享Person对象的偏移量
  4. Student部分的起始地址 + 偏移量,计算出共享 Person 对象的实际地址
  5. 访问该地址下的_name成员,完成赋值。

同理,当执行a.Teacher::_name = "Teachertwang"时,会通过Teachervbtptr和偏移量,最终也指向同一个 Person 对象_name,因此后一次赋值会覆盖前一次的结果 ------ 这就是 "所有访问都指向同一份 Person 成员" 的底层原因。

五、总结:虚继承解决问题的本质

  1. 数据冗余的解决 :通过将顶层基类Person抽离为共享的虚基类子对象Assistant中仅存储一份 Person 数据,而非两份副本;
  2. 二义性的解决 :无论通过Student还是Teacher访问 Person 成员,最终都会通过偏移量表找到同一个共享的 Person 对象,访问目标唯一;
  3. virtual 的位置:必须加在菱形 "腰部"(Student/Teacher 继承 Person 时),才能让腰部类放弃持有 Person 副本,改为引用共享对象;
  4. 偏移量表的作用 :Student/Teacher 通过vbtptr指向vbtable,再通过表中的偏移量找到共享的 Person,实现对 Person 成员的间接访问,保证了 "无论从哪个腰部类访问,都指向同一份 Person"。

虚继承是 C++ 为解决菱形继承缺陷设计的机制,其代价是引入了虚基类表的间接访问(微小的性能开销),但换来了数据的一致性和内存的节省,是实现 "多继承下共享顶层基类" 的核心方案。

相关推荐
咔咔咔的2 小时前
955. 删列造序 II
c++
xu_yule3 小时前
算法基础(数论)—欧拉函数
c++·算法·欧拉函数
xu_yule3 小时前
算法基础(数学)—数论
c++·算法·数论·最大公约数和最小公倍数·质数的判定·筛质数
Sheep Shaun3 小时前
二叉搜索树(下篇):删除、优化与应用
数据结构·c++·b树·算法
superman超哥3 小时前
仓颉借用检查器工作原理深度解析
c语言·开发语言·c++·python·仓颉
CoderCodingNo4 小时前
【GESP】C++五级真题(数论考点) luogu-B3871 [GESP202309 五级] 因数分解
开发语言·c++
ComputerInBook4 小时前
C++编程语言:标准库:第43章——C语言标准库(Bjarne Stroustrup)
c语言·c++·c语言标准库
wildlily84274 小时前
C++ Primer 第5版章节题 第九章
开发语言·c++
特立独行的猫a4 小时前
c++弱引用指针std::weak_ptr作用详解
开发语言·c++·智能指针·弱指针·weak_ptr