第11章

继承与接口

整个C++程序设计全面围绕面向对象的方式进行。类的继承特性是C++的一个非常重要的机制。继承特性可以使一个新类获得其父类的操作和数据结构,程序员只需在新类中增加原有类中没有的成分。

可以说这一章的内容是C++面向对象程序设计的关键。

下面我们简单地说一下继承的概念,先看下图。

alt

上图是一个抽象描述的特性继承表。

交通工具是一个基类(也称做父类)。在通常情况下所有交通工具所共同具备的特性是速度与额定载人的数量。但按照生活常规,我们继续对交通工具进行细分的时候,我们会分别想到汽车类和飞机类等。汽车类和飞机类同样具备速度和额定载人数量这样的特性,而这些特性是所有交通工具所共有的。那么当建立汽车类和飞机类的时候,我们无须再定义基类已经有的数据成员,而只需要描述汽车类和飞机类所特有的特性即可。飞机类和汽车类的特性是由其在交通工具类原有特性的基础上增加而来的,那么飞机类和汽车类就是交通工具类的派生类(也称做子类)。依次类推,层层递增,这种子类获得父类特性的概念就是继承。

一旦成功定义派生类,那么派生类就可以操作基类的所有数据成员,包括受保护型的。甚至我们可以在构造派生类对象的时候初始化它们,但我们不推荐这么做,因为类与类之间的操作是通过接口进行沟通的,为了不破坏类的这种封装特性,即使是父类与子类的操作也应遵循这个思想。这么做的好处也是显而易见的。当基类有错的时候,只要不涉及接口,那么基类的修改就不会影响到派生类的操作。

alt

至于为什么派生类能够对基类成员进行操作,右图可以简单地说明基类与子类在内存中的排列状态。

我们知道,类对象操作的时候在内部构造时会有一个隐性的this指针。由于Car类是Vehicle的派生类,那么当Car对象创建的时候,这个this指针就会覆盖到Vehicle类的范围,所以派生类能够对基类成员进行操作。

在面试过程中,各大企业会考量你对虚函数、纯虚函数、私有继承、多重继承等知识点的掌握程度。因此,这是本书比较难掌握的一章。

11.1 覆盖

面试例题1:以下代码的输出结果是什么?[中国著名门户网站W公司2007年9月校园招聘面试题]

alt

解析:构造函数从最初始的基类开始构造,各个类的同名变量没有形成覆盖,都是单独的变量。理解这两个重要的C++特性后解决这个问题就比较轻松了。下面我们详解这几条输出语句。

alt

本来是要调用C类的GetData(),C中未定义,故调用B中的,但是B中也未定义,故调用A中的GetData(),因为A中的doGetData()是虚函数,所以调用B类中的doGetData(),而B类的doGetData()返回B::m_data,故输出1。

alt

因为A中的doGetData()是虚函数,又因为C类中未重定义该接口,所以调用B类中的doGetData(),而B类的doGetData()返回B::m_data,故输出1。

alt

肯定返回1了。

alt

因为C类中未重定义GetData(),故调用从B继承来的GetData(),但是B类也未定义,所以调用A中的GetData(),因为A中的doGetData()是虚函数,所以调用B类中的doGetData(),而B类的doGetData()返回B::m_data,故输出1。

alt

肯定是B类的返回值1了。

alt

因为直接调用了A的doGetData(),所以输出0。

alt

因为直接调用了B的doGetData(),所以输出1。

alt

因为C类中未重定义该接口,所以调用B类中的doGetData(),而B类的doGetData()返回B::m_data,故输出1。这里要注意存在一个就近调用,如果父辈存在相关接口则优先调用父辈接口,如果父辈也不存在相关接口则调用祖父辈接口。

答案:1 1 1 1 1 0 1 1。

面试例题2:以下代码的输出结果是什么?[德国某著名电子/通信/IT企业2005年面试题]

alt

A.A A B A

B.A A B B

C.A A A B

D.A B B A

解析:这是一个虚函数覆盖虚函数的问题。A类里的f函数是一个虚函数,虚函数是被子类同名函数所覆盖的。而B类里的f函数也是一个虚函数,它覆盖A类f函数的同时,也会被它的子类覆盖。但是在Bpb=(B)pa;里面,该语句的意思是转化pa为B类型并新建一个指针pb,将pa复制到pb。但这里有一点请注意,就是pa的指针始终没有发生变化,所以pb也指向pa的f函数。这里并不存在覆盖的问题。

delete pa,pb;删除了pa和pb所指向的地址,但pa、pb指针并没有删除,也就是我们通常说的悬浮指针。现在重新给pa指向新地址,所指向的位置是B类的,而pa指针类型是A类的,所以就产生了一个覆盖。pa->f();的值是B。

pb=(B*)pa;转化pa为B类指针给pb赋值,但pa所指向的f函数是B类的f函数,所以pb所指向的f函数是B类的f函数。pb->f();的值是B。

答案:B

11.2 私有继承

面试例题1:Tell me the difference in public inherit and private inherit.(公有继承和私有继承的区别是什么?)[中国某著名计算机金融软件公司2005年面试题]

A.No difference.(没有区别。)

B.Private inherit will make every member from parent class into private.(私有继承使父类中所有元素变成私有。)

C.Private inherit will make functions from parent class into private.(私有继承使父类中的函数转化成私有。)

D.Private inherit make every member from parent not-accessible to sub-class.(私有继承使父类中所有元素无法与子类联系。)

解析:

A肯定错,因为子类只能继承父类的protected和public,所以B也是错误的。

C的叙述不全面,而且父类可能有自己的私有方法成员,所以也是错误的。

答案:D

扩展知识


一个私有的或保护的派生类不是子类,因为非公共的派生类不能做基类能做的所有的事。例如,下面的代码定义了一个私有继承基类的类:

alt

函数Func()要用一个Animal类型的对象,但调用Func(dao)实际上传递的是Cat类的对象。因为Cat是公共继承Animal类,所以Cat类中的对象可以使用Animal类的所有的公有成员变量或函数。Animal对象可以做的事,Cat对象也可以做。

但是,对于gir对象就不一样。Giraffe类私有继承了Animal类,意味着对象gir不能直接访问Animal类的成员。其实,在gir对象空间中,包含Animal类的对象,只是无法让其公开访问。

公有继承就像是三口之家的小孩,享受父母所给的温暖,享有父母的一切(public和protected的成员)。其中保护的成员不能被外界所享有,但可以为小孩所拥有。只是父母还有其一点点隐私(private成员)不能为小孩所知道。

私有继承就像是离家出走的小孩,一个人在外面漂泊。他(她)不能拥有父母的住房和财产(如gir.eat()是非法的),在外面自然也就不能代表其父母,甚至不算是其父母的小孩。但是在他(她)的身体中,流淌着父母的血液,所以,在小孩自己的行为中又有其与父母相似的成分。

例如下面的代码中,Giraffe继承了Animal类,Giraffe的成员函数可以像Animal对象那样访问其Animal成员:

alt

运行结果为:

alt

上例中,gir对象就好比是小孩。eat()成员函数是其父母的行为,take()成员函数是小孩的行为,在该行为中,渗透着其父母的行为。但是小孩无法直接使用eat()成员函数,因为,离家出走的他(她)无法拥有其父母的权力。保护继承与私有继承类似,继承之后的类相对于基类来说是独立的。保护继承的类对象,在公开场合同样不能使用基类的成员。代码如下:

alt

派生类的3种继承方式小结如下。

公有继承(public)、私有继承(private)和保护继承(protected)是常用的3种继承方式。

1.公有继承方式

基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。这里保护成员与私有成员相同。

基类成员对派生类的可见性对派生类来说,基类的公有成员和保护成员可见,基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态;基类的私有成员不可见,基类的私有成员仍然是私有的,派生类不可访问基类中的私有成员。

基类成员对派生类对象的可见性对派生类对象来说,基类的公有成员是可见的,其他成员是不可见的。

所以,在公有继承时,派生类的对象可以访问基类中的公有成员,派生类的成员函数可以访问基类中的公有成员和保护成员。

2.私有继承方式

基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。

基类成员对派生类的可见性对派生类来说,基类的公有成员和保护成员是可见的,基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问;基类的私有成员是不可见的,派生类不可访问基类中的私有成员。

基类成员对派生类对象的可见性对派生类对象来说,基类的所有成员都是不可见的。

所以,在私有继承时,基类的成员只能由直接派生类访问,而无法再往下继承。

3.保护继承方式

这种继承方式与私有继承方式的情况相同。两者的区别仅在于对派生类的成员而言,基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。

基类成员对派生类的可见性对派生类来说,基类的公有成员和保护成员是可见的,基类的公有成员和保护成员都作为派生类的保护成员,并且不能被这个派生类的子类所访问;基类的私有成员是不可见的,派生类不可访问基类中的私有成员。

基类成员对派生类对象的可见性对派生类对象来说,基类的所有成员都是不可见的。

所以,在保护继承时,基类的成员也只能由直接派生类访问,而无法再往下继承。

