[C++的读书笔记]C++中的栈堆和RAII

165 阅读5分钟

[C++的读书笔记]C++中的栈堆和RAII

基本内容

是 tack, 在内存管理中, 指的函数调用过程中产生的本地变量和调用参数的区域, 这个栈的栈顶在操作系统中是从高地址向低地址变化的. 一般在函数调用过程中, 一个函数调用栈, 我们称为栈帧.

是heap, 在内存管理中, 指的动态分配的内存区域, 这里在开发中需要使用heap, 需要使用new/delete或者malloc/free来手动分配或者释放.

RAII, 英文全称是Resource Acquisition is Initialization, 是C++中常用的资源管理方式!!! RAII简单来说, 使用栈 + 析构函数对多有的资源(堆内存, fileId, stream 等等)当做同一种内容进行管理!!!

C++ 并没有选用Java/Python/JS等语言使用的垃圾回收方法.

计算机程序运行时, 应用程序内存5大区相关的内容我们这里不再阐述.

堆的理解

我们在编程中使用堆内存非常自然, 例如:

// c
char *str = (char *)malloc(15);
​
// c++
auto ptr = new std::vector<int>();
​
// java
ArrayList<int> list = new ArrayList<int>();
​
// swift - 注意swift中Array等是结构体是值类型...
class Dog {}
let dog = Dog()

但是实际上不论使用上面哪种方式, 我们都绕不过以下3个内存操作的过程:

  1. 让内存管理器分配一块free store的堆内存区域, 可能有一定的大小要求 (分配算法的实现)
  2. 让内存管理器释放之前分配的某一个堆内存的区域 (释放过程中,可能有内存的碎片化, 需要处理碎片化问题)
  3. 让内存管理器进行垃圾回收算法, 计算寻找哪些内存块没有使用了, 直接回收. (不同的算法策略, 性能, 开销都不同)

实际开发过程中, 程序员并不关心以下的过程, 手动new/delete即可, 但是我们如果new/delete没有匹配, 很容易导致内存泄露, 例如如下代码:

void foo() {
  bar *ptr= new Bar();
  ...
  delete ptr;
}

ps: 一般c++开发不会写出以上代码... 这么写的基本是 原生Java开发吧.

以上这种写法, 会有个很严重的问题:

  1. 中间代码可能抛出异常throw error, 导致方法提前结束, delete ptr并不会执行.

我们常见的场景是, 对象的内存分配和内存释放不再同一个函数中:

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

这样, 内存泄露的可能性就更大了!!! 另外如果有多线程环境!!! 风险跟高

c++中利用栈进行内存管理

通常来说, 程序在被编译器编译的时, 内个函数需要的栈帧大小都确定了, 每次调用一个函数, 系统就挑战栈顶指针, 在内存栈上分配本地变量所需要的全部空间, 这种方式被称为扩栈, 在函数执行完成, 函数返回时, 只需要移动栈帧就能非常方便的清理本地变量, 这个过程叫做栈平衡. 因此栈的管理模式非常简单, 并且成本非常低.

有如下一个实例来演示使用栈进行内存管理的过程:

​
class Obj {
public:
  Obj() { puts("Obj()"); }
  ~Obj() { puts("~Obj()"); }
};
​
void foo(int n) {
  Obj obj;
  if(n == 42) {
    throw "throw an exception"
  }
}
​
int main() {
  try {
    foo(11);
    foo(22);
  }catch (const char *s) {
    puts(s);
  }
};
​
​
结果: 
  Obj()
  ~Obj()
  Obj()
  ~Obj()
  "throw an exception"

从结果上来看, 不论是否发生了异常,Obj的析构函数都会正常执行.

在C++中, 所有的变量默认都是值语义(如果使用了*或者&那么就是指针或者引用语义), 也就是说C++的对象可以分配在栈上. 其他的语言比如Java 的对象都是引用语义, 只能用堆管理对象, 普通的Integer之类是值语义, 可以存储在栈上.

其中Swift要单独拿出来说说, Swift中Struct, Enum 都是值语义的, Class 是引用语义. 标准库中的数组, String等等都是Struct 值语义的!!!

在使用C++中, 时刻要区分值语义和引用语义!!!

RAII的出现

我们前面看到, C++是可以用栈来管理对象的, 但是有一些场景不能或者不应该存储在栈上, 例如有以下场景:

  1. 对象非常大!!!!
  2. 对象的大小在编译时, 无法确定大小!!!
  3. 对象是函数的返回值, 由于特殊原因, 不应该使用对象的值返回!!!

其中第二点中, 最常见的是工厂方法, 该方法返回的是一个值类型的基类:

enum class Shape_type {
  circle,
  rectangle,
}
​
class Shape {};
class Circle: public Shape {};
class Rectangle: public Shape {};
​
Shape* create_shape(Shape_type type) {
  switch(type) {
    case Shape_type::Circle:
      return new Circle();
    case Shape_type:rectangle:
      return new Circle();
  }
}

上面方法中, create_shape方法会返回一个shape对象, 实际可能是某个 shape的子类, 这种场景不好使用值语义!!! 最好将对象内存分配在堆上, 返回对象的指针!!!

如何管理这个shape*指向的堆对象呢?? --- RAII + 析构函数!!!

简单来说将shape *返回值, 存储到一个本地值语义的对象里面, 这个对象在超出作用域时,一定会调用自己的析构函数, 在析构函数中删除该shape*对象即可!!!

class Shape_wrapper {
public:
    explicit Shape_wrapper(Shape * ptr = nullptr): ptr_(ptr) {}
    ~Shape_wrapper() {
        delete ptr_;
    }
    
    Shape* get() const {
        return ptr_;
    }
private:
    Shape* ptr_;
};
​
void foo() {
    Shape_wrapper ptr_wrappter(create_shape());
}

如果返回的shape * == nullptr, 以上代码逻辑也不会出现问题.

RAII除了管理Shape* 这中堆上对象的资源释放, 还能用于其他的资源管理, 例如官方库中的std::shared_ptr可以支持传入一个deletor:

  1. handle fd的释放
  2. stream的close
  3. lock释放等

其中锁释放的参考:

std::mutext mtx;
void some_func() {
  std::lock_guard<std::mutex> guard(mtx);
  // 同步工作!!!
  ...
} // return/exception导致的作用域退出, 就会自动解锁!

C++使用的RAII的方式, 有点像Swift中的defer{}, 它能保证部分代码超出作用域的时候, 必然会执行!!!!

new/delete 中的简单过程

// new 操作符中编译器插入的相关代码
void *temp = operator new(sizeof(Circle));
try {
    Circle *ptr = static_cast<Circle *>(temp);
    ptr->Circle();
    return ptr;
} catch { // 一般是 bad_alloc
    operator delete(ptr);
    throw;
}
​
​
// delete 操作符中编译器插入的相关代码
if (ptr != nullptr) {
    ptr->~Shape();
    operator delete(ptr);
}
​

从以上过程看出, new/delete的兼容性!!!

参考

极客时间 - 吴咏炜-现代C++实战30讲