购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第10章

面向对象

这样一句话:“编程是在计算机中反映世界”,我觉得再贴切不过。面向对象(Object-Oriented)对这种说法的体现也是最优秀的。比如,我们设计的数据结构是一个学生成绩的表现,而对数据结构的操作(函数)是分离的,虽然这些操作是针对这种数据结构而产生的。为了管理大量的数据,我们不得不小心翼翼地使用它们。

作为一名软件开发人员,我们可以深刻地体会到面向对象系统设计带来的种种便利。

● 良好的可复用性:开发同类项目的次数与开发新项目的时间成反比,减少重复劳动。

● 易维护:基本上不用花太大的精力跟维护人员讲解,他们可以自己读懂源程序并修改。否则开发的系统越多,负担就越重。

● 良好的可扩充性:以前,在向一个用结构化思想设计的庞大系统中加一个功能则必须考虑兼容前面的数据结构,理顺原来的设计思路。即使客户愿意花钱修改,作为开发人员多少都有点儿恐惧。在向一个用面向对象思想设计的系统中加入新功能,不外乎是加入一些新的类,基本上不用修改原来的东西。

在面试过程中,求职者是否对面向对象的基本概念、结构和类、多态性及构造函数有清晰的认识,是否能够有效地编程实现面向对象的各种功能,是IT企业考查的重点内容。

10.1 面向对象的基本概念

面试例题1: Which of the following(s)are NOT related to object-Oriented Design?(以下选项中哪个不是面向对象设计)

A.Inheritance(继承)

B.Liskov substitution principle(里氏代换原则)

C.Open-close principle(开闭原则)

D.Polymorphism(多态)

E.Defensive programming(防御式编程)

解析 :面向对象设计的三原则:封装,继承,多态。

Liskov Substitution Principle(里氏代换原则)是继承复用的基石:子类型必须能够替换它们的基类型。如果每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都代换为o2时,程序P的行为没有变化,那么类型T2是类型T1的子类型。换言之,一个软件实体如果使用的是一个基类的话,那么一定适用于其子类,而且它根本不能察觉出基类对象和子类对象的区别。只有衍生类替换基类的同时软件实体的功能没有发生变化,基类才能真正被复用。

Open-close principle(开闭原则)是面向对象设计的重要特性之一:软件对扩展应该是开放的,对修改应该是关闭的。通俗点说,已经设计好的代码应该是不做修改的(闭),如果需求改变,就另外自己扩展一块去(开),别破坏我原来的代码。

Defensive programming(防御式编程)只是一种编程技巧,与面向对象设计无关。

防御式编程的主要思想是:子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。这种思想是将可能出现的错误造成的影响控制在有限的范围内。简单来说,就是不管出了什么错,程序都不能崩溃。用过Windows 98,应该都对蓝屏深有体会。所以不让程序崩溃是合理的。但是这样会让程序留下隐患,比如传进来的字符串太长了,固定大小的字符串数组装不下,需要截取一段,但问题是,截取之后的信息可能就不是用户真正想要的信息了,这样可能会在后面使用数据的时候带来问题,而且对于很多后端模块,数据都应该是合法的,存在非法数据,应该是上游模块出bug了。

理想情况下,每一步都做防御,发现非法的情况,就输出log,然后解决问题。但是,如果是上游模块出了问题,上游的程序员可就不积极帮忙了,反正程序没崩溃,服务还正常。这时候也没办法,只能做些简单的容错处理,做了容错之后,错误就传递到了下一层,下一层也只能继续做容错。然后哪天来个新人,看到code有bug,修一下,这下惨了,下游已经把有bug的输入作为标准了,你这一修,把下游弄崩溃了,然后只能回滚。最后的结果就是系统里充满了各种各样的bug,而且没法子修。每个人想着不要在我这个模块崩溃,就算要在我这个模块崩溃,也不要在我维护的时候崩溃。这本质上是一种大公司病。各部门自扫门前雪,每个人都在想,我走了之后哪管洪水涛天。

不是说模块可以随便崩溃,想要服务稳定,更重要的不是单个模块是否崩溃,而是整个系统是否稳定,把错误控制在一定范围内。比如某些特定的输入会引起系统崩溃,在探测出这些输入之后,就把它挡在前端。如果是数据更新引起的崩溃,就先少量更新,一旦发现更新出错,剩下的就不更新了。系统中尽量不要有单点。但这些事情,在大公司里也不是程序员能做到的,而是架构师的设计问题了。

