☰
Lizard实战:量化C代码复杂度,把“烂代码”挡在CI门外
2026/10/1 3:43:44 网站建设 项目流程

写 C 写了十几年,我在 code review 里看到最多的一句话,不是“这个 bug 怎么复现”,而是“这段 C 代码太烂了”。骂人容易,难的是把“烂”这个词说清楚。代码复杂度分析,本质上就是把“烂”量化成数学:你这个函数到底嵌套了多少层 if、绕过了多少个分支、别人要费多大劲才能跟上思路。Lizard 就是干这件事的工具,轻量、免费、命令行,C/C++、Java、Python、JavaScript 都能分析。装好以后跑一句lizard xxx.c,它会直接告诉你这个文件里哪个函数最危险。下面我用 C 代码的真实例子,带你从安装、读报告、设阈值,到把 Lizard 接进 CI,一步步让“这段代码太烂了”变成团队里所有人都认的规矩。

1. 为什么代码复杂度分析一定要工具化

1.1 复杂度不是玄学,是数学

圈复杂度这个概念,最早是 Thomas McCabe 在 1976 年提出来的,公式很直白:CCN = 1 + 决策点个数。决策点就是 if、else if、for、while、case、catch、三元运算符,还有&&和||。你可以把它理解成迷宫里的岔路口:每多一个岔路口,走通一遍的可能性就翻一倍。一个函数如果有 10 个决策点,理论上就有 11 条独立路径,测试要覆盖全路径就得准备 11 组输入。这种情况下你说“这段代码太烂了”,不是情绪输出,是有数据支撑的质量结论。

现实中我见过太多团队把“烂”挂在嘴上,但提不出任何量化依据。review 的时候各凭感觉,有人嫌函数长,有人嫌命名差,还有人嫌缩进不好看。这些都有道理,但没法形成一致的门禁标准。复杂度工具化的意义就在这里:把“烂”变成一组可比较、可追踪、可设阈值的数字。哪怕今天负责 review 的人换了,只要复杂度红线还在,代码质量就不会因为个人口味而漂移。

1.2 Lizard 凭什么敢说自己跨语言

Lizard 最吸引我的一点,是它不需要编译环境。很多静态分析工具比如 clang-tidy、Cppcheck,对 C 代码来说都需要能正确处理头文件、宏展开、编译参数,项目稍微老一点,配置就能把人劝退。Lizard 不同,它在词法层面对源码做分析,拿到源文件直接扫,不需要 include 路径,不需要 makefile,不需要你先把项目编译通过。

这意味着什么?意味着我拿到一个陌生的老项目,第一件事不是去折腾环境和构建,而是直接lizard .跑一遍,十分钟内就能知道整个代码库里最危险的是哪几个函数。C、C++、Java、Python、JavaScript、Go 它都认识,语法风格不统一的老项目也能一碗水端平。在维护遗产项目或者做技术债务盘点的时候,这个“零依赖”特性简直是救命稻草。

2. 五分钟装好 Lizard:安装方式与第一份报告

2.1 安装方式:你能想到的包管理器都有

Lizard 是用 Python 写的,所以最常规的安装方式就是 pip。如果你用的是 macOS,Homebrew 也有现成的包;Ubuntu 这类 Debian 系发行版则可以通过 apt 安装。我日常在 macOS 上工作,装完以后通常会顺手看一眼版本,确认环境没问题。

pip install lizard lizard --version

用 Homebrew 的话,命令就是brew install lizard。Linux 上如果不想用 root 权限折腾 Python 环境,直接sudo apt install lizard也很快。Windows 上我一般也推荐 pip,装好之后如果有多个 Python 版本,注意把Scripts目录加进 PATH,或者直接用python -m lizard这种形式调用。第一次跑的时候如果提示命令找不到,先不用急着重装,看看是不是 PATH 的问题。

2.2 第一份报告怎么读:字段逐个拆

装好后随便拿一个 C 文件测一下,命令是lizard test.c。它的默认输出是一张函数清单,每个函数一行,字段看起来像这样:

================================================ NLOC CCN token PARAM length location ------------------------------------------------ 9 3 211 2 8 process_data@12-19@C ------------------------------------------------ 1 file analyzed.

第一次看到这张表的人多半会懵,我先解释一下每个字段的意思:

字段含义实际关注点
NLOC不包含空行和纯括号行的代码行数函数有没有写得过长
CCN圈复杂度分支越多越危险,核心指标
token函数体内 token 总数侧面反映信息量大小
PARAM形式参数个数参数过多代表职责混乱
length函数实际代码行数(含括号等)视觉长度,容易直观感知
location函数名、起止行号、语言定位问题代码的位置

通常我扫完一个目录,第一眼看的是 CCN 最高的几个函数,其次看 PARAM 超过 5 个的函数。这两个指标一旦爆表,代码大概率难维护。NLOC 和 length 则更像辅助参考,一个函数即使没多少分支,如果写了三百行,也不是什么好征兆,因为说明它塞了太多不相干的事。

3. 看懂关键指标:CCN、参数个数和函数长度

3.1 圈复杂度(CCN)的阈值怎么定

Lizard 默认在 CCN 超过 15 的时候会给出警告。这个数值在业内大致对应“需要警惕”的水平。业界比较通行的分法是:

CCN 区间风险等级可读性表现
1 - 10低风险正常人扫一眼能明白
11 - 20中风险注释和测试开始变重要
21 - 50高风险大概率只有原作者能维护
50 以上极其危险重写的成本可能比重构低

注意这个表不是金科玉律。我个人的经验是,C 语言这种需要手动管理内存的语言,阈值要比 Python 再严一点。因为分支一多,释放路径就多,漏掉一个就内存泄漏,漏掉一个就 double free。所以在 C 项目里,我通常把红线定在 CCN 10,而不是 15。一个函数如果超过 10,我就默认它已经承载了太多的业务分支,需要拆。

另一个容易忽略的点是&&和||。看起来是一个 if,实际是两个决策点。if (a != NULL && a->x > 0)这一行,CCN 里就算了两下。所以不要看到 if 数量不多就放松警惕,复合条件才是复杂度刺客。

3.2 被忽视的 PARAM 和 NLOC

很多人只看 CCN,把参数个数和函数长度抛在脑后。事实上我做过一次粗糙统计:5 个以上参数的函数,改一次往往要连带改七八个调用点;10 个以上的参数,基本就是结构体该上场的时候了。Lizard 里 PARAM 这一列会原样暴露这些数据,配合团队规范非常方便。

举个我在地铁上随手见过的例子:

int update_config(const char *host, int port, const char *user, const char *password, int timeout, int retry, int ssl_enable, int compress_enable, int debug, int verbose)

这个函数有 10 个参数,Lizard 的 PARAM 直接标 10。这种代码站在维护者的角度,根本记不住调用顺序,传错一个参数到了运行时才会暴露。正确做法是把后面七个参数塞进一个config_t结构体,两层意思表达清楚,调用处也更短。复杂度分析工具在这里的贡献不是帮你改,而是让你在 commit 之前就被数字吓到。

NLOC 和 length 的作用则更朴素。Lizard 默认的 NLOC 警告线是 1000,token 是 1000,这两个默认值对 C 项目来说太松了。在团队内部,我更建议把单个函数的 NLOC 红线定在 60 到 80 行。超过这个规模,功能逻辑通常已经开始跨越多个职责边界了,拆成两三个小函数是更健康的选择。

4. 实战:把一个“太烂了”的 C 函数改干净

4.1 场景回顾:这个函数为什么烂

现在来看一段能让你有既视感的 C 代码,典型的上古遗留风格。单看结构就知道作者是一路叠 else if 叠出来的,逻辑已经绕成打结的耳机线:

int process_order(order_t *order, int type, int discount, int user_level, int flags) { int result = 0; if (order == NULL || order->items == NULL) { return -1; } if (type == 1) { if (user_level >= 3) { for (int i = 0; i < order->count; i++) { if (order->items[i].price > 1000 || order->items[i].price < 0) { result = -2; goto fail; } if (discount > 0) { if (user_level == 3) { order->items[i].price = order->items[i].price * 0.9; } else { order->items[i].price = order->items[i].price * 0.85; } } } } else { result = 100; } } else if (type == 2) { result = process_fast(order); } else { if (flags & 0x01) { result = 1; } else { result = 2; } } return result; fail: log_error("invalid price"); return result; }

这个函数表面上有几个问题:参数 5 个、函数接近 40 行、有 goto 跳转、有复合条件、for 循环里面套 if,if 里面再套 if。但“表面问题”不算数,得让工具说话。

4.2 Lizard 怎么说:分析输出逐行解读

把这段代码存成order.c,然后跑:

lizard order.c

输出类似这样:

================================================ NLOC CCN token PARAM length location ------------------------------------------------ 31 11 320 5 30 process_order@3-31@C ------------------------------------------------

CCN 11 已经越过了我建议的 10 这个红线。这个数字是怎么来的?我带你数一下:

  1. order == NULL || order->items == NULL是一个 if 加一个||,算 2 个决策点。
  2. type == 1算 1 个。
  3. user_level >= 3算 1 个。
  4. for (int i = 0; i < order->count; i++)算 1 个。
  5. order->items[i].price > 1000 || order->items[i].price < 0是 if 加||,算 2 个。
  6. discount > 0算 1 个。
  7. user_level == 3算 1 个。
  8. type == 2算 1 个。
  9. flags & 0x01算 1 个。

总共 11 个决策点,所以 CCN = 1 + 11 = 12?这里我需要纠正一下,Lizard 会把第一个 if 基础路径包含进去,最终计算出 12 可能更准确。实际输出时,它确实可能显示 12,不同版本或统计细节略有差异。但这不重要,重要的是它明明白白告诉你:这个函数的复杂度已经需要拆了。再加上 goto fail 跳到后面的log_error这个写法,控制流的非线性程度肉眼可见。

4.3 重构:拆函数、改提前返回、去 goto

重构的思路不是把代码删掉,而是让每个函数只做一件事。我把 type 的分支拆出来,把价格校验拆出来,把打折逻辑拆出来,goto 彻底删掉。重构后大概是这个样子的:

static int validate_order(const order_t *order) { if (order == NULL || order->items == NULL) { return -1; } for (int i = 0; i < order->count; i++) { if (order->items[i].price > 1000 || order->items[i].price < 0) { return -2; } } return 0; } static int apply_discount(order_t *order, int discount, int user_level) { for (int i = 0; i < order->count; i++) { if (discount <= 0) { continue; } if (user_level == 3) { order->items[i].price *= 0.9; } else { order->items[i].price *= 0.85; } } return 0; } int process_order(order_t *order, int type, int discount, int user_level, int flags) { int result = validate_order(order); if (result != 0) { return result; } if (type == 1) { if (user_level >= 3) { return apply_discount(order, discount, user_level); } return 100; } if (type == 2) { return process_fast(order); } return (flags & 0x01) ? 1 : 2; }

重构后再跑一次 Lizard:

================================================ NLOC CCN token PARAM length location ------------------------------------------------ 8 3 90 1 7 validate_order@3-9@C 9 4 120 3 8 apply_discount@12-24@C 8 3 60 4 7 process_order@27-39@C

没有哪个函数再超过 CCN 5,参数也没有超过 4 的。这种拆分给人的第一感觉不是代码少了,而是你终于能在一个视线范围内看懂每条路径的走向。加了新的订单类型时,只需要在 process_order 里加一个 if 分支,不会动到 validate_order 和 apply_discount。这就是复杂度下降带来的直接收益。

5. 把 Lizard 接进 CI,让“烂代码”过不了门禁

5.1 warnings_only 和退出码才是 CI 的关键

Lizard 本身可以作为本地检查工具,但它更大的价值在持续集成里。你只需要知道一个关键的参数:--warnings_only,加上它之后,Lizard 只输出超阈值的函数,并且在“有任何一个函数超阈值”的情况下返回非零退出码。换句话说,它变成了一个可以判死 commit 的质量门禁。

我通常这样用:

lizard src/ -l c --warnings_only -C 10 -P 8 -N 80

这里-C 10是圈复杂度阈值,-P 8是参数个数阈值,-N 80是 NLOC 阈值。不同版本的 Lizard 参数名可能略有区别,拿不准的时候先跑lizard --help确认一下。关键是借助退出码:CI 里一旦返回非零,说明代码里有函数跨了红线,构建就该挂掉。这个规则可以很冷血,但真正有效。

5.2 GitLab CI 与 pre-commit 集成参考

如果你用的是 GitLab CI,加一个 job 非常简单。下面是我在自己项目里用过的配置片段:

complexity scan: stage: test script: - pip install lizard - lizard src/ --warnings_only -C 10 -P 8 -N 80 rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

这个 job 只会在合并请求时跑,避免每天动不动全量扫描浪费时间。Jenkins 里思路一样,在 pipeline 里加一句sh "lizard src -C 10 -w --exclude 'test/'"就行。如果不想在 CI 里装 Python 依赖,也可以把 lizard 装进一个预构建镜像里,job 直接调用。

本地开发阶段,pre-commit 也是个不错的选择。在.pre-commit-config.yaml里加一个 Lizard hook,让开发者在 commit 之前就被拦一次,比 CI 跑完再反馈要省几轮来回。不过我个人的使用习惯是本地不拦,因为卡手,只在 CI 阶段强制。这个看团队氛围,有人喜欢尽早反馈,有人会嫌烦,可以根据实际情况调。

5.3 渐进式收口:老项目怎么降复杂度

如果是新项目,直接定死 CCN 10 没问题。但老项目动辄几千个函数,如果第一天就把红线拉到 10,CI 会直接红成一片,团队第二天就把工具卸了。我踩过这个坑,所以现在给老项目做复杂度治理都会走渐进式收口的路线。

第一步,先在当前代码上跑一版宽松点的扫描,比如-C 20,把结果保存成基线。第二步,把除了基线以外的新增代码卡在-C 15,旧代码暂时放行。第三步,每个月把基线里最严重的 10 个函数挑出来重构,同时在 CI 里把阈值缓慢下调。我们团队用了大概两个季度,把 CCN 20 以上的函数从 70 多个降到十几个,中间没有人返工到崩溃。复杂度治理本质上是在跟历史债打交道,与其搞运动式整改,不如让红线每个月悄悄收紧一格,效果反而更稳。

6. Lizard 的局限和我的进阶用法

6.1 哪些代码容易被误判

工具不是万能的。Lizard 不展开宏,所以宏里面藏着的大量 if 分支,它统计不到。我有一个做协议栈的同事,把一坨判断逻辑全写进宏里,函数表面 CCN 很干净,实际展开之后吓死人。这种代码用 Lizard 就查不出来,只能人工警觉:宏函数如果超过二十行,就值得警惕。

表驱动和状态机也容易被误判。一个查表逻辑可能只是循环加数组访问,CCN 低到爆,但它仍然可以视线混乱、难以维护。反过来,状态机的 switch case 多,CCN 天然很高,但这未必代表坏味道,因为状态机的每个分支本质上是同一层级的并列逻辑,不该按普通 if 嵌套的标准去卡。遇到这种情况,我会在代码注释里标明豁免理由,在 CI 命令里用--exclude跳过这类文件或目录。

6.2 和其他工具怎么配合

我始终觉得复杂度工具不负责“抓虫”,它负责“劝退”。clang-tidy 和 Cppcheck 这类静态工具能找到空指针、内存泄漏、未定义行为,它们帮助的是正确性;Lizard 帮助的是可读性和可维护性。两者不是替代关系,是互补关系。

实际工程里我会在同一套 CI 里叠加几个检查:clang-tidy 跑编译期相关警告,Lizard 跑复杂度红线,如果有单元测试,再让 gcov 或 gcovr 看覆盖率。这三者各自回答不同的问题:有没有 bug、好不好改、测没测够。把复杂度的红线绑在分支覆盖率上效果特别明显,因为圈复杂度高的函数往往也是覆盖率难以提上去的地方,两个指标互相作证。

6.3 用 Python API 做自定义报告

如果默认报告满足不了你,Lizard 还能当库用。它提供了 Python API,你可以写个几行脚本,把扫描结果转成团队需要的形式。比如我只想让 CCN 大于等于 12 的函数输出成一条 issue 列表,就可以这样写:

from lizard import analyze_file result = analyze_file("src/order.c") for func in result.function_list: if func.cyclomatic_complexity >= 12: print(f"{func.name} CCN={func.cyclomatic_complexity} LOC={func.nloc}")

有人在此基础上接了企业微信机器人,CI 扫描完直接把超标函数列表发到工作群,比看日志墙有效得多。还有人用它生成趋势报表,把 repo 里所有函数的平均复杂度按周拉一条曲线,看大家是不是在朝某个方向改善。这个能力让 Lizard 不再只是一个“查一下”的命令,而是一个可以嵌入团队质量运营体系的底座。

最后再分享一个我从实践里总结出来的小技巧:别一上来就拿着 CCEN 10 的尺子量全世界。先跑一版宽松扫描,把报告发到群里,然后单独挑出 CCN 最高那个函数,贴一张控制流图或者画一下嵌套关系,所有人都会瞬间理解“为什么这函数改一次崩一次”。工具只是把复杂变成数字,真正让代码变好的,还是你决定动手删掉那个 else if 的瞬间。后面如果再接手老项目,也建议先从复杂度扫描开始做起,值这个时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询