Go 语言的 string 类型

string的基本概念

Go 语言中的字符串类型 string 是一个非常常用的数据类型,它用于表示文本信息。以下是关于 Go 语言中字符串的详细介绍:

  1. 基本类型:字符串是 Go 语言中的基本类型之一,属于预定义类型,无需额外导入包即可使用。

  2. 不可变性:字符串是不可变的,即一旦创建,其内容不可更改。要修改字符串,需要将其转换为可变类型(如 []byte)进行操作,然后再转换回字符串。

  3. 字面值:字符串可以使用双引号 " 或反引号 `来表示。双引号字符串中可以包含转义字符(例如 \n 表示换行),而反引号字符串则原样输出。

    1
    2
    
    str1 := "Hello, World!"
    str2 := `This is a raw string literal\n`
  4. UTF-8 编码:Go 语言中的字符串是以 UTF-8 编码的,因此支持多种字符集和语言。

  5. 字符串操作:Go 提供了一系列内置函数和方法来操作字符串,如 len() 获取字符串长度、+ 连接字符串、strings 包中的函数用于搜索、替换、分割等操作等。

    len() 返回的字节长度,而不是字符长度。

  6. 索引访问:可以通过索引访问字符串中的单个字符,索引从 0 开始,可以使用类似数组的访问方式。

  7. 字符串比较:可以使用 ==!= 运算符来比较字符串是否相等。

  8. 字符串的内存分配:字符串在 Go 语言中是一个不可变的字节切片,因此创建字符串时会分配内存,但字符串的内容不可修改。

  9. 字符串格式化:可以使用 fmt.Sprintf()strconv 包来进行字符串格式化。

len()

1
func len(v Type) int

len 内建函数返回 v 的长度,这取决于具体类型:

1
2
3
4
5
数组:v 中元素的数量。
数组指针:*v 中元素的数量(即使 v 为 nil)。
切片或映射:v 中元素的数量;若 v 为 nil,len(v) 即为零。
字符串:v 中字节的数量。
信道:信道缓存中队列(未读取)元素的数量;若 v 为 nil,len(v) 即为零。

特别注意:对字符串使用len()函数,得到的是v的字节数而不是字符串长度。如:

1
2
3
4
a := "hello"
b := "Go语言"
fmt.Println(len(a))  // 5
fmt.Println(len(b))  // 8(字节数为8,中文占3个字节)

如果想获取字符串中Unicode字符的数量(字符数),而不是字节数,可以使用utf8.RuneCountInString函数。这将返回字符串中的Unicode字符数。

1
2
3
str := "Hello, Go语言"
length := utf8.RuneCountInString(str)
fmt.Println(length) // 11(字符长度为11)

字符集

字符编码是一种将字符映射到数字代码的方式,使得计算机能够理解和处理文本数据。在计算机内部,所有的文本字符最终都会以数字形式表示,这些数字称为字符编码。字符编码可以将字符映射到唯一的数字标识,从而在计算机中进行存储和处理。

Unicode(统一码、万国码、单一码)是一个标准,它为世界上几乎所有的文字字符分配了唯一的数字代码,以便能够在不同的计算机系统和软件中互操作。Unicode 定义了一个包含几乎所有书面语言的字符集,每个字符都被分配了一个唯一的代码点(code point)。Unicode 的设计目标之一是为了解决不同字符集之间的兼容性问题,使得文本数据在全球范围内能够被正确地表示和处理。

Unicode 字符集是按照标准进行组织和编排的,其中包括了各种语言的字母、数字、标点符号、符号、控制字符等。Unicode 中的每个字符都有一个唯一的代码点,用来表示该字符,通常以 U+ 开头,后跟一个或多个十六进制数字。例如,拉丁字母 A 的 Unicode 代码点是 U+0041。

Unicode 字符集的实现有多种编码方式,最常见的是 UTF-8、UTF-16 和 UTF-32。这些编码方式将 Unicode 中的代码点编码成字节序列,以便在计算机中存储和传输。其中,UTF-8 是最常用的 Unicode 编码方式,它使用可变长度的编码,能够节省存储空间,并且兼容 ASCII 字符集。UTF-16 使用 16 位编码表示 Unicode 字符,而 UTF-32 则使用固定长度的 32 位编码。

UTF-8、UTF-16 和 UTF-32

UTF-8、UTF-16 和 UTF-32 是三种不同的 Unicode 编码方式,它们之间的主要区别在于编码的单位和字节顺序。

  1. UTF-8

    • 编码单位:UTF-8 使用 8 位(1 字节)为一个编码单位。
    • 变长编码:UTF-8 是一种变长编码方式,不同的字符使用不同长度的字节序列来表示,字符的编码长度为 1 到 4 个字节不等。
    • 兼容性:UTF-8 是一种兼容 ASCII 字符集的编码方式,ASCII 字符集中的字符使用单个字节编码,因此 ASCII 文本在 UTF-8 中保持不变。
    • 节省空间:对于大多数文本数据,UTF-8 通常比 UTF-16 和 UTF-32 更节省空间,因为它可以使用较少的字节数表示相同的字符。
  2. UTF-16

    • 编码单位:UTF-16 使用 16 位(2 字节)为一个编码单位。
    • 定长编码:UTF-16 是一种定长编码方式,大多数字符使用 2 个字节编码,但某些字符需要使用额外的 2 个字节来表示(称为代理对)。
    • 字节顺序:UTF-16 编码方式需要考虑字节顺序(大端序或小端序)。
    • 适用范围:UTF-16 可以很好地表示大多数常用字符,但对于罕见的字符或辅助平面字符(如 emoji),可能需要使用代理对表示,因此在某些情况下可能会占用更多的空间。
  3. UTF-32

    • 编码单位:UTF-32 使用 32 位(4 字节)为一个编码单位。
    • 定长编码:UTF-32 是一种定长编码方式,每个字符都使用固定长度的 4 个字节来表示。
    • 占用空间:UTF-32 在存储和传输文本数据时通常会占用更多的空间,因为每个字符都使用固定长度的 4 个字节。

字符编码边界

现在我们确定了字符编码,但我们如何确定一个字符的边界呢?

假设现在我们有字符串 ”Go语言“,它们的 Unicode 编码如下:

1
2
3
4
G -----  1000111
o -----  1101111
语 ----- 1000101111101101
言 ----- 1000101000000000

如果我们直接把他组合起来:

1000111110111110001011111011011000101000000000

这样如何能知道多少个字节应该解析成一个字符呢?显然,直接组合的方式是不可取的。

定长编码

解决方案一:每个字符采用固定长度的编码(统一按最长的来),位数不够,高位补零。

比如固定为2个字节,前面字符串的编码如下:

1
2
3
4
G -----  000000001000111
o -----  000000001101111
语 ----- 1000101111101101
言 ----- 1000101000000000

每次解析两个字节为一个字符,这样就解决了字符编码边界的问题。

但这样也存在浪费内存空间的问题,而且随着字符集收录符号的增加,编号跨度增大,定长编码造成的浪费就越显著。

变长编码

小编号少占字节,大编号多占字节。

在UTF-8编码中,每个Unicode字符使用1至4个字节表示,字节的排列方式遵循一定的规则,以便解码器可以正确地识别每个字符的边界。UTF-8的字符编码边界是通过字节的高位(最高有效位)的值来确定的。

UTF-8采用了一种前缀码的方式,具体规则如下:

  • 对于单字节的字符,即Unicode范围为U+0000到U+007F的字符,UTF-8编码为0xxxxxxx,其中x表示字符的码位。(和 ASCII 字符集相同)

  • 对于多字节的字符,UTF-8使用了多个字节来表示,其中第一个字节的高位位数表示了字符需要使用的总字节数,以及字符的高位信息。具体规则如下:

    • 2字节字符:Unicode范围为U+0080到U+07FF,UTF-8编码为110xxxxx 10xxxxxx。
    • 3字节字符:Unicode范围为U+0800到U+FFFF,UTF-8编码为1110xxxx 10xxxxxx 10xxxxxx。
    • 4字节字符:Unicode范围为U+10000到U+10FFFF,UTF-8编码为11110xxx 10xxxxxx 10xxxxxx 10xxxxxx。

在UTF-8中,每个字节的最高有效位(MSB)用于指示字节是否为字符的一部分以及字符的长度。如果最高位为0,则表示该字节是字符的一部分;如果最高位为1,则表示该字节是多字节字符的开始字节,并且有多少个连续的1可以告诉你字符的总字节数。

这种设计使得UTF-8解码器能够准确地识别每个字符的边界,从而正确地解析UTF-8编码的文本数据。

字符串结尾标识

字符串的终止有两种方式,一种是 C 语言中的隐式申明,以字符“\0”作为终止符。

这样的缺点是在字符串中不能出现字符“\0”。

一种是 Go语言中的显式声明。Go 语言运行时字符串 string 的表示结构如下。

1
2
3
4
type stringStruct struct {
	str unsafe.Pointer
	len int
}

其中,str 字段是一个指向存储字符串数据的字节数组的指针,len 字段表示字符串的长度。

这里的 len 表示的是字节长度,而不是字符长度。

底层数据结构

在 Go 语言中,字符串类型 string 的底层数据结构是一个不可变的字节切片(byte slice)。这个字节切片中存储着字符串的 UTF-8 编码的字节序列。由于字符串是不可变的,因此一旦创建,其内容就不能被修改。

在 Go 语言的源码中,string 类型的定义如下:

1
2
3
4
type stringStruct struct {
    str unsafe.Pointer
    len int
}

其中,str 字段是一个指向存储字符串数据的字节数组的指针,len 字段表示字符串的长度。由于字符串的不可变性,因此使用指针的方式可以确保字符串的内容在创建后不会被修改。

由于字符串是不可变的,因此可以在内存中共享相同的底层数据。这意味着,如果两个字符串具有相同的内容,那么它们可能会共享相同的底层数据,从而节省内存。

在 Go 语言中使用符文(rune)类型来表示和区分字符串中的“字符”,rune 其实是 int32 的别称。 当用 range 轮询字符串时,轮询的不再是单字节,而是具体的 rune。如下所示,对字符串 b 进行轮询,其第一个参数 index 代表每个 rune 的字节偏移量,而runeValue 为 int32,代表符文数。

1
2
3
4
var b = "Go语言"
for index, runeValue := range b {
    fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}

fmt.Printf有一个特殊的格式化符#U可用于打印符文数十六进制的Unicode编码方式及字符形状。如上例打印出:

1
2
3
4
U+0047 'G' starts at byte position 0
U+006F 'o' starts at byte position 1 
U+8BED '语' starts at byte position 2
U+8A00 '言' starts at byte position 5

修改字符串中的内容

在 Go 语言中,底层的字节切片是不可变的,这意味着你不能直接修改它。如果你尝试修改底层的字节切片,Go 语言的运行时系统会导致程序崩溃或者出现未定义的行为。这是因为底层的字节切片可能被多个字符串共享,修改其中一个字符串的底层字节切片可能会影响到其他字符串,破坏了字符串的不可变性和共享性。

通常情况下,如果你需要修改字符串中的内容,你应该创建一个新的字符串来代替原来的字符串。你可以使用 []byte 类型来修改字符串的内容,因为 []byte 是可变的字节切片,但是需要注意的是,这样做会创建一个新的字节数组,而不是修改原始字符串的底层数据。

以下是一个示例说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    str := "hello"
    // 尝试将字符串转换为可变的字节切片
    bytes := []byte(str)
    // 修改字节切片中的内容
    bytes[0] = 'H'
    // 将修改后的字节切片转换回字符串
    str = string(bytes)
    fmt.Println(str) // 输出: Hello
}

在这个示例中,我们通过将字符串转换为 []byte 类型来修改字符串的内容,然后再将修改后的字节切片转换回字符串。这样做是安全的,因为我们创建了一个新的字节数组来存储修改后的字符串内容,而不是直接修改原始字符串的底层数据。

【注意】正因为 string 和 []byte 间的转换非常方便,在某些高频场景中往往会成为性能的瓶颈,比如数据库访问、HTTP请求处理等。

byte 转 string 的编译优化

byte 切片转换为 string 的场景有很多,出于性能的考虑,有时候只是应用在临时需要字符串的场景下。byte 切片转换成 string 时并不会拷贝内存,而是直接返回一个 string,这个 string 的指针(string.str)指向切片的内存。

比如,编译器会识别如下临时场景:

  • 使用m[string(b)]来查找map(map 是 string 为 key,临时把切片 b 转成 string );
  • 字符串拼接,如"<" + “string(b)” + “>";
  • 字符串比较:string(b) == “foo”

由于只是临时把 byte 切片转换成 string,也就避免了因 byte 切片内容修改而导致 string 数据变化的问题,所以此时可以不必拷贝内存。

为什么不允许修改

像 C++语言中的 string,其本身拥有内存空间,修改 string 是支持的。但在 Go 的实现中 string 不包含内存空间,只有一个内存的指针,这样做的好处是 string 变得非常轻量,可以很方便地进行传递而不用担心内存拷贝。

因为 string 通常指向字符串字面量,而字符串字面量存储的位置是只读段,而不是堆或栈上,所以才有了 string 不可修改的约定。

string类型的空间占用

当查看string类型的变量所占的空间大小时,会发现是16字节(64位机器)。

1
2
str := "hello"
fmt.Println(unsafe.Sizeof(str)) // 16

也许你会好奇,为什么是16字节,它的底层存储模型是什么样子的。 在src/runtime/string.go中,定义了string的结构:

1
2
3
4
type stringStruct struct {
	str unsafe.Pointer
	len int
}

string底层结构是一个结构体,它有两个字段: str unsafe.Pointer:该字段指向了 string 底层的 byte 数组(切片)。 len int:该字段确定了 string 字符串的长度。 unsafe.Pointerint类型的字段分别占用 8 个字节空间,所以string类型的变量占用 16 字节(内存对齐后)空间。

参考:Go1.19.3 string原理简析

0%