跳转到内容

提炼函数 / 内联函数

提炼函数(Extract Function)把一段代码挪进一个有名字的新函数,原处只留一个调用;内联函数(Inline Function)正好相反,把函数体放回调用处,删掉函数。 它们解决的痛是:一段代码要靠注释或者读三遍才知道在干什么,或者反过来,一个函数名没有比它的函数体多说任何东西。

小周给一家咖啡店写收银系统。收银员结账时要打出一张小票:顾客名字、每件商品一行、小计、按会员等级算的折扣、税、合计。规则不复杂:VIP 打九折,普通会员打九五折,税按折后金额的 6% 算。小周写了一个函数,把这些事从上到下做完,返回小票的文本。

"""订单小票:重构前。"""
def receipt(order: dict) -> str:
lines = [f"顾客:{order['customer']['name']}"]
# 逐项列出,顺便累计小计
subtotal = 0.0
for item in order["items"]:
line_total = item["price"] * item["qty"]
subtotal += line_total
lines.append(f"{item['name']} x{item['qty']} {line_total:.2f}")
# 按会员等级打折
if order["customer"]["tier"] == "vip":
discount = subtotal * 0.10
elif order["customer"]["tier"] == "member":
discount = subtotal * 0.05
else:
discount = 0.0
# 税按折后金额算
taxable = subtotal - discount
tax = taxable * 0.06
total = taxable + tax
lines.append(f"小计 {subtotal:.2f}")
lines.append(f"折扣 -{discount:.2f}")
lines.append(f"税 {tax:.2f}")
lines.append(f"合计 {total:.2f}")
return "\n".join(lines)
ORDER = {
"customer": {"name": "张三", "tier": "vip"},
"items": [
{"name": "咖啡豆", "price": 68.0, "qty": 2},
{"name": "手冲壶", "price": 199.0, "qty": 1},
],
}
if __name__ == "__main__":
print(receipt(ORDER))
# 输出:
# 顾客:张三
# 咖啡豆 x2 136.00
# 手冲壶 x1 199.00
# 小计 335.00
# 折扣 -33.50
# 税 18.09
# 合计 319.59
// 订单小票:重构前。
export interface Item {
name: string;
price: number;
qty: number;
}
export interface Order {
customer: { name: string; tier: string };
items: Item[];
}
export function receipt(order: Order): string {
const lines = [`顾客:${order.customer.name}`];
// 逐项列出,顺便累计小计
let subtotal = 0;
for (const item of order.items) {
const lineTotal = item.price * item.qty;
subtotal += lineTotal;
lines.push(`${item.name} x${item.qty} ${lineTotal.toFixed(2)}`);
}
// 按会员等级打折
let discount: number;
if (order.customer.tier === "vip") {
discount = subtotal * 0.1;
} else if (order.customer.tier === "member") {
discount = subtotal * 0.05;
} else {
discount = 0;
}
// 税按折后金额算
const taxable = subtotal - discount;
const tax = taxable * 0.06;
const total = taxable + tax;
lines.push(`小计 ${subtotal.toFixed(2)}`);
lines.push(`折扣 -${discount.toFixed(2)}`);
lines.push(`税 ${tax.toFixed(2)}`);
lines.push(`合计 ${total.toFixed(2)}`);
return lines.join("\n");
}
export const ORDER: Order = {
customer: { name: "张三", tier: "vip" },
items: [
{ name: "咖啡豆", price: 68, qty: 2 },
{ name: "手冲壶", price: 199, qty: 1 },
],
};
console.log(receipt(ORDER));
// 输出:
// 顾客:张三
// 咖啡豆 x2 136.00
// 手冲壶 x1 199.00
// 小计 335.00
// 折扣 -33.50
// 税 18.09
// 合计 319.59

这个函数有测试,覆盖了三种会员等级和商品行:

