1. 程式人生 > 其它 >字串intern機制 | 字串駐留 | Python原始碼

字串intern機制 | 字串駐留 | Python原始碼

有次聊天,有人說字串駐留技術還是蠻好的。看著別人一臉認真的樣子,我一臉贊同的點點頭,現在來補一補這東西是啥。

先看看字串相關定義

PyStringObject 定義

# Include/stringobject.h
typedef struct {
    PyObject_VAR_HEAD
    long ob_shash;
    int ob_sstate;
    char ob_sval[1];

} PyStringObject;

PyObject_VAR_HEAD 中的 ob_size ,記錄變長物件的記憶體大小,ov_sval 作為字元指標指向一段記憶體,這段記憶體就是實際字串。比例 test_str 的 ob_size 則為11。

ob_shash 則是該物件的雜湊值,這在 dict 型別中是非常有用的,作為 key 值存在。

ob_sstate 則是表明該物件是否經過 intern 機制處理,簡單來說就是即值同樣的字串物件僅僅會儲存一份,放在一個字串儲蓄池中,是共用的,當然,肯定不能改變,這也決定了字串必須是不可變物件

PyStringObject 建立

# Include/stringobject.h

PyAPI_FUNC(PyObject *) PyString_FromStringAndSize(const char *, Py_ssize_t);
PyAPI_FUNC(PyObject *) PyString_FromString(const char *);
PyAPI_FUNC(PyObject *) PyString_FromFormatV(const char*, va_list)
				Py_GCC_ATTRIBUTE((format(printf, 1, 0)));
PyAPI_FUNC(PyObject *) PyString_FromFormat(const char*, ...)
				Py_GCC_ATTRIBUTE((format(printf, 1, 2)));

從定義來看,可以用很多種方式建立PyStringObject,最常用為PyString_FromString

# Objects/stringobject.c
# null 以及單字串,內部使用 interned 快取了,命中直接返回

static PyObject *interned;

void
PyString_InternInPlace(PyObject **p)
{
    register PyStringObject *s = (PyStringObject *)(*p);
    PyObject *t;
    if (s == NULL || !PyString_Check(s))
        Py_FatalError("PyString_InternInPlace: strings only please!");
    /* If it's a string subclass, we don't really know what putting
       it in the interned dict might do. */
    if (!PyString_CheckExact(s))
        return;
    if (PyString_CHECK_INTERNED(s))
        return;
    if (interned == NULL) {
        interned = PyDict_New();
        if (interned == NULL) {
            PyErr_Clear(); /* Don't leave an exception */
            return;
        }
    }
    t = PyDict_GetItem(interned, (PyObject *)s);
    if (t) {
        Py_INCREF(t);
        Py_SETREF(*p, t);
        return;
    }

    if (PyDict_SetItem(interned, (PyObject *)s, (PyObject *)s) < 0) {
        PyErr_Clear();
        return;
    }
    /* The two references in interned are not counted by refcnt.
       The string deallocator will take care of this */
    Py_REFCNT(s) -= 2;
    PyString_CHECK_INTERNED(s) = SSTATE_INTERNED_MORTAL;
}

# 字串建立
PyObject *
PyString_FromString(const char *str)
{
    register size_t size;
    register PyStringObject *op;

    assert(str != NULL);
    size = strlen(str);
    if (size > PY_SSIZE_T_MAX - PyStringObject_SIZE) {
        PyErr_SetString(PyExc_OverflowError,
            "string is too long for a Python string");
        return NULL;
    }
    if (size == 0 && (op = nullstring) != NULL) {
#ifdef COUNT_ALLOCS
        null_strings++;
#endif
        Py_INCREF(op);
        return (PyObject *)op;
    }
    if (size == 1 && (op = characters[*str & UCHAR_MAX]) != NULL) {
#ifdef COUNT_ALLOCS
        one_strings++;
#endif
        Py_INCREF(op);
        return (PyObject *)op;
    }

    /* Inline PyObject_NewVar */
    op = (PyStringObject *)PyObject_MALLOC(PyStringObject_SIZE + size);
    if (op == NULL)
        return PyErr_NoMemory();
    (void)PyObject_INIT_VAR(op, &PyString_Type, size);
    op->ob_shash = -1;
    op->ob_sstate = SSTATE_NOT_INTERNED;
    Py_MEMCPY(op->ob_sval, str, size+1);
    /* share short strings */
    if (size == 0) {
        PyObject *t = (PyObject *)op;
        PyString_InternInPlace(&t);
        op = (PyStringObject *)t;
        nullstring = op;
        Py_INCREF(op);
    } else if (size == 1) {
        PyObject *t = (PyObject *)op;
        PyString_InternInPlace(&t);
        op = (PyStringObject *)t;
        characters[*str & UCHAR_MAX] = op;
        Py_INCREF(op);
    }
    return (PyObject *) op;
}

總結就是:

  1. 判斷字串是否過長,過長,則返回 null 指標
  2. 判斷是否是空串,空串,則將引用
  3. 分配記憶體,並將字串複製到 op->ob_sval 中

PyStringObject 記憶體佈局

\ \ ob_size ob_shash ob_sstate ob_sval
REF TYPE 6 -1 0 P y t h o n \0

快取池過渡

In [6]: a = 'thisistest'

In [7]: b = 'thisistest'

In [8]: a is b
Out[8]: True

In [9]: id(a) == id(b)
Out[9]: True

按照上述解釋,長字串,應該是重新建立。結果提示記憶體地址都一樣,那麼說明字串駐留不僅僅是指建立這一塊,應該是一種優化方式。驗證程式碼裡存在定義,用法也存在,那我們看看定義。

什麼是“字串駐留”?

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

這種優化方法不會每次都建立一個新的字串副本,而是僅為每個適當的不可變值保留一個字串副本,並使用指標引用之。每個字串的唯一拷貝被稱為它的intern,並因此而得名 String Interning。

現代程式語言如 Java、Python、PHP、Ruby、Julia 等等,都支援字串駐留,以使其編譯器和直譯器做到高效能。

為什麼要駐留字串?

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

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

字串駐留減少了記憶體佔用。 Python 避免記憶體中充斥多餘的字串物件,通過享元設計模式共享和重用已經定義的物件,從而優化記憶體佔用。

哪些可以駐留呢?

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

# join不駐留
In [11]: a = 'thisistest'

In [12]: a
Out[12]: 'thisistest'

In [13]: a is "".join(a)
Out[13]: False

In [14]: a == "".join(a)
Out[14]: True

#  僅對匹配正則表示式[a-zA-Z0-9_]*的常量進行駐留
In [15]: A = 'This is test'

In [16]: B = 'This is test'

In [17]: A is B
Out[17]: False

Intern 機制的大致原理很好理解,然而影響結果的還有 CPython 直譯器的其它編譯及執行機制,字串物件受到這些機制的共同影響。實際上,只有那些“看起來像” Python 識別符號的字串才會被處理。原始碼StringObject.h的註釋中寫道:

/* … … This is generally restricted to strings that “looklike” Python identifiers, although the intern() builtin can be used to force interning of any string … … */

這些機制的相互作用,不經意間帶來了不少混亂的現象:

# 長度超過20,不被intern VS 被intern
'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa'
>>> False
'aaaaaaaaaaaaaaaaaaaaa' is 'aaaaaaaaaaaaaaaaaaaaa'
>>> True

# 長度不超過20,不被intern VS 被intern
s = 'a'
s * 5 is 'aaaaa'
>>> False
'a' * 5 is 'aaaaa'
>>> True


# join方法,不被intern VS 被intern
''.join('hi') is 'hi'
>>> False
''.join('h') is 'h'
>>> True

# 特殊符號,不被intern VS 被"intern"
'python!' is 'python!'
>>> False
a, b = 'python!', 'python!'
a is b
>>> True

參考文獻

字串駐留: https://zhuanlan.zhihu.com/p/351244769