引言
以前用 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 语言目前在语言本身和包文件上支持其他字符集。所以,你要么避免使用其他字符集,虽然没法和用这些字符集的程序共存,要么自己动手写很多代码。
总结
这一章没有什么代码,却有几个非常复杂的概念。当然,也取决于你:你要只满足说美式英语的人,那问题就简单了;要是你的应用也要让其他人可用,那你就要在这个复杂的问题上花点精力了。