转:超越设计模
简介: 可复用面向对象软件的基础 -- 设计模式,以其可复用的设计初衷、精巧的逻辑思维被广大面向对象程序设计所追捧。但不少程序设计者却经常将思考的问题转换为遇到了什么场景就要用什么模式。这种八股文式的思维在某种程度上严重影响了程序设计的艺术性,并固化了程序设计者的思想,违背了设计模式的初衷。在本文中,作者总结了设计模式背后的核心思想,并提出了几个关键的设计原则,例如面向接口、封装变化、依赖倒置原则、只和朋友交谈等。程序设计者只需在程序设计时遵循这些原则,便会发现原来已经在使用某些设计模式了。
GOF 的设计模式推出以后,受到程序员的热烈追捧,很多程序员不亦乐乎的埋头苦读甚至背诵其 23 个设计模式,并以熟悉设计模式而自豪。然而,在实际的程序设计中,很多程序员并未能把设计模式应用到自己的场景中。原因有很多,设计模式太多以至于常常被混淆;设计模式应用场景太局限或者程序员自己意识不到应用的场景。综合各种原因,根本原因只有一个,程序员并不能透彻理解,熟练应用设计模式的核心思想。笔者认为,设计模式并不是条条框框,设计模式也不是简单的 23 种。设计模式体现的一种思想是:尽可能的复用,而实现可复用的手段无外乎笔者总结的几个设计原则而已。
彻底的忘掉 GOF 的设计模式吧,程序设计应该是一门艺术,而不是备受束缚的那些模式。
该原则的核心思想是,在程序设计中找出应用中可能需要变化之处,把它们独立出来以便以后可以轻易的改动或者扩充,而不影响不需要变化的部分。事实上如果您回过头去重新阅读设计模式的书籍,您会发现,封装变化几乎是每个设计模式背后的精神所在。所有的模式都提供了一套方法让系统中的某部分改变不会影响其它部分。
我们举一个简单的例子,我们建立一个 Car 的基类,有两个继承类 Benz 和 BMW, 具体参见下图 1:
相信大部分人都会这么设计,但是这个设计有什么问题呢?我们看待问题需要以发展的眼光,假如科技发展了,所有的 Car 都可以飞了,怎么办?有人说,很简单,给 Car 加一个 protected 的 fly() 方法,这样 Benz 和 BMW 就都可以飞了,继承真伟大!好,那么如果我需要建立另外一个 Car 的子类玩具车(Toycar), 我们知道玩具车可以 run, 但不能 fly 的。怎么办?还是好办,我们可以重载玩具车的 fly 方法,让他们什么都不干。那好,又一个子类来了,模型车(ModelCar)。模型车不能 run,不能 fly,好办,继续重载他们的这些方法。见下图 2:
如果我们有更多的 Car 的子类呢?有没有觉得有点繁琐,是的,我们需要重载太多的方法了。
继承并不能帮我们解决问题!
可不可以使用接口,我们可以把 fly 从超类中取出来,分别作为接口,Flyable,这样一来只有 Benz 和 BMW 才实现 Flyable 接口,ToyCar 和 modelCar 并不实现该接口。Run 也作类似处理。见下图 3:
大家可以看到,这其实是一个很笨的办法,除去 description() 方法,我们使用继承需要重载 3 个方法,可是我们使用接口实现则需要额外定义两个接口类和5个方法。接口方法里面并不能有实现代码,而 ToyCar 的 fly 行为和 Benz 的飞行行为也可能不尽相同,那么这就意味着我们需要实现越来越多的 fly() 方法。
接口也不行!
怎么办?想一想我们的前面提到的设计原则,把变化的和不变化的分离开来,以便我们以后可以轻易的改动和扩充,而不影响其它不需要变化的部分。我们变化的部分是什么?是否可以飞行,是否可以 run,以何种方式飞行?何种方式 run ? Benz,BMW 和 ToyCar 的飞行行为和 run 行为各不相同。我们可以把这些不同的 fly 和 run 抽象出来。见如下图 4:
看到这,也许您应该大概明白接下来应该怎么办了。是的,很简单,我们可以给 Car 类加入飞行行为和 run 行为的实例变量。而在初始化 Car 的子类时传入的具体行为进行初始化,这样每个子类就自然拥有了相应的行为。
代码参见如下:
public abstract class Car { protected RunBehavior runBehavior; protected FlyBehaviro flyBehavior; public abstract description (); protected performFly() { flyBehavior.fly(); } protected performRun() { runBehavior.run(); } } public class Benz extends Car { public String description() { System.out.println(“I am Benz!”); } public Benz() { this.runBehavior = new HighSpeedRunBehavior(); this.flyBehavior = new HighFlyingBehavior(); } } |
上述代码中我们实现了 Benz,如果我们要实现 ToyCar,一个不能飞,但可以跑。尝试一下,看看多简单。
public class ToyCar extends Car { public String description() { System.out.println(“I am Toy car!”); } public Benz() { this.runBehavior = new LowSpeedRunBehavior(); this.flyBehavior = new NoFlyingBehavior(); } } |
- 回过头来,看看我们前面所作的工作,第一个我要告诉您的是,恭喜您学会了策略模式,上面我们的设计的核心实现就是使用了策略模式。
- 继承和接口不能解决一切问题,尽量的利用组合将为您的设计带来低耦合。
- 尽可能的针对接口或者抽象类而不是实现去编程,试想,如果我们定义的 Car 类组合具体的行为类,也就是实现,那么它就被绑死了,我们不可能以后再改变它的行为。但这样,我们可以在运行时动态的改变 Car 子类的具体行为。这也是我们成功的关键。
- 最重要的,把变化的和不变化的分离出来,封装变化的部分以应对随时改变。
继续我们的话题,假设我们有一家汽车公司,可以生产 Benz、BMW、ToyCar 和 ModelCar(姑且这么认为吧,虽然这不太符合常理),那么该如何设计我们的实现呢?很简单,参见下面代码:
public class CarCompany { public CarCompany () { } public Car produce(String type) { Car car = null; if(“Benz”.equals(type)) { car = new Benz(); } else if(“BMW”.equals(type)) { car = new BMW(); } else if(“ToyCar”.equals(type)) { car = new ToyCar(); } else if(“ModelCar”.equals(type)) { car = new ModelCar(); } else { } car.assembly(); // 组装 car.sprayPainting(); // 喷漆 car.proof(); // 校对 return car; } } |
老问题,上面的代码有问题么?但从业务逻辑上讲,当然没问题。可是还是要用变化的眼光看问题,上面的代码维护起来成本很高。上面的代码要求我们无论是 Benz、BMW、ToyCar 还是 ModelCar 都不能在将来发生变化。否则,我们这段代码就有维护的成本和风险。
有没有更有效的办法,想想我们第一个设计原则:把变化的部分提出去,变化的部分是什么,显然生成 car 的那一段。我们把它提出去,参见下面代码:
清单 4. CarFactory 和 CarCompany 另一种实现
public class CarFactory { public CarFactory () { } public Car createCar(String type) { Car car = null; if(“Benz”.equals(type)) { car = new Benz(); } else if(“BMW”.equals(type)) { car = new BMW(); } else if(“ToyCar”.equals(type)) { car = new ToyCar(); } else if(“ModelCar”.equals(type)) { car = new ModelCar(); } else { } return car; } } public class CarCompany { CarFactory carFactory; public CarCompany (CarFactory carFactory) { this.carFactory = carFactory; } public Car produce(String type) { Car car = null; Car = carFactory.createCar(type); car.assembly(); // 组装 car.sprayPainting(); // 喷漆 car.proof(); // 校对 return car; } } |
很显然,我们的 CarCompany 现在只依赖 CarFactory 一个类了。有人说这么做有什么用,我们只不过把问题转移到另外一个对象 CarFactory 了,问题依然存在。但是别忘了,我们的 CarCompany 可能不止一个 produce() 方法。它可能还有 sale(), repair() 方法。这样,我们相当于是把几个问题缩小为一个问题了。
- 这个例子虽然很简单,但是却告诉我们一条最重要的设计原则,一个类应该尽可能少的与其它类产生联系,尽可能的保持类之间的耦合度,保持类的最少知识量。
- 恭喜您,您学会了简单工厂模式。
- 外观模式也是对本原则的典型应用。具体请参见设计模式相关书籍。
在您的设计里面,一定要减少对具体类的依赖,尽量依赖抽象,不要依赖具体类。这就是依赖倒置原则。
听起来有点像面向接口,不针对实现编程。的确很类似,但是这里强调的是抽象。具体说来就是不要让高层组件依赖低层组件,而且不管高层低层组件,都应该依赖抽象。高层组件最多是依赖低层组件的抽象。低层的抽象和实现也只依赖于高层的抽象。
所谓高层组件是由其它低层组件定义其行为的类。高层组件是包含重要的业务模型和策略选择,低层模块则是不同业务和策略实现。
也许您不是很理解这段话的含义。不要紧,继续我们上面的例子。假设随着汽车公司规模越来越大,业务规模拓展到了亚洲和欧洲。我们希望可以针对亚洲人和欧洲人生产出不同的同一品牌的的汽车。比如同一品牌 BMW 亚洲是左驾驶座(当然除了一些特殊地区),欧洲是右驾驶座。看看下面的实现。
public class DependencyCarCompany { public CarCompany () { } public Car produce(String style, String type) { Car car = null; if(“Asia”.equals(style)) { if(“Benz”.equals(type)) { car = new AsiaBenz(); } else if(“BMW”.equals(type)) { car = new AsiaBMW(); } else if(“ToyCar”.equals(type)) { car = new AsiaToyCar(); } else if(“ModelCar”.equals(type)) { car = new AsiaModelCar(); } else { } } else if(“Europe”.equals(style)) { if(“Benz”.equals(type)) { car = new EuropeBenz(); } else if(“BMW”.equals(type)) { car = new EuropeBMW(); } else if(“ToyCar”.equals(type)) { car = new EuropeToyCar(); } else if(“ModelCar”.equals(type)) { car = new EuropeModelCar(); } else { } car.assembly(); // 组装 car.sprayPainting(); // 喷漆 car.proof(); // 校对 return car; } } } |
够简单吧!总结它们对象之间依赖的情况如图 5 所示:
我们发现 CarComany 依赖的具体类有 8 个,如果任何一个类发生改变,CarCompany 都需要改变。这至少不符合我们的原则二:只和朋友交谈。应用我原则一,把变化的部分提出来。我们可以定义两个 CarCompany 的子类:AsiaCarCompany 和 EuropeCarCompany 用来生产不同样式的同一品牌的汽车。在这两个子类里面,需要做的就是生成不同品牌和样式的汽车,然后再调用超类的三个方法。这样的话我们可以把生成汽车的方法提出来。
public abstract class CarCompany { public Car produce(String type) { Car car = createCar(type); car.assembly(); // 组装 car.sprayPainting(); // 喷漆 car.proof(); // 校对 return car; } protected abstract Car createCar(String type); } public class AsiaCarCompany { protected Car createCar(Sting type) { Car car = null; if(“Benz”.equals(type)) { car = new AsiaBenz(); } else if(“BMW”.equals(type)) { car = new AsiaBMW(); } else if(“ToyCar”.equals(type)) { car = new AsiaToyCar(); } else if(“ModelCar”.equals(type)) { car = new AsiaModelCar(); } else { } return car; } } public class EuropeCarCompany { protected Car createCar(Sting type) { Car car = null; if(“Benz”.equals(type)) { car = new EuropeBenz(); } else if(“BMW”.equals(type)) { car = new EuropeBMW(); } else if(“ToyCar”.equals(type)) { car = new EuropeToyCar(); } else if(“ModelCar”.equals(type)) { car = new EuropeModelCar(); } else { } return car; } } |
DependencyCarCompany 的问题在于,它依赖于每一个具体的 Car 类型。然而,在应用第二种方法后,CarCompany 现在只依赖 Car 类型的抽象,不再依赖具体类型,而是把这些依赖转移到子类中。我们可以画一个对象依赖图 6:
从这个图中我们可以看出:
- 我们的高层组件也就是 CarCompany 已经由原来的 8 个低层对象依赖变化为只依赖一个低层对象的抽象 Car。这就是依赖抽象。
- 对比上面两个图,您会发现,以前所绘制的依赖是自上而下,而现在则是倒置过来。高层和低层组件都依赖于抽象的 Car。这就是依赖的倒置。
- 恭喜您,您学会了工厂方法模式。上面的例子实际上是工厂方法的一个典型应用。
- 一些辅助原则可以帮助您更好的运用 DIP:
- 任何变量都不应该持有一个指向具体类的引用。
- 任何类都不应该从具体类派生,而是派生一个抽象类。
- 任何方法都不应该覆盖它的任何基类中已经实现了的方法。
继续刚才的例子,随着汽车公司业务越来越大,为了满足不同客户的不同需求,对于任一品牌的汽车我们将有不同型号的配置。我们的配置包括气囊(Balloon)、天窗(SkyLight)以及自动加热座椅(HeatedSeats)等。每款汽车的价格为汽车自身价值加上配件的价格。设计如下图 7:
Oh, My God! 这是什么?类爆炸?!好可怕的一件事。可以想象出来,这样的设计将来的维护成本又多高。假如我想增加新的配件怎么办,假如我想增加新的品牌又怎么办?
其实我们可以用实例变量和继承来重构上面的设计。见下图 8:
我们在超类 Car 里面 cost() 方法计算各种配件的价格,然后在子类里面覆盖 cost() 方法,但是会调用超类 cost 方法得到配件价格和,然后再加上子类汽车的基本价格。
public abstract class Car { protected Balloon balloon; protected SkyLight skyLight; protected HeatedSeat heatedSeat; protected int cost() { int res = 0; if(hasBalloon) { res += 25000; } if(hasSkyLight) { res += 20000; } if(hasHeatedSeat) { res +=10000; } } void setBalloon(Balloon balloon); boolean hasBalloon(); …. …. } public Benz extends Car { public Benz(Balloon blloon) { this.setBalloon(balloon); } public int cost() { int res= 1000000; res += super.cost(); } } |
怎么样?看起来好像天衣无缝的解决了我们的问题。然而有下面几个问题需要考虑:
- 如果配件的价格发生改变怎么办?
- 如果出现新的配件怎么办?
- 如果某些配件在某种品牌汽车上不能应用怎么办?比如您在玩具车上装 ABS(自动刹车系统)显然是没有意义的。
- 如果我想给我的车安装 4 个气囊而不是一个两个,怎么办?
为什么看起来完美的设计,会有这么多解决不了的问题? 因为它违背我们的设计原则:类应该对扩展开放,对修改关闭。我们的目标是允许类容易扩展。在不修改现有代码的基础上,就可以搭配新的行为。这样设计才可以接受新的功能来应对改变的需求。
该原则最典型的应用就是装饰模式。让我们以装饰模式的思想重构我们上面的实现。
- 首先用户需要一辆汽车。那我们就构造一辆裸车,并计算价格。
- 用户希望是 BMW, 那我们就把它封装为 BMW,并计算价格。
- 用户希望带有气囊,那我们就给我们的 BMW 装饰上气囊,并加上气囊的价格 25000。
- 用户希望有天窗,那我们就给我们的 BMW 装饰上天窗,并加上气囊的价格 20000。
- 用户希望有加热椅,那我们就给我们的 BMW 装饰上加热椅,并加上的价格 10000。
- 用户希望带有双重气囊,那我们就给我们的 BMW 再装饰上气囊,并加上气囊的价格 25000。
见图 9:
参见我们的实现代码。
public abstract class Car { protected abstract int cost(); } public class Benz extends Car { public int cost() { return 100000; } } public abstract class CarDecorator extends Car { protected abstract int cost(); } public class Balloon extends CarDecorator { public Car car; public Balloon(Car car) { this.car = car; } public int cost() { return car.cost() + 25000; } } public class SkyLight extends CarDecorator { public Car car; public SkyLight (Car car) { this.car = car; } public int cost() { return car.cost() + 20000; } } public class HeatedSeat extends CarDecorator { public Car car; public HeatedSeat (Car car) { this.car = car; } public int cost() { return car.cost(0 + 10000; } } |
下面看看我们的测试类。
public class CarWithDecorator { public static void mian(String[] args) { Car car = new BMW(); car = new Balloon(car); car = new SkyLight(car); car = new HeatedSeat(car); car = new Balloon(car); System.out.println(car.cost()); } ... } |
怎么样?回过头,想一想我们前面提出的那四个问题,是用这种设计方式是不是可以很好地解决呢?
- 恭喜您,您学会了装饰模式。
- 现实世界中,装饰模式,也即我们面向扩展开放,面向修改关闭的应用很多。最常见的就是 Java I/O。见下图 10:
图 10. Java I/O
- 应用开放封闭原则,有时候会带来小类过多的情况,这是这个原则所带来的潜在问题。所以在实际应用中也要注意设计上的考虑。而不要一味的遵循。
设计原则不是统一的,不同人对有不同的设计原则有不同的见解,设计原则也不限于上面所陈述的几点。然后设计原则大的方向是统一的,那就是让代码尽可能的应对变化,尽可能的可复用。设计模式不是万能的,没有设计模式也不是不能的。然而在程序设计过程中遵循一些最基本的设计原则则是一个优秀的程序员所必需的,良好的设计原则的应用可以让您设计的程序从容应对可能的改变,可以让您的代码变得优雅而富有艺术性。
学习
- 参考 设计模式概述,了解设计模式基本内容。
- 查看教程“Java 设计模式 101”,了解设计模式基本词汇以及简单使用设计模式。
- 查看教程“Java 设计模式 201:超越四人组”,了解设计模式深层次的应用。
- 查看系列文章“从 Java 类库看设计模式”,了解 JDK 在设计模式中的应用。
- 技术书店:浏览关于这些和其他技术主题的图书。
- developerWorks Java 技术专区:数百篇关于 Java 编程各个方面的文章。
讨论