深入理解go语言中的string

148 阅读4分钟

string的基本组成

在go语言中,string的组成其实和slice非常相似,只不过string是不可变的。

type StringHeader struct { 
    Data uintptr 
    Len int 
}

所以它的内部结构相比slice少了个cap。我们可以观察下面的小例子:

func print() {
	str1 := "abc"
	str2 := str1
	str2 = "def"
	fmt.Println(str1, str2) // 输出"abe def"
	fmt.Println(&str1)
	fmt.Println(&str2)
}

运行代码会发现,str1和str2的结果不一样,输出的地址也会不一样。换句话说,这两个是完全不一样的string当我们对str2修改的时候,实际上是重新分配内存并构造了一个新的string,需要注意的是:由于它内部有两个字段,这种复制并不是原子的,并发读写实际会有问题

string什么时候会共享底层的数据,什么时候不会?

有了上面的例子,我们不禁发问。string内部变化的规律是咋样的呢。我们看一个更复杂的例子:

func main() {

	s1 := "hello world"
	s2 := s1
	s3 := s1[:5]
	x1 := (*reflect.StringHeader)(unsafe.Pointer(&s1))
	x2 := (*reflect.StringHeader)(unsafe.Pointer(&s2))
	x3 := (*reflect.StringHeader)(unsafe.Pointer(&s3))

	fmt.Printf("x1: Data: %p, Len: %d\n", x1.Data, x1.Len)
	fmt.Printf("x2: Data: %p, Len: %d\n", x2.Data, x2.Len)
	fmt.Printf("x3: Data: %p, Len: %d\n", x3.Data, x3.Len)

	s2 = "test"

	x1 = (*reflect.StringHeader)(unsafe.Pointer(&s1))
	x2 = (*reflect.StringHeader)(unsafe.Pointer(&s2))
	x3 = (*reflect.StringHeader)(unsafe.Pointer(&s3))

	fmt.Printf("x1: Data: %p, Len: %d\n", x1.Data, x1.Len)
	fmt.Printf("x2: Data: %p, Len: %d\n", x2.Data, x2.Len)
	fmt.Printf("x3: Data: %p, Len: %d\n", x3.Data, x3.Len)
}

image.png

观察代码的输出可以发现:

  • 对于单纯的赋值给一个新的字符串对象,会共享底层的数据
  • 修改共享变量会重新分配内存
  • 对string的切片也是共享底层数据结构

同时,编译器对于string是有一定优化的。字符串内部化(string intern)是指一种让相同的字符串在内存中只保存一份的技术。对于要存储大量字符串的应用来说,它可以显著降低内存占用。换句话说:对于在编译期可以确定的字符串会共享同一份数据,而运行期才能确定的string则单独分配内存。

	s1 := "123"
	s2 := "1" + "23"
	s3 := strconv.Itoa(123)
	x1 := (*reflect.StringHeader)(unsafe.Pointer(&s1))
	x2 := (*reflect.StringHeader)(unsafe.Pointer(&s2))
	x3 := (*reflect.StringHeader)(unsafe.Pointer(&s3))

	fmt.Printf("x1: Data: %p, Len: %d\n", x1.Data, x1.Len)
	fmt.Printf("x2: Data: %p, Len: %d\n", x2.Data, x2.Len)
	fmt.Printf("x3: Data: %p, Len: %d\n", x3.Data, x3.Len)
}

image.png

观察代码的输出可以发现:对于运行期转型过来的字符串,字符串内部化并没有生效。所以为了节省内存,我们可以维护一些字符串常量,运行的时候再到池里取用。若是修改则重新赋值生成,否则可以共用底层的数据结构。

string的并发读写

string的并发读写并不是原子的,像下面的代码就会触发panic。所以在复杂的项目中,对于string的修改也需要注意。

package main

import (
	"fmt"
	"time"
)

func main() {
	flag := "init"
	go func() {
		for i := 1; i < 10000; i++ {
			request(flag)
		}
	}()

	for {
		flag = ""
		time.Sleep(10 * time.Nanosecond)
		flag = "test"
		time.Sleep(10 * time.Nanosecond)
	}

}

func request(f string) {
	k := f
	println(fmt.Sprintf("fullPath: %s", k))
}


string的循环读取

string的默认存储是utf-8,它是一种字符的可变编码。在go语言中,也就是[]rune类型,它是实际上就是4个字节的长度。当我们想要取string中的某个字符的时候,不同的迭代方式会有不同的结果。

package main

import (
    "fmt"
)

func main() {
    str := "hello 世界"

    //英文字符正常输出,中文乱码
    for i := 0; i < len(str); i++ {
       ch := str[i]
       fmt.Println(i, ch, string(ch))
    }

    // 全都正常输出
    for i, ch := range str {
       fmt.Println(i, ch, string(ch))
    }

    // 以下都会正常输出
    r := []rune(str)
    for i := 0; i < len(r); i++ {
       fmt.Println(i, string(r[i]))
    }

    for i, ch := range r {
       fmt.Println(i, string(ch))
    }
}

之所以能前面正常输出结果,是因为这里都是英文字符。后半部分是中文,使用下标每次取出一字节就会乱码。

image.png

string和[]byte互相转换

切片比string多了一个字段,转换的时候需要注意。这种转换避免了内存的拷贝,速度很快。但前提是保证slice是不可变的,否则转过去的string也会发生变化

func stringTobyteSlice(s string) []byte {
	tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
   // 这里的slice,len和cap相等
	tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
	return *(*[]byte)(unsafe.Pointer(&tmp2))

}

func byteSliceToString(bytes []byte) string {
   // 直接忽略cap字段
	return *(*string)(unsafe.Pointer(&bytes))
}

//或者采用以下方法,更具可读性
func ZeroCopyStringToBytes(s string) (b []byte) {
	bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	bh.Data = sh.Data
	bh.Len = sh.Len
	bh.Cap = sh.Len
	return b
}

func ZeroCopyBytesToString(b []byte) string {
	return *(*string)(unsafe.Pointer(&b))
}