1

各位小伙伴们好,今天我们来聊一聊JavaScript 中的“类型系统”。

但是在开始之前呢我们可以先思考一个简单的表达式,那就是在 JavaScript 中,“1+‘2’等于多少?”

其实这相当于是在问,在 JavaScript 中,让数字字符串相加是会报错,还是可以正确执行。

如果能正确执行,那么结果是等于数字 3,还是字符串“3”,还是字符串“12”呢?

如果你尝试用一些其他语言执行数字了字符串相加,会是什么杨的结果呢。

比如说用 Python 使用数字和字符串进行相加操作,则会直接返回一个执行错误,错误提示是这样的:

  >>>1+'2'
  Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
  TypeError: unsupported operand type(s) for +: 'int' and 
  'str'

但是在 JavaScript 中执行这段表达式,却是可以返回一个结果的,最终返回的结果是字符串“12”。

那么为什么同样的表达式,在 Python 和 JavaScript 中执行为什么会有不同的结果?为什么在 JavaScript 中执行,输出的是字符串“12”,不是数字 3 或者字符串“3”呢?

什么是类型系统 (Type System)?

在上边的表达式中,涉及到了两种不同类型的数据的相加。要想理清以上两个问题,我们就需要知道类型的概念,以及 JavaScript 操作类型的策略。

对机器语言来说,所有的数据都是一堆二进制代码,CPU 处理这些数据的时候,并没有类型的概念,CPU 所做的仅仅是移动数据,比如对其进行移位,相加或相乘。

而在高级语言中,我们都会为操作的数据赋予指定的类型,类型可以确认一个值或者一组值具有特定的意义和目的。所以,类型是高级语言中的概念

image.png

在 JavaScript 中,你可以这样定义变量:


  var num = 100 # 赋值整型变量
  let miles = 1000.0 # 浮点型
  const name = "John" # 字符串

V8 是怎么执行加法操作的?

了解了类型系统,接下来我们就可以来看看 V8 是怎么处理 1+“2”的了。当有两个值相加的时候,比如:


  a+b

V8 会严格根据 ECMAScript 规范来执行操作。ECMAScript 是一个语言标准,JavaScript 就是 ECMAScript 的一个实现,比如在 ECMAScript 就定义了怎么执行加法操作,如下所示:

image.png
具体细节你也可以参考规范,我将标准定义的内容翻译如下:

  1. 把第一个表达式 (AdditiveExpression) 的值赋值给左引用 (lref)。
  2. 使用 GetValue(lref) 获取左引用 (lref) 的计算结果,并赋值给左值。
  3. 使用ReturnIfAbrupt(lval) 如果报错就返回错误。
  4. 把第二个表达式 (MultiplicativeExpression) 的值赋值给右引用 (rref)。
  5. 使用 GetValue(rref) 获取右引用 (rref) 的计算结果,并赋值给 rval。
  6. 使用ReturnIfAbrupt(rval) 如果报错就返回错误。
  7. 使用 ToPrimitive(lval) 获取左值 (lval) 的计算结果,并将其赋值给左原生值 (lprim)。
  8. 使用 ToPrimitive(rval) 获取右值 (rval) 的计算结果,并将其赋值给右原生值 (rprim)。
  9. 如果 Type(lprim) 和 Type(rprim) 中有一个是 String,则:

    a. 把 ToString(lprim) 的结果赋给左字符串 (lstr);

    b. 把 ToString(rprim) 的结果赋给右字符串 (rstr);

    c. 返回左字符串 (lstr) 和右字符串 (rstr) 拼接的字符串。

  10. 把 ToNumber(lprim) 的结果赋给左数字 (lnum)。
  11. 把 ToNumber(rprim) 的结果赋给右数字 (rnum)。
  12. 返回左数字 (lnum) 和右数字 (rnum) 相加的数值。

通俗地理解,V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:

  • 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;
  • 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值;
  • 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。

image.png

当 V8 执行 1+“2”时,因为这是两个原始值相加,原始值相加的时候,如果其中一项是字符串,那么 V8 会默认将另外一个值也转换为字符串,相当于执行了下面的操作:Number(1).toString() + "2"

这里,把数字 1 偷偷转换为字符串“1”的过程也称为强制类型转换,因为这种转换是隐式的,所以如果我们不熟悉语义,那么就很容易判断错误。

我们还可以再看一个例子来验证上面流程,你可以看下面的代码:


  var Obj = {
      toString() {
        return '200'
      }, 
      valueOf() {
        return 100
      }   
    }
    Obj+3

执行这段代码,你觉得应该返回什么内容呢?

上面我们介绍过了,由于需要先使用 ToPrimitive 方法将 Obj 转换为原生类型,而 ToPrimitive 会优先调用对象中的 valueOf 方法,由于 valueOf 返回了 100,那么 Obj 就会被转换为数字 100,那么数字 100 加数字 3,那么结果当然是 103 了。

如果我改造下代码,让 valueOf 方法和 toString 方法都返回对象,其改造后的代码如下:


  var Obj = {
      toString() {
        return new Object()
      }, 
      valueOf() {
        return new Object()
      }   
  }
  Obj+3

再执行这段代码,你觉得应该返回什么内容呢?

因为 ToPrimitive 会先调用 valueOf 方法,发现返回的是一个对象,并不是原生类型,当 ToPrimitive 继续调用 toString 方法时,发现 toString 返回的也是一个对象,都是对象,就无法执行相加运算了,这时候虚拟机就会抛出一个异常,异常如下所示:


  VM263:9 Uncaught TypeError: Cannot convert object to primitive value
    at <anonymous>:9:6

提示的是类型错误,错误原因是无法将对象类型转换为原生类型。

所以说,在执行加法操作的时候,V8 会通过 ToPrimitive 方法将对象类型转换为原生类型,最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。

总结

今天我们主要了解了 JavaScript 中的类型系统是怎么工作的。类型系统定义了语言应当如何操作类型,以及这些类型如何互相作用。

在 JavaScript 中,数字和字符串相加会返回一个新的字符串,这是因为 JavaScript 认为字符串和数字相加是有意义的,V8 会将其中的数字转换为字符,然后执行两个字符串的相加操作,最终得到的是一个新的字符串。

在 JavaScript 中,类型系统是依据 ECMAScript 标准来实现的,所以 V8 会严格根据 ECMAScript 标准来执行。

在执行加法过程中,V8 会先通过 ToPrimitive 函数,将对象转换为原生的字符串或者是数字类型,在转换过程中,ToPrimitive 会先调用对象的 valueOf 方法,如果没有 valueOf 方法,则调用 toString 方法,如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。

思考题

我们一起来分析一段代码:


  var Obj = {
    toString() {
      return "200"
    }, 
    valueOf() {
      return 100
    }   
  }
  Obj+"3"

你觉得执行这段代码会打印出什么内容呢?欢迎你在留言区与我分享讨论。


LeapFE
1.1k 声望2.3k 粉丝

字节内推,发送简历至 zhengqingxin.dancing@bytedance.com