《Effective JS》的 68 条准则「四十三至五十二条」- 数组和字典

886 阅读10分钟

起因

阅读学习《Effective JavaScript》,以自身阅读和理解,着重记录内容精华部分以及对内容进行排版,便于日后自身回顾学习以及大家交流学习。

因内容居多,分为每个章节来进行编写文章,每章节的准条多少不一,故每篇学习笔记的文章以章节为准。

适合碎片化阅读,精简阅读的小友们。争取让小友们看完系列 === 看整本书的 85+%。

前言

内容总览

  • 第一章让初学者快速熟悉 JavaScript,了解 JavaScript 中的原始类型、隐式强制转换、编码类型等几本概念;
  • 第二章着重讲解了有关 JavaScript 的变量作用域的建议,不仅介绍了怎么做,还介绍了操作背后的原因,帮助读者加深理解;
  • 第三章和第四章的主题涵盖函数、对象及原型三大方面,这可是 JavaScript 区别于其他语言的核心;
  • 第五章阐述了数组和字典这两种容易混淆的常用类型及具体使用时的建议,避免陷入一些陷阱;
  • 第六章讲述了库和 API 设计;
  • 第七章讲述了并行编程,这是晋升为 JavaScript 专家的必经之路

第 5 章「数组和字典」

在 JavaScript 中取决于不同环境中,对象可以表示一个灵活的键值关联记录,一个继承了方法的面向对象数据抽象,一个密集或稀疏的数组,或一个散列表。本章解决将对象作为集合的(数组和字典)用法。

第 43 条:使用 Object 的直接实例构造轻量级的字典

对象将继承其原型对象属性

JavaScript 对象的核心是一个字符串属性名称与属性值的映射表,且提供了枚举一个对象属性名的利器——for...in 循环。

const dict = {
  alice: 34,
  bob: 24
};
const people = [];
for (const name in dict) {
  people.push(`name: ${dict[name]}`);
}
people;	// ["alice: 34", "bob: 24"];

每个对象还继承了其原型对象中的属性,for...in 循环除了枚举出对象“自身”的属性外,还会枚举出继承过来的属性。

如果我们创建一个自定义的字典类并将其元素作为该字典对象自身的属性来存储会怎么样?

function NaiveDict() {
}
NaiveDict.prototype.count = function() {
  const i = 0;
  for (const name in this)	{// counts every property
    i++;
  }
  return i;
}
NaiveDict.prototype.toString = function() {
  return "[object NaiveDict]";
}
const dict = new NaiveDict();
dict.alice = 34;
dict.bob = 24;
dict.chris = 62;
dict.count();	// 5

同一个对象来存储 NaiveDict 数据结构的固定属性(count、toString)和特定字典的变化条目(alice、bob、chris),当调用 count 来枚举字典的所有属性时,它会枚举出所有的属性,而不是仅仅枚举出我们关心的条目。

避免使用数组类型来表示字典

由于我们可以给任意类型的 JavaScript 对象增加属性,因此一种“关联数组”的字典模式有时似乎能工作。

const dict = new Array();
dict.alice = 34;
dict.bob = 24;
dict.chris = 62;

dict.bob;	// 24

不幸的是这段代码面对原型污染时很脆弱,当枚举字典的条目时,原型对象中的属性可能会导致出现一些不期望的属性。

Array.prototype.first = function() {
  return this[0];
};
Array.prototype.last = function() {
  return this[this.length - 1];
};

const names = [];
for (const name in dict) {
  names.push(name);
};

names;	// ["alice", "bob", "chris", "first", "last"]

因此我们需要将 Object 的直接实例作为字典,如使用 new Object() 声明,甚至直接使用空对象字面量,可以尽可能避免原型污染的影响。

const dict = {};

dict.alice = 34;
dict.bob = 24;
dict.chris = 62;

const names = [];
for (const name in dict) {
  names.push(name);
};

names;	// ["alice", "bob", "chris"]

虽然仍无法保证原型污染完全安全,任何人仍然能增加属性到 Object.prototype 中,但通过使用 Object 的直接实例,可以将风险仅仅局限于 Object.prototype

(如第 47 条解释的一样,所有人都不应当增加属性到 Object.prototype 中,相比之下增加属性到 Array.prototype 中是合理的。)

正如接下来第 44、45 条证实的一样,这条规则对于构建行为正确的字典是必要非充分的,它们也会面临许多危险。

总结

  • 使用对象字面量构建轻量级字典。
  • 轻量级字典应该是 Object.prototype 的直接子类,以使 for..in 循环免受原型污染。

第 44 条:使用 null 原型以防止原型污染

自定义原型属性 null

防止原型污染的最简单方式之一是一开始就不使用原型,在 ES5 未发布之前,没有标准方式创建一个空原型的新对象,可能会尝试设置一个构造函数的原型属性为 null 或 undefined。

function C() {};
C.prototype = null;

// 实例化该构造函数仍然得到的是 Object 的实例。
const o = new C();
Object.getPrototypeOf(o) === null;	// false
Object.getPrototypeOf(o) === Object.prototype;	// true

ES5 创建没有原型对象

ES5 后提供了标准方法来创建一个没有原型的对象,Object.create 函数能够使用一个用户指定的原型链和一个属性描述符动态地构造对象。属性描述符描述了新对象属性的值及特性,通过简单地传递一个 null 原型参数和一个空的描述符。

const x = Object.create(null);
Object.getPrototypeOf(o) === null;	// true

一些不支持 Object.create 函数的旧的 JavaScript 环境可能支持另一种值得一提的方式。在许多环境中,特殊的属性 __proto__ 提供了对对象内部原型链的读写访问。

const x = { __proto__: null};
x instanceof Object;	// false

__proto__ 属性是非标准的且并不是所有使用都是可移植性的。

还有另外自身的额外问题,即阻止自由原型对象作为真正健壮的字典实现:__proto__ 在某些 JavaScript 环境中会污染自身对象,甚至可以污染没有原型的对象。

因此尽可能使用标准的 Object.create 函数。

总结

  • 在 ES5 环境中,使用 Object.create(null) 创建的自由原型的空对象是不太容易被污染的。
  • 在一些较老的环境中,考虑使用 { __proto__: null }。
  • 注意 __proto__ 既不标准,也不是完全可移植的,可能会在未来的 JavaScript 环境中去除。
  • 绝不要使用 __proto__ 名作为字典中的 key,因为一些环境将其作为特殊的属性对待。

第 45 条:使用 hasOwnProperty 方法以避免原型污染

hasOwnProperty

前面讨论了属性枚举,但并没有解决属性查找中原型污染的问题。而 Object.prototype 提供了 hasOwnProperty 方法,当测试题字典条目时它可以避免原型污染。

const dict = {};

"bob" in dict;	// false
"toString" in dict;	// true

dict.hasOwnProperty('bob');	// false
dict.hasOwnProperty('toString');	// false

类似地,我们可以通过在属性查找时来防止其受原型污染的影响。

dict.hasOwnProperty('bob') ? dict.bob : undefined;

而使用该方法有个问题是:当调用时,请求查找该对象的 hasOwnproperty 方法,通常情况下,该方法会简单地继承自 Object.prototype 对象。然而如果字典中存储一个同为该方法的条目,那么原型中的将被覆盖。

dict.hasOwnProperty = 10;
dict.hasOwnProperty('bob');	// error: dict.hasOwnProperty is not a function

因此我们可以采用第 20 条中的 call 方法,先声明安全的 hasOwnProperty 方法。

const hasOwn = {}.hasOwnProperty;
const dict = {};
dict.bob = 24;

hasOwn.call(dict, "hasOwnProperty");	// false
hasOwn.call(dict, "bob");	// true

