元类型系统是对JavaScript内建概念的补充

本文是一个系列,包括:

  • JavaScript的元系统 - 在这里
  • JavaScript中创建原子的几种方法 - 在这里
  • 元类型系统是对JavaScript内建概念的补充(本文)

JavaScript的类型系统一贯是反人类的,以至于JavaScript之父Eich都会跳出来说“我做错了”。但是这并不是说它的整个类型系统就是不可理解的,相反,它提供了观察这门语言的多个不同角度。

远古JavaScript中的类型系统

在远古时期(我是指JavaScript 1.0)中,JavaScript的世界里还并没有“原型”这个东西,而且undefined也是一个奇葩的存在。

这时的undefined是一个概念定义而非值声明,它表明函数或某个运算(例如属性存取)没有返回值。由于还没有===!==运算符,所以undefined被约定为与null是等值的。

考察这个阶段,可以发现整个类型系统其实只有如下几种值类型。每种类型都包括一个它们各自表示(逻辑上的)“无值”的成员:

  • 字符串值,空串是无值;
  • 数字值,NaN是无值(数字值中的0表达非值);
  • 布尔值,false是非值(或也可以理解为无值);

接下来就是对象和函数。它们都是引用类型(从最早的JavaScript语言就这样定义了),而且也各有一个表达“无值”的方式:

  • 函数(以及表达式运算或值运算等)的结果:undefined
  • 对象:null

这基本上就是JavaScript 1.0时代对类型系统的全部理解,以及假设。——之所以称之为假设,是因为这时连typeof运算符都没有,所我们无法在语言的层面上验证它。

不过有趣的是,这个时代是支持面向对象编程的。JavaScript 1.0中的面向对象是“基于类”的,它有“类构造对象(的实例)”的概念:

obj = new Object;

// OR
function MyObject() {
    this.x = 100;
}
obj = new MyObject;

在这时,Object/MyObject已经被称为构造器(constructor)了。它采用的是被称为“类抄写”的方式,通过向实例“this”上添加成员来初始化对象。

这个时代并没有原型继承,也不支持instanceof运算,因此尽管Object/MyObject执行“构造一个对象”的职责,却没有人认得它是“”。由于JavaScript默认函数总是可以作new运算,并且this可以缺省指向global,因此它是不是“类”就不要紧,用new运算时都不会出错。

NOTE1:往前追溯20多年,一本名为《结构程序设计( Structured programming)》的书定义了面向对象编程。其中说:如果一个过程产生了比它生存时间更久的实例,那么这个过程就称为类,而这个实例就称为对象。因此,JavaScript 1.0时代的面向对象是古典的、传统的设计,不可思议却又历久弥新。

NOTE2:有关JavaScript 1.0,可以参见这里:https://docs.oracle.com/cd/E19957-01/816-6408-10/object.htm#1193255

概念灾难的源头

从JavaScript 1.1开始,这门语言总算提供了“识别自己的类型”的运算,也就是著名的typeof。在语义上,它提出的想法是:用undefined表达语言层面的“没有(无值)”。

由于在JavaScript 1.0中已经为stringboolean等各自定义了一个它们在语言层面上的“没有”,因此这一设计也就保留了下来。在1.1中,typeof返回如下六个值之一:

  • 针对值类型:'undefined'、'string'、'boolean'和'number'

  • 针对引用类型:'object'和'function'

有了undefined,JavaScript可以自如地表达类型间的运算,例如A + B。任何函数/过程总应该是有一个静态结果的,因此要么它是值,要么它就是无值(undefined)。——基本想法是:如果算不出结果,那么这个过程应当返回undefined

null表示的是对象层面的没有,因此它被理解为一个对象,也就是说它的typeof值仍然是'object'。——就好象说NaN表达数值上的‘无值’,但仍然是数字值。例如:

> typeof null
'object'

但是null又并不是由对象系统创建出来的,因此它不是Object()或其子类的实例。因此:

