简介:面向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文件全扫一遍,最后汇总成一个总报告。C++17 的std::filesystem让这件事变得特别简单,不需要依赖任何第三方库。
bool isTargetFile(const fs::path& p, const std::vector<std::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::vector<std::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会把子目录里的文件一起找出来,省去了手工写递归遍历的麻烦。如果你用的编译器比较老,不支持 C++17,可以用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?id=1"; 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 | // 行注释 | 注释行 | 注释行 |
| 4 | int main() { // 注释 | 代码行 | 代码行 |
| 5 | // 注释 | 注释行 | 注释行 |
| 6 | 字符串含http:// | 代码行 | 代码行 |
| 7 | 字符常量/ | 代码行 | 代码行 |
| 8-9 | 代码 | 代码行 | 代码行 |
| 10-12 | 跨行块注释 | 注释行 | 注释行 |
| 13 | 代码+注释 | 代码行 | 代码行 |
| 14 | return 0; | 代码行 | 代码行 |
对照下来完全一致。我当时还故意把url字符串去掉,让http://裸露在代码区,工具正确地把它判定成了注释开头。这说明状态机确实在起作用,而不是碰巧把这一行当成了代码。
6. 踩坑记录与扩展思路
6.1 实际开发中遇到的坑
第一个坑是编码问题。项目里有人用 GBK 编码写中文注释,有人用 UTF-8,扫描时会不会乱码?实测下来,我的统计逻辑只关心 ASCII 字符里的/、*、"、'和空白字符,中文的字节值不会和这些 ASCII 字符冲突,所以 GBK 和 UTF-8 都不会导致误判。但如果你要统计“注释里的中文字数”,那就必须处理编码了,这已经超出代码行统计的范畴。
第二个坑是未闭合的块注释。有一次朋友拿了一个残缺的 C 文件来跑,结果所有文件统计都被污染了。原因是他某个头文件里写了/*但忘了闭合,状态机到了文件末尾还停留在 BLOCK_CMT 状态,导致后面所有行都被当成注释。我现在在函数结尾加了检查,检测到未闭合注释就打印警告,至少能把问题暴露出来。
第三个坑是 C++11 的原始字符串字面量。R"(// 这不是注释)"这种写法,程序会把R"的起始引号当成字符串开始,然后在)"的引号处结束,中间的//虽然确实被保护住了,但如果原始字符串内部包含)"序列,就会提前结束字符串,导致后续误判。这是简化版工具的一个已知限制,处理它需要额外识别R"前缀,工作量增加不少。如果项目里大量使用 raw string,建议在工程层面限制编码规范,否则真没必要为这个冷门特性搞复杂状态机。
6.2 接下来还能怎么扩展
这个工具的可扩展性比我想象的好。加上lineHasComment判断,就能输出“注释代码混合行”的单独统计;在analyzeFile里顺便数一下函数名和花括号配对,就能统计函数数量;把输出格式改成 JSON 或 CSV,可以直接接入 CI/CD 流程,在每次代码提交后自动生成代码量报告。
我后来还做过一个加并行处理的版本:用std::async把每个文件的统计任务丢到线程池里,多核机器上处理大工程的速度提升很明显。不过要注意汇总结果时加锁,不然多线程同时累加Stats会产生数据竞争。
最后分享一个使用心得:统计工具最大的价值不是那个数字本身,而是它逼你把“什么是代码行、什么是注释行”的定义想清楚。这个定义想清楚了,写出来的工具才真正可信,汇报给别人的代码量数据也经得起推敲。
本文还有配套的精品资源,点击获取