设计模式之禅
技术, 七个模式的思路是一层, c语言实现是一层, 六大原则和模式关系是另一层。
设计模式之禅 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法; public abstract class AbstractGun { //枪用来干什么的?杀敌! public abstract void shoot(); } public class Handgun extends AbstractGun { //手枪的特点是携带方便,射程短 @Override public void shoot() { System.out.println("手枪射击..."); } } public class Rifle extends AbstractGun{ //步枪的特点是射程远,威力大 public void shoot(){ System.out.println("步枪射击..."); } } public class Soldier { //定义士兵的枪支 private AbstractGun gun; /给士兵一支枪 public void setGun(AbstractGun _gun){ this.gun = _gun; } public void killEnemy(){ System.out.println("士兵开始杀敌人..."); gun.shoot(); } } 注意粗体部分,定义士兵使用枪来杀敌,但是这把枪是抽象的,具体是手枪还是步枪需要在上战场前(也就是场景中)前通过setGun方法确定。(这也是我一直困惑的地方,明明用抽象的类型,怎么会shoot不同的东西出来。这是语言的提炼抽象吧,不用更加具体地描述shoot。只要一个shoot适配所有的枪?) public class Client { public static void main(String[] args) { //产生三毛这个士兵 Soldier sanMao = new Soldier(); //给三毛一支枪 sanMao.setGun(new Rifle()); (配置什么枪,最后用什么枪做任何事。还是通过抽象,简化了操作细节。继承的变量可以赋值给父类,代码也复用,底层就是地址排列函数排列一样,用的是地址的这个规律性,一致性。) sanMao.killEnemy(); 有人,有枪,也有场景 如果三毛要使用机枪,当然也可以,直接把sanMao.setGun(new Rifle())修改为sanMao.setGun(new MachineGun())即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。(貌似这个牵一发动全身的换枪,被代码分析清楚,除此之外都治好了,换枪这个接口留好了,通过多定义了抽象枪这个内存来存放不同的枪而已。) 由于引入了新的子类,场景类中也使用了该类,Client稍作修改。(场景类,每种场景都会被实现。) Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。kill时,如果是玩具枪不shoot。(这就要枪再多一个例外属性。如果有这个例外,这枪就没抽象,还是根据具体枪做事。)所有与这个父类有关系的类都增加一个判断(关键是要从头去修改抽象,全盘修改方案,重新抽象具体了。) (问题在于是继承,大家都一套规矩,怕得是抽象的例外问题。) ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系,如图2-3所示。AbstractToy中声明将声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。(上面那个就是用依赖了,另一抽象枪的特性拿来用) 封装、继承、多态。继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。(方法存储是函数指针而已。) 子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。(里氏替换用的是一块称做父类的公共内存,来替换变换成子类。向上就抽象了。但这个太理想了,不可能把子类特性归纳为几天,连一个都公认的shoot都有例外,具体的还有独特的特性和属性。那这个代码中用到的替换还管不管用。) public class Snipper { public void killEnemy(AUG aug){ //首先看看敌人的情况,别杀死敌人,自己也被人干掉 aug.zoomOut(); //开始射击 aug.shoot(); } } 狙击手使用AUG杀死敌人 public class Client { public static void main(String[] args) { //产生三毛这个狙击手 Snipper sanMao = new Snipper(); sanMao.setRifle(new AUG()); sanMao.killEnemy(); } } 系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?不能。(定义了要传递子类,就别传父类。) 会在运行期抛出java.lang.ClassCastException异常,这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。(继承是继承一个内存排序,子类也有这个内存排序,但会扩展开来。编译器的内存考量问题。)从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。 Web Service开发就应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。 里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做Design by Contract(契约设计) 契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。(相互配合的条件,大家都按接口工作) 方法名虽然相同,但方法的输入参数不同,就不是覆写,那这是什么呢?是重载(Overload)!不用大惊小怪的,不在一个类就不能是重载了?继承是什么意思,子类拥有父类的所有属性和方法,方法名相同,输入参数类型又不相同,当然是重载了。 Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在。(所以,子比父范围大) 子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,(还跟父类有啥关系,考虑它啥意图。)引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。(父范围小子范围大) 反例,父大map,子小hashmap。父类型调用,传入小的hashmap,自然走的是父的函数,因为父类没有重载过嘛,只有子类重载过。 public class Client { public static void invoker(){ //有父类的地方就有子类 Father f= new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } } 代码运行结果如下所示: 父类被执行... public class Client { public static void invoker(){ //有父类的地方就有子类 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } } (这个少了一个把son赋值给father的操作,给father的壶里装son。所以,不好理解。) 当定义子类,输出结果: 子类被执行。 在没有覆写情况下,子类被执行。用map做参数,肯定是父类被执行。 关键是父类范围可以选的太广泛,应用场景多。子类选的范围小,反而是特例。正常情况下,父类小点,子类大点,这样出下一个版本时,上一种情况还能接着用,不被破坏。 在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美! 根据里氏替换原则,父类出现的地方子类就可以出现,父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。(原有的归原有的父类) Invoker类中关联了一个父类,调用了一个父类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围 有父类出现的地方,子类可以。有子类出现的地方,父类未必可以。(向下转型不安全,说实话,还是一个内存占用替换问题。) 玩具枪的例子是继承了父类,到父类的shoot功能用不上怎么办?这就违背了里氏替换原则(子类必须完全实现父类的方法),你不实现,就得做例外 一个重写(一块内存),一个重载(两块内存,保留了父类的接口) 如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。 如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。 采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。(使用父类作为参数,传递不同子类。) 采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;(拔高一层,看得简单了。) 接口或抽象类不依赖于实现类; 实现类依赖接口或抽象类。 “倒置”这个词还是有点不理解,那到底什么是“倒置”呢?我们先说“正置”是什么意思,依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车,我要使用笔记本电脑就直接依赖笔记本电脑,而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“倒置”就是从这里产生的。 public class Driver { //司机的主要职责就是驾驶汽车 public void drive(Benz benz){ benz.run(); } } public class Benz { //汽车肯定会跑 public void run(){ System.out.println("奔驰汽车开始运行..."); } } 有车,有司机,在Client场景类产生相应的对象。 public class Client { public static void main(String[] args) { Driver zhangSan = new Driver(); Benz benz = new Benz(); //张三开奔驰车 zhangSan.drive(benz); } } “危难时刻见真情”,我们把这句话移植到技术上就成了“变更才显真功夫”,业务需求变更永无休止,技术前进就永无止境,在发生变更时才能发觉我们的设计或程序是否是松耦合。 张三司机不仅要开奔驰车,还要开宝马车,又该怎么实现呢?(对接口编程,故障注入验证代码怎么改?) 司机类和奔驰车类之间是紧耦合的关系,其导致的结果就是系统的可维护性大大降低,可读性降低,两个相似的类需要阅读两个文件,你乐意吗?还有稳定性,什么是稳定性?固化的、健壮的才是稳定的,这里只是增加了一个车类就需要修改司机类,这不是稳定性,这是易变性。 被依赖者的变更竟然让依赖者来承担修改的成本,这样的依赖关系谁肯承担! 稳定性较高的设计,在周围环境频繁变化的时候,依然可以做到“我自岿然不动”。 甲负责汽车类的建造,乙负责司机类的建造,在甲没有完成的情况下,乙是不能完全地编写代码的,缺少汽车类,编译器根本就不会让你通过!在缺少Benz类的情况下,Driver类能编译吗?更不要说是单元测试了!在这种不使用依赖倒置原则的环境中,所有的开发工作都是“单线程”的,甲做完,乙再做,然后是丙继续。 要协作就要并行开发,要并行开发就要解决模块之间的项目依赖关系,那然后呢?依赖倒置原则就隆重出场了! public interface IDriver { //是司机就应该会驾驶汽车 public void drive(ICar car); } 接口只是一个抽象化的概念,是对一类事物的最抽象描述。 在IDriver中,通过传入ICar接口实现了抽象之间的依赖关系,Driver实现类也传入了ICar接口,至于到底是哪个型号的Car,需要在高层模块中声明。(用一块内存占着,继承的对象可以覆盖它。) 在高层次的模块中应用都是抽象,Client的实现过程如代码清单3-8所示。 代码清单3-8 业务场景 public class Client { public static void main(String[] args) { IDriver zhangSan = new Driver(); ICar benz = new Benz(); //张三开奔驰车 zhangSan.drive(benz); } } Client属于高层业务逻辑,它对低层模块的依赖都建立在抽象上,zhangSan的表面类型是IDriver,Benz的表面类型是ICar。 zhangSan的表面类型是IDriver,是一个接口,是抽象的、非实体化的,在其后的所有操作中,zhangSan都是以IDriver类型进行操作,屏蔽了细节对抽象的影响。 张三如果要开宝马车,也很容易,我们只要修改业务场景类就可以。 在新增加低层模块时,只修改了业务场景类,也就是高层模块,对其他低层模块如Driver类不需要做任何修改,业务就可以运行,把“变更”引起的风险扩散降到最低。(场景类是可以改的,甚至可以平台区分。) 在Java中,只要定义变量就必然要有类型,一个变量可以有两种类型:表面类型和实际类型,表面类型是在定义的时候赋予的类型,实际类型是对象的类型,如zhangSan的表面类型是IDriver,实际类型是Driver。 两个类之间有依赖关系,只要制定出两者之间的接口(或抽象类)就可以独立开发了,而且项目之间的单元测试也可以独立地运行,而TDD(Test-Driven Development,测试驱动开发)开发模式就是依赖倒置原则的最高级应用。 抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自己与外部的关系。(抽象变成一种承诺) 保证所有的细节不脱离契约的范畴,确保约束双方按照既定的契约(抽象)共同发展,只要抽象这根基线在,细节就脱离不了这个圈圈,始终让你的对象做到“言必信,行必果”。() 项目越大,需求变化的概率也越大,通过采用依赖倒置原则设计的接口或抽象类对实现类进行约束,可以减少需求变化引起的工作量剧增的情况。人员的变动在大中型项目中也是时常存在的,如果设计优良、代码结构清晰,人员变化对项目的影响基本为零。大中型项目的维护周期一般都很长,采用依赖倒置原则可以让维护人员轻松地扩展和维护。 在现实世界中确实存在着必须依赖细节的事物,比如法律,就必须依赖细节的定义。“杀人偿命”在中国的法律中古今有之[插图],那这里的“杀人”就是一个抽象的含义 别为了遵循一个原则而放弃了一个项目的终极目标:投产上线和盈利。作为一个项目经理或架构师,应该懂得技术只是实现目的的工具,惹恼了顶头上司,设计做得再漂亮,代码写得再完美,项目做得再符合标准,一旦项目亏本,产品投入大于产出,那整体就是扯淡!