答案 :E

面试例题2: Which of the following C++keyword(s)is(are)related to encapsulation?(下面哪个/些关键字与封装相关?)

A.virtual B.void C.interface D.private E.all of the above

解析 :什么是封装?

从字面意思来看,封装就是把一些相关的东西打包成一“坨”。封装最广为人知的例子,就是在面向对象编程里面,把数据和针对该数据的操作,统一到一个class里。

很多人把封装的概念局限于类,认为只有OO中的class才算是封装。这实际上是片面的在很多不使用“类”的场合,一样能采用封装的手法:

(1)通过文件。

比如C和C++支持对头文件的包含(#include)。因此,可以把一些相关的常量定义、类型定义、函数声明,统统封装到某个头文件中。

(2)通过namespace/package/module。

C++的namespace、Java的package、Python的module,这些语法虽然称呼各不相同,但具有相同的本质。因此,也可以利用这些语法来进行封装。

那么封装有一个主要的好处,就是增加软件代码的内聚性。通过增加内聚性,进而提高可复用性和可维护性。此外还可以“信息隐藏”:把不该暴露的信息藏起来。如private、protected之类的关键字。这些关键字可以通过访问控制,来达到信息隐藏的目的。

本题中,interface属于继承,virtual属于多态,private才是与封装相关。

答案 :D

面试例题2 :C++中的空类默认产生哪些类成员函数?[中国某著名综合软件公司2005年面试题]

解析 :类的概念问题。

答案 :对于一个空类,编译器默认产生4个成员函数:默认构造函数、析构函数、复制构造函数和赋值函数。

10.2 类和结构

面试例题1 :structure是否可以拥有constructor/destructor及成员函数?如果可以,那么structure和class还有区别么?[中国台湾某著名计算机硬件公司2005年11月面试题]

答案 :区别是class中变量默认是private,struct中的变量默认是public。struct可以有构造函数、析构函数,之间也可以继承,等等。C++中的struct其实和class意义一样,唯一不同的就是struct里面默认的访问控制是public,class中默认的访问控制是private。C++中存在struct关键字的唯一意义就是为了让C程序员有个归属感,是为了让C++编译器兼容以前用C开发的项目。

扩展知识

我们可以写一段struct继承的例子,代码如下:

面试例题2 :现有以下代码,则编译时会产生错误的是()。[中国某著名计算机金融软件公司2005年面试题]

A.语句1

B.语句2

C.语句3

D.语句4

解析 :Test b()这个语法等同于声明了一个函数,函数名为b,返回值为Test,传入参数为空。但是实际上,代码作者是希望声明一个类型为Test,变量为b的变量,应该写成Test b;,但程序中这个错误在编译时是检测不出来的。出错的是语句4“b.fun();”,它是编译不过去的。

答案 :D

10.3 成员变量

面试例题1 :哪一种成员变量可以在同一个类的实例之间共享?[中国台湾某著名计算机硬件公司2005年11月面试题]

答案 :必须使用静态成员变量在一个类的所有实例间共享数据。如果想限制对静态成员变量的访问,则必须把它们声明为保护型或私有型。不允许用静态成员变量去存放某一个对象的数据。静态成员数据是在这个类的所有对象间共享的。

面试例题2 :指出下面程序的错误。如果把静态成员数据设为私有,该如何访问?[中国台湾某著名计算机硬件公司2005年11月面试题]

答案 :该程序错在设定了静态成员变量,却没有给静态成员变量赋初值。如果把静态成员数据设为私有,可以通过公有静态成员函数访问。

面试例题3: 请问下面程序打印出的结果是什么?[中国著名杀毒软件企业J公司2008年4月面试题]

解析 :本题想要得到的结果是“98,98”。但是成员变量的声明是先m_i,然后是m_j;初始化列表的初始化变量顺序是根据成员变量的声明顺序来执行的,因此m_i会被赋予一个随机值。更改一下成员变量的声明顺序可以得到预想的结果。如果要得到“98,98”的输出结果,程序需要修改如下:

答案 :输出结果第一个为随机数,第二个是98。

面试例题4 :这个类声明正确吗?为什么?

解析 :这道程序题存在着成员变量问题。常量必须在构造函数的初始化列表里面初始化或者将其设置成static。

答案

正确的程序如下:

或者:

10.4 构造函数和析构函数

面试例题1 :MFC类库中,CObject类的重要性不言自明。在CObject的定义中,我们看到一个有趣的现象,即CObject的析构函数是虚拟的。为什么MFC的编写者认为virtual destructors are necessary(虚拟的析构函数是必要的)?[美国某著名移动通信企业2004年面试题]

解析

我们可以先构造一个类如下:

上面代码在运行时,由于在生成CChild对象c时,实际上在调用CChild类的构造函数之前必须首先调用其基类CBase的构造函数,所以当撤销c时,也会在调用CChild类析构函数之后,调用CBase类的析构函数(析构函数调用顺序与构造函数相反)。也就是说,无论析构函数是不是虚函数,派生类对象被撤销时,肯定会依次上调其基类的析构函数。

那么为什么CObject类要搞一个虚的析构函数呢?

因为多态的存在。

仍以上面的代码为例,如果main()中有如下代码:

那么在pBase指针被撤销时,调用的是CBase的析构函数还是CChild的呢?显然是CBase的(静态联编)析构函数。但如果把CBase类的析构函数改成virtual型,当pBase指针被撤销时,就会先调用CChild类构造函数,再调用CBase类构造函数。

答案 :在这个例子里,所有对象都存在于栈框中,当离开其所处的作用域时,该对象会被自动撤销,似乎看不出什么大问题。但是试想,如果CChild类的构造函数在堆中分配了内存,而其析构函数又不是virtual型的,那么撤销pBase时,将不会调用CChild::~CChild(),从而不会释放CChild::CChild()占据的内存,造成内存泄漏。

将CObject的析构函数设为virtual型,则所有CObject类的派生类的析构函数都将自动变为virtual型,这保证了在任何情况下,不会出现由于析构函数未被调用而导致的内存泄漏。这才是MFC将CObject::~CObject()设为virtual型的真正原因。

面试例题2 :析构函数可以为virtual型,构造函数则不能。那么为什么构造函数不能为虚呢?[美国某著名移动通信企业2004年面试题]

答案 :虚函数采用一种虚调用的办法。虚调用是一种可以在只有部分信息的情况下工作的机制,特别允许我们调用一个只知道接口而不知道其准确对象类型的函数。但是如果要创建一个对象,你势必要知道对象的准确类型,因此构造函数不能为虚。

面试例题3 :如果虚函数是非常有效的,我们是否可以把每个函数都声名为虚函数?

答案 :不行,这是因为虚函数是有代价的:由于每个虚函数的对象都必须维护一个v表,因此在使用虚函数的时候都会产生一个系统开销。如果仅是一个很小的类,且不想派生其他类,那么根本没必要使用虚函数。

面试例题4 :析构函数可以是内联函数吗?[英国某著名计算机图形图像公司面试题]

解析 :我们可以先构造一个类,让它的析构函数是内联函数,如下所示:

该程序可以正确编译并得出结果。

答案 :析构函数可以是内联函数。

面试例题5 :请看下面一段程序:

问题:

(1)该程序输出的结果是什么?为什么会有这样的输出?

(2)B(int i):data(i),这种用法的专业术语叫什么?

(3)Play(5),形参类型是类,而5是个常量,这样写合法吗?为什么?

[英国著名图形图像公司A 2008年面试题]

解析 :B temp=Play(5),理论上该有两次复制(复制)构造函数,编译器把这两次合为一次,提高效率。所以把此句改为Play(5),会发现结果一样。都是2次析构,只不过区别在于:Play(5)的第一次析构是在函数退出时,对形参的副本进行析构。第二次析构是在函数返回类对象时,再次调用复制构造函数来创建返回类对象的副本。所以还需要一次析构函数来析构这个副本。而B temp=Play(5)中的第二次析构是析构B temp。在B temp=Play(5)加一句system('pause');可以验证第二次析构是在析构B temp,而不是析构函数返回值对象的副本,编译器把这两次合为一次,提高效率。

答案:

(1)应该有两个“destructed”输出:

(2)带参数的构造函数,冒号后面是成员变量初始化列表(member initialization list)。

(3)合法。单个参数的构造函数如果不添加explicit关键字,会定义一个隐含的类型转换(从参数的类型转换到自己);添加explicit关键字会消除这种隐含转换。

10.5 复制构造函数和赋值函数

面试例题1 :编写类String的构造函数、析构函数和赋值函数。[中国某著名综合软件公司2005年面试题]

答案:

已知类String的原型为:

编写String 的上述4个函数。

1.String的析构函数

为了防止内存泄漏,我们还需要定义一个析构函数。当一个String对象超出它的作用域时,这个析构函数将会释放它所占用的内存。代码如下:

2.String的构造函数

这个构造函数可以帮助我们根据一个字符串常量创建一个MyString对象。这个构造函数首先分配了足量的内存,然后把这个字符串常量复制到这块内存,代码如下:

strlen函数返回这个字符串常量的实际字符数(不包括NULL终止符),然后把这个字符串常量的所有字符赋值到我们在String对象创建过程中为m_data数据成员新分配的内存中。有了这个构造函数后,我们可以像下面这样根据一个字符串常量创建一个新的String对象:

3.String的复制构造函数

所有需要分配系统资源的用户定义类型都需要一个复制构造函数,这样我们可以使用这样的声明:

复制构造函数还可以帮助我们在函数调用中以传值方式传递一个Mystring参数,并且在当一个函数以值的形式返回Mystring对象时实现“返回时复制”。

4.String的赋值函数

赋值函数可以实现字符串的传值活动:

代码如下:

扩展知识

这里还有一个问题:String&String::operate=(const String&other)的const是做什么用的?

Const有两个作用。一个是如果不加入const的话,比如:

这样就会出现问题。因为一个const变量是不能随意转化成非const变量的。

其次是诸如:

不用const也会报错,因为用“+”赋值必须返回一个操作值已知的MyString对象,除非它是一个const对象。

面试例题2 :Which of the following is true about“Copy Constructor”?(下面关于复制构造函数的说法哪一个是正确的?)[中国某著名综合软件公司2005年面试题]

A.They copy constructor into each other.(给每一个对象复制一个构造函数。)

B.A default is provided,but simply does a member-wise copy.(有一个默认的复制构造函数。)

C.They can't copy arrays into each other.(不能复制队列。)

D.All of the above.(以上结果都正确。)

解析 :复制构造函数问题。

答案 :B

面试例题3 :Which of the following class DOES NOT need a copy constructor?(下面所列举的类哪个不需要复制构造函数?)[中国台湾某著名杀毒软件公司2004年面试题]

A.A matrix class in which the actual matrix is allocated dynamically within the constructor and is deleted within its destructor.(一个矩阵类:动态分配,对象的建立是利用构造函数,删除是利用析构函数。)

B.A payroll class in which each object is provided with a unique ID.(一个花名册类:每一个对象对照着唯一的ID。)

C.A word class containing a string object and vector object of line and column location pairs.(一个word类,对象是字符串类和模板类。)

D.A library class containing a list of book object.(一个图书馆类:由一系列书籍对象构成。)

解析:

按照题意,寻找一个不需要复制构造函数的类。

A选项要定义复制构造函数。

B选项中,不自定义复制构造函数的话,势必造成两个对象的ID不唯一。至于说自定义了复制构造函数之后,如何保证新对象的ID唯一,那是实现的问题。实现的方法多种多样,比如可以使用当前的系统tick数作为新ID。当然语义上有损失,不是完全意义上的复制,但在这儿只能在保持语义和实现目的之间来一个折中。

选C的原因是使用默认的复制构造,string子对象和vector子对象的类都是成熟的类,都有合适的赋值操作,复制构造函数以避免“浅复制”问题。

D选项显然是定义复制构造函数。

答案 :C

面试例题4 :Which virtual function re-declarations of the Derived class are correct?(哪个子类的虚函数重新声明是正确的?)[中国台湾某著名杀毒软件公司2004年面试题]

A.Base*Base::copy(Base*);

B.Base*Base::copy(Base*);

Base*Derived::copy(Derived*);

Derived*Derived::copy(Base*);

C.ostream&Base::print(int,ostream&=cout);

D.void Base::eval()const;

ostream&Derived::print(int,ostream&);

void Derived::eval();

解析 :本题问的是哪个派生类的虚函数再声明是对的。

A选项错误,因为虚函数的声明必须与基类中定义方式完全匹配。而子类的虚函数的形参为Derived*,与父类的虚函数形参不同。因此,子类不是虚函数的声明。但是书上解释A是函数重载,这个说法是错的。

A选项子类只是重新定义了一个具有不同形参的同名函数而已,并且这个同名函数会屏蔽父类的同名函数。因为派生类的作用域嵌套在基类的作用域中。

B选项正确,C++Primer第四版P477页所述:“派生类中虚函数的声明必须与基类中定义方式完全匹配,但是有一个例外,返回对基类型的引用或指针的虚函数。派生类中的虚函数可以返回基类函数所返回类型的派生类的引用或指针”。因此B选项就是P477页所述的例外,基类虚函数的返回类型是Base*,而子类虚函数的返回类型是Derived*,且Derived是Base的派生类。所以,B的虚函数声明是正确的。

C选项正确,虽然基类的虚函数声明中多了一个默认实参,但是依然和子类的虚函数属于同一个函数声明。

D选项错误,因为D的子类的虚函数不是一个const函数,和基类的虚函数声明不一致。D 选项也不是函数重载,只是子类重新定义了一个非const同名函数而已。

答案 :B和C

面试例题5 :以下程序存在问题么?该如何修改?[中国著名杀毒软件企业J公司2008年4月面试题]

解析 :本题有如下几个错误。

(1)析构函数中应处理字符指针的释放。

(2)应该编写复制构造函数与赋值函数,这是因为类中已经包含了需要深复制的字符指针。

(3)这个构造函数:NamedStr(const char*pName,const char*pData)中,存在为字符指针与内存大小不匹配的错误,应在原来的基础上增加一个字节,用来保存结束符。如m_pName=new char[strlen(pName)+1];,并在复制结束后手工增加结束符。另外最好使用较安全的strncpy代替strcpy。

(4)默认构造函数NamedStr()中对未分配内存空间的字符指针赋值,会引起异常。

(5)缺少头文件tchar.h。

答案

修改后的程序代码如下:

10.6 多态的概念

面试例题1: 什么是多态?

答案:

开门,开窗户,开电视。在这里的“开”就是多态!

多态性可以简单地概括为“一个接口,多种方法”,在程序运行的过程中才决定调用的函数。多态性是面向对象编程领域的核心概念。

多态(Polymorphisn),按字面的意思就是“多种形状”。多态性是允许你将父对象设置成为和它的一个或更多的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单地说就是一句话,允许将子类类型的指针赋值给父类类型的指针。多态性在Object Pascal和C++中都是通过虚函数(Virtual Function)实现的。

扩展知识(多态的作用)

虚函数就是允许被其子类重新定义的成员函数。而子类重新定义父类虚函数的做法,称为“覆盖”(override),或者称为“重写”。这里有一个初学者经常混淆的概念。上面说了覆盖(override)和重载(overload)。覆盖是指子类重新定义父类的虚函数的做法。而重载,是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。其实,重载的概念并不属于“面向对象编程”。重载的实现是编译器根据函数不同的参数表,对同名函数的名称做修饰,然后这些同名函数就成了不同的函数(至少对于编译器来说是这样的)。如,有两个同名函数function func(p:integer):integer;和function func(p:string):integer;。那么编译器做过修饰后的函数名称可能是int_func,str_func。对于这两个函数的调用,在编译器间就已经确定了,是静态的(记住:是静态)。也就是说,它们的地址在编译期就绑定了(早绑定),因此,重载和多态无关。真正与多态相关的是“覆盖”。当子类重新定义了父类的虚函数后,父类指针根据赋给它的不同的子类指针,动态(记住:是动态)地调用属于子类的该函数,这样的函数调用在编译期间是无法确定的(调用的子类的虚函数的地址无法给出)。因此,这样的函数地址是在运行期绑定的(晚绑定)。结论就是重载只是一种语言特性,与多态无关,与面向对象也无关。

引用一句Bruce Eckel的话:“不要犯傻,如果它不是晚绑定,它就不是多态。”

那么,多态的作用是什么呢?我们知道,封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类);它们的目的都是为了代码重用。而多态则是为了实现另一个目的—接口重用!而且现实往往是,要有效重用代码很难,而真正最具有价值的重用是接口重用,因为“接口是公司最有价值的资源。设计接口比用一堆类来实现这个接口更费时间,而且接口需要耗费更昂贵的人力和时间”。其实,继承为重用代码而存在的理由已经越来越薄弱,因为“组合”可以很好地取代继承的扩展现有代码的功能,而且“组合”的表现更好(至少可以防止“类爆炸”)。因此笔者个人认为,继承的存在很大程度上是作为“多态”的基础而非扩展现有代码的方式。

