安全性
链表是使用指针的最好例子,我们总是希望学生能充分理解链表,并亲自构造和使用它。但出于指针的复杂性,在使用链表时总会发生各种各样的意外。因此我不得不在这一部分结尾添加“安全性”一节,以介绍两种最常见的安全性错误:内存泄漏(Memory leak)和悬垂指针(Dangling pointer)。
内存泄漏
内存泄漏的本意就是某块申请来的内存没有在合适的时机释放,简单说就是 new 完了没有 delete。这个看上去很容易避免,但实际操作起来,还是很难保证每一块内存都能正确被释放。
有的人可能认为,不释放内存并没有什么坏处——这在某种意义上是可以理解的,因为有些偶发性的、一次性的内存泄漏并不会导致严重的后果。我们平时练习用的代码,以及提交到在线评测上的代码,都是一秒钟内就可以跑完的;那么这种程序不在中途释放内存也可以接受,操作系统会在进程退出后帮我们清理这些垃圾。
但这个世界不是由练习代码构成的,有很多代码是需要长时间、持续不断地运行的,比如服务器。一个服务器可能某一块代码没写好,导致每天都会产生几个 k 到几个 M 的内存没有被释放;久而久之,这些泄漏的内存就会严重拖慢计算机性能,造成一些不利的后果。
落实到代码上,内存泄漏可能以这些形式发生:
// 情形2:返回自某个函数的指针,理应被释放但是你不知道
// 假设下面这个函数是别人写的。
// 它返回的指针应该在使用完成后 delete,
// 但你(因没有仔细读文档,或者函数作者没强调这件事)并不知道这个事实
char* strdup(const char* src) {
char* p{new char[/* ... */]};
// [...]
return p;
}
int main() {
char original[]{"Hello"};
char* duplicate{strdup(original)};
// [...] 没有释放 duplicate
return 0;
}
// 情形3:对链表等复杂的指针使用情形没有正确处理
struct Node { Node* next; };
int main() {
int n;
Node* list{new Node{}};
Node* current{list};
for (int i{0}; i <= n; i++) {
current->next = new Node{};
current = current->next;
}
// [...] 没有正确将 list 内的节点释放掉
return 0;
}
// 情形4:直接丢弃了指向 new 出来的内存的指针
// 此时这片内存再也无法被访问到,更无法被 delete
int main() {
new int{};
// 或者
int* p{new int{}};
p = nullptr;
}
// 情形5:你以为你 delete 了,其实没有
#include <iostream>
using namespace std;
int main() {
int* p{new int{}};
cin >> *p;
// 做一下错误处理……Oops!
if (cin.fail()) {
cout << "Input fail!" << endl;
return 1;
}
*p = 120 / *p;
cout << *p << endl;
delete p;
// 注意:前面的 return 1 处没有 delete
}
总之,你可以发现有太多种代码可能导致内存泄漏,人即便再谨慎也可能会出错。因此现代 C++ 已经不推荐手动使用 new 和 delete,而建议使用一种称为“智能指针”的设施来管理内存。但介绍它需要非常多的前置知识,我计划把它的介绍放到本书的第十章。
悬垂指针
另外一种安全性错误——悬垂指针则简单得多。它的基本代码形式就长成这样:
就这么简单。这里函数 getPtr
返回了指针,指向函数内的局部变量 a
;但是当函数返回时,所有局部变量所在的内存空间都会被清除。换句话说,函数返回之后,a
已经不存在了;但 main 函数中的指针 p
却指向了这个 a
。一个指针指向不再存在的变量,此时对这个指针所指向对象做任何操作都是未定义的。
有人觉得不会写出这样蠢的代码。但事实上大有人在:比如有一个常见的需求是返回数组:
/*???*/ getIotaArr() {
int arr[5]{1, 2, 3, 4, 5};
return arr;
}
int main() {
/*???*/ iotaArr;
iotaArr = getIotaArr();
// [...]
}
这份代码试图定义一个函数,它返回 数组,也就是包含 1, 2, 3, 4, 5
这五个值的数组。但是他不知道返回值类型这里的 ???
该填什么。他尝试了 int[5] getIotaArr();
或者 int (getIotaArr())[5];
,但报了一堆编译错误。于是他错误地认为这里可以用指针:
int* getIotaArr() {
int arr[5]{1, 2, 3, 4, 5};
return arr;
}
int main() {
int* iotaArr{nullptr};
iotaArr = getIotaArr();
// [...]
}
一运行,发现,没编译错误了!可喜可贺,可喜可贺……个头啊!仔细看,这里返回的指针指向什么?
这里的 return 语句说 return arr
,但返回值类型是指针,所以 arr
会隐式转换成指针类型,指向 arr[0]
。也就是说,这里返回的是指向局部变量 arr[0]
的指针!代码返回到 main 函数的时候,arr[0]
已经消亡了。
因此这里不能这样写。而天然地,又存在“函数不能返回数组”的语法限制。难道就没办法了吗?方法也是有的,只是都有一些弊端。
第一种解决方案,仍然返回指针,但指向 new 出来的空间。
这种写法不会有悬垂指针问题,但可能会有内存泄漏问题——请参看上一个标题的“情形2”。
第二种,返回全局变量,或者静态局部变量。
由于全局变量或者静态局部变量不会随着函数返回而释放,所以这样做是 OK 的。但代价是,全局只有这一份内存,每次调用 getIotaArr
返回的值都是指向同一个变量或数组的,可能会造成使用上的不便。
第三种,把数组包装到结构体里,然后返回。
struct ArrayOf5 {
int value[5];
};
ArrayOf5 getIotaArr() {
ArrayOf5 arr{{1, 2, 3, 4, 5}};
return arr;
}
这种写法不会有任何安全问题,惟一的弊端就是丑,难用,啰嗦。
当然,悬垂指针也是有办法避免的。除了之前提到的“智能指针”能有所帮助外,还可以改用 std::array
(会在第八章介绍)等设施来实现刚刚的需求。