C++ 虚函数表及调用规范

很久没写博客了,最近实验室项目赶进度,有点忙不过来,先把之前的坑填上。

在支付工具想做社交,即时通讯工具想做app市场,英语字典想做新闻社交的今天,创造这些怪象的公司要求程序员懂得更多几乎是理所当然的,毕竟现在大家什么都想做。这不,正值招聘季,实验室的几位学长也是一直在讨论各种问题,发现对于C++而言,问的最多的还是虚函数表和STL。

STL的考点至少是实用的,哪怕要求你读过源码,也并不过分,毕竟知根知底才能更好地应用。但要求程序员掌握对象模型着实拎不清,因为这几乎用不到,远没有在设计模式上投入时间实在,或许它们最希望的是拿批发价招语言专家。。。

我已经近2年没用C++了,大三时下定觉心不再碰C++,因此原本这个博客里是不应该出现任何C++相关的内容的,然而读研后,实验室项目就是C++写的,只能拾起继续用,唯一的区别只是我不再会花大把的时间钻研其实现和语法规则了,人生苦短。

经历过n次考前“临阵磨枪”后,我得到了一个结论:对待用不到的知识,最好的方式是遗忘,区别只是让它保留多久。

既然招聘的时候会考虚函数表,我又用不到,那就只能把它记录下来,以便届时快速记忆。现在关于C++ vtab的文章早已烂大街,别太较真,笔记而已。

环境?

这里提及环境的原因有2点:

  1. 调用规范(call convention)和环境相关
  2. 虚函数的具体实现和具体的编译器相关

本文的环境为Linux x86_64,编译器为GCC 6.1。

虚函数表

对于面向对象的支持,使得C++的抽象能力相对于C有了长足的进步,而抽象能力的改进为复用(reuse)提供了有力支撑。封装,继承和多态3个特性中,最容易实现的是封装,其次是继承,涉及到了多重继承这个“大坑”,它们都能够在编译时直接确定,但是多态则正好相反,根本无法在编译时确定该调用哪个函数,所以需要找到一种方法使得程序在运行期间能够正确定位函数。

想要实现这个特性,需要先明确“变”与“不变”这两个关键要素:“变”的是实例的类型,“不变”的是实例的地址。类型无法确定,且由于重写(override)的引入,使得编译器无法准确从符号表中定位函数的地址。那么就只能从“不变”的部分入手了,由于地址是不变的,因此我们至少有如下几种方式来实现:

  1. runtime维护一个表,地址作为key,定位类型或者函数列表
  2. 实例头部持有一个标识,使其能够定位成员函数列表
  3. 实例持有指向对应函数列表的指针
  4. 实例直接持有函数列表

方案1使得class和struct的存储模型几乎一致,但是函数调用的开销会大一些,方案2和方案3本质上是一样的,方案4占用空间大,C++采用的是方案3。原则上应该避免让runtime持有数据,因为这样会在使用动态链接库时出现麻烦,暂时没有深究C++是怎么解决这个问题的,简单来看,多个runtime由于地址空间不重合,函数列表的地址必然不同,故而只需保证函数列表的内容一致即可,假如有不同的版本,那么结果可能会让人非常苦恼。

书归正传,C++实例持有的虚指针称为VPTR,指向的虚函数表称为VTAB,它是函数地址列表。

对于如下单继承的情况,测试代码如下:

class A {
public:
    virtual void f1() { cout << "A::f1" << endl; }
    virtual void f2() { cout << "A::f2" << endl; }
};

class B : public A {
public:
    void f1() { cout << "B::f1" << endl; }
};

typedef void (*Func)();
int main() {
    B tt;
    Func* vptr = *(Func**)&tt;
    vptr[0]();
    vptr[1]();
    return 0;
}

该段用例反馈的存储模型大致如下图:

SD_VTAB.png

可见,B类实例的头部包含了一个指向VTAB的指针,接下来就可以依靠基址+偏移的方式进行选择调用了。

对于如下多重继承的情况,测试代码如下:

class B1 {
public:
    virtual void fooB1() { cout << "B1::foo" << endl; }
    virtual void barB1() { cout << "B1::bar" << endl; }
};

class B2 {
public:
    virtual void fooB2() { cout << "B2::foo" << endl; }
    virtual void barB2() { cout << "B2::bar" << endl; }
};

class D : public B1, B2 {
public:
    void fooB1() { cout << "D::foo" << endl; }
    void barB2() { cout << "D::bar" << endl; }
};

typedef void (*Func)();
int main() {
    D tt;
    Func* vptr1 = *(Func**)&tt;
    Func* vptr2 = *((Func**)&tt + 1);
    vptr1[0]();
    vptr1[1]();
    vptr2[0]();
    vptr2[1]();
    return 0;
}

该段用例反馈的存储模型大致如下图:

MD_VTAB.png

和单继承的情况差不多,只不过包含多个VPTR罢了,VPTR的数量和继承的类型数量一致。

关于菱形继承中的虚继承,在虚函数表数量方面与普通的多重继承并没有什么不同。

在VTAB的内容方面,则会变得更加复杂,我们以一段代码进行阐述:

class Base {
public:
    virtual void foo() { cout << "Base::foo" << endl; }
    virtual void bar() { cout << "Base::bar" << endl; }
};

class A : virtual public Base {
public:
    virtual void foo()  { cout << "A::foo" << endl; }
    virtual void bar1() { cout << "A::bar1" << endl; }
};

class B : virtual public Base {
public:
    virtual void foo()  { cout << "B::foo" << endl; }
    virtual void bar2() { cout << "B::bar2" << endl; }
};

class Test : public A, public B {
public:
    virtual void foo()  { cout << "Test::foo" << endl; }
    virtual void bar1() { cout << "Test::bar1" << endl; }
    virtual void bar3() { cout << "Test::bar3" << endl; }
};

Test虚函数表的情况大致如下:

MMD-VTAB.png

变化大致如下所述:

  1. 存在2个VPTR,这里另其指向的虚函数表分别为VTAB1和VTAB2
  2. Test类新增的bar3函数的指针会被扩展到VTAB1的末尾,注意此时VTAB1可以同时作为Test和A(转型后)的虚函数表
  3. Test类的VTAB2[2]位置保存的是bar2的指针,需要VTAB1的前2个元素和VTAB2[2]共同表示B(转型后)的虚函数表

可见,g++或多或少对于虚继承情景下的类的虚函数表是有区别对待的。

调用规范

事实上并不存在绝对的标准,这也是为何为调用规范称为Call Convention而不是Call Standard的原因。常见的调用规范有:

  1. cdecl
  2. stdcall
  3. fastcall
  4. thiscall

通常它们都是可选的,我们总有方式告知编译器该怎么做,这里我并不准备详细介绍它们的区别,因为wiki写的相当详尽。

C的调用规范是十分简洁的,GCC默认为cdecl,在x86上通过压栈来传参,x86_64估计是因为寄存器数量多了,因此允许通过寄存器传递6个整型参数及8个浮点型参数,其余继续通过压栈解决,但这通常足以覆盖绝大多数情况。

那么,C++的调用规范呢?在给出答案前,需要先回答一个问题,以便更好地理解,C++相比C多了什么?

我的答案是更多的抽象层级,命名空间、嵌套的命名空间、类空间、成员函数等等,它们都是新出现的抽象层级,于是编译器在符号表中寻找符号时就不可避免的会出现优先级这个概念。所幸,绝大部分都可以通过优先级来解决,唯独成员函数有其特殊性。

成员函数不同于类函数(过度使用的static),后者是属于类本身的,能够访问类空间的静态变量和函数,以及类所属的外层命名空间的变量及函数,类函数其实和C的函数没什么区别,无需惊讶,事实就是如此,因此其调用规范使用cdecl即可。

问题在于成员函数是属于类实例的,确切地说只能通过类实例来完成对成员函数的调用,原因在于成员变量因实例而异,可以回想一下this指针,this作为一个隐式参数进行传递,没有它便访问不了成员变量了。

在这种情况下就会使用thiscall,它有点特殊,因为它没有明确的定义,既可以是stdcall加强版,通过rcx传递this指针,也可以是cdecl,通过将this作为第一个参数。

GCC的thiscall基本就是cdecl,即this指针作为第一个参数。下面我们通过实例验证:

class Test {
    int num = 999;
public:
    void dis(int n) { cout << this->num << n << endl; }
};

typedef void (*OFunc)(void*, int);

int main() {
    void (Test::* fdis) (int) = &Test::dis;
    OFunc of = (OFunc)fdis;
    Test test;
    Test* pt = &test;
    of(pt, 0);
    return 0;
}

虽然编译器向我们“抱怨”了,但这并不影响最终结果,可以发现最终成员函数dis的类型被强制转型后也能够工作,这符合预期。

其实,所谓的类型不过是供编译器进行类型检查罢了,之后的过程就只有符号这个概念,而不会出现类型了,这也正是C++的符号如此恶心的原因之一,函数重载和干净的符号,C++选择了前者。

写在最后

知道了上述内容后,我们获得了什么?更加熟悉了C++的虚函数表(废话),然后呢?有些知识,了解后真的就只代表“你知道”,但你很可能永远都用不到,除非去设计程序语言的对象模型。

相反,这只会增加开发者使用奇技淫巧的可能性,对于寻常软件而言,使用奇技淫巧并不是一件好事情,而杜绝这类状况发生的最好方式应该就是彻底屏蔽细节。在任何环境下,都不要试图使用上文中的技巧,因为你很可能会得到一个无法移植难以调试的程序。

已有 2 条评论
  1. 表示已经看不懂了

    1. 我也很久没深究C++了,实验室的项目基本也是各种C++特性“混用”

说两句: