Python學習之路23-文本和字節序列

《流暢的Python》筆記。
本篇主要講述不一樣編碼之間的轉換問題,比較繁雜,若是平時處理文本很少,或者語言比較單一,沒有多語言文本處理的需求,則能夠略過此篇。

1. 前言

本篇主要講述Python對文本字符串的處理。主要內容以下: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):

    系統支持的全部抽象字符的集合。能夠簡單理解爲人們使用的文字、符號等。bash

    這裏須要注意一個問題:有些語系裏面的字母上方或者下方是帶有特殊符號的,好比一點或者一撇;有的字符表裏面會將字母和特殊符號組合成一個新的字符,爲它單獨編碼;有的則不會單獨編碼,而是字母賦予一個編碼,特殊符號賦予一個編碼,而後當這倆在文中相遇的時候再將這倆的編碼組合起來造成一個字符。後面咱們會談到這個問題,這也是之前字符編碼轉換常出毛病的一個緣由。微信

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

  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號都還未使用,而1五、16號Plane保留爲私人使用區,而使用的5個Plane也並無全都用完,因此Unicode尚未很大的未編碼空間,至關長的時間內夠用了。

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

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

圖片描述

表中的×用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三明治"模型。圖示以下:

圖片描述

此模型的意思是:

  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 ~

相關文章
相關標籤/搜索