***slice,string,defer***
1.slice和arry的区别
arry:
Go语言中arry即为数据的一种集合,需要在声明时指定容量和初值,且一旦声明就长度固定,访问时按照索引访问。通过内置函数len可以获取数组中的元素个数。
初始化
数组在初始化时必须指定大小和初值,不过Go语言为我们提供了其他灵活的方式。
例如:
func main() {var arr [5]int //声明了一个大小为5的数组,初始化值为{0,0,0,0,0}arr := [5]int{1}//声明并初始化一个大小为5的数组,初始值为{1,0,0,0,0}arr := [...]int{1, 2, 3}//通过“...”自动获取数组长度,初始化后值为{1,2,3}arr := [...]int{4:1}//指定序列号为4的元素数值为1,初始值为{0,0,0,0.1}
}
数组作参数传入
Go语言数组作为参数传入时,必须指定参数数组的大小,且传入的大小必须与指定的大小一致,数组为按值传递的,函数内对数组的值的改变不影响初始数组
例如:
package mainimport "fmt"func PrintArry(arr [5]int) {arr[0] = 5fmt.Println(arr)
}func main() {var arr [5]int = [5]int{1, 2, 3, 4, 5}PrintArry(arr)fmt.Println(arr)
}
运行结果确实这样的:
Slice
切片是Go语言中极为重要的一种数据类型,可以理解为动态长度的数组(虽然实际上Slice结构内包含了一个数组),访问时可以按照数组的方式访问,也可以通过切片
操作访问。Slice有三个属性:指针、长度和容量。指针即Slice名,指向的为数组中第一个可以由Slice访问的元素;长度指当前slice中的元素个数,不能超过slice的容量;容量为slice能包含的最大元素数量,但实际上当容量不足时,会自动扩充为原来的两倍。通过内置函数len
和cap
可以获取slice的长度和容量
初始化
Slice在初始化时需要初始化指针,长度和容量,容量未指定时将自动初始化为长度的大小。可以通过获取数组的引用,获取数组/Slice的切片构建或是make函数初始化数组。
例如
s:=[]int{1,2,3}//通过数组的引用初始化,值为{1,2,3},长度和容量为3arr:=[5]int{1,2,3,4,5}
s:=arr[0:3] //通过数组的切片初始化,值为{1,2,3},长度和容量为5s:=make([]int,4)//通过make初始化,值为{0,0,0,0},长度和容量wei4s:=make([]int,3,5)//通过make初始化值为{0,0,0},长度为3,容量为5
气质特别要注意的时通过切片方式初始化。若时通过对Slice的切片进行初始化,实际上初始化之后的结构如图所示:
此时x的值为[2,3,5,7,11],y的值为[3,5,7],且两个slice的指针指向的是同一个数组,也即x中的元素的值的改变将会导致y中的值也一起改变
这样的初始化方式可能会导致内存被过度占用,如只需要使用一个极大的数组中的几个元素,但是由于需要指向整个数组,所以整个数组在GC时都无法被释放,一直占用内存空间。故使用切片操作进行初始化时,最好使用
append
函数将切片出来的数据复制到一个新的slice中,从而避免内存占用陷阱。
Slice作为函数参数
Go语言中Slice作为函数参数传递时为按引用传递的,函数内对Slice内元素的修改将导致函数外的值也发生改变,不过由于传入函数的时一个指针的副本,所以对该指针的修改不会导致原来的指针的变化(例如append不会改变原来slice的值)。
例如
func PrintSlice(s []int) {s = append(s, 4)s[0] = -1fmt.Println(s)
}func main() {s := []int{1, 2, 3, 4, 5}s1 := s[0:3]fmt.Println("s:", s)fmt.Println("s1:", s1)PrintSlice(s1)fmt.Println("s:", s)fmt.Println("s1:", s1)
}
总的来说
- 数组长度不能改变,初始化后长度就是固定的;切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
- 结构不同,数组是一串固定数据,切片描述的是截取数组的一部分数据,从概念上说是一个结构体。
- 初始化方式不同,如上。另外在声明时的时候:声明数组时,方括号内写明了数组的长度或使用
...
自动计算长度,而声明slice
时,方括号内没有任何字符。 - unsafe.sizeof的取值不同,unsafe.sizeof(slice)返回的大小是切片的描述符,不管slice里的元素有多少,返回的数据都是24字节。unsafe.sizeof(arr)的值是在随着arr的元素的个数的增加而增加,是数组所存储的数据内存的大小。
- 函数调用时的传递方式不同,数组按值传递,slice按引用传递。
Slice的扩容机制
1.18版本之前
Go1.18之前切片的扩容是以容量1024为临界点,当旧容量 < 1024个元素,扩容变成2倍;当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。
1.18版本之后
当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
有什么好处呢,首先是双倍容量扩容的最大阈值从1024降为了256,只要超过了256,就开始进行缓慢的增长。其次是增长比例的调整,之前超过了阈值之后,基本为恒定的1.25倍增长,而现在超过了阈值之后,增长比例是会动态调整的,随着切片容量的变大,增长比例逐渐向着1.25进行靠近
内存对齐
以下是内存对齐得源码;
switch {// 当数组元素的类型大小为1时,不需要乘除计算就能够得到所需要的值 case et.size == 1:lenmem = uintptr(old.len)newlenmem = uintptr(cap)//前面两个语句只是对老长度和预期cap的类型转换,关键是下一个语句决定了newcap的长度// 内存对齐capmem = roundupsize(uintptr(newcap))// 判断是否溢出overflow = uintptr(newcap) > maxAllocnewcap = int(capmem)// 当类型大小是8个字节时 case et.size == sys.PtrSize:lenmem = uintptr(old.len) * sys.PtrSizenewlenmem = uintptr(cap) * sys.PtrSizecapmem = roundupsize(uintptr(newcap) * sys.PtrSize)overflow = uintptr(newcap) > maxAlloc/sys.PtrSizenewcap = int(capmem / sys.PtrSize)// 当类型大小是2的幂次方时 case isPowerOfTwo(et.size):var shift uintptrif sys.PtrSize == 8 {// Mask shift for better code generation.shift = uintptr(sys.Ctz64(uint64(et.size))) & 63} else {shift = uintptr(sys.Ctz32(uint32(et.size))) & 31}lenmem = uintptr(old.len) << shiftnewlenmem = uintptr(cap) << shiftcapmem = roundupsize(uintptr(newcap) << shift)overflow = uintptr(newcap) > (maxAlloc >> shift)newcap = int(capmem >> shift)// 当大小不是上面任何一种时 default:lenmem = uintptr(old.len) * et.sizenewlenmem = uintptr(cap) * et.sizecapmem, overflow = math.MulUintptr(et.size, uintptr(newcap))capmem = roundupsize(capmem)newcap = int(capmem / et.size)}
之所以进行内存对齐,是因为更加合理得分配内存,如果分配得太多就会出现内存得浪费,如果分配得太少就会出现性能过低情况。
_MaxSmallSize: 其值为32768,即32kb大小。在Go中,当对象大小超过32kb时,内存分配策略和小于等于32kB时是有区别的。(对于内存大于32KB的称为大对象,会单独处理,对于内存小于等于32KB的对象,会在跨度类数组中找到合适的数组大小,其实这一步也就进行了内存对齐操作,找到了最小的对齐内存,所以往往newcap大小会比之前的稍有不同,一般都是向上取了一些值)
smallSizeMax: 其值为1024字节。
smallSizeDiv: 其值为8字节。
largeSizeDiv: 其值为128字节。
_PageSize: 8192字节,即8kb大小。Go按页来管理内存,而每一页的大小就为8kb。
class_to_size:Go中的内存分配会按照不同跨度(也可理解为内存大小,有点类似于段),其中跨度是指,go每一页的大小是8kb,对datablock划分成不同大小的内存块,
除了最小的8b,其余的大小都是8*2n,即8,16,32,48,…32768,具体规则间隔为8,16,32,64,128…,对应class_to_size的数组(1.18之后好像多了一个24元素)
将内存分割成不同内存块链表。当需要分配内存时,按照对象大小去匹配最合适的跨度找到空闲的内存块儿。Go中总共分为67个跨度,class_to_size是一个长度为68的数组,分别记录0和这67个跨度的值。
size_to_class8: 这是一个长度为129的数组,代表的内存大小区间为0~1024字节。以索引i为例,此位置的对象大小m为i
smallSizeDiv,size_to_class8[i]的值为class_to_size数组中跨度最接近m的下标。
size_to_class128:这是一个长度为249的数组,代表的内存大小区间为1024~32768字节。以索引i为例,此位置的对象大小m为smallSizeMax
i*largeSizeDiv, size_to_class128[i]的值为class_to_size数组中跨度最接近m的下标。
divRoundUp: 此函数返回a/b向上舍入最接近的整数。
alignUp: alignUp(size, _PageSize) = _PageSize * divRoundUp(size,
_PageSize)。
上面得一大块内容,简而言之就是Go语言未来更好得分配内存,将每次扩容得量划分为67个区间
例如:
s3 := []int{1, 2}
s3 = append(s3, 3, 4, 5)
fmt.Println(cap(s3))
根据前文知,所需容量为5,又因所需容量大于2倍当前容量,故新容量也为5。
又因为int类型大小为8(等于64位平台上的指针大小),所以实际需要的内存大小为5 * 8 = 40字节。而67个跨度中最接近40字节的跨度为48字节,所以实际分配的内存容量为48字节。
最终计算真实的容量为48 / 8 = 6,和实际运行输出一致。
零切片,空切片,nil切片的区别
零切片
简单来说就是切片中的值都为0,切片已经分配空间,并且值也不为空
// 创建零切片
slice4 := make([]int,2,5)
fmt.Println(slice4,*(*reflect.SliceHeader)(unsafe.Pointer(&slice4))) // 输出:[0 0] {824634474496 2 5}
空切片
空切片就是已经初始化过空间的切片,但是切片中并没有内容
通常用make或者字面量进行初始化
s1 := []int{} // s1 是一个空切片,通过字面量创建
s2 := make([]int, 0) // s2 也是一个空切片,通过 make 创建
nil切片
通常使用var 来定义,既没有分配空间,更不用说切片的长度
var slice []int
fmt.Println(slice,*(*reflect.SliceHeader)(unsafe.Pointer(&slice))) // 输出:[] {0 0 0}
string类型
string标准概念
在go的标准包中定义如下:
// 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是8bit字节的集合,通常是但并不一定非得是UTF-8编码的文本。
- string可以为空(长度为0),但不会是nil。
- string对象不可以修改。
type stringStruct struct {str unsafe.Pointer //字符串首地址,指向底层字节数组的指针len int //字符串长度
}
对于字符串Hello,实际底层结构如下:
3.string类型的操作
3.1 声明
var str string
str = "Hello"
具体的字符串构建过程,是先根据字符串构建stringStruct,再转换成string:
func gostringnocopy(str *byte) string {//根据字符串地址构建stringss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)} //先构造stringStructs := *(*string)(unsafe.Pointer(&ss))//再将stringStruct转换成stringreturn s
}
3.2.1 []byte转string
[]byte切片转换成string很简单(语法上):
func GetStringBySlice(s []byte) string {return string(s)
}
下面是转化时的内存图:
转换过程如下几步:
- 根据切片的长度申请内存空间,假设内存地址为p,切片长度为len(s)
- 构建string(sting.str =p; string.len=len)
- 拷贝数据(切片中数据拷贝到新申请的内存空间)
3.2.2 string类型转[]byte
下面是转化的代码,语法上很简单
func GetSliceByString(str string) []byte {return []byte(str)
}
同样string类型转化成[]byte类型也需要一次内存的拷贝。
、
1.申请切片内存空间
2.将string拷贝到切片
3.3 字符串的拼接
在Go语言中,字符串是不可变得,拼接字符串事实上是创建了一个新的字符串,如果代码中存在大量的字符串拼接,对性能会产生影响。
下面是go语言中关于拼接字符串的源码:
func concatstrings(buf *tmpBuf, a []string) string {idx := 0l := 0 //拼接后的字符串总长度count := 0for i, x := range a {n := len(x)if n == 0 {continue}if l+n < l {throw("string concatenation too long")}l += ncount++idx = i}if count == 0 {return ""}// If there is just one string and either it is not on the stack// or our result does not escape the calling frame (buf != nil),// then we can return that string directly.if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {return a[idx]}s, b := rawstringtmp(buf, l)//生成指定大小的字符串,返回一个string和切片,二者共享内存for _, x := range a {copy(b, x)b = b[len(x):]//string无法修改,只能通过切片修改}return s
}// 生成一个新的string,返回的string和切片共享相同的空间
func rawstring(size int) (s string, b []byte) {p := mallocgc(uintptr(size), nil, false)stringStructOf(&s).str = pstringStructOf(&s).len = size*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}return
}
3.3.1常见的拼接方式
使用“+”
s1+s2+s3
使用fmt.Sprintf
fmt.Sprintf("%s%s",s1,s2)
使用strings.Builder
func BuilderConcat(n int, str string) string {var builder strings.Builderfor i := 0; i < n; i++ {builder.WriteString(str)}return builder.String()
}
使用bytes.Buffer
func bufferConcat(n int, s string) string {buf := new(bytes.Buffer)for i := 0; i < n; i++ {buf.WriteString(s)}return buf.String()
}
使用[]byte
func byteConcat(n int, str string) string {buf := make([]byte, 0)for i := 0; i < n; i++ {buf = append(buf, str...)}return string(buf)
}
3.4 字符串的截取
1.截取普通英语字符串
str := "HelloWorld"
content := str[1 : len(str)-1]
2.截取带中文的字符串
一个中文字符确定不止一个字节,需要先将其转为[]rune,再截取后,再转为string
strRune := []rune(str)
fmt.Println("string(strRune[:4]) = ",string(strRune[:4]))
4.为什么字符串不允许修改(只读属性)
在go实现中,string不包含内存空间,只有一个内存的地址,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。
string通常指向字符串字面量,而字符串字面量存储存储位置是只读段,而不是堆或栈上,所以string不可修改。
修改字符串时,可以将字符串转换为 []byte 进行修改。
var str string = "hello"
strBytes := []byte(str)
strBytes[0] = 'H'
str = string(strBytes)
fmt.Println(str)
defer
defer
一个函数中多个defer的执行顺序
defer 的作用就是把defer关键字之后的函数执行压入一个栈中延迟执行,多个defer
的执行顺序是后进先出LIFO,也就是先执行最后一个defer,最后执行第一个defer
func main() {defer fmt.Println(1)defer fmt.Println(2)defer fmt.Println(3)
}
return返回值的运行机制
1.返回值赋值
2.RET指令
而defer执行在赋值之后,RET之前。
defer,return,返回值三者执行的顺序是:return最先执行,先将结果写入返回值中;接着defer开始执行一些收尾工作;最后函数携带返回值退出
不带命名返回值
如果函数的返回值是无名的(不带命名的返回值),则go语言会在执行return的时候执行一个类似创建一个临时变量作为保存return值得动作。
func main() {fmt.Println("return i:", test())
}func test() int {i := 0defer func() {i++fmt.Println("defer1 ---i:", i)}()defer func() {i++fmt.Println("defer2 ---i:", i)}()return i
}
运行结果如下图所示:
如图所示,函数执行时先返回值然后再执行defer之后得函数。
上面得例子实际上进行了三步操作:
(1)赋值,因为返回值没有命名,所以return默认指定了一个返回值(假设为s),首先将i赋值为s,i初始值是0,所以s也是0
(2)后续的defer操作因为是针对i进行的,所以不会影响s,此后s不会更新,所以s还是0
(3)返回值,return s,也就是return 0
var i int
s:=i
return s
带命名的返回值
有名返回值的函数,由于返回值在函数定义的时候已经将该变量进行定义,在执行return的时候会先执行返回值保存操作,而后续的defer函数会改变这个给返回值(虽然defer实在return之后执行的),由于使用函数定义的变量,所以执行defer操作后会对该变量的修改会影响的return 的值
func main() {fmt.Println("return i:", test())
}func test() (i int) {defer func() {i++fmt.Println("defer1 ---i:", i)}()defer func() {i++fmt.Println("defer2 ---i:", i)}()return i
}
运行结果如下;
这种情况其实就相当于一直在操作一个内存地址中的数。