ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

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

2022-05-14 16:01:34  阅读:154  来源: 互联网

标签:01 RAII C++ shape 内存 new ptr delete


一、基本概念

堆(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 的基本用法。这种清理并不限于释放内存,也可以是:

  • 关闭文件(fstream 的析构就会这么做)
  • 释放同步锁
  • 释放其他重要的系统资源

例如,我们应该使用:

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

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有