1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-30 00:32:22 +00:00
wizardforcel 70e6a2701f 6.
2018-05-03 11:09:16 +08:00

691 lines
34 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 六、深入理解对象
> The problem with object-oriented languages is theyve 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该方法返回一个数字表示单元格所需的最小宽度字符宽度
·drawwidthheight该方法返回一个数组其长度为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用于计算向量长度即点xy与原点00之间的距离。
```
// 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 另一种单元格
实现一个单元格类型将其命名为StretchCellinnerwidthheight对应本章介绍的表格单元格接口。该类型将另一个单元格对象比如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
```