第 3 版已发布。点击此处阅读

第 6 章
对象的秘密生活

面向对象语言的问题在于它们拥有所有这些隐式的环境,这些环境与它们息息相关。你想要一根香蕉,但你得到的是一只手持香蕉的猩猩和整个丛林。

Joe Armstrong, 在 Coders at Work 中接受采访

当程序员说“对象”时,这是一个含义丰富的术语。在我的职业生涯中,对象是一种生活方式,是圣战的主题,也是一个备受喜爱的流行语,它至今仍未完全失去其影响力。

对于局外人来说,这可能有点令人困惑。让我们从对象作为编程结构的简要历史开始。

历史

这个故事,就像大多数编程故事一样,始于复杂性的问题。一种哲学认为,可以通过将复杂性分离成相互隔离的小隔间来使复杂性变得易于管理。这些隔间最终获得了“对象”的名称。

对象是一个坚硬的外壳,它隐藏了内部的粘稠复杂性,而是提供了一些旋钮和连接器(例如方法),这些连接器提供了一个接口,通过该接口可以使用对象。这个想法是,接口相对简单,并且在使用对象时可以忽略对象内部发生的所有复杂事情。

A simple interface can hide a lot of complexity.

例如,您可以想象一个对象,它提供对屏幕上区域的接口。它提供了一种在该区域上绘制形状或文本的方法,但隐藏了将这些形状转换为构成屏幕的实际像素的所有细节。您将有一组方法——例如,drawCircle——这些方法是您使用此类对象时需要了解的唯一内容。

这些想法最初是在 1970 年代和 1980 年代提出的,并在 1990 年代被一股巨大的炒作浪潮——面向对象编程革命——带动。突然之间,一大群人宣称对象是编程的正确方式——任何不涉及对象的编程都是过时的胡说八道。

这种狂热总是会产生许多不切实际的愚蠢行为,从那时起就出现了一种反革命。在某些圈子里,对象现在声名狼藉。

我更喜欢从实际的角度,而不是意识形态的角度来看待这个问题。面向对象文化已经推广了一些有用的概念,其中最重要的是封装(区分内部复杂性和外部接口)。这些值得研究。

本章描述了 JavaScript 对对象的独特理解以及它们与一些经典的面向对象技术的关系。

方法

方法仅仅是保存函数值的属性。这是一个简单的方法

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 关键字输出说话的兔子类型。请记住,applybind 方法都采用第一个参数,该参数可用于模拟方法调用。事实上,这个第一个参数用于为 this 提供一个值。

有一个类似于 apply 的方法,称为 call。它也调用它是其方法的函数,但以正常方式而不是作为数组来获取其参数。与 applybind 一样,call 可以传递一个特定的 this 值。

speak.apply(fatRabbit, ["Burp!"]);
// → The fat rabbit says 'Burp!'
speak.call({type: "old"}, "Oh my.");
// → The old rabbit says 'Oh my.'

原型

仔细观察。

var empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

我刚刚从一个空对象中提取了一个属性。神奇!

好吧,其实也没有。我只是隐瞒了 JavaScript 对象工作方式的信息。除了它们的属性集之外,几乎所有对象还有一个原型。原型是另一个用作属性备用来源的对象。当对象收到对其没有的属性的请求时,将搜索其原型以查找该属性,然后搜索原型的原型,依此类推。

那么,那个空对象的原型是谁呢?它是伟大的祖先原型,几乎所有对象的实体,Object.prototype

console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

正如您所料,Object.getPrototypeOf 函数返回对象的原型。

JavaScript 对象的原型关系形成了一个树状结构,而 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!'

“proto”兔子充当所有兔子共享的属性的容器。单个兔子对象,如杀手兔子,包含仅适用于自身(在本例中为其类型)的属性,并从其原型继承共享属性。

构造函数

创建从某个共享原型派生的对象的一种更方便的方法是使用构造函数。在 JavaScript 中,在函数前面使用 new 关键字调用它,会导致它被视为构造函数。构造函数将使其 this 变量绑定到一个新的对象,除非它明确返回另一个对象值,否则这个新对象将从调用中返回。

使用 new 创建的对象被称为其构造函数的实例

这是一个简单的兔子构造函数。习惯上将构造函数的名称大写,以便于区分它们和其他函数。

function Rabbit(type) {
  this.type = type;
}

var killerRabbit = new Rabbit("killer");
var blackRabbit = new Rabbit("black");
console.log(blackRabbit.type);
// → black

