你不懂js系列学习笔记-类型与文法- 04

本文深入探讨JavaScript中的类型转换,即强制转换,分为明确和隐含两种。明确转换使用String(..)、Number(..)等函数显式进行;隐含转换则在特定操作中自动触发。文章分析了各种转换背后的原理及其应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第四章:强制转换

原文:You-Dont-Know-JS

1. 转换值

将一个值从一个类型明确地转换到另一个类型通常称为“类型转换(type casting)”,当这个操作隐含地完成时称为“强制转换(coercion)”(根据一个值如何被使用的规则来强制它变换类型)。

注意: 这可能不明显,但是 JavaScript 强制转换总是得到基本标量值的一种,比如 stringnumber、或 boolean。没有强制转换可以得到像 objectfunction 这样的复杂值。第三章讲解了“封箱”,它将一个基本类型标量值包装在它们相应的 object 中,但在准确的意义上这不是真正的强制转换。

另一种区别这些术语的常见方法是:“类型转换(type casting/conversion)”发生在静态类型语言的编译时,而“类型强制转换(type coercion)”是动态类型语言的运行时转换。

然而,在 JavaScript 中,大多数人将所有这些类型的转换都称为 强制转换(coercion),所以我偏好的区别方式是使用“隐含强制转换(implicit coercion)”与“明确强制转换(explicit coercion)”。

其中的区别应当是很明显的:在观察代码时如果一个类型转换明显是有意为之的,那么它就是“明确强制转换”,而如果这个类型转换是做为其他操作的不那么明显的副作用发生的,那么它就是“隐含强制转换”。

例如,考虑这两种强制转换的方式:

var a = 42;

var b = a + ""; // 隐含强制转换

var c = String(a); // 明确强制转换
复制代码

对于 b 来说,强制转换是隐含地发生的,因为如果与 + 操作符组合的操作数之一是一个 string 值(""),这将使 + 操作成为一个 string 连接(将两个字符串加在一起),而 string 连接的 一个(隐藏的)副作用a 中的值 42 强制转换为它的 string 等价物:"42"

相比之下,String(..) 函数使一切相当明显,它明确地取得 a 中的值,并把它强制转换为一个 string 表现形式。

两种方式都能达到相同的效果:从 42 变成 "42"。但它们 如何 达到这种效果,才是关于 JavaScript 强制转换的热烈争论的核心。

注意: 技术上讲,这里有一些在语法形式区别之上的,行为上的微妙区别。我们将在本章稍后,“隐含:Strings <--> Numbers”一节中仔细讲解。

2. 抽象值操作

2.1 ToString

当任何一个非 string 值被强制转换为一个 string 表现形式时,这个转换的过程是由语言规范的 9.8 部分的 ToString 抽象操作处理的。

内建的基本类型值拥有自然的字符串化形式:null 变为 "null"undefined 变为 "undefined"true 变为 "true"number 一般会以你期望的自然方式表达,但正如我们在第二章中讨论的,非常小或非常大的 number 将会以指数形式表达:

// `1.07`乘以`1000`,7次
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;

// 7次乘以3位 => 21位
a.toString(); // "1.07e21"
复制代码

对于普通的对象,除非你指定你自己的,默认的 toString()(可以在 Object.prototype.toString() 找到)将返回 内部 [[Class]](见第三章),例如 "[object Object]"

但正如早先所展示的,如果一个对象上拥有它自己的 toString() 方法,而你又以一种类似 string 的方式使用这个对象,那么它的 toString() 将会被自动调用,而且这个调用的 string 结果将被使用。

注意: 技术上讲,一个对象被强制转换为一个 string 要通过 ToPrimitive 抽象操作(ES5 语言规范,9.1 部分),但是那其中的微妙细节将会在本章稍后的 ToNumber 部分中讲解,所以我们在这里先跳过它。

数组拥有一个覆盖版本的默认 toString(),将数组字符串化为它所有的值(每个都字符串化)的(字符串)连接,并用 ","分割每个值。

var a = [1, 2, 3];

a.toString(); // "1,2,3"
复制代码

重申一次,toString() 可以明确地被调用,也可以通过在一个需要 string 的上下文环境中使用一个非 string 来自动地被调用。

JSON 字符串化

另一种看起来与 ToString 密切相关的操作是,使用 JSON.stringify(..) 工具将一个值序列化为一个 JSON 兼容的 string值。

重要的是要注意,这种字符串化与强制转换并不完全是同一种东西。但是因为它与上面讲的 ToString 规则有关联,我们将在这里稍微转移一下话题,来讲解 JSON 字符串化行为。

对于最简单的值,JSON 字符串化行为基本上和 toString() 转换是相同的,除了序列化的结果 总是一个 string

JSON.stringify(42); // "42"
JSON.stringify("42"); // ""42"" (一个包含双引号的字符串)
JSON.stringify(null); // "null"
JSON.stringify(true); // "true"
复制代码

任何 JSON 安全 的值都可以被 JSON.stringify(..) 字符串化。但是什么是 JSON 安全的?任何可以用 JSON 表现形式合法表达的值。

考虑 JSON 安全的值可能更容易一些。一些例子是:undefinedfunction、(ES6+)symbol、和带有循环引用的 object(一个对象结构中的属性互相引用而造成了一个永不终结的循环)。对于标准的 JSON 结构来说这些都是非法的值,主要是因为它们不能移植到消费 JSON 值的其他语言中。

JSON.stringify(..) 工具在遇到 undefinedfunction、和 symbol 时将会自动地忽略它们。如果在一个 array 中遇到这样的值,它会被替换为 null(这样数组的位置信息就不会改变)。如果在一个 object 的属性中遇到这样的值,这个属性会被简单地剔除掉。

考虑下面的代码:

JSON.stringify(undefined); // undefined
JSON.stringify(function() {}); // undefined

JSON.stringify([1, undefined, function() {}, 4]); // "[1,null,null,4]"
JSON.stringify({ a: 2, b: function() {} }); // "{"a":2}"
复制代码

但如果你试着 JSON.stringify(..) 一个带有循环引用的 object,就会抛出一个错误。

JSON 字符串化有一个特殊行为,如果一个 object 值定义了一个 toJSON() 方法,这个方法将会被首先调用,以取得用于序列化的值。

如果你打算 JSON 字符串化一个可能含有非法 JSON 值的对象,或者如果这个对象中正好有不适于序列化的值,那么你就应当为它定义一个 toJSON() 方法,返回这个 object 的一个 JSON 安全 版本。

例如:

var o = {};

var a = {
  b: 42,
  c: o,
  d: function() {}
};

// 在 `a` 内部制造一个循环引用
o.e = a;

// 这会因循环引用而抛出一个错误
// JSON.stringify( a );

// 自定义一个 JSON 值序列化
a.toJSON = function() {
  // 序列化仅包含属性 `b`
  return { b: this.b };
};

JSON.stringify(a); // "{"b":42}"
复制代码

一个很常见的误解是,toJSON() 应当返回一个 JSON 字符串化的表现形式。这可能是不正确的,除非你事实上想要字符串化 string 本身(通常不会!)。toJSON() 应当返回合适的实际普通值(无论什么类型),而 JSON.stringify(..) 自己会处理字符串化。

