Golang理解-数组和切片

数组

数组在Go中定义及特点

数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。

因为数组的长度是固定的,因此在Go语言中很少直接使用数组。

和数组对应的类型是Slice(切片),它是可以增长和收缩动态序列,slice功能也更灵活,但是要理解slice工作原理的话需要先理解数组。

默认情况下,数组的每个元素都被初始化为元素类型对应的零值,对于数字类型来说就是0。我们也可以使用数组字面值语法用一组值来初始化数组:

var q [3]int = [3]int{1, 2, 3}
var r [3]int = [3]int{1, 2}
fmt.Println(r[2]) // "0"

在数组字面值中,如果在数组的长度位置出现的是“...”省略号,则表示数组的长度是根据初始化值的个数来计算。因此,上面q数组的定义可以简化为

q := [...]int{1, 2, 3}
fmt.Printf("%T\n", q) // "[3]int"

数组的长度是数组类型的一个组成部分,因此[3]int和[4]int是两种不同的数组类型。数组的长度必须是常量表达式,因为数组的长度需要在编译阶段确定。

我们将会发现,数组、slice、map和结构体字面值的写法都很相似。上面的形式是直接提供顺序初始化值序列,但是也可以指定一个索引和对应值列表的方式初始化,就像下面这样:

type Currency int

const (
    USD Currency = iota // 美元
    EUR                 // 欧元
    GBP                 // 英镑
    RMB                 // 人民币
)

symbol := [...]string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}

fmt.Println(RMB, symbol[RMB]) // "3 ¥"

如果一个数组的元素类型是可以相互比较的,那么数组类型也是可以相互比较的,这时候我们可以直接通过==比较运算符来比较两个数组,只有当两个数组的所有元素都是相等的时候数组才是相等的。不相等比较运算符!=遵循同样的规则。

数组如何在函数参数中传递

当调用一个函数的时候,函数的每个调用参数将会被赋值给函数内部的参数变量,所以函数参数变量接收的是一个复制的副本,并不是原始调用的变量。

因为函数参数传递的机制导致传递大的数组类型将是低效的,并且对数组参数的任何的修改都是发生在复制的数组上,并不能直接修改调用时原始的数组变量

在这个方面,Go语言对待数组的方式和其它很多编程语言不同,其它编程语言可能会隐式地将数组作为引用或指针对象传入被调用的函数。

注意事项

虽然通过指针来传递数组参数是高效的,而且也允许在函数内部修改数组的值,

但是数组依然是僵化的类型,因为数组的类型包含了僵化的长度信息。

而且数组也没有任何添加或删除数组元素的方法。由于这些原因,除了一些需要处理特定大小数组的特例外,数组依然很少用作函数参数;相反,我们一般使用slice来替代数组。


Slice

slice在go中的定义及特点

Slice(切片)代表变长的序列,序列中每个元素都有相同的类型。一个slice类型一般写作[]T,其中T代表slice中元素的类型;slice的语法和数组很像,只是没有固定长度而已。

一个slice是一个轻量级的数据结构,提供了访问数组子序列(或者全部)元素的功能,而且slice的底层确实引用一个数组对象。

一个slice由三个部分构成:指针、长度和容量。

  • 指针指向第一个slice元素对应的底层数组元素的地址,要注意的是slice的第一个元素并不一定就是数组的第一个元素
  • 长度对应slice中元素的数目
  • 长度不能超过容量,容量一般是从slice的开始位置到底层数据的结尾位置。内置的len和cap函数分别返回slice的长度和容量。

内置的len和cap函数分别返回slice的长度和容量。

多个slice之间可以共享底层的数据,并且引用的数组部分区间可能重叠。

img

如果切片操作超出cap(s)的上限将导致一个panic异常,但是超出len(s)则是意味着扩展了slice,因为新slice的长度会变大

因为slice值包含指向第一个slice元素的指针,因此向函数传递slice将允许在函数内部修改底层数组的元素

换句话说,复制一个slice只是对底层的数组创建了一个新的slice别名

slice之间不能比较(这和数组不同),因此我们不能使用"=="操作符来判断两个slice是否含有全部相等元素。

不过标准库提供了高度优化的bytes.Equal函数来判断两个字节型slice是否相等([]byte),但是对于其他类型的slice,我们必须自己展开每个元素进行比较:

func equal(x, y []string) bool {
    if len(x) != len(y) {
        return false
    }
    for i := range x {
      	// 逐个元素比较
        if x[i] != y[i] {
            return false
        }
    }
    return true
}

为什么slice不支持比较呢?

  1. 一个slice的元素是间接引用的,一个slice甚至可以包含自身。虽然有很多办法处理这种情形,但是没有一个是简单有效的。
  2. 因为slice的元素是间接引用的,一个固定的slice值(译注:指slice本身的值,不是元素的值)在不同的时刻可能包含不同的元素,因为底层数组的元素可能会被修改. Go语言中map的key只做简单的浅拷贝,它要求key在整个生命周期内保持不变性(译注:例如slice扩容,就会导致其本身的值/地址变化)。
  3. 对于像指针或chan之类的引用类型,==相等测试可以判断两个是否是引用相同的对象

基于以上原因,我们安全的做法是直接禁止slice之间的比较操作,slice唯一合法的比较操作是和nil比较.

虽然slice是可以和nil进行比较的,但是只其中也有些细节需要注意:

  1. 一个零值的slice等于nil。一个nil值的slice并没有底层数组。
  2. 一个nil值的slice的长度和容量都是0,但是也有非nil值的slice的长度和容量也是0的,例如[]int{}或make([]int, 3)[3:]
  3. 如果你需要测试一个slice是否是空的,使用len(s) == 0来判断,而不应该用s == nil来判断。除了和nil相等比较外,一个nil值的slice的行为和其它任意0长度的slice一样;

内置的make函数创建一个指定元素类型、长度和容量的slice。容量部分可以省略,在这种情况下,容量将等于长度。

make([]T, len)
make([]T, len, cap) // same as make([]T, cap)[:len]

在底层,make创建了一个匿名的数组变量,然后返回一个slice;只有通过返回的slice才能引用底层匿名的数组变量。在第一种语句中,slice是整个数组的view。在第二个语句中,slice只引用了底层数组的前len个元素,但是容量将包含整个的数组。额外的元素是留给未来的增长用的。

slice扩容原则

先来看看slice源码中是如何写的,在来分析扩容的原则:

func growslice(et *_type, old slice, cap int) slice {
    if et.size == 0 {
        if cap < old.cap {
            panic(errorString("growslice: cap out of range"))
        }
        // append should not create a slice with nil pointer but non-zero len.
        // We assume that append doesn't need to preserve old.array in this case.
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        // 小于1024,*2扩容
        if old.len < 1024 {
            newcap = doublecap
        } else {
            // 大于1024,*1.25
            for newcap < cap {
                newcap += newcap / 4
            }
        }
    }
  // 下面代码省略
  ....
}

从上面代码可以看出, slice的扩容规则是:

  1. 小于1024,每次扩容后的cap = oldCap * 2
  2. 大于1024,每次扩容cap = oldCap * 1.25

我们可以看出,每次扩容会涉及到数组的copy,然后生成新的数组(slice指向新的数组),这样会给系统带来额外的开销,通常我们在创建slice的时候,建议使用make函数,更具业务场景给定一个合适的cap大小,避免slice因为扩容而发生底层数组的copy。

slice的内存使用技巧

案例一:

输入slice和输出slice共用一个底层数组,这可以避免分配另一个数组,不过原来的数据将可能会被覆盖:例如

func nonempty(strings []string) []string {
    i := 0
    for _, s := range strings {
        if s != "" {
            strings[i] = s
            i++
        }
    }
    return strings[:i]
}

// 输出:
data := []string{"one", "", "three"}
fmt.Printf("%q\n", nonempty(data)) // `["one" "three"]`
fmt.Printf("%q\n", data)           // `["one" "three" "three"]`

同样的,使用append也能实现同样的功能:

func nonempty2(strings []string) []string {
    out := strings[:0] // zero-length slice of original
    for _, s := range strings {
        if s != "" {
            out = append(out, s)
        }
    }
    return out
}

无论如何实现,以这种方式重用一个slice一般都要求最多为每个输入值产生一个输出值,事实上很多这类算法都是用来过滤或合并序列中相邻的元素。

这种slice用法是比较复杂的技巧,虽然使用到了slice的一些技巧,但是对于某些场合是比较清晰和有效的。

案例二:

使用slice来模拟stack操作,入栈即向slice中append元素,出栈则通过收缩slice,弹出栈顶的元素:

// 入栈, push
stack = append(stack, v)

// 出栈, pop
stack = stack[:len(stack)-1]

案例三:

要删除slice中间的某个元素并保存原有的元素顺序,可以通过内置的copy函数将后面的子slice向前依次移动一位完成:

list := []int{1,2,3,4,5,6}
// 删除元素i,并保留原来的顺序,原理就是将i后面的元素按次序copy
copy(list[i:],list[i+1:])

要删除元素后不用保持原来顺序的话,我们可以简单的用最后一个元素覆盖被删除的元素:

func remove(slice []int, i int) []int {
  	// 使用最后一个元素覆盖要删除的元素
    slice[i] = slice[len(slice)-1]
  	// 返回新的slice
    return slice[:len(slice)-1]
}

posted @ 2019-08-09 12:07  梧桐花落  阅读(2174)  评论(0编辑  收藏  举报