《深度剖析CPython解釋器》7. 解密Python中字符串的底層實現,以及相關操作


楔子

這一次我們分析一下Python中的字符串,首先Python中的字符串是一個變長對象,因為不同長度的字符串所占的內存空間是不一樣的;但同時字符串又是一個不可變對象,因為一旦創建就不可以再修改了。

而Python中的字符串是通過unicode來表示的,因此在底層對應的結構體是PyUnicodeObject。但是為什么需要unicode呢?

首先計算機存儲的基本單位是字節,由8個比特位組成,由於英文字母算上大小寫只有52個,再加上若干字符,數量不會超過256個,因此一個字節完全可以表示,這些字符稱之為ASCII字符。但是隨着非英文字符的出現,導致一個字節已經無法表示了,只能曲線救國,對於一個字節無法表示的字符,使用多個字節表示。

但是這樣會出現兩個問題:

  • 不支持多國語言,例如中文的編碼不可以包含日文;
  • 沒有統一標准,例如中文有GB2312、GBK、GB18030等多個標准;

所以由於編碼不統一,開發人員經常在不同的編碼間來回轉換,會錯誤頻出。為了徹底解決這個問題,unicode標准誕生了。unicode對世界上的文字系統進行了系統的整理、編碼,讓計算機可以用統一的方式處理文本,而且目前已經支持超過13萬個字符,天然地支持多國語言。

但是問題來了,unicode能表示這么多的字符,那么占用的內存一定不低吧。是的,根據當時的編碼,一個unicode字符最高會占用到4字節。但是對於西方人來說,明明一個字符就夠用了,為啥需要那么多。於是又出現了utf-8,它是為unicode提供的新一個新的編碼規則,具有可變長的功能。對於1個ASCII字符那么會使用一個字節存儲,對於非ASCII字符會使用3個字節存儲。

但Python3中表示unicode字符串時,使用的卻不是utf-8,至於原因我們下面來分析一下。國外有一篇文章,題目翻譯過來說的是"Python在存儲字符串的時候如何節省內存",寫的非常好,我們來看看。

Python在存儲字符串的時候如何節省內存

從Python3開始,str類型使用的是Unicode。而根據編碼的不同,Unicode的每個字符最大可以占到4字節,從內存的角度來說, 這種編碼有時會比較昂貴。

為了減少內存消耗並且提高性能,python的內部使用了三種編碼方式表示Unicode。

  • Latin-1 編碼:每個字符一字節;
  • UCS2 編碼:每個字符兩字節;
  • UCS4 編碼:每個字符四字節;

在Python編程中,所有字符串的行為都是一致的,而且大多數時間我們都沒有注意到差異。然而在處理大文本的時候,這種差異就會變得異常顯著、甚至有些讓人出乎意料。

為了看到內部表示的差異,我們使用sys.getsizeof函數,返回一個對象所占的字節數。

import sys

print(sys.getsizeof("a"))  # 50
print(sys.getsizeof("憨"))  # 76
print(sys.getsizeof("💻"))  # 80

我們看到都是一個字符,但是它們占用的內存卻是不一樣的。

正如你所見,Python面對不同的字符會采用不同的編碼。需要注意的是,Python中的每一個字符串都需要額外占用49-80字節,因為要存儲一些額外信息,比如:哈希、長度、字節長度、編碼類型等等。

import sys

# 對於ASCII字符,顯然一個占1字節,顯然此時編碼是Latin-1編碼
print(sys.getsizeof("ab") - sys.getsizeof("a"))  # 1

# 對於漢字,日文等等,一個占用2字節,此時是UCS2編碼
print(sys.getsizeof("憨憨") - sys.getsizeof("憨"))  # 2
print(sys.getsizeof("です") - sys.getsizeof("で"))  # 2

# 像emoji,則是一個占4字節 ,此時是UCS4編碼
print(sys.getsizeof("💻💻") - sys.getsizeof("💻"))  # 4

而采用不同的編碼,那么底層結構體實例額外的部分也會占用不同大小的內存。如果編碼是Latin-1,那么這個結構體實例額外的部分會占49個字階;編碼是UCS2,占74個字節;編碼是UCS4,占76個字節。然后字符串所占的字節數就等於:額外的部分 + 字符個數 * 單個字符所占的字節

import sys

# 所以一個空字符串占用49個字節, 此時會采用占用內存最小的Latin-1編碼
print(sys.getsizeof(""))  # 49

# 此時使用UCS2
print(sys.getsizeof("憨") - 2)  # 74

# UCS4
print(sys.getsizeof("👴") - 4)  # 76

為什么python底層存儲字符串不使用utf-8編碼

我們先來拋出一個問題:首先我們知道Python支持通過索引查找一個字符串中指定位置的字符,而且Python中默認是以字符為單位的,不是字節(我們后面還會提),比如s[2]搜索的就是字符串s中的第3個字符。

s = "My姫様"
print(s[2])  # 姫

那么問題來了,我們知道Python中通過索引查找字符串的指定字符,時間復雜度為O(1),那么Python是怎么通過索引、比如這里的s[2],一下子就跳到第3個字符呢?顯然是通過指針的偏移,用索引乘上每個字符占的字節數,得到偏移量,然后從頭部向后偏移指定數量的字節即可,這樣就能在定位到指定字符的同時保證時間復雜度為O(1),但是這就需要一個前提:字符串中每個字符所占的大小必須是相同的,如果字符占的大小不同(比如有的占用1字節、有的占用3字節),顯然無法通過指針偏移的方式了,這個時候還想准確定位的話,只能按順序對所有字符都逐個掃描,但這樣的話時間復雜度肯定不是O(1),而是O(n)。

