字串編碼入門科普

bestswifter發表於2019-03-04

背景

對於單純做前端或者後端的同學來說,一般很難接觸到編碼問題,因為在同一個平臺上,一般都是使用同一種編碼方式,自然問題不大。但對於寫爬蟲的同學來說,編碼很可能是遇到的第一個坑。這是因為字串無法直接通過網路被傳輸(也不能直接被儲存),需要先轉換成二進位制格式,再被還原。因此凡是涉及到通過網路傳輸字元的地方,通常都容易遇到編碼問題。

概念定義

為了方便解釋,我們首先來定義一些概念。每個開發者都知道字串,它是一些字元的集合, 比如 hello world 就是一個最常見的字串。相對來說,字元 比較難定義一些。從語義上來講,它是組成字串的最基本單位,比如這裡的字母、空格,以及標點、特定語言(中文、日文)、emoji 符號等等。

字元是語言中的概念,但是計算機只認識 0 和 1 這兩個數字。因此要想讓計算機儲存、處理字串,就必須把字串用二進位制表示出來。在 ASCII 碼中,每個英文字母都有自己對應的數字。我們通常把 ASCII 碼稱為字符集,也就是字元的集合。瞭解 ASCII 碼的同學應該都知道小寫字母 a 可以用 97 來表示,97 也被稱為字元 a 在 ASCII 字符集中的碼位

如果要設計一種密碼,最簡單的方式就是把字母轉換成它在 ASCII 碼中的碼位再傳送,接受者則查詢 ASCII 碼錶,還原字元。可見把字元轉換成碼位的過程類似於加密(encrypt),我們稱之為編碼(encode),反則則類似於解密,我們稱之為解碼(decode)

圖片
圖片

編碼方式

字元轉換成碼位的過程是編碼,這個過程有無數種實現方式。比如 a -> 97b -> 98 這種就是 ASCII 編碼,因為 255 = 2 ^ 8,所以所有 ASCII 編碼下的碼位恰好都可以由一個位元組表示。

ASCII 比較誕生得比較早,隨著越來越多的國家開始使用計算機,0-255 這麼點碼位肯定不夠用了。比如中國人為了展示漢字,發明了 GB2312 編碼。GB2312編碼完全向下相容 ASCII 編碼,也就是說 所有 ASCII 字符集中的字元,它在 GB2312 編碼下的碼位與 ASCII 編碼下的碼位完全一致,而中文則由兩個位元組表示,這也就是為什麼早期我們一般認為一箇中文等於兩個英文的原因。

Unicode

除了中國人自己的編碼方式,各個地區的人也都根據自己的語言擴充了相應的編碼方式。那麼問題就來了, 給你一個碼位 0xEE 0xDD,它到底表示什麼字元,取決於它是用哪種編碼方式編碼的。這就好比你拿到了密文,但沒有密碼錶一樣。因此,要想正確顯示一種語言,就必須攜帶這個語言的編碼規範,要想正確顯示世界上所有的語言,看起來就比較困難了。

因此 Unicode 實際上是一種統一的字符集規範,每一個 Unicode 字元由 6 個十六進位制數字表示,因此理論上可以表示出 16 ^ 6 = 16777216 個字元,顯然是綽綽有餘了。

Unicode 編碼怎麼樣

看起來 Unicode 就是一種很棒的編碼方式。誠然,Unicode 可以表示所有的字元,但過於全面帶來的缺點就是過於龐大。對於字元 a 來說,如果使用 ASCII 編碼,可以表示為 0x61,只要一個位元組就能存下,但它的 Unicode 碼位是 0x000061,需要三個位元組。因此採用 Unicode 編碼的英文內容,會比 ASCII 編碼大三倍。這大大增加了檔案本地儲存時佔用的空間以及傳輸時的體積。

因此,我們有了對 Unicode 字元再次編碼的編碼方式,常見的有 utf-8,utf-16 等。UTF 表示 Unicode Transfer Format,因此是針對 Unicode 字符集的一系列編碼方式。utf-8 是一種變長編碼,也就是說不同的 Unicode 字元在 utf-8 編碼下的碼位長度可能不同,如下表所示:

Unicode 編碼(16進位制) utf-8 碼位(二進位制)
000000-00007F 0xxxxxxx
000080-0007FF 110xxxxx 10xxxxxx
000800-00FFFF 1110xxxx 10xxxxxx 10xxxxxx
010000-1FFFFF 11110xxx10xxxxxx10xxxxxx10xxxxxx

這個表有兩點值得注意。一個是 ASCII 字符集中的所有字元,它們的 utf-8 碼位依然佔用一個位元組,因此 utf-8 編碼下的英文字元不會向 Unicode 一樣增加大小。另一個則是所有中文的 utf-8 碼位都佔用 3 個位元組,大於 GBK 編碼的 2 位元組。因此如果存在明確的業務需要,是可以用 GBK 編碼取代 utf-8 編碼的。

圖片
圖片

儘管 utf-8 非常常用,但它可變長度的特點不僅會導致某些場景下內容過大,也為索引和隨機讀取帶來了困難。因此在很多作業系統的記憶體運算中,通常使用 utf-16 編碼來代替。utf-16 的特點是所有碼位的最小單位都是 2 位元組,雖然存在冗餘,但易於索引。由於碼位都是兩個位元組,就會存在位元組序的問題。因此 utf-16 編碼的字串,一開頭會有幾個位元組的 BOM(Byte order markd)來標記位元組序,比如 0xFF 2(FE0x55,254) 表示 Intel CPU 的小位元組序,如果不加 BOM 則預設表示大位元組序。需要注意的是,某些應用會給 utf-8 編碼的位元組也加上 BOM。

雖然看起來問題變得複雜了,為了儲存/傳輸一個字元,竟然需要兩次編碼,但別忘了,Unicode 編碼是通用的,因此可以內建於作業系統內部。所以我們平時所謂的對字串進行 utf-8 編碼,其實說的是對字串的 Unicode 碼位進行 utf-8 編碼。

這一點在 python3 中得到了充分的體現,字串由字元組成,每一個字元都是一個 Unicode 碼位。

編解碼錯誤處理

如果把編解碼理解成利用密碼錶進行加解密,那麼就容易理解,為什麼編碼和解碼過程都是易錯的。

如果被編碼的 Unicode 字元,在某種編碼中根本沒有列出編碼方式,這個字元就無法被編碼:

city = 'São Paulo'
b_city = city.encode('cp437')
# UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>

b_city = city.encode('cp437', errors='ignore') 
# b'So Paulo'

b_city = city.encode('cp437', errors='replace')
# b'S?o Paulo'複製程式碼

同理,如果被解碼的碼位,在編碼表中找不到與之對應的字元,這個碼位就無法被解碼:

octets = b'Montr\xe9al'
s_octest1 = octets.decode('utf8')
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte

s_octest1 = octets.decode('cp1252')
# Montréal

s_octest1 = octets.decode('iso8859_7')
# Montrιal

s_octest1 = octets.decode('utf8', errors='replace')
# Montr�al複製程式碼

python 的解決方案是,encodedecode 函式都有一個引數 errors 可以控制如何處理無法被編、解碼的內容。它的值可以是 ignore(忽略這個錯誤並繼續執行),也可以是 replace(用系統的佔位符填充)。

一般來說,無法從碼位推斷出編碼方式,就像你不可能從密文推斷出加密方式一樣。但是某些編碼方式會留下非常顯著的特徵,一旦這些特徵頻繁出現,基本就可以斷定編碼方式。Python 提供了一個名為 Chardet 的包,可以幫助開發者推斷出編碼方式,並且會給出相應的置信度。置信度越高,說明是這種編碼方式的可能性越大。

octets = b'Montr\xe9al'
chardet.detect(octets)
# {'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}

octets.decode('ISO-8859-1')
# Montréal複製程式碼

總結

  1. 編碼是為了把人類人類可讀的字元轉換成計算機容易儲存和傳輸的二進位制,解碼反之,編碼後得到的結果稱之為碼位。
  2. Unicode 是一種通用字符集,從字元到 Unicode 字符集中碼位的轉換也可以叫做 Unicode 編碼
  3. Unicode 編碼對英文字元不友好,因此出現了針對 Unicode 碼位的再次編碼,比如 utf-8,希望在節省空間的同時保留強大的表達能力
  4. 各個編碼之間的關係如下圖所示:

相關文章