0%

【C++对象模型】(六)关于执行期

《深度探索C++对象模型》第六章重点梳理。主要内容包括:

  • 执行期对象的构造与析构
  • 详解 new 和 delete

1 对象的构造与析构

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

1
2
3
4
5
6
7
8
9
{ 
Point point;

// point.Point::Point() 一般被安插在这儿

...

// point.Point::~Point() 一般被安插在这儿
}

当代码有一个以上的离开点的时候,析构函数则必须放在对象被构造之后的每一个离开点之前。因此,尽可能将对象定义在接近要使用的地方,可以减少不必要的构造对象和析构对象的代码被插入到自己的代码当中。

1.1 全局对象(Global Objects)

一个全局对象,C++ 保证它在 main() 第一次使用它之前将其构造,而在 main() 结束之前,将之析构掉。C++ 程序中所有的 global objects 都被放置在程序的 data segment (数据段) 中,如果明确指定给它一个值, object 将以该值为初值。否则 object 所配置到的内存内容为 0.

虽然全局对象在编译期被即被置为 0,但真正的构造工作却需要直到程序激活后才能进行,而这个过程就是静态初始化。所谓静态初始化,是因为全局变量被放在 data segment,data segment 是在编译期已经布置好的,但构造函数的结果在编译期不能评估,因此先将对象的内容设置为 0,存储在数据段,而等到程序激活时,就可以通过构造函数对在数据段的全局对象进行初始化了.

以下引用自原书:

静态初始化的对象有一些缺点:如果构造函数支持异常机制,那么遗憾的是对象的构造函数的调用,无法被放置于 try 块中,我们知道一个没有得到 catch 的异常默认的调用 terminate() 函数。也就是说一个全局对象在构造过程中抛出异常,将导致程序的终结,而更悲剧的是,你还无法来捕获并处理这个异常。另一点在于,在不同文件中定义的全局变量,构造顺序有规则吗?我不知道。即使有规则,如果不同的构造顺序对程序有影响的话,那么有多琐碎复杂…

Lippman 甚至建议:根本就不要使用那些需要静态初始化的全局对象。真的非要一个全局对象,而且这个对象还需要静态初始化?那么我的方法是,用一个函数封装一个静态局部对象,也是一样的效果嘛。

1.2 局部静态对象(Local Static Objects)

下面一段代码:

1
2
3
4
5
6
const Matrix&  identity()
{
static Matrix mat_identity;
// ...
return mat_identity;
}

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

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

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

1.3 对象数组(Array of Objects)

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

1
Point knots[10];

实际上背后做的工作是:

  1. 分配充足的内存以存储 10 个 Point 对象;
  2. 为每个 Point 对象调用它们的默认构造函数(如果有的话,且不论是合成的还是显式定义的)。编译器一般以一个或多个函数来完成这个任务。当数组的生命周期结束的时候,则要逐一调用析构函数,然后回收内存,编译器同样一个或多个函数来完成任务。这些函数完成什么功能,大概都能猜得出来。而关于具体细节,不必要死扣了,每个编译器肯定都有些许差别。

2 new 和 delete

C++ 中一个经常容易混淆的问题是关于:operator new、new expression 和 placement operator new。这一节来详细讨论这三者的区别和联系。

2.1 operator new 和 new expression

首先要明确,operator new 是一个运算符,和 +、-、*、% 等一样,是可以被重载的,而 new expression 不可以被重载,new expression 是对 operator new 的又一层封装。

C 语言中 malloc 函数我们非常熟悉,函数原型为 void* malloc(size_t size) ,参数 size 为要分配的字节数,返回值是 void*,通常要强转为我们需要申请空间的类型,开辟成功回返回空间首地址,失败会返回 NULL,但是申请成功后并不进行初始化,每个数据都是随机值。

operator new 是对 malloc 的封装,因此功能和 malloc 完全一样,只有一点细微的不同,operator new 在内存申请失败时会抛出异常,除此之外完全相同,也就是说,operator new 只用于分配内存。

而当我们使用非常熟悉的 new expression,比如:

1
Point3d *p = new Point3d;

实际上包含了两个步骤:

  • 调用 operator new 分配内存
  • 调用合适的构造函数初始化这块内存,如果不是自定义的类,那么初始化就是简单的赋值操作

由此可见, new expression 是对 operator new 的又一层封装。

我们当然也可以直接使用 operator new,比如只想分配内存的时候,完全可以替代 C 语言中的 malloc 操作:

1
2
3
T* newelements = static_cast<T*>(operator new(sizeof(T));
// 等同于T* pa = (T*)malloc(sizeof(T));
// static_cast表示明确的显式转换,可以告诉编译器和阅读程序的人这样的转换是故意为之

STL 中重载有两个版本的 operator new,分别为单个对象和数组对象服务,单个对象版本提供给分配单个对象的 new 表达式调用,数组版提供给分配数组的 new[] 表达式调用:

1
2
void *operator new(size_t);     // allocate an object
void *operator new[](size_t); // allocate an array

我们可以分别重载这两个版本,来自定义单个对象或对象数组的内存分配方式。当我们自己在重载 operator new 时,不一定要完全按照上面两个版本的原型重载,唯一的两个要求是:返回一个 void* 类型和第一个参数的类型必须为 size_t

同样的,operator delete 和 delete expression 的关系和上述 operator new 和 new expression 的关系完全一样,delete expression 会先调用析构函数,再调用 operator delete 释放内存。operator delete 的功能和 C 中的 free 完全一样,是对 free 的封装。

需要注意的是,在类中重载的 operator new 和 operator delete 必须是静态的,因为前者运行于对象构造之前,后者运行于对象析构之后,所以他们不能也不应该拥有一个 this 指针来存取对象成员。另外,new expression 默认调用的是单参数的 operator new,即上面声明的那种,而其它不同形式的重载,则只能显式调用了。

2.2 野指针

有下面两种关于 delete expression 的情况,第一种:

1
2
3
int* p = new int;
delete p;
delete p; // p为什么能delete两次,而程序运行的时候还不报错。

第二种情况:

1
2
3
int* p = new int ;
delete p;
*p = 5; //delete后为什么还可以对*p进行再赋值?

在回答这两个问题之前,我们先想想 delete p 这一语句意味着什么?p 指向一个地址,以该地址为起始地址保存有一个 int 变量(虽然该变量并没有进行初始化,因此默认为 0),delete p 之后 p 所指向的地址空间被释放,也就是说这个 int 变量的生命结束,但是 p 仍是一个合法的指针,它仍指向原来的地址,而且该地址仍代表着一个合法的程序空间。与 delete 之前唯一不同的是,你已经丧失了那块程序空间的所有权。但你依然可以通过指针对这块空间进行操作,因为你还保留有这块空间的“钥匙” p。

此时通过指针 p 对这块空间进行操作虽然从语法上来说是合法的,但是暗藏着很大的逻辑错误。不论是对一块已经释放的内存再度 delete,还是再度给它赋值,都暗含着很大的危险,因为当你 delete 后,就代表着将这块内存归还,而这块被归还的内存很可能已经被再度分配出去,此时不论是你再度 delete 还是重新赋值,都将破坏其它代码的数据,同时你存储在其中的数据也很容易被覆盖。至于报不报错,崩不崩溃,完全取决于编译器够不够“聪明”。

上述情况下的指针 p 被称为野指针——指向了一块“垃圾内存”,或者说指向了一块不应该读写的内存。避免野指针的一个好方法是,当一个指针变为野指针的时候,马上赋值为 NULL,其缘由在于,你可以很容易的判断一个指针是否为 NULL,却难以抉择其是否为野指针。而且,delete 一个空指针,不会做任何操作,因此总是安全的。

2.3 placement operator new 和 placement new expression

placement new expression 的功能是对已经存在的空间进行初始化,即用来在指定地址上构造对象,因此它并不分配内存,仅仅是对指定地址调用构造函数。

而 placement operator new 是对 operator new 的重载,它的功能就是返回给定的指针:

1
2
3
4
void* operator new(size_t, void *p)
{
return p;
}

因为 p 指针指向的内存已经存在,不需要再分配内存,因此只要返回这个指针即可。

placement new expression 先调用 placement operator new 返回指针 p,然后调用构造函数初始化这个指针指向的内存,这样也就完成了对已经存在的空间进行初始化的工作。

需要注意的是,通过 placement new expression 构建的一个对象,如果你使用 delete 来删除对象,那么其内存也会被回收,如果想保留内存而只析构对象,好的办法是显式调用其析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A
{
public:
A(int a) : _a(a) {}
~A(){}
private:
int _a;
};

void test()
{
A* pa = static_cast<A*>(operator new (sizeof(A));
// 使用 placement new expression 初始化内存
new(pa) A(10);
// 显式调用析构函数析构对象
pa->~A();
// 此时内存还在,手动使用delete释放内存,如果直接使用delete则其自动先调用析构,再释放内存
delete pa;
}

事实上并没有 placement new expression 这么一说,上面的说法只是为了便于理解,从头到尾都只有一个 new expression,并且 new expression 永远都只进行以下两个步骤:

  • 调用一个合适参数的 operator new 来分配内存,可以是普通的 operator new,也可以是 operator new[],还可以是 placement operator new
  • 调用一个合适参数的构造函数初始化 operator new 分配的内存

2.4 总结

operator new、new expression 和 placement operator new 三者的关系现在非常明了,总结如下:

  • operator new 相当于 malloc 分配内存,区别在于申请失败时抛出异常而不是返回空指针
  • placement operator new 将给定的指针直接返回,相当于返回一块已经存在的内存
  • new expression 首先调用合适的 operator new 分配内存(可能是新分配的,也可能是已经存在的),然后调用构造函数初始化这块内存
  • operator delete 相当于 free 释放内存
  • delete expression 首先调用析构函数,然后调用 operator delete 释放内存

2.5 内存池

关于内存池技术有必要的话可以再做深入学习,这里只做简单了解即可。

利用默认的内存管理操作符 new 和 delete 在堆上分配和释放内存会有一些额外的开销。

系统在接收到分配一定大小内存的请求时,首先查找内部维护的内存空闲块表,并且需要根据一定的算法(例如分配最先找到的不小于申请大小的内存块给请求者,或者分配最适于申请大小的内存块,或者分配最大空闲的内存块等)找到合适大小的空闲内存块。如果该空闲内存块过大,还需要切割成已分配的部分和较小的空闲块。然后系统更新内存空闲块表,完成一次内存分配。类似地,在释放内存时,系统把释放的内存块重新加入到空闲内存块表中。如果有可能的话,可以把相邻的空闲块合并成较大的空闲块。默认的内存管理函数还考虑到多线程的应用,需要在每次分配和释放内存时加锁,同样增加了开销。

可见,如果程序频繁地使用 new 和 delete 在堆上分配和释放内存,会导致性能的损失。并且会使系统中出现大量的内存碎片,降低内存的利用率。默认的分配和释放内存算法自然也考虑了性能,然而这些内存管理算法的通用版本为了应付更复杂、更广泛的情况,需要做更多的额外工作。而对于某一个具体的应用程序来说,适合自身特定的内存分配释放模式的自定义内存池可以获得更好的性能。

C++ 引入了内存池(Memory Pool)来提高内存管理和运行效率。内存池是一种高效的内存分配方式,其工作原理是先向系统一次性申请比较大的空间,当我们每次去申请空间时就直接使用内存池里的空间,而省略了申请和释放的两个动作开销,也减少了系统内存碎片,从而提高了系统效率。

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。