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_FASTDELETE_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);
}

这里面直接去调了 PyTypeObjecttp_dealloc 函数。Python 在 C 中定义的类型 (type) 都是这样的:

  1. 创建一个和 PyObject 二进制兼容的结构,一般命名为 Py{Name}Object ,用来 object 的相关信息
  2. 另一方面要创建一个 PyTypeObject 的结构体,让 PyObjectob_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 call x.__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 官方文档里是这样说的:

The Python Standard Library » Python Runtime Services » sys — System-specific parameters and functions # sys.displayhook

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 又简单又屎)。