TypeScript 面向对象与泛型——从类到抽象类

本文为「TypeScript 深入理解」系列第 2 篇,涵盖类、泛型、枚举、迭代器、继承、多态和抽象类。第 1 篇介绍了基础类型系统和函数签名。

如果说接口定义了数据的形状,那类和泛型解决的是更高层的问题——如何组织行为、如何让类型随数据流动。

类:TypeScript 的面向对象

TypeScript 为 ES6 的 class 提供了类型注解和访问修饰符,让你在面向对象编程时获得更强的约束。

访问修饰符控制成员的可见性:public(默认,外部可访问)、private(仅类内部可访问)、protected(类内部和子类可访问)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Person {
// public 是默认值,外部可访问
public name: string;
// private 只能在类内部访问
private age: number;
// protected 在子类中也能访问
protected gender: string;
// static 属于类而非实例
static species = 'Homo sapiens';
// readonly 只读属性,只能在声明时或构造函数中赋值
readonly birthDate: Date;

constructor(name: string, age: number, gender: string, birthDate: Date) {
this.name = name;
this.age = age;
this.gender = gender;
this.birthDate = birthDate;
}

// getter / setter 通过 get / set 关键字定义
get formattedAge(): string {
return `${this.age} years old`;
}

set updateAge(newAge: number) {
if (newAge < 0) throw new Error('Age cannot be negative');
this.age = newAge;
}
}

const person = new Person('Alice', 30, 'female', new Date('1994-01-01'));
console.log(person.name); // Alice
console.log(Person.species); // Homo sapiens
// person.age; // Error: 'age' is private

参数属性是一种简写方式,在构造函数参数前加上修饰符,TypeScript 会自动声明并赋值对应的属性:

1
2
3
4
5
6
7
8
9
class Person {
constructor(
public name: string,
private age: number,
protected gender: string,
readonly birthDate: Date
) {}
}
// 等价于上面手动声明的写法,代码更简洁

泛型:让类型成为参数

泛型允许你编写可复用的代码,让类型成为参数。泛型本质上就是类型的参数化,你可以在函数、接口、类中使用它。

1
2
3
4
5
6
7
// T 是类型参数,调用时传入具体类型
function identity<T>(arg: T): T {
return arg;
}

console.log(identity<string>('hello')); // 'hello'
console.log(identity(42)); // 42,类型自动推断为 number

泛型在集合操作中特别有用,比如一个返回数组最后一个元素的函数:

1
2
3
4
5
6
7
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}

console.log(last([1, 2, 3])); // 3
console.log(last(['a', 'b', 'c'])); // 'c'
console.log(last([])); // undefined

使用 extends 约束泛型参数的范围,确保传入的类型满足特定条件:

1
2
3
4
5
6
7
8
// 约束 T 必须包含 length 属性
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}

console.log(getLength('hello')); // 5
console.log(getLength([1, 2, 3])); // 3
// getLength(123); // Error: number 没有 length 属性

泛型在接口和类中的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 泛型接口
interface KeyValuePair<K, V> {
key: K;
value: V;
}

const pair1: KeyValuePair<string, number> = { key: 'age', value: 30 };
const pair2: KeyValuePair<number, string> = { key: 1, value: 'first' };

