Python內建類型bytes深入理解
引言
“深入認識Python內建類型”這部分的內容會從源碼角度為大傢介紹Python中各種常用的內建類型。
在我們日常的開發中,str是很常用的一個內建類型,與之相關的我們比較少接觸的就是bytes,這裡先為大傢介紹一下bytes相關的知識點,下一篇博客再詳細介紹str的相關內容。
1 bytes和str之間的關系
不少語言中的字符串都是由字符數組(或稱為字節序列)來表示的,例如C語言:
char str[] = "Hello World!";
由於一個字節最多隻能表示256種字符,要想覆蓋眾多的字符(例如漢字),就需要通過多個字節來表示一個字符,即多字節編碼。但由於原始字節序列中沒有維護編碼信息,操作不慎就很容易導致各種亂碼現象。
Python提供的解決方法是使用Unicode對象(也就是str對象),Unicode口語表示各種字符,無需關心編碼。但是在存儲或者網絡通訊時,字符串對象需要序列化成字節序列。為此,Python額外提供瞭字節序列對象——bytes。
str和bytes的關系如圖所示:
str對象統一表示一個字符串,不需要關心編碼;計算機通過字節序列與存儲介質和網絡介質打交道,字節序列用bytes對象表示;存儲或傳輸str對象時,需要將其序列化成字節序列,序列化過程也是編碼的過程。
2 bytes對象的結構:PyBytesObject
C源碼:
typedef struct { PyObject_VAR_HEAD Py_hash_t ob_shash; char ob_sval[1]; /* Invariants: * ob_sval contains space for 'ob_size+1' elements. * ob_sval[ob_size] == 0. * ob_shash is the hash of the string or -1 if not computed yet. */ } PyBytesObject;
源碼分析:
字符數組ob_sval存儲對應的字符,但是ob_sval數組的長度並不是ob_size,而是ob_size + 1.這是Python為待存儲的字節序列額外分配瞭一個字節,用於在末尾處保存’\0’,以便兼容C字符串。
ob_shash:用於保存字節序列的哈希值。由於計算bytes對象的哈希值需要遍歷其內部的字符數組,開銷相對較大。因此Python選擇將哈希值保存起來,以空間換時間(隨處可見的思想,hh),避免重復計算。
圖示如下:
3 bytes對象的行為
3.1 PyBytes_Type
C源碼:
PyTypeObject PyBytes_Type = { PyVarObject_HEAD_INIT(&PyType_Type, 0) "bytes", PyBytesObject_SIZE, sizeof(char), // ... &bytes_as_number, /* tp_as_number */ &bytes_as_sequence, /* tp_as_sequence */ &bytes_as_mapping, /* tp_as_mapping */ (hashfunc)bytes_hash, /* tp_hash */ // ... };
數值型操作bytes_as_number:
static PyNumberMethods bytes_as_number = { 0, /*nb_add*/ 0, /*nb_subtract*/ 0, /*nb_multiply*/ bytes_mod, /*nb_remainder*/ };
bytes_mod:
static PyObject * bytes_mod(PyObject *self, PyObject *arg) { if (!PyBytes_Check(self)) { Py_RETURN_NOTIMPLEMENTED; } return _PyBytes_FormatEx(PyBytes_AS_STRING(self), PyBytes_GET_SIZE(self), arg, 0); }
可以看到,bytes對象隻是借用%運算符實現字符串格式化,並不是真正意義上的數值運算(這裡其實和最開始的分類標準是有點歧義的,按標準應該再分一個“格式型操作”,不過靈活處理也是必須的):
>>> b'msg: a = %d, b = %d' % (1, 2) b'msg: a = 1, b = 2'
序列型操作bytes_as_sequence:
static PySequenceMethods bytes_as_sequence = { (lenfunc)bytes_length, /*sq_length*/ (binaryfunc)bytes_concat, /*sq_concat*/ (ssizeargfunc)bytes_repeat, /*sq_repeat*/ (ssizeargfunc)bytes_item, /*sq_item*/ 0, /*sq_slice*/ 0, /*sq_ass_item*/ 0, /*sq_ass_slice*/ (objobjproc)bytes_contains /*sq_contains*/ };
bytes支持的序列型操作包括以下5個:
- bytes_length:查詢序列長度
- bytes_concat:將兩個序列合並為一個
- bytes_repeat:將序列重復多次
- bytes_item:取出給定下標的序列元素
- bytes_contains:包含關系判斷
關聯型操作bytes_as_mapping:
static PyMappingMethods bytes_as_mapping = { (lenfunc)bytes_length, (binaryfunc)bytes_subscript, 0, };
可以看到bytes支持獲取長度和切片兩個操作。
3.2 bytes_as_sequence
這裡我們主要介紹以下bytes_as_sequence相關的操作
bytes_as_sequence中的操作都不復雜,但是會有一個“陷阱”,這裡我們以bytes_concat操作來認識一下這個問題。C源碼如下:
/* This is also used by PyBytes_Concat() */ static PyObject * bytes_concat(PyObject *a, PyObject *b) { Py_buffer va, vb; PyObject *result = NULL; va.len = -1; vb.len = -1; if (PyObject_GetBuffer(a, &va, PyBUF_SIMPLE) != 0 || PyObject_GetBuffer(b, &vb, PyBUF_SIMPLE) != 0) { PyErr_Format(PyExc_TypeError, "can't concat %.100s to %.100s", Py_TYPE(b)->tp_name, Py_TYPE(a)->tp_name); goto done; } /* Optimize end cases */ if (va.len == 0 && PyBytes_CheckExact(b)) { result = b; Py_INCREF(result); goto done; } if (vb.len == 0 && PyBytes_CheckExact(a)) { result = a; Py_INCREF(result); goto done; } if (va.len > PY_SSIZE_T_MAX - vb.len) { PyErr_NoMemory(); goto done; } result = PyBytes_FromStringAndSize(NULL, va.len + vb.len); if (result != NULL) { memcpy(PyBytes_AS_STRING(result), va.buf, va.len); memcpy(PyBytes_AS_STRING(result) + va.len, vb.buf, vb.len); } done: if (va.len != -1) PyBuffer_Release(&va); if (vb.len != -1) PyBuffer_Release(&vb); return result; }
bytes_concat源碼大傢可自行分析,這裡直接以圖示形式來展示,主要是為瞭說明其中的“陷阱”。
圖示如下:
- Py_buffer提供瞭一套操作對象緩沖區的統一接口,屏蔽不同類型對象的內部差異
- bytes_concat則將兩個對象的緩沖區拷貝到一起,形成新的bytes對象
上述的拷貝過程是比較清晰的,但是這裡隱藏著一個問題——數據拷貝的陷阱。
以合並3個bytes對象為例:
>>> a = b'abc' >>> b = b'def' >>> c = b'ghi' >>> result = a + b + c >>> result b'abcdefghi'
本質上這個過程會合並兩次
>>> t = a + b >>> result = t + c
在這個過程中,a和b的數據都會被拷貝兩遍,圖示如下:
不難推出,合並n個bytes對象,頭兩個對象需要拷貝n – 1次,隻有最後一個對象不需要重復拷貝,平均下來每個對象大約要拷貝n/2次。因此,下面的代碼:
>>> result = b'' >>> for b in segments: result += s
效率是很低的。我們可以使用join()來優化:
>>> result = b''.join(segments)
join()方法是bytes對象提供的一個內建方法,可以高效合並多個bytes對象。join方法對數據拷貝進行瞭優化:先遍歷待合並對象,計算總長度;然後根據總長度創建目標對象;最後再遍歷待合並對象,逐一拷貝數據。這樣一來,每個對象隻需要拷貝一次,解決瞭重復拷貝的陷阱。(具體源碼大傢可以自行去查看)
4 字符緩沖池
和小整數一樣,字符對象(即單字節的bytes對象)數量也很少,隻有256個,但使用頻率非常高,因此以空間換時間能明顯提升執行效率。字符緩沖池源碼如下:
static PyBytesObject *characters[UCHAR_MAX + 1];
下面我們從創建bytes對象的過程來看一下字符緩沖池的使用:PyBytes_FromStringAndSize()函數是負責創建bytes對象的通用接口,源碼如下:
PyObject * PyBytes_FromStringAndSize(const char *str, Py_ssize_t size) { PyBytesObject *op; if (size < 0) { PyErr_SetString(PyExc_SystemError, "Negative size passed to PyBytes_FromStringAndSize"); return NULL; } if (size == 1 && str != NULL && (op = characters[*str & UCHAR_MAX]) != NULL) { #ifdef COUNT_ALLOCS one_strings++; #endif Py_INCREF(op); return (PyObject *)op; } op = (PyBytesObject *)_PyBytes_FromSize(size, 0); if (op == NULL) return NULL; if (str == NULL) return (PyObject *) op; memcpy(op->ob_sval, str, size); /* share short strings */ if (size == 1) { characters[*str & UCHAR_MAX] = op; Py_INCREF(op); } return (PyObject *) op; }
其中涉及字符緩沖區維護的關鍵步驟如下:
第10~17行:如果創建的對象為單字節對象,會先在characters數組的對應序號判斷是否已經有相應的對象存儲在瞭緩沖區中,如果有則直接取出
第28~31行:如果創建的對象為單字節對象,並且之前已經判斷瞭不在緩沖區中,則將其放入字符緩沖池的對應位置
由此可見,當Python程序開始運行時,字符緩沖池是空的。隨著單字節bytes對象的創建,緩沖池中的對象就慢慢多瞭起來。當緩沖池已緩存b’1’、b’2’、b’3’、b’a’、b’b’、b’c’這幾個字符時,內部結構如下:
示例:
註:這裡大傢可能在IDLE和PyCharm中獲得的結果不一致,這個問題在之前的博客中也提到過,查閱資料後得到的結論是:IDLE運行和PyCharm運行的方式不同。這裡我將PyCharm代碼對應的代碼對象反編譯的結果展示給大傢,但我對IDLE的認識還比較薄弱,以後有機會再給大傢詳細補充這個知識(抱拳~)。
這裡大傢還是先以認識字符緩沖區這個概念為主,當然字節碼的相關知識掌握好瞭也是很有幫助的。以下是PyCharm運行的結果:
以下操作的相關講解可以看這篇博客:Python程序執行過程與字節碼
示例1:
下面我們來看一下反編譯的結果:(下面的文件路徑我省略瞭,大傢自己試驗的時候要輸入正確的路徑)
>>> text = open('D:\\...\\test2.py').read() >>> result= compile(text,'D:\\...\\test2.py', 'exec') >>> import dis >>> dis.dis(result) 1 0 LOAD_CONST 0 (b'a') 2 STORE_NAME 0 (a) 2 4 LOAD_CONST 0 (b'a') 6 STORE_NAME 1 (b) 3 8 LOAD_NAME 2 (print) 10 LOAD_NAME 0 (a) 12 LOAD_NAME 1 (b) 14 IS_OP 0 16 CALL_FUNCTION 1 18 POP_TOP 20 LOAD_CONST 1 (None) 22 RETURN_VALUE
可以很清晰地看到,第5行和第8行的LOAD_CONST指令操作的都是下標為0的常量b’a’,因此此時a和b對應的是同一個對象,我們打印看一下:
>>> result.co_consts[0] b'a'
示例2:
為瞭確認隻會緩存單字節的bytes對象,我在這裡又嘗試瞭多字節的bytes對象,同樣還是在PyCharm環境下嘗試:
結果是比較出乎意料的:多字節的bytes對象依然是同一個。為瞭驗證這個想法,我們先來看一下對代碼對象的反編譯結果:
>>> text = open('D:\\...\\test3.py').read() >>> result= compile(text,'D:\\...\\test3.py', 'exec') >>> import dis >>> dis.dis(result) 1 0 LOAD_CONST 0 (b'abc') 2 STORE_NAME 0 (a) 2 4 LOAD_CONST 0 (b'abc') 6 STORE_NAME 1 (b) 3 8 LOAD_NAME 2 (print) 10 LOAD_NAME 0 (a) 12 LOAD_NAME 1 (b) 14 IS_OP 0 16 CALL_FUNCTION 1 18 POP_TOP 20 LOAD_CONST 1 (None) 22 RETURN_VALUE >>> result.co_consts[0] b'abc'
可以看到,反編譯的結果和單字節的bytes對象沒有區別。。。
(TODO:這裡我嘗試去看瞭PyBytes_FromStringAndSize()中相關的其他調用,但是由於水平有限,沒有找到這個問題的解釋,這個問題先暫時放下,隨著理解源碼更深刻再繼續解決)
以上就是Python內建類型bytes深入理解的詳細內容,更多關於Python內建類型bytes的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- python源碼剖析之PyObject詳解
- Python帶你從淺入深探究Tuple(基礎篇)
- Python列表創建與銷毀及緩存池機制
- Python對象的生命周期源碼學習
- Python 調用C++封裝的進一步探索交流