一道面试题引的思考之js隐式转换

584 阅读8分钟

来看一道有关于 js 隐式转换的题:

// 请问如下的输出结果是什么?
+{}

乍一看,题目很短好像很简单,这是一个隐式转换的题。但仔细想想,却道不出它的转换过程。如果你和我一样对隐式转换有些模糊不清,那我们就一起来研究研究 js 隐式转换的细节吧。

隐式转换是什么?

js 是一个弱类型语言。在一些场景下,它会将当前操作数的类型转化成其它的基本数据类型,以满足当前场景下的使用,这就是 js 的隐式转换。这个机制一方面使得 js 更加灵活,另一方面却可能使代码难以理解。所以要想熟练使用js,理解 js 的隐式转换必不可少。

隐式转换的四种方式

js 中有4种常见的隐式转换方式,分别是 ToPrimitive、ToBoolean、ToNumber 和 ToString。

ToPrimitive

将一个复杂数据类型转为基本数据类型

ToPrimitive 方法通过调用传入值的 valueOf 方法 或 toString 方法,并以方法返回的基本数据类型值作为转化后的值。转化过程两个方法并非一定都被调用,只有当调用的第一个方法返回的值不是基本数据类型时,才会继续调用下一个方法。如果两个方法的返回值都不是基本数据类型,那么就会报错。而具体先调用 valueOf 还是 toString,则由传入的第二参数决定。

下面用 javascript 简单实现一下 ToPrimitive:

const primitiveTypes = ['Undefined', 'Null', 'Number', 'String', 'Boolean']
const util = {
	getType (val) {
  	return Object.prototype.toString.call(val).slice(8, -1)
  },
  isprimitive (val) {
  	return primitiveTypes.includes(this.getType(val))
  }
}

function toPrimitive (input, preferredType) {
	if (util.isPrimitive(input)) return input // 本身就是基础数据类型,直接返回
  
  // 没有传 preferredType 时,如果 input 是日期类型,则默认是 String,否则默认 Number
  if (preferredType === undefined) {
  	preferredType = util.getType(input) === 'Date' ? 'String' || 'Number'
  }
    
   // Number 时,调用顺序为 valueOf -> toString
   if (preferredType === 'Number') {
		const value = input.valueOf()
    if (util.isPrimitive(value)) return value
    
    const str = input.toString()
    if (util.isPrimitive(str)) return str
   }
   // String 时,调用顺序为 toString -> valueOf
   if (preferredType === 'String') {
   	const str = input.toString()
    if (util.isPrimitive(str)) return str
     
    const value = input.valueOf()
    if (util.isPrimitive(value)) return value
   }
    
   throw new Error('valueOf 和 toString 方法返回的都不是基础数据类型')
}

注意到,toPrimitive 如果不传第二个参数,大多数情况下默认为 Number,只有在传入 Date 对象时,才默认为 String。至于为什么 Date 转基本数据类型时要采用 toString,那是因为早期 js 是运行于浏览器的,直接面向的是用户,所以隐式转换为字符串形式会比时间戳的形式更贴近用户。

以上是复杂数据类型转基本数据类型的实现过程,在这个过程中有两个关键的方法:valueOf 和 toString。在我们没有改写对象的这两个方法之前,js 默认已经为它们初始化了这些方法,现在我们就一起来看一看初始方法的行为。

valueOf

大多数构造函数都继承了 Object.prototype 的 valueOf 方法,除了以下4种构造函数重写了 valueOf 方法:Number、String、Boolean 和 Date。

// === 重写之后的 valueOf 的表现: ===
let num = new Number(12)
console.log(num.valueOf === Object.prototype.valueOf) // false
console.log(num.valueOf()) // 12

let str = new String(12)
console.log(str.valueOf === Object.prototype.valueOf) // false
console.log(str.valueOf()) // '12'

let boo = new Boolean(12)
console.log(boo.valueOf === Object.prototype.valueOf) // false
console.log(boo.valueOf()) // true

let date = new Date()
console.log(date.valueOf === Object.prototype.valueOf) // false
console.log(date.valueOf()) // 1584452005327

