文章目录
前言
多态是面向对象编程的三大核心特性(封装、继承、多态)之一,它使得同一接口可以呈现出不同的行为,极大地提升了代码的灵活性和可扩展性。在 C++ 中,多态的实现与虚函数、虚表等机制紧密相关,其底层逻辑涉及编译期与运行期的不同处理方式。
本文将系统梳理 C++ 多态的概念、实现条件、核心机制(虚函数与虚表),并深入解析多态在继承场景下的表现,同时结合典型问题与示例代码,帮助读者全面理解多态的本质与应用。无论是基础的虚函数重写,还是复杂的多继承虚表结构,本文都将逐一剖析,为开发者在实际编程中合理运用多态提供清晰指引。
多态的概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
为了更方便和灵活的实现多种形态的调用
多态的定义和实现
虚函数
概念:被virtual修饰的类成员函数称为虚函数(和前面的虚继承区分)
eg:class Person { public: virtual void text() {};
虚函数的重写(覆盖)
概念:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写或者覆盖了基类的虚函数。
省流:虚函数+三同
虚构函数重写的两个例外情况:
1.协变:
此时基类与派生类虚函数返回值类型可以不同,但是返回值必须是父子关系的指针和引用
一个虚函数返回值是指针,一个是引用这样也不行 但是一个返回的是父的,一个返回的是子的没关系
2.派生类重写虚函数可以不加
virtual
(但是建议加上)
总问题: 析构函数可以是虚函数吗?为什么需要是虚函数?
析构函数加virtual,是不是虚函数重写?
是,因为类析构函数都被处理成destructor这个统一的名字为什么要这么处理呢?
因为要让他们构成重写
那为什么要让他们构成重写呢?
因为下面的场景
(Person是基类,Student是派生类) Person* p = new Person; p->text(); delete p; p = new Student;//注意:这里的p还是Person类的 p->text(); delete p; // p->destructor() + operator delete(p) // 这里我们期望p->destructor()是一个多态调用,而不是普通调用
多态的构成条件
1.必须通过基类的指针或者引用调用虚函数(注意是基类!!!)
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
注意:多态调用看的是指向的对象,普通的调用看的是当前的类型
eg: class Person{
public:
virtual void text(){}
};
class Student : public Person{
public:
virtual void text(){}//--a
};
void func(const Person& p)
{
p.text();
}
main函数里面func(Student());是调用的a的
问题:
1.为什么必须要是父类的指针或引用,而不是父类对象或者子类的指针或引用
(编译器把这几种行为
ban
了的原因)原因:
1.不能是父类对象的原因:
不会拷贝子类的虚表和其他特有的,所以这个父类对象根本不知道子类的存在(指针和引用就可以避开这一点)
编译器选择不拷贝子类的虚表指针的原因:
害怕别人不知道父类对象虚表中是父类的还是子类的
2.不能是子类指针或引用:
怕去访问到父类中没有的成员
引申:
1.子类虚表的构建:
子类继承父类时,会先复制一份父类的虚表。如果子类没有重写父类的虚函数,那么虚表中对应函数指针就指向父类虚函数实现;若子类重写了某个虚函数,就会用子类自己的虚函数地址覆盖虚表中从父类继承来的对应函数指针。
2.子类赋值给父类对象切片,不会拷贝虚表,父类还是会要自己的虚表
override 和 final(C++11提出)
final
作用:1.修饰虚函数,表示该虚函数不能再被重写
2.使用
final
关键字修饰类,直接禁止任何类继承它eg: class Person final{};
用法:eg:virtual void text() final {}(前有无virtual不重要哈)
引申:一个有final一个无final也能构成重载和隐藏
override
作用:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Person{
public:
virtual void text(){}
};
class Student :public Person {
public:
virtual void text() override {}
};
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
概念:
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
作用:强制要求派生类重写虚函数,另外抽象类体现出了接口继承的关系
比如:class Car { public: virtual void Drive() = 0; };
接口继承和实现继承
普通函数的继承是一种实现继承,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
多态的原理
虚函数表(也叫做虚表)
包含虚函数的类会有虚函数表指针,虚函数表指针指向的是虚函数表的地址
虚函数表里面存了虚函数的指针
引申:函数不符合多态,编译时就确定地址了 符合多态,运行时到指向对象的虚函数表中找调用函数的地址
注意:同一个类的所有实例对象共享同一个虚函数表
比较特殊的是:VS编译器的虚表指向的地址后面会有0作为结束(可以用内存窗口看)
比如:
但是在进行增量编译之后,可能这个0就没了,这时候需要清理一下解决方案或者重新生成解决方案才行
引申:虚表的打印
虚表本质上是函数指针数组
typedef void(*FUNC_PTR) ();
//这里就是将 一个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");
}
int main()
{
Student st;
int vft2 = *((int*)&st);
//这个强转之后就一次++指++(int)个字节的东西了;而且这个32位上正好一个指针4个字节,正好读完
//注意:Linux是64位的!!!
PrintVFT((FUNC_PTR*)vft2);
//发现隐式类型转换会报错,就改成强转了
return 0;
}
注意:成员变量的变化会导致虚表的打印出错–因为可能会影响到内存布局
虚表和虚基表都是在编译阶段生成的
对象实例化之后,才会与虚表有联系(通过虚表指针)
多态的原理
核心的实现机制就是虚函数表和虚指针
满足多态的话,子类的虚指针指向的虚表中的虚函数就会覆盖父类的虚函数的地址,然后调用的就是子类的虚函数了
静态多态和动态多态
静态多态,又叫静态绑定,前期绑定(早绑定),在程序编译期间就确定了程序的行为
比如:函数重载
动态多态又称为动态绑定,后期绑定(晚绑定),是在程序运行期间才确定调用什么函数的
也就是继承+虚函数重写实现的多态
在默认情况下,多态一般指的是动态多态
多继承中的虚函数表
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;
cout << sizeof(d) << endl;
//X86环境下,这个占20个字节,组成:两个基类(都是一个虚表指针加一个成员变量)加一个成员变量
Base1* ptr1 = &d;
ptr1->func1();
Base2* ptr2 = &d;
ptr2->func1();//通过修正this指针,来让this指针指向派生类的头
return 0;
}
问题:为什么重写func1,Base1和Base2的虚表中func1的地址不一样?
Base2中func1的地址不一样是为了jmp去修正this指针的位置
注意:
1.多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中(其实是末尾)
作业部分
设计不想被继承类,如何设计?
方法1:基类构造函数私有 (C++98)
方法2:基类加一个final (C++11)
方法1:eg:
class A
{
public:
static A CreateObj()//这个static不能去掉,不然就不能通过域名去调用了
{
return A();
}
private:
A()
{}
};//当然,用析构函数这么搞也行哈
int main()
{
A::CreateObj();
return 0;
}
方法2:
class A final
{}
这里常考一道笔试题:sizeof(Base)是多少?(X86环境下的话)
答案:8个字节//不是一个字节,也不是四个字节
要注意的是:类里面还有一个虚函数表指针(_vfptr)
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
char _b = 1;
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
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[])//相当于int main()
{
B* p = new B;
p->test();
return 0;
}
结果:输出B->1
引申:如果把test()放在了B里面的话,就应该输出B->0了
因为此时this->func()的this不是父类指针,不构成多态
派生类那里不用加virtual的原因:
本质上只重写了实现
面试常考题:
1.什么是多态?–静态多态和动态多态都要答
2.inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是
inline,因为虚函数要放到虚表中去。
3.静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数
的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。(语法也会强制检查这个,会报错)
4.构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表
阶段才初始化的。
5.对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针
对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函
数表中去查找。
6.虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况
下存在代码段(常量区)的。