深度探索C++对象模型笔记 [3] 函数语意学
本文主要参考Stanley B.Lippman所著《Inside the C++ Object Model》,侯捷译。
一、函数成员的各自调用方式
Nonstatic Member Functions(非静态成员函数):C++的设计准则之一是,nonstatic member function至少必须和一般的nonmember function有相同的效率。实际上,为此,member function 会被转化为nonmember的形式!
名称的特殊处理:一般而言,member的名称前会被加上class名称,形成独一无二的命名。比如class Bar{public:int ival;…},其中的ival在name mangling后可能变为ival_3bar.这样做的原因是,经过派生操作,可以将member绝对清楚地指出来,目前,编译器对name mangling还没有统一标准。
Virtual Member Functions(虚成员函数):如果normalize()是一个virtual member function,那么以下调用
*ptr->normalize()
将会被内部转换为
(*ptr->vptr[1])(ptr)
其中vptr是编译器产生的指针,指向虚函数表。事实上其名称也会被mangled,因为在一个复杂的派生系统中,可能存在多个vptrs。
而上述的1是virtual table slot的索引,关联到normalize()函数
第二个ptr表示this指针。
Static Member Functions(静态成员函数):如果是静态成员函数,调用操作将会转换为一般的nonmember函数调用。事实上,只有当一个或者多个nonstatic data members在member function中被直接存取时,才需要class object。Class object提供了this指针用于这种形式的函数调用。这个this指针把“在member function中存取的nonstatic class members”绑定于“object内对应的members”之上。如果没有任何一个member被直接存取,事实上就不需要this指针,这时也就没有必要通过一个class object来调用一个member function。不过C++到目前为止并不能辨识这种情况。
这么一来,在存取static data members时产生了一些不规则性。如果class的设计者把static data member声明为nonpublic(这是好习惯),那么就必须提供一个或者多个member functions来存取该member。因此,尽管可以不依靠class object来存取一个static member,但其存取函数却得绑定于一个class object。独立于class object之外得操作,在某个时候特别重要----当class得设计者希望支持“没有class object存在”的情况。
解决之道,即是由cfront2.0引入的static member functions。其特征在于并没有this指针,以下的次要特性统统根源于主特性
- 不能够直接存取其class中的nonstatic members
- 不能够被声明为const、volatile或virtual(const修饰符用于表示函数不能修改成员变量的值,该函数必须是含有this指针的类成员函数,函数调用方式为thiscall,而类中的static函数本质上是全局函数,调用规约是__cdecl或__stdcall,不能用const来修饰它)
- 不需经由class object才被调用----虽然大部分时候它是这样被调用的!
二、虚成员函数
在C++中,多态表示“以一个public base class的指针或引用,寻址出一个derived class object”的意思。
多态机能主要扮演一个输送机制的角色,经由它,我们可以在程序的任何地方采取一组public derived类型。这种多态形式被称为是“消极的”,可以在编译时期完成----virtual base class的情况除外。如下:
Point *ptr;
Ptr = new Point2d;
当被指出的对象真正被使用时,多态也就变成积极的了。如下:
Ptr->z();
那么,什么信息才能让我们在执行期调用正确的实例?需要知道:
- ptr所指对象的真实类型,这可以选择正确的z()实例
- z()实例的位置,以便可正确调用之
为了实现之,第一点引入了一个字符串或数字,用于表示class的类型。
第二点则引入了虚函数表的概念,为了寻找表格,每个class object都被安插了一个由编译器内部产生的指针,指向该表格;为了找到函数地址,每一个虚函数又被指派了一个表格索引值。这些工作都由编译器完成,执行期所要做的,只是在特定的virtual table slot(记录着虚函数的地址)中**virtual functions。
一个class只会有一张虚函数表。每一个表内含其对应之class object中所有active virtual function 函数实例的地址。这些active virtual function包括:
- 这一类所定义的函数实例。其会改写一个可能存在的基类虚函数函数实例。
- 继承自基类的函数实例。这是在derived class决定不改写virtual function时才会出现的情况
- 一个pure_virtual_call()函数实例,它既可以扮演pure virtual function的空间保卫角色,也可以当做执行期异常处理函数(有时候会用到)
每一个虚函数都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数的关系。
那么,回到ptr->z()的例子,到底其在编译时期是如何调用的呢?
- 一般而言,每次调用z()时,并不知道ptr所指向对象的真正类型。但却直到ptr可以存取到对象的virtual table
- 虽然不知道哪一个z()函数实例会被调用,但却知道到每一个z()函数地址都会放在slot4中
这些信息可以使得编译器将之转化为:
( *ptr->vptr[ 4 ] )( ptr );
在这一转化中,vptr表示编译器所安插的指针,指向virtual table;4表示z()被指派的slot编号。唯一一个在执行期才能知道的东西是,slot所指的到底是哪一个z()函数实例。
多重继承下的virtual functions:
在多重继承之下,一个derived class内含n-1个额外的virtual tables,n表示其上一层的base classes的个数(因此,单一继承将不会由额外的virtual tables)。针对每一个virtual tables,derived对象中有对应的vptr。为了调节执行期链接器的效率,一些编译器把多个virtual tables连锁为一个。指向次要表格的指针,可以由主要表格名称加上offset而来。在这样的策略下,每一个class只有一个具名的virtual table。
多重继承下的虚表布局如下
注:
1.子类虚函数会覆盖每一个父类的每一个同名虚函数。
2.父类中没有的虚函数而子类有(即子类新增的虚函数),填入第一个虚函数表中,且用父类指针是不能调用的。
3.父类中有的虚函数而子类没有,则不覆盖。仅子类和该父类指针能调用。
虚拟继承下的Virtual functions:
注意,尽量不要在一个虚基类中声明nonstatic data members。如果这么做,会离复杂的深渊越来越近!
虚拟继承下的虚表布局如下:
三、指向成员函数的指针
取一个nonstatic member function的地址,如果该函数是nonvirtual,得到的结果是它在内存中的真正地址。然而这个值也不是完全的。它需要被绑定于某个class object的地址上,才能够通过它调用函数。所有的nonstatic member functions都需要对象的地址。
比如,一个指向member function的指针,其声明如下:
Double(
Point::*
pmf
)();
然后可以这样定义并初始化该指针:
Double (Point::*coord)() = &Point::x;
也可以这样指定其值:
Coord = &Point::y;
欲调用它,可以这么做:
(origin.*coord)();
或
(ptr->*coord)();
指向member function的指针声明语法,以及指向member selection运算符的指针,其作用是作为this指针的空间保留者。这也就是为什么static member function(没有this指针)的类型是”函数指针”,而不是”指向member function的指针”之缘故。
支持“指向Virtual Member Function”的指针:
对一个nonstatic member function取地址,得到的是该函数在内存中的地址。而对一个virtual function取地址,其地址在编译期是未知的,所能知道的仅是virtual function在其相关的virtual table中的索引值,也就是说,对一个虚成员函数取地址,所能获得的仅是一个索引值。
在多重继承下,指向member function的指针:
许多编译器在自身内部根据不同的classes提供多种指向member functions的指针形式。例如Microsoft就提供了三种风味:
- 一个单一继承实例(其中持有vcall thunk地址或者是函数地址)
- 一个多重继承实例(其中持有faddr和delta两个members)
- 一个虚拟继承实例(其中持有4个members)
四、内联函数
关键词inline只是一种请求。如果这个请求被接受,编译器就必须认为它可以用一个表达式合理地将这个函数扩展开来。所谓合理即指,其执行成本比一般的函数调用返回机制来的负荷低。
一般而言,处理一个inline函数,有两个阶段:
- 分析函数定义,以决定函数的”intrinsic inline ability”(本质的inline能力)。“intrinsic”在这里意指”与编译器有关”。
- 真正的inline函数扩展操作是在调用的那一点上。这回带来参数的求值操作以及临时性对象的管理。
形式参数:在inline扩展期间,每一个形式参数都会被对应的实际参数取代。如果说有什么副作用,那就是不可以简单地一一封塞程序中出现的每一个形式参数,因为这会导致对于实际参数的多次求值操作。一般而言,面对“会带来副作用的实参”,通常需要引入临时性对象。换句话说,如果实参是一个常量表达式,我们可以在替换前完成其求值操作;后继的inline替换,就可以直接把常量”绑”上去,如果既不是个常量表达式,也不是个有副作用的表达式,那就直接替换之。
举例,假设有如下inline函数:
Inline int
Min(int i, int j)
{
Return i<j ?i : j;
}
下面有三个调用操作
Inline int
Bar(){
Int minval;
Int val1 = 1024;
Int val2 = 2048;
/*(1)*/Minval = min(val1,val2);
/*(2)*/Minval = min(1024,2048);
/*(3)*/Minval = min(foo,bar()+1);
Return minval;
}
标记为(1)的那一行会被扩展为:
Minval = val1 < val2 ? val1:val2;
标记为(2)的那一行直接拥抱常量:
Minval = 1024;
标记为(3)的那以后则引发参数的副作用,需要引入临时对象,以避免重复求值
Int t1;
Int t2;
Minval = (t1 = foo()),(t2 = bar() +1),
t1 < t2? t1 : t2;
局部变量:
一般而言,inline函数中的每一个局部变量都必须放在函数调用的一个封闭区段中,拥有一个独一无二的名称。如果inline函数以单一表达式扩展多次,则每次扩展都需要自己的一组局部变量。如果inline函数以分离的多个式子被扩展多次,那么只需要一组局部变量,就可以重复使用(因为它们被放在一个封闭区段中,有自己的scope).
如下:
Inline int
Min(int i, int j)
{
Int minval = I < j ? i : j;
Return minval;
}
{
Int local_var;
Int minval;
//…
Minval = min(val1,val2);
}
其在编译器中可能是这个样子:
{
Int local_var;
Int minval;
Int _min_lv_minval;//将inline函数的局部变量处以mangling操作
Minval =
( _min_lv_minval = val1 < val2? Val1 : val2),
_min_lv_minval;
}
Inline函数中的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生。特别是如果它以单一表达式被扩展多次的话,如下面操作:
Minval = min(val1,val2) + min(foo(),foo()+1);
Inline函数对于封装提供了一种必要的支持,可以有效存取封装于class中的nonpublic数据,它同时也是C程序中大量使用的#define的一个安全替代品---特别是如果宏中的参数有副作用的话。然而一个inline函数如果被调用太多次,会产生大量扩展码,使得程序大小暴涨。
一如前文所述,参数带有副作用,或是以一个单一表达式做多重调用,或是再inline函数中有多个局部变量,都会产生临时性对象!