2. 继承基础

继承是一种强大但容易被过度使用和误用的机制。

简单来说,通过继承,一个基类(也称为基类型)定义了特定类型共有的状态和行为,并让子类(也称为子类型)提供这些状态和行为的专门版本。

为了清晰地展示如何使用继承,我们创建一个简单的例子:一个基类Person,它定义了人的通用字段和方法,而子类WaitressActress则提供额外的、更细化的方法实现。

以下是Person类:

public class Person {
    private final String name;

    // 其他字段、标准构造函数、getter
}

以下是子类:

public class Waitress extends Person {

    public String serveStarter(String starter) {
        return "Serving a " + starter;
    }
    
    // 其他方法/构造函数
}
public class Actress extends Person {
    
    public String readScript(String movie) {
        return "Reading the script of " + movie;
    } 
    
    // 其他方法/构造函数
}

此外,我们创建一个单元测试来验证WaitressActress类的实例也是Person的实例,从而在类型层面满足“is-a”条件:

@Test
public void givenWaitressInstance_whenCheckedType_thenIsInstanceOfPerson() {
    assertThat(new Waitress("Mary", "[email protected]", 22))
      .isInstanceOf(Person.class);
}
    
@Test
public void givenActressInstance_whenCheckedType_thenIsInstanceOfPerson() {
    assertThat(new Actress("Susan", "[email protected]", 30))
      .isInstanceOf(Person.class);
}

这里必须强调继承的语义方面。除了重用Person类的实现,我们还建立了基类型Person与子类型WaitressActress之间定义明确的“is-a”关系。服务员和女演员实际上都是人。

这可能会让我们思考:在哪些用例中继承是正确的方法?

如果子类满足“is-a”条件,并且主要在类层次结构中提供附加功能,那么继承就是可行的方式。

当然,只要被重写的方法保持里氏替换原则所提倡的基类型/子类型可替换性,方法重写是允许的。

此外,我们应该记住,子类继承了基类型的API,这在某些情况下可能过度或根本不理想。

否则,我们应该使用组合。

3. 设计模式中的继承

虽然共识是尽可能优先使用组合而非继承,但在一些典型用例中,继承仍有其用武之地。

3.1. 层超类型模式

在这种情况下,我们使用继承将公共代码移动到基类(超类型),按层进行

以下是该模式在领域层的基本实现:

public class Entity {
    
    protected long id;
    
    // setter
}
public class User extends Entity {
    
    // 其他字段和方法   
}

我们可以对系统中的其他层(如服务层和持久层)应用相同的方法。

3.2. 模板方法模式

在模板方法模式中,我们可以使用基类定义算法的不变部分,然后在子类中实现可变部分

public abstract class ComputerBuilder {
    
    public final Computer buildComputer() {
        addProcessor();
        addMemory();
    }
    
    public abstract void addProcessor();
    
    public abstract void addMemory();
}
public class StandardComputerBuilder extends ComputerBuilder {

    @Override
    public void addProcessor() {
        // 方法实现
    }
    
    @Override
    public void addMemory() {
        // 方法实现
    }
}

4. 组合基础

组合是面向对象编程提供的另一种重用实现的机制。

简而言之,组合允许我们建模由其他对象组成的对象,从而在它们之间定义“has-a”关系。

此外,组合是关联关系中最强的形式,这意味着当一个对象被销毁时,组成或包含它的对象也会被销毁

为了更好地理解组合的工作原理,假设我们需要处理表示计算机的对象。

计算机由不同的部分组成,包括微处理器、内存、声卡等,因此我们可以将计算机及其每个部分建模为单独的类。

以下是Computer类的简单实现:

public class Computer {

    private Processor processor;
    private Memory memory;
    private SoundCard soundCard;

    // 标准getter/setter/构造函数
    
    public Optional<SoundCard> getSoundCard() {
        return Optional.ofNullable(soundCard);
    }
}

以下类建模了微处理器、内存和声卡(为简洁起见省略接口):

public class StandardProcessor implements Processor {

    private String model;
    
    // 标准getter/setter
}
public class StandardMemory implements Memory {
    
    private String brand;
    private String size;
    
    // 标准构造函数、getter、toString
}
public class StandardSoundCard implements SoundCard {
    
    private String brand;

    // 标准构造函数、getter、toString
}

不难理解为什么优先选择组合而非继承。在任何场景下,只要能够在给定类与其他类之间建立语义上正确的“has-a”关系,组合就是正确的选择。

在上面的例子中,Computer类与建模其部件的类之间满足“has-a”条件。

还值得注意的是,在这种情况下,包含的Computer对象拥有被包含对象的所有权,当且仅当这些对象不能在另一个Computer对象中重用。如果可以重用,我们使用的是聚合而非组合,其中不隐含所有权。

5. 不使用抽象的组合

或者,我们可以通过硬编码Computer类的依赖关系来定义组合关系,而不是在构造函数中声明它们:

public class Computer {

    private StandardProcessor processor
      = new StandardProcessor("Intel I3");
    private StandardMemory memory
      = new StandardMemory("Kingston", "1TB");
    
    // 其他字段/方法
}

⚠️ 当然,这将是一个僵化、紧耦合的设计,因为我们会使Computer类强依赖于ProcessorMemory的特定实现。

我们不会利用接口和依赖注入提供的抽象级别。

基于接口的初始设计,我们得到了一个松耦合的设计,也更容易测试。

6. 结论

在本文中,我们学习了Java中继承和组合的基础知识,并深入探讨了两种关系类型(“is-a” vs “has-a”)之间的差异。

关键要点总结:

  • ✅ 继承建立“is-a”关系,适用于子类是基类的特化场景
  • ✅ 组合建立“has-a”关系,适用于对象由其他对象组成的场景
  • ❌ 避免过度使用继承,防止API污染和紧耦合
  • ⚠️ 组合时优先使用接口和依赖注入,而非硬编码依赖

一如既往,本教程中显示的所有代码示例都可以在GitHub上找到。


原始标题:Inheritance and Composition (Is-a vs Has-a relationship) in Java | Baeldung