“所有的函数中最多使用3个连续的缩进符号。”这句话可以作为一个接下来挺多文章的重要思路。你大可不看接下来的一切论述,靠着自己的直觉去追逐这句话想要达到的目标,你的代码从行文风格一定会变得比原来更加简单易懂。当然,过度迷信这句话可能让你的代码变得啰嗦而细碎,如何恪守3个缩进准则又避免这种副作用在未来会结合着设计模式和抽象去讨论。
近来在代码走查中被对着一段段的散发着不祥气息的代码搞得不胜其烦,挨个去给每个人解释清楚一些事情好像没有那么多能量,所以有了这篇文章。主要讲的是如何去写自解释强、维护方便的业务代码。没有太多高深的技术探讨,仅仅是一些实践经验的总结。如果你还在写代码的具体实现,希望自己的代码不论对别人还是对自己都能轻松的读懂、简单的维护、方便的拓展,这篇文章的思考应该对你有所帮助。
顺便碎碎念一下,至多3个缩进这种称不上激进的自我规则约束是一种极少数的“绝对的善”。生活中的种种居多是善和善的抉择、恶和恶的交锋,但是坚持至多3个缩进却是向代码腐败发起的善的冲锋,是那种极少数当成实践信条的东西。所以,希望在看完我的实践经验后,你可以也享受这种极端的限制带来的自由。
去追求更少的缩进展开并不是目的,我们真正想要的事情其实已经说过了——自解释强、维护方便的业务代码。但是去追求3缩进确实可以将很多代码质量相关的技巧杂糅进去,算是修行时候的某种引路窍门吧。
为什么是3次缩进
如果我们完全不限制缩进数目,那么可能得到了金字塔形代码。也就是不断缩进不断深入,最后统一的大段括号退出。或许你在实践中就见过这种代码。
这样的代码格式有什么缺点吗?那当然有,这种代码对小屏幕很不友好,逼着队友氪金买高分辨率屏幕,然后让这个项目组开启双屏内卷。还有就是破坏了缩进的暗示性——如果在一个for或者if后的缩进意味着重点讨论这个for或者if的职责,如果两段代码拥有相同的缩进距离,那么这两段应该是在处理同一级的事情,是处理一件事情时候的多个阶段的并列。另外过多的缩进必然伴随着各种复杂的逻辑判断,会让圈复杂度直线上升,会过度消耗我们阅读代码的精力,甚至懒得去弄懂这一大段东西到底在说什么,用起来权当是个黑盒子。还有更过分的情况——有的时候在一个循环中可能其实在同时进行了两件事,这两件事可能有一定关联,最后为了所谓的性能硬是在同一个循环里面处理完了,结果这些随着业务的推进,两件事在各自变更的同时又不得不迁就另一边,最后代码变得在一个循环里面不断的相互纠缠,然后又演化出了一堆零零碎碎的逻辑带来更多的缩进,最终难以调试。可见过多的缩进一定是通俗易懂代码的敌人。
那么为什么是3次缩进呢?因为3次是某种刚刚够用的甜区。写业务代码的时候一定是面向过程的,不论外界如何说设计的时候面向对象、面向函数、面向资源,落回最具体的业务代码的具体实现的时候,我们都回到了写代码的第一课——面向过程。除去邪恶的goto,面向过程的代码的结构有三种——顺序、选择、循环。其中顺序代码最普通,不会增加缩进;选择和循环退出前均会增加一次缩进。限制在三次缩进之内意味着这个函数中最多推了3次关系,可能是循环+选择+进而二级循环,也可能是选择+循环+更具体选择返回。超出的3次缩进部分,就应该改成另一个函数。所以阅读这种至多3次缩进的代码绝少有很多复杂麻烦烧脑的嵌套,代码圈复杂度十分友好,同时也限制了一个方法的职权极度的扩张。再配合上比较优秀的函数命名和精准的抽象,最终如果不想了解细节,完全停留在表层其实就能看大部分的意思了。
坚持最多能用3次缩进,其本质就是在限制我们单个函数的职能权利,去更多的将过程变成并列平行的流程过程,并且强迫自己多写短小精准的函数,增加代码的自描述特性,减少上帝函数。代码短小、分支少的函数的优点不言而喻,而接下来就要对实践3个缩进进行一些可操作的建议了。
极简主义门面
对于编程,所谓门面就是一个类中暴露的public方法,以及可以衍生认为是业务代码核心实现的部分,我们要尽可能的去保持门面的简洁清晰,之后维护时候的体验就能好很多。
很多时候一个复杂的门面就能劝退大多数阅读者——只要不是业务必须要在原有的基础上实现,可能你真的没兴趣去改一个超过1000行的业务函数,我更想去绕过它、修饰它、代理它、拓展它、绞杀它而不是钻研它,人生苦短,没去的地方、没吃的食物、没玩的游戏、没聊的朋友、没看的新番都那么多,真的没必要和几千行代码过不去。但是你的工作可能真的不会给你那么多骚操作空间,最后你也只能在伴随着对留下屎山人的恶毒咒骂,去选择正面击破屎山(关键这个被骂人还很有可能就是你自己)。
所以,为了避免这种自己看了都会头疼的大段代码,我们最好能在门面中用高度概括性的语言讲清楚你的这个函数的目标是做什么的。我们在实践中看如何写出一个合理的门面。
比如我们来谈谈这个假设的需求——
这是一个优惠卷的具体实施计算部分,输入商品列表和优惠券列表,一次消费可以使用多张优惠券。优惠券有对商品总金额满减券,特定数量商品的打折券,还有打折结算后总金额的打折券等等,这个优惠券最好做成方便后期数据库配置的。现在假设所有的输入优惠券都是合法的,且没有相互不冲突的,但是有可能不生效,计算一下最后的金额。
仅仅做简单的描述
排除掉一系列的边界的限制,我们可以用最概括性的自然语言去描述代码实现:优惠计算分为两个阶段(结算前商品优惠阶段和结算后总价优惠阶段),每个阶段筛选并使用对应的优惠卷,计算出每个优惠价格然后对最后金额做出对应的金额减少,最后再给出结算的价格就好了。
我们把这句话翻译成代码。
public BigDecimal checkWithDiscount(List<Goods> goodsList, List<Coupon> couponList) {
BigDecimal result = sumGoodsListPrice(goodsList);
// 结算前商品优惠
List<Coupon> beforeCheckCouponList = filterBeforeCheckConpon(couponList);
for (Coupon coupon : beforeCheckCouponList) {
BigDecimal goodsDiscount = makeDiscount(coupon, goodsList);
result = result.subtract(goodsDiscount);
}
// 结算后总价优惠
List<Coupon> afterCheckCouponList = filterAfterCheckConpon(couponList);
for (Coupon coupon : afterCheckCouponList) {
BigDecimal sumPriceDiscount = makeDiscount(coupon, result);
result = result.subtract(sumPriceDiscount);
}
return result;
}
这差不多就是一个门面的最重要职责了——门面的代码应该是尽可能的能够直接转换成自然语言。说白了,就是当介绍这段代码做了什么事情时候说出的话,就是写在public里面的代码。所以当你的去概括这个函数进行的大致操作变得简单明了之后,你的门面也会变得简单明了之后,这样简单明了的代码,3个缩进一定可以将业务主体概括出来的。如果超过了3个缩进,就不能称得上是简单了,尽快进行子函数的拆分吧。甚至因为代码阶段感股够好你还能将函数的拿日志打印出来,当成另类注解,同时也方便排查就像下面。
// 某个阶段开始了
log.debug("进行结算前商品优惠");
List<Coupon> beforeCheckCouponList = filterBeforeCheckCoupon(couponList);
for (Coupon coupon : beforeCheckCouponList) {
BigDecimal goodsDiscount = makeDiscount(coupon, goodsList);
result = result.subtract(goodsDiscount);
}
log.info("商品优惠计算完毕,使用了{}张优惠券,优惠后金额:{}", .size, result);
// 你能推理出beforeCheckCouponList是商品阶段优惠券,result是优惠后的金额。
不过对门面进行概括性抽象的时候我们需要注意——我们提前设计了函数,进行了高度抽象的概括,方便了理解和实现,但输入的信息量也在这个时候丢失了。比如这个过程中,在计算实际折扣的时候,你的单张优惠卷无法和所有的优惠卷信息关联上了。可能在目前的实现中你没有感觉什么不妥,但是如果有需求需要单张优惠券和整体优惠券列表的联系,就不得不在函数makeDiscount上额外添加一个入参。对门面中方法的入参的修改一定要警惕,可能这是入参数量爆炸的源头,这也是你概括的阶段存在问题的警示牌。或许是时候和业务人员画大饼聊聊未来的变更方向,然后根据聊天结果对抽象程度进行调整了。
不合理的抽象会导致未来变更的难受,那么在门面上合理的抽象程度应该用什么决定呢?阶段感是一个重要的标准,这也是和业务侃大山时候的一个依据。这个例子中我们将一串有点零碎的需求变成了有序的两个阶段,未来有变更的时候可以了解明白是在哪个具体阶段作出的变更。而且如果业务代码已经大体成型并投产了,很少有业务会去打破现有的阶段去另立门户,他也知道整理的复杂性和成本,情况好的话有的业务还会帮忙分析应该在什么阶段中进行哪些具体处理,这样变相的减轻了点工作负担,免得出现太多的天马行空和难于控制。我们用一个简单门面简化我们自身维护的思考,同时倒逼变更要按照我们的规范行使。很多时候的天马行空的需求总是因为缺少了确定的阶段感和边界感导致的(漂亮话罢了,配合的业务也要有点逻辑感才行,好多时候人根本就是走一步看一步的。不过阶段感好的代码确实在内部交接是有用的)。
当然,很多时候我们接手的代码已经就是一个屎山了。如果你选择了去挑战屎山,就要我们自己慢慢去发现代码中隐约的阶段感了。发现了这种感觉之后在其上逐渐构建出一个简单的门面,这个门面最终重构完成的状态当然也要去满足3次缩进的原则。如何去将一个屎山慢慢消化是很有趣的重构挑战,这件事我们未来有机会再谈,不过总体的原则还是不建议你去重构屎山,居多情况是在自我感动和出力不讨好。现在我们权当做的事最新的业务需要去完成,重构的话题放在日后讲。
总之,一个阶段感明显、语言简单明了、最多3个缩进的public代码就是不错的门面。但上面的这段代码缺少一些必要的防御,我们添加上。
提前返回
作为门面,防御应该是必须的,你永远不知道谁会突然调用你,对异常情况一定要做出防御。这里不卖关子,对于防御应该尽早进行返回而非接着深入缩进再去写,也就是这样的。
public BigDecimal checkWithDiscount(List<Goods> goodsList, List<Coupon> couponList) {
if (CollectionUtils.isEmpty(goodsList)) {
return BigDecimal.ZERO;
}
......
其实没什么特别的,最简单的说只是为了减少一次缩进,满足主题需求。但是好处不仅如此,提前返回可以减少阅读时候思考的压力,对这个分支的处理情况不用会占用大脑有限的空间了,不至于看了一堆业务逻辑代码,到最后突然蹦出来一个return 0吓自己一跳,然后向上求索才知道是最开始做的防御。
不过我们还是可以掰扯点东西的——这也算是一种缩进的暗示性,一个缩进下的代码应该是同一个处理层级。如果进行了一次缩进,就意味着这个缩进的内容主要讨论的是导致缩进(for if 或者单纯的代码块)的种种行为最关注的处理方案。提前返回后逻辑现在还位于函数内的第一个缩进上,也就是说下面的种种业务代码是这个函数最关心的东西。如果因为if进行了再一级缩进,从这种意义上讲感觉是在暗中强调if的重要性,业务代码是再if条件之下的存在,这样的主次关系颠倒了。
同样的提前返回的技巧也能用在for循环中——我们的业务代码应该就是for循环关注的对象,所以在for循环内的一个缩进所及的地方。for循环中的特例应该被continue掉。比如这样:
for (Object obj : list) {
if (obj == null || obj.equals(exceptOne) ) {
// 这个缩进是if最关心的事情
continue;
}
// 进行你的操作
// 这个缩进是for循环最关心的事情
}
副作用暴露
我们添加一个需求——因为有的优惠券可能没有使用到,现在将没能使用的优惠卷进行汇总,发给数据仓库。
对于这个事情,其实十分简单,创建一个list,然后往list里面塞优惠券,最后把优惠券发送给外系统。但其实存在两个实现思路。
// 实现思路1
List<Coupon> beforeCheckCouponList = filterBeforeCheckConpon(couponList);
List<Coupon> notUseCouponList = new LinkedList<>();
for (Coupon coupon : beforeCheckCouponList) {
// 在折扣计算函数里面直接添加添加未使用信息
BigDecimal goodsDiscount = makeDiscount(coupon, goodsList, notUseCouponList);
result = result.subtract(goodsDiscount);
}
// 实现思路2
List<Coupon> beforeCheckCouponList = filterBeforeCheckConpon(couponList);
List<Coupon> notUseCouponList = new LinkedList<>();
for (Coupon coupon : beforeCheckCouponList) {
BigDecimal goodsDiscount = makeDiscount(coupon, goodsList);
// 在外部判断是否生效
if (BigDecimal.ZERO.equals(goodsDiscount)) {
notUseCouponList.add(coupon);
}
result = result.subtract(goodsDiscount);
}
虽然前者更加简洁同时也少一次缩进,但是我还是支持后者,因为它副作用避免了,也避免了功能的杂糅(当然,实现还是太稚嫩了,日后再说怎么调整)。
什么是副作用呢?副作用就是超过了描述能力的状态修改行为。明明为了计算一个优惠券的折扣,却向list中塞了一个值,这就是副作用。副作用的恐怖在于很多时候是不知道究竟在什么地方进行了修改,最后只能挨个排查。副作用可以更加宽泛的被认为是对本地变量、集合内容变动的行为,对外系统资源的添加、修改、删除,对全局变量的操作。在Java里面,具有副作用的代码在传统上往往是用void作为返回进行暗示的,或者返回整形数字表示自己成功的修改了多少数据,比如setter、list.addAll等等,当然也有StringBuilder这种副作用混着连续的返回自身的例子。这些有修改能力的,会改变当前数据状态的动作我们都应该尽可能的放到门面上(也最好能将副作用体现到注解里面),尽量让阅读的人第一眼就能定位到你对现在数据的修改,避免未来为了找到某个数据修改跳转无数次子函数最终才定位问题。当然,这里说的将修改暴露也是函数实现关注的重点,如果不重要的数据修改其实不用拿到门面上来进行讨论。
对于副作用和状态的定义和纯函数的调试并发的好处以及实践中的用法也是一个能单独一个话题的内容,现在请去自己搜索一下然后大胆尝试,你会很快爱上无状态无副作用的纯函数的设计的。实践中少有系统能做到绝对的无状态,我们尽量将副作用外置,让人更加容易注意到副作用带来的变更。这个话题再后续的篇章中是有计划进行讨论的
门面中需要清楚的标明副作用,或者将所有的副作用尽可能聚集的处理。这样的话不至于说未来发生了未知的副作用引出的bug,却需要在具体的每一处的实现里面扑风捉影地寻找修改的痕迹。请将一切的变革尽量放在阳光下。甚至为了暴露副作用,我们可以去适当的放弃3次缩进的原则。
合理关系暗示
// 结算后总价优惠
List<Coupon> afterCheckCouponList = filterAfterCheckConpon(couponList);
for (Coupon coupon : afterCheckCouponList) {
BigDecimal sumPriceDiscount = makeDiscount(coupon, result);
result = result.subtract(sumPriceDiscount);
}de
其实这个代码里面有个很明显的bug——结算后优惠阶段,有可能出现计算结果和输入券顺序相关的问题。具体解释就是如果要支付201元,如果这个阶段同时使用无门槛打8折和满200-40的券,你会发现如果先打8折后就不能算使用200-40了。最后的输出金额居然会和优惠券输入的顺序相关,谁听到这个消息都会感觉到抓狂。
上报了这个问题(经过妖魔化和夸张)报给业务,业务的解决方案更加抓狂——为了简化的理解成本,结算后优惠阶段只允许使用一张优惠券,这个阶段用了多张优惠券就报错。胳膊拧不过大腿,你也就照着他的意思做了,主要是方便,你也懒得反驳。
其实现在的代码已经具备了业务说的核心功能了,如果你认为用最少的修改就完成业务的需求是个骄傲的事情,那么对结算后优惠阶段使用的优惠卷集合进行一次数量判断,大于1直接报错就好了。也就是这样
List<Coupon> afterCheckCouponList = filterAfterCheckConpon(couponList);
// 仅仅添加3行代码就完成了需求,只不过看起来怪怪的
if (afterCheckCouponList.size() > 1) {
throw new RuntimeException("最多使用一张总价优惠券");
}
for (Coupon coupon : afterCheckCouponList) {
BigDecimal sumPriceDiscount = makeDiscount(coupon, result);
result = result.subtract(sumPriceDiscount);
}
但是现在存在一个很尴尬的地方——我们对仅仅至多一个元素的集合进行遍历。未来的人看到这段代码,任谁都会骂人的。下面这个例子看起的暗示性就更差劲了,记住list中有且只有一个元素。对仅仅一个元素的处理却像是一个对集合中众多元素的处理,而且处理结果和输入顺序强相关,输入顺序还是他人控制的,这种代码任谁都会抓狂,如果不了解清楚,看了源码没人敢乱用这种API
String res = "";
// 可能你会认为res和list的输入顺序相关
// 但是因为业务场景限制,list中其实有且只有一个元素
// 糟糕的暗示,不如 Object obj = list.get(0);
for (Object obj : list) {
if (obj.equals(someOne))
res = "some";
else
res = "other";
}
System.out.println(res);
在实际的生产过程中让我们用一个list去拿至多一个元素的原因挺多的,可能是不敢动历经沧桑却时刻坚挺的遗留代码、可能是对面给出的API只能返回list类型。但是对于这个让人看起来就不舒服的小团块,我们还是将其抽出去当成一个独立的函数比较合理,比如这样。
public BigDecimal checkWithDiscount(List<Goods> goodsList, List<Coupon> couponList) {
......
// 结算后总价优惠
Coupon afterCheckCoupon = findOnlyAfterCheckConpon(couponList);
// 对于只有一个元素的情况,还是不要使用集合对代码的暗示性比较好
if (afterCheckCoupon != null) {
BigDecimal sumPriceDiscount = makeDiscount(afterCheckCoupon, result);
result = result.subtract(sumPriceDiscount);
}
return result;
}
private Coupon findOnlyAfterCheckConpon(List<Coupon> couponList) {
// 使用原有的实现,减少改造难度
List<Coupon> l = filterAfterCheckConpon(couponList);
return CollectionUtils.isEmpty(l) ? null : l.get(0);
}
比较遗憾的是我们还要进行一次null的判断,增加了一次缩进,让代码变的不再漂亮了。null的原因是没有优惠卷,那么我们手动给出一个空白优惠券,不会对金额进行任何影响,这样就不需要对null进行判断了。就像这样
public BigDecimal checkWithDiscount(List<Goods> goodsList, List<Coupon> couponList) {
......
// 结算后总价优惠
Coupon afterCheckCoupon = findOnlyAfterCheckConpon(couponList);
BigDecimal sumPriceDiscount = makeDiscount(afterCheckCoupon, result);
result = result.subtract(sumPriceDiscount);
return result;
}
private Coupon findOnlyAfterCheckConpon(List<Coupon> couponList) {
List<Coupon> l = filterAfterCheckConpon(couponList);
return CollectionUtils.isEmpty(l) ?
// 这里返回一个折扣为0的优惠券,用无业务作用实体代替null
noDiscountCoupon()
: l.get(0);
}
我们解决了一次令人不愉快的暗示,并且秉承着尽量少写几个缩进想法,用了点小花招,让代码变得简洁了很多。
最终代码效果,还是比较容易一眼看懂的,不过也开始有点腐败的迹象了。
public BigDecimal checkWithDiscount(List<Goods> goodsList, List<Coupon> couponList) {
if (CollectionUtils.isEmpty(goodsList)) {
return BigDecimal.ZERO;
}
BigDecimal result = sumGoodsListPrice(goodsList);
// 结算前商品优惠
List<Coupon> beforeCheckCouponList = filterBeforeCheckConpon(couponList);
List<Coupon> notUseCouponList = new LinkedList<>();
for (Coupon coupon : beforeCheckCouponList) {
BigDecimal goodsDiscount = makeDiscount(coupon, goodsList);
if (BigDecimal.ZERO.equals(goodsDiscount)) {
notUseCouponList.add(coupon);
}
result = result.subtract(goodsDiscount);
}
// 结算后总价优惠
Coupon afterCheckCoupon = findOnlyAfterCheckConpon(couponList);
BigDecimal sumPriceDiscount = makeDiscount(afterCheckCoupon, result);
result = result.subtract(sumPriceDiscount);
if (BigDecimal.ZERO.equals(sumPriceDiscount)
&& !afterCheckCoupon.equals(noDiscountCoupon())) {
notUseCouponList.add(afterCheckCoupon);
}
dataCollectService.notUseCoupons(notUseCouponList);
return result;
}
小结
这个小节中我们说谈论的话题是如何写好一个充当门面的public方法,我们最关注的问题是代码是否简单易读,是否为未来的拓展留下了空间,是否排查方便。对于一些关键性流程,对public以及关键步骤的监督是绝对有必要的,代码的腐化往往就是从门面方法的扩张开始的,其实现在的代码中就隐约能嗅到这种不祥的气息。有时候去思考一个不错的门面可能就能消耗不少的时间,这个时间也是用来让未来的一天不至于抱怨代码没法再改下去了(摸鱼的借口真多)。面向未来,合理抽象,适合拓展,简单清晰,副作用明确,暗示合理是一个门面方法的基本要求,随后更进一步的代码质量管理是锦上添花的事情了。
面对具体的门面实现,践行3次缩进的原则基本不会太差。我们可以使用简单语言去高度概括业务逻辑,从而让代码缩进变少,也能到提前返回,去主动干预代码缩进。不过缩进并不是洪水猛兽,它只是个第二重要的事情,对于副作用,即使存在让代码变的不简洁的风险我们也最好将其暴露在门面中。尽管有的缩进可以容许,但是我们还是有一点花招和小手段可以减少缩进的数量,比如给出默认值代替null。虽然就现在的门面就代码质量而言还有些问题,但这个话题我们下一话再说了,不然文字太多实在是不想再多写了。
其实你可以看到在文章的后半部分,我们聚焦的核心不再是绝对的如何单纯的减少缩进的次数,而是更多的考虑如何让代码变得更加易懂。没错,我们最核心的目的就是让代码简单易懂。无论是对于未来业务的修改调整,还是接下来更进一步的抽象,又或者是交接的时候介绍功能,简洁的代码就是一种让代码有继续保持生命力的良好手段。而至多三次缩进可以说是代码具有良好阅读的一个指示牌,而绝不是该一个门禁。至多3次缩进的原则其实也是一个自己给自己进行限制的枷锁,与别的能提升代码质量的原则类似,绝对的坚持反而会让代码变得畸形又奇怪。但是一旦你意识到了你的代码其实已经使用了超过3次的缩进,那么就应该产生构建全新子函数的警觉心,即使这个子函数仅仅只有几行,只是解决了个if后获取数据的问题,进行一次抽象也是可行的,当你不断的实践3次缩进原则后,有一天你可能豁然开朗——其实限制带来了另一种意义上的自由。
未来
嗯,尽管还有很多不爽的的地方,现在的门面已经很不错了,最起码大部分的人是第一眼就能看懂的程度了。但是现在压力来主要到了makeDiscount函数的实现这里,下次我们要集中处理这个函数,以及让副作用的处理看起来更加优雅一点。
在下次的话题中,除了必要坚持的3个缩进、其他的一些代码质量的技巧之外,还有一些关于设计模式的思考,以及谈谈在单人实践中没必要去过度追求设计模式的种种原因。
不过下一篇什么时候能够更新还真的不知道了。一切随缘吧。