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;
}

你看,每多一个 ifelsecaseforwhile,复杂度就加 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 吗?
  • 路径覆盖率:所有可能的执行路径都测了吗?

我建议用 gcovlcov 做覆盖率分析。编译时加 -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%。为什么?函数调用开销在热路径上被放大了。

所以,性能基准测试必须做。而且要在重构前后做对比。

我常用的工具是 perfgoogle 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 三者如何配合?

复杂度度量告诉你「哪里需要重构」。测试覆盖率告诉你「重构后有没有改坏」。性能基准测试告诉你「重构后快不快」。

三者缺一不可。

我一般的工作流是:

  1. lizard 扫描全项目,找出圈复杂度 > 15 的函数
  2. 对这些函数写单元测试,确保分支覆盖率 > 90%
  3. 跑性能基准,记录基线
  4. 重构代码,降低复杂度
  5. 重新跑测试,确保覆盖率不降
  6. 重新跑性能基准,确保性能不降
  7. 如果性能下降,分析热点,优化

嗯,这套流程我用了好几年。每次重构,心里都有底。

小技巧:把这三项指标写进 CI/CD 流水线。每次提交代码,自动检查复杂度、覆盖率、性能。超标就报警。这样就不会有人偷偷引入「屎山」了。

28.5 知识体系图

下面这张图,把本章的核心逻辑串起来了。你想想看,重构不是拍脑袋,而是有数据支撑的工程决策。

重构的度量与验证 代码复杂度度量 测试覆盖率 性能基准测试 圈复杂度 嵌套深度 函数长度 行覆盖率 分支覆盖率 条件覆盖率 吞吐量 延迟 缓存命中率 三者结合,形成重构的「体检报告」 复杂度 → 哪里需要改 | 覆盖率 → 改没改坏 | 性能 → 快不快

你看,这三者不是孤立的。复杂度高的地方,往往测试覆盖率低。性能瓶颈往往藏在复杂逻辑里。所以,重构不是单点优化,而是系统工程。

好了,这一章就聊到这儿。记住:没有度量的重构,就是耍流氓。


公众号:蓝海资料掘金营,微信deep3321