最近在编程一个问题的时候突然生出了对这个问题的思考。

在 Golang 中,String 的本质是什么呢?

官方十几年前有一篇文章写的很不错《Strings, bytes, runes and characters in Go》

读完这篇文章,给出一些总结。

字符串的本质

在 golang 中,字符串本质上是一个只读的字节切片/不可修改的 byte 数组(read-only slice of bytes):

  • 字符串可以包含任意字节,不一定是 UTF-8 编码或任何特定格式;
  • 从内容角度来看,字符串等价于一个字符切片;
  • 字符串是不可变的(immutable)

下面是一段代码,用于展示 golang 中的字符串的作用:

package main
 
import "fmt"
 
func main() {
    // 创建包含特殊字节的字符串
    // 这里使用十六进制表示法定义了一个字节序列
    // \xbd\xb2\x3d\xbc\x20\xe2\x8c\x98 分别代表不同的十六进制字节值
    // 注意:这个字符串可能包含非有效UTF-8序列
    s := "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
 
    // 按字节访问字符串
    // 在Go中,字符串实际上是只读的字节序列,可以通过索引访问单个字节
    // 输出的是该字节的十进制值189 (对应十六进制0xbd)
    fmt.Println(s[0]) // 输出 189(十进制),即 0xbd
 
    // 尝试打印完整字符串的字节序列
    fmt.Print("原始字符串的字节: ")
    for i := 0; i < len(s); i++ {
        fmt.Printf("%x ", s[i])
    }
    fmt.Println()
 
    // 无法修改字符串中的字节
    // s[0] = 'x' // 编译错误:字符串不可变
    // Go中的字符串是不可变的,尝试直接修改会导致编译错误
 
    // 可以转换为字节切片进行修改
    // []byte(s) 会创建字符串内容的一个副本作为字节切片
    b := []byte(s)
 
    // 修改字节切片的第一个元素为'x'的ASCII码(120)
    b[0] = 'x'
 
    // 将修改后的字节切片转换回字符串
    // string(b)会创建新的字符串
    s2 := string(b)
 
    fmt.Println("修改后的字符串:", s2)
 
    // 打印修改后字符串的字节序列
    fmt.Print("修改后字符串的字节: ")
    for i := 0; i < len(s2); i++ {
        fmt.Printf("%x ", s2[i])
    }
    fmt.Println()
}

代码主要是为了体现字符串的一些特性:

  1. 字符串的内部表示: Go中的字符串实际上是一个只读的字节序列,它们以UTF-8编码存储 5。可以通过索引直接访问字节,但不能修改 1
  2. 字节访问: 当使用s[0]这样的索引访问时,你获取的是该位置的原始字节值,而不是字符 1
  3. 字符串不可变性: 字符串在Go中是不可变的,代码中注释掉的s[0] = 'x'会导致编译错误 1
  4. 字符串和字节切片的转换
    • []byte(s)会创建原字符串内容的一个副本作为字节切片 4
    • 当你从字符串转换到字节切片时,会发生内存复制,这意味着会创建一个全新的切片 2
  5. 字节切片的修改: 由于字节切片是可变的,你可以修改切片中的任何元素,例如代码中的b[0] = 'x'
  6. 从字节切片转回字符串string(b)会将修改后的字节切片转换回一个新的字符串 4

字符串与 UTF-8

Go语言中的字符串通常采用UTF-8编码:

  • UTF-8是一种变长编码,一个Unicode字符可能由1-4个字节表示
  • 字符串长度(len函数)返回的是字节数,不是字符数
  • 在UTF-8中,ASCII字符占用1个字节,其他字符可能占用多个字节
package main
 
import "fmt"
 
func main() {
	s1 := "hello"
	s2 := "你好"
 
	fmt.Printf("s1: %s, 字节长度: %d\n", s1, len(s1)) // 输出 s1: hello, 字节长度: 5
	fmt.Printf("s2: %s, 字节长度: %d\n", s2, len(s2)) // 输出 s2: 你好, 字节长度: 6
 
	// 遍历字节
	for i := 0; i < len(s2); i++ {
		fmt.Printf("%x ", s2[i]) // 输出 e4 bd a0 e5 a5 bd
	}
	fmt.Println()
}
 

输出内容如下:

s1: hello, 字节长度: 5
s2: 你好, 字节长度: 6
e4 bd a0 e5 a5 bd
 
Process finished with the exit code 0

字节、符文与字符的区别

理解这三个概念对于处理Go字符串至关重要:

  • 字节(byte):8位无符号整数,uint8的别名,表示数据的最小单位
  • 符文(rune):表示一个Unicode码点,int32的别名,表示一个Unicode字符
  • 字符(character):人类语言的基本单位,在Unicode中通常与一个码点对应
package main
 
import "fmt"
 
func main() {
	s := "Hello, 世界"
 
	// 按字节遍历
	fmt.Println("按字节遍历:")
	for i := 0; i < len(s); i++ {
		fmt.Printf("%d: %x\n", i, s[i])
	}
 
	// 按rune遍历
	fmt.Println("\n按rune遍历:")
	for i, r := range s {
		fmt.Printf("字节位置:%d, Unicode码点:%U, 字符:%c\n", i, r, r)
	}
}

