Skip to content

指针、数组、字符串的统一视角:一套 char* 遍历法吃三者

引言:三条线收拢到一个指针视角

走到这里,我们手里有三条线:第 10 章说数组名会退化成指向首元素的指针、第 11 章说 C 字符串就是 \0 结尾的 char 数组、阶段2 前 4 章把指针本身讲透了。这一章就把它仨拧成一股——一旦想通「遍历一段连续 char 只要用一个 char* 往后走、遇到 \0 停」,数组、字符串、指针就不再是三件孤立的事,而是同一套手法在不同场合的应用。这一章我们会用这套手法做两件最经典的事:手写 strlen、手写 strcpy——它们就是标准库 <string.h> 那些函数的真身(第 11 章我们用过、但没看它怎么实现),看懂它们,你才算真正拿下了 C 字符串。

用 char* 遍历字符串

C 字符串处理的头号模式,是拿一个 char* 指向字符串开头、然后一边处理 *p 一边 p++ 往后走,直到撞上 \0(值为 0、循环条件为假):

c
#include <stdio.h>

int main(void) {
    char s[] = "hello";
    char* p = s; /* p 指向首字符 */
    while (*p) { /* 遇到 \0(值为 0)就停 */
        printf("%c ", *p);
        p++; /* 走到下一个字符 */
    }
    printf("\n");
    printf("走过的字符数 = %td  (p - s)\n", p - s); /* 指针减法 */
    return 0;
}
text
$ gcc -std=c11 -Wall traverse.c -o tv && ./tv
h e l l o 
走过的字符数 = 5  (p - s)

char* p = s;p 指向 s[0](数组名 s 退化为首元素地址,第 10 章)。while (*p) 的条件是「*p 不为 0」——*pp 指向的字符,遇到 \0(编码为 0)时条件为假、循环退出,其余字符('h''e'…)编码非 0、继续。每轮 printf("%c ", *p) 打印当前字符、p++p 走到下一个字符(char* 加 1 跨 1 字节,正好一个 char,第 2 章)。循环跑完打印出 h e l l o,这时 p 已经走到了 \0 那个位置。

最后一行 p - s 用上了第 2 章的指针减法p 走到末尾、s 还在开头,两者相减得到「中间隔了几个 char」——也就是走过的字符数 5(不含 \0)。这就是字符串长度的本质:首指针走到末指针、求差。把这个想法封进函数,就是 strlen

手写 strlen

标准库 strlen(第 11 章用过)干的就是上一节那件事——走到 \0、返回走过的字符数。我们自己写一遍:

c
#include <stddef.h>
#include <stdio.h>

/* 手写 strlen:用指针走到 \0,返回走过的字符数(不算 \0) */
size_t my_strlen(const char* s) {
    const char* p = s;
    while (*p) {
        p++;
    }
    return (size_t)(p - s);
}

int main(void) {
    printf("my_strlen(\"hello\") = %zu\n", my_strlen("hello"));
    printf("my_strlen(\"\") = %zu\n", my_strlen(""));
    return 0;
}
text
$ gcc -std=c11 -Wall my_strlen.c -o msl && ./msl
my_strlen("hello") = 5
my_strlen("") = 0

my_strlen 的参数是 const char* s——const(第 4 章)表示「我只读不改这个字符串」,因为求长度不需要动它;const char* 而不是 char* 让调用者放心传字符串字面量这类只读数据。函数体里 const char* p = s; 从头开始、while (*p) p++; 走到 \0return (size_t)(p - s); 把指针差(ptrdiff_t)转成 size_t 返回。"hello"5、空串 ""(第一个字符就是 \0)得 0——和标准库 strlen 完全一致。看,所谓的「字符串长度」没有任何魔法,就是「让指针从头走到尾、算差」。

手写 strcpy:经典的一行

strcpystrlen 更能体现 C 字符串处理的精髓。它要把源串(连同末尾 \0)逐字符拷到目标缓冲区。标准库的实现、以及几乎所有 C 教科书,都会写成下面这种密集到让人瞳孔地震的一行:

c
#include <stdio.h>

/* 手写 strcpy:逐字符拷贝,连末尾 \0 一起拷过去 */
char* my_strcpy(char* dst, const char* src) {
    char* ret = dst; /* 记住起始位置,等会儿返回 */
    while ((*dst++ = *src++) != '\0') {
        /* 经典一行:取 *src 赋给 *dst、两者各自后移一位,
           整个赋值表达式的值就是刚拷的字符,等于 \0 时循环结束 */
    }
    return ret;
}

int main(void) {
    char buf[10];
    my_strcpy(buf, "hello");
    printf("buf = %s\n", buf);
    return 0;
}
text
$ gcc -std=c11 -Wall my_strcpy.c -o msc && ./msc
buf = hello

my_strcpy(buf, "hello")"hello"(连同 \0)拷进了 buf,打印 buf = hello。关键是 while ((*dst++ = *src++) != '\0') 这一行的求值——把它拆开(C 的赋值表达式「先赋值、整个表达式的值就是刚赋的那个值」,§6.5.16):

  • *src:取 src 当前指向的字符;
  • *dst = ...:把这个字符赋到 dst 当前指向的位置;
  • dst++src++:两个后置 ++,各自在「被使用之后」让指针后移一位(char* 加 1 跨一个字符);
  • 整个 (*dst++ = *src++) 是一个赋值表达式,它的值就是刚拷过去的那个字符
  • != '\0':判这个字符是不是 \0,不是就继续、是就退出。

