公开 Python 3.6 的私有字典版本内容

136 阅读3分钟

公开CPython的内部结构

几年前的一篇文章中,我展示了如何使用ctypes 模块,在运行时混入CPython的内部实现,我在这里也将使用类似的策略。

简而言之,这个方法就是定义一个ctypes.Structure 对象,反映CPython用来实现有关类型的结构。我们可以从支撑每个 Python 对象的基础结构开始。

typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

一个ctypes 包装器可能看起来像这样。

在[1]中

import sys
assert (3, 6) <= sys.version_info < (3, 7) # Valid only in Python 3.6

import ctypes
py_ssize_t = ctypes.c_ssize_t  # Almost always the case

class PyObjectStruct(ctypes.Structure):
    _fields_ = [('ob_refcnt', py_ssize_t),
                ('ob_type', ctypes.c_void_p)]

接下来,让我们看看Python 3.6PyDictObject 的定义,它可以归结为以下内容。

typedef struct {
    PyObject_HEAD
    Py_ssize_t ma_used;
    uint64_t ma_version_tag;
    PyDictKeysObject *ma_keys;
    PyObject **ma_values;
} PyDictObject;

我们可以通过这种方式来反映dict 背后的结构,再加上添加一些以后有用的方法。

在 [2] 中

class DictStruct(PyObjectStruct):
    _fields_ = [("ma_used", py_ssize_t),
                ("ma_version_tag", ctypes.c_uint64),
                ("ma_keys", ctypes.c_void_p),
                ("ma_values", ctypes.c_void_p),
               ]
    
    def __repr__(self):
        return (f"DictStruct(size={self.ma_used}, "
                f"refcount={self.ob_refcnt}, "
                f"version={self.ma_version_tag})")
    
    @classmethod
    def wrap(cls, obj):
        assert isinstance(obj, dict)
        return cls.from_address(id(obj))

作为理智的检查,让我们确保我们的结构与它们要包裹的类型在内存中的大小一致。

在[3]中

assert object.__basicsize__ == ctypes.sizeof(PyObjectStruct)
assert dict.__basicsize__ == ctypes.sizeof(DictStruct)

有了这样的设置,我们现在可以包装任何dict对象,以获得其内部属性。下面是一个简单的dict的情况。

In [4]中

D = dict(a=1, b=2, c=3)
DictStruct.wrap(D)

Out[4]:

DictStruct(size=3, refcount=1, version=508220)

为了进一步说服自己,让我们对这个dict再做两次显式引用,增加一个新的键,并确保大小和引用次数反映出这一点。

In[5]:

D2 = D
D3 = D2
D3['d'] = 5
DictStruct.wrap(D)

Out[5]:

DictStruct(size=4, refcount=3, version=515714)

看来,这个工作是正确的

探索版本号

那么版本号是做什么的呢?正如Brandon在他的演讲中所解释的,CPython 3.6中的每一个dict现在都有一个版本号,它是

  1. 全局唯一的
  2. 每当一个dict被修改时,在本地更新
  3. 每当任何dict被修改时,都会在全局范围内递增。

这个全局值被存储在 pydict_global_version变量中。因此,如果我们创建了一堆新的字典,我们应该期望每个字典的版本号都比上一个高。

在[6]中:

for i in range(10):
    dct = {}
    print(DictStruct.wrap(dct))
DictStruct(size=0, refcount=1, version=518136)
DictStruct(size=0, refcount=1, version=518152)
DictStruct(size=0, refcount=1, version=518157)
DictStruct(size=0, refcount=1, version=518162)
DictStruct(size=0, refcount=1, version=518167)
DictStruct(size=0, refcount=1, version=518172)
DictStruct(size=0, refcount=1, version=518177)
DictStruct(size=0, refcount=1, version=518182)
DictStruct(size=0, refcount=1, version=518187)
DictStruct(size=0, refcount=1, version=518192)

你可能期望这些版本号每次递增一个,但是版本号受到Python在后台使用许多字典的影响:除其他外,局部变量、全局变量和对象属性都是以字典形式存储的,创建或修改其中任何一个都会导致全局版本号递增。

同样地,任何时候我们修改我们的dict都会得到一个更高的版本号。

在[7]中:

D = {}
Dwrap = DictStruct.wrap(D)
for i in range(10):
    D[i] = i
    print(Dwrap)
DictStruct(size=1, refcount=1, version=521221)
DictStruct(size=2, refcount=1, version=521254)
DictStruct(size=3, refcount=1, version=521270)
DictStruct(size=4, refcount=1, version=521274)
DictStruct(size=5, refcount=1, version=521278)
DictStruct(size=6, refcount=1, version=521288)
DictStruct(size=7, refcount=1, version=521329)
DictStruct(size=8, refcount=1, version=521403)
DictStruct(size=9, refcount=1, version=521487)
DictStruct(size=10, refcount=1, version=521531)

Monkey-patching Dict¶

让我们更进一步,用一个直接访问版本的方法对dict对象本身进行猴子式修补。基本上,我们要在dict 类中添加一个访问该值的get_version 方法。

我们的第一次尝试可能是这样的。

在 [8] 中:

dict.get_version = lambda obj: DictStruct.wrap(obj).ma_version_tag
---------------------------------------------------------------------------

我们得到一个错误,因为 Python 保护内置类型的属性不受这种干扰。但是不要害怕!我们可以用 (你猜对了)ctypes 来解决这个问题 !

任何 Python 对象的属性和方法都存储在它的__dict__ 属性中,在 Python 3.6 中它不是一个字典,而是一个mappingproxy 对象,你可以把它看作是底层字典的一个只读包装。

In [9]中:

class Foo:
    bar = 4
    
Foo.__dict__

Out[9]:

mappingproxy({'__dict__': <attribute '__dict__' of 'Foo' objects>,
              '__doc__': None,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'Foo' objects>,
              'bar': 4})

事实上,看看 Python 3.6 的mappingproxyobject 实现,我们看到它只是一个带有指向底层 dict 的指针的对象。

typedef struct {
    PyObject_HEAD
    PyObject *mapping;
} mappingproxyobject;

让我们写一个ctypes 结构来暴露这一点。

在 [10]中

import types

class MappingProxyStruct(PyObjectStruct):
    _fields_ = [("mapping", ctypes.POINTER(DictStruct))]
    
    @classmethod
    def wrap(cls, D):
        assert isinstance(D, types.MappingProxyType)
        return cls.from_address(id(D))
    
# Sanity check
assert types.MappingProxyType.__basicsize__ == ctypes.sizeof(MappingProxyStruct)

现在我们可以用它来获得任何映射代理的底层dict的C级手柄。

在[11]中:

proxy = MappingProxyStruct.wrap(dict.__dict__)
proxy.mapping

Out[11]:

<__main__.LP_DictStruct at 0x10667dc80>

我们可以把这个句柄传递给 C API 中的函数,以便修改被只读映射代理包裹的字典。

In [12]中

def mappingproxy_setitem(obj, key, val):
    """Set an item in a read-only mapping proxy"""
    proxy = MappingProxyStruct.wrap(obj)
    ctypes.pythonapi.PyDict_SetItem(proxy.mapping,
                                    ctypes.py_object(key),
                                    ctypes.py_object(val))

In [13]中:

mappingproxy_setitem(dict.__dict__,
                     'get_version',
                     lambda self: DictStruct.wrap(self).ma_version_tag)

一旦执行了这个,我们可以在任何Python 字典上作为一个方法调用get_version() 来获得版本号。

在 [15] 中

{}.get_version()

Out[15]:

544453

这种猴子式的修补可以用于任何内置类型;例如,我们可以给字符串添加一个scramble 方法,随机选择其内容的大写或小写。

在[16]中:

import random
mappingproxy_setitem(str.__dict__,
                     'scramble',
                     lambda self: ''.join(random.choice([c.lower(), c.upper()]) for c in self))

在[17]中

'hello world'.scramble()

Out[17]:

'hellO WORLd'

这种可能性是无穷无尽的,但要注意的是,任何时候你在运行时对CPython的内部结构做手脚,都有可能产生奇怪的副作用。这绝对不是你应该用于任何目的的代码,除了简单地享受探索语言的乐趣。

如果你对修改 CPython 运行时的其他方式感到好奇,你可能会对我两年前的文章《为什么 Python 很慢?看看引擎盖下面

那么......为什么?

现在我们可以很容易地访问dict的版本号,你可能想知道我们能用它做什么。

答案是,目前,没有那么多。在 CPython 源码中,除了它的定义之外,唯一引用版本标签的时候是在单元测试中。各种Python优化项目在未来将能够使用这一特性来更好地优化Python代码,但据我所知,目前还没有人这样做(例如,这里有一个相关的Numba问题FATpython讨论)。

所以就目前而言,对字典版本号的访问,正如他们所说的,纯粹是学术性的。但我希望在不久的将来,通过网络搜索,有人会发现这段代码不仅仅是纯学术意义上的有用。