重构实战:组织数据(二)——状态/策略模式取代类型码、字段取代子类、查询取代派生变量

这一章我们继续聊数据组织。上一讲我们处理了「直接访问字段」的问题,这次要面对的是更隐蔽的坏味道——类型码、子类滥用、以及派生变量。

说实话,这三个问题在项目中太常见了。我见过一个系统,光订单状态就用 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);
}

核心原则:数据只在一个地方定义。派生数据用计算得到,不要存储。

你可能会担心性能问题。确实,如果每次查询都重新计算,可能比直接读字段慢。但我的建议是:先保证正确性,再考虑优化。如果确实有性能瓶颈,可以用缓存(比如计算结果缓存到私有字段,并在数据变化时清空缓存)。

我记得有个项目,财务模块里到处都是派生变量。有一次改了单价字段,但忘了更新总价,导致报表数据全错了。改成查询方法后,这类问题再也没出现过。

知识体系图

下面这张图总结了本章的三个重构手法及其适用场景:

组织数据(二)重构手法 状态/策略模式 取代类型码 字段取代子类 简化过度设计 查询取代派生变量 消除数据不一致 适用场景 • 状态转换频繁 • 行为随类型变化 • switch-case 泛滥 适用场景 • 子类只有参数差异 • 子类数量少 • 行为无本质区别 适用场景 • 数据可从其他字段计算 • 数据一致性要求高 • 更新逻辑分散 核心思想 把「分类」和「行为」解耦,让数据只在一个地方定义 派生数据用计算得到,不要存储

避坑指南

最后分享几个我踩过的坑:

  • 不要盲目用状态模式。如果类型码只有 2-3 个值,且逻辑简单,用 switch-case 反而更清晰。我见过有人为「启用/禁用」两个状态搞了状态模式,结果代码量翻了三倍。
  • 字段取代子类后,注意构造函数的封装。最好提供静态工厂方法(如 CreateVip()),而不是直接暴露构造函数参数。否则调用方还得知道折扣率是多少。
  • 查询取代派生变量时,小心循环引用。如果 A 依赖 B,B 又依赖 A,查询方法会死循环。这种情况说明你的数据模型有问题,需要重新设计。

我的建议:重构时先小步快跑。比如先改一个类型码,测试通过后再改下一个。不要一次性把所有类型码都换成状态模式,那样风险太大。

好了,这一章就到这里。记住这三个手法:状态/策略模式处理类型码、字段简化子类、查询消除派生变量。它们能帮你把数据组织得更干净、更安全。


公众号:蓝海资料掘金营,微信deep3321