import unittest
from target import receipt
def order(tier: str) -> dict:
return {
"customer": {"name": "张三", "tier": tier},
"items": [
{"name": "咖啡豆", "price": 68.0, "qty": 2},
{"name": "手冲壶", "price": 199.0, "qty": 1},
],
}
class ReceiptTest(unittest.TestCase):
def test_lists_every_item(self):
self.assertIn("咖啡豆 x2 136.00", receipt(order("vip")))
def test_vip_gets_ten_percent_off(self):
self.assertIn("折扣 -33.50", receipt(order("vip")))
def test_member_gets_five_percent_off(self):
self.assertIn("折扣 -16.75", receipt(order("member")))
def test_regular_pays_full_price_plus_tax(self):
text = receipt(order("regular"))
self.assertIn("折扣 -0.00", text)
self.assertIn("合计 355.10", text)
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { receipt, type Order } from "./target";
function order(tier: string): Order {
return {
customer: { name: "张三", tier },
items: [
{ name: "咖啡豆", price: 68, qty: 2 },
{ name: "手冲壶", price: 199, qty: 1 },
],
};
}
test("每件商品一行", () => {
assert.match(receipt(order("vip")), /咖啡豆 x2 136\.00/);
});
test("VIP 打九折", () => {
assert.match(receipt(order("vip")), /折扣 -33\.50/);
});
test("普通会员打九五折", () => {
assert.match(receipt(order("member")), /折扣 -16\.75/);
});
test("非会员不打折,照常计税", () => {
const text = receipt(order("regular"));
assert.match(text, /折扣 -0\.00/);
assert.match(text, /合计 355\.10/);
});

如果现在要加一个需求:结账后给顾客的手机推一条通知,内容是“张三,您的订单合计 319.59 元”。这条通知只要合计,不要小票的其他行。

小周打开 receipt,发现合计这个数字没法单独拿到。它是在函数中段算出来的局部变量 total,要算它得先跑那个累加小计的循环,再做折扣判断,再算税。于是只有两条路:把这 15 行复制到一个新函数 summary 里,或者让 receipt 顺便返回合计。第一条路意味着以后改折扣规则要改两处,第二条路意味着一个叫“小票”的函数开始返回和小票无关的东西。

第二个问题是读。这个函数靠三条注释分段:逐项列出、按会员等级打折、税按折后金额算。要确认折扣是按小计算还是按折后算,得先找到第二段,再往上找 subtotal 是什么时候定下来的。注释在替函数结构打补丁。

第三个问题藏得深一点。“逐项列出”那一段同时在做两件事:拼每一行的文本,和累计小计。它们挤在同一个循环里,所以列商品的格式一改,小计的累加也得跟着在同一处小心对待。

用第 2 章的名字说,这里有过长函数和注释,而那个呼之欲出的 summary 一旦复制粘贴出来,就会再添一个重复代码。

把每段有名字的事提成一个函数。

"""订单小票:重构后。"""
DISCOUNT_RATE = {"vip": 0.10, "member": 0.05}
TAX_RATE = 0.06
def line_total(item: dict) -> float:
return item["price"] * item["qty"]
def subtotal(order: dict) -> float:
return sum(line_total(item) for item in order["items"])
def discount(tier: str, amount: float) -> float:
return amount * DISCOUNT_RATE.get(tier, 0.0)
def tax(amount: float) -> float:
return amount * TAX_RATE
def item_lines(order: dict) -> list[str]:
return [f"{item['name']} x{item['qty']} {line_total(item):.2f}" for item in order["items"]]
def summary_lines(sub: float, disc: float, tax_amount: float) -> list[str]:
return [
f"小计 {sub:.2f}",
f"折扣 -{disc:.2f}",
f"税 {tax_amount:.2f}",
f"合计 {sub - disc + tax_amount:.2f}",
]
def receipt(order: dict) -> str:
sub = subtotal(order)
disc = discount(order["customer"]["tier"], sub)
tax_amount = tax(sub - disc)
lines = [f"顾客:{order['customer']['name']}", *item_lines(order), *summary_lines(sub, disc, tax_amount)]
return "\n".join(lines)
ORDER = {
"customer": {"name": "张三", "tier": "vip"},
"items": [
{"name": "咖啡豆", "price": 68.0, "qty": 2},
{"name": "手冲壶", "price": 199.0, "qty": 1},
],
}
if __name__ == "__main__":
print(receipt(ORDER))
# 输出:
# 顾客:张三
# 咖啡豆 x2 136.00
# 手冲壶 x1 199.00
# 小计 335.00
# 折扣 -33.50
# 税 18.09
# 合计 319.59
// 订单小票:重构后。
export interface Item {
name: string;
price: number;
qty: number;
}
export interface Order {
customer: { name: string; tier: string };
items: Item[];
}
const DISCOUNT_RATE: Record<string, number> = { vip: 0.1, member: 0.05 };
const TAX_RATE = 0.06;
function lineTotal(item: Item): number {
return item.price * item.qty;
}
function subtotal(order: Order): number {
return order.items.reduce((sum, item) => sum + lineTotal(item), 0);
}
function discount(tier: string, amount: number): number {
return amount * (DISCOUNT_RATE[tier] ?? 0);
}
function tax(amount: number): number {
return amount * TAX_RATE;
}
function itemLines(order: Order): string[] {
return order.items.map((item) => `${item.name} x${item.qty} ${lineTotal(item).toFixed(2)}`);
}
function summaryLines(sub: number, disc: number, taxAmount: number): string[] {
return [
`小计 ${sub.toFixed(2)}`,
`折扣 -${disc.toFixed(2)}`,
`税 ${taxAmount.toFixed(2)}`,
`合计 ${(sub - disc + taxAmount).toFixed(2)}`,
];
}
export function receipt(order: Order): string {
const sub = subtotal(order);
const disc = discount(order.customer.tier, sub);
const taxAmount = tax(sub - disc);
const lines = [`顾客:${order.customer.name}`, ...itemLines(order), ...summaryLines(sub, disc, taxAmount)];
return lines.join("\n");
}
export const ORDER: Order = {
customer: { name: "张三", tier: "vip" },
items: [
{ name: "咖啡豆", price: 68, qty: 2 },
{ name: "手冲壶", price: 199, qty: 1 },
],
};
console.log(receipt(ORDER));
// 输出:
// 顾客:张三
// 咖啡豆 x2 136.00
// 手冲壶 x1 199.00
// 小计 335.00
// 折扣 -33.50
// 税 18.09
// 合计 319.59

输出一个字没变,测试一个字没改,四个用例照样通过。再看前面的三个问题。

重构前 receipt 一个函数做五件事;重构后每件事一个名字,推送通知只需复用其中三个函数

推送通知那个需求,现在是三行的事,不用复制任何东西:

def summary(order: dict) -> str:
sub = subtotal(order)
disc = discount(order["customer"]["tier"], sub)
return f"{order['customer']['name']},您的订单合计 {sub - disc + tax(sub - disc):.2f} 元"

三条注释没有了,因为每段代码有了自己的名字。要确认折扣按什么算,看 receipt 里 discount(tier, sub) 这一行就够了:传进去的是小计。

拼商品行和累计小计分开了:item_lines 只管文本,subtotal 只管数字。改小票格式时不会碰到金额计算。

还有一个附带的收获:折扣率从 if 链变成了一张表 DISCOUNT_RATE。这不是提炼函数本身做的,而是把折扣提成独立函数之后,那三个分支挤在一起的样子让人一眼看出它们只是在查表。很多重构都是这样,做完一个,下一个就自己冒出来。

提炼函数的步骤。每一步之后都跑测试。

  1. 新建一个空函数,用“它做什么”来命名,不用“它怎么做”。subtotal 好,loop_over_items_and_add 不好。想不出名字时先随便叫一个,提出来之后再改,提出来的代码往往会告诉你它该叫什么。
  2. 把要提炼的那段代码剪切到新函数里。
  3. 看这段代码用了哪些源函数的局部变量。只读不改的变量变成参数,比如 discount 用到的 subtotal 变成参数 amount。被这段代码赋值、之后还要用的变量变成返回值,比如累加出来的 subtotal。如果一段代码要返回两个以上的值,通常说明它该拆成两段,各提一个函数,就像这里把列商品行和累计小计拆开。
  4. 在源函数原来的位置写上对新函数的调用。
  5. 跑测试。
  6. 看看别处有没有同样或相似的代码,有就也换成对新函数的调用。

步骤 1 到 4 在 VS Code、PyCharm、WebStorm 里都能一键完成:选中代码,Extract Function 或 Extract Method,输入名字,IDE 会自动分析变量并生成参数(IDE 可自动完成)。IDE 做不了的是步骤 3 里“该不该拆成两段”的判断和步骤 6。

内联函数的步骤相反:

  1. 确认这个函数不是某个会被子类重写的方法。会被重写的不能内联。
  2. 找到所有调用点。
  3. 把每个调用替换成函数体,一次换一个,每换一个跑一次测试。
  4. 删掉函数定义。

IDE 的 Inline 命令能一次完成第 2 到第 4 步(IDE 可自动完成)。

提炼函数:把一段代码放进一个以其用途命名的函数里,并在原处调用它。判断该不该提炼的标准不是长度,而是“意图和实现是否分离”:如果你得读完一段代码才知道它在干什么,就把“它在干什么”写成函数名,把“怎么干”藏进函数体。一个六行的函数被提炼成三个两行的函数,完全正常。

它常出现在三种场合。一是过长函数靠注释分段,每条注释就是一个待提炼函数的名字。二是一段代码在别处也出现了,提炼出来之后第二处换成调用。三是准备做更大的重构,比如要搬移函数或提炼类,先把要搬的东西提成函数,搬起来就只是挪一个定义。

内联函数是它的反向手法。什么时候反过来做?当函数体和函数名说的是同一件事,没有多出任何信息的时候。下面的 needs_rush 就是这样:

"""内联函数:重构前。"""
def needs_rush(order: dict) -> bool:
return order["rush"]
def shipping_label(order: dict) -> str:
return "加急" if needs_rush(order) else "普通"
if __name__ == "__main__":
print(shipping_label({"rush": True}))
print(shipping_label({"rush": False}))
# 输出:
# 加急
# 普通
// 内联函数:重构前。
interface Order {
rush: boolean;
}
function needsRush(order: Order): boolean {
return order.rush;
}
export function shippingLabel(order: Order): string {
return needsRush(order) ? "加急" : "普通";
}
console.log(shippingLabel({ rush: true }));
console.log(shippingLabel({ rush: false }));
// 输出:
// 加急
// 普通

读者看到 needs_rush(order) 还得点进去确认它没做别的事,结果发现它只是读了一个字段。把它内联回去,调用处反而更清楚:

"""内联函数:重构后。"""
def shipping_label(order: dict) -> str:
return "加急" if order["rush"] else "普通"
if __name__ == "__main__":
print(shipping_label({"rush": True}))
print(shipping_label({"rush": False}))
# 输出:
# 加急
# 普通
// 内联函数:重构后。
interface Order {
rush: boolean;
}
export function shippingLabel(order: Order): string {
return order.rush ? "加急" : "普通";
}
console.log(shippingLabel({ rush: true }));
console.log(shippingLabel({ rush: false }));
// 输出:
// 加急
// 普通

另一个该内联的场合是一堆组织得不好的小函数互相调用,先把它们全部内联回一个大函数,看清全貌,再按新的理解重新提炼。

Node 的 Web 框架 Express 里,一个路由处理器接收请求、返回响应。随手写出来的处理器常常把参数校验、业务逻辑、组装响应堆在一起。下面用一个最小的 Req 和 Res 类型模拟 Express,createUserV1 是没提炼的版本,createUserV2 是提炼后的版本,两者对同一个请求给出同样的响应。

// 模拟 Express 的请求和响应对象。
interface Req {
body: Record<string, unknown>;
}
interface Res {
status: number;
json: unknown;
}
// 提炼前:校验、构造、响应挤在一个处理器里。
function createUserV1(req: Req): Res {
const errors: string[] = [];
if (typeof req.body.name !== "string" || req.body.name.length === 0) {
errors.push("name 不能为空");
}
if (typeof req.body.email !== "string" || !req.body.email.includes("@")) {
errors.push("email 格式不对");
}
if (errors.length > 0) {
return { status: 400, json: { errors } };
}
const user = { id: 1, name: req.body.name as string, email: req.body.email as string };
return { status: 201, json: { id: user.id, name: user.name } };
}
// 提炼后:三个函数各管一件事。
function validateUser(body: Record<string, unknown>): string[] {
const errors: string[] = [];
if (typeof body.name !== "string" || body.name.length === 0) errors.push("name 不能为空");
if (typeof body.email !== "string" || !body.email.includes("@")) errors.push("email 格式不对");
return errors;
}
function buildUser(body: Record<string, unknown>) {
return { id: 1, name: body.name as string, email: body.email as string };
}
function userResponse(user: { id: number; name: string }): Res {
return { status: 201, json: { id: user.id, name: user.name } };
}
function createUserV2(req: Req): Res {
const errors = validateUser(req.body);
if (errors.length > 0) return { status: 400, json: { errors } };
return userResponse(buildUser(req.body));
}
const good: Req = { body: { name: "李四", email: "[email protected]" } };
const bad: Req = { body: { name: "", email: "nope" } };
console.log(JSON.stringify(createUserV1(good)) === JSON.stringify(createUserV2(good)));
console.log(JSON.stringify(createUserV1(bad)) === JSON.stringify(createUserV2(bad)));
console.log(JSON.stringify(createUserV2(bad)));
// 输出:
// true
// true
// {"status":400,"json":{"errors":["name 不能为空","email 格式不对"]}}

提炼之后,validateUser 可以单独测,也可以给更新用户的处理器复用;createUserV2 读起来就是三句话:校验、构造、响应。

Python 的 Web 框架 Django 里,视图函数从请求的查询参数里拼出筛选条件去查数据库。参数一多,拼条件的代码就把视图撑成几十行。下面用字典模拟查询参数、用列表模拟数据表,product_list_v1 是提炼前,product_list_v2 把拼条件提成了 build_filters。

"""模拟 Django 视图:从查询参数拼筛选条件。"""
PRODUCTS = [
{"name": "咖啡豆", "category": "coffee", "price": 68, "in_stock": True},
{"name": "手冲壶", "category": "tools", "price": 199, "in_stock": True},
{"name": "滤纸", "category": "tools", "price": 15, "in_stock": False},
]
def product_list_v1(params: dict) -> list[str]:
"""提炼前:拼条件和查数据混在一起。"""
result = []
for product in PRODUCTS:
if "category" in params and product["category"] != params["category"]:
continue
if "max_price" in params and product["price"] > int(params["max_price"]):
continue
if params.get("in_stock") == "1" and not product["in_stock"]:
continue
result.append(product["name"])
return result
def build_filters(params: dict) -> list:
"""提炼后:每个查询参数变成一个判断函数。"""
filters = []
if "category" in params:
filters.append(lambda p: p["category"] == params["category"])
if "max_price" in params:
filters.append(lambda p: p["price"] <= int(params["max_price"]))
if params.get("in_stock") == "1":
filters.append(lambda p: p["in_stock"])
return filters
def product_list_v2(params: dict) -> list[str]:
filters = build_filters(params)
return [p["name"] for p in PRODUCTS if all(f(p) for f in filters)]
if __name__ == "__main__":
query = {"category": "tools", "in_stock": "1"}
print(product_list_v1(query) == product_list_v2(query))
print(product_list_v2(query))
print(product_list_v2({"max_price": "100"}))
# 输出:
# True
# ['手冲壶']
# ['咖啡豆', '滤纸']

真正的 Django 里 build_filters 返回的会是查询集的过滤条件,但形状一样:视图只剩“拼条件、查、返回”三步,拼条件的部分可以单独测试,也能被列表页和导出接口共用。

提炼过头的样子是一屏幕三行函数,每个只被调用一次,读主流程时要在十几个函数之间来回跳。判断标准还是意图和实现:如果一段代码的意图一眼就能看出来,提炼它只会增加一次跳转。x + 1 不需要变成 increment(x)。

名字起不好时先别提炼。一个叫 process_data 或 do_step_2 的函数比原地的代码更难懂,因为读者既要读函数体,又要怀疑这个名字是不是在骗人。提炼的价值全在名字里,没有好名字就没有价值。

在极少数性能敏感的热循环里,函数调用的开销可能被测量出来。但这是测量之后再说的事,不是预先回避提炼的理由。第 58 章会讲重构和性能怎么配合。

手法 它关心什么 提炼函数关心什么
提炼变量(第 5 章) 给一个表达式的结果起名字,不改变控制流 给一段语句起名字,可以包含循环和分支
搬移函数(第 21 章) 函数该住在哪个类或模块 函数的边界在哪里;常常先提炼再搬移
以函数调用取代内联代码(第 24 章) 用已有的函数替掉手写的同样逻辑 创建一个还不存在的函数

下面这个函数给用户列表做统计,三段靠注释分开。把它提炼成三个函数:算平均年龄、按城市计数、把结果拼成报表。提炼完测试应当照常通过。

"""练习:用户统计报表。"""
def report_stats(users: list[dict]) -> str:
# 平均年龄
total_age = 0
for user in users:
total_age += user["age"]
average = total_age / len(users) if users else 0
# 各城市人数
by_city: dict[str, int] = {}
for user in users:
by_city[user["city"]] = by_city.get(user["city"], 0) + 1
# 报表
lines = [f"用户数:{len(users)}", f"平均年龄:{average:.1f}"]
for city in sorted(by_city):
lines.append(f"{city}:{by_city[city]} 人")
return "\n".join(lines)
if __name__ == "__main__":
print(report_stats([
{"name": "A", "age": 20, "city": "北京"},
{"name": "B", "age": 30, "city": "上海"},
{"name": "C", "age": 40, "city": "北京"},
]))
# 输出:
# 用户数:3
# 平均年龄:30.0
# 上海:1 人
# 北京:2 人
import unittest
from target import report_stats
USERS = [
{"name": "A", "age": 20, "city": "北京"},
{"name": "B", "age": 30, "city": "上海"},
{"name": "C", "age": 40, "city": "北京"},
]
class ReportStatsTest(unittest.TestCase):
def test_counts_and_average(self):
self.assertEqual(report_stats(USERS).splitlines()[:2], ["用户数:3", "平均年龄:30.0"])
def test_cities_sorted(self):
self.assertEqual(report_stats(USERS).splitlines()[2:], ["上海:1 人", "北京:2 人"])
def test_empty_list(self):
self.assertEqual(report_stats([]), "用户数:0\n平均年龄:0.0")
if __name__ == "__main__":
unittest.main()
// 练习:用户统计报表。
export interface User {
name: string;
age: number;
city: string;
}
export function reportStats(users: User[]): string {
// 平均年龄
let totalAge = 0;
for (const user of users) {
totalAge += user.age;
}
const average = users.length > 0 ? totalAge / users.length : 0;
// 各城市人数
const byCity: Record<string, number> = {};
for (const user of users) {
byCity[user.city] = (byCity[user.city] ?? 0) + 1;
}
// 报表
const lines = [`用户数:${users.length}`, `平均年龄:${average.toFixed(1)}`];
for (const city of Object.keys(byCity).sort()) {
lines.push(`${city}:${byCity[city]} 人`);
}
return lines.join("\n");
}
console.log(
reportStats([
{ name: "A", age: 20, city: "北京" },
{ name: "B", age: 30, city: "上海" },
{ name: "C", age: 40, city: "北京" },
]),
);
// 输出:
// 用户数:3
// 平均年龄:30.0
// 上海:1 人
// 北京:2 人
import test from "node:test";
import assert from "node:assert/strict";
import { reportStats, type User } from "./target";
const USERS: User[] = [
{ name: "A", age: 20, city: "北京" },
{ name: "B", age: 30, city: "上海" },
{ name: "C", age: 40, city: "北京" },
];
test("人数和平均年龄", () => {
assert.deepEqual(reportStats(USERS).split("\n").slice(0, 2), ["用户数:3", "平均年龄:30.0"]);
});
test("城市按名字排序", () => {
assert.deepEqual(reportStats(USERS).split("\n").slice(2), ["上海:1 人", "北京:2 人"]);
});
test("空列表", () => {
assert.equal(reportStats([]), "用户数:0\n平均年龄:0.0");
});
  • 提炼函数的判断标准是意图和实现是否分离,不是行数。读完才知道在干什么的代码,把“干什么”写成函数名。
  • 靠注释分段的函数,每条注释就是一个待提炼函数的名字。
  • 提炼时只读的变量变参数,被改的变量变返回值;要返回两个值,通常该拆成两个函数。
  • 内联函数是反向手法:函数体没有比函数名多说什么时,把它放回去。
  • 每提炼一个函数就跑一次测试。IDE 能自动完成提炼和内联的机械部分,判断留给你。