跳转到内容

搬移函数

搬移函数(Move Function)把一个函数从它现在所在的类或模块,搬到它最常使用的数据所在的地方。 它解决的痛是:一个函数大部分时间在读别人的数据,别人的结构一变它就得跟着改,而真正拥有这些数据的类却对这段逻辑一无所知。

小钱维护银行系统的账户模块。每个账户有一个账户类型(普通、尊享),透支利息的算法随账户类型而变:尊享账户前 7 天固定收 10 元,之后每天加收 0.85 元;普通账户每天 1.75 元。这个算法写在 Account 类里。

"""账户透支费:重构前。"""
class AccountType:
def __init__(self, name: str, is_premium: bool) -> None:
self.name = name
self.is_premium = is_premium
class Account:
def __init__(self, account_type: AccountType, days_overdrawn: int) -> None:
self.type = account_type
self.days_overdrawn = days_overdrawn
def overdraft_charge(self) -> float:
if self.type.is_premium:
base = 10.0
if self.days_overdrawn <= 7:
return base
return base + (self.days_overdrawn - 7) * 0.85
return self.days_overdrawn * 1.75
def bank_charge(self) -> float:
result = 4.5
if self.days_overdrawn > 0:
result += self.overdraft_charge()
return result
PREMIUM = AccountType("尊享", True)
NORMAL = AccountType("普通", False)
if __name__ == "__main__":
for account in (Account(PREMIUM, 3), Account(PREMIUM, 10), Account(NORMAL, 4)):
print(account.type.name, account.days_overdrawn, account.bank_charge())
# 输出:
# 尊享 3 14.5
# 尊享 10 17.05
# 普通 4 11.5
// 账户透支费:重构前。
export class AccountType {
constructor(
readonly name: string,
readonly isPremium: boolean,
) {}
}
export class Account {
constructor(
readonly type: AccountType,
readonly daysOverdrawn: number,
) {}
overdraftCharge(): number {
if (this.type.isPremium) {
const base = 10;
if (this.daysOverdrawn <= 7) return base;
return base + (this.daysOverdrawn - 7) * 0.85;
}
return this.daysOverdrawn * 1.75;
}
bankCharge(): number {
let result = 4.5;
if (this.daysOverdrawn > 0) result += this.overdraftCharge();
return result;
}
}
export const PREMIUM = new AccountType("尊享", true);
export const NORMAL = new AccountType("普通", false);
for (const account of [new Account(PREMIUM, 3), new Account(PREMIUM, 10), new Account(NORMAL, 4)]) {
console.log(account.type.name, account.daysOverdrawn, Math.round(account.bankCharge() * 100) / 100);
}
// 输出:
// 尊享 3 14.5
// 尊享 10 17.05
// 普通 4 11.5

测试只看 bank_charge:

import unittest
from target import NORMAL, PREMIUM, Account
class AccountTest(unittest.TestCase):
def test_no_overdraft_pays_base_fee(self):
self.assertEqual(Account(NORMAL, 0).bank_charge(), 4.5)
def test_premium_first_week_flat(self):
self.assertEqual(Account(PREMIUM, 7).bank_charge(), 14.5)
def test_premium_after_first_week(self):
self.assertAlmostEqual(Account(PREMIUM, 10).bank_charge(), 17.05)
def test_normal_per_day(self):
self.assertEqual(Account(NORMAL, 4).bank_charge(), 11.5)
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { Account, NORMAL, PREMIUM } from "./target";
const near = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-9, `${a} ≠ ${b}`);
test("没透支只收基础费", () => {
assert.equal(new Account(NORMAL, 0).bankCharge(), 4.5);
});
test("尊享账户第一周固定", () => {
assert.equal(new Account(PREMIUM, 7).bankCharge(), 14.5);
});
test("尊享账户一周之后按天加收", () => {
near(new Account(PREMIUM, 10).bankCharge(), 17.05);
});
test("普通账户按天收", () => {
assert.equal(new Account(NORMAL, 4).bankCharge(), 11.5);
});

