资源管理
一般而言,所谓资源,指的是内存、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);
}