《深度探索C++对象模型》笔记

第一章 关于对象

1.多态:不同对象去完成同一行为时,可以展现出不同的形态。在运行时,可以通过指向基类的指针或引用,来调用实现派生类中的方法。

作用:在面向对象的程序设计中,使用多态能够增强程序的可扩充性,即类型需要修改或增加功能时,只需改动或增加较少的代码,提高开发效率。

2.C++对象模型
所有的非静态数据成员存储在对象本身中。所有的静态数据成员、成员函数(包括静态与非静态)都置于对象之外。另外,用一张虚函数表(virtual table)存储所有指向虚函数的指针,并在表头附加上一个该类的type_info对象,在每个对象中则保存一个指向虚函数表的指针。如下图:
在这里插入图片描述
函数存放在只读代码区的.text段内,虚函数表存放在.rodata段内。

3.多态运转原理:
存在虚函数的类都有一个一维的虚函数表叫做虚表,每一个类的对象有一个指向虚表的虚指针。虚表是和类对应的,虚表指针是和对象对应的。 父类和子类相对应的虚函数在虚表中的位置相同。

一个指针或引用之所以支持多态,是因为指针和引用并不引发“与类型有关的内存操作”,只影响他们所指向的内存的“解释方式”。
“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。

指向子类型对象的父类型指针,把子类型对象当成了父类型来解释,但当其通过虚指针调用虚函数时,使用的是子对象的虚指针,并且父类和子类相对应的虚函数在虚表中位置相同,因此调用了子对象的虚函数。
编译的时候就决定了,普通函数直接调用这个函数的地址,而如果是虚函数,则从这个虚表里取地址调用。

4.面向对象和基于对象:
C++通过类的指针和引用来支持多态,这种设计风格就被称为“面向对象”;
不使用继承和多态的编程风格就被称为基于对象object-based(OB),执行更快且空间更紧凑,但没有弹性。

第二章 构造函数语意学

NRV(命名返回值named return value)优化

X foo() 
{ 
    X xx; 
    if(...) 
        return xx; 
    else 
        return xx; 
}

形如foo(),所有return 指令传回相同的对象,编译器会自动对其做NRV优化,方法是以一个参数result取代返回对象:

void  foo(X &result)
{
    result.X::X();
    if(...)
    {
        //直接处理result
        return;
    }
    else
    {
        //直接处理result
        return;
    }
}

对于一句类似于X a = foo()这样的代码,NRV优化后的代码相较于原代码节省了一个临时对象的空间(省略了xx),同时减少了两次函数调用(减少xx对象的默认构造函数和析构函数,以及一次拷贝构造函数的调用,增加了一次对a的默认构造函数的调用)。

成员函数初始化队列:

在构造函数中对于对象成员的初始化发生在初始化队列中——或者我们可以把初始化队列直接看做是对成员的定义,而构造函数体中进行的则是赋值操作。
初始化队列运行在前(初始化顺序按class中成员声明顺序,而不是初始化队列顺序),构造函数体在后。
编译器实际上会一一操作初始化队列,按顺序在构造函数体内安插初始化操作,并在原来的显式代码之前。

第三章 Data语意学

1.多态 内存布局
在这里插入图片描述每一个 有虚函数 的类有一个虚函数表,虚函数表存储虚函数指针,虚函数指针指向该类的虚函数,该类的每一个对象有一个虚函数表指针vptr,指向该虚函数表。

在这里插入图片描述多重继承情况下,有多少个基类就有多少个虚函数表指针,前提是基类要有虚函数才算上这个基类。当子类有多出来的虚函数时,添加在第一个虚函数表中。
https://blog.csdn.net/qq_36359022/article/details/81870219

第四章 函数语意学

1.虚函数底层实现:
http://www.roading.org/develop/cpp/c%E4%B9%8B%E8%99%9A%E5%87%BD%E6%95%B0virtual-member-functions.html
简单概括:
单一继承中,无论基类指针指向的对象实际是什么类型,该指针都能通过虚指针(基类及其继承类中虚指针名称相同),以及该对象对应的虚函数表中的索引,调用到想要调用的某个虚函数,因为在不同类中,相对应的虚函数索引相同。

