🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz

💪 今日博客励志语录 :
你以为自己在孤独地爬坡吗?看看身后吧------那些被汗水浸湿的脚印,早已连成一道向上的阶梯
★★★ 本文前置知识:
继承
引入
从这篇文章开始,我们就正式进入面向对象的三大特性之一的最后一个特性,那么便是多态,面向对象的核心思想就是模拟我们现实生活中的各种场景,而多态的含义字面意思是多种形态,所以首先我们来看一下现实生活中符合多态的场景:以买火车票为例,买火车票的对象可以是成年人也可以是孕妇也可以是学生或者军人,同样是买票这个动作,但是却可以根据对象的不同,得到不同的结果,比如成年人买票就是全价,学生买票就是半价,而军人则是优先买票
又或者看演唱会,那么演唱会的门票分vip票和普通票,那么vip票相比于普通票的待遇就是可以在更靠近舞台的内场观看,甚至可以提前半个小时或者一个小时进场,而普通票只能到时间进入,所以同样都是演唱会门票的持有者,但是当他们共同通过安检的时候,那么只能持有vip票的观众被允许先进入,而普通票的观众只能在外面等待,所以同样是安检检票这个动作,但是会根据对象的不同会得到不同的结果,那么只有持有vip票对象才能进入而持有普通票对象不能进入,那么以上两个例子都是符合这里多态性的现实生活中的场景
那么通过刚才的这两个例子,那么想必读者就能够理解所谓的多态:所谓的多态就是同一个动作,交给不同对象来完成,会得到不同的结果
所以这里c++要实现上面刚才的场景,那么意味着就得实现多态,所以接下来,我将会带你实现多态,并且了解和掌握多态的原理以及相关细节
多态
语法
那么在具体讲解多态的原理之前,那么我首先先来关注一下语法,也就是如何在语法层面实现多态,那么这里多态的触发需要涉及到的条件比较多:第一你得至少得准备两个类,并且这两个类之间的关系是继承 关系,其次这两个类中得定义三同的成员函数,所谓的三同必须是函数名和参数列表以及返回值相同 ,只要两个类中定义的成员函数不是三同,那么多态就无法实现,第三就是父子类中这三同的成员函数还必须是虚函数 ,第四就是你只能用父类的指针或者引用去调用该成员函数从而实现多态
那么这些就是实现或者说触发多态所必须具备的条件,一旦有一个条件缺失或者不满足,那么是无法触发多态的,所以触发多态的条件确实很多并且要求严苛,那么读者会注意上述的条件中出现了一个虚函数这个专业术语,那么这里读者可以将虚函数先理解为一种特殊的成员函数,这里我先不解释什么是虚函数,我们先着重围绕语法,看看实现多态的语法是长什么样子,接着再来具体分析背后的原理:
那么就以上文的买票的场景为例,那么这里我们可以定义一个person类来代表成人,然后再定义一个student类代表学生,那么我们知道多态的触发必须要求继承,那么这里我们就让student类继承person类,并且父子类中都定义三同的成员函数buyticket,而这里要让该普通的成员函数是变成虚函数,就得再函数声明前面加virtual关键字:
cpp
class person
{
public:
virtual void buyticket()
{
cout<<"person::buyticket"<<endl;
}
};
class student : public person
{
public:
virtual void buyticket()
{
cout<<"student::butticket"<<endl;
}
};
那么virtual关键字可以不在子类的虚函数中添加,只在与继承的父类的三同的虚函数后面添加即可,那么编译器识别到你子类这个成员函数和父类定义的某个虚函数是三同的,那么编译器会默认将其处理为虚函数,但是一般建议父子类的虚函数都加上virtual关键字,并且父类的虚函数是一定得添加virtual关键字
那么这里定义完父子类以及父子类中三同的虚函数,那么多态的触发条件已经满足大部分了,那么最后触发这个多态就是通过父类的指针或者引用来触发多态
cpp
//引用
void buy(person& l1)
{
l1.buyticket();
}
//指针
void buy(person* l1)
{
l1->buyticket();
}
那么我们再把上面的代码整合在一起来看一下多态的现象:
cpp
#include<iostream>
using std::cout;
using std::endl;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
};
class student : public person
{
public:
virtual void buyticket()
{
cout << "student::butticket" << endl;
}
};
void buy(person& l1)
{
l1.buyticket();
}
int main()
{
person p;
student s;
buy(p);
buy(s);
return 0;
}

那么这里根据终端的结果,我们可以发现如果给buy函数传递的是父类对象,那么调用的就是父类的buyticket函数,那么如果传递的是子类对象,那么调用的就是子类的buyticket函数,那么这个结果符合多态的性质,也就是不同对象做同一个相同的动作,那么会产生不同的结果,那么这个相同的动作就是父子类定义的三同的虚函数
原理
继承
那么知道了如何在语法层面上实现多态,那么接下来我们再来谈实现多态的具体原理,也就是为什么触发多态一定需要那几个条件,那么我会一个一个来讲解,那么首先触发多态的第一个条件便是继承,那么我们要实现多态,首先得准备具有继承关系的基类和派生类,那么有的读者可能会疑惑,那么多态的实现为什么一定需要有继承的基础呢,那么实现上文的买票的例子,那么我自己定义了一个person类以及继承person类的student子类,并且两个类中都定义了三同的buyticket虚函数,然后根据父类的指针或者引用指向的对象从而调用其指向的对象中对应的虚函数
那么有的读者可能会认为,那么他也许不用继承也可以实现多态,同样是实现刚才买票的例子,那么读者认为我可以也定义person类和student类,但是这两个类是独立的,然后我再这两个类中分别定义相同函数名的buyticket成员函数,那么接着在分别定义定义student对象以及person对象,然后分别调用其定义的buyticket成员函数来实现多态:
cpp
#include<iostream>
using namespace std;
class person
{
public:
void buyticket()
{
cout<<"person::buyticket"<<endl;
}
};
class student
{
public:
void buyticket()
{
cout<<"student::buyticket"<<endl;
}
};
int main()
{
student st;
person p;
p.buyticket();
st.buyticket();
return 0;
}

