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

TypeScript继承机制:extends关键字的使用

2024-04-137.4k 阅读

一、TypeScript 继承机制概述

在面向对象编程中,继承是一种重要的概念,它允许一个类获取另一个类的属性和方法,从而实现代码的复用和层次结构的构建。在 TypeScript 中,通过 extends 关键字来实现继承机制。这使得我们可以创建一个新类,这个新类基于已有的类,并且可以拥有父类的部分或全部特性,同时还能添加自己特有的属性和方法。

二、基本继承语法

TypeScript 中继承的基本语法非常直观。假设我们有一个父类 Animal,它有一些属性和方法,然后我们可以创建一个子类 Dog 继承自 Animal

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

class Dog extends Animal {
    breed: string;
    constructor(name: string, breed: string) {
        super(name);
        this.breed = breed;
    }
    bark() {
        console.log(`${this.name} barks.`);
    }
}

在上述代码中:

  1. 我们首先定义了 Animal 类,它有一个 name 属性和一个 speak 方法。
  2. 然后定义了 Dog 类,使用 extends 关键字表明它继承自 Animal 类。
  3. Dog 类有自己特有的 breed 属性和 bark 方法。
  4. Dog 类的构造函数中,通过 super(name) 调用了父类 Animal 的构造函数,以初始化从父类继承来的 name 属性。这是在子类构造函数中必须要做的,因为子类构造函数在访问 this 之前必须先调用 super

三、继承与属性和方法的访问

(一)访问父类属性和方法

子类可以直接访问父类的非私有属性和方法。继续以上面的 AnimalDog 类为例:

let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // 输出: Buddy makes a sound.
myDog.bark();  // 输出: Buddy barks.

这里 myDogDog 类的实例,它可以调用从父类 Animal 继承来的 speak 方法,也能调用自己特有的 bark 方法。

(二)属性的覆盖与访问控制

在子类中可以定义与父类同名的属性,但这通常不是一个好的实践,因为它可能会导致混淆。然而,TypeScript 允许这样做,并且会以子类的属性为准。

class Animal {
    color: string = 'gray';
}

class Cat extends Animal {
    color: string = 'black';
}

let myCat = new Cat();
console.log(myCat.color); // 输出: black

关于访问控制,TypeScript 有 public(默认)、privateprotected 三种修饰符。

  1. public 修饰符:可以在类内部、子类以及类的实例外部访问。
  2. private 修饰符:只能在类内部访问,子类无法访问。
  3. protected 修饰符:可以在类内部和子类中访问,但不能在类的实例外部访问。
class Animal {
    private secretInfo: string = 'This is a secret';
    protected health: number = 100;
    public name: string;
    constructor(name: string) {
        this.name = name;
    }
}

class Tiger extends Animal {
    constructor(name: string) {
        super(name);
        console.log(this.health); // 可以访问,因为 health 是 protected
        // console.log(this.secretInfo); // 错误,不能访问,因为 secretInfo 是 private
    }
}

let myTiger = new Tiger('Tony');
// console.log(myTiger.health); // 错误,不能在类外部访问 protected 属性
// console.log(myTiger.secretInfo); // 错误,不能在类外部访问 private 属性
console.log(myTiger.name); // 可以访问,因为 name 是 public

四、方法的重写

(一)方法重写的概念

方法重写是指子类提供一个与父类中同名方法具有相同签名(参数列表和返回类型)的实现。这允许子类根据自身的需求来定制父类方法的行为。

class Bird {
    fly() {
        console.log('I can fly.');
    }
}

class Penguin extends Bird {
    fly() {
        console.log('Sorry, I can\'t fly.');
    }
}

let myPenguin = new Penguin();
myPenguin.fly(); // 输出: Sorry, I can't fly.

在这个例子中,Penguin 类继承自 Bird 类,并重写了 fly 方法,以适应企鹅不会飞的特性。

(二)使用 override 关键字(TypeScript 4.3+)

从 TypeScript 4.3 开始,引入了 override 关键字,用于明确标识子类中重写的方法。这有助于捕获一些常见的错误,比如在子类中意外地定义了一个与父类方法签名不匹配的方法,却误以为是重写。

class Shape {
    calculateArea() {
        return 0;
    }
}

class Rectangle extends Shape {
    override calculateArea() {
        return 4;
    }
}

// 以下情况会报错,如果没有使用 override 关键字,可能不会及时发现错误
// class Circle extends Shape {
//     calculateArea(radius: number) {
//         return Math.PI * radius * radius;
//     }
// }

在上述代码中,如果在 Circle 类的 calculateArea 方法前加上 override 关键字,TypeScript 编译器会检测到该方法的签名与父类 Shape 中的 calculateArea 方法签名不一致,从而报错。这使得代码更加健壮,减少潜在的运行时错误。

五、继承中的多态性

(一)多态的概念与实现

多态性是面向对象编程的重要特性之一,它允许通过相同的接口来处理不同类型的对象。在 TypeScript 继承中,多态通过方法重写和父类引用指向子类对象来实现。

class Vehicle {
    startEngine() {
        console.log('Vehicle engine started.');
    }
}

class Car extends Vehicle {
    startEngine() {
        console.log('Car engine started.');
    }
}

class Motorcycle extends Vehicle {
    startEngine() {
        console.log('Motorcycle engine started.');
    }
}

function startVehicle(vehicle: Vehicle) {
    vehicle.startEngine();
}

let myCar = new Car();
let myMotorcycle = new Motorcycle();

startVehicle(myCar); // 输出: Car engine started.
startVehicle(myMotorcycle); // 输出: Motorcycle engine started.

在上述代码中:

  1. 定义了 Vehicle 类及其子类 CarMotorcycle,每个子类都重写了 startEngine 方法。
  2. startVehicle 函数接受一个 Vehicle 类型的参数,通过这个函数,我们可以传入不同类型的车辆对象(CarMotorcycle),并且根据对象的实际类型调用相应的 startEngine 方法,这就是多态性的体现。

(二)多态与接口

接口在实现多态性方面也起着重要作用。接口定义了一组方法的签名,类可以实现接口,并且不同的类对接口方法的实现可以不同,从而实现多态。

interface Drawable {
    draw(): void;
}

class Circle implements Drawable {
    draw() {
        console.log('Drawing a circle.');
    }
}

class Square implements Drawable {
    draw() {
        console.log('Drawing a square.');
    }
}

function drawShapes(shapes: Drawable[]) {
    shapes.forEach(shape => shape.draw());
}

let circle = new Circle();
let square = new Square();

drawShapes([circle, square]); 
// 输出:
// Drawing a circle.
// Drawing a square.

在这个例子中,Drawable 接口定义了 draw 方法的签名。CircleSquare 类实现了这个接口,并提供了各自不同的 draw 方法实现。drawShapes 函数接受一个 Drawable 类型数组,通过遍历数组并调用每个对象的 draw 方法,实现了多态性。

六、抽象类与继承

(一)抽象类的定义与作用

抽象类是一种不能被实例化的类,它主要用于作为其他类的基类,为子类提供一个通用的框架。抽象类可以包含抽象方法,这些方法只有声明而没有实现,必须在子类中被重写。

abstract class Polygon {
    abstract getArea(): number;
    sides: number;
    constructor(sides: number) {
        this.sides = sides;
    }
}

class Triangle extends Polygon {
    constructor() {
        super(3);
    }
    getArea() {
        // 简单示例,假设是等边三角形
        return Math.sqrt(3) / 4 * this.sides * this.sides;
    }
}

class Rectangle extends Polygon {
    constructor(private width: number, private height: number) {
        super(4);
    }
    getArea() {
        return this.width * this.height;
    }
}

// let polygon = new Polygon(); // 错误,不能实例化抽象类
let triangle = new Triangle();
let rectangle = new Rectangle(4, 5);

console.log(triangle.getArea()); 
console.log(rectangle.getArea()); 

在上述代码中:

  1. 定义了抽象类 Polygon,它有一个抽象方法 getArea 和一个属性 sides
  2. TriangleRectangle 类继承自 Polygon,并实现了抽象方法 getArea
  3. 不能直接实例化 Polygon 类,而 TriangleRectangle 类可以被实例化,并根据自身的逻辑实现 getArea 方法。

(二)抽象类与接口的区别

  1. 抽象类可以包含属性和方法的实现,而接口只能定义方法签名:抽象类中的非抽象方法可以有具体的实现逻辑,子类可以直接使用或重写。接口只定义了方法的名称、参数列表和返回类型,不包含任何实现代码。
  2. 一个类只能继承一个抽象类,但可以实现多个接口:这使得接口在实现多继承方面更加灵活。例如,一个类可能需要从不同的来源获取不同的功能,通过实现多个接口可以满足这种需求。
  3. 抽象类中的抽象方法必须在子类中重写,接口的方法也必须实现:但抽象类更侧重于定义一组相关类的通用结构和行为,而接口更侧重于定义一种契约,不同类型的类只要满足契约就可以实现该接口。

七、泛型与继承

(一)泛型类的继承

当涉及到泛型类的继承时,需要注意泛型参数的传递和使用。假设我们有一个泛型类 Box,它可以存储任何类型的数据,然后我们创建一个子类 LockedBox 继承自 Box

class Box<T> {
    private content: T;
    constructor(value: T) {
        this.content = value;
    }
    getContent() {
        return this.content;
    }
}

class LockedBox<T> extends Box<T> {
    private isLocked: boolean = true;
    getContent() {
        if (this.isLocked) {
            throw new Error('Box is locked.');
        }
        return super.getContent();
    }
}

let stringBox = new Box<string>('Hello');
let lockedStringBox = new LockedBox<string>('World');

console.log(stringBox.getContent()); 
// console.log(lockedStringBox.getContent()); // 报错,Box is locked.

在上述代码中:

  1. Box 类是一个泛型类,它有一个泛型参数 T,用于表示存储数据的类型。
  2. LockedBox 类继承自 Box 类,并添加了一个 isLocked 属性和重写了 getContent 方法。在重写的方法中,首先检查箱子是否被锁定,如果锁定则抛出错误,否则调用父类的 getContent 方法获取内容。

(二)泛型约束与继承

泛型约束可以与继承一起使用,以确保泛型类型满足特定的条件。例如,我们可以定义一个接口 HasLength,然后在泛型类中约束泛型类型必须实现该接口。

interface HasLength {
    length: number;
}

class StringProcessor<T extends HasLength> {
    process(data: T) {
        return `Length of data: ${data.length}`;
    }
}

let stringProcessor = new StringProcessor<string>();
console.log(stringProcessor.process('test')); 
// 输出: Length of data: 4

// let numberProcessor = new StringProcessor<number>(); // 错误,number 类型没有 length 属性

在这个例子中,StringProcessor 类的泛型参数 T 被约束为必须实现 HasLength 接口。这意味着只有具有 length 属性的类型才能作为泛型参数传入,从而保证了 process 方法的正确执行。

八、多重继承与混入(Mixins)

(一)多重继承的问题与限制

在一些编程语言中支持多重继承,即一个类可以从多个父类继承属性和方法。然而,多重继承会带来一些问题,比如菱形继承问题(也称为“致命钻石问题”)。假设有四个类 ABCDBC 都继承自 AD 又同时继承自 BC。如果 A 中有一个方法,BC 分别对该方法进行了不同的重写,那么 D 在调用这个方法时就会出现歧义,不知道该调用 B 还是 C 的重写版本。

TypeScript 不直接支持多重继承,以避免这些复杂的问题。但 TypeScript 提供了其他方式来实现类似多重继承的功能,比如混入(Mixins)。

(二)混入(Mixins)的实现

混入是一种将多个对象的功能合并到一个对象中的技术。在 TypeScript 中,可以通过类和函数来实现混入。

// 定义混入类
class Logger {
    log(message: string) {
        console.log(`LOG: ${message}`);
    }
}

class Timestamp {
    getTimestamp() {
        return new Date().getTime();
    }
}

// 定义混入函数
function applyMixins(derivedCtor: any, baseCtors: any[]) {
    baseCtors.forEach(baseCtor => {
        Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
            derivedCtor.prototype[name] = baseCtor.prototype[name];
        });
    });
}

// 创建一个新类,使用混入
class MyClass { }
applyMixins(MyClass, [Logger, Timestamp]);

let myObj = new MyClass();
myObj.log('This is a log message'); 
console.log(myObj.getTimestamp()); 

在上述代码中:

  1. 定义了 LoggerTimestamp 两个类,分别提供了 loggetTimestamp 方法。
  2. applyMixins 函数用于将多个类的方法混入到一个目标类中。
  3. 创建了 MyClass 类,并通过 applyMixins 函数将 LoggerTimestamp 类的方法混入到 MyClass 类中。这样 MyClass 类的实例就可以调用 loggetTimestamp 方法,实现了类似多重继承的效果。

九、继承与模块

(一)模块中继承的使用

在 TypeScript 项目中,通常会使用模块来组织代码。当涉及到继承时,模块可以帮助我们更好地管理类之间的关系。假设我们有一个 animal.ts 模块定义了 Animal 类,然后在 dog.ts 模块中定义 Dog 类继承自 Animal 类。

animal.ts

export class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

dog.ts

import { Animal } from './animal';

export class Dog extends Animal {
    breed: string;
    constructor(name: string, breed: string) {
        super(name);
        this.breed = breed;
    }
    bark() {
        console.log(`${this.name} barks.`);
    }
}

在上述代码中:

  1. animal.ts 模块中定义了 Animal 类,并通过 export 关键字将其导出,以便其他模块可以使用。
  2. dog.ts 模块中,使用 import 语句导入 Animal 类,然后定义 Dog 类继承自 Animal 类。这样就实现了模块间的继承关系。

(二)模块与继承的注意事项

  1. 命名冲突:在不同模块中可能会出现同名的类或接口,这可能导致命名冲突。为了避免这种情况,可以使用命名空间或合理的模块命名约定。例如,可以将相关的类放在同一个命名空间下,或者在模块名中体现其功能,以便更好地区分。
  2. 模块加载顺序:虽然 TypeScript 编译器会处理模块之间的依赖关系,但在复杂项目中,理解模块加载顺序仍然很重要。确保在使用继承关系时,父类所在的模块已经被正确加载和解析,否则可能会出现运行时错误。

十、继承在大型项目中的应用与最佳实践

(一)代码复用与分层架构

在大型项目中,继承机制可以极大地提高代码复用性。通过将通用的属性和方法提取到父类中,子类可以继承并复用这些代码,减少重复代码的编写。例如,在一个企业级 Web 应用中,可以有一个 BaseComponent 类,包含一些通用的 UI 组件功能,如初始化、渲染等方法。然后不同的具体组件类,如 ButtonComponentInputComponent 等继承自 BaseComponent,并根据自身需求重写或扩展这些方法。

同时,继承有助于构建分层架构。比如在一个三层架构(表示层、业务逻辑层、数据访问层)的项目中,业务逻辑层的类可以继承一些基础的业务规则类,数据访问层的类可以继承通用的数据访问基类。这样可以使项目结构更加清晰,各层之间的职责更加明确。

(二)避免过度继承

虽然继承有很多优点,但过度继承可能会导致代码变得复杂和难以维护。如果继承层次过深,一个小小的修改在父类中可能会影响到众多子类,从而增加了代码的维护成本。此外,滥用继承可能会导致类之间的耦合度过高,违反了“高内聚、低耦合”的原则。

为了避免过度继承,可以考虑使用组合(Composition)来替代继承。组合是将一个类的实例作为另一个类的属性,通过调用该实例的方法来实现功能。例如,如果一个类需要某个功能,可以将实现该功能的类实例化并作为属性,而不是通过继承来获取该功能。这样可以降低类之间的耦合度,使代码更加灵活和易于维护。

(三)文档化与代码可读性

在使用继承机制时,良好的文档化非常重要。对于父类和子类,应该清晰地文档说明每个属性和方法的作用,特别是在子类重写父类方法时,要说明重写的原因和新的行为。这样可以帮助其他开发人员快速理解代码结构和功能,提高代码的可读性和可维护性。

同时,在命名方面也要遵循一定的规范。类名应该能够清晰地表达其含义,并且父类和子类的命名应该有一定的关联性,以便直观地看出继承关系。例如,父类名为 AbstractUserService,子类可以命名为 AdminUserServiceRegularUserService,这样从名称上就能很容易理解它们之间的继承关系。

在大型项目中,合理使用继承机制,并结合其他设计模式和最佳实践,可以提高代码的质量、可维护性和可扩展性,从而使项目更加健壮和高效地运行。