天天看點

《流暢的Python》讀書筆記——Python文本和位元組序列

字元問題

從Python3的str對象擷取的元素時Unicode字元,相當于從Python2的unicode對象中擷取的元素,而不是從Python2的str對象中擷取的原始位元組序列。

把碼位(字元的辨別)轉換成位元組序列的過程是編碼;把位元組序列轉換成碼位的過程是解碼:

>>> s = 'café' 
>>> len(s)#4個Unicode字元
4
>>> b = s.encode('utf8')#使用UTF-8把str對象編碼成bytes對象
>>> b
b'caf\xc3\xa9' #bytes對象以b開頭
>>> len(b)# 位元組序列b有5個位元組(UTF-8中,"é"的碼位編碼成兩個位元組)
5
>>> b.decode('utf8')#解碼成str對象
'café'      

雖然 Python 3 的 str 類型基本相當于 Python 2 的 unicode 類型,隻不

過是換了個新名稱,但是 Python 3 的 bytes 類型卻不是把 str 類型換

個名稱那麼簡單,而且還有關系緊密的 bytearray 類型。

位元組概要

Python 内置了兩種基本的二進制序列類型:Python 3 引入的不可變

bytes 類型和 Python 2.6 添加的可變 bytearray 類型。

bytes 或 bytearray 對象的各個元素是介于 0~255(含)之間的整

數,而不像 Python 2 的 str 對象那樣是單個的字元。然而,二進制序列

的切片始終是同一類型的二進制序列,包括長度為 1 的切片

>>> cafe = bytes('café',encoding='utf_8')#另一種構造bytes的方式
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]#每個元素時range(256)内的整數
99
>>> cafe[:1]#bytes對象的切片還是bytes對象,注意和上面通過索引通路的差別
b'c'
>>> cafe_arr = bytearray(cafe)#構造bytearray
>>> cafe_arr[-1:]#bytearray對象的切片還是bytearray對象
bytearray(b'\xa9')      

雖然二進制序列其實是整數序列,但是它們的字面量表示法表明其中有ASCII 文本。是以,各個位元組的值可能會使用下列三種不同的方式顯示。

  • 可列印的 ASCII 範圍内的位元組(從空格到 ~),使用 ASCII 字元本身。
  • 制表符、換行符、回車符和 \ 對應的位元組,使用轉義序列\t、\n、\r 和 \。
  • 其他位元組的值,使用十六進制轉義序列(例如,\x00 是空位元組)。

我們看到的是 b’caf\xc3\xa9’:前 3 個位元組b’caf’ 在可列印的 ASCII 範圍内,後兩個位元組使用十六進制轉義序列。

使用數組中的原始資料初始化 bytes 對象

>>> import array
>>> numbers = array.array('h',[-2,-1,0,1,2])#指定類型代碼h,建立一個短整數(16位)數組
>>> octets = bytes(numbers)
>>> octets 
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'#表示那5個短整數的10個位元組      

結構體和記憶體視圖

struct 子產品提供了一些函數,把打包的位元組序列轉換成不同類型字段

組成的元組,還有一些函數用于執行反向轉換,把元組轉換成打包的字

節序列。struct 子產品能處理 bytes、bytearray 和 memoryview對象。

memoryview 類不是用于建立或存儲位元組序列的,而是共享記憶體,讓你通路其他二進制序列、打包的數組和緩沖中的資料切片,而無需複制位元組序列。

使用 memoryview 和 struct 檢視一個 GIF 圖像的首部:

>>> import struct
>>> fmt = '<3s3sHH' #結構體的格式:<是小位元組序,3s3s是兩個3位元組序列,HH是兩個16位二進制整數 其實就是gif圖形首部的格式
>>> with open('test.gif','rb') as fp:
...      img = memoryview(fp.read())# 使用記憶體中的檔案内容建立一個memoryview對象
... 
>>> header = img[:10] #memoryview 對象的切片是一個新 memoryview 對象,而且不會複制位元組序列
>>> bytes(header)#轉換成位元組序列
b'GIF89aJ\x01\x87\x00'
>>> struct.unpack(fmt,header)#拆包memoryview對象,得到一個元組,包含類型、版本、寬度和 高度
(b'GIF', b'89a', 330, 135)
>>> del header# 釋放資源
>>> del      

基本的編碼解碼器

Python 自帶了超過 100 種編解碼器(codec, encoder/decoder),用于在文本和位元組之間互相轉換。每個編解碼器都有一個名稱,如’utf_8’,而且經常有幾個别名,如 ‘utf8’、‘utf-8’ 和 ‘U8’。這

些名稱可以傳給 open()、str.encode()、bytes.decode() 等函數的 encoding 參數。

使用 3 個編解碼器把相同的文本編碼成不同的位元組序列:

>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
...     print(codec,'El Niño'.encode(codec), sep='\t')
... 
latin_1 b'El Ni\xf1o'
utf_8 b'El Ni\xc3\xb1o'
utf_16  b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'      

編碼解碼問題

雖然有個一般性的 ​

​UnicodeError​

​​異常,但是報告錯誤時幾乎都會指

明具體的異常:​​

​UnicodeEncodeError​

​​(把字元串轉換成二進制序列

時)或 ​​

​UnicodeDecodeError​

​​(把二進制序列轉換成字元串時)。如

果源碼的編碼與預期不符,加載 Python 子產品時還可能抛出

​​

​SyntaxError​

​。

處理UnicodeEncodeError

多數非 UTF 編解碼器隻能處理 Unicode 字元的一小部分子集。把文本轉

換成位元組序列時,如果目标編碼中沒有定義某個字元,那就會抛出

UnicodeEncodeError 異常,除非把 errors 參數傳給編碼方法或函

數,對錯誤進行特殊處理:

>>> city = 'São Paulo'
>>> city.encode('utf_8') #'utf_?'編碼能處理任何字元串
b'S\xc3\xa3o Paulo'
>>> city.encode('utf_16')
b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
>>> city.encode('iso8859_1') #'iso8859_1'也能處理該字元串
b'S\xe3o Paulo'
>>> city.encode('cp437')#'cp437'無法編碼'ã',預設的錯誤處理方式抛出UnicodeEncodeError
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.6/lib/python3.6/encodings/cp437.py", line 12, in encode
    return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
>>> city.encode('cp437',errors='ignore')#處理方式悄無聲息地跳過無法編碼的字元;這樣做通常很是不妥
b'So Paulo'
>>> city.encode('cp437',errors='replace')#把無法編碼的字元替換成 '?';資料損壞了,但是使用者知道出了問題
b'S?o Paulo'
>>> city.encode('cp437',errors='xmlcharrefreplace') #把無法編碼的字元替換成 XML 實體。
b'São Paulo'      

處理UnicodeDecodeError

不是每一個位元組都包含有效的 ASCII 字元,也不是每一個字元序列都是

有效的 UTF-8 或 UTF-16。是以,把二進制序列轉換成文本時,如果假

設是這兩個編碼中的一個,遇到無法轉換的位元組序列時會抛出

​​

​UnicodeDecodeError​

​。

亂碼字元稱為鬼符(gremlin)或 mojibake(文字化け,“變形文本”的日文)

下面示範了使用錯誤的編解碼器可能出現鬼符或抛出​

​UnicodeDecodeError​

​:

>>> octets = b'Montr\xe9al'  #使用 latin1 編碼的“Montréal”;'\xe9' 位元組對應“é”。
>>> octets.decode('cp1252') #可以使用 'cp1252'(Windows 1252)解碼,因為它是 latin1 的有效超集
'Montréal'
>>> octets.decode('iso8859_7')#ISO-8859-7 用于編碼希臘文,是以無法正确解釋 '\xe9' 位元組,而且沒有抛出錯誤
'Montrιal'
>>> octets.decode('koi8_r')#KOI8-R 用于編碼俄文;這裡,'\xe9' 表示西裡爾字母“И”
'MontrИal'
>>> octets.decode('utf_8') #'utf_8' 編解碼器檢測到 octets 不是有效的 UTF-8 字元串,抛出UnicodeDecodeError。
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte
>>> octets.decode('utf_8',errors='replace') #使用 'replace' 錯誤處理方式,\xe9 替換成了�,表示未知字元
'Montr�al'      