那么这里不同的独立的对象之间定义一个相同函数名的成员函数,那么再创建完对象后,通过对象去调用该成员函数,那么不同的对象调用内部相同函数名的成员函数,那么就如同不同的对象去做相同的动作,那么根据运行结果来看,确实得到的是不同的结果,符合多态的性质
那么关于上面的这种方式,那么我想说的就是,虽然上面这种方式确实"营造"或者说呈现了一个多态的现象,但是实际上面这种方式所呈现出的现象看似符合多态,但是实际上和我们现实生活中的真正的多态的场景不是符合的,那么以我们现实生活的多态的场景为例,比如买票,那么在买票之前,系统或者买票的软件是不知道当前买票的用户的身份是什么,究竟是成年人还是学生,那么只有当用户登录然后身份认证之后,那么此时应用才知道当前购票的用户的身份,然后根据当前用户的身份从而得到对应的结果,比如用户是学生,那么票价就是半价,而如果是成人,那么票价就是全价,而应用是不可能事先知道当前登录的用户的身份是学生还是成年人
同理上文的演唱会的例子,那么演唱会的门票也分为vip票和普通票,那么vip票持有者能够优先提前进场,那么安保知道当前观众如果是vip票持有者,那么就会让其提前入场,而如果是普通票持有者则不能让其进场,而这里安保在检查每一个观众之前,那么安保是不可能预知或者提前知道当前观众的身份,那么只有当观众给安保出示了门票之后,那么此时安保才会识别到你的身份,然后做出对应的行为
那么通过刚才我讲述的这两个现实生活中的多态的例子,那么这里我想强调的就是现实生活中的多态一定都是运行时 才能触发的多态,那么应用或者安保,它事先是无法知道当前对象的具体身份,只有当对象真正执行动作那一刻起才能知道,比如你要用买票之前一定要身份认证才能买票以及进入场地之前一定要给安保出示门票,那么只有你真正执行了买票或者经过安保的检查的动作的时候,那么应用或者安保才能确认你的身份从而触发多态,那么如果你只是在应用中浏览而不买票或者在场外徘徊不选择立刻进场,那么此时应用或者安保是无法获取你的身份的,那么也就自然无法触发多态
那么我们再来结合刚才的实现方式,也就是定义了两个独立的对象,然后通过这两个对象调用同名的成员函数来实现多态,那么程序在编译阶段,那么编译器就能够识别当前对象的类型,那么识别完这两个对象的类型之后,那么编译器一定知道调用的成员函数就是其类内部定义的成员函数,不用等到程序运行,那么在编译阶段就已经知道调用的是哪个成员函数了,那么这种方式就好比你还没买票进行身份认证的时候,那么系统就告诉你,它已经知道你是学生了,不用在进行身份认证了
而继承的方式,也就是父类和子类定义三同(函数名和参数列表和返回值都相同)的虚函数,那么通过父类的指针或者引用来调用虚函数,那么这里这里在编译期间,那么编译器识别到了指针或者引用的类型是父类类型,但是编译器即使知道当前虽然是父类类型的指针或者引用,但是其调用的不一定就是父类当中定义的虚函数,那么也可能是调用子类的虚函数,那么具体是调用哪个类中的虚函数,那么虚函数的确认则是需要到程序运行时,借助一个后文要讲的虚函数表才能最终确认,所以这种继承的方式才是符合我们现实生活中的多态,也就是需要运行时才能触发的多态,所以这里实现多态,不能按照上面的方式,那么必须得满足继承的基础
虚函数&&虚函数表
那么上文我们知道了继承的必要性,那么接下来读者的主要的疑问就是这里多态是如何通过父类的指针或者引用来调用父类以及子类的虚函数的,那么这里就和一个虚函数表的结构有关,那么讲继承的时候,我们就提到过一个偏移量表,而这里多态同样也有一个虚函数表,那么虚函数表的本质就是一个函数指针数组,那么数组中每一个元素就是函数指针,其记录的就是虚函数定义所在的地址,并且当我们在一个类中定义了虚函数之后,那么该类的内存布局也会发生变化,那么我们可以写一个简单的代码来验证,那么第一次实验时我准备了一个person类,那么其中定义了buyticket成员函数以及一个int类型的成员变量,那么最开始这个成员函数没有被virtual修饰,那么其就是一个普通的成员函数,而第二次实验时我则是将person对象的buyticket函数设置为虚函数,那么两次实验都会打印对象的首地址以及成员变量的地址以及对象的大小,来对比验证一个类定义了虚函数其对象的内部布局会有什么变化:
cpp
//test1
#include<iostream>
using namespace std;
class person
{
public:
void buyticket()
{
cout<<"person::buyticket"<<endl;
}
int id;
};
int main()
{
person p1;
cout<<&p1<<endl;
cout<<&p1.id<<endl;
cout<<sizeof(p1)<<endl;
return 0;
}
//test2
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout<<"person::buyticket"<<endl;
}
int id;
};
int main()
{
person p1;
cout<<&p1<<endl;
cout<<&p1.id<<endl;
cout<<sizeof(p1)<<endl;
return 0;
}
第一次实验:
第二次实验:
那么这里根据运行结果发现,那么类在没有定义虚函数以及定义了虚函数的总大小是不一样的,并且成员变量的偏移量也发生了变化,那么在没定义虚函数之前,那么成员变量的偏移量为0,也就在对象的起始位置分配,而定义了虚函数之后,那么成员变量则是在对象位置之后的8个子节后开始分配
由于这里的代码是在64位平台下的机器运行,那么64位的平台下,那么指针的大小是8个字节而不是4个字节,那么这里对象的前8个字节,其实就是分配给了一个指针,而该指针指向的内容,正是我们说的虚函数表,那么我们可以从调试窗口,看到这个指针指向的虚函数表的内容:
那么这里虚函数表的内容就是记录了当前该类的虚函数的定义所在的地址
那么从上文的代码验证以及分析之后,那么读者现在知道了一个类中如果定义了虚函数,那么该类实例化出的对象的内存布局则不再只是存储成员变量,而是会在起始位置处分配一个指针指向一个函数指针数组,那么该函数指针数组的内容就是该类中定义的虚函数的定义所在的地址
那么接下来我们在引入较为复杂的场景,来逐渐理解虚函数表,那么接下里的场景就是我们定义了一个继承person类的student子类,那么student类中有和person类三同的虚函数,那么此时我们来观察person类的内存布局和子类的内存布局,那么在写代码具体验证之前,那么我们可以先进行一个推导,那么根据上文,那么我们知道了一个类定义了虚函数,那么该类实例化出的对象的内存布局一定得包含一个指针指向一个虚函数表,那么这里子类也定义了虚函数表,那么不出意外,那么该子类内部也会开辟一个虚函数表指针,但是由于其继承了person类,而person类内部也定义了虚函数,那么这里子类是否会开辟两个虚函数表指针,一个指向父类的虚函数表,然后另一个指针指向子类的虚函数表,还是说子类只会存一个指针,那么实践出真知,接下来就让我写代码来验证此场景下,子类的内存布局:
cpp
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
int id;
};
class student : public person
{
public:
virtual void buyticket()
{
cout << "student::buyticket" << endl;
}
int _id;
};
int main()
{
person p1;
student st;
cout << &p1 << endl;
cout << &p1.id << endl;
cout << sizeof(p1) << endl;
cout << "-------------------------" << endl;
cout << &st << endl;
cout << &st.id << endl;
cout << &st._id << endl;
cout << sizeof(st) << endl;
return 0;
}

