Python學習之路23-文字和位元組序列

VPointer發表於2019-02-16

《流暢的Python》筆記。

本篇主要講述不同編碼之間的轉換問題,比較繁雜,如果平時處理文字不多,或者語言比較單一,沒有多語言文字處理的需求,則可以略過此篇。

1. 前言

本篇主要講述Python對文字字串的處理。主要內容如下:

  • 字符集基本概念以及Unicode;
  • Python中的位元組序列;
  • Python對編碼錯誤的處理以及BOM;
  • Python對文字檔案的編解碼,以及對Unicode字元的比較和排序,而這便是本篇的主要目的
  • 雙模式API和Unicode資料庫

如果對字元編碼很熟悉,也可直接跳過第2節。

2. 字符集相關概念

筆者在初學字符集相關內容的時候,對這個概念並沒有什麼疑惑:字符集嘛,就是把我們日常使用的字元(漢子,英文,符號,甚至表情等)轉換成為二進位制嘛,和摩斯電碼本質上沒啥區別,用數學的觀點就是一個函式變換,這有什麼好疑惑的?直到後來越來也多地接觸字元編碼,終於,筆者被這堆概念搞蒙了:一會兒Unicode編碼,一會兒又Unicode字符集,UTF-8編碼,UTF-16字符集還有什麼字元編碼、位元組序列。到底啥時候該叫“編碼”,啥時候該叫“字符集”?這些概念咋這麼相似呢?既然這麼相似,幹嘛取這麼多名字?後來仔細研究後發現,確實很多學術名次都是同義詞,比如“字符集”和“字元編碼”其實就是同義詞;有的譯者又在翻譯外國的書的時候,無意識地把一個概念給放大或者給縮小了。

說到這不得不吐槽一句,我們國家網際網路相關的圖書質量真的低。國人自己寫的IT方面的書,都不求有多經典,能稱為好書的都少之又少;而翻譯的書,要麼翻譯得晦澀難懂,還不如直接看原文;要麼故作風騷,非得體現譯者的文學修養有多“高”;要麼生造名詞,同一概念同一單詞,這本書裡你翻譯成這樣,另一本書裡我就偏要翻譯成那樣(你們這是在翻譯小說嗎)。所以勸大家有能力的話還是直接看原文吧,如果要買譯本,還請大家認真比較比較,否則讀起來真的很痛苦。

回到主題,我們繼續討論字符集相關問題。翻閱網上大量資料,做出如下總結。

2.1 基本概念

始終記住編碼的核心思想:就是給每個字元都對應一個二進位制序列,其他的所有工作都是讓這個過程更規範,更易於管理。

