JavaScript设计模式与开发实践(txt+pdf+epub+mobi电子书下载)


发布时间:2020-05-09 16:46:41

点击下载

作者:曾探

出版社:人民邮电出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

JavaScript设计模式与开发实践

JavaScript设计模式与开发实践试读:

前言

《设计模式》一书自1995年成书一来,一直是程序员谈论的“高端”话题之一。许多程序员从设计模式中学到了设计软件的灵感,或者找到了问题的解决方案。在社区中,既有人对模式无比崇拜,也有人对模式充满误解。有些程序员把设计模式视为圣经,唯模式至上;有些人却认为设计模式只在C++或者Java中有用武之地,JavaScript这种动态语言根本就没有设计模式一说。

那么,在进入设计模式的学习之前,我们最好还是从模式的起源说起,分别听听这些不同的声音。

设计模式并非是软件开发的专业术语。实际上,“模式”最早诞生于建筑学。20世纪70年代,哈佛大学建筑学博士Christopher Alexander和他的研究团队花了约20年的时间,研究了为解决同一个问题而设计出的不同建筑结构,从中发现了那些高质量设计中的相似性,并且用“模式”来指代这种相似性。

受Christopher Alexander工作的启发,Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides四人(人称Gang Of Four ,GoF)把这种“模式”观点应用于面向对象的软件设计中,并且总结了23种常见的软件开发设计模式,录入《设计模式:可复用面向对象软件的基础》一书。设计模式的定义是:在面向对象软件设计过程中针对特

定问题的简洁而优雅的解决方案。

通俗一点说,设计模式是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的设计取个名字。

GoF成员之一 John Vlissides在他的另一本关于设计模式的著作《设计模式沉思录》中写过这样一段话:设想有一个电子爱好者,虽然他没有经过正规的培训,

但是却日积月累地设计并制造出许多有用的电子设备:业余

无线电、盖革计数器、报警器等。有一天这个爱好者决定重

新回到学校去攻读电子学学位,来让自己的才能得到真实的

认可。随着课程的展开,这个爱好者突然发现课程内容都似

曾相识。似曾相识的并不是术语或者表述的方式,而是背后

的概念。这个爱好者不断学到一些名称和原理,虽然这些名

称和原理原来他不知道,但事实上他多年来一直都在使用。

整个过程只不过是一个接一个的顿悟。

软件开发中的设计也是如此。这些“好的设计”并不是GoF发明的,而是早已存在于软件开发中。一个稍有经验的程序员也许在不知不觉中数次使用过这些设计模式。GoF最大的功绩是把这些“好的设计”从浩瀚的面向对象世界中挑选出来,并且给予它们一个好听又好记的名字。

那么,给模式一个名字有什么意义呢?上述故事中的电子爱好者在未进入学校之前,一点都不知道这些关于电器的概念有一些特定的名称,但这不妨碍他制造出一些电子设备。

实际上给“模式”取名的意义非常重要。人类可以走到生物链顶端的前两个原因分别是会“使用名字”和“使用工具”。在软件设计中,一个好的设计方案有了名字之后,才能被更好地传播,人们才有更多的机会去分享和学习它们。

也许这个小故事可以说明名字对于模式的重要性:假设你是一名足球教练,正在球场边指挥一场足球赛。通过一段时间的观察后,发现对方的后卫技术精湛,身体强壮,但边后卫速度较慢,中后卫身高和头球都非常一般。于是你在场边大声指挥队员:“用速度突破对方边后卫之后,往球门方向踢出高球,中路接应队员抢点头球攻门。”

在机会稍纵即逝的足球场上,教练这样费尽口舌地指挥队员比赛无疑是荒谬的。实际上这种战术有一个名字叫作“下底传中”。正因为战术有了对应的名字,在球场上教练可以很方便地和球员交流。“下底传中”这种战术即是足球场上的一种“模式”。

在软件设计中亦是如此。我们都知道设计经验非常重要。也许我们都有过这种感觉:这个问题发生的场景似曾相识,以前我遇到并解决过这个问题,但是我不知道怎么跟别人去描述它。我们非常希望给这个问题出现的场景和解决方案取一个统一的名字,当别人听到这个名字的时候,便知道我想表达什么。比如一个JavaScript新手今天学会了编写each函数,each函数用来迭代一个数组。他很难想到这个each函数其实就是迭代器模式。于是他向别人描述这个函数结构和意图的时候会遇到困难,而一旦大家对迭代器模式这个名字达成了共识,剩下的交流便是自然而然的事情。学习模式的作用

小说家很少从头开始设计剧情,足球教练也很少从头开始发明战术,他们总是沿袭一些已经存在的模式。当足球教练看到对方边后卫速度慢,中后卫身高矮时,自然会想到“下底传中”这种模式。

同样,在软件设计中,模式是一些经过了大量实际项目验证的优秀解决方案。熟悉这些模式的程序员,对某些模式的理解也许形成了条件反射。当合适的场景出现时,他们可以很快地找到某种模式作为解决方案。

比如,当他们看到系统中存在一些大量的相似对象,这些对象给系统的内存带来了较大的负担。如果他们熟悉享元模式,那么第一时间就可以想到用享元模式来优化这个系统。再比如,系统中某个接口的结构已经不能符合目前的需求,但他们又不想去改动这个被灰尘遮住的老接口,一个熟悉模式的程序员将很快地找到适配器模式来解决这个问题。

如果我们还没有学习全部的模式,当遇到一个问题时,我们冥冥之中觉得这个问题出现的几率很高,说不定别人也遇到过同样的问题,并且已经把它整理成了模式,提供了一种通用的解决方案。这时候去翻翻《设计模式》这本书也许就会有意外的收获。模式在不同语言之间的区别《设计模式》一书的副标题是“可复用面向对象软件的基础”。《设计模式》这本书完全是从面向对象设计的角度出发的,通过对封装、继承、多态、组合等技术的反复使用,提炼出一些可重复使用的面向对象设计技巧。所以有一种说法是设计模式仅仅是就面向对象的语言而言的。《设计模式》最初讲的确实是静态类型语言中的设计模式,原书大部分代码由C++写成,但设计模式实际上是解决某些问题的一种思想,与具体使用的语言无关。模式社区和语言一直都在发展,如今,除了主流的面向对象语言,函数式语言的发展也非常迅猛。在函数式或者其他编程范型的语言中,设计模式依然存在。

人类飞上天空需要借助飞机等工具,而鸟儿天生就有翅膀。在Dota游戏里,牛头人的人生目标是买一把跳刀(跳刀可以使用跳跃技能),而敌法师天生就有跳跃技能。因为语言的不同,一些设计模式在另外一些语言中的实现也许跟我们在《设计模式》一书中看到的大相径庭,这一点也不令人意外。

Google的研究总监Peter Norvig早在1996年一篇名为“动态语言设计模式”的演讲中,就指出了GoF所提出的23种设计模式,其中有16种在Lisp语言中已经是天然的实现。比如,Command模式在Java中需要一个命令类,一个接收者类,一个调用者类。Command模式把运算块封装在命令对象的方法内,成为该对象的行为,并把命令对象四处传递。但在Lisp或者JavaScript这些把函数当作一等对象的语言中,函数便能封装运算块,并且函数可以被当成对象一样四处传递,这样一来,命令模式在Lisp或者JavaScript中就成为了一种隐形的模式。

在Java这种静态编译型语言中,无法动态地给已存在的对象添加职责,所以一般通过包装类的方式来实现装饰者模式。但在JavaScript这种动态解释型语言中,给对象动态添加职责是再简单不过的事情。这就造成了JavaScript语言的装饰者模式不再关注于给对象动态添加职责,而是关注于给函数动态添加职责。设计模式的适用性

设计模式被一些人认为只是夸夸其谈的东西,这些人认为设计模式并没有多大用途。毕竟我们用普通的方法就能解决的问题,使用设计模式可能会增加复杂度,或带来一些额外的代码。如果对一些设计模式使用不当,事情还可能变得更糟。

从某些角度来看,设计模式确实有可能带来代码量的增加,或许也会把系统的逻辑搞得更复杂。但软件开发的成本并非全部在开发阶段,设计模式的作用是让人们写出可复用和可维护性高的程序。假设有一个空房间,我们要日复一日地往里面放一些东西。最简单的办法当然是把这些东西直接扔进去,但是时间久了,就会发现很难从这个房子里找到自己想要的东西,要调整某几样东西的位置也不容易。所以在房间里做一些柜子也许是个更好的选择,虽然柜子会增加我们的成本,但它可以在维护阶段为我们带来好处。使用这些柜子存放东西的规则,或许就是一种模式。

所有设计模式的实现都遵循一条原则,即“找出程序中变化的地方,并将变化封装起来”。一个程序的设计总是可以分为可变的部分和不变的部分。当我们找出可变的部分,并且把这些部分封装起来,那么剩下的就是不变和稳定的部分。这些不变和稳定的部分是非常容易复用的。这也是设计模式为什么描写的是可复用面向对象软件基础的原因。

设计模式被人误解的一个重要原因是人们对它的误用和滥用,比如将一些模式用在了错误的场景中,或者说在不该使用模式的地方刻意使用模式。特别是初学者在刚学会使用一个模式时,恨不得把所有的代码都用这个模式来实现。锤子理论在这里体现得很明显:当我们有了一把锤子,看什么都是钉子。拿足球比赛的例子来说,我们的目标只是进球,“下底传中”这种“模式”仅仅是达到进球目标的一种手段。当我们面临密集防守时,下底传中或许是一种好的选择;但如果我们的球员获得了一个直接面对对方守门员的单刀机会,那么是否还要把球先传向边路队友,再由边路队友来一个边路传中呢?答案是显而易见的,模式应该用在正确的地方。而哪些才算正确的地方,只有在我们深刻理解了模式的意图之后,再结合项目的实际场景才会知道。分辨模式的关键是意图而不是结构

在设计模式的学习中,有人经常发出这样的疑问:代理模式和装饰者模式,策略模式和状态模式,策略模式和智能命令模式,这些模式的类图看起来几乎一模一样,它们到底有什么区别?

实际上这种情况是普遍存在的,许多模式的类图看起来都差不多,模式只有放在具体的环境下才有意义。比如我们的手机,把它当电话的时候,它就是电话;把它当闹钟的时候,它就是闹钟;用它玩游戏的时候,它就是游戏机。我看到有人手中拿着iPhone18,但那实际上可能只是一个吹风机。有很多模式的类图和结构确实很相似,但这不太重要,辨别模式的关键是这个模式出现的场景,以及为我们解决了什么问题。对JavaScript设计模式的误解

虽然JavaScript是一门完全面向对象的语言,但在很长一段时间内,JavaScript在人们的印象中只是用来验证表单,或者完成一些简单动画特效的脚本语言。在JavaScript语言上运用设计模式难免显得小题大做。但目前JavaScript已成为最流行的语言之一,在许多大型Web项目中,JavaScript代码的数量已经非常多了。我们绝对有必要把一些优秀的设计模式借鉴到JavaScript这门语言中。许多优秀的JavaScript开源框架也运用了不少设计模式。

JavaScript设计模式的社区目前还几乎是一片荒漠。网络上有一些讨论JavaScript设计模式的资料和文章,但这些资料和文章大多都存在两个问题。

第一个问题是习惯把静态类型语言的设计模式照搬到JavaScript中,比如有人为了模拟JavaScript版本的工厂方法(Factory Method)模式,而生硬地把创建对象的步骤延迟到子类中。实际上,在Java等静态类型语言中,让子类来“决定”创建何种对象的原因是为了让程序迎合依赖倒置原则(DIP)。在这些语言中创建对象时,先解开对象类型之间的耦合关系非常重要,这样才有机会在将来让对象表现出多态性。

而在JavaScript这种类型模糊的语言中,对象多态性是天生的,一个变量既可以指向一个类,又可以随时指向另外一个类。JavaScript不存在类型耦合的问题,自然也没有必要刻意去把对象“延迟”到子类创建,也就是说,JavaScript实际上是不需要工厂方法模式的。模式的存在首先是能为我们解决什么问题,这种牵强的模拟只会让人觉得设计模式既难懂又没什么用处。

另一个问题是习惯根据模式的名字去臆测该模式的一切。比如命令模式本意是把请求封装到对象中,利用命令模式可以解开请求发送者和请求接受者之间的耦合关系。但命令模式经常被人误解为只是一个名为execute的普通方法调用。这个方法除了叫作execute之外,其实并没有看出其他用处。所以许多人会误会命令模式的意图,以为它其实没什么用处,从而联想到其他设计模式也没有用处。

这些误解都影响了设计模式在JavaScript语言中的发展。模式的发展

前面说过,模式的社区一直在发展。GoF在1995年提出了23种设计模式。但模式不仅仅局限于这23种。在近20年的时间里,也许有更多的模式已经被人发现并总结了出来。比如一些JavaScript图书中会提到模块模式、沙箱模式等。这些“模式”能否被世人公认并流传下来,还有待时间验证。不过某种解决方案要成为一种模式,还是有几个原则要遵守的。这几个原则即是“再现”“教学”和“能够以一个名字来描述这种模式”。

不管怎样,在一些模式被公认并流行起来之前,需要慎重地冠之以某种模式的名称。否则模式也许很容易泛滥,导致人人都在发明模式,这反而增加了交流的难度。说不准哪天我们就能听到有人说全局变量模式、加模式、减模式等。

在《设计模式》出版后的近20年里,也出现了另外一批讲述设计模式的优秀读物。其中许多都获得过Jolt大奖。数不清的程序员从设计模式中获益,也许是改善了自己编写的某个软件,也许是从设计模式的学习中更好地理解了面向对象编程思想。无论如何,相信对我们这些大多数的普通程序员来说,系统地学习设计模式并没有坏处,相反,你会在模式的学习过程中受益匪浅。

 第一部分基础知识作为本书的第一部分,我们在进入设计模式的学习之前,需要先了解一些相关的周边知识,例如一些面向对象的基础知识、this 等重要概念,还要掌握一些函数式编程的技巧。这些都是学习设计模式的必要铺垫。第 1 章面向对象的JavaScript

JavaScript没有提供传统面向对象语言中的类式继承,而是通过原型委托的方式来实现对象与对象之间的继承。JavaScript也没有在语言层面提供对抽象类和接口的支持。正因为存在这些跟传统面向对象语言不一致的地方,我们在用设计模式编写代码的时候,更要跟传统面向对象语言加以区别。所以在正式学习设计模式之前,我们有必要先了解一些JavaScript在面向对象方面的知识。1.1 动态类型语言和鸭子类型

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。

静态类型语言的优点首先是在编译时就能发现类型不匹配的错误,编辑器可以帮助我们提前避免程序在运行期间有可能发生的一些错误。其次,如果在程序中明确地规定了数据类型,编译器还可以针对这些信息对程序进行一些优化工作,提高程序执行速度。

静态类型语言的缺点首先是迫使程序员依照强契约来编写程序,为每个变量规定数据类型,归根结底只是辅助我们编写可靠性高程序的一种手段,而不是编写程序的目的,毕竟大部分人编写程序的目的是为了完成需求交付生产。其次,类型的声明也会增加更多的代码,在程序编写过程中,这些细节会让程序员的精力从思考业务逻辑上分散开来。

动态类型语言的优点是编写的代码数量更少,看起来也更加简洁,程序员可以把精力更多地放在业务逻辑上面。虽然不区分类型在某些情况下会让程序变得难以理解,但整体而言,代码量越少,越专注于逻辑表达,对阅读程序是越有帮助的。

动态类型语言的缺点是无法保证变量的类型,从而在程序的运行期有可能发生跟类型相关的错误。这好像在商店买了一包牛肉辣条,但是要真正吃到嘴里才知道是不是牛肉味。

在JavaScript中,当我们对一个变量赋值时,显然不需要考虑它的类型,因此,JavaScript是一门典型的动态类型语言。

动态类型语言对变量类型的宽容给实际编码带来了很大的灵活性。由于无需进行类型检测,我们可以尝试调用任何对象的任意方法,而无需去考虑它原本是否被设计为拥有该方法。

这一切都建立在鸭子类型(duck typing)的概念上,鸭子类型的通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”

我们可以通过一个小故事来更深刻地了解鸭子类型。从前在JavaScript王国里,有一个国王,他觉得世界上

最美妙的声音就是鸭子的叫声,于是国王召集大臣,要组建

一个1000只鸭子组成的合唱团。大臣们找遍了全国,终于

找到999只鸭子,但是始终还差一只,最后大臣发现有一只

非常特别的鸡,它的叫声跟鸭子一模一样,于是这只鸡就成

为了合唱团的最后一员。

这个故事告诉我们,国王要听的只是鸭子的叫声,这个声音的主人到底是鸡还是鸭并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注HAS-A, 而不是IS-A。

下面我们用代码来模拟这个故事。var duck = { duckSinging: function(){ console.log( '嘎嘎嘎' ); }};var chicken = { duckSinging: function(){ console.log( '嘎嘎嘎' ); }};var choir = []; // 合唱团var joinChoir = function( animal ){ if ( animal && typeof animal.duckSinging === 'function' ){ choir.push( animal ); console.log( '恭喜加入合唱团' ); console.log( '合唱团已有成员数量:' + choir.length ); }};joinChoir( duck ); // 恭喜加入合唱团joinChoir( chicken ); // 恭喜加入合唱团

我们看到,对于加入合唱团的动物,大臣们根本无需检查它们的类型,而是只需要保证它们拥有duckSinging方法。如果下次期望加入合唱团的是一只小狗,而这只小狗刚好也会鸭子叫,我相信这只小狗也能顺利加入。

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。例如,一个对象若有push和pop方法,并且这些方法提供了正确的实现,它就可以被当作栈来使用。一个对象如果有length属性,也可以依照下标来存取属性(最好还要拥有slice和splice等方法),这个对象就可以被当作数组来使用。

在静态类型语言中,要实现“面向接口编程”并不是一件容易的事情,往往要通过抽象类或者接口等将对象进行向上转型。当对象的真正类型被隐藏在它的超类型身后,这些对象才能在类型检查系统的“监视”之下互相被替换使用。只有当对象能够被互相替换使用,才能体现出对象多态性的价值。“面向接口编程”是设计模式中最重要的思想,但在JavaScript语言中,“面向接口编程”的过程跟主流的静态类型语言不一样,因此,在JavaScript中实现设计模式的过程与在一些我们熟悉的语言中实现的过程会大相径庭。1.2 多态“多态”一词源于希腊文polymorphism,拆开来看是poly(复数)+ morph(形态)+ ism,从字面上我们可以理解为复数形态。

多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。

从字面上来理解多态不太容易,下面我们来举例说明一下。主人家里养了两只动物,分别是一只鸭和一只鸡,当主

人向它们发出“叫”的命令时,鸭会“嘎嘎嘎”地叫,而鸡

会“咯咯咯”地叫。这两只动物都会以自己的方式来发出叫

声。它们同样“都是动物,并且可以发出叫声”,但根据主

人的指令,它们会各自发出不同的叫声。

其实,其中就蕴含了多态的思想。下面我们通过代码进行具体的介绍。1.2.1 一段“多态”的JavaScript代码

我们把上面的故事用JavaScript代码实现如下:var makeSound = function( animal ){ if ( animal instanceof Duck ){ console.log( '嘎嘎嘎' ); }else if ( animal instanceof Chicken ){ console.log( '咯咯咯' ); }};var Duck = function(){};var Chicken = function(){};makeSound( new Duck() ); // 嘎嘎嘎makeSound( new Chicken() ); // 咯咯咯

这段代码确实体现了“多态性”,当我们分别向鸭和鸡发出“叫唤”的消息时,它们根据此消息作出了各自不同的反应。但这样的“多态性”是无法令人满意的,如果后来又增加了一只动物,比如狗,显然狗的叫声是“汪汪汪”,此时我们必须得改动makeSound函数,才能让狗也发出叫声。修改代码总是危险的,修改的地方越多,程序出错的可能性就越大,而且当动物的种类越来越多时,makeSound有可能变成一个巨大的函数。

多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与 “可能改变的事物”分离开来。在这个故事中,动物都会叫,这是不变的,但是不同类型的动物具体怎么叫是可变的。把不变的部分隔离出来,把可变的部分封装起来,这给予了我们扩展程序的能力,程序看起来是可生长的,也是符合开放—封闭原则的,相对于修改代码来说,仅仅增加代码就能完成同样的功能,这显然优雅和安全得多。1.2.2 对象的多态性

下面是改写后的代码,首先我们把不变的部分隔离出来,那就是所有的动物都会发出叫声:var makeSound = function( animal ){ animal.sound();};

然后把可变的部分各自封装起来,我们刚才谈到的多态性实际上指的是对象的多态性:var Duck = function(){}Duck.prototype.sound = function(){ console.log( '嘎嘎嘎' );};var Chicken = function(){}Chicken.prototype.sound = function(){ console.log( '咯咯咯' );};makeSound( new Duck() ); // 嘎嘎嘎makeSound( new Chicken() ); // 咯咯咯

现在我们向鸭和鸡都发出“叫唤”的消息,它们接到消息后分别作出了不同的反应。如果有一天动物世界里又增加了一只狗,这时候只要简单地追加一些代码就可以了,而不用改动以前的makeSound函数,如下所示:var Dog = function(){}Dog.prototype.sound = function(){ console.log( '汪汪汪' );};makeSound( new Dog() ); // 汪汪汪1.2.3 类型检查和多态

类型检查是在表现出对象多态性之前的一个绕不开的话题,但JavaScript是一门不必进行类型检查的动态类型语言,为了真正了解多态的目的,我们需要转一个弯,从一门静态类型语言说起。

我们在1.1节已经说明过静态类型语言在编译时会进行类型匹配检查。以Java为例,由于在代码编译时要进行严格的类型检查,所以不能给变量赋予不同类型的值,这种类型检查有时候会让代码显得僵硬,代码如下:String str;str = "abc"; // 没有问题str = 2; // 报错

现在我们尝试把上面让鸭子和鸡叫唤的例子换成Java代码:public class Duck { // 鸭子类 public void makeSound(){ System.out.println( "嘎嘎嘎" ); }}public class Chicken { // 鸡类 public void makeSound(){ System.out.println( "咯咯咯" ); }}public class AnimalSound { public void makeSound( Duck duck ){ // (1) duck.makeSound(); }}public class Test { public static void main( String args[] ){ AnimalSound animalSound = new AnimalSound(); Duck duck = new Duck(); animalSound.makeSound( duck ); // 输出:嘎嘎嘎 }}

我们已经顺利地让鸭子可以发出叫声,但如果现在想让鸡也叫唤起来,我们发现这是一件不可能实现的事情。因为(1)处AnimalSound类的makeSound方法,被我们规定为只能接受Duck类型的参数:public class Test { public static void main( String args[] ){ AnimalSound animalSound = new AnimalSound(); Chicken chicken = new Chicken(); animalSound.makeSound( chicken ); // 报错,只能接受Duck类型的参数 }}

某些时候,在享受静态语言类型检查带来的安全性的同时,我们亦会感觉被束缚住了手脚。

为了解决这一问题,静态类型的面向对象语言通常被设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。这就像我们在描述天上的一只麻雀或者一只喜鹊时,通常说“一只麻雀在飞”或者“一只喜鹊在飞”。但如果想忽略它们的具体类型,那么也可以说“一只鸟在飞”。

同理,当Duck对象和Chicken对象的类型都被隐藏在超类型Animal身后,Duck对象和Chicken对象就能被交换使用,这是让对象表现出多态性的必经之路,而多态性的表现正是实现众多设计模式的目标。1.2.4 使用继承得到多态效果

使用继承来得到多态效果,是让对象表现出多态性的最常用手段。继承通常包括实现继承和接口继承。本节我们讨论实现继承,接口继承的例子请参见第21章。

我们先创建一个Animal抽象类,再分别让Duck和Chicken都继承自Animal抽象类,下述代码中(1)处和(2)处的赋值语句显然是成立的,因为鸭子和鸡也是动物:public abstract class Animal { abstract void makeSound(); // 抽象方法}public class Chicken extends Animal{ public void makeSound(){ System.out.println( "咯咯咯" ); }}public class Duck extends Animal{ public void makeSound(){ System.out.println( "嘎嘎嘎" ); }}Animal duck = new Duck(); // (1)Animal chicken = new Chicken(); // (2)

现在剩下的就是让AnimalSound类的makeSound方法接受Animal类型的参数,而不是具体的Duck类型或者Chicken类型:public class AnimalSound{ public void makeSound( Animal animal ){ // 接受Animal类型的参数 animal.makeSound(); }}public class Test { public static void main( String args[] ){ AnimalSound animalSound= new AnimalSound (); Animal duck = new Duck(); Animal chicken = new Chicken(); animalSound.makeSound( duck ); // 输出嘎嘎嘎 animalSound.makeSound( chicken ); // 输出咯咯咯 }}1.2.5 JavaScript的多态

从前面的讲解我们得知,多态的思想实际上是把“做什么”和“谁去做”分离开来,要实现这一点,归根结底先要消除类型之间的耦合关系。如果类型之间的耦合关系没有被消除,那么我们在makeSound方法中指定了发出叫声的对象是某个类型,它就不可能再被替换为另外一个类型。在Java中,可以通过向上转型来实现多态。

而JavaScript的变量类型在运行期是可变的。一个JavaScript对象,既可以表示Duck类型的对象,又可以表示Chicken类型的对象,这意味着JavaScript对象的多态性是与生俱来的。

这种与生俱来的多态性并不难解释。JavaScript作为一门动态类型语言,它在编译时没有类型检查的过程,既没有检查创建的对象类型,又没有检查传递的参数类型。在1.2.2节的代码示例中,我们既可以往makeSound函数里传递duck对象当作参数,也可以传递chicken对象当作参数。

由此可见,某一种动物能否发出叫声,只取决于它有没有makeSound方法,而不取决于它是否是某种类型的对象,这里不存在任何程度上的“类型耦合”。这正是我们从上一节的鸭子类型中领悟的道理。在JavaScript中,并不需要诸如向上转型之类的技术来取得多态的效果。1.2.6 多态在面向对象程序设计中的作用

有许多人认为,多态是面向对象编程语言中最重要的技术。但我们目前还很难看出这一点,毕竟大部分人都不关心鸡是怎么叫的,也不想知道鸭是怎么叫的。让鸡和鸭在同一个消息之下发出不同的叫声,这跟程序员有什么关系呢?

Martin Fowler在《重构:改善既有代码的设计》里写到:多态的最根本好处在于,你不必再向对象询问“你是什

么类型”而后根据得到的答案调用对象的某个行为——你

只管调用该行为就是了,其他的一切多态机制都会为你安排

妥当。

换句话说,多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。

Martin Fowler的话可以用下面这个例子很好地诠释:在电影的拍摄现场,当导演喊出“action”时,主角开

始背台词,照明师负责打灯光,后面的群众演员假装中枪倒

地,道具师往镜头里撒上雪花。在得到同一个消息时,每个

对象都知道自己应该做什么。如果不利用对象的多态性,而

是用面向过程的方式来编写这一段代码,那么相当于在电影

开始拍摄之后,导演每次都要走到每个人的面前,确认它们

的职业分工(类型),然后告诉他们要做什么。如果映射到

程序中,那么程序中将充斥着条件分支语句。

利用对象的多态性,导演在发布消息时,就不必考虑各个对象接到消息后应该做什么。对象应该做什么并不是临时决定的,而是已经事先约定和排练完毕的。每个对象应该做什么,已经成为了该对象的一个方法,被安装在对象的内部,每个对象负责它们自己的行为。所以这些对象可以根据同一个消息,有条不紊地分别进行各自的工作。

将行为分布在各个对象中,并让这些对象各自负责自己的行为,这正是面向对象设计的优点。

再看一个现实开发中遇到的例子,这个例子的思想和动物叫声的故事非常相似。

假设我们要编写一个地图应用,现在有两家可选的地图API提供商供我们接入自己的应用。目前我们选择的是谷歌地图,谷歌地图的API中提供了show方法,负责在页面上展示整个地图。示例代码如下:var googleMap = { show: function(){ console.log( '开始渲染谷歌地图' ); }};var renderMap = function(){ googleMap.show();};renderMap(); // 输出:开始渲染谷歌地图

后来因为某些原因,要把谷歌地图换成百度地图,为了让renderMap函数保持一定的弹性,我们用一些条件分支来让renderMap函数同时支持谷歌地图和百度地图:var googleMap = { show: function(){ console.log( '开始渲染谷歌地图' ); }};var baiduMap = { show: function(){ console.log( '开始渲染百度地图' ); }};var renderMap = function( type ){ if ( type === 'google' ){ googleMap.show(); }else if ( type === 'baidu' ){ baiduMap.show(); }};renderMap( 'google' ); // 输出:开始渲染谷歌地图renderMap( 'baidu' ); // 输出:开始渲染百度地图

可以看到,虽然renderMap函数目前保持了一定的弹性,但这种弹性是很脆弱的,一旦需要替换成搜搜地图,那无疑必须得改动renderMap函数,继续往里面堆砌条件分支语句。

我们还是先把程序中相同的部分抽象出来,那就是显示某个地图:var renderMap = function( map ){ if ( map.show instanceof Function ){ map.show(); }};renderMap( googleMap ); // 输出:开始渲染谷歌地图renderMap( baiduMap ); // 输出:开始渲染百度地图

现在来找找这段代码中的多态性。当我们向谷歌地图对象和百度地图对象分别发出“展示地图”的消息时,会分别调用它们的show方法,就会产生各自不同的执行结果。对象的多态性提示我们,“做什么”和“怎么去做”是可以分开的,即使以后增加了搜搜地图,renderMap函数仍然不需要做任何改变,如下所示:var sosoMap = { show: function(){ console.log( '开始渲染搜搜地图' ); }};renderMap( sosoMap ); // 输出:开始渲染搜搜地图

在这个例子中,我们假设每个地图API提供展示地图的方法名都是show,在实际开发中也许不会如此顺利,这时候可以借助适配器模式来解决问题。1.2.7 设计模式与多态

GoF所著的《设计模式》一书的副书名是“可复用面向对象软件的基础”。该书完全是从面向对象设计的角度出发的,通过对封装、继承、多态、组合等技术的反复使用,提炼出一些可重复使用的面向对象设计技巧。而多态在其中又是重中之重,绝大部分设计模式的实现都离不开多态性的思想。

拿命令模式1来说,请求被封装在一些命令对象中,这使得命令的调用者和命令的接收者可以完全解耦开来,当调用命令的execute方法时,不同的命令会做不同的事情,从而会产生不同的执行结果。而做这些事情的过程是早已被封装在命令对象内部的,作为调用命令的客户,根本不必去关心命令执行的具体过程。

1参见第9章。

在组合模式2中,多态性使得客户可以完全忽略组合对象和叶节点对象之前的区别,这正是组合模式最大的作用所在。对组合对象和叶节点对象发出同一个消息的时候,它们会各自做自己应该做的事情,组合对象把消息继续转发给下面的叶节点对象,叶节点对象则会对这些消息作出真实的反馈。

2参见第10章。

在策略模式3中,Context并没有执行算法的能力,而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出“计算”的消息时,它们会返回各自不同的计算结果。

3参见第5章。

在JavaScript这种将函数作为一等对象的语言中,函数本身也是对象,函数用来封装行为并且能够被四处传递。当我们对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在JavaScript中可以用高阶函数来代替实现的原因。1.3 封装

封装的目的是将信息隐藏。一般而言,我们讨论的封装是封装数据和封装实现。这一节将讨论更广义的封装,不仅包括封装数据和封装实现,还包括封装类型和封装变化。1.3.1 封装数据

在许多语言的对象系统中,封装数据是由语法解析来实现的,这些语言也许提供了private、public、protected等关键字来提供不同的访问权限。

但JavaScript并没有提供对这些关键字的支持,我们只能依赖变量的作用域来实现封装特性,而且只能模拟出public和private这两种封装性。

除了ECMAScript 6中提供的let之外,一般我们通过函数来创建作用域:var myObject = (function(){ var __name = 'sven'; // 私有(private)变量 return { getName: function(){ // 公开(public)方法 return __name; } }})();console.log( myObject.getName() ); // 输出:svenconsole.log( myObject.__name ) // 输出:undefined

另外值得一提的是,在ECAMScript 6中,还可以通过Symbol创建私有属性。详情可参阅 https://github.com/lukehoban/es6features,二维码见下边。1.3.2 封装实现

上一节描述的封装,指的是数据层面的封装。有时候我们喜欢把封装等同于封装数据,但这是一种比较狭义的定义。

封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。

从封装实现细节来讲,封装使得对象内部的变化对其他对象而言是透明的,也就是不可见的。对象对它自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的API接口来通信。当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。

封装实现细节的例子非常之多。拿迭代器来说明,迭代器的作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。我们编写了一个each函数,它的作用就是遍历一个聚合对象,使用这个each函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确便可以。即使each函数修改了内部源代码,只要对外的接口或者调用方式没有变化,用户就不用关心它内部实现的改变。1.3.3 封装类型

封装类型是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的4。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。比如工厂方法模式、组合模式等。

4详情可参阅1.2节中的Animal示例。

当然在JavaScript中,并没有对抽象类和接口的支持。JavaScript本身也是一门类型模糊的语言。在封装类型方面,JavaScript没有能力,也没有必要做得更多。对于JavaScript的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。在后面章节的学习中,我们可以慢慢了解这一点。1.3.4 封装变化

从设计模式的角度出发,封装在更重要的层面体现为封装变化。《设计模式》一书曾提到如下文字:“考虑你的设计中哪些地方可能变化,这种方式与关注

会导致重新设计的原因相反。它不是考虑什么时候会迫使你

的设计改变,而是考虑你怎样才能够在不重新设计的情况下

进行改变。这里的关键在于封装发生变化的概念,这是许多

设计模式的主题。”

这段文字即是《设计模式》提到的“找到变化并封装之”。《设计模式》一书中共归纳总结了23种设计模式。从意图上区分,这23种设计模式分别被划分为创建型模式、结构型模式和行为型模式。

拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。

通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。

从《设计模式》副标题“可复用面向对象软件的基础”可以知道,这本书理应教我们如何编写可复用的面向对象程序。这本书把大多数笔墨都放在如何封装变化上面,这跟编写可复用的面向对象程序是不矛盾的。当我们想办法把程序中变化的部分封装好之后,剩下的即是稳定而可复用的部分了。1.4 原型模式和基于原型继承的JavaScript对象系统

在Brendan Eich为JavaScript设计面向对象系统时,借鉴了Self和Smalltalk这两门基于原型的语言。之所以选择基于原型的面向对象系统,并不是因为时间匆忙,它设计起来相对简单,而是因为从一开始Brendan Eich就没有打算在JavaScript中加入类的概念。

在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程的思想中,类并不是必需的,对象未必需要从类中创建而来,一个对象是通过克隆另外一个对象所得到的。就像电影《第六日》一样,通过克隆可以创造另外一个一模一样的人,而且本体和克隆体看不出任何区别。

原型模式不单是一种设计模式,也被称为一种编程泛型。

本节我们将首先学习第一个设计模式——原型模式。随后会了解基于原型的Io语言,借助对Io语言的了解,我们对JavaScript的面向对象系统也将有更深的认识。在本节的最后,我们将详细了解JavaScript语言如何通过原型来构建一个面向对象系统。1.4.1 使用克隆的原型模式

从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另外一种方式,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。

既然原型模式是通过克隆来创建对象的,那么很自然地会想到,如果需要一个跟某个对象一模一样的对象,就可以使用原型模式。

假设我们在编写一个飞机大战的网页游戏。某种飞机拥有分身技能,当它使用分身技能的时候,要在页面中创建一些跟它一模一样的飞机。如果不使用原型模式,那么在创建分身之前,无疑必须先保存该飞机的当前血量、炮弹等级、防御等级等信息,随后将这些信息设置到新创建的飞机上面,这样才能得到一架一模一样的新飞机。

如果使用原型模式,我们只需要调用负责克隆的方法,便能完成同样的功能。

原型模式的实现关键,是语言本身是否提供了clone方法。ECMAScript 5提供了Object.create方法,可以用来克隆对象。代码如下:var Plane = function(){ this.blood = 100; this.attackLevel = 1; this.defenseLevel = 1;};var plane = new Plane();plane.blood = 500;plane.attackLevel = 10;plane.defenseLevel = 7;var clonePlane = Object.create( plane );console.log( clonePlane ); // 输出:Object {blood: 500, attackLevel: 10, defenseLevel: 7}

在不支持Object.create方法的浏览器中,则可以使用以下代码:Object.create = Object.create || function( obj ){ var F = function(){}; F.prototype = obj; return new F();}1.4.2 克隆是创建对象的手段

通过上一节的代码,我们看到了如何通过原型模式来克隆出一个一模一样的对象。但原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。

在用Java等静态类型语言编写程序的时候,类型之间的解耦非常重要。依赖倒置原则提醒我们创建对象的时候要避免依赖具体类型,而用new XXX创建对象的方式显得很僵硬。工厂方法模式和抽象工厂模式可以帮助我们解决这个问题,但这两个模式会带来许多跟产品类平行的工厂类层次,也会增加很多额外的代码。

原型模式提供了另外一种创建对象的方式,通过克隆对象,我们就不用再关心对象的具体类型名字。这就像一个仙女要送给三岁小女孩生日礼物,虽然小女孩可能还不知道飞机或者船怎么说,但她可以指着商店橱柜里的飞机模型说“我要这个”。

当然在JavaScript这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。从设计模式的角度来讲,原型模式的意义并不算大 。但JavaScript本身是一门基于原型的面向对象语言,它的对象系统就是使用原型模式来搭建的,在这里称之为原型编程范型也许更合适。1.4.3 体验Io语言

前面说过,原型模式不仅仅是一种设计模式,也是一种编程范型。JavaScript就是使用原型模式来搭建整个面向对象系统的。在JavaScript语言中不存在类的概念,对象也并非从类中创建出来的,所有的JavaScript对象都是从某个对象上克隆而来的。

对于习惯了以类为中心语言的人来说,也许一时不容易理解这种基于原型的语言。即使是对于JavaScript语言的熟练使用者而言,也可能会有一种“不识庐山真面目,只缘身在此山中”的感觉。事实上,使用原型模式来构造面向对象系统的语言远非仅有JavaScript一家。

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载