如果现在要加一个需求:新增“学生账户”类型,透支前 30 天免费,之后每天 0.5 元。改动在 Account.overdraft_charge 里,得再加一个分支,判断条件是 self.type.name == "学生" 或者给 AccountType 加一个 is_student 字段。再加一种类型又是一个分支。透支规则是账户类型的属性,却写在账户里,每加一种类型都要动 Account。

细看 overdraft_charge,它的整个结构都由账户类型决定:先问 self.type.is_premium,再按类型走完全不同的两套规则。Account 自己贡献的只有透支天数这一个数字。规则属于类型,天数只是输入,所以每多一种类型,改的却是 Account。这是依恋情结:函数住错了地方。而“加一种账户类型要改两个类”,就是霰弹式修改的起点。

搬移前:overdraft_charge 住在 Account 里,却主要读 AccountType 的字段

"""账户透支费:重构后。"""
class AccountType:
def __init__(self, name: str, is_premium: bool) -> None:
self.name = name
self.is_premium = is_premium
def overdraft_charge(self, days_overdrawn: int) -> float:
if self.is_premium:
base = 10.0
if days_overdrawn <= 7:
return base
return base + (days_overdrawn - 7) * 0.85
return days_overdrawn * 1.75
class Account:
def __init__(self, account_type: AccountType, days_overdrawn: int) -> None:
self.type = account_type
self.days_overdrawn = days_overdrawn
def bank_charge(self) -> float:
result = 4.5
if self.days_overdrawn > 0:
result += self.type.overdraft_charge(self.days_overdrawn)
return result
PREMIUM = AccountType("尊享", True)
NORMAL = AccountType("普通", False)
if __name__ == "__main__":
for account in (Account(PREMIUM, 3), Account(PREMIUM, 10), Account(NORMAL, 4)):
print(account.type.name, account.days_overdrawn, account.bank_charge())
# 输出:
# 尊享 3 14.5
# 尊享 10 17.05
# 普通 4 11.5
// 账户透支费:重构后。
export class AccountType {
constructor(
readonly name: string,
readonly isPremium: boolean,
) {}
overdraftCharge(daysOverdrawn: number): number {
if (this.isPremium) {
const base = 10;
if (daysOverdrawn <= 7) return base;
return base + (daysOverdrawn - 7) * 0.85;
}
return daysOverdrawn * 1.75;
}
}
export class Account {
constructor(
readonly type: AccountType,
readonly daysOverdrawn: number,
) {}
bankCharge(): number {
let result = 4.5;
if (this.daysOverdrawn > 0) result += this.type.overdraftCharge(this.daysOverdrawn);
return result;
}
}
export const PREMIUM = new AccountType("尊享", true);
export const NORMAL = new AccountType("普通", false);
for (const account of [new Account(PREMIUM, 3), new Account(PREMIUM, 10), new Account(NORMAL, 4)]) {
console.log(account.type.name, account.daysOverdrawn, Math.round(account.bankCharge() * 100) / 100);
}
// 输出:
// 尊享 3 14.5
// 尊享 10 17.05
// 普通 4 11.5

搬移后:overdraft_charge 成了 AccountType 的方法,Account 只把透支天数传过去

透支规则现在住在 AccountType 里,紧挨着它用的数据。加学生账户只改 AccountType 一个类,Account 不动。overdraft_charge 从 Account 那里需要的只有透支天数,所以它作为参数传进去;没有把整个 Account 传过去,免得 AccountType 反过来依赖 Account。

这一步也为下一步铺了路:等账户类型多了,overdraft_charge 里的 if self.is_premium 可以用以多态取代条件表达式(第 38 章)换成每种类型一个子类,各自实现透支规则。函数先搬到对的类,多态才有地方落脚。

  1. 检查函数用到的所有东西:读了哪些字段、调了哪些方法。考虑它们是不是也该一起搬。如果函数调用了另一个也该搬的函数,通常先搬那个被调用的。
  2. 检查这个函数是否在子类里被重写,或者在父类里有声明。如果有,搬移会影响多态,要先处理继承关系。
  3. 把函数复制到目标类,按新环境调整:原来的 self.type.is_premium 变成 self.is_premium;原来从 self 读的 days_overdrawn 变成参数。
  4. 跑一下静态检查(Python 的 mypy、TS 的 tsc),确认新函数在目标类里能编译通过。
  5. 让源函数变成一个转发:return self.type.overdraft_charge(self.days_overdrawn)。跑测试。
  6. 决定要不要内联源函数。调用方少就用内联函数(第 4 章)把它去掉,让调用方直接调目标类;如果源函数是公开接口,可以留着转发。本例里 bank_charge 是唯一调用方,所以内联了。跑测试。

IDE 的 Move 命令能处理模块级函数之间的搬移,包括更新导入(IDE 可自动完成)。类方法之间的搬移在 PyCharm 和 WebStorm 里部分支持,第 3 步的调整往往要手工做。

第 2 步“检查是否多态”值得展开说。如果 overdraft_charge 在 Account 的某个子类里被重写过,比如 CorporateAccount 有自己的透支规则,那么它的行为其实随账户子类而变,不只随账户类型而变。这时直接搬到 AccountType 会丢掉子类的那份规则。要么先把子类里的规则也表达成一种账户类型,要么承认这个函数确实依赖两边,留在原处。搬移函数假设函数只有一个实现,有多个实现时先处理继承关系(第八部分)。

模块之间的搬移还要注意循环导入。Python 里如果 accounts.py 导入了 account_types.py,而搬过去的函数又要用 accounts.py 里的东西,就形成了循环。出现这种情况通常说明搬的方向有问题,或者被依赖的那部分应该再拆出一个两边都依赖的小模块。TS 的 ES 模块对循环导入宽容一些,但同样会在初始化顺序上埋下难查的问题。

搬移函数:把函数从一个上下文(类、模块)移到另一个上下文,让它和它最依赖的数据或别的函数放在一起。

判断“住错了”的信号:函数读别的对象的字段比读自己的多;函数总是和另一个模块里的某些函数一起被修改;一个嵌套在别的函数里的辅助函数,后来发现别处也要用。

最后一种很常见。下面的 track_summary 里嵌了一个 distance,后来地图模块也要算两点距离,于是把它搬成模块级函数:

"""嵌套函数:搬移前。"""
def track_summary(points: list[tuple[float, float]]) -> str:
def distance(a: tuple[float, float], b: tuple[float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
total = sum(distance(points[i], points[i + 1]) for i in range(len(points) - 1))
return f"共 {len(points)} 个点,总长 {total:.1f}"
if __name__ == "__main__":
print(track_summary([(0, 0), (3, 4), (6, 8)]))
# 输出:
# 共 3 个点,总长 10.0
"""嵌套函数:搬移后,distance 成了模块级函数。"""
def distance(a: tuple[float, float], b: tuple[float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
def track_summary(points: list[tuple[float, float]]) -> str:
total = sum(distance(points[i], points[i + 1]) for i in range(len(points) - 1))
return f"共 {len(points)} 个点,总长 {total:.1f}"
if __name__ == "__main__":
print(track_summary([(0, 0), (3, 4), (6, 8)]))
print(distance((0, 0), (1, 1)) < 1.5)
# 输出:
# 共 3 个点,总长 10.0
# True

嵌套函数的好处是藏得深、不污染外面;坏处是别人用不上。从“只有这里用”变成“别处也要用”的那一刻,就该把它搬出来。

把一个模块级函数搬到另一个模块时,老调用方的导入语句都指向旧位置。调用方少就直接改导入;调用方多、或者有外部代码依赖时,可以在旧模块里留一行重新导出:Python 写 from new_module import distance,TS 写 export { distance } from "./geo"。这样旧的导入继续有效,新代码直接从新位置导入,等旧导入都迁移完了再删掉这一行。这和第 6 章改函数声明的迁移式做法是同一个思路:新位置先承担实现,旧位置只做转发。

IDE 在这一步帮得上大忙。PyCharm 的 Move 和 VS Code 的“移动到新文件”都会自动改掉项目里所有的导入语句,比手工搜索替换可靠,因为它们按符号而不是按文本查找,不会误改同名的别的东西。

Django 社区有一条老经验:业务逻辑放进模型,视图只做请求和响应的转换。新手常把折扣计算写在视图函数里,结果订单列表、订单详情、导出报表三个视图各写一遍。把它搬进模型之后,三个视图都调 order.discounted_total()。

"""模拟 Django:把折扣计算从视图搬进模型。"""
class Order:
def __init__(self, total: float, vip: bool, coupon: float = 0.0) -> None:
self.total = total
self.vip = vip
self.coupon = coupon
def discounted_total(self) -> float:
amount = self.total * (0.9 if self.vip else 1.0)
return round(max(0.0, amount - self.coupon), 2)
def order_detail_view(order: Order) -> dict:
return {"status": 200, "total": order.discounted_total()}
def export_csv_row(order: Order) -> str:
return f"{order.total},{order.discounted_total()}"
if __name__ == "__main__":
o = Order(200, vip=True, coupon=15)
print(order_detail_view(o))
print(export_csv_row(o))
# 输出:
# {'status': 200, 'total': 165.0}
# 200,165.0

视图里只剩“取数据、组装响应”。折扣规则改了,只改模型这一个方法;写单元测试也不用模拟 HTTP 请求,直接测模型。

React 项目里很常见的一幕:三个组件各自在文件里写了一个 formatPrice 或 formatDate,格式细节还不太一样。把它们搬到一个共用的 format 模块,组件只导入。下面把三个组件简化成返回字符串的函数。

// 模拟 React 项目:把各组件里的格式化函数搬到共用模块。
const format = {
price(cents: number): string {
return `¥${(cents / 100).toFixed(2)}`;
},
date(iso: string): string {
const [y, m, d] = iso.slice(0, 10).split("-");
return `${y}年${Number(m)}月${Number(d)}日`;
},
};
function ProductCard(p: { name: string; cents: number }): string {
return `<div>${p.name} ${format.price(p.cents)}</div>`;
}
function OrderRow(o: { id: string; cents: number; createdAt: string }): string {
return `<tr>${o.id} ${format.price(o.cents)} ${format.date(o.createdAt)}</tr>`;
}
function InvoiceHeader(i: { issuedAt: string }): string {
return `<h1>开票日期 ${format.date(i.issuedAt)}</h1>`;
}
console.log(ProductCard({ name: "咖啡豆", cents: 6800 }));
console.log(OrderRow({ id: "A100", cents: 33500, createdAt: "2026-10-07T09:00:00Z" }));
console.log(InvoiceHeader({ issuedAt: "2026-10-08" }));
// 输出:
// <div>咖啡豆 ¥68.00</div>
// <tr>A100 ¥335.00 2026年10月7日</tr>
// <h1>开票日期 2026年10月8日</h1>

真实项目里 format 会是一个单独的文件 utils/format.ts,组件 import { format } from "../utils/format"。三个组件的显示从此一致,改格式改一处。

判断“搬到哪边”拿不准的时候,可以问一句:将来最可能的需求变化是什么,它会改哪一边?透支规则最可能随账户类型增加而变,所以放在 AccountType;如果最可能的变化是“按客户的信用等级调整透支费”,那它也许该搬到客户那边,或者留在 Account 里组合两边的信息。代码的位置应该跟着变化的方向走,而不是跟着当前读了几个字段走。字段数只是一个容易观察的信号。

一个函数同等地用两个类的数据,搬到哪边都差不多,就别搬,留在调用它最多的那一边。搬来搬去本身也有成本:每次搬移,所有调用方都要改,代码评审的人也要重新找它在哪。

搬移之后如果目标类需要反过来引用源类(比如 AccountType 需要拿到整个 Account),通常说明搬错了方向,或者应该只传需要的那几个值进去。

Fowler 把搬移函数看作模块化的核心动作。一个系统设计得好不好,很大程度上看每个元素是否住在它该住的地方:相关的东西放在一起,不相关的东西分开。这件事不可能一开始就做对,因为“什么和什么相关”是随着需求逐渐显现的。所以搬移函数会贯穿一个项目的整个生命周期,每当你对业务的理解加深一点,就会发现几个函数该换个地方。

手法 它关心什么 搬移函数关心什么
提炼函数(第 4 章) 函数的边界在哪 函数住在哪个类;常常先提炼再搬移
搬移字段(第 22 章) 数据住在哪 行为住在哪;两者经常一起做
搬移语句到函数(第 23 章) 几行语句该在函数里面还是外面 整个函数该在哪个类

Order.shipping_cost 读的全是 customer 的字段:会员等级和所在城市。把它搬到 Customer,Order 里调用新方法。order_total 的输出不变。

"""练习:运费该住在哪。"""
class Customer:
def __init__(self, tier: str, city: str) -> None:
self.tier = tier
self.city = city
class Order:
def __init__(self, customer: Customer, amount: float) -> None:
self.customer = customer
self.amount = amount
def shipping_cost(self) -> float:
if self.customer.tier == "vip":
return 0.0
return 8.0 if self.customer.city in ("上海", "杭州") else 15.0
def order_total(self) -> float:
return self.amount + self.shipping_cost()
if __name__ == "__main__":
print(Order(Customer("vip", "北京"), 100).order_total())
print(Order(Customer("normal", "上海"), 100).order_total())
print(Order(Customer("normal", "北京"), 100).order_total())
# 输出:
# 100.0
# 108.0
# 115.0
import unittest
from target import Customer, Order
class OrderTest(unittest.TestCase):
def test_vip_ships_free(self):
self.assertEqual(Order(Customer("vip", "北京"), 100).order_total(), 100.0)
def test_nearby_city(self):
self.assertEqual(Order(Customer("normal", "杭州"), 100).order_total(), 108.0)
def test_far_city(self):
self.assertEqual(Order(Customer("normal", "北京"), 100).order_total(), 115.0)
if __name__ == "__main__":
unittest.main()
// 练习:运费该住在哪。
export class Customer {
constructor(
readonly tier: string,
readonly city: string,
) {}
}
export class Order {
constructor(
readonly customer: Customer,
readonly amount: number,
) {}
shippingCost(): number {
if (this.customer.tier === "vip") return 0;
return ["上海", "杭州"].includes(this.customer.city) ? 8 : 15;
}
orderTotal(): number {
return this.amount + this.shippingCost();
}
}
console.log(new Order(new Customer("vip", "北京"), 100).orderTotal());
console.log(new Order(new Customer("normal", "上海"), 100).orderTotal());
console.log(new Order(new Customer("normal", "北京"), 100).orderTotal());
// 输出:
// 100
// 108
// 115
import test from "node:test";
import assert from "node:assert/strict";
import { Customer, Order } from "./target";
test("会员免运费", () => {
assert.equal(new Order(new Customer("vip", "北京"), 100).orderTotal(), 100);
});
test("近处城市", () => {
assert.equal(new Order(new Customer("normal", "杭州"), 100).orderTotal(), 108);
});
test("远处城市", () => {
assert.equal(new Order(new Customer("normal", "北京"), 100).orderTotal(), 115);
});

搬移之后别忘了回头看一眼源类。函数搬走了,源类里可能留下只为这个函数服务的字段或辅助方法,它们现在没人用了,按第 28 章移除死代码一并清理。

  • 一个函数读别人的数据比读自己的多,就把它搬到数据那边。
  • 搬之前先看它用到了什么、有没有被子类重写;只把需要的值作为参数传过去,不要让目标类反过来依赖源类。
  • 先复制到目标、再让源函数转发、跑测试,最后决定是否内联掉转发。
  • 嵌套的辅助函数一旦别处也要用,就搬成模块级函数。
  • 函数先搬到对的类,后面的多态和提炼类才有地方落脚。