你不懂JS:类型与文法

 主页   资讯   文章   代码   电子书 

你不懂JS:类型与文法

第四章:强制转换

现在我们更全面地了解了 JavaScript 的类型和值,我们将注意力转向一个极具争议的话题:强制转换。

正如我们在第一章中提到的,关于强制转换到底是一个有用的特性,还是一个语言设计上的缺陷(或介于两者之间!),早就开始就争论不休了。如果你读过关于 JS 的其他书籍,你就会知道流行在世面上那种淹没一切的 声音:强制转换是魔法,是邪恶的,令人困惑的,而且就是彻头彻尾的坏主意。

本着这个系列丛书的总体精神,我认为你应当直面你不理解的东西并设法更全面地 搞懂它。而不是因为大家都这样做,或是你曾经被一些怪东西咬到就逃避强制转换。

我们的目标是全面地探索强制转换的优点和缺点(是的,它们 优点!),这样你就能在程序中对它是否合适做出明智的决定。

转换值

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

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

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

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

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

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

var a = 42;

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

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

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

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

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

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

“明确地”、“隐含地”、或“明显地”和“隐藏的副作用”这些术语,是 相对的

如果你确切地知道 a + "" 是在做什么,并且你有意地这么做来强制转换一个 string,你可能感觉这个操作已经足够“明确”了。相反,如果你从没见过 String(..) 函数被用于 string 强制转换,那么对你来说它的行为可能看起来太过隐蔽而让你感到“隐含”。

但我们是基于一个 大众的,充分了解,但不是专家或 JS 规范爱好者的 开发者的观点来讨论“明确”与“隐含”的。无论你的程度如何,或是没有在这个范畴内准确地找到自己,你都需要根据我们在这里的观察方式,相应地调整你的角度。

记住:我们自己写代码而也只有我们自己会读它,通常是很少见的。即便你是一个精通 JS 里里外外的专家,也要考虑一个经验没那么丰富的队友在读你的代码时感受如何。对于他们和对于你来说,“明确”或“隐含”的意义相同吗?

抽象值操作

在我们可以探究 明确隐含 强制转换之前,我们需要学习一些基本规则,是它们控制着值如何 变成 一个 stringnumber、或 boolean 的。ES5 语言规范的第九部分用值的变形规则定义了几种“抽象操作”(“仅供内部使用的操作”的高大上说法)。我们将特别关注于:ToStringToNumber、和 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;

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

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

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

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

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

var a = [1,2,3];

a.toString(); // "1,2,3"

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

JSON 字符串化

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

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

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

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

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

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

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

考虑下面的代码:

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

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

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

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

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

例如:

var o = { };

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

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

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

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

JSON.stringify( a ); // "{"b":42}"

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

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

考虑下面的代码:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ToNumber

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

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

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

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

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

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

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

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

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

考虑如下代码:

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

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

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

Number( a );            // 42
Number( b );            // 42
Number( c );            // 42
Number( "" );           // 0
Number( [] );           // 0
Number( [ "abc" ] );    // NaN

ToBoolean

下面,让我们聊一聊在 JS 中 boolean 如何动作。世面上关于这个话题有 许多的困惑和误解,所以集中注意力!

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

Falsy 值

但这还不是故事的结尾。我们需要讨论一下,除了这两个 boolean 值以外,当你把其他值强制转换为它们的 boolean 等价物时如何动作。

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

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

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

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

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

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

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

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

Falsy 对象

等一下,这一节的标题听起来简直是矛盾的。我 刚刚才说过 语言规范将所有对象称为 truthy,对吧?应该没有“falsy 对象”这样的东西。

这会是什么意思呢?

它可能诱使你认为它意味着一个包装了 falsy 值(比如 ""0false)的对象包装器(见第三章)。但别掉到这个 陷阱 中。

注意: 这个可能是一个语言规范的微妙笑话。

考虑下面的代码:

var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );

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

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

d; // true

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

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

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

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

什么!?

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

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

为什么!?

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

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

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

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

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

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

“我们应当怎么做?” “我知道了!让我们黑进 JS 的类型系统并假装 document.all 是 falsy!”

呃。这很烂。这是一个大多数 JS 开发者们都不理解的疯狂的坑。但是其它的替代方案(对上面两败俱伤的问题什么都不做)还要烂得 多那么一点点

所以……这就是我们得到的:由浏览器给 JavaScript 添加的疯狂、非标准的“falsy 对象”。耶!

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值。

换句话说,truthy列表是无限长的。不可能制成一个这样的列表。你只能制造一个falsy列表并查询它。

花五分钟,把falsy列表写在便利贴上,然后粘在你的电脑显示器上,或者如果你愿意就记住它。不管哪种方法,你都可以在自己需要的时候通过简单地查询一个值是否在falsy列表中,来构建一个虚拟的truthy列表。

truthy和falsy的重要性在于,理解如果一个值在被(明确地或隐含地)强制转换为boolean值的话,它将如何动作。现在你的大脑中有了这两个列表,我们可以深入强制转换的例子本身了。

明确的强制转换

明确的 强制转换指的是明显且明确的类型转换。对于大多数开发者来说,有很多类型转换的用法可以清楚地归类于这种 明确的 强制转换。

我们在这里的目标是,在我们的代码中指明一些模式,在这些模式中我们可以清楚明白地将一个值从一种类型转换至另一种类型,以确保不给未来将读到这段代码的开发者留下任何坑。我们越明确,后来的人就越容易读懂我们的代码,也不必费太多的力气去理解我们的意图。

关于 明确的 强制转换可能很难找到什么主要的不同意见,因为它与被广泛接受的静态类型语言中的类型转换的工作方式非常接近。因此,我们理所当然地认为(暂且) 明确的 强制转换可以被认同为不是邪恶的,或没有争议的。虽然我们稍后会回到这个话题。

明确地:Strings <--> Numbers

我们将从最简单,也许是最常见强制转换操作开始:将值在stringnumber表现形式之间进行强制转换。

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

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

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

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

b; // "42"
d; // 3.14

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

我称此为 明确的 强制转换是因为,一般对于大多数开发者来说这是十分明显的:这些操作的最终结果是适当的类型转换。

实际上,这种用法看起来与其他的静态类型语言中的用法非常相像。

举个例子,在C/C++中,你既可以说(int)x也可以说int(x),而且它们都将x中的值转换为一个整数。两种形式都是合法的,但是许多人偏向于后者,它看起来有点儿像一个函数调用。在JavaScript中,当你说Number(x)时,它看起来极其相似。在JS中它实际上是一个函数调用这个事实重要吗?并非如此。

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

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强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。

注意: 在开源的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像混淆了,而后者完全是不同的东西!

注意: 一元+的另一个极端使人困惑的地方是,被用于紧挨着另一个将要作为++递增操作符和--递减操作符的操作数。例如:a +++ba + ++b,和a + + +b。更多关于++的信息,参见第五章的“表达式副作用”。

记住,我们正努力变得明确并 减少 困惑,不是把事情弄得更糟!

Datenumber

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

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

+d; // 1408369986000

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

var timestamp = +new Date();

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

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

var timestamp = new Date().getTime();
// var timestamp = (new Date()).getTime();
// var timestamp = (new Date).getTime();

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

var timestamp = Date.now();

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

if (!Date.now) {
    Date.now = function() {
        return +new Date();
    };
}

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

奇异的~

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

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

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

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

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

0 | -0;         // 0
0 | NaN;        // 0
0 | Infinity;   // 0
0 | -Infinity;  // 0

这些特殊的数字是不可用32位表现的(因为它们源自64位的IEEE 754标准 —— 见第二章),所以ToInt32将这些值的结果指定为0

