这些年写了很多的代码、也读过很多的人写的代码,这几年,写代码的机会越来越少,但是每次写代码,感觉需要思考的东西越来越多,好的代码确实难能可贵,在国内业界中,好的软件不少,但是好的代码确实有点凤毛麟角了,写得出来的人不多,有追求的也不多,看到的好的代码越来越少。
可能是因为每个人对于好的评判标准不一,程序员中,也不乏文人相轻的较劲,总觉得比人写的代码都不够好,我不想介入这些无谓的争论,这篇文章中,我将结合我的编码经验,探讨一下,如何写出设计优良的代码,希望作为大家的参考。
好的代码首先是逻辑正确的
如何用编程语言表述正确的代码逻辑,这个问题好像很少有人单独拎出来讲,因为这个问题的答案很简单,简单得你都懒得去思考它,因为你肯定觉得,用编程语言正确的表述代码逻辑无非就是if 、while 之类的东西,有什么好探讨的,其实我要分享的并不是这些关键词的本身在逻辑中表达的含义,而是这些关键词的背后,编写程序的过程中,是否真的认真思考过背后的逻辑。
我曾不止遇到过很多有年编程经验的程序员,犯下类似的错误,也见过很多年轻的同学,反复强调纠正后,逻辑上还是会漏洞百出,这几年,我会经常组织我组里面的同学对代码进行走读,总结这些编码中的逻辑错误,很大一部分也是因为编程逻辑背后的思考是不够的。所以我要讲的,是很简单的知识,但是往往是最容易忽略的思考点。
我先给大家看一个例子:
if(userInfo != null){
//给用户派发一个100单位的优惠券
couponing(userInfo,100);
}
这段代码为的目的是判断userInfo不为空串的时候couponing,看起来这段代码非常简单,判断上似乎还算比较严谨,其实这段代码只是看到了眼前要做的事情,但是并没有看到整体逻辑,为什么这么说呢,请看下面几行代码,也许会引发最这个简单问题新的思考。
if(userInfo == null){
//思考尝试恢复不存在的userInfo情况
userInfo = fetchUserInfo(userId);
}
if(userInfo != null){
couponing(userId);
}else{
//思考userInfo不存在的情况下,是否是符合整体的业务逻辑,如果整体上不符合业务逻辑,应该立刻异常终端程序。
throw new RuntimeException("userInfo not exist.");
}
这段代码虽说相比之前的代码长了一些,但是反映出来的是逻辑思考的严谨性,从这两个例子比较我们可以很明显的感觉到,第一段代码的问题,我们看到的只是为了保护是否能做couponing的条件,但是并没有去思考,条件不满足的时候,如何去做,是否有能力去恢复这个错误,确实无法恢复的时候,我们是否还要在错误的道路上越错越远呢,这一点非常重要,也很容易忽略,需要在编码的过程中,进行完整的思考才会意识到这个问题的,如果让错误继续执行下去,直到程序运行到下一个我们不期望的点,如果下一个不期望的点,代码上也遵循这个风格,简单的判断不为null,就跳过执行,这样下去,就会有无穷的隐患,代码整体上看上去,就漏洞百出了。所以从这里要给大家一个建议:
【要有一颗勇敢的心,程序不要害怕抛出错误,越害怕,错误越多】
我们应该都知道,错误越是早发现越好处理,其实程序在执行过程中也是一样的,越早发现错误,执行中就越容易处理。我一般称这种代码为代码的盲目容错,看上去这行代码很健壮,不会报错,但是不报错,不能影响错误的客观存在性,错会还是会存在的,遇到错误的时候,我们应该首先想到的是恢复这个错误,对容错问题,是需要进行非常深入很全局的思考才能做的决定,盲目的容错,只会让情况变得更加不可控制。
这一小节只是拿一个小例子来说明我们需要有更多的思考,介绍到这里,我相信大家都理解这些思考的重要性了,但是最关键的问题我还没有给大家说清楚,就是如何保证我们的思考是完整的。我给大家介绍一个我朴素有效的方法,这也是我在做CodeReview中最能发现问题的方法
【千万不要忘记else的思考】
每当你要用到一个条件表达式的时候,切记要思考这个条件不成立的情况。 尽可能的不要出现只有if 没有else的情况,多组条件用 else if 连接使用,最后再加一个else去做大兜底。 其他的条件表达式类似,比如switch case 最后总有一个值得我们深思default。严谨的代码其实就提现在else上面的思考。
容易造成思考不足的条件语句
条件有两面性,思考要完整
有效降低逻辑的复杂度
上一节的例子中,肯定会有人觉得这样写代码,是不是觉得太复杂了,已经思考了这些问题,一定要用这么复杂的方式表达出来吗?这是另外一方面的问题,我们要让代码逻辑变得简单,这一节中,我尝试分享一些我如何降低代码复杂度的方法和经验。
还是用上面的例子,我尝试将代码变得更加简单,请看下面的代码,是不是感觉舒服很多。
userInfo = withDefault(userInfo,fetchUserInfo(userId));
Assert.assertNotNull(userInfo,"userInfo not exist.");
couponing(userInfo,100);
这段代码中,表达了上面所有的逻辑,而且没有引入分支,其实这里我想强调的是
【减少分支就是降低复杂度】
我一般的编码思想是,尽可能的不要用分支处理异常,也不要因为异常引入分支,分支的使用场景最好是业务逻辑所需要的,应该用分支尽可能的表达清楚业务逻辑,而尽量不要用分支去适应异常的处理。这里进一步又引入了一个被忽略的尝试。
【不要混淆分支和异常的概念】
这一点看起来很难做到,但是根据我的实际经验,我们是有办法做到的,通过优雅的定义和处理异常,是可以比较容易的明确异常和业务分支的区别的。不过在本味中,我还是希望能将减少分之的方法说清楚,关于如何优雅的处理和定义异常,本文先不做过多描述。
我想说的是,一个分支,最好是能表达一层业务的含义,用分支标示是分支的条件以及条件成立或不成立的时候,要做的动作。所以,还是基于上面的例子,我们引入一个业务条件,“当用户是VIP用户的时候,我们才能给用户发放优惠券,否则,我们不发放优惠券”,我们分支代码标示如下
userInfo = withDefault(userInfo,fetchUserInfo(userId));
Assert.assertNotNull(userInfo,"userInfo not exist.");
if(userInfo.isVip()){
couponing(userInfo,100);
} else {
return;
}
这段代码正常的表述的业务的含义,注意其中的else,这里else 进入之后是直接return的,写上这一句就是上一节中,说明的一样,保证我们的代码逻辑是完整的,这一句有很明确的语义,就是表示条件不成立的时候,我们不做,如果不写的话,其实这部分语义是丢失的或是不明确的。
上面的代码能正常满足当前的业务需求,但是业务是复杂的,比如业务上我们有了新的需求,需要对发放优惠券的规则进行调整,调整会后的规则为,增加白名单可以不是VIP也要发优惠券,或者这个用户的用户UID是以00结尾,所以这时候,我们条件代码成了下面这个样子
if(userInfo.isVip()
||inWhiteNameList(userInfo)
||StringUtil.endWith(userInfo.getUserId(),"00"){
couponing(userInfo,100);
} else {
return;
}
这段代码中,我们逻辑一下就变得复杂了,虽说我们只用了一个if else 表达式,但是这里的分支复杂度其实是2的3次方,但是我们处理的情况就是两种,一种是成立,一种是不成立,所以,我们更加关心的是成立或是不成立的情况,而不是所有条件的组合形式,通过观察,我们发现,所有的逻辑都是由“或”进行连接,根据这个特性,其实我们可以提炼出逻辑工具方法,更好的表达我们更加关系的成立或不成立的条件。我们提取一个命名为any的逻辑方法来表述刚才的逻辑,这个方法接收一个不定长的参数,值要有一个为真,则返回为真。其他场景,我们也可以自己峰值其他的逻辑方法,比如all、notAll notAny。
则代码修改为
if(any(userInfo.isVip(),inWhiteNameList(userInfo),StringUtil.endWith(userInfo.getUserId(),"00")){
couponing(userInfo,100);
} else {
return;
}
这段代码有效的减少了代码的分支数量,注意,这里仅仅是从分支数量上进行了减少,增加了一点点可读性,这样做的好处是,多数情况下,我们关注的业务分支的动作本身,而对于进入这个分组形成的的组合情况做所有讨论,所以,这样做,可以有效的降低分支的数量,减少用例的个数(写过单元测试的同学都知道,这样的逻辑要覆盖有多痛苦)。
这一节中,用了一个看上去有些鸡肋的方法去封装逻辑组合,其实,在现在日常生产中,想办法去封装逻辑表达式进行封装是非常有效果的,这里只是举了一个逻辑封装的例子,还有很多其它场景,比如从一个Map中,根据一组key逐个取值,如果取到值不为null,则放入到另外一个Map中,这里其实可以写一个putNotNull的方法来封装逻辑,这种做法非常有效。所以这一节我想给大家传递的一个思想,就是尽你最大的可能,对逻辑表达式进行封装
【减少分支数量就是减少复杂度】
代码和业务解耦
上一节的例子中,大家可以很容易看出来,不管逻辑怎么封装,代码是始终不稳定的,其实这里就引出了我们要强调的一个常识,就是能力要和业务解耦。
如何将能力和业务解耦,我对这个问题的理解是,首先我得把这个能力定义出来,这里我暂且定义为这个能力为发优惠券(其实定义一个能力是最难做的事情,深的思考,会发现这个问题难到需要重新思考人生,我这里不拉开篇幅讲了,结合这个例子,大家暂且先有一个模糊的理解,后面在慢慢讨论能力定义这个大的课题),有了这个能力定义之后,我们根据这个能力定义做一个面向能力的条件判断,代码示例如下:
if(canCouponing(userInfo)){
couponing(userInfo,100);
} else {
return;
}
从这几行代码中,可以看出,这里好像已经好了很多,我们将发优惠券的能力和判断条件canCouponing进行耦合,看上去这段代码已经稳定了,但是仔细观察后发现,canCouponing这个方法中依赖了userInfo,这个依赖貌似还是会存在很多问题,因为如果判断条件超出了userInfo的范畴,则这个地方又会变得难以解决,能力判断的要素看起来还是不可控的,为了解决这个问题,我们就要用到运行上下文或是领域模型的概念了,用一个运行时的上下文,作为数据信息载体,承载我们业务执行过程中所需要的模型数据,领域模型的发放则是我们对系统能力和业务有了足够深入理解之后,抽象出来的,能更加准确表述业务属性和行为的模型定义,在没有很好的理解和抽象之前,本节中我们还是先用运行上下文这样相对松散的概念来解决这个问题。根据这个思想,我们将代码进行修改:
if(canCouponing(runtimeContext)){
couponing(userInfo,100);
} else {
return;
}
在上面代码中,让runtimeContext中包含userInfo,通过一个更松散的对象来传递对象,交给canCouponing这个方法处理,这里也许有人会问,canCouponing这个方法内部还不是一堆逻辑,整体上还是控制不住复杂度。其实这类问题,我们将关键的业务点从硬代码中剥离出来,并且将业务逻辑集中起来进行管理的话,就可以使用规则引擎来处理了。通过规则引擎和专家系统,将这些规则交给业务人员或是运营人员统一进行管理就可以了,而我们的功能性代码可以做到非常的干净和稳定。
也许有另外的人会问,为什么couponing(userInfo,100);这行代码中没有用runtimeContext,而是直接使用的userInfo,在实际编程中,你可能真的需要用到runtimeContext,但是这里的目的是让大家理解如何让业务代码和能力解耦,关于能力本身这块如何更好的设计,这一方面的内容也有很多值得我们思考的,本文暂不做过多探讨。