爱技术 & 爱分享
爱蛋蛋 & 爱生活

Class Function

Nonstatic Nonvirtual Member Function

C++的设计准则之一就是:nonstatic nonvirtual member function 至少必须和nonmember function有相同的效率。也就说在此种情况下,class不会给function带来额外的开销。

要做到这一点,编译器一般会经过以下几个转换步骤:
1. 改写函数签名,将此class的this指针作为一个额外的参数插入到参数列表中。

Point3d::magnitude();
会被转换为↓
Point3d::magnitude(Point3d * const this);

const Point3d::magnitude();
会被转换为↓
Point3d::magnitude(const Point3d * const this);
  1. 对函数内的所有nonstatic data member 的操作都改为经由this完成。
  2. 将函数重写成一个外部函数。函数名经过mangling处理,使其变得独一无二。

至此一个nonstatic nonvirtual member function 就变的跟nonmember一样了,只不过编译器在背后做了一些处理。

Name Mangling

成员变量或者成员函数在符号表中都有一个独一无二的名称,这个名称若不进行特殊处理则可能产生冲突以及调用上的不明确。

比如 class A{public: int val;...}class B : public A{public: int val;...}。此时B中会有两个val,一个来自A一个来自B,要分辨出val的归属,name mangling一般会在member的名称前加上class名称。

但是因为function是可以重载的,所以此时只采用这个方法是不够的。

比如这两个member function void x(float newX)float x()如果只加class名称的话,这两个member function会拥有相同的名称,所以此时还需要加上他们的参数列表(参数个数,参数类型,参数顺序),此时就可以得到一个独一无二的函数名。并且C++标准规定函数的重载只能根据参数列表来区分,所以这里不需要将返回类型编进名称。

当然这并不是标准,只不过是目前大多数编译器采用的方法。

Static Member Function

  1. 没有 this 指针
  2. 不需要经由class object调用,可直接使用class scope operator调用
  3. 会被提出于class声明之外,并经过mangled(包含class 名称 static属性,参数列表)
  4. 取一个static member function的地址是获得其在内存中的位置(即实际地址,等同于nonmember函数指针),并不是指向class member function的指针。

&Point3d::ObjectCount()得到的是一个类型是unsigned int(*)()的指针,而不是unsigned int(Pount3d::*)()类型的指针。

Virtual Member Function

这里继续讨论,virtual member function的情况,如果magnitude是virtual member function,那么以下的调用ptrPoint3d->magnitude()可能会被转换为(*ptrPoint3d->vptr[1])(ptrPoint3d)

  1. vptr是由编译器产生的指针,指向virtual table。它存在于每一个声明或继承有一个或多个virtual function的class object中。而事实上vptr也会被mangled,因为在一个复杂的class派生体系中,可能存在多个vptr。
  2. 1是magnitude在virtual table中的slot索引值。
  3. 第二个ptrPoint3d代表this指针

这里需要注意:
1. 如果虚函数里面调用同类的其他虚函数,则第二个虚函数是不需要通过虚拟机制resolve的,因为第一重虚函数resolve的时候已经确定了。所以可以进行class scope operator的显式调用(抑制虚拟机制,resolve方式和nonstatic nonvirtual member function 一致),并且当第二个虚函数是inline的时候,效率提升更为明显。
2. 经由class object调用一个virtual function 会被编译器像对待nonstatic nonvirtual member function一样(在函数中通过引用传递参数的方式调用与指针的resolve一致)。

接下来我们继续更深入的探究一下virtual function的模型。为了支持virtual function机制,必须能够对于多态对象有某种形式的运行时类型判断方法。在C++中,多态表示”以一个 public base class 的指针或reference,定位到一个derived class function”的意思。

那么这份额外的类型判断信息应该放在那里呢?试想如果把这份额外的信息放在指针或者引用的实现中,那么明显增加了空间开销,并且打破了与C程序之间的链接兼容性。那么若将这份额外的信息放在对象本身的话,为了尽可能的减少对普通对象的影响,就需要鉴别哪一种对象真正需要这些信息。因为只有拥有virtual function的class才能展现出多态特性,所以只有这些class的object才需要这份额外的信息。

