深入了解Golang中的Slice底层实现

 更新时间:2023年02月26日 16:39:39   作者:nil  
本文主要为大家详细介绍了Golang中slice的底层实现,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

1 Go数组

Go数组是值类型,数组定义的时候就需要指定大小,不同大小的数组是不同的类型,数组大小固定之后不可改变。数组的赋值和传参都会复制一份。

func main() {
    arrayA := [2]int{100, 200}
    var arrayB [2]int

    arrayB = arrayA

    fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA)
    fmt.Printf("arrayB : %p , %v\n", &arrayB, arrayB)

    testArray(arrayA)
}

func testArray(x [2]int) {
    fmt.Printf("func Array : %p , %v\n", &x, x)
}

结果:

arrayA : 0xc4200bebf0 , [100 200]
arrayB : 0xc4200bec00 , [100 200]
func Array : 0xc4200bec30 , [100 200]

可以看到,三个内存地址都不同,这也就验证了 Go 中数组赋值和函数传参都是值复制的。尤其是传参的时候把数组复制一遍,当数组非常大的时候会非常消耗内存。可以考虑使用指针传递。

指针传递有个不好的地方,当函数内部改变了数组的内容,则原数组的内容也改变了。

因此一般参数传递的时候使用slice

2 切片的数据结构

切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装。

切片(slice)是对数组一个连续片段的引用,所以切片是一个引用类型。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个与指向数组的动态窗口。

给定项的切片索引可能比相关数组的相同元素的索引小。和数组不同的是,切片的长度可以在运行时修改,最小为 0 最大为相关数组的长度:切片是一个长度可变的数组。

切片数据结构定义

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

切片的结构体由3部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的。

如果想从 slice 中得到一块内存地址,可以这样做:

s := make([]byte, 200)
ptr := unsafe.Pointer(&s[0])

3 创建切片

3.1 方法一:make

使用make函数创建slice

// 创建一个初始大小是3,容量是10的切片
s1 := make([]int64,3,10)

底层方法实现:

func makeslice(et *_type, len, cap int) slice {
    // 根据切片的数据类型,获取切片的最大容量
    maxElements := maxSliceCap(et.size)
    // 比较切片的长度,长度值域应该在[0,maxElements]之间
    if len < 0 || uintptr(len) > maxElements {
        panic(errorString("makeslice: len out of range"))
    }
    // 比较切片的容量,容量值域应该在[len,maxElements]之间
    if cap < len || uintptr(cap) > maxElements {
        panic(errorString("makeslice: cap out of range"))
    }
    // 根据切片的容量申请内存
    p := mallocgc(et.size*uintptr(cap), et, true)
    // 返回申请好内存的切片的首地址
    return slice{p, len, cap}
}

3.2 方法二:字面量

利用数组创建切片

arr := [10]int64{1,2,3,4,5,6,7,8,9,10}
s1 := arr[2:4:6] // 以arr[2:4]创建一个切片,且容量到达arr[6]的位置,即cap=6-2=4,如果不写容量则默认为数组最后一个元素

4 nil和空切片

nil切片的指针指向的是nil

空切片指向的是一个空数组

空切片和 nil 切片的区别在于,空切片指向的地址不是nil,指向的是一个内存地址,但是它没有分配任何内存空间,即底层元素包含0个元素。

最后需要说明的一点是。不管是使用 nil 切片还是空切片,对其调用内置函数 append,len 和 cap 的效果都是一样的

5 切片扩容

5.1 扩容策略

func main() {
    slice := []int{10, 20, 30, 40}
    newSlice := append(slice, 50)
    fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
    fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
    newSlice[1] += 10
    fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
    fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
}

输出结果:

