深度探索cpp对象模型(4)

参考:http://www.roading.org/develop/cpp/%E3%80%8A%E6%B7%B1%E5%BA%A6%E6%8E%A2%E7%B4%A2c%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%9E%8B%E3%80%8B%E7%AC%94%E8%AE%B0%E6%B1%87%E6%80%BB.html

https://zhuanlan.zhihu.com/p/61610818

 

c++支持三种类型的成员函数,分别为static,nostatic,virtual。每一种调用方式都不尽相同。

非静态成员函数(Nonstatic Member Functions)

保证nostatic member function至少必须和一般的nonmember function有相同的效率是C++的设计准则之一。事实上在c++中非静态成员函数(nostatic member function)与普通函数的调用也确实具有相同的效率,因为本质上非静态成员函数就如同一个普通函数,如一个非静态成员函数Xfloat Point::X();就相当于一个普通函数float X(Point* this);。编译器内部会将成员函数等价转换为非成员函数,具体是这样做的:

1.改写成员函数的签名,使得其可以接受一个额外参数,这个额外参数即是this指针:

1
2
3
float Point::X();
//成员函数X被插入额外参数this
float Point:: X(Point* const this );

当然如果成员函数是const的,插入的参数类型将为 const Point * const 类型。(指向常量的常量指针)

2.将每一个对非静态数据成员的操作都改写为经过this操作。

3.将成员函数写成一个外部函数,对函数名进行“mangling”处理,使之成为独一无二的名称。

可以看出,将一个成员函数改写成一个外部函数的关键在于两点,一是给函数提供一个可以直接读写成员数据的通道;
二是解决好有可能带来的名字冲突。第一点通过给函数提供一个额外的指针参数来解决,第二点则是通过一定的规则将名字转换,使之独一无二。

由此可以做出一点总结:一个成员函数实际上就是一个被插入了一个接受其类的常量指针类型的额外参数的非成员函数,
当然还要额外对函数的名称进行处理。额外插入的参数用来访问数据成员,而名称的特殊处理用来避免名字冲突。

对于名称的特殊处理并没有统一的标准,各大编译器厂商可能有不同的处理规则。
在VC下上述的成员函数X()的名称X处理后就成了?X@Point@@QAEMXZ
更多信息可以参见维基百科的Visual C++名字修饰。

使用 extern “C” 会压抑名称特殊处理

于是在VC中对于上面的例子中的成员函数的调用将发生如下的转换:

1
2
3
4
//p->X();被转化为
?X@Point@@QAEMXZ(p);
//obj.X();被转化为
?X@Point@@QAEMXZ(&obj);

虚拟成员函数(Virtual Member Functions)

如果function()是一个虚拟函数,那么用指针或引用进行的调用将发生一点特别的转换——一个中间层被引入进来。例如:

1
2
3
// p->function()
//将转化为
(*p->vptr[1])(p);
  • 其中vptr为指向虚函数表的指针,它由编译器产生。vptr也要进行名字处理,因为一个继承体系可能有多个vptr。
  • 1是虚函数在虚函数表中的索引,通过它关联到虚函数function().

何时发生这种转换?答案是在必需的时候——一个再熟悉不过的答案。当通过指针调用的时候,要调用的函数实体无法在编译期决定,必需待到执行期才能获得,所以上面引入一个间接层的转换必不可少。但是当我们通过对象(不是引用,也不是指针)来调用的时候,
进行上面的转换就显得多余了,因为在编译器要调用的函数实体已经被决定。此时调用发生的转换,与一个非静态成员函数(Nonstatic Member Functions)调用发生的转换一致。

静态成员函数(Static Member Functions)

静态成员函数的主要特性:没有this指针,其他一些次要特性:

  1. 不能够直接存取其类中的非静态成员(nostatic members),包括不能调用非静态
    成员函数(Nonstatic Member Functions)。
  2. 不能够声明为 const、voliatile或virtual。
  3. 它不需经由对象调用,当然,通过对象调用也被允许。

