Python字符串對象是一個容器
PyASCIIObject、PyCompactUnicodeObject和PyUnicodeObject都是容器對象。因為它們有兩部分組成
- 頭部(Overhead):PyASCIIObject、PyCompactUnicodeObject、PyUnicodeObject初始化后的結構體信息
- 有效負載(Payload):就是實際保存字符串副本的有效內存區(qū)域。
容器對象最早是在C++中提出的一個面向對象的數(shù)據(jù)結構概念。容器對象通過一個頭部(Overhead)內部一個數(shù)據(jù)指針來維護著實質上持有對象內存數(shù)據(jù)的堆內存區(qū)域。從而減少程序員對指針的人為操作,因為像C語言那樣任由程序員操作指針,C++認為這是很危險的,因此容器對象通過一個類并定義了很多相關的屬性,當中包含一個內部數(shù)據(jù)指針(一般來說是void指針)用于指向存放對象數(shù)據(jù)的堆內存區(qū)域,容器的這些屬性字段就實時記錄整個對象數(shù)據(jù)的運行時狀態(tài)。并且C++的容器對內部的數(shù)據(jù)指針是私有,外部代碼通常無法訪問或操作其內部指針,這是面向對象編程中容器對象是類型安全和友好的。而CPython也借鑒了這一構思,因為CPython是基于C實現(xiàn)的,因此無法提供有效的運行時訪問限制,更談不上類型安全了。有趣的是C++所有內置的容器對象基本上是開源,你可以做一些hack處理,仍然能夠任意蹂躪其內部指針。但Java、.Net對容器的構思的實現(xiàn)更徹底了,他們的虛擬機從實現(xiàn)層面已經(jīng)徹底封裝任何可能涉及指針類型的操作。因此Java、.Net的語法層面并不存在指針這一說法。
從內存布局來說,這里PyASCIIObject、PyCompactUnicodeObject、PyUnicodeObject屬于緊湊型的容器對象,因為頭部和有效負載部分是緊挨著的。這樣的編碼設計對于內存回收非常有利,因為內存釋放時,能夠將一大片連續(xù)的內存歸還操作系統(tǒng)的虛擬內存管理器(VM),從而減少碎片的產(chǎn)生。還有一種叫做分離的容器對象,也就是說因為頭部和有效負載部分是分離的,例如CPython內部的arena對象就屬于這一類型。分離的容器對象會在內存回收時產(chǎn)生不必要的內存碎片,對操作系統(tǒng)造成一定的困擾。如果你曾深入領悟C/C++,一定會明白我說的個中體會。
Python字符串的內存模型
首要的事情,再來一遍—讓我們回顧一下到目前為止我們學到的知識:
- Python中的所有內容都是一個對象,一個變量可以引用的對象。
- 對象按其值,類型和標識(也稱為內存地址)分類。
- 不可變對象的值與其身份相關聯(lián)-如果值更改,則對象也會更改。
- 可變對象的值不依賴于其標識-標識在對對象所做的更改中保留。
- CPython實現(xiàn)預先分配了共享值,某些范圍的常用不可變類型。
- 當指示Python實例化一個新的不可變對象時,它首先檢查是否存在相同對象作為共享對象。
注意:本文中討論的行為特定于CPython 3.3及更高版本。您不能保證在不同的Python實現(xiàn)或版本上具有相同的行為。
正如我在上一篇文章中提到那樣,Python中的字符串對象實際上是unicode字符序列,我們將它們稱為專有的“文本”序列。這可以通過比較字符串中各個字符來證明這些特征,下圖通過變量a和b分別引用兩個不同的字符串。

不同的字符串位于不同的堆內存區(qū)塊。這個通過id函數(shù)非常輕易區(qū)分出示例中a和b引用的內存地址都是不一樣的。當我們再次調用is關鍵字比較a[3]和b[5]會返回True,因為a[3]、b[5]引用都是同一個內存位置的字符‘n’,我們說這樣的對象叫共享對象(Share Object)。
因為每次初始化Python解釋器時,CPython會將Latin-1范圍內的unicode編碼(0到255)作為共享庫加載到一個靜態(tài)的unicode_latin1數(shù)組,該數(shù)組的長度為256,每個ascii字符占用一個字節(jié),并且位于計算機的靜態(tài)內存區(qū)域。 后續(xù)對該范圍內的值的任何調用都將引用到那些預先存在unicode_latin1數(shù)組的對象。

unicode_latin1數(shù)組的源代碼定義在Objects/unicodeobject.c文件中有定義
#ifdef LATIN1_SINGLETONS
/* Single character Unicode strings in the Latin-1 range are being
shared as well. */
static PyObject *unicode_latin1[256] = {NULL};
#endif
上面的示例,我們用一個內存圖表示,我們知道變量s1、s2各自引用不同堆內存實體上的PyASCIIObject對象。

這個內存圖解除一部份人的疑惑,對于僅掌握Python語法,并沒有閱讀過CPython源代碼的新手來說,會錯誤地認為Python字符串就是一個類似數(shù)組的字符序列?,F(xiàn)在應該恍然大悟了吧!可以形象地認為Python字符串對象就是一個帶了“套”(就是頭部信息)的字符串序列(或unicode字節(jié)序列),為什么這么說呢?因為Python字符串對象按照內存組織來說,它是一個容器對象。
備注:字符串對象的內存分配由PyUnicode_New函數(shù)定義,前面3篇文章說得很清楚了,沒必要再解析。
在CPython中,Unicode字符存儲為PyUnicodeObject實例。 我們可以通過查看源代碼來查看PyUnicodeObject的格式:PyUnicodeObject根據(jù)三種不同編碼之一存儲字符。 這些編碼中的每一種占用不同的字節(jié)大小-Latin-1編碼為1字節(jié),UCS-2編碼為2字節(jié),UCS-4編碼為4字節(jié)。 此大小可在Python中訪問(需要減法,因為存儲字符串所需的實際字節(jié)數(shù)大于其字符的大小):
typedef struct {
PyCompactUnicodeObject _base;
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data; /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;
我們來一個簡單的示例,你們明白為什么gesizeof函數(shù)返回50個字節(jié)嗎?

我們來看看首先我們s1指向的是一個僅包含一個ASCII字符‘M’的字符串。由于是一個ASCII字符串,那么CPython會優(yōu)先以一個字節(jié)的位寬來實例化該對象,顯然該對象類型是PyASCIIObject,我們用如下圖來說明一切。
那么s1='M'表示:“變量標簽s引用C底層堆中一個PyASCIIObject內存實體”。該PyASCIIObject對象的頭部尺寸是48字節(jié),而有效負載的尺寸是2字節(jié)。

我們說‘!’這個字符實際上在堆中有一個對應的尺寸為50字節(jié)的PyASCIIObject內存實體,類似如上圖,我這里不再貼圖。只不過沒有一個變量去引用該字符串的內存實體,我們稱為這樣的字符串對象叫“匿名字符串”
問題1:s1+'!'表達式背后的內存含義是什么呢?
該表達式實際上執(zhí)行concat操作,以就是說該表達式會將s1引用的PyASCIIObject內存實體和‘!’字符對應的PyASCIIObject內存實體,它們各自的有效負載部分執(zhí)行合并操作,生成一個新的PyASCIIObject內存實體,如下圖所示。

也就是說現(xiàn)在堆內存中有3個不同的內存實體,一個是s1變量所指向的內存實體、一個是'!'對應的匿名字符串內存實體、一個是s1+'!'表達式對應的匿名字符串內存實體,有趣的是在Python語義中 id(s1+'!')同樣會獲取該字符串對象的內存地址。
問題2:sys.getsizeof(s1+'!')-sys.getsizeof(s1)這個表達式的含義是什么呢?
Ok,這個表達式就表示,讀取字符串內存實體的有效負載內的字節(jié)數(shù)據(jù),以1個字節(jié)位寬去解碼每個字符。
那么我們再來一個稍微復雜一點的例子,下圖的例子我想你應該心中有數(shù)了吧。

?和?這兩個字符在CPython內部是以1個字節(jié)的位寬來表示,他們的ASCII編碼分別是169和174,這些都是ASCII字符集范圍內的字符。而??這個屬于需要4個字節(jié)的位寬來表示,它的unicode編碼是128013,那么4字節(jié)位寬的二進制表示為"00000000 00000001 11110100 00001101"
>>> ord('?')
169
>>> ord('?')
174
>>> ord('??')
128013
>>> bin(ord('??'))
00000000 00000001 11110100 00001101
>>>
字符串駐留
什么是字符串駐留(String Interning)呢?其實這個跟C對待字符串在RAM中存儲方式是一樣的,就是一個"特定"的字符串在內存中只存在一份,其他Python變量都是其引用.
我們先來個自動駐留的示例,兩個變量引用一個字符串"Hello Lisa!?",我們同時對其字符串引用的變量,以及字符串本身傳入id函數(shù)。他們都指向“Hello Lisa!?”的真實的內存地址。

我們嘗試執(zhí)行后,在腳本的上下文中同樣的代碼測試得到期望的結果。

那么其內存圖如下,數(shù)據(jù)棧的變量標簽A和B都指向堆中"Hello Lisa!?"對應的PyASCIIObject實例的內存地址140619830398512。

那為什么會出現(xiàn)這種情況呢?Python解釋器在執(zhí)行第一條語句前,堆內存中還沒有該字符串,當執(zhí)行完第一條語句時,棧中的變量A立即被分配為引用到“Hello Lisa !?”。 執(zhí)行第一條語句之后,“ Hello Lisa !?” 將以駐留的方式一直活躍在堆內存中。這是通過調用以下任何一條CPython函數(shù)來實現(xiàn)的:
PyAPI_FUNC(void) PyUnicode_InternInPlace(PyObject **);
PyAPI_FUNC(void) PyUnicode_InternImmortal(PyObject **);
PyAPI_FUNC(PyObject *) PyUnicode_InternFromString(
const char *u /* UTF-8 encoded string */
);
在第二條語句"Hello Lisa!?"執(zhí)行時,CPython內部決定是否需要創(chuàng)建新的“Hello Lisa!?”實例之前,CPython首先檢查其內聯(lián)字符串的存儲,以確定是否已實例化相同的字符串。
/* Use only if you know it's a string */
#define PyUnicode_CHECK_INTERNED(op) \
(((PyASCIIObject *)(op))->state.interned)
顯然相同的字符串已經(jīng)駐留在堆中,那么變量B的“=”所謂賦值只是指向原來“ Hello Lisa !?” 實例的內存地址,并且“ Hello Lisa !?” 實例的引用計數(shù)會+1.
字符串駐留怎么跟共享對象那么相似的呢?當然! 字符串駐留背后的方法和思想都與CPython共享對象的實現(xiàn)并存。 實際上,一旦一個字符串駐留在內存后,它實質上就等同于一個共享庫-該字符串的實例對于給定Python會話中執(zhí)行的所有程序都是全局可用的。 就像共享對象一樣,通過內部字符串,Python在時間和內存上都可以更高效,但僅針對某些具體的應用場景。
字符串駐留的限定條件
對于Python來說,將每個被調用的字符串永久保存在內存中是沒有意義的,這最終會導致不必要的內存浪費。 取而代之的是,Python會盡最大努力專門駐留最可能被重用的字符串-標識符字符串。 標識符字符串包括以下內容:
- 函數(shù)和類名
- 變量名
- 參數(shù)名稱
- 字典鍵
- 屬性名稱
請注意,Python實際上并沒有檢測到以上內容-首先它甚至無法做到這一點。 而一個字符串對象在實例化駐留以否,分兩種運行環(huán)境進行討論.
在.py的腳本文件中的字符串初始化后,同時滿足以下三個條件才能達成字符串駐留。
-
條件1:該字符串必須是編譯時常量。除非在編譯時將其作為常量字符串加載,否則字符串不會被駐留在堆內存中。 這包括
- 定義為表達式的字符串-請記住,在實例化對象之前首先對表達式求值。
- 運行時構造的任何字符串(即通過方法,函數(shù)等生成的任何字符串)。

我們運行效果如下圖,由于A引用動態(tài)構造的字符串,在say_hello函數(shù)銷毀后,其內部緩存在堆的字符串對象也一同銷毀,接著B再次引用say_hello函數(shù)生成的相同文字的字符串對象,但是內存地址完全不一樣的全新對象。C和D引用的是一個編譯時的字符串常量。

- 條件2:字符串不得連續(xù)拼接,這個容易理解示例已經(jīng)解析的很清楚了。
- 條件3:字符串可以是任意編碼類型的字符串,ASCII字符、Unicode字符等,并且沒有長度限制,該條件其實是條件1的補充說明。
我們通過一個下面的簡單示例可以得到驗證。首先A和B引用的2字節(jié)位寬的字符串對象(由PyCompactUnicodeObject封裝),都是中文字 ;C和D引用的是4字節(jié)位寬的unicode字節(jié)序列(由PyUnicodeObject封裝)

運行測試一下,PyCompactUnicodeObject和PyUnicodeObject封裝的任意長度的字符串常量,在Python運行周期內都是允許駐留在堆內存中的。

接下來,我們分析一下另一種使用環(huán)境,在Python交互命令行的字符串駐留的3個條件都必須同時滿足,經(jīng)過前面的分析,我不想再過多廢話。請看下圖的在交互環(huán)境中的例子。

-
條件1:該字符串必須是編譯時常量。除非在編譯時將其作為常量字符串加載,否則字符串不會被駐留在堆內存中。 這包括
- 定義為表達式的字符串-請記住,在實例化對象之前首先對表達式求值。
- 運行時構造的任何字符串(即通過方法,函數(shù)等生成的任何字符串)。
- 條件2:字符串不得連續(xù)拼接,并且不得超過20個字符。
- 條件3:該字符串僅由ASCII字母,數(shù)字或下劃線組成。
我們分析字符串駐留的內存特性,可以滿足使得在某些應用場景令Python程序在字符串性能方面收益。例如使用PyQt或Tkinter寫的GUI程序,通常不同圖形的模塊會引用到相同的文字字面量(例如中文工具欄或者菜單,對話框的文字描述等),Python在初始化這些字符串常量初始化為堆中的字符串對象并駐留在堆內存中,后續(xù)調用這些字符串,例如加載某個菜單列表,加載速度都得到快速的提升。