简介:一份基于C++实现的记事本应用程序工程,面向掌握基础C++语法、希望通过实际项目提升文件读写与界面开发能力的开发者,解决从零构建文本编辑器所涉及的文件操作、字符串处理、异常处理与GUI设计等问题。资源为RAR压缩包,共129个文件,主要包含C++源文件、头文件,以及Visual Studio工程配置、窗体资源、文档说明和已编译好的可执行程序,整体大小3.08MB。目前已有1099人学习浏览。工程按功能划分主窗体、查找窗体、替换窗体、关于窗体等多个模块,代码中清晰展示了fstream文件流读写、std::string字符串查找替换、try-catch异常捕获、剪贴板操作、命令行动态打开文件等关键技术的实际用法;通过阅读源码可学习C++项目的基本组织结构、资源管理方式、事件驱动机制以及窗体设计思路,也可直接运行附带的exe体验效果,或在此基础上进行功能扩展与二次开发。这份资源对C++课程设计、自学项目练习或记事本类应用开发都有较好的参考价值。
1. 写在最前:C++记事本到底是不是个玩具项目
最近不少人问我,为什么折腾了一圈,最后还是回头用 C++ 写了一个记事本。其实答案很简单:记事本这种项目,看起来朴素,实际把一个桌面应用该踩的坑都踩了一遍——文本怎么存、文件怎么读写、撤销怎么做、查找替换在大文件下会不会卡死、窗口重绘会不会闪烁。这些全是 C++ 工程里的硬问题,而不是调几个 API 就算完事。
如果你正处在 C++ 学习阶段,或者准备面试想找个拿得出手的小项目,那我强烈建议你把“用 C++ 编写一个记事本”当成一个正经项目来做,而不是随手糊一个控制台版就交差。它能串起 C++ 语法、标准库容器、文件流、面向对象设计、甚至是 GUI 框架的基本用法,而且项目规模可控,三五百行能跑,三千行也能做出个像样的编辑器,伸缩性非常好。
这篇文章我就把整个项目的设计思路、核心模块、实操过程和踩坑记录全部拆开来讲。不画大饼,全是我实际写过的代码和遇到的问题。
2. 为什么记事本项目特别适合检验 C++ 基本功
2.1 不靠框架也能讲清楚的“最小可用产品”
记事本的核心需求无非四个:显示文本、编辑文本、保存文件、打开文件。听起来简单,但你以为的“显示文本”是往窗口上甩一行字符串,实际做的时候要处理换行、滚动、光标定位;你以为的“编辑文本”是准备一个字符串数组往里插字符,实际做的时候要考虑撤销栈、选区替换、回车缩进。这些需求就像放大镜,把你对 C++ 的掌握程度照得一清二楚。
另外,这个项目的技术栈可大可小。新手阶段,只用一个控制台程序 + 标准库就能实现“命令行记事本”;进阶之后,接一个 GUI 框架(比如 Qt)做真正的图形界面;再往后,还能引入插件机制、文本高亮、多标签页这些更复杂的能力。同一个题目,可以从入门写到就业,这是很多项目不具备的优点。
2.2 C++ 相比其他语言在这个项目里的“难”和“值”
我也见过有人用 Python 写记事本,大概五十行就完事了。Python 的字符串处理、list 操作太顺手,写起来确实痛快。但 C++ 记事本的难点和价值恰恰在于它不痛快:你需要自己管理内存、自己设计数据结构、自己处理编码和换行符差异,这些“不痛快”的过程才是编程能力的核心沉淀。
举个最典型的例子:Python 里lines = text.split('\n')一句话搞定的事情,在 C++ 里你得先弄清楚std::string的拷贝代价、std::vector<std::string>的内存增长策略、按行索引时的迭代器失效问题,然后才能写出一个性能合格的文本容器。这个过程里你学到的东西,远比那句 Python 代码有价值。
3. 认真聊一聊:记事本项目的整体设计该怎么拆
3.1 功能边界要画清楚,别一上来就想做 Notepad++
很多新手的第一个错误,是想法太大。听说要写记事本,立刻想做什么语法高亮、代码折叠、自动补全、多标签页,结果写了两个星期连文件保存都还没做利索。我的建议是分阶段定义功能边界:
第一阶段(必做):打开文件、保存文件、另存为、文本编辑、光标移动、撤销重做、查找替换、状态栏显示行列号。
第二阶段(选做):字体设置、自动换行、多标签页、最近文件列表、编码检测、行号显示。
第三阶段(进阶):语法高亮、自动缩进、括号匹配、插件系统、远程文件编辑。
我做的时候遵循的原则是:永远只做当前阶段够用的功能,做扎实了再往下一阶段走。记事本这种项目,功能堆得再多,核心体验依然是“打开快、编辑稳、保存不丢东西”,前面两点靠架构,最后一点靠代码健壮性。
3.2 UI 方案选型:Win32、Qt、还是 Electron?
这一步是决定项目走向的关键决策。我当时列了个对比表:
| 方案 | 学习成本 | 跨平台 | 打包体积 | 底层掌控感 | 适合人群 |
|---|---|---|---|---|---|
| 控制台版 | 最低 | 高(纯标准库) | 极小 | 一般 | 刚学完语法的新手 |
| Win32 API | 中 | 仅 Windows | 极小 | 强 | 想深入 Windows 编程的人 |
| Qt(Widgets) | 中高 | 高 | 较大 | 中等 | 想做跨平台桌面应用的多数人 |
| Electron | 高 | 高 | 很大 | 弱 | 前端转客户端的人 |
我自己最终选了 Qt Widgets。原因是 Win32 API 写记事本绕不开消息循环、窗口过程、GDI 绘制这些比较底层的概念,对新手不够友好,而且代码量大;而 Qt 把事件循环、布局、信号槽机制封装得干净,核心业务逻辑可以用标准 C++ 写,GUI 部分通过 Qt 的类库衔接,项目结构清晰很多。
注意:如果你的目标平台只有 Windows,并且想顺便提高内功,Win32 API 方案其实非常值得做一遍,它能把“Windows 窗口程序到底是什么”这个问题讲明白。只不过要给自己多留出心理预期,初期进度会相当慢。
3.3 数据结构的选型:std::string能不能直接用来存全文?
这是一个特别值得展开聊的点。很多人第一反应是“全文放一个std::string里不就行了?”对于小型文本确实可以,但一个真正的记事本要面对的是几十 MB 级别的日志文件、几万行的代码。这时候std::string的按行拆分、随机插入、撤销操作的性能问题就暴露出来了。
我做第三版的时候把文本存储改成了std::deque<std::string>,每一行作为一个元素。这样做的理由有三点:
- 按行读取和修改的时间复杂度是 O(n),而且只需要在一个“行容器”里做插入删除操作;
- 编辑器天然是按行模型思考的——光标定位、行号显示、查找逐行匹配,都是行优先;
- 后续做语法高亮时,按行着色和局部重绘非常方便。
当然,std::deque也有缺点:它不能保证元素在内存里连续,所以如果某一行特别大(比如压缩后的 JSON 单行几 MB),频繁切片还是会有拷贝开销。我的解决策略是折中:普通文本按行存std::deque,单个超长行再用std::string单独处理,不硬拆。
再往后深入一点,如果你的目标是做一个“高级记事本”,可以考虑 Gap Buffer 或 Piece Table 作为底层数据结构,这是现代编辑器常用的方案。Vim 的早期实现用的是 Gap Buffer,VS Code 的核心文本模型用的是 Piece Table。但这个阶段对大多数人来说属于超额设计,先能把std::deque用熟、用透,就已经领先很多“不过脑子直接std::string一把梭”的选手了。
4. 实操手记:从控制台版到 Qt 版的关键实现
4.1 第一步:命令行的“文件读写存”先跑通
我建议就算你最终目标是图形界面,也要先用控制台程序把核心的文件处理逻辑写好。因为 GUI 只是壳,核心是数据流的正确性。控制台版我用的是 IDE 里临时写的测试代码,功能很简单:
- 读入文件路径;
- 用
std::istreambuf_iterator<char>把整个文件读入std::string; - 按换行符拆成行,存入
std::deque<std::string>; - 打印到屏幕上,等待用户输入新内容,写回文件。
这里头有个关键细节:文件编码。控制台默认读入的是本地 ANSI 编码,Windows 下就是 GBK,Linux 下是 UTF-8。如果你打开一个 UTF-8 文件然后直接按字节回写,可能没问题,但如果你在 Windows 命令行里手输一个中文再保存,编码就乱了。所以从控制台版开始,我就强制统一内部编码为 UTF-8,对外读入二进制流后再手动解析。
#include <fstream> #include <iostream> #include <string> #include <deque> #include <iterator> std::deque<std::string> readFileLines(const std::string& filepath) { std::ifstream in(filepath, std::ios::binary); if (!in) { throw std::runtime_error("无法打开文件: " + filepath); } std::string content((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); // 这里可以做 BOM 检查,如果是 UTF-8 BOM 则去掉前三个字节 if (content.size() >= 3 && static_cast<unsigned char>(content[0]) == 0xEF && static_cast<unsigned char>(content[1]) == 0xBB && static_cast<unsigned char>(content[2]) == 0xBF) { content.erase(0, 3); } std::deque<std::string> lines; std::string line; for (char c : content) { if (c == '\n') { if (!line.empty() && line.back() == '\r') { line.pop_back(); } lines.push_back(std::move(line)); line.clear(); } else { line.push_back(c); } } if (!line.empty()) { lines.push_back(std::move(line)); } return lines; }这段代码里有三个值得注意的点:
- 用二进制模式打开文件,避免 Windows 下
\r\n被自动转成\n,这样你能自己控制换行符的处理逻辑; - 检查 UTF-8 BOM 并及时剥离,BOM 是 Windows 记事本的老毛病,不处理就会出现第一行开头莫名其妙多个空白;
- 用
std::move(line)把局部字符串移入容器,避免一次多余的深拷贝。
4.2 第二步:Qt 界面的最小骨架是怎么搭起来的
Qt 版我用的是 Qt Widgets 配合QPlainTextEdit。为什么不用QTextEdit?因为QPlainTextEdit就是专门为纯文本设计的,性能和渲染比QTextEdit好很多,尤其体现在大文件的滚动和选区操作上。
界面布局从上到下是菜单栏、工具栏、文本编辑区、状态栏。我在设计类结构时把业务逻辑抽离为三个类:
| 类名 | 职责 |
|---|---|
MainWindow | 窗口、菜单、快捷键绑定、布局管理 |
TextBuffer | 对QPlainTextEdit的封装,负责存文本、查行号、处理选中 |
FileIO | 静态工具类,负责读写文件、编码转换、BOM 处理 |
这样分离的好处是:如果以后我想换掉 Qt 的编辑控件(比如换成QScintilla),只需要改TextBuffer,MainWindow和文件读写逻辑都不动。这是最基本的“依赖倒置”思维,C++ 面向对象设计能不能落地就体现在这种细节上。
打开文件的核心代码大致长这样:
void MainWindow::openFile(const QString &filepath) { try { auto lines = FileIO::readFileLines(filepath.toStdString()); TextBuffer::setLines(lines); m_currentFile = filepath; setWindowTitle("记事本 - " + filepath); m_statusBar->showMessage("已打开 " + filepath, 3000); } catch (const std::exception &e) { QMessageBox::critical(this, "错误", e.what()); } }4.3 第三步:撤销重做功能的前后两种实现对比
记事本如果没有撤销,那基本没法用。Qt 的QPlainTextEdit自带撤销重做功能,直接用undoStack就能搞定。但这是“附送品”,如果你想理解撤销重做是怎么设计的,最好自己实现一版。
我早期自己写的是一个最朴素的命令栈:
class EditCommand { public: virtual ~EditCommand() = default; virtual void execute() = 0; virtual void undo() = 0; };每一项编辑动作都作为EditCommand的子类入栈,比如InsertTextCommand保存插入位置、插入文本,undo时就删除这段文本;DeleteTextCommand则保存被删除的内容和位置,undo时重新插回去。
这套设计在文本短、操作不频繁时没问题,但一旦用户连续输入几百个字符,每个字符都生成一个命令,内存和栈深度就不可控了。所以后来我在命令栈的基础上加了“合并机制”:如果新命令和栈顶命令是同一个操作类型(都是插入文本),并且光标位置连续,就把新操作合并进上一条命令里,而不是新建一条。这是很多现代编辑器做“连续输入只算一步撤销”的基本思路。
Qt 自带的QUndoStack也是类似命令模式,但它已经帮你实现了合并、清理、快捷键绑定。如果你是新手,建议用自带方案起步,理解明白之后再自己写一版“简化版命令栈”,两条腿走路最稳。
4.4 查找替换:C++ 正则表达式和逐行匹配怎么选
查找替换这个功能,看起来简单,实际写起来有几个分叉口。
第一版我直接在QPlainTextEdit的全文上跑QRegularExpression,小文件没问题,但到了 5 MB 以上的大文件,每次查找都要全量正则匹配,明显卡顿。后来我改成了逐行匹配:从当前光标行开始,依次取每一行做正则匹配,命中后把光标移动到对应位置。
这样有两个好处:一是扫描范围可以按方向控制(向上/向下),天然支持“当前光标附近优先搜索”;二是渲染的时候只改动命中的行,不用全量重绘。
bool MainWindow::findNext(const QString &keyword, Qt::CaseSensitivity cs) { QTextCursor cursor = m_editor->textCursor(); int currentLine = cursor.blockNumber(); int totalLines = m_editor->document()->blockCount(); for (int i = currentLine; i < totalLines; ++i) { QTextBlock block = m_editor->document()->findBlockByNumber(i); QString lineText = block.text(); int idx = lineText.indexOf(keyword, 0, cs); if (idx >= 0) { QTextCursor newCursor(block); newCursor.setPosition(block.position() + idx); newCursor.setPosition(block.position() + idx + keyword.length(), QTextCursor::KeepAnchor); m_editor->setTextCursor(newCursor); m_editor->setFocus(); return true; } } return false; }注意这里有个细节:findBlockByNumber是按 block 编号拿行,但如果用户启用了“自动换行”,一个逻辑行可能显示成多个视觉行,这时 block 编号和视觉行号就不一致了。如果你的记事本版本要做“自动换行 + 查找下一行”,这里需要用QTextCursor的视觉位置来修正。我最初没注意到这个问题,用户反馈“明明下一行有关键字,查找却跳过了”,排查了很久才找到是自动换行导致的 block 映射错位。
4.5 状态栏和光标位置刷新:别小看这几十行代码
状态栏显示“行:xx,列:xx”几乎是记事本的标配。Qt 里监听光标移动的信号是QPlainTextEdit::cursorPositionChanged。实现不复杂,但有一个内存和数据同步的点值得注意:cursorPositionChanged信号里如果直接里调用cursor.blockNumber()和cursor.columnNumber(),乘上大文件里反复刷新 font metrics 的消耗,很容易导致光标移动卡顿。
我的做法是:信号槽里只记录一次当前行和列,然后通过setText更新一个QLabel的文本。状态栏标签刷新本身开销不大,真正的坑在于如果你用QTextCursor去反复创建临时对象,就会产生额外的内存分配。性能瓶颈常常不是你想的那个地方。
5. 工具箱和避坑笔记:C++ 记事本必踩的五个坑
5.1 编码混乱是最隐蔽的问题
我现在内部一律使用 UTF-8,读写文件时手动处理 BOM,换行符统一转成\n存储,保存时按系统平台转回\r\n。千万别指望 Qt 或者标准库自动帮你搞定,自动检测编码这事现在都没有一个完美的方案。我的FileIO类里留了一个编码检测函数,靠 BOM、合法 UTF-8 序列统计、中文字节分布来做启发式判断,准确率足够应付日常使用,但不追求百分百。
另一个隐蔽问题是剪贴板。代码里从剪贴板粘贴进来的文本可能是各种编码,尤其是从老牌 Windows 程序复制过来的中文。我的方案是在粘贴事件里强制把QString转为 UTF-8 字节再存入文本缓冲区,这样能避免不少“粘贴后乱码”的反馈。
5.2 大文件打开前要预判,不然很容易“假死”
几百 KB 的文本文件,Qt 打开是毫秒级;但如果你拖进去一个 500 MB 的日志文件,在 UI 线程里读文件必然卡死界面。我的解决策略是:
- 打开文件前先用
std::filesystem::file_size拿到文件体积; - 如果大于 10 MB,弹确认提示“文件较大,是否以只读模式打开”;
- 在后台线程里做读取和解析,主线程显示加载进度条;
- 加载期间禁止文本编辑操作,只允许撤销打开。
后台线程读取时要注意:QPlainTextEdit不能在非 GUI 线程里直接修改,务必通过信号槽把数据传递回主线程。我第一版错误地在工作线程里调用setPlainText,结果偶尔崩溃偶尔正常,最后查文档才发现QPlainTextEdit不是线程安全的。
5.3 保存文件时的“崩溃保护”怎么做
这是我踩过最痛的坑之一。早期版本保存时直接打开目标文件覆盖写,如果程序在写入过程中崩溃,老文件就全丢了。后来我改成“临时文件 + 原子替换”策略:
- 在目标文件同目录下创建一个
.tmp后缀的临时文件; - 完整写入临时文件并
flush、关闭; - 用
std::filesystem::rename或 Win32 的ReplaceFileW把临时文件替换为目标文件; - 替换成功后删除备份。
这个方案在 Windows 和 Linux 上都稳定,唯一的代价是保存耗时翻倍。但对于记事本这种工具,“保存一次”的用户心智是“绝对不能丢”,这点性能开销完全值得。
5.4std::string和QString互转别用 toStdString 一把梭
Qt 6 的QString::toStdString()返回 UTF-8 编码的std::string,这在绝大部分场景下没问题。但如果你所在的项目需要兼容 Windows 本地代码页(比如某些老库只认 ANSI),直接互转就是乱码源头。
我的统一规范是:在模块边界处明确标注编码。FileIO对外开放的接口参数是std::filesystem::path,不直接传QString。这样文件系统相关操作完全交由标准库处理,编码转换只在必要边界做一次,避免到处都是互相转的混乱代码。
5.5 部署给别人的时候 vcredist 和 Qt DLL 是常见坑
写出来跑是一回事,发给别人又能跑是另一回事。C++ 记事本编译出来的 exe 在自己机器上跑得好好的,换台机器提示缺少各种 DLL,这是很多新人最容易沮丧的时刻。
我踩过之后总结了两套方案:
| 场景 | 方案 |
|---|---|
| 只在本机使用 | 静态编译 Qt,体积大一点但免部署 |
| 发给别人 | 打包动态库版本,带上可再发行 VC 运行库安装包 |
Windows 下如果你用了 MSVC 编译,那目标机器上大概率需要 Microsoft Visual C++ Redistributable。Qt 程序则要确保 Qt 的动态链接库跟着 exe 一起走,或者用windeployqt一键收集依赖。我曾经图省事直接把 Qt 的 DLL 扔在一堆同名的旧版本 DLL 目录里,结果另一台机器程序启动时加载了错误版本的Qt6Core.dll,直接崩溃。后来规矩了:每个程序一个独立目录,用windeployqt部署,绝不共用 DLL 目录。
6. 项目做完之后:怎么继续“榨干”这个记事本的价值
很多人的项目做完就结束了,其实这很可惜。记事本这种项目最大的优势是扩展点极多,每一个扩展都能倒逼你学习新的 C++ 知识点。
我最推荐的几个后续方向:
- 多标签页:把单个
QPlainTextEdit扩展成QTabWidget管理多个文档,这里要处理“全局查找替换跨标签”“未保存标签关闭提醒”,非常适合练习对象生命周期管理; - 语法高亮:用 Qt 的
QSyntaxHighlighter实现简易高亮,能顺便学会正则表达式的规则状态机写法; - Ctrl+S 快速保存:快捷键和信号绑定这类小事,也要注意跟系统的“文件已变更”冲突处理;
- 插件系统:用
Qt Plugin框架把“导出 PDF”之类的功能做成独立插件,体会接口设计对大型软件的杠杆效应; - 性能测试:拿一个 100 MB 的日志文件反复测打开、滚动、查找、替换,用
std::chrono做基准计时,看看哪个环节最慢,再针对性优化,这个过程很练内功,也是面试时能拿出来讲的高光点。
我个人更建议在项目稳定运行一段时间后,重新审视一遍自己的文本数据结构。如果在实际使用中发现std::deque<std::string>在超大文件下存在性能瓶颈,就可以顺理成章地学习 Gap Buffer 和 Piece Table 的实现思路,升级底层存储。这一步如果你能走完,整个项目的技术含量会再上一个台阶。
另外,如果你刚学完 C++ 基础知识,不知道下一个项目做什么,我可以负责任地说:先把记事本打磨到让你自己日常愿意用的程度,再考虑做更难的东西。所谓“用到自己手上”的标准,是每天打开它写笔记、改配置文件、临时记录信息,而不会觉得难用或缺功能。做到这一点的过程,能学到的工程经验比看十篇教程都值。
做这个项目最让我有成就感的事,反而不是功能有多少,而是把“文件打开快、保存安全、搜索不卡”这三件小事真正做到位。C++ 的乐趣就在这里:你亲手掌握每一个字节的去向,程序的一切行为都尽在掌握。
本文还有配套的精品资源,点击获取