跳至主要内容

字串的秘密生活(上)

為你自己學 Python

在大部份的程式語言裡,字串應該跟數字差不多都是最常用的資料型別。字串最主要是用來表示文字資料,不過你可曾想過當你在 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() 函數來建立字串物件:

檔案:Objects/unicodeobject.c
PyObject *
PyUnicode_New(Py_ssize_t size, Py_UCS4 maxchar)
{
// ... 略 ...
}

這個函數的行數稍微多一點,我們分段慢慢看。一開始會先檢查是不是空字串:

檔案:Objects/unicodeobject.c
/* Optimization for empty strings */
if (size == 0) {
return unicode_new_empty();
}

有專門為空字串最佳化耶!不過想想也合理,畢竟空字串使用的頻率是真的滿高的。我們來看看它是怎麼實作的:

檔案:Objects/unicodeobject.c
static inline PyObject* unicode_new_empty(void)
{
PyObject *empty = unicode_get_empty();
return Py_NewRef(empty);
}

也就是說,在 CPython 裡空字串只會有一份,而且這個空字串是直接被編譯進 Python 直譯器裡的,每次需要空字串的時候都是拿同一個空字串來用而不需要重新產生新的空字串,節省記憶體的同時也能減少 PyUnicode_New() 函數後續不必要的操作。接下來的判斷,就是根據字元的編碼,而決定產生哪一種字串物件:

檔案:Objects/unicodeobject.c
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章節介紹。

而這三種字串結構的定義也挺有趣,仔細看就會發現它們之間都是有關係的:

檔案:Include/cpython/unicodeobject.h
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 是最基本的結構,那麼我們就先來看看這個結構裡面的成員。

檔案:Include/cpython/unicodeobject.h
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 表示字串的編碼種類,也是用來區分 PyASCIIObjectPyCompactUnicodeObject 還是 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 函數指標。

檔案:Objects/unicodeobject.c
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。

字串不能改變

在 Python 中,字串是一種不可變的資料型態,讀取沒問題,但要修改字串裡的某個字元是不行的:

message = "Hello, World!"
print(message[0]) # 印出 "H"
message[0] = "h" # 這會出錯!

其實這個實作的方式也很簡單,像這種透過中括號對序列的讀取或修改,前面有介紹過會先找 tp_as_mapping 成員的 mp_subscript,如果沒有才會找 tp_as_sequence 成員。

檔案:Objects/unicodeobject.c
static PyObject*
unicode_subscript(PyObject* self, PyObject* item)
{
if (_PyIndex_Check(item)) {
// ... 略 ...
return unicode_getitem(self, i);
} else if (PySlice_Check(item)) {
// ... 略 ...
} else {
PyErr_Format(PyExc_TypeError, "string indices must be integers, not '%.200s'",
Py_TYPE(item)->tp_name);
return NULL;
}
}

可以看到這裡會先判斷 item 看是整數還是切片的寫法,如果是整數就會呼叫 unicode_getitem() 函數,如果是切片的話,就會複製字串物件並產生新的字串物件。讀取的部份沒什麼問題,但修改的話,會找 mp_ass_subscript 成員:

檔案:Objects/unicodeobject.c
static PyMappingMethods unicode_as_mapping = {
(lenfunc)unicode_length, /* mp_length */
(binaryfunc)unicode_subscript, /* mp_subscript */
(objobjargproc)0, /* mp_ass_subscript */
};

這裡可以看到 mp_ass_subscript0,表示沒實作這個功能,所以不管是透過一般整數的索引值,或是切片的索引值,想要進行修改就會出現錯誤訊息了:

TypeError: 'str' object does not support item assignment

也就是說,關於修改字串這件事,基本上就是:

  • 不管是字串的相加或是轉換大小寫或是其它操作,其實都是回傳一份新的字串物件。
  • 字串不能被修改,就只是因為字串沒有提供直接修改字串的函數而已。

就這樣而已。

更多關於字串的格式化、字串切片(Slice)操作以及用來節省記憶體開銷的「字串內部化(String Interning)」,我們就留到下一集再來討論。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!