字符串

0 阅读7分钟

字符串是由数字、字母、下划线或其他符号组成的有限字符序列。

也许很多人都是从“Hello World!”这个字符串打开编程世界的大门的,平时使用字符串也习以为常了,打印的日志、返回的提示信息、客户端界面上展示的文案等,包括现在这篇文章里的文本内容,都是字符串哦(ō)。

字符

先从组成字符串的字符说起,字符就是数字、字母等一个个单独的符号。

一些语言中,用单引号或双引号引用都表示字符串,但Go有所不同,在Go中,使用单引号引用的是单个字符(character):

	var ch1 byte = 'a'         // ASCII 字符
	var ch2 rune = '啊'         // Unicode 字符
	var ch3 rune = '🍎'         // Unicode 字符
	
	fmt.Println(ch1, ch2, ch3) // 97 21834 127822
	fmt.Printf("%#x %#x %#x\n", ch1, ch2, ch3) // 0x61 0x554a 0x1f34e

上面代码里的byte类型表示ASCII字符,rune类型表示Unicode字符。在上一篇文章《数字》中有说到,byte类型是uint8的别名,rune类型是int32类型的别名,所以单个字符就是数字类型。‘a’是97,’啊’是21834,这些数字一般是靠Unicode标准规范和字符对应上的,编程语言、软件等,一般都遵循Unicode字符集的标准来实现字符串。可以在 www.unicode.org/charts/ 查找对应的Unicode字符,例如码点为0x554a,对应就是字符‘啊’:

image.png

image 1.png

Unicode字符集定义了全世界的字符,如果出现了某个新的字符,就会新增一个编号来代表该字符,已经分配好的字符的编号不变。并且Unicode字符集是完全涵盖ASCII字符集的,0~127码点和字符的对应关系,Unicode和ASCII是一样的,所以使用Unicode字符直接就能兼容ASCII字符,不需要做其他特殊处理。

2019-02-24-ascii.png

Unicode U+0000 ~ U+007F的字符集:

image 2.png

	var ch4 byte = 'A'    // ASCII 字符
	var ch5 rune = 'A'    // Unicode 字符

	fmt.Println(ch4, ch5) // 65 65
	fmt.Printf("%#x %#x\n", ch4, ch5) // 0x41 0x41

根据以上的内容,我们已经了解到,单个字符就是一个个纯纯的码点(整数), 所以对单个字符来说,并不存在编码解码的说法,对字符串,才会说编码解码。

字符串

用双引号引用0个或多个有限字符,组成了字符串(string)。

简单总结一下字符串的编码解码:

  • 字符串编码——将字符串转换为二进制序列
    • 定长编码:编码后的二进制序列使用的字节数是固定的。比如UFT-32编码。
    • 变长编码:编码后的二进制序列使用的字节数不固定。比如UTF-8编码。
  • 字符串解码——将二进制序列转换为字符串

定长编码/解码

以UTF-32编解码为例,说明一下定长编解码。

UFT-32的编解码比较简单,编码就是将字符对应的码点一律转换为uint32类型的整数,解码就是用uint32的整数,去字符集中查找对应的字符。Unicode字符集码点的范围是U+0000~U+10FFFF,这个数值范围远远小于uint32类型能存储的整数范围,所以用uint32类型来存Unicode码点是绰绰有余的。但是一些本身用不了4字节的码点,也会在高位补0补成4字节,所以会有空间上的浪费

字符串.drawio.png

图中灰色部分都是浪费的空间。

	str := "1😊而过"
	srl := []rune(str)
	for _, v := range srl {
		fmt.Printf("字符 %q | Unicode码点: U+%04X | %b\n", v, v, v)
	}
	// 字符 '1' | Unicode码点: U+0031 | 110001
	// 字符 '😊' | Unicode码点: U+1F60A | 11111011000001010
	// 字符 '而' | Unicode码点: U+800C | 1000000000001100
	// 字符 '过' | Unicode码点: U+8FC7 | 1000111111000111

变长编码/解码

变长编码是根据需要,使用不同字节数的二进制序列表示字符,这样能节省空间

Go默认使用的是UTF-8变长编码,将每一个字符编码成对应的二进制序列。二进制序列的编码规则如下:

Unicode 码点范围十进制范围UTF-8 字节数二进制模板
U+0000 ~ U+007F0 ~ 1271 字节0xxxxxxx
U+0080 ~ U+07FF128 ~ 20472 字节110xxxxx 10xxxxxx
U+0800 ~ U+FFFF2048 ~ 655353 字节1110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF65536 ~ 11141114 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

一般情况下,ASCII字符使用1字节,中文使用3字节,emoji表情使用4字节。

uft-8变长编码.drawio.png

UTF-8是怎么编码的呢,以'😊' 为例, 它的码点是U+1F60A,转换为二进制是11111011000001010,看上面的编码规则表,U+1F60A 在U+10000 ~ U+10FFFF的范围内(U+10FFFF需要使用21位存储),所以使用的模板是11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,将码点转换为二进制数,不足21位的高位补0补到21位,依次填入x的部分,就得到的UFT-8编码后的二进制序列11110000 10011111 10011000 10001010:

utf-8编码01.drawio.png

(图中每4位一个空格仅仅是为了看起来直观一点,没有其他含义)

package pstring

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

func PrintString() {
	str := "1😊而过"
	sbl := []byte(str)

	fmt.Println(len(sbl), len(str)) // 11 11
	printStrUTF8Binary(str)
	// 字符 '1' |  Unicode码点 U+0031 | UTF-8 二进制: 00110001
	// 字符 '😊' |  Unicode码点 U+1F60A | UTF-8 二进制: 11110000 10011111 10011000 10001010
	// 字符 '而' |  Unicode码点 U+800C | UTF-8 二进制: 11101000 10000000 10001100
	// 字符 '过' |  Unicode码点 U+8FC7 | UTF-8 二进制: 11101000 10111111 10000111
}

func printStrUTF8Binary(str string) {
	for _, r := range str {
		// 把rune编码为UTF-8的字节序列
		var buf [utf8.UTFMax]byte
		n := utf8.EncodeRune(buf[:], r)
		utf8Bytes := buf[:n]
		bits := make([]string, n)
		for i, b := range utf8Bytes {
			bits[i] = fmt.Sprintf("%08b", b)
		}
		binStr := strings.Join(bits, " ")
		fmt.Printf("字符 %q |  Unicode码点 U+%04X | UTF-8 二进制: %s\n", r, r, binStr)
	}
}

拿到一个UTF-8编码的二进制序列,是这样进行解码的:

utf-8解码.drawio.png

Go中的字符串

Go的字符串底层存的是一个指向只读UTF-8字节数组的指针,以及字节长度。指针表明字符串从什么地方开始存储,指针+字节长度能确定字符串在什么地方结束。

utf-8字符串存储.drawio.png

“1😊而过”这个字符串,第一个二进制位是0,说明第一个字符占用1字节,到第2个字节就是下一个字符了,第二个字节以11110开头,说明第二个字符占用4字节,第2、3、4、5字节,都是存的第二个字符的二进制位,以此类推,就能判断字符串中的字符的边界在哪里。

package pstring

import (
	"errors"
	"fmt"
	"reflect"
	"unsafe"
)

func PrintStringUnderlying() {
	s := "1😊而过"

	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Printf("底层: ptr=%p len=%d\n", unsafe.Pointer(sh.Data), sh.Len) // 底层: ptr=0x100d93e09 len=11

	base := (*byte)(unsafe.Pointer(sh.Data))
	length := sh.Len

	printByteAtOffsetBinary(base, length, 0) // 偏移量0字节处的字节序列 00110001
	printByteAtOffsetBinary(base, length, 1) // 偏移量1字节处的字节序列 11110000
	printByteAtOffsetBinary(base, length, 5) // 偏移量5字节处的字节序列 11101000
}

func printByteAtOffsetBinary(base *byte, length, n int) error {
	if n < 0 || n >= length {
		return errors.New("offset out of range")
	}
	b := *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(base)) + uintptr(n)))
	fmt.Printf("%08b \n", b)
	return nil
}

另外,Go中字符串被认为是不可变的,字符串被分配到只读内存段,所以不可以修改字符串:

	str := "1😊而过"
	str[0] = "2" // 报错:cannot assign to str[0] (neither addressable nor a map index expression)

以上就是字符串的简单内容啦,下一篇文章准备记录数组和切片相关的内容。