下面我们引入一个抽象类的概念,所谓抽象,就是它并不实际存在。同样,抽象类也是实例不出对象的,那它有什么用呢?比如在实际生活中,我们说不同品牌的车给人的驾驶体验感使不同的,比如假设奔驰侧重于内饰,宝马侧重于运动性能,那我们就抽象出来一个车的概念,单说车是没有意义的,要加上品牌才可以。
那么什么样的类才算是抽象类呢?含有纯虚函数的类就是抽象类,下面举一个简单的例子
class car {
public:
virtual void thechara() = 0;
};
class Benz :public car {
public:
virtual void thechara() {
cout << "侧重于内饰" << endl;
}
};
class BMW :public car {
public:
virtual void thechara() {
cout << "侧重于运动性能" << endl;
}
};
car是抽象类,无法实例化出对象,这也是符合实际的。下面的类是继承的car的,如果子类不想是抽象类,那么就必须重写函数,这也是符合现实需求的。所以说抽象类的现实意义还是很大的
下面我们来看一下多态实现的原理,它是怎么实现同一个类的指针或引用调到不同的函数的呢?下面先看一个例子
这个类的大小为什么会是8呢?他不就一个int的一个成员吗?这就要看看对象中到底存着什么
我们看到不仅又一个int成员,还存着一个指针,32位下指针大小为4,确实总共为8。其实这个指针叫做虚函数表指针(virtual function pointer),也就是说它存的是虚函数的地址。也就是说,所有的虚函数的指针汇集到一起形成一个函数指针数组,而_vfptr中存的就是这个函数指针数组的指针。为了更加深入的理解,我们在多写几个函数,加上继承的关系来研究一下
class A {
public:
virtual void func1() {
cout << "A::func1()" << endl;
}
virtual void func2() {
cout << "A::func2()" << endl;
}
private:
int _a = 0;
};
class B:public A {
public:
virtual void func2() {
cout << "B::func2()" << endl;
}
virtual void func3() {
cout << "B::func3()" << endl;
}
private:
int _b=0;
};
class C :public B {
public:
virtual void func3() {
cout << "C::func3()" << endl;
}
private:
int _c = 0;
};
int main() {
A a;
B b;
C c;
}
先是有三个类的关系如上,我们分别创建三个类的对象,我们通过监视窗口来看一下它们中存着什么
我们可以看到,对于func1来说,B和C都是直接继承,并没有完成重写,所以A,B,C中func1的地址是相同的;对于func2来说,B重写了,C直接继承B的,所以B和C的func2地址是一样的;对于func3来说,它是B中新创建的一个新虚函数,它跟其他虚函数都是一样的,但是VS的监视窗口并没有显示地址,其实它就存在于虚函数表两个函数的下边,我们可以来验证一下,我们找一下b的虚函数表的地址,然后通过内存窗口看一下
可以看到,确实有三个函数的地址,那么我通过多态调用再看一下汇编代码验证一下
看jmp前边的地址,确实跟虚函数表中的地址确实是一样的
通过前边的验证,我们就大概知道了多态的原理,虽然是同一类型的指针,但是它指向了不同的虚函数表,于是就调到的不同的函数。其实这个虚函数表也是继承的父类的,所以我们不重写函数,函数的指针是相同的,我们重写之后就相当于把这个指针就覆盖了。所以虚函数的重写也叫做覆盖,覆盖是原理层上的概念,重写是语法层上的概念。
我们上面说的虚函数表是在编译阶段就生成了,运行的时候加载到内存中;至于说对象中存的虚函数表指针,它存放在成员变量的前面,所以是在初始化列表之前生成的。
接口继承和实现继承
我们先学的继承,那时候普通函数是会继承下来的,我们可以使用那个函数,所以继承的是它的实现,当然我们可以进行重定义(隐藏),重定义的也是它的实现;我们在上一篇说过,重写是声明并不重写,所以继承的是接口,目的是为了重写。所以如果不实现多态,不要把函数定义为虚函数。
动态绑定和静态绑定
静态绑定又称为前期绑定(早绑定),是在程序编译期间就确定了程序的行为,也称为静态多态。这也是一种多态,就是对于不同的对象有着不同的行为,并且是在编译期间确定的。比如函数重载:对于不同的对象调用不同的函数。
动态绑定又称为后期绑定(晚绑定),就是在程序运行期间,根据拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。我们这两篇博客讲的都是动态多态。
下面我们探索一下虚函数表存在哪个区域(栈区,堆区,静态区,常量区)。我们既然想看它存在哪个区域,就要看它的地址离那个区域最近
class A {
public:
virtual void func() {
cout << "class A" << endl;
}
};
class B:public A {
public:
virtual void func() {
cout << "class B" << endl;
}
};
int main() {
A a;
B b;
int i = 0;
int* j = new int;
static int k = 10;
const char* p = "xxxxxxxx";
printf("栈区:%p\n", &i);
printf("堆区:%p\n", j);
printf("静态区:%p\n", &k);
printf("常量区:%p\n", p);
printf("A:%p\n", *(int*)&a);
printf("B:%p\n", *(int*)&b);
return 0;
}
我们可以看到,它们两个的虚函数表的指针距离常量区是最近的,所以虚函数表就存在常量区
我们说过函数重写后虚函数表中的指针会被覆盖,所以同类型对象虚函数表指针相同
我们也可以试着来打印一下虚函数表,从前边我们可以看到,虚函数表是以空为结尾的,这可以作为我们的限制条件
这分别是内存中和我们打印出来的,可以看到它们是一样的
我们多继承的话,如果两个父类都继承下来了虚函数,那么子类的话是有两个虚函数表的
下面我们来看一下,各种特殊函数能不能构成多态
内联函数可以是虚函数,虽然说虚函数要存个地址到虚函数表,而内联函数是直接展开没有地址,看似是矛盾的,但是多态调用的时候编译器就忽略内联这个特性,而普通调用就有内联特性,这属于是多重属性
静态成员函数不能是虚函数,因为静态成员函数没有this指针,它是无法放进虚函数表的
构造函数不能是虚函数,因为虚函数表指针是在初始化列表之前完成的
析构函数可以是虚函数,并且最好把基类的析构函数定义为虚函数,因为之前我们讲到一个内存泄露的问题,这就是因为不满足多态调用,为了让它满足,我们就写成虚函数