第四章:强制转换 - 抽象值操作

优质
小牛编辑
143浏览
2023-12-01

抽象值操作

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

ToString

当任何一个非 string 值被强制转换为一个 string 表现形式时,这个转换的过程是由语言规范的 9.8 部分的 ToString 抽象操作处理的。

内建的基本类型值拥有自然的字符串化形式:null 变为 "null"undefined 变为 "undefined"true 变为 "true"number 一般会以你期望的自然方式表达,但正如我们在第二章中讨论的,非常小或非常大的 number 将会以指数形式表达:

  1. // `1.07`乘以`1000`,7次
  2. var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
  3. // 7次乘以3位 => 21位
  4. a.toString(); // "1.07e21"

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

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

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

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

  1. var a = [1,2,3];
  2. a.toString(); // "1,2,3"

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

JSON 字符串化

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

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

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

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

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

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

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

考虑下面的代码:

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

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

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

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

例如:

  1. var o = { };
  2. var a = {
  3. b: 42,
  4. c: o,
  5. d: function(){}
  6. };
  7. // 在 `a` 内部制造一个循环引用
  8. o.e = a;
  9. // 这会因循环引用而抛出一个错误
  10. // JSON.stringify( a );
  11. // 自定义一个 JSON 值序列化
  12. a.toJSON = function() {
  13. // 序列化仅包含属性 `b`
  14. return { b: this.b };
  15. };
  16. JSON.stringify( a ); // "{"b":42}"

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

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

考虑下面的代码:

  1. var a = {
  2. val: [1,2,3],
  3. // 可能正确!
  4. toJSON: function(){
  5. return this.val.slice( 1 );
  6. }
  7. };
  8. var b = {
  9. val: [1,2,3],
  10. // 可能不正确!
  11. toJSON: function(){
  12. return "[" +
  13. this.val.slice( 1 ).join() +
  14. "]";
  15. }
  16. };
  17. JSON.stringify( a ); // "[2,3]"
  18. 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

  1. var a = {
  2. b: 42,
  3. c: "42",
  4. d: [1,2,3]
  5. };
  6. JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"
  7. JSON.stringify( a, function(k,v){
  8. if (k !== "c") return v;
  9. } );
  10. // "{"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,这时每一级缩进将会使用它的前十个字符。

  1. var a = {
  2. b: 42,
  3. c: "42",
  4. d: [1,2,3]
  5. };
  6. JSON.stringify( a, null, 3 );
  7. // "{
  8. // "b": 42,
  9. // "c": "42",
  10. // "d": [
  11. // 1,
  12. // 2,
  13. // 3
  14. // ]
  15. // }"
  16. JSON.stringify( a, null, "-----" );
  17. // "{
  18. // -----"b": 42,
  19. // -----"c": "42",
  20. // -----"d": [
  21. // ----------1,
  22. // ----------2,
  23. // ----------3
  24. // -----]
  25. // }"

记住,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(..) 函数就是那样做的。

考虑如下代码:

  1. var a = {
  2. valueOf: function(){
  3. return "42";
  4. }
  5. };
  6. var b = {
  7. toString: function(){
  8. return "42";
  9. }
  10. };
  11. var c = [4,2];
  12. c.toString = function(){
  13. return this.join( "" ); // "42"
  14. };
  15. Number( a ); // 42
  16. Number( b ); // 42
  17. Number( c ); // 42
  18. Number( "" ); // 0
  19. Number( [] ); // 0
  20. 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)的对象包装器(见第三章)。但别掉到这个 陷阱 中。

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

考虑下面的代码:

  1. var a = new Boolean( false );
  2. var b = new Number( 0 );
  3. var c = new String( "" );

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

  1. var d = Boolean( a && b && c );
  2. 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

考虑下面代码:

  1. var a = "false";
  2. var b = "0";
  3. var c = "''";
  4. var d = Boolean( a && b && c );
  5. d;

你期望这里的 d 是什么值?它要么是 true 要么是 false

它是 true。为什么?因为尽管这些string值的内容看起来是falsy值,但是string值本身都是truthy,而这是因为在falsy列表中""是唯一的string值。

那么这些呢?

  1. var a = []; // 空数组 -- truthy 还是 falsy?
  2. var b = {}; // 空对象 -- truthy 还是 falsy?
  3. var c = function(){}; // 空函数 -- truthy 还是 falsy?
  4. var d = Boolean( a && b && c );
  5. d;

是的,你猜到了,这里的d依然是true。为什么?和前面的原因一样。尽管它们看起来像,但是[]{},和function(){} 不在 falsy列表中,因此它们是truthy值。

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

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

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