Cocos2d-x 的内存管理

分享一下我老师大神的人工智能教程!零基础,通俗易懂!http://blog.csdn.net/jiangjunshow

也欢迎大家转载本篇文章。分享知识,造福人民,实现我们中华民族伟大复兴!

               

既然选择了C++作为游戏开发的语言, 手动的管理内存是难以避免的, 而Cocos2d-x的仿Objctive-C的内存管理方式, 事实上让问题变得更加复杂(仅仅是移植更加方便了), 因为你需要很好的掌握两种语言的内存管理方式, 并且在使用时头脑清晰, 不能混用, 不然等待你的就是无穷的噩梦, 因为引用计数的原因, 问题比起纯粹的C++内存泄漏还要难以定位的多. 

这里统一的整理一下目前在Cocos2d-x中的内存管理相关的方法和问题. 为了让思路更加清晰, 我提及的不仅仅是具体在Cocos2d-x中怎么用, 也包含一些为啥在Cocos2d-x中应该这么用.
并且, 因为以下讲到的每个点要讲的详细了, 其实都可以写一整篇文章了, 没法在短时间内详述, 这篇文章也就仅仅作为一个引子而已.

C++的内存管理

C语言的malloc, free

因为C++基本兼容C, 所以C语言的malloc和free也是支持的. 简单的说用法就是malloc后记得free即可.

#include <stdio.h>#include <stdlib.h>const size_t kBufferSize = 16;void test() {  char *buffer = (char*)malloc(kBufferSize);  memset(buffer, 0, sizeof(char) * kBufferSize);  sprintf(buffer, "%s", "Hello World\n");  printf(buffer);  free(buffer);}int main(int, char**) {  test();  return 0;}

当然, 还有realloc和calloc这两个平时较少用的内存分配函数, 这里不提了, 在C语言时代, 我们就是用malloc和free解决了我们的内存问题. 重复的对同一段内存进行free是不安全的, 同时, 为了防止free后, 还拿着指针使用(即野指针), 我们一般使用将free后的内存置空.
因为这种操作特别的多, 我们常常会弄一个宏, 比如Cocos2d-x中的

#define CC_SAFE_FREE(p)         if(p) { free(p); p = 0; }

C++的new, delete, new[], delete[]

为什么malloc和free在C++时代还不够呢? malloc,free在C++中使用有如下的缺点:

  1. malloc和free并不知道class的存在, 所以不会调用对象的构造函数和析构函数.
  2. malloc返回的是void*, 不符合C++向强类型发展的趋势.

于是, BS在C++中增加了new-delete组合以替代malloc, free. 其实new, delete基本上都是用malloc, free实现的更高层函数, 只是增加了构造和析构的调用而已. 所以在平时使用的时候并无区别, 但是因为malloc其实是记录了分配的长度的, 长度以字节为单位, 所以一句malloc对应即可, 不管你是malloc出一个char, 一个int, 还是char的数组或者int的数组, 单位其实都是字节. 而长度一般记录在这个内存块的前几个字节, 在Windows中, 甚至可以使用_msize函数从指针中取出malloc出的内存长度.
而new的时候有两种情况, 可以是一个对象, 也可以是对象的数组, 并且, 没有统一的记录长度. 使得我们需要通过自己记住什么时候new出了一个对象, 什么时候new出了多个对象. 这个问题最近云风还吐槽过. 并且增加了delete[]用于删除new出多个对象的情况. 让问题更加容易隐藏不被发现的是, 当你讲delete用于new[]分配的内存时, 实际相当于删除了第一个对象, 是完全正确的语法, 不会报任何错误, 你只能在运行出现诡异问题的时候再去排查.
这个问题实际是语言设计的决策问题. 在BS这个完美主义者这里, C++语言的设计原则之一就是零负荷规则 — 你不会为你所不使用的部分付出代价. 并且在这里这个规则执行的显然是要比C语言还要严格. 当你只分配一个对象的时候, 为啥要像分配多个对象时一样, 记录一个肯定为1的计数呢? 估计BS是这么想的. 于是我们只好用人工来区分delete和delete[]. 某年某月, 我看到某人说C++是实用主义, 我笑了, 然后我看到C++的一条设计原则是”不一味的追求完美”, 我哭了……

#include <stdio.h>class Test {public:  Test() {    test_ = __LINE__;    printf("Test(): Run Code in %d\n", __LINE__);  }  ~Test() {    test_ = __LINE__;    printf("~Test(): Run Code in %d\n", __LINE__);  }  void print() {    printf("%d\n", test_);  }private:  int test_;};void test() {  Test *temp = new Test;  delete temp;  Test *temps = new Test[2];  delete []temps;  Test *error_delete_temps = new Test[2];  delete error_delete_temps;}int main(int, char **) {  test();  return 0;}

上面的代码最后用delete删除了使用new[]分配的内存, 但是编译时即使开启-wall选项, 你也不会看到任何的警告, 运行时也不会报错, 你只会发现析构函数少运行了一次. 实际上就是发生了内存泄漏.

C/C++内存管理的实际使用

上面虚构的例子也就是看看语法, 真实的使用情景就要复杂了很多. 最重要的原则之一就是谁分配谁释放原则. 这个原则即使是用malloc, free也一样需要遵循.
在C语言上的表现形态大概如下:

只管用, 不管分配

典型的例子就是C语言的文件读取API:

size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );

这里的ptr buffer并不由fread内部分配, 而是由外部传进来, fread只管使用这段buffer, 并且假设这段buffer的size 大于等于传进来的size. 通过这样的方式, fread本身逃避了malloc, free的责任, 也就不用关心内存该怎么管理的问题. 好处是fread的实现得到简化, 坏处是外部使用的负担增加了~~~
类似的API还有sprintf, fgets等, 而strcat这种API也算是这种类型的变化.

这种类型的API的一个最大问题在于很多时候不好确定buffer到底该多大, 于是在一些时候, 还有一个复杂的用法, 那就是在第一次传特定参数调用函数时, 函数仅传出需要buffer的大小, 分配了buffer后, 第二次调用才真正实现函数的功能.

这种API虽然外部的使用麻烦, 但是在绝大部分时候, 已经是C语言的最佳API设计方法.

管分配, 也管删除

只管用, 不管分配的API设计方式在仅仅需要一个buffer, 或者简单的结构缓存的时候基本已经够用, 但也不是万能的, 在想要隐藏内部实现时, 往往就没法使用这样的API设计, 比如Windows API中一系列与核心对象创建相关的方法, 如CreateThread, CreateProcess等函数返回的都是一个核心对象的handle, 然后必须要使用CloseHandle来释放, handle具体的对应什么结构, 分配了多少内存, 此时并不由我们关心, 我们只需要保证一个CreateXXX, 就一定要CloseHandle即可.
这种API的设计方式, 还常见于我们熟知的工厂模式, 工厂模式提供Create和Delete的接口, 就要比只提供Create接口, 让外部自己去Delete要好的多, 此时外部不用关心对象的删除方式, 将来需要在创建对象时进行更多操作(比如对对象进行引用计数管理), 外部代码也不需要更改. 我在网上随便一搜, 发现绝大部分关于工厂模式的代码中都没有考虑这个问题, 需要特别注意.
这里看一个我们常见的开源游戏引擎Ogre中的例子: (Ogre1.8.1 OgreFileSystem.h)

/** Specialisation of ArchiveFactory for FileSystem files. *///class _OgrePrivate FileSystemArchiveFactory : public ArchiveFactoryclass _OgreExport FileSystemArchiveFactory : public ArchiveFactory{  public:    virtual ~FileSystemArchiveFactory() {}    /// @copydoc FactoryObj::getType    const String& getType(void) const;    /// @copydoc FactoryObj::createInstance    Archive *createInstance( const String& name )     {      return OGRE_NEW FileSystemArchive(name, "FileSystem");    }    /// @copydoc FactoryObj::destroyInstance    void destroyInstance( Archive* arch) { delete arch; }};

这里这个FileSystemArchiveFactory就是典型的例子. 虽然这里的destroyInstance仅仅是一个delete, 但是还是这么设计了接口.

单独的缓存

一般而言, 所有与内存管理相关的C语言API设计都应该使用上面提到的两种方案, 其他的方案基本都是错的(或者有问题的), 但是因为各种考虑, 还是有些很少见的设计, 这里把一些可能出现的设计也列出来.
所谓的单独缓存的设计, 指的是一段代码内部的确需要缓存, 但是不由外部传入, 而是自己直接分配, 只是每次的调用都使用这一份缓存, 不再释放.
比如C语言中对错误字符串的处理:

char * strerror ( int errnum );

strerror这个API的设计咋一看就有蹊跷, 什么样的设计能传入一个整数, 然后返回一个字符串呢? 字符串的存储空间哪里来的? 一个简单的例子就能看出这样设计的问题:

#include <stdio.h>#include <string.h>#include <errno.h>int main (){  char *error1 = strerror(EPERM);  printf ("%s\n", error1);  char *error2 = strerror(ENOENT);  printf ("%s\n", error1);  printf ("%s\n", error2);  return 0;}

此时会输出:
Operation not permitted
No such file or directory
No such file or directory

意思就是说, 当第二次调用strerror的时候, 连error1的内容都变了, 对于不明情况的使用者来说, 这绝对是个很意外的情况. 虽然说, 这个例子比较极端, 在错误处理的时候不太可能出现, 但是这种设计带来的问题都是类似的. 至于为什么获得错误字符串的API要做这样的设计, 我就无从得知了, 可能是从易用性出发更多一些吧.

在另外一些基于效率的考虑时, 也可能会使用这样的设计, 我只在一个对效率有极端要求的情况下, 在真实项目环境中见过这样的设计. 那是在做服务器的时候, 一些辅助函数(对收发数据包的处理)需要大的缓存, 同时操作较为频繁并且效率敏感, 才使用了这样的设计.

一般来说, 这种设计常常导致更多的问题, 让你觉得获得的效率提升付出了不值得的代价. 所以除非万一, 并不推荐使用.

我分配, 你负责释放

这种API的唯一好处就是看起来使用较为简单, 但是在任何情况下, 都不仅仅是容易导致更多问题, 这种设计就是问题本身, 几乎只有错误的代码才会使用这样的设计, 起码有以下几个简单的原因:

  1. 返回一段buffer的接口, 内部可以使用new, 也可能使用malloc分配, 外部如何决定该使用delete还是free释放, 只能额外说明, 或者看源代码.
  2. 当API跨内存空间调用时, 就等于错误, 比如当API在动态库中时. 这是100%的错误, 无论是delete还是free, 也不能释放一个在动态库中分配的内存.

正是因为这两个原因, 你几乎不能在任何成熟的代码中看到这样的设计. 但是, 总有人经受不了使用看起来简单的这个诱惑, 比如cocos2d-x中CCFileUtils类的两个接口:

/**@brief Get resource file data@param[in]  pszFileName The resource file name which contains the path.@param[in]  pszMode The read mode of the file.@param[out] pSize If the file read operation succeeds, it will be the data size, otherwise 0.@return Upon success, a pointer to the data is returned, otherwise NULL.@warning Recall: you are responsible for calling delete[] on any Non-NULL pointer returned.*/unsigned char* getFileData(const char* pszFileName, const char* pszMode, unsigned long * pSize);/**@brief Get resource file data from a zip file.@param[in]  pszFileName The resource file name which contains the relative path of the zip file.@param[out] pSize If the file read operation succeeds, it will be the data size, otherwise 0.@return Upon success, a pointer to the data is returned, otherwise NULL.@warning Recall: you are responsible for calling delete[] on any Non-NULL pointer returned.*/unsigned char* getFileDataFromZip(const char* pszZipFilePath, const char* pszFileName, unsigned long * pSize);

这种直接返回数据的接口自然比fread这样的接口看起来容易使用很多, 同时还通过一个warning的注释表明了, 应该使用delete[]来释放返回的缓存, 以避免前面提到的第一个问题, 但是, 不管再怎么做, 当你将cocos2d-x编译成动态库, 然后尝试使用上述两个API, 等待你的就是错误. 有兴趣的可以尝试.

这种API还是偶尔能见到, 有的时候可能还不如上面这个例子这么明显, 比如当一个函数要求传入Class** obj参数, 并且返回对象的时候, 其实也已经是一样的效果了.

再次声明, 个人认为这种API本身就是错误, 不推荐在任何时候使用, 看到这么设计的API, 也最好不要在任何时候调用.

C++对内存管理的改进

上述提到的几种涉及内存分配的API设计虽然是C语言时代的, 但是基本也适用于C++. 只是在C++中, 在Class层面, 对内存管理进行了一些新的改进.
有new就delete, 有malloc就free, 听起来很简单, 但是在一些复杂情况下, 还是会成为负担, 让代码变得很难看. 更不要提, 程序员其实也是会犯错误的. C++对此的解决之道之一就是通过构造函数和析构函数.

看下面的例子:
在极其简单的情况下, 我们这样就能保证内存不泄漏:

const int32_t kBufferSize = 32;void Test1() {  char *buffer = new char[kBufferSize];  // code here  delete[] buffer;}

但是, 这仅限于最简单的情况, 当有可能的错误发生时, 情况就复杂了:

const int32_t kBufferSize = 32;bool Init() {  char *buffer = new char[kBufferSize];  bool result = true;  // code here  if (!result) {    delete[] buffer;    return false;  }  char *buffer2 = new buffer[kBufferSize];  // code here  if (!result) {    delete[] buffer;    delete[] buffer2;    return false;  }  delete[] buffer;  delete[] buffer2;  return true;}

仅仅是两次错误处理, 分配两段内存, 你不小心的写代码, 就很有可能出现错误了. 这不是什么好事情, 更进一步的说, 其实, 我上面这段话还是基于不考虑异常的情况, 当考虑异常发生时, 上述代码就已经是可能发生内存泄漏的代码了. 考虑到异常, 当尝试分配buffer2的内存时, 假如分配失败, 此时会抛出异常(对, 在C++中普通的new分配失败是抛出异常, 而不是返回NULL), 那么实际上此时buffer指向的内存就没有代码负责释放了. 在C++中, 讲buffer放入对象中, 通过析构函数来保证内存的时候, 就不会有这样的问题, 因为C++的设计保证了, 无论以何种方式退出作用域(不管是正常退出还是异常), 临时对象的析构函数都会被调用.
代码大概就会如下面这样:

const int32_t kBufferSize = 32;class Test {public:  Test() {    

猜你喜欢

转载自blog.csdn.net/rgjtfc/article/details/83956886
今日推荐