💤
强制转换
现在我们更全面地了解了 JS 的类型和值,我们将注意力转向一个极具争议的话题:强制转换。
正如我们在第一章中提到的,关于强制转换到底是一个有用的特性,还是一个语言设计上的缺陷(或介于两者之间!),早就开始争论不休了。正如你读过关于 JS 的其他书籍,你就会知道流行在市面上那种淹没一切的声音:强制转换是魔法,是邪恶的,令人困惑的,而且就是彻头彻尾的坏主意。
本着这个系列丛书的总体精神,我认为你应当直面你不理解的东西并设法更全面地搞懂它。而不是因为大家都这样做,或是你曾经被一些怪东西咬到就逃避强制转换。
我们的目标是全面地探索强制转换的优点和缺点(是的,它们有优点!),这样你就能在程序中对它是否合适做出明智的决定。
转换值
将一个值从一个类型明确地转换到另一个类型称为“类型转换(type casting)”,当这个操作隐含地完成时称为“强制转换(coercion)”(根据一个值如何被使用的规则来强制它变换类型)。
NOTE
这可能不明显,但是 JS 强制转换总是得到基本标量值的一种,比如string
、number
或boolean
。没有强制转换可以得到像object
和function
这样的复杂值。第三章讲解了“封箱”,它将一个基本类型标量值包装在它们相应的object
中,但在准确的意义上这不是真正的强制转换。
另一种区别这些术语的常见方法是:“类型转换(type casting/conversion)”发生在静态类型语言的编译时,而“类型强制转换(type coercion)”是动态类型语言的运行时转换。
然而,在 JS 中,大多数人都将所有这些类型的转换都称为强制转换(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"
。但它们如何达到这种效果,才是关于 JS 强制转换的激烈争论的核心。
NOTE
技术上讲,除了文体上的差异,还有一些细微的行为差异。我们将在本章稍后,“隐式地:Strings <--> Numbers”一节中自己讲解。
“明确地”、“隐式地”或“明显地”和“隐藏的副作用”这些术语,是相对的。
如果你确切的知道a + ''
是在做什么,并且你有意地这么做来强制转换一个string
,你可能感觉这个操作已经足够“明确”了。相反,如果你从没见过String(..)
函数被用于string
强制转换,那么它的行为可能看起来太过隐蔽而让你感到“隐含”。
但我们是基于一个大众的,充分了解,但不是专家或 JS 规范爱好者的开发者的观点来讨论“明确”与“隐式”的。无论你的程度如何,或是没有在这个范畴内准确地找到自己,你都需要根据我们在这里的观察方式,相应地调整你的角度。
TIP
我们自己写代码而且也只有我们自己会读它,通常是少见的。即便你是一个精通 JS 里里外外的专家,也要考虑一个经验没那么丰富的队友在读你的代码时的感受如何。对于他们和对于你来说,“明确”或“隐含”的意义相同吗?
抽象值操作
在我们可以探究明确与隐式强制转换之前,我们需要学习一些基本规则,是它们控制着值如何变成一个string
、number
或boolean
的。ES5 语言规范的第九部分用值的变形规则定义了几种“抽象操作”(“仅供内部使用的操作”的高大上说法)。我们将特别关注于:ToString
、ToNumber
、和ToBoolean
,并稍稍关注一下ToPrimitive
。
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
// 3位乘以7次 = 21位
a.toString() // "1.07e21" Chrome 127 显示: "1.07e+21"
对于普通的对象,除非你指定你自己的,默认的toString()
(可以在Object.prototype.toString()
找到)将返回内部[[Class]]
(见第三章),例如"[object Object]"
。
但正如早先所展示的,如果一个对象上拥有它自己的toString()
方法,而你又以一种类似string
的方式使用这个对象,那么它的toString()
将会被自动调用,而且这个调用的string
结果将被使用。
NOTE
技术上讲,一个对象被强制转换为一个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]}"
NOTE
在function
替换器的情况下,第一次调用 key 参数k
是undefined
(而对象a
本身会被传入)。if
语句会过滤掉名称为c
的属性。字符串化是递归的,所以数组[1, 2, 3]
会将它的每一个值(1
、2
和3
)都作为v
传递给替换器
,并将索引值(0
、1
和2
)作为k
。
WARNING
或许曾经是。但在 Chrome127 中,函数首次确实会将整个对象传入函数,但是 key 并不是 undefined,而是一个空字符串。
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 安全的。
ToNumber
如果任何非number
值,以一种要求它是number
的方式使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的ToNumber
抽象操作。
例如,true
变为1
而false
变为0
。undefined
变为NaN
,而(奇怪的是)null
变为0
。
对于一个string
值来说,ToNumber
工作起来很大程度上与数字字面量的规则/语法很相似(见第三章)。如果它失败了,结果将是NaN
(而不是number
字面量中会出现的语法错误)。一个不同之处的例子是,在这个操作中0
前缀的八进制数不会被作为八进制数来处理(而仅作为普通的十进制小数),虽然这样的八进制数作为number
字面量是合法的。
NOTE
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 与对象原型。
NOTE
我们会在本章稍后讲解如何强制转换至number
,但对于下面的代码段,想象Number(..)
函数就是那样做的。
考虑如下代码:
var a = {
valueOf() {
return '42'
},
}
var b = {
toString() {
return '42'
},
}
var c = [4, 2]
c.tosString = function () {
return this.join('') // "42"
}
Number(a) // 42
Number(b) // 42
Number(c) // 42
Number('') // 0 —— Number("") -> 0
Number([]) // 0 —— toString() -> "" -> Number("") > 0
Number(['abc']) // NaN —— toString() -> "abc" -> Number("abc") > NaN
ToBoolean
下面,让我们聊一聊在 JS 中boolean
的行为。市面上关于这个话题有许多的困惑和误解,所以集中注意力!
首先最重要的是,JS 实际上拥有true
和false
关键字,而且它们的行为正如你所期望的boolean
值一样。一个常见的误解是,值1
和0
与true
和false
是相同的。虽然这可能在其他语言中的是成立的,但在 JS 中number
就是number
,而boolean
就是boolean
。你可以将1
强制转换为true
(或反之),或将0
强制转换为false
(或反之)。但它们不是相同的。
Falsy 值
但这还不是故事的结尾。我们需要讨论一下,除了这两个boolean
值以外,当你把其他值强制转换为它们的boolean
等价物时的行为。
所有的 JS 值都可以被划分进两个类别:
- 如果被强制转换为
boolean
,将成为false
的值 - 其他的一切值(很明显将变为
true
)
我不是在出洋相。JS 语言规范给那些在强制转换为boolean
值时将会变为false
的值定义了一个明确的,小范围的列表。
我们如何才能知道这个列表中的值是什么?在 ES5 语言规范中,9.2 部分定义了一个ToBoolean
抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。
从这个表格中,我们得到了下面所谓的“falsy”值列表:
undefined
null
false
+0
、-0
和NaN
""
(空字符串)
就是这些。如果一个值在这个列表中,它就是一个“falsy”值,而且当你在它上面进行boolean
强制转换时它会转换为false
。
通过逻辑上的推论,如果一个值不在这个列表中,那么它一定在另一个列表中,也就是我们称为“truthy”值的列表。但是 JS 没有真正定义一个“truthy”列表。它给出了一些例子,比如它说所有的对象都是 truthy,但是语言规范大致上暗示着:任何没有明确地存在于 falsy 列表中的东西,都是 truthy。
Falsy 对象
等一下,这一节的标题听起来简直是矛盾的。我刚刚才说过语言规范将所有对象称为 truthy,对吧?应该没有“falsy 对象”这样的东西。
这会是什么意思呢?
它可能诱使你认为它意味着一个包装了 falsy 值(比如""
、0
或false
)的对象包装器(见第三章)。但别掉到这个陷阱中。
NOTE
这个可能是一个语言规范的微妙笑话。
考虑下面的代码:
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
的方法。
TIP
注意包在a && b && c
表达式外面的Boolean(..)
—— 你可能想知道为什么它在这儿。我们会在本章稍后回到这个话题,所以先做个心理准备。为了先睹为快,你可以自己试试如果没有Boolean(..)
调用而只有d = a && b && c
时d
是什么。 —— (已睹已快,d
是一个 Boolean{ false }
对象。
那么,如果“falsy 对象”不是包装着 falsy 值的对象,它们是什么鬼东西?
刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JS 本身的一部分。
什么?!
有些特定的情况,在普通的 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){ /* IE-only code */ }
代码依然留在市面上,而且大多数可能永远不会消失。所有这些遗留代码依然假设它们运行在那些给 IE 用户带来差劲儿的浏览体验的,几十年前的老 IE 上。
所以,我们不能完全移除document.all
,但是 IE 不再想让if(document.all){ .. }
代码继续工作了,这样现代 IE 的用户就能得到新的,符合标准的代码逻辑。
“我们应当怎么做?”“我知道了!让我们黑进 JS 的类型系统并假装document.all
是 falsy!”
呃。这很烂。这是一个大多数 JS 开发者们都不理解的疯狂的坑。但是其它的替代方案(对上面两败俱伤的问题什么都不做!)还要烂得多那么一点点。
所以...这就是我们得到的:由浏览器给 JS 添加的疯狂、非标准的“falsy 对象”。耶!
Truthy 值
回到 truthy 列表。到底什么是 truthy 值?记住:如果一个值不在 falsy 列表中,它就是 truthy。
考虑下面代码:
var a = 'false'
var b = '0'
var c = '""'
var d = Boolean(a && b && c)
d // true
你期望这里的d
是什么值?它要么是true
要么是false
。
它是true
。为什么?因为尽管这些string
值的内容看起来是 falsy 值,但是string
值本身都是 truthy,而这是因为 falsy 列表中""
是唯一的string
值。
那么这些呢?
var a = {}
var b = []
var c = function () {}
var d = Boolean(a && b && c)
d // true
是的,你猜对了,这里的d
依然是true
。为什么?和前面的原因一样。尽管它们看起来像,但是[]
,{}
和function(){}
不在 falsy 列表中,因此它们是 truthy 值。
换句话说,truthy 列表是无限长的。不可能制成一个这样的列表。你只能制造一个 falsy 列表并查询它。
花五分钟,把 falsy 列表写在便利贴上,然后粘在你的电脑显示器上,或者如果你愿意就记住它。不管哪种方法,你都可以在自己需要的时候通过简单地查询一个值是否在 falsy 列表中,来构建一个虚拟的 truthy 列表。
truthy 和 falsy 的重要性在于,理解如果一个值在被(明确地或隐式地)强制转换为boolean
值的话,它将如何表现。现在你的大脑中有了这两个列表,我们可以深入强制转换的例子本身了。
明确的强制转换
明确的强制转换指的是明显且明确的类型转换。对于大多数开发者来说,有很多类型转换的用法可以清楚地归类于这种明确的强制转换。
我们在这里的目标是,在我们的代码中指明一些模式,在这些模式中我们可以清楚明白地将一个值从一种类型转换至另一种类型,以确保不给未来将读到这段代码的开发者留下任何坑。我们越明确,后来的人就越容易读懂我们的代码,也不必费太多的力气去理解我们的意图。
关于明确的强制转换可能很难找到什么主要的不同意见,因为它与被广泛接受的静态类型语言中的类型转换的工作方式非常接近。因此,我们理所当然地认为(暂且)明确的强制转换可以被认同为不是邪恶的,或没有争议的。虽然我们稍后会回到这个话题。
明确的: Strings <--> Numbers
我们将从最简单,也许是最常见类型转换操作开始:将值在string
和number
表现形式之间进行强制转换。
为了在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
值。
我称之为明确的强制转换是因为,一般对于大多数开发者来说这是十分明显的:这些操作的最终结果是适当的类型转换。
实际上,这种用法看起来与其他的静态类型语言中的用法非常相像。
举个例子,在 C/C++中,你既可以说(int)x
也可以说int(x)
,而且它们都将x
中的值转换为一个整数。两种形式都是合法的,但是许多人偏向于后者,它看起来有点儿像一个函数调用。在 JS 中,当你说Number(x)
时,它看起来极其相似。在 JS 中它实际上是一个函数调用这个事实重要吗?并非如此。
除了String(..)
和Number(..)
,还有其他的方法可以把这些值在string
和number
之间进行“明确地”转换:
var a = 42
var b = a.toString()
var c = '3.14'
var d = +c
b // "42"
d // 3.14
调用a.toString()
在表面上是明确的("toString"意味着“变成一个字符串”是很明白的),但是这里实际上隐藏了一些隐含性。toString()
不能在像42
这样的基本类型值上调用。所以 JS 会自动地将42
“封箱”在一个对象包装器中(见第三章),这样toString()
就可以针对这个对象调用。换句话讲,你可能会叫它“明确的隐含”。
这里的+c
是+
操作符的一元操作符(操作符只有一个操作数)形式。取代进行数学加法(或字符串连接 —— 见下面的讨论)的是,一元的+
明确地将它的操作数(c
)强制转换为一个number
值。
+c
是明确的强制转换吗?这要看你的经验和角度。如果你知道(现在你知道了!)一元+
明确地意味着number
强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。
NOTE
在开源的 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
混淆了,而后者完全是不同的东西!
NOTE
一元+
的另一个极端使人困惑的地方是,被用于紧挨着另一个将要作为++
递增操作符和--
递减操作符的操作数。例如:a +++b
,a + ++b
,和a + + +b
。更多关于++
的信息,惨见第五章的“表达式副作用”。
记住,我们正努力变得明确并减少困惑,不是把事情弄得更糟!
从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()
NOTE
一些开发者知道一个 JS 中的特别的语法“技巧”,就是在构造器调用(一个带有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
这些特殊的数字是不可用 32 位表现形式的(因为它们源自于 64 位的 IEEE 754 标准 —— 见第二章),所以ToInt32
将这些值的结果指定为0
。
有争议的是,0 | __
是否是一种ToInt32
强制转换操作的明确的形式,还是更倾向于隐式。从语言规范的角度来说,毫无疑问是明确的,但是如果你没有在这样的层次上理解位操作,它就可能看起来有点像隐含的魔法。不管怎样,为了与本章中其他的断言保持一致,我们称它为明确的。
那么,让我们把注意力转回~
,~
操作符首先将值“强制转换”为一个 32 位number
值,然后实施按位取反(反转每一个比特位)。
NOTE
这与!
不仅强制转换它的值为boolean
而且还翻转它的每一位很相似(见后面关于“一元!
”的讨论)。
但是...为什么!?为什么我们要关心被翻转的比特位?这是一些相当特殊的,微妙的东西。JS 开发者需要推理个别比特位是十分少见的。
另一种考虑~
定义的方法是,~
源自学校中的计算机科学/离散数学:~
进行二进制取补操作。太好了,谢谢,我完全明白了!
我们再试一次:~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
表示“失败”。
JS 在定义string
操作indexOf(..)
时采纳了这种先例,它搜索一个子字符串,如果找到就返回它从 0 开始计算的索引位置,没有找到的话就返回-1
。
这样的情况很常见:不仅仅将indexOf(..)
作为取得位置的操作,而且作为检查一个子字符串存在/不存在于另一个string
中的boolean
值。这就是开发者们通常如果进行这样的检查:
var a = 'Hello World'
if (a.indexOf('lo') >= 0) {
// 'lo' found
}
if (a.indexOf('lo' !== -1)) {
// 'lo' found
}
if (a.indexOf('ol') < 0) {
// 'ol' not found
}
if (a.indexOf('ol') === -1) {
// 'ol' not found
}
我感觉看着>= 0
或=== -1
有些恶心。它基本上是一种“抽象泄露”,这里它将底层的实现行为 —— 使用哨兵值-1
表示“失败” —— 泄漏到我的代码中。我倒是乐意隐藏这样的细节。
现在,我们终于看到为什么~
可以帮到我们了!将~
和indexOf()
一起使用可以将值“强制转换”(实际上只是变形)为可以适当地强制转换为boolean
的值:
var a = 'Hello World'
~a.indexOf('lo') // -4 -> truthy!
if (~a.indexOf('lo')) {
// 'lo' found
}
~a.indexOf('ol') // 0 -> falsy!
!~a.indexOf('ol') // true
if(!~a.indexOf('ol'){
// 'ol' not found
})
~
拿到indexOf(..)
的返回值并将它变形:对于“失败”的-1
我们得到 falsy 的0
,而其他的值都是 truthy。
NOTE
~
的假想算法-(x+1)
暗示着~-1
是-0
,但是实际上它产生0
,因为底层的操作其实是按位的,不是数学操作。
技术上讲,if (~a.indexOf(..))
依然依靠隐式的强制转换将它的结果0
变为false
或非零变为true
。但总的来说,对我而言~
更像一种明确的强制转换机制,只要你知道在这种惯用法中它的意图是什么。
我感觉这样的代码要比前面凌乱的>= 0
或=== -1
更干净。
截断比特位
在你遇到的代码中,还有一个地方可能出现~
:一些开发者使用双波浪线~~
来截断一个number
的小数部分(也就是,将它“强制转换”为一个“整数”)。这通常(虽然是错的)被说成与调用Math.floor(..)
的结果相同。
~ ~
的工作方式是,第一个~
实施ToInt32
“强制转换”进行按位取反,然后第二个~
进行另一次按位取反,将每一个比特位都翻转回原来的状态。于是最终的结果就是ToInt32
“强制转换”(也叫截断)。
NOTE
~~
的按位双翻转,与双否定!!
的行为非常相似,它将在稍后的“明确地:* --> 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
正如这里给出的其他建议一样,仅在读/写这样的代码的每一个人都知道这些操作符如何工作的情况下,才将~
和~~
作为“强制转换”和将值变形的明确机制。
明确的:解析数字字符串
将一个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
)。
TIP
parseInt(..)
有一个孪生兄弟,parseFloat(..)
。它(听起来)从一个字符串中拉出一个浮点数。
不要忘了parseInt(..)
工作在string
值上。向parseInt(..)
传递一个number
绝对没有任何意义。传递其他任何类型也都没有意义,比如true
,function(){..}
或[1,2,3]
。
如果你传入一个非string
,你所传入的值首先将自动地被强制转换为一个string
(见早先的“ToString
”),这很明显是一种隐藏的隐式强制转换。在你的程序中依赖这样的行为真的是一个坏主意,所以永远也不要将parseInt(..)
与非string
值一起使用。
在 ES5 之前,parseInt(..)
还存在另外一个坑,这曾是许多 JS 程序的 bug 的根源。如果你不传递第二个参数来指定使用哪种进制(也叫基数)来翻译数字的string
内容,parseInt(..)
将会根据开头的字符进行猜测。
如果开头的两个字符是"ox"
或"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)
看起来无害,对吧?试着在小时上选择08
在分钟上选择09
。你会得到0:0
。为什么?因为8
和9
都不是合法的 8 进制数。
ES5 之前的修改很简单,但是很容易忘:总是在第二个参数值上传递10
。这完全是安全的:
var hour = parseInt(selectedHour.value, 10)
var minute = parseInt(selectedMinute.value, 10)
在 ES5 中,parseInt(..)
不再猜测八进制数了。除非你指定,否则它会假定为 10 进制(或者为"0x"
前缀猜测 16 进制数)。这好多了。只是要小心,如果你的代码不得不运行在 ES5 之前的环境中,你仍然需要为基数传递10
。
解析非字符串
几年前有一个挖苦 JS 的玩笑,使一个关于parseInt(..)
行为的一个臭名昭著的例子备受关注,它取笑 JS 的这个行为:
parseInt(1 / 0, 19) // 18
这里面设想(但完全不合法)的断言是,“如果我传入一个无限大,并从中解析出一个整数的话,我应该得到一个无限大,不是 18”。没错,JS 一定是疯了才得出这个结果,对吧?
虽然这是个明显故意造成的,不真实的例子,但是让我们放纵这种疯狂一小会儿,来检视一下 JS 是否真的那么疯狂。
首先,这其中最明显的原罪是将一个非string
传入了parseInt(..)
。这是不对的。这么做是自找麻烦。但就算你这么做了,JS 也会礼貌地将你传入的东西强制转换为它可以解析的string
。
有些人可能会争论说这是一种不合理的行为,parseInt(..)
应当拒绝在一个非string
值上操作。它应该抛出一个错误吗?坦白地说,像 Java 那样。但是一想到 JS 应当开始在满世界抛出错误,以至于几乎每一行代码都需要用try..catch
围起来,我就不寒而栗。
它应当返回NaN
吗?也许。但是...要是这样呢:
parseInt(new String('42')) // 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"
。我很高兴它这么选。
我认为在 JS 中所有的值都有某种默认的string
表现形式是一件好事,这样它们就不是我们不能调试和推理的神秘黑箱了。
现在,关于 19 进制呢?很明显,这完全是伪命题和造作。没有真实的 JS 程序使用 19 进制。那太荒谬了。但是,让我们再一次放任这种荒谬。在 19 进制中,合法的数字字符是0
- 9
和a
- i
(大小写无关)。
那么,回到我们的的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
其实parseInt(..)
在它的行为上是相当可预见和一致的。如果你正确地使用它,你就能得到合理的结果。如果你不正确地使用它,那么你得到的疯狂结果并不是 JS 的错。
明确的:* --> Boolean
现在,我们来检视从任意的非boolean
值到一个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
没有Boolean(..)
或!!
的话,任何这些ToBoolean
强制转换都将隐含地发生,比如在一个if(..)..
语句这样使用boolean
的上下文中。但这里的目标是,明确地强制一个值成为boolean
来使ToBoolean
强制转换的意图显得明明白白的。
另一个ToBoolean
强制转换的用例是,如果你想在数据结构的 JSON 序列化中强制转换一个true
/false
:
var a = [1, function () {}, 2, function () {}]
JSON.stringify(a) // [1, null, 2, null]
JSON.stringify(a, function (k, v) {
if (typeof v === 'function') {
return !!v
} else {
return v
}
}) // [1, true, 2, true]
如果你是从 Java 来到 JS 的话,你可能会认得这个惯用法:
var a = 42
var b = a ? true : false
? :
三元操作符将会测试a
的真假。然后根据这个测试的结果相应地将true
或false
赋值给b
。
表面上,这个惯用法看起来是一种明确的ToBoolean
类型强制转换形式,因为很明显它操作的结果要么是true
要么是false
。
然而,这里有一个隐藏的隐式强制转换,就是表达式a
不得不首先被强制转换为boolean
来进行真假测试。我称这种惯用法为“明确的隐含”。另外,我建议你在 JS 中完全避免这种惯用法。它不会提供真正的好处,而且会让事情变得糟糕。
对于明确的强制转换Boolean(a)
和!!a
是好得多的选项。
隐式的强制转换
隐式的强制转换是指这样的类型转换:它们是隐藏的,由于其他的动作隐含地发生的不明显的副作用。换句话说,任何(对你)不明显的类型转换都是隐式的强制转换。
虽然明确的强制转换的目的很明白,但是这可能太过明显 —— 隐式的强制转换拥有相反的目的:使代码更难理解。
从表明上来看,我相信这就是许多关于强制转换的愤怒的源头。绝大多数关于“JS 强制转换”的抱怨实际上都指向了(不管他们是否理解它)隐式的强制转换。
NOTE
Douglas Crockford,“JavaScript: The Good Parts”的作者,在许多会议和他的作品中声称应当避免 JS 强制转换。但看起来他的意思是隐式的强制转换是不好的(以他的意见)。然而,如果你读他自己的代码的话,你会发现相当多的强制转换的例子,明确和隐式都有!事实上,他的担忧主要在于==
操作,但正如你将在本章中看到的,那只是强制转换机制的一部分。
那么,隐含强制转换是邪恶的吗?它很危险吗?它是 JS 设计上的缺陷吗?我们应该尽一些力量避免它吗?
我打赌大多数读者都倾向于踊跃地欢呼,“是的!”
别那么着急。听我把话说完。
让我们在隐式的强制转换是什么,和可以是什么这个问题上采取一个不同的角度,而不是仅仅说它是“好的明确强制转换的反面”。这太过狭隘,而且忽视了一个重要的微妙细节。
让我们将隐式的强制转换的目的定义为:减少搞乱我们代码的繁冗,模板代码,和/或不必要的实现细节,不使它们的噪音掩盖更重要的意图。
用于简化的隐含
在我们进入 JS 之前,我建议使用某个理论上是强类型的语言的假想代码来说明一下:
SomeType x = SomeType( AnotherType( y ) );
在这个例子中,我在y
中有一些任意类型的值,想把它转换为SomeType
类型。问题是,这种语言不能从当前y
的类型直接走到SomeType
。它需要一个中间步骤,它首先转换为AnotherType
,然后从AnotherType
转换为SomeType
。
现在,要是这种语言(或者你可用这种语言创建自己的定义)允许你这么说呢:
SomeType x = SomeType( y )
难道一般来说你不会同意我们简化了这里的类型转换,降低了中间转换步骤的无谓的“噪音”吗?我的意思是,在这段代码的这一点上,能看到并处理y
先变为AnotherType
然后再变为SomeType
的事实,真的是很重要的一件事吗?
有些人可能会争辩,至少在某些环境下,是的。但我想我可以做出相同的争辩说,在许多其他的环境下,不管是通过语言本身的还是我们自己的抽象,这样的简化通过抽象或隐藏这些细节确实增强了代码的可读性。
毫无疑问,在幕后的某些地方,那么中间的步骤依然是发生的。但如果这样的细节在视野中隐藏起来,我们就可以将使y
变为类型SomeType
作为一个泛化操作来推理,并隐藏混乱的细节。
虽然不是一个完美的类比,我要在本章剩余部分争论的是,jS 的隐式的强制转换可以被认为是给你的代码提供了一个类似的辅助。
但是,很重要的是,这不是一个无边界的,绝对的论断。绝对有许多邪恶的东西潜伏在隐式强制转换周围,它们对你的代码造成的损害要比任何潜在的可读性改善厉害得多。很清楚,我们不得不学习如何避免这样的结构,使我们不会用各种 bug 来毒害我们的代码。
许多开发者相信,如果一个机制可以做某些有用的事儿 A ,但也可以被滥用或误用来做某些可怕的事儿 Z ,那么我们就应当将这种机制整个儿扔掉,仅仅是为了安全。
我对你的鼓励是:不要安心于此。不要“把孩子跟洗澡水一起泼出去”。不要因为你只见过它的“坏的一面”就假设隐式强制转换都是坏的。我认为这里有“好的一面”,而我想要帮助和启发你们更多的人找到并接纳它们!
隐含地:Strings <--> Numbers
在本章的早先,我们探索了string
和number
值之间的明确强制转换。现在,让我们使用隐式强制转换的方式探索相同的任务。但在我们开始之前,我们不得不检视一些将会隐式的发生强制转换的操作的微妙之处。
为了服务于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
连接。虽然这有一部分是对的,但实际情况要更复杂。
考虑如下代码:
var a = [1, 2] // [1,2].toString() -> '1,2'
var b = [3, 4] // [3,4].toString() -> '3,4'
a + b // '1,23,4'
两个操作数都不是string
,但很明显它们都被强制转换为string
然后启动了string
连接。那么到底发生了什么?
WARNING
语言规范式的深度细节就要来了,如果这会吓到你就跳过下面两段!
根据 ES5 语言规范的 11.6.1 部分,+
的算法是(当一个操作数是object
值时),如果两个操作数之一已经是一个string
,或者下列步骤产生一个string
表达形式,+
将会进行连接。所以,当+
的两个操作数之一收到一个object
(包括array
)时,它首先在这个值上调用ToPrimitive
抽象操作(9.1 部分),而它会带着number
的上下文环境提示来调用[[DefaultValue]]
算法(8.12.8 部分)。
如果你仔细观察,你会发现这个操作现在和ToNumber
抽象操作处理object
的过程是一样的(参见早先的ToNumber
一节)。在array
上的valueOf()
操作将会产生一个简单基本类型时失败,于是它退回到一个toString()
表现形式。两个array
因此分别变成了"1,2"
和"3,4"
。现在,+
就如你通常期望的那样连接这两个string
:"1,23,4"
。
让我们把这些乱七八糟的细节放在一边,回到一个早先的,简化的解释:如果+
的两个操作数之一是一个string
(或在上面的步骤中成为一个string
),那么操作就会是string
连接。否则,它总是数字加法。
NOTE
关于强制转换,一个经常被引用的坑是[] + {}
和{} + []
,这两个表达式的结果分别是"[object Object]"
和0
。虽然对此有更多的东西,但是我们将在第五章的“Block”中讲解这其中的细节。
这对隐式强制转换意味着什么?
你可以简单地通过将number
和空string
“相加”来把一个number
强制转换为一个string
:
var a = 42
var b = a + ''
b // '42'
TIP
使用+
操作符的数字加法是可交换的,这意味着2 + 3
与3 + 2
是相同的。使用+
的字符串连接很明显通常不是可交换的,但是对于""
的特定情况,它实质上是可交换的,因为a + ""
和"" + a
会产生相同的结果。
使用+ ""
操作将number
(隐式地)强制转换为string
是极其常见/惯用的。事实上,有趣的是,一些在口头上批评隐式强制转换的最严厉的人仍然在他们自己的代码中使用这种方式,而不是使用它的明确的替代形式。
在隐式强制转换的有用形式中,我认为这是一个很棒的例子,尽管这种机制那么频繁地被人诟病!
将a + ""
这种隐式的强制转换与我们早先的String(a)
明确的强制转换的例子相比较,有一个另外的需要小心的奇怪之处。由于ToPrimitive
抽象操作的工作方式,a + ""
在值a
上调用valueOf()
,它的返回值再最终通过内部的ToString
抽象转换为一个string
。但是String(a)
只直接调用toString()
。
两种方式的最终结果都是一个string
,但如果你是用一个object
而不是一个普通的基本类型number
的值,你可能不一定得到相同的string
值!
考虑这段代码:
var a = {
valueOf() {
return 42
},
toString() {
return 4
},
}
a + '' // '42'
String(a) // '4'
一般来说这样的坑不会咬到你,除非你真的试着创建令人困惑的数据结构和操作,但如果你为某些object
同时定义了你自己的valueOf()
和toString()
方法,你就应当小心,因为你强制转换这些值的方式将影响到结果。
那么另一个方向呢?我们如何将一个string
隐式强制转换为一个number
?
var a = '3.14'
var b = a - 0
b // 3.14
-
操作符是仅为数字减法定义的,所以a - 0
强制a
的值被转换为一个number
。虽然少见得多,a * 1
或a / 1
也会得到相同的结果,因为这些操作符也是仅为数字操作定义的。
那么对-
操作符使用object
值会怎样呢?和上面的+
的故事相似:
var a = [3]
var b = [1]
a - b // 2
两个array
值都不得不变为number
,但它们首先会被强制转换为string
(使用意料之中的toString()
序列化),然后在强制转换为number
,以便-
减法操作可以实施。
那么,string
和number
值之间的隐式强制转换还是你总是在恐怖故事当中听到的丑陋怪物吗?我个人不这么认为。
比较b = String(a)
(明确的)和b = a + ""
(隐式的)。我认为在你的代码中会出现两种方式都有用的情况。当然b = a + ""
在 JS 程序中更常见一些,不管一般意义上隐式强制转换的好处或害处的感觉如何,它都提供了自己的用途。
隐含地: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
NOTE
当然,除了在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++) {
if (arguments[i]) {
sum += Number(!!arguments[i])
}
}
return sum === 1
}
我们首先使用!!arguments[i]
来讲这个值强制转换为true
或false
。这样你就可以像onlyOne("42", 0)
这样传入非boolean
值了,而且它依然可以如意料的那样工作(要不然,你将会得到string
连接,而且逻辑也不正确)。
一旦我们确认它是一个boolean
,我们就使用Number(..)
进行另一个明确的强制转换来确保值是0
或1
。
这个工具的明确强制转换形式“更好”吗?它确实像代码注释中解释的那样避开了NaN
的陷阱。但是,这最终要看你的需求。我个人认为前一个版本,有赖于隐式的强制转换更优雅(如果你不传入undefined
或NaN
),而明确的版本是一种不必要的繁冗。
但与我们在这里讨论的几乎所有东西一样,这是一个主观判断。
NOTE
不管是隐式的还是明确的方式,你可以通过将最后的比较从1
改为2
或5
,来分别很容易地制造onlyTwo(..)
或onlyFive(..)
。这要比添加一大堆&&
和||
表达式要简单太多了。所以,一般来说,在这种情况下强制转换非常有用。
隐含地: * --> Boolean
现在,让我们将注意力转向目标为boolean
值的隐式强制转换上,这是目前最常见,并且还是目前潜在的最麻烦的一种。
记住,隐式的强制转换是当你以强制一个值被转换的方式使用这个值时从启动的。对于number
和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 && b) || c) {
console.log('yep') // yep
}
在所有这些上下文环境中,非boolean
值被隐式地强制转换为它们的boolean
等价物,来决定测试的结果。
||
和&&
操作符
很可能你已经在你用过的大多数或所有其它语言中见过||
(“逻辑或”)和&&
(“逻辑与”)操作符了。所以假设它们在 JS 中的工作方式和其他类似的语言基本上相同是很自然的。
这里有一个鲜为人知的,但是很重要的,微妙细节。
其实,我会争辩这些操作符甚至不应当称为“逻辑__操作符”,因为这样的名字没有完全地描述它们在做什么。如果让我给它们一个更准确的(也更蹩脚的)名称,我会叫它们“选择器操作符” 或更完整的,“操作数选择器操作符”。
为什么?因为在 JS 中它们实际上不会得出一个逻辑值(也就是boolean
),这与它们在其他的语言中的表现不同。
那么它们到底得出什么?它们得出两个操作数中的一个(而且仅有一个)。换句话说,它们在两个操作数的值中选择一个。
引用 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
等一下,什么!?。想一想。在像 C 和 PHP 这样的语言中,这些表达式结果为true
或false
,而在 JS 中(就此而言还有 Python 和 Ruby!),结果来自于值本身。
||
和&&
操作符都在第一个操作数(a
或c
)上进行boolean
测试。如果这个操作数还不是boolean
(就像在这里一样),就会发生一次普通的ToBoolean
强制转换,这样测试就可以进行了。
对于||
操作符,如果测试结果为true
,||
表达式就将第一个操作数的值(a
或c
)作为结果。如果测试结果为false
,||
表达式就将第二个操作数的值(b
)作为结果。
相反的,对于&&
操作数,如果测试结果为true
,&&
表达式将第二个操作数(b
)作为结果。如果测试结果为false
,那么&&
表达式就将第一个操作数的值(a
或c
)作为结果。
||
或&&
表达式的结果总是两个操作数之一的底层值,不是(可能是被强制转换来的)测试的结果。在c && a
中,c
是null
,因此是 falsy。但是&&
表达式本身的结果为null
(c
中的值),不是用于测试的强制转换来的false
。
现在你明白这些操作符如何像“操作数选择器”一样工作了吗?
另一种考虑这些操作数的方式是:
a || b
// 大体上等价于:
a ? a : b
a && b
// 大体上等价于:
a ? b : a
NOTE
我说a || b
“大体上等价于”a ? a : b
,是因为虽然结果相同,但是这里有一个微妙的不同。在a ? a : b
中,如果a
是一个更复杂的表达式(例如像调用function
那样可能带有副作用),那么这个表达式a
将有可能被求值两次(如果第一次求值的结果为 truthy)。相比之下,对于a || b
,表达式a
仅被求值一次,而且这个值将被同时用于强制转换测试和结果值(如果合适的话)。同样的区别也适用于a && b
和a ? b : a
表达式。
很有可能你在没有完全理解之前你就已经使用了这个行为的一个极其常见,而且很有帮助的用法:
function foo(a, b) {
a = a || 'hello'
b = b || 'world'
console.log(a + ' ' + b)
}
foo() // hello world
foo('hi', 'mars') // hi mars
这种a = a || "hello"
惯用法(有时被说成 C# “null 合并操作符”的 JS 版本)对a
进行测试,如果它没有值(或仅仅是一个不期望的 falsy 值),就提供一个后备的默认值("hello"
)。
但是要小心!
foo("That's it!", '') // That's it! world
看到问题了吗?作为第二个参数的""
是一个 falsy 值(参见本章早先的ToBoolean
),所以b = b || "world"
测试失败,而默认值"world"
被替换上来,即便本来的意图可能是想让明确传入的""
作为赋给b
的值。
这种||
惯用法极其常见,而且十分有用,但是你不得不只在所有的 falsy 值应当被跳过时使用它。不然,你就需要在你的测试中更加具体,而且应该使用一个? :
三元操作符。
这种默认值赋值惯用法是如此常见(和有用!),以至于那些公开激烈诽谤 JS 强制转换的人都经常在它(我怀疑译者是故意这么写的)们的代码中使用!
那么&&
呢?
有另一种在手动编写中不那么常见,而在 JS 压缩器中频繁使用的惯用法。&&
操作符会“选择”第二个操作数,当且仅当第一个操作数测试为 truthy,这种用法有时被称为“守护操作符”(参见第五章的“短路”) —— 第一个表达式的测试“守护”着第二个表达式:
function foo() {
console.log(a)
}
var a = 42
a && foo() // 42
foo()
仅在a
测试为 truthy 时会被调用。如果这个测试失败,这个a && foo()
表达式语句将会无声地停止 —— 这被称为“短路” —— 而且永远不会调用foo()
。
重申一次,几乎很少有人手动编写这样的东西。通常,他们会写if (a) { foo() }
。但是 JS 压缩器选择a && foo()
是因为它们够短。所以,现在,如果你不得不解读这样的代码,你就知道它是在做什么以及为什么了。
好了,那么||
和&&
在它们的功能上有些不错的技巧,只要你乐意让隐式的强制转换掺和进来。
NOTE
a = b || "something"
和a && b()
两种惯用法都依赖于短路行为,我们将在第五章讲述它的细节。
现在,这些操作符实际上不会得出true
和false
的事实可能使你的头脑有点儿混乱。你可能想知道,如果你的if
语句和for
循环包含a && (a || c)
这样的复合的逻辑表达式,它们到底都是怎么工作的。
别担心!天没塌下来。你的代码(可能)没有问题。你只是可能从来没有理解在这个复合表达式被求值之后,有一个向boolean
隐式的强制转换发生了。
考虑这段代码:
var a = 42
var b = null
var c = 'foo'
if (a && (b || c)) {
console.log('yep')
}
这段代码将会像你总是认为的那样工作,除了一个额外的微妙细节。a && (b || c)
的结果实际上是"foo"
,不是true
。所以,这之后if
语句强制值"foo"
转换为一个boolean
,这里当然将是true
。
看到了?没有理由惊慌。你的代码可能依然是安全的。但是现在关于它在做什么和如何做,你知道了更多。
而且现在你理解了这样的代码使用隐式的强制转换。如果你依然属于“避开(隐式)强制转换阵营”,那么你就需要退回去并使所有这些测试明确:
if (!!a && (!!b || !!c)) {
console.log('yep')
}
祝你好运!...抱歉,只是个玩笑。
Symbol 强制转换
在此为止,在明确的和隐式的强制转换之间几乎没有可以观察到的结果上的不同 —— 只有代码的可读性至关重要。
但是 ES6 的 Symbol 在强制转换系统中引入了一个我们需要简单讨论的坑。由于一个明显超出了我们将在本书中讨论的范围的原因,从一个symbol
到一个string
的明确强制转换时允许的,但是相同的隐式强制转换是不被允许的,而且会抛出一个错误。
考虑如下代码:
var s1 = Symbol('cool')
String(s1) // 'Symbol(cool)'
var s2 = Symbol('not cool')
s2 + '' // TypeError
symbol
值根本不能强制转换为null
(不论哪种方式都会抛出错误),但奇怪的是它们既可以明确地也可以隐式的强制转换为boolean
(总是true
)。
一致性总是容易学习的,而对付例外从来就不有趣,但是我们只需要在 ES6symbol
值和我们如何强制转换它们的问题上多加小心。
好消息:你需要强制转换一个symbol
值的情况可能极其少见。它们通常的被使用的方式(见第三章)可能不会用到强制转换。
宽松等价与严格等价
宽松等价是==
操作符,而严格等价是===
操作符。两个操作符都被用于比较两个值的“等价性”,但是“宽松”和“严格”暗示着它们行为之间的一个非常重要的不同,特别是在它们如何决定“等价性”上。
关于这两个操作符的一个非常常见的误解是:“==
检查值的等价性,而===
检查值和类型的等价性。”虽然这听起来很好很合理,但是不准确。无数知名的 JS 书籍和文章都是这么说的,但不幸的是它们都错了。
正确的描述是:“==
允许在等价性比较中进行强制转换,而===
不允许强制转换”。
等价性的性能
停下来思考一下第一种(不正确的)解释和这第二种(正确的)解释的不同。
在第一种解释中,看起来===
明显的要比==
做更多工作,因为它还必须检查类型。在第二种解释中,==
是要做更多工作的,因为它不得不在类型不同时遵循强制转换的步骤。
不要像许多人那样陷入陷阱中,认为这会与性能有任何关系,虽然在这个问题上==
好像要比===
慢一点。强制转换确实要花费一点点处理时间,但也就是仅仅几微秒(是的,1 微秒就是 1 秒的百万分之一!)。
如果你比较同类型的两个值,==
和===
使用的是相同的算法,所以除了在引擎实现上的一些微小的区别,它们做的应当是相同的工作。
如果你比较两个不同类型的值,性能也不是重要因素。你应当问自己的是:当比较这两个值时,我想要进行强制转换吗?
如果你想要进行强制转换,使用==
宽松等价,但如果你不想进行强制转换,就使用===
严格等价。
NOTE
这里暗示==
和===
都会检查它们的操作数的类型。不同之处在于它们在类型不同时如何反应。
抽象等价性
在 ES5 语言规范的 11.9.3 部分中,==
操作符的行为被定义为“抽象等价性比较算法”。那里列出了一个详尽但简单的算法,它明确地指出了类型的每一种可能的组合,与对于每一种组合强制转化应当如何发生(如果有必要的话)。
WARNING
当(隐式的)强制转换被中伤为太过复杂和缺陷过多而不能成为有用的,好的部分时,遭到谴责的正是这些“抽象等价”规则。一般上,它们被认为对于开发者来说过于复杂和不直观而不能实际学习和应用,而且在 JS 程序中,和改善代码的可读性比起来,它倾向于导致更多的 bug。我相信这是一种有缺陷的预断 —— 读者都是整天都在写(而且读,理解)算法(也就是代码)的能干的开发者。所以,接下来的是用简单的语句来直白地解读“抽象等价性”。但我恳请你也去读一下 ES5 规范的 11.9.3 部分。我想你将会对它是多么合理而感到震惊。
基本上,它的第一条款(11.9.3.1)是在说,如果两个被比较的值是同一类型,它们就像你期望的那样通过等价性简单自然地比较。比如,42
和42
相等,而"abc"
和"abc"
相等。
在一般期望的结果中,有一些例外需要小心:
NaN
永远不等于它自己(见第二章)+0
和-0
是相等的(见第二章)
条款 11.9.3.1 的最后一个规定是关于object
(包括function
和array
)的==
宽松相等比较。这样的两个值仅在它们引用完全相同的值时相等。这里没有强制转换发生。
NOTE
===
严格等价比较与 11.9.3.1 中定义一模一样。包括关于两个object
值的规定。很少有人知道,在两个object
被比较的情况下,==
和===
的行为相同!
11.9.3.1 算法中的剩余部分指出,如果你使用==
宽松等价来比较两个不同类型的值,它们两者或其中之一将需要被隐式的强制转换。由于这个强制转换,两个值最终归于同一类型,可以使用简单的值的等价性来直接比较它们相等于否。
NOTE
!=
宽松不等价操作是如你预料的那样定义的,它差不多就是==
比较操作完整实施,之后对结果取反。这对于!==
严格不等价操作也是一样的。
比较:string
与number
为了展示==
强制转换,首先让我们建立本章中早先的string
和number
的例子:
var a = 42
var b = '42'
a == b // true
a === b // false
我们所预料的a === b
失败了,因为不允许强制转换,而且值42
和"42"
确实是不同的。
然而,第二个比较a == b
使用了宽松等价,这意味着如果类型偶然不同,这个比较算法将会对两个或其中一个值实施隐式的强制转换。
那么这里发生的究竟是哪种强制转换呢?是a
的值变成了一个sting
,还是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 的结果。
WARNING
语言规范中使用Number
和String
作为类型的正式名称,虽然这本书中偏好使用number
和string
指代基本类型。别让语言规范中首字母大写的Number
与Number()
原生函数把你搞糊涂了。对于我们的目的说,类型名称的首字母大写是无关紧要的 —— 它们基本上是同一个意思。
显然,语言规范为了比较,将值"42"
强制转换为一个number
。这个强制转换如何进行已经在前面讲解过了,明确地说是通过ToNumber
抽象操作。在这种情况下十分明显,两个值42
是相等的。
比较:任何东西与boolean
当你试着将一个值直接与true
或false
相比较时,你会遇到==
宽松等价的隐式强制转换中的最大的一个坑。
考虑如下代码:
var a = '42'
var b = true
a == b // false
等一下,这里发生了什么!?我们知道"42"
是一个 truthy 值(见本章早先的部分)。那么它和true
怎么不是==
宽松等价的?
其中的原因既简单又刁钻的使人迷惑。它是如此的容易让人误解,许多 JS 开发者从来不会花费足够多精力来完全掌握它。
让我们再次引入语言规范,条款 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(x)
是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
。
如果这对你来讲很奇怪,那么你不是一个人。我个人建议永远,永远,不要在任何情况下,使用== true
或== false
。永远。
但是要记住,我在此说的仅与==
有关。=== true
和=== false
不允许强制转换,所以它们没有ToNumber
强制转换,因此是安全的。
考虑如下代码:
vara = '42'
// 不好(会失败的!):
if (a == true) {
// ..
}
// 也不该(会失败的!):
if (a == false) {
// ..
}
// 足够好(隐式的工作):
if (a) {
// ..
}
// 更好(明确的工作)
if (!!a) {
// ..
}
// 也很好(明确的工作):
if (Boolean(a)) {
// ..
}
如果你在你的代码中一直避免使用== true
或== false
(也就是与boolean
的宽松等价),你将永远不必担心这种真/假思维陷阱。
比较: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 === null || a === undefined) {
// ..
}
在我看来,a == null
的形式是另一个用隐式强制转换增进了代码可读性的例子,而且是以一种安全可靠的方式。
比较: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 的结果。
NOTE
你可能注意到了,这些条款仅提到了String
和Number
,而没有Boolean
。这是因为,正如我们早先引述的,条款 11.9.3.9-7 首先将任何出现的Boolean
操作数强制转换为一个Number
。
考虑如下代码:
var a = 42
var b = [42]
a == b // true
值[42]
的ToPrimitive
抽象操作(见先前的“抽象值操作”部分)被调用,结果为值"42"
。这里它就变为42 == "42"
,我们已经讲解过这将变为42 == 42
,所以a
和b
被认为是强制转换的等价。
TIP
我们在本章早先讨论过的ToPrimitive
抽象操作的所有奇怪之处(toString()
、valueOf()
),都在这里如你期望的那样适用。如果你有一个复杂的数据结构,而且你想在它上面定义一个valueOf()
方法为等价比较提供一个简单值的话,这将十分有用。
在第三章中,我们讲解“拆箱”,就是一个基本类型值的object
包装器(例如new String("abc")
这样的形式)被展开,其底层的基本类型值("abc"
)被返回。这种行为与==
算法中的ToPrimitive
强制转换有关:
var a = 'abc'
var b = Object(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
永远不会与它自己相等(见第二章)。
边界情况
现在我们已经彻底检视了==
宽松等价的隐式强制转换时如何工作的(从合理与惊讶两个方式),让我们召唤角落中最差劲儿的,最疯狂的情况,这样我们就能看到我们需要避免什么来防止被强制转换的 bug 咬到。
首先,让我们检视修改内建的原生 prototype 是如何产生疯狂的结果的:
一个拥有其他值的数字将会...
Number.prototype.valueOf = function () {
return 3
}
new Number(2) == 3 // true
WARNING
2 == 3
不会掉到这个陷阱中,这是由于2
和3
不会调用内建的Number.prototype.valueOf()
方法,因为它们已经是基本number
值,可以直接比较。然而,new Number(2)
必须通过ToPrimitive
强制转换,因此调用valueOf()
。
邪恶吧?当然。任何人都不应当做这样的事情。你可以这么做,这个事实有时被当成批评强制转换和==
的根据。但这种沮丧是被误导的。JS 不会因为你能做这样的事情而不好,是做这样的事的开发者不好。不要陷入“我的编程语言应当保护我不受我自己伤害”的谬论。
接下来,让我们考虑另一个刁钻的例子,它将前一个例子的邪恶带到另一个水平:
if (a == 2 && a == 3) {
// ..
}
你可能认为这是不可能的,因为a
绝不会同时等于2
和3
。但是“同时”是不准确的,因为第一个表达式a == 2
严格地发生在a == 3
之前。那么,要是我们让a.valueOf()
在每次被调用时拥有一种副作用,使它第一次被调使用返回2
而第二次被调用时返回3
呢?很简单:
var i = 2
Number.prototype.valueOf = function () {
return i++
}
var a = new Number(42)
if (a == 2 && a == 3) {
console.log('yep, this happened.')
}
重申一次,这些都是邪恶的技巧。不要这么做。也不要用它们来抱怨强制转换。潜在地滥用一种机制并不是谴责这种机制的充分证据。避开这些疯狂的技巧,并坚持强制转换的合法与合理的用法就好了。
False-y 比较
关于==
比较中隐式强制转换的最常见的抱怨,来自于 falsy 值相互比较时它们令人吃惊的行为。
为了展示,让我们看一个关于 falsy 值比较的极端例子的列表,来瞧瞧哪一个是合理的,哪一个是麻烦的:
'0' == null // false
'0' == undefined // false
'0' == false // true -- UH OH!
'0' == NaN // false
'0' == 0 // true Number('0') == 0
'0' == '' // false just string to string
false == null // false
false == undefined // false
false == NaN // false
false == 0 // true -- UH OH!
false == '' // true -- UH OH!
false == [] // true -- UH OH! Number(false) == [].toString() -> 0 == '' -> 0 == Number('') -> 0 == 0
false == {} // false Number(false) == {}.toString() -> 0 == '[object Object]' -> 0 == Number('[object Object]') -> 0 == NaN
'' == null // false
'' == undefined // false
'' == NaN // false
'' == 0 // true -- UH OH!
'' == [] // true -- UH OH! '' == [].toString() -> '' == '' -> true
'' == {} // false '' == {}.toString() -> '' == '[object Object]' -> false
0 == null // false
0 == undefined // false
0 == NaN // false
0 == [] // true -- UH OH! 0 == [].toString() -> 0 == '' -> 0 == Number('') -> 0 == 0
0 == {} // false 0 == {}.toString() -> 0 == '[object Object]' -> 0 == Number('[object Object]') -> 0 == NaN
在这 24 个比较的列表中,17 个是十分合理和可预见的。比如我们知道""
和"NaN"
是根本不可能相等的值,并且它们确实不会强制转换为宽松等价的,而"0"
和0
是合理等价的,而且确实强制转换为宽松等价。
然而,这些比较中的 7 个被标注了“UH OH!”。作为“误报”(false positive),它们更像是会将你陷进入的坑。""
和0
绝对是有区别的不同的值,而且你很少会将它们作为等价的,所以它们的互相强制转换是一种麻烦。注意这里没有任何“漏报”(false negative)。
TIP
false positive
是一个常用于技术领域(如软件测试、医学、网络安全等)中的术语,指的是一个测试结果错误地显示了一个问题存在,而实际上这个问题并不存在。false negative
的概念与false positive
相对应。是指一个测试结果错误地显示了一个问题不存在,而实际上这个问题确实存在。
疯狂的情况
但是我们不必停留在此。我们可以继续寻找更能引起麻烦的强制转换:
[] == ![] // true
噢,这看起来像是更高层次的疯狂,对吧!?你的大脑可能会欺骗你说,你在将一个 truthy 和 falsy 值比较,所以结果true
是令人吃惊的,因为我们知道一个值不可能同时为 truthy 和 falsy。
但这不是实际发生的事情。让我们来把它分解一下。我们了解!
一元操作符吧?它明确地使用ToBoolean
规则将操作数强制转换为一个boolean
(而且它还会翻转真假值)。所以在[] == ![]
执行之前,它实际上已经被翻译为了[] == false
。我们已经在上面的列表中见过了这种形式(false == []
),所以它的令人吃惊的结果对我们来说并不新鲜。
其他的极端情况呢?
2 == [2] // true 2 == Number([2].toString()) -> 2 == '2' -> 2 == Number('2') -> 2 == 2
'' == [null] // true '' == [null].toString() -> '' == '' -> true
在关于ToNumber
的讨论中我们说过,右手边的[2]
和[null]
值将会通过一个ToPrimitive
强制转换,以使我们可以方便地与左手边的简单基本类型值进行比较。因为array
值的valueOf()
只是返回array
本身,强制转换会退到array
的字符串化上。
对于第一个比较的右手边的值来说,[2]
将变为"2"
,然后它会ToNumber
强制转换为2
。[null]
就直接变成""
。
那么,2 == 2
和"" == ""
是完全可以理解的。
如果你的直觉依然不喜欢这个结果,那么你的沮丧实际上与你可能认为的强制转换无关。这其实是在抱怨array
值在强制转换为string
值时的默认ToPrimitive
行为。很可能,你只是希望[2].toString()
不返回"2"
,或者[null].toString()
不返回""
。
但是这些string
强制转换到底应该得出什么结果?对于[2]
的string
强制转换,除了"2"
我确实想不出其他合适的结果,也许是"[2]"
—— 但这可能会在其他的上下文中很奇怪!
你可以正确地制造另一个例子:因为String(null)
变成了"null"
,那么String([null])
也应当变成"null"
。这是个合理的断言。所以它才是真正的犯人。
隐式强制转换在这里并不邪恶。即使将[null]
明确强制转换到string
,结果也是""
。真正奇怪的是,array
值字符串化为它们内容的等价物是否有道理,和它是如何发生的。所以,应当将你沮丧的原因指向String([..])
的规则,因为这里才是疯狂起源的地方。也许根本就不应该有array
的字符串化强制转换?但这会在语言的其他部分造成许多的缺点。
另一个常被引用的著名的坑是:
0 == '\n' // true
正如我们早先讨论的空""
,"\n"
(或" "
,或其他任何空格的组合)是通过ToNumber
强制转换的,而且结果为0
。你还希望空格被转换为其他的什么number
值呢?明确的Number()
给出0
会困扰你吗?
实际上,空字符串和空格字符串可以转换的唯一合理的number
值是NaN
。但这真的会更好吗?" " == NaN
的比较当然会失败,但是我们不清楚是否真的解决了任何潜在的问题。
真实世界中的 JS 程序由于0 == "\n"
而失败的几率非常之低,而且这样的极端用例很容易避免。
在任何语言中,类型转换总是有极端用例 —— 强制转换也不例外。这里讨论的是特定的一组极端用例的马后炮,但不是针对强制转换整体而言的争论。
总之:你可能遇到的几乎所有普通值间的疯狂强制转换(除了像早先那样有意而为的valueOf()
或toString()
黑科技),都能归结为我们在上面指出的 7 种情况的短列表。
对比这 24 个意思强制转换的坑,考虑另一个像这样的列表:
42 == '43' // false
'foo' == 42 // false
'true' == true // false
42 == '42' // true
'foo' == ['foo'] // true
在这些非 falsy,非极端的用例中(而且我们简直可以向这个列表中添加无限多个比较),强制转换完全是安全,合理,和可解释的。
可行性检查
好的,当我们深入观察隐式的强制转换时,我确实找到了一些疯狂的东西。难怪大多数开发者声称强制转换是邪恶而应该避免的,对吧?
但是让我们退一步并做一下可行性检查。
通过大量比较,我们得到了一张 7 个麻烦的,坑人的强制转换列表,但我们还得到了另一张(至少 17 个,实际上有无限多个)完全正常和可以解释的强制转换的列表。
如果你在寻找一本”把孩子和洗澡水一起泼出去“的教科书,这就是了:由于一个仅有 7 个坑的列表,而抛弃整个强制转换(安全且有效的行为的无限大列表)。
一个更谨慎地反应是问,“我如何使用强制转换的好的部分,而避开这几个坏的部分呢?”
让我们再看一次这个坏列表:
'0' == false // true
false == 0 // true
false == '' // true
false == [] // true
'' == 0 // true
'' == [] // true
0 == [] // true
这个列表中 7 各项目的 4 个与== false
比较有关,我们早先说过你应当总是,总是避免的。
现在这个列表缩小到了 3 个项目。
'' == 0 // true
'' == [] // true
0 == [] // true
这些是你在一般的 JS 程序中使用的合法的强制转换吗?在什么条件下它们会发生?
我不认为你在程序中有很大的可能要在一个boolean
测试中使用== []
,至少在你知道自己在做什么的情况下。你可能会使用== ""
或== 0
,比如:
function doSomething(a) {
if (a == '') {
// ..
}
}
如果你偶然调用了doSomething(0)
或doSomething([])
,你就会吓一跳。另一个例子:
function doSomething(a, b) {
if (a == b) {
// ..
}
}
再一次,如果你调用doSomething('',0)
或doSomething([],'')
时,它们会失败。
所以,虽然这些强制转换会咬到你的情况可能存在,而且你会小心地处理它们,但是它们可能不会在你的代码库中超级常见。
安全地使用隐式强制转换
我能给你的最重要的建议是:检查你的程序,并推理什么样的值会出现在==
比较两边。为了避免这样的比较中的问题,这里有一些可以遵循的启发性规则:
- 如果比较的任意一边可能出现
true
或者false
,那么就永远,永远不要使用==
。 - 如果比较的任意一边可能出现
""
,0
,或者[]
,那么认真地考虑不使用==
。
在这些场景中,为了避免不希望的强制转换,几乎可以确定使用===
要比使用==
好。遵循这两个简单的规则,可以有效地避免几乎所有可能会伤害你的强制转换的坑。
在这些情况下,使用更加明确/繁冗的方式会减少很多使你头疼的东西。
==
与===
的问题其实可以更加恰当地表述为:你是否应当在比较中允许强制转换?
在许多情况下这样的强制转换会很有用,允许你更简练地表述一些比较逻辑(例如,null
和undefined
)。
对于整体来说,相对有几个隐式强制转换会真的很危险的情况。但是在这些地方,为了安全起见,绝对要使用===
。
TIP
另一个强制转换保证不会咬到你的地方是typeof
操作符。typeof
总是将返回给你 7 种字符串值一(见第一章),它们中没有一个是空""
字符串。这样,检查某个值的类型时不会有任何情况与隐式强制转换相冲突。typeof x == "function"
就像typeof x === "function"
一样 100%安全可靠。从字面意义上讲,语言规范说这种情况下它们的算法是相同的。所以,不要只是因为你的代码工具告诉你这么做,或者(最差劲儿的)在某本书中有人告诉你不要考虑它,而盲目地到处使用===
。你掌管着你的代码的质量。
隐式强制转换是邪恶和危险的吗?在几个情况下,是的,但总体来说,不是。
做一个负责任和成熟的开发者。学习如何有效并安全地使用强制转换(明确的和隐式的两者)的力量。并教会你周围的人也这么做。
这里是由 Alex Dorey(@dorey on GitHub)制作的一个方便的表格,将各种比较进行了可视化:
出处:🔗
抽象关系比较
虽然这部分的隐式强制转换经常不为人所注意,但无论如何考虑比较a < b
时发生了什么是很重要的(和我们如何深入检视a == b
类似)。
在 ES5 语言规范的 11.8.5 部分的“抽象关系型比较”算法,实质上把自己分成了两个部分:比如比较涉及两个string
只要做什么(后半部分),和除此之外的其他值要做什么(前半部分)。
NOTE
这个算法仅仅定义了a < b
。所以,a > b
作为b < a
处理。
这个算法首先在两个值上调用ToPrimitive
强制转换,如果两个调用的返回值之一不是string
,那么就使用ToNumber
操作规则将这两个值强制转换为number
值,并进行数字的比较。
举例来说:
var a = [42]
var b = ['43']
a < b // true
b < a // false
NOTE
早先讨论的关于-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
和a > b
都是false
,那么为什么a <= b
和a >= b
的结果为true
?
因为语言规范说,对于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 '42' < '043' 字符串比较
Number(a) < Number(b) // true 42 < 43 数字比较
复习
在这一章中,我们将注意力转向了 JS 类型转换如何发生,也叫强制转换,按性质来说它要么是明确的要么是隐式的。
强制转换的名声很坏,但它实际上在许多情况下很有帮助。对于负责任的 JS 开发者来说,一个重要的任务就是花时间去学习强制转换的里里外外,来决定哪一部分将帮助他们改进代码,哪一部分他们真的应该回避。
明确的强制转换是这样一种代码,它明显地有意将一个值从一种类型转换到另一种类型。它的益处是通过减少困惑来增强了代码的可读性和可维护性。
隐式的强制转换时作为一些其他操作的隐藏的副作用而存在的,将要发生的类型转换并不明显。虽然看起来隐式的强制转换是明确的反面,而且因此是不好的(确实,很多人这么认为!),但是实际上隐式的强制转换也是为了增强代码的可读性。
特别是对于隐式的,强制转换必须被负责的,有意识的使用。懂得为什么你在写你正在写的代码,和它是如何工作的。同时也要努力编写其他人容易学习和理解的代码。