> null instanceof Object
false

对象也会参与跨类型的值计算,因此它也需要一个“对象的值的含义”。于是Object.prototype.valueOf()就出现了。当JavaScript在值运算中发现操作数(x)是对象时,就会调用x.valueof()来得到它的值类型数据,并以此为操作数来进行值运算。这个过程很简洁,也很完美。

稍稍差一点的是null,它是对象,但又不继承自Object(),所以没有Object.prototype.valueOf(),而它就必须“被理解”有自己的value。——但是,如果一个运算数“有自己的value”,那么它不就是值么?

是的。概念开始混乱了。

JavaScript在1.x版本中确立的类型系统

无论如何,JavaScript在1.x版本中确立了自己对类型系统的理解。

types_in_javascript_1.x

这个类型系统可以完美地概括JavaScript中可能的各种对象和概念理解。其中"值类型"用于进行值运算或表达值运算的结果,而引用类型用于“索引到”一个值。

在这个系统中,null并不在基本类型系统中,它只是在“对象类型系统”中的、一个特殊对象的字面量表示。

惹祸的ECMA

ECMA开始制定ECMA-262(也就是ECMAScript)标准时,JavaScript已经发展到了v1.2版本,一直到后来发布ECMAScript ed3时(1999.12),它才基本与JavaScript v1.3对齐。随后(2000.07~11月),JavaScript发布了v1.5以及JScript发布了v5.5,才将现实中可用的JavaScript版本与ECMA规范标准对应起来,基本上三者同一了。

然而从ECMAScript ed1开始,它就采用了一种“不同寻常”的类型描述方法。在ECMAScript中,一直将Null独立作为一个类型来描述,并称之为“六种基本类型(six standard types)”之一。而这,显然是与JavaScript的typeof的返回值有异的。并且ECMAScript还描述了几种用于实现JavaScript的“内部类型(internal types),其中最重要的就是从ECMAScript ed1就开始包括的完成(Completion)、引用(Reference)和列表(List)。

从ECMAScript ed5开始,“基本类型”与“内部类型”就分别被称为语言类型与规范类型了。

根底上的原因,还在于ECMAScript并不是要描述“JavaScript的语言性质”,而是要描述“JavaScript如何实现”。在比目标语言级别更高的维度上,ECMAScript通过所谓“规范类型”来描述和解释“实现JavaScript”时可能操作的数据以及操作这些数据的方式。——它们被严格的区分为两种:对象,或非对象。其中,function类型从一开始就是对象,因而不存在语义矛盾。出于在前面讲到的种种理由,处理null时就比较尴尬了:它在JavaScript中是对象,却用来表示“没有对象(对象的‘无’值)”。

ECMAScript再一次在概念上向typeof的结果说了不,它约定:null是值,是非对象类型的。

ECMAScript中的类型系统

所以一直以来,JavaScript不得不按“两种类型系统”来进行语言描述,一种是JavaScript应用环境中的,称为“语言类型(Language types)”;另一种则是ECMAScript中的,称为“规范类型(Specification type)”。而ECMAScript在规范中描述的“语言类型”,还与它在实现中使用typeof()得到的结果不一致。

  • 在这两种类型系统中,JavaScript语言认为null是个是引用类型(Object)的值,而ECMAScript认为null是个原始类型(Primitive types)的值。

    NOTE3:很不幸的是,在讨论JavaScript的时候,这两种描述都是对的。

types_in_ecmascript

在ECMAScript中考察一个值(V)的类型时,是根据其内部操作Type()的结果值,来确定该值V是或不是“ECMAScript language value”,或者是更具体的某个类型。

