iT邦幫忙

2024 iThome 鐵人賽

DAY 10
1
Python

為你自己讀 CPython 原始碼系列 第 10

Day 10 - 字串的秘密生活(上)

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 字串的秘密生活(上)

字串的秘密生活(上)

為你自己學 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)
{
    // ... 略 ...
}

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

/* Optimization for empty strings */
if (size == 0) {
    return unicode_new_empty();
}

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

// 檔案:Objects/unicodeobject.c

// Return a strong reference to the empty string singleton.
static inline PyObject* unicode_new_empty(void)
{
    PyObject *empty = unicode_get_empty();
    return Py_NewRef(empty);
}

也就是說,在 CPython 裡空字串只會有一份,每次需要空字串的時候都是拿同一個空字串來用,節省記憶體的同時也能減少 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 裡有 3 個 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_sequence 成員,如果是讀取,就是找 tp_as_sequence 裡的 sq_item 成員,指定或修改的話,則是找 sq_ass_item。我們來看看 PyUnicode_Typetp_as_sequence 長什麼樣子:

// 檔案:Objects/unicodeobject.c

static PySequenceMethods unicode_as_sequence = {
    (lenfunc) unicode_length,       /* sq_length */
    PyUnicode_Concat,           /* sq_concat */
    (ssizeargfunc) unicode_repeat,  /* sq_repeat */
    (ssizeargfunc) unicode_getitem,     /* sq_item */
    0,                  /* sq_slice */
    0,                  /* sq_ass_item */
    0,                  /* sq_ass_slice */
    PyUnicode_Contains,         /* sq_contains */
};

讀取的 sq_item 有實作,但指定的 sq_ass_item0,表示沒實作這個功能,所以執行的時候就會出現錯誤訊息了:

TypeError: 'str' object does not support item assignment

不只這樣,sq_ass_slice 這個成員是透過切片方式來指定內容,同樣也是沒有實作,一樣也會出錯。也就是說,字串是不可修改的這件事情,基本上就是:

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

就這樣而已。

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

本文同步刊載於 「為你自己學 Python - 字串的秘密生活(上)


上一篇
Day 9 - 浮點數之小數點漂移記
下一篇
Day 11 - 字串的秘密生活(下)
系列文
為你自己讀 CPython 原始碼12
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言