JavaScript令人头秃的类型系统

lxf2023-05-22 01:15:14

类型系统,主要讲的是一门编程语言中的各种数据类型以及它们之间的相互转换。讲到类型,就需要提下静态语言、动态语言、强类型语言和弱类型语言。

静态语言 VS 动态语言、强类型语言 VS 弱类型语言

根据声明变量的时候是否需要指定变量类型,可以将编程语言分为静态语言动态语言。 比如Java是一门静态语言,在声明一个变量的时候需要先指定其类型:

int num = 1;
String str = "runoob";

JavaScript是一门动态语言,在声明变量的时候不需要指定其数据类型,而是在运行过程中JavaScript引擎自动推断数据类型。也因为JavaScript是动态语言,变量的类型是动态改变的,所以我们一般说的数据类型是说值的类型,而不是变量的类型

根据是否支持隐式类型转换,可以将编程语言分为不支持隐式类型转换的强类型语言支持隐式转换的弱类型语言。 比如Java是一门强类型语言,运行如下的代码报错

int res = 1;
if (res) {
    System.out.println("强转成功!");
}else {
    System.out.println("强转失败");
}
# 报错:error: incompatible types: int cannot be converted to boolean

JavaScript是一门弱类型语言,各种数据类型之间支持隐式转换。

那么,静态语言就是强类型语言,动态语言就是弱类型语言吗?答案见下图: JavaScript令人头秃的类型系统

回到主题,本次主要是讲JavaScript的类型系统,JavaScript的类型系统是一个有争论的话题,本文先引用我比较支持的一种。来自周爱民的《JavaScript的语言精髓与编程实践(第三版)》,内容如下:

JavaScript中存在两套类型系统,其一是基础类型系统(Base types),是由typeof运算来检测的,按照约定,该类型系统包括7种类型(undefined、number、boolean、string、symbol、function和object);其二是对象类型系统(Object types),对象类型系统是“对象基础类型(object)”中的一个分支。

注: 对象类型系统包括:原生对象、宿主对象和引擎扩展对象。点击查看对象类型系统详情

这个观点中的类型分类与ECMAScript规范中的标准的出入点有两个:

  • null属于对象类型,而ECMAScript中将null划分在基本数据类型当中
  • function属于基本数据类型吗,而ECMAScriptfunction作为对象类型中的一个子类型

先说说nullundefined,关于两者的区别以及typeof null === 'object'的原因网上有很多资料,大家比较能接受的一个观点是这是当时的一个设计缺陷,包括JavaScript的作者后来也承认了这个缺陷,但是如果你接受JavaScript中是有两套类型系统的,那就能合理化nullundefined并存的现象:undefined表示基本数据类型中的没有,而null表示对象类型系统中的没有,比如document.getElementById('一个不存在的id') === null

再来说说另一个争议点:function也属于一种基本数据类型,在其它的语言中,function是独立于类型系统之类的,但是在JavaScript中,function是属于Object的一个子类型,因为JavaScript中的function的本质就是一个对象,只是比对象多了一个[[call]]而已,在V8中,function也是用一个对象来表示的。但是typeof一个函数的话输出的是function,而且function足够复杂,并且在JavaScript中,函数是一等公民,所以在《JavaScript的语言精髓与编程实践(第三版)》中作者将function也归属于一种基本数据类型。对此有兴趣的可以参考知乎上的一篇文章:JavaScript里Function也算一种基本类型?

介绍JavaScript中的类型系统,现在来看下几种平时开发过程中常见的基本数据类型以及它们之间的转换(本文不涉及SymbolBigInt)。

类型转化涉及的三个方法

在类型转换的过程中,会涉及到ToPrimitive()toString()valueOf()三个方法,其中ToPrimitive()是对象的内部方法,我们无法直接调用,在需要的时候,JavaScript引擎会自动调用,而toString()valueOf()Object.prototype上的方法,然后每个类型(除了UndefinedNull)都重写了这两个方法,这三个方法的详细介绍如下:

ToPrimitive(input [,PreferredType])

该函数用于将一个值转成一个基本数据类型,它接受两个入参,input是需要转换的值,PreferredType可选,可选值有stringnumberdefault(默认值就是defaultdefault在函数内部赋值为number)。当PreferredType = string的时候,该函数的逻辑如下图所示: JavaScript令人头秃的类型系统 注:图中的原始值是指ECMAScript中的基本数据类型的值。比如返回值可以是null

PreferredType = number的时候,该函数的逻辑如下图所示: JavaScript令人头秃的类型系统

toString()

该方法用于返回一个表示该对象的字符串。各种数据类型的toString()的返回结果见下表:

