堆、栈、RAII:C++里该如何管理资源?
基本概念
堆
英文名heap,指的是动态内存分配的区域,并不是数据结构中的堆。这里的内存创建完成后需要手动释放,否则就会造成内存泄漏。
自由存储区free store
特指使用new和delete来释放和分配的区域,一般而言,这是堆的子集,因为new和delete底层调用了malloc和free,而malloc和free操作的区域是堆区域,所以free store也是堆
栈
英文名stack,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈与数据结构中的栈高度相似,都满足后进先出的规则。
RAII
依赖栈和析构函数,对所有资源包括堆内存在内的资源进行管理。
堆
很多场合会禁用动态内存,因为存在实时性问题和分配失败问题。
堆的增长方向是向下的,是朝着内存地址增加的方向增加的。
在堆上new和delete之间发生异常,就会导致delete无法被执行,导致内存泄漏。
栈
首先看一段示例代码:
void foo(int n){
//
}
void bar(int n){
int a=n+1;
foo(a);
}
int main(){
//
bar(42)
}
分析代码执行过程的栈变化:
如图所示,栈是向上增长的,在包括x86在内的大部分计算机体系架构中,栈的增长方向是低地址,因此上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另一个函数时,会将参数也压入栈中(此处忽略使用寄存器传值的情况),然后将下一条汇编指令也压入栈中,并跳转到新的函数。新的函数进入之后,首先做一些必须的保存工作,然后会调整栈指针,分配出本地变量所需的空间,随后执行函数中的代码,并且在执行完毕以后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。
本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起,当函数执行完成以后,这些内存也就自然而然释放掉了。
- 栈上的分配极为简单,移动一下栈指针即可
- 栈的释放也极为简单,函数执行结束时移动一下栈指针即可
- 由于后进先出的执行过程,不可能出现内存碎片
图2中的每种颜色都表示某个函数占用的栈空间,叫做栈帧。
本地变量是简单类型,C++中称为POD类型(Plain Old Data)。对于有构造函数和析构函数的非POD类型,栈上的内存分配同样有效,只不过C++编译器会在合适的位置生成代码,插入对构造函数和析构函数的调用。
重要的是:编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用叫做栈展开(stack unwinding)。
代码验证一下:
#include<iostream>
using namespace std;
class Obj {
public:
Obj() { std::cout << "Obj()" << endl; }
~Obj() { std::cout << "~Obj()" << endl; }
};
void foo(int n) {
Obj obj;
if (n == 42) {
throw"life,the universe and everything";
}
}
int main() {
try {
foo(41);
foo(42);
}
catch (const char* s) {
std::cout << s << endl;
}
}
执行结果:
Obj()
~Obj()
Obj()
~Obj()
life,the universe and everything
可知,不管是否发生了异常,obj的析构函数都会执行
RAII
C++支持将对象存储在栈上,但是很多情况下不可以,比如:
- 对象很大;
- 对象的大小在编译时不确定;
- 对象是函数的返回值,但是由于特殊原因,不应使用对象的值返回
常见的情况之一,在工厂方法或其他面向对象编程的情况下,返回值类型是基类。代码示例:
enum class shape_type{
circle,
triangle,
rectangele,
};
class shape{};
class circle:public shape{...};
class triangle:public shape{...};
class rectangle:public shape{...};
shape* create_shape(shape_type type){
...
switch(type){
case shape_type::cirle:
return new circle(...);
case shape_type::triangle:
return new triangle();
case shape_type::rectangle():
return new rectangle();
}
}
这个create_shape函数会返回一个shape对象,对象的实际类型是某个shape的子类,圆、三角形、矩形等。这种情况下,函数的返回值只能是指针或者其变形体。如果返回类型是shape,实际返回的是circle,编译器不会报错,但是结果多半是错的,这种现象叫做对象切片(object slicing),是C++中特有的一种编码错误,属于对象复制相关的语义错误。
那么应该如何做,才能确保create_shape的返回值不会发生内存泄漏呢?
答案就在析构函数和他的栈展开行为上。我们只需要将这个返回值放在一个本地变量里,并确保其析构函数会删除该对象即可。
简单的实现如下:
class shape_wrapper{
public:
explicit shape_wrapper(shape* ptr=nullptr): ptr_(ptr){}
~shape_wrapper(){delete ptr_;}
shape* get()const{ return ptr_;}
private:
shapr* ptr_;
};
void foo(){
...
shape_wrapper ptr_wrapper(create_shape(...));
...
}
delete一个空指针是一个合法的空操作,new一个对象和delete一个指针时编译器都需要干不少活,大致可以翻译为如下操作
// new circle(...)
{
void* temp=operator_new(sizeof(circle));
try{
circle* ptr =static_cast<circle*>(temp);
ptr->circle(...);
return ptr;
}
catch(...){
operator_delete(ptr);
throw;
}
}
if(ptr!=nullptr){
ptr->~shape();
operator_delete(ptr);
}
new的时候先分配内存(失败时整个操作失败并向外抛出异常,通常是bad_alloc),然后在这个结果指针上构造对象;构造成功则new操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。delete时则判断指针是否为空,在指针不为空时调用析构函数并释放之前分配的内存。
回到shape_wrapper和它的析构行为。在析构函数中做些必要的清理工作,这就是RAII的基本用法。这种清理不限于释放内存,也可以是:
- 关闭文件
- 释放同步锁
- 释放其他重要的系统资源