搞懂字符編碼

首先是unicode、utf-32、utf-16、ucs-2和utf-8編碼原理的簡單解釋,介紹的先後順序並不代表它們在歷史上出現的先後順序,隻是為瞭邏輯連貫,我並不是很清楚它們的歷史。

unicode是字符集,它囊括瞭世界上的所有字符,規定瞭每個字符與一個二進制數的一一映射關系,這個二進制數的范圍是 0 ~ 1 0000 1111 1111 1111 1111 ,用十六進制數表示是 0x0 ~ 0x10FFFF 。 將每16位二進制數表示的范圍作為一個平面,第一個平面稱為基本多語言平面,用於與常用字符對應,其范圍是 0000 0000 0000 0000 ~ 1111 1111 1111 1111,用十六進制表示為 0x0000 ~ 0xFFFF ;剩餘十六個平面稱為輔助平面,與一些輔助字符對應。在基本多語言平面中,有兩個范圍沒有對應字符,分別是 1101 1000 0000 0000 ~ 1101 1011 1111 1111 和 1101 1100 0000 0000 ~ 1101 1111 1111 1111,用十六進制表示為 0xD800 ~ 0xDBFF 和 0xDC00 ~ 0xDFFF,之所以沒有對應字符,其實是為瞭保留給utf-16編碼,前一個范圍稱為高位代理,後一個范圍稱為低位代理(utf-16才是unicode的親兒子啊)。

unicode隻定義瞭字符與二進制數之間的對應關系,不涉及二進制數如何存儲在內存和磁盤中,所以該二進制數是一個數學概念,是字符的標識,和用幾個字節存儲無關。為瞭方便表述,我們暫且把字符所對應的二進制數稱為unicode編碼(一定要記得這句話,一定啊,親),其實更準確的叫法應該是把字符所對應的二進制數稱為碼點(code point),碼點有個專門的記法是用前綴 U+ 加上二進制數的4到6位十六進制形式,如用這種記法表示碼點的范圍:U+0000 ~ U+10FFFF 。

首先,我們能想到的最直接的方式就是用定長字節存儲unicode編碼,於是就出現瞭utf-32編碼,utf-32直接用4個字節存字符對應的unicode編碼,但是最大的unicode編碼也隻需要3個字節,utf-32似乎有點費空間,尤其是在內存中,是非常難以容忍的。好的,那我們就用統一用3個字節來存呀,又可以表示全部字符,又是定長字節,編碼解碼也快;哎,其實也不行,因為剩餘十六個平面所表示的字符其實很少用到,為瞭容納他們用3個字節,還是太浪費。好的,那我們用變長編碼,基本多語言平面用兩個字節,剩餘十六個平面用三個字節;好吧,似乎想法不錯,但是,你能區分下一個字符是以2個字節在存儲,還是以3個字節在存儲嗎,如果不在基本多語言平面裡保留一個范圍用於表示這不是一個2字節字符,而是一個多字節字符,你是分不清的。所以出現瞭utf-16,utf-16的編碼方式如下:

  • 如果 unicode編碼 <= 0xFFFF , 直接用兩個字節存unicode編碼
  • 如果 unicode編碼 > 0xFFFF , 先計算 U = unicode編碼 - 0x10000,然後將 U 寫成二進制形式:yyyy yyyy yyxx xxxx xxxx ,接著用4個字節這樣存:110110yyyyyyyyyy 110111xxxxxxxxxx ,前綴就是上圖我用紅色標記的那部分。

utf-16真的很巧妙,unicode編碼最大的數是0x10FFFF,也就是第十七個平面最後一個數,用utf-16編碼表示剛好是110110111111111 110111111111111 ,unicode編碼再大一點都不行,utf-16表示不瞭第十八個平面的第一個數,不過還好沒有第十八個平面。每當utf-16解碼程序讀到一個2字節是處於代理范圍,那麼utf-16就會再多讀2個字節,用4個字節去解碼。

ucs-2可以看作是utf-16的簡化版,不像utf-16那樣是變長編碼,它是定長編碼,隻用2個字節直接存unicode的基本多語言平面的二進制數值,存不瞭剩餘十六個平面的unicode編碼,所以拿ucs-2去解碼utf-16的編碼,遇到處於代理范圍的2個字節就亂碼。正因為ucs-2是2個字節的定長編碼,如果在內存中使用ucs-2的話,可以實現隨機訪問,降低算法的時間復雜度。

由於utf-32、utf-16和usc-2都是用多個字節來表示一個字符,所以會涉及到要區分文件的字節序是大端模式(big endian,be)還是小端模式的問題(little endian,le),為瞭解決這個問題,可以在文件的開頭添加幾個字節的編碼用於表示該文件是哪種字節序,這幾個字節的編碼稱為字節序標記(Byte Order Mark,BOM)。unicode也為BOM專門騰出瞭1個碼點U+FEFF,處於基本多語言平面:

  • 碼點U+FEFF的utf-32大端編碼是0x0000 FEFF,所以以0x0000 FEFF開頭的文件是大端utf-32文件;碼點U+FEFF的utf-32小端編碼是0xFFFE 0000,所以以0xFFFE 0000開頭的文件是小端utf-32文件。
  • 碼點U+FEFF的usc-2和utf-16編碼是一樣的,大端編碼是0xFEFF,所以usc-2和utf-16的大端編碼文件以0xFEFF開頭;小端編碼是0xFFFE,所以usc-2和utf-16的大端編碼文件以0xFFFE開頭。

utf-16是挺不錯的哈,但是在磁盤用utf-16上存ASCII碼上的字符的時候,有一個字節是全0的,還是感覺有點浪費空間,我們還想再改進改進,於是就出現瞭utf-8,utf-8也是變長編碼,它的編碼方式是:

  • 對於ASCII碼中的字符,用1個字節存儲,和ASCII碼完全一樣
  • 其他字符用多個字節存儲,見下表,根據unicode編碼的范圍填進相應的模板即可得到多字節的utf-8編碼

utf-8也很巧妙,從圖中可以看到,utf-8在讀完第一個字節後就知道還需要再讀幾個字節才能正確地完成解碼;另外,對於多字節編碼,編碼的第一個字節的開頭與後面字節的開頭都不一樣,於是雖然utf-8是用多字節表示一個字符,但是它並不用管字節序的問題,所以特別適合網絡傳輸,而且它最少隻用瞭一個字節,所以也特別適合在磁盤中儲存。

雖然utf-8不用管字節序的問題,但是畢竟對於utf-8來說碼點U+FEFF閑著也是閑著,於是utf-8就把它利用起來瞭,碼點U+FEFF的utf-8編碼是0xEF BB BF,占三個字節,放在文件的開頭,用於表示這是一個utf-8文件,更準確的說應該是帶有BOM的utf-8文件,這三個字節在帶有BOM的utf-8文件裡也稱為signature。這個signature對於像windows記事本這類應用程序來說是很有用的,因為記事本就靠它來自動識別文件的編碼,但是對於咱程序員來說不是很友好,因為很多編譯器不支持識別這種帶BOM的文件。

再次強調,unicode編碼與字符之間具有一一對應關系,但不涉及儲存,隻是字符的標識;utf-8、utf-16、ucs-2和utf-32編碼既與字符之間具有一一對應關系,也是具體的儲存方式。

關於ANSI是什麼編碼可以參考下面的鏈接。ANSI其實不是一種具體的編碼,操作系統會根據設置的區域來確定ANSI,比如你使用的是簡體中文,那麼ANSI就是gbk。gbk是變長編碼,用1個或者2個字節進行存儲,1個字節時就是ASCII碼,所以gbk和utf-8一樣都完全兼容ASCII碼,但gbk不是unicode系的編碼,與unicode沒有關系。

綜上來看有些字符編碼適合於內存,有些適合於外存。那程序在內存中用的是什麼編碼呢,這個問題真的是我的心思你別猜。

以我目前的認知水平得到的程序從編譯到運行的抽象流程是這樣的: 首先你在文本編輯器或者IDE裡打的代碼,會用你選定的編碼保存下來的,接下來是由編譯器來解碼源代碼文件並編譯,除瞭雙引號和單引號裡面的字符,其它代碼都最終被編譯成瞭表示指令的二進制序列保存在可執行文件裡面瞭(或者字節碼文件),但是雙引號和單引號裡面的字符在可執行文件裡面肯定不是一串表示指令的二進制序列,而應該是把源碼文件中的字符編碼解碼後再編碼成某種字符編碼保存在可執行件中瞭,當可執行文件運行時,再把這種字符編碼賦給內存裡的字符變量。

這中間的字符編碼轉換過程可能很復雜,需要在各種編碼之間轉換好幾次。像C/C++比較靠近底層的編程語言可能還好,源代碼中的字符串轉換成某種編碼保存在可執行文件中,運行時直接賦給內存中的字符變量;但是Java和python這類封裝層次很高的語言就不一定,源碼到字節碼一種轉換,運行時字節碼到內存變量又可能一種轉換。還有程序向控制臺輸出打印的過程可能還需字符編碼的轉換,太復雜,咱們還是把問題簡單化吧,重點解決現階段常見的編碼問題,即編譯器使用什麼編碼解碼源文件和編程語言在內存中用什麼編碼存儲字符變量和字符串。

C語言:

C語言裡內存用的是哪種字符編碼請參考博客 http://www.iteye.com/blog/jimmee-2165685 ,寫的很棒,建議先進去看看,但博客年代有些久遠瞭,編譯器可能有變動。

我在VS2019上簡單實驗瞭一下,C語言的char類型隻占一個字節,所以在編譯時,會對變量進行類型檢查,如果源代碼中的字符在轉換編碼後超過1個字節會直接進行截斷,然後再保存到可執行文件中,用字符數組則不會截斷:

564033f6663e4c72022bb236564d7db6

為什麼會輸出的是問號?因為VS默認是用gbk編碼將字符保存到可執行文件中,而VS2019默認以utf-8 BOM保存源cpp文件,所以在編譯時會先用utf-8解碼源文件,然後再用gbk進行編碼,但是gbK字符中沒有emoji,於是編譯器就將轉換失敗的字符統一編碼成 0x3f 再寫到可執行文件中,0x3f 就是 ? 的ASCII碼。可以在VS2019中將源文件的編碼和可執行文件中的字符編碼都設置成utf-8,具體設置過程參見鏈接:Microsoft文檔: 將源和可執行字符集設置為 UTF-8

VS2019的編譯器支持用 u (小寫的u)轉義基本多語言平面的字符的unicode編碼,u後面需要用4位十六進制數來表示unicode編碼;用 U (大寫的U)轉義剩餘16個平面的字符的unicode編碼,U後面需要用8位十六進制數來表示unicode編碼:

d054b4225c146ecc17ec130ad8ad1f08

Python:

pyton在內存中使用的是什麼字符編碼可以參考這本博客http://juejin.cn/post/6844904056062754829 ,寫的很好,同樣建議先進去瞅瞅。下面先講一下字符編碼在python中的用法,看完你應該知道為什麼總強調的是unicode編碼。實驗使用到的python版本是python3.8,因為cmd打印不瞭emoji,也用到瞭jupter notebook。

python同樣支持用 u和 u轉義unicode編碼,其中,u(小寫的u)用於轉義基本多語言平面的字符的unicode編碼,u後面跟用4位十六進制數表示的unicode編碼:

f39d6d9b80fcd562408a5e6bf6a77a81

U(大寫的U)用於轉義剩餘16個平面的字符的unicode編碼,U後面跟用8位十六進制數表示的unicode編碼:

如果超出unicode編碼范圍就會報錯:

python希望程序員在用python進行編程時的思想是,字符就是字符,字符對應的就是unicode編碼(碼點),像上圖那樣,用4個或者8個十六進制數字表示。字符和unicode編碼隻存在於你的腦子裡面,然後在打代碼的時候用到,不存在於內存和磁盤中,底層存儲的事由python來負責給你屏蔽掉,我們可以用encode()函數和decode()函數實現unicode編碼與具體儲存編碼之間的轉換,也即字符與字節之間的轉換。

將unicode編碼轉換成字節序的過程就是編碼,用encode()函數實現,下圖是一些很好的示例:

使用utf-16不指定大小端模式,就默認是小端模式,並加上utf-16小端模式的前綴,指定瞭大小端就按對應模式編碼,而不加前綴;顯示時的前綴b表示是這是bytes類型的字節序列,其中 x 表示後面緊跟著的兩個字符是十六進制數,如果這個字節處於ASCII碼的范圍,就直接顯示對應的ASCII碼字符;當然在表示bytes類型時,如果這個字節處於ASCII碼的范圍,你既可以使用ASCII碼字符,也可以改為對應的十六進制。

將字節序轉換成unicode編碼的過程是解碼,用decode()函數實現,編碼和解碼的方式要一樣,不然就亂碼瞭:

上圖中的ord()函數返回的是字符的unicode編碼的十進制,hex()函數將十進制轉換成十六進制。

然後我們還要知道python編譯器能夠識別的編碼,python編譯器默認的編碼是utf-8,也可以識別utf-8 BOM,但養成習慣,寫代碼最好不要用utf-8 BOM,還支持gbk,但不支持其他utf編碼。我們用utf-8保存帶有中文字符的源文件時,無論開頭有沒有聲明編碼:# coding: utf-8 ,都可以成功編譯並運行:

但是如果用gbk編碼保存有中文字符的源文件時,編譯就會出錯,'啊'的gbk編碼是0xb0a1,從下圖中的報錯信息也可以看出在 b0 那裡無法用utf-8解碼,並且指出沒有聲明編碼,所以我們要加上編碼聲明。

加上編碼聲明 # coding: gbk 或者 # coding: ansi,仍以gbk的編碼保存:

運行成功,可見編譯器會先識別第一行的編碼聲明,然後再用聲明的編碼去解碼後面的內容,之後再編譯,所以編碼聲明要與保存源文件的編碼一致。無論是gbk編碼還是utf-8編碼,編譯器識別第一行是不會出現問題的,因為這兩種編碼對在ASCII碼范圍的字符的編碼都是相同的,都與ASCII碼相同。但如果把編碼聲明 # coding: gbk 放到第三行,並以gbk保存源文件是會出錯的:

因為編譯器隻會對源文件的前兩行代碼做預處理,所以前兩行最好是 #!/usr/bin/python 和 # coding: utf-8

Java:

Java編譯器默認的編碼跟隨操作系統,也就是ANSI。所以用gbk編碼保存帶有中文的Java源文件在編譯時不會出錯:

但是如果以utf-8保存源文件,那麼編譯時就會出錯,見下圖,'啊' 的utf-8編碼是 0xE5 95 8A ,在解碼時,gbk會先讀一個字節,如果該字節不在ASCII碼的范圍,那麼還會再讀1個字節然後解碼,所以報錯會指出gbk不可解碼 0x8A :

可以想象如果再加1個英文字符,湊成4個字節,那麼編譯就會成功,但是輸出會亂碼:

那怎麼正確編譯utf-8的Java文件呢,答案是編譯時加上 -encoding utf-8 選項就不出錯瞭:

還有另外一種指定Java編譯器用何種編碼解碼源碼文件的方式,就是配置環境變量JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8",具體可以參見博客:http://blog.csdn.net/huangshaotian/article/details/7472662,Java編譯器並不支持utf-8 BOM等其他UTF編碼。

不像python可能隻需要瞭解一下內存中的字符編碼方式,Java程序員必須記住Java內存中的字符編碼是utf-16,並理解《Java核心技術卷1》中講到瞭兩個概念,一個是碼點,也就是我們上面所說的碼點或unicode編碼;另一個是代碼單元,一個代碼單元就是一個char大小,Java中的char占2個字節,也就是一個代碼單元就是2個字節,unicode剩餘十六個平面的碼點用utf-16編碼需要4個字節,也就是2個char大小,這時碼點與代碼單元的關系是1個碼點對應2個代碼單元。下面實驗的Java環境是Java 15。

因為Java中的char隻有2個字節大小,所以不能存儲4個字節的utf-16編碼,比如emoji 的碼點是0x1F37A,utf-16be編碼是0xd83c df7a,如果用char存 ,在編譯時對字符變量進行類型檢查,發現源碼文件中的字符轉換編碼後超過2個字節大小,那麼編譯就會報錯。由此可見Java比更接近底層的C語言要嚴格。註意此時源文件應該用utf-8保存,不能用ANSI保存,因為gbk的字符集中沒有emoji。

Java不支持用 U (大寫的U)轉義剩餘十六個平面的unicode編碼,隻支持用 u (小寫的u)轉義基本多語言平面的unicode編碼:

如果非要用轉義的方式表示剩餘十六個平面的unicode編碼,那隻能用 u轉義utf-16be編碼瞭, 的碼點是0x1F37A,utf-16be編碼是0xD83C DF7A,於是:

隻顯示一個 ? 就代表雙引號裡面的內容確實隻被解碼成瞭一個字符,至於為什麼是 ? ,可能是因為控制臺的字符集是gbk,這中間有個編碼轉換的過程。

接下來測試代碼單元和碼點之間的關系,String類的方法length()返回代碼單元的數量,codePointCount()返回碼點的數量。

public class code{
public static void main(String[] args){
String str = " ";
System.out.println(str);
int n = str.length();//2
System.out.println(n);
int cpCount = str.codePointCount(0,str.length());//1
System.out.println(cpCount);
}
}

发表回复

相关推荐

《潘氏源流史》之十一:潘佑后裔初探

潘佑后裔初探 潘佑,生于937年①,卒于973年②。幽州人③。他的祖父潘贵,是刘仁恭(唐末藩镇割据者之一)手下的将领,父亲潘 ...

· 19分钟前

全新的2023款豐田皇冠(Toyota Crown)

全新的2023款豐田皇冠(Toyota Crown)以其獨特的外觀,將大膽的設計風格帶到瞭豐田轎車系列的頂端。憑借巨大的車輪、流暢的...

· 27分钟前

一文讀懂微內核

2019年8月9日華為 餘承東 發佈HarmonyOS 1.0,HarmonyOS的發佈將一個計算機領域內非常專業的詞帶到瞭廣大公眾的視線內,這就...

· 31分钟前

加油打工人——《中國飛俠》

今天聊聊電影《中國飛俠》。片名China Hero (2020)。看到本片主演是許君聰、李琦時,我還以為《中國飛俠》是一部喜劇電影,會...

· 31分钟前

楚味荆州 | 飘香的葱油饼,传统与改良你更爱哪种

在荆州,葱油饼代表的不仅仅是一种清晨街道小吃,更是儿时的美好回忆,是学校门口的葱油香,是过早摊头前排的长队。油润酥香 ...

· 35分钟前