dict.hasOwnProperty = 10;	// 自定义 hasOwnProperty 属性
hasOwn.call(dict, "hasOwnProperty");	// true

健壮字典

该字典方法函数是通用的,我们可以将该模式抽象到 Dict 的构造函数中。

function Dict(elements) {
  this.elements = elements || {};	// 初始化对象
}

Dict.prototype.has = function(key) {
  return {}.hasOwnProperty.call(this.elements, key);
};
Dict.prototype.get = function(key) {
  return this.has(key) ? this.elements[key] : undefined;
};
Dict.prototype.set = function(key, val) {
  this.elements[key] = val;
};
Dict.prototype.remove = function(key) {
  delete this.elements[key];
};

const dict = new Dict({
  alice: 34,
  bob: 24,
})

dict.has('alice');	// true
dict.get('bob');	// 24
dict.has('valueOf');	// false

字典避免使用 __proto__

在某些环境中,__proto__ 属性只是简单地继承自 Object.prototype,因此空对象才是真正的空对象。

const emptyO = Object.create(null);
const hasOwn = {}.hasOwnProperty;

// 在某些环境下,下列两个表达式均可能为 true 或者 false
"__proto__" in empty;
hasOwn.call(empty, '__proto__');

因此为了兼容此可移植性和安全性,我们只能为每个 Dict 方法的 __proto__ 关键字增加一种特例。

function Dict(elements) {
  this.elements = elements || {};	// 初始化对象
  
  // 兼容 __proto__
  this.hasSpercialProto = false;
  this.spercialProto = undefined;
}

Dict.prototype.has = function(key) {
  if (key === '__proto__') {
    return this.hasSpercialProto
  }
  return {}.hasOwnProperty.call(this.elements, key);
};

......

总结

  • 使用 hasOwnProperty 方法避免原型污染。
  • 使用词法作用域和 call 方法避免覆盖 hasOwnProperty 方法。
  • 考虑在封装 hasOwnProperty 测试样板代码类中实现字典操作。
  • 使用字典类避免将 __proto__ 作为 key 来使用。

第 46 条:使用数组而不要使用字典来存储有序集合

for... in 顺序依赖

一个 JavaScript 对象是一个无序的属性集合,获取和设置不同的属性与顺序无关。

ECMAScript 标准并未规定属性存储的任何特定顺序,这将导致问题是:**for...in 循环会挑选一定的顺序来枚举对象的属性。**由于标准允许 JS 引起自由选择一个顺序,它们的选择会微妙改变程序行为。

// 如创建一个有序的报表。
function report(highScores) {
  const result = "";
  const i = 1;
  for (const name in highScores) {
    result += i + ". " + name + ": "+ highScores[name] + "\n";
  }
}

report([{ name: 'Hank', points: 1110100 },
  { name: 'Steve', points: 1064500 },
  { name: 'Billy', points: 1050200}]);
// ??

在不同环境中会选择以不同的顺序来存储和枚举对象属性,所以这个函数会导致产生不同的字符串。然而我们并不能显而易见地确定程序是否依赖对象枚举的顺序,因此如果需要依赖一个数据结构中的条目顺序,请使用数组而不是字典。

function report(highScores) {
  const result = "";
  for (const i = 0, n = highScores.length; i < n; i++) {
    const score = highScores[i];
    result += `${(i+1)}. ${score.name}: ${score.points} \n`;
  }
  return result;
}

report([{ name: 'Hank', points: 1110100 },
  { name: 'Steve', points: 1064500 },
  { name: 'Billy', points: 1050200}]);

// "1. Hank: 1110100\n2. Steve: 1064500\n3. Billy: 1050200\n"

通过接受一个对象数组,可以顺序遍历从 0 到 highScores.length - 1 的所有元素。

for...in 中浮点数的顺序依赖

const ratings = {
  "Good Will Hunting": 0.8,
  "Mystic River": 0.7,
  "21": 0.6,
  "Doubt": 0.9
}

