MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Typescript中的抽象类和接口

2023-01-302.7k 阅读

一、理解抽象类

在 TypeScript 中,抽象类是一种不能被实例化的类,它主要为其他类提供一个通用的基类。抽象类可以包含抽象方法和具体方法。抽象方法是没有实现体的方法,必须在子类中被重写。

1.1 抽象类的定义

使用 abstract 关键字来定义抽象类。例如:

abstract class Animal {
    // 具体属性
    name: string;

    // 构造函数
    constructor(name: string) {
        this.name = name;
    }

    // 具体方法
    eat() {
        console.log(`${this.name} is eating.`);
    }

    // 抽象方法,没有方法体
    abstract makeSound(): void;
}

在上述代码中,Animal 是一个抽象类,它有一个具体属性 name,一个具体方法 eat 和一个抽象方法 makeSound

1.2 子类继承抽象类

当一个类继承抽象类时,必须实现抽象类中的所有抽象方法。例如:

class Dog extends Animal {
    constructor(name: string) {
        super(name);
    }

    makeSound() {
        console.log(`${this.name} barks.`);
    }
}

class Cat extends Animal {
    constructor(name: string) {
        super(name);
    }

    makeSound() {
        console.log(`${this.name} meows.`);
    }
}

这里 DogCat 类继承自 Animal 抽象类,并实现了 makeSound 抽象方法。这样我们就可以创建 DogCat 的实例:

let dog = new Dog('Buddy');
dog.eat();
dog.makeSound();

let cat = new Cat('Whiskers');
cat.eat();
cat.makeSound();

运行这段代码,我们会看到相应的输出,Buddy is eating.Buddy barks.Whiskers is eating.Whiskers meows.

1.3 抽象类的作用

抽象类的主要作用是为一组相关的类提供一个通用的接口和实现。它定义了一些公共的属性和方法,子类可以继承并扩展这些功能。同时,通过抽象方法,强制子类实现特定的行为,保证了一定的一致性。

比如在一个图形绘制的应用中,我们可以定义一个抽象的 Shape 类:

abstract class Shape {
    abstract calculateArea(): number;
    abstract draw(): void;
}

class Circle extends Shape {
    radius: number;

    constructor(radius: number) {
        super();
        this.radius = radius;
    }

    calculateArea() {
        return Math.PI * this.radius * this.radius;
    }

    draw() {
        console.log(`Drawing a circle with radius ${this.radius}`);
    }
}

class Rectangle extends Shape {
    width: number;
    height: number;

    constructor(width: number, height: number) {
        super();
        this.width = width;
        this.height = height;
    }

    calculateArea() {
        return this.width * this.height;
    }

    draw() {
        console.log(`Drawing a rectangle with width ${this.width} and height ${this.height}`);
    }
}

在这个例子中,Shape 抽象类定义了 calculateAreadraw 抽象方法。CircleRectangle 子类分别实现了这些方法,以提供特定的行为。这样,我们可以将不同形状的对象统一管理,例如在一个数组中存储不同形状的对象,并调用它们的公共方法:

let shapes: Shape[] = [];
shapes.push(new Circle(5));
shapes.push(new Rectangle(4, 6));

for (let shape of shapes) {
    console.log(`Area: ${shape.calculateArea()}`);
    shape.draw();
}

这段代码会输出圆和矩形的面积以及绘制信息,通过抽象类实现了代码的复用和多态性。

二、认识接口

接口在 TypeScript 中用于定义对象的形状(shape),它描述了对象应该具有的属性和方法,但不包含具体的实现。

2.1 接口的定义

使用 interface 关键字来定义接口。例如:

interface Person {
    name: string;
    age: number;
    greet(): void;
}

上述代码定义了一个 Person 接口,它要求实现该接口的对象必须有一个 name 属性(类型为 string),一个 age 属性(类型为 number)以及一个 greet 方法(无返回值)。

2.2 实现接口

一个类可以通过 implements 关键字来实现接口。例如:

class Employee implements Person {
    name: string;
    age: number;

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

    greet() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}

这里 Employee 类实现了 Person 接口,提供了接口中要求的属性和方法的具体实现。

2.3 接口的特性

接口具有以下一些特性:

  1. 可选属性:接口中的属性可以是可选的。例如:
interface Car {
    brand: string;
    model?: string;
    year: number;
}

在这个 Car 接口中,model 属性是可选的。一个对象只要满足 brandyear 属性的要求,并且可以有 model 属性,就符合这个接口。

let myCar: Car = {
    brand: 'Toyota',
    year: 2020
};
  1. 只读属性:可以将接口中的属性定义为只读。例如:
interface Point {
    readonly x: number;
    readonly y: number;
}

Point 接口中,xy 属性是只读的,一旦对象创建,这些属性的值就不能被修改。

let point: Point = {
    x: 10,
    y: 20
};
// point.x = 15; // 这会导致编译错误
  1. 函数类型接口:接口还可以定义函数的形状。例如:
interface AddFunction {
    (a: number, b: number): number;
}

这个 AddFunction 接口定义了一个函数,它接受两个 number 类型的参数,并返回一个 number 类型的值。可以这样使用:

let add: AddFunction = function(a: number, b: number): number {
    return a + b;
};
  1. 可索引接口:可以定义对象的索引类型。例如:
interface StringDictionary {
    [key: string]: string;
}

StringDictionary 接口表示一个对象,它的所有键都是 string 类型,值也是 string 类型。

let dict: StringDictionary = {};
dict['name'] = 'John';
dict['city'] = 'New York';

三、抽象类与接口的比较

虽然抽象类和接口都用于定义某种规范,但它们在很多方面存在差异。

3.1 定义和实现方式

  • 抽象类:抽象类使用 abstract 关键字定义,可以包含具体的属性、方法和抽象方法。子类通过 extends 关键字继承抽象类,并实现抽象方法。
  • 接口:接口使用 interface 关键字定义,只包含属性和方法的签名,不包含具体实现。类通过 implements 关键字实现接口,提供接口中定义的属性和方法的具体实现。

3.2 实例化

  • 抽象类:不能直接实例化,只能通过子类来创建实例。例如,不能 new Animal(),但可以 new Dog()
  • 接口:接口本身更像是一种类型定义,不能被实例化。它用于约束对象或类的形状。

3.3 继承和实现关系

  • 抽象类:一个类只能继承一个抽象类,因为类继承是单继承的。例如,class Dog extends AnimalDog 不能同时继承另一个抽象类。
  • 接口:一个类可以实现多个接口,用逗号分隔。例如:
interface Flyable {
    fly(): void;
}

interface Swimmable {
    swim(): void;
}

class Duck implements Flyable, Swimmable {
    fly() {
        console.log('Duck is flying.');
    }

    swim() {
        console.log('Duck is swimming.');
    }
}

这里 Duck 类同时实现了 FlyableSwimmable 接口。

3.4 成员特性

  • 抽象类:抽象类可以包含具体的属性和方法,这些属性和方法可以被子类直接继承和使用。抽象类中的抽象方法强制子类重写。例如,Animal 抽象类中的 eat 方法是具体方法,DogCat 子类可以直接使用。
  • 接口:接口只定义属性和方法的签名,不包含具体实现。接口中的所有属性和方法都是抽象的,实现接口的类必须提供所有属性和方法的具体实现。

3.5 使用场景

  • 抽象类:当需要定义一组相关类的通用行为和状态,并且希望有一些具体的实现可以被子类复用,同时有一些行为需要子类根据自身特点去实现时,适合使用抽象类。比如前面提到的 Shape 抽象类,它定义了图形的通用行为 calculateAreadraw,同时具体的图形类 CircleRectangle 可以复用一些基础的结构。
  • 接口:当需要定义对象的形状,或者希望不同类之间有统一的外部接口时,适合使用接口。例如,不同的类如 EmployeeCustomer 等可能都需要实现 Person 接口,以提供统一的 nameagegreet 行为。

四、深入探讨抽象类和接口的细节

4.1 抽象类中的构造函数

抽象类可以有构造函数,并且在子类继承抽象类时,子类的构造函数必须调用 super 来调用抽象类的构造函数。这是为了确保抽象类中的属性能够正确初始化。例如:

abstract class Base {
    value: number;

    constructor(value: number) {
        this.value = value;
    }

    abstract print(): void;
}

class Derived extends Base {
    constructor(value: number) {
        super(value);
    }

    print() {
        console.log(`The value is ${this.value}`);
    }
}

在这个例子中,Base 抽象类有一个构造函数来初始化 value 属性。Derived 子类在构造函数中通过 super(value) 调用了 Base 抽象类的构造函数,以确保 value 属性被正确初始化。

4.2 接口的合并

TypeScript 允许同名接口进行合并。当有多个同名接口定义时,它们的成员会被合并到同一个接口中。例如:

interface User {
    name: string;
}

interface User {
    age: number;
}

let user: User = {
    name: 'Alice',
    age: 30
};

这里两个 User 接口合并后,User 接口就同时有了 nameage 属性。这种特性在模块化开发中很有用,不同的模块可以为同一个接口添加不同的成员。

4.3 抽象类与接口的嵌套

在 TypeScript 中,抽象类和接口都可以进行嵌套定义。例如,在抽象类中定义接口:

abstract class Outer {
    interface Inner {
        message: string;
    }

    abstract getInner(): Inner;
}

class Implementation extends Outer {
    getInner(): Outer.Inner {
        return {
            message: 'Hello from inner'
        };
    }
}

在这个例子中,Outer 抽象类中定义了 Inner 接口,Implementation 子类实现了 getInner 方法并返回符合 Inner 接口的对象。

同样,接口中也可以嵌套抽象类:

interface Container {
    abstract class Inner {
        abstract print(): void;
    }

    getInner(): Inner;
}

class MyContainer implements Container {
    class Inner implements Container.Inner {
        print() {
            console.log('Printing from inner class');
        }
    }

    getInner(): Container.Inner {
        return new this.Inner();
    }
}

这里 Container 接口中定义了抽象类 InnerMyContainer 类实现了 Container 接口,并在内部定义了具体的 Inner 类来实现 Container.Inner 抽象类的要求。

4.4 抽象类和接口与泛型的结合

泛型可以与抽象类和接口很好地结合,以提供更灵活和可复用的代码。例如,定义一个泛型抽象类:

abstract class GenericStorage<T> {
    data: T[] = [];

    add(item: T) {
        this.data.push(item);
    }

    abstract get(index: number): T;
}

class NumberStorage extends GenericStorage<number> {
    get(index: number): number {
        return this.data[index];
    }
}

class StringStorage extends GenericStorage<string> {
    get(index: number): string {
        return this.data[index];
    }
}

在这个例子中,GenericStorage 是一个泛型抽象类,T 是类型参数。NumberStorageStringStorage 子类分别指定了 Tnumberstring,并实现了 get 抽象方法。

对于接口,也可以使用泛型。例如:

interface GenericMapper<T, U> {
    map(item: T): U;
}

class NumberToStringMapper implements GenericMapper<number, string> {
    map(item: number): string {
        return item.toString();
    }
}

这里 GenericMapper 是一个泛型接口,NumberToStringMapper 类实现了该接口,将 number 类型映射为 string 类型。

五、实际应用案例

5.1 图形绘制库中的应用

假设我们正在开发一个简单的图形绘制库。我们可以使用抽象类和接口来构建这个库的架构。 首先,定义一个抽象类 Graphic 作为所有图形的基类:

abstract class Graphic {
    abstract draw(ctx: CanvasRenderingContext2D): void;
}

然后,定义一些具体的图形类继承自 Graphic 抽象类,比如 RectangleCircle

class Rectangle extends Graphic {
    x: number;
    y: number;
    width: number;
    height: number;

    constructor(x: number, y: number, width: number, height: number) {
        super();
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    draw(ctx: CanvasRenderingContext2D) {
        ctx.fillRect(this.x, this.y, this.width, this.height);
    }
}

class Circle extends Graphic {
    x: number;
    y: number;
    radius: number;

    constructor(x: number, y: number, radius: number) {
        super();
        this.x = x;
        this.y = y;
        this.radius = radius;
    }

    draw(ctx: CanvasRenderingContext2D) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI);
        ctx.fill();
    }
}

同时,我们可以定义一个接口 Groupable,表示图形可以被分组:

interface Groupable {
    groupName: string;
}

有些图形类可能实现这个接口,比如 GroupedRectangle

class GroupedRectangle extends Rectangle implements Groupable {
    groupName: string;

    constructor(x: number, y: number, width: number, height: number, groupName: string) {
        super(x, y, width, height);
        this.groupName = groupName;
    }
}

这样,在绘制图形时,我们可以统一处理所有 Graphic 类型的对象,并且对于实现了 Groupable 接口的图形,可以进行分组相关的操作。

5.2 数据访问层中的应用

在一个数据访问层(DAL)的开发中,我们可以使用抽象类和接口来规范数据访问的操作。 定义一个抽象类 BaseRepository,它包含一些通用的数据访问方法:

abstract class BaseRepository<T> {
    abstract findAll(): Promise<T[]>;
    abstract findById(id: string): Promise<T | null>;
    abstract create(entity: T): Promise<T>;
    abstract update(id: string, entity: T): Promise<T | null>;
    abstract delete(id: string): Promise<boolean>;
}

然后,针对不同的实体,定义具体的仓库类。比如,对于用户实体:

class User {
    id: string;
    name: string;
    email: string;
}

class UserRepository extends BaseRepository<User> {
    async findAll(): Promise<User[]> {
        // 实际实现中从数据库查询所有用户
        return [];
    }

    async findById(id: string): Promise<User | null> {
        // 实际实现中从数据库根据ID查询用户
        return null;
    }

    async create(entity: User): Promise<User> {
        // 实际实现中向数据库插入用户
        return entity;
    }

    async update(id: string, entity: User): Promise<User | null> {
        // 实际实现中更新数据库中的用户
        return null;
    }

    async delete(id: string): Promise<boolean> {
        // 实际实现中从数据库删除用户
        return true;
    }
}

同时,我们可以定义一个接口 Transactionable,表示支持事务操作:

interface Transactionable {
    startTransaction(): Promise<void>;
    commitTransaction(): Promise<void>;
    rollbackTransaction(): Promise<void>;
}

如果某些仓库需要支持事务,就可以实现这个接口。例如:

class TransactionalUserRepository extends UserRepository implements Transactionable {
    async startTransaction(): Promise<void> {
        // 实际实现中启动数据库事务
    }

    async commitTransaction(): Promise<void> {
        // 实际实现中提交数据库事务
    }

    async rollbackTransaction(): Promise<void> {
        // 实际实现中回滚数据库事务
    }
}

通过这样的方式,我们可以清晰地规范数据访问的行为,并且可以根据需要灵活地扩展功能。

六、总结抽象类和接口的要点

  1. 抽象类
    • abstract 关键字定义,不能直接实例化。
    • 可以包含具体属性、方法和抽象方法。
    • 子类通过 extends 继承抽象类,并实现抽象方法。
    • 主要用于定义一组相关类的通用行为和状态,实现代码复用和多态。
  2. 接口
    • interface 关键字定义,不能实例化,用于定义对象的形状。
    • 只包含属性和方法的签名,不包含具体实现。
    • 类通过 implements 实现接口,提供具体实现。
    • 支持可选属性、只读属性、函数类型接口和可索引接口等特性。
    • 主要用于定义不同类之间的统一外部接口,实现代码的灵活性和可扩展性。
  3. 两者比较
    • 抽象类是单继承,接口可以多实现。
    • 抽象类有具体实现部分,接口完全是抽象的。
    • 根据不同的场景选择使用抽象类或接口,抽象类适合有共同实现的情况,接口适合定义外部形状和规范。

在实际的 TypeScript 项目开发中,深入理解并合理运用抽象类和接口,能够构建出更加健壮、可维护和可扩展的代码结构。无论是大型的企业级应用还是小型的工具库开发,它们都是非常重要的概念。通过不断地实践和思考,开发者可以更好地发挥 TypeScript 的优势,提高代码的质量和开发效率。同时,结合泛型、嵌套等特性,可以进一步提升代码的灵活性和复用性,满足各种复杂的业务需求。在设计架构时,需要根据具体的功能需求和代码组织方式,谨慎选择使用抽象类还是接口,或者两者结合使用,以达到最佳的设计效果。例如,在一些需要继承公共行为并进行扩展的场景下,抽象类可能是首选;而在需要定义不同对象的统一接口,或者实现多态行为但不需要继承公共实现的情况下,接口则更为合适。通过合理运用这些概念,我们可以构建出更加清晰、高效且易于维护的 TypeScript 项目。