實用Unicode

翻譯: https://nedbatchelder.com/text/unipain.html

這是我在PyCon 2012做的一次分享。你在這個頁面看到的是演示稿和描述文字,全屏版的演示稿在這裏,也可以直接看這個分享視頻(國內 國外)。

點擊演示稿會調到全屏模式。 因爲裏面使用了Symbola字體,所以你需要在那些特殊符號出現之前就先下載這個字體。

大家好,我是Ned Batchelder。我寫Python已經10多年了,也就是說,大家犯過的絕大多數Unicode錯誤我也都犯過。

如果你像絕大多數Python程序員那樣:編了一個挺好的應用,一切看起來運行得都很好。然後突然有一天不知哪裏冒出來一個奇怪的字符,然後你的代碼拋出一個Unicode錯誤,然後就沒有然後了……

你大體知道該怎麼改,不外乎在拋異常的地方加個encode或者decode,但是接着別的地方又開始拋UnicodeError了。所以你就繼續去報錯的地方加encode或者decode。你像打地鼠一樣折騰一番之後,問題看起來好像解決了。

過了幾天,別的地方出現了另一個特殊字符,你又得像打地鼠那樣解決問題。

也就是說,一開始你的程序跑得挺好,後來你被惹惱了而且很不爽,因爲Unicode問題耗費了太多時間,關鍵是你知道並沒有徹底改好,最後你開始討厭自己了。所以你得出的結論是,你討厭Unicode。

你壓根就不想了解稀奇古怪的字符集,你只想寫一個不讓你煩心的程序。

其實你可以不必像打地鼠那樣折騰。Unicode並不簡單,但它也並不難。只要你瞭解一些背景知識和行爲準則,就可以輕鬆和優雅地解決Unicode問題了。

下面我會告訴大家5條常識和3條使用技巧,用以解決大家遇到的Unicode問題。下面的內容會涵蓋Unicode基礎,如何在Python 2和Python 3裏操作。具體的做法可能有差別,但路數基本是一樣的。

世界&Unicode

我們先從Unicode基礎知識講起。

常識1:計算機裏的一切都是字節,磁盤上的文件就是一堆字節,網絡連接裏傳輸的也是字節。大家寫的程序輸入/輸出的一切數據都是字節,無一例外。

程序讀寫字節,這些字節本身沒有任何意義,所以我們需要做一些約定,賦予它們以意義。

爲了呈現文本,我們用了ASCII編碼將近50年。每個字節都被賦予了上面95個符號中的一個。當我給大家發了一個值爲65的字節,大家就知道我想表達是大寫的A,前提是大家提前知道每個字節對應的是哪個字符。

ISO Latin 1, 或者8859-1也是ASCII編碼,只不過額外擴展了96個符號。

Windows在此基礎上又添加了27個符號,搞出來個CP1252。這基本就是大家能用一個字節表示的符號的上限了,因爲沒有多少空間可以用來添加新的符號了。

使用這樣的字符集,我們最多可以表示256種字符。但是常識2是,我們有很多種方式讓世界上的文本字符超過256種。單個字節根本無法呈現世界範圍內的任何一個文本。在我們暗無天日的打地鼠的日子裏,我們多麼希望全世界的人都說英語,但是這是不可能的。人們需要很多符號才能進行交流。

常識1和常識2一起,構成了我們的計算機架構和人民羣衆需求之間的根本性矛盾。

人們曾經做了很多種嘗試,試圖解決這一個矛盾。(其中一些)像ASCII那樣,使用單字節字符編碼,在字節和字符之間做了(另外幾種)映射。每一種都假裝常識2不存在。

那些單字節編碼方案,都解決不了這個問題。每個都只適用於一小部分人類語言。他們沒能解決全球性文本問題。

人們曾經嘗試創建一些雙字節字符集,但是它們仍然是有侷限性的,僅能在小範圍內使用。有很多特定範圍內的標準,但是都沒有大到足以包含人類所需的所有符號。

這時Unicode被設計出來,成爲解決陳舊字符編碼問題的大殺器。Unicode爲字符賦予整型(integer)值,即編碼點(code point)。它有110w個編碼點,並且目前爲止僅使用了11w個,所以有足夠多的空間應對未來的增長。

Unicode的目標是包羅萬象。它以ASCII開頭,包含了成千上萬個符號,其中就有著名的雪人符號,覆蓋了世界上所有的寫作體系,並且還在源源不斷地被擴展。例如,最新的版本包含了”一坨翔”的符號。