当JavaScript引擎在分析脚本代码时,会将代码解析成为数据或执行逻辑以便后续处理,这(通常)也就对应于ECMAScript规范类型中的记录(Record)和词法环境(Lexical Environment)。而在运行期,任何的JavaScript代码最终都会被理解成“可执行的”语句或表达式,并且当一个操作(op)是表达式时,它总有结果值;而当操作是语句时,它的最终状态是由一个称为“完成记录(Completion Record)”的规范类型来存放的——并用这个记录的[[value]]字段来存放语句的结果值。

下图完整地展示了四种主要的规范类型的使用。

specification_type_in_ecmascript

null仍然是无法解释的

上述由ECMAScript构建的类型系统运转良好——比如我们确实可以按照这样的规范来编写一个JavaScript引擎。然而它仍然无法有效地解释null值的语言特性。

尤其是在ES5之后。因为ES5开始提供了新的创建对象的语法:

obj = Object.create(null)

ECMAScript无法对“对象create自null”这样的语义给出合理解释,其根本原因在于它隐藏(至少是有意不讨论)这样的一个事实:Object.prototype就是一个“创建自null”的对象:

> Object.getPrototypeOf(Object.prototype)
null

ECMAScript禁止用户代码向Object.prototype置值或改变属性——这很容易理解,它将prototype创建为一个只读且不可配置的属性就可以了。但是,ECMAScript还同时禁止了用户代码改变Object.prototype的原型,亦即是:

// 当置为非null值时将触发异常
> Object.setPrototypeOf(Object.oprototype, {})
TypeError: Immutable prototype object '#<Object>' ...

所以Object.prototype被称为“不可变原型对象(Immutable prototype object)”。——在ECMAScript中目前只有Object.prototype和模块名字空间的原型会这样(尽管后者并没有明确指出)。

NOTE4: Object.prototype是唯一被明确约定为“不可变原型对象(Immutable prototype object)”的,而<aNamespaceOfModule>.prototype却只是置它的[[SetPrototypeOf]]内部槽为一个特殊的写方法。这二者的处理方法并不一致,一定程度上是为了强调在“Object.prototype是什么对象”这个问题上的特殊性。

ECMAScript只说明了“该对象不可变原型”这样的性质,却没有解释“原型为null的对象是什么”这一问题。因为ECMAScript在语义上并没有对应于“创建自null的对象”这样的概念。也正是因此,下面的类声明才显得牵强:

class MyClass extends null {
    // ...
}

并且当它没有自有的构造方法(constructor)时,才会出现下面的问题:

  • extends xxx决定了缺省情况下由父类来创建实例;但是
  • extends null表明父类是null;所以,
  • 创建过程出现异常。
> new MyClass
TypeError: Super constructor null of MyClass ...

Metameta的概念补充

在Metameta(@aimingoo/metameta)中对这一现象给出了自己的解释:

  • 当一个对象创建自null时,它是一个原子;且,
  • 派生自这种原子的、非Object()及其子类实例的对象,是原子对象。

在这样的解释下,可以看到Object.prototype本质上来说也是一个原子对象。亦即是说,我们找到了ECMAScript/JavaScript创建自己的对象系统的原始方式。加上JavaScript开放了get/setPrototypeOf()和有关操作属性描述符的方法,于是我们既得了创建“原子/原子对象”的能力,也得到了在“属性表”这一级别维护这些原子自有成员的能力。

进一步的,由于JavaScript中的'function'类型事实上也是对象,因此我们既得到了表达静态数据的原子,也得到了支持运算过程的原子。再加上JavaScript还通过eval()提供了原生的解析代码和操作执行上下文的能力,那么——事实上——我们也就得到了整个的原子计算环境/运行框架。

这也是Metameta提供了一个.from()工具方法的原因:Metameta致力于扩展JavaScript的内建概念,并试图用为ECMAScript建立一个更为完整的概念集。在这其中,.from()所体现出来的事实是:JavaScript的对象系统,是以原子为基础的类型系统的一个实现。

如图:

types_of_object_system

而下图则是更完整的、以元类型为基础来实现的、对象/类型系统的景象:

meta_type_system