资源管理

一般而言,所谓资源,指的是内存、CPU、硬件、文件、句柄等资源。操作系统为每种资源的管理都提供了相关的接口。要想获得并访问资源,需要先获得资源,然后需要拥有可操纵资源的句柄,能否获得资源由操作系统决定,而程序员能做的就是通过资源句柄去操纵对象,并决何时获得/释放资源。

资源的管理第一原则为:谁获取资源,谁释放资源

决定函数的返回值

上面已经说过了:谁获取资源,谁释放资源,但是有些时候我们确实需要返回一个指针怎么办呢?例如需要将两个字符串进行拼接,那么函数必定要分配空间,但是指针返回后生命周期就不再归函数管理了。

一种直观的方法是使用智能指针,另一种则是使用输出指针

将指针分为输入指针和输出指针,函数从输入指针获取数据,然后将数据写入到输出指针中,输出指针指向的内存的尺寸由用户指定,这样就避免了指针逃出控制的问题

获取资源

以文件资源为例:

// 打开文件并判断是否成功
FILE *hFile = fopen("D:\\main.cpp", "r");
if (!hFile) exit(EXIT_FAILURE);
// 获取文件长度
fseek(hFile, 0, SEEK_END);
long len = ftell(hFile);
// 在内存中开辟缓冲区
char *buf = (char *) malloc(len + 1);
rewind(hFile);
// 读取文件内容到缓冲区
fread(buf, 1, len, hFile);
buf[len] = '\0';
// 释放文件资源
fclose(hFile);
printf("%s", buf);
// 释放缓冲区资源
free(buf);

在上面的代码中,我们首先通过 fopen 打开文件 D:\main.cpp 并获取其句柄(hfile),然后获取文件的大小并通过 malloc 开辟缓冲区,读取文件内容后 立即 通过 fclose 关闭文件,打印缓冲区内容后 立即 释放缓冲区资源。

注意:这里我用到了两个 立即 来说明 何时释放资源。对于资源的获取和释放我们有以下约定:

尽晚获取资源,尽早释放资源

对于 C++ 而言,我们可以通过类将上述操纵进行封装,从而简化操作:

class File {
   char *buf = nullptr;
   FILE *hFile = nullptr;
public:
   File(const char *_FileName, const char *_Mode) {
      hFile = fopen(_FileName, _Mode);
   }

   bool success() {
      return hFile != nullptr;
   }

   const char *read() {
      fseek(hFile, 0, SEEK_END);
      long len = ftell(hFile);
      buf = (char *) malloc(len + 1);
      rewind(hFile);
      fread(buf, 1, len, hFile);
      buf[len] = '\0';
      return buf;
   }

   ~File() {
      if (hFile) fclose(hFile);
      if (buf) free(buf);
   }
};
// 用法
File *objFile = new File("D:\\main.cpp", "r");
if (!objFile->success()) exit(EXIT_FAILURE);
cout << objFile->read();
delete objFile;

在上述代码中,要点为:

  • 在构造函数中获取资源,在析构函数中释放资源

  • 使用 new/delete 操作符自动执行构造函数和析构函数

  • 我们通过对象操纵资源

可以看到,通过对文件句柄操作的简单封装,我们在使用时大幅度减少了代码的数量。更重要的是我们现在通过对象操纵资源,而不是通过句柄操纵资源。这样,我们将资源封装到一个一个类中,然后统一使用对象操纵资源(而不是文件句柄、设备句柄等)

这种在构造函数获取资源并初始化对象,在析构函数释放资源的手段称为 RAII (Resource Acquisition Is Initialization)

验证资源的有效性

现在我思考另外一个问题:如果我们的 read() 函数中某行代码执行失败了怎么办?比如内存分配失败了,文件读取失败了,文件定位失败了。更糟糕的是可能文件打开失败了,而我们却不加校验地调用了 read() 函数,导致我们错误地对空指针进行了操作。最简单的方法当然是直接返回一个空指针,但是这意味着我们在调用 read() 后又要进行一次资源有效性的判断。

这样,我们调用的代码就变成了:

File *objFile = new File("D:\\main.cpp", "r");
if (!objFile->success())
   exit(EXIT_FAILURE);
const char *content = objFile->read();
if (!content)
   exit(EXIT_FAILURE);
cout << content;
delete objFile;

