资讯动态

C++实现命令行背单词工具:技术拆解与工程实践

发布时间:2026/10/23 12:45:52 来源:尧图企业网站定制
简介用C实现的背单词软件源码包面向编程学习者与需要课程设计参考的同学融合单词展示、拼写测试、复习计划与学习记录等功能可直观练习面向对象设计、文件读写和基础算法适合作为C实战练手或外语学习工具开发样例。包内共31个文件含19个cpp、4个h、6个txt及makefile、dic词典文件cpp按功能拆分h声明类接口txt存放词库与配置makefile便于一键编译压缩包仅641KB。已有681人学习下载。源码覆盖从txt单词库加载、用户进度跟踪、测试结果统计到智能调整复习策略的完整流程多个类抽象和交互模块展示了界面组织、记忆模式切换与数据持久化方法。阅读代码可掌握C标准库文件操作、容器管理和算法应用进一步扩展图形界面或听力训练也不困难方便二次开发或迁移到新平台。1. 背单词软件用 C 实现一个离线命令行工具的拆解价值很多人觉得背单词是手机 App 的天下UI 好看、词库全、能语音跟读。但真到自己动手我反而更推荐先拆这个 C 实现的命令行背单词程序。它不依赖网络、不依赖界面框架整个程序就是一个可执行文件词库加载、遗忘曲线、错题重背、进度持久化全在本地闭环。没有数据库没有前端标准库加文件读写就把背单词这件事跑了起来。适合刚学完 C 想找个完整项目练手的人也适合想做词库工具二次开发但不想被界面框架干扰的人。整个项目把背单词拆成一条清晰链路读词、排队、复习、测试、存状态每一段都能单独看懂、单独替换。2. 技术选型与工程结构C17、文件存储和可移植构建2.1 技术栈取舍为什么是 C17 加本地文件而不是 Qt 和数据库先回答一个最常见的问题背单词软件为什么不用 Qt 写界面Qt 能做但会引入 moc、qmake、事件循环、线程回调一堆概念把“背单词算法”本身淹没在界面代码里。做命令行版本交互用 std::cin / std::cout标准库就够编译产物也小拷到别的机器直接能跑。存储层也是一样。词库顶天几千个词条复习进度是“单词 熟练度 下次复习时间”的结构用一个 std::map 就能装载。用数据库反而要处理动态库依赖、SQL 语句拼接、游标遍历。文件存储配合按行解析、按行序列化逻辑直观出问题能用文本工具直接看。等词库量级到百万词条、要做模糊搜索和标签过滤时再迁移 SQL 不迟。具体选型归纳成表项选择原因语言标准C17结构化绑定、std::optional、filesystem 都可用交互方式std::cin / std::cout跨平台不引入第三方依赖词库存放文本文件TAB 分隔记事本就能维护解析逻辑一目了然进度存放文本序列化方便排错避免结构体直接落盘的对齐问题构建工具CMake 3.10Linux、Windows 下同一套脚本编译2.2 目录结构四个源文件和一个数据目录的职责边界源码分成四个核心文件而不是堆在一个 main.cpp 里。这样改词库格式的人不用碰算法改算法的人不用碰文件读写。vocab_project/ CMakeLists.txt include/ word.h // 单词结构体和记忆状态 scheduler.h // 间隔重复算法接口 storage.h // 进度存取接口 src/ main.cpp // 入口菜单和主循环 word_loader.cpp // 解析词库文本 scheduler.cpp // 简化版 SM-2 遗忘曲线 reviewer.cpp // 四选一生成与判题 storage.cpp // 进度文件的保存和加载 data/ words.txt // 内置词库 progress.txt // 个人进度运行时生成 tests/ test_scheduler.cpp每个文件的职责一眼能看清word.h 里定义结构体word_loader 只负责把文件解析成 std::vector scheduler 只认“单词当前状态”和“这次成绩”算出下一次复习时间storage 只管把内存状态变成文本、把文本变回内存。main.cpp 是唯一能把用户输入和上面几个模块串起来的角色。这个分层的好处是想加“错词本”功能只需要在 storage 加一张表想加“发音”功能只需要在 Word 里塞一个音频路径字段。改数据结构和改算法的人不会互相打架这也是项目适合二次开发的原因之一。2.3 可移植构建CMake 写法与两个 Windows 注意点CMakeLists.txt 写得简陋没问题但有几个点要提防。第一个是 MSVC 下调用 fopen 会报 C4996 警告新手容易被吓到第二个是 Windows 控制台默认代码页和文件编码不一致这个放到第五章展开。先看构建文件cmake_minimum_required(VERSION 3.10) project(vocab_cli) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(MINGW OR CYGWIN OR MSVC) add_definitions(-D_CRT_SECURE_NO_WARNINGS) endif() add_executable(vocab src/main.cpp src/word_loader.cpp src/scheduler.cpp src/reviewer.cpp src/storage.cpp) target_include_directories(vocab PRIVATE include) enable_testing() add_executable(test_scheduler tests/test_scheduler.cpp src/scheduler.cpp) target_include_directories(test_scheduler PRIVATE include)逻辑说明把测试单独编译成一个 test_scheduler 可执行文件它不依赖 main.cpp只有 scheduler.cpp 和测试文件。这样测循环复习算法时不用把整个项目跑起来。_CRT_SECURE_NO_WARNINGS用编译器宏直接屏蔽了 MSVC 的安全函数告警然后在 target_include_directories 里把 include 目录暴露给两个可执行目标。参数说明项目名 vocab_cliC 标准 17。如果编辑器只装了老版本 GCC把 17 改成 14 也能编译前提是不用 filesystem 相关 API。测试目标通过 ctest 执行也可以直接跑./test_scheduler看输出。跨平台还有一个容易忽略的点控制台里用 ANSI 颜色码比如\033[31m在 Linux/macOS 直接生效在 Windows 10 以上的 cmd 需要开启虚拟终端序列。常见做法是#ifdef _WIN32 HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); DWORD mode 0; GetConsoleMode(hOut, mode); SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); #endif这段只针对 _WIN32 平台需要包含 windows.h。不写的后果是Windows 下单词列表里到处是裸的←[31m看着像乱码还会把后面的文字颜色全部带偏。3. 核心模块拆解一个单词从进词库到复习完成的完整链路3.1 词库加载从文本行到内存里的 Word 结构词库文件用“一行一个词条”格式是单词TAB释义。用 TAB 分隔而不是逗号因为释义里天然有逗号和分号。以 # 开头的行是注释便于维护词源备注。先看结构体定义// include/word.h struct Word { std::string text; // 单词原文 std::string meaning; // 释义多个义项用分号隔开 int stage 0; // 熟练度0 表示新词1-5 逐步递增 int interval_day 0; // 当前复习间隔天 int review_count 0; // 累计复习次数 int error_count 0; // 累计答错次数 int64_t due_at 0; // 下次复习时间戳Unix 秒 };再看向量的加载函数// src/word_loader.cpp bool load_word_file(const std::string path, std::vectorWord out) { std::ifstream in(path); if (!in.is_open()) return false; Word w; std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] #) continue; auto pos line.find(\t); if (pos std::string::npos) continue; w.text line.substr(0, pos); w.meaning line.substr(pos 1); w.stage 0; w.interval_day 0; w.review_count 0; w.error_count 0; w.due_at 0; out.push_back(w); } return true; }逻辑说明这个函数只做“按行切分 首次状态初始化”不做去重、不做排序。去重要等整个词库加载完再做二次遍历放在这里会让函数职责变重。分隔符取find(\t)返回的第一个位置所以释义内部即使再出现 TAB也不会影响第一个词的切分。参数说明path 是词库文件路径out 是复用的 vector多次调用可以往同一个容器追加。如果文件不存在返回 false 并保持 out 原样由调用方决定是退出还是提示用户换词库路径。加载完成后main 里打印已加载 N 个词条这里建议顺手做一次去除重复 text 的操作。3.2 遗忘曲线引擎一个简化版 SM-2 间隔重复算法完整 SM-2 是超级记忆法的经典算法参数包括难度系数、每次复算的 easiness factor。对背单词项目来说一套递增间隔已经覆盖大多数场景而且容易背下来。这里的实现比完整版少算了难度衰减换来的是逻辑肉眼可读。// src/scheduler.cpp int next_interval(const Word w, bool pass) { if (!pass) { return 1; // 答错强制回到 1 天后再见 } const int scale[] {1, 2, 4, 7, 15, 30}; int idx w.stage; if (idx 5) idx 5; // 熟练度封顶 return scale[idx]; } int64_t calc_due_at(int interval_day) { // 以今天的本地零点为基准避免跨天误差 std::time_t now std::time(nullptr); std::tm local *std::localtime(now); local.tm_hour 0; local.tm_min 0; local.tm_sec 0; std::time_t today_start std::mktime(local); int64_t due static_castint64_t(today_start) static_castint64_t(interval_day) * 86400; return due; }逻辑说明答错一律回到 1 天错题必然排在明天优先级最高的位置答对后按当前 stage 查表。stage 从 0 开始所以一个新词第一次答对的间隔是 scale[0] 1 天第二次是 2 天第三次 4 天直到 30 天封顶。之后无论复习多少次最多 30 天出现一次。参数说明calc_due_at 特意做了“归零到本地零点”的处理。如果直接用time(nullptr) interval * 86400跨月、跨夏令时的时候会出现“刚背完的词第二天一早又出现在队列”的偏差。这里先把 tm 的小时分秒清零再用 mktime 换算回时间戳保证复习队列按自然日滚动。完整版 SM-2 还会根据每次答题的质量调整 easiness这里选择不做原因是测试环境和真实使用场景下用户对“1 天后、2 天后、7 天后”的感知比抽象的难度系数更直接。3.3 复习队列生成今天该出现哪些词队列生成有两个来源stage 为 0 的新词和 due_at 已经小于当前时间的旧词。新词每天最多放 10 个进学习流避免一天灌 50 个新词让人崩溃。这个数字放在一个常量里想改难度就改一处。// src/reviewer.cpp const int kMaxNewWordsPerDay 10; std::vectorconst Word* build_today_queue( const std::vectorWord words, int64_t now) { std::vectorconst Word* queue; int new_added 0; for (const auto w : words) { if (w.stage 0) { if (new_added kMaxNewWordsPerDay) { queue.push_back(w); new_added; } } else if (w.due_at now) { queue.push_back(w); } } // 错得多的旧词排前面 std::stable_sort(queue.begin(), queue.end(), [](const Word* a, const Word* b) { return a-error_count b-error_count; }); return queue; }逻辑说明新词和到期旧词混进同一个队列然后按 error_count 降序稳定排序保证同批次里错得多的先复习。用const Word*而不是拷贝 Word避免每次构建队列都复制整串字符串队列只读不会修改词库。参数说明now 是调用方传入的当前时间戳这样单元测试可以模拟“一周以后”来验证队列变化。kMaxNewWordsPerDay 是每日新词上限成人背单词一般 10 到 20 之间如果做考试冲刺版可以把 20 作为默认值重新编译。队列里出现同一个单词两次的情况只在词库有重复 text 时发生所以 3.1 里那句去重是必要的。3.4 四选一选项生成与判题逻辑测试环节是英文给释义用户选一个。干扰项从当前词库随机抽但有两个前提不能是正确答案本身不能和其他选项释义一模一样。后者比前者更容易被忽略。std::vectorconst Word* build_options( const std::vectorWord pool, const Word target) { std::vectorconst Word* opts {target}; std::mt19937 rng(std::random_device{}()); std::vectorint idx(pool.size()); std::iota(idx.begin(), idx.end(), 0); std::shuffle(idx.begin(), idx.end(), rng); std::unordered_setstd::string used; used.reserve(4); used.insert(target.meaning); for (size_t i 0; i idx.size() opts.size() 4; i) { const Word* cand pool[idx[i]]; if (cand-text ! target.text used.insert(cand-meaning).second) { opts.push_back(cand); } } return opts; }逻辑说明先把正确答案放进结果再洗牌整个词库的索引逐个往 opts 里塞候选词。used 集合存的是释义字符串而不是单词文本原因是释义相近的两个词会互相干扰容易造成“两个都对”的错觉影响复习效果。参数说明pool 是当前整体词库target 是当前测试的单词返回的 vector 顺序上第一个是正确项所以判题只比对用户选的指针是否等于 opts[0]。显示给用户前还需要再 shuffle 一次把正确答案的位置打乱否则选项位置永远固定用户会记住位置而不是释义。如果词库总词数小于 4这个函数返回不足 4 项调用方要补一个“以上都不对”之类的虚拟选项。4. 实战代码主循环、进度持久化与统计报表4.1 命令行主循环命令字符驱动的菜单交互设计原则是能打一个字母不打一个单词。菜单列出 learn、review、stat、quit 四项同时接受首字母减少输入成本。这个循环里不写任何业务细节所有命令都转发给对应的 run 函数。// src/main.cpp int main() { std::vectorWord words; if (!load_word_file(data/words.txt, words)) { std::cerr 词库加载失败请检查 data/words.txt\n; return 1; } std::cout 已加载 words.size() 个词条\n; std::string cmd; while (std::cout std::cin cmd) { if (cmd l || cmd learn) { run_learn(words); } else if (cmd r || cmd review) { run_review(words); } else if (cmd s || cmd stat) { run_stat(words); } else if (cmd q || cmd quit) { save_progress(words, data/progress.txt); break; } else { std::cout 未知命令输入 l/r/s/q 之一\n; } } return 0; }逻辑说明主循环把“解析命令”和“执行动作”分离。后面想改成方向键选择菜单只需要换输入解析层run 系列函数不动。退出时统一 save 一次日常学习过程中每答完一组题也会写临时文件两者性质不同下一节说清楚。参数说明词库路径和进度路径目前硬编码。对学习者够用但给朋友用时建议在启动时读一个 config.ini或者用 CMake 预编译宏把路径编进去。硬编码的好处是简单坏处是用户移动目录后要重新编译。4.2 进度持久化不要直接 write 整个结构体早期版本直接out.write((const char*)w, sizeof(Word))跑起来没问题。换到 MSVC 后旧进度全部读不出来原因有几层结构体成员对齐和 sizeof 在不同编译器下有差异std::string 内部是堆指针直接落盘等于存了一个无效地址。踩完之后改成按字段序列化。std::string serialize_words(const std::vectorWord words) { std::ostringstream ss; for (const Word w : words) { ss w.text | w.stage | w.interval_day | w.review_count | w.error_count | w.due_at \n; } return ss.str(); }逻辑说明每一行对应一个词条字段用|分隔。文本格式的劣势是占用空间稍大优势是进度文件可以直接用记事本打开字段错乱一眼能看到。由于 text 和 meaning 里可能出现|或换行这套代码最多算“够用”。想要长期维护我会在第六章的改造方案里换成 JSON 作为正式解法。参数说明stage、interval_day、review_count、error_count 都是整数序列化时以十进制写出due_at 是 int64_t用文本存可以避免不同平台 time_t 位宽不同的问题。读入端解析逻辑是从每行拆字段解析失败的行直接跳过不中断整个文件加载这样坏一行不会丢全部进度。4.3 统计报表进度条和错误率柱状图背单词最需要反馈命令行工具也能给反馈总词数、已掌握数、学习中数以及按错误率倒序的前十个词。不引入 ncurses就用字符画柱状图。void print_stats(const std::vectorWord words) { int new_cnt 0, learning 0, mastered 0; for (const Word w : words) { if (w.stage 0) new_cnt; else if (w.stage 3) learning; else mastered; } int total words.size(); printf(词库统计新词 %d | 学习中 %d | 已掌握 %d | 总量 %d\n, new_cnt, learning, mastered, total); std::vectorconst Word* worst(words.size()); for (size_t i 0; i words.size(); i) worst[i] words[i]; std::partial_sort(worst.begin(), worst.begin() std::minsize_t(10, worst.size()), worst.end(), [](const Word* a, const Word* b) { return a-error_count b-error_count; }); for (size_t i 0; i 10 i worst.size(); i) { int bar std::min(worst[i]-error_count, 40); printf(%-15s %3d | %s\n, worst[i]-text.c_str(), worst[i]-error_count, std::string(bar, #).c_str()); } }逻辑说明统计函数不依赖任何全局状态传入 words 就能算。柱状图宽度取 error_count 和 40 的较小值防止某词错了几百次把终端刷满。# 号数量和错误次数成正比视觉上比数字直白。worst 数组用 partial_sort 只排前 10避免对完整词库做全量排序。参数说明printf 里的%-15s表示按 15 个字符宽度左对齐词长超过 15 个字符时自动撑开不影响显示bar 上限 40 对应最恶劣的错词也可以改成 50 让图形更夸张。学习中的判定是 stage 1 到 3stage 4 和 5 算已掌握这个划分标准可以在 print_stats 里按自己的复习策略调整。5. 避坑指南编译失败、乱码与进度丢失的五个排查样本5.1 中文释义在 Windows cmd 下变成问号现象词库是 UTF-8 编码在 Windows cmd 里运行释义输出全是问号偶尔在调用 string::length 时崩溃。原因cmd 默认代码页是 GBK936std::cout 把 UTF-8 字节序列原样输出GBK 解释不了就显示问号。更麻烦的是某些 UTF-8 字节在 GBK 里恰好组成另一个字符导致界面错得莫名其妙。解决在 _WIN32 下调用SetConsoleOutputCP(CP_UTF8)把控制台输出代码页切成 UTF-8。这行代码放在 main 开头。Linux/macOS 的终端本来就是 UTF-8不需要处理。我自己的习惯是把设置抽成一个init_console()函数在词库加载前调用。提示如果用户目录或配置文件路径含中文还要同时设置SetConsoleCP(CP_UTF8)否则 std::ifstream 打开中文路径文件会失败。5.2 进度数据在程序崩溃后完全丢失现象学了半小时中间断电或强杀进程重启后进度回到昨天。原因第一版只在主循环退出时 save 一次进程被强杀时根本没走到 save 代码。即使改成每答一题写一次文件假设写文件写到一半崩溃文件内容被截断等于也丢了。解决改成“临时文件 原子替换”的两段式。先写progress.txt.tmp全部写完再用 rename 覆盖progress.txt。rename 在同一个文件系统内是原子的不会产生半截文件。bool atomic_save(const std::vectorWord words, const std::string path) { std::string tmp path .tmp; { std::ofstream out(tmp, std::ios::trunc); out serialize_words(words); if (!out.good()) return false; } return std::rename(tmp.c_str(), path.c_str()) 0; }对应的解析逻辑不变。程序里默认每完成 20 个词的复习调用一次 atomic_save退出时再调一次双重保险。后来我复盘发现这个做法比任何“定时自动保存”都可靠因为它在任何崩溃点上都不会留下半截文件。5.3 四选一干扰项重复现象选项里出现两个词释义文字完全一样用户怀疑程序有 Bug。原因build_options 从词库里随机选干扰项时只判断了“不是正确答案”没有判重。词库量大时不容易撞车一旦词库里大量存在同义项比如两个词的释义都包含“放弃”就会选中相同释义。解决在生成选项之前开一个std::unordered_setstd::string把正确答案的 meaning 插入每次新候选词只有当它的 meaning 不在集合里才放进选项并把它的 meaning 也插入集合。这样既排除了重复释义也顺带排除了同义词干扰。如果排完没凑满四个选项就补一个“以上都不对”的兜底项。5.4 表格工具导出的词库行内有多余引号现象换成别人维护的词库后程序加载出的单词数和词条数对不上有的行直接缺胳膊少腿。原因从表格工具导出的 TSV 里带逗号、换行的字段会被双引号包裹比如get over, 克服。按第一个 TAB 切开后单词列里还带着引号释义列也被截成两段。解决在解析函数里加一个小状态机遇到引号内的 TAB 不切分遇到行尾的\r去掉。这一步看起来多写了 20 行但换来的是任意词库都能加载不用先教对方“导出前先清洗”。具体实现就是在循环里加一个 in_quote 布尔位逐字符扫描而不是直接 find。5.5 刚复习完的词第二天又出现在队列现象把一批词标成已掌握第二天打开软件它们又出现在今日复习列表里。原因due_at 用time(nullptr) interval * 86400计算。假如用户晚上 23:30 复习第二天早上 8:00 打开按时间戳算根本没到期。问题出在保存时用了字符串日期而不是时间戳日期字符串只存到“日”跨零点后当天日期刷新所有 interval_day 是 1 的词全部变成到期。解决统一改用 3.2 节里的 calc_due_at计算时把 tm 的小时分秒清零以本地零点作为基准。后来还补了单元测试模拟“23:59 复习一个 interval1 的词1 分钟后打开”断言它不能出现在队列里。这一步把最容易翻车的时区问题变成了可回归的测试用例。6. 二次开发手册换词库、换界面、加排序算法的三条实操路线6.1 把词库从自定义格式迁到 JSON最直接的第一步改造是给 Word 增加 JSON 版本。用现成库或者手写一个极简 JSON 解析。我的做法是只解析自己生成的字典字段名固定不追求完整 JSON 标准。好处是释义里的竖线、换行、引号都不需要转义整个项目少了一个“字符转义”的黑匣子。std::string word_to_json(const Word w) { return {\text\:\ w.text \,\meaning\:\ w.meaning \,\stage\: std::to_string(w.stage) ,\due_at\: std::to_string(w.due_at) }; }验证方法是先写一个 parse_json 的单元测试把 word_to_json 的输出再解析一次断言字段一致然后再替换 storage.cpp 里的行分隔符逻辑。6.2 把终端菜单换成字符界面如果觉得纯命令行不够直观可以加 libcurses 或用 ANSI 控制码画框体。不改业务逻辑只替换 main.cpp 的输入输出层。做法是抽出三个方法show_word、show_result、show_menu终端版和字符界面版各一个实现。scheduler、storage、word_loader 都不用动。换界面时注意一个点控制台输入读取方式要同步换getline 在界面下会被方向键序列干扰字符界面用 getch。6.3 给遗忘曲线写回放测试最值得养成的一个习惯是所有算法改动都跑一遍伪装时间测试。scheduler 不依赖真实时钟calc_due_at 接受外部传入的 now天然可测。我加了这样一个测试模式调用 fake_clock 把全局当前时间在测试代码里往前推 40 天跑完新学 10 个词的流程断言每天应复习的数量不超过一个上限。这个回放测试写过一次后续每次调间隔数组、改新词上限都先跑它再做手工体验。真实背单词场景里的“跨天、跨时区、文件截断”很难靠手工点击覆盖而伪时间回放把这些边界挡在发布前。从那以后我每次改词库格式或调算法参数都会强制把单测跑一遍再打新包这个习惯救了我好几次。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