Golang基础--字符串编码(Unicode&UTF-8&Rune)

本文主要是介绍Golang字符的编码以及存储。主要涉及以下几方面:1.几种字符串编码形式(ASCII, UNICODE, UTF-8)之间的关系;2.golang中的rune。

开头

首先要理解一个问题,无论是什么文字或者符号都是计算机不同的展示方式,在内部存储的都是一个个字节,所有语言都是一样的,因为计算机只能认识0和1。所以你要存储所谓的字符,计算机也是将其翻译成0和1来存储。对计算机来说,没有所谓的字符,都是一些字节而已。那这些字符是如何按照0,1存储,最终有如何解释的呢?这个不同的语言是不同的。这篇文章只说说golang里面的表示。

从一个例子说起

 1func main() {
 2    str1 := "enlish"
 3    fmt.Printf("plain string: %s \n",str1)
 4    fmt.Printf("hex bytes: ")
 5    for i := 0; i < len(str1); i++ {
 6        fmt.Printf("%x ", str1[i])
 7    }
 8    fmt.Printf("\n")
 9    for i := 0; i < len(str1); i++ {
10        fmt.Printf("%q ", str1[i])
11    }
12    fmt.Printf("\n")
13   
14    str2 := "中国人"
15    fmt.Printf("plain string: %s \n",str2)
16    fmt.Printf("hex bytes: ")
17    for i := 0; i < len(str2); i++ {
18        fmt.Printf("%x ", str2[i])
19    }
20    fmt.Printf("\n")
21    for i := 0; i < len(str2); i++ {
22        fmt.Printf("%q ", str2[i])
23    }
24    fmt.Printf("\n")
25}

输出结果:

1plain string: enlish 
2hex bytes: 65 6e 6c 69 73 68 
3'e' 'n' 'l' 'i' 's' 'h' 
4plain string: 中国人 
5hex bytes: e4 b8 ad e5 9b bd e4 ba ba 
6'ä' '¸' '\u00ad' 'å' '\u009b' '½' 'ä' 'º' 'º' 
这是两个不同的字符串,一个是英文,一个是中文,但是在计算机内部存储的都是一个个字节,hex bytes便是这些字符在计算机的内部存储字节按照16进制的输出。"enlish"存储的第一个字节是65,用%q进行转义打印输出e,对比ASCII表我们知道e的ASCII码值就是65,所以我们合理猜想,golang将我们的字符都按照ASCII码存储了。
但是对中文 "中国人",发现不一样了。首先中文明显使用了更多的字节来存储,而且在针对每个单独的字节按照ASCII进行输出的时候,发现输出的并不是我们的一开始设置的一个个中文"字",而是一些不认识的字符。到这里发现golang好像又不是按照ASCII来存储的,因为我们知道ASCII码值最只到127,里面并没有中文字符,那golang里到底是按照什么存储字符的呢?

字符编码-ASCII

我们首先要有个基础的认知,那就是我们在按照0和1存储字符的时候,一定是经过某种形式的"翻译"的,也就是肯定是有种规则来指导字符和0和1序列的相互转换。这里所说的规则就是字符集。
先来看一个字节的二进制表示:01100101,现在问问自己这个表示什么意思,你是不是不认识?因为这是计算机的语言,要让我们人能看懂,我们需要个 "翻译" ,将其译成我们看懂的语言。那我们就人为的规定每个字符在计算机里怎么表示,形成一个对照表。拿英语来说,e在计算机里就表示为145,也就是刚刚的二进制表示01100101,那计算机在输出的时候,只要一查这个对照表就能准确的输出的e这个字符,这样我们就能看懂了。这里的编码(01100101 = e )就是字符编码,而一个个的字符编码就是字符集。而ASCII表就是这样一个字符集,ASCII码一共规定了128个字符的编码,也就是一个字节的7位,最前面的一位永远是0,这对英文使用者来说已经够了,但是对非英语使用者来说,这还不够。

扩展ASCII码表

这里的不够不仅仅是说一个字节256个字符不能表示一门语言,而是说在用来表示不同语言的时候会有混淆。首先由于先发优势,0~127已经在ASCII有明确的定义,而且已经在全世界流行使用了,这时候如果你要表示其他的字符,就只能用剩下的128位来表示。这时候一些国家或者企业就将字节的最高位也拿来编码,比如上面输出的ä,可以认认为属于一种扩展ASCII码表(其实是Unicode码点,下一节会讲到),对应的二进制为11100100。IBM-PC 有一些后来被称为OEM字符集的东西,它为欧洲语言提供了一些重音字符和一堆画线字符……水平条、垂直条、右侧带有小吊坠的水平条等,您可以使用这些画线字符来制作不同的图形。但是不同国家制定的标准是不一样的。比如说字符代码130显示为é,但在以色列的计算机中显示的却是希伯来语字Gimel。如果真的这样发展下去,那大家都没法使用这剩余的128位,最终这种混乱局面被编入了ANSI标准。
在ANSI标准中,每个人都同意在128以下做什么,其实就是按照ASCII来编码。但是有很多不同的方法来处理 128 及以上的字符,具体取决于您居住的地方。不同地区的不同系统会去使用不同的代码页(其实就是不同的字符集)来进行字符的编码和翻译。例如,在以色列DOS使用名为862的代码页,而希腊用户使用737。它们在128以下相同,但与128以上不同,t特殊的字母都驻留在 128 以上。MS-DOS 的国家版本有几十个这样的代码页,可以处理从英语到冰岛语的所有内容。但是,在同一台计算机上显示希伯来语和希腊语是完全不可能的,因为你不可能一个系统处理显示不同的代码页。而且这还没说到亚洲的语言,因为亚洲的语言更复杂,有几千或者几十万个字符表示,一个字节是无法容纳这些所有的字符的,可能需要两个甚至更多的字节表示。这就导致一个什么问题?你从亚洲发一封邮件到欧洲,显示出来的很可能就是一大堆的乱码表示。
到这里可以发现,目前的字符编码主要有两个缺陷,一个就是不同的地区使用的是不同的字符集,无法统一。第二个就是无法将全世界所有语言进行字符编码.这个时候,Unicode出现了。

Unicode

Unicode所做的就是统一所有语言的字符的编码表示,用单一字符集表示地球上所有的字符,每一个符号都给予一个独一无二的编码(在Unicode里称为代码点),那么乱码问题就会消失。
Unicode是一个很大的集合,现在的规模可以容纳100多万个符号。每个符号的编码都不一样,比如,U+0639表示阿拉伯字母Ain,U+0041表示英语的大写字母A,U+4E25表示汉字严(上面示例输出的\u00ad和\u009b就是unicode码)。具体的符号对应表,可以查看unicode官网,或者专门的汉字对应表。U+XXXX这种编码形式在unicode里叫做code point(代码点),也是一种字符编码,所以Unicode也就是一种字符集,跟ASCII所起到作用是一样的,但是Unicode的存储可没有ASCII那么简单。
比如计算机里存储了两个字节:4E25,请问这两个字节到底是按照Unicode翻译成 '严' , 还是按照ASCII翻译成 N% 呢?这是遇到的第一个问题,也就是如何才能区别 Unicode 和 ASCII?计算机怎么知道两个字节表示一个符号,而不是分别表示两个符号呢?
再来看一个,Hello 转成unicode码表示为: U+0048 U+0065 U+006C U+006C U+006F
这里显示是一堆代码点,那如何存储这些代码点呢?unicode早期的想法是将这些统一都存在两个字节中(所以有人认为unicode都是两个字节的),就变成了:00 48 00 65 00 6C 00 6C 00 6F
但真的太浪费空间了。这就是Unicode代码点编码所遇到的第二个问题,如何合理的表示以及存储这些代码点? 为了解决这些问题(这里还有大小端的顺序问题,为了不影响理解的顺序性,这里不做介绍,有兴趣的同学可以自行网上查找相关知识),就出现了多种不同的编码形式,比如UCS-2(因为它有两个字节)或 UTF-16(因为它有 16 位),UTF-8等。这里UTF-8用的最多,所以本篇文章主要来说说UTF-8。

UTF-8

首先声明一点,UTF-8只是表示Unicode一种表示形式,你也可以用比如上面说的UCS-2或者UTF-x编码形式来表示unicode的代码点,你甚至可以使用老旧的OEM编码,只要能将Unicode编码正确的存储以及翻译出来就行。但是你用这些编码存储的unicode代码点,其他计算机不一定能认识,主要是这些编码形式不怎么通用,比如你用UCS-2编码了一个Unicdode代码点,另一台计算机用的UTF-8那就没法认识这个字节表示。所以一般建议直接使用UTF-8编码,这是目前最流行的编码形式,现在就来说说这个UTF-8编码。 UTF-8最大的特点就是它是变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。UTF-8 的编码规则很简单,只有二条:

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
  2. 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

可以发现在UTF-8 中,从0到127的每个代码点都存储在单个字节中。只有代码点128及以上才使用2、3个字节(实际上最多6个字节)来存储。

unicode_utf8
根据上表,解读 UTF-8 编码非常简单。如果一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。
下面,还是以汉字为例,演示如何实现 UTF-8 编码。
严的 Unicode 是 4E25(100111000100101),根据上表,可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),
因此严的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,严的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是E4B8A5.
到这里,字符编码的相关知识就已经说完了。为什么用这么多篇幅讲这些编码知识,因为golang就是以UTF-8进行编码的。
golang中所有字符都是UTF-8编码的,不仅如此,GO的源代码都是被定义为UTF-8,也就是说在golang中任何字符串都按照UTF-8形式存储。回到我们一开始举得例子:65 其实是UTF-8对Unicode代码点U+0065的表示(其实等同于ASCII码,因为UTF-8针对<128的字符与ASCII是一致的)。而 e4 就是Unicode代码点 U+00e4,表示的就是 ä ,ad 由于是不可打印字符,所以直接显示了Unicode代码点 '\u00ad' ,到这里我们已经能够知道,例子中输出的字符都是什么含义了。
到这里,我们已经算是彻底知道golang里如何进行字符串编码和表示的了,那还有最后一个问题,为什么"中国人"输出来的是一堆代码点,而不是输出来的对应的字符呢?以及如何能够分字符输出'中','国','人'。

golang中的代码点--rune

为什么输出来的是一堆代码点?文章一开始的时候我们说了,字符在计算机中都是一个个字节表示,当我们用for 下标去遍历的时候,只是遍历的一个个字节,再将字节按照字符编码进行转义,golang中就是按照utf-8转义。比如e4,按照我们上面所说的UTF8和Unicode转换算法,我们知道e4就是代码点u+00e4,而对应的可输出文字'ä'。
那怎样才能正确输出'中','国','人'三个字呢?我们只要正确取到对应的代码点即可。'中' 的unicode为U+4e2d,对应的UTF-8为 0xE4 0xB8 0xAD,这与我们样例输出是一致的,这也验证了我们前面说的golang的字符串值是UTF-8编码。我们再试着将这个UTF-8编码值输出:

1func main() {
2	hexStr := "e4b8ad"
3	data, _ := hex.DecodeString(hexStr)
4	fmt.Printf("%s\n",data)
5}
发现的确输出是'中'。
那如何可以输出这些中文字符呢?换个问法,就是golang里面如何正确去表示非英文字符呢?或者准确的说如何去表示这个UTF-8编码的代码点呢?用代码点实在有点拗口,所以golang引入了rune类型。该类型完全等同于代码点,它其实是int32的别名,所以我们知道它始终占据四个字节(utf-8是变长的)。现在我们可以尝试使用rune输出我们的'中'.
1func main() {
2	var r rune = '\u4e2d'
3	fmt.Printf("%q\n",r)
4}
在Go中,一个rune值表示一个Unicode码点。 一般说来,我们可以将一个Unicode码点看作是一个Unicode字符(但是Unicode字符可以由多个Unicode码点组成)。 每个英文或中文Unicode字符值含有一个Unicode码点。 一个rune字面量由若干包在一对单引号中的字符组成。 包在单引号中的字符序列表示一个Unicode码点值。 rune字面量形式有几个变种,其中最常用的一种变种是将一个rune值对应的Unicode字符直接包在一对单引号中。比如:'a',下面这些rune字面量形式的变种和'a'是等价的(字符a的Unicode值是97)
1'\141'   // 141是97的八进制表示
2'\x61'   // 61是97的十六进制表示
3'\u0061'
4'\U00000061'
注意:\之后必须跟随三个八进制数字字符(0-7)表示一个byte值, \x之后必须跟随两个十六进制数字字符(0-9,a-f和A-F)表示一个byte值, \u之后必须跟随四个十六进制数字字符表示一个rune值(此rune值的高四位都为0), \U之后必须跟随八个十六进制数字字符表示一个rune值。 这些八进制和十六进制的数字字符序列表示的整数必须是一个合法的Unicode码点值,否则编译将失败。
总而言之,只需要记住三点:

  1. Rune在golang中代表的是一个UTF-8编码的Unicode代码点;
  2. 由于是int32类型,所以其实可以存储任何整型值;
  3. Rune式固定的4字节,而UTF-8是变长的. By the way,上面的样例我们已经可以看到常规的for循环只是输出了原始的字节,并不能输出我们单个中文字符。那我们可以借助golang中另一种形式的遍历进行rune形式表示的代码点输出.
    1func main() {
    2    const chinese = "中国人" 
    3    for index, runeValue := range chinese { 
    4        fmt.Printf("%#U 从字节位置 %d 开始\n", runeValue, index) 
    5    }
    6}
    输出:
    1U+4E2D '中' 从字节位置 0 开始
    2U+56FD '国' 从字节位置 3 开始
    3U+4EBA '人' 从字节位置 6 开始
    详细的使用可以参考golang的UTF8库.

总结

到这里,我们基本聊完了golang中的字符/字符串存储,编码和显示。我们做下总结:

  1. golang的字符串只是一个只读的字节切片,它并不是一个个字符组成,这个是跟其他语言不同的地方。
  2. golang的字符基本都是以UTF-8存储的,如果字符串中含义转义字符,那就可能不是UTF-8编码存储的(可能不是正确的utf-8编码)。
  3. UTF-8只是Unicode一种编码存储形式,用以存储和翻译一个Unicode代码点.除了UTF-8外,还有其他很多编码形式表示Unicode代码点.
  4. golang中对代码点有一个特殊的表示形式,就是Rune,它是int32的别名,固定为4字节.
  5. golang中没有字符的含义,只有原始字节.

参考文章

  1. https://blog.csdn.net/Deft_MKJing/article/details/79460485
  2. https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
  3. https://go.dev/blog/strings