前言
面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。
这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。
所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。
=================================
↑前言↑
↓正文↓
=================================
单一职责原则(Single Responsibility Principle)
单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。
=================================
↑单一职责:是什么↑
↓单一职责:为什么↓
=================================a
为什么
严格来说,单一职责原则并没有解决问题,而只是把问题从“怎样定义类”变成了“怎样划分职责”。
可是,划分职责这件事情,本身没有一个放之四海而皆准的标准。我们固然可以说“查询用户银行卡绑定状态”、“查询用户实名认证状态”、“查询用户授权状态”、“查询用户密码强度状态”等是不同的职责,但是也可以认为它们同属于“查询用户账号状态”这一个职责。同时,“查询用户银行卡绑定状态”这个职责,也还可以拆分为“查询用户名下是否有银行卡”、“判断系统是否支持该银行卡所属银行”、“判断该银行卡是否用户本人的银行卡”、“判断该银行卡是否通过四要素鉴权”等几项职责。这几种划分方式都有各自的道理,很难说谁对谁错。想要在这样的基础上落实单一职责原则,就真是一个将军一个令,必然要陷入无所适从的田地。
这是单一职责原则变得“食之无味、弃之有肉”的主要原因。
那么,我们是不是就可以把单一职责原则丢到九霄云外呢?答案显然是否定的。
软件开发有个“一生之敌”,就是复杂度。我们为了把时间复杂度由O(n)改进为O(log2n)而拼命改进算法,为了把函数的圈度复杂度降到7以下而不断重构函数,为了把协议的复杂度“分而治之”而把完整的协议分成七层或者四层,为了把复杂度拆分到不同的系统中而构建微服务。
但是,从系统的层面来考虑的话,几乎所有方式都没有真正降低复杂度,而只是把一种复杂度转变成另一种复杂度。改进算法可以降低程序运行的复杂度,但是会增加人们去理解和维护的复杂度;对函数的重构往往只是把复杂度从这个函数分散到其它的函数里去;分层是把整个协议的复杂度拆分到各个层次上去;微服务架构则是把单体系统的复杂度拆分到不同的微服务上去。
复杂度就像能量一样,只能转化或转移,但是无法彻底湮灭。我们甚至可以说,除非删代码,否则一个系统的复杂度永远是只增不减的。
话虽如此,但我们不用感到灰心丧气。虽然整个系统的复杂度居高不下,但是冰冻三尺非一日之寒,绝大多数时候,这么高的复杂度都是一点一点累加上去的。换句话说,我们绝大多数时候需要面对和处理的复杂度,往往都只是沧海一粟。所以,面对系统复杂度这头巨兽,我们虽然无法一口鲸吞,但还是可以找到办法把它逐步蚕食下去的。
什么办法呢?我认为,最简单、最通用的办法,就是把“逻辑复杂度”转变为“结构复杂度”。
逻辑复杂度很好理解,一个逻辑单元(如函数、对象、模块、甚至服务等)中的分支越多,其逻辑复杂度就越高。圈度复杂度就是逻辑复杂度的一种常用的度量方式。结构复杂度略费解一些,逻辑单元之间的关系(如函数调用、对象扇入/扇出、模块引用、服务依赖等)越复杂,其结构复杂度就越高。如果把这张关系网画成图,那么我们就可以很直观地度量结构复杂度了。
把“逻辑复杂度”转变为“结构复杂度”,就是降低逻辑单元内的逻辑复杂度、提高逻辑单元间的结构复杂度。或者简单来说,就是把系统由“复杂逻辑、简单结构”转变成“简单逻辑、复杂结构”。除了优化算法之外,重构函数、系统分层、拆分服务等方式,本质上都是在按这种思路来应对系统复杂度。
“简单逻辑、复杂结构”这个思路,同样也是划分职责的一个思路。逻辑单元尽可能的简单,也就是它所包含的职责应当尽可能的少。当需要某种更复杂的逻辑时,通过某种结构把这些简单的单元组合在一起来实现就可以了。
而这,就是我们为什么要遵守单一职责原则的主要原因。
=================================
↑单一职责:为什么↑
↓单一职责:怎么做↓
=================================
怎么做
要说怎样落实单一职责原则,其实很简单:把你能拆出来的功能、职责都拆分到独立的类里面去。
但是显然的,这样做会迅速地引发类爆炸,并且导致开发工作量指数级别上升——Java代码的啰嗦麻烦是有目共睹甚至千夫所指的问题。写一个类就够累的了,真要发生类爆炸了,恐怕手指都要写抽筋了。
又要遵守单一职责原则,又要避免开发量过大,这简直是又要马儿跑、又要马儿不吃草。所以,一般我们都会在这二者之间做折衷处理。怎么折衷呢?我们可以把多种职责放到同一个类里面;但是至少应该把不同职责划分到不同的方法中去。然后,在有时间或者有必要的时候,再通过重构,把它们放到单独的类里去。
说到重构我忍不住多说一嘴。我们常说系统架构是演化出来的。但是具体要怎样演化呢?其实就是“靠重构”。偶尔也会有人问我“整天做CRUD,要怎么样才能提高自己”,我的答案也是“靠重构”。重构是一件不仅能提升系统代码质量、还能提高个人技术能力的事情,我们应该把它作为开发组的一项日常工作来定期/不定期的推进落地。尽管在实际工作中,由于需求任务太多、开发资源太少、测试运维不好配合等原因,我们很难安排专门的重构任务,但是,在业务需求开发工作中加入一些重构任务,“积小胜为大胜”,也能够起到可观的效果。
说回单一职责原则。类爆炸以及增加工作量的问题,很多时候都会具体的表现为“要不要为这些拆分出来的类创建独立的接口”?以及,“如果要创建接口,难道要给每个独立的类都创建一个接口吗”?
很多人跟我聊过他们为什么不愿意面向接口编程。大多数人的理由都是“反正一个接口只有一个实现类,再声明一个接口纯属浪费”。在我看来,这个理由跟“反正我只会把飞机当汽车来开,所以给它装上翅膀纯属浪费”没什么区别。
它(抽象)也是分层次的。越是高层级的抽象,其中的细节就越少,对业务的概括能力就越高,可维护和可扩展性也就越好;越是低层级的抽象就越“反其道而行之”:细节信息就越多、业务概括能力越低、可维护和可扩展性也就越差。这也是为什么我们鼓励“面向接口编程”的一个原因。一般来说,接口都是顶层抽象。在它的基础上编程,维护和扩展所受到的限制也就越少。
花园的景昕,公众号:景昕的花园
抽象
所以,如果你问我“要不要为这些拆分出来的类创建独立的接口”,我的答案一定是“要”。但是,“要给每个独立的类都创建一个接口”吗?不一定。一个接口代表一个业务抽象;如果好几个类都从属于同一个业务抽象,那么它们就应该实现同一个接口;否则,我们就有必要为它们创建不同的接口。
在这里,问题又被转移了:我们要考虑的不再是“要不要为这个类创建一个接口”,而是“这个类承担的职责应该从属于一个怎样的抽象、或者从属于哪个抽象”。实际上,相比于类爆炸或者增加开发工作量,这个新问题才是单一职责原则带来的最大的挑战。
=================================
↑单一职责的为什么与怎么做↑
↓单一职责与面向对象↓
=================================
单一职责与面向对象
单一职责原则非常好的体现了面向对象与管理思想之间的相似性。
当团队规模小、业务并不复杂的时候,我们可以让一个人身兼数职,以此来节约成本、提高效率。随着开发团队由小变大、负责的系统由少变多,团队成员们会逐渐由全栈转向客户端、服务端、运维、DBA等更专精的方向。随着公司由小变大、市场份额和业务范围不断扩张,老板们也会逐步由一人独掌财务、人事、市场大权转为把专权交给专业的“O”们。这就是管理上的“单一职责”,有时候也叫“让专业的人做专业的事”。如果在规模大增之后不能“一事一权”、还试图沿用以前的“事随人定”,那很容易像武汉红十字会那样把事情和自己一起搞砸。
面向对象其实也是一样。业务复杂度低、系统规模小的时候,让一个模块或者一个类承担若干种无关逻辑,还算不上什么大问题。但是,当逻辑越来越复杂、系统越来越大、跨系统交互越来越多的时候,如果不能把业务职责分开、把纠缠在一起的代码分开,而还是像刚开始那样把它们像麻花一样拧在一起的话,当需要修改这些代码中的bug时、当需要向其中添加一些业务逻辑时、当跑出一些“奇怪”的数据时、当评估与之相关的需求改动范围和工作量时,想要从这种代码中找出我们想要的结果,真真是“难于上青天”。
我曾经重构过一个类似的系统。重构前,光是梳理原有逻辑就花了将近三个月。重构的核心方向就是单一职责原则和“逻辑简单、结构复杂”:把不同的功能拆分到不同的类中,然后用一套虽然有点复杂、但还算比较清晰的类结构把它们组织成完整的业务逻辑。相比之下,后续的改造开发就简单得多了。
后来在公司内部分享这次重构的经验时,我打过这样一个比方:如果你面对的是像下图一样杂乱无章且纷繁复杂的网线,那么,即使发现了有个地方接触不良,你也不知道还有哪些地方受了影响;如果你要往里面加一条、减一条、或者替换一条网线,光是搞清楚这根网线连着哪些机器就够折腾三四天了。
但是,如果你面对是下图这样的线缆呢?上面那些让人头疼的问题,是不是都迎刃而解甚至易如反掌了呢?
这就是团队管理和面向对象设计中的单一职责原则。它是一个“大”方向,有时只有“大”团队、“大”系统需要考虑。同时,它又能在最“小”处落脚:它所考虑的,往往都是“小小”的一个人、一个类,一项职责。
=================================
↑单一职责与面向对象↑
↓单一职责与抽象↓
=================================
单一职责与抽象
大多数情况下,我们都认为“抽象”约等于“接口”,所以,在设计抽象时,单一职责原则就会摇身一变,以“接口隔离原则”的形式出现。
尽管如此,单一职责原则与抽象设计也并非全无关联;只不过,它主要关注我们如何实现一个抽象。
首先,单一职责原则能够帮助我们对抽象做进一步的拆分,从而达到逐步分解业务复杂度的目标。
考虑到“抽象”约等于“接口”,实现一个抽象的最简单方式就是一个接口+一个实现类。如果这个功能的逻辑很简单,这样做无可厚非。但是,如果它的逻辑很复杂,还坚持只用一个实现类,就会陷入“逻辑复杂、结构简单”的困境中去。
借助单一职责原则,我们就可以把复杂的逻辑划分为不同的职责、并把这些职责拆分到不同的实现类中去。
例如,我们可以把完整功能划分为“控制”+“处理”两种职责,“处理”职责又可以按“控制”职责的维度划分为不同的子职责,然后通过控制类来选择处理类,以此实现完整的业务功能。或者,我们可以把完整功能划分为“基本功能”、“增强功能”、“增强功能2.0”、“增强功能最终版”、“再增强就不是人功能”等等,并通过不同功能的组合来实现不同的增强增强再增强功能。
其次,我们常说,系统架构是演化出来的。在这个演化过程中,我们往往会对已有的类结构做一些调整,如把某些类放到新的接口下、为某些类提取一个父类,等等。
从面向对象设计的角度来说,这种架构演化,本质上都是开发人员在梳理和调整系统中的抽象。例如,我最近做的两项比较大的重构,其实都是在把散落在不同的Controller、Business、Service中的代码整合到一个接口下的一套类中,从而让它们的业务抽象更清晰、易用。
单一职责原则在这个过程中起到了什么作用呢?
如果老代码能够遵循单一职责原则,那么重构时就能轻松得多。很多时候我们设计一个业务抽象,就是在做一道“阅读代码并概括中心思想”的题目,最后得到的“中心思想”就是我们想要的业务抽象。很显然,如果一个类只做了一件事情,概括它的“中心思想”并抽取接口和抽象就变得非常容易;而如果一个类做了好几件没什么关联的事情,想要“一言以蔽之”就难多了。
以单一职责为方向,重建业务抽象,是重构的一种常用手段。例如,我们有个为不同用户展示不同的首页广告的功能,它的逻辑是这样的:
这段逻辑并不算复杂。但是,在重构之前,所有的代码都在Controller里面,类图我就不放了,就那一个类,标准的“结构简单、逻辑复杂”。这不仅使得我们很难调整广告推送逻辑,而且,当需要向某个新渠道开放一个只包含广告A和通用广告的逻辑的新接口时,由于原有逻辑完全搅在了一起,我们很难通过复用代码来开辟新的接口。
所以,在开新接口之前,我们重构了一下这段代码。重构的方向,就是遵循单一职责原则,保证一个类只负责一类广告的展示判断。然后,借助某种不太直观的类结构(其实就是责任链模式;不过设计模式先按下不表,留作后话),把这些类组合成两套不同的业务。最终的代码逻辑和相关的类被改成了这样:
从这个例子也可以看出“逻辑简单、结构复杂”的另一个优点:我们既不用改接口也不用改代码,只要修改SpringIOC配置,就可以组合出不同的业务功能来。这种高复用性和高扩展性,也是我们提倡单一职责原则的重要原因。
=================================
↑单一职责与抽象↑
↓单一职责与高内聚低耦合↓
=================================
单一职责与高内聚低耦合
"高内聚"是说,一个业务应当尽量把它所涉及的功能和代码放到一个模块中;"低耦合"则是说,一个业务应当尽量减少对其它业务或功能模块的依赖。
花园的景昕,公众号:景昕的花园
高内聚与低耦合
如果我们把这里的“模块”缩小范围为单独的一个类,就能很明显地看出:单一职责原则在降低类中功能和代码的耦合性的同时,也有可能降低它们的内聚性。
如果不遵守单一职责原则,最常见的情形就是一个接口下只有一个实现类,这个接口所涵盖的所有逻辑全都放到了这一个类中,即使这些功能可以进一步拆分为不同的子功能或不同的低层抽象。显然,这个模块——或者说这个类——的内聚性很高,但与此同时,它的耦合性也很强。同一项业务在不同场景下的处理逻辑——如根据不同的理财产品配置和优惠活动规则计算用户购买理财产品的收益等——全都杂糅在一起,牵一发而动全身,很容易在后续的扩展和维护中产生莫名其妙的bug。
而按照单一职责原则来设计类结构的话,一个接口下往往会出现多个实现类;有时还会出现一些辅助接口或者抽象类。在这种情况下,单靠其中任何一个实现类、甚至依靠一部分实现类,都无法实现接口所定义的完整功能。因此,这种类结构的内聚性比只有一个实现类的结构要低。但是显然的,这种类结构的耦合性要更弱:不同的功能被分散到不同的实现类中,仅通过某种结构组合出完成的业务。无论我们要增、删、改哪一项功能,都不影响其它功能。这样不仅能减少线上的bug率,而且能提高开发和测试效率——低耦合的优点可以尽收囊中。前文提到过的首页产品展示的类、还有以前提到过的短信签约操作的类,都可以佐证这一点。
曾经有同事和我讨论过接口和实现类的关系。他的代码习惯是不写接口、直接写实现类:反正一个接口下只会有一个实现类嘛,再声明一个接口出来岂不是自讨苦吃。而且对我的“一个接口、多个实现类”的代码习惯,他也颇有微词:这么多实现类,在需要注入时不知道该用哪一个;看代码时也不知道该看哪一个实现类。其实他所说的,就是高内聚和低内聚之间的几个优缺点对比。不用说,我自然是用低耦合和强耦合之间的优缺点对比来反驳他的。
我俩谁对谁错呢?脱离了具体场景,自然是“公说公有理婆说婆有理”。单一职责原则也是一样:到底要不要遵守它、要遵守到什么程度,都要视具体场景做出取舍。如果业务确实简单,那么一个实现类就可以搞定;而无论业务逻辑再怎么复杂,也没必要针对每个if-else都创建一个子类出来。大多数时候,“执两用中”是取舍的最佳方案。
=================================
↑单一职责与高内聚低耦合↑
↓单一职责与封装继承多态↓
=================================
单一职责与封装继承多态
继承和多态是履行单一职责原则所必不可少又实用便捷的手段。相比之下,封装与单一职责原则的关系就更疏远一些了。
前面我们提到,“单一职责原则能够帮助我们对抽象做进一步的拆分”。拆分的方式有两种:一种是纵向拆分,也就是增加继承层次,把抽象内的各种职责逐层拆分;另一种则是横向拆分,也就是增加实现类个数,把各种职责分摊到平级的实现类中。
显然,纵向拆分主要是通过继承,把一部分通用的职责(如方法、数据)放在父类中,而子类只需要专注于自己的职责即可。这样,父类和子类各司其职,相得益彰。横向拆分则主要通过多态,在保持对外的抽象(接口或者父类)不变的前提下,把不同的职责拆分到不同的子类中。这样,不同的实现类之间就可以独立自主地和平共处了。
当然,纵向拆分也是一种多态;横向拆分也要用到继承。而且,这两者并不互斥。纵向拆分到某一层级时,也可以做横向拆分;横向拆分出来的类如果职责仍然太多,也可以通过纵向拆分进一步“单一职责”。
不过,既然说到了继承,这里还是要再重复一遍:慎用继承。继承固然能让我们方便快捷地复用代码,但是它带来的强耦合性就像一块埋在脑袋里的弹片一样,不知道什么时候就会让我们头疼不已。而且,很多的继承关系其实都可以改用组合来实现。在不能百分百确定应该使用继承的情况下,个人建议还是尽量使用组合来复用代码、扩展功能。
虽然说封装与单一职责原则的关系不如继承和多态那么密切,但二者多少还是有些联系的。封装隐含有“封装变化”、“隔离变化”的含义。而单一职责原则所说的“一个类应该只有一个发生变化的原因”也隐含有“一种变化只影响一个类(的代码)”的含义。两相对照,不谋而合。
说到“隔离变化”,五大设计原则中最能体现这一点的当属开闭原则。不过说到“开闭”嘛,就“请听下回分解”吧。
=================================
↑正文↑
↓往期索引↓
=================================
《面向对象是什么》
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?
公众号:景昕的花园
面向对象是什么
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
花园的景昕,公众号:景昕的花园
抽象
《高内聚与低耦合》
《细说几种内聚》
《细说几种耦合》
"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
花园的景昕,公众号:景昕的花园
高内聚与低耦合
《封装》
《继承》
《多态》
——“面向对象的三大特性是什么?”
——“封装、继承、多态。”
=================================
↑全文完↑
↓请看下集↓
=================================