关于C字符串的一个陷阱
今天有小伙伴来问了我一个挺有趣的问题,正好最近在读的《C++ 沉思录》在开头谈到“为什么用C++”的时候也提到了这一点,于是决定写一篇笔记。
注:笔者水平有限,如有错误欢迎指出,不胜感激。
众所周知,C语言中是没有真正的“字符串”的,我们不能像在Python或者C++中那样很方便地使用str
或者std::string
来处理字符串,而只能用以空字符'\0'
结尾的字符数组来假装一个字符串。这种字符串最大的问题在于,它将拷贝控制和内存管理这两件棘手的工作完全交给了用户。
不妨考虑一个“切片”函数slice
:它接受一个字符串
s
s
s和一个下标范围
[
s
t
,
e
d
)
[st,ed)
[st,ed),返回
s
s
s中下标在区间
[
s
t
,
e
d
)
[st,ed)
[st,ed)内的片段的拷贝。初学C/C++的人也许会写出这样的代码:
char *slice(const char *s, int st, int ed) {
char res[100]; // ???
int sz = 0;
while (st != ed)
res[sz++] = s[st++];
res[sz] = '\0';
return res; // 错误
}
既然需要返回一个拷贝,我们就必须得找到一块地方来存储这个拷贝。在上面的代码中,我们开了一个静态数组res
,并假设100
是一个足够大的、可以容纳我们需要处理的任何字符串大小的值。
先不去管这个100
是否合理,这段代码本身就包含一个典型的错误:C/C++不允许返回一个局部变量的指针或引用。这是因为,res
是这个函数内部定义的静态数组,一旦函数执行完毕,res
就会被销毁,它所占用的内存空间就会被释放。在这种情况下,返回的res
指针将指向未知的内存区域。如果我们写
const char *s = "helloworld";
char *p = slice(s, 3, 9);
cout << p << endl;
这段代码在Windows下输出的内容是未定义的,而在Linux下会直接产生Segmentation fault(段错误)。
一种可能的修改方案是将res
定义为局部静态对象:
char *slice(const char *s, int st, int ed) {
static char res[100]; // 局部静态对象,正确但使用麻烦
int sz = 0;
while (st != ed)
res[sz++] = s[st++];
res[sz] = '\0';
return res;
}
由于res
是一个局部静态对象,它的生命周期是从它被创建开始一直到整个程序运行完毕,不会在中途被销毁,因此将它返回给调用者是没有问题的。
然而,这仅仅是在语法上符合要求。在实际使用上,一方面我们仍然没有解决“100
究竟够不够大”的问题,另一方面,想想这个slice
函数被多次调用的时候会发生什么:
const char *s = "helloworld";
char *p = slice(s, 3, 9); // 输出 loworl
cout << p << endl;
char *p2 = slice(s, 4, 7);
cout << p2 << endl; // 输出 owo
cout << p << endl; // 输出 owo 而不是 loworl!
因为res
是一个静态对象,所以无论函数被调用了多少次,使用的都是同一个res
,那么每一次切片都会覆盖上一次的结果。于是,用户为了能够正常使用slice
函数,必须记得每一次调用后都做一次拷贝,这显然是不合理的设计。
有没有什么办法可以在每次调用的时候都创建一个新的、生命期足够长的、可以作为返回值返回的字符数组呢?我们自然联想到了动态分配内存:(这里使用的是C++的new
和delete
运算符,在C语言中有对应的malloc
和free
函数)
char *slice(const char *s, int st, int ed) {
char *res = new char[ed - st + 1]; // 动态分配内存,正确但有陷阱
int sz = 0;
while (st != ed)
res[sz++] = s[st++];
res[sz] = '\0';
return res;
}
看起来这段代码完美地解决了以上所有问题:每一次调用slice
都会动态地分配一片内存,用来保存给定字符串的切片,并将这片内存的地址返回。并且,我们终于可以抛弃100
这样的大小限制了,因为动态数组的大小可以在运行时确定。
但是这段代码暗藏了一个陷阱:slice
函数分配了一块内存,那么何时释放它?slice
本身是不能释放这块内存的,于是这个棘手的工作交给了用户,每一次调用之后都要记得在合适的时机释放内存:
char *p = slice(s, 3, 9);
cout << p << endl;
// 可能还有一些对于 p 的其它操作
delete[] p; // 使用完毕之后释放它,而且不要漏写中括号!
如果我是slice
的使用者,我一定会抱怨:明明是你slice
分配的内存,凭什么要我来帮你释放?我说不定哪次就会忘记这件事。
从这个例子中我们看到,对于C风格的字符串来说,一个看似简单的切片操作,我们却很难拿出一个完美的实现方案。类似的操作还有很多,比如字符串的连接、拷贝等等。对此,标准库cstring
(或string.h
)给出的答案是:干脆让用户自己来管理内存。例如,标准库cstring
中的字符串拷贝函数strcpy
声明如下:
char *strcpy(char *destination, const char *source);
用户调用的时候需要自己提供拷贝的目标位置destination
,而拷贝函数strcpy
本身并不分配内存。于是乎,内存管理的问题被完全交给了用户,如果用户处理不当,例如内存分配得太多或者太少,或者分配了动态内存而忘记释放,那都是用户你自己的问题。
那我们不禁要问:内存管理究竟是谁的工作?slice
函数做不到,让用户来做似乎也不够友好。事实上,slice
函数的工作是“创建一个字符串”,并“将字符串返回”;而用户也希望处理“字符串”而不是处理“内存”。所以,应该有一种名为“字符串”的东西,它自己来管理自己的内存,而不是让slice
或者slice
的用户来为它分配内存、帮它释放。这让我想到了《C++ 沉思录》中提到的所谓“软件工程基本定理”:通过引入一个额外的中间层,我们可以解决任何问题。这里,我们引入一个名为“字符串”的中间层,解决了slice
的设计者与用户之间的尴尬。
这样的“字符串”在C语言中是很难实现的,这时C++的优势就体现出来了:C++的面向对象机制可以解决这个问题,我们可以编写一个字符串类,在构造函数中分配内存,在析构函数中释放内存。我们还可以合理地控制拷贝,甚至可以允许对字符串的长度进行改变。C++标准库早已实现了这些功能,这就是std::string
。