關於Unicode,字符集,字符編碼,每個程序員都應該知道的事

李笑來的文章如何判斷一個人是否聰明?中提到:

必要、清晰、且準確的概念,是一切思考的基石。所謂思考,很大程度上,就是在建立那些概念與概念之間的關聯。概念是必要、清晰、且準確的,它們之間的關聯也應該是準確的。

確實很認同這兩句話,搞清楚字符集,字符編碼,Unicode等關鍵詞的意義,基本上也就能搞明白遇到的編碼問題了。本文力求通俗易懂,但涉及的內容比較多,而且編碼問題又不是那麼容易理解的,所以如果大家看完之後還是對編碼問題一知半解,那也不要灰心,下次遇到編碼問題時回過頭來再看看本文。我也是斷斷續續花了很長時間才理解清楚編碼問題的。

基本概念

字符[character]

字符代表了字母表中的字符,標點符號和其他的一些符號。在計算機中,文本是由字符組成的。

字符集合[character set]

由一套用於特定用途的字符組成,例如支持西歐語言的字符集合,支持中文的字符集合。字符集合只定義了符號和他們的語意,其實跟計算機沒有直接關係。

現實生活中,不同的語系有自己的字符集合,例如藏文有自己的字符集合,漢文有自己的字符集合。到計算機的世界中,也有各種字符集合,例如ASCII字符集合,GB2312字符集合,GBK字符集合。還有一個其他字符集合的超集--Unicode字符集定義了幾乎絕大部分現存語言需要的字符,是一種通用的字符集,來支持多語言環境(可以同時處理多種語言混合的情況)。各個國家和地區在制定編碼標準的時候,“字符集合”和“字符編碼”一般都是同時制定的。所以像ASCII字符集合一樣,它也同時代表了一種字符的編碼。

字符編碼[character encoding]

是一套規則,定義了在計算機內存中如何表示字符,是字符集中的每個字符與計算機內存中字節之間的轉換關係,也可以認為是把字符數字化,規定每個“字符”分別用一個字節還是多個字節存儲,用哪些字節來存儲。例如ASCII編碼[你沒看錯,它既是一種字符集合,也是一種字符編碼],定義了英文字母和符號在計算機中的表示方式,是用一個字節來表示。Unicode字符集合,有好幾種字符編碼方式,例如變長度編碼的UTF8,UTF16等。中文字符集也有很多字符編碼,例如上文提到的GB2312編碼,GBK編碼等。

知乎上的這篇介紹字符編碼,字體,iconv的文章很贊,內容淺顯易懂。還有一篇很有名的有關Unicode和字符集的文章可以看看:The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!),網上有中文版。

UCS和ISO 10646標準

ISO 10646標準定義了通用字符集UCS[Universal Character Set],是其他所有字符集合的超集。它保證了和其他字符集合之間可以來回轉換,不會丟失信息。

UCS不僅給每個字符做了編碼,而且還定義了一個官方的名稱。用來表示一個UCS或者Unicode的十六進制數字通常是用"U+"來作為前綴的,例如用"U+0041"來表示拉丁文中的大寫字母A。

UCS[Universial Character Set]和Unicode的關係

簡單粗暴的總結一下,就是兩撥人搞的同一套標準。具體經過如下:

在1980年代後期,有獨立的兩撥人想創建一個通用的字符集合。一個是國際化標準組織ISO[Internaltional Organization for Standardization],另外一個是最初成員大部分是美國多語言軟件服務提供商的財團發起的Unicode項目。幸運的是在1991年左右,兩個項目的成員都意識到世界不需要兩個統一的字符集。於是他們一起合作制定了一個字符表。雖然兩個項目至今仍然存在並獨立發佈各自的標準,但是Unicode財團和國際化標準組織都已經同意會讓Unicode和ISO 10646標準互相兼容並會在未來緊密協作。具體兩者之間的區別,見這裡

什麼是UTF8

Unicode/UCS只是字符集合,雖然為每個字符分配了一個唯一的整數值,但具體怎麼用字節來表示每個字符,是由字符編碼決定的。Unicode的字符編碼方式有UTF-8, UTF-16, UTF-32。由於UTF-16和UTF-32編碼中包含"\0",或者"/"這樣對於文件名和其他C語言庫函數來說具有特殊意義的字符,所以不適合在Unix下用來做文件名稱,文本文件和環境變量的Unicode編碼。UTF-8沒有這樣的問題,它有很多優點:可以向前兼容ASCII碼,是變長的編碼,由於編碼沒有狀態,所以很容易重新同步,在傳輸過程中丟失了一些字節後,具有魯棒性。

POSIX語系[locale]機制