换句话说,toJSON() 应当被翻译为:“变为一个适用于字符串化的 JSON 安全的值”,而不是像许多开发者错误认为的那样,“变为一个 JSON 字符串”。

考虑下面的代码:

var a = {
  val: [1, 2, 3],

  // 可能正确!
  toJSON: function() {
    return this.val.slice(1);
  }
};

var b = {
  val: [1, 2, 3],

  // 可能不正确!
  toJSON: function() {
    return "[" + this.val.slice(1).join() + "]";
  }
};

JSON.stringify(a); // "[2,3]"

JSON.stringify(b); // ""[2,3]""
复制代码

在第二个调用中,我们字符串化了返回的 string 而不是 array 本身,这可能不是我们想要做的。

既然我们说到了 JSON.stringify(..),那么就让我们来讨论一些不那么广为人知,但是仍然很有用的功能吧。

JSON.stringify(..) 的第二个参数值是可选的,它称为 替换器(replacer)。这个参数值既可以是一个 array 也可以是一个 function。与 toJSON() 为序列化准备一个值的方式类似,它提供一种过滤机制,指出一个 object 的哪一个属性应该或不应该被包含在序列化形式中,来自定义这个 object 的递归序列化行为。

如果 替换器 是一个 array,那么它应当是一个 stringarray,它的每一个元素指定了允许被包含在这个 object 的序列化形式中的属性名称。如果一个属性不存在于这个列表中,那么它就会被跳过。

如果 替换器 是一个 function,那么它会为 object 本身而被调用一次,并且为这个 object 中的每个属性都被调用一次,而且每次都被传入两个参数值,keyvalue。要在序列化中跳过一个 key,可以返回 undefined。否则,就返回被提供的 value

var a = {
  b: 42,
  c: "42",
  d: [1, 2, 3]
};

JSON.stringify(a, ["b", "c"]); // "{"b":42,"c":"42"}"

JSON.stringify(a, function(k, v) {
  if (k !== "c") return v;
});
// "{"b":42,"d":[1,2,3]}"
复制代码

注意:function 替换器 的情况下,第一次调用时 key 参数 kundefined(而对象 a 本身会被传入)。if 语句会 过滤掉 名称为 c 的属性。字符串化是递归的,所以数组 [1,2,3] 会将它的每一个值(12、和 3)都作为 v 传递给 替换器,并将索引值(01、和 2)作为 k

JSON.stringify(..) 还可以接收第三个可选参数值,称为 填充符(space),在对人类友好的输出中它被用做缩进。填充符可以是一个正整数,用来指示每一级缩进中应当使用多少个空格字符。或者,填充符 可以是一个 string,这时每一级缩进将会使用它的前十个字符。

var a = {
  b: 42,
  c: "42",
  d: [1, 2, 3]
};

JSON.stringify(a, null, 3);
// "{
//    "b": 42,
//    "c": "42",
//    "d": [
//       1,
//       2,
//       3
//    ]
// }"

JSON.stringify(a, null, "-----");
// "{
// -----"b": 42,
// -----"c": "42",
// -----"d": [
// ----------1,
// ----------2,
// ----------3
// -----]
// }"
复制代码

记住,JSON.stringify(..) 并不直接是一种强制转换的形式。但是,我们在这里讨论它,是由于两个与 ToString 强制转换有关联的行为:

  1. stringnumberboolean、和 null 值在 JSON 字符串化时,与它们通过 ToString 抽象操作的规则强制转换为 string 值的方式基本上是相同的。
  2. 如果传递一个 object 值给 JSON.stringify(..),而这个 object 上拥有一个 toJSON() 方法,那么在字符串化之前,toJSON() 就会被自动调用来将这个值(某种意义上)“强制转换”为 JSON 安全 的。

2.2 ToNumber

如果任何非 number 值,以一种要求它是 number 的方式被使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的 ToNumber 抽象操作。

例如,true 变为 1false 变为 0undefined 变为 NaN,而(奇怪的是)null 变为 0

对于一个 string 值来说,ToNumber 工作起来很大程度上与数字字面量的规则/语法很相似(见第三章)。如果它失败了,结果将是 NaN(而不是 number 字面量中会出现的语法错误)。一个不同之处的例子是,在这个操作中 0 前缀的八进制数不会被作为八进制数来处理(而仅作为普通的十进制小数),虽然这样的八进制数作为 number 字面量是合法的。

注意: number 字面量文法与用于 string 值的 ToNumber 间的区别极其微妙,在这里就不进一步讲解了。更多的信息可以参考 ES 语言规范的 9.3.1 部分。

对象(以及数组)将会首先被转换为它们的基本类型值的等价物,而后这个结果值(如果它还不是一个 number 基本类型)会根据刚才提到的 ToNumber 规则被强制转换为一个 number

为了转换为基本类型值的等价物,ToPrimitive 抽象操作(ES5 语言规范,9.1 部分)将会查询这个值(使用内部的 DefaultValue 操作 —— ES5 语言规范,8.12.8 部分),看它有没有 valueOf() 方法。如果 valueOf() 可用并且它返回一个基本类型值,那么 这个 值就将用于强制转换。如果不是这样,但 toString() 可用,那么就由它来提供用于强制转换的值。

如果这两种操作都没提供一个基本类型值,就会抛出一个 TypeError

在 ES5 中,你可以创建这样一个不可强制转换的对象 —— 没有 valueOf()toString() —— 如果它的 [[Prototype]] 的值为 null,这通常是通过 Object.create(null) 来创建的。关于 [[Prototype]] 的详细信息参见本系列的 this 与对象原型

注意: 我们会在本章稍后讲解如何强制转换至 number,但对于下面的代码段,想象 Number(..) 函数就是那样做的。

考虑如下代码:

var a = {
  valueOf: function() {
    return "42";
  }
};

var b = {
  toString: function() {
    return "42";
  }
};

var c = [4, 2];
c.toString = function() {
  return this.join(""); // "42"
};

Number(a); // 42
Number(b); // 42
Number(c); // 42
Number(""); // 0
Number([]); // 0
Number(["abc"]); // NaN
复制代码

2.3 ToBoolean

首先而且最重要的是,JS 实际上拥有 truefalse 关键字,而且它们的行为正如你所期望的 boolean 值一样。一个常见的误解是,值 10true/false 是相同的。虽然这可能在其他语言中是成立的,但在 JS 中 number 就是 number,而 boolean 就是 boolean。你可以将 1 强制转换为 true(或反之),或将 0 强制转换为 false(或反之)。但它们不是相同的。

2.3.1 Falsy 值