所以每一轮:拷一个字符、两个指针各前进一位、判刚拷的字符。当 src 走到末尾的 \0、它被拷进 dst 后,!= '\0' 为假、循环退出——而此时 \0 已经被拷过去了,目标串正好是个合法的 C 字符串。这里 dstsrc 是两个不同的指针对象、各自自增,没有「同一对象既读又改」的冲突,所以这个写法是定义良好的(§6.5p2 不冲突),是几十年来 C 代码里的经典惯用法。char* ret = dst; 在最开头记下 dst 的起始位置,因为循环里 dst 一直在往后走、函数最后要 return ret; 把目标串的起点还给调用者(标准库 strcpy 的返回值就是这个约定)。

理解了 strlenstrcpy 这两个的手写实现,第 11 章那一大家子 <string.h> 函数(strcatstrchrstrstr…)就都不神秘了——它们全都是「拿 char*\0 终止的字符序列上做某种模式匹配或搬运」,套路一样。工程里你当然直接用标准库(它们经过高度优化、很多是手写汇编),但「能看懂手写版」是你真正理解 C 字符串的及格线。

三者统一的指针视角

把前面几章串起来,你会发现「数组、字符串、指针」在 C 里是同一件事的三张面孔。第 10 章我们说数组名退化为指向首元素的指针——所以 char s[]char* p = s 在「用下标 s[i]/p[i]」、「用指针算术 *(s+i)/*(p+i)」上是等价的(第 10、2 章)。第 11 章我们说字符串字面量 "hello" 本身就是个 char*(落在只读存储区)。所以无论是「char 数组」、「指向 char 数组的指针」、还是「字符串字面量」,只要它是一段以 \0 结尾的连续 char,就能用上面这套 char* 遍历法处理——while (*p) { ...; p++; } 这一招通吃三者。

唯一的差别仍是第 11 章那个老坑:char a[] = "hi" 是把字面量拷贝到栈上的可改副本,而 char* p = "hi" 是让 p 指向只读的字面量本身。用 char* 遍历(只读)两者都安全;但想,只能在 char[] 那份副本上改,改字面量(char* 指向的那个)是 UB、会段错误(第 11 章真跑过退出码 139)。所以「用 const char* 接收字符串参数」(第 4 章)就成了通用约定——它兼容「char 数组」和「字面量」两种实参、又明确「我只读不改」,标准库所有字符串函数的参数(strlen(const char*)strcpy(char*, const char*))都是这个套路。下一章我们把字符串和动态内存接上——用 malloc 要一块堆内存、拷个字符串进去,做出运行期才知长度的可变字符串。

小结

C 的数组、字符串、指针是同一件事的三张面孔,统一的处理手法是「char* 遍历」:拿 char* p 指向开头、while (*p) { 处理 *p; p++; }、遇 \0(值为 0)停(真跑逐字符打印 h e l l o),指针减法 p - s 得走过的字符数(真跑 5,第 2 章)。这套手法封进函数就是标准库:手写 strlenconst char* p 走到 \0return (size_t)(p - s)(真跑 "hello"→5、""→0,和标准库一致),const char* 参数表示「只读不改」。手写 strcpy 的经典一行 while ((*dst++ = *src++) != '\0') 把「取 *src、赋 *dst、两指针后置自增各走一位、赋值表达式的值=刚拷的字符、判 \0」五件事压进一个表达式(§6.5.16 赋值表达式有值),拷到 \0 正好退出、\0 已带过去(真跑拷出 buf = hello);dst/src 是不同对象各自自增、无读改冲突,定义良好。看懂这两个手写版,第 11 章的 <string.h> 全家(strcat/strchr/strstr…)都不神秘——都是「char*\0 终止序列上做匹配/搬运」。三者统一的指针视角:char[](栈上可改副本)、char*(指向首元素)、字符串字面量(只读 char*)用 char* 遍历是同一套,唯一差别仍是 char[] 可改、char* 指字面量改了 UB(第 11 章段错误 139),所以「字符串参数一律 const char*」是兼容两者又自文档的通用约定(标准库 strlen(const char*)/strcpy(char*, const char*) 都这么签名)。下一章接动态内存:malloc 出堆缓冲区、拷字符串进去、做可变长字符串。

参考资源

  • ISO/IEC 9899:2011 §7.1.1(字符串定义:连续字符到首个 \0)、§6.5.6(指针加减与减法:p-s 得 ptrdiff_t)、§6.5.16(赋值表达式「值=左操作数赋值后的值」)、§6.5.2.1(p[i]≡*(p+i))、§6.3.2.1p3(数组退化为指针)
  • K. N. King《C Programming: A Modern Approach》第 13 章 Strings(用指针遍历字符串、char* vs char[])、第 12 章(指针处理数组/字符串)
  • Robert C. Seacord《Effective C》第 7 章(字符串遍历、<string.h> 实现、char* 与字符数组)
  • Brian W. Kernighan & Dennis M. Ritchie《The C Programming Language》第 2 版第 5 章(指针与数组、手写 strcpy 的经典一行)
  • 第 10 章:数组(退化)、第 11 章:C 字符串(\0char[] vs 字面量、<string.h>)、阶段2·第1/2/4章(指针、算术、const)
  • 阶段2·第6章:动态内存(malloc 出字符串缓冲区)、第11章:void* 与字节操作(memcpychar* 当字节流)