深入 Python 直譯器原始碼,我終於搞明白了字串駐留的原理!
阿新 • • 發佈:2021-02-15
英文:https://arpitbhayani.me/blogs/string-interning
作者:arpit
譯者:豌豆花下貓(“Python貓”公眾號作者)
宣告:本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。
每種程式語言為了表現出色,並且實現卓越的效能,都需要有大量編譯器級與直譯器級的優化。
由於字串是任何程式語言中不可或缺的一個部分,因此,如果有快速操作字串的能力,就可以迅速地提高整體的效能。
在本文中,**我們將深入研究 Python 的內部實現,並瞭解 Python 如何使用一種名為字串駐留([String Interning](https://en.wikipedia.org/wiki/String_interning))的技術,實現直譯器的高效能。** 本文的目的不僅在於介紹 Python 的內部知識,而且還旨在使讀者能夠輕鬆地瀏覽 Python 的原始碼;因此,本文中將有很多出自 [CPython](https://github.com/python/cpython/) 的程式碼片段。
全文提綱如下:
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gno8pogzbzj20w40m2gnr.jpg)
(在 **Python貓** 公眾號回覆數字“0215”,下載高清思維導圖)
## 1、什麼是“字串駐留”?
**字串駐留是一種編譯器/直譯器的優化方法,它通過`快取`一般性的字串,從而節省字串處理任務的空間和時間。**
這種優化方法不會每次都建立一個新的字串副本,而是僅為每個*適當的*不可變值保留一個字串副本,並使用指標引用之。
每個字串的唯一拷貝被稱為它的`intern`,並因此而得名 String Interning。
> Python貓注:String Interning 一般被譯為“字串駐留”或“字串留用”,在某些語言中可能習慣用 String Pool(字串常量池)的概念,其實是對同一種機制的不同表述。intern 作為名詞時,是“實習生、實習醫生”的意思,在此可以理解成“駐留物、駐留值”。
查詢字串 intern 的方法可能作為公開介面公開,也可能不公開。現代程式語言如 Java、Python、PHP、Ruby、Julia 等等,都支援字串駐留,以使其編譯器和直譯器做到高效能。
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gnneebmna7j20xc0hg3zv.jpg)
## 2、為什麼要駐留字串?
**字串駐留提升了字串比較的速度。** 如果沒有駐留,當我們要比較兩個字串是否相等時,它的時間複雜度將上升到 O(n),即需要檢查兩個字串中的每個字元,才能判斷出它們是否相等。
但是,如果字串是固定的,由於相同的字串將使用同一個物件引用,因此只需檢查指標是否相同,就足以判斷出兩個字串是否相等,不必再逐一檢查每個字元。由於這是一個非常普遍的操作,因此,它被典型地實現為指標相等性校驗,僅使用一條完全沒有記憶體引用的機器指令。
**字串駐留減少了記憶體佔用。** Python 避免記憶體中充斥多餘的字串物件,通過[`享元設計模式`](https://en.wikipedia.org/wiki/Flyweight_pattern)共享和重用已經定義的物件,從而優化記憶體佔用。
## 3、Python的字串駐留
像大多數其它現代程式語言一樣,Python 也使用字串駐留來提高效能。在 Python 中,我們可以使用`is`運算子,檢查兩個物件是否引用了同一個記憶體物件。
因此,如果兩個字串物件引用了相同的記憶體物件,則`is`運算子將得出`True`,否則為`False`。
```python
>>> 'python' is 'python'
True
```
我們可以使用這個特定的運算子,來判斷哪些字串是被駐留的。在 CPython 的,字串駐留是通過以下函式實現的,宣告在 unicodeobject.h 中,定義在 unicodeobject.c 中。
```c
PyAPI_FUNC(void) PyUnicode_InternInPlace(PyObject **);
```
為了檢查一個字串是否被駐留,CPython 實現了一個名為`PyUnicode_CHECK_INTERNED`的巨集,同樣是定義在 unicodeobject.h 中。
這個巨集表明了 Python 在`PyASCIIObject`結構中維護著一個名為`interned`的成員變數,它的值表示相應的字串是否被駐留。
```c
#define PyUnicode_CHECK_INTERNED(op) \
(((PyASCIIObject *)(op))->state.interned)
```
## 4、字串駐留的原理
**在 CPython 中,字串的引用被一個名為`interned`的 Python 字典所儲存、訪問和管理。** 該字典在第一次呼叫字串駐留時,被延遲地初始化,並持有全部已駐留字串物件的引用。
### 4.1 如何駐留字串?
負責駐留字串的核心函式是`PyUnicode_InternInPlace`,它定義在 unicodeobject.c 中,當呼叫時,它會建立一個準備容納所有駐留的字串的字典`interned`,然後登記入參中的物件,令其鍵和值都使用相同的物件引用。
以下函式片段顯示了 Python 實現字串駐留的過程。
```c
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 中](https://github.com/python/cpython/blob/master/Objects/unicodeobject.c)定義。
```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](https://github.com/python/cpython/blob/master/Objects/codeobject.c),它表明在建立新的`PyCode`物件時,直譯器將對所有編譯期的常量、名稱和字面量進行駐留。
```c
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,](https://github.com/python/cpython/blob/master/Objects/dictobject.c)展示了實際的行為。
有趣的地方:在`PyUnicode_InternInPlace`函式被呼叫處有一條註釋,它問道,我們是否真的需要對所有字典中的全部鍵進行駐留?
```c
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中](https://github.com/python/cpython/blob/master/Objects/object.c),負責為 Python 物件設定新屬性。
```c
int
PyObject_SetAttr(PyObject *v, PyObject *name, PyObject *value)
{
........
PyUnicode_InternInPlace(&name);
........
}
```
### 5.4 顯式地駐留
**Python 還支援通過`sys`模組中的`intern`函式進行顯式地字串駐留。**
當使用任何字串物件呼叫此函式時,該字串物件將被駐留。以下是 [sysmodule.c](https://github.com/python/cpython/blob/master/Python/sysmodule.c) 檔案的程式碼片段,它展示了在`sys_intern_impl`函式中的字串駐留過程。
```c
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() 方法是動態建立字串,因此其建立的字串不會被駐留;[常量摺疊機制](https://mp.weixin.qq.com/s/p1Zb_linFLWwPlNyA5Ui1Q)也發生在編譯期,因此有時候容易把它跟字串駐留搞混淆。推薦閱讀《[join()方法的神奇用處與Intern機制的軟肋](https://mp.weixin.qq.com/s/M2uHVqaHe_nyO5jT60V_6Q)》
**包含 ASCII 字元和下劃線的字串會被駐留。** 在編譯期間,當對字串字面量進行駐留時,[CPython](https://github.com/python/cpython/blob/master/Objects/codeobject.c) 確保僅對匹配正則表示式`[a-zA-Z0-9_]*`的常量進行駐留,因為它們非常貼近於 Python 的識別符號。
> Python貓注:關於 Python 中識別符號的命名規則,在 Python2 版本只有“字母、數字和下劃線”,但在 Python 3.x 版本中,已經支援 Unicode 編碼。這部分內容推薦閱讀《[醒醒!Python已經支援中文變數名啦!](https://mp.weixin.qq.com/s/eaxlo71sN4JRnBiPNBxKzQ)》
## 參考材料
- 字串駐留(https://en.wikipedia.org/wiki/String_interning)
- CPython優化(https://stummjr.org/post/cpython-optimizations/)
- Python物件第三部分:字串駐留(https://medium.com/@bdov_/https-medium-com-bdov-python-objects-part-iii-string-interning-625d3c7319de)
- Python字串駐留的內部原理(http://guilload.com/python-string-interning/)
- Python優化機制:常量摺疊(https://mp.weixin.qq.com/s/p1Zb_linFLWwPlNyA5Ui1Q)
- join()方法的神奇用處與Intern機制的軟肋(https://mp.weixin.qq.com/s/M2uHVqaHe_nyO5jT