安全性

链表是使用指针的最好例子,我们总是希望学生能充分理解链表,并亲自构造和使用它。但出于指针的复杂性,在使用链表时总会发生各种各样的意外。因此我不得不在这一部分结尾添加“安全性”一节,以介绍两种最常见的安全性错误:内存泄漏(Memory leak)和悬垂指针(Dangling pointer)。

内存泄漏

内存泄漏的本意就是某块申请来的内存没有在合适的时机释放,简单说就是 new 完了没有 delete。这个看上去很容易避免,但实际操作起来,还是很难保证每一块内存都能正确被释放。

有的人可能认为,不释放内存并没有什么坏处——这在某种意义上是可以理解的,因为有些偶发性的、一次性的内存泄漏并不会导致严重的后果。我们平时练习用的代码,以及提交到在线评测上的代码,都是一秒钟内就可以跑完的;那么这种程序不在中途释放内存也可以接受,操作系统会在进程退出后帮我们清理这些垃圾。

但这个世界不是由练习代码构成的,有很多代码是需要长时间、持续不断地运行的,比如服务器。一个服务器可能某一块代码没写好,导致每天都会产生几个 k 到几个 M 的内存没有被释放;久而久之,这些泄漏的内存就会严重拖慢计算机性能,造成一些不利的后果。

落实到代码上,内存泄漏可能以这些形式发生:

// 情形1:忘了,或者懒得写 delete

int main() {
    int* p{new int{42}};
    // [...] 没有释放 p
    return 0;
}
// 情形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,而建议使用一种称为“智能指针”的设施来管理内存。但介绍它需要非常多的前置知识,我计划把它的介绍放到本书的第十章。

悬垂指针

另外一种安全性错误——悬垂指针则简单得多。它的基本代码形式就长成这样:

int* getPtr() {
    int a{42};
    return &a;
}
int main() {
    int* p{getPtr()};
}

就这么简单。这里函数 getPtr 返回了指针,指向函数内的局部变量 a;但是当函数返回时,所有局部变量所在的内存空间都会被清除。换句话说,函数返回之后,a 已经不存在了;但 main 函数中的指针 p 却指向了这个 a。一个指针指向不再存在的变量,此时对这个指针所指向对象做任何操作都是未定义的。

有人觉得不会写出这样蠢的代码。但事实上大有人在:比如有一个常见的需求是返回数组:

/*???*/ getIotaArr() {
    int arr[5]{1, 2, 3, 4, 5};
    return arr;
}

int main() {
    /*???*/ iotaArr;
    iotaArr = getIotaArr();
    // [...]
}

这份代码试图定义一个函数,它返回 ι\iota 数组,也就是包含 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 出来的空间。

int* getIotaArr() {
    int* arr{new int[5]{1, 2, 3, 4, 5}};
    return arr;
}

这种写法不会有悬垂指针问题,但可能会有内存泄漏问题——请参看上一个标题的“情形2”。

第二种,返回全局变量,或者静态局部变量。

int* getIotaArr() {
    static int arr[5]{1, 2, 3, 4, 5};
    return arr;
}

由于全局变量或者静态局部变量不会随着函数返回而释放,所以这样做是 OK 的。但代价是,全局只有这一份内存,每次调用 getIotaArr 返回的值都是指向同一个变量或数组的,可能会造成使用上的不便。

第三种,把数组包装到结构体里,然后返回。

struct ArrayOf5 {
    int value[5];
};
ArrayOf5 getIotaArr() {
    ArrayOf5 arr{{1, 2, 3, 4, 5}};
    return arr;
}

这种写法不会有任何安全问题,惟一的弊端就是丑,难用,啰嗦。

当然,悬垂指针也是有办法避免的。除了之前提到的“智能指针”能有所帮助外,还可以改用 std::array (会在第八章介绍)等设施来实现刚刚的需求。

最近更新:
代码未运行