C++支持多重继承,从而大大增强了面向对象程序设计的能力。多重继承是一个类从多个基类派生而来的能力。派生类实际上获取了所有基类的特性。当一个类是两个或多个基类的派生类时,必须在派生类名和冒号之后,列出所有基类的类名,基类间用逗号隔开。派生类的构造函数必须激活所有基类的构造函数,并把相应的参数传递给它们。派生类可以是另一个类的基类,这样,相当于形成了一个继承链。当派生类的构造函数被激活时,它的所有基类的构造函数也都会被激活。在面向对象的程序设计中,继承和多重继承一般指公共继承。在无继承的类中,protected和private控制符是没有差别的。在继承中,基类的private对所有的外界都屏蔽(包括自己的派生类),基类的protected控制符对应用程序是屏蔽的,但对其派生类是可访问的。

保护继承和私有继承只是在技术上讨论时有其一席之地。


面试例题2:请考虑标记为A到J的语句在编译时可能出现的情况。如果能够成功编译,请记为“RIGHT”,否则记为“ERROR”。[中国台湾某著名计算机硬件公司2005年12月面试题]

alt

解析:

A、B、C都是错误的。因为m_nPrt是父类Parent的私有变量,所以不能被子类访问。

D正确。cd1是公有继承,可以访问并改变父类的公有变量。

E错误。m_nPtd是父类Parent的保护变量,可以被公有继承的cd1访问,但不可以修改。

F正确。可以通过函数访问父类的保护变量。

G错误。cd2是保护继承的,不可以直接修改父类的公有变量。

H正确。可以通过函数访问父类的保护变量。

I错误。cd3是私有继承的,不可以直接修改父类的公有变量。

J正确。可以通过函数访问父类的保护变量。

答案:

A、B、C、E、G、I是“ERROR”。

D、F、H、J是“RIGHT”。

11.3 虚函数继承和虚继承

面试例题1:Which of the following options describe the expected answer for a class that has 5 virtual functions?(一个类有5个虚方法,下列说法正确的是哪项?)[英国某图形软件公司A2009年10月面试题]

A.Every object of the class holds the address of the first virtual function, and each function in turn holds the address of the next virtual function.(类中的每个对象都有第一个虚方法的地址,每一个方法都有下一个虚方法的地址。)

B.Every object of the class holds the address of a link list object that holds the addresses of the virtual functions.(类中的每个对象都有一个链表用来存虚方法地址。)

C.Every object of the class holds the addresses of the 5 virtual functions.(类中的每个对象都保存5个虚方法的地址。)

D.Every object of the class holds the address of a structure that holding the addresses of the 5 virtual functions.(类中的每个对象有一个结构用来保存虚方法地址。)

解析:每个对象里有虚表指针,指向虚表,虚表里存放了虚函数的地址。虚函数表是顺序存放虚函数地址的,不需要用到链表(link list)。

答案:B

面试例题2:下面程序的结果是什么?

alt

解析:C++ 2.0以后全面支持虚函数继承。这个特性的引入为C++增强了不少功能,也引入了不少烦恼。如果能够了解编译器是如何实现虚函数继承,它们在类的内存空间中又是如何布局的,就可以对C++的了解深入不少。

(1)对于class A,由于有一个虚函数,那么必须有一个对应的虚函数表来记录对应的函数入口地址。每个地址需标有一个虚指针,指针的大小为4。类中还有一个char k[3],每一个char值所占位置是1,所以char k[3]所占大小是3。做一次数据对齐后(编译器里一般以4的倍数为对齐单位),char k[3]所占大小变为4。sizeof(A)的结果就是char k[3]所占大小4和虚指针所占大小4,两者之和等于8。

(2)对于class B,由于class B虚继承了class A,同时还拥有自己的虚函数,那么class B中首先拥有一个vfptr_B,指向自己的虚函数表。还有char j[3],大小为4。可虚继承该如何实现?首先要通过加入一个虚类指针(记vbptr_B_A)来指向其父类,然后还要包含父类的所有内容。有些复杂,不过还不难想象。sizeof(B)的结果就是char k[3]所占大小4和虚指针vfptr_B所占大小4加sizeof(A)所占大小8,三者之和等于16。

(3)下面是class C了。class C首先也得有个vfptr_C,然后是char i[3],然后是sizeof(B),所以sizeof(C)的结果就是char i[3]所占大小4和虚指针vfptr_C所占大小4加sizeof(B)所占大小16,三者之和等于24。

答案:在gcc中打印上面几个类的大小,结果为8、16、24。

扩展知识


关键字virtual告诉编译器它不应当完成早绑定,相反,它应当自动安装实现晚绑定所必需的所有机制。这意味着,如果我们对brass对象通过基类instrument地址调用play(),我们将得到恰当的函数。

