探究Python源碼,終於弄懂了字符串駐留技術

摘要:在本文中,咱們將深刻研究 Python 的內部實現,並瞭解 Python 如何使用一種名爲字符串駐留(String Interning)的技術,實現解釋器的高性能。

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

因爲字符串是任何編程語言中不可或缺的一個部分,所以,若是有快速操做字符串的能力,就能夠迅速地提升總體的性能。git

在本文中,咱們將深刻研究 Python 的內部實現,並瞭解 Python 如何使用一種名爲字符串駐留(String Interning)的技術,實現解釋器的高性能。 本文的目的不只在於介紹 Python 的內部知識,並且還旨在使讀者可以輕鬆地瀏覽 Python 的源代碼;所以,本文中將有不少出自 CPython 的代碼片斷。github

全文提綱以下:正則表達式

一、什麼是「字符串駐留」?

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

這種優化方法不會每次都建立一個新的字符串副本,而是僅爲每一個適當的不可變值保留一個字符串副本,並使用指針引用之。每一個字符串的惟一拷貝被稱爲它的intern,並所以而得名 String Interning。設計模式

String Interning 通常被譯爲「字符串駐留」或「字符串留用」,在某些語言中可能習慣用 String Pool(字符串常量池)的概念,實際上是對同一種機制的不一樣表述。intern 做爲名詞時,是「實習生、實習醫生」的意思,在此能夠理解成「駐留物、駐留值」。緩存

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

二、爲何要駐留字符串?

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

可是,若是字符串是固定的,因爲相同的字符串將使用同一個對象引用,所以只需檢查指針是否相同,就足以判斷出兩個字符串是否相等,沒必要再逐一檢查每一個字符。因爲這是一個很是廣泛的操做,所以,它被典型地實現爲指針相等性校驗,僅使用一條徹底沒有內存引用的機器指令。post

字符串駐留減小了內存佔用。 Python 避免內存中充斥多餘的字符串對象,經過享元設計模式共享和重用已經定義的對象,從而優化內存佔用。

三、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;
      }
  ​
      ........
  }

六、字符串駐留的其它發現

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

Python貓注:這一條規則值得展開思考,我曾經在上面踩過坑……有兩個知識點,我相信 99% 的人都不知道:字符串的 join() 方法是動態建立字符串,所以其建立的字符串不會被駐留;常量摺疊機制也發生在編譯期,所以有時候容易把它跟字符串駐留搞混淆。推薦閱讀《join()方法的神奇用處與Intern機制的軟肋》

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

注:關於 Python 中標識符的命名規則,在 Python2 版本只有「字母、數字和下劃線」,但在 Python 3.x 版本中,已經支持 Unicode 編碼。這部份內容推薦閱讀《醒醒!Python已經支持中文變量名啦!》

參考材料

本文分享自華爲雲社區《深刻 Python 解釋器源碼,我終於搞明白了字符串駐留的原理!》,原文做者:Python貓 。

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

相關文章
相關標籤/搜索