語系[locale]就是軟件運行時的語言環境,它是語言和文化規則的一個集合,包含字符編碼,日期/時間的表示方式,字符排序的規則等。語系的名稱通常是由ISO 639-1規定的語言[language]和ISO 3166-1規定的國家代碼[country code]以及額外的字符編碼名稱[character encoding]共同組成,例如zh_TW.UTF-8語系,zh代表語言是漢語,TW是臺灣地區,UTF-8是字符編碼。而zh_CN.GBK中,CN是指中國大陸地區,採用GBK編碼。

Linux下語系由幾個類別的環境變量組成,指定了在軟件中跟語言慣例相關的行為信息。例如LC_CTYPE決定字符編碼方式,LC_COLLATE決定字符排序的規則。LANG環境變量用來設置所有類別的默認語系,但是LC_*這些變量能夠覆蓋每個單獨的類別。

理解了上述概念,咋們就可以去實踐一下了。

實戰

C語言對Unicode和UTF-8的支持

多字節字符和寬字符

C語言中用單獨的一個char類型的變量是無法唯一地表示像漢語這樣的自然語言的。C語言標準支持兩種不同的方式來處理擴展的自然語言編碼方式:寬字符[wide characters]和多字節字符[multibyte characters]。

  1. 寬字符是一種內部表示方式,每個字符是用一個單獨的wchar_t類型來表示的。
  2. 多字節字符是用來做輸入和輸出的,每個字符用C語言中char類型的序列來表示。所以每個字符會用一個或多個(最多MB_LEN_MAX)字節來表示

wchar_t這種類型是從GNU glibc 2.2開始引入的,目的是在運行時用單個的對象來表示字符,跟當前使用的語系無關。ISO C99標準要求通過宏__STDC_ISO_10646__來告訴程序支持wchar_t類型,並且保證所有的寬字符處理函數都會把寬字符當作Unicode字符。C語言中處理寬字符的函數多數是在處理char類型字符的函數名基礎上,添加了"w"或者是把"str"替換成"wcs",例如wprintf(),wscpy()等。字符串常量之前添加L前綴就可以告訴讓編譯器用wchar_t類型來存儲字符串常量,例如printf("%ls\n", L"Schöne Grüße"),如果用寬字符來表示字符串,此時的字符串長度就是以wchar_t為單位的,而不是字節;

2011版的C和C++標準都各自引入了固定大小的字符類型char16_t和char32_t來明確提供16位和32位Unicode編碼格式,讓wchar_t成為實現相關的類型。ISO 10646:2003 Unicode 4.0標準說:

wchar_t類型的寬度是由編譯器指定的,可以小到只有8位。因此對於需要在C或C++編譯器之間可移植的程序不應該使用wchar_t來存儲Unicode文本。wchar_t類型的目的是存儲編譯器定義的寬字符,有可能不是用Unicode編碼的。

多字節字符的字符編碼方式,是由當前系統的語系[locale]來決定的,例如當前語系中字符編碼是UTF-8,那麼多字節字符編碼就是UTF-8。因此語系也控制著寬字符和多字節之間的轉換。

glibc2.2及更高版本完整地實現ISO C語言多字節轉換函數(mbsrtowcs(), wcsrtomb()等)。這些函數用來在wchar_t和任何語系相關的多字節編碼,包括UTF-8,ISO 8859-1等之間進行轉化。

建議是使用這些函數中可重啟動的[restartable,函數名中有字母r],是多線程安全的函數,例如wcsrtombs(), mbsrtowcs()。

使用這些函數的好處是:

  • 是跟廠商無關的標準
  • 函數會根據用戶的語系做正確的事情。程序需要做的是在程序開頭調用setlocale(LC_ALL, "")來根據環境變量來設置用戶語系

例如可以寫出如下代碼:

#include <stdio.h>
#include <locale.h>
int main()
{
if (!setlocale(LC_CTYPE, "")) {
fprintf(stderr, "Can't set the specified locale! "
"Check LANG, LC_CTYPE, LC_ALL.\n");
return 1;
}
printf("%ls\n", L"Schöne Grüße");
return 0;
}
/<locale.h>/<stdio.h>

setlocale(LC_CTYPE, "")函數,會依次測試環境變量 LC_ALL, LC_CTYPE和 LANG的值,如果有值,就用這個值來決定用哪個語系數據來加載LC_CTYPE這個分類(控制著多字節轉換的函數)。

printf中的%ls格式說明符是用來指定把寬字符形式的字符串參數轉化成由語系決定的多字節編碼來輸出。printf函數是不知道輸出的字符的編碼方式的,它會把傳給它的字節原封不動地輸出出去。在顯示的時候,操作系統會根據當前的語系來將這些字節解碼到對應的字符,所以只有當傳給printf的字符編碼方式和用戶環境變量指定的字符編碼方式相同,用printf打印出的字符才不會亂碼。

使用這些函數的壞處:

  • 有些函數是非線程安全的,因為兩次函數調用之間有隱藏的內部狀態
  • 不能同時支持多種語系或編碼方式

通過上述的分析可以看到,如果全部都使用C語言庫中多字節的函數來進行外部字符編碼和程序內部使用的wchar_t類型之間的轉換,那麼C語言庫會根據環境變量LC_CTYPE的值來選擇正確的字符編碼,你的程序甚至不用顯示地知道當前多字節編碼是什麼。

然而,有一些情況下你可能不會全部都用C語言庫中的多字節函數,此時程序不得不知道當前語系是什麼。此時需要首先在程序開始處調用setlocale(LC_TYPE, ""函數來根據環境變量設置語系。之後利用函數nl_langinfo(CODESET)函數來獲得當前語系指定的字符編碼的名稱。

C語言如何書寫採用了某種字符編碼的字符串常量

對於一坨字節數據來說,字符編碼就相當於是有色眼鏡一樣,我們可以戴上UTF-8編碼的眼鏡去解讀這片字節數據,也可以戴上GBK編碼的眼鏡去解讀它。只有當我們採用了跟寫入時的編碼一致的編碼去解讀,才能讀取出有意義的字符串,否則可能就是亂碼了。

轉義序列

轉義序列[escape sequences]:轉義是以多個字符的有序組合來表示原本很難直接表示出來的字符的技術。轉義序列指在轉義時使用的有序字符組合。

需要了解C語言中如下的幾個轉義方式:

'\798':值為十進制值798的字符

'\\x7D':值為十六進制7D的字符

'\\u0041':代表字符名稱中名為U+0041的這個Unicode字符,可能最終編譯器會用幾個字節來存儲這個字符。這種方式只有C99以後才支持。由編譯器來決定具體用什麼方式存儲。

有了這幾個轉義字符這樣就很容易書寫出特定編碼的字符串了,例如"我是Jack47",採用各種編碼形式的值如下:

char gbk_name[] = "\\xced2\\xcac7Jack47";

char unicode_name[] = "\\u6211\\u662FJack47"

char utf8_name[] = "\\xe6\\x88\\x91\\xe6\\x98\\xafJack47"

上述的這種方式,是直接把編碼後的字節寫入到了數組裡,是一種"硬編碼"[hard code]的方式。

知道了上述的知識後,問題就來了,當前軟件要支持UTF8,要如何修改?

如何修改軟件來支持UTF8

有兩種辦法,可以這樣劃分:

1. 軟轉換:數據在所有地方都是以UTF-8的形式存儲的。
2. 硬轉換:程序讀取的輸入是UTF-8數據,在程序內部轉換成寬字符後進行處理,只有在最終輸出的時候轉換成UTF-8編碼。在內部一個字符是一個固定大小的內存對象。

也可以這樣劃分:

1. 硬編碼的方法
把UTF-8相關的信息硬編碼到程序中。這樣能夠在某些場景下顯著提高程序執行效率。這或許是那些只需要支持ASCII和UTF-8編碼的程序的最好辦法。
2. 取決於語系的方法
C語言提供了可以處理任意特定語系,採用多字節編碼的字符串的處理函數。依賴於這些函數的程序員可以不用感知到UTF-8編碼的實際細節。通過僅僅改變語系設置,就可以自動支持其他的多字節編碼(例如EUC)。

如果使用了UTF-8或者其他類似的多字節編碼,需要程序員清楚地區分以下概念:

1. 字節[Byte]
2. 字符[Character]
3. 顯示時候的寬度

如何在不同編碼間轉換

可以使用iconv函數在兩個不同的編碼之間進行轉換,例如從GBK編碼轉換到UTF-8編碼。

Java與Unicode

Java語言內部使用的就是Unicode編碼。char類型表示一個Unicode字符[這是跟C語言不一樣的地方],java.lang.String類表示一個從Unicode字符構建的字符串。

java.io.DataInput和java.io.DataOutput接口分別有叫做readUTF和writeUTF的方法。但記住他們使用的不是UTF-8;他們用的是修改後的UTF-8編碼:NUL字符不是用一個字節的0x00來表示,而是用兩個字節的0xC0 0x80來表示的,在最後添加一個字節的0x00。這樣編碼,字符串包含NUL字符而不需要增加表示字符串長度的前綴字段--這樣C語言<string.h>中定義的strlen()和strcpy這些函數就可以用來操作這些數據了。/<string.h>

一些練習

  1. 如何處理輸入的中文參數,例如中文參數的字符個數打印出來?
  2. 在json串中遇到了這樣的字符串,是什麼意思呢?"\\u82f9\\u679c\\u624b\\u673a"


分享到:


相關文章: