首页 / 资讯中心 / 文章详情

用C++写一个命令行背单词工具:项目拆解与状态机设计

用C++写一个命令行背单词工具:项目拆解与状态机设计 ★ FEATURED ARTICLE
简介这是一套基于C实现的背单词软件源码与配套词库数据包定位清晰适合正在学习C的开发者、英语词汇工具爱好者以及需要完成课程设计的计算机专业学生。压缩包共31个文件包含19个cpp源文件、4个头文件、6个txt词库及配置文件还附有makefile和dic词库文件整体包体仅641KB结构紧凑、便于查阅。目前已有681人学习下载。源码覆盖单词类与用户类抽象封装、txt单词文件读取解析、文本界面与图形界面切换、拼写测试与单词卡片等记忆模式并基于用户学习记录生成报告、按记忆曲线智能调整复习计划从设计理念到具体实现展示了C面向对象、标准库文件操作及基础算法在真实项目中的综合运用是一份适合借鉴和二次开发的完整实践案例。1. 背单词这件事我用 C 写了套能跑的命令行工具背单词软件的痛点从来不是「有没有词库」而是「今天该复习哪些词」。市面上的 App 把界面做得花团锦簇核心的间隔重复逻辑却藏在黑匣子里你根本不知道它凭什么把一个词连推三遍。我拆的这份 C 实现反其道而行之——用纯命令行交互把单词库、复习队列、拼写测试和错误记录全部落成可读的本地文件核心逻辑一抬眼就能看完。它特别适合两类人一类是刚学完 C 基础、想找个完整项目练手的学生另一类是嫌图形界面干扰太多、想专注刷词的自学者。整个项目不依赖任何第三方库把源码包下载下来g 直接编译就能跑数据文件用文本存储改起来毫无心理负担。2. 项目结构与数据流从单词库到记忆曲线的筛选逻辑2.1 目录布局入口、核心、数据三块分工拿到源码包后先别急着编译花两分钟把目录结构捋一遍。这套代码的分层非常朴素但恰好符合一个命令行工具该有的样子├── main.cpp // 程序入口参数解析与主循环 ├── src/ │ ├── Vocabulary.cpp // 单词类的实现 │ ├── WordBank.cpp // 单词库的加载与查询 │ ├── StudySession.cpp // 一次学习会话的状态管理 │ ├── QuizEngine.cpp // 拼写测试引擎 │ └── StatRecorder.cpp // 学习记录的落盘与读取 ├── include/ │ ├── Vocabulary.h │ ├── WordBank.h │ ├── StudySession.h │ ├── QuizEngine.h │ └── StatRecorder.h ├── data/ │ ├── words.txt // 单词库原始数据 │ └── progress.dat // 每个单词的复习记录 └── Makefile入口文件只管解析命令行参数和调度模块业务逻辑全在 src 目录下。Vocabulary是数据实体封装一个单词的拼写、释义和记忆状态WordBank负责把words.txt里的行文本解析成Vocabulary对象StudySession维护「当前学到哪、接下来该复习谁」的状态QuizEngine负责出题和判分StatRecorder把每次作答的时间戳和结果追加到progress.dat。这种分层的意义在于你想换一种记忆算法只需要动StudySession其他模块完全不用碰。我在拆这个项目时第一反应就是把StudySession从「状态容器」里摘出来看因为它才是整套软件的决策核心。2.2 单词库设计为什么用文本文件而不是数据库words.txt的每一行是一条单词记录格式是「单词 | 释义 | 词性」比如abandon | 放弃抛弃 | v。选择文本格式而不是 SQLite是在「零依赖」和「易修改」之间做了权衡——用户拿手机拍下生词后用记事本就能追加不需要数据库客户端。加载逻辑的核心是WordBank的loadFile方法它逐行读取用getline和find(|)切分字段遇到空行或格式错误的行会跳过并计数bool WordBank::loadFile(const std::string path) { std::ifstream fin(path); if (!fin.is_open()) return false; std::string line; int skipCount 0; while (std::getline(fin, line)) { if (line.empty()) continue; // 跳过空行 size_t firstSep line.find(|); if (firstSep std::string::npos) { skipCount; // 坏行计数最后统一报告 continue; } size_t secondSep line.find(|, firstSep 1); Vocabulary v; v.word line.substr(0, firstSep); // 去掉首尾空格避免手工编辑带来的脏数据 v.meaning trim(line.substr(firstSep 1, secondSep - firstSep - 1)); v.pos (secondSep std::string::npos) ? unknown : trim(line.substr(secondSep 1)); m_words.push_back(v); } return skipCount 0; // 返回是否有坏行方便用户排查 }这段代码里有三个容易被忽略的细节。第一trim是必要的——手工编辑文本时很容易在分隔符两侧留空格不清理的话后面做拼写匹配时会莫名失败。第二第二根分隔符可能不存在用户只写了「单词 | 释义」这里用条件运算符兜了一个默认值「unknown」。第三skipCount不是无用的统计它决定了loadFile的返回布尔值让调用方知道「数据文件其实有脏行」而不是加载完就假装一切正常。2.3 记忆状态机一个单词在系统里如何流转这套软件没有引入复杂的 SM-2 算法而是用一个简单状态机模拟「新词 → 学习中 → 熟悉 → 掌握」的流转。Vocabulary结构体里有一个int reviewCount和一个int statusstatus取值 0 到 3分别对应上述四个状态。每次答对reviewCount加一当它达到阈值就升级状态每次答错状态直接跌回「学习中」reviewCount归零。void StudySession::markCorrect(int wordIndex) { Vocabulary v m_bank.getWord(wordIndex); v.reviewCount; if (v.status 0 v.reviewCount 3) { v.status 1; // 连续答对3次从新词转为学习中 v.reviewCount 0; // 重置计数准备下一阶段的判定 } else if (v.status 1 v.reviewCount 5) { v.status 2; // 学习中答对5次进入熟悉 v.reviewCount 0; } else if (v.status 2 v.reviewCount 8) { v.status 3; // 熟悉答对8次判为掌握 } }markCorrect的阈值是一组可调参数源码里用宏定义在了头文件顶部比如#define NEW_WORD_PASS_COUNT 3。你要是觉得 3 次太少、5 次太多改宏就行。这种设计谈不上算法先进但胜在逻辑透明——用户完全知道自己每次作答后系统发生了什么不像某些 App 的推荐策略你永远猜不透。3. 核心模块拆解背词、测验与统计的完整链路3.1 背词引擎一次会话的调度逻辑StudySession里最核心的方法是nextWord它决定「下一个该给你看哪个单词」。它维护两个队列新词队列和复习队列。新词队列按原始顺序排列每次只取队首复习队列的排列依据是「距离上次出错越久的越靠前」这个顺序在每次加载progress.dat时计算。int StudySession::nextWord() { // 复习队列里永远有词优先保证错误单词不被遗忘 if (!m_reviewQueue.empty()) { return m_reviewQueue.front(); } // 新词队列为空表示全部学完 if (m_newWordIndex m_bank.size()) { return -1; // 会话结束 } return m_newWordIndex; }这里有个反直觉的设计复习队列永远优先于新词。大多数背单词软件会让新词和复习词按比例穿插但这个项目选择了「先清复习再学新词」逻辑是「学不会的新词永远是下一个新词但忘了的旧词如果不先补上时间越久越忘」。这种取舍适合考试前突击的场景——用户的时间预算有限把快忘的词稳住比多学新词更划算。复习队列的构建是在loadProgress里完成的void StudySession::loadProgress(const std::string path) { std::ifstream fin(path); std::string line; while (std::getline(fin, line)) { // 格式单词文本 | 状态 | 上次错误时间戳 | 错误次数 std::string word line.substr(0, line.find(|)); int idx m_bank.find(word); if (idx -1) continue; // 词库里删掉了这个词忽略记录 int status parseStatus(line); if (status 0 needsReview(line)) { m_reviewQueue.push(idx); } } }needsReview判断当前时间戳减去上次错误时间戳是否超过一个阈值默认是 24 小时。这个阈值没写死编译时通过宏传给StudySession构造函数留给二次开发时按需改。3.2 测验模块拼写判分与选项生成的边界情况QuizEngine提供两种测验模式看释义拼单词、看单词选释义。拼写判分的坑最多我把关键逻辑贴出来bool QuizEngine::checkSpelling(const std::string userAnswer, const Vocabulary target) { // 先做小写归一化避免大小写导致的误判 std::string lowerInput toLower(userAnswer); std::string lowerTarget toLower(target.word); // 完全匹配直接通过 if (lowerInput lowerTarget) return true; // 编辑距离 1 视为可接受错误比如少打一个字母 int dist levenshteinDistance(lowerInput, lowerTarget); return dist 1; }编辑距离的容忍是后期加的功能。最初版本是「大小写不敏感但逐字符必须完全一致」结果就是用户明明会拼只是手一抖多按了一个键系统就判错挫败感极强。后来加了编辑距离 ≤ 1 的容错把「多打一个字母」「漏打一个字母」「相邻字母顺序写反」三种高频手误都放过去了。选项生成看单词选释义的边界情况是错误选项不能太离谱。源码里先从词库里随机抽四个不同词的释义再检查它们和正确释义的文本相似度如果有两个释义包含完全相同的单词比如两个词都带「放弃」的含义会重新抽一次最多重试五次。这一步很多人会漏做结果就是出现两个长得几乎一样的选项用户只能靠猜。std::vectorstd::string QuizEngine::generateChoices(int correctIdx) { std::vectorstd::string choices; choices.push_back(m_bank.getWord(correctIdx).meaning); int attempt 0; while (choices.size() 4 attempt 10) { int randomIdx m_rng() % m_bank.size(); std::string candidate m_bank.getWord(randomIdx).meaning; // 避免重复选项且避免和已有选项过于相似 if (!contains(choices, candidate) !isSimilar(candidate, choices)) { choices.push_back(candidate); } attempt; } std::shuffle(choices.begin(), choices.end(), m_rng); return choices; }isSimilar内部查的是「两个字符串的前三个字符是否相同」这是一种朴素但够用的去重策略。英文单词的词根往往集中在前三个字母前三个相同通常意味着同一词源比如abandon和abnormal作为干扰项太容易被排除。3.3 统计模块用时间序列记录学习轨迹progress.dat的每一行就是一个单词的完整生命周期格式是abandon | 1 | 1715100000 | 3四个字段分别是单词、当前状态0-3、上次错误时间戳、错误总次数。StatRecorder每次学完一个词就把这行重写一遍——不是追加是找到原单词对应的行然后整行替换。这种设计在数据量小的时候没问题但如果你往词库里塞了一万个词每次重写文件都是 O(n) 的全量 IO性能会有点难看。我拆到这一层时发现可以用两个文件解决问题一个progress.dat存速度要求不高的状态一个access.log纯追加存每次作答记录含时间戳、对错、用时。查询「某个词一周内错了多少次」时只需要扫access.log不用翻progress.dat。源码里没做这个拆分属于明显的优化点我把它写在最后一章的二次开发思路里。统计模块的查询接口是getStats它接收一个单词索引返回该单词的学习次数、错误次数、当前状态和上次作答时间std::arrayint, 4 StatRecorder::getStats(int idx) const { std::arrayint, 4 result {0, 0, 0, 0}; // 结果集分别是状态、总学习次数、错误次数、上次作答时间戳 auto it m_records.find(idx); if (it ! m_records.end()) { result[0] it-second.status; result[1] it-second.totalAttempts; result[2] it-second.wrongCount; result[3] it-second.lastTimestamp; } return result; }返回std::array而不是自定义结构体是个讨巧但略显偷懒的做法。优点是代码简短缺点是可读性差——调用方得靠注释去记「下标 2 是错误次数」。你要是接手这个项目第一件事就是把这里改成结构体返回能省后面不少事。4. 避坑指南C 命令行项目的五个常见问题4.1 中文乱码Windows 下控制台不认 UTF-8现象words.txt是用 UTF-8 编码保存的但在 Windows 命令行里跑释义部分全是乱码。原因Windows 控制台默认代码页是 GBK936而现代编辑器默认保存为 UTF-8 无 BOM两者不兼容。解决在main.cpp入口处加一句system(chcp 65001);同时确保words.txt另存为 UTF-8 带 BOM 格式。如果用的是 Linux 或 macOS不需要这行建议用条件编译包一层#ifdef _WIN32 system(chcp 65001); #endif4.2 文件路径带空格导致加载失败现象把项目放在C:\My Projects\WordTool\目录下编译通过但运行时WordBank::loadFile返回 false。原因源码里硬编码了data/words.txt相对路径而程序启动时的「当前工作目录」不一定是源码根目录。如果你从 IDE 里启动工作目录可能是项目根目录如果你从命令行手动 cd 到别处再执行程序相对路径就失效了。解决让入口从argv[0]推导程序所在目录然后拼上data/words.txt。argv[0]在不同平台有差异一个可行的做法是std::string getExecutableDir(const char* argv0) { std::filesystem::path p std::filesystem::absolute(argv0); return p.parent_path().string(); }之后加载数据文件时用getExecutableDir(argv[0]) /data/words.txt再也不依赖调用方的当前目录。4.3std::shuffle每次结果一样现象程序每次启动测验模块给出的四个选项顺序完全相同几乎等于「背答案」。原因std::shuffle用的是伪随机数生成器如果种子的值固定比如std::default_random_engine默认种子生成的随机序列就是固定的。解决用随机设备std::random_device来生成种子或者退一步用当前时间戳std::mt19937 m_rng; // 初始化时用当前时间播种保证每次运行顺序不同 m_rng.seed(std::chrono::system_clock::now().time_since_epoch().count());4.4 单词中间有空格导致解析错位现象往words.txt里加了take off | 起飞脱下 | v结果程序把take和off当成两个独立的单词。原因WordBank::loadFile用find(|)切分但take off中间的空格没有特殊处理如果你在Vocabulary的word字段上又做了substr(0, firstSep)前的按空格 split就会多拆。解决源码里word字段是整体保存的只要你不额外写按空格切分的代码就没事。如果你自己加了代码记住「单词内空格是合法的」只有|才是字段分隔符。4.5 记录文件越写越大程序启动越来越慢现象用了两周后程序启动时明显卡顿。原因StatRecorder::loadAll是逐行读取全部记录到内存每次启动即使你只想背三个单词也要解析完整的历史文件。解决懒加载只加载下一次会话会用到的那部分记录。怎么知道「会用到」只加载所有status 0至少学过一遍的词条新词不需要历史记录。这样记录再多启动时也只扫描索引而非全量解析。5. 编译运行与个性化配置从 g 到数据文件5.1 三分钟跑起来Makefile 与编译参数源码包里附带了一个Makefile核心规则只有两条CXX g CXXFLAGS -stdc17 -Wall -O2 TARGET wordtool $(TARGET): main.cpp src/*.cpp include/*.h $(CXX) $(CXXFLAGS) -Iinclude main.cpp src/*.cpp -o $(TARGET) clean: rm -f $(TARGET)在项目根目录执行make就会生成wordtool可执行文件。两个值得注意的编译选项-Wall是开启所有警告不是强制报错但建议保留-O2是优化选项对这个小程序没意义但保留了也无妨。如果你想用调试器可以把-O2换成-gmake CXXFLAGS-stdc17 -Wall -g运行方式是一个主参数加可选参数./wordtool start # 开始一次新的学习会话 ./wordtool test --spell # 进入拼写测验模式 ./wordtool test --choice # 进入选择题测验模式 ./wordtool stats --all # 打印所有单词的学习统计5.2 配置文件不改代码就能调阈值源码里把NEW_WORD_PASS_COUNT和REVIEW_INTERVAL_SECONDS定义成了宏但更好的做法是从配置文件读取。项目里留了一个config.ini的示例模板# config.ini new_word_pass_count 3 review_interval_hours 24 max_daily_new_words 30加载逻辑非常简单用std::ifstream逐行读find()切分键值void Config::load(const std::string path) { std::ifstream fin(path); std::string line; while (std::getline(fin, line)) { if (line.empty() || line[0] #) continue; // 注释行 size_t eq line.find(); if (eq std::string::npos) continue; std::string key trim(line.substr(0, eq)); int value std::stoi(line.substr(eq 1)); m_map[key] value; } }这套逻辑跟WordBank::loadFile几乎是同一个模子区别只在分隔符不同。把配置外置的唯一理由是让不懂编译的用户也能调整「每天新词上限」。如果你是自己编译自己跑直接改宏更快不必迷信配置文件。5.3 数据文件的自检与修复跑起来之前我强烈建议先执行一遍自带的自检命令./wordtool checkcheck会做三件事检查words.txt的内容格式是否合法检查progress.dat里引用的单词是否都存在于词库中防止你手工删除词条后残留脏记录检查有无重复单词。如果发现问题它会打印出具体行号你可以直接编辑数据文件修掉。这个小功能是后期加的最初版本里格式错误只会导致加载失败黑匣子一样让人摸不着头脑。6. 二次开发思路把命令行工具改造成带界面的学习助手如果你想在这个项目上做二次开发我建议按「增强数据层 → 替换交互层 → 扩展算法层」的顺序来每一步都有独立的验证方法。第一步把StatRecorder的写文件改成追加日志模式。原理我在 3.3 节提过progress.dat只保存最新状态新建一个access.log只做追加每行一条作答记录格式建议为timestamp,word,result,duration_ms。改动量不大但注意保持旧格式的兼容——你可以在loadProgress里同时读两个文件以progress.dat为基准用access.log补充历史统计。验证方式连续学习三个单词后手动打开access.log确认三行记录都各出现一次。第二步替换交互层。main.cpp里的主循环是一个while(true)结构每次迭代打印菜单、读输入、分发命令。把这个循环抽出来接一个 web 后端用crow或cpp-httplib把StudySession和QuizEngine的方法映射成 HTTP 接口你就可以用浏览器当界面。这一步的价值在于命令行版本的「下一词」逻辑保持不变所有测试逻辑都不用动你只需要验证 HTTP 接口返回的 JSON 和原来控制台输出的内容一致即可。第三步把StudySession的简单状态机替换成实际记忆算法。我建议先不动框架先做 A/B 对比旧算法的完成率和新算法的完成率分别记录跑一周后对比「错误次数下降幅度」。这种验证不用写复杂的统计代码直接用现有的StatRecorder就能导出数据到 CSV。有一个我从这个项目里学到的血泪教训改背单词算法的第一步不是写新算法而是冻结旧行为。把当前状态机的所有判定逻辑写成OldEngine再写NewEngine两个引擎跑同一份会话数据逐词对比「下一个词选择是否一致」。不一致的地方就是行为分叉点你要么接受差异要么在新引擎里主动对齐。没有这一步你改完算法后根本说不清「用户感觉变难了」是心理作用还是真实行为变化。从那以后我每次改推荐算法或排序策略都会强制走一遍「双引擎对比」流程旧行为先固定成黑盒新行为逐点对照所有分叉处列成清单逐条确认是有意为之还是 bug。这个过程虽然繁琐但能省掉大量「上线后用户报告异常回头翻代码才发现是改坏了」的后悔药。希望这份 C 背单词项目解析能帮你少走一段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站