Java 复用设计思想深度剖析:组合、继承与"多用组合少用继承"原则
Java 复用设计思想深度剖析:组合、继承与"多用组合少用继承"原则
本篇技术指南以 YCBlogs 仓库的 Java 类与对象学习体系为背景,系统讲解面向对象中的代码复用设计思想:从"组合(Composition)"与"继承(Inheritance)"两种复用方式的基本概念与完整代码案例入手,深入分析为何业界主张"多用组合少用继承"、组合相比继承究竟有哪些优势,并结合接口、委托(Delegation)与多态原理,给出"何时该用继承、何时该用组合"的可落地判断标准。读完本文,你将掌握一套完整的复用决策框架,能够识别深继承层次的坏味道,并熟练用"接口 + 组合 + 委托"重构出更灵活、更可维护的 Java 代码。
01 快速了解复用
1.1 什么是复用
对于 C 语言等面向过程语言来说,"复用"通常指的就是"复制代码"。任何语言都可以通过简单复制来达到代码复用的目的,但这样做效果并不好:复制意味着同样的逻辑存在多份拷贝,任何一处修改都要同步到所有副本,极易引入不一致的 Bug。
Java 围绕"类"(Class)来解决问题。我们可以直接使用别人构建或调试过的代码,而非创建新类、重新开始。也就是说,复用让开发者站在已有、经过验证的代码之上工作,而不必重复造轮子。
1.2 复用的核心思想:组合与继承
如何在不污染源代码的前提下使用现存代码,是需要技巧的。本章有两条核心路径:
- 组合(Composition):第一种方式直截了当,在新类中创建现有类的对象,通过这种方式复用代码的功能,而非其形式。Java 中组合体现为在类的字段位置持有另一个对象的引用。
- 继承(Inheritance):第二种方式更为微妙,创建现有类类型的新类。照字面理解:采用现有类的形式,又无需在编码时改动其代码。这种方式叫做继承,编译器会做大部分的工作(自动获得父类的字段与方法)。继承是面向对象编程(OOP)的重要基础之一。
关于继承的更多细节(继承格式、构造器初始化、单继承限制等),可参考仓库中的 继承思想详细分析 一文。
02 快速了解组合
2.1 什么是组合
组合是最简单直接的复用方式:你仅需要把对象的引用(object references)放置在一个新的类里,这就使用了组合。组合表达的是 has-a(有一个)关系——新类"拥有"被组合进来的对象。
2.2 组合的入门案例:SprinklerSystem
假设你需要一个对象,其中内置几个 String 对象、两个基本类型(primitives)属性字段,以及一个其他类的对象。对于非基本类型对象,将引用直接放置在新类中;对于基本类型属性字段则仅进行声明:
class WaterSource {
private String s;
WaterSource() {
System.out.println("WaterSource()");
s = "Constructed";
}
@Override
public String toString() { return s; }
}
public class SprinklerSystem {
private String valve1, valve2, valve3, valve4;
private WaterSource source = new WaterSource();
private int i;
private float f;
@Override
public String toString() {
return
"valve1 = " + valve1 + " " +
"valve2 = " + valve2 + " " +
"valve3 = " + valve3 + " " +
"valve4 = " + valve4 + "\n" +
"i = " + i + " " + "f = " + f + " " +
"source = " + source; // [1]
}
public static void main(String[] args) {
SprinklerSystem sprinklers = new SprinklerSystem();
System.out.println(sprinklers);
}
}
这个案例里有几个关键点需要展开理解:
- toString() 的特殊性:每个非基本类型对象都有一个
toString()方法,在编译器需要字符串但手里只有对象的情况下会被自动调用。因此,在[1]处,编译器看到你试图"添加"一个WaterSource类型的对象,而字符串只能拼接另一个字符串,所以它先调用toString()将source转换成一个字符串,然后再拼接并传递给System.out.println()。要让自定义类获得这种行为,只需编写一个toString()方法即可。 - @Override 注解的作用:在
toString()上使用@Override告诉编译器验证重写是否正确。它是可选的,但有助于在编译期发现拼写错误(或者更微妙的大小写字母输入错误),避免静默地新定义一个方法而非重写。 - 默认初始化规则:类中的基本类型字段自动初始化为零(
int为 0、float为 0.0),但对象引用被初始化为null。如果尝试调用 null 引用的任何方法,会得到运行时异常(NullPointerException);方便的是,直接打印 null 引用却不会抛异常——这正是toString()中source = "null"的由来。
2.3 四种初始化引用方式:Bath 案例
编译器不会为每个引用自动创建一个默认对象,这是有意义的,因为在许多情况下这会导致不必要的开销。初始化引用一共有四种方式:
- 定义时初始化:当对象被定义时直接赋值,这意味着它总是在调用构造函数之前初始化。
- 构造函数中初始化:在该类的构造函数中完成。
- 延迟初始化:在实际使用对象之前才初始化。在对象创建开销大且不需要每次都创建对象的情况下,这种方式可以显著减少开销。
- 实例初始化:使用实例初始化块(instance initializer)完成。
下面用 Bath 案例把四种方式完整演示一遍:
class Soap {
private String s;
Soap() {
System.out.println("Soap()");
s = "Constructed";
}
@Override
public String toString() { return s; }
}
public class Bath {
private String // Initializing at point of definition:
s1 = "Happy",
s2 = "Happy",
s3, s4;
private Soap castille;
private int i;
private float toy;
public Bath() {
System.out.println("Inside Bath()");
s3 = "Joy";
toy = 3.14f;
castille = new Soap();
}
// Instance initialization:
{ i = 47; }
@Override
public String toString() {
if(s4 == null) // Delayed initialization:
s4 = "Joy";
return
"s1 = " + s1 + "\n" +
"s2 = " + s2 + "\n" +
"s3 = " + s3 + "\n" +
"s4 = " + s4 + "\n" +
"i = " + i + "\n" +
"toy = " + toy + "\n" +
"castille = " + castille;
}
public static void main(String[] args) {
Bath b = new Bath();
System.out.println(b);
}
}
/* Output:
Inside Bath()
Soap()
s1 = Happy
s2 = Happy
s3 = Joy
s4 = Joy
i = 47
toy = 3.14
castille = Constructed
*/
运行结果揭示了组合初始化的完整时序:
s1、s2在定义处初始化(方式一),最先完成;s3、toy、castille在构造函数中初始化(方式二),其中Soap的构造在Bath构造函数内被调用,所以打印出Soap();i通过实例初始化块赋值(方式四),注意实例初始化块在所有构造逻辑之前执行;s4属于延迟初始化(方式三),在toString()第一次被调用时才赋值。之所以在toString()里才初始化,是为了保证"在使用字段的时候所有的属性都已被初始化"。
需要注意:当你不在定义处初始化时,仍然不能保证在向对象引用发送消息之前执行任何初始化——如果你试图对未初始化的引用调用方法,未初始化的引用将产生运行时异常。
2.4 复杂的组合案例:PlaceSetting(组合 + 继承)
实际开发中,你经常会同时使用组合和继承。下面的例子展示了如何用继承创建一组组件类,再通过组合把它们装配进一个更大的类,并理清构造函数的初始化顺序:
class Plate {
Plate(int i) {
System.out.println("Plate constructor");
}
}
class DinnerPlate extends Plate {
DinnerPlate(int i) {
super(i);
System.out.println("DinnerPlate constructor");
}
}
class Utensil {
Utensil(int i) {
System.out.println("Utensil constructor");
}
}
class Spoon extends Utensil {
Spoon(int i) {
super(i);
System.out.println("Spoon constructor");
}
}
class Fork extends Utensil {
Fork(int i) {
super(i);
System.out.println("Fork constructor");
}
}
class Knife extends Utensil {
Knife(int i) {
super(i);
System.out.println("Knife constructor");
}
}
// A cultural way of doing something:
class Custom {
Custom(int i) {
System.out.println("Custom constructor");
}
}
public class PlaceSetting extends Custom {
private Spoon sp;
private Fork frk;
private Knife kn;
private DinnerPlate pl;
public PlaceSetting(int i) {
super(i + 1);
sp = new Spoon(i + 2);
frk = new Fork(i + 3);
kn = new Knife(i + 4);
pl = new DinnerPlate(i + 5);
System.out.println("PlaceSetting constructor");
}
public static void main(String[] args) {
PlaceSetting x = new PlaceSetting(9);
}
}
/* Output:
Custom constructor
Utensil constructor
Spoon constructor
Utensil constructor
Fork constructor
Utensil constructor
Knife constructor
Plate constructor
DinnerPlate constructor
PlaceSetting constructor
*/
这个案例展示了两个"强制"与一个"不强制":
- 编译器强制你初始化基类:
PlaceSetting构造函数第一行必须通过super(i + 1)调用基类Custom的带参构造,否则编译报错;对基类构造函数的调用必须是派生类构造函数中的第一个操作。 - 编译器强制从基类向外初始化:构造从最顶层基类
Custom开始,逐层向下,每个成员对象(Spoon、Fork、Knife、DinnerPlate)也遵循"先初始化自己的基类(Utensil、Plate),再执行自己的构造体"的规则。 - 编译器不监视成员对象的初始化:它并不确保你初始化了所有成员对象——成员对象默认是
null,忘记new只会到运行期才暴露问题。
关于继承初始化顺序的更完整验证(成员变量先于构造函数、从最顶层父类逐级向下),可参考 继承思想详细分析 中的 Animal → Dog → Huskie 三级日志案例。
另外注意,这个案例中类被干净地分离:你甚至不需要方法复用代码的源代码,最多只导入一个包——这一点对继承和组合都成立。
03 深入理解继承:复用与委托
3.1 继承的格式与意义
继承用来表示类之间的 is-a 关系(猫是一种哺乳动物),Java 使用 extends 关键字实现:class 子类名 extends 父类名 {}。单独的这个类称为父类、基类或超类;多个子类称为子类或派生类。
继承最大的好处是代码复用:把多个类的相同属性和方法抽取到父类,子类自动获得这些成员。同时继承也是多态的前提——多态要求存在继承关系的子类和父类、子类重写父类方法、父类引用指向子类对象(向上转型)。基于继承的多态与基于接口的多态的具体区别,可参见仓库中的 多态思想详细分析。
3.2 委托:介于继承与组合之间的第三种复用关系
Java 不直接支持的第三种重用关系叫做委托(Delegation),它介于继承和组合之间:你将一个成员对象放在正在构建的类中(像组合),同时又在新类中公开来自成员对象的所有方法(像继承)。
以宇宙飞船的控制模块为例。先定义一个控制模块类:
public class SpaceShipControls {
void up(int velocity) {}
void down(int velocity) {}
void left(int velocity) {}
void right(int velocity) {}
void forward(int velocity) {}
void back(int velocity) {}
void turboBoost() {}
}
用继承来建造宇宙飞船虽然可行,但语义是错误的——DerivedSpaceShip 并不是真正"一种" SpaceShipControls:
public class DerivedSpaceShip extends SpaceShipControls {
private String name;
public DerivedSpaceShip(String name) {
this.name = name;
}
@Override
public String toString() { return name; }
public static void main(String[] args) {
DerivedSpaceShip protector = new DerivedSpaceShip("NSEA Protector");
protector.forward(100);
}
}
更准确的说法是:一艘宇宙飞船包含了控制模块,同时控制模块中的所有方法都暴露在宇宙飞船上。委托正好解决这个难题:
public class SpaceShipDelegation {
private String name;
private SpaceShipControls controls = new SpaceShipControls();
public SpaceShipDelegation(String name) {
this.name = name;
}
// Delegated methods:
public void back(int velocity) { controls.back(velocity); }
public void down(int velocity) { controls.down(velocity); }
public void forward(int velocity) { controls.forward(velocity); }
public void left(int velocity) { controls.left(velocity); }
public void right(int velocity) { controls.right(velocity); }
public void turboBoost() { controls.turboBoost(); }
public void up(int velocity) { controls.up(velocity); }
public static void main(String[] args) {
SpaceShipDelegation protector = new SpaceShipDelegation("NSEA Protector");
protector.forward(100);
}
}
方法被转发到底层的 control 对象,对外接口与继承得到的接口完全相同。但委托的优势在于可控:你可以只暴露成员对象方法的一个子集,而不是像继承那样全盘接收,这对封装性更友好。委托是后面"组合 + 接口 + 委托替代继承"方案的核心机制之一。
04 结合组合与继承:经典设计原则
4.1 经典设计原则:组合优于继承
在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。随之而来的三个核心追问是:为什么不推荐使用继承?组合相比继承有哪些优势?如何判断该用组合还是继承?
4.2 为何不推荐使用继承
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用问题。但继承层次过深、过复杂,也会影响代码的可维护性,所以网上很多人认为继承是一种反模式,应该尽量少用甚至不用。
用一个经典的"鸟类"设计案例来说明。假设要把"鸟类"定义为抽象类 AbstractBird,麻雀、鸽子、乌鸦等都继承它。那么问题来了:大部分鸟都会飞,能不能在 AbstractBird 中直接定义 fly() 方法?
答案是不能。因为鸵鸟不会飞——鸵鸟继承带 fly() 的父类,就具有了"飞"的行为,这不符合现实认知。有人会说:在子类中重写 fly() 抛出异常不就行了吗?
public class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
}
public class Ostrich extends AbstractBird { //鸵鸟
//...省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException("I can't fly.'");
}
}
再看一个更贴近工程实践的代码案例——把"会不会飞、会不会叫、会不会游泳"三个行为维度硬塞进一个抽象类:
//多组合,少继承
//比如。会不会飞,会不会叫,会不会游泳,这样定义抽象类会比较多。有的可以不用
public abstract class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
public void eat(){ }
public void call(){ }
public void swimming(){ }
}
public class BirdA extends AbstractBird {
//A,不会飞,会叫,会游泳
public void fly() { throw new RuntimeException("I can't fly.'"); }
@Override public void call() { super.call(); }
@Override public void swimming() { super.swimming(); }
}
public class BirdB extends AbstractBird {
//B,不会飞,不会叫,会游泳
public void fly() { throw new RuntimeException("I can't fly.'"); }
@Override public void call() { throw new RuntimeException("I can't call.'"); }
@Override public void swimming() { super.swimming(); }
}
public class BirdC extends AbstractBird {
//C,会飞,不会叫,不会游泳
public void fly() { }
@Override public void call() { super.call(); }
@Override public void swimming() { super.swimming(); }
}
这种设计思路虽然能解决问题,但不够优美,至少存在两处硬伤:
- 除了鸵鸟,不会飞的鸟还有企鹅等一大类,每个都要重写
fly()抛出异常,徒增编码工作量; - 它违背了最小知识原则(Least Knowledge Principle,也叫最少知识原则或迪米特法则):父类暴露了不该暴露的接口给外部,增加了类被误用的概率。
如果继续沿着继承的思路"修补",派生 AbstractFlyableBird 和 AbstractUnFlyableBird 两个子抽象类,继承层次就变成了三层。再叠加第二个行为维度"会不会叫",两两组合产生四种情况,就需要四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird);如果再考虑"是否会下蛋"这个行为,那就要组合爆炸了。
深层问题随之浮现:类的继承层次会越来越深、继承关系越来越复杂。这种设计一方面导致代码可读性变差——要搞清楚某个类有哪些方法、属性,必须一路追溯父类、父类的父类直到最顶层;另一方面破坏了封装特性——父类实现细节暴露给子类,子类实现依赖父类实现,两者高度耦合,父类代码一旦修改,就会影响所有子类逻辑。
继承最大的问题:继承层次过深、继承关系过于复杂,会影响代码的可读性和可维护性,这正是不推荐使用继承的根本原因。
4.3 组合的优势:接口 + 组合 + 委托
实际上,可以利用**组合(composition)、接口、委托(delegation)**三个技术手段一起解决继承存在的问题。
接口表示具有某种行为特性。针对"会飞",定义 Flyable 接口,只让会飞的鸟实现它;类似地定义 Tweetable(会叫)和 EggLayable(会下蛋)接口:
public interface Flyable { void fly(); }
public interface Tweetable { void tweet(); }
public interface EggLayable { void layEgg(); }
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
//... 省略其他属性和方法...
@Override public void tweet() { //... }
@Override public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
//... 省略其他属性和方法...
@Override public void fly() { //... }
@Override public void tweet() { //... }
@Override public void layEgg() { //... }
}
但接口只声明方法、不定义实现,每个会下蛋的鸟都要重复实现一遍逻辑相同的 layEgg(),导致代码重复。解决方案是:针对每个接口再定义实现类,然后用组合 + 委托消除重复:
public interface Flyable { void fly(); }
public class FlyAbility implements Flyable {
@Override public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); //组合
private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
//... 省略其他属性和方法...
@Override public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
至此可以看到,继承的三个作用——表示 is-a 关系、支持多态特性、代码复用——都有替代方案:
| 继承的作用 | 替代技术手段 |
|---|---|
| is-a 关系 | 组合 + 接口的 has-a 关系 |
| 多态特性 | 接口(多态基于接口的实现) |
| 代码复用 | 组合 + 委托 |
所以从理论上讲,通过组合、接口、委托三个技术手段,完全可以替换掉继承,在项目中不用或少用继承关系,尤其是复杂的继承关系。
这里对抽象类与接口的选择做一点补充:如果表示 is-a 关系且为了解决代码复用问题,用抽象类;如果表示 has-a 关系且为了解决抽象(解耦)而非代码复用问题,用接口。抽象类是自下而上(先有子类重复,再抽象出父类)的设计思路,接口是自上而下(先定义契约,再考虑实现)的设计思路。更详细的对比见仓库中的 抽象类和接口分析。
05 组合与继承的选择
5.1 如何选择
尽管鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。把继承改写成组合意味着更细粒度的类拆分——要定义更多的类和接口,这或多或少会增加代码的复杂程度和维护成本。所以在实际项目中,还是要根据具体情况来选择。
判断标准可以归纳为两条:
- 继承结构稳定(不会轻易改变)、层次浅(最多两层继承关系)、关系不复杂 → 可以大胆使用继承。
- 系统不稳定、继承层次深、继承关系复杂 → 尽量使用组合替代继承。
此外,仅仅为了代码复用而强行抽象父子关系也是反模式。比如 Crawler 类和 PageAnalyzer 类都用到了 URL 拼接和分割功能,但两者既不是父子关系也不是兄弟关系。此时生硬地抽一个 Url 父类出来,会让不熟悉设计思路的同事觉得"莫名其妙";用组合则更合理、更灵活:
public class Url {
//...省略属性和方法
}
public class Crawler {
private Url url; // 组合
public Crawler() {
this.url = new Url();
}
//...
}
public class PageAnalyzer {
private Url url; // 组合
public PageAnalyzer() {
this.url = new Url();
}
//..
}
5.2 案例分析:Car(has-a 与 is-a 的判断)
组合和继承都允许在新类中放置子对象(组合是显式的,继承是隐式的)。判断依据很朴素:当你想在新类中包含一个已有类的功能时,使用组合而非继承——在新类中嵌入一个对象(通常是私有的)以实现其功能,使用者看到的是新类定义的接口,而非嵌入对象的接口。
经典的 Car 案例:
class Engine {
public void start() {}
public void rev() {}
public void stop() {}
}
class Wheel {
public void inflate(int psi) {}
}
class Window {
public void rollup() {}
public void rolldown() {}
}
class Door {
public Window window = new Window();
public void open() {}
public void close() {}
}
public class Car {
public Engine engine = new Engine();
public Wheel[] wheel = new Wheel[4];
public Door left = new Door(), right = new Door(); // 2-door
public Car() {
for (int i = 0; i < 4; i++) {
wheel[i] = new Wheel();
}
}
public static void main(String[] args) {
Car car = new Car();
car.left.window.rollup();
car.wheel[0].inflate(72);
}
}
这个例子中 Car 的组合本身就是问题分析的一部分(不是底层设计的一部分),所以成员声明为 public 有助于客户端程序员理解如何使用类,也降低了类创建者面临的复杂度。但请记住这是一个特例——通常属性还是应该声明为 private。
用继承时,则是使用一个现有类并开发出它的新版本,通常意味着把通用类特殊化。稍微思考就会发现:用一个交通工具对象来组成一部车毫无意义——车不包含交通工具,它就是交通工具。"是一个"关系用继承表达,"有一个"关系用组合表达。这正是本节判断标准的最好注脚。
5.3 设计模式与组合/继承的固定搭配
除了稳定性与层次深浅之外,还有一些设计模式会固定使用继承或组合:
- 使用组合关系的模式:装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等。
- 使用继承关系的模式:模板模式(template pattern)。
以装饰者模式为例,它"以对客户端透明的方式扩展对象的功能,是继承关系的一个替代方案",核心是持有构件对象的实例并委派调用(private Component component; 再加接口一致的转发方法),这正是组合 + 委托的典型结构。Java IO 标准库的 InputStream → FilterInputStream → BufferedInputStream/DataInputStream 就是装饰者模式最著名的应用,具体分析见 装饰者设计模式介绍。
策略模式同样体现组合思想:环境类(Context)持有抽象策略的引用,通过构造函数注入具体策略对象,运行时利用多态动态调用——"针对一组算法,将每一个算法封装到具有共同接口的独立类中,从而使得它们可以相互替换",见 策略者设计模式。
5.4 必须使用继承的特殊场景
还有一个场景必须使用继承:不能改变函数的入参类型,而入参又非接口,为了支持多态只能采用继承。比如 FeignClient 是外部框架类,我们没有权限修改它的代码,但希望重写它运行时执行的 encode() 方法:
public class FeignClient { // feign client框架代码
//...省略其他代码...
public void encode(String url) { //... }
}
public void demofunction(FeignClient feignClient) {
//...
feignClient.encode(url);
//...
}
public class CustomizedFeignClient extends FeignClient {
@Override
public void encode(String url) { //...重写encode的实现...}
}
// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);
这种"外部类 + 非接口入参 + 需要重写行为"的组合,只能通过继承来达成多态替换。
尽管有人主张杜绝继承、100% 用组合替代继承,但这里的观点没那么极端。"多用组合少用继承"的口号之所以喊得响,只是因为长期以来存在过度使用继承的问题。组合并不完美,继承也不是一无是处——只要控制好各自的副作用、发挥各自的优势,在不同的场合下恰当地选择继承还是组合,这才是我们所追求的境界。
06 总结一下笔记
-
为什么不推荐使用继承? 继承是面向对象的四大特性之一,表示类之间的 is-a 关系,可以解决代码复用问题。但继承层次过深、过复杂会严重影响代码的可维护性:一方面阅读成本高(要追溯整条父类链),另一方面破坏封装、子类与父类高度耦合。这种情况下应该尽量少用甚至不用继承。
-
组合相比继承有哪些优势? 继承的三个作用(is-a 关系、多态特性、代码复用)都可以通过组合、接口、委托三个技术手段达成;除此之外,组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
-
如何判断该用组合还是继承? 组合并不完美、继承并非一无是处,应根据具体情况选择:继承结构稳定、层次浅、关系不复杂,可以大胆使用继承;反之尽量用组合替代。同时,装饰者模式、策略模式、组合模式固定使用组合,模板模式固定使用继承;而"外部类 + 非接口入参 + 需要重写行为"的场景则必须使用继承。
相关延伸阅读:本文是 YCBlogs 仓库 Java 面向对象体系中的一节,配套资料包括 继承思想详细分析、多态思想详细分析、抽象类和接口分析,设计原则侧可参考 面向对象六大原则 与 多用组合和少继承 两篇姊妹文章。