这篇文章记录了在做 JavaScript 继承的时候突然发现的一个 TypeScript 的坑:继承(extends
)对于静态字段/函数的语义和主流 OOPL 不同。
TypeScript 目前(1.6.2)没有解决,于是就记录下来了。最后提出了一种兼容方案。
在对 Bulletproof 重构的时候发现了一个问题。在 WebGL 的实现中,我用了一个 RenderTarget
类。这个类的渲染目标是一个纹理缓冲区(就像 XNA/MonoGame 中的 RenderTarget2D
类那样),在 WebGL 绘制的时候需要提供顶点数据。而这个纹理的顶点是固定的(永远保持原始大小),底层绑定到一个 WebGLBuffer
,最好的方案就是用一个静态字段保存。如果你做过 WebGL 编程,你可能知道,任何一个 WebGLBuffer
的创建都是需要通过一个 WebGLRenderingContext
来完成的,而这个上下文是对于一个 <canvas>
而言的,所以这个“静态字段”并不是严格的静态(与实例无关性)。
但是,JavaScript 不支持静态初始化,所以我只好写出了这种代码:
class RenderTarget {
constructor() {
if (!RenderTarget._isInitializedStatically) {
RenderTarget.__staticInit();
}
// ...
}
static __staticInit(): void {
// 各种静态字段的初始化
// ...
RenderTarget._isInitializedStatically = true;
}
static _isInitializedStatically: boolean = false;
static _textureCoords: WebGLArrayBuffer = null; // WebGLArrayBuffer 是我编写的类
static _textureIndicies: WebGLArrayBuffer = null;
}
确实,这样在我的程序里是没问题的,一切工作都如预期,RenderTarget
及其子类的静态字段整个程序生命周期内只被初始化了一次。
然后现在,由于我实在不想忍受 TypeScript 的模块行为(这个行为导致我将所有强相关模块放到了一个超大的 .ts 文件里,这就是为什么 Bulletproof 的每个文件大而臃肿),决定这次用 JavaScript 重写,自己控制导出。
简单说,TypeScript 中有一个 export
关键字,控制着类(和其他)的编译结果。如果不带 export
,则这个类/模块会被声明为全局下的一个闭包;如果带,会用 exports.ModuleOrClassName = ModuleOrClassName
或者 ModuleName.ClassName = ClassName
这样导出,其中 ModuleOrClassName
和 ClassName
是对应的闭包。
我希望的调用方式是这样的:
import Class1 = require("moduleFile.classFile"); // 导入 Module1.Class1
module Module1 {
export class Class2 extends Class1 {
// ...
}
}
但是 TypeScript 狠狠地嘲笑了我一番。如果在 moduleFile.classFile
中的模块 module1
不使用 export
修饰符的话,import
语句会报错说找不到那个文件导出的对象;如果用则必须用这样的语法导入:
import module1External = require("moduleFile.classFile");
import Class1 = module1External.module1.Class1; // 这一步其实是创建别名(alias),不是文件导入
// ...
你看,那个 .module1.Class1
是一个非常烦人的东西。这里是一个很好的例子,里面的各种引用因共用 bulletproof
命名空间,还得分多个变量来做别名,十分混乱。
还是继续说正题吧。这次做继承,我立刻想到的是 Function.call()
。但是转念一想,静态字段怎么办?想到有 TypeScript 的先例,我就翻出了其编译的结果:
// d: ChildFunction, b: ParentFunction
var __extends = (this && this.__extends) || function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
在继承链上,除了在构造函数中调用了 Function.call()
(这里没展示)之外,在此之前调用的就是这个 __extends()
。慢着!除了对 prototype
和 constructor
的处理,对静态字段/函数怎么会只有一个简单的键值对遍历-复制过程?稍微有点了解的人应该会意识到,如果被复制的对象是一个基元类型,在父子类之间是无法保持值的关联的。也即,修改了其中一个的值,另一个不会改变。
这可能会导致意外的结果。例如,如果这个静态字段不是在初始化时发生变化的,而是懒惰求值的(例子见下文的 TypeScript 示例),那么就会造成父子类中此字段的值不一致。如果此时需要访问此字段,则会发生问题:如果你是父类,你怎么知道子类的此字段的值呢?再举个栗子,如果上文中 _isInitializedStatically
字段是延迟更新的,那么实际上就会在创造每个类的时候都将字段初始化一次,总次数是 1+N 次,其中 N 为子类数量。这不是我们所希望的。
为了验证这一点,我们先要知道传统的面向对象的语言是怎么处理这种情况的。一般来说,静态变量会被放到一个全局内存区域,保持其全局的唯一性。在这里我用 C# 来演示这个 static
对于成员变量的语义:
using System;
static class Program {
class ParentClass
{
// 值类型(value type)测试
public static int StaticInt = 0;
// 引用类型(reference type)测试
public static Uri StaticUri = null;
}
class ChildClass : ParentClass
{
public static void ChangeInt()
{
StaticInt = 1;
}
public static void ChangeUri()
{
StaticUri = new Uri(@"http://www.baidu.com/?pn=foo");
}
}
static void Main(string[] args)
{
Console.WriteLine($"Original StaticInt: {ParentClass.StaticInt}");
ChildClass.ChangeInt();
Console.WriteLine($"New StaticInt: {ParentClass.StaticInt}");
Console.Write("Original StaticUri: ");
Console.WriteLine(ParentClass.StaticUri == null ? "null" : ParentClass.StaticUri.AbsoluteUri);
ChildClass.ChangeUri();
Console.Write("New StaticUri: ");
Console.WriteLine(ParentClass.StaticUri == null ? "null" : ParentClass.StaticUri.AbsoluteUri);
Console.ReadKey();
}
}
输出:
Original StaticInt: 0
New StaticInt: 1
Original StaticUri: null
New StaticUri: http://www.baidu.com/?pn=foo
可以见到,在声明为 static
的时候,ParentClass.StaticInt
和 ParentClass.StaticUri
被放到了一个全局区域,ChildClass
在继承的时候没有进行隐藏(shadow),所以引用的是父类的这两个静态字段。根据结果我们可以看出,它们确实是全局唯一的,对于 ParentClass
和 ChildClass
,指向的是同一个位置。所以,即使是修改了 ChildClass
中的值,其修改对象其实为静态对象的定义位置——即是 ParentClass.StaticInt
和 ParentClass.StaticUri
,这个修改在所有引用了这两个字段的类中都得到了反映。(我没有输出 ChildClass.StaticInt
和 ChildClass.StaticUri
的值,因为要验证的是全局的同步,输出这两个不能证明要证明的东西。)
然后看一下在弄清楚了继承机制下的 TypeScript:
class ParentClass {
// 基元类型(primitive type)测试
static StaticInt = 0;
// 引用类型(reference type)测试
static StaticObject = Object.create(null);
}
class ChildClass extends ParentClass {
static changeInt(): void {
ChildClass.StaticInt = 1;
}
static changeObject(): void {
ChildClass.StaticObject["key"] = "value";
}
}
console.log("Original StaticInt: ", ParentClass.StaticInt);
ChildClass.changeInt();
console.log("New StaticInt (Parent): ", ParentClass.StaticInt);
console.log("New StaticInt (Child): ", ChildClass.StaticInt);
console.log("Original StaticObject: ", ParentClass.StaticObject["key"]);
ChildClass.changeObject();
console.log("New StaticObject (Parent): ", ParentClass.StaticObject["key"]);
console.log("New StaticObject (Child): ", ChildClass.StaticObject["key"]);
输出:
Original StaticInt: 0
New StaticInt (Parent): 0
New StaticInt (Child): 1
Original StaticObject: undefined
New StaticObject (Parent): value
New StaticObject (Child): value
我们同样仅改变 ChildClass
的静态字段,输出 ChildClass
和 ParentClass
的静态字段的值。可以看到,输出是意料之中的(如果事先了解 TypeScript 对 extends
的编译结果的话),基元类型没有发生改变,而引用类型由于在继承时复制了字段引用所以会发生改变。
可以看到,在值类型/基元类型静态字段的继承行为上,C#(其实也可以把 Java 揪来)和 TypeScript 虽然有着相近的语法,但是有着不同的行为。换一个角度,也可以说,对于熟悉传统面向对象编程语言的人来说,TypeScript 的 extends
语义是有问题的,很容易让人被坑。
插一句,静态函数是没有这个问题的,因为新声明的函数对原函数的行为是隐藏,在 C# 和 JavaScript 中是一致的,只不过 JavaScript 没有一个明确表明此意图(而不是不小心写了一个同名函数)的注释或关键字,而 C# 有 new
(VB .NET 有 Shadows
)。
当然这可能是有意为之的。毕竟考虑到与 JavaScript 的兼容性(TypeScript 是 JavaScript 的超集),连访问成员字段/函数都必须每个加 this
、访问静态字段/函数必须加类名呢。例如,我如果指定的是 ParentClass.StaticInt
,和指定 ChildClass.StaticInt
相比,输出的结果就不一样了。因此,在使用静态字段/函数之前,必须好好考虑使用的是谁的字段——不方便(例如在不知道完整继承树、不知道谁声明了这个字段的情况下),但是绝对严谨。所以,将其判定为语义错误的结论是有待商榷的。
反过来看原来的代码为什么工作似乎正常,不过也只是看上去正常。这是因为这些字段在第一个 RenderTarget
的子类(RenderTarget
不是直接使用的)初始化时被初始化。初始化的时候做了什么呢?
- 检查
RenderTarget._isInitializedStatically
,如果为true
则不初始化静态字段。(当然,如果一个类在其任何子类实例化之前初始化了自己的静态字段,则其子类必然不会重复初始化,因为复制的这个字段值为true
。) - 初始化各个静态字段。除了
_isInitializedStatically
,其他都是引用类型。 - 将
RenderTarget._isInitializedStatically
设为true
。 - 通过将这个行为包装在
RenderTarget.constructor
中,确保子类同样在构造函数中执行此行为。
可以看到,由于所有类访问的都是 RenderTarget._isInitializedStatically
而不是各个类的 _isInitializedStatically
,加上所有要访问的字段都是引用类型,歪打正着:由于所有 RenderTarget
及其派生类的第一个实例创建时设置了全局唯一的标志(RenderTarget._isInitializedStatically
)所以这个字段只在此时被修改,其他所有的初始化过程访问的都是这个标志所以会跳过静态初始化,同时由于它们的其他静态字段都是引用类型所以指向了同一个位置,访问时就是同一个引用。
如果要和主流 OOPL 的静态字段继承语义保持一致(其实 JavaScript 并不是 OOPL),可以利用引用的不变性来解决这个问题。简单说就是将这些字段保存到一个自动生成的对象中。编译的结果可能会像这个样子:
var ParentClass = (function () {
function ParentClass() {
}
ParentClass.$someStrangeLongStaticMemberName.StaticInt = 0;
ParentClass.$someStrangeLongStaticMemberName.StaticObject = Object.create(null);
return ParentClass;
})();
var ChildClass = (function (__super) {
__extends(ChildClass, __super);
function ChildClass() {
__super.call(this, arguments);
}
return ChildClass;
})(ParentClass);
此时对 StaticInt
和 StaticObject
的引用自动识别(在符号表里有记录的吧),分别编译为访问 $someLongStaticMemberName.StaticInt
和 $someLongStaticMemberName.StaticObject
,这样由于共享了 $someLongStaticMemberName
的引用,因此更改能同步,也就符合了常见的语义。
- 问1:如果子类声明了新的静态字段怎么办?
- 答1:在符号表里加入一个
ChildClass.$someLongStaticMemberNameForChild
呗。 - 问2:为什么不改
tsc
呢? - 答2:因为……代码集中到了一个文件里,太大……如果只是提一个 issue 的话倒是可以吧……
用上文的 RenderTarget
初始化举一个栗子。为了避免初始化不确定性,如果使用原生 JavaScript,配合 Node.js require()
的缓存特性,可以实现静态初始化并避免暴露无关的静态字段。
var WebGLArrayBuffer = require("./WebGLArrayBuffer");
// 父类静态初始化标志不暴露
var isStaticallyInitialized = false;
function RenderTarget(glc) {
if (!isStaticallyInitialized) {
staticInit(glc);
}
}
module.exports = ParentClass;
RenderTarget._textureCoords = null;
RenderTarget._textureIndicies = null;
// 父类的静态初始化函数不暴露
function staticInit(glc) {
RenderTarget._textureCoords = WebGLArrayBuffer.create(glc, ...);
RenderTarget._textureIndicies = WebGLArrayBuffer.create(glc, ...);
isStaticallyInitialized = true;
}
var __extends = require("./__extends");
var RenderTarget = require("./RenderTarget");
var __super = RenderTarget;
function PrimitiveRenderTarget(glc) {
__super.call(this, glc);
}
__extends(PrimitiveRenderTarget, __super);
module.exports = PrimitiveRenderTarget;
当然,如果使用情景没有这么复杂(需要延迟初始化)的话,将静态字段的初始化代码放在 staticInit()
中并直接调用,就实现了类静态构造函数的语义:
function ParentClass() {
}
module.exports = ParentClass;
ParentClass.StaticObject = null;
(function staticInit() {
ParentClass.StaticObject = Object.create(null);
ParentClass.StaticObject.foo = "bar";
})();