继承层次中如何设计 Builder?为什么容易写错?
简化版
继承层次中的 Builder 难点在于父类字段和子类字段都要参与构建,同时链式调用要返回正确的子类 Builder 类型。常见方案是使用自限定泛型让父类 Builder 方法返回 T extends Builder<T>,或在简单场景中避免继承,改用组合。
详细版
普通 Builder 在单个类里很好写,但遇到继承就会出现两个问题:
- 父类字段要复用,不想每个子类 Builder 重复写;
- 父类 Builder 方法如果返回父类类型,调用完父类方法后就拿不到子类方法。
比如 Vehicle 有 brand、maxSpeed,Car 还有 seatCount。如果 brand() 返回 Vehicle.Builder,那么 .brand("BMW").seatCount(5) 可能编译失败,因为返回类型已经退回父类 Builder。
常见解决方式是父类 Builder 使用泛型:
abstract static class Builder<T extends Builder<T>> {
String brand;
public T brand(String brand) {
this.brand = brand;
return self();
}
protected abstract T self();
}
子类 Builder 实现 self() 返回自己:
static class Builder extends Vehicle.Builder<Builder> {
int seatCount;
public Builder seatCount(int seatCount) {
this.seatCount = seatCount;
return this;
}
protected Builder self() {
return this;
}
}
面试里要注意补充:继承 Builder 代码复杂度较高,适合稳定继承层次;如果只是为了复用几个字段,组合、嵌套配置对象或静态工厂可能更清晰。
完整版教学
一、为什么继承会破坏普通链式 Builder
链式 Builder 的基础是每个方法都返回当前 Builder。单类场景里写 return this 很自然,但父类方法的返回类型通常是父类 Builder。问题出在静态类型上:编译器只看声明类型,不会因为运行时对象是子类 Builder 就自动开放子类方法。
Car car = Car.builder()
.brand("BMW")
.seatCount(5)
.build();
如果 brand() 声明返回 Vehicle.Builder,那么 .brand("BMW") 后面的静态类型就是 Vehicle.Builder,它没有 seatCount()。这不是链式调用语法问题,而是继承和返回类型协变没有设计好。
易错点:继承 Builder 的核心问题不是字段复制,而是“父类链式方法返回后还能不能继续调用子类方法”。
二、自限定泛型的基本思路
常见写法是让父类 Builder 接收一个类型参数 T,这个 T 表示“具体子类 Builder 类型”。父类方法返回 T,并通过 self() 把 this 转成正确类型。
abstract class Vehicle {
protected final String brand;
protected final int maxSpeed;
protected Vehicle(Builder<?> builder) {
this.brand = builder.brand;
this.maxSpeed = builder.maxSpeed;
}
abstract static class Builder<T extends Builder<T>> {
private String brand;
private int maxSpeed = 120;
public T brand(String brand) {
this.brand = brand;
return self();
}
public T maxSpeed(int maxSpeed) {
this.maxSpeed = maxSpeed;
return self();
}
protected abstract T self();
}
}
T extends Builder<T> 看起来绕,本质是在告诉编译器:父类 Builder 方法返回的是“某个具体 Builder 自己”。这类写法也叫 self type 或 CRTP 风格。
三、子类 Builder 如何接上父类字段
子类 Builder 继承父类 Builder,并把自己的类型作为泛型参数传进去。这样父类的 brand()、maxSpeed() 返回的就是 Car.Builder,后面可以继续调用 seatCount()。
public class Car extends Vehicle {
private final int seatCount;
private Car(Builder builder) {
super(builder);
this.seatCount = builder.seatCount;
}
public static Builder builder() {
return new Builder();
}
public static class Builder extends Vehicle.Builder<Builder> {
private int seatCount = 5;
public Builder seatCount(int seatCount) {
this.seatCount = seatCount;
return this;
}
public Car build() {
if (seatCount <= 0) {
throw new IllegalArgumentException("seatCount must be positive");
}
return new Car(this);
}
protected Builder self() {
return this;
}
}
}
此时调用链可以保持自然:
Car car = Car.builder()
.brand("BMW")
.maxSpeed(240)
.seatCount(5)
.build();
这段代码里,父类字段由父类 Builder 管理,子类字段由子类 Builder 管理,最终构造时子类先调用 super(builder) 交给父类复制字段,再复制自己的字段。
四、用数字看继承 Builder 的复杂度
假设父类有 4 个字段,3 个子类各有 5 个字段。如果不用继承 Builder,每个子类 Builder 都要重复写父类 4 个字段的设置方法,总共重复 4 * 3 = 12 个方法。使用父类 Builder 后,这 4 个方法只写一次。
不复用:
CarBuilder: 4 个父字段方法 + 5 个子字段方法
TruckBuilder: 4 个父字段方法 + 5 个子字段方法
BikeBuilder: 4 个父字段方法 + 5 个子字段方法
父字段方法重复 12 次
泛型父 Builder:
Vehicle.Builder: 4 个父字段方法
每个子 Builder: 5 个子字段方法 + self() + build()
但复杂度也会上升:读者要理解泛型边界、self()、父子构造器协作。如果继承层次只有 1 个子类,或者父字段只有 1 到 2 个,可能没必要引入这套结构。
五、继承 Builder 中校验应该放在哪里
父类字段的通用校验放父类 Builder 或父类构造器兜底,子类字段的校验放子类 build()。如果校验涉及父子字段组合,例如 Vehicle.maxSpeed > 200 时 Car.seatCount 不能超过 2,就要在子类 build() 中统一判断,因为只有子类 Builder 同时知道父类字段和子类字段。
| 校验内容 | 推荐位置 | 原因 |
|---|---|---|
| brand 非空 | 父类 Builder 或父类构造器 | 所有子类共享 |
| maxSpeed 范围 | 父类 Builder 或父类构造器 | 通用规则 |
| seatCount 范围 | 子类 Builder | 子类字段 |
| maxSpeed 与 seatCount 组合 | 子类 build() | 需要同时看父子字段 |
父类构造器最好保留关键不变量兜底,防止未来新增子类 Builder 时漏掉通用校验。Builder 负责友好的构建 API,Product 构造器负责守住对象不变量。
六、Lombok @SuperBuilder 与手写 Builder 的取舍
Lombok 提供 @SuperBuilder 支持继承层次 Builder,能减少大量模板代码。它适合字段多、继承结构稳定、团队熟悉 Lombok 的项目。代价是生成代码不直观,排查构造行为时要看反编译或 IDE 展开结果。
面试中可以这样评价:@Builder 适合单类,继承场景要用 @SuperBuilder 才能处理父子字段;但它仍不能替代业务校验。尤其是默认值、集合防御性拷贝、构造器可见性、框架反序列化兼容,都需要人工确认。
如果项目对编译插件依赖敏感,或者对象构造规则很复杂,手写 Builder 更透明。自动生成适合消除重复,但不能把设计责任也交给注解。
七、什么时候应该避免继承 Builder
继承 Builder 的存在前提是:产品类真的存在稳定的继承层次。如果只是为了复用字段,比如很多请求对象都有 requestId、tenantId、timeoutMs,直接抽父类未必合适。组合一个 RequestMeta 可能更简单。
继承方案:
BaseRequest <- CreateOrderRequest
BaseRequest <- PayOrderRequest
组合方案:
CreateOrderRequest has RequestMeta
PayOrderRequest has RequestMeta
组合的优势是字段复用不绑定类型继承,演进更灵活。设计模式面试里,能主动说出“继承 Builder 很强,但组合经常更稳”,通常比只背泛型写法更有工程判断。
八、常见误区与追问
- 误区:父类 Builder 方法直接返回父类 Builder 就够了。 这样链式调用会丢失子类方法,调用体验被破坏。
- 误区:继承结构里每个子类 Builder 复制父类字段最简单。 短期简单,长期会让默认值和校验规则分散重复。
- 误区:用了 Lombok @SuperBuilder 就不用理解原理。 注解只是生成代码,父子字段、默认值和校验边界仍要设计。
- 追问:为什么需要 self() 方法? 父类无法直接知道具体子类 Builder 类型,
self()用来把链式返回交给子类确定。 - 追问:父类校验放父类还是子类? 通用不变量放父类,子类特有和父子组合规则放子类
build()。 - 追问:多层继承还能继续套吗? 可以,但泛型会更复杂,超过 2 到 3 层时要重新评估继承本身是否合理。
- 追问:组合和继承怎么选? 稳定的 is-a 关系用继承;只是字段复用或能力复用时优先组合。
九、加强记忆
继承 Builder 记住三个关键词:父类字段复用、返回类型不丢、校验边界清楚。泛型 T extends Builder<T> 是为了解决链式返回类型,self() 是为了让父类方法返回具体子类 Builder;如果只是复用几个字段,组合往往比继承更清爽。