从 1 + '2' = ? 开始解读 JS 中的 '+' 运算符

686 阅读7分钟

我们都知道 JavaScript 是弱类型语言,即 JS 变量没有预先确定的类型,变量的类型就是其值的类型。

然而在进行某些操作时,不同类型的变量是可以用运算符连接做运算的,那 JS 会怎样处理不同类型变量运算呢?

先来看下面这个表达式:

 1 + '2'

有经验的前端同学应该都知道输出是 '12', 实际结果也是 '12'。那既然是一个数字加一个字符串,输出为什么不是3'3'12

 1 + '2' // '12'

1 + 2 + '3' 输出又是什么呢?

1 + 2 + '3' // '33'

这个问题虽然问的很蠢,但是主要目的是为了引出本篇的主题:JS 中的类型系统与类型转换,其实本文只是狭隘地介绍了 JS 中的 “+” 运算符的操作。

何为类型系统?

在所有电脑中,基于数字电子学的底层数据,都是以比特(01)表示,机器语言中并没有类型的概念。而在高级语言中,我们却往往会为操作的数据赋予指定的类型。

类型系统

计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。

所谓 强类型 ,是指禁止错误类型的参数继续运算。

弱类型 是指一个语言可以隐式的转换类型(或直接转型)。

而即使都是弱类型语言,不同语言对于同样的表达式的处理结果可能并不相同,比如开头的例子,1 + '2' = ?,在 JS 中得到的结果是 '12',但是在 PHP 中却是 3。这是基于这两种语言的规则是以两个操作数为考量的。而在部分语言,如 AppleScript 中,这个表达式的值却会以最左边的操作数的类型所决定。这意味着:在 AppleScript 中,1 + '2''1' + 2 输出的结果可能是不一样的!

所以要想掌握一门语言,最基本的还是 看标准

这篇文章就带大家走进 JS 中关于 + 操作符的标准定义。

JS 中的 ‘+’ 运算符标准解读

本节是粗暴的标准解(fan)读(yi),看不下去的同学可以直接略过,下一节会简单归纳一下。

首先,简单粗暴上 标准 的伪代码:

要计算 AdditiveExpression : AdditiveExpression + MultiplicativeExpression

// 获取左右两个表达式的值
let lref = AdditiveExpression
let lval = GetValue(lref)
ReturnIfAbrupt(lval)

let rref = MultiplicativeExpression
let rval = GetValue(rref)
ReturnIfAbrupt(rval)

// 获取左右两个表达式的基本类型表示的值
let lprim = ToPrimitive(lval)
ReturnIfAbrupt(lprim)

let rprim = ToPrimitive(rval)
ReturnIfAbrupt(rprim)

// 如果有一个基本类型是String,则按照字符串拼接
if (Type(lprim) === String || Type(rprim) === String) {
    let lstr = ToString(lprim)
    ReturnIfAbrupt(lstr)

    let rstr = ToString(rprim)
    ReturnIfAbrupt(rstr)

    return lstr + rstr   // 返回字符串连接
}

// 否则按照数字加和
let lnum = ToNumber(lprim)
ReturnIfAbrupt(lnum)

let rnum = ToNumber(rprim)
ReturnIfAbrupt(rnum)

return lnum + rnum // 返回加法计算结果

我们看到其中有几个函数或术语,先简单介绍一下:

Completion Record

指值和控制流的运行时状态记录,如下表:

Field Value Meaning
[[type]] normal, break, continue, return, throw 之一 完成状态
[[value]] 任何 ES language valueempty 产生的值
[[target]] 任何 ES 字符串或 empty 控制流转移的目标

abrupt completion 指具有 [[type]] 值的 completion 状态

GetValue(val)

  • 简单理解,就是返回 val 的值: 如果 val 本来就是个纯值,直接返回;如果 val 是个变量名,或属性(在对象中的引用),就获取对应值后返回;