那么根据运行结果,那么父类的成员变量与整个对象的起始位置相差8个字节,其次就是子类的成员变量的地址与父类的成员变量的地址相差8个字节,那么从结果我们可以发现,这里子类的内存布局还是父后子,并且我们可以验证这里子类只会维护一个虚函数表指针,那么其位置就在对象开头,因为整个对象是由完整的父类对象16字节加上子类的int成员变量的4个字节以及内存对齐填充的4个字节总共24个字节,所以这里子类的内存布局是先父后子,其中的父类部分就是之前上文带有虚函数的完整的父类对象的副本
那么接下来,我们再来通过调试窗口来查看父子类对象的指针指向的虚函数表的内容:
那么这里我们就可以初见端倪,那么观察调试窗口里的内容,我们可以对比父子类的对象中的指针指向虚函数表里面的内容,那么这里父类的虚函数表里面的内容则是记录了父类内部定义的虚函数的定义的地址,而子类的虚函数表中则是记录子类内部定义的虚函数的定义所在的地址,那么还没完,那么此时我们如果再在子类中定义一个和父类不是三同的虚函数,比如fun虚函数,那么fun虚函数的除了函数名与父类的buytick虚函数不一样外,其余返回值和参数列表都是一样的,那么此时我们再来观察子类对象存储的指针指向的虚函数表里面的内容
而由于调试窗口只能显示虚函数表的第一个条目,而不能完整显示出虚函数表的所有条目,所以这里要验证当前场景下的子类的虚函数表的内容,那么我们就得自己写代码来验证,那么我们知道虚函数表本质就是函数指针数组,那么数组的每一个元素是一个指针,其保存了虚函数定义的地址,而子类对象中存储了一个指针,其位置在子类对象的父类部分的开头,那么其保存了函数指针数组的首元素的地址,而函数指针数组的每一个元素都是指针,那么意味着子类对象中存储的指针,其保存的是一个函数指针数组中的第一个指针的地址,那么指针指向的内容是指针,所以子类对象中的该指针的类型就是二级指针,那么我们得解引用两次,第一次先解引用子类对象中的指针获取到函数指针数组的首元素的地址,然后再解引用函数指针数组的首元素从而获取到虚函数的定义的地址,所以需要解引用两次
而子类的虚函数表指针是位于子类的父类部分的起始位置,而在该场景下,父类部分person的起始位置其实就是整个子类对象的起始位置,所以这里我们直接取子类对象的地址从而直接获取到子类对象中存储的指针的地址,
cpp
student st;
&st;//虚函数表的地址的地址
那么该地址是二级指针也就是虚函数表指针的地址,那么意味着该地址可以视作一个三级指针,那么接下来我们就要解引用该地址,得到虚函数表的首元素的地址,但是这里地址的类型是一个student类型,那么我们还得强制类型转化成三级函数指针类型,而由于我们定义的函数都是void类型且没有参数,但要注意编译器会隐藏添加一个tstudent类型的his指针,所以这里我们还得定义一个函数指针类型Fuc_Ptr
cpp
typedef void(*Fuc_Ptr)(student*);
//函数指针类型的定义:
typedef 返回值(*指针类型名)(函数的参数列表);
那么Fun_ptr是一级指针,而Fuc_ptr* 就是二级指针,那么Fuc_ptr**就是三级指针,那么这里我们将其强制类型转化为三级函数指针类型,然后解引用该三级指针得到虚函数表的首元素的地址
接下来的思路就是获取虚函数表的首元素的地址后,然后遍历该虚函数表并且执行虚函数表中记录的虚函数,那么这部分内容则是都定义到print函数中完成,那么它会接收一个二级指针,那么由于虚函数表的最后的一个元素是以NULL结尾,那么我们就可以用以for循环遍历这个虚函数表,然后每一次循环执行对应的函数
cpp
void print(Fuc_Ptr* vptr,student* l1)
{
for (int i = 0;vptr[i] != NULL;i++)
{
vptr[i](l1);
}
}
完整验证代码:
cpp
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
int id;
};
class student : public person
{
public:
virtual void buyticket()
{
cout << "student::buyticket" << endl;
}
virtual void fun()
{
cout << "student::fun()" << endl;
}
int _id;
};
typedef void(*Fuc_Ptr)(student*);
void print(Fuc_Ptr* vptr,student* l1)
{
for (int i = 0;vptr[i] != NULL;i++)
{
vptr[i](l1);
}
}
int main()
{
student st;
Fuc_Ptr* ptr = *(Fuc_Ptr**)&st;
print(ptr,&st);
return 0;
}

那么这里我们根据运行结果,我们就能验证在这个场景下,子类的虚函数表的内容,那么我们能够确认子类的虚函数表的条目有两项,分别是记录自己定义的buyticket虚函数以及新增了与父类不是三同的fun虚函数
这里我再增加最后一个场景,那么就是子类没有虚函数,但是继承的父类定义了虚函数,比如这里的person类定义了buyticket虚函数而student类没有定义任何虚函数,那么这里我们直接按照刚才的验证方式,来验证这个场景:
cpp
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
int id;
};
class student : public person
{
public:
int _id;
};
typedef void(*Fuc_Ptr)(student*);
void print(Fuc_Ptr* vptr,student* l1)
{
for (int i = 0;vptr[i] != NULL;i++)
{
vptr[i](l1);
}
}
int main()
{
student st;
&st;
Fuc_Ptr* ptr = *(Fuc_Ptr**)&st;
print(ptr,&st);
return 0;
}

那么这里我们根据运行结果,发现子类还是会有虚函数表,并且其虚函数表记录的是其父类定义的虚函数的地址
那么这里我们就结合刚才的所有场景,也就是一个类定义了虚函数、父类和继承当前父类的子类都定义了三同的虚函数、继承当前的父类的子类定义了和父类不是三同的虚函数、父类定义了虚函数但继承当前父类的子类没有定义虚函数。通过这4个场景,那么我们就能够理解并且掌握虚函数表的相关原理:
那么当一个类定义了虚函数,那么其实例化的对象内部必然有一个指针,指向虚函数表,那么如果该类没有虚函数,但是其继承的父类有虚函数,我们知道单继承的子类的内存布局就是先父后子,那么其中父类部分就是完整的父类对象的副本,而如果父类定义了虚函数,那么父类对象的内存布局是会在起始位置处定义一个指针,然后之后再是父类的成员变量,并且整体按照内存对齐规则分布,所以子类中的父类部分已经有了指向虚函数表的指针,那么子类就不要再额外创建一个虚函数表的指针,那么直接复用父类部分的指针,只不过让其指向子类自己的虚函数表
那么子类的虚函数表的条目的构成则是由两部分组成分别数父类定义的虚函数以及子类自己定义的与父类不是三同的虚函数,并且先是排列父类部分的虚函数的定义的地址,再是排列子类内部定义的虚函数的地址
那么如果父类和子类定义了三同的虚函数,那么这里子类指向的虚函数表中,原本指向记录父类虚函数定义的地址的位置处则会被覆盖为子类中与父类三同的虚函数的定义的地址,那么之后就不用再新增该虚函数的条目,因为直接覆盖了父类的三同的虚函数地址
那么这里知道虚函数表的相关原理之后,那么这里我会给读者抛一个问题,那么这个问题也就是理解虚函数表的最后一道门槛,那么假设我现在有一个person类,那么person类定义了虚函数,那么有了上文的讲解,想必你已经知道如果我实例化一个person对象,那么该person对象内部起始位置一定有一个指针,指向一个虚函数表,那么我的问题就是:如果我实例化了多份person对象,那么每一个person对象内部都有一个指针指向一个虚函数表,那么其指向的虚函数表是同一个虚函数表还是指向独属于每一个person对象的虚函数表?
那么这个问题,我相信很多读者即使不知道正确答案,但是凭感觉也应该认为指向的是同一个虚函数表,那么我们也可以推测出原因,这里的原因其实和对象是否存储成员函数的原因是差不多的,因为实例化出的不同的person对象中的虚函数表的条目都是一样的,其记录了person类中虚函数的定义的地址,所以这里没必要为每一个对象开辟一份虚函数表,就和不同实例化的对象共用一个成员函数是一个道理
那么这里我也可以写代码来验证这个情况:
cpp
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
int id;
};
class student : public person
{
public:
int _id;
};
void test()
{
person p2;
}
int main()
{
person p1;
return 0;
}

那么这里我们可以通过调试窗口,来看到这里的实例化的两个person对象的指针指向的虚函数表的地址都是相同的。都是:0x00007ff6783dbcf8
p1:
名称 | 值 | 类型 | |
---|---|---|---|
◢ | __vfptr | 0x00007ff6af6fbcf8 {多态.exe!void(* person::`vftable'[2])()} {0x00007ff6af6f141f {多态.exe!person::buyticket(void)}} | void * * |
p2:
名称 | 值 | 类型 | |
---|---|---|---|
◢ | __vfptr | 0x00007ff6af6fbcf8 {多态.exe!void(* person::`vftable'[2])()} {0x00007ff6af6f141f {多态.exe!person::buyticket(void)}} | void * * |
那么接着我们还可以验证虚函数表存放的位置,是在栈上还是堆上还是静态区还是只读数据段
那么实践出真知,接下来我们就写代码来验证虚函数表的位置,那么这里我们还是分别打印栈变量的地址以及const修饰的变量的地址以及堆上申请的变量的地址和static修饰的变量的地址以及虚函数表的首元素的地址,const修饰的变量位于只读数据段,栈变量位于栈区,而new申请的变量位于堆,而static修饰的变量位于静态区,那么我们在比较虚函数的首元素的地址和这些变量之间的位置关系,来确定其更靠近与哪个区域:
cpp
#include<iostream>
using namespace std;
class person
{
public:
virtual void buyticket()
{
cout << "person::buyticket" << endl;
}
int id;
};
class student : public person
{
public:
int _id;
};
typedef void(*Fuc_Ptr)(student*);
int main()
{
int a;
const int b = 1;
int* ptr = new int;
static int c;
cout << &a << endl;
cout << &b << endl;
cout << ptr << endl;
cout << &c << endl;
person p;
Fuc_Ptr* vptr = *(Fuc_Ptr**)&p;
cout << vptr << endl;
return 0;
}

那么这里根据打印的地址,那么我们可以发现,这里虚函数表的首元素的地址与静态区的地址相差的字节是3572,而与其他地址相差的字节是远大于这个量级的,那么我们可以通过代码来验证得到虚函数表是存储在静态区当中的
虚函数表的生成
那么这里就要补充虚函数表的生成,那么虚函数表是在编译阶段由编译器来生成,那么根据上文的内容,那么我们知道只有当前类定义了虚函数以及继承的父类的子类的类定义了虚函数,那么此时该类就会有指针指向该类对应的虚函数表,而子类的虚函数表的条目由两部分组成,分别是父类定义的虚函数以及新增子类自己与父类不是三同的虚函数
那么假设现在有一个继承链,那么这里编译器要为类中定义了虚函数的类以及继承的父类有虚函数的子类生成虚函数表,那么编译器生成的虚函数表的顺序就是先生成整个继承链中最顶层且定义了虚函数的父类,那么该父类生成完了虚函数表之后,在依次沿着继承链往下依次生成子类的虚函数表,那么子类的虚函数表的生成首先就需要拷贝其父类的虚函数表的所有条目,拷贝完之后,接着在检查子类是否存在有和继承下来的父类的三同的虚函数,如果有,那么此时该子类有与父类三同的虚函数的地址就会覆盖到与父类三同的虚函数在虚函数表的条目,将其覆盖为子类的虚函数的地址,那么检查并且覆盖完父类三同的条目之后,然后编译器再会增加子类与父类不是三同的虚函数,那么重复这样的步骤处理接下来的子类
根据刚才的原理,那么我们知道编译器在生成继承链中的每一个类的虚函数表的时候,都需要利用并且拷贝其继承的父类的虚函数表条目然后接着再检查三同然后再进行覆盖工作,那么编译器就得记录代码中定义的继承关系,所以这里编译器会维护一个继承树的数据结构,那么继承树就是记录了这些类的继承关系,那么这棵树的根节点就是最上层的父类,那么最底层的叶子节点就是最下层的子类,那么它会根据这棵树,从最底层的叶子节点开始往上递归,那么如果当前叶子节点有虚函数,那么就会递归先生成父类的虚函数表,那么如果扫描到父类的父类还定义了虚函数,那么就会继续往上递归,直到递归到最顶层的定义且虚函数的父类,然后停止递归,生成该父类的虚函数表,然后再回溯到下一层的子类,那么子类会拷贝上层的父类的虚函数表的条目,先检查三同,有三同的虚函数,那么就覆盖,然后处理完之后再新增子类与父类没有三同的虚函数,依次往下回溯到最底层的子类,所以经过刚才的讲述,我们可以认识到,编译器在编译阶段就已经为所有定义了虚函数的类以及其继承的父类带有虚函数的子类全部都生成了虚函数表,没有所谓的按需创建的说法,那么即使你不创建带有虚函数的person对象,但是编译器在编译阶段还是已经为person对象生成了对应的虚函数表
cpp
void build_vtable(ClassMetadata* cls) {
// 递归构建基类vtable
for (auto base : cls->bases) {
build_vtable(base); // 确保基类先构建
cls->vtable_size = base->vtable_size; // 继承槽位大小
}
// 处理重写函数
for (auto& vfunc : cls->vfuncs) {
if (vfunc.overrides) {
int slot = find_base_slot(vfunc); // 在基类查找槽位
cls->vtable_map[vfunc.id] = slot; // 复用原槽位
}
}
// 追加新虚函数
for (auto& vfunc : cls->vfuncs) {
if (!vfunc.overrides) {
cls->vtable_map[vfunc.id] = cls->vtable_size++;
}
}
}
那么当我们创建一个带有虚函数或者继承的父类带有虚函数的子类对象的时候,那么这里由于子类的内存布局是先父后子,那么这里我们可以得到一个结论,那么就是这个单继承链中从定义了虚函数的父类开始往下的所有子类,那么其对象中都++只有一个虚函数表指针++,指向一个属于该类的虚函数表
但是这里的指针的存放位置是一个容易混淆的点,很多人都会默认认为指针就一定是在子类对象的起始位置处
那么注意,子类的对象是先父后子,那么先是父类部分然后再是子类部分,并且其父类部分是按照继承的声明顺序排列,那么虚函数表的指针一定是位于这个子类对象中第一个定义虚函数的父类部分的起始位置,而注意该父类的起始位置不一定就整个子类对象的起始位置重合,因为在单继承链中定义虚函数的父类不一定是从第一个父类开始定义:

那么有的人之所以有这个惯性思维,那么是因为大部分读者都是习惯了第一个继承的父类就定义虚函数
那么继承带有虚函数的父类的子类或者自己本身定义了虚函数的类,那么编译器在这些类的构造函数中,会完成虚函数表指针的初始化,将虚函数表指针指向编译阶段已经在静态区生成好的该类的虚函数表,那么这就是虚函数表诞生的一个过程
cpp
person::person() {
// 编译器注入的关键步骤 ▼
this->__vptr = &person::vtable; // 初始化vptr
// 用户编写的构造代码
}
多继承
那么前面我们介绍了单继承下子类的内部布局以及虚函数表,那么这里我们来引入多继承,那么看一下多继承下子类的虚函数表以及内存布局会有什么变化,那么这里我定义了两个父类b分别是base1和base2,和一个子类derive同时多继承这两个父类base1和base2,那么这里我们还是引入几个场景,那么首先第一个场景就是父类base1和base2中都定义了没有参数没有返回值的虚函数fun1,并且子类derive也定义了没有参数没有返回值的fun1
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout<<"base1::fun1()"<<endl;
}
int id1;
};
class base2
{
public:
virtual void fun1()
{
cout<<"base2::fun1()"<<endl;
}
int id2;
};
class derive:public base1,public base2
{
public:
virtual void fun1()
{
cout<<"derive::fun1()"<<endl;
}
int id3;
};
int main()
{
derive d;
cout<<&d<<endl;
cout<<&d.id1<<endl;
cout<<&d.id2<<endl;
cout<<sizeof(d)<<endl;
return 0;
}

那么这里我们打印了这三个地址,我们可以发现此时子类的内存布局,那么还是先父后子,并且父类部分是按照声明顺序排列,而这里base1的第一个成员变量并不在子类对象的起始位置分配,而是往后移动了8个字节,那么有了上文的讲解,我们知道这里的8个字节就是指针,其指向虚函数表,而base2的成员变量与base1的成员变量的起始位置相差16个字节,那么这16个字节就是由base1的成员变量的4个字节以及内存对齐填充的4个字节以及base2中的前8个字节的指针加起来,总共16个字节,而这里base2的部分的起始位置也会存储一个指针指向虚函数表
那么这里我们就能知道多继承的子类的内存布局,那么还是先父后子,并且父类部分是按照声明顺序排列,那么如果父类部分定义了虚函数,那么每一个父类部分的起始位置就会分配一个指针,指向父类部分的虚函数表
那么接下来我们再来验证这里父类部分的指针指向的虚函数表的内容,那么采取的方式还是执行虚函数表中记录的虚函数,这里就注意父类base1中指向虚函数表的指针就在子类对象的起始位置,而父类base2的指针则是存放在base2父类部分的起始位置,那么这里要得到base2的虚函数表指针,那么我们可以先定义一个base2类型的指针指向derive对象,那么这里编译器识别到该指针的类型是base2但其指向的却是子类对象,所以这里编译器会隐式的插入一行代码将该指针往后移动一定的偏移量,从而将base2的指针指向子类对象中的base2父类部分的起始位置,那么我们可以通过这个巧妙的方式从而得到base2父类部分的虚函数表指针的地址,那么读者也可以尝试获取子类对象的起始地址然后加上一定的偏移量得到base2部分的虚函数表指针的地址,
下一步就是解引用该虚函数表指针得到base2虚函数表的首元素地址,接着在print函数内遍历虚函数表并执行对应的虚函数:
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class base2
{
public:
virtual void fun1()
{
cout << "base2::fun1()" << endl;
}
int id2;
};
class derive :public base1, public base2
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id3;
};
typedef void(*Fuc_Ptr)(derive*);
void print(Fuc_Ptr* vptr, derive* l1)
{
for (int i = 0;vptr[i] != NULL;i++)
{
vptr[i](l1);
}
}
int main()
{
derive d;
Fuc_Ptr* l1 = *(Fuc_Ptr**)&d;
print(l1, &d);
cout << "------------" << endl;
base2* ptr =&d;
Fuc_Ptr* l2 = *(Fuc_Ptr**)ptr;
print(l2, &d);
return 0;
}

那么这里我们根据运行结果得知,那么子类的多继承的两个父类部分的指针指向的虚函数表记录的条目都是子类定义的虚函数fun1,因为这里子类的fun1和多继承的父类的fun1是三同的,所以这里会覆盖父类对应的虚函数表中的条目
那么此时再引入第二个场景,那么就是子类中定义与父类不是三同的虚函数fun2,那么在验证之前,我们可以自己先进行一个推测,那么这里子类定义了与父类不是三同的虚函数fun2,那么子类的内存布局肯定不会发生变化,那么一定是先父后子,那么父类部分按照声明顺序排列,并且父类部分的第一个位置都是一个指针指向虚函数表,那么这里与之前的场景不同的是,这里由于子类定义了与父类不是三同的虚函数fun2,那么这里的两个父类的虚表是都会新增这个fun2函数的条目还是只是其中一个父类的虚表新增该条目,那么这个场景下我们就只需验证父类base1和base2的虚函数表的内容:
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class base2
{
public:
virtual void fun1()
{
cout << "base2::fun1()" << endl;
}
int id2;
};
class derive :public base1, public base2
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
virtual void fun2()
{
cout << "derive::fun2()" << endl;
}
int id3;
};
typedef void(* Fuc_Ptr)(derive*);
void print(Fuc_Ptr* vptr, derive* l1)
{
for (int i = 0;vptr[i] != NULL;i++)
{
vptr[i](l1);
}
}
int main()
{
derive d;
Fuc_Ptr* l1 = *(Fuc_Ptr**)&d;
print(l1, &d);
cout << "------------" << endl;
base2* ptr =&d;
Fuc_Ptr* l2 = *(Fuc_Ptr**)ptr;
print(l2, &d);
return 0;
}

那么这里我们可以验证,那么子类内部定义的与父类不是三同的虚函数的地址则是添加在base1的虚函数表中,而没有添加在base2的虚函数表中,所以这里结合上面的两个场景,那么我们也能够摸清楚多继承的虚函数表的条目生成的机制:
那么子类多继承的父类如果定义了虚函数,那么该父类部分就有虚函数表指针指向对应的虚函数表,并且如果子类定义了与父类三同的虚函数,那么会覆盖父类的虚函数表中对应的条目,而如果子类定义了与父类虚函数不是三同的虚函数,那么该条目只会新增到声明顺序最靠前且定义了虚函数的父类的虚函数表中,这里读者看到这里我添加了两个定语,分别是声明顺序最靠前以及定义了虚函数,那么这里要注意的是,子类多继承的所有父类不一定都定义了虚函数,比如子类先后同时继承了base1和base2和base3
cpp
class derive:public base1,public base2,public base3
{
.....
};
那么如果base1没有定义虚函数而base2和base3定义了虚函数,那么这里子类与父类没有三同的虚函数条目则添加到base2的虚函数表中,这里一定要严谨
那么看到这里,那么读者又会有一个疑问,那么就是这里为什么要这么设计,也就是子类与父类不是三同的虚函数只能添加到声明顺序最靠前并且定义了虚函数的父类当中呢?
那么这个问题就和虚函数的调用有关,也就是接下来下文所讲的用父类指针或者引用调用虚函数的内容,那么这个问题就先埋下一个伏笔,会会在下文中解答
父类指针/父类引用
单继承
那么我们知道触发多态的最后一个条件就是父类的指针或者引用,那么这里为什么一定要求是父类的指针或者引用,为什么不能是子类的指针或者引用或者父类的对象来调用呢?
那么以单继承为例,假设这里有一个derive类和base类,那么derive类继承了base类,并且两个类都定义了三同的函数名为fun的虚函数,那么这里如果用derive *的指针去调用,那么derive *只能指向一个derive对象,那么这里用derive指针去调用函数,那么编译器识别到指针的类型是derive类型,那么它会自动调用derive类绑定的成员函数,而如果derive类和base类定义了三同的虚函数,那么这里虚函数之间就会构成隐藏,那么调用的永远是子类的虚函数
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :public base1
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id2;
};
int main()
{
derive d;
derive* ptr = &d;
ptr->fun1();
return 0;
}

