本文爲翻譯文章,已獲得 @rushter 的許可
原文連接:rushter.com/blog/python…
轉載請註明出處python
從 Python 3 開始,str 類型表明着 Unicode 字符串。取決於編碼的類型,一個 Unicode 字符可能會佔 4 個字節,這個有些時候有點浪費內存。git
出於內存佔用以及性能方面的考慮,Python 內部採用下面 3 種方式來存儲 Unicode 字符:github
使用 Python 進行開發的時候,咱們會以爲字符串的處理都很相似,不少時候根本不須要注意這些差異。但是,當碰到大量的字符處理的時候,這些細節就要特別注意了。後端
咱們能夠作一些小實驗來體會下上面三種方式的差異。方法 sys.getsizeof 用來獲取一個對象所佔用的字節,這裏咱們會用到。bash
>>> 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)。ui
>>> ord('a')
97
>>> ord('你')
20320
>>> ord('!')
33
複製代碼
大部分語言文字使用 2 個字節(UCS-2)來編碼就已經足夠了。4 個字節(UCS-4)的編碼在保存特殊符號、emoji 表情或者少見的語言文字的時候會用到。編碼
設想有一個 10GB 的 ASCII 文本文件,咱們準備將其讀到內存裏面去。若是你插入一個 emoji 表情到文件中,文件佔用空間將會達到 4 倍。若是你處理 NLP 問題較多的話,這種差異你應該能常常體會到。
最多見的 Unicode 編碼是 UTF-8,可是 Python 內部並無使用它。
UTF-8 編碼字符的時候,取決於字符的內容,佔的空間在 1-4 個字節內發生變化。這是一種特別省空間的存儲方式,但正由於這種變長的存儲方式,致使字符串不能經過下標直接進行隨機讀取,只能遍歷進行查找。好比,若是採用的是 UTF-8 編碼的話,Python 獲取 string[5] 只能一個一個字符的進行掃描,直至找到目標字符。若是是定長編碼的話也就沒有問題了,要用一個下標定位一個字符,只須要用下標乘以指定長度(一、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 對象的源碼。
本文首發於公衆號「小小後端」。