那么什么是接口重用?我们举一个简单的例子。假设我们有一个描述飞机的基类如下(Object Pascal语言描述):

然后,我们从plane派生出两个子类,直升机(copter)和喷气式飞机(jet):

现在,我们要完成一个飞机控制系统。有一个全局的函数plane_fly,它负责让传递给它的飞机起飞,那么,只需要这样:

就可以让所有传给它的飞机(plane的子类对象)正常起飞。不管是直升机还是喷气机,甚至是现在还不存在的、以后会增加的飞碟。因为,每个子类都已经定义了自己的起飞方式。

可以看到plane_fly函数接受的参数是plane类对象引用,而实际传递给它的都是plane的子类对象。现在回想一下开头所描述的“多态”:多态性是允许你将父对象设置成为和一个或更多的它的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。很显然,parent=child;就是多态的实质。因为直升机“是一种”飞机,喷气机也“是一种”飞机,因此,所有对飞机的操作都可以对它们操作。此时,飞机类就是一种接口。多态的本质就是将子类类型的指针赋值给父类类型的指针(在OP中是引用),只要这样的赋值发生了,多态也就产生了,因为实行了“向上映射”。

应用多态的例子非常普遍。在Delphi的VCL类库中,最典型的就是TObject类有一个虚拟的Destroy虚构函数和一个非虚拟的Free函数。Free函数中是调用Destroy的。因此,当我们对任何对象(都是TObject的子类对象)调用.Free();之后,都会执行TObject.Free();,它会调用我们所使用的对象的析构函数 Destroy();。这就保证了任何类型的对象都可以正确地被析构。

