移除标记参数
移除标记参数(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 送达输出一行没变,三个测试照样通过。
排班系统要普通件日期,现在搜 regular_delivery_date 就找到了,调用处读出来就是“普通件送达日期”,不需要跳进去看参数名。
新疆、西藏改七天的需求只碰 regular_delivery_date,加急那张表在另一个函数里,改错分支的可能没有了。两个函数各自只有一张表,读起来就是一张时效表的样子。
“次日达”来了,就再写一个 next_day_delivery_date,现有两个函数和它们的调用方一行不用动。
注意 confirmation 的变化。它传的不是写死的 True 或 False,而是订单里的 order["rush"],所以重构后它自己多了一个三元表达式。这一处变得略微啰嗦,换来的是另外两处清楚了。如果一个模块里大多数调用方都像 confirmation 这样传变量,账就算不过来了,后面“什么时候别用”会说到。
怎么一步步做
Section titled “怎么一步步做”每一步之后都跑测试。
- 为标记参数的每个取值建一个明确的函数。如果原函数的主体本来就是一个按标记分开的
if,像delivery_date这样,就用第 35 章的分解条件表达式(Decompose Conditional),把两个分支各提成一个函数:rush_delivery_date和regular_delivery_date。原函数暂时保留,改成按标记调用这两个新函数。(提炼分支可以用 IDE 的 Extract Function 自动完成) - 逐个找到传字面量的调用方,
delivery_date(order, True)换成rush_delivery_date(order),delivery_date(order, False)换成regular_delivery_date(order)。每换一处跑一次测试。 - 处理传变量的调用方,比如
confirmation。可以在调用处写分支,也可以让它继续调原函数。本章选了前者,因为只有这一处。 - 原函数没有调用方之后,删掉它。
如果标记参数的用法没有这么整齐,比如它在函数中间影响一两行,分支前后还有大段共用的代码,那就不要硬拆函数体。改为包一层:
def rush_delivery_date(order: dict) -> date: return _delivery_date(order, True)
def regular_delivery_date(order: dict) -> date: return _delivery_date(order, False)原函数加下划线变成私有的,调用方只看见两个有名字的函数。函数体里的布尔参数还在,但它被关在了模块内部,不再出现在任何调用处。以后有空,再看函数体里能不能拆得更干净。
定义与适用场景
Section titled “定义与适用场景”移除标记参数:如果调用方用一个字面量参数来选择函数执行哪段逻辑,就为每个取值提供一个明确的函数,去掉这个参数。
判断一个参数是不是标记参数,看两点。第一,调用方传的是不是字面量: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 只是选分支,拆开是对的。
真实系统里的例子
Section titled “真实系统里的例子”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]
# 自己的函数要留布尔参数时,设成仅限关键字,调用处就不会出现裸的 Truedef 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# 2list_products_split 多出来的那个 if,会在每个需要动态排序方向的调用方重复一遍。sorted 的两个分支也只差“比较结果取反”这一点,不像 delivery_date 那样是两张不相干的表。这两个理由让 reverse 成了一个合格的布尔参数。
前端框架的 render(app, el, true)
Section titled “前端框架的 render(app, el, true)”自己写的小前端框架里常见这样的挂载函数:第三个参数为 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),一眼看出这是服务端渲染的页面。两个函数各自演化也互不干扰:接管模式以后要加“不一致时自动重新渲染”的选项,不会给普通挂载添一个它永远用不上的参数。
什么时候别用
Section titled “什么时候别用”标记的值大多来自数据时,别拆。像 sorted 的 reverse,像本章的 confirmation:如果十个调用方里有八个传的是变量,拆开就意味着八处各写一个 if,函数里少了一个分支,调用方多了八个。这时更好的办法是保留参数,把它设成仅限关键字(Python 的 *)或者换成一个有名字的选项对象(TS 的 { rush: true }),让调用处读得懂。
两个分支差别很小时,也别拆。如果标记只影响一行,比如决定日期格式里带不带星期几,拆出两个函数会让其余二十行重复一遍。这种参数更像一个配置项,给它一个好名字、一个默认值就够了。
标记参数不止一个时,别急着按组合拆。两个布尔参数拆成四个函数,三个就是八个,这时候问题已经不是标记参数了,而是这个函数承担了太多种变化。先看看是不是该用第 49 章的以命令取代函数(Replace Function with Command),或者第 38 章的以多态取代条件表达式(Replace Conditional with Polymorphism)。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 移除标记参数关心什么 |
|---|---|---|
| 函数参数化(第 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)
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):
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);}
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("发货通知发邮件", () => {});- 标记参数是调用方传字面量、函数拿它选分支的参数。调用处的
True读不懂,就是信号。 - 函数体本来就按标记分成两段时,用分解条件表达式各提一个函数;纠缠在一起时,先包两层有名字的函数,把布尔参数关进模块内部。
- 值来自数据的布尔参数可以留,但设成仅限关键字或放进选项对象,让调用处读得懂。
- 参数参与计算就参数化(第 43 章),参数只是开关就拆开,两个手法用的是同一个标准。