Go既然有数组为啥还要有切片

622 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2天,点击查看活动详情

“既生瑜何生亮” ,Go的数组和切片,相爱相杀、相辅相成。

前言

PHP的数组,功能非常强大,除了基础的增删改查操作外,还支持截取合并翻转一系列的操作。

刚从PHP转Go的朋友,一定会有疑问,Go既然已经有了数组,为啥还要有切片这个类型呢?

先说结论

Go的数组长度是固定,在声明数组时长度就已经确定,并且长度也是数组类型的一部分。这样就会导致不支持添加元素等一系列操作问题,很不灵活。所以,Go在语言设计上,基于数组做了一层封装,增强数组的操作能力,非常灵活,并且还自动扩容,这就是切片

接下来,再一块具体学习一下Go的数组与切片

数组

定义

数组是相同数据类型的元素集合,分配的内存是连续的

特点

定长,长度是数组类型的一部分,不能修改长度,只能通过下标访问或修改元素

声明和初始化

三种方式:

func main() {
  //方式1:指定长度
  var nums [3]int        //声明
  fmt.Println(nums)      //[0 0 0]  默认初始化为零值
  nums = [3]int{1, 2, 3} //初始化(手动赋值)
  fmt.Println(nums)      //[1 2 3]

  var nums2 = [3]int{1, 2, 3} //声明+初始化
  fmt.Println(nums2)          //[1 2 3]

  //方式2:使用...,go做长度推断
  var names = [...]int{1, 2, 3}
  fmt.Println(names, len(names), fmt.Sprintf("%T", names)) //[1 2 3] 3 [3]int

  //方式3:指定下标
  a := [...]int{1: 1, 3: 4}
  fmt.Println(a, len(a), fmt.Sprintf("%T", a)) //[0 1 0 4] 4 [4]int
}

多维数组

两种定义方法:

//方式1:指定长度
arr := [3][2]int{
  {1, 2},
  {3, 4},
}
fmt.Println(arr) //[[1 2] [3 4] [0 0]]
for k, v := range arr {
  for k2, v2 := range v {
    fmt.Println(k, k2, v2)
  }
}
//输出
//0 0 1
//0 1 2
//1 0 3
//1 1 4
//2 0 0
//2 1 0

//方式2:使用...,go做长度推断,注意只有第一个[]才能使用...
arr := [...][2]int{
    {1, 2},
    {3, 4},
}
fmt.Println(arr) //[[1 2] [3 4]]
for k, v := range arr {
  for k2, v2 := range v {
    fmt.Println(k, k2, v2)
  }
}
//输出
//0 0 1
//0 1 2
//1 0 3
//1 1 4

值类型,产生副本

数组是值类型,赋值或传参时,会产生副本,修改副本不会影响原数组的值

a1 := [3]int{1, 2, 3}
b1 := a1
b1[0] = 100
fmt.Println(a1) //[1 2 3]
fmt.Println(b1) //[100 2 3]

可做对比

数组支持 “==“、”!=” 操作符,因为内存总是被初始化过的

注意:数组类型要一致才能对比,因为长度也是数组类型的一部分,长度不同不能对比,会编译失败

aa := [3]int{1, 2, 3}
bb := [3]int{1, 2, 1}
fmt.Println(aa == bb) //false
cc := [4]int{1, 2, 3, 4}
fmt.Println(aa == cc) //编译失败,invalid operation: aa == cc (mismatched types [3]int and [4]int)

切片

定义

切片是相同数据类型的元素的可变长度的序列,它是基于数组的一层封装,非常灵活、支持自动扩容(跟PHP的array索引数组更像)

内部结构包含地址长度容量

//GOROOT/src/runtime/slice.go 13行
type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

注意:切片是引用类型,必须初始化后才能使用

声明和初始化

两种方式:

//方式1:普通写法
var a []int              //声明
fmt.Println(a, a == nil) //[] true
a = []int{1, 2, 3}       //初始化
fmt.Println(a, a == nil) //[1 2 3] false  (所以说只要切片初始化后就等于nil)

var b = []int{}          //声明+初始化
fmt.Println(b, b == nil) //[] false

//方式2:使用make构造切片
c := make([]int, 0, 5)   //声明+初始化,构造出一个len=0 cap=5的切片(len必填 cap非必填)
fmt.Println(c, c == nil) //[] false

切片表达式 [:]

作用:指定两个下标,截取切片中的第几位到第几位的元素。要领:前闭后开

切一次、切两次,各有学问,好好看下面示例(带理解说明):

a := []int{1, 2, 3, 4, 5, 6, 7, 8}
fmt.Println(fmt.Sprintf("%p", a)) //0xc000022080

//切一次:基于a切
s1 := a[1:5]
fmt.Println(fmt.Sprintf("s1:%v len:%v cap:%v", s1, len(s1), cap(s1))) //s1:[2 3 4 5] len:4 cap:7
fmt.Println(fmt.Sprintf("%p", s1))                                    //0xc000022088

// 帮忙理解:从下标1开始截取5-1个元素,所以长度=4,但容量为len(a)-1=7,也就是a的总长度-开始下标
//       注意:a的左边界为0,右边界为len(a)=8。别超过,否则panic

//切两次:基于s1再切一次,也就是切片的切片(大有学问)
s2 := s1[1:5]
fmt.Println(fmt.Sprintf("s2:%v len:%v cap:%v", s2, len(s2), cap(s2))) //s2:[3 4 5 6] len:4 cap:6
fmt.Println(fmt.Sprintf("%p", s2))                                    //0xc000022090

// 帮忙理解:首先,s1:[2 3 4 5] len:4 cap:7。这时s1的左边界也是=0,但右边界得为cap(s1)=7
//         从s1的下标1开始截取5-1个元素,所以长度=4,但容量为cap(s1)-1=6,也就是s1的容量-开始下标

//异常case:切片操作,边界越界,就会panic
s3 := s1[1:len(a)] //panic: runtime error: slice bounds out of range [:8] with capacity 7
//s3 := s1[1:cap(s1)] //这样才是正确写法,会输出:s3:[3 4 5 6 7 8] len:6 cap:6
fmt.Println(fmt.Sprintf("s3:%v len:%v cap:%v", s3, len(s3), cap(s3)))

同时,为了方便起见,也支持省略切片表达式中的任何索引

a[2:]  // 等同于 a[2:len(a)]
a[:3]  // 等同于 a[0:3]
a[:]   // 等同于 a[0:len(a)]

引用类型,修改原值

由于切片是引用类型,赋值或切片操作后,修改元素的值,都会相互影响生效,因为操作的都是同一块内存空间

q1 := []int{1, 2, 3}
q3 := q1
q3[1] = 100
fmt.Println(q1, q3)  //[1 100 3] [1 100 3]

不可对比

切片只能和nil对比;切片和切片之间不能对比

q1 := []int{1, 2, 3}
q2 := []int{1, 2, 3}
fmt.Println(q1 == nil, q2 == nil) //false false
//fmt.Println(q1 == q2) //编译失败:invalid operation: q1 == q2 (slice can only be compared to nil)

判空

请始终使用len(s) == 0来判断,而不应该使用s == nil来判断

原因: 一个nil值的切片并没有底层数组,长度和容量都是0。但我们不能说一个长度和容量都是0的切片一定是nil,也有可能是已初始化过的空切片(有指向底层数组,不等于nil)

append添加元素

动态添加元素,可以是1个、多个、或者整个切片(记得后面加...)

var nums []int
//nums = []int{} //下面用append,这么没必要初始化
nums = append(nums, 1)
nums = append(nums, 2, 3, 4)
nums2 := []int{5, 6, 7}
nums = append(nums, nums2...)
fmt.Println(nums) //[1 2 3 4 5 6 7]

扩容策略

往切片里添加元素,在容量不足的情况下,会触发自动扩容策略。每次扩容,都会重新申请一块内存空间,用于存储,所以此时切片指针也会发生变化,重新指向新地址

func main() {
  var nums []int
  fmt.Println(fmt.Sprintf("nums:%v ptr:%p len:%d cap:%d", nums, nums, len(nums), cap(nums)))

  for i := 0; i < 10; i++ {
    nums = append(nums, i)
    fmt.Println(fmt.Sprintf("nums:%v ptr:%p len:%d cap:%d", nums, nums, len(nums), cap(nums)))
  }
}

输出:
nums:[] ptr:0x0 len:0 cap:0
nums:[0] ptr:0xc0000ae020 len:1 cap:1
nums:[0 1] ptr:0xc0000ae030 len:2 cap:2
nums:[0 1 2] ptr:0xc0000b8040 len:3 cap:4
nums:[0 1 2 3] ptr:0xc0000b8040 len:4 cap:4
nums:[0 1 2 3 4] ptr:0xc0000ba040 len:5 cap:8
nums:[0 1 2 3 4 5] ptr:0xc0000ba040 len:6 cap:8
nums:[0 1 2 3 4 5 6] ptr:0xc0000ba040 len:7 cap:8
nums:[0 1 2 3 4 5 6 7] ptr:0xc0000ba040 len:8 cap:8
nums:[0 1 2 3 4 5 6 7 8] ptr:0xc0000bc000 len:9 cap:16
nums:[0 1 2 3 4 5 6 7 8 9] ptr:0xc0000bc000 len:10 cap:16

规律:0 1 2 4 8 16 ...

扩容策略底层源码:

//GOROOT/src/runtime/slice.go 144行 
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
  newcap = cap
} else {
  if old.cap < 1024 {
    newcap = doublecap
  } else {
    // Check 0 < newcap to detect overflow
    // and prevent an infinite loop.
    for 0 < newcap && newcap < cap {
      newcap += newcap / 4
    }
    // Set newcap to the requested cap when
    // the newcap calculation overflowed.
    if newcap <= 0 {
      newcap = cap
    }
  }
}

简单描述:容量小于1024,要扩容则按2倍规律进行扩容;容量超过1024之后,则按1.25倍的规律进行扩容

所以说,能确定切片容量大小,make构造切片时最好指定cap值进行初始化,可避免自动扩容不断申请新内存空间而带来的性能开销

copy产生切片副本

有时候我们不希望,修改了一个切片元素值,导致指向相同底层数组的其他切片相互影响,这时我们就得使用copy函数,来产生副本,隔离开相互影响

src := []int{1, 2, 3}
dest := make([]int, len(src)) //注意:len必须指定
copy(dest, src)
dest[0] = 100
fmt.Println(src, dest, len(dest), cap(dest)) //[1 2 3] [100 2 3] 3 3

删除切片元素

go没有专门的方法做删除,可以使用切片特性(切片表达式)来完成

注意:删除后切片的长度会减小,但容量不变

func main() {
  a := []int{1, 2, 3, 4, 5}
  //要删除下标2的元素
  a = append(a[:2], a[3:]...)
  fmt.Println(a, len(a), cap(a))    //[1 2 4 5] 4 5
  fmt.Println(fmt.Sprintf("%p", a)) //0xc000018150

  //拓展一下:移除值为1的元素
  b := removeElement(a, 1)
  fmt.Println(a, len(a), cap(a))    //[2 4 5 5] 4 5
  fmt.Println(b, len(b), cap(b))    //[2 4 5] 3 5  //有坑:看上一行结果,a也受影响了。好理解,引用类型...
  fmt.Println(fmt.Sprintf("%p", b)) //0xc000018150
}

//使用双指针法,移除指定元素
func removeElement(s []int, ele int) []int {
  fmt.Println(fmt.Sprintf("%p", s)) //0xc000018150
  i := 0
  for _, v := range s {
    if v == ele {
      continue
    }
    s[i] = v
    i++
  }
  s = s[:i]
  return s
}

数组和切片对比

  • 长度:数组定长,长度是类型的一部分;切片变长,自动扩容,操作灵活;
  • 类型:数组是值类型,值传递会产生副本,修改互不影响;切片是引用类型,值传递不会产生副本,修改会相互影响,操作同一块内存空间(想要产生副本得用copy函数);
  • 初始化:数组声明就会自动初始化为零值;切片必须手动初始化后才能使用,否则panic;
  • 做对比:数组可对比,但需要注意类型必须一致;切片只能和nil对比,两个切片不能对比,会编译失败;

总结

Go的数组和切片相辅相成,深入学习了数组与切片的用法和注意点之后,最初的问题(“Go既然有数组为啥还要有切片”)游刃而解。并且对两者进行了相互对比,进一步加深差异理解。

如果本文对你有帮助,欢迎点赞收藏加关注,如果本文有错误的地方,欢迎指出!