3

RAII:如何编写没有内存泄漏的代码 with C++

 3 years ago
source link: https://zhuanlan.zhihu.com/p/264855981
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

RAII:如何编写没有内存泄漏的代码 with C++

一个人NB的不是标签

RAII是C++内功的基石

在有垃圾回收(GC)的编程语言里面,比如Java, Python, Node, Go,不需要程序员随时注意内存是否泄漏了,因为它们自带垃圾回收(负责帮你收拾“残羹剩饭”)(有GC的语言也是存在内存泄漏的)。而C++则需要程序员细心认真的处理内存,避免内存泄漏。没有内存泄漏意味着任何申请的内存都要被释放。但是实际操作层面,如何实现呢?C++的解决方案是RAII和构建在其上面的引用计数。没有RAII,一切都是空中楼阁。Java 7学了五分RAII,引入了ARM(Automatic Resource Management,也就是try-with-resource);Rust是完完全全学走了;Go有defer,神似RAII。

为什么RAII是基石?

程序员每次申请的内存(通过new, malloc,等等)都需要释放。不释放就会造成内存泄漏,而内存泄漏就会出现没有内存可用的情况,触发系统的OOM,程序从而被KILL掉。

在远古时代,没有内存泄漏靠的是程序员编写代码的认真细心,自己在申请内存,记得在必要的时候delete掉,比如

// 代码1
void f() {
   int*p = new int{3};
   int error = doSomething(p);
   if (error)
       return;
   
   finalize(p);
   delete p;
}

代码1一开始申请了内存,存储int,在最后函数结束的时候,释放内存通过delete p。

看起来这么做,似乎很容易嘛。自己使用记得删除不就行了嘛~可实际上,这么做问题很大!比如上面的代码就有可能内存泄漏。因为假设doSomething 返回了错误,函数就会提前结束,而delete p就不会执行导致内存泄漏了!有人可能会说,你代码写得渣怪谁!那好吧,我们把错误处理加上,代码如下

// 代码2
void f() {
   int*p = new int{3};
   int error = doSomething(p);
   if (error) {
       delete p; //释放内存,当出现错误的时候
       return;
    }
   
   finalize(p);
   delete p;
}

是不是添加了上面的错误处理,就不会有内存泄漏了?不!上面的代码可能还存在内存泄漏。如果doSomething抛出异常,那么两个delete p都不会被执行,内存泄漏!

有人可能会说,事儿怎么这么多,直接加上try catch,在catch的时候释放内存不就OK了嘛?实际上并不OK,因为加上try catch犯的是更严重的错误。而且就算加了,代码还是可能会内存泄漏。因为哪天有程序员(可能是自己)增加新的代码的时候,就可能就忘记delete了。

所以这种靠程序员细致认真的方法,是不靠谱的!

(Java之父给的答案是,老子不用C++了)

因为依赖程序员自己记得释放内存,有下面的问题:

  1. 代码结束分支太多,遗漏其中一个就会造成泄漏,比如代码1就遗漏的错误分支,异常分支。
  2. 代码会经常修改维护,中间某一次修改可能会增加新的分支。特别是在函数又长又臭的时候。(那不写得又长又臭不就可以了嘛?假设你写的代码很美,但是你还是要维护别人的又臭又长的代码)。
  3. 如果多次释放相同的内存(delete 同一个指针多次)你将会面对更严重的问题。内存泄漏直接的最坏结果是没有内存可用,程序被杀掉。但是现在你面对的是double-free,行为是undefined,C++里面最难搞的之一,意味着你的程序员可能莫名奇妙地crash,而且随机。想想你熬多少个夜晚,而且还不一定能找到bug。(如何系统地分析crash,减少熬的夜晚呢?日后讲解我的方法)。
  4. 程序员水平深浅不一样,C++里面坑很多,有时候程序员时刻提醒自己,但是他也不知道要不要释放啊,什么时候要释放啊。比如调用一个函数 int *p = getArray()。getArrray不是他写的,那么他怎么知道要释放。编译器又不会告诉他。(现在编译器自带动态分析告诉我们程序有没有内存泄漏)

对这四个问题,C++提供的解决方案是RAII,以及构建在RAII上面的reference count(引用计数,也就是智能指针)。

RAII 全称就Resource acquisition is initialization. 意为资源获取要通过构造函数初始化,然后析构函数负责释放资源。大部分时候又被用于做Scope Guard,Scope Guard同lambda服用,效果更佳,见下文。

RAII,C++之父当年取得比较"随意“的名字。表达的意思就是我们获取资源的时候要通过构造函数初始化。构造函数就不细说(要说起来内容也很多),它就是C++对象的初始化函数。

为什么推荐RAII来管理资源(内存是资源的一种)?首先我们要明白堆对象和栈对象的区别。堆对象就是我们通过new, malloc 动态获取的内存,栈对象就是在存在栈上面的,栈对象在失效的时候会自动调用析构函数。为什么呢?因为编译器是知道栈对象什么时候会失效。比如代码1的error栈对象,编译器知道它的作用域,只要离开了它的作用域就会失效,栈对象自然要析构。

这样子,我们就解决了问题1,因为栈对象会自动通用析构函数,那么我们根本不用关心函数具体从哪个分支结束。我们要的是”我不管你怎么退出,你都要释放内存“。所以我们可以创建一个wrapper栈对象,代码里面使用wrapper栈对象,比如将代码1改成如下

// 代码3
class MyInt {
public:
  int* p;
  MyInt(int i) {
   p = new int{i};
  }
  ~MyInt() {
    delete p;
  }
};
void f() {
   MyInt my(3);
   int error = doSomething(my.p);
   if (error) {
       return;
    }  
   finalize(p);
}
  

代码3只需要在MyInt的析构函数delete p,而函数f里面完全不用care哪里需要delete p。而这么写,不管函数如何退出,我们都会保证p指向的内存被释放,因为my是栈对象,编译器会帮我们在各个分支退出的时候插入析构函数。(而这么做没有overhead,不会产生性能影响。实际上C++标准库提供了unique_ptr供我们使用,不用自己编写MyInt)。

问题1解决了,而问题2和3也同时解决了。因为当保证函数退出的时候内存被释放且只有一次,问题2和3也就解决了。

对于问题4,构建在RAII上面的引用计数就是用来解决这个的。这个问题需要另外的篇幅来阐述,日后再发文阐述。

这里,我们也可以使用基于RAII的Scope Guard来帮助我们释放内存,代码如下

// 代码4
class ScopeGuard
{
    std::function<void()> mFunc;

public:
    ScopeGuard(std::function<void()> f)
    {
        mFunc = f;
    }
    ~ScopeGuard()
    {
        mFunc();
    }
};
int main()
{
    int *p = new int{4};
    ScopeGuard s([&p]() {
        if (p) {
            delete p;
        };
        std::cout << "delete point\n";
    });
    std::cout << "end\n";
}
void f() {
   int* p = new int{3};
   ScopeGuard s([&p]() {
        if (p) {
            delete p;
        };
        std::cout << "delete point\n";
    });
   int error = doSomething(my.p);
   if (error) {
       return;
    }  
   finalize(p);
   std::cout<<"Function ends!\n";
}

在这里ScopeGuard就帮我们保证函数结束时,不论怎么退出,都会delete当前的指针。(go里面的defer的用法就差不多是ScopeGuard). ScopeGuard一般用于关闭资源,内存一般用wrapper的方式,比如unique_ptr, shared_ptr。

RAII替我们实现了”某个操作在任何分支结束的时候,会被执行,且被执行一次“。而利用这个保证,我们可以不再那么麻烦和痛苦的叮嘱自己一定要记得释放内存,一定不要释放两次及以上。而任何需要这个效果的都可以而且也推荐使用RAII来实现,不要依赖程序员的自觉和认真!

References:

https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK