字符串的定义

Go 语言中,字符串是一个不可变的字节序列。字符串可以用双引号 " 包裹起来,也可以用反引号 ` 包裹起来。反引号包裹的字符串可以跨多行,并且不会对其中的内容进行转义。

1
2
s1 := "Hello, World!"
s2 := `Hello, World!`

Go 语言中,字符串的结构体定义如下:

1
2
3
4
type stringStruct struct {
str unsafe.Pointer // 指向字符串内容的指针
len int // 字符串的长度
}

其中 str 是一个指向字符串内容的指针,len 是字符串的长度。所以字符串本质是所有字节的集合(字节数组),但字符串不一定是 UTF-8 编码。所以其中 len 字段表示的是字节数,而不是字符数。如果有一些特殊字符(如中文字符),可能会占用多个字节,len 字段仍然表示的是字节数,而非字符数。

另外字符串可以为 empty,即长度为 0 的字符串("")。但不能是 nil,因为 nil 是一个指针类型,而字符串是一个值类型。

最后,字符串是不可变的,一旦创建就不能修改。如果需要修改字符串,可以创建一个新的字符串,或者转换为 []byte[]rune 类型进行修改:

1
2
3
4
5
6
7
s := "Hello, World!"
b := []byte(s) // 转换为字节切片
b[0] = 'h' // 修改字节切片
s = string(b) // 转换回字符串
r := []rune(s) // 转换为字符切片
r[1] = 'E' // 修改字符切片
s = string(r) // 转换回字符串

string 与 []byte 的转换

Go 语言中,字符串和字节切片([]byte)之间的转换是非常常见的操作。由于字符串是不可变的,而字节切片是可变的,因此在需要修改字符串内容时,通常会先将字符串转换为字节切片。切片的底层实现之前总结过,是一个指向数组的指针、长度和容量的结构体。所以把字符串转换为字节切片时,实际上是创建了一个新的切片,然后把字符串的内容复制到这个切片的底层数组中。当然把切片转换为字符串时,也是创建了一个新的字符串,拷贝切片的底层数组到字符串的内容指针指向的内存中。

需要注意这里的转换是拷贝,而不是引用。也就是说,修改字节切片不会影响原字符串,反之亦然。当然并非所有情况下都是拷贝,编译器会对临时使用的字符串和字节切片进行优化,避免不必要的拷贝。比如:

  • 字符串比较:string(strSlice) == "hello"
  • 字符串拼接:str + "world"
  • mapkeyv, ok := myMap[string(strSlice)]

这几种情况中,[]byte 转换为字符串后并不会被后面的程序使用,只会临时用一次。此时编译器就会优化生成一个临时 string,并且该字符串底层的指针是指向切片的数组内存,而不是拷贝一份数据。这样可以减少内存的使用和提高性能。

和之前切片作为函数参数一样,字符串作为函数参数也是值传递,但是传递的是一个结构体,包含了指向字符串内容的指针和长度。这样即便字符串非常大,传递时也不会复制整个字符串的内容,而是只复制了指针和长度信息。显然这样更高效。

字符串的拼接

字符串的拼接有多种方式,最常用的有以下几种:

  • 使用 + 运算符:这是最简单的拼接方式,但在循环中使用时可能会导致性能问题,因为每次拼接都会创建一个新的字符串。
  • 使用 strings.Join 函数:这是推荐的拼接方式,特别是当需要拼接多个字符串时。它会创建一个新的字符串,并且性能更好。
  • 使用 bytes.Buffer:这是在需要频繁拼接字符串时的最佳选择。它使用一个动态增长的字节缓冲区,可以有效地减少内存分配和拷贝操作。
  • 使用 strings.Builder:这是在 Go 1.10 版本引入的,类似于 bytes.Buffer,但专门用于字符串拼接,性能更好。
  • 使用 append 函数:这是在需要拼接多个字符串时的先转换为切片然后拼接后再把切片转换回字符串。
  • 使用 fmt.Sprintf:这是格式化字符串的方式,可以在拼接时进行格式化,但性能较差。

根据一个用例可以来测试各种实现的效率。

LCR 182. 动态口令

某公司门禁密码使用动态口令技术。初始密码为字符串 password,密码更新均遵循以下步骤:

  • 设定一个正整数目标值 target
  • 将 password 前 target 个字符按原顺序移动至字符串末尾

请返回更新后的密码字符串。

示例 1:

输入: password = “s3cur1tyC0d3”, target = 4
输出: “r1tyC0d3s3cu”

示例 2:

输入: password = “lrloseumgh”, target = 6
输出: “umghlrlose”

提示:

  • 1 <= target < password.length <= 10000

性能测试

采用 testing 包来测试各种实现的效率。以下是一个简单的性能测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package main

import (
"bytes"
"fmt"
"strings"
"testing"
)

const LIMIT = 10007

var loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`
var strSlice = make([]string, LIMIT, LIMIT)

func init() {
for i := 0; i < LIMIT; i++ {
strSlice[i] = loremIpsum
}
}

func BenchmarkConcatenationOperator(b *testing.B) {
for i := 0; i < b.N; i++ {
var result string
for _, str := range strSlice {
result += str
}
}
b.ReportAllocs()
}

func BenchmarkStringJoin(b *testing.B) {
for i := 0; i < b.N; i++ {
var result string = strings.Join(strSlice, "")
_ = result
}
b.ReportAllocs()
}

func BenchmarkBytesBuffer(b *testing.B) {
for i := 0; i < b.N; i++ {
var result bytes.Buffer
result.Grow(len(loremIpsum) * LIMIT)
for _, str := range strSlice {
result.WriteString(str)
}
_ = result.String()
}
b.ReportAllocs()
}

func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var result strings.Builder
result.Grow(len(loremIpsum) * LIMIT)
for _, str := range strSlice {
result.WriteString(str)
}
_ = result.String()
}
b.ReportAllocs()
}

func BenchmarkAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
var result []byte
for _, str := range strSlice {
result = append(result, str...)
}
_ = string(result)
}
b.ReportAllocs()
}

func BenchmarkFmtSprintf(b *testing.B) {
for i := 0; i < b.N; i++ {
var result string
for _, str := range strSlice {
result = fmt.Sprint(result, str)
}
_ = result
}
b.ReportAllocs()
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ go test -bench=.
goos: darwin
goarch: arm64
pkg: zuo-xuan-zhuan-zi-fu-chuan-lcof
cpu: Apple M1 Pro
BenchmarkConcatenationOperator-8 1 1378852416 ns/op 22324205776 B/op 10369 allocs/op
BenchmarkStringJoin-8 5294 222495 ns/op 4456457 B/op 1 allocs/op
BenchmarkBytesBuffer-8 3109 484892 ns/op 8912900 B/op 2 allocs/op
BenchmarkStringBuilder-8 5382 219420 ns/op 4456456 B/op 1 allocs/op
BenchmarkAppend-8 531 2447284 ns/op 30962169 B/op 34 allocs/op
BenchmarkFmtSprintf-8 1 2638417125 ns/op 46161654104 B/op 53801 allocs/op
PASS
ok zuo-xuan-zhuan-zi-fu-chuan-lcof 9.863s

从测试结果可以看出,strings.Joinstrings.Builder 的性能最好,适合用于字符串拼接。而 + 运算符在循环中使用时性能较差,因为每次拼接都会创建一个新的字符串。bytes.Buffer 的性能也不错,但略逊于 strings.Builderappend 函数的性能较差,因为需要先转换为字节切片。fmt.Sprintf 的性能最差,不建议用于频繁的字符串拼接。

总结

方法 性能 备注
+ 运算符 较差 每次拼接都会创建新字符串
strings.Join 最佳 适用于将字符串切片拼接为字符串,性能高。但是如果字符串本身不是切片,那构建切片的过程会有一定的性能损耗。
bytes.Buffer 较好 底层使用 []byte,每次分配内存的时候都会根据切片特性预分配内存并自动扩容,所以总体来说重新分配内存的次数较少,性能较好。 但最后转换为字符串时需要拷贝数据。
strings.Builder 最佳 相较于 bytes.Buffer,底层实现类似,但 strings.Builder 专门用于字符串拼接,最后返回字符串时会直接复用 []byte 底层数组的内存空间,所以相较于 bytes.Buffer 性能更好。
append 函数 较差 需要先转换为字节切片,但是如果基于切片的特性,预分配合适的内存来减少重新分配内存的次数,性能会无限接近于 bytes.Buffer
fmt.Sprintf 最差 性能较差,底层实现使用了 fmt 包的格式化功能,适用于格式化字符串,但不适合频繁的字符串拼接。

性能对比:strings.Builderstrings.Join > bytes.Bufferappend > + 运算符 > fmt.Sprintf

回头看一下 LCR 182 的题目,要求将字符串的前 target 个字符移动到末尾。以上所有方法都能实现,且由于数据量较小,所以性能差异不大。随意选择一种实现即可。以下是一个使用 strings.Builder 的实现:

1
2
3
4
5
6
7
func dynamicPassword(password string, target int) string {
var ans strings.Builder
ans.Grow(len(password))
ans.WriteString(password[target:])
ans.WriteString(password[:target])
return ans.String()
}

当然前面提到过编译器对临时字符串的优化,所以也可以直接使用 + 来实现:

1
2
3
func dynamicPassword(password string, target int) string {
return password[target:] + password[:target]
}