位置:首页 > Python > Python 里 `add()` 和 `__add__()` 怎么选?从对象修改、副作用到并发汇总一次讲清

Python 里 `add()` 和 `__add__()` 怎么选?从对象修改、副作用到并发汇总一次讲清

时间:2026-08-24  |  作者:星河游者  |  阅读:0

目录

  1. `add()` 和 `__add__()` 到底差在哪
  2. 为什么 `add()` 更容易埋下隐藏问题
  3. 多线程下真正要注意的点
  4. 为什么当前这份代码还能继续保留 `add()`
  5. 实际选择建议:先看语义,再看执行场景

前言

在 Python 里,`add()` 和 `__add__()` 都能做“相加”,但一个偏向原地累加,一个偏向返回新值,选错后常见问题往往不是语法报错,而是对象被悄悄改掉,甚至在并发场景里丢统计。本文结合 `Usage` 的实际例子,把两种写法的语义、性能和线程行为拆开说明,再落到当前架构为什么还能安全保留 `add()`。

`add()` 和 `__add__()` 到底差在哪

这两个写法表面上都在做“相加”,但适用场景并不相同。一个是直接修改当前对象,另一个是返回一个新的结果对象;理解这点,后面关于性能、副作用和线程安全的判断才不会混在一起。

`add()` 与 `__add__()` 的行为差异对比图
`add()` 与 `__add__()`把“是否修改自身”“是否创建新对象”“链式计算”和“批量累加成本”放在一张图里。

`add()`:原地修改,适合连续累加

def add(self, other: "Usage") -> None:
    self.prompt += other.prompt
    self.completion += other.completion
    self.total += other.total
    self.cached += other.cached
    self.calls += other.calls
    self.cost += other.cost

常见用法是把多个统计值不断加到同一个对象上:

total = Usage()
total.add(usage1)
total.add(usage2)

这种方式的直接好处是不创建新对象,在大量累加时更省分配开销:

usage = Usage()

for item in usages:
    usage.add(item)

如果你的目标就是把许多 `Usage` 汇总到一个容器里,`add()` 的执行路径通常更直接。

`__add__()`:返回新对象,表达更安全

def __add__(self, other: "Usage") -> "Usage":
    return Usage(
        prompt=self.prompt + other.prompt,
        completion=self.completion + other.completion,
        total=self.total + other.total,
        cached=self.cached + other.cached,
        calls=self.calls + other.calls,
        cost=self.cost + other.cost,
    )

它对应的是 Python 的 `+` 运算符:

total = usage1 + usage2

优点是原对象不变,结果更容易预测,也支持链式写法:

total = u1 + u2 + u3 + u4

这更接近“输入不变,输出新值”的思路。代价则是频繁相加时会持续创建临时对象:

result = Usage()

for u in usages:
    result = result + u

在这个过程中,结果对象会不断被新对象替换。原文里的过程可以概括为:

第1次:Usage对象A + u1 -> B

第2次:B + u2 -> C

第3次:C + u3 -> D

为什么 `add()` 更容易埋下隐藏问题

`add()` 最大的问题不是语法,而是副作用。只要还有别的变量指向同一个对象,或者这个对象被函数直接拿去改,结果就可能超出预期。

共享引用与函数传参导致 add() 连带修改外部对象的示意图
`add()` 的副作用从哪里来用对象引用关系和函数调用路径说明 `add()` 的副作用来源。

1. 共享引用会导致意外修改

usage = Usage(prompt=100)

backup = usage

usage.add(Usage(prompt=50))

print(backup.prompt)

输出结果是:

150

原因不是 `backup` 被“同步更新”,而是 `backup = usage` 只复制了引用。两者实际指向同一个对象:

backup ─┐
        ├──> Usage(prompt=100)
usage ──┘

因此 `add()` 改到的是这块共享对象本身。

2. 作为函数参数传递时,会顺带改掉外部状态

def calculate_total(usage):
    usage.add(Usage(prompt=100))
    return usage

u = Usage(prompt=10)

result = calculate_total(u)

print(u.prompt)

输出:

110

这里 `calculate_total()` 并不是基于参数“算出一个新值”,而是直接把传入对象改掉了。所以调用方拿回 `result` 的同时,原来的 `u` 也已经变化。

多线程下真正要注意的点

如果多个线程同时对同一个 `Usage` 实例执行 `add()`,风险就不再只是副作用,而是会出现数据竞争

`self.prompt += other.prompt` 不是原子操作

假设多个 LLM 请求结束后,同时更新一个全局对象:

global_usage.add(response_usage)

线程 A 可能这样执行:

读取 prompt=100

+50

写回150

线程 B 可能同时执行:

读取 prompt=100

+30

写回130

最后结果可能是:

130

而不是预期中的:

180

原因在于下面这句并不是一次不可分割的操作:

self.prompt += other.prompt

它实际可以理解成:

tmp = self.prompt
tmp = tmp + other.prompt
self.prompt = tmp

多个线程交错执行时,就会发生丢更新。要直接在共享对象上做原地累加,至少要加锁:

from threading import Lock

lock = Lock()

with lock:
    usage.add(other)

为什么当前这份代码还能继续保留 `add()`

关键不在于 `add()` 天生安全,而在于它现在所处的并发结构,并没有让多个线程同时去写同一个 `Usage` 对象

并发执行、主线程串行汇总的 Usage 统计流程图
当前并发结构为什么没有写冲突这张图专门解释为什么当前架构下 `add()` 暂时没有并发写同一对象的问题。

每个 worker 线程只处理自己的局部统计

当前结构里:

  • 每个并发任务都会在 `run_source()` 里创建自己的 `Workflow`
  • 每个 `Workflow` 有自己的 `self.usage`
  • `source_usage` 也是每个任务自己的局部变量
  • `global_usage` 只在主线程的 `for future in as_completed(futures)` 里合并

对应流程如下:

# 每个线程:
run_source()
  -> Workflow()
  -> workflow.usage
  -> source_usage

# 主线程:
as_completed(futures)
  -> 拿到某个 source_usage
  -> merge_usage_map(global_usage, source_usage)

也就是说,worker 线程内部可以各自统计,但不会一起对同一份全局 `Usage` 直接执行:

usage.add(other)

并发执行,串行汇总

真正的全局合并发生在主线程,因此当前统计不会因为 `add()` 并发写入而乱掉。风险更大的其实是下面这种设计:

global_usage = {}

# 多个 worker 线程里同时执行
merge_usage_map(global_usage, workflow.usage)

如果多个线程同时改同一个 `global_usage`,同一模型下的计数就可能丢失。

而现在的汇总方式是:

for future in as_completed(futures):
    title, success, error, usage = future.result()
    merge_usage_map(global_usage, usage)

虽然前面的 5 个 worker 在并发运行,但每个任务只是返回自己的 `usage`;真正写入 `global_usage` 的动作,是主线程一个一个处理完成结果。

`as_completed(futures)` 的特点也决定了它不会被某个慢任务拖住全部已完成任务的汇总。谁先完成,主线程就先拿谁的结果:

任务 3 先完成 -> 主线程立刻拿到任务 3 的 usage

-> merge_usage_map(global_usage, 任务3_usage)

-> 线程池空出一个位置,开始任务 6

任务 1 完成 -> 立刻 add 任务 1 的 usage

任务 5 很慢 -> 它没完成前不会 add 它自己的 usage,但不会阻塞其他已完成任务的汇总

唯一会等待的地方,是最终总函数返回前要等全部 `future` 结束,因为最后的总计必须包含所有任务。

实际选择建议:先看语义,再看执行场景

如果你需要的是可预测、无副作用的对象运算,`__add__()` 更贴近直觉;如果你明确是在做批量累加,并且能保证对象不会被共享误改,`add()` 会更省对象创建成本。

结合这份代码的现状,可以得到一个更准确的结论:

  • 当前代码没有并发问题,因为全局合并发生在主线程
  • `add()` 本身不是线程安全的,不能直接据此推广到共享全局对象的并发写入场景
  • 现阶段最稳的做法,仍然是让 worker 只返回各自的 `usage`,再由主线程统一合并

因此,这里的判断不该简单落在“`add()` 好还是 `__add__()` 好”,而是要先确认:你到底是在做局部汇总、值对象运算,还是跨线程共享状态更新。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多