读《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(price100.0, strategyVipStrategy()) 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, rate0.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(pospos, texttext): doc.insert(pos, text) def undo(pospos, texttext): 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 ii: 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绑定配置生成策略函数但配置变更后的热替换还需要做更多验证。另一个方向是把命令队列序列化到存储介质这需要将函数名、模块路径和参数完整保存闭包在这种场景下并不友好具体怎么做还需要再看。这些内容暂时没有形成稳定方案等实验完成之后我再单独整理一篇。这一章给我的直接收益是代码量明显下降策略和命令相关的逻辑跳转路径变得清晰同事做代码评审时也更容易看出“哪一步调用了哪一个策略”。代价是刚上手时需要适应函数对象作值和闭包捕获这一套思维方式踩两次坑之后就会顺手很多。如果你也在读这一章建议不要把函数化当成唯一正确答案而是把它当作一种新的视角再回去重新审视你项目中已有的那些“必须用类”的理由很可能会发现一半理由早就站不住脚了。