C++多态与虚函数的原理解析
一,派生类指针与基类指针的互相转换
1,基本原理
参考文章《C++继承关系中,深度解析类内存布局与多态的实现》。可以知道,派生类的内存布局中包含基类子对象。那么可以通过指针的偏移完成派生类指针与基类指针的互相转换。
- 派生类指针可以隐式/显式转换为基类指针
(派生类对象一定包含基类子对象,一定可以转换,所以可以隐式转换,更可以显式转换)
- 基类指针可以显式转换为派生类指针
(基类对象不一定是派生类的子对象,转换时需要开发人员显式进行转换,由开发人员保证该基类对象属于某派生类对象)
- 派生类的多重继承的基类指针之间不可以互相转换
(这种转换时的指针偏移不明确,在不同的多重继承情况下,被不同的派生类同时多重继承时,可能有不同的偏移量,并且从业务角度这种转换没有现实意义)
2,代码验证
#include <iostream>
#include <string>class N1
{int m_A;
};class N2
{int m_A;
};class N3 : public N1, public N2
{int m_A;
};
int main() {// 派生类指针可以隐式/显式转换为基类指针N3* n3_1 = new N3;N1* n1_1 = n3_1;N2* n2_1 = static_cast<N2*>(n3_1);// 基类指针可以显式转换为派生类指针N3* n3_2 = static_cast<N3*>(n1_1);//基类指针之间不可以互相转换//编译器报错误 E0171 类型转换无效//N2* n2 = static_cast<N2*>(n1_1);std::cout << "派生类指针地址:" << (int)n3_1 << std::endl;std::cout << "基类N1指针地址:" << (int)n1_1 << ",派生类地址相同,说明派生类对象与N1子对象的起始地址相同" << std::endl;std::cout << "基类N2指针地址:" << (int)n2_1 << ",派生类地址相差4字节,说明派生类对象与N2子对象的起始地址相差了一个N1子对象的大小" << std::endl;
}
运行后输出:
3,不明确的转换
当派生类的内存布局中有多个同类型的基类子对象时,进行派生类与基类的指针转换时会报转换不明确的错误。
#include <iostream>
#include <string>class N1
{int m_A;
};class N2 : public N1
{int m_A;
};class N3 : public N1, public N2
{int m_A;
};
int main() {N3* n3_1 = new N3;// 错误 C2594:从“N3 * ”到“N1* ”的转换不明确//N1* n1_1 = n3_1;//N1* n1_2 = static_cast<N1*>(n3_1);
}
使用虚继承可以解决这个问题,使用虚继承可保证派生类对象只创建一个基类子对象的实例。可以保证基类指针到派生类指针之间的转换关系是明确且唯一的。
在虚继承中,从派生类到基类指针的转换,可以在编译时确定,因为通过派生类指针可以访问到派生类虚基类指针,再根据虚基类指针访问到虚基类表,虚基类表中可以确定派生类带虚基类的指针偏移量。但这一过程不可逆。因为从虚基类的角度,访问不到虚基类表指针,也无法访问到虚基类表。所以会导致无法在编译时通过static_cast静态的确定偏移量,所以,使用static_cast对虚基类指针到派生类指针的转换会报编译错误。
为了解决这个问题,可以使用dynamic_cast在运行时通过RTTI进行转换。这需要借助多态技术,在基类中定义一个虚函数,这会在虚基类对象中,维护一个虚函数表指针,通过虚函数表,可以获取到从基类对象到派生类对象的偏移量,那么就可以完成从虚基类对象的指针到派生类对象的指针的转换了。
#include <iostream>
#include <string>class N1
{int m_A;virtual void M1() {};
};class N2 : virtual public N1
{int m_A;
};class N3 : virtual public N1, public N2
{int m_A;
};
int main() {N3* n3_1 = new N3;// 使用虚继承可保证派生类对象只创建一个基类子对象的实例N1* n1_1 = n3_1;N1* n1_2 = static_cast<N1*>(n3_1);// 错误:E0288 无法将指向基类 "N1" 的指针转换为指向派生类 "N3" 的指针 -- 基类是虚拟的//N3* n3_2 = static_cast<N3*>(n1_2);// 通过dynamic_cast,在运行时通过RTTI进行转换N3* n3_2 = dynamic_cast<N3*>(n1_2);std::cout << "派生类地址:" << (int)n3_1 << std::endl;std::cout << "虚基类地址:" << (int)n1_1 << std::endl;std::cout << "虚基类动态转换为派生类的地址:" << (int)n3_2 << std::endl;
}
上述代码中,N3的内存布局为:
ConsoleApplication1.cppclass N3 size(20):+---0 | +--- (base class N2)0 | | {vbptr}4 | | m_A| +---8 | m_A+---+--- (virtual base N1)
12 | {vfptr}
16 | m_A+---N3::$vbtable@:0 | 01 | 12 (N3d(N2+0)N1)N3::$vftable@:| -12 // 虚函数表指针在整个对象中的偏移量0 | &N1::M1 // 函数地址
vbi: class offset o.vbptr o.vbte fVtorDispN1 12 0 4 0
二,虚函数与虚函数表
虚函数会在类内部生成虚函数表指针,虚函数表指针指向虚函数表。虚函数表是编译器在编译时生成的静态数据结构,它存储在程序的可执行文件中,并在程序加载时映射到内存的只读数据段。所有同类型的类对象共享同一个虚函数表。
在有虚函数的起始类对象中,会创建虚函数表指针。
- 若派生类未重写基类虚函数,派生类的虚函数表中保留基类函数的地址(指向基类实现)。
- 若派生类重写了基类虚函数,派生类的虚函数表中对应条目更新为派生类函数的地址(覆盖基类地址)。
- 若派生类新增虚函数,新虚函数的地址会追加到虚函数表末尾。
虚函数表的存在,导致无论通过派生类指针还是通过基类指针去调用虚函数,调用的函数地址和传入的this指针都是一样的。函数地址就是重写的函数地址,this指针就是重写函数所在的类的指针。
下面根据代码做详细说明:
#include <iostream>
#include <string>class N1
{
public:int m_A;virtual void M1() {std::cout << "&N1::M1(),m_A:" << m_A << std::endl;}virtual void M2() = 0;void M3(){std::cout << "&N1::M3(),m_A:" << m_A << std::endl;}
};class N2 : public N1
{
public:int m_A;void M1() override { std::cout << "&N2::M1(),m_A:" << m_A << std::endl;}void M2() override{std::cout << "&N2::M2(),m_A:" << m_A << std::endl;}
};class N3 :public N2
{
public:int m_A;void M2() override{std::cout << "&N3::M2(),m_A:" << m_A << std::endl;}void M3(){std::cout << "&N3::M3(),m_A:" << m_A << std::endl;}
};int main()
{// 初始化N3 n3;n3.m_A = 3;n3.N2::m_A = 2;n3.N2::N1::m_A = 1;std::cout << "通过作用域调用函数:" << std::endl;// 通过作用域运算符(如n3.N2::M1())调用虚函数时,会绕过虚函数表,直接进行静态绑定。n3.M1(); // 调用函数&N2::M1(),this指针是N2子对象的指针。n3.N2::M1(); // 调用函数&N2::M1(),this指针是N2子对象的指针。n3.N2::N1::M1(); // 调用函数&N1::M1(),this指针是N1子对象的指针。n3.M2(); // 调用函数&N3::M2(),this指针是N3对象的指针。std::cout << std::endl << "通过虚函数表调用函数:" << std::endl;N1* n1ptr = &n3;n1ptr->M1(); // 通过虚函数表,调用函数&N2::M1(),this指针是N2子对象的指针。n1ptr->M2(); // 通过虚函数表,调用函数&N3::M2(),this指针是N3对象的指针。n1ptr->M3(); // 函数地址静态绑定,调用函数&N1::M3(),this指针是N1对象的指针。
}
N3的虚函数表的情况如下:
ConsoleApplication1.cppclass N3 size(16):+---0 | +--- (base class N2)0 | | +--- (base class N1)0 | | | {vfptr}4 | | | m_A| | +---8 | | m_A| +---
12 | m_A+---N3::$vftable@:| &N3_meta| 0 // 从虚函数表指针到整个派生类对象指针之间的偏移量0 | &N2::M11 | &N3::M2N3::M2 this adjustor: 0
三、纯虚函数与抽象类
纯虚函数不需要有函数实现,纯虚函数声明为:
virtual 返回值类型 函数名(参数列表) = 0;
含有纯虚函数的类称为抽象类。抽象类不能实例化为对象。继承自抽象类的派生类需要重写这个纯虚函数,否则也是抽象类。
四、虚析构函数
在将派生类指针赋值给基类指针对象时,在使用delete删除基类指针时,编译器会将整个派生类给清理掉。但是不会调用派生类的析构函数,所以,需要写虚析构函数。
1,delete操作会释放掉完整分配的内存大小
C++在使用基类指针清理内存时,会将整个派生类对象的内存清理掉。
验证代码为:
#include <iostream>
#include <vector>
#include <cstdlib> // 包含 malloc 和 free// 使用链表避免递归分配问题
struct AllocationRecord {void* ptr;size_t size;AllocationRecord* next;
};AllocationRecord* alloc_head = nullptr;void* operator new(size_t size) {// 使用 malloc 而不是 ::operator new 避免递归void* ptr = malloc(size);// 创建记录节点(使用 malloc 避免递归)AllocationRecord* rec = static_cast<AllocationRecord*>(malloc(sizeof(AllocationRecord)));rec->ptr = ptr;rec->size = size;rec->next = alloc_head;alloc_head = rec;std::cout << "[ALLOC] " << size << " bytes at " << ptr << "\n";return ptr;
}void operator delete(void* ptr) noexcept {if (!ptr) return;// 查找并移除记录AllocationRecord** prev = &alloc_head;AllocationRecord* current = alloc_head;size_t freed_size = 0;while (current) {if (current->ptr == ptr) {freed_size = current->size;*prev = current->next;free(current); // 释放记录节点break;}prev = &(current->next);current = current->next;}std::cout << "[FREE] " << freed_size << " bytes at " << ptr << "\n";free(ptr); // 释放实际内存
}// 原始类定义保持不变
class N1 {
public:int m_A;virtual void M1() = 0;virtual void M2() = 0;
};class N2 : public N1 {
public:int m_B;void M1() override { std::cout << "N2::M1\n"; }void M2() override { std::cout << "N2::M2\n"; }
};class N3 : public N2 {
public:int m_C;void M1() override { std::cout << "N3::M1\n"; }
};int main() {std::cout << "Sizes:\n";std::cout << "N1: " << sizeof(N1) << " bytes\n";std::cout << "N2: " << sizeof(N2) << " bytes\n";std::cout << "N3: " << sizeof(N3) << " bytes\n\n";N1* n1Ptr = new N3;std::cout << "\nDeleting...\n";delete n1Ptr;// 清理剩余的分配记录(如果有)while (alloc_head) {AllocationRecord* temp = alloc_head;alloc_head = alloc_head->next;free(temp);}
}
2,虚析构函数
delete操作不会调用派生类的析构函数。C++在使用基类指针清理内存时,会将整个派生类对象的内存清理掉。但不会调用派生类的析构函数。使用虚析构可以解决这个问题。使用虚析构时,会调用所有的虚析构的函数。
使用虚析构时,需要将最底层基类的析构函数增加virtual关键字。这样在整个派生结构中的所有析构函数都会被调用。
虚析构的常用的定义:
virtual ~类名(){std::cout << "N1的析构函数" << std::endl;}
// 虚析构,使用默认实现
virtual ~类名() = default;
class N1
{
public:int m_A;// 纯虚析构,对应的,类为抽象类virtual ~N1() = 0;
};// 纯虚析构,会将类编程抽象类,但是仍然需要析构函数的实现,否则编译器会报错。
N1::~N1()
{
}