上节课咱们把指针和数组的关系捋清楚了——数组名就是首元素的地址,指针可以当数组名用,数组名也可以当指针用,两者在大多数情况下可以互换。
今天咱们讲指针和字符串。你先别觉得这是新东西,字符串本质上就是字符数组,只不过末尾多了个\0。所以指针和数组的那套玩法,直接搬到字符串上就完事了。但字符串有几个自己的脾气,今天咱们专门说说。
字符串的两种“身份”
在C语言里,你写一个字符串,有两种方式:
char str1[] = "Hello"; // 方式一:字符数组 char *str2 = "Hello"; // 方式二:指针指向字符串常量
这俩看着差不多,其实差别大了去了。咱们一个一个说。
方式一:char str1[] = "Hello";
这是在内存里开了一个数组,把'H','e','l','l','o','\0'这6个字符挨个存进去。数组名str1就是这块内存的首地址。你可以改里面的字符:
str1[0] = 'h'; // 合法,str1变成"hello"
因为这是你自己的数组,你想怎么改就怎么改。
方式二:char *str2 = "Hello";
这个就有意思了。"Hello"是一个字符串常量,它存放在内存的只读数据区(也叫常量区)。str2是一个指针,指向这个常量的首地址。
str2[0] = 'h'; // 危险!试图修改只读区的数据,程序崩溃
你可能会问:为啥一个是数组,一个是指针,差别这么大?
因为char str1[] = "Hello"是在栈上开辟了一块新内存,把字符串拷贝了一份进去。而char *str2 = "Hello"只是让指针指向了常量区里的那个字符串,没有拷贝。你改常量区的东西,操作系统不答应。
用指针遍历字符串
字符串以\0结尾,所以遍历的时候不用知道长度,一直往后走,撞到\0就停。
#include <stdio.h> int main() { char *str = "Hello"; // 方式一:用下标 int i = 0; while (str[i] != '\0') { printf("%c", str[i]); i++; } printf("\n"); // 方式二:用指针自己往后走 char *p = str; while (*p != '\0') { printf("%c", *p); p++; } printf("\n"); return 0; }
方式二特别能体现指针操作字符串的精髓:p一开始指向'H',打印出来,然后p++走到'e',再打印,再往后走……直到*p是\0,循环结束。
这就是用指针遍历字符串的标准套路。你只要记住:字符串的结束标志是\0,指针走到\0就停。
字符串指针的算术运算
跟数组一样,指针在字符串上也可以加减。p + 1就是往后跳一个字符(一个字节,因为char占1字节)。
char *str = "Hello"; printf("%c\n", *str); // H printf("%c\n", *(str + 1)); // e printf("%c\n", *(str + 4)); // o
因为char是1个字节,所以指针加1就是往后挪一个字符的位置。这比int数组简单多了——int数组加1是跳4个字节,char数组加1就是跳1个字节。
经典例题:手写字符串拷贝
咱们用指针来实现strcpy,你感受一下指针操作字符串的写法:
#include <stdio.h> void my_strcpy(char *dest, char *src) { while (*src != '\0') { *dest = *src; dest++; src++; } *dest = '\0'; // 别忘了结尾的\0 } int main() { char src[] = "Hello World"; char dest[50]; my_strcpy(dest, src); printf("%s\n", dest); // 输出 Hello World return 0; }
拆开看执行过程:
-
src指向'H',dest指向目标数组开头。 -
*dest = *src把'H'拷过去,然后两个指针都往后走一格。 -
重复这个过程,直到
*src是\0。 -
循环退出后,在
dest当前位置补上\0。
这个写法比用下标写更直观——你能看到两个指针在内存里同步往前挪。
还有更简练的写法,把赋值和移动合并:
void my_strcpy(char *dest, char *src) { while ((*dest++ = *src++) != '\0'); }
这行代码的玄机:先把*src赋给*dest,两个指针都自增,然后判断赋完的值是不是\0。能看懂就行,平时写代码不推荐这么炫技。
经典例题:手写字符串比较
用指针比较两个字符串,逐个字符比过去,遇到不一样的或者遇到\0就出结果:
#include <stdio.h> int my_strcmp(char *s1, char *s2) { while (*s1 != '\0' && *s2 != '\0') { if (*s1 != *s2) { return *s1 - *s2; } s1++; s2++; } return *s1 - *s2; } int main() { printf("%d\n", my_strcmp("Hello", "Hello")); // 0 printf("%d\n", my_strcmp("Apple", "Banana")); // 负数 printf("%d\n", my_strcmp("World", "Hello")); // 正数 return 0; }
*s1 - *s2返回的是两个字符ASCII码的差值,正数表示s1大,负数表示s1小,零表示相等。跟库函数strcmp的行为一致。
指针数组 vs 数组指针——这俩容易搞混,提前说清楚
字符串处理里经常碰到一个场景:我要存一堆字符串,比如5个名字。怎么存?
方式一:二维数组
char names[5][20] = {"Alice", "Bob", "Charlie", "David", "Eve"};
这相当于5行20列的表格,每行存一个名字。缺点是每行都固定20个字符,短名字浪费空间,长名字存不下。
方式二:指针数组
char *names[5] = {"Alice", "Bob", "Charlie", "David", "Eve"};
这是个数组,里面有5个元素,每个元素都是一个char *指针,分别指向一个字符串常量。
这两种方式用起来很像:
printf("%s\n", names[0]); // 都能输出 Alice
但内存布局完全不同。方式一是在一个连续的二维数组里存实际字符,方式二是一个指针数组,指针指向常量区的字符串。
指针数组的好处是灵活——每个字符串长度可以不一样,不用预先分配固定大小的空间。你甚至可以在运行时让这些指针指向不同的字符串:
char *names[3]; names[0] = "Alice"; names[1] = "Bob"; names[2] = "Charlie";
几个常见的坑
坑1:试图修改字符串常量
char *str = "Hello"; str[0] = 'h'; // 崩溃!字符串常量在只读区
正确做法:如果你需要修改,用数组。
char str[] = "Hello"; str[0] = 'h'; // 安全,数组里的内容可改
坑2:字符串指针没初始化
char *str; strcpy(str, "Hello"); // 危险!str指向未知地址
指针没指向合法内存之前,不能往里写东西。
坑3:混淆char *str和char str[]
char *s1 = "Hello"; char s2[] = "Hello"; printf("%lu %lu\n", sizeof(s1), sizeof(s2)); // 输出 8 6(指针大小 vs 数组大小)
sizeof(s1)算的是指针的大小(8字节),sizeof(s2)算的是整个数组的大小(6字节,含\0)。
坑4:用==比较字符串指针
char *s1 = "Hello"; char *s2 = "Hello"; if (s1 == s2) { // 比的可能是地址,不是内容 // ... }
编译器可能会把相同的字符串常量合并到同一个地址,所以s1 == s2有时候是true,有时候是false。比较内容用strcmp,永远用strcmp。
总结一下
用指针操作字符串,核心就几句话:
字符串是以\0结尾的字符数组,数组名就是地址,所以指针可以直接指向字符串。
遍历字符串,就是让指针从开头走到\0。
char *p = "Hello"指向只读的字符串常量,不能改;char str[] = "Hello"是数组,可以改。
指针数组可以存多个字符串,每个指针指向一个独立的字符串。
char *s和char s[]在函数参数里等价,都是指针。
字符串处理是C语言里最常用的操作之一,指针操作字符串又是最自然的方式。你把这节课的内容吃透了,以后看到while (*p)、p++这种代码就不会发怵了。
回去自己动手,把字符串拷贝、比较、连接的指针版本都敲一遍。跑出正确结果之后,再故意改错——比如忘了补\0、指针不初始化——看看程序会出什么问题。错误见多了,你就知道该怎么写了。下节课咱们讲指针和函数的更深层关系——函数指针。那玩意儿才开始有点绕,但你现在把基础打好了,到时候就不怕。