35 KiB
六、对象的秘密
抽象数据类型是通过编写一种特殊的程序来实现的,该程序根据可在其上执行的操作来定义类型。
Barbara Liskov,《Programming with Abstract Data Types》
第 4 章介绍了 JavaScript 的对象(object)。 在编程文化中,我们有一个名为面向对象编程(OOP)的东西,这是一组技术,使用对象(和相关概念)作为程序组织的中心原则。
虽然没有人真正同意其精确定义,但面向对象编程已经成为了许多编程语言的设计,包括 JavaScript 在内。 本章将描述这些想法在 JavaScript 中的应用方式。
封装
面向对象编程的核心思想是将程序分成小型片段,并让每个片段负责管理自己的状态。
通过这种方式,一些程序片段的工作方式的知识可以局部保留。 从事其他方面的工作的人,不必记住甚至不知道这些知识。 无论什么时候这些局部细节发生变化,只需要直接更新其周围的代码。
这种程序的不同片段通过接口(interface),函数或绑定的有限集合交互,它以更抽象的级别提供有用的功能,并隐藏它的精确实现。
这些程序片段使用对象建模。 它们的接口由一组特定的方法(method)和属性(property)组成。 接口的一部分的属性称为公共的(public)。 其他外部代码不应该接触属性的称为私有的(private)。
许多语言提供了区分公共和私有属性的方法,并且完全防止外部代码访问私有属性。 JavaScript 再次采用极简主义的方式,没有。 至少目前还没有 - 有个正在开展的工作,将其添加到该语言中。
即使这种语言没有内置这种区别,JavaScript 程序员也成功地使用了这种想法。 通常,可用的接口在文档或数字一中描述。 在属性名称的的开头经常会放置一个下划线(_
)字符,来表明这些属性是私有的。
将接口与实现分离是一个好主意。 它通常被称为封装(encapsulation)。
6.2 方法
方法不过是持有函数值的属性。 这是一个简单的方法:
let rabbit = {};
rabbit.speak = function(line) {
console.log(`The rabbit says '${line}'`);
};
rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'
方法通常会在对象被调用时执行一些操作。将函数作为对象的方法调用时,会找到对象中对应的属性并直接调用。当函数作为方法调用时,函数体内叫做this
的绑定自动指向在它上面调用的对象。
function speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak: speak};
let 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!'
hungryRabbit.speak("I could use a carrot right now.");
// → The hungry rabbit says 'I could use a carrot right now.'
你可以把this
看作是以不同方式传递的额外参数。 如果你想显式传递它,你可以使用函数的call
方法,它接受this
值作为第一个参数,并将其它处理为看做普通参数。
speak.call(hungryRabbit, "Burp!");
// → The hungry rabbit says 'Burp!'
这段代码使用了关键字this来输出正在说话的兔子的种类。我们回想一下apply和bind方法,这两个方法接受的第一个参数可以用来模拟对象中方法的调用。这两个方法会把第一个参数复制给this。
由于每个函数都有自己的this
绑定,它的值依赖于它的调用方式,所以在用function
关键字定义的常规函数中,不能引用外层作用域的this
。
箭头函数是不同的 - 它们不绑定他们自己的this
,但可以看到他们周围(定义位置)作用域的this
绑定。 因此,你可以像下面的代码那样,在局部函数中引用this
:
function normalize() {
console.log(this.coords.map(n => n / this.length));
}
normalize.call({coords: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]
如果我使用function
关键字将参数写入map
,则代码将不起作用。
原型
我们来仔细看看以下这段代码。
let 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
返回一个对象的原型。
JavaScript对象原型的关系是一种树形结构,整个树形结构的根部就是Object.prototype。Object.prototype提供了一些可以在所有对象中使用的方法。比如说,toString方法可以将一个对象转换成其字符串表示形式。
许多对象并不直接将Object.prototype作为其原型,而会使用另一个原型对象,用于提供一系列不同的默认属性。函数继承自Function.prototype,而数组继承自Array.prototype。
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
Array.prototype);
// → true
对于这样的原型对象来说,其自身也包含了一个原型对象,通常情况下是Object.prototype,所以说,这些原型对象可以间接提供toString这样的方法。
你可以使用Object.create
来创建一个具有特定原型的对象。
let protoRabbit = {
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
};
let killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'
像对象表达式中的speak(line)
这样的属性是定义方法的简写。 它创建了一个名为speak
的属性,并向其提供函数作为它的值。
原型对象protoRabbit是一个容器,用于包含所有兔子对象的公有属性。每个独立的兔子对象(比如killerRabbit)可以包含其自身属性(比如本例中的type属性),也可以派生其原型对象中公有的属性。
类
JavaScript 的原型系统可以解释为对一种面向对象的概念(称为类(class))的某种非正式实现。 类定义了对象的类型的形状 - 它具有什么方法和属性。 这样的对象被称为类的实例(instance)。
原型对于属性来说很实用。一个类的所有实例共享相同的属性值,例如方法。 每个实例上的不同属性,比如我们的兔子的type
属性,需要直接存储在对象本身中。
所以为了创建一个给定类的实例,你必须使对象从正确的原型派生,但是你也必须确保,它本身具有这个类的实例应该具有的属性。 这是构造器(constructor)函数的作用。
function makeRabbit(type) {
let rabbit = Object.create(protoRabbit);
rabbit.type = type;
return rabbit;
}
JavaScript 提供了一种方法,来使得更容易定义这种类型的功能。 如果将关键字new
放在函数调用之前,则该函数将被视为构造器。 这意味着具有正确原型的对象会自动创建,绑定到函数中的this
,并在函数结束时返回。
构造对象时使用的原型对象,可以通过构造器的prototype
属性来查找。
function Rabbit(type) {
this.type = type;
}
Rabbit.prototype.speak = function(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
};
let weirdRabbit = new Rabbit("weird");
构造器(实际上是所有函数)都会自动获得一个名为prototype
的属性,默认情况下它包含一个普通的,来自Object.prototype
的空对象。 如果需要,可以用新对象覆盖它。 或者,您可以将属性添加到现有对象,如示例所示。
按照惯例,构造器的名字是大写的,这样它们可以很容易地与其他函数区分开来。
重要的是,理解原型与构造器关联的方式(通过其prototype
属性),与对象拥有原型(可以通过Object.getPrototypeOf
查找)的方式之间的区别。 构造器的实际原型是Function.prototype
,因为构造器是函数。 它的prototype
属性拥有原型,用于通过它创建的实例。
console.log(Object.getPrototypeOf(Rabbit) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(weirdRabbit) ==
Rabbit.prototype);
// → true
类的表示法
所以 JavaScript 类是带有原型属性的构造器。 这就是他们的工作方式,直到 2015 年,这就是你编写他们的方式。 最近,我们有了一个不太笨拙的表示法。
class Rabbit {
constructor(type) {
this.type = type;
}
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
}
let killerRabbit = new Rabbit("killer");
let blackRabbit = new Rabbit("black");
class
关键字是类声明的开始,它允许我们在一个地方定义一个构造器和一组方法。 可以在声明的大括号内写入任意数量的方法。 一个名为constructor
的对象受到特别处理。 它提供了实际的构造器,它将绑定到名称"Rabbit"
。 其他函数被打包到该构造器的原型中。 因此,上面的类声明等同于上一节中的构造器定义。 它看起来更好。
类声明目前只允许方法 - 持有函数的属性 - 添加到原型中。 当你想在那里保存一个非函数值时,这可能会有点不方便。 该语言的下一个版本可能会改善这一点。 现在,您可以在定义该类后直接操作原型来创建这些属性。
像function
一样,class
可以在语句和表达式中使用。 当用作表达式时,它没有定义绑定,而只是将构造函数作为一个值生成。 您可以在类表达式中省略类名称。
let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello
覆盖派生的属性
将属性添加到对象时,无论它是否存在于原型中,该属性都会添加到对象本身中。 如果原型中已经有一个同名的属性,该属性将不再影响对象,因为它现在隐藏在对象自己的属性后面。
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之下,我们可以从原型中找到对象中没有的属性。
覆盖原型中存在的属性是很有用的特性。就像示例展示的那样,我们覆盖了killerRabbit的teeth属性,这可以用来描述实例(对象中更为泛化的类的实例)的特殊属性,同时又可以让简单对象从原型中获取标准的值。
覆盖也用于向标准函数和数组原型提供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]
映射
我们在上一章中看到了映射(map)这个词,用于一个操作,通过对元素应用函数来转换数据结构。 令人困惑的是,在编程时,同一个词也被用于相关而不同的事物。
映射(名词)是将值(键)与其他值相关联的数据结构。 例如,您可能想要将姓名映射到年龄。 为此可以使用对象。
let ages = {
Boris: 39,
Liang: 22,
Júlia: 62
};
console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true
在这里,对象的属性名称是人们的姓名,并且该属性的值为他们的年龄。 但是我们当然没有在我们的映射中列出任何名为toString
的人。 似的,因为简单对象是从Object.prototype
派生的,所以它看起来就像拥有这个属性。
因此,使用简单对象作为映射是危险的。 有几种可能的方法来避免这个问题。 首先,可以使用null
原型创建对象。 如果将null
传递给Object.create
,那么所得到的对象将不会从Object.prototype
派生,并且可以安全地用作映射。
console.log("toString" in Object.create(null));
// → false
对象属性名称必须是字符串。 如果你需要一个映射,它的键不能轻易转换为字符串 - 比如对象 - 你不能使用对象作为你的映射。
幸运的是,JavaScript 带有一个叫做Map
的类,它正是为了这个目的而编写。 它存储映射并允许任何类型的键。
let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);
console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false
set
,get
和has
方法是Map
对象的接口的一部分。 编写一个可以快速更新和搜索大量值的数据结构并不容易,但我们不必担心这一点。 其他人为我们实现,我们可以通过这个简单的接口来使用他们的工作。
如果您确实有一个简单对象,出于某种原因需要将它视为一个映射,那么了解Object.keys
只返回对象的自己的键,而不是原型中的那些键,会很有用。 作为in
运算符的替代方法,您可以使用hasOwnProperty
方法,该方法会忽略对象的原型。
console.log({x: 1}.hasOwnProperty("x"));
// → true
console.log({x: 1}.hasOwnProperty("toString"));
// → false
6.8 多态
当你调用一个对象的String
函数(将一个值转换为一个字符串)时,它会调用该对象的toString
方法来尝试从它创建一个有意义的字符串。 我提到一些标准原型定义了自己的toString
版本,因此它们可以创建一个包含比"[object Object]"
有用信息更多的字符串。 你也可以自己实现。
Rabbit.prototype.toString = function() {
return `a ${this.type} rabbit`;
};
console.log(String(blackRabbit));
// → a black rabbit
这是一个强大的想法的简单实例。 当一段代码为了与某些对象协作而编写,这些对象具有特定接口时(在本例中为toString
方法),任何类型的支持此接口的对象都可以插入到代码中,并且它将正常工作。
这种技术被称为多态(polymorphism)。 多态代码可以处理不同形状的值,只要它们支持它所期望的接口即可。
我在第四章中提到for/of
循环可以遍历几种数据结构。 这是多态性的另一种情况 - 这样的循环期望数据结构公开的特定接口,数组和字符串是这样。 你也可以将这个接口添加到你自己的对象中! 但在我们实现它之前,我们需要知道什么是符号。
符号
多个接口可能为不同的事物使用相同的属性名称。 例如,我可以定义一个接口,其中toString
方法应该将对象转换为一段纱线。 一个对象不可能同时满足这个接口和toString
的标准用法。
这是一个坏主意,这个问题并不常见。 大多数 JavaScript 程序员根本就不会去想它。 但是,语言设计师们正在思考这个问题,无论如何都为我们提供了解决方案。
当我声称属性名称是字符串时,这并不完全准确。 他们通常是,但他们也可以是符号(symbol)。 符号是使用Symbol
函数创建的值。 与字符串不同,新创建的符号是唯一的 - 您不能两次创建相同的符号。
let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(blackRabbit[sym]);
// → 55
我们的表格绘制程序的构建函数会根据用户输入的宽度和高度来确定每列的宽度和每行的高度。接着,构建函数会根据之前输入的参数来绘制表格,并将最终结果用字符串输出。
排版程序将会通过定义良好的接口来操作单元格对象。这样一来,程序就可以支持程序自定义的单元格类型。我们日后可以为程序增加新的单元格类型。比如说,如果想支持表头中的下划线单元格,我们不需要修改排版程序的源代码,只要这些单元格支持我们提供的接口即可。接口的定义如下:
·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/)的数据集列表下载有关山脉的数据,数据存储在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