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

TypeScript类的组合优于继承的设计原则

2024-08-295.6k 阅读

一、理解继承与组合

1.1 继承

在TypeScript中,继承是一种类与类之间的关系,通过extends关键字实现。子类可以继承父类的属性和方法,从而复用代码。例如:

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

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

const myDog = new Dog('Buddy');
myDog.speak(); 
myDog.bark(); 

在上述代码中,Dog类继承自Animal类,它不仅拥有Animal类的name属性和speak方法,还定义了自己的bark方法。继承在一定程度上实现了代码复用,减少了重复代码的编写。

然而,继承也存在一些问题。首先,继承会导致类之间的紧密耦合。如果父类的实现发生改变,可能会影响到所有的子类。例如,如果Animal类的speak方法逻辑改变,所有继承自Animal的子类都会受到影响。其次,继承会限制代码的灵活性。子类一旦继承了父类,就很难改变继承来的行为,除非通过重写方法,但重写可能会导致代码重复。

1.2 组合

组合是将不同的对象组合在一起,以实现更复杂的功能。在TypeScript中,可以通过在类中包含其他类的实例来实现组合。例如:

class Logger {
    log(message: string) {
        console.log(`[LOG] ${message}`);
    }
}

class Database {
    private logger: Logger;
    constructor(logger: Logger) {
        this.logger = logger;
    }
    connect() {
        this.logger.log('Connecting to database...');
        // 实际连接数据库的逻辑
    }
}

const logger = new Logger();
const database = new Database(logger);
database.connect(); 

在上述代码中,Database类通过在构造函数中接收Logger类的实例,实现了日志记录功能。这种方式使得Database类和Logger类之间的耦合度较低,Logger类的变化不会直接影响Database类的其他功能。而且,如果需要更换日志记录的方式,只需要创建一个新的类似Logger功能的类,并传入Database的构造函数即可。

二、组合优于继承的优势

2.1 低耦合性

继承使得子类与父类紧密关联,父类的任何改变都可能波及子类。而组合则不同,组合中的各个类之间相对独立。以图形绘制为例,假设我们有一个Shape类和一个Renderer类,使用继承时:

class Shape {
    color: string;
    constructor(color: string) {
        this.color = color;
    }
    draw() {
        console.log(`Drawing a ${this.color} shape.`);
    }
}

class Circle extends Shape {
    radius: number;
    constructor(color: string, radius: number) {
        super(color);
        this.radius = radius;
    }
    draw() {
        console.log(`Drawing a ${this.color} circle with radius ${this.radius}.`);
    }
}

如果Shape类的draw方法逻辑改变,Circle类也会受到影响。

而使用组合时:

class Renderer {
    renderShape(shape: { color: string, type: string, radius?: number }) {
        if (shape.type === 'circle') {
            console.log(`Drawing a ${shape.color} circle with radius ${shape.radius}.`);
        } else {
            console.log(`Drawing a ${shape.color} shape.`);
        }
    }
}

class Circle {
    color: string;
    radius: number;
    renderer: Renderer;
    constructor(color: string, radius: number, renderer: Renderer) {
        this.color = color;
        this.radius = radius;
        this.renderer = renderer;
    }
    draw() {
        this.renderer.renderShape({ color: this.color, type: 'circle', radius: this.radius });
    }
}

Circle类只关心自身的属性和调用Renderer的方法,Renderer的改变不会直接影响Circle类,除非其接口发生变化,但这种变化可以通过修改Circle类中调用Renderer的方式来应对,而不需要修改Circle类的核心逻辑。

2.2 高灵活性

继承限制了子类的行为扩展方式,通常只能通过重写方法来改变行为。而组合可以通过更换组合对象来实现不同的行为。比如在游戏开发中,有一个Character类,使用继承来实现不同角色的移动方式:

class Character {
    move() {
        console.log('Character is moving.');
    }
}

class Warrior extends Character {
    move() {
        console.log('Warrior is running.');
    }
}

class Mage extends Character {
    move() {
        console.log('Mage is teleporting.');
    }
}

如果需要为角色增加新的移动方式,比如飞行,就需要在每个子类中重写move方法,并且如果有多个类似的行为需要扩展,代码会变得复杂且难以维护。

使用组合的方式:

class MoveStrategy {
    move() {
        console.log('Default move.');
    }
}

class RunStrategy extends MoveStrategy {
    move() {
        console.log('Running.');
    }
}

class TeleportStrategy extends MoveStrategy {
    move() {
        console.log('Teleporting.');
    }
}

class Character {
    moveStrategy: MoveStrategy;
    constructor(moveStrategy: MoveStrategy) {
        this.moveStrategy = moveStrategy;
    }
    move() {
        this.moveStrategy.move();
    }
}

const warrior = new Character(new RunStrategy());
const mage = new Character(new TeleportStrategy());
warrior.move(); 
mage.move(); 

这样,只需要创建新的MoveStrategy子类,并将其传入Character的构造函数,就可以轻松为角色添加新的移动方式,无需修改Character类的核心代码,灵活性大大提高。

2.3 代码复用的粒度更细

继承是对整个类的复用,包括所有的属性和方法。而组合可以根据需求选择复用哪些部分。例如,在一个电商系统中,有Product类和DiscountCalculator类。使用继承时:

class Product {
    price: number;
    constructor(price: number) {
        this.price = price;
    }
    getDiscountedPrice() {
        return this.price; 
    }
}

class DiscountedProduct extends Product {
    discount: number;
    constructor(price: number, discount: number) {
        super(price);
        this.discount = discount;
    }
    getDiscountedPrice() {
        return this.price * (1 - this.discount);
    }
}

这里DiscountedProduct继承自Product,复用了Productprice属性和构造函数,但同时也继承了getDiscountedPrice方法,即使在Product类中这个方法可能并不合理(因为它没有实现折扣计算)。

使用组合时:

class DiscountCalculator {
    calculateDiscount(price: number, discount: number) {
        return price * (1 - discount);
    }
}

class Product {
    price: number;
    constructor(price: number) {
        this.price = price;
    }
}

class DiscountedProduct {
    product: Product;
    discount: number;
    discountCalculator: DiscountCalculator;
    constructor(product: Product, discount: number, discountCalculator: DiscountCalculator) {
        this.product = product;
        this.discount = discount;
        this.discountCalculator = discountCalculator;
    }
    getDiscountedPrice() {
        return this.discountCalculator.calculateDiscount(this.product.price, this.discount);
    }
}

DiscountedProduct通过组合ProductDiscountCalculator,只复用了DiscountCalculator中计算折扣的逻辑,以及Productprice属性,复用粒度更细,代码结构更清晰。

三、在TypeScript中实现组合的方式

3.1 简单属性组合

通过在类中定义其他类的实例作为属性来实现组合。例如,在一个文件管理系统中,有File类和Metadata类:

class Metadata {
    createdAt: Date;
    updatedAt: Date;
    constructor() {
        this.createdAt = new Date();
        this.updatedAt = new Date();
    }
    update() {
        this.updatedAt = new Date();
    }
}

class File {
    name: string;
    content: string;
    metadata: Metadata;
    constructor(name: string, content: string) {
        this.name = name;
        this.content = content;
        this.metadata = new Metadata();
    }
    save() {
        this.metadata.update();
        console.log(`${this.name} saved with updated metadata.`);
    }
}

const myFile = new File('example.txt', 'This is the content.');
myFile.save(); 

在上述代码中,File类通过包含Metadata类的实例,实现了文件的元数据管理。File类可以调用Metadata类的方法来更新元数据,而不需要关心Metadata类内部的具体实现。

3.2 构造函数注入组合

在类的构造函数中接收其他类的实例,实现更灵活的组合。比如在一个用户认证系统中,有User类和Authenticator类:

class Authenticator {
    authenticate(username: string, password: string): boolean {
        // 实际认证逻辑,这里简单返回true
        return true;
    }
}

class User {
    username: string;
    password: string;
    authenticator: Authenticator;
    constructor(username: string, password: string, authenticator: Authenticator) {
        this.username = username;
        this.password = password;
        this.authenticator = authenticator;
    }
    login() {
        if (this.authenticator.authenticate(this.username, this.password)) {
            console.log('User logged in successfully.');
        } else {
            console.log('Authentication failed.');
        }
    }
}

const authenticator = new Authenticator();
const user = new User('john', 'password123', authenticator);
user.login(); 

通过构造函数注入,User类可以在创建时选择不同的Authenticator实现,比如使用不同的认证策略(如基于数据库的认证、基于第三方服务的认证等),而不需要修改User类的核心逻辑。

3.3 接口组合

通过接口来定义组合对象的行为,使得不同的类可以实现相同的接口,从而在组合时具有更高的灵活性。例如,在一个图形绘制库中,有Shape类和不同的Drawer实现类:

interface Drawer {
    draw(shape: { color: string, type: string, radius?: number }): void;
}

class CircleDrawer implements Drawer {
    draw(shape: { color: string, type: string, radius?: number }) {
        if (shape.type === 'circle') {
            console.log(`Drawing a ${shape.color} circle with radius ${shape.radius}.`);
        }
    }
}

class RectangleDrawer implements Drawer {
    draw(shape: { color: string, type: string, radius?: number }) {
        if (shape.type ==='rectangle') {
            console.log(`Drawing a ${shape.color} rectangle.`);
        }
    }
}

class Shape {
    color: string;
    type: string;
    radius?: number;
    drawer: Drawer;
    constructor(color: string, type: string, radius?: number, drawer: Drawer) {
        this.color = color;
        this.type = type;
        this.radius = radius;
        this.drawer = drawer;
    }
    draw() {
        this.drawer.draw({ color: this.color, type: this.type, radius: this.radius });
    }
}

