敏捷软件开发——开放封闭原则OCP
首先,让我们分析一下背景。什么是软件开发过程中最不稳定的因素?——答案是需求!需求在软件开发过程中时时刻刻都可能发生变化。那么,如何灵活应对变化是软件结构设计中最重要也是最困难的一个问题。好的设计带来了极大了灵活性,不好的设计则充斥着僵化的臭味。这样,也就引出了本文的主题:【开发封闭原则】。
下面,就来简单扼要的介绍一下什么是【开放封闭原则】。【开发封闭原则】包括两个特征:对于扩展是开放的;对于修改是封闭的。对于扩展开放,意味着模块的行为是可扩展的。对于修改封闭,就是说在扩展模块行为的同时不对任何既有代码或二进制代码(.jar)进行修改。当然,这里说的过于绝对。有时为了完成某些任务不得不改动既有代码。但是,我们的目标是尽量的遵守原则。
那如何才能做到对扩展开发,对修改封闭呢?关键在于抽象!那什么是抽象呢?
我个人的理解是:抽象是对事物本质的概念上的理解。面向对象的分析中应该以行为分析为主线。举一个例子说明:假如从北京到上海,我们可以坐飞机,可以坐火车,可以坐汽车,也可以骑自行车。那对于这个问题如何分析呢?这个问题的抽象又是什么呢?也许可以这样思考:飞机、火车、汽车和自行车在本质上都是交通工具。所以得到这样的分析结果:交通工具被抽象为父类,飞机、火车、汽车和自行车都是其特例化的实现,父类中定义一个抽象方法,可以将人从一个地方运送到另外一个地方,各个子类重新定义运送的方式。这个设计无可厚非!但是,注意了!如果哪天某个人心情不错,想从北京走到上海了。那他的交通工具又是什么呢?11路!如果把步行也算成交通工具那就太不符合实际了。所以,面向对象的分析角度,不应该是从事物物理方面的关联去分析,而应该是从事物的行为方面的管理区分析。对于这个问题,一个人从北京道上海,无论是怎么到达的,只不过是移动策略不同。
下面,就给出相应的示例代码来说明一下上述问题。
class Traveller {
private Car car = new Car();
public void travel(Address srcAddress,Address destAddress){
car.move(srcAddress,destAddress);
}
}
这是最开始直接使用Car的旅行者,如果想替换成AirPlane怎么办?修改代码,用new AirPlane()代替Car。面向对象的实践原则指出:面向接口编程,而不面向实现编程。当代码依赖于具体实现时,就缺失了灵活性,面对新的扩展(也就是新的实现),必须修改既有代码。
接着,给出设计灵活的代码:
class Traveller {
private TravelStrategy _strategy;
public void travel(Address srcAddress,Address destAddress){
_strategy.move(srcAddress,destAddress);
}
public void setTravelStrategy(TravelStrategy strategy){
_strategy = strategy;
}
}
public interface TravelStrategy{
void move(Address srcAddress,Address destAddress);
}
public class CarStrategy implements TravelStrategy{
public void move(Address srcAddress,Address destAddress){
...
}
}
public class AirPlaneStrategy implements TravelStrategy{
public void move(Address srcAddress,Address destAddress){
...
}
}
public class WorkStrategy implements TravelStrategy{
public void move(Address srcAddress,Address destAddress){
...
}
}
使用这种设计就能灵活的应对设计。比如说:现在需要另外一种从北京到上海的方式——爬。呵呵,也许这种行为不可思议,但它也能达到目的。那如何扩展呢?只需要扩展一个新的TravelStrategy实现即可。这样,Traveller类不需要修改任何代码!可以通过调用setter方法来切换移动策略。现在还有一个问题:如何确定实例化哪个策略?这里可以引用工厂,专门负责对象实例化。可以将需要使用的策略放在文件中,这样改变策略时就不需要修改源代码了。但当新增策略时,还是需要修改Factory。没办法,现实中没有完全符合原则的情况,我们只能尽力去遵守!
现在,我们看一下以上代码中类之间的关系。Traveller类、TravelStrategy接口及其实现类,Traveller类是TravelStrategy接口的客户代码。那Traveller和TravelStrategy的关系与TravelStrategy和它的实现类指尖的关系哪个更紧密一些呢?答案是前者。这也正是另外一个敏捷原则【依赖倒置原则】。高层代码不应该依赖低层代码,低层代码要依赖于高层代码。在这里,说白了的意思就是:一个旅行者要从北京到上海,而旅行团已经给他安排好了去的方法,他本身并不关心怎么到达,只需要知道有办法到达就可以了。这里旅行者和到达方法之间的关系就非常密切了,而具体如何到达那是低层次的问题了。
上述的问题与实现是实现【开放封闭原则】的一种常用方法——策略模式。还有一种常用的实现方法:模板方法。其实,对于这两种方法的本质所在也就是面向对象中的组合
与继承。
我们已经学会了如何封装变化,那合适才封装呢?掌握了这项技能是一件好事,但滥用就出问题了~!因为遵循OCP的代价是昂贵的,创建正确的抽象是要花费时间和精力的,同时那些抽象也增加了软件设计的复杂性。通常,我们采用这种办法:只受愚弄一次。也就是说,最初的实现不封装任何东西。当真正的变化到来了,重构代码,封装变化,以避免同类问题再次发生。
OCP是面向对象设计的核心所在。开发人员应该仅仅对程序中呈现出频繁变化的那些部分做出抽象。拒绝不成熟的抽象与抽象本身一样重要。
最后,在简单介绍一下面向对象分析的常用方法:
寻找问题域中的各个事物或行为共性
从共性中创建抽象
从共性的变化中创建派生
看共性之间的关系如何
开闭原则OCP(Open-Close Principle)被称作是OOD的基石,是OOD最重要的原则之一。
这个原则由大师Bertrand Meyer在1988年提出(汗,那个时候恐怕国内还很少人知道OO,甚至计算机为何物):Software entities should be open for extension,but closed for modification。多简单啊?!这个原则的意思大概是说:软件对扩展应该是开发的,对修改应该是关闭的。说的更通俗点儿,就是说我们开发了一个软件,应该可以对它进行功能扩展(开放),而在进行这些扩展的时候,不需要对原来的程序进行修改(关闭)!
为什么会有这样的要求呢?如果一个软件是符合OCP原则的,那么至少,我们有两个极大的好处:
1.在软件可用性上,非常灵活。你可以在软件完成对软件进行扩展,加入新的功能。这样,这个软件就可以通过不断的增加新模块满足不断变化的新需求!
2.由于对软件原来的模块不能修改,因此不用担心软件的稳定性。
目前,对OCP的实现,主要的一条就是抽象,就是我们常常挂在嘴边的要面向抽象(接口)。把系统的所有可能的行为抽象成一个抽象底层,这个抽象底层规定出所有的具体类必须提供的方法的特征作为系统设计的抽象层,这个抽象层要预见所有可能的扩展,从而使得在任何扩展情况下,系统的抽象层不需修改;同时由于可以从抽象层导出一个或多个新的具体类可改变系统的行为,因此对于可变的部分,系统设计对扩展是开放的。
关于系统可变的部分,还有一个更具体的对可变性封装原则(Principle of Encapsulation of Variation, EVP),从工程实现的角度对开闭原则进行了进一步的解释。EVP要求在做系统设计的时候,对系统所有可能(或允许)发生变化的部分进行评估和分类,每一个可变的因素都单独进行封装。
我们很容易就可以想到,在设计的开始就罗列系统所有可能的行为加入到抽象底层是不可能的(实际上也是不合算的),对所有的可变因素进行预计和封装也不太现实,因此,开闭原则很难被完全实现,只能在某些模块、某种程度上、某个限度内符合OCP的要求。所以可以说,OCP具有理想主义的色彩,是OOD的终极目标。因此,针对OCP的实现方法,许多OOD的大师都费尽心机,研究OCP的实现方式。后面要提到的里氏代换原则、合成复用原则,依赖倒转原则,接口隔离原则,抽象类,迪米特法则等,都可以看作是OCP的实现方法。