蘭陵N梓記

一指流沙,程序年华


  • 首页

  • 归档

  • 关于

  • 搜索
close

飞哥讲代码27:此圈复杂度,改还是不改,是个问题

时间: 2021-08-15   |   分类: 技术     |   阅读: 2555 字 ~6分钟

背景

圈复杂度是一种代码复杂度的衡量标准。在我们的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。

#软件开发# #c++#
软件开发漫谈3:开发者测试
软件开发漫谈2:抽象设计
微信扫一扫交流

标题:飞哥讲代码27:此圈复杂度,改还是不改,是个问题
作者:兰陵子
关注:lanlingthink(览聆时刻)
声明:自由转载-非商用-非衍生-保持署名(创作共享3.0许可证)

  • 文章目录
  • 站点概览
兰陵子

兰陵子

Programmer & Architect

164 日志
4 分类
57 标签
GitHub 知乎
  • 背景
  • 案例
    • 优化方法1
    • 优化方法2
    • 优化方法3
    • 小结
  • 另一个案例
  • 结语
© 2009 - 2022 蘭陵N梓記
Powered by - Hugo v0.101.0
Theme by - NexT
0%