编程语言
首页 > 编程语言> > 01 | C++ 资源管理:堆、栈、RAII

01 | C++ 资源管理:堆、栈、RAII

作者:互联网

一、基本概念

堆(heap),在内存管理的语境下,指的是动态分配内存的区域,与数据结构里的堆不是一个概念。这里的内存,被分配之后需要手工释放,否则,就会造成内存泄漏。

C++ 标准里使用 new 和 delete 分配和释放内存的区域叫自由存储区(free store),这是堆的一个子集:

  • new 和 delete 操作的区域是 free store
  • malloc 和 free 操作的区域是 heap
  • new 和 delete 底层通常使用 malloc 和 free 实现,所以 free store 也是 heap

栈(stack),在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构的栈高度相似,满足后进先出。

RAII (Resource Acquisition Is Initialization, 资源获取即初始化),C++ 特有的资源管理方式,依托栈和析构函数对所有资源(包括堆)进行管理

  • RAII要求:资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。
  • RAII 机制的使用,使得 C++ 不需要类似 Java 那样的垃圾收集方法,也能有效地对内存进行管理。

二、堆

在堆上分配内存并构造对象

std::vector<int> nums;   // 栈
auto ptr = new std::vector<int>();  // 堆

在堆上分配内存,通常涉及三个可能的内存管理操作:

  1. 让内存管理器分配一个某个大小的内存块
  2. 让内存管理器释放一个之前分配的内存块
  3. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放
    C++ 通常做 1 和 2,Java 通常做 1 和 3,Python 通常做 1、2、3。
  • 分配内存要考虑当前已经有多少未分配的内存,绝大多数情况下,可用内存会比要求分配的内存大;如果内存管理器支持垃圾收集的话,分配内存的操作还可能触发垃圾收集。
  • 释放内存不只是简单地把内存标记为未使用,还需要将连续未使用的内存块整合;
  • 内存配合和释放的管理,是内存管理器的任务,只需要正确地使用 new 和 delete,对每个 new 出来的对象都用 delete 来释放。

漏掉 delete 是常见情况,这叫“内存泄漏”。看一个例子:

void foo(){
    Bar *ptr = new Bar();
    // ...
    delete ptr;
}
  1. 如果中间省略的代码抛出异常,会导致会面的 delete 得不到执行;
  2. 不符合 C++ 惯用法,这种情况 C++ 中应该使用栈内存分配,即Bar ptr;

更常见、更合理情况,是分配和释放不在一个函数里:

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

void foo()
{
    …
    bar* ptr = make_bar(…)
    …
    delete ptr;
}

但同样存在漏 delete 的可能性。

三、栈

本地变量所需的内存会在栈上,跟函数执行所需的其他数据在一起。当函数执行完后,这部分内存会释放掉。

  1. 栈上的分配极为简单,移动一下栈指针;
  2. 栈上的释放也简单,函数执行结束时移动一下栈指针;
  3. 由于后进先出的执行过程,不可能出现内存碎片。

POD类型是C++中常见的概念,用来说明类/结构体的属性,是指没有使用面向对象的思想来设计的类/结构体,其目的是为了解决C++与C之间数据类型的兼容性问题,具体可看这篇文章 C++之POD数据类型

对于有构造和析构函数的非 POD 类型,栈上的内存分配同样有效,编译器会在生成代码的合适位置,插入对构造函数和析构函数的调用。

编译器会自动调用析构函数,包括函数执行发生异常的情况。发生异常时对析构函数的调用叫栈展开。下面演示栈展开:

class Obj {
public:
    Obj() { puts("Obj()"); }
    ~Obj() { puts("~Obj()"); }
};

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) {
        puts(s);
  }
}

执行结果:

Obj()
~Obj()
Obj()
~Obj()
life, the universe and everything

不管是否发生异常,析构函数都会先得到执行。

在 C++ 里,所有变量的缺省都是值语义,如果不使用 * 和 & 的话,变量不会像 java/python 一样引用一个堆上的对象。

四、RAII

RAII(Resource Acquisition Is Initialization, 资源获取即初始化),这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入。

也就是说,RAII 机制会对资源申请、释放的操作成对封装,利用的是栈内存自动销毁对象的特性。

C++不应该存储在栈上的情形:

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

先看一个工厂模式(返回值类型是基类的指针或引用)下内存泄露的例子:

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(…);
    // …
    }
}

这种情况函数的返回值只能是指针或引用,如果返回值(shape),实际返回 circle,不会报错,但会出现对象切片,指的是将派生类对象给基类时,丢失了一部分信息。

通过析构函数和它的栈展开,可以确保在使用create_shape的返回值时不会发生内存泄漏。

class shape_wrapper {
public:
    // 声明为 explicit 的构造函数不能在隐式转换中使用
    explicit shape_wrapper(shape* ptr = nullptr): ptr_(ptr){}
    ~shape_wrapper()
    {
        delete ptr_;
    }
    shape* get() const { return ptr_; }
private:
    shape* ptr_;
};

// 调用 foo 函数时会新建一个 shape_wrapper,函数结束时编译器会自动调用析构函数释放内存
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(…); // 不是合法的 C++ 代码
        return ptr;
    }
    catch (...) {
        operator delete(ptr);
        throw;
    }
}
# delete
if (ptr != nullptr) {
    ptr->~shape(); // 不是合法的 C++ 代码
    operator delete(ptr);
}

new 时先分配内存,然后在这个结果指针上构造对象;构造成功则 new 操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。

析构函数里做必要的清理工作,这就是 RAII 的基本用法。这种清理并不限于释放内存,也可以是:

例如,我们应该使用:

std::mutex mtx;

void some_func()
{
    std::lock_guard<std::mutex> guard(mtx);
    // 做需要同步的工作
}

而不是:

std::mutex mtx;

void some_func()
{
    mtx.lock();
    // 做需要同步的工作……
    // 如果发生异常或提前返回,
    // 下面这句不会自动执行。
    mtx.unlock();
}

另一个例子:

class Test
{
public:
    Test(int i)
    {
        this->i = i;
        cout << "constructor~" << i << endl;
    }

    ~Test()
    {
        cout << "deconstructor~" << i << endl;
    }

private:
    int i;
};

int main()
{
    Test *test = new Test(1);
    Test test2(2);
}

constructor~1
constructor~2
deconstructor~2

可以看到,对于堆上分配内存新建的对象 test,没有手动 delete 的情况下不会调用其析构函数。

class Test
{
public:
    Test(int i)
    {
        this->i = i;
        cout << "constructor~" << i << endl;
    }

    ~Test()
    {
        cout << "deconstructor~" << i << endl;
    }

private:
    int i;
};

class wrap
{
public:
    wrap(Test *test) : test(test) {}
    ~wrap()
    {
        delete test;
    }

private:
    Test *test;
};

int main()
{
    Test *test = new Test(1);
    Test test2(2);
    wrap test3(test);
}

constructor~1
constructor~2
deconstructor~1
deconstructor~2

参考资料

标签:01,RAII,C++,shape,内存,new,ptr,delete
来源: https://www.cnblogs.com/cscshi/p/15476647.html