从一道面试题开始,重温类型转换

一道题目

最近准备回成都,开始了各种面试,这次面试官出了一道函数坷里化的题,题目如下:

实现如下函数

1
2
3
sum(1)       // 1 
sum(1)(2)(3) // 6
sum(1)(2)(3)(4) // 10

面试的时候这道题愣了一下,没有答出来,主要纠结了一下在不定参的时候,如果能够链式调用,那一定需要返回的是一个函数,按照题目要求,值需要既是一个函数,又是一个数字。

面试结束后仔细想了下,正好想到之前看《你不知道的JavaScript》中的对于对象类型的toPrimitive操作,面试后很快写了个Demo出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
function sum(...args){
let previousArgs = []
const func = (...args)=>{
previousArgs = previousArgs.concat(args)
return func
}

func.valueOf = ()=> previousArgs.reduce((acc, currentVal)=> acc + currentVal)

return func(...args)
}

console.log(sum(2)(3) + 2) // 7

这里实际上关于使用toString以及Symbol.toPrimitive可以达成效果的,但具体原理有一些差别。

这里就借助这个机会回顾一下Js整个类型转换逻辑。

隐式类型转换

总所周知,js作为一种弱类型语言,在需要的场景下,会进行隐式的类型转换,为此 ECMA262 Type Conversion 中规定了一系列类型转换的抽象方法,以及在各种运算符操作及函数中规定了如何去调用这些类型转换方法。

抽象方法

ECMA262 Type Conversion 中规定了许多抽象方法,例如ToStringToNumbertoPrimitivetoBoolean,其他的抽象方法也有很多,这里只简单介绍一下toPrimitive方法,步骤很清楚明白。

ToPrimitive(input[,preferredType])

toPrimitive方法主要用于将对象转换为原始值,它接受两个参数,第一个参数为任意的合法值,第二个参数为倾向转换的类型(stringnumber)。

其执行逻辑如下

  1. 如果input为对象,就:
    1. 获取对象上定义的@@Primitive方法,也就是Symbol.Primitive
    2. 如果@@Primitive方法不为undefined,就
      1. 如果preferredType 未指定,就将hint默认设置为"default"
      2. 否则,如果preferredTypestring,就把hint设置为"string"
      3. 否则,preferredTypenumber,就把hint设置为"number"
      4. result设置为执行@@Primitive(input, hint)方法的结果
      5. 如果result的类型不为object,将resule作为结果返回
      6. 抛一个TypeError错误
    3. 如果preferredType 未指定,就默认设置为number
    4. 返回 调用 OrdinaryToPrimitive(input,preferredType)方法的结果
  2. 否则将input作为返回值
OrdinaryToPrimitive(O,hint)
  1. 如果hint"string",就

    1. mthodNames设置为<<"toString","valueOf">>
  2. 如果hint"number",就

    1. mthodNames设置为<<"valueOf","toString">>
  3. 按顺序取出mthodNames中的name,对于,每一项

    1. method设置为对应对象上name的值
    2. 如果method可以被调用,就
      1. result设置为执行method()方法的结果
      2. 如果result的类型不为object,将resule作为结果返回
  4. 抛一个TypeError错误

这里就是ToPrimitive的逻辑了,整个逻辑就是优先调用Symbol.Primitive方法,如果不存在,再根据偏好顺序,执行valueOftoString并作为结果返回。

运算符

在上面我们大致讲了类型转换中,将对象转为原始值的ToPrimitive方法,它被调用的一个典型案例就是使用+运算符,也就是本文的例子。在ECMA262 ApplyStringOrNumericBinaryOperator 中,规定了+运算符的行为。

ApplyStringOrNumericBinaryOperator( lval, opText, rval )

对于字符串或数字的运算都被定义在这个方法下,这里简便起见,我们仅看类型转换的部分

  1. 如果opText+ ,就
    1. lprim设为 ToPrimitive(lval)执行的结果
    2. rprim设为ToPrimitive(rval)执行的结果
    3. 如果lprimrprim的类型为string,就
      1. lstr设为 ToString(rprim)执行的结果
      2. rstr设为 ToString(rprim)执行的结果
      3. lstrrstr连接后返回的新字符串作为结果返回
    4. lval设为 lprim
    5. rval设为rprim
  2. lnum设为 ToNumeric(lval)执行的结果
  3. rnum设为 ToNumeric(rval)执行的结果
  4. 如果lnumrnum的值不匹配,返回一个TypeError 错误
  5. 执行定义的数学运算,相关逻辑省略

运行结果解析

有了上面定义的运算符逻辑及对象转换逻辑,我们就能比较清晰的明白上面的例子在执行时,valueOftoString,还有Symbol.toPrimitive的调用逻辑是什么,有什么区别。

valueOf()

为了方便对比,我们写一个最小化的Demo

1
2
3
4
5
6
7
8
const demo = {
valueOf(){
return 3
}
}

console.log(demo + 1) // 4
console.log(demo + '1') // "31"

这里按照上述的流程,会执行以下的步骤

  1. demo + 1 , 执行toPrimitive(demo)
  2. toPrimitive(demo) 检查没有@@Primitive方法,并且没有指定preferredType,默认偏好类型设置为value。
  3. 按照偏好类型,首先获取valueOf方法,并执行
  4. 执行后,返回3,类型不为object,返回后续结果。
  5. 执行后续流程。

toString()

实际上,重写toString()方法也能拿到一样的结果。

1
2
3
4
5
6
7
8
const demo = {
toString(){
return 3
}
}

console.log(demo + 1) // 4
console.log(demo + '1') // "31"

在这种场景下,toString()valueOf是近乎等同的。

Symbol.toPrimitive(hint)

Symbol.toPrimitive方法是在ES6中出现的,让js有了覆写对象转换为原始值这一行为的能力,入参hint会根据转换的偏向类型,传入'string''number''default',上面的例子也可以使用Symbol.toPrimitive达到一样的效果。

Symbol.toPrimitive方法除会接受入参以外,如果返回值未返回原始值类型,就直接抛出TypeError

1
2
3
4
5
6
7
const demo = {
[Symbol.toPrimitive]: ()=>{
return {}
}
}

demo +1 // Uncaught TypeError: Cannot convert object to primitive value

而对于toStringvalueOf方法,由于有默认的Object.prototype.toString方法,总是会输出对应的对象描述。

如果没有toStringvalueOf方法,也会抛出这个错误。

1
Object.create(null) + 1 // Uncaught TypeError: Cannot convert object to primitive value

小结

这里实际上是第一次阅读规范文档去解决自己的疑惑,规范实际写的非常简洁明了,很多时候要是不明白js的一些行为,阅读规范文档是一个好的办法。

参考文档

MDN Symbol.toPrimitive
ECMA262 Type Conversion
ECMA262 ApplyStringOrNumericBinaryOperator
《你不知道的JavaScript 中卷》强制类型转换