現代編碼模型將這個過程分了5個層次,所用的術語列舉如下(為了避免混淆,這裡不再列出它們的同義詞):

  1. 抽象字元表(Abstract character repertoire):

    系統支援的所有抽象字元的集合。可以簡單理解為人們使用的文字、符號等。

    這裡需要注意一個問題:有些語系裡面的字母上方或者下方是帶有特殊符號的,比如一點或者一撇;有的字元表裡面會將字母和特殊符號組合成一個新的字元,為它單獨編碼;有的則不會單獨編碼,而是字母賦予一個編碼,特殊符號賦予一個編碼,然後當這倆在文中相遇的時候再將這倆的編碼組合起來形成一個字元。後面我們會談到這個問題,這也是以前字元編碼轉換常出毛病的一個原因。

    提醒:雖然這裡扯到了編碼,但抽象字元表這個概念還和編碼沒有聯絡。

  2. 編碼字符集(Coded Character Set,CCS):字元 --> 碼位

    首先給出總結:編碼字符集就是用數字代替抽象字符集中的每一個字元!

    將抽象字元表中的每一個字元對映到一個座標(整數值對:(x, y),比如我國的GBK編碼)或者表示為一個非負整數N,便生成了編碼字符集。與之相應的還有兩個抽象概念:編碼空間(encoding space)、碼位(code point)和碼位值(code point value)。

    簡單的理解,編碼空間就相當於許多空位的集合,這些空位稱之為碼位,而這個碼位的座標通常就是碼位值。我們將抽象字符集中的字元與碼位一一對應,然後用碼位值來代表字元。以二維空間為例,相當於我們有一個10萬行的表,每一行相當於一個碼位,二維的情況下,通常行號就是碼位值(當然你也可以設定為其他值),然後我們把每個漢字放到這個表中,最後用行號來表示每一個漢字。**一個編碼字符集就是把抽象字元對映為碼位值。**這裡區分碼位和碼位值只是讓這個對映的過程更形象,兩者類似於座位和座位號的區別,但真到用時,並不區分這兩者,以下兩種說法是等效的:

    字元A的碼位是123456
    字元A的碼位值是123456(很少這麼說,但有這種說法)
    複製程式碼

    編碼空間並不只能是二維的,它也可以是三維的,甚至更高,比如當你以二維座標(x, y)來編號字元,並且還對抽象字符集進行了分類,那麼此時的編碼空間就可能是三維的,z座標表示分類,最終使用(x, y, z)在這個編碼空間中來定位字元。不過筆者還沒真見過(或者見過但不知道......)三維甚至更高維的編碼,最多也就見過變相的三維編碼空間。但編碼都是人定的,你也可以自己定一個編碼規則~~

    並不是每一個碼位都會被使用,比如我們的漢字有8萬多個,用10萬個數字來編號的話還會剩餘1萬多個,這些剩餘的碼位則留作擴充套件用。

    注意:到這一步我們只是將抽象字符集進行了編號,但這個編號並不一定是二進位制的,而且它一般也不是二進位制的,而是10進位制或16進位制。該層依然是個抽象層。

    而這裡之所以說了這麼多,就是為了和下面這個概念區分。

  3. 字元編碼表(Character Encoding Form,CEF):碼位 --> 碼元

    將編碼字符集中的碼位轉換成有限位元長度的整型值的序列。這個整型值的單位叫碼元(code unit)。即一個碼位可由一個或多個碼元表示。而這個整型值通常就是碼位的二進位制表示。

    到這裡才完成了字元到二進位制的轉換。程式設計師的工作通常到這裡就完成了。但其實還有後續兩步。

    注意:直到這裡都還沒有將這些序列存到儲存器中!所以這裡依然是個抽象,只是相比上面兩步更具體而已。

  4. 字元編碼方案(Character Encoding Scheme,CES):碼元 --> 序列化

    也稱為“serialization format”(常說的“序列化”)。將上面的整型值轉換成可儲存或可傳輸8位位元組序列。簡單說就是將上面的碼元一個位元組一個位元組的儲存或傳輸。每個位元組裡的二進位制數就是位元組序列。這個過程中還會涉及大小端模式的問題(碼元的低位位元組裡的內容放在記憶體地址的高位還是低位的問題,感興趣的請自行查閱,這裡不再贅述)。

    直到這時,才真正完成了從我們使用的字元轉換到機器使用的二進位制碼的過程。 抽象終於完成了例項化。

  5. 傳輸編碼語法(transfer encoding syntax):

    這裡則主要涉及傳輸的問題,如果用計算機網路的概念來類比的話,就是如何實現透明傳輸。相當於將上面的位元組序列的值對映到一個更受限的值域內,以滿足傳輸環境的限制。比如Email的Base64或quoted-printable協議,Base64是6bit作為一個單位,quoted-printable是7bit作為一個單位,所以我們得想辦法把8bit的位元組序列對映到6bit或7bit的單位中。另一個情況則是壓縮位元組序列的值,如LZW或程式長度編碼等無失真壓縮技術。

綜上,整個編碼過程概括如下:

字元 --> 碼位 --> 碼元 --> 序列化,如果還要在特定環境傳輸,還需要再對映。從左到右是編碼的過程,從右到左就是解碼的過程。

下面我們以Unicode為例,來更具體的說明上述概念。

2.2 統一字元編碼Unicode

每個國家每個地區都有自己的字元編碼標準,如果你開發的程式是面向全球的,則不得不在這些標準之間轉換,而許多問題就出在這些轉換上。Unicode的初衷就是為了避免這種轉換,而對全球各種語言進行統一編碼。既然都在同一個標準下進行編碼,那就不存在轉換的問題了唄。但這只是理想,至今都沒編完,所以還是有轉換的問題,但已經極大的解決了以前的編碼轉換的問題了。

Unicode編碼就是上面的編碼字符集CCS。而與它相伴的則是經常用到的UTF-8,UTF-16等,這些則是上面的字元編碼表CEF。

最新版的Unicode庫已經收錄了超過10萬個字元,它的碼位一般用16進製表示,並且前面還要加上U+,十進位制表示的話則是前面加&#,例如字母“A”的Unicode碼位是U+0041,十進位制表示為&#065

Unicode目前一共有17個Plane(面),從U+0000U+10FFFF,每個Plane包含65536(=2^16^)個碼位,比如英文字符集就在0號平面中,它的範圍是U+0000 ~ U+FFFF。這17個Plane中4號到13號都還未使用,而15、16號Plane保留為私人使用區,而使用的5個Plane也並沒有全都用完,所以Unicode還沒有很大的未編碼空間,相當長的時間內夠用了。

注意:自2003年起,Unicode的編碼空間被規範為了21bit,但Unicode編碼並沒有佔多少位之說,而真正涉及到在儲存器中佔多少位時,便到了字元編碼階段,即UTF-8,UTF-16,UTF-32等,這些字元編碼表在程式設計中也叫做編解碼器

UTF-n表示用n位作為碼元來編碼Unicode的碼位。以UTF-8為例,它的碼元是1位元組,且最多用4個碼元為Unicode的碼位進行編碼,編碼規則如下表所示:

Python學習之路23-文字和位元組序列

表中的×用Unicode的16進位制碼位的2進位制序列從右向左依次替換,比如U+07FF的二進位制序列為 :00000,11111,111111(這裡的逗號位置只是為了和後面作比較,並不是正確的位置);

那麼U+07FF經UTF-8編碼後的位元序列則為110 11111,10 111111,暫時將這個序列命名為a

至此已經完成了前3步工作,現在開始執行序列化:

如果CPU是大端模式,那麼序列a就是U+07FF在機器中的位元組序列,但如果是小端模式,序列a的這兩個位元組需要調換位置,變為10 111111,110 11111,這才是實際的位元組序列。

3. Python中的位元組序列

Python3明確區分了人類可讀的字串和原始的位元組序列。Python3中,文字總是Unicode,由str型別表示,二進位制資料由bytes型別表示,並且Python3不會以任何隱式的方式混用strbytes。Python3中的str型別基本相當於Python2中的unicode型別。

Python3內建了兩種基本的二進位制序列型別:不可變bytes型別和可變bytearray型別。這兩個物件的每個元素都是介於0-255之間的整數,而且它們的切片始終是同一型別的二進位制序列(而不是單個元素)。

以下是關於位元組序列的一些基本操作:

>>> "China".encode("utf8")  # 也可以 temp = bytes("China", encoding="utf_8")
b'China'
>>> a = "中國"
>>> utf = a.encode("utf8")
>>> utf
b'\xe4\xb8\xad\xe5\x9b\xbd'
>>> a
'中國'
>>> len(a)
2
>>> len(utf)
6
>>> utf[0]
228
>>> utf[:1]
b'\xe4'
>>> b = bytearray("China", encoding="utf8")   # 也可以b = bytearray(utf)
>>> b
bytearray(b'China')
>>> b[-1:]
bytearray(b'a')
複製程式碼

二進位制序列實際是整數序列,但在輸出時為了方便閱讀,將其進行了轉換,以b開頭,其餘部分:

  • 可列印的ASCII範圍內的位元組,使用ASCII字元本身;
  • 製表符、換行符、回車符和\對應的位元組,使用轉義序列\t\n\r\\
  • 其他位元組的值,使用十六進位制轉義序列,以\x開頭。

bytesbytesarray的構造方法如下:

  • 一個str物件和一個encoding關鍵字引數;
  • 一個可迭代物件,值的範圍是range(256)
  • 一個實現了緩衝協議的物件(如bytesbytearraymemoryviewarray.array),此時它將源物件中的位元組序列複製到新建的二進位制序列中。並且,這是一種底層操作,可能涉及型別轉換。

除了格式化方法(formatformat_map)和幾個處理Unicode資料的方法外,bytesbytearray都支援str的其他方法,例如bytes. endswithbytes.replace等。同時,re模組中的正規表示式函式也能處理二進位制序列(當正規表示式編譯自二進位制序列時會用到)。

二進位制序列有個str沒有的方法fromhex,它解析十六進位制數字對,構件二進位制序列:

>>> bytes.fromhex("31 4b ce a9")
b'1K\xce\xa9'
複製程式碼

補充:struct模組提供了一些函式,這些函式能把打包的位元組序列轉換成不同型別欄位組成的元組,或者相反,把元組轉換成打包的位元組序列。struct模組能處理bytesbytearraymemoryview物件。這個不是本篇重點,不再贅述。

4. 編解碼器問題

如第2節所述,我們常說的UTF-8,UTF-16實際上是字元編碼表,在程式設計中一般被稱為編解碼器。本節主要講述關於編解碼器的錯誤處理:UnicodeEncodeErrorUnicodeDecodeErrorSyntaxError

Python中一般會明確的給出某種錯誤,而不會籠統地丟擲UnicodeError,所以,在我們自行編寫處理異常的程式碼時,也最好明確錯誤型別。

4.1 UnicodeEncodeError

當從文字轉換成位元組序列時,如果編解碼器沒有定義某個字元,則有可能丟擲UnicodeEncodeError

>>> country = "中國"
>>> country.encode("utf8")
b'\xe4\xb8\xad\xe5\x9b\xbd'
>>> country.encode("utf16")
b'\xff\xfe-N\xfdV'
>>> country.encode("cp437")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "E:\Code\Python\Study\venv\lib\encodings\cp437.py", line 12, in encode
    return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode characters in position 0-1: character 
maps to <undefined>
複製程式碼

可以指定錯誤處理方式:

>>> country.encode("cp437", errors="ignore")  # 跳過無法編碼的字元,不推薦
b''
>>> country.encode("cp437", errors="replace") # 把無法編碼的字元替換成“?”
b'??'
>>> country.encode("cp437", errors="xmlcharrefreplace") # 把無法編碼的字元替換成XML實體
b'&#20013;&#22269;'
複製程式碼

4.2 UnicodeDecodeError

相應的,當從位元組序列轉換成文字時,則有可能發生UnicodeDecodeError

>>> octets.decode("cp1252")
'Montréal'
>>> octets.decode("iso8859_7")
'Montrιal'
>>> octets.decode("utf_8")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: 
invalid continuation byte

# 解碼錯誤的處理與4.1類似
>>> octets.decode("utf8", errors="replace")
# "�"字元是官方指定的替換字元(REPLACEMENT CHARACTER),表示未知字元,碼位U+FFFD
'Montr�al'  
複製程式碼

4.3 SyntaxError

當載入Python模組時,如果原始碼的編碼與檔案解碼器不符時,則會出現SyntaxError。比如Python3預設UTF-8編碼原始碼,如果你的Python原始碼編碼時使用的是其他編碼,而程式碼中又沒有宣告編解碼器,那麼Python直譯器可能就會發出SyntaxError。為了修正這個問題,可在檔案開頭指明編碼型別,比如表明編碼為UTF-8,則應在原始檔頂部寫下此行程式碼:#-*- coding: utf8 -*- ”(沒有引號!)

補充:Python3允許在原始碼中使用非ASCII識別符號,也就是說,你可以用中文來命名變數(笑。。。)。如下:

>>> 甲="abc"
>>> 'abc'
複製程式碼

但是極不推薦!還是老老實實用英文吧,哪怕拼音也行。

4.4 找出位元組序列的編碼

有時候一個檔案並沒有指明編碼,此時該如何確定它的編碼呢?實際並沒有100%確定編碼型別的方法,一般都是靠試探和分析找出編碼。比如,如果b"\x00"位元組經常出現,就很有可能是16位或32位編碼,而不是8位編碼。Chardet就是這樣工作的。它是一個Python庫,能識別所支援的30種編碼。以下是它的用法,這是在終端命令列中,不是在Python命令列中:

$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99
複製程式碼

4.5 位元組序標記BOM(byte-order mark)

當使用UTF-16編碼時,位元組序列前方會有幾個額外的位元組,如下:

>>> 'El Niño'.encode("utf16")
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'   # 注意前兩個位元組b"\xff\xfe"
複製程式碼

BOM用於指明編碼時使用的是大端模式還是小端模式,上述例子是小端模式。UTF-16在要編碼的文字前面加上特殊的不可見字元ZERO WIDTH NO-BREAK SPACE(U+FEFF)。UTF-16有兩個變種:UTF-16LE,顯示指明使用小端模式;UTF-16BE,顯示指明大端模式。如果顯示指明瞭模式,則不會生成BOM:

>>> 'El Niño'.encode("utf_16le")
b'E\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
>>> 'El Niño'.encode("utf_16be")
b'\x00E\x00l\x00 \x00N\x00i\x00\xf1\x00o'
複製程式碼

根據標準,如果檔案使用UTF-16編碼,且沒有BOM,則應假定它使用的是UTF-16大端模式編碼。然而Intel x86架構用的是小端模式,因此很多檔案用的是不帶BOM的小端模式UTF-16編碼。這就容易造成混淆,如果把這些檔案直接用在採用大端模式的機器上,則會出問題(比較老的AMD也有大端模式,現在的AMD也是x86架構了)。

由於大小端模式(位元組順序)只對一個字(word)佔多個位元組的編碼有影響,所以對於UTF-8來說,不管裝置使用哪種模式,生成的位元組序列始終一致,因此不需要BOM。但在Windows下就比較扯淡了,有些應用依然會新增BOM,並且會根據有無BOM來判斷是不是UTF-8編碼。

補充:筆者查資料時發現有“顯示指明BOM”一說,剛看到的時候筆者以為是在函式中傳遞一個bom關鍵字引數來指明BOM,然而不是,而是傳入一個帶有BOM標識的編解碼器,如下:

# 預設UTF-8不帶BOM,如果想讓位元組序列帶上BOM,則應傳入utf_8_sig
>>> 'El Niño'.encode("utf_8_sig") 
b'\xef\xbb\xbfEl Ni\xc3\xb1o'
>>> 'El Niño'.encode("utf_8")
b'El Ni\xc3\xb1o'
複製程式碼

5. 處理文字檔案

處理文字的最佳實踐是"Unicode三明治"模型。圖示如下:

Python學習之路23-文字和位元組序列

此模型的意思是:

  1. 對輸入的位元組序列應儘早解碼為字串;
  2. 第二層相當於程式的業務邏輯,這裡應該保證只處理字串,而不應該有編碼或解碼的操作存在;
  3. 對於輸出,應盡晚地把字串編碼為位元組序列

當我們用Python處理文字時,我們實際對這個模型並沒有多少感覺,因為Python在讀寫檔案時會為我們做必要的編解碼工作,我們實際處理的是這個三明治的中間層。

5.1 Python編解碼

Python中呼叫open函式開啟檔案時,預設使用的是編解碼器與平臺有關,如果你的程式將來要跨平臺,推薦的做法是明確傳入encoding關鍵字引數。其實不管跨不跨平臺,這都是推薦的做法。

對於open函式,當以二進位制模式開啟檔案時,它返回一個BufferedReader物件;當以文字模式開啟檔案時,它返回的是一個TextIOWrapper物件:

>>> fp = open("zen.txt", "r", encoding="utf8")
>>> fp
<_io.TextIOWrapper name='zen.txt' mode='r' encoding='utf8'>
>>> fp2 = open("zen.txt", "rb")  # 當以二進位制讀取檔案時,不需要指定編解碼器
>>> fp2
<_io.BufferedReader name='zen.txt'>
複製程式碼

這裡有幾個點

  • 除非想判斷編碼方式,或者檔案本身就是二進位制檔案,否則不要以二進位制模式開啟文字檔案;就算想判斷編碼方式,也應該使用Chardet,而不是重複造輪子。
  • 如果開啟檔案時未傳入encoding引數,預設值將由locale.getpreferredencoding()提供,但從這麼函式名可以看出,其實它返回的也不一定是系統的預設設定,而是使用者的偏好設定。使用者的偏好設定在不同系統中不一定相同,而且有的系統還沒法設定偏好,所以,正如官方文件所說,該函式返回的是一個猜測的值;
  • 如果設定了PYTHONENCODING環境變數,sys.stdout/stdin/stderr的編碼則使用該值,否則繼承自所在的控制檯;如果輸入輸出重定向到檔案,編碼方式則由locale.getpreferredencoding()決定;
  • Python讀取檔案時,對檔名(不是檔案內容!)的編解碼器由sys.getfilesystemencoding()函式提供,當以字串作為檔名傳入open函式時就會呼叫它。但如果傳入的檔名是位元組序列,則會直接將此位元組序列傳給系統相應的API。

總之:別依賴預設值

如果遵循Unicode三明治模型,並且始終在程式中指定編碼,那將避免很多問題。但Unicode也有不盡人意的地方,比如文字規範化(為了比較文字)和排序。如果你只在ASCII環境中,或者語言環境比較固定單一,那麼這兩個操作對你來說會很輕鬆,但如果你的程式面向多語言文字,那麼這兩個操作會很繁瑣。

5.2 規範化Unicode字串

由於Unicode有組合字元,所以字串比較起來比較複雜。

補充:組合字元指變音符號和附加到前一個字元上的記號,列印時作為一個整體。

>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False
複製程式碼

在Unicode標準中,'é''e\u0301'叫做標準等價物,應用程式應該將它們視為相同的字元,但從上面程式碼可以看出,Python並沒有將它們視為等價物,這就給Python中比較兩個字串新增了麻煩。

解決的方法是使用unicodedata.normalize函式提供的Unicode規範化。它有四個標準:NFCNFDNFKCNFKD

5.2.1 NFC和NFD

NFC使用最少的碼位構成等價的字串,NFD把組合字元分解成基字元和單獨的組合字元。這兩種規範化方法都能讓比較行為符合預期:

>>> from unicodedata import normalize
>>> len(normalize("NFC", s1)), len(normalize("NFC", s2))
(4, 4)
>>> len(normalize("NFD", s1)), len(normalize("NFD", s2))
(5, 5)
>>> normalize("NFD", s1) == normalize("NFD", s2)
True
>>> normalize("NFC", s1) == normalize("NFC", s2)
True
複製程式碼

NFC是W3C推薦的規範化形式。西方鍵盤通常能輸出組合字元,因此使用者輸入的文字預設是NFC形式。我們對變音字元用的不多。但還是那句話,如果你的程式面向多語言文字,為了安全起見,最好還是用normalize(”NFC“, user_text)清洗字串

使用NFC時,有些單字元會被規範成另一個單字元,例如電阻的單位歐姆(Ω,U+2126\u2126)會被規範成希臘字母大寫的歐米伽(U+03A9, \u03a9)。這倆看著一樣,現實中電阻歐姆的符號也就是從希臘字母來的,兩者應該相等,但在Unicode中是不等的,因此需要規範化,防止出現意外。

5.2.2 NFKC和NFKD

NFKCNFKD(K表示“compatibility”,相容性)是比較嚴格的規範化形式,對“相容字元”有影響。為了相容現有的標準,Unicode中有些字元會出現多次。比如希臘字母'μ'U+03BC),Unicode除了有它,還加入了微符號'µ'(U+00B5),以便和latin1標準相互轉換,所以微符號是個“相容字元”(上述的歐姆符號不是相容字元!)。這兩個規範會將相容字元分解為一個或多個字元,如下:

>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize("NFKC", half)
'1/2'
>>> four_squared = '4²'
>>> normalize("NFKC", four_squared)
'42'
複製程式碼

從上面的程式碼可以看出,這兩個標準可能會造成格式損失,甚至曲解資訊,但可以為搜尋和索引提供便利的中間表述。比如使用者在搜尋1/2 inch時,可能還會搜到包含½ inch的文章,這便增加了匹配選項。

5.2.3 大小寫摺疊

對於搜尋或索引,大小寫是個很有用的操作。同時,對於Unicode來說,大小寫摺疊還是個複雜的問題。對於此問題,如果是初學者,首先想到的一定是str.lower()str.upper()。但在處理多語言文字時,str.casefold()更常用,它將字元轉換成小寫。自Python3.4起,str.casefold()str.lower()得到不同結果的有116個碼位。對於只包含latin1字元的字串ss.casefold()得到的結果和s.lower()一樣,但有兩個例外:微符號'µ'會變為希臘字母'μ';德語Eszett(“sharp s”,ß)為變成'ss'

5.2.4 規範化文字匹配使用函式

下面給出用以上內容編寫的幾個規範化匹配函式。對大多數應用來說,NFC是最好的規範形式。不區分大小寫的比較應該使用str.casefold()。對於處理多語言文字,以下兩個函式應該是必不可少的:

# 兩個多語言文字中的比較函式
from unicodedata import normalize

def nfc_equal(str1, str2):
    return normalize("NFC", str1) == normalize("NFC", str2)

def fold_equal(str1, str2):
    return normalize("NFC", str1).casefold() == normalize("NFC", str2).casefold()
複製程式碼

