1. 全景总览:Python 操作运算符不是"会算数"就够的
很多人学 Python 是从算术运算开始的,+、-、*、/谁都会写,但 Python 的操作运算符(Operators)远不止算数这么简单。从列表拼接、字典合并到集合交集,从短路求值到位掩码操作,运算符渗透在 Python 编程的每一个角落。我见过不少入了门、写了半年项目的开发者,遇到//和%的结果符号问题照样一脸懵,看到海象运算符:=在条件表达式里被误用导致语法错误,还有人因为is和==的区别没搞清楚,在跨模块对象比较时踩出诡异 bug。
这篇文章会把 Python 里七大类运算符全部过一遍,每种运算符不只讲"怎么用",更重点讲"为什么是这个结果"以及"哪些地方容易翻车"。
这不是一篇从官方文档抄一遍的说明书。每个知识点我都会结合真实项目中踩过的坑、调试过的案例,把底层原理和避坑经验一起给你。不管你是刚开始学 Python 的新手,还是写了几年想查漏补缺的老手,这篇文章里的内容都能帮你在写代码时少走几条弯路。
我先把目录摆出来,这样你能快速找到自己需要的段落:
- 运算符分类与优先级,先把全局观建立起来
- 算术运算符里最容易忽略的三个细节
- 比较运算符与身份运算符的"等值"陷阱
- 赋值运算符和原地操作的隐蔽差异
- 逻辑运算符的短路求值与返回值特性
- 位运算符的实用场景与补码陷阱
- 运算符重载,让自定义对象不露怯
- 一份可以贴墙上的常见坑速查表
2. 七大运算符分类:先建立一张全景地图
2.1 运算符分类速览
Python 官方把运算符分成七大类,我直接给你一张全景表:
| 类别 | 运算符 | 典型用途 |
|---|---|---|
| 算术 | + - * / // % ** | 数学计算 |
| 比较 | == != > < >= <= | 值比较 |
| 赋值 | = += -= *= /= //= %= **= := | 绑定与更新变量 |
| 逻辑 | and or not | 布尔逻辑 |
| 成员 | in not in | 容器包含性检测 |
| 身份 | is is not | 对象身份(内存地址)比较 |
| 位运算 | `& | ^ ~ << >>` |
这七类你大概率都见过,但每个人踩坑的原因几乎都出在"知道分类、不知道分类背后的语义边界"。
举个例子,+在数字之间做加法,在字符串、列表之间做拼接,在元组、集合之间做合并,可一旦你把+用在字典上,直接给你抛TypeError: unsupported operand type(s) for +: 'dict' and 'dict'。这个错误我见过无数次,newer 开发者尤其容易中招。为什么字典不能用+?因为字典合并存在键冲突语义,+无法表达"冲突时保留哪个值"这种规则,Python 宁可让你显式写{**d1, **d2}或d1 | d2,也不给+含糊的空间。
这种"一个符号在不同类型上有不同语义"的特性,是 Python 运算符体系的核心,也是避坑的重灾区。所以全景图的意义在于:你要知道什么时候该用哪个运算符,而不是只会"算数"。
2.2 运算符优先级:看似由高到低,实则处处藏雷
优先级这张表,科班出身的人能背,但实际写代码时该踩的坑一个也没少。我把从高到低的常见优先级列出来:
- 幂运算
** - 一元正负号
+x-x,按位取反~x - 乘法、除法、取模、整数除法
* / % // - 加法、减法
+ - - 移位
<< >> - 按位与
& - 按位异或
^ - 按位或
| - 比较运算
== != > < >= <= - 身份
is / is not,成员in / not in - 逻辑非
not - 逻辑与
and - 逻辑或
or - 条件表达式
x if cond else y - 赋值与海象
= :=
这里最容易翻车的点有三个。
第一,**的优先级比一元负号高。-2 ** 2的结果是-4,不是4。因为**先于-执行,等价于-(2 ** 2)。如果你想表示"负二的平方",必须写(-2) ** 2。
第二,not的优先级低于比较运算,但高于and和or。所以not a == b会先算a == b再取反,这个符合直觉;但not a and b究竟怎么解析?答案是(not a) and b。因为not比and优先级高,结果和你"口头理解"的not (a and b)完全不同。
第三,布尔运算符and/or优先级低于比较运算符,这倒是符合直觉:a == 1 and b == 2会按(a == 1) and (b == 2)执行。
优先级造成的 bug 不会报错,只会给出错误结果,所以是最隐蔽的。我的习惯是:只要表达式里混了三种以上运算符,能加括号就加括号,绝对不靠"我记得优先级顺序"硬写。这不算婆婆妈妈,这是省下三小时调试时间的最好方式。
3. 算术运算符:加减乘除背后的三个隐藏细节
3.1 除法三兄弟:/、//、%的符号逻辑
Python 3 里/永远返回浮点数,哪怕4 / 2也是2.0。这一点很多人已经知道了,但有两个细节值得展开说。
第一个是//的行为:它是向下取整,不是向零取整。这个区别在负数场景下会直接改变结果:
print(7 // 3) # 2,正数时与向零取整结果相同 print(-7 // 3) # -3,而不是 -2 print(7 // -3) # -3-7 // 3数学上等于-2.333...,向下取整得到-3。如果你是从 C 语言或 Java 转过来的,很容易按"截断小数"的思维预期-2,结果就会在这里出错。比如实现分页或二分查找时,用mid = (left + right) // 2,如果 left 或 right 出现负数,计算出的 mid 就会比预期小一格,可能导致死循环或越界。
第二个是%取模的结果符号永远与除数(右边的操作数)保持一致:
print(-7 % 3) # 2,不是 -1 print(7 % -3) # -2-7 % 3的结果是2,因为 Python 保证a == (a // b) * b + a % b这个恒等式成立。-7 // 3 == -3,(-3) * 3 == -9,所以余数必须是2才能让-9 + 2 == -7。这在处理循环数组索引、哈希分桶,尤其是负索引场景时非常关键。比如你想实现一个循环队列,index = (current - 1) % size,当current == 0时得到-1 % size,Python 返回size - 1,恰好是数组末尾,这就非常优雅。在 C 语言里你还需要额外判断负数,在 Python 里天然避开了。
关于//和%的配合,我踩过一个实际 bug:当时做一个时间戳换算,把毫秒数转成"天-时-分-秒",用户输入的是距今 N 天的倒计时,时间为负值。我最初用total_seconds // 86400取天数,再用total_seconds % 86400取剩余秒数,结果发现负数时间戳下两个值都对不上,调试半天才反应过来是向下取根和余数符号规则的问题。后来在所有地方显式写了文档注释://是地板除,%结果跟随除数符号。之后再也没在符号逻辑上翻过车。
3.2 浮点数运算:0.1 + 0.2不等于0.3
算术运算符的另一个深坑是浮点精度。0.1 + 0.2 == 0.3的返回值是False,因为这个计算在 IEEE 754 双精度标准下得到的是0.30000000000000004。
这不是 Python 的问题,几乎所有现代编程语言都一样。但 Python 有一个特性让这个问题更"显眼":交互式终端里直接输出浮点数时会显示得比较友好,而repr()会暴露真实值。
处理浮点数比较有三条路:
- 用
math.isclose()判断近似相等。 - 涉及金额计算时用
Decimal。 - 能用整数运算就别碰浮点数。
在普通业务逻辑中,比如判断某个百分比是否达到阈值,我用abs(a - b) < 1e-9这种简单的容差判断就够了。需要严谨的场景,比如金融计算、物理仿真,直接转Decimal或改用整数分/厘单位,别在浮点上纠结。
import math # 错误的写法(极其常见) if 0.1 + 0.2 == 0.3: print("equal") # 不会执行 # 推荐的写法 if math.isclose(0.1 + 0.2, 0.3): print("equal")3.3 幂运算与超大数:**的边界与性能
**在 Python 中支持任意大整数,2 ** 1000直接给你一个 300 多位的大数,完全不用像 C 语言那样考虑溢出。这本是好事,但也意味着你可能无意中写出一个天文数字级别的循环或内存消耗。
比如10 ** 1000000会在内存里创建一个约 4MB 的整数对象,这个操作本身很快,但如果你在一个循环里反复做这种大数运算,内存就会爆炸。
另外注意**的运算顺序:2 ** 3 ** 2在 Python 中结果是512,因为幂运算的绑定规则是右结合的,等价于2 ** (3 ** 2)。这个和大多数运算符左结合的习惯不同,也是常见的混淆点。
pow(a, b, mod)是取模幂运算,在密码学或算法题里非常有用,pow(2, 10, 1000)只计算2^10 mod 1000,效率远高于先算2 ** 10再取模,因为前者不会产生巨大的中间结果。
4. 比较运算符与身份运算符:等于不等于,别栽在"身份"上
4.1==比较值,is比较身份
==调用对象的__eq__方法,比较的是"值是否相等";is比较的是"两个变量是否指向同一个对象",也就是内存地址是否相同。这个区别基础知识里都有,但实际场景中坑点无处不在。
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True,值和元素都一样 print(a is b) # False,两个不同的列表对象最常见的误用场景是跟None做比较。判断一个变量是否为None应该用is None,不是== None。虽然两者在绝大多数情况下结果相同,但==会调用对象的__eq__方法,而某些自定义对象的__eq__可能被重写成"永远返回 True"或"永远返回 False",导致判断失灵。
class WeirdObject: def __eq__(self, other): return True obj = WeirdObject() print(obj == None) # True,因为 __eq__ 总是返回 True print(obj is None) # False,这才是正确的判断入坑的另一个经典场景是整数缓存。CPython 会对小整数(通常是 -5 到 256)做缓存,所以a = 100; b = 100; a is b返回 True。但一旦超过这个范围,256和257的行为就不同了:
a = 256 b = 256 print(a is b) # True,命中缓存 a = 257 b = 257 print(a is b) # 可能是 False,取决于解释器实现这个问题并非在所有 Python 实现中都一致,PyPy 和 CPython 行为可能有差异。所以绝对不能依赖is去比较整数、字符串这种不可变类型的值。用is只做两件事:比较None、比较单例对象(比如True、False和某些枚举常量)。
4.2 链式比较:Python 独一份的糖衣语法
Python 支持链式比较,a < b < c等价于a < b and b < c,而且中间的b只求值一次。这个特性在写区间判断时非常优雅:
# 判断 x 是否在 [0, 100] 区间内 if 0 <= x <= 100: pass在其它语言里你得写x >= 0 && x <= 100,在 Python 里直接链式搞定。这也是为什么很多从 Java 转 Python 的人第一次看到这种写法会觉得"竟然还能这样"。
链式比较有个容易忽略的坑:不要在里面嵌入带副作用的函数调用。比如a < random.random() < b,虽然中间的random.random()只求值一次,但如果你写成a < next(it) < b这种带迭代器推进的写法,一旦逻辑复杂起来,行为会很难预测。我建议仅在普通变量或简单属性比较时用链式语法,保持可读性优先。
4.3 浮点数比较的三个层次
除了常见的==陷阱,浮点比较在数值分析中还有"绝对值容差"和"相对容差"的区别。math.isclose()默认使用相对容差rel_tol=1e-09,对大数和小数有不同的表现。
print(math.isclose(1e10, 1e10 + 1)) # True,相对误差很小 print(math.isclose(0.0, 1e-10)) # False,绝对差太小但相对差无穷大如果你比较的对象是金额、浓度这种"必须绝对精确到小数位"的量,建议把rel_tol和abs_tol同时设置:
if math.isclose(actual, expected, rel_tol=1e-6, abs_tol=1e-9): pass5. 赋值运算符:从=到:=的认知升级
5.1 复合赋值:x += y不等于x = x + y(对可变对象)
在 Python 中,x += y和x = x + y对不可变对象(int、str、tuple)行为基本一致,但对可变对象(list、dict、set)就完全不同了。
a = [1, 2, 3] b = a a += [4] # 原地扩展 print(b) # [1, 2, 3, 4],a 和 b 指向同一个对象,b 也跟着变了 c = [1, 2, 3] d = c c = c + [4] # 新创建一个列表 print(d) # [1, 2, 3],c 重新绑定到新对象,d 不受影响+=在可变对象上调用的是__iadd__,它原地修改对象;+调用的是__add__,它生成一个新对象再重新赋值。这个差异在传参、共享引用时会直接改变程序行为。
你的函数里如果接收一个列表参数并做了args += item,那么函数外部传入的列表也会被修改,这在 Python 里叫做"副作用"。写 API 时如果不想让外部数据被意外篡改,要么改成args = args + [item],要么在函数开头用args = args.copy()做防御性拷贝。
这个坑我曾经在实现一个队列管理器时踩得很惨。当时在类的方法里用self._tasks += new_tasks,觉得只是给任务列表追加元素,结果外部有另一个对象也引用了同一个_tasks,数据被悄悄污染,排查了很久才发现是复合赋值原地修改的锅。
5.2 海象运算符:=:赋值与表达式合二为一,但要小心边界
Python 3.8 引入了海象运算符:=,允许在表达式中完成赋值。最经典的用途是避免在循环或条件判断中重复调用同一个函数:
# 不用海象运算符 data = read_data() if data is not None: process(data) # 用海象运算符 if (data := read_data()) is not None: process(data)看起来简洁,但坑也藏在简洁里。
第一,海象运算符的优先级非常低,几乎和赋值运算符一样。所以几乎在所有非最简单场景中都要加括号:
# 错误的写法 if data := read_data() is not None: # 等价于 data := (read_data() is not None) pass # 正确的写法 if (data := read_data()) is not None: pass第二,海象运算符不能用在赋值表达式所在语句之外引用变量。比如print(x := 5)是合法的,会打印 5,但x := 5, y := 6这种多个变量同时赋值的写法是语法错误,除非写成(x := 5), (y := 6)。
第三,海象运算符在列表推导式中的作用域很特殊,它会泄漏到外层。这算是一个特性,但也容易造成意外:
# 列表推导式中用 :=,变量 i 会泄漏出去 values = [(i := x * 2) for x in range(3)] print(i) # 4,i 泄漏到了外部我个人的习惯是:海象运算符用在while循环读取数据块、正则匹配、文件读取这类"赋值-判断-使用"三连场景,其它地方尽量不用。它是一个利器,但不是让你炫技的玩具。代码写完三个月后,自己回来看一个复杂的海象表达式,很可能要花五分钟才能理清逻辑。
5.3 可变默认参数的"赋值陷阱"
这个坑虽然不完全是运算符的问题,但由于=的存在,显得让人防不胜防。
def append_item(item, lst=[]): lst.append(item) return lst第一次调用append_item(1)返回[1],第二次调用append_item(2)返回[1, 2]。默认参数lst=[]只在函数定义时被创建一次,之后所有调用共享同一个列表。这在内部表现为"记忆效应",在外部表现为完全意想不到的行为。
修复方式很简单:用None作为默认值,内部再创建新列表。
def append_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst这类问题看起来和运算符无关,但归根结底是=绑定语义在可变对象上造成的。Python 里的"变量"不是盒子,而是标签,=做的是"把标签贴到对象上",不是"把值复制进盒子"。理解这一点,赋值运算符相关的坑基本能避掉 80%。
6. 逻辑运算符:短路求值返回的不一定是布尔值
6.1and/or返回操作数,不是 True/False
这是 Python 新手最常误解的点之一。and和or返回的不是布尔值,而是参与求值的操作数本身。
规则很简单:
a and b:如果a为假,返回a;否则返回ba or b:如果a为真,返回a;否则返回b
print(0 and "hello") # 0,因为 0 为假,直接短路 print(1 and "hello") # "hello" print(0 or "hello") # "hello" print(1 or "hello") # 1,因为 1 为真,直接短路这套规则在实际编码中极其常用。最常见的写法是用or提供默认值:
username = input_name or "anonymous"如果input_name是空字符串(假值),就使用"anonymous"。同样,and常用于守卫表达式:
user and user.name如果user为None,user and user.name返回None,不会抛出AttributeError。这比写一长串if user is not None:要简洁不少。
但这里有一个重要的坑:如果值可能是0、空列表、False这类"合法但为假"的值,用or做默认值就会误伤。
# 用户设置了一个配置,值为 0 表示"不启用",为空表示"未设置" setting = config.get("timeout") or 30 # 如果 timeout 设为 0,上面的表达式会返回 30,而不是 0这里正确做法是用显式的 None 判断:
setting = config.get("timeout") if setting is None: setting = 30判断"是 None"和"是假值"是完全不同的两个概念。用or之前一定要想清楚你要的是哪一个。
6.2 短路求值:不只是节省性能,更是程序语义的一部分
短路求值意味着and和or在左侧结果已经能确定整个表达式的结果时,不再计算右侧。这在两个场景下有关键作用:一是避免不必要的计算,二是避免报错。
# 避免除零错误 if divisor != 0 and dividend / divisor > 10: pass # 避免空指针错误(Python 里的 NoneType 错误) if obj is not None and obj.attr > 1: pass如果把两个判断的顺序换一下,dividend / divisor > 10 and divisor != 0,程序会在除数为 0 时直接抛ZeroDivisionError。
还有一点值得注意:and和or的短路只会发生在运行时,不会因为右侧是一个"肯定会报错"的表达式而在编译期阻止你。比如False and 1 / 0的结果是False,因为右侧根本没有执行。
6.3not的关键禁忌:别在in表达式里乱用
not的优先级我们已经提过,低于比较运算符,高于and/or。最常见的误用是把not a in b和a not in b视为等价。事实上它们在结果上确实等价,但语义上not in是专门为成员检测设计的运算符,可读性更强,也很少引起误解。
# 推荐的写法 if key not in dict: pass # 等价但不推荐的写法 if not key in dict: pass从性能上讲没有明显差别,但not in在语义上更清晰,也避免了not (key in dict)的括号细节。写代码是给人看的,能用清晰的运算符表达清楚,就不要用组合逻辑绕弯子。
7. 成员与身份运算符:in 的判断效率与 is 的复制警告
7.1in的性能差距:list 与 set 的差别是 O(n) 和 O(1)
in运算符在不同容器上的性能差异巨大。在列表中,in是线性扫描,时间复杂度和列表长度成正比;在集合和字典中,in是哈希查找,时间复杂度和容器大小无关。
import time data_list = list(range(1000000)) data_set = set(data_list) target = 999999 start = time.time() print(target in data_list) print("list 查找耗时:", time.time() - start) start = time.time() print(target in data_set) print("set 查找耗时:", time.time() - start)实测下来,百万级数据上 list 的in查找大约是 set 的几百倍耗时。这不是 Python 实现太慢,而是两种数据结构的本质区别:list 是顺序存储按位置访问,set 是哈希表按内容定位。
所以当你需要频繁判断某个元素是否在容器中时,一开始就考虑用set或dict,而不是把所有元素装进 list 再做in判断。这是一个写代码之前就该做好的数据结构和运算符搭配决策。
7.2is与副本拷贝:复制对象时最容易犯的"身份错觉"
除了小整数缓存,is在字符串上的行为更迷惑。Python 会缓存部分字符串,尤其是短字符串和看起来像标识符的字符串,导致a = "hello"; b = "hello"; a is b返回 True。但一旦字符串由拼接、切片或格式化产生,缓存机制就不一定生效了:
a = "hello" b = "hel" + "lo" print(a is b) # CPython 中可能是 True,字面量拼接会被编译器折叠 c = "".join(["h", "e", "l", "l", "o"]) print(a is c) # 可能是 False,运行时拼接产生新对象 d = "hello " print(a is d.strip()) # 取决于解释器实现,大概率 False还有一种常见误用场景是复制自定义对象后立刻用is验证"复制成功"。比如你用切片复制列表,new_list = old_list[:],然后new_list is old_list返回 False,这是正常的,因为切片创建了新对象。但如果你做的是new_list = old_list.copy(),结果当然也是 False。
用type(x) is type(y)判断类型时也要注意,这不是安全的写法。正确的类型判断应该用isinstance(),因为isinstance支持继承关系,而type() is只能做精确类型匹配。在判断子类实例时,type判断会让你误判。
class Animal: pass class Dog(Animal): pass d = Dog() print(type(d) is Animal) # False print(isinstance(d, Animal)) # True8. 位运算符:看似底层,实则是高效编码的利器
8.1 位运算基础与补码陷阱
位运算符&、|、^、~、<<、>>在 Python 中直接作用于整数在二进制下的每一位。很多人写业务代码觉得位运算用不上,但在标志位管理、权限控制、哈希算法、性能优化场景里,位运算的效率优势极其明显。
Python 整数是无限精度的,这带来一个和 C 语言截然不同的坑:负数在内存中并不像 C 语言那样是有限的二进制补码,Python 给你的是"无限长度的补码"概念,这直接导致~运算结果和直觉不一样。
print(~5) # -6,而不是预期的 -5 或 0xFFFFFFFA print(~0) # -1 print(~-1) # 0~x的规则在 Python 中是-(x+1)。因为~会把所有位取反,包括无限延伸的符号位。~5等价于-(5+1) = -6。
>>右移对于负数来说也不是简单除以 2。-5 >> 1的结果是-3,不是-2。因为右移操作在负整数上仍然是向下取整语义,-5 / 2 = -2.5,向下取整是-3。
编写位运算代码前,我建议先在交互式环境里把正负数都测试一遍,再写入正式代码。这个建议听起来很基础,但我在写权限标志位判断时,几乎每一次都要用实际数值确认过正负号行为,才能在压测时不翻车。
8.2 位运算的三个实用场景
第一个是权限标志位管理。用一个整数存储多个开关状态,每一位代表一个权限:
READ = 1 << 0 # 1 WRITE = 1 << 1 # 2 EXEC = 1 << 2 # 4 user_perm = READ | WRITE # 3 has_write = bool(user_perm & WRITE) # True has_exec = bool(user_perm & EXEC) # False把四个布尔值压缩到一个整数字段里,存数据库时只占一列,读取时用位运算解包,效率高且逻辑清晰,这在配置管理中非常常见。
第二个是快速乘除 2 的幂。x << 1等价于x * 2,x >> 1等价于x // 2。注意这个等价关系只对标量整数操作成立,不要用在负数上碰运气。
第三个是利用异或^特性做值交换和加解密基础:
# 不用临时变量交换两个整数 a = 10 b = 20 a ^= b b ^= a a ^= b # 现在 a = 20, b = 10异或交换在算法题里是个经典技巧,但实际业务代码中我不建议用,可读性实在太差。了解原理即可,真正写代码时老老实实用临时变量或者直接a, b = b, a。
8.3 位运算的优先级再次提醒
位运算的优先级介于移位和比较之间,也就是说a & b == 0会被解析成a & (b == 0)而不是(a & b) == 0。这又是一个优先级导致结果错误的典型雷区。判断"a 和 b 按位与非零"时,必须写成:
if (a & b) == 0: pass不加括号的位运算判断,我见过太多次了。一个很普通的权限判断代码,因为漏掉了括号,直接导致权限校验失效,这是一个在生产环境里能引起安全事故的 bug。请大家一定把括号养成习惯。
9. 运算符重载:让自定义对象融入语言生态
9.1 基础重载:__add__、__sub__怎么实现
运算符之所以对不同类型的对象有不同行为,本质是对象定义了对应的特殊方法。自定义类也可以通过实现这些特殊方法,让自己支持+、-、*等运算符。
拿三维向量举一个实际例子:
class Vector3D: def __init__(self, x, y, z): self.x = x self.y = y self.z = z def __add__(self, other): return Vector3D( self.x + other.x, self.y + other.y, self.z + other.z ) def __repr__(self): return f"Vector3D({self.x}, {self.y}, {self.z})" v1 = Vector3D(1, 2, 3) v2 = Vector3D(4, 5, 6) print(v1 + v2) # Vector3D(5, 7, 9)这只是最基础的形态。实际中还要处理:
__iadd__:原地加法+=__radd__:右加法,比如5 + v1__eq__:相等判断__lt__:小于判断,用于排序
9.2NotImplemented和NotImplementedError是两码事
重载中最容易踩的坑是把NotImplemented和NotImplementedError搞混。
NotImplementedError是一个异常类,用于在抽象方法中声明"子类必须实现"。NotImplemented是一个单例对象,用于二元运算符表示"我不支持这种组合运算"。
class Number: def __init__(self, value): self.value = value def __add__(self, other): if isinstance(other, Number): return Number(self.value + other.value) return NotImplemented # 关键点:返回这个单例,而不是抛异常当__add__返回NotImplemented时,Python 会尝试调用对方的__radd__;如果对方也没有实现,最终才抛TypeError。如果你错误地在__add__里raise NotImplementedError,程序会直接崩溃,而不会给另一个对象一个机会来处理这个运算。
在实现数值运算类时,注意NotImplemented的返回值是必须的。这也是"运算符重载正确性"和"普通异常处理"之间最容易被忽视的界限。
9.3 运算符重载实际应用中的注意清单
重载__eq__时,有个隐藏问题:如果你只重载__eq__而没有同步重载__hash__,这个对象就会变成不可哈希的,放进字典键或集合时会报错。Python 的要求是:如果两个对象相等,它们的哈希值也必须相等。反之,如果重载了__hash__但没有保证相等对象的哈希一致,字典查找就会出现"能相等但找不到键"的诡异现象。
重载比较运算符时,推荐用functools.total_ordering装饰器,它可以根据你定义的__eq__和__lt__自动补全<=、>、>=:
from functools import total_ordering @total_ordering class Person: def __init__(self, name, age): self.name = name self.age = age def __eq__(self, other): return self.age == other.age def __lt__(self, other): return self.age < other.age p1 = Person("Alice", 30) p2 = Person("Bob", 25) print(p1 > p2) # True print(p1 >= p2) # True运算符重载的能力是把双刃剑。它让自定义类型用起来像内置类型,但过度重载会严重损害代码可读性。比如把一个业务类重载+来表示"合并订单",如果你不加足够的文档说明,同事看到order1 + order2会一头雾水。重载前先问自己:这个运算符语义是不是足够直觉?如果答案不明确,不如改成命名方法order1.merge(order2)。
10. 常见问题速查表:把踩过的坑贴墙上
经常有人问我:"Python 运算符还有哪些坑?" 与其零散地记,不如整理成一张速查表。下面这份表格,是我把多年排查过的线上问题整理出来的,每一行背后都对应一个真实 bug:
| 场景 | 错误写法 | 正确写法 | 坑的本质 |
|---|---|---|---|
| 负数的整除 | -7 // 3期望 -2 | 结果是 -3 | 向下取整不是截断 |
| 负数的取模 | -7 % 3期望 -1 | 结果是 2 | 余数符号跟随除数 |
| 浮点精度 | 0.1 + 0.2 == 0.3 | math.isclose(0.1+0.2, 0.3) | IEEE 754 精度 |
| 幂和负号 | -2 ** 2期望 4 | 结果是 -4,要写(-2) ** 2 | 优先级高于负号 |
| 链式右结合 | 2 ** 3 ** 2期望 64 | 结果是 512 | 幂运算是右结合 |
| None 判断 | x == None | x is None | __eq__可能被重载 |
| 列表拷贝 | b = a后改 b 想不改变 a | 用b = a.copy() | 变量是标签不是盒子 |
| 复合赋值别名 | a += [x]共享对象被改 | 用a = a + [x] | __iadd__原地修改 |
| and/or 返回值 | 认为返回布尔值 | 返回操作数本身 | 短路求值语义 |
| or 默认值 | config.get("port") or 8080想忽略 0 | 显式判断 None | 0 是假值但合法 |
| 位运算判断 | a & b == 0 | (a & b) == 0 | 优先级低于== |
| 右位移负数 | -5 >> 1期望 -2 | 结果是 -3 | 向下取整语义 |
~x取反 | ~5期望 0xFFFFFFFA | 结果是 -6 | 无符号位概念 |
| 类型判断 | type(x) is int | isinstance(x, int) | 继承关系被忽略 |
| 可变默认参数 | def f(a, lst=[]) | 用 None 加内部创建 | 默认值只创建一次 |
| 重载没回 NotImplemented | 抛NotImplementedError | 返回NotImplemented | 二元运算符双派发机制 |
这张表我建议你收藏起来,写代码遇到相似场景时翻一眼。很多问题不是"不懂",而是"以为自己懂但记错了细节"。
11. 我的最后一个建议:用类型标注与静态检查工具兜底
运算符相关的坑,很多靠记忆和经验能避开,但人总会遗忘、会劳损、会在 deadline 前写出糊涂代码。所以我强烈建议在项目里启用类型标注,并且把mypy或pyright加入 CI 流程。
比如def calc(a: int, b: int) -> int这种基础标注,能让静态检查工具在a + b之前就告诉你类型是否可能不匹配;Optional[int]结合is not None的检查,也能在编译期提前发现潜在的空值运算问题。
类型标注不能消除所有运算符坑(比如浮点精度、负数整除这类语义问题),但它能帮你拦住一批最基础、最容易被忽略的类型错误,把踩坑概率显著降低。我自己的项目里用pyright做了严格模式后,跟运算符相关的低级 bug 几乎消失,排查时间也从"小时级"降到了"分钟级"。
写了这么长,最后再说一点心得。运算符是 Python 的基石,它并不枯燥,只是细节多。不要把这篇内容当知识点背,而是带着实际场景去看:写出一个-7 // 3试试,打断点看一次;把a += [1]放在共享列表的场景里,观察一次引用变化。只有亲手踩过坑,你才会对运算符体系真正形成肌肉记忆。如果这篇文章能帮你少踩几次坑、少熬几次夜,那就算值回票价了。