文章目录
- 多态概念及其触发条件
- 重写和协变
- (考点1)
- (考点2)
- 虚函数表及其位置
- (考点3)
- 多继承中的虚函数表
多态概念及其触发条件
多态的概念:通俗来说,就是多种形态。具体点就是去完成某个行为,当不同的对象去完成时,会产生出不同的状态
多态的构成条件:
1.必须通过基类的指针或者引用调用虚函数(即被virtual修饰的类成员函数称为虚函数)
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
重写和协变
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数类型),称子类的虚函数重写了基类的虚函数
虚函数重写的两个例外:
1. 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变
2. 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
override和final两个关键字
(考点1)
这里强调一下,重写重写的是实现。看以下这个场景:(考点)
class A
{
public:
A()
{}
virtual void func(int val = 1)
{
std::cout << "A->" << val << std::endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
public:
void func(int val = 0)
{
std::cout << "B->" << val << std::endl;
}
};
int main()
{
A* p = new B();
p->test();
return 0;
}
打印结果为B->1,说明调的是子类的func函数,但是缺省值用的却是父类,返回值,函数名,参数类型相同即构成重写,重写重写的是实现,壳子用的是父类的,写的内容自己控制
(考点2)
那为什么要把析构函数构成重写呢?看以下这个场景:(考点)
class Person {
public:
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
~Student() {
cout << "~Student()" << endl;
delete[] ptr;
}
protected:
int* ptr = new int[10];
};
int main()
{
Person* p = new Person;
delete p;
p = new Student;
delete p;
return 0;
}
当我们用父类指针,指向子类对象时,期望析构的是子类对象,而不是父类对象。不构成重写的话,无论父类指针是指向子类对象还是父类对象,析构的都是父类对象,导致下面的 ptr 动态开辟的空间没有释放而内存泄漏
当我们给父类析构函数加上 virtual,让其构成重写后。同时注意这里析构玩~Student后还会析构继承父类,照应上面的构造先父后子,"析构先子后父"
抽象类:
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
动态绑定与静态绑定:
虚函数表及其位置
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,通过下面这个例子来看对象模型
(考点3)
这时候我们再反过来思考,为什么一定是父类的指针或者引用,而不能是父类对象?
首先我们可以看出,子类对象会先拷贝父类虚函数表,然后再对需要重写的虚函数进行地址修改。
假如我们把子类对象赋值给父类对象,那么子类对象的虚函数表要不要拷贝给父类?如果虚函数表不拷贝,那么还是调用父类的函数,没有构成多态。
如果拷贝了,那么父类对象的虚函数表存的是子类对象修改后的虚函数,如下图:此时我们无法再调用父类本身被重写的函数,因为无论我们传子类还是父类对象,调用的都是子类对象的函数,不能构成多态。
因此多态的条件,一定是父类的指针或者引用,这样可以避免像下面这样拷贝带来的错误。
虚表位置
class Person {
public:
virtual void BuyTicket() const { cout << "成人-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() const { cout << "学生-半价" << endl; }
};
int main()
{
Person ps;
Student st;
int a = 0;
printf("栈:%p\n\n", &a);
static int b = 0;
printf("静态区:%p\n\n", &b);
int* p = new int;
printf("堆:%p\n\n", p);
const char* str = "hello world";
printf("常量区:%p\n\n", str);
printf("虚表1:%p\n", *((int*)&ps));
printf("虚表2:%p\n", *((int*)&st));
return 0;
}
虚表存放在哪里呢?首先排除堆,虚表由编译器生成,不会自己去动态申请空间。其次排除栈,同类型对象公用一张虚表,栈都是伴随栈帧走的,不能函数调用结束,栈帧销毁,虚表就销毁了吧。我们用打印的方式来看一下虚表是存在哪里的
看下面的代码和输出结果,我们可以发现,虚表是存在常量区的
多继承中的虚函数表
// 打印函数指针数组
typedef void(*FUNC_PTR) ();
void PrintVFT(FUNC_PTR* table)
{
for (size_t i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p->", i, table[i]);
FUNC_PTR f = table[i];
f();//这个地址可以调用说明一定是函数
}
printf("\n");
}
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
Derive d;
int vft1 = *((int*)&d);
Base2* ptr = &d;
int vft2 = *((int*)ptr);
printf("第一张虚表:\n");
PrintVFT((FUNC_PTR*)vft1);
printf("第二张虚表:\n");
PrintVFT((FUNC_PTR*)vft2);
return 0;
}
先看上面这段代码,首先d对象有几张虚表呢?看下面的监视窗口,很明显发现d对象有两张虚表,但是d对象自己的虚函数func3去哪里了,其实它在第一张虚表中,我们可以通过上面的代码打印观察出来,f()这个地址可以调用,说明它一定是函数。这里是可以认为是编译器的监视窗口故意隐藏了func3函数,也可以认为是它的一个小bug
可是细心一点发现,两张表中的func1地址不一样,它们不是都重写了func1函数吗?而且用父类指针调用会发现,它们调的是同一个函数,那么这里为什么地址不一样呢?
看下面这个场景
注意:这里你要调用的是派生类d对象的func1函数,this指针应该指向d对象,而这里的ptr1指针恰好指向d对象,不需要改动。而ptr2指向的却是Base2对象。调用d对象的func1函数要传d对象的this指针, 而不是Base2对象的this指针。所以这里第二张表的地址其实是"虚地址",多封装了几层是为了修正this指针
接下来我们通过汇编来看看ptr1和ptr2调用的区别,更好理解Base2的"虚地址"
ptr1调用
ptr2调用