说起来设计原则的第一篇是在6月份,之后7、8月因为工作比较忙基本都没怎么学习,9月份才又赶上来了,这段时间基于极客时间的《设计模式之美》重新学习了下经典设计原则,收获认知提升颇多。这里对这一阶段的经典设计原则的学习和重点进行一个小结。
经典设计原则博客目录
话不多说,对这段时间学习的七篇设计原则进行总结
这其中涉及到不少知识点,总体总结在这里:
左侧的5 个设计原则组成的SOLID,它们分别是:SRP单一职责原则(the single responsibility principle )、OCP开闭原则(the open closed principle)、LSP里氏替换原则(the liskov substitution principle)、ISP接口隔离原则(the interface segregation principle)、DIP依赖反转原则(the dependency inversion principle)
经典设计原则重点内容
接下来回顾一下这些设计原则学习过程中的重点
SRP单一职责原则
以下内容为我认为认知得到加深的内容:
SRP单一职责原则的定义是什么
一个类或者模块只负责完成一个职责(或者功能),也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类
SRP原则解决什么问题
单一职责原则的目标是设计粒度小、功能单一的类,从类自身功能角度出发考虑。通过重构类的功能实现代码高内聚(功能相关高内聚)、促进代码低耦合(功能无关低耦合),提高代码的可复用性、可读性、可维护性
如何实现SRP原则
主观上依据当前场景合理拆分,开始设计要尽量避免过度设计,因为根本不知道业务未来会发展成什么样,能做的最好只是提前预留好扩展点,静候业务发展然后实时做出调整,可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
客观上可以参照如下几个标准,下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分; 从另一个角度来看,当一个类的代码,可读性非常差,实现某个功能时不知道该用哪个函数,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了
- 类依赖的其他类过多,或者依赖类的其他类过多(被其它类使用),不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
有了主观重构意识再加上客观条件判断能帮助我们写出符合SRP的代码
OCP开闭原则
以下内容为我认为认知得到加深的内容:
OCP开闭原则的定义是什么
软件实体(模块、类、方法等)应该对扩展开放、对修改关闭。详细表述就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)
对于方法的扩展是对于类的修改,这样不满足OCP么
- 同样一个代码改动,在粗代码粒度下,被认定为修改,在细代码粒度下,又可以被认定为扩展。改动一添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为修改;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。那么该如何衡量呢?抓住一个原则就可以了:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,它就符合OCP
OCP规定代码一行都不能改么
- 添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则
写代码越OCP越好么
- 写OCP的代码成本比非OCP的高,逻辑也更负责,读起来更费劲,有些时候代码的扩展性会跟可读性相冲突。对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求
OCP原则解决什么问题
OCP的目标就是提高代码的可扩展性,23种设计模式大多数也是这个目标,所以说OCP非常重要
如何实现OCP原则
主观上要具备扩展意识、抽象意识、封装意识 ,具体的方法论则是:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)
LSP里氏替换原则
以下内容为我认为认知得到加深的内容:
LSP里氏替换原则的定义是什么
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
LSP原则解决什么问题
用来指导继承关系中子类该如何设计 ,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性,保证父子关系更加稳定健壮,通常用于指导框架版本向后兼容增强而不改变原有功能的设计。搭配【基于接口而非实现编程】使用效果最佳
如何实现LSP原则
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系
ISP接口隔离原则
以下内容为我认为认知得到加深的内容:
ISP接口隔离原则的定义是什么
客户端不应该被强迫依赖它不需要的接口。其中的客户端,可以理解为接口的调用者或者使用者,这里的接口不仅指Java里的接口类,实际上它有三种含义: 一组 API 接口或方法集合; 单个 API 接口或方法; OOP 中的接口概念
SRP和ISP都是降低代码依赖耦合,区别是什么
- 通过拆分方法让代码粒度变细的方式,ISP和SRP有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。也就是说ISP的判定更为主观一些。
ISP原则解决什么问题
解决代码的无用依赖问题,指导接口的设计,让接口的功能更内聚单一,让调用方和接口直接的耦合度降低,提升接口的可复用性、可读性、可维护性
如何实现ISP原则
- 接口是【一组 API 接口或方法集合】:调用者的角度出发:如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口
- 接口是【单个 API 接口或方法】:调用者的角度出发:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现
- 接口是【OOP 中的接口概念】:使用者和调用者的角度出发:那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数
DIP依赖反转原则
以下内容为我认为认知得到加深的内容:
DIP依赖反转原则的定义是什么
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions),所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层
所谓反转并不是指反过来低层要依赖高层,而是二者都依赖抽象,这里的反转指的是原本高层依赖低层,在DIP下实际上是规范低层次模块的设计。低层次模块提供的接口要足够的抽象、通用,在设计时需要考虑高层次模块的使用种类和场景。明明是高层次模块要使用低层次模块,对低层次模块有依赖性。现在反而低层次模块需要根据高层次模块来设计,出现了「倒置」的显现
DIP原则解决什么问题
DIP用来指导框架设计,框架是高层,我们写的代码是低层,我们要按照通用的约定实现自己的底层代码,以满足高层的调用需求。例如:
- JVM是高层模块,我们编写的Java代码是底层模块。JVM和Java代码没有直接的依赖关系,两者都依赖同一个抽象,也就是class字节码。字节码不依赖具体的JVM虚拟机和Java语言,而JVM虚拟机和Java依赖字节码规范。 这样做的好处就是JVM与Java高度解耦,Java语言可以替换成Groovy、Kotlin,只要能编译为字节码,符合虚拟机的执行规范
- Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范
如何实现DIP原则
如果我们编写框架,就得让框架依赖的抽象规范明确,基于抽象而非实现编程;如果我们编写实现代码,要想让框架或者容器能调用我们的实现方法实现功能,我们就得依赖满足高层运行功能抽象规范去写自己的实现,也就是按照约定编程。
KISS保持简单原则
以下内容为我认为认知得到加深的内容:
KISS保持简单原则的定义是什么
KISS原则:Keep It Simple and Stupid。翻译成中文就是:尽量保持简单,这里的简单并不是代码行数越少就越简单,还要考虑逻辑复杂度、实现难度、代码的可读性,还有一点就是本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则
KISS原则解决什么问题
KISS 原则是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
如何实现KISS原则
KISS原则其实本来就比较主观,不过还是有一些验证方式的:
- 不要使用同事可能不懂的技术来实现代码。例如复杂的正则表达式,还有一些编程语言中过于高级的语法等。如果想用,培训一下大家
- 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
- 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
主观验证的方式就是代码CR,让大家看看是否满足。
YAGNI勿过度设计原则
以下内容为我认为认知得到加深的内容:
YAGNI勿过度设计原则的定义是什么
不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计,当然这并不意味着我们不要预留代码扩展点
YAGNI原则解决什么问题
防止项目中代码冗余,防止降低代码的可读性和可维护性
如何实现YAGNI原则
感觉未来要实现的地方,预留扩展点,先不要实现。
DRY勿重复编码原则
以下内容为我认为认知得到加深的内容:
DRY勿重复编码原则的定义是什么
就是字面意思:不要写重复的代码。但这里的重复有不同的含义,包括:实现逻辑重复(代码写重复了)、功能语义重复(方法的用处相同)、代码逻辑重复(方法内代码块重复执行)
DRY原则可以提升代码的复用性么?
- 代码复用表示一种行为:我们在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用,DRY原则是一条原则要保证的是不要写重复的代码,但并不意味着DRY就提高了代码的可复用性
如何提高代码复用性
- 减少代码耦合,对于高度耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,要尽量减少代码耦合。
- 满足单一职责原则,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
- 模块化,模块不单单指一组类构成的模块,还可以理解为单个类、函数。要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
- 业务与非业务逻辑分离,越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,需要将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
- 通用代码下沉,从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,通常只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
- 继承、多态、抽象、封装,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
- 应用模板等设计模式,一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
DRY原则解决什么问题
减少项目中的重复功能代码,提高代码的可读性和可维护性
如何实现DRY原则
实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则,可以通过更细粒度的SRP改造来去除重复代码。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执行重复也算是违反 DRY 原则,后边这两种情况需要通过重构去除重复功能方法、逻辑代码块以及重构来满足DRY
LOD迪米特最小知道法则
以下内容为我认为认知得到加深的内容:
LOD迪米特最小知道法则的定义是什么
每个模块(unit)只应该了解那些与它关系密切的模块的有限知识(knowledge)也即每个模块只和自己的朋友说话,不和陌生人说话,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)
LOD迪米特法则解决什么问题
让代码尽量高内聚,低耦合,重点侧重低耦合,设计类间关系时要多加小心,防止过度依赖。目的也是提高代码的可读性和可维护性
如何实现LOD法则
- 不该有直接依赖关系的类之间,不要有依赖。可以通过调整方法,例如依赖的类有5个属性只用到其中2个,那么方法不要传入整个依赖类,而是把这两个属性提出来当做参数
- 有依赖关系的类之间,尽量只依赖必要的接口。可以通过ISP隔离接口,然后通过基于接口而非实现的方式将依赖注入,这样就能只依赖必要接口了。
经典设计原则横向对比
这里对所有涉及到的原则和法则做一个横向总结,这里的接口指OOP中的接口或抽象类。
设计原则 | 应用范围 | 看待视角 | 解决什么问题 |
SRP单一职责原则 | 模块、类(接口)、方法 | 实体自身视角 | 提高代码的内聚性,可复用性、可读性、可维护性 |
OCP开闭原则 | 模块、类(接口)、方法 | 实体自身视角 | 提高代码的可扩展 |
LSP里氏替换原则 | 类(接口) | 父子间关系视角 | 指导子类设计、继承设计 |
ISP接口隔离原则 | 接口 | 实体间关系视角 | 降低调用者或使用者依赖,提升接口的可复用性、可读性、可维护性 |
DIP依赖反转原则 | 模块 | 框架设计视角 | 指导框架设计 |
KISS保持简单原则 | 方法 | 全局视角 | 提高代码的简洁性、可读性、可维护性 |
YAGNI勿过度设计原则 | 类(接口)、方法 | 全局视角 | 不过度设计只预留扩展防止降低代码的可读性和可维护性 |
DRY勿重复编码原则 | 类(接口)、方法 | 全局视角 | 去除重复代码 防止降低代码的可读性和可维护性 |
LOD迪米特法则 | 类(接口) | 实体间关系视角 | 防止类间过度依赖,降低代码的耦合性,提高代码的可读性和可维护性 |
总结一下
其实无论是SOLID、还是KISS、YAGNI、DRY以及LOD,都是服务于写出高质量代码。简单而言就是写出:可维护性、可读性、可扩展性、灵活性、简洁性(简单、复杂)、可复用性、可测试性强的高质量代码。可读性、可维护性的一个基本设计就是写出高内聚、低耦合的代码,其中SRP服务于高内聚,ISP和LOD服务于低耦合。毋庸置疑OCP服务于代码的扩展性,LSP和DIP场景就比较具体了:LSP用于指导父子关系设计,DIP用于指导框架设计。而KISS、YAGNI以及DRY都是从代码的整体视角去告诉我们写代码要尽量简单明了,不要写现在没用或重复的代码,这样代码的可读性和可维护性才高。