有争议的是,0 | __是否是一种ToInt32强制转换操作的 明确的 形式,还是更倾向于 隐含。从语言规范的角度来说,毫无疑问是 明确的,但是如果你没有在这样的层次上理解位操作,它就可能看起来有点像 隐含的 魔法。不管怎样,为了与本章中其他的断言保持一致,我们称它为 明确的

那么,让我们把注意力转回~~操作符首先将值“强制转换”为一个32位number值,然后实施按位取反(翻转每一个比特位)。

注意: 这与!不仅强制转换它的值为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表示“失败”。

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

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

明确地:解析数字字符串

将一个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)。

提示: parseInt(..)有一个孪生兄弟,parseFloat(..),它(听起来)从一个字符串中拉出一个浮点数。

不要忘了parseInt(..)工作在string值上。向parseInt(..)传递一个number绝对没有任何意义。传递其他任何类型也都没有意义,比如truefunction(){..}[1,2,3]

如果你传入一个非string,你所传入的值首先将自动地被强制转换为一个string(见早先的“ToString”),这很明显是一种隐藏的 隐含 强制转换。在你的程序中依赖这样的行为真的是一个坏主意,所以永远也不要将parseInt(..)与非string值一起使用。

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

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

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

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

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

看起来无害,对吧?试着在小时上选择08在分钟上选择09。你会得到0:0。为什么?因为89都不是合法的8进制数。

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

var hour = parseInt( selectedHour.value, 10 );
var minute = parseInt( selectedMiniute.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") );

这也应当失败吗?它是一个非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 - 9a - 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(..)在它的行为上是相当可预见和一致的。如果你正确地使用它,你就能得到合理的结果。如果你不正确地使用它,那么你得到的疯狂结果并不是JavaScript的错。

明确地:* --> 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(key,val){
    if (typeof val == "function") {
        // 强制函数进行 `ToBoolean` 转换
        return !!val;
    }
    else {
        return val;
    }
} );
// "[1,true,2,true]"

如果你是从Java来到JavaScript的话,你可能会认得这个惯用法:

var a = 42;

var b = a ? true : false;

? :三元操作符将会测试a的真假,然后根据这个测试的结果相应地将truefalse赋值给b

表面上,这个惯用法看起来是一种 明确的 ToBoolean类型强制转换形式,因为很明显它操作的结果要么是true要么是false

然而,这里有一个隐藏的 隐含 强制转换,就是表达式a不得不首先被强制转换为boolean来进行真假测试。我称这种惯用法为“明确地隐含”。另外,我建议你在JavaScript中 完全避免这种惯用法。它不会提供真正的好处,而且会让事情变得更糟。

对于 明确的 强制转换Boolean(a)!!a是好得多的选项。

隐含的强制转换

隐含的 强制转换是指这样的类型转换:它们是隐藏的,由于其他的动作隐含地发生的不明显的副作用。换句话说,任何(对你)不明显的类型转换都是 隐含的强制转换

虽然 明确的 强制转换的目的很明白,但是这可能 太过 明显 —— 隐含的 强制转换拥有相反的目的:使代码更难理解。

从表面上来看,我相信这就是许多关于强制转换的愤怒的源头。绝大多数关于“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

在本章的早先,我们探索了stringnumber值之间的 明确 强制转换。现在,让我们使用 隐含 强制转换的方式探索相同的任务。但在我们开始之前,我们不得不检视一些将会 隐含地 发生强制转换的操作的微妙之处。

为了服务于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 + 33 + 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 * 1a / 1也会得到相同的结果,因为这些操作符也是仅为数字操作定义的。

那么对-操作符使用object值会怎样呢?和上面的+的故事相似:

var a = [3];
var b = [1];

a - b; // 2

两个array值都不得不变为number,但它们首先会被强制转换为string(使用意料之中的toString()序列化),然后再强制转换为number,以便-减法操作可以实施。

那么,stringnumber值之间的 隐含 强制转换还是你总是在恐怖故事当中听到的丑陋怪物吗?我个人不这么认为。

比较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(很明显,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,是因为虽然结果相同,但是这里有一个微妙的不同。