代码的复杂度评价

435 阅读5分钟

引言

美国童子军有一条简单的军规:让营地比你来时更干净 --《代码整洁之道》

复杂度

阅读函数时,会遇到形形色色的函数。有些函数逻辑清奇,有些函数风格独特,让人难以揣摩其真实意图;有些函数结构整洁、清晰明了,让人一眼就能理解其含义。那么,该如何评价一个函数的复杂程度呢?

复杂度是一种相对客观方式评价一个函数的复杂程度和理解成本,下面介绍两个常用的复杂度评价指标

image

圈复杂度(Cyclomatic complexity,V(G))

合理的预防错误所需测试的最少路径条数,即完全覆盖一个方法所需的最少测试用例数。

圈复杂度(Cyclomatic complexity)也称为条件复杂度或循环复杂度,是一种软件度量,是由Thomas J. McCabe在1976年提出,用来表示程序的复杂度,其符号为VG或是M。圈复杂度是用来衡量一个模块判定结构的复杂程度,数量上表现为独立路径的条数。

控制流图(Control Flow Graph, CFG)是一个过程或程序的抽象表现,代表了一个程序执行过程中会遍历到的所有路径。流图中有3个概念:节点、边、区域。流图中的圆称为节点,一个节点代表一条或多条语句;

流图中的箭头线称为边,代表控制流;由边和节点围成的部分称为区域。

image

  1. 通过流图中点边进行计算: V(G) = E−N+2

其中E是流图中边的条数,N是流图中节点数。所以使用这种方式计算,上图的V(G) =11 - 9 +2 = 4

  1. 通过流图中判定节点计算: V(G) = P+1

其中P是流图中判定节点的数目。在流图中当一个节点分出两条或多条边指向其他节点时,这个节点就是一个判定节点。上面流图中p为3 所以上图的 V(G) = 3+1 = 4

  1. 通过流图中区域计算: V(G) = R

其中R是区域数。计算区域数时不仅包括由边和节点围起来的区域,也包括图外部未被围起来的那个区域。上面流图中R为4 所以上图的V(G) = 4

  1. 通过条件表达式计算:

上面的常规计算方法需要画出控制流图有些麻烦,我们可以粗暴一点根据判定条件节点计算法粗略估计,其实也就是上图中的第二个计算方式。

一般圈复杂度中的判定节点是指下面这些条件语句:

条件语句: if语句、while语句(包含do...while...语句)、for语句(包含foreach语句)、switch case语句。

条件表达式(二元或多元):&& 表达式、|| 表达式、三元运算符。

//函数1:计算所有质数之和
func sumOfPrimes(max int) int { //+1
   total := 0
   for i := 1; i < max; i++ { //+1(圈复杂度2)
       isPrime := true
       for j := 2; j < i; j++ { //+1(圈复杂度3)
           if i%j == 0 { //+1(圈复杂度4)
               isPrime = false
               break
           }
       }
       if isPrime { //+1(圈复杂度5)
           total += i
       }
   }
   return total
}

函数2:简单的转化
func change(a int) string {//+1
   switch a {
   case 1: //+1(圈复杂度2)
       return "a"
   case 2: //+1(圈复杂度3)
       return "b"
   case 3: //+1(圈复杂度4)
       return "c"
   case 4: //+1(圈复杂度5)
       return "d"
   }
   return ""
}

认知复杂度(Cognitive Complexity, Cogc)

圈复杂度用来描述一段代码“可测性”很好(可测性这里指需要构建完善的覆盖全面的单元测试需要付出多少代价),但它的设计模型很难得出一个很好的“可读性&可维护性”的测量结果

圈复杂度长期以来一直是衡量方法控制流复杂度的事实标准。它最初的目的是“识别难以测试或维护的软件模块”[1],但虽然它准确计算了完全覆盖一个方法所需的最少测试用例数,但它并不是一个令人满意的可理解性度量。这是因为具有相同圈复杂度的方法不一定给维护者带来相同的难度,导致测量通过高估某些结构而低估其他结构的“假象”。

//函数1:计算所有质数之和
func sumOfPrimes(max int) int { //+1
   total := 0
   for i := 1; i < max; i++ { //+1(圈复杂度2)
       isPrime := true
       for j := 2; j < i; j++ { //+1(圈复杂度3)
           if i%j == 0 { //+1(圈复杂度4)
               isPrime = false
               break
           }
       }
       if isPrime { //+1(圈复杂度5)
           total += i
       }
   }
   return total
}

函数2:简单的转化
func change(a int) string {//+1
   switch a {
   case 1: //+1(圈复杂度2)
       return "a"
   case 2: //+1(圈复杂度3)
       return "b"
   case 3: //+1(圈复杂度4)
       return "c"
   case 4: //+1(圈复杂度5)
       return "d"
   }
   return ""
}

上面的两个函数的圈复杂度均为4,但是明显[函数2]要比[函数1]更容易理解。因此提出了认知复杂度来反映函数的理解成本,认知复杂度分数根据三个基本规则进行评估:

  1. 忽略【允许将多个语句易于理解地简写成一个】的情况。

在认知复杂度的制定想法中,一个指导性的原则是:激励使用者写出好的编码规范。也就是说,需要无视或低估让代码更可读的feature(不计算进复杂度)。“方法”本身就是一个朴素的例子,把一段代码拆的把几句抽离成一个方法,用一句方法调用代替掉,“简写”它,认知复杂度不会因为这这一次方法调用增加。

鼓励将大的函数拆分成多个小、功能单一的函数,此操作不会带来额外的认知复杂度。

  1. 在代码线性流程中的每一次中断都+1复杂度。

结构控制会打断一条线性的流从头到尾走完,使代码的维护者需要花更大功夫来理解代码。常见的结构控制语句如下:

  • if, else if, else, 三元运算符

  • switch

  • for, foreach

  • while, do while

  • catch

  • 嵌套方法和类似方法的结构,例如lambda(nested methods and method-like structures such as lambdas)

和圈复杂度不同之处在于,结构控制每次打断一次线性流程,复杂度只固定加1。例如:

func change(a int) string {//+1
    switch a {
    case 1: //+1(圈复杂度2)
        return "a"
    case 2: //+1(圈复杂度3)
        return "b"
    case 3: //+1(圈复杂度4)
        return "c"
    case 4: //+1(圈复杂度5)
        return "d"
    }
    return ""
}

func change(a int) string {
    switch a { //+1 认知复杂度1
    case 1: 
        return "a"
    case 2: 
        return "b"
    case 3: 
        return "c"
    case 4: 
        return "d"
    }
    return ""
}
  1. 控制结构嵌套时叠加增加复杂度

五个 if 和 for 结构的线性系列比连续嵌套的五个相同的结构更容易理解,无论通过每个系列的执行路径的数量如何。 由于这种嵌套增加了理解代码的心理需求,因此认知复杂度评估了它的嵌套增量。

下面控制结构可增加控制结构嵌套级别,下述控制结构中再叠加控制结构时,需要逐次累计嵌套增量

  • if, else if, else, 三元运算符

  • switch

  • for, foreach

  • while, do while

  • catch

  • 嵌套方法和类似方法的结构,例如lambda(nested methods and method-like structures such as lambdas)

func T1(a int64) bool {
    if a > 10 { //+1
        if a < 30 { //+2(1+嵌套增量1)
            if a%2 != 0 { //+3(1+嵌套增量2)
                return true
            }
        }
    }
    return false
}


func T2(a int64) bool {
    if a > 10 { //+1
        return true
    }
    if a%2 != 0 { // +1
        return true
    }
    if a%5 != 0 { //+1
        return true
    }
    return false
}


func T3(a int64) bool {
    if a > 10 { //+1
        if a > 20 && a < 30 { //+2 +1 //嵌套if整体+2。该if中又出现a<30新的控制结构+1
            return true
        }
    }
    return false
}

func T4(datas []int64) []int64 {
    res := []int64{}
    for _, d := range datas { //+1
        if d > 10 { //+2
            res = append(res, d)
        } else { //+1
            res = append(res, d*2)
        }
    }
    return res
}

最后用认知复杂度再评价一下开头的两个函数:

func sumOfPrimes(max int) int {
    total := 0
    for i := 1; i < max; i++ { //+1
        isPrime := true
        for j := 2; j < i; j++ { //+2
            if i%j == 0 { //+3
                isPrime = false
                break
            }
        }
        if isPrime { //+2
            total += i
        }
    }
    return total
}

func change(a int) string {
    switch a { //+1
    case 1: 
        return "a"
    case 2:
        return "b"
    case 3: 
        return "c"
    case 4: 
        return "d"
    }
    return ""
}

go语言复杂度检测工具

github.com/alecthomas/…

github.com/uudashr/goc…