INDEX
§1 前言与总纲
说在前面
介绍 OO 思想与设计的帖子很多,本文旨在用尽量通俗的语言描述一根 思维脉络
为了各位(尤其是工作经验有限的)读者在阅读的过程中脱离开技术,优先以更局外的目光审视这些思想
下文尽量不涉及代码,同时为了使例子更容易被近乎所有人理解,例子会尽量往“吃”这个方向靠(笔者也更容易想例子就是了)
总纲
下面这一段,有经验的研发人员可以跳过
总体上讲,面向对象的设计原则遵循下面总纲:
首先,面向对象中涉及的设计原则,都是以 项目实现的体积和复杂度不断演进为前提 的。
永远不会再变动的项目,没有纠结的价值,怎么做都无所谓,而不断演进意味着两个问题
- 现在看似清晰简洁的实现,以后可能复杂并且混乱
- 现在看似没有必要的冗余,以后可能成为稳定不变的脉络
这就是为什么很多新手在一开始接触这个话题时,总是不能 get 到点。尤其是讲解者给他们写出一个易于阅读的例子后,他们通常抱有这样的疑惑:“明明不这么做更简单啊……”。其中清醒的那一部分人,转头就会意识到是自己理解上出现了问题,并没有体会到一定程度;而一些纯真的小可爱会直接认为他们听到的东西是无用之物,然后自信满满的丢弃掉(这种小可爱,笔者集中的接触过若干)。
回到刚刚的话题,有这样的疑问,是因为你看到的简单的逻辑,并不是这些设计原则或者更后面的设计模式的应对场景。
设计原则和设计模式,是因为一大堆一大堆的上古程序员,在实现不断演进的业务、编写越来越复杂的逻辑、处理越来越诡异的 bug 的过程中,从一个又一个自己或别人挖的坑里爬出来时,总结的经验教训。他们遇到过很多复杂的问题,里面总有一些问题是相同或相似的,前辈们由此整理出的通用的应对套路就是设计模式,总结套路的过程中里里外外遵循的就是下面的这些设计原则。
换言之,你看到的例子其实是最小的、可以勉强解释为对某某原则的应用的例子,而不是这些原则的实际应用场景,约等于煎鸡蛋和完整酒席的区别。里面也有一些客观原因,如果真的来个比较实际的场景,光撸明白业务就需要不短的时间,然后才能讲解怎么用的,最后才轮到对比应用和不应用原则的差异并从中体会好处,太费劲了。因此此文尽量使用生活案例不用代码举例。
因此,各位读者在类似场景,不妨将你看到的例子,使劲往复杂的场景想象。
其次,面向对象中涉及的设计原则,都是以 在拥抱(业务上)变化的同时,尽量积累一些(设计上)不变的东西为目的 的
所谓不变的东西其实是指一些脉络,是应用面向对象设计原则的直接产物
可以泛化的理解为面向对象的设计本身,或狭义的理解为设计模式,它们有两个明显的价值
- 节省实现变化的过程中,花费的成本
业务越来越充盈、逻辑上越来越复杂对几乎任何一个项目而言都是不可避免的,否则这个项目凭什么可以创造价值?
但,每一次变化,都让研发人员去完整的看一遍代码、梳理一遍脉络是不现实的。
而通过这些不变的东西,我们可以快速理解并定位变更点,并给与实现,相当于信标的作用 - 保证后面(至少很长一段时间内)的变更,都可以重复借用以前的经验
在研发工作的过程中,这些不变的东西是催化剂,而不是消耗品。这意味着在长期的开发工作中,这些不变的东西其实是作为“很多问题的共通的已知条件” 或 “问题解决方案的一部分” 而存在,进而使当前的工作可以尽可能多的在以前的工作量上继续。
另外值得一提的是,通常我们认为,越抽象的东西不变程度越高,越具体的东西不变程度越低。这很好理解,因为越具体的东西越容易包含越多的细节,而细节通常很容易变化。因此无论是设计原则还是设计模式,通常含有大量抽象的内容,甚至它们本身就是高度抽象的产物。
举个例子,现在有一个问题叫做 把这块鸡肉做成菜
,在解决这个问题的过程中,尽管它的全部细节是足够多变和细碎的,但我们整理出了一个 通用但比较抽象的 流程:预处理 -> 熟制 -> 调味
。而这个流程,可以作为不变的东西,帮助我们解决后面的类似 把这块猪肉做成菜
的问题。
因此,当一个新的问题到来时,我们会快速的将问题转换为 猪肉应该怎么预处理
、猪肉应该怎么弄熟
、猪肉应该怎么调味
,而不是两眼 @ 符,我应该怎么把这块猪肉做成菜
更具有意义的是,这个流程并不是一个消耗品,当 把这块牛肉做成菜
、 把这块羊肉做成菜
的问题到来时,我们可以使用这个流程进行处理。同时前面的工作量可以帮助我们更快更好的完成现在的工作,比如对于猪牛羊这三个同属于四条腿走兽的玩意来说,单论 熟制
这一步区别好像并不是很大(适量水,适量辅料,炖煮适量时间)
接下来,面向对象中涉及的设计原则,都是以 高内聚、低耦合为标准 的
这是一个老生常谈的口号(很多时候,它真的只是个口号),但尤其需要强调要正确的理解它。
- 高内聚,不是让你往里加,而是向外减
- 低耦合,不是让你尽量砍掉联系,而是减少联系的必要性
一种常见但细品非常离谱的理解是:
让类或者说实现,尽可能多的容纳更多的功能和逻辑,以达到内聚。
并且,让它尽可能不和其他类或者说实现产生关联,比如引用或继承,以降低耦合。
想象一个极端的例子,假设一个项目,就比如说京东商城吧。现在,把它所有的功能和逻辑,都撸到一个文件中,完全符合上面高内聚低耦合的要求。技术上完全可以实现,但道理上明显讲不通对吧。
来尝试直观的想象一下高内聚、低耦合。现在,想象冥冥之中有一大团逻辑,这一大团逻辑是某个项目所有业务、逻辑的集合。而你去开发这个项目,就是把这团冥冥之中的逻辑具现,并归纳整理好的过程。
掐住其中的一小块,这一定是某一个业务或功能点。现在,使劲甩它,将它连带的多余的部分尽可能的剥离掉。当然要保证剩下的部分依然在业务上具有意义(否则每个点就只剩一条语句,那玩个屁)。当已经不能继续从它身上甩掉任何东西时,你手中的就是一个具有功能意义的最小单元,不妨用 实体
这个词称呼它(这个概念只在本帖中如此定义,只为了表述方便)。接下来,用同样的方法对付剩下的部分,直到你收获了一堆 实体
。尝试拿起任意一个,它都不掉渣,并且不会连带出其他的 实体
。现在,这个项目中 实体
的状态就叫高内聚、低耦合。
回归之前的话题:
高内聚,实际上是强调 实体的纯净度高
- 没有附着任何不属于自己的东西,于是留下来的核心必然内聚
- 这可以保证
实体
本身清晰简洁
低耦合,实际上是强调 实体之间联系的必然性弱
- 不会因为一个实体的出现 必然导致 另一个实体的出现,于是耦合自然降低
低耦合这里,有疑惑的同学可以把这里的实体理解为专指具体实现,同时低耦合不意味着无关联,后面还会说 - 这可以保证
实体
之间的组合更加灵活
最后,面向对象中涉及的设计原则,都是以 通过增加实体数量,使实体的组合更加灵活,进而换取演进空间为实践 的
实践面向对象设计原则的过程,可以抽象的概括为对混沌的逻辑进行拆解和组合的过程
- 面向对象的封装,本质上就是拆解
- 面向对象的继承和多态,本质上都属于组合的一部分
业务、功能,就是由拆解出来的 实体
组合出来的,类似于用砖头垒房子。需要再次强调,低耦合是指 实体
之间联系的必然性弱,而不是至没有联系,更不是指不能有联系。就像不能认为买了一堆转头堆在一边,就直接是有了房子一样,房子还需要在有砖头的基础上将砖头垒起了。但是,砖头之间是可以相对自由组合的,如果拿起一块砖必然带出来另一块,那大概率这砖有问题。这就是 联系的必然性弱,但不是不能有联系。
实体
和 实体的组合
的关系可以参考三节棍。对于棍子与鞭子,前者坚硬但变化不足,后者多变但太过柔软。如何兼顾二者的优点?恭喜你获得了一个三节棍,棍的部分负责硬,连接的部分负责变化。对应到业务的实现上,实体
负责稳定和可靠, 实体的组合
应对业务功能的灵活多变。
完全可以将所有的逻辑划分为 实体
和 实体的组合
两个部分。这其中,实体的组合
本身又有一部分可以抽象成 实体
。
为了使拆解出的 实体
和 实体的组合
达成到高内聚、低耦合的标准,不可能没有代价
在实践的过程中,最明显的代价就是 实体
数量的暴增,同时比较隐晦的成本是完整熟悉项目的成本会有一定程度的增加
但是这些代价是可以接受的
- 实体是可以复用的逻辑单元,它通常不是一次性的消耗品,而是一种积累
- 单次演进中有必要了解并涉及的
实体
一般是比较稳定数量 - 单次演进增加的
实体
数量会越来越少 实体
数量的增加,带来的是实体的组合
上的灵活性,进而带来了演进的空间
回顾一下前文,实体
这个词其实是对 具有功能意义的最小单元
起的别名。它就是上文中 不变的东西 本身,或它们的实现,或它们的其他衍生物。
实体
本身是高内聚的,因此我们可以很容易的复用它们,且通常不会带来后遗症。这意味着,对一个项目而言,实体
不是一次性的消耗品,而是极具稳定性与逻辑价值的 积累。
实体
本身是低耦合的,因此它们通常可以单独的拿起并放下,鲜少相互强制连带。这意味着,对一轮演进而言,为了实现它而需要了解的 实体
的数量通常是稳定的。这个数量只和这轮演进的复杂度有关,不会出现即使同样复杂,但越后面的演进必然涉及的 实体
数量(完成功能所需要考虑的实体的最小数量)也会越多的情况。
从长远角度看,随着项目演进轮次的增加,实体
逐渐积累,上文中 不变的东西 也逐渐积累。这意味着,本次演进中,很多工作量或思考过程,都在之前的演进中完成了,现在只需要增加并调整很少的一部分。即,本身每轮演进必然涉及的 实体
的数量就是相对稳定的,随演进轮次的增加和 实体
的积累,还会在这个数量的基础上砍掉一些,变得更少。
积累的 实体
越多,可以进行的 实体的组合
也就越灵活。而上文交代,逻辑完全可以划分为实体
和 实体的组合
两个部分。这意味着实现逻辑的成本会变小,速度会变快,实现方式也会变得更具多样性。
同时, 实体的组合
也是一种积累,它本身就可以抽象为 实体
。如果良性的积累到一定规模,仅增改配置就完成一轮演进是可能的。虽然这对积累的 实体
的要求较高,但在局部,比如针对某个模块或更具体的某个功能,并不难实现。
在项目演进过程中实践设计原则,实践的代价是 实体
增加,但代价是可承受、可控制,有充足良性积累的前提下甚至是可忽略的,而带来的好处是持续的
实体
不会随着演进而劣化- 演进的复杂度不会随着演进的轮次而上升
- 历史演进会给项目带来积累,并使未来的演进获益
这就为项目带来了演进的空间,即实践设计原则的演进,通过可控的代价持续收益,不会因为前面的演进,导致项目因混乱程度加剧而无法继续进步,进而可以保证项目可以一直演进下去。
部分读者读到这里会有反对意见:“我经历的项目,实体就是会随着时间而劣化,完全没有你说持续的好处”。这里必须着重强调:上面的收益是实践面向对象设计原则的过程中,用代价换来的。如果切实进行了良好的实践,你积累下来的实体通常是高内聚低耦合的,它必然不会劣化。不会劣化必然带来积累,积累必然不会使演进的复杂度随轮次递增,而这种稳定必然使项目可以持续演进。因此,有质疑的同学可以先衡量一下自己的项目是在哪个环节,实践的不够充分。
总结
- 不断演进是前提
停驻不再发展的项目,只要运行正确不出问题,讲不讲原则无所谓 - 积累不变的东西是设计目的
设计原则是为了产出设计,产出的设计是为了帮助研发
设计就是通过这些不变的东西帮助研发的
因此说讲究设计原则,就是希望遵循原则的设计可以更好的生成并积累这些不变的东西 - 高内聚低耦合是标准
但需要正确的理解,否则南辕北辙 - 通过增加实体数量,使实体的组合更加灵活,换取演进空间为实践
没有任何收获不需要代价,只需要保证代价值得即可
上面的总纲就是开头说的 思维脉络
,笔者认为可以很好辅助理解设计原则和设计模式。
为什么应用了原则看起来更复杂了,或者什么时候应该注意实践原则?考虑一下前提。
为什么提出这个方向的原则?考虑一下目的。
应该把原则实践到怎样的地步?考虑一下标准。
可我就是纠结一套用原则,类的数量就会爆炸!参考一下实践。
§2 设计原则
§2.1 设计原则开闭原则 (Open-Closed Principle)
内容
鼓励软件 实体
对扩展开放,对修改关闭
在实际使用中,这要求
- 可以增加
实体
或它的接口的数量 - 尽量不要修改现有逻辑
作用
- 可以有效保证
实体
不会劣化 - 可以有效检验
实体
实践设计原则的程度
如果出现必须修改实体
的情况,大概率是设计原则实践不到位 - 但会增加类的数量
§2.2 单一职责 (Single Responsibility)
内容
一个类只负责一个职责
作用
- 提高类的可读性
- 提高类的可维护性,降低类内部变更可能导致的风险
- 但会增加类的数量
如何理解
重点
- 原则应用的主体是类
用于约束接口的原则是 接口隔离 - 强调职责的单一
职责是一个相对来讲比较宏观的东西
误区
最常见的误区是 一个类只能有一个方法
这个错误理解是很多人不能正确区分 单一职责 和 接口隔离 的元凶
举例
社会上有一种人,叫做 打工人,他们日常所做的活计可以统称为 打工
打工人中有一类比较勤劳的存在,他们不满足于只打一份工,而是在 兼职
假设小李就是这样的兼职打工人,他白天送外卖,晚上跑网约车。
对于小李的模型,很多人的第一反应如下
class 兼职打工人 {
public void job(){
if(在白天)
送外卖
else
开网约车
}
}
好,这明显没有遵守单一职责,但暂时问题不大。
但是,现在问题来了,网约车现在要求人证车证了,否则不让开。小李一看,“卧槽,我都没有,完了,我得换工作了!”
经过一系列斟酌,小李将开网约车改成了家政服务。延续上面的思路,调整上面的模型,很多人第一反应如下:
class 兼职打工人 {
public void job(){
if(在白天)
送外卖
else
家政服务
}
}
你正在往一条路上走到黑,但暂时问题还不大。不过需要记住,你已经做出一轮修改了,同时需要知晓,即使这次修改在上面的例子里只是改了四个字,但实际项目中,这可能是几天的工作量。为了说明这么做的劣势,咱们继续制造问题。
小李发现了一个坑,送外卖和家政服务存在时间冲突,于是调整外卖为跑腿,时间相对自由的多,二轮修改;
接下来,小李发现了另一个坑,自己的时间有太多空余,与是他开始寻求更多兼职,三轮修改;
最后,更严重的问题是上面说是兼职打工人,其实只是小李,真正的兼职打工人应该是下面这样,四轮修改
class 兼职打工人 {
public void job(){
if(条件1)
兼职1
else if(条件2)
兼职2
else
兼职3
}
}
虽然功能肯定是能实现,但我们不难发现最大的问题是,这个类完全没有做到单一职责。这导致兼职打工人在 job 中我们一直在修改,并且改动量不小。很显然,它不是一个合格的 实体
,不足以作为一种积累,在项目演进中传递。
我们尝试通过增加类来实践单一职责。
-
拆分各个兼职
兼职打工人需要处理的工作通常是多个,但只有一个job
方法用来盛放逻辑。这是大量改动的主因,因此我们需要对它进行拆分。
既然兼职打工人的身上不太好直接处理多个兼职,那就抽象一个用来做单项兼职的实体
,每个实体
负责一种兼职工作。
可以将这个实体
命名为 兼职职业,每个 兼职职业 实现各自的job
每个 兼职职业 单一职责了 -
选择兼职
很显然,兼职打工人中会包含多个兼职职业,并且兼职打工人的job
就是从兼职职业中找到合适的 兼职职业 并执行
这倒是可以说兼职打工人单一职责了,但是很勉强。因为严格的说,如果兼职打工人严格的实践单一职责,那它唯一的职责应该是完成兼职任务。至于从兼职职业中找到合适兼职,对他来说应该算是杂质。
我们可以再抽象出一类实体
去完成这件事,就叫 兼职适配器 好了,同时改由兼职适配器 持有众多兼职。
兼职任务本身应该具有需要那种兼职职业来处理的信息,兼职适配器 可以通过这个信息匹配合适的兼职
兼职适配器 天然单一职责 -
描述兼职打工人的行为
现在,兼职打工人的行为已经完全固定了:获取当前任务,得到任务匹配的兼职,最后干。
兼职打工人也单一职责了 -
丰富细节
兼职打工人具有多个兼职。多个兼职的管理可以抽象出 兼职身份容器,里面封装增删兼职的方法。这可以帮助我们更好的管理每个兼职打工人的兼职职业集合
兼职打工人可能同时具有多个任务。多个任务也可以抽象对应的 兼职任务容器,与身份容器类似。
最终模型如下,下列所有内容均为合格 实体
,并都满足单一职责:
class 兼职职业{
public void job(兼职任务){ /* 实现兼职 */ }
}
class 兼职身份容器{
Map<兼职职业信息,兼职职业> ;
public void regist(兼职职业){}
public void remove(兼职职业){}
public 兼职职业 get(String 兼职职业信息){}
}
class 兼职任务{
String 兼职职业信息;
Object 兼职任务目标;
}
class 兼职任务容器{
Node<兼职任务>;// 队列或堆
public void regist(兼职任务){}
public void remove(兼职任务业){}
public 兼职任务 first(){}
}
class 兼职适配器{
兼职身份容器;
public 兼职职业 select(String 兼职职业信息){
return 兼职身份容器.get(兼职职业信息);
}
}
class 兼职打工人{
兼职任务容器;
兼职适配器;
public job(){
兼职任务 task = 兼职任务容器.first();
return 兼职适配器.select(task.兼职职业信息).job(task);
}
}
§2.3 接口隔离 (Interface Segregation)
内容
客户端应该将依赖建立在最小的接口上
作用
- 可以有效提升实体的内聚性,并降低耦合
或者反过来理解,接口没有隔离完全,必然导致耦合性的升高
因为这是典型的应该拆出去的没拆,不该带进来的东西带来了
如何理解
重点
- 原则应用的主体是接口,但严格的说是
实体
对接口的耦合 - 强调接口本身的纯粹,即高内聚接口
- 强调
实体
依赖最小接口,即对接口的低耦合
§2.4 依赖反转 (Dependence Inversion)
内容
依赖抽象,而不是具体
- 高层模块不应该直接依赖低层模块,两者都应该依赖抽象层
- 抽象不能依赖细节,细节必须依赖抽象
作用
- 有效的使
实体
和实体的组合
成为不变的东西 - 有效的防止
实体
和实体的组合
劣化
如何理解
在总纲中有交代,面向对象的设计中,越抽象的东西不变程度越高。抽象的 实体
鲜少包含细节,因此需要对它们做出修改的情况就越少,它们也越稳定可靠。
- 依赖反转中的反转,指的是 将具体实现反转为抽象
- 依赖反转的核心其实是 面向接口 设计
举例
最通俗易懂的举例
真实研发中,我们通常依靠下面的语句声明列表,而不是具体实现
List list = new ArrayList();
§2.5 里氏替换 (Liskov Substitution)
内容
子类实例应该可以随意替换父类实例
在实际使用中,这要求
- 子类可以实现父类的抽象方法
- 子类尽量不重写父类的非抽象方法
作用
- 有效降低继承带来的耦合度上升
如何理解
继承是一种有弊端的操作
- 继承本身是强耦合的
一旦实体
之间有继承关系,就意味着子类会强制与父类耦合并自动获取父类的所有接口(虽然这也是继承的目的) - 父类被子类继承,意味着子类有机会破坏父类的当前状态
如果父类本身是一个合格的实体
,这种破坏通常是恶性的 - 对父类的破坏,通常发生在子类复写了父类的非抽象方法时
这可能会导致子类与父类某种行为不一致,使调用者在希望使用父类行为时触发陷阱
同时,也是对单一职责的破坏,可以理解成有继承的类在被复写的方法上,具有正反两面的实现
因此,里氏替换原则在行为上要求避免重写父类的非抽象方法
这里有一个常见误解:子类不能复写父类方法。那咱继承父类是图什么??
里氏替换原则实际上可以看做 开闭原则 和 单一职责 在继承上的应用和补充
- 防止子类的内外两层反映不同的职责
- 将父类的扩展开放(可以使原来的抽象方法扩展为实现),将父类的修改关闭(不能改变原有逻辑)
§2.6 最小知道 (Least Knowledge)
内容
对象应该对其他对象保持最少的了解
在实际使用中,这要求
- 除了实现的接口中的方法,类应该慎重的决定其他方法的访问权限,尽可能少的暴露内部信息
- 类的内部逻辑只使用接口中涉及的类,包括成员、入参、返回值(即直接朋友)
尽量避免对局部变量中突兀出现的类进行直接操作,这些操作通常需要封装为接口(留在 理解 中说)
最佳实践 (建议看完 理解 再看)
在实现逻辑时,保证逻辑可以实现的前提下
- 尽可能依赖抽象程度更高的
实体
能用接口就不用抽象,能用抽象就不用实现 - 尽可能依赖范围更小的
实体
能用最小接口就不用比它稍大的接口,能用实现程度低的抽象就不用比它更具体的 - 不能越过接口探究内部细节(所以其实反射是反设计的)
- 不能忽略接口直接(使用成员中突兀出现的
实体
)实现逻辑
作用
- 有效降低继承之外,依赖、关联、组合、聚合所带来的的劣化
- 有效提示
实体
内聚性不足
需注意这里的内聚性不足不是指应该拆出去的没拆,而是应该包进来的东西没包了
或者是一些逻辑,并没有被封装为实体
,而是散落在外
如何理解
一个有意思的点是,前文介绍的设计原则都是针对类或接口的,而这个原则的描述却是针对对象的。
笔者认为,最小知道它更好的表达应该是 实体
之间应该保持最少的了解,是对 接口隔离、依赖反转 在应用上的补充
对象之间如何保持最小知道,无外乎下面的途径
- 尽量少暴露自己,体现高内聚(对应 内容 中的第一点,其余部分都是说明第二点)
提供功能时,将可以暴露的都通过接口暴露,其他的尽数隐藏 - 尽量少的使用别人,体现低耦合
使用功能时,尽量从自身接口获得其他实体
,然后只使用它们的接口 - 依赖时尽量依赖对象上实现的接口,而不是对象的具体类本身,体现 接口隔离 和 依赖反转
即,对象通过自身接口了解到了被操作对象的接口而不是具体实现,这样自然屏蔽对方不暴露的内容,对 依赖反转 补充
同理,对象应该通过自身接口只了解到足够完成当前接口功能的对方的接口,即完成某个功能所需的最小接口,对 接口隔离 补充
虽然说的是对象之间的事,但能采取的措施其实都是针对类和接口的,因此概括为 实体
。
这个原则本质上是鼓励黑盒的,或者说鼓励面向接口的,上文的 最佳实践,就是基于此总结梳理。
最后需要补充(上文 内容 中留的那个话题)
如果在最佳实践的过程中,真的出现接口给类提供的信息不足以完成一个逻辑,而需要一段由陌生 实体
作为局部变量参与的逻辑时,通常提示
- 需要给类添加接口
- 需要给接口涉及的成员、入参、返回值添加接口
也正是因此,这个原则可以有效的提示相关 实体
内聚性不足,并促进补全 实体
的内聚或增加 实体