正如第 2 条中讲过的一样,浮点型算术运算的四舍五入会导致对计算顺序的微妙依赖,当组合未定义顺序的枚举时,可能会导致循环不可预知。

let total = 0, count = 0;
for (const key in ratins) {
  total += ratings[key];
  count++;
};
total /= count;
total;	// ?

流行的 JavaScript 环境实际上使用不同的顺序执行这个循环,如我们来模拟上面对象,不同环境下的计算顺序:

(0.8 + 0.7 + 0.6 + 0.9) / 4	// 0.75

而有些环境总是先枚举潜在的数组索引,然后才是其他 key。

// 如 key 值为 21 的条目
(0.6 + 0.8 + 0.7 + 0.9) / 4	// 0.74999999999999...

我们可以使用整数值,整数加法可以以任意顺序执行。

(8 + 7 + 6 + 9) / 4 / 10	// 0.75
(6 + 8 + 7 + 9) / 4 / 10  // 0.75

总结

  • 使用数组而不是字典来存储有序集合。
  • 使用 for...in 循环来枚举对象属性应当与顺序无关。
  • 如果聚集运算字典中的数据,确保聚集操作与顺序无关。

第 47 条:绝不要在 Object.prototype 中增加可枚举的属性

for...in 循环最常见的用法是枚举字典中的元素,这暗示我们如果想允许对字典对象使用 for...in 循环,那么不要在共享的 Object.prototype 中增加可枚举的属性。

Object.prototype.allKeys = function() {
  const result = [];
  for (const key in this) {
    result.push(key);
  }
  return result;
}
// 将会污染其自身
({ a: 1, b: 2, c: 3 }).allKeys();	// ["allKeys", "a", "b", "c"]

如果非要在 Object.prototype 中增加可枚举属性,ES5 提供了一种友好的机制。

Object.defineProperty 方法可以定义一个对象的属性并指定该属性的元数据。例如,我们可以用与之前完全一样的方法定义上面的属性而通过设置其可枚举属性为 false 使其在 for...in 循环中不可见。

Object.defineProperty(Object.prototype, "allKeys", {
  value: function() {
    const result = [];
    for (const key in this) {
			result.push(key);
    }
    return result;
  },
  writable: true,
  emumerable: false,	// 不可枚举
  configurable: true
});

针对其他对象使用这一技术也是值得的,每当你需要增加一个不应该在 for...in 循环中出现的属性时,Object.defineProperty 便是你的选择。

总结

  • 避免在 Object.prototype 中增加属性。
  • 如果确定需要在 Object.prototype 中增加枚举属性,请使用 ES5 中的 Object.defineProperty 方法将其定义为不可枚举的属性。

第 48 条:避免在枚举期间修改对象

ECMAScript 对并发修改在不同 JavaScript 环境下的行为规范不一致:如果被枚举的对象在枚举期间添加了新的属性,那么在枚举期间并不能保证新添加的属性能被访问。

这个隐式规范的实际后果是:如果我们修改了被枚举的对象,则不能保证 for...in 循环的行为是可预见的

因此将数据条目存储到数组中而不是集合中,能避免在某个平台通过,在其他平台上失败,甚至在同一个平台会出现间歇性的失败。

总结

  • 当使用 for...in 循环枚举一个对象的属性时,由于其不能隐式规范,确保不要修改该对象。
  • 当迭代一个对象时,如果该对象的内容可能会在循环期间被改变,应该使用 while 循环或经典的 for 循环来代替 for...in 循环。
  • 为了在不断变化的数据结构中能够预测枚举,考虑使用一个有序的数据结构,例如数组,而不要使用字典对象。

这与其底层机器存储有关。

第 49 条:数组迭代要优先使用 for 循环而不是 for...in 循环

看一段 for...in 循环看输出结果值:

const scores = [98, 74, 85, 77, 93, 100, 89];
const total = 0;
for (let score in scores) {
  total += score;
}
const mean = total / scores.length;
mean;	// ?

