目录
[二、仿函数的核心特性(对比普通函数 / 函数指针)](#二、仿函数的核心特性(对比普通函数 / 函数指针))
[二、非类型模板参数的核心作用(结合 Stack 模板拆解)](#二、非类型模板参数的核心作用(结合 Stack 模板拆解))
[四、非类型模板参数 vs 传统常量方案(核心对比)](#四、非类型模板参数 vs 传统常量方案(核心对比))
[二、全特化(Full Specialization):所有模板参数 "完全固定"](#二、全特化(Full Specialization):所有模板参数 “完全固定”)
[三、偏特化(Partial Specialization):部分模板参数 "固定 / 加约束"](#三、偏特化(Partial Specialization):部分模板参数 “固定 / 加约束”)
继承方式和访问限定符(public、protected、private)
继承[六、补充:继承方式的对比(理解 public 的必要性)](#六、补充:继承方式的对比(理解 public 的必要性))
[二、切片(切割)的概念与本质(结合代码场景 3)](#二、切片(切割)的概念与本质(结合代码场景 3))
[四、切片与 "临时对象转换" 的对比(代码场景 1/2 vs 场景 3)](#四、切片与 “临时对象转换” 的对比(代码场景 1/2 vs 场景 3))
[二、构造函数:先父后子的执行顺序 + 自动调用父类默认构造的规则](#二、构造函数:先父后子的执行顺序 + 自动调用父类默认构造的规则)
[三、拷贝构造与赋值运算符重载:自动调用的条件 + 显式调用的必要性](#三、拷贝构造与赋值运算符重载:自动调用的条件 + 显式调用的必要性)
[四、析构函数:先子后父的执行顺序 + 显式调用父类析构的风险](#四、析构函数:先子后父的执行顺序 + 显式调用父类析构的风险)
[virtual 菱形虚拟继承](#virtual 菱形虚拟继承)
[二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象](#二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象)
仿函数
// 定义仿函数(函数对象)类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 算法(如
sort、find)中定制逻辑的核心工具。
二、仿函数的核心特性(对比普通函数 / 函数指针)
| 特性 | 仿函数(函数对象) | 普通函数 | 函数指针 |
|---|---|---|---|
| 调用形式 | 对象名(参数),与普通函数完全一致,直观 |
函数名(参数) |
指针名(参数) |
| 状态存储 | 支持(通过类的成员变量存储状态,无副作用) | 不支持(需全局变量,有副作用) | 不支持 |
| 类型明确性 | 类型唯一,模板推导高效(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 算法更高效
仿函数是具体的类类型(如Less、LessWithCount),而函数指针是抽象的 "函数地址类型"。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)
- 两种方式逻辑完全一致,隐式调用更符合 "函数" 的使用习惯,是实际开发中的首选。
四、仿函数的典型应用场景
- STL 算法的谓词参数 :定制
sort(排序规则)、find_if(查找条件)、count_if(计数条件)等算法的逻辑; - 自定义回调逻辑:替代函数指针,实现带状态的回调(如异步任务的回调、事件处理);
- 模板元编程:在编译期计算、类型推导等场景中,仿函数是核心工具;
- 策略模式实现:将不同的业务策略(如不同的比较规则、计算规则)封装为仿函数,动态切换。
总结
仿函数的核心是 "以对象之形,行函数之事":
- 语法上:重载
operator(),让对象可像函数一样调用; - 特性上:兼具函数的易用性和类的状态存储 / 定制化能力;
- 效率上:类型明确,易被编译器优化,优于函数指针;
- 场景上:是 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> st1、Stack<double, 10000> st2:N=10/10000是编译期字面量常量,满足 "静态数组大小必须编译期确定" 的要求;- 编译器会为不同的
N生成不同的静态数组:st1的_a是int[10],st2的_a是double[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. 类型限制(仅支持以下类型)
非类型模板参数的类型必须是 "编译期可求值的常量类型",常见的有:
- 整数类型:
int、size_t、bool、char、long等(代码中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>是两个完全独立的类,就像int和double是不同类型一样:
- 无法将
Stack<int, 10>的对象赋值给Stack<int, 20>的对象(类型不匹配); - 各自的成员函数(如
push)是独立的,编译器为每个N生成专属代码。
四、非类型模板参数 vs 传统常量方案(核心对比)
| 方案 | 非类型模板参数 | #define 宏 | 类内 const 成员 | 构造函数传参 |
|---|---|---|---|---|
| 类型安全 | 有(编译期类型检查) | 无(文本替换) | 有,但无法做数组大小 | 有,但运行期确定 |
| 编译期确定 | 是 | 是(预处理阶段) | 否(运行期) | 否(运行期) |
| 内存开销 | 无(静态数组) | 无,但全局生效 | 有(动态数组) | 有(动态数组 + 成员) |
| 定制化能力 | 多实例,多容量 | 全局单一值 | 单一容量 | 多容量,但类型相同 |
总结
非类型模板参数的核心是 "编译期常量值的占位符",其核心作用可概括为:
- 让模板不仅能定制 "类型"(
T),还能定制 "编译期固定的常量值"(N),实现 "类型 + 值" 的双重定制; - 编译期确定常量值,支持静态数组、编译期优化,消除动态内存分配开销;
- 类型安全,替代预处理宏,从编译期规避非法值风险;
- 生成独立模板实例,实现类型隔离,提升代码安全性和执行效率。
非类型模板参数尤其适合 "需要编译期固定大小 / 常量" 的场景(如静态数组容器、固定容量栈 / 队列、编译期计算等),是 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):部分模板参数 "固定 / 加约束"
偏特化也叫 "局部特化",分为两种常见形式(代码中是第一种):
- 参数数量偏特化:保留部分泛化参数,固定另一部分(如固定 T2=double,保留 T1 泛化);
- 参数性质偏特化 :对参数加约束(如固定 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. 规避泛化模板的语法限制
某些类型不支持泛化模板的默认操作(如泛化模板用+运算符,但自定义类型未重载+),特化版本可替换为适配该类型的逻辑。
六、特化的关键注意事项
- 特化必须基于泛化模板:先定义泛化模板,才能定义其特化版本(编译器需先识别基础模板);
- 特化版本的类名必须与泛化版本一致 :如泛化是
Data,特化也必须是Data(只是参数组合不同); - 偏特化不是 "函数重载":偏特化是模板的特化,而非类的重载,仅针对模板参数做约束,而非参数个数 / 类型;
- 特化版本需重新定义成员:特化版本的成员(变量 / 函数)不会继承泛化版本,需按需重新实现。
总结
类模板特化是 "通用模板 + 定制化扩展" 的核心机制:
- 全特化:针对 "唯一的参数组合" 定制实现,所有参数固定,匹配优先级最高;
- 偏特化:针对 "一类参数组合" 定制实现,部分参数固定 / 加约束,匹配优先级次之;
- 核心价值:既保留泛化模板的代码复用,又解决 "特殊类型 / 参数组合需要特殊逻辑" 的问题;
- 匹配规则:全特化 > 偏特化 > 泛化,编译器自动选择最匹配的版本。
特化是 STL(如std::vector对bool类型的特化)、泛型编程中处理 "特殊场景" 的核心手段,让模板既通用又灵活。
继承方式和访问限定符(public、protected、private)
| 类成员 / 继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| 基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
- 基类的
private成员:无论采用哪种继承方式,在派生类中均不可见。 - 基类的
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 函数间接访问) |
关键规则拆解:
-
基类
public成员(如Print()):公有继承后,在派生类中仍为public,因此派生类对象(s/t)可直接调用Print()------ 这是复用基类行为的核心方式。 -
基类
private成员(如_name/_age):派生类无法直接访问 (比如在 Student 类中写cout << _name;会编译报错),但可通过基类的public成员函数(如Print())间接访问 ------ 因为Print()是基类的成员函数,能访问基类的私有成员。代码中
s.Print()的执行逻辑:- 调用继承自 Person 的
Print()函数; Print()在 Person 类内执行,能直接访问_name/_age,最终打印出这两个私有成员的值。
- 调用继承自 Person 的
四、代码中继承的完整使用逻辑
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())来访问,实现完整的业务逻辑。
五、继承的核心价值
- 代码复用:通用属性 / 行为抽象到基类,派生类直接继承,避免重复编码(比如 Person 的 Print () 无需在 Student 和 Teacher 中各写一遍);
- 层次化设计:符合 "抽象→具体" 的逻辑(人是抽象概念,学生 / 教师是具体的人),代码结构更清晰;
- 可扩展性:新增 "角色类"(如 Worker)时,只需继承 Person 并新增专属成员,无需修改基类,符合 "开闭原则";
- 为多态铺路:继承是多态的前提(后续可通过虚函数实现 "一个接口,多种行为")。
六、补充:继承方式的对比(理解 public 的必要性)
除了public,还有protected和private继承,三者核心差异是 "基类 public 成员在派生类中的权限":
| 继承方式 | 基类 public 成员在派生类中的权限 | 派生类对象能否调用基类 public 成员 | 适用场景 |
|---|---|---|---|
| public | public | 能 | 绝大多数场景(保留接口) |
| protected | protected | 不能 | 仅派生类内部复用基类接口 |
| private | private | 不能 | 完全隐藏基类接口 |
代码中用public继承,正是为了让派生类对象(s/t)能直接调用基类的Print(),体现 "人都能打印姓名年龄" 的通用逻辑。
总结
继承的基本使用可概括为:
- 定义 :基类封装通用特征,派生类通过
class 子类 : 继承方式 父类继承,新增专属特征; - 访问:公有继承下,基类 public 成员可被派生类对象直接调用,基类 private 成员需通过基类 public 函数间接访问;
- 核心:复用通用代码,扩展特殊逻辑,实现面向对象的层次化设计。
基类和派生类对象赋值转换
// 基类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;是派生类对象:
- 派生类对象赋值给基类对象
Base b = d;→ 编译器将d中继承自Base的成员 拷贝到b中,d的专属成员被 "切掉"(切片)。 - 基类指针指向派生类对象
Base* pb = &d;→ 指针pb仅指向d中基类部分的起始地址 ,无法访问d的专属成员(切片)。 - 基类引用绑定到派生类对象
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" 的类型转换不同(这些会生成临时对象),
Student是Person的子类型 (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;:int转double生成临时的double对象,临时对象无法被普通非const引用绑定(C++ 禁止普通引用绑定右值),因此必须加const;而场景 3 中Person& rp = s;无临时对象,直接绑定s的基类部分,因此普通引用即可。
五、总结
- 基类与派生类的赋值转换 :仅支持 "派生类→基类" 的向上转换,核心表现为切片------ 基类只能访问派生类对象的基类部分,派生类专属成员被 "切掉"。
- 切片的本质:基类指针 / 引用 / 对象仅能操作派生类的基类成员,是 C++ 实现 "基类接口统一操作派生类" 的基础。
- public 继承是切片的前提 :
public继承体现 is-a 关系,派生类被视为基类的子类型,编译器允许赋值转换和切片;protected/private继承体现 has-a 关系,派生类不被视为基类的子类型,禁止赋值转换和切片。
- 切片与普通类型转换的区别 :切片无临时对象,无需
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的_name在Student的_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(虽无动态资源)会被两次析构,若基类有动态资源,直接导致程序崩溃。
五、总结
-
默认成员函数的关联规则:
- 派生类未显式定义默认成员函数时,编译器生成的合成版本会自动调用基类的对应成员,处理基类部分;
- 派生类显式定义时,需手动调用基类的对应成员(构造、拷贝构造、赋值重载),否则基类部分无法被正确初始化 / 拷贝 / 赋值。
-
构造顺序:先父后子:派生类依赖基类的成员,必须先初始化基类才能保证派生类初始化的合法性,同时符合内存 "先分配先初始化" 的规则。
-
析构顺序:先子后父:派生类可能依赖基类的资源,先析构派生类可避免访问已释放的基类资源,符合内存 "后分配先释放" 的规则。
-
显式调用父类析构的风险 :会导致基类部分被多次析构,引发资源重复释放、内存布局破坏等未定义行为,绝对禁止在派生类析构中显式调用父类析构。
继承体系中默认成员函数的这些规则,本质是为了保证基类与派生类的成员都能被正确初始化和清理,同时遵循 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和_age在Assistant中存储了两次,造成内存浪费。
2. 二义性
当访问Assistant的 Person 成员时(如a._name),编译器无法确定是访问Student继承的_name,还是Teacher继承的_name,直接编译报错;即使通过作用域指定(a.Student::_name),修改的也是两份不同的_name,逻辑上不符合 "助教是一个人(仅一个姓名)" 的现实意义。
虚继承的核心目标就是彻底解决这两个问题 ,其本质是将顶层基类Person从 "被腰部类复制" 改为 "被所有派生类共享"。
二、虚继承解决问题的核心逻辑:将 Person 抽离为共享的虚基类子对象
虚继承并非让Student和Teacher"不继承 Person",而是改变了继承的内存布局方式:
- 普通继承 :
Student/Teacher的内存中直接包含独立的 Person 子对象 (副本),Assistant会继承这两个副本,导致冗余和二义性; - 虚继承 :将顶层基类
Person抽离为一个独立的、全局共享的虚基类子对象 ,放在Assistant内存的专属区域;Student和Teacher不再持有 Person 的副本,而是通过偏移量间接指向这个共享的 Person 对象。
最终Assistant的内存中只有一份 Person 子对象 ,所有对Student::_name、Teacher::_name、_name的访问,都会指向这同一个 Person 的成员,既解决了数据冗余(仅一份数据),又解决了二义性(只有一个访问目标)。
三、virtual关键字的位置要求:必须加在菱形的腰部
虚继承的virtual关键字必须声明在 "菱形腰部" 的类继承顶层基类时 (即Student和Teacher继承Person时加virtual),而非在Assistant继承Student/Teacher时加。原因如下:
-
菱形继承的结构分层:
- 顶层:
Person(被共享的基类); - 腰部:
Student、Teacher(直接继承顶层基类的类); - 底部:
Assistant(继承腰部类的最终派生类)。虚继承的核心是让腰部类放弃持有顶层基类的副本 ,改为引用共享的顶层基类对象。如果仅在底部Assistant继承Student/Teacher时加virtual,腰部的Student/Teacher已经持有了 Person 的副本,依然会导致Assistant中有两份 Person,无法解决问题。
- 顶层:
-
语法规则验证 :若错误地将
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对象的内存分为三部分:
Student的成员:包含vbtptr(Student 的虚基类表指针) +_StudentID;Teacher的成员:包含vbtptr(Teacher 的虚基类表指针) +_EmployeeID;- 共享的 Person 子对象 :独立存储在
Assistant内存的末尾,包含_name+_age; Assistant的新增成员:_majorCourse。
3. 访问 Person 成员的具体流程(以a.Student::_name为例)
当代码执行a.Student::_name = "Studentwang"时,编译器会按以下步骤找到共享的 Person 对象:
- 从
Assistant对象a中,定位到Student部分的虚基类表指针(vbtptr); - 通过
vbtptr找到Student的虚基类表(vbtable); - 从虚基类表中读取
Student到共享Person对象的偏移量; - 用
Student部分的起始地址 + 偏移量,计算出共享 Person 对象的实际地址; - 访问该地址下的
_name成员,完成赋值。
同理,当执行a.Teacher::_name = "Teachertwang"时,会通过Teacher的vbtptr和偏移量,最终也指向同一个 Person 对象 的_name,因此后一次赋值会覆盖前一次的结果 ------ 这就是 "所有访问都指向同一份 Person 成员" 的底层原因。
五、总结:虚继承解决问题的本质
- 数据冗余的解决 :通过将顶层基类
Person抽离为共享的虚基类子对象 ,Assistant中仅存储一份 Person 数据,而非两份副本; - 二义性的解决 :无论通过
Student还是Teacher访问 Person 成员,最终都会通过偏移量表找到同一个共享的 Person 对象,访问目标唯一; - virtual 的位置:必须加在菱形 "腰部"(Student/Teacher 继承 Person 时),才能让腰部类放弃持有 Person 副本,改为引用共享对象;
- 偏移量表的作用 :Student/Teacher 通过
vbtptr指向vbtable,再通过表中的偏移量找到共享的 Person,实现对 Person 成员的间接访问,保证了 "无论从哪个腰部类访问,都指向同一份 Person"。
虚继承是 C++ 为解决菱形继承缺陷设计的机制,其代价是引入了虚基类表的间接访问(微小的性能开销),但换来了数据的一致性和内存的节省,是实现 "多继承下共享顶层基类" 的核心方案。