单一职责原则
单一职责原则的英文全称是 Single Responsibility Principle,简写为 SRP。它的英文描述是:A class or module should have a single responsibility。翻译成中文就是:一个类或者模块只负责完成一个职责(或者功能)。注意,这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。我们可以把模块看作比类更加抽象的概念,模块中包含多个类,多个类组成一个模块。
单一职责原则的定义描述非常简单,也不难理解。一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。那如何判定一个类的职责是否够单一呢?
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,那就可以考虑将这几个属性和对应的方法拆分出来。
为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开闭原则
开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。这个描述比较简略,如果我们详细表述一下就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
但遵循开闭原则也并不意味着不能修改代码,实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
实际上,开闭原则讲的就是代码的扩展性问题,如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。那如何才能写出扩展性好的代码呢?这就需要我们时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考下这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上。
还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
但是,即便我们对业务、对系统有足够的了解,也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些不一定发生的需求去提前买单,做过度设计。最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况或实现成本不高的扩展点,在编写代码时,事先做些扩展性设计。但对于一些不确定未来是否要支持的需求或实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。
里式替换原则
里式替换原则的英文全称是:Liskov Substitution Principle,简写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出,他是这么描述这条原则的:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
我们综合两者的描述,将这条原则用中文翻译出来就是:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
虽然这条原则从定义上来看,跟面向对象中的“多态”有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
里式替换原则规定:子类在设计时,要遵守父类的行为约定。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。为了更好地理解这句话,我举几个违反里式替换原则的例子来解释一下。
子类违背父类声明要实现的功能:比如,父类中提供的 sort() 排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sort() 函数后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
子类违背父类对输入、输出、异常的约定:比如,在父类中的某个函数约定获取数据为空的时候返回空集合(empty collection)。而子类重写后,获取不到数据返回 null。那子类的设计就违背里式替换原则。或者在父类中的某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出该异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
-
接口隔离原则
接口隔离原则的英文全称是 Interface Segregation Principle,简写为 ISP。它的英文描述是:Clients should not be forced to depend upon interfaces that they do not use。翻译成中文就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”可以理解为接口的调用者或使用者。
实际上,“接口”这个名词可以用在很多场合中。在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:
- 一组 API 接口集合:在设计微服务或类库接口时,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
- 单个 API 接口或函数:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。从这个角度看,接口隔离原则跟单一职责原则类似,但单一职责原则针对的是模块、类、接口的设计,而接口隔离原则更侧重于接口的设计,并且它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
- OOP 中的接口概念,也可以理解为面向对象编程语言中的接口语法。比如 Java 中的 interface。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
依赖反转原则
在讲“依赖反转原则”之前,我们先讲一讲“控制反转”。控制反转的英文全称是 Inversion Of Control,简写为 IOC。实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
接下来,我们再来看依赖注入。依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文全称是 Dependency Injection,简写为 DI。依赖注入用一句话来概括就是:我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。这样就提高了代码的扩展性,我们也可以灵活地替换依赖的类。
依赖反转原则的英文全称是 Dependency Inversion Principle,简写为 DIP。中文翻译有时候也叫依赖倒置原则。它的英文描述是:
High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
翻译成中文就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)要依赖抽象(abstractions)。
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。实际上,这条原则主要还是用来指导框架层面的设计。比如:Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。
按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。