FanXing Blog

FanXing Blog

一个热爱编程的高中生,正在努力成为一位优秀的后端工程师~

Day2. Go言語の精進の道:stringの実装原理と効率的な使用方法

前几天忙着復習 GORM,那篇文章过一段时间发,今天先来学 Golang 的 string 类型。


关于 String#

文字列型 (string) 是现代编程语言中最常用的数据类型。在 Go 的先祖之一的 C 语言中,字符串没有被显式定义,是以字符串字面值常量或是以 \0 结尾的字符类型 (char) 数组组成。

#define GOAUTHERS "Robert Griesemer, Rob Pike, and Ken Thompson"
const char * s = "hello world"
char s[] = "hello gopher"

但是这样,就给 C 程序员使用字符串时造成了一些困扰,诸如:

  • 类型安全差
  • 字符串操作时要时时考虑结尾的 \0
  • 字符串数据可变
  • 获取字符串长度代价大 (O(n)的时间复杂度)
  • 未内置对非 ASCII 字符的处理

但是在 Go 语言中修复了 C 语言的这一 "缺陷",内置了 string 类型,并且进行了统一的抽象。

Go 语言中的字符串#

在 Go 语言中,无论是代码中的常量、还是变量、还是出现的字符串字面量,他们都被统一的设置为 string

const S = "hello world"

func main() {
    var s1 string = "hello string"
    fmt.Printf("%T\n", S) // string
    fmt.Printf("%T\n", s1) // string
    fmt.Printf("%T\n", "hello") // string
}

Go 的 string 类型吸取了 C 语言字符串设计的经验,并且结合了其他语言的字符串类型上的最佳实践,最终 Go 语言的字符串呈现出以下功能特点:

1. 数据不可变#

一旦声明了一个 string 类型的字符串,无论是 常量 还是 变量 那么该标识符所代指的数据在整个程序的生命周期内无法改变。我们可以尝试一下,首先尝试第一种方案:

func main() {
	var s1 string = "hello string"
    fmt.Printf("原字符串: %s\n", s1)

	// 切片化后尝试改变
	sl := []byte(s1)
	sl[0] = 'l'
	fmt.Printf("slice: %s\n", sl)
	fmt.Printf("切片后,原字符串为: %s\n", s1)
}
// output:
// 原字符串: hello string
// slice: lello string
// 切片后,原字符串为: hello string

可以看到,我们将字符串转换为一个切片后,尝试对其修改,结果和我们想象中的不一致。对 string 切片后,Go 编译器会为切片重新分配新的底层内存,而不是和原 string 共用同一个底层内存,因此对于切片的修改,并没有对 s1 造成任何影响。

我们通过 unsafe 进行更暴力的手段试试:

func main() {
	var s1 string = "hello"
	fmt.Printf("原字符串: %s\n", s1)

	modifyString(&s1)
	fmt.Println(s1)
}

func modifyString(s *string) {
	// 取出第一个 8 字节的值
	p := (*uintptr)(unsafe.Pointer(s))

	// 获取底层数组的地址
	var array *[5]byte = (*[5]byte)(unsafe.Pointer(p))
	var l *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(s)) + unsafe.Sizeof((*uintptr)(nil))))

	for i := 0; i < (*l); i++ {
		fmt.Printf("%p => %c\n", &((*array)[i]), (*array)[i])
		pl := &((*array)[i])
		v := (*pl)
		(*pl) = v + 1
	}
}
// output:
// 原字符串: hello
// 0xc00016b8f0 => ô
// 0xc00016b8f1 => Í
// 0xc00016b8f2 => 
// 0xc00016b8f3 =>  
// 0xc00016b8f4 =>  
// unexpected fault address 0x10120cef5
// fatal error: fault
// [signal 0xc0000005 code=0x0 addr=0x10120cef5 pc=0x13ef7c]

我们可以发现,string 的底层数据区仅能进行只读操作,一旦尝试修改那部分内存,就会得到 SIGBUS 运行时错误,对 string 数据的 "篡改攻击" 再次失败。

2. 零值可用#

Go string 类型支持 "零值可用" 的理念。Go 字符串无需像 C 语言中那样考虑结尾 \0 的字符,因此其零值为 "",长度为 0。

var s string
fmt.Printf("%s\n", s)
fmt.Printf("%d\n", len(s))
// output:
// ""
// 0

3. 获取长度的时间复杂度是 O (1) 复杂度#

Go string 类型的数据长度是不可变的,因此一旦有了初值,那块数据就不会变,其长度也不会改变。Go 将这个长度作为一个字段存储在 string 类型的内部表示结构中。这样获取字符串长度时,即 len(s) 实际上就只是在读取 runtime/string 中的一个字段,这是个代价极低的操作。

4. 支持通过 +/+= 操作符进行字符串连接#

对开发者来讲,通过 ++= 操作符进行的字符串连接是体验最好的字符串连接操作,而 Go 语言支持该操作:

s := "hello"
s = s + " world"
s += ", golang"

fmt.Println(s)
// output: hello world, golang

5. 支持各种比较关系操作符:==, !=, >=, <=,> 和 <#

Go 支持各种比较关系操作符:

func main() {
	s1 := "hello world"
	s2 := "hello" + " world"
	fmt.Println(s1 == s2)

	s1 = "Go"
	s2 = "C"
	fmt.Println(s1 != s2)

	s1 = "12345"
	s2 = "23456"
	fmt.Println(s1 < s2)
	fmt.Println(s1 <= s2)

	s1 = "12345"
	s2 = "123"
	fmt.Println(s1 > s2)
	fmt.Println(s1 >= s2)
}
// output:
// true
// true
// true
// true
// true
// true

由于 Go String 是不可变的,因此如果两个字符串长度不相同,那么无须比较具体字符串数据即可断定两个字符串是不同的。如果长度相同,则要进一步判断数据指针是否指向同一块存储数据。如果相同,则两个字符串是等价的;如果不同,则还需进一步比较实际的数据内容。

6. 对非 ASCII 字符提供原生支持#

Go 语言源文件全部默认采用 unicode 字符集。Unicode 字符集是目前市面上最流行的字符集,几乎囊括了所有非 ASCII 字符。Go 字符串的每个字符都是一个 unicode 字符,而且这些 unicode 字符是以 utf-8 编码存储在内存当中。

7. 原生支持多行字符串#

C 语言中如果要构建多行字符串,要么使用多个字符串进行拼接,要么结合续行符 \,很难控制好格式。

而 Go 语言直接提供了反引号方式构建多行字符串的方法:

func main() {

	s := `hello
world
golang`

	fmt.Println(s)
}
// output:
// hello
// world
// golang

字符串的内部表示#

Go string 类型的特性实现与 Go runtime 对 string 类型的内部表示是分不开的。Go string 在 runtime 中表现为以下结构:

// $GOROOT/src/runtime/string.go
type stringStruct struct {
    str unsafe.Pointer
    len int
}

我们看到的 string 字符串其实是一个描述符,它本身并不真正存储数据,它指向了一个由指向底层存储的指针和字符串的长度字段组成的对象。我们结合 string 的实例化过程来看:

// $GOROOT/src/runtime/string.go

func rawstring(size int) (s string, b []byte) {
    p := mallocgc(uintptr(size), nil, false)
    stringStructOf(&s).str = p
    stringStructOf(&s).len = size

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

    return
}

结合该图分析:

image

我们可以看到,每一个字符串都对应了一个 stringStruct 实例。我们在执行 rawstring 后,stringStruct 中的 str 指针指向了真正存储字符串数据的底层内存区域,len 字段存储的是字符串的长度;同时,rawstring 同时还会创建一个临时 slice,该 slicearray 指针也指向字符串的底层存储内存区域。注意,rawstring 执行之后,申请的内存区域还没有被写入数据,该 slice 就是为了在后续运行时中向内存中写入数据 "hello"

写完数据后,slice 就会被回收掉了。

根据 stringruntime 中的表示我们可以发现,直接将 string 类型通过 函数 / 方法 参数传入也不会有太多损耗,因为传入的仅仅是一个描述符,而不是真正的字符串数据。

字符串的高效构造#

前面讲到,Go 原生就支持使用 +/+= 操作符来连接多个字符串以构造一个更长的字符串,并且通过 +/+= 来构造是开发体验最好的一种方法。但是 Go 还提供了一些其他的构造方式:

  • fmt.Sprintf
  • strings.Join
  • strings.Builder
  • bytes.Buffer

这些方法中,究竟哪种方法效率最高?我们使用基准测试的数据作为参考:

var sl []string = []string{
	"Rob Pike ",
	"Robert Griesemer ",
	"Ken Thompson ",
}

func concatStringByOperator(sl []string) string {
	var s string
	for _, v := range sl {
		s += v
	}
	return s
}

func concatStringBySprintf(sl []string) string {
	var s string
	for _, v := range sl {
		s = fmt.Sprintf("%s%s", s, v)
	}
	return s
}

func concatStringByJoin(sl []string) string {
	return strings.Join(sl, "")
}

