
第1章 初探

在这个计算机发展日新月异的时代,软件产品不断推陈出新、让人应接不暇,软件需求更是变幻莫测,难以捉摸。作为技术人员,我们在软件开发过程中常常会遇到代码重复的问题,从而不得不对系统进行大量改动,这不但带来很多额外工作,而且会给产品带来不必要的风险。因此,良好、稳固的软件架构就显得至关重要。设计模式正是为了解决这些问题,它针对各种场景提供了适合的代码模块的复用及扩展解决方案。
设计模式最早于1994年由Gang Of Four(四人小组)提出,并以面向对象语言C++作为示例,如今已大量应用于Java、C#等面向对象语言所开发的程序中。其实设计模式和编程语言并不是密切相关的,因为编程语言只是人与计算机沟通的媒介,它们可以用自己的方式去实现某种设计模式。从某种意义上讲,设计模式并不是指某种具体的技术,而更像是一种思想,一种格局。本书将以时下流行的面向对象编程语言Java为例,对23种设计模式逐一拆解、分析。
在学习设计模式之前,我们先得搞清楚到底什么是面向对象。我们生活的现实世界里充满了各种对象,如大自然中的山川河流、花鸟鱼虫,抑或是现代文明中的高楼大厦、车水马龙,我们每天都要面对它们,与它们沟通、互动,这是对面向对象最简单的理解。为了将现实世界重现于计算机世界中,我们想了各种方法针对这些对象建立数字模型,但是理想很“丰满”,而现实很“骨感”,我们永远无法包罗万象。人们在“造物”的过程中发现,各种模型并非孤立存在的,它们之间有着千丝万缕的关联,于是便出现了面向对象所特有的编程方法。我们利用封装、继承、多态的方式去建模,从而大量减少重复代码、降低模块间耦合,像拼积木一样组装了整个“世界”。这里提到的“封装”“继承”和“多态”便是面向对象的三大特性,它们是掌握设计模式不可或缺的先决条件与理论基础,我们必须要对其进行全面透彻的理解。
1.1 封装
想要理解封装,我们可以先观察一下现实世界中的事物,比如胶囊对于各类混合药物的封装;钱包对于现金、身份证及银行卡的封装;计算机主机机箱对于主板、CPU及内存等配件的封装等。
由此可见,封装在我们生活中随处可见。我们举一个现实生活中常见的例子。如图1-1所示,注意餐盘中的可乐杯,其中的饮料是被装在杯子里面的,杯子的最上面封上盖子,只留有一个孔用于插吸管,这其实就是封装。封装隐藏了杯子内部的饮料,也许还会有冰块,而对于杯子外部来说只留有一个“接口”用于访问。这样的做法是否多此一举?又会带来什么好处呢?首先是方便、快捷,只有这样我们才能拿着饮料杯四处行走,随吸随饮,而不至于把饮料洒得到处都是,因为零散的数据缺乏集中管理,难以引用、读取。其次是封装后的可乐更加干净、卫生,可以防止外部的灰尘落入,杯子里面以关键字“private”声明的可乐会成为内部的私有化对象,因此能防止外部随意访问,避免造成数据污染。最后,对外暴露的吸管接口带来了极大便利,顾客在喝可乐时根本不需要关心杯子的内部对象和工作机制,如杯子中的冰块如何让可乐降温、杯体内部的气压如何变化、气压差又是如何导致可乐流出等实现细节对顾客完全是不可见的,留给顾客的操作其实非常简单,只需调用“吸”这个公有方法就可以喝到冰爽的可乐了。

图1-1 饮料的封装
我们再来分析一下对计算机主机的封装,它必然需要一个机箱把各种配件封装进去,如主板、CPU、内存、显卡、硬盘等。一方面,机箱起到保护作用,防止异物(如老鼠、昆虫等)进入内部而破坏电路;另一方面,机箱也不是完全封闭的,它一定对外预留有一些访问接口,如开机按钮、USB接口等,这样用户才能够使用计算机,计算机主机的类结构如图1-2所示。

图1-2 计算机主机的类结构
封装的概念在历史发展中也非常多见,其实它就是随着时间的推移对前人经验和技术产物的逐渐堆叠和组合的结果。举个例子,早期的枪设计得非常简陋,打一发子弹需要很长时间去准备,装填时要先把火药倒入枪管内,然后装入铅弹,最后用棍子戳实后才能发射;而下一次发射还要再重复这一过程,耗时费力。为了解决这个问题,人们开始了思考,既然弹药装填如此困难,那么不如把弹头和火药组合后封装在弹壳里。这样只要撞击弹壳底部,弹头就会被爆炸的火药崩出去,装入枪膛的子弹便可发出,如图1-3所示。
从弹药到子弹的发展其实就是对弹药的“封装”,因此大大提高了装弹效率。其实一次装一发子弹还是不够高效,如果再进一步,在子弹外再封装一层弹夹的话则会更显著地提升效率。我们可以定义一个数据结构“栈”来模拟这个弹夹,保证最早压入(push)的子弹最后弹出(pop),这就是栈结构“先进后出,后进先出”的特点。如此一来,子弹打完后只需更换弹夹就可以了。至此,封装的层层堆叠又上了一个层次,在机枪被发明出来之后冷兵器时代就彻底结束了。

图1-3 弹药的发展
在Java编程语言中,一对大括号“{}”就是类的外壳、边界,它能很好地把类的各种属性及行为包裹起来,将它们封装在类内部并固化成一个整体。封装好的类如同一个黑匣子,外部无法看到内部的构造及运转机制,而只能访问其暴露出来的属性或方法。需要注意的是,我们千万不要过度设计、过度封装,更不要东拉西扯、乱攀亲戚,比如把台灯、轮子、茶杯等物品封装在一起,或者在计算机主机里封装一个算盘。如果把一些不相干的对象硬生生封装在一起,就会使代码变得莫名其妙,难于维护与管理,所谓“物极必反,过犹不及”,所以封装一定要适度。
1.2 继承
继承是非常重要的面向对象特性,如果没有它,代码量会变得非常庞大且难以维护、修改。继承可以使父类的属性和方法延续到子类中,这样子类就不需要重复定义,并且子类可以通过重写来修改继承而来的方法实现,或者通过追加达到属性与功能扩展的目的。从某种意义上讲,如果说类是对象的模板,那么父类(或超类)则可以被看作模板的模板。
生物一代一代延续是靠什么来保持父辈的特征呢?没错,答案就是遗传基因DNA,如图1-4所示。正所谓“龙生龙凤生凤,老鼠的儿子会打洞”,如果没有这个遗传机制,代码量就会急剧增大,很多功能、资源都会出现重复定义的情况,这样就会造成极大的冗余和资源的浪费,所以受自然界的启发,面向对象就有了继承机制。

图1-4 生物的遗传基因
举个例子,儿子从父亲那里继承了一些东西,就不需要通过别的方式获得了,如继承家产。再举个例子,我们知道,狗是人类忠实的朋友,它们在一万多年的进化过程中不断繁衍,再加上人类的培育,衍生出许多品种,如图1-5所示。

图1-5 犬类的继承
基于图1-5所示的继承关系,我们思考一下如何用代码来建模,倘若为每个犬类品种都定义一个类并封装各自的属性和方法,这显然不行,因为类一多势必会造成代码泛滥。其实,不管是什么犬类品种,它们都有某些共同的特征与行为,如吠叫行为等,所以我们需要把犬类共有的基因抽离出来,并封装到一个犬类祖先中以供后代继承,请参看代码清单1-1。
代码清单1-1 犬类的祖先Dog
1. public class Dog {
2. protected String breeds;//品种
3. protected boolean sex;//性别
4. protected String color;//毛色
5. protected int age;//年龄
6.
7. public Dog(String breeds) {
8. this.age = 0; //初始化为0岁
9. this.breeds = breeds; //初始化犬类品种
10. }
11.
12. public void bark(){//吠叫
13. System.out.println("汪汪汪");
14. }
15.
16. public String getBreeds() {
17. return breeds;
18. }
19.
20. /*假设自出生后就不可以变种了,那么此处不应暴露setBreeds方法
21. public void setBreeds(String breeds) {
22. this.breeds = breeds;
23. }
24. */
25. public boolean isSex() {
26. return sex;
27. }
28.
29. public void setSex(boolean sex) {
30. this.sex = sex;
31. }
32.
33. public String getColor() {
34. return color;
35. }
36.
37. public void setColor(String color) {
38. this.color = color;
39. }
40.
41. public int getAge() {
42. return age;
43. }
44.
45. public void setAge(int age) {
46. this.age = age;
47. }
48. }
如代码清单1-1所示,我们为犬类定义了品种、性别、毛色、年龄这4个属性,并且带有相应的setter方法和getter方法。第12行的吠叫方法是犬类的共有行为,理所当然能被子类继承。需要注意的是,倘若我们把犬类属性的访问权限由“protected”改为“private”,就意味着子类不能再直接访问这些属性了,但这并无大碍,最终子类依旧可以通过继承而来的并且声明为“public”的getter方法和setter方法去间接访问它们。好了,接下来我们用子类哈士奇类来说明如何继承,请参看代码清单1-2。
代码清单1-2 哈士奇类Husky
1. public class Husky extends Dog {
2.
3. public Husky() {
4. super("哈士奇");
5. }
6.
7. public void sleighRide() {//拉雪橇
8. System.out.println("拉雪橇");
9. }
10.
11. }
如代码清单1-2所示,为了延续父类的基因,哈士奇类在第一行的类定义后用“extends”关键字声明了对父类Dog的继承。第4行以“super”关键字调用了父类的构造方法,并初始化了狗的品种breeds为“哈士奇”,当然年龄一并会被父类初始化为0岁。我们可以看到哈士奇类的代码已经变得特别简单了,既没有定义任何getter方法或setter方法,又没有定义吠叫方法,而当我们调用这些方法时却能神奇般地得到结果,这是因为它继承了父类的方法,不需要我们重新定义。只是能够单单地继承父类是不够的,哈士奇类还应该有自己的特色,这就要增加其自己的属性、方法,在代码第7行中我们增加了哈士奇类所特有的“拉雪橇”行为,这是父类所不具有的。除此之外,哈士奇吠叫起来比较特殊,这可能是基因突变或者是返祖现象所致,这时我们甚至可以重写吠叫方法以让它发出狼的叫声。其他子类的继承也可以各尽其能,比如贵宾犬可以作揖,藏獒可以看家护院等,读者可以自己发挥。总之,继承的目的并不只是全盘照搬,而是可以基于父类的基因灵活扩展。
扩展阅读
我们知道任何类都有一个toString()方法,但我们根本没有声明它,这是为什么呢?其实这是从Object类继承的方法,因为Object是一切类的祖先类。
1.3 多态
众所周知,在我们创建对象的时候通常会再定义一个引用指向它,以便后续进行对象操作,而这个引用的类型则决定着其能够指向哪些对象,用犬类定义的引用绝不能指向猫类对象,所以对于父类定义的引用只能指向本类或者其子类实例化而来的对象,这就是一种多态。除此之外,还有其他形式的多态,例如抽象类引用指向子类对象,接口引用指向实现类的对象,其本质上都别无二致。
我们继续以1.2节中的犬类继承为例。如果以犬类Dog作为父类,那么哈士奇、贵宾犬、藏獒、吉娃娃等都可以作为其子类。如果我们定义犬类引用dog,那么它就可以指向犬类的对象,或者其任意子类的对象,也就是“哈士奇是犬类,藏獒是犬类……”。下面我们用代码来表示,请参看代码清单1-3。
代码清单1-3 犬类多态构造示例
1. Dog dog; //定义父类引用
2. dog = new Dog();//父类引用指向父类对象(狗是犬类)
3. dog = new Husky()//父类引用指向子类对象(哈士奇是犬类)
4.
5. Husky husky = new Dog();//错误:子类引用指向父类对象(犬类是哈士奇)
如代码清单1-3所示,前3行没有任何问题,犬类引用可以指向犬类的对象,也可以指向哈士奇类的对象,这让dog引用变得更加灵活、多变,可以引用任何本类或子类的对象。然而第5行代码则会出错,因为让哈士奇类的引用指向犬类Dog的对象就行不通了,这就好像说“犬类就是哈士奇”一样,逻辑不通。
再进一步讲,多态其实是利用了继承(或接口实现)这个特性体现出来的另一番景象。我们以食物举例,中华美食博大精深,菜品众多且色香味俱全,这都离不开各种各样的食材,如图1-6所示。

图1-6 有机食物的多态性
虽然食材形态各异,但是万变不离其宗,它们都是自然界生长出来的有机生物。而作为人类,我们可以食用哪些食物呢?显而易见,人类只可以食用有机食物,对于金属、塑料等是不能消化的。所以正如图1-7所展示的那样,人类所能接受的食物对象可以是番茄、苹果、牛肉等有机食物的多形态表现,而不能是金属类物质。

图1-7 人类与食物的关系类结构
也许有人会提出疑问,全部用Object类作为引用不是更加灵活,多态性更加丰富吗?其实,任何事物都有两面性,一方面带来了灵活性,而另一方面造成了破坏性。
1.4 计算机与外设
为了更透彻地理解面向对象的特性,以及设计模式如何巧妙利用面向对象的特性来组织各种模块协同工作,我们就以计算机这个既形象又贴切的例子来切入实战部分。如图1-8所示,相信很多年轻的读者都没有见过这种早期的个人计算机,它的键盘、主机和显示器等都是集成为一体的。

图1-8 老式计算机
越是老式的计算机,其集成度越高,甚至把所有配件都一体化,配件之间的耦合度极高,难以拆分。这种过度封装的计算机为什么会退出历史舞台呢?试想,某天显示器坏了,我们只能把整个机器拆开更换显示器。如果显示器是焊接在主板上的,情况就更糟糕了。缺少接口的设计造成了极高的耦合度,而更糟的是,如果这种显示器已经停产了,那么结果只能是整机换新。
为解决这个问题,设计人员提出了模块化的概念,各种外设如雨后春笋般涌现,如鼠标、键盘、摄像头、打印机、外接硬盘……但这时又出现一个问题,如果每种设备都有一种接口,那么计算机主机上得有多少种接口?这些接口包括串口、并口、PS2接口……接口泛滥将是一场灾难,采用标准化的接口势在必行,于是便有了现在的USB接口。USB提供了一种接口标准:电压5V,双工数据传输,最重要的是其物理形态上的统一规范,只要是USB标准,设备就可以进行接驳,最终计算机发展成为图1-9所示的样子。

图1-9 现代计算机
我们每天都在接触计算机,对于这种设计可能从未思考过。为了便于理解,我们让计算机和各种外设鲜活起来,下面是它们之间展开的一场精彩对话,其中的角色包括一台计算机,一个USB接口,还有几个USB设备,故事就这样开始了。
计算机:“我宣布,从现在开始USB接口晋升为我的秘书,我只接收它传递过来的数据,谁要找我沟通必须通过它。”
USB接口:“我不关心要接驳我的设备是什么,但我规定你必须实现我定义的getData()这个方法,但具体怎样实现我不管,总之我会调用你的这个方法把数据读取过来。”
USB键盘:“我有readData(data Data)这个方法,我已经实现好了,传过去的是用户输入的字符。”
USB鼠标:“我也一样,但传过去的是鼠标移动或点击数据。”
USB摄像头:“没错,我也实现了这个方法,只是我的数据是视频流相关的。”
USB接口:“不管你们是什么类型的数据,只要传过来转换成Data就行了,我接收你们的接驳请求,除了PS2鼠标。”
PS2鼠标:“@计算机,老大,这怎么办?你找来的这个USB接口太霸道了,我们根本无法沟通,你们不能尊重一下老人吗?”
计算机:“你自己想办法,要顺应时代潮流,与时俱进。”
PS2鼠标:……
通过这场对话,我们对计算机和外设以及它们之间的关系有了更深刻的认识。计算机中装了一个USB接口,这就是“封装”,而键盘、鼠标及摄像头都是USB接口的实现类,从广义上理解这就是一种“继承”,所以计算机的USB接口就能接驳各种各样的USB设备,这就是“多态”。我们来看它们的类结构,如图1-10所示。

图1-10 现代计算机的类结构
通过对计算机接口的抽象化、标准化,我们对各个模块重新分类、规划,并合理封装,最终实现计算机与外设的彻底解耦。多态化的外设使计算机功能更加强大、灵活、可扩展、可替换。其实这就是设计模式中非常重要的一种“策略模式”,接口的定义是解决耦合问题的关键所在。但对于一些老旧的接口设备模块,我们暂时还无法使用,正如同上面故事里那个可怜的PS2鼠标。
我们都知道有一种设备叫转换器,它能轻松地将老旧的接口设备调制适配到新的接口,以达到兼容的目的,这就是“适配器模式”。这些设计模式后续都会被讲到,我们会由浅入深、一步一个脚印地逐个解析。读者一定要边学边思考,理论一定要与实践结合才能举一反三、融会贯通,如此才能合理有效地利用设计模式设计出更加优雅、健壮、灵活的应用程序。