JavaScript类型系统的一种概述
JavaScript中的数据类型
JavaScript是一门动态弱类型的编程语言,即是类型在运行时确定并可以进行隐式类型转换的语言。JavaScript语言本身包含了
- 核心语法(规格来自ECMA组织的ECMAScript)
- BOM和DOM(规格来自whatwg组织)
ECMAScript是标准,JavaScript核心语法部分是对其的实现。在日常场合,ECMAScript等同于JavaSctipt。目前我们最常看到的ES版本叫法是ES5(2009)和ES6(泛指2015年及以后版本,后续以ES2020形式命名)。当时由于市场使用率很高的IE的兼容性问题,所以开发者不得不使用Babel等工具将ES6转译为ES5进行兼容处理。现在,IE于2022年6月正式退役,距离ES6发布的2015年也已经过去快8年(2023)了,除了IE系列,pc和移动端浏览器对ES6的兼容情况已经很好了,所以在大部分情况下,我们是可以直接使用ES6语法的。接下来看下ES5与ES6的数据类型:在ES5中:有5个基本类型(原始类型/值类型):Undefined(未定义)、Null(空值)、Boolean(布尔值)、String(字符串)、Number(数值),1个引用类型:Object(对象)。共6个数据类型。
在ES6(ES2015)中:有6个基本类型(原始类型/值类型):Undefined(未定义)、Null(空值)、Boolean(布尔值)、String(字符串)、Number(数值),Symbol(Symbol)1个引用类型:Object。共7个数据类型。
在ES2020中:新增了基本类型BigInt(大整数),目前共有8个数据类型,如下表
数据类型 | 中文 | 表示 |
---|---|---|
Undefined | 未定义 | undefined |
Null | 空值 | null |
String | 字符串 | 'foo', "bar" |
Symbol | Symbol | Symbol('hello') |
Number | 数值 | 3, 3.234, 3e-2 |
BigInt | 大整数 | 900719925124740999n , 1n |
Boolean | 布尔值 | true 和 false |
Object | 对象 | {} |
类型是什么?
前面说了JavaScript是动态弱类型的语言,具体规则是数据类型是否允许隐式转换、运行时的确定类型还是编译时的确定类型,可见类型在编程语言中非常重要。在计算机底层和编译原理角度,编程语言都有数据类型,类型表达了不同粒度下一种数据的结构和约束。如最初我们学习的C语言中的int类型,表达为一个机器字长(32位机一般为32bit)内存表示的数字(机器字长:如32位机是4B,如64位机是8B),而Char类型表达为一个1B的字符(ASCII 编码)。数据的类型赋予了底层数据的意义,也方便了编译器区分代码和数据。在大部分的语言中,数据类型分为了值类型(基本类型)和引用类型,基本类型有number、string、boolean,引用类型有classes、object等,细节类型因语言不同而不同。值类型取得的值是实际值,引用类型取到的值是其他值的引用。这里可以看到引用类型是一种更加复杂的类型。从大概的内存模型角度看(具体的内存模型因不同引擎的实现是不完全符合的),值类型变量存储在栈内存中,引用类型数据存储在堆内存中,引用类型变量仍然存储在栈内存中,访问时获取引用地址后,再从堆内存取得数据。
编程语言中的类型系统是什么
编程语言中的类型系统泛指一门编程语言中的数据类型及类型的规则(具体在语法层面、IDE(静态检查、编译器)、运行时中体现),另一种更官方的解释是类型进行定义、检查和处理的系统。同时类型系统也是计算机科学研究的重要方向。目前最常见到的类型系统的分类依据是动态类型(Dynamic)和静态类型(Static),即静态类型指在编译阶段确定类型,动态类型指在运行阶段确定类型。这里的一个需要关注的是静态类型 ≠显式类型,动态类型≠隐式类型,比如我们看到JavaScript(动态)和Java(静态)分别是典型的隐式类型(let,var等声明)和显式类型(int等类型关键字声明),但实际上。如Haskell、C#等静态语言就可以是隐式静态类型(局部类型推导)。还有一种从TypeScript中了解到的分类是结构化类型系统(Structural Type System)和名义(记名)类型系统(Nominal/Nominative Type System)。前者是基于结构判断类型的兼容性的类型系统,后者是按名字判断的类型系统,目前的主流强类型语言(C++、Java、C#和Swift)大多采用名义(记名)类型系统,而早期的一些语言(OCaml和Go)和TypeScript采用的则是结构化类型系统。
回到JavaScript的类型系统
除了前面提到的数据类型之外,下面会关注一些类型相关的规则或技术:
有限的动态类型检查
动态类型(检查)是像JavaScript这样的动态语言特性之一。运行JavaScript时,发生类型异常时会抛出TypeError错误。常发生在以下情况
- 运算符的操作数的预期类型不一致
- 修改无法被修改的值时
- 调用一个非函数类型(不存在则是undefined类型)
const foo = null;
console.log(foo.prop); //Uncaught TypeError: Cannot read properties of null (reading 'prop')
const bar = undefined;
console.log(bar.prop); //Uncaught TypeError: Cannot read properties of undefined (reading 'prop')
const baz = 123;
baz = 456;//Uncaught TypeError: Assignment to constant variable.
let qux = 789;
qux.push(10); //Uncaught TypeError: qux.push is not a function
隐式类型转换与显式类型转换
JavaScript是一门在执行过程中以表达式求值为核心设计的语言。但一份JavaScript代码文本(通常)总是由语句构成的,语句是代码语法分析中的核心元素。语句中包含了表达式语句,另外还有声明语句、分支语句、循环语句、控制结构。表达式语句是只有表达式,而没有其他语言元素的语句,那么什么是表达式呢?表达式是最终求值的语句,一般是包含运算符与操作数的(除了一些特殊语法和字面量求值),JavaScript的表达式总有结果值—一个值类型或引用类型的数据,或者undefined。隐式类型转换发生表达式中的运算符所需要的操作数类型不一致的场景,主要体现为不同类型向基本类型中的string、number、boolean转换的过程,具体过程同后文提到的toPrimitive过程。
隐式类型转换发生的场景及转换规则
这里按不同预期运算的角度分类:
- 逻辑运算
逻辑运算的操作数与目标类型都是布尔值(true/false),运算符会将操作数隐式转换为布尔值,以进行布尔运算,相关运算符有:||(或),&&(与),!(非),??(空值合并运算符)
规则如下(同显式转换结果):
Boolean(null) // false
Boolean(undefined) // false
Boolean(0) // false
Boolean(false) // false
Boolean(NaN) // false
Boolean("") // false
Boolean("false") // true
Boolean([]) // true
Boolean({}) // true
- 字符串运算
最常见的一种字符串运算就是字符串连接,运算符是“+”,因为“+”还被用在一些其他地方(数值运算),所以有+号的表达是是否是字符串连接要取决于操作数个数和类型,规则是两个操作数,两个操作数一个为字符串,另一个就会隐式转换为字符串;或者两个操作数都不为数字,也会隐式转换为字符串。规则如下(同显式转换结果):
//示例来自 Autumn秋田-https://juejin.cn/post/6844903934876745735
String(null) // "null"
String(undefined) // "undefined"
String(true) // "true"
String(false) // "false"
String(11) // "11"
String(11e20) // "1.1e+21"
String([]) // ""
String([1,null,2,undefined,3]) // 1,,2,,3
String(function a(){}) // "function a(){}"
String({}) // "[object,object]"
String({name:'zhang'}) // "[object,object]"
- 数值运算
一般情况下的数值运算既包括“+-*/”这样的数值运算,也包括数值的位运算,此处仅讨论数值运算相关场景。在JavaScript中,最令人诟病的是+
运算符与==
运算符,比如出自此的一张图:此处先讨论下
+
号运算符,其运算规则为:
- 参与的操作数有一个可以被转换为string,就进行字符串连接
- 除了1情况外,参与的操作数有一个可以被转换为number,就进行数值意义上的相加操作
- 需要关注
{}
本身的二义性导致的特殊表现
一些需要注意的是
- null被转换为'null'或0;undefined可被转换为"undefined"或NaN;
- []可被转换为''或0;[1,2]可被转换为"1,2"或NaN;
- true可被转换为'true'或1,false可被转换为'false'或0;
{}
作为语法/词法符号,具体在JavaScript中有六种作用,此处当{}+x时,{}会被视为一种空代码块,将进行+x运算;x+{}时,{}可被toString为"[object Object]",故进行字符串连接操作。
具体示例如下:
// 示例来自:剑大瑞-https://juejin.cn/post/6854573211208646670
1 + "1" // '11'
1 + "string" // "1tring" (加非数字字符串)
1 + true // 2
1 + false //1
1 + [] // "1"
1 + [1,2,3] // "11,2,3"
1 + {} // "1[object Object]"
1 + null // 1
1 + undefined // 1
null + undefined // NaN
true + 1 // 2
true + "1" // "true1"
true + "true" // "truetrue" (加非数字字符串)
true + true // 2
true + false // 1
true + [] // "true"
true + [1,2,3] // "true1,2,3"
true + "true[object Object]" // NaN
true + null // 1
true + undefined // NaN
false + 1 // 1
false + "1" // "false1"
false + "string" // "falsestring" (加非数字字符串)
false + false // 0
false + true // 1
false + [] // "false"
false + {} // "false[object Object]"
[] + 1 // "1"
[] + "1" // "1"
[] + "string" // "string" (加非数字字符串)
[] + true // "true"
[] + false // "false"
[] + [] // ""
[1] + [1] // "11"
[] + {} // "[object Object]" (注意!!!)
[] + null // "null"
[] + undefined // "undefined"
{} + 1 // 1
{} + "1" // 1
{} + "string" // NaN
{} + true // 1
{} + false // 0
{} + [] // 0 (注意!!!)
{ a:1 } + [] // 0(注意!!!)
{} + [1] // 1 (注意!!!)
{} + [1,2,3] // NaN (注意!!!)
{} + {} // "[object Object][object Object]"
{} + null // 0
{} + undefined // NaN
-Infinity + Infinity // NaN
除了+号,-*/的操作数预期都是数值类型,非数值类型会转换为数值类型后进行运算。
数值类型与非数值类型进行运算都会得到NaN
1-'foo' //NaN
1-false //1
1-true //0
1-[] //0
1-[1,2,3] //NaN
null - undefined //NaN
null - null //0
true - true //0
[] - 1 // -1
1- {} //NaN
undefined - {} //NaN
null - {} //NaN
[] - {} //NaN
- 比较运算
- 等值检测
等值检测的目的是判断两个变量是否相等,JavaScript中有四个运算符
- == 相等
- != 不等
- === 严格相等
- !== 严格不相等
这里先讨论下另外一个常见但非常不推荐使用的运算符==
的运算规则:
类型 | 运算规则 |
---|---|
值类型与引用类型比较 | 引用类型转换为另一个对应的值类型后进行”数据等值“比较注1 |
两个值类型的比较 | 转换成相同数据类型的值进行”数据等值“比较注2 |
两个引用类型的比较 | 比较引用(的地址) |
注1:x=={} 中的{}可被转换为'[object Object]'、NaN、true, {} ==x则会转换为==x操作,引起异常;[]可被转换为''、0、true,[1,2,3]可被转换为'1,2,3'、NaN、true;function可被转换为'function(){}'、NaN、true。注2:字符串与数值类型时,将字符串转为数值类型再进行比较;操作数存在布尔类型时,将另外一个操作数转为布尔类型。
// 示例来自 剑大瑞-https://juejin.cn/post/6854573211208646670
1 == '1' // true
1 == true // true
1 == [] // false
1 == [1] // true
1 == {} // false
true == "1" // true
true == "true" // false
true == [] // true
true == [1] // true
true == {} // false
[] == "1"// false
[] == true // false
[] == false // true
[] == [] // false (注意!!!)
[] == {} // false
[] == ![] // true (注意!!!)
{} == 1 // Unexpected token '=='
{} == "1" // Unexpected token '=='
{} == true // Unexpected token '=='
{} == false // Unexpected token '=='
{} == [] // Unexpected token '=='
{} == {} // Unexpected token '=='
"0" == null // false
"0" == undefined // false
"0" == false // true (注意!!!)
"0" == NaN // false
"0" == 0 // true
"0" == "" // false
false == null // false (注意!!!)
false == undefined // false (注意!!!)
false == NaN // false
false == 0 // true (注意!!!)
false == "" // true (注意!!!)
false == [] // true (注意!!!)
false == {} // false
"" == null // false
"" == undefined //false
"" == NaN // false
"" == 0 //true (注意!!!)
"" == [] // true (注意!!!)
"" == {} // false
0 == null // false
0 == undefined // false
0 == NaN // false
0 == [] // true (注意!!!)
0 == {} // false
+0 == -0 // true
null == null // true
null == undefined // true
null == "" // false
null == 0 // false
undefined == "" // false
undefined == 0 // false
NaN == NaN // false 可以使用isNaN() 判断是不是NaN
不相等运算符(!=)检查其两个操作数是否不相等,并返回布尔结果。它如同==相等运算符一样会进行上述的转换过程。=== 严格相等运算符的运算规则为类型必须一致的情况下比较,不再进行隐式类型转换,如果是值类型进行”数据等值“比较,如果是对象,同样进行引用(的地址)比较。严格不相等运算符(!==)检查它的两个对象是否不相等,返回一个布尔结果。与不相等运算符不同,严格不相等运算符总是认为不同类型的对象是不同的。**b. 序列检测 **序列检测的含义在于比较变量在序列中的大小[插图],即数学概念中的数轴上点的位置先后。序列检测相关的运算符>=、>、<、<=运算规则如下:
类型 | 运算规则 |
---|---|
两个值类型 | 直接比较数据再序列中的大小 |
值类型与引用类型 | 将引用类型的值转换为与值类型数据相同的数据,再进行”序列“大小比较 |
两个引用类型 | 无意义,总是返回false |
2>1 //true
2>true //true Number(true)===1
2>'1' //true
2>undefined //false isNaN(Number(undefined))===true
2>null // true Number(null)===0
2>{} //false Number({})===NaN
2>[] //true Number([])===0
//js的字符串比较算法更复杂
'bc'>'bb' // true (按字母顺序逐字比较)
'a' > 'A' // true (Unicode 编码顺序( UTF-16 ))
true>false // true
const foo = {a:1}
const bar = {a:2}
foo>bar //false
foo>=bar //true 与上个判断存在逻辑异常
foo<bar //false
- 判断语句(if、while、switch/case、for)
判断语句中的隐式类型转换同Boolean方法显式转换结果。
- 部分输出只支持string类型的接口
如alert({})会输出'[object Object]', html中的显示逻辑也与此相同,隐式转换规则同字符串运算的隐式转换规则。
显式类型转换
String、Number、Boolean是JavaScript中的包装类,实际是三个构造函数,可以通过new关键字调用,也可以直接以函数的形式调用。类似以函数形式调用时可以将参数转换为对应的包装类型。目前有如下方法:
- Object(x)
- Number(x)
- Boolean(x)
- String(x)
- Symbol(x)
- BigInt(x)
调用上述方法,将得到一个预期类型的对象。
JavaScript中的类型推断(类型推导)
类型推断是说IDE或编译器可以将隐式类型(未显式声明类型)的变量自动推导出数据类型的能力,我是通过TypeScript才让了解到这个概念,它一般是强静态类型语言的特性,因为JavaScript是解释型语言,类型在运行时确定,所以JavaScript并不具备类型推断的能力。像Rust, Haskell、C++11、TypeScript、Kotlin、Go等静态语言都具有类型推断能力。
JavaScript中的包装类与拆装箱过程
在面向对象的语言中通常认为”一切皆对象“,JavaScript语法也借鉴了Java,那JavaScript中的基本类型要如何与”一切皆对象“(OOP思想)的统一连接起来呢,结果就是为部分基础类型设定对应的包装类,通过包装类,就可以像使用对象一样来处理值类型数据。
基础类型 | 字面量 | 包装类 |
---|---|---|
undefined | undefined | (不需要包装类) |
boolean | true、false | Boolean |
number | 数值 | Number |
string | 'foo'、"bar"、baz | String |
symbol | (无) | Symbol |
bigint | (无) | BigInt |
通过包装类创建的基本类型将存储在堆中,用字面量直接创建的将存储在栈中,后者在使用上更加高效。最常见也最用的三个 包装类是Boolean、Number、String。
装箱
装箱(包装)(boxing)是指将基本类型(值类型)转换为对应的引用类型的操作,又分为隐式装箱与显式装箱。隐式装箱发生在一个基本类型通过.
运算符(成员存取运算符)调用方法时发生。因为基本类型成为对象才有属性可以被使用。
let foo = 'hello';
let bar = foo.substring(0,1)//substing 返回index1 到index2的字符串
// 等同于
let foo = new String('hello');
let bar = foo.substring();
let baz = 123;
Object.prototype.getSelf = function(){ return this }
let me = baz.getSelf(); //隐式装箱过程发生在这个存取运算过程中
console.log(typeof me); //object
let str = 'hello'
str.age = 23
//在方法调用结束后,临时创建的包装对象将被清理
console.log(str.age) // undefined
显式装箱是通过包装类实例化一个基本类型的对象。
let foo = new String('hello');
console.log(foo,typeof foo);//String {'hello'} 'object'
let bar = new Object(123);
console.log(bar,typeof bar);//Number {123} 'object'
拆箱
拆箱(unboxing)是把引用类型转换为基本类型的操作,会遵循ECMAScript规范规定的toPrimitive过程:
- 如果是基本类型,则不处理。
- 调用valueOf(),并确保返回值是基本类型。
- 如果没有valueOf这个方法或者valueOf返回的类型不是基本类型,那么对象会继续调用toString()方法。
- 如果同时没有valueOf和toString方法,或者返回的都不是基本类型,那么直接抛出TypeError异常。
注意:如果preferedType=string,那么2、3顺序调换。
抽象操作 ToPrimitive
接受一个输入参数和一个可选的参数 PreferredType
。抽象操作 ToPrimitive
将其输入参数转换为一个值类型。参数格式为ToPrimitive(input [, PreferredType])
,具体细节参考此文章。
// 隐式拆箱
// 示例来自 - ConardLi-https://juejin.cn/post/6844903854882947080
const obj = {
valueOf: () => { console.log('valueOf'); return 123; },
toString: () => { console.log('toString'); return 'ConardLi'; },
};
console.log(obj - 1); // valueOf 122
console.log(`${obj}ConardLi`); // toString ConardLiConardLi
const obj2 = {
[Symbol.toPrimitive]: () => { console.log('toPrimitive'); return 123; },
};
console.log(obj2 - 1); // valueOf 122
const obj3 = {
valueOf: () => { console.log('valueOf'); return {}; },
toString: () => { console.log('toString'); return {}; },
};
console.log(obj3 - 1);
// valueOf
// toString
// TypeError
// 显式拆箱
var num =new Number("123");
console.log( typeof num.valueOf() ); //number
console.log( typeof num.toString() ); //string
值类型的不可变性与赋值修改过程
在JavaScript中,原始类型或值类型(primitive value)是具有不可变性的(immutable)。具体是7种基本数据类型的字面量(值)是不可变的,但变量本身的指向仍是可以被改变的。也就是说如果尝试修改一个值类型的值,实际上是创建了一个新的值并将其赋值给相应的变量或属性,而不是修改原始值。JavaScript的值类型的不可变性为程序员带来了更好的理解性、更安全的并发执行、更容易优化和更少的副作用等多个优势。(--ChatGPT)
let x = 10;
x = 20; // 创建一个新的数字值20,并将其分配给x变量
var foo = 'Hello';
foo.slice(1);
foo.substr(1);
console.log(foo); // Hello foo的方法并没有修改原始值,而是返回一个修改后的值
foo = foo+' World';
console.log(foo); //Hello World
var bar = foo;
bar = '233';
console.log(foo);//Hello World 值类型复制是值的拷贝,不改变原始值
console.log(bar);//233
JavaScript中的类型判断(类型检测)
此内容摘抄于此。
这里需要再说明一点,ECMAScript规范的类型系统与具体的JavaScript实现之间是存在差异的,具体更多可以参见JavaScript语言精粹与编程实战》-周爱民-2.2.1.4 讨论:ECMAScript的类型系统。类型检测的方法:
- typeof
- instanceof
- Object.prototype.toString
- constructor
具体来说
1. typeof
typeof 操作符返回一个字符串,表示未经计算的操作数的类型。能被typeof检查的类型称为第一类类型(first class type),跟据之前我们分析的数据类型,目前应该有8中,分别是string、number、boolean、undefined、null、symbol、bigint、object。但实际上如下:
typeof 'foo' //'string'
typeof 123 //'number'
typeof true //'boolean'
typeof undefined // 'undefined'
typeof null // 'object'
typeof Symbol('s') //'symbol'
typeof BigInt(1) //'bigint'
typeof {} //'object'
typeof [] //'object'
typeof function(){} //'function'
可以发现:
- 实际上null值是对象类型的一个特殊实例,JavaScript中并没有Null类型,这里一方面可以理解为一种历史错误(JS的发明者Brendan Eich曾说),另一方面从正面角度理解,null表达的是没有指向任何对象的引用(reference)。
- 另外一个是
function
是和string
等一样的JavaScript的第一类类型,这里就和ECMAScript的规范有差异,ECMAScript中称最基础的对象为普通对象,其他在基础对象上添加定制行为的被称为变体对象,在这里,array、function实际都是object的变体对象(或者称为子类型),这种设计可以理解为函数是JavaScript中的一等公民(函数式语言特性)。 - 所以typeof的结果值依然是8种。
typeof 操作符适合对 基本类型(除 null 之外)及 function 的检测使用,而对引用数据类型(如 Array)等不适合使用。
2. instanceof
如果按照周爱民老师书6.2中提到的JavaScript存在两套类型系统,一套是基础类型系统(Base types),是由typeof运算来检测的,该类型系统包括8种类型(string、number、boolean、undefined、object、bigint、symbol、function)。[注意概念的相似性和差异,此处的基本类型≠基本类型(原始类型)]第二个就是对象类型系统(Object types),对象类型系统是“对象基础类型(object)"的一个分支。这其中就通过包装类将基本类型纳入了进来。typeof是无法对object类型做进一步区分的,如果需要知道一个对象的具体类型时,就需要instanceof来检测对象类型。instanceof 运算符用于检测一个对象在其 原型链 中是否存在一个构造函数的 prototype 属性。左操作数为对象,不是就返回 false,右操作数必须是 函数对象 或者 函数构造器,不是就返回 TypeError 异常。
function Person() {}
function Student() {}
Student.prototype = new Person();
Student.prototype.constructor = Student;
const ben = new Student();
ben instanceof Student;
// true
const one = new Person();
one instanceof Person;
// true
one instanceof Student;
// false
ben instanceof Person;
// true
任何一个构造函数都有一个 prototype 对象属性,这个对象属性将用作 new 实例化对象的原型对象。
- instanceof 适合用于判断对象是否属于 Array、Date 和 RegExp 等内置对象。
- 不同 window 或 iframe 之间的对象类型检测无法使用 instanceof 检测。
3. Object.prototype.toString
使用 Object.prototype.toString 方法能精准地判断出值的数据类型。
可以通过 toString() 来获取每个对象的类型。为了 每个对象 都能通过 Object.prototype.toString 来检测,需要以 Function.prototype.call 或者 Function.prototype.apply 的形式来调用,传递要检查的对象作为第一个参数。
Obejct.prototype.toString.call(undefined);
// "[object Undefined]"
Obejct.prototype.toString.call(null);
// "[object Null]"
Obejct.prototype.toString.call(true);
// "[object Boolean]"
Obejct.prototype.toString.call('');
/// "[object String]"
Obejct.prototype.toString.call(123);
// "[object Number]"
Obejct.prototype.toString.call([]);
// "[object Array]"
Obejct.prototype.toString.call({});
// "[object Object]"
⚠️ 注意事项:
- 方法重写:Object.prototype.toString 属于 Object 的原型方法,而 Array 或 Function 等类型作为 Object 的实例,都重写了 toString 方法。因此,不同对象类型调用 toString 方法时,调用的是重写后的 toString 方法,而非 Object 上原型 toString 方法,所以采用 xxx.toString() 不能得到其对象类型,只能将 xxx 转换成字符串类型。
4. constructor
任何对象都有 constructor 属性,继承自原型对象,constructor 会指向构造这个对象的构造器或构造函数。
Student.prototype.constructor === Student;
// true
补充说明: 语言的强弱类型一般是指是否允许类型的隐式转换,但有时也把强弱类型等同为静态类型或动态类型,本文仅认同为前者的一般的解释。
参考
转载自:https://juejin.cn/post/7212821769030402107