那么下一个问题,这份额外的类型判断信息究竟是什么?也就是说,需要什么信息我们才能在运行时正确的得到指针或引用所指对象的真实类型,以及被调用的virtual function的真实位置。
1. 一个特定的识别码(一个字符串或者数字,唯一标识一个class,通常也是放在虚表中)
2. 一个指针,指向一个表格(常被称为,虚函数表),表格中存放着virtual function的实际地址

表格中的virtual function的地址是可以在编译时期固定下来的,所以这一组地址是不变的,运行时不能新增或者修改。并且由于虚函数表的大小和内容都不会再运行时发生变换,所以其构建和读写都可以完全交由编译器来掌控(正因如此,不同编译器实现是不一样的~),不需要运行时的任何介入。

那么现在我们知道virtual function 存在什么地方了,在运行时怎么找到这些对应的函数又是另外一个问题。
1. 为了找到表格,每一个含有virtual function的class object都被安插了一个由编译器内部产生的指针(常被称为,虚指针),指向该表格。
2. 为了定位到对应的虚函数,每一个virtual function 被指定一个表格的slot索引值。
以上这些工作,在编译器都能准备完毕,运行时,只需要经由这个被安插指针,找到表格,然后再取对应slot的函数地址执行即可。

根据以上的分析我们知道了virtual function实现的大体流程,但是还有关键的一点,virtual function 地址在虚表中是如何组织的?

虚表中virtual function的地址包括:
1. 继承自父类的virtual function地址
2. 当前class所定义的virtual function地址(若当前函数override基类函数,则会改写基类函数在虚表中的地址,即override函数只会在class中存在一份当前class所定义的virtual function地址)
3. 一个pure_virtual_called()函数的地址,用来在运行时处理异常。

每一个virtual function都被分配一个固定的slot索引值,这个值在整个继承体系中保持不变。也就说无论是否override在父类和子类中的同样签名的virtual function 拥有相同的slot索引值。

单继承

Point单继承体系下的内存布局以及virtual table:

Class Point
{
public:
    virtual ~Point();
    void NonVirtual(){printf("Point::NonVirtual()\n");};
    virtual Point& Mult(float)=0;
    virtual void Print(){printf("Point.x : %d\n",x);};
    //...
    virtual float X() const {return _x;};
    //...
protected:
    Point(float x = 0.0);
    float _x;
};

class Point2d : public Point
{
public:
    Point2d(float x = 0.0, float y = 0.0):Point(x),_y(y){};
    ~Point2d();
    //改写base class virtual function
    virtual Point2d & Mult(float);
    virtual void Print(){printf("Point2d.x : %d Point2d.y : %d \n",x,y);};
    virtual floar Y() const {return _y;};
    //...
protected:
    float _y;
};

class Point3d : public Point2d
{
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0):Point2d(x,y),_z(z){};
    ~Point3d();
    //改写base class virtual function
    virtual Point3d & Mult(float);
    virtual void Print(){printf("Point3d.x : %d Point3d.y : %d Point3d.z : %d\n",x,y,z);};
    virtual floar Z() const {return _z;};
    //...
protected:
    float _z;
};

Virtual Function

若子类中没有override基类中的virtual function则在子类的虚表中的同一个slot放的是基类中的virtual function地址,若重写的话则存放的是子类的virtual function。

不是virtual的nonstatic member function不会出现在虚表中。

子类中独有的virtual function会以新增的形式出现在virtual table中。

通过上面的分析,我们现在有了足够的信息在编译时期设定多态情况下的virtual function调用。

例:

void Print(Point* ptr)
{
    ptr->Print();
}

因为我们并不知道ptr指向的是Point,Poin2d还是Point3d,但是我们知道ptr所指向的对象一定有一个_vptr_Point,并且在对象中的位置是固定的,所以我们可以经由ptr找到_vptr_Point进而找到virtual table。我们并不知道会调用哪一个Print()函数,但是我们知道它都放在virtual table的#3为位置。
所以以上调用可以改写为:

(*ptr->vptr[3])(ptr);

这一转换中,vptr代表编译器所安插的虚指针,指向virtual table。3表示virtual function Print在 virtual table中的slot,并且这个值在整个继承体系中不会改变。

这一机制在单一继承的体系下,表现十分良好,但是在多重继承和虚拟继承下,就没那么美好了。

多重继承

在多重继承体系中支持virtual function,其复杂度主要来自第二个以及后继base classes身上,以及在运行时适当的调整this指针。

class Base1
{
public:
    Base1();
    virtual ~Base1();
    virtual void Base1Only();
    virtual Base1 *Clone() const;
protected:
    float fBase1Data;
}

class Base2
{
public:
    Base2();
    virtual ~Base2();
    virtual void Base2Only();
    virtual Base2 *Clone() const;
protected:
    float fBase2Data;
}

class Derived : public Base1,public Base2
{
public:
    Derived();
    virtual ~Derived();
    virtual Derived* Clone() const ;
protected:
    float fDerivedData;
}

Derived 支持virtual function的困难度,全部都落在了Base2 subobject上。

分析此种情况:

Derived* pDerived = new Derived;
Base2* pbase2= pDerived;

把Derived对象地址赋值给Base2的指针时,新的Derived对象的地址必须调整以指向其Base2 subobject,所以上述两个指针的地址是不一样的。它在编译期会被编译器进行调整:

Derived* pDerived = new Derived;
Base2* pbase2= pDerived ? pDerived + sizeof(Base1) : nullptr;

当删除pbase2所指的对象时,必须先调用正确的virtual destructor函数,然后执行delete操作,pbase2的地址需要再次被调整到指向对象的起始点。
但是上述操作却无法在编译时得到足够的信息,进而直接调整,因为pbase2所指向的真正的对象只有运行时的某一刻才能真正被确定下来。
比如:

void ExampleFunction(Base2 *pbase2)
{
    //.......
    delete pbase2;
}

一般规则:经由第二或后继base class的指针(或reference)调用 derived class virtual function。其所连带的必要的”this”指针调整操作,必须在执行期完成,也就是说,offset的大小,以及把offset加到this指针的这一操作代码,必须由编译器在某个地方插入。

Bajarne最开始在cfront编译器中的方法是将virtual table改造,使得每一个 virtual table slot,不再是virtual function 地址,而是一个struct 包含 virtual function地址和需要调整的offset。

所以virtual function的调用操作 (*pbase2->vptr[1]) ( pbase2 );修改为( *pbase2->vptr[1].faddr ) ( pbase2 + pbase2->vptr[1] .offset );

但是这么做很显然有个很严重的问题,那就是对于所有的virtual function调用操作,不管是否需要offset调整,都会执行此操作,并且也额外增加了虚表的空间消耗。

为了解决这个问题,诞生了一个叫Thunk的技术,它是一小段assembly代码,用来以适当的offset值调整this指针,并跳转到对应的virtual function地址。如果应用于所有的virtual function操作的话,单是这样并没有实际上的解决问题。所以还需要改造virtual table slot 使它继续保持简单的指针,因此在virtual table上没有任何额外的空间开销,slot中的地址可以直接指向virtual function 也可以指向Thunk技术所安插的assembly代码地址。

例:经由Base2指针调用 Derived destructor,其相关的thunk可能是↓

pbase2_dtor_thunk:
    this += sizeof(base1);
    Derived::~Derived(this);

但是这样调整this指针不是很完美,因为经由derived class(或第一个base class调用) 和经由第二个(或后继)base class 调用,同一函数在virtual table中可能需要存在多笔对应的slots。

Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;

delete pbase1;
delete pbase2;

虽然delete最后都是调用相同的 derived destructor,但是它们需要在virtual table slot存在两份。

  1. pbase1不需要调整this指针(因为是第一个base class),所以virtual table slot放置的是真正的destructor地址。
  2. pbase2需要调整this指针,其virtual table slot放置thunk地址。

