Java中继承与组合的权衡:可维护性设计模式详解

发布于 2025-07-17 | 更新于 2025-07-17

组合VS继承

今天我们来聊点基础但实用的编程思想。

怎么形容滥用继承带给我们的后果呢,我在网上找了一张图片,一头奔跑的牛:

image-20250717084115100

如果你的代码以某种莫名的方式运行起来了,就不要再碰它了。

大家看到这张图时,可能都会忍不住笑出声来,然后一笑了之。但真正静下心来思考:到底是什么样的过程,才“诞生”出了这头奇葩的牛?恐怕很少有人认真追究过背后的原因。

其实,只要是经历过多轮迭代的老项目,几乎都逃不过类似的命运,只是“长成这副模样”的频率和程度不同罢了。

故事往往是这样开始的:某天产品提了个新需求,说项目里的这头牛得“跑起来”。开发接到需求后开始琢磨,正准备新增“脚”的功能时,突然发现牛身上那个原本用于“产奶”的部位,看上去有点像需求里提到的“四条腿”。为了不破坏现有结构,又能快速交付,于是干脆“继承”了一下,把产奶器官伪装成了脚,加上了“奔跑”的方法。最终,奇迹就这样诞生了,画面中的这头四不像牛,便成了现实。

又比如,下面这幅画,一直飞起来的小鸟:

让鸟飞起来

产品提了个需求,说这只鸟得飞起来。开发一听,心想飞起来不就是鸟的本事嘛,应该不难。结果一看代码,这鸟压根没翅膀,连“飞”这个功能都从未实现过。

为了尽快上线,开发灵机一动:飞不起来没关系,可以“借用”会飞的。于是他找到项目里一个飞得挺稳的模块:直升机。反正都有“升空”这个能力,不如让这只鸟直接继承直升机的逻辑。

于是,这只鸟拥有了旋转桨叶、启动引擎的技能,最终也算是“飞起来”了。虽然飞的方式有点怪,但好歹满足了需求不是?


在项目开发中,**“代码复用 vs 灵活性”**几乎是每个开发者绕不开的一道选择题。

继承,作为面向对象里那块“看起来最顺手”的工具,确实让我们能快速复用代码、提炼通用逻辑——几行代码就能把功能“继承”下来,听起来是不是很爽?

但问题也随之而来:**一不小心,就把多个类绑得死死的,耦合度爆表,改动一个地方,整条继承链都得抖三抖。**更别说测试起来有多麻烦,代码也越来越“不自由”。

这篇文章,我打算结合案例,聊聊继承到底有哪些坑、哪些用得巧的场景,以及——什么时候我们该忍住别继承,转头拥抱更灵活的设计方式。

当然,我也能理解大家最初会有这种想法。就连 Oracle 官方的 Java SE 文档也可能误导你[1]

One of the primary purposes of inheritance is code reuse: When you want to create a new class and there is already a class that includes some of the code that you want, you can derive your new class from the existing class. In doing this, you can reuse the fields and methods of the existing class without having to write (and debug) them yourself.

这句话大致意思就是:继承的主要目的是为了代码复用。当你想创建一个新类,而已有的类已经包含了你需要的字段和方法时,可以通过继承来重用现有的实现,无需自己重新编写和调试。

我想在这里郑重说一句:继承,绝不该只是“图省事”的代码复用工具。

太多次,我看到项目里为了省几行代码、复用个方法,硬生生拉出一个继承关系——结果刚开始看着还行,等到后期维护,才发现简直是踩进了耦合的泥潭。改一处,挂一片,测试爆成片,根本不知道是哪里动了哪根神经。

我们常说继承体现的是一种“是一个(is-a)”的语义关系。也就是说,**只有当子类真的“本质上就是”父类的一种特化,继承才是合适的。**比如“管理员是用户的一种”,“鸟是动物的一种”,这时候继承就讲得通。

而如果我们仅仅为了复用而忽视了这一点,就会偏离设计的初衷。许多 MVC 框架和第三方库通过提供可继承的基类,鼓励用户直接扩展以实现功能复用,这让开发者误以为继承是一种优秀的工程实践。然而,一旦真正采用这种思路,就会陷入维护的困境:各类之间被无意义地耦合,一次改动往往会导致大量单元测试失效。

当然,话说回来,**继承也不是原罪。**如果你确实是在已有类型基础上做语义明确的扩展,功能和角色清晰,继承依旧是一个合理、甚至优雅的选择。前提是——你得想清楚,子类到底是不是“那个父类”。

继承正确案例

以下是一个典型场景:荔枝继承水果(Fruit)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义水果层次:Lychee 应当继承自 Fruit
public class Fruit {
public void eat() {
System.out.println("Fruit is eaten");
}
}

public class Lychee extends Fruit {
// 把荔枝送到长安
public void deliverToChangAn() {
System.out.println("Delivering lychee to Chang'an");
}
}

public class Main {
public static void main(String[] args) {
Lychee lychee = new Lychee();
lychee.eat(); // 重用 Fruit 的 eat 方法
lychee.deliverToChangAn(); // 添加荔枝送往长安的逻辑
}
}

类图如下:

classDiagram
    class Fruit {
        +eat() void
    }
    
    class Lychee {
        +deliverToChangAn() void
    }
    
    Fruit <|-- Lychee : extends

在上述代码中,Lychee 是一种 Fruit,符合“is-a”语义。通过继承,我们重用了 Fruit 中定义的 eat 方法,同时在 Lychee 中添加了 deliverToChangAn 方法,实现了行为扩展。

下面我将借用一个常见示例来说明如何“慎用”继承。

继承错误案例

首先,是一个滥用继承导致错误的场景。

话说这荔枝要运送给杨贵妃,如果里面有虫子那绝对不行,为此,我们先要给荔枝除虫,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 基础 Fruit 类:去除单个虫子的操作
class Fruit {
void removeWorm(Worm worm) {
// 真正的去虫逻辑
System.out.println("Removed one worm from fruit");
}

void removeAllWorms(Worm... worms) {
for (Worm each : worms) {
removeWorm(each);
}
}
}

// 子类尝试统计去虫次数
class CountingLychee extends Fruit {
int wormsRemoved = 0;

@Override
void removeWorm(Worm worm) {
wormsRemoved++;
super.removeWorm(worm);
}

@Override
void removeAllWorms(Worm... worms) {
// 这里误以为只需一次性加 worms.length
wormsRemoved += worms.length;
super.removeAllWorms(worms);
}

int getWormsRemoved() {
return wormsRemoved;
}
}

public class Main {
public static void main(String[] args) {
Worm w1 = new Worm(), w2 = new Worm();
CountingLychee lychee = new CountingLychee();
lychee.removeAllWorms(w1, w2);
// 期望去除 2 只虫子,但实际 wormsRemoved = 4
System.out.printf("Worms removed: %d%n", lychee.getWormsRemoved());
}
}

类图如下:

classDiagram
    class Fruit {
        +removeWorm(Worm worm) void
        +removeAllWorms(Worm... worms) void
    }
    
    class CountingLychee {
        +int wormsRemoved
        +removeWorm(Worm worm) void
        +removeAllWorms(Worm... worms) void
        +getWormsRemoved() int
    }
    
    Fruit <|-- CountingLychee : extends

如上所示,removeAllWorms 里既手动累加了 worms.length,又调用了被子类重写的 removeWorm,导致每只虫子被统计了两次。这正是继承滥用导致的典型问题。

这个问题本质上是把统计去虫的数量的逻辑耦合在了荔枝对象里面,我们可以使用对象组合把统计去虫数量的计数器单独拎出来,避免这种问题。

对象组合的使用

接着,我们用组合(Composition)优雅地解决这一问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 组合实现:统计去除虫子次数(非侵入式)
interface Fruit {
void removeWorm(Worm worm);
void removeAllWorms(Worm... worms);
}

class Lychee implements Fruit {
@Override
public void removeWorm(Worm worm) {
// 真正的去虫逻辑
System.out.println("Removed one worm from lychee");
}

@Override
public void removeAllWorms(Worm... worms) {
for (Worm each : worms) {
removeWorm(each);
}
}
}

// Composition:包装 Fruit 实现计数功能
class CountingFruit implements Fruit {
private final Fruit fruit;
private int wormsRemoved = 0;

public CountingFruit(Fruit fruit) {
this.fruit = fruit;
}

@Override
public void removeWorm(Worm worm) {
// 每移除一次,计数器增加
wormsRemoved++;
fruit.removeWorm(worm);
}

@Override
public void removeAllWorms(Worm... worms) {
// 计数器增加 worms.length 次
wormsRemoved += worms.length;
fruit.removeAllWorms(worms);
}

public int getWormsRemoved() {
return wormsRemoved;
}
}

public class Main {
public static void main(String[] args) {
Worm w1 = new Worm(), w2 = new Worm(), w3 = new Worm(), w4 = new Worm();
Fruit lychee = new CountingFruit(new Lychee());
lychee.removeAllWorms(w1, w2, w3, w4);
// 正确输出 4
System.out.printf("Worms removed: %d%n", ((CountingFruit) lychee).getWormsRemoved());
}
}

如此一来,我们既复用了荔枝的去虫逻辑,又杜绝了重复统计的问题,组合彻底消除了侵入式耦合。

类图如下:

classDiagram
    class Fruit {
        <>
        +removeWorm(Worm worm)
        +removeAllWorms(Worm... worms)
    }
    
    class Lychee {
        +removeWorm(Worm worm)
        +removeAllWorms(Worm... worms)
    }
    
    class CountingFruit {
        -Fruit fruit
        -int wormsRemoved
        +CountingFruit(Fruit fruit)
        +removeWorm(Worm worm)
        +removeAllWorms(Worm... worms)
        +getWormsRemoved() int
    }
   
    
    Fruit <|.. Lychee : implements
    Fruit <|.. CountingFruit : implements
    CountingFruit o-- Fruit : composition

这里的 CountingFruit 内部持有一个 Fruit 引用,通过委托(delegation)来扩展功能,这是典型的 对象组合(Object Composition)

这是“组合模式”吗?

这里的 CountingFruit 内部持有一个 Fruit 引用,通过委托(delegation)来扩展功能,这是典型的 对象组合(Object Composition)

这里更契合 装饰器模式(Decorator Pattern) 的思想:在不修改原类的情况下,对其功能进行增强,而不是组合模式。“组合模式”(Composite Pattern)通常指将对象组织成树形结构,让客户端以一致的方式处理单个对象和组合对象;与你这里的场景并不相符。

装饰器模式

进一步地,如果要在给荔枝除虫场景中支持日志记录、缓存等功能,也可以使用装饰器模式(Decorator Pattern),示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 抽象装饰器:委托 Fruit 的实现
abstract class FruitDecorator implements Fruit {
protected final Fruit fruit;
public FruitDecorator(Fruit fruit) {
this.fruit = fruit;
}
@Override
public void removeWorm(Worm worm) {
fruit.removeWorm(worm);
}
@Override
public void removeAllWorms(Worm... worms) {
fruit.removeAllWorms(worms);
}
}

// 日志装饰器:在移除虫子前记录日志
class LoggingFruit extends FruitDecorator {
public LoggingFruit(Fruit fruit) {
super(fruit);
}

@Override
public void removeWorm(Worm worm) {
System.out.println("Log: Removing worm " + worm);
super.removeWorm(worm);
}
}

// 缓存装饰器:避免重复移除相同虫子
class CachingFruit extends FruitDecorator {
private Worm lastRemoved = null;

public CachingFruit(Fruit fruit) {
super(fruit);
}

@Override
public void removeWorm(Worm worm) {
if (worm.equals(lastRemoved)) {
System.out.println("Cache: Worm " + worm + " already removed, skipping.");
return;
}
super.removeWorm(worm);
lastRemoved = worm;
}
}

// 使用装饰器组合示例
public class DecoratorDemo {
public static void main(String[] args) {
Fruit lychee = new CachingFruit(
new LoggingFruit(
new Lychee()));
Worm w = new Worm();
lychee.removeWorm(w);
lychee.removeWorm(w);
}
}

类图如下:

---
config:
  layout: elk
---
classDiagram
    class Fruit {
        <>
        +removeWorm(Worm worm) void
        +removeAllWorms(Worm... worms) void
    }
    class FruitDecorator {
        <>
        #Fruit fruit
        +FruitDecorator(Fruit fruit)
        +removeWorm(Worm worm) void
        +removeAllWorms(Worm... worms) void
    }
    class LoggingFruit {
        +LoggingFruit(Fruit fruit)
        +removeWorm(Worm worm) void
    }
    class CachingFruit {
        -Worm lastRemoved
        +CachingFruit(Fruit fruit)
        +removeWorm(Worm worm) void
    }
    Fruit <|.. FruitDecorator : implements
    FruitDecorator <|-- LoggingFruit : extends
    FruitDecorator <|-- CachingFruit : extends
    FruitDecorator o-- Fruit : composition

这样,整个除虫流程:

  1. BasicLychee 负责核心除虫逻辑
  2. LoggingLychee 负责前置日志记录
  3. CachingLychee 负责缓存去重

既保持了核心实现的清晰,又能灵活叠加不同功能。


除了装饰器模式,其他模式也可以为我们的提供方法的扩展。

访问者模式

我们不妨通过一个轻松但不失逻辑的业务场景——“将荔枝送往长安”——来直观理解访问者模式(Visitor Pattern)

设想一下:从南方采摘的新鲜荔枝,要通过一系列步骤才能顺利抵达长安——包括水路运输陆路运输,以及途中偷偷尝一颗以慰风尘

现在的需求是,我们希望对这些步骤各自加上一些额外的处理逻辑(比如统计成本、记录日志、验证条件等),**但又不想频繁修改原有“步骤类”代码。**这正是访问者模式大展拳脚的好机会。

访问者模式可以让我们“访问”每一个步骤节点,并对它们附加不同逻辑,而无需动原始类一行代码。

下面我们先定义一下“运输步骤”的类型结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface DeliveryStep {
void accept(DeliveryProcessor processor);
}

class WaterRoute implements DeliveryStep {
@Override
public void accept(DeliveryProcessor processor) {
processor.visit(this);
}
}

class LandRoute implements DeliveryStep {
@Override
public void accept(DeliveryProcessor processor) {
processor.visit(this);
}
}

class StealLychee implements DeliveryStep {
@Override
public void accept(DeliveryProcessor processor) {
processor.visit(this);
}
}

如果想在不同步骤中加入额外逻辑,传统做法可能需要大量 instanceof 分支,维护成本高。访问者模式则简化了这一过程:

  1. 定义访问者接口:

    1
    2
    3
    4
    5
    interface DeliveryProcessor {
    void visit(WaterRoute step);
    void visit(LandRoute step);
    void visit(StealLychee step);
    }
  2. 实现具体访问者,例如记录日志和执行业务:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class LoggingProcessor implements DeliveryProcessor {
    @Override public void visit(WaterRoute step) { System.out.println("记录日志:沿水路运送荔枝"); }
    @Override public void visit(LandRoute step) { System.out.println("记录日志:沿陆路运送荔枝"); }
    @Override public void visit(StealLychee step) { System.out.println("记录日志:偷吃一颗荔枝"); }
    }

    class ExecuteProcessor implements DeliveryProcessor {
    @Override public void visit(WaterRoute step) { System.out.println("执行:开始水路运输"); }
    @Override public void visit(LandRoute step) { System.out.println("执行:开始陆路运输"); }
    @Override public void visit(StealLychee step) { System.out.println("执行:偷偷吃掉一颗荔枝"); }
    }
  3. 在主流程中调度:

    1
    2
    3
    4
    public void deliverLychee(DeliveryStep step) {
    step.accept(new LoggingProcessor());
    step.accept(new ExecuteProcessor());
    }

类图如下:

classDiagram
    class DeliveryProcessor {
        <>
        +visit(WaterRoute step) void
        +visit(LandRoute step) void
        +visit(StealLychee step) void
    }
    
    class LoggingProcessor {
        +visit(WaterRoute step) void
        +visit(LandRoute step) void
        +visit(StealLychee step) void
    }
    
    class ExecuteProcessor {
        +visit(WaterRoute step) void
        +visit(LandRoute step) void
        +visit(StealLychee step) void
    }
    
    class DeliveryStep {
        <>
        +accept(DeliveryProcessor processor) void
    }
    
    class WaterRoute {
        +accept(DeliveryProcessor processor) void
    }
    
    class LandRoute {
        +accept(DeliveryProcessor processor) void
    }
    
    class StealLychee {
        +accept(DeliveryProcessor processor) void
    }
    
    class Client {
        +deliverLychee(DeliveryStep step) void
    }
    
    DeliveryProcessor <|.. LoggingProcessor : implements
    DeliveryProcessor <|.. ExecuteProcessor : implements
    DeliveryStep <|.. WaterRoute : implements
    DeliveryStep <|.. LandRoute : implements
    DeliveryStep <|.. StealLychee : implements
    
    Client ..> DeliveryStep : uses
    Client ..> LoggingProcessor : creates
    Client ..> ExecuteProcessor : creates
    DeliveryStep ..> DeliveryProcessor : uses
    DeliveryProcessor ..> WaterRoute : visits
    DeliveryProcessor ..> LandRoute : visits
    DeliveryProcessor ..> StealLychee : visits

两行代码就完成了对不同步骤的分发与处理 —— 同时支持新功能扩展,无需修改原有调度逻辑。


从上面的例子可以看出几个关键点:

  1. 继承不是不好,关键看怎么用 —— 在装饰器模式、访问者模式这类经典设计里,继承和组合、策略、模式匹配等手法搭配使用,能构建出既灵活又好维护的系统。
  2. 真正的问题是滥用继承 —— 如果只是为了“省点事”重用代码而继承,很容易让子类和父类绑得太死,维护起来简直噩梦。
  3. 工具多了不一定乱,关键 —— 不管是组合、继承,还是设计模式、函数式思想,甚至最原始的 for 循环,每种方式都有它能大展拳脚的地方。别盲目崇拜某一种,也别一棍子打死某一派。

说到底,写代码就像木匠干活儿:有时候该用锯子就别拿锤子硬敲。能灵活运用各种设计思路和工具,才是真正的“工程化”能力。

希望你也能在编码中找到乐趣,而不是天天和一坨谁也不敢动的代码打架。

References


  1. 4 Sealed Classes ↩︎

本文作者: arthinking

本文链接: https://www.itzhai.com/java/from-inheritance-to-composition-design-patterns.html

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请订阅本站。

×
Java架构杂谈

订阅及时获取网站内容更新。

充电

当前电量:100%

Java架构杂谈

订阅我,及时获取网站内容更新。