go

go中string是如何实现的呢

go中string是如何实现的呢

Posted by liz on January 22, 2021

go中string是如何实现的呢

前言

go中的string可谓是用到的最频繁的关键词之一了,如何实现,我们来探究下

实现

// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

string我们看起来是一个整体,但是本质上是一片连续的内存空间,我们也可以将它理解成一个由字符组成的数组,相比于切片仅仅少了一个Cap属性。

src/reflect/value.go

type StringHeader struct {
	Data uintptr
	Len  int
}

切片的数据结构

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

1、相比于切片少了一个容量的cap字段,就意味着string是不能发生地址空间扩容;

2、可以把string当成一个只读的切片类型;

3、string本身的切片是只读的,所以不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上的写入操作都是通过拷贝实现的。

go语言中的string是不可变的

func main() {
	s := "212"
	fmt.Println(&s)
	fmt.Println(len(s))
	s = "33hhhhhhhhhnihihfnHSIHISASIASJAISJAISJAISJAISJAISA"
	fmt.Println(&s)
	fmt.Println(len(s))
}

上面的例子,s发生了,两次赋值,里面的值发生了改变,好奇怪,明明是不可修改的

go中的字符串底层也是引用类型,类似切片,只是对比切片少了一个cap字段,也就是不能发生扩容。也包含一个指针,指向它引用的字节系列的数组[]byte,所以当改变一个字符串的值,原来指针指向的[]byte将被丢弃,字符串包含的指针,将指向一个新的值转换而来的字节数组。所以说字符串的值是不能被修改的,对于重新赋值,老的值没有被修改,只是被弃用了。

[]byte转string

src/runtime/string.go

type stringStruct struct {
	str unsafe.Pointer
	len int
}

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
	l := len(b)
	if l == 0 {
		return ""
	}
	if l == 1 {
		stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])
		stringStructOf(&str).len = 1
		return
	}
	var p unsafe.Pointer
	// 判断传入的缓冲区大小,决定是否重新分配内存
	if buf != nil && len(b) <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		// 重新分配内存
		p = mallocgc(uintptr(len(b)), nil, false)
	}
	// 将输出的str转化成stringStruct结构
	// 并且赋值
	stringStructOf(&str).str = p
	stringStructOf(&str).len = len(b)
	// 将[]byte中的内容,复制到内存空间p中
	memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))
	return
}

// 转换成
func stringStructOf(sp *string) *stringStruct {
	return (*stringStruct)(unsafe.Pointer(sp))
}

总结下流程:

1、根据传入的内存大小,判断是否需要分配重新分配内存;
2、构建stringStruct,分类长度和内存空间;
3、赋值[]byte里面的数据到新构建stringStruct的内存空间中。

string转[]byte

src/runtime/string.go

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
}

// 为[]byte重新分配一段内存
func rawbyteslice(size int) (b []byte) {
	cap := roundupsize(uintptr(size))
	p := mallocgc(cap, nil, false)
	if cap != uintptr(size) {
		memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))
	}

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

1、判断传入的缓存区大小,如果内存够用就使用传入的缓冲区存储 []byte;
2、传入的缓存区的大小不够,调用 runtime.rawbyteslice创建指定大小的[]byte;
3、将string拷贝到切片。

字符串的拼接

+方式进行拼接

func main() {
	s := "hai~"
	s += "hello world"
	fmt.Println(s)
}

一个拼接语句的字符串编译时都会被存放到一个切片中,拼接过程需要遍历两次切片,第一次遍历获取总的字符串长度,据此申请内存,第二次遍历会把字符串逐个拷贝过去。

所以,这种方式拼接只要性能问题是在copy上,适合短小的、常量字符串(明确的,非变量)。

fmt 拼接

func main() {
	s := fmt.Sprintf("%s%s%d", "hello", "world", 2021)
	fmt.Println(s)
}

fmt可以方便对各种类型的数据进行拼接,转换成string,具体详见printf的用法

Join 拼接

func main() {
	var s []string

	s = append(s, "hello")
	s = append(s, "world")

	fmt.Println(strings.Join(s, ""))
}

buffer 拼接

func main() {
	var b bytes.Buffer
	b.WriteString("hello")
	b.WriteString("world")
	fmt.Println(b.String())
}

builder 拼接

func main() {
	var b bytes.Buffer
	b.WriteString("hello")
	b.WriteString("world")
	fmt.Println(b.String())
}

