[Effective C++]条款49-52 内存分配
本文初发于 “天目中云的小站”,同步转载于此。
条款49 : 了解new-handler的行为
条款50 : 了解new和delete的合理替换时机
条款51 : 编写new和delete时需固守常规
条款52 :写了placement new也要写placement delete
条款49-52中详细讲述了定制new和delete的实现, 加上前面所讲述的智能指针及资源管理类, 让我们对内存管理有了比较深刻的认知. 但是这部分有些内容经查证已经相对过时, 所以我将统合这四个条款, 先对内存分配有一个初步的认识, 然后再简单讲解一下定制new和delete的编写, 之后再讨论一下各种内存分配的方式.
内存分配的整体认知
相比于java等编程语言以垃圾回收功能津津乐道, 但其实际也会带来运行效率下降的弊端, C++因此并没有纳入垃圾回收的机制, 将内存分配的任务交给了程序员自己, 所以学习内存分配是成为一个优秀C++程序员的所必须的. 为了实现更好的内存分配, 诸如智能指针, allocator等的各种资源管理类如雨后春笋般产生, 当然在此之外C++本身就可以对new进行定制, 这种做法虽然原始, 但是也有一定的用武之地.
先让我们了解一下内存分配的流程 :
- C : 内存申请(malloc) -> …(使用) -> 释放内存(free)
- C++ : 内存申请 -> 构造 -> …(使用) -> 析构 -> 释放内存
上面展示了C/C++内存分配的流程, 其实内存申请和释放在底层都是调用malloc和free, 但是在C++中对其进行了封装, 因为其OOP的特性, 在new中不仅申请了内存, 还进行了对应的构造, 简单来说就是先调用malloc申请了一块内存, 然后在这片内存上调用对应对象的构造函数, delete也是同理不再赘述.
定制new和delete
-
什么是定制new和delete?
即对new和delete运算符进行重载, 包括new和delete的使用以及运算符重载学到这里我们应当都已经非常熟悉.
-
为什么要定制new和delete?
根本原因就是标准版本的new和delete提供的服务太少, 只有申请和构造, 析构和销毁, 因此定制可以实现更多的操作.
书中这里用了一个条款来解释, 我这里简单提炼一下定制new和delete主要可以做到的提升 :
- 提前检测运用new或delete上的错误.
- 强化分配的效能, 可以引入内存池提升分配和释放的效率.
- 收集统计数据, 加入日志功能, 记录内存分配情况, 便于信息分析.
-
如何实现定制new和delete?
主要就是学会new和delete的运算符重载, 但是其中还是有一些门道, 我们不妨来回顾一下new是如何使用的 :
MyClass* myclass = new MyClass(42);
假设有一个MyClass类, 其构造需要传入一个int, 这段代码便可以实现申请内存 + 构造的全过程, 但是我们还可以通过下面的代码实现 :
void* ptr = ::operator new(sizeof(MyClass)); // ::operator new MyClass* myclass = new(ptr) MyClass(42); // placement new
这和上面代码实现的效果是一样的, 里面的两个语法::operator new(全局分配函数)和placement new(定位new), 都是C++基础中应该学到过的, 其实也就分别对应了申请内存和构造, 于是我们可以对内存分配流程做出如下的对应 :
- 内存申请(::operator new) -> 构造(placement new) -> …(使用) -> 析构(placement delete) -> 释放内存(::operator delete)
那么定制new和delete就变成了定制::operator new和placement new(及对应deltete版本)了, 定制方法也很简单 :
-
重载operator new, 不同的参数对应不用的功能, 只传入一个size对应::operator new, 不仅传入size还传入ptr对应placement new, 也许这样设定你会觉得很怪, 但是事实就是这样, 我们通过代码来认识 :
// 定制 ::operator new void* operator new(std::size_t size) { // 进行参数检测, 日志记录, 调用内存池等 // ...... return ::operator new(size); // 可以仍然使用标准的 ::operator new, 当然也可以自己malloc } // 定制 placement new 操作符 void* operator new(std::size_t size, void* ptr) noexcept { // 进行日志记录等操作 return ptr; // 不进行内存分配,只是返回已分配的内存地址 }
当然这种运算符重载如果放在类外就是作用于全局, 放在类内就是类专属, 我们应当有所认知.
申请内存失败的处理方式
我们知道malloc是可能出错的, 内存不足等问题都有可能发送, malloc出错会返回一个nullptr, 在标准库中new会抛出std::bad_alloc
这个异常, 因此我们在自己的operator new
中如果使用malloc, 当其返回一个nullptr时也应抛出一个bad_alloc
.
当然书中也提出应该有更优秀的处理方式, 不应该只是抛出异常, 在条款49中便提出标准库内置了一个错误处理函数new_handler
, 可以通过set_new_handler(handler)
这个函数来设置, 参数是一个函数指针, 也就是我们可以自己写回调函数并将其设置为new_handler
, 在发生内存失败时可以自动调用该函数. 我们可以在其中记录日志, 使用内存池机制(如果用了内存池的话), 决定是否抛出异常(如果申请失败无所谓的话可以不抛出异常).
但是真的有必要吗?
其实new_handler在实际运用中已经很少使用, 根本原因还是在现代内存大小已经不再是问题, 也就是说内存申请失败几乎不可能出现, 只在极少嵌入式设备中可能有需要了. 而且如果真的出现了申请内存失败那只能说明问题很大, 不是硬件有问题就是代码哪里出现了严重的内存泄露, 这不是写一个new_handler可以解决的了, 因此其实在现代背景下还是老实抛出异常就行了.
浅谈内存分配
关于内存分配我认为学好智能指针的使用是最关键的, 因为其可以很大程度上保证我们的代码不会内存泄漏, 只要不发生内存泄漏, 内存大小一般不会是问题.
我们对于内存分配的关注点应该放在如何避免内存泄漏和提升效率上, 内存泄漏有智能指针避免, 提速可以通过引入内存池来缓解, 内存池这里不再详述, 简单来说就是申请和释放内存其实是有一定花销的, 不断的一小块一小块地申请内存然后构造其实是效率远低于直接申请一大块内存, 然后根据需求分配这块内存的, 而内存池就是做到了后者.
至于如何引入, 可以通过上文的定制new和delete的重载函数中引入, 也可以通过自定义的allocator(分配器)来引入, allocator也是一种相对有用的内存分配技术, 如果感兴趣可以自己搜索学习.
请记住 :
- 内存分配在现代主要还是依赖智能指针, 可以有效避免内存泄露.
- 如果想对内存分配做更细致的处理, 例如提速或记录日志等, 可以尝试定制new和delete, 使用allocator, 引入内存池.
- 内存分配失败的情况在当下非常少见, 可以写简单的失败处理机制, 但没必要投入过多精力.
by 天目中云