关于C字符串的一个陷阱

关于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++的newdelete运算符,在C语言中有对应的mallocfree函数)

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

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值