C++ 对象的底层原理都在这儿了,还敢说学不会?
Part1 一、关于对象 C 语言是程序性的,语言本身并没有支持数据和函数之间的关联性 C++ 中可能采取抽象数据类型,或者是多层次的类结构完成 C++ 的封装并没有增加多少成本,每一个成员函数虽然在 class 中声明,但是却不出现在每个对象中 每一个非内联的成员函数只会诞生一个函数实例 每个内联函数会在其每一个使用者身上产生一个函数实例
C++ 在布局以及存储时间上主要的额外负担是由 virtual 引起的 虚函数机制用以支持一个有效率的“执行期绑定” 虚基类用来实现“多次出现在继承关系中的基类,有一个单一而被共享的实例”
还有一些多重继承下的额外负担,发生在一个派生类和其第二或后继之基类的转换之间
1.1 C++对象模式 C++对象模型有以下几点非静态数据成员放在类对象内静态数据成员放在类对象外静态和非静态成员函数也放在类对象外虚函数则不同 每个类中存放一个指针称为 vptr,指向虚函数表表中每个都指向一个虚函数
C++对象模型 1.2 关键词所带来的差异 int ( *pq ) ( ); //声明当语言无法区分那是一个声明还是一个表达式时,我们需要一个超越语言范围的规则,而该规则会将上述式子判断为一个“声明“
struct 和 class 可以相互替换,他们只是默认的权限不一样如果一个程序员需要拥有 C 声明的那种 struct 布局,可以抽出来单独成为 struct 声明,并且和 C++部分组合起来
1.3 对象的差异 C++支持三种程序范式:程序模型、抽象数据类型模型、面向对象模型面向对象模型在继承体系中 ,有时候编译期间无法确定指针或引用所指类型
C++支持的多态类型:
经由一组隐式的转化操作:如派生类指针转化为指向父类的指针 2. 经由虚函数机制 3. 经由 dynamic_cast 和 typeid 运算符一个 class 所占的大小包括:其非静态成员所占的大小 由于内存对齐填补上的大小 加上支持虚函数而产生的大小
指针的类型,只能代表其让编译器如何解释其所指向的地址内容,和它本身类型无关,所以转换其实是一种编译器指令,不改变所指向的地址,只影响怎么解释它给出的地址
当一个基类对象被初始化为一个子类对象时,派生类就会被切割用来塞入较小的基类内存中,派生类不会留下任何东西,多态也不会再呈现。
Part2 二、构造函数语意学 2.1 默认构造函数的构造操作以下四种情况下,会合成有用的构造函数:带有默认构造函数的成员函数对象,不过这个合成操作只有在构造函数真正需要被调用时才发生,但只是调用其成员的默认构造函数,其他则不会初始化如果一个派生类的父类带有默认构造函数,那么子类如果没有定义构造函数,则会合成默认构造函数,如果有的话但是没有调用父类的,则编译器会插入一些代码调用父类的默认构造函数带有一个虚函数的类类声明(或继承)一个虚函数 类派生自一个继承串链,其中有一个或更多的虚基类带有一个虚基类的类
C++新手常见的两个误解:任何 class 如果没有定义默认构造函数,就会被合成出来一个编译器合成出来的默认构造函数会显式设定类中的每一个数据成员的额 默认值
2.2 拷贝构造函数的构造操作有三种情况会调用拷贝构造函数:对一个对象做显式的初始化操作当对象被当作参数交给某个函数当函数传回一个类对象时
如果类没有声明一个拷贝函数,就会有隐式的声明和隐式的定义出现,同默认构造函数一样在使用时才合成出来 什么情况下一个类不展现“浅拷贝语意”:当类内含有一个成员类而后者的类声明中有一个拷贝构造函数(例如内含有 string 成员变量) 当类继承自一个基类而基类中存在拷贝构造函数这两个编译器都会合成拷贝构造函数并且安插进那个成员和基类的拷贝构造函数当类声明了一个或多个虚函数编译器会显式的设定新类的虚函数表,而不是直接拷贝过来指向同一个当类派生自一个继承串链,其中有一个或多个虚基类编译器会合成一个拷贝构造函数,安插一些代码用来设定虚基类指针和偏移的初值,对每个成员执行必要的深拷贝初始化操作,以及执行其他的内存相关工作
2.3 程序转化语意学在将一个类作为另一个类的初值情况下,语言允许编译器有大量的自由发挥的空间,用来提升效率,但是缺点是不能安全的规划拷贝构造函数的副作用,必须视其执行而定
拷贝构造的应用,编译器会多多少的进行部分转换,尤其是当一个函数以值传递的方式传回一个对象,而该对象有一个合成的构造函数,此外编译器也会对拷贝构造的调用进行调优,以额外的第一参数取代 NRV(Named Return Value)
2.4 成员们的初始化队伍四种情况下你需要使用成员初始化列表当初始化一个引用成员变量当初始化一个 const 成员变量当调用一个基类的构造函数,而它拥有一组参数当调用一个类成员变量的构造函数,而它拥有一组参数
class Word{String _name;int _cnt;public:Word(){_name = 0;_cnt = 0;}/*使用成员列表初始化可以解决
Word() : _name(0),_cnt(0){
}*/}上式不会报错,但是会有效率问题,因为这样会先产生一个临时的 string 对象,然后将它初始化,之后以一个赋值运算符将临时对象指定给_name,再摧毁临时的对象
成员初始化列表中的初始化顺序是按照类中的成员变量声明的顺序,与成员初始化列表的排列顺序无关
Part33、Data 语意学 class X{};class Y : public virtual X {};class Z : public virtual X {};class A : public Y,public Z {};
sizeof(X) //1sizeof(Y) //4sizeof(Z) //4sizeof(A) //8X 为 1 是因为编译器的处理,在其中插入了 1 个 char,为了让其对象能在内存中有自己独立的地址 Y,Z 是因为虚基类表的指针 A 中含有 Y 和 Z 所以是 8
每一个类对象大小的影响因素:非静态成员变量的大小 virtual 特性内存对齐
3.1 数据成员的绑定如果类的内部有 typedef,请把它放在类的起始处,因为防止先看到的是全局的和这个 typedef 相同的冲突,编译器会选择全局的,因为先看到全局的
3.2 数据成员的布局非静态成员变量的在内存中的顺序和其声明顺序是一致的但是不一定是连续的,因为中间可能有内存对齐的填补物 virtual 机制的指针所放的位置和编译器有关
3.3 成员变量的存取静态变量都被放在一个全局区,与类的大小无关,正如对其取地址得到的是与类无关的数据类型,如果两个类有相同的静态成员变量,编译器会暗自为其名称编码,使两个名称都不同非静态成员变量则是直接放在对象内,经由对象的地址和在类中的偏移地址取得,但是在继承体系下,情况就会不一样,因为编译器无法确定此时的指针指的具体是父类对象还是子类对象
3.4 继承下的数据成员在下面给定的两个类中依次讨论不同情况:
原本的数据模型在单一继承没有虚函数的情况下布局图
单一继承且无虚函数这种情况下常见错误:可能会重复设计一些操作相同的函数,我们可以把某些函数写成 inline,这样就可以在子类中调用父类的某些函数来实现简化把数据放在同一个类中和继承起来的内存布局可能不同,因为每个类需要内存对齐
分层继承的布局可见内存大了 100%
容易出现的不易发现的问题:
继承下易犯错误当加上多态之后,对空间上增加的额外负担包括:导入一个虚函数表,表中的个数是声明的虚函数的个数加上一个或两个 slots(用来支持运行类型识别)在每个对象中加入 vptr,提供执行期的链接,使每一个类能找到相应的虚函数表加强构造函数,使它能够为 vptr 设定初值,让它指向对应的虚函数表,这可能意味着在派生类和每一个基类的构造函数中,重新设定 vptr 的值加强析构函数,使它能够消抹“指向类的相关虚函数表”的 vptr,vptr 很可能以及在子类析构函数中被设定为子类的虚表地址。析构函数的调用顺序是反向的,从子类到父类
以下是三种情况:不同的继承下会有不同的布局
vptr 放在前端
单一继承有虚函数
多重继承单一继承特点:派生类和父类对象都是从相同的地址开始,区别只是派生类比较大能容纳自己的非静态成员变量
多重继承下会比较复杂
多重继承关系一个派生对象,把它的地址指定给最左边的基类,和单一继承一样,因为起始地址是一样的,但是后面的需要更改,因为需要加上前面基类的大小,才能得到后面基类的地址
多重继承数据分布虚继承 STL 标准库中使用的虚继承:
虚继承例子虚继承关系:
虚继承数据在内存中的分布
虚继承数据模型 23.5 对象成员的效率程序员如果关心程序效率,应该实际测试,不要光凭推论、常识判断或假设。优化操作并不一定总是能够有效运行,我不止一次以优化方式来 编译一个已通过编译的正常程序,却以失败收场
3.6 指向数据成员的指针 vptr 通常放在起始处或尾端,与编译器有关,C++标准允许放在类中的任何位置 取某个类成员变量的地址,通常取到得的是在类的首地址的偏移位置例如 & Point3d::z; 将得到在类的偏移位置,最低限度是类的成员大小总和,而这个偏移量通常都被加上了 1 如果用一个真正绑定类对象(也就是使用 . 操作符访问成员变量)去取地址,得到的将会是内存中真正的地址
在多重继承下,若要将第二个积累的指针和一个与派生类绑定的成员结合起来,那么将会因为需要加入偏移量而变得相当复杂
Part4 四、Function 语意学 C++支持三种类型的成员函数:static 、non-static 、virtualstatic 函数限制:不能直接存取 non-static 数据不能被声明为 const
4.1 成员函数的各种调用方式非静态成员函数:C++会保证至少和一般的普通的函数有相同的效率,经过三个步骤的转换改写函数,安插一个额外的参数到该函数中,用来提供一个存取管道------即 this 指针对每一个非静态成员的存取操作改成使用 this 指针来调用将成员函数改写成一个外部函数,并且名称改为独一无二的
虚函数成员函数:也会经过类似的转化例如:ptr->normalize()会被转化为( * ptr->vptr[1] )( ptr )vptr 是编译器产生的指针,指向虚函数表,其名称也会被改为独一无二 1 是该函数在虚函数表中的索引 ptr 则是 this 指针
静态成员函数:它没有 this 指针,因此会有以下限制:它不能直接存取类中的非成员变量它不能够被声明为 const、volatile 和 virtual 它不需要经过类的对象才能被调用----虽然很多事情况是这样调用的
4.2 详解虚成员函数虚函数一般实现模型:每一个类都有一个虚函数表,内含该类中有作用的虚函数地址,然后每个对象有一个虚函数指针,指向虚表位置多态含义:以一个基类的指针(或引用),寻址出一个子类对象什么是积极多态?
当被指出的对象真正使用时,多态就变成积极的了
单一继承虚函数布局图
单一继承虚函数图一个类派生自 Point 会发生什么事?三种可能的情况它可以继承基类所声明的虚函数的函数实例,该函数实例的地址会被拷贝进子类的虚表的相对应的 slot 之中它可以使用自己的虚函数实例----它自己的函数实例地址必须放在对应的 slot 之中它可以加入一个新的虚函数,这时候虚函数表的尺寸会增加一个 slot,而新加入的函数实例地址也会被放进该 slot 之中
多重继承下的虚函数
多继承虚函数图虚拟继承下的虚函数
虚继承下的虚函数图 4.4 指向成员函数的指针对于普通的成员函数,编译器会将其转化为一个函数指针,然后使用成员函数的地址去初始化
例如 double (Point :: *pmf ) ( );转化为 double ( Point :: coord )( ) = &Point :: x;这样调用 (coord) (& origin)或 (coord)(ptr)
对一个虚函数取地址,在 vc 编译器下,要么得到 vacll thunk 地址(虚函数时候),要么得到的是函数地址(普通函数)
4.5 内联函数 inline 只是向编译器提出一个请求,是否真的优化取决于编译器自己的判定对于形式参数,会采用:常量表达式替换 常量替换 引入临时变量来避免多次求值操作
对于局部变量,会采用:使用临时变量
Part5 五、构造、析构、拷贝语意学应注意的一些问题:构造函数不要写为纯虚函数,因为当抽象类中有数据的时候,将无法初始化把所有函数设计成虚函数,再由编译器去除虚函数是错误的,不应该成为虚函数的函数不要设计成虚函数当你无法抉择一个函数是否需要为 const 时,尤其是抽象类,最好不设置成 const
5.1 "无继承" 情况下的对象构造对于没有初始化的全局变量,C 语言中会将其放在一个未初始化全局区,而 C++会将所有全局对象初始化对于不需要构造函数、析构函数、赋值操作的类或结构体,C++会将其打上 POD 标签,赋值时按照 c 那样的位搬运对于含有虚函数的类,编译器会在构造函数的开始放入一些初始化虚表和虚指针的操作面对函数以值方式返回,编译器会将其优化为加入一个参数的引用方式,避免多次构造函数
5.2 继承体系下的对象构造构造函数会含有大量的隐藏嘛,因为编译器会进行扩充:记录在成员初始化列表中的成员数据初始化会被放进构造函数的本体,并以成员在类中声明的顺序为顺序 如果有一个成员并没有出现在成员初始化列表中,但是它由一个默认构造函数,那么也会被调用在那之前,如果类对象由虚表指针,它必须被设定初值,指向适当的虚表在那之前,所有上一层的基类构造函数必须被调用,以基类声明的顺序(不是成员初始化列表出现的顺序)如果基类被列于成员初始化列表中,那么任何显式指定的参数应该传递过去如果基类没有被列于基类初始化列表中,而它有默认的构造函数,那么就调用如果基类是多重继承下的第二个或后继的基类,那么 this 指针必须有所调整在那之前,所有虚基类构造函数必须被调用,从左到右,从最深到最浅如果类被列于成员初始化列表中,那么如果有任何显式指定的参数,都应该传递过去。若没有列于 list 中,而类中有一个默认构造,亦应该调用此外,类中的每一个虚基类的偏移位置必须在执行期可被存取如果类对象是最底层的类,其构造函数可能被调用,某些用以支持这一行为的机制必须被放进来
不要忘记在赋值函数中,检查自我赋值的情况
5.3 对象复制语意学当一个类复制给另一个类时,能采用的有三种方式:什么都不做,会实施默认行为如果有需要,会自动生成一个浅拷贝,至于什么时候需要深拷贝(见第二章讲)提供一个拷贝复制运算符显式地拒绝把一个类拷贝给另一个
虚基类会使其复制操作调用一次以上,因此我们应该避免在虚基类中声明数据成员
5.5 析构语意学什么时候会合成析构函数?在类内含的成员函数有析构函数基类含有析构函数
析构的正确顺序:析构函数的本体首先被执行,vptr 会在程序员的代码执行前被重设。如果类拥有成员类对象,而后者拥有析构函数,那么他们会以其声明顺序的相反顺序被调用如果类内涵一个 vptr,现在被重新设定,指向适当的积累的虚表如果有任何直接的非虚基类拥有析构函数,它们会以其声明顺序的相反顺序被调用如果有任何虚基类拥有析构函数,而且目前讨论的这个类是最尾端的类,那么它们会以其原来的构造顺序的相反顺序被调用
Part6 六、执行期语意学 C++难以从程序源码看出表达式的复杂过程,因为你并不知道编译器会在其中添加多少代码
编译器对不同的对象会做不同的操作:
对于全局对象:编译器会在添加__main 函数和_exit 函数(和 C 库中的不同),并且在这两个函数中对所有全局对象进行静态初始化和析构
全局对象编译器操作使用被静态初始化的对象,有一些缺点:
如果异常处理被支持,那么那些对象将不能被放置到 try 区段之内增加了程序的复杂度
因此,不建议用那些需要静态初始化的全局对象
对于局部静态对象:
会增加临时的对象用来判断其是否被构造,用来保证在第一次进入含有该静态对象的起始处调用一次构造函数,并且在离开文件的时候利用临时对象判断是否已经被构造来决定是否析构
对于对象数组:(例如 Point knots[10])
如果对象没有定义构造函数和析构函数,那么编译器只需要分配需要存储 10 个连续空间即可 如果有构造函数,且如果有名字,则会分为是否含有虚基类调用不同的函数来构造,如果没有名字,例如没有 knots,则会使用 new 来分配在堆中 当声明结束时会有类似构造的析构过程 我们无法在程序中取出一个构造函数的地址
6.2 new 和 delete 运算符对于普通类型变量:例如 int *pi = new int(5) 调用函数库中的 new 运算符 if(int *pi = _new (sizeof(int) )) 再配置初值 *pi = 5 对于 delete 来说 delete pi; 则先进行保护 if( pi != 0) 再调用 delete 、_delete(pi)
对于成员对象:
对于 Point3d* origin = new Point3d
实际调用 operator new,其代码如下
语言要求每一次对 new 的调用都必须传回一个独一无二的指针,为了解决这个问题,传回一个指向默认为 1Byte 的内存区块,允许程序员自己定义_new_handler 函数,并且循环调用
至于 delete 也相同
对于对象数组,会在分配的内存上方放上 cookies,来存储数组个数,方便 delete 调用来析构
程序员最好避免以一个基类指向一个子类所组成的数组---如果子类对象比其基类大的话
解决方式:
for(int ix = 0; ix < elem_count; ++ix){Point3d p = &((Point3d)ptr)[ix];delete p;}程序员必须迭代走过整个数组,把 delete 运算符实施与每一个元素身上。以此方式,调用操作将是 virtual。因此,Point3d 和 Point 的析构函数都会实施于每一个对象上
Placement Operator new 的语意有一个预先定义好的重载的 new 运算符,称为 placement operator new。它需要第二个参数,类型为 void*
形如 Point2w *ptw = new (arena) Point2w,其中 arena 指向内存中的一个区块,用以放置新产生出来的 Point2w 对象
void* operator new(size_t , void* p){return p;}如果我们在已有对象的基础上调用 placement new 的话,原来的析构函数并不会被调用,而是直接删除原来的指针,但是不能使用 delete 原来的指针
正确的方法应该是 :
6.3 临时性对象临时对象在类的表达式并赋值,函数以值方式传参等都会产生临时对象-----而临时对象会构造和析构,所以会拖慢程序的效率,我们应该尽量避免
Part7 七、站在对象模型的顶端三个著名的 C++语言扩充性质:模板、异常(EH)、RTTI(runtime type identification)
7.1 Template 模板实例化时间线:
当编译器看到模板类的声明时,什么都不会做,不会进行实例化模板类中明确类型的参数,通过模板类的某个实例化版本才能存取操作即使是静态类型的变量,也需要与具体的实例版本关联,不同版本有不同的一份如果声明一个模板类的指针,那么不会进行实例化,但是如果是引用,那么会进行实例化对于模板类中的成员函数,只有在函数使用的时候才会进行实例化
模板名称决议的方法-----即如果非成员函数在类中调用,那么会调用名称相同的哪个版本:
会根据该函数是否与模板有关来判断,如果是已知类型,那么会在定义的范围内直接查找,如果依赖模板的具体类型,那么会在实例化的范围查找
7.2 异常处理 C++异常处理由三个主要的语汇组件:
一个 throw 子语。它在程序某处发出一个 exception,exception 可以说内建类型也可以是自定义类型一个或多个 catch 子句。每一个 catch 子句都是一个 exceotion hander,它用来表示说,这个子句准备处理某种类型 exception,并且在封闭的大括号区段中提供实际的处理程序一个 try 区段。它被围绕一系列的叙述句,这些叙述句可能会引发 catch 子句起作用
当一个异常被抛出去,控制权会从函数调用中被释放出来,并寻找一个吻合的 catch 子句。如果都没有吻合者,那么默认的处理例程 terminate()会被调用,当控制权被放弃后,堆栈中的每一个函数调用也就被推离。这个程序被称为 unwingding the stack 。在每一个函数被推离堆栈之前,函数的局部类的析构会被调用
因此一个解决办法就是将类封装在一个类中,这样变成局部类,如果抛出异常也会被自动析构当一个异常抛出,编译系统必须:
检查发生 throw 操作的函数决定 throw 操作是否发生在 try 区段若是,编译系统必须把异常类型拿来和每一个 catch 子句进行比较如果比较后吻合,流程控制应该交到 catch 子句中如果 throw 的发生并不在 try 区段中,或没有一个 catch 子句吻合,那么系统必须:摧毁所有活跃局部类 从堆栈中将目前的函数(unwind)释放掉 进行到程序堆栈的下一个函数中去,然后重复上述步骤 2—57.3 执行期类型识别当两个类有继承关系的时候,我们有转换需求时,可以进行向下转型,但是很多时候是不安全的 C++的 RTTI (执行期类型识别)提供了一个安全的向下转型设备,但是只对多态(继承和动态绑定)的类型有效,其用来支持 RTTI 的策略就是,在 C++的虚函数表的第一个 slot 处,放一个指针,指向保存该类的一些信息----即 type_info 类(在编译器文件中可以找到其定义)dynamic_cast 运算符可以在执行期决定真正的类型对于指针来说:如果转型成功,则会返回一个转换后的指针如果是不安全的,会传回 0 放在程序中通过 if 来判断是否成功,采取不同措施对于引用来说:如果引用真正转换到适当的子类,则可以继续如果不能转换的话,会抛出 bad_cast 异常通常使用 try 和 catch 来进行判断是否成功
Typeid 运算符:可以传入一个引用,typeid 运算符会传回一个 const reference,类型为 type_info。其内部已经重载了 == 运算符,可以直接判断两个是否相等,回传一个 bool 值例如 if( typeid( rt ) == typeid( fct ) )RTTI 虽然只适用于多态类,但是事实上 type_info object 也适用于内建类,以及非多态的使用者自定类型,只不过内建类型 得到的 type_info 类是静态取得,而不是执行期取得。
品味经典
▊《C++ Primer 中文版(第 5 版)》
Stanley B. Lippman,Josee Lajoie,Barbara E. Moo 著
王刚 杨巨峰 译
评论