搬移字段
搬移字段(Move Field)把一个字段从当前的类搬到总和它一起用、一起变的那个类。 它解决的痛是:字段放错了地方,每次改它都要连带改另一个类,传参时也总要把两个对象一起传。
一个 SaaS 系统里,客户的折扣率最早存在 Customer 上。后来公司改成按合同定折扣:同一个客户续签新合同,折扣率可能不同。合同类 CustomerContract 早就有了,存着签约日期。
"""客户折扣:重构前。"""
class CustomerContract: def __init__(self, start_date: str) -> None: self.start_date = start_date
class Customer: def __init__(self, name: str, discount_rate: float, contract: CustomerContract) -> None: self.name = name self.discount_rate = discount_rate self.contract = contract
def apply_discount(self, amount: float) -> float: return round(amount * (1 - self.discount_rate), 2)
if __name__ == "__main__": c = Customer("多练科技", 0.1, CustomerContract("2026-01-01")) print(c.apply_discount(1000), c.contract.start_date)
# 输出:# 900.0 2026-01-01// 客户折扣:重构前。class CustomerContract { constructor(readonly startDate: string) {}}
class Customer { constructor( readonly name: string, public discountRate: number, public contract: CustomerContract, ) {}
applyDiscount(amount: number): number { return Math.round(amount * (1 - this.discountRate) * 100) / 100; }}
const c = new Customer("多练科技", 0.1, new CustomerContract("2026-01-01"));console.log(c.applyDiscount(1000), c.contract.startDate);
// 输出:// 900 2026-01-01如果现在要加一个需求:客户续签合同时折扣率跟着新合同变,旧合同期间产生的账单仍按旧折扣算。折扣率在 Customer 上只有一份,换合同时得记得同时改 customer.discount_rate;要按旧合同重算历史账单,又找不到旧合同的折扣率在哪。折扣率和签约日期总是因为同一件事(签合同)而变,却分住在两个类里。
这是霰弹式修改的一个小例子:一个业务动作要同时改两个对象。
"""客户折扣:重构后。"""
class CustomerContract: def __init__(self, start_date: str, discount_rate: float = 0.0) -> None: self.start_date = start_date self.discount_rate = discount_rate
class Customer: def __init__(self, name: str, discount_rate: float, contract: CustomerContract) -> None: self.name = name self.contract = contract self.discount_rate = discount_rate
@property def discount_rate(self) -> float: return self.contract.discount_rate
@discount_rate.setter def discount_rate(self, value: float) -> None: self.contract.discount_rate = value
def apply_discount(self, amount: float) -> float: return round(amount * (1 - self.discount_rate), 2)
if __name__ == "__main__": c = Customer("多练科技", 0.1, CustomerContract("2026-01-01")) print(c.apply_discount(1000), c.contract.start_date)
# 输出:# 900.0 2026-01-01// 客户折扣:重构后。class CustomerContract { constructor( readonly startDate: string, public discountRate = 0, ) {}}
class Customer { constructor( readonly name: string, discountRate: number, public contract: CustomerContract, ) { this.discountRate = discountRate; }
get discountRate(): number { return this.contract.discountRate; }
set discountRate(value: number) { this.contract.discountRate = value; }
applyDiscount(amount: number): number { return Math.round(amount * (1 - this.discountRate) * 100) / 100; }}
const c = new Customer("多练科技", 0.1, new CustomerContract("2026-01-01"));console.log(c.applyDiscount(1000), c.contract.startDate);
// 输出:// 900 2026-01-01续签合同现在只是给客户换一个新的 CustomerContract,折扣率随合同一起换;旧合同对象里保留着当时的折扣率,重算历史账单时拿得到。Customer.discount_rate 变成一个读写都转发到合同的属性,构造函数的签名也没变,所以 apply_discount 和外部调用方都不用改。
怎么一步步做
Section titled “怎么一步步做”- 确认源字段已经被封装(第 8 章、第 13 章),所有读写都经过访问方法。没封装就先封装,否则下一步要改的地方会很多。
- 在目标类里建字段和访问方法。跑测试。
- 确认源对象能拿到目标对象。本例里
Customer本来就持有contract。 - 让源类的访问方法改为读写目标对象的字段。跑测试。
- 删掉源类里的字段。跑测试。
- 看看调用方是否该直接访问目标对象,如果是,再用移除中间人(第 18 章)去掉转发。
本章的写法里,Customer 的构造函数直接创建了合同对象。这只是为了让例子短。实际系统里合同通常是单独创建、单独存储的,客户在签约时被关联上一份合同。搬移字段之后,“客户的折扣率”就变成了“客户当前合同的折扣率”,这正是业务本来的样子:折扣是合同条款,不是客户的固有属性。好的搬移会让代码说的话更接近业务人员说的话。
定义与适用场景
Section titled “定义与适用场景”搬移字段:把字段从一个类移到另一个类,并调整所有访问它的代码。好的数据结构让代码自然变简单,差的数据结构逼着每段代码绕路,这个手法就是在纠正数据结构。
信号:每次传一个对象,都要同时传另一个对象里的某个字段;改一条记录的某个字段时,总要连带改另一条记录;一个字段在多个类里都有,需要同步更新。
字段被很多地方直接访问又没有封装时,搬移的代价很高。这也是为什么第 8、13 章建议尽早封装:封装过的字段搬起来只改访问方法。
要搬的字段如果被很多对象共享,比如同一份合同被好几个客户引用,搬到合同上之后,改一个客户的折扣就会影响所有共享这份合同的客户。搬之前先想清楚:这个字段在新位置上是每个对象一份,还是大家共用一份。前者要在搬的时候为每个对象建自己的目标对象,后者要确认共享正是业务想要的效果,第 33 章会专门讲这两种关系。
什么时候别用
Section titled “什么时候别用”一个字段两边都用得差不多,搬过去只是让另一边绕路,别动。搬移字段在共享的数据库表或接口格式上要小心:代码里的类可以随便调整,存储结构和对外接口改起来要迁移数据、通知调用方,那是另一件事。
搬移字段之后,原来读写这个字段的方法往往也该跟着搬(第 21 章):apply_discount 只用到折扣率,它也许该成为合同的方法。字段和用它的函数通常一起搬,顺序是先搬字段,再搬函数。
- 字段总和另一个类的数据一起变、一起传,就搬过去。
- 先封装,再在目标类建字段,再改访问方法,最后删源字段。
- 源类可以留一个只读属性转发,让调用方不受影响。
- 字段越早封装,日后搬起来越便宜。