在 Python 数据模型那套方法里,__pos__ 绝对属于“平时想不起来、一旦想起来又到处是问题”的冷门选手。写这一系列笔记时,我习惯把每个方法丢到 dir() 里先盘一遍,结果轮到编号 66 的 __pos__,却发现它几乎没有存在感。很多人知道 __neg__ 可以重载,知道 __abs__ 可以重载,但很少人会主动去给自定义类实现 __pos__。可现实是,只要你的类里有对称的数值操作,漏掉 __pos__ 往往会在某个边角场景里给你来一下。
这篇文章就围绕 Python 3.12 里 __pos__ 的完整行为展开,讲清楚一元正号在什么时候触发、CPython 内部走了哪条路径、自定义类型如何设计这个方法的语义,以及最常见的对称性坑位。无论你是写数值计算库、单位换算库,还是只是在自己项目里重载运算符,这部分内容都值得收藏一下。
1. 正号不是“没用”,而是“默认行为被藏得太深”
1.1 先看一下没有 pos 时会发生什么
一个普通 Python 类,如果不实现任何一元运算符,那么对它使用正号会直接报错。
python复制class Signal:
def __init__(self, value):
self.value = value
sig = Signal(3)
print(+sig)
运行结果:
text复制TypeError: bad operand type for unary +: 'Signal'
同样的情况也发生在 +[]、+{}、+set() 上。很多人第一次看到这个报错会很困惑:加号对列表不是挺常用吗?注意,[] + [1] 是二元加号,它走的是 __add__;而 +[] 是一元正号,它走的是 __pos__。Python 把这两个概念分得非常清楚,list 实现了前者但没有实现后者,所以 +[] 直接抛 TypeError。
也就是说,__pos__ 不是“可选的装饰性方法”,它是对象能否安全出现在一元正号表达式里的唯一闸门。如果你的代码库里有类似 +current_value 的写法,而 current_value 恰好是某个不支持正号的自定义类型,程序会立刻崩溃。
1.2 为什么 __pos__ 容易被忽略
从数学直觉来说,+x 看起来像是一个空操作,大部分情况下它不改变数值,也不会产生副作用。所以 Python 的官方文档里对它的描述也极其简短:object.__pos__(self) 就是用于实现一元正号运算的方法。
但“看起来像空操作”不代表实现者不需要关心它。一个典型场景是:你定义了一个价格类,给它的负数形态实现了 __neg__,于是 -price 能正常工作;接着你在另一个模块里写了 +price,想要“取回内部的正向金额”,结果发现没有 __pos__,整个流程中断。
更麻烦的是,二元运算符遇到 NotImplemented 时会尝试反向操作,比如 a + b 里 a.__add__(b) 不行还会尝试 b.__radd__(a)。但一元正号只有一次机会:+sig 只会调用 sig.__pos__(),如果这个方法不存在,解释器不会去找其他任何后备协议,直接抛异常。这是 __pos__ 和二元运算符在容错性上最大的区别。
1.3 标准库里其实早就普遍实现
你可能没注意到,Python 标准库里很多类型都实现过 __pos__,只是它们太顺滑了,平时根本没让你感知到。
datetime.timedelta支持+td,会返回一个语义相同的timedelta对象。decimal.Decimal支持+Decimal('-1.5'),方法会保留数值符号与精度规则,并返回规范化后的 Decimal。fractions.Fraction也实现了__pos__,用于配合完整的数值协议。- 内置的
int、float、complex当然都有对应的槽位,虽然它们的实现经常只是一个内部快速路径。
既然标准库类型都在默默实现 __pos__,说明它并不是一个可以随意省略的协议。对需要参与数值运算的自定义类型而言,实现它是“完成运算符拼图”的一部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从表达式到 CPython 内部:+obj 的执行链路
2.1 字节码视角:UNARY_POSITIVE 长什么样
想知道 +x 在解释器里经历了什么,最简单的办法是直接反汇编。
python复制import dis
def apply_positive(x):
return +x
dis.dis(apply_positive)
在 Python 3.12 下,输出大致是:
text复制 2 0 RESUME 0
2 2 LOAD_FAST 0 (x)
4 UNARY_POSITIVE
6 RETURN_VALUE
RESUME 0 是 3.12 新增的解释器内部调试/缓存指令,基本上每个函数开头都会出现,暂时不用管。关键在 UNARY_POSITIVE 这条字节码:它专门负责一元正号运算。类似的还有 UNARY_NEGATIVE 和 UNARY_INVERT,分别对应 -x 和 ~x。
从这里可以看出,一元正号不是某个语法糖经过简单替换就能绕过的优化,它在字节码层面就有独立的操作指令。
2.2 槽函数与 PyNumber_Positive
字节码 UNARY_POSITIVE 在 CPython 解释器主循环里会调用 PyNumber_Positive 这个 C 函数。PyNumber_Positive 做的事情很直接:
- 找到对象的类型对象。
- 检查类型的
tp_as_number指针是否存在。 - 如果存在,取出
nb_positive槽。 - 调用槽函数并返回结果。
对于纯 Python 类来说,这个 nb_positive 槽在类型创建时被 slot wrapper 机制绑定到你在类体里定义的 __pos__ 方法上。也就是说,从语言层面看,你定义的是 __pos__;从 CPython 内部看,真正被调用的其实是类型对象数值协议表里的一个函数指针。这也是为什么某些通过 C 扩展编写的类型并没有暴露 __pos__ 方法,但仍然能参与一元正号运算,因为它们直接填充了 nb_positive 槽。
2.3 返回 NotImplemented 时会怎样
二元运算符中返回 NotImplemented 是“我不处理这个操作,请你试试右边操作数”。但一元运算符并没有“另一个操作数”的概念,所以 __pos__ 返回 NotImplemented 之后,解释器不会继续尝试什么,而是直接把这个结果转换成异常。
python复制class Broken:
def __pos__(self):
return NotImplemented
b = Broken()
print(+b)
运行结果:
text复制TypeError: bad operand type for unary +: 'Broken'
在实际代码里,除非你想故意让 +obj 崩溃,否则不要在 __pos__ 中返回 NotImplemented。官方文档更推荐的错误处理方式是直接抛出有意义的异常,比如 TypeError 或 ValueError。因为返回 NotImplemented 给调用方留下的错误信息非常不友好。
2.4 内置类型的一元正号也做了不少事
虽然内置类型的一元正号看起来“什么也没干”,但它们的 __pos__ 实现其实是会走一些额外逻辑的。举几个有意思的例子:
python复制print(+True) # 1
print(+False) # 0
print(+0.0) # 0.0
import math
print(+math.nan) # nan
True 是 bool 类型,而 bool 是 int 的子类。+True 返回的是整数 1 而不是布尔值 True,这是因为内置的 int.__pos__ 会把输入转换成一个正规的 int 对象。float 类型则会保持浮点数的符号位,所以 +(-0.0) 依然得到 -0.0,不会因为正号操作而意外丢失负零标志。
这给自定义类型带来的启示是:__pos__ 不只是原样返回,它是一个“重新规范化自身”的机会。如果你实现了一个有理数包装类,可以在 __pos__ 中约分、统一单位、清理缓存标志等。
3. 给自定义类型添加 pos:真实建模案例
3.1 矢量类:让一元运算符对每个分量透传
假设你在写一个二维向量类,里面已经实现了 __neg__。如果不实现 __pos__,你会得到一个不对称的对象:-v 能跑,+v 却报错。任何拿向量去参与矩阵运算或数值表达式的代码,都可能因为加号而崩溃。
更合理的做法是让 __pos__ 对每个分量执行“分量本身的一元正号”,从而把符号语义逐层传导下去。
python复制import math
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector2D({self.x!r}, {self.y!r})"
def __pos__(self):
return type(self)(+self.x, +self.y)
def __neg__(self):
return type(self)(-self.x, -self.y)
def __abs__(self):
return math.hypot(self.x, self.y)
v = Vector2D(-1.5, 2.5)
print(+v) # Vector2D(-1.5, 2.5)
print(-v) # Vector2D(1.5, -2.5)
这里有个很容易被忽视的点:我用了 type(self)(...),而不是直接写 Vector2D(...)。这种做法在继承场景下非常关键。如果子类继承了 __pos__ 并重写了 __init__,那么返回 type(self) 就能保持子类类型,避免把子类对象悄悄降级成父类对象。
3.2 数量与单位类:用 pos 做显式标准化复制
在单位换算或者金额处理库里,一个对象往往携带数值、单位、精度和上下文信息。__pos__ 可以定义成“生成一个经过标准化处理的新对象”,这样用户在不想写复杂拷贝方法时,可以直接用加号表达意图。
python复制from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Quantity:
magnitude: Decimal
unit: str
def __pos__(self):
# 返回一份规范化后的副本,单位保持不变
return type(self)(self.magnitude, self.unit)
def __neg__(self):
return type(self)(-self.magnitude, self.unit)
price = Quantity(Decimal("2.500"), "USD")
new_price = +price
print(new_price) # Quantity(magnitude=Decimal('2.500'), unit='USD')
你可能会问:这和直接写 copy.copy(price) 有什么区别?区别在于语义表达。copy.copy 是“复制对象”,而 +obj 是“对对象执行数值正运算”。在领域建模里,这个差别会影响阅读代码的人怎么理解你的意图。对于不可变的值对象,让 __pos__ 返回一个重新构造的新实例,还能顺手完成精度规整、默认值填充、缓存清理等副作用。
当然,如果你的值对象里包含不可变字段,也可以直接 return self,这样连内存分配都省了。但这里要特别注意对象语义的一致性,见后面对可变对象的讨论。
3.3 数组/矩阵包装类:正号作为拷贝语义
在数值库设计里,一个常见约定是让 +arr 返回一个新的数组对象,而不是原始数组的别名。这来自 Numpy 等库的使用习惯:用户对某个数组执行一元正号后,不用担心修改返回结果会污染原数据。
我自己写过一个轻量矩阵类,内部保存一块可变列表。最初实现 __pos__ 时图省事,直接:
python复制def __pos__(self):
return self
结果在业务代码里,有人对 +matrix 返回的对象做了原地修改,导致原矩阵也被改了。排查半天才发现问题不在算法,而在 __pos__ 的引用语义上。后来改成了显式独立副本:
python复制class SimpleMatrix:
def __init__(self, rows):
self.rows = rows
def __pos__(self):
# 返回一个独立副本,避免外界持有同一块可变数据
return type(self)([[cell for cell in row] for row in self.rows])
def __neg__(self):
return type(self)([[-cell for cell in row] for row in self.rows])
这告诉我们:当你给可变类型设计 __pos__ 时,必须明确“返回值是否共享内部状态”。如果返回一个新对象,最好在文档里写清楚它是深拷贝还是浅拷贝;如果返回 self,也要写清楚所有修改都会反映到原对象上。没有明确语义的 __pos__,在协同开发里最容易产生隐蔽 bug。
3.4 表达式树里的显式正号
在构造表达式树、SQL 查询条件、规则引擎一类的 DSL 时,一元正号偶尔会用来表示“强制保留该节点本身,不做隐式化简”。比如你解析到的表达式是 +(some_column),为了在 AST 中保留括号或原始标记,你可以在节点类中让 __pos__ 返回一个带标记的包装节点。
这种用法不算普遍,但如果你在做 DSL 解释器,遇到 +expr 时就不应该简单忽略正号。给它一个专门的 __pos__ 实现,能让你在 AST 层区分“用户显式写了正号”和“用户没有写任何符号”。这是很实用的编辑器/DEBUG 辅助设计。
4. pos 与 neg、abs 的“三项一致性”
4.1 最容易被忽视的问题:符号翻转后必须还能取正
很多人只给类实现 __neg__,不加 __pos__。这种情况下,单独执行 -obj 没问题,但如果代码里写的是 +(-obj),解释器就会先调用 __neg__ 得到一个临时对象,再对这个临时对象调用 __pos__。只要临时对象没有 __pos__,整个表达式依然会崩溃。
python复制class OneSide:
def __init__(self, value):
self.value = value
def __neg__(self):
return OneSide(-self.value)
x = OneSide(5)
print(-x) # 正常
print(+(-x)) # TypeError: bad operand type for unary +: 'OneSide'
这种 bug 在代码 review 时极难发现,因为它潜伏在“用户自定义对象嵌套表达式”里。要让一个类稳定参与数值运算,__neg__ 和 __pos__ 应该成对实现。就算你认为 +obj 应该原样返回对象,也请显式写上:
python复制def __pos__(self):
return self
这样至少能保证 +(-obj) 不会因为缺方法而暴毙。
4.2 用一套测试清单防止协议不对称
我给自定义数值类总结了一个简便的一致性检查清单,每次定义完运算符后跑一遍,能省去很多后续排查时间。
python复制from operator import neg, pos, abs
def check_unary_consistency(obj):
# 一元正号和负号都应该保留对象类型
assert type(pos(obj)) is type(obj)
assert type(neg(obj)) is type(obj)
# 两次取负应回到起点,绝大多数实现应满足
assert pos(neg(neg(obj))) == obj
# 零值不应因为取负产生非零
zero = obj * 0 # 如果你的类型支持乘法;否则换成等价构造
assert neg(zero) == zero
当然,具体断言要结合数据类型调整。比如浮点数 nan 就不满足 neg(neg(x)) == x 的简单形式,需要用 math.isnan 处理。这个清单的价值在于强制你想清楚:你的 __pos__ 是否真的等价于“符号不变的操作”?还是你有意让它做了一些额外工作?如果有意做额外工作,那这种不对称是否被文档记录下来了?
4.3 可变对象请想清楚拷贝是谁的拷贝
在实现 __pos__ 时,最隐蔽的坑就是“返回自己的引用”与“返回拷贝”的语义漂移。
对于不可变对象,__pos__ 返回 self 是安全的,因为没有人能修改 self 的内部状态。但对于内部有 list、dict、bytearray 或其他可变字段的对象,return self 意味着 +obj 和 obj 共享状态。你的后续代码一旦对一个引用做了原地修改,另一个引用看到的也会变。
我的建议是:可变数值类型的 __pos__ 默认返回浅拷贝或深拷贝,除非你的类本身就是单例或者文档明确声明正号不产生新对象。另一个可选方案是让 __pos__ 返回一个只读视图对象,但这会引入额外的包装类型,复杂度较高。取舍标准很直接:你的使用方更看重性能还是更看重隔离性。
需要说明的是,Python 对象模型并没有强制约定 __pos__ 必须返回一个新对象,也没有强制它返回和原对象相同的类型。官方只规定了“实现一元正号运算”。所以返回值类型完全由你来定义。自由意味着你必须更谨慎地设计,绝不能随手写个 return self 就以为完成了工作。
5. 在 Python 3.12 下调试和维护自定义 pos
5.1 注意反汇编中 3.12 新增的指令
Python 3.12 对字节码解释器做了不少调整,最直观的变化是反汇编输出里会多出很多以前没有的指令。如果你在调试 __pos__ 相关的性能问题,直接看 dis.dis 时不要被 RESUME、CACHE 之类的字样吓到,它们不是你的对象方法被额外调用了,而是解释器为后续优化准备的元数据与缓存机制。
真正和你自定义类相关的仍然只有 UNARY_POSITIVE 这一条运算指令。如果在此基础上你调用了一些 Python 函数,那么反汇编里会看到 CALL 指令;如果你的 __pos__ 内部访问了属性,还会看到 LOAD_ATTR。逐条对照字节码能帮你快速定位性能热点到底是在 __pos__ 调用本身,还是方法内部的数据处理逻辑。
5.2 用 typing.Self 标注返回值
Python 3.11 开始引入了 typing.Self 类型,3.12 依然推荐使用。它的作用是在类型标注中表示“返回当前类型”。
python复制from typing import Self
from decimal import Decimal
class Money:
def __init__(self, amount: Decimal, currency: str):
self.amount = amount
self.currency = currency
def __pos__(self) -> Self:
return type(self)(self.amount, self.currency)
def __neg__(self) -> Self:
return type(self)(-self.amount, self.currency)
如果不用 Self,你可能想写 -> "Money",但在子类继承时就会出问题:子类的 __pos__ 返回类型被标注成了父类,静态检查工具会认为你可能把子类降级了。而 type(self)(...) 配合 Self,能够完整表达“返回和当前对象一致的类型”这一意图。
这套组合对于实现类似运算符协议的方法尤为重要,因为运算符重载方法天然需要在继承体系中保持类型一致性。
5.3 几个从实际踩坑中总结出来的实现建议
第一,如果一个类定义了 __neg__,请在同一个类里同时定义 __pos__。这不是风格偏好,而是协议闭环的基本要求。漏掉它,你的用户很快会在某个写满 +(-item) 的逻辑里收到莫名其妙的 TypeError。
第二,__pos__ 内部不要做重计算。很多开发者以为正号是空操作,于是把单位换算、金额精度调整这些逻辑塞进去,导致每一次 +x 都伴随高昂开销。如果你确实需要标准化和取正值两个操作,请拆分语义:__pos__ 只做语义上的“原值呈现”,把真正的计算放到独立方法或构造函数中。
第三,在纯 Python 类型中实现 __pos__ 会让原本在原生整数上几乎零成本的 +x 变成一次完整的方法调用。对于性能敏感的大规模数值循环,你应该尽量避免对自定义对象反复使用一元正号。Python 函数调用的开销并不小,即使方法体只是 return self.value,一次的调用开销也远高于内置类型槽位上的快速路径。需要压榨性能时,要么在循环外面把正号结果缓存下来,要么考虑用 C 扩展实现数值协议。
第四,写测试时不要只测 +obj 是否等于期望值,还要测 +obj is obj 是否符合你的文档描述。引用相等性往往是可变对象 __pos__ 最容易出问题的地方。一个简单的 assert (+data) is not data 就能第一时间暴露浅拷贝或共享状态的意外。
__pos__ 这个方法的调试体验,说到底就是一次 Python 对象模型的教育:运算符重载不仅是给对象加功能,更是在为类型建立可靠的行为契约。把 +obj 当成“无操作”的地方,往往就是后续隐藏 bug 的高发区。
