一文說清文本編碼那些事

一文說清文本編碼那些事

一直以來,編碼問題像幽靈一般,不少開發人員都受過它的困擾。

試想你請求一個數據,卻得到一堆亂碼,丈二和尚摸不着頭腦。有同事質疑你的數據是亂碼,雖然你很確定傳了 UTF-8 ,卻也無法自證清白,更別說幫同事 debug 了。

有時,靠着百度和一手瞎調的手藝,亂碼也能解決。儘管如此,還是很羨慕那些骨灰級程序員。爲什麼他們每次都能犀利地指出問題,並快速修復呢?原因在於,他們早就把編碼問題背後的各種來龍去脈搞清楚了。

本文從 ASCII 碼說起,帶你扒一扒編碼背後那些事。相信搞清編碼的原理後,你將不再畏懼任何編碼問題。

從 ASCII 碼說起

現代計算機技術從英文國家興起,最先遇到的也是英文文本。英文文本一般由 26 個字母、 10 個數字以及若干符號組成,總數也不過 100 左右。

計算機中最基本的存儲單位爲 字節 ( byte ),由 8 個比特位( bit )組成,也叫做 八位字節 ( octet )。8 個比特位可以表示 $ 2^8 = 256 $ 個字符,看上去用字節來存儲英文字符即可?

計算機先驅們也是這麼想的。他們爲每個英文字符編號,再加上一些控制符,形成了我們所熟知的 ASCII 碼錶。實際上,由於英文字符不多,他們只用了字節的後 7 位而已。

根據 ASCII 碼錶,由 010000018 個比特位組成的八位字節,代表字母 A

順便提一下,比特本身沒有意義,比特上下文 ( context )中才構成信息。舉個例子,對於內存中一個字節 01000001 ,你將它看做一個整數,它就是 65 ;將它作爲一個英文字符,它就是字母 A ;你看待比特的方式,就是所謂的上下文。

所以,猜猜下面這個程序輸出啥?

#include <stdio.h>

int main(int argc, char *argv[])
{
    char value = 0x41;

    // as a number, value is 65 or 0x41 in hexadecimal
    printf("%d\n", value);

    // as a ASCII character, value is alphabet A
    printf("%c\n", value);

    return 0;
}

latin1

西歐人民來了,他們主要使用拉丁字母語言。與英語類似,拉丁字母數量並不大,大概也就是幾十個。於是,西歐人民打起 ASCII 碼錶那個未用的比特位( b8 )的主意。

還記得嗎?ASCII 碼錶總共定義了 128 個字符,範圍在 0~127 之間,字節最高位 b8 暫未使用。於是,西歐人民將拉丁字母和一些輔助符號(如歐元符號)定義在 128~255 之間。這就構成了 latin1 ,它是一個 8 位字符集,定義了以下字符:

圖中綠色部分是不可打印的( unprintable )控制字符,左半部分是 ASCII 碼。因此,latin1 字符集是 ASCII 碼的超集:

一個字節掰成兩半,歐美兩兄弟各用一半。至此,歐美人民都玩嗨了,東亞人民呢?

GB2312、GBK和GB18030

由於受到漢文化的影響,東亞地區主要是漢字圈,我們便以中文爲例展開介紹。

漢字有什麼特點呢?—— 光常用漢字就有幾千個,這可不是一個字節能勝任的。一個字節不夠就兩個唄。道理雖然如此,操作起來卻未必這麼簡單。

首先,將需要編碼的漢字和 ASCII 碼整理成一個字符集,例如 GB2312 。爲什麼需要 ASCII 碼呢?因爲,在計算機世界,不可避免要跟數字、英文字母打交道。至於拉丁字母,重要性就沒那麼大,也就無所謂了。

GB2312 字符集總共收錄了 6 千多個漢字,用兩個字節來表示足矣,但事情遠沒有這麼簡單。同樣的數字字符,在 GB2312 中佔用 2 個字節,在 ASCII 碼中佔用 1 個字節,這不就不兼容了嗎?計算機裏太多東西涉及 ASCII 碼了,看看一個 http 請求:

GET / HTTP/1.1
Host: www.example.com   

那麼,怎麼兼容 GB2312ASCII 碼呢?天無絕人之路, 變長 編碼方案應運而生。

變長編碼方案,字符由長度不一的字節表示,有些字符只需 1 字節,有些需要 2 字節,甚至有些需要更多字節。GB2312 中的 ASCII 碼與原來保持一致,還是用一個字節來表示,這樣便解決了兼容問題。

GB2312 中,如果一個字節最高位 b80 ,該字節便是單字節編碼,即 ASCII 碼。如果字節最高位 b81 ,它就是雙字節編碼的首字節,與其後字節一起表示一個字符。

變長編碼方案目的在於兼容 ASCII 碼,但也帶來一個問題:由於字節編碼長度不一,定位第 N 個字符只能通過遍歷實現,時間複雜度從 $ O(1) $ 退化到 $ O(N) $ 。好在這種操作場景並不多見,因此影響可以忽略。

GB2312 收錄的漢字個數只有常用的 6 千多個,遇到生僻字還是無能爲力。因此,後來又推出了 GBKGB18030 字符集。GBKGB2312 的超集,完全兼容 GB2312 ;而 GB18030 又是 GBK 的超集,完全兼容 GBK

因此,對中文編碼文本進行解碼,指定 GB18030 最爲健壯:

>>> raw = b'\xfd\x88\xb5\xc4\xb4\xab\xc8\xcb'
>>> raw.decode('gb18030')
'龍的傳人'

指定 GBKGB2312 就只好看運氣了,GBK 多半還沒事:

>>> raw.decode('gbk')
'龍的傳人'

GB2312 經常直接拋錨不商量:

>>> raw.decode('gb2312')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'gb2312' codec can't decode byte 0xfd in position 0: illegal multibyte sequence

chardet 是一個不錯的文本編碼檢測庫,用起來很方便,但對中文編碼支持不是很好。經常中文編碼的文本進去,檢測出來的結果是 GB2312 ,但一用 GB2312 解碼就跪:

>>> import chardet
>>> raw = b'\xd6\xd0\xb9\xfa\xc8\xcb\xca\xc7\xfd\x88\xb5\xc4\xb4\xab\xc8\xcb'
>>> chardet.detect(raw)
{'encoding': 'GB2312', 'confidence': 0.99, 'language': 'Chinese'}
>>> raw.decode('GB2312')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'gb2312' codec can't decode byte 0xfd in position 8: illegal multibyte sequence

掌握 GB2312GBKGB18030 三者的關係後,我們可以略施小計。如果 chardet 檢測出來結果是 GB2312 ,就用 GB18030 去解碼,大概率可以成功!

>>> raw.decode('GB18030')
'中國人是龍的傳人'

Unicode

GB2312GBKGB18030 都是中文編碼字符集。雖然 GB18030 也包含日韓表意文字,算是國際字符集,但畢竟是以中文爲主,無法適應全球化應用。

在計算機發展早期,不同國家都推出了自己的字符集和編碼方案,互不兼容。中文編碼的文本在使用日文編碼的系統上是無法顯示的,這就給國際交往帶來障礙。

這時,英雄出現了。統一碼聯盟 站出來說要發展一個通用的字符集,收錄世界上所有字符,這就是 Unicode 。經過多年發展, Unicode 已經成爲世界上最通用的字符集,也是計算機科學領域的業界標準。

Unicode 已經收錄的字符數量已經超過 13 萬個,每個字符需佔用超過 2 字節。由於常用編程語言一般沒有 24 位數字類型,因此一般用 32 位數字表示一個字符。這樣一來,同樣的一個英文字母,在 ASCII 中只需佔用 1 字節,在 Unicode 則需要佔用 4 字節!英美人民都要哭了,試想你磁盤中的文件大小都增大了 4 倍是什麼感受!

UTF-8

爲了兼容 ASCII 並優化文本空間佔用,我們需要一種變長字節編碼方案,這就是著名的 UTF-8 。與 GB2312 等中文編碼一樣,UTF-8 用不固定的字節數來表示字符:

  1. ASCII 字符 Unicode 碼位由 U+0000U+007F ,用 1 個字節編碼,最高位爲 0
  2. 碼位由 U+0080U+07FF 的字符,用 2 個字節編碼,首字節以 110 開頭,其餘字節以 10 開頭;
  3. 碼位由 U+0800U+FFFF 的字符,用 3 個字節編碼,首字節以 1110 開頭,其餘字節同樣以 10 開頭;
  4. 46 字節編碼的情況以此類推;

如圖,以 0 開頭的字節爲 單字節 編碼,總共 7 個有效編碼位,編碼範圍爲 U+0000U+007F ,剛好對應 ASCII 碼所有字符。以 110 開頭的字節爲 雙字節 編碼,總共 11 個有效編碼位,最大值是 0x7FF ,因此編碼範圍爲 U+0080U+07FF ;以 1110 開頭的字節爲 三字節 編碼,總共 16 個有效編碼位,最大值是 0xFFFF 因此編碼範圍爲 U+0800U+FFFF

