这篇blog我写了一天一夜, 但是我初学时花了好几个月才学懂 , 我想告诉你, 我那个时候也非常痛苦, 数次想要放弃, 我花了好几天时间才搞懂虚函数原理, 我真是个很笨的人, 只能花很多时间一点一点理解这些东西.
学习C++的过程很艰辛, 不要被困难打倒, 一定要坚持下去!没有任何困难是克服不了的
--------2025,12,17,深夜
继承的概念
C++ 继承是面向对象编程(OOP)的三大核心特性(封装、继承、多态)之一,其核心目的是代码复用和层次化设计:允许从已有类(基类 / 父类)派生出新类(派生类 / 子类),子类可复用父类的属性和方法,同时扩展自身独有的功能。
继承的设计原则
- 里氏替换原则:子类必须能替换父类(
public继承满足 “is-a” 关系);- 单一继承优先:多继承易引发二义性,优先用单一继承 + 接口(纯虚函数类);
- 最小权限原则:继承方式选择最严格的(如无需外部访问基类成员,用
protected继承)。
章节一. 继承
基本语法
// 基类(父类)
class 基类名 {
// 成员(属性、方法)
};
// 派生类(子类)
class 派生类名 : 继承方式 基类名 {
// 派生类新增成员
};
- 基类:被继承的已有类(如
Person);- 派生类:基于基类扩展的新类(如
Student);- 类成员权限: 也分为
public、protected、private
- public成员: 类内外都可访问, 派生类也可以访问
private成员: 外部也不可访问private成员, 同时, 无论哪种继承方式,派生类都无法直接访问(可通过基类的public/protected成员函数间接访问);protected成员:派生类内部可访问,外部(如main函数)不可访问;- 实际开发中,
public继承是最常用的(符合 “is-a” 关系,如 “学生是一个人”),protected/private继承仅在特殊场景使用。
- 子类继承方式:决定基类成员在派生类中的访问权限,分为
public、protected、private(默认private)。
继承方式会约束基类成员在派生类内部和外部的可见性,核心规则如下(表格更清晰):
继承原则:
基类成员权限 public 继承 protected 继承 private 继承 public public protected private protected protected protected private private 不可见 不可见 不可见
父类的私有元素无法被子类直接访问, 但是子类是继承了父类的私元素, 只是没有访问权限
继承方式:
1. 公共继承:父类的元素被继承到子类中不会改变其访问权限
2. 保护继承:父类的公共元素和保护元素都会被作为保护元素被继承
3. 私有继承:父类中的公共元素和保护元素都会被继承为私有元素
四个注意事项:
1. 父类中的静态成员无法被子类继承, 因为父类和子类会共用静态成员;
2. 对象的模型可以在windows的cmd窗口查看;
3. 友元关系不能继承:基类的友元函数 / 类,无法访问派生类的私有成员;
4. 构造 / 析构 / 赋值运算符不能继承:派生类需自定义,若要复用基类逻辑,可在派生类中显式调用;
示例:public 继承(最常用)
#include <iostream>
using namespace std;
// 基类:人
class Person {
public:
string name; // 公有成员
protected:
int age; // 保护成员
private:
string id; // 私有成员
public:
void setId(string s) { id = s; } // 间接访问私有成员
};
// 派生类:学生(public继承Person)
class Student : public Person {
public:
int score; // 新增成员
void show() {
name = "张三"; // 可访问基类public成员
age = 18; // 可访问基类protected成员
// id = "123456"; // 错误:基类private成员不可直接访问
setId("123456");// 正确:通过基类public函数间接访问
score = 90;
}
};
int main() {
Student s;
s.name = "李四"; // 正确:public继承后name仍为public
// s.age = 20; // 错误:protected成员外部不可访问
// s.id = "654321";// 错误:基类private成员外部不可见
s.show();
return 0;
}
C++ 继承:派生类的构造、拷贝构造、赋值重载、析构
0. 派生类的构造与析构顺序:
派生类对象的生命周期中,构造和析构遵循固定顺序,核心规则:
1. 构造顺序(从父到子)
基类构造函数 → 派生类的成员对象构造函数(若有) → 派生类构造函数
2. 析构顺序(从子到父)
派生类析构函数 → 派生类的成员对象析构函数(若有) → 基类析构函数若派生类包含其他类的成员对象(如
class A { B b; };),构造顺序为:基类构造 → 成员对象构造 → 派生类构造;析构顺序相反。
1. 派生类的构造函数
派生类实例化时,需同时调用自身构造函数 + 基类构造函数,规则如下:
- 若基类提供默认构造函数(无参、带默认参数、编译器自动生成):派生类构造函数无需显式调用基类构造,编译器会自动调用。
- 若基类仅提供非默认构造函数(无默认参数的有参构造):必须在派生类构造函数的初始化列表中显式调用基类构造,且该调用需放在初始化列表的首位(语法强制要求)。
// 基类(仅非默认构造) class Person { private: string _name; public: Person(const string& name) : _name(name) {} // 非默认构造 }; // 派生类 class Student : public Person { private: string _address; int _stuNum; public: // 初始化列表首位调用基类构造,再初始化自身成员 Student(const string& name, const string& addr, int num) : Person(name) // 必须显式调用基类非默认构造(首位) , _address(addr) , _stuNum(num) { // 构造函数体:仅处理非初始化列表的逻辑 } };无法被继承的基类:
- 基类构造函数被声明为private:派生类无法访问基类构造,因此无法实例化,等价于禁止继承。
- 基类声明时加final关键字(C++11):class Person final {};,编译器直接禁止该类被继承(编译报错)。
2. 派生类的拷贝构造
- 默认情况:系统自带浅拷贝,一般无需手动编写。
- 手动编写的场景:派生类成员包含堆区手动分配的内存(new),避免浅拷贝问题。
- 实现方式:初始化列表中直接将派生类对象传入基类拷贝构造(利用 “父类指针 / 引用可接收子类对象” 的特性)。
Student(const Student& s) : Person(s) // 调用基类拷贝构造 , _address(s._address) , _num(s._num) {}
3. 派生类的赋值重载(operator=)
- 默认赋值重载:编译器自动生成浅拷贝,成员包含堆区内存会导致浅拷贝问题,需手动重写。
- 名字隐藏:派生类的
operator=会隐藏基类的operator=,需通过基类名::显式调用基类赋值重载。- 自赋值判断:必须先判断
this != &s,避免自赋值导致的堆内存提前释放。- 返回值要求:返回
*this的引用(Student&),支持链式赋值(如s1 = s2 = s3)。Student& operator=(const Student& s) { // 第一步:避免自赋值 if (this == &s) { return *this; } // 第二步:调用基类赋值重载,拷贝基类成员 Person::operator=(s); // 第三步:派生类成员深拷贝(释放旧堆内存→重新分配→拷贝内容) delete[] _address; // 释放当前对象的旧堆内存 _address = new char[strlen(s._address) + 1]; strcpy(_address, s._address); _stuNum = s._stuNum; // 第四步:返回自身引用,支持链式赋值 return *this; }拷贝构造和赋值重载的核心差异:拷贝构造是 “创建新对象”,赋值重载是 “给已有对象赋值”,前者无需释放旧内存,后者需先释放再拷贝。
名字隐藏(name hiding)
派生类中定义的成员函数,若与基类成员函数同名,无论参数列表(函数签名)是否不同,基类的该函数都会被隐藏。
4. 派生类的析构函数
1. 执行顺序
析构顺序与构造相反:派生类析构函数体执行 → 派生类成员对象析构 → 基类析构函数执行,基类析构由编译器自动调用,无需手动触发。
2. 禁止行为
严禁手动调用基类析构函数(如Person::~Person();),编译器会对析构函数名做统一修饰(如
_ZN6PersonD1Ev),手动调用会导致析构函数被重复执行,引发内存错误。
5. 基类的虚析构
当基类指针 / 引用指向派生类对象时,若基类析构非虚函数,
delete指针仅会调用基类析构,派生类析构不执行,导致堆内存泄漏;将基类析构声明为virtual,可触发动态绑定,先执行派生类析构,再执行基类析构。
- 基类析构加
virtual后,派生类析构无论是否加virtual,都会与基类析构构成重写(编译器统一修饰析构函数名为destructor,满足重写的函数签名要求)。- 若类作为基类使用,建议默认将析构声明为虚析构(即使无堆内存,仅增加一个虚函数表指针的开销,避免内存泄漏风险)。
class Person { public: virtual ~Person() { // 虚析构 cout << "Person::~Person()" << endl; } }; class Student : public Person { private: char* _address; public: ~Student() { // 自动继承虚属性,无需加virtual delete[] _address; cout << "Student::~Student()" << endl; } }; // 测试:基类指针指向派生类对象 int main() { Person* p = new Student("张三", "北京", 1001); delete p; // 先执行Student::~Student(),再执行Person::~Person() return 0; }
继承中的同名成员处理
隐藏(hide) 规则:派生类中定义的同名的成员(变量 / 函数),若与基类同名的成员(变量 / 函数)同名,无论参数列表(函数签名)是否不同,基类的该函数都会被隐藏。
1. 同名成员变量
需通过
基类名::显式访问基类的同名变量:class Base { public: int num = 10; }; class Derived : public Base { public: int num = 20; // 隐藏基类num void show() { cout << "派生类num:" << num << endl; // 20 cout << "基类num:" << Base::num << endl; // 10 } };2. 同名成员函数
即使参数列表不同,基类函数也会被隐藏;需通过
基类名::访问基类函数:class Base { public: void func() { cout << "Base::func()" << endl; } void func(int x) { cout << "Base::func(int)" << endl; } }; class Derived : public Base { public: void func() { cout << "Derived::func()" << endl; } // 隐藏基类所有func void test() { func(); // 调用派生类func() // func(10); // 错误:基类func(int)被隐藏 Base::func(10); // 正确:显式调用基类func(int) } };
继承中同名静态成员的访问规则
核心原则
继承中 ** 同名静态成员(变量 / 函数)** 的处理方式,与非静态同名成员一致:子类的同名静态成员会隐藏父类的同名静态成员。
- 访问子类的同名静态成员:直接访问即可。
- 访问父类的同名静态成员:必须通过 ** 作用域限定符(父类名::)** 指定。
- 子类同名静态成员会隐藏父类的,访问父类成员必须加
父类名::作用域;- 静态成员属于类本身,更推荐通过类名 + 作用域的方式访问,符合静态成员的特性。
1. 访问同名静态成员变量
(1)通过对象访问
cout << "通过对象访问:" << endl; Son s; // 访问子类的静态成员变量m_A cout << "Son 下 m_A = " << s.m_A << endl; // 访问父类的静态成员变量m_A(加父类作用域) cout << "Base 下 m_A = " << s.Base::m_A << endl;(2)通过类名访问
(静态成员属于类,更推荐此方式)
cout << "通过类名访问:" << endl; // 访问子类的静态成员变量m_A(类名::成员) cout << "Son 下 m_A = " << Son::m_A << endl; // 访问父类的静态成员变量m_A(子类名::父类名::成员) cout << "Base 下 m_A = " << Son::Base::m_A << endl;2. 访问同名静态成员函数
(1)通过对象访问
cout << "通过对象访问" << endl; Son s; // 访问子类的静态成员函数func() s.func(); // 访问父类的静态成员函数func()(加父类作用域) s.Base::func();(2)通过类名访问
cout << "通过类名访问" << endl; // 访问子类的静态成员函数func() Son::func(); // 访问父类的静态成员函数func()(子类名::父类名::成员函数) Son::Base::func();
多继承(一个子类继承多个父类)
C++十分不建议使用多继承语法, 如果强烈需要复用两个父类的代码, 可以使用多继承, 但是更推荐使用"组合"的语法( 组合语法文章后续会详细介绍)
C++ 支持多继承(Java 不支持),语法:
class 派生类名 : 继承方式 基类1, 继承方式 基类2, ... {
// 新增成员
};
1. 多继承的核心问题:二义性
若多个基类有同名成员,派生类访问时会触发二义性,需通过基类名::明确指定:
class A {
public:
void func() { cout << "A::func()" << endl; }
};
class B {
public:
void func() { cout << "B::func()" << endl; }
};
class C : public A, public B {
public:
void test() {
// func(); // 错误:二义性(A和B都有func)
A::func(); // 正确:指定A的func
B::func(); // 正确:指定B的func
}
};
2. 菱形继承(钻石继承):多继承的典型坑
场景:两个子类继承同一个基类,第三个子类继承这两个子类(形成菱形结构)。问题:
- 数据冗余:基类成员会被继承两次(两个子类各存一份);
- 二义性:访问基类成员时无法确定来源。
解决方式:虚继承(virtual)在继承时加virtual关键字,让派生类共享基类的一份实例(虚基类):
#include <iostream>
using namespace std;
class Top
{
public:
int _e:
};
class Mid1 : virtual public Animal
{};
class Mid2 : virtual public Animal
{};
class Bottom : public Sheep,public Tuo
{};
void test01()
{
Bottom bo;
bo.Mid1::_e = 91;
bo.Mid2::_e = 19;
cout<<bo.Mid1::_e<<bo.Mid2::_e<<bo._e<<endl;
//以上结果均为19;
}
虚继承原理(会用就行,看不懂没事):
虚继承会为派生类生成 “虚基类表vbtable”,编译器会根据Bottom的整体内存布局,重新计算并改写 Mid1 的 vbtable 偏移量,从而保证基类实例唯一。
如果没有虚继承,Mid1和Mid2分别继承一份Top类的成员,这对Mid1和Mid2没有任何问题的影响;
问题是: Bottom如果继承了Mid1和Mid2, 编译器会把两份Top类的成员全部载入Bottom, 无虚继承时,直接访问 Bottom 的 x 会编译报错(编译器无法分辨是 Mid1::x 还是 Mid2::x), 而且两份Top类成员, 我们大概率只用一份, 多出来的就浪费了, 然后平时调用成员时必须用基类名::明确指定, 这很麻烦..
为了解决这两个问题: 我们可以让Mid1和Mid2虚继承Top1, 这样编译器会在Mid1和Mid2的内部隐式生成一个虚基类表, 编译器会在(以Mid1为例)这个表里存储「整个 Top 虚基类实例的起始地址」到 Mid1 的 vbptr 的偏移量, 平时访问Top1成员时, 编译器自动处理, 和操作普通成员函数无差别(有微小性能差距);
当Bottom类继承Mid1和Mid2时, 编译器在编译Bottom类时,会识别出这是 “菱形虚继承” 场景,因此会:
- Top::x 的位置迁移:把 Mid1 和 Mid2 各自的 Top::x 抽离出来,合并为一份共享的 Top::x,放在 Bottom 对象内存的最末尾(地址
0x128),而非跟着 Mid1/Mid2 的子区域; - Mid1和 Mid2 的 vbptr 地址改写:Mid1 作为 Bottom 的子区域,其 vbptr 的地址变成了
0x100(Bottom 对象内的子区域起始地址),而非独立时的0x200。 - 改写vbtable 里的偏移量值: 重新计算每个虚继承类(Mid1/Mid2)的 vbptr 到共享 Top::x 的偏移量;把独立时的
0x10替换为 Bottom 适配后的0x28(Mid1)/0x18(Mid2);
这样做既避免了Bottom对Mid1和Mid2同时继承导致的二义性和数据冗余, 也满足了 Mid1和Mid2对Top成员的调用.,而且Mid1/Mid2 的原有代码无需修改,就能适配 Bottom 的共享 Top 实例.
下面是一些更加详细的解释:
独立的Mid1对象内存布局(虚继承Top)
独立实例化Mid1 m1_obj;时,内存布局如下(假设起始地址为0x200):
| 内存区域 | 起始地址 | 结束地址 | 占用字节 | 核心说明 |
|---|---|---|---|---|
| Mid1 的 vbptr(虚基类表指针) | 0x200 | 0x207 | 8 字节 | 指向Mid1的虚基类表(vbtable) |
Mid1 的成员m1(含填充) | 0x208 | 0x20F | 8 字节 | int m1占 4 字节,填充 4 字节至 8 字节 |
Top 的成员x(唯一实例) | 0x210 | 0x217 | 8 字节 | 虚基类Top的成员,放在对象末尾 |
以 Mid1 的 vbtable 的具体结构
vbtable不存储 Top 每个成员的偏移,只存储「整个 Top 虚基类实例的起始地址」到 Mid1 的 vbptr 的偏移量(因为 Top 是一个完整的类实例,只要找到 Top 实例的起始地址,就能直接访问其所有成员,无需为每个成员存偏移)
| 元素索引 | 存储值(十六进制) | 含义(核心作用) |
|---|---|---|
| 0 | 0x00 | vbptr自身相对于当前类(Mid1)起始地址的偏移(通常为 0,因为 vbptr 在 Mid1 的起始位置) |
| 1 | 0x28 | Mid1的vbptr到虚基类Top::x的偏移量(之前计算的核心偏移) |
Bottom类的内存分布
0x100: Mid1的vbptr → 指向Mid1的vbtable
0x108: Mid1的成员
0x110: Mid2的vbptr → 指向Mid2的vbtable
0x118: Mid2的成员
0x120: Bottom的成员
0x128: Top的成员_e(唯一实例)
0x18 和 0x28 不是 “随便定的”,编译器在编译Bottom类时,会识别出这是 “菱形虚继承” 场景,因此:
- Top::x 的位置迁移:把 Mid1 和 Mid2 各自的 Top::x 抽离出来,合并为一份共享的 Top::x,放在 Bottom 对象内存的最末尾(地址
0x128),而非跟着 Mid1/Mid2 的子区域; - Mid1和 Mid2 的 vbptr 地址改写:Mid1 作为 Bottom 的子区域,其 vbptr 的地址变成了
0x100(Bottom 对象内的子区域起始地址),而非独立时的0x200。 - 改写vbtable 里的偏移量值: 重新计算每个虚继承类(Mid1/Mid2)的 vbptr 到共享 Top::x 的偏移量;把独立时的
0x10替换为 Bottom 适配后的0x28(Mid1)/0x18(Mid2);
运行时访问 x 时,只需要 “当前 vbptr 的地址 + 预存的偏移量”,就能精准定位到唯一的 x—— 这也是虚继承能解决菱形继承二义性的核心:无论走哪个路径,最终通过 “地址 + 偏移量” 都能找到同一个 x。
章节二. 多态
C++多态是面向对象编程的核心概念之一,它允许我们通过基类的指针或引用来调用派生类中重写的方法,从而实现接口的复用和运行时方法的动态绑定。
多态的作用:
提高代码的可复用性:通过多态,我们可以编写出能够处理基类对象的通用代码,这些代码可以不加修改地应用于所有的派生类对象。
提高代码的可扩展性:当需要增加新的派生类时,我们不需要修改原有的基于基类的代码,只需让新的派生类重写基类的虚函数即可。这符合开闭原则(对扩展开放,对修改封闭)。
实现接口的统一定义:通过基类中定义的虚函数,我们可以为所有派生类提供一个统一的接口。不同的派生类可以根据自己的需要重写这些虚函数,提供具体的实现。
实现运行时类型识别和动态绑定:多态允许程序在运行时根据对象的实际类型来调用相应的方法,而不是根据指针或引用的类型。这使得程序更加灵活。
设计模式的基础:许多设计模式(如工厂模式、策略模式、观察者模式等)都依赖于多态性。
多态的原理:
在C++中,多态是通过虚函数(virtual function)和继承来实现的。具体来说,我们需要在基类中将希望被多态调用的函数声明为虚函数,然后在派生类中重写这些虚函数。当我们通过基类的指针或引用调用虚函数时,会根据实际对象的类型来调用相应的函数。
- 必须通过**基类指针或引用**调用虚函数才能触发多态。
- 直接使用对象(而非指针/引用)会导致**对象切片(slicing)**,失去多态性。
- 基类虚函数必须要写virtual,但是派生类可以不加virtual也能构成重写,虽然这样很不规范
- 基类析构函数应声明为虚函数,确保正确释放派生类资源。
class Animal {
public:
virtual void speak() {
std::cout << "Animal sound" << std::endl;
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void speak() override {
std::cout << "Meow!" << std::endl;
}
};
void makeSound(Animal* animal) {
animal->speak(); // 多态调用,根据实际对象类型调用对应的speak方法
}
int main() {
Dog dog;
Cat cat;
makeSound(&dog); // 输出:Woof!
makeSound(&cat); // 输出:Meow!
return 0;
}
其实, 目前看来, 多态还不够灵活, 虽然子类可以在父类的基础上, 重写父类的虚函数, 自定义自己的功能, 还可以统一通过父类的指针调用这些功能, 看起来非常自由灵活, 但是实际上依然是带着镣铐起舞, 因为子类自己单独的函数, 不能使用多态, 无法让一个统一的父类指针调用, 虽然可以通过类型强行转换基类指针为子类指针,然后调用子类的非虚函数,但是这样很危险, 你必须保证你强转的基类指针确实指向你以为的子类对象, 当然你可以使用各种安全类型转换函数, 但是会产生额外的性能开销. 协变可以一定程度解决上面的问题.
对象切片vs多态:
对象切片(Object Slicing):定义与场景
对象切片是指:将派生类对象直接赋值(值拷贝)给基类对象时,基类对象只能保存派生类中 “属于基类的部分”,派生类特有的成员(属性、方法)会被 “切掉”,最终基类对象仅包含基类子对象,丢失派生类特有信息。
场景 是否切片 本质原因 基类指针 指向子类对象
基类引用 引用子类对象不切片 指针 / 引用是 “间接访问”,指向完整的子类对象,仅访问范围被限制为基类接口 子类对象直接赋值 / 值传递给基类对象 切片 发生值拷贝,基类对象只能容纳自身成员,子类特有部分被丢弃 (1)发生切片的示例
class Base { int a; }; class Derived : public Base { int b; }; // 派生类特有成员b Derived d; Base b = d; // 直接赋值:b仅保留Base的a,Derived的b被切掉(切片)(2)不发生切片的示例
Derived d; Base* ptr = &d; // 指针指向完整的d,仅以Base视角访问 Base& ref = d; // 引用绑定完整的d,仅以Base视角访问 // ptr/ref可通过虚函数调用派生类重写版本(证明d的派生类部分仍存在)
多态与对象切片的关系
多态(动态绑定)的核心是 “根据对象实际类型调用对应虚函数”,而对象切片会直接破坏多态,需注意以下规则:
(1) 多态的触发条件:必须通过基类指针 / 引用调用虚函数
虚函数的 “动态多态”(运行时确定调用版本),仅在基类指针 / 引用调用虚函数时触发;若用派生类指针 / 引用调用,属于 “静态调用”(编译期确定),不是多态的核心机制。
示例(多态生效):
class Animal { public: virtual void speak() { cout << "Animal sound" << endl; } virtual ~Animal() {} // 虚析构(后续补充) }; class Dog : public Base { public: void speak() override { cout << "汪汪" << endl; } }; class Cat : public Base { public: void speak() override { cout << "喵喵" << endl; } }; int main() { Animal* animal1 = new Dog(); Animal* animal2 = new Cat(); animal1->speak(); // 输出“汪汪”(多态:调用Dog的版本) animal2->speak(); // 输出“喵喵”(多态:调用Cat的版本) delete animal1; delete animal2; }(2) 对象切片会导致多态失效
若直接将派生类对象赋值给基类对象(发生切片),调用虚函数时仅会执行基类版本,多态性丢失。
示例(多态失效):
class Person { public: virtual void BuyTicket() { cout << "买成人票" << endl; } }; class Student : public Person { public: void BuyTicket() override { cout << "买学生票" << endl; } }; int main() { Student s; Person p = s; // 发生切片:p仅保留Person的成员 p.BuyTicket(); // 输出“买成人票”(仅执行基类版本,多态失效) }
多态的关键补充:基类需声明虚析构
若基类析构函数不是虚函数,当用基类指针指向派生类对象并 delete时,仅会调用基类析构函数,派生类的析构函数不会执行,导致派生类中动态分配的资源(堆内存、文件句柄等)无法释放,造成内存泄漏。
示例(内存泄漏 vs 正确释放)
class Person { public: // 非虚析构(错误写法) ~Person() { cout << "Person析构" << endl; } }; class Student : public Person { public: ~Student() { cout << "Student析构" << endl; } }; int main() { Person* p = new Student(); delete p; // 仅调用Person析构,Student析构不执行→资源泄漏 }class Person { public: virtual ~Person() { cout << "Person析构" << endl; } // 虚析构(正确写法) }; class Student : public Person { public: ~Student() { cout << "Student析构" << endl; } }; int main() { Person* p = new Student(); delete p; // 先调用Student析构,再调用Person析构→资源正确释放 }
虚函数(virtual)
父类的非虚成员函数是编译期静态绑定的,无论
this指针指向父类还是子类对象,都会固定调用父类版本,功能不可变;为了让函数调用能根据this指针指向的对象的实际类型(父类 / 子类)动态调整执行逻辑,需将父类成员函数声明为virtual虚函数:虚函数不会修改自身代码,而是通过虚函数表机制,在运行时根据this指向对象的动态类型,调用对应类的虚函数版本,实现多态。基类中用
virtual修饰的成员函数,允许派生类重写(覆盖),并支持 “基类指针 / 引用指向派生类对象时,调用派生类的重写函数”。
1. 虚函数重写函数的规则:
- 函数名,参数,返回值,都必须相同,但是参数默认值可以不同
- 协变允许返回值不同, 但是返回值被限制, 只能是基类的虚函数返回值的派生类的引用或者指针
- (协变在后面会详细讲解)
2. 虚函数重写例子:
class Parent {
public:
// 虚函数:静态类型是Parent*,但调用版本由this指向的对象类型决定
virtual void func() { cout << "Parent::func()" << endl; }
};
class Child : public Parent {
public:
// 重写父类虚函数:子类vtable中替换为Child::func()的地址
void func() override { cout << "Child::func()" << endl; }
};
int main() {
Parent* p1 = new Parent();
Parent* p2 = new Child(); // this的静态类型是Parent*,动态类型是Child*
p1->func(); // this指向Parent对象 → 调用Parent::func()
p2->func(); // this指向Child对象 → 调用Child::func()
delete p1; delete p2;
return 0;
}
纯虚函数与抽象类
- 纯虚函数:
virtual 返回值 函数名(参数) = 0;(无函数体);- 抽象类:包含纯虚函数的类(无法实例化,仅作为基类);
- 规则:派生类必须重写纯虚函数,否则派生类也是抽象类。
示例:
// 抽象类(接口) class Shape { public: // 纯虚函数:计算面积 virtual double area() = 0; }; // 派生类:矩形(必须重写area) class Rect : public Shape { private: int w, h; public: Rect(int x, int y) : w(x), h(y) {} double area() override { return w * h; } }; int main() { // Shape s; // 错误:抽象类不能实例化 Shape* ptr = new Rect(3, 4); cout << ptr->area() << endl; // 12(多态) delete ptr; return 0; }
析构函数建议设为虚函数
若基类指针指向派生类对象,删除指针时:
- 基类析构非虚:仅调用基类析构,派生类析构不执行 → 内存泄漏;
- 基类析构为虚:先调用派生类析构,再调用基类析构 → 正确释放。
虚析构示例:
class Base { public: virtual ~Base() { cout << "Base析构" << endl; } // 虚析构 }; class Derived : public Base { public: ~Derived() { cout << "Derived析构" << endl; } }; int main() { Base* ptr = new Derived(); delete ptr; // 输出:Derived析构 → Base析构(正确) return 0; }
虚函数原理(会用就行,看不懂也没事):
虚函数表是 C++ 实现 “运行时多态(动态绑定)” 的核心机制,其使用分为编译期准备、对象初始化、运行时调用三个阶段。
1. 编译期:生成 vtable 和插入 vptr
vtable(虚函数表)的生成:每个包含虚函数的类(或继承了虚函数的类),编译器会为其生成一份唯一的虚函数表(存储在程序的全局只读数据段),表中按声明顺序存储该类所有虚函数的函数地址。
- 子类会继承父类的虚函数表, 但是在此之后子类的虚函数表和父类的虚函数表是独立的
- 若子类重写了父类的虚函数,子类 vtable 中对应位置的函数地址会被替换为子类的重写版本, 不会影响父类的虚函数表;
- 若子类未重写,则保留父类虚函数的地址。
vptr(虚函数表指针)的插入:编译器会在每个该类的对象中,隐式插入一个 vptr(通常是对象的第一个成员),vptr 的值是所属类 vtable 的起始地址,用于运行时找到对应的 vtable。
2. 对象创建时:初始化 vptr
当通过
new或栈实例化对象时,构造函数会自动初始化 vptr,让它指向当前对象所属类的 vtable:
- 父类对象的 vptr → 父类的 vtable;
- 子类对象的 vptr → 子类的 vtable(若子类重写了父类虚函数,vtable 中对应地址已替换为子类版本)。
3. 调用虚函数时:动态寻址(核心步骤)
当通过父类指针 / 引用调用虚函数时,程序不会直接绑定函数,而是通过以下步骤动态找到实际执行的函数:
- 获取对象的 vptr:从指针 / 引用指向的对象中,取出 vptr(对象的第一个成员);
- 通过 vptr 找到 vtable:vptr 的值是 vtable 的起始地址,直接定位到整个 vtable 数组;
- 在 vtable 中找到目标函数地址:vtable 中虚函数按声明顺序排列,编译器已知每个虚函数的索引位置(如第一个虚函数索引 0),通过索引取出对应函数地址;
- 调用函数:用取出的函数地址执行对应逻辑(子类重写则调用子类版本,未重写则调用父类版本)。
一个例题用来检测你对虚函数的理解:
下面代码的运行结果是( )
A: A->0 B: B->1 C: A->1D: B->0 E: 编译出错 F: 以上都不正确
class A { public: virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; } virtual void test() { func(); } }; class B : public A { public: void func(int val = 0) { std::cout << "B->" << val << std::endl; } }; int main(int argc, char* argv[]) { B* p = new B; p->test(); return 0; }考点分析(结合 vtable 机制)
test 函数的调用:B 未重写
test(),因此p->test()调用的是 A 的test()函数体(A 的 vtable 中test的地址未被替换)。func 函数的动态绑定:在 A 的
test()中调用func()时,func是虚函数,会触发动态寻址:
- 从 B 对象中取出 vptr → 找到 B 的 vtable;
- B 的 vtable 中,
func的地址已被替换为 B 的重写版本 → 调用 B 的func函数体。虚函数默认参数的绑定规则:C++ 规定:虚函数的默认参数是 “静态绑定”(编译期确定),即默认参数由 “调用虚函数时的静态类型” 决定,而非实际对象的动态类型。
- 在 A 的
test()中调用func()时,func的静态类型是A::func→ 默认参数取 A 中func的val=1,而非 B 中func的val=0。最终输出
B 的
func函数体被调用,参数为 A 中func的默认值 1 → 输出B->1,对应选项B。
虚函数表vs虚继承表:
虚函数表(vtable)和虚继承表(vbtable)是 C++ 编译器为实现完全不同的面向对象特性设计的静态表结构,二者的核心目的、触发条件、存储内容、工作逻辑均无交集,仅因都带 “虚” 字且由编译器隐式生成易被混淆。
- vtable:为 “多态” 服务,是 “虚函数地址的集合”,让基类指针能调用派生类的重写函数;
- vbtable:为 “虚继承” 服务,是 “偏移量的集合”,让菱形继承中虚基类的实例唯一,消除冗余和二义性。
一个有意思的冷知识: 大多数教材在介绍虚函数表指针和虚继承表指针时都说它们会被隐式存放在类的起始位置,那到底谁在最开始呢?
其实C++ 标准未强制规定 vptr(虚函数表指针)和 vbptr(虚基类表指针)的位置,但主流编译器(GCC/Clang、MSVC)均遵循统一优先级:
虚函数表指针(vptr)优先放在对象内存的最起始位置,虚基类表指针(vbptr)紧随其后;
这种设计的核心目的是优化多态调用效率:vptr 是运行时多态的核心,高频访问,放开头可直接通过对象首地址获取,无需额外地址计算;vbptr 仅服务于虚继承的成员访问,频率更低,因此后置。
以下是更详细的全方位的对比解析:
核心定位(最根本区别)
| 特性 | 虚函数表(vtable) | 虚继承表(vbtable) |
|---|---|---|
| 核心目的 | 实现运行时多态(动态绑定):让基类指针 / 引用能调用派生类重写的虚函数 | 解决菱形继承的两大问题:数据冗余(Top 成员重复存储)+ 访问二义性(无法确定调用哪份 Top 成员) |
| 设计初衷 | 适配 “重写(override)” 语义,支持多态扩展 | 适配 “虚继承” 语义,保证虚基类实例唯一 |
关键维度详细对比
| 对比维度 | 虚函数表(vtable) | 虚继承表(vbtable) |
|---|---|---|
| 触发条件 | 类中包含至少一个虚函数(自身声明 / 从基类继承) | 类采用虚继承(virtual public 基类),且处于菱形继承场景 |
| 存储内容 | 存储类中所有虚函数的入口地址:1. 基类 vtable 存自身虚函数地址;2. 派生类重写虚函数时,替换 vtable 中对应位置的函数地址 | 存储偏移量(地址差):1. 首元素:vbptr 自身的校准偏移(通常为 0);2. 后续元素:vbptr 到虚基类(Top)实例起始地址的偏移量 |
| 对应的对象指针 | 每个含虚函数的对象会隐式生成1 个 vptr(虚函数表指针),指向类的 vtable | 每个虚继承的类在对象中生成N 个 vbptr(虚基类表指针,N = 虚继承的基类数),指向各自的 vbtable |
| 数量规则 | 每个 “含虚函数的类” 对应1 份 vtable(静态存储区),所有对象共享 | 每个 “虚继承的类” 对应1 份 vbtable(静态存储区),编译器会根据最终派生类(Bottom)的布局调整表内偏移量 |
| 访问逻辑 | 运行时动态绑定:基类指针→vptr→vtable→找到派生类重写的虚函数地址→调用 | 编译期计算 + 运行时定位:vbptr→vbtable→读取偏移量→计算虚基类实例地址→访问成员 |
| 内存位置 | 存储在程序只读数据段(.rodata),属于类级别的静态资源 | 同 vtable,存储在只读数据段,属于类级别的静态资源 |
| 性能影响 | 调用虚函数时多一次 “vptr 找 vtable” 的间接跳转(性能损耗极小) | 访问虚基类成员时多一次 “vbptr 找 vbtable + 计算偏移量”(性能损耗比 vtable 略小,纯地址计算) |
| 与继承的关系 | 无继承也可存在(单个类声明虚函数就会生成 vtable) | 仅存在于继承场景,且必须是虚继承 + 菱形结构才会体现价值 |
协变与工厂模式:
协变(Covariance)是 C++ 虚函数重写的语法规则:
工厂模式(尤其是工厂方法模式)是面向对象的设计模式;
二者的核心关联是:协变为工厂模式的多态接口提供了更灵活、更安全的产品返回能力—— 让派生工厂类能返回 “更具体的派生产品类型”,既保持工厂接口的多态统一性,又避免手动类型转换的风险,完美适配工厂模式的设计目标。
协变不是工厂模式的 “必需条件”,但却是工厂方法模式的 “最佳实践”:
- 没有协变,工厂方法模式也能实现(但需手动类型转换);
- 有了协变,工厂方法模式既能保持 “基类接口统一” 的多态特性,又能兼顾 “派生类型精准使用” 的灵活性,同时规避类型转换风险,是语法规则对设计模式的完美赋能。
协变的核心规则:
协变是 C++ 对 “虚函数重写” 的放宽规则,满足以下条件即视为合法重写:
- 基类虚函数返回值:基类产品的指针 / 引用(如
Product*);- 派生类重写函数返回值:该基类产品的派生类指针 / 引用(如
ConcreteProduct*);- 其余签名(参数列表、const/volatile、函数名)必须完全一致;
- 仅支持指针 / 引用(值类型会触发对象切片,不适用协变)。
class Base {}; class Derived : public Base {}; class A { public: virtual Base* create() { // 基类虚函数返回Base* return new Base(); } }; class B : public A { public: virtual Derived* create() override { // 派生类虚函数返回Derived*(Base的派生类) return new Derived(); } };
工厂模式的核心目标(以工厂方法为例):
- 定义 “基类工厂” 的虚接口(如
createProduct()),负责声明产品创建逻辑;- 每个 “派生工厂” 重写该接口,创建对应的 “派生产品”;
- 使用者通过基类工厂指针 / 引用调用
createProduct(),无需关心具体产品类型(多态)。
无协变时,工厂模式的痛点
如果没有协变,派生工厂的
createProduct()只能返回 “基类产品指针”,会导致两个核心问题:1. 代码示例(无协变的工厂方法)
#include <iostream> using namespace std; // 产品层级 class Fruit { // 基类产品 public: virtual void show() = 0; virtual ~Fruit() = default; }; class Apple : public Fruit { // 派生产品:苹果 public: void show() override { cout << "我是苹果" << endl; } void appleOnlyFunc() { cout << "苹果专属功能:脆甜" << endl; } // 派生产品特有方法 }; // 工厂层级 class FruitFactory { // 基类工厂 public: virtual Fruit* createFruit() = 0; // 只能返回基类产品指针 virtual ~FruitFactory() = default; }; class AppleFactory : public FruitFactory { // 派生工厂:苹果工厂 public: Fruit* createFruit() override { // 无协变:只能返回Fruit* return new Apple(); } }; // 使用者代码 int main() { FruitFactory* factory = new AppleFactory(); Fruit* fruit = factory->createFruit(); fruit->show(); // 多态调用:输出“我是苹果” // 问题1:要调用苹果专属方法,必须手动强转(类型安全风险) Apple* apple = dynamic_cast<Apple*>(fruit); if (apple) { apple->appleOnlyFunc(); // 输出“苹果专属功能:脆甜” } delete fruit; delete factory; return 0; }2. 核心痛点
- 类型安全风险:手动
dynamic_cast可能失败(如工厂返回错误产品),导致空指针 / 未定义行为;- 代码冗余:使用者必须知道 “基类产品实际是哪个派生类”,才能正确转换,违背工厂模式 “隐藏具体产品” 的设计初衷;
- 接口不直观:派生工厂明明只生产苹果,却只能返回 “水果” 指针,语义上不匹配。
协变如何解决工厂模式的痛点?
协变允许派生工厂的
createFruit()直接返回 “派生产品指针(如Apple*)”,既保持虚函数重写的多态性,又避免手动转换,让工厂接口更贴合语义。1. 代码示例(有协变的工厂方法)
#include <iostream> using namespace std; // 产品层级(和无协变版本一致) class Fruit { public: virtual void show() = 0; virtual ~Fruit() = default; }; class Apple : public Fruit { public: void show() override { cout << "我是苹果" << endl; } void appleOnlyFunc() { cout << "苹果专属功能:脆甜" << endl; } }; // 工厂层级(核心:协变返回值) class FruitFactory { public: virtual Fruit* createFruit() = 0; // 基类返回Fruit* virtual ~FruitFactory() = default; }; class AppleFactory : public FruitFactory { public: Apple* createFruit() override { // 协变:返回Apple*(Fruit的派生类指针) return new Apple(); } }; // 使用者代码 int main() { // 场景1:多态使用(基类指针)—— 保持工厂模式的多态性 FruitFactory* factory1 = new AppleFactory(); Fruit* fruit = factory1->createFruit(); fruit->show(); // 输出“我是苹果” // 场景2:直接使用派生类型(无需转换)—— 兼顾灵活性和类型安全 AppleFactory* factory2 = new AppleFactory(); Apple* apple = factory2->createFruit(); // 直接返回Apple*,无需强转 apple->appleOnlyFunc(); // 安全调用专属方法 delete fruit; delete apple; delete factory1; delete factory2; return 0; }
2. 协变带来的核心价值(针对工厂模式)
维度 无协变的工厂模式 有协变的工厂模式 类型安全 依赖手动强转,有风险 直接返回派生类型,无转换风险 代码简洁性 冗余的类型转换逻辑 无需转换,代码更简洁 接口语义 派生工厂返回基类指针,语义不匹配 派生工厂返回对应派生产品指针,语义精准 多态兼容性 支持多态,但灵活性差 既支持多态(基类指针调用),又支持精准类型使用
协变在工厂模式中的适用场景与限制
1. 适用场景(核心是 “工厂方法模式”)
- 工厂方法模式:依赖虚函数重写实现 “一个工厂造一个产品”,协变是该模式的 “最优语法搭配”;
- 需调用派生产品特有方法:如果使用者不仅需要基类产品的通用接口,还需要派生产品的专属功能,协变是最安全的实现方式;
- 符合开闭原则:新增派生产品 / 工厂时,只需重写带协变返回值的
createProduct(),无需修改基类接口。2. 限制(协变的语法边界)
- 仅支持指针 / 引用:协变返回值不能是值类型(如
Fruit),否则会触发对象切片,且不符合 C++ 重写规则;- 派生关系必须是公有:派生产品必须是基类产品的公有派生(私有 / 保护派生会导致协变失效);
- 不适用简单工厂:简单工厂依赖 “静态方法 + 参数判断” 创建产品(无虚函数重写),因此无法利用协变;
- 基类接口需统一:协变仅改变返回值类型,虚函数的参数列表、const 属性等必须和基类完全一致。
章节三: 组合
组合是 C++ 面向对象编程中基于 “has-a(有一个)” 关系的类复用方式,核心是将一个类(成员类 / 组件类)的对象作为另一个类(整体类 / 容器类)的成员变量,通过封装成员对象的功能实现代码复用,而非像继承那样基于 “is-a(是一个)” 关系复用接口。
组合是 C++ 中比继承更灵活、耦合度更低的复用手段,也是 “优先使用组合而非继承” 这一经典设计原则的核心载体。
组合的核心本质
组合的核心是 “整体 - 部分” 关系:比如汽车(Car)有一个发动机(Engine)、电脑(Computer)有一个 CPU,这类场景中,“部分”(Engine/CPU)无法脱离 “整体”(Car/Computer)独立存在(或无独立存在的意义),适合用组合实现。
关键特征:
- 成员对象是整体类的 “一部分”,生命周期通常与整体类绑定;
- 整体类通过调用成员对象的公开接口实现功能复用,而非直接继承其属性 / 方法;
- 整体类可完全封装成员对象,对外仅暴露需要的接口,符合 “封装” 原则。
组合的两种形式:强组合 vs 弱组合
组合分为 “强组合” 和 “弱组合”,核心区别是成员对象的生命周期是否由整体类管理:
| 类型 | 实现方式 | 生命周期特点 | 适用场景 |
|---|---|---|---|
| 强组合 | 成员对象为值类型(如Engine engine;) | 与整体类完全绑定:整体创建则成员创建,整体销毁则成员销毁 | 部分无法脱离整体存在(如 Car-Engine) |
| 弱组合 | 成员对象为指针 / 引用(如Engine* engine;) | 成员对象生命周期由外部管理,整体类仅持有指针;需手动释放 | 需动态替换成员(如 Car 换不同发动机) |
组合的基础语法示例(强组合)
以 “汽车 + 发动机” 为例,实现最典型的 “强组合”(成员对象为值类型,生命周期完全绑定):
#include <iostream> #include <string> using namespace std; // 成员类(组件类):发动机 class Engine { public: // 发动机的核心功能 void start() { cout << "发动机启动:嗡嗡嗡~" << endl; } void stop() { cout << "发动机关闭:嘀~" << endl; } // 构造/析构:观察生命周期 Engine() { cout << "[生命周期] Engine 构造" << endl; } ~Engine() { cout << "[生命周期] Engine 析构" << endl; } }; // 整体类(容器类):汽车 —— 组合Engine class Car { private: // 核心:将Engine作为私有成员(封装,外部无法直接访问) Engine engine; // 值类型成员:强组合,生命周期绑定 string brand; // 汽车自身属性 public: // 构造函数:初始化自身成员,同时触发Engine的构造 // 注意:成员对象的构造顺序由“声明顺序”决定,而非初始化列表顺序 Car(string b) : brand(b) { cout << "[生命周期] Car 构造:" << brand << endl; } // 汽车的功能:封装并复用Engine的功能 void startCar() { cout << brand << " 准备启动:" << endl; engine.start(); // 调用成员对象的方法 cout << brand << " 启动完成!" << endl; } void stopCar() { cout << brand << " 准备停止:" << endl; engine.stop(); // 调用成员对象的方法 cout << brand << " 停止完成!" << endl; } // 析构:触发Engine的析构(析构顺序与构造相反) ~Car() { cout << "[生命周期] Car 析构:" << brand << endl; } }; // 测试代码 int main() { Car myCar("特斯拉"); // 创建Car对象,自动构造Engine myCar.startCar(); // 复用Engine的start() myCar.stopCar(); // 复用Engine的stop() return 0; }输出结果(重点看生命周期):
[生命周期] Engine 构造 [生命周期] Car 构造:特斯拉 特斯拉 准备启动: 发动机启动:嗡嗡嗡~ 特斯拉 启动完成! 特斯拉 准备停止: 发动机关闭:嘀~ 特斯拉 停止完成! [生命周期] Car 析构:特斯拉 [生命周期] Engine 析构关键解读:
- 构造顺序:先构造成员对象(Engine),再构造整体类(Car);
- 析构顺序:先析构整体类(Car),再析构成员对象(Engine);
- 封装性:Engine 是 Car 的私有成员,外部无法直接调用
myCar.engine.start(),只能通过 Car 提供的startCar()接口访问,避免成员对象被滥用;- 复用性:Car 无需继承 Engine,仅通过组合就能复用其功能,且不依赖 Engine 的内部实现。
弱组合示例(指针形式):
class Car { private: Engine* engine; // 指针形式的成员对象(弱组合) string brand; public: // 构造:手动创建Engine对象 Car(string b) : brand(b) { engine = new Engine(); // 外部管理成员对象的创建 cout << "[弱组合] Car 构造:" << brand << endl; } // 析构:必须手动释放engine,否则内存泄漏 ~Car() { delete engine; // 手动销毁成员对象 cout << "[弱组合] Car 析构:" << brand << endl; } // 支持动态替换发动机(强组合无法实现) void replaceEngine(Engine* newEngine) { delete engine; // 释放旧发动机 engine = newEngine; // 替换为新发动机 cout << brand << " 更换发动机完成!" << endl; } };弱组合注意事项:
- 必须手动管理成员对象的内存(
new/delete),否则会导致内存泄漏;- 若涉及拷贝构造 / 赋值,需自定义实现深拷贝(避免多个 Car 对象共享同一个 Engine 指针,导致重复释放)。
组合的典型使用场景
- 整体 - 部分关系:Car-Engine、Computer-CPU、Phone-Battery、Order-OrderItem 等;
- 功能复用且避免继承耦合:比如要复用
File类的 “读写” 功能,但不想让Document类成为File的子类(Document 不是 “一种” File);- 动态扩展功能:比如策略模式(Strategy Pattern)中,通过组合不同的策略类(如
PaymentStrategy),让订单类动态切换支付方式;- 组合模式(Composite Pattern):用于实现 “树形结构”(如文件夹 - 文件),让整体和单个对象具有统一接口;
- 替代多重继承:C++ 不支持多继承(或多继承易出问题),可通过组合多个类实现 “多功能复用”(比如
Car组合Engine+Wheel+Battery)。
组合的注意事项
- 成员对象的初始化顺序:C++ 中,成员对象的构造顺序由 “类内声明顺序” 决定,而非构造函数初始化列表的顺序!示例:
class C { A a; // 声明顺序1 B b; // 声明顺序2 public: C() : b(), a() {} // 初始化列表顺序是b→a,但构造顺序仍是a→b };- 浅拷贝问题:弱组合(指针成员)的默认拷贝构造 / 赋值运算符是 “浅拷贝”,会导致多个对象共享同一个指针,销毁时重复释放。需自定义拷贝构造和赋值运算符,实现深拷贝;
- 单一职责原则:避免一个类组合过多成员对象(比如 Car 组合 Engine+GPS+Audio+Seat+...),导致类的职责过重,需拆分;
- 避免过度封装:成员对象的核心接口应通过整体类暴露,而非完全隐藏(比如 Car 需暴露
startCar(),而非让用户无法操作发动机)。
组合 vs 继承(核心对比)
维度 组合(has-a) 继承(is-a) 关系本质 整体 - 部分(有一个) 父类 - 子类(是一个) 耦合度 低(仅依赖成员类的公开接口) 高(子类依赖父类的实现,父类修改可能导致子类崩溃) 灵活性 高(可动态替换成员对象) 低(继承关系在编译期固定,无法动态改变) 接口暴露 可控制(仅暴露需要的接口) 子类继承父类所有公开 / 保护接口,可能暴露多余接口 代码复用 封装复用成员类功能 直接复用父类属性 / 方法 典型问题 浅拷贝(弱组合) 菱形继承、对象切片、父类析构非虚导致内存泄漏 核心能力 多态、强制接口、框架扩展 低耦合、动态替换、单一职责 经典设计原则:优先使用组合,而非继承
C++ 设计模式的核心原则之一是 “Favor composition over inheritance”,原因:
- 继承的 “强耦合” 会导致代码脆弱(比如父类新增一个虚函数,可能破坏子类的重写逻辑);
- 组合的 “低耦合” 让代码更易扩展(比如 Car 可替换燃油发动机 / 电动发动机,无需修改 Car 的核心逻辑);
- 组合避免了继承的诸多 “坑”(如菱形继承的二义性、对象切片等)。

被折叠的 条评论
为什么被折叠?



