设计模式之七大设计原则
单一职责原则(SRP)
如何理解单一职责原则
- 单一职责原则:Single Responsibility Principle, 缩写SRP。
- 意思:一个类或模块只负责完成一个职责(或者功能)。
- 单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
如何判断一个类的职责是否足够单一
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数、函数或者属性过多;
- 类依赖的其他类过多,或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性。
类的职责是否设计得越单一越好
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开闭原则(OCP)
如何理解“对扩展开放、修改关闭”?
- 开闭原则:Open Closed Principle,简称为OCP。
- 描述:软件实体( 模块、类、方法等)应该“对扩展开发、对修改关闭”。
- 解释:添加一个新的功能,应该是通过在已有代码的基础上扩展代码(新增模块,类,方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式完成。
- 注意点:
- 开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
- 同样的代码改动,在粗代码粒度下,可能被认定为“修改”,在细代码粒度下,可能又被认定为”扩展“。
如何做到“对扩展开放、修改关闭”
- 我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
- 很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
里氏替换原则(LSP)
如何理解“里氏替换原则"
- 里氏替换原则:Liskov Substitution Principle,缩写为LSP。
- 描述:子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
- 虽然从定义描述和代码实现上来看,多态和里氏替换原则优点类似,但它们关注的角度是不一样的。多态是面向编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里氏替换原则是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
哪些代码违背了LSP
-
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义了父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
-
子类违背父类声明要实现的功能
- 如果父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
-
子类违背父类对输入、输出、异常的约定
- 在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
- 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
- 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
-
子类违背了父类注释中所罗列的任何特殊说明
- 父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
接口隔离原则(ISP)
如何理解“接口隔离原则”
- 接口隔离原则:Interface Segregation Principle,缩写为ISP。
- 描述:客户端不应该被强迫依赖它不需要的接口。其中的客户端可以理解为接口的调用者或者使用者。
- 接口理解为下面三种东西:
- 一组API接口集合
- 单个API接口或函数
- OOP中的接口概念
- 把“接口”理解为一组API接口集合
- 可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
- 单个API接口或函数:
- 部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
- OOP中的接口概念:
- 理解为面向对象编程语言中的接口语法,那接口的设计尽量要单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
接口隔离原则与单一职责原则的区别
- 单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
依赖反转原则(DIP)
控制反转
- 控制反转:Inversion Of Control, 缩写为IOC。
- 这里的“控制 ”指的是对程序员执行流程的控制,而“反转”指的是没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员"反转"到了框架。
- 实现控制反转的方法有很多,有依赖注入等方法,所以控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
依赖注入
- 依赖注入:Dependency Injection,缩写为DI。
- 描述:依赖注入和控制反转相反,它是一种具体的编程技巧。不通过new()的方式在类内部创建依赖类的对象,而是将依赖的对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
依赖注入框架
-
依赖注入框架:DI Framework
-
我们可以通过依赖注入框架提供的扩展点,简单配置一下所有需要的类以及类与类之间依赖关系,就可以实现由框架自动创建对象、管理对象的声明周期、依赖注入等原本需要程序员来做的事情。
依赖反转原则
- 依赖反转原则:Dependency Inversion Principle, 缩写为DIP。
- 中文有时叫做依赖倒置原则。
- 这条原则跟控制反转优点类似,主要用来指导框架层面的设计。高层模块不依赖底层模块,他们共同依赖同一个抽象,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
KISS、YAGNI原则
如何理解“KISS”原则
- KISS原则:Keep It Simple and Stupid。
- 描述:尽量保持简单。KISS原则是保持代码可读和维护的重要手段。代码足够简单,也就意味着很容易懂,bug比较难隐藏,即便出现bug,修复也比较简单。
代码行数越少就越“简单”吗
- KISS原则是保持代码可读和可维护的重要手段。KISS原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑代码的复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背KISS原则。除此之外,同样的代码,在某个业务场景下满足KISS原则,换一个应用场景可能就不满足了。
如何写出满足KISS原则的代码呢
- 不要使用同事可能不懂的技术来实现代码。比如正则表达式,一些编程语言中过于高级的语法等。
- 不要重复造轮子,要善于使用已有的工具类库。经验证明,自己去实现这些类库,出bug的概率会更高,维护的成本也比较高。
- 不要过度优化代码。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替if-else、使用一些过于底层的函数等)来优化代码、牺牲代码的可读性。
YAGNI跟KISS说的是一回事吗
- YAGNI:You Ain't Gonna Need It。
- 描述:不会需要它。不要去设计当前用不到的功能;不要去编写当前用不到的代码。核心思想:不要做过度设计。
- 比如:不要在项目中提前引入不需要依赖的开发包,这样是违反YAGNI原则的。
DRY原则
DRY原则
-
DRY原则:Don't Repeat Yourselt, 简称DRY。
-
描述:不要重复自己,理解为不要写重复的代码。
-
三种典型的代码重复情况:实现逻辑重复、功能语义重复和代码执行重复。
-
实现逻辑重复,但功能语义不重复的代码。并不违反DRY原则。实现逻辑不重复,但功能语义重复的代码,也算违反DRY原则。代码执行重复也算违反DRY原则。
什么是代码复用性
- 区分三个概念:代码复用性(Code Reusability)、代码复用(Code Resue)和DRY原则。
- 代码复用表示一种行为:在开发新功能的时候,尽量复用已经存在的代码。
- 代码的可复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用。
- DRY原则是一条原则:不要写重复的代码。
- 首先,“不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用是两个概念。
- 其次,“复用”和“可复用性”关注角度不同。代码“可复用性“是从代码的开发者的角度来讲的,”复用“是从代码的使用者的角度讲的。
怎么提高代码复用性
- 减少代码耦合
- 对于高度耦合的代码,当我们希望复用其中一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高低耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。
- 满足单一职责原则
- 如果职责不够单一,模块、类设计的大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合性。越细粒度的代码,代码的通用性会越好,越容易被复用。
- 模块化
- 这里的模块,不单单指一组类的模块。还可以理解为单个类、函数。善于将功能独立的代码,封装成模块。
- 业务与非业务逻辑分离
- 越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
- 通用代码下沉
- 从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计的足够可复用。
- 继承、多态、抽象、封装
- 利用继承,可以将公共的代码抽取到父类,子类复用父类的方法和属性。
- 利用多态,可以动态地替换一段代码的逻辑,让这段代码可复用。
- 越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
- 应用模板等设计模式
- 一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
迪米特法则
何为“高内聚、松耦合”
- 高内聚、松耦合是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。
- 所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。修改集中,维护容易。
- 所谓松耦合,在代码中,类与类之间的依赖关系简单清晰。即时两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。
“迪米特法则“理论描述
- 迪米特法则:Law of Demeter,缩写LOD。
- 描述:每个模块只应该了解那些与它关系密切的模块的优先知识。或者说,每个模块只和自己的朋友说话,不和陌生人说话。
- 不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。
- 迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
引用:
极客时间《设计模式之美》