背景
圈复杂度是一种代码复杂度的衡量标准。在我们的Clean Code的度量指标中很自然也少不了它的影子,通常我们会检查最大圈复杂度与平均圈复杂度。一般说来,人的记忆时长有限,当圈复杂度大于10时,就可能存在逻辑出错风险。圈复杂度也是条件复杂度,高复杂度的代码表现为条件分支多,导致代码可读性差,可测试性难,可维护性差等问题。整体的圈复杂度的确能反映代码整体是否清晰易懂,因此的确我们有必要分析与优化高复杂度的代码。
我们在实践开发中经常会遇到高圈复杂度的代码,会纠结一些单点的圈复杂度要不要修改的问题。需要对圈复杂度思辨:
- 高圈复杂度是否一定高复杂
- 高圈复杂度是否一定可读性,可维护性差
本文案例的代码并不复杂,修改圈复杂度也是老生常谈的问题。但在Clean Code的指标下,我们(包括我自己)似乎早已麻木地习惯小心谨慎,不少时间花在修改一些不太增值的代码上。虽说指标是死的,人不能死板,我们不能基于不信任的指标来管控开发可信的代码,具体问题具体分析嘛。
案例
在我们内部一个群,讨论如下c++代码怎么修改:
typedef struct TagLalelKey {
int unit;
int entity;
int mid;
std::string dynLalelKey;
int keyValue;
friend bool operator < (const TagLalelKey& k1, const TagLalelKey& k2) {
if (k1.unit < k2.unit) {
return true;
} else if (k1.unit == k2.unit) {
if (k1.entity < k2.entity) {
return true;
} else if (k1.entity == k2.entity) {
int cmp = k1.dynLalelKey.compare(k2.dynLalelKey);
if (cmp < 0) {
return true;
} else if (cmp == 0) {
return k1.keyValue < k2.keyValue;
}
}
}
return false;
}
} TagLalelKey;
直观上面的代码觉得不是好的代码,存在坏的味道:
- 嵌套层次达到4层
- if/else太多,不易理解与修改。
采用CMetrics工具分析,圈复杂度是7,总体来说复杂度还算不高。细读代码,代码逻辑是直观清晰的,似乎也没有什么大的问题。在一股可信Clean Code整改之风下,大家习惯对这种代码说不,应该函数级微重构。
在《重构,改善既有代码的设计》一书中,针对结构化编程降低圈复杂度有9种得构技巧:
- Composing Methods(重新组织函数)
- Extract Method(提炼函数): 将独立业务或模块代码独立出来,封装为函数,通过函数名诠释代码作用,做到见名知意
- Substitue Algorithm(替换算法): 复杂算法会导致bug可能性的增加及可理解性/可维护性的降低,如果函数对性能要求不高,提倡使用简单明了的算法
- Simplifying Conditioanl Expressions(简化条件表达式)
- Decompose Condational(分解条件式): 复杂的条件表达式,使用函数进行封装
- Consolidate Condational Expression(合并条件式): 将一系列得到相同结果的条件表达式合并,可以的话封装为函数
- Consolidate Duplicate Conditional Fragment(合并重复的条件片断): 不同条件的分支,有相同的处理,可以提炼出分支以外,或者封装为函数
- Remove Control Flag(移除控制标记): 使用控制标签作为条件的,使用break 和 return取代
- Making Method Calls Simpler(简化函数调用)
- Separate Query from Modifier(将查询函数和修改函数分离): 单一职责原则,强调函数的复用性而不是多用性
- Parameterize Method(令函数携带参数): 使用带参函数,强调函数的复用性
- Replace Parameter with Explicit Methods(以明确函数取代参数): 强调函数的功能的明确性
上面具体的应用代码示例,请参见控制圈复杂度的9种重构技术。
优化方法1
案例中的代码显然是条件分支嵌套多,优化思路是要简化条件表达式。但分解
或合并
条件式似乎不能解决问题,尝试先采用逆向表达式。
bool cmp1(const TagLalelKey& k1, const TagLalelKey& k2) {
if (k1.unit < k2.unit) {
return true;
}
if (k1.unit > k2.unit) {
return false;
}
if (k1.entity < k2.entity) {
return true;
}
if (k1.entity > k2.entity) {
return false;
}
int dynLableKeyCmp = k1.dynLalelKey.compare(k2.dynLalelKey);
if (dynLableKeyCmp < 0) {
return true;
}
if (dynLableKeyCmp > 0) {
return false;
}
return k1.keyValue < k2.keyValue;
}
乍一看似乎解决了问题,事实上,条件分支个数并没有变化,因而圈复杂度也没有降低(CMetrics统计还是7),好处是减少了嵌套层次。这种代码是否具体更好的可读性,可维护性,大家可能见仁见智。我在平时Review代码发现,大家正向条件判断写法还是占多数,可能觉得逆向判断会打断顺序思维的流畅性。
优化方法2
按重构一书的套路,那再想到重新组织函数
:
- 提炼函数,若把不同的值比较放在不同的函数中,这个优化有什么意义呢?
- 替换算法,原方法逻辑上,是结构体的不同字段需要先后比较
提到需要先后比较,可以采用队列,数组之类,再次修改代码如下:
bool cmp2(const TagLalelKey& k1, const TagLalelKey& k2) {
int cmpVals[4] = {
k1.unit - k2.unit,
k1.entity - k2.entity,
k1.dynLalelKey.compare(k2.dynLalelKey),
k1.keyValue - k2.keyValue
};
for (int value : cmpVals) {
if (value > 0) { return true; }
if (value < 0) { return false; }
}
return false;
}
上述的代码引入了数组与循环,减少总的if/else分支,圈复杂度下降到4。优点是当后续增加其它字段需要比较时,它具有更好的扩展性,代码结构层次不会随之进一步恶化。但这样修改的代价也比较明显,所有的比较值都提前计算了,增加性能的损耗。
优化方法3
既然方法2的代价是比较值提前计算了,那能不能改成延迟计算,再次修改代码如下:
using cmp_func_t = std::function<int()>;
bool cmp3(const TagLalelKey& k1, const TagLalelKey& k2) {
auto cmpFuncs = {
cmp_func_t([&]()-> int { return k1.unit - k2.unit; }),
cmp_func_t([&]()-> int { return k1.entity - k2.entity; }),
cmp_func_t([&]()-> int { return k1.dynLalelKey.compare(k2.dynLalelKey); }),
cmp_func_t([&]()-> int { return k1.keyValue - k2.keyValue; }),
};
for (auto&& func : cmpFuncs) {
auto value = func();
if (value > 0) { return true; }
if (value < 0) { return false; }
}
return false;
}
上述代码引入了c++11的新特性lambda与initialization list,利用lambda函数调用从而达到延迟计算。不过也随之c++的复杂带来了代码理解上复杂性:
- lambda不能直接auto推导,需要转为std::function
- lambda数组循环,采用右值变量,减少std::function的copy
原本是一个简单的问题,却用上C++的新特性来解决,除了有点炫技之谦之外,对增加可读性,可维护性没有任何裨益,不建议这种改法。
小结
我尝试上述几种代码修改方式,却都没有达到我期望的目的,反而代码似乎越来越南辕北辙了,我的初心是代码更简洁呀!丝毫没有重构代码的喜悦。
另一个案例
再来看一个案例,下面两段代码摘抄 详解圈复杂度。
代码片段1:
string getWeight(int i) {
if (i <= 0) {
return "no weight";
}
if (i < 10) {
return "light";
}
if (i < 20) {
return "medium";
}
if (i < 30) {
return "heavy";
}
if (i < 40) {
return "very heavy";
}
return "super heavy"
}
代码片段2:
int sumOfNonPrimes(int limit) {
bool bAdd = false;
int sum = 0;
for (int i = 0; i < limit; ++i) {
if (i <= 2) {
continue;
}
for (int j = 2; j < i; ++j) {
if (i % j == 0) {
bAdd = false;
break;
}
bAdd = true;
}
if (bAdd) {
sum += i;
}
}
return sum;
}
代码1与代码2的圈复杂度都是6。显然我们觉得代码1从可读性、可维护性都优于代码2。所以不能只从圈复杂度指标来看,是否需要重构还得具体问题具体分析。
结语
结合两个案例来看,直观存在坏味道的代码,修改起来并不是那么轻松,尤其我们可能为了降低指标把代码修改得可读性更差。代码圈复杂度一样高,并不一定代表代码可读性差。我们不能机械地认指标、降指标,应该有自己的判断力,对于拿不准的代码,可拿出来探讨,真理需要show me the code。