TypeScript 的一个语义问题,或者是一个需要注意的语法细节

这篇文章记录了在做 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 这样导出,其中 ModuleOrClassNameClassName 是对应的闭包。

我希望的调用方式是这样的:

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()。慢着!除了对 prototypeconstructor 的处理,对静态字段/函数怎么会只有一个简单的键值对遍历-复制过程?稍微有点了解的人应该会意识到,如果被复制的对象是一个基元类型,在父子类之间是无法保持值的关联的。也即,修改了其中一个的值,另一个不会改变。

这可能会导致意外的结果。例如,如果这个静态字段不是在初始化时发生变化的,而是懒惰求值的(例子见下文的 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.StaticIntParentClass.StaticUri 被放到了一个全局区域,ChildClass 在继承的时候没有进行隐藏(shadow),所以引用的是父类的这两个静态字段。根据结果我们可以看出,它们确实是全局唯一的,对于 ParentClassChildClass,指向的是同一个位置。所以,即使是修改了 ChildClass 中的值,其修改对象其实为静态对象的定义位置——即是 ParentClass.StaticIntParentClass.StaticUri,这个修改在所有引用了这两个字段的类中都得到了反映。(我没有输出 ChildClass.StaticIntChildClass.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 的静态字段,输出 ChildClassParentClass 的静态字段的值。可以看到,输出是意料之中的(如果事先了解 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 不是直接使用的)初始化时被初始化。初始化的时候做了什么呢?

  1. 检查 RenderTarget._isInitializedStatically,如果为 true 则不初始化静态字段。(当然,如果一个类在其任何子类实例化之前初始化了自己的静态字段,则其子类必然不会重复初始化,因为复制的这个字段值为 true。)
  2. 初始化各个静态字段。除了 _isInitializedStatically,其他都是引用类型。
  3. RenderTarget._isInitializedStatically 设为 true
  4. 通过将这个行为包装在 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);

此时对 StaticIntStaticObject 的引用自动识别(在符号表里有记录的吧),分别编译为访问 $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";
})();
分享到 评论