C/C++ 在内存使用上的方式方法

概述

C++ 因为要程序员自己控制内存的分配和释放,在写程序的时候上会有很多和其他有 GC 的语言有很多的不同。刚刚接触 C++ 的工程师可能很不习惯。这篇文章就来总结和归纳一下 C++ 在使用过程中的一些技巧和常用方法。

  • 在设计代码时候,就要连同逻辑一起,将内存的释放和分配设计好。而不是发生问题之后再去 Debug。
  • RAII 原则,也就是“资源获取就是初始化”,是 C++ 的一种管理资源、避免泄漏的惯用法。其实本文介绍的就是这种思想的指导下的方法,包括现在流行的智能指针,也是这种思想的具体实现。
  • 搞清楚对象的所有权,一个对象属于谁,就要由谁去负责管理。
  • 时刻遵循谁分配谁释放,谁污染谁治理,谁渣男谁接盘的准则。不仅代码如此,生活也要如此哦。这其实也是 RAII 的原则。
  • 不要瞎 new / malloc。坚决控制 new 的次数。
  • 在性能不吃紧的情况下,宁可将内存拷贝一份,也不要随意传递指针,这是是很多 Java 程序员初写 C++ 的时候常犯的错误。
  • 一个正常的 C++ 模块应该是怎样的

    我们用一个常见的矩阵运算类来尝试说明一下,一个设计得比较干净的模块应该是怎样管理内存的。

    矩阵,是我们常用到的一个数学工具,尤其在写一些 3d 项目的时候,一个 4x4 的矩阵可以让我们很方便得描述一些三维变换。事实上一个 4x4 的矩阵是由 16 个浮点数表示的,我们就写一个 Mat4x4,来看看c++应该如何管理内存。

    class Mat4x4
    public:
      float * mat = NULL;
      int matLen = 0;
    public:
      Mat4x4();
      ~Mat4x4();
    // 实现
    Mat4x4::Mat4x4()
      // 此处为了演示,使用 malloc ,其实完全可以分配到栈内存上
      matLen = 16 * sizeof(float);
      mat = (float *)malloc(matLen);
      memset(mat, 0, matLen);
    Mat4x4::~Mat4x4()
      if(mat != NULL){
        free(mat);
        mat = NULL;
      matLen = 0;
    

    由上述代码可以知道,我们在类的构造方法里malloc出来一个长度为16*4长度的内存空间,用来存放矩阵中用到的16个浮点数。而在析构函数中,我们将这段内存释放掉。这就符合我们的准则,谁分配谁释放。

    我们来看一下我们应该如何使用这个类。通常我们有两种实例化这个类的方式

    Mat4x4 a;
    Mat4x4 * pA = new Mat4x4();
    // 用完记得 delete
    delete pA;
    

    但是,我们在使用过程中,尤其是局部使用这个变量的时候,应该尽量避免使用new的方式。首先,使用 new 的方式,会带来额外的性能开销,最重要的是,使用 new 的方式,你需要额外考虑何时将这个类释放,一个两个还好,如果有多个的话,会让你的代码看起来非常臃肿奇怪,而且如果有忘记释放的,就会造成内存泄露。很多从前写 Java 的童鞋在初写 C++ 的时候,本着万物皆可 new 的原则,往往在很多不需要 new 的地方去 new ,这让其代码看起来非常奇怪且容易出问题。

    赋值与拷贝

    试想另外一种场景。

    Mat4x4 a;
    // 给a赋值,此处省略一万字 
    Mat4x4 b;
    // 此处的 b ,我们想让 b 的内部的 16 个浮点数和 a 里的完全一样,应该怎么做
    

    我们声明了一个 Mat4x4 a,并给它内部的 16 个浮点数赋值。接下来我们想要一个 Mat4x4 b,与 a 内部的值完全相同,我们应该怎么做。正常来说,我们要重载 Mat4x4 的赋值运算符。

    class Mat4x4
    public:
      float * mat = NULL;
      int matLen = 0;
    public:
      Mat4x4();
      ~Mat4x4();
      Mat4x4 & operator = (Mat4x4 & _mat);
    // 实现
    Mat4x4::Mat4x4()
      // 此处为了演示,使用 malloc ,其实完全可以分配到栈内存上
      matLen = 16 * sizeof(float);
      mat = (float *)malloc(matLen);
      memset(mat, 0, matLen);
    Mat4x4::~Mat4x4()
      if(mat != NULL){
        free(mat);
        mat = NULL;
      matLen = 0;
    // 重载等号运算符,将内存拷贝过来
    Mat4x4 & Mat4x4::operator = (Mat4x4 & _mat)
      memcpy(mat, _mat.mat, matLen);
      return *this;
    

    复写等号运算符之后,我们就可以直接使用赋值运算符了。

    Mat4x4 a;
    Mat4x4 b;
    b = a;
    

    注意,这里的 a 和 b 其实是两块完全不同的内存,我们通过重载其赋值运算符,将 a 的内容拷贝给了 b。

    我们可以比较一下上面这种写法和下面这种写法的区别

    Mat4x4 * a = new Mat4x4();
    Mat4x4 * b = new Mat4x4();
    b = a;
    

    可见,第一种写法调用了重载的赋值运算符,第二种写法,其实是根本没有调用 Mat4x4 赋值函数,调用的其实是 Mat4x4 * (这里是指针)的赋值,这种情况下,b 其实是指向了 a。而不是把 a 的内容复制一份。这种情况下,a 和 b 其实指向的是同一片内存,修改 a 的内容其实就是在修改 b 的内容,而 b 原先的内存,就成了永远无法被修改和释放的内存垃圾。这种情况是非常危险的,也是非常容易出问题的一种写法,除非你很清楚自己在干什么,否则要坚决避免这种写法。

    其实在内存管理上,多个指针指向同一片内存就是一种非常危险的行为。在某些性能相关的场景下,我们有时不得不这样做,这是没有办法的事情。但是在性能不敏感的地方,坚决不要发生这样的事情。

    还是以我们的 Mat4x4 为例子,假设一个场景,我们有一个函数,需要一个 Mat4x4 的变量作为参数,我们应该怎么做。正常来说,我们会传一个引用进去。

    int SetMat(Mat4x4 & mat);
    

    如上,在函数内部,我们可以按照一般对象的方式来使用这个形参。但是注意,如果你使用引用传参,那么如果你在函数内部修改他的值的话,是会连同函数外部的变量一起修改的,因为引用其实就是外部的变量(其实就是指针,引用其实就是指针的语法糖)。

    那么有办法不修改吗?

    int SetMat(Mat4x4 mat);
    

    这样写就可以了,但是和传一个引用有什么区别呢?实际上,第二种方法在传值的时候是会调用 Mat4x4 的拷贝构造方法的,也就说,第二种方法实际上是将 mat 复制了一份传给了函数,也就是说,这中间会发生一次拷贝,而引用就不会。

    从函数中返回一个值

    我们期望一个函数返回一个对象的时候,我们也许会这样做。

    Mat4x4 GetMat()
      Mat4x4 mat;
      return mat;
    

    这样做是没有问题的,我们在一些场景下也会使用,但是其在返回的时候,事实上会调用 Mat4x4 的拷贝构造方法,也就是说这里的mat也会被复制一次。

    那么有没有办法不进行复制呢?有人想到了引用和指针。

    Mat4x4 & GetMat()
      Mat4x4 mat;
      return mat;
    Mat4x4 * GetMat()
      Mat4x4 mat;
      return &mat;
    

    但是这种方法是错误的,由于函数内的mat对象是在栈上了,这个函数结束后就会被自动释放。返回后拿到的引用或者指针,指向的内存实际上已经被释放,再次访问一定会出问题。

    有人说既然栈上不行,那么分配到堆上是不是就可以了。

    Mat4x4 * GetMat()
      Mat4x4 * mat = new Mat4x4();
      return mat;
    

    确实,这样做是完全可以的,但是又涉及到一个设计的问题。我们设计程序的时候,往往遵循谁分配,谁释放的原则,如果写成这样,我们就等于是在函数内分配,函数外释放,显然会对我们的调用者产生如何管理这片内存的疑惑。所以,我们往往这样设计。

    int ChangeMat(Mat4x4 & mat)
      // ......
      return 0;
    

    我们可以看到,内存由外部分配,通过引用和指针,将其传到函数中,函数负责填充这片由外部分配的内存。而其返回值往往是一个整型,用来表示函数的执行结果,通常返回0表示执行成功,返回负数代表错误。

    如果我们非要函数内为我们分配内存呢,也是可以的,我们可以这样设计

    Mat4x4 * CreateMat(Mat4x4 * mat)
      if(mat == NULL){
        mat = new Mat4x4();
      // 操作 mat
      return mat;
    // 调用
    Mat4x4 * mat = NULL; // 由函数内部分配
    mat = CreateMat(mat);
    delete mat;
    Mat4x4 * mat = new Mat4x4(); // 由调用者分配
    mat = CreateMat(mat);
    delete mat;
    

    这样设计的好处是,我们可以将是否由函数内部分配内存的决定权交给函数调用者,如果函数调用者传入的是一个 NULL,那么内存就由函数内部分配。这样写增加了灵活性。

    函数返回二进制数据

    在一些场景中我们有时会想让一个函数为我们返回一些数据量比较大的二进制数据,例如,我们通过一个函数去获取摄像头的帧数据,这种情况下,外部调用者其实并不知道已经将从函数中获取到内容的大小,不知道大小自然也就无法分配内存,这种时候,我们往往这样设计。

    int GetFrame(unsigned char * data)
      // 实现
    // 调用
    int frameLen = 0;
    frameLen = GetFrame(NULL);
    if(frameLen > 0){
      unsigned char * frame = (unsigned char *)malloc(frameLen);
      int ret = GetFrame(frame);
      free(frame);
    

    函数可以被多次调用,根据参数的不同,函数做的事情其实也不相同。当参数为NULL的时候,实际上是外部调用者在询问函数,此时有没有数据可以被获取。当有数据的时候,函数会返回可以被拷贝出去的数据的大小,当没有数据的时候,函数返回0或者负数。调用者拿到返回值后,可以根据返回值分配相应的内存大小,之后再次调用,就可以把之前查询到内容拷贝出来。