作者:爱写代码的刚子
时间:2023.8.16
前言:本篇博客主要介绍C++中多态有关的知识,是C++中的一大难点,刚子将带你深入C++多态的知识。(该博客涉及到的代码是在x86的环境下,如果是在x86_64环境下指针的大小可能需要变成8bytes)
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同 的状态。
虚函数:即被virtual修饰的类成员函数称为虚函数
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
被重写的函数必须是虚函数
重写的条件:虚函数 + 三同,但是有些例外
虚函数重写的两个例外:
【问题】:析构函数加virtual,是不是虚函数重写?
是,因为类析构函数都被处理成destructor这个统一的名字,因为要让他们构成重写。
【问题】:将析构函数变为虚函数和普通的析构函数有什么区别呢?
答:在特殊场景下会不同:
上面的场景很可能会造成内存泄漏,这里我们期待p->destructor()是一个多态调用,而不是普通调用。(多态调用看指向的内容,普通调用看对象的类型),上图中的p指针是Person类型且普通调用。要想实现多态调用需要加上virtual,构成重写
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序 写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
【附】:如果一个类不想被继承有几种方法?
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
通过观察和测试,我们发现了以下几点问题:
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的
class Base1 {
public:
virtual void func1() {cout << "Base1::func1" << endl;}
virtual void func2() {cout << "Base1::func2" << endl;}
private:
int b1=3;
};
class Base2 {
public:
virtual void func1() {cout << "Base2::func1" << endl;}
virtual void func2() {cout << "Base2::func2" << endl;}
private:
int b2=4;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() {cout << "Derive::func1" << endl;}
virtual void func3() {cout << "Derive::func3" << endl;}
private:
int d1=5;
};
int main() {
Derive d;
printf("%d",sizeof(d));
return 0;
}
多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
这里有一个需要注意的地方:虽然重写的都是func1(),但是他们地址不一样而调用的是同一个函数,为什么?
可以查看汇编代码发现:第一个Base1中的func1()的地址就是正常的地址。第二个Base2中调用func1()时在ecx寄存器中发生了this指针减8,指向了Derive对象的首地址,保证this指针的正确(因为是派生类调用,而不是基类调用)。所以本质就是修正this指针,不同编译器处理方法不同,也可以在存相同的地址,在调用前让ecx中的this指针减8。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果要深入研究的话可以参考以下文章:
C++虚函数表解析
C++对象的内存分布
class A
{
public:
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(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
答案是:B->1(本题可能误认为是B->0)
【问题1】:多态的条件中为什么不能子类指针或者引用,为什么不能是父类的对象?
答:1. 因为只有父类的指针才能既能指向父类又能指向子类 2. 对象的切片和指针或引用的切片是有一些不同的。子类赋值给父类对象切片,不会拷贝虚表,如果拷贝虚表,那么父类对象虚表中是父类虚函数还是子类就不确定了。所以对象的切片不拷贝虚表就已经不满足多态了。如果是指针或引用的切片,父类的切片对应父类的虚表,子类的切片对应子类的虚表。重写后子类会将从父类拷贝来的虚表进行修改(用重写的函数来覆盖)来实现多态。
【问题2】: inline函数可以是虚函数吗?
答:不能,因为inline函数没有地址,无法把地址放到虚函数表中。
【问题3】:静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
【问题4】:构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
【问题5】:析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。
【问题6】:对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
【问题7】:虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
【问题8】:什么是抽象类?抽象类的作用?
答:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
【附】:
- 不是虚函数不会进入虚表
- 通常虚表最后会加个0,但是不同的编译器处理不同,VS下会给,g++没给
- 派生类如果自己加虚函数,编译器会将该虚函数放在虚表后面,但监视窗口很可能不会显示。
- 同类型的对象共用虚表
- 虚表存在哪?打印地址进行比较可发现虚表存在代码段(常量区)
打印虚函数表:
typedef void(*FUNC_PTR)();
void PrintVFT(FUNC_PTR* table)
{
for(size_t i=0;table[i]!=nullptr;i++)//vs下,linux下要写死范围
{
printf("[%d]:%p\n",i,table[i]);
}
}
int main()
{
Person ps;
int vft = *((int*)&ps);
PrintVFT((FUNC_PTR*)vft);
return 0;
}
有虚函数表就可以拿到里面的函数,无论是不是私有还是公有,拿到地址就可以使用里面的函数