[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个内存操作的过程:
- 让内存管理器分配一块
free store的堆内存区域, 可能有一定的大小要求 (分配算法的实现) - 让内存管理器释放之前分配的某一个堆内存的区域 (释放过程中,可能有内存的碎片化, 需要处理碎片化问题)
- 让内存管理器进行垃圾回收算法, 计算寻找哪些内存块没有使用了, 直接回收. (不同的算法策略, 性能, 开销都不同)
实际开发过程中, 程序员并不关心以下的过程, 手动new/delete即可, 但是我们如果new/delete没有匹配, 很容易导致内存泄露, 例如如下代码:
void foo() {
bar *ptr= new Bar();
...
delete ptr;
}
ps: 一般c++开发不会写出以上代码... 这么写的基本是 原生Java开发吧.
以上这种写法, 会有个很严重的问题:
- 中间代码可能抛出异常
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++是可以用栈来管理对象的, 但是有一些场景不能或者不应该存储在栈上, 例如有以下场景:
- 对象非常大!!!!
- 对象的大小在编译时, 无法确定大小!!!
- 对象是函数的返回值, 由于特殊原因, 不应该使用对象的值返回!!!
其中第二点中, 最常见的是工厂方法, 该方法返回的是一个值类型的基类:
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:
- handle fd的释放
- stream的close
- 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讲