Python 儲存字串時是如何節省空間的?

小小後端發表於2019-04-21

本文為翻譯文章,已得到 @rushter 的許可
原文連結:rushter.com/blog/python…
轉載請註明出處

從 Python 3 開始,str 型別代表著 Unicode 字串。取決於編碼的型別,一個 Unicode 字元可能會佔 4 個位元組,這個有些時候有點浪費記憶體。

出於記憶體佔用以及效能方面的考慮,Python 內部採用下面 3 種方式來儲存 Unicode 字元:

  • 一個字元佔一個位元組(Latin-1 編碼)
  • 一個字元佔二個位元組(UCS-2 編碼)
  • 一個字元佔四個位元組(UCS-4 編碼)

使用 Python 進行開發的時候,我們會覺得字串的處理都很類似,很多時候根本不需要注意這些差別。可是,當碰到大量的字元處理的時候,這些細節就要特別注意了。

我們可以做一些小實驗來體會下上面三種方式的差別。方法 sys.getsizeof 用來獲取一個物件所佔用的位元組,這裡我們會用到。

>>> import sys
>>> string = 'hello'
>>> sys.getsizeof(string)
54
>>> # 1-byte encoding
... sys.getsizeof(string + '!') - sys.getsizeof(string)
1
>>> # 2-byte encoding
... string2  = '你'
>>> sys.getsizeof(string2 + '好') - sys.getsizeof(string2)
2
>>> sys.getsizeof(string2)
76
>>> # 4-byte encoding
... string3 = '?'
>>> sys.getsizeof(string3 + '?') - sys.getsizeof(string3)
4
>>> sys.getsizeof(string3)
80
複製程式碼

如上所示,當字串的內容不同時,所採用的編碼也會不同。需要注意的是,Python 中每個字串都會另外佔用 49-80 位元組的空間,用於儲存額外的一些資訊,比如雜湊、字串長度、字串位元組數和字串標識。這麼一來,一個空字串會佔用 49 個位元組,也就好理解了。

我們可以通過 cbytes 直接獲取一個物件的編碼型別:

import ctypes


class PyUnicodeObject(ctypes.Structure):
    # internal fields of the string object
    _fields_ = [("ob_refcnt", ctypes.c_long),
                ("ob_type", ctypes.c_void_p),
                ("length", ctypes.c_ssize_t),
                ("hash", ctypes.c_ssize_t),
                ("interned", ctypes.c_uint, 2),
                ("kind", ctypes.c_uint, 3),
                ("compact", ctypes.c_uint, 1),
                ("ascii", ctypes.c_uint, 1),
                ("ready", ctypes.c_uint, 1),
                # ...
                # ...
                ]


def get_string_kind(string):
    return PyUnicodeObject.from_address(id(string)).kind
複製程式碼

然後測試

>>> get_string_kind('Hello')
1
>>> get_string_kind('你好')
2
>>> get_string_kind('?')
4
複製程式碼

如果一個字串中的所有字元都能用 ASCII 表示,那麼 Python 會使用 Latin-1 編碼。簡單說下,Latin-1 用於表示前 256 個 Unicode 字元。它能支援很多拉丁語言,比如英語、瑞典語、義大利語等。不過,如果是漢語、日語、西伯爾語等非拉丁語言,Latin-1 編碼就行不通了。因為這些語言的文字的碼位值(編碼值)超過了 1 個位元組的範圍(0-255)。

>>> ord('a')
97
>>> ord('你')
20320
>>> ord('!')
33
複製程式碼

大部分語言文字使用 2 個位元組(UCS-2)來編碼就已經足夠了。4 個位元組(UCS-4)的編碼在儲存特殊符號、emoji 表情或者少見的語言文字的時候會用到。

設想有一個 10GB 的 ASCII 文字檔案,我們準備將其讀到記憶體裡面去。如果你插入一個 emoji 表情到檔案中,檔案佔用空間將會達到 4 倍。如果你處理 NLP 問題較多的話,這種差別你應該能經常體會到。

Python 內部為什麼不直接使用 UTF-8 編碼

最常見的 Unicode 編碼是 UTF-8,但是 Python 內部並沒有使用它。

UTF-8 編碼字元的時候,取決於字元的內容,佔的空間在 1-4 個位元組內發生變化。這是一種特別省空間的儲存方式,但正因為這種變長的儲存方式,導致字串不能通過下標直接進行隨機讀取,只能遍歷進行查詢。比如,如果採用的是 UTF-8 編碼的話,Python 獲取 string[5] 只能一個一個字元的進行掃描,直至找到目標字元。如果是定長編碼的話也就沒有問題了,要用一個下標定位一個字元,只需要用下標乘以指定長度(1、2 或者 4)就能確定。

字串駐留

Python 中的空字串和 ASCII 字元都會使用到字串駐留(string interning)技術。怎麼理解?你就把這些字元(串)看作是單例的就行。也就是說,兩個相同內容的字串如果使用了駐留的技術,那麼記憶體裡面其實就只開闢了一個空間。

>>> a = 'hello'
>>> b = 'world'
>>> a[4],b[1]
('o', 'o')
>>> id(a[4]), id(b[1]), a[4] is b[1]
(4567926352, 4567926352, True)
>>> id('')
4545673904
>>> id('')
4545673904
複製程式碼

正如你看到的那樣,a 中的字元 o 和 b 中的字元 o 有著同樣的記憶體地址。Python 中的字串是不可修改的,所以提前為某些字元分配好位置便於後面使用也是可行的。

使用到字串駐留的除了 ASCII 字元、空竄之外,字元長度不超過 20 的串也使用到了同樣的技術,前提是這些串的內容在編譯的時候就能確定。

這包括:

  • 方法名、型別
  • 變數名
  • 引數名
  • 常量(程式碼中定義的字串)
  • 字典的鍵
  • 屬性名

當你在互動式命令列中編寫程式碼的時候,語句同樣也會先被編譯成位元組碼。所以說,互動式命令列中的短字串也會被駐留。

>>> a = 'teststring'
>>> b = 'teststring'
>>> id(a), id(b), a is b
(4569487216, 4569487216, True)
>>> a = 'test'*5
>>> b = 'test'*5
>>> len(a), id(a), id(b), a is b
(20, 4569499232, 4569499232, True)
>>> a = 'test'*6
>>> b = 'test'*6
>>> len(a), id(a), id(b), a is b
(24, 4569479328, 4569479168, False)
複製程式碼

因為必須是常量字串會使用到駐留,所以下面的例子不能達到駐留的效果:

>>> open('test.txt','w').write('hello')
5
>>> open('test.txt','r').read()
'hello'
>>> a = open('test.txt','r').read()
>>> b = open('test.txt','r').read()
>>> id(a), id(b), a is b
(4384934576, 4384934688, False)
>>> len(a), id(a), id(b), a is b
(5, 4384934576, 4384934688, False)
複製程式碼

字串駐留技術,減少了大量的重複字串的記憶體分配。Python 底層通過字典實現的這種技術,這些暫存的字串作為字典的鍵。如果想要知道某個字串是否已經駐留,使用字典的查詢操作就能確定。

Python 的 unicode 物件的實現(https://github.com/python/cpython/blob/master/Objects/unicodeobject.c)大約有 16,000 行 C 程式碼,其中有很多小優化在本文中未提及。如果你想更多的瞭解 Python 中的 Unicode,推薦你去看一下字串相關的 PEPs(https://www.python.org/dev/peps/),同時檢視下 unicode 物件的原始碼。

本文首發於公眾號「小小後端」。

相關文章