☰
顺序表通讯录进阶:修复输入与实现文件存储
2026/9/28 13:02:03 网站建设 项目流程

上次那篇我们把顺序表的基本框架搭起来了,通讯录的基础功能——添加、删除、显示——跑通了,当时的程序确实能运行,菜单一按,联系人能存进去也能列出来。但说句实话,那种程度只能叫“完成了作业”,离真正能用的通讯录软件还有不小的距离。当时文章评论区里也有不少人在问:为什么我输入电话号码时中间有个空格,后面就全乱了?为什么程序一关,刚才录的十几个人全没了?这其实不是代码写错,而是顺序表通讯录做到“能用”这个阶段,必然会撞上的一批现实问题。

这篇续篇就围绕这些问题来补刀,核心目标有三个:第一,把输入那块打磨干净,让用户在终端里想怎么输就怎么输;第二,加上文件存储,数据不再因为程序退出而丢;第三,把查找、修改、排序这些日常操作补齐,让顺序表除了“能存”,还能“好用”。如果你正在写数据结构实验报告,或者刚学完顺序表想拿一个小项目练手,这篇可以直接照着改。如果你是考研党在复习408,那这篇文章同样值得看,顺序表的查找、删除、动态扩容,都是常考的点,后面我会带你逐个从代码里看它们的实际样子。

1. 能跑的代码和能用的通讯录,到底差在哪

1.1 上一篇做完的基础功能,如今差什么

先花半分钟回顾一下之前做出来的东西。我们的通讯录基于一个顺序表结构,底层就是一块连续内存里的结构体数组,每个结构体保存一个联系人的姓名、电话、组别什么的。顺序表的基本操作——初始化、尾插、指定位置删除、遍历打印——已经写好了。菜单循环也能正常跑:选1添加,选2删除,选3显示,选0退出。

这个阶段的问题不在于“数据结构没实现”,而在于“用户根本没法正常用”。我举三个最典型的场景,你如果已经上手跑过代码,应该会有感觉。

第一个场景:添加联系人的时候,姓名一栏输入“张三 李四”这种带空格的名字,回车之后发现程序就好像读了一部分,剩下的字符莫名跑到了电话那一栏。第二个场景:输入电话之后,程序显示“请选择操作”,结果你还没按数字,菜单就自动跳了过去,像是有一个看不见的回车偷偷替你做了一次选择。第三个场景:程序里增删改查了一会儿,觉得差不多了,关掉终端重新打开,刚才录进去的人一个都不剩,全在内存里蒸发了。

这三个场景对应三个层面的问题:字符串输入的安全与缓冲区处理、程序的状态持久化、以及通讯录功能闭环的缺失。这篇文章就按这三条线来展开。第一条线解决“输入”的问题,第二条线解决“存储”的问题,第三条线解决“查找、修改、排序”的问题,最后再送你一份常见的踩坑记录。

1.2 本篇的四刀,切在哪些地方

我给这次改造做了个规划,一共四刀。

第一刀,砍掉 scanf 这个不省心的输入函数,换用 fgets + sscanf 的组合,彻底解决空格、回车残留、缓冲区串味这些老毛病。第二刀,给通讯录加文件读写能力,退出前把数据写进一个文本文件,启动时自动加载回来,让数据跨会话存活。第三刀,补充查找、修改、排序三个操作,这三件事在顺序表上各有各的经典实现,我会带着你把每一步的原理和代码对齐。第四刀,处理容量不足和程序健壮性问题,让顺序表在数据量超过初始容量时能自动扩容,同时把内存管理的细节补干净。

这四刀切完之后,你再回头看这个通讯录程序,它就不只是一个数据结构课的作业了,而是一个真正有“产品雏形”的小工具。你可以拿它当数组、指针、文件操作、字符串处理的综合复习材料,也可以只提取其中某一块的代码思路用到别处。接下来我们一刀一刀来。

2. 第一刀:把 scanf 丢掉,换 fgets 接管字符串输入

2.1 为什么通讯录里用 scanf 是个坑

很多教材在讲 scanf 的时候,用的都是最简单的例子,读一个整数、读一个不含空格的单词,看起来一切正常。但通讯录不是这种场景。你让用户输入姓名,凭什么规定人家不能姓“欧阳”名“娜娜”中间带个空格?现实是姓名字符串里完全可能出现空格。而 scanf("%s", name) 的语义恰好是“读到空白字符就停”,所以输入“张三 李四”时,它只读走了“张三”,剩下的“ 李四”还躺在输入缓冲区里,下一个 scanf 读到电话时,就直接把这个残渣吃进去了。

比空格更要命的是回车残留。scanf 在读取数据时,遇到匹配失败或者读完一个数据后,会把后面的换行符留在缓冲区里。比如你连续写了两个 scanf,第一个读字符串,第二个读整数。用户输完字符串敲回车,字符串被正确读走了,但那个回车还留在缓冲区。轮到第二个 scanf("%d") 读整数时,它一看缓冲区开头是个换行符,直接匹配失败返回了,程序不报错,只是整数读了个寂寞,变量保持原来的值或者未初始化的垃圾值。于是菜单像被人按了快进键一样,自己就跳过去了。

这个问题在学C语言的头几个月几乎人人都会踩,但很少有人会停下来想一想:scanf 到底能不能在交互式输入里做到靠谱?答案是它能,但需要你小心翼翼地配合清空缓冲区。可清空缓冲区这件事本身又没有标准函数,于是很多人只能用 getchar() 循环吃掉多余字符。代码丑不说,万一缓冲区里同时有回车又有空格,循环条件写不对,照样翻车。换 fgets 是更省心的路线。

2.2 fgets 的正确读法,以及必须处理的换行符

fgets 的签名是 char *fgets(char *s, int size, FILE *stream),它从 stream 中最多读 size-1 个字符,遇到换行或 EOF 就停下来,并在字符串末尾自动加 '\0'。和 scanf 最大的区别是:fgets 会把整一行都吃进去,包括中间的空格和末尾的换行符。也就是说,不管用户输入“张三”还是“张三 李四”,fgets 都能完整拿走。

但这里有一个新手必踩的坑:fgets 读进来的字符串末尾带着一个 '\n'。如果我们直接把它存进联系人的 name 数组里,那打印的时候倒是看不出来,但在做字符串比较(strcmp)的时候就会翻车——你拿“张三”去和“张三\n”比较,结果永远不等于0。所以拿到 fgets 的结果后,第一步是把末尾的换行符清理掉。我习惯用 strcspn 这个函数来定位换行符的位置,然后把那里替换成 '\0'。

char buf[50]; fgets(buf, sizeof(buf), stdin); buf[strcspn(buf, "\n")] = '\0'; // 找到换行符的位置并替换成字符串结束符

这里有个细节值得说明。strcspn(buf, "\n") 返回的是 buf 中第一次出现 '\n' 的下标,如果没找到,返回的是字符串长度。无论是哪种情况,把它作为下标赋 '\0' 都是安全的。有些人喜欢用 strlen(buf) - 1 然后赋值 '\0',但万一用户输入超长导致 fgets 没读到换行,strlen(buf)-1 指向的就不是换行符而是最后一个字符,会误杀内容。所以 strcspn 的写法更稳。

我一般会把这个操作封装成一个工具函数,避免每次输入都重复一遍:

void read_line(char *dest, int size) { fgets(dest, size, stdin); dest[strcspn(dest, "\n")] = '\0'; }

调用的时候就很简单了:read_line(contact[i].name, sizeof(contact[i].name));。注意,sizeof 在数组上才是安全的写法,如果 got 一个指针,要传长度参数,这就是为什么函数里要留 size 参数的原因。

2.3 菜单整数的读取,也用字符串中转

字符串交给我们刚才的 read_line 解决了,但菜单选择需要读的是整数。现在的问题是,如果直接开一个 int opt; scanf("%d",&opt); 那之前的缓冲区问题又会回来。解决办法也简单:把菜单输入也当成字符串来读,然后用 sscanf 或者 atoi 从字符串里解析出整数。

