Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

细读 JS |(隐式)数据类型转换详解 #242

Open
toFrankie opened this issue Feb 26, 2023 · 0 comments
Open

细读 JS |(隐式)数据类型转换详解 #242

toFrankie opened this issue Feb 26, 2023 · 0 comments
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章

Comments

@toFrankie
Copy link
Owner

配图源自 Freepik

在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主动显式地去转换,但 JavaScript 在私底下“偷偷地”帮我们做了很多类型转换的工作。那么,它们究竟是按照什么规则去转换的呢?我们试图在本文中找到答案,来解开谜底。

ECMAScript 是负责制定标准的,而 JavaScript 则是前者的一种实现。

在 ECMAScript 标准中定义一组转换抽象操作,常见的抽象操作(Abstract Operation)有 ToPrimitiveToBooleanToNumberToStringToObject 等等。

一、ToPrimitive

1. ToPrimitive

在 ECMAScript 标准中,使用 ToPrimitive 抽象操作将引用类型转换为原始类型。(详情看 #sec-7.1.1

ToPrimitive Abstract Operation

ToPrimitive(input[, PerferredType])

参数 input 是文章开头提到的 8 种数据类型的值(Undefined、Null、Boolean、String、Symbol、Number、BigInt、Object)。参数 PreferredType 是可选的,表示要转换到的原始值的预期类型,取值只能是字符串 "default"(默认)、"string""number" 之一。

ToPrimitive 操作,可概括如下:

  1. input 是 ECMAScript 语言类型的值。
  2. 如果 input 是引用类型,那么
    1. 如果没有传递 PreferredType 参数,使 hint 等于 "default"
    2. 如果参数 PreferredType 提示 String 类型,使 hint 等于 "string"
    3. 否则,参数 PreferredType 提示 Number 类型,使 hint 等于 "number"
    4. 使 exoticToPrim 等于 GetMethod(input, @@toPrimitive) 的结果(大致意思是,获取 input 对象的 @@toPrimitive 属性值,并将其赋给 exoticToPrim)。
    5. 如果 exoticToPrim 不等于 undefined(即 input 对象含 @@toPrimitive 属性),那么
      1. 使 result 等于 Call(exoticToPrim, input, « hint ») 的结果(大致意思是,执行 @@toPrimitive 方法,即 exoticToPrim(hint))。
      2. 如果 result 不是引用类型,则返回 result
      3. 否则抛出 TypeError 类型错误。
    6. 如果 hint"default",则将 hint 设为 "number"
    7. 返回 OrdinaryToPrimitive(input, hint)
  3. 返回 input(即原始类型的值直接返回)。

注意:当不带 hint 去调用 ToPrimitive 抽象操作时,通常它的行为就像 hintNumber 类型一样。但是,(派生)对象可以通过定义 @@toPrimitive 方法来替代此行为。在本规范中定义的对象里,只有 Date 对象(详情看 #sec-20.4.4.45)和 Symbol 对象(详情看 #sec-19.4.3.5)会覆盖默认的 ToPrimitive 行为。

其他:

  1. 以上提到的 @@toPrimitive 方法,即属性名称为 [Symobl.toPrimitive] 的方法。
  2. Date 对象的 @@toPrimitive 方法定义:当 hint"default" 时,会使 hint 等于 "string"。所以这也是 Date 对象转换为原始值时,会先调用 instance.toString() 方法,而不是 instance.valueOf() 方法的原因。
  3. 目前 JavaScript 的内置对象中,含有 @@toPrimitive 方法的,只有 DateSymbol 对象。

用口水话再总结一下,如下(哈哈):

  1. 如果 input 是原始类型,直接返回 input(不做转换操作)。
  2. 如果参数 PreferredType 是 String(Number)类型,那么使得 hint 等于 "string""number"),否则 hint 等于默认的 "default"
  3. 如果 input 中存在 @@toPrimitive 属性(方法),若 @@toPrimitive 方法的返回值为原始类型,则 ToPrimitive 的操作结果就是该返回值,否则抛出 TypeError 类型错误。
  4. 如果经过以上步骤之后 hint"default",则使 hint 等于 "number"
  5. 返回 OrdinaryToPrimitive(input, hint) 操作的结果。

提醒:

  1. 关于 Date 对象的 Date.prototype[@@toPrimitive] 内部方法实现,其实是将 hint"default" 的情况改为 "string",然后执行第 5 步的 OrdinaryToPrimitive 操作。(详情看 #sec-20.4.4.45

  2. 关于 Symbol 对象的 Symbol.prototype[@@toPrimitive] 内部方法实现,如果传递给该方法的是一个 Symbol 类型的值,则直接返回该值。如果该值是引用类型,且含有属性 [[SymbolData]],而且该属性值为 Symbol 类型,则返回该属性值,否则会抛出 TypeError 类型错误。(详情看 #sec-19.4.3.5

那么 OrdinaryToPrimitive 的操作是怎样的呢?我们接着往下看...

2. OrdinaryToPrimitive

详情看:#sec-7.1.11

OrdinaryToPrimitive Abstract Operation

OrdinaryToPrimitive(O, hint)

参数 O 为引用类型。参数 hint 为 String 类型,其值只能是字符串 "string""number" 之一。

(官话)OrdinaryToPrimitive 操作,可概括如下:

  1. O 是引用类型。
  2. hint 是 String 类型,且 hint 的值只能是 "string""number" 之一。
  3. 如果 hint"string",使 methodNames 等于 « "toString", "valueOf" »(其中 «» 表示规范中的 List,类似于数组)。
  4. 如果 hint"number",使 methodNames 等于 « "valueOf", "toString" »
  5. 遍历 methodNames,使 name 等于每个迭代值,并执行:
    1. 使 method 等于 Get(O, name)(即获取对象 Oname 属性,相当于获取对象的 toStringvalueOf 属性,具体执行顺序视 hint 而定)。
    2. 如果 IsCallable(method) 结果为 true,那么:
      1. 使 result 等于 Call(method, O) 结果(即调用 method() 方法)。
      2. 如果 result 为原始类型,则返回 result
  6. 抛出 TypeError 类型错误。

其中 IsCallable(argument) 操作,大致内容是:当参数 argument 为引用类型且 argument 对象包含内部属性 [[Call]] 时返回 true, 否则返回 false。话句话说,就是用于判断是否为函数。

(口水话)再总结一下:

  1. 当经过 ToPrimitive 操作,然后执行 OrdinaryToPrimitive(input, hint) 操作,那么步骤如下:
  2. 如果 hint"string",它会先调用 input.toString() 方法,
    1. toString() 结果为原始类型,则直接返回该结果。
    2. 否则,继续调用 input.valueOf() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。
  3. hint"number",它先调用 input.valueOf() 方法,
    1. valueOf() 结果为原始类型,则直接返回该结果。
    2. 否则,继续调用 input.toString() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。

到这里,已经完整地讲述了 ToPrimitive 操作的全部过程。文笔不太好,我不知道你们有没看明白,倘若仍有疑惑,请反复斟酌或直接查看 ECMAScript 标准。

3. 一些示例
  • -*/% 这四种操作符都会把符号两边的操作数先转换为数字再进行运算。
  • + 的作用可以是数值求和,也可以是字符串拼接。
    • 若符号两边操作数都是数字,则进行数字运算。
    • 若符号一边是字符串,则会把另一端转换为字符串进行拼接操作。

一元加运算符 +(unary plus)是将操作数转换为数字的最快且首选的方式,因为它不对该数字执行任何其他运算。

区分一元加运算符(+)和算术运算符(+)的方法,就是前者只有一个操作数,而后者是两个操作数。

// 运算符: x + y

// Number + Number -> 数字相加
1 + 2 // 3

// Boolean + Number -> 数字相加
true + 1 // 2

// Boolean + Boolean -> 数字相加
false + false // 0

// Number + String -> 字符串连接
5 + 'foo' // "5foo"

// String + Boolean -> 字符串连接
'foo' + false // "foofalse"

// String + String -> 字符串连接
'foo' + 'bar' // "foobar"
const obj = {
  [Symbol.toPrimitive]: hint => {
    if (hint === 'number') {
      return 1
    } else if (hint === 'string') {
      return 'string'
    } else {
      return 'default'
    }
  }
}

+obj          // 1              hint is "number"
`${obj}`      // "string"       hint is "string"
obj + ''      // "default"      hint is "default"
obj + 1       // "default1"     hint is "default"
Number(obj)   // 1              hint is "number"
String(obj)   // "string"       hint is "string"

二、ToBoolean

将一个操作数转换为布尔值,这应该是最简单的了。(详情看 #sec-7.1.2

ToBoolean Abstract Operation

所以总结下来就是:

操作数 结果
undefinednullfalse+0-0NaN''0n false
除以上这些(falsy)值之外 true

在 JavaScript 中,如果一个操作数 argument 通过 ToBoolean(argument) 操作后被转换为 true,那么这些操作数称为真值(truthy),否则为虚值(falsy)。

// 转换为 Boolean 值的两种方式
!!x
Boolean(x)

三、ToNumber

将一个操作数转换为数字值。(详情看 #sec-7.1.4

ToNumber Abstract Operation

参数类型 结果
Undefined NaN
Null +0
Boolean true 转换为 1false 转换为 +0
Number 直接返回,不做类型转换。
String 1. 纯数字的字符串转换为相应的数字;
2. 空字符串 '' 转为 +0
3. 否则为 NaN

其中 0x 开头的字符串被当成 16 进制。
Symbol 无法转换,抛出 TypeError 错误。
BigInt 无法转换,抛出 TypeError 错误。
Object 两个步骤:

1. 将引用类型转化为原始值 ToPrimitive(argument, 'number')
2. 转化为原始值后,进行 ToNumber(primValue) 操作,即按上面的类型转换。

需要注意的是

  1. Number(undefined) 结果为 NaN,而 Number(null) 结果为 0
  2. 含有前导和尾随空白符(\n\r\t\v\f)的字符串,在转换为数字类型的时候空白符会被忽略。
  3. 上面也提到过,使用一元加运算符(+) 是将其他类型转换为数值的常用方式。
Number(undefined) // NaN
Number(null) // 0

'\n  123  \t' == 123 // true

+'string' // NaN
+true // 1
+[] // 0
+{} // NaN

四、ToString

将一个操作数转换为字符串类型的值。(详情看 #sec-7.1.17

ToString Abstract Operation

参数类型 结果
Undefined undefined
Null null
Boolean true 转换为 "true"false 转换为 "false"
Number 1. NaN 转换为 "NaN"
2. +0-0 转换为 "0"
3. 其中 Infinity-Infinity 分别转换为 "Infinity""-Infinity"
4. 若 x 是小于 0 的负数,则返回 "-x";若 x 是大于 0 的正数,则返回 "x"
5. 其他不常用的数值,请看 #sec-6.1.6.1.20
String 直接返回,不做类型转换。
Symbol 无法转换,抛出 TypeError 错误。
BigInt 10n 转换为 "10"
Object 两个步骤:

1. 将引用类型转化为原始值 ToPrimitive(argument, 'string')
2. 转化为原始值后,进行 ToString(primValue) 操作,即按上面的类型转换。

需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 Symbol.prototype.toString() 方法。

// 下面会导致 Symbol('foo') 进行隐式转换,即 ToString(Symbol),按以上规则,是会抛出异常的
console.log(Symbol('foo') + 'bar' ) // TypeError: Cannot convert a Symbol value to a string

// Symbol('foo') 结果是 Symbol 的原始值,再调用其包装对象的属性时,会自动转化为包装对象再调用其 toString() 方法
console.log(Symbol('foo').toString() + 'bar' ) // "Symbol(foo)bar"

抛一个有趣的问题:

// 运行出错
var name = Symbol() // TypeError: Cannot convert a Symbol value to a string

// 正常运行,不会抛出错误
let name = Symbol()

// 为什么呢 ❓❓❓

答案我不写了,感兴趣的可以自行搜索。对此有疑问的可以先看下文章:关于 var、let 的顶层对象的属性

五、ToObject

将一个操作数转换为引用类型的值。(详情看 #sec-7.1.18

ToObject Abstract Operation

参数类型 结果
Undefined 无法转换,抛出 TypeError 错误。
Null 无法转换,抛出 TypeError 错误。
Boolean 返回 new Boolean(argument)
Number 返回 new Number(argument)
String 返回 new String(argument)
Symbol 返回 Object(Symbol(argument))
BigInt 返回 Object(BigInt(argument))
Object 直接返回,不做类型转换。

需要注意都是,JavaScript 内置的 SymbolBigInt 对象不能使用 new 关键字去创建实例对象,只能通过 Object() 函数来创建一个包装对象(wrapper object)。

// 错误示例
const sym = new Symbol() // TypeError: Symbol is not a constructor

// 正确示例
const sym = Symbol()
console.log(typeof sym) // "symbol"
const symObj = Object(sym)
console.log(typeof symObj) // "object"

// BigInt 同理

需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 Symbol、BigInt 数据类型无法通过 new 关键字创建实例对象的原因。

六、参考

@toFrankie toFrankie added the JS 与 JavaScript、ECMAScript 相关的文章 label Feb 26, 2023
@toFrankie toFrankie added the 2021 2021 年撰写 label Apr 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant