数组和切片知识点

Posted cody0909

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数组和切片知识点相关的知识,希望对你有一定的参考价值。

(一)数组和切片知识点

最近在面试中发现有些不错的面试者由于基础太差错过了一些工作机会,所以打算吧工作中整理的一些go的基础知识,大概花两个月发到csdn上。希望对大家有用

问题1 : 我们都知道切片是引用类型,数组是值类型,请问引用和值类型有什么区别?

golang中的引用类型包括:切片 channel map interface 函数
golang中的值类型包括: 所有内置数据类型,数组,结构等
引用类型一般是内部数据结构中包含了指向底层结构的指针。
值类型就是一片内存空间而已。
GO语言在参数传递的时候,全部都是按值传递。

问题2: 下面代码输出什么?

package main

import "fmt"

func main() 
    s1 := make([]int, 5)
    fmt.Printf("The length of s1: %d\\n", len(s1)) 
    fmt.Printf("The capacity of s1: %d\\n", cap(s1)) 
    fmt.Printf("The value of s1: %d\\n", s1) 
    s2 := make([]int, 5, 8)
    fmt.Printf("The length of s2: %d\\n", len(s2)) 
    fmt.Printf("The capacity of s2: %d\\n", cap(s2)) 
    fmt.Printf("The value of s2: %d\\n", s2) 

问题3: 下面代码输出什么?

s3 := []int1, 2, 3, 4, 5, 6, 7, 8
s4 := s3[3:6] // 左闭右开
fmt.Printf("The length of s4: %d\\n", len(s4)) // 3
fmt.Printf("The capacity of s4: %d\\n", cap(s4)) // 5
fmt.Printf("The value of s4: %d\\n", s4) // [4, 5, 6]

问题4: 怎样估算切片容量的增长?

一旦一个切片无法容纳更多的元素,Go 语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的 2 倍。
但是当原切片的长度(以下简称原长度)大于或等于1024时,Go 语言将会以原容量的1.25倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与1.25相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终新容量往往会比新长度大一些,当然,相等也是可能的。
首先需要明白怎么才能扩容切片的容量:

  • 当切片底层数组容量足够的时候使用 s = s[0: k],并且k <= cap(s)来扩容
  • 当切片底层数组容量不足以满足扩容需求的时候, 也就是说 k > cap(s)的时候, 使用 s = append(s, cap(s) + 1)来扩容
    GO语言中对切片的容量扩容可以看如下代码:
    func main()
    s1 := […]int1, 2, 3, 4, 5, 6, 7, 8, 9, 10
    s2 := s1[1:3]
    fmt.Println(len(s2)) // 2
    // 扩容s2
    s2 = s2[0:cap(s2)]
    fmt.Println(len(s2), cap(s2)) // 9, 9
    // s2 = s2[0:cap(s2) + 1] // 将会引发一个panic, slice bounds out of range [:10] with capacity 9
    // 通过append扩容
    s2 = append(s2, s2…)
    fmt.Println(len(s2), cap(s2)) // 18, 18

问题5: 切片的底层数组什么时候会被替换?

确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。请记住,在无需扩容时,append函数返回的是指向原底层数组的新切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。

问题6: 如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么?

  • 初始时两个切片引用同一个底层数组,在后续操作中对某个切片的操作超出底层数组的容量时,这两个切片引用的就不是同一个数组了,比如下面这个例子:
s1 := []int1, 2, 3, 4, 5
s2 := s1[0:5]
s2 = append(s2, 6)
s1[3] = 30
fmt.Println(s1[3], s2[3]) // 30, 4 这是因为s2的底层数组已经是扩容后的新数组了
  • 当两个长度不一的切片使用同一个底层数组,并且两切片的长度均小于数组的容量时,对其中长度较小的一个切片进行append操作,但不超过底层数组容量,这时会影响长度较长切片中原来比较小切片多看到的值,因为底层数组被修改了。

问题7: 怎样沿用“扩容”的思想对切片进行“缩容”?请写出代码。

切片缩容之后还是会引用底层的原数组,这有时候会造成大量缩容之后的多余内容没有被垃圾回收。可以使用新建一个数组然后copy的方式。

问题8: 是否可以使用new来初始化slice?

