String

字符串与编码

本节目标:

  • 字符编码的前世今生
  • Go 中字符串是什么

字符编码

在说字符串之前,我们先来聊一下,字符编码,做过的开发的你相比多多少少知道 ASCII、UTF-8、GB2312 等等,一堆堆的字符集,试问为啥子有这么多的字符集呢?且听我慢慢道来。

ASCII

Long long ago,世界还是比较简单的,计算机在美国被发明出来,在计算机的世界里只认识二进制,也就是 0 和 1,如果想用计算机表达出 a、b、c 这样的字母还有数字和常用符号,那咋办呢?

在 1967 年,第一套标准 ASCII(American Standard Code for Information Interchange:美国信息交换标准代码)就发表了,为了称呼方便后续我将简称为:ASCII 字符集,截止目前共收录 128 个字符,为了能更好的认识它,我把码表贴出来如下:

ASCII

ASCII 占用一个字节长度,比如:大写字母 A,计算机中二进制存储为:0100 0001,十进制是:65,十六进制是:0x41

GB2312

有了 ASCII 字符集,美国人民使用计算机基本上是搞定了,But 里面一个汉字(日文、韩文等等)可都没有,那我大中华民族可咋弄来?唉、不怕不怕,在 1980 年制定的中国汉字编码国家标准 GB2312 就问世了,共记录 7445 个字符,其中汉字 6763 个,采用扩展 ASCII 码的形式进行编码,完全兼容标准 ASCII 码,是不是很牛掰。

GB2312 汉字占用两个字节,每个字节的最高位为 1,其中 ASCII 是采用一个字节。

GBK

上面说到 GB2312,扩展了 ASCII 码表,编进去了汉字,但是吧大中华博大精深怎可能只有 6 千多个汉字,于是乎 1995 年制定 GBK(汉字内码扩展规范),也用每个字占据 2字节的方式又编码了许多汉字。经过 GBK 编码后,可以表示的汉字达到了 20902 个(含繁体)

ASCII-GBK-GB2312关系图

可以很明显看出 GBK 不仅涵盖了 GB2312 所有字符编码,又扩充了很多,更强大了有没有。

Unicode

上面说了这么多中国的文字编码,那日、韩国际同胞,咋办的呢?

日有 Shift_JIS 码表 ,韩有 Euc-kr 码表,世界这么大,如果每个国家搞一套自己的标准,那计算机中这不乱套的了么,因此,超级牛逼大 Boss,Unicode 应运而生(又称统一码、万国码、单一码)。

Unicode 1990年开始研发,1994年正式公布,把所有语言都统一到一套编码里 https://unicode.org/

Unicode 收集了这个世界上所有的符号系统,现在收集了超过 12 万个字符,那这些在计算中是怎么体现的呢?

通用的标识一个 Unicode 码点数据类型是 int32,对应 Go 语言中的 rune 类型,我们将一个字符表示为一个 int32 序列,这种编码方式叫 UTF-32,这种方式简单统一,但是吧唯一缺点是太费存储空间。

比如:一个 ASCII 用 8bit(1字节)就可以表示,如果用4个字节岂不是太浪费了,那有什么其他更好的编码方式嘛?

UTF-8

UTF-8 是以 Unicode 码点为字节序列的变长编码,是由 Go 语言之父 Ken Thompson 和 Rob pike 共同发明,现在已是 Unicode 标准。

UTF-8 使用 1到4个字节表示每个 Unicode 码点,从 Unicode 到 UTF-8 的编码方式如下:

UTF-8

ASCII 字符只使用1字节,对应 Unicode 十六进制区间 0x00-0x7F 之间的字符,与 ASCII 编码完全相同,常用字符部分使用2或3个字节表示,仔细观察不难发现,每个符号编码第一个字节的高端 bit 位用于表示总共有多少编码字节。

比如:第一个字节的高端 bit 位为 0,表示对应 7bit 的 ASCII 字符。

再比如:第一个字节的高端 bit 位是 110,则说明需要 2个字节,后面的每个高端 bit 位都以 10 开头;

字符串

弄明白了字符编码,接下来我们来看一下 Go 中的字符串,Go 中字符串使用 UTF8 编码,定义的字符串说白了就是定义一组 UTF8 字符,有两种方式如下:

方式一:使用双引号("")

s := "中华人民共和国"
fmt.Println(len(s)) // 21

len() 函数计算字符字节长度(源码注释:String: the number of bytes in v.),常用汉字在 UTF8 编码中一个汉字占用 3个字节,7个汉字共计字节长度便是 21。

方式二:使用反引号(`)

hi := `
How are you ?
Fine, Thank you.
`
fmt.Println(hi)
// output:
// How are you ?
// Fine, Thank you.

一般用于大段的字符串定义,以及 HTML。

了解完字符串的定义,我们再看一下 Go 中如何处理字符串的循环以及存储的,如下,定义字符串 Hello, 世界

str := "Hello, 世界"
for i, r := range str {
    fmt.Printf("%d\t%q\t%d\n", i, r, r)
}
  • \t 制表符,作用对齐输出
  • %q 输出带单引号的 rune 或者是输出带双引号的字符串

为了更好的理解,下面是该字符串的内存中的(字节)存储图,如下:

hello世界

注解:

图中右下角的遍历结果的表头,意思是:

  • 第一列 i 表示:输出每个字符索引的开始值
  • 第二列 r 表示:输出 rune 字符
  • 第三列 r 表示:输出十进制的 unicode 码点

整个字符串长度是 13,“世”字符之前整个字符串索引 i 都是递增 1,遍历到“世”时,突然递增了 3,程序是怎么知道“世”字占3个长度,你没有很好奇?

如果不清楚一猜就是没好好读 UTF-8 小节,敲黑板划重点了,请仔细认真品接下来的话:

UTF-8 是变长编码,为了知道每个字符其自身有几个字节(或着说多少位)组成,采用的是第一个字节的高端位(bit)表示,

比如我们看一下“世”字符串,在计算机中二进制位是怎么表示的,然后看其高端位如下:

w := "世"
wr := []byte(w)
fmt.Printf("%T %[1]b", wr)
//output: []uint8 [11100100 10111000 10010110]

注解:

  • []byte(w) 意思是将字符“世”,转为 byte 切片(byte 是 uint8 的别名,也可以说是 uint8 的切片)

“世”这个字符,在计算机中真实存储的值便是:11100100 10111000 10010110,第一个字节高端位(bit)是:111,即表示需要 3个字节存储,所以在遍历时会主动连续查找 3 个字节的长度,同理“界”也是如此。

好了,搞明白了这点,那么我的问题来了,在 for range 遍历该字符串时,究竟遍历了几次?

是 9 次?还是 13 次?,如果是为什么?请自行思考 1分钟,再继续往下看。


假装 1 分钟到了,这个问题抽象为代码,便是如下两行代码,请问会打印出什么结果?

fmt.Println("len=", len(str))
// Unicode 码点数(rune)
fmt.Println("rune count=",utf8.RuneCountInString(str))

提示:

  • Go 源码中 len()函数针对 string 类型,返回的长度是字节数
  • utf8.RuneCountInString() 获取字符串的 rune 个数

请先自行思考一下,再看答案。

点击看答案
len= 13
rune count= 9
// 如果答错了,也没关系,还请留言说出你的思考方式,将会更好的帮助我完善此文档。

你有没有很好奇,长度 len 是 13,图上表格可以看出遍历确只遍历了 9次,那 Go 中的 range 循环是不是有啥不可告人的秘密呢?

其实 range 再做循环会自动隐士的解码 UTF8 字符串,也就是上面的循环运行图。

怎么证明呢?如下我们不用 range 循环,换成主动解码循环的方式:

点击查看
for i := 0; i < len(str); {
  r, size := utf8.DecodeRuneInString(str[i:])
  fmt.Printf("%d\t%c\n", i, r)
  i += size
}
// output:
// 0	H
// 1	e
// 2	l
// 3	l
// 4	o
// 5	,
// 6	 
// 7	世
// 10	界

解释一下 str[i:] 的意思,[i:] 是切片的意思,切片我将在 复合数据类型章节详解,看俩例子你就明白了,如下:

a := "abcd"
fmt.Println(a[0:1]) // a
fmt.Println(a[0:2]) // ab
fmt.Println(a[1:])  // bcd

注解:

a[startIndex:endIndex]

  • startIndex 表示索引开始值(包含)
  • endIndex 表示要切的截止索引处(不包含)

字符串,允许以切片的形式访问,但是不能这么赋值修改,如下错误写法:

// 不允许这么修改字符串值
a[0:1] = "e" // cannot assign to a[0:1]

定义 rune 字符

rune 字符在 Go 中,其实就是 int32 的别名,也可以称其为 Unicode 字符(或码点),叫法上的不同,本质都是一个东西。

以后说到 rune 字符,(是不是就不迷糊了),Go 源码定义如下:

// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32

那如何定义一个 rune 字符呢,使用单引号,举例如下:

s2 := '世'
s3 := '\u4e16'
s4 := '\U00004e16'
fmt.Printf("%T %v\n", s2, s2) // int32 19990
fmt.Printf("%T %v\n", s3, s3) // int32 19990
fmt.Printf("%T %v\n", s4, s4) // int32 19990

此三种方式是等价的,

  • \uhhhh 对应 16 bit 的码点值
  • \Uhhhhhhhh 对应 32 bit 的码点值(很少用其表示一个 UTF8 编码)

字符串与 rune 切片

我们首先来看看字符串与字符(UTF8)这两个类型的转换问题,首先我们定义一个 String 的字符串:

s := "汉族"
fmt.Printf("% x \n", s) // e6 b1 89 e6 97 8f

Printf 中的 % x 的意思是:让每个十六进制前插入一个空格。

String 到 rune 的类型转换,如下:

r := []rune(s)
fmt.Printf("%T %x \n", r, r) // []int32 [6c49 65cf]

符号[] 定义切片用的,[]rune(s) 意思是将字符串 s 转为 rune 切片,切片我们将在下章节符合类型中再详细讨论。

这个过程我们也可以称之为:UTF8 编码字符串解码为 Unicode 字符。

有解码那么就有编码,我们继续看一下从 Unicode 字符到 UTF8 编码的过程,比如已知上面的 rune 切片变量 r 进行 UTF8 编码,如下:

fmt.Println(string(r)) // 汉族

有没有很申请,使用 Go 中提供的 string() 函数,就可以搞定了,那如果我们瞎输入一个 rune 类型的参数呢?比如:

fmt.Println(string(123456))

试想一下,这个可能不存在的码点字符,能否转为 string 呢?

点击看答案
// 则是用 \uFFFD 无效字符作为替换,在印刷中这个符号通常是一个黑色六角或钻石形状,里面包含一个白色的问号(?)
𞉀

如果我输入正确的码点字符,比如以下:

fmt.Println(string(65))     // "A"
fmt.Println(string(0x4eac)) // 京

注意哦:将一个整数转型为字符串,是生成只包含对应 Unicode 码点字符的 UTF8 字符串,如 string(65) 结果是 “A”,而不是 “65”

字符串和 Byte 切片

byte 字节,等同于 8bit(位),Go 中 byte 是 uint8 的别名,源码定义如下:

// byte is an alias for uint8 and is equivalent to uint8 in all ways. It is
// used, by convention, to distinguish byte values from 8-bit unsigned
// integer values.
type byte = uint8

搞懂了 字符串与 rune 之前的编码与解码,那么字符串与 byte 之前的的相互转换也就更容易了解了,如下完整示例:

s := "abc"
b := []byte(s)
fmt.Printf("%T %[1]v\n", b) // []uint8 [97 98 99]
s2 := string(b)
fmt.Printf("%T %[1]q\n", s2) // string "abc"

一个 []bayte(s) 转换,是分配一个新的字节数组用于保存字符串的数据拷贝,然后引用这个底层的字节数组。

字符串和数字间转换

除了字符串、字符、字节之间的转换,字符串与数值之间的转换也比较常见,由 strconv 包提供这类转换需求。

先来看一下将一个整数转为字符串的两种方式:

方式一,使用 fmt.Sprintf 方法,如下:

x := 123
y := fmt.Sprintf("%d", x)
fmt.Printf("%T %T\n", x, y) // int string

Sprintf 根据指定的格式(%d:数值型 %b:二进制 %o:十六进制 等等)格式化并返回字符串。

方式二,使用 strconv.Itoa 方法,如下:

fmt.Printf("%T\n", strconv.Itoa(x)) // string

那么反过来,将字符串转为数值型,的两种写法,如下:

方式一:使用 strconv.Atoi 方法:

if i, e := strconv.Atoi("456"); e == nil {
  fmt.Printf("%T %[1]v\n", i) // int 456
}

方式二:strconv.ParseInt 方法:

if i, e := strconv.ParseInt("456", 10, 64); e != nil {
  fmt.Println(e) // int64 456
} else {
  fmt.Printf("%T %[1]v\n", i)
}

strconv.ParseInt 参数解释:

  • 参数一 string:待格式化的字符串
  • 参数二 base:该字符串对应的进制类型(2、8、10、16)
  • 参数三 bitSize:该整数型的大小,64 表示 int64,0 表示 int

返回值永远是 int64

这里以为 ParseInt 为例讲解了其用法,除此外还有 ParseUint 就再演示了。

strconv 包里还有更多方法已提供其他功能使用,这里更多还是起一个抛砖引玉的作用。

课后一练:将整数值 x := 7 分别转为二进制、八进制、十六进制,请使用两种写法实现?

点击看答案
x := 7
// 将一个数值:格式化为二进制、八进制等
fmt.Printf("%v\n", strconv.FormatInt(int64(x), 2)) // 111
fmt.Printf("%v\n", strconv.FormatInt(int64(x), 8)) // 7
fmt.Printf("%v\n", strconv.FormatUint(uint64(x), 10)) // 7

// 将一个数值:格式化为二进制、八进制等
fmt.Println(fmt.Sprintf("x=%b\n", x)) // x=111
fmt.Println(fmt.Sprintf("x=%d\n", x)) // x=7
fmt.Println(fmt.Sprintf("x=%o\n", x)) // x=7

小结

本章节主要讲述了字符的演变历史,以及 Go 中字符与字符串定义与使用,最后分享了字符串(UTF8)、 rune 切片、Byte 切片、以及数字间的转换。