"深度探索C++对象模型"

  "C++对象模型"

Posted by Xu on April 21, 2018

深度探索C++对象模型

第一章:关于对象

对象的含义:将数据和函数关联起来

误解:将数据和函数封装为对象后,并没有增加内存布局成本,数据出现在每一个对象中,但成员函数却只会诞生一个函数实例,不出现在对象之中,其实成员函数定义在类中,只不过是限定了该函数的作用域,其它的 和普通函数没什么区别。

虚函数的作用:支持一个有效率的“执行期绑定”,实现多态 虚基类的作用:实现“多次出现在继承体系中的基类有一个单一而被共享的实例”

1.1对象模型

一个对象中包含两种类型成员:

  • 数据类型:分为静态和非静态的
  • 成员函数类型:分为静态和非静态的以及虚函数

三个对象模型:

  1. 简单对象模型:每一个数据成员以及函数成员对象中都有一个指针对应
  2. 表格驱动对象模型:数据成员有一张表格,函数成员也有一张表格,对象中只包含这两个表格的指针
  3. C++使用的对象模型:
    • 非静态数据成员放在对象内部,静态数据成员放在对象外部
    • 无论是静态还是非静态成员函数都放在对象外部
    • 每个类产生一堆指向虚函数的指针,这些指针都存放在虚函数表里面
    • 每个对象都会有一个指针指向相关的虚函数表,且这个指针的设定和重置由类的构造函数,析构函数,拷贝赋值函数完成
    • 每个类所关联的type_info对象存放在虚函数表的第一个slot

派生类如何模建出基类的实例(如何构建和基类的关联关系):

  1. 派生类对象中由一个指针指向基类对象实例,一个指针对应一个直接基类
  2. 派生类对象中由一个指针指向一个基类表(1,2两种体制都是间接访问基类,这会随着继承深度的增加,存取效率也就随之降低。)
  3. 基类对象直接放在派生类对象中,存取效率最高,但每次基类成员变量出现变动,该派生类需要重新编译

object_model_1

1.2关键词所带来的差异

struct和class两个关键字的区别,C struct在C++中的唯一的合理用途是当你要传递“一个复杂的class object的全部或部分”到某个C函数中去(将类中的数据成员通过struct进行封装,可以保证其在内存布局中的顺序,因为class中往往是不确定的),struct可以将数据封装起来,并保证拥有与C兼容的空间布局,然而这个保证只在组合的情况下才存在,如果是继承而不是组合,编译器会决定是否应该有额外的数据成员被安插到base struct subobject之中

1.3对象的差异

C++程序设计模型支持三种程序设计范式(编程思路,编程风格:面向过程,面向对象等)

  1. 程序模型:面向过程,和c类似,比如当比较两个字符串时,需要调用具体的函数,执行具体的比较过程
  2. 抽象数据类型模型:将一系列操作接口隐藏在类(如String)内部,隐而未明,比如在类中定义赋值运算符(String::operator=()),外部可以直接用“=”进行类型赋值(string1 = string2)
  3. 面向对象模型:任何操作以对象(如pmat)为基础,接口从对象(pmat->func())中进行调用。

统一的编程风格:不要将抽象数据(ADT)模型和面向对象(OO)模型混用,导致得不到我们想要的结果。

  1. 使用ADT的模型来实现OO模型的多态
Library_materials thing1;

class Book:public Library_materials{...};

Book book;

thing1 = book;//利用隐藏的赋值运算符赋值,首先将book的对象强制转换为父类Library_materials的引用,然后调用赋值函数,进行赋值,这里thing1只是Library_materials类并不是book

thing1.check_in();//其实是调用父类Library_materials的虚函数版本,并没有实现函数在继承类中的多态
  1. 使用OO模型编程,只有通过引用或指针来进行间接处理才会支持所需要的多态性质:
Library_materials &thing2 = book;//应用引用或指针

thing2.check_in();//这里的thing2就是book类,很好实现虚函数check_in的多态调用

多态的实现:指针或引用+public class体系

C++中的方法来实现多态:

  1. 隐式的转化操作,如将一个派生类指针转化成一个指向基类的指针
  2. 虚函数的机制
  3. 经由dynamic_cast和typeid运算符来实现多态

所以动态绑定的多态实现主要经由指针和引用来确定,如果想要指定调用函数的版本,不随着动态绑定而时常变动,我们可以通过直接定义一个基类的实例,然后调用虚函数。

一个类对象的内存主要受到三部分的影响:

  1. 非静态成员所需要的存储空间
  2. 处于内存对齐的目的所需要的存储空间
  3. 为支持虚函数机制,而产生的虚函数指针。
  • 指针是如何区分其所指向的类型?

指针类型会教导编译器如何解释某个特点地址中的内存内容及其大小,dynamic_cast并不改变一个指针所含的真正地址,而是影响“被指出之内存的大小和其内容”。

  • 一个父类ZooAnimal指针pz和一个子类Bear指针pb指向一个子类时有什么区别?

父类指针pz涵盖ZooAnimal父类部分,子类指针涵盖整个Bear类部分,所以我们不能通过父类指针pz访问任何Bear类成员,唯一例外的就是利用虚函数机制

  • 问题:为什么当用一个子类对象给父类对象进行赋值时,虚函数指针部分为什么没有赋值过去,父类对象中的虚函数指针依然指向父类的虚函数表?

因为编译器在(1)初始化和(2)指定(assignment赋值)操作之间做出了仲裁,编译器必须确保如果某个对象含有一个或一个以上的虚函数指针,那些虚函数的内容不会被基类对象初始化或改变。

2.构造函数语意学

默认的构造函数会在编译器需要的时候被产生出来,通常当一个类,如果没有任何用户声明的构造函数,那么会有一个默认的构造函数被隐式(implicit)声明出来。但一个被隐式声明出来的构造函数将会是一个无用的构造函数(并不会对成员进行合理初始化),只有如下四种情况下,默认的构造函数才会是有用的?

  1. 带有默认构造函数的成员类对象

    • 当一个类没有任何构造函数,但它内含类成员对象,编译器要为该成员类合成一个默认构造函数,不过这个合成操作只有在构造函数真正被调用时才会发生。
    • C++如何防止在不同的编译模块(不同源文件)中避免合成多个默认的构造函数? 解决办法是把这些合成的构造函数都以内联inline方式完成
    • 当类X中内含一个或一个以上的成员类,类X的每一个构造函数必须调用每一个成员类的默认构造函数,编译器会扩张已存在的构造函数,在其中安插一些代码使得在用户代码执行之前,先调用成员类的构造函数,且调用的顺序和这些成员类在类中声明的顺序一致
     class Foo{public:Foo(),Foo(int)..};
    
     class Bar{public:Foo foo;char *str;};//Bar内含成员类foo,合成的默认构造函数会有相应代码调用成员类foo的默认构造函数(有用的),但不会有任何代码对str做初始化操作(无用的,这个初始化只是为了满足程序的需要,而不是编译器的需要。)
    	
    
  2. 带有默认构造函数的基类

    • 和成员类类似,派生类会在默认的构造函数中调用父类的构造函数(有用的),如果用户定义的构造函数没有对父类进行构造,则会扩张并在用户代码之前插入代码构造父类。
  3. 带有虚函数的类
    • class声明或继承一个虚函数
    • 编译时期会发生两个扩张行动
      • 一个虚函数表会被编译器产生出来,内放class的虚函数地址
      • 在每一个类对象中,一个额外的指针成员(指向虚函数表)会被编译器合成出来。(构造函数会安插代码做这件事)
  4. 带有一个虚基类的类
    • class派生自一个继承链串,其中有一个或更多虚基类
    • 编译器会合成一个_vbcX指针指向虚基类,构造函数中会安插相关代码,合成_vbcX指针

2.1构造函数总结

  • 没有上述四种情况发生的类,它们拥有的是隐含的无用的默认构造函数,它们实际上不会被合成出来
  • 只有基类和成员类会被初始化,所有其他非静态成员不会被初始化

2.2拷贝构造函数的构造操作

有三种情况会发生以一个对象的内容作为另一个对象的初值,调用拷贝构造函数

  1. 显示将一个对象作为另一个对象初值:X xx = x;//将x对象作为初值赋给对象xx
  2. 将对象作为参数交给某个函数
  3. 当函数将一个对象进行返回时

默认成员范围初始化(merberwise,深拷贝)

将每一个内建的或派生的data member的值从某个object拷贝一份到另一个object身上,不过它并不会拷贝其中的成员类,而是以递归的方式施行成员范围拷贝

位逐次拷贝(bitwise,浅拷贝)

概念理解:拷贝过程中将类看成一块bit构成的内存单元,然后进行拷贝,而成员范围拷贝则是把对象看成不同的成员组合构成的内存范围

有四种情况,一个类的拷贝不会展现出位逐次拷贝的风格:

  1. 当类中包含成员类
  2. 当类继承于一个基类时,并且该基类存在一个拷贝构造函数
  3. 当类中声明了一个或多个虚函数
  4. 当类的继承链中包含虚基类

上述四种情况中的情况1,2,编译器会将对应的成员类或基类的拷贝构造函数安插到被合成的拷贝构造函数中去。而情况3,4有点复杂。

情况3:重新设定虚函数表的指针

当编译器导入一个vptr虚函数表指针到类中去时

  • 这个类就不一定再展现位逐次拷贝的特点,当拷贝过程当中,两个对象为同一类(虚函数指针指向同一需函数表),则浅拷贝足矣,因为虚函数指针直接拷贝不会有什么影响
    • 注意:拷贝构造函数只会在需要的时候被合成
Bear yogi;

Bear winnie = yogi;//这里使用浅拷贝足矣,完全不需要深拷贝,所以并不需要合成拷贝构造函数

  • 当一个类对象(不是指针)被它的派生类初始化时,编译器就需要合成一个拷贝构造函数将vptr适当初始化,因为派生类的vptr不能直接拷贝父类的vptr,会引起blow up
    ZooAnimal franny = yogi;//这里浅拷贝已经不能满足需求了,我们需要合成相应的拷贝构造函数对franny的vptr进行合理初始化
    

例子:

  • Bear 为ZooAnimal的派生类,当Bear类对象 给ZooAnimal赋值时ZooAnimal franny = yogi;(yogi为Bear类对象)
  • 并不是仅仅将Bear类对象内存中的字节拷贝到ZooAnimal的对象内存中去,合成出来的拷贝构造函数会显式设定ZooAnimal对象的虚函数指针指向ZooAnimal虚函数表(编译器安插代码),而不是直接从Bear对象中的虚函数表指针拷贝过来
情况4:处理虚基类子对象

当一个继承链中含有虚基类,派生类作为初值拷贝到父类时,父类的虚基类指针(虚基类表记录偏移量),也会发生相应变化,不能用逐字节浅拷贝。

//ZooAnimal是虚基类,Raccoon虚继承ZooAnimal,RedPanda继承自Raccoon

RePanda little_red;

Raccoon little_critter = litter_red;//little_critter中虚基类指针所指向的虚基类表有所不同,偏移量也不同,不能直接将little_red中的虚基类指针拷贝到little_critter

2.3程序转换语意学

在一个函数调用的过程中,以一个类对象作为参数,并且返回一个类对象,我们分析该函数代码部分会有哪些转换过程

显示初始化过程的转化

 X x0;
 void foo_bar(){
 	X x1(x0);
 	X x2=x0;
 	X x3 = X(x0);

 }

这个转换分为两个阶段:1. 重写定义 2. 安插拷贝构造函数

void foo_bar(){
	//定义被重写,初始化操作被剥除
	X x1;
	X x2;
	X x3;
    //编译器安插X拷贝构造函数的调用操作
	x1.X::X(x0);
	x2.X::X(x0);
	x3.X::X(x0);

}

参数初始化的转化

void foo(X x0);

X xx = arg;

foo(xx);
  • x0会将xx作为初值用成员用memberwise方式拷贝
  • 还一种策略是导入所谓的临时性对象object,并调用拷贝构造函数将它初始化,然后把此临时性对象交给函数,转换代码如下:
X _temp0;
_temp0.X::X(xx);//先调用拷贝构造函数为临时对象设定初值
foo(_temp0);//在将该临时对象用bitwise方式拷贝到x0的局部实例中

这就会调用两次拷贝操作,所以我们需要将函数声明进行转化,从原先的类对象,改为类的引用

void foo(X& x0);//这样可以减少一次bitwise转化

返回值的初始化

返回值是如何实现从局部对象中拷贝并返回的,Stroustrup编译器的解决办法是通过一个双阶段转化:

  1. 首先加上一个额外参数,类型是返回类对象的引用,这个参数就是用来返回。
  2. 在return之前,安插一个拷贝构造函数调用操作,将我们想要传回的类对象内容作为该参数的初值

如下:

X bar()
{
	X xx;

	return xx;

}

//转化后:
void bar(X& _result){
	X xx;
	//这里是前面所说的初始化转化
	xx.X::X();
	//...处理xx

    //对return语句的转化
	_return .X::XX(xx);//对参数引用调用拷贝构造函数
	return;

}

所以在实际函数的调用过程中:

X xx = bar();
//转化为:
X xx;
bar(xx);//bar接收一个引用作为返回结果

使用者层面的优化(程序员优化)

因为当一个类对象被返回时,必须要将想返回的局部对象作为初值调用引用_result的拷贝构造函数进行拷贝。为了节约效率,我们可以直接调用_result的构造函数,不经过局部变量的一次拷贝过程直接对返回引用进行构造

优化代码:

X bar(const T &y,const T &z){
	X xx;
	//用y,z处理xx
	return xx;

}

//优化后的代码:

X bar(const T &y,const T &z){
	
	return X(y,z);//直接调用构造函数,将y,z处理xx的过程写到X的构造函数中去,这样就可以少一次拷贝过程。
}

编译器层面的优化

目前编译器是想将代码中要返回的局部对象进行逻辑处理,然后将该局部变量通过拷贝构造函数来拷贝到返回的引用对象

  • NRV(Named Return Value)优化思路:直接将该局部变量用返回引用_result进行替换,所有逻辑操作直接在_result上进行,这样不需要拷贝,节约效率
X bar()
{
	
	X xx;
	//处理xx
	return xx;
}

//优化后的编译器会将该函数代码转化成

void bar(X &_result){
	_result.X::X();//调用默认构造函数

	//直接在_result上进行逻辑处理

	return;//不需要拷贝

}
  • 缺点
    • 这种优化由编译器默默完成,而它是否被完成,程序员并不十分清楚
    • 一旦函数比较复杂,比如出现带有return语句嵌套的语句块后,这个优化将会被关闭
    • 因为NRV优化是编译器自动执行的,但有时候程序员并不希望进行NRV优化,抑制拷贝构造函数的调用
//从上部分代码可知,NRV会直接替换想要返回的局部变量xx,调用构造函数构造_result对象
//但是有时候程序员是通过拷贝构造函数来构造xx,并不希望编译器抑制该拷贝构造函数

X xx0(1024);//直接调用构造函数
X xx1 = X(1024);//先调用构造函数然后拷贝赋值到xx1
X xx2 = (X)1024;//先通过单参数构造函数进行隐式转换,然后拷贝赋值到xx2

上述三种初始化方式,下面两种是两个步骤的初始化方式:

  1. 先构造一个临时对象
  2. 在拷贝构造的方式构造xx

经过NRV优化,可能会抑制这种拷贝构造的初始化方式,而通过替换以及默认构造函数进行构造。但这种优化并不是程序员想要的,程序员并不能很好的规划拷贝构造函数的副作用

2.4成员的初始化列表 (Member Initialization List)

为了使程序能被顺利编译,以下四种情况必须使用成员初始化列表:

  1. 初始化引用成员
  2. 初始化常量成员
  3. 调用基类的构造函数,且该构造函数拥有参数时
  4. 调用成员类的构造函数,构造函数拥有参数时。
class Word{
	String _name;
	int _cnt;
public:
	Word(){
		_name = 0;
		_cnt = 0;
	}

}

上述例子中没有使用成员初始化列表,而是在构造函数本体中,对_name,_cnt进行初始化,虽然可以被正确编译,但是效率不高,该String _name初始化过程会被扩张为如下四个步骤:

  1. _name调用默认的构造函数
  2. 产生临时String对象tmp
  3. 拷贝临时对象tmp 到 _name
  4. 销毁临时对象tmp

如下:

Word::Word{

	_name.String::String();

	String tmp = String(0);//这里的成员类构造函数拥有参数

	_name.String::operator=(tmp);//深拷贝到_name

	tmp.String::~String();//销毁临时对象

}

优化:直接在成员初始化列表中构造,这也是上面提到的四个使用成员初始化列表的情况四:调用成员类的构造函数,且拥有参数时

Word::Word:_name(0)
{
	_cnt = 0;

}

成员初始化列表会根据适当的顺序在构造体内 用户代码之前安插初始化操作的代码,list中 初始化顺序根据成员的 声明顺序决定

我们最好 不要在成员初始化列表中调用成员函数,虽然有时候可以正确执行,但该成员函数所使用的成员是否被初始化,还无法确定,这样会得不到我们想要的结果

3.Data语意学

一个例子: inherit

class X{};
class Y:public virtual X{};
class Z:public virtual X{};
class A:public Y,public Z{};
//上述四个类中都不保护明显的数据成员

对这四个类进行sizeof操作,获取四个类的大小

  • sizeof X: 1
  • sizeof Y: 8
  • sizeof Z: 8
  • sizeof A: 12

分析:

  • 虽然类X中不用存放任何数据,但 事实上它不是空的,它有一个隐藏的1byte大小,这使得这一class的两个objects得以在内存中配置独一无二的地址。

  • 对于类Y,Z虚继承自X,会拥有对应的 虚基类表指针(支持虚继承导致的额外负担),4个字节,还有 虚基类(还是Y,Z本身为空而添加的?)1byte的内存大小位于Y,Z内存空间的尾部。处于 内存对齐 的考虑,Y,Z的内存空间大小为8字节

  • 对于类A, 两个虚基类指针(8个字节) ,加上虚基类1byte大小,再出于 内存对齐的要求,所以类A的内存大小为12字节

总结:对于类Y和Z的内存大小主要受到三个因素的影响

  1. 语言本身造成的 额外负担:为了支持虚函数,虚基类添加的指针
  2. 编译器对特殊情况所提供的 优化处理:虚基类的内存大小存放在派生类 尾部,且对于空的虚基类提供特殊支持
  3. 内存对齐的要求会影响到类的内存分布

一个类的数据成员可以表现这个class在程序执行时的某种状态,非静态数据成员放置的是个别对象感兴趣的数据,静态数据成员(放置在全局数据栈),放置的是整个类感兴趣的数据

一个类的内存大小可能比我们想象的要大主要有两个原因:

  1. 编译器为支持语言特性(虚函数,虚继承)而添加的额外负担,指针
  2. 内存对齐,边界调整的需要

3.1数据成员Data Member的绑定

在早期的C++编译器中,在一个类的成员函数中使用一个成员,而该成员的声明在成员函数的声明之后,则编译器会在类的外部作用域去寻找这个成员。所以出于防范这种情况发生:

  1. 所有数据成员的声明都放在类的声明起头处,确保正确绑定
  2. 所有内联 inline functions,不管大小都放在class声明之外

新出现的C++编译规则,避免了这种情况的发生:“一个inline函数实体,在整个class声明未被完全看见之前,是不会被评估求值的”

  • 所以对于成员函数本体的分析会直到整个class的声明都出现了才开始。
  • 但这并不能对成员函数的参数列表起作用,成员函数中参数列表中的数据成员依然会在声明过程中进行绑定,只会绑定该成员函数声明之前出现的数据成员。
    • 但当一个参数已经被决议绑定为之前的一个声明数据类型,之后又出现了该成员的数据类型声明,则会将之前参数的绑定视为“非法”( 同一个数据成员不可以在类内部进行多次声明)
  • 所以出于防御性编程的目的,请总将 内部(nested)的数据成员声明放在类声明的起始处
typedef int length;

class Point3d{
public:
	void mumble(length val){_val = val;}//将参数val绑定为global
	length mumble(){return _val};
private:
	typedef float length;
	length _val;//参数绑定后又进行声明,将之前的参数绑定视为"非法"

}

3.2数据成员Data Member的布局

  • 在一个类对象中的非静态数据成员的排列顺序将和其被声明的顺序一样,任何中间介入的静态数据成员会被放到全局静态栈
  • 这些数据成员之间可能会穿插一些其他的数据:边界调整所需要的字节,编译器为支持某种特性而内部使用的数据成员(vptr)
  • C++标准中允许编译器将多个访问单元(public,private..)之间的数据成员自由安排,但目前编译器都是将各个访问单元块连在一起,按照声明的顺序排列

3.3数据成员的存取

问题:通过对象直接对数据成员进行存取,和通过指针直接对数据对象进行存取有什么差异?

答案:当一个类Point3d是一个派生类,而其继承结构中有一个虚基类,被存取的成员是一个从该虚基类中继承过来的成员,那么这两种访问就有很大区别。

  • 通过对象的访问,成员的偏移量在编译时期就已经确定,甚至直接完成对成员访问
  • 通过指针的访问,我们无法知道这个指针指向哪一类class,所以我们必须要延迟到执行期,经由一个间接引导,才能解决。

静态数据成员:

  • 每一次程序读取静态成员时,就会被内部转化为对该extern实例的直接参考作用
  • 读取静态数据成员也是通过指针和通过对象存取数据成员 结论完全相同的唯一一种情况,并且访问静态成员并 不需要类对象
  • 如果两个类,都声明了一个同名的静态数据成员,存放在静态栈区,这会导致 命名冲突,编译器的解决办法是暗中 对每一个静态数据成员编码(name_mangling)