func concatStringByStringsBuilder(sl []string) string {
	var b strings.Builder
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByStringsBuilderWithInitSize(sl []string) string {
	var b strings.Builder
	b.Grow(64)
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByBytesBuffer(sl []string) string {
	var b bytes.Buffer
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func concatStringByBytesBufferWithInitSize(sl []string) string {
	buf := make([]byte, 0, 64)
	b := bytes.NewBuffer(buf)
	for _, v := range sl {
		b.WriteString(v)
	}
	return b.String()
}

func BenchmarkConcatStringByOperator(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByOperator(sl)
	}
}

func BenchmarkConcatStringBySprintf(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringBySprintf(sl)
	}
}

func BenchmarkConcatStringByJoin(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByJoin(sl)
	}
}

func BenchmarkConcatStringByStringsBuilder(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByStringsBuilder(sl)
	}
}

func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByStringsBuilderWithInitSize(sl)
	}
}

func BenchmarkConcatStringByBytesBuffer(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByBytesBuffer(sl)
	}
}

func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) {
	for n := 0; n < b.N; n++ {
		concatStringByBytesBufferWithInitSize(sl)
	}
}

测试结果如下:

goos: windows
goarch: amd64
pkg: prometheus_for_go
cpu: AMD Ryzen 7 5800H with Radeon Graphics         
BenchmarkConcatStringByOperator
BenchmarkConcatStringByOperator-16                      	16968997	        67.09 ns/op	      80 B/op	       2 allocs/op
BenchmarkConcatStringBySprintf
BenchmarkConcatStringBySprintf-16                       	 3519394	       337.6 ns/op	     176 B/op	       8 allocs/op
BenchmarkConcatStringByJoin
BenchmarkConcatStringByJoin-16                          	27803714	        42.37 ns/op	      48 B/op	       1 allocs/op
BenchmarkConcatStringByStringsBuilder
BenchmarkConcatStringByStringsBuilder-16                	13961523	        85.36 ns/op	     112 B/op	       3 allocs/op
BenchmarkConcatStringByStringsBuilderWithInitSize
BenchmarkConcatStringByStringsBuilderWithInitSize-16    	32188840	        38.04 ns/op	      64 B/op	       1 allocs/op
BenchmarkConcatStringByBytesBuffer
BenchmarkConcatStringByBytesBuffer-16                   	18743878	        63.34 ns/op	     112 B/op	       2 allocs/op
BenchmarkConcatStringByBytesBufferWithInitSize
BenchmarkConcatStringByBytesBufferWithInitSize-16       	33670977	        35.76 ns/op	      48 B/op	       1 allocs/op

我们可以看到,做了预初始化的 Bytes.Buffer 的效率最高,每次操作仅需要 35.76ns,并且内存操作也仅仅需要一次。经过我的多次测试,效能最佳的均是该项,也就是预初始化后的 bytes.Buffer

然后就是预初始化的 strings.Builder,仅仅比 bytes.Buffer 慢了大概 3ns,可以说是非常接近 bytes.Buffer 的水平。

所以我们可以得出结论:

  • 在能预估最终字符串长度的情况下,使用预初始化的 bytes.Bufferstrings.Builder 构建字符串的效果最佳;
  • strings.Join 的性能是最稳定的,如果输入多个字符串,是以 []string 承载的,那么 strings.Join 也是个不错的选择
  • 直接使用操作符是最直观、最自然的,在编译器知道连接字符串个数的情况下,编译器会对这种方法进行优化。
  • fmt.Sprintf 虽然效率不高,但是如果我们需要稳定的使用不同变量来构建某种特定格式的字符串,这种方式还是最合适的

字符串的高效转换#

前面的例子中,我们看到了 string[]rune 之间的转换,以及 string[]byte 的转换,这两个转换都是可逆的。也就是说,string 可以和 []byte 以及 []rune 之间相互转换。这是一个例子:

func main() {
    rs := []rune{
        0x4E2D,
        0x56FD,
        0x6B22,
        0x8FCE,
        0x60A8,
    }

    s := string(rs)
    fmt.Println(s)

    sl := []byte{
        0xE4, 0xB8, 0xAD,
        0xE5, 0x9B, 0xBD,
        0xE6, 0xAC, 0xA2,
        0xE8, 0xBF, 0x8E,
        0xE6, 0x82, 0xA8,
    }

    s = string(sl)
    fmt.Println(s)
}

$go run string_slice_to_string.go
中国欢迎您
中国欢迎您

不管是 stringslice 还是 slicestring,转换都需要付出代价,也就是内存操作。这个代价的根源是,string 是不可变的,所以每次转换,都需要为转换后的类型分配新的内存,我们可以看一下 stringslice 之间的转换过程中,内存分配情况:

func byteSliceToString() {
	sl := []byte{
		0xE4, 0xB8, 0xAD,
		0xE5, 0x9B, 0xBD,
		0xE6, 0xAC, 0xA2,
		0xE8, 0xBF, 0x8E,
		0xE6, 0x82, 0xA8,
		0xEF, 0xBC, 0x8C,
		0xE5, 0x8C, 0x97,
		0xE4, 0xBA, 0xAC,
		0xE6, 0xAC, 0xA2,
		0xE8, 0xBF, 0x8E,
		0xE6, 0x82, 0xA8,
	}

	_ = string(sl)
}

func stringToByteSlice() {
	s := "中国欢迎您,北京欢迎您"
	_ = []byte(s)
}

func main() {
	fmt.Println(testing.AllocsPerRun(1, byteSliceToString))
	fmt.Println(testing.AllocsPerRun(1, stringToByteSlice))
}
// output:
// 1
// 0

在这里,出现了个有趣的地方,我的操作环境是:go version go1.23.4 windows/amd64,在我将 s 字符串更换为 s := fmt.Sprintf("中国欢迎您,北京欢迎您") 之后,stringToBytesSlice 的内存操作次数从 0 变为了 1。

也就是这样的代码:

// 其他代码均无改变

func stringToByteSlice() {
	s := fmt.Sprintf("中国欢迎您,北京欢迎您")
	_ = []byte(s)
}

// output:
// 1
// 1

也就是说,如果一个字符串是编译器可以预测到的,那么编译器会优化 stringToByteSlice 这样的操作,使其内存操作次数为 0。但是如果,字符串是不可知的,也就是 fmt.Sprintf 这种在线构建的,只有在运行时才会知道字符串的内容,那么就无法被编译器所优化了,内存操作次数就会为 1。

那么就可以确定一个概念:

如果是一个可以确定内容的字符串,那么编译器会在 stringToByteSlice 的过程中进行优化,如果字符串的内容是在运行时确定,那么在转换过程中仍然会出现内存分配。

那么在 Go Runtime 层面呢?在 Golang runtime 中,负责转换的函数如下:

// $GOROOT/src/runtime/string.go
slicebytetostring: []byte -> string
slicerunetostring: []rune -> string
stringtoslicebyte: string -> []byte
stringtoslicerune: string -> []rune

我们就以 byte slice 为例子,看看 stringbytetostringstringtoslicebyte 的具体实现:

// $GOROOT/src/runtime/string.go

const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]byte

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
	var b []byte
	if buf != nil && len(s) <= len(buf) {
		*buf = tmpBuf{}
		b = buf[:len(s)]
	} else {
		b = rawbyteslice(len(s))
	}
	copy(b, s)
	return b
}

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
	if n == 0 {
		// Turns out to be a relatively common case.
		// Consider that you want to parse out data between parens in "foo()bar",
		// you find the indices and convert the subslice to string.
		return ""
	}
	if raceenabled {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			abi.FuncPCABIInternal(slicebytetostring))
	}
	if msanenabled {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if asanenabled {
		asanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if n == 1 {
		p := unsafe.Pointer(&staticuint64s[*ptr])
		if goarch.BigEndian {
			p = add(p, 7)
		}
		return unsafe.String((*byte)(p), 1)
	}

	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(n), nil, false)
	}
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return unsafe.String((*byte)(p), n)
}

如果想要实现高效的转换,那么唯一的方法就是减少内存分配。我们可以看到,runtime 中实现类型转换的函数已经加入了避免内存重复操作的代码,比如 tmpBuf 复用,空字符串优化,单字节字符串优化。

slice 是不可比较的,而 string 是可以被比较的,所以我们经常会将 slice 转换为 string。Go 编译器为了这样的场景专门做了优化,runtime 中有一个叫做 slicebytetostringtmp 的函数就是协助实现该优化的:

func slicebytetostringtmp(ptr *byte, n int) string {
	if raceenabled && n > 0 {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			abi.FuncPCABIInternal(slicebytetostringtmp))
	}
	if msanenabled && n > 0 {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if asanenabled && n > 0 {
		asanread(unsafe.Pointer(ptr), uintptr(n))
	}
	return unsafe.String(ptr, n)
}

那么 slicebytetostringtmp 做了什么优化?这个函数的优化操作是很激进的,它选择直接复用 slice 的底层内存,这样不会做任何的内存分配和值拷贝。但是用这个函数的前提是:原 slice 被修改后,这个字符串会直接不可用。所以这个函数一般会用在以下几个场景中:

b := []byte("k", "e", "y")

// 1. string(b) 用在 map 类型的 key 中
m := make(map[string]string)
m[string(b)] = "value"
m[[3]string{string(b), "key1", "key2"}] = "value1"

// 2. string(b) 用在字符串连接语句中
s := "hello " + string(b) + "!"

// 3. string(b) 用在字符串比较重
s := "tom"

if s < string(b) {
    ...
}
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。