读《Fluent Python》到一个阶段之后,最大的感受是:这不是一本语法手册,它一直在教你换一种方式理解 Python 对象模型。第 6 章“使用一等函数实现设计模式”尤其如此,它把“设计模式”这个话题拉到 Python 的实际表达能力里重新审视。我读完这一章的第一反应是:以前我在业务代码里写的不少“类架构”,本质上只是在给“一个函数”披上好几层外衣。
这一章以策略模式、命令模式为主线,展示了当函数是一等公民时,经典设计模式可以变得多么直接。我用连续两个周末,把书里的例子亲手改写成“类版本”和“函数版本”两个对照工程,放在同一个目录里反复对比。这篇内容就是从那几轮对比里整理出来的,适合正在啃这本书的读者,也适合那些在 Python 项目里纠结“要不要为了一个模式去增加一堆类”的工程师。我会把每一步的思考、代码和实际踩过的坑都摊开来讲清楚。
1. 为什么这一章改变了我的编码习惯
1.1 模式是语言缺失特性的补偿
讨论设计模式时,经典书里最常被忽略的一句话是:很多模式的存在,本身是对某种语言能力不足的补偿。当一门语言无法直接把一段行为传递给另一段代码时,你只能把这个行为包装成对象,再把这个对象传过去。这就有了“策略类”“命令类”等一大堆结构性产物。
Java 风格的经典模式里,行为传递通常依赖接口和类层次,因为函数在语言里不是可以独立存在的值。你把“打 8 折”这个逻辑单独拿出来,想在运行时作为一个参数传给业务函数,在那种语境下必须写一个实现类。而 Python 在语言层面就让函数对象可以当作值传递,所以问题是:你还有必要为了“传递行为”去创建一个类吗?
第 6 章给我的核心顿悟是:你不是在“放弃设计模式”,而是在“用 Python 的语言能力替代模式的必要性”。这个认知转换很重要,因为它避免了两个极端——一个极端是每个场景都照着类图套代码,完全不顾 Python 的简洁性;另一个极端是干脆什么模式都不讲,遇到重复修改需求就堆 if-else。真正舒服的做法是理解模式要解决的问题,然后用函数、闭包、字典这类语言内建能力去落地。
1.2 一等函数、可调用对象与行为注册表
要把这一章读透,有三个基本概念必须先在脑子里建立起来:函数是对象;任何实现了__call__方法的实例都是可调用对象;函数对象可以放进列表、字典,也可以作为参数继续传递给其他函数。
这些概念组合起来,会产生一种在静态语言里不容易见到的结构:行为注册表。举个例子,你在程序启动时维护一个字典,把业务类型字符串映射到处理函数,运行时只需要根据类型去字典里取出函数再调用。这不是什么稀奇技巧,很多框架内部几十年前就在这么干了,但在设计模式的语境下重新看,它其实就是“策略模式”的一等函数版本。
我在自己的项目里最早引入类似的字典分派,是因为业务里需要按“渠道类型”走不同的计价逻辑。一开始写的版本就是经典策略类,后来发现每个类里只有一两个方法,反复扩展类文件,最后终于把整套逻辑删掉,换成了一个模块级别的字典,代码量少了一半以上。这个体验让我对第 6 章的内容产生了强烈共鸣。
2. 策略模式:从一堆“策略类”到一个函数字典
2.1 经典写法:接口、策略实现类、上下文对象
为了让对比足够清楚,我选了一个非常常见的场景:订单计算最终价格时,根据用户类型选择不同折扣策略。
如果按传统静态语言的设计思路,大概会先抽象一个策略接口,再写几个实现类,然后让上下文对象持有策略实例:
from abc import ABC, abstractmethod from dataclasses import dataclass class DiscountStrategy(ABC): @abstractmethod def apply(self, price: float) -> float: """输入原价,返回折扣后的价格""" class NormalStrategy(DiscountStrategy): def apply(self, price: float) -> float: return price class VipStrategy(DiscountStrategy): def apply(self, price: float) -> float: return price * 0.8 class StaffStrategy(DiscountStrategy): def apply(self, price: float) -> float: return price * 0.6 @dataclass class Order: price: float strategy: DiscountStrategy def final_price(self) -> float: return self.strategy.apply(self.price) order = Order(price=100.0, strategy=VipStrategy()) print(order.final_price()) # 80.0这段代码的问题在哪?策略本身只有一个apply方法,而且大多数情况下,策略是没有内部状态的纯函数。为了传递这一点计算逻辑,你不得不用类包装,继承接口,实例化后再传给上下文。类在这里承担的责任只有一个:把行为变成一个可传递的对象。
当策略数量增多时,这个问题会更明显。每增加一种用户类型,你就要新建一个类文件、重写接口继承、保持命名一致。刚开始写觉得清楚,维护到第十个策略类的时候,你就会开始犹豫:这里是不是有点过度设计了?
2.2 一等函数改写:函数字典与查找表
同样的需求,用一等函数写会变成什么样?我可以直接从定义一个顶层函数开始,然后把函数放进字典:
def normal_discount(price: float) -> float: return price def vip_discount(price: float) -> float: return price * 0.8 def staff_discount(price: float) -> float: return price * 0.6 DISCOUNT_STRATEGIES = { "normal": normal_discount, "vip": vip_discount, "staff": staff_discount, } def final_price(price: float, strategy_name: str) -> float: strategy = DISCOUNT_STRATEGIES[strategy_name] return strategy(price)对比之下,变化非常明显:接口定义没了,实现类没了,上下文对象也没了。策略就是一组有名字的函数,被放进同一个字典里统一管理。调用时要选择哪种策略,等价于从字典里查一个 key。
这种写法还能自然扩展。比如策略需要额外参数时,可以接着用functools.partial做一次部分应用,把配置参数先绑定进去,生成一个“无参数或更少参数”的新函数,再放进注册表。例如:
from functools import partial def season_discount(price: float, rate: float) -> float: return price * rate DISCOUNT_STRATEGIES = { "normal": normal_discount, "vip": vip_discount, "staff": staff_discount, "summer": partial(season_discount, rate=0.85), }这比新建一个类更接近“策略是什么就是什么”的本质,策略本身就是一段可供复用的算法逻辑,把算法作为值保存、传递和查找,是函数式写法最舒服的状态。
2.3 什么时候保留类,什么时候换函数
函数写法很爽,但我并不主张把一切都推平重写。这里需要一个判断标准。我用一个原则来指导自己的重构:策略是否无状态、是否只需要一个公开入口。满足这两点时,函数字典是最优选。
如果策略本身需要维护历史记录、需要多次调用之间积累状态,或者策略内部有多个公开操作需要暴露给外部,那就不能硬塞进一个裸函数里。这时候用类或者闭包工厂更合理。还有一种情况是策略需要在启动时从配置文件加载大量参数,这种情况我更推荐写一个“策略工厂函数”,用函数生成策略,而不是直接写策略类。
单纯地觉得“函数很优雅”就到处替换类,会走进另一个坑。模式也好,函数也好,都是为了降低修改成本。关键是保持代码在变化面前容易调整,而不是为了表达某种风格去炫技。
3. 命令模式:把“发号施令”的对象换成闭包
3.1 命令对象最小实现
命令模式的目标是让“操作”本身可以被传递、排队、延迟执行和撤销。在编辑器、事务系统、任务队列里都很常见。经典的实现思路是定义 Command 接口,每个具体命令一个类,类里至少包含execute和undo两个方法。
一个典型的最小命令类可能长这样:
class InsertCommand: def __init__(self, doc, pos, text): self.doc = doc self.pos = pos self.text = text def execute(self): self.doc.insert(self.pos, self.text) def undo(self): self.doc.delete(self.pos, len(self.text))然后在上层维护一个命令队列或者撤销栈:
class CommandManager: def __init__(self): self.history = [] def run(self, command): command.execute() self.history.append(command) def undo(self): command = self.history.pop() command.undo()这段代码逻辑没有错,但值得停下来想想:为了记录“在某位置插入某文本”这个操作,我们创建了一个类,它的属性和execute、undo方法,本质上就是在描述两个函数。那为什么不直接把这两个函数返回出来?
3.2 用闭包捕获状态,替代实例属性
函数式写法不需要把参数先存到self上,而是让内部函数通过闭包捕获外部变量:
def insert_command(doc, pos, text): def execute(): doc.insert(pos, text) def undo(): doc.delete(pos, len(text)) return execute, undo上层管理器可以改成直接接收一对函数:
class CommandManager: def __init__(self): self.undo_stack = [] self.logs = [] def run(self, execute_fn, undo_fn, label=""): execute_fn() self.undo_stack.append(undo_fn) self.logs.append(label) def undo(self): if not self.undo_stack: raise RuntimeError("nothing to undo") undo_fn = self.undo_stack.pop() undo_fn()对比之下,原来的self.doc、self.pos、self.text变成了闭包捕获的doc、pos、text。原来调用command.execute()和command.undo()的地方变成了直接调用两个函数。撤销栈里保存的不再是“命令对象”,而是一个个撤销函数。
这种做法对可测试性也更友好。想测试某个命令,不再需要构造完整的命令对象,只需要调用返回的两个函数并验证文档状态变化。命令之间的差异也被降到了最小,核心差异只是“函数执行什么”。
3.3 撤销、事务与队列:核心其实是顺序和状态
在真实项目里,命令模式经常被用来实现事务回滚。以前我把撤回操作想得很重,觉得需要一大堆命令类和序列化协议。后来实际做交易类业务时才发现,大多数情况下只需要两样东西:一个按时间顺序保存的“撤销函数列表”,和一个能逆序调用这些函数的事务控制方法。
事务本质上可以这样抽象:
def transaction(commands): for execute_fn, undo_fn in commands: execute_fn() return [undo_fn for _, undo_fn in reversed(commands)]回滚时直接逆序遍历撤销函数。如果中途某一步失败,也需要逆序执行已经成功步骤的撤销函数,保证状态不残留。这种结构完全不需要类层次。
不过有一个点要重点提示:闭包保存的是变量引用,不是变量快照。如果你在生成命令之后、执行命令之前修改了被捕获的外部变量,闭包里读到的会是新值。这个特性有时候是优点,有时候是惊吓。想要在捕获瞬间冻结值,可以用默认参数技巧:
def insert_command(doc, pos, text): def execute(pos=pos, text=text): doc.insert(pos, text) def undo(pos=pos, text=text): doc.delete(pos, len(text)) return execute, undo把参数写到默认值位置之后,闭包捕获的就不再是外层变量槽,而是两个固定的默认对象。我在第一次实现撤销功能时,就因为没有注意这个细节,导致连续插入多条命令后再撤销,撤销的位置全部指向了同一个变量的最新值。那段调试经历让我对这个坑印象特别深。
4. 再看几个可以函数化的模式
4.1 模板方法模式退化为“步骤参数”
模板方法模式原本描述的是:父类定义算法骨架,子类负责实现某些步骤。在 Python 里,算法骨架完全可以是一个普通函数,步骤用函数参数传入。
def price_pipeline(step_a, step_b, step_c): value = step_a() value = step_b(value) return step_c(value)你要切换步骤策略时,只需要替换传入的函数。这种写法的核心优势是:算法骨架不需要依赖继承,任何符合要求的函数都可以直接塞进来。相对类继承,函数组合的方式更容易看清楚“哪一个步骤发生了替换”,也更方便单独测试每一步。
如果你的步骤比较多,也可以把多个步骤函数组合在一个字典里,按场景分别选用不同的“步骤配置”。这又回到了注册表的思想。
4.2 装饰器与函数装饰器:名相近,意不同
说到模式,装饰器模式可能是最容易和 Python 装饰器语法混淆的一个。装饰器模式的核心是在不修改原对象的情况下,通过包装扩展功能,比如给文件流增加缓冲、加密、日志能力。Python 的@decorator语法则是更高层的函数修改手段。
两者不是一回事,但思想有相通之处。Python 的装饰器本质上是高阶函数:接收一个函数,返回一个新函数。设计模式里装饰器模式则是在对象层面包装接口。如果你在 Python 里想实现经典装饰器模式,还是可以照常定义包装类,但如果你只是想给某个函数加日志、加缓存、加权限校验,直接用装饰器或者functools.wraps会更符合语言习惯。
我在实际代码里最常用的做法是写一个带参数的工厂装饰器,把业务函数包装成带日志和重试能力的版本,完全没使用类图意义上的装饰器模式。这恰恰说明:语言特性有时候会直接覆盖模式的应用场景。
4.3 观察者、责任链等模式的函数式联想
观察者模式在 Python 里常常退化成“回调函数列表”。要通知一批订阅者时,你不需要维护订阅者对象,只需要维护一个函数列表,逐个调用。
subscribers = [] def subscribe(fn): subscribers.append(fn) def notify(event): for fn in subscribers: fn(event)责任链模式则可以用函数链实现。把处理函数按顺序放进列表,逐个尝试,满足条件就返回结果:
handlers = [handler_a, handler_b, handler_c] def process(event): for handler in handlers: result = handler(event) if result is not None: return result这些联想型的改写并不要求你立刻迁移代码,它更多是帮助建立一种“行为参数化”的直觉。读第 6 章时不要只背结论,重点是把思维从“类和继承”切换到“函数和数据”。
5. 踩坑记录:一等函数方案里藏着的细节
5.1 循环闭包的晚绑定陷阱
用闭包代替类后,最常见的问题来自延迟绑定。在循环里创建闭包并保存,循环结束之后调用所有闭包,结果往往会和直觉相反:
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())输出不是 0 1 2,而是 2 2 2。原因是闭包里的i绑定的是变量本身,循环结束后变量值停在最后一次迭代。这个问题在回调注册、批量命令生成时特别容易出现。
解决方案我前面已经提到:用默认参数固定当前值,或者用functools.partial。如果使用lambda,必须写成lambda i=i: i。如果使用partial,写法是partial(lambda x: x, i)。两者都能解决同一个问题,具体用哪种看可读性。
5.2 函数字典当分派表的坑
函数字典很好用,但使用不严谨时会踩三类坑。第一类是键重复覆盖,后注册的函数会悄悄替换先注册的函数,没有任何报错。第二类是未收录函数时的KeyError,一旦用户传入未知策略名,调用链会直接崩。第三类是签名不统一,字典里不同函数的参数数量和含义不一致,调用点要写很长一坨分支才能适配。
我现在的做法是:在注册阶段主动检查重复键,把函数统一设计成“一个 payload 参数”的形式,调用点只负责传入统一的数据结构,函数内部再去取自己需要的字段。这样既绕开了签名不统一的问题,又能让分派表保持干净。对于未知 key,使用dict.get加默认处理函数,避免关键路径上直接抛KeyError。
5.3 函数无类型的代价:依靠注解和文档补救
用函数替代类之后,最容易损失的其实是“可发现性”。在类写法里,IDE 可以通过类声明推断出上下文对象有哪些属性,函数写法里如果用裸字典,你看到的只是strategy(price)这样的调用,IDE 无法自动补全策略细节。
我的补救方案有三层。第一,所有顶层函数都写上完整类型注解,包括参数类型和返回类型。第二,函数字典用模块级常量命名,并且紧挨函数定义,方便读者顺藤摸瓜。第三,每个策略函数都写清楚 docstring,说明业务含义、参数约定和返回值。
这套做法不能完全替代类图的静态结构感,但它能很大程度减轻函数字典带来的“可读性迷雾”。对于那些需要被团队长期维护的公共模块,我还会额外写一段说明注释,解释这个字典的 key 规则以及新增策略的标准流程。
5.4 测试策略的差别
类版本和函数版本的测试策略也有明显差别。测试函数版本时更简单,直接 import 顶层函数,传入业务参数断言结果即可。测试命令闭包版本时更要小心上下文状态,因为闭包捕获了外部对象,断言时必须对捕获对象的变化做验证。
建议在测试命令模块时,使用真正的最小可复现文档对象,而不是用 Mock 代替,否则很容易出现 Mock 使用方法对、但真实对象行为不同而导致的问题。我遇到过类似情况:Mock 对象没有抛出预期异常,切换到真实对象之后才发现插入逻辑有边界缺陷。这类问题在类式命令里也一样存在,但闭包式命令的模拟对象设置会更隐晦,问题也更难定位。
6. 学习笔记之外的三点工程经验
6.1 写类之前先质问自己三个问题
经过第 6 章的学习之后,我给自己定了一条代码规范:新建类之前,先问三个问题。这个对象需要维护跨方法的状态吗?它需要提供多个独立公开操作吗?它需要通过类继承或协议进行身份识别吗?三个答案都是否,就用函数代替。
这条规范帮我减少了很多无意义的抽象。很多时候我们引入一个类,只是因为在别的语言里“这就是标准做法”,却忽略了 Python 本身拥有一等函数这个语言特性。把逻辑拆成顶层函数后,测试更容易、调用更直接、代码审查也更轻松。
6.2 用文档字符串承载设计意图
函数写法的短板之一是设计意图不容易一眼看出。为了弥补这个问题,我习惯在注册表顶部集中写一段说明,标明每个 key 对应的策略函数、更新策略的步骤、以及默认策略是谁。函数自身的 docstring 写清楚业务逻辑和边界条件,比如“价格为负数时如何处理、返回的是原价还是折扣价”。
这看起来像是小技巧,但在团队协作里非常关键。函数字典最大的危险就是“看起来很简单,改起来不知从哪下手”。清晰的注册表文档可以把维护成本降下来,也让后续接入的同事不需要去通读所有函数实现才能改一处配置。
6.3 后续想尝试的扩展
我还留着几个未完全想透的扩展点,比如如何把函数式策略和配置中心结合起来。目前我倾向于用functools.partial绑定配置生成策略函数,但配置变更后的热替换还需要做更多验证。另一个方向是把命令队列序列化到存储介质,这需要将函数名、模块路径和参数完整保存,闭包在这种场景下并不友好,具体怎么做还需要再看。
这些内容暂时没有形成稳定方案,等实验完成之后我再单独整理一篇。
这一章给我的直接收益是:代码量明显下降,策略和命令相关的逻辑跳转路径变得清晰,同事做代码评审时也更容易看出“哪一步调用了哪一个策略”。代价是刚上手时需要适应函数对象作值和闭包捕获这一套思维方式,踩两次坑之后就会顺手很多。如果你也在读这一章,建议不要把函数化当成唯一正确答案,而是把它当作一种新的视角,再回去重新审视你项目中已有的那些“必须用类”的理由,很可能会发现一半理由早就站不住脚了。