为了完成这件事,编译器对每个包含虚函数的类创建一个表(称为vtable)。在vtable中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地设置一指针,称为vpointer(缩写为vptr),指向这个对象的vtable。通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取得这个vptr,并在vtable表中查找函数地址的代码,这样就能调用正确的函数使晚绑定发生。

为每个类设置vtable、初始化vptr、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕编译器还不知道这个对象的特定类型。

画图简单表示一下虚表跟踪后的结果,如下图所示。

alt

我们已经知道有一个vptr,不过vptr的位置也许在对象的开始,也许在对象的尾部。所以上面的操作的值应该是8或者12(如果vptr在前面的话)。但实际上取回的值被加上了1。原因是必须要区别一个不指向任何成员的指针和一个指向第一个成员的指针。这里又有点儿不好理解了,举个例子来说明一下。

想象你和你的另外两个朋友合住一个有3个房间的别墅,你住在第三间。如果一个你们3人共同的朋友来找你玩,你就给他别墅的地址就行了,不用给出你们任意一个人的房间号(不指向任何成员)。但如果你的一个私人朋友来拜访你(这个私人朋友不认识你的那两位朋友),你会给出别墅的地址和你的那个房间号。为了使这个地址有区别,你必须用第一个房间作为偏移量(offset)来表示你的房间位置。你可以对你的朋友说从进门后的第一间房子再往里面走两个房子就是我的房子。如果房子间距是4,那么第一间到第三间的距离是8。

如果以上函数稍加变动,不采用虚继承的方式而是直接继承,结果就将不会产生偏移,结果为8,12,16。


面试例题3:什么是虚继承?它与一般的继承有什么不同?它有什么用?写出一段虚继承的C++代码。[美国某著名计算机软件公司2005年面试题]

答案:

虚拟继承是多重继承中特有的概念。虚拟基类是为解决多重继承而出现的。请看下图:

alt

类D继承自类B和类C,而类B和类C都继承自类A,因此出现如下图所示这种情况:

alt

在类D中会两次出现A。为了节省内存空间,可以将B、C对A的继承定义为虚拟继承,而A就成了虚拟基类。最后形成如下图所示的情况:

alt

代码如下:

alt

注意:虚函数继承和虚继承是完全不同的两个概念,请不要在面试中混淆。

面试例题4:如果一个圆角矩形有直边和圆角,那么圆角矩形也就多重继承了圆形和矩形,而圆形和矩形又都是从shape类里继承的。请问:当你创建一个圆角矩形类时,共创建了多少个shape?

答案:如果圆形类和矩形类都不是用关键字virtual继承shape类,那么生成两个shape,一个为圆形类,一个为矩形类。如果圆形类和矩形类都是用关键字virtual继承shape类,那么生成一个共享的shape。

11.4 多重继承

面试例题1:请评价多重继承的优点和缺陷。

答案:

多重继承在语言上并没有什么很严重的问题,但是标准本身只对语义做了规定,而对编译器的细节没有做规定。所以在使用时(即使是继承),最好不要对内存布局等有什么假设。此类的问题还有虚析构函数等。为了避免由此带来的复杂性,通常推荐使用复合。但是,在《C++设计新思维》(Andrei Alexandrescu)一书中对多重继承和模板有极为精彩的运用。

(1)多重继承本身并没有问题,如果运用得当可以收到事半功倍的效果。不过大多数系统的类层次往往有一个公共的基类,就像MFC中的Cobject,Java中的Object。而这样的结构如果使用多重继承,稍有不慎,将会出现一个严重现象——菱形继承,这样的继承方式会使得类的访问结构非常复杂。但并非不可处理,可以用virtual继承(并非唯一的方法)及Loki库中的多继承框架来掩盖这些复杂性。

(2)从哲学上来说,C++多重继承必须要存在,这个世界本来就不是单根的。从实际用途上来说,多重继承不是必需的,但这个世界上有多少东西是必需的呢?对象不过是一组有意义的数据集合及其上的一组有意义的操作,虚函数(晚期绑定)也不过是一堆函数入口表,重载也不过是函数名扩展,这些东西都不是必需的,而且对它们的不当使用都会带来问题。但是没有这些东西行吗?很显然,不行。

(3)多重继承在面向对象理论中并非是必要的——因为它不提供新的语义,可以通过单继承与复合结构来取代。而Java则放弃了多重继承,使用简单的interface取代。多重继承是把双刃剑,应该正确地对待。况且,它不像goto,不破坏面向对象语义。跟其他任何威力强大的东西一样,用好了会带来代码的极大精简,用坏了那就不用说了。