构造函数(实际上,所有函数)都会自动获得一个名为 prototype 的属性,该属性默认情况下包含一个普通的空对象,该对象源自 Object.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.getPrototypeOf 获取)。构造函数的实际原型是 Function.prototype,因为构造函数是函数。它的 prototype 属性将是通过它创建的实例的原型,但不是它自身的原型。

覆盖派生属性

当您向对象添加属性时,无论该属性是否存在于原型中,该属性都会添加到对象本身中,该对象此后将拥有该属性作为其自身属性。如果原型中存在同名属性,则此属性将不再影响该对象。原型本身不会改变。

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

下面的图表描绘了这段代码运行后的情况。RabbitObject 原型作为一种背景位于 killerRabbit 背后,其中可以在对象本身中找不到的属性进行查找。

Rabbit object prototype schema

覆盖存在于原型中的属性通常是一件很有用的事情。正如兔子牙齿示例所示,它可用于表达更通用对象类实例中的异常属性,同时让非异常对象从其原型简单地获取标准值。

它还用于为标准函数和数组原型提供与基本对象原型不同的 toString 方法。

console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

对数组调用 toString 会产生类似于对其调用 .join(",") 的结果——它在数组中的值之间放置逗号。直接对数组调用 Object.prototype.toString 会产生不同的字符串。该函数不知道数组,因此它只将“object”一词以及类型的名称放在方括号之间。

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

原型干扰

原型可以在任何时候用于向所有基于它的对象添加新属性和方法。例如,我们的兔子可能需要跳舞。

Rabbit.prototype.dance = function() {
  console.log("The " + this.type + " rabbit dances a jig.");
};
killerRabbit.dance();
// → The killer rabbit dances a jig.

这很方便。但是,在某些情况下会导致问题。在前面的章节中,我们使用对象作为一种将值与名称相关联的方法,方法是为名称创建属性,并将相应的 value 作为其值赋予它们。以下是一个来自 第 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 区分可枚举属性和不可枚举属性。

我们通过简单地分配创建的所有属性都是可枚举的。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

现在属性存在了,但它不会在循环中显示出来。这是好事。但我们仍然面临着使用常规的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
  }
}

无原型对象

但事情并没有那么简单。如果有人在我们的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的解决方案,因为对象拥有的所有属性都是它自己的属性。现在我们可以安全地使用for/in循环,无论人们对Object.prototype做了什么。

多态

当你对一个对象调用String函数(将一个值转换为字符串)时,它将调用该对象的toString方法来尝试创建一个有意义的字符串并返回。我提到了一些标准原型定义了它们自己的toString版本,这样它们就可以创建一个包含比"[object Object]"更有用信息的字符串。

这是一个强大思想的简单例子。当一段代码被编写用来处理具有特定接口的对象时——在本例中,这是一个toString方法——任何碰巧支持这个接口的对象都可以插入代码中,它会正常工作。

这种技术被称为多态——尽管没有实际的形状转换。多态代码可以处理不同形状的值,只要它们支持它期望的接口。

布局表格

我将逐步讲解一个稍微复杂一点的例子,试图让你更好地了解多态以及面向对象编程的一般情况。这个项目是:我们将编写一个程序,给定一个表格单元格数组,它将构建一个包含精心布局表格的字符串——这意味着列是直的,行是对齐的。类似这样

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

我们的表格构建系统的工作方式是,构建函数会询问每个单元格想要多宽多高,然后使用这些信息来确定列的宽度和行的高度。构建函数将接着要求单元格以正确的尺寸绘制自身,并将结果组装成一个单独的字符串。

布局程序将通过一个定义明确的接口与单元格对象进行通信。这样,程序支持的单元格类型就不需要事先固定。我们以后可以添加新的单元格样式——例如,表格标题的带下划线的单元格——如果它们支持我们的接口,它们会正常工作,无需对布局程序进行更改。

这是接口

在这个例子中,我将大量使用高阶数组方法,因为它很适合这种方法。

程序的第一部分计算一个单元格网格的最小列宽和行高的数组。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函数不应该太难理解。它使用reduce来计算一个单元格数组的最大高度,并将其包装在map中,以便为rows数组中的所有行执行此操作。

colWidths函数的情况稍微复杂一些,因为外部数组是行数组,而不是列数组。我之前没有提到过map(以及forEachfilter以及类似的数组方法)将第二个参数传递给它接收的函数:当前元素的索引。通过映射第一行中的元素,并且仅使用映射函数的第二个参数,colWidths构建了一个包含每个列索引的一个元素的数组。对reduce的调用针对每个索引在外部的rows数组上运行,并选出在该索引处最宽单元格的宽度。

