C++读书笔记3:继承与多态

来源:互联网 发布:nginx配置地址转发 编辑:程序博客网 时间:2024/05/22 00:40

一、继承的基本概念

1、继承C++支持一个新类通过继承的方式重用另一个类的数据和方法。这里的新类称为派生类derived,原有的类称为基类base。具体形式如下:

class deivedclass: public baseclass{};

2、派生类的构造函数与析构函数的调用规则

派生类的构造函数除了要负责自身类成员的初始化外,还要显示调用直接基类的构造函数,向它们传递参数,以完成基类对象的建立和初始化。除非基类没有显示定义任何构造函数或者基类的构造函数的参数有缺省值,这样编译器才知道自动插入并调用基类的默认构造函数。否则,都需要派生类按照如下方式显示调用:

派生类构造函数名(总参数表列)基类构造函数名(参数表列)成员对象名(参数表列)

{

  // 在这里完成派生类新增数据成员的初始化

};

上述定义的具体执行过程如下

- 派生类的构造函数启动时,首先量好了派生类对象的大小尺寸,规定了对象的位置(给this赋值),然后执行基类的构造函数,产生的基类的无名对象就定位在this指针指向的地方。

- 调用成员对象构造函数,完成成员对象初始化。

- 再执行派生类构造函数本身,对派生类数据成员初始化。

因为构造函数不止一个(默认构造函数、一般构造函数),在缺少判断条件的情况下,编译器需要派生类显示调用合适的构造函数。但是,析构函数就只有一个,所以,编译器在处理派生类的析构函数时,又有另外的规则:

- 派生类的析构函数启动时,先执行派生类自身的析构函数,对派生类新增加的成员进行清理。

- 然后编译器自动插入并调用成员对象的析构函数,对成员对象进行清理。

- 最后编译器自动插入并调用基类的析构函数,对基类进行清理。

3、如果基类有普通构造函数(即带参数的构造函数),那么在创建派生类对象的时候,如何给基类的构造函数传递参数?

可在派生类构造函数的初始化列表中调用基类的构造函数并传入参数,否则编译器就只能调用基类的默认构造函数。如下:

deivedclass::deivedclass(参数1, 参数2):baseclass(参数){};

4、如果派生类和基类中定义了同名的成员变量则派生类对象的内存布局中,这两个同名变量同时存在。编译器在处理这两个同名变量的时候采用就近原则。如果子类中想使用父类的同名变量需要使用父类的名字作为作用域分辨符。

5、多态:一个基类的指针(或引用),在运行过程中,可能指向基类对象,也可能指向派生类对象,通过这个指针调用类成员的虚函数的时候,就可能调用基类的函数,也可能调用派生类的函数。

这种在运行时根据实际的对象类型确定调用函数的能力被称为多态或者是延后联编(late binding),传统的在编译时就确定调用函数的方式被称为早期联编(early binding)。C++通过定义虚函数(virtual关键字)实现多态。只有支持多态的技术才被认为是面向对象。派生类和基类的虚函数之间必须:函数名、参数个数、参数类型都相同才能实现多态(此时虚函数之间的关系也称为函数重写)。另外,有些函数是不能定义为虚函数的

- 静态成员函数是全局的,不属于任何类的对象,所以不能是虚函

- 内联函数在编译过程中执行替换动作,也不能是虚函数

- 构造函数是用于创建对象空间的(包括创建虚表),所以也不能是虚函数

6、如果基类和派生类定义了同名的虚函数,但函数的参数不同此时,派生类覆盖基类的同名虚函数。

class Base{public:    virtual void function1(int x);};class Derive: public Base{public:    virtual void function1 (double* x);};Derive* p = new Derive;p-> function1 (10); // 此处发生编译错误,因为int无法自动转换为指针类型,而编译器此时对基类的同名函数采用了覆盖原则

【《Effective C++ 第二版》条款50中通过上面的例子解释了具体的原因】:

这种情况下,可能90%的用户是希望调用Derive::function1,因为在一个继承链更长的实际工程中,用户可能都不知道Derive继承了Base,就更不可能知道存在Base:: function1(int)这个函数了,所以,编译器的做法是Derive::function1覆盖了Base:: function1,在这里编译器认为输入参数10的类型是int,与double*类型不匹配(需要显示的强制转换,而这里又没有),所以发生编译错误。

7、如果基类和派生类定义了同名的虚函数,但是参数不同或返回类型不同

编译器默认是早期联编(early binding)模式,只有在看到virtual定义的虚函数时才会考虑延后联编(late binding)。如果虚函数定义的不合适,例如基类的虚函数和派生类的虚函数函数名相同,但是参数不同返回类型不同,编译器会把他们当作不同的函数,这些函数被放在派生类对象虚表的不同位置,所以无法表现出多态。(如果基类虚函数返回基类指针,派生类虚函数返回派生类指针,这种情况编译器认为他们的返回值还算是相同的)。例如:

// 因为参数不同,编译器将基类和派生类的function函数当成2个不同的函数,两个函数指针被放在派生类虚表中不同的位置,// 例如:Base::function(char)被放在vtable[0]中,Derive::function(int) 被放在vtable[1]中。class Base{public:    virtual void function(char x);};class Derive: public Base{public:    virtual void function(int x);};char x = 'a';   Base* pBase = new Derive;pBase->function(x); // 编译器在编译这行代码的时候,主要工作就是要确定使用                    // 虚表vtable中哪个元素作为这里函数调用的指针,虽然                    // 实参x的类型与派生类的形参不匹配,但是在默认类型转换                    // 支持的情况下,编译器还是会选择vtable[1],即调用                    // 派生类的Derive::function(int)函数,如果参数无法通过                    // 默认类型转换实现匹配,编译器直接返回编译失败(可参考上面6中的例子)

8、如果基类和派生类定义了同名的非虚函数无论参数是否相同,这种函数之间都不存在多态。通过指针调用这些同名函数的时候,完全按照指针声明的类型(不是对象的实际类型)确定调用函数。所以,这种情况下,如果使用派生类的指针,编译器直接忽视基类的同名函数。

class Base{public:    void function(char i);    void function(int i);    void function(float i);};class Derive: public Base{public:    // 派生类重新定义了同名非虚函数,而且参数不一样    void  function(double* p); };Derive obj;// 从派生类的角度调用,下面三个函数调用的实参都无法匹配派生类函数的形参,同时也无法自动转换,最终都会编译失败obj.function(‘c’); obj.function(1);obj.function(1.0);

【《C++ Thinking》中将这种现象称为名字隐藏。在《Effective C++ 第二版》条款50的最后一个例子解释的内容虽然的是针对同名虚函数的,但是我觉得其中的道理同样适用于同名的非虚函数)所以,《Effective C++》第二版,条款37强调“不要重新定义继承而来的非虚拟函数”因为这会导致继承来的函数直接被编译器隐藏了。】

9、如果派生类从基类继承的虚函数包含缺省参数,而派生类的虚函数定义又修改了缺省参数的值,会导致函数执行结果的混乱。《Effective C++》第二版,条款38的建议是不要这样做。例如:

class A{    virtual void function(int para = 7);}class B : public A{    virtual void function(int para = 6);}A* pa = new B;pa-> function(); // 执行到这里,函数输入的默认参数是7,不是我们希望的6

出现上述问题的原因是,编译器在编译的时候就必须决定pa->function(int)的输入参数,此时,编译器唯一知道的是pa的类型是A*,所以,编译器能做的就是到class A中查找函数需要的默认参数。

10、如果某个类不包含虚函数,也不会作为一个基类来使用,就不要将析构函数定义为虚函数否则会增加一个虚函数表,使得对象的体积翻倍,还有可能降低其可移值性。一个类如果有虚函数,那它的所有成员函数(除构造函数外)都应该尽量定义为虚函数,这样的好处是支持多态,即面向对象。

11、如果派生类没有单独定义需要new操作的指针成员,就不需要显示定义赋值运算符函数和拷贝构造函数使用编译器默认生成的函数即可,此时编译器会会在派生类的赋值运算符函数和拷贝构造函数的最前面自动插入基类的赋值运算函数和拷贝构造函数。

12、如果派生类显示定义了赋值运算符重载函数和拷贝构造函数函数的代码中需要显示调用基类的运算符重载函数和拷贝构造函数(基类的拷贝构造函数应该放在初始化列表中调用),否则,对于派生类的拷贝构造函数编译器会自动插入基类的默认构造函数,而对于派生类的运算符重载函数,编译器不会自动插入基类的运算符重载函数,导致派生类对象的基类数据成员没有被正确赋值。《Effective C++》条款12

13、基类的析构函数绝大多数情况下都应该是虚函数

一般情况下基类和派生类都会申请不同的资源,面向对象编程,经常使用基类指针指向派生类对象。此时如果通过delete基类指针来释放派生类对象时,必须先执行派生类的析构函数,再执行基类的析构函数。但是这里的指针类型是基类,编译器编译时无法判断运行时指针实际承载对象,只能尝试调用基类的析构函数。如果基类的析构函数是虚函数,自然可以通过多态首先调用到派生类的析构函数(同时,在派生类析构函数的尾部,编译器已嵌入了上一级父类的析构函数,以此类推)。所以析构函数需要支持多态,才能实现正确的资源释放操作。【详见《Effective C++ 第三版》条款7】

14、构造函数和析构函数中不要调用虚函数

因为执行构造函数的时候,虚表可能未生成,实际是按普通函数调用的,没有多态性,应尽量避免。执行析构函数的时候,虚表可能已经释放了。【《Effective C++ 第三版》条款9】

15、纯虚函数:纯虚函数又可以有自己的函数定义(函数体),纯虚函数所在的类是一个抽象类,不能实例化。这个抽象类也被称为接口类。需要注意的是:纯虚类不能实例化,但是纯虚类可以声明一个指针或引用。纯虚函数的意义是:起到了声明函数接口、占用虚表位置和编译校验的作用。

