你不懂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(很明显,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(..)。这要比添加一大堆&&||表达式要简单太多了。所以,一般来说,在这种情况下强制转换非常有用。

隐含地:* --> 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等价物,来决定测试的结果。

||&&操作符

很可能你已经在你用过的大多数或所有其他语言中见到过||(“逻辑或”)和&&(“逻辑与”)操作符了。所以假设他们在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这样的语言中,这些表达式结果为truefalse,而在JS中(就此而言还有Python和Ruby!),结果来自于值本身。

||&&操作符都在 第一个操作数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表达式。

很有可能你在没有完全理解之前你就已经使用了这个行为的一个极其常见,而且很有帮助的用法:

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()两种惯用法都依赖于短接行为,我们将在第五章中讲述它的细节。

现在,这些操作符实际上不会得出truefalse的事实可能使你的头脑有点儿混乱。你可能想知道,如果你的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值的情况可能极其少见。它们典型的被使用的方式(见第三章)可能不会用到强制转换。

宽松等价与严格等价

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

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

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

等价性的性能

停下来思考一下第一种(不正确的)解释和这第二种(正确的)解释的不同。

在第一种解释中,看起来===明显的要比==做更多工作,因为它还必须检查类型。在第二种解释中,==是要 做更多工作 的,因为它不得不在类型不同时走过强制转换的步骤。

不要像许多人那样落入陷阱中,认为这会与性能有任何关系,虽然在这个问题上==好像要比===慢一些。强制转换确实要花费 一点点 处理时间,但也就是仅仅几微秒(是的,1微秒就是一秒的百万分之一!)。

如果你比较同类型的两个值,=====使用的是相同的算法,所以除了在引擎实现上的一些微小的区别,它们做的应当是相同的工作。

如果你比较两个不同类型的值,性能也不是重要因素。你应当问自己的是:当比较这两个值时,我想要进行强制转换吗?

如果你想要进行强制转换,使用==宽松等价,但如果你不想进行强制转换,就使用===严格等价。

注意: 这里暗示=====都会检查它们的操作数的类型。不同之处在于它们在类型不同时如何反应。

抽象等价性

在ES5语言规范的11.9.3部分中,==操作符的行为被定义为“抽象等价性比较算法”。那里列出了一个详尽但简单的算法,它明确地指出了类型的每一种可能的组合,与对于每一种组合强制转化应当如何发生(如果有必要的话)。

警告: 当(隐含的)强制转换被中伤为太过复杂和缺陷过多而不能成为 有用的,好的部分 时,遭到谴责的正是这些“抽象等价”规则。一般上,它们被认为对于开发者来说过于复杂和不直观而不能实际学习和应用,而且在JS程序中,和改善代码的可读性比起来,它倾向于导致更多的bug。我相信这是一种有缺陷的预断 —— 读者都是整天都在写(而且读,理解)算法(也就是代码)的能干的开发者。所以,接下来的是用简单的词语来直白地解读“抽象等价性”。但我恳请你也去读一下ES5规范的11.9.3部分。我想你将会对它是多么合理而感到震惊。

基本上,它的第一个条款(11.9.3.1)是在说,如果两个被比较的值是同一类型,它们就像你期望的那样通过等价性简单自然地比较。比如,42只和42相等,而"abc"只和"abc"相等。

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

  • NaN永远不等于它自己(见第二章)
  • +0-0是相等的(见第二章)

条款11.9.3.1的最后一个规定是关于object(包括functionarray)的==宽松相等性比较。这样的两个值仅在它们引用 完全相同的值相等。这里没有强制转换发生。

注意: ===严格等价比较与11.9.3.1的定义一模一样,包括关于两个object的值的规定。很少有人知道,在两个object被比较的情况下,=====的行为相同

11.9.3算法中的剩余部分指出,如果你使用==宽松等价来比较两个不同类型的值,它们两者或其中之一将需要被 隐含地 强制转换。由于这个强制转换,两个值最终归于同一类型,可以使用简单的值的等价性来直接比较它们相等与否。

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

比较: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是相等的。

比较:任何东西与boolean

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

考虑如下代码:

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

a == b; // false

等一下,这里发生了什么!?我们知道"42"是一个truthy值(见本章早先的部分)。那么它和true怎么不是==宽松等价的?

其中的原因既简单又刁钻得使人迷惑。它是如此的容易让人误解,许多JS开发者从来不会花费足够多的精力来完全掌握它。

让我们再次引用语言规范,条款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

如果这对你来讲很奇怪,那么你不是一个人。我个人建议永远,永远,不要在任何情况下,使用== true== false。永远。

但时要记住,我在此说的仅与==有关。=== true=== false不允许强制转换,所以它们没有ToNumber强制转换,因而是安全的。

考虑如下代码:

var a = "42";

// 不好(会失败的!):
if (a == true) {
    // ..
}

// 也不该(会失败的!):
if (a === true) {
    // ..
}

// 足够好(隐含地工作):
if (a) {
    // ..
}

// 更好(明确地工作):
if (!!a) {
    // ..
}

// 也很好(明确地工作):
if (Boolean( a )) {
    // ..
}

如果你在你的代码中一直避免使用== true== false(也就是与boolean的宽松等价),你将永远不必担心这种真/假的思维陷阱。

比较: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的形式是另一个用 隐含 强制转换增进了代码可读性的例子,而且是以一种可靠安全的方式。

比较: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永远不会它自己相等(见第二章)。

边界情况

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

邪恶吧?当然。任何人都不应当做这样的事情。你 可以 这么做,这个事实有时被当成批评强制转换和==的根据。但这种沮丧是被误导的。JavaScript不会因为你能做这样的事情而 不好,是 做这样的事的开发者 不好。不要陷入“我的编程语言应当保护我不受我自己伤害”的谬论。

接下来,让我们考虑另一个刁钻的例子,它将前一个例子的邪恶带到另一个水平:

if (a == 2 && a == 3) {
    // ..
}

你可能认为这是不可能的,因为a绝不会 同时 等于23。但是“同时”是不准确的,因为第一个表达式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 -- 噢!
"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是合理等价的,而且确实强制转换为宽松等价。

然而,这些比较中的7个被标上了“噢!”。作为误判的成立,它们更像是会将你陷进去的坑。""0绝对是有区别的不同的值,而且你很少会将它们作为等价的,所以它们的互相强制转换是一种麻烦。注意这里没有任何误判的不成立。

疯狂的情况

但是我们不必停留在此。我们可以继续寻找更能引起麻烦的强制转换:

[] == ![];      // true

噢,这看起来像是更高层次的疯狂,对吧!?你的大脑可能会欺骗你说,你在将一个truthy和falsy值比较,所以结果true是令人吃惊的,因为我们知道一个值不可能同时为truthy和falsy!

但这不是实际发生的事情。让我们把它分解一下。我们了解!一元操作符吧?它明确地使用ToBoolean规则将操作数强制转换为一个boolean(而且它还会翻转真假性)。所以在[] == ![]执行之前,它实际上已经被翻译为了[] == false。我们已将在上面的列表中见过了这种形式(false == []),所以它的令人吃惊的结果对我们来说并不 新鲜

其它的极端情况呢?

2 == [2];       // true
"" == [null];   // 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 -- 噢!

这些是你在一般的JavaScript程序中使用的合理的强制转换吗?在什么条件下它们会发生?

我不认为你在程序里有很大的可能要在一个boolean测试中使用== [],至少在你知道自己在做什么的情况下。你可能会使用== ""== 0,比如:

function doSomething(a) {
    if (a == "") {
        // ..
    }
}

如果你偶然调用了doSomething(0)doSomething([]),你就会吓一跳。另一个例子:

function doSomething(a,b) {
    if (a == b) {
        // ..
    }
}

再一次,如果你调用doSomething("",0)doSomething([],"")时,它们会失败。

所以,虽然这些强制转换会咬到你的情况 可能 存在,而且你会小心地处理它们,但是它们可能不会在你的代码库中超级常见。

安全地使用隐含强制转换

我能给你的最重要的建议是:检查你的程序,并推理什么样的值会出现在==比较两边。为了避免这样的比较中的问题,这里有一些可以遵循的启发性规则:

  1. 如果比较的任意一边可能出现true或者false值,那么就永远,永远不要使用==
  2. 如果比较的任意一边可能出现[]"",或0这些值,那么认真地考虑不使用==

在这些场景中,为了避免不希望的强制转换,几乎可以确定使用===要比使用==好。遵循这两个简单的规则,可以有效地避免几乎所有可能会伤害你的强制转换的坑。

在这些情况下,使用更加明确/繁冗的方式会减少很多使你头疼的东西。

=====的问题其实可以更加恰当地表述为:你是否应当在比较中允许强制转换?

在许多情况下这样的强制转换会很有用,允许你更简练地表述一些比较逻辑(例如,nullundefined)。

对于整体来说,相对有几个 隐含 强制转换会真的很危险的情况。但是在这些地方,为了安全起见,绝对要使用===

提示: 另一个强制转换保证 不会 咬到你的地方是typeof操作符。typeof总是将返回给你7中字符串之一(见第一章),它们中没有一个是空""字符串。这样,检查某个值的类型时不会有任何情况与 隐含 强制转换相冲突。typeof x == "function"就像typeof x === "function"一样100%安全可靠。从字面意义上将,语言规范说这种情况下它们的算法是相同的。所以,不要只是因为你的代码工具告诉你这么做,或者(最差劲儿的)在某本书中有人告诉你 不要考虑它,而盲目地到处使用===。你掌管着你的代码的质量。

隐含 强制转换是邪恶和危险的吗?在几个情况下,是的,但总体说来,不是。

做一个负责任和成熟的开发者。学习如何有效并安全地使用强制转换(明确的隐含的 两者)的力量。并教你周围的人也这么做。

这里是由Alex Dorey (@dorey on GitHub)制作的一个方便的表格,将各种比较进行了可视化:

fig1.png

出处:https://github.com/dorey/JavaScript-Equality-Table

抽象关系比较

虽然这部分的 隐含 强制转换经常不为人所注意,但无论如何考虑比较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开发者来说,一个重要的任务就是花时间去学习强制转换的里里外外,来决定哪一部分将帮助他们改进代码,哪一部分他们真的应该回避。

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

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容