Contents

三次法则实战:从重复到抽象的重构决策

为什么"不要重复"不是铁律

很多开发者把 DRY(Don’t Repeat Yourself)奉为圭臬,一看到重复代码就急着抽取公共函数。结果呢?公共函数越来越臃肿,参数列表越来越长,最终变成一个谁也不敢动的"上帝函数"。

The Pragmatic Programmer 中提出了一个更务实的原则——三次法则(Rule of Three)

当同一段逻辑被复制第三次时,才值得提取抽象。前两次的重复是可接受的,甚至是必要的。

这不是在为懒惰找借口,而是一种经过验证的工程智慧。下面用一个电商订单处理的真实案例来说明。

第一次:重复是合理的

第一个需求是处理普通订单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func ProcessOrder(order *Order) error {
    // 校验
    if order.Amount <= 0 {
        return errors.New("invalid amount")
    }
    if order.UserID == "" {
        return errors.New("missing user")
    }

    // 计算
    total := order.Amount * (1 - order.Discount)

    // 持久化
    db.Save(&OrderRecord{
        OrderID: order.ID,
        Total:   total,
        Status:  "processed",
    })
    return nil
}

现在来了第二个需求:处理会员订单。逻辑很像,但我们还不知道未来会有多少变体。此时最好的做法就是直接复制一份代码,改改细节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func ProcessMemberOrder(order *Order) error {
    // 校验(和上面一模一样)
    if order.Amount <= 0 {
        return errors.New("invalid amount")
    }
    if order.UserID == "" {
        return errors.New("missing user")
    }

    // 计算(会员额外9折)
    total := order.Amount * (1 - order.Discount) * 0.9

    // 持久化
    db.Save(&OrderRecord{
        OrderID: order.ID,
        Total:   total,
        Status:  "member_processed",
    })
    return nil
}

这时候你有两个选择:提取公共函数,或者保持重复。三次法则的建议是——保持重复

为什么?因为此时只有两个数据点,还看不出真正的共性在哪里。过早抽象往往提取出错误的抽象边界。

第二次:开始看到模式

第三个需求来了:处理企业订单。现在有了三个相似函数,模式开始浮现——校验逻辑完全一致,计算逻辑各有不同,持久化结构相同但字段值不同

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func ProcessEnterpriseOrder(order *Order) error {
    // 校验(第三次复制同样的代码)
    if order.Amount <= 0 {
        return errors.New("invalid amount")
    }
    if order.UserID == "" {
        return errors.New("missing user")
    }

    // 计算(企业阶梯折扣)
    total := calculateEnterpriseTotal(order.Amount, order.Discount)

    // 持久化
    db.Save(&OrderRecord{
        OrderID: order.ID,
        Total:   total,
        Status:  "enterprise_processed",
    })
    return nil
}

三次法则触发——现在该提取抽象了。

第三次:提取正确的抽象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 统一的处理流程
func ProcessOrderFlow(order *Order, calcFn func(*Order) (float64, string)) error {
    if err := validateOrder(order); err != nil {
        return err
    }

    total, statusTag := calcFn(order)

    return db.Save(&OrderRecord{
        OrderID: order.ID,
        Total:   total,
        Status:  statusTag + "_processed",
    })
}

// 校验逻辑(只写一次)
func validateOrder(order *Order) error {
    if order.Amount <= 0 {
        return errors.New("invalid amount")
    }
    if order.UserID == "" {
        return errors.New("missing user")
    }
    return nil
}

// 三种计算策略
func normalCalc(o *Order) (float64, string) {
    return o.Amount * (1 - o.Discount), "normal"
}

func memberCalc(o *Order) (float64, string) {
    return o.Amount * (1 - o.Discount) * 0.9, "member"
}

func enterpriseCalc(o *Order) (float64, string) {
    return calculateEnterpriseTotal(o.Amount, o.Discount), "enterprise"
}

// 调用方保持简洁
func ProcessOrder(order *Order) error {
    return ProcessOrderFlow(order, normalCalc)
}
func ProcessMemberOrder(order *Order) error {
    return ProcessOrderFlow(order, memberCalc)
}
func ProcessEnterpriseOrder(order *Order) error {
    return ProcessOrderFlow(order, enterpriseCalc)
}

注意几个关键决策:

  1. 只提取真正重复的部分:校验和持久化被提取,计算逻辑保持在外部
  2. 用函数参数而非继承:Go 没有继承,函数式策略更灵活更轻量
  3. 状态标记由计算函数返回:不硬编码,让策略自己决定 tag

过度抽象的反面教材

三次法则的反面是零容忍重复——也就是过早抽象。来看一个真实的反面案例:

1
2
3
4
5
6
7
// 过早抽象:第一次写就做了全面通用化
func ProcessOrderGeneric(order *Order, validator OrderValidator,
    calculator OrderCalculator, persister OrderPersister,
    notifier OrderNotifier, logger OrderLogger) error {
    // 5个接口参数,但当前只有一个调用方
    // ...
}

这个设计在只有一个调用方时引入了5个接口。每次需求变化,你都得同时修改接口和实现。抽象的成本远大于重复的成本

更糟糕的是,当真正的第三个变体出现时,你会发现之前设计的接口假设可能是错误的——比如 validator 突然需要访问 DB,但接口签名没传 context。于是要么改接口(影响所有实现),要么在接口外打补丁(破坏抽象的完整性)。

什么时候打破三次法则

三次法则不是教条,有些场景应该在第一次就抽象:

  1. 基础设施代码:数据库连接、日志、配置——从一开始就该统一
  2. 领域核心逻辑:金额计算、权限校验——重复一次就可能产生数据不一致
  3. 安全相关代码:加密、认证——重复意味着遗漏漏洞的风险
1
2
3
4
5
// 这类代码第一次就该统一,不存在"容忍两次重复"的余地
func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
    return string(bytes), err
}

实践建议

  • 容忍短期重复:复制粘贴不可耻,可耻的是复制了五次还不提取
  • 标记重复次数:在代码注释中写上 // DUP: 2nd,提醒自己和团队第三次到来时触发重构
  • 需求驱动而非设计驱动:你不是预言家,等模式浮现再出手
  • 提取后立即验证:跑一遍全部测试,确保三个调用方行为完全不变

结语

三次法则的本质是承认一个事实:我们没有水晶球。在只有一两个实例时,我们无法预知正确的抽象形状。容忍有限的重复,等到模式真正浮现时再出手,往往比一开始就过度设计更务实。

好的抽象来自观察,而非预测。