多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++代码统计工具:从状态机原理到CI质量门禁的工程实践

C++代码统计工具:从状态机原理到CI质量门禁的工程实践 简介这是一款面向C与C语言开发者的代码统计工具可分析源码中的代码行、空行与注释行帮助评估项目规模、代码密度与可维护性适合需要做代码质量审查、重构效果对比或开源项目规模评估的初中级程序员。压缩包共102个文件约7.04MB以cpp与h源码文件为核心配合obj、sbr、pdb等编译中间文件以及ico、bmp、rc等界面资源另有dsp、dsw工程文件与exe可执行程序构成一套完整的工程源码与运行环境。资源已有883人学习下载具备一定参考热度。读者可获得完整的C工程实现涵盖单行与多行注释识别、空行区分、代码密度计算等核心逻辑并可通过工程文件直接编译运行对照源码理解统计模块的设计思路为二次开发或集成到自有工具链提供基础。1. 从一次代码评审翻车说起C 代码统计工具到底在统计什么接手一个存量的 C 语言老项目时我最怕的不是编译报错而是评审会上有人问「这个模块到底多少行、注释率多少」。当时我随手用编辑器自带的统计功能报了个数字结果被当场打脸——编辑器把空行、被#if 0包住的死代码、字符串里的//全算进去了。那次翻车之后我才认真去写一个能区分代码行、注释行、空行的 C 代码统计工具顺带把 C 代码也一起覆盖。这类工具解决的核心问题很具体给你一个目录递归扫描.c、.cpp、.h、.hpp文件逐行判定它属于代码行LOC、注释行CLOC还是空行BLANK最后汇总出总行数、注释率、文件数。它适合三类人做代码审计要交注释率报告的、接手遗留 C 项目想快速摸清体量的、以及想给自己项目加一个 CI 质量门禁的。热词里常出现的「统计行数」「字段注释」「文档级 doxygen 注释」其实都指向同一个诉求——把注释从代码里干净地剥离出来单独计数。下面我按自己实际落地的路径从状态机原理讲到命令行工具再到踩过的坑一步步拆开。2. 逐行状态机为什么不能靠正则一把梭2.1 三种行类型的判定边界很多人第一反应是写个正则遇到//或/*就判注释。这个思路在简单文件上能跑但一碰到真实 C 代码就崩。原因在于 C/C 的注释和字符串是互相干扰的char* s http://x;里的//是字符串内容不是注释/* 注释里出现 引号 */里的引号也不该开启字符串状态。所以判定必须是有状态的逐字符扫描而不是逐行匹配。我一般把每一行归为四类状态之一纯代码行、纯注释行、空行、以及代码与注释混合行比如int a 1; // 初始化。混合行怎么算是个业务决策——多数统计口径把它算作代码行注释率只统计纯注释行。这个口径要在工具里写死并说明否则不同人跑出来的注释率对不上又是一场评审扯皮。判定时维护两个关键状态位是否在块注释中in_block_comment、是否在字符串或字符字面量中in_string/in_char。扫描到/*且不在字符串里就进入块注释态扫描到*/退出扫描到且不在注释里就进入字符串态注意处理\转义。行尾的\续行符也要处理否则宏定义里的多行注释会算错。2.2 用 Python 写一个最小可用的逐行扫描器先用 Python 把核心状态机跑通逻辑清晰、改起来快验证口径没问题之后再考虑用 C 重写提性能。下面这段是能直接跑的最小版本def classify_file(path): 逐字符扫描返回 (code, comment, blank) 三类行数 code comment blank 0 in_block False # 是否处于 /* */ 块注释中 in_str False # 是否处于字符串字面量中 in_char False # 是否处于字符字面量中 with open(path, r, encodingutf-8, errorsignore) as f: for raw in f: line raw.rstrip(\n) if not line.strip(): blank 1 continue has_code False has_comment False i 0 n len(line) while i n: ch line[i] nxt line[i1] if i1 n else if in_block: has_comment True if ch * and nxt /: in_block False i 2 continue elif in_str: has_code True if ch \\: # 跳过转义字符 i 2 continue if ch : in_str False elif in_char: has_code True if ch \\: i 2 continue if ch : in_char False else: if ch / and nxt /: has_comment True break # 行注释后面全是注释 if ch / and nxt *: in_block True has_comment True i 2 continue if ch : in_str True has_code True elif ch : in_char True has_code True elif not ch.isspace(): has_code True i 1 if has_code: code 1 elif has_comment: comment 1 else: blank 1 return code, comment, blank逻辑说明外层按行读内层按字符走。in_block为真时只找*/其余字符都算注释in_str/in_char为真时只找配对的引号中间内容一律算代码。只有三个状态位都为假时才去识别//、/*、、这些起始符号。行尾判定用has_code优先只要这一行出现过任何代码字符就归为代码行注释行只在整行没有任何代码字符时才成立。参数说明errorsignore是为了兼容老项目里 GBK 或混合编码的文件避免一个坏字节让整个统计中断rstrip(\n)只去换行符保留行内空格用于判断空行。这个版本没处理续行符\如果项目里宏定义多需要额外加一个「上一行以\结尾则拼接」的逻辑否则宏里的注释会漏统计。2.3 递归扫描目录与结果汇总单文件跑通后套一层目录遍历就能出报告。用os.walk递归按扩展名过滤把每个文件的三类计数累加import os EXTS (.c, .cpp, .cc, .h, .hpp) def scan_dir(root): total {code: 0, comment: 0, blank: 0, files: 0} for dirpath, _, filenames in os.walk(root): for name in filenames: if not name.endswith(EXTS): continue c, m, b classify_file(os.path.join(dirpath, name)) total[code] c total[comment] m total[blank] b total[files] 1 return total if __name__ __main__: import sys r scan_dir(sys.argv[1] if len(sys.argv) 1 else .) total_lines r[code] r[comment] r[blank] rate r[comment] / total_lines * 100 if total_lines else 0 print(f文件数: {r[files]}) print(f代码行: {r[code]} 注释行: {r[comment]} 空行: {r[blank]}) print(f总行数: {total_lines} 注释率: {rate:.2f}%)逻辑说明EXTS用元组而不是列表是因为str.endswith接受元组做多后缀匹配比循环判断快。注释率的分母我用的是总行数代码注释空行这是比较常见的口径如果你的团队要求分母只算代码行把total_lines换成r[code]即可但一定要在报告里写清楚不然数字对不上。参数说明os.walk默认会进入隐藏目录和build、.git这类目录实际用的时候建议加一个排除列表否则第三方库和构建产物会把行数撑得虚高。这个坑我在第 5 章会展开讲。3. 从脚本到命令行工具参数、性能与工程化3.1 用 C 重写核心扫描器的取舍Python 版本在几万行的项目上够用但扫一个几十万行的 C 大仓时逐字符的 Python 循环会明显变慢这时候用 C 重写核心扫描器是值得的。重写的关键不是把逻辑翻译一遍而是换一种更省事的读法一次性把整个文件读进内存用指针或索引遍历避免逐行 IO 的开销。#include fstream #include sstream #include string struct Stat { long code 0, comment 0, blank 0; }; Stat classify(const std::string path) { std::ifstream in(path, std::ios::binary); std::stringstream ss; ss in.rdbuf(); std::string s ss.str(); Stat st; bool inBlock false, inStr false, inChar false; bool lineHasCode false, lineHasComment false; bool lineStart true; for (size_t i 0; i s.size(); i) { char c s[i]; char n (i 1 s.size()) ? s[i1] : \0; if (c \n) { if (lineHasCode) st.code; else if (lineHasComment) st.comment; else st.blank; lineHasCode lineHasComment false; lineStart true; continue; } if (inBlock) { lineHasComment true; if (c * n /) { inBlock false; i; } } else if (inStr) { lineHasCode true; if (c \\) { i; } else if (c ) inStr false; } else if (inChar) { lineHasCode true; if (c \\) { i; } else if (c \) inChar false; } else { if (c / n /) { lineHasComment true; break; } if (c / n *) { inBlock true; lineHasComment true; i; continue; } if (c ) { inStr true; lineHasCode true; } else if (c \) { inChar true; lineHasCode true; } else if (!std::isspace(static_castunsigned char(c))) lineHasCode true; } } return st; }逻辑说明整体读入后用单层循环遍历遇到\n就结算当前行的归属。lineStart变量在这个版本里其实没用到可以删掉我保留它是因为有些扩展口径比如统计「以注释开头的行」会用到。break出现在//分支里表示本行剩余内容全是注释直接跳出内层循环去结算这是性能上的小优化。参数说明std::ios::binary很重要Windows 上文本模式会把\r\n转成\n虽然对行数统计影响不大但二进制模式能保证跨平台行为一致。static_castunsigned char是为了避免isspace在遇到负值字符时产生未定义行为这是 C 里一个经典的玄学 bug很多人栽过。3.2 命令行参数设计排除目录与输出格式工具一旦要给别人用参数设计就得上心。我一般会留这几个开关--exclude排除目录、--ext自定义扩展名、--format输出格式text / csv / json、--min-comment注释率低于阈值时返回非零退出码方便接 CI。参数作用默认值典型用法--exclude跳过指定目录可多次指定无--exclude build --exclude third_party--ext覆盖默认扩展名列表.c,.cpp,.h,.hpp--ext .c,.h--format输出格式text--format json--min-comment注释率下限低于则退出码为 10--min-comment 20--min-comment这个参数是给 CI 用的在流水线里跑一遍注释率低于 20% 就 fail逼着团队补注释。注意退出码要区分「统计失败」和「注释率不达标」前者用 2后者用 1否则 CI 日志里分不清是工具崩了还是质量门禁没过。3.3 大仓扫描的性能与内存控制一次性读入整个文件在单文件上没问题但如果目录里有超大生成文件比如几 MB 的.h内存会抖。稳妥做法是设一个文件大小上限超过就退化成流式逐行读或者直接跳过并记一条 warning。我一般把上限设在 5 MB超过的文件单独列出来让人工确认因为那种体量的文件多半是自动生成的统计进去反而失真。另一个性能点是并行。目录遍历本身是 IO 密集用线程池按文件并行扫描能明显提速但要注意结果汇总时的锁竞争。简单做法是每个线程维护自己的局部计数最后合并避免每扫一个文件就抢一次全局锁。这个优化在几万文件级别才明显小项目没必要上。4. 避坑与排查注释统计最容易翻车的五个地方4.1 现象注释率虚高代码行被算成注释原因字符串里的//被误判。比如printf(http://example.com);这行如果状态机没先进入字符串态就会在//处断掉把后半行算成注释。更隐蔽的是字符字面量/后面跟/两个独立字符被连起来当成行注释起始。解决确保字符串态和字符态的优先级高于注释识别。扫描顺序必须是「先判断是否在字符串/字符里再判断是否在块注释里最后才识别注释起始符」。上面两段代码都是这个顺序照抄即可。4.2 现象块注释跨行统计错位*/后面的代码丢失原因/* ... */ int a 1;这种一行内注释结束后还有代码的情况如果退出块注释后没有继续扫描本行剩余字符int a 1;就被漏掉了整行被误判为纯注释行。解决退出块注释态时不要break而是continue继续处理后面的字符。Python 版本里in_block分支处理完*/后是i 2; continueC 版本里是i后自然进入下一轮循环都保证了后续字符被继续扫描。4.3 现象Windows 下注释乱码中文注释统计异常原因老 C 项目常用 GBK 编码Python 默认按 UTF-8 读会抛异常或读出乱码乱码字符可能被误判为代码字符导致注释行被算成代码行。热词里「vscode 注释乱码」说的就是这类问题。解决读文件时显式指定编码或者用errorsignore兜底。更稳的做法是先探测编码比如用chardet再按探测结果读。C 版本因为是按字节扫描中文注释的字节不会被误判成 ASCII 符号反而更安全但要注意多字节字符里如果恰好出现0x2F/这种字节理论上会误判——实际 GBK 和 UTF-8 的汉字字节都避开了 ASCII 区间所以不会出问题。4.4 现象#if 0包住的死代码被算进代码行原因预处理器条件编译是编译期行为纯文本扫描器看不懂#if 0会把里面的内容照常统计。这会让代码行数虚高注释率虚低。解决这是个口径问题不是 bug。要么在报告里注明「未处理条件编译」要么加一个简单的#if 0块跳过逻辑。我一般选前者因为完整实现预处理器逻辑成本太高而且#if 0的嵌套和宏展开很难在文本层面正确处理。如果团队在意这个建议在 CI 里用编译器预处理后再统计而不是在源码上硬扫。4.5 现象第三方库和构建产物把行数撑爆原因os.walk默认递归所有子目录build/、third_party/、.git/里的文件全被算进去一个实际几万行的项目统计出几十万行。解决维护一个默认排除列表至少包含build、dist、.git、third_party、vendor、node_modules。同时提供--exclude让用户追加。排除逻辑要在遍历时判断目录名命中就dirs.remove()剪枝而不是扫完再过滤否则大仓遍历本身就很慢。5. 进阶技巧把统计接进 CI 与注释率趋势跟踪工具跑通之后真正有价值的是把它变成持续可见的指标而不是每次评审前手动跑一遍。我的习惯是在 CI 里加一个 job每次提交都统计一次把注释率和代码行数写进构建产物再用一个简单脚本对比上一次的结果注释率下降就报警。# CI 里调用统计工具并做门禁 ./codestat --exclude build --exclude third_party \ --format json --min-comment 20 stat.json if [ $? -eq 1 ]; then echo 注释率低于 20%请补充注释 exit 1 fi逻辑说明--min-comment 20让工具在注释率低于 20% 时返回退出码 1CI 据此判定失败。--format json方便后续用jq提取字段做趋势图。注意退出码 2 表示工具本身出错比如路径不存在要和门禁失败区分开否则工具崩了会被误认为注释率不达标。参数说明阈值 20% 不是拍脑袋定的是团队根据历史数据定的基线——先跑一个月只记录不拦截看注释率的分布再把阈值设在略低于中位数的位置避免一上来就大面积 fail 打击积极性。再进一步可以把每次的stat.json存到制品库用时间序列看注释率走势。我见过一个团队用这种方式发现某个模块注释率连续三个月下滑追进去发现是新来的同学不知道注释规范补了文档之后就回升了。这种趋势跟踪比单次统计有用得多也是这个工具从「一次性脚本」变成「长期基础设施」的关键一步。最后说个我自己的习惯统计口径一定要写进工具的--help里尤其是混合行怎么算、空行算不算分母、条件编译处不处理。我踩过最深的坑不是代码写错而是两个人用同一个工具跑出两个数字最后发现是口径理解不一致。工具可以简单口径必须唯一。希望帮到你。本文还有配套的精品资源点击获取
返回列表