公开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现在都有一个版本号,它是
- 全局唯一的
- 每当一个dict被修改时,在本地更新
- 每当任何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讨论)。
所以就目前而言,对字典版本号的访问,正如他们所说的,纯粹是学术性的。但我希望在不久的将来,通过网络搜索,有人会发现这段代码不仅仅是纯学术意义上的有用。