Python diving — Unicode 深入淺出

Falldog Hsieh
GoFreight HQ
Published in
12 min readApr 22, 2021

Unicode 的處理對很多人來說,都是心裡面最軟的一塊,大概了解,但是卻又沒這麼清楚,又很難花時間好好理解。我從工作開始後,也是花了很多學費(?),踩了很多坑後,才開始對 Unicode 慢慢有點理解,進一步也能理解為什麼會有這樣子的設計。以下就用我粗淺的理解來告訴各位帶各位深入淺出 Unicode 與 Python 還有 Platform 的關係吧。

誰適合閱讀本文

  • 有 Unicode, ASCII, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4 的基礎概念
  • 對 Python str & bytes 有基本的認識

1. Python unicode & bytes

這邊先說一下 Python3 裡的字串可以用兩個型別來表示:(1) str(2) bytes(其實 str 就是一般所謂的 unicode ,以下統稱為 unicode )。而 unicode 跟 bytes 的關係,相信會讀這篇文章的讀者,應該都對 Python3 的 unicode 與 bytes 的轉換有些經驗,只要在對的時機點做 decode or encode,結果就可以跑了。沒錯!就是這個概念。

在 Python 的程式裡,會建議所有「內部」的商業邏輯操作都使用 unicode 來處理,只有在與「外界」溝通時,才需要轉換成 bytes。

這邊可以定義為「外界」的可以有下列幾個,在 Python3 裡的 function 參數都只吃 bytes 而且,傳 unicode 字串就會 exception

  1. File I/O (read, write)
  2. System I/O (stdin, stdout, stderr)
  3. Binary I/O (http content)

下面這段說明應該滿清楚的

  • Use .encode() to convert human text to bytes.
  • Use .decode() to convert bytes to human text.

也可以以下圖來呈現說明

python unicode encode / decode concept

概念上來說,就是 Python Application 「內部」核心處理的部分都用以 Unicode 處理,「外界」的部分,不管是 Terminal 的 stdout / stdin、HTTP content、File ,只要跟 I/O 有關,都是以 bytes 來處理,進來一律 decode 成 unicode,出去一律 encode 成 bytes。

2. char / wchar_t / multi-bytes

這邊先跳脫一下 Python 這個程式語言,畢竟所有的程式語言都有 Unicode 的設定,這邊主要以 C 語言為依據。

1 個 char 等於 1 個 byte / 8 個 bit,ASCII 編碼只需要 7 個 bit 就可以表示 128 個字元。而不同國家不同語系可能會有自己的編碼,如繁體中文的 Big5、簡體中文的 GBK,唯有 UTF 系列的編碼包含了所有的字元集,這應該就是大家眼中的 Unicode。

Unicode 的定義,對每個「字」都有一個獨一無二的編號 (目前總共有 14萬的字),稱為 Code Point(有書將它翻譯為”碼點”)。格式皆為 U+Number, 例如 的 Code Point 為 U+786C ,而且連 emoji 都有自己的 Code Point,😂 U+1F602。(不過,有些例外的是,有些「字」會有多個以上的 Code Point)。至於這個 Code Point 要怎麼存成 binary ,就是各個編碼的定義了。各個國家語系的編碼,基本上都只能處理 部分 的 Code Point,比如 Big5 只能處理中文的部分 (13060個字),而 UTF 理論上應該都要可以處理所有的字,UTF-8 與 UTF-16 都算是不定長度的編碼。UTF-32 每個字都是 4 個 bytes 可以支援的量為 2 的 32 次方 4,294,967,296。

硬 (U+786C) 不同的 encoding 的 binary 結果對照表

在 C 語言裡的 wchar_t 就是為了解決/實作 unicode 的解法,而各個平台的作法也各有所不同。不同的地方像是 (1) 長度的不同,UTF-16 與 UTF-32 的差別在於 2 個 bytes 或是 4 個 bytes,(2) 資料在 binary data 裡的擺放方向不同,big-endian 或是 little-endian。各個平台不同指的是,Windows wchar_t 的使用的是 UTF-16,Linux wchar_t 使用的是 UTF-32。而 Windows 版本的 UTF-16 ,為了支援更多的 Code Point,有做額外的 Surrogates 處理,讓部分 Code Point 可能也超過 2 個 bytes。[1]

multi-bytes 的概念,應該就是 wchar_t 從 Unicode 轉換成不同的編碼後可以用 char 來儲存。比如 硬 (U+786C)這個字,wchar_t 字串的長度為 1,而經過 WideCharToMultiByte or wcstombs的 function 轉換為 UTF-8 後,char 字串的長度會是 3,而內容應該為 E7 A1 AC ,轉換為 Big5後,char 字串長度會是 2,而內容應該為 B5 77

3. CPython — PyUnicode / PyBytes

Python3 一般的字串就是 Unicode e.g. "Test", bytes 在字串前必需加前 b 的前綴字 e.g. b"Test"。在 CPython 裡,Unicode 定義的名稱皆為 PyUnicode_xxx ,而 bytes 的定義名稱皆為 PyBytes_xxx。

先看一下 PyUnicode 官網上面的定義,其實 PyUnicode 的每一個字元的 type 即為 wchar_t,所以 Python wchar_t 的 size (2 個 bytes 或是 4 個 bytes),也跟 platform 有關。[2]

Py_UNICODE
This is a typedef of wchar_t, which is a 16-bit type or 32-bit type depending on the platform.
Changed in version 3.3: In previous versions, this was a 16-bit type or a 32-bit type depending on whether you selected a “narrow” or “wide” Unicode version of Python at build time.

這邊參考一下 CPython source code — unicodeobject.h

/* 
Py_UNICODE was the native Unicode storage format (code unit) used by Python and represents a single Unicode element in the Unicode type. With PEP 393, Py_UNICODE is deprecated and replaced with a typedef to wchar_t. */
#define PY_UNICODE_TYPE wchar_t
/* Py_DEPRECATED(3.3) */ typedef wchar_t Py_UNICODE;

PyBytesObject 看一下源碼的定義

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 byte string or -1 if not computed yet.
*/
} PyBytesObject;

這邊可以注意到的是 PyBytesObject 將資料儲存在 ob_sval 裡,雖然它的長度只設定了 [1] ,但是實際上它配置的記憶體長度為 ob_size+1

參考下面的 CPython source code - bytesobject.c
PyObject_Malloc 的 size 是 PyBytesObject_SIZE + sizePyBytesObject_SIZE 是 struct 的長度,size 就是 bytes 內容的長度。

static PyObject *
_PyBytes_FromSize(Py_ssize_t size, int use_calloc)
{
PyBytesObject *op;
assert(size >= 0);
if (size == 0) {
return bytes_new_empty();
}
if ((size_t)size > (size_t)PY_SSIZE_T_MAX-PyBytesObject_SIZE) {
PyErr_SetString(PyExc_OverflowError,
"byte string is too large");
return NULL;
}
/* Inline PyObject_NewVar */
if (use_calloc)
op = (PyBytesObject *)PyObject_Calloc(1, PyBytesObject_SIZE + size);
else
op = (PyBytesObject *)PyObject_Malloc(PyBytesObject_SIZE + size);
if (op == NULL) {
return PyErr_NoMemory();
}
_PyObject_InitVar((PyVarObject*)op, &PyBytes_Type, size);
op->ob_shash = -1;
if (!use_calloc) {
op->ob_sval[size] = '\0';
}
return (PyObject *) op;
}

4. Python 2 -> Python3 的轉變

對 Python 來說,不管 2 或是 3,概念上可以分為 Text 與 Bytes。[3]

  • Text — Human readable string,Python2 為 unicode ,Python3 為 str
  • Bytes — Binary data string,Python2 為 str ,Python3 為 bytes

在 Python2 時,許多人都遇過的 exception 就是 UnicodeEncodeError ,帶給開發者很大的困擾,因為很多人都搞不太懂為什麼會發生這個 exception。主因是 str 跟 unicode 可以混用的關係,而且會做自動 encode or decode。預設的字串都以 str 來處理,某些 built-in function 在需要 unicode 時就會自動以 ASCII encoding 轉換,然後就… 悲劇了。

在 Python3 裡,為了避免 Python2 的悲劇,所以將許多的 built-in function 的處理加上型別的驗證,傳入的參數如果是 str 而不是 bytes 的話,就直接噴 exception。許多情境下 str 與 bytes 不會自動轉換,必須要自己做 encode or decode 才不會有 exception 發生。而這也呼應了 Python 的提倡的精神「 Explicit is better than implicit 」[4]

>>> f = open('test.txt', 'wb')
>>> f.write('FOO')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: a bytes-like object is required, not 'str'

對 CPython 來說,Text 還是一樣的 PyUnicode。而 Bytes 從 PyString 轉變為 PyBytes 了。

而在概念上 bytes 更貼近真實世界的設計,bytes 代表的不只是「字串」而已了。以 File 而言,不同的副檔名都有自己儲存的格式,「文字檔」才有所謂的文字編碼 (encoding, e.g. Big5, UTF-8, …),其他的影片檔,圖片檔,聲音檔…等等,你在讀檔案出來時,絕對不可能對它做 decode,因為可能會發生 UnicodeDecodeError,而且就算幸運的通過了 decode,呈現在 stdout 的內容也只是亂碼而已。

5. 結論

以 Clean Architecture 的概念來說,在程式邏輯上做好 boundary 的切割,可以避免過於複雜的問題。所以在所有的內部文字的處理,統一用 Unicode 處理,只有在跟外部溝通時,再 encode 成 bytes,理論上就可以避掉大部分 UnicodeEncodeError or UnicodeDecodeError 的問題了。

你知道我在找你嗎?

有興趣加入 GoFreight 團隊一起解決世界級的難題的話,請參考 Teamdoor 職缺表,等待高手的加入。

Reference

--

--