
1. 运算符的本质是方法调用先理解这一层再谈使用很多Python入门教程会把运算符罗列成一张表算术、比较、逻辑、位运算……看上去一目了然好像没什么可深究的。但我做项目越久越觉得运算符恰恰是初学者最容易留下坏习惯的地方。原因很简单运算符看起来像数学符号实际上在Python里是语法糖真正的执行逻辑是一系列对象方法。1.1 一个运算符背后对应着一个魔术方法你在写a b的时候Python解释器实际执行的是a.__add__(b)。如果没有找到__add__会尝试b.__radd__(a)再不行就抛TypeError。这跟你在数学课上学到的“加号就是把两个数加起来”是完全两码事。a 3 b 5 print(a b) # 8 print(a.__add__(b)) # 8 print(int.__add__(a, b)) # 8同理a - b对应__sub__a * b对应__mul__a / b对应__truediv__a // b对应__floordiv__a % b对应__mod__a ** b对应__pow__。比较运算符也一样会调用__lt__调用__le__调用__eq__。理解这一层的价值在于你现在写的代码本质上是在安排对象之间的协作而不是在做数学运算。同样是1 2得到整数3a b得到字符串ab[1] [2]得到列表[1, 2]。同一个符号三种完全不同的行为——为什么因为三种类型的__add__方法实现不同。1.2 operator模块把运算符变成函数的形态Python 的operator模块把每个运算符又包了一层函数。operator.add(a, b)等价于a boperator.mul(a, b)等价于a * b。那为什么要多此一举因为在functools.reduce、sorted这类回调函数场景里直接传函数名比写 lambda 干净得多。from functools import reduce import operator numbers [1, 2, 3, 4] print(reduce(operator.add, numbers)) # 10 # 对比lambda写法 print(reduce(lambda x, y: x y, numbers)) # 10实际项目里sorted排序字典列表时也常用operator.itemgetter它与运算符关系密切。读别人源码时见到operator.add、operator.and_这类函数不用懵都是运算符的函数形态。注意and是关键字不能做属性名所以模块里用operator.and_。1.3 理解底层对调试和读源码有什么用一旦你接受“运算符是方法调用”这个设定很多怪现象就有了合理的解释。比如为什么1 1会报TypeError而不是像 JavaScript 那样自动拼接成11——因为int.__add__没定义与字符串相加的逻辑它只接受整数。再比如为什么自定义类可以定义__eq__来控制的行为——因为本来就是在调用这个方法。另一个实用的点是写代码时遇到TypeError: unsupported operand type(s)不要只盯着运算符本身要去看两边的对象类型是否支持对应的魔术方法。我之前排查过一个线上问题某人重写了类的__eq__但忘了返回布尔值导致if a b判断永远走到else分支。这类 bug 不报错极难发现原因就是在底层完全信任__eq__的返回值。2. 算术运算符实操除法三兄弟的分工、负数取模的奇怪表现与浮点精度算术运算符是大家最早接触的但调查过身边二十多位写过几年 Python 的同事后我发现至少有一半人说不清楚//和%在负数下的行为也解释不了0.1 0.2 ! 0.3的现象。2.1 真除法、整除、取余在面对负数时的差异Python 里有三种除法相关运算/是普通除法结果是浮点数//是整除结果向下取整floor%是取余结果符号与除数一致。前两个好理解大多数人栽在负数和//、%的组合上。print(7 // 2) # 3 print(-7 // 2) # -4 print(7 // -2) # -4 print(-7 // -2) # 3这里的关键是//是向下取整不是向零取整。数学里的向下取整函数floor是往更小的方向取。-7 / 2 -3.5比它小的整数是-4所以结果是-4。这与 C、Java 之类的语言不同那些语言的整数除法往往是向零截断同样-7 / 2会得到-3。取余运算%也不是简单的“整除后的余数”它的精确定义是a % b a - (a // b) * b。既然-7 // 2 -4那么-7 % 2 -7 - (-4 * 2) -7 8 1。print(7 % 2) # 1 print(-7 % 2) # 1 print(7 % -2) # -1 print(-7 % -2) # -1注意看结果的正负只由除数决定除数正结果正除数负结果负。这个性质在做环形数组、周期调度时非常有用。比如index (current - 1) % n用 Python 写永远能保证结果落在[0, n-1]不会出现负下标。2.2 取模和整除在实际场景里怎么用项目里最常见的用途是分页和轮询# 分页知道总条数和每页条数算总页数 total 97 page_size 10 pages (total page_size - 1) // page_size print(pages) # 10 # 轮询调度三个服务器轮流处理请求 server_id 0 for _ in range(5): server_id (server_id 1) % 3 print(server_id) # 1 2 0 1 2时间换算也常用divmod它可以同时拿到商和余数total_seconds 3661 minutes, seconds divmod(total_seconds, 60) hours, minutes divmod(minutes, 60) print(hours, minutes, seconds) # 1 1 1强调一下负数的取模在日期计算中的正确性。比如“今天是周二三天前是星期几”如果直接用(2 - 3) % 7Python 得到的是 6直接就是正确答案。这一点是 Python 相对 C 系语言的优势写业务代码时能省好多if修正。2.3 浮点精度0.1 0.2 不等于 0.3 的原因与对策这是每个 Python 开发都会遇到的经典问题。在交互式环境里执行0.1 0.2得到的是0.30000000000000004。原因不是什么 Python 的 bug而是计算机内部用二进制表示浮点数0.1和0.2在二进制里是无限循环小数存储时就丢了一部分精度。print(0.1 0.2 0.3) # False print(round(0.1 0.2, 2) 0.3) # True需要精确计算金额时我建议直接用decimal.Decimal而不是用浮点数算完再 roundfrom decimal import Decimal price Decimal(0.1) Decimal(0.2) print(price) # 0.3注意Decimal的构造参数用字符串不要直接写Decimal(0.1)那会把已经在二进制浮点里精度受损的值传进去等于白用。工程上做金额累加、折扣计算等场景坚持用 Decimal后面出对账问题的概率会小很多。2.4 幂运算和优先级里的易错点**是幂运算符但它的结合方向与加减乘除相反是右结合print(2 ** 3 ** 2) # 512等价于 2 ** (3 ** 2)新手一看2 ** 3 ** 2按从左到右算成(2 ** 3) ** 2 64就错了。Python 官方文档明确写了幂运算的优先级高于右侧的一元运算符但低于左侧的一元运算符。这句话很抽象直接记结论写成连乘幂时一定要加括号没人愿意读这种依赖结合性的代码。3. 比较、赋值与身份三组符号最容易被搞混的边界、、is这三样东西是面试和代码评审里出现频率最高的低级错误来源。它们表面看都是“判断”或“给值”实际分工完全不同。3.1 是赋值 是判断两个等号别少写是赋值运算符是比较运算符这是写进任何一本教材的常识但真到高强度写业务代码时少写一个等号的状态判断几乎每个人都会遇到# 错误写法 if user_name admin: print(is admin) # 正确写法 if user_name admin: print(is admin)Python 不允许在if条件里直接赋值所以这种错误会直接报SyntaxError比 C 语言那种“悄悄赋值然后永远为真”好很多。但如果你在类属性赋值时少写了一个等号比如self.count 0它不会报错只是静默地做了一个比较然后丢弃结果——这类 bug 不好查建议养成写完赋值语句扫一眼的习惯。3.2 is 与 的区别小白最容易忽略的差异比较的是值是否相等底层调用__eq__方法is比较的是两个对象是不是同一个身份内存地址底层比较的是对象的 id。a [1, 2, 3] b [1, 2, 3] print(a b) # True值相等 print(a is b) # False不是同一个列表对象 c a print(c is a) # Truec和a指向同一个对象判断None时要用is这是社区约定也是效率考虑if data is None: pass不要写if data None虽然大多数时候结果一样但当某个类重写了__eq__并搞出不规范返回时 None可能返回诡异结果。is None直接比较身份安全可靠。3.3 小整数缓存与链式比较的坑Python 的 CPython 实现会缓存-5到256之间的整数对象所以小整数用is比较通常返回 Truea 1 b 1 print(a is b) # True x 257 y 257 print(x is y) # False大多数情况下注意这个行为不是语言规范只是 CPython 的优化实现。不同版本、不同环境下结果可能不同。我的建议是不要在任何业务代码里依赖整数is比较你只需要记住is是用来比较单例对象None、True、False的。链式比较是 Python 的特色3 x 5这样的写法在其他语言里基本见不到。它的求值机制相当于3 x and x 5但有一个细节中间的表达式只会求值一次。def get_value(): print(get_value called) return 4 result 2 get_value() 6 # 只会打印一次 get_value called print(result) # True这个特性在阅读代码时会造成一点理解负担但它本身是个很好的能力。只要不搞出特别长的链式比较比如a b c d e可读性其实是提升的。3.4 赋值运算符全家桶、- 为什么建议谨慎用赋值运算符有、-、*、/、//、%、**、、|、^、、这些都是“取当前值做运算再赋回去”的简写。大多数时候没问题但列表的有一个反直觉行为a [1, 2] b a a [3] print(a) # [1, 2, 3] print(b) # [1, 2, 3]b也变了 c [1, 2] d c c c [3] print(c) # [1, 2, 3] print(d) # [1, 2]d没变对可变对象执行的是原地修改相当于list.extend而c c [3]创建了一个新列表再赋值给c。如果函数里不小心对外部列表用了可能产生意外的副作用。所以我写代码时有一条不成文规定函数参数是可变对象时先复制再修改或者明确用list.extend替代防止背锅。4. 逻辑运算符三个关键点短路、非布尔返回值与默认值兜底Python 的and、or、not三个逻辑运算符和 Java、JavaScript 里的同类运算符是两回事。最大的区别是 Python 的逻辑运算返回的是操作数本身而不一定是 True 或 False。4.1 and 和 or 返回的是操作数而不是布尔值很多人以为a and b返回的是布尔值其实它返回的是决定整个表达式结果的那个操作数print(0 and hi) # 0 print(3 and hi) # hi print(0 or hi) # hi print(3 or hi) # 3规则可以这样记and在遇到第一个假值时就返回它否则返回最后一个值or在遇到第一个真值时就直接返回它否则返回最后一个值。这个机制让 Python 写默认值、守卫条件非常方便很多人天天在用却意识不到自己在依赖它。4.2 短路求值的性能收益与隐藏bug短路求值指“能确定结果就停止计算后面的表达式”。比如False and expensive_func()expensive_func()根本不会被调用。这既是性能优化也是写条件守卫的基础if user is not None and user.is_admin(): # 只有 user 不是 None 时才调用 is_admin() pass如果不小心把顺序写反user.is_admin() and user is not None在user为 None 时直接抛AttributeError。这种错误在传参不确定的代码里很常见。但短路也是一把双刃剑。如果and右侧的表达式有副作用比如给计数加一、写入日志之类短路时这些副作用不会发生这可能导致隐式 bug# 本意是每次都打印调试日志但当 status 为 0 时不会打印 status 0 status and debug_log(status)这种写法本身就不推荐为了可读性建议逻辑清晰的多行写法。短路求值是特性不是让你把有副作用的调用塞进逻辑表达式里的理由。4.3 用 or 做默认值兜底、用 and 做条件守卫or最常见的场景是给配置项一个默认值name input_name or anonymous当input_name是空字符串、0、None 或空列表等假值时结果都会回退到默认值。注意这跟if input_name is None的语义不同or会把所有假值都替换掉假值很多不只是 None。如果只想拦住 None请写成name input_name if input_name is not None else anonymousand的使用场景则是“前面的条件成立时才取后面的值”age user.age if user else 0 # 等价写法 age user and user.age or 0 # 不推荐可读性差我在代码评审时看到过大量and/or串联的复杂表达式比如a b and c or d。这种写法源自不知道三元表达式或者想偷懒。实际维护成本非常高后来者读代码时必须心算短路规则。我的态度是and/or最多用于单个默认值兜底一旦出现“两个以上逻辑运算符连用”立刻改写成普通 if 语句或三元表达式。4.4 处理布尔取反 not 时的常见误用not返回的永远是布尔值这个好理解。但要注意not与is not、!的混用a None print(not a) # True print(a is not None) # False print(a ! None) # Falsenot a是在判断 a 的真假is not None是在判断身份两者完全不同。我自己见过有人用if not dict_data判断字典为空这没问题但如果是查询结果为 0 的数量字段if not count会把 0 当成无数据语义就不对了。判断“是否存在”用is not None判断“是否为假”才用not。5. 位运算符的真实价值权限位、标志位与二进制处理位运算符大概是 Python 里被误解最深的一组运算符。日常业务开发中它们的出场频率不算高但在权限系统、协议解析、状态压缩、高效标志位管理这些场景里用好了能极大简化代码。5.1 六个位运算符一表速查位运算符直接操作整数的二进制位有六个运算符含义例子结果按位与两个位都为1才为10b1100 0b10100b1000(8)|按位或有一个为1就为10b1100 | 0b10100b1110(14)^按位异或相同为0、不同为10b1100 ^ 0b10100b0110(6)~按位取反0变1、1变0~0b1100-13左移右边补0相当于乘2的n次方1 38右移左边补符号位相当于除2的n次方16 24初学者最容易困惑的是~。Python 的整数是任意精度的取反之后~5得到-6因为5的二进制表示前面有无数个0取反后变成无数个1也就是负数的补码表示。日常业务里~用得少但在掩码操作和某些算法里它很强。5.2 用一个权限系统案例吃透 | ^ ~ 权限管理是位运算的经典场景。假设一个系统有三档权限读、写、执行。用三个不同的二进制位来表示READ 1 2 # 0b100 4 WRITE 1 1 # 0b010 2 EXECUTE 1 0 # 0b001 1给用户分配权限用|合并admin_perm READ | WRITE | EXECUTE # 0b111 7 editor_perm READ | WRITE # 0b110 6 viewer_perm READ # 0b100 4判断是否有权限用def has_perm(perm, required): return (perm required) required print(has_perm(admin_perm, WRITE)) # True print(has_perm(viewer_perm, WRITE)) # False追加和撤销权限# 追加执行权限 perm viewer_perm | EXECUTE # 0b101 # 撤销写权限先用 ~ 做掩码再用 清掉对应位 perm editor_perm ~WRITE # 0b100这种写法的最大好处是一个整数能表达多组开关状态存储、传输都极其紧凑。如果业务权限只有十几个以内的开关比维护一长串布尔字段方便很多。代码里只要把每个位代表什么注释清楚可读性一点都不差。5.3 位运算在实战中的其他场景除权限系统外位运算还常用于状态压缩。比如记录用户在一个产品里的签到状态7天可以用一个7位的整数表示每天签到就把对应位设为1# 假设从周一到周日第0位表示周一 status 0 status | (1 0) # 周一签到 status | (1 3) # 周四签到 print(bin(status)) # 0b1001 # 判断周四是否签到 print(status (1 3)) # 8 # 连续签到7天判断 full (1 7) - 1 # 0b1111111 print(status full) # False网络编程里也常见 0xFF这类取低八位的操作本质是掩码。在解析二进制协议、处理哈希散列、分片缓存时位运算的效率优势无可替代。比如hash (bucket_size - 1)在bucket_size是 2 的幂时等价于hash % bucket_size但位运算速度更快。5.4 位运算和逻辑运算千万别混用和and、|和or长得像语义完全不同。and/or是逻辑运算符返回操作数做的是真假判断/|是位运算符返回整数做的是逐位计算。a 4 # 0b100 b 2 # 0b010 print(a b) # 0按位与 print(a and b) # 2逻辑与a为真返回b print(a | b) # 6按位或 print(a or b) # 4逻辑或a为真直接返回a这段输出第一次看的人往往很懵。判断条件里写if status FLAG通常没问题因为非零即真但如果随手写成if status and FLAG结果可能是FLAG本身而不是真假的布尔判断这在条件判断里虽然通常也能用可一旦你拿返回值去做运算就会踩坑。6. 优先级与三目运算能用括号解决的事不要交给记忆运算符优先级是一张庞杂的表格从高到低几十个类别很少有人能完整背下来。重点不是你背不背得下来而是当你写出一个“需要依赖优先级”的表达式时维护者是否也能心算出来。6.1 两个常见的优先级误判场景第一个场景是移位和加减法混用。你可能觉得位移肯定先算但实际是加法优先print(1 2 3) # 32等价于 1 (2 3) print((1 2) 3) # 7这才是你可能想要的结果1 2 3在多数人直觉里是(1 2) 3 7但 Python 规则是优先级高于所以结果是1 5 32。这种代码线上出了也不容易排查因为它不报错只是数值不对。第二个场景是逻辑运算与比较运算混用# 期望的意思x 在 0 到 10 之间 x 5 if 0 x 10: # True pass # 如果不懂链式比较而写成 if 0 x and x 10: # 等价但更长 pass # 更危险的是位运算和逻辑运算混在一起 a 6 b 12 # 设想判断 a 和 b 是否都有 WRITE 权限 WRITE 0b010 if (a WRITE) and (b WRITE): pass不熟悉位运算的人可能把a WRITE and b WRITE对不对这里的优先级高于and所以逻辑上没问题但可读性很差。正确做法是给位运算部分加括号让意图一目了然。6.2 三目运算符为什么从右往左结合Python 的三目表达式是x if condition else y它跟 C 系语言的condition ? x : y不太一样读起来更像英语。所谓“从右往左”主要体现在嵌套时value a if cond1 else b if cond2 else c # 解析为 value a if cond1 else (b if cond2 else c)Python 没有独立的else if三目语法嵌套三目的结合顺序就是从右往左每个else会把后面一整段当成自己的分支。我实测过超过一层的嵌套三目表达式几乎没有人能一眼读懂它到底在算什么。真实项目里最好就写两层以内level admin if user.role 1 else user如果要表达多分支我建议直接用字典映射或者普通 if/elifif score 90: grade A elif score 80: grade B else: grade C这种写法没有歧义任何人接手都不用做心算。6.3 优先级的实用记忆策略与括号约定我自己的记忆策略很简单乘除高于加减比较高于逻辑and 高于 or其余一切靠括号。只要涉及移位、位运算、复杂布尔表达式无条件加括号。# 不推荐 result a mask b | c # 推荐 result (a mask) (b | c)团队拿着这份约定写代码哪怕成员对优先级表不那么熟也不会出现解析歧义。每次代码评审时我看到“需要停下来查优先级表”的表达式都会直接标注要求改为括号版本。另一个值得注意的点是Python 官方文档里not的优先级低于比较运算符但高于and/or。这导致not a b被解析为not (a b)而不是(not a) b。如果表达的意思就是 “a 与 b 不相等”更清晰的写法是用a ! b不要用not a b。6.4 格式化代码与运算符空格的习惯优先级问题还可以通过格式化规避一部分。PEP8 建议在二元运算符两侧各留一个空格但幂运算**两侧可以不留因为幂运算优先级足够高、歧义少# PEP8 风格 x a b * c y a ** b z (a mask) | (b mask)我之前接手过一个没有格式化工具的项目里面满是一行写十几层运算的表达式读起来跟看天书一样。后来团队给整个工程接了black很多潜在的优先级误读问题直接消失了。工具能解决规范问题但解决不了你写代码时的表达习惯——尽量把复杂表达式拆开或者提取成带名字的中间变量。7. Python专属运算符玩法in、海象运算符与 的使用Python 除了继承自 C 系语言的那套运算符还有几个自己特有的或者用法独特的运算符in、:(海象运算符)、都值得单独说说因为它们经常在“写了好几年 Python 还是不会用”的列表里。7.1 in 成员运算与容器查找in用来判断一个元素是否在容器里print(a in abc) # True print(2 in [1, 2, 3]) # True print(name in {name: Tom}) # Truedict 的 in 检查的是键in底层的逻辑是调用对象的__contains__方法。没有__contains__时解释器会尝试迭代对象如果还不能判断就退回用__getitem__下标查找。这个机制意味着任何实现了迭代协议的对象基本都能用in非常灵活。性能上有个常见建议判断元素在大列表里是否存在用in是 O(n) 的遍历。如果这个判断出现在循环里把列表先转成集合或者字典O(1) 的哈希查找会快出几个数量级users fetch_large_user_list() user_set set(users) for some_user in candidate_users: if some_user in user_set: pass7.2 海象运算符 : 的典型使用场景:是 Python 3.8 引入的海象运算符它的作用是“在表达式中同时完成赋值和取值”。两个最常见的场景是循环里避免重复调用和条件里临时赋值# 读取文件直到结束不用在 while 和循环体里写两遍read while (chunk : file.read(1024)): process(chunk) # 列表推导里复用计算值 results [y for x in data if (y : heavy_transform(x)) is not None]我最初并不推荐滥用海象运算符因为它打破了“赋值不是表达式”的直觉会增加阅读负担。但有两个场景我认为它确实值得用一是while循环的哨兵模式能保证read只调用一次、结果只有一个放置位置二是在判断与复用之间强绑定的时候比如正则匹配if (match : re.search(pattern, text)): print(match.group(0))没有海象运算符时得先match re.search(...)再if match:多一行不算什么但多个类似判断时这种写法可以避免缩进层级变深。7.3 矩阵乘法与自定义类的运算符重载在 Python 3.5 被引入专门用于矩阵乘法。在 NumPy 里它几乎无处不在import numpy as np A np.array([[1, 2], [3, 4]]) B np.array([[5, 6], [7, 8]]) print(A B) # [[19 22] # [43 50]]在 NumPy 出现之前矩阵乘法要写np.dot(A, B)语义不够直观。的优势是符号本身传达“做矩阵运算”的含义代码读起来更像线性代数公式。7.4 运算符重载要遵循的直觉原则自定义类可以通过实现魔术方法让运算符“为自己服务”。一个最典型的例子是实现二维向量class Vector: def __init__(self, x, y): self.x x self.y y def __add__(self, other): return Vector(self.x other.x, self.y other.y) def __repr__(self): return fVector({self.x}, {self.y}) v1 Vector(1, 2) v2 Vector(3, 4) print(v1 v2) # Vector(4, 6)重载运算符的原则是让运算符的行为符合直觉。就应该是“合并”“累加”*就应该是“缩放”或“内积”不要搞出实际是减法的反常识设计。想想datetime模块date timedelta返回新日期date - date返回时间差多直觉。你重载运算符时也要追求这种别人不用看文档就能猜出结果的设计。我在生产代码里重载过__eq__用于对象比较重载过__lt__用于自定义排序重载过__matmul__用于业务里的矩阵语义运算。每一次都反复确认“普通用户对这个符号的预期行为是什么”。运算符是表达能力极强的工具滥用它是灾难正确使用它就是抽象利器。