- 面向对象设计原则之单一职责原则
单一职责原则是最简单的面向对象设计原则, 它用于控制类的粒度大小。 单一职责原则定义
如下: 单一职责原则(Single Responsibility Principle, SRP): 一个类只负责一个功能领域中的相
应职责, 或者可以定义为: 就一个类而言, 应该只有一个引起它变化的原因。
单一职责原则告诉我们: 一个类不能太“累”! 在软件系统中, 一个类( 大到模块, 小到方法)
承担的职责越多, 它被复用的可能性就越小, 而且一个类承担的职责过多, 就相当于将这些
职责耦合在一起, 当其中一个职责变化时, 可能会影响其他职责的运作, 因此要将这些职责
进行分离, 将不同的职责封装在不同的类中, 即将不同的变化原因封装在不同的类中, 如果
多个职责总是同时发生改变则可将它们封装在同一类中。
单一职责原则是实现高内聚、 低耦合的指导方针, 它是最简单但又最难运用的原则, 需要设
计人员发现类的不同职责并将其分离, 而发现类的多重职责需要设计人员具有较强的分析设
计能力和相关实践经验。
2.面向对象设计原则之开闭原则
开闭原则是面向对象的可复用设计的第一块基石, 它是最重要的面向对象设计原则。 开闭原
则由Bertrand Meyer于1988年提出, 其定义如下:
开闭原则(Open-Closed Principle, OCP): 一个软件实体应当对扩展开放, 对修改关闭。 即软件
实体应尽量在不修改原有代码的情况下进行扩展。
在开闭原则的定义中, 软件实体可以指一个软件模块、 一个由多个类组成的局部结构或一个
独立的类。
任何软件都需要面临一个很重要的问题, 即它们的需求会随时间的推移而发生变化。 当软件
系统需要面对新的需求时, 我们应该尽量保证系统的设计框架是稳定的。 如果一个软件设计
符合开闭原则, 那么可以非常方便地对系统进行扩展, 而且在扩展时无须修改现有代码, 使
得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。 随着软件规模越来越
大, 软件寿命越来越长, 软件维护成本越来越高, 设计满足开闭原则的软件系统也变得越来
越重要。
为了满足开闭原则, 需要对系统进行抽象化设计, 抽象化是开闭原则的关键。 在Java、 C#等
编程语言中, 可以为系统定义一个相对稳定的抽象层, 而将不同的实现行为移至具体的实现
层中完成。 在很多面向对象编程语言中都提供了接口、 抽象类等机制, 可以通过它们定义系
统的抽象层, 再通过具体类来进行扩展。 如果需要修改系统的行为, 无须对抽象层进行任何
改动, 只需要增加新的具体类来实现新的业务功能即可, 实现在不修改已有代码的基础上扩
展系统的功能, 达到开闭原则的要求。
3.面向对象设计原则之里氏代换原则
里氏代换原则由2008年图灵奖得主、 美国第一位计算机科学女博士Barbara Liskov教授和卡内
基·梅隆大学Jeannette Wing教授于1994年提出。 其严格表述如下: 如果对每一个类型为S的对
象o1, 都有类型为T的对象o2, 使得以T定义的所有程序P在所有的对象o1代换o2时, 程序P的
行为没有变化, 那么类型S是类型T的子类型。 这个定义比较拗口且难以理解, 因此我们一般
使用它的另一个通俗版定义: 里氏代换原则(Liskov Substitution Principle, LSP): 所有引用基类
( 父类) 的地方必须能透明地使用其子类的对象。
里氏代换原则告诉我们, 在软件中将一个基类对象替换成它的子类对象, 程序将不会产生任
何错误和异常, 反过来则不成立, 如果一个软件实体使用的是一个子类对象的话, 那么它不
一定能够使用基类对象。 例如: 我喜欢动物, 那我一定喜欢狗, 因为狗是动物的子类; 但是
我喜欢狗, 不能据此断定我喜欢动物, 因为我并不喜欢老鼠, 虽然它也是动物。
例如有两个类, 一个类为BaseClass, 另一个是SubClass类, 并且SubClass类是BaseClass类的子
类, 那么一个方法如果可以接受一个BaseClass类型的基类对象base的话, 如: method1(base),
那么它必然可以接受一个BaseClass类型的子类对象sub, method1(sub)能够正常运行。 反过来
的代换不成立, 如一个方法method2接受BaseClass类型的子类对象sub为参数: method2(sub),
那么一般而言不可以有method2(base), 除非是重载方法。
里氏代换原则是实现开闭原则的重要方式之一, 由于使用基类对象的地方都可以使用子类对
象, 因此在程序中尽量使用基类类型来对对象进行定义, 而在运行时再确定其子类类型, 用
子类对象来替换父类对象。
在使用里氏代换原则时需要注意如下几个问题:
(1)子类的所有方法必须在父类中声明, 或子类必须实现父类中声明的所有方法。 根据里氏代
换原则, 为了保证系统的扩展性, 在程序中通常使用父类来进行定义, 如果一个方法只存在
子类中, 在父类中不提供相应的声明, 则无法在以父类定义的对象中使用该方法。
(2) 我们在运用里氏代换原则时, 尽量把父类设计为抽象类或者接口, 让子类继承父类或实现
父接口, 并实现在父类中声明的方法, 运行时, 子类实例替换父类实例, 我们可以很方便地
扩展系统的功能, 同时无须修改原有子类的代码, 增加新的功能可以通过增加一个新的子类
来实现。 里氏代换原则是开闭原则的具体实现手段之一。
(3) Java语言中, 在编译阶段, Java编译器会检查一个程序是否符合里氏代换原则, 这是一个
与实现无关的、 纯语法意义上的检查, 但Java编译器的检查是有局限的。
4.面向对象设计原则之依赖倒转原则
如果说开闭原则是面向对象设计的目标的话, 那么依赖倒转原则就是面向对象设计的主要实
现机制之一, 它是系统抽象化的具体实现。 依赖倒转原则是Robert C. Martin在1996年
为“C++Reporter”所写的专栏Engineering Notebook的第三篇, 后来加入到他在2002年出版的经
典著作“Agile Software Development, Principles, Patterns, and Practices”一书中。 依赖倒转原则定
义如下: 依赖倒转原则(Dependency Inversion Principle, DIP): 抽象不应该依赖于细节, 细节应
当依赖于抽象。 换言之, 要针对接口编程, 而不是针对实现编程。
依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中, 尽量引用层次高的抽象层
类, 即使用接口和抽象类进行变量类型声明、 参数类型声明、 方法返回类型声明, 以及数据
类型的转换等, 而不要用具体类来做这些事情。 为了确保该原则的应用, 一个具体类应当只
实现接口或抽象类中声明过的方法, 而不要给出多余的方法, 否则将无法调用到在子类中增
加的新方法。
在引入抽象层后, 系统将具有很好的灵活性, 在程序中尽量使用抽象层进行编程, 而将具体
类写在配置文件中, 这样一来, 如果系统行为发生变化, 只需要对抽象层进行扩展, 并修改
配置文件, 而无须修改原有系统的源代码, 在不修改的情况下来扩展系统的功能, 满足开闭
原则的要求。
在实现依赖倒转原则时, 我们需要针对抽象层编程, 而将具体类的对象通过依赖注入
(DependencyInjection, DI)的方式注入到其他对象中, 依赖注入是指当一个对象要与其他对象发
生依赖关系时, 通过抽象来注入所依赖的对象。 常用的注入方式有三种, 分别是: 构造注
入, 设值注入( Setter注入) 和接口注入。 构造注入是指通过构造函数来传入具体类的对象,
设值注入是指通过Setter方法来传入具体类的对象, 而接口注入是指通过在接口中声明的业务
方法来传入具体类的对象。 这些方法在定义时使用的是抽象类型, 在运行时再传入具体类型
的对象, 由子类对象来覆盖父类对象。
5.面向对象设计原则之接口隔离原则
接口隔离原则定义如下:
接口隔离原则(Interface Segregation Principle, ISP): 使用多个专门的接口, 而不使用单一
的总接口, 即客户端不应该依赖那些它不需要的接口。
根据接口隔离原则, 当一个接口太大时, 我们需要将它分割成一些更细小的接口, 使用该接
口的客户端仅需知道与之相关的方法即可。 每一个接口应该承担一种相对独立的角色, 不干
不该干的事, 该干的事都要干。 这里的“接口”往往有两种不同的含义: 一种是指一个类型所具
有的方法特征的集合, 仅仅是一种逻辑上的抽象; 另外一种是指某种语言具体的“接口”定义,
有严格的定义和结构, 比如Java语言中的interface。 对于这两种不同的含义, ISP的表达方式以
及含义都有所不同:
(1) 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候, 这就是一种逻辑上的概
念, 接口的划分将直接带来类型的划分。 可以把接口理解成角色, 一个接口只能代表一个角
色, 每个角色都有它特定的一个接口, 此时, 这个原则可以叫做“角色隔离原则”。
(2) 如果把“接口”理解成狭义的特定语言的接口, 那么ISP表达的意思是指接口仅仅提供客户端
需要的行为, 客户端不需要的行为则隐藏起来, 应当为客户端提供尽可能小的单独的接口,
而不要提供大的总接口。 在面向对象编程语言中, 实现一个接口就需要实现该接口中定义的
所有方法, 因此大的总接口使用起来不一定很方便, 为了使接口的职责单一, 需要将大接口
中的方法根据其职责不同分别放在不同的小接口中, 以确保每个接口使用起来都较为方便,
并都承担某一单一角色。 接口应该尽量细化, 同时接口中的方法应该尽量少, 每个接口中只
包含一个客户端( 如子模块或业务逻辑类) 所需的方法即可, 这种机制也称为“定制服务”, 即
为不同的客户端提供宽窄不同的接口。
6.面向对象设计原则之合成复用原则
合成复用原则又称为组合/聚合复用原则(Composition/Aggregate Reuse Principle, CARP), 其定
义如下:
合成复用原则(Composite Reuse Principle, CRP): 尽量使用对象组合, 而不是继承来达到复
用的目的。
合成复用原则就是在一个新的对象里通过关联关系( 包括组合关系和聚合关系) 来使用一些
已有的对象, 使之成为新对象的一部分; 新对象通过委派调用已有对象的方法达到复用功能
的目的。 简言之: 复用时要尽量使用组合/聚合关系( 关联关系) , 少用继承。
在面向对象设计中, 可以通过两种方法在不同的环境中复用已有的设计和实现, 即通过组合/
聚合关系或通过继承, 但首先应该考虑使用组合/聚合, 组合/聚合可以使系统更加灵活, 降低
类与类之间的耦合度, 一个类的变化对其他类造成的影响相对较少; 其次才考虑继承, 在使
用继承时, 需要严格遵循里氏代换原则, 有效使用继承会有助于对问题的理解, 降低复杂
度, 而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度, 因此需要慎重使用继
承复用。
通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性, 因为继承会将基类的实
现细节暴露给子类, 由于基类的内部细节通常对子类来说是可见的, 所以这种复用又称“白
箱”复用, 如果基类发生改变, 那么子类的实现也不得不发生改变; 从基类继承而来的实现是
静态的, 不可能在运行时发生改变, 没有足够的灵活性; 而且继承只能在有限的环境中使用
( 如类没有声明为不能被继承) 。
7.面向对象设计原则之迪米特法则
迪米特法则来自于1987年美国东北大学(Northeastern University)一个名为“Demeter”的研究项
目。 迪米特法则又称为最少知识原则(LeastKnowledge Principle, LKP), 其定义如下:
迪米特法则(Law of Demeter, LoD): 一个软件实体应当尽可能少地与其他实体发生相互作
用。
如果一个系统符合迪米特法则, 那么当其中某一个模块发生修改时, 就会尽量少地影响其他
模块, 扩展会相对容易, 这是对软件实体之间通信的限制, 迪米特法则要求限制软件实体之
间通信的宽度和深度。 迪米特法则可降低系统的耦合度, 使类与类之间保持松散的耦合关
系。
迪米特法则还有几种定义形式, 包括: 不要和“陌生人”说话、 只与你的直接朋友通信等, 在迪
米特法则中, 对于一个对象, 其朋友包括以下几类:
(1) 当前对象本身(this);
(2) 以参数形式传入到当前对象方法中的对象;
(3) 当前对象的成员对象;
(4) 如果当前对象的成员对象是一个集合, 那么集合中的元素也都是朋友;
(5) 当前对象所创建的对象。
任何一个对象, 如果满足上面的条件之一, 就是当前对象的“朋友”, 否则就是“陌生人”。 在应
用迪米特法则时, 一个对象只能与直接朋友发生交互, 不要与“陌生人”发生直接交互, 这样做
可以降低系统的耦合度, 一个对象的改变不会给太多其他对象带来影响。
迪米特法则要求我们在设计系统时, 应该尽量减少对象之间的交互, 如果两个对象之间不必
彼此直接通信, 那么这两个对象就不应当发生任何直接的相互作用, 如果其中的一个对象需
要调用另一个对象的某一个方法的话, 可以通过第三者转发这个调用。 简言之, 就是通过引
入一个合理的第三者来降低现有对象之间的耦合度。
在将迪米特法则运用到系统设计中时, 要注意下面的几点: 在类的划分上, 应当尽量创建松
耦合的类, 类之间的耦合度越低, 就越有利于复用, 一个处在松耦合中的类一旦被修改, 不
会对关联的类造成太大波及; 在类的结构设计上, 每一个类都应当尽量降低其成员变量和成
员函数的访问权限; 在类的设计上, 只要有可能, 一个类型应当设计成不变类; 在对其他类
的引用上, 一个对象对其他对象的引用应当降到最低。