第四章:强制转换 - 明确的强制转换

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

明确的强制转换

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

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

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

明确地:Strings <—> Numbers

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

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

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

  1. var a = 42;
  2. var b = String( a );
  3. var c = "3.14";
  4. var d = Number( c );
  5. b; // "42"
  6. d; // 3.14

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

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

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

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

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

  1. var a = 42;
  2. var b = a.toString();
  3. var c = "3.14";
  4. var d = +c;
  5. b; // "42"
  6. d; // 3.14

调用a.toString()在表面上是明确的(“toString”意味着“变成一个字符串”是很明白的),但是这里有一些藏起来的隐含性。toString()不能在像42这样的 基本类型 值上调用。所以JS会自动地将42“封箱”在一个对象包装器中(见第三章),这样toString()就可以针对这个对象调用。换句话讲,你可能会叫它“明确的隐含”。

这里的+c+操作符的 一元操作符(操作符只有一个操作数)形式。取代进行数学加法(或字符串连接 —— 见下面的讨论)的是,一元的+明确地将它的操作数(c)强制转换为一个number值。

+c明确的 强制转换吗?这要看你的经验和角度。如果你知道(现在你知道了!)一元+明确地意味着number强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。

注意: 在开源的JS社区中一般被接受的观点是,一元+是一个 明确的 强制转换形式。

即使你真的喜欢+c这种形式,它绝对会在有的地方看起来非常令人困惑。考虑下面的代码:

  1. var c = "3.14";
  2. var d = 5+ +c;
  3. d; // 8.14

一元-操作符也像+一样进行强制转换,但它还会翻转数字的符号。但是你不能放两个减号--来使符号翻转回来,因为那将被解释为递减操作符。取代它的是,你需要这么做:- -"3.14",在两个减号之间加入空格,这将会使强制转换的结果为3.14

你可能会想到所有种类的可怕组合 —— 一个二元操作符挨着另一个操作符的一元形式。这里有另一个疯狂的例子:

  1. 1 + - + + + - + 1; // 2

当一个一元+(或-)紧邻其他操作符时,你应当强烈地考虑避免使用它。虽然上面的代码可以工作,但几乎全世界都认为它是一个坏主意。即使是d = +c(或者d =+ c!)都太容易与d += c像混淆了,而后者完全是不同的东西!

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

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

Datenumber

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

  1. var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );
  2. +d; // 1408369986000

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

  1. var timestamp = +new Date();

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

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

  1. var timestamp = new Date().getTime();
  2. // var timestamp = (new Date()).getTime();
  3. // var timestamp = (new Date).getTime();

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

  1. var timestamp = Date.now();

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

  1. if (!Date.now) {
  2. Date.now = function() {
  3. return +new Date();
  4. };
  5. }

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

奇异的~

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

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

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

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

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

  1. 0 | -0; // 0
  2. 0 | NaN; // 0
  3. 0 | Infinity; // 0
  4. 0 | -Infinity; // 0

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

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

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

注意: 这与!不仅强制转换它的值为boolean而且还翻转它的每一位很相似(见后面关于“一元!”的讨论)。

但是……什么!?为什么我们要关心被翻转的比特位?这是一些相当特殊的,微妙的东西。JS开发者需要推理个别比特位是十分少见的。

另一种考虑~定义的方法是,~源自学校中的计算机科学/离散数学:~进行二进制取补操作。太好了,谢谢,我完全明白了!

我们再试一次:~x大致与-(x+1)相同。这很奇怪,但是稍微容易推理一些。所以:

  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值。这就是开发者们通常如何进行这样的检查:

  1. var a = "Hello World";
  2. if (a.indexOf( "lo" ) >= 0) { // true
  3. // 找到了!
  4. }
  5. if (a.indexOf( "lo" ) != -1) { // true
  6. // 找到了
  7. }
  8. if (a.indexOf( "ol" ) < 0) { // true
  9. // 没找到!
  10. }
  11. if (a.indexOf( "ol" ) == -1) { // true
  12. // 没找到!
  13. }

我感觉看着>= 0== -1有些恶心。它基本上是一种“抽象泄漏”,这里它将底层的实现行为 —— 使用哨兵值-1表示“失败” —— 泄漏到我的代码中。我倒是乐意隐藏这样的细节。

现在,我们终于看到为什~可以帮到我们了!将~indexOf()一起使用可以将值“强制转换”(实际上只是变形)为 可以适当地强制转换为boolean的值

  1. var a = "Hello World";
  2. ~a.indexOf( "lo" ); // -4 <-- truthy!
  3. if (~a.indexOf( "lo" )) { // true
  4. // 找到了!
  5. }
  6. ~a.indexOf( "ol" ); // 0 <-- falsy!
  7. !~a.indexOf( "ol" ); // true
  8. if (!~a.indexOf( "ol" )) { // true
  9. // 没找到!
  10. }

~拿到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(..)不同!

  1. Math.floor( -49.6 ); // -50
  2. ~~-49.6; // -49

Math.floor(..)的不同放在一边,~~x可以将值截断为一个(32位)整数。但是x | 0也可以,而且看起来还(稍微)省事儿 一些。

那么,为什么你可能会选择~~x而不是x | 0?操作符优先权(见第五章):

  1. ~~1E20 / 10; // 166199296
  2. 1E20 | 0 / 10; // 1661992960
  3. (1E20 | 0) / 10; // 166199296

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

明确地:解析数字字符串

将一个string强制转换为一个number的类似结果,可以通过从string的字符内容中解析(parsing)出一个number得到。然而在这种解析和我们上面讲解的类型转换之间存在着区别。

考虑下面的代码:

  1. var a = "42";
  2. var b = "42px";
  3. Number( a ); // 42
  4. parseInt( a ); // 42
  5. Number( b ); // NaN
  6. 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进制数字的猜测过于常见了。比如:

  1. var hour = parseInt( selectedHour.value );
  2. var minute = parseInt( selectedMinute.value );
  3. console.log( "The time you selected was: " + hour + ":" + minute);

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

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

  1. var hour = parseInt( selectedHour.value, 10 );
  2. var minute = parseInt( selectedMiniute.value, 10 );

在ES5中,parseInt(..)不再猜测八进制数了。除非你指定,否则它会假定为10进制(或者为"0x"前缀猜测16进制数)。这好多了。只是要小心,如果你的代码不得不运行在前ES5环境中,你仍然需要为基数传递10

解析非字符串

几年以前有一个挖苦JS的玩笑,使一个关于parseInt(..)行为的一个臭名昭著的例子备受关注,它取笑JS的这个行为:

  1. parseInt( 1/0, 19 ); // 18

这里面设想(但完全不合法)的断言是,“如果我传入一个无限大,并从中解析出一个整数的话,我应该得到一个无限大,不是18”。没错,JS一定是疯了才得出这个结果,对吧?

虽然这是个明显故意造成的,不真实的例子,但是让我们放纵这种疯狂一小会儿,来检视一下JS是否真的那么疯狂。

首先,这其中最明显的原罪是将一个非string传入了parseInt(..)。这是不对的。这么做是自找麻烦。但就算你这么做了,JS也会礼貌地将你传入的东西强制转换为它可以解析的string

有些人可能会争论说这是一种不合理的行为,parseInt(..)应当拒绝在一个非string值上操作。它应该抛出一个错误吗?坦白地说,像Java那样。但是一想到JS应当开始在满世界抛出错误,以至于几乎每一行代码都需要用try..catch围起来,我就不寒而栗。

它应当返回NaN吗?也许。但是……要是这样呢:

  1. parseInt( new String( "42") );

这也应当失败吗?它是一个非string值啊。如果你想让String对象包装器被开箱成"42",那么42先变成"42",以使42可以被解析回来就那么不寻常吗?

我会争论说,这种可能发生的半 明确隐含 的强制转换经常可以成为非常有用的东西。比如:

  1. var a = {
  2. num: 21,
  3. toString: function() { return String( this.num * 2 ); }
  4. };
  5. 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(..)行为的,令人吃惊但又十分合理的例子还包括:

  1. parseInt( 0.000008 ); // 0 ("0" from "0.000008")
  2. parseInt( 0.0000008 ); // 8 ("8" from "8e-7")
  3. parseInt( false, 16 ); // 250 ("fa" from "false")
  4. parseInt( parseInt, 16 ); // 15 ("f" from "function..")
  5. parseInt( "0x10" ); // 16
  6. parseInt( "103", 2 ); // 2

其实parseInt(..)在它的行为上是相当可预见和一致的。如果你正确地使用它,你就能得到合理的结果。如果你不正确地使用它,那么你得到的疯狂结果并不是JavaScript的错。

明确地:* —> Boolean

现在,我们来检视从任意的非boolean值到一个boolean值的强制转换。

正如上面的String(..)Number(..)Boolean(..)(当然,不带new!)是强制进行ToBoolean转换的明确方法:

  1. var a = "0";
  2. var b = [];
  3. var c = {};
  4. var d = "";
  5. var e = 0;
  6. var f = null;
  7. var g;
  8. Boolean( a ); // true
  9. Boolean( b ); // true
  10. Boolean( c ); // true
  11. Boolean( d ); // false
  12. Boolean( e ); // false
  13. Boolean( f ); // false
  14. Boolean( g ); // false

虽然Boolean(..)是非常明确的,但是它并不常见也不为人所惯用。

正如一元+操作符将一个值强制转换为一个number(参见上面的讨论),一元的!否定操作符可以将一个值明确地强制转换为一个boolean问题 是它还将值从truthy翻转为falsy,或反之。所以,大多数JS开发者使用!!双否定操作符进行boolean强制转换,因为第二个!将会把它翻转回原本的true或false:

  1. var a = "0";
  2. var b = [];
  3. var c = {};
  4. var d = "";
  5. var e = 0;
  6. var f = null;
  7. var g;
  8. !!a; // true
  9. !!b; // true
  10. !!c; // true
  11. !!d; // false
  12. !!e; // false
  13. !!f; // false
  14. !!g; // false

没有Boolean(..)!!的话,任何这些ToBoolean强制转换都将 隐含地 发生,比如在一个if (..) ..语句这样使用boolean的上下文中。但这里的目标是,明确地强制一个值成为boolean来使ToBoolean强制转换的意图显得明明白白。

另一个ToBoolean强制转换的用例是,如果你想在数据结构的JSON序列化中强制转换一个true/false

  1. var a = [
  2. 1,
  3. function(){ /*..*/ },
  4. 2,
  5. function(){ /*..*/ }
  6. ];
  7. JSON.stringify( a ); // "[1,null,2,null]"
  8. JSON.stringify( a, function(key,val){
  9. if (typeof val == "function") {
  10. // 强制函数进行 `ToBoolean` 转换
  11. return !!val;
  12. }
  13. else {
  14. return val;
  15. }
  16. } );
  17. // "[1,true,2,true]"

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

  1. var a = 42;
  2. var b = a ? true : false;

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

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

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

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