不能。make 专门用来创建 切片、map、channel 的值。它返回的是被创建的值,并且立即可用。
new 是申请一小块内存并标记它是用来存放某个值的。它返回的是指向这块内存的指针,而且这块内存并不会被初始化。或者说,对于一个引用类型的值,那块内存虽然已经有了,但还没法用(因为里面没有针对那个值的数据结构的初始化操作)。
所以,对于引用类型的值,不要用 new,能用 make 就用 make,不能用 make 就用复合字面量来创建。
new 和make的区别:看起来二者没有什么区别,都在堆上分配内存,但是它们的行为不同,适用于不同的类型。

  • new(T) 为每个新的类型T分配一片内存,初始化为 0 并且返回类型为*T的内存地址:这种方法返回一个指向类型为 T,值为 0 的地址的指针,它适用于值类型如数组和结构体(参见第 10 章);它相当于 &T
  • make(T) 返回一个类型为 T 的初始值,它只适用于3种内建的引用类型:切片、map 和 channel。换言之,new 函数分配内存,make 函数初始化内存;下图给出了区别:

slice、map以及channel都是golang内建的引用类型,三者在内存中存在多个组成部分, 需要对内存组成部分初始化后才能使用,而make就是对三者进行初始化的一种操作方式
new 获取的是存储指定变量内存地址的一个变量,对于变量内部结构并不会执行相应的初始化操作, 所以slice、map、channel需要make进行初始化并获取对应的内存地址,而非new简单的获取内存地址

问题9: 下面输出是什么?

// []int1, 2, 3, 4, 5, 6
func Assign1(s []int) 
   s = []int6, 6, 6


func Reverse0(s [5]int) 
   for i, j := 0, len(s)-1; i < j; i++ 
      j = len(s) - (i + 1)
      s[i], s[j] = s[j], s[i]
   


func Reverse1(s []int) 
   for i, j := 0, len(s)-1; i < j; i++ 
      j = len(s) - (i + 1)
      s[i], s[j] = s[j], s[i]
   


func Reverse2(s []int) 
   s = append(s, 999)
   for i, j := 0, len(s)-1; i < j; i++ 
      j = len(s) - (i + 1)
      s[i], s[j] = s[j], s[i]
   
   fmt.Println(s)


func Reverse3(s []int) 
   s = append(s, 999, 1000, 1001)
   for i, j := 0, len(s)-1; i < j; i++ 
      j = len(s) - (i + 1)
      s[i], s[j] = s[j], s[i]
   


func main() 
   s := []int1, 2, 3, 4, 5, 6
   Assign1(s)
   fmt.Println(s) // 输出[1 2 3 4 5 6]

   array := [5]int1, 2, 3, 4, 5
   Reverse0(array)
   fmt.Println(array) // 输出[1 2 3 4 5]

   s = []int1, 2, 3
   Reverse2(s) // [99 3 2 1]
   fmt.Println(s) // [1 2 3] 

   var a []int
   for i := 1; i <= 3; i++ 
      a = append(a, i)
   
   fmt.Println(len(a), cap(a)) // 3, 4 
   Reverse2(a) // [999 3 2 1]
   fmt.Println(a) // [999 3 2]

   var b []int
   for i := 1; i <= 3; i++ 
      b = append(b, i)
   
   Reverse3(b)
   fmt.Println(b)  // [1, 2, 3] 这里容易错

   c := [3]int1, 2, 3
   d := c
   c[0] = 999
   fmt.Println(d) // [1, 2, 3]


slice底层结构:
type slice struct 
    array unsafe.Pointer
    len   int
    cap   int

slice的底层结构由一个指向数组的指针ptr和长度len,容量cap构成,也就是说slice的数据存在数组当中。
slice的重要知识点

  • slice的底层是数组指针。
  • 当append后,slice长度不超过容量cap,新增的元素将直接加在底层数组中。
  • 当append后,slice长度超过容量cap,将创建一个新的底层数组。
    使用array还是slice?
    一个很重要的知识点是:Go的函数传参,都是以值的形式传参。
    如果要给函数传递一个有100w个元素的array时,直接使用array传递的效率是非常低的,因为array是值拷贝,100w个元素都复制一遍是非常可怕的;这时就应该使用slice作为参数,就相当于传递了一个指针。
    如果元素数量比较少,使用array还是slice作为参数,效率差别并不大。

问题10: 什么是空切片,什么是nil切片?

切片有nil切片和空切片,它们的长度和容量都是0,但是它们指向底层数组的指针不一样,nil切片意味着指向底层数组的指针为nil,而空切片对应的指针是个地址。

// nil 切片
var nilSlice []int
// 空切片
slice := []int

nil切片表示不存在的切片,而空切片表示一个空集合,它们各有用处。

问题11: 请看下面代码输出什么?

func main() 
    slice := []int1, 2, 3, 4, 5
    for _, v := range slice 
        v = v  + 1
    
    fmt.Println(slice)

这里需要说明的是range返回的是对切片元素的复制,而不是元素的引用。

问题12: 切片可以向后移动吗?

s := []int1,2,3,4
s1 := s[2:]
s2 := s1[-1:] // 编译错误

切片不能被重新分片以获取数组的前一个元素。

问题13: 可以使用一个指针指向一个切片吗?

可以,但没必要。 就跟一个北京人可以去深圳打工吗? 可以,但是没有必要。

slice := []int1, 2, 3, 4, 5
pslice := &slice
for _, v := range *pslice 
    fmt.Println(v)

问题14: 如何修改字符串中的某个字符?

func main() 
    a := "Hello, world"
    aslice := []byte(a)
    aslice[0] = 'G'
    a = string(aslice)
    fmt.Println(a)


func main() 
    a := "Hello, world"
    a[0] = 'C' // Cannot assign to a[0]
    fmt.Println(a)
     

问题15: append函数常见操作

  • 将切片 b 的元素追加到切片 a 之后:a = append(a, b…)

  • 赋值切片a的元素到新的切片b上:
    b = make([]T, len(a))
    copy(b, a)

  • 删除位于索引 i 的元素:a = append(a[:i], a[i+1:]…)

  • 切除切片 a 中从索引 i 至 j 位置的元素([i, j):a = append(a[:i], a[j:]…)

  • 为切片 a 扩展 j 个元素长度:a = append(a, make([]T, j)…)

  • 在索引 i 的位置插入元素 x:a = append(a[:i], append([]Tx, a[i:]…)…)

  • 在索引 i 的位置插入长度为 j 的新切片:a = append(a[:i], append(make([]T, j), a[i:]…)…)

  • 在索引 i 的位置插入切片 b 的所有元素:a = append(a[:i], append(b, a[i:]…)…)

  • 取出位于切片 a 最末尾的元素 x:x, a = a[len(a)-1], a[:len(a)-1]

  • 将元素 x 追加到切片 a:a = append(a, x)

问题16: 内存泄漏

切片的底层指向一个数组,该数组的实际容量可能要大于切片所定义的容量。只有在没有任何切片指向的时候,底层的数组内存才会被释放,这种特性有时会导致程序占用多余的内存。
示例 函数 FindDigits 将一个文件加载到内存,然后搜索其中所有的数字并返回一个切片。

var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte 
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)

这段代码可以顺利运行,但返回的 []byte 指向的底层是整个文件的数据。只要该返回的切片不被释放,垃圾回收器就不能释放整个文件所占用的内存。换句话说,一点点有用的数据却占用了整个文件的内存。
想要避免这个问题,可以通过拷贝我们需要的部分到一个新的切片中:

func FindDigits(filename string) []byte 
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c

问题17: 数组的类型

数组作为一种基本的数据类型,我们通常都会从两个维度描述数组,我们首先需要描述数组中存储的元素类型,还需要描述数组最大能够存储的元素个数,在 Go 语言中我们往往会使用如下所示的方式来表示数组类型:

[10]int
[200]interface

Go 语言中数组在初始化之后大小就无法改变,存储元素类型相同、但是大小不同的数组在 Go 语言看来也是完全不同的,只有两个条件都相同才是同一个类型。

问题18: 数组的初始化方式有那些?

Go 语言中的数组有两种不同的创建方式,一种是显式的指定数组的大小,另一种是使用 […]T 声明数组,Go 语言会在编译期间通过源代码对数组的大小进行推断:

arr1 := [3]int1, 2, 3
arr2 := [...]int1, 2, 3

上述两种声明方式在运行期间得到的结果是完全相同的,后一种声明方式在编译期间就会被『转换』成为前一种,这也就是编译器对数组大小的推导,下面我们来介绍编译器的推导过程。
上限推导
两种不同的声明方式会导致编译器做出完全不同的处理,如果我们使用第一种方式 [3]int,那么变量的类型在编译进行到类型检查阶段就会被提取出来,随后会使用 cmd/compile/internal/types.NewArray 函数创建包含数组大小的 Array 类型。
当我们使用 […]T 的方式声明数组时,虽然在这一步也会创建一个 Array 类型 ArrayElem: elem, Bound: -1,但是其中的数组大小上限会是 -1,这里的 -1 只是一个占位符,编译器会在后面的 cmd/compile/internal/gc.typecheckcomplit 函数中对该数组的大小进行推导; 该函数通过遍历元素的方式来计算数组中元素的数量。
所以我们可以看出 […]T1, 2, 3 和 [3]T1, 2, 3 在运行时是完全等价的,[…]T 这种初始化方式也只是 Go 语言为我们提供的一种语法糖,当我们不想计算数组中的元素个数时就可以通过这种方法来减少一些工作。
语句转换
对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的 cmd/compile/internal/gc.anylit 函数中做两种不同的优化:

  1. 当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上;
  2. 当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出;
    总结起来,如果数组中元素的个数小于或者等于 4 个,那么所有的变量会直接在栈上初始化,如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上,这些转换后的代码才会继续进入中间代码生成和机器码生成两个阶段,最后生成可以执行的二进制文件。

问题19: 数组的访问和赋值

无论是在栈上还是静态存储区,数组在内存中其实就是一连串的连续的内存空间,表示数组的方法就是一个指向数组开头的指针、数组中元素的数量以及数组中元素类型占的空间大小,如果我们不知道数组中元素的数量,访问时就可能发生越界,而如果不知道数组中元素类型的大小,就没有办法知道应该一次取出多少字节的数据,如果没有这些信息,我们就无法知道这片连续的内存空间到底存储了什么数据。
数组访问越界是非常严重的错误,Go 语言中对越界的判断是可以在编译期间由静态类型检查完成的:
访问数组的索引是非整数的时候直接报错-- non-integer array index %v

问题20: 下面函数输出什么?

func SliceRise(s []int) 
   s = append(s, 0)
   for i := range s 
      s[i]++
   


func main() 
   s1 := []int1, 2
   s2 := s1
   s2 = append(s2, 3) // s2 := 1, 2, 3
   SliceRise(s1) // 
   SliceRise(s2) // 2, 3, 4
   fmt.Println(s1, s2) // 1, 2 和 2,3,4

输出结果为:
[1 2] [2 3 4]

问题21: 下面函数输出什么

func main() 
   s1 := []int1, 2, 3
   s2 := []int2, 3, 4, 5
   copy(s1, s2)
   fmt.Println(s1)

输出结果为:
[2 3 4]

使用copy()内置函数拷贝两个切片时, 会将原切片的数据逐个拷贝到目的切片指向的数组中, 拷贝数量为两个切片的长度最小值。拷贝过程中不会发生扩容。

问题22: 下面的函数输出什么?

func main() 
   s1 := []int1, 2, 3
   s1 = s1[0:cap(s1) + 1]

如果切片表达式不满足边界,则会引发panic。因此上述代码输出:

panic: runtime error: slice bounds out of range [:4] with capacity 3

goroutine 1 [running]:
main.main()
        /Users/miaolinjie/Code/learngo/main.go:5 +0x1d

问题23: 下面的函数输出什么?

func main() 
   arr := []int1, 2, 3, 4, 5, 6, 7, 8, 9, 10
   s1 := arr[0:1] // s1 = 1
   s1 = s1[2:3] // s1 := 3
   fmt.Println(s1)

如果简单表达式切取的对象是切片, 那么表达式a[low:high]中的low和high的最大值可以为a的容量,而不是a的长度。因此上述代码输出:
[3]

问题24: 下面函数输出什么?

func main() 
   arr := []int1, 2, 3, 4, 5, 6, 7, 8, 9, 10
   s1 := arr[0:1:1] // s1 = 1
   s2 := append(s1, 100) // s2 = 1, 100
   fmt.Println(&s1[0] != &s2[0])// true

   s11 := arr[0:1] // s11 = 1
   s22 := append(s11, 100) // s22 = 1,100
   fmt.Println(&s11[0] == &s22[0]) // true

扩展表达式a[low:high:max],用来限制切片的容量。 当切片容量不足的时候,会产生一个新的切片, 不会覆盖原始数组或者切片。

以上是关于数组和切片知识点的主要内容,如果未能解决你的问题,请参考以下文章

数组和切片知识点

数组和切片知识点

Golang 数组 切片 字典 基本知识点

项目变更管理项目集合管理流程管理知识管理战略管理真题

Go 在数组子切片上的内置范围具有不一致的行为

数据分析之道-NumPynumpy切片与索引