Python运算符全解:从算术到位运算的易错点与实战技巧
写Python代码的人十个里有九个都觉得自己会把运算符用得明明白白。结果呢and和or返回的不一定是布尔值负数取余和数学课上的余数概念有偏差和is在列表身上表现得完全是两码事。细拆下去踩坑的地方比想象中多得多。这篇文章就围绕Python中的运算符系统展开把算术运算、赋值运算、比较运算、逻辑运算、位运算、身份与成员运算全部过一遍。不光是列语法规则更会把运算符底层执行逻辑、常见易错点、实际编码中的注意事项一起讲清楚。无论你是刚装好Python还没写几行代码的新手还是已经写了段时间但老在运算符上犯迷糊的进阶学习者这篇内容都值得认真看一遍。1. 先给运算符分个类别着急动手写代码1.1 Python里的运算符一共分几类在开始逐个拆解之前先把整体框架立起来。Python中的运算符按照功能可以分成七大类算术运算符、赋值运算符、比较运算符、逻辑运算符、位运算符、身份运算符、成员运算符。这七类运算符解决的问题各不相同。算术运算符做数学计算赋值运算符给变量绑定值比较运算符判断两个值的大小和相等关系逻辑运算符组合多个条件位运算符直接操作二进制位身份运算符比较两个对象是不是同一个成员运算符判断一个值在不在一个集合里面。日常编码中用到最多的是算术、比较、逻辑、赋值这四类位运算在某些特定场景比如权限系统、状态压缩里会发挥巨大作用而身份运算符虽然用得不多但if x is None这种判断在写代码时几乎是免不了的。分类的意义在于当你遇到这个代码为什么结果不对的问题时能够先从运算符类型上做一次框架性排查。比如表达式结果不符合预期先看看是不是比较和赋值混用了写成了再看看是不是逻辑运算符的返回值特性导致结果类型不是自己以为的布尔值最后考虑优先级问题。有了分类体系排查思路会清晰很多。1.2 搞懂运算的本质运算符和操作数的关系很多教程一上来就罗列运算符的语法但从来不讲运算符背后到底发生了什么。其实在Python里运算符本质上是一种语法糖底层调用的是对象的特殊方法也叫魔法方法、双下划线方法。举个例子a b这行代码表面上是两个变量相加实际上Python解析器在执行时会去调用a对象的__add__方法把b作为参数传进去。如果a没有定义__add__方法Python会尝试调用b的__radd__方法来尝试反向相加再不行就抛出TypeError: unsupported operand type(s) for 的报错。这个理解为什么重要因为Python中运算符的行为并不是固定的——不同数据类型对同一个运算符有不同的实现。字符串用做拼接列表用做合并数字用做加法它们都叫但底层执行的是各自类型里的不同方法。当你用把两个列表拼在一起字符串对的处理逻辑是拼接成新字符串而列表是生成一个包含所有元素的新列表这些细节如果不看底层就很容易理解偏差。理解了运算符底层是方法调用就能解释很多看似诡异的现象为什么自定义对象默认不能比较大小、为什么print一个对象时输出的是__main__.X object at 0x...这样的内容。因为你没在类里实现对应的魔法方法Python只能用默认行为来兜底。2. 算术运算符不只是加减乘除2.1 加减乘除的隐藏行为Python的算术运算符一共有七个加、-减、*乘、/除、//整除、%取余、**幂。前四个看起来简单但坑全藏在细节里。先来说/这是很多从C语言、Java转过来的程序员最容易踩坑的地方。C语言里5 / 2结果是2因为整数除法直接截断小数部分但在Python 3里/执行的是真正的除法5 / 2得到的永远是2.5类型是浮点数哪怕是6 / 2这样能整除的式子结果也是3.0而不会是3。这个设计其实是Python故意为之的。Python选择了让/始终返回浮点结果避免不同语言里同一个符号得到不同类型结果的隐式转换混乱。如果你想要C语言那种整数除法的效果在Python里应该用//比如5 // 2的结果就是2。加减乘这三个运算符倒没什么大坑但要注意不同数据类型之间的运算兼容性。abc * 3会得到abcabcabc这是重复字符串的意思但abc * def就会直接报错字符串和字符串之间不能做乘法。列表也有类似特性[0] * 5得到[0, 0, 0, 0, 0]这个技巧在初始化定长列表时非常好用但要注意它做的是浅拷贝——如果列表中元素本身是可变对象乘出来的多个元素会共享同一个引用改一个全改这个坑后面细说。2.2//地板除和%取余到底怎么算//和%看起来特别简单但它们在Python中的行为和其他语言有微妙的差异尤其是处理负数的时候。先记住这个公式它是一切的底层逻辑a // b floor(a / b) # 向下取整 a % b a - b * (a // b) # 余数公式关键在向下取整这四个字上。向下取整的意思是往数轴负无穷方向取整不是简单地把小数截掉。比如-7 // 3-7 / 3 -2.333...向下取整后是-3而不是-2。再代入余数公式-7 % 3 -7 - 3 * (-3) 2。这就产生了一个反直觉的结果在Python里-7 % 3的结果是2正数而在C语言里这个式子的结果是-1。原因是C语言做的是向零截断Python做的是向下取整两者的余数符号规则不同。在实际应用中这个差异很重要。比如你要把一组时间戳换算成星期几day timestamp % 7如果timestamp是负数在Python里这个结果会落在0-6之间保证索引永远合法用C语言风格的取余就可能拿到负数索引。这也是为什么Python中%取余特别适合处理循环索引从负数归入正区间这类场景。//的另一个隐藏特性是当两个操作数中有一个是浮点数时它的返回值也是浮点数。9.0 // 2的结果是4.0而不是4类型变了这点在做数值计算时容易让人意外建议随手验证。2.3 幂运算和优先级别写出有歧义的表达式**是幂运算符2 ** 10等于1024这个大家基本都知道。但幂运算的优先级有一个容易踩的坑它的优先级高于单目运算符-负号但又低于一些其他运算符从而导致表达式含义和直觉不同。比如-2 ** 2在Python里结果是-4不是4。原因是**优先级高于-所以Python把它解释为-(2 ** 2)也就是先算2 ** 2 4再取负。要得到负2的平方必须显式写成(-2) ** 2。在实际项目中我见过太多因为这种优先级问题导致的代码bug。所以我的建议很简单凡是涉及**与负号、取反运算符组合的表达式一律加括号。代码是写给人看的不应该让阅读者去翻一张优先级表来理解你的意图。另外一个和幂运算相关的小技巧2 ** 0.5可以直接求平方根2 ** (1/3)可以求立方根不需要额外导入math模块做sqrt和cbrt。在做快速数值计算时这个写法很简洁。3. 赋值运算符从单次赋值到链式赋值、复合赋值3.1 为什么是赋值而不是相等赋值运算符在Python里只有一个基础符号。它的作用是把右侧的值绑定到左侧的名字上。这个绑定不是存放这里有很多朋友理解有偏差。引用一段Python官方文档的说法Python中的变量可以理解为贴在对象上的标签。执行a [1, 2, 3]时其实是创建了一个列表对象[1, 2, 3]然后把名字a贴到了这个对象上。当你执行b a时并不是复制一份新列表而是把b这个标签也贴在了同一个列表对象上。此时修改a通过索引改内容b也会跟着变——因为它们是同一个对象。这就是为什么Python中有可变对象和不可变对象的区分。整数、字符串、元组属于不可变对象所谓改变变量的值其实是换了一个新对象并重新绑定了标签列表、字典、集合属于可变对象修改操作是在对象内部完成的不需要换标签。理解是绑定的另一个重要含义是Python中不支持C语言的连续赋值在声明场景下等同于同时定义这种概念但在运行时支持链式赋值比如a b c 1这会把三个名字全部指向整数对象1。链式赋值对于不可变对象来说没有风险但如果你写的是a b []那两个名字指向同一个空列表对a做append操作后b也会看到相同的变化。真想要两个独立列表必须写成a []; b []。3.2 复合赋值运算符、-的底层逻辑复合赋值运算符把运算和赋值合并成一个操作常见的有、-、*、/、//、%、**、、|、^、、。这部分内容看着简单实际上隐藏着一个非常重要的知识点复合赋值并不等价于先运算再赋值的简单组合。拿a b来说它的执行逻辑是计算a b的结果尝试调用a对象的__iadd__方法就地加法如果__iadd__不存在则退回到__add__把结果重新绑定到a关键在于第二步的就地和第三步的区别。对于不可变对象整数、字符串、元组__iadd__不存在所以a b等价于a a b会创建新对象。对于列表这种可变对象__iadd__存在a b等价于在原来的列表对象上执行extend不会新建对象。这个差异的后果是如果你把同一个列表传给了两个变量对其中一个做操作另一个也会看到变化但对其中一个做 旧值 另一个列表的普通加法并重新赋值那另一个变量不会变化因为生成了新列表。除了这个区别还有一个容易忽略的点它的可读性在大多数场景下优于冗长的完整写法但调试时可观察的信息变少了。比如写total price如果要加日志就得多写一行写total total price可以很容易在中间插入打印。不过从代码整洁度来说复合赋值还是更推荐的写法只要保证在同一个作用域内操作即可。我在实际编码里还见过一种误用在函数内部想修改全局变量写成count 1然后报错UnboundLocalError。这个错误的原因在于包含赋值语义Python会把count视为函数内的局部变量而它又没有初值自然就报错了。这种情况必须先声明global count或者用可变容器绕过。这是个典型的需要理解复合赋值背后机制才能解决的报错。4. 比较运算符返回True/False的判断器4.1 数字、字符串、列表怎么比大小比较运算符一共六个大于、小于、大于等于、小于等于、等于、!不等于。其中前四个用于比较大小关系后两个用于比较相等关系。数字之间的比较没什么好说的但字符串和列表的比较规则值得多说两句。字符串比较用的是字典序lexicographic order可以理解为按照字母表顺序逐位比较。两个字符串比较时Python先比较第一个字符的Unicode码点如果相等再比较第二个字符以此类推。所以abc abd会是False因为前两位一样第三位c的码点小于d。大写字母的码点比小写字母小所以A a为True。这个规则在给字符串排序时非常重要默认的sorted()函数就是按这个顺序排的。列表的比较规则类似于字符串的字典序按元素逐一比较。[1, 2, 3] [1, 2, 4]为True因为前两个相等第三个3 4。不同长度但前缀相同的情况下短列表小于长列表比如[1, 2] [1, 2, 0]为True。这个规则对元组同样适用。在比较运算中有一个Python独有的特性比较运算符支持链式比较。1 2 3不是(1 2) 3而是被解析为1 2 and 2 3。这意味着你可以用0 x 100来优雅地判断一个数是否在某个区间里不需要写成x 0 and x 100。这个特性在数值范围判断中真的很好用但不要写太长的链式比较会让代码理解成本变高。4.2与is相等和相同不是一回事这是Python入门阶段绕不过去的一个经典问题。比较的是两个对象的内容是否相等is比较的是两个变量是否指向同一个对象。前者比较的是值后者比较的是身份。举个例子a [1, 2, 3] b [1, 2, 3] print(a b) # True元素完全一样 print(a is b) # False两个不同的列表对象a和b的内容相同所以返回True但它们是相互独立创建的两个列表在内存中是两个不同的对象所以is返回False。反过来说对于小整数和短字符串Python有一些缓存机制。比如x 256; y 256x is y会返回True因为Python解释器启动时缓存了-5到256范围内的小整数对象x和y都指向了同一个缓存对象。当数字超过这个范围比如x 300; y 300x is y就可能返回False因为创建了两个不同的大整数对象。这个可能很关键因为不同Python解释器CPython、PyPy等对小整数的缓存策略不完全一样。所以永远不要依赖is去比较整数或字符串的相等性。is只用于一种场景判断一个变量是不是None——if result is None:是标准写法因为None是一个全局唯一的单例对象不存在多个副本的问题。在我代码审查的经验里见过有人用if x None:而不是if x is None:。这两种写法在多数情况下结果一样但is更符合Python社区的习惯语义也更准确。而且有些类会重写__eq__方法导致 None的判断结果不可预测用is就彻底避开了这个风险。5. 逻辑、位、身份与成员运算符进阶四件套5.1and、or、not的短路求值和返回值逻辑运算符有三个and与、or或、not非。表面上看它们是用来组合布尔表达式的但这里有一个很关键的特性and和or返回的不一定是布尔值而是参与运算的操作数中的一个。and运算的逻辑是如果第一个操作数为假值直接返回第一个操作数否则返回第二个操作数。or运算的逻辑相反如果第一个操作数为真值直接返回第一个操作数否则返回第二个操作数。print(0 and 100) # 0因为0是假值 print(1 and 100) # 100因为1是真值 print(0 or 100) # 100 print( or 默认值) # 默认值空字符串是假值这种特性配合短路求值机制在实际编码中有很多巧妙的用途。短路求值的意思是对于a and b如果a已经是假值那b根本不会被执行对于a or b如果a已经是真值那b也不会被执行。利用这个特性可以写出很简洁的防空判断user get_user() or 游客如果get_user()返回空值就使用默认的游客。也可以用来做条件调用is_debug and print(调试信息)这行代码在is_debug为假时不会执行print。这种写法比完整的if语句简洁但要谨慎使用——过度依赖会降低代码可读性项目里约定俗成地适度用可以全员都用就会很混乱。关于not它的规则很简单取反返回布尔值。not 1是Falsenot []是True。需要注意的是一些对象真假值的判断规则在Python中整数0、浮点数0.0、空字符串、空列表[]、空字典{}、空集合set()、空元组()、None、False都属于假值其他一切值都是真值。这个规则和某些语言不同比如空数组在JavaScript中是真值在Python中却是假值跨语言切来切去时容易出错。5.2 位运算符与二进制思维位运算符包括按位与、|按位或、^按位异或、~按位取反、左移、右移。它们直接操作整数的二进制位是性能和底层控制力的体现。每个运算符的规则如下运算符名称运算规则按位与对应位都是1才为1否则为0|按位或对应位只要有一个是1就为1^按位异或对应位不同则为1相同则为0~按位取反0变11变0注意结果是补码运算左移所有位向左移动n位低位补0等价于乘以2^n右移所有位向右移动n位等价于除以2^n并向下取整按位与和按位或在真实场景中一个典型的应用是权限管理。假设定义一个文件的权限为三个布尔状态可读位0、可写位1、可执行位2用三位的二进制数就能表示READ 0b100 # 4 WRITE 0b010 # 2 EXECUTE 0b001 # 1 # 赋予读和写权限 permission READ | WRITE # 0b110 6 # 检查是否有读权限 has_read (permission READ) ! 0 # True # 取消写权限 permission permission ~WRITE # 0b100 4这种设计在C语言系中特别常见Python中的一些标准库枚举也使用了类似的位组合机制。左移和右移的一个日常用途是快速乘除2的幂x 1等于x * 2x 1等于x // 2。在算法竞赛或性能敏感场景中位运算比乘除法快但在普通业务代码里直接用*和//可读性更好不必刻意追求位运算。~按位取反是最容易让人困惑的一个。~5的结果不是-5而是-6。因为在Python中整数是无限精度的5在底层用补码表示~5把所有二进制位取反后得到的是-6。记住这个规律~x恒等于-x-1。如果需要判断某个位是否为1通常结合掩码用而不是直接用~可以避免思维混乱。5.3 身份运算和成员运算身份运算符有两个is和is not。它们用来判断两个变量是否指向同一个对象。上面已经讲过is和的区别这里补充一个非常实用的点在做单例判断时is是唯一正确的选择。除了None之外True和False也建议用is判断比如if flag is True:。成员运算符也有两个in和not in。它们判断一个值是否存在于一个容器中。这个容器可以是字符串、列表、元组、字典、集合。print(Py in Python) # True子串判断 print(3 in [1, 2, 3]) # True元素判断 print(key in {key: 1}) # True字典判断的是键有几个容易忽视的细节。第一对字典使用in时判断的是键而不是值。第二对字符串使用in判断的是子串不是单个字符这比某些语言比如JavaScript的includes语义更强大。第三in的底层执行依赖于对象的__contains__方法或__iter__方法对于列表这种需要逐个遍历的容器判断千万级大列表的成员关系是很慢的时间复杂度是O(n)而集合set和字典dict底层是哈希表时间复杂度是O(1)。这就是为什么去重后判断成员是一个高性能的优化技巧# 慢 if target in big_list: ... # 快 big_set set(big_list) # 只转换一次 if target in big_set: ...如果你的代码有频繁判断元素是否属于某个大集合的需求用set代替list几乎是碾压级优化而且代码改动量极小。6. 运算符优先级、括号与实操建议6.1 优先级表与结合性运算符优先级决定了一个表达式中多个运算符共存的执行顺序。Python的优先级规则从高到低大致如下只列常用的优先级运算符说明最高()、[]、{}括号、下标、字典/集合字面量高**幂运算较高x、-x、~x正号、负号、按位取反中上*、/、//、%乘法、除法、整除、取余中、-加法、减法中下、位左移、位右移较低、^、|按位与、按位异或、按位或低、!、、、、比较运算符更低not逻辑非很低and逻辑与最低or逻辑或特殊、等赋值运算符结合性上大部分运算符是左结合的从左往右计算但**是右结合的2 ** 3 ** 2等于2 ** (3 ** 2)即512不是(2 ** 3) ** 2即64。这个和数学中幂运算的习惯一致但确实容易写错。优先级最容易出错的场景是布尔表达式里混用位运算符和比较运算符。比如if a b 1:这行代码Python解析为a (b 1)因为比较运算优先级高于位运算。如果你的本意是(a b) 1结果就会完全错误。这种bug极难排查因为语法合法、不会报错只有运行结果不符合预期。6.2 写代码时的优先级使用建议我先给一个非常直接的结论不要指望自己和同事能记住完整优先级表更不要依赖优先级表写出更简洁的代码。在真实工程中最稳妥的做法是拿不准就加括号。示例# 不推荐需要读者翻表确认 if a b 1 and c or d: ... # 推荐意图一目了然 if (((a b) 1) and c) or d: ...括号即便多余也不会有任何性能损失只会让代码更清晰。但也不能矫枉过正到处乱加括号一样影响阅读。我的个人习惯是and、or、not组合的表达式能简化就简化必要时加括号区分逻辑块乘法除法与加减法混合时不加括号也够直观但求积求和的语义复杂时加位运算与其他运算符混合时全部显式加括号赋值语句的右侧表达式如有二义性用括号包裹明确的运算组这四条规则是我写了多年代码后沉淀下来的经验核心思想就一个减少读者包括未来的自己的认知负担让代码一眼就能看懂意图。有一类常见问题值得一提Python 3.8之后的版本支持海象运算符:Walrus operator它可以在表达式中直接赋值比如if (n : len(items)) 10: print(f列表长度是{n})海象运算符的优先级非常低低于or和and所以几乎所有用到它的场景都必须加括号。如果可以不用我建议在团队项目中少用——它提高了代码密度但对维护不总是友好的。除非是在列表推导式、正则匹配需要复用结果这类高效场景里否则传统写法更清晰。7. 常见问题与排查技巧实录7.1 一列表运算符使用高频坑对照表把这么多年积累下来的典型错误整理成一个速查表遇到类似问题可以直接对照定位。问题现象常见原因正确做法x a or b结果不符合预期拿到的不是布尔值不了解逻辑运算符返回值特性如果需要明确的布尔值用bool(a or b)包一层或用三元表达式对列表用*初始化后发现改一个元素其余全变浅拷贝共享引用使用列表推导式[[0] * n for _ in range(m)]创建多维列表-7 // 2得到的结果和C语言不一样Python向下取整C语言向零截断做算法题时注意跨语言移植代码时务必验证负数场景判断变量是否为None用了没理解is和的区别一律用if x is None:字符串比较大小总是不符合预期字符串按字典比较不是按长度直接看apple banana这类比较结果时按Unicode码点逐位分析某个对象判断结果诡异对方类重写了__eq__方法检查该类是否有自定义的相等逻辑不要盲目信任大列表频繁用in判断成员程序卡顿列表是顺序容器查找复杂度O(n)改用set或dict忘了//的优先级导致表达式算错混用了//和%的优先级复杂表达式加括号或拆成多行计算在函数中执行x 1报UnboundLocalError复合赋值使变量被视为函数局部变量声明global x或把可变状态放到字典/列表等容器中这张表覆盖了我在论坛里看到的高频问题也基本覆盖了面试中常出的运算符相关题目。建议把它收藏起来或抄到笔记里写完代码后对照检查一遍能省去很多调试时间。7.2 运算符相关的调试技巧与学习建议如果代码里运算符相关的逻辑出了bug我的排查思路一般是先做三件事第一单独把表达式抽出来跑一下。在交互式环境Python REPL或者Jupyter里单独执行表达式逐层加括号观察结果。比如怀疑a b c有歧义就分别运行a (b c)和(a b) c看哪个结果是你想要的。这种方式比盯着代码干想要快得多。第二用type()检查表达式结果的类型。很多时候不是数值不对而是类型不对——逻辑运算符返回的是操作数而不是布尔值、/返回浮点数、//配合浮点数操作数也返回浮点数这些都会悄悄改变结果类型进而影响后续流程。在关键节点加一行print(type(...))能快速排除类型问题。第三把一个复杂表达式拆成多个简单步骤。比如# 原来的复杂表达式 result a ** 2 b * c - d // e % f # 排查时拆开 step1 a ** 2 step2 b * c step3 d // e step4 step3 % f result step1 step2 - step4这样做虽然多写了几行但每一行的结果都能单独验证一旦某一步计算与预期不符立刻能定位问题出在哪个运算符上。对于刚开始学Python的朋友学习运算符最好的方式不是背书而是把每个运算符拿到交互式环境里玩一遍。把数字换成负数把整数换成浮点数把列表换成元组把字符串换成空字符串观察输出结果的变化。这个过程花不了多少时间但对运算符的理解深度远远超过看一百遍教程。我自己带人的时候一定会让他们做这件事因为运算符是Python这门语言语法体系中最具肌肉记忆性质的部分这些细节在真正写代码时会反复返回找你——与其到时候再查不如现在就把坑全部踩一遍。最后提一个我经常在实操中强调的建议Python里没有C语言那种把任意非0值当true、非NULL指针当false的隐式转换但每个对象都有真假值这既是一种便利也是一种陷阱。在使用逻辑运算符和条件表达式时时刻问自己这里的对象如果为空、为0、为None会走哪条分支养成这个习惯之后你写出来的代码健壮性会提升一个明显的档次。