嵌套、卫语句、switch 与多态:复杂度到底降了没有
代码扫描报告说一个方法的圈复杂度超标。开发者把嵌套的 if 改成了提前返回,看上去清爽多了,再扫一遍,圈复杂度反而多了 1。另一个人把分支换成了策略对象,数字降到了 3,评审时大家却发现读懂这条规则要在四个文件之间跳。
复杂度度量是发现问题的好线索,但它们数的东西不一样:有的数判定点,有的数嵌套,有的数路径组合。同一个改写,在一个度量上下降,在另一个上可能上升。把度量当成目标,就会出现「为了数字而改写」。
本文把同一条运费规则写成五种形式,先确认它们结果完全一致,再用 PMD 计算三种度量。实验见文末。
一、同一条规则,五种写法
规则:会员免运费;否则按地区定基础运费(本地 6、省内 10、偏远 20),偏远地区超重另加 15,订单满 99 运费减半。
// 卫语句
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、按地区查表、每个地区一个策略对象。改写之前先证明它们等价:
48 组输入(3 个地区 × 4 个金额 × 是否会员 × 是否超重),另外四种写法与嵌套写法不一致的次数:0金额取 0、98、99、500,覆盖了「满 99」这条边界的两侧。改写控制流的第一步永远是证明等价,否则后面比较度量没有意义:一个改写如果悄悄改了行为,它的度量再好看也是错的。保行为比较的系统做法见把长方法拆开,哪些行为最容易被改掉。
二、三个度量各数什么
| 写法 | 认知复杂度 | 圈复杂度 | NPath |
|---|---|---|---|
| 层层嵌套 | 16 | 6 | 9 |
| 卫语句 | 7 | 7 | 19 |
| switch 表达式 | 5 | 7 | 9 |
| 查表 | 4 | 5 | 7 |
| 策略对象 | 2 | 3 | 3 |
圈复杂度数的是判定点的个数加一,大致等于独立路径的数量。它不关心代码怎么排布:一个 if 无论嵌套在第几层都算 1。卫语句把嵌套展开成平铺的条件,判定点一个没少,三元运算符和 && 还各算一个判定,所以从 6 变成了 7。改成提前返回通常不减少圈复杂度。
认知复杂度试图度量的是读者的理解成本。它对嵌套加罚:同一个 if,在第三层嵌套里比在顶层「贵」得多。所以嵌套写法是 16,卫语句降到 7,switch 表达式 5。从读代码的角度,这个变化和直觉是一致的:卫语句先把特殊情况排除掉,主干在左边对齐,读者不用在脑子里维护「我现在在哪个 else 里」。
NPath 数的是通过方法的所有可能路径的组合数,顺序的独立条件会相乘。卫语句写法里三个独立的 if 顺序排列,组合数到了 19,是五种写法里最高的。它提示的是测试要覆盖的组合多,不一定是读起来难。
三个数字说的是三件事,没有哪一个能单独代表「复杂」。
三、策略写法的数字为什么最低
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 表达式,让编译器检查覆盖。
- 同一个分派出现在多个操作里时,考虑多态;只出现一次时不必。
- 策略、查表让单个方法的数字变低,要同时看规则散落到了几处。
配套实验
- codesphere-labs/refactoring/control-flow-complexity:同一条运费规则的五种写法(JDK 25.0.4.1),48 组输入下的等价检查,以及 PMD 7.28.0 报出的认知复杂度、圈复杂度与 NPath(验证记录)
参考资料