面向对象的设计原则
在满足非功能需求的前提下,尽量保持设计的简单。简单的设计有利于其它开发人员理解。
类应该对扩展开放,对修改关闭。
为了遵循开闭原则,通常需要引入新的抽象层次,从而增加代码的复杂度。因此,没必要在任何时候严格遵守开闭原则,应当只去考虑那些最有可能变化的代码。
一个类只应当有一个引起变化的原因。该原则要求我们尽量让每个类保持单一责任。
以集合和迭代器为例,集合类本身的职责是管理一群对象,如果不引入迭代器,直接让集合类实现迭代功能,那么集合类就有了两项职责,相应的就是两项变化的原因。
当一个模块或者类被设计为只支持一组相关功能时,我们称之为高内聚(Cohesion),反之则称为低内聚,低内聚往往意味着不必要的耦合。
下面是一个小型射击游戏的例子,一个类中牵涉到三类不同的职责:游戏会话、游戏动作、玩家信息,这是一个典型的低内聚高耦合的例子:
1 2 3 4 5 6 7 8 |
# 一个射击游戏 class Game: def login( self ): pass # 登录 def signup( self ): pass # 注册 def move( self ): pass # 移动 def fire( self ): pass # 开火 def getName( self ): pass # 获取用户名 def getHighScore( self ): pass # 获取高分 |
软件开发中,唯一不变的是变化这个事实。
软件设计要解决的一个核心问题,就是将软件中变化与不变的部分进行分离。尽可能的避免将易变化的代码和不需要变化的代码混在一起,这样做至少有以下好处:
- 对于易变的代码:后续对其进行修改,不至于影响到软件中已经稳定的部分
- 对于不变的代码:有利于提高其可重用性
考虑以下场景,我们需要设计一些人员类,这些人员可以打招呼,最原始的做法是使用继承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# -*- coding: UTF-8 -*- from abc import abstractmethod class Person( object ): @abstractmethod def sayHello( self ): pass class Chinese( Person ): def sayHello( self ): print "你好" # 一般中国人这么说 class English ( Person ): def sayHello( self ): print "hi" # 一般外国人这么说 |
可以看到,中国人和英国人使用不同的方式打招呼。在后续开发过程中,我们发现哑巴是不会说话的,因此继续扩展类层次:
1 2 3 |
class Dumb( Person ): def sayHello( self ): pass # 哑巴不说话 |
很快我们就发现,中国人和英国人都可以是哑巴,难道还要继续继承下去?就像这样:
1 2 |
class ChineseDumb( Dumb , Chinese ): pass |
仔细思考一下,可以发现打招呼的方式和人员类型耦合在一起,不同人员类型必须以固定的方式来打招呼,是这样么?中国人也可以学英语,英国人也可以是哑巴……既然打招呼的方式本质上和人的类型无关、并且打招呼的方式可以随着人员的具体实例灵活变化,我们就应该将这种变化隔离,变继承为组合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# -*- coding: UTF-8 -*- from abc import abstractmethod class GreetHehavior( object ): @abstractmethod def greet ( self ): pass class ChineseGreetHehavior( GreetHehavior ): def greet( self ): print "你好" class Person( object ): def sayHello( self ): self.greetHehavior.greet() class English ( Person ): pass if __name__ == '__main__': p = English() p.greetHehavior = ChineseGreetHehavior() p.sayHello() #英国人也可以用中文打招呼,只要他学习了汉语 |
现在,易变的打招呼逻辑被封装在GreetHehavior接口中,并且可以灵活的与人员的实例进行组合。
类的两大要素是状态和方法(行为),而例子中重构后的代码却把两要素之一:行为给独立为新的类型,这其实是合理的,作为新类型的“行为”也可以有自己的变量,例如打招呼时说话的速度。
针对接口编程,而不是针对实现编程。这里的接口是泛指的超类型概念,在不同的编程语言里面有不同的具体表象,可以是抽象类、或者类似Java语言中的interface。
多态——在运行时动态绑定,并寻找真正的行为(代码),是针对接口编程的技术前提。
具体的说,针对接口编程,要求变量以超类型的形式进行声明,当然这对于某些动态语言不适用,因为其语法结构中就不要求声明变量类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
#include <iostream> using namespace std; class Person { public: virtual ~Person() { } //虚函数用于启用动态绑定 virtual void sayHello()=0; }; class English : public Person { public: virtual ~English() { } void sayHello() { cout << "Hi" << endl; } }; class Chinese : public Person { public: virtual ~Chinese() { } void sayHello(); }; void Chinese::sayHello() { cout << "你好" << endl; } int main( int argc, char **argv ) { Person* p = new Chinese; //面向接口的变量声明 //虽然声明类型是Person,但是多态使程序调用了子类的代码 p->sayHello(); //你好 delete p; p = new English; p->sayHello(); // Hi Chinese alex; //面向实现的声明 return 0; } |
使用组合建立类系统,具有很大的弹性。利用继承设计子类的行为,是在编译时静态决定的,而且所有的子类都会继承到相同的行为。使用组合的做法扩展对象的行为,就可以在运行时动态地进行扩展。
通过动态的组合对象,可以写新的代码添加新功能,而无须修改现有代码,因而引入Bug或者意外副作用的可能性就会大幅度减小。这也体现了开闭原则。
松耦合的设计有助于建立有弹性的OO系统,能够应对变化,因为对象之间的互相依赖降到了最低。
当两个对象之间松耦合时,它们依然可以交互,但是不太清楚彼此的细节。例如在观察者模式中,主题、观察者就是一种松耦合关系,因为:
- 关于观察者的一切,主题只知道它实现了观察者接口,其它一概不知
- 在任何时候,主题的观察者可以被动态的添加、删除
- 当新类型的观察者出现时,主题的代码不需要进行修改
- 修改主题或者观察者的任一方,对方不会受到影响
要依赖于抽象,而不是依赖于具体类。但凡在类A的代码中出现类B的引用,就意味着类A依赖于类B。
该原则乍看与面向接口编程类似,但是更加强调抽象——在类层次中,处于高层的组件不应该依赖于处于低层的组件,同时不管高低层组件,两者都应该依赖于抽象。以下几个TIPS可以帮助你遵循此原则:
- 变量不要持有具体类的引用——避免new,使用工厂规避
- 不要让类派生自具体类——派生是一种强依赖,应当派生自抽象类或者接口
- 不要覆盖基类中已经实现的方法——如果覆盖基类已经实现的方法,说明基类并不是一个真正适合被继承的抽象,基类中实现的方法应该被所有子类共享
在Java中,使用基于Annotation,比起XML文件的配置方式,有时会引入对DIP的违反,例如Hibernate的多态映射:
1 2 3 4 5 6 7 8 9 10 11 12 |
@AnyMetaDef ( name = "mapItemTarget", idType = "string", metaType = "integer", metaValues = { @MetaValue ( value = "0", targetEntity = AbstractMonitorPoint.class ), @MetaValue ( value = "1", targetEntity = AbstractDeviceHost.class ), @MetaValue ( value = "2", targetEntity = AbstractGeneralDevice.class ), @MetaValue ( value = "3", targetEntity = Area.class ), } ) @Any ( metaDef = "mapItemTarget", metaColumn = @Column ( name = "TGT_TYPE") ) @JoinColumn ( name = "TGT_ID") private AbstractEntity target; |
又称最少知识原则(Least Knowledge),该原则告诫我们“不要和陌生人说话”——对象之间的交互应当尽量减少,仅仅和联系最紧密的对象进行交互。
太多的类进行网状交互会导致难以维护的耦合,复杂的交互导致代码难以被理解。如何能在实践中遵循该原则呢?
在一个对象的方法体内,我们应该只调用以下范围内的方法:
- 该方法体中创建的对象的方法
- 当前对象本身的方法
- 被作为方法参数传入的对象的方法
- 当前对象任何属性(组件,Has-A)的方法
按照上述规则,我们不应该调用作为返回值的对象的任何方法,例如:
1 2 3 |
def getTemp(self): #调用了监控站的温度计的方法 return self.station.getThermometer().getTemperature() |
应当改为:
1 2 3 |
def getTemp(self): #调用了监控站的温度方法,至于监控站如何得到温度,我不关心 return self.station.getTemperature() |
该原则会造成一些问题:
- 导致更多的包装类/方法被制造出来,增加复杂度和降低性能
别调用(打电话给)我们,我们会调用(打电话给)你。好莱坞原则可以防止“依赖腐败”——当高层组件依赖于低层组件,低层组件又依赖于高层组件,高层组件又依赖于边侧组件,边侧组件又依赖于低层组件时,依赖腐败就产生了(环状依赖),这样的情况下没人能轻易搞清楚系统是如何设计的。
在好莱坞原则下,低层组件可以将自己挂钩到系统上,但是高层组件会决定什么时候、如何使用这些低层组件。
好莱坞原则是创建框架时常见的一种技巧。
不能生硬的硬搬上面的设计原则,在实际的设计中,可能需要做很多折衷。例如组合模式就在透明性和安全性之间做了取舍,为了透明性,它在超类接口中定义了不适用于子类的方法,并且违反了SRP。
所谓模式,是指在某种情境(Context)下,针对某问题的一种解决方案。情境必须是重复的、不断出现的;解决方案必须是通用的,用来解决约束,达到目标。
使用模式切忌生搬硬套,为了使用模式而使用模式,可以根据实际需要对模式进行变形。模式可能带来不必要的复杂性,因此应当在权衡后使用。
设计模式可以分为:
- 创建型:牵涉到如何进行对象的实例化,提供一种方法将Client从需要实例化的对象中解耦。这类模式包括单例、建造者、原型、抽象工厂、工厂方法等
- 行为型:牵涉到类与对象如何交互和分配职责。这类模式包括模板方法、访问者、中介者、迭代器、备忘录、命令、观察者、职责链、解释器、状态、策略等
- 结构型:牵涉到如何把类或对象组合到更大的结构中。这类模式包括:迭代器、代理、外观、组合、轻量、适配器、桥接等
另外一种分类方式:
- 类模式:描述类之间的关系如何通过继承定义,类模式的关系是在编译期间建立的。这类模式包括:模板方法、工厂方法、适配器、解释器等
- 对象模式:描述对象之间的关系,主要利用组合定义,对象模式的关系通常在运行时建立,更加动态有弹性。这类模式包括:组合、装饰器、代理、策略、桥接、轻量、抽象工厂、单例、原型、状态、建造者、命令、外观、访问者、职责链、备忘录、中介者、观察者、迭代器等
Leave a Reply