C++继承¶
约 4245 个字 838 行代码 16 张图片 预计阅读时间 25 分钟
C++继承行为¶
在C++中,当多个类有共同的成员时,可以考虑使用继承将共同的成员放在单独的类中,剩下的类通过继承获得共用类的成员,这里的共用类通常称为父类,也称为基类,继承父类的类称为子类,也成为派生类
在C++中,可以使用下面的语法格式对类进行继承
C++ | |
---|---|
1 |
|
例如,子类学生和老师继承父类人
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
|
在上面的代码中,尽管子类student
和teacher
没有print()
方法,但是因为继承了person
类,person
类中有print()
方法,所以可以直接调用
并且子类也拥有父类的成员变量_age
和_name
当子类不对父类的成员变量进行修改时,子类直接打印父类成员变量的缺省值,而当子类改变父类的成员变量时,则显示改变后的内容,但是不影响teacher
和person
两个类
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
|
继承权限¶
在C++中,允许子类以不同的继承权限继承父类,分别有public
、private
和protected
三种权限,对应的成员也有三种权限修饰public
、private
和protected
Note
对于class
来说,其默认的继承权限是private
,所以对于private继承来说,可以省略不写继承权限;对于struct
来说,其默认的继承权限public
,所以对于public继承来说,可以省略不写继承权限,但是还是建议写出继承权限
三种权限修饰符的所对应的访问范围如下:
public
:允许类内访问,也允许在类外访问(权限最宽松)protected
:允许类内和子类访问,但是不允许类外访问private
:允许类内访问,不允许类外和子类访问(权限最严格)
两组两两组合有九种可能情况,这九种可能情况可以归类为一个公式MIN=(子类继承权限,父类成员权限)
,所以对于父类中是private
的成员,子类不论是三种继承方式中的任意一种,都无法访问父类的private
成员
需要注意的是,不可访问不等于没有继承
对应父类成员是private
时,尽管子类无法访问当父类的private
成员,但是子类中依旧有父类private
成员的空间
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
上述可以归结为下面的表格
继承权限/访问权限 | public | protected | private |
---|---|---|---|
public | 子类public 继承父类public 成员成为子类public 成员 | 子类public 继承父类protected 成员成为子类protected 成员 | 子类public 继承父类private 成员成为子类private 成员 |
protected | 子类protected 继承父类public 成员称为子类protected 成员 | 子类protected 继承父类protected 成员成为子类protected 成员 | 子类protected 继承父类private 成员成为子类private 成员 |
private | 子类private 继承父类public 成员成为子类private 成员 | 子类private 继承父类protected 成员成为子类private 成员 | 子类private 继承父类private 成员成为子类private 成员 |
父类成员权限低,子类成员权限高
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
父类成员权限高,子类继承权限低
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
对于父类成员权限高时,尽管子类和类外都无法直接访问,但是父类内可以访问自己的private
成员,如果父类提供一个函数,函数内部访问private
成员,而该函数为protected
则此时子类可以访问,如果为public
,则子类和类外都可以访问,例如:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
父类和子类对象赋值转换¶
对于赋值运算来说,如果左边的类型和右边的类型不同时,会发生类型转换,类型转换的过程中会产生临时变量且其具有常性,所以有如下的情况:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
int
:四个字节下的1234
char
:一个字节下的1234,截断04,只留下d2
将num1
赋值给num
进行整型提升后:
但是上面的效果对于子类和父类有所不同,具体表现为当子类继承父类后,如果将子类对象给父类的对象(包括对象本身、对象的引用和对象的指针)时,此时为父类只能访问子类中继承的部分
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
赋值前:
直接赋值后:
指针和引用:
表现为下图的情况:
上面的子类赋值给父类,父类访问部分的现象称为赋值兼容转换,这种转换是继承中的一种针对类型转换的特殊处理,也有个形象的说法叫做切割或者切片
但是需要注意的是,如果子类对象不能直接赋值给父类引用或者指针就变为了多态
继承中的作用域¶
目前一共有四种作用域:
- 类域
- 命名空间域
- 局部域
- 全局域
作用域的影响见下表:
域/作用 | 语法查找规则顺序(是否影响) | 生命周期(是否影响) |
---|---|---|
类域 | 影响 | 不影响 |
命名空间域 | 影响 | 不影响 |
局部域 | 影响 | 影响 |
全局域 | 影响 | 影响 |
查找规则顺序:
-
局部域:这是最内层的作用域,通常指函数体内部,包括函数参数和在函数中声明的变量。局部域中的标识符优先级最高(除非指定了命名空间域),如果局部域中有同名标识符,则会屏蔽外层作用域中的同名标识符
C++ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
//NameSpace.h namespace test { int i = 10; } //test.cpp #include <iostream> #include "NameSpace.h" using namespace std; using namespace test; //int i = 5; int main() { int i = 4; cout << i << endl;// 优先局部 cout << test::i << endl;// 优先命名空间 return 0; } 输出结果: 4 10
-
类域:类域是指在类定义中的作用域。类成员的访问取决于它们的访问控制属性(
public
、protected
、private
)。在类的成员函数中,可以访问该类的public
和protected
成员,而在类的外部,只能访问public
成员。 -
全局域:在所有函数外部声明的标识符属于全局域。全局域中的标识符可以在整个源文件中访问,除非被局部域或类域中的同名标识符所隐藏。如果展开的命名空间中有与全局域同名的变量,此时编译器会报错为“不明确的符号”
-
命名空间域:命名空间用于组织代码,避免命名冲突。命名空间中的标识符可以在命名空间外部通过命名空间限定符(
::
)来访问。如果没有显式指定,编译器不会自动检查命名空间域。
关系如下图所示:
因为有类域的存在,所以在继承中,如果子类中存在与父类同名的成员(成员变量和成员函数),则会优先访问子类中的成员,因为子类的成员在子类的类域中,而父类的成员在父类的类域中,这种现象被称为隐藏或者重定义(不是同一作用域下的重定义)
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
如果需要在子类中访问到父类的成员可以指定父类的类域
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
Note
对于成员函数也是如此,只是成员函数的隐藏只需要满足同名多态中具体介绍
Warning
实际开发中不建议定义重名的成员
子类的默认成员函数¶
在C++中,每一个类会有自己的六大默认成员函数,对于子类来说也是一样,但是在继承中,不同的默认成员函数会有不同,下面以四种默认成员函数为例
构造函数¶
默认情况下,子类会调用父类的构造函数对父类成员初始化,子类自己的成员调用自己的构造函数,而显式写出构造函数后,在初始化列表处,如果需要对父类成员初始化就需要调用父类的构造函数而不能额外指定父类成员单独初始化,并且初始化顺序总是满足父类先初始化(即先调用父类构造函数初始化父类成员),接着初始化子类成员(满足先父后子)
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
拷贝构造函数¶
对于拷贝构造函数来说,因为拷贝构造函数也会走初始化列表,所以对于父类成员的拷贝会走父类成员的拷贝构造,并且这里会涉及到赋值兼容转换,而子类自己的成员则走自己的拷贝构造进行初始化,初始化顺序依旧是先父类成员初始化再子类成员(满足先父后子)
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
赋值运算符重载函数¶
对于赋值运算符重载函数来说,因为赋值运算符重载函数默认对内置类型进行浅拷贝,对自定义类型调用其赋值运算符重载函数,而对于子类和父类来说,父类中的成员需要调用父类的赋值运算符重载函数,此处需要注意的是,因为赋值运算符重载函数在当前子类的类域中也有,如果直接写为operator=()
则会出现无穷递归,正确做法是指定为父类类域中的operator=()
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
|
析构函数¶
对于析构函数来说,内置类型不处理,自定义类型会调用对应的析构函数,而对于继承中的析构来说,因为子类和父类中可能也存在空间释放,所以子类和父类也会有析构函数,此时的析构函数都被编译器隐式叫destructor()
,所以调用父类析构函数时也需要指定类域
Note
但是,如果在子类中显式调用父类的析构函数就不能保证父类的析构函数最后被执行(构造满足先父后子,析构满足先子后父),所以父类的析构函数不能显示调用
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
|
总结¶
对于上面四种默认成员函数来说,处理方式为:
- 构造函数:父类成员构造走父类,子类成员构造走子类,满足先父类后子类
- 拷贝构造:父类成员拷贝构造走父类(涉及到赋值兼容转换),子类成员拷贝构造走子类
- 赋值运算符重载符函数:父类成员走父类赋值运算符重载函数(涉及到赋值兼容转换),子类成员拷贝走子类赋值运算符重载符函数
- 析构函数:父类析构函数不能显式调用,父类成员走父类析构函数,满足先子类后父类
友元与继承¶
当父类中有友元函数时,子类无法继承友元函数
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
在C++中,只有类中声明了友元,对应函数才可以访问友元所在类中的成员,所以上述代码中的func
函数无法访问student
类中的成员
Note
因为友元声明早于student
类出现,所以需要有student
类的前置声明,否则友元函数无法找到student
类
静态成员与继承¶
在C++中,当父类中含有静态成员,子类继承时,父类和子类共用该静态成员
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
上述代码可以理解为:父类对象创建时_count
增加1,随后子类对象创建,父类成员调用父类构造函数_count
再增加1,但是因为static
修饰,父类和子类共用,所以是在原来_count=1
的基础上增加
单继承与多继承¶
多继承介绍¶
单继承:一个子类只继承一个父类
多继承:一个子类继承多个父类
从继承的概念来看多继承:对于单继承来说,父类中的成员会在子类中有一份新的拷贝,所以子类中有父类的成员,再看多继承,因为继承自两个父类,所以子类中会有两个父类的成员的新拷贝
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
对于多继承,如果两个父类有同名成员,需要指定类域名,否则编译器无法区分,其余规则可类推
Note
需要注意到:
当子类继承父类后,子类中会有父类中的成员,在多继承中,第一个继承的父类的成员排在第一个,第二个继承的成员排在第二个,以此类推,父类成员排完后才是本类中特有的成员
菱形继承¶
菱形继承是多继承的一种特殊情况,如果当前子类继承的多个父类继承自另外一个相同的父类,此时就可能产生菱形继承,如图所示:
此时的vegetable
和fruit
继承自一个相同父类plant
,而vegetable
和fruit
又作为父类被tomato
继承,形成菱形继承
Note
菱形继承不一定继承形状必须满足是个菱形,只要是出现两个或以上的子类继承自相同的父类,并且这两个子类又作为另外类的父类,此时就属于菱形继承
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
观察到,在菱形继承中,vegetables
和plants
类既作为tomato
的父类,又作为plant
子类,所以会出现数据冗余和二义性
数据冗余:因为vegetables
和fruits
继承自plants
,所以两个类中都会有plants
中的成员,接着tomato
继承vegetables
和fruits
,tomato
中也会存在vegetables
和fruits
继承自plants
的成员,导致出现了数据冗余
二义性:因为tomato
类中有两个父类vegetables
和fruits
继承自plants
的成员,所以在直接使用tomato
对象访问时_kind
时无法明确知道是哪一个对象的_kind
,产生二义性
对于二义性来说,可以通过指定类域名的方式解决,例如vegetables::_kind
和fruits::_kind
,但是对于数据冗余来说并不能通过这个方法解决
虚拟继承¶
为了解决菱形继承中的数据冗余和二义性,可以在继承权限前方使用virtual
关键字修饰第一层既作为父类又作为子类的类,修饰后的两个类继承行为就称为虚拟继承
Note
虚拟继承对于数据冗余和二义性的处理方式为:所有子类都共用最高父类的成员
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
继承与组合的选择¶
组合¶
继承关系:子类和父类是is-a的关系
组合关系:两个类是has-a的关系
例如:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
二者的选择¶
继承和组合的选择:
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语白箱是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装。基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以黑箱的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。
总结:因为项目中的代码需要遵守高内聚,低耦合,所以当不是特别需要使用继承(比如不需要实现多态或者关系上没有特别强的继承关系),就使用组合,否则使用继承
Info
高内聚,低耦合参考:
Coupling(耦合)
Coupling is a measure of how tightly two modules are bound to each other. The more tightly coupled, the less independent they are. Since the objective is to make modules as independent as possible, we want them to be loosely coupled. There are at least three reasons why loose coupling is desirable.
-
Loosely coupled modules are more likely to be reusable. (低耦合便于代码重用)
-
Loosely coupled modules are less likely to create errors in related modules.(低耦合不容易造成连锁问题)
-
When the system needs to be modified, loosely coupled modules allow us to modify
only modules that need to be changed without affecting modules that do not need to change. (低耦合便于代码修改)
Coupling between modules in a software system must be minimized. (耦合必须最小化)
Cohesion(内聚)
Another issue in modularity is cohesion. Cohesion is a measure of how closely the mod-ules in a system are related. We eed to have maximum possible cohesion between modules in a software system.
Cohesion between modules in a software system must be maximized. (内聚必须最大化)