多态性是面向对象最重要的特性。

面试例题2 :重载和覆盖有什么不同?

答案 :虚函数总是在派生类中被改写,这种改写被称为“override”(覆盖)。

override是指派生类重写基类的虚函数,就像我们前面在B类中重写了A类中的foo()函数。重写的函数必须有一致的参数表和返回值(C++标准允许返回值不同的情况,但是很少有编译器支持这个特性)。Override这个单词好像一直没有什么合适的中文词汇来对应。有人译为“覆盖”,还贴切一些。

overload约定成俗地被翻译为“重载”,是指编写一个与已有函数同名但是参数表不同的函数。例如一个函数既可以接收整型数作为参数,也可以接收浮点数作为参数。重载不是一种面向对象的编程,而只是一种语法规则,重载与多态没有什么直接关系。

面试例题3 :which of the following one is NOT resolved at compile time?(下面哪个不能在编译时间被解析)

A.Macros

B.Inline functions

C.Template in C++

D.virtual function calls in C++

解析 :宏,内联函数,模板都可以在编译时候解析,唯独虚函数不行,它必须在运行时才能确定。

答案 :D

10.7 友元

面试例题1 :写一个程序,设计一个点类Point,求两个点之间的距离。[中国软件企业LC公司2007年12月面试题]

解析 :本题可以使用友元。

类具有封装和信息隐藏的特性。只有类的成员函数才能访问类的私有成员,程序中的其他函数是无法访问私有成员的。非成员函数可以访问类中的公有成员,但是如果将数据成员都定义为公有的,这又破坏了隐藏的特性。另外,应该看到在某些情况下,特别是在对某些成员函数多次调用时,由于参数传递、类型检查和安全性检查等都需要时间开销,而影响程序的运行效率。

为了解决上述问题,提出一种使用友元的方案。友元是一种定义在类外部的普通函数,但它需要在类体内进行说明,为了与该类的成员函数加以区别,在说明时前面加以关键字friend。友元不是成员函数,但是它可以访问类中的私有成员。友元的作用在于提高程序的运行效率,但是,它破坏了类的封装性和隐藏性,使得非成员函数可以访问类的私有成员。

友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类。

答案

代码如下:

面试例题2 :请描述模板类的友元重载,用C++代码实现。[中国软件企业LC公司2007年12月面试题]

答案

代码如下:

10.8 异常

面试例题1 :In C++,you should NOT throw exceptions from:(在C++中,你不应该从以下哪个抛出异常)。[美国著名软件企业M公司2013年面试题]

A.Constructor(构造函数)

B.Destructor(析构函数)