C++是为实用而设计的,在语言里有很多东西存在着各种各样的“缺陷”。所以,对于这种有“缺陷”的东西,它的优劣就要看使用它的人。C++不回避问题,它只是把问题留给使用者,从而给大家更多的自由。像Ada、Pascal这类定义严格的语言,从语法上回避了问题,但并不是真正解决了问题,而使人做很多事时束手束脚(当然,习惯了就好了)。

(4)多重继承本身并不复杂,对象布局也不混乱,语言中都有明确的定义。真正复杂的是使用了运行时多态(virtual)的多重继承(因为语言对于多态的实现没有明确的定义)。为什么非要说多重继承不好呢?如果这样的话,指针不是更容易出错,运行时多态不是更不好理解吗?

因为C++中没有interface这个关键字,所以不存在所谓的“接口”技术。但是C++可以很轻松地做到这样的模拟,因为C++中的不定义属性的抽象类就是接口。

(5)要了解C++,就要明白有很多概念是C++试图考虑但是最终放弃的设计。你会发现很多Java、C#中的东西都是C++考虑后放弃的。不是说这些东西不好,而是在C++中它将破坏C++作为一个整体的和谐性,或者C++并不需要这样的东西。举一个例子来说明,C#中有一个关键字base用来表示该类的父类,C++却没有对应的关键字。为什么没有?其实C++中曾经有人提议用一个类似的关键字inherited,来表示被继承的类,即父类。这样一个好的建议为什么没有被采纳呢?这本书中说得很明确,因为这样的关键字既不必须又不充分。不必须是因为C++有一个typedef * inherited,不充分是因为有多个基类,你不可能知道inherited指的是哪个基类。

很多其他语言中存在的时髦的东西在C++中都没有,这之中有的是待改进的地方,有的是不需要,我们不能一概而论,需要具体问题具体分析。

面试例题2:声明一个类Jetplane,它是从Rocket和Airplane继承而来的。

解析:多重继承问题。

答案:

class JetPlane: public Rocket, public Airplane

面试例题3:在多继承的时候,如果一个类继承同时继承自class A和class B,而class A和B中都有一个函数叫foo(),如何明确地在子类中指出override是哪个父类的foo()?

解析:多重继承问题。

答案:比如,C继承自A和B,如果出现了相同的函数foo(),那么C.A::foo(),C.B::foo()就分别代表从A类中继承的foo函数和从B类中继承的foo函数。

alt

面试例题4:下面程序的输出结果是多少?[英国某图形软件公司2009年10月面试题]

alt

解析:本题涉及基类和派生类的地址和布局的问题。

alt

这里两端的数据类型不同,比较时需要进行隐式类型转换。(pC==pB)相当于:

alt

pB实际上指向的地址是对象C中的子类B部分,从地址上跟pC不一样,所以直接比较地址数值的时候是不相等的。

但是,当进行pC==pB比较时,实际上是比较pC指向的对象和(C )隐式转换pB后pB指向的对象(pC指向的对象)的部分,这个是同一部分,也就显示相等了。假设pC指向的地址是0x3d3dd8,pB指向的地址是0x3d3ddc。(C )隐式转换pB后pB指向的地址也就变成了0x3d3dd8,如下图所示。

alt

第二处两端转换为int,指针pC和pB的值不同,转换后的int值不同:

alt

如果转化成下面的形式,就可以了:

alt

答案:输出如下:

equal

not equal

11.5 检测并修改不适合的继承

面试例题1:如果鸟是可以飞的,那么鸵鸟是鸟么?鸵鸟如何继承鸟类?[美国某著名分析软件公司2005年面试题]

解析:如果所有鸟都能飞,那鸵鸟就不是鸟。回答这种问题时,不要相信自己的直觉。将直觉和合适的继承联系起来还需要一段时间。

根据题干可以得知:鸟是可以飞的。也就是说,当鸟飞行时,它的高度是大于0的。鸵鸟是鸟类(生物学上)的一种,但它的飞行高度为0(鸵鸟不能飞)。

不要把可替代性和子集相混淆。即使鸵鸟集是鸟集的一个子集(每个驼鸟集都在鸟集内),但并不意味着鸵鸟的行为能够代替鸟的行为。可替代性与行为有关,与子集没有关系。当评价一个潜在的继承关系时,重要的因素是可替代的行为,而不是子集。

答案:如果一定要让鸵鸟来继承鸟类,可以采取组合的办法,把鸟类中的可以被鸵鸟继承的函数挑选出来,这样鸵鸟就不是“a kind of”鸟了,而是“has some kind of”鸟的属性而已。代码如下:

alt

面试例题2:Find the defects in each of the following programs, and explain why it is incorrect.(找出下面程序的错误,并解释它为什么是错的。)[中国台湾某著名杀毒软件公司2005年面试题]

alt

解析:这是个类继承问题。如果不指定public,C++默认的是私有继承。私有继承是无法继承并使用父类函数中的公有变量的。

答案:把class Derive: Base改成class Derive:public Base。

扩展知识(组合)


若在逻辑上A是B的“一部分(a part of)”,则不允许B从A派生,而是要用A和其他东西组合出B。

例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类Head应该由类Eye、Nose、Mouth、Ear组合而成,而不是派生而成。程序如下:

alt

Head由Eye、Nose、Mouth、Ear组合而成。如果允许Head从Eye、Nose、Mouth、Ear派生而成,那么Head将自动具有Look、Smell、Eat、Listen这些功能。程序十分简短并且运行正确,但是下面这种设计方法却是不对的。

alt


面试例题3:Find the defects in each of the following programs, and explain why it is incorrect.(找出下面程序的错误,并解释它为什么是错的。)[德国某著名软件咨询企业2005年面试题]

alt

解析:要在子类中设定初始成员变量,把derived(int x, int y)改成derived(int x, int y) : base(x)。

答案:

代码如下:

alt

11.6 纯虚函数

面试例题1:下面的程序有何错误?[德国某著名软件咨询企业2004年面试题]

alt

解析:因为Shape类中的Draw函数是一个纯虚函数,所以Shape类是不能实例化一个对象的。Shape s1;是不可以的,解决方法是把Draw函数修改成一般的虚函数。

答案:

修改后的代码如下:

alt

面试例题2:什么是虚指针?[美国某著名移动通信企业面试题]

答案:虚指针或虚函数指针是一个虚函数的实现细节。带有虚函数的类中的每一个对象都有一个虚指针指向该类的虚函数表。

面试例题3:声明一个类Vehicle,使其成为抽象数据类型。写出类Car和Bus的声明,其中每个类都从类Vehicle里派生。使Vehicle成为一个带有两个纯虚函数的ADT,使Car和Bus不是ADT。[美国某著名移动通信企业面试题]

答案:

alt

面试例题4:虚函数的入口地址和普通函数有什么不同?[英国某著名计算机图形图像公司面试题]

答案:每个虚函数都在vtable中占了一个表项,保存着一条跳转到它的入口地址的指令(实际上就是保存了它的入口地址)。当一个包含虚函数的对象(注意,不是对象的指针)被创建的时候,它在头部附加一个指针,指向vtable中相应的位置。调用虚函数的时候,不管你是用什么指针调用的,它先根据vtable找到入口地址再执行,从而实现了“动态联编”。而不像普通函数那样简单地跳转到一个固定地址。

面试例题5:

1.C++中如何阻止一个类被实例化?

2.一般在什么时候构造函数被声明成private呢?

3.什么时候编译器会生成默认的copy constructor呢?

4.如果你已经写了一个构造函数,编译器还会生成copy constructor吗?[英国某著名计算机图形图像公司面试题]

答案:

1.使用抽象类,或者构造函数被声明成private。

2.比如要阻止编译器生成默认的copy constructor的时候。

3.只要自己没写,而程序中需要,都会生成。

4.会生成。

扩展知识


在C#中,有一个专门的seal class(封闭类)来防止继承。


11.7 运算符重载与RTTI

面试例题1:Which of the following statements provide a valid reason NOT to use RTTI for distributed (i.e. networked between different platforms) applications in C++?(在分布式系统中,不使用RTTI的一个合理解释是?)[中国某互联网公司2010年6月面试题]

A.RTTI is too slow and use too much memory(RTTI太慢了)

B.RTTI does not have standardized run-time behavior(RTTI不是一个标准行为)

C.TTI's performance is unpredictable/non-deterministic and is lack of expansibility(RTTI行为不可预期及缺乏扩展性)

D.RTTI must fail to function correctly at run-time(RTTI函数在运行时会失败)

解析:C++中的每个特性,都是从程序员平时生活中逐渐精化而来的。在不正确的场合使用它们必然会引起逻辑、行为和性能上的问题。对于上述特性,应该只在必要、合理的前提下才使用。

C++引入的额外开销体现在以下两方面。

1.编译时开销

模板、类层次结构、强类型检查等新特性,以及大量使用了这些新特性的C++模板、算法库都明显地增加了C++编译器的负担。但是应当看到,这些新机能在不增加程序执行效率的前提下,明显降低了广大C++程序员的工作量。

2.运行时开销

运行时开销恐怕是程序员最关心的问题之一了。相对于传统C程序而言,C++中有可能引入额外运行时开销特性包括:

