FEATURED · 精选文章

用C++写一个记事本:从数据结构到Qt GUI的完整实践

发布时间 / 2026/9/9 12:18:16
来源 / 创域科博编辑部
栏目 / 资讯中心
用C++写一个记事本:从数据结构到Qt GUI的完整实践 简介一份基于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::vectorstd::string的内存增长策略、按行索引时的迭代器失效问题然后才能写出一个性能合格的文本容器。这个过程里你学到的东西远比那句 Python 代码有价值。3. 认真聊一聊记事本项目的整体设计该怎么拆3.1 功能边界要画清楚别一上来就想做 Notepad很多新手的第一个错误是想法太大。听说要写记事本立刻想做什么语法高亮、代码折叠、自动补全、多标签页结果写了两个星期连文件保存都还没做利索。我的建议是分阶段定义功能边界第一阶段必做打开文件、保存文件、另存为、文本编辑、光标移动、撤销重做、查找替换、状态栏显示行列号。第二阶段选做字体设置、自动换行、多标签页、最近文件列表、编码检测、行号显示。第三阶段进阶语法高亮、自动缩进、括号匹配、插件系统、远程文件编辑。我做的时候遵循的原则是永远只做当前阶段够用的功能做扎实了再往下一阶段走。记事本这种项目功能堆得再多核心体验依然是“打开快、编辑稳、保存不丢东西”前面两点靠架构最后一点靠代码健壮性。3.2 UI 方案选型Win32、Qt、还是 Electron这一步是决定项目走向的关键决策。我当时列了个对比表方案学习成本跨平台打包体积底层掌控感适合人群控制台版最低高纯标准库极小一般刚学完语法的新手Win32 API中仅 Windows极小强想深入 Windows 编程的人QtWidgets中高高较大中等想做跨平台桌面应用的多数人Electron高高很大弱前端转客户端的人我自己最终选了 Qt Widgets。原因是 Win32 API 写记事本绕不开消息循环、窗口过程、GDI 绘制这些比较底层的概念对新手不够友好而且代码量大而 Qt 把事件循环、布局、信号槽机制封装得干净核心业务逻辑可以用标准 C 写GUI 部分通过 Qt 的类库衔接项目结构清晰很多。注意如果你的目标平台只有 Windows并且想顺便提高内功Win32 API 方案其实非常值得做一遍它能把“Windows 窗口程序到底是什么”这个问题讲明白。只不过要给自己多留出心理预期初期进度会相当慢。3.3 数据结构的选型std::string能不能直接用来存全文这是一个特别值得展开聊的点。很多人第一反应是“全文放一个std::string里不就行了”对于小型文本确实可以但一个真正的记事本要面对的是几十 MB 级别的日志文件、几万行的代码。这时候std::string的按行拆分、随机插入、撤销操作的性能问题就暴露出来了。我做第三版的时候把文本存储改成了std::dequestd::string每一行作为一个元素。这样做的理由有三点按行读取和修改的时间复杂度是 O(n)而且只需要在一个“行容器”里做插入删除操作编辑器天然是按行模型思考的——光标定位、行号显示、查找逐行匹配都是行优先后续做语法高亮时按行着色和局部重绘非常方便。当然std::deque也有缺点它不能保证元素在内存里连续所以如果某一行特别大比如压缩后的 JSON 单行几 MB频繁切片还是会有拷贝开销。我的解决策略是折中普通文本按行存std::deque单个超长行再用std::string单独处理不硬拆。再往后深入一点如果你的目标是做一个“高级记事本”可以考虑 Gap Buffer 或 Piece Table 作为底层数据结构这是现代编辑器常用的方案。Vim 的早期实现用的是 Gap BufferVS Code 的核心文本模型用的是 Piece Table。但这个阶段对大多数人来说属于超额设计先能把std::deque用熟、用透就已经领先很多“不过脑子直接std::string一把梭”的选手了。4. 实操手记从控制台版到 Qt 版的关键实现4.1 第一步命令行的“文件读写存”先跑通我建议就算你最终目标是图形界面也要先用控制台程序把核心的文件处理逻辑写好。因为 GUI 只是壳核心是数据流的正确性。控制台版我用的是 IDE 里临时写的测试代码功能很简单读入文件路径用std::istreambuf_iteratorchar把整个文件读入std::string按换行符拆成行存入std::dequestd::string打印到屏幕上等待用户输入新内容写回文件。这里头有个关键细节文件编码。控制台默认读入的是本地 ANSI 编码Windows 下就是 GBKLinux 下是 UTF-8。如果你打开一个 UTF-8 文件然后直接按字节回写可能没问题但如果你在 Windows 命令行里手输一个中文再保存编码就乱了。所以从控制台版开始我就强制统一内部编码为 UTF-8对外读入二进制流后再手动解析。#include fstream #include iostream #include string #include deque #include iterator std::dequestd::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_iteratorchar(in)), std::istreambuf_iteratorchar()); // 这里可以做 BOM 检查如果是 UTF-8 BOM 则去掉前三个字节 if (content.size() 3 static_castunsigned char(content[0]) 0xEF static_castunsigned char(content[1]) 0xBB static_castunsigned char(content[2]) 0xBF) { content.erase(0, 3); } std::dequestd::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只需要改TextBufferMainWindow和文件读写逻辑都不动。这是最基本的“依赖倒置”思维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实现简易高亮能顺便学会正则表达式的规则状态机写法CtrlS 快速保存快捷键和信号绑定这类小事也要注意跟系统的“文件已变更”冲突处理插件系统用Qt Plugin框架把“导出 PDF”之类的功能做成独立插件体会接口设计对大型软件的杠杆效应性能测试拿一个 100 MB 的日志文件反复测打开、滚动、查找、替换用std::chrono做基准计时看看哪个环节最慢再针对性优化这个过程很练内功也是面试时能拿出来讲的高光点。我个人更建议在项目稳定运行一段时间后重新审视一遍自己的文本数据结构。如果在实际使用中发现std::dequestd::string在超大文件下存在性能瓶颈就可以顺理成章地学习 Gap Buffer 和 Piece Table 的实现思路升级底层存储。这一步如果你能走完整个项目的技术含量会再上一个台阶。另外如果你刚学完 C 基础知识不知道下一个项目做什么我可以负责任地说先把记事本打磨到让你自己日常愿意用的程度再考虑做更难的东西。所谓“用到自己手上”的标准是每天打开它写笔记、改配置文件、临时记录信息而不会觉得难用或缺功能。做到这一点的过程能学到的工程经验比看十篇教程都值。做这个项目最让我有成就感的事反而不是功能有多少而是把“文件打开快、保存安全、搜索不卡”这三件小事真正做到位。C 的乐趣就在这里你亲手掌握每一个字节的去向程序的一切行为都尽在掌握。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