【GO基础学习】基础语法(2)切片slice

文章目录


回顾基础语法(1)

完整链接:【GO基础学习】环境安装到基础语法(1)

回顾&总结:

  1. 一个Go工程中主要包含以下三个目录:

src:源代码文件

pkg:包文件(用于生成.a文件)

bin:相关bin文件(用于生成可执行文件)

golang中的import name,实际是到GOPATH中去寻找name.a, 使用时是该name.a的源码中生命的package 名字

  1. 编译主程序go build goproject.go=>成功后会生成goproject.exe文件
  2. 系统编译时 go install abc_name时,系统会到GOPATH的src目录中寻找abc_name目录,然后编译其下的go文件。

报错:

java 复制代码
C:\Users\Administrator>go install GoStudy
go: 'go install' requires a version when current directory is not in a module
        Try 'go install GoStudy@latest' to install the latest version

go buildgo install 的区别

  • go build:编译 Go 项目中的代码并生成可执行文件,但并不会将该文件安装到 GOBIN 目录中。如果只是为了编译代码并在当前目录运行该程序,使用 go build。
  • go build 会根据当前目录下的文件生成一个可执行文件(Windows 上会生成 .exe 文件)。它不会改变你的工作空间,编译过程只在当前目录中完成,不会将编译后的程序安装到系统的 GOBIN 目录下。
  • go install:编译并安装 Go 项目中的代码。安装后,程序会被放入 GOBIN 目录(如果设置了该变量),并且你可以从任何地方运行它(如果 GOBIN 在环境变量 PATH 中)。go install 相当于先执行 go build,然后将可执行文件放入指定的 GOBIN 路径。
  • go build 会根据当前目录下的文件生成一个可执行文件(Windows 上会生成 .exe 文件)。它不会改变你的工作空间,编译过程只在当前目录中完成,不会将编译后的程序安装到系统的 GOBIN 目录下。
  • go install:编译并安装 Go 项目中的代码。安装后,程序会被放入 GOBIN 目录(如果设置了该变量),并且你可以从任何地方运行它(如果 GOBIN 在环境变量 PATH 中)。go install 相当于先执行 go build,然后将可执行文件放入指定的 GOBIN 路径。

【解决:进入项目目录,然后执行go build后会在当前目录生成.exe文件】

在 Go 1.11 及之后版本,Go 引入了 Go Modules(通过 go.modgo mod init GoStudy 】文件管理),而在 Go Modules 模式下,go build 默认会在当前目录生成可执行文件,而不是 GOPATH/bin 目录。这个行为与传统的 GOPATH 工作方式有所不同。

  1. 变量申明:var 变量名 变量类型例如:var name string;var age int;var isOk bool ,不指定的情况下,整型和浮点型变量的默认值为0。 字符串变量的默认值为空字符串。 布尔型变量默认为false。 切片、函数、指针变量的默认为nil。自行初始化格式:var 变量名 类型 = 表达式

在函数内部,可以使用更简略的 := 方式声明并初始化变量:

go 复制代码
package main

import (
    "fmt"
)
// 全局变量m
var m = 100

func main() {
    n := 10
    m := 200 // 此处声明局部变量m
    fmt.Println(m, n)
}
  1. 常量定义: const pi = 3.1415,常量在定义的时候必须赋值。

  2. 字符串操作:

方法 介绍
len(str) 求长度
+或fmt.Sprintf 拼接字符串
strings.Split 分割
strings.Contains 判断是否包含
strings.HasPrefix,strings.HasSuffix 前缀/后缀判断
strings.Index(),strings.LastIndex() 子串出现的位置
strings.Join(a[]string, sep string) join操作

uint8类型,或者叫 byte 型,代表了ASCII码的一个字符。
rune类型,代表一个 UTF-8字符。

  1. 数组的操作:
    (1)数组定义:var a [len]int,比如:var a [5]int,数组长度必须是常量,且是类型的组成部分。一旦定义,长度不能变。
    (2)数组可以通过下标进行访问,下标是从0开始,最后一个元素下标是:len-1
go 复制代码
    for i := 0; i < len(a); i++ {
    }
    for index, v := range a {
    }

(3)数组是值类型 ,赋值和传参会复制整个数组,而不是指针。因此改变副本的值,不会改变本身的值。【与JAVA不同的点,JAVA的数组是引用类型】

(4)具体使用:

go 复制代码
    全局:
    var arr0 [5]int = [5]int{1, 2, 3}
    var arr1 = [5]int{1, 2, 3, 4, 5}
    var arr2 = [...]int{1, 2, 3, 4, 5, 6}
    var str = [5]string{3: "hello world", 4: "tom"}
    局部:
    a := [3]int{1, 2}           // 未初始化元素值为 0。
    b := [...]int{1, 2, 3, 4}   // 通过初始化值确定数组长度。
    c := [5]int{2: 100, 4: 200} // 使用索引号初始化元素。
    d := [...]struct {
        name string
        age  uint8
    }{
        {"user1", 10}, // 可省略元素类型。
        {"user2", 20}, // 别忘了最后一行的逗号。
    }
    全局:
    var arr0 [5][3]int
    var arr1 [2][3]int = [...][3]int{{1, 2, 3}, {7, 8, 9}}
    局部:
    a := [2][3]int{{1, 2, 3}, {4, 5, 6}}
    b := [...][2]int{{1, 1}, {2, 2}, {3, 3}} // 第 2 纬度不能用 "..."。
go 复制代码
package main

import "fmt"

func main() {
	var arr0 [5][3]int
	arr1 := [5][3]int{}
	arr2 := [3][3]int{{1, 2, 3}, {2, 3, 4}, {4, 5, 6}}
	fmt.Println(arr0, arr1, arr2)
}

输出

bash 复制代码
[[0 0 0] [0 0 0] [0 0 0] [0 0 0] [0 0 0]] [[0 0 0] [0 0 0] [0 0 0] [0 0 0] [0 0 0]] [[1 2 3] [2 3 4] [4 5 6]]

(5)内置函数 lencap 都返回数组长度 (元素数量)

java 复制代码
package main

import "fmt"

func main() {
	arr1 := [5][3]int{{2, 3, 4}}
	fmt.Println(arr1)
	arr2 := [...]int{1, 2, 3}
	fmt.Println(arr2)

	fmt.Println(len(arr1), cap(arr1))
	fmt.Println(len(arr2), cap(arr2))
}
bash 复制代码
[[2 3 4] [0 0 0] [0 0 0] [0 0 0] [0 0 0]]
[1 2 3]
5 5
3 3

(7)指针数组

java 复制代码
package main

import "fmt"

func printArr(arr *[5]int) {
	arr[0] = 10
	for i, v := range arr {
		fmt.Println(i, v)
	}
}

func main() {
	var arr1 [5]int
	printArr(&arr1)
	fmt.Println(arr1)
	arr2 := [...]int{2, 4, 6, 8, 10}
	printArr(&arr2)
	fmt.Println(arr2)
}

传入的是&arr1引用,所以printArr方法不是拷贝,会改变原数组的值。

  1. 切片Slice的使用:切片是数组的一个引用,因此切片是引用类型。但自身是结构体,值拷贝传递。

(1)动态长度 :切片是基于数组的,但它的长度可以动态变化。切片底层的数据实际存储在一个数组中,但切片自己并不拥有数据。

(2)引用类型 :切片是一个引用类型,指向一个底层数组的某段区域 。当切片被赋值或传递时,引用的是相同的底层数组

(3)内存结构 :切片在 Go 中是一个结构体,包含指向底层数组的指针(ptr)切片的长度(len)容量(cap)

(4)切片的定义主要两种方式:可以不指定长度,通过 make 创建,也可以直接通过字面量方式创建。== nil判断是否为空

go 复制代码
slice := []int{1, 2, 3}  // 长度为3,容量为3
slice := make([]int, 3, 5)  // 创建长度为3,容量为5的切片

切片的长度和容量可以动态变化,长度表示切片实际存储的元素数量,容量表示切片在底层数组中可以容纳的最大元素数。

go 复制代码
package main

import "fmt"

func printArr(arr []int) {
	arr[0] = 10
	for i, v := range arr {
		fmt.Println(i, v)
	}
}

func main() {
	arr1 := []int{1, 2, 3}
	printArr(arr1)
	fmt.Println(arr1)
}

arr1是切片的定义,传入方法的是引用,会改变原切片的值。

(5)切片其实是一个结构体,包含三个字段:

  • 指针(Pointer):指向底层数组某个位置的指针。
  • 长度(Length):切片实际存储的元素个数。
  • 容量(Capacity):从切片的起始位置到底层数组结尾的元素个数。
    (6)切片的扩容
    当切片需要添加元素而容量不足时,Go 会创建一个新的、容量更大的底层数组,并将原数组的内容复制过去。这种情况下,切片指向的底层数组就变成了新数组,避免了频繁的数组拷贝操作。
    (7)相关操作:

(8)make创建slice:

go 复制代码
package main

import "fmt"

func main() {
	slice := make([]int, 3, 5)
	slice[0] = 1
	fmt.Println(slice)
	slice[3] = 3
	fmt.Println(slice)
}

报错:

go 复制代码
[1 0 0]
panic: runtime error: index out of range [3] with length 3

goroutine 1 [running]:
main.main()
	E:/goproject/src/GoStudy/main.go:9 +0x85

Process finished with the exit code 2

当前切片的长度为 3,索引 3 超出了切片的当前长度(len(slice)),因此会引发运行时错误 "index out of range"。想要添加元素到切片中,可以使用 append 函数,而不是直接赋值。

go 复制代码
package main

import "fmt"

func main() {
	slice := make([]int, 3, 5)
	slice[0] = 1
	fmt.Println(slice)

	// 使用 append 添加元素,而不是直接赋值
	slice = append(slice, 3)
	fmt.Println(slice)
}

但是,append 方法默认只能在切片的末尾添加元素,它会将新元素追加到切片的当前长度(len(slice))的位置。这是 append 的特性,也是为什么切片通常是动态、顺序增长的数据结构。

关于append方法更多操作:

go 复制代码
 // 追加元素到切片末尾
 slice = append(slice, 4) // 追加单个元素
 slice = append(slice, 5, 6, 7) // 追加多个元素
 // 追加一个切片到另一个切片
 slice1 = append(slice1, slice2...) // 将 slice2 追加到 slice1

(9)在任意位置插入元素

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4}
	insertIndex := 2
	element := 99

	// 在索引 2 位置插入 99
	slice = append(slice[:insertIndex], append([]int{element}, slice[insertIndex:]...)...)
	fmt.Println(slice) // 输出:[1 2 99 3 4]
}

(10)删除指定位置的元素

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4, 5}
	deleteIndex := 2

	slice = append(slice[:deleteIndex], slice[deleteIndex+1:]...)
	fmt.Println(slice)
}

(11)删除切片的首尾元素

go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4, 5}
	slice = slice[1 : len(slice)-1]
	fmt.Println(slice)
}

(12)动态扩容

go 复制代码
package main

import "fmt"

func main() {
	slice := make([]int, 3, 5) // 长度为 3,容量为 5
	slice[0], slice[1], slice[2] = 1, 2, 3
	fmt.Println(slice, cap(slice)) // 输出:[1 2 3] 5

	// 添加元素,超出原容量时扩展
	slice = append(slice, 4, 5, 6)
	fmt.Println(slice, cap(slice)) // 输出:[1 2 3 4 5 6] 10(容量翻倍)
}
go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4, 5}
	slice = append(slice, 6)
	fmt.Println(slice, cap(slice)) // 输出:	[1 2 3 4 5 6] 10
}

