继承与多态
继承
继承是面向对象中使代码可以复用的重要手段,允许在保持原有类的特性的基础上进行扩展。
定义
定义格式
单继承
class parent
{
private:int _a;
};class child:public parent
{
public:int _b;
};
多继承
class parent1
{
private:int _a;
};class parent2
{
private:int _c;
};class child:public parent1,public parent2
{
public:int _b;
};
继承方式
public>protected>private>不可见,当基类成员为private的时候,在继承后不可见,当基类成员不是private的时候,比较成员继承方式和访问限定符,谁的权限小,最后的权限就是什么。
不可见并不意味着没有被继承,而是指派生类依然继承了private成员,但在派生类中不可用,在基类中可以使用,比如说有一个在基类中的private成员,可以调用基类的public函数来访问这个成员。
特性:
1.继承后,基类成员可能只改变了基类的成员函数,所以基类和派生类的大小可能相同。
2.友元关系不能继承,基类的友元不能访问子类的私有和保护成员。
3.对于基类的静态成员,派生类和基类公用,不会额外创建。
4.如果把基类的构造函数或者析构函数设置为私有,就可以阻止这个基类被继承,因为派生类在构造自己的对象的时候,必须调用基类的构造函数,而基类的构造函数在派生类中不可见,所以调用就会报错,析构函数同理。
5.继承后,初始化列表调用构造函数的顺序为继承的顺序。
赋值兼容
派生类的对象可以赋值给基类的对象/指针/引用,赋值后的基类对象会取用派生类对象的基类部分来创建对象,而赋值后的基类指针和引用,只能访问派生类中的基类部分。
而反过来,基类的对象不能赋值给派生类的对象,基类的指针和引用可以通过强制类型转换赋值给派生类的指针或者引用,但在进行这种转换之前,必须让父类的指针实际指向的是子类,否则就是不安全的。
class parent
{
private:int _a;
};class child:public parent
{
public:int _b;
};int main()
{parent* pod=new child;child* cod=(child*)pod;return 0;
}
创建与销毁
构造函数:在派生类的构造函数里,不能初始化基类的成员变量,派生类对象会先调用基类的构造函数,再调用自己的构造函数,一般要初始化基类的构造函数可以有两种做法,一是在初始化列表中显式调用,二是如果不显式调用的话,编译器会隐式调用基类的默认构造函数。
拷贝构造:派生类的拷贝构造函数不能拷贝基类的成员变量,所以想要拷贝从基类继承下来的成员,就需要调用基类的拷贝构造函数,一般和拷贝函数一样,分为在初始化列表中显式调用,和不显式调用的时候默认调用基类的默认拷贝构造函数。
class parent
{
public:parent(const parent& p):_a(p._a){}
private:int _a;
};class child:public parent
{
public:child(const child& c):parent(c),_b(c._b){}
public:int _b;
};
赋值重载:派生类拷贝构造中,需要显式调用基类的赋值重载函数。
class parent
{
public:parent& operator=(const parent& p){_a=p._a;}
private:int _a;
};class child:public parent
{
public:child& operator=(const child& c){parent::operator=(c);_b=c._b;return *this;}
public:int _b;
};
析构函数:派生类对象会先调用自己的析构函数,再调用基类的析构函数,因为如果先析构基类,那派生类后来还要访问基类成员,就可能访问到基类已经被释放的空间。编译器会把所有的析构函数的名字处理为destructor,无论我们是否在派生类的析构函数中显式调用了基类的析构函数,在派生类调用完析构函数后,会立刻调用一次基类的析构函数,所以如果在派生类中调用了析构函数,基类就会被析构两次。
作用域:基类和派生类的作用域是不一样的,如果基类中有成员和派生类中的名字是一样的,那么派生类就会屏蔽对基类的同名成员的访问,这种称为重定义,或者隐藏。隐藏后,需要指定函数的作用域为基类,才能访问基类的成员。要注意隐藏和重载的区别,重载是在同一个作用域,而隐藏是属于不同的作用域。
组合:相比于继承关系,组合关系的耦合度更低,所以能耦合就最好不用继承。
class parent
{
public:parent& operator=(const parent& p){_a=p._a;}
private:int _a;
};class child
{
public:child& operator=(const child& c){_b=c._b;return *this;}
public:int _b;parent p;//组合
};
虚继承
菱形继承
上图BC为直接基类,而A为间接基类,菱形继承是多继承带来的问题,当菱形继承发生的时候,最后一个派生类中,就会有两份间接基类的消息,就会造成数据的冗余(有一些数据会存储两份在派生类中)和二义性(对于从最顶部继承下来的变量,在派生类会有两份,编译器无法确认我们要访问的是哪一个直接基类继承下来的变量,所以这里需要指定作用域才能解决)的问题,于是我们提出虚继承来解决这个问题。
虚继承
虚继承是为了解决继承时命名冲突和数据冗余的问题的,虚继承可以使派生类中只保留一份间接基类的成员。
class A
{};class B:virtual public A
{};class C:virtual public A
{};class D:public B,public C
{};
A是虚基类,B和C承诺共享他们两个的虚基类,D继承到BC后,由于BC被virtual修饰,D中就只保留一份A的成员变量。
底层原理:
菱形继承的底层实际上是:
而当我们进行虚继承之后:
当一个类继承到虚基类的时候,会把虚基类放到类的末尾,将原本用来存储虚基类的位置换成虚基表的指针,指向虚基表,虚基表中存储着它到虚基类成员的偏移量,这样,无论BC是直接访问,还是通过类域访问,访问的都是同一个变量。
多态
虚函数
类中被virtual修饰的函数就是虚函数。
class A
{
public:virtual void func(){}
};
虚函数重写:当派生类中有一个和基类完全相同的虚函数(返回值,函数名,参数列表都完全相同)就会发生虚函数的重写。
class A
{
public:virtual void func(int a){}
};
class B:public A
{
public:virtual void func(int b)//这里的func就构成重写{}
};
比较例外的是,当基类有一个函数和派生类完全相同,并且这个函数是基类的虚函数,那么派生类的这个函数可以不写virtual,依旧也是虚函数
class A
{
public:virtual void func(int a){}
};
class B:public A
{
public:void func(int b)//虚函数{}
};
协变:派生类和基类的虚函数之间,返回值可以不同,但返回值必须是父子关系的指针或者引用,这样依旧构成重写。
class A
{
public:virtual A* func(int a){return new A();}
};
class B:public A
{
public:virtual B* func(int b){return new B();}
};
修饰符:被final修饰的基类的函数,不能再重写
当我们使用override修饰虚函数的时候,编译器在编译前会检测虚函数是否重写成功,如果重写失败,就会报错。
class A
{
public:virtual A* func(int a){return new A();}
};
class B:public A
{
public:virtual B* func(int b) override{return new B();}
};
构成
多态指的是在继承关系中不同位置的类对象,去调用同一个函数的时候,产生不同的行为,但这需要两个条件,一是必须通过基类的指针或者引用调用虚函数,第二个条件是基类必须存在相应的虚函数,而派生类必须对虚函数进行重写。
多态会根据指针或者引用指向的对象的类型来调用对应的函数,而不是根据引用和指针原本的类型来调用函数。
#include<iostream>
using namespace std;
class A
{
public:virtual void func(){cout<<"class parent"<<endl;}
};
class B:public A
{
public:virtual void func(){cout<<"class child"<<endl;}
};int main()
{A a;B b;A& aa=a;A& bb=b;aa.func();bb.func();return 0;
}
可以看到,下图的运行结果是多态的。
而对于一般的类,由于这里是A的类型,所以最后都会调用A类对象的func
#include<iostream>
using namespace std;
class A
{
public:void func(){cout<<"class parent"<<endl;}
};
class B:public A
{
public:void func(){cout<<"class child"<<endl;}
};int main()
{A a;B b;A& aa=a;A& bb=b;aa.func();bb.func();return 0;
}
接口继承:当两个函数满足虚函数重写,并且用多态调用,这个时候就会发生接口继承,因为接口继承会把virtual继承下去,所以派生类不需要写virtual,只有完全满足多态的才会发生接口继承,按照一般的方式调用func函数依旧是派生类自己的接口。
#include<iostream>
using namespace std;
class A
{
public:virtual void func(int b=5){cout<<"class parent"<<b<<endl;}
};
class B:public A
{
public:void func(int a=10){cout<<"class child"<<a<<endl;}
};
int main()
{A a;B b;A& aa=a;A& bb=b;aa.func();bb.func();return 0;
}
析构函数的重写:当delete调用析构函数的时候,如果指针类型不对,析构函数就会调用错误,此时我们就可以用virtual修饰析构函数,并且析构函数的函数名都会变成destructor,构成多态,此时调用析构函数,就取决于指针指向的类型,而不是指针的类型。
对比
抽象类:当在虚函数末尾加上“=0”,这个函数就变成纯虚函数,包含纯虚函数的类称为抽象类/接口类,抽象类不能实例化出对象,抽象类的派生类也不能实例化出对象,派生类必须重写虚函数,才能实例化出对象,这样就可以强制派生类进行重写。
多态原理
虚函数表:虚函数的重写,是基于虚函数表的,虚函数表是一个存储虚函数指针的数组,由于存储一个类中所有虚函数的指针,简称虚表,在类中,会额外加入一个指针,叫做虚函数表指针,其指向了虚表,在访问类的虚函数的时候,会通过虚表指针找到虚表,再在虚表中查找虚函数,进行调用。
重写:每个类都有自己的虚表,派生类和基类的虚表也是分别独立的,当派生类拷贝基类的时候,会拷贝基类的虚表,当派生类对基类的虚函数进行重写,那么就会用新的地址去覆盖原本的虚函数地址,
当派生类的对象交给基类的指针/引用维护的时候,不会发生拷贝,而是进行一次切片,此时的指针依旧指向原来的对象,访问虚函数的时候,通过派生类的虚表来访问。
派生类虚表的生成:先将基类的虚表拷贝一份到派生类的虚表中,当派生类重写了虚函数的时候,那么重写的虚函数会覆盖原本的虚函数,如果派生类还有自己独有的虚函数,那么就添加到虚表末尾,虚表的函数顺序,就是虚函数的声明顺序。
和指针和引用不一样的是,当我们将一个派生类对象切片为基类对象,此时不是直接进行拷贝,基类在拷贝派生类的对象的时候,不会拷贝派生类的虚表,而是使用基类自己的虚表,因此,如果我们将一个派生类对象切片成基类对象,访问到的依旧是基类的虚函数,无法构成多态。
特性:虚表在编译阶段生成,虚表存储在代码段/常量区中,只有虚函数才能进入虚表,普通函数是无法添加进虚表的,虚表指针在构造函数的初始化列表完成初始化。
动态绑定和静态绑定:在程序编译的时候就已经确定的程序的行为,称为静态绑定,当程序运行的时候才知道具体的行为,称为动态绑定。
多继承下的虚表
多继承
class A
{
public:virtual void func1(int a = 5){cout << "class parent" << a << endl;}virtual void func2(int a = 5){cout << "class parent" << a << endl;}
};class B
{
public:virtual void func1(int b = 5){cout << "class parent" << b << endl;}virtual void func2(int a = 5){cout << "class parent" << a << endl;}
};
class C : public A,public B
{
public:void func1(int c = 10){cout << "class child" << c << endl;}
};
C类会继承下来两张虚表,第一张虚表继承自A,第二张虚表继承自B,C类重写了func1函数,但这个函数在两张虚表里的地址是不一样的,因为第一张虚表是可以直接调用func1的,但是对于第二张虚表,它的指针并不在对象的头部,所以如果直接调用func1函数,就会导致指针指向错误,所以第二张虚表会先跳转到其他地址,修正自己的this指针,把this指针指向对象的最开头,再去调用func1函数。
菱形继承:当在菱形继承的情况下,如果A有一个虚函数,B和C都继承了这个虚函数,那么继承BC的D就必须对这个虚函数进行重写,否则无法确认最后要使用哪一个函数。
虚基表:类 B 和类 C 虚继承自 A,B 和 C 的对象中除了自身的成员变量和虚函数表指针(如果有虚函数)外,还会有一个指向虚基表的指针。虚基表中的内容就是 A 类子对象相对于 B 或 C 对象起始地址的偏移量。当 D 类对象要访问虚基类 A 的成员时,就可以通过 B 或 C 中的虚基表指针找到虚基表,进而根据其中记录的偏移量定位到 A 类子对象的位置。如果一个类同时有虚表指针和虚基表指针,虚基表指针放在前面,在虚基表中,第一个位置会空出来,用于存储虚基表指针和虚表指针的偏移量。