点击下载PDF版(极力推荐)
C++程序设计基础 第八章.pdf
前言
本文档由 @ItsJiale 创作,作者博客:https://jiale.domcer.com/,作者依据数学与大数据学院 2024 级大数据教学班的授课重点倾向编写而成。所有内容均为手动逐字录入,其中加上了不少自己的理解与思考,耗费近一周时间精心完成。
此文档旨在助力复习 C++ 程序设计基础,为后续学习数据结构筑牢根基。信计专业的同学,也可参考本文档规划复习内容。需注意,若个人学习过程中存在不同侧重点或对重难点的理解有差异,应以教材内容为准。倘若文档内容存在任何不妥之处,恳请各位读者批评指正。
By:ItsJiale
2025.4.8
第8章 继承与派生
8.1 继承
继承是面向对象程序设计基础的重要特性之一
8.1.1 继承的概念
继承就是在原有类的基础上产生出新类,新类会继承原有类的所有属性和方法。原有的类称为基类或父类,新类称为派生类或子类(注意,为了和Java学习统一,今后将基类称为_父类_,派生类称为_子类_,_但更标准的说法是基类和派生类,此举仅仅为了方便记忆_)
声明一个类继承另一个类的格式如下
class 子类名称:继承方式 父类名称{
子类成员声明
};
注意:
- 父类的构造函数与析构函数不能被继承
- 子类对父类成员的继承没有选择权,不能选择继承或不继承某些成员
- 子类可以添加新的成员,用于实现新的功能,保证子类的功能在父类的基础上有所拓展
- 一个父类可以有多个子类,一个子类可以继承多个父类
#include <iostream>
using namespace std;
// 父类 Animal
class Animal {
public:
void eat() {
cout << "Animal is eating." << endl;
}
};
// 子类 Dog 继承自 Animal
class Dog : public Animal {
public:
void bark() {
cout << "Dog is barking." << endl;
}
};
int main() {
Dog dog;
dog.eat(); // 调用从父类继承的方法
dog.bark(); // 调用子类自己的方法
return 0;
}
8.1.2 继承方式
观察子类的变化
注意:
- 在类内:子类成员对父类成员的访问权限
父类成员有三种访问权限,分别是 public
(公有)、private
(私有)和 protected
(保护)。子类对父类不同权限成员的访问情况如下:
- 公有成员:子类可以随意访问。
- 保护成员:子类能够访问。
- 私有成员:子类无法访问。
- 在类外:通过子类对象对父类成员的访问权限
在类外,只能通过子类对象访问父类的公有成员,因为公有成员在类外部是可见的,而私有和保护成员不可见。
8.1.3 类型兼容
(公有继承下,派生对象可以当做基类对象使用,此时派生对象只能发挥基类对象的作用)——>不说人话
稍微改写一下:公有继承时,子类对象能当作父类对象用,不过只能发挥父类对象的作用。
#include <iostream>
using namespace std;
// 父类
class Parent {
public:
int parentValue;
};
// 子类
class Child : public Parent {
public:
int childValue;
};
// 函数参数传递示例
void printValue(Parent p) {
cout << "Parent value: " << p.parentValue << endl;
}
int main() {
Child c;
c.parentValue = 10;
c.childValue = 20;
// 赋值兼容
Parent p = c;
cout << "Assigned parent value: " << p.parentValue << endl;
// 函数参数传递
printValue(c);
return 0;
}
- 赋值兼容:在
main
函数里,Parent p = c;
把Child
类的对象c
赋值给Parent
类的对象p
,此时p
只能访问父类中定义的成员parentValue
。 - 函数参数传递:
printValue(c);
可以将Child
类的对象c
传递给期望接收Parent
类对象的函数printValue
,在函数内部,也只能访问父类的成员parentValue
。
总结
公有继承时子类对象能当作父类对象使用,是因为子类包含了父类的所有成员,但在使用过程中只能发挥父类对象的作用,即只能访问和调用父类中定义的成员。
在将子类对象当作父类对象使用的情境下,通常是无法直接访问子类特有的方法的
#include <iostream>
using namespace std;
// 定义父类 B0
class B0 {
public:
// 定义成员函数 display,用于输出 "B0.display()"
void display() {
cout << "B0.display()" <<endl;
}
};
// 定义子类 B1,公有继承自 B0
class B1 : public B0 {
public:
// 定义与父类同名的成员函数 display,用于输出 "B1.display()"
void display() {
cout << "B1.display()" << endl;
}
};
// 定义子类 D1,公有继承自 B1
class D1 : public B1 {
public:
// 定义与父类同名的成员函数 display,用于输出 "D1.display()"
void display() {
cout << "D1.display()" << endl;
}
};
// 定义函数 fun,该函数接受一个 B0 类型的指针作为参数
// 函数的功能是调用指针所指向对象的 display 函数
void fun(B0 *p) {
p->display();
}
int main()
{
// 创建 B0 类的对象 b0
B0 b0;
// 创建 B1 类的对象 b1
B1 b1;
// 创建 D1 类的对象 d1
D1 d1;
// 定义一个 B0 类型的指针 pt
B0 *pt;
// 让指针 pt 指向对象 b0
pt = &b0;
// 调用 fun 函数,传入指向 b0 的指针
// 由于指针类型是 B0*,会调用 B0 类的 display 函数
fun(pt);
// 让指针 pt 指向对象 b1
pt = &b1;
// 调用 fun 函数,传入指向 b1 的指针
// 虽然 pt 指向的是 B1 对象,但由于指针类型是 B0*,
//仍然会调用 B0 类的 display 函数
fun(pt);
// 让指针 pt 指向对象 d1
pt = &d1;
// 调用 fun 函数,传入指向 d1 的指针
// 同样,因为指针类型是 B0*,会调用 B0 类的 display 函数
fun(pt);
return 0;
}
上述例子类型兼容的体现:
1. 赋值兼容
在 main
函数里,你可以把 B1
和 D1
类的对象地址赋值给 B0
类型的指针 pt
,这就体现了赋值兼容。代码如下:
B0 b0;
B1 b1;
D1 d1;
B0 *pt;
pt = &b0; // 指向 B0 类对象,这是常规操作
pt = &b1; // 把 B1 类对象的地址赋值给 B0 类型的指针
pt = &d1; // 把 D1 类对象的地址赋值给 B0 类型的指针
这里 B1 是 B0 的直接父类,D1 是 B0 的间接子类(通过 B1 继承)。由于是公有继承,B1 和 D1 类的对象包含了 B0 类对象的所有成员,所以可以将它们的地址赋值给 B0 类型的指针。这意味着在赋值时,B1 和 D1 类对象被视为 B0 类对象
2. 函数参数传递
函数 fun
接收一个 B0
类型的指针作为参数,在调用 fun
函数时,可以传入 B0
、B1
或 D1
类对象的指针,这体现了函数参数传递方面的类型兼容。代码如下:
void fun(B0* p) {
p->display();
}
// 在 main 函数中调用 fun 函数
pt = &b0;
fun(pt);
pt = &b1;
fun(pt);
pt = &d1;
fun(pt);
当把 B1
或 D1
类对象的指针传递给 fun
函数时,函数内部将其当作 B0
类对象的指针来处理。这是因为在公有继承关系下,B1
和 D1
类对象在一定程度上可以替代 B0
类对象使用,也就是满足类型兼容的要求。
大总结:
类型兼容规则就是派生类对象能够被当作基类对象使用
派生类对象可以赋值给基类对象
即可以把一个派生类对象直接赋值给基类对象,在赋值过程中,派生类对象中属于基类的那部分成员会被复制给基类对象。
#include <iostream>
using namespace std;
class Base {
public:
int baseValue;
Base(int val) : baseValue(val) {}
};
class Derived : public Base {
public:
int derivedValue;
Derived(int baseVal, int derivedVal) : Base(baseVal), derivedValue(derivedVal) {}
};
int main() {
Derived derivedObj(10, 20);
Base baseObj = derivedObj;
cout << baseObj.baseValue << endl;
return 0;
}
在上述代码里,Derived 类继承自 Base 类,我们把 derivedObj 赋值给 baseObj,此时 baseObj 仅获取了 derivedObj 中属于 Base 类的成员 baseValue。
派生类对象可以初始化基类的引用
即可以用派生类对象来初始化基类的引用,这样基类引用就可以访问派生类对象中属于基类的部分。
#include <iostream>
using namespace std;
class Base {
public:
void print() {
cout << "Base::print()" << endl;
}
};
class Derived : public Base {
public:
void print() {
cout << "Derived::print()" << endl;
}
};
int main() {
Derived derivedObj;
Base& baseRef = derivedObj;
baseRef.print();
return 0;
}
这里使用 derivedObj 初始化了 Base 类的引用 baseRef,baseRef 能够调用 Derived 对象中 Base 类的成员函数。
派生类对象的地址可以赋值给基类指针
即可以把派生类对象的地址赋值给基类指针,通过基类指针可以访问派生类对象中属于基类的成员。如果基类中有虚函数,还能实现动态多态。
#include <iostream>
using namespace std;
class Base {
public:
virtual void print() {
cout << "Base::print()" << endl;
}
};
class Derived : public Base {
public:
void print() override {
cout << "Derived::print()" << endl;
}
};
int main() {
Derived derivedObj;
Base* basePtr = &derivedObj;
basePtr->print();
return 0;
}
此代码中,<font style="color:rgba(0, 0, 0, 0.85);">basePtr</font>
指向 <font style="color:rgba(0, 0, 0, 0.85);">derivedObj</font>
,由于 <font style="color:rgba(0, 0, 0, 0.85);">print</font>
函数是虚函数,所以调用的是 <font style="color:rgba(0, 0, 0, 0.85);">Derived</font>
类中重写后的 <font style="color:rgba(0, 0, 0, 0.85);">print</font>
函数,实现了动态多态。
8.2 派生类(子类)
8.2.1 派生类(子类)的构造函数和析构函数
构造函数
子类成员变量包括从父类继承的成员变量和新增的成员变量,因此,子类的构造函数除了要初始化子类中新增的成员变量,还要初始化父类的成员变量,即子类的构造函数要负责调用父类的构造函数。子类的构造函数定义如下:
子类构造函数(形参列表):父类构造函数(父类构造函数参数列表)
{
子类新增成员的初始化语句
}
关于子类的构造函数的定义,有以下几点需要注意:
- 子类的构造函数与父类构造函数的调用顺序时:先调用父类的构造函数在调用子类的构造函数 (类似于Java继承中的super(); )
- 子类的构造函数的参数列表中需要包含子类新增成员变量和父类成员变量的参数值。调用父类构造函数时,父类构造函数从子类的参数列表中获取实参,因此不需要类型名
- 如果父类没有构造函数或仅存在无参构造函数,则在定义子类构造函数时可以省略对父类构造函数的调用。
- 如果父类定义了有参构造函数,子类必须定义构造函数,提供父类构造函数的参数,完成父类成员变量的初始化
以上给人的感觉——不讲人话(笑)
下面我们用例子仔细深究:
#include <iostream>
using namespace std;
// 父类
class Parent {
public:
int parentData;
// 无参构造函数
Parent() {
cout << "父类的无参构造函数被调用了" << endl;
parentData = 0;
}
// 有参构造函数
Parent(int data) : parentData(data) { //初始化对象
cout << "父类的有参构造函数被调用了" << endl;
}
};
// 子类
class Child : public Parent {
public:
int childData;
// 父类有无参构造函数,子类构造函数省略对父类构造函数调用
Child() {
cout << "子类的无参构造函数被调用了" << endl;
childData = 0;
}
// 父类有有参构造函数,子类构造函数调用父类有参构造函数
Child(int pData, int cData) : Parent(pData), childData(cData){//初始化对象&调用父类构造函数
cout << "子类的有参构造函数被调用了" << endl;
}
};
int main() {
cout << "创建一个子类的无参对象" << endl;
Child child1;
cout << "创建一个子类的有参对象" << endl;
Child child2(100, 200);
return 0;
}
第一点很容易理解
第二点:Parent 类是父类,有个成员变量 parentData,还有个有参构造函数 Parent(int data),这个 data 就是用来初始化 parentData 的。
Child 类是子类,它继承了 Parent 类,并且新增了自己的成员变量 childData。
子类 Child 有个有参构造函数 Child(int pData, int cData),这里面的 pData 和 cData 就是构造函数的参数列表。其中,pData 是给父类成员变量 parentData 准备的参数值,cData 是给子类新增成员变量 childData 准备的参数值。
在这个构造函数里调用父类构造函数时,代码是 Parent(pData),意思就是把 pData 这个实参直接传递给父类的构造函数,不需要再像定义参数那样指定 pData 的类型(不用写成 Parent(int pData) )。这就好像你给别人东西,直接给就行,不用再去说这个东西是什么类型的。
总结一下就是,子类构造函数在接收参数的时候,既要接收能初始化自己新增成员变量的参数,也要接收能初始化父类成员变量的参数。在调用父类构造函数传递参数时,直接把这些参数拿过去用,不用再提它们的类型。当父类没有默认构造函数,仅有有参构造函数时,子类的有参构造函数就必须显式调用父类的有参构造函数,不然会产生编译错误。
理解完上述后,第三第四点就容易理解了。其实也很好理解,在继承中如果子类构造函数想要调用父类相关方法,就必须在子类本身的构造函数中显示调用父类的有参构造函数,让父类存在有参和无参两种方式时,就不用关心调用哪一个了,否则就会出错。
当子类含有成员对象时,子类构造函数除了负责父类成员变量的初始化和本类新增成员变量的初始化,还要负责成员对象的初始化,其定义格式如下:
子类构造函数(参数列表):父类构造函数(父类构造函数的参数列表),成员对象(参数列表){
子类新增成员的初始化语句;
}
当创建子类对象时,各个构造函数的调用顺序为:
①先调用父类构造函数(有参or无参)
②调用成员对象的构造函数 (当一个类包含其他类的对象作为其成员变量时,这些成员变量就被称作成员对象)
③调用子类构造函数
析构函数
父类对象和成员对象的析构工作由父类析构函数和成员对象的析构函数完成。如果子类中没有定义析构函数,编译器会提供一个默认的析构函数。_在继承中,析构函数的调用顺序与构造函数相反_,在析构时,先调用子类的析构函数,再调用成员对象的析构函数,最后调用父类的构造函数。
注意:
虽然公有派生类的构造函数可以直接访问基类的公有成员变量和保护成员变量,甚至可以在构造函数中对它们进行初始化,但一般不这样做,而是通过调用基类的构造函数对它们进行初始化,再调用基类接口(普通成员变量)访问它们。这样可以降低类之间的_耦合度_。
8.2.2 在派生类中隐藏基类成员函数
有时子类需要根据自身的特点改写从父类继承的成员函数。例如,交通工具都可以行驶,在交通工具类中可以定义 run()函数,但是,不同的交通工具其行驶方式、速度等会不同,比如小汽车需要燃烧汽油、行驶速度比较快;自行车需要人力脚蹬、行驶速度比较慢。如果定义小汽车类,该类从交通工具类继承了 run() 函数,但需要改写 run()函数,使其更贴切地描述小汽车的行驶功能。
在子类中重新定义父类同名函数,父类同名函数在子类中被隐藏,通过子类对象调用同名函数时调用的是改写后的子类成员函数,父类同名函数不会被调用。如果想通过子类对象调用父类的同名函数, 需要使用作用域限定符“: :”指定要调用的函数,或者根据类型兼容规则,通过基类指针调用同名成员函数。
简单来说,通过继承,父类的方法被子类继承,但是子类重写了父类的方法,使得父类原本的方法被自动隐藏,需要作用域限定符才能显示。
#include <iostream>
using namespace std;
// 定义交通工具类
class Vehicle {
public:
// 基类的 run 函数
void run() {
cout << "交通工具以一般方式行驶" << endl;
}
};
// 定义小汽车类,继承自交通工具类
class Car : public Vehicle {
public:
// 派生类中改写 run 函数
void run() {
cout << "小汽车燃烧汽油,快速行驶" << endl;
}
};
int main() {
Car car;
// 直接调用派生类改写后的 run 函数
car.run();
// 通过作用域限定符调用基类的 run 函数
car.Vehicle::run();
// 根据类型兼容规则,通过基类指针调用同名成员函数
Vehicle* vehiclePtr = &car;
vehiclePtr->run();
return 0;
}
8.3 多继承
一个子类往往可以有多个父类(?说得好奇怪:一个儿子可以有多个爸爸 (doge)相信大家已经习惯了,那么从这节开始我们回到最精准的定义“派生类”与“基类”)
一个派生类往往可以会有多个基类,派生类从多个基类中获取所需要的属性,这种继承方式称为多继承。(一个中医小白拜了很多个厉害的中医大家为师!!)
8.3.1 多继承方式
多继承是单继承的拓展,在多继承中,派生类的定义与单继承类似,其语法格式如下:
class 派生类名:继承方式 基类1名称,继承方式 基类2名称,......,继承方式 基类n名称
{
新增成员;
};
基类与派生类的对应关系
- 单继承:派生类只从一个基类派生
- 多继承:派生类从多个基类派生
- 多重派生:基类派生出多个派生类
- 多层派生:派生类作为基类,派生出新类(一环套一环)
8.3.2 多继承派生类的构造函数与析构函数
1.构造函数
多继承中派生类的构造函数除了要初始化派生类中新增的成员变量,还要初始化基类的成员变量。在多继承中,由于派生类继承了多个基类,因此派生类构造函数要负责调用多个基类的构造函数。
在多继承中,派生类构造函数的定义格式如下:
派生类构造函数名(参数列表):基类1 构造函数名(参数列表),基类2 构造函数名(参数列表),.......
{
派生类新增成员的初始化语句
}
在上述格式中,派生类构造函数的参数列表包含了新增成员变量和各个基类成员变量需要的所有参数。定义派生类对象时,构造函数的调用顺序是:
①首先按照基类继承顺序,依次调用基类构造函数
②调用派生类构造函数。
如果派生类中有成员对象,构造函数的调用顺序是:
①首先按照基类继承顺序,依次调用基类构造函数
②然后是调用成员对象的构造函数
③最后调用派生类构造函数。
多继承的构造函数的调用顺序(不包括成员对象)
#include <iostream>
using namespace std;
//多继承的构造函数的调用顺序(不包括成员对象)
class A {
public:
int a;
void fun() { cout << a << endl; }
A(int aa) {
a = aa;
cout << "类A的构造函数调用了" << endl;
}
};
class B {
public:
int b;
void fun() { cout << b << endl; }
B(int bb) {
b = bb;
cout << "类B的构造函数调用了" << endl;
}
};
class C : public A, public B{
public:
int c;
void fun() { cout << c << endl; }
C(int aa , int bb ,int cc):A(aa),B(bb){
cout << "类C的构造函数调用了" << endl;
c = cc;
}
};
int main()
{
C c(1, 2, 3);
return 0;
}
多继承的构造函数的调用顺序(包括成员对象)
#include <iostream>
using namespace std;
//多继承的构造函数的调用顺序(包括成员对象)
class A {
public:
int a;
void fun() { cout << a << endl; }
A(int aa) {
a = aa;
cout << "类A的构造函数调用了" << endl;
}
};
class B {
public:
int b;
void fun() { cout << b << endl; }
B(int bb) {
b = bb;
cout << "类B的构造函数调用了" << endl;
}
};
class M1 {
public:
int m1;
M1(int mm1) {
m1 == mm1;
cout << "类M1的构造函数调用了" << endl;
}
void fun() {
cout << m1 << endl;
}
};
class M2 {
public:
int m2;
M2(int mm2) {
m2 == mm2;
cout << "类M2的构造函数调用了" << endl;
}
void fun() {
cout << m2 << endl;
}
};
class C : public A, public B {
public:
void fun() { cout << c << endl; }
int c;
M1 _m1;
M2 _m2;
C(int aa, int bb, int mm1,int mm2,int cc) :A(aa), B(bb),_m1(mm1),_m2(mm2) {
cout << "类C的构造函数调用了" << endl;
c = cc;
}
};
int main()
{
C c(1,2,3,4,5);
return 0;
}
2.析构函数
析构函数的调用顺序与构造函数的调用顺序相反。
#include <iostream>
using namespace std;
//多继承的析构函数的调用顺序
class A {
public:
int a;
void fun() { cout << a << endl; }
A(int aa) {
a = aa;
cout << "类A的构造函数调用了" << endl;
}
~A() {
cout << "【析构】类A的析构函数调用了" << endl;
}
};
class B {
public:
int b;
void fun() { cout << b << endl; }
B(int bb) {
b = bb;
cout << "类B的构造函数调用了" << endl;
}
~B() {
cout << "【析构】类B的析构函数调用了" << endl;
}
};
class M1 {
public:
int m1;
M1(int mm1) {
m1 == mm1;
cout << "类M1的构造函数调用了" << endl;
}
void fun() {
cout << m1 << endl;
}
~M1() {
cout << "【析构】类M1的析构函数调用了" << endl;
}
};
class M2 {
public:
int m2;
M2(int mm2) {
m2 == mm2;
cout << "类M2的构造函数调用了" << endl;
}
void fun() {
cout << m2 << endl;
}
~M2() {
cout << "【析构】类M2的析构函数调用了" << endl;
}
};
class C : public A, public B {
public:
void fun() { cout << c << endl; }
int c;
M1 _m1;
M2 _m2;
C(int aa, int bb, int mm1, int mm2, int cc) :A(aa), B(bb), _m1(mm1), _m2(mm2) {
cout << "类C的构造函数调用了" << endl;
c = c;
}
~C() {
cout << "【析构】类C的析构函数调用了" << endl;
}
// 先构造的后析构 后构造的先析构
// 调用了C的构造函数 -> _m2 对象创建 _m1对象创建 -> B的构造函数 -> A的构造函数
};
int main()
{
C c(1, 2, 3, 4, 5);
return 0;
}
8.3.3 多继承二义性问题
什么叫二义性问题:由于多个基类成员同名而产生二义性,简单来说,都叫同一个名字,不知道谁是谁。
不同基类有同名成员函数
在多继承场景下,派生类会继承多个基类的成员。若多个基类存在同名成员函数,当通过派生类对象调用该同名函数时,编译器就无法明确要调用的是哪个基类的成员函数,从而产生二义性。
#include <iostream>
using namespace std;
class Base1 {
public:
void display() {
cout << "Base1's display function" << endl;
}
};
class Base2 {
public:
void display() {
cout << "Base2's display function" << endl;
}
};
class Derived : public Base1, public Base2 {
// 派生类继承了 Base1 和 Base2 的 display 函数
};
int main() {
Derived d;
// 下面这行代码会产生二义性错误
d.display();
return 0;
}
解决方法:通过作用域限定符“::”指定调用的是哪个基类的函数
#include <iostream>
using namespace std;
class Base1 {
public:
void display() {
cout << "Base1's display function" << endl;
}
};
class Base2 {
public:
void display() {
cout << "Base2's display function" << endl;
}
};
class Derived : public Base1, public Base2 {
// 派生类继承了 Base1 和 Base2 的 display 函数
};
int main() {
Derived d;
d.Base1::display();
d.Base2::display();
return 0;
}
间接基类成员变量在派生类中有多份拷贝
#include<iostream>
using namespace std;
class B{
public:
B(){cout<<"类B的构造函数调用"<<endl;}
void fun(){cout<<"B"<<endl;}
};
class S1:public B{
public:
S1(){cout<<"类s1的构造函数调用"<<endl;}
void funS1(){cout<<"s1"<<endl;}
};
class S2:public B{
public:
S2(){cout<<"类s2的构造函数调用"<<endl;}
void funS2(){cout<<"s2"<<endl;}
};
class D:public Sl,public S2
{
public:
D(){cout<<"类D的构造函数调用"<<endl;}
void funD(){cout<<"D"<<endl;}
};
int main ()
{
D d;
//c.B::fun():
return 0;
}
在多继承中,派生类有多个基类,这些基类可能由同一个基类。在这种继承方式中,间接基类的成员变量在底层的派生类中存在多份拷贝,通过底层派生类对象访问间接基类的成员变量时,会出现二义性。
解决方法:通过作用域限定符“::”指定调用的是哪个基类的函数
8.4 虚继承
在程序中,通常希望间接基类的成员变量在底层派生类中只有一份拷贝,从而避免成员访问的二义性。通过虚继承可以达到这样的目的,虚继承就是在派生类继承基类时,在权限控制符前加上, virtual
关键字,其格式如下:
class 派生类名:virtual 权限控制符 基类名
{
派生类成员
};
在上述格式中,在权限控制符前面添加了virtual
关键字,就表明派生类虚继承了基类。被虚继承的基类通常称为虚基类,虚基类只是针对虚继承,而不是针对基类本身。在普通继承,该基类并不成为虚基类。