返回介绍

第一部分 类型和语法

第二部分 异步和性能

4.4 隐式强制类型转换

发布于 2023-05-24 16:38:21 字数 10632 浏览 0 评论 0 收藏 0

隐式强制类型转换 指的是那些隐蔽的强制类型转换,副作用也不是很明显。换句话说,你自己觉得不够明显的强制类型转换都可以算作隐式强制类型转换。

显式强制类型转换旨在让代码更加清晰易读,而隐式强制类型转换看起来就像是它的对立面,会让代码变得晦涩难懂。

对强制类型转换的诟病大多是针对隐式 强制类型转换。

《JavaScript 语言精粹》的作者 Douglas Crockford 在许多场合和文章中都主张不要使用强制类型转换,认为其非常糟糕。然而他的代码中也大量使用了隐式和显式强制类型转换。实际上他的吐槽大部分是针对 == 运算符,但读完本章你会发现这只是强制类型转换的冰山一角。

问题是,隐式强制类型转换真是如此不堪吗?它是不是 JavaScript 语言的设计缺陷?我们是否应该对其退避三舍?

估计大多数读者会回答“是的”。其实不然,请容我细细道来。

让我们从另一个角度来看待隐式强制类型转换,看看它究竟为何物、该如何使用,不要简单地把它当作“显式强制类型转换的对立面”,因为这样理解过于狭隘,忽略了它们之间一个细微却十分重要的区别。

隐式强制类型转换的作用是减少冗余,让代码更简洁。

4.4.1 隐式地简化

我们先来看一个例子,它不是 JavaScript 代码,而是强类型语言的伪代码:

SomeType x = SomeType( AnotherType( y ) )

其中变量 y 的值被转换为 SomeType 类 型。问题是语言本身不允许直接将 y 转换为 SomeType 类型。于是我们需要一个中间步骤,先将 y 转换为 AnotherType 类型,然后再从 AnotherType 转换为 SomeType 。

如果能够这样:

SomeType x = SomeType( y )

省去了中间步骤以后,类型转换变得更简洁了。这些无关紧要的中间步骤可以也应该被隐藏。

也许有些情况下这些中间步骤还是必要的,但是我觉得通过语言机制或定制方法来简化代码,抽象和隐藏那些细枝末节,有助于提高代码的可读性。

当然这些中间步骤仍然会发生在某处。通过隐藏这些细节,我们就可以专注于问题本身,这里是将变量 y 转换为 SomeType 类型。

虽然这并非是个十分恰当的隐式强制类型转换的例子,但我想说明的问题是,隐式强制类型转换同样可以用来提高代码可读性。

然而隐式强制类型转换也会带来一些负面影响,有时甚至是弊大于利。因此我们更应该学习怎样去其糟粕,取其精华。

很多开发人员认为如果某个机制有优点 A 但同时又有缺点 Z,为了保险起见不如全部弃之不用。

我不赞同这种“因噎废食”的做法。不要因为只看到了隐式强制类型转换的缺点就想当然地认为它一无是处。它也有好的方面,希望越来越多的开发人员能加以发现和运用。

4.4.2 字符串和数字之间的隐式强制类型转换

前面我们讲了字符串和数字之间的显式强制类型转换,现在介绍它们之间的隐式强制类型转换。先来看一些会产生隐式强制类型转换的操作。

通过重载,+ 运算符即能用于数字加法,也能用于字符串拼接。JavaScript 怎样来判断我们要执行的是哪个操作?例如:

var a = "42";
var b = "0";

var c = 42;
var d = 0;

a + b; // "420"
c + d; // 42

这里为什么会得到 "420" 和 42 两个不同的结果呢?通常的理解是,因为某一个或者两个操作数都是字符串,所以 + 执行的是字符串拼接操作。这样解释只对了一半,实际情况要复杂得多。

例如:

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

a 和 b 都不是字符串,但是它们都被强制转换为字符串然后进行拼接。原因何在?

下面两段内容与规范有关,如果太难理解可以跳过。

根据 ES5 规范 11.6.1 节,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+ 将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用 ToPrimitive 抽象操作(规范 9.1 节),该抽象操作再调用 [[DefaultValue]] (规范 8.12.8 节),以数字作为上下文。

你或许注意到这与 ToNumber 抽象操作处理对象的方式一样(参见 4.2.2 节)。因为数组的 valueOf() 操作无法得到简单基本类型值,于是它转而调用 toString() 。因此上例中的两个数组变成了 "1,2" 和 "3,4" 。+ 将它们拼接后返回 "1,23,4" 。

简单来说就是,如果 + 的其中一个操作数是字符串(或者通过以上步骤可以得到字符串),则执行字符串拼接;否则执行数字加法。

有一个坑常常被提到,即 [] + {} 和 {} + [] ,它们返回不同的结果,分别是 "[object Object]" 和 0 。我们将在 5.1.3 节详细介绍。

对隐式强制类型转换来说,这意味着什么?

我们可以将数字和空字符串 "" 相 + 来将其转换为字符串:

var a = 42;
var b = a + "";

b; // "42"

+ 作为数字加法操作是可互换的,即 2 + 3 等同于 3 + 2 。作为字符串拼接操作则不行,但对空字符串 "" 来说,a + "" 和 "" + a 结果一样。

a + "" 这样的隐式转换十分常见,一些对隐式强制类型转换持批评态度的人也不能免俗。

这本身就很能说明问题,无论怎样被人诟病,隐式强制类型转换 仍然有其用武之地。

a + "" (隐式)和前面的 String(a) (显式)之间有一个细微的差别需要注意。根据 ToPrimitive 抽象操作规则,a + "" 会对 a 调用 valueOf() 方法,然后通过 ToString 抽象操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString() 。

它们最后返回的都是字符串,但如果 a 是对象而非数字结果可能会不一样!

例如:

var a = {
  valueOf: function() { return 42; },
  toString: function() { return 4; }
};

a + "";     // "42"

String( a );  // "4"

你一般不太可能会遇到这个问题,除非你的代码中真的有这些匪夷所思的数据结构和操作。在定制 valueOf() 和 toString() 方法时需要特别小心,因为这会影响强制类型转换的结果。

再来看看从字符串强制类型转换为数字的情况。

var a = "3.14";
var b = a - 0;

b; // 3.14

- 是数字减法运算符,因此 a - 0 会将 a 强制类型转换为数字。也可以使用 a * 1 和 a / 1 ,因为这两个运算符也只适用于数字,只不过这样的用法不太常见。

对象的 - 操作与 + 类似:

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

a - b; // 2

为了执行减法运算,a 和 b 都需要被转换为数字,它们首先被转换为字符串(通过 toString() ),然后再转换为数字。

字符串和数字之间的隐式强制类型转换真如人们所说的那样糟糕吗?我个人不这么看。

b = String(a) (显式)和 b = a + "" (隐式)各有优点,b = a + "" 更常见一些。虽然饱受诟病,但隐式强制类型转换仍然有它的用处。

4.4.3 布尔值到数字的隐式强制类型转换

在将某些复杂的布尔逻辑转换为数字加法的时候,隐式强制类型转换能派上大用场。当然这种情况并不多见,属于特殊情况特殊处理。

例如:

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

如果其中有且仅有一个参数为 true ,则 onlyOne(..) 返回 true 。其在条件判断中使用了隐式强制类型转换,其他地方则是显式的,包括最后的返回值。

但如果有多个参数时(4 个、5 个,甚至 20 个),用上面的代码就很难处理了。这时就可以使用从布尔值到数字(0 或 1 )的强制类型转换:

function onlyOne() {
  var sum = 0;
  for (var i=0; i < arguments.length; i++) {
    // 跳过假值,和处理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(..) 函数。

通过 sum += arguments[i] 中的隐式强制类型转换,将真值(true/truthy)转换为 1 并进行累加。如果有且仅有一个参数为 true ,则结果为 1 ;否则不等于 1 ,sum == 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) (否则的话,字符串会执行拼接操作,这样结果就不对了)。

转换为布尔值以后,再通过 Number(..) 显式强制类型转换为 0 或 1 。

这里使用显式强制类型转换会不会更好一些?注释里说这样的确能够避免 NaN 带来的问题,不过最终是看我们自己的需要。我个人觉得前者,即隐式强制类型转换,更为简洁(前提是不会传递 undefined 和 NaN 这样的值),而显式强制类型转换则会带来一些代码冗余。

总之如本书一贯强调的那样,一切都取决于我们自己的判断和权衡。

无论使用隐式还是显式,我们都能通过修改 onlyTwo(..) 或者 onlyFive(..) 来处理更复杂的情况,只需要将最后的条件判断从 1 改为 2 或 5 。这比加入一大堆 && 和 || 表达式简洁得多。所以强制类型转换在这里还是很有用的。

4.4.4 隐式强制类型转换为布尔值

现在我们来看看到布尔值的隐式强制类型转换,它最为常见也最容易搞错。

相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。

(1) if (..) 语句中的条件判断表达式。

(2) for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。

(3) while (..) 和 do..while(..) 循环中的条件判断表达式。

(4) ? : 中的条件判断表达式。

(5) 逻辑运算符 || (逻辑或)和 && (逻辑与)左边的操作数(作为条件判断表达式)。

以上情况中,非布尔值会被隐式强制类型转换为布尔值,遵循前面介绍过的 ToBoolean 抽象操作规则。

例如:

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
}

上例中的非布尔值会被隐式强制类型转换为布尔值以便执行条件判断。

4.4.5 ||&&

逻辑运算符 || (或)和 && (与)应该并不陌生,也许正因为如此有人觉得它们在 JavaScript 中的表现也和在其他语言中一样。

这里面有一些非常重要但却不太为人所知的细微差别。

我其实不太赞同将它们称为“逻辑运算符”,因为这不太准确。称它们为“选择器运算符”(selector operators)或者“操作数选择器运算符”(operand selector operators)更恰当些。

为什么?因为和其他语言不同,在 JavaScript 中它们返回的并不是布尔值。

它们的返回值是两个操作数中的一个(且仅一个)。即选择两个操作数中的一个,然后返回它的值。

引述 ES5 规范 11.11 节:

&& 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

例如:

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 ,在 JavaScript(以及 Python 和 Ruby)中却是某个操作数的值。

|| 和 && 首先会对第一个操作数 (a 和 c )执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。

对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c )的值,如果为 false 就返回第二个操作数(b )的值。

&& 则相反,如果条件判断结果为 true 就返回第二个操作数(b )的值,如果为 false 就返回第一个操作数(a 和 c )的值。

|| 和 && 返回它们其中一个操作数的值,而非条件判断的结果(其中可能涉及强制类型转换)。c && b 中 c 为 null ,是一个假值,因此 && 表达式的结果是 null (即 c 的值),而非条件判断的结果 false 。

现在明白我为什么把它们叫作“操作数选择器”了吧?

换一个角度来理解:

a || b;
// 大致相当于(roughly equivalent to):
a ? a : b;

a && b;
// 大致相当于(roughly equivalent to):
a ? b : a;

之所以说大致相当,是因为它们返回结果虽然相同但是却有一个细微的差别。在 a ? a : b 中,如果 a 是一个复杂一些的表达式(比如有副作用的函数调用等),它有可能被执行两次(如果第一次结果为真)。而在 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# 的“空值合并运算符”的 JavaScript 版本)检查变量 a ,如果还未赋值(或者为假值),就赋予它一个默认值("hello" )。

这里需要注意!

foo( "That's it!", "" ); // "That's it! world" <-- 晕!

第二个参数 "" 是一个假值(falsy value,参见 4.2.3 节),因此 b = b || "world" 条件不成立,返回默认值 "world" 。

这种用法很常见,但是其中不能有假值,除非加上更明确的条件判断,或者转而使用 ? : 三元表达式。

通过这种方式来设置默认值很方便,甚至那些公开诟病 JavaScript 强制类型转换的人也经常使用。

再来看看 && 。

有一种用法对开发人员不常见,然而 JavaScript 代码压缩工具常用。就是如果第一个操作数为真值,则 && 运算符“选择”第二个操作数作为返回值,这也叫作“守护运算符”(guard operator,参见 5.2.1 节),即前面的表达式为后面的表达式“把关”:

function foo() {
  console.log( a );
}

var a = 42;

a && foo(); // 42

foo() 只有在条件判断 a 通过时才会被调用。如果条件判断未通过,a && foo() 就会悄然终止(也叫作“短路”,short circuiting),foo() 不会被调用。

这样的用法对开发人员不太常见,开发人员通常使用 if (a) { foo(); } 。但 JavaScript 代码压缩工具用的是 a && foo() ,因为更简洁。以后再碰到这样的代码你就知道是怎么回事了。

|| 和 && 各自有它们的用武之地,前提是我们理解并且愿意在代码中运用隐式强制类型转换。

a = b || "something" 和 a && b() 用到了“短路”机制,我们将在 5.2.1 节详细介绍。

你大概会有疑问:既然返回的不是 true 和 false ,为什么 a && (b || c) 这样的表达式在 if 和 for 中没出过问题?

这或许并不是代码的问题,问题在于你可能不知道这些条件判断表达式最后还会执行布尔值的隐式强制类型转换。

例如:

var a = 42;
var b = null;
var c = "foo";

if (a && (b || c)) {
  console.log( "yep" );
}

这里 a && (b || c) 的结果实际上是 "foo" 而非 true ,然后再由 if 将 foo 强制类型转换为布尔值,所以最后结果为 true 。

现在明白了吧,这里发生了隐式强制类型转换。如果要避免隐式强制类型转换就得这样:

if (!!a && (!!b || !!c)) {
  console.log( "yep" );
}

4.4.6 符号的强制类型转换

目前我们介绍的显式和隐式强制类型转换结果是一样的,它们之间的差异仅仅体现在代码可读性方面。

但 ES6 中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。ES6 允许从符号到字符串的显式 强制类型转换,然而隐式 强制类型转换会产生错误,具体的原因不在本书讨论范围之内。

例如:

var s1 = Symbol( "cool" );
String( s1 );   // "Symbol(cool)"

var s2 = Symbol( "not cool" );
s2 + "";    // TypeError

符号不能够被强制类型转换为数字(显式和隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式结果都是 true )。

由于规则缺乏一致性,我们要对 ES6 中符号的强制类型转换多加小心。

好在鉴于符号的特殊用途(参见第 3 章),我们不会经常用到它的强制类型转换。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文