字串的秘密生活(上)
在大部份的程式語言裡,字串應該跟數字差不多都是最常用的資料型別。字串最主要是用來表示文字資料,不過你可曾想過當你在 Python 程式碼裡寫著 message = "Hello, World!"
這樣簡單的語法的時候,背後發生了什麼呢?
建立字串
我們先從最簡單的開始:
message = "Hello, World!"
這行程式碼在 Python 裡面是很常見的,這行程式碼的目的是建立一個字串,並將它指派給 message
變數。先看一下這行程式碼的 Bytecode 長什麼樣子:
1 2 LOAD_CONST 0 ('Hello, World!')
4 STORE_NAME 0 (message)
同樣也是執行 LOAD_CONST
指令讀取常數,這表示這個字串在 Bytecode 啟動之前已經被編譯進 Bytecode 裡了。像這樣直接使用字面值(String Literal)來建立字串的方式,都會被編譯器給編進 Bytecode 裡。至於 Bytecode 是怎麼編的,我們後續會有 一整個章節來介紹。我們先看看字串物件在 CPython 是怎麼建立的。
字串物件
在 Python 3 裡,我們都會說字串預設是 Unicode 字串,在 CPython 是使用 PyUnicode_New()
函數來建立字串物件:
PyObject *
PyUnicode_New(Py_ssize_t size, Py_UCS4 maxchar)
{
// ... 略 ...
}
這個函數的行數稍微多一點,我們分段慢慢看。一開始會先檢查是不是空字串:
/* Optimization for empty strings */
if (size == 0) {
return unicode_new_empty();
}
有專門為空字串最佳化耶!不過想想也合理,畢竟空字串使用的頻率是真的滿高的。我們來看看它是怎麼實作的:
static inline PyObject* unicode_new_empty(void)
{
PyObject *empty = unicode_get_empty();
return Py_NewRef(empty);
}
也就是說,在 CPython 裡空字串只會有一份,而且這個空字串是直接被編譯進 Python 直譯器裡的,每次需要空字串的時候都是拿同一個空字串來用而不需要重新產生新的空字串,節省記憶體的同時也能減少 PyUnicode_New()
函數後續不必要的操作。接下來的判斷,就是根據字元的編碼,而決定產生哪一種字串物件:
if (maxchar < 128) {
kind = PyUnicode_1BYTE_KIND;
char_size = 1;
is_ascii = 1;
struct_size = sizeof(PyASCIIObject);
}
else if (maxchar < 256) {
kind = PyUnicode_1BYTE_KIND;
char_size = 1;
}
else if (maxchar < 65536) {
kind = PyUnicode_2BYTE_KIND;
char_size = 2;
}
else {
if (maxchar > MAX_UNICODE) {
PyErr_SetString(PyExc_SystemError,
"invalid maximum character passed to PyUnicode_New");
return NULL;
}
kind = PyUnicode_4BYTE_KIND;
char_size = 4;
}
這裡會判斷 maxchar
的值,來決定要建立哪一種字串物件。CPython 裡有定義三種字串物件,PyASCIIObject
用於純 ASCII 字串,每個字元佔 1 個 Byte 的大小,PyCompactUnicodeObject
用於小型 Unicode 字串,每個字元佔 2 個 Byte。而 PyUnicodeObject
用於大型 Unicode 字串,每個字元佔 4 個 Byte。也就是說,Python 會根據實際需求,選擇最適合的字串物件來建立字串,避免不必要的浪費。
如果是中文字或 Emoji,使用 Unicode 來表示的確合理,但如果只是英文數字的話,用 ASCII 來表示就夠了。如果你不知道什麼是 ASCII 的話,可參考「為你自己學 Python」裡關於 Unicode 與 UTF章節介紹。
而這三種字串結構的定義也挺有趣,仔細看就會發現它們之間都是有關係的:
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 statically_allocated:1;
unsigned int :24;
} state;
} PyASCIIObject;
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) */
} PyCompactUnicodeObject;
typedef struct {
PyCompactUnicodeObject _base;
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data; /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;
從原始碼應該不難看出來,PyASCIIObject
是最基本的結構,PyCompactUnicodeObject
是基於 PyASCIIObject
再加幾個成員,而 PyUnicodeObject
又基於 PyCompactUnicodeObject
再加一些東西。
最基礎的字串結構
既然 PyASCIIObject
是最基本的結構,那麼我們就先來看看這個結構裡面的成員。
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 statically_allocated:1;
unsigned int :24;
} state;
} PyASCIIObject;
PyObject_HEAD
這種每個物件都有的東西就不用特別看它了。length
這個成員也滿好猜的,就是用來放這個字串有幾個字。這會在字串建立的時候就設定好,想要知道這個字串有幾個字問它就行,不用每次都得重新計算。hash
用來存放這個字串的雜湊值,如果是-1
的話表示還沒有計算過雜湊值。- 接下來這個
state
結構裡面放的成員:interned
表示這個字串會不會設定成「內部化(interned)」,如果是的話,這個字串就會被放到一個內部的字串池裡被重複使用,不用每次都重新建立。kind
表示字串的編碼種類,也是用來區分PyASCIIObject
、PyCompactUnicodeObject
還是PyUnicodeObject
的。compact
這個用來表示字串是否直接儲存在字串物件之後,而不是分開分配記憶體空間,這對於字串來說可以提高效能。ascii
用來表示是不是純 ASCII 字串。statically_allocated
這個成員是用來表示這個字串是不是靜態分配的,靜態分配的字串不需要被垃 圾回收。- 最後留了 24 個位元沒使用,這是保留給將來擴充使用的,這樣可以添加新的標記而不改變整個結構的大小。
再補充一下關於 compact
的設計,舉個例子,如果你有一個裝著衣服的箱子,也許你會把衣服的標籤和衣服分開放,雖然都在箱子裡,要找也是找的到。但如果採用 compact
的方式,有點像是把標籤直接縫在衣服上,這樣可以省一點空間,要找的時候也有效率。
當然,這樣的設計也不是沒缺點,比如當你要修改字串的時候,就需要重新分配記憶體空間,這反而會比較慢。不過還好,反正 Python 的字串設計是不可變的,暫時沒這個困擾。
字串操作
編碼轉換
Python 會根據字串的內容來決定要用哪種字串物件,如果我對一個 ASCII 字串添加一個 Emoji 笑臉(😊,Unicode 編碼 = U+1F60A
),像這樣:
message = "Hello, world!"
message = message + "😊"
這會發生什麼事?我們一步一步來看。字串是一種 PyUnicode_Type
型別,之前我們在介紹 PyType_Type
結構的時候有提過三個 tp_as_
開頭的成員,其中一個叫做 tp_as_sequence
,它定義了物件作為序列型別時的行為,像是索引、切片、連接等,而這裡要把兩個字串串在一起,就是看這個成員裡的 sq_concat
函數指標。
PyObject *
PyUnicode_Concat(PyObject *left, PyObject *right)
{
PyObject *result;
// ... 略 ...
maxchar = PyUnicode_MAX_CHAR_VALUE(left);
maxchar2 = PyUnicode_MAX_CHAR_VALUE(right);
maxchar = Py_MAX(maxchar, maxchar2);
/* Concat the two Unicode strings */
result = PyUnicode_New(new_len, maxchar);
if (result == NULL)
return NULL;
_PyUnicode_FastCopyCharacters(result, 0, left, 0, left_len);
_PyUnicode_FastCopyCharacters(result, left_len, right, 0, right_len);
assert(_PyUnicode_CheckConsistency(result, 1));
return result;
}
在這裡可以看到會判斷哪一個字串的 maxchar
比較大,然後用這個值傳給 PyUnicode_New()
函數建立字串物件的,這個函數會根據字串的內容來決定要用哪一種字串物件。在這裡,maxchar
的值是 U+1F60A
,這個值大於 65536,所以 Python 會選擇 PyUnicodeObject
來建立字串物件。
在 Python 的字串是不能修改的(Immutable),所以這裡的 message
並不是修改了原本的字串,而是建立了一個新的字串物件,然後把這個新的字串物件指派給 message
變數。這時候 Python 發現裡面字元超過它能處理的範圍,就會自動轉換成 PyUnicodeObject
,而不會是原本的 PyASCIIObject
了。我們可以寫一小段 Python 程式來驗證結果:
import sys
def string_info(s):
print(f"Length: {len(s)}")
print(f"Size: {sys.getsizeof(s)} bytes")
print(f"Is ASCII: {s.isascii()}")
message = "Hello World!"
string_info(message)
message = message + "😊"
string_info(message)
執行結果:
Length: 12
Size: 53 bytes
Is ASCII: True
Length: 13
Size: 112 bytes
Is ASCII: False
可以看到雖然只加了一個 Emoji,但字串的大小就從 53 Bytes 變成 112 Bytes 了,原本的 state
裡的 ascii
成員變數也變成 0。