类型描述例子
String返回对象的字符串形式str.toString() === 'str'
Number返回对象的字符串形式(123).toString() === '123';NaN.toString() === 'NaN'
Boolean根据对象的值返回true或者falsetrue.valueOf() === 'true'
Object返回 "[object type]"(type是对象的类型){}.toString() === '[object Object]'
Array返回用逗号连接数组元素的字符串[1,2,3].toString() === '1,2,3'
Function返回当前函数源代码的字符串function test() {console.log('hello')} test.toString() === 'function test() {console.log('hello')}'

valueOf()

该方法用于返回值为该对象的原始值,如果对象没有原始值,则返回对象本身。各种数据类型的valueOf()的返回结果见下表:

类型描述例子
String返回String对象的原始值str.toString() === 'str'
Number返回被Number对象包装的原始值1..valueof() === 1
Boolean返回Boolean对象的原始值true.valueOf() === true
Object返回对象本身obj.valueOf() === obj
Array返回数组本身arr.valueOf() === arr
Function返回函数本身fn.valueOf === fn

String

创建字符串

先来看几种我们常用的创建字符串的方式

const str1 = 'hello' // 用字面量的方式声明一个字符串
const str2 = String(1) // 强转
/**
记得这里的1要加括号,因为.运算符是一个有效的数字字符,会被优先识别为数字字面量的一部分
如果不加括号的时候,JS引擎会将1.0作为一个整体
然后就缺少属性访问运算符'.'来调用toString()而报错Uncaught SyntaxError: Invalid or unexpected token
如果你不觉得奇怪的话,你可以这样写1..toString()
*/
const str = (1).toString()
const str4 = new String('hello')

用字面量的方式创建一个字符串是我们开发中常用的方式,一个小问题,V8会把str1是存在栈中还是堆中的呢?

我们常说:基本数据类型存放在栈中,而引用数据类型存在于堆中。其实不然,V8会把字符串常量存放在堆中,在堆中,有一个叫常量池的区域。这里存放着V8解析到的字符串常量。 回到上面的代码,当V8解析到这行代码的时候,会根据该字符串计算出一个hash值,然后去常量池中查找key值等于该hash值的字符串并比较内容是否相同,如果找到了且内容相同就直接返回,不再分配空间。否则,就会在老生代的常量池中申请空间,将该hash值写入常量池中。 可以通过一个小demo来测试一下:

JavaScript令人头秃的类型系统

从上图可以看到:

  • 字符串是存在于老生区的常量池中
  • 值相同的字符串会共用一个地址

String强制/显示类型转换

String(value)value.toStirng()可以强制类型转换得到一个字符串,先来看看String(value)的转换规则: 如果value是个空串,那么直接返回'';如果value有值,那么调用内部的ToString()方法,根据入参value的类型不同ToString()有不同的处理方式,ToString()的转换规则如下:

  • Undefined类型,返回"undefiend"
  • Null类型,返回'null'
  • Boolean类型,如果是true就返回'true',如果是false就返回'false'
  • String类型,直接返回对应的value即可
  • Number类型,分多种情况处理,常见的情况如下:
String(NaN) === 'NaN'
String(+0) === '0'
String(-0) === '0'
String(Infinity) === 'Infinity'
String(-1) === '-1' // 强转负数返回值会带上负号
String(1000000000000000000000) // 1e+21
String(0.0000001) // 1e-7
  • Object类型,调用ToString()。 当String()的入参是一个对象的时候,可以用下面的例子验证下内部调用顺序:
function Student(name = 'unknown', age = 10) {
  this.name = name;
  this.age = age;
}
Student.prototype.toString = function() {
  console.log('调用toString')
  return Boolean(1)
}
Student.prototype.valueOf = function() {
  console.log('调用valueOf')
  return {}
}
const stu = new Student('hello')
console.log(String(stu));

注意:如果没重写对象的toString()的话,则需要判断是否重写了valueOf(),如果重写了就调用valueOf(),如果没有的话就调用父类ObjecttoString()

那么value.toString()String(value)有什么区别呢?

  1. NullUndefined类型的数据可以作为入参被String()强转,得到'null'或者'undefined'。但是用NullUndefined类型的数据没有toString()方法(调用toString()会报错TypeErro: Cannot read property 'toString' of null/undefined)。
  2. toString()可以进行进制的转换。比如(12).toString(2)输出'1100'

Number

JavaScript中的数字只有一种类型,那就是Number,而Number是双精度浮点数,一个数字用八个字节表示,其中1位符号位 + 11位指数位 + 52位小数位。也就是说在JavaScript中没有真正意义上的整数。比如const num = 1,在num的小数点后面还有52个0。