C.Virtual function(虚方法)

D.None of the above(以上答案都不对)

解析 :构造函数中抛出异常是有一定必要的,试想如下情况:

构造函数中有两次new操作,第一次成功了,返回了有效的内存,而第二次失败,此时因为对象构造尚未完成,析构函数是不会调用的,也就是delete语句没有被执行,第一次new出的内存就悬在那儿了(发生内存泄露),所以异常处理程序可以将其暴露出来。

构造函数中遇到异常是不会调用析构函数的,一个对象的父对象的构造函数执行完毕,不能称之为构造完成,对象构造是不可分割的,要么完全成功,要么完全失败,C++保证这一点。对于成员变量,C++遵循这样的规则,即会从异常的发生点按照成员变量的初始化的逆序释放成员。举例来说,有如下初始化列表:

假定m3的初始化过程中抛出异常,则会按照m2,m1的顺序调用这两个成员的析构函数。在{}之间发生的未捕捉异常,最终会导致在栈的开解时析构所有的数据成员。

处理这样的问题,使用智能指针是最好的,这是因为auto_ptr成员是一个对象而不是指针。换句话说,只要不使用原始的指针,那么就不必担心构造函数抛出异常而导致资源泄漏。所以在C++中,资源泄漏的问题一般都用RAII(资源获取就是初始化)的办法:把需要打开/关闭的资源用简单的对象封装起来(这种封装可以同时有多种用处,比如隐藏底层API细节,以利于移植)。这可以省去很多的麻烦。

