读这一章的标题就觉得是对症的——当时我刚从一门“纯面向对象”的语言切换到 Python,心里一直有个疙瘩:那些在教科书里背过的策略模式、命令模式、模板方法模式,到了 Python 里写起来总感觉臃肿,明明一段函数就能说清楚的事,非要先定义接口、再写实现类、再把它塞进某个构造函数里。《Fluent Python》第 6 章“使用一等函数实现设计模式”把这层窗户纸捅破了:设计模式的很多“标准形态”其实是受了当初目标语言表达能力不足的拖累,而 Python 的函数是一等对象,可以大大简化这些模式。这篇文章适合两类人:一类是从静态类型语言转过来、还保留着“凡事建类”习惯的开发者;另一类是 Python 写了好几年,却从没认真想过“函数本身也是可以传来传去的对象”的朋友。下面就是我这轮读这一章整理的笔记、实战代码和踩坑记录。
1. 设计模式课里没讲的那个变量:Python 函数是一等对象
1.1 经典设计模式为什么那么“类中心主义”
二十世纪九十年代成形的经典设计模式,主要建模对象是 C++ 和 Smalltalk 这样的语言。C++ 里函数指针虽然存在,但语法不友好,用起来也容易出错,于是“接口 + 实现类”就成了一种通行做法。在 Java 里更是如此:想传一个行为给某个对象,你要么传一个对象过去,要么传一个接口的实现类过去。至于“把一个函数本身拿出来递来递去”,在很多纯面向对象时代的主流语言里几乎不是一种可被任意传递的平民操作。
结果就是,策略、命令、模板方法这些本质上和“行为传递”有关的模式,统统被改造成了“类的组合”。比如策略模式在教科书里的样子永远是:先定义一个策略接口,再写两个策略类,再把策略实例通过构造函数传给上下文类。对 Java 来说这确实是把行为参数化的近乎唯一方式,可它也被不少人迁移到了 Python 里,导致 Python 代码出现大量不必要的类。
生活里也好理解:想换一家餐厅的“口味”,Java 中你把一个“厨师”对象派过去,Python 中你干脆把“做菜”这个行为本身递过去。你关心的不是厨师这个人,而是“会做辣子鸡”这个能力。
1.2 一等函数到底“一等”在哪里
Python 里函数是一等对象,意味着它具备了普通对象不该被轻视的三种能力:可以在运行时创建,比如 def 语句本身就是一条可执行语句,执行到它的时候函数对象才诞生;可以赋给变量、放进列表字典等数据结构;可以作为参数传给别的函数,也可以作为函数的返回值返回。
def greet(name): return f"hello, {name}" # 函数可以赋给变量 say_hello = greet print(say_hello("python")) # hello, python # 函数可以放进数据结构 handlers = [greet, lambda n: f"hi, {n}"] for handler in handlers: print(handler("world")) # 函数可以作为返回值 def make_logger(prefix): def logger(msg): print(f"[{prefix}] {msg}") return logger info_logger = make_logger("INFO") info_logger("service started") # [INFO] service started第二个例子里的lambda只是语法糖,本质还是创建了一个函数对象并放进列表。理解了这一点,后面globals()收集策略、把函数塞进命令队列这些用法就顺理成章了。更进一步,任何实现了__call__方法的对象,也能表现得像函数,所以“用函数替代类”并不绝对排斥类,只要这个类创建出来的实例能当函数用就行。给后面带参数策略的解法埋个伏笔。
1.3 这一章真正的核心洞察:把行为当作数据
把策略模式和命令模式放在一起看,会发现它们的底层诉求是相似的:把一段行为抽取出来,让它可被传递、可被存储、可被替换。策略模式是把行为作为参数传给上下文;命令模式是把行为作为对象排队、记录、撤销。行为在这种视角下就是一种数据。
既然是数据,函数就是最轻量的一种“行为数据表示”。于是模式的外壳就坍缩了:策略类变成一个函数,命令类变成一个闭包或者可调用对象。模式完全没有消失,它只是换了一副朴素的嘴脸,在一个表达能力足够强的语言里,不再需要那些只是为了“把行为包起来”而存在的类。
2. 策略模式:从类爆炸到函数字典
2.1 先看熟悉的经典写法
我直接用 Python 模拟一个 Java 风格的实现,这样后面的对比才更有冲击力。场景是某个电商平台的订单折扣,平台希望按不同情况给订单计算折扣:老客户积分达到 1000 打 95 折;同一商品买满 20 件,该商品给 10% 折扣;一次性购买 10 种以上不同商品,整单打 93 折。
教科书式做法是这样的:
from abc import ABC, abstractmethod class Promotion(ABC): @abstractmethod def discount(self, order): pass class FidelityPromo(Promotion): def discount(self, order): return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 class BulkItemPromo(Promotion): def discount(self, order): for item in order.cart: if item.quantity >= 20: return item.total() * 0.1 return 0 class LargeOrderPromo(Promotion): def discount(self, order): distinct_items = {item.product for item in order.cart} if len(distinct_items) >= 10: return order.total() * 0.07 return 0订单类接收一个 Promotion 实例:
class Order: def __init__(self, customer, cart, promotion: Promotion): self.customer = customer self.cart = list(cart) self.promotion = promotion def total(self): return sum(item.total() for item in self.cart) def due(self): return self.total() - self.promotion.discount(self)这套代码没有毛病,只要平台促销规则继续变多,类就会跟着膨胀:每一条规则一个类,每个类占一个文件,文件之间只靠一个公共接口约束。真正痛苦的是当我要自动选择最优折扣时,还得再写一个选择器类,把 promo 对象们统一管理起来,于是回旋镖又一次飞了回来。
2.2 把策略写成普通函数,世界立刻干净了
同样三条规则,函数式版本看起来是这样:
def fidelity_promo(order): return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0 def bulk_item_promo(order): for item in order.cart: if item.quantity >= 20: return item.total() * 0.1 return 0 def large_order_promo(order): distinct_items = {item.product for item in order.cart} return order.total() * 0.07 if len(distinct_items) >= 10 else 0Order 类里不再需要保存“策略对象”,只需要保存一个“能根据订单算出折扣数值的函数”:
class Order: def __init__(self, customer, cart, promotion=None): self.customer = customer self.cart = list(cart) self.promotion = promotion def total(self): return sum(item.total() for item in self.cart) def due(self): if self.promotion is None: discount = 0 else: discount = self.promotion(self) return self.total() - discount调用方式变成了Order(joe, cart, promotion=fidelity_promo),传的是函数而非函数调用的结果。函数在这里天然承担了“接口”的职责:只要一个东西接收 Order 并返回一个数值,它就能当策略用。静态类型语言需要手动声明“我实现了 Promotion 接口”,Python 则是结构匹配,鸭子类型在函数层面成立。
2.3 自动收集策略:globals() 技巧与装饰器改进
书里还提供了一个经典的自动选择策略的方案。既然所有策略都是模块里的普通函数,就可以用globals()把当前模块全局命名空间里所有名字以_promo结尾的函数捞出来:
promos = [globals()[name] for name in globals() if name.endswith("_promo") and name != "best_promo"] def best_promo(order): return max(promo(order) for promo in promos)best_promo会自动比较所有策略,返回最高折扣。这背后的原理正是 1.2 节提到的:def是执行语句,模块在导入时所有函数已经被创建并绑定到了全局名字空间,所以globals()能拿到全部策略函数。新增促销时只要按_promo结尾命名函数,它就会被自动纳入比较,不需要改动best_promo本身,这很符合开闭原则。
但这个方案有一个我不太喜欢的点:它依赖“命名约定”。如果项目里全局空间混进了一个和其他业务函数重名的_promo函数,或者有人导入了第三方模块的同名函数,best_promo会静默选错。我自己的项目更偏爱显式注册表,用装饰器处理:
promo_registry = [] def promotion(func): promo_registry.append(func) return func @promotion def fidelity_promo(order): ... @promotion def bulk_item_promo(order): ... def best_promo(order): return max(promo(order) for promo in promo_registry)装饰器版本在函数定义完成后就立刻登记,语义清楚:凡是挂着@promotion的函数都是候选策略。即使多个模块各有一批促销函数,只要共享同一个注册表,也能统一管理。这一步是对书中思路的小改进,实际维护时省心不少。
2.4 遇到带参数策略:functools.partial 与可调用对象
上面三条策略都是“写死”的,可现实中的促销大多是参数化的:满 300 减 50、满 500 打 8 折之类。如果每个参数组合都写一个新函数,又会陷入另一种“函数爆炸”。
这时有两个顺手工具。第一是工厂函数配合functools.partial:
import functools def percentage_discount(rate, order): if order.total() >= 300: return order.total() * rate return 0 promo_8 = functools.partial(percentage_discount, rate=0.8)partial冻结了rate参数,剩下的order由调用方传入,因此promo_8依然是一个接收 Order 返回数值的合法策略。
第二是定义一个实现__call__的类,实例可以像函数一样被调用,同时还能携带状态:
class AmountDiscount: def __init__(self, min_total, discount): self.min_total = min_total self.discount = discount self.used_count = 0 def __call__(self, order): if order.total() >= self.min_total: self.used_count += 1 return self.discount return 0 Order(joe, cart, promotion=AmountDiscount(300, 50))选择哪种取决于你需不需要额外状态:只冻结参数用partial,需要累计调用次数之类状态就用可调用对象。两条路都让策略保持“可调用”的本质,不破坏整体设计。
3. 命令模式改造:撤销队列里存的变成了函数
3.1 命令模式想解决的问题
命令模式的目标是把“一次用户操作”封装成对象,这样操作可以被排队、记录日志、支持撤销和重做。典型场景是编辑器里的保存、粘贴、格式化,以及 GUI 工具里的按钮动作。Java 的做法是每个操作一个命令类,类里实现execute(),复杂一点的再补一个undo()。一个桌面应用几十个按钮,就是几十个命令类,每个类通常还长得差不多。
3.2 在 Python 里,函数本身就是命令
Python 的 GUI 和异步生态把这件事简化了很多:控件的command参数、事件回调、定时器的target参数,传入的都是一个普通函数。只要签名符合约定,函数就是命令。命令队列也可以直接存函数:
history = [] def save_document(doc): doc.save() history.append(doc) def undo_last_save(): if history: doc = history.pop() doc.restore_previous_version()命令需要携带上下文时,闭包就成了最顺手的工具。比如实现一个“粘贴”命令,它既要知道剪贴板在哪,也要知道文档对象是谁;与其定义一个 PasteCommand 类接受两个字段,不如在工厂函数里把上下文捕获进闭包:
def create_paste_command(clipboard, document): def execute(): text = clipboard.get_text() document.insert_text(text) undo_stack.append(undo_entry) def undo_entry(): document.remove_last_insert() return execute, undo_entry闭包的最大好处是上下文在生成命令的那个瞬间就被“冻”进去了,之后这个函数跑到哪里都不会丢上下文,也不需要额外维护实例字段。
3.3 一个能撤销重做的小框架
顺着上面的函数对思路,可以很轻松地搭一个最小撤销框架。命令就是一对函数:执行函数和撤销函数。
from collections import namedtuple Command = namedtuple("Command", ["do", "undo"]) class ActionHistory: def __init__(self): self._undo_stack = [] self._redo_stack = [] def execute(self, command): command.do() self._undo_stack.append(command) self._redo_stack.clear() def undo(self): if not self._undo_stack: return command = self._undo_stack.pop() command.undo() self._redo_stack.append(command) def redo(self): if not self._redo_stack: return command = self._redo_stack.pop() command.do() self._undo_stack.append(command)使用时,业务方只需把一个函数对交给ActionHistory.execute。对于“把画布背景调成红色”这种操作,撤销逻辑往往就是“把画布背景调回原来的颜色”,因此构造命令时先把旧值快照进闭包:
def change_background(canvas, new_color, history: ActionHistory): previous = canvas.background def do(): canvas.background = new_color def undo(): canvas.background = previous history.execute(Command(do, undo))你不用创建 BackgroundChangeCommand 类,也不需要维护一个 CanvasState 成员。整个设计从“命令对象 + 状态快照”变成了“闭包 + 自由变量”,可维护性反而更好,因为每个命令的行为和状态都写在同一处。
3.4 什么时候还是得用类
闭包的弱点同样明显:没法直接序列化,没法跨进程传输,也没有稳定的类型名和属性。如果应用需要把命令录制成宏保存到磁盘、在另一个进程里重放,或者命令需要通过网络传递,函数就会暴露短板。还有,当命令本身包含大量元数据、校验规则、依赖注入关系,而行为逻辑十分复杂时,类或数据类依然是更合适的选择。
我的判断标准比较简单:数据结构复杂度高于行为复杂度,就用类或dataclass;行为复杂度高、上下文只在本地内存里存活,就用函数和闭包。二者不是替代关系,而是不同强度等级的工具。
4. 模式形态背后的语言观:哪些模式被“函数化”吸收了
4.1 只有一个方法的对象,其实就是函数
经典设计模式里出现频率极高的“接口 + 实现类 + 单方法”组合,比如 Runnable、Command、Strategy、EventListener,它们的存在价值很大程度上是因为传统语言里函数无法作为普通值传递。Python 的标准库其实一直在用函数解决这类问题:sorted(numbers, key=str.lower)里的 key 是函数,threading.Thread(target=run_task)里的 target 是函数,GUI 里的回调也是函数。这些场景如果翻译成 Java,都得包一个类。
所以,读这一章时真正该建立的思维模式是:看到“一个接口只有一个方法”,先问问自己——在 Python 里,它是不是就是“一个普通函数”?
4.2 模板方法模式:把继承换成回调
模板方法模式的经典结构是定义算法的骨架,把可变化步骤留给子类重写。Java 用继承来实现这一点:抽象父类里写死骨架,子类覆盖 hook 方法。Python 里更自然的方式是直接把步骤作为函数参数传入,组合优于继承在函数世界里变得顺手很多。
def process_files(files, transform, before=None, after=None): if before: before() for file in files: transform(file) if after: after()原来的“子类重写步骤”变成“调用方传入策略函数”。对定义框架的人来说,骨架保留在process_files里;对调用方来说,不需要为了更换一个日志策略就新建一个子类。
4.3 访问者模式和新语法:模式的退场是一种进步
访问者模式之所以存在,是因为很多语言无法方便地“按运行时类型执行对应逻辑”,才需要借助“双重分派”来模拟。Python 3.10 引入模式匹配语句之后,很多访问者场景可以直接用match重写;functools.singledispatch也提供了另一种函数式的分派方式,同样是围绕单参数类型进行动态选择。
这说明语言特性进化后,一部分设计模式会退居幕后,甚至“消失”进语法里。读这一章给的最大启发不是“我可以用函数省三行代码”,而是需要学会判断:某段代码写成类,到底是因为业务真的需要类的层次结构,还是仅仅因为以前的语言只能这么做?在 Python 里,更合理的解法往往是把模式磨薄,让代码离问题本身更近。
5. 实操中的四个坑:从模式到代码的细节
5.1 自动收集策略的命名耦合
globals()扫描的问题是隐式依赖函数命名。函数一旦改了名,或模块里混入无关的xxx_promo,best_promo的行为立刻变得不可预料。更严重的是,如果同一个项目有多个模块,各自内部都用了globals()收集,策略之间的边界会变得模糊。
我建议把“注册表 + 装饰器”作为默认方案。装饰器版本还天然适合配合配置系统:想禁用某个策略时,只需在装饰器或注册表里加一个条件过滤,而不是去改调用方。
5.2 闭包晚绑定:循环里生成函数时的经典坑
命令模式函数化之后,最容易踩进去的是闭包晚绑定。看这个例子:
funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2]原因在于lambda中的i是自由变量,调用函数时才去外层作用域取当前值,而循环结束时i已经是 2。修复方式是用默认参数把当前值冻结:
funcs = [lambda i=i: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]在按钮命令、宏录制这类场景里忘记这一点,就会出现“所有按钮执行的都是最后一件事”的诡异 Bug。排查方法可以看命令是否是在循环里生成的,如果是,再检查是否用默认参数做了绑定。
注意:闭包保存的是变量的引用而不是值,所以只要变量的值在函数被调用前被修改,调用结果就会跟着变。想保留某个时刻的值,要么用默认参数,要么让变量不可变。
5.3 类型标注与结构化协议
函数策略写起来很轻量,类型标注却要注意。与其把所有策略都标注成某个公共接口,不如使用Callable,或者更严谨地使用typing.Protocol:
from typing import Protocol class DiscountStrategy(Protocol): def __call__(self, order: Order) -> float: ... def best_promo(order: Order) -> float: ...Protocol的好处是静态检查器可以验证函数签名是否匹配,运行时不强制继承,保持鸭子类型风格。对超过一定规模的项目,这种“运行时宽松、静态时严格”的平衡很有价值。
5.4 可读性与可测性
函数式策略写起来短,可维护时也有代价:如果只看best_promo,读者并不能一眼看出系统到底有哪些策略。这是可读性的隐性风险。我的习惯是把注册表放在模块顶层,并让注册表里的函数保持从属关系清晰;每个策略函数写清楚 docstring,包括适用条件和优先级;测试则直接对每个函数喂订单数据断言返回值,而不必绕一个 Order 再间接测试策略。
问题 解决方案 globals() 命名耦合 显式注册表 + 装饰器 循环生成 lambda 全指向最后值 默认参数绑定当前值 / 闭包工厂 类型标注不明确 Callable 或 Protocol 策略散落难以发现 注册表集中管理 + docstring这张表是目前我在实际项目中处理函数式策略时的自查清单。
6. 读完这章之后,我把旧项目重构了一版
落到最后说点自己动手的体验。我以前维护过一个“优惠计算器”模块,里面十来个策略类,每条促销规则一个文件,改一次规则要动好几个文件。读完这章后,我把它重构成了“函数 + 装饰器注册表”的结构:每个规则是一个普通函数,注册表放在模块最上方一眼能看见,选择最优策略的best_pricing只剩三行。两个月后来了新需求,要加一条“某分类商品满 3 件减 20”的规则,真正新写的代码就是一个函数加一个装饰器,调用方不需要碰。
测试也比以前好写。以前测折扣逻辑要实例化策略对象,现在直接给函数喂订单对象、断言返回值。唯一需要适应的是找策略的方式——传统面向对象那样通过类名找文件的方式行不通了,但注册表的存在让这个问题变得很轻。
也要说清楚,不是所有地方都要函数化。涉及序列化、跨进程、插件热加载时,数据类或类命令依然更合适。但我的默认思路确实变了:现在拿到需求,我会先问一句“能不能用一个函数表达它”,而不是“我该建哪个类”。这也算这章给我留下的最实际的东西。