Python Interactive 中 __del__ 的奇怪行为
一个简单的问题
今天在某群里看见一个 Python 很怪异的行为,最后总结到可以复现的最小例子如下:
PS C:\Users\azuk> python3
Python 3.8.9 (default, Apr 13 2021, 15:54:59) [GCC 10.2.0 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> class X:
... def __del__(self):
... print('die')
...
>>> a = X()
>>> del a
die
>>> a = X()
>>> a
<__main__.X object at 0x0000022eb60f9430>
>>> del a
>>>
>>> X
die
<class '__main__.X'>
>>>
当在 Python Interactive 运行 a
,再删除这个对象时,并没有马上执行 a.__del__
的操作,而是等运行了下一条语句时才打印出了 ‘die’。如果把它做成一个简单的 Python 脚本来运行,就不会发生这种奇怪的现象。
TLDR: 因为 Python Interactive 会把上一次运算结果的值保存在 __builtins__._
里,所以引用计数器没归零。
一番困难的研究
最近也做了不少 Python 的工作,正好借这个机会来梳理一下整个过程。
减少引用计数的过程
首先要弄清楚的是,为什么执行完 del a
后应该执行 a.__del__
。在 Python 里简单 dis 一下这段话看看结果:
>>> from dis import dis
>>> del a; f = sys._getframe(0)
die
>>> dis(f.f_code)
1 0 DELETE_NAME 0 (a)
2 LOAD_NAME 1 (sys)
4 LOAD_METHOD 2 (_getframe)
6 LOAD_CONST 0 (0)
8 CALL_METHOD 1
10 STORE_NAME 3 (f)
12 LOAD_CONST 1 (None)
14 RETURN_VALUE
直接 dis 'del a'
可能与实际执行有差异,这里直接获取到 frame object 的 f_code
。这里 del a
实际上对应了 bytecode DELETE_NAME(a)
。 而 DELETE_NAME 在 ceval.c 中的定义是:
Python/ceval.c
case TARGET(DELETE_NAME): {
PyObject *name = GETITEM(names, oparg);
PyObject *ns = f->f_locals;
int err;
if (ns == NULL) {
_PyErr_Format(tstate, PyExc_SystemError,
"no locals when deleting %R", name);
goto error;
}
err = PyObject_DelItem(ns, name);
if (err != 0) {
format_exc_check_arg(tstate, PyExc_NameError,
NAME_ERROR_MSG,
name);
goto error;
}
DISPATCH();
}
DELETE_NAME
首先获取参数的名字,再获取当前 frame object 的 f_locals ,最后在 PyObject_DelItem
中删除该变量。
Objects/abstract.c
int
PyObject_DelItem(PyObject *o, PyObject *key)
{
// ...
PyMappingMethods *m = Py_TYPE(o)->tp_as_mapping;
if (m && m->mp_ass_subscript) {
int res = m->mp_ass_subscript(o, key, (PyObject*)NULL);
assert(_Py_CheckSlotResult(o, "__delitem__", res >= 0));
return res;
}
// ...
}
注意到 locals() 是一个 dict 对象,所以这里用了从集合类型中删除元素的函数进行操作。mp_ass_subscript
不为空说明可以对元素执行下标操作(包括 set 和 del)。在 dictobject.c 中,定义有:
Objects/dictobject.c
static PyMappingMethods dict_as_mapping = {
// ...
(objobjargproc)dict_ass_sub, /*mp_ass_subscript*/
};
之后由 dict_ass_sub
-> PyDict_DelItem
-> _PyDict_DelItem_KnownHash
-> delitem_common
一路调用过来,最终对 Value 执行了一次 Py_DECREF
,对值的引用计数减 1 。
要注意的是,不是所有情况下执行 del
都会产生 DELETE_NAME
。我个人认为 CPython 里带 NAME 的 opcode 都有一种“我也不知道在哪里,你运行的时候自己找找”的感觉。假如有如下函数:
def f():
a = X()
print()
del a
那么它会生成 STORE_FAST
和 DELETE_FAST
。就像它名字表示的一样,它真的很快。值放在了一个数组上(数组是 locals 和 stack),删除也是对数组进行操作(以直接将值设置为 NULL 的方式),不在这里讨论的范围内。
调用 __del__
的过程
首先追踪一下 Py_DECREF
:
Python/ceval.c
# define Py_DECREF(op) _Py_DECREF(_PyObject_CAST(op))
// 这里简化了许多用于调试的预编译指令
static inline void _Py_DECREF(PyObject *op)
{
if (--op->ob_refcnt != 0) {
}
else {
_Py_Dealloc(op);
}
#endif
这里的逻辑比较简单,当引用计数归零时,就去调用 _Py_Dealloc
。
Objects/object.c
void
_Py_Dealloc(PyObject *op)
{
destructor dealloc = Py_TYPE(op)->tp_dealloc;
(*dealloc)(op);
}
这里面直接去调了 PyTypeObject
的 tp_dealloc
函数。Python 在 C 中定义的类型 (type) 都是这样的:
- 创建一个和
PyObject
二进制兼容的结构,一般命名为Py{Name}Object
,用来 object 的相关信息 - 另一方面要创建一个
PyTypeObject
的结构体,让PyObject
的ob_type
指向它,里面包含了 object 的相关行为函数;我们的 class X 也对应了某个PyTypeObject
。
Python 中类的创建机制比较复杂,文中先略过不谈。最终我们调用到了作为 tp_dealloc
出现的 subtype_dealloc
,通过检查是否有 Finalizer , 从 PyObject_CallFinalizerFromDealloc
-> PyObject_CallFinalizer
-> tp_finalize
最终调用到了我们写的 Python 层面上的 __del__
。
这就和我们在 Python 官方文档上看到的说明一样了:调用 del
未必触发 __del__
。
The Python Language Reference » Data model # object.__del__
Note
del x
doesn’t directly callx.__del__()
— the former decrements the reference count for x by one, and the latter is only called when x’s reference count reaches zero.
从引用计数下手
现在我们实锤了:既然 __del__
没有被调用,说明变量引用计数根本没有归零。做一个简单的实验:
>>> a = X()
>>> a
<__main__.X object at 0x000001c3fab693a0>
>>> sys.getrefcount(a)
3
>>> sys.getrefcount(a)
2
>>> sys.getrefcount(a)
2
惊了,引用计数突然就减少了 1 !此时我的第一想法就是 Python Interactive 会保存上一次执行的结果,马上去搜索了一番(实在不想看代码找问题了)。还真是这样,stackoverflow 上有人说 Python Interactive 会调用 sys.displayhook 把上一次的结果保存到 __builtins__._
里。
Python 官方文档里是这样说的:
sys.displayhook is called on the result of evaluating an expression entered in an interactive Python session. The display of these values can be customized by assigning another one-argument function to sys.displayhook.
Pseudo-code:
def displayhook(value): if value is None: return # Set '_' to None to avoid recursion builtins._ = None text = repr(value) try: sys.stdout.write(text) except UnicodeEncodeError: bytes = text.encode(sys.stdout.encoding, 'backslashreplace') if hasattr(sys.stdout, 'buffer'): sys.stdout.buffer.write(bytes) else: text = bytes.decode(sys.stdout.encoding, 'strict') sys.stdout.write(text) sys.stdout.write("\n") builtins._ = value
但无论是 stackoverflow 还是 Python 文档都没说这个 sys.displayhook
到底是哪来的,为什么只在 interactive session 里生效。
探寻 sys.displayhook
没有办法,继续从源码里寻找答案。sys
是一个库,直接去对应的 module 里找:
Python/sysmodule.c
static PyObject *
sys_displayhook(PyObject *module, PyObject *o)
{
// ...
/* Print value except if None */
/* After printing, also assign to '_' */
/* Before, set '_' to None to avoid recursion */
if (o == Py_None) {
Py_RETURN_NONE;
}
if (_PyObject_SetAttrId(builtins, &PyId__, Py_None) != 0)
return NULL;
outf = sys_get_object_id(tstate, &PyId_stdout);
if (outf == NULL || outf == Py_None) {
_PyErr_SetString(tstate, PyExc_RuntimeError, "lost sys.stdout");
return NULL;
}
if (PyFile_WriteObject(o, outf, 0) != 0) {
// ...
}
if (newline == NULL) {
newline = PyUnicode_FromString("\n");
if (newline == NULL)
return NULL;
}
if (PyFile_WriteObject(newline, outf, Py_PRINT_RAW) != 0)
return NULL;
if (_PyObject_SetAttrId(builtins, &PyId__, o) != 0)
return NULL;
Py_RETURN_NONE;
}
这正是 Python Interactive 交互的表现!通过 dis
的结果来看,当我们“运行”一条变量,它是这样的:
>>> dis('a')
1 0 LOAD_NAME 0 (a)
2 RETURN_VALUE
返回值就是 a
,所以在屏幕上也打印了它的值。
但问题是,为什么只有在 interactive 的情况下,才会去触发 sys.displayhook
呢?换言之,是谁在调用 sys.displayhook
?
水落石出:一条特殊的 opcode
直接运行 python
,就可以进入它的交互模式。所以这次从 main
一路跟进,直到这里:
run_mod
-> _PyAST_Compile
-> compiler_mod
:
Python/compile.c
static PyCodeObject *
compiler_mod(struct compiler *c, mod_ty mod)
{
// ...
case Interactive_kind:
if (find_ann(mod->v.Interactive.body)) {
ADDOP(c, SETUP_ANNOTATIONS);
}
c->c_interactive = 1;
VISIT_SEQ_IN_SCOPE(c, stmt, mod->v.Interactive.body);
break;
// ...
}
这里给 compiler 设置了一个 c_interactive
选项,于是在后面:
Python/compile.c
static int
compiler_visit_stmt_expr(struct compiler *c, expr_ty value)
{
if (c->c_interactive && c->c_nestlevel <= 1) {
VISIT(c, expr, value);
ADDOP(c, PRINT_EXPR);
return 1;
}
// ...
}
这里填加了一个 opcode PRINT_EXPR
,而它的作用就是:
Python/ceval.c
case TARGET(PRINT_EXPR): {
_Py_IDENTIFIER(displayhook);
PyObject *value = POP();
PyObject *hook = _PySys_GetObjectId(&PyId_displayhook);
PyObject *res;
if (hook == NULL) {
_PyErr_SetString(tstate, PyExc_RuntimeError,
"lost sys.displayhook");
Py_DECREF(value);
goto error;
}
就是把当前栈上的值 value
弹出来,然后调用 sys.displayhook(value)
把它打印,再保存进 __builtins__._
里。
结语
Python 的源码量真的很庞大,初看很容易摸不到头脑。相比之下 Go 的源码里注释就写得特别好,很多地方逻辑也很简单,很容易上手(当然也因为 Go 又简单又屎)。