likes
comments
collection
share

JavaScript | 动态类型的前世今生(上)

作者站长头像
站长
· 阅读数 13

JavaScript | 动态类型的前世今生(上)

动态类型是 JavaScript 的动态语言特性中最有代表性的一种。动态执行与动态类型是天生根植于 JavaScript 语言核心设计中的基础组件,它们相辅相成,导致了 JavaScript 在学习上是易学难精,在使用中是易用易错。是好是坏难以定论。

类型系统的简化

从根底上来说,JavaScript 有着两套类型系统,如果仅以此论,那么还算不上复杂。 但是 ECMAScript 对语言类型的约定,又与 JavaScript 原生的、最初的语言设计不同,这导致了各种解释纷至沓来,很难统一成一个说法。而且,ECMAScript 又为规范书写而订立了一套类型系统,并不停地演进它。这就如同雪上加霜,导致 JavaScript 的类型系统越发地说不清楚了。 在讨论动态类型的时候,可以将 JavaScript 类型系统做一些简化,从根底里来说, JavaScript 也就是 typeof() 所支持的 7 种类型,其中的“对象(object)”与“函数 (function)”算一大类,合称为引用类型,而其他类型作为值类型。 无论如何,我们且先以这种简单的类型划分为基础,来讨论 JavaScript 中的动态类型。因为这样一来,JavaScript 中的类型转换变得很简单、很干净,也很易懂,可以用两条规则概括如下:

  1. 从值 x 到引用,调用 Object(x) 函数。
  2. 从引用 x 到值,调用 x.valueOf() 方法;或调用 4 种值类型的包装类函数,例如 Number(x),或者 String(x) 等等。

简单吧?当然不会这么简单。

先搞定一半

在类型转换这件事中,有“半件”是比较容易搞定的。 这个一半,就是“从值 x 到引用”。因为主要的值类型都有对应的引用类型,因此 JavaScript 可以用简单方法一一对应地将它们转换过去。 使用Object(x)来转换是很安全的方法,在用户代码中不需要特别关心其中的x是什么样的数据——它们可以是特殊值(例如 null、undefined 等),或是一般的值类型数据,又或者也可以是一个对象。所有使用 Object(x) 的转换结果,都将是一个尽可能接近你的预期的对象。例如,将数字值转换成数字对象:

x = 1234;
Object(x); 
[Number: 1234]

类似的还包括字符串、布尔值、符号等。而 null、undefined 将被转换为一个一般的、空白的对象,与new Object或一个空白字面量对象(也就是{ })的效果一样。这个运算非常好用的地方在于,如果 x 已经是一个对象,那么它只会返回原对象,而不会做任何操作。也就是说,它没有任何的副作用,对任何数据的预期效果也都是“返回一个对象”。而且在语法上,Object(x)也类似于一个类型转换运算,表达的是将任意x转换成对象x。 简单的这“半件事”说完后,我们反过来,接着讨论将对象转换成值的情况。

值 VS 原始值(Primitive values)

任何对象都会有继承自原型的两个方法,称为 toString() 和 valueOf(),这是 JavaScript 中“对象转换为值”的关键。 一般而言,你可以认为“任何东西都是可以转换为字符串的”,这个很容易理解,比如 JSON.stringify() 就利用了这一个简单的假设,它“几乎”可以将 JavaScript 中的任何对象或数据,转换成 JSON 格式的文本。

所以在 JavaScript 中将任何东西都转换成字符串这一点,在具体的处理技术上并不存在什么障碍。 但是如何理解“将函数转换成字符串”呢? 从最基础的来说,函数有两个层面的含义,一个是它的可执行代码,也就是文本形式的源代码;另一个则是函数作为对象,也有自己的属性。 所以,函数也可以被作为一个对象来转换成字符串,或者说,序列化成文本形式。 又或者再举一个例子,我们需要如何来理解将一个“符号对象”转换成“符号”呢?是的, 我想你一定会说,没有“符号对象”这个东西,因为符号是值,不是对象。其实这样讲只是对了一半,因为现实中确实可以将一个“符号值”转换为一个“符号对象”,例如:

(new Object).toString() 
'[object Object]'

为了将这个问题“一致化”——也就是将问题收纳成更小的问题,JavaScript 约定,所有“对象 -> 值”的转换结果要尽量地趋近于 string、number 和 boolean 三者之一。不过这从来都不是书面的约定,而是因为 JavaScript 在早期的作用,就是用于浏览器上的开发,而:

  • 浏览器可以显示的东西,是 string;
  • 可以计算的东西,是 number;
  • 可以表达逻辑的东西,是 boolean。

因此,在一个“最小的、可以被普通人理解的、可计算的程序系统中”,支持的“值类型数 据”的最小集合,就应该是这三种。

这个问题不仅仅是浏览器,就算是一台放在云端的主机,你想要去操作它,那么通过控制台登录之后的 shell 脚本,也必须支持它。更远一点地说,你远程操作一台计算机,与浏览器用户要使用 gmail,这二者在计算的抽象上是一样的,只是程序实现的复杂性不一样而已。

所以,对于 ECMAScript 5 JavaScript 来说,当它支持值转换向对应的对象时,或者反过来从这些对象转换回值的时候,所需要处理的也无非是这三种类型而已。而处理的具体方法也很简单,就是在使用Object(x)来转换得到的对象实例中添加一个内部槽,存放这个x的值。更确切地说,下面两行代码在语义上的效果是一致的:

obj = Object(x); 
// 等效于(如果能操作内部槽的话) 
obj.[[PrimitiveValue]] = x;

于是,当需要从对象中转换回来到值类型时,也就是把这个PrimitiveValue值取出来就 可以了。而“取出这个值,并返回给用户代码”的方法,就称为valueOf()。 到了 ECMAScript 6 中,这个过程就稍稍有些不同,这个内部槽是区别值类型的,因此为每种值类型设计了一个独立的私有槽名字。加上 ES8 中出现的大整数类型(BigInt),一共就有了 5 个对应的私有槽:[[BooleanData] [[NumberData]]、[[StringData] [[SymbolData]]和[[BigIntData]]。

那么在 ECMAScript 6 之后,除[[PrimitiveValue]]这个私有槽变成了 5 种值类型对应的、独立的私有槽之外,还有什么不同呢?

是的,这个你可能也已经注意到了。ECMAScript 6 之后还出现了Symbol.toPrimitive 这个符号。而它,正是将原本的[[PrimitiveValue]]这个私有槽以及其访问过程标准化,然后暴露给 JavaScript 用户编程的一个界面。

说到这里,就必须明确一般的值(Values)与原始值(Primitive values)之间的关系了。不过,在下一步的讨论之前,先总结一下前面的内容:也就是说,从typeof(x)的 7 种结果类型来看,其中 string、boolean、number、 bigint 和 symbol 的值类型与对象类型转换,就是将该值存入私有槽,或者从私有槽中把相应的值取出来就好了。 在语言中,这些对应的对象类型被称为“包装类”,与此相关的还有“装箱”与“拆箱”等行为,这也是后续会涉及到的内容。

所以,一种关于“原始值”的简单解释是:所有 5 种能放入私有槽(亦即是说它们有相应的包装类)的值(Values),都是原始值;并且,再加上两个特殊值 undefined 和 null, 那么就是所谓原始值(Primitive values)的完整集合了。 接下来,如果转换过程发生在“值与值”之间呢?

隐式转换

由于函数的参数没有类型声明,所以用户代码可以传入任何类型的值。对于 JavaScript 核心库中的一些方法或操作来说,这表明它们需要一种统一、一致的方法来处理这种类型差异。例如说,要么拒绝“类型不太正确的参数”,抛出异常;要么用一种方式来使这些参 数“变得正确”。 后一种方法就是“隐式转换”。但是就这两种方法的选择来说,JavaScript 并没有编码风格层面上的约定。基本上,早期 JavaScript 以既有实现为核心的时候,倾向于让引擎吞掉类型异常(TypeError),尽量采用隐式转换来让程序在无异常的情况下运行;而后期,以 ECMAScript 规范为主导的时候,则倾向于抛出这些异常,让用户代码有机会处理类型问题。

隐式转换最主要的问题就是会带来大量的“潜规则”。 例如经典的String.prototype.search(r)方法,其中的参数从最初设计时就支持在r参数中传入一个字符串,并且将隐式地调用r = new RegExp(r)来产生最终被用来搜索的正则表达式。而new RegExp(r)这个运算中,由于RegExp()构造器又会隐式地将r从任何类型转换为字符串类型,因而在这整个过程中,向原始的r参数传入任何值都不会产生任何的异常。

隐式转换导致的“潜规则”很大程度上增加了理解用户代码的难度,也不利于引擎实现。因 此,ECMAScript 在后期就倾向于抛弃这种做法,多数的“新方法”在发现类型不匹配的时 候,都设计为显式地抛出类型错误。一个典型的结果就是,在 ECMAScript 3 的时代, TypeError 这个词在规范中出现的次数是 24 次;到了 ECMAScript 5,是 114 次;而 ECMAScript 6 开始就暴增到 419 次。

因此,越是早期的特性,越是更多地采用了带有“潜规则”的隐式转换规则。然而很不幸的 是,几乎所有的“运算符”,以及大多数常用的原型方法,都是“早期的特性”。 所以在类型转换方面,JavaScript 成了“潜规则”最多的语言之一。

消化一下

到现在为止,这一节课其实才开了个头,也就是对“a + b”这个标题做了一个题解而已。 这主要是因为在 JavaScript 中有关类型处理的背景信息太多、太复杂,而且还处在不停的变化之中。许多稍早的信息,与现在的应用环境中的现状,或者你手边可备查的资料之间都存在着不可调和的矛盾冲突,因此对这些东西加以梳理还原,实在是大有必要的。这也就是为什么会写到现在,仍然没有切入正题的原因。

你至少应该知道这些:

  • 语言中的引用类型和值类型,以及 ECMAScript 中的原始值类型(Primitive values)之间存在区别;
  • 语言中的所谓“引用类型”,与 ECMAScript 中的“引用(规范类型)”是完全不同的概念;
  • 所有值通过包装类转换成对象时,这个对象会具有一个内部槽,早期它统一称为 [[PrimitiveValue]],而后来 JavaScript 为每种包装类创建了一个专属的;使用 typeof(x) 来检查 x 的数据类型,在 JavaScript 代码中是常用而有效方法;
  • 原则上来说,系统只处理 boolean/string/number 三种值类型(bigint 可以理解为 number 的特殊实现),其中 boolean 与其他值类型的转换是按对照表来处理的。

总的来说,类型在 JavaScript 中的显式转换是比较容易处理的,而标题“a + b”其实包含了太多隐式转换的可能性,因此尤其复杂。关于这些细节,下一篇再说。

参考:红宝书,犀牛书,你不知道的 JS、 JS 核心原理解析

转载自:https://juejin.cn/post/7153215295929188365
评论
请登录