跳转到内容

改变函数声明

改变函数声明(Change Function Declaration)改一个函数的名字、参数或返回值,并把所有调用方跟着改过来。 它解决的痛是:函数名没说清楚它做什么,或者参数的形状让想用它的人用不上。

小方维护一个物流系统里的地理工具模块。里面有算两点距离的函数,当初随手叫了 dist;还有一个判断顾客是否在包邮区的函数 in_free_zone,接收整个顾客对象,从里面取省份来判断。结算页和优惠券模块都在用它们。

"""地理工具:重构前。"""
import math
FREE_ZONE = {"江苏", "浙江", "上海"}
WAREHOUSES = {"苏州": (31.3, 120.6), "杭州": (30.3, 120.2)}
def dist(a: tuple[float, float], b: tuple[float, float]) -> float:
"""两点的直线距离,单位公里。粗略按 1 度 111 公里算。"""
return math.hypot(a[0] - b[0], a[1] - b[1]) * 111
def in_free_zone(customer: dict) -> bool:
return customer["address"]["province"] in FREE_ZONE
def nearest_warehouse(point: tuple[float, float]) -> str:
return min(WAREHOUSES, key=lambda name: dist(point, WAREHOUSES[name]))
def shipping_fee(customer: dict, weight_kg: float) -> float:
if in_free_zone(customer):
return 0.0
return 10.0 + weight_kg * 2.0
def can_use_coupon(customer: dict, coupon: dict) -> bool:
return coupon["code"] == "FREESHIP" and not in_free_zone(customer)
SUZHOU_CUSTOMER = {"name": "王五", "address": {"province": "江苏", "city": "苏州"}}
SHENZHEN_CUSTOMER = {"name": "赵六", "address": {"province": "广东", "city": "深圳"}}
if __name__ == "__main__":
print(nearest_warehouse((31.2, 121.5)))
print(shipping_fee(SUZHOU_CUSTOMER, 3))
print(shipping_fee(SHENZHEN_CUSTOMER, 3))
print(can_use_coupon(SUZHOU_CUSTOMER, {"code": "FREESHIP"}))
# 输出:
# 苏州
# 0.0
# 16.0
# False
// 地理工具:重构前。
type Point = [number, number];
export interface Customer {
name: string;
address: { province: string; city: string };
}
const FREE_ZONE = new Set(["江苏", "浙江", "上海"]);
const WAREHOUSES: Record<string, Point> = { 苏州: [31.3, 120.6], 杭州: [30.3, 120.2] };
/** 两点的直线距离,单位公里。粗略按 1 度 111 公里算。 */
function dist(a: Point, b: Point): number {
return Math.hypot(a[0] - b[0], a[1] - b[1]) * 111;
}
function inFreeZone(customer: Customer): boolean {
return FREE_ZONE.has(customer.address.province);
}
export function nearestWarehouse(point: Point): string {
let best = "";
let bestDist = Infinity;
for (const [name, location] of Object.entries(WAREHOUSES)) {
const d = dist(point, location);
if (d < bestDist) {
best = name;
bestDist = d;
}
}
return best;
}
export function shippingFee(customer: Customer, weightKg: number): number {
if (inFreeZone(customer)) return 0;
return 10 + weightKg * 2;
}
export function canUseCoupon(customer: Customer, coupon: { code: string }): boolean {
return coupon.code === "FREESHIP" && !inFreeZone(customer);
}
export const SUZHOU_CUSTOMER: Customer = { name: "王五", address: { province: "江苏", city: "苏州" } };
export const SHENZHEN_CUSTOMER: Customer = { name: "赵六", address: { province: "广东", city: "深圳" } };
console.log(nearestWarehouse([31.2, 121.5]));
console.log(shippingFee(SUZHOU_CUSTOMER, 3));
console.log(shippingFee(SHENZHEN_CUSTOMER, 3));
console.log(canUseCoupon(SUZHOU_CUSTOMER, { code: "FREESHIP" }));
// 输出:
// 苏州
// 0
// 16
// false

测试走的是这个模块对外的三个函数,不直接碰 dist 和 in_free_zone:

import unittest
from target import SHENZHEN_CUSTOMER, SUZHOU_CUSTOMER, can_use_coupon, nearest_warehouse, shipping_fee
class GeoTest(unittest.TestCase):
def test_nearest_warehouse(self):
self.assertEqual(nearest_warehouse((31.2, 121.5)), "苏州")
self.assertEqual(nearest_warehouse((30.0, 120.0)), "杭州")
def test_free_zone_ships_free(self):
self.assertEqual(shipping_fee(SUZHOU_CUSTOMER, 3), 0.0)
def test_outside_free_zone_pays_by_weight(self):
self.assertEqual(shipping_fee(SHENZHEN_CUSTOMER, 3), 16.0)
def test_coupon_only_outside_free_zone(self):
self.assertFalse(can_use_coupon(SUZHOU_CUSTOMER, {"code": "FREESHIP"}))
self.assertTrue(can_use_coupon(SHENZHEN_CUSTOMER, {"code": "FREESHIP"}))
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { SHENZHEN_CUSTOMER, SUZHOU_CUSTOMER, canUseCoupon, nearestWarehouse, shippingFee } from "./target";
test("最近的仓库", () => {
assert.equal(nearestWarehouse([31.2, 121.5]), "苏州");
assert.equal(nearestWarehouse([30.0, 120.0]), "杭州");
});
test("包邮区免运费", () => {
assert.equal(shippingFee(SUZHOU_CUSTOMER, 3), 0);
});
test("包邮区外按重量收费", () => {
assert.equal(shippingFee(SHENZHEN_CUSTOMER, 3), 16);
});
test("优惠券只在包邮区外可用", () => {
assert.equal(canUseCoupon(SUZHOU_CUSTOMER, { code: "FREESHIP" }), false);
assert.equal(canUseCoupon(SHENZHEN_CUSTOMER, { code: "FREESHIP" }), true);
});

如果现在要加一个需求:结算页在顾客填完收货省份、还没填完其他信息时,就提示“该地区包邮”。这时候手里只有一个省份字符串,没有顾客对象。in_free_zone(customer) 用不上,除非先造一个假的顾客对象 {"address": {"province": province}} 塞进去。这种假对象一旦出现,in_free_zone 以后就不能放心地多读顾客的别的字段了,因为不知道哪个调用方传的是假的。

问题出在参数的形状:这个函数真正需要的只是省份,却要了整个顾客。它关心的太多,能用它的地方就太少。还有一个隐蔽的后果:顾客对象的结构是别的团队定的,哪天他们把 address 拆成 shipping_address 和 billing_address,这个和地址结构本来无关的判断函数也得跟着改。

dist 是另一种问题。名字是对的,但没说全:是直线距离还是路程?单位是米还是公里?每个调用方都要点进去看文档字符串才知道。小方自己就在仓库调度模块里见过一行 if dist(a, b) < 500,写的人以为单位是米,设的阈值差了一千倍,测试没覆盖到,上线两周才发现。名字如果叫 distance_km,这行代码在评审时就会被看出来。

用第 2 章的名字说,dist 是神秘命名;in_free_zone 要整个顾客对象,是过长参数列表的近亲:参数不多,但参数比需要的大。

"""地理工具:重构后。"""
import math
FREE_ZONE = {"江苏", "浙江", "上海"}
WAREHOUSES = {"苏州": (31.3, 120.6), "杭州": (30.3, 120.2)}
def distance_km(a: tuple[float, float], b: tuple[float, float]) -> float:
"""两点的直线距离。粗略按 1 度 111 公里算。"""
return math.hypot(a[0] - b[0], a[1] - b[1]) * 111
def in_free_zone(province: str) -> bool:
return province in FREE_ZONE
def nearest_warehouse(point: tuple[float, float]) -> str:
return min(WAREHOUSES, key=lambda name: distance_km(point, WAREHOUSES[name]))
def shipping_fee(customer: dict, weight_kg: float) -> float:
if in_free_zone(customer["address"]["province"]):
return 0.0
return 10.0 + weight_kg * 2.0
def can_use_coupon(customer: dict, coupon: dict) -> bool:
return coupon["code"] == "FREESHIP" and not in_free_zone(customer["address"]["province"])
SUZHOU_CUSTOMER = {"name": "王五", "address": {"province": "江苏", "city": "苏州"}}
SHENZHEN_CUSTOMER = {"name": "赵六", "address": {"province": "广东", "city": "深圳"}}
if __name__ == "__main__":
print(nearest_warehouse((31.2, 121.5)))
print(shipping_fee(SUZHOU_CUSTOMER, 3))
print(shipping_fee(SHENZHEN_CUSTOMER, 3))
print(can_use_coupon(SUZHOU_CUSTOMER, {"code": "FREESHIP"}))
# 输出:
# 苏州
# 0.0
# 16.0
# False
// 地理工具:重构后。
type Point = [number, number];
export interface Customer {
name: string;
address: { province: string; city: string };
}
const FREE_ZONE = new Set(["江苏", "浙江", "上海"]);
const WAREHOUSES: Record<string, Point> = { 苏州: [31.3, 120.6], 杭州: [30.3, 120.2] };
/** 两点的直线距离。粗略按 1 度 111 公里算。 */
function distanceKm(a: Point, b: Point): number {
return Math.hypot(a[0] - b[0], a[1] - b[1]) * 111;
}
function inFreeZone(province: string): boolean {
return FREE_ZONE.has(province);
}
export function nearestWarehouse(point: Point): string {
let best = "";
let bestDist = Infinity;
for (const [name, location] of Object.entries(WAREHOUSES)) {
const d = distanceKm(point, location);
if (d < bestDist) {
best = name;
bestDist = d;
}
}
return best;
}
export function shippingFee(customer: Customer, weightKg: number): number {
if (inFreeZone(customer.address.province)) return 0;
return 10 + weightKg * 2;
}
export function canUseCoupon(customer: Customer, coupon: { code: string }): boolean {
return coupon.code === "FREESHIP" && !inFreeZone(customer.address.province);
}
export const SUZHOU_CUSTOMER: Customer = { name: "王五", address: { province: "江苏", city: "苏州" } };
export const SHENZHEN_CUSTOMER: Customer = { name: "赵六", address: { province: "广东", city: "深圳" } };
console.log(nearestWarehouse([31.2, 121.5]));
console.log(shippingFee(SUZHOU_CUSTOMER, 3));
console.log(shippingFee(SHENZHEN_CUSTOMER, 3));
console.log(canUseCoupon(SUZHOU_CUSTOMER, { code: "FREESHIP" }));
// 输出:
// 苏州
// 0
// 16
// false

结算页那个需求现在是一行:in_free_zone(province)。没有假对象,in_free_zone 也不再知道顾客长什么样,以后顾客对象改结构它不受影响。distance_km 把单位写进了名字,调用方不用再点进去看。

注意测试一个字没改。测试走的是 shipping_fee、can_use_coupon、nearest_warehouse 这三个对外的函数,它们的签名没变,变的只是内部用的两个函数。这是有意的:测试应该盯着模块的行为,而不是盯着每个内部函数的签名,否则每次改声明都要跟着改测试。

改名有两种做法,看调用方有多少、改起来有多安全。

简单做法,适用于调用方都在手边、能一次改完的情况:

  1. 改函数的声明。
  2. 找到所有调用方,逐个改成新的名字或参数。IDE 的 Rename 和 Change Signature 能一步完成这两步(IDE 可自动完成)。
  3. 跑测试。

dist 改名就用这种做法:选中函数名,Rename,输入 distance_km,IDE 把三处调用一起改掉。

迁移式做法,适用于调用方多、散在各处、不能一次全改的情况,比如函数被别的团队的代码或者发布出去的库调用。in_free_zone 换参数就用这种:

第 1 步,用提炼函数(第 4 章)把函数体提到一个新函数里,新函数用新的签名。因为 Python 和 TS 都不允许同一个函数名有两个实现,新函数先用个临时名字:

def province_in_free_zone(province: str) -> bool:
return province in FREE_ZONE

第 2 步,让旧函数变成一个转发:

def in_free_zone(customer: dict) -> bool:
return province_in_free_zone(customer["address"]["province"])

第 3 步,跑测试。此时所有调用方都还在用旧函数,行为不变。

第 4 步,把调用方一个一个改成调用新函数,每改一个跑一次测试。

第 5 步,所有调用方都迁移完后,删掉旧函数。

第 6 步,用简单做法把新函数改名成 in_free_zone。

迁移式做法的好处是中间任何时刻都能停下来发布。如果旧函数是公开接口,第 5 步可以推迟很久,给旧函数加一条弃用警告,等用户都迁走了再删。下面两个真实例子就是这么做的。

迁移式改法的五个状态:每个状态程序都能跑,中间任何一步都可以停下来发布

两种做法都要注意 IDE 看不见的调用方。用字符串拼出来的函数名、配置文件里登记的回调、模板里引用的方法,Rename 都找不到。改完声明后全文搜索一遍旧名字,再跑一遍完整的测试和一次手工冒烟,才算改完。这也是迁移式做法在大项目里更稳的原因:旧函数还在,漏掉的调用方不会立刻崩,而是走转发并在日志里留下警告。

改返回值和改参数走同样的步骤。比如 nearest_warehouse 从返回仓库名改成返回仓库名和距离,调用方少,用简单做法一次改完;如果调用方多,就先加一个返回新结构的新函数,旧函数从新函数的结果里取出名字返回,逐个迁移。

改变函数声明:修改函数的名字、参数列表或返回类型,并同步修改所有调用方,保持行为不变。它可能是最值得练熟的手法之一,因为函数的声明就是它和外界的契约,契约不清楚,别的一切都难办。

这个手法覆盖四种改动,每种的动机不同:

  • 改名。函数名没说清楚它做什么,或者说得不准。好名字的检验办法是写一句注释说明这个函数干什么,然后看能不能把注释变成名字。dist 的注释是“两点的直线距离,单位公里”,名字就该是 distance_km。
  • 加参数。新需求要函数多知道一件事。比如 shipping_fee 要支持加急,就加一个 rush 参数。加之前先问:这件事能不能从已有参数推出来?能就不加,见第 46 章以查询取代参数。
  • 删参数。所有调用方都传同一个值,或者函数内部根本没用这个参数。多余的参数让每个调用方多写一个值,也让读者多猜一次它的用途。
  • 改参数的形状。本章的例子就是这种:参数从整个对象改成它的一个字段,或者反过来。方向取决于函数真正需要什么。

一次只改一个方面。要给 dist 改名又要加参数,先改名、跑测试、提交,再加参数。两件事混在一起做,测试失败时分不清是哪件事错了。

它没有严格意义上的反向手法,但有一个近亲是“引入参数对象”(第 9 章):当要改的参数是一组总是一起出现的值时,先看那一章。

迁移式做法的中间状态里,旧函数 in_free_zone(customer) 只做一件事:把调用方熟悉的旧签名翻译成新函数的签名。这正是适配器(Adapter)模式的形状:调用方面对的是旧接口,真正干活的是新函数,中间一层负责转换。区别只在寿命:模式里的适配器是长期存在的设计,这里的转发函数是过渡期的脚手架,调用方迁移完就拆掉;加上弃用警告,则是让这个适配器多活几个版本。适配器模式见《设计模式》书第 9 章。

pandas、requests 这类库经常给参数改名,又不能让用户的旧代码一下子全坏。它们的做法是迁移式:新参数名正式生效,旧参数名继续接受但发出弃用警告,几个版本之后再删。下面模拟一个 read_table 函数,把参数 sep 改名成 delimiter。

"""模拟 Python 库给参数改名:旧名字继续可用,但发弃用警告。"""
import warnings
def read_table(text: str, delimiter: str = ",", sep: str | None = None) -> list[list[str]]:
if sep is not None:
warnings.warn("参数 sep 已改名为 delimiter,sep 将在 3.0 版移除", DeprecationWarning, stacklevel=2)
delimiter = sep
return [line.split(delimiter) for line in text.splitlines()]
if __name__ == "__main__":
text = "a;b\nc;d"
print(read_table(text, delimiter=";"))
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
print(read_table(text, sep=";"))
print(caught[0].message)
# 输出:
# [['a', 'b'], ['c', 'd']]
# [['a', 'b'], ['c', 'd']]
# 参数 sep 已改名为 delimiter,sep 将在 3.0 版移除

Express 3 允许 res.send(404, "not found") 这样把状态码当第一个参数,Express 4 把它改成了 res.status(404).send("not found"),旧写法在过渡期里继续能用,但打印弃用提示。下面用一个最小的 HttpResponse 类模拟这个过渡期。

// 模拟 Express 4 过渡期的 res.send:新签名正式,旧签名转发并提示。
class HttpResponse {
statusCode = 200;
body = "";
log: string[] = [];
status(code: number): this {
this.statusCode = code;
return this;
}
send(bodyOrStatus: string | number, legacyBody?: string): this {
if (typeof bodyOrStatus === "number") {
this.log.push("res.send(status, body) 已弃用,请用 res.status(status).send(body)");
return this.status(bodyOrStatus).send(legacyBody ?? "");
}
this.body = bodyOrStatus;
return this;
}
}
const modern = new HttpResponse().status(404).send("not found");
const legacy = new HttpResponse().send(404, "not found");
console.log(modern.statusCode, modern.body);
console.log(legacy.statusCode, legacy.body);
console.log(legacy.log[0]);
// 输出:
// 404 not found
// 404 not found
// res.send(status, body) 已弃用,请用 res.status(status).send(body)

两个例子的骨架一样:新签名是真正的实现,旧签名只是转发加提示。这正是迁移式做法的第 2 步停在那里不动,等用户迁移。

