9

虚指针是什么玩意?fat pointer in GO/Rust vs thin pointer in C++

 3 years ago
source link: https://zhuanlan.zhihu.com/p/259875110
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

虚指针是什么玩意?fat pointer in GO/Rust vs thin pointer in C++

微信公众号:CrackingOysters

稍微深入学过C++的都应该听说虚指针,虚函数表(虚表)。而这也经常作为许多公司C++面试的题目。记得当年我毕业的时候也看了一下这玩意究竟是什么。

那么虚函数表究竟是什么呢?从使用C++的角度看,虚函数,虚函数表,是为了实现动态派发(dynamic dispatch)。知道这一点就够应付90%的情况了。那么什么是动态派发呢?

动态派发是相对于静态派发而言。静态派发就是指在编译期[1]就能知道要调用的函数具体是哪一个。比如下面代码的f(3)

#include <iostream>
#include <string>
void f(int i) {
  std::cout<<"running f() with integer"<< i << std::endl;
}
void f(std::string s) {
  std::cout<<"running f() with string"<< s << std::endl;
}
int main() {
  f(3); //调用的是 f(int) 而不是f(std::string);
}

在程序编译的时候,f(3)确定调用的就是f(int)而不是f(string)。所以程序运行的时候,输出的是

running f() with integer3

感兴趣的可以看看main对应的汇编代码,从中可以清晰地看到f(int)被调用了(看不懂可以直接跳过汇编。后续再写一篇如何读懂简单的汇编代码)

而动态派发就是编译期”不知道“要调用的是哪个函数,只有等到程序运行,跑到这一行代码才知道要调用的是哪个函数。比如下面的d->f(3)

class Base {
public:
  virtual void f(int i) {
      
  }
};
class Derive: public Base {
public:
  virtual void f(int i) override {

  }
};

int main() {
    Base* d = new Derive();
    d->f(3);
    
}

上面的d->f(3)只有等到程序运行到这一行代码的时候才会确定调用的函数是Base::f(int)还是Derive::f(int)。有些人可能会说,那肯定是调用Derive::f(int)。上面这个例子是人肉眼可以知道的(肉眼不知道的例子也很多,比如库代码),但是程序并不知道。实际上这个程序也不知道它在动态派发。

那么程序知道的是什么呢?答:它只知道它在执行一些固定的模式指令,而这些模式指令达到了动态派发的目的。这些模式指令具体细节取决于作用的类型(比如这里d对应Base),但是它们的步骤是确定:(请结合下面的图一起阅读)

  1. 编译器知道变量Base* d是一个指针,它指向Heap的一块内存区域(绿色开头的,也就是C++的对象)(几乎都在Heap,你要硬搞不在heap, C++也可以做到)。
  2. 首先沿着d指针找到这块内存区域。
  3. 接着读取这块内存区域的头部内容(绿色部分, 也就是虚指针vptr)。
  4. 接着沿着这个虚指针到达另外一块内存区域(蓝色区域,也就是虚函数表vtble,简称虚表)。
  5. 然后根据偏移量(这个偏移量是由我们调用的函数确定),移动到虚表的某个位置,这个位置存储的内容就是我们要调用的函数的地址 f_addr。
  6. 最后根据这个地址调用函数,也就是运行指令 call f_addr。

仔细品品这些指令模式,就会明白为什么叫动态派发。因为程序不运行的时候,d的值,是不知道的,不知道d,那么我们就不可能找到下一个我们要去的内存区域。所以只有动态运行程序,我们才能确定调用的是哪个具体函数。这些模式指令可以轻松的理解为:当你写了d->f(3)的时候,编译器会把这个函数调用,转换成在vtble[i](3),也就是在虚函数表偏移特定位置,然后读取函数地址,最后调用这个函数。

为什么需要动态派发?

从前面的讲解,我们知道了什么是动态派发,那么为什么要动态派发呢?它在编程里面起到了什么作用?

首先看一个C/C++的一个不用虚指针和虚表实现动态派发的例子。假设我们有一个函数, 接收一个参数shape*,函数会根据这个参数实际是circle还是rectangle,确定怎么绘制这个形状。

enum SHAPE {
    enumCircle,
    enumRectangle
};
struct shape {
    SHAPE type;

};
void draw_circle(shape* s) {

}
void draw_rectangle(shape* s) {

}
void draw(shape* s) {
    if (s->type == enumCircle) {
        draw_circle(s);
    } else if (s->type =enumRectangle) {
        draw_rectangle(s);
    }
}
int main() {
    shape s = {enumCircle};
    draw(&s);
}

我们看到在draw(shape* s)函数里面,需要手动检查type然后确定执行是draw_circle还是draw_rectangle。设想一下你有上百个形状,那么你就要写100多个if else,多么壮观的场面。这么写代码,可维护性,可阅读性,可扩展性都不好。

C++,为了解决这样的问题,Stroustrup(C++之父)秉着C++的设计原则

C++ implementations obey the zero-overhead principle: What you don't use, you don't pay for. And further: What you do use, you couldn't hand code any better.
-- Stroustrup

加上借用其他语言的思想,设计了虚函数和虚表来实现动态派发。所以上面的例子使用了虚表和虚函数指针以后,可以改写成

