第四章:强制转换
1. 转换值
将一个值从一个类型明确地转换到另一个类型通常称为“类型转换(type casting)”,当这个操作隐含地完成时称为“强制转换(coercion)”(根据一个值如何被使用的规则来强制它变换类型)。
注意: 这可能不明显,但是 JavaScript 强制转换总是得到基本标量值的一种,比如 string
、number
、或 boolean
。没有强制转换可以得到像 object
和 function
这样的复杂值。第三章讲解了“封箱”,它将一个基本类型标量值包装在它们相应的 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 不 安全的值可能更容易一些。一些例子是:undefined
、function
、(ES6+)symbol
、和带有循环引用的 object
(一个对象结构中的属性互相引用而造成了一个永不终结的循环)。对于标准的 JSON 结构来说这些都是非法的值,主要是因为它们不能移植到消费 JSON 值的其他语言中。
JSON.stringify(..)
工具在遇到 undefined
、function
、和 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
,那么它应当是一个 string
的 array
,它的每一个元素指定了允许被包含在这个 object
的序列化形式中的属性名称。如果一个属性不存在于这个列表中,那么它就会被跳过。
如果 替换器 是一个 function
,那么它会为 object
本身而被调用一次,并且为这个 object
中的每个属性都被调用一次,而且每次都被传入两个参数值,key 和 value。要在序列化中跳过一个 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 参数 k
是 undefined
(而对象 a
本身会被传入)。if
语句会 过滤掉 名称为 c
的属性。字符串化是递归的,所以数组 [1,2,3]
会将它的每一个值(1
、2
、和 3
)都作为 v
传递给 替换器,并将索引值(0
、1
、和 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
强制转换有关联的行为:
string
、number
、boolean
、和null
值在 JSON 字符串化时,与它们通过ToString
抽象操作的规则强制转换为string
值的方式基本上是相同的。- 如果传递一个
object
值给JSON.stringify(..)
,而这个object
上拥有一个toJSON()
方法,那么在字符串化之前,toJSON()
就会被自动调用来将这个值(某种意义上)“强制转换”为 JSON 安全 的。
2.2 ToNumber
如果任何非 number
值,以一种要求它是 number
的方式被使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的 ToNumber
抽象操作。
例如,true
变为 1
而 false
变为 0
。undefined
变为 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 实际上拥有 true
和 false
关键字,而且它们的行为正如你所期望的 boolean
值一样。一个常见的误解是,值 1
和 0
与 true
/false
是相同的。虽然这可能在其他语言中是成立的,但在 JS 中 number
就是 number
,而 boolean
就是 boolean
。你可以将 1
强制转换为 true
(或反之),或将 0
强制转换为 false
(或反之)。但它们不是相同的。
2.3.1 Falsy 值
所有的 JavaScript 值都可以被划分进两个类别:
- 如果被强制转换为
boolean
,将成为false
的值 - 其它的一切值(很明显将变为
true
)
JS 语言规范给那些在强制转换为 boolean
值时将会变为 false
的值定义了一个明确的,小范围的列表。
在 ES5 语言规范中,9.2 部分定义了一个 ToBoolean
抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。
从这个表格中,我们得到了下面所谓的“falsy”值列表:
undefined
null
false
+0
,-0
, andNaN
""
就是这些。如果一个值在这个列表中,它就是一个“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 && c
时 d
是什么。
那么,如果“falsy 对象” 不是包装着 falsy 值的对象,它们是什么鬼东西?
刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JavaScript 本身的一部分。
什么!?
有些特定的情况,在普通的 JS 语义之上,浏览器已经创建了它们自己的某种 外来 值的行为,也就是这种“falsy 对象”的想法。
一个“falsy 对象”看起来和动起来都像一个普通对象(属性,等等)的值,但是当你强制转换它为一个 boolean
时,它会变为一个 false
值。
为什么!?
最著名的例子是 document.all
:一个 由 DOM(不是 JS 引擎本身) 给你的 JS 程序提供的类数组(对象),它向你的 JS 程序暴露你页面上的元素。它 曾经 像一个普通对象那样动作 —— 是一个 truthy。但不再是了。
document.all
本身从来就不是“标准的”,而且从很早以前就被废弃/抛弃了。
“那他们就不能删掉它吗?” 对不起,想得不错。但愿它们能。但是世面上有太多的遗产 JS 代码库依赖于它。
那么,为什么使它像 falsy 一样动作?因为从 document.all
到 boolean
的强制转换(比如在 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
为了在string
和number
之间进行强制转换,我们使用内建的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(..)
,还有其他的方法可以把这些值在string
和number
之间进行“明确地”转换:
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
像混淆了,而后者完全是不同的东西!
从Date
到number
另一个一元+
操作符的常见用法是将一个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
(以0x
或0X
开头)没那么容易搞混。但是事实证明 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
(很明显,0
或1
)的强制转换可以提供巨大帮助的地方:
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]
来将这个值强制转换为true
或false
。这样你就可以像onlyOne( "42", 0 )
这样传入非boolean
值了,而且它依然可以如意料的那样工作(要不然,你将会得到string
连接,而且逻辑也不正确)。
一旦我们确认它是一个boolean
,我们就使用Number(..)
进行另一个 明确的 强制转换来确保值是0
或1
。
这个工具的 明确 强制转换形式“更好”吗?它确实像代码注释中解释的那样避开了NaN
的陷阱。但是,这最终要看你的需要。我个人认为前一个版本,依赖于 隐含的 强制转换更优雅(如果你不传入undefined
或NaN
),而 明确的 版本是一种不必要的繁冗。
但与我们在这里讨论的几乎所有东西一样,这是一个主观判断。
注意: 不管是 隐含的 还是 明确的 方式,你可以通过将最后的比较从1
改为2
或5
,来分别很容易地制造onlyTwo(..)
或onlyFive(..)
。这要比添加一大堆&&
和||
表达式要简单太多了。所以,一般来说,在这种情况下强制转换非常有用。
4.3 隐含地:* --> Boolean
现在,让我们将注意力转向目标为boolean
值的 隐含 强制转换上,这是目前最常见,并且还是目前潜在的最麻烦的一种。
记住,隐含的 强制转换是当你以强制一个值被转换的方式使用这个值时才启动的。对于数字和string
操作,很容易就能看出这种强制转换是如何发生的。
但是,哪个种类的表达式操作(隐含地)要求/强制一个boolean
转换呢?
- 在一个
if (..)
语句中的测试表达式。 - 在一个
for ( .. ; .. ; .. )
头部的测试表达式(第二个子句)。 - 在
while (..)
和do..while(..)
循环中的测试表达式。 - 在
? :
三元表达式中的测试表达式(第一个子句)。 ||
(“逻辑或”)和&&
(“逻辑与”)操作符左手边的操作数(它用作测试表达式 —— 见下面的讨论!)。
在这些上下文环境中使用的,任何还不是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
复制代码
||
和&&
操作符都在 第一个操作数(a
或c
) 上进行boolean
测试。如果这个操作数还不是boolean
(就像在这里一样),就会发生一次普通的ToBoolean
强制转换,这样测试就可以进行了。
对于||
操作符,如果测试结果为true
,||
表达式就将 第一个操作数 的值(a
或c
)作为结果。如果测试结果为false
,||
表达式就将 第二个操作数 的值(b
)作为结果。
相反地,对于&&
操作符,如果测试结果为true
,&&
表达式将 第二个操作数 的值(b
)作为结果。如果测试结果为false
,那么&&
表达式就将 第一个操作数 的值(a
或c
)作为结果。
||
或&&
表达式的结果总是两个操作数之一的底层值,不是(可能是被强制转换来的)测试的结果。在c && b
中,c
是null
,因此是 falsy。但是&&
表达式本身的结果为null
(c
中的值),不是用于测试的强制转换来的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 && b
和a ? 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 比较:string
与number
为了展示==
强制转换,首先让我们建立本章中早先的string
和number
的例子:
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 说:
- 如果 Type(x)是 Number 而 Type(y)是 String, 返回比较 x == ToNumber(y)的结果。
- 如果 Type(x)是 String 而 Type(y)是 Number, 返回比较 ToNumber(x) == y 的结果。
警告: 语言规范中使用Number
和String
作为类型的正式名称,虽然这本书中偏好使用number
和string
指代基本类型。别让语言规范中首字母大写的Number
与Number()
原生函数把你给搞糊涂了。对于我们的目的来说,类型名称的首字母大写是无关紧要的 —— 它们基本上是同一个意思。
显然,语言规范说为了比较,将值"42"
强制转换为一个number
。这个强制转换如何进行已经在前面将结过了,明确地说就是通过ToNumber
抽象操作。在这种情况下十分明显,两个值42
是相等的。
###5.2 比较:任何东西与boolean
当你试着将一个值直接与true
或false
相比较时,你会遇到==
宽松等价的 隐含 强制转换中最大的一个坑。
考虑如下代码:
var a = "42";
var b = true;
a == b; // false
复制代码
让我们再次引用语言规范,条款 11.9.3.6-7
- 如果 Type(x)是 Boolean, 返回比较 ToNumber(x) == y 的结果。
- 如果 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"
没有 被强制转换为一个boolean
(true
),而是true
被强制转换为一个1
,而后"42"
被强制转换为42
。
不管我们喜不喜欢,ToBoolean
甚至都没参与到这里,所以"42"
的真假是与==
操作无关的!
而有关的是要理解==
比较算法对所有不同类型组合如何动作。当==
的任意一边是一个boolean
值时,boolean
总是首先被强制转换为一个number
。
###5.4 比较:null
与undefined
另一个 隐含 强制转换的例子可以在null
和undefined
值之间的==
宽松等价中看到。又再一次引述 ES5 语言规范,条款 11.9.3.2-3:
- 如果 x 是 null 而 y 是 undefined,返回 true。
- 如果 x 是 undefined 而 y 是 null,返回 true。
当使用==
宽松等价比较null
和undefined
,它们是互相等价(也就是互相强制转换)的,而且在整个语言中不会等价于其他值了。
这意味着null
和undefined
对于比较的目的来说,如果你使用==
宽松等价操作符来允许它们互相 隐含地 强制转换的话,它们可以被认为是不可区分的。
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
复制代码
null
和undefined
之间的强制转换是安全且可预见的,而且在这样的检查中没有其他的值会给出测试成立的误判。我推荐使用这种强制转换来允许null
和undefined
是不可区分的,如此将它们作为相同的值对待。
比如:
var a = doSomething();
if (a == null) {
// ..
}
复制代码
a == null
检查仅在doSomething()
返回null
或者undefined
时才会通过,而在任何其他值的情况下将会失败,即便是0
,false
,和""
这样的 falsy 值。
这个检查的 明确 形式 —— 不允许任何强制转换 —— (我认为)没有必要地难看太多了(而且性能可能有点儿不好!):
var a = doSomething();
if (a === undefined || a === null) {
// ..
}
复制代码
在我看来,a == null
的形式是另一个用 隐含 强制转换增进了代码可读性的例子,而且是以一种可靠安全的方式。
5.5 比较:object
与非object
如果一个object
/function
/array
被与一个简单基本标量(string
,number
,或boolean
)进行比较,ES5 语言规范在条款 11.9.3.8-9 中这样说道:
- 如果 Type(x)是一个 String 或者 Number 而 Type(y)是一个 Object, 返回比较 x == ToPrimitive(y) 的结果。
- 如果 Type(x)是一个 Object 而 Type(y)是 String 或者 Number, 返回比较 ToPrimitive(x) == y 的结果。
注意: 你可能注意到了,这些条款仅提到了String
和Number
,而没有Boolean
。这是因为,正如我们早先引述的,条款 11.9.3.6-7 首先将任何出现的Boolean
操作数强制转换为一个Number
。
考虑如下代码:
var a = 42;
var b = [42];
a == b; // true
复制代码
值[ 42 ]
的ToPrimitive
抽象操作(见先前的“抽象值操作”部分)被调用,结果为值"42"
。这里它就变为42 == "42"
,我们已经讲解过这将变为42 == 42
,所以a
和b
被认为是强制转换地等价。
提示: 我们在本章早先讨论过的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 == b
为true
是因为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
复制代码
值null
和undefined
不能被装箱 —— 它们没有等价的对象包装器 —— 所以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
不会掉到这个陷阱中,这是由于2
和3
都不会调用内建的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
复制代码
注意: 早先讨论的关于-0
和NaN
在==
算法中的类似注意事项也适用于这里。
然而,如果<
比较的两个值都是string
的话,就会在字符上进行简单的字典顺序(自然的字母顺序)比较:
var a = ["42"];
var b = ["043"];
a < b; // false
复制代码
a
和b
不会 被强制转换为number
,因为它们会在两个array
的ToPrimitive
强制转换后成为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 <= b
和a >= b
的结果为true
,如果a < b
和a == b
和a > b
都是false
?
因为语言规范说,对于a <= b
,它实际上首先对b < a
求值,然后反转那个结果。因为b < a
也是false
,所以a <= b
的结果为true
。
到目前为止你解释<=
在做什么的方式可能是:“小于 或 等于”。而这可能完全相反,JS 更准确地将<=
考虑为“不大于”(!(a > b)
,JS 将它作为(!b < a)
)。另外,a >= b
被解释为它首先被考虑为b <= a
,然后实施相同的推理。
不幸的是,没有像等价那样的“严格的关系型比较”。换句话说,没有办法防止a < b
这样的关系型比较发生 隐含的 强制转换,除非在进行比较之前就明确地确保a
和b
是同种类型。
使用与我们早先==
与===
合理性检查的讨论相同的推理方法。如果强制转换有帮助并且合理安全,比如比较42 < "43"
,就使用它。另一方面,如果你需要在关系型比较上获得安全性,那么在使用<
(或>
)之前,就首先 明确地强制转换 这些值。
var a = [42];
var b = "043";
a < b; // false -- 字符串比较!
Number(a) < Number(b); // true -- 数字比较!
复制代码
复习
在这一章中,我们将注意力转向了 JavaScript 类型转换如何发生,也叫 强制转换,按性质来说它要么是 明确的 要么是 隐含的。
强制转换的名声很坏,但它实际上在许多情况下很有帮助。对于负责任的 JS 开发者来说,一个重要的任务就是花时间去学习强制转换的里里外外,来决定哪一部分将帮助他们改进代码,哪一部分他们真的应该回避。
明确的 强制转换时这样一种代码,它很明显地有意将一个值从一种类型转换到另一种类型。它的益处是通过减少困惑来增强了代码的可读性和可维护性。
隐含的 强制转换是作为一些其他操作的“隐藏的”副作用而存在的,将要发生的类型转换并不明显。虽然看起来 隐含的 强制转换是 明确的 反面,而且因此是不好的(确实,很多人这么认为!),但是实际上 隐含的 强制转换也是为了增强代码的可读性。
特别是对于 隐含的,强制转换必须被负责地,有意识地使用。懂得为什么你在写你正在写的代码,和它是如何工作的。同时也要努力编写其他人容易学习和理解的代码。