多重继承中,有多个虚指针、虚函数表,对于 第二个或后续基类 的处理 需要调整this指针,所以比较复杂;

虚拟继承中,虚基在派生类中也存在虚指针,存在多个虚函数表,更复杂。

3.成员函数实际上通过携带类对象的this指针,转化为非成员函数,和非成员函数具有相同的效率。

4.指向函数的指针
指向非虚拟函数的指针,输出为函数地址;
指向虚拟函数的指针,输出为该虚拟函数在虚拟函数表中的索引值。

第五章 构造、析构、拷贝语意学

1.类设计原则:
1)即使是一个抽象基类,如果它有非静态数据成员,也应该给它提供一个带参数的构造函数,来初始化它的数据成员。
2)不要将析构函数设计为纯虚的,这不是一个好的设计。
3)真的必要的时候才使用虚函数,不要滥用虚函数。
4)不能决定一个虚函数是否需要 const ,那么就不要它。
5)决不在构造函数或析构函数中使用虚函数机制。

2.POD数据类型
Plain Old Data(简旧数据类型) : 能直接以其二进制形式与 C 库交互的数据类型,比如可以直接使用memmove、memcopy等C函数进行复制。
详细介绍

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

概念上来讲,对于一段这样的C++代码,编译器会为之合成一个默认构造函数、拷贝构造函数、析构函数、赋值操作符。然而实际上编译器会分析这段代码,并给Point贴上Plain OId Data标签。编译器在此后对于Point的处理与在C中完全一样,也就是说上述的函数都不会被合成。可见概念上应当由编译器合成的函数,并不一定会合成,编译器只有在必要的时候才会合成它们。由此一来,原本在观念上应该调用这些函数的地方实质上不会调用,而是用其它的方法来完成上面的功能,比方复制操作会用bitwise copy(位拷贝)。

3.类的显式列表初始化

struct N{
    int x,y,z;
};
//或者
class N{
public:
    int x,y,z;
};
int main(){
    N a={1,2,3};
	return 0;
}

限制:1)只有成员都是public,且没有构造函数时才能使用,2)只能指定常量。

4.虚拟继承的构造函数
在这里插入图片描述
根据c++ 语法,Point 的初始化应有most-derived class来施行。也就是说当Vertex3d为most-derived class的时候,应当由它的构造函数来调用Point的构造函数初始化Point子对象,Vertex3d的子对象的构造函数对于Point的调用则应当抑制。如果没有抑制会怎么样?当我们定义Vertex3d cv;时,Vertex3d的构造函数中调用Point的构造函数、而随之调用它的子对象,Point3d和Vertex的构造函数中也调用了Point的构造函数。先不说,对于同一个子对象进行三次初始化是否有效率,更重要的是,这将不可避免的带来错误。由Vertex3d指定的子对象Point的值,会被覆盖掉。

编译器通常使用一个条件变量来表示是否为most-derived class,各构造函数根据这个条件变量来决定是否调用虚基类的构造函数,因此通过控制这个条件变量,就可以抑制非most-derived class调用虚基类的构造函数。当然也有其它的方法来做同样的事。

假设该体系每个类中都有一个虚函数size()返回当前类的大小,在每一个构造函数中都放置一个size()。
定义一个PVertex对象,前述的5个构造函数每次调用的size()都被决议为当前正在执行的构造函数所对应的类 的size()函数实例,而不全是PVertex::size()。为什么?这就要引出 vptr的初始化操作 以及 构造函数的执行算法。

构造函数的执行算法
1)在派生类构造函数中,所有虚基类 以及 上一层基类 的构造函数被调用。(递归)
2)上述完成后,对象的vptr被初始化,指向相关的vtable。(某些情况可省略)
3)如果有成员函数初始化队列,则在构造函数体内扩展开来。因为vptr已经初始化完成,所以虚成员函数可以被调用。
4)最后,执行构造函数体内程序员提供的代码。

其中,vptr并不是一定要被设定,可以省略,除了两种情况:
1)当一个完整的对象被构造起来。比如声明Point3d,则Point3d的构造函数必须设定其ptr。
2)当一个子对象构造函数调用了一个虚函数(直接调用或间接调用)时。

所以说,在类的构造函数初始化列表里调用该类的虚拟函数是安全的,因为调用时的vptr已经被编译器正确设定好。

对象复制语意学

设计一个类,并考虑到要以一个对象指定给另一个对象时,有三种选择:

  • 什么都不做,采用编译器提供默认行为(bitwise copy或者由编译器合成一个)。
  • 自己提供一个赋值运算符操作。
  • 明确拒绝将一个对象指定给另一个对象。(设置为private不定义,或者=delete)

对于第三点,只要将赋值操作符声明为private,且不定义它就可以了。对于第二点,只有在第一点的行为不安全或不正确,或你特别想往其中插入点东西的时候。

编译器的默认行为:如果没有特殊情况,用bitwise copy(展现bitwise copy语意),否则合成赋值运算符。

不使用bitwise copy的四种特殊情况:

  • 类包含有定义了copy assignment operator的class object成员。
  • 类的基类有copy assignment operator。
  • 类声明有任何虚函数的时候(问题同样会出现在由继承类对象向基类对象拷贝的时候)。
  • 当class继承体系中有虚基类时。

在虚拟继承情况下,copy assignment operator会遇到一个不可避免的问题,virtual base class sub-object的复制行为会发生多次,与前面说到的在虚拟继承情况下虚基类被构造多次是一个意思,不同的是在这里不能抑制非most-derived class 对virtual base class 的赋值行为。

安全的做法是把虚基类的赋值放在最后,避免被覆盖。或者直接不在虚基中声明数据。

对象析构语意学

只有在基类拥有析构函数,或者object member拥有析构函数的时候,编译器为类合成析构函数,否则都被视为不需要。如果程序提供了析构函数,则编译器会扩展析构函数,在我们提供的代码后面调用对基类或对object member的析构。

析构的顺序正好与构造相反:

  • 本身的析构函数被执行。
  • 如果member class object拥有析构函数,则以声明的相反顺序调用member object 的析构函数。
  • 如果对象内含vptr,则重设vptr 指向适当的基类的虚函数表。
  • 如上一层非虚基类有析构函数,则以声明相反的顺序被调用。
  • 如果当前类是 most-derived class,那么以构造的相反顺序调用虚基类的析构函数。

析构函数实现策略:维护两份析构函数实例

  • 对于完整对象实例:总是设定好vptr,并调用虚基类析构函数。
  • 基类子对象实例:除非析构函数中调用了虚函数,否则不会设定vptr。

第6章 执行期语意学

6.1对象的构造和析构

一般而言,构造函数被安插在对象的定义处,而析构函数被安插在对象生命周期结束前:

// Pseudo C++ Code  
{  
    Point point;  
    // point.Point::Point() 一般被安插在这儿  
    ...  
    // point.Point::~Point() 一般被安插在这儿 
}

当代码有一个以上的离开点的时候,析构函数则必须放在对象被构造之后的每一个离开点(return)之前。

因此,尽可能将对象定义在接近要使用的地方,可以减少不必要的构造对象和析构对象的代码被插入到自己的代码当中。把所有对象定义放在函数或区段起始处不是好的习惯。

全局对象

C++中所有的全局对象(类的对象)都放在程序的data段中,如果显式指定一个值,则对象以该值为初值,否则内存内容为0)。其构造函数要到程序启动才会实施(静态初始化)。

局部静态变量

下面一段代码:

const Matrix&  identity()
{  
    static Matrix mat_identity;  
    // ...  
    return mat_identity;  
}

因为静态语意保证了 mat_identity 在整个程序周期都存在,而不会在函数 identity()退出时被析构,所以:

  • mat_identity的构造函数只能被施行一次,虽然identity()可以被调用多次。
  • mat_identity 的析构函数只能被施行一次,虽然identity()可以被调用多次。

