28、重构的度量与验证:代码复杂度度量、测试覆盖率、性能基准测试
重构这件事,最怕什么?
怕改完了心里没底。怕复杂度没降反升。怕功能对了性能崩了。
我见过太多团队,重构全凭感觉。改完代码,跑一遍主流程,觉得没问题就上线了。结果呢?线上出故障,回滚都来不及。
所以这一章,咱们聊聊怎么用数据说话。代码复杂度、测试覆盖率、性能基准测试——这三样东西,是重构的「体检报告」。没有它们,你就是在闭着眼做手术。
28.1 代码复杂度度量:别让圈复杂度骗了你
圈复杂度,英文叫 Cyclomatic Complexity。说白了,就是衡量一个函数里有多少条独立路径。
公式很简单:M = E - N + 2P。E 是边数,N 是节点数,P 是连通分量数。但实际用的时候,没人手算。工具帮你算好了。
我个人习惯用 pmccabe 或者 lizard。比如:
// 圈复杂度:1
int add(int a, int b) {
return a + b;
}
// 圈复杂度:3
int max(int a, int b) {
if (a > b) {
return a;
} else {
return b;
}
}
// 圈复杂度:5
int classify(int x) {
if (x < 0) return -1;
if (x == 0) return 0;
if (x > 0 && x < 100) return 1;
return 2;
}
你看,每多一个 if、else、case、for、while,复杂度就加 1。逻辑运算符 && 和 || 也会增加复杂度。
经验阈值:
- 1-10:简单,可维护
- 11-20:中等,建议拆分
- 21-50:复杂,必须重构
- 50+:不可维护,重写吧
我在项目中遇到过一段代码,圈复杂度 47。一个函数处理了 12 种消息类型,里面嵌套了 5 层 if-else。我花了三天才理清楚逻辑。最后拆成 8 个小函数,平均复杂度降到 6。测试覆盖率从 30% 直接拉到 85%。
避坑指南:我曾经以为圈复杂度越低越好。后来发现不对。有些场景,比如状态机,天然复杂度高。强行拆分反而让逻辑更散。关键是要「合理」,不是「最低」。
28.2 测试覆盖率:别只看行覆盖率
很多团队把「行覆盖率 80%」当成目标。但行覆盖率是骗人的。
举个例子:
int divide(int a, int b) {
if (b == 0) {
return -1; // 异常分支
}
return a / b; // 正常分支
}
如果你只测 divide(10, 2),行覆盖率是 100%(两行都执行了)。但 b == 0 这个分支根本没测到。这就是行覆盖率的陷阱。
真正要关注的是:
- 分支覆盖率:每个 if-else 的两个分支都跑到了吗?
- 条件覆盖率:复合条件里的每个子条件都覆盖了 true/false 吗?
- 路径覆盖率:所有可能的执行路径都测了吗?
我建议用 gcov 或 lcov 做覆盖率分析。编译时加 -fprofile-arcs -ftest-coverage,运行后生成 .gcda 文件,再用 gcov 解析。
# 编译
gcc -fprofile-arcs -ftest-coverage -o test_program test.c
# 运行测试
./test_program
# 生成覆盖率报告
gcov test.c
输出会告诉你每行代码执行了多少次。但记住,这只是起点。
注意:覆盖率 100% 不代表没有 bug。它只代表你测过的代码没有挂。逻辑错误、边界条件、并发问题,覆盖率是看不出来的。
28.3 性能基准测试:重构不能降性能
重构最怕什么?改完代码,功能对了,但性能掉了一半。
我见过一个团队重构网络协议栈,把循环展开改成了函数调用。代码漂亮了,但吞吐量下降了 40%。为什么?函数调用开销在热路径上被放大了。
所以,性能基准测试必须做。而且要在重构前后做对比。
我常用的工具是 perf 和 google benchmark。写一个简单的基准测试:
#include <benchmark/benchmark.h>
// 重构前的代码
int old_func(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += i;
}
return sum;
}
// 重构后的代码
int new_func(int n) {
return n * (n - 1) / 2;
}
static void BM_Old(benchmark::State& state) {
for (auto _ : state) {
benchmark::DoNotOptimize(old_func(10000));
}
}
static void BM_New(benchmark::State& state) {
for (auto _ : state) {
benchmark::DoNotOptimize(new_func(10000));
}
}
BENCHMARK(BM_Old);
BENCHMARK(BM_New);
BENCHMARK_MAIN();
运行结果会告诉你:新函数比旧函数快了多少倍。如果慢了,就要分析原因。
关键指标:
- 吞吐量(ops/sec):每秒能处理多少请求
- 延迟(latency):单次操作耗时
- CPU 利用率:是否跑满了
- 缓存命中率:L1/L2/L3 miss 率
我个人习惯在重构前跑一次完整的性能基线,保存结果。重构后跑同样的测试,对比差异。如果性能下降超过 5%,我会停下来分析。
28.4 三者如何配合?
复杂度度量告诉你「哪里需要重构」。测试覆盖率告诉你「重构后有没有改坏」。性能基准测试告诉你「重构后快不快」。
三者缺一不可。
我一般的工作流是:
- 用
lizard扫描全项目,找出圈复杂度 > 15 的函数 - 对这些函数写单元测试,确保分支覆盖率 > 90%
- 跑性能基准,记录基线
- 重构代码,降低复杂度
- 重新跑测试,确保覆盖率不降
- 重新跑性能基准,确保性能不降
- 如果性能下降,分析热点,优化
嗯,这套流程我用了好几年。每次重构,心里都有底。
小技巧:把这三项指标写进 CI/CD 流水线。每次提交代码,自动检查复杂度、覆盖率、性能。超标就报警。这样就不会有人偷偷引入「屎山」了。
28.5 知识体系图
下面这张图,把本章的核心逻辑串起来了。你想想看,重构不是拍脑袋,而是有数据支撑的工程决策。
你看,这三者不是孤立的。复杂度高的地方,往往测试覆盖率低。性能瓶颈往往藏在复杂逻辑里。所以,重构不是单点优化,而是系统工程。
好了,这一章就聊到这儿。记住:没有度量的重构,就是耍流氓。
公众号:蓝海资料掘金营,微信deep3321