命令记不住

登陆

切片和 string 原理

string原理

1.数据结构

源码包src/runtime/string.go:stringStruct定义了string的数据结构:

type stringStruct struct {
	str unsafe.Pointer
	len int
}

其数据结构很简单:

stringStruct.str:字符串的首地址
stringStruct.len:字符串的长度

string数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上string和切片,准确的说是byte切片经常发生转换.

2.string操作

声明

如下代码所示,可以声明一个string变量变赋予初值:

var str string
str = "Hello World"

字符串构建过程是先跟据字符串构建stringStruct,再转换成string,转换的源码如下:

// 跟据字符串地址构建string
func gostringnocopy(str *byte) string {
	// 先构造stringStruct
	ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
	// 再将stringStruct转换成string
	s := *(*string)(unsafe.Pointer(&ss))
	return s
}

string在runtime包中就是stringStruct,对外呈现叫做string.

1.1 []byte转string

byte切片可以很方便的转换成string,如下所示:

func GetStringBySlice(s []byte) string {
	return string(s)
}

需要注意的是这种转换需要一次内存拷贝

转换过程如下:
\1. 跟据切片的长度申请内存空间,假设内存地址为p,切片长度为len(b);
\2. 构建string(string.str = p;string.len = len;)
\3. 拷贝数据(切片中数据拷贝到新申请的内存空间)

1.2 string转[]byte

string也可以方便的转成byte切片,如下所示:

func GetSliceByString(str string) []byte {
	return []byte(str)
}

string转换成byte切片,也需要一次内存拷贝,其过程如下:
申请切片内存空间
将string拷贝到切片

1.3 字符串拼接

字符串可以很方便的拼接,像下面这样:

str := "Str1" + "Str2" + "Str3"

即便有非常多的字符串需要拼接,性能上也有比较好的保证,因为新字符串的内存空间是一次分配完成的,所以性能消耗主要在拷贝数据上。

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

  • 字符串拼接伪代码如下:
// 字符串拼接
func concatstrings(a []string) string {
	length := 0 // 拼接后总的字符串长度
	for _, str := range a {
		length += length(str)
	}
	s, b := rawstring(length) // 生成指定大小的字符串,返回一个string和切片,二者共
	享内存空间
	for _, str := range a {
		copy(b, str) // string无法修改,只能通过切片修改
		b = b[len(str):]
	}
	return s
}

因为string是无法直接修改的,所以这里使用rawstring()方法初始化一个指定大小的string,同时返回一个切片,二者共享同一块内存空间,后面向切片中拷贝数据,也就间接修改了string。

  • rawstring()源代码如下:
// 生成一个新的string,返回的string和切片共享相同的空间
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
}

3.字符串不允许修改

像C++语言中的string,其本身拥有内存空间,修改string是支持的。但Go的实现中,string不包含内存空间,只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。

因为string通常指向字符串字面量,而字符串字面量存储位置是只读段,而不是堆或栈上,所以才有了string不可修改的约定。

4.类型转换的拷贝内存

byte切片转换成string的场景很多,为了性能上的考虑,有时候只是临时需要字符串的场景下,byte切片转换成string时并不会拷贝内存,而是直接返回一个string,这个string的指针(string.str)指向切片的内存。

比如,编译器会识别如下临时场景:

  • 使用m[string(b)]来查找map(map是string为key,临时把切片b转成string);
  • 字符串拼接,如"<" + "string(b)" + ">";
  • 字符串比较:string(b) == "foo"

因为是临时把byte切片转换成string,也就避免了因byte切片同容改成而导致string引用失败的情况,所以此时可以不必拷贝内存新建一个string.

5.string和[]byte如何取舍

string和[]byte都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要根据实际应用场景来选择。

  • string 擅长的场景
需要字符串比较的场景;
不需要nil字符串的场景;
  • []byte擅长的场景
修改字符串的场景,尤其是修改粒度为1个字节;
函数返回值,需要用nil表示含义的场景;
需要切片操作的场景;

虽然看起来string适用的场景不如[]byte多,但因为string直观,在实际应用中还是大量存在,在偏底层的实现中[]byte使用更多

6.切片的原理

切片 ( slice ) 是 Go中一种比较特殊的数据结构,这种数据结构更便于使用和管理数据集合。
切片是围绕动态数组的概念构建的,与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
但是很多同学对 slice 的模糊认识,造成认为Go中的切片作为函数参数是地址传递,结果就是在实际开发中碰到很多坑,以至于出现一些莫名奇妙的问题,数组中的数据丢失了。

func Demo(slice []int) {
	slice = append(slice, 6, 6, 6)
	fmt.Println("函数中结果:", slice)
}
func main() {
	//定义一个切片
	slice := []int{1, 2, 3, 4, 5}
	Demo(slice)
	fmt.Println("定义中结果:", slice)
}

结果

函数中结果: [1 2 3 4 5 6 6 6]
定义中结果: [1 2 3 4 5]

在上面代码中并没有将切片(slice)的修改结果在主调函数中显示,因为Go语言所有函数参数都是值传递。

下面就开始详细理解下 slice,理解后会对开发出高效的程序非常有帮助

//定义一个切片
var slice []int
//unsafe.Sizeof功能:计算一个数据在内存中占的字节大小
size := unsafe.Sizeof(slice)
fmt.Println(slice)
//无论切片是否有数据 计算结果都为:24

slice 的数据结构很简单,一个指向真实数据集合地址的指针ptr,slice 的长度 len 和容量 cap

  • 片数据结构在源码包src的 /runtime/slice.go里面:
//path:Go SDK/src/runtime/slice.go
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

7.切片的源码

在创建切片时,可以根据源码进行分析

var slice []int = make([]int, 10, 20)

会被编译器翻译为 runtime.makeslice,并执行如下函数:

func makeslice(et *_type, len, cap int) slice {
	maxElements := maxSliceCap(et.size)
	// 判断len是否满足条件
	if len < 0 || uintptr(len) > maxElements {
		panic(errorString("makeslice: len out of range"))
	}
	// 判断cap是否满足条件
	if cap < len || uintptr(cap) > maxElements {
		panic(errorString("makeslice: cap out of range"))
	}
	//根据cap大小,通过mallocgc创建内存空间
	p := mallocgc(et.size*uintptr(cap), et, true)
	// 将地址作为其中一个结构体成员返回
	return slice{p, len, cap}
}

如果创建切片是基于新建内存空间

var slice *[]int = new([]int)

会编译为

func newobject(typ *_type) unsafe.Pointer {
	// 创建内存空间 并返回指针
	return mallocgc(typ.size, typ, true)
}

8.扩展表达式和简单表达式

  • slice表达式分为简单表达式a[low , hight]和扩展表达式a[low : high : max]
  • 简单表达式作用于数组,切片时产生新的切片,作用于字符串时产生新的字符串
  • 扩展表达式只能作用于数组,切片,不能作用于字符串

简单表达式生成得到新切片与原数组或切片共享底层数组避免了拷贝元素,节约内存空间的同时可能会带来风险

新切片b(b : = a[low,hight])不仅可以读写a[low]至a[hight-1]之间的所有元素,而且在使用append(b,x)函数增加新的元素x时,还可能会覆盖a[hight]以及后面的元素。例如:

a := [5]int{1, 2, 3, 4, 5}

b := a[1:4]

b = append(b, 0) //此时元素a[4]将由5变为0

使用新的切片覆盖a[hight]及后面的元素,有可能是非预期的,从而产生灾难性的后果

为了避免该风险,可以使用扩展表达式限制新切片容量

a[low:hight:max]

扩展表达式中的max用于限制新生成切片的容量,新切片的容量为max - low,表达式中的low、hight和max需要满足以下关系

0 <= low <= hight <= max <= cap(a)

例如:

array : = [10]int

slice := array[5:5:7]

如果使用简单表达式,切片的容量为5,而使用扩展表达式时切片的容量则被限制为2.

扩展表达式常见于偏底层的代码,比如GO源码,扩展表达式生成的切片将被限制存储容量,习惯将其称为封印的切片,当使用append()函数向被封印的切片追加新元素时,如果存储容量不足则会产生一个全新的切片,而不会覆盖原始的数组或切片

扩展表达式中的hight或max则会产生编译错误

package main

import "fmt"

func sliceRise1(s []int) {
	s = append(s, 0)
	fmt.Println("append s1 = ", s)
	for i := range s {
		s[i]++
	}
	fmt.Println("range s1 = ", s)
}
func sliceRise2(s []int) {
	s = append(s, 0)
	fmt.Println("append s2 = ", s)
	for i := range s {
		s[i]++
	}
	fmt.Println("range s2 = ", s)
}
5
func main() {
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	fmt.Println("main s2 = ", s2)
	sliceRise1(s1)
	sliceRise2(s2)
	fmt.Println("result s1 = ", s1)
	fmt.Println("result s1 = ", s2)
}

程序输出:

main s2 =  [1 2 3]
append s1 =  [1 2 0]
range s1 =  [2 3 1]
append s2 =  [1 2 3 0]
range s2 =  [2 3 4 1]
result s1 =  [1 2]
result s1 =  [2 3 4]

9.扩容和覆盖

示例

func main() {

	arr1 := [...]int{1, 2, 3, 4}
	arr2 := [...]int{1, 2, 3, 4}

	sli1 := arr1[0:2] // 长度为2,容量为4
	sli2 := arr2[2:4] // 长度为2,容量为2

	fmt.Printf("sli1 pointer is %p, len is %v, cap is %v, value is %v\n", &sli1, len(sli1), cap(sli1), sli1)
	fmt.Printf("sli2 pointer is %p, len is %v, cap is %v, value is %v\n", &sli2, len(sli2), cap(sli2), sli2)

	newSli1 := append(sli1, 5)
	fmt.Printf("newSli1 pointer is %p, len is %v, cap is %v, value is %v\n", &newSli1, len(newSli1), cap(newSli1), newSli1)
	fmt.Printf("source arr1 become %v\n", arr1)

	newSli2 := append(sli2, 5)
	fmt.Printf("newSli2 pointer is %p, len is %v, cap is %v, value is %v\n", &newSli2, len(newSli2), cap(newSli2), newSli2)
	fmt.Printf("source arr2 become %v\n", arr2)

	arr3 := [...]int{1, 2, 3, 4}
	sli3 := arr3[0:2:2] // 长度为2,容量为2

	fmt.Printf("sli3 pointer is %p, len is %v, cap is %v, value is %v\n", &sli3, len(sli3), cap(sli3), sli3)

	newSli3 := append(sli3, 5)
	fmt.Printf("newSli3 pointer is %p, len is %v, cap is %v, value is %v\n", &newSli3, len(newSli3), cap(newSli3), newSli3)
	fmt.Printf("source arr3 become %v\n", arr3)

}

输出

sli1 pointer is 0xc0000ae018, len is 2, cap is 4, value is [1 2]
sli2 pointer is 0xc0000ae030, len is 2, cap is 2, value is [3 4]
newSli1 pointer is 0xc0000ae078, len is 3, cap is 4, value is [1 2 5]
source arr1 become [1 2 5 4]
newSli2 pointer is 0xc0000ae0a8, len is 3, cap is 4, value is [3 4 5]
source arr2 become [1 2 3 4]
sli3 pointer is 0xc0000ae0d8, len is 2, cap is 2, value is [1 2]
newSli3 pointer is 0xc0000ae108, len is 3, cap is 4, value is [1 2 5]
source arr3 become [1 2 3 4]

通过上面的例子,我们可以发现,容留昂足够的sli1直接将append添加的新元素覆盖到原有数组arr1中,而容量不够的sli2进行了扩容操作,申请了新的底层数组,不在原数组的基础上进行操作。在实际使用的过程中要记住两种区别

如果原有数组可以添加新的元素,既切片指向的数组后还有空间,但切片自身的容量已经饱和,此时进行append操作,同样会进行扩容,申请新的内存空间。

我们指定了创建切片的第三个参数cap,cap必须大于end,生成切片的容量将为cap-begin,限定了sli3的容量为2,当进行append操作时,即使原有数组存在足够的空间,newLi3还是指向新的数组空间。

.

推荐阅读