用状态机实现C++代码统计工具:注释与代码行精准识别 简介面向C/C开发者的代码统计工具可快速统计源文件中的代码行数、注释行数与空行数量帮助评估项目规模与代码可维护性。压缩包为RAR格式共102个文件既包含cpp、h源码及工程配置文件也带有编译生成的exe可执行程序另有obj、pdb等构建中间文件整包约7.04MB。工具采用可视化界面支持单行及多行注释识别、分类统计与代码密度计算并自动生成统计报告便于项目进度跟踪与质量分析。工程内含主框架、文件夹树、文件列表、自定义对话框等模块结构清晰适合研读MFC程序的界面组织方式。目前已有880人学习浏览适合需要量化代码结构、关注注释覆盖率或进行项目健康度检查的C/C程序员。通过阅读源码还可掌握文件读写、行计数、注释匹配等具体实现技巧。 写这个工具之前我统计代码行数的方式还是最原始的打开项目挨个看文件底部状态栏显示的行号再用计算器加总最后把注释占比大概填一个数交差。直到有一回我要给一个两百多个文件的 C 工程整理代码量报告光数行数就耗了一个下午那一刻我就决定一定要做一个自己的 C 代码统计工具把行数、注释行、代码行一次性算清楚。这篇文章会把整个开发过程拆开讲统计口径怎么定义、注释怎么识别、状态机怎么设计、完整代码长什么样、以及我实际踩过哪些坑。不管你是 C 新手还是被代码量统计折磨过的老开发都能从里面拿到一份可以直接用的工具代码顺便把文本解析中很核心的“状态机”思想也摸透。1. 统计口径先搞清楚“一行代码”到底怎么算1.1 统计工具能解决什么问题在日常开发里代码统计工具解决的是三个很实际的痛点第一工作量评估领导问你这个项目写了多少行代码总不能说“好像挺多的”第二代码质量复盘注释率过低说明文档缺失注释率虚高说明可能有人拿注释凑数第三代码审查前的排查比如找某个文件为什么那么大一眼看到行数分布就能定位。这个工具的目标很明确输入一个 C/C 文件或者整个工程目录输出四类数值——总行数、代码行数、注释行数、空白行数。C 和 C 共用一套注释语法所以一个工具两种语言都能覆盖。但真正动手写的时候会发现难的不是“数行数”而是“定义行数”。同样是return 0;有的文件里一行一个有的文件里跟前面的逻辑挤在一起同样是注释//和/* */是两种完全不同的品种后者还能跨行。如果不把口径定清楚统计结果在不同工具之间会差得很离谱。1.2 四类行的判定标准我给这四种行下了明确的定义你可以直接参照这个口径行类型判定规则示例总行数文件里物理存在的所有行即使行内只有换行符也算空白行去掉空格、Tab、回车后内容为空、\t注释行去掉注释内容和空白后没有实际代码// xxx、/* xxx */代码行去掉注释内容后还有非空白代码int a 1;、} // 结束这里最关键的一条是“代码行”的判定一行里既有代码又有注释比如int a 1; // 初始化应该算作代码行而不是注释行。原因很简单这行代码是生成逻辑的一部分砍掉注释它依然成立但反过来纯注释行如果有 100 行说明文档量确实大。这个口径和 cloc、SourceCounter 等主流工具基本一致互相印证的时候不会差太多。2. 状态机让程序知道“自己现在在哪段代码里”2.1 为什么正则表达式搞不定一听到统计注释很多人第一反应是写正则匹配//到行尾匹配/* ... */。但真实代码里有一个致命场景http://出现在字符串里或者注释内容里出现了/*正则一匹配就误判。最典型的是这段代码const char* url http://example.com; // 访问首页如果用简单的“匹配//就删掉后面”的思路http://里的//会被误伤把example.com后面的引号都删掉整行统计直接崩。你可能会说那我加一条“字符串里的//不算”不就行了可问题是要判断一个字符是不是在字符串里程序必须记住“我之前进入过字符串状态”这个事实这就是跨字符的记忆需求正则表达式做不到或者说做起来极其痛苦。这种情况下正确的思路是把读取过程看成一个逐字符扫描的流水线每读一个字符程序根据自己的“当前状态”决定下一步怎么走。这就是状态机。2.2 状态机的本质一个带记忆的自动售货机状态机这个概念听起来吓人其实生活中到处都是。你想想自动售货机你投币之前它处于“等待投币”状态你投了币但没选货它处于“已投币”状态你选了货并且余额够它进入“出货”状态出货完成又回到“等待投币”。程序在不同阶段做不同的事就是状态机的核心。我们的代码扫描器一共有五个状态状态含义进入条件退出条件CODE普通代码区默认状态遇到//、/*、、LINE_CMT//行注释内遇到//到达行尾BLOCK_CMT/* ... */块注释内遇到/*遇到*/STR双引号字符串内遇到遇到未转义的CHR单引号字符常量内遇到遇到未转义的这个状态表是整个工具的骨架。程序每读入一个字符先看自己当前在哪个状态再决定这个字符该如何处理。比如在 BLOCK_CMT 状态里就算出现十个//也全部忽略在 STR 状态里//只是普通字符不会触发注释。这样http://...的问题自然就解决了。2.3 为什么逐字符扫描而不是逐行匹配还有个常见的误区是既然我们统计的是行数为什么不按行处理先判断这行里有没有注释再决定算哪类原因是块注释/* ... */完全可以跨行/* 这段注释 跨越了三行 直到这里才结束 */如果按行处理第一行只有/*第三行只有*/程序必须记住“我正处在块注释中”这个状态才能把中间那行也识别成注释行。本质上按行处理也得引入状态记忆还不如直接逐字符扫描来得自然。逐字符的好处是状态机只关心“当前字符是什么、下一个状态是什么”不需要关心物理行边界到了行尾把统计结果落账就行。3. 核心实现用 C 写出统计引擎3.1 数据结构与整体流程统计结果用一个结构体保存每个文件一个实例多个文件再做汇总struct Stats { int code 0; // 代码行 int comment 0; // 注释行 int blank 0; // 空白行 int total 0; // 总行数 void add(const Stats o) { code o.code; comment o.comment; blank o.blank; total o.total; } };整体流程分三步打开文件、逐行读取、逐字符扫描。逐行读取用std::getline这个是 C 标准库自带的行读取函数用起来最稳。逐字符扫描用的是for循环配合索引因为我们在处理注释//和块注释结束符*/时都需要“往后多看一个字符”用索引可以方便地i 2跳过两个字符。3.2 状态机主循环逐段解析下面是单个文件统计的核心函数也是整个工具的心脏。我把它拆成几段讲清楚Stats analyzeFile(const fs::path path) { std::ifstream in(path); if (!in.is_open()) { std::cerr 警告无法打开文件 path std::endl; return {}; } Stats st; State state State::CODE; std::string line; while (std::getline(in, line)) { st.total; bool lineHasComment false; std::string codePart; size_t i 0; while (i line.size()) { char c line[i]; char nx (i 1 line.size()) ? line[i 1] : \0; switch (state) { case State::CODE: if (c / nx /) { lineHasComment true; state State::LINE_CMT; i 2; } else if (c / nx *) { lineHasComment true; state State::BLOCK_CMT; i 2; } else { codePart.push_back(c); if (c ) state State::STR; else if (c \) state State::CHR; i; } break; case State::LINE_CMT: i line.size(); break; case State::BLOCK_CMT: if (c * nx /) { state State::CODE; i 2; } else { i; } break; case State::STR: case State::CHR: codePart.push_back(c); if (c \\ nx ! \0) { codePart.push_back(nx); i 2; } else { if (state State::STR c ) state State::CODE; if (state State::CHR c \) state State::CODE; i; } break; } } if (state State::LINE_CMT) state State::CODE; if (isBlankLine(line)) st.blank; else if (trim(codePart).empty()) st.comment; else st.code; } if (state State::BLOCK_CMT) std::cerr 警告文件 path 的块注释没有闭合 std::endl; return st; }核心逻辑其实就在 CODE 状态里那两个判断看到//就进入行注释状态看到/*就进入块注释状态。进入行注释后这一行后面的字符全部略过直到行尾进入块注释后一直扫描到*/才重新回到 CODE 状态中间无论多少行都不产生codePart。字符串和字符常量的处理有个细节值得注意当我在 CODE 状态遇到时会先把这个引号放进codePart再进入 STR 状态。因为在统计口径里字符串字面量本身是代码的一部分不能丢掉。而字符串里的\\转义处理是避免把\里的引号当成字符串结束符这一步是很多初学者容易漏的。3.3 统计判定为什么要在行尾做一行扫描结束后程序根据三个条件给这行归类isBlankLine(line)原始行去掉空白就为空判定为空白行。trim(codePart).empty()说明去掉注释后没有任何实际代码判定为注释行。否则判定为代码行。关键点trim(codePart).empty()判定的是“去注释后没有代码”而不是“这行有没有注释”。所以int a 1; // 初始化这种混合行codePart里有int a 1;判定结果是代码行。如果想严格区分“纯注释行”和“带代码的注释行”可以再加一个分类但工具阶段不需要保持口径清晰即可。这里顺带说明一下lineHasComment变量的作用当前版本里它只被赋值没被使用。真正的用途是在扩展版里加一个“注释代码混合行”的统计类型如果你需要这部分数据直接用它判断即可。4. 支持整个目录遍历与汇总4.1 文件筛选规则单文件统计只是第一步实际用的时候我更想要的是输入一个目录把里面所有.c、.cpp、.h、.hpp文件全扫一遍最后汇总成一个总报告。C17 的std::filesystem让这件事变得特别简单不需要依赖任何第三方库。bool isTargetFile(const fs::path p, const std::vectorstd::string exts) { if (!p.has_extension()) return false; std::string ext p.extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); return std::find(exts.begin(), exts.end(), ext) ! exts.end(); }扩展名必须转换成小写再判断不然.CPP和.cpp会漏掉一个这是我在 Windows 上遇到的第一个小坑。之后用递归遍历void scanDirectory(const fs::path dir, const std::vectorstd::string exts, Stats total) { for (const auto entry : fs::recursive_directory_iterator(dir)) { if (!entry.is_regular_file()) continue; const fs::path p entry.path(); if (!isTargetFile(p, exts)) continue; Stats st analyzeFile(p); printFileStats(p, st); total.add(st); } }recursive_directory_iterator会把子目录里的文件一起找出来省去了手工写递归遍历的麻烦。如果你用的编译器比较老不支持 C17可以用std::experimental::filesystem加一个宏开关来兼容但建议直接升级编译器filesystem是现在跨平台目录操作的标配。4.2 运行效果演示写一个简单的main函数支持两种调用方式传单个文件或者传目录。运行结果长这样$ ./linecount ./src 文件: ./src/main.cpp 代码: 128 注释: 24 空白: 18 总行: 170 文件: ./src/utils.cpp 代码: 88 注释: 30 空白: 12 总行: 130 文件: ./src/utils.h 代码: 42 注释: 15 空白: 8 总行: 65 ------------------------------------------------------------ 总计: 代码 258 注释 69 空白 38 总行 365这个输出格式还有一个隐藏好处复制到 Excel 里按空格分列就能直接做透视表按目录维度看每个模块的代码量分布。我在实际汇报项目工作量时就是这么干的省了很多整理时间。5. 测试用例怎么证明统计结果是对的5.1 必测的几个边界场景写完工具必须验证不然不敢拿真实项目做报告。我准备了一个测试文件故意把所有容易出错的场景塞进去#include iostream #include string // 行注释这行应该算注释行 int main() { // 初始化入口这行应该算代码行 // 字符串里含有 http:// 和 /* 都不影响 const char* url http://example.com/path?id1; char ch /; // 字符常量里的斜杠不是注释 int a 1; /* 这是一个 跨行的块注释 注释结束 */ int b 2; /* 同行块注释 */ int c 3; return 0; }逐个看第 3 行是//行注释纯注释行。第 4 行int main() {后面有行注释但前面是代码应该算代码行。第 5 行纯粹注释算注释行。第 6 行字符串里有http://如果状态机有 bug这行代码会被拦腰截断正确结果是代码行。第 7 行char ch /;单引号里有一个/这也不是注释关键是字符常量的结束引号不能被误判。第 10 到 12 行是跨行块注释三行都应该算注释行。第 13 行/* 同行块注释 */ int c 3;注释后面还有代码算代码行。5.2 实测结果对照我把这个测试文件用工具跑了一遍同时手工数了一遍对比如下行号实际内容手工判定工具判定1-2#include等代码行代码行3// 行注释注释行注释行4int main() { // 注释代码行代码行5// 注释注释行注释行6字符串含http://代码行代码行7字符常量/代码行代码行8-9代码代码行代码行10-12跨行块注释注释行注释行13代码注释代码行代码行14return 0;代码行代码行对照下来完全一致。我当时还故意把url字符串去掉让http://裸露在代码区工具正确地把它判定成了注释开头。这说明状态机确实在起作用而不是碰巧把这一行当成了代码。6. 踩坑记录与扩展思路6.1 实际开发中遇到的坑第一个坑是编码问题。项目里有人用 GBK 编码写中文注释有人用 UTF-8扫描时会不会乱码实测下来我的统计逻辑只关心 ASCII 字符里的/、*、、和空白字符中文的字节值不会和这些 ASCII 字符冲突所以 GBK 和 UTF-8 都不会导致误判。但如果你要统计“注释里的中文字数”那就必须处理编码了这已经超出代码行统计的范畴。第二个坑是未闭合的块注释。有一次朋友拿了一个残缺的 C 文件来跑结果所有文件统计都被污染了。原因是他某个头文件里写了/*但忘了闭合状态机到了文件末尾还停留在 BLOCK_CMT 状态导致后面所有行都被当成注释。我现在在函数结尾加了检查检测到未闭合注释就打印警告至少能把问题暴露出来。第三个坑是 C11 的原始字符串字面量。R(// 这不是注释)这种写法程序会把R的起始引号当成字符串开始然后在)的引号处结束中间的//虽然确实被保护住了但如果原始字符串内部包含)序列就会提前结束字符串导致后续误判。这是简化版工具的一个已知限制处理它需要额外识别R前缀工作量增加不少。如果项目里大量使用 raw string建议在工程层面限制编码规范否则真没必要为这个冷门特性搞复杂状态机。6.2 接下来还能怎么扩展这个工具的可扩展性比我想象的好。加上lineHasComment判断就能输出“注释代码混合行”的单独统计在analyzeFile里顺便数一下函数名和花括号配对就能统计函数数量把输出格式改成 JSON 或 CSV可以直接接入 CI/CD 流程在每次代码提交后自动生成代码量报告。我后来还做过一个加并行处理的版本用std::async把每个文件的统计任务丢到线程池里多核机器上处理大工程的速度提升很明显。不过要注意汇总结果时加锁不然多线程同时累加Stats会产生数据竞争。最后分享一个使用心得统计工具最大的价值不是那个数字本身而是它逼你把“什么是代码行、什么是注释行”的定义想清楚。这个定义想清楚了写出来的工具才真正可信汇报给别人的代码量数据也经得起推敲。本文还有配套的精品资源点击获取