char line[16]; read_line(line, sizeof(line)); int opt = atoi(line);

用 atoi 的坏处是它无法区分“输入错误”和“输入0”,因为非法输入它返回0,输入0它也返回0。所以严格一点应该用 sscanf 的返回值来判断:

int opt = -1; char line[16]; read_line(line, sizeof(line)); if (sscanf(line, "%d", &opt) != 1) { printf("无效输入,请重新选择。\n"); continue; }

这段代码的含义是:sscanf 从 line 字符串里按 %d 格式提取一个整数,如果成功提取到返回1,没提取到就返回0。这样你就知道用户是不是真的给了一个数。用这个组合,菜单里不管用户输入的是“3”、“3 后面乱敲的东西”,还是直接回车,都不会导致程序失控。菜单输入这关就算彻底过了。

3. 第二刀:给通讯录加上文件存取,让数据不丢

3.1 存储格式:纯文本优先,二进制先放一边

决定做文件持久化时,第一个要拍板的问题是:联系人数据存成什么格式。这个选择直接决定后面读写代码的复杂度和可调试性。

方案一是二进制格式,直接用 fwrite 把整个结构体数组原封不动写入文件。代码量最少,读写也快,但坑很多:结构体里有 char 数组成员时,编译器可能会在成员之间插入填充字节(struct padding),导致同一个结构体在不同编译器甚至同一个编译器的不同优化选项下,写出来的二进制文件字节布局不一致。你今天用一个编译器写的文件,明天换了编译器可能就读不回来了。而且二进制文件你用记事本打开全是乱码,出了问题排查起来很难受。

方案二是纯文本格式,每个联系人占一行或几行,字段之间用分隔符隔开,比如“姓名,电话,组别”。代码量稍微多一点,但文件是明文,你能直接 cat 出来看到保存的内容对不对,出错了也知道去哪改。对于学习阶段的小项目,我强烈推荐文本格式。后面你做大项目的时候再根据性能需求去选二进制。

我的文本格式设计得很简单:每个联系人占一行,字段之间用逗号分隔。保存的时候,一个 fprintf 就能写一行;加载的时候,用 fgets 读整行,再用 sscanf 按逗号解析。注意,为了让 sscanf 能按逗号解析,名字和电话里最好不能出现未转义的逗号,这个限制在通讯录场景下完全可接受。

3.2 保存:把顺序表遍历一遍写进文件

保存函数的逻辑不复杂:打开文件,遍历顺序表里的每个有效元素,按约定格式逐行写入,最后关闭文件。要处理好的只有一个点——打开文件失败的情况,比如磁盘满了或者目录没有写权限,fopen 会返回 NULL,这时候不能直接往 NULL 指针上写数据,否则程序立刻崩溃。

int save_contacts(const char *filename, Contact *list, int count) { FILE *fp = fopen(filename, "w"); if (fp == NULL) { perror("无法打开文件"); return -1; } for (int i = 0; i < count; i++) { fprintf(fp, "%s,%s,%s\n", list[i].name, list[i].phone, list[i].group); } fclose(fp); return 0; }

这里有个经验值得说:fprintf 的格式字符串里,%s 之间用逗号分隔,结尾写一个换行符。这样每条记录正好占一行,后面加载的时候一行一行地读,天然对齐。字段顺序要和加载代码保持严格一致,一旦两个函数之间约定不一致,存进去的数据就乱了。所以我习惯把格式定义成宏,注释里写清楚:

// 存储格式约定:每行 name,phone,group,逗号为分隔符 #define CONTACT_LINE_FORMAT "%s,%s,%s\n"

宏的好处是保存和加载两处都用同一个格式串,改起来也只改一处,不会改漏。

3.3 加载:从文件里逐行还原联系人

加载函数要处理的事情多一点:打开文件、逐行读取、按逗号切分、构造联系人结构体、判断是否已经存满。逐行读用一个 fgets 循环,但这里要小心一个语文题:fgets 是按“读到换行就停”来工作的,所以如果文件最后一行没有换行符,它也会正常返回。循环条件要写成 while (fgets(line, sizeof(line), fp) != NULL)。

按逗号切分,最稳妥的方法是用 sscanf。比如行内容是“张三,13800138000,朋友”,那 sscanf(line, "%[^,],%[^,],%[^,\n]", name, phone, group) 就能把三个字段拆出来。%[^,] 表示“读取所有不是逗号的字符”,这是 C 风格正则里比较常用的一个技巧。

char line[128]; while (fgets(line, sizeof(line), fp) != NULL) { Contact c; if (sscanf(line, "%[^,],%[^,],%[^,\n]", c.name, c.phone, c.group) == 3) { list[count++] = c; } else { printf("警告:第 %d 行格式不正确,已跳过。\n", line_no); } }

注意 sscanf 的返回值是成功转换的参数个数,这里必须是3。如果某一行格式不对,我们应该跳过而不是终止整个加载过程,这样即使文件末尾有一行空行或者手误改出来的坏数据,程序也能尽量恢复已读到的正确内容。不过加载前要记得先检查容量,如果文件很大而容量不够,会出现数组越界。容量问题我们放到第五刀再处理,这里先提个醒。

3.4 让程序自动完成“退出保存、启动加载”

有了保存和加载两个函数,剩下的事情就是把它们挂到程序的生命周期上:程序启动后,先把全局联系人列表从默认文件里 load 一遍;菜单循环正常跑;用户选择退出时,先 save 再退出。为了让用户少点几次操作,最好不要让用户手动指定文件名,而是固定一个默认名字,比如 contact_book.txt。

int main(void) { Contact list[MAX_CAPACITY]; int count = 0; load_contacts(DATA_FILE, list, &count); while (1) { // 菜单 + 分支处理 } save_contacts(DATA_FILE, list, count); return 0; }

这样整个交互就变成了:打开程序,之前存的人都在;改了一通,退出时自动保存;下次再打开,数据无缝衔接。从用户的角度看,通讯录真正变成“长期有效”的东西了。这也是大多数人第一次感受到“程序状态跨会话存在”的时刻,朴素但很关键。

4. 第三刀:补上查找、修改、排序,真正把顺序表用起来

4.1 按姓名查找:strcmp 逐个比对,别用 ==

查找一个联系人,核心是遍历顺序表,逐个用 strcmp 比较名字。注意这里不能写成 if (list[i].name == target),因为在 C 语言里,两个 char 数组之间用 == 比较的是数组首元素的地址,地址不可能相等,所以这种比较永远为假。这是新手最常见的逻辑错误之一。

int find_contact(Contact *list, int count, const char *name, int *positions, int max) { int found = 0; for (int i = 0; i < count && found < max; i++) { if (strcmp(list[i].name, name) == 0) { positions[found++] = i; } } return found; }

这里我让函数接收一个 positions 数组,把查到的所有下标都存进去,而不是只返回第一个。为什么?因为通讯录里两个“张三”是完全正常的,你返回第一个就漏了第二个。返回所有匹配位置的集合,后续的删除、修改就能一次性处理所有的同名联系人。

查找的时间复杂度是 O(n)。如果你以后数据量大到几千几万条,可以考虑把顺序表换成二叉搜索树或者哈希表,但在通讯录这种个人场景下,线性查找完全够用。这个“数据结构选型要看场景”的道理,是这门课反复强调的,这里正好体验一下。

4.2 修改联系人:先定位,后覆写,顺序表天然支持随机访问

修改功能很像数组的“改”操作。顺序表底层是数组,所以支持 O(1) 的随机访问,直接取下标就能改。这个特性是链表做起来更麻烦的地方,链表需要先遍历到目标位置才能改。所以修这个功能,你其实是在感受顺序表的最大优势。

修改的流程分三步:先让用户输入要查找的姓名,然后列出查到的联系人,让用户选择修改哪个(如果有多个),最后把该联系人的字段重新用 read_line 读一遍覆盖进去。

if (find_result > 0) { printf("匹配到 %d 个联系人,请选择要修改的编号:", find_result); int idx; // 读取 idx Contact *p = &list[positions[idx]]; printf("新姓名:"); read_line(p->name, sizeof(p->name)); printf("新电话:"); read_line(p->phone, sizeof(p->phone)); printf("新组别:"); read_line(p->group, sizeof(p->group)); }

代码里有个常用的模式:Contact *p = &list[positions[idx]];。把数组取地址赋给指针后,后面用 p-> 来访问字段,代码会简洁很多,而且语义清晰——p 指向我们要修改的那个元素本体,改 p 就是改数组里的数据。这个“指针即别名”的思路,在 C 语言里几乎是万能解。

4.3 排序:先懂冒泡的套路,再用 qsort 收尾

排序是通讯录里的一个常被忽略但很实用的功能:按姓名排个字典序,这样电话本才好翻。教材里讲到排序算法时,喜欢从冒泡排序开始,因为它代码直观,能让你理解“比较—交换”这个基本动作。我这里也先给出冒泡的版本,因为它的思路对你理解时间复杂度很有帮助。

void sort_contacts(Contact *list, int count) { for (int i = 0; i < count - 1; i++) { for (int j = 0; j < count - 1 - i; j++) { if (strcmp(list[j].name, list[j+1].name) > 0) { Contact tmp = list[j]; list[j] = list[j+1]; list[j+1] = tmp; } } } }

冒泡排序的比较次数大约是 O(n²),数据一多就很吃力。但工程上我们不需要手写排序,C 标准库提供了 qsort,它的底层是快速排序,平均复杂度 O(n log n)。关键是写对比较函数。比较函数接收两个 const void * 参数,内部要转成 Contact* 再比较,返回值遵循“小于0、等于0、大于0”的约定。

int compare_by_name(const void *a, const void *b) { const Contact *ca = (const Contact *)a; const Contact *cb = (const Contact *)b; return strcmp(ca->name, cb->name); } // 调用:qsort(list, count, sizeof(Contact), compare_by_name);

这里想提醒两点。第一,strcmp 返回的值本身就是一个“负、零、正”的整数,和 qsort 要求完全吻合,直接 return strcmp(...) 即可,不要再画蛇添足做 return strcmp(...) > 0 ? 1 : -1。第二,qsort 的比较函数里如果要按电话排序,就换成 strcmp(ca->phone, cb->phone),想倒序就在返回值前面加个负号,非常灵活。如果你在做数据结构实验报告,我建议你在报告里写清楚:冒泡是教学演示,说明排序思路;qsort 是工程实现,展示标准库的调用。两者都写上,老师会觉得你既有底层意识,又有工程习惯。

4.4 把新功能整合进菜单,流程闭环

就这样,菜单里多出了“查找联系人”“修改联系人”“排序显示”三个选项。整个菜单循环的逻辑会变得越来越清晰。我建议你把每个功能都拆成一个函数,菜单里只做输入和调用。这样 main 函数不会膨胀,读代码的人也能一眼看到业务逻辑。

while (1) { show_menu(); int opt = read_menu_option(); switch (opt) { case 1: add_contact(); break; case 2: delete_contact(); break; case 3: search_contact(); break; case 4: modify_contact(); break; case 5: sort_contacts(); break; case 6: list_all(); break; case 0: return 0; default: printf("无效选项\n"); } }

到这一步,通讯录的功能已经比很多课程设计的作业要完整了。不过别急着收工,第四刀的内容也很关键,容量、内存、文件组织,都是后面面试和实操里经常聊到的话题。

5. 第四刀:让顺序表学会扩容,把内存和工程细节收拾干净

5.1 容量满了怎么办:从固定数组到动态扩容

很多学生写的顺序表是静态数组版,提前定一个 MAX_CAPACITY,比如100。这在数据量小的时候能跑,可一旦通讯录录入第101个人,程序就崩溃或者直接报错“联系人已满”。真实的需求哪有“100人上限”这种说法?所以动态扩容几乎是必须的。

动态扩容的核心是 realloc。当 count 等于 capacity 时,申请一块更大的内存(我习惯按原来的2倍扩容),把旧数据搬运过去,释放旧内存,更新数组指针和容量。倍增而不是每次加1的原因很现实:每次加1必然导致频繁的 realloc,而 realloc 可能涉及内存拷贝和搬家,代价高;倍增能让扩容次数从 O(n) 降到 O(log n),总的时间开销摊下来是线性的,这就是数据结构里说的“均摊复杂度”。

void ensure_capacity(Contact **list, int *capacity, int count) { if (count < *capacity) return; int new_cap = (*capacity == 0) ? 4 : (*capacity) * 2; Contact *new_list = (Contact *)realloc(*list, new_cap * sizeof(Contact)); if (new_list == NULL) { printf("内存不足,扩容失败。\n"); exit(1); } *list = new_list; *capacity = new_cap; }

要注意一个常见错误:不能直接写成list = (Contact)realloc(*list, ...),因为 realloc 失败时返回 NULL,同时原内存还没释放。一旦你把 NULL 赋给 *list,原来的数组指针就丢了,后续既无法访问数据也无法释放内存。所以必须用一个临时变量接住 realloc 的返回值,判断非空后再赋值给 *list。这是 C 语言里 realloc 的经典陷阱,面试也爱考。

配合扩容,所有使用顺序表的地方都要把“数组名”改成“指针+容量”的组合。函数签名要跟着变。如果你一开始就写的是静态数组版,这次改造会涉及好多个函数签名,稍微繁琐,但值得。

5.2 文件加载时的容量联动

文件加载逻辑也需要配合扩容。以前我们假设 MAX_CAPACITY 足够大,现在要改为:每次要往数组里放新元素之前,先 llamar ensure_capacity 检查一下。加载函数没办法提前知道文件里有多少行,所以变成边读边检查的模式。

while (fgets(line, sizeof(line), fp) != NULL) { ensure_capacity(&list, &capacity, count); Contact c; if (sscanf(...) == 3) { list[count++] = c; } }

这样哪怕文件里有1000行,程序也能全部读进来,而不是被固定容量卡死。一个小细节:读取文件时如果文件里最后有一个空行,sscanf 会失败,程序会打印一条“格式不正确”的警告。空行在纯文本存储里太常见了,所以我一般会把警告级别放低,或者只在格式错误时打印,空行静默跳过,避免用户看到一堆无意义的信息。

5.3 程序退出前,把内存归还系统

动态扩容之后,主函数退出前必须 free(list),否则程序一退出,操作系统虽然会回收进程的内存,但你在长周期运行或者把这个逻辑嵌到服务器代码里时,不 free 就会内存泄漏。写 C 程序的习惯应该是:谁 malloc 谁 free,谁 realloc 谁负责。我们只在 main 里动了这块内存,就在 main 结束时释放。

save_contacts(DATA_FILE, list, count); free(list); return 0;

有些同学会问:反正程序退出操作系统会回收,我不写 free 行不行?行,但你是在给自己埋雷。一旦思路迁移到一直在跑的程序(后台服务、嵌入式设备),不释放内存就会越积越多。把这个习惯建立起来,比具体某一次 free 更重要。

5.4 代码拆文件:头文件、顺序表模块、通讯录模块

最后顺手讲一下工程组织。到了这个规模,全部代码塞进一个 main.c 已经有点拥挤了。我建议拆成三个文件:

  • seqlist.h / seqlist.c:顺序表的核心操作,初始化、扩容、插入、删除、遍历,这些是针对“任意元素顺序表”的通用代码,不关心元素是不是联系人。
  • contact.h / contact.c:通讯录业务逻辑,输入姓名电话、保存文件、加载文件、按姓名查找。这些代码依赖 seqlist 提供的接口。
  • main.c:菜单和程序入口。

拆文件的好处是复用。你以后写图书管理系统也好,写学生成绩管理也好,seqlist 这套代码可以直接搬过去,只改元素结构体就行。C 语言里那种“造一个通用容器”的思路,就是从这种模块划分开始的。如果你在做课程设计,一个结构清晰的多文件工程,在答辩时是非常加分的。

6. 常见问题与排查实录

6.1 菜单输入被“跳过”,一个回车引发的血案

症状:添加完一个联系人后,回到主菜单,用户还没按任何键,程序就像自己按了一次回车一样,菜单一闪而过。

原因:printf 提示“按回车继续”时,用户敲的回车符被遗留在缓冲区,下一个 fgets 本来是想读菜单选项字符串的,结果一上来就读到了那个残留的换行符,直接把 opt 解析成了无效值。

排查:在 read_line 里打印调试信息,看到读进来的 line 是不是空字符串。修复方案有两个:一是每次 read_line 前手动清空缓冲区(不推荐,可移植性差);二是在菜单输入之后用一个循环,如果解析失败就让用户重新输入。我们的 read_menu_option 逻辑里已经有 continue 了,所以只要确保 sscanf 失败时会提示并重新读,这个问题就解决了。

6.2 程序崩溃,多半是下标越界

症状:录了十几个联系人后,程序突然崩了,或者打印出一些奇怪的乱码。

原因:八成是数组下标越界。比如删除联系人时,逻辑写成了 memmove 之后没有把 count 减1;或者加载文件时忘了检查容量,导致 list[count++] 越界;还有可能是 sort 函数里循环边界写错,j < count - 1 - i 写成 j < count - i。

排查:在关键位置打印 count 和 i,看遍历时是否越界。或者用 valgrind 跑一遍程序,它会精确报出是第几行越界的。说实话,valgrind 这个工具值得所有学 C 的人用一次,报错输出虽然一开始看着吓人,但用顺手之后,找 bug 快得惊人。

6.3 文件加载后数据重复,每次都叠加一遍

症状:启动程序,发现联系人数量比上次保存的多了一倍。仔细看,全是重复的。

原因:main 里启动时 load 了一次,后来某个菜单逻辑里又 load 了一次,两次加载没有做“覆盖写”而是“追加写”。修复:加载函数里先 count = 0,保证每次加载都是从空列表开始。这是一个典型的初始化遗漏。

6.4 中文联系人乱码

症状:程序里的中文字符串在终端显示正常,但写进文件再读出来,用记事本打开全是乱码。

原因:编码问题。终端里通常用的是 UTF-8 或 GBK,文件里存的是程序运行时采用的编码。只要读写前后编码一致,程序内自洽,一般不乱。乱码多发生在 Windows 记事本打开 UTF-8 文件、或 Linux 终端打开 GBK 文件时。解决思路:明确你的源代码和运行环境用什么编码,存储文件保持同一编码,展示端也统一。跨平台时优先 UTF-8。

6.5 排序后联系人丢失或错乱

症状:调用 qsort 后,联系人顺序乱了,甚至有些联系人“消失”了。

原因:qsort 的参数搞错。最常见的是 sizeof(Contact) 写成了 sizeof(Contact*) ,导致 qsort 按指针大小切分内存,整个排序就全乱了。另一个原因是比较函数里把 const void* 转错了类型,导致内存读取越界。

排查:打印 sizeof(Contact) 的实际值,再检查比较函数的强制类型转换。只要这两个点对齐,qsort 基本不会出问题。

写在最后的一个小建议

我实操下来最大的体会是:顺序表通讯录这个项目,真正难的不是顺序表本身,而是怎么把它做出“能用”的感觉。很多同学卡在 scanf 和缓冲区上面就放弃了,很可惜,因为只要跨过那道坎,后面就是一片坦途。如果这篇文章能让你少走一次弯路,那就值了。代码不要求一次写对,照着这个思路多跑几遍,出了问题就一步步打日志排查,这个过程本身就是最好的复习。后续你还可以把它继续扩展:加个分组筛选,把查找改成支持模糊匹配,甚至接一个图形界面。但核心的顺序表功夫,就在这里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询