虚函数,虚指针,虚表,虚析构函数和多态

 

目录

虚函数(virtual function)

纯虚函数

虚指针(vptr)

虚表(vtable)

多态(Polymorphism)

多态,虚函数,虚指针和虚表的关系

虚函数调用逻辑,以及怎么实现动态绑定详解(通过代码示例和图解)

虚函数表是什么时候生成的:

代码示例(virtual.cpp)

 源码

编译​编辑

通过gdb调试查看变量地址

查看对应变量指针如下(图a)

代码解释以及图示说明

代码说明

根据gdb查看对象指针推理调用逻辑如下图(图b)

结论

虚析构函数

源码示例1 :基类的析构函数不是虚析构函数

编译运行

结论

源码示例1 :基类的析构函数是虚析构函数

编译运行

结论

补充

对象切割

构造函数为什么不能是虚函数


虚函数(virtual function)

虚函数指的是类的带有virtual关键字修饰的类的成员函数。

纯虚函数

纯虚函数是一种特殊的虚函数,在基类中没有具体实现,而是在派生类中去写具体实现,一般在函数签名后使用=0作为此类函数的标志。

纯虚函数写法如下:

class base
{
public:
    virtual void test()=0;
};

虚指针(vptr)

虚指针是对象的一个隐藏的指针成员变量,存在于每个包含虚函数的类的对象中

虚表(vtable)

虚表是一个存储了类的虚函数地址的查找表。

多态(Polymorphism)

多态,就是存在虚函数的基类和存在一个或者多个基于该基类(并且实现了该基类的虚函数)的派生类,当一个基类指针指向不同的派生类时,通过基类指针调用虚函数,会有不同的实现,从而实现虚函数的多态性质。

多态,虚函数,虚指针和虚表的关系

1:多态是基于虚函数的来实现的

2:虚函数的实现依靠虚指针和虚表

3:每个具有虚函数的类(或其父类)都会有一个相应的虚表,虚指针的调用依靠虚表。当通过虚指针调用虚函数时,程序首先会根据对象的类型查找虚表,并根据虚表中的指针执行相应的函数。

4:当一个类包含虚函数时,每个对象实例都会包含一个指向虚函数表(vtable)的指针。这个指针通常被称为虚函数指针(vptr),它位于对象的起始地址。虚函数表本身通常是一个全局的数据结构,包含了该类的虚函数的地址。

因此,对于含有虚函数的类,每个对象的内存布局通常包括:实际的成员变量,这些变量属于类的非虚部分。
一个指向虚函数表的指针,即虚函数指针(vptr)。
这个虚函数指针允许在运行时进行动态绑定,使得通过基类指针或引用调用虚函数时可以正确地调用派生类的实现。因此,含有虚函数的类的对象在内存中会多出一个指针用于支持动态多态性。

总结:虚指针和虚表是一种实现动态多态性的机制。它们允许以统一的方式处理不同类型的对象,并在运行时动态地分派正确的函数实现。这种机制在面向对象编程中常用于实现继承、多态和运行时多态性。

5:指向派生类的基类指针调用非虚函数的调用逻辑

当通过指向派生类的基类指针调用非虚函数时,编译器将使用指针的静态类型(即指针声明时的类型)来确定要调用的函数。这被称为静态绑定或早期绑定。具体的调用逻辑如下:

编译器确定函数位置: 编译器使用指针的静态类型查找基类中相应的非虚函数。这是在编译时完成的。

函数调用: 根据静态类型确定的函数位置,编译器生成对应的函数调用。这将导致调用基类中的函数,而不考虑实际运行时对象的类型。

以下是一个简单的例子:

#include 

class Base {
public:
    void nonVirtualFunction() {
        std::cout << "Base non-virtual function" << std::endl;
    }
};

class Derived : public Base {
public:
    void nonVirtualFunction() {
        std::cout << "Derived non-virtual function" << std::endl;
    }
};

int main() {
    Base baseObj;
    Derived derivedObj;

    Base* ptr = &derivedObj;  // 指向派生类的基类指针

    // 静态绑定,调用基类的非虚函数
    ptr->nonVirtualFunction();  // 输出: Base non-virtual function

    return 0;
}

在这个例子中,通过指向派生类 Derived 的基类 Base 指针 ptr 调用 nonVirtualFunction,由于静态绑定,将调用基类 Base 中的 nonVirtualFunction,而不考虑指针实际指向的对象类型。

6:当类中含有虚函数的时候,创建该类的对象时,该对象的首地址即为虚函数表的地址,无论对该对象进行怎样的类型转换,该对象都只能访问自己的虚函数表

7:虚函数的使用原则:可以把public或protected的部分成员函数声明为虚函数;
                 C++中的析构函数通常是虚析构函数;
                 构造函数不能声明为虚函数;
                 虚函数不能声明为静态的、全局的、友元的。

8:子类必须重载overload父类里的纯虚函数,这句话是错的,应该是重写override

虚函数调用逻辑,以及怎么实现动态绑定详解(通过代码示例和图解)

虚函数表是什么时候生成的:

虚函数表(vtable)是在编译阶段由编译器生成的,用于支持 C++ 中的动态绑定(运行时多态)。

在 C++ 中,当一个类包含至少一个虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个指针数组,其中每个指针指向相应虚函数的实现。每个包含虚函数的类都有其自己的虚函数表。

生成虚函数表的过程发生在编译阶段,编译器为每个包含虚函数的类生成虚函数表,并将其存储在对象的内存布局的开始部分。当类被实例化为对象时,对象的内存中的虚函数指针(vptr)被初始化为指向该类的虚函数表。

这个虚函数表允许在运行时进行动态绑定。当使用基类指针或引用调用虚函数时,实际调用的是指向派生类的虚函数表的相应虚函数。这使得在运行时确定调用的是哪个函数,从而实现了多态性。

代码示例(virtual.cpp)

 源码

#include 
#include 
class base
{
private:
    int m_iNum = 1;
public:
    virtual int getNum() const
    {   
        std::cout <<"line:"<<__LINE__<<":is base getNum"<getNum();
    std::cout <<"line:"<<__LINE__<<":"<< num << std::endl;
    num = pBa->getNum2();
    std::cout <<"line:"<<__LINE__<<":"<< num << std::endl;

    base bat;
    void** vtablePtr = *(void***)&bat; //获取基类虚函数表的地址  虚指针指向的是虚表 ,虚表的内容是一个指针数组,数组的每一个成员对应一个虚函数的地址
    int (*func1Ptr)() = reinterpret_cast(vtablePtr[0]);//利用reinterpret_cast地址转换成第一个虚函数的地址(格式是函数指针)
    func1Ptr();
    int (*func1Ptr2)() = reinterpret_cast(vtablePtr[1]);//获取第二个虚函数的地址
    func1Ptr2();

    derived de1;
    void** vtablePtrDev2 = *(void***)&de1;//获取派生类虚函数表的地址
    int (*func1PtrDev2)() = reinterpret_cast(vtablePtrDev2[0]);//获取第一个虚函数的地址
    func1PtrDev2();
    int (*func1PtrDev3)() = reinterpret_cast(vtablePtrDev2[1]);//获取第一个虚函数的地址
    func1PtrDev3();


    void** vtablePtrDev = *(void***)&de;//获取派生类虚函数表的地址
    int (*func1PtrDev)() = reinterpret_cast(vtablePtrDev[0]);//获取第一个虚函数的地址
    func1PtrDev();
    int (*func1PtrDev1)() = reinterpret_cast(vtablePtrDev[1]);//获取第一个虚函数的地址
    func1PtrDev1();

    std::cout <<"line:"<<__LINE__<<":end"<

编译

执行:

虚函数,虚指针,虚表,虚析构函数和多态_第1张图片

通过gdb调试查看变量地址

虚函数,虚指针,虚表,虚析构函数和多态_第2张图片

查看对应变量指针如下(图a)

虚函数,虚指针,虚表,虚析构函数和多态_第3张图片

                                                                         图a

代码解释以及图示说明

代码说明

virtual.cpp源码中,设计了两个类

  • 基类 base 的定义:

    • base 类包含一个私有成员变量 m_iNum,并提供了三个成员函数:getNumgetNum2getNumNoVir
    • getNumgetNum2 是虚函数,用 virtual 关键字进行声明,这意味着它们可以在派生类中被重写。
    • getNumNoVir 不是虚函数,不带有 virtual 关键字。
  • 派生类 derived 的定义:

    • derived 继承自 base,并覆盖了基类的虚函数 getNum
    • derived 包含一个私有成员变量 m_iNum
  • main 函数:

    • 创建了一个 derived 类的对象 de
    • 创建了一个指向基类的指针 pBa,该指针指向 de 对象。
    • 通过 pBa 调用虚函数 getNum,由于pBa 指向 de 对象,因此实际上调用了 derived 类的 getNum
    • 输出调用结果。
  • 虚函数表操作以bat为例说明:

    • 使用 void** vtablePtr = *(void***)&bat; 获取基类对象 bat 的虚函数表地址。这里使用了一些底层的指针操作,将对象指针转换为指向虚函数表的指针。
    • 通过 reinterpret_cast 将虚函数表中的地址转换为函数指针,并调用相应的函数。这是一种手动模拟虚函数调用的方式。
    • 类似的操作被用于派生类对象 de 和另一个基类对象 bat1
根据gdb查看对象指针推理调用逻辑如下图(图b)

虚函数,虚指针,虚表,虚析构函数和多态_第4张图片

                                                                   图b

结论

        结合代码以及图a中查看的变量值分析,在代码中,pBa 指针指向了 de 对象,这是向上转型的过程。但要注意,这并没有改变 de 对象本身,只是让基类指针指向了派生类对象。当通过 pBa 指针调用 getNum 函数时,由于 pBa 指向的是 de 对象,从而虚指针也是de对象的虚指针,也就是调用的是 derived 类中实现的虚函数 getNum。C++ 中的多态性是通过虚函数表来实现的,每个类有一个虚函数表,对象存储一个指向该类虚函数表的指针。因此,通过基类指针可以实现对不同派生类对象的调用,从而实现了运行时的多态性。

虚析构函数

所谓的虚析构函数就是在析构函数的前面就上关键字virtual

如果基类没有虚析构函数,并且通过基类指针删除派生类对象,可能会导致未定义行为或内存泄漏。派生类中的析构函数可能不会被调用,从而导致派生类特有的资源没有被正确释放。

源码示例1 :基类的析构函数不是虚析构函数

#include
class base
{
public:
    base()
    {
        std::cout<<"base constructor"<

编译运行

虚函数,虚指针,虚表,虚析构函数和多态_第5张图片

结论

这里基类的析构函数不是虚析构函数,导致derived的析构函数没有调用,从而成员变量m_pI也没有释放,从而导致了内存泄露。

源码示例1 :基类的析构函数是虚析构函数

#include
class base
{
public:
    base()
    {
        std::cout<<"base constructor"<create();
    delete pA;
    return 0;
}

编译运行

虚函数,虚指针,虚表,虚析构函数和多态_第6张图片

结论

这里基类的析构函数是虚析构函数,derived的析构函数有调用,从而成员变量m_pI也有释放。

补充

对象切割

对象切割(Object Slicing)是指当将派生类对象赋值给基类对象时,只会复制基类部分的成员,派生类特有的成员将被丢失。这通常发生在使用值语义(value semantics)的情况下,例如通过赋值操作符或传递参数的方式。

源码示例

#include 
#include 

class Base {
public:
    int baseValue;

    Base(int value) : baseValue(value) {}

    void display() {
        std::cout << "Base: " << baseValue << std::endl;
    }
};

class Derived : public Base {
public:
    int derivedValue;

    Derived(int base, int derived) : Base(base), derivedValue(derived) {}

    void display() {
        std::cout << "Derived: " << baseValue << ", " << derivedValue << std::endl;
    }
};

int main() {
    Derived derivedObj(10, 20);
    Base baseObj = derivedObj; // 对象切割发生在这里

    baseObj.display(); // 调用的是基类的 display 函数,而不是派生类的

    return 0;
}

在这个例子中,Derived 类从 Base 类继承,并添加了一个额外的成员 derivedValue。在 main 函数中,Derived 类对象 derivedObj 被赋值给 Base 类对象 baseObj。这个过程中发生了对象切割。

结果是,baseObj 只包含 Base 部分的成员,而 Derived 类的特有成员 derivedValue 被丢失。因此,当调用 baseObj.display() 时,虽然 baseObj 实际上是一个 Derived 类对象,但只会调用 Base 类的 display 函数,而不是 Derived 类的版本。

为了避免对象切割,可以使用指针或引用。例如:

Derived derivedObj(10, 20);
Base* basePtr = &derivedObj; // 指针没有对象切割的问题

basePtr->display(); // 调用的是 Derived 类的 display 函数

在使用指针或引用时,实际上引用的是派生类对象,而不会发生对象切割。

构造函数为什么不能是虚函数

1. 从存储空间角度,虚函数对应一个指向vtable虚函数表的指针,这大家都知道,可是这个指向vtable的指针其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,怎么找vtable呢?所以构造函数不能是虚函数。
2. 从使用角度,虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
3. 构造函数不需要是虚函数,也不允许是虚函数,因为创建一个对象时我们总是要明确指定对象的类型,尽管我们可能通过实验室的基类的指针或引用去访问它但析构却不一定,我们往往通过基类的指针来销毁对象。这时候如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
4. 从实现上看,vbtl在构造函数调用后才建立,因而构造函数不可能成为虚函数从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有必要成为虚函数。
5. 当一个构造函数被调用时,它做的首要的事情之一是初始化它的VPTR。因此,它只能知道它是“当前”类的,而完全忽视这个对象后面是否还有继承者。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码——既不是为基类,也不是为它的派生类(因为类不知道谁继承它)。所以它使用的VPTR必须是对于这个类的VTABLE。而且,只要它是最后的构造函数调用,那么在这个对象的生命期内,VPTR将保持被初始化为指向这个VTABLE, 但如果接着还有一个更晚派生的构造函数被调用,这个构造函数又将设置VPTR指向它的 VTABLE,等.直到最后的构造函数结束。VPTR的状态是由被最后调用的构造函数确定的。这就是为什么构造函数调用是从基类到更加派生类顺序的另一个理由。但是,当这一系列构造函数调用正发生时,每个构造函数都已经设置VPTR指向它自己的VTABLE。如果函数调用使用虚机制,它将只产生通过它自己的VTABLE的调用,而不是最后的VTABLE(所有构造函数被调用后才会有最后的VTABLE)。

你可能感兴趣的:(c++,c++基础,STL,c++)