除了缺乏一个this指针他与非静态成员函数没有太大的差别。在这里通过对象调用和通过指针或引用调用,将被转化为同样的调用代码。

需要注意的是通过一个表达式或函数对静态成员函数进行调用,被C++ Standard要求对表达式进行求值。如:

1
(a+=b).static_fuc();

虽然省去对a+b求值对于static_fuc()的调用并没有影响,但是程序员肯定会认为表达式a+=b已经执行,一旦编译器为了效率省去了这一步,很难说会浪费多少程序员多少时间。这无疑是一个明智的规定。

 

 

《深度探索C++对象模型》是这样来说多态的:

在C++中,多态表示“以一个public base
class的指针(或引用),寻址出一个derived class object”的意思。

消极多态与积极多态

用基类指针来寻址继承类的对象,我们可以这样:

1
Point ptr=new Point3d;               //Point3d继承自Point

在这种情况下,多态可以在编译期完成(虚基类情况除外),因此被称作消极多态(没有进行虚函数的调用)。相对于消极多态,则有积极多态——指向的对象类型需要在执行期在能决定^注1。积极多态的例子如虚函数和RTTI:

1
2
3
4
5
//例1,虚函数的调用
ptr->z();
//例2,RTTI 的应用
if(Point3d *p=dynamic_cast(ptr) )
	return p->z();

关于RTTI的笔记可见笔记EH & RTTI。本文主要精力将集中于虚函数上。对于一个如上例关于虚函数的调用,要如何来保证在执行期调用的是正确的z()实体——Point3d::z()而不是调用了Point::z()。来看看虚函数的实现机制吧,它将保证这一点。

单继承下的虚函数

虚函数的实现:

  • 为每个有虚函数的类配一张虚函数表,它存储该类类型信息和所有虚函数执行期的地址。
  • 为每个有虚函数的类插入一个指针(vptr),这个指针指向该类的虚函数表。
  • 给每一个虚函数指派一个在表中的索引。

用这种模型来实现虚函数得益于在C++中,虚函数的地址在编译期是可知的,而且这一地址是固定不变的。而且表的大小不会在执行期增大或减小。

一个类的虚函数表中存储有类型信息(存储在索引为0的位置)和所有虚函数地址,这些虚函数地址包括三种:

  • 这个类定义的虚函数,会改写(overriding)一个可能存在的基类的虚函数实体——假如基类也定义有这个虚函数。
  • 继承自基类的虚函数实体,——基类定义有,而这个类却没有定义。直接继承之。
  • 一个纯虚函数实体。用来在虚函数表中占座,有时候也可以当做执行期异常处理函数。

每一个虚函数都被指派一个固定的索引值,这个索引值在整个继承体系中保持前后关联,例如,假如z()在Point虚函数表中的索引值为2,那么在Point3d虚函数表中的索引值也为2。

当一个类单继承自有虚函数的基类的时候,将按如下步骤构建虚函数表:

  1. 继承基类中声明的虚函数——这些虚函数的实体地址被拷贝到继承类中的虚函数表中对于的slot中。
  2. 如果有改写(override)基类的虚函数,那么在1中应将改写(override)的函数实体的地址放入对应的slot中而不是拷贝基类的。
  3. 如果有定义新的虚函数,那么将虚函数表扩大一个slot以存放新的函数实体地址。

我们假设z()函数在Point虚函数表中的索引为4,回到最初的问题——要如何来保证在执行期调用的是正确的z()实体?其中微妙在于,编译将做一个小小的转换:

1
2
3
ptr->z();
//被编译器转化为:
(*ptr->vptr[4])(ptr);

这个转换保证了调用到正确的实体,因为:

  • 虽然我们不知道ptr所指的真正类型,但它可以通过vptr找到正确类型的虚函数表。
  • 在整个继承体系中z()的地址总是被放在slot 4。

多重继承下的虚函数

在多重继承下,继承类需要为每一条继承线路维护一个虚函数表(也有可能这些表被合成为一个,但本质意义并没有变化)。当然这一切都发生在需要的情况下。

