文章目录
- 多态的概念
- 多态的定义和实现
- 多态的构成条件
- 虚函数重写的两个例外
- 协变(基类和派生类虚函数返回值类型不同)
- 析构函数的重写(基类和派生类析构函数名字不同)
- c++11 `override` 和 `final`关键字
- 重载,重写(覆盖), 隐藏(重定义)对比
- 抽象类(纯虚函数)
- 多态的原理
- 虚表
- 派生类虚表行为
- 多态实现细节
- 动态绑定与静态绑定
- 多继承的虚函数表
- 菱形继承,菱形虚继承
- 关于多态使用的小细节
多态的概念
多态,通俗来说,就是多种形态,就是当去完成某种行为时,不同的对象会发生不同的行为。
就像学生和普通成人去景区买票,同样是买票,学生和普通成人所要花费的资金是不一样的。
多态的定义和实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生的不同的行为。如下面的例子:student继承了person,student买票半价,person买票全价。
在继承中要构成多态需要三个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数在基类必须用
virtual
关键字声明,并且派生类必须对基类的虚函数进行重写(注意,这里的重写和继承中函数的隐藏(重定义)是两个概念)
被virtual定义的函数叫做虚函数
重写形成的条件相对重定义更加苛刻,需要派生类虚函数和基类虚函数的返回值类型,函数名字,参数列表完全相同。
**注意:**关于在符合重写条件的情况下,可以只在基类将函数用virtual关键字修饰,而派生类该函数不用加virtual,但不能只在派生类该函数加上virtual(一般情况下建议两边都加上virtual)
虚函数重写的两个例外
协变(基类和派生类虚函数返回值类型不同)
派生类重写虚函数时,有一种情况允许其于基类虚函数返回值不同,那就是协变。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。
注意,只用同时为指针或者同时为引用能完成协变,其他类型都不行,一个指针一个引用也不行,基类和派生类返回顺序相反也不能构成协变。(即基类返回派生类的指针或引用,派生类返回基类的指针或引用也是不行的)
class person
{
public:
virtual void buyTicket() { cout << "买票——全价" << endl; }
virtual person& f() { return *this; }
};
class student : public person
{
public:
virtual void buyTicket() { cout << "买票——半价" << endl; }
virtual student& f() { return *this; }
};
析构函数的重写(基类和派生类析构函数名字不同)
如果基类的析构函数为虚函数,此时其和派生类的析构函数一定构成重写,虽然派生类和基类的函数名一定不相同,看起来违背了重写的规则,但实则不然,在底层,编译器都会将析构函数的名称做统一的特殊处理,编译后析构函数的名称将会统一处理成destructor()
。
那么为什么要支持析构函数多态呢?我们看下面的场景:
void test()
{
person* p1 = new person;
person* p2 = new student;
delete p1;
delete p2;
}
正是由于这个场景,一定要支持虚函数多态,由于基类指针可以指向派生类指针,如果不支持析构函数多态,上面的这段代码将不能正常调用派生类析构函数清理多余资源,将会导致内存泄漏问题,因此只有通过多态才能正常释放资源。
class person
{
public:
virtual void buyTicket() { cout << "买票——全价" << endl; }
//virtual person& f() { return *this; }
virtual ~person() { cout << "析人\n"; }
};
class student : public person
{
public:
virtual void buyTicket() { cout << "买票——半价" << endl; }
//virtual student& f() { return *this;}
virtual ~student() { cout << "析学\n"; }
};
void test()
{
person* p = new person;
person* s = new student;
//将会调用基类析构
delete p;
//调用派生类析构释放派生类资源
//然后调用基类析构释放基类资源
delete s;
}
int main()
{
test();
return 0;
}
c++11 override
和 final
关键字
从上面我们知道,虚函数对重写的要求很严格,需要三同(函数名相同,参数列表相同,返回值相同)以及基类指针或引用调用,但是在有些情况下容易疏忽,容易出现错误,因此c++11提供了这两个关键字帮助用户检查是否重写。
**final:**修饰虚函数,表示该虚函数不能再被重写(该关键字放在函数名括号之后)
**override:**检查派生类虚函数是否重写了某个虚函数,如果没有重写编译报错。
重载,重写(覆盖), 隐藏(重定义)对比
抽象类(纯虚函数)
在虚函数后面加上=0,则这个函数就叫做纯虚函数。包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例化出对象。派生类继承之后也不能实例化出对象,只有重写了纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承。
override的作用是检查重写,而纯虚函数的作用是强制重写。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
在探究 多态原理之前,我们先来看一道常考的面试题:
//请问sizeof(base)是多少
//(32位平台下)
class base
{
public:
virtual void func()
{
cout << "func()" << endl;
}
private:
int _b = 1;
}
虚表
通过测试我们可以发现base对象是8bytes(32位平台下),除了b成员,还有一个 _vfptr放在对象的最前面(与平台有关), 对象中的这一指针叫做虚函数表指针(v——virtual,f——function),一个含有虚函数的类中至少都有一个虚函数指针,因为虚函数的地址要放到虚函数表中,虚函数表也简称为虚表。
注意,这里的虚表要和虚继承中解决菱形继承问题的虚基表区分开,两者是截然不同的概念,如果有不清楚虚基表和虚继承是什么的,可以看看博主的另一篇博客,链接如下:
c++_深究继承
里面关于菱形继承的部分就有为大家讲解虚继承是什么。
派生类虚表行为
那么,了解了这个之后,我们继续看看派生类在这个表中做了什么,又是如何实现多态的。
针对上面的代码,我们进行如下的改造:
class base
{
public:
virtual void func1()
{
cout << "func1()" << endl;
}
virtual void func2()
{
cout << "func2()" << endl;
}
void func3()
{
cout << "func3()" << endl;
}
private:
int _b = 1;
};
class derive : public base
{
public:
virtual void func1()
{
cout << "next::func1()" << endl;
}
private:
int _c = 2;
};
int main()
{
base b;
derive n;
return 0;
}
通过观察和测试,我们发现了几点问题:
- 派生类对象n中也有一个虚表指针,n对象由两部分构成,一部分是父类继承下来的成员以及虚表指针,另一部分是自己的成员
- 基类b对象和派生类对象虚表是不一样的,我们发现func1完成了重写,所以n的虚表里面存储的是derive::func1,而func2在派生类中并没有重写,所以派生类虚表中仍然是base::func2(),因此重写也可以叫做覆盖,覆盖就是指虚表中虚函数的覆盖,重写是语法层的叫法,覆盖是原理层的叫法。
- 虚表中存放的只有虚函数,也就是被声明为
virtual
的函数,因此在该例子中func3并没有在虚表内。 - 虚函数表本质上是一个存虚函数指针的指针数组,有些编译器的虚表数组最后面放了一个
nullptr
。 - 接下来总结一下派生类虚表是生成过程:a. 先将基类的虚表内容拷贝一份到派生类虚表 b. 如果派生类重写了基类的某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c. 派生类自己新增加的虚函数按其在派生类中的声明顺序依次加到派生类虚表的最后面。
对于最后一步,vs的监视窗口可能有一点小bug无法直接看到,需要用一些小技巧才能看到。
- 接下来还有一个很多同学都容易混淆的问题:虚表存在哪里呢? 网上有很多种说法,很大一部分说法说虚表存在数据段中,但这种说法真的对吗???我们通过比较实验的方法来观察一下。
首先通过刚才的测试我们知道,在derive类中虚表指针是放在对象开始的,所以我们先将derive对象强转成int*然后对齐解引用就拿到了虚表的地址,通过对四个区域的数据进行比对,我们可以发现虚表的位置和代码段数据的位置相隔最近,与数据段的位置看似不远,但是16进制的第四位差别已经接近上万字节了,和虚表还是有点距离的,所以我们可以推荐虚表并不放在数据段(静态区),而放在**代码段(常量区)**中。
其实放在常量区中也是一个比较合理的选择,因为虚表是不能被随意修改的。
多态实现细节
接下来,有了虚表这个概念后,我们就可以更容易的理解多态了。
回顾一下多态需要的条件:
- 基类指针调用
- 派生类虚函数满足三同,构成重写
在学习了虚表之后,多态这个过程也就不那么神秘了,其实就是在用基类指针调用重写函数时,编译器会直接进入虚表内拿到所要调用的函数地址,也就是说在满足多态以后的函数调用,不是在编译的时候确定的,是运行起来以后到对象的虚表中去查找的。而不满足多态的函数调用在编译的时候早已确认好。
那么,再来思考一个问题,为什么一定要是**基类指针或引用调用?**直接用基类对象调用不行吗?
这里我们需要理解的一个至关重要的点就是引用或指针不会修改原来对象的虚表!正是由于这个原因,才必须用引用或者指针,如果函数参数是基类对象,那么将派生类对象传入时,就会修改对象虚表,从而不能达到多态的效果!
动态绑定与静态绑定
上面的内容又引出了一个概念,就是动态绑定和静态绑定。
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,函数重载就是经典的静态多态。
- 动态绑定又称为后期绑定(晚绑定),是在程序运行期间,根据拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
多继承的虚函数表
看如下多继承:
class base1
{
public:
virtual void func1()
{
cout << "func1()" << endl;
}
virtual void func2()
{
cout << "func2()" << endl;
}
void func3()
{
cout << "func3()" << endl;
}
private:
int _b = 1;
};
class base2
{
public:
virtual void func1()
{
cout << "base2::func1()\n";
}
virtual void func2()
{
cout << "base2::func2()\n";
}
};
class derive : public base1, public base2
{
public:
virtual void func1()
{
cout << "next::func1()" << endl;
}
virtual void func4()
{
cout << "func4()" << endl;
}
private:
int _c = 2;
};
对于多继承来说,派生类将有多个虚表(有几个带虚函数的基类就有几个虚表),如果两个基类有构成重写的函数,并且派生类也有构成重写的该函数,那么派生类的该函数指针将会同时覆盖两个基类函数的虚表内的该函数指针,另外,如果派生类中有自己新增的虚函数,将会放进第一个继承的基类的需表中,同样可以通过监视窗口操作看到。下图可以更好的说明:
菱形继承,菱形虚继承
在继承的学习中,我们知道为了解决菱形继承的数据冗余和二义性问题,引入了虚继承,而虚继承是用虚基表实现的,而多态是由虚表实现的,那将这两者结合起来之后,就越能感觉到c++的恐怖了,在实际中我们并不建议设计出菱形虚拟继承,一方面太复杂容易出问题,另一方面这样庞大的模型,访问基类成员有一定的性能损耗。
因此,菱形虚拟继承的虚表我们也不需要进行深究,这里带大家简单的了解一下即可。
可以看到虚继承+虚函数是非常复杂的,另外通过观察得知最终类的虚函数同样被放在了第一个继承的类中,而不是放在person类,当然这也跟编译器有关,本编译器是vs2022的结果。
另外,还有一个疑点就是虚基表中的第一行存放的是0xfffffc,翻译成十进制是-4,博主对于-4的作用还未能得知,如果有知道的佬欢迎在评论区解答。
由于菱形虚继承过于复杂,所以在实际应用中一定要尽量避免使用菱形虚继承,否则会造成很大的麻烦。
关于多态使用的小细节
inline
函数可以是虚函数,但是在编译器会忽略inline这一属性。
很合理,因为内联函数没有地址,没办法放进需表中
- 静态成员函数不可以是虚函数,因为静态成员函数没有this指针,无法访问虚函数表,所以不能通过运行时确定调用对象,因此没办法放入虚函数表
- 构造函数不能是虚函数,因为虚函数指针是在初始化列表中初始化的(和先有鸡还是先有蛋的问题很想)
- 虚表是在编译期间就生成了,一般存放在代码段中。