再看面向对象的六原则

在学习 spring 的时候看到它利用了许多设计模式,如代理,单例,策略,原型等,感到有继续学习设计模式,并且联系实践的必要了。

目前看《设计模式之禅》,这本书关于面向对象的六个原则提出了更加深刻和具有实践性的见解,颠覆了我以往的看法,这次跟着详细学习一次。目标不是学究似的抓准确定义,而是看对实践有无新的启迪。认识世界的目的终究是改造世界。

单一职责原则

单一职责原则(Single Responsibility Principle),表面上看起来内涵并不多——好好分工,各司其职,从而保证维护性,扩展性,解耦嘛。但是这里有两个问题——它的主体是谁?也就是说,是谁要承担这里所谓的单一职责?是类,接口还是方法?另一个问题是,职责该如何划分?比如打电话是一个职责,它继续细分不是还能够分成电话的接通,通信,挂断吗?这些过程不是还能够分成更多部分吗?

关于单一职责的主体问题,答案是均有,但是主要是接口和方法,在生产实践中,对类的设计通常是不强求满足单一职责原则的——如果每个类都只负责一个职责,那类的数量以及关系的复杂度将会严重上升,因此一般是让接口满足单一职责,面向接口(抽象层)编程嘛!至于方法的单一职责,这应当是无需强调的——对方法/函数的使用,要么是利用其返回值,要么利用其副作用,如若均有则会让代码不清晰,阅读困难,难以通过方法/函数名直接获取其作用等。

职责的划分则是一个更重要的问题,我认为真正的单一“职责”只存在于编程语言的“原子”,即我们一般能接触到的最底层的形式——单行表达式(就本质来说,这职责还能够继续细分,划分成机器语言,微代码的执行等,乃至更加底层的物理领域,层层的抽象)。单一职责原则中所说的职责和前面所述的“职责”,显然是更加接近自然语言中所提到的“职责”的。

因此,我认为对职责的划分是一个广泛和复杂的问题,问题领域(即代码所面临的场景),划分的根据,代码编写者的实践经验,语言限制,甚至硬件限制等都可能对职责的划分做出影响。并且任一职责一般来说都是非“单一”的,其应当能够继续细分,形成一个树状结构。比如上面所说的打电话,可分为接通,通信,挂断三个步骤,这是按次序或步骤进行职责划分,但是这里又可以看到,接通和挂断是和协议相关的,而通信则是和接通、挂断完全无关的——我说什么话和用电信还是联通没有任何关系,因此这里又可以将接通和挂断综合为一个职责……最终究竟采用何种抽象可能都能完成任务,只不过其的维护性,扩展性,抽象性等需要在实践中才能认知到了。但是一定的场景下一定有一个相应的最佳实践(当然,绝对和相对的关系),只不过归纳出一般论是困难的。

单一职责原则原话的解释是There should never be more than one reason for a class to change。老实说难以理解,change 是什么意思?改变状态?副作用??do something?但是这里也可以看到,其主体是类,这在生产实践中通常是难以遵守的。

对单一职责原则,我认为这里仅需要认知的是,其一般适用于接口,设计接口时一定要满足单一职责原则,类则尽量进行满足。更加丰富的结论要在积累足够感性经验后才能获得。

里氏替换原则

下面所说的父类既可以是类,也可以是抽象类,接口,子类也同样如此。

这个原则表面上仍然是容易理解的——能用父类的地方,其子类一定要能够透明地(对程序员不可见)使用且不产生问题。这仍旧是对现实关系的抽象——兔子是动物,要动物的地方可以给一个兔子,但是要兔子的地方就不能给一个动物——除非它就是兔子。子类一定父类,父类不一定子类。这里的“是”指逻辑上的属种关系。

子类在拥有父类的特性的基础上,要能够有自己特定的特性。但这种特性是不违反父类特性的基础上的。在实践中这既是优点也是缺点,优点在于能够重用,容易对父类进行扩展;缺点在于,子类必须继承父类所有方法和域(当然,只有 public 和 protected 的方法和域对子类可见),实现父类中所有方法,代码灵活性降低,且耦合性增强了——一旦父类要进行更改,所有子类都要进行更改。

在进行编程时,应当使用父类或接口作为声明类型,这时候父类或接口就像一个契约(依赖倒置原则也对其起作用),如若发现某种情况下无法进行这样的使用,则说明里氏替换原则被违反了,应当进行重构。

要满足里氏替换原则,子类对父类的方法进行覆盖或重载时,只能够将参数进行扩大而不能缩小,只能将输出结果进行缩小而不能扩大。这里的扩大是指从子类到父类,比如从 HashMap 到 Map,缩小指从父类到子类,如从 Map 到 HashMap。前者设计是为了子类对覆盖的方法进行重载时能够不覆盖父类的实现,否则里氏替换原则就要被破坏了——子类分明没有重写父类的方法,调用的时候却跑了子类的逻辑而非父类的逻辑!一个代码示例见下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 这个示例
class Father {
// 父类返回较大
public void doSomething(List<Integer> list) {
System.out.println("父类被调用");
}
}

class Son extends Father{
// 子类返回较小,不满足上面的要求
public void doSomething(ArrayList<Integer> list) {
System.out.println("子类被调用");
}
}
public class Client {
public static void main(String[] args) {
ArrayList<Integer> tmp = new ArrayList<>();
Father obj = new Son();
obj.doSomething(tmp);
}
}
// 这里的输出是有趣的——当数组使用 List 声明类型,无论何时都是父类被调用;数组使用 ArrayList 声明类型时,obj 的声明类型是 Father 时父类被调用,是 Son 时子类被调用,里氏替换原则不满足了——能用父类的地方用子类,结果和预想的不同了(预想的是应当进入父类的方法,或者是子类所重写的父类的方法)。这是令人迷惑的。