运行结果如下:

按字节遍历:
0: 48
1: 65
2: 6c
3: 6c
4: 6f
5: 2c
6: 20
7: e4
8: b8
9: 96
10: e7
11: 95
12: 8c
 
按rune遍历:
字节位置:0, Unicode码点:U+0048, 字符:H
字节位置:1, Unicode码点:U+0065, 字符:e
字节位置:2, Unicode码点:U+006C, 字符:l
字节位置:3, Unicode码点:U+006C, 字符:l
字节位置:4, Unicode码点:U+006F, 字符:o
字节位置:5, Unicode码点:U+002C, 字符:,
字节位置:6, Unicode码点:U+0020, 字符:
字节位置:7, Unicode码点:U+4E16, 字符:世
字节位置:10, Unicode码点:U+754C, 字符:界
 
Process finished with the exit code 0

字符串操作和展示

Go提供了多种方式查看字符串的内容:

字符串直接打印

s := "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
fmt.Println(s) // 输出可能包含乱码字符:��=� ⌘

以十六进制查看字节

fmt.Printf("%x\n", s)      // 无空格:bdb23dbc20e28c98
fmt.Printf("% x\n", s)     // 带空格:bd b2 3d bc 20 e2 8c 98

转义显示

fmt.Printf("%q\n", s)      // 转义不可打印字符:"\xbd\xb2=\xbc ⌘"
fmt.Printf("%+q\n", s)     // 额外转义非ASCII字符:"\xbd\xb2=\xbc \u2318"

字符串遍历与处理

在处理UTF-8字符串时,有两种常见的遍历方式:

按字节遍历(获取原始字节)

s := "Hello, 世界"
for i := 0; i < len(s); i++ {
    fmt.Printf("%d:%x ", i, s[i])
}
// 输出: 0:48 1:65 2:6c 3:6c 4:6f 5:2c 6:20 7:e4 8:b8 9:96 10:e7 11:95 12:8c

按 rune 遍历(获取 Unicode 字符)

s := "Hello, 世界"
for i, r := range s {
    fmt.Printf("%d:%c ", i, r)
}
// 输出: 0:H 1:e 2:l 3:l 4:o 5:, 6:  7:世 10:界

注意上面的索引是不连续的,因为range给出的是字符(rune)在字节序列中的起始位置。

字符串转换

Go提供了字符串与其他类型的转换:

package main
 
import "fmt"
 
func main() {
	// 字符串与字节切片的转换
	s := "Hello, 世界"
	b := []byte(s)
	s2 := string(b)
 
	fmt.Println(s2)
 
	// 字符串与rune切片的转换
	r := []rune(s)
	fmt.Printf("字符串长度: %d, rune切片长度: %d\n", len(s), len(r))
	// 输出: 字符串长度: 13, rune切片长度: 9
 
	s3 := string(r)
	fmt.Println(s3) // Hello, 世界
}

展示结果如下:

Hello, 世界
字符串长度: 13, rune切片长度: 9
Hello, 世界
 
Process finished with the exit code 0

字符串操作常用技巧

package main
 
import (
    "fmt"
    "unicode/utf8"
)
 
func main() {
    s := "Hello, 世界"
 
    // 1. 获取字符串字节长度
    byteLen := len(s)
    fmt.Printf("字节长度: %d\n", byteLen) // 13
 
    // 2. 获取字符串Unicode字符数量
    runeCount := utf8.RuneCountInString(s)
    fmt.Printf("字符数: %d\n", runeCount) // 9
 
    // 3. 截取子串(注意:按字节截取,可能截断Unicode字符)
    sub1 := s[:5]
    fmt.Println(sub1) // Hello
 
    // 4. 安全地截取Unicode字符
    // 先转换为rune切片,再截取,再转回字符串
    runes := []rune(s)
    sub2 := string(runes[:6])  // 前6个Unicode字符
    fmt.Println(sub2) // Hello,
}

运行结果如下:

字节长度: 13
字符数: 9
Hello
Hello,
 
Process finished with the exit code 0
 

总结

目前凭借我的经验,实在是没有太多好说的,这个玩意,还是属于简短的没啥好说的~~~

上面可以当做我的翻译文章。

下面的代码可以作为一点思考的代码体现:

package main
 
import "fmt"
 
func main() {
	s := "golang"
	fmt.Println(s)
 
	// 字符串本质,不可修改的 byte 数组
	fmt.Println(len(s)) // 6
	a := "golang你好"
	fmt.Println(len(a)) // 12
 
	arr := []rune(a)
	fmt.Println(len(arr))
	fmt.Println(string(arr))
 
	for _, ele := range arr {
		fmt.Printf("%c\n", ele)
	}
 
	b := `golang
 
				hello  	
 
你好`
	fmt.Println(b)
}
 

输出结果如下:

golang
6
12
8
golang你好
g
o
l
a
n
g


golang
 
				hello  	
 
你好
 
Process finished with the exit code 0