C语言课设实战:手写购物管理系统,串联链表与内存管理核心考点

发布时间:2026/10/13 1:09:09
C语言课设实战:手写购物管理系统,串联链表与内存管理核心考点
每到了课程设计的季节,总有人在群里问C语言做什么题目好。我的答案一直是购物管理系统。这题目看起来烂大街,但仔细想想,它几乎把C语言的核心考点全串起来了——结构体、指针、链表、函数封装、文件操作、内存管理,一个不落。你要是能把一个购物系统从零手写出来,再去看那些图书管理学生管理,基本就是同一套骨架换层皮。这篇文章就围绕这个项目,把我自己从设计到调试、从存储到扩展的完整思路写出来,给正在做课设的同学一个可以参照的样板,也给想把C语言基本功打扎实的人一条具体的练习路径。1. 这个题目为什么值得做:购物系统背后的考点拆解1.1 一个看似普通的课设题,到底在考什么很多人选通讯录学生成绩管理这类题目,理由是好抄、逻辑直白。但这类项目最大的问题是:数据操作太单一了。通讯录基本就是录入—查找—删除,链表操作永远只有按姓名查一个人,你写完之后对链表的理解还是停留在背下来几个函数的阶段。购物管理系统不一样。它的核心是状态流转:商品从上架,到被加入购物车,再到生成订单、扣减库存,最后写入文件。这中间数据一直在变,而且是多个链表之间联动地变。商品链表里的节点,会被购物车链表引用;购物车又在结算时反向去修改商品库存;订单生成后又是一个独立链表。这一套流程走完,你等于把链表的增删查改在真实业务里各用了一遍。另外一个隐性考点是内存管理。链表里每一个节点都是malloc出来的,用户退出程序、清空购物车、删除商品时都要free。很多人在题库里写的链表代码能跑,但一检查全是内存泄漏。购物系统由于链路长、节点多,天然逼着你去处理什么时候释放、释放之后指针怎么办这类问题。这个项目的适用范围很广:正要交C语言课设的大学生、想补数据结构基础的自学者、准备面试但C语言链表部分心虚的开发者,都可以拿它当练手素材。它的难度曲线也友好——核心功能纯C就能写,不需要引入任何第三方库,一台电脑、一个编译器就能开工。1.2 数据结构选型:为什么单链表在这里够用我见过不少人在开写之前纠结:商品列表要不要用双向链表?购物车要不要用哈希表?订单是不是该用数组?我的回答是:这个规模用单链表就够了,甚至可以说是最优解。先想清楚数据量。课程设计级别的购物系统,商品撑死几百条,购物车里同时存在的条目几十个,订单就算历史积累也就几千。这个量级下,单链表的线性查找耗时可以忽略不计。你算一下,一万条数据的线性查找平均也就几千次比较,每次比较不过几条指令,加起来不到一毫秒。为了这点性能引入双向链表、跳表、哈希表,属于过度设计,只会让代码量翻倍、bug变多。再想清楚操作特征。购物车里的删除操作有个特点:用户在购物车页面删一条记录、改一次数量,操作频率不高,链表完全扛得住。真正高频的插入发生在结算后清空购物车——那是一次性释放整个链表,单链表做起来最干净。我用的是带头尾指针的单链表:头指针负责顺序遍历和删除,尾指针负责O(1)尾部插入。商品录入时希望新商品排后面,购物车追加商品时也希望保持加入顺序,尾插法是必须的。如果没有尾指针,每次添加都要从头遍历到尾部,虽然数据量小也无所谓,但代码写起来啰嗦,还容易在遍历时出空指针问题。提示:链表结构选择的关键不是哪个更高级,而是你的业务插入和查找频率哪个更高。高频尾部追加,一定要带尾指针;频繁按ID定位,才需要考虑哈希索引。购物系统这两个场景都不极端,单链表尾指针是平衡点。2. 三个结构体和链表操作:先把地基打稳2.1 商品、购物车、订单:三个结构体的设计思路开写之前,先把数据结构定义清楚。我最终用的是下面这三个结构体:typedef struct Product { int id; // 商品编号,唯一 char name[50]; // 商品名称 double price; // 单价 int stock; // 库存数量 struct Product *next; } Product; typedef struct CartItem { int productId; // 对应商品ID char name[50]; // 名称快照 double price; // 单价快照 int count; // 购买数量 struct CartItem *next; } CartItem; typedef struct Order { int orderId; // 订单号 int itemCount; // 共有几种商品 double totalAmount; // 订单总金额 struct Order *next; } Order;商品结构体没什么好讲的,重点是购物车里的两个快照字段——name和price。很多人图省事,在购物车里只存一个Product指针,这是大坑。你想一个场景:用户把商品加进购物车之后,管理员改了商品价格。如果购物车只是引用商品节点,结算时用户看到的金额就悄悄变了,订单里的价格跟用户确认过的对不上。把name和price拷贝一份进购物车节点,相当于在加入那一刻给商品信息拍了张照片,之后商品表怎么改都不影响这笔购物车的结算。这个快照思路在很多真实系统里都能看到,订单必须保存下单时刻的商品名称和价格,不能跟着商品主数据漂移。在C语言里实现起来也就是多两个字段、多一次strcpy,成本几乎为零,但对数据正确性的提升非常明显。订单结构体我用了聚合设计:每个订单节点只存总金额和商品种类数,不保存订单明细。为什么?一是课程设计阶段查看订单通常只需要看到单号、总额、几件商品,明细可以后面加;二是订单明细涉及一个订单到多个商品的关联,用C语言写要再加一个嵌套链表,复杂度陡增。先把核心流程跑通,想加明细时扩展一个OrderItem链表再挂上就行。2.2 链表增删里最容易出错的四个位置结构体定好了,接下来是基本功:链表增删。这块我当初踩了不少坑,挑四个最容易出错的地方详细说。第一,头插和尾插的边界处理。头插法在链表为空和不为空时其实是一样的:新节点next指向原头指针,然后头指针指向新节点。但尾插法不一样,空链表时尾指针指向NULL,如果你不看情况直接tail-next new_node,那就是给NULL赋值,程序立刻段错误。所以尾插必须判断tail NULL时让头指针也指向新节点。void append_product(Product **head, Product **tail, Product *new_node) { if (*tail NULL) { *head new_node; *tail new_node; } else { (*tail)-next new_node; *tail new_node; } new_node-next NULL; }注意这里传的是二级指针。很多初学者会问为什么不直接返回新头指针,其实两种方式都行。二级指针的好处是调用方不需要写head append_product(head, ...),函数内部直接改掉原指针。我建议统一用一种,别一会儿传指针一会儿返回值,混用容易把自己绕晕。第二,删除节点必须记录前驱。单链表删除的核心是让前一个节点的next跳过待删节点,指向它的后一个。为此你必须从头遍历,用prev指针记录当前节点的前一个。看起来简单,但我见过好几种错误写法:有人删除时不去找前驱,而是重新从头找一遍;有人用当前节点下一个节点的双指针窗口;还有人在删除头节点时没有单独处理。最稳妥的模板是这样:void delete_by_id(Product **head, int id) { Product *cur *head; Product *prev NULL; while (cur ! NULL) { if (cur-id id) { if (prev NULL) { *head cur-next; // 删除头节点 } else { prev-next cur-next; } free(cur); return; } prev cur; cur cur-next; } }第三,释放之后的指针必须置NULL。free(cur)之后,cur指向的内存已经归还系统,但cur变量本身还保留着那个地址。如果你习惯性地访问cur-next或者再次free(cur),轻则读到垃圾数据,重则double free直接崩溃。我自己的习惯是封装一个free_node宏或者函数,里面统一在free之后node NULL,从根上杜绝这个隐患。第四,别用边遍历边删除的粗暴方式。清空购物车时,有人会写:while (head ! NULL) { free(head); head head-next; // 已经在free之后访问head-next了 }这是典型的使用已释放内存。正确做法是先用next保存后继,再释放当前节点:void clear_cart(CartItem **head) { CartItem *cur *head; while (cur ! NULL) { CartItem *next cur-next; free(cur); cur next; } *head NULL; }2.3 一组可以直接抄的辅助函数链表项目的代码量一大,最烦的就是每个功能里都要写遍历逻辑。我建议把这些公共操作抽成辅助函数,后面所有业务模块复用。find_product(head, id):按ID找商品节点,返回指针,找不到返回NULL。print_products(head):遍历打印商品列表,方便调试和展示。free_all_lists(productHead, cartHead, orderHead):退出程序前的统一释放。find_product是核心,加购物车、结算扣库存、删除商品都要调用它。返回NULL的情况必须每个调用方都处理,否则后面访问node-stock就是空指针。Product *find_product(Product *head, int id) { Product *cur head; while (cur ! NULL) { if (cur-id id) return cur; cur cur-next; } return NULL; }这个函数看起来平平无奇,但它暴露了一个所有C链表项目的通用原则:谁使用内存,谁就要先确认内存还在。商品可能被删除了,节点可能已经free了,你拿着一个旧指针到处跑,迟早出问题。规定好所有查找操作都以头指针为起点,不缓存跨函数的长生命周期指针,项目会安全很多。3. 核心业务逻辑:从用户下单到扣库存的完整链路3.1 菜单循环与输入校验:别让scanf毁掉整个交互主菜单是个经典的死循环加switch分发。功能不外乎:商品管理(增删改查)、购物车操作(加购/修改/删除/清空)、结算、订单查询、退出。框架很简单,但输入校验是重灾区。C语言课程设计最常见的翻车现场是:用户输入了一个字母,比如菜单数字选择时误敲了q,scanf的%d读取失败,choice变量根本没被赋值,但程序已经返回0。如果你傻乎乎地直接使用choice,switch会进入default分支。更糟的是,那个q还留在输入缓冲区里,循环下一轮scanf又读到它,于是程序陷入死循环,菜单疯狂刷新。正确的处理方式是检查scanf的返回值,并且把输入缓冲区里残留的字符全部清掉:int choice; printf(请输入操作编号: ); if (scanf(%d, choice) ! 1) { // 读取失败,清空输入缓冲,避免死循环 int c; while ((c getchar()) ! \n c ! EOF); printf(输入无效,请重新输入数字。\n); continue; }没有那一行while ((c getchar()) ! \n c ! EOF);,你后面不管写多少判断都救不回来。这个坑我在课设答辩前专门调试了两个小时,后来每次写scanf都条件反射地带上清缓冲。除了菜单,商品价格、数量、用户选择ID,凡是int和double类型输入,统一走scanf检查返回值清残留的路子。虽然啰嗦,但这是C语言输入系统本身决定的,嫌麻烦的话可以封装一个read_int(int *out)函数,内部处理校验和清缓冲,业务代码里调它就清爽多了。3.2 加入购物车:快照、合并与库存上限加入购物车的完整流程是:让用户输入商品ID和购买数量,然后按ID查找商品。查不到就提示商品不存在,查到了就检查stock count,库存不够直接拒绝。这个流程里有几个细节容易被忽略。第一个是购物车合并。用户可能连续两次把同一个商品加进购物车,如果你每次都新建节点,购物车清单里会出现两行一模一样的商品。更好的做法是:先遍历购物车,如果发现已有相同productId的条目,直接累加count:CartItem *item find_cart_item(cartHead, id); if (item ! NULL) { item-count count; printf(该商品已在购物车中,数量已更新。\n); return; }第二个是价格快照。前面2.1说过,创建购物车节点时把商品当前的name和price拷贝一份存进购物车,之后商品价格再怎么变都不影响这单结算。第三个是库存校验的时机。我在加入购物车时校验一次库存,是为了不让用户加超过库存的数量;但库存是个变动值,用户加完购物车之后逛了半天,期间另一笔订单可能已经把库存买光了,所以结算时要再校验一次。两处校验的意义不同,别省。3.3 结算与扣库存:先校验后扣减,杜绝回滚结算模块是这个项目的灵魂。流程上分四步:打印购物车清单、计算总金额、确认结算、扣库存并生成订单。很多人的第一版代码是边走边扣:遍历购物车,每找到一个商品就把库存减掉,同时累加金额。这么做有个致命隐患:假设购物车里有三件商品,前两件库存都够,第三件库存不足。你走完前两件已经把库存扣了,第三件才发现扣不下去,这时候整个购物车的状态已经坏了一半——前两件扣掉的库存得有人反悔补回来,而你又不能简单地遍历回去加,因为加的过程可能又把别的商品改坏了。正确的做法是两遍遍历、先校验后扣减。第一遍遍历购物车,针对每一项调用find_product找到对应商品,检查它的stock count。只要有任何一项不满足,整笔结算直接中止,购物车不变、库存不动,用户自己去调整数量。全部通过之后再走第二遍遍历,真正执行扣库存、生成订单、清空购物车。这样根本不需要回滚逻辑,因为永远不会出现扣了一半发现扣不动的中间状态。// 第一遍:只校验,不动任何数据 CartItem *cur cartHead; while (cur ! NULL) { Product *p find_product(productHead, cur-productId); if (p NULL || p-stock cur-count) { printf(商品 %s 库存不足,结算取消。\n, cur-name); return; } cur cur-next; } // 第二遍:校验全部通过,开始真正扣库存 cur cartHead; while (cur ! NULL) { Product *p find_product(productHead, cur-productId); if (p ! NULL) { p-stock - cur-count; } cur cur-next; } // 然后生成订单节点、写入文件、清空购物车这段逻辑我建议反复看几遍。它不涉及高深语法,但体现了程序设计的核心思想:把检查和执行分离,让系统不进入需要回滚的危险状态。这个思想比任何链表技巧都值钱,面试时讲出来也很加分。订单生成之后,别忘了把订单写入订单链表和订单文件。如果订单是追加存储,订单号可以取当前最大订单号加一,注意遍历找到最大值要处理空链表的情况。4. 数据持久化:为什么不能直接fwrite整个链表4.1 文本文件还是二进制:两种方案的取舍购物系统的数据要存得住,程序退出再启动,商品、库存、订单都还在。大多数人的第一反应是把链表节点直接fwrite进文件,几行代码搞定,加载的时候fread出来,看起来完美——直到你运行第二次,程序直接崩溃。原因很简单:链表节点里有next指针,里面存的是内存地址。这次程序运行时某个节点在内存地址0x00A1F3C0,你把整个结构体写进文件;下次启动程序,内存布局完全变了,那个地址指向的根本不知道是什么东西。你把一个包含内存地址的二进制块原样读回来塞进链表,能跑才怪。所以无论文本还是二进制,存储时必须逐字段写业务数据,绝不允许把next指针一起写盘。正确做法是遍历链表,把每个节点的id、name、price、stock逐个写,读取时逐条构建新节点,再用malloc分配内存、建立next链接。链表结构是靠读盘后重建出来的,不是从磁盘恢复出来的。两种文件格式我实际都写过,各有利弊:方案优点缺点文本文件(fprintf格式化)能直接打开看,出问题容易定位解析麻烦,格式被用户改坏就加载失败;float精度受格式化控制二进制文件(fwrite逐字段)写入读取快,字段无损不可直接查看;跨平台字节序/结构体对齐要小心我的个人推荐是二进制逐字段写入。购物系统产生的数据量不大,性能差异根本感知不到,但二进制写入能保持double的完整精度,读取时逐字段对应,结构清晰。而且规避了文本文件被手工改坏导致加载崩溃的问题——课设阶段,老师打开data文件手滑存了个格式错误内容的情况,完全可能出现。4.2 防崩溃写入:临时文件加原子替换文件的写入时机要设计好。最稳的做法是每次业务数据发生变化后立即同步落盘,而不是等退出时才写。比如加购、扣库存、生成订单后,马上调用保存函数。一旦程序中途崩溃,损失最多是最近一次操作。写入的时候别直接打开正式文件写。正规一点的做法是:先写一个临时文件(比如goods.tmp),数据全部写完之后,把临时文件改名为正式文件goods.dat。这一步很关键。如果直接写正式文件,写到一半程序挂了,那goods.dat就剩半截数据,下次启动加载直接崩。而临时文件rename的方式,rename在同一个目录里是原子操作——要么旧文件还在,要么新文件完整替换,不存在中间状态。void save_products(Product *head) { FILE *fp fopen(goods.tmp, wb); if (fp NULL) { printf(保存失败!\n); return; } Product *cur head; while (cur ! NULL) { fwrite(cur-id, sizeof(int), 1, fp); fwrite(cur-name, sizeof(char), 50, fp); fwrite(cur-price, sizeof(double), 1, fp); fwrite(cur-stock, sizeof(int), 1, fp); cur cur-next; } fflush(fp); fclose(fp); rename(goods.tmp, goods.dat); }注意我这里没写sizeof(Product)这种一整个结构的fwrite,而是字段逐个写。原因是结构体存在内存对齐填充,不同编译环境下struct内部padding可能不一样,按整块写入的文件换个环境就读不对了。四个人为拆开的字段全是标准类型,字节数固定,兼容性最好。fflush(fp)也很容易被忽略。它把C标准库缓冲区里的数据强制刷到操作系统,确保fclose前数据真正落了盘。虽然fclose通常也会刷新,但显式fflush能让写失败的时机提前暴露在保存函数内部。4.3 加载与初始化:让程序能从上次的状态继续跑加载逻辑和保存逻辑要成对设计。保存时按id、name、price、stock的顺序写,加载时同样按这个顺序读。读取的关键点是边读边malloc、边建链表:Product *load_products() { FILE *fp fopen(goods.dat, rb); if (fp NULL) { // 文件不存在,说明是首次运行 return NULL; } Product *head NULL; Product *tail NULL; while (1) { Product *p malloc(sizeof(Product)); size_t n fread(p-id, sizeof(int), 1, fp); if (n ! 1) { free(p); // 读到文件末尾,释放最后一个没凑齐的节点 break; } fread(p-name, sizeof(char), 50, fp); fread(p-price, sizeof(double), 1, fp); fread(p-stock, sizeof(int), 1, fp); p-next NULL; // 尾插法重建链表 if (tail NULL) { head tail p; } else { tail-next p; tail p; } } fclose(fp); return head; }有一个细节:文件不存在时返回NULL,调用方要把它看成空商品列表,再调用一个init_default_goods函数往里面填充默认商品数据。千万别在加载函数里见文件不存在就直接报错退出,否则用户第一次运行程序就白屏了。加载时每读一条记录,还应该做合法性检查,比如价格不能为负、库存不能为负、name长度不能超过数组边界。文件是程序自己写的,正常情况下不会有问题,但被外部改动或者写坏时,逐条过滤能保证程序至少不整体崩溃。5. 调试与排错:链表项目最常见的崩溃根因5.1 野指针、双重释放与内存泄漏写完核心功能之后,真正的战斗才开始——调试。我回顾自己写这个项目的过程,遇到的崩溃几乎都集中在下面几类:问题典型症状根因解决思路空指针访问段错误,崩溃位置在-next处链表为空却直接遍历,find返回NULL没处理所有访问前判空,调用find处必须检查返回值野指针数据时对时错free之后没有置NULL,后续再次访问free后立即置NULL,统一封装释放函数双重释放malloc/free错误,程序崩溃同一节点被两段代码各free一次明确节点所有权,从链表摘下之后才能free内存泄漏valgrind报definitely lostmalloc的节点没释放退出前遍历释放所有链表,并用valgrind复查野指针为什么是这个项目的头号杀手,我想多说几句。C语言里free(p)只是把内存还给系统,并不会清空p变量里的地址值。你紧接着写p-next,系统不会拦你,它会顺着那个已经归还的地址去读,而这块内存可能已经被人改写了。表现出来就是:有时能跑,有时崩,完全看运气。这种薛定谔的bug最难查。所以我的铁律是:每个free之后,必须马上把指针变量置成NULL。宁可多写几行,也不给野指针留机会。双重释放的问题,通常出现在删除商品和清空列表同时存在的时候。比如你先删了一个商品节点,又调用了一个free_all_products去遍历释放所有节点,而那个被删的节点还残留在商品链表里——其实不会,只要删除时正确修改了next链接,被free的节点已经不在链表上了。但如果你的删除函数里有BUG导致链表没断干净,就等着双重释放吧。遇到这种情况,别急着调释放函数,先把删除后链表还连着谁打出来看。5.2 用内存检测工具把看不见的问题揪出来肉眼查内存泄漏是很痛苦的,直接上工具。Linux环境下我推荐valgrind,Windows上可以用Visual Studio的CRT内存检测,或者Dr. Memory。valgrind的用法非常简单:# 假设编译出来的可执行文件叫 shopping valgrind --leak-checkfull --show-leak-kindsall ./shopping运行之后正常操作一遍程序(加购、结算、退出),然后看valgrind的总结输出。重点关注一行:definitely lost: 0 bytes in 0 blocks只要definitely lost不是0,就说明你malloc出来的内存没被全部释放。valgrind还会直接告诉你泄漏的内存是在哪个源文件的哪一行分配出来的,顺着它找到对应的malloc,往前追它应该被谁free,基本就能定位。我第一次跑valgrind时,发现自己把订单节点整个漏掉了。订单链表只在退出程序时才释放,但我在订单生成函数里malloc了节点,却忘了把它挂到订单链表上——这块内存从挂上那一刻起就没有任何指针指向它,直接泄漏。valgrind清清楚楚指出来,这种问题靠人眼盯代码很难看出来。5.3 三个调试习惯除了工具,我总结了自己写链表项目时最有用的三个调试习惯。第一,写一个print_list函数,专门打印链表的节点地址和关键字段,排查断链特别有用。比如打印每个节点的当前地址 - id - name - next地址,一眼就能看出哪个节点的next指向了不该指向的位置。节点地址连续性是判断链表结构是否正确的直接证据——正常链表每个节点的next地址都应该等于下一个节点的打印地址,一旦出现节点A的next指向节点C,但打印顺序里B夹中间的情况,基本可以断定你某个地方把next指针接错了。第二,边界情况优先测试。空链表加商品、空购物车结算、删除头节点、删除最后一个节点、删除后继续遍历、只买一件商品把库存买光、连续结算两笔订单。这些边界场景比正常操作一遍能暴露更多问题。我自己测试时发现删除头节点和删除尾节点的代码路径跟删除中间节点不是一回事,头节点删除需要改head指针,尾节点删除要改前驱的next,少一个分支就会漏。第三,加一个DEBUG开关宏,把调试信息控制在编译期。比如:#ifdef DEBUG #define dbg_print(fmt, ...) printf([DEBUG] fmt, ##__VA_ARGS__) #else #define dbg_print(fmt, ...) #endif调试时编译命令加-DDEBUG,所有调试输出生效;正常运行时去掉宏,打印语句自动消失,不用手动删代码。我后期做功能扩展时,这个开关帮我省了大量注释时间。6. 从课设到作品:功能扩展与代码规范6.1 值得做的几处改造如果核心功能已经稳定跑通,想让这个项目在答辩或面试时更有说服力,可以从下面几个方向做小改造,工作量不大但观感提升明显。第一,把全局变量换成结构体上下文。很多课设代码喜欢直接声明全局的Product *productHead,写起来方便,但老师和面试官看到全局变量就容易皱眉。更好的做法是定义一个ShopSystem结构体,把三个链表指针和订单计数都放进去:typedef struct { Product *productHead; CartItem *cartHead; Order *orderHead; int nextOrderId; } ShopSystem;所有功能函数都接收一个ShopSystem *system参数,模块边界立刻清晰了。这个改造其实就是把面向过程往模块化推了一步,不涉及新知识,却最能体现代码素养。第二,商品排序功能。可以做一个按价格升序或降序排列的功能,用链表排序实现。注意排序时只需要交换节点里的业务数据,不要动next指针结构(交换next指针极其容易把链表弄断,交换int和double字段则安全得多)。这个题目能顺带把选择排序在链表上的变体练了。第三,订单明细。之前订单节点只存了总额,如果要看每个订单买了哪些商品,需要增加订单明细存储。简单做法是Order加一个char detail[1024],结算时把每件商品的名称和数量拼成一个字符串存进去。虽然丑,但实现成本最低,展示效果却很好——用户查订单时能看到完整清单,而不是只有一串总金额。6.2 后续可以深挖的进阶方向这个项目做完,再往上走有几个方向,都对应不同的能力目标。如果是想练算法,可以把商品ID的线性查找换成哈希索引。对C语言来说,自己实现一个简单的哈希表并不难:用一个固定大小的指针数组,把商品ID哈希到对应桶里,桶内再拉一条链表。这样查找复杂度从O(n)降到O(1),是一个很经典的数据结构优化业务的案例。面试聊项目时能把这个讲清楚,比背一百道八股都有说服力。如果是想练工程化,可以加配置文件。比如把数据文件名、最大商品数量、初始库存这些参数抽到一个config.ini里,程序启动时解析配置。这一步引入了配置与代码分离的思想,是很多真实项目的标配,但课设里极少有人做。加上它,你的项目在可维护性维度上会明显超过同学。如果是想让系统更真实,可以做限时折扣:商品结构体加一个discount字段,结算时按折扣价计算。这个改动会牵动结算、订单金额、文件格式三处逻辑,是很好的全链路改造练习。改的时候你会发现,一个看似简单的字段新增,会扩散到整个系统的各个角落——这种改一处牵全身的体验,正是后面做真实系统天天要面对的。还有文件格式升级的问题。一旦你给商品加了discount字段,旧的数据文件格式就对不上了。正确的做法是文件头写一个版本号,比如第一个int存SCHEMA_VERSION,加载时先读版本,旧版本走迁移逻辑,新版本直接加载。这个版本号机制我已经习惯性地写进所有需要持久化的项目里了,成本一行int,收益是永远不怕改格式。另外提一句中文输出乱码的问题。Windows控制台默认代码页可能跟源码编码对不上,中文输出全是问号。我的建议是学习阶段直接输出英文界面,省下时间用在逻辑上;如果一定要中文,Windows下可以用SetConsoleOutputCP(65001)切到UTF-8,但不同编译器环境下效果还不一样,很容易在环境配置上浪费大量时间。个人体会是这个项目值得完整手写一遍,哪怕你期末时间再紧张,也尽量不要整段复制别人的代码。每一个malloc、每一个free、每一个next指针的跳转,都亲手敲出来、亲手debug过,你对C语言指针和内存的那层恐惧感才会真正消失。我做完这个项目之后,再去刷那些链表相关的面试题,脑子里会自动浮现出购物车删除节点时prev指针怎么跳、结算扣库存时为什么先遍历校验再执行修改——曾经抽象的概念,全都有了具体的画面。后来面试遇到删除链表倒数第K个节点这类题,我几乎是一秒之内就想清楚了边界条件,靠的就是在这个项目里反复琢磨链表边界练出来的直觉。如果你打算把这个项目当作找工作时的项目经历,记住一点:不要只写实现了商品管理和购物车。你真正要讲的是,你怎么设计数据结构来保证订单价格不被商品价格变动影响,你怎么用先校验后扣减来避免复杂的回滚逻辑,你怎么用临时文件加rename保证数据文件不损坏,以及你怎么用valgrind把内存泄漏清零。这些细节,才是把一个课设作业变成值得讲的项目的分水岭。