使用預期之外的編碼加載子產品時抛出的SyntaxError

Python 3 預設使用 UTF-8 編碼源碼,Python 2(從 2.5 開始)則預設使用

ASCII。如果加載的 .py 子產品中包含 UTF-8 之外的資料,而且沒有聲明

編碼,會得到類似下面的消息:

SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line
1, but no encoding declared; see http://python.org/dev/peps/pep-0263/
for details      

為了修正這個問題,可以在檔案頂部添加一個神奇的 coding 注釋:

(也可以通過utf8編碼解碼python2中的中文注釋問題)

# coding: cp1252
print('Olá, Mundo!')      

Python 3 允許在源碼中使用非 ASCII 辨別符。

如何找出位元組序列的編碼

如何找出位元組序列的編碼?簡單來說,不能。必須有人告訴你。

然而,就像人類語言也有規則和限制一樣,隻要假定位元組流是人類可讀

的純文字,就可能通過試探和分析找出編碼。例如,如果 b’\x00’ 字

節經常出現,那麼可能是 16 位或 32 位編碼,而不是 8 位編碼方案,因

為純文字中不能包含空字元;

二進制序列編碼文本通常不會明确指明自己的編碼,但是 UTF 格式可

以在文本内容的開頭添加一個位元組序标記

BOM:有用的鬼符

你可能注意到了,UTF-16 編碼的序列開頭有幾個額外的位元組,如下所示:

>>> u16 = 'El Niño'.encode('utf_16')
>>> u16
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'      

指的是 ​

​b'\xff\xfe'​

​。這是 BOM,即位元組序标記(byte-order mark),指明編碼時使用 Intel CPU 的小位元組序。

在小位元組序裝置中,各個碼位的最低有效位元組在前面:字母 ‘E’ 的碼

位是 U+0045(十進制數 69),在位元組偏移的第 2 位和第 3 位編碼為 69和 0。

在大位元組序 CPU 中,編碼順序是相反的;‘E’ 編碼為 0 和 69。

UTF-8 的一大優勢是,不管裝置使用哪種位元組序,生成的位元組序列始終一緻,是以不需要 BOM。

盡管如此,某些Windows 應用(尤其是 Notepad)依然會在 UTF-8 編碼的檔案中添加

BOM;而且,Excel 會根據有沒有 BOM 确定檔案是不是 UTF-8 編碼,

否則,它假設内容使用 Windows 代碼頁(codepage)編碼。

《流暢的Python》讀書筆記——Python文本和位元組序列

難怪NodePad++會有無BOM格式編碼這個選項。

處理文本檔案

《流暢的Python》讀書筆記——Python文本和位元組序列

處理文本的最佳實踐是“Unicode 三明治”。 意思是,

要盡早把輸入(例如讀取檔案時)的位元組序列解碼成字元串。這種三明

治中的“肉片”是程式的業務邏輯,在這裡隻能處理字元串對象。在其他

處理過程中,一定不能編碼或解碼。對輸出來說,則要盡量晚地把字元

串編碼成位元組序列。

在 Python 3 中能輕松地采納 Unicode 三明治的建議,因為内置的 open

函數會在讀取檔案時做必要的解碼,以文本模式寫入檔案時還會做必要

的編碼,是以調用 my_file.read() 方法得到的以及傳給

my_file.write(text) 方法的都是字元串對象。

可以看出,處理文本檔案很簡單。但是,如果依賴預設編碼,你會遇到麻煩。

>>> open('cafe.txt', 'w', encoding='utf_8').write('café')
4
>>> open('cafe.txt').read()
'café'      
一個平台上的編碼問題(如果在你的機器上運作,它可能會發生,也可能不會)

寫入檔案時指定了 UTF-8 編碼,但是讀取檔案時沒有這麼做,

是以 Python 假定要使用系統預設的編碼(Windows 1252),于是檔案的

最後一個位元組解碼成了字元 ‘é’,而不是 ‘é’。