如果认为答案是88,恭喜你理解了这段程序,但不同机器会给你不同的结果。for...in 循环始终枚举所有的 key,合理猜测为(0 + 1 + ... + 6)/ 7 = 21,**但即使是数组的索引属性,对象属性 key 始终是字符串。**根据 += 操作符执行字符串链接操作,total 值为 00123456

因此为了确保不会混淆它们或引发意想不到的字符串强制转换,希望确保以正确的顺序迭代数组,且不会意外地包括在数组对象或其原型链中的非整数属性。那么你需要整数索引和数组元素值获取它们。

const scores = [98, 74, 85, 77, 93, 100, 89];
const total = 0;
for (let i = 0, n = scores.length; i < n; i++) {
  total += score[i];
}
const mean = total / scores.length;
mean;	// 88

总结

  • 迭代数组的索引属性应当总是使用 for 循环而不是 for...in 循环。

第 50 条:迭代方法优于循环

ES5 的常见迭代方法

重复的代码使人阅读代码时太容易忽略一个模式实例与另一个细微差别,导致编程中的一些错误都是在确定循环的终止条件时引入的一些简单错误,如 for 循环:

for (let i = 0; i <= n; i++) {...}
for (let i = 0; i < n; i++) {...}
for (let i = n; i >= 0; i--) {...}
for (let i = n - 1; i >0; i--) {...}

搞清楚终止条件是一个累赘,非常无聊且易犯错误。庆幸的是 JavaScript 的闭包是一种为这些模式建立抽象方便的、富有表现力的手法,避免我们复制、粘贴循环头部。

如 ES5 的一些便利方法 Array.prototype.forEach

for (let i = 0; n = players.length; i < n; i++) {
  players[i].score++;
}

// forEach
players.forEach(p => p.score++);

这段代码不仅更简单可读,还消除了终止条件和任何数组索引。正如另一种常见模式是对数组的每个数组进行一些操作后建立一个新的数组:

const trimmed = [];
for (let i = 0, n = input.length; i < n; i++) {
  trimmed.push(input[i].trim());
}

// map
const trimmed = input.map(s => s.trim());

自定义迭代方法

上述只是 ES5 中的默认方法,我们完全可以定义自己的迭代抽象。例如我们只需要提取满足谓词数组的前几个元素。

function takeWhile(a, pred) {
  const result = [];
  for (let i = 0, n = a.length; i < n; i ++) {
    if (!pred(a[i], i)) {
      break;
    }
    result[i] = a[i];
  }
  return result;
}

const prefix = takeWhile([1, 2, 4, 8, 16, 8, 32], function (n) {
  return n < 10;
});	// [1, 2, 4, 8]

因此我们可以将抽象的 takeWhile 函数添加到 Array.prototype 中使其作为一个方法(第 42 条中关于猴子补丁的影响)。

Array.prototype.takeWhile = function(pred) {
  const result = [];
  for (let i = 0, n = a.length; i < n; i ++) {
    if (!pred(a[i], i)) {
      break;
    }
    result[i] = a[i];
  }
  return result;
}

const prefix = [1, 2, 4, 8, 16, 8, 32].takeWhile(function (n) {
  return n < 10;
});	// [1, 2, 4, 8]

总结

  • 使用迭代方法(如 forEach、map、filter、every、some)代替 for 循环使得代码更可读,并且避免了重复循环控制逻辑。
  • 在自己的库中使用自定义的迭代函数来抽象未被标准库支持的常见循环模式。

第 51 条:在类数组对象上复用通用的数组方法

Array.prototype 中的标准方法被设计成其他对象可复用的方法,即便没有继承自 Array。如第 22 条中所描述的函数的 arguments 对象,但其没有继承 Array.prototype,因此我们不能简单地通过调用 arguments.forEach 方法来遍历每一个参数,只能如下使用:

function highlight() {
  [].forEach.call(arguments, function(widget) {
    widget.setBackground('yellow');
  })
}

forEach 方法是一个 Function 对象,这意味着它继承了 Function.prototype 中的 call 方法。这使得可以使用一个自定义的值座位 forEach 方法内部的 this 绑定来调用它。

类数组

那什么是类数组?其数组对象的基本契约总共有两个简单的规则。

  • 具有一个范围在 0 到 2^32 - 1 的整型 length 属性。
  • length 属性大于该对象的最大索引,索引是一个范围在 0 到 2^32 - 2 的整数,它的字符串表示的是该对象中你的一个 key。

以上两个规则,我们就可以模拟出一个类数组,甚至是一个简单的对象字面量。

const arrayLike = { 0: "a", 1: "b", 2: "c", length: 3 };
const result = Array.prototype.map.call(arrayList, function(s) {
  return s.toUpperCase();
});	// ["A", "B", "C"]

再例如字符串也表现为不可变的数组,因为它们是可索引的,并且其长度也可以通过 length 属性获取。因此,Array.prototype 中的方法操作字符串时并不会修改原始数组。

const result = Array.prototype.map.call("abc", function(s) {
  return s.toUpperCase();
});	// ["A", "B", "C"]

模拟 JavaSciprt 数组的所有行为很精妙,这要归功于数组行为的两个方面。

  • 将 length 属性值设为小于 n 的值会自动地删除索引值大于或小于 n 的所有属性。
  • 增加一个索引值为 n (大于或等于 length 属性值)的属性会自动地设置 length 属性为 n+1。

第二条规则需要监控索引属性的增加以自动地更新 length 属性。但对于使用 Array.prototype 中的方法,这两条规则都不是必须的,因为在增加或删除索引属性的时候,其方法都会强制地更新 length 属性。

类数组无法复用 concat

数组连接方法 concat 类数组无法完全通用,因为该方法会检查其接收者参数的 [[Class]] 属性。如果为真实数组,那么 concat 会将该数组的内容连接起来作为结果。反之则将以一个单一的元素来连接。

function namesColumn() {
  return ["Names"].concat(arguments);
};

// 只将类数组和数组简单连接在一起
namesColumn("Alice", "Bob", "Chris");	// ["Names", { 0: "Alice", 1: "Bob", 2: "Chris" }]

因此我们可以自定义转换类数组为真实数组:

function namesColumn() {
  return ["Names"].concat([].slice.call(arguments));
};
namesColumn("Alice", "Bob", "Chris");	// ["Names", "Alice", "Bob", "Chris"]

总结

  • 对于类数组对象,通过提取方法对象并使用其 call 方法来复用通用的 Array 方法。
  • 任意一个具有索引属性和恰当 length 属性的对象都可以使用通用的 Array 方法。

第 52 条:数组字面量优于数组构造函数

JavaScript 的优雅很大程度上要归功于其程序中最常见的构造块(对象、函数及数组)的简明的字面量语法。字面量是一种表示数组的优雅的方法。

const a1 = [1, 2, 3, 4, 5];
const a2 = new Array(1, 2, 3, 4, 5);	// 数组构造函数

抛开美学不谈,使用构造函数你必须确保没有人重新包装过 Array 变量。

Array = String;
new Array(1, 2, 3, 4, 5);	// new String(1)

还有一种特殊情况,如果使用单个数字参数调用 Array 构造函数时,它试图创建一个没有元素的数组,但其长度属性为给定的参数。这意味着 ["hell"]new Array("hello") 的行为项目,但 [17]new Array(17) 的行为完全不同!

const a1 = [17];	// 创建一个长度为1,包含 17 元素的数组
const a2 = new Array(17);	// 创建一个长度为 17 的空数组

使用字面量具有更规范、更一致的语义。

总结

  • 如果数组构造函数的第一个参数是数字则数组的构造函数行为是不同的。
  • 使用数组字面量替代数组构造函数。

链接