目录
第三章 Data语意学(The Semantics of Data)
3.1 The Binding of a Data Member
第四章 Function 语意学 (The Semantics of Function)
怎么判断 class 是否需要一个程序层面的析构函数/构造函数
第三章 Data语意学(The Semantics of Data)
一个 library
class X{ }; class Y : public virtual X { }; class Z : public virtual X { }; class A : public Y, public Z { };
在这个继承体系下,X 、Y 、Z 、A 各自的大小不是0,而分别是:1、8 、 8 、12(byte)。
原因:
-
对于X:X大小为1,是因为编译器不允许独立 class 占用内存为0。它在 class X 中偷偷插入了一个 char 指针。
-
对于Y、Z:
-
语言本身的 overhead :为了支持 virtual base class , 类内会插入一个 vptr 指针来指向相关 virtual table。
table 里存放 virtual base class subobject 的偏移地址或实际地址。
-
X 的 char 指针被偷偷继承了,此时子类大小为 1+4 = 5 byte 。
这里许多编译器会对这个char指针进行优化,但该例中没有。
-
Alignment 齐位要求导致 5->8 byte。
32位计算机的内存齐位大小:4byte
许多编译器对继承来的 空白基类char指针会进行优化。
(优化后,Y和Z 只剩一个指针的大小为 4 byte)
优化方式:EBO ,让空白虚基类作为子类对象开头的一部分(不花费任何空间)
-
-
对于 A:virtual base class 不会让A的内存变成简单的 8+8=16。
至于为何变成12:
-
虚基类的subobject(子内容)占 1 byte。
-
Base class Y 和 Z 减去了vptr 的大小为4 byte,4+4=8byte
-
class A 自己的大小为 0
-
齐位要求:9->12 byte 。
如果编译器有EBO,大小则为 8 byte。
(如果虚基类里本身有数据(一个以上),EBO就会失效,两种编译器的对象布局会完全相同)
-
另外
C++Standard 不强制规定 base class subobject 的排列顺序或 不同存取层级的 data member 顺序。
它也不规定 virtual function 或 virtual base class 的实现细节。它认为这些应该交给厂商决定
这一章中有两个关键点:class中的 data members 和 class hierarchy 。
class.data members
能够表现 class 的状态:
-
Nonstatic data member 放置的是针对个别 object 的数据。数据和对象的内存在一起。
-
static data member 放置的是针对整个类(共享)的数据。数据被放置在 global data segment 中。它永远只有一份实例。
即使 class 没有任何 object ,static data 也存在。这一点在template class 中稍有不同。
Nonstatic data 的设计考虑到了与 C-struct 的兼容。
编译器为实现 virtual 等会加上许多额外 data member ,加上边界需要,内存往往比想象中更大。
3.1 The Binding of a Data Member
从编译器角度探索代码实现的一些过程
//某个foo.h头文件 extern float x; //x在别处被定义,此处被引用。这里x是声明不是定义,定义是要分配存储空间的。 //Point3d.h文件 class Point3d{ public: Point3d(float,float,float); //问题:被回传和设定的x是哪一个x呢? float X()const {return x;} void X(float new_x)const { x=new_x; } //... private: float x,y,z; };
在今天的编译器下,point::X()传回 class 内部的x。早期编译器为了防止指向 global x object ,有两种设计思路。
-
所有data member 放在开头,来确保正确绑定。
class Point3d { float x,y,z; //看到它们了 public: float X() const {return x;} //不怕被外部引入调用了! }
-
把所有 inline function 放到类的声明之外。
classs Point3d { public: Point3d(); float X() const; void X(float) const; //编译器只看到了声明,看到下面的内部变量后再去实现 ... } inline float Point3d::X() const {return x;}放到实现外 ...
这两个古老思路被称为 member rewriting rule ,不允许 inline 函数实体在 class 声明被完全看见之前去进行 evaluate。
C++ Standard 以 member scope resolution rules 来更好完成 rewriting rule 。
效果:inline function 在class声明后被立刻定义,仍然对其评估求值。
extern float x; class Point3d{ public: ... void X(float new_x)const { x=new_x; } //对于函数本体的分析将延迟到编译器发现 class 的 “}” 时 private: float x,y,z; };对member function 的本体分析,直到整个 class 声明都出现才开始。这时不需要把所有类内部函数实现转移到类外部了。
特殊情况:member function 的 argument list
typedef int length; //重命名类型了 class Point3d{ public: ... void X(length val)const { _val=val; } // argument 1 length mumble(){ return _val;} // argument 2 //对于函数本体的分析将延迟到编译器发现 class 的 “}” 时 private: typedef float length; length _val; };
非我们所愿的数据绑定情况在两个参数第一次被编译器看到时,仍然会发生。上面的两个 length 类型 都被 resolve 成 global typedef 了。
这样一来,后续在去 nested - typedef length,就会导致编译器报错并定义最早的绑定不合法。
所以,把嵌套的 typedef 放到 class 起始处吧。
3.2 Data Member Layout
class Point3d{ public: //... private: float x; static List<Point3d*> *freeList; float y; static const int chunkSize=250; float z; };
non-static data member 在 class object 中的排列顺序和其被声明的顺序一样。
任何中间介入例如freeList和chunkSize的static数据成员 都不会被放进对象的布局之中。
在上述例子中,每一个Point3d的对象由3个float组成,次序是x,y,z
static数据成员存放在程序的数据段中,属于整个类,不属于某个对象。
C++ Standard要求,在同一个access section(也就是private,public,protected等区段)中,只需要满足“较晚出现的数据成员在对象中有较高的地址”即可。
即在同一个acess section中,次序按照声明的次序,但是不一定连续,可能因为齐位调整(alignment)或者编译器自动合成的一些内部使用的数据成员,如虚函数表指针vptr插入到这些数据成员到中间。
(传统的编译器会把vptr放到所有明确声明的数据成员最后,当然也有编译器放在对象的最前端。总之C++ Standard 对这种布局很宽松啦)
对于不同access section的情况:
class Point3d{ public: //... private: //一个acess section float x; static List<Point3d*> *freeList; private: //另一个acess section float y; static const int chunkSize=250; private //另一个acess section float z; };
大小和组成同先前的一样。排列顺序由编译器决定。
主流想法是:把一个以上的 access section 连锁在一起,按照声明顺序形成连续区块。
Access section 的多寡没有额外负担。8个section中的8个member 和1个 section 中的8个 member 大小一样。
拓展
一个能判断谁先出现在 class object中的 template function。
(两个 member 都是不同的 access section 中第一被声明者,此函数就可以用来判断哪一个 section 先出现)
template< class class_type, class data_type1, class data_type2 > char* access_order(data_type1 class_type::*mem1, data_type2 class_type::*mem2) { assert (mem != mem2 ); return mem1 < mem2 ? "member 1 occurs first" : "member 2 occurs first" }
access_order(&Point3d::z , &Point3d::y); //class_type == Point3d //data_type1 == data_type2 == float
3.3 Data Member的存取
Point3d origin; origin.x = 0.0;
下面来根据根据不同情况分析 x 的存取成本。
分析前的一个问题
Point3d origin, *pt = &origin; origin.x = 0.0; pt->x = 0.0;
通过 origin 存取和 通过 pt 存取 有什么差别吗?
稍后会回答。
Static Data Members
前面讲过,每一个static成员只存在一个实体,存放在程序的data segment数据段中,被视为一个global变量(只在class存在范围内可见)
origin.chunkSize=250; //这样调用,内部转化为: //Point3d::chunkSize=250; pt->chunkSize=250; //这样调用,内部转化为: //Point3d::chunkSize=250;
通过 member selection operators(“.”运算符)对 静态成员变量进行操作只是语法上的简便操作。实际上static-member 并不在 class 对象中。所以存取 static member 并不需要通过 class 对象。
chunksize是继承来的member
各种复杂关系,比如虚基类的虚基类那里继承而来。。。
不会发生任何变化。static-member 仍然在栈里等待着。
static data member 通过函数调用
一种可能的转化(不同标准不同处理)
foobar().chunkSize=250; //这样调用,内部转化为: //(void)foobar(); //Point3d.chunkSize = 250;
取静态成员变量地址
因为 static member 不内含在 class 对象里,取其地址不会得到指向对应 class member 的指针,而会得到指向其本身数据类型的指针。
&Point3d::chunkSize; //得到 const int* 类型的内存地址 //而不是Point3d::*类型的地址(指向类对象成员的指针)。
对于静态成员冲突:
如果一个程序里定义了两个类,两个类都声明了一个static成员,且两个static成员同名,那么都存放在程序的数据段中时会引起同名冲突。
编译器会暗中对每一个static成员编码,得到一个独一无二的程序识别代码(一起扔到某个表格之类的东西里),这种手法叫name-mangling(不同编译器编码不同)。主要做两件事:
1、一种算法,推导出独一无二的名称 2、推导出的名称能够还原(万一编译系统(或环境工具)必须与使用者交谈,那么那些独一无二的名称 可以轻易被推导回原来的名称)
Nonstatic Data Member
non-static成员存在于每一个对象中,必须通过显示的或者隐式的对象才能对non-static成员进行存取。
只要程序员在成员函数里直接处理non-static成员,隐式的对象就会出现(它就是被编译器隐藏的家伙)。
Point3d::translate(const Point3d &pt){ x+=pt.x; y+=pt.y; z+=pt.z; } //内部转化为:(编译器在参数列表上加了this指针) Point3d::translate(Point3d *const this,const Point3d &pt){ //第一个参数 this 是隐藏的! this->x+=pt.x; //this指针就是上述的隐式的对象 this->y+=pt.y; this->z+=pt.z; }
implicit class object 由 this 指针表达。
对 nonstatic data member 进行存取操作时,编译器会要求 class object 的起始地址 + offset(data member)偏移地址 。
origin.y = 0.0; //即 &origin + (&Point3d::y-1);
-1是为了让系统区分指向成员变量的指针中,空指针和指向第一个变量的指针(两者都是0)
nonstaic 成员变量的offset在编译期就能得知。即使该 member 属于 base class subobject(继承来的子内容)。因此存取效率和 C struct 成员 或独立类中的 成员 是一样的。
虚拟继承
Point3d origin; Point3d *pt = &origin; origin.x=0.0; pt->x=0.0;
虚拟继承使 base class subobject 存取 class members 增加了新的间接性。
(指针的间接性 + virtual vptr 间接性)
当然,x 在作为 struct 成员,独立类成员、普通继承(非virtual)成员的效率都相同。 但在作为我们当前讨论的 virtual base class 时,存取速度会稍慢。
这时就回到了该节开头的那个问题:以上两个存取方式有什么重大差别?
答案:当Point3d为子类,继承过一个 virtual base class,而 member(如x)又属于这个虚基类时,差别就会很大:
-
pt由于间接性,不能确定指向哪一种 class type ,于是在编译期就无从知晓该成员的偏移位置。所以这个存取操作被转移到了运行期(通过额外的间接索引来解决)。
-
origin 不会存在pt的问题,他的类型很明确,即 Point3d class。即使它继承自虚基类,成员偏移量也能够在编译期固定。
戏份更多的编译器甚至能通过 origin 静态解决掉对x的存取操作。
3.4 继承与 Data Member
CPP继承模型中,一个子类对象表现出来的东西,是 derived class member 和 base class member 的总和。但这两者的排列顺序并没有明确规定。
通常 base class members 先出现,属于virtual base class 的部分除外。
class Point2d{ puiblic: //functions private: float x,y; } class Point3d{ puiblic: //functions private: float x,y,z; }
下面我们将在以上两个独立类之间的关系上做文章,分别讨论:“单一继承且不含 virtual function”、“单一继承并含virtual functions”、“多重继承”、“虚拟继承”的情况。
先看下独立类时的状态(non-virtual function)
和 C struct 完全一样
只要继承不要多态
现在从Point2d派生出Point3d,于是Point3d将继承x和y坐标的一切(包括数据实体和操作方法),使Point无论2d或3d都可以共享数据本身和数据的处理方法。
一般而言,非虚拟继承并不会增加空间或存取时间上的额外负担。
class Point2d{ public: Point2d(float x=0.0,float y=0.0):_x(x),_y(y){}; float x(){return _x;} float y(){return _y;} void x(float newX){ _x=newX; } void y(float newY){ _y=newY; } void operator+-(const Point2d& rhs){ _x += rhs.x(); _y += rhs.y(); } //... protected: float _x,_y; }; class Point3d:public Point2d{ public: Point3d(float x=0.0,float y=0.0,float z=0.0): Point2d(x,y),_z(z){}; float z(){return _z;} void z(float newZ){ _z=newZ; } void operator+-(const Point3d& rhs){ Point2d::operator+=(rhs); _z += rhs.z(); } //... protected: float _z; };
普通继承的好处是负责坐标点的程序代码能够局部化,同时能够表现出两个 class 的紧密关系。即使两个类独立出来,也不需要改变声明和使用。
但这种设计有两个坑:
-
这种继承关系需要选择某些函数作为 inline 函数,否则就会出现相同操作的函数重复出现。
如示例中的 operator += 和 constructor 函数:
Point3d object 的初始化和加法操作,需要部分point2d 和 部分 point3d 作为成本。
-
将一个类拆分成两层或者更多层的类时,为了表现”类体系的抽象化“而造成空间膨胀。
以 concrete 类为例
class Concrete{ private: int val; char c1; char c2; char c3; };
空间分析(32bit):val - 4byte , c1 , c2 ,c3 各占用 1byte;齐位要求:4+3→8byte
这个案例中如果有以下应用场景:将 concrete 分裂成三层结构
class Concrete1{ private: int val; char bit1; }; class Concrete2:public Concrete1{ private: char bit2; }; class Concrete3:public Concrete2{ private: char bit3; };
空间膨胀喽:三次齐位要求造成内存为 8+4+4 = 16 byte
不要想当然认为 concrete::nonstatic data member bit2 会填补 concrete1的空间。齐位填充会提前发生。
那么这种提前填充,或者说没有把子类和父类子对象填充在一起的设计用意何在呢?
看看这个例子:
Concrete2 *pc2; Concrete1 *pc1_1 , *pc1_2; //这俩可以指向上述三种classes object。
发生下述操作时:
*pc1_2 = *pc1_1;
应该执行一个默认的 memberwise 复制操作,来一个一个复制 Concrete1的member 。
如果pc1_1 指向一个 Concrete2 object 或 Concrete3 object 的话,上述指针赋值操作应将复制内容指定为 Concrete1 subobject。
但如果子类成员和父类子对象捆绑在一起,来填补空间的话,就会发生意外:
//pc1_1和pc2:既有基类子对象,又有自身成员属性。但两者绑定在了一起 //pc1_2 pc1_1 = pc2; //令 pc1_1 指向 Concrete2 对象 *pc1_1=*pc1_2; // pc1_1 的 derived class subobject 会被覆盖,使bit2被覆盖产生非预期
上述操作会把Concrete1对象逐对象拷贝,包括原本应该padding的三个字节,于是实际pc1_1指向的Concrete2对象的bit2会被覆盖掉,出现一个无法确定的值。
下图为绑定后的过程 :
在子类中的 base class subobject 的原样性被破坏后,会导致 copy 时
Concrete 1 的子对象复制给 Concrete2, 破坏了 Concrete 2 (捆绑后) 的成员。
加上多态
多态嘛,处理一个坐标点而不在乎它是 Point2d 还是 Point3d ,在继承关系中提供一个 virtual function 接口。
class Point2d{ public: Point2d(float x=0.0,float y=0.0):_x(x),_y(y){}; float x(){return _x;} float y(){return _y;} void x(float newX){ _x=newX; } void y(float newY){ _y=newY; } //修改1:加上z的保留空间,当前什么也没做,2d的z点返回0.0也是合理的(只是为扩展性存在) virtual float z(){return 0.0;} virtual void z(float){} //修改2:设定下面的运算符操作为virtual virtual void operator+=(const Point2d& rhs){ _x += rhs.x(); _y += rhs.y(); } //... protected: float _x,_y; };
既然多态,导入一个 virtual 接口才显得合理。
void foo(Point2d &p1,Point2d &p2){ //... p1+=p2; //p1 可能是2d,可能是3d //... }
Point3d:
class Point3d:public Point2d{ public: Point3d(float x=0.0,float y=0.0,float z=0.0):Point2d(x,y),_z(z){}; float z(){return _z;} void z(float newZ){ _z=newZ; } //修改:参数改成const Point2d& rhs(原来是Point3d&) void operator+=(const Point2d& rhs){ Point2d::operator+=(rhs); _z += rhs.z(); } //... protected: float _z; }; //修改后最大的好处就是可以把operator+=运用在一个Point3d对象和Point2d对象上 Point2d p2d(2.1,2.2); Point3d p3d(3.1,3.2,3.3); p3d+=p2d; //得到的p3d新值为(5.2,5.4,3.3)
两个 z() member function 和 operator+=()运算符 都成了 virtual function
每一个Point3d class object 内含一个额外的 vptr member(from Point2d)
面向对象的弹性会带来相应的实现负担:
-
导入一个和Point2d有关的虚函数表,存放声明的虚函数地址,还有支持runtime type identification相关的东西。
-
每一个类对象要加一个虚函数表指针vptr,提供执行期的链接,使得每一个对象都能找到相应的虚函数表。
-
构造函数需要为vptr提供初始值,让它指向类对应的虚函数表。这可能意味着所有派生类和基类的构造函数都要重新设定vptr的值。
这些操作都是编译器偷偷做出的。
-
析构函数需要消除vptr。vptr很可能已经在派生类析构函数中被设定为派生类的虚函数表地址,析构函数的调用次序反向的,从派生类到基类。
负担程度视“被处理的Point2d objects 的个数和生命期”而定。同时也要考虑“多态设计取得的收益”。
vptr位置问题
编译器领域有一个讨论点:vptr 放置在 class object 的哪个位置。
-
放在尾端
Struct no_virts{ int d1,d2; }; class has_virts:public no_virts{ public: virtual void foo(); //... private: int d3; } no_virts *p = new has_virts;
好处:与 base class C struct 对象布局相兼容。
上例中,带有虚函数的继承布局(vptr在尾端)
-
放开头
vptr 在 class object 前端,对于多重继承场景,通过指针(指向类成员)调用虚函数有帮助:
class object 起始点开始测量计算的 offset 不需要在运行期准备了;与 class vptr 间的 offset 也不需要在运行期准备。
缺点:丧失了C语言兼容性。
老实说,这种兼容无关痛痒,谁会在 C struct 上派生出多态 class 呢?
多重继承
自然多态(natural polymorphism):单一继承中,父类和子类转换很自然,因为它们的继承对象起始地址相同,所以父类指针指向子类对象时,不需要编译器参与调整地址,效率很高。
non-virtual 基类下如果存在一个子类有 virtual function,就会失去单一继承的自然多态。这时,子类转为基类,就需要编译器介入来调整地址(因为vptr插入到了 class object 的起始处)。
多重继承中,子类和基类的关系并不那么“自然”。
class Point2d{ public: //有虚函数,所以Point2d对象中有vptr protected: float _x,_y; }; class Point3d{ public: //... protected: float _z; }; class Vertex{ public: //有虚函数,所以Vertex对象中有vptr protected: Vertex *next; }; class Vertex3d:public Point3d,public Vertex{ public: //... protected: float mumble; };
对于这种多重派生对象,将其地址指定给最上层的 base class 指针时,情况和单一继承相同(父子指向相同地址,成本只有指定地址的操作)。
后续子类的地址指定操作,则需要手动调整地址。
Vertex3d v3d; Vertex *pv; Point2d *p2d; Point3d *p3d; //Point3d的定义看回上一小节 pv=&v3d; //内部转化为: pv=(Vertex*)(((char*)&v3d)+sizeof(Point3d)); //Point3d 包含 Point 2d。 p2d=&v3d; //这两个操作只需要简单地拷贝地址就行了 p3d=&v3d; //因为Point2d和Point3d和Vertex3d的对象起始地址都相同 Vertex3d *pv3d; Vertex *pv; pv=pv3d; //不能简单地转换成下面这样,因为如果p3d为0,那么将获得sizeof(Point3d)的值,这是错误的 pv=(Vertex*)(char*)v3d+sizeof(Point3d); //错误 //应该加个条件判断应付空指针情况,如果是引用则不需要加这个判断 pv=pv3d ? (Vertex*)(char*)v3d+sizeof(Point3d):0; //正确
C++ Standard 并未要求Vertex3d 中的 base class Point3d 和 Vertex 有特定排列顺序。
CFront 和许多编译器,按照声明顺序排列继承的基类:Point3d subobject + Vertex subobject + Vertex 3d subobject 依次存储。
(加上虚拟继承就不一样了)
存取第二层以上的基类的 data member ,只是一个简单的 offset 运算。
虚拟继承
虚拟继承的应用场景很狭窄,几乎是为解决多重继承的重复副本而生的。
//多重继承 class ios{//...}; class istream:public ios{//...}; class ostream:public ios{//...}; class iostream:public istream,public ostream{//...}; //虚拟继承 class ios{//...}; class istream:virtual public ios{//...}; class ostream:virtual public ios{//...}; class iostream:public istream,public ostream{//...};
iostream 继承 istream 和 ostream 时,只需要一个 ios subobject。解决方法即虚继承。
虚拟继承的实现需要将 两个基类各自维护的一个 ios subobject 折叠成一个由共同子类维护的单一 subobject,同时保存好基类和子类各自的指针(引用)间的多态指定操作。
Class 继承体系出现 virtual base class subobject 后,会分割成两部分:
-
不变区域:这里的数据不管后续继承的变化如何,拥有固定 offset (从 object 开头),可直接存取。
-
共享区域:即virtual base class subobject 部分的数据,其位置随着每次的派生操作都会有变化,只能间接存取。
各编译器对间接存取的实现技术不同。
以下是三种 v-base-class-s 的间接存取策略。
-
指针实现
class Point2d{ public: //... protected: float _x,_y; }; class Vertex:public virtual Point2d{ public: //... protected: Vertex *next; }; class Point3d:public virtual Point2d{ public: //... protected: float _z; }; class Vertex3d:public Vertex,public Point3d{ public: //... protected: float mumble; };
一般的布局策略是先安排好派生类中不变的部分,再建立共享部分。cfront编译器会在每一个派生类对象中安插一些指针,每个指针指向一个虚基类。要存取继承得来的虚基类成员,可以使用相关指针间接完成。
void Point3d::operator+=(const Point3d &rhs){ _x+=rhs._x; _y+=rhs._y; _z+=rhs._z; }; //在cfront的策略下,这个运算符会被内部转化为: //c++伪码 _vbcPoint2d->_x += rhs_vbcPoint2d->_x; //vbc 即virtual base class _vbcPoint2d->_y += rhs_vbcPoint2d->_y; _z+=rhs._z; //一个派生类和基类的实例之间的转换: Point2d *p2d=pv3d; //在cfront的实现模型下,会变成: //c++伪码 Point2d *p2d=pv3d?pv3d->_vbcPoint2d:0;
-
每一个对象必须针对每一个虚基类背负一个额外的指针,但是我们希望每一个类对象的大小是固定的,不因为其虚基类的数量而变化。
解决方法:
1、微软的编译器里会引入虚基类表(类似于虚函数表),在继承虚基类的子类对象中,通过一个虚基类表指针指向虚基类表(虚基类指针存放在这些表格中)。 2、在虚函数表中放置虚基类的offset(而不是地址)。
(作者实现时,将虚基类偏移地址和虚函数入口混杂在一起,通过正负值索引区分虚函数表中的地址:正数索引到虚函数,负数所引导虚基类)
下图显示了这种base class offset实现模型:
void Point3d::operator+=(const Point3d &rhs){ //这里_vptr_Point3d[-1]存放的是虚基类距离对象起始地址的offset //this是对象起始地址,所以加起来就是虚基类的subobject (this+_vptr_Point3d[-1])->_x += (&rhs + rhs._vptr_Point3d[-1])->_x; (this+_vptr_Point3d[-1])->_y += (&rhs + rhs._vptr_Point3d[-1])->_y; _z+=rhs._z; };//为了可读性,没有做类型转换,也没有先执行对效率有帮助的地址预先计算操作
这种功能的成本只会在member使用的过程中消耗,所以属于局部性成本(虽然本身有点昂贵)。
示例:
Point2d *p2d=pv3d; //在上述实现模型下变成: Point2d *p2d=pv3d?pv3d+pv3d->_vptr_Point3d[-1]:0;
-
每有一层虚拟继承,间接存取的层次就会加一层(三层继承,就要通过三个 virtual base class 指针进行三次间接存取)。我们希望每次存取时间都是固定的,不因为虚拟派生的深度而改变。
通过拷贝操作取得所有嵌套虚基类指针,将之放到子类对象中,这样就不用间接存取了,用空间换时间。下图显示了这种模型的实现:
区分
非多态的 class object 存取继承而来的 virtual base class 的成员:
Point3d origin; ... origin._x;
可直接被优化为直接存取,在这次存取和下一次存取的过程中间,对象类型不可改变。
如同对象调用虚函数可以在编译器完成。
一般而言,虚基类最有效的一种运用方式就是:一个抽象的虚基类,没有任何数据成员。
3.5 对象成员的效率
1、直接存取对象成员和使用inline的Get和Set函数存取对象成员经过优化后效率一样。 2、除了虚拟继承情况外,1中的效率一样(包括单一继承的情况)。随着虚拟继承层数增加,1中存取对象的时间增大。
3.6指向数据成员的指针
#include "pch.h" #include <iostream> using namespace std; class Point3d { public: ~Point3d(); void static getOffsetOfZ() { printf("%d\n", &Point3d::z); //8 //因为同一个acess section里的成员要按声明次序排列,z前面有x和y //一个float是4 bytes,这里是8说明vptr放在尾端,如果是放在头端这里的输出应该是12 bytes cout << &Point3d::z << endl; //1,因为Point3D没有定义<<操作,所以编译器这里自 // 己偷偷进行转化,输出结果就为1 } //... protected: static Point3d origin; float x, y, z; }; int main() { Point3d::getOffsetOfZ(); }
实际上 offset 往往比 正常地址位置 多1,也就是说,如果vptr放在对象头端,三个坐标值在对象布局中的offset分别是1,5,9;如果vptr放在对象尾端,三个坐标值在对象布局中的offset分别是5,9,13。
(原因和3.3中non-static成员中-1的原因一样):为了区别一个类数据成员类型的指针是空指针和指向第一个offset为0的成员时的情形.
理解了指向成员变量的指针后,就可以明确下 & Point3d::z
和 & origin.z
的差别了。
-
取 nonstatic data member 的地址:得到它在类中的 offset
-
取 绑定在特定实例对象身上的成员地址: 得到它在内存中的真正 address
&origin.z
减 z 偏移量 同时 加 1 ,即origin起始地址
& origin.z
返回类型为 float* 而不是 float Point3d::*
在多重继承下,如果要将第二个或者后继的基类指针和一个“与派生类对象绑定”的成员结合起来,会在偏移量的问题上变得比较复杂:
#include "pch.h" #include <iostream> using namespace std; struct Base1 { int val1; }; struct Base2 { int val2; }; struct Derived :Base1, Base2 {}; void func1(int Derived::*dmp, Derived *pd) { //第一个参数期待传入的是指向 Derived的成员 的指针 //但是如果传进来的是指向 基类的成员的 指针呢? printf("%d\n", pd->*dmp); //-858993460 } void func2(Derived *pd) { int Base2::* bmp = &Base2::val2; //注意这里特意设置为 base2指针而非base1 pointer printf("%d\n", bmp); //0,pffset为0没问题(注意,这里算上偏移量,bmp为0+1=1) //但是在Derived中,val2的offset是4 (额,offset便是4+1=5) func1(bmp, pd); } int main() { Derived d; func2(&d); }
当 (指向base2成员) 变量的bmp被作为func1()的第一个参数时,它的值就必须因介入的Base1 class的大小而调整,否则pd->*dmp
将存取到Base1::val1
,而不是希望的Base1:val2
,要解决这个问题,必须经过以下过程:
//经由编译器内部转换 func1(bmp+sizeof(Base1),pd); //还要防范bmp==0 func1(bmp?(bmp+sizeof(Base1)):0 ,pd);
指向 member 指针的效率问题就不赘述了,具体看书。
第四章 Function 语意学 (The Semantics of Function)
前言:
float Point3d::magnitude()const { return sqrt(_x*_x + _y*_y + _z*_z); } Point3d Point3d::normalize()const { /* 用register说明的局部变量称为寄存器变量,该变量将可能以寄存器作为存储空间。 register说明仅能建议(而非强制)系统使用寄存器,这是因为寄存器虽然存取速 度快,但个数有限,当寄存器不够用时,该变量仍按auto变量处理。 */ register float mag = magnitude(); Point3d normal; normal._x = _x / mag; normal._y = _y / mag; normal._z = _z / mag; return normal; }
下面我们进行操作:
Point3d obj; Point3d *ptr=&obj; //What Will Happen? obj.normalize(); ptr->normalize();
这里还无法确定实际的操作。
C++支持三种函数调用:static func , nontatic func , virtual func 。
每种方式都不同,也是我们接下来要区分的。
这里可以确定 normalize() 和 magnitude() 两函数绝不是 staic。
原因:
可以直接存取 nonstatic 数据
它被声明为了const
注释:static 数据没有隐式 this 指针,同理 static 成员不是任何对象的成分(元素),没有 const 对应的“不修改所属对象的成员属性”一说。
4.1Member 的三种调用方式
Nonstatic Member Function
1.nonstatic 成员函数至少和 nonmember function 有相同效率
达成这个效果,是因为编译器内部已将 成员函数 转换成了对等的 non-member 函数
举个例子吧:
float magnitude3d(const Point3d *_this) { return sqrt(_this->_x*_this->_x + _this->_y*_this->_y + _this->_z*_this->_z); }
反差:利用 this 指针 不断间接取用坐标成员好像降低了效率,不同于非成员函数的直接取用。
转化步骤:
-
改写函数原型(signature),安插一个 this指针 作为额外参数到成员函数中,从而使类对象被直接调用。
const 成员函数,其额外参数 this 指针同样是 const *this pointer。
-
将后续操作中涉及存取 nonstatic data member 的,改为通过 this 指针存取。
return sqrt(_this->_x*_this->_x + _this->_y*_this->_y + _this->_z*_this->_z);
-
将成员函数重新写成一个外部函数
为了防止重名,函数名会经过 mangling 处理,成为绝不会重名的词。
extern magnitude__7Point3dFv( register Point3d *const this);
成员函数转换为外部函数,意味着调用操作也完成了转换:
obj.magnitude();
变成了magnitude__7Point3dFv(&obj);
ptr->magnitude();
变成了magnitude__7Point3dFv(ptr);
这时回到原题:normalize函数的调用问题。
//伪码 void normalize__7Point3dFv(register const Point3d *const this, Point3d &__result) { register float mag = this->magnitude(); __result.Point3d::Point3d();//default constructor __result._x = this-> _x / mag; __result._x = this-> _x / mag; __result._x = this-> _x / mag; return; }
这是转变情况之一。
另外一种情况是利用Point3d的构造函数构造匿名对象简化操作:
Point3d::Point3d::normalize()const { register float mag = magnitude(); return Point3d(_x / mag, _y / mag, _z / mag); }
转化(假设拷贝构造相关操作已完成,NRV优化1也已实现):
void normalize__7Point3dFv(register const Point3d *const this, Point3d &__result) { //调用拷贝构造函数 __result.Point3d::Point3d(this->_x / mag, this->_y / mag, this->_z / mag); return; }
节省了默认构造初始化的额外负担。
2.名称的特殊处理(Name Mangling)
编译器的处理下,通常成员名称前会被加上类名称。
class Bar{public: int ival;} //成员 ival 经过Name Mangling 后的可能结果: ival_3Bar
-
Name Mangling的原因:便于区分重名变量。
1.子类继承时重名
class Foo:public Bar{public:int ival;}
子类Foo和父类发生成员变量重名的情况;编译器为了区分会处理名称:
class Foo { public: int ival_3Bar; int ival_3Foo; }
2.成员函数重载时重名
class Point { public: void x(float newX); float x(); };
简单转换为
class Point { public: void x__5Point(float newX); float x__5Point(); }; //会导致被重载的函数实例拥有相同名称。 //为了区分,还需要加上它们的参数链表,同时把参数类型也编码进去,就能够防止重复。 class Point { public: void x__5PointFf(float newX); float x__5PointFv(); };
这就是cfront的编码方法。目前还没有Name Mangling 的工业标准统一这一行为。
-
Name Mangling 能实现有限的类型检验:
声明和定义两部分参数类型不一致的函数,会产生不同的编码名称,这就会导致链接时期无法 resolved 而失败。
有限之处在于它只能涉足函数签名(signature),不能检测返回值。
-
拓展:
现在的编译系统中,有一种 demangling 工具,能把编码后的名称转换回去。这个系统内会存储两种名称:”mangled 之前“和”mangled 之后”。
这会导致一种误导行为:编译器错误信息提示时,使用源码名称;链接器却使用编码后的内部名称。
把内部名称(mangled 编码后)给 user 看会让 user 一头雾水。
Virtual Member Function
第三章讲过,如果normalize()
是个虚拟成员函数:
ptr->normalize() //converse to (*ptr->vptr[1])(ptr); register float mag = (*this->vptr[2])(this); //converse to register float mag = (*this->vptr[2])(this);
vptr : 指向 virtual table 的指针,由编译器产生安插在 virtual function -involved class 中。
vptr也会被 name mangling 处理。
1 是 virtual table slot 索引值,关联到 normalize
第二个 ptr 表示 this 指针。
这里还不是最终版本,因为这段代码还有值得优化的地方:
在上面的 code segmeng 中,magnitude() 是在 normalize() 中被调用的,后者由虚拟机制 resolved 后,可以直接显式调用 Point3d 实例中的 magnitude 函数。
大家都在一个类中,我又处在你的作用域里。
register float mag=Point3d::magnitude(); //直接调用算了,这能回避不必要的虚拟机织重复调用操作
还可以继续优化:将 magnitude() 声明为 inline 函数。这样一来代码会变成:
reginster float mag = magnitude__7Point3dFv(this); //mangled name!
通过 this 指针指出对象所在位置的同时调用虚函数,resolve 的状态会和非静态成员函数一样!
另外,对于函数obj.normalize()
转换成(* obj.vptr[1])(&obj)
的意义不大。
因为这个情况中的类对象并不支持多态,所以通过 obj 实例调用的函数只能是 Point3d::normalize()
。
通过类对象调用虚函数,会被编译器如同对待非静态成员函数一样,迅速完成 resolved ,成为带对象参数的普通函数调用。
此时,这个虚函数还成为了 inlined function 实例,能够在后续应用中广泛扩展,提高效率。
obj.normalize(); //not to (* obj.vptr[1])(&obj) //converse to normalize__7Point3dFv(&obj);
Static Member Function
//如果Point3d::normalize()是一个static member function,以下两个调用操作: obj.normalize(); ptr->normalize(); //将被转换为一般的nonmember函数调用, //obj.normalize()↓ normalize__7Point3dSFv(); //ptr->normalize()↓ normalize__7Point3dSFv();
对静态成员函数的处理,要分为“引入静态成员函数前”(cfront2.0引入)和引入后。
1.引入前的处理:
抛开上面的例子,看一个很有特点的函数形式:
((Point3d*) 0 )->object_count(); //嗯,这个 object_count 只是简单传回 _object_count 这个简单静态成员变量。
这种函数形式出现的原因:
-
引入...前,CPP要求所有的成员函数都必须通过对应类的实例对象来调用。
这也是 this 指针的重要作用之一
-
this 指针把 在成员函数中存取的非静态成员 绑定在了 类对象内的相应成员上。
但事实上:
-
只有当成员函数中,存在非静态成员变量的存取时,才需要类对象。
其他情况根本用不到 class object 。
-
成员函数如果没有成员被存取,那么它压根就不需要 this 指针。也没有必要强行要求通过一个类对象的实例来调用这个成员函数。
这种情况下,如果设计者把静态成员变量声明为 非public ,他就必须提供很多成员函数来存取这个成员变量。
这时,即使静态成员变量不需要类对象去存取,可对应的存取函数还是需要类对象,这就是一种无奈的绑定关系。
所以脱离类对象的一些存取操作还是很重要的。上面的奇葩函数形式就是这种脱离需求下的产物:
((Point3d*) 0 )->object_count(); //将 0 强转为一个类指针,来提供一个略显生硬的 this指针实例。 //(生硬之处:还是在费尽心机去贴合成员和对象间的绑定关系。)
这时,为了让独立于类对象的一些存取函数操作更自然:静态成员函数引入了。
2.引入后的处理
我们来看看引入的静态成员函数特性:
-
没有 this 指针。
-
不能直接存取所属类中的非静态成员。
-
不能被声明为 const、volatile、virtual。
-
不需要通过类成员去调用。
首先关于静态成员函数的“.”语法(打官腔叫“member selection”),编译器是这样处理的:
//类成员实例的直接调用: Point3d p3; if(p3.object_count() > 1) ... //converse to if(Point3d::object_count() > 1) ... //通过表达式获得类成员的调用 if(foo().object_count() > 1) ... //converse to (void) foo();//foo()函数还是被调用了,只是没有蹩脚的返回值而已。 if(Point3d::object_count() > 1)...
name mangled 效果:
unsigned int Point3d::object_count() { return _object_count; } //converse to unsigned int object_count__5Point3dSFv() { //额,这里的SFv表示它是一个static member function,拥有一个void(空白)的参数 return _object_count__5Point3d; }
取地址
如果此时你想取一个 静态成员函数 的地址,将获得其在内存中的地址。
这和其类成员实例无关,且类型不是指向类成员函数的指针(它和this指针已经没有关系),而是一个实实在在的非成员函数指针。
&Point3d::object_count(); //会得到一个数值,类型是 unsigned int(*)(); // right! //而不是 unsigned int(Point3d::*)(); //wrong.
静态成员函数没有了this指针束缚,效果几乎等同于非成员函数了。
扩展:
一个意想不到的好处:static member function 成为了一个回调函数(callback func)。
这有利于CPP和 C-based X Window 系统的兼容,也可以自然的应用在线程(thread)函数上。
4.2 虚拟成员函数
这一章我们会从头到尾过一遍设计虚函数模型的过程。
老生常谈的模型:一个类,里面有虚表,虚表里存储着虚函数的地址。该类的每个实例对象都会自带一个虚指针,指向这个虚表,来完成多态调用。
-
执行期多态的实现
既然是虚函数,那么看到这个操作:
ptr->z();
你面对的第一个问题便是执行期类型判断(runtime type resolution)。
先从编译期多态的尝试走起:
-
尝试把信息绑定在函数指针上
判断方式里,最自然的想法是将帮助判断对象的信息绑定在ptr身上。
这种策略下,通常一个指针需要的信息有:
-
它所指向的对象地址
-
对象类型的编码或者结构信息的地址。
Ps:结构信息是用来分辨对象函数实例的,即help resolved to z()。
这种策略的问题:
-
空间负担
-
和C程序的链接不再兼容。
如果这些额外信息不和指针放在一起,那就只能放在对象(class/struct)的身上了。
类似这种形式:
struct data{ int m,d,y; }
(这种形式没有摆脱把信息与指针绑定的弊端)如果为了C兼容性,而要求这种策略只针对class,也不能解决问题。
因为 struct 可能会发生多态,而 class 可能并不需要这些冗余信息。
-
执行期
尝试半天,这个责任还是落到了执行期。所以执行期多态到底怎么实现呢?
多态的定义:
在C++中,多态表示以一个public base class的指针(或引用),寻址出一个derived class object的意思。
Point *ptr; ptr = new Point2d; ptr = new Point3d;
消极多态:
让ptr扮演输送机制的角色,仅仅让基类指针指向子类对象,而没有其他操作。
积极多态:
指出的对象通过指针被使用。
//1. ptr->z(); //ptr指向了子类 //2. Point3d *p3d=dynamic_cast<Point3d *>(ptr) p3d->z();//这就说明是Point3d的_z函数
仅仅是这种操作还不够。因为我们无从知晓ptr指向的是基类还是子类或者它有没有实现多态。
结论:
识别一个class是否支持多态,只能看它是否有虚函数。只要要虚函数,它就需要额外的执行期信息。
-
-
存储的额外信息什么?
现在我们知晓了一个类要实现多态,需要执行期信息来完成正确多态操作。
需要的信息:
-
ptr所指对象的真实类型,方便找到正确的实例(如
z()
-
对象实例的位置,方便ptr调用
实现:
-
一个字符串或数字来表示类的类型
-
一个指向表格的指针,该表格存储虚函数执行期地址
这时,vptr和 virtual table 很自然的出现了。
剩下的问题是,虚表怎么找到并构建这些地址?
-
找到地址:虚函数地址在编译期就能通过类对象找到。而且这些地址固定不变,不需要执行期介入。
-
存储建构:
-
由编译器在每个类对象中安插一个指针指向该表格。
-
每个虚函数生成一个表格索引值,帮助表格寻找地址。
-
PS:表类似一个数组,索引值即下标,元素是虚函数地址。
(编译器立大功。)
这些都是编译期工作。执行期只是通过 virtual table slot (那些索引)找到地址,然后触发虚函数。
关于虚表的详细探讨:
一个class 只有一个虚表,每个表里有积极虚函数实例地址。
积极虚函数(active virtual func):
-
这一class所定义的函数实例。它会改写一个可能存在的base class virtual function函数实例
-
继承自base class的函数实例。这是在derived class决定不改写virtual function时才会出现的情况(也就是单纯的继承)
-
一个pure_virtual_called()函数实例(纯虚函数,没有内容),可以用来保存纯虚函数的空间,或当作执行期的异常处理函数等。
举个例子:
class Point { public: virtual ~Point(); virtual Point& mult(float) = 0; float x()const { return _x; } virtual float y() const { return 0; } virtual float z() const { return 0; } protected: Point(float x = 0.0); float _x; };
-
解释:虚析构函数生成 slot 1,mult() 生成 2,y() 和 z() 分别为 slot3 slot4。
x()不是虚函数,没有slot。
PS:mult()作为纯虚函数,没有定义。如果意外调用,直接结束掉。
继续深入一下,找个 class 继承 Point 会是什么结构呢?
class Point2d :public Point { public: Point2d(float x = 0.0, float y = 0.0) :Point(x), _y(x) {} ~Point2d(); //改写base class virtual functions Point2d& mult(float); float y()const { return _y; } protected: float _y; };
一共又三种可能性:
1.它可以继承base class所声明的virtual functions的函数实例。正确地说是,该函数实例的地址会被拷贝到derived table的相对应slot之中。比如上图中的#4位置
2.它可以使用自己的函数实例,也就是在base class中的virtual function上做改变了。这表示它自己的函数实例地址必须放在对应的slot之中。比如上图中的#2、#3位置
3.它可以加入一个新的virtual function。这时候virtual table的尺寸会增大slot,而新的函数实例地址则会被放入该slot之中
( Point2d的virtual table在slot1中指出析构函数,而在slot2中指出mult()(取代纯虚函数)。它自己的y()函数实例放在slot 3中,继承自Point的z()函数实例地址则被放在slot 4中。 )
继续继承:
class Point3d :public Point2d { public: Point3d(float x = 0.0, float y = 0.0, float z = 0.0) :Point2d(x, y), _z(z) {} ~Point3d(); //改写babse class virtual functions Point3d& mult(float); float z()const { return _z; } protected: float _z; };
其virtual table中的slot 1位置放置Point3d的destructor,slot 2放置Point3d::mult()函数地址,slot 3放置继承自Point2d的y()函数地址,slot 4放置自己的z()函数地址。
现在,让我们再次面对ptr->z();
如何在编译期设定虚函数的调用?
可以解答如下了:
1.一般而言,在每次调用z()时,我并不知道ptr所指对象的真正类型。然而我知道,经由ptr可以存取到该对象的virtual table。
2虽然我不知道哪一个z()函数实例会被调用,但我知道每一个z()函数地址都被放在slot4中,从上面的图可以看出来。
由这两条信息使得编译器可以将该调用转化为:
(*ptr->vptr[4])(ptr); //编译期工作:vptr指向vitrual table,生成编号 slot4 //执行期工作:知道 slot 4 指向的那个z()示例
这是单一继承下,虚函数的实现过程。
多重继承下的虚函数
-
编译期实现多态的困境
多重继承中虚函数的复杂度,主要来源于第二个或后续基类和执行期 this 指针的调整。
示例:
class Base1 { public: Base1(); virtual ~Base1(); virtual void speakClearly(); virtual Base1 *clone() const; protected: float data_Base1; }; class Base2 { public: Base2(); virtual ~Base2(); virtual void mumble(); virtual Base2 *chone() const; protected: float data_Base2; }; class Derived :public Base1, public Base2 { public: Derived(); virtual ~Derived(); virtual Derived *clone() const; protected: float data_Derived; };
让 Derived 支持虚函数困难度落在了 Base2 子对象上。
这里我的理解是,Base1和Derived的指针位置是一样的,都为0;而Base2指针的指向地址则>是
0 + sizeof(base1)
。 这时我们面前有三个问题:1.虚析构2.继承而来的Base2::mumble()
3.一组clone()函数实例。
这时,我们尝试多态操作:
指针赋值
Base2 *pbase2 = new Derived; //编译期的代码会变成这样: Derived* temp = new Derived; Base2 *pbase2 = temp?temp + sizeof(Base1) : 0; //这里的思想第三章有阐释(例如不这样做,相关 base2 调用会失败)
删除
delete pbase2;
这里出现了问题:指针必须再次调整,因为要指向Derived对象的起始处。但这个指向涉及的偏移量操作已经不能在编译期设定,因为 pbase2 指向的真正对象根本确定不了(该死的多态),除非在执行期。
1、这里是开头所说的问题之一:虚析构。
delete pbase2的时候Derived,Base1,Base2的析构函数都要被调用。
因为一个Derived对象中含有Base1和Base2的subobject,所以也需要调用它们的析构函数销毁它们的对象并释放内存空间(当然只有析构函数是这样)。
父类子对象可能有很多种多态情况,因此我们无法确定其指向的真正对象,(不同对象的偏移量不同),所以不能在编译期准确调用其析构函数。
//本例中的多态就有两种情况 //两个new返回的都是首地址:子类子对象在地址0处,父类子对象在0+sizeof(Base2)处 Base2 *pbase2 = new Derived; //该情况不需要偏移(甚至不需要this) Base2 *pbase2 = new Base2; //该情况需要偏移 //于是编译器处理的两种结果不同 (*pbase2->vptr[1])(pbase2 + offset???)而且有无偏移量是一种情况,偏移量是多少又是一种情况(比如后续基类的Base3,Base4 ...)
另外,调用虚析构函数时 this 指针发生了偏移(指向pbase2 到 指向 derived ,因为你需要调用人家的函数)。我既需要调整后的derived对象的起始地址,我又需要调整前的
pbase2::vptr
,来指向子类析构函数实际地址。这里出现矛盾:我同时需要调整前后的信息。编译器如何实现的情况,我的理解是:
1.调整前先通过pbase2找到vptr中Derived的析构函数地址,
2.隐式地传this指针时才会调整指回整个Derived对象的起始地址,像这样:
(*pbase2->vptr[1])(pbase2+offset); //pbase2-sizeof(Base1) //左括号调整前,右括号调整后
-
执行期解决方案:替代加大虚表的方法:thunk设计
执行期解决的一般的思路是,通过第二或后续的基类指针调用子类虚函数。 该操作必需的“this指针调整”操作,必须在执行期完成。
这意味着,操作的信息:offset的大小,以及把offset加上this指针的那一小段代码必须由编译器安插在某个地方。
问题是,在那个地方?
最自然(最笨)的方法是,都塞到虚表里,把虚表加大,来容纳这些偏移信息和地址。
虚表的 slot 也从整型之类的数字类型变成了结构体。
(pbase2->vptr[1])(pbase2); //改变为: (*pbase2->vptr[1].faddr) (pabse2 + pbase2->vptr[1].offset)
毫无疑问,太大了。不需要这些信息的空间也被塞满了。
thunk:主角登场
thunk是一小段汇编代码,实现:1.调整this指针偏移量 2.跳到目标虚函数中
pbase2_dtor_thunk: this += sizeof(base1); Derived::~Derived(this);
所以可以暂时将thunk理解成一个函数,它是对上述两点操作的封装(调整this指针和调用对应的函数),只不过它是用汇编语言写才有效率可言。
thunk 让 虚表 slot 内含一个简单指针,要么指向虚函数,要么指向一个thunk(也就是要调整this指针时)。
但一个问题是,就如我们分析编译期局限性时一样,可能有多个不同继承层级的对象来调用同一个析构函数,这时的偏移量是不同的,对应slot也不同。
最高层级且处于最左侧的类对象,甚至不需要偏移量——它和子类对象的起始地址一样。这时slot直接放真正的调用函数地址就好了。
多重继承下,一个派生类内含n-1个额外的虚函数表,n表示上一层基类的数目(因此单一继承不会有额外的虚函数表)。如果子类有额外的虚函数,会存放在第一个基类的虚函数表里。
对本例Derived而言,编译器会产生有两个虚函数表。 分别对应 Base1 和 Base2 。针对每个虚表,子类对象都会有专门的 vptr ,且这些 vptr 会在构造函数中被赋初值(编译器的工作喽)
于是子类对象地址赋给子类指针或父类指针,就会产生多态效果——根据不同的类型,用不同表格来处理。
执行期链接器处理符号链接这种东西可能有点慢。一个思路是将多个虚表连接成一个:次要表格(次要仅从继承顺序上说)的获取只需要主要表格地址加上偏移量。
-
后续基类影响虚函数运行的三种情况
这三种情况在上面的图里有所反映
-
通过指向同层、次级父类的指针,调用子类虚函数
Base2 *ptr = new Derived; //调用Derived::~Derived //ptr要减去sizeof(Base1)个bytes delete ptr;
之前已经讨论过,为了正确执行,ptr必须调整指向子类对象的起始处 (整个对象地址的 0)
-
通过指向子类对象的指针,调用同层、次级父类继承而来的父类自己的虚函数
Derived *pder = new Derived; //调用Base2::mumble() //pder要加上sizeof(Base1)个bytes pder->mumble();
第一种情况的逆向。到父类子对象那里调用父类的虚函数
-
允许一个virtual function的返回值类型有所变化,可能成为base type,也可能是publicly derived type。
Base2 *pb1=new Derived; //调用Derived* Derived::clone() //返回值必须被调整,以指向Base2 subobject Base2 *pb2=pb1->clone();
Derived::clone()传回一个Derived类指针,改写了它的两个基类的函数体。当我们通过指向第二个基类的指针来调用clone()时,this指针的offset问题就会产生:
当进行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,所以调用的是Derived::clone()。它会传回一个指针,指向一个新的Derived对象,该对象的地址在被指定给pb2前,必须先经过调整,以指向Base2 subobject。
书中还提供了两种提高效率的方法,一种是sun编译器的"split function",一种是微软的“address points”
虚继承下的虚函数
class Point2d { public: Point2d(float = 0.0, float = 0.0); virtual ~Point2d(); virtual void mumble(); virtual float z(); protected: float _x, _y; //纯纯毒瘤 }; class Point3d : public virtual Point2d { public: Point3d(float = 0.0, float = 0.0, float = 0.0); ~Point3d(); float z(); protected: float _z; };
图右下角的两个vtbls内容有错。至少mumble()应该是Point2d::mumble 而不是 Point3d::mumble。
此例反映出的唯一有用的信息便是虚继承下的内存空间排布和正常继承不一样,并且一旦虚基类中虚继承了另一个虚基类,整个内存空间会非常混乱。this 指针的调整就会复杂难辨。
所以我们的启示只有一个:不要再虚基类里声明 nonstatic data members 。
指向成员函数的指针
-
nonstatic 成员函数 和 nonstatic 成员变量与类对象的绑定
-
取非静态成员变量的地址,是它在类中的相对byte位置(可能+1),这个offset需要类对象的地址,也就是绑定关系。
-
取非静态成员函数的地址,如果函数为non-virtual,它就和成员变量一样,是个绑定在类对象上的offset 地址。
对象地址来自 this指针 。
-
-
指向成员函数的指针
//double是返回值类型 //Point::*说明是Point类成员,pmf是成员指针名 //最后的()是参数列表 double (Point::*pmf)(); //两种方法定义并初始化该指针的两种方法: double (Point::*coord)() = &Point::x; coord=&Point::y; //调用的两种方法: (origin.*coord)(); (ptr->*coord)(); //分别会被转换成(C++伪码) (coord)(&origin); (coord)(ptr);
指向成员函数的指针的声明语法,以及指向“member selection”运算符(->或 .)的指针,这两部分的作用是为this指针保留空间。
所以静态成员函数的类型是函数指针,因为他们不需要 member selection ,更不需要 this指针。
在没有虚函数,多重继承,虚拟继承的情况下,编译器可以让 使用成员函数的指针与使用非成员函数的指针 的效率相同。
支持“指向虚拟成员函数”的指针
-
需求场景
//z()是个虚函数 (Point::*pmf)()=&Point::z; Point *ptr=new Point3d; ptr->z(); //调用Point3d::z() (ptr->*pmf)(); //调用Point3d::z()
ptr 调用 z(),被调用的是 Point 3d::z();
ptr 通过 pmf 调用z(),被调用的还能是 Point3d 中的 z 吗?
答案是肯定的。
-
实现方法
首先,虚函数真正地址在编译期未知,只能知道它的 virtual table slot。
class Point{ public: virtual ~Point(); float x(); float y(); virtual float z(); }; &Point::~Point; //得到虚函数表里的索引1 &Point::x(); //得到内存地址 &Point::y(); //得到内存地址 &Point::z(); //得到虚函数表里的索引2 //通过 pmf 调用 z(),会被内部转化为编译期的式子: (*ptr->vptr[(int)pmf])(ptr); //大概是这个样子了
于是函数指针就有了两个意义:普通成员函数的内存地址和虚函数的slot专属版。
//pmf内部定义 float(Point::*pmf)();
这个函数要具备能寻址两种类型的成员函数的能力:
float Point::x() { return _x;} //一长串吧 float Point::z() { return 0;} //小串 //都可以指定给 pmf
于是pmf有两个功能:
-
能持有两种数值(注意不是同时)
-
能区分这个数值是内存地址还是虚表索引值
cfront2.0的解决办法:
(((int)pmf)&~127) ? (*pmf)(ptr) : (*ptr->vptr[(int)pmf](ptr));
这个实现只能应用在继承体系中最多只有128个虚函数的情况。
-
多重继承下,指向成员函数的指针
如题,怎样让指向成员函数的指针在多重继承下可行?
Stroustrup的方法:
struct __mptr { int delta; int index; //表现 虚表索引 (用不到就设为-1) union { ptrtofunc faddr; //表现 成员函数 int v_offset; }; }; (ptr->*pmf)(); //转换为 (pmf.index < 0) ? //non-virtual (*pmf.faddr)(ptr) : //virtual (*ptr->vptr[pmf.index](ptr));
缺点:
-
检查成本高
Microsoft把这项检查拿掉,导入一个它所谓的vcall thunk。
在此策略下,faddr被指定的要不就是真正的member function(如果函数是nonvirtual的话),要不就是vcall thunk的地址。
于是virtual或nonvirtual函数的调用操作透明化,vcall thunk会选出并调用相关virtual table中适当的slot。
-
传递一个不变值指针给成员函数时,它需要产生一个临时对象
举例子:
extern Point3d foo(const Point3d& , Point3d (Point3d::*)()); void bar(const Point3d& p ){ Point3d pt = foo(p,&Point3d::normal ); } //&Point3d::normal 的值类似这样: {0 , -1 , 10727417} //需要产生临时对象,有明确初值 _mptr temp = {0 , -1 , 10727417} //伪码 foo(p,temp);
回到开始的那个结构体:
delta字段表示this指针的offset指针,而v_offset字段放的是一个virtual base class(或多重继承中的第二个、第三个等等)的vptr位置。
如果vptr被编译器放在class对象的起头处,这个字段就没什么必要了:这些字段在多重继承或虚拟继承的情况下才有必要性。
有许多编译器在自身内部根据不同的classes特性提供多种指向member functions的指针形式,例如Microsoft就供应了三种针对:
一个单一继承实例(其中带有vcall thunk地址或是函数地址)
一个多重继承实例(其中带有faddr和delta两个members)
一个虚拟继承实例(其中带有四个members)
内联函数
-
加了inline关键字,函数不一定就是内联函数。
编译器真的相信它可以扩展成inline函数时,其执行成本比一般函数调用和返回机制带来的负荷要低。
-
编译器处理inline函数,有两个阶段:
-
分析函数定义和复杂度,编译器会判断能否成为inline函数。
如果函数成不了inline ,就会转成一个static函数,并在被编译模块内产生对应函数定义。
-
真正的inline函数扩展操作是在调用的那一点上,这会带来参数的求值操作以及临时性对象的管理。
-
Formal arguments
inline 函数扩展期间,形参会被实参替代。
但无脑替代实参决定是有副作用的,比如一些表达式的重复求值之类的。
inline int min(int i,int j){ return i<j?i:j; } inline int bar(){ int minval; int val1=1024; int val2=2048; minval=min(val1,val2); //参数直接替换,会扩展成 minval=val1<val2?val1:val2; minval=min(1024,2048); //计算常量表达式1024<2048?1024:2048得出结果1024,直接使用常量minval=1024 minval=min(foo(),bar()+1) //有副作用,导入临时对象,避免重复求值 //int t1,t2; //minval=(t1=foo()),(t2=bar()+1),t1<t2?t1:t2; return minval; }
Local variables
加入一个局部变量,类似int minval
inline int min(int i,intt j){ int minval=i<j?i:j; return minval; } //如果这样调用 { int local_val; int minval; //... minval=min(val1,val2); } // inline函数扩展后的局部变量可能变成下面的样子 { int local_val; int minval; //将inline函数的局部变量mangling int __min_lv_minval; minval=(__min_lv_minval=val1<val2?val1:val2),__min_lv_minval; }
如果 inline 函数以单一表达式扩展多次,则每次扩展都需要自己的一组局部变量。
inline 函数以分离的多个式子被扩展多次,那么只需要一组局部变量求值就能重复使用。
inline
函数扩展后的局部变量,再加上有副作用的参数,可能会导致大量临时性对象的产生,特别是它以单一表达式被扩展多次的话,例如:
//例如: minval=min(val1,val2)+min(foo(),foo()+1); //可能扩展成: //为局部变量产生临时变量 int __min_lv_minval_00; int __min_lv_minval_01; //为放置副作用值而产生临时变量 (副作用值即要重复计算的值) int t1; int t2; minval= ((__min_lv_minval_00 = val1 < val2 ? val1:val2), __min_lv_minval_00) + ((__min_lv_minval_01= (t1=foo()), (t2=foo()+1), t1 < t2 ? t1:t2), __min_lv_minval_01 ); }
两个特性
-
inline 函数可以有效存取封装于class中的nonpublic数据,同时也是#define的一个安全代替品。
但其弊端是,一个inline函数如果被调用太多次,会产生大量的扩展码,使程序的大小暴涨。
-
inline里再有inline可能因为连锁复杂度扩展不出来。
所以你需要小心处理 inline 函数
[1] NRV优化:
返回一个即将销毁的局部对象,像这样:
X bar() { X xx; ... return xx; }//bar 的返回值怎么从局部对象 xx 中拷贝回来。
cfront做法:
-
加上一个类型为类对象引用的额外参数,用来放置拷贝构造出的返回值。
-
return 前穿插一个拷贝构造的操作:利用传回值(例子中的
xx
)初始化上面增加的额外参数。
于是我们希望得到的局部对象值,在真正的返回值返回通过额外参数返回了;而真正的返回值呢?
void bar(X& __result) //额外参数 { X xx xx.X::X(); //默认构造 __result.X::XX(xx); //拷贝构造 return ; //真正的返回值什么都不返回(注意函数类型) }
接下来转换每个bar()调用操作:
X xx = bar(); //转化为 X xx; bar(xx); //不必实行默认构造 bar().memfunc(); //可能转化为 X __temp0 (bar(__temp0),__temp0).memfunc(); X (*pf)(); pf = bar; //转化为 void (*pf)(X&); pf = bar;
概括来说:用一个载体来 copy 对象(已经有载体了就作为参数)。
第五章:构造、析构、拷贝语意学
-
不要在纯虚基类里声明成员变量!
这是《effective c++》里的一条忠告,现在我们回顾一下。
下面是个漏洞百出的例子,并且围绕漏洞会有所讨论:
class Abstract_base { public: virtual ~Abstract_base() = 0; virtual void interface() const = 0; virtual const char* mumble() const {return _mumble;} protected: char* _mumble; // damn bug... }; //这个类需要一个显式的构造函数初始化mumble,否则后续使用很容易出问题
可能你想的是后续子类能完成初始化工作。这样想的话,纯虚基类还是要提供一个接口:带有参数的构造函数(最好还是 protected 权限)。
Abstract_base:: Abstract_base(char* mumble_value = 0) //like this :_mumble(mumble_value){}
真是有够麻烦的......不要再提后面的使用者或修改者忘记赋值之类的问题了。。
最实用的观点还是:不要在纯虚基类里声明成员变量。
但在某些情况下,把子类共享的数据放到基类里也算是一种自然的想法和设计。
-
纯虚函数的存在
虽然和设计初衷相违背,但纯虚函数确实可以在虚基类里定义甚至调用。
inline void Abstract_base:: interface() const { function //... } inline void Concrete_derived::interface() const { //静态调用 //调用一个pure virtual function Abstract_base::interface(); //调用的方式是静态调用而非虚拟机制特有的执行期动态调用。 }
-
Pure Virtual destruction
例外是纯虚析构。你必须定义它。
因为析构“树”的存在,子类对象的析构连带着其父类的所有析构,到纯虚基类这里不能缺席啊。
准确来说:静态子类析构会被编译器扩张,静态调用其基类(包括虚基类)的析构函数。析构函数的缺失会导致链接失败。
纯虚函数定义的可能性,使编译器不会在面对纯虚析构函数时停止执行。
另外,编译器不能像默认构造函数那样自动合成纯虚析构函数。
因为编译器对可执行文件采取“分离编译模型”。编译器是看不到那些必要的信息的。
这里的结论秉承上文:不要把虚析构声明成纯虚函数。
-
虚拟准则(virtual specification)
所谓“虚构准则”:不要把所有成员函数都一刀切的声明为虚函数,并妄想编译器的优化操作能去除非必要的virtual-invocation
。
像上例中只是返回成员变量的 Abstract_base::mumble
:
-
因为函数定义内容和继承类型无关,完全没必要 virtual 。
-
它的non-virtual函数实例是个inline函数,常常调用会拉低效率。
理论上,编译器如果发现整个继承体系中只有这一个virtual函数,是否能将其调用操作转换为静态调用,并允许其调用操作的 inline expansion 呢?
这么做以后,新的 class 加入又包含了这个单一virtual函数的虚构函数,就会破坏这个优化:函数会被重新编译,产生第二个 virtual 函数实例来配合多态。
(实例能够以二进制形式存放在 library 中)
啊,别把这些麻烦事推给编译器了,记住这句话:
不要为了图省事把所有函数声明为 virtual 。
-
虚拟准则中 对 const 的态度
注意:语境为虚拟继承体系中
态度:别用const
决定虚函数是否用const
-
使用const:预期子类对象中的 subclass object 会被使用无数次。
-
不适用const:该函数将不能获得一个 const 引用和指针。
真正的难题:声明为const,但发现子类对象又要修改相关成员变量。
别用const。
-
重新考虑 class 的声明
上例的最终版本如下:
class Abstract_base { public: virtual ~Abstract_base(); //不再是pure virtual void interface() const = 0; //不再是const const char* mumble() const {return _mumble;} //不再是virtual protected: Abstract_base(char* pc = 0); //新增一个带唯一参数的constructor char* _mumble; };
5.1 “无继承”情况下的对象构造
下面展示产生不同对象的方式
Point global; //global 内存配置 Point foobar() { Point local; //local内存配置 Point *heap = new point; //heap内存配置 *heap = local; //把一个类对象指定给另一个(拷贝赋值) //...stuff... delete heap; //显式 delete 删除 heap object return local; }
注意:这里出现的 Point 数据类型尚未定义,因为接下来会根据不同情况进行分析。
情况1:质朴的C struct
typedef struct { float x, y, z; }Point;
普及一个概念:POD —— Plain OI' Data ,可以理解为与C兼容的 c++ 数据类型。
这里的 Point 便是 POD 。
用C++编译器编译这个POD时:
观念上Point的trival constructor和destructor都会被产生出来并被调用,constructor在程序起始处被调用而destructor在程序的exit()处被调用(exit()是由系统产生的,放在main()结束之前)。然而,事实上那些trival members要不是没被定义,就是没被调用。表现和C编译器没什么区别。
-
local变量
作为POD没有被构造也没有必要析构,但这里没有初始化
-
heap object
Point *heap = new Point; //会被转换为 Point *head = __new(sizeof(Point)); //空间而已
没有默认构造函数调用在 new 出来的 Point 对象上。
-
拷贝赋值
local 如果被初始化了就当然没问题,但没初始化的话问题也不大:local 对象是个POD,所以赋值操作只是简单的C风格的二进制码位搬移。
-
delete 操作
delete heap; //转换为 __delete(heap);
这个操作理应触发编译器产生的
trival destructor
,但析构函数要么没被产生要么没被调用。函数最后通过传值方式把local传回,这也理应触发
trival constructor
,但这里 return 仅仅是个位拷贝操作,因为对象是个POD。
注意例外:global 变量 ——
-
在C中被视为临时定义:因为它没有显式初始化操作,所以可在程序中定义多次。
定义多次的实例会被链接器折叠起来,只留下单独一个实例,存储在程序 data segment 中一个空间中。
这个空间“特别保留给未初始化的全局对象使用”,称作BBS(Block Started by Symbol)。
-
在C++中被视为完全定义(它会阻止第二个或更多个定义)。C++根本不支持临时定义,因为class构造行为的应用。
C和C++的一个差异就在于:C++的所有全局对象都被以“初始化过的数据”来对待。 BBS对c++来说没那么重要。
即使C++有能力判断这个类是 class object 还是 POD。
情况2:抽象数据类型
class Point { pubblic: Point(float x = 0.0, float y = 0.0, float z = 0.0) : _x(x), _y(y), _z(z) {} private: float _x, _y, _z; };
情况3:为继承做准备
class Point { public: Point(float x = 0.0, float y = 0.0) : _x(x), _y(y){} virtual float z(); protected: float _x, _y; };
5.2 继承体系下的对象构造
当我们定义了一个object
,如:
T object
除了会调用其构造函数外,还可能伴随大量的隐藏码:这些隐藏代码由编译器扩充。扩充程度要看 class T
的继承体系。
扩充操作如下:
-
初始化列表:在成员初始化列表中的成员变量初始化操作会被放进构造函数本体,并以成员的声明顺序为顺序。
我个人对“以成员的声明顺序为顺序“ 存疑
-
成员构造函数:成员如果没有被初始化,而它本身有个默认构造函数,这个默认构造函数会被强制调用。
-
虚表指针(vptr):如果类对象里有虚表指针,编译器会为其设定初值来指向适当的虚表。
-
基类构造函数:父辈的基类构造函数会按声明顺序(和成员初始化列表无关)被调用。
-
如果基类在成员初始化列表中,就应把需要显式指定的参数都传递过去
-
基类没有在成员初始化列表中,但它有默认构造函数(默认 memberwise 拷贝构造也行),就调用。
-
如果基类是多重继承下,第二或后继的基类,this 指针就要有所调整。
-
-
调用所有虚基类构造函数(从左到右,从深到浅):
-
如果类在成员初始化列表中,就把需要显式指定的参数都传递过去,没有就调用默认构造函数。
-
类中的虚基类子对象的偏移量,要能在执行期存取。
-
如果类处于继承体系中最底层,其构造函数会可能被调用,所以其调用机制也要由编译器放进来。
-
下面结合实际例子,来看看这些扩充机制的必要性。
以Point为例(增加了拷贝函数和虚析构):
class Point { Point(float x = 0.0, float y = 0.0); Point(const Point&); Point& operator=(const Point&); virtual ~Point(); virtual float z() { return 0.0; } protected: float _x, _y; }; class Line { Point _begin, _end; //它由两个点构成~ public: Line(float = 0.0, float = 0.0, float = 0.0, float = 0.0); Line(const Point&, const Point&); void draw(); };
先看看 line class 的扩充结果:
每一个显式构造函数都会扩充和调用其两个成员类对象(Point)的构造函数。
Line::Line(const Point &begin, const Point &end) :_end(end), _begin(begin) {}
会被编译器扩充为
Line* Line::Line(Line *this, const Point &bebgin, const Point &end) { this->_begin.Point::Point(begin); this->_end.Point::Point(end); return this; }
于Point声明了一个copy constructor、一个copy operator,以及一个destructor,所以Line class的implicit copy constructor、copy operator和destructor都将是有具体效用的,即non-trivial。 (trivial是没意义的杂项)
再看一个例子:
Line a;
implicit Line copy destructor会被合成出来,同时会调用其成员类对象的析构函数(以构造的相反顺序)。
// C++伪代码:合成出来的Line destructor inline void Line::~Line(Line *this) { this->_end.Point::~Point(); //虽然Point::~Point()是virtual this->_begin.Point::~Point();//其调用操作仍然会静态决议,在containing class destructor中 } //如果Point的析构是inline函数,则会在调用地点扩展。
同理
line b = a; //mplicit line copy constructor会被合成出来,成为一个inline public member。 a = b; //mplicit copy assignment operator会被合成出来,成为一个inline public member。
最后,多数编译器会缺少对自我指派情况的处理。
if(this == &rhs) return *this; //like this
然而很多时候都要考虑这种情况,像拷贝操作时忘记:
// 使用者供应的copy assignment operator // 忘记提供一个自我拷贝时的筛选 String &String::operator=(const String &rhs) { // 这里需要筛选(在释放资源之前) //if(this == &rhs) return *this; delete []str; str = new char[strlen(rhs.str) + 1]; }
虚拟继承
虚拟继承,啊,还是继承我们的Point class
吧。
class Point3d : public virtual Point { public: Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point(x, y), _z(z) {} Point3d(const Point3d &rhs) : Point(rhs), _z(rhs._z) {} ~Point3d(); Point3d &operator=(const Point3d &); virtual float z() { return _z; } protected: float _z; };
传统的"constructor扩充现象"并没有用,这是因为 virtual base class 的"共享性"的缘故:
// C++伪代码:不合法的constructor扩充内容 Point3d *Point3d::Point3d(Point3d *this, float x, float y, float z) { this->Point::Point(x, y); this->__vptr_Point3d = __vtbl_Point3d; this->__vptr_Point3d__Point = __vtbl_Point3d_Point; this->_z = rhs._z; return this; }
Point3d constructor 扩充内容有错误,这里卖个关子。
-
现在对不同继承层次对象的初始化策略
//看看这三种派生情况 class Vertex : virtual public Point { ... }; //Vertex的constructor必须也调用Point的constructor。 class Vertex3d : public Point3d, public Vertex { ... }; //holly shit double inherit //当Point3d和Vertex同为Vertex3d的subobjects时,它们对Point constructor的调用操作一定不可以发生,取而代之的是,作为一个最底层的class,Vertex3d有责任将Point初始化。 class PVertex : public Vertex3d { ... }; //更往下的继承,则由PVertex(不再是Vertex3d)来负责完成"被共享的Point subobject"的构造。
-
传统的初始化策略
传统的初始化策略如果要支持初始化虚基类,会导致constructor中有更多的扩充内容,用以指示 virtual base class constructors应不应该被调用。
constructor的函数本身因而必须尝试测试传进来的参数,然后决定调用或不调用相关的 virtual base class constructors。
下面是Point3d的扩充内容(伪码)
// C++伪代码:在virtual base class情况下的constructor扩充内容 Point3d *Point3d::Point3d(Point3d *this, bool __most_derived, float x, float y, float z) { if (__most_derived != false) this->Point::Point(x, y); this->__vptr_Point3d = __vtbl_Point3d; this->__vptr_Point3d_Point = __vtbl_Point3d__Point; this->_z = rhs._z; return this; }
在更深层的继承情况下,例如Vertex3d, 当调用Point3d和Vertex的constructor时,总是会把__most_derived参数设为 false,于是就压制了两个constructors中对Point constructor的调用操作。
// C++伪代码:在virtual base class情况下的constructor扩充内容 Vertex3d *Vertex3d::Vertex3d(Vertex3d *this, bool __most_derived, float x, float y, float z) { if (__most_derived != false) this->Point::Point(x, y); // 调用上一层base classes // 设定__most_derived为false this->Point3d:::Point3d(false, x, y, z); this->Vertex::Vertex(false, x, y); // 设定vptrs // 插入user mode return this; }这样的策略得以保持语意的正确无误.例如,
当定义:
Point3d origin
时, Point3d constructor可以正确地调用其Point virtual base class subobject当定义:
Vertex3d cv
时, Vertex3d constructor正确地调用Point constructor.Point3d和Vertex的constructor会做每一件该做的事情——对Point的调用操作除外。
结论:只有当一个完整的类对象被定义出来(origin),虚基类构造函数才会被调用。
如果object 只是个子对象,就不会调用。
某些新的编译器,为了产生更有效率的构造函数,将每个构造函数一分为二:
一个针对完整的object:“完整object”版无条件地调用virtual base constructor,设定所有的vptrs等。
一个针对 subobject :“subobject”版则不调用virtual base constructors,也可能不设定vptrs等。
vptr初始化语意学
-
继承体系下,构造函数的调用顺序
以 PVertex 对象为例,它的构造函数调用顺序:
Point(); Point3d(); Vertex(); Vertex3d(); PVertex();
假设每个class都定义了一个
virtual function size();
返回该class的大小。我们来看看定义的PVertex constructor:
PVertex::Pvertex(float x, float y, float z) : _next(0), Vertex3d(x, y, z), Point(x, y) { if(spyOn)//每个构造函数内含一个调用操作 cerr << "Within Pvertex::PVertex()" << "size: " << size() << endl; }
在一个类的构造函数或析构函数中,通过构造对象来调用一个虚函数,其函数实例应该是在此类中真正有作用的那个。(本例中“该类”为Point3d)
在Point3d 构造函数中调用的 size() 函数,必须被决议为 Point3d::size() 而非PVertex::size()。
基类构造函数执行时,子类还没有构造起来:
Pvertex构造函数没完成前,Pvertex还不是完整对象;
Point3d构造函数执行完毕后,紧紧意味着Point3d的子对象构造完毕了
构造函数顺序:由父到子,由内而外。
-
如何保证适当的重名函数被调用
上面的实现很妥帖,因为每个Pvertex 基类构造函数被调用时,编译器保证了适当的size函数被调用。
如何实现呢?
-
静态决议每个调用操作
既然静态决议了,就不要用虚拟机制。
在Point3d 的构造函数里,就调用Point3d 的size()。
如果size()里又调用虚函数,这个调用必须决议为Point3d的函数实例。
其他情况下,这个调用会视作virtual,要通过正常虚拟机制决定执行。也就是说虚拟机制本身要知道这个调用源来不来自一个构造函数中。
-
在构造函数/析构函数中设立一个标志
标志的作用是判断是否要以静态方式决议。
但更好的设计是执行构造函数后,让可能要调用的虚函数数量少些。
决定虚函数数量的关键是虚表。决定虚表如何处理的关键是vptr。
-
-
vptr :决定虚函数调用的关键
vptr的决定效果来自初始化和设定操作。这些操作是编译器的责任,程序员不用瞎操心。
但还是要看看编译器怎么做到的:
vptr 什么时候初始化?无非三个情况:1.在其他任何操作前 2.在基类构造函数调用后,但还没进行成员初始化。3.所有操作后
情况2 更好。
令每一个base class constructor设定其对象的vptr,使它指向相关的virtual table之后,构造中的对象就可以严格而正确地变成“构造过程所幻化出来的每一个class”的对象。
一个PVertex对象会先形成一个Point对象、一个Point3d对象、一个Vertex对象、一个Vertex3d对象,然后才成为一个PVeretex对象。
在每一个base class constructors中,对象可以与constructors’s class 的完整对象作比较。对于对象而言,“个体发生学”概况了“系统发生学”。
我的理解是“一个接一个”进化到了“整体”。
constructor的执行步骤:
-
在derived class constructor中,“所有virtual base classes”及“上一层base class”的constructors会被调用
-
上述完成之后,对象的vptrs被初始化,指向相关的virtual tables
-
如果有member initialization list的话,将在constructor体内扩展开来。这必须在vptr被设定之后才做,以免有一个virtual member function被调用。
-
最后,执行程序员所提供的代码
PVertex::PVertex(float x, float y, float z) _next(0), Vertex3d(x, y, z), Point(x, y) { if(spyOn){ cerr << “Within PVertex::PVertex()” << "size: " << size() << endl; } } //它可能被扩展为: PVertex* PVertex::PVertex(PVertex *this, bool _most_derived, float x, float y, float z){ //条件式调用virtual base constructor if(_most_derived != false) this->Point::Point(x, y); //无条件地调用上一层base this->Vertex3d::Vertex3d(x, y, z); //将相关的vptr初始化 this->_vptr_PVertex = _vtbl_PVertex; this->_vptr_Point_PVertex = _vtbl_Point_PVertex; //程序员缩写代码 if(spyOn){ cerr << "Within PVertex::PVertex()" Point3d::Point3d(), << "size: " << (*this->_vptr_PVertex[3].faddr)(this) << endl; } //传回被构造的对象 return this;
-
-
vptr 的初始化
下面是vptr必须被设定的两种情况:
-
当一个完整的对象被构造起来时,如果我们声明一个Point对象,Point constructor必须设定其vptr。
-
当一个subobject constructor调用了一个virtual function(不管是直接调用还是间接调用时)。
如果我们声明一个PVertex对象,然后由于我们对其base class constructors的最新定义,其vptr将不再需要在每一个base class constructors中被设定。
解决之道是把constructor分裂为一个完整的object实体和一个subobject实体。在subobject实体中,vptr的设定可以省略(如果可以的话)。
这样能回答两个问题:
1.类的构造函数的成员初始化列表调用类的虚拟函数,安全吗?
编译器使 vptr 能保证在成员列表初始化前设定好,挺安全;
函数本身可能会依赖没有设定初值的成员,语意上不太安全。
2.什么时候给基类构造函数一个参数?这种情况,问题1情况还安全吗?
不安全。vptr 还没设定好或者指向错误的类。该函数存取的任何类成员数据一定还未初始化。
5.3对象复制
对象复制,即研究 copy assignment operator 的语意,看看它们怎么被塑造出来。
-
bitwise copy
所谓bitwise copy 和 memberwise copy 即深拷贝和浅拷贝。
深拷贝(memberwise copy)和浅拷贝(bitwise copy)的区别在于:
-
深拷贝(对象拷贝)是指源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。
-
浅拷贝(按位拷贝)在拷贝指针、引用时,按位拷贝会导致拷贝的指针和原指针指向了同一地址。
-
利用Point class 来讨论。
class Point { public: Point(float x= 0.0, float y = 0.0); // ... 没有virtual function protected: float _x, _y; };
关于拷贝赋值操作,先看看默认生成的拷贝行为是否够用。
如果够用,那么默认拷贝操作将更有效率,不需要再画蛇添足,重写为新的拷贝操作
默认行为不够用,甚至可能导致一些不安全、不正确的操作,需要自己设计一个 copy assginment operator 。拿上面的Point class
来说,默认的 memberwise copy
,编译器不会产生示例(类似拷贝构造的情况),因为该类已经有了 bitwise copy 语意(这个 class 人畜无害,没有指针也没有多态),所以隐式拷贝赋值操作没什么意义。
一个 class 对于默认的copy assignment operator,在下面情况不会表现出bitwise copy语意:
当 class 内带一个member object,而其 class 有一个copy assignment operator时.
当一个 class 的base class 有一个copy assignment operator时.
当一个 class 声明了任何 virtual functions
(一定不可以拷贝右端 class object的vptr地址,由于它可能是一个derived class object).
当 class 继承自一个 virtual base class(不论此base class 有没有copy operator)时。
C++ Standard上说copy assignment operators并不表示 bitwise copy semantics 是 nontrivial 。
实际上,只有存在nontrivial instances时才会被合成出来。
Point a, b; a = b; //其间并没有copy assignment operator被调用.
进行按位拷贝,把Point b拷贝给Point a。
注意:
我们还是可能提供一个copy constructor,来配合 name return value (NRV) 的优化。
copy constructor的出现不应该暗示出也一定要提供一个copy assignment operator 。
-
继承下的拷贝构造行为
现在导入一个拷贝赋值操作,来说明该操作在继承下的行为
inline Point &Point::operator=(const Point &p) { _x = p._x; _y = p._y; return *this } //虚继承 class Point3d : virtual public Point { public: Point3d(float x = 0.0, float y = 0.0, float z = 0.0); protected: float _z; };
如果没有声明拷贝赋值函数,编译器就会合成类似下面的代码:
// C++伪代码:被合成的copy assignment operator inline Point3d &Point3d::operator=(Point3d *const this, const Point3d &p) { //调用base class的函数实体 this->Point::operator=(p); // memberwise copy the derived class members _z = p._z; return *this; }
这时,拷贝赋值操作有一个不太理想的情况:缺乏成员初始化列表。
这导致下面的情况将不存在:
// C++伪代码,下面性质并不支持 inline Point3d &Point3d::operator=(const Point3d &p3d) : Point(p3d), z(p3d._z) {} //取而代之的是 Point::operator=(p3d); //或 (*(Point *)this) = p3d;
-
缺乏初始化列表,在继承体系中该如何阻止基类的拷贝操作
为什么要阻止呢?
a
看下面的例子:
// class Vertex : virtual public Point inline Vertex &Vertex::operator=(const Vertex &v) { this->Point::operator(v); _next = v._next; return *this; } inline Vertex3d &Vertex3d::operator=(const Vertex3d &v) { this->Point::operator=(v); this->Point3d::operator(v); this->Vertex::operator=(v); }
-
传统的 constructor 解决方案:附加额外参数
附加额外参数没用,因为:取拷贝赋值函数地址是合法的,下面的使用将推翻拷贝赋值函数的设计。
typedef Point3d &(Point3d::*pmfPoint3d) (const Point3d &); pmfPoint3d pmf = &Point3d::operator=; (x.*pmf)(x);
仍然需要根据其独特的继承体系,插入任何可能数目的参数给copy assignment operator
-
为copy assignment operator 产生分化函数(split function)
产生后,希望函数能支持这个类成为中间基类或最底层子类。
最好让编译器借助分化函数产生拷贝赋值操作。class-defined user 亲自操刀,可能面临某些函数很难分化的困境:
inline Vertex3d &Vertex3d::operator=(const Vertex3d &v) { init_bases(v);//甚至让它成为虚函数 }
copy assignment operator在虚拟继承情况下很复杂,需要特别小心地设计和说明.
如果使用一个以语言为基础的解决方法,那么应该为copy assignment operator提供一个附加的"member copy list"。
简单地说,任何解决方案如果是以程序操作为基础,将导致较高的复杂度和较大的错误倾向. 一般公认,这是语言的一个弱点,也是应该小心检验程序代码的地方(当使用 virtual base classes时).
-
语言为基础的方法:在子类拷贝函数示例最后调用那个 operator
在derived class 的copy assignment operator函数实体的最后,明确地调用那个operator
inline Vertex3d &Vertex3d::operator=(const Vertex3d &v) { this->Point3d::operator=(v); this->Vertex:;operator=(v); // must place this last if your compiler dose not suppress intermediate class invocations this->Point::operator=(v); }
这并不能省略subobjects的多重拷贝,但却可以保证语意正确.另一个解决方案要求把 virtual subobjects拷贝到一个分离的函数中,并根据call path条件化调用它。
最好的办法是 尽可能不要允许一个 virtual base class 的拷贝操作。
甚至有一个奇怪的方法是: 不要在任何 virtual base class 中声明数据
-
5.4对象的效能(略)
略
5.5析构语意学
析构函数不是所有情况都是必要的
如果class没有定义destructor,那么只有在class内含的member object(或者是class自己的base class)拥有destructor的情况下,编译器才会合成出一个来。
否则,destructor被视为不需要,也就不需要被合成(当然更不需要被调用)。
下面举出一个没有合成析构函数的 class (它甚至还有个虚函数)
class Point { public: Point(float x = 0.0, float y = 0.0); Point(const Point&); virtual float z(); private: float _x, _y; };
类似的道理,如果把两个Point对象组合成一个Line class:
class Line { public:Line(const Point&, const Point&); virtual draw(); protected: Point _begin, _end; };
Line也不会拥有一个被合成出来的destructor,因为Point并没有destructor。
当我们从Point派生出Point3d(即使是一种虚拟派生关系)时,如果我们没有声明一个destructor,编译器也就没必要合成一个destructor。
你应该拒绝某种强迫症:你已经定义了一个constructor,所以你觉得提供一个destructor是天经地义的事情。事实上,程序员应该根据需要而非感觉来选择是否提供destructor。
怎么判断 class 是否需要一个程序层面的析构函数/构造函数
考虑标准:
-
保证对象完整性
-
类对象生命周期的起点和终点
析构函数顺序:
一个程序员定义的析构函数的扩展方式与构造函数方式相同,但顺序相反:
destructor的函数本体现在被执行,也就是说vptr会在程序员的代码执行前被重设(reset)。
如果class拥有member class objects,而后者拥有destructors,那么它们会以声明顺序的相反顺序被调用。
如果object内含一个vptr,那么首先重设(reset)vptr来指向相关的virtual table。
如果有任何直接的(上一层)nonvirtual base classes拥有destructor,它们会以其声明顺序的相反顺序被调用。
如果有任何virtual base classes拥有destructor,到目前讨论的这个class的最尾端(most-derived)的class,那么它们会以其原来的构造顺序的相反顺序被调用。
析构函数的最佳实现策略和5.2章节最后,构造函数采取的“一分为二”法一样:
就像constructor一样,目前对于destructor的一种最佳实现策略就是维护两份destructor实例: 1、一个complete object实例,总是设定好vptr(s),并调用virtual base class destructors。 2、一个base class subobject实例;除非在destructor函数中调用一个virtual function,否则它绝不会调用virtual base class destructors并设定vptr。
-
一个object的生命结束于其destructor开始执行之时。由于每一个base class destructor都轮番被调用,所以derived object实际上变成了一个完整的object。
例如一个PVertex对象归还其内存空间之前,会依次变成一个Vertex3d对象、一个Vertex对象,一个Point3d对象,最后成为一个Point对象。
-
当我们在destructor中调用member functions时,对象的蜕变会因为vptr的重新设定(在每一个destructor中,在程序员所供应的代码执行之前)而受到影响。
举个例子:
{ Point pt; Point *p = new Point3d; foo(&pt, p); ... delete p; }
pt 、 p 作为参数之前,要初始化为坐标值,通过构造函数或显式提供坐标值。
类使用者没法检验local或heap变量是否需要初始化。
所以构造函数很有必要。
那么显式delete掉 p 是否需要提前处理呢?
```cpp
p->x(0);p->y(0); //like this ```
没必要。没有任何理由说在delete一个对象之前先得将其内容清除干净。
如果确保在结束pt和p的生命之前,没有任何和该 class 有关的程序操作是必要的,往往不一定会需要一个destructor。
例外情况 :delete 存在“结束前和该 class 有关的程序操作”
考虑Vertex class,它维护了一个由紧邻的顶点所形成的链表,并且当一个顶点的生命结束时,在链表上来回移动以完成删除操作。如果这正是程序员所需要的,那么这就是Vertex destructor的工作。
这个delete 存在“结束前和该 class 有关的程序操作”
当我们从Point3d和Vertex派生出Vertex3d时,如果我们不供应一个explicit Vertex3d destructor,那么我们还是希望Vertex destructor被调用,以结束一个Vertex3d object。
因此编译器必须合成一个Vertex3d destructor,其唯一任务就是调用Vertex destructor。
如果我们提供一个Vertex3d destructor,编译器会扩展它,使它调用Vertex destructor(在我们所供应的程序代码之后)。