根據開頭不同, UTF-8 流中的字節,可以分爲以下幾類:

字節最高位 類別 有效位
0 單字節編碼 7
10 多字節編碼非首字節
110 雙字節編碼首字節 11
1110 三字節編碼首字節 16
11110 四字節編碼首字節 21
111110 五字節編碼首字節 26
1111110 六字節編碼首字節 31

至此,我們已經具備了讀懂 UTF-8 編碼字節流的能力,不信來看一個例子:

概念回顧

一直以來,字符集編碼 這兩個詞一直是混着用的。現在,我們總算有能力釐清這兩者間的關係了。

字符集 顧名思義,就是由一定數量字符組成的集合,每個字符在集合中有唯一編號。前文提及的 ASCIIlatin1GB2312GBKGB18030 以及 Unicode 等,無一例外,都是字符集。

計算機存儲和網絡通訊的基本單位都是 字節 ,因此文本必須以 字節序列 的形式進行存儲或傳輸。那麼,字符編號如何轉化成字節呢?這就是 編碼 要回答的問題。

ASCII 碼和 latin 中,字符編號與字節一一對應,這是一種編碼方式。GB2312 則採用變長字節,這是另一種編碼方式。而 Unicode 則存在多種編碼方式,除了 最常用的 UTF-8 編碼,還有 UTF-16 等。實際上,UTF-16 編碼效率比 UTF-8 更高,但由於無法兼容 ASCII ,應用範圍受到很大制約。

最佳實踐

認識文本編碼的前世今生之後,應該如何規避編碼問題呢?是否存在一些最佳實踐呢?答案是肯定的。

編碼選擇

項目開始前,需要選擇一種適應性廣的編碼方案,UTF-8 是首選,好處多多:

  • Unicode 是業界標準,編碼字符數量最多,天然支持國際化;
  • UTF-8 完全兼容 ASCII 碼,這是硬性指標;
  • UTF-8 目前應用最廣;

如因歷史原因,不得不使用中文編碼方案,則優先選擇 GB18030 。這個標準最新,涵蓋字符最多,適應性最強。儘量避免採用 GBK ,特別是 GB2312 等老舊編碼標準。

編程習慣

如果你使用的編程語言,字符串類型支持 Unicode ,那問題就簡單了。由於 Unicode 字符串肯定不會導致諸如亂碼等編碼問題,你只需在輸入和輸出環節稍加留意。

舉個例子,Python3 以後, str 就是 Unicode 字符串了,而 bytes 則是 字節序列 。因此,在 Python 3 程序中,核心邏輯應該統一用 str 類型,避免使用 bytes 。文本編碼、解碼操作則統一在程序的輸入、輸出層中進行。

假如你正在開發一個 API 服務,數據庫數據編碼是 GBK ,而用戶卻使用 UTF-8 編碼。那麼,在程序 輸入層GBK 數據從數據庫讀出後,解碼轉換成 Unicode 數據,再進入核心層處理。在程序 核心層 ,數據以 Unicode 形式進行加工處理。由於核心層處理邏輯可能很複雜,統一採用 Unicode 可以減少問題的發生。最後,在程序的 輸出層 將數據以 UTF-8 編碼,再返回給客戶端。

整個過程僞代碼大概如下:

# input
# read gbk data from database and decode it to unicode
data = read_from_database().decode('gbk')

# core
# process unicode data only
result = process(data)

# output
# encoding unicode data into utf8
response_to_user(result.encode('utf8'))

這樣的程序結構看起來跟個三明治一樣,非常形象:

當然了,還有很多編程語言字符串還不支持 UnicodePython 2 中的 str 對象,跟 Python 3 中的 bytes 比較像,只是字節序列;C 語言中的字符串甚至更原始。

這都無關緊要,好的編程習慣是相通的:程序核心層統一使用某種編碼,輸入輸出層則負責編碼轉換。至於核心層使用何種編碼,主要看程序中哪種編碼使用最多,一般是跟數據庫編碼保持一致即可。

附錄

更多 Python 技術文章,請查看:Python語言小冊 ,轉至 原文 可獲得最佳閱讀體驗。

訂閱更新,獲取更多學習資料,請關注我們的 微信公衆號
小菜學編程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章