我們以golang為例,golang中的字符串默認就是使用的utf-8。

驚了,我們看到打印的並不是我們希望的結果。因為golang底層使用的是utf-8,不同的字符可能會占用不同的編碼。但是golang中通過索引定位的時候,時間復雜度是O(1),所以golang它就無法定位到准確的字符。

golang的字符串在通過索引定位的時候,比如這里的s[2],會跳轉兩個字節,因為不同字符占的字節可能是不同的,因此在計算偏移量的時候只能以占用最小的、ASCII字符所占的字節為單位,即1個字節,所以計算的。只不過前面兩個字符碰巧都是英文,每個占1字節,所以跳到了"姫"這個位置上。如果出現了非ASCII字符,那么是絕對跳不准的。而且在獲取的時候,也只能獲取1個字節。但是使用utf-8的話,非ASCII字符是占3個字節,顯然一個字節是無法表示的。

所以Python會使用3個編碼,對應編碼的字符分別是1、2、4字節。因此Python在創建字符串的時候,會先掃描。或者嘗試使用占字節數最少的Latin1編碼存儲,但是范圍肯定有限。如果發現了存儲不下的字符,只能改變編碼,使用UCS2,繼續掃描。但是又發現了新的字符,這個字符UCS2也無法存儲,因為兩個字節最多存儲65535個不同的字符,所以會再次改變編碼,使用ucs4存儲。ucs4占四個字節,肯定能存下了。

一旦改變編碼,字符串中的所有字符都會使用同樣的編碼,因為它們不具備可變長功能。比如這個字符串:"hello古明地覺",肯定都會使用UCS2,不存在說"hello"使用Latin1,"古明地覺"使用UCS2,因為一個字符串只能有一個編碼。當通過索引獲取的時候,會將索引乘上每個字符占的字節數,這樣就能跳到准確位置上,因為字符串里面的所有字符占用的字節都是一樣的,然后獲取也會獲取指定的字節數。比如:使用UCS2編碼,那么定位到某個字符的時候,會取兩個字節,這樣才能表示一個完整的字符。

import sys 

# 此時全部是ascii字符,那么Latin1編碼可以存儲
# 所以結構體實例額外的部分占49個字節
s1 = "hello"
# 有5個字符,一個字符一個字節,所以加一起是54個字節
print(sys.getsizeof(s1))  # 54


# 出現了漢字,那么Latin肯定存不下,於是使用UCS2
# 所以此時結構體實例額外的部分占74個字節
# 但是別忘了此時的英文字符也是ucs2,所以也是一個字符兩字節
s2 = "hello憨"
# 6個字符,74 + 6 * 2 = 86
print(sys.getsizeof(s2))  # 86


# 這個牛逼了,ucs2也存不下,只能ucs4存儲了
# 所以結構體實例額外的部分占76個字節
s3 = "hello憨💻"
# 此時所有字符一個占4字節,7個字符
# 76 + 7 * 4 = 104
print(sys.getsizeof(s3))  # 104

除此之外,我們再舉一個例子更形象地證明這個現象。

import sys

s1 = "a" * 1000
s2 = "a" * 1000 + "💻"

# 我們看到s2只比s1多了一個字符
# 但是兩者占的內存,s2卻將近是s1的四倍。
print(sys.getsizeof(s1), sys.getsizeof(s2))  # 1049 4080

# 我們知道s2和s1的差別只是s2比s1多了一個字符,但就是這么一個字符導致s2比s1多占了3031個字節
# 顯然這多出來的3031個字節不可能是多出來的字符所占的大小,什么字符一個會占到三千多個字節
# 盡管如此,但它也是罪魁禍首,不過前面的1000個字符也是共犯
# 我們說Python會根據字符串選擇不同的編碼,s1全部是ascii字符,所以Latin1能存下,因此一個字符只占一個字節
# 所以大小就是49 + 1000 = 1049 
# 但是對於s2,python發現前1000個字符Latin1能存下,但是不幸的是,最后一個字符發現存不下了,只能使用UCS4
# 而字符串的所有字符只能有一個編碼,為了保證索引查找的時候,時間復雜度為O(1),這是Python的設計策略
# 因此導致前面一個字節就能存下的字符,每一個也變成了4個字節。
# 而我們說使用UCS4,結構體額外的內存會占76個字節
# 因此s2的大小就是:76 + 1001 * 4 = 76 + 4004 = 4080



# 相信下面你肯定能分析出來
print(sys.getsizeof("爺的青春回來了"))  # 88
print(sys.getsizeof("👴的青春回來了"))  # 104

所以如果字符串中的所有字符對應的ASCII碼都在0~255范圍內,則使用1字節Latin1對其進行編碼。基本上,Latin1能表示前256個Unicode字符。它支持多種拉丁語,如英語、瑞典語、意大利語、挪威語。但是它們不能存儲非拉丁語言,比如漢語、日語、希伯來語、西里爾語。這是因為它們的代碼點(數字索引)定義在1字節(0-255)范圍之外。

大多數流行的自然語言都可以采用2字節(UCS2)編碼。當字符串包含特殊符號、emoji或稀有語言時,使用4字節(UCS4)編碼。Unicode標准有將近300個塊(范圍)。你可以在0XFFFF塊之后找到4字節塊。假設我們有一個10G的ASCII文本,我們想把它加載到內存中,但如果我們在文本中插入一個表情符號,那么字符串的大小將增加4倍。這是一個巨大的差異,你可能會在實踐當中遇到,比如處理NLP問題。

