剖析C++中继承、多态的底层原理
继承的底层原理主要涉及内存布局、虚函数表、类型转换等机制。
一、内存布局:
继承的底层实现是通过内存布局来完成的。派生类对象的内存布局通常包括:
a.基类子对象:派生类对象中包含基类的所有数据成员。
b.派生类特有的数据成员。
例如:
class Base
{
public:
int a;
};
class Derived : public Base
{
public:
int b;
};
Derived对象的内存布局如下:
+---------+
| Base::a | // 基类子对象
+---------+
| Derived::b | // 派生类特有的数据成员
+---------+
多重继承同理。在Derived对象的内存布局中多了另一个基类的对象。基类子对象按照继承的顺序排列。
二、虚函数表(vtable)
当类中有虚函数时,C++会为每个类生成一个虚函数表,通过虚表实现动态绑定(多态)
虚函数表是一个函数指针数组,意味着虚函数表中存放虚函数的地址,或者说虚函数的入口。
当派生类继承基类时,派生类会继承基类的虚函数表。如果派生类重写了基类的虚函数,派生类的虚函数表中对应的条目会被更新为派生类的对应函数的地址。如果派生类没有重写基类的虚函数,派生类的虚函数表中仍然保留基类的函数地址。
每个包含虚函数的类对象中都会包含一个指向虚函数表的指针(vptr),这个指针在对象创建时被初始化,指向该类的虚函数表。派生类对象中也会包含自己的vptr,指向派生类的虚函数表,而不是基类的虚函数表。当派生类对象被创建时,vptr会被初始化为指向派生类的虚函数表。
举个例子:
#include <iostream>
class Base
{
public:
virtual void foo()
{
std::cout << "Base::foo()" << std::endl;
}
virtual void bar()
{
std::cout << "Base::bar()" << std::endl;
}
};
class Derived : public Base
{
public:
void foo() override
{
std::cout << "Derived::foo()" << std::endl;
}
};
int main()
{
Base* ptr = new Derived;
ptr->foo(); // 调用 Derived::foo()
ptr->bar(); // 调用 Base::bar()
delete ptr;
return 0;
}
Base类的虚函数表:
+-------------------+
| Base::foo() 地址 |
+-------------------+
| Base::bar() 地址 |
+-------------------+
Derived类的虚函数表:
+-------------------+
| Derived::foo() 地址 | // 重写了 Base::foo()
+-------------------+
| Base::bar() 地址 | // 未重写 Base::bar()
+-------------------+
Derived对象的内存布局:
+---------+
| vptr | // 指向 Derived 的虚函数表
+---------+
| Base 部分 |
+---------+
调用过程:
当ptr->foo()被调用时:
编译器通过ptr找到对象的vptr,因为ptr是一个指向Derived对象的基类指针,所以编译器通过ptr找到的是Derived对象的vptr。
接着,通过vptr找到Derived的虚函数表,调用Derived::foo().
类似地,当ptr->bar()被调用时:
编译器通过ptr找到Derived对象的vptr,通过vptr找到Derived的虚函数表
由于没有重写,因此Derived的虚函数表中存放的只有Base::bar()的地址。调用Base::bar().
几个疑惑的点:
1.怎么理解“地址晚绑定”?
其意思是:在派生类中重写了虚函数后,才能确定这个函数具体的地址。
2.怎么理解“基类指针 指向 派生类对象”?某个类型的指针不是只能指向该类型的变量吗?如果使用派生类指针指向派生类对象会有什么后果?
在C++中有一种特殊的机制,允许基类指针指向派生类对象。这种机制是通过向上转型实现的。
向上转型(Upcasting)是指将派生类指针或引用转换为基类指针或引用。这是安全的,因为派生类对象中包含基类子对象。
例如:
Derived d;
Base*ptr=&d;//向上转型
为什么基类指针可以指向派生类对象?因为派生类对象的内存布局中包含基类子对象。基类指针指向的是派生类对象中的基类子对象部分。
也就是说,当Base*ptr=&d时,ptr指向的是Derived对象中的Base子对象部分,而不是指向Derived对象中特有的数据成员。
当基类中有虚函数时,通过基类指针调用虚函数会根据对象的实际类型(派生类)调用正确的函数实现。需要说明的是,通过基类指针调用虚函数时,实际调用的是派生类中重写的虚函数。当通过基类指针调用虚函数时,程序通过对象的vptr找到虚函数表(因为创建对象的时候,vptr就被初始化指向虚函数表了),然后从虚函数表中查找对应虚函数的地址,最后调用该地址指向的函数。
如果我们向下转型(基类指针转换为派生类指针),需要使用dynamic_cast进行安全检查。
Base* ptr = new Derived;
Derived* dptr = dynamic_cast<Derived*>(ptr); // 向下转型
if (dptr)
{
dptr->foo();
}
如果派生类指针指向派生类对象,并不会有什么负面的后果。并且派生类指针可以直接访问派生类中定义的所有成员(包括从基类继承的成员和派生类特有的成员),而基类指针只能访问派生类中从基类继承的成员。
通过基类指针调用虚函数时,会根据对象的实际类型(派生类)调用正确的函数实现。而派生类指针不需要通过虚函数表间接调用函数,直接调用派生类的成员函数。这也是它不能实现多态性的原因。编译器会根据指针的静态类型(派生类)直接绑定到派生类的函数实现。
3.虚函数表占用内存吗?为什么在Derived对象的内存布局中没有见到虚函数表?
虚函数表确实占用内存,但它并不直接存储在对象的内存布局当中。虚函数表是编译器为每个包含虚函数的类生成的一个全局数据结构,存储在程序的只读数据段。每个类都有自己的虚函数表,而不是每个对象都有自己的虚函数表。
对象的内存布局中只包含数据成员和指向虚函数表的指针,该指针指向的是类的虚函数表,而不是对象的虚函数表(类对象的内存布局中不包含虚函数表)。
#include <iostream>
class Base
{
public:
virtual void foo()
{
std::cout << "Base::foo()" << std::endl;
}
int a;
};
class Derived : public Base
{
public:
void foo() override
{
std::cout << "Derived::foo()" << std::endl;
}
int b;
};
int main()
{
Derived d;
std::cout << "Size of Derived: " << sizeof(d) << std::endl;
return 0;
}
Base类的虚函数表:
+-------------------+
| Base::foo() 地址 |
+-------------------+
Derived类的虚函数表:
+-------------------+
| Derived::foo() 地址 | // 重写了 Base::foo()
+-------------------+
Derived对象中的内存布局:
+---------+
| vptr | // 指向 Derived 的虚函数表
+---------+
| Base::a | // 基类数据成员
+---------+
| Derived::b | // 派生类数据成员
+---------+
在64位系统上,sizeof(d)的大小为16,因为vptr占用8字节(指针大小),Base::a占用4字节,Derived::b占用4字节。
虚函数表不在对象的内存布局中,这样避免了空间浪费。一个类有一个虚函数表就可以了,对象通过虚函数指针访问即可。