Before slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4
Before newSlice = [10 20 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8
After slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4
After newSlice = [10 30 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8

Go 中切片扩容的策略是这样的:

如果切片的容量小于 1024 个元素,于是扩容的时候就翻倍增加容量。上面那个例子也验证了这一情况,总容量从原来的4个翻倍到现在的8个。

一旦元素个数超过 1024 个元素,那么增长因子就变成 1.25 ,即每次增加原来容量的四分之一。

5.2 底层数组是不是新地址

不一定。当发生了扩容就肯定是新数组,没有发生扩容则是旧地址

不管切片是通过make创建还是字面量创建,底层都是一样的,指向的是一个数组。当使用字面量创建时,切片底层使用的数组就是创建时候的数组。修改切片中的元素或者往切片中添加元素,如果没有扩容,则会影响原数组的内容,切片底层和原数组是同一个数组;当切片扩容了之后,则修改切片的元素或者往切片中添加元素,不会修改数组内容,因为切片扩容之后,底层数组不再是原数组,而是一个新数组。

所以尽量避免切片底层数组与原始数组相同,尽量使用make创建切片

range遍历数组或者切片需要注意

func main() {
	// slice := []int{10, 20, 30, 40}
	slice := [4]int{10, 20, 30, 40}
	for index, value := range slice {
		fmt.Printf("value = %d , value-addr = %x , slice-addr = %x\n", value, &value, &slice[index])
	}
}

结果:

value = 10 , value-addr = c00000a0a8 , slice-addr = c000012360
value = 20 , value-addr = c00000a0a8 , slice-addr = c000012368
value = 30 , value-addr = c00000a0a8 , slice-addr = c000012370
value = 40 , value-addr = c00000a0a8 , slice-addr = c000012378

从上面结果我们可以看到,如果用 range 的方式去遍历一个数组或者切片,拿到的 Value 其实是切片里面的值拷贝。所以每次打印 Value 的地址都不变。

由于 Value 是值拷贝的,并非引用传递,所以直接改 Value 是达不到更改原切片值的目的的,需要通过 &slice[index] 获取真实的地址

尤其是在for循环中使用协程,一定不能直接把index,value传入协程,而应该通过参数传进去

错误示例:

func main() {
	s := []int{10,20,30}
	for index, value := range s {
		go func() {
			time.Sleep(time.Second)
			fmt.Println(fmt.Sprintf("index:%d,value:%d", index,value))
		}()
	}
	time.Sleep(time.Second*2)
}

结果:

index:2,value:30
index:2,value:30
index:2,value:30

正确示例:

func main() {
	s := []int{10,20,30}
	for index, value := range s {
		go func(i,v int) {
			time.Sleep(time.Second)
			fmt.Println(fmt.Sprintf("index:%d,value:%d", i,v))
		}(index,value)
	}
	time.Sleep(time.Second*2)
}

结果:

index:0,value:10
index:2,value:30
index:1,value:20

到此这篇关于深入了解Golang中的Slice底层实现的文章就介绍到这了,更多相关Golang Slice内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Go语言中零拷贝的原理与实现详解

    Go语言中零拷贝的原理与实现详解

    零拷贝是相对于用户态来讲的,即数据在用户态不发生任何拷贝,那么零拷贝的原理是什么,又是如何实现的呢,下面小编就来和大家详细聊聊吧
    2023-08-08
  • Golang开发gRPC服务入门介绍

    Golang开发gRPC服务入门介绍

    这篇文章主要介绍了Golang开发gRPC服务,Golang开发gRPC应用程序的套路也已经很清晰,这篇文章就来做一个简单的介绍,算是入门,需要的朋友可以参考下
    2022-04-04
  • 在go中进行单元测试的案例分享

    在go中进行单元测试的案例分享

    这篇文章主要介绍了使用Go进行单元测试的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2024-01-01
  • Go语言实现动态开点线段树详解

    Go语言实现动态开点线段树详解

    线段树是一种用于高效处理区间查询和区间更新的数据结构,下面我们就来看看如何使用Go实现动态开点线段树的方式,感兴趣的可以了解下
    2025-02-02
  • GO 函数式选项模式(Functional Options Pattern)

    GO 函数式选项模式(Functional Options Pattern)

    Option模式支持传递多个参数,并且在参数个数、类型发生变化时保持兼容性,任意顺序传递参数,下面给大家介绍GO 函数式选项模式(Functional Options Pattern)的相关知识,感兴趣的朋友一起看看吧
    2021-10-10
  • 浅析Go语言的数据类型及数组

    浅析Go语言的数据类型及数组

    Golang是一种静态强类型、编译型语言。Go 语言语法与 C 相近,但功能上有:内存安全,GC(垃圾回收),结构形态及 CSP-style 并发计算。本文主要和大家聊聊Go语言的数据类型及数组,希望对大家有所帮助
    2022-11-11
  • 详解Go语言中的Slice链式操作

    详解Go语言中的Slice链式操作

    这篇文章主要为大家详细介绍了Go语言中的Slice链式操作的相关知识,文中的示例代码讲解详细,具有一定的学习价值,感兴趣的小伙伴可以了解一下
    2023-04-04
  • Go语言类型转换的方式有哪些

    Go语言类型转换的方式有哪些

    本文主要介绍了Go语言类型转换的方式有哪些,类型转换主要有4种,分别为断言类型转换、显式类型转换、隐式类型转换、强制类型转换,感兴趣的可以了解一下
    2023-11-11
  • 通过源码分析Golang cron的实现原理

    通过源码分析Golang cron的实现原理

    golang实现定时任务很简单,只须要简单几步代码即可以完成,最近在做了几个定时任务,想研究一下它内部是怎么实现的,所以将源码过了一遍,记录和分享在此。需要的朋友可以参考以下内容,希望对大家有帮助
    2022-10-10
  • golang fmt占位符的使用详解

    golang fmt占位符的使用详解

    这篇文章主要介绍了golang fmt占位符的使用详解,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12

最新评论