From 70e6a2701f9226e69da30a2c85da5d191df7492a Mon Sep 17 00:00:00 2001 From: wizardforcel <562826179@qq.com> Date: Thu, 3 May 2018 11:09:16 +0800 Subject: [PATCH] 6. --- 6.md | 690 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 6.md diff --git a/6.md b/6.md new file mode 100644 index 0000000..ba1c103 --- /dev/null +++ b/6.md @@ -0,0 +1,690 @@ +## 六、深入理解对象 + +> The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. +> +> Joe Armstrong,《Coders at Work》中的采访 + +当程序员说“对象”(object)时,你要知道这可是一个内涵丰富的术语。在我的职业当中,对象就是一种生活方式、是神圣工作的一部分,同时也是现如今大家热衷讨论的词语。 + +在外人看来,这些话可能有些让人摸不着头脑。让我们一起来简要了解一下对象作为编程结构的历史。 + +### 6.1 历史 + +与绝大多数程序设计的故事一样,我们先从需要解决的问题的复杂性切入。有一种理论说,我们可以将复杂的问题划分成小而独立的子问题来解决,这样复杂的问题就可以迎刃而解了。而这些子问题被称之为对象。 + +对象就像一个坚硬的外壳,它隐藏了其内部的复杂性,并提供给我们一些旋钮和接头(比如方法)作为操纵对象的接口。其想法是这样的:我们可以忽略对象内部的复杂性,转而直接使用相对简单的接口。 + +我们举个例子,假设一个对象提供了操作显示屏界面部分区域的接口,你可以使用这些接口在屏幕特定区域中绘制形状或文字,而不必考虑如何将这些形状转换成屏幕上的像素。你可以使用一套方法(比如drawCircle)来操作对象,这也是你使用该对象唯一需要了解的事情。 + +这些想法最早出现于20世纪70和80年代,而在20世纪90年代,随着面向对象的编程变革的兴起,这些思想才真正流行起来。突然之间,就有一群人开始宣扬面向对象才是编程的正道,而且任何没有使用面向对象思想的程序都是过时的设计。 + +![](../Images/00191.jpeg) + +这些狂热的言论总会造成一些不切实际的荒谬行径,也因此产生了一群反对面向对象变革的人。现如今在某些领域里,面向对象甚至被认为是一种不好的设计。 + +我喜欢从实际应用的角度,而非从单纯的意识形态上来看待问题。面向对象的变革推动了一些概念的流行,其中不乏一些十分有用的概念,其中最重要的一个概念就是封装(将内部的复杂性与外部接口区分开来)。这些概念值得我们学习。 + +本章将会对JavaScript中处理对象的不同之处以及一般的面向对象技术进行详细阐述。 + +### 6.2 方法 + +方法只是引用了函数值的属性。以下是一个简单的方法: + +``` +var rabbit = {}; +rabbit.speak = function(line) { + console.log("The rabbit says '" + line + "'"); +}; + +rabbit.speak("I'm alive."); +// → The rabbit says 'I'm alive.' +``` + +方法通常会在对象被调用时执行一些操作。将函数作为对象的方法调用时,会找到对象中对应的属性并直接调用。在调用object.method()时,对象中的一个特殊变量this会指向当前方法所属的对象。 + +``` +function speak(line) { + console.log("The " + this.type + " rabbit says '" + + line + "'"); +} +var whiteRabbit = {type: "white", speak: speak}; +var fatRabbit = {type: "fat", speak: speak}; + +whiteRabbit.speak("Oh my ears and whiskers, " + + "how late it's getting!"); +// → The white rabbit says 'Oh my ears and whiskers, how +// late it's getting!' +fatRabbit.speak("I could sure use a carrot right now."); +// → The fat rabbit says 'I could sure use a carrot +// right now.' +``` + +这段代码使用了关键字this来输出正在说话的兔子的种类。我们回想一下apply和bind方法,这两个方法接受的第一个参数可以用来模拟对象中方法的调用。这两个方法会把第一个参数复制给this。 + +函数有一个call方法,类似于apply方法。该方法也可以用于函数调用,但该方法会像普通函数一样接受参数,我们不需要将参数放到数组中。和apply和bind方法一样,你也可以向call方法传递一个特定的this值。 + +``` +speak.apply(fatRabbit, ["Burp!"]); +// → The fat rabbit says 'Burp!' +speak.call({type: "old"}, "Oh my."); +// → The old rabbit says 'Oh my.' +``` + +### 6.3 原型 + +我们来仔细看看以下这段代码。 + +``` +var empty = {}; +console.log(empty.toString); +// → function toString(){…} +console.log(empty.toString()); +// → [object Object] +``` + +我从空的对象中取出了一个属性。有意思! + +实际上并非如此。我只是掩盖了一些JavaScript对象的内部工作细节罢了。每个对象除了拥有自己的属性外,几乎都包含一个原型(prototype)。原型是另一个对象,是对象的一个属性来源。当开发人员访问一个对象不包含的属性时,就会从对象原型中搜索属性,接着是原型的原型,依此类推。 + +那么空对象的原型是什么呢?是Object.prototype,它是所有对象中原型的父原型。 + +``` +console.log(Object.getPrototypeOf({}) == + Object.prototype); +// → true +console.log(Object.getPrototypeOf(Object.prototype)); +// → null +``` + +和我们所想的结果一样,Object.getPrototypeOf函数返回了一个空对象的原型,即Object.prototype。 + +JavaScript对象原型的关系是一种树形结构,整个树形结构的根部就是Object.prototype。Object.prototype提供了一些可以在所有对象中使用的方法。比如说,toString方法可以将一个对象转换成其字符串表示形式。 + +许多对象并不直接将Object.prototype作为其原型,而会使用另一个原型对象,用于提供对象自己的默认属性。函数继承自Function.prototype,而数组继承自Array.prototype。 + +``` +console.log(Object.getPrototypeOf(isNaN) == + Function.prototype); +// → true +console.log(Object.getPrototypeOf([]) == + Array.prototype); +// → true +``` + +对于这样的原型对象来说,其自身也包含了一个原型对象,通常情况下是Object.prototype,所以说,这些原型对象可以间接提供toString这样的方法。 + +Object.getPrototypeOf函数返回的结果是对象原型。你可以利用一个特定的原型来使用Object.create方法创建对象。 + +``` +var protoRabbit = { + speak: function(line) { + console.log("The " + this.type + " rabbit says '" + + line + "'"); + } +}; +var killerRabbit = Object.create(protoRabbit); +killerRabbit.type = "killer"; +killerRabbit.speak("SKREEEE!"); +// → The killer rabbit says 'SKREEEE!' +``` + +原型对象protoRabbit是一个容器,用于包含所有兔子对象的公有属性。每个独立的兔子对象(比如killerRabbit)可以包含其自身属性(比如本例中的type属性),也可以派生其原型对象中公有的属性。 + +### 6.4 构造函数 + +这里有一种更加简便的方法创建对象,我们可以直接从一些公有原型中派生并构造对象,即使用构造函数来创建对象。在JavaScript中,调用函数之前添加一个关键字new则表示调用其构造函数。构造函数中包含了指向新对象的变量this,除非构造函数显式地返回了另一个对象的值,否则构造函数会返回这个新创建的对象。 + +通过关键字new创建的对象称之为构造函数的实例。 + +这里给出一个简单的用于创建rabbit的构造函数。按照惯例,构造函数的名称一般以大写字母开头,这样便于区分构造函数与其他的函数。 + +``` +function Rabbit(type) { + this.type = type; +} + +var killerRabbit = new Rabbit("killer"); +var blackRabbit = new Rabbit("black"); +console.log(blackRabbit.type); +// → black +``` + +对于构造函数来说(实际上,对所有函数适用),都会自动获得一个名为prototype的属性。在默认情况下,该属性是一个普通的派生自Object.prototype的空对象。所有使用特定构造函数创建的对象都会将构造函数的prototype属性作为其原型。因此,我们可以很容易地为所有使用Rabbit构造函数创建的对象添加speak方法。 + +``` +Rabbit.prototype.speak = function(line) { + console.log("The " + this.type + " rabbit says '" + + line + "'"); +}; +blackRabbit.speak("Doom..."); +// → The black rabbit says 'Doom...' +``` + +我们需要注意两种方法使用上的区别,第一种是使用构造函数建立对象与原型之间的关联(直接通过prototype属性来获取),第二种是将原型对象作为对象的属性(使用Object.getPrototyOf获取属性)。构造函数其实就是函数,因此其实际原型是Function.prototype。而构造函数的prototype属性则是其所创建的实例的原型,而非构造函数自身的原型。 + +### 6.5 覆盖继承的属性 + +当你向一个对象添加属性时,无论该属性是否存已经存在于对象原型中,该属性都会被添加到这个对象中去,并作为对象自己的属性使用。如果原型中存在同名属性,那么在调用该属性时,就不会再调用原型中的那个属性了,转而调用我们添加到对象中的属性。但原型本身不会被修改。 + +``` +Rabbit.prototype.teeth = "small"; +console.log(killerRabbit.teeth); +// → small +killerRabbit.teeth = "long, sharp, and bloody"; +console.log(killerRabbit.teeth); +// → long, sharp, and bloody +console.log(blackRabbit.teeth); +// → small +console.log(Rabbit.prototype.teeth); +// → small +``` + +下图简单地描述了代码执行后的情况。其中Rabbit和Object原型画在了killerRabbit之下,我们可以从原型中找到对象中没有的属性。 + +![](../Images/00204.jpeg) + +覆盖原型中存在的属性是很有用的一个特性。就像示例展示的那样,我们覆盖了killerRabbit的teeth属性,这可以用来描述实例(对象中更为泛化的类的实例)的特殊属性,同时又可以让普通对象从原型中获取标准的值。 + +我们也可以为标准函数和数组原型提供一个不同于Object原型的toString方法。 + +``` +console.log(Array.prototype.toString == + Object.prototype.toString); +// → false +console.log([1, 2].toString()); +// → 1,2 +``` + +调用数组的toString方法后得到的结果与调用.join(“,”)的结果十分类似,即在数组的每个值之间插入一个逗号。而直接使用数组调用Object.prototype.toString则会产生一个完全不同的字符串。由于Object原型提供的toString方法并不了解数组结构,因此只会简单地输出一对方括号,并在方括号中间输出单词“object”和类型的名称。 + +``` +console.log(Object.prototype.toString.call([1, 2])); +// → [object Array] +``` + +### 6.6 原型污染 + +我们随时都可以使用原型对象添加新的属性和方法。比如说,我们也许希望兔子能够跳舞。 + +``` +Rabbit.prototype.dance = function() { + console.log("The " + this.type + " rabbit dances a jig."); +}; +killerRabbit.dance(); +// → The killer rabbit dances a jig. +``` + +这种方法非常方便,但有时也会引发问题。在前面的章节中,我们使用对象将一些值和名称关联起来,实现方法是为这些名称创建对应的属性,并赋予这些属性相应的值。这里给出第4章中的示例: + +``` +var map = {}; +function storePhi(event, phi) { + map[event] = phi; +} + +storePhi("pizza", 0.069); +storePhi("touched tree", -0.081); +``` + +我们可以使用for/in循环遍历对象中所有的phi系数,并使用in操作符测试对象是否包含对应的属性。但不幸的是,这种方式会到对象的原型中寻找属性。 + +``` +Object.prototype.nonsense = "hi"; +for (var name in map) + console.log(name); +// → pizza +// → touched tree +// → nonsense +console.log("nonsense" in map); +// → true +console.log("toString" in map); +// → true + +// Delete the problematic property again +delete Object.prototype.nonsense; +``` + +上面的代码是错误的,我们的数据集中并没有名为“nonsense”的事件。 + +而且这里肯定也没有名为“toString”的事件。 + +奇怪的是,toString并没有出现在for/in循环中,而使用in运算符测试时则返回true。这是因为JavaScript会区分“可枚举(enumerable)”与“不可枚举(nonenumerable)”属性。 + +我们创建并赋予对象的所有属性都是可枚举的。而Object.prototype中的标准属性都不可枚举,因此这些标准属性不会出现在for/in循环中。 + +我们可以使用Object.defineProperty函数定义自己的不可枚举属性,该函数允许我们在创建属性时控制属性类型。 + +``` +Object.defineProperty(Object.prototype, "hiddenNonsense", + {enumerable: false, value: "hi"}); +for (var name in map) + console.log(name); +// → pizza +// → touched tree +console.log(map.hiddenNonsense); +// → hi +``` + +因此现在hiddenNonsense属性确实存在,但不会出现在循环中。这就是我们想要的效果。但目前依然有个问题,常规的in运算符会认为Object.prototype中的属性存在于我们的对象中。为此,我们可以使用对象的hasOwnProperty方法。 + +``` +console.log(map.hasOwnProperty("toString")); +// → false +``` + +该方法告知我们对象自身是否包含某个属性,而不会搜索其原型。该方法提供的信息往往比in运算符给出的结果更有用。 + +当你担心某些人(装载到你程序中的某些其他代码)会干扰基础对象的原型时,我建议这样使用for/in循环: + +``` +for (var name in map) { + if (map.hasOwnProperty(name)) { + // ... this is an own property + } +} +``` + +### 6.7 无原型对象 + +不过兔子洞的故事还没结束。如果有人在我们的map对象中添加了hasOwnProperty属性并将其值设置为42该怎么办?现在我们调用map.hasOwnProperty就会调用当前对象的属性,该属性指向一个数字,而不是函数。 + +在这种情况下,原型只会给我们造成不必要的麻烦,而我们实际上需要的是一个没有原型的对象。我们可以使用Object.create函数并根据特定原型来创建对象。你可以传递null作为原型,并创建一个无原型对象。对于像map这样的对象来说,属性可以是任何元素,我们恰好可以使用这种方式来创建对象。 + +``` +var map = Object.create(null); +map["pizza"] = 0.069; +console.log("toString" in map); +// → false +console.log("pizza" in map); +// → true +``` + +好多了!我们不再需要hasOwnProperty函数,因为对象的所有属性都是对象自己的属性。现在,无论是否有人修改Object.prototype,我们都可以安全地使用for/in循环了。 + +### 6.8 多态 + +当你对某个对象调用String函数将对象的值转换成字符串时,该函数将会调用对象的toString方法,创建并返回一个包含某些含义的字符串。我曾提到过一些标准类型的原型都定义了各自版本的toString方法,因此这些对象创建的字符串会比“[object Object]”这样的结果更有意义。 + +我们用简单的例子演示了这个强大的功能。当编写一段代码时,我们可以使用包含特定接口的对象进行工作。在这个例子中是toString方法,只要对象支持这些接口,我们就可以将这些对象插入代码中,并保证代码正常工作。 + +我们将这种技术称为多态(polymorphism)。虽然在整个过程中没有修改任何东西的形状,但我们还是这么称呼这种技术。我们可以利用多态来操作不同类型的值,只要这些值支持所需的接口即可。 + +### 6.9 绘制表格 + +我们来通过一个稍微复杂的例子来深入了解一下多态和面向对象的编程思想。我们编写一个程序,将一个由表格单元格组成的二维数组转化成字符串,该字符串包含了与二维数组对应且布局规整的表格,要求每一列笔直整齐,每一行也要保证对齐。构建出来的字符串如下所示: + +``` +name height country +------------ ------ ------------- +Kilimanjaro 5895 Tanzania +Everest 8848 Nepal +Mount Fuji 3776 Japan +Mont Blanc 4808 Italy/France +Vaalserberg 323 Netherlands +Denali 6168 United States +Popocatepetl 5465 Mexico +``` + +我们的表格绘制程序的构建函数会根据用户输入的宽度和高度来确定每列的宽度和每行的高度。接着,构建函数会根据之前输入的参数来绘制表格,并将最终结果用字符串输出。 + +排版程序将会通过定义良好的接口来操作单元格对象。这样一来,程序就可以支持程序自定义的单元格类型。我们日后可以为程序增加新的单元格类型。比如说,如果想支持表头中的下划线单元格,我们不需要修改排版程序的源代码,只要这些单元格支持我们提供的接口即可。接口的定义如下: + +·minHeight():该方法返回一个数字,表示单元格所需的最小高度(行高)。 + +·minWidth():该方法返回一个数字,表示单元格所需的最小宽度(字符宽度)。 + +·draw(width,height):该方法返回一个数组,其长度为height,数组中每个元素是一个字符串,每个字符串宽度为width。该数组表示的是单元格内容。我会在程序中多次使用数组提供的高阶函数,这些函数能够为我们的程序提供良好的支持。 + +首先,程序会计算每列的最小宽度和每行的最大高度,并保存到数组中。变量rows是一个二维数组,其中的每个数组元素用来表示一个单元格组成的行。 + +``` +function rowHeights(rows) { + return rows.map(function(row) { + return row.reduce(function(max, cell) { + return Math.max(max, cell.minHeight()); + }, 0); + }); +} + +function colWidths(rows) { + return rows[0].map(function(_, i) { + return rows.reduce(function(max, row) { + return Math.max(max, row[i].minWidth()); + }, 0); + }); +} +``` + +以下划线(_)开头或只包含单个下划线的变量名表示(告诉代码阅读者)代码中不会使用该参数。 + +rowHeights函数应该很好理解,该函数使用map逐行处理rows数组,在map的函数中使用reduce计算每个单元格数组中单元格的最大高度。 + +colWidths函数则稍微复杂一些,因为rows数组的数组元素表示一行,而非一列。我之前并未提到过map函数的第二个参数(forEach、filter以及其他类似的数组方法都有该参数),该参数表示当前元素的索引编号。colWidths函数调用了第一行单元格数组的map方法,并且只使用了map函数的第二个参数(表示列索引编号)来创建一个数组,数组中每个元素表示表格每一列的最大宽度。随后调用rows数组的reduce方法进行逐行遍历,计算出当前这一列单元格的最大宽度并填入map函数创建的数组中。 + +下面是绘制表格的代码: + +``` +function drawTable(rows) { + var heights = rowHeights(rows); + var widths = colWidths(rows); + + function drawLine(blocks, lineNo) { + return blocks.map(function(block) { + return block[lineNo]; + }).join(" "); + } + + function drawRow(row, rowNum) { + var blocks = row.map(function(cell, colNum) { + return cell.draw(widths[colNum], heights[rowNum]); + }); + return blocks[0].map(function(_, lineNo) { + return drawLine(blocks, lineNo); + }).join("\n"); + } + + return rows.map(drawRow).join("\n"); +} +``` + +drawTable函数中使用了一个辅助函数drawRow来绘制表格的行,然后将每一行绘制好表格用换行符链接起来。 + +drawRow函数先将行中的单元格对象转换成块(block),每个块是一个字符串数组,表示单元格内容,每个字符串表示单元格中的一行。一个只包含数字3776的单元格可能表示成一个单元素数组:[“3776”],而附加下划线的单元格需要两行,使用数组表示就是["name","---"]。 + +每行中所有块的高度需要保证一致,而且在最终的输出结果中,这些块应该彼此紧挨才对。drawRow中的第二个map调用会对最左侧的块进行映射,并通过map的第二个参数获得每一行的索引,接着使用drawLine函数绘制出每行(所有块中的某一行)的内容,逐行构建出最终输出。drawRow使用换行符来连接绘制好的每行文本,然后返回连接结果。 + +drawLine函数每次从所有块中提取出属于同一行的文本,并将彼此相邻的块用一个空格字符连接起来,这样可以在表格的每一列之间创建一个字符宽度的间隔。 + +现在,我们来编写用于创建文本单元格的构造函数,实现表格的单元格接口。构造函数使用split方法将字符串分割成数组,数组中每个元素是一行文本。我们可以给split方法指定一个字符串,每当遇到该字符串时,split方法会使用该参数分割字符串,并返回分割后的字符串片段数组。minWidth方法用于找出数组中字符串的最大宽度。 + +``` +function repeat(string, times) { + var result = ""; + for (var i = 0; i < times; i++) + result += string; + return result; +} + +function TextCell(text) { + this.text = text.split("\n"); +} +TextCell.prototype.minWidth = function() { + return this.text.reduce(function(width, line) { + return Math.max(width, line.length); + }, 0); +}; +TextCell.prototype.minHeight = function() { + return this.text.length; +}; +TextCell.prototype.draw = function(width, height) { + var result = []; + for (var i = 0; i < height; i++) { + var line = this.text[i] || ""; + result.push(line + repeat(" ", width - line.length)); + } + return result; +}; +``` + +代码中使用了repeat这个辅助函数将string参数连接起来,重复的次数是times次。draw方法向每行文本中填充空格,使得每行文本都能满足最小长度的需求。 + +让我们来使用编写好的程序来创建一个5x5的棋盘。 + +``` +var rows = []; +for (var i = 0; i < 5; i++) { + var row = []; + for (var j = 0; j < 5; j++) { + if ((j + i) % 2 == 0) + row.push(new TextCell("##")); + else + row.push(new TextCell(" ")); + } + rows.push(row); +} +console.log(drawTable(rows)); +// → ## ## ## +// ## ## +// ## ## ## +// ## ## +// ## ## ## +``` + +成功了!由于每个单元格的尺寸都相同,因此我们的代码并不需要执行多么复杂的逻辑。 + +你可以从本书网站中沙箱([http://eloquentjavascript.net/code/](http://eloquentjavascript.net/code/))的数据集列表下载有关山脉的数据,数据存储在MOUNTAINS变量中。 + +现在我们准备在每个格子下方添加一系列破折号作为下划线,将包含列名在内的首行内容进行高亮。没问题,我们只需编写一个下划线类型的单元格即可。 + +``` +function UnderlinedCell(inner) { + this.inner = inner; +} +UnderlinedCell.prototype.minWidth = function() { + return this.inner.minWidth(); +}; +UnderlinedCell.prototype.minHeight = function() { + return this.inner.minHeight() + 1; +}; +UnderlinedCell.prototype.draw = function(width, height) { + return this.inner.draw(width, height - 1) + .concat([repeat("-", width)]); +}; +``` + +下划线类型的单元格中会包含另一个单元格。该单元格的最小尺寸与内部单元格相同(直接调用内部单元格的minWidth和minHeight方法即可),只不过这里为了容纳一行下划线,需要将高度加1。 + +绘制这种单元格非常简单,我们只需要获取内部单元格的内容,并与一行全是破折号的字符串连接在一起即可。 + +既然已经支持带下划线的单元格,那么我们现在可以编写一个函数,根据我们的数据集构建出对应的表格。 + +``` +function dataTable(data) { + var keys = Object.keys(data[0]); + var headers = keys.map(function(name) { + return new UnderlinedCell(new TextCell(name)); + }); + var body = data.map(function(row) { + return keys.map(function(name) { + return new TextCell(String(row[name])); + }); + }); + return [headers].concat(body); +} + +console.log(drawTable(dataTable(MOUNTAINS))); +// → name height country +// ------------ ------ ------------- +// Kilimanjaro 5895 Tanzania +// … etcetera +``` + +标准Object.keys函数会返回一个数组,该数组中存储了对象中的所有属性名称。表格的首行需要包含带下划线的单元格,用于展示列名。在首行下方,我们将数据中的所有对象的值包装成一般的单元格,使用map来处理对象的keys数组,然后从对象中提取数据,确保每行单元格的列顺序相同。 + +最后产生的表格与之前的示例类似,但是height列中的数字没有右对齐。我们稍后再去实现右对齐的功能。 + +### 6.10 Getter与Setter + +在定义接口的时候,我们可以在对象中增加一些非方法属性。我们可以定义minHeight和minWidth属性来存储数字值。但这就需要我们在构造函数中加入计算高度与宽度的逻辑,这样会在构造函数中添加与构造对象无关的代码,并导致一些问题。比如带下划线单元格的内部单元格改变时,带下划线单元格的尺寸也需要改变。 + +这就促使一些人采用了一种原则,即接口中的所有属性必须都是方法。我们并不直接访问简单的值属性,而是使用get和set方法读取修改属性。使用这种原则的缺点是我们需要额外编写大量用于修改和读取属性的方法。 + +幸运的是,JavaScript提供了一种技术,兼具两种方法的优点。我们可以像普通属性一样从外部访问属性,只不过将与属性关联的方法悄悄隐藏起来了。 + +``` +var pile = { + elements: ["eggshell", "orange peel", "worm"], + get height() { + return this.elements.length; + }, + set height(value) { + console.log("Ignoring attempt to set height to", value); + } +}; + +console.log(pile.height); +// → 3 +pile.height = 100; +// → Ignoring attempt to set height to 100 +``` + +在对象中,get或set方法用于指定属性的读取函数和修改函数,读取或修改属性时会自动调用这些函数。你也可以向已存在的对象中添加这类属性,比如使用Object.defineProperty函数向原型添加属性(这与我们之前使用该函数创建不可枚举属性是一样的)。 + +``` +Object.defineProperty(TextCell.prototype, "heightProp", { + get: function() { return this.text.length; } +}); + +var cell = new TextCell("no\nway"); +console.log(cell.heightProp); +// → 2 +cell.heightProp = 100; +console.log(cell.heightProp); +// → 2 +``` + +类似的,你也可以在传递给defineProperty的对象中使用set属性指定设置器方法。当定义了获取器但未定义设置器时,JavaScript会简单地忽略所有的属性修改操作。 + +### 6.11 继承 + +我们还差一些工作才能完成绘制表格的程序。我们希望将数字列右对齐以增强表格的可读性。我们应该创建另一个类似于TextCell的单元格类型,只不过不是右侧填补空格,而是在左侧填补空格,这样就可以将内容右对齐。 + +我们可以简单地编写一个新的构造函数,并在原型中添加三个方法。但由于原型自身可以包含原型的,因此我们可以采取更为巧妙的方法来完成任务。 + +``` +function RTextCell(text) { + TextCell.call(this, text); +} +RTextCell.prototype = Object.create(TextCell.prototype); +RTextCell.prototype.draw = function(width, height) { + var result = []; + for (var i = 0; i < height; i++) { + var line = this.text[i] || ""; + result.push(repeat(" ", width - line.length) + line); + } + return result; +}; +``` + +我们重用了TextCell的构造函数、minHeight和minWidth属性。RTextCell基本上就是TextCell,只不过draw方法中包含了不同的函数。 + +我们将这种模式称之为继承。我们可以使用继承来花很少的力气构造出与当前类型相似的数据类型,其中两个数据结构只有细微差别。新的构造函数通常会调用旧的构造函数(使用call方法将新对象作为旧构造函数的this值)。当调用构造函数时,我们可以认为所有旧的对象类型中包含的字段都已经添加到了新对象中。我们让构造函数的原型继承旧的原型对象,因此所有新类型实例都可以访问旧原型中的属性。最后,我们将一些属性添加到新的原型中并覆盖这些属性。 + +现在,如果我们稍微调整一下dataTable函数,使用RTextCall来处理数字,最终得到的表格就可以满足我们的要求了。 + +``` +function dataTable(data) { + var keys = Object.keys(data[0]); + var headers = keys.map(function(name) { + return new UnderlinedCell(new TextCell(name)); + }); + var body = data.map(function(row) { + return keys.map(function(name) { + var value = row[name]; + // This was changed: + if (typeof value == "number") + return new RTextCell(String(value)); + else + return new TextCell(String(value)); + }); + }); + return [headers].concat(body); +} + +console.log(drawTable(dataTable(MOUNTAINS))); +// → … beautifully aligned table +``` + +继承、封装和多态都是面向对象方法中最为重要的概念。而封装和多态均被认为是非常好的编程思想,但继承则颇受争议。 + +遭受争议的主要原因是继承常常会与多态混淆。而且其功能经常被夸大,随后被过度使用,产生了一些不好的代码。封装与多态可以用来分隔代码,减少整个程序的耦合性,而继承则使类型紧密耦合,增加了耦合性。 + +正如我们所见,你可以在使用多态时不使用继承。我并不建议你完全不使用继承这种特性,我常常就在自己的程序中使用继承。不过你应该视其为一种在定义新类型时减少代码的技巧,而非编写代码的绝对原则。更好的扩展类型的方式是组合,比如基于另一个对象构建UnderlinedCell时,只是简单地将该对象存储为其属性并在UnderlinedCell的方法中,将函数调用转发给内部存储的对象,而这种方法就是组合。 + +### 6.12 instanceof运算符 + +在有些时候,了解某个对象是否继承自某个特定构造函数也是十分有用的。JavaScript为此提供了一个二元运算符,名为instanceof。 + +``` +console.log(new RTextCell("A") instanceof RTextCell); +// → true +console.log(new RTextCell("A") instanceof TextCell); +// → true +console.log(new TextCell("A") instanceof RTextCell); +// → false +console.log([1] instanceof Array); +// → true +``` + +该运算符会查遍所有继承类型。RTextCell对象是TextCell的实例,因为RTextCell.prototype派生自TextCell.prototype。该运算符也可以用于标准构造函数(比如Array)。几乎所有对象都是Object的实例。 + +### 6.13 本章小结 + +对象比我们之前了解到的复杂许多。对象中有另一个对象:原型,只要原型中包含了属性,那么根据原型构造出来的对象也就可以看成包含了相应的属性。简单对象直接以Object.prototype作为原型。 + +构造函数的名称通常以大写字母开头,可以和new运算符一起使用创建新的对象。新对象的原型是构造函数的prototype属性。你可以充分利用原型将特定类型的所有属性放在原型中共享。instanceof运算符可以判断特定对象是否是特定构造函数的实例。 + +我们可以为对象添加接口,用户只需通过接口来使用对象即可。你的对象中的其他细节则被封装了起来,隐藏在接口之后为用户提供所需功能。 + +当我们谈论接口的时候,其实不同的对象也可以实现相同的接口,只不过不同的对象提供了不同的内部实现细节罢了,我们把这种特性称之为多态。我们会在编程过程中经常使用到多态这个特性。 + +如果我们实现的对象之间的差别微乎其微,那么我们可以直接使用原型来创建新的类型,而新类型则通过继承原有类型的原型来实现,并使用新的构造函数来调用原有类型中的构造函数。这样可以得到一个类似于旧类型的新类型,而且我们还可以在原有类型当中添加属性或覆盖属性。 + +### 6.14 习题 + +#### 6.14.1 向量类型 + +编写一个构造函数Vector,以二维空间表示数组。该函数接受两个数字参数x和y,并将其保存到对象的同名属性中。 + +向Vector原型添加两个方法:plus和minus,它们接受另一个向量作为参数,分别返回两个向量(一个是this,另一个是参数)的和向量与差向量。 + +向原型添加一个getter属性length,用于计算向量长度,即点(x,y)与原点(0,0)之间的距离。 + +``` +// Your code here. + +console.log(new Vector(1, 2).plus(new Vector(2, 3))); +// → Vector{x: 3, y: 5} +console.log(new Vector(1, 2).minus(new Vector(2, 3))); +// → Vector{x: -1, y: -1} +console.log(new Vector(3, 4).length); +// → 5 +``` + +#### 6.14.2 另一种单元格 + +实现一个单元格类型,将其命名为StretchCell(inner,width,height),对应本章介绍的表格单元格接口。该类型将另一个单元格对象(比如UnderlinedCell)存储在内部,并确保产生的单元格长度与高度必须大于等于width和height值。 + +``` +// Your code here. + +var sc = new StretchCell(new TextCell("abc"), 1, 2); +console.log(sc.minWidth()); +// → 3 +console.log(sc.minHeight()); +// → 2 +console.log(sc.draw(3, 2)); +// → ["abc", " "] +``` + +#### 6.14.3 序列接口 + +设计一个接口,用于抽象集合的元素迭代过程。对象提供该接口,将自身展现成一种序列形式,代码中可以使用这类对象迭代序列,查看组成序列的每一个元素,并能发现序列的结束位置。 + +指定了接口后,编写一个logFive函数,该函数接受一个序列对象,并调用console.log返回其中前五个元素,如果序列中元素数量少于5个,则输出所有元素。 + +接着实现一个对象类型ArraySeq,并提供一个你设计的接口来迭代存储在内部的数组。然后实现另一个对象类型RangeSeq,提供一个迭代一定范围内(使用from和to两个参数来指定迭代范围)整数的接口。 + +``` +// Your code here. + +logFive(new ArraySeq([1, 2])); +// → 1 +// → 2 +logFive(new RangeSeq(100, 1000)); +// → 100 +// → 101 +// → 102 +// → 103 +// → 104 +```