所有的 JavaScript 值都可以被划分进两个类别:

  1. 如果被强制转换为 boolean,将成为 false 的值
  2. 其它的一切值(很明显将变为 true

JS 语言规范给那些在强制转换为 boolean 值时将会变为 false 的值定义了一个明确的,小范围的列表。

在 ES5 语言规范中,9.2 部分定义了一个 ToBoolean 抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。

从这个表格中,我们得到了下面所谓的“falsy”值列表:

  • undefined
  • null
  • false
  • +0, -0, and NaN
  • ""

就是这些。如果一个值在这个列表中,它就是一个“falsy”值,而且当你在它上面进行 boolean 强制转换时它会转换为 false

通过逻辑上的推论,如果一个值 在这个列表中,那么它一定在 另一个列表 中,也就是我们称为“truthy”值的列表。但是 JS 没有真正定义一个“truthy”列表。它给出了一些例子,比如它说所有的对象都是 truthy,但是语言规范大致上暗示着:任何没有明确地存在于 falsy 列表中的东西,都是 truthy

2.3.2 Falsy 对象

考虑下面的代码:

var a = new Boolean(false);
var b = new Number(0);
var c = new String("");
复制代码

我们知道这三个值都是包装了明显是 falsy 值的对象(见第三章)。但这些对象是作为 true 还是作为 false 动作呢?这很容易回答:

var d = Boolean(a && b && c);

d; // true
复制代码

所以,三个都作为 true 动作,这是唯一能使 d 得到 true 的方法。

提示: 注意包在 a && b && c 表达式外面的 Boolean( .. ) —— 你可能想知道为什么它在这儿。我们会在本章稍后回到这个话题,所以先做个心理准备。为了先睹为快,你可以自己试试如果没有 Boolean( .. ) 调用而只有 d = a && b && cd 是什么。

那么,如果“falsy 对象” 不是包装着 falsy 值的对象,它们是什么鬼东西?

刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JavaScript 本身的一部分。

什么!?

有些特定的情况,在普通的 JS 语义之上,浏览器已经创建了它们自己的某种 外来 值的行为,也就是这种“falsy 对象”的想法。

一个“falsy 对象”看起来和动起来都像一个普通对象(属性,等等)的值,但是当你强制转换它为一个 boolean 时,它会变为一个 false 值。

为什么!?

最著名的例子是 document.all:一个 由 DOM(不是 JS 引擎本身) 给你的 JS 程序提供的类数组(对象),它向你的 JS 程序暴露你页面上的元素。它 曾经 像一个普通对象那样动作 —— 是一个 truthy。但不再是了。

document.all 本身从来就不是“标准的”,而且从很早以前就被废弃/抛弃了。

“那他们就不能删掉它吗?” 对不起,想得不错。但愿它们能。但是世面上有太多的遗产 JS 代码库依赖于它。

那么,为什么使它像 falsy 一样动作?因为从 document.allboolean 的强制转换(比如在 if 语句中)几乎总是用来检测老的,非标准的 IE。

IE 从很早以前就开始顺应规范了,而且在许多情况下它在推动 web 向前发展的作用和其他浏览器一样多,甚至更多。但是所有那些老旧的 if (document.all) { /* it's IE */ } 代码依然留在世面上,而且大多数可能永远都不会消失。所有这些遗产代码依然假设它们运行在那些给 IE 用户带来差劲儿的浏览体验的,几十年前的老 IE 上,

所以,我们不能完全移除 document.all,但是 IE 不再想让 if (document.all) { .. } 代码继续工作了,这样现代 IE 的用户就能得到新的,符合标准的代码逻辑。

2.3.3 Truthy 值

回到 truthy 列表。到底什么是 truthy 值?记住:如果一个值不在 falsy 列表中,它就是 truthy

考虑下面代码:

var a = "false";
var b = "0";
var c = "''";

var d = Boolean(a && b && c);

d;
复制代码

你期望这里的 d 是什么值?它要么是 true 要么是 false

它是 true。为什么?因为尽管这些string值的内容看起来是 falsy 值,但是string值本身都是 truthy,而这是因为在 falsy 列表中""是唯一的string值。

那么这些呢?

var a = []; // 空数组 -- truthy 还是 falsy?
var b = {}; // 空对象 -- truthy 还是 falsy?
var c = function() {}; // 空函数 -- truthy 还是 falsy?

var d = Boolean(a && b && c);

d;
复制代码

是的,你猜到了,这里的d依然是true。为什么?和前面的原因一样。尽管它们看起来像,但是[]{},和function(){}不在 falsy 列表中,因此它们是 truthy 值。

3. 明确的强制转换

3.1 明确地:Strings <--> Numbers

为了在stringnumber之间进行强制转换,我们使用内建的String(..)Number(..)函数(我们在第三章中所指的“原生构造器”),但 非常重要的是,我们不在它们前面使用new关键字。这样,我们就不是在创建对象包装器。

取而代之的是,我们实际上在两种类型之间进行 明确地强制转换

var a = 42;
var b = String(a);

var c = "3.14";
var d = Number(c);

b; // "42"
d; // 3.14
复制代码

String(..)使用早先讨论的ToString操作的规则,将任意其它的值强制转换为一个基本类型的string值。Number(..)使用早先讨论过的ToNumber操作的规则,将任意其他的值强制转换为一个基本类型的number值。

除了String(..)Number(..),还有其他的方法可以把这些值在stringnumber之间进行“明确地”转换:

var a = 42;
var b = a.toString();

var c = "3.14";
var d = +c;

b; // "42"
d; // 3.14
复制代码

这里的+c+操作符的 一元操作符(操作符只有一个操作数)形式。取代进行数学加法(或字符串连接 —— 见下面的讨论)的是,一元的+明确地将它的操作数(c)强制转换为一个number值。

+c明确的 强制转换吗?这要看你的经验和角度。如果你知道(现在你知道了!)一元+明确地意味着number强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。

注意: 在开源的 JS 社区中一般被接受的观点是,一元+是一个 明确的 强制转换形式。

即使你真的喜欢+c这种形式,它绝对会在有的地方看起来非常令人困惑。考虑下面的代码:

var c = "3.14";
var d = 5 + +c;

d; // 8.14
复制代码

一元-操作符也像+一样进行强制转换,但它还会翻转数字的符号。但是你不能放两个减号--来使符号翻转回来,因为那将被解释为递减操作符。取代它的是,你需要这么做:- -"3.14",在两个减号之间加入空格,这将会使强制转换的结果为3.14

你可能会想到所有种类的可怕组合 —— 一个二元操作符挨着另一个操作符的一元形式。这里有另一个疯狂的例子:

1 + -+(+(+-+1)); // 2
复制代码

当一个一元+(或-)紧邻其他操作符时,你应当强烈地考虑避免使用它。虽然上面的代码可以工作,但几乎全世界都认为它是一个坏主意。即使是d = +c(或者d =+ c!)都太容易与d += c像混淆了,而后者完全是不同的东西!

Datenumber

另一个一元+操作符的常见用法是将一个Date对象强制转换为一个number,其结果是这个日期/时间值的 unix 时间戳(从世界协调时间的 1970 年 1 月 1 日 0 点开始计算,经过的毫秒数)表现形式:

var d = new Date("Mon, 18 Aug 2014 08:53:06 CDT");

+d; // 1408369986000
复制代码

这种习惯性用法经常用于取得当前的 现在 时刻的时间戳,比如:

var timestamp = +new Date();
复制代码

注意: 一些开发者知道一个 JavaScript 中的特别的语法“技巧”,就是在构造器调用(一个带有new的函数调用)中如果没有参数值要传递的话,()可选的。所以你可能遇到var timestamp = +new Date;形式。然而,不是所有的开发者都同意忽略()可以增强可读性,因为它是一种不寻常的语法特例,只能适用于new fn()调用形式,而不能用于普通的fn()调用形式。

但强制转换不是从Date对象中取得时间戳的唯一方法。一个不使用强制转换的方式可能更好,因为它更加明确:

var timestamp = new Date().getTime();
// var timestamp = (new Date()).getTime();
// var timestamp = (new Date).getTime();
复制代码

但是一个 更更好的 不使用强制转换的选择是使用 ES5 加入的Date.now()静态函数:

var timestamp = Date.now();
复制代码

而且如果你想要为老版本的浏览器填补Date.now()的话,也十分简单:

if (!Date.now) {
  Date.now = function() {
    return +new Date();
  };
}
复制代码

我推荐跳过与日期有关的强制转换形式。使用Date.now()来取得当前 现在 的时间戳,而使用new Date( .. ).getTime()来取得一个需要你指定的 非现在 日期/时间的时间戳。

奇异的~

一个经常被忽视并通常让人糊涂的 JS 强制操作符是波浪线~操作符(也叫“按位取反”,“比特非”)。许多理解它在做什么的人也总是想要避开它。但是为了坚持我们在本书和本系列中的精神,让我们深入并找出~是否有一些对我们有用的东西。

在第二章的“32 位(有符号)整数”一节,我们讲解了在 JS 中位操作符是如何仅为 32 位操作定义的,这意味着我们强制它们的操作数遵循 32 位值的表现形式。这个规则如何发生是由ToInt32抽象操作(ES5 语言规范,9.5 部分)控制的。

ToInt32首先进行ToNumber强制转换,这就是说如果值是"123",它在ToInt32规则实施之前会首先变成123

虽然它本身没有 技术上进行 强制转换(因为类型没有改变),但对一些特定的特殊number值使用位操作符(比如|~)会产生一种强制转换效果,这种效果的结果是一个不同的number值。

举例来说,让我们首先考虑惯用的空操作0 | x(在第二种章有展示)中使用的|“比特或”操作符,它实质上仅仅进行ToInt32转换:

0 | -0; // 0
0 | NaN; // 0
0 | Infinity; // 0
0 | -Infinity; // 0
复制代码

另一种考虑~定义的方法是,~源自学校中的计算机科学/离散数学:~进行二进制取补操作。太好了,谢谢,我完全明白了!

我们再试一次:~x大致与-(x+1)相同。这很奇怪,但是稍微容易推理一些。所以:

~42; // -(42+1) ==> -43
复制代码

你可能还在想~这个鬼东西到底和什么有关,或者对于强制转换的讨论它究竟有什么要紧。让我们快速进入要点。

考虑一下-(x+1)。通过进行这个操作,能够产生结果0(或者从技术上说-0!)的唯一的值是什么?-1。换句话说,~用于一个范围的number值时,将会为输入值-1产生一个 falsy(很容易强制转换为false)的0,而为任意其他的输入产生 truthy 的number

为什么这要紧?

-1通常称为一个“哨兵值”,它基本上意味着一个在同类型值(number)的更大的集合中被赋予了任意的语义。在 C 语言中许多函数使用哨兵值-1,它们返回>= 0的值表示“成功”,返回-1表示“失败”。

JavaScript 在定义string操作indexOf(..)时采纳了这种先例,它搜索一个子字符串,如果找到就返回它从 0 开始计算的索引位置,没有找到的话就返回-1

这样的情况很常见:不仅仅将indexOf(..)作为取得位置的操作,而且作为检查一个子字符串存在/不存在于另一个string中的boolean值。这就是开发者们通常如何进行这样的检查:

var a = "Hello World";

if (a.indexOf("lo") >= 0) {
  // true
  // 找到了!
}
if (a.indexOf("lo") != -1) {
  // true
  // 找到了
}

if (a.indexOf("ol") < 0) {
  // true
  // 没找到!
}
if (a.indexOf("ol") == -1) {
  // true
  // 没找到!
}
复制代码

我感觉看着>= 0== -1有些恶心。它基本上是一种“抽象泄漏”,这里它将底层的实现行为 —— 使用哨兵值-1表示“失败” —— 泄漏到我的代码中。我倒是乐意隐藏这样的细节。

现在,我们终于看到为什~可以帮到我们了!将~indexOf()一起使用可以将值“强制转换”(实际上只是变形)为 可以适当地强制转换为 boolean 的值

var a = "Hello World";

~a.indexOf("lo"); // -4   <-- truthy!

if (~a.indexOf("lo")) {
  // true
  // 找到了!
}

~a.indexOf("ol"); // 0    <-- falsy!
!~a.indexOf("ol"); // true

if (!~a.indexOf("ol")) {
  // true
  // 没找到!
}
复制代码

~拿到indexOf(..)的返回值并将它变形:对于“失败”的-1我们得到 falsy 的0,而其他的值都是 truthy。

注意: ~的假想算法-(x+1)暗示着~-1-0,但是实际上它产生0,因为底层的操作其实是按位的,不是数学操作。

技术上讲,if (~a.indexOf(..))仍然依靠 隐含的 强制转换将它的结果0变为false或非零变为true。但总的来说,对我而言~更像一种 明确的 强制转换机制,只要你知道在这种惯用法中它的意图是什么。

我感觉这样的代码要比前面凌乱的>= 0 / == -1更干净。

截断比特位

在你遇到的代码中,还有一个地方可能出现~:一些开发者使用双波浪线~~来截断一个number的小数部分(也就是,将它“强制转换”为一个“整数”)。这通常(虽然是错误的)被说成与调用Math.floor(..)的结果相同。

~ ~的工作方式是,第一个~实施ToInt32“强制转换”并进行按位取反,然后第二个~进行另一次按位取反,将每一个比特位都翻转回原来的状态。于是最终的结果就是ToInt32“强制转换”(也叫截断)。

注意: ~~的按位双翻转,与双否定!!的行为非常相似,它将在稍后的“明确地:* --> Boolean”一节中讲解。

然而,~~需要一些注意/澄清。首先,它仅在 32 位值上可以可靠地工作。但更重要的是,它在负数上工作的方式与Math.floor(..)不同!

Math.floor(-49.6); // -50
~~-49.6; // -49
复制代码

Math.floor(..)的不同放在一边,~~x可以将值截断为一个(32 位)整数。但是x | 0也可以,而且看起来还(稍微)省事儿 一些。

那么,为什么你可能会选择~~x而不是x | 0?操作符优先权(见第五章):

~~1e20 / 10; // 166199296

1e20 | (0 / 10); // 1661992960
(1e20 | 0) / 10; // 166199296
复制代码

正如这里给出的其他建议一样,仅在读/写这样的代码的每一个人都知道这些操作符如何工作的情况下,才将~~~作为“强制转换”和将值变形的明确机制。

3.2 明确地:解析数字字符串

将一个string强制转换为一个number的类似结果,可以通过从string的字符内容中解析(parsing)出一个number得到。然而在这种解析和我们上面讲解的类型转换之间存在着区别。

考虑下面的代码:

var a = "42";
var b = "42px";

Number(a); // 42
parseInt(a); // 42

Number(b); // NaN
parseInt(b); // 42
复制代码

从一个字符串中解析出一个数字是 容忍 非数字字符的 —— 从左到右,如果遇到非数字字符就停止解析 —— 而强制转换是 不容忍 并且会失败而得出值NaN

解析不应当被视为强制转换的替代品。这两种任务虽然相似,但是有着不同的目的。当你不知道/不关心右手边可能有什么其他的非数字字符时,你可以将一个string作为number解析。当只有数字才是可接受的值,而且像"42px"这样的东西作为数字应当被排除时,就强制转换一个string(变为一个number)。

在 ES5 之前,parseInt(..)还存在另外一个坑,这曾是许多 JS 程序的 bug 的根源。如果你不传递第二个参数来指定使用哪种进制(也叫基数)来翻译数字的string内容,parseInt(..)将会根据开头的字符进行猜测。

如果开头的两个字符是"0x""0X",那么猜测(根据惯例)将是你想要将这个string翻译为一个 16 进制number。否则,如果第一个字符是"0",那么猜测(也是根据惯例)将是你想要将这个string翻译成 8 进制number

16 进制的string(以0x0X开头)没那么容易搞混。但是事实证明 8 进制数字的猜测过于常见了。比如:

var hour = parseInt(selectedHour.value);
var minute = parseInt(selectedMinute.value);

console.log("The time you selected was: " + hour + ":" + minute);
复制代码

ES5 之前的修改很简单,但是很容易忘:总是在第二个参数值上传递 10。这完全是安全的:

var hour = parseInt(selectedHour.value, 10);
var minute = parseInt(selectedMiniute.value, 10);
复制代码

在 ES5 中,parseInt(..)不再猜测八进制数了。除非你指定,否则它会假定为 10 进制(或者为"0x"前缀猜测 16 进制数)。这好多了。只是要小心,如果你的代码不得不运行在前 ES5 环境中,你仍然需要为基数传递10

解析非字符串

一个关于parseInt(..)行为的一个臭名昭著的例子:

parseInt(1 / 0, 19); // 18
复制代码

首先,这其中最明显的原罪是将一个非string传入了parseInt(..)。这是不对的。这么做是自找麻烦。但就算你这么做了,JS 也会礼貌地将你传入的东西强制转换为它可以解析的string

有些人可能会争论说这是一种不合理的行为,parseInt(..)应当拒绝在一个非string值上操作。它应该抛出一个错误吗?坦白地说,像 Java 那样。但是一想到 JS 应当开始在满世界抛出错误,以至于几乎每一行代码都需要用try..catch围起来,我就不寒而栗。

它应当返回NaN吗?也许。但是……要是这样呢:

parseInt(new String("42"));
复制代码

这也应当失败吗?它是一个非string值啊。如果你想让String对象包装器被开箱成"42",那么42先变成"42",以使42可以被解析回来就那么不寻常吗?

我会争论说,这种可能发生的半 明确隐含 的强制转换经常可以成为非常有用的东西。比如:

var a = {
  num: 21,
  toString: function() {
    return String(this.num * 2);
  }
};

parseInt(a); // 42
复制代码

事实上parseInt(..)将它的值强制转换为string来实施解析是十分合理的。如果你传垃圾进去,那么你就会得到垃圾,不要责备垃圾桶 —— 它只是忠实地尽自己的责任。

那么,如果你传入像Infinity(很明显是1 / 0的结果)这样的值,对于它的强制转换来说哪种string表现形式最有道理呢?我脑中只有两种合理的选择:"Infinity""∞"。JS 选择了"Infinity"

那么,回到我们的parseInt( 1/0, 19 )例子。它实质上是parseInt( "Infinity", 19 )。它如何解析?第一个字符是"I",在愚蠢的 19 进制中是值18。第二个字符"n"不再合法的数字字符集内,所以这样的解析就礼貌地停止了,就像它在"42px"中遇到"p"那样。

结果呢?18。正如它应该的那样。对 JS 来说,并非一个错误或者Infinity本身,而是将我们带到这里的一系列的行为才是 非常重要 的,不应当那么简单地被丢弃。

其他关于parseInt(..)行为的,令人吃惊但又十分合理的例子还包括:

parseInt(0.000008); // 0   ("0" from "0.000008")
parseInt(0.0000008); // 8   ("8" from "8e-7")
parseInt(false, 16); // 250 ("fa" from "false")
parseInt(parseInt, 16); // 15  ("f" from "function..")

parseInt("0x10"); // 16
parseInt("103", 2); // 2
复制代码

3.3 明确地:* --> Boolean

正如上面的String(..)Number(..)Boolean(..)(当然,不带new!)是强制进行ToBoolean转换的明确方法:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

Boolean(a); // true
Boolean(b); // true
Boolean(c); // true

Boolean(d); // false
Boolean(e); // false

Boolean(f); // false
Boolean(g); // false
复制代码

虽然Boolean(..)是非常明确的,但是它并不常见也不为人所惯用。

正如一元+操作符将一个值强制转换为一个number(参见上面的讨论),一元的!否定操作符可以将一个值明确地强制转换为一个boolean问题 是它还将值从 truthy 翻转为 falsy,或反之。所以,大多数 JS 开发者使用!!双否定操作符进行boolean强制转换,因为第二个!将会把它翻转回原本的 true 或 false:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

!!a; // true
!!b; // true
!!c; // true

!!d; // false
!!e; // false
!!f; // false
!!g; // false
复制代码

另一个ToBoolean强制转换的用例是,如果你想在数据结构的 JSON 序列化中强制转换一个true/false

var a = [
  1,
  function() {
    /*..*/
  },
  2,
  function() {
    /*..*/
  }
];

JSON.stringify(a); // "[1,null,2,null]"

JSON.stringify(a, function(key, val) {
  if (typeof val == "function") {
    // 强制函数进行 `ToBoolean` 转换
    return !!val;
  } else {
    return val;
  }
});
// "[1,true,2,true]"
复制代码

4. 隐含的强制转换

4.1 隐含地:Strings <--> Numbers

为了服务于number的相加和string的连接两个目的,+操作符被重载了。那么 JS 如何知道你想用的是哪一种操作呢?考虑下面的代码:

var a = "42";
var b = "0";

var c = 42;
var d = 0;

a + b; // "420"
c + d; // 42
复制代码

是什么不同导致了"420"42?一个常见的误解是,这个不同之处在于操作数之一或两者是否是一个string,这意味着+将假设string连接。虽然这有一部分是对的,但实际情况要更复杂。

简化的解释:如果+的两个操作数之一是一个string(或在上面的步骤中成为一个string),那么操作就会是string连接。否则,它总是数字加法。

注意: 关于强制转换,一个经常被引用的坑是[] + {}{} + [],这两个表达式的结果分别是"[object Object]"0。虽然对此有更多的东西,但是我们将在第五章的“Block”中讲解这其中的细节。

你可以简单地通过将number和空string``""“相加”来把一个number强制转换为一个string

var a = 42;
var b = a + "";

b; // "42"
复制代码

4.2 隐含地:Booleans --> Numbers

我认为 隐含 强制转换可以真正闪光的一个情况是,将特定类型的复杂boolean逻辑简化为简单的数字加法。当然,这不是一个通用的技术,而是一个特定情况的特定解决方法。

考虑如下代码:

function onlyOne(a, b, c) {
  return !!((a && !b && !c) || (!a && b && !c) || (!a && !b && c));
}

var a = true;
var b = false;

onlyOne(a, b, b); // true
onlyOne(b, a, b); // true

onlyOne(a, b, a); // false
复制代码

这个onlyOne(..)工具应当仅在正好有一个参数是true/truthy 时返回true。它在 truthy 的检查上使用 隐含的 强制转换,而在其他的地方使用 明确的 强制转换,包括最后的返回值。

但如果我们需要这个工具能够以相同的方式处理四个,五个,或者二十个标志值呢?很难想象处理所有那些比较的排列组合的代码实现。

但这里是boolean值到number(很明显,01)的强制转换可以提供巨大帮助的地方:

function onlyOne() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    // 跳过falsy值。与将它们视为0相同,但是避开NaN
    if (arguments[i]) {
      sum += arguments[i];
    }
  }
  return sum == 1;
}

var a = true;
var b = false;

onlyOne(b, a); // true
onlyOne(b, a, b, b, b); // true

onlyOne(b, b); // false
onlyOne(b, a, b, b, b, a); // false
复制代码

注意: 当然,除了在onlyOne(..)中的for循环,你可以更简洁地使用 ES5 的reduce(..)工具,但我不想因此而模糊概念。

我们在这里做的事情有赖于true/truthy 的强制转换结果为1,并将它们作为数字加起来。sum += arguments[i]通过 隐含的强制转换使这发生。如果在arguments列表中有且仅有一个值为true,那么这个数字的和将是1,否则和就不是1而不能使期望的条件成立。

我们当然本可以使用 明确的 强制转换:

function onlyOne() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    sum += Number(!!arguments[i]);
  }
  return sum === 1;
}
复制代码

我们首先使用!!arguments[i]来将这个值强制转换为truefalse。这样你就可以像onlyOne( "42", 0 )这样传入非boolean值了,而且它依然可以如意料的那样工作(要不然,你将会得到string连接,而且逻辑也不正确)。

一旦我们确认它是一个boolean,我们就使用Number(..)进行另一个 明确的 强制转换来确保值是01

这个工具的 明确 强制转换形式“更好”吗?它确实像代码注释中解释的那样避开了NaN的陷阱。但是,这最终要看你的需要。我个人认为前一个版本,依赖于 隐含的 强制转换更优雅(如果你不传入undefinedNaN),而 明确的 版本是一种不必要的繁冗。

