[5+1]开闭原则(二)
前言
面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。
这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。
所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。
[5+1]开闭原则(一)
开闭原则与面向对象
虽然我一直在从项目管理的角度来理解和讨论面向对象思想,但是二者还是存在不少区别的。例如,二者的项目成本就有很大的不同。
绝大多数项目的成本都有这样一个特点:新建成本比维护成本更高。例如,盖房子的成本高,物业管理的成本低;买房子的成本高,装修的成本低;造车、买车的成本高,维护保养的成本低;等等等等。这里的“维护”也包括了新增或修改,例如,研发一种新飞机的成本显然要远远高于在现有机型上增增改改,不然的话,歼七姬怎么会被改得连亲妈都不认识了呢=。=
但是,软件项目的成本却与众不同:新建成本比维护成本更低。我们在开发新的软件系统、或者在做新需求时,再怎么认真细致地做需求分析、设计方案,再怎么认真细致地开发代码、测试软件,再怎么认真细致的评审、驳回、重写,能多花多少人天呢?可是,对软件系统后续的扩展和维护,无论是增加新的业务功能、修复线上bug,还是技术上的重构和优化,又要花费多少人天?
虽然我自己没有确切地统计过——估计也很少有人会统计这个——但是我印象里,是《人月神话》还是哪本书来着,曾经提出来过具体的数据,指出需求或设计上的变化引发的返工才是软件开发成本中的主要部分。
面对软件项目成本的这种“低开高走”的特点,我们有两种办法。
一种办法是继续压低前期新建系统的成本,把高企的后期维护成本交给后来人处理——低的更低、高的更高,倒是很符合马太效应。事实上,由于IT行业人员流动性比较大,很多项目和系统都是这样处理的:反正系统不需要自己来维护,后期成本也不用自己担,岂不美哉。显然,这种办法只是在转嫁成本,而并没有真正降低成本。
另一种则是面向对象的办法:把目光放长远一点,想办法提高系统的可扩展性,以此来降低后期维护的成本。诚然,这样做有可能会抬高项目前期的成本;但这是切切实实降低成本的方法。对于需要长期维护的项目、系统及其团队来说,这才是康庄大道。
开闭原则就是面向对象用来降低后期维护成本的一种方法。既然新建成本比维护成本低,那干脆只新建、不维护好了。产品需求里说的是“在原有逻辑基础上修改三个步骤”?没问题,新增一套逻辑来替换掉原有逻辑就好了。反正产品是看不懂这里面的区别的;即使看懂了,“做什么”可以由产品说了算,“怎么做”必须是开发说了算,系统流程设计绝不能被业务流程牵着鼻子走。
诚然,在降低后期维护成本这个目标上,开闭原则可以说是非常投机取巧、甚至简单粗暴的。但是,开闭原则同时也为面向对象思想提供了一个用来衡量设计方案好坏的、非常简单明了的标准。
我们已经讨论过抽象、高内聚低耦合、封装继承多态,但我们仍然很难以它们为依据,来判定一个设计方案是否是“合格的”抽象、是否“足够”高内聚低耦合、封装继承多态做得是否“到位”。
然而,有了开闭原则之后,好坏之分就变得泾渭分明起来了。如果在发生扩展时,一个抽象不需要修改自己的“语义外观”、只需要在内部增加新的实现,那么它就是“合格的”抽象。如果在发生扩展时,一个模块只需要在模块内部增加新的代码,而不需要调用方做什么修改,那么这个模块就“足够”高内聚低耦合了;如果在发生扩展时,类的组合、继承等静态结构不需要做出修改,而只需要提供新的实现类,那么这个类的封装继承多态就很“到位”了。
可以说,有了开闭原则,面向对象思想的光芒才真正地照到开发实践中来。
所以,虽然提到五个设计原则时一般都会说“SOLID设计原则”,这里也是按照SOLID的顺序来讨论它们的,但是,把它们称作“SOLID”绝对只是为了拼凑一个英文单词以方便记忆,而不是按重要程度给它们做了一个排序。实际上,开闭原则(O)才是其中最重要、最核心的一个设计原则。
开闭原则与抽象
如前所述,如果在发生扩展时,一个抽象不需要修改自己的“语义外观”、而只需要在内部增加新的实现,那么它才是“合格的”抽象。
为什么这么说呢?抽象所定义的“语义外观”,旨在说明它能“做什么”,而绝口不提它是“怎么做”的。因为“语义外观”是“抽象”的,自然也不应该与“具体”实现有什么关联。
但是,如果不能遵守“开闭原则”,抽象就会被具体实现所钳制、甚至受其左右、任其摆布,自然也称不上是一个“合格”的抽象了。例如,每个系统都少不了Dao类。在我们的系统中,Dao类一般是这么声明的:
public interface OrderDao{
Order queryByUserAndChannel(Order queryParam);
}
public interface OrderDaoImpl implements OrderDao{
public Order queryByUserAndChannel(Order queryParam){
// 实际用的是mybatis的动态SQL;这里示意一下动态SQL中的参数
String sql = "select * from tb_order"
+ " where user_id= ? and channel_id = ?";
// 后续略
return null;
}
}
这是查借款订单的接口及其中一个方法。从方法名可以看出,这个方法只能根据用户id和渠道id来查询订单。与方法名相对应的,参数列表也限制了调用方只能使用用户id和渠道id来做查询操作。
看起来似乎很美好。但是,这个接口并不是一个合格的抽象,因为它的语义外观与具体实现死死地绑定在了一起。
当需要增加或修改查询条件时,我们就不得不面对一个尴尬的选择:是在原有方法上修改?还是在接口中新增方法呢?
如果在原有方法上修改,就会导致这个方法“名不副实”。例如,上面那个queryByUserAndChannel方法,如果增加一个查询参数,变成下面这样之后,它到底是根据用户和渠道来查询,还是在根据用户、渠道和产品来查询呢?
public interface OrderDaoImpl implements OrderDao{
public Order queryByUserAndChannel(Order queryParam){
// 实际用的是mybatis的动态SQL;这里示意一下动态SQL中的参数
String sql = "select * from tb_order"
+ " where user_id= ? and channel_id = ?"
+ " and productId = ?";
// 后续略
return null;
}
}
诚然,我们可以修改方法名。但是对上面这个方法来说,有些调用方仍可以沿用用户和渠道的查询方式,有些则必须改用用户、渠道和产品的查询方式。如果修改方法名,该改成什么名字呢?
如果在接口中新增方法,虽然可以解决“名不副实”的问题,但是又会引入新的问题。
首当其冲的是重复代码的问题。很多方法的逻辑基本一样,只是需要针对新的参数做一些额外处理。如果处理不当——比如为了节约时间而简单粗暴地复制粘贴——就很容易产生重复代码,又进一步增加技术债。当然,重复代码的问题还算比较好解决,毕竟只需要在接口内部进行重构即可。
比这更严重的问题是接口暴露了自己的实现细节,使得调用方与服务方之间产生了更紧密的耦合。在系统后续的演化中,这种耦合会给调用方和服务方带来不小的麻烦。这方面的例子在《高内聚低耦合》和《细说几种耦合》中已经列举过不少了,这里不再赘述。
显然,这个接口不是一个“合格”的抽象:因为每一次对它的功能进行扩展,我们都需要修改接口定义——除非能够忍受挂羊头卖狗肉的代码,否则我们必然要修改或者新增接口方法。这也意味着,这个接口毫无抽象能力。它完全是在描述自己“怎么做”,而无法概括自己“做什么”。
同时我们可以发现,这个接口不符合开闭原则:因为每一次对它的功能进行扩展,我们都需要修改接口定义——除非能够忍受挂羊头卖狗肉的代码,否则我们必然要修改或者新增接口方法。
一个符合开闭原则的接口,应该只需要新增实现类、而不需要修改接口声明,就可以满足新的需求。
所以,符不符合开闭原则,可以用来评价抽象合不合格。不符合开闭原则的抽象,一定是不合格的抽象;符合开闭原则的抽象,一般是合格的抽象。
开闭原则与高内聚低耦合
严格遵循开闭原则,有可能会降低业务功能的内聚性;但同时,开闭可以降低功能之间的耦合性。
“严格的说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改”,因而,严格遵循开闭原则,指的就是一旦代码上线,就不再做修改。任何需求的变更都通过扩展来实现。
显然,这样做太过于胶柱鼓瑟了。如果真的这样做,很多本应聚合在一个类中的功能和代码必然会被分散到若干个不同的类中。这也是为什么本文一开头就说,“实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的‘修改’之中”。这样,属于同一个功能的代码可以保持在同一个类中;不同的功能才会扩展到不同的类里面去。
不过,在极端情况下,也可以按最严格的标准来执行开闭原则。例如我们做过的老系统迁移项目,项目目标之一就是把老代码全部“封版”。所以,在这个项目中,我们严格要求不允许对老代码做任何修改,所有功能或性能的改造全部通过扩展来完成。
但是这毕竟是极端情况。日常工作中,我们不会、也不鼓励这样处理。可见,只有在严格遵循开闭原则这种情况下,才会降低模块内聚性。日常工作中的“折中处理”并不会带来这种问题。
但无论是坚持原则性还是掌握灵活性,遵循开闭原则都能降低功能之间的耦合性。
虽然很多需求都只需要“修改”一部分老代码,然而不可否认的是,修改后代码所承担的功能与修改前往往大相径庭——即使流程步骤很相似,背后的业务逻辑也各不相同。
因此,通过修改老代码来实现新需求的做法,会把老代码所承载的业务功能和新需求所要求的业务功能耦合在一起,而且是耦合度非常高的内容耦合:两个不同的功能用同一个类、同一段代码来实现,无论是哪个功能要求修改这段代码,都不可避免地会影响到另一个功能。
而遵循开闭原则、用扩展的方式来实现新需求,则会把新老两套代码分别放在不同的类、甚至是不同的模块下。这样一来,无论需求发生怎样的新变化,都不会对无关的代码、模块造成影响。这不正是低耦合的目标吗?
作为最重要、最核心的一个设计原则,开闭原则有非常强的原则性,我们的设计和开发工作应当尽可能地遵循开闭原则。但是同样的,在实际应用中,我们也要把握好灵活性,认真考虑好利弊得失,做出最适合、最恰当的取舍。
开闭原则与封装继承多态
要遵循开闭原则,就要做好封装、继承和多态。
如果接口不能做好封装、不能很好地把实现细节隐藏在抽象内部,那么新增需求时,就难免要修改接口、破坏抽象。此前关于方法名的例子已经可以说明这一点了。
良好的封装可以保证代码和模块“对修改关闭”,而继承和多态则是“对扩展开放”的利器。在很多文章介绍过“如何重构if-else”的文章中,都会提到一项终极利器:使用多态。借助这些重构技巧,我们可以摆脱繁琐的if-else、摆脱维护if-else的烦恼。
如果我们不是在写了一大堆if-else之后再用多态去重构、而是在一开始就使用多态而非if-else来实现功能呢?
这就是开闭原则最常用的落地方式。
例如,在OA类系统中,审批流是非常常见的一项需求。在我参与开发的一个OA系统中,随着领导们越来越多地接触和接受新鲜事物,审批方式也越来越多样化。除了一开始的网页端操作之外,这个系统逐步接入了短信审批、邮件审批、移动办公APP审批、微信审批等多种审批方式。虽然所有审批方式最终都要操作同一张表,但是入口不同、提交的信息不同,所做的操作也不尽相同。
我们没有用if-else的方式来实现需求,而是定义了一个审批服务接口,通过继承和多态的方式,来满足不同的需求:
上图是只有浏览器审批和邮件审批两个功能时的类图。诚然,相比直接堆砌if-else,这个框架的前期成本比较高,梳理业务、设计框架、开发核心代码,都需要付出额外的心思和汗水。
不过,这点付出很物超所值。后来接入短信、移动办公APP和微信等多种审批方式时,我们都只需要增加一个新的实现类,而不需要修改原有代码。这就是继承与多态为开闭原则带来的便利性。
继承与多态不仅使得新增功能变得简单易行,还能让下线功能也变得轻松快捷。为了节约成本,我们的短信平台砍掉了一些功能,其中就包括我们OA系统的短信审批功能。自然地,这个OA系统中的短信审批相关代码也要下线、删除。
在开闭原则的“庇佑”下,这个需求只花了不到三分钟就搞定了:只需要把当初“新增”的类再给删掉就好了。
作为对比,我们在另一个系统中也因为类似的原因删过代码。虽然在把核心代码删掉之后,按照编译报错的提示来删除代码调用,也花不了太多时间。但是,处理与这些代码相关的if-else可让我们折腾了好久。
一方面,我们需要仔细检查几乎每个if-else中的布尔运算,确定删除代码后逻辑不变,这就足够开发们喝一壶了。另一方面,测试也需要针对每一个if-else的场景来进行测试,QA组同事表示真是醉了。
然而,即使开发小心谨慎、测试回归覆盖,删完代码上线后还是出现了问题。在修改某一个if-else时, 开发错误地改变了条件表达式,而测试也没有覆盖到新表达式中可能出错的那种场景。结果就像墨菲定律说的那样,虽然出错概率很小,但只要有可能出错,就一定会出错。
这类修改代码带来的风险和问题,都可以通过开闭原则来规避或解决。而要将开闭原则很好的用在系统代码中,就必然要对封装、继承和多态这三种面向对象的基本特性善加运用。
开闭原则与单一职责原则
理论上讲,开闭原则与单一职责原则之间,并没有直接的关联。然而在实践中,我们常常会发现,二者之间有着某种其妙的“默契”。遵循开闭原则所写下的代码,往往也都符合单一职责原则;而为了遵循单一职责原则,我们也常常需要运用一些开闭原则的技巧。
就像前面例举的审批模块一样。我们遵循开闭原则,设计并开发了ApproveBizByBrowser和ApproveBizByEmail两个类,后续还逐步增加了ApproveBizBySms、ApproveBizByApp、ApproveBizByWeChat等类。显然,每个类都只承担一种职责,因而,他们既符合开闭原则,也符合单一职责原则。
为了遵守单一职责原则,我们就必须避免新需求中的功能随着代码修改而“入侵”原有代码、“污染”原有类和模块,导致原本符合只承担一种职责的类变成“多面手”、“万能类”。而开闭原则的要求正是对修改关闭、对扩展开放。因此,遵守开闭原则,我们就不会把新的功能写入已有的功能代码中,而只能通过扩展出新的实现类/子类来实现新的功能、承担新的职责。可见,对单一职责来说,最好的“护城河”就是开闭原则。
另外,开闭原则和单一职责之间的这种“默契”,还可以用作“开”与“闭”的一个决策依据。
面对新的需求变更时,是坚持开闭原则的原则性、采取扩展的方式来实现需求,还是把握开闭原则的灵活性、通过直接修改代码来实现需求,这是我们在落实开闭原则时常常要面对的问题。
单一职责原则可以很好地帮助我们做出判断、解决这个问题。如果修改后的类仍然符合单一职责,那么,这次修改就不在开闭原则所说的“对修改关闭”的范畴之内,因而可以放行。但是,如果修改后的类不符合单一职责原则了,那么抱歉,我们必须抡起“对修改关闭”的大棒,再次高声喊出那句咒语——
往期索引
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?
花园的景昕,公众号:景昕的花园面向对象是什么
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
花园的景昕,公众号:景昕的花园抽象
《高内聚与低耦合》
《细说几种内聚》
《细说几种耦合》
"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
花园的景昕,公众号:景昕的花园高内聚与低耦合
《封装》
《继承》
《多态》
——“面向对象的三大特性是什么?”——“封装、继承、多态。”
单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。
花园的景昕,公众号:景昕的花园[5+1]单一职责原则
什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。花园的景昕,公众号:景昕的花园[5+1]开闭原则(一)