改声明的代价和调用方数量成正比。一个被几百处调用的公开函数,改名带来的好处要大到值得让几百处跟着动,否则宁可留着一个不太准的名字,在文档里说清楚。发布出去的库尤其如此:你改一个参数名,用户的代码全部要动,所以库的作者都用迁移式做法并且把旧签名保留好几个版本。

不要为了加新需求而给函数不断加参数。函数有了五个参数,再加第六个之前先看第 9 章引入参数对象,或者第 44 章移除标记参数。也不要来回改名:今天叫 distance_km,下周觉得 km_between 更顺口又改一次,每次改名都是调用方的一次无意义改动。改名之前花一分钟想清楚,改完就留着。

手法 它关心什么 改变函数声明关心什么
变量改名(第 7 章) 函数内部的变量名 函数对外的名字和参数
引入参数对象(第 9 章) 把一组参数合成一个对象 单个参数的增删改和函数改名
保持对象完整(第 45 章) 把几个字段参数换成整个对象 本章的例子正好相反,把整个对象换成一个字段;两个方向都对,看函数真正需要什么

下面的 send 函数有四个位置参数,调用方 notify_order_shipped 在用它。请用迁移式做法把它改成 send(message, recipients, urgent=False),其中 recipients 是一个有 to 和 cc 两个键的字典。notify_order_shipped 的输出不能变,测试要照常通过。

"""练习:发通知。"""
def send(msg: str, to: str, cc: str, urgent: bool) -> str:
header = "[紧急] " if urgent else ""
return f"{header}{msg} -> {to} (抄送 {cc})"
def notify_order_shipped(order: dict) -> str:
return send(f"订单 {order['id']} 已发货", order["email"], "[email protected]", order["amount"] > 1000)
if __name__ == "__main__":
print(notify_order_shipped({"id": 7, "email": "[email protected]", "amount": 1500}))
print(notify_order_shipped({"id": 8, "email": "[email protected]", "amount": 200}))
# 输出:
# [紧急] 订单 7 已发货 -> [email protected] (抄送 [email protected])
# 订单 8 已发货 -> [email protected] (抄送 [email protected])
import unittest
from target import notify_order_shipped
class NotifyTest(unittest.TestCase):
def test_large_order_is_urgent(self):
self.assertEqual(
notify_order_shipped({"id": 7, "email": "[email protected]", "amount": 1500}),
"[紧急] 订单 7 已发货 -> [email protected] (抄送 [email protected])",
)
def test_small_order_is_not_urgent(self):
self.assertEqual(
notify_order_shipped({"id": 8, "email": "[email protected]", "amount": 200}),
"订单 8 已发货 -> [email protected] (抄送 [email protected])",
)
if __name__ == "__main__":
unittest.main()
// 练习:发通知。
interface Order {
id: number;
email: string;
amount: number;
}
function send(msg: string, to: string, cc: string, urgent: boolean): string {
const header = urgent ? "[紧急] " : "";
return `${header}${msg} -> ${to} (抄送 ${cc})`;
}
export function notifyOrderShipped(order: Order): string {
return send(`订单 ${order.id} 已发货`, order.email, "[email protected]", order.amount > 1000);
}
console.log(notifyOrderShipped({ id: 7, email: "[email protected]", amount: 1500 }));
console.log(notifyOrderShipped({ id: 8, email: "[email protected]", amount: 200 }));
// 输出:
// [紧急] 订单 7 已发货 -> [email protected] (抄送 [email protected])
// 订单 8 已发货 -> [email protected] (抄送 [email protected])
import test from "node:test";
import assert from "node:assert/strict";
import { notifyOrderShipped } from "./target";
test("大额订单标紧急", () => {
assert.equal(
notifyOrderShipped({ id: 7, email: "[email protected]", amount: 1500 }),
"[紧急] 订单 7 已发货 -> [email protected] (抄送 [email protected])",
);
});
test("小额订单不标紧急", () => {
assert.equal(
notifyOrderShipped({ id: 8, email: "[email protected]", amount: 200 }),
"订单 8 已发货 -> [email protected] (抄送 [email protected])",
);
});
  • 函数的声明是它和外界的契约。名字说不清、参数要得太多,先改声明。
  • 调用方都在手边时用简单做法,IDE 的 Rename 和 Change Signature 一步完成。
  • 调用方多或在别人手里时用迁移式:新函数承担实现,旧函数转发,逐个迁移,最后删旧的。
  • 公开接口的旧签名可以长期保留并发弃用警告,这是迁移式做法停在中间的样子。
  • 测试盯模块的对外行为,不盯内部函数的签名,改声明才不用改测试。