C++中调用虚函数都是动态绑定吗

多态在面向对象编程中是一个重要的概念,一般是使用虚函数来实现的,原理就是通过虚函数表(vtbl)保存了一个类的所有虚成员函数指针,在调用虚函数时,可以通过对象的虚函数表指针(vptr)从虚函数表中获取相应的函数指针,并进行调用,因为函数地址是通过函数指针来访问的,所以在编译时是不知道函数入口地址的,只能在运行时通过访问虚函数表来定位。

为了方便分析说明,先定义几个有继承关系的类:

// 基类
class base {
public:
	base() {}

	base(int) {
		foo(); // 在构造函数中调用虚函数
	}

	virtual void foo() {
        puts("base foo");
	}

    ~base() {
        foo(); // 在析构函数中调用虚函数
    }

    virtual void f() {}
};

// 派生类
class derived1 : public base {
public:
    derived1() {
        f(); // 在构造函数中调用虚函数
    }

	virtual void foo() {
        puts("base derived1");
	}
	
	virtual void bar() {
		foo(); // 调用另一个虚函数
	}
};

// 派生类,final修饰
class derived2 final : public derived1 {
public:
	virtual void foo() {
        puts("base derived2");
	}
	
	virtual void bar() {
		foo(); // 调用另一个虚函数
	}
};

// 静态函数
static base *get_static() {
	return new derived2;
}

// 在库中定义的函数
extern base *get_extern();

我们以上面的代码为例,先看一下虚函数多态调用时最为常见的应用场景:

void func(base &b) {
	b.foo();
}

参数b是一个基类base类型的引用,按照C++的继承语义,base的派生类对象都可以作为参数传入,因此,编译器在编译时不知道参数实际是指向的哪一个具体类型,为了能够调用到正确的虚函数版本,会采用动态绑定的方式。下面是编译后产生的汇编代码:

func(base&):
        sub     rsp, 8
        mov     rax, QWORD PTR [rdi] // 获取vptr
        call    [QWORD PTR [rax]] // 调用虚函数
        add     rsp, 8
        ret

调用这个函数时,寄存器rdi存放的是this指针,通过寄存器间接寻址得到虚函数表指针vptr(this位置偏移0处,vptr指向虚函数表vtbl),存放到寄存器rax中,再一次通过寄存器间接寻址得到虚函数表的地址入口(虚函数表vtbl位置偏移0处存放的是base::foo的函数指针),然后调用虚函数。显然,使用动态绑定时,程序在运行时需要两次间接寻址才能调用到真正的成员函数,相当于一次二维指针的解引用过程,会造成 CPU 的cache miss,更为重要的是编译器不知道函数是哪一个,无法进行内联优化。因为函数入口不明确,在函数调用时,就无法进行内联,不能进行优化。

不过,并不是在调用虚函数时编译器都要采用动态绑定的方式,在某些场景下还是可以选择静态绑定的方式,也就意味着可以进行内联优化。下面讨论一下这些静态绑定的场景。

1、以值对象的形式调用

derived1 d;
d.foo();

尽管foo()是virtual函数,但是因为d对象的类型在编译时是明确的,是derived1类型,因此,编译器选择使用静态绑定的方式来调用它。下面是相关的汇编代码片段:

call    derived1::derived1() // 构造derived1对象
lea     rax, [rbp-24]
mov     rdi, rax // rdi是this指针
call    derived1::foo() // 静态绑定

2、对象的创建时类型明确
当使用一个引用类型的来指向一个对象时,如果该引用对象的创建过程对编译器是可见的,编译器能够确定对象的类型,使用该引用来调用虚函数时,可以采用静态绑定的方式。

2.1 创建对象和使用对象在同一个作用域内

base *b = new derived2;
b->foo();

尽管变量 b 的声明类型是指向base类型的指针,实际指向的是 derived2 类型,通过它来调用虚函数 foo() 时,按说应该使用动态绑定机制,但是,编译器在编译过程中是能够分析出 b 指向的实际类型是 derived2,因此编译器在优化时可以采用静态绑定的方式,直接选择 derived2 中的 foo() 函数。
下面是打开优化选项 -O1 时,gcc 生成的汇编代码片段。

        call    operator new(unsigned long)
        mov     rdi, rax
        mov     QWORD PTR [rax], OFFSET FLAT:vtable for derived2+16 // 构造函数内联了,此处初始化vptr
        call    derived2::foo() // 静态绑定

2.2 被调用函数可以内联inline展开
看下面的代码片段:

void func(base &b) {
	b.foo();
}

调用方代码片段:

derived1 d;
func(d);

前面已经分析过,void func(base&)内部要使用动态绑定的方式调用foo()成员函数,但是如果void func(base&)能够内联的话,该代码片段展开为:

