引言
以前用 EBCDIC 和 ASCII 編碼,(別看只有兩種編碼),但事情從來沒有簡單過,恰恰相反變得越來越復雜了。但據推測,編碼簡化就像(黎明前)地平線上閃過了一道光,但要等到天亮還得 50 年。
早期計算機是從美國、英國、澳大利亞這些英語國家發展起來的,結果計算機字符集就以這些國家使用的語言和字符進行設計,大體上,也就是拉丁字母
,加上數字
、標點
和別的字符
。他們使用 ASCII 或 EBCDIC 進行編碼。
字符處理的機制是基于此的:文本文件和基于字節序列的基本輸入輸出,每個字節代表一個單獨的字符。字符串比較可以通過對比相對應的字節實現,字符串的大小寫轉換可以通過單個字節的操作完成,等等。
用理工科的眼光看,世界上只有 ASCII 一種編碼就清靜了。但實際正是相反的趨勢,越來越多的人需要計算機軟件中使用自己熟悉的語言。如果你的軟件可以在不同的國家運行,那你的用戶就需要軟件使用他們自己的語言。在分布式的系統中,使用不同的系統模塊的人可能希望不同的語言和字符。
國際化(i18n)
是指你的應用怎么處理不同的語言和文化。本地化(l10n)
是說你怎么把國際化的應用適配成小群體使用。
國際化和本地化各自都是一個很大的課題。舉個例子,關于顏色的話題:白色在西方表示純潔,在中國表示死亡,在埃及表示喜悅。在這章中我們只關注字符的處理。
定義
我們所關心的是系統處理你所表述的內容,十分重要。下面是有人做的一套行之有效的定義方法。
字符
字符
是"自然語言中用符號表示信息的單位,比如字母、數字、標點"(維基百科),字符是有價值的最小書寫單位(Unicode)這就包括了 a 和 A,或其他語言字符,也包括數字 2和標點',',還有像 '£'這樣的字符。
字符實際上是符號的抽象組合,也就是說 a 代表了所有手寫的 a,有點像柏拉圖圓也是圓的關系。原則上字符也包括控制字符,也就是實際中不存在只是為了處理語言的格式用的。
字符本身并不沒有特定形狀,只是我們通過形狀來識別它。即使如此,我們也要聯系上下文才能理解:數學中,如果你看到 π (pi)這個字符,它表示圓周率,但是如果你讀希臘文,它只是 16 個字母;"προσ"是希臘詞語“with”,這個和 3.14159 沒有半點關系。
字符體系和字符集
字符集
就是一個不同的且唯一的字符的集合,像拉丁字母,不需要指定順序。在英語中,盡管我們說 a 是在 z 的前面,但我們不說 a 比 z 要小。電話聯系人的排序方式里,McPhee 在MacRea 的前面說明了字母排序不是嚴格的按字符的順序。
字符體系
就是字名和字形的結合,比如,a 可能寫成 'a', 'a' or 'a',但這不是強制的,他們只是樣本。字符體系可能區分大小寫,所以 a 和 A 是不同的。但他們的意思可能是一樣的,就算是長的不一樣。(有點像編程語言對待大小寫,有的大小寫敏感,比如 Go 語言,有的就是一樣的,比如 Basic。)。另一方面,字符系統可能包括長的一樣但意義不同的:希臘字母的數學符號就有兩個意思,比如 π。他們也被叫成無法編碼的字符集。
字符編碼
字符編碼
是字符到整數的映射。一個字符集的映射也被稱為一個編碼字符集
或字符集
。這個映射中的每個字符的值通常被稱為一個編碼(code point)。 ASCII 也是一個字符集,'a'的編碼是 97,'A'是 65(十進制)。
字符編碼仍然是一個抽象的概念。它不是我們可以看到的文件或者 TCP 的包。不過,確和這兩個概念很像,它就是一種把人抽象出來的概念轉化為數字的映射關系。
字符編碼
字符的交互(傳輸)和存儲都要以某種方式編碼。要發送一個字符串,你需要將字符串中的所有字符進行編碼。每種字符集都有很多的編碼方案。
例如,7 位字節 ASCII 編碼可以轉換成 8 位字節(8 進制)。所以,ASCII 的'A'(編碼值 65)可以被編碼為 8 進制的 01000001。不過,另一種不同的編碼方式對最高位別有用途,如奇偶校驗,帶有奇校驗的 ASCII 編碼“A”將是這個 8 進制數11000001。還有一些協議,如 Sun的 XDR,使用 32 位字長編碼 ASCII 編碼。所以,'A'將被編碼為0000000000000000000000001000001。
字符編碼是在程序應用層面使用的。應用程序處理編碼的字符時,是否帶包含奇偶校驗處理8 位字符或 32 位字符,顯然有很大的差別。
把字符編碼擴展到字符串。一個字節寬、帶有奇偶校驗的“ABC”編碼為 10000000(高位奇偶校驗)0100000011(C)01000010(B)01000001(A 在低位)。對于編碼在字符串上的討論也很重要,雖然編碼規則可能不同。
編碼傳輸
某個應用程序的字符編碼只要內部能處理字符串就足夠了。然而,一旦你需要在不同應用程序之間交互,那怎么編碼可就成了需要進一步討論問題了:字節、字符、字是怎么傳輸的。字符編碼可能有很多空白字符(待商議),從而可以使用如 zip 算法對文本進行壓縮,從而節省帶寬。或者,它可以減少到 7 位字節,奇偶校驗位,使用 base64 編碼來代替。
如果我們知道的字符編碼和傳輸編碼,那么問題就成了如何通過編程處理字符和字符串;如果我們不知道字符編碼和傳輸編碼,那么如何猜到某個特定字符串的編碼方式就是大問題。因為沒有約定發送文件的字符編碼
不過,在互聯網上傳輸文本的編碼是有約定的。很簡單:文本消息頭包含的編碼信息。例如,HTTP 報頭可以包含這么幾行,如
Content-Type: text/html; charset=ISO-8859-4
Content-Encoding: gzip
上面是說,將字符集是 ISO 8859-4(對應到歐洲的某些國家)作為默認編碼,然后用 gzip壓縮。內容類型的第二部分就是我們指的是“傳輸編碼”(IETF RFC2130)。
但是,怎么讀懂這個信息呢?它沒有編碼?這不就是先有雞還是先有蛋的問題么?嗯,不是的。按照慣例,這樣的信息使用 ASCII 編碼(準確地說,美國 ASCII),所以程序可以讀取headers,然后適配其文檔的其余部分的編碼。
ASCII 編碼
ASCII 字符集包含的英文字符
、數字
,標點符號
和一些控制字符
。
最常見的** ASCII 編碼使用 7 位字節**,所以 A 的碼是 65。
這個字符集是實際的美國 ASCII。鑒于歐洲需要處理重音字符,于是省略一些標點字符,形成一個最小的字符集,ISO 646,同時有合適的歐洲本國字符的“國家變種字符集”。有興趣的可以看看 Jukka Korpel 的這個網頁 http://www.cs.tut.fi/?jkorpela/ chars.html。
ISO 8859 字符集
8 進制是字節的標準長度。這使得 ASCII 可以有 128 個額外的編碼。 ISO 8859 系列的字符集可以包含眾多的歐洲語言字符集。。 ISO 8859-1 也被稱為 Latin-1
,覆蓋了許多在西歐國家的語言,同時這一系列的其他字符集包括歐洲其他國家,甚至希伯來語,阿拉伯語和泰語。例如,ISO 8859-5 包括使用斯拉夫語字符的俄羅斯等,而 ISO 8859-8 則包含希伯來文字母。
這些字符集使用 8進制作
為標準的編碼格式。例如,在 ISO 8859-1 字符' 'á'的字符編碼為 193,同時被編碼為 193。所有的 ISO 8859 系列前 128 個保持和 ASCII 相同的值,所以,ASCII 字符在所有這些集合都是相同的。
HTML 語言規范
曾經推薦 ISO 8859-1 字符集,不過 HTML3.2 之后的規范就不再推薦,4.0 開始推薦 Unicode 編碼。2010 年 Google 通過它抓取的網頁做出了一個估算,20%的網頁使用ISO 8859 編碼,20%使用 ASCII(unicode 接近 50%,
Unicode 編碼
ASCII 和 ISO 8859 都不能覆蓋象形文字。中文大約有 20000 個獨立的字符,其中 5000 個常用字符。這些字符需要不止一個字節,基本上雙字節都會被用上。也有一些多字節的編碼:中文的 Big5, EUC-TW, GB2312 和 GBK/GBX,日文的 JIS X 0208,等等。這些編碼通常是不兼容的
Unincode
是一個受到擁護的字符集編碼標準,旨在統一主要使用的編碼。它包含了歐洲文字、亞洲文字和印度文字等。現在 Unicode 已經到了 5.2 的版本,包含 107,0000 個字符。編碼字符超過 65536,也就是 2^16。這已經覆蓋了整個編碼。
(Unicode 編碼)前 256 個編碼對應 ISO 8859-1,同時前 128 個也是美式 ASCII 編碼。所以主流的編碼都是相互兼容的,ISO 8859-1、ASCII 和 Unicode 是一樣的。對其他字符集則不一定正確:例如,雖然 Big5 編碼也在 Unicode 中,但他們的編碼值并不相同。http://moztw.org/docs/big5/table/unicode1.1-obsolete.txt 這個頁面就是證明:一張 Big5 到Unicode 的大的映射表。
為了在計算機系統中表示 Unicode 字符,必須使用一個編碼方案。UCS 編碼使用兩個字節來編碼一個字符值。然而,Unicode 現在有太多的字符需要對應到雙字節的編碼。以下方案是替代原來陳舊的編碼方案的:
- UTF-32 使用 4 個字節編碼,但是已經
不再推薦
,HTML5 甚至嚴重警告反對使用 - UTF-16 是最常見的,它通過溢出兩個字節來處理 ASCII 和 ISO 8859-1 外的字符
- UTF-8 每個字符使用 1 到 4 個字節,所以 ASCII 值不變,但 ISO 8859-1 的值會變化
- UTF-7 有時會用到,但
不常見
UTF-8, Go 語言和 runes
UTF - 8
是最常用的編碼。谷歌估計它抓取的網頁有 50%使用 UTF-8 編碼。ASCII 字符集具有相同的在 UTF-8 中編碼值相同,所以 UTF-8 的讀取方法可以用 Unicode 字符集讀取一個ASCII 字符組成的網頁。
Go 語言使用 UTF-8 編碼字符串。每個字符類型都是 rune
。rune 是 int32 的一個別名,因為Unicode 編碼可以是 1,2 或 4 個字節。字符和字符串其實都是一個 runes 的數組
Unicode 中一個字符串其實是一個字節數組,但是你要注意:只有 ASCII 這個字符集是一個字節等于一個字符。所有其他字符占用 2 個,三個或四個字節。這意味著,一個字符串的長度(runes)通常是不一樣的長度的字節數組。他們只有在全是 ASCII 字符是才相同。
下面的程序片段可以說明這些。如果我們使用 utf-8 來檢驗它的長度,你只會得到它字符層面的長度。但如果你把字符串轉換成 rues 數組[]rune
,你就等到一個 Unicode 編碼的數組:
str := "百度一下,你就知道"
println("String length", len([]rune(str)))
println("Byte length", len(str))
輸出為
String length 9
Byte length 27
UTF-8 編碼的客戶端和服務端
可能令人驚訝的是,無論是客戶端或服務器你不需要對 utf-8 的文本做任何特殊的處理。UTF-8 字符串的數據類型是一個字節數組,如上所示。Go 語言自動處理編碼后的字符串是1,2,3 或 4 個字節。所以 utf-8 的字符串你可以隨便寫。
類似于讀取字符串,只要讀入一個字節數組,然后使用 string([]byte)將數組轉換成一個字符串。如果 Go 語言不能正確解碼,將字節轉換為 Unicode 字符,那么它給使用 Unicode 替換字符\uFFFD。生成的字節數組的長度是有效字符串的長度。
所以前面章節中提到的客戶端和服務端使用 uft-8 編碼表現的很好
ASCII 編碼的客戶端和服務器
ASCII 字符的 ASCII 編碼和 UTF-8 編碼的值相同,所以普通的 UTF-8 字符能正常處理 ASCII字符,不需要做任何特殊的處理。
Go 語言和 utf-16
utf-16 編碼
可以用 16 位字節無符號整形數組處理。 utf16 包
就是用來處理這樣的字串的。將一個 Go 語言的 utf-8 正常編碼的字串轉換 utf-16 的編碼,你應先將字串轉換成[]rune數組,然后使用 utf16.Encode 生成一個 uint16 類型的數組。
同樣,解碼一個無符號短整型的 utf-16 數組成一個 Go 字符串,你需要 utf16.Decode 將編碼轉換成[]rune ,然后才能改成一個字符串。如下面的代碼所示:
str := "百度一下,你就知道"
runes := utf16.Encode([]rune(str))
ints := utf16.Decode(runes)
str = string(ints)
類型轉換需要客戶端和服務器在合適的時機讀取和寫入 16 位的整數
Little-endian 和 big-endian
然而,UTF-16 編碼潛藏著一個小的惡魔。它基本上是一個 16 字節字符編碼。最大的問題是:每一個短字,是如何拼寫的?高位在前還是高位在后?無論哪種方式,只要是發生器和接收器約定好就可以
Unicode
通過一個特殊字節標記了尋址方式,這個字節就被稱為 BOM(字節順序標記)。這是一個零寬度非打印字符,所以你永遠不會在文本中看到它。但是它通過 0xFFFE 的值,可以告訴你編碼的順序
- 在 big-endian 系統中,它是 FF FE
- 在 little-endian 系統中,它是 FE FF
有時 BOM 會位于文本的第一個字符。文本被讀入時可以檢查,以確定使用的是那種系統。
UTF-16 編碼的客戶端和服務器
根據 BOM 的約定,服務器可以預先設置 BOM 來表示 utf-16,如下
/* UTF16 Server
*/
package main
import (
"fmt"
"net"
"os"
"unicode/utf16"
)
const BOM= '\ufffe'
func main() {
service := "0.0.0.0:1210"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for{
conn, err := listener.Accept()
if err != nil {
continue
}
str := "j'ai arrêté"
shorts := utf16.Encode([]rune(str))
writeShorts(conn, shorts)
conn.Close() // we're finished
}
}
func writeShorts(conn net.Conn, shorts []uint16) {
var bytes [2]byte
// send the BOM as first two bytes
bytes[0] = BOM>> 8
bytes[1] = BOM&255
_, err := conn.Write(bytes[0:])
if err != nil {
return
}
for _, v := range shorts {
bytes[0] = byte(v >> 8)
bytes[1] = byte(v & 255)
_, err = conn.Write(bytes[0:])
if err != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
但客戶端讀取一個字節流,提取并檢查 BOM 時解碼該流的其余部分的。
/* UTF16 Client
*/
package main
import (
"fmt"
"net"
"os"
"unicode/utf16"
)
const BOM= '\ufffe'
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
shorts := readShorts(conn)
ints := utf16.Decode(shorts)
str := string(ints)
fmt.Println(str)
os.Exit(0)
}
func readShorts(conn net.Conn) []uint16{
var buf [512]byte
// read everything into the buffer
n, err := conn.Read(buf[0:2])
for true {
err := conn.Read(buf[n:])
if m == 0 || err != nil {
break
}
n += m
}
checkError(err)
var shorts []uint16
shorts = make([]uint16, n/2)
if buf[0] == 0xff && buf[1] == 0xfe {
// big endian
for i := 2; i <n; i += 2 {
shorts[i/2] = uint16(buf[i])<<8 + uint16(buf[i+1]) )<<8 + uint16(buf[i+1])
}
} else if buf[1] == 0xff && buf[0] == 0xfe {
// little endian
for i := 2; i < n; i += 2 {
shorts[i/2] = uint16(buf[i+1])<<8 + uint16(buf[i]) 1])<<8 + uint16(buf[i])
}
} else{
// unknown byte order
fmt.Println("Unknown order")
}
return shorts
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
Unicode 的疑難雜癥
這本書不是有關國際化問題。特別是,我們不想鉆研的神秘的 Unicode。但是你應該知道,Unicode 不是一個簡單的編碼,也有很多的復雜的地方。例如,一些早期的字符集用非空格字符,尤其是重音字符。這些重音字符要轉換成 Unicode 可以用兩種辦法:作為一個 Unicode字符,或作為一個非空格字符和非重音字符的組合。例如, U+04D6 CYRILLIC CAPITAL LETTER IE WITH BREVE 是一個字符。這是相當于 U+0415 CYRILLIC CAPITAL LETTER IE 和 U+0306 加上 BREVE.。這使得字符串比較有時變得困難了。 GO 規范確目前沒有對這個問題過深研究。
ISO 8859 編碼和 Go 語言
ISO 8859 系列字符集都是 8 位字符集,他們為歐洲不同地區和其他一些地方設計。他們有相同的 ASCII 并且都在地位,但高位不同。據谷歌估計,ISO 8859 編碼了盡 20%的網頁。
第一個編碼字符集,ISO 8859-1 或叫做 Latin-1,前 256 個字符和 Unicode 相同。 Latin-1 字符的 utf-16 和 ISO 8859-1 有相同的編碼。但是,這并不真的有用,因為 UTF-16 是一個 16位的編碼字符集而 ISO 8859-1 是 8 位編碼。 UTF-8 是一種 8 位編碼,但是高位用來表示更多的字符,所以只有 ASCII 的一部分是 utf-8 和 ISO 8859-1 相同,所以UTF-8 并沒有多大實際用途(都是 8 位的)。
但 ISO8859 系列沒有任何復雜的問題。每一組中的每個字符對應一個唯一的 Unicode 字符。例如,在 ISO 8859-2 中的字符“latin capital letter I with ogonek”在 ISO 8859-2 是 0xc7(十六進制),對應的 Unicode 的 U+012E。 ISO 8859 字符集和 Unicode 字符集之間轉換其實只是一個表查找。
這個從 ISO 8859 到 Unicode 的查找表,可以用一個 256 的數組完成。因為,許多字符索引相同。因此,我們只需要一個標注不同索引的映射就可以。
ISO 8859-2 的映射為
var unicodeToISOMap = map[int] uint8 {
0x12e: 0xc7,
0x10c: 0xc8,
0x118: 0xca,
// plus more
}
從 utf-8 轉換成 ISO 8859-2 的函數
/*Turn a UTF-8 string into an ISO 8859 encoded byte array 8 string into an ISO 8859
*/
func unicodeStrToISO(str string) []byte {
// get the unicode code points
codePoints := []int(str)
// create a byte array of the same length
bytes := make([]byte, len(codePoints))
for n, v := range(codePoints) {
// see if the point is in the exception map tion map
iso, ok := unicodeToISOMap[v]
if !ok {
// just use the value
iso = uint8(v)
}
bytes[n] = iso
}
return bytes
}
同樣你可以將 ISO 8859-2 轉換為 utf-8
var isoToUnicodeMap = map[uint8] int {
0xc7: 0x12e,
0xc8: 0x10c,
0xca: 0x118,
// and more
}
func isoBytesToUnicode(bytes []byte) string {
codePoints := make([]int, len(bytes))
for n, v := range(bytes) {
unicode, ok :=isoToUnicodeMap[v]
if !ok {
unicode = int(v) unicode = int(v) unicode = int(v)
}
codePoints[n] = unicode
}
return string(codePoints)
}
這些函數可以用來將 ISO 8859-2 當作 UTF-8 來讀寫。通過改變映射表,可以覆蓋其他的 ISO8859 字符集合。Latin-1 字符集(ISO 8859-1)是一個特殊的情況:地圖映射為空,因為字符在 Latin-1 和 Unicode 中編碼相同。同樣的方法,你也可以使用其他字符集構建映射表,如Windows1252。
其他字符集和 Go 語言
還有非常非常多的字符集編碼。據谷歌稱,這些字符集通常只有很少地方使用,所以可能用的會更少。但是,如果你的軟件要占據所有市場,那么你可能需要對這些字符集進行處理。
在最簡單的情況下,查找表就夠了。但是,這樣也不是總是奏效。ISO 2022 字符編碼方案通過……。這是從日本某寫編碼中借用來個,相當復雜。
Go 語言目前在語言本身和包文件上支持其他字符集。所以,你要么避免使用其他字符集,雖然沒法和用這些字符集的程序共存,要么自己動手寫很多代碼。
總結
這一章沒有什么代碼,卻有幾個非常復雜的概念。當然,也取決于你:你要只滿足說美式英語的人,那問題就簡單了;要是你的應用也要讓其他人可用,那你就要在這個復雜的問題上花點精力了。