后者设计则是协变机制。这里不多做介绍。

这个原则是非常迷惑的,但若是遵循面向抽象层编程的规范,一般来说不会出现这个问题。

保证里氏替换原则能够达到这样的效果——即使新增加子类,原有的使用该类父类的代码都可以继续运行而不会出现问题。这保证了可维护性和扩展性。

依赖倒置原则

依赖倒置原则包括三层含义——

  • 高层模块(的实现)不应当依赖底层模块(的实现),两者都应当依赖抽象。
  • 抽象不应当依赖于细节。
  • 细节应当依赖于抽象。

这个原则实际上就是要求我们面向接口编程。第一层含义表示,高层模块对底层模块进行使用时,不应当依赖底层模块的具体实现,而是依赖底层模块所做的抽象,在 Java 中,这种抽象是接口或抽象类。至于后两个含义,细节和抽象的实质和关系,我认为之前理解的已经很清楚了。

依赖倒置原则反映到使用 Java 语言中,表现为如下几点——

  • 模块间通过抽象(接口,抽象类)进行依赖,而非是实现类之间进行直接依赖。
  • 接口和抽象类的实现不依赖于实现类。
  • 实现类的实现依赖于接口或抽象类。

就如上面的里氏替换原则所提到的,这里的抽象就像契约,其进行的约束不仅是对代码,更是对程序员。通过接口或抽象类进行规范能够极大地提升可维护性,扩展性等。但是现实世界终究是抽象依赖于细节的,很多时候我们得先把正着把路走一遍,吸收够足够感性经验了,才能化为理性认识,把抽象抽取出来,用在之后的实践中。从感性认识到理性认识,再反作用于感性认识,由此螺旋上升,认识是这样的规律,这个原则也是这样的规律。

接口隔离原则

  • 客户端不应当依赖它不需要的接口。
  • 类间的依赖关系应当建立在最小的接口上。

该原则中所谓的“接口”并非指的是语言中的 interface,而是某种更为抽象的东西。接口分为类接口和实例接口,前者为 interface,后者为实例对象。

接口隔离原则也是容易理解的——只取所需要的依赖,不要任何多余的东西。在 Java 中,对这个原则的实践就和它的描述一样一致。接口隔离原则应当和单一职责原则结合使用——实现类只拿自己所需的依赖,每个接口在保持单一职责的前提下最小化。

存在这样的情况,即一个接口的设计满足单一职责原则,但是不满足接口隔离原则——很多模块对于这个接口的使用只是使用其的一部分,然后被迫使用文档而非语言特性进行约束,要求不使用某些方法。这种情况下就可以按照隔离原则将这个接口拆分成多个相互独立的接口。但是这种拆分仍旧应当满足单一职责原则。

要满足接口隔离原则,一个好的实践是尽量减少 public 的方法/域成员,也就是说做尽量少的“承诺”,减少与外界的交互。同时也应当认识到,接口的拆分很大程度上是取决于具体业务的。

迪米特法则

迪米特法则是关于类之间的耦合的——一个类对于自己相耦合的类应当知道的最少。具体来说,类只和自己的直接朋友交流。这同样要求——尽量减少耦合的类的数量;尽量削弱耦合关系——对该类进行改变时,其它相耦合的类的改变最小化。

朋友就是与该类相互有耦合关系的类,组合,聚合,依赖等关系都属于在内。方法中的输入输出参数中的类为成员朋友类,但是出现在方法体中的类不属于朋友

开闭原则

对扩展开放,对修改封闭,一切设计模式归根结蒂都是要满足这个原则,只因它是最基础的原则。可以认为前五个原则都是开闭原则在某个具体方面上的表现,或者说是具体与抽象的关系。

软件产品在它的生命周期内是时时刻刻在变化的,如何保证进行变化时能对原有代码不做大的改动是一个至关重要的问题,这个原则就要求我们尽量通过扩展而非修改的方式实现变化。

修改分为三类——

  • 逻辑变化

单纯一个逻辑的变化,不涉及其它模块,这一般能通过修改原有类的方法来解决。

  • 模块变化

模块变化可以认为是模块提供的接口发生的变化,低层次的模块的变化必定会影响高层次模块。

  • 视图变化

视图层的变化可能会引起严重的连锁反应,暂且不表。(前后端分离好哇!)

对这个原则的实现,在 Java 编程中的一个基本要求是,接口应当稳定可靠,不经常发生变化。如果必须要进行修改,对接口的修改是最后选择。

满足开闭原则要求尽量少修改历史代码——所有已经投产的代码都是有意义的(当然,总有特例存在),对历史代码的修改可能会让测试人员重新编写测试,原有的代码的经过实践保证的健壮性需要被重新验证了。这可能会产生很坏的后果。且能够通过扩展来实现变化,能够减轻编码人员的压力——不用在旧代码的海洋(或者是、**)里四处游弋,寻找需要修改的地方了。

OOP 的六大原则,归根结底就是要求系统能够拥抱变化,而实现其的方法并不局限于这六大原则。二十三种设计模式是对这六个原则进行应用,针对特定场景的“样板戏”,对其的学懂弄通是大有益处的——模仿样板戏来进行自己的创造,甚至开发出新的样板戏,对设计模式的学习也能够使对这六大原则认识更清楚…这就是接下来的任务了。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!