浅谈重载new操作符

new是C++里非常重要的一个关键词,用于申请内存、初始化对象。俗话说“有借有还再借不难”,通过new向操作系统“借”到的内存用完后必然要“还”回去,所以对应地还有一个delete操作符与new共同管理内存,delete的作用是析构对象、释放内存。

new有什么作用?

  • 申请内存
  • 初始化对象

说到内存管理,有些同学会想到C标准库函数malloc()free()。C++是C语言的延续,那么C++一定可以丝滑地使用这两个标准库函数管理内存,那为什么还要提供关键词newdelete呢?

我们申请到内存后一般都会先做初始化,关键词new相当于用一条语句做两件事——申请内存并初始化。考虑类Test

struct Test {
    ...
};

使用new生成一个Test的对象

Test* test = new Test(...);

相当于malloc()申请内存后再进行初始化

// void initTest(Test* test);

void* testBuff = malloc(sizeof(Test));
initTest((Test*)testBuff);

同理,delete也是一条语句做了两件事——先析构对象释放相关资源,然后再释放内存。

new和malloc有什么区别?

malloc()free()是函数,只用于管理内存。

newdelete是操作符,newdelete用于管理内存,还有其他职责——new初始化对象,delete析构对象(清理资源)。

函数和操作符的使用方式存在很大差异。另外,编译后函数调用对应一条函数调用指令,操作符对应一条或多条其他汇编指令。

new有哪些用法?

  • plain new
  • operator new
  • placement new

plain newnew的常见用法,用于生成对象。会申请内存并调用构造函数

Test* test = new Test(...);

operator new用于申请内存,作用与malloc()一样,实际上是函数调用。只会申请内存,不会调用构造函数

void* buff = operator new(sizeof(Test));

placement new用于调用构造函数初始化指定内存。只会调用构造函数,不会申请内存

// void* testBuff;

Test* test = new (testBuff)Test(...);

实际上只要观察Test* test = new Test(...);对应的汇编结果就能发现plain new用法包含了两步——申请内存、调用构造函数

...
mov     edi, 4
call    operator new(unsigned long)
mov     rbx, rax
mov     rdi, rbx
call    Test::Test() [complete object constructor]
mov     QWORD PTR [rbp-24], rbx
...

第3行指令表示调用函数operator new(unsigned long)申请内存;第6行指令表示调用Test的构造函数初始化内存。所以

Test* test = new Test();

相当于

void* buff = operator new(sizeof(Test));
Test* test = new ((Test*)buff)Test;

如何重载new操作符?

重载new操作符实际上是重写函数operator new(...),对应的是申请内存的步骤,所以重载new操作符后只会影响plain new的内存分配,不影响调用构造函数。

重载new操作符的形式有两种——全局重载、局部重载。

全局重载就是直接重写全局函数operator new(...)

void* operator new(size_t size) {
    ...
}

从前面Test* test = new Test(...);对应的汇编结果就能看出operator new()是一个全局函数(汇编后函数符号没有任何改变,也没有加任何作用阈),全局重写new操作符后,所有类型的new操作都会调用自定义的operator new()申请内存。

局部重载就是在类中重写静态函数operator new(...)(即便不以static修饰,编译器也会自动转为静态函数)

struct Test {
    void* operator new(size_t size) throw() {
        ...
    }
};

局部重载new操作符后,Test* test = new Test(...);对应的汇编代码为

...
mov     edi, 4
call    Test::operator new(unsigned long)
mov     rbx, rax
mov     rdi, rbx
call    Test::Test() [complete object constructor]
mov     QWORD PTR [rbp-24], rbx
...

可以看到第3行的函数调用变为Test::operator new(unsigned long),增加了作用域Test,意味着自定义的operator new()只会对Test有效。

operator new和throw?

我们知道Test* test = new Test(...);实际上是两步——申请内存、调用构造函数,现在有一个问题,如果内存申请失败返回空指针,构造函数初始化成员函数时岂不是会出现访问空指针的问题?之前在项目上遇到过类似的问题,重载new操作符后构造函数报了空指针访问异常,代码如下

class Test {
private:
    int value;

public:
    Test() {
        printf("[Test] Constructor\n");
        value = 1;
    }

    void* operator new(size_t size) {
        printf("[Test] operator new\n");
        return NULL;    // 内存申请失败,返回空指针
    }
};

客户代码

int main() {
    printf("-------- start --------\n");
    Test* point = new Test();
    printf("[main] point is %p\n", point);
    printf("-------- done --------\n");
    return 0;
}

运行结果出现内存访问错误,错误的位置发生在Test的构造函数中

-------- start --------
[Test] operator new
[Test] Constructor
Segmentation fault: 11

new操作符申请内存失败后,在构造函数中出现了访问空指针的错误。这个问题看起来非常棘手,new操作符会先申请内存然后调用构造函数,这个流程是编译器决定的不能修改,那么要解决这个问题似乎只有两个思路——在使用new之前手动判断内存是否会申请成功;在构造函数中判断this指针是否为空。

用这两种方式解决都存在隐患——因为需要改动很多代码,而人总是会出错的。事实上,一个throw()声明(或者宏_NOEXCEPT)就可以解决这个问题。

关键字thow的是用来声明异常的,说明函数会抛出哪些类型的异常,显而易见throw()说明函数不会抛出任何类型的异常,Test类只要做以下改动即可

class Test {
...
    void* operator new(size_t size) throw() {   // 只比原来的代码多了 throw()
        ...
    }
...
};

运行结果

-------- start --------
[Test] operator new
[main] point is 0x0
-------- done --------

new操作符申请内存失败后未调用构造函数,不会出现访问空指针的问题。

为了一探究竟,我们比较一下加了thow()声明和没有throw()声明的汇编结果(右侧是无throw()的,左侧是有throw()的)

浅谈重载new操作符_第1张图片

总的来说,加了throw()声明后会多两条汇编指令,用于在operator new的返回结果是空指针时不调用Test的构造函数。

在线工具

最后,分享一个在线汇编神器: https://godbolt.org/

你可能感兴趣的:(C++,c++)