derived1 d;
d.foo();

显然,内联后的代码形式就属于 2.1 中的场景,编译器在编译时能够明确d的类型为derived1,此处就使用静态绑定的方式。

2.3 创建对象的函数可以内联展开
有时候基类对象指针/引用是由一个函数返回的,比如工厂方法,如果编译器能够知道它的实现过程,而且函数比较短小、简单,可以进行内联优化。如下面的例子:

base *b = get_static();
b->foo();

创建base对象的工厂方法 get_static() 是使用 static 修饰的,它与调用它的代码肯定是在同一个编译文件中,因此编译器是可以见到这个函数的实现,可以对它进行了内联优化,优化后的代码形式就属于 2.1 中的场景,也就是能够确定 get_static() 返回的是哪一个 base 的派生类型,此时可以选择使用静态绑定。
下面是打开优化选项 -O1 时,gcc 生成的汇编代码片段。

call    operator new(unsigned long) // get_static()被内联优化了
mov     rdi, rax
mov     QWORD PTR [rax], OFFSET FLAT:vtable for derived2+16
call    derived2::foo()

为了对比,看一下另一种场景:

base *b = get_extern();
b->foo();

创建 base 对象的工厂方法 get_extern() 是在别的编译单元中定义的,也可能是在已经编译好的库文件中,编译器不知道创建对象的细节,无法进行内联,也就是说在使用它返回的 base 类型的指针调用虚函数时,编译器并不知道它引用的是哪一个具体对象类型,因此只能采用动态绑定的方式。
下面是打开优化选项 -O1 时,gcc 生成的汇编代码片段:

call    get_extern()
mov     rdi, rax // this指针
mov     rax, QWORD PTR [rax] // 得到vptr
call    [QWORD PTR [rax]] // 动态绑定

3、在本类的成员函数中

3.1 在构造函数和析构函数中
当在构造函数和析构函数中调用虚函数时,都是采用的静态绑定,如果本类提供了虚函数,则选择本类中的虚函数版本,否则选择离它最近的父类中的虚函数版本。

看一下派生derived1的构造函数,在构造函数中调用了虚函数f(),但是自己没有自己重写版本,编译器在编译器时选择了它的父类的版本,属于静态绑定。下面是编译后的汇编代码:

derived1::derived1() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    base::base() [base object constructor] // 调用基类构造函数
        mov     edx, OFFSET FLAT:vtable for derived1+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    base::f() // 静态绑定
        nop
        leave
        ret

看一下基类 base 的析构函数,在析构函数中调用了虚函数 foo(),编译器在编译时选择了它自己实现的版本,属于静态绑定。下面是编译后的汇编代码:

base::~base() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     edx, OFFSET FLAT:vtable for base+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    base::foo() // 静态绑定
        nop
        leave
        ret

至于具体实现机制可参考“构造函数和析构函数中调用虚函数是多态吗”一文。

3.2 在 final 修饰的类中
我们看一下上面的 derived1 类和它的派生类 derived2,定义了虚函数 bar(),在里面都调用了虚函数foo(),但是派生类 derived2 是使用final修饰的,意思是它不会有派生类了,因此这里就意味着如果使用 derived2* (类型指针调用 foo() 时,肯定调用的是derived2 版本的 foo() 函数,不可能是别的版本,因此,编译器是能够明确在derived2::bar()中选择的是 derived2::foo() 。如果 derived2 自己没覆盖 foo() 函数,也会使用静态绑定的方式调用离它最近的父类版本的 foo() 函数。

derived2::bar():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    derived2::foo()  //  静态绑定
        nop
        leave
        ret

但是,对于derived1类,它没有使用final修饰,意味着它可能有派生类,如果使用derived1 *类型指针调用foo()时,它有可能指向的是它的一个派生子类对象,此时,编译器在编译时是不知道使用的是哪个foo()版本,只能采用动态绑定的方式。

derived1::bar():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     rax, QWORD PTR [rbp-8]
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    rdx // 动态绑定
        nop
        leave
        ret

3.3 在 final 修饰的 virtual 函数
在本类和派生类的成员函数中调用final修饰的虚函数时,都会使用静态绑定的方式。根据 final 的语义,该类的派生类中不可能 override 这个虚函数了。因此,编译器在编译时很明确这个函数的版本,直接采用静态绑定。

值得注意的是,如果在类中 virtual 函数是使用 private 修饰的,在本类的成员函数中调用它时,编译器是使用动态绑定的,也许大家可能认为这个函数是 private,只能自己访问,子类无法访问,可以使用静态绑定。但是,因为C++中virtual和private没有关系,只要基类的成员函数被注明是virtual,不管是何种可见性修饰符(public, protected, private),都可以被子类override重写,因此这种情况下仍采用动态绑定机制。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值