ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言基础第8讲:数组指针函数与字符串如何协同工作

C语言基础第8讲:数组指针函数与字符串如何协同工作 进入C语言基础概念系列第八篇咱们聊的东西就不该再是单个语法点而是一组语法点怎么在程序里真正配合起来。很多初学者学到这个阶段都会有一种感觉变量、循环、函数、数组单独拿出来都懂但一写综合点的作业就卡住指针和数组混在一起就发蒙字符串操作更是一碰就乱。这篇文章想解决的就是这个问题我会用一个“学生成绩统计”的小案例把数组、指针、函数、字符串和内存视角串起来顺带把文件读写、编译告警这些日常会用到的内容也补上。适合的人群是已经学完基本语法、正在往进阶过渡的C语言学习者也适合那些回过来查漏补缺的开发者。1. 第8讲的主线把零散语法点串成程序能力1.1 案例目标一个成绩统计小工具为了不泛泛地讲“数组和指针”我们这一篇从头到尾围绕一个具体目标展开从文件或键盘读入若干学生的成绩统计最高分、最低分、平均分并输出所有高于平均分的学生编号。这个案例看似简单但仔细拆解下来它几乎覆盖了C语言基础阶段的全部核心内容数据怎么存储数组、数据怎么在函数间传递指针和数组参数、输入输出怎么处理scanf/fgets/fprintf等、调试时怎么发现越界和内存问题。我带的很多初学者都觉得基础概念应该一个一个背但实际上最有价值的学习方式是拿一个小目标把这些概念拧在一起每用一次就理解得深一层。1.2 前置基础与系列定位如果你是第一次看到本系列文章建议至少先掌握前三篇的内容基本数据类型与运算符、三种程序结构顺序/分支/循环、一维数组的声明与遍历。函数和指针如果只停留在“眼熟”的程度也可以读因为本讲会把它们再拆开讲一遍。这篇“基础概念8”在系列里的定位是“地基收尾和综合起步”变量作用域、流程控制、数组、基础指针这些建筑材料已经介绍完了从这一讲开始你应该从“认识语法”转向“用语法实现功能”。之后的第九、第十篇再往上走就可以看链表、动态内存管理和模块化工程组织了。1.3 为什么从这里开始打通数组、指针和函数我先说一个判断C语言初学者遇到的最大障碍不是指针本身难懂而是指针、数组和函数在内存层面互相纠缠。数组名到底是不是指针为什么函数里改不了外边的变量为什么字符串明明是一串字符却总有人说它是字符数组如果只用“背概念”的方式去记很容易陷入死记硬背。但如果在同一个程序里你亲自用指针去遍历数组、用数组参数向函数批量传递数据、再因为漏写一个取地址符而崩溃一次这些概念之间的边界会瞬间清晰。本文讲的顺序就是按这条串起来的主线推进的。2. 数组与指针从仓库和门牌号理解内存与下标2.1 数组名和指针不是一回事但关系密切先说结论数组名在绝大多数表达式中会“退化”成指向首元素的指针但数组名本身不是指针变量。怎么理解这句话可以把内存想象成一个大型仓库数组是一排连续编号的货架数组名就是这排货架的门牌号指针则是一张写着门牌号的便签你可以把便签改写成别的门牌号但不能修改仓库本身的编号。下面这段代码非常直观#include stdio.h int main(void) { int arr[5] {10, 20, 30, 40, 50}; int *p arr; // 数组名 arr 退化为指向 arr[0] 的指针 printf(sizeof(arr) %zu\n, sizeof(arr)); printf(sizeof(p) %zu\n, sizeof(p)); return 0; }在一台64位机器上用 gcc 编译运行输出会是sizeof(arr) 20sizeof(p) 8。如果觉得数组名就是指针看到这个结果就会困惑同样指向那排数据为什么前者算出来是20字节、后者只有8字节因为sizeof(arr)求的是整个数组对象的字节数sizeof(p)求的是指针变量本身的大小。这个例子也提醒我们即使在“数组名可以当成指针用”的场景里数组和指针在类型层面依然是两个东西。2.2 下标是语法糖编译器眼里只有地址偏移很多人学了指针之后会纠结一个看起来很哲学的问题a[i]和*(a i)哪个才是“本质”。其实在C语言标准里下标运算就是通过指针运算定义的a[i]等价于*(a i)。编译器不会区分你是不是写了方括号它只负责计算“首地址 i × 元素大小”然后取出那块内存里的数据。也正因为如此代码里会出现一些让新手觉得匪夷所思但语法合法的写法#include stdio.h int main(void) { int arr[4] {1, 2, 3, 4}; printf(%d\n, arr[2]); // 常规写法 printf(%d\n, 2[arr]); // 等效写法*(2 arr) return 0; }2[arr]能编译能运行本质原因是下标运算满足加法交换律2[arr]最终被解析成*(2 arr)。实际项目里没人会这么写但理解这一点能帮你真正消除对指针的恐惧方括号不是数组的专属魔法它只是“从某个起始地址偏移若干元素”的简写。一维数组传参是另一个高频考点。把数组传给函数时实参那边的数组名退化成指针所以函数形参可以写成int a[]也可以写成int *a二者完全等价。但要注意形参里的int a[]并不会真的复制一份数组它只是告诉读代码的人“我希望你传一个数组过来”底层收到的仍然是个指针。这就解释了为什么在函数内部用sizeof(a)算不出数组长度因为这时a已经是指针了。2.3 二维数组的传参为什么必须交代第二维二维数组比一维数组更容易把人绕晕。很多初学者写出这样的函数void printMatrix(int m[][], int rows) { // 想遍历 rows 行 }然后编译直接报错原因是形参里第二维不能为空。要知道为什么得回到地址计算m[i][j]的真实地址是(char *)m i * cols * sizeof(int) j * sizeof(int)如果函数不知道cols它就没法算出第 i 行的起点。所以二维数组当参数时第二维必须明确写出来第一维则可以被省略因为遍历时我们不需要靠它计算行内偏移。#include stdio.h void printMatrix(int m[][4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%4d, m[i][j]); } putchar(\n); } } int main(void) { int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; printMatrix(matrix, 3); return 0; }这里matrix传入函数后形参m的类型是“指向包含4个int的数组的指针”。如果把它理解成int **那是不对的。int **表示“指向指针的指针”它指向的每个元素都是指针而int (*m)[4]指向的每个元素都是“一整行数组”。这两类类型在内存布局和寻址方式上完全不同混用是初学者最容易踩的坑。2.4 越界不报错不代表安全用编译器工具兜底C语言的运行模型是信任程序员的数组越界时编译器通常不会给你任何提示因为下标运算最终只是地址计算语言标准并没有强制要求运行时检查。问题是越界写坏的数据可能不会立刻崩溃而是等函数返回时把栈上保存的返回地址也冲掉程序才在一个毫无关联的位置崩溃。这种“延迟爆炸”最让人头疼调试时往往找不出真正的错误点。我自己排查越界问题时的经验是三步走。第一步编译时开-Wall -Wextra让编译器先帮你抓明显类型问题第二步如果程序能跑但没有规律地崩溃用 AddressSanitizer 重编一次gcc -fsanitizeaddress -g demo.c -o demo ./demoAddressSanitizer 会在数组越界、栈缓冲区溢出、堆溢出等错误发生时直接报告出错位置省去大把瞎猜时间。第三步如果手头环境不好装额外工具就在关键循环里写断言或打印访问的下标范围把可疑的索引先暴露出来。3. 字符串处理字符数组的约定、读入与逆序3.1 C语言没有真正的字符串类型很多从Python、Java转过来学C的人第一反应是问“C语言的String类型在哪”。答案是C语言没有内建字符串类型我们说的字符串本质上是一个以空字符\0结尾的字符数组。你可以把字符串理解成一种“约定”比如一个char数组里存了H,e,l,l,o,\0它就可以作为字符序列被printf(%s, ...)输出也可以被各种str开头的库函数处理。这个约定带来两个直接影响第一处理字符串时必须时刻想到结尾多占用的那个\0申请空间时写成char buf[5]; strcpy(buf, hello);就会越界因为hello实际需要6个字节。第二所有字符串函数都不知道“数组有多长”它们只能从首地址开始逐个往后找直到看见\0。如果字符串没正确结尾strlen可能扫过合法区域继续读内存形成未定义行为。这是很多隐藏bug的来源。3.2 字符串字面量能不能修改一个让新手栽跟头的细节再看两种初始化字符串的方式char str1[] hello; char *str2 hello; str1[0] H; // 合法str1 是本地字符数组内容可改 str2[0] H; // 未定义行为str2 指向的是只读区hello这个字符串字面量在程序里通常被放在只读数据段。char *str2 hello;是把字符数组首地址交给指针理论上你可以通过指针下标去读但不应通过它去写。这里的判断标准很简单如果字符串放在数组里字符是可修改的如果只是被指针指着那就默认它是只读字符序列。工程上建议把这类指针声明成const char *str hello;一旦误写编译器就会立刻报警告而不是等到运行期崩溃。3.3 用 fgets 和 getchar 处理输入时的“残留换行”新手写交互式程序时最经典的一个坑是这样的#include stdio.h int main(void) { int age; char name[50]; printf(请输入年龄); scanf(%d, age); printf(请输入姓名); fgets(name, sizeof(name), stdin); printf(姓名是%s\n, name); return 0; }运行后你会发现姓名还没来得及输入程序就输出了。原因是scanf(%d, age)读取数字后输入缓冲区里还留着一个用户按回车产生的换行符\n随后的fgets直接把这个换行符读走了。解决办法也很常见在fgets之前用while (getchar() ! \n);清掉当前行剩余内容。反过来如果使用scanf读取字符串也要注意它会在空白字符处停下无法读取带空格的整句话。需要读取包含空格的文本行时fgets是更合适的选择。fgets(name, sizeof(name), stdin)的第二个参数告诉函数缓冲区有多大它最多读入sizeof(name) - 1个字符并自动在末尾补上\0比不限制长度的gets安全很多gets已经在标准里被移除不要在新代码里再使用。3.4 两个经典练习字符串逆序和“梦中的统计”“字符串逆序”几乎是每个C语言学习者都写过的练习题很多在线题库也爱出。它本身不难但能帮你训练双指针思想#include stdio.h #include string.h void reverse(char s[]) { int left 0; int right strlen(s) - 1; while (left right) { char tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } } int main(void) { char str[] hello c; reverse(str); puts(str); return 0; }另一个很有代表性的题目是“统计一组数据中各个数字出现的次数”这个题目在洛谷等题库里经常出现。这类题的通用解法不是对每个数字做一次线性查找而是用一个长度为10的计数数组把数字本身当成下标#include stdio.h void countDigits(int n, int cnt[]) { while (n 0) { cnt[n % 10]; n / 10; } } int main(void) { int cnt[10] {0}; countDigits(20240517, cnt); for (int i 0; i 10; i) { printf(%d: %d\n, i, cnt[i]); } return 0; }这个思路看起来幼稚但它是“数据值即索引”的典型应用后面学到哈希表时你会发现同一个套路被放大了无数倍。很多基础概念之间的连接就是这么建立的。4. 函数调用机制与内存生命周期值传递和悬垂指针4.1 从 swap 看值传递、指针参数和“引用模拟”C语言里函数参数的传递方式只有按值传递一种。这句话需要强调一下所有参数包括指针都是把实参的值复制一份给形参。区别只在于如果复制的值本身是个地址函数就能通过这个地址去修改外部变量。看下面两个函数#include stdio.h void swap_fail(int a, int b) { int tmp a; a b; b tmp; } void swap_ok(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main(void) { int x 3, y 5; swap_fail(x, y); printf(after swap_fail: x%d, y%d\n, x, y); swap_ok(x, y); printf(after swap_ok: x%d, y%d\n, x, y); return 0; }swap_fail的问题在于它把x和y的值复制给了自己的局部变量a和b交换的是自己的局部变量函数结束就没了。swap_ok则传入x和y的地址函数内部通过*a和*b修改的是调用者的原始变量。只要把这个例子彻底搞懂指针参数为什么存在就完全清楚了它本质上是让函数拥有修改外部数据的能力也就是许多其他语言里“引用传参”的底层实现方式。4.2 千万别把局部数组地址返回给上层新手写函数时很容易写出类似这样的代码#include stdio.h char *getMessage(void) { char buf[64]; snprintf(buf, sizeof(buf), hello c); return buf; // 危险 } int main(void) { char *msg getMessage(); printf(%s\n, msg); return 0; }这段代码编译时可能会有警告运行结果则不确定有时能打印出正确内容有时打印出乱码有时程序直接崩溃。原因在于buf是getMessage函数的局部数组它在栈上分配。函数返回时这块栈内存就“失效”了理论上已经不允许再访问。虽然内存里的字节可能还没被覆盖所以偶尔能读出旧值但这种行为属于典型的悬垂指针问题。正确做法有三种。第一种把缓冲区定义在函数外面由调用者传入char *getMessage(char *buf, size_t size) { snprintf(buf, size, hello c); return buf; }第二种在函数内部使用malloc分配堆内存然后返回指针但调用者用完必须freechar *getMessage(void) { char *buf malloc(64); if (buf NULL) { return NULL; } snprintf(buf, 64, hello c); return buf; }第三种把缓冲区声明成static。用static修饰函数内的局部数组后数组生命周期会延长到整个程序运行期函数返回后指针仍然有效。但这也带来一个问题每次调用都会复用同一块内存下一次调用会覆盖上一次的内容所以在多线程环境下不能用这种写法。实际工程里第一种“调用者负责提供缓冲区”是最常见、最安全的设计思路理由很简单谁分配内存谁负责释放所有权清楚。4.3 栈与堆生命周期是基础概念里最值得补的一课说到局部变量和malloc就得把栈与堆的区别说透。栈上的变量由编译器自动分配和回收特点是分配取消极快、有严格的作用域限制比如局部数组、局部变量堆上的变量由开发者通过malloc申请生命周期由开发者控制必须手动free释放否则会内存泄漏。有个类比很直观栈像是借来的临时工位你上班期间可以使用下班离开后工位会被清理并分配给下一个人堆像公司租的独立仓库你和仓库公司签了合同想用多久用多久但合同里明确写了一旦不再使用必须退租否则费用会一直算下去。对这个“所有权”没有足够的敏感度写出的C程序就会在大型项目中表现出各种内存泄漏和释放后使用的问题。5. 函数指针和回调把“一段逻辑”当成参数来传递5.1 函数指针如何读写从“指着函数的指针”练起常规指针存储的是变量的地址函数指针存储的则是函数的入口地址。声明一个函数指针时只要把原来的函数名替换成(*指针名)即可。比如int add(int a, int b) { return a b; } int (*fp)(int, int) add; int result fp(2, 3); // 通过函数指针调用 int result2 (*fp)(2, 3); // 古老的等价写法fp的类型是int (*)(int, int)表示“指向一个返回 int、接收两个 int 参数的函数的指针”。多数现代代码里直接用fp(2, 3)调用就行。这一语法起初看着别扭但它带来的抽象能力很强因为函数名本身也能像变量一样被传来传去。5.2 用 qsort 理解泛型回调与 void*C标准库里的qsort是理解“函数作为回调参数”最经典的素材。它的原型是void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));最后一个参数就是函数指针qsort自己不关心你要排序的是整数还是结构体它只知道每次需要比较两个元素时就调用你提供的compar函数。因为这个设计同一种排序函数可以复用到各种数据类型上。假设要对 int 数组升序排序比较函数这样写#include stdio.h #include stdlib.h int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } int main(void) { int nums[] {5, 2, 9, 1, 7}; size_t n sizeof(nums) / sizeof(nums[0]); qsort(nums, n, sizeof(nums[0]), cmp_int); for (size_t i 0; i n; i) { printf(%d , nums[i]); } putchar(\n); return 0; }这里比较函数收到的是两个const void *指针指向数组中的两个元素。因为void *是没有具体类型的指针所以在比较函数内部必须先把它们转换回const int *再解引用取值。不要返回x - y当 x 和 y 差距较大时可能溢出改成先比较大小再返回 -1/0/1 更稳妥。在自己封装排序、查找这类通用工具时也可以仿照qsort的设计把“同一套流程”和“具体的比较方式”解耦。这就是“把逻辑当成参数传递”的最朴素价值。5.3 后面可扩展的方向状态机与面向对象思想的雏形函数指针在嵌入式、底层驱动、图形界面库里出现频率很高常见场景就是“登记事件回调”比如把某个按键事件的处理函数注册进一个结构体主循环不清楚具体按键逻辑只负责按表调用。这已经隐隐有了“把数据和操作绑在一起”的味道。很多人搜过“C语言面向对象编程”相关的资料发现C语言居然也能写出类似类继承的效果。其核心手法就是用结构体保存状态用函数指针数组保存一组行为。比如struct operation { int (*exec)(int, int); }; struct operation add_op { add }; struct operation mul_op { mul };这就是很粗糙的多态模型。本篇不展开完整实现但了解这种用途后再回头学结构体和函数指针就会明白这些基础语法为什么会存在也知道把语法学活之后能走到多远。6. 编译告警、常见错误与排查实录6.1 编译输出 unreferenced label 警告怎么办有初学者用gcc编译代码时见到unreferenced label end这样的提示中文意思就是“存在一个从未被 goto 跳转过的标签”。原因通常是写 goto 时把标签位置放错了或者删除了 goto 语句但忘了删标签。解决起来很简单找到提示里的标签名看代码里是否还有对应的goto 标签;如果没有直接把标签行删掉即可。比如error_handling: return -1;如果代码里根本没有goto error_handling;那这个标签就是多余信息留着只会污染代码。这里也提醒一个习惯工具链给出的每一行警告都值得看一遍。C编译器不像有些语言的解释器那么友善警告往往意味着代码里存在未定义行为或逻辑漏洞忽略它等于把定时炸弹埋到后面的维护期。6.2 for 循环执行顺序的“1243”理解法很多讲解循环的资料会把 for 循环的执行顺序归纳成“1243”这四个编号1是初始化表达式2是条件判断4是循环体3是每次循环结束后的更新表达式。按这个顺序循环执行时第一次进入时会执行1然后判断2成立则执行4再执行3回到2再次判断如此反复。这个规则初看很机械但排错时非常有用。举一个经常出错的例子for (int i 0; i 10; i) { printf(%d , i); }输出结果是0 1 2 3 4 5 6 7 8 9因为循环先判断i 10为真才进循环体打印完 i 后执行i再回来判断。如果把条件写成i 10或者更新写成i 2按同样的顺序推导就能知道结果会变成什么样。理解这个执行顺序还能帮助你判断“循环结束后 i 到底是几”这一类容易混淆的问题。6.3 常见运行时错误速查表与现场排查思路把带初学者这几年的经验汇总一下下面几个问题出现频率最高现象常见原因处理思路scanf 后变量值没变漏写取地址符如scanf(%d, num)改为scanf(%d, num)字符串相等判断总是失败错误使用比较两个字符串使用strcmp或strncmp程序打印到一半崩了字符串没有以\0结尾检查strncpy、sprintf后的结尾输入姓名时被跳过前一次scanf留下了换行符while (getchar() ! \n);函数返回后内容变了返回了局部数组或局部变量地址改用调用者传入缓冲区而不要返回局部地址编译报 unreferenced label存在没有对应 goto 的标签删除多余标签或补上对应跳转越界访问却查不出来数组越界产生延迟可见错误用-Wall和 AddressSanitizer 复现其中我特别想强调一下strncpy的坑。它在复制长度不足时不会自动补\0如果来源字符串比目标缓冲区长目标数组可能变成一个没有结束符的字符序列。更安全的做法是用snprintf(buf, sizeof(buf), %s, src)它在空间允许的情况下总会正确添加\0行为更一致。6.4 文件读写如何融入这套基础思维很多人学到文件读写时会把它当成一个独立模块来记但其实它完全可以被纳入“数组、指针、缓冲区”的统一模型里。比如用fgets从文件读一行和从键盘读一行的处理方式一模一样只是第一个参数从stdin换成FILE *fp。写一个把成绩写入文件的基础例子#include stdio.h int main(void) { FILE *fp fopen(scores.txt, w); if (fp NULL) { perror(fopen); return 1; } int scores[] {78, 85, 92, 60}; size_t n sizeof(scores) / sizeof(scores[0]); for (size_t i 0; i n; i) { fprintf(fp, %zu %d\n, i, scores[i]); } fclose(fp); return 0; }初学者最容易遗漏的是fopen的返回值检查。在一些嵌入式或老式环境中文件打不开是常态直接使用空指针会导致崩溃。判断fp NULL后用perror打出具体原因是一种成本极低但极其有效的防御式编程。读文件时的思路类似用fgets按行读取然后sscanf解析每行数据和从键盘上读入相比只差一个数据来源参数。这篇内容我特意把范围卡在一个可以动手写完的程序里。如果你跟着案例把成绩统计的完整版本写一遍再试着加入“从文件读入”“按平均分筛选”“统计不及格人数”这些扩展点会发现之前零散记下的概念会真正开始变成你自己的思路。那之后再翻指针和字符串的参考书观感会和现在完全不同——语法还是那些语法但你已经有足够的内存模型来理解它们为什么这么设计了。
返回列表