但与我们在这里讨论的几乎所有东西一样,这是一个主观判断。

注意: 不管是 隐含的 还是 明确的 方式,你可以通过将最后的比较从1改为25,来分别很容易地制造onlyTwo(..)onlyFive(..)。这要比添加一大堆&&||表达式要简单太多了。所以,一般来说,在这种情况下强制转换非常有用。

4.3 隐含地:* --> Boolean

现在,让我们将注意力转向目标为boolean值的 隐含 强制转换上,这是目前最常见,并且还是目前潜在的最麻烦的一种。

记住,隐含的 强制转换是当你以强制一个值被转换的方式使用这个值时才启动的。对于数字和string操作,很容易就能看出这种强制转换是如何发生的。

但是,哪个种类的表达式操作(隐含地)要求/强制一个boolean转换呢?

  1. 在一个if (..)语句中的测试表达式。
  2. 在一个for ( .. ; .. ; .. )头部的测试表达式(第二个子句)。
  3. while (..)do..while(..)循环中的测试表达式。
  4. ? :三元表达式中的测试表达式(第一个子句)。
  5. ||(“逻辑或”)和&&(“逻辑与”)操作符左手边的操作数(它用作测试表达式 —— 见下面的讨论!)。

在这些上下文环境中使用的,任何还不是boolean的值,将通过本章早先讲解的ToBoolean抽象操作的规则,被 隐含地 强制转换为一个boolean

我们来看一些例子:

var a = 42;
var b = "abc";
var c;
var d = null;

if (a) {
  console.log("yep"); // yep
}

while (c) {
  console.log("nope, never runs");
}

c = d ? a : b;
c; // "abc"

if ((a && d) || c) {
  console.log("yep"); // yep
}
复制代码

在所有这些上下文环境中,非boolean值被 隐含地强制转换 为它们的boolean等价物,来决定测试的结果。

4.4 ||&&操作符

引用 ES5 语言规范的 11.11 部分:

一个&&或||操作符产生的值不见得是 Boolean 类型。这个产生的值将总是两个操作数表达式其中之一的值。

让我们展示一下:

var a = 42;
var b = "abc";
var c = null;

a || b; // 42
a && b; // "abc"

c || b; // "abc"
c && b; // null
复制代码

||&&操作符都在 第一个操作数ac) 上进行boolean测试。如果这个操作数还不是boolean(就像在这里一样),就会发生一次普通的ToBoolean强制转换,这样测试就可以进行了。

对于||操作符,如果测试结果为true||表达式就将 第一个操作数 的值(ac)作为结果。如果测试结果为false||表达式就将 第二个操作数 的值(b)作为结果。

相反地,对于&&操作符,如果测试结果为true&&表达式将 第二个操作数 的值(b)作为结果。如果测试结果为false,那么&&表达式就将 第一个操作数 的值(ac)作为结果。

||&&表达式的结果总是两个操作数之一的底层值,不是(可能是被强制转换来的)测试的结果。在c && b中,cnull,因此是 falsy。但是&&表达式本身的结果为nullc中的值),不是用于测试的强制转换来的false

另一种考虑这些操作数的方式是:

a || b;
// 大体上等价于:
a ? a : b;

a && b;
// 大体上等价于:
a ? b : a;
复制代码

注意: 我说a || b“大体上等价”于a ? a : b,是因为虽然结果相同,但是这里有一个微妙的不同。在a ? a : b中,如果a是一个更复杂的表达式(例如像调用function那样可能带有副作用),那么这个表达式a将有可能被求值两次(如果第一次求值的结果为 truthy)。相比之下,对于a || b,表达式a仅被求值一次,而且这个值将被同时用于强制转换测试和结果值(如果合适的话)。同样的区别也适用于a && ba ? b : a表达式。

那么&&呢?

有另一种在手动编写中不那么常见,而在 JS 压缩器中频繁使用的惯用法。&&操作符会“选择”第二个操作数,当且仅当第一个操作数测试为 truthy,这种用法有时被称为“守护操作符”(参见第五章的“短接”) —— 第一个表达式的测试“守护”着第二个表达式:

function foo() {
  console.log(a);
}

var a = 42;

a && foo(); // 42
复制代码

foo()仅在a测试为 truthy 时会被调用。如果这个测试失败,这个a && foo()表达式语句将会无声地停止 —— 这被称为“短接” —— 而且永远不会调用foo()

5. 宽松等价与严格等价

宽松等价是==操作符,而严格等价是===操作符。两个操作符都被用于比较两个值的“等价性”,但是“宽松”和“严格”暗示着它们行为之间的一个 非常重要 的不同,特别是在它们如何决定“等价性”上。

关于这两个操作符的一个非常常见的误解是:“==检查值的等价性,而===检查值和类型的等价性。”虽然这听起来很好很合理,但是不准确。无数知名的 JavaScript 书籍和文章都是这么说的,但不幸的是它们都 错了

正确的描述是:“==允许在等价性比较中进行强制转换,而===不允许强制转换”。

在一般期望的结果中,有一些例外需要小心:

  • NaN永远不等于它自己
  • +0-0是相等的

包括关于两个object的值的规定。很少有人知道,在两个object被比较的情况下,==和===的行为相同

注意: !=宽松不等价操作是如你预料的那样定义的,它差不多就是==比较操作完整实施,之后对结果取反。这对于!==严格不等价操作也是一样的。

###5.1 比较:stringnumber

为了展示==强制转换,首先让我们建立本章中早先的stringnumber的例子:

var a = 42;
var b = "42";

a === b; // false
a == b; // true
复制代码

我们所预料的,a === b失败了,因为不允许强制转换,而且值42"42"确实是不同的。

然而,第二个比较a == b使用了宽松等价,这意味着如果类型偶然不同,这个比较算法将会对两个或其中一个值实施 隐含的强制转换。

那么这里发生的究竟是那种强制转换呢?是a的值变成了一个string,还是b的值"42"变成了一个number

在 ES5 语言规范中,条款 11.9.3.4-5 说:

  1. 如果 Type(x)是 Number 而 Type(y)是 String, 返回比较 x == ToNumber(y)的结果。
  2. 如果 Type(x)是 String 而 Type(y)是 Number, 返回比较 ToNumber(x) == y 的结果。

警告: 语言规范中使用NumberString作为类型的正式名称,虽然这本书中偏好使用numberstring指代基本类型。别让语言规范中首字母大写的NumberNumber()原生函数把你给搞糊涂了。对于我们的目的来说,类型名称的首字母大写是无关紧要的 —— 它们基本上是同一个意思。

显然,语言规范说为了比较,将值"42"强制转换为一个number。这个强制转换如何进行已经在前面将结过了,明确地说就是通过ToNumber抽象操作。在这种情况下十分明显,两个值42是相等的。

###5.2 比较:任何东西与boolean

当你试着将一个值直接与truefalse相比较时,你会遇到==宽松等价的 隐含 强制转换中最大的一个坑。

考虑如下代码:

var a = "42";
var b = true;

a == b; // false
复制代码

让我们再次引用语言规范,条款 11.9.3.6-7

  1. 如果 Type(x)是 Boolean, 返回比较 ToNumber(x) == y 的结果。
  2. 如果 Type(y)是 Boolean, 返回比较 x == ToNumber(y) 的结果。

我们来把它分解。首先:

var x = true;
var y = "42";

x == y; // false
复制代码

Type(x)确实是Boolean,所以它会实施ToNumber(x),将true强制转换为1。现在,1 == "42"会被求值。这里面的类型依然不同,所以(实质上是递归地)我们再次向早先讲解过的算法求解,它将"42"强制转换为42,而1 == 42明显是false

反过来,我们任然得到相同的结果:

var x = "42";
var y = false;

x == y; // false
复制代码

这次Type(y)Boolean,所以ToNumber(y)给出0"42" == 0递归地变为42 == 0,这当然是false

换句话说,值"42"既不== true 也不== false。猛地一看,这看起来像句疯话。一个值怎么可能既不是 truthy 也不是 falsy 呢?

"42"的确是 truthy,但是"42" == true根本就 不是在进行一个 boolean 测试/强制转换,不管你的大脑怎么说,"42" 没有 被强制转换为一个booleantrue),而是true被强制转换为一个1,而后"42"被强制转换为42

不管我们喜不喜欢,ToBoolean甚至都没参与到这里,所以"42"的真假是与==操作无关的!

而有关的是要理解==比较算法对所有不同类型组合如何动作。当==的任意一边是一个boolean值时,boolean总是首先被强制转换为一个number

###5.4 比较:nullundefined

另一个 隐含 强制转换的例子可以在nullundefined值之间的==宽松等价中看到。又再一次引述 ES5 语言规范,条款 11.9.3.2-3:

  1. 如果 x 是 null 而 y 是 undefined,返回 true。
  2. 如果 x 是 undefined 而 y 是 null,返回 true。

当使用==宽松等价比较nullundefined,它们是互相等价(也就是互相强制转换)的,而且在整个语言中不会等价于其他值了。

这意味着nullundefined对于比较的目的来说,如果你使用==宽松等价操作符来允许它们互相 隐含地 强制转换的话,它们可以被认为是不可区分的。

var a = null;
var b;

a == b; // true
a == null; // true
b == null; // true

a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false
复制代码

nullundefined之间的强制转换是安全且可预见的,而且在这样的检查中没有其他的值会给出测试成立的误判。我推荐使用这种强制转换来允许nullundefined是不可区分的,如此将它们作为相同的值对待。

比如:

var a = doSomething();

if (a == null) {
  // ..
}
复制代码

a == null检查仅在doSomething()返回null或者undefined时才会通过,而在任何其他值的情况下将会失败,即便是0false,和""这样的 falsy 值。

这个检查的 明确 形式 —— 不允许任何强制转换 —— (我认为)没有必要地难看太多了(而且性能可能有点儿不好!):

var a = doSomething();

if (a === undefined || a === null) {
  // ..
}
复制代码

在我看来,a == null的形式是另一个用 隐含 强制转换增进了代码可读性的例子,而且是以一种可靠安全的方式。

5.5 比较:object与非object

如果一个object/function/array被与一个简单基本标量(stringnumber,或boolean)进行比较,ES5 语言规范在条款 11.9.3.8-9 中这样说道:

  1. 如果 Type(x)是一个 String 或者 Number 而 Type(y)是一个 Object, 返回比较 x == ToPrimitive(y) 的结果。
  2. 如果 Type(x)是一个 Object 而 Type(y)是 String 或者 Number, 返回比较 ToPrimitive(x) == y 的结果。

注意: 你可能注意到了,这些条款仅提到了StringNumber,而没有Boolean。这是因为,正如我们早先引述的,条款 11.9.3.6-7 首先将任何出现的Boolean操作数强制转换为一个Number

考虑如下代码:

var a = 42;
var b = [42];

a == b; // true
复制代码

[ 42 ]ToPrimitive抽象操作(见先前的“抽象值操作”部分)被调用,结果为值"42"。这里它就变为42 == "42",我们已经讲解过这将变为42 == 42,所以ab被认为是强制转换地等价。

提示: 我们在本章早先讨论过的ToPrimitive抽象操作的所以奇怪之处(toString()valueOf()),都在这里如你期望的那样适用。如果你有一个复杂的数据结构,而且你想在它上面定义一个valueOf()方法来为等价比较提供一个简单值的话,这将十分有用。

在第三章中,我们讲解了“拆箱”,就是一个基本类型值的object包装器(例如new String("abc")这样的形式)被展开,其底层的基本类型值("abc")被返回。这种行为与==算法中的ToPrimitive强制转换有关:

var a = "abc";
var b = Object(a); // 与`new String( a )`相同

a === b; // false
a == b; // true
复制代码

a == btrue是因为b通过ToPrimitive强制转换为它的底层简单基本标量值"abc",它与a中的值是相同的。

然而由于==算法中的其他覆盖规则,有些值是例外。考虑如下代码:

var a = null;
var b = Object(a); // 与`Object()`相同
a == b; // false

var c = undefined;
var d = Object(c); // 与`Object()`相同
c == d; // false

var e = NaN;
var f = Object(e); // 与`new Number( e )`相同
e == f; // false
复制代码

nullundefined不能被装箱 —— 它们没有等价的对象包装器 —— 所以Object(null)就像Object()一样,它们都仅仅产生一个普通对象。

NaN可以被封箱到它等价的Number对象包装器中,当==导致拆箱时,比较NaN == NaN会失败,因为NaN永远不会它自己相等。

5.6 边界情况

现在我们已经彻底检视了==宽松等价的 隐含 强制转换是如何工作的(从合理与惊讶两个方式),让我们召唤角落中最差劲儿的,最疯狂的情况,这样我们就能看到我们需要避免什么来防止被强制转换的 bug 咬到。

首先,让我们检视修改内建的原生 prototype 是如何产生疯狂的结果的:

一个拥有其他值的数字将会……
Number.prototype.valueOf = function() {
  return 3;
};

new Number(2) == 3; // true
复制代码

警告: 2 == 3不会掉到这个陷阱中,这是由于23都不会调用内建的Number.prototype.valueOf()方法,因为它们已经是基本number值,可以直接比较。然而,new Number(2)必须通过ToPrimitive强制转换,因此调用valueOf()

False-y 比较

关于==比较中 隐含 强制转换的最常见的抱怨,来自于 falsy 值互相比较时它们如何令人吃惊地动作。

为了展示,让我们看一个关于 falsy 值比较的极端例子的列表,来瞧瞧哪一个是合理的,哪一个是麻烦的:

"0" == null; // false
"0" == undefined; // false
"0" == false; // true -- 噢!
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false

false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true -- 噢!
false == ""; // true -- 噢!
false == []; // true -- 噢!
false == {}; // false

"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true -- 噢!
"" == []; // true -- 噢!
"" == {}; // false

0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true -- 噢!
0 == {}; // false
复制代码

在这 24 个比较的类表中,17 个是十分合理和可预见的。比如,我们知道"""NaN"是根本不可能相等的值,并且它们确实不会强制转换以成为宽松等价的,而"0"0是合理等价的,而且确实强制转换为宽松等价。

6. 抽象关系比较

虽然这部分的 隐含 强制转换经常不为人所注意,但无论如何考虑比较a < b时发生了什么是很重要的(和我们如何深入检视a == b类似)。

在 ES5 语言规范的 11.8.5 部分的“抽象关系型比较”算法,实质上把自己分成了两个部分:如果比较涉及两个string值要做什么(后半部分),和除此之外的其他值要做什么(前半部分)。

注意: 这个算法仅仅定义了a < b。所以,a > b作为b < a处理。

这个算法首先在两个值上调用ToPrimitive强制转换,如果两个调用的返回值之一不是string,那么就使用ToNumber操作规则将这两个值强制转换为number值,并进行数字的比较。

举例来说:

var a = [42];
var b = ["43"];

a < b; // true
b < a; // false
复制代码

注意: 早先讨论的关于-0NaN==算法中的类似注意事项也适用于这里。

然而,如果<比较的两个值都是string的话,就会在字符上进行简单的字典顺序(自然的字母顺序)比较:

var a = ["42"];
var b = ["043"];

a < b; // false
复制代码

ab 不会 被强制转换为number,因为它们会在两个arrayToPrimitive强制转换后成为string。所以,"42"将会与"043"一个字符一个字符地进行比较,从第一个字符开始,分别是"4""0"。因为"0"在字典顺序上 小于 "4",所以这个比较返回false

完全相同的行为和推理也适用于:

var a = [4, 2];
var b = [0, 4, 3];

a < b; // false
复制代码

这里,a变成了"4,2"b变成了"0,4,3",而字典顺序比较和前一个代码段一模一样。

那么这个怎么样:

var a = { b: 42 };
var b = { b: 43 };

a < b; // ??
复制代码

a < b也是false,因为a变成了[object Object]b变成了[object Object],所以明显地a在字典顺序上不小于b

但奇怪的是:

var a = { b: 42 };
var b = { b: 43 };

a < b; // false
a == b; // false
a > b; // false

a <= b; // true
a >= b; // true
复制代码

为什么a == b不是true?它们是相同的string值("[object Object]"),所以看起来它们应当相等,对吧?不。回忆一下前面关于==如何与object引用进行工作的讨论。

那么为什么a <= ba >= b的结果为true,如果a < ba == ba > b都是false

因为语言规范说,对于a <= b,它实际上首先对b < a求值,然后反转那个结果。因为b < a也是false,所以a <= b的结果为true

到目前为止你解释<=在做什么的方式可能是:“小于 等于”。而这可能完全相反,JS 更准确地将<=考虑为“不大于”(!(a > b),JS 将它作为(!b < a))。另外,a >= b被解释为它首先被考虑为b <= a,然后实施相同的推理。

不幸的是,没有像等价那样的“严格的关系型比较”。换句话说,没有办法防止a < b这样的关系型比较发生 隐含的 强制转换,除非在进行比较之前就明确地确保ab是同种类型。

使用与我们早先=====合理性检查的讨论相同的推理方法。如果强制转换有帮助并且合理安全,比如比较42 < "43"就使用它。另一方面,如果你需要在关系型比较上获得安全性,那么在使用<(或>)之前,就首先 明确地强制转换 这些值。

var a = [42];
var b = "043";

a < b; // false -- 字符串比较!
Number(a) < Number(b); // true -- 数字比较!
复制代码

复习

在这一章中,我们将注意力转向了 JavaScript 类型转换如何发生,也叫 强制转换,按性质来说它要么是 明确的 要么是 隐含的

强制转换的名声很坏,但它实际上在许多情况下很有帮助。对于负责任的 JS 开发者来说,一个重要的任务就是花时间去学习强制转换的里里外外,来决定哪一部分将帮助他们改进代码,哪一部分他们真的应该回避。

明确的 强制转换时这样一种代码,它很明显地有意将一个值从一种类型转换到另一种类型。它的益处是通过减少困惑来增强了代码的可读性和可维护性。

隐含的 强制转换是作为一些其他操作的“隐藏的”副作用而存在的,将要发生的类型转换并不明显。虽然看起来 隐含的 强制转换是 明确的 反面,而且因此是不好的(确实,很多人这么认为!),但是实际上 隐含的 强制转换也是为了增强代码的可读性。

特别是对于 隐含的,强制转换必须被负责地,有意识地使用。懂得为什么你在写你正在写的代码,和它是如何工作的。同时也要努力编写其他人容易学习和理解的代码。

转载于:https://juejin.im/post/5aeac62c518825670a1022ef

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值