尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用C语言做一个唐诗输入输出小工具:结构体、文件读写与中文编码实战

用C语言做一个唐诗输入输出小工具:结构体、文件读写与中文编码实战 很多人练 C 语言都是写个计算器、打印个九九乘法表、摆弄一下冒泡排序就以为把“输入输出”学到了手。结果真到了做课程设计或者自己写个小工具的时候才发现连“从键盘读一段带空格的中文诗句再原样输出”都写不利索更别说把数据存到文件里、下次启动再读回来。我后来换了个思路用 C 语言做了一个唐诗输入输出的命令行小工具能把诗录进去、存到文件、从文件读出来、按关键词搜索、随机抽取温习、还能顺手统计一下字数。这个项目看起来不大却把结构体、字符串处理、指针、文件读写、命令行交互这些 C 语言最核心的东西全串起来了。如果你学完了 C 语言的基础语法但一直不知道拿它做什么或者正在发愁课程设计选题这篇文章就是给你准备的。我会把一个完整的 C 语言“唐诗输入输出”小项目从需求分析、数据结构设计、代码实现到踩坑过程全部拆开讲。所有代码都是标准 C 语言没有花哨的库复制下来就能在 Windows 或 Linux 的终端里编译运行。项目里最难的不是某个语法点而是那些教程里不会明说、只有自己真正上手才会遇到的细节比如中文编码导致的乱码、scanf残留在缓冲区里的换行符、Windows 和 Linux 下文件读写行为不一样这些地方我都踩过坑也会把排查思路完整写出来。1. 为什么选“唐诗输入输出”当练手项目而不是计算器1.1 这个题目看起来简单实际上把 C 语言的关键点串成了闭环很多初学项目最大的问题是“没有数据”。计算器算完一个数就扔了图书管理系统又过于依赖数据库初学者根本接触不到底层。唐诗输入输出不一样一首诗有诗题、作者、正文这个结构非常清晰是一个天然的“记录”。更妙的是它要求你先从终端把数据收集起来再落盘保存然后重新读取、检索、展示——这不就是“输入输出”的完整闭环吗C 语言里的printf和scanf只是最表面的输入输出真正在实际项目里遇到的是“字节从键盘到内存、从内存到硬盘、再从硬盘回到屏幕”这一整条链路。我当时选这个题目的另一个原因是唐诗的正文天然是中文这会让很多初学者提前撞上编码问题。你在终端里敲“床前明月光”程序读进去的是什么编码打印出来为什么会乱码为什么strlen数出来一个字数是 15 而不是 5这些问题在纯英文练手题里永远不会出现但在真实项目里迟早要面对。早点踩过这个坑后面写中文处理相关的程序会轻松很多。1.2 一个入门项目应该覆盖哪些核心知识点在你自己动手之前先看一张我从这个项目里提炼出来的知识点对照表。这些不是硬背出来的考点而是你在实现每个功能时必然要碰到的关卡功能模块涉及的 C 语言核心知识为什么绕不开录入诗的信息fgets、缓冲区清理、字符串去除换行符gets已废弃scanf读不了带空格的正文用结构体保存一首诗typedef struct、字符数组、指针程序里要同时管理多首诗的多个属性保存到文件fopen追加模式、fprintf没有持久化程序一关数据就没了从文件读取fopen读模式、fgets、strtok分割字段核心难点比单纯的“读一个数字”复杂得多检索和随机抽取strstr字符串匹配、rand随机数让项目从“存取”升级为“可用”中文处理与对齐UTF-8 编码、字节宽度计算中文汉字是变长编码和英文处理方式完全不同这张表也是我在动手前给自己列的清单。每完成一行功能就在对应的格子上打个勾。如果你做完这个项目后能不看参考独立把上面每一项都解释清楚那 C 语言的基础基本就算扎实了。2. 动手前的关键决策数据结构与存储格式2.1 用结构体把一首诗变成程序里的一个变量写任何程序之前先想清楚数据长什么样。一首诗在现实里是文字但在程序里必须变成有固定结构的数据。最直观的做法就是定义一个结构体#define MAX_TITLE 64 #define MAX_AUTHOR 32 #define MAX_CONTENT 512 typedef struct { char title[MAX_TITLE]; // 诗题 char author[MAX_AUTHOR]; // 作者 char content[MAX_CONTENT]; // 正文内容 } Poem;这里我故意用了定长字符数组而不是指针。原因很简单作为练手项目定长数组可以节省大量内存管理的心力不用考虑malloc和free的配对问题赋值直接用strcpy就行。等你想进阶再把它改成char *title用malloc按需分配那会引入新的挑战但那是后话。可能有人会问正文 512 字节够吗《长恨歌》这种长篇肯定不够但一个入门项目不必考虑极端情况。我在代码里加了宏定义把上限集中管理以后想扩大只需要改一处。另外这种设计还有个好处每首诗占用固定大小的内存后续可以很方便地放进数组逻辑简单不容易出错。2.2 用数组还是链表一张对比表说明白确定了单条记录的形态下一个问题是怎么存放多首诗。两种主流方案是定长数组和单向链表。我把它们的核心差异拉了一张表对比维度定长数组单向链表内存分配方式一次性分配连续每次插入动态分配非连续访问第 N 首诗直接poems[N]O(1)从头遍历O(N)容量限制需预先设定上限不限制直到内存耗尽删除/插入中间元素需要移动元素改指针即可代码复杂度低适合入门中高容易出指针错误对这个项目的适配度高略复杂但更具扩展性我这个项目最终选了定长数组定义了Poem poems[MAX_POEMS]其中MAX_POEMS设为 100。为什么因为唐诗输入输出的核心操作是追加、遍历、查找没有频繁的中间删除数组完全够用代码也更好读。链表的优势在这个场景下发挥不出来反而容易让初学者迷失在next指针里。我建议你先用数组把整个流程跑通再考虑改成链表作为进阶练习那才是链表该出现的时机。2.3 文件存储格式我为什么坚持“一行一首诗”数据要持久化就必须设计文件格式。这是很多人忽略的环节可它恰恰决定了后续所有读写代码的复杂程度。我一共比较过三种方案第一多行文本裸存每首诗占四五行靠空行分隔。这种格式人类阅读起来最舒服但程序解析非常痛苦你怎么知道哪个空行是“诗的结束”哪个是“段落之间的空行”多读几首就会翻车。第二二进制文件用fwrite把结构体整块写进去。这种方案存取快代码也短但缺点很明显文件不可读换个平台可能有字节序问题而且一旦结构体定义变了旧文件全部作废。对初学者来说调试时的不可见性是大忌。第三每行一首诗字段之间用分隔符隔开。这也是我最终采用的方案文件里每一行记录一首完整作品静夜思|李白|床前明月光/疑是地上霜/举头望明月/低头思故乡 春晓|孟浩然|春眠不觉晓/处处闻啼鸟/夜来风雨声/花落知多少三个字段用|分隔诗题、作者、正文。正文内部原本是多行文字我把换行符统一替换成/这样一首诗在文件里永远只占一行。解析时用strtok按|切分展示时再把/还原成换行。这种格式最大的优点是“一行一条记录”读取时逐行处理即可逻辑简单而且文件本身能直接打开看出问题了肉眼就能发现。这也是一种很通用的思路很多真实项目的配置文件、数据文件都在用类似的分隔符方案。3. 录诗功能从键盘到结构体的完整链路3.1 用 fgets 而不是 gets 或 scanf是新手必须养成的习惯录入诗的信息是第一道坎。很多教材早年教的是gets但这函数已经被 C11 标准彻底移除了原因就是它无法限制输入长度用户一多按几下键盘就能把缓冲区写穿属于最经典的安全漏洞。而scanf(%s, ...)也靠不住它遇到空格就停下来连“床前明月光”这种五个字的诗题都读不完整。正确做法是使用fgetsvoid input_poem(Poem *p) { printf(请输入诗题); fgets(p-title, MAX_TITLE, stdin); p-title[strcspn(p-title, \n)] \0; printf(请输入作者); fgets(p-author, MAX_AUTHOR, stdin); p-author[strcspn(p-author, \n)] \0; printf(请输入正文每句一行输入空行结束\n); p-content[0] \0; char line[128]; while (1) { fgets(line, sizeof(line), stdin); if (line[0] \n) { break; } line[strcspn(line, \n)] \0; if (p-content[0] ! \0) { strcat(p-content, /); } strcat(p-content, line); } }这里有个很关键的细节fgets会把用户按回车产生的换行符也读进字符串里。如果不去掉存进文件时就会莫名其妙多出换行。我用strcspn(p-title, \n)找到换行符的位置直接把它替换成字符串结束符\0这就是常见的“去换行”操作。写这一行代码时最好能理解它背后的原理而不是复制粘贴完事。3.2 正文里的换行符为什么统一替换成斜杠录入正文时用户习惯一句一行按下回车换行。如果任由这些换行符留在content里那么保存到文件时就会出现下面的情况静夜思|李白|床前明月光 疑是地上霜 举头望明月 低头思故乡文件里这首诗一下占了四行解析程序再也没法用“一行一首”的规则去读了。所以我在录入时做了一个转换检测到用户输入空行就结束每一句读进来后如果不是第一句就在前面补一个/再拼接上这一句。最终content里存的是床前明月光/疑是地上霜/举头望明月/低头思故乡所有换行都被转义成了普通字符。这个设计值得你多想一步它本质上是在解决“数据内部的分隔符和数据记录之间的分隔符冲突”问题。只要保存的文件里还有|和换行符出现解析时就必须处理各种边界情况万一诗题里也有|怎么办万一正文里本来就有/怎么办我这次选择用相对少见的分隔符来规避冲突字段间用|行间用/。对于入门项目来说够用了但如果你继续深入你会发现更健全的方案是做转义处理或者干脆用专门的序列化库思路是一样的。3.3 录完立即写文件而不是攒到最后一起写数据录好之后什么时候保存我最初的版本是“全部录完选退出时统一写盘”结果一次断电让我辛辛苦苦敲的五首诗全没了。后来改成“每录完一首确认无误就立刻追加写入文件”int save_poem(const Poem *p, const char *filename) { FILE *fp fopen(filename, a); if (fp NULL) { perror(无法打开文件); return -1; } fprintf(fp, %s|%s|%s\n, p-title, p-author, p-content); fclose(fp); return 0; }使用a追加模式写文件每次只在末尾追加一行不会覆盖已有内容。这里要记住打开文件后一定要检查返回值只要fopen返回NULL就要立刻提示用户否则后面所有操作都在解引用空指针程序必崩。每写一次就fclose一次看似啰嗦但保证了写入内容立刻落盘比攒一批最后写入要安全得多。4. 读诗与展示从文件恢复并打印出好看的排版4.1 按行读取再拆字段是最容易调试的读取方式程序重启后要把文件里的诗重新加载进内存。逻辑很简单逐行读取遇到文件末尾就停下每行按|拆出三个字段填进结构体数组。int load_poems(Poem poems[], int max, const char *filename) { FILE *fp fopen(filename, r); if (fp NULL) { return 0; // 文件不存在时视为空库 } char line[MAX_TITLE MAX_AUTHOR MAX_CONTENT 4]; int count 0; while (count max fgets(line, sizeof(line), fp) ! NULL) { line[strcspn(line, \n)] \0; char *t strtok(line, |); char *a strtok(NULL, |); char *c strtok(NULL, |); if (t NULL || a NULL || c NULL) { continue; // 跳过格式不对的行 } strcpy(poems[count].title, t); strcpy(poems[count].author, a); strcpy(poems[count].content, c); count; } fclose(fp); return count; }这段代码里我特别做了两件事。第一文件不存在的场景被当成“空库”处理而不是直接报错因为用户第一次运行程序时仓库本来就是空的。第二用strtok拆分时任何字段为空都跳过这一行防止脏数据把程序搞挂。这里有个隐蔽的坑strtok会直接修改传入的字符串把分隔符替换成\0所以你不用担心原字符串被污染但这也意味着你不能传字符串字面量进去一旦传了就会往只读内存里写直接段错误。4.2 展示层要把斜杠还原成换行数据从文件读出来时content里存的还是带/的压缩格式。给用户看之前要还原void display_poem(const Poem *p) { char temp[MAX_CONTENT]; strcpy(temp, p-content); for (int i 0; temp[i] ! \0; i) { if (temp[i] /) { temp[i] \n; } } printf(《%s》 %s\n, p-title, p-author); printf(----------------------------------------\n); printf(%s\n, temp); }先在临时缓冲区里做还原是因为原始结构体里的content还要在保存时继续以/格式写回文件不能直接改。这里也体现了一个很重要的编程习惯不要在原始数据上做展示层的修改多拷贝一份出来更安全。如果你直接把content里的/替换成\n下次保存文件时格式就乱了这是一个我见过很多人会犯的隐形错误。4.3 中文对齐为什么 printf 的宽度控制对汉字无效展示古诗时为了让界面整齐我给诗题设置了固定宽度比如printf(%-20s, p-title)。结果实测直接翻车中文诗题根本对不齐有的空格多有的少。原因是printf的宽度单位是“字节”而一个 UTF-8 编码的汉字占 3 个字节。一个四个汉字的诗题实际占 12 个字节在%-20s看来只差 8 个空格而两个汉字的诗题占 6 个字节要补 14 个空格自然就参差不齐。真正要按“显示宽度”对齐需要自己写一个统计函数英文字符按 1 算汉字按 2 算然后动态计算要补多少空格。我在这里给一个简化版本int display_width(const char *s) { int width 0; while (*s) { unsigned char ch (unsigned char)*s; if (ch 0x80) { width 1; s; } else if ((ch 0xE0) 0xE0) { width 2; // UTF-8 汉字占3字节显示宽度约等于2个英文字符 s 3; } else { s; } } return width; }但说实话命令行工具里强行对齐的性价比不高所以我最终选择了“诗题和作者一行正文自起一行”的极简排版。如果你想追求更好的视觉效果这个display_width函数可以作为进阶练习这也是不少人写“终端表格”时的通用办法。5. 让项目活起来检索、随机温习与统计功能5.1 关键词搜索一行 strstr 背后的边界问题只做存取的话这个程序还比较死板。我给它加了一个搜索功能输入“明月”就能把所有带“明月”的诗过滤出来。实现非常直接遍历每一首诗分别在诗题、作者、正文里做子串匹配。int search_poems(Poem poems[], int count, const char *keyword) { int found 0; for (int i 0; i count; i) { if (strstr(poems[i].title, keyword) ! NULL || strstr(poems[i].author, keyword) ! NULL || strstr(poems[i].content, keyword) ! NULL) { printf(%d. , found 1); display_poem(poems[i]); found; } } return found; }这里有一个值得注意的细节strstr匹配的是/分隔的压缩正文如果用户搜索“明月”压缩正文和还原后没有任何区别不受影响。但如果你搜索的内容恰好包含换行符几乎不可能压缩格式就会匹配失败。更稳妥的做法是在搜索前先把正文还原到临时缓冲区再匹配。虽然本项目用不上但你把这个边界记在脑子里以后处理字符串检索时就会多想一步。5.2 随机抽一首srand 只调用一次这是很多新手的通病随机温习功能也很简单随机生成一个下标输出对应那首诗。但我见过太多人把srand(time(NULL))写进循环里结果每过一秒抽出来的都是同一首因为time(NULL)的精度是秒循环执行时间不到一秒随机种子根本没变。正确做法是在main函数里初始化一次srand((unsigned)time(NULL));之后每次需要随机数直接rand() % count就行。rand()的均匀性在“从 100 首诗里抽 1 首”这种场景下足够用了不值得为此引入更重的随机数库。这个体会可以用在很多地方不是所有地方都要上最好的算法工具适合场景才是第一原则。5.3 字数统计别直接用 strlen汉字根本不是 1 个 char我加的最后一个小功能是统计每首诗的字符数。很多初学者会直接strlen(content)得到的数字非常离谱因为strlen数的是字节数不是字符数。在 UTF-8 下一个汉字占 3 个字节“床前明月光”这五个字用strlen数是 15。要统计真正的汉字个数就得按 UTF-8 的编码规则解码。我的简化做法是遍历字符串凡是最高位为 1 的多字节序列计数加一然后跳过后续的连续字节。这也是中文文本处理的基本功虽然不够严谨但应付纯中文古诗的场景足够了。顺带还能统计句数数一下content里面/的数量再加一。统计功能虽然不起眼却让使用者对“字节”和“字符”有了直觉上的认知差异这是纯英文练手题不可能带来的收获。6. 我在实际运行中踩过的三个坑与完整排查链路6.1 终端里汉字突然变乱码我一级一级排查出的原因第一次把程序放到 Windows 上跑录入的中文完全正常可一保存到文件再读出来就乱码了。我当时的排查链路是先直接在main函数里printf(床前明月光\n)发现屏幕输出正常说明终端编码没问题再把文件打开看发现文件里存的文本也是正常的中文说明fprintf写入正常于是问题就锁定在读取环节。后来发现罪魁祸首是 Windows 的文本模式换行转换Windows 把\n存成\r\n读取时再转回\n这本来自动处理但我的文件里第二行开头紧跟着字段。某些版本的工具在 UTF-8 无 BOM 且混合\r\n时会出现解析偏移导致strtok切出的字段带上半个汉字。解决办法有两个要么统一用二进制模式fopen(filename, rb)和wb关闭换行自动转换要么在读取每行后把字符串末尾的\r也一并清掉。我在代码里做了后者——在去掉\n之后顺手判断一下最后一个字符是不是\r是就也置为\0。这些底层的编码和换行问题只有多平台跑过才能真正理解光看教材不会遇到。6.2 录入菜单时 scanf 之后 fgets 直接“跳行”一查是缓冲区残留我的程序主菜单用的是scanf(%d, choice)选完数字后调用input_poem。结果每次fgets都跳过输入直接拿到一个空字符串。起初以为是fgets的问题后来发现就是经典的键盘缓冲区残留scanf读走数字后用户按的回车键\n还留在缓冲区里紧接着的fgets一读就读到了这个换行符自然认为用户输入了一行空内容。排查出原因后的解决方案很简单在scanf后面清空缓冲区int c; while ((c getchar()) ! \n c ! EOF) { // 吞掉缓冲区里残留的字符 }这段循环会把读取数字之后剩下的所有字符全部消费掉。其实更稳妥的做法是整个项目统一使用fgets读菜单选项再用atoi或sscanf转成整数彻底避免scanf和fgets混用。我后来把代码改了全部走fgets问题再没出现过。这条经验特别值得刚入门的人记住scanf和fgets尽量别混着用混用就要有清理缓冲区的意识。6.3 文件读出来最后几首诗不见了问题出在缓冲区大小和换行判断有一次录入了 20 首诗重启程序后只显示出来 18 首最后两首直接消失。我也不慌先用cat命令直接看文件发现 20 行都在。于是问题定位在读取循环里。检查后发现fgets读入一行时如果这一行特别长——比如一首五言绝句加上/后达到 300 多字节——就超过了我的缓冲区上限一半缓存区根本装不下整行fgets只读到了前半行第二次循环又把后半行当成新记录导致字段不全被continue跳过。我当时的缓冲区大小是MAX_TITLE MAX_AUTHOR MAX_CONTENT 4看起来够用但忽略了 Windows 下\r\n会多占一个字符。把缓冲区再扩大一倍并且在解析前明确判断line是否完整读到换行符问题就解决了。这里也更印证了之前的判断文件格式要“一行一首”这个约束让缓冲区大小的计算变得可预测如果一首诗在文件里占好几行缓冲区边界问题会复杂得多。6.4 给初学者的最后建议先画功能图再定文件格式最后写代码我自己做完这个小项目最大的感受是代码反而是整个项目里最不费脑筋的部分真正费脑筋的是想清楚数据怎么放、文件格式怎么定、边界情况怎么处理。我在动手前先画了一张简单的功能图只分四块录入、浏览、查找、随机后来又加了统计。功能图定下来后才去设计Poem结构体和poems.txt的格式。如果一上来就写代码写到一半必然发现数据结构不适合、读写格式有问题然后推倒重来那才是最耗时间的。这个唐诗输入输出工具到现在还在我本地留着偶尔想背诗就打开随机抽一首。它算不上什么了不起的作品但它让我第一次感觉到 C 语言的输入输出不是教材里那两页printf和scanf而是从真实数据到持久化存储再到展示的一整条链路。之后我再去学文件操作、学指针、学字符串处理因为有这个项目的底子在就再也没有哪块知识点是悬空的。希望这篇完整的实践记录也能让你的 C 语言路线少走几步弯路。
返回列表