提炼函数 / 内联函数
提炼函数(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输出一个字没变,测试一个字没改,四个用例照样通过。再看前面的三个问题。
推送通知那个需求,现在是三行的事,不用复制任何东西:
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。这不是提炼函数本身做的,而是把折扣提成独立函数之后,那三个分支挤在一起的样子让人一眼看出它们只是在查表。很多重构都是这样,做完一个,下一个就自己冒出来。
怎么一步步做
Section titled “怎么一步步做”提炼函数的步骤。每一步之后都跑测试。
- 新建一个空函数,用“它做什么”来命名,不用“它怎么做”。
subtotal好,loop_over_items_and_add不好。想不出名字时先随便叫一个,提出来之后再改,提出来的代码往往会告诉你它该叫什么。 - 把要提炼的那段代码剪切到新函数里。
- 看这段代码用了哪些源函数的局部变量。只读不改的变量变成参数,比如
discount用到的subtotal变成参数amount。被这段代码赋值、之后还要用的变量变成返回值,比如累加出来的subtotal。如果一段代码要返回两个以上的值,通常说明它该拆成两段,各提一个函数,就像这里把列商品行和累计小计拆开。 - 在源函数原来的位置写上对新函数的调用。
- 跑测试。
- 看看别处有没有同样或相似的代码,有就也换成对新函数的调用。
步骤 1 到 4 在 VS Code、PyCharm、WebStorm 里都能一键完成:选中代码,Extract Function 或 Extract Method,输入名字,IDE 会自动分析变量并生成参数(IDE 可自动完成)。IDE 做不了的是步骤 3 里“该不该拆成两段”的判断和步骤 6。
内联函数的步骤相反:
- 确认这个函数不是某个会被子类重写的方法。会被重写的不能内联。
- 找到所有调用点。
- 把每个调用替换成函数体,一次换一个,每换一个跑一次测试。
- 删掉函数定义。
IDE 的 Inline 命令能一次完成第 2 到第 4 步(IDE 可自动完成)。
定义与适用场景
Section titled “定义与适用场景”提炼函数:把一段代码放进一个以其用途命名的函数里,并在原处调用它。判断该不该提炼的标准不是长度,而是“意图和实现是否分离”:如果你得读完一段代码才知道它在干什么,就把“它在干什么”写成函数名,把“怎么干”藏进函数体。一个六行的函数被提炼成三个两行的函数,完全正常。
它常出现在三种场合。一是过长函数靠注释分段,每条注释就是一个待提炼函数的名字。二是一段代码在别处也出现了,提炼出来之后第二处换成调用。三是准备做更大的重构,比如要搬移函数或提炼类,先把要搬的东西提成函数,搬起来就只是挪一个定义。
内联函数是它的反向手法。什么时候反过来做?当函数体和函数名说的是同一件事,没有多出任何信息的时候。下面的 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 }));
// 输出:// 加急// 普通另一个该内联的场合是一堆组织得不好的小函数互相调用,先把它们全部内联回一个大函数,看清全貌,再按新的理解重新提炼。
真实系统里的例子
Section titled “真实系统里的例子”Express 的路由处理器
Section titled “Express 的路由处理器”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: "li@example.com" } };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 读起来就是三句话:校验、构造、响应。
Django 的视图函数
Section titled “Django 的视图函数”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 返回的会是查询集的过滤条件,但形状一样:视图只剩“拼条件、查、返回”三步,拼条件的部分可以单独测试,也能被列表页和导出接口共用。
什么时候别用
Section titled “什么时候别用”提炼过头的样子是一屏幕三行函数,每个只被调用一次,读主流程时要在十几个函数之间来回跳。判断标准还是意图和实现:如果一段代码的意图一眼就能看出来,提炼它只会增加一次跳转。x + 1 不需要变成 increment(x)。
名字起不好时先别提炼。一个叫 process_data 或 do_step_2 的函数比原地的代码更难懂,因为读者既要读函数体,又要怀疑这个名字是不是在骗人。提炼的价值全在名字里,没有好名字就没有价值。
在极少数性能敏感的热循环里,函数调用的开销可能被测量出来。但这是测量之后再说的事,不是预先回避提炼的理由。第 58 章会讲重构和性能怎么配合。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 提炼函数关心什么 |
|---|---|---|
| 提炼变量(第 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 能自动完成提炼和内联的机械部分,判断留给你。