演示代码的运行环境:VS 2017
· 继承的基础知识
-
概念
继承机制是面向对象程序设计使代码可以复用的最重要的手。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。 -
定义
继承产生的新类称为派生类(子类),被继承的类称为父类(基类)。定义方式为class 类名:继承方式 基类类名
其中继承方式有public
、protected
、private
三种,继承方式的不同会影响从基类继承过来的成员在派生类中的访问权限:- 父类的private成员对子类不可见
- public继承不修改成员的访问权限
- protect继承会将父类的public成员变为子类的protect成员
- private继承会将所有继承过来的成员变为private访问
- protected成员在类外不可见
可以看出,protected访问是专为继承而设计的,而在实际应用中,我们常使用public继承,这样保证了父类的public方法子类也可以使用。
下面举一个例子简单感受一下继承
class Biology { public: bool IsAlive; protected: bool haveEat; private: std::string breed; }; class People : public Biology{ People() { IsAlive = true; haveEat = false; //breed为 Biology的 private成员不可访问 //breed = "people"; } protected: std::string name; };
能够被子类继承而不想被外界访问的使用protect,如例子中的 “吃了没” 成员,不能被子类继承的使用private,如例子中的 “物种类别” 成员。这体现了C++的封装特性。
· 基类和派生类对象之间的交互
- 切片
将派生类对象赋值给:基类的对象/基类的指针/基类的引用。前提:public继承 - 基类对象不能赋值给派生类对象
基类的指针可以通过强制类型转换赋值给派生类的指针,但是不安全。基类对象不能给子类对象赋值(强制转换也不行!)
· 作用域在继承中的运用
- 隐藏
子类成员将屏蔽父类对同名成员的直接访问。可以使用父类::父类成员
的方式显示访问
与重载的区别: 只要子类的成员名和父类同名就构成隐藏,就算满足函数重载的条件也会构成隐藏 - 赋值运算符重载
如果没有显示定义,编译器默认生成的会自动调用父类的赋值运算符成员函数;否则会构成同名隐藏,可以使用父类::opreator=()
显示的调用父类的赋值运算符成员函数 - 析构
析构函数在底层的函数名都是一样的,会构成同名隐藏,但是不能显示调用,因为编译器会在最后调用父类的析构函数
· 派生类的默认成员函数
- 子类对象初始化时先调用父类的构造函数,再调用子类的构造函数
- 子类对象释放时先调用子类的析构函数,再调用父类的析构函数
- 如果父类没有默认构造,子类构造函数需要在初始化列表中显示调用父类的构造函数
- 编译器默认生成的拷贝构造会自动调用父类的拷贝构造
· 复杂的菱形继承及菱形虚拟继承(应该是重点)
- 单继承:一个子类只有一个直接父类
- 多继承:一个子类有两个或以上直接父类
- 菱形继承:多继承的一种特殊情况
下面用一个简单的代码介绍菱形继承:
Person类继承了Male和Female,如果直接调用Person类的成员会有数据二义性的问题,因此要显示的调用Person父类的成员,这里使用Creature来访问成员会访问到Person第一个继承的父类成员。class Creature { public: bool IsAlive; char sex; }; class Male :public Creature { public: Male() { sex = 'h'; } }; class Female :public Creature { public: Female() { sex = 's'; } }; class Person :public Male, public Female { public: Person() { Male::IsAlive = false; Female::IsAlive = false; Creature::IsAlive = true; Creature::sex = '1'; } };
可以使用虚拟继承解决数据冗余和二义性。
在继承方式前加virtual表示虚拟继承该基类,对于继承过来的重复数据,子类中只会保存一份,并且会保存一个指针,这个指针称作虚基表指针,指针指向的表称为虚基表,表中存放的是成员变量的偏移量。
上面这段话看不懂没关系不用看了,我写一个虚继承来说明。
在Person的构造函数中打一个断点,调试程序,然后单步调试,我们会发现不管通过哪个父类修改IsAlive,修改的都是同一个成员变量,不信可以在自动窗口中查看。class Creature { public: bool IsAlive; char sex; }; class Male :virtual public Creature { public: Male() { sex = 'h'; } }; class Female :virtual public Creature { public: Female() { sex = 's'; } }; class Person :public Male, public Female { public: Person() { Male::IsAlive = false; Creature::IsAlive = true; Female::IsAlive = false; Creature::IsAlive = true; Creature::sex = '1'; sex = '?'; } char sex; };
从图中可以看出,只需修改一次IsAlive,三个IsAlive的值同时发生改变,看到这里,你是否有很多问号?
那编译器是如何实现的?刚才说了,对于虚继承的父类,子类当中只会保存一个虚基表指针,这里的地址是0x0095FE04,打开内存窗口查看一下:
图中标红的地方分别是父类Male和父类Female的虚基表指针,通过这个指针我们可以找到虚基表:
可以看到,虚基表指针指向的地址中保存的内容是0,占四个字节,将指针+1,可以得到一个四字节的数据,这个数据中保存的就是成员变量相对于当前虚基表指针的偏移量,可以再看一下地址0x0095FE04中保存的数据:
通过偏移量,编译器就可以找到虚继承的父类——Creature的成员变量的首地址,这样通过任意父类修改的都是同一个成员变量。
· 怎样理解继承(我认觉得是重点)
从编译器的角度来看,继承就相当于编译器帮你在子类中封装了一个匿名的父类成员变量,这样来理解继承的一些特性就很方便了。
- 继承方式是什么样的,子类对父类成员的访问方式就是什么样的。
- 同名隐藏。如果没有构成同名隐藏,编译器会通过父类访问继承过来的成员,否则编译器会做优化,也就是直接访问子类当中的成员
- 默认成员函数。初始化列表当中需要调用类成员的构造函数
- 友元关系不能继承。类中的成员的友元当然不能访问该类的成员。
- 无论派生出多少个子类,都只有一个static成员实例 。
- 切片操作。直接将子类当中的父类成员赋给父类的对象/指针/引用
和组合的区别
虽然说继承的底层实现和组合比较相似,但是在使用时还是略有不同的:
子类可以访问父类的protected成员,而且还有虚继承这种骚操作,而类类型的protected成员变量是不能访问的。也就是说,继承在一定程度上破坏了封装。
因此,若是对象之间的依赖关系不是很强,尽量使用组合;若对象之间的依赖关系强,需要实现多态的时候就可以考虑使用继承。