需要在多台裝置中或多種場合下運作的代碼,一定不能依賴

預設編碼。打開檔案時始終應該明确傳入 ​

​encoding=​

​​ 參數,因為

不同的裝置使用的預設編碼可能不同,有時隔一天也會發生變化。

下面展示一個奇怪的細節:第一個語句中的 write 函數報告寫入了 4

個字元,但是下一行讀取時卻得到了 5 個字元。

>>> fp = open('cafe.txt','w',encoding='utf_8')
>>> fp
<_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'>
>>> fp.write('café') #傳回寫入的Unicode字元數
4
>>> fp.close()
>>> import os
>>> os.stat('cafe.txt').st_size # os.stat 報告檔案中有 5 個位元組;UTF-8 編碼的 'é' 占兩個位元組,0xc3 和 0xa9
5
>>> fp2 = open('cafe.txt') #打開文本檔案時沒有顯式指定編碼
>>> fp2
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='UTF-8'>
>>> fp2.encoding
'UTF-8'
>>> fp2.read()
'café'      

為了正确比較而規範化Unicode字元串

因為 Unicode 有組合字元,是以字元串比較起來很複雜。

例如,“café”這個詞可以使用兩種方式構成,分别有 4 個和 5 個碼位,

但是結果完全一樣:

>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1,s2
('café', 'café')
>>> len(s1),len(s2)
(4, 5)
>>> s1 == s2
False      

這個問題的解決方案是使用 unicodedata.normalize 函數提供的

Unicode 規範化。這個函數的第一個參數是這 4 個字元串中的一

個:‘NFC’、‘NFD’、‘NFKC’ 和 ‘NFKD’。

NFC(Normalization Form C)使用最少的碼位構成等價的字元串,而

NFD 把組合字元分解成基字元和單獨的組合字元。這兩種規範化方式都

能讓比較行為符合預期:

>>> from unicodedata import normalize
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> len(s1),len(s2)
(4, 5)
>>> len(normalize('NFC',s1)),len(normalize('NFC',s2))
(4, 4)
>>> len(normalize('NFD',s1)),len(normalize('NFD',s2))
(5, 5)
>>> normalize('NFC',s1) == normalize('NFC',s2)
True
>>> normalize('NFD',s1) == normalize('NFD',s2)
True      

在另外兩個規範化形式(NFKC 和 NFKD)的首字母縮略詞中,字母 K

表示“compatibility”(相容性)。

下面是 NFKC 的具體應用:

>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize('NFKC',half)
'1⁄2'
>>> four_squared = '4²'
>>> normalize('NFKC',four_squared)
'42'
>>> micro = 'μ'
>>> micro_kc = normalize('NFKC',micro)
>>> micro,micro_kc
('μ', 'μ')
>>> ord(micro), ord(micro_kc)
(956, 956)
>>> name(micro), name(micro_kc)
('GREEK SMALL LETTER MU', 'GREEK SMALL LETTER MU')      

使用 ‘1/2’ 替代 ‘½’ 可以接受,微符号也确實是小寫的希臘字母

‘μ’,但是把 ‘4²’ 轉換成 ‘42’ 就改變原意了。

Unicode文本排序

Python 比較任何類型的序列時,會一一比較序列裡的各個元素。對字元

串來說,比較的是碼位。可是在比較非 ASCII 字元時,得到的結果不盡

如人意。

下面對一個生長在巴西(作者是巴西人)的水果的清單進行排序:

>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted(fruits)
['acerola', 'atemoia', 'açaí', 'caju', 'cajá']      

排序時“cajá”視作“caja”,必定排在“caju”前面。

排序後的 fruits 清單應該是:

​['açaí', 'acerola', 'atemoia', 'cajá', 'caju']​

>>> import locale
>>> locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
'pt_BR.UTF-8'
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=locale.strxfrm)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']      
  • 區域設定是全局的,是以不推薦在庫中調用 setlocale 函數。應用或架構應該在程序啟動時設定區域設定,而且此後不要再修改。
  • 作業系統必須支援區域設定,否則 setlocale 函數會抛出locale.Error: unsupported locale setting 異常。