这是绘制表格的代码

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函数本身首先将行中的单元格对象转换为是表示单元格内容的字符串数组,按行分割。一个只包含数字 3776 的单元格可以用一个单元素数组表示,例如["3776"],而一个带下划线的单元格可能占用两行,可以用数组["name", "----"]表示。

一行中的块,它们都具有相同的高度,应该在最终输出中彼此相邻。drawRow中的第二个map调用通过映射最左边块中的行来构建这个逐行输出,并为每一行收集一个跨越表格整个宽度的行。然后将这些行用换行符连接起来,以提供drawRow的返回值,即整行。

drawLine函数从一个块数组中提取应该彼此相邻的行,并将它们用空格字符连接起来,在表格的列之间创建一个一字符的间隙。

现在让我们编写一个包含文本的单元格构造函数,它实现了表格单元格的接口。构造函数使用字符串方法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方法使用它来添加“填充”到行中,以便它们都具有所需的长度。

让我们尝试一下我们目前为止编写的代码,构建一个 5 × 5 的棋盘。

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));
// → ##    ##    ##
//      ##    ##
//   ##    ##    ##
//      ##    ##
//   ##    ##    ##

它起作用了!但由于所有单元格都具有相同的尺寸,因此表格布局代码并没有真正做任何有趣的事情。

我们试图构建的山脉表格的源数据在沙箱中的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)]);
};

一个带下划线的单元格包含另一个单元格。它将它的最小尺寸报告为与其内部单元格的尺寸相同(通过调用该单元格的minWidthminHeight方法),但将高度加一以说明下划线占用的空间。

绘制这样一个单元格非常简单——我们获取内部单元格的内容,并向其连接一行由破折号组成的行。

有了下划线机制,我们现在可以编写一个函数,从我们的数据集中构建一个单元格网格。

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函数返回一个对象中属性名称的数组。表格的顶行必须包含带下划线的单元格,这些单元格给出列的名称。在它下面,数据集中的所有对象的 value 作为普通单元格出现——我们通过对keys数组进行映射来提取它们,以确保每个行中单元格的顺序都相同。

生成的表格类似于之前显示的例子,只是它没有将height列中的数字右对齐。我们很快就会讲到这个。

getter 和 setter

在指定接口时,可以包含非方法属性。我们本可以将minHeightminWidth定义为简单地保存数字。但这样做会要求我们在构造函数中计算它们,这会在构造函数中添加与构建对象无关的代码。例如,如果带下划线的单元格的内部单元格发生了更改,这会导致问题,此时带下划线的单元格的尺寸也应该更改。

这导致有些人采用了一个原则,即在接口中永远不包含非方法属性。与其直接访问一个简单的值属性,他们会使用getSomethingsetSomething方法来读写属性。这种方法的缺点是,你最终会编写——并读取——大量的额外方法。

幸运的是,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

在对象字面量中,属性的 getset 表示法允许您指定一个函数,该函数将在读取或写入属性时运行。您也可以使用 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

您可以使用类似的 set 属性,在传递给 defineProperty 的对象中,来指定一个 setter 方法。当定义了 getter 但没有定义 setter 时,写入该属性将被简单地忽略。

继承

我们的表格布局练习还没有完全结束。右对齐数字列有助于提高可读性。我们应该创建一个新的单元格类型,它类似于 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 的构造函数和 minHeightminWidth 方法。现在,RTextCell 本质上等同于 TextCell,只是它的 draw 方法包含一个不同的函数。

这种模式被称为继承。它允许我们用相对少的工作量从现有的数据类型构建略微不同的数据类型。通常,新的构造函数会调用旧的构造函数(使用 call 方法,以便能够将新的对象作为其 this 值传递给它)。一旦这个构造函数被调用,我们就可以假设所有旧对象类型应该包含的字段都已经被添加了。我们安排构造函数的原型派生自旧的原型,以便这种类型的实例也能访问该原型中的属性。最后,我们可以通过将它们添加到我们的新原型中来覆盖其中一些属性。

现在,如果我们稍微调整 dataTable 函数,以便为值为数字的单元格使用 RTextCell,那么我们就得到了我们想要的表格。

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 如何通过简单地将另一个单元格对象存储在一个属性中并在自己的方法中转发对它的方法调用来构建在另一个单元格对象之上。

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

该运算符会看到继承的类型。RTextCellTextCell 的实例,因为 RTextCell.prototype 派生自 TextCell.prototype。该运算符可以应用于标准构造函数,如 Array。几乎每个对象都是 Object 的实例。

总结

因此,对象比我最初描述的要复杂。它们有原型,原型是其他对象,并且只要原型具有该属性,它们就会表现得好像它们具有它们没有的属性。简单对象以 Object.prototype 作为其原型。

