ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言通讯录管理系统实战:从结构体到文件读写与排序

2026/9/16 19:15:25 拓冰建站 浏览量
C语言通讯录管理系统实战:从结构体到文件读写与排序 简介这是一份面向C语言初学者的通讯录管理系统完整项目基于DevC环境编写。功能覆盖通讯录信息的批量录入、列表显示、多关键字排序、按姓名查找、插入、删除与修改等基础操作并扩展了按分类统计、从文件读入与保存到CSV文件的数据持久化能力适合K12阶段编程学习或高校课程设计参考。压缩包内含7个文件以C语言源文件、DevC工程文件、布局文件、可执行文件和CSV数据文件为主整体大小约51KB结构精简可直接打开工程查看源码也能运行exe程序体验完整流程。项目当前已有128人学习对于希望掌握结构体、链表和文件操作综合应用的读者是一份小巧完整的C语言实战范例能帮助理解一个小型管理系统从界面、逻辑到数据存储的闭环设计思路。压缩包中还包含DevC布局文件与makefile等工程配置便于在DevC中一键编译运行。1. 从 DevC 到通讯录一个能跑起来的 C 语言综合项目如果你上过 C 语言的课大概率绕不开“通讯录管理系统”这道大作业。它不复杂却把结构体、字符串、数组、排序、查找、文件读写这些 C 语言的核心知识点全串到了一起。我手边这个 DevC 工程包解压后就是完整的main.c、contacts.csv、Makefile.win以及编译好的通讯录管理系统.exe用 DevC 打开.dev工程文件就能直接跑起来。和网上很多只做展示的版本不同它实现了题目里要求的批量输入、显示、排序、查找、插入、删除、修改还扩展了从 CSV 文件读入数据、保存数据、按分类统计的功能。不管你是刚学完指针和结构体想找个项目练手还是需要一份能交课程设计的参考代码这份资源都值得拆开来看。下面我会按数据组织、核心操作、文件持久化、可靠性改进的顺序把实现细节和容易踩的坑讲清楚。2. 结构体与数据组织先把通讯录的容器设计好2.1 字段类型怎么定从需求到宏定义题目要求每个联系人包含姓名、单位、移动手机、分类同事、朋友、同学、家人等、EMAIL、QQ 六个信息。设计结构体之前先要确定每个字段用什么 C 类型。手机号、QQ 号虽然看起来像数字但不能用int或long存——手机号可能以 0 开头QQ 号可能超 10 位回头还需要按前缀模糊查找。所以这类字段一律用字符数组。我一般会在文件开头用宏定义把数组长度固定下来而不是在结构体里写魔法数字。这样改长度时只动一处代码也不会飘得满屏都是20、50。参考定义如下#define MAX_NAME 20 #define MAX_UNIT 50 #define MAX_PHONE 15 #define MAX_CATEGORY 10 #define MAX_EMAIL 40 #define MAX_QQ 15 typedef struct { char name[MAX_NAME]; char unit[MAX_UNIT]; char mobile[MAX_PHONE]; char category[MAX_CATEGORY]; char email[MAX_EMAIL]; char qq[MAX_QQ]; } Contact;这里用char[]而不是char *是因为联系人数据需要被数组或链表容器持有。如果用指针就得手动为每个字符串分配堆内存释放时容易漏剑指方向的free一旦错位就是未定义行为。固定数组的代价是每个字段都有上限但从课程设计和单机练习的规模看1000 条以内的通讯录用固定数组完全够用。2.2 结构体内存布局与字符串拷贝陷阱Contact结构体在没有指针成员的情况下可以用Contact a b;直接整体赋值编译器会按内存逐个拷贝。但字符串成员不能写成c.name 张三因为数组名是常量地址不能作为左值。必须用strcpy或strncpy。初始化的常见做法是先用memset把结构体清零再往里填内容避免遗留上一次栈帧的垃圾数据Contact c; memset(c, 0, sizeof(c)); strcpy(c.name, 张三);memset(c, 0, sizeof(c))属于 C 语言内存管理的入门操作。它把Contact占用的内存全部置零这样字符串后面不会有随机的\0位置打印时也不会出现乱码。注意strcpy不会检查目标数组的长度如果输入的姓名超过MAX_NAME-1就会越过缓冲区。后面在文件读取时会改用strncpy做长度限制。2.3 用数组还是链表DevC 工程常见选择的取舍通讯录这种频繁增删的数据结构理论上链表更合适。但实际开发中数组和链表的取舍要看具体场景。我在项目里先用数组主要原因是 DevC 的调试环境对数组更友好contacts[i]直接可见而链表要一个个节点展开。文件持久化时数组也能用循环一次写完。对比项固定数组链表内存分配静态分配连续动态分配不连续插入 / 删除需要移动后续元素改指针即可随机访问O(1)O(n)文件序列化直接循环写需要遍历节点代码复杂度低中等容易忘 free这个工程最终用Contact contacts[MAX_CONTACTS];加一个int contactCount;来管理数据contactCount既是有效元素个数也是下一次插入的位置。所有函数都通过contactCount判断数组是否越界这比在函数里写死 1000 更安全。如果你后续想改成链表只需要把操作contacts[i]的地方改成遍历节点的指针循环业务逻辑层不用动。3. 增删改查与排序通讯录核心功能的 C 实现3.1 批量输入一次录入若干条联系人题目要求“可以一次完成若干条信息的输入”。实现思路是循环里让用户逐条输入遇到空姓名时结束。这里比较推荐用fgets代替scanf因为scanf(%s)遇到空格就分段而fgets能读入带空格的单位名称。int addContacts(Contact contacts[], int count) { while (count MAX_CONTACTS) { Contact c; memset(c, 0, sizeof(c)); printf(姓名直接回车结束: ); fgets(c.name, MAX_NAME, stdin); c.name[strcspn(c.name, \n)] 0; if (strlen(c.name) 0) break; printf(单位: ); fgets(c.unit, MAX_UNIT, stdin); c.unit[strcspn(c.unit, \n)] 0; printf(手机: ); fgets(c.mobile, MAX_PHONE, stdin); c.mobile[strcspn(c.mobile, \n)] 0; printf(分类同事/朋友/同学/家人: ); fgets(c.category, MAX_CATEGORY, stdin); c.category[strcspn(c.category, \n)] 0; printf(Email: ); fgets(c.email, MAX_EMAIL, stdin); c.email[strcspn(c.email, \n)] 0; printf(QQ: ); fgets(c.qq, MAX_QQ, stdin); c.qq[strcspn(c.qq, \n)] 0; contacts[count] c; printf(添加完成当前共 %d 人\n, count); } return count; }代码里c.name[strcspn(c.name, \n)] 0是处理fgets的关键。fgets会把回车换行也读进字符串如果不删掉后续strcmp匹配姓名时永远对不上。strcspn返回字符串中第一个匹配\n的位置把那一位改成字符串结束符0即可。如果输入超过了MAX_NAME-1fgets会截断剩下的字符留在输入缓冲区里更健壮的做法是判断最后一位如果不是换行就循环读掉残余字符这里出于篇幅不展开。3.2 显示让表格对得整整齐齐显示函数的核心是控制格式。用printf的%-Ns可以左对齐并固定最小宽度N 是对应宏。宽度最好和宏保持一致否则中文字符在控制台里可能错位。void displayContacts(Contact contacts[], int count) { printf(%-6s %-16s %-14s %-10s %-22s %-12s\n, 姓名, 单位, 手机, 分类, EMAIL, QQ); for (int i 0; i count; i) { printf(%-6s %-16s %-14s %-10s %-22s %-12s\n, contacts[i].name, contacts[i].unit, contacts[i].mobile, contacts[i].category, contacts[i].email, contacts[i].qq); } printf(共 %d 条记录\n, count); }这里%-6s中的-表示左对齐6是最小宽度。如果字符串实际宽度超过 6printf不会截断会原样输出所以宽度只影响对齐效果不限制数据长度。输出后加一行记录总数方便确认增删是否生效。3.3 排序冒泡排序和 qsort 两条路题目要求支持按姓名、年龄、城市、单位等多种方式排序。项目里没有年龄和城市字段就按姓名、单位和手机号来排。最容易想到的是冒泡排序 C 语言写法代码如下static int cmpByName(const Contact *a, const Contact *b) { return strcmp(a-name, b-name); } void sortByBubble(Contact contacts[], int count, int (*cmp)(const Contact *, const Contact *)) { for (int i 0; i count - 1; i) { for (int j 0; j count - 1 - i; j) { if (cmp(contacts[j], contacts[j 1]) 0) { Contact tmp contacts[j]; contacts[j] contacts[j 1]; contacts[j 1] tmp; } } } }函数指针int (*cmp)(const Contact *, const Contact *)是排序系统里的关键。调用方想按姓名排就传cmpByName想按单位排就再写一个cmpByUnit排序逻辑本身不用改。这个设计比在排序函数内部写死strcmp(contacts[j].name, ...)要好维护得多。如果你不想自己写排序C 标准库提供了qsort。缺点是它的比较函数参数必须是const void *需要转回结构体指针int qsortByName(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(contacts, contactCount, sizeof(Contact), qsortByName);qsort内部是快速排序平均 O(n log n)比冒泡排序 O(n²) 快。但课程设计里如果联系人不到几百条冒泡排序反而更直观容易在答辩时讲清楚。两者比较排序方式时间复杂度稳定性代码量适用场景冒泡排序O(n²)稳定10 行左右数据量小教学演示qsort平均 O(n log n)不稳定需额外写比较函数数据量大工程化还有一个坑strcmp是按字节比较 ASCII 码中文在 GBK 编码下不会按拼音排序。如果你需要按姓名的拼音顺序显示得先转换成拼音首字母或者使用setlocale(LC_ALL, Chinese)后调用strcoll。这个需求如果考核没提可以不做提了就要额外写拼音码表。3.4 查找按姓名、电话还是 QQ查找函数是删除和修改的基础。这里写一个通用的单向查找返回找到的下标找不到返回-1。返回值设计成下标而非布尔值是为了让上层函数能直接利用下标做后续操作。int findByName(const Contact contacts[], int count, const char *name) { for (int i 0; i count; i) { if (strcmp(contacts[i].name, name) 0) { return i; } } return -1; }如果要求按手机号或 QQ 查找思路一模一样只是比较字段不同。你甚至可以再包一层把“查找字段”做成枚举或函数指针但那样对新手来说不容易看懂。我的建议是保留多个独立函数findByName、findByMobile通过菜单让用户选择查找方式。这样代码重复了十几行可读性提升很多。3.5 删除、修改和插入注意数组搬运删除的核心是找到目标后把它后面的所有元素往前搬一格再让contactCount--。这里有一个容易错的地方如果直接用for (i idx; i count - 1; i) contacts[i] contacts[i1];最后一个元素会被复制一份但count--后它已经不在有效范围内所以不用清除。int deleteByName(Contact contacts[], int *count, const char *name) { int idx findByName(contacts, *count, name); if (idx -1) return 0; for (int i idx; i *count - 1; i) { contacts[i] contacts[i 1]; } (*count)--; return 1; }修改的操作比删除简单先查下标然后重新输入要修改的字段。注意修改时也要用fgets并去除换行否则新数据可能带\n导致显示和排序出问题。插入有两种常见理解一种是往数组末尾追加新记录另一种是按排序位置插入。如果新记录要放进中间位置可以用memmove先把后段内存整体后移一位再写入新结构体int insertAtIndex(Contact contacts[], int *count, int idx, const Contact *newItem) { if (*count MAX_CONTACTS || idx 0 || idx *count) return 0; memmove(contacts[idx 1], contacts[idx], (*count - idx) * sizeof(Contact)); contacts[idx] *newItem; (*count); return 1; }memmove是从 C 语言内存管理角度处理重叠拷贝的正确选择memcpy在源与目标重叠时行为未定义。这里sizeof(Contact)乘以元素个数得到要移动的字节数如果结构体里有指针成员这样的内存搬动会把指针值也复制过去容易产生悬空指针。好在Contact全部是数组所以安全。4. 文件读写与分类统计让通讯录数据落地4.1 CSV 文件格式为什么不选二进制工程包里自带contacts.csv说明数据用 CSV 文本存储。相比二进制文件CSV 可以直接用记事本和 Excel 打开调试时能肉眼检查数据是否写对。DevC 工程里的contacts.csv正是程序启动时读取、退出时写入的文件。CSV 的每行是一条记录字段之间用逗号分隔。比如姓名,单位,手机,分类,EMAIL,QQ 张三,XX公司,13800138000,同事,zhangsanexample.com,123456 李四,YY学校,13900139000,同学,lisiexample.com,654321第一行是表头读取时可以跳过。这里有个限制如果姓名、单位里本身包含逗号这个简单的 CSV 解析逻辑就会出错。课程设计里一般不会出现这种情况所以不用为字段加引号转义。4.2 保存联系人到 contacts.csv保存函数用fprintf按行写入每条记录一行最后写一个\n换行int saveContacts(const char *filename, Contact contacts[], int count) { FILE *fp fopen(filename, w); if (!fp) return 0; fprintf(fp, 姓名,单位,手机,分类,EMAIL,QQ\n); for (int i 0; i count; i) { fprintf(fp, %s,%s,%s,%s,%s,%s\n, contacts[i].name, contacts[i].unit, contacts[i].mobile, contacts[i].category, contacts[i].email, contacts[i].qq); } fclose(fp); return 1; }fopen的w模式会先清空原文件再写入如果文件不存在则创建。这里只调用了fclose没有检查返回值严格来说应该判断fclose(fp) 0因为关闭失败可能导致数据没完全落盘。小项目可以忽略但面试里可能会被问到。4.3 从 CSV 读取联系人读取比保存麻烦要用strtok按逗号切分再逐个strncpy到结构体。注意strtok会修改原始字符串所以每行先用fgets读进line再用strtok处理。int loadContacts(const char *filename, Contact contacts[], int maxCount) { FILE *fp fopen(filename, r); if (!fp) return 0; char line[256]; int count 0; fgets(line, sizeof(line), fp); // 跳过表头 while (count maxCount fgets(line, sizeof(line), fp)) { Contact c; memset(c, 0, sizeof(c)); char *p strtok(line, ,\n); if (!p) break; strncpy(c.name, p, MAX_NAME - 1); p strtok(NULL, ,\n); if (p) strncpy(c.unit, p, MAX_UNIT - 1); p strtok(NULL, ,\n); if (p) strncpy(c.mobile, p, MAX_PHONE - 1); p strtok(NULL, ,\n); if (p) strncpy(c.category, p, MAX_CATEGORY - 1); p strtok(NULL, ,\n); if (p) strncpy(c.email, p, MAX_EMAIL - 1); p strtok(NULL, ,\n); if (p) strncpy(c.qq, p, MAX_QQ - 1); contacts[count] c; } fclose(fp); return count; }strtok第一次调用传入line后续传入NULL表示继续从上次切分的位置找下一个分隔符。分隔符写了,\n这样能同时把每行末尾的换行切掉省去手动去除。strncpy第三个参数MAX_NAME-1是留一个字节给字符串结束符但strncpy在源字符串短于目标长度时会把后面填\0源字符串更长时不会自动补\0因此目标数组在调用前已经用memset清零所以最坏情况下最后一个字符也一定是\0。读文件时还有一个细节不能把while (!feof(fp))放在循环条件里因为feof只有在尝试读取超过文件末尾后才会置位容易多读一行。正确方式是用fgets的返回值判断是否成功读入。4.4 分类统计按同事、朋友、同学、家人分组分类统计是扩展功能实现方式很直接。先定义一个已知的分类名数组遍历通讯录挨个比较统计出前四类的人数剩下的算“其它”。void statisticsByCategory(Contact contacts[], int count) { const char *knownCats[] {同事, 朋友, 同学, 家人}; int knownCount[4] {0, 0, 0, 0}; int knownTotal 0; for (int i 0; i count; i) { for (int j 0; j 4; j) { if (strcmp(contacts[i].category, knownCats[j]) 0) { knownCount[j]; knownTotal; break; } } } printf(分类统计共 %d 人\n, count); for (int j 0; j 4; j) { printf(%s: %d\n, knownCats[j], knownCount[j]); } printf(其它: %d\n, count - knownTotal); }这个函数的时间复杂度是 O(n)因为内层分类只有 4 个固定项。如果分类是用户随便输入的就需要用链表或哈希表来统计未知类别但题目给出的分类列表固定用数组足够。统计完打印后还可以把结果追加写进一个statistics.txt方便留存不过这不是核心需求。5. 从正确到可靠指针、内存管理与边界场景处理5.1 字符串输入与缓冲区别让 gets 毁掉你的程序老教材里经常出现gets(c.name)但它没有长度限制输入过长直接越界C11 标准已经把它删掉了。DevC 自带的 GCC 版本会警告gets不安全所以一律用fgets。项目里我封装了一个小工具函数void trimNewline(char *s) { s[strcspn(s, \n)] 0; }如果程序里先用了scanf(%d, menuOption)再调用fgets读字符串scanf会把用户按下的回车留在输入缓冲区里导致后面的fgets读到空字符串。解决办法是在scanf之后加一句while (getchar() ! \n);清空缓冲。这不是高深的问题但在实际联调时经常把人绊住。5.2 数组越界和 contactCount 的保护所有对数组的写操作第一件事就是检查contactCount MAX_CONTACTS。比如添加函数里的while (count MAX_CONTACTS)删除函数里findByName返回值必须小于*count插入函数里idx的范围也要校验。C 语言不会自动检查数组下标越界写可能静默破坏相邻变量表现为排序结果稀奇古怪或者程序在exit时崩溃。保持contactCount只由增删函数修改不要在外层手动加加减减是避免这类问题的最低成本方案。5.3 程序结束前自动保存atexit 的小技巧很多学生写的程序是退出菜单后手动保存忘了保存就全部丢失。更稳的做法是在main里注册退出回调让程序无论从哪个分支结束都会执行保存void onExitSave(void) { saveContacts(contacts.csv, contacts, contactCount); } int main() { atexit(onExitSave); contactCount loadContacts(contacts.csv, contacts, MAX_CONTACTS); // 主菜单循环 return 0; }atexit接受一个void (*)(void)类型的函数指针。程序通过return或调用exit()结束时会按注册顺序反向调用这些函数。不过atexit不会捕获程序崩溃和CtrlC强制终止所以我会在每次增删改成功后立刻再掉一次saveContacts这样即使意外断电也只是丢掉最后一次操作不会全部丢失。5.4 一个验证技巧用测试数据覆盖边界输入写完代码不要急着交用一组边界输入验证不会崩溃第一空通讯录时执行显示、排序、查找、删除看函数是否越界。第二添加一条记录后立即退出重新启动看contacts.csv是否回写。第三手动改坏contacts.csv里的一行比如少了一个字段看读取是否会中途停下。第四输入超过数组长度比如姓名填 30 个字符看fgets截断后程序是否还能继续。这些测试不算复杂但能把指针和内存管理问题提前暴露出来。最后一个小提示在 DevC 里编译时把菜单里的“启用编译器检查”打开把-Wall -Wextra加上警告信息能帮你发现很多未定义行为。本文还有配套的精品资源点击获取