這裏有6個奇異的Unicode符號。Unicode編碼點用帶有U+前綴的4/5/6位十六進制形式表示。每一個符號都有一個用大寫ASCII表示的獨一無二的全名。

上面的字符串看起來像”Python”但是沒用一個ASCII字符。

儘管Unicode包含了我們用到的所有字符,但是我們仍然要解決常識1:計算機使用字節。出於存儲和傳輸的目的,我們仍然需要一種以字節的形式呈現Unicode編碼點的方法。

Unicode標準定義了一些以字節呈現編碼點的方式。這叫做編碼。

UTF-8是我們能最熟悉的而且也是使用最廣的Unicode存儲、傳輸的編碼方式。它爲每個編碼點使用了不定數量的字節。編碼點的值越大,在UTF-8中使用的字節數越多。ASCII裏的字符(在UTF-8中)都使用一個字節,對應的值也跟ASCII裏的相同,所以ASCII只是UTF-8的一個子集。

上面的奇異字符串就是以UTF-8的形式展示的。ASCII符號H和i是單字節,其他符號根據它們的編碼點的值,使用了2個、3個字節。也有些Unicode編碼點需要4個字節,但是我們這裏沒有用到。

Python 2

OK,理論講完了,現在讓我們說說Python 2。在這次的演示稿中,Python 2的例子都有在右上角有個大大的2,Python 3的例子有大大的3。

在Python 2中,有兩種不同的字符串數據類型。一個普通又老式的字符串常量返回”str”對象,以byte形式存儲。如果大家使用了”u”前綴,大家會可以拿到一個”unicode”對象,以編碼點形式存儲。在一個Unicode字符串常量中,大家可以用反斜線-u的方式(\u)插入Unicode編碼點。

注意,”字符串”這個詞是個坑。”str”和”unicode”都是字符串,我們經常圖一時方便就把它們都或者一個叫成”字符串”了,但是爲了表述得更直觀,我們最好用更加準確的名字(“str”和”unicode”)。

字節字符串(str)和Unicode字符串(unicode)都有一個方法可以把自己轉換成對立的那種字符串。Unicode字符串有一個.encode()方法可以生成字節字符串,字節字符串有一個.decode()方法可以生成Unicode字符串。它們都接收一個參數,用以指定操作所需的編碼名稱。

我們定義一個名叫my_unicode的Unicode字符串,可以看到它有9個字符。我們使用UTF-8編碼方式創建名叫my_utf8的byte字符串,它有19個字節。如大家所料,使用UTF-8反向解碼得到了原始的Unicode字符串。

不幸的是,如果用於編碼、解碼的數據不符合特定的編碼方案,就開始報錯了。這裏我們嘗試把奇異Unicode字符串編碼成ASCII。結果失敗了,因爲ASCII只能呈現0到127範圍內的符號,我們的Unicode字符串含有該範圍之外的編碼點。

拋出的UnicodeEncodeError以”codec”的形式(coder/decoder的縮寫)指出了使用的編碼名稱,同時也指出了導致問題的字符所在的實際位置。

解碼同樣也會報錯。這裏我們嘗試將UTF-8字符串解碼成ASCII,同樣報了UnicodeDecodeError,也是因爲ASCII只能接收不大於127的值,而我們的UTF-8字符串有些字節超過了這個範圍。

即使是UTF-8,也不能解碼任意字節序列。接下來我們嘗試解碼一些隨機垃圾數據,同樣產生了UnicodeDecodeError錯誤。實際上,UTF-8的一個優點是,存在無法解碼的字節序列,這樣可以幫我們構建魯棒性更好的系統:不接受非法數據,只接受合法數據。

編碼或者解碼時可以爲無法處理的數據指定處理方式。encode和decode方法有一個可選的第二位的參數可以指定這類策略。缺省值是”strict”,表示拋出一個異常,正如我們所見。

“replace”值表示給我們一個標準的替換字符。在編碼時,這個替換字符是問號(?),所以任何一個不能被指定編碼格式編碼的編碼點都會產生一個”?”。

其他錯誤處理方式更有用。”xmlcharrefreplace”生成一個HTML/XML符號實體引用,所以\u01B4 變爲 “ƴ” (十六進制 01B4 是十進制的 436.)。如果大家需要爲HTML文件輸出Unicode的話,這就非常有用了。

注意,不同的錯誤處理策略用於不同的錯誤原因。”replace”是一種在無法解析數據時的一種防禦機制,它會導致信息丟失。”xmlcharrefreplace”保留了全部的原始信息,它用於向接受XML轉義的場景輸出數據。

大家同樣可以在解碼的時候指定錯誤處理策略。”ignore”會丟棄不能被正確解碼的字節。”replace”會在問題字節的位置插入Unicode U+FFFD,”替代字符(REPLACEMENT CHARACTER)”。注意,因爲解碼器不能解碼有問題的數據,所以它也不清楚應該插入多少個Unicode字符。在把(上面)我們的UTF-8字節解碼成ASCII的時候,產生16個替代字符,每個不能被解碼的字節一個替代字符,儘管這些字節實際上只代表了6個Unicode字符。

Python 2會在大家操作Unicode字符串和字節字符串的時候,爲大家提供一些便利。如果當大家試圖在一個Unicode字符串和一個字節字符串上執行一項字符串操作的時候,Python 2會自動把字節字符串解碼,生成一個新的Unicode字符串,然後在這兩個Unicode字符串上完成相應的字符串操作。

例如,當我們嘗試爲一個Unicode字符串”Hello”拼接上字節”world”的時候,其結果是一個Unicode字符串”Hello world”。Python 2替我們把字節字符串”world”用ASCII編解碼方式解碼了。這種隱式解碼的編解碼方式通過調用sys.getdefaultencoding()方法獲得。

使用ASCII方式隱式解碼,是因爲這是唯一安全的猜測:ASCII應用如此廣泛並且是衆多編碼方案的子集,以至於它不太可能誤報。

當然,這些隱式解碼不能避免所有解碼錯誤。如果大家嘗試拼接一個字節字符串和一個Unicode字符串而且恰好這個字節字符串不能被解碼爲ASCII,這時候就會報UnicodeDecodeError。

這就是各種Unicode錯誤的痛苦之源。大家的代碼無意中混用了Unicode字符串和字節字符串,而且只要這些數據都是ASCII(編碼格式)的,隱式轉換就會使字符串操作默默成功。但是只要有非ASCII字符以某種方式混入大家的程序,隱式解碼就會失敗,然後就開始報UnicodeDecodeError。

Python 2的哲學是,Unicode字符串和字節字符串讓大家混淆,但是它可以通過在二者之間隱式自動轉換來減輕大家的負擔,就像在int和float之間的自動轉換那樣。int到float的轉換一定不會失敗,但是字節字符串到Unicode字符串的轉換卻有可能失敗。

Python 2默默掩飾了字節字符串到Unicode字符串的轉換,這讓編寫處理ASCII文本的代碼非常容易。但大家爲此付出的代價是,遇到非ASCII數據時報錯。

有很多種方法可以拼接2種字符串,它們都會把字節解碼成Unicode,所以大家在使用的時候必須要格外注意。

這裏我們用一個ASCII格式化字符串和Unicode數據。格式化字符串會被解碼成Unicode,然後格式化纔會執行,生成一個Unicode字符串。

下面我們把二者交換一下:一個Unicode格式化字符串和一個字節字符串,也會可以生成一個Unicode字符串,因爲字節字符串的數據會被按照ASCII解碼。

甚至只是嘗試打印一個Unicode字符串都會發生一次隱式編碼:輸出總是字節,所以Unicode字符串在被打印之前,就必須被編碼成字節。

下一個才真正叫人困惑:我們要把一個字節字符串編碼成UTF-8,但卻得到一個錯誤,說不能按照解碼成ASCII!問題在於字節字符串不能被編碼:記住一點,編碼是指把Unicode轉成字節。所以想要完成我們期望的編碼,Python 2需要一個Unicode字符串,這個字符串可以被隱式地解碼成ASCII字節。

所以大家想編碼成UTF-8,但卻得到了一個關於解碼ASCII的錯。這個錯誤值得我們好好研究,因爲它提供了關於字符串被執行了哪些操作的細節以及操作爲何失敗的原因。

最後,我們把一個ASCII字符串編碼成UTF-8,純屬娛樂,Unicode字符串會被編碼。爲了讓它可行,Python執行了相同的隱式解碼,以得到一個用於編碼的Unicode字符串,因爲這個字符串是ASCII的,所以能成,然後Python把它編碼成UTF-8,生成了一個原始的字節字符串,因爲ASCII是UTF-8的一個子集。

這是最重要的一條常識:字節和Unicode都很重要,二者大家必須都能處理。大家不能假裝一切都是字節,或者一切都是Unicode。大家需要根據場景選擇性使用它們,並且在需要的時候顯式地轉換它們。

Python 3

我們已經見識了Python 2的Unicode痛苦之源,現在我們看一下Python 3。Python 2和Python 3之間最大的不同在於它們對於Unicode的態度。

跟Python 2一樣,Python 3也有2種字符串類型,一個用於Unicode,一個用於字節,但是他們的名字卻不同。

現在,大家通過一個普通字符串常量獲取的”str”類型被存儲成Unicode,而”bytes”類型存儲成字節。大家可以通過一個b前綴創建字節字符串常量。

所以,在Python 2中的”str”現在被叫做”bytes”,Python 2中的”unicode”現在被叫做”str”。這比Python 2中的名字更有意義,因爲Unicode指的是大家期望所有的文本該如何存儲,而字節字符串僅用於大家處理字節的情況。

Python 3對Unicode的支持中,最大的改變是,不再自動解碼字節字符串。如果大家嘗試拼接一個字節字符串和一個Unicode字符串,大家總是會得到一個錯誤,無論涉及什麼數據!

所有我展示的Python 2靜默轉換字節字符串到Unicode字符串,以完成相關操作的例子,都會在Python 3中報錯。

另外,如果一個Unicode字符串和一個字節字符串包含相同的ASCII字節,那麼Python 2會認爲二者是相同的,這在Python 3中是不行的。這樣做的一個後果便是,Unicode字典key不能被字節字符串key檢索到,反過來也是,這跟Python 2不一樣。

這就根本性地改變了Python 3中Unicode痛點的本質原因。在Python 2中,只要大家只使用ASCII數據,把Unicode和字節混用是沒有問題的。但是在Python 3中,這會立即失敗,不管是什麼數據。

也就是說,Python 2的痛點是延後的:大家認爲自己的程序是正確的,直到遇到奇異字符之後它才失敗。

在Python 3中,大家的代碼會立即失敗,即使大家處理的只是ASCII,大家必須顯式處理字節和Unicode之間的差異。

Python 3對待字節和Unicode的態度比較嚴苛。大家被迫在自己的代碼中清楚的知道自己處理的是什麼。這很有爭議,以至於大家時常感到痛苦。

受這個新約束的影響,Python 3改變了大家讀文件的方式。Python一直支持2種模式讀取文件:二進制和文本。在Python 2中,讀取模式僅影響行尾,在Unix平臺上甚至沒有任何影響。

在Python 3中,這兩種模式會產生不同的結果。當大家以文本模式(用”r”或者乾脆默認模式)打開一個文件,從文件中讀到的數據被隱式解碼成Unicode,所以大家得到的是str對象。

如果大家以二進制方式打開一個文件(使用”rb”模式),那麼從文件中讀到的數據就是字節,它們不會被做任何加工。

從字節到Unicode的隱式轉換所使用的編碼方式由locale.getpreferredencoding()獲取,但有時候它並不能返回大家期望的結果。例如,當我們讀取hi_utf8.txt的時候,它被使用地域最優的編碼方式解碼,因爲我是在Windows上創建的示例,所以編碼方式被選成了”CP-1252”。與ISO 8859-1類似, CP-1252是一種單字節字符編碼,它只接受字節值而且不會拋出UnicodeDecodeError錯誤。這也就是說,它會快快樂樂地解碼數據,即使這些數據不是CP-1252,然後生成一堆亂碼。

爲了能夠正確讀取文件,大家需要指定一種編碼類型。現在open()函數有一個可選的編碼類型參數可以設置。

撥雲見日

好了,我們該如何解決這些痛點?好消息是,解決的規則很容易記住,而且它們在Python 2和Python 3中相同。

正如我們在常識1中看到的那樣,大家程序的輸入/輸出必須是字節。但是大家沒不要在自己程序內部處理這些字節。最佳策略是儘量早地解碼輸入的字節,生成Unicode。大家在自己的程序中通篇使用Unicode,然後當輸出數據時,儘量晚地編碼成字節。

這就創建了一個Unicode三明治:外面是字節,裏面是Unicode。

注意一點,有時大家用的某些類庫可能會爲我們完成這些轉換。這種類庫可能接受Unicode輸入或者可以輸出Unicode,然後這些類庫會自行處理從/到字節的邊界轉換。例如,Django接受Unicode,JSON模塊也是。

第2條規則是,大家必須知道自己正在處理哪種數據。大家在的程序的任何位置,都需要知道自己拿到的是字節字符串還是Unicode字符串。這個不能猜,而應該是意料之中的事。

另外,如果大家拿到一個字節字符串,打算把它當做一個文本處理的時候,一定要知道它用了何種編碼方式。

如果大家在調試代碼的時候,不能只是簡單地打印一下看看值是什麼。大家應該關注它的類型(type),而且應該關注它的repr值,看看到底大家拿到的是什麼數據。

我說過,大家必須理解自己的字節字符串用了何種編碼格式。這就是常識4:大家不能通過測驗的方式確定字節字符串的編碼格式。大家需要通過別的方式確定。例如,很多協議都包含了設置編碼格式的方法。這裏我們從HTTP,HTML,XML和Python代碼裏找了幾個例子。大家也可以通過事先的設計來獲取編碼格式,例如,數據源的規格說明裏可能已經設置了編碼格式。

有很多種方法可以去猜測字節的編碼格式,但是它們也僅僅是些猜測而已。必須通過其他方式來獲取編碼格式,這也是唯一的方式。

這個例子裏,我們找了一個包含奇異的Unicode符號的字符串,以UTF-8方式編碼,然後它錯誤地被以各種編碼格式解碼。正如大家看到的那樣,用錯誤的編碼格式解碼,可能會成功,但是產生的卻是錯誤的字符。大家的程序無法判定它的解碼結果出了錯,只有當人們嘗試閱讀這些文本時,大家纔會發現出幺蛾子了。

這是常識4的一個很好的例證:同一個字節流,可以被很多不同的編碼格式解碼。這些字節本身無法預示它們用了何種編碼格式。

順便提一下,專門有一個詞來稱呼這種亂七八糟的顯示,來源於日本人,他們年復一年地解決這些東西:亂碼(Mojibake)。

不幸的是,因爲字節的編碼格式和字節的文本本身是分別交換的,所以有時候編碼格式會設置錯誤。例如,大家可能從Web服務器上拉下來一個HTML頁面,在HTTP header裏聲明頁面使用8859-1編碼,但實際上它可能使用UTF-8編碼的。

某些情況下,編碼格式不匹配不會報錯,只是產生一堆亂碼。其他情況下,編碼格式與字節數據不匹配會拋出一個UnicodeError之類的錯誤。

毋庸置疑的是:大家必須專門測試自己的代碼對Unicode的支持情況。爲此,大家需要用Unicode數據貫穿測試自己的代碼。如果大家是一個英語語言國家的人,這麼做可能會有困難,因爲很多非ASCII數據不容易閱讀。幸運的是,有大量Unicode編碼點可以讓大家,以英語語言國家的人能夠讀得懂的方式,構建複雜的Unicode字符串。

這裏有一個過度古怪的文本,一段可讀的”僞ASCII”文本,和一段顛倒的文本。這類文本的一個好的來源一些網站,它們向中學生提供類似的文本,讓他們可以往社交網站上發。(類似非主流的QQ狀態)

根據每個應用程序的具體情況,大家可能需要深入挖掘Unicode世界的一些更復雜的主題。有很多細節我都沒有覆蓋到,因爲他們可能過於專業。我管它叫常識5½,因爲大家可能不會遇到這類問題。

回顧一下,這裏是五個不可迴避的常識:

  1. 大家程序的所有輸入/輸出都是字節。
  2. 我們的世界需要超過256個符號用於文字交流。
  3. 大家的程序必須同時處理字節和Unicode。
  4. 一個字節流不會告訴大家它用了何種編碼格式。
  5. 編碼格式可能會被錯誤指定。

這裏有三條有用的建議,在大家構建自己的軟件的時候要牢記,可以使自己的代碼純用Unicode(而不是Unicode和字節混用):

  1. Unicode三明治:讓自己代碼裏的文本都以Unicode形式使用,然後在儘量靠近邊界的地方進行轉換。
  2. 明白自己拿到的字符串到底是什麼:你必須要明白哪些字符串是Unicode,哪些是字節,對於拿到的字節字符串,它們使用了何種編碼格式。
  3. 測試代碼對Unicode的支持程度。在測試套件中貫穿使用古怪的文本,以確定你覆蓋了全部情況。

如果大家遵循這些建議,大家就可以編寫穩定又良好的可以很好處理Unicode的代碼,而且不管遇到多變態的Unicode都不會崩潰。

其他可能對大家有用的資源:

Joel Spolsky寫的程序員必知必會 之 Unicode和字符集,涵蓋了Unicode如何運作以及爲何如此。它講的不是Python知識,但是比我的分享要好!

如果大家要處理任意Unicode字符的語義方面的問題, 那麼Python標準庫裏的unicodedata模塊爲此提供了很多有用的函數。

爲了測試Unicode,有很多”炫酷”文本生成器,用於生成社交網絡上用的好玩的符號。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章