如果不用RAII,即使当前构造函数里获取的东西在析构函数里都释放了,如果某天对类有改动,要新增加一种资源,构造函数里一般能适当地获取,但记不记得要在析构函数里相应地释放呢?失误的比率很大。如果考虑到构造函数里抛出异常,就更复杂了。随着项目的不断扩大和时间的推移,这些细节不可能都记得住,而且,有可能会由别人来实施这样的改动。

从运行结果可以得出如下结论:

(1)C++中通知对象构造失败的唯一方法,就是在构造函数中抛出异常;

(2)对象的部分构造是很常见的,异常的发生点也完全是随机的,程序员要谨慎处理这种情况;

(3)当对象发生部分构造时,已经构造完毕的子对象将会逆序地被析构(即异常发生点前面的对象);而还没有开始构建的子对象将不会被构造了(即异常发生点后面的对象),当然它也就没有析构过程了;还有正在构建的子对象和对象自己本身将停止继续构建(即出现异常的对象),并且它的析构是不会被执行的。

下面再说一下析构函数抛异常的情况。

Effective C++建议,析构函数尽可能地不要抛出异常。设想如果对象出了异常,现在异常处理模块为了维护系统对象数据的一致性,避免资源泄漏,有责任释放这个对象的资源,调用对象的析构函数,可现在假如析构过程又再出现异常,那么请问由谁来保证这个对象的资源释放呢?而且这新出现的异常又由谁来处理呢?不要忘记前面的一个异常目前都还没有处理结束,因此这就陷入了一个矛盾之中,或者说处于无限的递归嵌套之中。所以C++标准就做出了这种假设。看一下如下析构函数抛出异常的例子:

运行没有问题。下面打开注释1(//base.fun();),再试运行,结果程序会崩溃。

为什么呢?

因为SEH 是一个链表,链表头地址存在FS:[0]的寄存器里面。函数base.fun先抛出异常,从FS:[0]开始向上遍历 SHL 节点,匹配到catch 块。找到代码里面的一个catch块,再去展开栈,调用base 的析构函数,然而析构又抛出异常。如果系统再去从SEL链表匹配,会改变FS:[0]值,这时候程序迷失了,不知道下面该怎么办?因为它已经丢掉了上一次异常链的那个节点。

如果把异常完全封装在析构函数内部,不让异常抛出函数之外。程序还会正常运行吗?

的确可以运行。因为析构抛出来的异常,在到达上一层析构节点之前已经被别的catch块给处理掉了。那么当回到上一层异常函数时,其SEH没有变,程序可以继续执行。所以“析构函数尽可能地不要抛出异常”。如果非抛不可,语言也提供了方法,就是自己的异常,自己给吃掉。但是这种方法不提倡,有错最好早点报出来。

答案 :B 7mBxZjomGxm95UkyKgGRsupX8Qexk3M4D0fWOmduzNicQ+pOh1Usp8ZbdZ3LfWrU

点击中间区域
呼出菜单
上一章
目录
下一章
×