第四章:强制转换 - 抽象值操作
抽象值操作
在我们可以探究 明确 与 隐含 强制转换之前,我们需要学习一些基本规则,是它们控制着值如何 变成 一个 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;
// 7次乘以3位 => 21位
a.toString(); // "1.07e21"
对于普通的对象,除非你指定你自己的,默认的 toString()
(可以在 Object.prototype.toString()
找到)将返回 内部 [[Class]]
(见第三章),例如 "[object Object]"
。
但正如早先所展示的,如果一个对象上拥有它自己的 toString()
方法,而你又以一种类似 string
的方式使用这个对象,那么它的 toString()
将会被自动调用,而且这个调用的 string
结果将被使用。
注意: 技术上讲,一个对象被强制转换为一个 string
要通过 ToPrimitive
抽象操作(ES5 语言规范,9.1 部分),但是那其中的微妙细节将会在本章稍后的 ToNumber
部分中讲解,所以我们在这里先跳过它。
数组拥有一个覆盖版本的默认 toString()
,将数组字符串化为它所有的值(每个都字符串化)的(字符串)连接,并用 ","
分割每个值。
var a = [1,2,3];
a.toString(); // "1,2,3"
重申一次,toString()
可以明确地被调用,也可以通过在一个需要 string
的上下文环境中使用一个非 string
来自动地被调用。
JSON 字符串化
另一种看起来与 ToString
密切相关的操作是,使用 JSON.stringify(..)
工具将一个值序列化为一个 JSON 兼容的 string
值。
重要的是要注意,这种字符串化与强制转换并不完全是同一种东西。但是因为它与上面讲的 ToString
规则有关联,我们将在这里稍微转移一下话题,来讲解 JSON 字符串化行为。
对于最简单的值,JSON 字符串化行为基本上和 toString()
转换是相同的,除了序列化的结果 总是一个 string
:
JSON.stringify( 42 ); // "42"
JSON.stringify( "42" ); // ""42"" (一个包含双引号的字符串)
JSON.stringify( null ); // "null"
JSON.stringify( true ); // "true"
任何 JSON 安全 的值都可以被 JSON.stringify(..)
字符串化。但是什么是 JSON 安全的?任何可以用 JSON 表现形式合法表达的值。
考虑 JSON 不 安全的值可能更容易一些。一些例子是:undefined
、function
、(ES6+)symbol
、和带有循环引用的 object
(一个对象结构中的属性互相引用而造成了一个永不终结的循环)。对于标准的 JSON 结构来说这些都是非法的值,主要是因为它们不能移植到消费 JSON 值的其他语言中。
JSON.stringify(..)
工具在遇到 undefined
、function
、和 symbol
时将会自动地忽略它们。如果在一个 array
中遇到这样的值,它会被替换为 null
(这样数组的位置信息就不会改变)。如果在一个 object
的属性中遇到这样的值,这个属性会被简单地剔除掉。
考虑下面的代码:
JSON.stringify( undefined ); // undefined
JSON.stringify( function(){} ); // undefined
JSON.stringify( [1,undefined,function(){},4] ); // "[1,null,null,4]"
JSON.stringify( { a:2, b:function(){} } ); // "{"a":2}"
但如果你试着 JSON.stringify(..)
一个带有循环引用的 object
,就会抛出一个错误。
JSON 字符串化有一个特殊行为,如果一个 object
值定义了一个 toJSON()
方法,这个方法将会被首先调用,以取得用于序列化的值。
如果你打算 JSON 字符串化一个可能含有非法 JSON 值的对象,或者如果这个对象中正好有不适于序列化的值,那么你就应当为它定义一个 toJSON()
方法,返回这个 object
的一个 JSON 安全 版本。
例如:
var o = { };
var a = {
b: 42,
c: o,
d: function(){}
};
// 在 `a` 内部制造一个循环引用
o.e = a;
// 这会因循环引用而抛出一个错误
// JSON.stringify( a );
// 自定义一个 JSON 值序列化
a.toJSON = function() {
// 序列化仅包含属性 `b`
return { b: this.b };
};
JSON.stringify( a ); // "{"b":42}"
一个很常见的误解是,toJSON()
应当返回一个 JSON 字符串化的表现形式。这可能是不正确的,除非你事实上想要字符串化 string
本身(通常不会!)。toJSON()
应当返回合适的实际普通值(无论什么类型),而 JSON.stringify(..)
自己会处理字符串化。
换句话说,toJSON()
应当被翻译为:“变为一个适用于字符串化的 JSON 安全的值”,而不是像许多开发者错误认为的那样,“变为一个 JSON 字符串”。
考虑下面的代码:
var a = {
val: [1,2,3],
// 可能正确!
toJSON: function(){
return this.val.slice( 1 );
}
};
var b = {
val: [1,2,3],
// 可能不正确!
toJSON: function(){
return "[" +
this.val.slice( 1 ).join() +
"]";
}
};
JSON.stringify( a ); // "[2,3]"
JSON.stringify( b ); // ""[2,3]""
在第二个调用中,我们字符串化了返回的 string
而不是 array
本身,这可能不是我们想要做的。
既然我们说到了 JSON.stringify(..)
,那么就让我们来讨论一些不那么广为人知,但是仍然很有用的功能吧。
JSON.stringify(..)
的第二个参数值是可选的,它称为 替换器(replacer)。这个参数值既可以是一个 array
也可以是一个 function
。与 toJSON()
为序列化准备一个值的方式类似,它提供一种过滤机制,指出一个 object
的哪一个属性应该或不应该被包含在序列化形式中,来自定义这个 object
的递归序列化行为。
如果 替换器 是一个 array
,那么它应当是一个 string
的 array
,它的每一个元素指定了允许被包含在这个 object
的序列化形式中的属性名称。如果一个属性不存在于这个列表中,那么它就会被跳过。
如果 替换器 是一个 function
,那么它会为 object
本身而被调用一次,并且为这个 object
中的每个属性都被调用一次,而且每次都被传入两个参数值,key 和 value。要在序列化中跳过一个 key,可以返回 undefined
。否则,就返回被提供的 value。
var a = {
b: 42,
c: "42",
d: [1,2,3]
};
JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"
JSON.stringify( a, function(k,v){
if (k !== "c") return v;
} );
// "{"b":42,"d":[1,2,3]}"
注意: 在 function
替换器 的情况下,第一次调用时 key 参数 k
是 undefined
(而对象 a
本身会被传入)。if
语句会 过滤掉 名称为 c
的属性。字符串化是递归的,所以数组 [1,2,3]
会将它的每一个值(1
、2
、和 3
)都作为 v
传递给 替换器,并将索引值(0
、1
、和 2
)作为 k
。
JSON.stringify(..)
还可以接收第三个可选参数值,称为 填充符(space),在对人类友好的输出中它被用做缩进。填充符 可以是一个正整数,用来指示每一级缩进中应当使用多少个空格字符。或者,填充符 可以是一个 string
,这时每一级缩进将会使用它的前十个字符。
var a = {
b: 42,
c: "42",
d: [1,2,3]
};
JSON.stringify( a, null, 3 );
// "{
// "b": 42,
// "c": "42",
// "d": [
// 1,
// 2,
// 3
// ]
// }"
JSON.stringify( a, null, "-----" );
// "{
// -----"b": 42,
// -----"c": "42",
// -----"d": [
// ----------1,
// ----------2,
// ----------3
// -----]
// }"
记住,JSON.stringify(..)
并不直接是一种强制转换的形式。但是,我们在这里讨论它,是由于两个与 ToString
强制转换有关联的行为:
string
、number
、boolean
、和null
值在 JSON 字符串化时,与它们通过ToString
抽象操作的规则强制转换为string
值的方式基本上是相同的。- 如果传递一个
object
值给JSON.stringify(..)
,而这个object
上拥有一个toJSON()
方法,那么在字符串化之前,toJSON()
就会被自动调用来将这个值(某种意义上)“强制转换”为 JSON 安全 的。
ToNumber
如果任何非 number
值,以一种要求它是 number
的方式被使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的 ToNumber
抽象操作。
例如,true
变为 1
而 false
变为 0
。undefined
变为 NaN
,而(奇怪的是)null
变为 0
。
对于一个 string
值来说,ToNumber
工作起来很大程度上与数字字面量的规则/语法很相似(见第三章)。如果它失败了,结果将是 NaN
(而不是 number
字面量中会出现的语法错误)。一个不同之处的例子是,在这个操作中 0
前缀的八进制数不会被作为八进制数来处理(而仅作为普通的十进制小数),虽然这样的八进制数作为 number
字面量是合法的。
注意: number
字面量文法与用于 string
值的 ToNumber
间的区别极其微妙,在这里就不进一步讲解了。更多的信息可以参考 ES 语言规范的 9.3.1 部分。
对象(以及数组)将会首先被转换为它们的基本类型值的等价物,而后这个结果值(如果它还不是一个 number
基本类型)会根据刚才提到的 ToNumber
规则被强制转换为一个 number
。
为了转换为基本类型值的等价物,ToPrimitive
抽象操作(ES5 语言规范,9.1 部分)将会查询这个值(使用内部的 DefaultValue
操作 —— ES5 语言规范,8.12.8 部分),看它有没有 valueOf()
方法。如果 valueOf()
可用并且它返回一个基本类型值,那么 这个 值就将用于强制转换。如果不是这样,但 toString()
可用,那么就由它来提供用于强制转换的值。
如果这两种操作都没提供一个基本类型值,就会抛出一个 TypeError
。
在 ES5 中,你可以创建这样一个不可强制转换的对象 —— 没有 valueOf()
和 toString()
—— 如果它的 [[Prototype]]
的值为 null
,这通常是通过 Object.create(null)
来创建的。关于 [[Prototype]]
的详细信息参见本系列的 this 与对象原型。
注意: 我们会在本章稍后讲解如何强制转换至 number
,但对于下面的代码段,想象 Number(..)
函数就是那样做的。
考虑如下代码:
var a = {
valueOf: function(){
return "42";
}
};
var b = {
toString: function(){
return "42";
}
};
var c = [4,2];
c.toString = function(){
return this.join( "" ); // "42"
};
Number( a ); // 42
Number( b ); // 42
Number( c ); // 42
Number( "" ); // 0
Number( [] ); // 0
Number( [ "abc" ] ); // NaN
ToBoolean
下面,让我们聊一聊在 JS 中 boolean
如何动作。世面上关于这个话题有 许多的困惑和误解,所以集中注意力!
首先而且最重要的是,JS 实际上拥有 true
和 false
关键字,而且它们的行为正如你所期望的 boolean
值一样。一个常见的误解是,值 1
和 0
与 true
/false
是相同的。虽然这可能在其他语言中是成立的,但在 JS 中 number
就是 number
,而 boolean
就是 boolean
。你可以将 1
强制转换为 true
(或反之),或将 0
强制转换为 false
(或反之)。但它们不是相同的。
Falsy 值
但这还不是故事的结尾。我们需要讨论一下,除了这两个 boolean
值以外,当你把其他值强制转换为它们的 boolean
等价物时如何动作。
所有的 JavaScript 值都可以被划分进两个类别:
- 如果被强制转换为
boolean
,将成为false
的值 - 其它的一切值(很明显将变为
true
)
我不是在出洋相。JS 语言规范给那些在强制转换为 boolean
值时将会变为 false
的值定义了一个明确的,小范围的列表。
我们如何才能知道这个列表中的值是什么?在 ES5 语言规范中,9.2 部分定义了一个 ToBoolean
抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。
从这个表格中,我们得到了下面所谓的“falsy”值列表:
undefined
null
false
+0
,-0
, andNaN
""
就是这些。如果一个值在这个列表中,它就是一个“falsy”值,而且当你在它上面进行 boolean
强制转换时它会转换为 false
。
通过逻辑上的推论,如果一个值 不 在这个列表中,那么它一定在 另一个列表 中,也就是我们称为“truthy”值的列表。但是 JS 没有真正定义一个“truthy”列表。它给出了一些例子,比如它说所有的对象都是 truthy,但是语言规范大致上暗示着:任何没有明确地存在于 falsy 列表中的东西,都是 truthy。
Falsy 对象
等一下,这一节的标题听起来简直是矛盾的。我 刚刚才说过 语言规范将所有对象称为 truthy,对吧?应该没有“falsy 对象”这样的东西。
这会是什么意思呢?
它可能诱使你认为它意味着一个包装了 falsy 值(比如 ""
、0
或 false
)的对象包装器(见第三章)。但别掉到这个 陷阱 中。
注意: 这个可能是一个语言规范的微妙笑话。
考虑下面的代码:
var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );
我们知道这三个值都是包装了明显是 falsy 值的对象(见第三章)。但这些对象是作为 true
还是作为 false
动作呢?这很容易回答:
var d = Boolean( a && b && c );
d; // true
所以,三个都作为 true
动作,这是唯一能使 d
得到 true
的方法。
提示: 注意包在 a && b && c
表达式外面的 Boolean( .. )
—— 你可能想知道为什么它在这儿。我们会在本章稍后回到这个话题,所以先做个心理准备。为了先睹为快,你可以自己试试如果没有 Boolean( .. )
调用而只有 d = a && b && c
时 d
是什么。
那么,如果“falsy 对象” 不是包装着 falsy 值的对象,它们是什么鬼东西?
刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JavaScript 本身的一部分。
什么!?
有些特定的情况,在普通的 JS 语义之上,浏览器已经创建了它们自己的某种 外来 值的行为,也就是这种“falsy 对象”的想法。
一个“falsy 对象”看起来和动起来都像一个普通对象(属性,等等)的值,但是当你强制转换它为一个 boolean
时,它会变为一个 false
值。
为什么!?
最著名的例子是 document.all
:一个 由 DOM(不是 JS 引擎本身) 给你的 JS 程序提供的类数组(对象),它向你的 JS 程序暴露你页面上的元素。它 曾经 像一个普通对象那样动作 —— 是一个 truthy。但不再是了。
document.all
本身从来就不是“标准的”,而且从很早以前就被废弃/抛弃了。
“那他们就不能删掉它吗?” 对不起,想得不错。但愿它们能。但是世面上有太多的遗产 JS 代码库依赖于它。
那么,为什么使它像 falsy 一样动作?因为从 document.all
到 boolean
的强制转换(比如在 if
语句中)几乎总是用来检测老的,非标准的 IE。
IE 从很早以前就开始顺应规范了,而且在许多情况下它在推动 web 向前发展的作用和其他浏览器一样多,甚至更多。但是所有那些老旧的 if (document.all) { /* it's IE */ }
代码依然留在世面上,而且大多数可能永远都不会消失。所有这些遗产代码依然假设它们运行在那些给 IE 用户带来差劲儿的浏览体验的,几十年前的老 IE 上,
所以,我们不能完全移除 document.all
,但是 IE 不再想让 if (document.all) { .. }
代码继续工作了,这样现代 IE 的用户就能得到新的,符合标准的代码逻辑。
“我们应当怎么做?” “我知道了!让我们黑进 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
值的话,它将如何动作。现在你的大脑中有了这两个列表,我们可以深入强制转换的例子本身了。