Non-Profit, International

Spirit unsterblich.

TypeScript 手册指北 Part3

字数统计:3649 blog

TypeScript 手册指北 第三部分,第二部分在 TypeScript 手册指北 Part2,记录了本人阅读 TypeScript 官方手册的一些想法,包含 Classes 的内容。

ECMAScript 6 和 TypeScript 的 Class 本质上是带属性的函数的模板,这是由于 JavaScript 中的函数,在 new 之后可以通过 this 来对自身的属性赋值,如果不是通过 new,则 this 一般指向全局对象。

TypeScript 的 Class 声明在开启 strictPropertyInitialization 后强制使用初始化器或者提供构造函数,以防出现未初始化的变量:


class Point {
    x: number = 0
    y: number = 0
}

class OKGreeter {
    // Not initialized, but no error
    name!: string
}

理所当然的,在有初始化器的情况下类型标注是可选的。如果既没有类型标注也没有初始化器,则类型也理所当然的是 any

如果某一类型的对象来自外部的库,则可使用 ! 来让编译器放弃对初始化的要求。

与 C++ 不同的是,构造函数必须使用 this 来对属性进行赋值,这其实也是理所当然的,因为 Class 在实现上等价于函数:


class GoodGreeter {
    name: string
    constructor() {
        this.name = 'hello'
    }
}

function GoodGreeter() {
    this.name = 'hello'
    return this
}

当然,TypeScript 的语法中 Class 不是函数,这两种写法不等价,这种表示只是为了说明原因。

和 C++ 的 const 类似,TypeScript 可以把属性标记为只读,但构造函数不受此限制:


class Greeter {
    readonly name: string
    constructor(otherName: string = '') {
        this.name = otherName
    }
    /*
    constructor(otherName?: string) {
        if (otherName !== undefined) {
            this.name = otherName
        }       
        otherName = ''
    }
    */
}

和普通函数一样,构造函数也可也有默认值,也可以有可选参数,初始化的方式和 C++ 一样多样,并且构造函数和普通函数一样支持重载。

和 C++ 类似的是,构造函数也没有泛型参数和返回值。

很不幸的是,TypeScript 可能是为了减轻 JavaScript 学习者的负担,派生类调用基类构造并没有选择 C++ 和 C# 的先进方式,而是选择了与 JavaScript 兼容,使用了和 Java 一样的方式,通过在派生类的函数体第一句上使用 super 代表基类构造函数来完成基类的构造。所以 JavaScript 和 Java 没有关系这句话是假的。2015 年还抄 Java 的这个特性,看得出来 ECMAScript 委员会真的很没品。

TypeScript 还支持 getter 和 setter:


class C {
    private _length = 0
    get length() {
        return this._length
    }
    set length(value) {
        this._length = value
    }
}

getter 和 setter 可以伪装成一个只读/只写的属性,内部操纵真正的属性,从而使得成员为 private 的时候能够进行单向访问控制。

TypeScript 4.3 开始 setter 和普通函数一样参数支持联合类型(实际上本来就可以支持,因为如果不标注默认为 any)。

由于 Type-only Field Declarations 的原因,不建议用 getter 和 setter,属于垃圾特性。

索引签名

Classes 和 object 类型类似,支持索引签名:


class MyClass {
    [s: string]: boolean | ((s: string) => boolean)
    check(s: string) {
        return this[s] as boolean
    }
}

继承


interface Pingable {
    ping(): void
}
class Sonar implements Pingable {
    ping() {
        console.log('ping!')
    }
}

TypeScript 中派生类可以实现(implements)多个接口或者类型别名,使用逗号分隔。

和 C++ 不同的是,TypeScript 中的 interfacetype 的类型系统和 class 的类型系统是独立的两个系统,这意味着实现了 interface 的一个 class 并不代表它也是这个 interface 的对象:记住,class 的对象是 function,而 interface 的对象是 object

实际上实现 interface 或者 type 更像是加上了一种约束,并且 class 内的属性必须完全和其实现的接口或者类型别名一致,这意味着不能依赖实现目标来自动推导出属性的类型。

和 C++ 的继承类似的是 extends,当然,TypeScript 没有虚函数,所以整套“继承”系统实际上是通过“实现”来约束属性,通过“扩展”来复用代码。


class Animal {
    move() {
        console.log('Moving along!')
    }
}

class Dog extends Animal {
    woof(times: number) {
        for (let i = 0 i < times i++) {
            console.log('woof!')
        }
    }
}

理所当然的,“扩展”后的派生类也支持覆盖基类的函数,同时,和 Java 类型,使用 super 关键字来指代基类实现调用基类被覆盖的函数。

和 C++ 类似的是,派生类重写的函数必须兼容被覆盖的函数:


class Base {
    greet() {
        console.log('Hello, world!')
    }
}

class Derived extends Base {
    greet(name?: string) {
        if (name === undefined) {
            super.greet()
        } else {
            console.log(`Hello, ${name.toUpperCase()}`)
        }
    }
    // greet(name: string)
    // not assignable to the same property
    // Type '(name: string) => void' is not assignable to type '() => void'.
}

TypeScript 的成员初始化方式和 C++ 类似,先使用基类初始化器,再使用构造函数,再使用派生类初始化器和派生类构造函数。

TypeScript 的函数也是属性的一种,函数也是对象,由于 Type-only Field Declarations 的原因,需要避免基类和派生类中的属性名字撞车,除非覆盖掉基类的函数,否则不要使用一个名字。

实际上现行版本中,在派生类中重复定义的基类属性,会覆盖掉基类的属性,并且可以改变 protected 可见性。

成员可见性

TypeScript 支持成员可见性,使用类似 C++ 的 protectedprivatepublic 来控制成员是否对外部或者派生类可见。

幸运的是,ECMAScript 不支持成员可见性,所以 TypeScript 不需要向 ECMAScript 妥协:若类 Base 存在受保护成员 p,则外部不可以直接访问 pDerived 继承 Base,则 Derived 的成员函数不能通过 Base 的引用访问 p,但可以通过 Dervied 的引用访问 p。C# 和 C++ 遵循相同的规则,而 Java 允许此时通过 Base 的引用访问 p

和 C++ 类似,基类的受保护成员的可见性可以被修改,而基类的私有成员则不可见。

此外,TypeScript 允许使用下标属性访问法访问私有成员。

构造函数

不仅仅通过属性声明来声明属性,还可以通过构造函数声明属性:


class FileSystemObject {
    constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
    constructor(path: string, public content: string) {
    }
}

在构造函数的参数上添加可见性修饰符,或者只读说明符则可将该参数声明为属性,构造函数内会自动生成 this.para = para

静态成员

和 C++ 类似,TypeScript 也支持静态成员。静态成员可以控制可见性,使用和 C# 一样的访问方式。

有一点需要注意的是,在 JavaScript 的类型系统中,继承是通过原型实现的,所以有一些名字已经被使用了,无法作为静态成员:namelengthcall

静态块

TypeScript 从 ECMAScript 2022 中间接的继承了 Java 的静态块用于初始化静态成员。但这实在是一个鸡肋的功能,除了增加逻辑的复杂度以外毫无用处。

泛型类

理所当然的,类也支持泛型,并且和泛型函数有着类似的语法。并且和 C++ 的类模板实参推导(CTAD)类似的是,TypeScript 支持泛型类实参推导,这意味着可以通过构造函数来推导出泛型类型。

this 归属

由于 JavaScript 为动态类型,因此代码的行为会随着类型的改变而改变:


class MyClass {
    name = 'MyClass'
    getName() {
        return this.name
    }
}
const c = new MyClass()
const obj = {
    name: 'obj',
    getName: c.getName,
}
 
// Prints 'obj', not 'MyClass'
console.log(obj.getName())

虽然这种写法在 TypeScript 不常见而且很难发生,但是需要注意的是,obj 可能是用户创建的,并且所有 TypeScript 代码都会转换为 JavaScript 执行。

为了解决这个问题,有两种解决方案:


class MyClass {
    name = 'MyClass'
    getName = () => {
        return this.name
    }
}

使用箭头函数可以消除错误,但是会占用更多的内存,并且不能再使用 super 来调用基类的函数。


class MyClass {
    name = 'MyClass'
    getName(this: MyClass) {
        return this.name
    }
}

另一种则是类似 C++ 中的显式对象形参,添加额外的 this 参数来进静态检查:在 TypeScript 环境中不允许对 this 类型进行转换。因此错误的使用会被检查出来。

this 类型

在类内,this 还可以作为类型标注:


class Box {
    content: string = ''
    sameAs(other: this) {
        return other.content === this.content
    }
}

注意,这时候 this 类似于 C++ 中的模板显式对象形参,这意味着一个派生自 Box 的类的引用不能使用 sameAs

类型谓词

可以使用 is 来构造类型谓词:


class FileSystemObject {
    isFile(): this is FileRep {
        return this instanceof FileRep
    }
    isDirectory(): this is Directory {
        return this instanceof Directory
    }
    isNetworked(): this is Networked & this {
        return this.networked
    }
    constructor(public path: string, private networked: boolean) {}
}

一个常见的应用是判断可选属性:


class Box<T> {
    value?: T
    hasValue(): this is { value: T } {
        return this.value !== undefined
    }
}

const box = new Box()
box.value = 'Gameboy'
// (property) Box<unknown>.value?: unknown

if (box.hasValue()) {
    box.value
    // (property) value: unknown
}

抽象成员和方法

和 C++ 的抽象类类似,TypeScript 使用 abstract 关键字来声明一个抽象类:


abstract class Base {
    abstract getName(): string
    printName() {
        console.log('Hello, ' + this.getName())
    }
}

const b = new Base()
// Cannot create an instance of an abstract class.

不同的是,必须先把类定义为抽象,才能声明抽象方法。

构造签名

Part2 中提到过约束泛型类的构造,还有另一种方式:


function create<Type>(c: { new (): Type }): Type {
    return new c()
}
// 可以改写为如下形式:
function create<Type>(c: new () => Type ): Type {
    return new c()
}

实际上也可以约束普通函数:


function greet(ctor: new () => Base) {
    const instance = new ctor()
    instance.printName()
}
greet(Derived)

其中 Derived 继承 Base

类型之间的关系

TypeScript 中,具有相同属性的类型是同一类型,在一个类型的基础上添加属性,则原类型是添加后的类型的子类型。


若无特殊声明,本文以 CC BY-SA 3.0 许可协议 提供。