const circle = new Shape('red', 'circle', 5, new CircleDrawer());
const rectangle = new Shape('blue','rectangle', undefined, new RectangleDrawer());
circle.draw(); 
rectangle.draw(); 

通过接口DrawerShape类可以与不同的绘制实现类组合,实现不同图形的绘制,提高了代码的可扩展性和灵活性。

四、何时使用继承

虽然组合在很多情况下优于继承,但继承也并非毫无用处。在以下几种情况下,继承可能是更合适的选择。

4.1 当存在明确的“is - a”关系时

如果一个类确实是另一个类的一种具体类型,继承可以清晰地表达这种关系。例如,在一个几何图形库中,Square类确实是Rectangle类的一种特殊情况(正方形是长和宽相等的矩形),使用继承可以很好地体现这种关系:

class Rectangle {
    width: number;
    height: number;
    constructor(width: number, height: number) {
        this.width = width;
        this.height = height;
    }
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Rectangle {
    constructor(side: number) {
        super(side, side);
    }
}

const mySquare = new Square(5);
console.log(mySquare.getArea()); 

这里Square类继承自Rectangle类,清晰地表达了“正方形是矩形”这种“is - a”关系,并且复用了Rectangle类的getArea方法。

4.2 当需要复用父类的大部分实现且无需频繁更改时

如果子类需要复用父类的大部分功能,并且这些功能相对稳定,不太可能频繁修改,继承也是一个不错的选择。例如,在一个日志记录系统中,有一个基础的Logger类,它提供了基本的日志记录功能,如记录到控制台:

class Logger {
    log(message: string) {
        console.log(`[LOG] ${message}`);
    }
}

class FileLogger extends Logger {
    filePath: string;
    constructor(filePath: string) {
        super();
        this.filePath = filePath;
    }
    logToFile(message: string) {
        // 实际将日志记录到文件的逻辑
        console.log(`[FILE LOG] ${message} written to ${this.filePath}`);
    }
}

FileLogger类继承自Logger类,复用了Logger类的log方法,同时扩展了自己将日志记录到文件的功能。由于Logger类的log方法不太可能频繁修改,使用继承可以方便地实现功能扩展。

五、如何从继承过渡到组合

5.1 分析继承关系

首先,需要仔细分析当前继承关系中存在的问题。比如,查看子类是否过度依赖父类的实现,是否存在重复代码,以及是否有一些功能在子类中并不适用但却因为继承而被包含进来。例如,在一个电商系统中,有Product类和DigitalProduct类,DigitalProduct类继承自Product类:

class Product {
    name: string;
    price: number;
    shippingWeight: number;
    constructor(name: string, price: number, shippingWeight: number) {
        this.name = name;
        this.price = price;
        this.shippingWeight = shippingWeight;
    }
    calculateShippingCost() {
        // 基于重量计算运费的逻辑
        return this.shippingWeight * 10; 
    }
}

class DigitalProduct extends Product {
    constructor(name: string, price: number) {
        super(name, price, 0); 
    }
}

在这里,DigitalProduct类继承了Product类,但shippingWeightcalculateShippingCost方法对于数字产品来说并不适用,这就是继承带来的问题。

5.2 提取公共部分

将继承关系中公共的部分提取出来,形成独立的类或模块。对于上述电商系统的例子,可以将Product类中与产品基本信息相关的部分提取出来:

class ProductInfo {
    name: string;
    price: number;
    constructor(name: string, price: number) {
        this.name = name;
        this.price = price;
    }
}

class PhysicalProduct {
    productInfo: ProductInfo;
    shippingWeight: number;
    constructor(productInfo: ProductInfo, shippingWeight: number) {
        this.productInfo = productInfo;
        this.shippingWeight = shippingWeight;
    }
    calculateShippingCost() {
        return this.shippingWeight * 10; 
    }
}

class DigitalProduct {
    productInfo: ProductInfo;
    constructor(productInfo: ProductInfo) {
        this.productInfo = productInfo;
    }
}

通过这种方式,PhysicalProductDigitalProduct类通过组合ProductInfo类来获取产品的基本信息,避免了不必要的继承。

5.3 重构子类逻辑

根据组合的方式,重构子类的逻辑。将原来依赖父类的部分改为依赖组合的对象。例如,对于PhysicalProductDigitalProduct类,它们不再依赖Product类的继承,而是通过组合ProductInfo类和其他必要的功能类来实现自身的逻辑。同时,如果有需要,还可以为PhysicalProductDigitalProduct类添加各自独特的方法和属性,而不会受到继承关系的限制。

通过以上步骤,可以逐步将继承关系转换为组合关系,提高代码的灵活性和可维护性。

在TypeScript前端开发中,理解并正确运用组合优于继承的设计原则,可以使代码结构更加清晰、灵活,减少类之间的耦合度,提高代码的可维护性和可扩展性。虽然继承在某些特定情况下仍然有其用武之地,但在大多数场景下,组合能够带来更多的优势,开发者应根据实际需求做出合理的选择。