非静态数据成员

  • 所有对非静态数据成员的访问都是通过i一个隐式类对象(this)来进行存取的
  • 对一个非静态数据成员进行存取操作,编译器需要首先将类对象的起始地址加上数据成员的偏移位置(偏移位置在编译期就已经得知。
    • 指向数据成员的指针,其偏移量总是+1,用于区分:
      • 一个指向数据成员的指针,用以指向类的第一个成员
      • 一个指向数据成员的指针,没有指出任何成员

3.4继承和数据成员

分析四种情况:

  1. 单继承
  2. 单继承+虚函数
  3. 多继承
  4. 虚拟继承

单继承

首先将两个类不安排继承关系,而是将代码局部化,通过构造函数的初始化部分构建两个类之间的关联关系。(Point2d和Point3d,见P101)如果我们想将这两个类通过继承关系表达出来,合并过程中可能会犯如下两个错误:

  • 重复设计一些相同操作的函数,并没有被做成内联inline函数。选择某些函数做成inline函数是设计类class的重要内容
  • 把一个class分解为两层或多层,导致“为表示继承体系的逻辑性和抽象性”而因边界对齐膨胀产生额外的空间
class Concrete{
public:
 //...
 private:
 	int val;
 	char c1;
 	char c2;
 	char c3;
}

Concrete

分层后:

class Concrete1{
public:
 //...
 private:
 	int val;
 	char c1;
};

class Concrete2:public Concrete1{
public:
 //...
 private:
 	char c2;
};

class Concrete3:public Concrete2{
public:
 //...
 private:
 	char c3;
};

Concrete

原则:要保证基类子对象在派生类中的原样性

原因:在进行类型转换动态绑定时,要保证数据拷贝的正确性

Concrete2 *pc2;
Concrete1 *pc1_1,*pc1_2;

*pc1_1 = *pc1_2;

C++OJModel_1

多重继承

单继承:

  • 在单继承的环境下,将一个派生类指定给一个基类的指针和引用,不需要经过地址转换,只需要修改编译器解释该指针的方式即可
  • 基类没有虚函数,而派生类有虚函数,这种情况下将一个派生类对象转换为基类对象,需要编译器的介入,调整vptr带来的地址变化

多重继承:

  • 多重继承的问题主要发生于派生类和其第二或后继的基类对象之间的转换
  • 对一个多重派生类对象,将其地址指定给“最左端(也就是第一个)基类的指针”,也不需要地址转换,因为起始地址相同,当指定给第二个或后继的基类的地址指定操作,需要将地址修改过,加上中间基类的偏移量
    • 指针需要判断是否为空后加上偏移量
    • 引用直接加上偏移量,不用判断是否为空
    • 在派生类中存取第二个或后继基类时,没有额外的成本,因为这些偏移信息早在编译时期就固定

C++OJModel_3 C++OJModel_2

虚拟继承:

将一个类分为两个区域:不变区域和共享区域

  • 不变区域:不管后继如何派生,总是拥有固定的offset
  • 共享区域:为虚基类的存储区域,位置会随着每次派生操作和有变化

一般的布局策略都是先安排好派生类的不变区域,然后建立共享部分区域

如何存取类的共享部分?

存取方案:cfront编译器会在每一个派生类对象安插一些指针,每个指针指向一个虚基类,所以访问虚基类中的成员通常是通过指针的间接访问完成的

缺点:

  1. 每个对象都需要对每一个虚基类背负一个额外的指针,理想情况下我们希望类对象有固定的负担,不因虚基类的个数有所变化
  2. 随着虚基类嵌套层次不断深入,间接访问的深度也在不断增加,访问时间也在变大,理想情况,我们希望有固定的存取时间,不因虚拟派生的深度而改变

对于第一个缺点,有两个解决办法:

  1. Microsoft编译器引入所谓的虚基类表,每一个类对象如果有一个或多个虚基类,编译器就会安插一个指针,指向虚基类表,虚基类表中存放指向虚基类对象的指针
  2. g++中使用的方法,将虚基类表和虚函数表统一到一个虚表中,索引为负值表示为虚基类表项,索引为正值为虚函数表项,虚表中存放的是虚基类的偏移量,而不是存放的地址

C++OJModel_4

对于第二个缺点解决方法:对于每一个虚基类都有一个指针直接指向它

3.5对象成员的效率

3.6指向数据成员的指针

指向数据成员的指针,是得到该成员在该类中的偏移量+1的值(相对于类而言,而不是相对于对象而言):

& Point3d::z;//获取z在类Point3d中的偏移量+1的值,与类相关
float Point3d::*;//获取一个指向类Point3d种类型为float的成员的偏移量+1

Point3d origin;
& origin.z;//获取origin对象中z的实际地址,与对象相关

为什么偏移量要加1,因为要将 “指向第一个首部成员的指针”和“定义了一个指向类的数据成员指针, 但还没有指向任何成员的指针”有所区分

float Point3d::*p1 = 0;//p1是一个指向Point3d的float成员的指针,但目前没有指向任何成员,所以值为0
float Point3d::*p2 = &Point3d::x;//p2也是一个指向Point3d的float成员的指针,它指向Point3d的x成员,x在Point3d的首部,所以其和类首部的偏移量应该为0,但由于要和p1进行区分,所以要+1

Microsoft Visual C++ 5对空虚基类进行了特殊处理,没有在偏移量上+1

如何将一个指向基类数据成员的指针(偏移量)转化为一个指向派生类中数据成员的指针(偏移量):

struct Base1{int val1;};
struct Base2{int val2;};
struct Derived:Base1,Base2{...};
//下面的函数传入的第一个参数为指向Derived中数据成员的指针(偏移量+1),如果传入一个指向基类数据成员的指针,会怎么样?
void func1(int Derived::*dmp,Derived *pd){
	pd->*dmp;

}

int Base2::*bmp = &Base2::val2;

func1(bmp,pd);//传入指向基类Base2种成员val2的指针(偏移量+1),此时bmp==1
//但是在Derived中val2的偏移量为5
//所以会经过如下转化

func1(bmp?bmp+sizeof(Base1):0,pd);//根据介入的Base1进行偏移量调整,同时对bmp==0进行防范

4.Function语意学

C++支持三种类型的成员函数:静态,非静态和虚函数

静态成员函数:

  1. 不能读取非静态成员
  2. 不能被声明为const
  3. 是最后被引入的函数类型

非静态成员函数

设计原则: 非静态成员函数至少必须和一般的非成员函数有着相同的效率。

实现方案:编译器内部已将 “member函数实例”转换为对等的 “非成员函数实例”

转换步骤:

  1. 改写函数的函数原型,以安插一个额外的参数到成员函数中,用来提供一个存取管道,该额外参数为this指针
Point3d Point3d::magnitude(Point3d *const this);//非const成员函数转换
Point3d Point3d::magnitude(const Point3d *const this);//const成员函数转换
  1. 对每一个非静态成员的存取操作,改为经由this指针来存取
  2. 将成员函数重新写成一个外部函数,将函数名称经过”mangling”编码处理,使它在程序中成为独一无二的词汇

最后可对该成员函数进行优化:NRV优化,使用者层面的优化(直接构造,避免使用局部变量)。

名称的特殊处理(Name Mangling)

一般而言,成员的名称前面会被加上class名称,形成独一无二的命名,如类Bar成员ival会变成ival_3Bar

在一个继承链中,有 同名的成员,或重载的函数名,我们通过对这些名称进行mangling处理就可以,清晰的找到该成员的位置,并且一个重载函数名称往往会加上 参数链表,使得可以区分这些重载的函数名。

虚成员函数

如果normalize()是一个虚成员函数,那么下面的调用:

ptr->normalize();

会被内部转化为:

(*ptr->vptr[1](ptr));//vptr是指向虚函数表的指针,这个名称"vptr"也是被“mangled”特殊处理过
//1是代表normalize在虚函数表中的索引值
//第二个ptr表示this指针

如果magnitude()也是一个虚函数,它在normalize()之中被调用,调用操作也会被转化成:

//register float mag = magnitude();//函数原型,转换如下:
register float mag = (*this->vptr[2])(this);

由于magnitude是在normalize中调用的,后者已经由虚拟机制决议妥当,所以显示调用Point3d会比较有效率,并因此压制由于虚拟机制而产生的不必要重复操作:(不是很理解,为什么会重复调用)

register float mag = Point3d::magnitude();//显示调用,不经过动态绑定

如果magnitude声明为inline函数会更有效率。因为这会在编译时期就可以决议(resolved)该函数名的全名(mangled后的名称),函数可以直接被扩展。

经由一个对象调用虚函数,编译器会像对待一般非静态成员函数来进行决议(决议的含义我觉得就是确定该函数对应哪一个被mangled的函数名):

obj.normalize();
//编译器转化为:
(*obj.vptr[1])(&obj);

上述转换完全没必要,因为由对象obj调用虚函数,并不能支持多态(多态只能由指针和引用实现),函数的版本已经由对象所确定,所以函数名也已经被决议,所以编译器应该像对待非成员函数来处理它,直接获取它的mangled的函数名:

//正确应该转换为:
normalize_7Point3dFv(&obj);

静态成员函数

静态成员函数通过对象来调用和通过指针来调用没有什么区别,编译器都会在编译时就将该函数进行决议,然后直接转换为mangled的函数名调用

obj.normalize();
ptr->normalize();

//都会转换为非成员函数调用
normalize_7Point3dSFv();//SFv代表静态函数

静态成员函数出来之前,所有成员函数都必须经由该class的对象来调用,由于一些静态成员(nonpublic)的读取需要通过成员函数来读取,而成员函数又依赖于对象调用,所以静态成员函数被提出来解决这一问题,可以独立于对象之外对静态数据成员进行读取

静态成员函数的特点(最主要的就是它没有this指针)

  1. 它不能直接存取class中的非静态成员
  2. 它不能够被声明为const ,volatile或virtual
  3. 它不需要经由对象才能被调用(虽然大部分都是这样被调用的)

如果经由对象调用静态成员函数,然后该对象又由表达式获得,编译器会做如下转换:

if(foo().object_count()>1)//foo()函数获得对象,object_count为静态成员函数
//会被转化为:
(void)foo();//为了保留foo函数的副作用
if(Point3d::object_count()>1)...

如果取一个静态成员函数的地址,获得的将是它在内存中的位置,也就是它的地址,而不是偏移量

&Point3d::object_count();//获取的静态成员函数object_count在内存中的地址unsigned int(*)(),而不是在类Pointe3d中的偏移量unsinged int(Point3d::*)();

静态成员函数可以成为一个callback函数,可以成功应用在线程函数身上。

虚拟成员函数

需要维护执行期的相关信息,我们只有在需要”多态”的时候需要指针的执行期信息

多态:通过父类指针找到派生类对象的相关信息

  • “消极”多态:一个父类指针明确指向一个特定子类对象,编译时期就可以确定
  • “积极“多态:一个父类指针指向的对象必须要在运行时才能确定

所有虚函数表的大小和内容在编译时期就已经确定,当一个类继承一个虚函数表时,可以有三种行为:继承,改写,新增

C++OJModel_5.png

多重继承下的虚函数

主要实现难点:围绕在第二个及后继的基类身上,如何通过第二或后继基类来调用派生类虚函数?

因为当单继承时,虚函数表指针只有一个,所以使用该指针指向派生类的虚函数表即可,但是多继承时,每一个基类都有一个虚指针,虚指针只指向该基类拥有的虚函数,如何实现多态,从该基类的指针调用派生类的虚函数版本?

当我们用一个指向第二基类的指针指向派生类时,指针或做相应的偏移操作,指向第二基类的内存首地址。如下:

B *pt_b = new Bottom;
//pt_b会进行如下偏移操作转化

Bottom * tmp = new Bottom;
B *pt_b = tmp?tmp+sizeof(A):0;

C++OJModel_6.png

但pt_b指向的类B的内存空间,vptr也是指向的类B的虚函数表,如何根据pt_b来调用派生类Bottom的虚函数

答案:因为派生类所有的虚函数指针都存放在第一个基类的虚函数表中,所以pt_b只需要做相应的偏移量地址调整操作即可。这种地址调整是通过thunk技术完成的。 * thunk技术其实就是一段汇编代码,通过B类的虚函数表,虚函数表中,涉及到派生类的虚函数则保存该虚函数对应的汇编代码,汇编代码实现的主要为如下

this+=sizeof(A);//找到第一个基类对应的虚函数表指针
Bottom::h(this);//调用第一个基类虚函数表中对应的函数地址

C++OJModel_7.png

有三种情况,涉及到多重继承对虚函数支持产生的影响:

  1. 前面提到的通过第二或后继基类指针调用派生类虚函数,需要地址调整
  2. 第二种是第一种的变化,通过一个指向派生类指针调用从第二或后继基类继承来的的虚函数,如上图mumble函数所示也需要进行地址调整
  3. 用一个第二或后继基类指针指向一个虚函数调用后返回的派生类对象,也需要地址调整:
Base2 *pb1 = new Derived;
Base2 *pb=pb1->clone();//返回的是Derived对象,地址会调整到第二基类的内存地址

虚继承下的虚函数见C++中类内存分布笔记(幕布)

函数的效能,注意在多重继承,单继承深度增加的情况下,构造函数的成本会逐渐增加

指向成员函数的指针

同指向数据成员的指针,指向非静态成员函数的指针是在内存中真正的地址,但也是不完全的,它也需要被绑定于某个对象的地址上才能调用。(绑定一个对象的含义其实就是传入一个对象作为函数的参数,和普通非成员函数没什么区别,只不过参数依赖于该类的对象

//定义
double (Point3d::*coord)() = &Point3d::x;
Point3d origin,*ptr;
(origin.*coord)();//调用
//转化为:(coord)(&origin);
(ptr->*coord)();
//转化为(coord)(ptr);

使用一个成员函数(除了虚函数,多重继承,虚基类等情况)指针并不会比使用非成员函数指针的成本更高。

支持“指向虚成员函数”的指针

获取一个指向虚成员函数的指针实际上获取的是该虚函数在虚函数表中的索引值

  • 指向成员函数的指针依然可以支持虚拟机制中的多态实现。
  • 在运行时,会根据索引值找到对应的虚函数地址(可能会直接调用,也可能根据thunk进行地址调整后调用。支持多态)
float (Point::*pmf)() = &Point::x;//x为虚函数,此时pmf的值为1
Point *ptr = new Point3d;
(ptr->*pmf)();
//编译器转化为:
(*ptr->vptr[(int)pmf])(ptr);//转化为索引调用

为了让成员函数的指针支持多重继承和虚拟继承,设计如下结构体:

struct __mptr{
	int delta;
	int index;//虚函数的索引值,为-1表示该指针指向非虚成员函数
	union{
		protofunc faddr;//指向非虚成员函数的内存地址,或vcall thunk,指向虚函数的thunk代码地址
		int v_offset;//保存虚基类的vptr偏移量,要是vptr放在首部,该字段没有意义
	}
}

Microsoft有三种风格的结构体:

  1. 一个单一继承的函数指针(其中持有vcall thunk地址或是函数地址)
  2. 一个多重继承的函数指针(持有faddr(要么是vcall thunk地址或是函数地址)和delta两个成员的指针结构体)
  3. 一个虚拟继承中函数指针(有四个成员的指针结构体,如上__mptr所示)

    指向成员函数指针的效率

    五类指针:

  4. 指向非成员函数指针(内存地址)
  5. 指向类成员函数指针(内存地址)
  6. 指向虚拟成员函数指针(索引值)
  7. 多重继承下指向非虚和虚拟成员函数指针(非虚:faddr为函数内存地址;虚函数:faddr为v call thunk地址,delta为this指针的偏移量,传入成员函数合适的this指针,如调用第二个虚基类的虚函数,需要该delta偏移量找到合适的this指针)
  8. 虚拟继承下指向非虚和虚拟成员函数指针(非虚:faddr为函数内存地址,虚函数,同多重继承,faddr和delta)
(pA.*pmf)(pB);//pB为参数,pmf为函数指针
//编译器会转换为:
pmf.index<0
?(*pmf.faddr)(&pA+pmf.delta,pB)//非虚函数调用,faddr为函数的调用内存地址,delta帮助找到合适的this指针传入参数
:(pA.__vptr__Point3d[pmf.index].faddr)
(&pA + pA.__vptr__Point3d[pmf.index].delta,pB);//虚函数调用过程,上面根据index和faddr找到要调用的函数地址,然后根据index和delta找到合适this指针传入参数


从上面编译器的转化步骤,我们可以看到,虚函数和非虚函数的调用其实就是经过一个index的转化得到faddr和delta,非虚函数直接使用faddr获得函数地址,delta获得参数this,虚函数则是通过虚函数表以及index来获得对应的faddr和delta来进行调用

内联函数

类声明中的成员函数和友元函数的内联只是一项请求,编译器会判断该函数是否可以进行内联操作,一般内联函数的处理分为两个阶段:

  1. 分析函数是否具有内联能力,如果因其复杂度(内联函数的复杂度计算是根据表达式的权值相加后的总和求得的)或建构问题被判断为不能成为inline,则会被转换为static函数
  2. 真正的内联函数扩展操作发生在调用点上,即调用该内联函数的代码部分会根据内联函数在编译时期直接进行求值,以及临时性对象的管理(副作用参数)。

形式参数

在内联的过程中,每一个形式参数都会被对应的实际参数取代(一般对参数有所修改的函数都不能内联)

  1. 一般而言,对于“会带来副作用的实际参数”,通常会引入临时对象。
  2. 如果是常量表达式,可以直接完成求值操作,并将结果直接替换
  3. 如果即不是常量表达式,又不是带有副作用的表达式,那么就直接实际参数直接替换

例子:

inline int min(int i, int j){
	return i<j?i:j;
}
//调用格式
minval = min(foo(),bar()+1);//“带有副作用的参数”,引入临时性对象
minval = min(1024,1028);//常量表达式,直接求值替换
minval = min(val1,val2);//非常量表达式,没有副作用,直接用实际参数替换

//分别内联扩展为:
1.
int t1,t2;
minval = (t1=foo()),(t2=bar()+1),t1<t2?t1:t2;

2.
minval = 1024;//直接结果替换

3.
minval = val1<val2?val1:val2;//实际参数直接替换

局部变量

如果一个内联函数定义中加入一个局部变量:

inline int min(int i,int j){
	int minval = i<j?i:j;
	return minval;
}
minval = min(val1,val2);
//扩展为:
int _min_lv_minval;
minval = (_min_lv_minval = val1<val2?val1:val2),_min_lv_minval;//为了维护该局部变量

一般而言,内联函数中每一个局部变量都必须放在函数调用的一个封闭区段中,拥有一个独一无二的名称,如果inline以单一表达式扩展多次,则每次都需要自己的一组局部变量。如果以分离的多个式子被扩展多次,则只需要一组变量,因为可以重复使用

内联展开产生大量的临时性对象的原因:

  1. 参数带有副作用
  2. 单一表达式多次调用
  3. 内联函数中有多个局部变量

构造、析构、拷贝语意学

  • 纯虚函数只能在类外部定义或派生类中定义,在类内部不能定义
  • 为了封装接口,纯虚基类不能被实例化
  • 继承该纯虚基类的类必须实现该纯虚基类中所有接口
  • 纯虚函数可以被调用,但只能通过静态调用的方式(通过类访问而不是对象)进行调用。

一个函数是否应该被设计为虚函数(分析效率),一个虚函数又是否要被设计成const(分析该函数是否需要修改数据成员)?

“无继承”情况下的对象构造

typedef struct{
	float x,y,z;
}Point;

上面这种风格的类定义为Plain Old Data定义,即C风格的旧式定义,类成员不涉及到自定义数据类型,或OO面向对象模型相关的代码。

编译器会分析出POD风格的类定义,对这种风格的类不会合成拷贝构造函数,也不会合成析构函数,当进行拷贝时直接进行字节拷贝即可。

抽象数据类型

相对于POD风格的类多了private数据,提供完整封装性,但没有提供任何虚函数。这一类的数据类型,也不需要合成拷贝构造函数,因为程序默认的内存管理方法足够了。

class Point{
public:
	Point(float x = 0.0,float y = 0.0,float z = 0.0):_x(x),_y(y),_z(z){

	}
private:
	float _x,_y,_z;
};

显式初始化快于内联扩展代码,因为当函数的activation record被放进程序堆栈时,初始化列表的中的常量就已经被放进内存了。

为继承做准备

class Point{
public:
	Point(float x = 0.0,float y = 0.0):_x(x),_y(y){

	}

	virtual float z();
protected:
	float _x,_y;
};

多了虚函数,由于虚指针的引入导致不能简单用自己饿拷贝进行赋值操作,所以当需要调用拷贝构造函数时,需要合成“有用的拷贝构造函数”。同时也会触发NRV优化(直接在_result上计算)

5.2继承体系下的对象构造

一般,编译器所做的扩充操作如下:

  1. 判断是否有虚基类需要构造,有则从左到右,从深到浅调用虚基类的构造函数
  2. 判断是否有基类需要构造,有则调用所有基类的构造函数,构造顺序与派生类列表声明顺序一致
    • 如果基类是多重继承下的第二或后继的基类,那么调用构造函数,this指针需要有所调整
  3. 如果类对象中有虚函数表指针,则要对该指针进行初始化
  4. 如果有成员类没有出现在初始化列表中,但该成员有默认的构造函数,那么调用该默认构造函数
  5. 初始化列表中的初始化操作放在构造函数本地,与成员声明顺序一致

虚拟继承

在一条具有虚继承链的关系中,如下:

C++OJModel_7.png

在这个关系中,Point3d在被单独声明调用构造函数时,会构造虚基类,但是当声明Vextex3d时,会构造虚基类,也需要构造Point3d,难道再一次构造虚基类Point吗?这里的处理办法是引入一个参数,来判断调用一个构造函数的时候是否以完全体的形式调用:

Point3d* Point3d::Point3d(Point3d *this,bool __most_derived,float x,float y,float z){
	if(__most_derived!=false){
		this->Point::Point(x,y);
	}//当以完全体身份调用构造函数时,则__most_derived设为true,当以只为某个完整对象的subobject的身份调用时,为false
	this->__vptr_Point3d = __vtbl_Point3d;
	this->vptr_Point3d_Point = __vtbl_Point3d_Point;
	this->_z = rhs._z;
	return this;
}

“完整object”版无条件调用虚基类构造函数,“subobject”版则不调用虚基类构造函数,也有可能不设定vptr等

vptr初始化过程

为什么不建议在基类构造函数中调用虚函数?

答案:一个vptr的初始化发生在 基类构造函数调用之后初始化列表中成员初始化之前。所以在任何基类的构造函数中调用虚函数时,通过虚拟机制vptr找到的虚函数表只能是该基类所拥有的虚函数表,所以调用的虚函数版本也是该基类的虚函数版本。并且此时由于基类的构造函数还没有执行完毕,所以派生类中的vptr还没有被正确初始化

Point3d:Point3d(float x,float y,float z):Point(x,y),_z(z){
	//Point为Point3d的虚基类
}

当派生类初始化列表中显式调用虚基类或基类的构造函数时,因为此时派生类中的 vptr已经被设定好(发生在初始化列表之前),虚基类或基类的构造函数又 再一次设置了vptr,这样导致vptr指向了错误的虚函数表。为防治这种情况的发生,我们可以设定下面两条规则:

两个vptr必须被设定的情况:

  1. 当一个”完整object”被构造时
  2. 但一“subobject版”对象调用一个虚函数时

我们同样可以传入一个参数来判断是以”完整object”还是“subobject”身份来调用,构造函数,”完整object”需要设定vptr值,”subobject”版可以忽略vptr的设定,除非调用了虚函数

  • 在初始化列表中调用该类的虚函数,安全吗?

    • 答案:安全,因为vptr在初始化列表之前已经被正确设置好,但有可能该虚函数所需要的数据成员还没有被初始化
  • 当我们在派生类初始化列表中为基类的构造函数提供参数并调用时,再在该初始化列表中调用该派生类的虚函数时,安全吗?

    • No!不安全,因为由于基类构造函数的调用,vptr要么没有被设定,要么指向错误的类(不是该派生类)

5.3对象复制语意学

当我们把一个对象赋值给另一个对象时,我们有三种选择:

  1. 使用默认的拷贝赋值行为
  2. 提供一个显式的拷贝构造函数
  3. 显式回绝该对象的赋值操作

默认的拷贝赋值操作符,当使用浅拷贝就足够时,并不会合成该拷贝赋值函数的实例,只有需要使用深拷贝时才会合成,而不使用浅拷贝的情况有以下四种:

  1. 成员类,且有默认拷贝赋值操作符
  2. 基类且有拷贝赋值运算符
  3. 虚函数的存在(虚函数指针)
  4. 虚基类的存在(虚基类指针)

拷贝赋值运算符不支持初始化列表,所以我们调用拷贝赋值运算符时不能使用{}初始化列表来进行赋值

Point p3d;
p3d = {1,2,3};//不支持

为什么在有虚基类的继承链中,拷贝赋值函数不能被抑制,这就导致子类中的对虚基类进行拷贝,基类又重复对虚基类进行拷贝。

为什么我们不能通过添加参数(同虚基类的构造抑制解决方案)的形式,来判断是否需要抑制对虚基类的拷贝赋值?

答案:因为获取拷贝赋值运算符的指针是合法操作

typedef Point3d& (Point3d::*pmfPoint3d)(const Point3d&);//设置pmfPoint3d是一个执行Point3d类中成员函数的指针,且该成员函数的参数为Point3d&
pmfPoint3d pmf = &Point3d::operator=;//设置一个指针指向Point3d的拷贝赋值函数

//如果Point3d有一个参数为Point3d&的函数时
Point::dosomething(Point3d &){
	...
}
//按道理讲pmfPoint3d类型的指针应该也能指向该dosomething函数
pmfPoint3d pmf =&Point3d::dosomething;

//如果当我们给拷贝赋值运算符添加参数后,那么就和dosomething的函数不一致了,这样我们就无法正确获得Point3d的拷贝赋值函数的指针

解决方案:

  1. 让拷贝赋值函数产生分化函数,一个版本会拷贝虚基类,另一个版本不会拷贝虚基了,但这样会导致编译时期代码量变大
  2. 放宽要求,不抑制拷贝赋值函数,允许多次虚基类的拷贝赋值。

所以C++的有一条建议:尽可能不要允许一个虚基类的拷贝操作,甚至不要在虚基类中声明数据成员

析构函数语意学

如果类没有定义析构函数,那么只有在类内所含有的成员类拥有析构函数的情况下,编译器才会自动合成,否则析构函数被视为不需要合成,就算有虚函数的存在也不需要。

  • 因为当我们delete一个对象时,没有理由说我们必须把这个类中的成员内容先清空,归还资源,我们只需要知道,这个对象被舍弃了即可,不用在代码层面做更多操作
  • 为什么有虚函数的存在,虚基类的存在也不用合成析构函数:虽然在析构过程中可能会说要重设虚函数指针,或者虚基类指针。其实只要没有显式的析构函数去调用这些虚函数,这些指针是否被重置根本不会影响到什么,因为对象被舍弃,不会再使用到虚指针和虚基类指针,我们不用关心这些指针是否被重设
  • 除非有基类中有显式的析构函数,或成员类中有显式的析构函数,我们需要合成析构函数去调用这些显式的析构函数,析构步骤如下
    • 首先执行析构函数本体代码块内容
    • 如果成员类中拥有有析构函数,则按成员类声明顺序的相反顺序来析构这些成员类
    • 如果对象含有虚函数表指针,重设虚函数表指针指向适当基类的虚函数表(只有在需要合成析构函数的时候会重设vptr
    • 如果 直接基类中拥有析构函数,则调用它,调用顺序,与这些直接基类的声明顺序相反(派生列表)
    • 如果任何 虚基类拥有析构函数的,则调用它们,从右到左,从浅到深(和构造顺序相反)

执行期语意学

对象的构造和析构

如果一个区段或函数有多个离开点(return),那么对象的析构函数必须安插在每一个离开点。

  • 一般而言,我们会把对象尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作,有可能这些对象还没有被使用时就需要离开返回,这样我们尽可能避免没有必要的对象的构造和析构

全局对象

C++中的全局对象都存放在全局数据栈中,如果显式指定给它一个初值,此object会以该值为初值,否则object所配置的内容为0。C语言中不会自动给全局变量设值。虽然类对象在编译时期放置在全局数据栈中,并且内容为0,但默认构造函数一直到程序启动才会实施。所以对象需要静态初始化(显式调用构造函数)

munch策略:

  1. 为每一个需要静态初始化的文件产生一个_sti()函数,内含必要的构造函数调用操作。
  2. 在每一个需要静态的内存释放操作的文件中产生一个_std()函数,内含必要的destruct调用操作
  3. 提供一组runtime library “munch”函数,一个_main()函数调用可执行文件中所有的_sti()函数,一个_exit()函数调用所有可执行文件的_std()函数

C++OJModel_8.png

局部静态对象

在一个函数中定义一个静态局部的类对象,我们要保证该对象只被 唯一构造一次,也只被析构唯一一次

  • 导入一个 临时性对象来保护局部静态类对象的初始化操作(临时性对象:一个指向该对象的 指针,初始化为0
  • 函数的调用过程会判断该临时性对象(指针)是否为0(false),为0则需要进行构造,然后将该指针指向构造出来的对象,如果不为0(说明已经指向构造出来的静态局部对象),则不需要操作
  • 当调用 静态内存释放函数(std)时,也会对该指针临时性对象进行判断,如果 判断不为0,则需要调用析构函数,为0则不需要调用析构函数

对象数组

如果一个类如Point既没有定义一个构造函数,也没有定义一个析构函数,那么定义一个该类的对象数组不会比“建立一个内置类型所组成的数组”的工作更多,只需要配置足够的内存即可

当该类Point明确定义了构造函数和析构函数,我们创建该对象数组时,需要调用vec_new() 函数,该函数参数结构如下:

void * vec_new(
	void *array,					//该参数为数组的起始地址
	size_t elem_size,				//元素的大小
	int elem_count,					//元素的个数
	void(*constructor)(void *),		//元素的构造函数地址(明确定义的)
	void(*destructor)(void*,char)	//元素的析构函数地址
	)

//当定义一个对象数组:
Point knots[10];
vec_new(&knots,sizeof(Point),10,&Point::Point ,0);

构造函数会施行在每一个对象上。

对应的析构函数的调用函数为:

void * vec_delete(
	void *array,					//该参数为数组的起始地址
	size_t elem_size,				//元素的大小
	int elem_count,					//元素的个数
	void(*destructor)(void*,char)	//元素的析构函数地址
	)

当程序员提供一个或多个明显初值给一个有类对象组成的字符串时:

Point knots[10] = {Point(),Point(1.0,1.0,0.5),-1.0}

// 构造过程会转化成:
Point::Point(&knots[0]);
Point::Point(&knots[1],1.0,1.0,0.5);
Point::Point(&knots[2],-1.0,0,0);
vec_new(&knots[3],sizeof(Point),7,&Point::Point ,0);

new和delete运算符

对于内置数据类型,new的操作就经过两个步骤:

int *pi = new int(5);
//1. 调用_new运算符函数配置内存
int *pi = _new(sizeof(int));
//2. 初始化对象
*pi = 5;

析构函数则只有一步:

if(pi!=0)
	_delete(pi);
//先判断pi是否为0,为0则不要有所操作,否则调用_delete释放掉pi所指的内存
//注意:是释放不是修改,pi依然是一个指针,只不过不能保证pi所指的内容依然合法
//为了后续不要因为使用pi而导致错误的计算,最好在delete之后,pi = nullptr;

对于new和delete一个对象而言

  • new则是先_new()分配内存,后构造对象
  • delete则是先析构对象,后_delete释放内存

_new和_delete运算符的实现:

extern void operator new(size_t size){
	if(size ==0)
		size = 1;
	void *last_alloc;
	while(!(last_alloc = malloc(size)))
	{
		if(_new_handler)
			(*_new_handler)();
		else
			return 0;	
	}
	return last_alloc;
}
//_new_handler由使用者自定义提供。
//所以这么写是合法的:
new T[0];//也会返回一个指针,该指针指向一个默认为1byte的内存区块

//delete运算符的实现:
extern void operator delete(void *ptr){
	if(ptr)
		free((char*)ptr);
}	

对于new一个数组时的语意:

  • 如果数组的元素为内置数组,或没有定义默认构造函数和析构函数的对象,只需要_new,_delete运算符即可完成内存分配和释放所有操作,不会调用vec_new;
  • 如果class有定义一个默认的构造函数,某些版本的vec_new则会被调用,需要对每个元素执行构造操作 ,其中传入的元素包含析构函数,是为了防治构造过程发生异常,vec_new有义务去释放所有的内存

delete一个有定义默认析构函数的对象数组时,需要使用delete[]来提升编译器需要寻找数组的维度,但是如何寻找数组的维度:

  1. cookie策略,及在vec_new时就在内存区块配置一个额外的字,然后将元素个数存在该字中保存
    • 这个策略有一个缺点,当我们传入一个错误的起始地址后,delete会根据函数获取一个错误的元素个数,然后根据错误的地址和错误的元素个数,释放调用一段错误的非预期的内存区域
    •  //将元素个数插入到该字中的函数
       int __insert_new_array(arr,elem_count);//arr为数组的起始地址,elem_count为元素个数
       //获取该数组的维度:
       int __remove_old_array(arr); //arr为数组的起始地址
      
  2. 维护一个联合数组,该数组存放指针及其大小

当我们一个基类指针指向派生类

Point *ptr = new Point3d[10];
delete[] ptr;

上述的delete操作传入到vec_delete的析构函数是Point的析构函数,这样并不是我们所希望的。所以为解决这个问题,应该在程序员层面进行要求,而不是在语言层面,程序员应该进行如下转化:

for(int ix = 0;ix<elem_count;++ix){
	Point3d *p = &((Point3d*)ptr)[ix];//指针进行转换
	delete p;
}

定位new的语意

Point2w *ptw = new(arena) Point2w;//在指定的地址arena构造对象Point2w;

不同于普通new,先分配内存,后构造对象

  1. 定位new第一步只是简单返回给定的地址arena
  2. 然后以该地址为this指针构造对象。

当指定地址上原已经存在一个对象,我们是否需要调用该原对象的析构函数(为什么要先析构?):

delete ptw;
ptw = new(arena)Point2w;

不能这么操作调用析构函数,因为delete不仅会析构该原对象,还会释放掉该内存区域,随后再在arena地址上构造对象则会出现问题,所以我们应该显式地调用该析构函数

ptw->~Point2w;//显式调用
ptw = new(arena)Point2w;

给定的地址arena所表现出来的真正指针类型是什么?C++标准说它必须指向相同类型的class,要不就是一块”新鲜”的内存,足以容纳该类型的object。

定位new不支持多态,当在一个原本是指向基类的内存区域构造一个大于该基类的派生类时,会导致严重的破坏,即使当派生类的大小和基类相等时,其虚拟机制也不能正常调用

临时性对象

** 初始化语句 **

T a,b;
T c = a+b;
  1. 编译器会产生一个临时性对象,放置a+b的结果,然后使用T的拷贝构造函数
  2. 直接以拷贝构造的方式将a+b的值放入到c中
  3. NRV优化,直接在c中求表达式结果

** 赋值语句 ** 一般而言,这种初始化操作根本不会产生临时性对象,不同于第二种运算形式c = a+b;这种赋值语句,是不能忽略临时性对象的

c = a+b;
//会做如下转化:
T tmp;
tmp.operator+(a,b);
c.operator=(tmp);//将临时对象赋值到c中
tmp.T::~T();//析构临时性对象

这种赋值语句是不能将c直接传递给operator+运算符操作,因为operator+并不会调用c对象的析构函数,而operator=会先调用析构函数,后调用拷贝构造函数。我们是希望将临时性对象赋值给一块新鲜的内存。

所以类似于c = a+b;这种语句是不安全的,并且会产生很多临时性对象,所以我们建议使用更有效率的初始化语句:

T c = a+b;

** 无目标对象 **

第三种运算形式是,没有出现目标对象:

a+b;

这种就有必要产生一个临时性对象以放置运算后的结果,临时性对象的生命周期有以下几点规则:

  1. 临时性对象被摧毁,应该是对完整表达式(如?:语句)求值过程中的最后一个步骤。。
  2. 凡持有表达式结果的临时性对象,应该存留到object初始化操作完成为止
  3. 如果一个临时性对象被绑定在一个引用或指针,临时性对象将保留,直到指针和引用的生命周期结束

站在对象模型的尖端

模版

三个问题:

  1. 模版的声明,当你声明一个模版类时会发生什么事情
  2. 如何实例化类对象,内莲非成员对象以及成员膜拜函数
  3. 如何实例化非成员模版函数,成员模版函数以及静态模版类成员

对于每一个实例化的模版,都会经过mangled处理获取一个独一无二的名字

当我们定义一个指针,指向特定的实例:

Point<float> *ptr = 0;
//程序中什么也没有发生,一个指向类对象的指针本身并不是一个类对象,没有必要进行实例化

当我们定义一个引用时,编译器会实例化一个实例

Point<float> &ptr = 0;//引用必须绑定在一个实例化的对象上。

一个类对象的定义,也会导致模版的实例化

Point<float> origin;

成员函数在没有被使用之前不应该被实例化,只有在诚意函数被使用的时候,才要求成员函数进行实例化

编译时期只会解析词汇错误和解析错误,而模版的类型检查则推迟到模版被实例化的时候进行,这会导致一些非常明显的模版错误不能被及时检测出来:

template <class type>
class Foo{
	public:
		Foo();
		type val();
		void val(type);
	private:
		type _val;
};

template <class type> double Foo<type>::bogus_member(){
	return this->dbx;//Foo类中病没有bogus_member成员函数,这样的明显的错误,编译时期确并不能发现
}

编译器会将模版的声明放倒一系列lexical tokens解析模版的操作则需要等到真正实例化操作时才开始,会将这些tokens推送到解析器进行解析

模版中名称决议法

一种是标准的 “模版声明作用域”,一种是 “模版实例化作用域”

//文件1:
extern double foo(double);

template <class type>
class ScopeRules{
	public:
		void invariant(){
			_member = foo(_val);
		}
		type type_dependent(){
			return foo(_member);
		}

	private:
		int _val;
		type _member;

}
//文件二:
extern int foo(int);
//同上ScopeRules模版定义
ScopeRules<int> sr0;
sr0.invariant();
sr0.type_dependent();

在文件一中,_val没有涉及到类型参数type,所以直接按照编译时的声明作用域,也就是该模版声明之前的作用域,所以直接定位到 double foo(double);函数,invariant函数调用的就是这个版本的foo函数。

在情况二中,sr0调用函数invariant();该函数匹配是在声明作用域内进行匹配,而该作用域只有double foo(double);函数,所以选中的是这个函数

sr0调用函数type_dependent,其中foo(_member)函数的 _member与类型type相关,所以他的作用域为实例化作用域(所有文件),该作用域有两个函数double foo(double);,int foo(int);根据参数匹配会调用int foo(int)这个版本的函数。

成员函数的实例化行为

模版函数实例化的策略目前主要有两种:

  1. 编译时期策略
  2. 链接时期策略

问题:

  1. 编译器如何找到函数定义
    • 包含program text file,像头文件一样
    • 文件命名规则,如我们在Point.h中发现的函数生命,其template program text一定要放置于文件Point.c,Point.cpp中
  2. 编译器如何能够只实例化程序中用到的成员函数
    • 忽略这个要求,把实例化类的所有成员函数都产生出来
    • 模拟链接操作,检测哪个函数需要,只为它们生产实例
  3. 编译器如何阻止成员函数在多个.o文件中被实例化?
    • 从链接器中获得支持,产生多个实例,最后只留下一个
    • 由使用则引导“模拟链接阶段”的实例化策略。

异常处理

C++中异常处理主要由三个语汇组件构成:

  1. 一个throw语句,它在程序某处发出一个exception
  2. 一个或多个catch子句,每一个catch子句都是一个exception handler,提供实际的处理程序
  3. 一个try区段

当一个exception抛出的时候,控制权会从函数调用中被释放出来,并寻找一个吻合的catch子句,如果没有吻合者,则调用terminate()例程。不断寻找catch子句的过程叫做栈展开,栈展开的过程中,将一个个函数从栈中推出,会先调用该函数中局部对象的析构函数。

exception handling改变了函数在资源管理上的语意,如下面函数含有一块共享内存的locking和unlocking操作,虽然看起来和exception没有什么关系,但在exception之下并不能保证能够正确运行:

void mumble(void *arena){
	Point *p = new Point;
	smLock(arena);//对共享区域加锁
	...//如果这里出现异常,程序不能正确运行,因为异常处理并不会去对该共享内存进行解锁操作
	smUnLock(arena);//解锁
	delete p;
}

所以我们需要添加一个default catch子句,处理正常异常处理机制所不能处理的问题:

void mumble(void *arena){
	Point *p;
	p = new Point;//new如果出现异常,new自身会有heap上的析构处理,也就不需要delete操作,所以不需要在try block区域内
	try{
		smLock(arena);
		//...
	}
	catch(...){
		//添加共享内存解锁操作
		smUnLock(arena);
		delete p ;
		throw;
	}
	smUnLock(arena);
	delete p;

}

建议:将资源需求封装于一个class object体内,由destructor来释放资源。

对异常处理的支持

异常发生时,编译器会完成一下事情:

  1. 检验发生throw操作的函数
  2. 决定throw操作是否发生在try区段中
  3. 若是,则将异常类型和每个catch子句进行比较
  4. 如果比较后吻合,流程控制交给catch子句
  5. 如果throw的发生并不在try区段中,或没有一个catch子句吻合则
    • 摧毁所有激活的局部对象
    • 从堆栈中将该函数弹出“unwind”
    • 进入到程序堆栈中的下一个函数中去重复上升所有步骤
程序计数器的范围表格:

一个函数可以分为三个区域:

  1. try区段以外的区域,没有局部变量被激活
  2. try区段以外的区域,但有一个以上的局部对象被激活,并且需要析构
  3. try区段以内的区域

如何标示出这三个区域,并使它们对执行期的异常处理系统有所作用,通过“范围表格“,当throw操作发生时,目前程序计数器中的值拿来和对应的”范围表格”进行比对,以决定目前作用中的区域是否在一个try区段中,如果是,则找出相关的catch子句,如果这个exception无法被处理,该函数会从函数栈中推出,程序计数器进行下一轮的比较。

如何将异常类型和catch子句的类型做比较

每一个抛出来的异常,编译器必须产生一个类型描述器,对异常类型进行编码,如果该异常是一个派生类,则编码中必须要包含基类的类型信息,RTTI正是为了支持异常处理而获得的副产品。将每一个异常的类型描述器和catch子句中的类型描述器进行比较,吻合则进入该catch子句

当一个实际对象在程序执行时被抛出,会发生什么?
catch(exPoint p ){
	//do something;
	throw;
}

其中exVertex是exPoint的派生类,假设程序抛出的异常为exVertex异常,则作为参数传递给catch子句:

  • 由于p是一个对象参数,所以会将抛出的异常exVertex拷贝构造给catch子句内部的局部变量
  • 并且non-exPoint的派生部分也会被切割掉,而且虚函数表指针也不会被覆盖,所以这种类型的异常参数不支持多态
  • 并且catch子句中对异常的修改不会作用到原异常对象
catch(exPoint &p ){
	//do something;
	throw;
}

这种引用类型的catch参数就可以支持多态,不用拷贝构造。

执行期类型识别RTTI

Type-Safe Downcast保证安全的向下转化操作

C++吹毛求疵的一点就是,它缺乏一种保证安全的downcast,只有在“类型真的可以被适当转换的情况下”才能执行downcast,一个类型安全的downcast必须对指针有所查询,看它是否指向它所表达的对象的真正类型。

目前C++编译器都是将一个指针指向对象的类型放在虚函数表的第一个slot中,被编译器静态设定。

dynamic_cast 保证安全的动态转换

dynamic_cast的成本:会首先获取该对象的类型描述器:

((type_info*)(pt->vptr[0]))->_type_descriptor;//type_info就是代表类型描述器的类

获取完这个类型描述器后可以知道类型转换是否是安全的,是安全的dynamic_cast则返回被转换过的指针,不是安全的则返回0

引用并不是指针

前面提到,当对一个指针进行转换的时候,会检查是否安全,安全返回转换后的指针,不安全则返回0

  • 这里引用则有所区别,当引用的转换是安全的,正常转换并返回
  • 但是当不安全时,不能将引用赋为0,而是抛出一个bad_cast exception

Typeid运算符

返回一个const reference,类型为type_info,该类定义如下:

class type_info{
public:
	virtual ~type_info();
	bool operator == (const type_info&) const;
	bool operator!=(const type_info&) const;
	bool before(const type_info&) const;//用于排序

	const char* name() const;//传回class的原名称
private:
	type_info(const type_info&);
	type_info& operator=(const type_info&);

	//data_member
 }

typeid也可使用于内建数据类型,只不过和多态不同的是,内建类型的type_info是在编译期静态取得的,多态类型的指针或引用的type_info是在执行期动态取得的。