C++的静态绑定和动态绑定、虚函数表的理解

发布于:2024-04-04 ⋅ 阅读:(26) ⋅ 点赞:(0)

C++的静态绑定和动态绑定、虚函数表的理解

概念

在C++中,静态绑定(Static Binding)和动态绑定(Dynamic Binding)是两种不同的函数调用机制,主要涉及到如何根据对象的类型来选择执行哪个函数的决定过程。这两种机制在多态性的实现中扮演着关键角色。动态绑定是面向对象编程中实现多态性的关键机制,允许代码在更高的抽象层次上运行,提高了代码的复用性和扩展性。而静态绑定则因其高效性,在不需要多态性的情况下,依然是一个很好的选择。
下面我将通过代码来详细说明这个内容。

静态绑定
静态绑定,也称为早期绑定,指的是函数调用在编译时期就已经确定了。在这种情况下,调用哪个函数是根据指针或引用的 静态类型(即编码时指定的类型,而不是运行时的实际类型) 来决定的。静态绑定适用于非虚函数(non-virtual functions)。

  • 优点:效率高,因为函数调用的决策在编译时就已经完成了,不需要在运行时进行查找。
  • 缺点:缺乏灵活性,不能根据对象的实际类型来调用对应的函数,不利于实现多态性。

动态绑定

动态绑定,也称为晚期绑定,是指函数调用在运行时确定。这通常是通过虚函数(virtual functions)机制来实现的。当一个函数在基类中被声明为虚函数后,派生类可以重写(Override)这个函数,创建自己的实现。如果通过基类的指针或引用调用这个函数,那么实际调用的版本 将根据对象的实际类型(运行时的类型) 来确定。

  • 优点:提供了灵活性,允许多态性的实现。可以根据对象的实际类型调用相应的函数,即使是在基类的指针或引用上调用。
  • 缺点:效率相对较低,因为需要在运行时进行类型检查和函数查找。

注意点

什么是绑定

"绑定"指的是函数调用与函数实现之间的关联,在C++中,这种绑定可以是静态的(编译时决定)或动态的(运行时决定)。

  • 静态绑定发生在一个函数调用与一个函数实现之间。这里,函数的调用是基于调用对象的静态类型(即声明时的类型)来决定的。也就是说,编译器在编译时就决定了会调用哪个函数;
  • 动态绑定发生在一个虚函数调用与多个可能的函数实现之间。在这种情况下,函数的调用是基于调用对象的实际类型(即运行时对象的类型)来决定的。也就是说,运行时系统(而不是编译器)在运行时决定了会调用哪个函数。

静态绑定和动态绑定如何判断

不加virtual:静态绑定

当一个成员函数没有被声明为virtual时,其绑定方式是静态的。这意味着函数调用会在编译时解析,基于对象的静态类型(即代码中声明的类型)。这种方式适用于大多数普通函数调用,它能提供更高的执行效率,因为调用的函数在编译时就已经确定,无需在运行时进行额外的查找。

如果你调用一个非虚函数,C++编译器会根据调用该函数的对象的类型(更准确地说,是指针或引用的类型)来确定应该调用哪个函数。

静态绑定适用于当你不需要多态性,即在编译时就能确定调用哪个函数的场景。

加virtual:动态绑定

当在一个类的成员函数前加上virtual关键字时,你是在告诉编译器:“请在运行时决定调用哪个函数”。这允许所谓的“后期绑定”或“动态绑定”,意味着如果有任何派生类重写了该虚函数,那么通过基类指针或引用调用该函数时,将调用对象实际类型的函数实现,而不是其静态类型的函数实现。(下面会介绍其中的原理——虚函数表)
动态绑定是实现多态性的关键,特别是当你想通过基类的接口来调用派生类的实现时。

关键点

一旦在基类中声明了一个函数为virtual,所有派生类中的同名函数都自动成为虚函数,即使在派生类中没有显式地使用virtual关键字。

构造函数不能是虚函数。静态成员函数也不能是虚函数,因为静态成员函数不依赖于类的任何特定实例。

代码举例

#include <iostream>

class A
{
public:
    A();
    A(int a):m_a(a){};
    void printValue()
    {
        std::cout<<"A: "<<m_a<<std::endl;
    }

private:
    int m_a;
};

