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

TypeScript抽象类abstract class的设计与实现

2023-12-072.3k 阅读

一、TypeScript 抽象类基础概念

在 TypeScript 中,抽象类是一种特殊的类,它不能被直接实例化,主要用于作为其他类的基类,为这些派生类提供一个通用的接口和部分实现。通过抽象类,我们可以定义一些抽象成员(抽象方法和抽象属性),这些成员在抽象类中只有声明而没有具体实现,必须在派生类中被实现。

1.1 抽象类的定义

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

abstract class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    abstract makeSound(): void;
}

在上述代码中,Animal 是一个抽象类,它有一个构造函数用于初始化 name 属性,同时定义了一个抽象方法 makeSound。注意,抽象方法没有方法体,仅以分号结尾。

1.2 不能实例化抽象类

由于抽象类不能被直接实例化,下面的代码会报错:

let animal = new Animal('Generic Animal'); 
// 报错:无法创建抽象类的实例

这是因为抽象类通常是为了提供一个通用的结构和行为模板,具体的实现由继承它的非抽象类来完成。

二、继承抽象类

2.1 派生类实现抽象方法

当一个类继承自抽象类时,它必须实现抽象类中的所有抽象成员。例如:

class Dog extends Animal {
    makeSound(): void {
        console.log('Woof!');
    }
}
class Cat extends Animal {
    makeSound(): void {
        console.log('Meow!');
    }
}
let dog = new Dog('Buddy');
let cat = new Cat('Whiskers');
dog.makeSound(); 
cat.makeSound(); 

在上述代码中,DogCat 类继承自 Animal 抽象类,并分别实现了 makeSound 抽象方法。这样我们就可以创建 DogCat 的实例,并调用它们实现的 makeSound 方法。

2.2 继承抽象类的构造函数

派生类在继承抽象类时,也会继承抽象类的构造函数。派生类的构造函数必须调用 super() 来初始化从抽象类继承的属性。例如:

abstract class Shape {
    color: string;
    constructor(color: string) {
        this.color = color;
    }
    abstract getArea(): number;
}
class Circle extends Shape {
    radius: number;
    constructor(color: string, radius: number) {
        super(color);
        this.radius = radius;
    }
    getArea(): number {
        return Math.PI * this.radius * this.radius;
    }
}
let circle = new Circle('red', 5);
console.log(`Circle area: ${circle.getArea()}`);

Circle 类中,构造函数首先调用 super(color) 来初始化从 Shape 抽象类继承的 color 属性,然后再初始化自己特有的 radius 属性。

三、抽象类的作用

3.1 代码复用

抽象类允许我们将一些通用的属性和方法定义在抽象类中,避免在多个派生类中重复编写相同的代码。例如,在前面的 Animal 抽象类中,name 属性和构造函数可以被所有继承自 Animal 的类复用。

abstract class Vehicle {
    brand: string;
    constructor(brand: string) {
        this.brand = brand;
    }
    abstract drive(): void;
}
class Car extends Vehicle {
    model: string;
    constructor(brand: string, model: string) {
        super(brand);
        this.model = model;
    }
    drive(): void {
        console.log(`Driving a ${this.brand} ${this.model}`);
    }
}
class Motorcycle extends Vehicle {
    type: string;
    constructor(brand: string, type: string) {
        super(brand);
        this.type = type;
    }
    drive(): void {
        console.log(`Riding a ${this.brand} ${this.type} motorcycle`);
    }
}

在这个例子中,Vehicle 抽象类中的 brand 属性和构造函数被 CarMotorcycle 类复用,减少了代码冗余。

3.2 定义规范和约束

通过抽象类的抽象成员,我们可以为派生类定义一个明确的接口规范。所有派生类必须按照这个规范来实现抽象成员,这有助于保证代码的一致性和可维护性。例如,在 Shape 抽象类中定义了 getArea 抽象方法,所有继承自 Shape 的类都必须实现这个方法来计算各自的面积。这使得代码在处理不同形状时具有统一的接口,易于理解和扩展。

abstract class Payment {
    amount: number;
    constructor(amount: number) {
        this.amount = amount;
    }
    abstract processPayment(): void;
}
class CreditCardPayment extends Payment {
    cardNumber: string;
    constructor(amount: number, cardNumber: string) {
        super(amount);
        this.cardNumber = cardNumber;
    }
    processPayment(): void {
        console.log(`Processing credit card payment of $${this.amount} with card number ${this.cardNumber}`);
    }
}
class PayPalPayment extends Payment {
    paypalEmail: string;
    constructor(amount: number, paypalEmail: string) {
        super(amount);
        this.paypalEmail = paypalEmail;
    }
    processPayment(): void {
        console.log(`Processing PayPal payment of $${this.amount} to email ${this.paypalEmail}`);
    }
}

在这个支付系统的例子中,Payment 抽象类定义了 processPayment 抽象方法,CreditCardPaymentPayPalPayment 类必须实现这个方法,从而保证了支付处理的一致性。

四、抽象类与接口的比较

4.1 相似之处

  • 都用于定义规范:接口和抽象类都可以用于为其他类定义一种规范或契约。接口通过定义一组方法签名来约束实现类必须提供这些方法的实现,抽象类则通过抽象方法来达到类似的目的。
  • 都不能直接实例化:接口和抽象类都不能被直接实例化,它们的存在主要是为了被其他类实现或继承。

4.2 不同之处

  • 成员定义:接口只能定义方法签名、属性声明(且只能是抽象的),不能包含具体的实现代码。而抽象类可以包含具体的属性、方法实现以及抽象方法。例如:
interface Printable {
    print(): void;
}
abstract class Logger {
    logLevel: string;
    constructor(logLevel: string) {
        this.logLevel = logLevel;
    }
    abstract log(message: string): void;
    logInfo(message: string) {
        if (this.logLevel === 'info') {
            console.log(`[INFO] ${message}`);
        }
    }
}

在上述代码中,Printable 接口只定义了 print 方法签名,而 Logger 抽象类不仅有抽象方法 log,还包含具体的属性 logLevel 和具体的方法 logInfo

  • 继承与实现:一个类只能继承一个抽象类,但可以实现多个接口。例如:
abstract class BaseClass {
    baseMethod(): void {
        console.log('Base method');
    }
}
interface Interface1 {
    method1(): void;
}
interface Interface2 {
    method2(): void;
}
class DerivedClass extends BaseClass implements Interface1, Interface2 {
    method1(): void {
        console.log('Method 1 implementation');
    }
    method2(): void {
        console.log('Method 2 implementation');
    }
}

在这个例子中,DerivedClass 继承自 BaseClass 抽象类,同时实现了 Interface1Interface2 两个接口。

  • 对象类型兼容性:接口在对象类型兼容性方面更灵活,只要对象满足接口定义的形状,就可以被视为实现了该接口。而抽象类更强调继承关系,只有继承自抽象类的类才被视为符合抽象类的类型。例如:
interface Point {
    x: number;
    y: number;
}
let obj = {x: 1, y: 2};
let point: Point = obj; 
abstract class ShapeBase {
    color: string;
    constructor(color: string) {
        this.color = color;
    }
}
class Rectangle extends ShapeBase {
    width: number;
    height: number;
    constructor(color: string, width: number, height: number) {
        super(color);
        this.width = width;
        this.height = height;
    }
}
let rect: ShapeBase = new Rectangle('red', 10, 20); 
let otherObj = {width: 10, height: 20}; 
// 报错:类型“{ width: number; height: number; }”缺少属性“color”,但类型“Rectangle”中需要该属性。
// let otherRect: Rectangle = otherObj; 

在上述代码中,obj 虽然不是一个显式实现 Point 接口的类的实例,但由于它具有 Point 接口定义的形状,所以可以赋值给 Point 类型的变量。而对于抽象类 ShapeBase 和它的派生类 Rectangle,只有 Rectangle 类的实例才能赋值给 ShapeBase 类型的变量,其他形状类似但没有继承关系的对象则不行。

五、在实际项目中的应用场景

5.1 图形绘制库

在一个图形绘制库中,可以使用抽象类来定义各种图形的基本行为和属性。例如:

abstract class Graphic {
    position: { x: number; y: number };
    constructor(x: number, y: number) {
        this.position = { x, y };
    }
    abstract draw(ctx: CanvasRenderingContext2D): void;
}
class Circle extends Graphic {
    radius: number;
    constructor(x: number, y: number, radius: number) {
        super(x, y);
        this.radius = radius;
    }
    draw(ctx: CanvasRenderingContext2D): void {
        ctx.beginPath();
        ctx.arc(this.position.x, this.position.y, this.radius, 0, 2 * Math.PI);
        ctx.fillStyle = 'blue';
        ctx.fill();
    }
}
class Rectangle extends Graphic {
    width: number;
    height: number;
    constructor(x: number, y: number, width: number, height: number) {
        super(x, y);
        this.width = width;
        this.height = height;
    }
    draw(ctx: CanvasRenderingContext2D): void {
        ctx.fillStyle = 'green';
        ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
    }
}
function drawAllGraphics(graphics: Graphic[], ctx: CanvasRenderingContext2D) {
    graphics.forEach((graphic) => graphic.draw(ctx));
}
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (ctx) {
    let circle = new Circle(100, 100, 50);
    let rect = new Rectangle(200, 200, 100, 50);
    drawAllGraphics([circle, rect], ctx);
}

在这个例子中,Graphic 抽象类定义了图形的基本位置属性和抽象的 draw 方法。CircleRectangle 类继承自 Graphic 并实现了 draw 方法,用于在画布上绘制各自的图形。drawAllGraphics 函数接受一个 Graphic 数组,并调用每个图形的 draw 方法,实现了统一的绘制逻辑。

5.2 数据访问层

在一个 Web 应用的数据访问层(DAL)中,抽象类可以用于定义数据访问的通用接口和部分实现。例如:

abstract class DataAccess {
    connectionString: string;
    constructor(connectionString: string) {
        this.connectionString = connectionString;
    }
    abstract query(sql: string): Promise<any>;
    abstract execute(sql: string): Promise<void>;
}
class MySqlDataAccess extends DataAccess {
    async query(sql: string): Promise<any> {
        // 实际的 MySQL 查询逻辑,这里用模拟代码代替
        console.log(`Executing MySQL query: ${sql}`);
        return { result: 'Mocked MySQL query result' };
    }
    async execute(sql: string): Promise<void> {
        // 实际的 MySQL 执行逻辑,这里用模拟代码代替
        console.log(`Executing MySQL statement: ${sql}`);
    }
}
class SqlServerDataAccess extends DataAccess {
    async query(sql: string): Promise<any> {
        // 实际的 SQL Server 查询逻辑,这里用模拟代码代替
        console.log(`Executing SQL Server query: ${sql}`);
        return { result: 'Mocked SQL Server query result' };
    }
    async execute(sql: string): Promise<void> {
        // 实际的 SQL Server 执行逻辑,这里用模拟代码代替
        console.log(`Executing SQL Server statement: ${sql}`);
    }
}
async function getData(dataAccess: DataAccess) {
    let result = await dataAccess.query('SELECT * FROM SomeTable');
    console.log(result);
}
let mySqlDataAccess = new MySqlDataAccess('mysql_connection_string');
let sqlServerDataAccess = new SqlServerDataAccess('sqlserver_connection_string');
getData(mySqlDataAccess);
getData(sqlServerDataAccess);

在这个数据访问层的例子中,DataAccess 抽象类定义了数据库连接字符串和两个抽象方法 queryexecuteMySqlDataAccessSqlServerDataAccess 类继承自 DataAccess 并实现了这两个方法,分别用于处理 MySQL 和 SQL Server 数据库的查询和执行操作。getData 函数可以接受任何实现了 DataAccess 抽象类的实例,实现了数据访问逻辑的复用和灵活性。

六、抽象类的局限性

6.1 单一继承限制

由于 TypeScript 中类只能继承一个抽象类,这在某些情况下可能会限制代码的设计。例如,如果一个类需要复用多个不同抽象类的功能,就无法直接通过继承来实现。虽然可以通过实现多个接口来部分解决这个问题,但接口不能包含具体的实现代码,所以在复用具体实现方面还是存在不足。

6.2 增加复杂性

过度使用抽象类可能会增加代码的复杂性。特别是在大型项目中,如果抽象类层次结构过于复杂,可能会导致代码难以理解和维护。开发人员需要花费更多的时间来跟踪抽象类之间的关系以及派生类对抽象方法的实现。

七、最佳实践

7.1 合理设计抽象层次

在设计抽象类时,要确保抽象层次合理。抽象类应该足够抽象,能够提取出通用的行为和属性,但又不能过于抽象,导致失去实际意义。例如,在图形绘制库中,Graphic 抽象类抽象出了图形的基本位置和绘制行为,这是合理的抽象层次。如果再创建一个过于宽泛的抽象类,比如 ObjectInSpace,它可能包含了太多不相关的属性和方法,对于图形绘制来说就不是一个好的抽象。

7.2 文档化抽象类

为抽象类及其抽象成员添加详细的文档是非常重要的。这有助于其他开发人员理解抽象类的目的、抽象方法的预期行为以及派生类需要满足的要求。可以使用 JSDoc 等工具来为代码添加文档。例如:

/**
 * 抽象类 Payment 用于定义支付相关的通用接口和属性。
 * 所有具体的支付方式类都应继承自此类。
 * @param amount - 支付金额
 */
abstract class Payment {
    amount: number;
    constructor(amount: number) {
        this.amount = amount;
    }
    /**
     * 抽象方法 processPayment 用于处理具体的支付逻辑。
     * 派生类必须实现此方法来完成支付操作。
     */
    abstract processPayment(): void;
}

通过这样的文档,其他开发人员在使用或扩展这个抽象类时能够清楚地知道该怎么做。

7.3 测试抽象类

虽然抽象类不能直接实例化,但仍然需要对抽象类中的具体方法进行测试。可以通过创建一个继承自抽象类的测试类,并实现抽象方法来进行测试。例如:

abstract class MathOperation {
    numbers: number[];
    constructor(numbers: number[]) {
        this.numbers = numbers;
    }
    abstract calculate(): number;
    sumNumbers(): number {
        return this.numbers.reduce((acc, num) => acc + num, 0);
    }
}
class AddOperation extends MathOperation {
    calculate(): number {
        return this.sumNumbers();
    }
}
// 测试 sumNumbers 方法
let addOp = new AddOperation([1, 2, 3]);
let sum = addOp.sumNumbers();
console.log(`Sum: ${sum}`); 

在这个例子中,通过创建 AddOperation 类继承自 MathOperation 抽象类,并实现 calculate 抽象方法,从而可以测试 MathOperation 抽象类中的 sumNumbers 具体方法。

通过以上对 TypeScript 抽象类的深入探讨,我们了解了抽象类的设计、实现及其在实际项目中的应用。合理使用抽象类可以提高代码的复用性、规范性和可维护性,但也需要注意其局限性和遵循最佳实践,以确保代码的质量和可扩展性。