字符编码

我们都知道,程序中的所有信息都是以二进制的形式存储在计算机的底层,也就是说我们在代码中定义的一个 char 字符或者一个 int 整数都会被转换成二进制码储存起来,这个过程可以被称为编码,而将计算机底层的二进制码转换成屏幕上有意义的字符(如“hello world”,”你好,世界”),这个过程就称为*解码

在计算机中字符的编解码就涉及到 字符集(Character Set) 这个概念,他就相当于能够将一个字符与一个整数一一对应的一个映射表,常见的字符集有 ASCII、Unicode 等

很多时候我们会将字符集的编码字符集混为一谈,从这里就可以看出它们并非同一个概念,字符集仅仅是一个字符的集合,而编码却是一个更复杂的过程。至于为什么会经常将这两个概念放在一起,他们之间的联系是什么,我们经常使用的 UTF-8 又是什么,这就是本文需要分享的内容。

注意:
从字符集中找一个字符或数字对应的值的过程,不是编码过程。

ASCII 编码

历史中的很长一段时间里,计算机仅仅应用在欧洲的一些发达国家,因此在他们的程序中只存在他们所理解的拉丁字母(如a、b、c、d等)和阿拉伯数字,他们在编码解码时也只需要考虑这一种情况,就是如何将这些字符转换成计算机所能理解的二进制数,此时 ASCII 字符集应运而生,他们在编码时只需要对照着 ASCII 字符集,每当在程序中遇到字符 a 时,就会相应的找到其中 a 对应的 ASCII 值 97 然后以二进制形式存起来即可。

下图为 ASCII 字符集对照表,其中包括了控制字符(回车键、退格、换行键等)和可显示字符(英文大小写字符、阿拉伯数字和西文符号)

一文彻底搞定字符编码 - 图1

这种编码方式就被称为ASCII编码,从字符集对照表中可以看出,ASCII 字符集支持 128 种字符,仅使用 7 个 bit 位,也就是一个字节的后 7 位就可以将它们全部表示出来,而最前面的一位统一规定为 0 即可(如 0110 0001 表示 a)
以上对早期的计算机世界已经足够了,但是让世界上众多使用其它语言的人无法在计算机上使用自己的文书体系。随着互联网的兴起,包含纷繁语言的数据屡见不鲜。到底怎样才能应对语言的繁杂多样,还能兼顾高效率?答案就是Unicode

Unicode

它将世界上所有的符号都纳入其中,成功实现了每个数字代表唯一的至少在某种语言中使用的符号,目前,Unicode 字符集中已经收录超过 13 万个字符(第十万个字符在2005年获采纳)。值得关注的是,Unicode 依然兼容 ASCII,即 0~127 意义依然不变

每一个符号在Unicode字符集中对应的标准数字叫做Unicode码点。在Go的术语中,这些字符记号称为文字符号(rune)天然适合保存单个文字符号的数据类型就是int32,而Go恰好采用的就是这个。正因如此,rune类型作为int32类型的别名

码点

Unicode 表示的是一个字符集,与我们通常所说的 UTF-8、UTF-6 等编码方式并不相同,本节介绍的编号就相当于 ASCII 码中的 ASCII 值,它就是 Unicode 字符集中唯一表示某个字符的标识,在 Unicode 也称作码点(Code Point) ,如码点 U+0061,这里的 61 就是 97 的十六进制表示,它就表示 Unicode 字符集中的字符 ‘a‘

码点的表示的形式为 U+[XX]XXXX,X 代表一个十六制数字,一般可以有 4-6 位,不足 4 位前补 0 补足 4 位,超过则按是几位就是几位,具体范围是 U+0000~U+10FFFF,大概是 111 万。按 Unicode 官方的说法,码点范围就这样了,以后也不扩充了,一百多万足够用了,目前也只定义了 11 万多个字符左右
整个编码过程中码点就作为了一个中间的过渡层,如图:

一文彻底搞定字符编码 - 图2

从这张图可以看出,整个编码可分为两个过程

  • 将程序中的字符根据字符集中的编号数字化为某个特定的数值
  • 根据编号以特定的方式存储到计算机中

显然,这时候我们就可以发现编号并不是最终存储在计算机中的结果。按照之前的理解,编码即把一个字符编码为一个二进制数字存储起来,然而这种表述并不准确,真正的编码不止这么简单,这其中还涉及了每个数字用几个字节表示,是用定长还是变长表示等具体细节

Unicode编码

Unicode 字符集衍生出来的编码方案有三种,分别是 UTF-32、UTF-16 和 UTF-8,这使他与之前的编码模式不同,因为 ASCII、GBK 等类编码模式的字符集和编码方式都是一一对应的,而 Unicode 的编码实现却有三种,这就是我们需要区分字符集与编码的原因之一,因为此时 Unicode 并不特指 UTF-8 或者 UTF-32
通过下图来探究各种编码模式下,码点是如何具体转换成各种编码的

一文彻底搞定字符编码 - 图3


上面表中包含了四个字符的码点,其中也展示了四个不同的码点在 UTF-32、UTF-16 和 UTF-8 三种编码模式下的编码结果。其中:码点到 UTF-32 的转换最简单,就是在前面填充 0,满 4 个字节即可;码点到 UTF-8 的转换,除了最小那个在数值上一样外,其它三个完全看不出两者的关系;码点到 UTF-16 的转换则是最不规则的,可以看出前三个字符 UTF-16 与码点是完全一致的,但那个大码点(准确地说是超过了 U+FFFF 的码点)则有了很大的变化,长度变成了四字节,值也变得很不一样了

这其中又涉及到编码过程中定长变长两种实现方式,这里的 UTF-32 就属于定长编码,即永远用 4 字节存储码点,而 UTF-8、UTF-16 就属于变长存储,UTF-8 根据不同的情况使用 1-4 字节,而 UTF-16 使用 2 或 4 字节来存储码点

定长与变长

为什么要有定长于变长这两种编码形式?在中文的表达中都会有所谓的断句问题,如果我们处理不好断句很有可能会将意思传递错误。
如下面这句来自算命先生纸条中的内容:

大富大贵没有灾难要小心

此时,如果算命侠客这样断句

大富大贵,没有灾难要小心

表示我福大命大,没有灾难,可以肆意妄为了,但是没过多久这位侠客就去世了,算名先生绝望地说,你会错意了,原来,其实是这样断句的

大富大贵没有,灾难要小心

表示你没有大富大贵,出门要小心,断句就可能会出现这样严重的后果
这也是计算机在解码时需要使用定长与变长的原因。因为计算机底层的二进制码也和算命先生纸条中的内容一样,毫无章法,我们如果想要正确理解其中的意思就要有一个约定俗成的规则

UTF-32

在 UTF-32 这种定长的编码方式下就表示每 4 个子节一个断句,那么字符 A 的码点 U+0041(二进制为 1000001)被 UTF-32 编码后就会变成如下形式存储在计算机中

代码解读复制代码00000000 00000000 00000000 01000001

它会将 4 个字节中空出的高位全部填充为 0。这种表示的最大缺点是占用空间太大,因为不管都大的码点都需要4个字节来存储,非常的占空间,那么如何突破这个瓶颈呢?变长方案应运而生

UTF-8

UTF-8 属于变长的编码方式,它可以由 1,2,3,4 四种字节组合,使用的是高位保留的方式来区别不同变长,具体方式如下:

  1. 对于只有一个字节的符号,字节的第一位设为0,后面 7 位为这个符号的 Unicode 码。此时,对于英语字母UTF-8 编码和 ASCII 码是相同的
  2. 对于n字节的符号(n>1),第一个字节的前 n 位都设为 1,第 n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码,如下表所示:
Unicode 码点范围(十六进制) UTF-8 编码方式(二进制) 字节数
0000 0000 ~ 0000 007F 0xxxxxxx 一个字节
0000 0080 ~ 0000 07FF 110xxxxx 10xxxxxx 二个子节
0000 0800 ~ 0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx 三个字节
0001 0000 ~ 0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 四个字节

跟据上表,编码字符时就非常简单了,以汉字 “丑” 为例,它的码点为 0x4E11(0100 1110 0001 0001)在上表的第三行范围(0000 0800 ~ 0000 FFFF)内,因此 “丑” 需要以三个字节的形式编码:

一文彻底搞定字符编码 - 图4

这里最高位的第一个字节中的三个 1 表示该字符占 3 个字节,空出的 16 位 x 就会从 “丑” 的最后一个二进制位开始,依次从后向前填入格式中,多出的位补 0,这样就得到了 “丑” 的 UTF-8 编码是 11100100 10111000 10010001,转换成十六进制就是 E4B891。
解码 UTF-8 编码也很简单了,如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个 1,就表示当前字符占用多少个字节,”丑” 有三个 1 表示占三个字符,然后取出有效位即可

Go中的UTF-8

Go的源码文件,总是以UTF-8编码,同时,需要用Go程序操作的文本字符串也优先采用UTF-8编码。unicode包具备针对单个文字符号的函数(例如区分字母和数字,转换大小写),而unicode/utf8包则提供了按UtF-8编码和解码文字符号的函数
许多Unicode字符难以直接从键盘输入,有的看起来十分相似无法分辨,有些甚至不可见。Go语言中,字符串字面量的转义让我们得以用码点的值来指明Unicode字符。有两种形式

  • \uhhhh 表示16位码点值
  • \uhhhhhhhh 表示32位码点值

其中每一个h代表一个十六进制数字。32位形式的码点值几乎用不到。这两种形式都以UTF-8编码表示出给定的码点。因此,下边几个字符串字面量都表示长度为6字节的相同字符串:

”世界“
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"
后边三行的转义序列用不同形式表示第一行的字符串,但实际上他们的字符串值都一样

码点值小于256的文字符号,可以写成单个十六进制数转义的形式,如 ‘A’ 写成 ‘\x41’ ,而更高的码点值则必须使用\u或\U转义。这就导致,’\xe4\xb8\x96’不是合法的文字符号,虽然这三个字节构成了某个有效的UTF-8编码码点
由于UTF-8的优秀特性,许多字符串操作都不需要解码。我们可以直接判断某个字符串是否为另一个的前缀:

func HasPrefix(s, prefix string) bool {
    return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

或者是否为另一个字符串的后缀
func HasSuffix(s, suffix string) bool {
    return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

或者它是否是另一个的子字符串
func Contains(s, substr string) bool {
    for i:=0; i < len(s); i++ {
        if HasPrefix(s[i:], substr) {
            return true
        }
    }

    return false
}

如果你真的需要逐个处理Unicode字符,则必须使用其他编码机制。如下例:

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "hello, 世界"
    fmt.Println(len(s)) //13
    fmt.Println(utf8.RuneCountInString(s))//9

    // 需要借助UTF-8解码器来处理这些字符,unicode/utf8包就具备一个
    for i:=0; i < len(s); {
        r, size := utf8.DecodeRuneInString(s[i:])
        fmt.Printf("%d\t%c\n", i, r)
        i += size
    }
}

每次DecodeRuneInString的调用都返回r(文字符号本身)和一个值(表示r按UTF-8编码所占用的字节数)。这个值用来更新下标i,定位字符串中的下一个文字符号的位置。按照这种方法,就总是需要循环的形式。所幸,Go的range循环也适用于字符串,按UTF-8隐式解码。下图展示了循环的输出(对于非ASCII文字符号,下标的增量大于1)

一文彻底搞定字符编码 - 图5


来源:
作者:书旅
链接:https://juejin.cn/post/6993528126554767367