☰
Python运算符详解:优先级、陷阱与实战避坑指南
2026/10/7 4:29:28 网站建设 项目流程

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 运算符优先级:看似由高到低,实则处处藏雷

优先级这张表,科班出身的人能背,但实际写代码时该踩的坑一个也没少。我把从高到低的常见优先级列出来:

  1. 幂运算**
  2. 一元正负号+x-x,按位取反~x
  3. 乘法、除法、取模、整数除法* / % //
  4. 加法、减法+ -
  5. 移位<< >>
  6. 按位与&
  7. 按位异或^
  8. 按位或|
  9. 比较运算== != > < >= <=
  10. 身份is / is not,成员in / not in
  11. 逻辑非not
  12. 逻辑与and
  13. 逻辑或or
  14. 条件表达式x if cond else y
  15. 赋值与海象= :=

这里最容易翻车的点有三个。

第一,**的优先级比一元负号高。-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()会暴露真实值。

处理浮点数比较有三条路:

  1. 用math.isclose()判断近似相等。
  2. 涉及金额计算时用Decimal。
  3. 能用整数运算就别碰浮点数。

在普通业务逻辑中,比如判断某个百分比是否达到阈值,我用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): pass

5. 赋值运算符:从=到:=的认知升级

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;否则返回b
  • a 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)) # True

8. 位运算符:看似底层,实则是高效编码的利器

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.3math.isclose(0.1+0.2, 0.3)IEEE 754 精度
幂和负号-2 ** 2期望 4结果是 -4,要写(-2) ** 2优先级高于负号
链式右结合2 ** 3 ** 2期望 64结果是 512幂运算是右结合
None 判断x == Nonex 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显式判断 None0 是假值但合法
位运算判断a & b == 0(a & b) == 0优先级低于==
右位移负数-5 >> 1期望 -2结果是 -3向下取整语义
~x取反~5期望 0xFFFFFFFA结果是 -6无符号位概念
类型判断type(x) is intisinstance(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]放在共享列表的场景里,观察一次引用变化。只有亲手踩过坑,你才会对运算符体系真正形成肌肉记忆。如果这篇文章能帮你少踩几次坑、少熬几次夜,那就算值回票价了。

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

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

立即咨询