研究思考丨关于软件复杂度的困局

2023-04-18 11:04:00来源:阿里开发者

本文重点围绕软件复杂度进行剖析,希望能够帮助读者对软件复杂度成因和度量方式有所了解。


【资料图】

作者 | 王洋(古训)

来源 | 阿里开发者公众号

前言

大型系统的本质问题是复杂性问题。互联网软件,是典型的大型系统,如下图所示,数百个甚至更多的微服务相互调用/依赖,组成一个组件数量大、行为复杂、时刻在变动(发布、配置变更)当中的动态的、复杂的系统。而且,软件工程师们常常自嘲,“when things work, nobody knows why”。

本文将重点围绕软件复杂度进行剖析,希望能够帮助读者对软件复杂度成因和度量方式有所了解,同时,结合自身的实践经验谈谈我们在实际的开发工作中如何尽力避免软件复杂性问题。

导致软件复杂度的原因

导致软件复杂度的原因是多种多样的。

宏观层面讲,软件复杂是伴随着需求的不断迭代日积月累的必然产物,主要原因可能是:

1.对代码腐化的退让与一直退让。

2.缺乏完善的代码质量保障机制。如严格的 CodeReview、功能评审等等。

3.缺乏知识传递的机制。如无有效的设计文档等作为知识传递。

4.需求的复杂性导致系统的复杂度不断叠加。比如:业务要求今天 A 这类用户权益一个图标展示为✳️,过了一段时间,从 A 中切分了一部分客户要展示。

对于前三点我觉得可以通过日常的工程师文化建设来尽量避免,但是随着业务的不断演化以及人员的流动、知识传递的缺失,长期的叠加之下必然会使得系统越发的复杂。此时,我觉得还需要进行系统的重构。

从软件开发微观层面讲,导致软件复杂的原因概括起来主要是两个:依赖(dependencies) 和隐晦(obscurity)。

依赖会使得修改过程牵一发而动全身,当你修改模块一的时候,也会牵扯到模块二、模块三等等的修改,进而容易导致系统 bug。而隐晦会让系统难于维护和理解,甚至于在出现问题时难于定位问题的根因,要花费大量的时间在理解和阅读历史代码上面。

软件的复杂性往往伴随着如下几种表现形式:

修改扩散

修改时有连锁反应,通常是因为模块之间耦合过重,相互依赖太多导致的。比如,在我们认证系统中曾经有一个判断权益的接口,在系统中被引用的到处都是,这种情况会导致一个严重问题,今年这个接口正好面临升级,如果当时没有抽取到一个适配器中去,那整个系统会有很多地方面临修改扩散的问题,而这样的变更比较抽取到适配器的修改成本是更高更风险的。

@Overridepublic boolean isAllowed(Long accountId, Long personId, String featureName) {    boolean isPrivilegeCheckedPass = privilegeCheckService.isAllowed(        accountId, personId, featureName);    return isPrivilegeCheckedPass;}

认知负担

当我们说一个模块隐晦、难以理解时,它就有过重的认知负担,开发人员需要较长的时间来理解功能模块。比如,提供一个没有注释的计算接口,传入两个整数得到一个计算结果。从函数本身我们很难判断这个接口是什么功能,所以此时就不得不去阅读内部的实现以理解其接口的功能。

int calculate(int v1, int v2);

不可知(Unknown Unknowns)

相比于前两种症状,不可知危险更大,在开发需求时,不可知的改动点往往是导致严重问题的主要原因,常常是因为一些隐晦的依赖导致的,在开发完一个需求之后感觉心里很没谱,隐约觉得自己的代码哪里有问题,但又不清楚问题在哪,只能祈祷在测试阶段能够暴露出来。

软件复杂度度量

Manny Lehman 教授在软件演进法则中首次系统性提出了软件复杂度:

软件(程序)复杂度是软件的一组特征,它由软件内部的相互关联引起。随着软件的实体(模块)的增加,软件内部的相互关联会指数式增长,直至无法被全部掌握和理解。

软件的高复杂度,会导致在修改软件时引入非主观意图的变更的概率上升,最终在做变更的时候更容易引入缺陷。在更极端的情况下,软件复杂到几乎无法修改。

在软件的演化过程中,不断涌现了诸多理论用于对软件复杂度进行度量,比如,Halstead 复杂度、圈复杂度、John Ousterhout 复杂度等等。

Halstead 复杂度

Halstead 复杂度(霍尔斯特德复杂度量测) (Maurice H. Halstead, 1977) 是软件科学提出的第一个计算机软件的分析“定律”,用以确定计算机软件开发中的一些定量规律。Halstead 复杂度根据程序中语句行的操作符和操作数的数量计算程序复杂性。针对特定的演算法,首先需计算以下的数值:

上述的运算子包括传统的运算子及保留字,运算元包括变数及常数。

依上述数值,可以计算以下的量测量:

举一个,这是一段我们当前应用中接入 AB 实验的适配代码:

try {    DiversionRequest diversionRequest = new DiversionRequest();    diversionRequest.setDiversionKey(diversionKey);    if (MapUtils.isNotEmpty(params)) {        DiversionCondition condition = new DiversionCondition();        condition.setCustomConditions(params);        diversionRequest.setCondition(condition);    }    ABResult result = xsABTestClient.ab(testKey, diversionRequest);    if (result == null || !result.getSuccess()) {        return null;    }    return result.getDiversionResult();} catch (Exception ex) {    log.error("abTest error, testKey:{}, diversionKey:{}", testKey, diversionKey, ex);    throw ex;}

我们梳理这段代码中的预算子和运算元以及分别统计出其个数:

根据统计上面统计得到的对应的数据我们进行计算:

Halstead 方法优点

1.不需要对程序进行深层次的分析,就能够预测错误率,预测维护工作量;

2.有利于项目规划,衡量所有程序的复杂度;

3.计算方法简单;

4.与所用的高级程序设计语言类型无关。

Halstead 方法的缺点

1.仅仅考虑程序数据量和程序体积,不考虑程序控制流的情况;

2.不能从根本上反映程序复杂性。给我的直观感受是他能够对软件复杂性进行度量,但是很难讲清楚每一部分代码是好还是坏。

圈复杂度

圈复杂度(Cyclomatic complexity)是一种代码复杂度的衡量标准,在 1976 年由Thomas J. McCabe, Sr. 提出。

在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系,一般来说,圈复杂度大于 10 的方法存在很大的出错风险。

计算方法:

计算公式1:V(G)=e-n+2。其中,e 表示控制流图中边的数量,n 表示控制流图中节点的数量。

计算公式2:V(G)=区域数=判定节点数+1。圈复杂度所反映的是“判定条件”的数量,所以圈复杂度实际上就是等于判定节点的数量再加上 1,也即控制流图的区域数。

计算公式3:V(G)=R。其中 R 代表平面被控制流图划分成的区域数。

举个,以前面 AB 实验的代码片段为例子,画出流程图如下,通过计算得出其圈复杂度为 4:

John Ousterhout 的复杂度定义

John Ousterhout(约翰欧斯特霍特),在他的著作《A Philosophy of Software Design》中提出,软件设计的核心在于降低复杂性。他选择从认知的负担和开发工作量的角度来定义软件的复杂性,并且给出了一个复杂度量公式:

子模块的复杂度乘以该模块对应的开发时间权重值,累加后得到系统的整体复杂度C。系统整体的复杂度并不简单等于所有子模块复杂度的累加,还要考虑开发维护该模块所花费的时间在整体时间中的占比(对应权重值)。也就是说,即使某个模块非常复杂,如果很少使用或修改,也不会对系统的整体复杂度造成大的影响。

如何避免复杂度问题

软件复杂度问题可以完全避免么?我觉得不可能,但是这并不能成为我们忽视软件复杂度的理由,有很多措施可以帮助我们尽量避免自身的需求开发或工作中引入问题代码而导致软件复杂。这里结合日常的开发理解谈一下自己的认知:

1.开发前:我们可以通过需求梳理沉淀需求分析、架构设计等文档作为知识传递的载体。

2.开发中:我们需要强化系统架构理解,战略优先于战术,系统分层架构清晰统一,开发中接口设计要做到高内聚和低耦合同时保持良好代码注释的习惯。

3.维护阶段:我们可以进行代码重构,针对之前存在设计问题的代码,以新的思维和架构实现方案进行重构使得代码越来越清晰。

战略先于战术

在战术编程中,开发者主要关注点是能够 work,比如修复一个 bug 或者增加一段兼容逻辑。乍一看,代码能够 work,功能也得到了修复,然而,战术编程已经为系统设计埋下了坏的味道,只是还没人察觉,当相同的代码交接给后人的时候,经常会听到一句“屎山一样的代码”,这就是以战术编程长期累积的结果,是短视的,缺乏宏观设计导致系统不断的引入复杂性问题以至于代码很容易变得隐晦。

成为一名优秀的软件设计师的第一步是认识到仅仅为了完成工作编写代码是不够的。为了更快地完成当前的任务而引入不必要的复杂性是不可接受的。最重要的是这个系统的长期结构。--John Ousterhout(约翰欧斯特霍特),《A Philosophy of Software Design》

目前我们所维护的系统往往都是在前人代码的基础上进行升级和扩展,日常需求开发工作中,一个重要的工作是借助需求开发的契机,推动需求所涉及到坏味道的设计能够面向未来扩展,而非仅仅着眼于完成当前的需求,这就是我理解的 战略编程

举一个,有一个消息监听的处理逻辑,根据不同的业务执行对应的业务处理,其中一部分关键代码如下,可以猜想按照战术编程的思路以后会还会有无数的else if在后面进行拼接实现,而这里完全可以通过策略模式的方式进行简单的重构,使得后续业务接入时更加清晰和简单。

public void receiveMessage(Message message, MessageStatus status) {    // .....    if(StringUtils.equals(authType, OnetouchChangeTypeParam.IC_INFO_CHANGE.getType())                  || StringUtils.equals(authType, OnetouchChangeTypeParam.SUB_COMPANY_CHANGE.getType())){             if(StringUtils.equals("success", authStatus)){                 oneTouchDomainContext.getOneTouchDomain().getOnetouchEnableChangeDomainService().notifySuccess(userId.toString(), authRequestId);             }         } else if(StringUtils.equals(authType,AUTH_TYPE_INTL_CGS_ONSITE)){             // XXXXXX         } else if(StringUtils.equals(authType,AUTH_TYPE_INTL_CGS_ONSITE_CHANGE)) {             // XXXXXX         } else if (AUTH_TYPE_VIDEO_SHOOTING.equals(authType)) {             if (AUTH_STATUS_SUCCESS.equals(authStatus)) {                 // XXXXXX             } else if (AUTH_STATUS_PASS.equals(authStatus)) {                 // XXXXXX             } else if (AUTH_STATUS_SUBMIT.equals(authStatus)) {                 // XXXXXX             }         }         // ..... }

短期来看战略编程的成本会高于战术编程,但是从上面的案例长期来看,这样的成本是值得的,他能够有效的降低系统的复杂度,从而长期来看最终能降低后续投入的成本。开发同学在需求迭代的过程中应该先通过战略编程的思维进行设计和思考,然后再进行战术实现,所以我的观点是战略设计要优先于战术实现。

高内聚低耦合设计

高内聚低耦合,是判断软件设计好坏的标准,主要用于程序的面向对象的设计,主要看类的内聚性是否高,耦合度是否低。目的是使程序模块的可重用性、移植性大大增强。通常程序结构中各模块的内聚程度越高,模块间的耦合程度就越低,当模块内聚高耦合低的情况下,其内部的腐化问题不容易扩散,从而带给系统本身的好处就是复杂度的降低。

内聚是从功能角度来度量模块内的联系,好的内聚模块应当做好一件事情,它描述了模块内部的功能联系;而耦合是软件结构中各模块之间相互连接的一种度量,耦合强弱取决于模块间接口的依赖程度,如调用一个模块的点以及通过接口的数据等。那么如何实现一个高内聚低耦合的接口呢?

简化接口设计

简单的接口往往意味着调用者使用更加方便,如果我们为了实现简单,提供一个复杂的接口给外部使用者,此时往往带来的是耦合度增大,内聚降低,继而当该接口出现升级等场景时会产生修改扩散的问题,进而影响面发生扩散,带来一定的隐患。

因此,在模块设计的时候,要尽量遵守 把简单留给别人,把复杂留给自己的原则

比如这样一个例子,下面两段代码实现的是同样的逻辑,方法一的设计明显要由于方法二,为什么?方法一更简单,而方法二明显违背了把简单留给别人,把复杂留给自己的原则。如果你是接口的使用者当你使用方法二的时候,你一定会遇到的两个问题:第一,需要传递哪些参数要靠问方法的提供方,或者要看方法的内部实现;第二,你需要在取到返回值后从返回值中解析自己想要的结果。这些问题无疑会让系统复杂度提升。

所以,我们要简化接口设计,把简单留给别人,把复杂留给自己,从而保证接口的高内聚和低耦合,进而降低系统的复杂度。

@Overridepublic boolean createProcess(StartProcessDto startProcessDto) {    // XXXXXXX}@Overridepublic HashMap createProcess(HashMap dataMap) {    // XXXXXXX}

隐藏实现细节

隐藏细节指的就是只给调用者暴露重要的信息,把不重要的细节隐藏起来。接口设计时,我们要通过接口告诉使用者我们需要哪些信息,同时也要通过接口告诉使用者我会给到你哪些信息,至于内部如何实现使用者不需要关心的。

剩余60%,完整内容请点击下方链接查看:

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

关键词:

上一篇:港股异动 | 中国太保(02601)涨超5%领涨内险股 寿险保费持续回暖 预计一季度NBV底部反转|每日快讯
下一篇:最后一页