● 虚基类。

● 虚函数。

● RTTI(dynamic_cast和typeid)。

● 异常。

● 对象的构造和析构。

虚基类,从直接虚继承的子类中访问虚基类的数据成员或其虚函数时,将增加两次指针引用(大部分情况下可以优化为一次)和一次整型加法的时间开销。定义一个虚基类表,定义若干虚基类表指针的空间开销。

虚函数的运行开销有进行整型加法和指针引用的时间开销。定义一个虚表,定义若干个(大部分情况下是一个)虚表指针的空间开销。

RTTI的运行开销主要有进行整型比较和取址操作(可能还会有一两次整形加法)所增加的时间开销。定义一个type_info对象(包括类型ID和类名称)的空间开销。"dynamic_cast"用于在类层次结构中漫游,对指针或引用进行自由的向上、向下或交叉转化。"typeid"则用于获取一个对象或引用的确切类型。一般地讲,能用虚函数解决的问题就不要用"dynamic_cast",能够用"dynamic_cast"解决的就不要用"typeid"。

关于异常,对于几乎所有编译器来说,在正常情况(未抛出异常)下,try块中的代码执行效率和普通代码一样高,而且由于不再需要使用传统上通过返回值或函数调用来判断错误的方式,代码的实际执行效率还会进一步提高。抛出和捕捉异常的开销也只是在某些情况下会高于函数返回和函数调用的开销。

关于构造和析构,开销也不总是存在的。对于不需要初始化/销毁的类型,并没有构造和析构的开销,相反对于那些需要初始化/销毁的类型来说,即使用传统的C方式实现,也至少需要与之相当的开销。

实事求是地讲,RTTI是有用的。但因为一些理论上及方法论上的原因,它破坏了面向对象的纯洁性。

首先,它破坏了抽象,使一些本来不应该被使用的方法和属性被不正确地使用。其次,因为运行时类型的不确定性,它把程序变得更脆弱。第三点,也是最重要的一点,它使程序缺乏扩展性。当加入了一个新的类型时,你也许需要仔细阅读你的dynamic_cast或instanceof的代码,必要时改动它们,以保证这个新的类型的加入不会导致问题。而在这个过程中,编译器将不会给你任何帮助。

很多人一提到RTTI,总是侧重于它的运行时的开销。但是,相比于方法论上的缺点,这点运行时的开销真是无足轻重的。

总的来说,RTTI因为它的方法论上的一些缺点,它必须被非常谨慎地使用。今天面向对象语言的类型系统中的很多东西就是产生于避免RTTI的各种努力。

答案:C

扩展知识


RTTI是Runtime Type Information的缩写,从字面上来理解就是执行时期的类型信息,其重要作用就是动态判别执行时期的类型。有的读者会认为设计类时使用虚函数就已经足够了,可是虚函数有本身的局限性,当涉及类别阶层时,需要判断某个对象所属的类别,而因为类别设计中大量使用了虚函数,所以使得这一工作难以实现,但又极其重要,于是使用RTTI的typeid运算符能使程序员确定对象的动态类型。

借用一个例子(也是一道面试题):写了一个base类,派生derived类,在base类中实现一些常见的方法。下面两个函数要求能够输出形参的真实类型,funcC是用dynamic_cast类型转换是否成功来识别类型的,dynamic_cast必须要在有虚函数的hierarchy里进行,如果成功则返回1;funcD是用typeid判断基类地址是否一致的办法来识别类型的。

alt

运行时类型识别RTTI使用时要注意以下几点:

● 用typeid()返回一个typeinfo对象,也可以用于内部类型,当用于非多态类型时,没有虚函数,用typeid返回的将是基类地址。

● 动态映射dynamic_cast<类型>(变量)可以映射到中间级,将派生类映射到任何一个基类,然后在基类之间可以相互映射。

● 不能对void指针进行映射。

● 如果p是指针,typeid(*p)返回p所指向的派生类类型,typeid(p)返回基类类型;如果r是引用,typeid(r)返回派生类类型,typeid(&r)返回基类类型。

● 典型的RTTI是通过在VTABLE中放一个额外的指针实现的。每个新类只产生一个typeinfo实例,额外指针指向typeinfo,typeid返回对它的一个引用。

● 动态映射dynamic_cast<目标*><源指针>,先恢复源指针的RTTI信息,再取目标的RTTI信息,比较两者是否相同,或者是目标类型的基类;由于它需要检查一长串基类列表,故动态映射的开销比typeid大。


