设计模式之美-设计原则

什么是“高内聚,低耦合”

高内聚指所有相近的功能应该放在一个类中,不相近的功能应该区分出来,单一职责就是就可以很好的实现高内聚,低耦合指类与类之间的依赖应该简单清晰。依赖注入、接口隔离、基于接口而非实现编程,可以很好的实现低耦合。

高内聚用来指导类的设计,低耦合用来指导类之间的依赖关系,高内聚有助于松耦合,松耦合需要高内聚的支持。


单一职责原则 Single Responsibility Principle(SRP)

一个类或者模块只负责完成一个职责(或者功能)。

模块可以看做是比类更加抽象的的代码块,多个类组成一个模块。 单一职责原则主要用来约束我们不要设计大而全的类,要设计粒度小、功能单一的类。如果一个类包含了两个以上不相干的业务那么这个类的职责就不够单一。譬如(在用户模块中存在订单的业务)。

但大部分情况下,类里的方法是归为同一类功能,还是归为不相关的两类功能,并不是那么容易判定的。不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。一些侧面的判断指标更具有指导意义和可执行性,比如:

  • 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,就需要考虑对类进行拆分;
  • 私有方法过多,就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
  • 类中大量的方法都是集中操作类中的某几个属性。
  • 比较难给类起一个合适名字,很难用一个业务名词概括。

单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。


开闭原则 Open Closed Principle(OCP)

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。

开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。比如,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。


如何在开发中做到“开闭原则”

如果开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。要时刻具备扩展意识、抽象意识、封装意识。

即便对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。


里氏替换原则 Liskov Substitution Principle(LSP)

子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。最核心的就是理解“design by contract“,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。

哪些代码违反了”里氏替换原则“
  • 子类违背父类声明要实现的功能
    譬如:父类中提供的 SortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 SortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
  • 子类违背父类对输入、输出、异常的约定
    譬如:在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
  • 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
  • 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
  • 子类违背父类注释中所罗列的任何特殊说明
    父类中定义的 Withdrawal() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 Withdrawal() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

接口隔离原则 Interface Segregation Principle(ISP)

尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。客户端不应该依赖它不需要的接口。

如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

接口隔离原则与单一职责原则的区别

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。


依赖反转原则 Dependency Inversion Principle (DIP)

高层模块要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象要依赖具体实现细节,具体实现细节依赖抽象。核心思想是:要面向接口编程,不要面向实现编程。细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。

迪米特原则 Law of Demeter (LOD)

又叫作最少知识原则(The Least Knowledge Principle,每个模块应该只关心自己密切相关的模块的有限知识。不应该直接有依赖的类之间不要有依赖,有依赖关系的类应该尽量只依赖必要的接口。


KISS 原则

KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。并不是代码数量越少就月简单,也并不越复杂的代码就违背了Kiss原则,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。

几个满足Kiss的方法

  • 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
  • code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化。

做开发的时候,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。


YAGNI 原则

YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。当然,这并不是说就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现。

KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。

设计原则本身没有对错,只有能否用对之说。不要为了应用设计原则而应用设计原则,在应用设计原则的时候,一定要具体问题具体分析。


DRY 原则

DRY 原则。它的英文描述为:Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。

几种的代码重复情况,分别是:

  • 实现逻辑重复
    函数中有有重复的代码,但是如果函数语义不重复就没有违反 DRY 原则。
  • 功能语义重复
    譬如两个函数都是判断ip是否正确,就算它们实现逻辑不一致,也算违反了 DRY 原则。
  • 代码执行重复
    相同的函数在一次请求里被重复执行且幂等,算违反了 DRY 原则。

DRY不是只代码重复,而是“语义”的重复,意思是指业务逻辑。例如由于沟通不足,两个程序员用两种不同的方法实现同样功能的校验。

什么是代码的复用性

首先来区分三个概念:代码复用性(Code Reusability)、代码复用(Code Resue)和 DRY 原则。
代码复用表示一种行为:在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:在编写代码的时候,让代码尽量可复用。

DRY 原则是一条原则:不要写重复的代码。从定义描述上,它们好像有点类似,但深究起来,三者的区别还是蛮大的。首先,“不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则跟代码的可复用性讲的是两回事。

其次,“复用”和“可复用性”关注角度不同。代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。比如,A 同事编写了一个 UrlUtils 类,代码的“可复用性”很好。B 同事在开发新功能的时候,直接“复用”A 同事编写的 UrlUtils 类。

尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug 会比从零重新开发要少。


备注

整理自王争《设计模式之美》设计原则篇

posted @ 2021-12-10 11:39  她微笑的脸  阅读(153)  评论(0编辑  收藏  举报