(13)其他常见操作

  • 切片可以通过切片操作符 [:] 来截取部分内容:左闭右开
  • 切片为空的判断条件是 len(slice) == 0,而不是 slice == nil,因为长度为 0 的切片并不一定是 nil。
go 复制代码
package main

import "fmt"

func main() {
	var slice1 []int           // 未初始化,为 nil
	slice2 := make([]int, 0)   // 初始化,但长度为 0

	fmt.Println(slice1 == nil) // 输出:true
	fmt.Println(len(slice2))   // 输出:0
	fmt.Println(slice2 == nil) // 输出:false
}

Slice底层实现

切片是 Go 中的一种基本的数据结构,使用这种结构可以用来管理数据集合。切片的设计想法是由动态数组概念而来,为了开发者可以更加方便的使一个数据结构可以自动增加和减少。但是切片本身并不是动态数据或者数组指针。切片常见的操作有 resliceappendcopy。与此同时,切片还具有可索引,可迭代的优秀特性。

切片和数组:

在 Go 中,与 C 数组变量隐式作为指针使用不同,Go 数组是值类型,赋值和函数传参操作都会复制整个数组数据。

go 复制代码
package main

import (
	"fmt"
)

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

	arrayB = arrayA

	fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA) // arrayA : 0xc00000a0f0 , [100 200]
	fmt.Printf("arrayB : %p , %v\n", &arrayB, arrayB) // arrayB : 0xc00000a100 , [100 200]

	testArray(arrayA)
}

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

可以看到,三个内存地址都不同,这也就验证了 GO 中数组赋值和函数传参都是值复制的。那这会导致什么问题呢?

假想每次传参都用数组,那么每次数组都要被复制一遍。如果数组大小有 100万,在64位机器上就需要花费大约 800W 字节,即 8MB 内存。这样会消耗掉大量的内存。于是乎有人想到,函数传参用数组的指针

go 复制代码
package main

import (
	"fmt"
)

func main() {
	arrayA := []int{100, 200}
	testArrayPoint(&arrayA) // 1.传数组指针  func Array : 0xc0000081b0 , [100 200]
	arrayB := arrayA[:]
	testArrayPoint(&arrayB)                           // 2.传切片  func Array : 0xc0000081e0 , [100 300]
	fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA) // arrayA : 0xc0000081b0 , [100 400]
}

func testArrayPoint(x *[]int) {
	fmt.Printf("func Array : %p , %v\n", x, *x)
	(*x)[1] += 100
}

总结

  • 切片和指针:通过指针传递切片允许在函数中修改原始切片的内容。
  • 内存地址:arrayAarrayB 指向相同的底层数组,但它们的切片结构体(包含指针、长度和容量)在内存中是不同的。
  • 修改影响:修改 arrayB 的元素直接影响 arrayA,因为它们共享相同的底层数组。
  • 这种机制使得 Go 切片既灵活又高效,能够轻松共享和修改数据。

在 Go 语言中,传递数据到函数时,使用数组和切片的内存管理方式有显著的区别。通常情况下,使用切片作为参数会更加高效,因为切片是引用类型,仅包含指向底层数组的指针、长度和容量,而不需要复制整个数组。

数组与切片的内存消耗

  • 数组传递: 当将一个大数组(如 array 函数中的 [1024]int)传递给函数时,Go 会复制整个数组。这意味着如果数组很大,这种复制会消耗大量内存,并且可能导致性能下降。
  • 切片传递: 切片则不同。当我们传递切片时,实际上是传递一个结构体,其中包含指向底层数组的指针。这样,不论切片的大小如何,内存消耗都是固定的,因为只有指针、长度和容量被复制,不需要额外的内存。

存在反例
尽管切片在大多数情况下更高效,但仍有一些场景需要注意

  • 小数组和切片的比较: 在一些情况下,尤其是当数组很小且频繁创建时,复制小数组的开销可能与切片相近,甚至可能更低。因此,使用数组的简单性在这些情况下可能更具优势。
  • 内存分配: 使用 make 创建切片时,会分配内存,而使用数组则是在栈上分配内存。在极少数情况下,栈内存的使用可能更高效,尤其是在大数组被复制时。

小结

  • 切片的效率:切片的优势在于它们通过引用传递避免了大内存块的复制,通常比数组更高效。
  • 场景选择:在选择使用数组或切片时,需要考虑数据的大小、内存开销和性能需求。在大多数情况下,切片是更优选择,但在特定场景中,简单的数组可能会更高效。

测试

go 复制代码
package main

import "testing"

func array() [1024]int {
    var x [1024]int
    for i := 0; i < len(x); i++ {
        x[i] = i
    }
    return x
}

func slice() []int {
    x := make([]int, 1024)
    for i := 0; i < len(x); i++ {
        x[i] = i
    }
    return x
}

func BenchmarkArray(b *testing.B) {
    for i := 0; i < b.N; i++ {
        array()
    }
}

func BenchmarkSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
        slice()
    }
}

(1)Go 的测试工具要求测试文件名以 _test.go 结尾。如果你的文件名是 main.go,那么你需要将其重命名为 main_test.go 或创建一个新的测试文件 main_test.go。

(2)确保你的测试文件导入了 testing 包。

(3)运行命令 go test -bench . -benchmem -gcflags "-N -l",确保你在包含 *_test.go 文件的目录中。禁用内联和优化,来观察切片的堆上内存分配的情况。

  • 测试 Array 的时候,用的是12核,循环次数是411032,平均每次执行时间是2796 ns,每次执行堆上分配内存总量是0,分配次数也是0 。
  • 而切片的结果就"差"一点,同样也是用的是12核,循环次数是162054,平均每次执行时间是6436ns,但是每次执行一次,堆上分配内存总量是8192,分配次数也是1 。
  • 这样对比看来,并非所有时候都适合用切片代替数组,因为切片底层数组可能会在堆上分配内存,而且小数组在栈上拷贝的消耗也未必比make 消耗大

切片的数据结构

切片在 Go 中实际上是一个结构体,通常包含以下三个字段:

  • 指针(ptr):指向底层数组的首地址。
  • 长度(len):切片当前包含的元素数量。
  • 容量(cap):底层数组的总元素数量,从切片的起始位置到数组末尾的元素数量。
go 复制代码
type Slice struct {
    ptr *int // 指向底层数组的指针
    len int  // 当前长度
    cap int  // 容量
}

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

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

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

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

go 复制代码
s := make([]byte, 200)
ptr := unsafe.Pointer(&s[0])

如果反过来呢?从 Go 的内存地址中构造一个 slice。

c 复制代码
var ptr unsafe.Pointer
var s1 = struct {
    addr uintptr
    len int
    cap int
}{ptr, length, length}
s := *(*[]byte)(unsafe.Pointer(&s1))

更加直接的方法,在 Go 的反射中就存在一个与之对应的数据结构 SliceHeader,我们可以用它来构造一个 slice:

go 复制代码
    var o []byte
    sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&o)))
    sliceHeader.Cap = length
    sliceHeader.Len = length
    sliceHeader.Data = uintptr(ptr)

创建切片

make 函数允许在运行期动态指定数组长度,绕开了数组类型必须使用编译期常量的限制。

创建切片有两种形式,make 创建切片,空切片。

go 复制代码
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}
}
c 复制代码
func makeslice64(et *_type, len64, cap64 int64) slice {
    len := int(len64)
    if int64(len) != len64 {
        panic(errorString("makeslice: len out of range"))
    }

    cap := int(cap64)
    if int64(cap) != cap64 {
        panic(errorString("makeslice: cap out of range"))
    }

    return makeslice(et, len, cap)
}

除了 make 函数可以创建切片以外,字面量也可以创建切片。

这里是用字面量创建的一个 len = 6,cap = 6 的切片,这时候数组里面每个元素的值都初始化完成了。需要注意的是 [ ] 里面不要写数组的容量,因为如果写了个数以后就是数组了,而不是切片了。

上图就 Slice A 创建出了一个 len = 3,cap = 3 的切片。从原数组的第二位元素(0是第一位)开始切,一直切到第四位为止(不包括第五位)。同理,Slice B 创建出了一个 len = 2,cap = 4 的切片。

nil 和空切片

go 复制代码
var slice []int

nil 切片被用在很多标准库和内置函数中,描述一个不存在的切片的时候,就需要用到 nil 切片。比如函数在发生异常的时候,返回的切片就是 nil 切片。nil 切片的指针指向 nil。

空切片一般会用来表示一个空的集合。比如数据库查询,一条结果也没有查到,那么就可以返回一个空切片。

go 复制代码
    silce := make( []int , 0 )
    slice := []int{ }

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

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

切片扩容

c 复制代码
func growslice(et *_type, old slice, cap int) slice {
    if raceenabled {
        callerpc := getcallerpc(unsafe.Pointer(&et))
        racereadrangepc(old.array, uintptr(old.len*int(et.size)), callerpc, funcPC(growslice))
    }
    if msanenabled {
        msanread(old.array, uintptr(old.len*int(et.size)))
    }

    if et.size == 0 {
        // 如果新要扩容的容量比原来的容量还要小,这代表要缩容了,那么可以直接报panic了。
        if cap < old.cap {
            panic(errorString("growslice: cap out of range"))
        }

        // 如果当前切片的大小为0,还调用了扩容方法,那么就新生成一个新的容量的切片返回。
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

  // 这里就是扩容的策略
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 1024 {
            newcap = doublecap
        } else {
            for newcap < cap {
                newcap += newcap / 4
            }
        }
    }

    // 计算新的切片的容量,长度。
    var lenmem, newlenmem, capmem uintptr
    const ptrSize = unsafe.Sizeof((*byte)(nil))
    switch et.size {
    case 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        newcap = int(capmem)
    case ptrSize:
        lenmem = uintptr(old.len) * ptrSize
        newlenmem = uintptr(cap) * ptrSize
        capmem = roundupsize(uintptr(newcap) * ptrSize)
        newcap = int(capmem / ptrSize)
    default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem = roundupsize(uintptr(newcap) * et.size)
        newcap = int(capmem / et.size)
    }

    // 判断非法的值,保证容量是在增加,并且容量不超过最大容量
    if cap < old.cap || uintptr(newcap) > maxSliceCap(et.size) {
        panic(errorString("growslice: cap out of range"))
    }

    var p unsafe.Pointer
    if et.kind&kindNoPointers != 0 {
        // 在老的切片后面继续扩充容量
        p = mallocgc(capmem, nil, false)
        // 将 lenmem 这个多个 bytes 从 old.array地址 拷贝到 p 的地址处
        memmove(p, old.array, lenmem)
        // 先将 P 地址加上新的容量得到新切片容量的地址,然后将新切片容量地址后面的 capmem-newlenmem 个 bytes 这块内存初始化。为之后继续 append() 操作腾出空间。
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // 重新申请新的数组给新切片
        // 重新申请 capmen 这个大的内存地址,并且初始化为0值
        p = mallocgc(capmem, et, true)
        if !writeBarrier.enabled {
            // 如果还不能打开写锁,那么只能把 lenmem 大小的 bytes 字节从 old.array 拷贝到 p 的地址处
            memmove(p, old.array, lenmem)
        } else {
            // 循环拷贝老的切片的值
            for i := uintptr(0); i < lenmem; i += et.size {
                typedmemmove(et, add(p, i), add(old.array, i))
            }
        }
    }
    // 返回最终新切片,容量更新为最新扩容之后的容量
    return slice{p, old.len, newcap}
}

上述就是扩容的实现。主要需要关注的有两点,一个是扩容时候的策略,还有一个就是扩容是生成全新的内存地址还是在原来的地址后追加。
扩容策略

go 复制代码
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 = 0xc0000081b0, len = 4, cap = 4
Before newSlice = [10 20 30 40 50], Pointer = 0xc0000081c8, len = 5, cap = 8
After slice = [10 20 30 40], Pointer = 0xc0000081b0, len = 4, cap = 4
After newSlice = [10 30 30 40 50], Pointer = 0xc0000081c8, len = 5, cap = 8
*/

新的切片和之前的切片已经不同了,因为新的切片更改了一个值,并没有影响到原来的数组,新切片指向的数组是一个全新的数组。并且 cap 容量也发生了变化。这之间究竟发生了什么呢?
Go 中切片扩容的策略是这样的:

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

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

注意:扩容扩大的容量都是针对原来的容量而言的,而不是针对原来数组的长度而言的。

新数组 or 老数组 ?

再谈谈扩容之后的数组一定是新的么?这个不一定,分两种情况。

情况一:

go 复制代码
func main() {
    array := [4]int{10, 20, 30, 40}
    slice := array[0:2]
    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))
    fmt.Printf("After array = %v\n", array)
}
/*
Before slice = [10 20], Pointer = 0xc000092198, len = 2, cap = 4
Before newSlice = [10 20 50], Pointer = 0xc0000921b0, len = 3, cap = 4
After slice = [10 30], Pointer = 0xc000092198, len = 2, cap = 4
After newSlice = [10 30 50], Pointer = 0xc0000921b0, len = 3, cap = 4
After array = [10 30 50 40]
*/

在这种情况下,扩容以后并没有新建一个新的数组,扩容前后的数组都是同一个,这也就导致了新的切片修改了一个值,也影响到了老的切片了。并且 append() 操作也改变了原来数组里面的值。一个 append() 操作影响了这么多地方,如果原数组上有多个切片,那么这些切片都会被影响!无意间就产生了莫名的 bug!

这种情况,由于原数组还有容量可以扩容,所以执行 append() 操作以后,会在原数组上直接操作,所以这种情况下,扩容以后的数组还是指向原来的数组。

这种情况也极容易出现在字面量创建切片时候,第三个参数 cap 传值的时候,如果用字面量创建切片,cap 并不等于指向数组的总容量,那么这种情况就会发生。

go 复制代码
 slice := array[1:2:3]

上面这种情况非常危险,极度容易产生 bug 。

建议用字面量创建切片的时候,cap 的值一定要保持清醒,避免共享原数组导致的 bug。

情况二其实就是在扩容策略里面举的例子,在那个例子中之所以生成了新的切片,是因为原来数组的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行 append() 操作。这种情况丝毫不影响原数组。

所以建议尽量避免情况一,尽量使用情况二,避免 bug 产生。

切片拷贝

Slice 中拷贝方法有2个:

(1)slicecopy

go 复制代码
func slicecopy(to, fm slice, width uintptr) int {
    // 如果源切片或者目标切片有一个长度为0,那么就不需要拷贝,直接 return 
    if fm.len == 0 || to.len == 0 {
        return 0
    }
    // n 记录下源切片或者目标切片较短的那一个的长度
    n := fm.len
    if to.len < n {
        n = to.len
    }
    // 如果入参 width = 0,也不需要拷贝了,返回较短的切片的长度
    if width == 0 {
        return n
    }
    // 如果开启了竞争检测
    if raceenabled {
        callerpc := getcallerpc(unsafe.Pointer(&to))
        pc := funcPC(slicecopy)
        racewriterangepc(to.array, uintptr(n*int(width)), callerpc, pc)
        racereadrangepc(fm.array, uintptr(n*int(width)), callerpc, pc)
    }
    // 如果开启了 The memory sanitizer (msan)
    if msanenabled {
        msanwrite(to.array, uintptr(n*int(width)))
        msanread(fm.array, uintptr(n*int(width)))
    }

    size := uintptr(n) * width
    if size == 1 { 
        // TODO: is this still worth it with new memmove impl?
        // 如果只有一个元素,那么指针直接转换即可
        *(*byte)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
    } else {
        // 如果不止一个元素,那么就把 size 个 bytes 从 fm.array 地址开始,拷贝到 to.array 地址之后
        memmove(to.array, fm.array, size)
    }
    return n
}

slicecopy 方法会把源切片值(即 fm Slice )中的元素复制到目标切片(即 to Slice )中,并返回被复制的元素个数,copy 的两个类型必须一致。slicecopy 方法最终的复制结果取决于较短的那个切片,当较短的切片复制完成,整个复制过程就全部完成了。

go 复制代码
package main

import "fmt"

func main() {
	array := []int{10, 20, 30, 40}
	slice := make([]int, 6)
	n := copy(slice, array)
	fmt.Println(n, slice, array)  // 4 [10 20 30 40 0 0] [10 20 30 40]
}

(2)slicestringcopy

go 复制代码
func slicestringcopy(to []byte, fm string) int {
    // 如果源切片或者目标切片有一个长度为0,那么就不需要拷贝,直接 return 
    if len(fm) == 0 || len(to) == 0 {
        return 0
    }
    // n 记录下源切片或者目标切片较短的那一个的长度
    n := len(fm)
    if len(to) < n {
        n = len(to)
    }
    // 如果开启了竞争检测
    if raceenabled {
        callerpc := getcallerpc(unsafe.Pointer(&to))
        pc := funcPC(slicestringcopy)
        racewriterangepc(unsafe.Pointer(&to[0]), uintptr(n), callerpc, pc)
    }
    // 如果开启了 The memory sanitizer (msan)
    if msanenabled {
        msanwrite(unsafe.Pointer(&to[0]), uintptr(n))
    }
    // 拷贝字符串至字节数组
    memmove(unsafe.Pointer(&to[0]), stringStructOf(&fm).str, uintptr(n))
    return n
}
go 复制代码
package main

import "fmt"

func main() {
	slice := make([]byte, 3)
	n := copy(slice, "abcdef")
	fmt.Println(n, slice) // 3 [97 98 99]
}

切片遍历中的 range

range 是 Go 用于遍历数组、切片、映射和通道的关键字。在切片中,range 遍历时会产生两个值:

  • 索引值:当前遍历到的元素索引。
  • 元素值:当前索引处的元素值的拷贝。
    range 的值是拷贝
    在使用 range 遍历切片时,得到的是切片中每个元素的值拷贝,而不是元素的引用。这意味着对遍历得到的值进行修改,不会影响原切片。
go 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4}

	// 遍历并尝试修改值
	for _, v := range slice {
		v = v * 10
	}
	fmt.Println(slice) // 输出:[1 2 3 4],原切片未改变
}

需要通过索引来访问切片中的元素进行修改:

java 复制代码
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4}

	// 通过索引修改原切片的值
	for i := range slice {
		slice[i] = slice[i] * 10
	}
	fmt.Println(slice) // 输出:[10 20 30 40],原切片已改变
}

引用

  1. https://www.runoob.com/go/go-tutorial.html
  2. https://www.topgoer.cn/
  3. https://gopl-zh.github.io
  4. https://www.jianshu.com/p/030aba2bff41

相关推荐
转世成为计算机大神11 分钟前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
宅小海30 分钟前
scala String
大数据·开发语言·scala
朝九晚五ฺ31 分钟前
【Linux探索学习】第十四弹——进程优先级:深入理解操作系统中的进程优先级
linux·运维·学习
qq_3273427333 分钟前
Java实现离线身份证号码OCR识别
java·开发语言
锅包肉的九珍34 分钟前
Scala的Array数组
开发语言·后端·scala
心仪悦悦37 分钟前
Scala的Array(2)
开发语言·后端·scala
yqcoder1 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
baivfhpwxf20231 小时前
C# 5000 转16进制 字节(激光器串口通讯生成指定格式命令)
开发语言·c#
许嵩661 小时前
IC脚本之perl
开发语言·perl
长亭外的少年1 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin