设计模式之美
- 代码质量的评价标准
- 学习重点
- 面向对象编程
- 充血模型、贫血模型、领域驱动设计
- 设计原则
- 基于接口/抽象而非实现编程
- 组合优于继承
- 单一职责原则(Single Responsibility Principle, SRP)
- 开闭原则(Open Closed Principle, OCP)
- 里氏替换原则(Liskov Substitution Principle, LSP)
- 接口隔离原则(Interface Segregation Principle, ISP)
- 依赖反转原则(Dependency Inversion Principle, DIP)
- KISS原则(Keep It Simple and Stupid)
- YAGNI(You Ain't Gonna Need It)
- DRY(Don't Repeat Yourself)
- 迪米特法则(Law of Demeter, LOD)
- 高内聚、松耦合
- 代码重构
代码质量的评价标准
- 可维护性(maintainability):在不破坏原有代码设计、不引入新的bug的情况下,能够快速地修改或者添加代码
- 易维护 — 分层清晰、模块化、高内聚低耦合、基于接口而非实现编程的设计原则;与项目代码量的多少、业务的负责程度、使用技术的复杂程度、文档是否全面、团队成员的开发水平等诸多因素有关;有较强的主观性
- 可读性(readability):是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等
- 可扩展性(extensibility):“对修改关闭,对扩展开放”
- 灵活性(flexibility):易扩展、易复用、易用
- 简洁性(simplicity):KISS原则—“Keep it simple, stupid”
- 可复用性(reusability):DRY原则—“Don’t repeat yourself”
- 可测试性(testability)
学习重点
面向对象编程
以类或对象作为组织代码的基本单元
四大特性:封装、抽象、继承、多态
封装(Encapsulation)
封装也被称为信息隐藏或数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(函数)来访问内部信息或数据。 对于封装这个特性,我们需要编程语言本身提供一定的语法机制——访问权限控制。
封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
抽象(Abstraction)
抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性。由于类的方法是通过编程语言中的“函数”来实现的,通过函数包裹具体的实现逻辑本身就是一种抽象,所以类本身就满足抽象特性。
抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。
继承(Inheritance)
继承是用来表示类之间的is-a关系。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如 Java 使用 extends 关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用 parentheses (),Ruby 使用 <。不过,有些编程语言只支持单继承,不支持多重继承,比如 Java、PHP、C#、Ruby 等,而有些编程语言既支持单重继承,也支持多重继承,比如 C++、Python、Perl 等。
继承主要是用来解决代码复用的问题。
多态(Polymorphism)
多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
实现方式
- 继承+方法重写
- 接口类语法(C++不支持)
- duck-typing语法(Python、JavaScript等动态语言):只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系
面向对象 vs 面向过程
面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。
抽象类 vs 接口
语法特性
-
抽象类
- 抽象类不允许被实例化,只能被继承
- 抽象类可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现(抽象方法)
- 子类继承抽象类,必须实现抽象类中的所有抽象方法
-
接口
-
接口不能包含属性(即成员变量)
-
接口只能声明方法,方法不能包含代码实现
Java 8中接口可以用使用关键字default来实现一个默认方法
-
类实现接口时,必须实现接口中声明的所有方法
-
设计关系
子类与抽象类—— is-a关系
实现与接口—— has-a关系(协议)/ behave like
作用
抽象类更多的是为了代码复用,而接口则更侧重于解耦,隔离接口和具体实现,提高代码的扩展性
抽象类模拟接口
class Strategy { // 用抽象类模拟接口
public:
~Strategy();
virtual void algorithm()=0; //抽象方法
protected:
Strategy();
};
普通类模拟接口
public class MockInteface {
protected MockInteface() {} //避免被实例化
public void funcA() {
throw new MethodUnSupportedException();
} //模拟无代码实现的方法,强迫子类重写该方法
}
充血模型、贫血模型、领域驱动设计
只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。贫血模型分离了数据和方法,符合面向过程编程风格。
充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。
领域驱动设计,即 DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。
充血模型 vs 贫血模型
实际上,基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 三层架构分层的。Controller 层还是负责暴露接口,Repository 层还是负责数据存取,Service 层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在 Service 层。
在基于贫血模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑,业务逻辑集中在 Service 类中。
在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 相当于贫血模型中的 BO,但它是基于充血模型开发的,既包含数据,也包含业务逻辑,而 Service 类则变得非常单薄。
总结而言,基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重 Domain。
设计原则
基于接口/抽象而非实现编程
-
函数的命名不能暴露任何实现细节。e.g. uploadToAliyun() --> upload()
-
封装具体的实现细节。
-
为实现类定义抽象的接口,与特定实现有关的方法不要定义在接口中。具体的实现类都依赖于统一的接口定义,使用者依赖接口,而不是具体的实现类来编程。
接口的定义只表明做什么,而不是怎么做
组合优于继承
继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段来解决继承存在的问题。
public interface Flyable {
void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); //组合
private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
//... 省略其他属性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
单一职责原则(Single Responsibility Principle, SRP)
一个类或者模块只负责完成一个职责(或者功能)。 单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
判定法则
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,需要考虑对类进行拆分;
- 私有方法过多,需要考虑能否将私有方法独立到新的类中,设置为public方法以供更多类使用,从而提高代码的复用性;
- 难以使用业务名词给类命名,说明类的职责定义得可能不够清晰;
- 类中大量的方法都在集中操作类中的某几个属性,可以考虑将这几个属性和对应的方法拆分出来。
如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开闭原则(Open Closed Principle, OCP)
模块、类、方法等应该“对扩展开放、对修改关闭”。 添加新的功能时,应在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码。然而,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
不破坏原有代码的正常运行,不破坏原有的单元测试,就可以说这是一个合格的代码改动。
里氏替换原则(Liskov Substitution Principle, LSP)
子类对象(object of subtype/derived class)能够替换程序中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变且正确性不被破坏。
实际上,里氏替换原则有另外一个更有指导意义的描述——“Design By Contract”(即“按照协议来设计”)。设计子类时要遵守父类的行为约定(或协议),包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
使用父类的单元测试验证子类的代码,若某些单元测试运行失败,则子类的设计可能违背了里氏替换原则。
接口隔离原则(Interface Segregation Principle, ISP)
接口的调用者或使用者不应该被强迫依赖它不需要的接口。 具体而言,“接口”指的是一组API接口集合、单个API接口或函数、OOP中的接口概念。
在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
依赖反转原则(Dependency Inversion Principle, DIP)
高层模块(high-level modules)不要依赖底层模块(low-level)。 高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。所谓高层模块和低层模块的划分,简单来说就是调用者属于高层,被调用者属于低层。
KISS原则(Keep It Simple and Stupid)
尽量保持简单
YAGNI(You Ain’t Gonna Need It)
不要做过度设计
DRY(Don’t Repeat Yourself)
不写重复的代码。重复包括实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复、但功能语义不重复的代码并不违反DRY原则;实现逻辑不重复但功能语义重复的代码违反DRY原则;代码执行重复也违反了DRY原则。
迪米特法则(Law of Demeter, LOD)
每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。
高内聚、松耦合
高内聚是指相近的功能应该放到同一个类中,不相近的功能放在不同类中;松耦合是指类与类之间的依赖关系简单清晰。
【单一职责原则】是从自身功能出发,实现高内聚,低耦合
【接口隔离原则】和【基于接口而非实现编程】是从调用者出发,实现低耦合
【迪米特法则 】是从类关系出发,实现低耦合
代码重构
目的
在不改变软件的可见行为的情况下,使其更易理解、修改成本更低
对象
大型重构指的是对顶层代码设计的重构,包括系统、模块、代码结构、类与类之间的关系等的重构。重构的手段有分层、模块化、解耦、抽象可复用组件等,需要借助设计思想、原则、模式等理论知识。此类重构涉及的代码改动较多、影响面较大,所以难度较大、耗时较长、引入bug的风险也相对较大。
小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,如规范命名、规范注释、消除超大类或函数、提取重复代码等。此类重构修改的地方较集中、简单,可操作性强,耗时较短,引入bug的风险相对较小。
单元测试 vs 集中测试
集中测试的测试对象是整个系统或某个功能模块(如测试用户注册、登录功能是否正常),是一种端到端的测试;而单元测试的对象是类或函数,用来测试一个类和函数是否按照预期逻辑执行,是代码层级的测试。
Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等
单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计,比如,JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖。