Python 比较运算符展开的隐藏坑

Python 比较运算符有个很有简便的用法,即:1 < x < 10,表示判断 x 是否满足在 (1, 10)之间,通常被人称为 链式比较。最近发现了一个隐藏的坑点,相当有意思。

注意:在本文开始之前需要说明的是,以下内容均在 3.x 测试,2.x 不一定适用。

一个例子 🌰

问,按照你以前对 Python 的了解,以下语句中,哪些是 True,哪些是 False?(答案放在本文最下面,放心大胆往下看)

1
2
3
4
1 in (1, 2) == True
None is None in (True, False)
None is False in (True, False, None)
None is True in (True, False, None)

比较运算符的展开

文章一开始提到,1 < x < 10,表示判断 x 是否满足在 (1, 10)之间。那么 Python 到底是怎么处理这个展开的呢?我们可以用 dis 一探究竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
In [1]: from dis import dis

In [2]: x = 5

In [3]: dis('1 < x < 10')
1 0 LOAD_CONST 0 (1)
2 LOAD_NAME 0 (x)
4 DUP_TOP
6 ROT_THREE
8 COMPARE_OP 0 (<)
10 JUMP_IF_FALSE_OR_POP 18
12 LOAD_CONST 1 (10)
14 COMPARE_OP 0 (<)
16 RETURN_VALUE
>> 18 ROT_TWO
20 POP_TOP
22 RETURN_VALUE

Python 代码首先会被“编译“为字节码,然后再由 Python 虚拟机来执行字节码(Python 的字节码是一种类似汇编指令的中间语言,一个 Python 语句会对应若干字节码指令)。dis 这个内置模块是 Python 字节码“反汇编器“,可以帮助我们理解 Python 的代码是怎么执行的。

那么怎么看这个指令呢?比如上面这段结果,以第一行为例:

  1. 第一列的数字1:表示其对应的源代码的行数
  2. 第二列的0:表示字节码的索引,指令LOAD_CONST0位置
  3. 第三列:可读性较高的指令名称,告诉我们这个指令是啥意思
  4. 第四列:表示指令参数所在的位置,我的理解是在堆栈里的位置。
  5. 第五列:是参数值。
  6. 上面的结果中,还有个 >>:表示跳转的目标,会配合类似 JUMP 的指令使用。比如 index 为 10 的指令,JUMP_IF_FALSE_OR_POP,后面跟着参数是 18,表明了跳转到索引为 18 的指令,即 ROT_TWO

若有疑问或者想查阅所有的指令,可以查看官方文档:传送门🚪

那么现在终于可以一探究竟了:

  1. 0 LOAD_CONST:将 1 压入栈顶。此时堆栈为:顶[ 1 ]底
  2. 2 LOAD_NAME:将 x 压入栈顶。此时堆栈为:顶[ x, 1 ]底
  3. 4 DUP_TOP:复制堆栈顶部的引用。此时堆栈为:顶[ x, x, 1 ]底
  4. 6 ROT_THREE:将第二个和第三个堆栈项向上提升一个位置,原来的顶项移动到位置三。此时堆栈为:顶[ x, 1, x ]底
  5. 8 COMPARE_OP:执行布尔运算操作,这里就是 <,执行完之后会把结果压回堆栈,而 1 < x1<5True。此时堆栈为:顶[ True, x ]底
  6. 10 JUMP_IF_FALSE_OR_POP:如果堆栈顶部为 False 就跳,否则就把堆栈顶部弹出,这里明显是要弹出。此时堆栈为:顶[ x ]底
  7. 12 LOAD_CONST:压入 10。此时堆栈为:顶[ 10, x ]底
  8. 14 COMPARE_OP:执行比较 x < 10。此时堆栈为:顶[ True ]底
  9. 16 RETURN_VALUE:返回 True,结束

可以看出,形如 1 < x < 10 的语句,Python 会利用 DUP_TOPROT_THREE 展开为 1 < x and x < 10。为什么是 and 呢?从上面的 JUMP_IF_FALSE_OR_POP 可以看出,这是短路运算,如果前面的是 False 就直接返回 False 了,所以是 and 不是 or

我们也可以看一下 1 < x and x < 10 的字节码来验证我们的想法:

1
2
3
4
5
6
7
8
9
In [1]: dis('1 < x and x < 10')
1 0 LOAD_CONST 0 (1)
2 LOAD_NAME 0 (x)
4 COMPARE_OP 0 (<)
6 JUMP_IF_FALSE_OR_POP 14
8 LOAD_NAME 0 (x)
10 LOAD_CONST 1 (10)
12 COMPARE_OP 0 (<)
>> 14 RETURN_VALUE

是不是很像?

坑点在哪里

在查找 COMPARE_OP的时候,官方文档有一句话:

cmp_op[opname] 在哪呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
In [1]: import dis

In [2]: dis.cmp_op
Out[2]:
('<',
'<=',
'==',
'!=',
'>',
'>=',
'in',
'not in',
'is',
'is not',
'exception match',
'BAD')

可以看到,我们常用的链式比较符都在里面,但是还有一些稍微有点特殊的:
(not) inis (not)

这也就引出了前面那几个语句执行方式,实际上也是会被展开的,以 1 in (1, 2) == True 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
In [94]: dis('1 in (1, 2) == True')
1 0 LOAD_CONST 0 (1)
2 LOAD_CONST 1 ((1, 2))
4 DUP_TOP
6 ROT_THREE
8 COMPARE_OP 6 (in)
10 JUMP_IF_FALSE_OR_POP 18
12 LOAD_CONST 2 (True)
14 COMPARE_OP 2 (==)
16 RETURN_VALUE
>> 18 ROT_TWO
20 POP_TOP
22 RETURN_VALUE

简直一模一样,1 in (1, 2) == True 会被展开成 1 in (1, 2) and (1, 2) == True

答案

想必到这里,你应该可以看出,开头的那几个语句均为 False 了吧~

至于解决方法嘛,当然是加括号啦~

来呀快活呀


Python 比较运算符展开的隐藏坑
https://www.tr0y.wang/2020/04/17/traps-in-python-cmp/
作者
Tr0y
发布于
2020年4月17日
更新于
2024年4月19日
许可协议