隐含的强制转换
隐含的 强制转换是指这样的类型转换:它们是隐藏的,由于其他的动作隐含地发生的不明显的副作用。换句话说,任何(对你)不明显的类型转换都是 隐含的强制转换。
虽然 明确的 强制转换的目的很明白,但是这可能 太过 明显 —— 隐含的 强制转换拥有相反的目的:使代码更难理解。
从表面上来看,我相信这就是许多关于强制转换的愤怒的源头。绝大多数关于“JavaScript强制转换”的抱怨实际上都指向了(不管他们是否理解它) 隐含的 强制转换。
注意: Douglas Crockford,“JavaScript: The Good Parts” 的作者,在许多会议和他的作品中声称应当避免JavaScript强制转换。但看起来他的意思是 隐含的 强制转换是不好的(以他的意见)。然而,如果你读他自己的代码的话,你会发现相当多的强制转换的例子,明确 和 隐含 都有!事实上,他的担忧主要在于==
操作,但正如你将在本章中看到的,那只是强制转换机制的一部分。
那么,隐含强制转换 是邪恶的吗?它很危险吗?它是JavaScript设计上的缺陷吗?我们应该尽一切力量避免它吗?
我打赌大多数读者都倾向于踊跃地欢呼,“是的!”
别那么着急。听我把话说完。
让我们在 隐含的 强制转换是什么,和可以是什么这个问题上采取一个不同的角度,而不是仅仅说它是“好的明确强制转换的反面”。这太过狭隘,而且忽视了一个重要的微妙细节。
让我们将 隐含的 强制转换的目的定义为:减少搞乱我们代码的繁冗,模板代码,和/或不必要的实现细节,不使它们的噪音掩盖更重要的意图。
用于简化的隐含
在我们进入JavaScript以前,我建议使用某个理论上是强类型的语言的假想代码来说明一下:
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];
var b = [3,4];
a + b; // "1,23,4"
两个操作数都不是string
,但很明显它们都被强制转换为string
然后启动了string
连接。那么到底发生了什么?
(警告: 语言规范式的深度细节就要来了,如果这会吓到你就跳过下面两段!)
根据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
连接。否则,它总是数字加法。
注意: 关于强制转换,一个经常被引用的坑是[] + {}
和{} + []
,这两个表达式的结果分别是"[object Object]"
和0
。虽然对此有更多的东西,但是我们将在第五章的“Block”中讲解这其中的细节。
这对 隐含 强制转换意味着什么?
你可以简单地通过将number
和空string``""
“相加”来把一个number
强制转换为一个string
:
var a = 42;
var b = a + "";
b; // "42"
提示: 使用+
操作符的数字加法是可交换的,这意味着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: function() { return 42; },
toString: function() { 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
注意: 当然,除了在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(..)
。这要比添加一大堆&&
和||
表达式要简单太多了。所以,一般来说,在这种情况下强制转换非常有用。
隐含地:* —> 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
等价物,来决定测试的结果。
||
和&&
操作符
很可能你已经在你用过的大多数或所有其他语言中见到过||
(“逻辑或”)和&&
(“逻辑与”)操作符了。所以假设它们在JavaScript中的工作方式和其他类似的语言基本上相同是很自然的。
这里有一个鲜为人知的,但很重要的,微妙细节。
其实,我会争辩这些操作符甚至不应当被称为“逻辑__操作符”,因为这样的名称没有完整地描述它们在做什么。如果让我给它们一个更准确的(也更蹩脚的)名称,我会叫它们“选择器操作符”或更完整的,“操作数选择器操作符”。
为什么?因为在JavaScript中它们实际上不会得出一个 逻辑 值(也就是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 && 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
表达式。
很有可能你在没有完全理解之前你就已经使用了这个行为的一个极其常见,而且很有帮助的用法:
function foo(a,b) {
a = a || "hello";
b = b || "world";
console.log( a + " " + b );
}
foo(); // "hello world"
foo( "yeah", "yeah!" ); // "yeah yeah!"
这种a = a || "hello"
惯用法(有时被说成C#“null合并操作符”的JavaScript版本)对a
进行测试,如果它没有值(或仅仅是一个不期望的falsy值),就提供一个后备的默认值("hello"
)。
但是 要小心!
foo( "That's it!", "" ); // "That's it! world" <-- Oops!
看到问题了吗?作为第二个参数的""
是一个falsy值(参见本章早先的ToBoolean
),所以b = b || "world"
测试失败,而默认值"world"
被替换上来,即便本来的意图可能是想让明确传入的""
作为赋给b
的值。
这种||
惯用法极其常见,而且十分有用,但是你不得不只在 所有的falsy值 应当被跳过时使用它。不然,你就需要在你的测试中更加具体,而且可能应该使用一个? :
三元操作符。
这种默认值赋值惯用法是如此常见(和有用!),以至于那些公开激烈诽谤JavaScript强制转换的人都经常在它们的代码中使用!
那么&&
呢?
有另一种在手动编写中不那么常见,而在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()
是因为它短的多。所以,现在,如果你不得不解读这样的代码,你就知道它是在做什么以及为什么了。
好了,那么||
和&&
在它们的功能上有些不错的技巧,只要你乐意让 隐含的 强制转换掺和进来。
注意: a = b || "something"
和a && b()
两种惯用法都依赖于短接行为,我们将在第五章中讲述它的细节。
现在,这些操作符实际上不会得出true
和false
的事实可能使你的头脑有点儿混乱。你可能想知道,如果你的if
语句和for
循环包含a && (b || 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
值根本不能强制转换为number
(不论哪种方式都抛出错误),但奇怪的是它们既可以 明确地 也可以 隐含地 强制转换为boolean
(总是true
)。
一致性总是容易学习的,而对付例外从来就不有趣,但是我们只需要在ES6symbol
值和我们如何强制转换它们的问题上多加小心。
好消息:你需要强制转换一个symbol
值的情况可能极其少见。它们典型的被使用的方式(见第三章)可能不会用到强制转换。