继承定义
继承是面向对象编程中的一个重要概念。它的由来可以追溯到软件开发中的模块化设计和代码复用的需求。
在软件开发过程中,我们经常会遇到需要为多个类添加相同的行为或属性的场景,这样就产生了代码重复的问题。为了解决这个问题,工程师们开始寻找一种方法来实现代码的复用。
继承就是一种解决代码复用问题的方式。它允许我们创建一个新的类,继承自一个已经存在的类,从而继承和复用父类的属性和方法。通过继承,我们可以在不改变父类的前提下,为子类添加额外的属性和方法,实现功能的扩展。
继承方式
class parent
{
public:int _age;
};class child : public parent
{
public:int _id;
};
以上就是一个简单的继承结构,
child
继承了parent
。
其中parent
这种被别人继承的类叫做:基类 / 父类child
这种继承别人的类叫做:派生类 / 子类
继承方式与基类的成员访问限定符共同决定了派生类对基类成员的访问权限
不可见不是指不继承,基类中的private成员继承后在派生类中不可见,就是派生类无法直接访问到这个成员,但是派生类依然是存储着这个成员的。
当然,与访问限定符一样,继承方式也是有默认值的:
用
class
定义的类,默认继承方式是private
用struct
定义的类,默认的继承方式是public
多继承:
一个派生类可以同时继承多个基类:
class parent1
{
public:int _age;
};class child : public parent1, public parent2
{
public:int _id;
};
继承基本特性
- 继承后,派生类有可能只增改了基类的成员函数,而成员变量是一样的,所以基类和派生类的大小可能是一样的
- 友元关系不能继承,基类的友元不能访问子类的私有和保护成员
- 对于基类的静态成员,派生类和基类共用,派生类不会额外创建静态成员
- 如果不希望一个类被继承,可以将这个类的构造函数或者析构函数用private修饰
- 继承后,派生类的初始化列表指向顺序为继承顺序
继承的作用域
基类与派生类有两个分别独立的作用域
隐藏:
当派生类继承了基类的成员后,如果派生类自己创建了与基类同名的成员,那么派生类成员将屏蔽对同名基类成员的直接访问,这种情况叫做隐藏。
函数重载要求两个函数在同一个作用域,而基类与派生类是两个不同作用域,所以就算参数不同也不能构成重载。所以只要基类与派生类内的函数名相同就构成隐藏,不考虑参数。
赋值兼容
赋值兼容是一个基类与派生类之间的转换规则,其可以让派生类转换为父类。
class person
{
public:string _name;string _sex;int _age;
};class student : public person
{
public:int _No;
};
规则:
- 派生类的对象可以赋值给基类的对象
student s;
person p = s;
2.派生类的指针可以转换为基类的指针
3.派生类的引用可以转换为基类的引用
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;
};
赋值兼容处说过:派生类的引用可以转化为基类的引用
所以此处在传参时会发生一次隐式的切片,基类的拷贝构造只访问派生类的基类部分,来拷贝出一个基类。拷贝构造也属于构造函数,所以拷贝构造在初始化列表中如果没有显式调用拷贝构造,就会隐式调用默认构造函数。
赋值重载
在派生类拷贝构造中,必须显式调用基类的赋值重载,因为赋值重载也把基类当作一个类成员做处理。赋值重载不会直接调用成员的赋值重载,而是需要我们显式调用。
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;
};
菱形继承是多继承带来的问题,当菱形继承发生时,最后一个派生类中,就会有两份间接基类的信息,这会造成数据冗余与二义性的问题。
数据冗余:有一些数据会存储两份在派生类中
二义性:对于从最顶部间接基类继承下来的变量,会有两份,当访问这个变量时,编译器无法确定你需要访问哪一个直接基类继承下来的变量。此时需要指定作用域才能解决
虚继承
为了解决多继承时的命名冲突和冗余数据问题,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。
当一个类继承到虚基类时,其会把虚基类放到类的末尾,将原本存储虚基类的区域换成一个虚基表指针,指向虚基表,虚基表内部存储着它们到虚基类成员的指针偏移量
数据冗余问题:
这样就可以将多个相同的成员合并,解决了数据冗余的问题
二义性问题:
由于BC内部存储A的地方已经被换成了虚基表指针,不论是直接访问,通过B或C的类域访问,访问到的都是同一个变量,解决了二义性的问题