class B:public A
{
public:
    B(int a,int b):A(a),m_b(b){};
    void printValue()
    {
        std::cout<<"B: "<<m_b<<std::endl;
    }
private:
    int m_b;
};

int main()
{
    A* objA=nullptr;
    B* objB=new B(10,20);
    objA=objB;
    objA->printValue(); // 输出A: 10
    return 0;
}

上面这段代码中,objA是指向A类型的指针,也就是它的静态类型是A*,虽然在下面有领objA指向了objB,但是printValue函数在A类中没有被声明为virtual,因此调用该函数的时候使用的静态绑定,也就是编译器会根据调用该函数的对象的静态类型(即A*)来决定调用A的printValue函数,而不是运行时实际指向的对象类型(B)调用B的printValue函数。

因此,最终调用的是A的printValue函数。

如何解决呢?定义为虚函数即可

#include <iostream>
class A
{
public:
    A();
    A(int a):m_a(a){};
    virtual void printValue()
    {
        std::cout<<"A: "<<m_a<<std::endl;
    }

private:
    int m_a;
};

class B:public A
{
public:
    B(int a,int b):A(a),m_b(b){};
    void printValue() override
    {
        std::cout<<"B: "<<m_b<<std::endl;
    }
private:
    int m_b;
};

int main()
{
    A* objA=nullptr;
    B* objB=new B(10,20);
    objA=objB;
    objA->printValue(); // 输出B: 20
    return 0;
}

这段代码中,我将printValue函数定义为了虚函数,因此编译器会在运行时决定调用哪个printValue。由于运行的时候objA实际指向的对象类型是B,因此便会调用B的printValue函数。

原理就是下面的虚函数表!

虚函数表

首先要明确 “虚函数表” 的概念:每一个具有虚函数的类都有一个自己的虚函数表,这个表是编译器生成的“数组”,里面保存的是指向类中虚函数的指针(地址);派生类的虚函数表和基类的虚函数表都是单独存在的,并且内容默认与基类一样,但是当发生如下两种情况的时候,派生类的虚函数表会变化:

  1. 当派生类重写了基类的虚函数时;
  2. 当派生类中新增了虚函数时。
    因此,上面的代码中,类A的虚函数表是这样的
    在这里插入图片描述

由于在类B中重写了虚函数,因此类B的虚函数表是这样的
在这里插入图片描述

不管是基类还是派生类,每个类的对象的成员都会有一个指向自家虚函数表的指针,也就是虚指针,虚指针通常是对象内存布局的第一个成员(为了确保不管类的成员如何变化,虚指针的位置不变),一个对象调用虚函数的时候,首先会访问它的虚指针,然后根据虚指针指向的位置找到虚函数表,然后在虚函数表中找到要调用的函数的入口地址,然后根据此地址去调用对应的函数实现。
所以,对于类A,加上虚指针的图就是这样的(类B的图把其中的A和a换成B、b即可)。
在这里插入图片描述
因此,上面的代码执行时,过程是这样的
发现有virtual关键字,为动态绑定——运行的时候发现objA指向的对象是objB——访问objB的虚指针找到虚函数表——在虚函数表中找到printValue函数的入口地址——跳转到入口地址执行printValue函数

总结

从原理上看,虚函数只是多了一次寻址,但是实际应用中使用虚函数会比普通函数开销大很多,原因主要是:

  • 虚函数的使用场景往往是运行时,即编译时无法确定,这意味着调用虚函数的路径中会有很多编译器无法优化的跳转和分支;
  • 虚表多了一层寻址,这种间接寻址更容易导致缓存不命中,需要频繁访问内存,大大降低了访问效率;
  • 虚函数往往无法进行内联优化,而普通成员函数可以以内联的方式提高程序运行效率。

根据以上描述,非必要的情况我们不使用虚函数,但是反过来,只要是有用到虚函数的场景,比如运行时确定、必要的多态,那就毫不犹豫地使用虚函数,这套机制远比大多数自己实现的运行时多态要好得多。

也就是,虚函数该用就用,没必要用就不用!


网站公告

今日签到

点亮在社区的每一天
去签到