《C++Primer》| 第十五章 面向对象程序设计
15.1 OOP:概述
面向对象程序设计:核心思想是数据抽象、继承和动态绑定
- 数据抽象:类的接口与实现进行分离
- 继承:定义相似的类型并对相似关系建模
- 动态绑定:一定程度上忽略相似类型的区别,用统一的方式使用它们的对象
继承
通过继承联系在一起的类构成一种层次关系,这种层次关系的根部有一个基类,其他类直接或间接由基类继承而来,继承得到的类称为派生类,继承的关系满足 is a ,即派生类本身也是一种基类的类型
对于基类中的某些函数,如果派生类需要自定义适合自身的版本,可以在基类中将这些函数声明为虚函数,派生类必须在内部对所有的虚函数重新声明
动态绑定
使用动态绑定,可以用同一段代码处理基类和派生类的对象
比如说定义一个函数参入的形参是基类类型,然后打印处基类的成员,此时如果传入一个派生类也同样可以打印派生类的成员,这时调用的函数会根据实际传入的类型进行调用
动态绑定过程是在函数运行时由实参决定,有时也可以称为运行时绑定
15.2 定义基类和派生类
15.2.1 定义基类
首先定义 Quote 类:
|
|
基类通常定义一个虚析构函数,即使不执行任何操作
成员函数和继承
派生类可以继承基类的成员,遇见 net_price 这种与类型相关的操作,派生类需要对这些操作提供自己新定义以覆盖从基类继承而来的定义
通常将基类希望派生类进行覆盖的函数定义为虚函数,当使用指针或者引用调用虚函数时,该调用将被动态绑定
访问控制和继承
- 派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员
- 派生类可以访问公有成员,但是不能访问私有成员
- 如果希望基类的派生类有权访问该成员,同时禁止其他用户访问,可以定义为受保护的(protected)
15.2.2 定义派生类
派生类必须通过类派生列表来指明它是从哪个基类继承而来的,类派生列表形式是:首先一个冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有以下三种访问说明符之一:public、protected和private
定义 Bulk_quote 类
|
|
Bulk_quote 类从基类 Quote 继承了 isbn 函数和 bookNo、price等数据成员,此外还定义了 net_price 的新版本,同时增加了两个新的数据成员
派生类中的虚函数
派生类如果没有覆盖基类的虚函数,则派生类会直接继承基类中的版本
C++11新标准允许派生类显示注明它使用某个成员函数覆盖了它继承的虚函数,具体做法是在函数定义后面加个关键字 override
派生类对象即派生类向基类的类型转换
派生类对象含有基类对应的成员部分,我们可以将派生类对象当成基类对象来使用,也能将基类指针或引用绑定到派生类对象的基类部分上
|
|
这种转换称为派生类到基类的类型转换
派生类构造函数
派生类对象中含有从基类继承的成员,但是派生类不能直接初始化这些成员,派生类必须使用基类的构造函数来初始化它的基类部分
派生类在初始化成员时,可以通过构造函数初始化列表将实参传给基类构造函数,如下:
|
|
派生类首先初始化基类部分,然后按照声明顺序初始化派生类的成员
派生类使用基类的成员
派生类可以访问基类的公用成员和受保护的成员,例如:
|
|
继承与静态成员
如果基类定义了一个静态成员,则整个继承体系中只存在该成员的唯一定义,不能派生出来多个各类,每一个静态成员只存在唯一的实例
静态成员遵循通用的访问控制规则,如果基类中定义为 private 的,派生类无权进行访问;如果静态成员可访问,既可以通过基类使用它,也可以通过派生类使用
|
|
派生类的声明
派生类声明和其他类差别不大,声明中包含类名但是不包含它的派生列表:
|
|
被用作基类的类
如果想用某个类作为基类,则这个类必须已经定义而非仅仅声明:
|
|
一个类不可以派生它本身
防止继承的发生
如果不希望某一个类派生出其他类,或者不考虑将它作为一个基类,C++11新标准提供了一种防止继承发生的方法,即在类名后面跟一个关键字 final:
|
|
15.2.3 类型转换与继承
我们可以将基类的指针或引用绑定到派生类对象上,例如:可以用 Quote& 指向一个 Bulk_quote 对象
==当使用基类的引用(或指针)时,我们并不清楚该引用(指针)所绑定对象的真实类型==,可能该对象时基类的对象,也可能时派生类的对象
智能指针类也支持派生类向基类的类型转换
静态类型和动态类型
- 使用存在继承关系的类型时,需要区分一个变量或者表达式的静态类型和动态类型
- 静态类型编译时总是已知的,它是变量声明时的类型或者表达式生成的类型
- 动态类型则是变量或表达式表示内存中的对象的类型,运行时才可以确定
- 如果表达式既不是引用也不是指针,则它的动态类型和静态类型永远一致
不存在基类向派生类的隐式转换
- 派生类可以向基类转换是因为派生类中含有基类的成员,基类的引用或指针可以绑定到该基类部分上
- 而基类对象中可能存在派生类对象中的成员,也可能不存在,所以不能从基类向派生类自动类型转换
|
|
==即使一个基类指针或者引用类型绑定在派生类对象上,我们也不能执行从基类向派生类的转换==
|
|
编译器在编译时候无法确定上述转换过程在运行时是否安全,编译器只能检查静态类型来判断是否合法
对象之间不存在类型转换
派生类向基类的自动类型转换只能对指针和引用有效,在派生类和基类类型之间不存在这样的转换。
当对基类进行初始化或者拷贝赋值时,如果传入的是派生类的对象,通常只会将派生类中含有基类的部分进行拷贝复制,而派生类多的部分会被忽略
|
|
当构造 item 时,只会处理 Quote 类中的 bookNo 和 price 两个部分,同时忽略了 bulk 中其他成员
当使用一个派生类对象为基类对象初始化或赋值时,只有派生类对象中基类部分会被拷贝、移动或赋值,它的派生类部分会被忽略
问:给出静态类型和动态类型的定义
- 静态类型在编译时就己经确定了,它是变量声明时的类型或表达式生成的类型;
- 而动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才能知道;
- 如果一个变量非指针也非引用,则它的静态类型和动态类型永远一致。但基类的指针或引用的动态类型可能与其动态类型不一致
15.3 虚函数
我们使用基类的引用或指针调用一个虚函数的成员函数时,会执行动态绑定,直到运行时我们才能知道到底调用了哪个版本的虚函数,所以所有的虚函数都必须被定义
虚函数的调用可能在运行时才被解析
下面是个例子:
|
|
- 第一次调用 print_total 时,base 绑定到 Quote 上,运行的是 Quote 定义的版本
- 第二次调用时,derived 绑定的 Bulk_quote 版本上,调用的会是 Bulk_quote 定义的版本
动态绑定只有当通过引用或指针调用虚函数时才会发生
|
|
使用普通类型(非指针非引用)的表达式调用虚函数时,在编译阶段就已经确定下来调用的版本了
派生类中的虚函数
-
派生类如果覆盖继承而来的虚函数,它的形参类型和返回类型必须和基类函数一致
-
当派生类返回类型是指针或引用时,返回类型存在例外,如果 D 由 B 派生而来,则派生类的返回类型可以时 B* ,而基类返回类型为 D*
final 和 override 说明符
派生类如果定义了一个函数和虚函数名字相同但是形参列表不同,编译器会认为这个函数和原有函数相互独立,这样的话就不符合我们的想法,派生类没有覆盖基类中的虚函数。或者是在派生类定义函数时因为疏忽打错了函数名,也会出现上述情况
C++11新标准提供 override 关键字来说明派生类中的虚函数,这样使得程序员意图更清晰同时编译器也可以发现错误
|
|
如果我们使用 final 关键字,则之后尝试覆盖该函数操作都会失败
|
|
final 和 override 说明符出现在形参列表(包括任何const和引用修饰符)以及尾置返回类型之后
虚函数和默认实参
虚函数也可以像其他函数一样拥有默认实参,基类和派生类定义的默认实参最好相同
回避虚函数的机制
如果希望对虚函数的调用不要进行动态绑定,而是强迫执行虚函数的特定版本,可以使用作用域运算符实现这一目的
|
|
- 通常情况下,只有成员函数(或友元)中代码才需要使用作用域运算符回避虚函数机制
- 通常当一个派生类的虚函数想调用基类中虚函数版本时,需要回避虚函数的机制
- 如果派生类虚函数中没有使用作用域运算符,运行时被解析成调用自身版本,导致无限循环
15.4 抽象基类
纯虚函数
我们如果将虚函数定义为纯虚函数,含有该函数的类是抽象基类,抽象基类负责定义接口,而后续其他类可以覆盖该接口
我们在函数体的位置(即分号前面)书写 =0 就可以将一个虚函数说明为纯虚函数
|
|
-
纯虚函数无须定义,我们也可以为其定义,但函数体必须写在类的外部,即我们不能在类的内部为一个 =0 的函数提供函数体
-
我们不能直接创建一个抽象基类的对象
派生类构造函数只初始化它的直接基类
下面重新实现 Bulk_quote
|
|
每个类各自控制其对象的初始化过程,即使 Bulk_quote 中没有任何数据成员,也需要提供一个构造函数,初始化它的直接基类,也就是 Disc_quote
15.5 访问控制与继承
每个类分别控制自己成员初始化过程,每个类还控制着其成员对于派生类来说是否可访问
受保护的成员
protected 关键字可以控制那些它希望与派生类分享但是不想和其他公共访问使用的成员
- 和私有成员类似,受保护的成员对于类的用户是不可访问的
- 和公有成员类型,受保护的成员对于派生类和友元是可访问的
- 派生类的成员或友元只能通过派生类对象来访问基类中受保护的成员
- 派生类对于基类对象中受保护成员没有任何访问权限
|
|
上面的最后一条的含义是一个派生类成员和友元只能访问派生类继承过来属于基类那部分的受保护成员(这些成员,派生类自己本身也有,成员和友元可以访问自己本身的受保护的成员),而不能直接访问基类对象中的受保护成员
公共、私有和受保护继承
类对继承而来的成员的访问权限受到两个因素影响:一是基类在该成员的访问说明符,二是派生类的派生列表中的访问说明符
|
|
派生类访问说明符对于派生类的成员能否访问直接基类的成员没有什么影响,如上 Pub_Derv 和 Priv_Derv 都可以访问基类 Base 中的 prot_mem
派生类访问说明符是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限,相当于将基类成员定义为相应的访问说明符
|
|
此时在 Priv_Derv 中基类的成员被定义为 private 的了,这样它创建的对象就不可以直接访问
派生类向基类转换的可访问性
派生类向基类的转换是否可访问由使用该转换的代码决定,同时派牛类的派生访问说明符也会有影响。假定D 继承自B :·
- 只有当 D 公有地继承 B 时,用户代码才能使用派生类向基类的转换;如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换。
- 不论 D 以什么方式继承B , D 的成员函数和友元都能使用派生类向基类的转换: 派生类向其直接基类的类型转换对千派牛类的成员和友元来说水远是可访问的。
- 如果 D 继承 B 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换; 反之,如果 D 继承 B 的方式是私有的,则不能使用。
友元和继承
友元关系不能传递,也不能继承,基类的友元在访问派生类成员时不具有特殊性
|
|
f3 访问了派生类中基类部分的成员,Pal 是基类的友元,Pal 能够访问 Base 对象的成员
友元只对做出声明的类有效,而友元的基类或派生类不具有特殊访问能力
|
|
改变个别成员的可访问性
如果需要改变派生类继承的某个名字的访问性,可以使用 using 声明达到这个目的
|
|
Derived 为私有继承,继承的size 和 n 默认情况下是 Derived 的私有成员,使用 using 声明语句,可以改变这个成员的可访问性,此时的 Derived 用户都可以使用 size,而只有 Derived 的派生类才可以使用 n
using 声明控制的访问权限仅由声明语句前面的访问说明符决定
默认的继承保护级别
默认情况下,class 关键字的派生类是私有继承的,struct 关键字的派生类是公有继承的
|
|
struct 和 class 关键字唯一区别就是默认成员访问说明符默认派生访问说明符的区别
一个私有派生的类最好显示的声明 private,不要仅仅依赖默认设置
15.6 继承中的类作用域
每个类定义自己的作用域,在这个作用域类定义类的成员
存在继承关系时,派生类的作用域嵌套在基类作用域之内,当一个名字在派生类的作用域无法解析,编译器会继续在外层的基类作用域中寻找改名字的含义
|
|
名字 isbn 解析过程如下:
- 我们通过 Bulk_quote 对象调用 isbn 的,首先在 Bulk_quote 中查找,没有找到 isbn
- Bulk_quote 由 Disc_quote 派生而来,记下来在 Disc_quote 中查找,仍然找不到
- Disc_quote 由 Quote 派生而来,接着在 Quote 中查找,此时找到了 isbn,最终被解析为 Quote 中的 isbn
在编译时进行名字查找
一个对象、引用或指针的静态类型决定了对象哪些成员是可见的,静态类型和动态类型可能不一样,但是能使用哪些成员依然由静态类型决定
我们在 Disc_quote 中添加一个新成员
|
|
我们只能通过 Disc_quote 及派生类对象、引用或指针使用 discount_policy
|
|
名字冲突与继承
和其他作用域一样,派生类也能重用定义在基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字
可以通过作用域运算符来使用一个被隐藏的基类成员
名字查找先于类型检查
声明在内层作用域的函数并不会重载声明在外层作用域的函数,定义在派生类中的函数也不会重载基类中的成员
如果派生类和基类某个成员同名,派生类将在其作用域内隐藏该基类成员,即使形参列表不一样,基类成员仍然会被隐藏
|
|
虚函数和作用域
15.3说到基类和派生类的虚函数必须有相同的形参列表,如果基类和派生类虚函数接受实参不一样,无法通过基类的引用或指针调用派生类的虚函数
|
|
下面是使用几种函数的方法
|
|
覆盖重载的函数
成员函数无论是否为虚函数都能被重载,如果派生类希望所有的重载版本对于它都是可见的,那么它就需要覆盖所有的版本,或者一个都不覆盖
如果只需要覆盖其中一些函数,但是不得不自己重新定义覆盖基类的每一个版本,否则未重新定义的函数会被隐藏
通过使用 using 声明,就无须覆盖所有版本。using 声明语句指定一个名字而不指定形参列表,所有一条基类成员函数的 using 声明语句就可以把该函数所有重载实例添加到派生类作用域中。此时派生类只需要定义特有的函数就行了
15.7 构造函数与拷贝控制
15.7.1 虚析构函数
当我们 delete 一个动态分配的对象指针时,将执行析构函数,如果指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象动态类型不符合的情况,这样的话编译器必须确保执行正确的析构函数
通过将基类中的析构函数定义为虚函数来确保执行正确的析构函数版本
|
|
析构函数的虚属性也会继承,基类的析构函数为虚函数就可以确保 delete 基类指针时能够调用正确的析构函数版本
|
|
如果基类的析构函数不是虚函数,则 delete 一个指向派生类对象的基类函数将产生未定义的行为
虚函数将阻止合成移动操作
如果一个类定义了一个析构函数,即使通过=default 的形式使用了合成版本,编译器也不会为这个类合成移动操作
15.7.2 合成拷贝控制与继承
基类和派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似,它们对类本身成员依次进行初始化、赋值和销毁操作,此外还负责对直接基类部分进行初始化、赋值、销毁的操作
这里需要注意的就是顺序问题:
- 对于构造函数,派生类使用合成的构造函数时,会自动先调用直接基类的构造函数,然后再初始化自己的成员,直接基类又会进一步调用基类的构造函数
- 构造函数整体的表现为,先初始化基类成员->初始化直接基类->派生类
- 对于析构函数,合成的析构函数体是空的,隐式的析构部分负责销毁类的成员,对于派生类的析构函数来说,除了销毁自己的成员外,还负责销毁派生类的直接基类,依次进行
- 整体表现为,先调用派生类析构函数->直接基类析构函数->基类析构函数
派生类中删除的拷贝控制和基类的关系
- 如果基类拷贝控制成员是被删除的或者不可访问,派生类中对于的成员也是被删除的
- 如果基类中有一个不可访问或删除的析构函数,派生类中合成的默认和拷贝构造函数是删除的,因为编译器无法销毁派生类对象中基类部分
|
|
如果基类中没有默认、拷贝或移动构造函数,一般情况下派生类也不会定义相应的操作
移动操作和继承
大多数基类会定义一个虚析构函数,默认情况下,基类不含有合成的移动操作,派生类也没有
如果需要移动操作,首先应该在基类中进行定义,可以使用合成的版本,但是要显示的定义,一旦定义移动操作,还有同时定义拷贝操作(3/5准则)
|
|
15.7.3 派生类的拷贝控制成员
- 派生类构造函数初始化阶段不仅需要初始化派生类自己的成员,还负责初始化派生类对象中基类部分
- 派生类的拷贝、移动构造函数、赋值运算符也类似,拷贝移动赋值派生类成员的同时,也要对基类部分进行相同的操作
- 而析构函数只负责销毁派生类自己分配的资源,派生类中基类部分是自动销毁的
定义派生类的拷贝或移动构造函数
|
|
初始值 Base(d) 将一个 D 对象传递给基类构造函数,值得注意的是,这里传给基类拷贝构造函数是一个 D 对象,它会自动匹配 Base 中的拷贝构造函数
如果没有提供基类初始值的话,基类部分被默认初始化,而不是从其他对象拷贝而来
|
|
派生类赋值运算符
和拷贝移动构造函数一样,赋值运算符也必须显示为基类部分进行赋值
|
|
派生类析构函数
派生类析构函数只负责销毁派生类自己分配的资源
|
|
构造函数和析构函数中调用虚函数
书上这里写的有点绕,我的理解就是:
- 在基类的构造或析构函数中调用虚函数时,执行的版本时基类的虚函数版本
- 在派生类的构造或析构函数中调用虚函数时,执行的是派生类的虚函数版本
我自己也写了下面的代码进行了测试,确实是这样:
|
|
15.7.4 继承的构造函数
C++11新标准下,派生类可以重用直接基类定义的构造函数,一个类只负责初始化它的直接基类,也只继承直接基类的构造函数。
类不能继承默认、拷贝和移动构造函数,如果派生类没有直接定义这些构造函数,编译器将为派生类合成它们
派生类继承基类构造函数的方式是提供一条注明了直接基类名的 using 声明语句
|
|
using 声明语句作用在构造函数时,将令编译器产生代码,对于基类的每个构造函数,在派生类都会生成一个与之对应的构造函数,形如:derived(parms) : base(args) {}
在上面Bulk_quote 中,继承的构造函数等价于:
|
|
如果派生类有自己的成员,这些成员将会被默认初始化
继承的构造函数特点
- 和普通成员 using 声明不一样,using 不会改变构造函数的访问级别,如,基类的私有构造函数在派生类中还是一个私有的构造函数
- using 声明不能指定 explicit 或 constexpr,基类构造函数是 explicit 或 constexpr,派生类也是如此
- 基类构造函数中的默认实参不会被继承,派生类会获得多个继承的构造函数,其中每个构造函数分别省略掉一个含义默认实参的形参
- 如果派生类定义的构造函数和基类构造函数有相同形参列表,这些构造函数不会被继承
- 默认、 拷贝和移动构造函数不会被继承,这些按照正常合成规则被合成