而用双精度浮点数最大的问题就是精度丢失,也就是我们常见的那个经典问题:0.1 + 0.2 !== 0.3。那么在程序里面,我们要怎么避开这个精度丢失的问题让0.1 + 0.2 === 0.3呢?在ES6中,在Number上有个常量EPSILON(机器精度),Number.EPSILON === 2.220446049250313e-16,也就是2的负52次方,如果0.1 + 0.2 - 0.3 < Number.EPSILON为真,就判断0.1 + 0.2 === 0.3

Number中还有一个平时开发中需要注意的小细节是:Number.isNaN()isNaN()不是一个东西,isNaN()ES5中的全局函数,它的判断标准是当入参不是NaN或者不是数字的时候都会返回true。而Number.isNaN()ES6Number上的函数,它是用来判断入参是否严格等于NaN的可靠方法,可以参看下MDN中的介绍:

JavaScript令人头秃的类型系统 可以通过下面的代码看下两者的区别:

isNaN({}) // true
Number.isNaN({}) // false

创建Number

看看我们平时创建数字常用方式:

const num1 = 1;
const num2 = Number(true)

还是那个问题,V8中的Number类型变量是存放在堆中和栈中,是不是和字符串一样,也存放在堆中的常量池里面?看下图:

JavaScript令人头秃的类型系统 从上图可以看到,有的在堆中的常量池中,有的在栈中,那存放规则是什么呢?其实在V8中,数据分为三种:小整数、浮点数和其他,对于小整数V8是将它们放在栈中的,而对于非小整数,V8是存放在堆中的,和字符串一样的处理。

Number显示/强制类型转换

通过Number(value)强转可以得到一个Number类型的变量,我们来看下ESMAScriptNumber(value)的转换规则: 如果没有入参,就直接返回0,如果有入参,则调用内部的ToNumner()方法,ToNumber()的转换规则如下:

  • Undefined类型,返回NaN
  • Null类型,返回0
  • Boolean类型,入参是true,返回1;入参是false,返回0
  • Number类型,直接返回入参即可
  • Object类型,调用ToNumber()方法
  • String类型,分多种情况处理,常见的情况如下:
# 入参是字符串的话,情况比较复杂,但是统一的原则就是尽可能的转成功,比如`0001`中的三个`0`可以全部忽略。
Number('-1') // -1
Number('1.2') // 1.2
Number('0001') // 1
Number('-00001') // -1
Number('1 1') // NaN
Number('5E10') // 50000000000
Number('5E21') // 5e+21 指数超过20就输出科学计数法
Number('5E-6') // 0.000005
Number('5E-7') // 5e-7 指数小于-6就用科学计数法

Boolean

Boolean类型比较简单,直接看下用Boolean(value)强制转换返回false的六种情况:

Boolean()
Boolean(Null)
Boolean(Undefinde)
Boolean(0)
Boolean(NaN)
Boolean('')

除了上述几种情况,其他情况下Boolean()都会返回true

包装类

一切皆对象,为了体现这一思想,JavaScript给除了NullUndefined的基本数据类型都内置了包装类。基本数据类型转对象很简单,调用它们的构造函数即可。 有没有想过为什么像true或者1这样的基本数据类型也可以调用一个方法,其实这是V8内部将基本数据类型转成了对象,这个过程叫做包装。比如,true.toString()这行代码等价于以下代码:

const tmp = new Boolean(true)
tmp.toString()
tmp = null

与包装相对的是拆封,拆封一般发生需要用到封装对象的基本类型值的时候,比如在隐式类型转化的过程中,如下列代码所示:

const newStr = new String('abc') + 'd' // newStr = 'abcd'

上面的代码等价于new String('abc').valueof() + 'd'

需要注意的是我们在开发的过程中,我们尽量少用包装类,尽量让JS引擎自动处理。比如以下情况使用包装类就会出问题:

const flag = new Boolean(false)
if (flag) {
    // 为真,执行逻辑
} else {
    // 为假,执行逻辑
}

上述代码走不到else分支……

隐式类型转换

JavaScript的隐式类型转换发生在很多情况下,下面讲下我平时开发过程中常见的几种会发生隐式类型转换的场景。

&&||

&&先对第一个操作数进行判断,如果不是Boolean类型,就调用ToBoolean()进行类型转换,如果为false就返回第一个操作数的值,如果为true就返回第二个操作数的值。

||也是先对第一个操作数进行判断,如果不是Boolean类型,就调用ToBoolean()进行类型转换,如果为true就返回第一个操作数的值,如果为false就返回第二个操作数的值。

可以看到&&||的返回值不是Boolean类型,比如'hello' && 123 === 123。平时我们可以用它进行判断,是因为if()的时候发生了隐式类型转换。

if

