自学内容网 自学内容网

Go八股(Ⅳ)***slice,string,defer***

***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 main

import "fmt"

func PrintArry(arr [5]int) {
arr[0] = 5
fmt.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能包含的最大元素数量,但实际上当容量不足时,会自动扩充为原来的两倍。通过内置函数lencap可以获取slice的长度和容量

初始化

Slice在初始化时需要初始化指针,长度和容量,容量未指定时将自动初始化为长度的大小。可以通过获取数组的引用,获取数组/Slice的切片构建或是make函数初始化数组。

例如

s:=[]int{1,2,3}//通过数组的引用初始化,值为{1,2,3},长度和容量为3

arr:=[5]int{1,2,3,4,5}
s:=arr[0:3] //通过数组的切片初始化,值为{1,2,3},长度和容量为5

s:=make([]int,4)//通过make初始化,值为{0,0,0,0},长度和容量wei4

s:=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] = -1
fmt.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) > maxAlloc
        newcap = int(capmem)
      // 当类型大小是8个字节时  
      case et.size == sys.PtrSize:
        lenmem = uintptr(old.len) * sys.PtrSize
        newlenmem = uintptr(cap) * sys.PtrSize
        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
      // 当类型大小是2的幂次方时  
      case isPowerOfTwo(et.size):
        var shift uintptr
        if 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) << shift
        newlenmem = uintptr(cap) << shift
        capmem = roundupsize(uintptr(newcap) << shift)
        overflow = uintptr(newcap) > (maxAlloc >> shift)
        newcap = int(capmem >> shift)
      // 当大小不是上面任何一种时  
      default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem, 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 {//根据字符串地址构建string
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)} //先构造stringStruct
s := *(*string)(unsafe.Pointer(&ss))//再将stringStruct转换成string
return s
}

3.2.1  []byte转string

[]byte切片转换成string很简单(语法上):

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

下面是转化时的内存图:

转换过程如下几步:

  1. 根据切片的长度申请内存空间,假设内存地址为p,切片长度为len(s)
  2. 构建string(sting.str =p; string.len=len)
  3. 拷贝数据(切片中数据拷贝到新申请的内存空间)

 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 := 0
l := 0 //拼接后的字符串总长度
count := 0
for i, x := range a {
n := len(x)
if n == 0 {
continue
}
if l+n < l {
throw("string concatenation too long")
}
l += n
count++
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 = p
stringStructOf(&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.Builder
for 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 := 0
defer 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
}

运行结果如下;

这种情况其实就相当于一直在操作一个内存地址中的数。


原文地址:https://blog.csdn.net/xiawubushangban/article/details/143500071

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!