深入 Python 解釋器源碼,我終於搞明白了字符串駐留的原理!

2021-02-19 小白學視覺

聲明:本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。

每種程式語言為了表現出色,並且實現卓越的性能,都需要有大量編譯器級與解釋器級的優化。

由於字符串是任何程式語言中不可或缺的一個部分,因此,如果有快速操作字符串的能力,就可以迅速地提高整體的性能。

在本文中,我們將深入研究 Python 的內部實現,並了解 Python 如何使用一種名為字符串駐留(String Interning)的技術,實現解釋器的高性能。 本文的目的不僅在於介紹 Python 的內部知識,而且還旨在使讀者能夠輕鬆地瀏覽 Python 的原始碼;因此,本文中將有很多出自 CPython 的代碼片段。

全文提綱如下:

(在 Python貓 公眾號回複數字「0215」,下載思維導圖)

字符串駐留是一種編譯器/解釋器的優化方法,它通過緩存一般性的字符串,從而節省字符串處理任務的空間和時間。

這種優化方法不會每次都創建一個新的字符串副本,而是僅為每個適當的不可變值保留一個字符串副本,並使用指針引用之。

每個字符串的唯一拷貝被稱為它的intern,並因此而得名 String Interning。

查找字符串 intern 的方法可能作為公開接口公開,也可能不公開。現代程式語言如 Java、Python、PHP、Ruby、Julia 等等,都支持字符串駐留,以使其編譯器和解釋器做到高性能。


字符串駐留提升了字符串比較的速度。 如果沒有駐留,當我們要比較兩個字符串是否相等時,它的時間複雜度將上升到 O(n),即需要檢查兩個字符串中的每個字符,才能判斷出它們是否相等。

但是,如果字符串是固定的,由於相同的字符串將使用同一個對象引用,因此只需檢查指針是否相同,就足以判斷出兩個字符串是否相等,不必再逐一檢查每個字符。由於這是一個非常普遍的操作,因此,它被典型地實現為指針相等性校驗,僅使用一條完全沒有內存引用的機器指令。

字符串駐留減少了內存佔用。 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)


在 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);
}


既然了解了字符串駐留及清理的內部原理,我們就可以找出 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;
    }

    ...
}


只有編譯期的字符串會被駐留。 在解釋時或編譯時指定的字符串會被駐留,而動態創建的字符串則不會。、

包含 ASCII 字符和下劃線的字符串會被駐留。 在編譯期間,當對字符串字面量進行駐留時,CPython 確保僅對匹配正則表達式[a-zA-Z0-9_]*的常量進行駐留,因為它們非常貼近於 Python 的標識符。

相關焦點

  • 面試官:這次聊聊Python字符串駐留機制?
    在本文中,我們將深入研究 Python 的內部實現,並了解 Python 如何使用一種名為字符串駐留(String Interning)的技術,實現解釋器的高性能。>>> 'python' is 'python'True我們可以使用這個特定的運算符,來判斷哪些字符串是被駐留的。
  • WTF Python:有趣且鮮為人知的Python特性
    這個有趣的項目意在收集 Python 中那些難以理解和反人類直覺的例子以及鮮為人知的功能特性,並嘗試討論這些現象背後真正的原理!雖然下面的有些例子並不一定會讓你覺得 WTFs,但它們依然有可能會告訴你一些你所不知道的 Python 有趣特性。我覺得這是一種學習程式語言內部原理的好辦法,而且我相信你也會從中獲得樂趣!
  • Python帶我飛:50個有趣而又鮮為人知的Python特性
    這個有趣的項目意在收集 Python 中那些難以理解和反人類直覺的例子以及鮮為人知的功能特性, 並嘗試討論這些現象背後真正的原理!雖然下面的有些例子並不一定會讓你覺得 WTFs,但它們依然有可能會告訴你一些你所不知道的 Python 有趣特性。我覺得這是一種學習程式語言內部原理的好辦法, 而且我相信你也會從中獲得樂趣!
  • 13-python中的字符串
    通過前兩天的文章12-python中的集合我們學習了有關集合的知識,今天我們將學習一下python中的字符串。(一)字符串的介紹    字符串,是python中的基本數據類型,是一個不可變的字符序列。    字符串的駐留機制,是僅保留一份相同且不可變字符串的一種方法。
  • 我的 Python 編碼規範
    源碼文件應該以下部分組成。如果 python 源碼文件沒有聲明編碼格式,python 解釋器會默認使用 ASCII 編碼,一旦源碼文件包含非ASCII編碼的字符,python 解釋器就會報錯。以 UTF-8 為例,以下兩種編碼格式聲明都是合乎規則的。我一直 UTF-8 編碼格式,喜歡使用第一種聲明方式。Windows 平臺上,編碼格式聲明必須位於 python 文件的第一行。
  • Python程序執行過程與字節碼
    程序寫好後,只需敲下 python 命令,便可將程序啟動起來並開始執行:$ python some-program.py那麼,一個文本形式的 .py 文件,是如何一步步轉換為能夠被 CPU 執行的機器指令的呢?
  • 11 個優秀的 Python 編譯器和解釋器
    其解釋器可在Windows、Linux 和 Mac OS 等多種作業系統上使用。它的可移植性和可伸縮性等特性使得它更加容易被運用。本文重點介紹了適用於 Python 程式設計師的 11 種優秀的 Python 編譯器和解釋器。很好的 Python 編譯器和解釋器1.Brython
  • 字符串(深入Python3)
    同時,(在這一條上你得相信我,因為我不打算給你展示它的數學原理。)由位操作的天性使然,使用UTF-8不再存在字節順序的問題了。一份以UTF-8編碼的文檔在不同的計算機之間是一樣的比特流。概述在Python 3,所有的字符串都是使用Unicode編碼的字符序列。不再存在以UTF-8或者CP-1252編碼的情況。
  • python爬蟲-字符串
    python字符串 Python中的字符串可以使用單引號、雙引號和三引號(三個單引號或三個雙引號,可以換行的)括起來,使用反斜槓 \ 轉義特殊字符
  • python爬蟲 - 字符串
    python字符串Python中的字符串可以使用單引號、雙引號和三引號(三個單引號或三個雙引號,可以換行的)括起來,使用反斜槓 \ 轉義特殊字符Python3源碼文件默認以UTF-8編碼,所有字符串都是unicode字符串支持字符串拼接、截取等多種運算
  • python解釋器到底是什麼?
    有很多入門學習python的同學都沒有搞清python解釋器是怎麼回事,所以今天在這裡追根溯源的解釋一下。 計算機程式語言 從計算機程式語言說起,它主要分為三類:機器語言、彙編語言、高級語言。
  • 十六、深入Python字符串
    「@Author :Runsen」python日常處理字符串較多,本文總結一下Python的日常使用。注意python的字符串是不可變的,這個和元組一樣。s[0] = 'r'Traceback (most recent call last):  File "<stdin>", line 1, in <module>TypeError: 'str' object does not support item assignmentpython
  • 學Python,搞Kodi
    如果只是編寫xbmc插件,並且在xbmc裡面進行調試的話,可以不用下載安裝python軟體包。在官方下載地址上有多個版本下載,因為XBMC內置的解釋器是基於python2的,所以請大家下載2.7.3版本進行安裝。特別注意,python 3在很多地方與python 2是不兼容的。    DreamPie是一個很好的Python Shell,我經常拿它來進行交互開發,或者作為高級計算器。
  • Python中的「f-」字符串
    student_id = 23f"我的學號是:{student_id}">>> 輸出:'我的學號是:23'如果用.format來表示上述兩個例子,則是這樣的:"你好!{}!".format(name)"我的學號是:{}".format(student_id)相較之下,f-字符串比.format格式還是顯得輕盈許多,下面繼續給出例子:fruit = ["蘋果", "香蕉", "梨子", "桃子"]f"水果:{fruit}">>> 輸出:"水果:['蘋果', '香蕉', '梨子', '桃子
  • Python構件:PyObject
    深入使用Python語言之前,我們有必要熟悉其中的主要概念。十分簡單,每個元素都是一個對象。這是我們學習Python內部原理的第一步,以便我們繼續深入學習。  本文主要探討的是如何在低版本Python系統中操作它的對象。接下來要討論的CPython操作基於Python 2.7.8。
  • 深入理解Python字符串的用法
    拼接字符串字符串的拼接操作最常用,我專門為這個話題寫過一篇《Python拼接字符串的七種方式》,建議你回看。在此,簡單回顧一下:七種拼接方式從實現原理上劃分為三類,即格式化類(%佔位符、format()、template)、拼接類(+操作符、類元祖方式、join())與插值類(f-string),在使用上,我有如下建議——當要處理字符串列表等序列結構時,採用join()方式;拼接長度不超過20時,選用+號操作符方式;長度超過20的情況,高版本選用f-string,低版本時看情況使用
  • Python小白必備知識:Python字符串詳解
    注意,此時 Python 解釋器並不會忽略長字符串,也會按照語法解析,只是長字符串起不到實際作用而已。當程序中有大段文本內容需要定義成字符串時,優先推薦使用長字符串形式,因為這種形式非常強大,可以在字符串中放置任何內容,包括單引號和雙引號。
  • 阿里P7工程師耗時兩天整理的292道python大廠面試題,內含解析!
    解釋器種類以及特點CPythonc語言開發的 使用最廣的解釋器IPython基於cpython之上的一個交互式計時器交互方式增強功能和cpython一樣PyPy目標是執行效率勁JIT技術對python代碼進行動態編譯,提高執行效率JPython運行在Java.上的解釋器直接把python代碼編譯成Java字節碼執行
  • Python基礎(六):字符串的使用(上)
    通俗的說:字符串就是一系列的字符。2.常見的5種轉義字符3.1 轉義字符列舉Python 中常見的轉義字符,我這裡為大家詳細列舉出來了。>>> x = 'python'>>> print(x[0],x[5])p n>>> print(x[-6],x[-1])p n>>> print(x[1:4])yth>>> print(x[-5:-2
  • Python編譯器與解釋器
    但是,在此之前,還要先說說編譯器與解釋器相關的內容。如果這部分內容,讓你覺得難以理解或不能完全明白,可以暫時跳過,等以後再回過頭來重新讀一遍。一、數據的表示方式我們都知道,現實生活中,數字的表示方式有很多種,常見的有二進位、八進位、十進位和十六進位。