那么 mat_identity的构造函数和析构函数到底在什么时候被调用?答案是:mat_identity的构造函数只有在第一次被调用时在被施行,而在整个程序退出之时按构造相反的顺序析构局部静态对象。

对象数组(Array of Objects)

对于定义一个普通的数组,例如:

Point knots[ 10 ];

实际上背后做的工作则是:

  1. 分配充足的内存以存储10个Point元素;
  2. 为每个Point元素调用它们的默认构造函数(不论是合成的还是显式定义的,如果没有则调用有默认值的其他构造函数,如果不适配则无法运行)。编译器一般以一个或多个函数来完成这个任务。当数组的生命周期结束的时候,则要逐一调用析构函数,然后回收内存,编译器同样一个或多个函数来完成任务。
  3. 如果初始化了前几个对象,则先用给出的初值初始化前几个对象,剩下的对象调用默认构造函数。

6.2 new和delete运算符

一个看起来很简单的new expression运算,其实暗含一些步骤,像这样的一次简单运用:Point3d *p=new Point3d;实际上包含着两个步骤:

  1. 调用一个合适的operator new函数分配足够的内存,默认使用malloc分配,分配失败会抛出异常;
  2. 调用合适的构造函数初始化这块内存。
//Point3d *p=new Point3d;等价于
Point3d* p;
if (p = operator new(sizeof(Point3d)))
    p = Point3d::Point3d( p );

delete运算符类似,但如果p指针值为0,C++会要求delete不要有操作:

//delete p;等价于
if (p !=0 ){
	Point3d::~Point3d( p );
    operator delete(p);
}

但p并不会因此自动清除为0,仍为原来的地址,但成为了野指针——指向了一块“垃圾内存”,或者说指向了一块不应该读写的内存。避免野指针的好方法是,当一个指针变为野指针的时候,马上赋值为NULL,其缘由在于,你可以很容易的判断一个指针是否为NULL,却难以抉择其是否为野指针。

由此可见:new expression和operator new完全不是一回事,但关系不浅——operator new 为new expression分配内存。

摘录一下 《C++ primer》关于对比new expression 和 operator new的一小段话:

标准库函数 operator new和 operator delete 的命名容易让人误解。与其他operator 函数(如 operator=)不同,这些函数没有重载new或delete expression,实际上,我们不能重定义new或delete expression的行为。

我们能够重载的只有new expression 中第一步的operator new函数。

针对数组的new语意

Point *p=new Point3d[10];
p[1];//p + sizeof(Point);

将基类指针指向派生类数组,很容易出问题。

因为派生类和基类对象的大小是通常不一样的,对基类指针使用下标操作符时,编译器按基类对象大小寻址,而不是我们需要的派生类。

Placement Operator new 的语意 (布局new)

placement operator new 是重载 operator new 的一个标准、全局的版本,它不能够被自定义的版本代替。

用来在指定地址上构造对象,要注意的是,它并不分配内存,仅仅是 对指定地址调用构造函数。其调用方式如下:

Point *pt=new(p) Point;

它的实现方式异常简单,传回一个指针即 可:

void* operator new(void *p ,size_t){//size_t被忽略
    return p;
}

代替了 普通 operator new 的分配内存,因为地址已经给出,所以不需要自己分配内存。 之后自动执行就是 new 表达式的第二步:调用合适的构造函数初始化这块内存。

通过一个placement operator new构建的一个对象,如果你使用delete来撤销对象,那么其内 存也被回收,如果想保存内存而析构对象,好的办法是显示调用其析构函数。

使用条件:指针 p 必须指向相同类型的类对象,或者是一块 新内存,足够容纳该类型的对象。如下:

Point *p= new Point;
char *p = new char[ sizeof( Point )];

临时对象

T operator+( const T&, const T&);//存在+重载
T a, b;
T c= a + b ;

上面的程序片段实际上只执行三次构造函数,没有执行拷贝构造,没有临时变量。理论上是因为实施了NRV优化,将c作为额外参数提供给了operator+函数,直接在函数内部修改了c。

但是对于一个情况非常类似的赋值操作语句c = a + b,却有很大的差别,那个临时变量是不能省的。

不能忽略临时对象,反而导致如下过程:(有临时对象的构造、析构,还有赋值运算符)

// Pseudo C++ code  
// T temp = a + b;  
T temp;  
a.operator+( temp, b ); 	// @1 
	
// c = temp  
c.operator =( temp ); 		// @2  
temp.T::~T();

在代码@1处,表明以拷贝构造函数或NRV方式将结果保存的临时对象中,而不是c中,因为运算符并不为其外加参数调用一个析构函数(它期望一块新的内存)。

对于一个没有出现目标对象的表达式a + b,那么产生一个临时对象来存储运算结果,则是非常必要的。

所以:T c = a + b总是比c = a + b更有效率。

原因可以简单概括为:运算符函数并不为外加参数调用析构函数,期望新的内存,所以前者(内存新鲜)可以使用NRV优化,而后者需要使用赋值运算符。

临时对象的生命周期

对于一个没有出现目标对象的表达式a + b;,产生一个临时对象来存储运算结果,是非常必要的。比如

string s1("hello "), s2("world "),s3("by Adoo");
std::cout<<s1+s2+s3<<std::endl;

C++标准规定

  • 临时性对象的摧毁应当作为造成产生这个临时对象的完整表达式的最后一个步骤

    完整表达式:不是另一个表达式 子表达式 的表达式。

但有两个例外:

  • 凡含有表达式执行结果的临时性对象,应该保存到Object的初始化操作完成为止。

  • 如果临时性对象被绑定与一个引用,临时对象将残留,直至被初始化的引用的生命结束,或直到临时对象的scope结束——视哪一种情况先达到,对应于这种情况:

string s1("hello ");
const string &s=s1+"world";

第七章

http://www.roading.org/develop/cpp/%E6%A8%A1%E6%9D%BF%E4%BA%8C%E4%BA%8B.html

http://www.roading.org/develop/cpp/eh-rtti.html

1.当catch的参数是基类时,可以捕获其派生类异常。

//异常类型为exVertex,派生自exPoint
catch(exPoint p){
	//do something
	throw;
}
catch(exPoint &rp){
	//do something
	throw;
}

如果参数是引用类型,则会有多态性质;如果是普通基类,则会被裁剪为基类对象。
如果函数体内修改该捕获对象且继续抛出,对引用类型的修改会被繁殖到下一个catch子句;而非引用类型的修改会被抛弃。

exVertex errVer;
//...
mumble(){
	//...
    if(mumble_cond){
        //...
        throw errVer;
    }
}

被抛出的errVer是全局对象的复制品,所以catch子句中对异常对象的改变是局部性的,不会影响到errVer。只有在一个catch子句评估完毕并且知道他不会再抛出异常后,真正的异常对象才会被摧毁。

7.3执行期类型识别(Runtime Type Identification RTTI)


downcast(向下转换)

意为 将基类转换至其派生类中的某一个。

需要downcast的情况:指向派生类对象的基类指针,需要使用派生类的非虚函数,此时需要先把基类指针转换为派生类指针,再调用函数。

downcast引发的问题:如果downcast不正确的使用,比如:被转换的指针并不是指向该派生类对象,而是指向其他派生类对象或基类对象,则也会被转换且不会报错,后续使用会造成问题。

由此,产生了dynamic_cast

dynamic_cast运算符

dynamic_cast运算符,用于将 基类指针或引用安全地转换为派生类的指针或引用。

通过在执行期(因此,只对支持多态的类适用)查询指向对象的类型来判断转换是否安全:

如果指针的转换是不安全的,则传回0;

如果引用的转换是不安全的,因为无法将0传回引用,所以会抛出bad_cast exception。

typeid运算符

对类型或表达式使用,返回const type_info&,用以获取类型信息。

所有类型都可以使用typeid:

对于多态类型,执行期查询虚函数表;非多态类型,直接静态取得。

Point3d x;
Point *p=&x;
typeid(*p).name();//返回Point3d类型,通过查询虚函数表

因此,可以先使用typeid判断对象类型是否一样,再决定是否转换,以代替dynamic_cast运算符。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值