构造函数(其名称通常以大写字母开头)可以与 new 运算符一起使用来创建新对象。新对象的原型将是在构造函数的 prototype 属性中找到的对象。您可以通过将所有给定类型的值的属性放入其原型中来充分利用这一点。instanceof 运算符可以根据给定的对象和构造函数,判断该对象是否是该构造函数的实例。

对对象进行的一种有用的操作是为它们指定一个接口,并告诉每个人他们应该只通过该接口与您的对象进行通信。构成您的对象的其余细节现在被封装起来,隐藏在接口后面。

一旦你开始使用接口,谁说只有一个类型的对象可以实现这个接口呢?让不同的对象暴露相同的接口,然后编写对具有该接口的任何对象都有效的代码,这被称为多态性。它非常有用。

在实现仅在某些细节上有所不同的多个类型时,只需让新类型的原型派生自旧类型的原型,并让您的新构造函数调用旧的构造函数,这可能会有所帮助。这会为您提供一个类似于旧类型但您可以根据需要添加和覆盖属性的对象类型。

练习

向量类型

编写一个名为 Vector 的构造函数,它表示二维空间中的向量。它接受 xy 参数(数字),应该将它们保存到相同名称的属性中。

Vector 原型提供两个方法,plusminus,它们接受另一个向量作为参数,并返回一个新的向量,该向量具有两个向量的(this 中的一个和参数)xy 值的和或差。

为原型添加一个名为 length 的 getter 属性,它计算向量的长度——即点 (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

您的解决方案可以非常接近本章中的 Rabbit 构造函数的模式。

可以通过 Object.defineProperty 函数将 getter 属性添加到构造函数中。要计算从 (0, 0) 到 (x, y) 的距离,可以使用勾股定理,该定理指出我们正在寻找的距离的平方等于 x 坐标的平方加上 y 坐标的平方。因此,√(x2 + y2) 是您想要的数字,Math.sqrt 是在 JavaScript 中计算平方根的方法。

另一个单元格

实现一个名为 StretchCell(inner, width, height) 的单元格类型,它符合本章前面描述的表格单元格接口。它应该包装另一个单元格(就像 UnderlinedCell 一样),并确保结果单元格至少具有给定的 widthheight,即使内部单元格本身可能更小。

// 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", "   "]

您必须将所有三个构造函数参数存储在实例对象中。minWidthminHeight 方法应该调用 inner 单元格中的相应方法,但确保返回的数字不小于给定的尺寸(可能使用 Math.max)。

不要忘记添加一个 draw 方法,该方法只是将调用转发到内部单元格。

序列接口

设计一个接口,它抽象地遍历一组值。提供此接口的对象表示一个序列,并且该接口必须以某种方式使使用此类对象的代码能够遍历序列,查看构成它的元素值,并找到一种方法来找出序列的结束位置。

当您指定了接口后,尝试编写一个名为 logFive 的函数,它接受一个序列对象,并对它的前五个元素调用 console.log——或者更少,如果序列少于五个元素。

然后实现一个名为 ArraySeq 的对象类型,它包装一个数组,并允许使用您设计的接口遍历该数组。实现另一个名为 RangeSeq 的对象类型,它遍历一系列整数(在其构造函数中接受 fromto 参数)而不是数组。

// Your code here.

logFive(new ArraySeq([1, 2]));
// → 1
// → 2
logFive(new RangeSeq(100, 1000));
// → 100
// → 101
// → 102
// → 103
// → 104

解决此问题的一种方法是为序列对象提供状态,这意味着它们的属性在使用过程中会发生变化。您可以存储一个计数器,该计数器指示序列对象已经前进到哪里。

您的接口将至少需要公开一种方法来获取下一个元素,并找出迭代是否已经到达序列的末尾。将它们合并成一个名为 next 的方法很诱人,该方法在序列结束时返回 nullundefined。但现在,当序列实际上包含 null 时,您就会遇到问题。因此,最好使用单独的方法(或 getter 属性)来找出是否已经到达结尾。

另一种解决方案是避免更改对象中的状态。您可以公开一个方法来获取当前元素(而不前进任何计数器),以及另一个方法来获取一个新的序列,该序列表示当前元素之后的剩余元素(或者如果到达序列的末尾,则使用特殊值)。这很优雅——即使序列值在使用后会“保持自身”,因此可以与其他代码共享,而无需担心可能会发生什么。不幸的是,在像 JavaScript 这样的语言中,它也效率不高,因为它涉及在迭代过程中创建许多对象。