Skip to content

嵌套、卫语句、switch 与多态:复杂度到底降了没有 ​

代码扫描报告说一个方法的圈复杂度超标。开发者把嵌套的 if 改成了提前返回,看上去清爽多了,再扫一遍,圈复杂度反而多了 1。另一个人把分支换成了策略对象,数字降到了 3,评审时大家却发现读懂这条规则要在四个文件之间跳。

复杂度度量是发现问题的好线索,但它们数的东西不一样:有的数判定点,有的数嵌套,有的数路径组合。同一个改写,在一个度量上下降,在另一个上可能上升。把度量当成目标,就会出现「为了数字而改写」。

本文把同一条运费规则写成五种形式,先确认它们结果完全一致,再用 PMD 计算三种度量。实验见文末。

认知复杂度圈复杂度NPath层层嵌套1669卫语句7719switch 表达式579查表457策略对象233度量是找问题的线索:数字下降不等于规则变简单,规则本身的分支数量没有变
图 1 · 实测(PMD 7.28.0;五种写法在 48 组输入上结果完全一致):三个度量数的不是同一件事。认知复杂度惩罚嵌套,从嵌套写法的 16 一路降到策略写法的 2;圈复杂度数的是判定点,卫语句和 switch 反而比嵌套多 1;NPath 数的是路径组合,卫语句最高。策略写法的数字最低,是因为判断搬进了 lambda,而 lambda 不计入任何方法。

一、同一条规则,五种写法 ​

规则:会员免运费;否则按地区定基础运费(本地 6、省内 10、偏远 20),偏远地区超重另加 15,订单满 99 运费减半。

java
// 卫语句
static int guarded(Region r, int amount, boolean member, boolean heavy) {
    if (member) return 0;
    int fee = r == Region.LOCAL ? 6 : r == Region.PROVINCE ? 10 : 20;
    if (r == Region.REMOTE && heavy) fee += 15;
    if (amount >= 99) fee /= 2;
    return fee;
}

// switch 表达式
static int switched(Region r, int amount, boolean member, boolean heavy) {
    if (member) return 0;
    int fee = switch (r) {
        case LOCAL -> 6;
        case PROVINCE -> 10;
        case REMOTE -> heavy ? 35 : 20;
    };
    return amount >= 99 ? fee / 2 : fee;
}

另外三种是层层嵌套的 if/else、按地区查表、每个地区一个策略对象。改写之前先证明它们等价:

text
48 组输入(3 个地区 × 4 个金额 × 是否会员 × 是否超重),另外四种写法与嵌套写法不一致的次数:0

金额取 0、98、99、500,覆盖了「满 99」这条边界的两侧。改写控制流的第一步永远是证明等价,否则后面比较度量没有意义:一个改写如果悄悄改了行为,它的度量再好看也是错的。保行为比较的系统做法见把长方法拆开,哪些行为最容易被改掉。

二、三个度量各数什么 ​

写法认知复杂度圈复杂度NPath
层层嵌套1669
卫语句7719
switch 表达式579
查表457
策略对象233

圈复杂度数的是判定点的个数加一,大致等于独立路径的数量。它不关心代码怎么排布:一个 if 无论嵌套在第几层都算 1。卫语句把嵌套展开成平铺的条件,判定点一个没少,三元运算符和 && 还各算一个判定,所以从 6 变成了 7。改成提前返回通常不减少圈复杂度。

认知复杂度试图度量的是读者的理解成本。它对嵌套加罚:同一个 if,在第三层嵌套里比在顶层「贵」得多。所以嵌套写法是 16,卫语句降到 7,switch 表达式 5。从读代码的角度,这个变化和直觉是一致的:卫语句先把特殊情况排除掉,主干在左边对齐,读者不用在脑子里维护「我现在在哪个 else 里」。

NPath 数的是通过方法的所有可能路径的组合数,顺序的独立条件会相乘。卫语句写法里三个独立的 if 顺序排列,组合数到了 19,是五种写法里最高的。它提示的是测试要覆盖的组合多,不一定是读起来难。

三个数字说的是三件事,没有哪一个能单独代表「复杂」。

三、策略写法的数字为什么最低 ​

java
static final Map<Region, RegionFee> STRATEGIES = new EnumMap<>(Map.of(
        Region.LOCAL, heavy -> 6,
        Region.PROVINCE, heavy -> 10,
        Region.REMOTE, heavy -> heavy ? 35 : 20));

static int strategy(Region r, int amount, boolean member, boolean heavy) {
    if (member) return 0;
    int fee = STRATEGIES.get(r).base(heavy);
    return amount >= 99 ? fee / 2 : fee;
}

策略方法的认知复杂度是 2、圈复杂度是 3,看起来简单了很多。但规则本身的分支一个都没有少:按地区的分派变成了查 Map,偏远地区超重的判断搬进了 lambda。PMD 只报告了接口方法 base(圈复杂度 1),lambda 里的 heavy ? 35 : 20 没有计入任何方法。

复杂度没有消失,只是搬到了度量看不到的地方。 这本身不一定是坏事:如果地区会不断增加、每个地区的规则各不相同、需要由不同团队维护,策略对象让每个地区的规则自成一体,新增地区只需要新增一个实现。但如果地区只有三个、规则只有一行,策略对象让读者为了理解一条规则在多个地方跳转,单个方法的数字低了,整体的理解成本反而高了。

选择写法的依据是变化的方向,不是度量值:

情况合适的写法
少数特殊情况要先排除,主流程清晰卫语句
按一个枚举分派,每个分支是简单的值或表达式switch 表达式(编译器检查是否覆盖所有枚举值)
分派结果是数据,可能由配置给出查表
每个类别有自己的一套行为,类别会增加,由不同的人维护多态或策略对象
多个操作都按同一个类别分派多态(避免在每个操作里各写一遍 switch)

最后一行是使用多态最有力的理由:同一组 switch (region) 出现在计算运费、计算时效、选择承运商三个地方,新增一个地区就要改三处,漏改一处就是缺陷。这时候把「地区」变成一个有行为的类型,变化就集中到了一个地方。只出现一次的分支,用 switch 表达式通常就够了。

四、几个常见的误解 ​

  • else、循环、switch 不是坏味道。 它们是表达规则的基本手段。问题在于嵌套太深、同一个分派重复出现在多处,而不是语法本身。
  • 提前返回不等于更简单。 它降低的是嵌套,不是判定点。一个有十个提前返回的方法仍然有十个判定。
  • 度量阈值是提示,不是验收标准。 超过阈值的方法值得看一眼,看完之后可能决定保持原样:一个状态转换表本来就有很多分支,硬拆开只会让规则散落在各处。
  • 不同工具的数字不能比。 PMD、SonarQube、IDE 插件对三元运算、&& 序列、switch 分支、lambda 的计数规则各不相同,同一段代码在不同工具里得到不同的值。团队要固定一个工具和版本,只比较同一工具下的变化。

五、检查清单 ​

  • 改写控制流前,先用覆盖边界的输入矩阵证明新旧写法等价。
  • 看度量时分清它数的是什么:嵌套、判定点,还是路径组合。
  • 卫语句用来排除特殊情况、降低嵌套,不指望它降低圈复杂度。
  • 按枚举分派优先用 switch 表达式,让编译器检查覆盖。
  • 同一个分派出现在多个操作里时,考虑多态;只出现一次时不必。
  • 策略、查表让单个方法的数字变低,要同时看规则散落到了几处。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权