Qiming の 小屋

Qiming の 小屋

01 | 堆、栈、RAII:C++里该如何管理资源?

C++
9
2024-10-16

基本概念

堆,英文是(heap),在内存管理的语境下,指的是动态分配内存的区域。

这个堆和数据结构的堆不是一回事。

这里的内存,被分配之后,需要手动释放,否则就会造成内存泄漏。

在C++中,标准里面有一个概念叫自由存储区,英文是 free store,特制使用 new 和 delete 关键字来分配和释放内存的区域。一般而言,这是堆的子集。

  • new 和 delete 操作的区域是 free store
  • malloc 和 free 操作的区域是 heap

但是 new 和 delete 通常底层使用 malloc 和 free 来实现。因此 free store 也是 heap。

auto ptr = new std::vector<int>();

从历史的角度,动态内存分配出现较晚,因为会带来不确定性(内存分配需要多久?是否分配成功?等),至今有很多场合会禁止使用动态内存,尤其是在实时性要求特别高的场合,例如飞行控制器和电信设备。

在堆上分配内存,程序通常有如下操作:

  1. 让内存管理器分配一个某个大小的内存块
  2. 让内存管理器释放一个之前分配的内存块
  3. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放

C++ 通常做操作1、2,Java是1和3,python则是1、2、3均进行。

上述3个操作都不简单,而且彼此相关。

分配内存要考虑当前有多少未分配的内存,内存不足的时候要从操作系统申请新的内存,充足时要从可用的内存中取出一块大小合适的内存,将其标记为已用,返回给要求内存的代码。

绝大部分时候可用内存都会比要求分配的内存大,所以代码只被允许使用其被分配的内存区域,而剩余的内存属于未被分配的状态,可以在后面的分配中使用。如果内存管理器支持垃圾收集,则分配内存的操作可能触发垃圾收集。

释放内存并非简单将内存标记为使用,对于连续未使用的内存块,通常内存管理器要将其合并成一块,以便满足后续较大内存的分配需求。

内存分配和释放的管理,是内存管理器的任务,一般不需要我们介入。我们只需要使用好 new 和 delete 就行。

但事实证明,漏掉 delete 非常常见。这会导致“内存泄漏”。

void foo() {
	bar* ptr = new bar();
	/* 业务逻辑的代码,此处省略 */
	delete ptr;
}

上述的代码看着没什么问题,但是有隐藏的坑:

  1. 中间的业务逻辑代码可能抛出异常,导致delete未被执行。
  2. **这个代码不符合C++的惯用法。**在c++中,这种情况有很大可能性不需要使用堆内存分配,而应该使用栈内存分配。

更常见的情况如下:

bar* make_bar(...)
{
	try {
		bar* ptr = new bar();
		
		...
		
	}
	catch (...) {
		delete ptr;
		throw;
	}
	return ptr;
}

void foo()
{
	bar * ptr = make_bar(...);
	
	...
	
	return ptr;
}

在大多数计算机体系结构中,栈的增长方向是低地址。

当函数调用另一个函数时,会把参数压入栈中(忽略寄存器传参),然后把下一行汇编指令的地址压入栈(方便函数返回),并跳转新的函数。新的函数进入后,首先做一些必须的准备工作,然后会调整栈指针,分配出本地变量所需的空间,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回调用者未执行的代码中继续执行。

也就是说,本地变量存储在栈上,和函数执行所需的其他数据一起。当函数执行完成后,这些内存也会随之释放。

即:

  • 栈上的内存分配极为简单,仅仅移动一下指针而已。
  • 释放也极为简单,函数执行结束后移动栈指针即可。
  • 由于后进先出的执行过程,不可能出现内存碎片。

在栈中分配简单类型(int、char、double……)很常见,C++称为 POD 类型(Plain Old Data)。对于有构造函数和析构函数的非POD类型,栈上的内存分配也同样有效,只不过编译器会在合适位置,插入构造函数和析构函数的调用。

#include <iostream>  
  
class Obj {  
public:  
    Obj() { std::cout << "Obj constructor" << std::endl; }  
    ~Obj() { std::cout << "Obj destructor" << std::endl; }  
};  
  
void func(int val) {  
    Obj obj;  
    if (val == 42)   throw "life, the universe and everything";  
}  
  
int main()  
{  
    try {  
        func(41);  
        func(42);  
    } catch (const char* msg) {  
        std::cout << msg << std::endl;  
    }  
}

代码中,不管 func 函数是否发生了异常,obj 的析构函数都会执行。

pic1

在C++中,所有的变量缺省都是值语义。如果不使用 *& 的话,变量不会向 Java 或 Python 一样引用一个堆上的对象。

RAII

很多时候,变量不能,或者不应该,存储在栈上。例如:

  • 对象很大
  • 对象的大小在编译时不能确定
  • 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回

常见的情况之一是,在工厂方法或其他面向对象编程的情况下,返回值类型是基类。

// 简单的工厂方法
enum class shape_type {  
    circle,  
    triangle,  
    rectangle,  
};  
  
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::circle:  
            return new circle();  
        case shape_type::triangle:  
            return new triangle();  
        case shape_type::rectangle:  
            return new rectangle();  
    }  
}

如何确保在使用 create_shape 函数的返回值时不会发生内存泄漏?

只需要把这个返回值放到一个本地变量里,并确保析构函数会删除该对象即可。

enum class shape_type {  
    circle,  
    triangle,  
    rectangle,  
};  
  
class shape {};  
class circle : public shape {};  
class triangle : public shape {};  
class rectangle : public shape {};  
  
class shape_wrapper {  
public:  
    explicit shape_wrapper(  
        shape* ptr = nullptr)  
            : ptr_(ptr) {}  
    ~shape_wrapper() {  
        delete ptr_;  
    }  
    shape* ptr() const { return ptr_; }  
private:  
    shape* ptr_;  
};  
  
shape* create_shape(shape_type type) {  
    switch (type) {  
        case shape_type::circle:  
            return new circle();  
        case shape_type::triangle:  
            return new triangle();  
        case shape_type::rectangle:  
            return new rectangle();  
    }  
}  
  
void func() {  
	...
    shape_wrapper wrapper(create_shape(shape_type::circle));  
    ...
}

说明一点:delete一个空指针是合法操作。