if的布尔判断比较简单,和Boolean()是一样的逻辑,底层都是调用的ToBoolean()

!!!

!是对操作数进行取反操作:调用ToBoolean()对操作数进行强制转换然后取反。

!!是将操作数转成Boolean类型,!!value等价于Boolean(value)。在开发的时候,推荐使用Boolean(value)显示转换,语义明显,可读性更强。

+

一元操作符

+运算符作为一元操作符的时候,表示将操作数转成一个数字,所以会调用ToNumber()处理该值。 来个例子+[]等于什么?

  1. 先调用valueOf(),返回的是一个[]
  2. valueOf()的返回值不是一个原始值,继续调用toString()
  3. toString()的返回值是空串''
  4. 调用ToNumber(''),返回0
二元操作符

+作为二元操作符计算value1 + value2的时候,处理逻辑如下:

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

来几个例子:

1 + true
/**
1、左右操作数都是原始值
2、两者都不是字符串,执行ToNumber()操作
3、ToNumber(true) = 1 ==> 1 + 1 = 2
*/
[] + {}
/**
1. 执行ToPrimitive([]),[].valueOf()返回的是[]本身,所以调用toString()方法,[].toString()为空串,所以返回''
2. 执行ToPrimitive({}),{}.valueOf()返回的是{}本身,所以调用toString()方法,{}.toString()为'[Object Object]',所以返回'[Object Object]'
3. lprim和rprim返回的都是字符串,执行拼接操作,所以最后的结果是'[Object Object]'
*/
{} + []
/**
分析过程同上,结果和上面的一样,但是chrome的console中执行,发现结果是0
这是因为在chrome的console中,{}放在前面,被当成了一个代码块,所以此时的{} + []等价于+[],所以结果是0
如果加个括号({} + [])就可以得到和[] + {}一样的执行结果了
*/
{} + {}
/**
踩了上面的坑,我们知道第一个{}被当成了代码块,所以{} + {}等价于+ {}
对{}执行ToNumber()操作,得到NaN,所以结果是NaN。
但是chrome的console中输出结果却是'[object Object][object Object]',和({} + {})是一样的结果
这又是怎么回事?感觉{}什么时候被当成代码块什么时候被当成对象有点混乱
个人的建议就是在浏览器的console中测试这些类似的代码的时候,带上()
*/

==

一般我们区分=====的时候是说:==是比较值是否相等,而===除了值相等,还需要类型相等,如果这样区分,怎么解释0 == []呢?准确的区分这两者应该是:

非严格相等==(loose equals): 允许在比较两个值是否相等的时候进行强制类型转换

严格相等===(strict equals):不允许在比较两个值是否相等的时候进行强制类型转换

当执行a == b的时候,ab数据类型不一样的时候,会发生隐式类型转换将两个被比较的值转换为相同类型,然后执行严格相等判断。转换规则可以参考MDN上的一张表格,如下图: JavaScript令人头秃的类型系统 总结下表格中的转化规则,如果左右操作数数据类型相同,执行严格相等判断,如果类型不同,隐式转换规则如下:

  • NaN不等于任何值
  • null == undefined
  • 一般而言,根据ECMAScript规范,所有的对象都与undefinednull都不相等。但是大部分浏览器中的document.all对象除外,在chrome等浏览器中typeof document.all === 'undefined'
  • 操作数是对象类型,执行ToPrimitive()操作
  • 操作数是基本数据类型,是非Number类型的操作数执行ToNumber()操作 来几个例子:
console.log(false == undefined)
/**
1、false和undefined都是基本数据类型,执行ToNumber()操作
2、lprim = ToNumber(false) = 0
   rprim = ToNumber(undefined) = NaN
3、0 === NaN ? false
*/
console.log(false == [])
/**
1、ToNumber(false); ToPrimitive([])
2、ToNumber(false) = 0
   ToPrimitive([]) ==> 调用valueOf() ==> 返回数组本身[] 
                   ==> 继续调用toString(),返回空串'',转换成功,结果是'' ==> ToNumber('') = 0
3、0 === 0 ? true
*/
console.log(1 == ['1'])
/**
1、1不需要转,ToPrimitive(['1'])
2、ToPrimitive(['1']) ==> 调用valueOf() ==> 返回数组本身['1'] 
                      ==> 继续调用toString(),返回字符串'1',转换成功,结果是'1' ==> ToNumber('1') = 1
3、1 === 1 ? true
*/
console.log([] == ![])
/**
1、ToPrimitive([]) 返回0
2、右操作数先取反 ToBoolean([]) = true ==> 取反等于false ==> ToNumber(false) = 0
3、0 === 0 ? true
*/

参考

《你不知道的JavaScript

JavaScript的语言精髓与编程实践(第三版)》

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!