资讯详情

C语言图书借阅管理系统:结构体、链表与文件持久化实战

📅 2026/9/16 12:28:59 | 华诺云谱 👁 阅读
C语言图书借阅管理系统:结构体、链表与文件持久化实战
简介面向C语言学习者与课程设计场景一套基于VC2010开发的图书借阅管理系统完整项目。系统以菜单方式驱动核心功能包括新书录入按书号排序写入文件、借书与还书手续自动增减图书数量并更新存在状态、按图书编号或借书人学号查询借阅情况、删除图书信息以及从文件读取并浏览全部借阅记录数据均通过文件持久化存储适合用来理解C语言文件操作、结构体数组与模块化程序设计。包内共54个文件以main.c源码、图书借阅管理系统.vcxproj工程文件、.sln解决方案和可运行的.exe为主同时包含book.csv、student.csv数据示例以及编译调试生成的pdb、tlog等辅助文件总大小仅815KB结构紧凑方便直接打开查看或重新编译。目前已有456人学习浏览。下载后可直接运行体验完整功能也可对照源码重点学习借书/还书时数量和状态同步更新的处理逻辑为课程设计或C语言综合练习提供可复用的参考。1. 图书借阅管理系统VC2010 里 C 语言项目的真实工作量“图书借阅管理系统”大概是 C 语言课程里出现频率最高的大作业放在 VC2010 里就是一个 Win32 控制台程序黑窗口打印菜单按数字键选择“增加图书”“查询”“借书”“还书”退出前把数据写进文件下次启动再读回来。真正拦住新手的不是增删改查而是数据生命周期——数组和结构体只是内存里的东西程序一关就没了你必须自己设计落盘格式几十本书、几百条借阅记录才能从内存搬到磁盘再恢复回来。这篇文章按这个项目的三个重心展开结构体与链表怎么组织数据、文件读写怎么保存状态、借还书和排序怎么写最后把 VC2010 的坑和换到 VS Code 的迁移方法一起说清楚。适合正在写课程设计、要快速交出一个能演示的完整项目的人。2. C 语言数据组织结构体与链表撑起图书借阅管理系统菜单能跑起来只是第一步这个项目真正的骨架是数据怎么在内存里组织。图书信息、读者信息、借阅状态如果没有一个统一的结构体后面每个功能都要写一遍散乱的变量声明改需求时必然出事。2.1 结构体设计把书变成 C 语言里的一个变量先定义图书结构体。字段看起来是抄书单但每个字段的选型都有讲究#define MAX_TITLE 64 #define MAX_AUTHOR 32 typedef struct Book { int id; /* 图书编号主键 */ char title[MAX_TITLE]; /* 书名 */ char author[MAX_AUTHOR]; /* 作者 */ int total; /* 总册数 */ int borrowed; /* 已借出册数 */ struct Book *next; /* 链表后继指针 */ } Book;这里最容易踩的坑是 title 和 author 的类型。很多初学者写成char *title觉得用指针更灵活但链表节点一旦存指针节点的sizeof只有 4 或 8 字节真正的内容躺在堆上节点和内容之间的关联全靠那个地址值。后面做文件保存时直接把节点fwrite进文件写进去的是地址而不是字符串下次程序启动那个地址指向的内存早就释放了。定长数组多占一点内存换来的是“节点本身就是完整数据”序列化、拷贝、排序都简单得多。total和borrowed分开存而不是存一个“剩余量”是因为借书、还书、补册、下架这四个操作里任何一方单独变化都需要另一个字段做校验。剩余量每次显示时现算total - borrowed就是可借余量不额外占一个字段。2.2 数组还是链表数据规模与增删频率的取舍数据量只有几百条时结构体数组完全跑得动甚至代码更短。选不选链表要看这个系统的主要操作对比项结构体数组单向链表随机访问O(1)下标直达O(n)要遍历插入/删除O(n)需要搬移元素找到位置后只改指针O(1)内存分配预分配固定上限比如 Book books[1000]用多少分多少每个节点多一个 next 指针文件保存连续内存一次 fwrite必须遍历写入排错难度越界访问容易暴露指针指错直接段错误图书管理的特点是插入和删除都发生在任意位置按编号插入、删除下架的旧书中间位置的修改是常态。数组删除中间元素要把后面所有元素往前搬性能问题在几百本时看不出来但代码里出现memmove之后就多了一个边界条件要维护。链表的核心收益在于插入和删除只操作相邻节点的指针不碰其他数据。我一般会在这个项目里选链表还有一个很实际的原因链表遍历、节点分配、指针修改本身就是 C 语言课程的考核点答辩时“为什么用链表”比“为什么用数组”好讲得多评委也更愿意顺着链表问你内存管理的问题。2.3 链表插入与删除头指针会变必须用二级指针链表操作的三段基础代码值得一次写对因为后面的按编号查书、删除图书、恢复文件都要复用Book *create_book(int id, const char *title, const char *author, int total) { Book *node (Book *)malloc(sizeof(Book)); if (node NULL) { /* malloc 失败必须检查 */ fprintf(stderr, malloc failed\n); exit(1); } node-id id; strncpy(node-title, title, MAX_TITLE - 1); node-title[MAX_TITLE - 1] \0; /* 手动补结束符 */ strncpy(node-author, author, MAX_AUTHOR - 1); node-author[MAX_AUTHOR - 1] \0; node-total total; node-borrowed 0; node-next NULL; return node; } void insert_sorted(Book **head, Book *node) { if (*head NULL || (*head)-id node-id) { node-next *head; *head node; return; } Book *cur *head; while (cur-next cur-next-id node-id) { cur cur-next; } node-next cur-next; cur-next node; } int delete_by_id(Book **head, int id) { if (*head NULL) { return 0; } Book *tmp NULL; if ((*head)-id id) { tmp *head; *head (*head)-next; /* 头指针变化所以需要二级指针 */ } else { Book *cur *head; while (cur-next cur-next-id ! id) { cur cur-next; } if (cur-next NULL) { return 0; /* 没找到 */ } tmp cur-next; cur-next tmp-next; } free(tmp); /* 释放节点内存 */ return 1; }insert_sorted的入参是Book **head而不是Book *head因为插入到头部时调用方的head变量本身要指向新节点传普通指针只能改副本回到主函数后 head 还是原来的值。delete_by_id同理删掉头节点后要让调用方的指针指向下一个节点。这两个函数里cur-next的指针修改顺序决定了链表会不会断先把新节点挂到后继再把前驱指向新节点中间断了一环都会丢链表。create_book里的strncpy必须在拷贝完以后手动写\0因为目标数组的长度如果不足以容纳整个字符串strncpy不会自动补结束符。题目里的书名长度超过 63 字节就截断但保证数组不越界这就是固定缓冲区换来的安全性。3. 文件读写与数据持久化让图书借阅管理系统关掉再开也能恢复内存里的链表再漂亮程序退出就全部清空。这一步要解决的是把链表完整搬到磁盘里并在下次启动时原样重生。实际课程设计里文件读写代码的分量往往超过业务逻辑这是因为数据格式设计、读写两个方向保持一致、异常文件处理都集中在这一层。3.1 二进制还是文本可调试性优先于性能文件格式有两种选法先看对比存储方式核心函数优点缺点二进制fwrite / fread速度快结构体整块读写文件不可读结构体对齐导致跨编译器不兼容文本fprintf / fscanf记事本能打开格式问题一眼看出字段解析要设计分隔符字符串含空格时麻烦二进制最大的坑是结构体对齐。同一个Book在 VC2010 里默认 4 字节对齐换个编译器可能变成 8 字节对齐文件里每个字段的偏移全变读出来就是乱数据。而且链表节点里有next指针直接把节点写盘等于把地址写进文件下次启动读出来的是一个无效指针。文本格式没有这些问题肉眼可以检查到底哪一行被写坏了。这个项目的数据量就是几百行性能差异可以忽略所以常见做法是文本文件、字段之间用\t分隔一行一本书。3.2 最小可用的保存与加载函数保存就是把链表里的每个节点按固定格式打成一串字符加载就是把每一行拆回结构体再插回链表。注意这里必须是“先读到临时结构体再插入”不能把next一起读进来/* 依赖stdio.h string.h */ int save_books(const char *path, Book *head) { FILE *fp fopen(path, w); if (fp NULL) { perror(save_books); return -1; } Book *p; for (p head; p ! NULL; p p-next) { fprintf(fp, %d\t%s\t%s\t%d\t%d\n, p-id, p-title, p-author, p-total, p-borrowed); } fclose(fp); return 0; } Book *load_books(const char *path) { Book *head NULL; FILE *fp fopen(path, r); if (fp NULL) { return NULL; /* 文件不存在不一定是错误 */ } Book tmp; while (fscanf(fp, %d\t%63s\t%31s\t%d\t%d\n, tmp.id, tmp.title, tmp.author, tmp.total, tmp.borrowed) 5) { insert_sorted(head, tmp); } fclose(fp); return head; }fprintf的格式串里%d\t%s\t%s\t%d\t%d五个字段用制表符分隔最后一个字段后的\n表示一条记录的结束。%63s和%31s限制读入的最大长度防止超长书名溢出tmp.title数组这里的数字比数组长度小 1留出结束符的位置。fscanf的返回值等于 5表示五个字段都成功解析了。文件如果最后一行只有部分字段读出来返回值不是 5循环立刻结束不会把垃圾数据插进链表。这里tmp的next成员没有初始化也没关系insert_sorted只拷贝字符串和数值并且在链表尾部把node-next置为 NULL。提示读写格式必须严格一致最隐蔽的问题是字段里混入空格。fscanf的%s遇到空格就停止解析所以书名和作者里不要放空格界面上也拦掉空输入。3.3 fopen 模式与文件路径模式选错数据就没了fopen的模式决定了数据是覆盖、追加还是报错这个项目里至少要用到三种模式文件不存在时文件存在时写入位置r返回 NULL正常打开只读w新建内容清空从开头写a新建保留原内容从末尾追加图书主数据用 r 读、用 w 写。启动时加载用 r第一次运行还没有数据文件fopen返回 NULL这是正常分支不应该当错误退出返回空链表让用户从菜单里建数据。保存用 w每次保存都是全量覆盖保证文件里的内容和内存链表一致。操作日志用 a借书、还书记录逐条追加不动已有内容。保存时机有个实用策略每次借书、还书、增加图书成功后立刻调用save_books而不是等到用户选“退出”才写。控制台程序被直接点掉叉的场景太常见了退出菜单里的保存逻辑可能根本没执行。数据文件路径集中放在一个宏里#define DATA_FILE data/books.txt相对路径是相对于程序的当前工作目录VC2010 调试时默认是.vcxproj工程文件所在目录不是生成的 Debug 文件夹。把路径集中定义后面换目录结构只需要改一处。4. 借书、还书与排行VC2010 图书借阅管理系统核心逻辑数据层铺好之后业务逻辑就是一层薄壳菜单接收数字调用数据层函数在控制台打印结果。这个项目里反复出现的代码套路就那么几段——模糊查询匹配书名、借书还书改字段、超期算天数、排序出榜单每段都对应一个明确考点。4.1 模糊查询用 strstr 而不是 strcmp用户只记得书名中的一个词这是常态精确匹配的strcmp在这里派不上用场。字符串函数里的strstr(haystack, needle)在 haystack 里查找 needle找到返回匹配位置的指针找不到返回 NULL#include string.h int search_books(Book *head, const char *keyword) { int count 0; Book *p; for (p head; p ! NULL; p p-next) { if (strstr(p-title, keyword) ! NULL || strstr(p-author, keyword) ! NULL) { printf(%-4d %-30s %-20s 总册:%d 已借:%d\n, p-id, p-title, p-author, p-total, p-borrowed); count; } } return count; }两个注意点。第一||的两边都检查书名命中或作者命中都算匹配用户搜“刘慈欣”时能把该作者所有书都列出来。第二keyword是空字符串时strstr返回非 NULL会列出全部图书所以菜单调用前要过滤空输入提示用户至少输入一个关键字。主菜单里调用后拿返回值count打印“共找到 n 条”这就是整个菜单循环里唯一需要传递的数据。查询本身不修改链表不需要传二级指针。4.2 借书还书先查合法性再改字段最后写日志借书最容易出的逻辑错误是“书存在就借”忽略了库存校验和重复操作。一个能过基本测试的借书函数长这样/* 依赖stdio.h time.h */ /* 返回值0成功 -1书不存在 -2库存不足 */ int append_log(const char *path, int book_id, const char *op) { FILE *fp fopen(path, a); if (fp NULL) { return -1; } fprintf(fp, %ld %d %s\n, (long)time(NULL), book_id, op); fclose(fp); return 0; } int borrow_book(Book *head, int id) { Book *p; for (p head; p ! NULL p-id ! id; p p-next) ; if (p NULL) { return -1; } if (p-borrowed p-total) { return -2; } p-borrowed; append_log(log.txt, id, borrow); return 0; }borrowed total表示所有册数都借出去了这里用而不是即使数据因为某种原因出现borrowed比total大的脏状态也能拦住借书而不是继续恶化。还书函数是它的镜像遍历找书返回 -1 表示书不存在然后检查borrowed 0等于 0 时返回错误码提示“没有可还的记录”。真正执行的是p-borrowed--。很常见的错误是写借书时不加borrowed还书时却写了减一结果借出去的书记录永远不对。append_log用 a 模式追加一次借还就多一行。time(NULL)返回自 1970 年以来的秒数强转成long打印方便后面做超期分析时比较先后顺序。日志文件和图书主数据分开主数据被覆盖改写多次操作痕迹还能查。4.3 日期与超期判断算天数差而不是比字符串还书时要判断“是否超期”最常见做法是借书时记录当天的日期还书时再取一次当天日期两个日期都转成从 2000 年 1 月 1 日算起的天数相减得到借阅天数static const int month_days[12] {31,28,31,30,31,30,31,31,30,31,30,31}; int is_leap(int y) { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } int days_from_2000(int y, int m, int d) { int days 0; int yy, mm; for (yy 2000; yy y; yy) { days is_leap(yy) ? 366 : 365; } for (mm 1; mm m; mm) { days month_days[mm - 1]; if (mm 2 is_leap(y)) { days 1; /* 闰年二月多一天 */ } } return days d; }month_days是硬编码的 12 个月天数表二月按平年 28 天闰年由第二个循环里的if补上 1 天。两个循环把年份和月份分开算逻辑清楚不需要记复杂的儒略日公式。在 Book 结构体里加一个int borrow_date;存借书当天的days_from_2000结果还书时比较当前天数和借书天数的差。超过 30 天按每本每天 0.1 元打印罚款金额。这个函数对输入参数不做验证用户在菜单里输入 2 月 30 日也能算出一个数所以菜单层要用 if 做基本范围检查m 1 m 12 d 1 d month_days[m-1]这一步拦掉非法日期。4.4 最热图书排行链表数据搬到数组再冒泡按借出数量排行是课程设计里加分的功能。链表本身不适合直接做冒泡排序每交换一次节点就要改至少三个节点的 next 指针边界条件多、容易断链且难以调试。常见做法是先把链表拍平到临时数组void show_top_books(Book *head) { int n 0; Book *p; for (p head; p ! NULL; p p-next) { n; /* 先统计节点数 */ } if (n 0) { return; } Book *arr (Book *)malloc(sizeof(Book) * n); if (arr NULL) { return; } int i, j; Book *cur head; for (i 0; i n; i) { /* 拷贝节点数据不拷贝 next */ arr[i] *cur; arr[i].next NULL; cur cur-next; } for (i 0; i n - 1; i) { for (j 0; j n - 1 - i; j) { if (arr[j].borrowed arr[j 1].borrowed) { Book tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } for (i 0; i n i 10; i) { printf(%-4d %-30s 借出:%d 次\n, arr[i].id, arr[i].title, arr[i].borrowed); } free(arr); }冒泡内层循环的n - 1 - i是经典写法每一轮结束后最大的元素已经沉到最后下一轮不用再比。排序用arr[j].borrowed arr[j1].borrowed实现借出量降序想要升序就把小于号换成大于号。整个数组用malloc分配用完必须free链表本身的节点不用动。数据量超过几千条时冒泡就吃力了可以换成 C 标准库的qsort写一个int cmp(const void *a, const void *b)比较两个Book的borrowed字段。课程设计规模下冒泡代码更直白也正好覆盖“排序算法”这个普遍考点。5. 排错与移植从 VC2010 换到 VS Code 的 C 语言兼容写法项目在 VC2010 里跑通只是第一步你这个.c文件随时可能被拿到别的环境编译很多问题是 IDE 特有的而不是 C 语言本身的。5.1 先处理 VC2010 的两个固有脾气打开工程后第一波编译警告通常是 C4996fopen、scanf、strcpy这些 CRT 函数在 VS 2005 之后被标记为“不安全”。最省事的解法是在源文件第一行写#define _CRT_SECURE_NO_WARNINGS或者到项目属性里C/C - 预处理器 - 预处理器定义加这一项。两个写法选一个就行我习惯用代码里的#define因为文件拷走了定义也跟着走。另一个坑是 C 语言标准支持。VC2010 的 C 编译器按 C89 规则为主变量声明要放在函数块的开头for (int i 0; i n; i)这种写法在 .c 文件里可能报 C2143 语法错误。上面所有代码都刻意写成 C89 风格int i, j;提在循环外面换到任何编译器都能过。如果自己在调试时收到了这类错误检查是不是声明位置问题。5.2 换到 VS Code gcc 时改改这些点把同样的 .c 文件丢到 VS Code 配置好的 C 语言环境里大部分代码原封不动要动的是这四类VC2010 里的写法gcc 环境下的对应写法scanf_s/fopen_sscanf/fopen或保持_CRT_SECURE_NO_WARNINGS不生效的代码不动system(pause)暂停窗口getchar()之前先清空输入缓冲源文件默认 GB2312中文注释正常UTF-8 编码的源文件控制台中文乱码时setlocale(LC_ALL, ).sln/.vcxproj工程用 tasks.json 调用 gcc 编译单文件输入缓冲区清空是个细节如果用户先输入了数字再回车后来调用getchar()取到的是缓冲区里残留的回车符程序直接跳过等待。常见做法是while (getchar() ! \n);把缓冲区的字符读干净再getchar()挂起。setlocale 需要#include locale.h。退出前遍历链表释放所有节点的内存也是一项容易忽略的收尾工作free全部节点之后再退出内存泄漏在几十个节点时毫无感知但把系统挂在循环里反复借还多次后任务管理器里的内存占用会缓慢上升。写成void free_all_books(Book *head)循环保存 next 再 free 当前节点和delete_by_id的指针顺序一样先存后删。最终验证可以按这个流程走手工造一个包含 10 本书的 books.txt格式按第 3 章的id\t书名\t作者\t总量\t已借排列启动程序查询、借书、还书、排序各跑一遍退出后打开文件确认字段被正确改写再复制一份 .c 文件到 VS Code 里用 gcc 编译运行能跑通就说明代码没有绑死在微软工具链上。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。