023 C++ 控制内存分配

某些应用程序对内存分配有特殊的需求,因此我们无法将标准内存管理机制直接应用于这些程序。它们常常需要自定义内存分配细节,比如使用关键字 new 将对象放置在特定的内存空间中。为了实现这一目的,应用程序需要重载 new 运算符和 delete 运算符以控制内存分配的过程。

重载 new 和 delete

尽管我们说能够“重载 new 和 delete”,但是实际上重载这两个运算符与重载其他运算符的过程不大相同。要想真正掌握重载 newdelete 的方法,首先要对 new 表达式和 delete 表达式的工作机理有更多了解。

当我们使用一条 new 表达式时:

// new 表达式
string *sp = new string("a value"); // 分配并初始化一个 string 对象
string *arr = new string[10]; // 分配 10 个默认初始化的 string 对象

实际执行了三步操作:

  1. new 表达式调用一个名为 operator new(或者 operator new[])的标准库函数。该函数分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象的数组)。
  2. 编译器运行相应的构造函数以构造这些对象,并为其传入初始值。
  3. 对象被分配了空间并构造完成,返回一个指向该对象的指针。

当我们使用一条 delete 表达式删除一个动态分配的对象时:

delete sp;    // 销毁 *sp,然后释放 sp 指向的内存空间
delete [] arr;    // 销毁数组中的元素,然后释放对应的内存空间

实际执行了两步操作:

  1. 对 sp 所指的对象或者 arr 所指的数组中的元素执行对应的析构函数。
  2. 编译器调用名为 **operator delete **(或者 operator delete [])的标准库函数释放内存空间。

如果应用程序希望控制内存分配的过程,则它们需要定义自己的 operator new 函数和 operator delete 函数。即使在标准库中已经存在这两个函数的定义,我们仍旧可以定义自己的版本。编译器不会对这种重复的定义告警,相反,编译器将使用我们自定义的版本替换标准库定义的版本。

注意:当自定义了全局的 operator new 函数和 operator delete 函数后,我们就担负起了控制动态内存分配的职责。这两个函数必须是正确的:因为它们是程序整个处理过程中至关重要的一部分。

应用程序可以在全局作用域中定义 operator new 函数和 operator delete 函数,也可以将它们定义为成员函数。当编译器发现一条 new 表达式或 delete 表达式后,将在程序中查找可供调用的 operator 函数。如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找。此时如果该类包含有 operator new 成员和 operator delete 成员,则相应的表达式将调用这些成员。否则,编译器在全局作用域查找匹配的函数。此时如果编译器找到了用户自定义的版本,则使用该版本执行 new 表达式或 delete 表达式;如果没有找到,则使用标准库定义的版本。

我们可以使用作用域运算符令 new 表达式或 delete 表达式忽略定义在类中的函数,直接执行全局作用域中的版本。例如,::new 只在全局作用域中查找匹配的 operator new 函数,::delete 同理。

operator new 接口和 operator delete 接口

标准库定义了 operator new 函数和 operator delete 函数的 8 个重载版本。其中前 4 个版本可能抛出 bad_alloc 异常,后 4 个版本则不会抛出异常:

// 这些版本可能抛出异常
void *operator new(size_t);    // 分配一个对象
void *operator new[](size_t);    // 分配一个数组
void *operator delete(void *) noexcept;    // 释放一个对象
void *operator delete[] (void *) noexcept;    // 释放一个数组

// 这些版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept;
void *operator new[](size_t, nothrow_t&) noexcept;
void *operator delete(void *, nothrow_t&) noexcept;
void *operator delete[] (void *, nothrow_t&) noexcept;

类型 nothrow_t 是定义在 new 头文件中的一个 struct,在这个类型中不包含任何成员。new 头文件中还定义了一个名为 nothrowconst 对象,用户可以通过这个对象请求 new 的保证不抛出异常版本。

new_header_nothrow_t_struct

与析构函数类似,operator delete 也不允许抛出异常。当我们重载这些运算符时,必须使用 noexcept 异常说明符指定其不抛出异常。

应用程序可以自定义上面函数版本中的任意一个,前提是自定义的版本必须位于全局作用域或者类作用域中。当我们将上述运算符函数定义成类的成员时,它们是隐式静态的。无需显式地声明 static,当然这么做也不会引发错误。因为 operator new 用在对象构造之前而 operator delete 用在对象销毁之后,所以这两个成员(newdelete)必须是静态的,而且它们不能操纵类的任何数据成员。

对于 operator new 函数或者 operator new[] 函数来说,它的返回类型必须是 void*,第一个形参的类型必须是 size_t 且该形参不能含有默认实参。当我们为一个对象分配空间时使用 operator new;为一个数组分配空间时使用 operator new[]。当编译器调用 operator new 时,把存储指定类型对象所需的字节数传递给 size_t 形参;当调用 operator new[] 时,传入函数的则是存储数组中所有元素所需的空间。

如果我们想要自定义 operator new 函数,则可以为它提供额外的形参。此时,用到这些自定义函数的 new 表达式必须使用 new 的定位形式将实参传给新增的形参。但是需要注意,下面的这个函数不能被用户重载(这种形式只供标准库使用,不能被用户重载):

void *operator new(size_t, void*);  // 不允许重载这个版本

对于标准库的 operator delete 和 operator delete[] 来说,它们的返回值类型必须是 void,第一个形参的类型必须是 void*。执行一条 delete 表达式将调用相应的 operator 函数,并用指向待释放内存的指针来初始化 void* 形参。

当我们将 operator delete 或 operator delete[] 定义为类的成员时,该函数可以包含另外一个类型为 size_t 的形参。此时,该形参的初始值是第一个形参所指对象的字节数。size_t 形参可用于删除继承体系中的对象。如果基类有一个虚析构函数,则传递给 operator delete 的字节数将因待删除指针所指对象的动态类型不同而有所区别。而且,实际运行的 operator delete 函数版本也由对象的动态类型决定。

术语:new 表达式与 operator new 函数

标准库函数 operator new 和 operator delete 的名字容易让人误解。和其他 operator 函数不同(比如 operator =),这两个函数并没有重载 new 表达式或 delete 表达式。实际上我们根本无法自定义 new 表达式或 delete 表达式的行为。

一条 new 表达式的执行过程总是先调用 operator new 函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条 delete 表达式的执行过程总是先销毁对象,然后调用 operator delete 函数释放对象所占的空间。

我们提供新的 operator new 函数和 operator delete 函数的目的在于改变内存分配方式,但是不管怎样,我们都不能改变 new 运算符和 delete 运算符的基本含义。

malloc 函数与 free 函数

当我们定义了自己的全局 operator new 和 operator delete 后,这两个函数必须以某种方式执行分配内存与释放内存的操作。也许我们的初衷仅仅是使用一个特殊定制的内存分配器,但是这两个函数还应该同时满足某些测试的目的,即检查其分配内存的方式是否与常规方式类似。

为此,我们可以使用名为 mallocfree 的函数,C++ 从 C 语言中继承了这些函数,并将其定义在 cstdlib 头文件中。

malloc 函数接受一个表示待分配字节数的 size_t,返回指向分配空间的指针或者返回 0 表示分配失败。free 函数接受一个 void*,它是 malloc 返回的指针的副本,free 将相关内存返回给系统。调用 free(0) 没有任何意义。

下面是编写 operator new 和 operator delete 的一种简单形式,其他版本与之类似:

void *operator new(std::size_t size)
{
    if (void *mem = malloc(size))
        return mem;
    else
        throw std::bad_alloc();
}
 
void operator delete(void *mem) noexcept { free(mem); }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352