第9章 字符串去空格:手写 trim、ltrim、rtrim 函数
各位同学,咱们今天聊一个看似简单、实则暗藏玄机的话题——字符串去空格。
你可能会想:“不就是去掉首尾的空格吗?有什么好讲的?”
嗯,我当年也是这么想的。直到有一次,我在一个嵌入式项目里,因为一个trim函数没写好,导致整个协议解析模块出了bug,排查了整整一个下午。从那以后,我对这种“基础函数”再也不敢掉以轻心。
说白了,trim、ltrim、rtrim这三个函数,是字符串处理中最常用的工具之一。但标准C库并没有提供它们,你得自己手写。怎么写?写得好不好?直接关系到代码的健壮性和性能。
9.1 什么是 trim、ltrim、rtrim?
先明确概念:
- trim:去掉字符串首尾的空格(包括空格、制表符、换行符等空白字符)
- ltrim:只去掉字符串左侧(开头)的空格
- rtrim:只去掉字符串右侧(结尾)的空格
举个例子:
char str[] = " hello world ";
// trim 后变成 "hello world"
// ltrim 后变成 "hello world "
// rtrim 后变成 " hello world"
看起来很简单,对吧?但实现起来,有几个坑你得注意。
9.2 手写 ltrim 函数
先从最简单的 ltrim 开始。思路很直接:找到第一个非空格字符的位置,然后把后面的内容整体往前挪。
我个人习惯用指针操作,效率高,代码也简洁。
char* ltrim(char* str) {
if (str == NULL) return NULL; // 防御性编程,必须的
char* p = str;
// 跳过开头的空白字符
while (*p && isspace((unsigned char)*p)) {
p++;
}
// 如果开头就有非空格字符,p == str,不会触发memmove
if (p != str) {
memmove(str, p, strlen(p) + 1); // +1 是为了拷贝结尾的 '\0'
}
return str;
}
这里有个细节:我用了 memmove 而不是 memcpy。为什么?因为源和目标内存区域可能重叠。memmove 能正确处理重叠情况,而 memcpy 不行。这是很多新手容易踩的坑。
memmove(str, p, ...) 的源和目标有重叠,必须用 memmove。
9.3 手写 rtrim 函数
rtrim 稍微绕一点。你得从字符串末尾往前找,找到第一个非空格字符,然后在其后一个位置放上 '\0'。
char* rtrim(char* str) {
if (str == NULL) return NULL;
size_t len = strlen(str);
if (len == 0) return str; // 空字符串直接返回
char* p = str + len - 1; // 指向最后一个字符
// 从后往前找,跳过空白字符
while (p >= str && isspace((unsigned char)*p)) {
p--;
}
// 在最后一个非空格字符后面放 '\0'
*(p + 1) = '\0';
return str;
}
这里有个边界情况:如果字符串全是空格,p 会一直减到 str - 1,然后 *(p + 1) 就是 *str,也就是把第一个字符设为 '\0',字符串变成空串。逻辑是对的。
我曾经在项目里见过有人用 while (p > str) 做判断,结果全空格字符串时漏掉了第一个字符,导致 rtrim 没完全去掉空格。嗯,这种边界情况,你写单元测试时一定要覆盖到。
9.4 手写 trim 函数
trim 最简单的方式就是组合 ltrim 和 rtrim:
char* trim(char* str) {
if (str == NULL) return NULL;
return rtrim(ltrim(str));
}
先去掉左边空格,再去掉右边空格。顺序无所谓,结果一样。
但如果你追求极致性能,也可以一次遍历完成。不过说实话,对于绝大多数场景,组合调用的性能完全够用。我一般不会为了这点微优化去增加代码复杂度。
9.5 完整代码示例
下面是一个完整的、带 main 函数测试的版本:
#include <stdio.h>
#include <string.h>
#include <ctype.h>
char* ltrim(char* str) {
if (str == NULL) return NULL;
char* p = str;
while (*p && isspace((unsigned char)*p)) {
p++;
}
if (p != str) {
memmove(str, p, strlen(p) + 1);
}
return str;
}
char* rtrim(char* str) {
if (str == NULL) return NULL;
size_t len = strlen(str);
if (len == 0) return str;
char* p = str + len - 1;
while (p >= str && isspace((unsigned char)*p)) {
p--;
}
*(p + 1) = '\0';
return str;
}
char* trim(char* str) {
if (str == NULL) return NULL;
return rtrim(ltrim(str));
}
int main() {
char test1[] = " hello world ";
char test2[] = "\t\n foo bar \r\n";
char test3[] = "no_spaces";
char test4[] = " ";
char test5[] = "";
printf("trim('%s') = '%s'\n", test1, trim(test1));
printf("trim('%s') = '%s'\n", test2, trim(test2));
printf("trim('%s') = '%s'\n", test3, trim(test3));
printf("trim('%s') = '%s'\n", test4, trim(test4));
printf("trim('%s') = '%s'\n", test5, trim(test5));
return 0;
}
输出结果:
trim(' hello world ') = 'hello world'
trim('
foo bar
') = 'foo bar'
trim('no_spaces') = 'no_spaces'
trim(' ') = ''
trim('') = ''
9.6 避坑指南与性能考量
写这几个函数,有几个坑我帮你总结一下:
- NULL 指针检查:函数入口一定要判断
str == NULL,否则传入 NULL 直接崩溃。我在生产代码里见过这种事故。 - 空字符串处理:
strlen(str)为 0 时,rtrim里str + len - 1会变成str - 1,这是未定义行为。所以要先判断。 - isspace 的参数类型:
isspace接收int,但char在有些平台上是 signed。如果字符的 ASCII 值大于 127,直接传char会导致负数,isspace的行为是未定义的。所以要用(unsigned char)强转。 - 不要修改传入的字符串常量:如果你传一个字符串常量(如
" hello ")给trim,函数内部会修改它,这是未定义行为。所以调用方要确保传入的是可修改的内存(如字符数组)。
malloc 分配内存。但记得调用方要 free。
9.7 知识体系图
下面这张图帮你理清本章的核心逻辑:
9.8 扩展思考
如果你需要处理 Unicode 字符串(比如 UTF-8),事情就复杂了。空格字符不止 ' ' 一种,还有不间断空格(U+00A0)、全角空格(U+3000)等。这时候 isspace 就不够用了,你得用专门的 Unicode 库。
另外,有些场景下你只想去掉普通空格,保留制表符和换行符。那你就不能直接用 isspace,得自己写判断条件:
while (*p == ' ') { p++; } // 只去掉空格,不去掉 \t \n
你看,一个简单的去空格函数,背后有这么多门道。写代码就是这样,细节决定成败。
好了,本章的内容就到这里。记住我强调的那几个坑,下次写 trim 函数时,你就能一次写对,不用像我当年那样排查一下午了。