《Effective C++ 第二版》条款36描述

- 基类定义非虚函数的目的是希望派生类继承基类的接口和实现,并且不要改写(override),即不希望此函数呈现出多态。

- 基类定义虚函数的目的是希望派生类继承基类的接口和缺省实现,并且可以改写,即希望此函数呈现出多态。

- 基类定义纯虚函数的目的是希望派生类只继承基类的接口。

- 令人意外的是,C++编译器允许纯虚函数被定义,此时调用此纯虚函数的唯一途径是通过基类的类名::纯虚函数名()的方式(也被称为静态调用)。

参考《深度探索C++对象模型》第5章193页,C++编译器允许用户定义(不仅是声明)纯虚函数的函数体,并允许通过静态方式调用被定义的纯虚函数。

另外这个章节的结论是:C++编译器硬性规定,在类的继承链中,全部类的析构函数都必须被调用(包括抽象类),所以全部类的析构函数都应该有函数体,且基类的析构函数应该是虚函数,但是建议不要定义为纯虚函数(虽然纯虚函数也可以有函数体,但是在大多数人的概念里,纯虚函数就是一个只有接口,没有定义的函数声明)。

二、多重继承:带来的语法复杂度成倍增加,完全掌握之前,应该避免使用。

表面上看多重继承需要解决大量的名字冲突,实际上还需要解决更多的逻辑问题,导致需要新的语法规则。这里从经典的菱形继承开始:

这里写图片描述

A是基类,B和C继承A,D同时继承B和C(即多继承),因为B和C分别包含A的成员,则D中同时包含两份A的成员,这样的结果显然不是我们想要的。因此,C++引入了新的语法虚基类。

class B: virtual public Aclass C: virtual public Aclass D: virtual public B, virtual public C

注意派生类D的构造函数,与以往的用法有所不同。以往,在派生类的构造函数中只需负责对其直接基类初始化,再由其直接基类负责对间接基类初始化。现在,由于虚基类A在派生类D中只有一份成员变量,所以对这份成员变量的初始化必须由派生类直接给出。如果不由最后的派生类直接对虚基类初始化,而由虚基类的直接派生类(如类B和类C)对虚基类初始化,就有可能由于在类B和类C的构造函数中对虚基类给出不同的初始化参数而产生矛盾。所以规定:在最后的派生类中不仅要负责对其直接基类进行初始化,还要负责对虚基类初始化。

有的读者会提出:类D的构造函数通过初始化表调了虚基类的构造函数A,而类B和类C的构造函数也通过初始化表调用了虚基类的构造函数A,这样虚基类的构造函数岂非被调用了3次?大家不必过虑,C++编译系统只执行最后的派生类对虚基类的构造函数的调用,而忽略虚基类的其他派生类(如类B和类C)对虚基类的构造函数的调用,这就保证了虚基类的数据成员不会被多次初始化。

上面讲了一大堆,需要记住的是:菱形继承导致的逻辑困难,引出了C++中虚基类的概念。由此带来的构造函数复杂度指数增加。其实大多数面向对象语言如Java都是不支持严格意思上的多重继承的。所以,C++程序员完全可以少用这个玩意。(个人感觉菱形继承在现实生活中,相当于一对夫妻B和C,他们有共同的父亲A,这绝对是近亲结婚啊,生下的傻儿子D注定是麻烦不断,所以菱形继承应该直接产生编译错误,不知道C++设计师是怎么考虑的。)

三、C++基础总结

 学习C++就是了解C++编译器在代码背后都做了些啥,并根据这些背后的机制形成我们代码设计的决策原则。《Effective C++ 第二版》描述了编译器在必要且合理的情况下可能为一个class自动添加的函数,如下所示:

class Empty{public:    Empty();            // 默认构造函数,创建对象数组必备    ~Empty();           // 析构函数    Empty(Empty& rhs);  // 拷贝构造函数    Empty& operator=(const Empty& rhs); // 赋值运算符重载函数    Empty* operator&(){return this;}    // 取地址运算符重载函数     const Empty* operator&() const {return this;} // 取地址运算符重载函数};

对于初学者应该先了解编译器默认产生的这些函数的运行规则和调用规则。然后,初学者还应该了解到,在某些情况下,编译器会拒绝为客户自动产生这些函数,例如,只要用户自定义了任何一种构造函数(包括拷贝构造函数),编译器就不在自动生成默认构造函数(这有可能对用户想在栈上创建对象数组带来麻烦)。这种情况下,用户必须自己定义默认构造函数。

又例如,一个类包含引用或const类型的成员,编译器会拒绝为这个类自动生成拷贝构造函数(和赋值运算符重载函数),如果需要,必须用户自己定义。

总结一句话: C++通过一些背后的规则,简化用户代码,实现强大功能。同时,为了支持继承、运算符重载、模板等强大的功能,这些背后的规则有互相耦合,必须用全局的眼光,把相关的特性统一分析才能理解这些背后规则的真实结果,才能真正用好C++这个工具。

0 0
原创粉丝点击