多重继承下,一个derived class内含有n-1个额外的virtual tables,n为base classes的个数(因此,单一继承不会有额外的virtual tables)。

Member Function Pointer

前面提到过的取 nonstatic data member 的地址是该member在class 布局中的偏移地址(有些编译器会+1),所以他不是一个完整的值,必须绑定在一个 class object pointer上才可以被存取。

取一个 nonstatic nonvirtual member function 的地址得到的是它在内存中的真正地址,但是这也不是完整的,也需要依附于class object pointer,才可以通过它调用该函数(所有的nonstatic member function 都需要对象的地址(this指针))。

//声明
double              //return type
(Point::*           //class the function is member
pmf)                //name of pointer to member
();                 //argument list 

//初始化
double (Point::*pmf)() = &Point::MemberFunction1;

//赋值
pmf = &Point::MemberFunction1;

//调用
Point objPoint;
Point *ptrPoint = new Point();

objPoint.*pmf();                //可能转换为 (*pmf)(&objPoint);
ptrPoint->*pmf();               //可能转换为 (*pmf)(ptrPoint);

一个 nonstatic nonvirtual member function pointer 如果不在虚继承和多继承下的话,其调用成本并不会比一个 nonmember function pointer 成本高。

接下来讨论,virtual member function 的情况,对于 nonvirtual member function 取地址得到的是函数的实际地址,那么virtual member function 也会如此吗?这里不妨先思考下。我们知道通过基类指针调用派生类虚函数的时候,是先找到virtual table 然后取相同索引上的函数地址来调用,那这里取 virtual member function 地址的时候是取到索引还是实际内存中的地址呢。

class Point
{
public:
    virtual ~Point() {};
    float x() { return 0; };
    float y(int a) { return 0; };
    virtual float z() {};
    virtual float zz(int b) {};
    //...
}

在VS2019和GCC下,是不允许取Point::~Point地址的,其他输出分别如下

    printf("%p\n", &Point::x);
    printf("%p\n", &Point::y);
    printf("%p\n", &Point::z);
    printf("%p\n", &Point::zz);
    cout << &Point::x << endl;
    cout << &Point::y << endl;
    cout << &Point::z << endl;
    cout << &Point::zz << endl;

    //VS2019
    00961717
    00961721
    00961712
    0096170D
    1
    1
    1
    1

    //GCC 4.8.5
    0x400ae4
    0x400aee
    0x11
    0x19
    1
    1
    1
    1

cout输出明显没有参考价值,忽略

这里使用 /d1 reportSingleClassLayoutPoint-fdump-class-hierarchy参数分别输出 Point 在 VS2019 和 GCC 4.8.5 下的内存布局进行分析

//VS2019
1>Test.cpp
1>class Point   size(4):
1>  +---
1> 0    | {vfptr}
1>  +---
1>Point::$vftable@:
1>  | &Point_meta
1>  |  0
1> 0    | &Point::{dtor}
1> 1    | &Point::z
1> 2    | &Point::zz
1>Point::{dtor} this adjustor: 0
1>Point::z this adjustor: 0
1>Point::zz this adjustor: 0
1>Point::__delDtor this adjustor: 0
1>Point::__vecDelDtor this adjustor: 0

//GCC 4.8.5
Vtable for Point
Point::_ZTV5Point: 6u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI5Point)
16    (int (*)(...))Point::~Point
24    (int (*)(...))Point::~Point
32    (int (*)(...))Point::z
40    (int (*)(...))Point::zz

Class Point
   size=8 align=8
   base size=8 base align=8
Point (0x0x7f07fae78de0) 0 nearly-empty
    vptr=((& Point::_ZTV5Point) + 16u)

忽略掉cout的输出,通过对比printf的输出发现,在VS2019下取虚函数的地址是这个函数实际在内存中的地址,而GCC 4.8.5下取到的是这个函数在virtual table中的偏移。