ReturnIfAbrupt(val)

  • 如果 val 是 abrupt completion,返回 val
  • 否则,如果 val 是 Completion Record,val = argument.[[value]

ToPrimitive ( input [, PreferredType] )

  1. 参数:
  • input 表示要处理的输入值;
  • PreferredType 非必填,表示希望转换成的类型,有两个值可选: Number 或 String。此参数不传时,通常会将其视为 Number。可以通过定义 @@toPrimite 方法来覆盖此默认行为。标准中只有 Date 对象和 Symbol 对象覆盖了此默认行为。Date 对象将把 PreferredType 默认设置为 String。
  1. 处理步骤:
    1. 如果 input 类型是 UndefinedNullBooleanNumberString 类型,直接返回该值;
    2. 如果 PreferredType 是 String, let methodNames = ['toString', 'valueOf'],
    3. 否则: PreferredType 是 Number, let methodsNames = ['valueOf', 'toString']
    4. 依次调用 methodsNames 中的函数 method,如果结果是原始值,直接返回
    5. 抛出 TypeError 异常

总的来说,就是对于 Object 类型的 input,toPrimitive 会根据传入的 PreferredType 判断优先调用 'toString'(对 String) 还是 'valueOf'(对 Number)。如果两个都拿不到原始类型的结果才会抛出异常。

Type(val)

获取 val 的类型

ToString(val):

根据下表将 val 转换成 String 类型的值:

val Type 结果
Completion Record 如果 val 是 abrupt completion,返回 val;否则,返回 ToString(val.[[value]])
Undefined 返回 "undefined"
Null 返回 "null"
Boolean 如果 val === true, 返回 "true"; 如果 val === false, 返回 "false"
Number 参加下文
String 返回 val
Symbol 抛出 TypeError 异常
Object let primValue = ToPrimitive(val, hint String); return ToString(primValue);

Number 类型 ToString(val) 步骤

  1. 如果 val 是 NaN,返回字符串 “NaN”
  2. 如果 val 是 +0-0,返回字符串 “0”
  3. 如果 val 比 0 小,返回字符串 "-" 和 ToString(-m) 的连接结构
  4. 如果 val 是 +∞ ,返回字符串 "Infinity"
  5. 否则,正数的转换方法(本文略,具体看标准)

整体来讲,ToString 的操作和我们直觉上的结果是一致的;

ToNumber(val)

根据下表将 val 转换成 Number 类型的值:

val Type 结果
Completion Record 如果 val 是 abrupt completion,返回 val;否则,返回 ToValue(val.[[value]])
Undefined 返回 NaN
Null 返回 +0
Boolean 如果 val 是 true,返回 1;如果 val 是 false,返回 +0
Number 返回 val
String 本文略,参见标准
Symbol 抛出 TypeError 异常
Object let primValue = ToPrimitive(val, hint Number); return ToNumber(primValue);

总结一下“+”操作符的运算步骤

虽然上一节搬标准也比较清晰,而且最为详细,但是鉴于其中调用了太多的函数,所以还是简单总结一下:

  1. let lprim = ToPrimitive(value1)
  2. let rprim = ToPrimitive(value2)
  3. 如果 lprim 和 rprim 中有一个是字符串,那么则返回 ToString(lprim) 和 ToString(lprim) 的拼接结果
  4. 返回 ToNumber(lprim) 和 ToNumber(rprim) 的运算结果

ToStirngToNumberToPrimitive 的运算结果参见上一节。

光说不练假把式,下面看几个例子:

1. 字符串 + 数字

'1' + 2

解读: 把本文开头的例子稍微改了下,结果还是不变:两个值之间有一个是字符串则会返回字符串的拼接结果

'1' + 2  // '12'

2. undefined + 数字

undefined + 1

解读: undefined 和 1 都是基本类型,toPrimitive 返回自身。因为两者都不是字符串,所以会使用 ToNumber(undefined) + ToNumber(1) 加法运算。而 ToNumber(undefined) 会返回 NaN。NaN + 1 结果还是 NaN,因此:

undefined + 1 // NaN

3. null + 数字

null + 1

解读:前面和 undefined + 1 的分析 类似,但是要注意: ToNumber(null) 结果是 0,因此:

null + 1 // 1

4. 日期 + 数字

new Date() + 1

解读:日期对象调用 ToPrimitive 方法会默认将第二个参数视为 String,因此会返回 字符串。所以会按照字符串方式来拼接:

new Date() + 1 //"Sat Apr 11 2020 18:42:20 GMT+0800 (中国标准时间)1"

5. 数组 + 对象

[] + {}

解读:依然按照规范:

  1. toPrimitive([]),因为 [] 不是基本类型也不是 Date, PreferredType 默认按照 Number 解析。首先调用 valueOf() 方法,返回其本身,不是基本类型;然后调用 toString 方法,返回空字符串""
  2. toPrimitive({}),类似
[].valueOf()  //[]
[].toString() //""
({}).valueOf() //{}
({}).toString() //"[Object Object]"

这样,+ 左右的操作值都是字符串,按拼接操作,结果:

[] + {} // "[Object Object]"

6. 对象 + 数组

{} + []

我本来以为结果肯定和上面的一样,结果输出却是:

what?

但如果把这个表达式包裹在 console.log 中是没问题的:

分析原因,是因为 {} 在开头出现时,被当成空的代码块,所以 {} + [] 就变成了 +[],结果就变成了0

那么 {} + {} 呢?

{} + {} // "[object Object][object Object]"

emmmm

我是在 chrome 中测试的,参考 mqyqingfeng 大佬的 文章 在火狐里还会有不同的结果。这大概就属于在标准未明确定义(或标准定义了但有浏览器有bug)的领域各浏览器实现上的差异了。

好在实际开发中并不会产生类似的歧义。

以上就是从 1 + '2' = ? 引出的 JS 类型转换相关的知识。这里只介绍了 + 符号引发的隐式转换,实际还需要了解类型转换的内容有更多。如果有兴趣,大家可以在参考资料里找到更多有待挖掘的知识点。

参考资料

The Addition operator ( + )

维基百科:类型系统

JavaScript深入之从ECMAScript规范解读this

JavaScript 深入之头疼的类型转换(上)

JavaScript 深入之头疼的类型转换(下)