print(ord("a"))  # 97
print(ord("憨"))  # 25000
print(ord("💻"))  # 128187

所以最著名和最流行的Unicode編碼都是utf-8,但是python不在內部使用它,而是使用Latin1、UCS2、UCS4。至於原因我們上面已經解釋的很清楚了,主要是Python的索引是基於字符:

當一個字符串使用utf-8編碼存儲時,根據它所表示的字符,每個字符會根據自身選擇一個合適的編碼。這是一種存儲效率很高的編碼,但是它有一個明顯的缺點。由於每個字符的字節長度可能不同,因此就導致無法按照索引瞬間定位到單個字符,即便能定位,也無法定位准確。如果想准,那么只能逐個掃描所有字符。

因此要對使用utf-8編碼的字符串執行一個簡單的操作,比如s[5],就意味着Python需要掃描每一個字符,直到找到需要的字符,這樣效率是很低的。但如果是固定長度的編碼就沒有這樣的問題,所以當Latin 1存儲的"hello",在和"憨色兒"組合之后,整體每一個字符都會向大的方向擴展、變成了2字節。這樣定位字符的時候,只需要將"索引 * 2"計算出偏移的字節數、然后跳轉該字節數即可。但如果原來的"hello"還是一個字節、而漢字是2字節,那么只通過索引是不可能定位到准確字符的,因為不同類型字符的編碼不同,必須要掃描整個字符串才可以。但是掃描字符串,效率又比較低。所以python內部才會使用這個方法,而不是使用utf-8。

所以對於golang來講,如果想像Python一樣,那么需要這么做:

package main

import (
	"fmt"
)


func main() {
	s := "My姫様"
	//我們看到長度為8, 因為它使用utf-8編碼
	//底層一個非ascii字符占3字節, 所以總共8字節
	fmt.Println(s, len(s))  // My姫様 8

	//如果想像Python一樣,那么golang中提供了一個rune, 相當於int32, 此時直接使用4個字節
	r := []rune(s)
	fmt.Println(string(r), len(r))  // My姫様 4
	//雖然打印的內容是一樣的,但是此時每個字符都使用4字節存儲

	//此時跳轉會和Python一樣偏移 2 * 4 個字節, 然后獲取也會獲取4個字節, 因為一個字符占4個字節
	//所以不光索引跳轉會將索引乘上4, 在獲取的時候也會一次獲取4個字節
	//因為都知道一個字符占4字節了,所以肯定獲取指定數量的字節,這樣才能表示完整字符
	fmt.Println(string(r[2]))  //姫

}

其實可以想一下C中的數組,比如int類型的數組,那么數組指針在往后偏移一個單位的時候,偏移的也是1個int(4字節),而不是1個字節,這是顯然的;然后獲取的時候也會一次獲取4個字節,因為這樣才能表示一個int。但是utf-8表示的unicode字符串里面的字符可能占用不同的字節,那么顯然沒辦法實現Python中字符串的索引查找效果,所以Python內部的字符串沒有使用utf-8。

因此Python才會提供了三種編碼,先使用占用最小的Latin1,不行的話再使用UCS2、UCS4,總之會確保每個字符占用的字節是一樣的,原因的話我們上面分析的很透徹了。並且無論是索引還是切片、還是計算長度等等,都是基於字符的,顯然這也符合人類的思維習慣。

然后字符串還有intern機制,原文中也提到了,但是我們會在本文的后面介紹。下面先看字符串底層的結構,以及支持的相關操作是如何實現的。

字符串的底層實現

我們之前提到了,字符串采用不同的編碼,底層的結構體實例所占用的額外內存是不一樣的。其實本質上是,字符串會根據內容的不同,而選擇不同的存儲單元。

至於到底是怎么做到的,我們只能去源碼中尋找答案了,與str相關的源碼:Include/unicodeobject.hObjects/unicodeobject.c

enum PyUnicode_Kind {
/* String contains only wstr byte characters.  This is only possible
   when the string was created with a legacy API and _PyUnicode_Ready()
   has not been called yet.  */
    PyUnicode_WCHAR_KIND = 0,
/* Return values of the PyUnicode_KIND() macro: */
    PyUnicode_1BYTE_KIND = 1,
    PyUnicode_2BYTE_KIND = 2,
    PyUnicode_4BYTE_KIND = 4
};

我們在unicodeobject.h中看到,str對象根據底層存儲會根據unicode的不同而分為以下幾類:

  • PyUnicode_1BYTE_KIND:所有字符碼位均在 U+0000 到 U+00FF 之間
  • PyUnicode_2BYTE_KIND:所有字符碼位均在 U+0000 到 U+FFFF 之間,且至少一個大於 U+00FF(否則每個字符就用1字節了)
  • PyUnicode_4BYTE_KIND:所有字符碼位均在 U+0000 到 U+10FFFF 之間,且至少一個大於 U+FFFF

如果文本字符碼位均在 U+0000U+00FF 之間,單個字符只需 1 字節來表示;而碼位在 U+0000U+FFFF 之間的文本,單個字符則需要 2 字節才能表示;以此類推。這樣一來,根據文本碼位范圍,便可為字符選用盡量小的存儲單元,以最大限度節約內存。

typedef uint32_t Py_UCS4; //我們看到4字節使用的是無符號32位整型
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;  //Latin-1