那我们来考虑GCC下的这种形式,假设有如下代码:

float (Point::*pmf)() = &Point::z;
Point* ptr = new Point();
ptr->*pmf();

通过pmf调用函数z()在编译器内部可能会被转换为ptr->vptr[(int)pmf](ptr)

因为pmf可能持有的地址是函数x()也可能是函数z(),所以pmf持有的值就有两种含义,1.实际地址 2.续表中的索引(或偏移),那么编译器还需要安插一段辨析指向那种函数调用方式的代码(在cfront 2.0非正式版中小于128的值认为是所以,大于128的值认为是实际函数地址(对虚函数个数做出了假设),不过现在编译器都不会采用这种实现方式)。

继续讨论多重继承和虚拟继承下 member function pointer 的表现。

为何解决上述对pmf持有数值类型判断的问题,以及支持多重继承和虚拟继承。

Stroustrup设计了如下结构体:

struct _mptr
{
    int delta;
    int index;
    union
    {
        ptrtofunc faddr;
        int v_offset;
    };
};

index 和 faddr 分别持有 virtual table 索引和 nonvirtual member function地址(不能同时持有,如果持有faddr时 index被设置为-1),所以通过 member function pointer 调用的时候编译器会安插一段代码,以检查index是否是-1来决策是调用 virtual function 还是 nonvirtual function ,但是这样会带来一个问题是,使得所有经由 member function pointer 的调用都付出了更多的开销。

Microsoft引入一个 vcall thunk 的技术,废弃了 index ,统一使用 faddr ,当函数是 virtual member function 的时候 faddr 指向的是一小段assembly代码,这里会选出 virtual table 中对应 slot 的函数来进行调用。这样就把 virtual 和 nonvirtual 的调用透明化。

delta字段表示的是 this 指针的 offset 值,v_offset 字段存放的是一个 virtual(或者是多继承中的第二个及后续的) base class 的 vptr 位置。如果不是虚拟继承以及多继承的情况下且vptr放置在class object的首部的话,vptr就没有必要存在了,但是在虚拟继承和多继承情况仍然是需要的。

许多编译器对不同的class 采用了不同的策略,以Microsoft的 MSVC 为例:

1. 一个单一继承实例(faddr持有的是vcall thunk或 实际函数地址)
2. 一个多重继承实例 (持有 faddr和 delta)
3. 一个虚拟继承实例 (全部持有)

Inline Function

当我们声明一个函数为 inline 时仅仅是对编译器提供了一个建议,实际是否生效,还是由编译器决策的。编译器有一套复杂的计算方法,用来计算assignments,function call,virtual function call,等操作的次数,每一种类的表达式都有一个权重,而inline函数的复杂度就是这些操作的总和。

处理一个inline函数一般而言有两个阶段

  1. 分析函数定义计算其复杂度,如果函数太过复杂被判断为不可inline,它会被转换为一个static函数,并在”被编译模块”内产生对应的函数定义,若多个模块分开编译的话,可能会存在多个副本,基本上来说链接器不会清除这些重复的副本。但是strip命令可以做到。

  2. 真正的inline函数扩展操作是在调用的哪一点上。这里会产生参数的求值操作,以及临时性对象的管理(相当于解决了宏的副作用)。

在inline扩展期间每一个形式参数都会被替换为对应的实际参数,并且如果存在有副作用的实际参数一般都需要引入临时对象,以避免多次求值,也就是说需要在形式参数到实际参数的替换之前完成求值操作。

局部变量在inline函数是需要特殊处理的,每一个inline的所有局部变量在每次调用中都需要处于一个独立的scope中,因为我们会在调用点展开inline函数体,如果是在单个表达式中扩展多次,则每次扩展都需要有一组自己的局部变量,如果inline函数在多个表达式中被扩展多次,那么就只需要一组局部变量即可。

inline函数的局部变量加上有副作用的参数,可能会导致大量的临时对象的产生,对于这些临时对象,编译器或许优化也或许不会。

赞(1)
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。墨影 » Class Function