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

本文详细介绍了C++中的堆、栈内存管理和RAII(Resource Acquisition Is Initialization)原则。堆内存由new和delete管理,易引发内存泄漏,而栈内存由编译器自动分配和释放,避免了碎片问题。RAII利用析构函数确保资源在作用域结束时得到正确释放,例如在异常处理中。通过示例展示了如何使用shape_wrapper类实现智能指针,防止内存泄漏。
摘要由CSDN通过智能技术生成

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

基本概念

英文名heap,指的是动态内存分配的区域,并不是数据结构中的堆。这里的内存创建完成后需要手动释放,否则就会造成内存泄漏。

自由存储区free store

特指使用new和delete来释放和分配的区域,一般而言,这是堆的子集,因为new和delete底层调用了malloc和free,而malloc和free操作的区域是堆区域,所以free store也是堆

英文名stack,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈与数据结构中的栈高度相似,都满足后进先出的规则。

RAII

依赖栈和析构函数,对所有资源包括堆内存在内的资源进行管理。

很多场合会禁用动态内存,因为存在实时性问题和分配失败问题。

堆的增长方向是向下的,是朝着内存地址增加的方向增加的。

在堆上new和delete之间发生异常,就会导致delete无法被执行,导致内存泄漏。

首先看一段示例代码:

void foo(int n){
//
}
void bar(int n){
	int a=n+1;
	foo(a);
}
int main(){
	//
	bar(42)
}

分析代码执行过程的栈变化:

在这里插入图片描述

如图所示,栈是向上增长的,在包括x86在内的大部分计算机体系架构中,栈的增长方向是低地址,因此上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另一个函数时,会将参数也压入栈中(此处忽略使用寄存器传值的情况),然后将下一条汇编指令也压入栈中,并跳转到新的函数。新的函数进入之后,首先做一些必须的保存工作,然后会调整栈指针,分配出本地变量所需的空间,随后执行函数中的代码,并且在执行完毕以后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。

本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起,当函数执行完成以后,这些内存也就自然而然释放掉了。

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

图2中的每种颜色都表示某个函数占用的栈空间,叫做栈帧。

本地变量是简单类型,C++中称为POD类型(Plain Old Data)。对于有构造函数和析构函数的非POD类型,栈上的内存分配同样有效,只不过C++编译器会在合适的位置生成代码,插入对构造函数和析构函数的调用。

重要的是:编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用叫做栈展开(stack unwinding)。

代码验证一下:

#include<iostream>
using namespace std;
class Obj {
public:
    Obj() { std::cout << "Obj()" << endl; }
    ~Obj() { std::cout << "~Obj()" << endl; }
};
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) {
        std::cout << s << endl;
    }
}

执行结果:

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

可知,不管是否发生了异常,obj的析构函数都会执行

RAII

C++支持将对象存储在栈上,但是很多情况下不可以,比如:

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

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

enum class shape_type{
    circle,
    triangle,
    rectangele,
};
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::cirle:
                return new circle(...);
            case shape_type::triangle:
                return new triangle();
            case shape_type::rectangle():
                return new rectangle();
        }
}

这个create_shape函数会返回一个shape对象,对象的实际类型是某个shape的子类,圆、三角形、矩形等。这种情况下,函数的返回值只能是指针或者其变形体。如果返回类型是shape,实际返回的是circle,编译器不会报错,但是结果多半是错的,这种现象叫做对象切片(object slicing),是C++中特有的一种编码错误,属于对象复制相关的语义错误。

那么应该如何做,才能确保create_shape的返回值不会发生内存泄漏呢?

答案就在析构函数和他的栈展开行为上。我们只需要将这个返回值放在一个本地变量里,并确保其析构函数会删除该对象即可。

简单的实现如下:

class shape_wrapper{
public:
    explicit shape_wrapper(shape* ptr=nullptr): ptr_(ptr){}
    ~shape_wrapper(){delete ptr_;}
    shape* get()const{ return ptr_;}
private:
    shapr* ptr_;
};
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(...);
        return ptr;
    }
    catch(...){
        operator_delete(ptr);
        throw;
    }
}
if(ptr!=nullptr){
	ptr->~shape();
    operator_delete(ptr);
}

new的时候先分配内存(失败时整个操作失败并向外抛出异常,通常是bad_alloc),然后在这个结果指针上构造对象;构造成功则new操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。delete时则判断指针是否为空,在指针不为空时调用析构函数并释放之前分配的内存。

回到shape_wrapper和它的析构行为。在析构函数中做些必要的清理工作,这就是RAII的基本用法。这种清理不限于释放内存,也可以是:

  • 关闭文件
  • 释放同步锁
  • 释放其他重要的系统资源
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值