跳转到内容

移除标记参数

移除标记参数(Remove Flag Argument)把一个靠布尔参数切换行为的函数拆成几个函数,每种行为一个名字。 它解决的痛是:调用处写着 delivery_date(order, True),读的人得跳进函数里才知道这个 True 是什么意思。

小孟在一家网店负责物流模块。结算页要给顾客两个选项:加急和普通,各自显示预计送达日期;下单后的确认短信里,再按顾客选的那种写一遍日期。加急和普通的时效表不一样:加急对江浙一天、沪皖两天,其余三天,另加一天出库;普通对江浙沪两天、皖赣三天,其余四天,另加两天出库。小孟写了一个函数 delivery_date(order, is_rush),用第二个参数决定走哪张表。

"""发货日期:重构前。"""
from datetime import date, timedelta
def delivery_date(order: dict, is_rush: bool) -> date:
if is_rush:
if order["province"] in ("江苏", "浙江"):
delivery_time = 1
elif order["province"] in ("上海", "安徽"):
delivery_time = 2
else:
delivery_time = 3
return order["placed_on"] + timedelta(days=1 + delivery_time)
else:
if order["province"] in ("江苏", "浙江", "上海"):
delivery_time = 2
elif order["province"] in ("安徽", "江西"):
delivery_time = 3
else:
delivery_time = 4
return order["placed_on"] + timedelta(days=2 + delivery_time)
def shipping_options(order: dict) -> list[str]:
return [
f"加急:{delivery_date(order, True)} 前送达",
f"普通:{delivery_date(order, False)} 前送达",
]
def confirmation(order: dict) -> str:
return f"订单 {order['id']} 预计 {delivery_date(order, order['rush'])} 送达"
ZHEJIANG = {"id": 1001, "province": "浙江", "placed_on": date(2026, 10, 8), "rush": True}
GUANGDONG = {"id": 1002, "province": "广东", "placed_on": date(2026, 10, 8), "rush": False}
if __name__ == "__main__":
for line in shipping_options(ZHEJIANG):
print(line)
print(confirmation(ZHEJIANG))
print(confirmation(GUANGDONG))
# 输出:
# 加急:2026-10-10 前送达
# 普通:2026-10-12 前送达
# 订单 1001 预计 2026-10-10 送达
# 订单 1002 预计 2026-10-14 送达
// 发货日期:重构前。
export interface Order {
id: number;
province: string;
placedOn: string;
rush: boolean;
}
function addDays(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export function deliveryDate(order: Order, isRush: boolean): string {
if (isRush) {
let deliveryTime: number;
if (["江苏", "浙江"].includes(order.province)) deliveryTime = 1;
else if (["上海", "安徽"].includes(order.province)) deliveryTime = 2;
else deliveryTime = 3;
return addDays(order.placedOn, 1 + deliveryTime);
} else {
let deliveryTime: number;
if (["江苏", "浙江", "上海"].includes(order.province)) deliveryTime = 2;
else if (["安徽", "江西"].includes(order.province)) deliveryTime = 3;
else deliveryTime = 4;
return addDays(order.placedOn, 2 + deliveryTime);
}
}
export function shippingOptions(order: Order): string[] {
return [`加急:${deliveryDate(order, true)} 前送达`, `普通:${deliveryDate(order, false)} 前送达`];
}
export function confirmation(order: Order): string {
return `订单 ${order.id} 预计 ${deliveryDate(order, order.rush)} 送达`;
}
export const ZHEJIANG: Order = { id: 1001, province: "浙江", placedOn: "2026-10-08", rush: true };
export const GUANGDONG: Order = { id: 1002, province: "广东", placedOn: "2026-10-08", rush: false };
for (const line of shippingOptions(ZHEJIANG)) {
console.log(line);
}
console.log(confirmation(ZHEJIANG));
console.log(confirmation(GUANGDONG));
// 输出:
// 加急:2026-10-10 前送达
// 普通:2026-10-12 前送达
// 订单 1001 预计 2026-10-10 送达
// 订单 1002 预计 2026-10-14 送达

测试走结算页和确认短信这两个入口。重构会拆掉 delivery_date,但这两个函数的签名不变,同一份测试能跑重构前后两个版本:

import unittest
from target import GUANGDONG, ZHEJIANG, confirmation, shipping_options
class DeliveryTest(unittest.TestCase):
def test_options_near_province(self):
self.assertEqual(shipping_options(ZHEJIANG), ["加急:2026-10-10 前送达", "普通:2026-10-12 前送达"])
def test_options_far_province(self):
self.assertEqual(shipping_options(GUANGDONG), ["加急:2026-10-12 前送达", "普通:2026-10-14 前送达"])
def test_confirmation_follows_customer_choice(self):
self.assertEqual(confirmation(ZHEJIANG), "订单 1001 预计 2026-10-10 送达")
self.assertEqual(confirmation(GUANGDONG), "订单 1002 预计 2026-10-14 送达")
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { GUANGDONG, ZHEJIANG, confirmation, shippingOptions } from "./target";
test("近处省份的两个选项", () => {
assert.deepEqual(shippingOptions(ZHEJIANG), ["加急:2026-10-10 前送达", "普通:2026-10-12 前送达"]);
});
test("远处省份的两个选项", () => {
assert.deepEqual(shippingOptions(GUANGDONG), ["加急:2026-10-12 前送达", "普通:2026-10-14 前送达"]);
});
test("确认短信按顾客的选择", () => {
assert.equal(confirmation(ZHEJIANG), "订单 1001 预计 2026-10-10 送达");
assert.equal(confirmation(GUANGDONG), "订单 1002 预计 2026-10-14 送达");
});

如果现在要加一个需求:仓库的排班系统要按普通件的承诺日期安排拣货。负责排班的同事打开物流模块,在结算页的代码里看到 delivery_date(order, True) 和 delivery_date(order, False)。哪一个是普通件?他得点进函数,看到参数名 is_rush,再回来对照。如果参数名当初起成了 express 或 fast,他还得读函数体,看两个分支里哪个天数少。一个本该一眼看懂的调用,要在两个文件之间来回跳一趟。

再加一个需求:普通件对新疆、西藏改成七天。改动只涉及普通件那张表,可小孟得打开一个同时装着两张表的函数,在 else 分支里找到位置。两张表的省份写法很像,("江苏", "浙江") 和 ("江苏", "浙江", "上海") 只差一个词,改错分支的事并不稀奇,而且改错之后加急件的日期变了,测试如果只覆盖了普通件,就发现不了。

第三个问题在函数的形状上。delivery_date 其实是两个互不相干的函数被一个 if 粘在了一起:两个分支没有共用的变量,没有共用的步骤,连“出库要几天”这样的常数都不一样。它们的唯一共同点是返回类型。粘在一起的结果是,任何一边的改动都要在另一边的眼皮底下做。

最后,布尔参数会繁殖。下个季度如果再来一个“次日达”,最省事的改法是再加一个参数 is_next_day,调用处变成 delivery_date(order, False, True),两个布尔值四种组合,其中“既加急又次日达”是什么意思谁也说不清。

用第 2 章的名字说,这是过长参数列表的一种:参数不多,但其中一个参数不是数据,而是在告诉函数“你这次扮演哪个函数”。这种参数就叫标记参数(flag argument)。

"""发货日期:重构后。"""
from datetime import date, timedelta
def rush_delivery_date(order: dict) -> date:
if order["province"] in ("江苏", "浙江"):
delivery_time = 1
elif order["province"] in ("上海", "安徽"):
delivery_time = 2
else:
delivery_time = 3
return order["placed_on"] + timedelta(days=1 + delivery_time)
def regular_delivery_date(order: dict) -> date:
if order["province"] in ("江苏", "浙江", "上海"):
delivery_time = 2
elif order["province"] in ("安徽", "江西"):
delivery_time = 3
else:
delivery_time = 4
return order["placed_on"] + timedelta(days=2 + delivery_time)
def shipping_options(order: dict) -> list[str]:
return [
f"加急:{rush_delivery_date(order)} 前送达",
f"普通:{regular_delivery_date(order)} 前送达",
]
def confirmation(order: dict) -> str:
when = rush_delivery_date(order) if order["rush"] else regular_delivery_date(order)
return f"订单 {order['id']} 预计 {when} 送达"
ZHEJIANG = {"id": 1001, "province": "浙江", "placed_on": date(2026, 10, 8), "rush": True}
GUANGDONG = {"id": 1002, "province": "广东", "placed_on": date(2026, 10, 8), "rush": False}
if __name__ == "__main__":
for line in shipping_options(ZHEJIANG):
print(line)
print(confirmation(ZHEJIANG))
print(confirmation(GUANGDONG))
# 输出:
# 加急:2026-10-10 前送达
# 普通:2026-10-12 前送达
# 订单 1001 预计 2026-10-10 送达
# 订单 1002 预计 2026-10-14 送达
// 发货日期:重构后。
export interface Order {
id: number;
province: string;
placedOn: string;
rush: boolean;
}
function addDays(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export function rushDeliveryDate(order: Order): string {
let deliveryTime: number;
if (["江苏", "浙江"].includes(order.province)) deliveryTime = 1;
else if (["上海", "安徽"].includes(order.province)) deliveryTime = 2;
else deliveryTime = 3;
return addDays(order.placedOn, 1 + deliveryTime);
}
export function regularDeliveryDate(order: Order): string {
let deliveryTime: number;
if (["江苏", "浙江", "上海"].includes(order.province)) deliveryTime = 2;
else if (["安徽", "江西"].includes(order.province)) deliveryTime = 3;
else deliveryTime = 4;
return addDays(order.placedOn, 2 + deliveryTime);
}
export function shippingOptions(order: Order): string[] {
return [`加急:${rushDeliveryDate(order)} 前送达`, `普通:${regularDeliveryDate(order)} 前送达`];
}
export function confirmation(order: Order): string {
const when = order.rush ? rushDeliveryDate(order) : regularDeliveryDate(order);
return `订单 ${order.id} 预计 ${when} 送达`;
}
export const ZHEJIANG: Order = { id: 1001, province: "浙江", placedOn: "2026-10-08", rush: true };
export const GUANGDONG: Order = { id: 1002, province: "广东", placedOn: "2026-10-08", rush: false };
for (const line of shippingOptions(ZHEJIANG)) {
console.log(line);
}
console.log(confirmation(ZHEJIANG));
console.log(confirmation(GUANGDONG));
// 输出:
// 加急:2026-10-10 前送达
// 普通:2026-10-12 前送达
// 订单 1001 预计 2026-10-10 送达
// 订单 1002 预计 2026-10-14 送达

输出一行没变,三个测试照样通过。

重构前三处调用都经过 delivery_date 里的 if 分支;重构后结算页直接调用两个各有名字的函数,只有确认短信因为标记来自订单数据而保留了一个分支

排班系统要普通件日期,现在搜 regular_delivery_date 就找到了,调用处读出来就是“普通件送达日期”,不需要跳进去看参数名。

新疆、西藏改七天的需求只碰 regular_delivery_date,加急那张表在另一个函数里,改错分支的可能没有了。两个函数各自只有一张表,读起来就是一张时效表的样子。

“次日达”来了,就再写一个 next_day_delivery_date,现有两个函数和它们的调用方一行不用动。

注意 confirmation 的变化。它传的不是写死的 True 或 False,而是订单里的 order["rush"],所以重构后它自己多了一个三元表达式。这一处变得略微啰嗦,换来的是另外两处清楚了。如果一个模块里大多数调用方都像 confirmation 这样传变量,账就算不过来了,后面“什么时候别用”会说到。

每一步之后都跑测试。

  1. 为标记参数的每个取值建一个明确的函数。如果原函数的主体本来就是一个按标记分开的 if,像 delivery_date 这样,就用第 35 章的分解条件表达式(Decompose Conditional),把两个分支各提成一个函数:rush_delivery_date 和 regular_delivery_date。原函数暂时保留,改成按标记调用这两个新函数。(提炼分支可以用 IDE 的 Extract Function 自动完成)
  2. 逐个找到传字面量的调用方,delivery_date(order, True) 换成 rush_delivery_date(order),delivery_date(order, False) 换成 regular_delivery_date(order)。每换一处跑一次测试。
  3. 处理传变量的调用方,比如 confirmation。可以在调用处写分支,也可以让它继续调原函数。本章选了前者,因为只有这一处。
  4. 原函数没有调用方之后,删掉它。

如果标记参数的用法没有这么整齐,比如它在函数中间影响一两行,分支前后还有大段共用的代码,那就不要硬拆函数体。改为包一层:

def rush_delivery_date(order: dict) -> date:
return _delivery_date(order, True)
def regular_delivery_date(order: dict) -> date:
return _delivery_date(order, False)

原函数加下划线变成私有的,调用方只看见两个有名字的函数。函数体里的布尔参数还在,但它被关在了模块内部,不再出现在任何调用处。以后有空,再看函数体里能不能拆得更干净。

移除标记参数:如果调用方用一个字面量参数来选择函数执行哪段逻辑,就为每个取值提供一个明确的函数,去掉这个参数。

判断一个参数是不是标记参数,看两点。第一,调用方传的是不是字面量:True、False、"rush" 这类写死的值。传进来的如果是计算结果或者用户输入,它就是数据,不是标记。第二,它是不是在控制流程:参数只被拿去做 if 判断,而不是参与计算。round(x, 2) 里的 2 参与计算,不是标记;delivery_date(order, True) 里的 True 决定走哪段代码,是标记。

标记参数常出现在“先写了一种,后来加了第二种”的函数上。最初只有普通件,后来加急件来了,最快的改法是给原函数加个参数、加个 if。这种增长本身没错,错在没有及时把它拆开。另一个高发地是 UI 代码里的 render(show_header=True)、format(compact=False) 之类,一个组件的参数里攒着五六个布尔值,每个调用处都是一串 true, false, true。

本章的反向手法是第 43 章的函数参数化(Parameterize Function):几个函数只差一个值,就合成一个带参数的函数。两者看上去方向相反,其实标准一致:参数是数据,就参数化;参数是开关,就拆开。raise_salary(person, 0.1) 的 0.1 参与计算,合成一个函数是对的;delivery_date(order, True) 的 True 只是选分支,拆开是对的。

Python 的 sorted(reverse=True) 为什么保留

Section titled “Python 的 sorted(reverse=True) 为什么保留”

Python 内置的 sorted 有一个布尔参数 reverse,标准库没有把它拆成 sorted_asc 和 sorted_desc。原因正是上面的第一条判断标准:排序方向往往是数据。列表页的排序方向来自请求参数 ?order=desc,如果只有两个函数,每个调用方都得自己写分支选函数。另外,reverse 是仅限关键字参数,调用处必须写成 reverse=True,不会出现一个光秃秃的 True,读的人不用猜。下面模拟一个商品列表接口,对比两种设计,最后给自己的函数也用上这个办法。

"""sorted(reverse=...):标记参数何时可以留着。"""
PRODUCTS = [("手冲壶", 199), ("咖啡豆", 68), ("滤纸", 15)]
def price(product: tuple[str, int]) -> int:
return product[1]
def list_products(params: dict) -> list[str]:
# 排序方向来自请求参数 ?order=desc,是数据而不是写死的值
descending = params.get("order") == "desc"
rows = sorted(PRODUCTS, key=price, reverse=descending)
return [name for name, _ in rows]
# 假如只有两个函数,每个调用方都得自己写分支来选
def sorted_asc(items: list, key) -> list:
return sorted(items, key=key)
def sorted_desc(items: list, key) -> list:
return sorted(items, key=key, reverse=True)
def list_products_split(params: dict) -> list[str]:
if params.get("order") == "desc":
rows = sorted_desc(PRODUCTS, price)
else:
rows = sorted_asc(PRODUCTS, price)
return [name for name, _ in rows]
# 自己的函数要留布尔参数时,设成仅限关键字,调用处就不会出现裸的 True
def delivery_days(province: str, *, rush: bool) -> int:
handling = 1 if rush else 2
return handling + (1 if province in ("江苏", "浙江") else 3)
if __name__ == "__main__":
print(list_products({"order": "desc"}))
print(list_products({}))
print(list_products_split({"order": "desc"}) == list_products({"order": "desc"}))
print(delivery_days("浙江", rush=True))
# 输出:
# ['手冲壶', '咖啡豆', '滤纸']
# ['滤纸', '咖啡豆', '手冲壶']
# True
# 2

list_products_split 多出来的那个 if,会在每个需要动态排序方向的调用方重复一遍。sorted 的两个分支也只差“比较结果取反”这一点,不像 delivery_date 那样是两张不相干的表。这两个理由让 reverse 成了一个合格的布尔参数。

自己写的小前端框架里常见这样的挂载函数:第三个参数为 true 时表示页面已经由服务端渲染好了 HTML,客户端只需要“接管”它(hydrate),绑定事件而不重新生成 DOM。调用处的 render(app, el, true) 读不出这层意思,而且两种模式的逻辑差别很大:一个生成 HTML,一个校验已有 HTML 是否一致。React 把这两件事做成了两个函数,React 18 里是 createRoot 和 hydrateRoot。下面用一个普通对象模拟 DOM 容器。

// 模拟前端框架的挂载入口,容器用普通对象代替 DOM 元素。
interface Container {
html: string;
listeners: number;
}
type App = () => string;
// 改造前:第三个参数 true 表示"服务端已经渲染过 HTML,只需接管"。
function render(app: App, container: Container, hydrate: boolean): string {
if (hydrate) {
if (container.html !== app()) return "警告:服务端 HTML 与客户端不一致";
container.listeners += 1;
return "接管已有 HTML,绑定事件";
}
container.html = app();
container.listeners += 1;
return "生成 HTML,绑定事件";
}
// 改造后:两个函数,名字说清各做什么。
function mountApp(app: App, container: Container): string {
container.html = app();
container.listeners += 1;
return "生成 HTML,绑定事件";
}
function hydrateApp(app: App, container: Container): string {
if (container.html !== app()) return "警告:服务端 HTML 与客户端不一致";
container.listeners += 1;
return "接管已有 HTML,绑定事件";
}
const app: App = () => "<h1>你好</h1>";
console.log(render(app, { html: "", listeners: 0 }, false));
console.log(mountApp(app, { html: "", listeners: 0 }));
console.log(render(app, { html: "<h1>你好</h1>", listeners: 0 }, true));
console.log(hydrateApp(app, { html: "<h1>你好</h1>", listeners: 0 }));
console.log(hydrateApp(app, { html: "<h1>旧内容</h1>", listeners: 0 }));
// 输出:
// 生成 HTML,绑定事件
// 生成 HTML,绑定事件
// 接管已有 HTML,绑定事件
// 接管已有 HTML,绑定事件
// 警告:服务端 HTML 与客户端不一致

拆开以后,入口文件里写的是 hydrateApp(app, el),一眼看出这是服务端渲染的页面。两个函数各自演化也互不干扰:接管模式以后要加“不一致时自动重新渲染”的选项,不会给普通挂载添一个它永远用不上的参数。

标记的值大多来自数据时,别拆。像 sorted 的 reverse,像本章的 confirmation:如果十个调用方里有八个传的是变量,拆开就意味着八处各写一个 if,函数里少了一个分支,调用方多了八个。这时更好的办法是保留参数,把它设成仅限关键字(Python 的 *)或者换成一个有名字的选项对象(TS 的 { rush: true }),让调用处读得懂。

两个分支差别很小时,也别拆。如果标记只影响一行,比如决定日期格式里带不带星期几,拆出两个函数会让其余二十行重复一遍。这种参数更像一个配置项,给它一个好名字、一个默认值就够了。

标记参数不止一个时,别急着按组合拆。两个布尔参数拆成四个函数,三个就是八个,这时候问题已经不是标记参数了,而是这个函数承担了太多种变化。先看看是不是该用第 49 章的以命令取代函数(Replace Function with Command),或者第 38 章的以多态取代条件表达式(Replace Conditional with Polymorphism)。

手法 它关心什么 移除标记参数关心什么
函数参数化(第 43 章) 几个函数只差一个参与计算的值,合成一个 一个函数靠开关参数切换两段逻辑,拆成几个
分解条件表达式(第 35 章) 把 if 的条件和分支提成有名字的函数 常常借它完成第 1 步,但目的是让调用处不再传开关
将查询函数和修改函数分离(第 42 章) 拆开“回答问题”和“修改状态” 拆开“两种行为”;给函数加 silent=True 这种开关,往往说明该先做第 42 章

下面的 send 用第三个参数决定发短信还是发邮件。把它拆成 send_urgent 和 send_normal 两个函数,去掉标记参数。on_payment_failed 和 on_shipped 的签名不变。

"""练习:通知发送。"""
def send(user: dict, text: str, urgent: bool) -> str:
if urgent:
return f"短信 {user['phone']}:【紧急】{text}"
return f"邮件 {user['email']}:{text}"
def on_payment_failed(user: dict) -> str:
return send(user, "付款失败,请更新支付方式", True)
def on_shipped(user: dict) -> str:
return send(user, "您的订单已发货", False)
USER = {"phone": "13800000000", "email": "[email protected]"}
if __name__ == "__main__":
print(on_payment_failed(USER))
print(on_shipped(USER))
# 输出:
# 短信 13800000000:【紧急】付款失败,请更新支付方式
# 邮件 [email protected]:您的订单已发货
import unittest
from target import USER, on_payment_failed, on_shipped
class NotifyTest(unittest.TestCase):
def test_payment_failure_goes_by_sms(self):
self.assertEqual(on_payment_failed(USER), "短信 13800000000:【紧急】付款失败,请更新支付方式")
def test_shipping_goes_by_email(self):
self.assertEqual(on_shipped(USER), "邮件 [email protected]:您的订单已发货")
if __name__ == "__main__":
unittest.main()
// 练习:通知发送。
export interface User {
phone: string;
email: string;
}
function send(user: User, text: string, urgent: boolean): string {
if (urgent) return `短信 ${user.phone}:【紧急】${text}`;
return `邮件 ${user.email}:${text}`;
}
export function onPaymentFailed(user: User): string {
return send(user, "付款失败,请更新支付方式", true);
}
export function onShipped(user: User): string {
return send(user, "您的订单已发货", false);
}
export const USER: User = { phone: "13800000000", email: "[email protected]" };
console.log(onPaymentFailed(USER));
console.log(onShipped(USER));
// 输出:
// 短信 13800000000:【紧急】付款失败,请更新支付方式
// 邮件 [email protected]:您的订单已发货
import test from "node:test";
import assert from "node:assert/strict";
import { USER, onPaymentFailed, onShipped } from "./target";
test("付款失败发短信", () => {
assert.equal(onPaymentFailed(USER), "短信 13800000000:【紧急】付款失败,请更新支付方式");
});
test("发货通知发邮件", () => {
assert.equal(onShipped(USER), "邮件 [email protected]:您的订单已发货");
});
  • 标记参数是调用方传字面量、函数拿它选分支的参数。调用处的 True 读不懂,就是信号。
  • 函数体本来就按标记分成两段时,用分解条件表达式各提一个函数;纠缠在一起时,先包两层有名字的函数,把布尔参数关进模块内部。
  • 值来自数据的布尔参数可以留,但设成仅限关键字或放进选项对象,让调用处读得懂。
  • 参数参与计算就参数化(第 43 章),参数只是开关就拆开,两个手法用的是同一个标准。