1. 为什么这两个词总是被放在一起纠结这几年在技术社区里带新人或者帮朋友代码评审的时候我经常遇到同一个问题一个简单的功能比如读取配置文件、计算订单金额、发送通知有人说你就写个函数直接调简单直接另一个人却坚持要建一个类把它实例化出来再调用。两边都有道理最后往往争不出结果。这个问题其实不是技术能力问题而是很多人没有想清楚函数调用和模块实例化在底层到底做了什么事各自牺牲了什么、换来了什么。函数调用是一种执行流的分发方式模块实例化是一种状态和结构的组织方式。大多数人把它们当成两套风格偏好实际上它们解决的完全是两个层面的问题。函数调用关心的是一段代码怎么被触发、参数怎么传、结果怎么回模块实例化关心的是数据怎么存放、行为怎么绑定、生命周期怎么管理。你把这两件事混在一起谈自然会觉得怎么选都行。我最初也认为这是个人品味问题。直到有一次做一个消息推送系统同样的推送逻辑我用函数式封装写得很顺手同事用类封装写得也很顺手但到了需要给每个用户维护独立的发送频率限制、独立的重试队列的时候函数式的写法开始变得别扭——每次调用都要手动传一堆状态进去漏传一个就出问题。那时候我才真正意识到函数调用是轻量的瞬间行为模块实例化是有归属的长期状态两者的取舍远不止代码风格那么简单。这篇文章想做的事很直接把函数调用和模块实例化的底层机制拆开看理清楚各自承担了哪些成本、适合哪些场景、在什么情况下坚持某一方会后悔。不是说要你非此即彼而是要你拿到一个需求的时候能用一套清晰的筛选逻辑做出判断。2. 函数调用轻巧背后有代价2.1 一次函数调用到底发生了什么先别急着谈高级特性。我们从最朴素的角度看一次函数调用。在大多数主流语言里函数调用都会触发一套固定的机制当前执行位置返回地址被压栈函数参数被压栈或放到寄存器控制权转移到函数入口函数执行完后把返回值放回约定位置再恢复调用方的现场。这套机制的代价是纳秒级的现代 CPU 对它有深度优化你几乎感觉不到。真正需要留意的不是调用本身而是调用发生之后函数内部怎么处理数据。看一个最简单的例子def calculate_total(items, discount_rate): total 0 for item in items: total item[price] * item[quantity] return total * (1 - discount_rate)这个函数做三件事接收外部数据在内部产生局部变量total最终返回一个纯计算结果。它的特点是不管谁调用它、调用多少次只要输入相同输出就一定相同。它不依赖任何外部可变状态用完即走。这是函数调用的理想形态——我愿称它为干净函数。但这种干净不是白来的为了保持相同输入必定相同输出你必须在调用时把所有需要的数据都传进去。当业务变复杂比如需要判断用户的会员等级、需要查一下历史订单、需要知道当前的优惠活动是否生效这些额外信息要么全部变成参数要么函数内部自己去外部拿。全部变成参数的结果你已经猜到了参数列表变成七八个甚至十几个调用方要记住参数顺序漏传一个编译器帮你查出来还好如果是动态语言运行到一半才发现拿到了None那排查起来就很酸爽。函数内部自己去外部拿又会引入对模块级变量、全局配置的依赖函数的干净属性就消失了。2.2 无状态是优点也是边界函数调用的本质优势在于无状态。无状态意味着并发安全意味着可以随意被调度意味着不需要关心这个函数上一次调用留下了什么脏数据。这在处理请求、批量任务、纯计算场景下是巨大的优势。但反过来想无状态也意味着每件事都要从零开始。我举个生活化的例子。函数调用就像去便利店买东西每次结账时你都要重新掏出钱包、重新报会员号、重新说明你要什么买完东西提走下一次再去的时候店员不记得你上次买了什么。对于一次性交易这种方式干脆利落但如果你要的是一个长期客户关系希望店员记住你的偏好、你的余额、你的信用额度每次交易都从零开始就很折磨。编程场景里也是一样的。你要写一个 HTTP 客户端连接池需要保持Cookie 需要持久重试策略需要维护如果你用一组函数来实现那每次调用都要把这个客户端的状态作为参数传进去或者把它放在一个全局变量里。前者传得崩溃后者在并发环境下会产生各种奇奇怪怪的竞争问题。所以说函数调用适合的是那些不需要在调用之间保留状态的功能。它是一次快递把包裹送过去签收完事。它不是一座房子你住进去之后里面的一切应该保持上一次离开时的样子。2.3 函数调用的隐藏成本重复创建临时数据还有一类成本很多人没意识到。为了在无状态的函数之间传递数据你经常需要创建临时对象组装参数用的字典、处理中间结果的数组、拼接出来的字符串。这些对象在函数调用结束后就失去引用变成垃圾等待回收。如果你的系统里大量使用纯函数并且每个请求要调用几十上百次这部分临时对象的分配和回收压力是实打实的。也不是说这就不能优化很多语言有逃逸分析、栈上分配、JIT 内联等手段但前提是你能写出让编译器看得懂的代码。而一旦你的函数内部为了保持无状态频繁复制大对象那性能损耗比多 new 几个类还要严重。从这个角度看函数调用并不是零成本它只是把成本分散到了调用发生时和临时数据的创建上。大多数时候这些成本不足挂齿但当你处理高频路径比如每秒钟几万次的校验、格式化、日志打点时这部分开销就会出现在 profiling 报告里。3. 模块实例化买的不是代码是状态空间3.1 new 关键字背后发生了什么和函数调用走栈不同模块实例化通常体现为new SomeClass()或类似机制做了一件更重的事它会在堆上申请一块内存为这个实例绑定独立的属性存储空间然后执行构造函数完成初始化。这里的核心词是独立。class RateLimiter: def __init__(self, max_requests, window_seconds): self.max_requests max_requests self.window_seconds window_seconds self.records {} def allow(self, user_id, now): self._cleanup(user_id, now) count len(self.records.get(user_id, [])) if count self.max_requests: return False self.records.setdefault(user_id, []).append(now) return True这个限流器的核心不是allow方法本身而是records这个状态字段。每次调用allow它都会看到之前的调用留下的历史记录。你创建两个RateLimiter实例它们互不干扰各自维护自己的限流窗口。这就是实例化的价值它把一组数据变成一个有归属的对象并且让操作这些数据的行为自然地绑定在这个对象上。你不需要在每次调用时手动传递records不需要担心别人把别的数据塞进来构造函数确保了每个实例在诞生时就具备一套合理的初始状态。换个比喻函数调用是你打电话咨询客服每次都要描述一遍自己的问题模块实例化是你注册了一个专属客服他手边有你的档案你说老问题他就知道是什么。3.2 实例化的显性成本内存、初始化、生命周期既然是有归属的数据自然就有生命周期管理的成本。每个实例都会占用一份独立的堆内存。构造函数跑得再快也是一个额外的执行阶段。如果初始化涉及建立数据库连接、读取大文件、初始化复杂缓存那么这个成本会被放大成一次真正的 I/O 操作。你以为只是new一下实际上背后可能拉起了一个线程池。更隐蔽的成本是生命周期维护。实例化出来的对象什么时候销毁由谁负责销毁如果它是全局单例整个过程里只有一份那还好如果你在高频循环里反复创建和销毁实例那原本栈上几次压栈调用能解决的事就变成了堆上反复分配、GC 反复回收的事性能差距是数量级的。我见过不少代码在一个被循环十万次的逻辑里创建了一个新的客户端实例、一个格式化的转换器、一个事件总线每一轮循环都重新做一遍初始化然后立刻丢掉。明明这些工具对象是没有任何需要保留状态的变化点的完全可以用一个共享的模块级对象或直接写函数结果被生生写成了大开销的实现。这不是实例化的问题是不区分有状态和无状态就滥用实例化的结果。3.3 实例化真正的回报依赖注入、策略替换、可测试性成本这么高为什么业界还是大量使用类实例化因为它在可变性上提供了巨大的弹性。最典型的是依赖注入。你写一个OrderService构造函数接收一个PaymentGateway接口的具体实现。测试的时候注入一个内存版本的假网关线上注入真实的支付网关一行都不改业务逻辑。这种灵活性是纯函数很难给的纯函数要获得类似效果得靠高阶函数传入行为参数一层层嵌套下去代码的阅读难度会指数级上升。实例化还带来了策略替换的便利。同样一个排序函数你写一个Sorter类构造函数传入不同的比较器实例就能在运行时切换排序方式。函数式可以用闭包捕获比较器达到类似效果但当策略本身有内部状态比如带缓存的比较器、带统计信息的处理器时类的表达力明显更强。所以判断该不该实例化的一个关键问题是这个对象在它的生命周期里需不需要维护一份会见变化、跨越多次调用的数据需要就老老实实实例化不需要就没有理由去 new。4. 别只盯性能真正的分水岭是状态归属4.1 性能差异的真相很多人在选择函数调用还是模块实例化的时候第一反应是先跑个基准测试。我理解这个冲动但实测下来大部分情况下这种对比没有太大意义。在不太极端的场景下现代解释器和 JIT 编译器已经让两者差距缩小到可以忽略。解释型语言里函数调用本身有一点开销类方法调用其实就是从对象属性表里取出函数再调用额外多一次属性查找但这点时间通常是微秒以下的。真正拉开差距的是你在这个函数或方法里干了什么如果你做的是 I/O、网络、复杂计算那调用方式这点差异连零头都算不上。性能对比里唯一值得留意的是高频循环中的对象分配和动态分派。一个在循环体里不断创建的短命实例带来的是持续的内存分配压力一个通过继承体系链路上多次查找的方法调用在 JIT 眼里也比一个稳定形态的函数更难内联。但这些都是可以测试和确认的不是拍脑袋决定用哪种写法的理由。前面说过模块实例化在堆上分配内存并维护状态函数调用通常在栈上执行且不保留额外数据。两者的内存模型差异决定了它们对垃圾回收的影响完全不同。如果系统极度在意停顿和内存占用纯函数式的高频路径确实更有优势。但这属于优化阶段的讨论不是架构阶段的理由。拿性能作为最初选择函数式还是面向对象的核心理由在我看到的大多数项目里都是过早优化。4.2 状态归属决定行为边界性能不是分水岭什么才是我在大量代码评审中总结出一条经验看这个功能有没有一个状态宿主。状态宿主是指一份数据、要经历多次操作、并且这些操作之间关心前一次操作留下的结果。最简单的判断方法你试着把同样的功能用一组函数写出来看那些中间数据放哪里。如果中间数据可以完全由参数传入、返回值带出不依赖任何外部环境那就是纯函数的地盘。如果中间数据你不得不放在某个全局变量、模块级变量、或者每次调用都要从外部传入的对象里那就说明这个功能天生需要一个宿主也就是一个实例。如果中间数据不仅要存而且有严格的初始化顺序、有撤销和重试逻辑、有并发访问限制那模块实例化几乎是唯一自然的选择因为你需要的不仅是内存而是一套规范管理的状态空间。举个实际例子。日志系统。你可以写一个log(level, message)的函数它把日志写到 stdout干净利落。但当你的需求变成不同模块输出到不同文件、可以动态调整日志级别、在内存里保留最近的500条记录供后台查询你就需要一个Logger实例来维护这些设置和缓冲。日志的级别不是一个参数——它是一个贯穿整个生命周期的配置环形缓冲也不是一次调用的参数——它是一段随时间积累的现场。4.3 边界漂移写着写着就跨界了项目里最麻烦的不是一开始选错了而是一个功能本来不需要状态写着写着开始需要状态了。我碰到过一个场景一个支付回调处理函数原本只是个纯函数给个回调数据返回处理结果。后来需求迭代要支持幂等处理——同一个回调 ID 不能重复处理。于是函数里不得不查一下已经处理过哪些 ID。这个信息放哪全局变量并发环境下一锁就是性能瓶颈。数据库每次调用多一次查询而且为了一个幂等标记建表也夸张。最后不得已改成了模块实例化维护一个ProcessedCache的实例用它的check_and_record方法兼管并发。这个改动本身不大但它证明了状态需求的加入会改变最基础的实现范式。所以我在写代码前通常会专门做一步推演这个功能在未来半年内最可能新增的需求是什么如果大概率会引入状态我就直接采用实例化如果始终是算完就丢函数调用就能让我更轻松。这个推演不是玄学而是基于业务本身的性质凡是涉及谁、做了什么、做了多少次的天然需要状态凡是涉及给定条件算个结果的天然适合无状态。5. 实战选型我的一套判断流程与反模式5.1 五个问题的选型清单如果你现在手上有个功能要写与其纠结风格不如按下面这个顺序问自己五个问题。我用了很久至少能覆盖八成场景。调用之间需不需要保留数据不需要选函数需要考虑实例化。这份数据会被多个代码路径共享吗不会实例化之后局部持有会考虑单例或显式注入。行为的变更有多少种可能只有一种固定实现函数足够可能有多套算法或实现实例化加接口/抽象类更方便替换。单元测试好写吗纯函数直接测输入输出带依赖的功能问问自己愿不愿意每次 mock 这么一大片。不愿意就用实例化把依赖作为构造参数注入测试时传假的。性能敏感路径上会不会频繁创建和销毁会那就做成共享实例或函数不会实例化带来的额外开销可以忽略。这套流程的最终判断往往不是绝对的选函数或选实例化而是确定哪一部分用函数哪一部分用实例化它们之间怎么组合。5.2 常见的反模式这里聊几个我实际看到过无数遍的、非蠢即坏的做法。第一个反模式把有状态的需求塞进函数里用模块级变量存状态。比如模块顶部定义一个全局字典函数里读它、改它。看起来代码既保留了函数的直接调用感又偷偷存了状态。但全局变量是所有并发问题的温床而且它隐藏了依赖关系你在函数头根本看不出这个函数依赖了什么外部数据测试时还要记得重置全局变量。这就是用函数的外壳包裹了模块实例的内核两边的好处都没拿到。第二个反模式无状态功能硬套类。一个工具类里只有静态方法或者一堆不修改self的方法却要求每个调用方先new一个实例。这种纯粹的仪式感只会增加噪音。class StringFormatter: def format_name(self, first, last): return f{last}, {first} # 无意义的实例化 formatter StringFormatter() print(formatter.format_name(Alice, Lin))这里format_name完全不依赖self的任何属性写成普通函数或者staticmethod都更合适。因为实例化在这里没有带来任何状态、任何策略替换、任何可测试性提升——它只是让代码变长了。第三个反模式一个类里面既有纯函数方法又有有状态方法但是混在一起不加标记。这其实是可读性问题调用方看到service.calculate()他无法判断这个调用会不会修改内部状态会不会受之前调用的影响。如果calculate是纯的方法名最好暗示它如果它会依赖状态那它的命名和文档必须明确提醒。5.3 一个混合决策的现场演示我用一个实际小型功能来演示这套判断流程的用法。需求是一个 Web 后端需要限制单个用户每分钟最多调用某个接口 10 次。状态角度看每个用户的历史调用时间列表必须保留并且每次请求之间要共享这个数据。这就是典型的状态宿主。初步判断走实例化。但这里有个选择做成一个全局限流器实例还是每个用户一个限流器实例还是把限流器做成纯模块考虑扩展性我倾向于一个RateLimiterManager实例内部维护user_id - RateLimiter的映射。外层管理实例负责创建、存储、淘汰内层每个用户的RateLimiter负责写入当前这个用户的时间戳数组。再看变更多样性以后可能要支持按接口限流、按 IP 限流、动态调阈值。如果把这些都塞进一个类会非常臃肿。更好的拆分是限流的算法部分做成一个可替换的策略对象比如滑动窗口、令牌桶、漏桶通过构造函数注入限流的存储部分做一个独立接口测试时可以传入内存版生产可以传 Redis 版。这样下来函数在其中扮演的角色是什么那些判断当前时间是否在窗口内清理过期时间戳的小逻辑本身没有任何跨调用状态完全可以写成私有方法或纯函数。它们负责单次计算的正确性实例负责状态管理的正确性。这个例子就是函数调用和模块实例化同时存在各司其职谁也不替代谁。6. 最容易翻车的三个边界场景6.1 并发环境下的共享实例一个共享实例被多个线程同时调用是常见场景。比如前面说的限流器如果多个请求同时到达records[user_id].append(now)就会发生竞态条件。即使 Python 的 GIL 让纯字节码操作看上去是原子的一旦逻辑变复杂先检查、再插入、还要清理中间就可能被切换。解决思路一般有两个一是给实例的方法加锁让整个操作串行化二是把状态存储改成原子数据结构。但这些问题在函数调用下也存在如果你用模块级变量存状态并发问题一模一样甚至更隐蔽因为状态被藏在一个不那么显眼的地方。我的经验是当实例意味着并发访问时从第一天就要想清楚并发的边界不要想着后面再加锁。锁加在方法入口是最简单粗暴的做法但会降低吞吐如果每个用户的限流key是独立的用分片锁或者原子字典会好得多。6.2 构造函数里做重活实例化之后第一件事是初始化但很多人把构造函数当成什么都往里塞的地方比如建立数据库连接、读取系统配置、预热缓存。这样做的问题在于一旦构造函数抛异常你得到的错误信息往往很难定位——到底是连接失败还是配置缺失还是网络超时而且很多测试场景根本不想触发真实的数据库连接你 mock 起来也会很痛苦。比较靠谱的做法是构造函数只做轻量的一致性初始化把属性设置好保证对象处于一个可用的初始状态连接、加载、预热这类 I/O 操作放到独立的init()或connect()方法里由调用方在合适时机显式触发。这样一来单元测试可以不调用connect()进程启动逻辑可以统一处理连接的失败重试不把二者绑死。6.3 过早抽象与替换焦虑还有一个让我最头疼的情况写了一个功能函数完全可以搞定但为了将来可能换成类写法硬是在函数外面包了一层类。等到真的需要状态了却发现这个类已经被二十个地方引用改起来很费劲。这种情况的本质是倒果为因。抽象不应该服务于将来可能而应该服务于现在确实有多个维度在变化。将来可能可以通过重构解决重构有编译器/测试保驾护航但现在就有多个变化点是设计阶段就该认真对待的。我个人的原则是先让代码用最简单的方式跑通当第二个反复出现的变化迹象出现时再引入实例化和抽象。这也对应着那句老话——你不是在用两种风格写代码你是在管理变化的风险。7. 最后的几条经验做技术选型最难的不是不知道概念的区别而是每天都有一堆真实的业务压力推着你走。函数调用和模块实例化不是敌人也不是审美分歧。它们在你的代码里其实经常共存外层模块实例维护状态、注入依赖、组织逻辑内层函数保持纯粹、方便复算、避免副作用。一个成熟的代码库几乎都是这样混搭的。如果你非要一个简单粗暴的答案看到self里的字段会随着方法调用而改变就放心的用实例看到逻辑计算完就返回不关心下一次来的时候会怎样就大胆的写函数。别急着把一套代码做成纯函数式或者纯面向对象编程不是信仰是把正确的机制放到正确的位置上。最后分享一个实际操作时的检查小技巧。改完代码后我会重新读一遍关键函数的签名和类的构造方法如果一个类的构造函数里找不到任何真正影响行为的参数如果一堆方法从头到尾没有碰过self我就会减掉这层包装如果一个函数里冒出三四个绕不开的外部状态变量我就会考虑是不是该把这些变量收进实例里。这种反复微调不丢人真正的成本从来不在重命名和重构上而在于你一直坚持一个已经不合理的结构还不自知。
阅读完成 · 觉得有帮助?