// === 未重写的 valueOf 的表现:返回值都是对象自身 ===
let arr = [1]
console.log(arr.valueOf === Object.prototype.valueOf) // true
console.log(arr.valueOf() === arr) // true

let fn = function () {}
console.log(fn.valueOf === Object.prototype.valueOf) // true
console.log(fn.valueOf() === fn) // true

let err = new Error ('')
console.log(err.valueOf === Object.prototype.valueOf) // true
console.log(err.valueOf() === err) // true

let reg = new RegExp('^a')
console.log(reg.valueOf === Object.prototype.valueOf) // true
console.log(reg.valueOf() === reg) // true
toString

除了对象自身之外,其它的构造函数均重写 toString 方法

let num = new Number(12)
console.log(num.toString === Object.prototype.toString) // false
console.log(num.toString()) // "12"

let str = new String(12)
console.log(str.toString === Object.prototype.toString) // false
console.log(str.toString()) // "12"

let boo = new Boolean(12)
console.log(boo.toString === Object.prototype.toString) // false
console.log(boo.toString()) // "true"

let obj = {a: 1}
console.log(obj.toString === Object.prototype.toString) // true
console.log(obj.toString()) // "[object Object]"

let arr = [1, 2, 3]
console.log(arr.toString === Object.prototype.toString) // false
console.log(arr.toString()) // "1,2,3"

let fn = function () {return 1}
console.log(fn.toString === Object.prototype.toString) // false
console.log(fn.toString()) // "function () {return 1}"

let date = new Date()
console.log(date.toString === Object.prototype.toString) // false
console.log(date.toString()) // "Tue Mar 17 2020 21:48:24 GMT+0800 (中国标准时间)"

let err = new Error ('123')
console.log(err.toString === Object.prototype.toString) // false
console.log(err.toString()) // "Error: 123"

let reg = new RegExp('^a')
console.log(reg.toString === Object.prototype.toString) // false
console.log(reg.toString()) // "/^a/"

ToBoolean

将值转为布尔值,经测试与 Boolean 函数的表现行为一致

参数结果
undefinedfalse
nullfalse
boolean无需转化
string'' -> false;其它 true
number0/NaN -> false;其它 true
objecttrue

ToNumber

将值转为数值,经测试与 Number 函数的表现行为一致

参数结果
undefinedNaN
null0
boolean(true/false)1/0
string'' -> 0; '123' -> 123; '123a' -> NaN;
number无需转化
object先 ToPrimitive(input, Number),再转数值

ToString

将值转为字符串,经测试与 String 函数的表现行为一致

参数结果
undefined'undefined'
null'null'
boolean(true/false)'true' / 'false'
number123 -> '123'
string无需转化
object先 ToPrimitive(input, String),再转字符串

什么情况下会进行隐式转换?

if 条件 | 与或运算 | “!” 操作符

这几个操作符都会将操作数进行 toBoolean 转换。

+ 操作符

该操作符的目的,是为了得出一个数值,所以会对操作数进行 ToNumber 隐式转换。

+undefined // NaN
+null // 0
+true(+false) // 1(0)
+'12' // 12
+'12a' // NaN

+{} // NaN

看到了上面栗子中的 +{} 了吗,这就是我们文章开头所说的那道题,其实它的实际转化流程是这样的:

1、{} 传入 ToPrimitive,首先调用了 valueOf,返回了它自身

2、由于 valueOf 的返回值不是基本数据类型,那么继续执行 toString 方法,返回 '[object Object]'

3、'[object Object]' 通过以字符串的方式去转数值,被转成了 NaN

+ 运算符

现在讨论一下 “+” 运算符。它与前面的 “+”操作符不同,它的目的在于得出一个相加后的数值或者字符串,而到底是相加为数值还是字符串由操作数的类型决定。如果有1个操作数是字符串,那么就拼接成字符串,否则数值相加。所以一开始就会将操作数转为基本数据类型。

这个过程的伪代码:

// left + right
pLeft = ToPrimitive(left)
pRight = ToPrimitive(right)

if(pLeft is string || pRight is string)
  return concat(ToString(pLeft), ToString(pRight))
else
  return add(ToNumber(pLeft), ToNumber(pRight))

-*/ 运算符

