当前位置: 首页 > article >正文

剖析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字节。

虚函数表不在对象的内存布局中,这样避免了空间浪费。一个类有一个虚函数表就可以了,对象通过虚函数指针访问即可。


http://www.kler.cn/a/598902.html

相关文章:

  • 前端会话控制技术:cookie/session/token
  • 哈尔滨工业大学DeepSeek公开课人工智能:大模型原理 技术与应用-从GPT到DeepSeek|附视频下载方法
  • Sql Server数据迁移易错的地方
  • HarmonyOS Next~鸿蒙系统功耗优化体系解析:前台交互与后台任务的全场景节能设计
  • 红数码影视(RED Digital Cinema)存储卡格式化后的恢复方法
  • AF3 rot_matmul 和 rot_vec_mul函数解读
  • 跨平台数据集成:从SQLServer到MySQL的高效实践
  • QT 图表(拆线图,栏状图,饼状图 ,动态图表)
  • Python散点密度图(Scatter Density Plot):数据可视化的强大工具
  • SysVinit和Systemd的系统运行级别
  • others-rustdesk远程
  • 卷积神经网络(Convolutional Neural Network,CNN)
  • c++二分查找模板
  • 【详细解决】pycharm 终端出现报错:“Failed : 无法将“Failed”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。
  • 爬虫案例-爬取某站视频
  • 13、STL中的unordered_set使用方法
  • 单片机写的小液晶屏驱动+汉字滚屏
  • 前端性能优化利器:CDN 技术详解与实战
  • Elasticsearch未授权访问漏洞
  • Redis Cluster 详解