51、代码坏味道:临时字段 — 实战提炼类与引入Null对象
说实话,临时字段这个坏味道,我见过太多次了。
它不像长方法或者过大的类那么显眼,但破坏力一点都不小。你想想看,一个类里有些字段只在特定场景下才有值,其他时候都是 null 或者某个默认值。这种字段就像「幽灵」,平时不出现,一到某个分支逻辑里就冒出来。
我接手过一个老项目,里面有个 OrderProcessor 类,十几个字段里有一半都是这种临时字段。每次改代码都得小心翼翼,生怕哪个 null 指针炸了。后来我花了两个迭代彻底重构了它——核心手法就是今天要讲的:提炼类 和 引入 Null 对象。
什么是临时字段?
说白了,就是一个字段只在某个方法的某个分支里被赋值,其他时候都是 null 或者无意义的值。
举个例子:
public class Invoice {
private String customerName;
private double amount;
private String discountCode; // 只在有折扣时使用
private double discountPercent; // 只在有折扣时使用
private String specialNote; // 只在特殊备注时使用
public double calculateTotal() {
double total = amount;
if (discountCode != null) {
total = total * (1 - discountPercent / 100);
}
return total;
}
public String generateSummary() {
StringBuilder sb = new StringBuilder();
sb.append("客户: ").append(customerName);
if (specialNote != null) {
sb.append(" [备注: ").append(specialNote).append("]");
}
return sb.toString();
}
}
你看,discountCode、discountPercent、specialNote 这三个字段,大部分时候是 null。它们的存在让类的职责变得模糊——这个 Invoice 到底是在做账单计算,还是在处理折扣和备注?
为什么临时字段是坏味道?
- 可读性差:新接手的人得猜「这个字段什么时候有值?什么时候是 null?」
- 容易出 bug:忘记判空就 NPE,判空太多又让代码臃肿。
- 违反单一职责:一个类承担了多个不相关的职责。
- 测试困难:为了测试某个分支,你得构造一堆 null 字段。
我记得有一次线上事故,就是因为一个临时字段在某个罕见路径下没被初始化,结果生产环境炸了。从那以后,我对临时字段就特别敏感。
重构手法一:提炼类(Extract Class)
把那些临时字段和它们相关的方法,一起提炼成一个独立的类。
还是上面那个 Invoice 的例子。我们可以把折扣相关的字段和方法提炼出来:
public class Discount {
private String code;
private double percent;
public Discount(String code, double percent) {
this.code = code;
this.percent = percent;
}
public double applyTo(double amount) {
return amount * (1 - percent / 100);
}
public boolean isActive() {
return code != null;
}
}
然后 Invoice 里就清爽多了:
public class Invoice {
private String customerName;
private double amount;
private Discount discount; // 可能为 null
public double calculateTotal() {
double total = amount;
if (discount != null && discount.isActive()) {
total = discount.applyTo(total);
}
return total;
}
}
嗯,这里要注意:提炼之后,Invoice 里仍然有一个可能为 null 的 discount 字段。这就是我们下一步要解决的问题。
重构手法二:引入 Null 对象(Introduce Null Object)
与其到处判空,不如创建一个「什么都不做」的 Null 对象。这样调用方就不需要关心是不是 null 了。
我们给 Discount 创建一个 Null 对象:
public class NullDiscount extends Discount {
public NullDiscount() {
super(null, 0.0);
}
@Override
public double applyTo(double amount) {
return amount; // 不打折
}
@Override
public boolean isActive() {
return false;
}
}
然后在 Invoice 中,默认使用 Null 对象:
public class Invoice {
private String customerName;
private double amount;
private Discount discount = new NullDiscount(); // 默认就是 Null 对象
public void setDiscount(Discount discount) {
this.discount = discount != null ? discount : new NullDiscount();
}
public double calculateTotal() {
return discount.applyTo(amount); // 不再需要判空
}
}
完整重构流程
我一般按这个步骤来:
- 识别临时字段:找出那些大部分时间为 null 的字段。
- 分组:把逻辑上相关的临时字段分成一组。
- 提炼类:为每组创建一个新类,把字段和方法搬过去。
- 引入 Null 对象:为每个新类创建一个 Null 对象子类。
- 替换判空逻辑:把原来的 if (xxx != null) 替换为多态调用。
- 测试:确保行为不变。
知识体系图
下面这张图展示了临时字段重构的核心逻辑:
实战中的避坑指南
- 不要过度提炼:如果临时字段只有一两个,且逻辑简单,直接内联处理可能更好。我见过有人把两个字段就提炼成一个类,反而增加了复杂度。
- Null 对象要完整:Null 对象必须实现所有抽象方法,并且返回「无操作」的结果。如果漏掉一个方法,运行时还是会 NPE。
- 注意性能:如果 Null 对象被频繁创建,可以考虑单例模式。不过大多数场景下,GC 开销可以忽略。
什么时候该用,什么时候不该用?
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 临时字段有 3 个以上,且逻辑相关 | 提炼类 + Null 对象 | 职责清晰,消除判空 |
| 临时字段只有 1 个,且逻辑简单 | 直接内联,或简单判空 | 过度设计反而不好 |
| 临时字段出现在多个类中 | 考虑提取公共基类或策略模式 | 消除重复 |
| 临时字段是性能热点 | 谨慎使用 Null 对象,考虑缓存 | 避免频繁分配对象 |
好了,关于临时字段的重构就聊到这里。核心就两招:提炼类 把散落的字段收拢,引入 Null 对象 把判空逻辑消灭掉。这两招组合起来,代码质量能上一个台阶。