面试例题2:A C++ developer wants to handle a static_cast <char>() operation for the class String shown below. Which of the following options are valid declarations that will accomplish this task?(一个C++程序员想要运行一个static_cast <char>(),为了能够确保合法化,下面这段代码横线处应填写什么?)

alt

A.char operator char();

B.operator char*();

C.char* operator();

D.char* operator String();

解析:运算符重载问题。

运算符重载就是赋予已有的运算符多重含义。C++中通过重新定义运算符,使它能够用于特定类的对象执行特定的功能,这增强了C++语言的扩充能力。运算符重载的作用是允许程序员为类的用户提供一个直觉的接口。通过重载类上的标准运算符,可以发掘类的用户的直觉。使得用户程序所用的语言是面向问题的,而不是面向机器的。最终目标是降低理解难度并减少错误率。

几乎所有的运算符都可用做重载。具体包含:

alt

下列运算符不允许重载:

alt

用户重载新定义运算符,不改变原运算符的优先级和结合性。这就是说,对运算符重载不改变运算符的优先级和结合性,并且运算符重载后,也不改变运算符的语法结构,即单目运算符只能重载为单目运算符,双目运算符只能重载双目运算符。运算符重载实际是一个函数,所以运算符的重载实际上是函数的重载。编译程序对运算符重载的选择,遵循着函数重载的选择原则。当遇到不很明显的运算时,编译程序将去寻找参数相匹配的运算符函数。

运算符重载可以使程序更加简洁,使表达式更加直观,增加可读性。但是,运算符重载使用不宜过多,否则会带来一定的麻烦。运算符重载的函数一般采用如下两种形式:成员函数形式和友元函数形式。这两种形式都可访问类中的私有成员。

这里有一个复数运算重载的四则运算符的例子。复数由实部和虚部构造,可以定义一个复数类,然后再在类中重载复数四则运算的运算符:

alt

在本题中,static_cast <char>实现强制类型转换,char是目标类型;operation char*()是转换函数,无参数,无返回类型,以目标类型为其函数名,其函数定义中要返回转换结果。

答案:B

面试例题3: Which of the following options are returned by the typeid operator in C++?(C++里面的typeid运算符返回值是什么?)

A.A reference to a std::type_info object(type_info对象的引用)

B.A const reference to a const std::type_info object(type_info常量对象的常量引用)

C.A const reference to a std::type_info object(type_info对象的常量引用)

D.A reference to a const std::type_info object(type_info常量对象的引用)

解析:本题用于查看typeid的输出结果,typeid是用来获取一个对象或引用的确切类型。先看一段程序:

alt

本段程序在VC++2008的输出结果如下(VC6.0和dev输出可能会有所不同):

alt

请读者对照上面代码详细研读typeid的输出规律。

本题选择D,type_info常量对象的引用。

答案:D

面试例题4:Which of the following statements accurately describe unary operator overloading in C++?(一元运算符重载,下列说法正确的是哪项?)

A.A unary operator can be overloaded with no parameters when the operator function is a class member(当运算符函数是一个类成员时,一元运算符能被无参形式重载)

B.A unary operator can be overloaded with one parameter when the operator function is a class member(当运算符函数是一个类成员时,一元运算符能被带一个参数形式重载)

C.A unary operator can be overloaded with 2 parameters when the operator function is free standing function (not a class member)(当运算符函数是一个独立函数时,一元运算符能被带两个参数形式重载)

D.A unary operator can only be overloaded if the operator function is a class member(当且仅当运算符函数是一个类成员时,一元运算符才会被重载)

解析:本题考的知识点是运算符重载问题。

定义一个重载运算符就像定义一个函数,只是该函数的名字是operator@,这里@代表运算符。函数参数表中参数的个数取决于两个因素:

● 运算符是一元的(一个参数)还是二元的(两个参数)。

● 运算符被定义为全局函数(对于一元运算符是一个参数,对于二元运算符是两个参数),如果运算符是成员函数(对于一元运算符没有参数,对于二元运算符是一个参数)。

对于二元运算符,单个参数是出现在运算符右侧的那个。当一元运算符被定义为成员函数时,没有参数。成员函数被运算符左侧的对象调用。在C++中,后缀++如果是成员函数,那么它就是二元的操作符。这里C++标准中有详细的阐述:

A binary operator shall be implemented either by a non-static member function with one parameter or by a non-member function with two parameters.(非静态成员函数操作符(带1个参数)是二元运算符;非成员函数操作符(带两个参数)是二元运算符。)

一个常见的例子如下:

alt

对于非条件运算符(条件运算符通常返回一个布尔值),如果两个参数是相同的类型,希望返回和运算相同类型的对象或引用。如果它们不是相同类型,它作什么样的解释就取决于程序设计者。

答案:A