第十五章:面向对象程序设计
面向对象程序设计基于三个基本概念:数据抽象,继承和动态绑定。
一.OOP:概述
通过使用数据抽象可以将类的接口与实现分离;使用继承可以定义相似的类型并对其相似关系建模;使用动态绑定可以在一定程度上忽略相似类型的区别,而以统一的方式使用他们的对象。
基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数。C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后加一个override关键字。
在C++中当我们使用基类的引用或指针调用一个虚函数时将发生动态绑定。
二.定义基类和派生类
基类通常都应该定义一个虚析构函数,即使该函数不做说明。
关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明为虚函数,则该函数在派生类中的重写隐式地也是虚函数,再派生再重写也还是虚函数。
派生类会继承基类的所有成员,包括私有成员,但是不能直接访问私有成员,不过可以通过基类的函数来访问私有成员。
将基类的指针或引用绑定到派生类对象中的基类部分上,称为派生类到基类的类型转换。
一个派生类对象包含多个组成部分:一个含有派生类自己的非静态的成员的子对象以及一个与该派生类继承的基类对应的子对象。
派生类必须使用基类的构造函数来初始化它的基类部分。对于派生类的构造函数,首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
从语法上来说我们可以在派生类构造函数中给它的公有或受保护的基类成员赋值,但最好不要这样做,因为每个类负责定义各自的接口,要想与类的对象交互必须使用该类的接口即使这个对象是派生类的基类部分也是如此。
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。静态成员遵循通用的访问控制规则。
如果我们想将某个类作为基类,则该类必须已经定义而非仅仅声明。
C++11提供了一种防止继承发生的方法,在类名后加上关键字final,其他类就不能继承它了。
当我们使用存在继承关系的类的时候,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时总是一直的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在基类向派生类的隐式类型转换,因为派生类中始终有基类的部分,派生类向基类的隐式转换实质上是基类的引用或指针可以绑定到该基类对象上,所以派生类可以隐式转换到基类,而基类不一定存在于派生类的部分中。
即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换。
如果在基类中有虚函数,那么可以使用dynamic_cast请求一个类型转换,此转换在运行时执行,会执行安全检查。如果已经已知某个基类向派生类转换是安全的,那么可以用static_cast强制覆盖掉编译器的检查工作。
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类和基类类型之间不存在这样的转换。
当我们用一个派生类对象为一个基类对 象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分会被忽略掉。
三.虚函数
当且仅当对通过指针或引用调用虚函数时才会在运行时解析,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
当派生类中的形参与基类中的形参不同时,即使虚函数名字相同也会被认为是两个不同的函数,为了避免这种情况,如果我们要复写基类中的虚函数,可以在形参列表后加上override,这样编译器会检查基类中是否有对应的虚函数,注意,只有虚函数才能被override。
我们可以在形参列表后加上final,之后任何复写该函数的行为都将引发错误。
虚函数也可以使用默认实参,不过注意,如果某次调用使用了默认实参,那么实参的值将由静态类型决定。例如基类指针或引用绑定到派生类,然后调用使用默认实参的虚函数,这个时候运行的是派生类对应的虚函数版本,不过虚函数的默认实参的值是基类中对应虚函数默实参的值。所以如果虚函数要用默认实参,最好基类和派生类中的默认实参一致,否则可能得到意想不到的结果。
有时候我们想回避虚函数的机制,直接指定调用基类或派生类中的虚函数,可以使用类名加上作用域的方式。如果一个派生类虚函数使用了其基类版本但又没有加上作用域,那么会无限递归调用自身。
四.抽象基类
通过在函数体的位置(即声明语句之前的分号)书写=0即可将一个虚函数说明为纯虚函数,=0只能位于函数内部。类内部不能为纯虚函数提供定义,不过可以在类外部提供定义。
含有(或者未经覆盖直接继承)存纯虚函数的类为抽象基类,我们不能直接定义抽象基类的对象。
五.访问控制与继承
派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限。
我们可以这样理解派生类和基类:派生类可以看作有两个对象(在只继承一个类的情况下),对象之间是独立的,根据基类的访问属性会基类对象会复制protected和public的成员到派生类对象,根据继承的属性复制的对象可能会保留更改可访问性。
不能继承友元关系;每个类负责控制各自成员的访问权限,这意味着虽然派生类单独复制了一份,但是如果复制过去的基类成员的访问权限依旧是由基类负责的,所以友元函数可以访问这部分成员。
通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中的任何可访问成员标记出来using声明中名字的访问权限由该using声明语句之前的访问说明符来决定。
默认情况下,使用class关键字定义的派生类是私有继承的,使用struct关键字定义的派生类是公有继承的。
struct和class唯一的差别就是默认成员访问说明符和默认派生类访问说明符。
六.继承中的类作用域
派生类的作用域嵌套在基类的作用域中
如果一个名字在派生类的作用域内无法正常解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。正因为类作用域有这种继承嵌套的关系,所以派生类才能像使用自己的成员一样使用基类的成员。
派生类的成员将隐藏同名的基类成员。
静态类型决定了对象的哪些成员是可见的,即使静态类型和动态类型不一致。
定义在内部作用域的函数并不会重载声明在外部的函数,内部的函数会隐藏在外部的函数,即使两个函数形参不同,所以通过派生类来调用基类的某个同名函数即使形参列表不同的话也是调用不了的。
在C++的继承中函数调用过程如下:根据静态类型在作用域中找到该名字的函数;进行类型检查;根据是否是虚函数和动态类型决定运行哪个版本。
派生类可以覆盖重载函数的0个或多个版本。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。这样的话如果派生类要重写某一个版本会很麻烦,因为这样会覆盖其他的版本,这个时候可以使用using声明,将基类的所有函数版本都添加到派生类的作用域中,此时派生类只需要定义其特有的函数即可。
七.构造函数与拷贝控制
基类通常一个定义一个虚析构函数,如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
如果一个类定义了虚析构函数,即使同时=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
如果我们想拷贝或移动基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝或移动构造函数。
和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
在C++11新标准中,派生类能够重用其直接基类定义的构造函数,具体的“继承”方式是提供一条注明了直接基类名的using声明语句。具体规则比较复杂,见书15.7.4。
八.容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式,因为容器不允许保存不同类型的元素。我们实际上存储的是基类指针(或智能指针)。
练习
没有的答案请参考答案
15.1
所谓虚成员就是基类希望派生类有各自版本的成员,基类在成员加上virtual声明。
15.2
派生类可以直接访问protected成员,不能直接访问private成员。
15.4
(a) 不正确,类不能继承自身。
(b) 正确。
(c) 错误,声明不能加继承列表。
15.8
静态是编译时就已经确定的,是声明时的类型。动态是运行时确定的,是运行时内存实际的类型。
15.9
当基类的指针或引用绑定到派生类时。
15.12
有必要,其重写基类但不允许其派生类继续重写。
15.13
有问题,如果print不指明的话调用的是自身的print会造成递归调用。
void print(ostream &os) { base::print(os); os << " " << i; }
15.14
base::print() : a,e
derived:print() : b,f
base::name() : c,d
derived::name() :
15.24
静态类型和动态类型有可能不一致的类需要虚析构函数;
没有必须执行的操作。