C++面向对象编程进阶I
C++面向对象编程进阶知识I
参考南软2024秋C++高级程序设计课件CPP-2-2
继承
派生类对应Java
的子类,基类对应Java
的父类
单继承
注意声明时不需要强调类之间的继承关系,定义时才需要,声明时带上继承关系会报编译错误
// class Student: public Person; // 编译错误 |
继承方式有:public, private, protected
三种继承方式的差异
继承方式 | 基类成员的访问权限 | 外部可访问性 | 使用场景 |
---|---|---|---|
公有继承 | 公有成员 -> 公有,保护成员 -> 保护,私有成员不可访问 | 继承的公有成员对外可见,保护成员不可见 | 表示派生类是基类的一种特殊类型,符合“is-a”关系 |
保护继承 | 公有成员 -> 保护,保护成员 -> 保护,私有成员不可访问 | 外部无法访问任何基类成员,内部可访问 | 用于继承实现,内部使用基类成员,不暴露给外部 |
私有继承 | 公有成员 -> 私有,保护成员 -> 私有,私有成员不可访问 | 外部无法访问任何基类成员,内部可访问 | 用于实现“has-a”关系,派生类是基类的一部分,但对外不可见 |
示例
class Base { |
class Derived : protected Base { |
class Derived : private Base { |
派生类友元对基类成员的可访问性
派生类的友元只能访问基类的public
成员,无法访问protected
和private
的成员
示例
class A { |
派生类对象的初始化
- 基类和派生类共同完成
构造函数的执行次序
- 基类构造函数
- 派生类对象成员类(成员变量是对象)的构造函数
- 派生类自己的构造函数
析构函数执行次序
- 与构造函数相反
基类构造函数的调用
- 在派生类的构造函数中,若缺省对基类构造函数的调用则执行基类默认(无参)构造函数,若基类并未提供无参构造函数则会编译错误
- 如果指定执行非默认构造函数,则必须在派生类的成员初始化表中显式指出,此时不在调用基类默认构造函数
示例:
class A { |
虚函数
在正式介绍虚函数前,先介绍一些C++中的重要概念,对后面正确理解静态与动态、判断多态很有帮助
相容
相容分为两种:类型相容和赋值相容
类型相容
类型相容是指两个类型之间是否能够在某些操作(如比较、函数调用、运算等)中互相使用。不意味着赋值操作能成功执行,仅指两个类型在语义上是兼容的,可以进行某些转换
- 如果类型完全相同,自然是兼容的,比如
int
和int
- 两个类型不同,但能够通过显式或隐式的转换来兼容,比如
int
和double
;如果必须通过显式的类型转换才能兼容将会触发上面的特殊情况即赋值操作不能成功进行,示例:
// B继承A |
赋值相容
赋值相容则是指是否可以把一个类型的值赋给另一个类型的变量。赋值相容关系通常取决于类型是否可以通过赋值语句进行转换。
- 如果类型完全相同自然是相容的
- 类型不同,但可以通过显式或隐式的类型转换使得一个类型的变量可以赋给另一个类型,还是可以参考上例
思考:
A a; |
什么时候上面的a = b
是合法的?—— B是A的派生类,反过来不合法
// b = a; // 编译错误 |
尽管当B是A的派生类时赋值是合法的,但需要注意:
- 对象的身份已经发生变化,即b赋值给a后,a和b并不等价(思考为什么,下面会放一张图解释)
- 对上一步的文字解释:属于派生类的属性不存在了(这是为什么?见图解)
这种对象赋值操作称作切片,指a只保留了b的一部分属性,而这部分属性是基类和派生类共有的。
图解:

考虑指针类型/引用类型的变量:
// B仍是A的派生类 |
此外,总是可以把派生类直接赋给基类而不用做显式的类型转换,但是基类不能直接赋给派生类,必须做显示类型转换(可能出错,因此需要程序运行过程中进行适当的检查来保证);形象地说就是:狗都是动物,但动物不全是狗。
override何时发生?
构造函数中无法使用多态,原因是当一个对象的构造函数被调用时,类的继承关系尚未完全建立,因此无法通过调用虚函数实现多态
看一个例子
|
输出:
A's call |
该例可以解释为派生类构造时会先调用基类的构造函数,因此此时派生类尚未构造完成,内存找还找不到B::call
的定义,而目前处于A
的命空间内。只能找到A::call
,因此调用的是A::call
再看另一个例子
class A { |
为什么上例调用的都是A::f
,明明传入的实例是B
? —— f并没有用virtual
关键字修饰,编译器未把它解释成虚函数,因此执行静态绑定,编译时刻即与A::f
绑定,因此不论后面传入A的哪个派生类,总是调用A::f
。
下面开始正式介绍虚函数来解答上面的问题。
两种绑定
前期(静态)绑定q
- 编译时刻确定
- 依据对象的静态类型(即声明的类型)
- 效率高但灵活性差
动态绑定
- 运行时刻
- 依据对象的动态类型(即实际的类型)
- 灵活性高但效率低
为了注重效率,C++默认使用前期绑定,若使用动态绑定需显式指出(virtual
)
定义虚函数
virtual
class A { |
若基类中的成员函数被定义为虚函数,则派生类中所有override的该函数都是虚函数,从而可以继续被派生类重写。
限制
- 类的成员函数才可以是虚函数
- 静态成员函数不能是虚函数
- 内联成员函数不能是虚函数
- 构造函数不能是虚函数
- 析构函数可以是虚函数且往往是虚函数
如何实现后期(动态)绑定?
原理分析
对象的内存空间中存在一个指针,指向其对应的虚函数表,这个虚函数表指针位于对象首地址的前4字节中。
具体到一个对象指针p,p指向空间的前4字节中存放着指向对应虚函数表的指针,示例:
class A { |
p - 4
即取得虚函数表指针,这个虚函数表里记录两个运行时刻确定的虚函数的地址(动态绑定),对应实际指向的对象的虚函数,因为f
在g
前面定义所以虚函数表中f在g前面,虚函数表的首地址即指向f
执行p->f()
时,实际执行的是(**((char*)p - 4))(p)
, 当p
指向a时,该虚函数表指针就指向A的虚函数(A::f, A::g
),当p指向b时,该虚函数表指针就指向B的虚函数(B::f, B::g
)。因此如果拿到对象的地址就可以根据地址向前偏移4B找到对应的虚函数表去调用具体的虚函数,找到的是哪一个虚函数完全由运行时刻赋予的具体的实际对象的地址情况决定,编译时刻不会绑定对象(也无法绑定因为编译时不知道地址),因此实现动态绑定。
更多例子
再看另一个例子:
class A { |
另一个例子:
class A { |
final与override
final
关键字告诉编译器这个函数已经是“最终版本”了,不允许被派生类重写;尝试重写final
修饰的函数会触发编译错误
override
关键字在任何时候都不是必须的,但它告诉编译器这个函数的定义意图重写基类的虚函数,那么编译器会帮助做一些检查来保证重写是正确的(即函数签名完全一致),如果函数签名并不满足重写的要求,会触发编译错误。
示例:
class B { |
纯虚函数
- 声明时在虚函数原型后面加上
= 0
, 意味着定义不在这里 - 往往只给出函数声明,不给定义(实现)
抽象类
- 至少包含一个纯虚函数
- 不能用于创建对象(类似
Java
抽象类abstract class
),因此没有栈上对象,只有指针 - 为派生类提供框架,派生类必须为抽象基类的所有纯虚成员函数提供实现
示例:
class AbstractClass { |
提供框架的意义:抽象类总结出出派生类的特征,提供共有的行为,设为纯虚,派生类按照各自特性对同一行为表现出不同动作,即为纯虚函数提供不同的具体实现。
利于一些设计模式的实现,比如抽象工厂模式、策略模式等
虚析构函数
析构函数往往是虚函数,只要在堆上申请了新资源。
因为如果基类析构函数不是虚函数,那么析构的时候就不会调用派生类的析构函数,如果派生类申请了超过基类持有的资源(指针),就会发生内存泄漏。
好的继承习惯
- 确定public继承是真正意义上的"
is a
"关系 - 不要定义一个成员函数,它和继承而来的非虚成员函数同名。因为非虚函数是静态绑定的,同名没有意义,除非静态类型是派生类否则还是会调用基类的版本,并不会重写
示例:
class Rectangle { |
class B { |
- 明智地运用private继承,private继承后自己的派生类就继承不了自己基类中的方法了
函数种类汇总
- 纯虚函数
- 只有函数接口会被继承
- 派生类必须继承函数接口
- 派生类必须提供实现代码
- 一般虚函数
- 函数的接口及缺省的实现代码都会被继承
- 子类必须继承函数接口
- 可以缺省实现代码
- 非虚函数
- 函数的代码及其实现代码都会被继承
- 必须同时继承接口和实现代码,无法重写以提供自己的实现
切记
绝对不要重新定义继承而来的缺省参数值!考虑到:
- 缺省参数值是静态绑定的
- 效率问题
示例:
class A { |
多继承
一个常见的问题:名冲突
语法
class <派生类名>: [<继承方式>]<基类名1>, [<继承方式>]<基类名2>, ... { |
示例:
case1
class Furniture { |
那么一个更好的实践是什么呢?—— 虚继承
虚继承
在声明继承时使用virtual
关键字,表示派生类继承的并不是一个“单独新”的基类,而是所有虚继承的派生类“共享”一个基类,使得继承树形成一个**“格”**的结构。
示例:
case2
class Furniture { |
本质
虚继承不会带来名冲突的本质:并没有真的继承基类的成员,而是生成了一个指向基类的指针,因此不会有名冲突问题(基类实际上只有一份,被所有虚继承的派生类共用)
多继承总结
基类的声明次序将会决定:
- 对基类构造函数/析构函数的调用次序,先声明的构造函数先调用,析构函数则相反
- 对基类数据成员的存储安排
名冲突问题
如果不使用虚继承可能会引入名冲突问题,即不同的命名域存在重名变量,编译器找不到该用哪个从而引起编译错误,解决方法是显式标明命名域,即<基类名>::<基类成员名>
;或使用虚继承
非虚基类
如果多继承派生类的直接基类有公共的基类,则该公共基类中的成员变量在多继承的派生类中有多个副本(直接基类对公共基类不使用虚继承,见上例case1
), 不标明namespace将引起上述的名冲突问题
虚继承
解决派生类中成员变量有多个基类副本的名冲突问题,且使得继承结构更优雅清晰
虚基类
virtual
继承
- 虚基类的构造函数由最新派生出的类(即继承树中最底层的类)的构造函数调用
- 虚基类的构造函数优先于非虚基类的构造函数执行
(•‿•)