浅谈golang字符编码

1、 Golang 字符编码

Golang 的代码是由 Unicode 字符组成的,并由 Unicode 编码规范中的 UTF-8 编码格式进行编码并存储。

Unicode 是编码字符集,囊括了当今世界使用的全部语言和符号的字符。有三种编码形式:UTF-8UTF-16UTF-32。(UTF: Unicode Transformation Format,统一码转换格式)

在这几种编码格式的名称中,- 右边的整数的含义是,以多少个比特作为一个编码单元。以 UTF-8 为例,它会以 8 个比特也就是一个字节,作为一个编码单元。并且,它与标准的 ASCII 编码是完全兼容的。也就是说,在 [0x00, 0x7F]的范围内,这两种编码表示的字符都是相同的,这也是 UTF-8 编码格式的一个巨大优势(这里不探讨 UTF-16UTF-32)。

UTF-8 是一种可变长的编码方案。换句话说,它会用一个或多个字节来表示某个字符,最多使用四个字节。比如,对于一个英文字符,它仅用一个字节就可以表示,而对于一个中文字符,它需要使用三个字节才能够表示。不论怎样,一个受支持的字符总是可以由 UTF-8 编码为一个字节序列。以下会简称后者为 UTF-8 编码值。

从上图可知 UTF-8 的编码方式:

  • 什么时候读1个字节的字符?
    • 字节的第一位为0,后面7位为符号的unicode码。所以这样看,英语字母的utf-8ascii一致。
  • 什么时候读多个字节的字符?
    • 对于有n个字节的字符,(n>1).... 其中第一个字节的高n位就为1,换句话说:
      • 第一个字节读到0,那就是读1个字节
      • 第一个字节读到n1,就要读n个字节
go 复制代码
0xxxxxxx # 读1个字节
110xxxxx 10xxxxxx # 读2个字节
1110xxxx 10xxxxxx 10xxxxxx #读3个字节
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx #读4个字节

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

Unicode 是如何填充UTF-8各个字节的呢?

比如 这个汉字,对应的 unicode编码为 U+7801

  • 对应的十六进制处于 0000 0800-0000 FFFF 中,也就是 3 个字节,相应的二进制为 1110xxxx 10xxxxxx 10xxxxxx
  • unicode编码为 U+7801 对应的二进制为 111100000000001,为了和接下来填充字节方便,这里做个格式优化 111 100000 000001
  • 从后向前填充,高位不够的补0
  • 000001 填充第三个字节(从左往右数)10000001
  • 100000 填充第二个字节 10100000
  • 111 填充第一个字节,高位不够的就补0,为 11100111
  • 最终结果为 11100111 10100000 10000001(对应的十六进制分别对应 e7 a0 81
go 复制代码
func TestInt(t *testing.T) {
	s1 := "码"
	for i := 0; i < len(s1); i++ {
		fmt.Printf("%x ", s1[i])
	}
}

打印的结果为 e7 a0 81,和上面演算的一致。

2、string 数据结构

先来看看 Golangstring 的数据结构

go 复制代码
type StringHeader struct {
	Data uintptr
	Len  int
}

其中包含指向字节数组的指针 Data 和数组的大小 Len,后者 Len 方便在 len() 时可以 O(1) 时间给出大小,就是常见的以空间换时间。字符串由字符组成,字符的底层由字节组成,而一个字符串在底层的表示是一个字节序列,这个字节序列就存储在 Data 里,不过是只读的。

go 复制代码
import (
	"fmt"
	"testing"
)

func TestStr(t *testing.T) {
	str := "Hello World"
	fmt.Println(str)
}

把上面代码 go tool compile -S str_test.go > str_test.S 生成汇编代码,然后找到

go 复制代码
go.string."Hello World" SRODATA dupok size=11
	0x0000 48 65 6c 6c 6f 20 57 6f 72 6c 64                 Hello World

能够看到 Hello World 旁有一个 SRODATA 的标记,在 Golang 中编译器会将只读数据标记成 SRODATA

再来看看 slice 的数据结构

go 复制代码
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

相比 string 多了个 Cap,因此在 Golang 中,字符串实际上是只读的字节切片。

那么对于只读的 string,若是想要改值应该怎么弄呢?

go 复制代码
func TestModifyString(t *testing.T) {

	str := "golang编程"

	l := []byte(str)
	l[0] = 'G'

	fmt.Println(string(l)) // Golang编程
}

转成相应的字节数组,然后以索引的形式更新值。

3、string 编码方式

前面说过,字符串由字符组成,字符的底层由字节组成,而一个字符串在底层的表示是一个字节序列。在 Golang 中,字符可以被分成两种类型处理:对占 1 个字节的英文类字符,可以使用 byte(或者 unit8);对占 1 ~ 4 个字节的其他字符,可以使用 rune(或者int32),如中文、特殊符号等。

go 复制代码
// 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 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

可以看到 byterune 其实分别就是 uint8int32 的别名,byte1 个字节, rune4个字节。

go 复制代码
func TestStrLen(t *testing.T) {

	str1 := "go"
	str2 := "go编程"

	fmt.Printf("%v len is %d\n", str1, len(str1))
	fmt.Printf("%v len is %d\n", str2, len(str2))

}

运行后,发现 str1 长度为 2 这个没问题,但 str2 的长度不是 4 而是 8,这是什么原因呢?

先不着急找答案,看看下面的代码

go 复制代码
func printBytes(s string) {
	fmt.Printf("Bytes: ")
	for i := 0; i < len(s); i++ {
		fmt.Printf("%x ", s[i]) // 按十六进制输出
	}
	fmt.Printf("\n")
}

func printChars(s string) {
	fmt.Printf("Charaters: ")
	for i := 0; i < len(s); i++ {
		fmt.Printf("%c ", s[i]) // 将数字转换成它对应的 Unicode 字符
	}
	fmt.Printf("\n")
}

func TestInt(t *testing.T) {
	s1 := "go编程"
	fmt.Printf("s1: %s, bytes len(s1)=%d\n", s1, len(s1))
	fmt.Printf("s1: %s, rune  len(s1)=%d\n", s1, len([]rune(s1)))
	printBytes(s1)
	printChars(s1)
}

运行后打印如下

go 复制代码
s1: go编程, bytes len(s1)=8
s1: go编程, rune  len(s1)=4
Bytes: 67 6f e7 bc 96 e7 a8 8b 
Charaters: g o ç ¼ -- ç ¨

仔细看,发现 rune 类型的输出了 4,另外 printChars 输出乱码了。

先来看看 rune 类型,是 int32 的别名,也就是说,一个 rune 类型的值会由 4 个字节宽度的空间来存储。它的存储空间总是能够存下一个 UTF-8 编码值。一个 rune 类型的值在底层其实就是一个 UTF-8 编码值。前者是(便于我们人类理解的)外部展现,后者是(便于计算机系统理解的)内在表达。

Golang 中常用 rune 类型来处理中文。printChars 之所以输出乱码,是因为在第一节中提到的在 UTF-8 中汉字是以三个字节存储的,len() 是按单字节来计算长度,因此对于三个字节的中文来说输出三分之铁定乱码。那么如何输出才不乱码呢?

go 复制代码
func TestRune(t *testing.T) {
	str := "golang编程"
	l := []rune(str)
	for i := 0; i < len(l); i++ {
		fmt.Printf("%c ", l[i])
	}
}

打印输出 g o l a n g 编 程

当然了,还可以使用 for range 来打印字符串里的中文。

go 复制代码
func TestRange(t *testing.T) {
	str := "golang编程"
	for i, s := range str {
		fmt.Printf("%d: %c\n", i, s)
	}
}

打印输出

0: g
1: o
2: l
3: a
4: n
5: g
6: 编
9: 程

那为什么会这样呢?原因就在 Golang 中,会把 for range 结构转换成如下所示的形式

go 复制代码
	// Transform string range statements like "for v1, v2 = range a" into
	ha := a
	for hv1 := 0; hv1 < len(ha); {
	  hv1t := hv1
	  hv2 := rune(ha[hv1])
	  if hv2 < utf8.RuneSelf {
	     hv1++
	  } else {
	     hv2, hv1 = decoderune(ha, hv1)
	  }
	  v1, v2 = hv1t, hv2
	  // original body
	}

for range 循环在迭代字符串时会逐个处理字符串中的 Unicode 码点(rune),而不是字节。由于 Golang 的原生字符串类型是以 UTF-8 编码的,UTF-8 是一种能够表示 Unicode 码点的变长编码方式,for range 循环能够正确处理这种编码。

通俗点就是 for range 会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个 UTF-8 编码值,或者说每一个 Unicode字符。

go 复制代码
func TestRange(t *testing.T) {
	str := "golang编程"
	for i, s := range str {
		fmt.Printf("%d: %c [% x]\n", i, s, []byte(string(s)))
	}
}

打印输出

go 复制代码
0: g [67]
1: o [6f]
2: l [6c]
3: a [61]
4: n [6e]
5: g [67]
6: 编 [e7 bc 96]
9: 程 [e7 a8 8b]

由此可以看出,字符串中相邻 Unicode 字符的索引值不一定是连续的。 这取决于前一个 Unicode 字符是否为单字节字符(byte)。Golang 中的一个 string 类型值会由若干个 Unicode 字符组成,每个 Unicode 字符都可以由一个 rune 类型的值来承载。这些字符在底层都会被转换为 UTF-8 编码值,而这些 UTF-8 编码值又会以字节序列的形式表达和存储。因此,一个string 类型的值在底层就是一个能够表达若干个 UTF-8 编码值的字节序列。

ok,到这里了,发现两种不同的 for 循环在输出字符串的字符时会有所不同,这里做个归类

  • for-standalone 会遍历字符串的每一个字节(Byte类型),在遇到字符串中有汉字时会乱码
  • for-range 会遍历字符串的每一个 Unicode 字符(Rune 类型) ,在遇到字符串中有汉字时不会乱码

最后说说 stringbyterune 三者之间的关系。

  • string 在底层的表示是由单个字节组成的只读的字节序列,Golang 的字符串是以 UTF-8 编码存储的,这意味着它们可以包含任意的 Unicode 字符。Golang 把字符分 byterune 两种类型处理。
  • byte 是类型 unit8 的别名,用于存放占 1 个字节的 ASCII 字符,如英文字符,返回的是字符原始字节。由于 Golang 的字符串是以 UTF-8 编码的,一个 byte 可能表示一个字符的一部分(对于多字节字符如中文字符),也可能表示一个完整的字符(对于 ASCII 字符)。
  • rune 是类型 int32 的别名,用于存放多字节字符,如占 3 字节的中文字符,返回的是字符 Unicode 码点值(或者说它代表一个 Unicode 码点)。在处理字符串时,rune 用于表示字符串中的一个完整的 Unicode 字符,无论这个字符是由多少个字节组成的。rune 类型的变量可以存储任何 Unicode 字符,包括那些由多个字节表示的字符。

等等,等等,到这里,不妨再多看看。那么如果计算一个字符串的长度呢,用自带的 len() 函数对于单字节的字符串来说是准确的,若是带有中文字符这种多字节的字符串就不准确了,这时除了自己造轮子外,其实可以用 Golang 内置的 utf8.RuneCountInString 来统计。

go 复制代码
func TestCountStr(t *testing.T) {
	str := "golang编程"
	fmt.Println(utf8.RuneCountInString(str)) // 8
}

有兴趣的读者可以看看其内部实现。

go 复制代码
// RuneCountInString is like RuneCount but its input is a string.
func RuneCountInString(s string) (n int) {
	ns := len(s)
	for i := 0; i < ns; n++ {
		c := s[i]
		if c < RuneSelf {
			// ASCII fast path
			i++
			continue
		}
		x := first[c]
		if x == xx {
			i++ // invalid.
			continue
		}
		size := int(x & 7)
		if i+size > ns {
			i++ // Short or invalid.
			continue
		}
		accept := acceptRanges[x>>4]
		if c := s[i+1]; c < accept.lo || accept.hi < c {
			size = 1
		} else if size == 2 {
		} else if c := s[i+2]; c < locb || hicb < c {
			size = 1
		} else if size == 3 {
		} else if c := s[i+3]; c < locb || hicb < c {
			size = 1
		}
		i += size
	}
	return n
}
相关推荐
Python大数据分析@2 分钟前
python操作CSV和excel,如何来做?
开发语言·python·excel
上海_彭彭28 分钟前
【提效工具开发】Python功能模块执行和 SQL 执行 需求整理
开发语言·python·sql·测试工具·element
3345543236 分钟前
element动态表头合并表格
开发语言·javascript·ecmascript
沈询-阿里40 分钟前
java-智能识别车牌号_基于spring ai和开源国产大模型_qwen vl
java·开发语言
残月只会敲键盘1 小时前
面相小白的php反序列化漏洞原理剖析
开发语言·php
ac-er88881 小时前
PHP弱类型安全问题
开发语言·安全·php
ac-er88881 小时前
PHP网络爬虫常见的反爬策略
开发语言·爬虫·php
爱吃喵的鲤鱼1 小时前
linux进程的状态之环境变量
linux·运维·服务器·开发语言·c++
DARLING Zero two♡2 小时前
关于我、重生到500年前凭借C语言改变世界科技vlog.16——万字详解指针概念及技巧
c语言·开发语言·科技
Gu Gu Study2 小时前
【用Java学习数据结构系列】泛型上界与通配符上界
java·开发语言