有時我們還想把變音符號去掉(例如“caf锓cafe”),比如谷歌在搜尋時就有可能去掉變音符號;或者想讓URL更易讀時,也需要去掉變音符號。如果想去掉文字中的全部變音符號,則可用如下函式:

# 去掉多語言文字中的變音符號
import unicodedata

def shave_marks(txt):
    """去掉全部變音符號"""
    # 把所有字元分解成基字元和組合字元
    norm_txt = unicodedata.normalize("NFD", txt)
    # 過濾掉所有組合記號
    shaved = "".join(c for c in norm_txt if not unicodedata.combining(c))
    # 重組所有字元
    return unicodedata.normalize("NFC", shaved)

order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
print(shave_marks(order))
greek = 'Ζέφυρος, Zéfiro'
print(shave_marks(greek))

# 結果:
“Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai.”
Ζεφυρος, Zefiro
複製程式碼

上述程式碼去掉了所有的變音字元,包括非拉丁字元,但有時我們想只去掉拉丁字元中的變音字元,為此,我們還需要對基字元進行判斷,以下這個版本只去掉拉丁字元中的變音字元:

# 僅去掉拉丁文中的變音符號
import unicodedata
import string

def shave_marks_latin(txt):
    """去掉拉丁基字元中的所有變音符號"""
    norm_txt = unicodedata.normalize("NFD", txt)
    latin_base = unicodedata.combining(norm_txt[0])  # <1>
    keepers = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base:
            continue
        keepers.append(c)
        if not unicodedata.combining(c):
            latin_base = c in string.ascii_letters
    shaved = "".join(keepers)
    return unicodedata.normalize("NFC", shaved)

# '́'   這是提取出來的變音符號
t = '́cafe'
print(shave_marks_latin(t))

# 結果
cafe
複製程式碼

注意<1>處,如果一開始直接latin_base = False,那麼遇到刁鑽的人,該程式的結果將是錯誤的:大家可以試一試,把<1>處改成latin_base = False,然後執行該程式,看c上面的變音符號去掉了沒有。之所以第7行寫成上述形式,就是考慮到可能有的人閒著沒事,將變音符號放在字串的開頭。

更徹底的規範化步驟是把西文中的常見符號替換成ASCII中的對等字元,如下:

# 將拉丁文中的變音符號去掉,並把西文中常見符號替換成ASCII中的對等字元
single_map = str.maketrans("""‚ƒ„†ˆ‹‘’“”•–—˜›""",
                           """'f"*^<''""---~>""")

multi_map = str.maketrans({
    '€': '<euro>',
    '…': '...',
    'Œ': 'OE',
    '™': '(TM)',
    'œ': 'oe',
    '‰': '<per mille>',
    '‡': '**',
})

multi_map.update(single_map)

# 該函式不影響ASCII和latin1文字,只替換微軟在cp1252中為latin1額外新增的字元
def dewinize(txt):
    """把win1252符號替換成ASCII字元或序列"""
    return txt.translate(multi_map)

def asciize(txt):
    no_mark = shave_marks_latin(dewinize(txt))
    no_mark = no_mark.replace('ß', 'ss')
    return unicodedata.normalize("NFKC", no_mark)

order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
print(asciize(order))

# 結果:
"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."
複製程式碼

5.3 Unicode文字排序

Python中,非ASCII文字的標準排序方式是使用locale.strxfrm函式,該函式“把字串轉換成適合所在地區進行比較的形式”,即和系統設定的地區相關。在使用locale.strxfrm之前,必須先為應用設定合適的區域,而這還得指望著作業系統支援使用者自定義區域設定。比如以下排序:

>>> fruits = ["香蕉", "蘋果", "桃子", "西瓜", "獼猴桃"]
>>> sorted(fruits)
['桃子', '獼猴桃', '蘋果', '西瓜', '香蕉']
>>> import locale
>>> locale.setlocale(locale.LC_COLLATE, "zh_CN.UTF-8") # 設定後能按拼音排序
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "locale.py", line 598, in setlocale
    return _setlocale(category, locale)
locale.Error: unsupported locale setting
>>> locale.getlocale()
(None, None)
複製程式碼

筆者是Windows系統,不支援區域設定,不知道Linux下支不支援,大家可以試試。

5.3.1 PyUCA

