1. 程式人生 > >深入 Python 直譯器原始碼,我終於搞明白了字串駐留的原理!

深入 Python 直譯器原始碼,我終於搞明白了字串駐留的原理!

英文: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