看吧,我们只是简单地读取一下文件的内容,但是由于要检测错误却出现了这么多行的代码,而且我们甚至不知道为什么代码出错了,代码不仅丑陋而且对于错误检测也毫无意义。

现在我们考虑 异常 ,当我们函数体内的代码失败后我们 立即 中断当前函数的执行并抛出一个异常,在异常中我们可以详细叙述代码出错的时间、地点、原因等附加信息,而在调用函数前,我们通过捕获异常来查明代码出现的错误。通过核查异常的附加信息,我们可以很方便地找到错误的原因。

现在我们在 File 中添加异常:

class File {
   char *buf = nullptr;
   FILE *hFile = nullptr;
public:
   File(const char *_FileName, const char *_Mode) {
      hFile = fopen(_FileName, _Mode);
      if (!hFile) throw runtime_error("文件打开失败");
   }

   const char *read() {
      fseek(hFile, 0, SEEK_END);
      long len = ftell(hFile);
      buf = (char *) malloc(len + 1);
      if (!buf) throw bad_alloc();
      rewind(hFile);
      size_t size = fread(buf, 1, len, hFile);
      if (size < len && ferror(hFile)) throw runtime_error("文件读取失败");
      buf[len] = '\0';
      return buf;
   }

   ~File() {
      if (hFile) fclose(hFile);
      if (buf) free(buf);
   }
};
// 使用
try {
   File *objFile = new File("D:\\Documents\\projects\\clion\\test\\main.cpp", "r");
   cout<<objFile->read();
   delete objFile;
} catch (bad_alloc&e) {
   cout<<e.what();
   exit(EXIT_FAILURE);
} catch (runtime_error&e) {
   cout << e.what();
   exit(EXIT_FAILURE);
}

现在,我们在 try 中运行我们的代码,在 catch 中检测异常,我们的代码和错误检查已经分开了

异常带来的资源泄露

注意到了吗?我说“函数体内的代码失败后我们 立即 中断当前函数的执行并抛出一个异常”,现在假设有这样的代码:

char* str = new char[20];
throw "这里抛出了异常";
delete[] str

str 在抛出异常之前获得内存资源,但是在释放资源前由于抛出异常导致 str 持有的内存资源无法被释放,而且这时没有其他变量引用 str 指向的内存,这就导致 str 持有的内存只能在程序关闭后被操作系统回收,此即所谓的“内存泄露”。内存泄露导致系统可用资源减少,当我们的程序持续造成内存泄露时,就会导致系统的资源不够用。

你可能会发现我这里都是通过 new 创建的堆对象,之所以如此是因为 C++ 做出以下保证:

  • 保证栈对象在退出作用域时执行析构函数

  • 保证析构函数不抛出异常

注意第二点,这意味着:析构函数如果出现异常应当在析构函数内解决,而不是将其抛出去,否则直接终止程序

借助第一点,我们可以考虑使用栈对象来管理堆对象,比如:

class CharGuard{
public:
   typedef void (*hClose)(char *);

   CharGuard(char *obj, hClose close) {
      this->obj = obj;
      this->close = close;
   }

   ~CharGuard() {
      close(obj);
}

private:
   char *obj;
   hClose close;
};

void closeFunc(char *str) {
   delete[] str;
}
// 用法
char* s = new char[20];
CharGuard guard(s, closeFunc);

我们将堆对象的生命周期绑定到了栈对象上,这意味我们无需担心资源发生泄露的问题。关于 CharGuard 更好的实现参见 ScopeGuard

C++ 提供了 智能指针 ,通过指针指针,我们可以做到整份 C++ 代码不再出现 new/delete ,通过这种“智能”手段管理资源可以大大减少资源泄露的可能。

使用引用管理对象声明周期

引用提供了一种“智能”管理对象生命周期的方法,其可以简单地分为左值引用和右值引用,其中右值引用可以用来延长对象的生命周期,左值引用可以用来消除代码中的指针变量。

具体做法为:

  • 在函数中返回指针变量的解引用,则函数返回值为左值引用

  • 在函数中使用 std::move 移动临时对象,返回值为右值引用

如果不使用 std::move 移动临时对象,而返回值却为引用,则得到的引用为 悬空引用 ,结果未定义。

例如:

File& getChar(File* file){
   return *file;
}

File&& getFile(const char *_FileName, const char *_Mode){
   File file(_FileName, _Mode);
   return move(file);
}
Last moify: 2023-12-15 04:28:35
Build time:2025-07-18 09:41:42
Powered By asphinx