【C++】继承系统性学习
- 继承的概念:
- 基类和派生类的赋值兼容转换
- 继承中的作用域
- 派生类的默认成员函数
- 构造函数
- 拷贝函数
- 赋值函数
- 析构函数
- 多继承与菱形继承
- 虚继承
- 继承和组合
- 思考:
- 为什么说继承会破坏封装?
- 实现一个不能被继承的类。
- C++的类之间的五种关系。
- inline函数可以是虚函数吗?
- 静态函数可以是虚函数吗?
- 虚函数和虚基表的区别?
- 构造函数可以是虚函数吗?
- 虚函数的生成阶段、在哪生成?
- 什么是抽象类?
-
…………
😁继承的概念:

- 需要注意的是,我们派生类的继承类型并不影响基类相关成员使用以及派生类成员使用。影响的是我们站在派生类外部再去通过派生类的接口访问它们,这个时候接口体现出的就是继承中的基类与派生类中添加的最小权限;
- 另外,别忘了基类对派生类来说会有点见外,愿意分享private成员继承给派生类,但是派生类却无法使用(就像那谁说的“钱是给……看的,不是给……花的”)。
- 相当来说派生类的限制访问域继承,在一定程度上来讲,由于接口性质的改变,变相是对于被继承成员的访问域的限制和缩小。
😎基类和派生类之间的转换:(赋值兼容转换)
- public继承的派生类对象,可以赋值给 基类的指针 / 基类的引用。这里有个形象的说法叫切片,即把派生中基类那部分切出来,基类指针或者引用指向的是派生类中切出来的基类那部分。
- 基类对象不能赋值给派生类对象。
这里需要格外注意!
问:为什么只能是 public 继承?
答: 因为 public 继承 = “是一个”(is-a)关系。
规则:
public 继承:派生类 是一个 基类✅ 可以赋值给基类指针 / 引用✅ 可以切片
protected/private 继承:不是 “是一个” 关系❌ 不能赋值给基类指针 / 引用❌ 不能切片❌ 编译器直接报错
综上:只有 public 继承,才满足 is-a 关系,才能发生向上转型(切片)。
补充:
public继承:是 - a(is-a),派生类对象可被当作基类对象使用。protected继承:是 - a,但对外隐藏,对内是 - a,对外不是。private继承:不是 - a,派生类是基类的一种实现,而非特殊版本。
代码展示: (public继承派生类赋值给基类指针)
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student sobj;
//1.派生类对象可以赋值给基类的指针 / 引用
Person* pp = &sobj;
Person& rp = sobj;
}
- 子类可以赋值构造父类,父类不可以赋值构造子类。

🥳继承中的作用域:
🤨隐藏规则:
- 在继承体系中基类和派生类都有独立的作用域。
- 派生类和基类中有同名成员,派生类成员将屏蔽基类同名成员的直接访问,这种情况叫隐藏(派生类成员函数中被隐藏的基类成员,可以使用 基类::基类成员 显示访问)。
- 需要注意都是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
- 注意在实际中在继承体系里面最好不要定义同名的成员~
- 🫡如果派生类和基类中拥有相同名称的成员:
- 派生类中使用同名成员时,编译器会优先调用派生类中的值就像我们函数中的临时变量一样,会先调用函数的,再调用全局的!
- 如果一定要在派生类中去调用基类中的同名成员!我们要指定定义域,基类::同名成员!
class Person_2
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student_2 : public Person_2
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 学号:" << _num << endl; // 隐藏
cout << " 基类 " << Person_2::_num << endl;
}
protected:
int _num = 999; // 学号
};
int main()
{
Student_2 s;
s.Print();
return 0;
}
🫡A和B类中的两个func构成什么关系()
A. 重载 B. 隐藏 C.没关系
🫡🫡下⾯程序的编译运⾏结果是什么()
A. 编译报错 B. 运⾏报错 C. 正常运⾏
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "func(int i)" <<i<<endl;
}
};
int main()
{
B b;
b.fun(10);
b.fun();
return 0;
}
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "func(int i)" <<i<<endl;
}
};
int main()
{
B b;
b.fun(10);
b.A::fun();
return 0;
}
派生类中的默认成员函数:
前情提要:各个默认成员函数默认生成规则:
- 默认构造:写了任意构造 → 不生成
- 析构:写了析构 → 不生成
- 拷贝构造:写拷贝 / 移动相关 → 不生成
- 拷贝赋值:写拷贝 / 移动 + 有 const / 引用 → 不生成
- 移动构造:写拷贝 / 析构 / 赋值 任意 → 不生成
- 移动赋值:写拷贝 / 析构 / 赋值 + 有 const / 引用 → 不生成
🤔4个常见的默认成员函数与继承的关系:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有构造函数,则必须在派生类构造函数的初始列表显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调用基类的析构。
- 因为多态中一些场景析构函数需要构成重写,重写的条件之一就是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
😗派生类构造函数:
- 当我们依赖系统默认的构造函数机制时:
- 在父类不存在默认构造函数时派生类成员的初始化顺序:
#include<iostream>
using namespace std;
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
//这个函数是派生类进行兼容类型转换时调用的
Person(const Person& p)
: _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
Person& operator=(const Person& p)
{
cout << "Person operator=(const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
// 派生类
class Student : public Person
{
public:
protected:
string _address;
int _num; //学号
};
int main()
{
Student s;
return 0;
}
// 派生类
class Student : public Person
{
public:
Student(const char* name, string address, int num)
:_name(name), ←—————————————————————— ❌
_address(address),
_num(num)
{
}
protected:
string _address;
int _num; //学号
};
😗派生类构造函数——拷贝构造:
- 当我们依赖系统默认的拷贝构造函数机制时:
- 思考:我们调用派生类的拷贝构造,那么其中基类的拷贝构造所需要的参数,派生类对象怎么才能提供呢?
// 派生类
class Student : public Person
{
public:
Student(const char* name, string address, int num)
:Person(name),
_address(address),
_num(num)
{
}
赋值兼容转换——切片切割出父类的成员,从而合理的传给父类。
Student(const Student& s)
:Person(s),
_address(s._address),
_num(s._num)
{
}
protected:
string _address;
int _num; //学号
};
int main()
{
Student s("baby", "baby", 2);
return 0;
}
何时基类拷贝构造需显示调用:
基类编译器默认合成拷贝 子类自定义拷贝构造 → 自动调基类拷贝,不用写。
基类自己手写拷贝构造(且没有默认合成) 子类自定义拷贝构造 → 必须显式调用基类拷贝。
基类拷贝
= delete子类无论写不写,全都不能拷贝,显式调用直接报错。
😗派生类构造函数——赋值拷贝:
- 幽默实现:由于隐藏的缘故,调用父类operator=函数,误调用到了自己。
Student& operator=(const Student& s)
{
if (this != &s)
{
operator=(s);
_address = s._address;
_num = s._num;
}
return *this;
}
- 正确实现:
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);
_address = s._address;
_num = s._num;
}
return *this;
}
😗派生类析构函数:
- 错误函数:函数再次调用到了自己
~Student()
{
~Person();
}
在多态的一些场景这中构函数需要构成重写,重写的条件之一就是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
- 版本一
~Student() { }
- 版本二
- 我们观察到这里的一个对象调用了两次析构:
😉多继承与菱形继承问题:
😙虚继承:
- 例如下方我们的Person需要在Assitant中自行构造,虽然Person在Student和Teacher中也进行了构造,但并不会影响到我们Assitant中的_Person的值。
class Person
{
public:
Person(int a)
:_Person(a)
{
}
int _Person;
};
class Student : virtual public Person
{
public:
Student(int name, int num)
:Person(name),
_Student(num)
{
}
int _Student;
};
class Teacher : virtual public Person
{
public:
Teacher(int name, int num)
:Person(name),
_teacher(num)
{
}
int _teacher;
};
class Assitant : public Student, public Teacher
{
public:
Assitant(int a, int b, int c)
:Student(a, c),
Teacher(a, b),
Person(c)
{
}
int _Assitant;
};
int main()
{
Assitant At(1, 2, 3);
cout << At._Person << endl;
//At.Teacher::_Person = 2;
//At.Student::_Person = 2;
//At._Person = 1;
return 0;
}
I / O流的库是标准的菱形继承:
😏继承和组合:
- 白箱:关注内部结构与工作原理。
- 黑箱:关注外部功能与输入输出。
🐶
- public继承是⼀种is-a的关系。也就是说每个派⽣类对象都是⼀个基类对象。
- 组合是⼀种has-a的关系。假设B组合了A,每个B对象中都有⼀个A对象。
- 继承允许你根据基类的实现来定义派⽣类的实现。这种通过⽣成派⽣类的复⽤通常被称为⽩箱复⽤
(white-box reuse)。术语“⽩箱”是相对可视性⽽⾔:在继承⽅式中,基类的内部细节对派⽣类可⻅ 。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度⾼。
- 对象组合是类继承之外的另⼀种复⽤选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接⼝。这种复⽤⻛格被称为⿊箱复⽤(black-box reuse),因为对象的内部细节是不可⻅的。对象只以“⿊箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使⽤对象组合有助于你保持每个类被封装。
- 优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合
- 在新类中包含现有类的对象作为成员变量
- 通过调用成员对象的方法来实现功能复用
- 建立的是"has-a"(拥有)关系
#include <iostream>
#include <string>
// 旧的类 - 引擎
class Engine {
private:
std::string type;
public:
Engine(const std::string& engineType) : type(engineType) {}
void start() {
std::cout << type << " 引擎启动...嗡嗡嗡!" << std::endl;
}
void stop() {
std::cout << type << " 引擎停止" << std::endl;
}
};
// 旧的类 - 车轮
class Wheel {
private:
int size;
public:
Wheel(int wheelSize) : size(wheelSize) {}
void rotate() {
std::cout << size << "寸车轮开始旋转" << std::endl;
}
};
// 新类 - 汽车(通过组合复用旧类)
class Car {
private:
Engine engine; // 组合:拥有一个引擎
Wheel wheels[4]; // 组合:拥有四个车轮
std::string brand;
public:
// 在构造函数中初始化成员对象
Car(const std::string& carBrand, const std::string& engineType, int wheelSize)
: brand(carBrand), engine(engineType) {
// 初始化四个车轮
for(int i = 0; i < 4; ++i) {
wheels[i] = Wheel(wheelSize);
}
}
void start() {
std::cout << brand << " 汽车启动:" << std::endl;
engine.start(); // 调用成员对象的方法
for(int i = 0; i < 4; ++i) {
wheels[i].rotate(); // 调用成员对象的方法
}
std::cout << brand << " 开始行驶!" << std::endl;
}
void stop() {
std::cout << brand << " 汽车停止:" << std::endl;
engine.stop();
std::cout << "汽车已停稳" << std::endl;
}
};
// 使用示例
int main() {
// 创建汽车对象,自动组合了引擎和车轮
Car myCar("宝马", "V8", 18);
Car yourCar("奥迪", "V6", 17);
myCar.start();
std::cout << "---" << std::endl;
yourCar.start();
std::cout << "---" << std::endl;
myCar.stop();
return 0;
}
😜思考:
为什么说继承会破坏封装:
说“继承会破坏封装”,主要是从设计层面而非语法层面来讨论的。它指的是,如果不加选择地使用公有继承,子类可能会以一种不可控的方式依赖和干扰父类的内部实现细节,从而导致父类的设计变更异常困难,违背了封装“隐藏实现细节,只暴露稳定接口”的初衷。下面我们从几个具体方面来详细解释:1. 对实现细节的依赖,而非对接口的依赖
封装的核心思想:使用者(客户端代码)只关心对象“能做什么”(公有接口),而不需要知道它“怎么做”(私有/保护成员)。继承带来的问题:为了实现功能,子类常常需要了解甚至直接访问父类的内部实现。
- 访问保护成员:父类中的 protected 成员对子类是可见的。这意味着父类的内部状态和辅助函数暴露给了子类。
- 后果:一旦父类需要修改这些 protected 成员(例如,改变一个变量的类型,或删除一个辅助函数),所有依赖它的子类都将被迫需要修改,甚至无法编译。父类的作者在进行重构时会束手束脚。
示例:class Stack { protected: int* m_data; // 子类可以看到这是一个动态数组 int m_top; // 子类可以看到栈顶指针 public: void push(int v) { /* ... */ } int pop() { /* ... */ } }; class FancyStack : public Stack { public: void clear() { // 子类直接操纵父类的内部状态 m_top = -1; // 这依赖于父类用 m_top 来管理栈顶 // 如果父类将来改用链表实现,移除了 m_top,这个子类就崩溃了。 } };在这里,FancyStack 的 clear 方法并不是通过 Stack 的公有接口(如连续调用 pop)实现的,而是直接“侵入”了父类的内部实现。这破坏了 Stack 的封装。
2. 父类实现的脆弱性影响子类
由于子类依赖于父类的实现,父类实现的任何微小改动都可能像“蝴蝶效应”一样,在子类中引发难以预料的错误。
- 示例:重写虚函数对内部调用的依赖
class Account { public: virtual void withdraw(double amount) { if (amount <= getBalance()) { // ... 执行扣款 updateLog(); // 可能是一个非虚的私有函数 } } private: void updateLog() { /* 记录日志 */ } }; class MicroAccount : public Account { public: void withdraw(double amount) override { // 子类可能不知道父类 withdraw 内部调用了 updateLog() // 它只覆盖了扣款逻辑,但忘记记录日志了? // 或者,父类将来在 withdraw 中增加了新的内部调用,子类覆盖时根本不知道。 if (amount <= getBalance() + 10) { // 允许透支10元 // ... 执行扣款 // 但忘记了调用 updateLog()? 或者不知道要调用? } } };子类在重写虚函数时,必须了解父类该函数的内部行为(例如它调用了哪些私有函数,改变了哪些状态),而不仅仅是它的接口契约。这使得重写变得复杂且容易出错,本质上是因为子类破坏了父类函数的“实现封装”。
3. 名字隐藏与重载问题
在C++中,如果子类重定义了父类的一个函数(即使是重载版本中的一个),它会隐藏父类中所有同名的其他重载函数,除非使用 using 显式引入。class Base { public: virtual void func(int); virtual void func(double); // 重载函数 }; class Derived : public Base { public: void func(int) override; // 只重写了 func(int) }; int main() { Derived d; d.func(3.14); // 错误!Base::func(double) 被 Derived::func(int) 隐藏了 }这迫使子类的设计者必须了解父类接口的所有细节,并手动处理名字隐藏问题,否则会破坏客户端代码对父类接口的预期。
如何避免继承破坏封装?(组合优于继承)
为了维持良好的封装性,“组合” 通常是比“继承”更安全、更灵活的选择。组合:在新类中包含一个已有类的对象作为成员,通过调用该成员的公有接口来使用其功能。对比示例:
- 使用继承(可能破坏封装):
class FancyStack : public Stack { // “是一个” Stack // 可以直接访问 Stack 的 protected 成员,产生强耦合 };
- 使用组合(保持封装):
class FancyStack { // “有一个” Stack private: Stack m_stack; // 内部包含一个 Stack 对象 public: void push(int v) { m_stack.push(v); } int pop() { return m_stack.pop(); } void clear() { // 只能通过 Stack 的公有接口来操作 while (!m_stack.empty()) { m_stack.pop(); } } };组合的优势:
- 封装完好:FancyStack 完全无法触及 Stack 的内部实现,只能使用其稳定的公有接口。Stack 的内部实现可以自由更改,只要接口不变,FancyStack 就无需改动。
- 依赖更少:耦合度显著降低。
- 更灵活:可以在运行时动态更换所包含的对象(例如,通过注入不同的 Stack 实现)。
结论
“继承破坏封装”是一个警示,提醒我们:
- 谨慎使用公有继承,严格遵循 Liskov 替换原则(子类必须能替换掉父类)。
- 继承应该只用于建立 “是一个” 的语义关系,而不是为了代码复用。
- 对于代码复用,“组合” 通常是比继承更好的工具,因为它保持了类之间的界限清晰,维护了封装性。
因此,在C++设计中,有一条著名的准则:“优先使用对象组合,而非类继承”。
实现一个不能被继承的类:
- 方法一:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构造函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
- 方法二:C++11新增了一个final关键字,final修改基类,派生类就不能继承了。
#include<iostream>
using namespace std;
class Base final
{
public:
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
private:
//C98的方法
/*Base() {}*/
};
//class Driver : public Base ❌编译器报错
//{
//
//};
int main()
{
return 0;
}
C++中的五种关系:
C++ / OOP 类与类的五种关系(考试必背,除继承、组合全覆盖)继承(泛化)
- 关系:is-a
- 语法:class B : public A
- 特点:父子、代码复用、权限继承
组合(整体–部分)[ private: T* class_ ]
- 关系:contains-a / 强 has-a
- 特点:生命周期一致
- 整体销毁,局部直接销毁
class A { B b; };
聚合(聚集)
- 关系:weak has-a
- 整体和部分生命周期独立
- 整体没了,部分还能单独存在
- 语法:用指针 / 引用持有
class A { B* pb; };组合:密不可分;聚合:临时搭伙
关联
最普通的临时使用关系一个类在成员函数参数 / 局部变量里用到另一个类无长期成员持有class A{ public: void fun(B b){} // 只是函数参数用到 };
依赖
最弱关系,临时依赖比如:
- 函数参数
- 局部对象
- 头文件简单引用
- 继承:is-a
- 组合:强 has-a(成员对象,同生死)
- 聚合:弱 has-a(指针 / 引用,各活各的)
- 关联:长期引用
- 依赖:临时使用
更多推荐

所有评论(0)