继承定义
继承是面向对象编程中的一个重要概念。它的由来可以追溯到软件开发中的模块化设计和代码复用的需求。
在软件开发过程中,我们经常会遇到需要为多个类添加相同的行为或属性的场景,这样就产生了代码重复的问题。为了解决这个问题,工程师们开始寻找一种方法来实现代码的复用。
继承就是一种解决代码复用问题的方式。它允许我们创建一个新的类,继承自一个已经存在的类,从而继承和复用父类的属性和方法。通过继承,我们可以在不改变父类的前提下,为子类添加额外的属性和方法,实现功能的扩展。
继承方式的由来可以追溯到早期的面向对象编程语言。早期的面向对象编程语言如Smalltalk、Simula等提供了基于类的继承机制。后来的编程语言如Java、C++等也引入了类似的继承机制。继承方式的由来和发展是为了提高软件开发的效率和可维护性,同时也体现了面向对象编程的思想和原则。
总而言之,在C++中,继承是代码复用的最重要手段。
继承方式
那么我们要如何实现继承呢?
我先为大家展示一个继承的样例,再讲解语法规则:
class parent { public: int _age; }; class child : public parent { public: int _id; };
以上就是一个简单的继承结构,child
继承了parent
。
其中parent
这种被别人继承的类叫做:基类 / 父类
child
这种继承别人的类叫做:派生类 / 子类
: public parent
这条语句,紧跟在child
的类名后面,说明child
继承了parent
。而public
是继承方式,这个继承方式有什么作用呢?
继承方式与基类的成员访问限定符共同决定了派生类对基类成员的访问权限
基类成员 \ 继承方式 | public继承 | protect继承 | private继承 |
public成员 | 派生类的public成员 | 派生类的protect成员 | 派生类的private成员 |
protect成员 | 派生类的protect成员 | 派生类的protect成员 | 派生类的private成员 |
private成员 | 派生类中不可见 |
以上表格展示了所有情况下的继承,基类的成员会根据自身的访问属性以及继承方式,共同决定最终继承到派生类的成员是什么属性。
其中,不可见不是指不继承,基类中的private成员继承后在派生类中不可见,就是派生类无法直接访问到这个成员,但是派生类依然是存储着这个成员的。
当然,与访问限定符一样,继承方式也是有默认值的:
用
class
定义的类,默认继承方式是private
用
struct
定义的类,默认的继承方式是public
多继承:
一个派生类可以同时继承多个基类:
class parent1 { public: int _age; }; class child : public parent1, public parent2 { public: int _id; };
继承基本特性
继承关系中,有以下注意点:
- 继承后,派生类有可能只增改了基类的成员函数,而成员变量是一样的,所以基类和派生类的大小可能是一样的
- 友元关系不能继承,基类的友元不能访问子类的私有和保护成员
- 对于基类的静态成员,派生类和基类共用,派生类不会额外创建静态成员
- 如果不希望一个类被继承,可以将这个类的构造函数或者析构函数用private修饰
- 继承后,派生类的初始化列表指向顺序为继承顺序
继承的作用域
基类与派生类有两个分别独立的作用域
隐藏:
当派生类继承了基类的成员后,如果派生类自己创建了与基类同名的成员,那么派生类成员将屏蔽对同名基类成员的直接访问,这种情况叫做隐藏。
示例:
class A { void func() {} public: int num; }; class B : public A { void func() {} public: int num; };
在以上继承关系中,B继承了A的num
变量与func
函数,而B类自己还创建了同名的func
与num
。那么此时在B内部直接访问num
与func
,就是访问B自己的num
。如果想要访问A的成员,需要限定作用域。
B b; b.func();//访问B的func函数 b.A::func();//访问A的func函数
此外,函数重载要求两个函数在同一个作用域,而基类与派生类是两个不同作用域,所以就算参数不同也不能构成重载。所以只要基类与派生类内的函数名相同就构成隐藏,不考虑参数。
赋值兼容
赋值兼容是一个基类与派生类之间的转换规则,其可以让派生类转换为父类。
以如下的继承关系做讲解:
class person { public: string _name; string _sex; int _age; }; class student : public person { public: int _No; };
规则:
- 派生类的对象可以赋值给基类的对象
student s; person p = s;
如下图:
我们可以将一个派生类的成员赋值给基类成员,此时会发生一个切片效果,基类只取出派生类中属于基类的部分来构造基类。
- 派生类的指针可以转换为基类的指针
- 派生类的引用可以转换为基类的引用
student s; person* pp = &s; person& rp = s;
如图:
基类是被包含在派生类中的,所以我们用基类的指针去访问派生类,相当于只访问了基类的部分。上图中就是只访问了红色的部分。
派生类的创建销毁
派生类是如何创建销毁的?因为派生类内部还包含了一个基类,那么基类这一部分要如何处理?
其实想要理解这一部分,就记住一句话:派生类的默认成员函数,把基类当作一个类成员变量处理。
接下来我为大家讲解构造函数,拷贝构造,赋值重载,析构函数这几个与创建销毁相关的函数,来理解派生类是如何创建销毁的。
构造函数
派生类构造函数将基类当作一个成员变量,不会直接初始化基类的成员,而是通过调用基类的构造函数。
在一般的类中,类内部如果有其他类的成员变量,构造函数会在初始化列表调用其构造函数。如果不直接调用,那么会隐式调用其相应的默认构造函数。
如下:
class person { public: string _name; }; class child : public parent { public: child(string name, int num) :parent(name) ,_num(num) {} private: int _num; };
:parent(name)
就是在初始化列表显式地调用构造函数。
派生类会先调用基类的构造函数,再调用自己的构造函数
拷贝构造
派生类拷贝构造将基类当作一个成员变量,不会直接拷贝基类的成员,而是通过调用基类的拷贝构造。
在一般的类中,类内部如果有其他类的成员变量,拷贝构造会在初始化列表调用其拷贝构造。如果不直接调用,那么会隐式调用其相应的默认构造函数。
如下:
class person { public: string _name; }; class child : public parent { public: child(const child& c) :parent(c) ,_num(c.num) {} private: int _num; };
:parent(c)
就是在显式调用基类的拷贝构造,不过我们在调用基类的拷贝构造时,传入的却是派生类的引用。这是为什么?
我们刚在赋值兼容处说过:派生类的引用可以转化为基类的引用
所以此处在传参时会发生一次隐式的切片,基类的拷贝构造只访问派生类的基类部分,来拷贝出一个基类。
要注意:拷贝构造也属于构造函数,所以拷贝构造在初始化列表中如果没有显式调用拷贝构造,就会隐式调用默认构造函数。
赋值重载
在派生类拷贝构造中,必须显式调用基类的赋值重载,因为赋值重载也把基类当作一个类成员做处理。赋值重载不会直接调用成员的赋值重载,而是需要我们显式调用。
如下:
class person { public: string _name; }; class child : public parent { public: child& operator=(const child& c) { parent::operator=(c); _num= c._num; } private: int _num; };
parent::operator=(c);
就是在显式地调用基类的拷贝构造,这里不能直接调用operator=(c);
,因为派生类中存在operator=;
这个函数,基类的函数被隐藏了,所以我们要指定作用域,来调用基类的赋值重载。
析构函数
同样的,在析构函数中,基类也会被当作一个类成员处理,会自动调用相应的析构函数。
要注意的是,如果我们在派生类的析构函数内部,显式调用基类的析构函数,那么这个析构函数会执行两次。
因为派生类和基类的析构顺序有要求:先调用派生类的析构函数,再调用基类的析构函数。所以不论是我们自己写的析构函数,还是默认生成的析构函数,在派生类析构结束时,会直接调用基类的析构函数。以保证在派生类析构后,基类进行析构。
所以最好不要显式调用基类的析构,让编译器自己调用就好。
总结:
从以上四个创建销毁的函数可以看出来,派生类创建时,就是把基类当作一个类成员处理的。
菱形继承
多继承可以让一个类同时继承到多个类的成员,但是其也会带来一个问题:菱形继承。
看到以下代码:
class A { public: int _a; }; class B : public A { public: int _b; }; class C : public A { public: int _c; }; class D : public B, public C { public: int _d; };
这既是一个典型的菱形继承,结构示意图如下:
对于D来说,A是简介基类,BC是直接基类。
菱形继承是多继承带来的问题,当菱形继承发生时,最后一个派生类中,就会有两份间接基类的信息,这会造成数据冗余与二义性的问题。
数据冗余:有一些数据会存储两份在派生类中
二义性:对于从最顶部间接基类继承下来的变量,会有两份,当访问这个变量时,编译器无法确定你需要访问哪一个直接基类继承下来的变量。此时需要指定作用域才能解决
比如这样:
D d1; d1._a = 1;
请问d1
的_a
变量,是从B继承下来的_a
还是从C继承下来的_a
呢?
这就是二义性问题,要解决这个问题,我们访问变量时,必须指定作用域。
D d1; d1.B::_a; d1.C::_a;
但是这样未免太麻烦了,于是C++推出了虚继承来解决这个问题。
虚继承
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员
在继承时,在继承方式前面加上virtual关键字,此时的继承就变成了虚继承
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类,这个被共享的基类就称为虚基类
示例:
class A { public: int _a; }; class B : virtual public A { public: int _b; }; class C : virtual public A { public: int _c; }; class D : public B, public C { public: int _d; };
结构示意图:
其中A就是一个虚基类。
此时BC继承A时,被virtual
修饰,相当于这两个类承诺,共享它们的虚基类。
D继承到BC后,由于BC被virtual
修饰,愿意共享虚基类,此时D中只保留一份A的成员变量。
那么虚继承是如何做到的呢?我们一起来看一看虚继承的底层原理。
虚继承底层原理
对于一般的菱形继承,我们的结构图如下:
可以看到,其保存了两份A在D中,这就是造成数据冗余与二义性的原因。
虚继承后,A的成员变量会合二为一,放在整个类的末尾:
这样就实现了类的合二为一,那么这个类要如何访问A呢?
这就要靠虚基表
了:在原先存放A的地方,会改为存放一个指针,这个指针叫做虚基表指针
。指针指向了虚基表,在虚基表内部,存放了这个指针到A的偏移量,然后指针根据偏移量来找到A。
我们再看看真实的内存视图:
可以看到,B的虚机表中,偏移量为十六进制的14,也就是20,说明B中的虚基表指针只要偏移20个字节,就可以到达A的位置;C的虚基表中,偏移量为十六进制的c,也就是12.说明C中的虚基表指针只要偏移12个字节,就可以到达A的位置。
当一个类继承到虚基类时,其会把虚基类放到类的末尾,将原本存储虚基类的区域换成一个虚基表指针,指向虚基表,虚基表内部存储着它们到虚基类成员的指针偏移量
数据冗余问题:
这样就可以将多个相同的成员合并,解决了数据冗余的问题
二义性问题:
由于BC内部存储A的地方已经被换成了虚基表指针,不论是直接访问,通过B或C的类域访问,访问到的都是同一个变量,解决了二义性的问题
其它特性:
- 虚继承后,对于BC类本身,它们创建出来的对象也通过虚基表这种方式来访问A了,这样可以保持访问的统一
- 对于多个同类型的变量,它们的结构是一致的,指针偏移量也是一致的,所以同一个类的所以对象共用一份虚基表
继承与组合对比
说完基础,我们再对比一下继承与组合:
组合关系:
class A {}; class B { A _aa; };
组合关系是一个类作为另外一个类的成员。
继承关系:
class A {}; class B : public A {};
继承关系和组合关系都可以实现代码的复用,继承关系可以直接将基类的成员继承到派生类中,让派生类直接访问。组合关系可以通过对成员变量的访问,来间接访问其他类的成员。
组合关系的耦合度比继承关系低,代码容易维护
所以能用组合关系就尽量使用组合关系。
那我们讲了半天,继承就是个没有用的东西吗?
并不是的,在后续多态的学习中,是必须基于继承关系的,继承关系的用处依然很大,很多地方组合关系无法替代继承关系。