测试下几种方法的性能

压力测试

package main

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

func String() string {
	var s string
	s += "hello" + "\n"
	s += "world" + "\n"
	s += "今天的天气很不错的"
	return s
}

func BenchmarkString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		String()
	}
}

func StringFmt() string {
	return fmt.Sprintf("%s %s %s", "hello", "world", "今天的天气很不错的")
}

func BenchmarkStringFmt(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StringFmt()
	}
}

func StringJoin() string {
	var s []string
	s = append(s, "hello ")
	s = append(s, "world ")
	s = append(s, "今天的天气很不错的 ")

	return strings.Join(s, "")
}

func BenchmarkStringJoin(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StringJoin()
	}
}

func StringBuffer() string {
	var s bytes.Buffer
	s.WriteString("hello ")
	s.WriteString("world ")
	s.WriteString("今天的天气很不错的 ")

	return s.String()
}

func BenchmarkStringBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StringBuffer()
	}
}

func StringBuilder() string {
	var s strings.Builder
	s.WriteString("hello ")
	s.WriteString("world ")
	s.WriteString("今天的天气很不错的 ")

	return s.String()
}

func BenchmarkStringBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StringBuilder()
	}
}

看下执行的结果

$ go test string_test.go  -bench=. -benchmem -benchtime=3s
goos: darwin
goarch: amd64
BenchmarkString-4               28862838               115 ns/op              64 B/op          2 allocs/op
BenchmarkStringFmt-4            20697505               169 ns/op              48 B/op          1 allocs/op
BenchmarkStringJoin-4           11304583               293 ns/op             160 B/op          4 allocs/op
BenchmarkStringBuffer-4         31151836               104 ns/op             112 B/op          2 allocs/op
BenchmarkStringBuilder-4        29142151               120 ns/op              72 B/op          3 allocs/op
PASS
ok      command-line-arguments  17.740s

ns/op 平均一次执行的时间 B/op 平均一次真申请的内存大小 allocs/op 平均一次,申请的内存次数

从上面我们就能直观的看出差距,不过差距不大,当然具体的性能信息要结合当前go版本,具体讨论。

看上去很low的+拼接方式,在性能上倒是还不错。

go101中评论

标准编译器对使用+运算符的字符串衔接做了特别的优化。 所以,一般说来,在被衔接的字符串的数量是已知的情况下,使用+运算符进行字符串衔接是比较高效的。

我的版本是go version go1.13.15 darwin/amd64

字符类型

我们在go中经常遇到rune和byte两种字符串类型,作为go中字符串的两种类型:

byte

byte 也叫 uint8。代表了 ASCII 码的一个字符。

对于英文,一个ASCII表示一个字符,根据ASCII表。我们知道 A对应的十进制编码是65。我们看看byte打印的结果

func main() {
	s := "A"
	fmt.Print("打印下[]byte(s),结果十进制:")
	fmt.Println([]byte(s))

	fmt.Print("打印下[]byte(s)中存储的类型,存储的是十六进制:")
	fmt.Printf("%#v\n", []byte(s))

	s1 := "世界"
	fmt.Print("打印下[]byte(s1),结果十进制:")
	fmt.Println([]byte(s1))

	fmt.Print("打印下[]byte(s1)中存储的类型,存储的是十六进制:")
	fmt.Printf("%#v\n", []byte(s1))

	fmt.Print("打印下s1的十六进制:")
	fmt.Printf("%x\n", s1)
}

结果

打印下[]byte(s),结果十进制:[65]
打印下[]byte(s)中存储的类型,存储的是十六进制:[]byte{0x41}
打印下[]byte(s1),结果十进制:[228 184 150 231 149 140]
打印下[]byte(s1)中存储的类型,存储的是十六进制:[]byte{0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c}
打印下s1的十六进制:e4b896e7958c

对于ASCII一个索引位表示一个字符(也就是英文)

对于非ASCII,索引更新的步长将超过1个字节,中文的是三个字节表示一个中文。

rune

rune 等价于 int32 类型,UTF8编码的Unicode码点。

func main() {
	s := "哈哈"
	fmt.Println([]rune(s))

	s1 := "A"
	fmt.Println([]rune(s1))
}

打印下结果

[21704 21704]
[65]

我们可以看到里面对应的是UTF-8的十进制数字。对于英文来讲UTF-8的码点,就是对应的ASCII。

