☰
函数调用与模块实例化:状态归属决定技术选型
2026/10/22 3:45:41 网站建设 项目流程

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 五个问题的选型清单

如果你现在手上有个功能要写,与其纠结风格,不如按下面这个顺序问自己五个问题。我用了很久,至少能覆盖八成场景。

  1. 调用之间需不需要保留数据?不需要,选函数;需要,考虑实例化。
  2. 这份数据会被多个代码路径共享吗?不会,实例化之后局部持有;会,考虑单例或显式注入。
  3. 行为的变更有多少种可能?只有一种固定实现,函数足够;可能有多套算法或实现,实例化加接口/抽象类更方便替换。
  4. 单元测试好写吗?纯函数直接测输入输出;带依赖的功能,问问自己愿不愿意每次 mock 这么一大片。不愿意,就用实例化把依赖作为构造参数注入,测试时传假的。
  5. 性能敏感路径上会不会频繁创建和销毁?会,那就做成共享实例或函数;不会,实例化带来的额外开销可以忽略。

这套流程的最终判断往往不是绝对的"选函数"或"选实例化",而是确定"哪一部分用函数,哪一部分用实例化,它们之间怎么组合"。

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,我就会减掉这层包装;如果一个函数里冒出三四个绕不开的"外部状态变量",我就会考虑是不是该把这些变量收进实例里。这种反复微调不丢人,真正的成本从来不在重命名和重构上,而在于你一直坚持一个已经不合理的结构还不自知。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询