一直以來,編碼問題像幽靈一般,不少開發人員都受過它的困擾。
試想你請求一個數據,卻得到一堆亂碼,丈二和尚摸不着頭腦。有同事質疑你的數據是亂碼,雖然你很確定傳了 UTF-8 ,卻也無法自證清白,更別說幫同事 debug 了。
有時,靠着百度和一手瞎調的手藝,亂碼也能解決。盡管如此,還是很羡慕那些骨灰級程序員。為什么他們每次都能犀利地指出問題,並快速修復呢?原因在於,他們早就把編碼問題背后的各種來龍去脈搞清楚了。
本文從 ASCII 碼說起,帶你扒一扒編碼背后那些事。相信搞清編碼的原理后,你將不再畏懼任何編碼問題。
從 ASCII 碼說起
現代計算機技術從英文國家興起,最先遇到的也是英文文本。英文文本一般由 26 個字母、 10 個數字以及若干符號組成,總數也不過 100 左右。
計算機中最基本的存儲單位為 字節 ( byte ),由 8 個比特位( bit )組成,也叫做 八位字節 ( octet )。8 個比特位可以表示 $ 2^8 = 256 $ 個字符,看上去用字節來存儲英文字符即可?
計算機先驅們也是這么想的。他們為每個英文字符編號,再加上一些控制符,形成了我們所熟知的 ASCII 碼表。實際上,由於英文字符不多,他們只用了字節的后 7 位而已。
根據 ASCII 碼表,由 01000001 這 8 個比特位組成的八位字節,代表字母 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
那么,怎么兼容 GB2312 和 ASCII 碼呢?天無絕人之路, 變長 編碼方案應運而生。
變長編碼方案,字符由長度不一的字節表示,有些字符只需 1 字節,有些需要 2 字節,甚至有些需要更多字節。GB2312 中的 ASCII 碼與原來保持一致,還是用一個字節來表示,這樣便解決了兼容問題。
在 GB2312 中,如果一個字節最高位 b8 為 0 ,該字節便是單字節編碼,即 ASCII 碼。如果字節最高位 b8 為 1 ,它就是雙字節編碼的首字節,與其后字節一起表示一個字符。
變長編碼方案目的在於兼容 ASCII 碼,但也帶來一個問題:由於字節編碼長度不一,定位第 N 個字符只能通過遍歷實現,時間復雜度從 $ O(1) $ 退化到 $ O(N) $ 。好在這種操作場景並不多見,因此影響可以忽略。
GB2312 收錄的漢字個數只有常用的 6 千多個,遇到生僻字還是無能為力。因此,后來又推出了 GBK 和 GB18030 字符集。GBK 是 GB2312 的超集,完全兼容 GB2312 ;而 GB18030 又是 GBK 的超集,完全兼容 GBK 。
因此,對中文編碼文本進行解碼,指定 GB18030 最為健壯:
>>> raw = b'\xfd\x88\xb5\xc4\xb4\xab\xc8\xcb'
>>> raw.decode('gb18030')
'龍的傳人'
指定 GBK 或 GB2312 就只好看運氣了,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
掌握 GB2312 、 GBK 、 GB18030 三者的關系后,我們可以略施小計。如果 chardet 檢測出來結果是 GB2312 ,就用 GB18030 去解碼,大概率可以成功!
>>> raw.decode('GB18030')
'中國人是龍的傳人'
Unicode
GB2312 、 GBK 與 GB18030 都是中文編碼字符集。雖然 GB18030 也包含日韓表意文字,算是國際字符集,但畢竟是以中文為主,無法適應全球化應用。
在計算機發展早期,不同國家都推出了自己的字符集和編碼方案,互不兼容。中文編碼的文本在使用日文編碼的系統上是無法顯示的,這就給國際交往帶來障礙。
這時,英雄出現了。統一碼聯盟 站出來說要發展一個通用的字符集,收錄世界上所有字符,這就是 Unicode 。經過多年發展, Unicode 已經成為世界上最通用的字符集,也是計算機科學領域的業界標准。
Unicode 已經收錄的字符數量已經超過 13 萬個,每個字符需占用超過 2 字節。由於常用編程語言一般沒有 24 位數字類型,因此一般用 32 位數字表示一個字符。這樣一來,同樣的一個英文字母,在 ASCII 中只需占用 1 字節,在 Unicode 則需要占用 4 字節!英美人民都要哭了,試想你磁盤中的文件大小都增大了 4 倍是什么感受!
UTF-8
為了兼容 ASCII 並優化文本空間占用,我們需要一種變長字節編碼方案,這就是著名的 UTF-8 。與 GB2312 等中文編碼一樣,UTF-8 用不固定的字節數來表示字符:
- ASCII 字符 Unicode 碼位由 U+0000 至 U+007F ,用 1 個字節編碼,最高位為 0 ;
- 碼位由 U+0080 至 U+07FF 的字符,用 2 個字節編碼,首字節以 110 開頭,其余字節以 10 開頭;
- 碼位由 U+0800 至 U+FFFF 的字符,用 3 個字節編碼,首字節以 1110 開頭,其余字節同樣以 10 開頭;
- 4 至 6 字節編碼的情況以此類推;
如圖,以 0 開頭的字節為 單字節 編碼,總共 7 個有效編碼位,編碼范圍為 U+0000 至 U+007F ,剛好對應 ASCII 碼所有字符。以 110 開頭的字節為 雙字節 編碼,總共 11 個有效編碼位,最大值是 0x7FF ,因此編碼范圍為 U+0080 至 U+07FF ;以 1110 開頭的字節為 三字節 編碼,總共 16 個有效編碼位,最大值是 0xFFFF 因此編碼范圍為 U+0800 至 U+FFFF 。
根據開頭不同, UTF-8 流中的字節,可以分為以下幾類:
字節最高位 | 類別 | 有效位 |
---|---|---|
0 | 單字節編碼 | 7 |
10 | 多字節編碼非首字節 | |
110 | 雙字節編碼首字節 | 11 |
1110 | 三字節編碼首字節 | 16 |
11110 | 四字節編碼首字節 | 21 |
111110 | 五字節編碼首字節 | 26 |
1111110 | 六字節編碼首字節 | 31 |
至此,我們已經具備了讀懂 UTF-8 編碼字節流的能力,不信來看一個例子:
概念回顧
一直以來,字符集 和 編碼 這兩個詞一直是混着用的。現在,我們總算有能力厘清這兩者間的關系了。
字符集 顧名思義,就是由一定數量字符組成的集合,每個字符在集合中有唯一編號。前文提及的 ASCII 、 latin1 、 GB2312 、GBK 、GB18030 以及 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 字符串肯定不會導致諸如亂碼等編碼問題,你只需在輸入和輸出環節稍加留意。
舉個例子,Python 從 3 以后, 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'))
這樣的程序結構看起來跟個三明治一樣,非常形象:
當然了,還有很多編程語言字符串還不支持 Unicode 。Python 2 中的 str 對象,跟 Python 3 中的 bytes 比較像,只是字節序列;C 語言中的字符串甚至更原始。
這都無關緊要,好的編程習慣是相通的:程序核心層統一使用某種編碼,輸入輸出層則負責編碼轉換。至於核心層使用何種編碼,主要看程序中哪種編碼使用最多,一般是跟數據庫編碼保持一致即可。
附錄
更多 Python 技術文章,請查看:Python語言小冊 ,轉至 原文 可獲得最佳閱讀體驗。
訂閱更新,獲取更多學習資料,請關注我們的 微信公眾號 :