既然unicode內部的存儲結構會因字符而異,那么unicode底層就必須有成員來維護相應的信息,所以Python內部定義了若干標志位:

  • interned:是否被intern機制維護,這個機制我們會在本文后面介紹
  • kind:類型,用於區分字符底層存儲單元的大小。如果是Latin1編碼,那么就是1;UCS2編碼則是2;UCS4編碼則是4
  • compact:內存分配方式,對象與文本緩沖區是否分離
  • ascii:字符串是否是純ASCII字符串, 如果是1就是1, 否則就是0。注意: 雖然每個字符都會對應ASCII碼,但是只有對應的ASCII碼為0~127之間的才是ASCII字符。所以雖然一個字節可表示的范圍是0~255,但是128~255之間的並不是ASCII字符。

而為unicode字符串申請空間,底層可以調用一個叫PyUnicode_New的函數,這也是一個特型API。比如:元組申請空間可以使用PyTuple_New,列表申請空間可以使用PyList_New等等,會傳入一個整型,創建一個能夠容納指定數量元素的結構體實例。而PyUnicode_New則接受一個字符個數以及最大字符maxchar初始化unicode字符串對象,之所以會多出一個maxchar,是因為要根據它來為unicode字符串對象選擇最緊湊的字符存儲單元,以及結構體。

下面我們就來分析字符串底層對應的結構體。

PyASCIIObject

如果 str 對象保存的文本均為 ASCII ,即 maxchar<128,則底層由 PyASCIIObject 結構進行存儲:

/* ASCII-only strings created through PyUnicode_New use the PyASCIIObject
   structure. state.ascii and state.compact are set, and the data
   immediately follow the structure. utf8_length and wstr_length can be found
   in the length field; the utf8 pointer is equal to the data pointer. */
typedef struct {
    PyObject_HEAD
    Py_ssize_t length;          /* Number of code points in the string */
    Py_hash_t hash;             /* Hash value; -1 if not set */
    struct {
        unsigned int interned:2;
        unsigned int kind:3;
        unsigned int compact:1;
        unsigned int ascii:1;
        unsigned int ready:1;
        unsigned int :24;
    } state;
    wchar_t *wstr;              /* wchar_t representation (null-terminated) */
} PyASCIIObject;

PyASCIIObject結構體也是其他 Unicode 底層結構體的基礎,所有字段均為 Unicode 公共字段:

  • ob_refcnt:引用計數
  • ob_type:類型指針
  • length:字符串長度
  • hash:字符串的哈希值
  • state:unicode對象標志位,包括intern、kind、ascii、compact等
  • wstr:一個指針,指向由寬字符組成的字符數組。字符串和字節序列一樣,底層都是通過字符數組來維護具體的值。

以字符串"abc"為例,看看它在底層的存儲結構:

注意:state 成員后面有一個 4 字節的空洞,這是結構體字段內存對齊造成的現象。在 64 位機器上,指針大小為 8 字節,為優化內存訪問效率,必須以 8 字節對齊。現在我們知道一個空字符串為什么占據49個字節了,因為ob_refcnt、ob_type、length、hash、wstr 都是 8 字節,所以總共 40 字節;而 state 是 4 字節,但是留下了 4 字節的空洞,加起來也是 8 字節,所以總共占 40 + 8 = 48 個字節,但是 Python 的 unicode 字符串在 C 中也是使用字符數組來存儲的,只不過此時的字符不再是 char 類型,而是 wchar_t。但是它的內部依舊有一個 '\0',所以還要加上一個 1,總共 49 字節。

對於 "abc" 這個 unicode 字符串來說,占的總字節數就是 49 + 3 = 52。

import sys
print(sys.getsizeof("abc"))  # 52

# 長度為n的ASCII字符串, 大小就是49 + n
print(sys.getsizeof("a" * 1000))  # 1049

PyCompactUnicodeObject

如果文本不全是 ASCII ,Unicode 對象底層便由 PyCompactUnicodeObject 結構體保存:

/* Non-ASCII strings allocated through PyUnicode_New use the
  PyCompactUnicodeObject structure. state.compact is set, and the data
  immediately follow the structure. */
typedef struct {
   PyASCIIObject _base;
   Py_ssize_t utf8_length;     /* Number of bytes in utf8, excluding the
                                * terminating \0. */
   char *utf8;                 /* UTF-8 representation (null-terminated) */
   Py_ssize_t wstr_length;     /* Number of code points in wstr, possible
                                * surrogates count as two code points. */
} PyCompactUnicodeObject;

我們看到PyCompactUnicodeObject是在PyASCIIObject的基礎上增加了3個字段。

  • utf8_length:字符串的utf-8編碼長度
  • utf8:字符串使用utf-8編碼的結果,這里是緩存起來從而避免重復的編碼運算
  • wstr_length:寬字符的數量

我們說 PyCompactUnicodeObject 只是多了3個字段,顯然多出了 24 字節。那么之前的 49+24 等於 73,咦不對啊,我們不是說一個是 74 一個 76 嗎?你忘記了 '\0',如果使用 UCS2,那么 '\0' 也占兩個字節,所以應該是 73 -1 + 2 = 74;同理 UCS4 是 73 - 1 + 4 = 76,所以此時 unicode 字符串所占內存我們算是分析完了。然后我們再來看看這幾種不同編碼下對應的字符串結構吧。

PyUnicode_1BYTE_KIND

如果 128 <= maxchar < 256,雖然一個字節可以存儲的下,但Unicode 對象底層也會由 PyCompactUnicodeObject 結構體保存,字符存儲單元為 Py_UCS1(Latin-1) ,大小為 1 字節。以字符串 "sator¡" 為例:

import sys

# 雖然此時所有的字符都占一個 1 字節,但是只有當 maxchar < 128 的時候,才會使用 PyASCIIObject
# 如果大於等於 128, 那么會使用 PyCompactUnicodeObject 存儲, 只不過內部字符依舊每個占一字節
print(sys.getsizeof("sator¡"))  # 79

# 我們知道對於使用 UCS2 的 PyCompactUnicodeObject 來說, 空字符串會占 74 字節
# 而 \0 占了兩個字節,所以除去 \0,額外部分是 72 字節
# 而這里是 Latin-1,\0 是一個字節,所以一個空字符串應該占 73 字節,加上這里的 6 個字符,總共是 79 字節。


# 因此當使用 Latin1 編碼的時候,不一定就是 PyASCIIObject, 只有當 0 < maxchar < 128 的時候才會使用 PyASCIIObject
# 所以如果將上面的 "sator¡" 改成 "satori",那么就會使用 PyASCIIObject 存儲了。
# 此外還要注意所占的內存, 因為 Latin1 和 UCS2、UCS4 三個編碼都可以對應 PyCompactUnicodeObject
# 而不包括 \0 的話,那么一個 PyCompactUnicodeObject 是占據72字節的,如果算上 \0
# 那么使用 Latin1 編碼的空字符串就是 73 字節,使用 UCS2 編碼的空字符串就是 74 字節,使用 UCS4 編碼的空字符串就是 76 字節,因為 \0 分別占 1、2、4 字節

PyUnicode_2BYTE_KIND

如果 256 <= maxchar < 65536Unicode 對象底層同樣由 PyCompactUnicodeObject 結構體保存,但字符存儲單元為 UCS2 ,大小為 2 字節。以字符串 "My姫様" 為例:

import sys

# 74 + 4 * 2, 或者72 + 5 * 2
print(sys.getsizeof("My姫様"))  # 82

當文本中包含了 Latin1 無法存儲的字符時,會使用兩字節的 UCS 保存,但是連前面的英文字符也變成兩字節了。至於原因我們上面已經分析的很透徹了,因為定位的時候是獲取的字符,但如果采用變長的 utf-8 方式存儲導致不同字符占的內存大小不一,那么就無法在 O(1) 的時間內取出准確的字符了,只能從頭到尾依次遍歷。而 Go 基於 utf-8,因此它無法獲取准確的字符,只能轉成 rune,此時內部一個字符直接占4字節。

PyUnicode_4BYTE_KIND

如果 65536 <= maxchar < 429496296,便只能使用4字節存儲單元的UCS4了,以字符串"👴青回"為例:

import sys

# 76 + 3 * 4, 或者72 + 4 * 4
print(sys.getsizeof("👴青回"))  # 88

因此此時每個字符都采用UCS4編碼,因此每個字符占四個字節,這是Python內部采取的策略。

我們后面通過分析字符串的一些操作的時候,會更加深刻的體會到。

PyUnicodeObject

不是說Python中字符串底層對應PyUnicodeObject嗎?目前出現了PyASCIIObject和PyCompactUnicodeObject,那么PyUnicodeObject呢?

typedef struct {
    PyCompactUnicodeObject _base;
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

這便是 PyUnicodeObject 的定義了,里面 data 是一個共同體,這里我們沒有必要關注,我們直接把它當成 PyCompactUnicodeObject 來用即可。

字符串的操作

先來看看str類型對象在底層的定義吧。

PyTypeObject PyUnicode_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "str",              	    /* tp_name */
    sizeof(PyUnicodeObject),    /* tp_size */
    //...
    unicode_repr,           	/* tp_repr */
    &unicode_as_number,         /* tp_as_number */
    &unicode_as_sequence,       /* tp_as_sequence */
    &unicode_as_mapping,        /* tp_as_mapping */
    //...
};

首先哈希操作(unicode_hash)之類的肯定是支持的,然后我們關注一下tp_as_number、tp_as_sequence、tp_as_mapping,我們看到三個操作簇居然都滿足。不過有了bytes的經驗,我們知道tp_as_number里面的實際上只有取模,也就是格式化(bytes和str在很多行為上都是相似的,但是這兩者的區別我們后面會說,目前認為str對象可以編碼成bytes對象,bytes對象可以解碼成str對象即可)

我們來看一下這幾個操作簇吧。

//不出我們所料, 只有一個取模
static PyNumberMethods unicode_as_number = {
    0,              /*nb_add*/
    0,              /*nb_subtract*/
    0,              /*nb_multiply*/
    unicode_mod,    /*nb_remainder*/
};


//所以我們看到這個和bytes對象是幾乎一樣的,因為我們說了str對象和bytes都是不可變的變長對象,並且可以相互轉化
//它們的行為時高度相似的
static PySequenceMethods unicode_as_sequence = {
    (lenfunc) unicode_length,       	 /* sq_length */
    PyUnicode_Concat,           		 /* sq_concat */
    (ssizeargfunc) unicode_repeat,  	 /* sq_repeat */
    (ssizeargfunc) unicode_getitem,      /* sq_item */
    0,                  				/* sq_slice */
    0,                  				/* sq_ass_item */
    0,                  				/* sq_ass_slice */
    PyUnicode_Contains,        			 /* sq_contains */
};


//也和bytes對象一樣
static PyMappingMethods unicode_as_mapping = {
    (lenfunc)unicode_length,        /* mp_length */
    (binaryfunc)unicode_subscript,  /* mp_subscript */
    (objobjargproc)0,           /* mp_ass_subscript */
};

下面我們先來重點看一下PyUnicode_Concat這個操作,它是用來將兩個字符串相加、組合成一個新的字符串。

PyObject *
PyUnicode_Concat(PyObject *left, PyObject *right)
{	
    //參數left和right顯然指向兩個unicode字符串
    //result則是指向相加之后的字符串
    PyObject *result;
    
    //還記得這個Py_UCS4嗎, 它是相當於一個無符號32位整型
    Py_UCS4 maxchar, maxchar2;
    //顯然是left的長度、right的長度、相加之后的長度
    Py_ssize_t left_len, right_len, new_len;
	
    //檢測是否是PyUnicodeObject
    if (ensure_unicode(left) < 0)
        return NULL;

    if (!PyUnicode_Check(right)) {
        //如果右邊不是str對象的話,報錯
        PyErr_Format(PyExc_TypeError,
                     "can only concatenate str (not \"%.200s\") to str",
                     right->ob_type->tp_name);
        return NULL;
    }
    //屬性的初始化, ensure_unicode實際上是調用了PyUnicode_Check和PyUnicode_READY這兩部
    //當然這些都是Python內部做的檢測,我們不用太關心
    if (PyUnicode_READY(right) < 0)
        return NULL;

    //這里快分支
    //如果其中一方為空的話,那么直接返回另一方即可,顯然這里的快分支命中率就沒那么高了,但還是容易命中的
    if (left == unicode_empty)
        return PyUnicode_FromObject(right);
    if (right == unicode_empty)
        return PyUnicode_FromObject(left);
	
    //計算left的長度和right的長度
    left_len = PyUnicode_GET_LENGTH(left);
    right_len = PyUnicode_GET_LENGTH(right);
    //如果相加超過PY_SSIZE_T_MAX,那么會報錯, 因為要維護字符串的長度,顯然長度是有范圍的
    //但是幾乎不存在字符串的長度會超過PY_SSIZE_T_MAX的
    if (left_len > PY_SSIZE_T_MAX - right_len) {
        PyErr_SetString(PyExc_OverflowError,
                        "strings are too large to concat");
        return NULL;
    }
    //計算新的長度
    new_len = left_len + right_len;
	
    //計算存儲單元占用的字節數
    maxchar = PyUnicode_MAX_CHAR_VALUE(left);
    maxchar2 = PyUnicode_MAX_CHAR_VALUE(right);
    //取大的那一方,因為一個是UCS2一個是UCS4,那么相加之后肯定會選擇UCS4
    maxchar = Py_MAX(maxchar, maxchar2);

    //通過PyUnicode_New申請能夠容納new_len寬字符的PyUnicodeObject, 並且字符的存儲單元是大的那一方
    result = PyUnicode_New(new_len, maxchar);
    if (result == NULL)
        return NULL;
    //將left拷進去
    _PyUnicode_FastCopyCharacters(result, 0, left, 0, left_len);
    //將right拷進去
    _PyUnicode_FastCopyCharacters(result, left_len, right, 0, right_len);
    assert(_PyUnicode_CheckConsistency(result, 1));
    return result;
}

和bytes對象一樣,+的效率非常低下,所以官方建議通過join的方式。

PyObject *
PyUnicode_Join(PyObject *separator, PyObject *seq)
{
    PyObject *res;
    PyObject *fseq;
    Py_ssize_t seqlen;
    PyObject **items;

    fseq = PySequence_Fast(seq, "can only join an iterable");
    if (fseq == NULL) {
        return NULL;
    }

    items = PySequence_Fast_ITEMS(fseq);
    seqlen = PySequence_Fast_GET_SIZE(fseq);
    res = _PyUnicode_JoinArray(separator, items, seqlen);
    Py_DECREF(fseq);
    return res;
}


PyObject *
_PyUnicode_JoinArray(PyObject *separator, PyObject *const *items, Py_ssize_t seqlen)
{
    //...
}

代碼比較長,但是邏輯不難理解,這里就不貼了。就是獲取列表或者元組里面的每一個unicode字符串對象的長度,然后加在一起,並取最大的存儲單元,然后一次性申請對應的空間,再逐一進行拷貝。所以拷貝是避免不了的,+這種方式導致低效率的主要原因就在於大量PyUnicodeObject的創建和銷毀。

因此如果我們要拼接大量的PyUnicodeObject,那么使用join列表或者元組的方式;如果數量不多,還是可以使用+的,畢竟維護一個列表也是需要資源的。使用join的方式,只有在PyUnicodeObject的數量非常多的時候,優勢才會凸顯出來。

初始化

然后我們在看看PyUnicodeObject的初始化,Python很多方式,從C中原生的字符串創建PyUnicodeObject對象。比如:PyUnicode_FromString、PyUnicode_FromStringAndSize、PyUnicode_FromUnicodeAndSize、PyUnicode_FromUnicode、PyUnicode_FromWideChar等等

PyUnicode_FromString(const char *u)
{
    size_t size = strlen(u);
    // PY_SSIZE_T_MAX是一個與平台相關的數值,在64位系統下是4GB
    //如果創建的字符串的長度超過了這個值,那么會報錯
    //個人覺得這種情況應該不會發生,就跟變量的引用計數一樣
    //只要不是吃飽了撐的,寫惡意代碼,基本不會超過這個閾值
    if (size > PY_SSIZE_T_MAX) {
        PyErr_SetString(PyExc_OverflowError, "input too long");
        return NULL;
    }
    //會進行檢測字符串是哪種編碼格式,從而決定分配幾個字節
    return PyUnicode_DecodeUTF8Stateful(u, (Py_ssize_t)size, NULL, NULL);
}

字符串對象的intern機制

如果字符串的interned標識位為1,那么Python虛擬機將為其開啟interned機制。那么,什么是interned機制呢?

在Python中,某些字符串也可以像小整數對象池中的整數一樣,共享給所有變量使用,從而通過避免重復創建來降低內存使用、減少性能開銷,這便是intern機制。

Python的做法是在內部維護一個全局字典,所有開啟intern機制的字符串均會保存在這里,后續如果需要使用的話,會先嘗試在全局字典中獲取,從而實現避免重復創建的功能。

 
void
PyUnicode_InternInPlace(PyObject **p)
{
    PyObject *s = *p;
    PyObject *t;
    //對PyUnicodeObjec進行類型和狀態檢查
    if (!PyUnicode_CheckExact(s))
        return;
    //檢測interned標識位, 判斷是否開啟intern機制
    if (PyUnicode_CHECK_INTERNED(s))
        return;
    //創建intern機制的dict
    if (interned == NULL) {
        interned = PyDict_New();
        if (interned == NULL) {
            PyErr_Clear(); /* Don't leave an exception */
            return;
        }
    }
    Py_ALLOW_RECURSION
	
	//下面的內容單獨分析
    t = PyDict_SetDefault(interned, s, s);
    Py_END_ALLOW_RECURSION
    if (t == NULL) {
        PyErr_Clear();
        return;
    }
    if (t != s) {
        Py_INCREF(t);
        Py_SETREF(*p, t);
        return;
    }
    Py_REFCNT(s) -= 2;
    _PyUnicode_STATE(s).interned = SSTATE_INTERNED_MORTAL;
}

PyDict_SetDefault函數中首先會進行一系列的檢查,包括類型檢查、因為intern共享機制只能用在字符串對象上,所以檢查傳入的對象是否已經被intern機制處理過了。

我們在代碼中看到了interned = PyDict_New(),這個PyDict_New()是python中的dict對象,因此可以發現在程序中有一個key、value映射關系的集合。

intern機制中的PyUnicodObject采用了特殊的引用計數機制,將一個PyUnicodeObject對象a的PyObject指針作為key和valu添加到intered中時,PyDictObjec對象會通過這兩個指針對a的引用計數進行兩次+1操作。這會造成a的引用計數在python程序結束前永遠不會為0,這也是最后面Py_REFCNT(s) -= 2; 要將計數減2的原因。

Python在創建一個字符串時,會首先檢測是否已經有該字符串對應的PyUnicodeObject對象了,如果有,就不用創建新的,這樣可以節省空間。但其實不是這樣的,事實上,節省內存空間是沒錯的,可Python並不是在創建PyUnicodeObject的時候就通過intern機制實現了節省空間的目的。從PyUnicode_FromString中我們可以看到,無論如何一個合法的PyUnicodeObject總是會被創建的,而intern機制也只對PyUnicodeObject起作用。

對於任何一個字符串,Python總是會為它創建對應的PyUnicodeObject,盡管創建出來的對象所維護的字符數組,在intern機制中已經存在了(有另外的PyUnicodeObject也維護了相同的字符數組)。而這正是關鍵所在,通常Python在運行時創建了一個PyUnicodeObject對象temp之后,基本上都會調用PyUnicode_InternInPlace對temp進行處理,如果維護的字符數組有其他的PyUnicodeObject維護了,或者說其他的PyUnicodeObject對象維護了一個與之一模一樣的字符數組,那么temp的引用計數就會減去1。temp由於引用計數為0而被銷毀,只是曇花一現,然后歸於湮滅。

所以現在我們就明白了intern機制,並不是說先判斷是否存在,如果存在,就不創建。而是先創建,然后發現已經有其他的PyUnicodeObject維護了一個與之相同的字符數組,於是intern機制將引用計數減一,導致引用計數為0,最終被回收。

但是這么做的原因是什么呢?為什么非要創建一個PyUnicodeObject來完成intern操作呢?這是因為PyDictObject必須要求必須以PyObject *作為key。

關於PyUnicodeObject對象的intern機制,還有一點需要注意。實際上,被intern機制處理過后的字符串分為兩類,一類處於SSTATE_INTERNED_IMMORTAL,另一類處於SSTATE_INTERNED_MORTAL狀態,這兩種狀態的區別在unicode_dealloc中可以清晰的看到,SSTATE_INTERNED_IMMORTAL狀態的PyUnicodeObject是永遠不會被銷毀的,它與python解釋器共存亡。

PyUnicode_InternInPlace只能創建SSTATE_INTERNED_MORTAL的PyUnicodeObject對象,如果想創建SSTATE_INTERNED_IMMORTAL對象,必須通過另外的接口來強制改變PyUnicodeObject的intern狀態。

void
PyUnicode_InternImmortal(PyObject **p)
{
    PyUnicode_InternInPlace(p);
    if (PyUnicode_CHECK_INTERNED(*p) != SSTATE_INTERNED_IMMORTAL) {
        _PyUnicode_STATE(*p).interned = SSTATE_INTERNED_IMMORTAL;
        Py_INCREF(*p);
    }
}

但是問題來了,什么樣的字符才會開啟intern機制呢?

在Python3.8中,如果一個字符串的所有字符都位於0 ~ 127之間,那么會開啟intern機制。

>>> a = "abc" * 1000
>>> b = "abc" * 1000
>>> a is b  # 之前的話是不超過20個字符,但是在Python3.8中這個限制被擴大了很多
True
>>>
>>> a = "abc" * 2000
>>> b = "abc" * 2000
>>> a is b
False  # 顯然3 * 2000,6000個字符是不會開啟intern機制的,所以長度限制是多少,有興趣可以自己試一下
>>>

在Python3.8中,如果一個字符串只有一個字符,並且位於0~255之間,那么會開啟intern機制。

>>> a = chr(255) * 2
>>> b = chr(255) * 2
>>> a is b  # 不位於0~127之間,所以不是ASCII字符,因此沒有開啟intern機制
False
>>>
>>> a = chr(255)
>>> b = chr(255)
>>> a is b
True  # 但如果只有一個字符的話,則會開啟
>>> # 另外,空字符串也會開啟
>>> a = ""
>>> b = ""
>>> a is b
True

實際上,存儲單個字符這種方式有點類似於bytes對象中的緩存池。是的,正如整數有小整數對象池、bytes對象有字符緩存池一樣,字符串也有其對應的PyUnicodeObject緩存池。

在Python中的整數對象中,小整數的對象池是在Python初始化的時候被創建的,而字符串對象體系中的緩存池則是以靜態變量的形式存在的。在Python初始化完成之后,緩沖池的所有PyUnicodeObject指針都為空。

當創建一個PyUnicodeObject對象時,如果字符串只有一個字符,且位於0~255。那么會先對該字符串進行intern操作,再將intern的結果緩存到池子當中。同樣當再次創建PyUnicodeObject對象時,檢測維護的是不是只有一個字符,然后檢查字符是不是存在於緩存池中,如果存在,直接返回。

str對象和bytes對象之間的關系

首先str對象我們稱之為字符串,bytes對象我們稱之為字節序列,把字符串中的每一個字符都轉成對應的編碼,那么得到就是字節序列了。因為計算機存儲和網絡通訊的基本單位都是字節,所以字符串必須以字節序列的形式進行存儲或傳輸。

那么如何轉化呢?首先我們需要清楚兩個概念:字符集和編碼。

字符集顧名思義就是由字符組成的集合,每個字符在集合中都有唯一編號,像ASCII、unicode都是字符集。只不過 ASCII 能夠容納的字符是有限的,而 unicode 可以容納世界上所有的字符。

而編碼則負責告訴你字符在字符集中對應的編號,編碼有:gbk、utf-8等等

s = "姫様"
# 采用utf-8編碼, encode成bytes對象
print(s.encode("utf-8"))  # b'\xe5\xa7\xab\xe6\xa7\x98'
for _ in s.encode("utf-8"):
    print(_)
"""
229
167
171
230
167
152
"""
# 說明"姫"對應的ASCII碼是: 229 167 171, 因為utf-8編碼的話, 一個漢字占3個字節
# 我們使用utf-8解碼, 也能得到對應的字符串
print(s.encode("utf-8").decode("utf-8"))  # 姫様
print(bytearray([229, 167, 171, 230, 167, 152]).decode("utf-8"))  # 姫様

因此字符串和字節序列在某種程度上是很相似的,字符串按照指定的編碼進行encode即可得到字節序列(將字符轉成ASCII碼),字節序列按照相同的編碼decode即可得到字符串(將ASCII碼轉成字符)

# 比如我有一個gbk編碼的字節序列,但是在傳輸的時候需要utf-8編碼的字節序列
b = b'\xc4\xe3\xba\xc3'

# 所以我們就按照gbk解碼成字符串,因為不同的編碼會得到不同的ASCII碼
# 因此encode和decode都要使用同一種編碼, 如果前后使用了不同的編碼,那么在decode的時候會因為無法正確解析而報錯
s = b.decode("gbk")
print(s)  # 你好

# 然后我們使用utf-8進行encode
b = s.encode("utf-8") + "我很可愛".encode("utf-8")

# 再使用utf-8進行decode
print(b.decode("utf-8"))  # 你好我很可愛

但是對於ASCII字符來說,由於不管采用哪一種編碼,它們得到的ASCII碼都是固定的,所以在顯示的時候直接以字符本身顯示了。

s = "abc"
# a對應的ASCII碼是97, 所以你在C中寫char c = 'a'和char c = 97是完全等價的
print(s.encode("utf-8"))  # b'abc'

print(bytearray([97, 98, 99]).decode("utf-8"))  # abc

# 所以我們創建一個字節序列的時候,也可以這么做
print(b"abc")  # b'abc'

# 但是我們不可以b'憨', 因為'憨'這個字符不是ASCII字符, ASCII字符要求對應的ASCII碼唯一、並且小於128
# 所以在不同的編碼下會對應不同的ASCII碼,比如gbk編碼的話對應兩個ASCII碼, utf-8對應三個ASCII碼
# 因此b'憨'的話,由於不知道使用哪一種編碼, 所以Python不允許這么做,而是通過'憨'.encode的方式來手動指定編碼
# 而'abc'都是純ASCII字符,不管采用哪一種編碼都會得到相同的ASCII碼,所以Python允許這么做
# 當然對ASCII字符使用ASCII碼也是可以的

小結

字符串的內容還是比較多的,在源碼中有一萬六千多行,顯然我們沒辦法一步一步地全部分析完,有興趣的可以自己深入研究一下。其實我們能把字符串的存儲搞明白,其實已經是前進了一大步了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM