4. The Semantics of Function
C++支持三中类型的member function:static、nonstatic和virtual,每一种类型被调用的方式都不相同。对于static成员函数,不能直接读写nonstatic数据,也不能声明为const。
4.1 成员函数的三种调用方式
A. 非静态成员函数
C++设计准则之一:非静态成员函数至少必须和一般的非成员函数有相同的效率。选择成员函数不应该带来额外负担,因为编译器会按以下步骤,把成员函数实例转换为对等的非成员函数实例:
- 改写函数原型,安插一个额外的参数(即this指针)到成员函数中,使得class object可以调用这个函数
- 将成员函数中,每一个对非静态数据的操作,改为通过this指针来操作
- 将成员函数重写成一个外部函数,将函数名经过mangle处理,编码成为程序中独一无二的语汇
B. 虚成员函数
// 如果normalize()是一个虚成员函数
ptr->normalize();
// ptr指针调用会在内部转为
(*ptr->vptr[1]) (ptr);
正如本书前面所述的虚函数实现多态的对象模型:
- 编译器产生指针vptr指向vbtl,vptr安插在每一个继承虚函数的class object。当复杂的类继承体系中,会存在多个vptr,此时vptr名称会被mangled。
- vptr[1]存有normalize函数的地址,关联到该函数
- 第二个ptr表示this指针。
C. 静态成员函数
static member function是在cfont 2.0引入的,其主要特性是没有this指针:
- 它不能够直接读写其class中的nonstatic member
- 它不能够被声明为const、volatile或virtual
- 它不需要经由class object才被调用
一个静态成员函数,会在class外声明,并给予一个经过mangled的名称。如果对其取值,或得到其在内存中的位置,由于没有this指针,其地址类型不是一个指向class member function的指针,而是一个nonmember函数指针。
4.2 虚成员函数
A. 单继承下的虚函数
这一节详细说明了vptr和vtbl的实现原理,全书最有收获的地方。
C++中的多态表示“以一个public base class的指针或引用,寻址出一个derived class object”。即使用基类指针,也能在运行时runtime正确调用到其指向对象(派生类)的成员(主要是派生类改写的继承自基类的虚成员函数)。
为了实现多态的特性,一个class只会有一个virtual tables,每一个vtbl中含有其对应的class object所有(active)virtual function的地址,一个函数地址放在一个slot。这些地址在编译时期就可知道,并且固定不变,在runtime不会新增或替换,class object通过这些地址正确调用对应类的virtual function。(vtbl的构建和存取完全由编译器控制,不需要runtime介入。)
vtbl中存储的地址指向的virtual functions包括:
- 当前class定义的虚函数,它会改写(override)继承自基类的同名虚函数;
- 继承自基类的虚函数,这是当前派生类没有改写该函数出,保留了基类该函数的地址;
- 一个pure_virtual_called()函数,既可以扮演pure virtual function的空间保卫角色,也能当做runtime异常处理函数。意外被调用时,通常结束掉这个程序,
对于以下单一继承的类布局,Point2d继承Point,Point3d继承Point2d。
class Point {
public:
virtual ~Point(); // slot 1
virtual Point& mult(float) = 0; // slot 2: 纯虚函数没有定义,放pure_virtual_called()的地址
float x() const { return 0; }
virtual float y() const { return 0; } // slot 3
virtual float z() const { return 0; } // slot 4
protected:
Point2d(float x = 0.0);
// ...
};
class Point2d : public Point {
public:
Point2d(float x = 0.0, floaty = 0.0): Point(x), _y(y) {}
~Point2d(); // slot 1: 覆盖了基类虚析构函数的地址
// 改写基类的虚函数
Point2d& mult(float); // slot 2: 指向当前类的虚函数
float y() const {return _y;} // slot 3: 指向当前类的虚函数
// slot 4: 继承基类的slot4指向的函数Point::z()
// ...
};
class Point3d : public Point2d {
public:
Point3d(float x = 0.0, float y =0.0, float z = 0.0) : Point2d(x, y), _z(z) {}
~Point3d(); // slot 1: 覆盖了基类虚析构函数的地址
// 改写基类的虚函数
Point3d& mult(float); // slot 2: 指向当前类的虚函数
// slot 3: 继承了基类的slot3指向的函数Point2d::y()
float z() const {return _z;} // slot 4: 指向当前类的虚函数
// ...
};
当一个类继承基类时,可能有三种可能:
- 它可以继承基类的虚函数:该函数的地址会被拷贝到derived class的vtbl的对应slot之中(原来在基类vtbl是slot3,在派生类的vtbl也是slot3)。
- 它改写了基类的虚函数:自己的函数地址必须放在对应的slot之中。
- 它可以加入新的虚函数:这时候vtbl的size会增大一个slot,放入新的虚函数地址。
具体的vtbl布局如下图:
按照上述原理实现vtbl后,在编译时期设定vtbl。
对于ptr->z()
,虽然通过ptr调用函数z(),并不知道ptr指向的对象类型(Point、Point2d还是Point3d),但ptr可以获取到该对象的vtbl,而且函数z()是放在slot4(虽然不知道是哪个z()),这两点信息可以让编译器把指针调用转换为(*ptr->vptr[4]) (ptr)
,实现调用对应类的函数。这就是所谓的,只有在运行时,才知道slot4指的是哪个类的z()函数,即指针调用哪个类的z()函数。
B. 多继承下的虚函数
多继承支持虚函数,复杂度主要在于派生类的第二个及以后的基类上。
class B1 {
virtual Base1 *clone() const;
};
class B2 {
virtual Base2 *clone() const;
};
class Derived: public Base1, public Base2 {
virtual Derived *clone() const;
};
Base2 *pbase2 = new Derived; // 新的Derived对象地址必须调整以指向其Base2 subobject
// ==>编译时转换为:
Derived *temp = new Derived;
Base2 *pbase2 = temp ? temp + sizeof(Base1) : 0; // 调整offset
当用第二个或以后的基类指针,来调用派生类虚函数时,必须在runtime完成this指针调整。
Bjarne在cfront的做法是加大vtbl,每一个table slot不再是指针,而是一个包含offset以及地址的结构体。缺点是连坐处罚了所有虚函数调用。
Thunk是另一种比较有效率的方法,thunk是一小段assembly代码,用来根据offset调整this指针并跳到相应的虚函数。Thunk技术允许slot内含一个简单指针(没有额外空间代价),可以(不需要调整this指针时)指向虚函数,也可以(需要调整this指针时)指向一个相关的thunk。
// Thunk ==> C++
pbase2_dtor_thunk:
this += sizeof(base1);
Derived::~Derived(this);
一个继承n个基类的派生类包含n-1个额外的vtbl,对于Derived而言,有两个vtbl产生,一个和Base1共享,命名为vtbl_Derived;一个和Base2有关,命名为vtbl_Base2_Derived。
多继承下,在调用析构函数时有两种情况,是否需要调整this指针。 如下例子,虽然两个delete调用相同的Derived析构函数,但:
- Base1是第一个基类,它指向Derived对象起始处,因此pbase1不需要调整this指针,vtbl的slot放置真正的析构函数地址。
- Base2是第二个基类,需要调整this指针,vtbl需要相关的thunk地址。
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
delete pbase1;
delete pbase2;
因此,当用一个Base1或Derived指针指向一个Derived对象地址,被处理的vtbl是vtbl_Derived;当用Base2指针指向一个Derived对象时,被处理的vtbl是vtbl_Base2_Derived。
有三种情况,第二或后继的基类会影响对虚函数的支持:
- 第二基类指针ptr调用子类的虚函数:ptr指向子类对象的Base2 Subobject,要向后调整sizeof(Base1)个bytes,从而指向Derived对象起始处。
- 子类指针pder调用第二基类的虚函数(继承来的):pder要向前调整sizeof(Base1)个bytes
- 当允许一个虚函数返回derived类时:如下例子,pb1调用clone(),pb1调整指向Derived对象的起始地址, Derived::clone()会被调用,回传一个指针,指向一个新的Derived指针,该对象的指针在赋值给pb2之前,必须先经过调整以指向Base2 subobject。
Base2 *pb1 = new Derived;
Base2 *pb1 = pb->clone(); // 会调用子类的clone, 返回值要调整指向Base2 subobject
C. 虚继承下的虚函数
4.4 指向成员函数的指针
A. 指向virtual成员函数的指针
B. 多继承下指向成员函数的指针
4.5 内联函数
编译器判断可以合理展开一个inline函数,表明在某个层次上,其执行成本比一般的函数调用及返回机制带来的代价低。
inline函数的处理流程:
1. 分析函数定义,以决定函数的intrinsic inline ability。如果函数因为复杂度或构建问题,被判断为不可成为inline,它会被转为static函数,在被编译模块产生对应的函数定义。
2. 真正的inline函数扩展操作是在调用点,会带来参数的求值操作(evaluation)以及临时性对象的管理。
inline函数在扩展时,每个形参都会被实参取代。当面对会带来副作用的实参,编译器需要引入临时对象,避免重复求值。
当inline函数内定义了局部变量,inline函数被扩展的时候,为了维护局部变量,会对该局部变量进行mangling改名,也会产生临时变量。