Medium Python终于来到了最终话。经历了前四话的撰写,笔者决定以第五话作为收尾,故这段时间一直在思考python里还有什么内容是我们常见但值得推敲且有应用意义的点。绞尽脑汁,最终得到了今天这个主题:with
关键字。
with
关键字的含义,是笔者接触python以来希望彻底搞懂的问题之一,也是一定会困惑大量玩python的同学的问题之一。相信每一个玩过python的同学都接触过with
语法,比如with open(xxx) as f
的文件操作,或者是with lock
这样的加解锁操作,这些东西每个python教程里都有。但是with
语法具体表示什么,具体能够翻译成怎样的简单语法,基本没啥人能够说的清楚,说的科学。即便在网上有许多文章在剖析这一点,提到了许多诸如“上下文管理(context manager
)”、“异常处理”、“__enter__
、__exit__
”之类的词汇,但是就正因为缺少些硬核的东西,比如源码分析,导致许多个文章的内容都很水,看了也不能完完全全的明白,实际写代码的时候也觉得难以彻底掌握。
因此,为了把这件事情说明白,本文决定继续源码分析的套路,让大伙儿彻底理解with
关键字是怎么一回事。老样子,一切的吹水都没有源码分析来的实在。看完这篇文章,其它关于with
的文章都可以统统无视了。
with代码测试
首先我们上一段测试代码:
1 | import sys |
这段测试代码包含了两个我们常见的with
操作:文件读写和线程加锁。test_fopen
是读文件内容,test_thread_lock
是不同线程交替增加_CNT
的操作。在代码里面,再次出现了我们的老同志:反编译库dis
,这是为了用来解析每一个函数具体包含哪些操作码,以能够让我们快速定位对应操作的源代码实现。每个被dis
的函数,在with
的最后都有pass
操作,这是为了更加方便看到在退出with
范围时,代码实际做了哪些附加操作(嗯,这些pass
是实际调试之后才加的)。
以文件读写test_fopen
为例,我们看下反编译之后的结果:
1 | 13 0 LOAD_CONST 1 ('1.log') |
我们看到,在with
的一行(14),多了SETUP_WITH
的操作指令,而在即将退出with
代码块的pass
一行(18),出现了大量指令,并且有点类似于异常处理的内容。那么这里到底蕴含了什么信息呢?
那么首先,我们从SETUP_WITH
——with
代码块的初始化操作开始看起。
with代码块的初始化
我们在EvalFrame
的循环中,先找到SETUP_WITH
对应的代码:
1 | // ceval.c |
可以看到,SETUP_WITH
操作的步骤如下:
- 一开始,寻找
with
对应实例的__enter__
以及__exit__
方法(绑定实例的),如果两者有其一找不到的话都会直接跳到error
报错。 - 设置
__exit__
为栈顶 - 直接调用
instance.__enter__()
- 进行BlockSetup:
PyFrame_BlockSetup(f, SETUP_FINALLY, INSTR_OFFSET() + oparg, STACK_LEVEL())
- 将
__enter__
的返回值PUSH
到栈上
在test_fopen
里面,__enter__
函数对应的是iobase_enter
:
1 | // iobase.c |
可以看到,在__enter__
函数中会检测这个io
对象是否已经close
掉,如果close
掉的话会返回NULL
,正常的话返回io
对象。如果__enter__
返回NULL
,在SETUP_WITH
里面,就goto
到error
逻辑了。
之后我们再看一下BlockSetup
语句,其中会调用PyFrame_BlockSetup
函数:
1 | // frameobject.c |
PyFrame_BlockSetup
的本质是设置了一个PyTryBlock
。如果进一步检索PyFrame_BlockSetup
的引用的话,会发现SETUP_FINALLY
这个操作本质就是调用了这个函数。而SETUP_FINALLY
本身,比如在try/except/finally
结构里,不论是except
还是finally
,都用的这个字节码。
PyTryBlock
除了在try/except/finally
结构中有使用之外,在循环loop
的时候也会用到,其三个属性的意义分别为:
b_type
:当前代码块block
的类型(SETUP_FINALLY
)b_handler
:处理错误信息的handler
的指令位置(INSTR_OFFSET() + oparg
)b_level
:比如出现exception
的场景下,要对栈做恢复,pop一系列栈上的value时,用来参考的栈高度(STACK_LEVEL()
)
我们进一步看PyTryBlock
跟FrameObject
的定义及注释,也可以证实这些信息:
1 | // frameobject.h |
有了PyTryBlock
存储一系列栈上信息,就可以保证with
结构下的代码块在结束之后,整个栈上的状态能够恢复到with
之前的状态。注意这个时候栈顶上是__exit__
函数,这样如果之后恢复栈,然后push一系列错误信息,我们的__exit__
函数就能处理对应的错误信息了。
BlockSetup
之后,就是把__enter__
的返回值推进栈里,交由后面的STORE
指令存储到locals
里面。比如我们在python
中编写的with a as b
这种形式,最后我们取到的b
,就是__enter__
的返回值了。
with代码块的退出以及异常处理
执行完with
一行的代码之后,我们开始执行with
代码块里面的内容。with
代码块执行完之后,当退出之时,也会执行一系列行为。
从上面的字节码结果中也可以看到,有非常长的一串,这里也再列出来:
1 | 18 34 POP_BLOCK |
退出with
的一刻,需要考虑两种情况:有异常和没有异常。当没有异常的时候下来,会到字节码的34~46。34先POP_BLOCK
退出代码块,然后之后有一个CALL_FUNCTION
操作:由于先前讲到栈顶已经被设置成了__exit__
函数,那么这里相当于再顶了3个None
,然后执行了instance.__exit__(None, None, None)
。之后就走到64,退出这个with
流程了。
而当有异常时,我们会跳到48:WITH_EXCEPT_START
,这一块在前面SETUP_WITH
的字节码有标注:
1 | 10 SETUP_WITH 36 (to 48) |
如果说with
结构最终走到了WITH_EXCEPT_START
的分支,那么在此之前一定已经执行了某些抛异常(比如raise
)且没有捕获的操作。为了模拟这个场景,我们在with
代码块中加一行代码raise Exception
,来看下抛异常时候的情况。
1 | import dis |
用dis
得到的反编译结果:
1 | 5 0 LOAD_GLOBAL 0 (open) |
我们可以从中看到,当raise
异常时,会执行RAISE_VARARGS 1
的指令。我们先来看RAISE_VARARGS
对应的代码:
1 | // ceval.c |
在RAISE_VARARGS
中,case
对应的指令会顺着往下走,直到case 0
的do_raise
逻辑里面。do_raise
是抛异常的实际操作,里面会检查抛出的异常类型以及参数是否合理,之后再设置当前线程的异常类型type
以及异常值value
RAISE_VARARGS
最后会跳到error
以及exception_unwind
代码段:
1 | // ceval.c |
在error
段中,会提取当前frame
上的异常traceback
信息,然后就直接到了exception_unwind
段。exception_unwind
段会恢复栈上的信息,其逻辑如下:
1 | // ceval.c |
由于我们先前执行过了PyFrame_BlockSetup(f, SETUP_FINALLY, INSTR_OFFSET() + oparg, STACK_LEVEL())
,最终代码会运行到if (b->b_type == SETUP_FINALLY)
对应的段落。在其中进行了以下步骤:
PyFrame_BlockSetup(f, EXCEPT_HANDLER, -1, STACK_LEVEL())
:设定了一个新的代码块,标识为EXCEPT_HANDLER
- 将异常栈(串连异常信息的链)当前最顶端的异常信息push到栈中
- 将当前需要
raise
的异常信息push到栈中- 这个场景下,应当和异常栈最顶端的一样
- 注意
_PyErr_Fetch
会将表示当前线程要抛出的异常的几个变量(curexc_type
、curexc_value
、curexc_traceback
)重置为NULL
。这样如果当前异常得到妥善处理掉,后面执行时候发现线程里面这些变量是NULL
,也不会触发程序终止打印异常。 _PyErr_Fetch
相反的操作叫做_PyErr_Restore
,相当于设定当前线程已经出现异常。
进行了这个操作之后,现在栈上应当至少有7个元素,自顶而下是:
- 前3个是当前需要
raise
的异常信息 - 中间3个是异常栈最顶端的异常信息
- 然后第7个就是
__exit__
函数
之后通过JUMPTO(handler)
、goto main_loop
,就走到了WITH_EXCEPT_START
逻辑
1 | // ceval.c |
在WITH_EXCEPT_START
的逻辑里,直接调用了instance.__exit__(exc_type, exc_value, exc_traceback)
,然后把结果再推到栈上。这样栈上就有8个元素了。
以先前的with open(xxx) as f
为例,其__exit__
函数对应了iobase_exit
1 | // iobase.c |
可以看到这个函数会返回f.close
的返回值,其实就是None
,并且对异常信息(包在args
里)没有任何处理。__exit__
函数的返回值有什么用处呢?我们看到紧接着的操作是POP_JUMP_IF_TRUE
:
1 | // ceval.c |
可以看到,一开始我们会POP
出来栈顶的值,也就是__exit__
的返回值,然后再根据这个返回值走下面的逻辑。如果这个返回值可以作为真值(比如1、有内容的list
/dict
)的话,就跳到指定的指令,如果不是真值(比如None
、0、空的list
/dict
)的话,就接续下去。因此结合先前反编译操作码的结果来看,会是这样的效果:
- 如果
__exit__
返回真值,则走后面的POP
一堆东西的逻辑(50)- 理论上,不会结束程序,打不打印异常看你在
__exit__
里有没有操作了
- 理论上,不会结束程序,打不打印异常看你在
- 如果
__exit__
返回非真值,就走下面的RERAISE
指令(48),结束程序打印异常
首先来看RERAISE
:
1 | case TARGET(RERAISE): { |
RERAISE
实际把栈顶3个待raise
的异常信息POP出来,并通过_PyErr_Restore
重新设置当前线程出现的异常信息,然后又走到了exception_unwind
。在exception_unwind
的遍历代码块的while
循环中,首先识别到先前BlockSetup
的EXCEPT_HANDLER
代码段,调用UNWIND_EXCEPT_HANDLER
把先前PUSH
的异常栈顶的异常信息全给POP
了,之后由于没有任何SETUP_FINALLY
的标记,整个遍历代码块就结束了,最终就会把栈里剩下的值(__exit__
)清掉,退出代码执行。
代码执行完毕之后,由于表示当前线程要抛出的异常的几个变量被_PyErr_Restore
设置了,最终就会触发程序终止,并在stderr
打印异常信息。
然后我们再看__exit__
返回真值情况下那一堆POP
操作,大概是这样:
- 首先是3个
POP_TOP
,把待raise
的异常信息POP
掉 - 然后是
POP_EXCEPT
,一方面会退出前面设置的EXCEPT_HANDLER
代码段,另一方面会把先前PUSH
进去的那个时刻的异常栈顶的信息给POP出来,并重新设置到异常栈顶上,保证异常信息恢复原样 - 最后又来一个
POP_TOP
,就是把__exit__
给POP掉
这样,整个with
代码块的部分就执行完成了!
总结
with
关键字分析了那么久,大家也能够看的明白,with
本身其实相当于try/except/finally
结构的变体。剖析with
结构的同时,也不得不需要参考异常处理相关的代码逻辑。这篇文章与其说在讲with
,不如说在讲一些异常处理相关的实现。
从上面的分析结果,我们就可以得出来:
比如一个python代码段:
1 | with a as b: |
就能够被简单地翻译为:
1 | b = a.__enter__() |
翻译成这样,每一个学过一点python的同学都会很清楚地理解吧!
那么,如果我们要自己编写支持with语法的程序,可以参考下面的python代码:
1 | import pprint |
支持with
的实例,需要有只带self
一个参数的__enter__
函数,以及带self
以及异常类型、异常值、异常traceback三个参数的__exit__
函数。通过上面“代码翻译”的样式,不难看出,执行main
函数会输出这样的结果,不带Exception
报错:
1 | [WithTester] triggered enter: 1 |
看到了吧!with
关键字的含义,就是这样简单。
写在最后的话
相信通过Medium Python系列的讲解,大家应该会对python语言本身有了新的理解吧!在最后,笔者也推荐一本书:《Python源码剖析》,是一本老书,基于python2.5的,但是在python已经到3.10的今天,读起来仍然令人大开眼界。这个系列的许多分析,都参考了这本书的分析方法以及结论。
知识是永远没有尽头的!做这个系列的过程中,笔者是一次又一次地在体验着这样的真理。今后的将来,希望大家一起勉励!