想要正確實現Unicode排序,可以使用PyPI中的PyUCA庫,這是Unicode排序演算法的純Python實現。它沒有考慮區域設定,而是根據Unicode官方資料庫中的排序表排序,只支援Python3。以下是它的簡單用法:

>>> import pyuca
>>> coll = pyuca.Collator()
>>> sorted(["cafe", "caff", "café"])
["cafe", "caff", "café"]
>>> sorted(["cafe", "caff", "café"], key=coll.sort_key)
["cafe", "café", "caff"]
複製程式碼

如果想定製排序方式,可把自定義的排序表路徑傳給Collator()構造方法。

6. 補充

6.1 Unicode資料庫

Unicode標準提供了一個完整的資料庫(許多格式化的文字檔案),它記錄了字元是否可列印、是不是字母、是不是數字、或者是不是其它數值符號等,這些資料叫做字元的後設資料。字串中的isidentifierisprintableisdecimalisnumeric等方法都用到了該資料庫。unicodedata模組中有幾個函式可用於獲取字元的後設資料,比如unicodedata.name()用於獲取字元的官方名稱(全大寫),unicodedata.numeric()得到數值字元(如“1”)的浮點數值。

6.2 支援字串和位元組序列的雙模式API

目前為止,我們一般都將字串作為引數傳遞給函式,但Python標準庫中有些函式既支援字串也支援位元組序列作為引數,比如re和os模組中就有這樣的函式。

6.2.1 正規表示式中的字串和位元組序列

如果使用位元組序列構建正規表示式,\d\w等模式只能匹配ASCII字元;如果是字串模式,就能匹配ASCII之外的Unicode數字和字母,如下:

import re

re_numbers_str = re.compile(r'\d+')  # 字串模式
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+')  # 位元組序列模式
re_words_bytes = re.compile(rb'\w+')

# 要搜尋的Unicode文字,包括“1729”的泰米爾數字
text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef"   
            " as 1729 = 1³ + 12³ = 9³ + 10³.")

text_bytes = text_str.encode('utf_8')

print('Text', repr(text_str), sep='\n  ')
print('Numbers')
print('  str  :', re_numbers_str.findall(text_str))   # 字串模式r'\d+'能匹配多種數字
print('  bytes:', re_numbers_bytes.findall(text_bytes))  # 只能匹配ASCII中的數字
print('Words')
print('  str  :', re_words_str.findall(text_str))  # 能匹配字母、上標、泰米爾數字和ASCII數字
print('  bytes:', re_words_bytes.findall(text_bytes))  # 只能匹配ASCII字母和數字

# 結果:
Text
  'Ramanujan saw ௧௭௨௯ as 1729 = 1³ + 12³ = 9³ + 10³.'
Numbers
  str  : ['௧௭௨௯', '1729', '1', '12', '9', '10']
  bytes: [b'1729', b'1', b'12', b'9', b'10']
Words
  str  : ['Ramanujan', 'saw', '௧௭௨௯', 'as', '1729', '1³', '12³', '9³', '10³']
  bytes: [b'Ramanujan', b'saw', b'as', b'1729', b'1', b'12', b'9', b'10']
複製程式碼

6.2.2 os模組中的字串和位元組序列

Python的os模組中的所有函式、檔名或操作路徑引數既能是字串,也能是位元組序列。如下:

>>> os.listdir(".")
['π.txt']
>>> os.listdir(b".")
[b'\xcf\x80.txt']
>>> os.fsencode("π.txt")
b'\xcf\x80.txt'
>>> os.fsdecode(b'\xcf\x80.txt')
'π.txt'
複製程式碼

在Unix衍生平臺中,這些函式編解碼時使用surrogateescape錯誤處理方式以避免遇到意外位元組序列時卡住。surrogateescape把每個無法解碼的位元組替換成Unicode中U+DC00U+DCFF之間的碼位,這些碼位是保留位,未分配字元,共應用程式內部使用。Windows使用的錯誤處理方式是strict

7. 總結

本節內容較多。本篇首先介紹了編碼的基本概念,並以Unicode為例說明了編碼的具體過程;然後介紹了Python中的位元組序列;隨後開始接觸實際的編碼處理,如Python編解碼過程中會引發的錯誤,以及Python中Unicode字元的比較和排序。最後,本篇簡要介紹了Unicode資料庫和雙模式API。


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路23-文字和位元組序列

相關文章