string

// 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

关于Unicode和UTF8的区别和联系,以及ASCII码的联系,参考字符编码-字库表,字符集,字符编码

内存泄露的场景

不正确的使用会导致短暂的内存泄露

var s0 string // 一个包级变量

func main() {
	s := bigString() // 申请一个大string
	// 如果f函数的执行链路很长,申请的大s的内存将得不到释放,直到f函数执行完成被gc
	f(s)
}

func f(s1 string) {
	s0 = s1[:50]
	// 目前,s0和s1共享着承载它们的字节序列的同一个内存块。
	// 虽然s1到这里已经不再被使用了,但是s0仍然在使用中,
	// 所以它们共享的内存块将不会被回收。虽然此内存块中
	// 只有50字节被真正使用,而其它字节却无法再被使用。
}

func bigString() string {
	var buf []byte
	for i := 0; i < 10; i++ {
		buf = append(buf, make([]byte, 1024*1024)...)
	}
	return string(buf)
}

上面的例子,一个大的变量s1,另一个字符串s0对这个变量进行了部分引用,新申请的变量s0和老的变量s1共用同一块内存。所以虽然大变量s1不用了,但是也不能马上被gc。

解决方法

思路是发生一次copy,类似go中闭包的解决方法

strings.Builder

func f(s1 string) {
	var b strings.Builder
	b.Grow(50)
	b.WriteString(s1[:50])
	s0 = b.String()
}

感觉有点繁琐,当前也可以使用strings.Repeat, 从Go 1.12开始,此函数也是用strings.Builder实现的。

func Repeat(s string, count int) string {
	if count == 0 {
		return ""
	}

	if count < 0 {
		panic("strings: negative Repeat count")
	} else if len(s)*count/count != len(s) {
		panic("strings: Repeat count causes overflow")
	}

	n := len(s) * count
	var b Builder
	b.Grow(n)
	b.WriteString(s)
	for b.Len() < n {
		if b.Len() <= n/2 {
			b.WriteString(b.String())
		} else {
			b.WriteString(b.String()[:n-b.Len()])
			break
		}
	}
	return b.String()
}

使用demo

func main() {
	res := strings.Repeat("你好", 1)
	fmt.Println(res)
}

string和[]byte如何取舍

  • string 擅长的场景:

需要字符串比较的场景;
不需要nil字符串的场景;

  • []byte擅长的场景:

修改字符串的场景,尤其是修改粒度为1个字节;
函数返回值,需要用nil表示含义的场景;
需要切片操作的场景;

所以底层实现会发现有很多[]byte

总结

1、字符串不允许修改,string不包含内存空间,只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝;
2、我们阅读go源码,会发现很多地方使用[]byte,使用[]byte能更方便的修改字符串;
3、go中的[]byte存储的是十六进制的ascii码,对于英文一个ascii码表示一个字母,对于中文是三个ascii码表示一个字母;
4、对于[]rune来讲,里面存储的是UTF8编码的Unicode码点,关于UTF8Unicode区别,Unicode是字符集,UTF8是字符集编码,是Unicode规则字库的一种实现形式;
5、字符串的不正确使用会发生内存泄露;
6、对于字符串的拼接,+拼接是一种看上去low,但是对于短小的、常量字符串(明确的,非变量),效率还不错的方法;

参考

【字符串】https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-string/
【字符串】https://chai2010.gitbooks.io/advanced-go-programming-book/content/ch1-basic/ch1-03-array-string-and-slice.html
【Golang中的string实现】https://erenming.com/2019/12/11/string-in-golang/
【字符串】https://gfw.go101.org/article/string.html
【Go string 实现原理剖析(你真的了解string吗)】https://my.oschina.net/renhc/blog/3019849
【Go语言字符串高效拼接(一)】https://cloud.tencent.com/developer/article/1367934
【Go语言字符类型(byte和rune)】http://c.biancheng.net/view/18.html
【go 的 [] rune 和 [] byte 区别】https://learnku.com/articles/23411/the-difference-between-rune-and-byte-of-go
【go语言圣经】http://books.studygolang.com/gopl-zh/ch3/ch3-05.html
【【Go语言踩坑系列(二)】字符串】https://www.mdeditor.tw/pl/pCg8
【为什么说go语言中的string是不可变的?】https://studygolang.com/topics/3727