如何正确实现一个返回指针的函数?

· · 个人记录

有的时候,我们会遇到形如这样的函数 API(来源 WC 2022 T3):

const char *guess(int num_testcase, int remaining_guesses, char initial_letter, bool *gold, bool *silver);

这样的 API 的显著特点是,它的返回值是一个指针类型变量。实现一个返回指针的函数事实上暗藏着许多坑,缺乏相关编程经验的编程者上手时很容易遇到各种问题。

本文将给出此类函数的正确实现方法,也希望大家在阅读本文后,能够养成一种更现代,更健康的编程习惯。

错误的实现

如果你没有使用堆空间(不少算法竞赛选手可能缺乏相关知识的了解)的习惯,很容易会写出这样的代码:

const char *fun() {
  char s[5];
  // do something
  return s;
}

看起来没问题啊?这个函数里声明了一个长度为 5 的字符数组,然后返回了这个数组的首地址。没错,语义上确实是没毛病的。

让我们来分析一下上面这个程序的运行过程:

  1. 调用者调用了 fun 函数。
  2. fun 函数创建了局部变量,占用 5 字节,分配在 栈空间 上(简单来说,栈指针 sp -= 5,相应内存位置入栈,分配给该局部变量)。
  3. 函数体执行完毕后,栈指针恢复到调用 fun 函数前的位置(栈指针 sp += 5,相应内存位置被弹出栈),程序继续执行,相应的内存空间已经不在栈中。

(更多的技术细节这里不再叙述,感兴趣的同学可以阅读 CSAPP 等系统方面的书籍。)

我们发现,被调用者拿到的返回指针,在函数返回后就无效了!这样的指针一般被称为 悬垂指针。由于指针对应的空间已经被弹出栈,在后续的出入栈操作下,悬垂指针指向的内存空间可能会被分配给其他函数 / 变量,对应空间的内容往往会被意外改变,被调用者再访问该指针指向的内存空间,可能会拿到错误的结果(甚至可能出现运行时错误)!

正确的实现

栈空间在函数调用返回时会失效,但 堆空间 不会。所以正确的方法是声明一个指针,将该指针指向堆空间。

const char *fun() {
  char *s = new char[5];
  // do something
  return s;
}

C++ 中,newdelete 关键字用于管理堆上空间。这里在堆上分配了一个 5 字节的空间用于存放该数组,并返回该空间的首地址存放于指针变量 s 中。

接下来,你可以继续使用下标访问运算符读写这个空间中某个位置的变量,操作完毕后,放心将 s 返回即可。

当然,这里还需要注意一点:分配空间并不总是成功的,如果因为剩余内存不足等原因导致分配空间失败,new 操作将返回一个空指针 nullptr。一个实现严谨的程序应该对这种情况作出处理,不过算法竞赛中不太可能会出现这种情况。

另外,C++ 也保留了 C 中的 mallocfree 函数用于管理堆空间。不过 newdelete 的语法相对来说更友好,一般更推荐使用后者。注意不要混合使用两种堆内存管理方法!例如将 new 申请的空间用 free 释放,后果不可预料!

总结

前面的内容已经足够让大家在算法竞赛中避坑了,如果再遇到像去年 WC 的 T3 这样实现一个返回指针的函数,相信大家已经知道了正确的做法。

虽然我们成功解决了避开了一个大坑,但目前的做法还存在其他方面的问题:

  1. C++ 中并没有垃圾回收等机制,我们在堆上分配的空间,如果不手动释放,在程序结束前都会被一直占用,如果我们意外丢失了指向堆空间上的指针,这部分空间就被浪费了,这一问题称为 内存泄漏
  2. char* 这样类型的变量并不好用,我们还需要自己处理很多细节问题(例如记得在字符串的末尾手动添加 \0 结束符)。

事实上,C++ 标准库相比 C,提供了很多高层次的抽象,帮助我们有效解决了这些问题。包括 std::string 在内的类型相比原始数组,提供了更多有用的功能,安全性也更好,使用起来也更加方便。

因此,请尽量避免再写下面的函数 API 了:

int *fun1(int x);
char *fun2();

请替换为下面这些更 C++ 的 API:

vector<int> fun1(int x);
string fun2();