// 泛型类
class Stack<T> {
private items: T[] = [];

push(item: T): void {
this.items.push(item);
}

pop(): T | undefined {
return this.items.pop();
}
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2

keyof 操作符常与泛型一起使用,用于约束属性名:

1
2
3
4
5
6
7
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { name: 'Alice', age: 30, email: 'alice@example.com' };
console.log(getProperty(user, 'name')); // 'Alice'
// getProperty(user, 'phone'); // Error: 'phone' 不存在于 user

枚举:给魔法数字一个名字

枚举为一组数值或字符串赋予友好的名称,让代码更具可读性。

1
2
3
4
5
6
7
8
9
10
11
12
// 数字枚举,默认从 0 开始递增
enum Direction {
Up = 0,
Down,
Left,
Right,
}

console.log(Direction.Up); // 0
console.log(Direction.Right); // 3
// 反向映射:从值取名称
console.log(Direction[0]); // 'Up'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 字符串枚举,不具备反向映射
enum HttpStatus {
OK = '200 OK',
NotFound = '404 Not Found',
InternalError = '500 Internal Server Error',
}

function handleResponse(status: HttpStatus) {
switch (status) {
case HttpStatus.OK:
console.log('Request succeeded');
break;
case HttpStatus.NotFound:
console.log('Resource not found');
break;
default:
console.log('Something went wrong');
}
}
1
2
3
4
5
6
7
8
9
// const enum 编译后会被内联,不生成额外代码
const enum Color {
Red,
Green,
Blue,
}

const c = Color.Red;
// 编译后: const c = 0;

迭代器与生成器

TypeScript 支持 ES6 的迭代器协议和生成器语法。一个对象实现了 Symbol.iterator 方法就算可迭代的,for...of 循环会自动调用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 自定义可迭代对象
class Range implements Iterable<number> {
constructor(private start: number, private end: number) {}

[Symbol.iterator](): Iterator<number> {
let current = this.start;
const end = this.end;
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
}
}

for (const n of new Range(1, 5)) {
console.log(n); // 1, 2, 3, 4, 5
}

生成器用 function* 定义,通过 yield 逐步产出值,执行可以在每次 yield 后暂停:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* fibonacci(): Generator<number, void, unknown> {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3

for...of 遍历可迭代对象,for...in 遍历对象的可枚举属性(键名),两者不要混淆:

1
2
3
4
5
6
7
8
9
const arr = ['a', 'b', 'c'];

for (const item of arr) {
console.log(item); // 'a', 'b', 'c' 遍历值
}

for (const key in arr) {
console.log(key); // '0', '1', '2' 遍历键名(字符串)
}

面向对象:继承、多态与抽象

TypeScript 提供了完整的面向对象支持。如果你从 Java 或 C# 转过来,这些概念会很熟悉——但即便你一直是 JavaScript 开发者,理解它们也能让你的代码组织方式上一个台阶。

继承与多态

继承使用 extends 关键字,子类可以复用父类的属性和方法,也可以重写它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Animal {
constructor(public name: string) {}

speak(): string {
return `${this.name} makes a sound`;
}
}

class Dog extends Animal {
// 重写:子类重新定义父类方法
speak(): string {
return `${this.name} says Woof!`;
}
}

class Cat extends Animal {
speak(): string {
return `${this.name} says Meow!`;
}
}

// 多态:同一接口,不同行为
const animals: Animal[] = [new Dog('Rex'), new Cat('Whiskers')];
animals.forEach(a => console.log(a.speak()));
// Rex says Woof!
// Whiskers says Meow!

方法重写时,子类可以通过 super 调用父类的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class GuideDog extends Dog {
constructor(name: string, public owner: string) {
super(name); // 调用父类构造函数
}

speak(): string {
// 调用父类的 speak 方法,再拼接额外信息
return `${super.speak()} (Guide dog for ${this.owner})`;
}
}

console.log(new GuideDog('Buddy', 'Alice').speak());
// Buddy says Woof! (Guide dog for Alice)

方法重载在类的上下文中同样适用。先声明重载签名,最后给出统一实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Calculator {
calculate(x: number): number;
calculate(x: string): number;
calculate(x: number | string): number {
if (typeof x === 'string') {
return x.length;
}
return x * 2;
}
}

const calc = new Calculator();
console.log(calc.calculate(5)); // 10
console.log(calc.calculate('abc')); // 3

抽象类:定义模板而非实现

abstract 修饰符用于定义抽象类和抽象方法。抽象类不能被直接实例化,只能被继承。抽象方法只有声明没有实现,子类必须提供具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
abstract class Shape {
// 抽象方法:子类必须实现
abstract getArea(): number;

// 普通方法:子类直接继承使用
describe(): string {
return `This shape has an area of ${this.getArea()}`;
}
}

class Circle extends Shape {
constructor(public radius: number) {
super();
}

getArea(): number {
return Math.PI * this.radius ** 2;
}
}

class Rectangle extends Shape {
constructor(public width: number, public height: number) {
super();
}

getArea(): number {
return this.width * this.height;
}
}

// const shape = new Shape(); // Error: 无法创建抽象类的实例
const circle = new Circle(5);
const rect = new Rectangle(4, 6);

console.log(circle.describe()); // This shape has an area of 78.53981633974483
console.log(rect.describe()); // This shape has an area of 24

抽象类和接口的核心区别:抽象类可以有实现代码(普通方法),接口只能定义签名。抽象类用来说明「是什么」的继承关系,接口用来声明「能做什么」的能力约定。


下一篇将介绍 TypeScript 的装饰器系统和编译原理——装饰器如何在编译期对类进行元编程改造,以及 tsc 从源码到 JS 的五步编译管线。