class Shape {
    virtual void draw() = 0;
};
class Circle: public Shape {
    virtual void draw() override {

    }
};
class Rectangle: public Shape {
    virtual void draw() override  {

    }
};
void draw(Shape* s) {
    s->draw();
}
int main() {
    Shape* s = new Circle();
    draw(s);
    Shape* sr = new Rectangle();
    draw(sr);
}

修改过的代码,draw()函数只需要直接调用Shape* s的draw()就可以了。不需要作if else的检查来动态派发。那是因为有了虚函数指针和虚表,编译器通过前文所讲的模式指令帮我们做了动态派发。

所以动态派发,可以在某种程度上让我们编写阅读性高,可维护性好和可扩展性好的代码。

明白了动态派发,也就是明白了虚指针,也明白了虚表。

下面探讨一下C++是如何排列虚指针,虚表(又称C++对象的内存布局),以及什么叫胖瘦指针。

C++对象布局

我们将C++的对象从上图摘取出来,可以看到的Derive对象除了包含的数据以外,还包含了一个虚指针。

这个虚指针指向虚表,如下图,

虚表存储了这个对象的实际虚函数地址。它的作用请看前文的模式指令。

明白了这个简单的例子,我们就可以理解多继承,虚继承了。网上已经有大量的好的文章讲解,如果感兴趣,请看
Memory Layout of C++ Object in Different Scenarios - Vishal Chovatiya

https://web.archive.org/web/20160413064252/http://www.phpcompiler.org/articles/virtualinheritance.html
C++ Virtual Functions

还有一个要注意的点,那就是我们怎么在这些类型中进行cast,也就是如何理解static_cast, dynamic_cast?

类型继承与类型转换,容易忘记的dynmaic_cast

假如我们有下面的继承关系,那么哪些类型对象之间可以互相转换呢?以及是使用static_cast还是dynamic_cast?

请问以下转换是否成立

Bottom* bottom = new Bottom();
Left* left = bottom;
Right* right = bottom;
Left* left = right;
Bottom* bot = left;

提示:父类的对象内容会按顺序平铺在子类的对象中。如下图

bottom对象的布局

从栈上指针到对象,从虚指针到虚表,从虚表偏移这三个步骤都是确定的。只有虚指针具体的值,虚表的具体内容是随着栈上指针的变化而变化。理解了这个提示,你就会豁然开朗!其他的细节问题都是慢慢填充一下就可以了!

现在用汇编填充一下细节,不感兴趣的,可以直接跳过。

查看bottom的值,也就是栈上指针的值

(lldb) p bottom
(Bottom *) $7 = 0x0000000100304120

接着查看bottom指向的Bottom对象的值

(lldb) x/16a 0x0000000100304120
0x100304120: 0x0000000100004030 m`vtable for Bottom + 16
0x100304128: 0x0000000000000000
0x100304130: 0x0000000100004048 m`vtable for Bottom + 40
0x100304138: 0x0000000000000000

从上面的汇编可以看到对象的第一个地址指向Bottom的vtable + 16,接下来是0,这个是Left部分的值,感兴趣的读者可以求证一下。接下来是指向Bottom的vtable + 40。所以我们知道Bottom的vtable的地址是0x0000000100004030 - 16 = 0x0000000100004020。那么让我们看看Bottom的vtable包含了什么内容

(lldb) x/16a 0x0000000100004020
0x100004020: 0x0000000000000000
0x100004028: 0x0000000100004070 m`typeinfo for Bottom
0x100004030: 0x0000000100003f60 m`Left::f() at multi-inheritance.cpp:5
0x100004038: 0xfffffffffffffff0
0x100004040: 0x0000000100004070 m`typeinfo for Bottom
0x100004048: 0x0000000100003f70 m`Right::g() at multi-inheritance.cpp:12
<省略若干>

http://godbolt.org里面输出的一致!

vtable for Bottom:
        .quad   0
        .quad   typeinfo for Bottom
        .quad   Left::f()
        .quad   -16
        .quad   typeinfo for Bottom
        .quad   Right::g()

试着结合图形思考一下汇编,那么你就会茅塞顿开!(还是有疑问欢迎评论一下,从而我可以修改一下措辞,使之更明白)

dynamic_cast就是负责从父类向子类转换或者父类1向父类2转换,需要借助运行时的数据(typeinfo)。一般比较少用。

Go/Rust都有虚表,但是它们跟C++的区别在于它们栈上的指针大小不一样。从上文分析可以看到C++栈上的指针是一个地址的大小(x64上面是8字节)。而Go/Rust的栈上指针却是两个地址的大小:一个指针指向vtble,一个指针指向data member。如下图,

所以人们称C++的是瘦指针,Go/Rust的是胖指针。各有优劣。

(未完待续)

参考文献:

What is a "fat pointer" in Rust?

Exploring Dynamic Dispatch in Rust

Golang interface value: fat pointer again?

Learn Rust fat pointer and type erasure from a Cpp programmer's perspective

DST, Take 5

  1. ^编译期:程序编译的时候发生的。C++运行程序,先要编译代码,然后再运行二进制。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK