资讯详情 C语言顺序表实现通讯录:从底层原理到完整项目源码
📅 2026/10/7 21:46:30
1. 序为什么我建议你用顺序表来实现通讯录先交代一下背景。作为一个被C语言虐过无数遍、又在工作里靠C语言吃饭的人我对数据结构这门课的感情很复杂——当年学的时候觉得这玩意儿有什么用直到后来做嵌入式开发和系统工具才发现所有复杂系统的基础就是这些看似简单的数据结构。顺序表Sequence List又是其中最基础、最朴素的一种说白了就是用一段连续的内存挨个存放一组数据。这次要说的简易通讯录项目是我当年课程设计时写的也是我带过的新人练手时最常推荐的一个题目。原因很简单通讯录功能明确增删改查、数据天然是线性的一堆联系人排成一排、而且它逼着你去解决两个最关键的问题——内存怎么管理、数据怎么持久化。这两个问题一旦啃下来后面学链表、学树、学图都会轻松很多。文章里我会把顺序表的底层原理、每个操作的实现逻辑、通讯录项目的完整代码拆开揉碎了讲还会附上我在调试过程中踩过的坑。适合三类人看刚学完指针和结构体、想找个完整项目练手的初学者正在做数据结构课程设计、需要源码参考的同学以及想搞明白顺序表到底怎么应用到真实项目的进阶学习者。如果你只是想要一份能跑通的代码直接抄作业本文也会给出完整的可运行版本。2. 先把底子打牢顺序表的核心设计思路2.1 为什么选顺序表而不是链表在做通讯录的时候很多人第一个纠结的问题是用顺序表还是链表我在课上听老师讲链表的时候就一直在想这个问题后来自己动手写代码才真正想明白。顺序表的底层是一块连续的内存就像一栋楼的房间号是挨着的你想找第n号房直接走到那个位置就行了——对应的就是数组的随机访问时间复杂度O(1)。而链表是每个节点带着一个指向下一个节点的指针就像一栋楼里每间房都只告诉你怎么去下一间你想找第n间房就得从第一间房开始一个个问过去——随机访问的时间复杂度O(n)。对于通讯录这种场景核心操作是按名字快速定位和遍历显示所有联系人。顺序表在这两方面都碾压链表。更关键的是通讯录虽然会有增删操作但整体频率不高而顺序表的增删代价移动元素在数据量小的时候完全可接受。再加上顺序表的代码量比链表少一半对初学者来说更容易掌握不容易写到一半心态崩了。提示如果你的通讯录需求变成频繁在中间插入、删除很少随机访问那才需要考虑链表。但是在教学场景和一般课程设计里顺序表是更合适的选择。2.2 结构体设计的三个关键字段顺序表的结构体定义是基本功但很多人刚开始写的时候会犯一个经典错误只定义了数组和一个长度没有容量字段。typedef struct { char name[32]; char phone[20]; char email[48]; } Contact; typedef struct { Contact* data; // 指向动态分配的内存 int length; // 当前有效元素个数 int capacity; // 当前分配的容量 } ContactBook;这里的data字段用的是指针而不是固定数组这是整个设计的核心。用指针意味着我们可以在运行期动态分配内存将来元素多了还能扩容。如果写成Contact data[100]这种固定数组就失去了弹性而且C语言里数组作为参数传递时会退化成指针函数内部根本算不出数组大小也容易出问题。length和capacity是两个完全不同的概念初学者最容易混淆。打个比方capacity是停车场的总车位数量length是当前实际停了多少辆车。你买了一个100个车位的停车场初始化capacity100可能只停了三辆车length3。往里面停车的时候得先看还有没有空车位length capacity没有的话就得扩建停车场扩容然后再停新车。2.3 初始化时最容易忽略的错误处理初始化函数看起来简单但我在审代码的时候发现至少一半人会漏掉一个关键检查。int InitBook(ContactBook* book) { if (book NULL) { return -1; // 防止传入空指针 } book-data (Contact*)malloc(INIT_CAPACITY * sizeof(Contact)); if (book-data NULL) { return -1; // 内存分配失败 } book-length 0; book-capacity INIT_CAPACITY; return 0; }你可能会说谁会传空指针进来啊实际操作中真的会。特别是当一个函数在多个地方被调用、调用的地方有不同参数时一个不小心传了NULL进来程序直接崩溃。C语言没有Java那种引用的安全性所有指针都要自己防御。malloc之后的判空也是一样在嵌入式环境或者内存紧张的设备上malloc真的会失败不是只在教科书里才存在。注意在C语言里检查指针的惯用方式是if (book NULL)别写if (!book)。虽然功能一样但前者更明确代码评审的时候也更清晰。3. 顺序表四大核心操作的实现与边界条件3.1 插入操作为什么必须从后往前移动插入是顺序表最核心的操作也是值得反复琢磨的一个。很多人第一次写插入代码时脑子里的逻辑是把新元素放到pos位置然后把后面的元素往后挪。这个思路是对的但实现的时候有个关键细节——必须从最后一个元素开始依次向后移动。int InsertContact(ContactBook* book, int pos, const Contact* c) { if (book NULL || c NULL) { return -1; } if (pos 0 || pos book-length) { return -1; // 位置越界 } // 检查容量不够就扩容 if (book-length book-capacity) { if (ExpandCapacity(book) ! 0) { return -1; // 扩容失败 } } // 从后往前移动元素给新元素腾出位置 for (int i book-length; i pos; i--) { book-data[i] book-data[i - 1]; } book-data[pos] *c; book-length; return 0; }从length开始往前到pos这个方向本质上是先腾位置再放元素。如果你从pos开始往后移动就会出现数据覆盖你先把data[pos]赋值给data[pos1]此时data[pos]和data[pos1]是同一个值再往后移动就乱了。位置的有效范围也是新手经常犯的错误。插入的位置可以是0插到头也可以是length插到尾所以判断条件是pos 0 || pos book-length注意是而不是。如果你误用了就永远无法在末尾追加元素了这算是一个隐蔽的bug。3.2 删除操作内存顺序和剩余元素的关系删除和插入是镜像操作方向相反但逻辑一样清晰。删除pos位置的元素本质是从pos1开始每个元素往前覆盖前一个。int DeleteContact(ContactBook* book, int pos) { if (book NULL || pos 0 || pos book-length) { return -1; } for (int i pos; i book-length - 1; i) { book-data[i] book-data[i 1]; } book-length--; return 0; }这里有个初学者容易问的问题我删掉最后一个元素之后那个内存位置上的旧数据还在吗答案是——形式上还在但我们已经不管它了因为length减一之后那个位置已经不在有效范围内了。还有一个更隐蔽的问题如果data中的元素是指针比如指向动态分配的内存光删掉这个元素还不够必须先释放它指向的内存否则就是内存泄漏。通讯录项目里我们用的是固定大小的字符数组所以没有这个烦恼。但从学习角度你要记住这个区别。3.3 查找与修改返回值和返回方式的取舍查找操作我采用的是返回下标的方式而不是返回指针。这是有意为之——下标是稳定且可验证的返回指针的话一旦后续发生扩容旧指针就失效了你都不知道该不该继续用。int FindContactByName(const ContactBook* book, const char* name) { if (book NULL || name NULL) { return -1; } for (int i 0; i book-length; i) { if (strcmp(book-data[i].name, name) 0) { return i; // 找到返回下标 } } return -1; // 没找到返回-1 }这里不得不强调一个C语言新手的经典误区字符串比较绝对不能用。比较的是两个字符串的首地址是否相同而strcmp比较的是两个字符串的内容是否相同。哪怕两个不同的字符串字面量内容一模一样它们的地址也可能不同。用去比较字符串我第一次写通讯录时就在这里栽了跟头找了一晚上bug。3.4 动态扩容realloc 的正确使用姿势扩容是整个顺序表设计里的重头戏也是检验一个人是否真正理解内存管理的关键点。最简单粗暴的扩容方式是分配一块更大的新内存把旧数据拷贝过去释放旧内存。int ExpandCapacity(ContactBook* book) { if (book NULL) { return -1; } int newCapacity book-capacity * GROWTH_FACTOR; Contact* newData (Contact*)malloc(newCapacity * sizeof(Contact)); if (newData NULL) { return -1; } // 拷贝旧数据 for (int i 0; i book-length; i) { newData[i] book-data[i]; } free(book-data); book-data newData; book-capacity newCapacity; return 0; }这里我用的是malloc 手动拷贝 free而不是标准库的realloc。可能有人会问为什么不直接用realloc其实两派做法都有而我在教学项目中特意用malloc版本是有原因的——它把新分配内存、拷贝数据、释放旧内存这三个步骤摊开了初学者能直观地看到扩容到底做了什么而realloc是一个黑盒接口用起来很省事但反而不利于理解机制。等你看懂malloc版本后自然会明白realloc其实就是这个逻辑的封装。提示实际生产环境中如果数据量非常大realloc存在找不着连续大块内存而返回NULL的风险此时旧的data仍然有效但你可能需要换用malloc新开一大块空间并迁移数据所以理解手动版本的逻辑是很有价值的。扩容因子的选择也有讲究。我用的是* 2也就是每次扩容到原来的两倍。为什么不是加100个或者* 1.5这里涉及摊还分析的思想如果每次只加一个位置插入n个元素的复杂度会变成O(n²)而每次翻倍的话总共的拷贝次数大约是O(n)算下来平均每次插入的代价是常数级别。具体的数学推导不展开了你只需要记住** 2倍是比较均衡的选法**既不会因为扩容太频繁导致性能差也不会因为一次扩太大浪费内存。4. 通讯录项目的主流方案对比为什么最终选择了顺序表在动手写通讯录之前我其实比较了好几种方案。这些对比在课设报告里也值得写一段能让老师知道你不是只会闷头写代码。4.1 三种主流方案的核心差异方案数据结构核心优势核心劣势适用场景固定数组静态数组最简单无需动态内存容量无法扩展浪费或不足数据量固定、小型程序顺序表动态数组动态数组长度容量随机访问快、支持动态扩展、内存连续中间插入删除需移动元素通用数据管理、通讯录链表结构体指针插入删除快、容量天然动态随机访问慢、代码复杂易出错频繁中间增删的场景4.2 为什么是顺序表而非静态数组静态数组是通讯录项目最粗暴的做法——直接定义一个Contact contacts[100]然后就用它。这个方案的代码量最少但两个致命问题摆在面前一是用户联系人超过99个怎么办程序直接崩溃或者拒绝添加二是即便只有10个联系人数组也占着100个联系人的内存空间浪费严重。动态顺序表完美解决了这两个问题。一开始只分配8个联系人的空间随着不断添加逐渐扩容用多少扩多少不够了自动长这在真实项目里是基本要求。4.3 为什么最终没有选链表链表并不是不好而是对于这个项目来说是杀鸡用牛刀。通讯录的核心交互是显示所有联系人遍历和按名字找人查找两者都是顺序表的主场。链表固然在中间插入删除上表现更好但通讯录的插入基本是往末尾追加删除也是先查找到位置再删这种模式用的恰好是链表的弱势操作。说句实在话很多同学用链表写完通讯录自己都说不清楚代码里那一堆malloc和next指针到底哪块对应哪块出了问题都不知道怎么排查。5. 通讯录项目的整体架构与模块划分明确了用顺序表之后下一步就是搭项目的整体框架。通讯录这种小程序我不想写成一个几百行的main函数而是按功能拆成模块。项目里分了三层数据层Contact结构体定义、操作层顺序表操作函数、界面层菜单与用户交互。完整项目目录结构如下contact_book/ ├── contact.h // 联系人结构体和操作函数声明 ├── contact.c // 顺序表与通讯录操作的实现 └── main.c // 主程序菜单和用户交互5.1 数据层联系人结构体定义这个结构体的字段设计是通讯录的基础我选择了三个字段姓名、电话和邮箱。为什么没有QQ号、微信号、住址那些乱七八糟的字段因为字段越多代码量越大对初学者来说阅读负担越重。每个字段的数组大小也值得注意#define NAME_LEN 32 #define PHONE_LEN 20 #define EMAIL_LEN 48 typedef struct { char name[NAME_LEN]; char phone[PHONE_LEN]; char email[EMAIL_LEN]; } Contact;name定为32是因为中国人的名字一般来说不会超过这个长度虽然复姓加生僻字但模板上写32就够用了phone定为20是因为手机号最长也就11位再加上 号和国家代码20绰绰有余email定为48是因为现在邮箱地址确实可能很长给定的富余比较多。5.2 操作层函数的划分与接口设计接下来是通讯录的核心操作函数。我把所有数据操作都封装成了独立的函数每个函数的职责单一方便测试和复用。// 顺序表基础操作 int InitBook(ContactBook* book); int ExpandCapacity(ContactBook* book); void DestroyBook(ContactBook* book); int InsertContact(ContactBook* book, int pos, const Contact* c); int DeleteContact(ContactBook* book, int pos); int FindContactByName(const ContactBook* book, const char* name); int ModifyContact(ContactBook* book, int pos, const Contact* newInfo); // 通讯录业务操作 void ShowAllContacts(const ContactBook* book); void ShowSingleContact(const ContactBook* book, int pos); void AddNewContact(ContactBook* book); void DeleteOneContact(ContactBook* book); void SearchContact(const ContactBook* book); void ModifyOneContact(ContactBook* book);你可能会问InsertContact和AddNewContact有什么区别InsertContact是通用的顺序表操作任何调用者都可以通过指定下标插入任意联系人AddNewContact是面向通讯录用户的业务函数它在内部处理输入姓名、输入电话这些交互逻辑然后调用InsertContact把数据塞进去。这种分层让底层的数据结构与上层的业务逻辑解耦——如果哪天下层从顺序表换成链表业务函数基本不用改动。5.3 界面层menu驱动的交互设计最后是主程序的菜单。我用一个while循环加switch分支来实现命令行交互这是最经典也最好懂的方式int main() { ContactBook book; if (InitBook(book) ! 0) { printf(初始化通讯录失败\n); return 1; } int choice; do { printf(\n 简易通讯录 \n); printf(1. 添加联系人\n); printf(2. 删除联系人\n); printf(3. 查找联系人\n); printf(4. 修改联系人\n); printf(5. 显示所有联系人\n); printf(6. 保存到文件\n); printf(7. 从文件加载\n); printf(0. 退出\n); printf(请选择); scanf(%d, choice); // 清空输入缓冲区防止\n残留 while (getchar() ! \n); switch (choice) { case 1: AddNewContact(book); break; case 2: DeleteOneContact(book); break; case 3: SearchContact(book); break; case 4: ModifyOneContact(book); break; case 5: ShowAllContacts(book); break; case 6: SaveToFile(book, contacts.txt); break; case 7: LoadFromFile(book, contacts.txt); break; case 0: printf(感谢使用再见\n); break; default: printf(无效选择请重新输入\n); break; } } while (choice ! 0); DestroyBook(book); return 0; }这个菜单逻辑有一个很容易被忽略的细节scanf读取整数后输入缓冲区里会残留一个换行符如果你接下来再用scanf(%s, ...)读字符串残留的换行符会被当作用户的输入导致程序跳过输入直接往下走。我用了while (getchar() ! \n)来清空缓冲区这是C语言命令行交互里的经典防坑代码几乎每个从汇编时代传下来的老程序员都写过。6. 通讯录核心功能的逐行实现与关键难点6.1 添加联系人从键盘输入到插入顺序表添加联系人的函数把用户输入和数据结构操作串起来这也是整个项目里最直观的一个流程。void AddNewContact(ContactBook* book) { Contact c; printf(请输入姓名); scanf(%s, c.name); printf(请输入电话); scanf(%s, c.phone); printf(请输入邮箱); scanf(%s, c.email); // 插入到末尾 if (InsertContact(book, book-length, c) 0) { printf(联系人添加成功\n); } else { printf(添加失败内存可能不足\n); } }这个函数看似简单但有两个容易被忽略的细节。第一scanf(%s, c.name)里name已经是数组名也就是指向数组首元素的指针所以不需要加。我知道这种错误在C语言入门阶段太常见了当年我自己也加过。第二插入位置用了book-length也就是直接追加到末尾这符合通讯录的实际使用习惯——新联系人永远加在最后面。InsertContact内部如果发现容量不够会自动扩容所以AddNewContact里不需要操心容量问题这也是封装的好处。6.2 删除与查找的联动逻辑删除联系人的流程不是让用户输入下标而是先按名字找到位置再执行删除。这样才能贴合真实使用场景——谁会记得某个联系人在通讯录的第几个位置void DeleteOneContact(ContactBook* book) { char name[NAME_LEN]; printf(请输入要删除的联系人姓名); scanf(%s, name); int pos FindContactByName(book, name); if (pos -1) { printf(未找到该联系人\n); return; } // 先展示一下要删的人确认一下 printf(找到联系人信息如下\n); ShowSingleContact(book, pos); printf(确认删除(y/n)); char confirm; scanf( %c, confirm); if (confirm y || confirm Y) { DeleteContact(book, pos); printf(删除成功\n); } else { printf(已取消删除\n); } }这里我给删除操作加了一步确认环节。通讯录里的数据对用户来说是重要的误删之后想恢复基本不可能我们没有做撤销功能所以不如在删除前让用户确认一次。这在真实的业务系统里是一个很常见的交互设计思想虽然代码只多了三行但是使用体验完全是两个档次。6.3 文件持久化让数据不再关了就丢顺序表存储在内存里的数据程序一退出就全部清空了。为了让通讯录真正可用必须把数据保存到文件里。这是很多人第一次接触序列化这个概念的场景其实用最简单的文本格式就能实现。int SaveToFile(const ContactBook* book, const char* filename) { FILE* fp fopen(filename, w); if (fp NULL) { return -1; } fprintf(fp, %d\n, book-length); for (int i 0; i book-length; i) { fprintf(fp, %s %s %s\n, book-data[i].name, book-data[i].phone, book-data[i].email); } fclose(fp); return 0; } int LoadFromFile(ContactBook* book, const char* filename) { FILE* fp fopen(filename, r); if (fp NULL) { return -1; } int count; fscanf(fp, %d, count); // 先清空当前数据避免重复加载 book-length 0; for (int i 0; i count; i) { Contact c; fscanf(fp, %s%s%s, c.name, c.phone, c.email); // 直接插入末尾内部会自行扩容 if (InsertContact(book, book-length, c) ! 0) { fclose(fp); return -1; } } fclose(fp); return 0; }文件格式很简单第一行是联系人数量后面每行是一个联系人的三个字段用空格分隔。这个格式的优点是人能直接看懂也能用文本编辑器手动修改。学生项目做到这个程度就已经足够了不需要上SQLite那种重量级方案。LoadFromFile里有个细节加载前把length清零。如果没有这步重复加载文件会导致数据翻倍、越积越多。我当时调试时遇到为什么加载两次数据多了一倍的诡异问题最后发现就是这个bug。6.4 释放内存代码可以烂内存泄漏不可以有程序的最后一步是释放所有动态分配的内存。这一步看似简单但就我审代码的经历来看至少60%的初学者会忘记写。在课程设计这类小项目里程序结束操作系统会回收内存所以忘写好像也没事但这不是好习惯——到了服务端程序随时可能跑上几个月不重启内存泄漏的后果就是系统慢慢卡死最后崩溃。void DestroyBook(ContactBook* book) { if (book NULL) { return; } free(book-data); book-data NULL; book-length 0; book-capacity 0; }这里有一个重要的实践free之后立即把指针置为NULL。原因很朴素——free只是释放了内存指针本身还指向那块已经被释放的地址这叫悬空指针如果你不小心再次free它或者通过它访问内存程序可能直接崩溃。置为NULL之后任何对它的再次操作都能被if (book-data NULL)拦截掉。很多开源项目里都有这个习惯我自己写代码时也一直这样要求自己。7. 实战中的坑我在编译调试时踩过的地雷这部分我想专门分享一下调试过程中遇到的典型问题每一个我都真实踩过花了不少时间才找出原因。如果你也遇到类似问题按这个思路排查会快很多。7.1 输入缓冲区的幽灵换行符第一次运行程序我选择添加联系人输入完姓名后程序直接跳过了电话输入让我一头雾水。我盯着代码看了半天也没看出问题。后来给代码加了断点才在调试器里发现scanf(%d, choice)读入数字后缓冲区里还留着一个\n接下来scanf(%s, c.name)时%s会自动跳过空白字符所以它把那个\n忽略掉名字读成功了。但诡异的是再往后电话输入时又出问题了。真正把我坑住的是另一种情况scanf(%c, confirm)这种格式%c不会跳过空白。也就是说如果你前一次输入按了回车缓冲区里残留的\n会被%c直接吃掉导致confirm的值是\n而不是用户输入的y或n。解决办法就是在每次读入后清空缓冲区。我在菜单函数里加了while (getchar() ! \n);这个技巧对整个程序都适用。这也是一个非常通用的经验——凡是在同一个命令行程序里混用scanf的不同格式符都要留意缓冲区的问题。7.2 越界的教训插入位置判断的边界错误早些版本的InsertContact里我写的是if (pos 0 || pos book-length)也就是把写成了。结果就出现了一个经典现象可以正常插入到中间位置但用AddNewContact往末尾追加时永远提示位置越界。当时真的调了很久才意识到问题——所有中间插入都能成功把错误迷惑性拉满了。这个表达式里中间插入合法位置是0到length-1末尾追加的位置是length所以合法的范围实际是0到length判断越界的条件应该是pos book-length而不是pos book-length。前后就差一个等号行为却是天壤之别。这个bug教给我的道理是数据结构操作中的边界条件永远是最容易出问题的地方每次写完循环和判断都要把第0个、最后一个、刚好越界这种临界情况代入检查一遍。7.3 文件读写模式踩坑w和r不是你想用就能用有一个版本的保存函数一直出问题我以为是存储格式写错了后来发现是fopen的模式不对。我在一个需要追加数据的场景用了w模式调用整个保存函数导致每次保存都重新写文件而不是追加。加上我那时用的是文件指针没有fclose程序连续运行多次后不释放文件句柄——Windows下会提示文件被占用Linux下虽然能打开但文件锁一直放着后面再读取就报段错误。这里要记住的规矩就三条写文件前想清楚是覆盖还是追加fopen成功后检查是否为NULL用完文件一定要fclose。这套习惯比任何高级技巧都重要。8. 完整可运行的源码以下是我最终调试通过的完整源码三个文件可以直接编译运行。编译命令在文章末尾给出。8.1 contact.h —— 头文件声明结构体和函数#ifndef CONTACT_H #define CONTACT_H #include stdio.h #include stdlib.h #include string.h #define INIT_CAPACITY 8 #define GROWTH_FACTOR 2 #define NAME_LEN 32 #define PHONE_LEN 20 #define EMAIL_LEN 48 typedef struct { char name[NAME_LEN]; char phone[PHONE_LEN]; char email[EMAIL_LEN]; } Contact; typedef struct { Contact* data; int length; int capacity; } ContactBook; // 顺序表基础操作 int InitBook(ContactBook* book); int ExpandCapacity(ContactBook* book); void DestroyBook(ContactBook* book); int InsertContact(ContactBook* book, int pos, const Contact* c); int DeleteContact(ContactBook* book, int pos); int FindContactByName(const ContactBook* book, const char* name); int ModifyContact(ContactBook* book, int pos, const Contact* newInfo); // 通讯录业务操作 void ShowAllContacts(const ContactBook* book); void ShowSingleContact(const ContactBook* book, int pos); void AddNewContact(ContactBook* book); void DeleteOneContact(ContactBook* book); void SearchContact(const ContactBook* book); void ModifyOneContact(ContactBook* book); int SaveToFile(const ContactBook* book, const char* filename); int LoadFromFile(ContactBook* book, const char* filename); #endif8.2 contact.c —— 所有功能的实现#include contact.h int InitBook(ContactBook* book) { if (book NULL) { return -1; } book-data (Contact*)malloc(INIT_CAPACITY * sizeof(Contact)); if (book-data NULL) { return -1; } book-length 0; book-capacity INIT_CAPACITY; return 0; } int ExpandCapacity(ContactBook* book) { if (book NULL) { return -1; } int newCapacity book-capacity * GROWTH_FACTOR; Contact* newData (Contact*)malloc(newCapacity * sizeof(Contact)); if (newData NULL) { return -1; } for (int i 0; i book-length; i) { newData[i] book-data[i]; } free(book-data); book-data newData; book-capacity newCapacity; return 0; } void DestroyBook(ContactBook* book) { if (book NULL) { return; } free(book-data); book-data NULL; book-length 0; book-capacity 0; } int InsertContact(ContactBook* book, int pos, const Contact* c) { if (book NULL || c NULL) { return -1; } if (pos 0 || pos book-length) { return -1; } if (book-length book-capacity) { if (ExpandCapacity(book) ! 0) { return -1; } } for (int i book-length; i pos; i--) { book-data[i] book-data[i - 1]; } book-data[pos] *c; book-length; return 0; } int DeleteContact(ContactBook* book, int pos) { if (book NULL || pos 0 || pos book-length) { return -1; } for (int i pos; i book-length - 1; i) { book-data[i] book-data[i 1]; } book-length--; return 0; } int FindContactByName(const ContactBook* book, const char* name) { if (book NULL || name NULL) { return -1; } for (int i 0; i book-length; i) { if (strcmp(book-data[i].name, name) 0) { return i; } } return -1; } int ModifyContact(ContactBook* book, int pos, const Contact* newInfo) { if (book NULL || newInfo NULL || pos 0 || pos book-length) { return -1; } book-data[pos] *newInfo; return 0; } void ShowSingleContact(const ContactBook* book, int pos) { if (book NULL || pos 0 || pos book-length) { return; } printf(姓名%s\n, book-data[pos].name); printf(电话%s\n, book-data[pos].phone); printf(邮箱%s\n, book-data[pos].email); printf(--------------------------------\n); } void ShowAllContacts(const ContactBook* book) { if (book NULL || book-length 0) { printf(通讯录是空的\n); return; } printf( 所有联系人 \n); for (int i 0; i book-length; i) { ShowSingleContact(book, i); } } void AddNewContact(ContactBook* book) { Contact c; memset(c, 0, sizeof(Contact)); printf(请输入姓名); scanf(%s, c.name); printf(请输入电话); scanf(%s, c.phone); printf(请输入邮箱); scanf(%s, c.email); if (InsertContact(book, book-length, c) 0) { printf(联系人添加成功\n); } else { printf(添加失败\n); } } void DeleteOneContact(ContactBook* book) { char name[NAME_LEN]; printf(请输入要删除的联系人姓名); scanf(%s, name); int pos FindContactByName(book, name); if (pos -1) { printf(未找到该联系人\n); return; } printf(找到联系人信息如下\n); ShowSingleContact(book, pos); printf(确认删除(y/n)); char confirm; scanf( %c, confirm); if (confirm y || confirm Y) { DeleteContact(book, pos); printf(删除成功\n); } else { printf(已取消删除\n); } } void SearchContact(const ContactBook* book) { char name[NAME_LEN]; printf(请输入要查找的联系人姓名); scanf(%s, name); int pos FindContactByName(book, name); if (pos -1) { printf(未找到该联系人\n); } else { printf(找到联系人信息如下\n); ShowSingleContact(book, pos); } } void ModifyOneContact(ContactBook* book) { char name[NAME_LEN]; printf(请输入要修改的联系人姓名); scanf(%s, name); int pos FindContactByName(book, name); if (pos -1) { printf(未找到该联系人\n); return; } Contact c; memset(c, 0, sizeof(Contact)); printf(请输入新的姓名留空表示不修改); char input[NAME_LEN]; scanf(%s, input); if (strcmp(input, -) ! 0) { strcpy(c.name, input); } else { strcpy(c.name, book-data[pos].name); } printf(请输入新的电话输入-保留原值); scanf(%s, input); if (strcmp(input, -) ! 0) { strcpy(c.phone, input); } else { strcpy(c.phone, book-data[pos].phone); } printf(请输入新的邮箱输入-保留原值); scanf(%s, input); if (strcmp(input, -) ! 0) { strcpy(c.email, input); } else { strcpy(c.email, book-data[pos].email); } ModifyContact(book, pos, c); printf(修改成功\n); } int SaveToFile(const ContactBook* book, const char* filename) { FILE* fp fopen(filename, w); if (fp NULL) { return -1; } fprintf(fp, %d\n, book-length); for (int i 0; i book-length; i) { fprintf(fp, %s %s %s\n, book-data[i].name, book-data[i].phone, book-data[i].email); } fclose(fp); return 0; } int LoadFromFile(ContactBook* book, const char* filename) { FILE* fp fopen(filename, r); if (fp NULL) { return -1; } int count; fscanf(fp, %d, count); book-length 0; // 清空当前数据准备重新加载 for (int i 0; i count; i) { Contact c; fscanf(fp, %s%s%s, c.name, c.phone, c.email); if (InsertContact(book, book-length, c) ! 0) { fclose(fp); return -1; } } fclose(fp); return 0; }8.3 main.c —— 主程序入口#include contact.h int main() { ContactBook book; if (InitBook(book) ! 0) { printf(初始化通讯录失败\n); return 1; } int choice; do { printf(\n 简易通讯录 \n); printf(1. 添加联系人\n); printf(2. 删除联系人\n); printf(3. 查找联系人\n); printf(4. 修改联系人\n); printf(5. 显示所有联系人\n); printf(6. 保存到文件\n); printf(7. 从文件加载\n); printf(0. 退出\n); printf(请输入你的选择); scanf(%d, choice); while (getchar() ! \n); // 清空缓冲区 switch (choice) { case 1: AddNewContact(book); break; case 2: DeleteOneContact(book); break; case 3: SearchContact(book); break; case 4: ModifyOneContact(book); break; case 5: ShowAllContacts(book); break; case 6: if (SaveToFile(book, contacts.txt) 0) { printf(保存成功\n); } else { printf(保存失败\n); } break; case 7: if (LoadFromFile(book, contacts.txt) 0) { printf(加载成功\n); } else { printf(加载失败文件可能不存在\n); } break; case 0: printf(感谢使用再见\n); break; default: printf(无效选择请重新输入\n); break; } } while (choice ! 0); DestroyBook(book); return 0; }编译运行命令gcc -Wall -o contact_book main.c contact.c ./contact_book-Wall参数开启编译警告强烈建议加上。编译器是一个很尽责的代码审查员很多潜在bug在编译阶段就会被它揪出来——比如变量未使用、类型不匹配、函数声明不一致等问题有警告的时候别忽略一个个处理干净再往下走。9. 写在代码之后我的实际使用体会如果你照着上面的源码敲了一遍并且跑通了恭喜你你已经迈过了C语言数据结构的第一道门槛。我回头再看这个项目它最宝贵的部分其实不是那段能跑的代码而是埋在里面的一系列C语言核心概念——动态内存分配、指针的安全性、边界条件、文件IO、模块化设计。这些概念单拎出来讲都挺枯燥但放在一个通讯录项目里每个都变得具体而有意义。有两个小建议送给后面做类似项目的朋友。第一个是测试的颗粒度——不要等整个通讯录写完了再整体测试而是每完成一个函数就用几行临时代码测一下。比如刚写完InsertContact就手动构造几个Contact插进去看看顺序对不对、插到中间时后面的元素有没有被正确挪动这样能省下大量排查的时间。第二个是善用调试器在插入、删除、扩容这些关键位置打断点观察book-length和book-capacity的值、观察data数组里的元素排列这种眼见为实的感觉比自己瞎猜有用得多。学会了这两招往后学链表、栈、队列都会顺手很多。如果后续还想继续扩展我觉得可以往两个方向走一是把查找功能升级成支持按电话号码搜索二是把文本文件存储换成二进制文件存储能省下不少磁盘空间这也顺便让你接触一下fwrite和fread这两个函数。通讯录这个题目就像一把钥匙它可以打开的远不止一扇门。