所以通过子类的指针调用,那么其是静态绑定的调用,不符合多态借助虚函数表的动态绑定的机制,所以不能用子类的指针
那么再来说为什么无法用父类的对象来调用,那么注意的是对象调用不管是虚函数还是普通的成员函数,那么其机制都是调用绑定当前对象的成员函数或者虚函数,还是上面说的场景,base1和继承base1的derive类定义了三同的虚函数fun1,而这里如果你定义了一个base1对象,那么当前对象调用的函数只能是base1内部定义的函数其中包括虚函数以及普通成员函数,和用子类指针调用的机制一样,也是静态绑定
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :public base1
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id3;
};
int main()
{
base1 b;
b.fun1();
return 0;
}

那么由于对象调用函数的机制是静态绑定,编所以译器不会去找到对象中的虚函数指针解引用访问虚函数表然后再找到对应的虚函数在虚函数表中的索引,那么编译器只会调用其作用域内定义的函数,所以父类的对象是不行的,子类的对象就更不用说了,肯定更不可以
那么这里排除了子类的指针和父子类对象,那么这里要触发多态,就只能父类的指针或者引用,那么这里我们知道子类的指针以及父类的对象为什么不行,这里我们还得知道父类的指针或者引用为什么行,那么父类的指针或者引用如果指向的是父类的对象,那么虚函数表指针就在父类对象的起始位置,然后解引用该指针访问到虚函数表,然后根据虚函数在虚函数表中的索引,然后跳转到定义去执行,而如果指向的是子类的对象,那么在单继承下,那么它会首先计算出声明顺序最靠前且定义了虚函数的父类部分的偏移量,然后解引用指针访问虚函数表,然后根据对应的虚函数在虚函数表的索引,跳转到定义执行虚函数的代码
所以当父类的指针指向父类对象,那么它能够访问父类的虚函数表,调用父类定义的虚函数,而父类的指针也能够指向子类对象,那么它能够访问子类的虚函数表,从而调用子类中与父类三同的虚函数,能够正确实现多态
虚函数表 vtable 子类对象内存布局 三同虚函数 非三同虚函数 覆盖 覆盖 新增 原Base::fun1 索引0: &Derive::fun1 原Base::fun2 索引1: &Derive::fun2 子类特有函数 索引2: &Derive::fun3 Derive对象 Base数据成员 vptr Derive特有数据 父类指针 Base* ptr 访问对象首地址 取vptr 查vtable 虚函数类型 按索引跳转 编译器禁止调用 执行正确函数代码
多继承
在多继承的场景下,那么也只能用父类的指针或者引用去调用,那么相比与单继承,那么多继承就能够允许我们用多个父类的指针或者引用触发多态,比如这里有base1和base2两个父类,那么这里derive同时继承base1和base2,如果base1和base2分别定义了和derive类三同的虚函数fun1和fun2,那么我们可以定义base1的指针或者base2的指针来调用该虚函数,那么多继承虚函数调用的机制则是:编译器会识别指针的类型然后计算出在子类对象中父类部分的偏移量,然后解引用该父类部分中的虚函数表指针,在根据虚函数在虚函数表中的索引,然后跳转执行对应的虚函数的代码,和单继承的调用机制是一样
虚函数表 子类对象内存布局 三同虚函数 非三同虚函数 &Derive::fun1 &Derive::fun3 &Derive::fun2 ... vptr_base1 Base1子对象 data1 vptr_base2 Base2子对象 data2 data3 Derive数据 父类指针 Base1* ptr 计算偏移量 父类指针 Base2* ptr 访问父类子对象 取vptr 查vtable 虚函数类型 按索引跳转 编译器禁止调用 执行正确函数代码
那么这里就能够来解释上文埋下的伏笔,也就是子类定义了不与父类三同的虚函数,那么这里为什么只在声明顺序最靠前且定义了虚函数的父类的虚函数表中添加该条目
那么假设这里derive类定义了与base1和base2不是三同的虚函数fun2,那么我们根据上文的讲解,我们知道fun2的条目只添加在base1的虚函数表中,不在base2的虚函数中添加,那么原因是这个虚函数由于没有与多继承的父类中定义的所有虚函数出现三同,那么这里调用该子类与父类不是三同的虚函数,那么只能通过子类的指针调用,那么即使我们知道子类与父类不是三同的虚函数是添加在base1的虚函数表中,那么理论上,base1的指针指向该对象,那么能够解引用该虚函数表指针从而到虚函数表中访问到fun2虚函数,但是编译器还是做了检查,不允许我们用base1的指针指向derive对象然后访问fun2虚函数:

所以这里只能用子类的指针去访问fun2虚函数,那么就没必要每一个父类部分的虚函数表添加该条目,只需要在声明顺序最前且定义了虚函数的父类部分的虚函数表添加即可
补充:析构函数/带有虚函数的对象的切片/子类虚继承父类且父类定义了虚函数,此时子类的内存布局/final以及override关键字
析构函数
那么这里c++允许我们在类的析构函数前面添加virtual关键字,那么意味着此时析构函数也可以作为虚函数,那么为什么这里析构函数也可以作为虚函数呢?
那么我们知道父类的指针可以指向子类的对象,那么会存在这么一个场景,那么这里我定义了一个base类和一个derive类,那么derive类继承base类,那么我接着定义一个base类型的指针指向在堆上创建的derive对象,调用new运算符来创建,并且这里derive对象内部有一个成员变量是指针,那么其内部的构造函数会将其初始化指向堆上的一片空间
cpp
class base
{
public:
int ptr1;
base()
:ptr1(new int[100])
{
}
~base()
{
delete [] ptr1;
ptr1=nullptr;
}
};
class derive:public base
{
public:
int* ptr2;
derive()
:ptr(new int[10])
{
}
~derive()
{
delete[] ptr2;
ptr2=nullptr;
}
};
int main()
{
base1* ptr=new derive;
delete ptr;
return 0;
}
那么这里我们知道由于该derive对象是在堆上申请的,那么在堆上申请的空间需要我们手动释放,所以就需要我们调用delete运算符来释放该指针指向的对象,那么这里注意new运算符其实底层是分为了两个步骤来进行,那么第一步就是调用该对象的析构函数,那么第二步则是调用operator delete运算符重载函数,那么其内部封装了free函数来释放整个对象的空间,而如果这里的析构函数不是虚函数,那么编译器识别到该指针的类型是base类型,那么编译器会调用base绑定的析构函数,也就是只执行base类的析构函数,那么base类的析构函数只会清理父类部分的资源,那么子类部分的资源此时没有得到清理,而在这个场景下子类部分有一个指针的成员变量并且指向了堆上的空间,那么子类部分资源没有清理,那么就会造成内存泄漏的问题
所以这里我们期望的是调用子类的析构函数因为子类的析构函数会先执行自己的析构函数体里面的内容再自动调用父类的析构函数
而这里是父类的指针,我们却期望调用子类的析构函数,那么这不正是我们多态的场景吗,所以这里的析构函数自然可以被允许设置为虚函数,但是多态的触发条件是函数名和返回值和参数列表要相同,所以为了满足三同的要求,所以这里编译器对析构函数进行了特殊处理,那么将所有类的析构函数的函数名统一处理为了destructor
Yes No delete ptr 通过vptr查找析构函数 是否为最派生类? 执行派生类析构函数体 编译器插入基类析构调用 执行基类析构函数体 继续向上调用基类析构 直接执行当前类析构
带有虚函数的对象的切片
那么我们知道子类对象可以赋值给父类对象,那么此时发生一个切片的行为,那么编译器会计算出子类部分的父类部分的起始位置的偏移量和父类的大小,然后将子类对象的父类部分给拷贝过去,而其余部分通通切掉,那么这就是切片,而如果假设这里有一个父类对象和一个继承该父类对象的子类对象,并且父子类都定义了三同的虚函数,那么这里我们知道将该子类对象赋值给父类对象会发生切片,那么对于子类对象中的父类部分的成员变量肯定是原封不动的拷贝过去,但是虚函数表指针呢?
那么这里根据上文的讲解,这里编译器肯定不可能那么把虚函数指针也原封不动拷贝过去,因为父类的对象只能调用自己的虚函数,无法调用子类的虚函数,所以这里编译器会进行一个处理,那么在上文我们讲解虚函数表的生成的原理的时候,我们知道编译器在编译阶段就会为每一个带有虚函数的类以及其继承的父类带有虚函数的子类都生成虚函数表,所以这里编译器就会将该父类对象的指针指向在静态区创建好的父类的虚函数表即可,不会进行虚函数表的指针的拷贝
切片赋值 仅复制数据成员 不复制虚表指针 虚表指针指向base类的虚函数表
子类虚继承父类且父类定义了虚函数,此时子类的内存布局
那么这里补充的最后一点就是这里如果同时存在虚继承和虚函数,那么子类的内存布局会是怎样的,那么我们知道虚继承子类对象内部会维护一个指针指向偏移量表,而定义了虚函数则子类内部还会维护一个指针指向虚函数表,那么这里必然会有两个指针,并且我们知道子类采取虚继承的方式继承父类,那么子类的内存布局是先子后父,那么其中子类对象的起始位置会分配一个指针指向偏移量表,那么这里我们核心要验证的就是这两个指针的位置
那么这里我们还是写了一个代码来验证,并且通过打印地址的方式来确认:
cpp
#include<iostream>
using namespace std;
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :virtual public base1
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id2;
};
int main()
{
derive d;
cout << &d << endl;
cout << &d.id2 << endl;
cout << &d.id1 << endl;
cout << sizeof(d) << endl;
return 0;
}

那么根据代码的结果,我们知道这里子类的整体的内存布局还是先子后父,那么这里子类对象则是在起始位置的8个字节后开始分配,而这里的8个字节就是指针,该指针指向的是偏移量表,而这里父类的成员变量的地址与子类第一个成员变量的地址相差16个字节,那么这16个字节就是由子类的成员变量的4个字节加上其内存对齐填充的4个字节和虚函数表指针的8个字节,总共16个字节,所以这里我们就能知道子类的内存布局
final以及override关键字
那么这里补充这两个关键字,那么final关键字只能修饰虚函数,那么定义在虚函数的后面,代表该虚函数不能被重写或者说覆盖,那么一旦子类定义了与final修饰的三同的虚函数就会报错:
cpp
class base1
{
public:
virtual void fun1() final
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :public base1
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id2;
};

final也可以修饰在类名后面,代表该类无法被继承,如果有继承,则会报错:
cpp
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :public base1
{
public:
virtual void fun1()
{
cout << "derive::fun1()" << endl;
}
int id2;
};

而override关键字则是可以检查该子类定义的虚函数是否有与继承的父类有三同的虚函数存在,没有则会保了报错,该关键字的作用就是检查该虚函数是否重写了父类的虚函数
cpp
class base1
{
public:
virtual void fun1()
{
cout << "base1::fun1()" << endl;
}
int id1;
};
class derive :public base1
{
public:
virtual void fun1(int i=0) override
{
cout << "derive::fun1()" << endl;
}
int id2;
};

结语
那么这就是多态的全部内容了,那么多态算是c++学习的一道大的门槛了,那么十分感谢耐心看到这里的读者,那么恭喜你成功越过多态这道门槛,那么下一期我将更新搜索二叉树,我会持续更新,希望你能多多关注,如果本文有帮组到你,还请三连加关注,你的支持就是我创作的最大的动力!