这3个运算符的目的都是为了得出一个数值,所有运算符两边的操作数都通过 toNumber 转化,再计算。如果有一个有一个转为 NaN ,则最后的返回值为 NaN。

== 和 != 运算符

“==” 和 “!=” 运算符是为了比较它们的操作数是否相等,而操作数需要在同一类型下才能比较,所以如果不是同一类型,需要将操作数往类型一致的方向进行隐式转换。

先上一波 “==” 的隐式转换规则:

比较运算 x==y, 其中 x 和 y 是值,返回 true 或者 false。这样的比较按如下方式进行:

1、若 Type(x) 与 Type(y) 相同, 则

    1* 若 Type(x) 为 Undefined, 返回 true。
    2* 若 Type(x) 为 Null, 返回 true。
    3* 若 Type(x) 为 Number, 则
  
        (1)、若 x 为 NaN, 返回 false。
        (2)、若 y 为 NaN, 返回 false。
        (3)、若 x 与 y 为相等数值, 返回 true。
        (4)、若 x 为 +0 且 y 为 −0, 返回 true。
        (5)、若 x 为 −0 且 y 为 +0, 返回 true。
        (6)、返回 false。
        
    4* 若 Type(x) 为 String, 则当 x 和 y 为完全相同的字符序列(长度相等且相同字符在相同位置)时返回 true。 否则, 返回 false。
    5* 若 Type(x) 为 Boolean, 当 x 和 y 为同为 true 或者同为 false 时返回 true。 否则, 返回 false。
    6*  当 x 和 y 为引用同一对象时返回 true。否则,返回 false。
  
2、若 x 为 null 且 y 为 undefined, 返回 true。
3、若 x 为 undefined 且 y 为 null, 返回 true。
4、若 Type(x) 为 Number 且 Type(y) 为 String,返回比较 x == ToNumber(y) 的结果。
5、若 Type(x) 为 String 且 Type(y) 为 Number,返回比较 ToNumber(x) == y 的结果。
6、若 Type(x) 为 Boolean, 返回比较 ToNumber(x) == y 的结果。
7、若 Type(y) 为 Boolean, 返回比较 x == ToNumber(y) 的结果。
8、若 Type(x) 为 String 或 Number,且 Type(y) 为 Object,返回比较 x == ToPrimitive(y) 的结果。
9、若 Type(x) 为 ObjectType(y) 为 String 或 Number, 返回比较 ToPrimitive(x) == y 的结果。
10、返回 false。

看到上面这一堆规则,是不是感觉头要大了。不急,我这里整理总结了一下:

  • 类型相等直接比较(注意 NaN 与其它数值不等)
  • 类型不等时
    • null == undefined 返回 true;如果只有它们两者中的一个,返回 false

    • boolean 操作数先 toNumber。之后,复杂类型操作数 toPrimitive。转化到最后,如果两边的类型依旧不同,那两边肯定分别是 Number 和 String。所以将 String 类型的操作符 toNumber 之后进行比较。

比较运算符

“<” “>” “<=” “>=” 这几个属于比较比较运算符。比较运算符的目的是为了对比两边的数值或者字符串是否相等。当两边都是字符串时,按字符串进行对比,否则按数值进行对比。所以需要在一开始就将操作数转为基本数据类型。

这个过程的伪代码:

// left < right
pLeft = ToPrimitive(left)
pRight = ToPrimitive(right)

if(pLeft is string && pRight is string)
  return compareStr(pLeft, pRight)// 字符串对比
else
	return compareNum(ToNumber(pLeft), ToNumber(pRight)) // 数值对比

基于隐式转换的机制,有什么应用场景?

场景一:

用户通过表单输入值之后,我们拿到的值通常会是一个全是数字的字符串,如果我们需要进行 -/,那么我们只需要直接将字符串进行 -/ 即可,因为操作数会隐式转化为数值(请先忽略数值运算中的精度损耗)。如果我们需要进行 + 运算,则需要对每一个操作数通过 +操作符进行转化。

'12' - '3' // 9
'12' * '3' // 36
'12' / '3' // 4

+'12' - (+'3') // 9
场景...(欢迎留言补充)