面向对象设计原则
一、概述
我们如何设计出“高内聚,低耦合”的软件呢,设计时我们应遵循以下设计原则:
设计原则名称 | 设计原则简介 | 重要性 |
---|---|---|
单一职责原则 | 类的职责要单一,不能将太多的职责放在一个类中。 | ★★★★☆ |
开闭原则 | 软件实体对扩展是开放的,但对修改是关闭的,即在不修改一个软件实体的基础上去扩展其功能。 | ★★★★★ |
里氏替换原则 | 在软件系统中,一个可以接受基类对象的地方必然可以接受一个子类对象。 | ★★★★☆ |
依赖倒转原则 | 要针对抽象层编程,而不要针对具体类编程。 | ★★★★★ |
接口隔离原则 | 使用多个专门的接口来取代一个统一的接口。 | ★★☆☆☆ |
组合/聚合复用原则 | 在系统中应该尽量多使用组合和聚合关联关系,尽量少使用甚至不使用继承关系。 | ★★★★☆ |
迪米特法则 | 一个软件实体对其他实体的引用越少越好,或者说如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,而是通过引入一个第三者发生间接交互。 | ★★★☆☆ |
二、设计原则
1. 单一职责原则
单一职责原则(SRP),英文全称是Single Responsibility Principle,该原则的思想是:系统中的每一个对象都应该只有一个单独的职责,而所有对象所关注的就是自身职责的完成。
SRP中,把职责定义为“变化的原因”。就一个类而言,应该仅有一个引起它变化的原因,如果你能想到多个原因去改变一个类,那么这个类就具有多于一个的职责。
每一个职责都是一个设计的变因,需求变化的时候,需求变化反映为类职责的变化。当系统中的对象都只有一个变化的原因的时候,就已经很好的遵循了SRP。如果一个类承担的职责过多,就等于把这些职责耦合在了一起。一个职责的变化就可能削弱或者抑制这个类其它职责的能力,这种设计会导致脆弱的设计。当变化发生的时候,设计会遭到意想不到的破坏。SRP让这个系统更容易管理维护,因为不是所有的问题都搅在一起。
SRP简单而直观,但在实际应用中很难实现。SRP中,把职责定义为“变化的原因”,这里说的“变化的原因”,只有实际发生时才有意义。可能预测到会有多个原因引起这个类的变化,但仅仅是预测,并没有真的发生,这个类仍可看做具有单一职责,不需要分离职责。如果分离,会带来不必要的复杂性。
SRP的尺度如何掌握,是不是应该百分之百的做到呢?原则还是需求导向,即需求决定设计。实际操作中,类设计时的职责划分和类粒度的确定不是一件很简单的事情,需要设计者经验的积累和对需求的仔细分析。
SRP是所有原则中最简单的,也是最基本的一个。运用这个原则,可以提高类的内聚性,有助于充分发挥面向对象编程语言的优势。
个人认为,该原则可以有效降低耦合,减少对不必要资源的引用。但后果是造成源文件增多,给管理带来不便,所以在实际应用中,可以对经常使用或经常需要改动的模块应用该原则。
2. 开闭原则
开闭原则(OCP),英文全称是Open for extention, Closed for modification Principle,即对扩展开放,对修改关闭原则。该原则的思想是:可以通过扩展来满足变化,而不需要修改代码,或者说在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展。
对扩展开放(Open for extension):这意味着模块的行为是可以扩展的。当应用的需求改变时,可以对模块进行扩展,使其具有满足改变的新行为。也就是说,我们可以扩展模块的功能。
对修改关闭(Closed for modification):对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。
2.1 如何实现OCP
在面向对象设计中,不允许修改的是系统的抽象层,而允许扩展的是系统的实现层。那么可以定义一个抽象层,只规定功能而不提供实现,实现通过定义具体类来完成。当需求变化时,不是通过修改抽象层来完成,而是通过重新定义抽象层的新实现来完成,即通过扩展来完成。换言之,定义一个一劳永逸的抽象设计层,允许尽可能多的行为在实现层被实现。
实现OCP的关键是抽象,抽象是面向对象设计的一个核心特征。
对一个事物抽象化,实质上是在概括归纳总结它的本质。抽象让我们抓住最最重要的东西,从更高一层去思考,这降低了思考的复杂度,我们不用同时考虑那么多的东西。换言之,我们封装了事物的本质,看不到任何细节。
在面向对象编程中,通过抽象类及接口,抽取了具体类的特征作为抽象层,相对稳定,不需更改,从而满足“对修改关闭”;而从抽象类或接口导出的具体类可以改变系统的行为,从而满足“对扩展开放”。
即使无法百分之百的做到OCP,但朝这个方向努力,可以显著改善一个系统的结构。另一方面,对于系统的每个部分都肆意地进行抽象也不是一个好主意,应该仅仅对程序中需求频繁变化的部分进行抽象。拒绝不成熟的抽象和抽象本身一样重要。
2.2 OCP的好处
- 通过扩展已有软件系统,可以提供新的行为,以满足对软件的新需求,使变化中的软件系统有一定的适应性和灵活性。
- 已有的软件模块,特别是最重要的抽象层模块不能再修改,这就使变化中的软件系统有一定的稳定性和延续性。
- 这样的系统同时也满足了可复用性与可维护性。
个人认为,OCP的关键是抽象。面向对象编程中,抽象的目的是创建一个固定却能够描述一组任意个可能行为的基类。而这一组可能的行为则表现为派生类。对于基类的更改是封闭的,所以它里边的方法一旦确定就不能更改(对接口里的方法进行更改将带来灾难性的后果)。模块通过抽象基类进行引用,对派生类的扩展并不影响整个模块,所以它是开放的。遵循OCP的代价也是昂贵的,创建正确的抽象是要花费开发时间和精力的,同时抽象也增加了软件设计的复杂性。因此有效的预知变化是OCP设计的要点,这需要我们进行适当的调查,提出正确的问题,并利用我们的经验和一般常识来做出判断。
3. 里氏替换原则
里氏替换原则(LSP),英文全称是Liskov Substitution Principle,Liskov是该替换原则的提出者。该原则的思想是:在任何父类出现的地方都可以用它的子类来替代,而不影响功能。
其实里氏替代原则的意思就是:同一个继承体系中的对象应该有共同的行为特征。里氏代换原则关注的是怎样良好地使用继承,也就是说不要滥用继承,它是继承复用的基石。
LSP是对OCP的扩展。如果要采用OCP必然用到抽象和多态,而这又离不开继承,LSP就是对如何良好继承提出了要求。
面向对象编程中,实现的LSP方法是面向接口编程:将公共部分抽象为接口或抽象类,通过提取接口或抽象类,在子类中通过覆写父类或实现接口的方法实现新的方式支持同样的职责。
LSP是关于继承机制的设计原则,违反了LSP就必然导致违反OCP。
LSP能够保证系统具有良好的拓展性,同时实现基于多态的抽象机制,能够减少代码冗余,避免运行期的类型判别。
4. 依赖倒转原则
依赖倒转原则(DIP),英文全称是Dependence Inversion Principle,该原则的思想是:要依赖于抽象,不要依赖于实现。
面向对象编程中,要针对接口编程,不要针对实现编程。也就是说应当使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来实现这些操作。要保证做到这一点,一个具体类应当只实现接口和抽象类中声明过的方法,而不要给出多余的方法。
以抽象方式耦合是依赖倒转原则的关键。抽象耦合关系总要涉及具体类从抽象类继承,并且需要保证在任何引用到基类的地方都可以改换成其子类,因此,里氏代换原则是依赖倒转原则的基础。
在抽象层次上的耦合虽然有灵活性,但也带来了额外的复杂性,如果一个具体类发生变化的可能性非常小,那么抽象耦合能发挥的好处便十分有限,这时可以用具体耦合反而会更好。
DIP虽然很强大,但却最不容易实现。因为依赖倒转的缘故,对象的创建很可能要使用对象工厂,以避免对具体类的直接引用,此原则的使用可能还会导致产生大量的类,对不熟悉面向对象技术的工程师来说,维护这样的系统需要较好地理解面向对象设计。
DIP假定所有的具体类都是会变化的,这也不总是正确。有一些具体类可能是相当稳定,不会变化的,使用这个具体类实例的应用完全可以依赖于这个具体类型,而不必为此创建一个抽象类型。
DIP与其它原则的关系:
开闭原则与依赖倒转原则是目标和手段的关系。如果说开闭原则是目标,依赖倒转原则是到达开闭原则的手段。如果要达到最好的开闭原则,就要尽量的遵守依赖倒转原则,依赖倒转原则是对抽象化的最好规范。
里氏代换原则是依赖倒转原则的基础,依赖倒转原则是里氏代换原则的重要补充。
5. 接口隔离原则
接口隔离原则(ISP),英文全称是Interface Separate Principle,该原则的思想是:不要强迫客户依赖于它们不用的方法,只给每个客户它所需要的接口,使用多个专门的接口比使用单一的总接口要好。
这里的“接口”有两种不同的含义:一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象;另外一种是指某种语言具体的“接口”定义,有严格的定义和结构。比如Java语言里面的interface结构。对于这两种不同的含义,ISP的表达方式以及含义都有所不同。
当我们把“接口”理解成一个类所提供的所有方法的特征集合的时候,这就是一种逻辑上的概念。接口的划分就直接带来类型的划分。这里,我们可以把接口理解成角色,一个接口就只是代表一个角色,每个角色都有它特定的一个接口,这里的这个原则可以叫做“角色隔离原则”。
如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是说,对不同的客户端,同一个角色提供宽窄不同的接口,也就是定制服务,个性化服务。就是仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来。
应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。这也是对软件实体之间通信的限制,但它限制的只是通信的宽度,就是说通信要尽可能的窄。
6. 组合/聚合复用原则
组合/聚合复用原则(CARP),英文全称是Composite/Aggregate Reuse Principle,该原则的思想是:在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用这些对象的目的。
聚合表示整体和部分的关系,表示“拥有”。如奔驰S360汽车,对奔驰S360引擎、奔驰S360轮胎的关系是聚合关系,离开了奔驰S360汽车,引擎、轮胎就失去了存在的意义。在设计中,聚合不应该频繁出现,这样会增大设计的耦合度。
组合则是一种更强的“拥有”,部分和整体的生命周期一样。组合的新的对象完全支配其组成部分,包括它们的创建和湮灭等。一个组合关系的成分对象是不能与另一个组合关系共享的。
如果两个类是“Has-a”关系应使用组合、聚合,如果是“Is-a”关系可使用继承。
通过组合/聚合复用的优点:
- 新对象存取成分对象的唯一方法是通过成分对象的接口。
- 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的。
- 这种复用支持包装。
- 这种复用所需的依赖较少。
- 每一个新的类可以将焦点集中在一个任务上。
- 这种复用可以在运行时间内动态进行,新对象可以动态的引用与成分对象类型相同的对象。
- 作为复用手段可以应用到几乎任何环境中去。
缺点:系统中会有较多的对象需要管理。
7. 迪米特法则
迪米特法则(LOD),英文全称是Law of Demeter,又称为“最少知识原则”。该原则的思想是:一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易。
迪米特法则是对软件实体之间通信的限制,它对软件实体之间通信的宽度和深度做出了要求。
迪米特法则的主要用意是控制信息的过载,在将其运用到系统设计中应注意以下几点:
- 在类的划分上,应当创建弱耦合的类。类之间的耦合越弱,就越有利于复用。
- 在类的结构设计上,每一个类都应当尽量降低成员的访问权限。一个类不应当public自己的属性,而应当提供取值和赋值的方法让外界间接访问自己的属性。
- 在类的设计上,只要有可能,一个类应当设计成不变类。
- 在对其它对象的引用上,一个类对其它对象的引用应该降到最低。
关于迪米特法则的其他表述:
- 只与你直接的朋友们通信。
- 不要跟“陌生人”说话。
- 每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。