

深入 Python 解释器源码,我终于搞明白了字符串驻留的原理!
source link: https://segmentfault.com/a/1190000039213356
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

英文: https://arpitbhayani.me/blogs...
作者:arpit
译者:豌豆花下猫(“Python猫”公众号作者)
声明:本翻译是出于交流学习的目的,基于 CC BY-NC-SA 4.0 授权协议。为便于阅读,内容略有改动。
每种编程语言为了表现出色,并且实现卓越的性能,都需要有大量编译器级与解释器级的优化。
由于字符串是任何编程语言中不可或缺的一个部分,因此,如果有快速操作字符串的能力,就可以迅速地提高整体的性能。
在本文中, 我们将深入研究 Python 的内部实现,并了解 Python 如何使用一种名为字符串驻留( String Interning )的技术,实现解释器的高性能。 本文的目的不仅在于介绍 Python 的内部知识,而且还旨在使读者能够轻松地浏览 Python 的源代码;因此,本文中将有很多出自 CPython 的代码片段。
全文提纲如下:
(在 Python猫 公众号回复数字“0215”,下载高清思维导图)
1、什么是“字符串驻留”?
字符串驻留是一种编译器/解释器的优化方法,它通过 缓存
一般性的字符串,从而节省字符串处理任务的空间和时间。
这种优化方法不会每次都创建一个新的字符串副本,而是仅为每个 适当的 不可变值保留一个字符串副本,并使用指针引用之。
每个字符串的唯一拷贝被称为它的 intern
,并因此而得名 String Interning。
Python猫注:String Interning 一般被译为“字符串驻留”或“字符串留用”,在某些语言中可能习惯用 String Pool(字符串常量池)的概念,其实是对同一种机制的不同表述。intern 作为名词时,是“实习生、实习医生”的意思,在此可以理解成“驻留物、驻留值”。
查找字符串 intern 的方法可能作为公开接口公开,也可能不公开。现代编程语言如 Java、Python、PHP、Ruby、Julia 等等,都支持字符串驻留,以使其编译器和解释器做到高性能。
2、为什么要驻留字符串?
字符串驻留提升了字符串比较的速度。如果没有驻留,当我们要比较两个字符串是否相等时,它的时间复杂度将上升到 O(n),即需要检查两个字符串中的每个字符,才能判断出它们是否相等。
但是,如果字符串是固定的,由于相同的字符串将使用同一个对象引用,因此只需检查指针是否相同,就足以判断出两个字符串是否相等,不必再逐一检查每个字符。由于这是一个非常普遍的操作,因此,它被典型地实现为指针相等性校验,仅使用一条完全没有内存引用的机器指令。
字符串驻留减少了内存占用。Python 避免内存中充斥多余的字符串对象,通过 享元设计模式
共享和重用已经定义的对象,从而优化内存占用。
3、Python的字符串驻留
像大多数其它现代编程语言一样,Python 也使用字符串驻留来提高性能。在 Python 中,我们可以使用 is
运算符,检查两个对象是否引用了同一个内存对象。
因此,如果两个字符串对象引用了相同的内存对象,则 is
运算符将得出 True
,否则为 False
。
>>> 'python' is 'python' True
我们可以使用这个特定的运算符,来判断哪些字符串是被驻留的。在 CPython 的,字符串驻留是通过以下函数实现的,声明在 unicodeobject.h 中,定义在 unicodeobject.c 中。
PyAPI_FUNC(void) PyUnicode_InternInPlace(PyObject **);
为了检查一个字符串是否被驻留,CPython 实现了一个名为 PyUnicode_CHECK_INTERNED
的宏,同样是定义在 unicodeobject.h 中。
这个宏表明了 Python 在 PyASCIIObject
结构中维护着一个名为 interned
的成员变量,它的值表示相应的字符串是否被驻留。
#define PyUnicode_CHECK_INTERNED(op) \ (((PyASCIIObject *)(op))->state.interned)
4、字符串驻留的原理
在 CPython 中,字符串的引用被一个名为 interned
的 Python 字典所存储、访问和管理。 该字典在第一次调用字符串驻留时,被延迟地初始化,并持有全部已驻留字符串对象的引用。
4.1 如何驻留字符串?
负责驻留字符串的核心函数是 PyUnicode_InternInPlace
,它定义在 unicodeobject.c 中,当调用时,它会创建一个准备容纳所有驻留的字符串的字典 interned
,然后登记入参中的对象,令其键和值都使用相同的对象引用。
以下函数片段显示了 Python 实现字符串驻留的过程。
void PyUnicode_InternInPlace(PyObject **p) { PyObject *s = *p; ......... // Lazily build the dictionary to hold interned Strings if (interned == NULL) { interned = PyDict_New(); if (interned == NULL) { PyErr_Clear(); return; } } PyObject *t; // Make an entry to the interned dictionary for the // given object t = PyDict_SetDefault(interned, s, s); ......... // The two references in interned dict (key and value) are // not counted by refcnt. // unicode_dealloc() and _PyUnicode_ClearInterned() take // care of this. Py_SET_REFCNT(s, Py_REFCNT(s) - 2); // Set the state of the string to be INTERNED _PyUnicode_STATE(s).interned = SSTATE_INTERNED_MORTAL; }
4.2 如何清理驻留的字符串?
清理函数从 interned
字典中遍历所有的字符串,调整这些对象的引用计数,并把它们标记为 NOT_INTERNED
,使其被垃圾回收。一旦所有的字符串都被标记为 NOT_INTERNED
,则 interned
字典会被清空并删除。
这个清理函数就是 _PyUnicode_ClearInterned
,在 unicodeobject.c 中 定义。
void _PyUnicode_ClearInterned(PyThreadState *tstate) { ......... // Get all the keys to the interned dictionary PyObject *keys = PyDict_Keys(interned); ......... // Interned Unicode strings are not forcibly deallocated; // rather, we give them their stolen references back // and then clear and DECREF the interned dict. for (Py_ssize_t i = 0; i < n; i++) { PyObject *s = PyList_GET_ITEM(keys, i); ......... switch (PyUnicode_CHECK_INTERNED(s)) { case SSTATE_INTERNED_IMMORTAL: Py_SET_REFCNT(s, Py_REFCNT(s) + 1); break; case SSTATE_INTERNED_MORTAL: // Restore the two references (key and value) ignored // by PyUnicode_InternInPlace(). Py_SET_REFCNT(s, Py_REFCNT(s) + 2); break; case SSTATE_NOT_INTERNED: /* fall through */ default: Py_UNREACHABLE(); } // marking the string to be NOT_INTERNED _PyUnicode_STATE(s).interned = SSTATE_NOT_INTERNED; } // decreasing the reference to the initialized and // access keys object. Py_DECREF(keys); // clearing the dictionary PyDict_Clear(interned); // clearing the object interned Py_CLEAR(interned); }
5、字符串驻留的实现
既然了解了字符串驻留及清理的内部原理,我们就可以找出 Python 中所有会被驻留的字符串。
为了做到这点,我们要做的就是在 CPython 源代码中查找 PyUnicode_InternInPlace
函数的调用,并查看其附近的代码。下面是在 Python 中关于字符串驻留的一些有趣的发现。
5.1 变量、常量与函数名
CPython 对常量(例如函数名、变量名、字符串字面量等)执行字符串驻留。
以下代码出自 codeobject.c ,它表明在创建新的 PyCode
对象时,解释器将对所有编译期的常量、名称和字面量进行驻留。
PyCodeObject * PyCode_NewWithPosOnlyArgs(int argcount, int posonlyargcount, int kwonlyargcount, int nlocals, int stacksize, int flags, PyObject *code, PyObject *consts, PyObject *names, PyObject *varnames, PyObject *freevars, PyObject *cellvars, PyObject *filename, PyObject *name, int firstlineno, PyObject *linetable) { ........ if (intern_strings(names) < 0) { return NULL; } if (intern_strings(varnames) < 0) { return NULL; } if (intern_strings(freevars) < 0) { return NULL; } if (intern_strings(cellvars) < 0) { return NULL; } if (intern_string_constants(consts, NULL) < 0) { return NULL; } ........ }
5.2 字典的键
CPython 还会驻留任何字典对象的字符串键。
当在字典中插入元素时,解释器会对该元素的键作字符串驻留。以下代码出自 dictobject.c, 展示了实际的行为。
有趣的地方:在 PyUnicode_InternInPlace
函数被调用处有一条注释,它问道,我们是否真的需要对所有字典中的全部键进行驻留?
int PyDict_SetItemString(PyObject *v, const char *key, PyObject *item) { PyObject *kv; int err; kv = PyUnicode_FromString(key); if (kv == NULL) return -1; // Invoking String Interning on the key PyUnicode_InternInPlace(&kv); /* XXX Should we really? */ err = PyDict_SetItem(v, kv, item); Py_DECREF(kv); return err; }
5.3 任何对象的属性
Python 中对象的属性可以通过 setattr
函数显式地设置,也可以作为类成员的一部分而隐式地设置,或者在其数据类型中预定义。
CPython 会驻留所有这些属性名,以便实现快速查找。以下是函数 PyObject_SetAttr
的代码片段,该函数定义在文件 object.c中 ,负责为 Python 对象设置新属性。
int PyObject_SetAttr(PyObject *v, PyObject *name, PyObject *value) { ........ PyUnicode_InternInPlace(&name); ........ }
5.4 显式地驻留
Python 还支持通过 sys
模块中的 intern
函数进行显式地字符串驻留。
当使用任何字符串对象调用此函数时,该字符串对象将被驻留。以下是 sysmodule.c 文件的代码片段,它展示了在 sys_intern_impl
函数中的字符串驻留过程。
static PyObject * sys_intern_impl(PyObject *module, PyObject *s) { ........ if (PyUnicode_CheckExact(s)) { Py_INCREF(s); PyUnicode_InternInPlace(&s); return s; } ........ }
6、字符串驻留的其它发现
只有编译期的字符串会被驻留。在解释时或编译时指定的字符串会被驻留,而动态创建的字符串则不会。
Python猫注:这一条规则值得展开思考,我曾经在上面踩过坑……有两个知识点,我相信 99% 的人都不知道:字符串的 join() 方法是动态创建字符串,因此其创建的字符串不会被驻留; 常量折叠机制 也发生在编译期,因此有时候容易把它跟字符串驻留搞混淆。推荐阅读《 join()方法的神奇用处与Intern机制的软肋 》
包含 ASCII 字符和下划线的字符串会被驻留。在编译期间,当对字符串字面量进行驻留时, CPython 确保仅对匹配正则表达式 [a-zA-Z0-9_]*
的常量进行驻留,因为它们非常贴近于 Python 的标识符。
Python猫注:关于 Python 中标识符的命名规则,在 Python2 版本只有“字母、数字和下划线”,但在 Python 3.x 版本中,已经支持 Unicode 编码。这部分内容推荐阅读《 醒醒!Python已经支持中文变量名啦! 》
参考材料
- 字符串驻留( https://en.wikipedia.org/wiki...
- CPython优化( https://stummjr.org/post/cpyt...
- Python对象第三部分:字符串驻留( https://medium.com/@bdov_/htt...
- Python字符串驻留的内部原理( http://guilload.com/python-st...
- Python优化机制:常量折叠( https://mp.weixin.qq.com/s/p1...
- join()方法的神奇用处与Intern机制的软肋( https://mp.weixin.qq.com/s/M2...
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK