重构实战:组织数据(二)——状态/策略模式取代类型码、字段取代子类、查询取代派生变量
这一章我们继续聊数据组织。上一讲我们处理了「直接访问字段」的问题,这次要面对的是更隐蔽的坏味道——类型码、子类滥用、以及派生变量。
说实话,这三个问题在项目中太常见了。我见过一个系统,光订单状态就用 int 表示,然后到处写 switch-case。还有个项目,为了区分几种用户角色,硬生生搞出十几个子类。嗯,这些做法在初期看起来挺「面向对象」的,但一旦业务复杂起来,维护成本直线上升。
1. 以状态/策略模式取代类型码
先说说类型码。类型码就是用数字或枚举来表示某种分类,比如订单状态:0=待支付,1=已支付,2=已发货。然后代码里到处都是:
if (order.status == 0) {
// 待支付逻辑
} else if (order.status == 1) {
// 已支付逻辑
}
这种写法有什么问题?你想想看,每次新增一个状态,就要改所有条件判断的地方。而且逻辑分散在各处,根本没法复用。
解决方案:用状态模式或策略模式来封装这些行为。
我个人习惯先判断类型码的变化频率。如果状态之间会相互转换(比如待支付→已支付→已发货),用状态模式。如果只是根据类型执行不同算法(比如不同支付方式的手续费计算),用策略模式。
来看个例子。假设我们有订单状态:
// 重构前
public class Order {
public int Status { get; set; } // 0=新建, 1=已支付, 2=已发货
public void Process() {
if (Status == 0) { /* 新建处理 */ }
else if (Status == 1) { /* 支付处理 */ }
else if (Status == 2) { /* 发货处理 */ }
}
}
重构后,用状态模式:
// 重构后
public abstract class OrderState {
public abstract void Process(Order order);
}
public class NewOrderState : OrderState {
public override void Process(Order order) {
// 新建处理逻辑
order.State = new PaidOrderState(); // 状态转换
}
}
public class PaidOrderState : OrderState {
public override void Process(Order order) {
// 支付处理逻辑
}
}
public class Order {
public OrderState State { get; set; }
public void Process() => State.Process(this);
}
核心要点:类型码的本质是把「分类」和「行为」混在一起。状态/策略模式把它们拆开,每个类型有自己的类,行为内聚在类内部。
我的经验:我在一个电商项目中重构过订单模块。原来有 8 个状态,switch-case 写了 300 多行。改成状态模式后,每个状态类平均 30 行,新增状态只需要加一个新类,完全不影响现有代码。
2. 以字段取代子类
这个重构手法可能有点反直觉。我们通常说「用子类取代类型码」,但这里反过来了——当子类只用来区分少量行为时,用字段取代子类反而更简单。
什么时候该用?我总结了两条:
- 子类中只有少量方法被重写(比如 1-2 个)
- 子类之间没有行为上的本质差异,只是参数不同
举个例子。假设我们有用户角色:
// 重构前
public abstract class User {
public abstract double GetDiscount();
}
public class VipUser : User {
public override double GetDiscount() => 0.8;
}
public class NormalUser : User {
public override double GetDiscount() => 1.0;
}
这里两个子类只差一个折扣率。用字段取代子类:
// 重构后
public class User {
private readonly double _discount;
public User(double discount) {
_discount = discount;
}
public double GetDiscount() => _discount;
public static User CreateVip() => new User(0.8);
public static User CreateNormal() => new User(1.0);
}
注意:如果子类之间有行为上的本质差异(比如 VipUser 有专属功能,NormalUser 没有),那就不要用字段取代子类。这个手法只适用于「子类只是参数不同」的场景。
我曾经在一个项目中看到有人为「男用户」和「女用户」各建了一个子类,里面只重写了性别字段的获取方法。嗯,这种就是典型的过度设计。用字段取代子类后,代码量减少了一半。
3. 以查询取代派生变量
派生变量是指那些可以从其他数据计算出来的变量。比如:
public class Order {
public List<Item> Items { get; set; }
public double TotalPrice { get; set; } // 派生变量
public double Discount { get; set; }
public double FinalPrice { get; set; } // 另一个派生变量
}
问题在哪?每次 Items 变化,都要手动更新 TotalPrice。每次 TotalPrice 或 Discount 变化,又要更新 FinalPrice。只要漏掉一处,数据就不一致了。
解决方案:用查询方法取代这些派生变量。
// 重构后
public class Order {
public List<Item> Items { get; set; }
public double Discount { get; set; }
public double GetTotalPrice() => Items.Sum(i => i.Price * i.Quantity);
public double GetFinalPrice() => GetTotalPrice() * (1 - Discount);
}
核心原则:数据只在一个地方定义。派生数据用计算得到,不要存储。
你可能会担心性能问题。确实,如果每次查询都重新计算,可能比直接读字段慢。但我的建议是:先保证正确性,再考虑优化。如果确实有性能瓶颈,可以用缓存(比如计算结果缓存到私有字段,并在数据变化时清空缓存)。
我记得有个项目,财务模块里到处都是派生变量。有一次改了单价字段,但忘了更新总价,导致报表数据全错了。改成查询方法后,这类问题再也没出现过。
知识体系图
下面这张图总结了本章的三个重构手法及其适用场景:
避坑指南
最后分享几个我踩过的坑:
- 不要盲目用状态模式。如果类型码只有 2-3 个值,且逻辑简单,用 switch-case 反而更清晰。我见过有人为「启用/禁用」两个状态搞了状态模式,结果代码量翻了三倍。
- 字段取代子类后,注意构造函数的封装。最好提供静态工厂方法(如 CreateVip()),而不是直接暴露构造函数参数。否则调用方还得知道折扣率是多少。
- 查询取代派生变量时,小心循环引用。如果 A 依赖 B,B 又依赖 A,查询方法会死循环。这种情况说明你的数据模型有问题,需要重新设计。
我的建议:重构时先小步快跑。比如先改一个类型码,测试通过后再改下一个。不要一次性把所有类型码都换成状态模式,那样风险太大。
好了,这一章就到这里。记住这三个手法:状态/策略模式处理类型码、字段简化子类、查询消除派生变量。它们能帮你把数据组织得更干净、更安全。
公众号:蓝海资料掘金营,微信deep3321