当使用第一继承的基类指针来调用继承类的虚函数的时候,与单继承的情况没有什么异样,问题出生在当以第二或后继的基类指针(或引用)的使用上。例如:

1
2
3
4
5
//假设有这样的继承关系:class Derived:public base1,public base2;
//base1,base2都定义有虚析构函数。
base2 *ptr = new derived;
//需要被转换为,这个转换在编译期完成
base2 *ptr = temp ? temp + sizeof(base1) : 0 ;

如果不做出上面的转换,那么 ptr 指向的并不是 derived 的 base2 subobject
。后果是,ptr 将一个derived类型当做base2类型来用。

当要delete ptr时又面临了一次转换,因为在delete ptr的时候,需要对整个对象而不是其子对象施行delete运算符,这期间需要调整ptr指向完整的对象起点,因为不论是调用正确的析构函数还是delete运算符都需要一个指向对象起点的指针,想一想给予一个derived类的成员函数指向base2 subobjuect 的this指针会发生什么吧。因为ptr的具体类型并不知道,所以必须要等到执行期来完成。

Bjame的解决方法是将每一个虚函数表的slot
扩展,以使之存放一个额外的偏移量。于是虚函数的调用:

1
2
3
(*ptr->vptr[1])(ptr);
//将变成:
(*ptr->vptr[1].addr)(ptr+*ptr->vptr[1].offset);

其中使用ptr->vptr[1].addr用以获取正确的虚函数地址,而ptr+*ptr->vptr[1].offset来获得指向对象完整的起点。这种方法的缺点显而易见,代价过大了一点,所有的情况都被这一种占比较小的情况拖累。

还有一种叫做thunk的方法,

Thunk技术即是:虚函数表中的slot仍然继续放一个虚函数实体地址,但是如果调用这个虚函数需要进行this调整的话,该slot中的地址就指向一个Thunk而不是一个虚函数实体的地址。

thunk的作用在于:

  1. 以适当的offset值来调整this指针.
  2. 跳到虚函数中去。

书中纷杂的讲到不少中种情况,但我以我的理解,做如下小结:

多继承下的虚函数,影响到虚函数的调用的实际质上为this的调整。而this调整一般为两种:

  1. 调整指针指向对应的subobject,一般发生在继承类类型指针向第2,第3 ....基类类型指针赋值的情况下。
  2. 将指向subobject的指针调整回继承类对象的起始点,一般发生在第2基类指针对继承类虚函数进行调用的时候。

第一点:

1)发生在继承类类型指针向第2,第3 ....基类类型指针赋值的情况下

也就是 Base2 * ptr = new Derived;

调整是为了使得该基类指针指向一个与其指针类型匹配的子对象

这么做是为了让指针的非多态运用不出错!

保证使得该指针在执行与其指针类型相匹配的特定行为的正确性。比方调用基类的成员。可以想象如果不调整,用ptr存取base2 subobject的数据成员时,会发生什么?调用base2的成员函数的时候,其成员函数接受的this指针指向derived
类型对象,这又会发生什么?(成员函数里面,一般都会读写数据成员)结果是整个对象的内存结构有可能都被破坏。

这一点跟thunk这个没啥关系

2)发生在通过一个指向派生类的指针,调用从第2,第3.....基类继承而来的虚函数(潜台词,没有覆盖)

这时候指针必须调到base2 subobject 上,原因跟1) 成员函数一般会读写数据成员一样,

这个就跟thunk有关了,这个时候,主要表格(次要表格不会,还是指向虚函数)的 那个有关slot,就指向了一个thunk,,而不是虚函数,那个thunk起到调整this指针,调用虚函数的功能

第二点:

发生在发生在第2,第3.。。基类指针对 本来他们自己定义的虚函数,但是派生类给你覆盖了的  那些虚函数调用的时候,(因为第2,第3基类的虚表,都是次要表格,只存跟自己有关,也就是自己定义的那些虚函数,而不会扩张)  

这个跟thunk也有关,这个次要表格(不是所有次要表格,也不是主要表格)的 那个有关slot,就指向了一个thunk,,而不是虚函数,那个thunk起到调整this指针,调用虚函数的功能

p166 说的 第三点,我觉得就是 第二点的一种,因为返回类型,在基类中可以是基类的指针or引用,子类中可以是子类的指针or引用,这也算是函数覆盖。

第一顺序继承类之所以不需要进行调整的关键在于,其subobject的起点与继承类对象的起点一致。

虚拟继承下的虚函数

其复杂性同样在于指针值的运行时修改(但是会涉及到 vptr和vbtr), 书中建议不要在虚基类中声明非静态的函数.

成员函数的指针(即函数指针):
返回类型 (类名::*指针名)(形参类型) = &类名::成员函数名(注意类名前一定要加取地址符&,不然报错)

指向静态成员函数的指针(还是函数指针):(其实就是普通函数指针)
返回类型 (*指针名)(形参类型) = Student::s_add(注意类型前面可以不加&也可以加)

成员变量的指针:

类型 类名::* 指针名 = &类名::成员名

静态数据成员的指针:

类型 *指针名 = &类名::成员名、

 

成员函数指针

成员函数指针只能指向类的非静态成员函数, 使用方法如下:

struct C
{
    void f(int i) {}
};

void (C::* p)(int) = &C::f; // pointer to member function
C c, *cp = &c;
(c.*p)(1); // 通过对象调用函数f
(cp->*p)(2); // 通过对象指针调用函数f

父类成员函数指针可以直接赋值给子类成员函数指针, 如下面的例子:

struct B
{
    virtual void f() {}
};

struct D : B
{
    virtual void f() {}
};

void (B::* bf)() = &B::f;
void (D::* df)() = bf;

B bp = new D;
(bp.*bf)(); // 调用D::f()
(bp.*df)(); // 调用D::f()

而子类的成员函数指针可以通过static_cast或C风格的类型转换将其转换为父类的成员函数指针.

void (D::* df)() = &D::f;
void (B::* bf1)() = static_cast(df);
void (B::* bf2)() = (void (B::*)())df;

从上面的例子中可以看到, 成员函数指针仍然支持虚函数机制. 下面看看编译器是如何支持各种虚拟机制的.

虚函数

成员函数指针可以指向一个普通函数, 此时她可以是函数地址. 如果指向一个虚函数, 她可以是该函数在虚表中的偏移. 这两种值可以保存在相同类型的变量中, 但是如何区分她们呢? 早期C++限制最多有128个虚函数(应该是限制虚表长度为128吧), 所以偏移值最大为127. 而程序空间起始地址必定大于127, 因此可以通过将指针值和127做"&"(按位与)运算来判断是偏移还是函数地址.

(((int)pmf) & ~127) ? (*pmf)(ptr) : (*ptr->vptr[(int)pmf])(ptr);

多继承和虚继承

支持这些机制的方法就更加复杂了. Stroustrup提出的一种方式是将成员函数指针定义为一个结构体, 包含this指针偏移, 虚基类指针偏移等等. 不过因为对不需要如此复杂机制的函数调用带来额外负担而受到批评. 有的实现对成员函数指针有多种实现方式, 以减少不必要的负担. 比如微软, 对单继承, 多继承, 虚继承就采用不同的方式来实现. 这个地方感觉还是不够具体, 坑先留着, 以后再填.

inline函数

在下面的情况下, 一个函数是inline函数:

  • 声明中包含inline关键字的函数
  • 当一个函数(成员函数或非成员友元函数)的定义在类内部时
  • 被声明为constexpr的函数(since C++11)

inline函数只是一种建议, 建议编译器将对inline函数的调用转换, 但是编译器并不一定会接受该建议, 而且非inline函数也有可能被转换, 这依赖于具体实现. 使用inline函数时要注意下面几点:

  • inline函数可能会增加生成的文件的大小.
  • inline函数尽可能简单. 减少不必要的局部变量, 否则可能会在结果中产生大量的局部变量.(现在的编译器应该可以优化这个了吧)

你可能感兴趣的:(cpp对象模型)