GO成神之路:你真的会使用数组与切片吗?|Go主题月

807 阅读7分钟

该片文章我将详细带领大家搞定这道面试必考题。

开始之前

在众多常用的高级语言中比如:C#,Java,JavaScript等都有数组的概念。

如果你是以上语言的使用者,当被问及数组是什么的时候,第一反应就是数组是引用类型,指向的是一块内存的首地址。 其实这个回答没有什么问题,但是在Go中却不对,Go是什么?我觉得应该是C的语法糖。

谈谈数组

我的理解:一堆相同类型的数据,占用固定大小的内存,按照固定的顺序紧密排列在一起。

抛开语言层面对数组定义的干扰,数组本身就应该是上面的概念

再谈值类型与引用类型

值类型: 当值类型数据需要被传递时,传递的总是内存中的数据的副本

引用类型:引用类型需要被传递时,传递的总是对应数据的首地址

面试官想听到的数组是什么样子的

从下面三个方向回答(这里就不考虑内存回收策略了,太复杂),不太较真的面试官应该会给满分。

  1. 数组申请内存时需要先清空对应内存的数据

    数组在被创建时,会将对应的内存空间全部初始化为0
    
  2. 数组是固定大小的,用完时无法容纳更多数据

     在数组被创建的时候,通常必须指定数组元素的个数,个数*每个元素的大小=数组的总大小。
     所以在创建数组的时候,其实已经占用了对应大小的内存,不管你放不放真实数据,内存已经被消耗了。
    
  3. 数组要保存容量之外的数据,需要使用更大的数组(或者说是数组扩容,大家都是这么回答,但是我觉得不太准确)

    通常,当数组放不下更多数据时,几乎所有的语言扩容策略都是将原来数组的大小*2,重新申请一块内存,并将原来数组中的数据复制过来。  
    
    由此,我们可以看到数组的扩容极其麻烦,如果数组本身就很大,那扩容将是一场灾难。
    

Go中的数组

在Go语言中定义了数组是值类型,为什么是值类型?

package main

import (
	"fmt"
)

func main() {
	a:=[10]int8{}
	b:=a
	fmt.Printf("a:%p  b:%p",&a,&b)
}

# out
a:0xc000102070  b:0xc000102080

a,b不同的内存地址,说明了b数组开辟了新的内存空间,a将数据复制到了b对应的内存中

Go中的切片

在Go语言中切片是数组的引用,指向的是一个数组的地址。这句话怎么解释?

package main

import (
	"fmt"
	"reflect"
)

func main() {
	a:=[10]int8{}
	b:=a[1:]
	fmt.Printf("a:%s	b:%s\n", reflect.TypeOf(a),reflect.TypeOf(b))
	fmt.Printf("a:%p  a[1]:%p  b:%p b len:%d  b cap:%d\n",&a,&(a[1]),&b,len(b),cap(b))
}
# out
a:[10]int8	b:[]int8
a:0xc0000120a0  a[1]:0xc0000120a1  b:0xc000004078 b len:9  b cap:9

从上面的例子可以看到,a是一个数组,b是一个切片,但b并不指向a[1]元素的地址,这样就说明了,在创建b时,首先创建了一个大小为9个元素的新数组,将a的第1个到最后一个元素复制到了b中。

package main

import "log"

func main() {
	arr1:=[4]int{1,2,3,4}
	arr := []int{1, 2, 3, 4}
	changeArr1(arr1)
	log.Println(arr1)
	changeArr(arr1[:])
	log.Println(arr1)
	changeArr(arr)
	log.Println(arr)
}
func changeArr1(arr [4]int){
	for i := 0; i < len(arr); i++ {
		arr[i] = 0
	}
}
func changeArr(arr []int) {
	for i := 0; i < len(arr); i++ {
		arr[i] = 0
	}
}

# out
2021/03/26 23:16:40 [1 2 3 4]
2021/03/26 23:16:40 [0 0 0 0]
2021/03/26 23:16:40 [0 0 0 0]

从这个例子可以看出arr1的参数按值类型传递,arr的参数是按引用传递的(其实不是按引用传递,还是按值传递的,为什么?接着往下看)。

切片虽然好用,但要慎用

在Go中最常用的莫过于切片,这都归功于切片提供的一系列使用api,这里我就不解释这些api了,网上教程一大把,自选吧。

这里我们主要解释以下为什么要慎用切片,我觉得有以下两个问题需要注意:
1. 切片扩容

上面我们已经解释了数组的扩容,也知道了,切片其实指向的是一个数组的首个元素的内存地址,除此之外切片还有一个len与cap字段分别表示当前的元素个数以及最大能容纳的元素个数,扩容其实就是数组扩容,只不过扩容后切片的指针会指向新的数组的首个元素地址。

arr:=make([]int,10,20)
log.Println(len(arr))
log.Printlncap(arr)

# out
10
20

2. 切片的传递
由于切片扩容后,会导致指向的地址发生变化,所以我们应尽可能的将切片的使用限制在函数、代码段中,避免将切片的传递。

注意一: 切片指向的数组不需要扩容时

package main

import "log"

func main() {
	arr := make([]int, 5, 10)
	log.Println(arr)
	log.Printf("arr的地址:%p",&arr
        // 将arr作为参数传入change中
	change(arr)
	log.Printf("arr的地址:%p",&arr)
	log.Println(arr)

}

// 改变传入参数中元素的值,但不改变参数的元素个数
func change(arr1 []int) {
	arr1[0] = 1
	arr1[1] = 2
	log.Printf("arr1的地址:%p", &arr1)

}

# out
2021/03/30 00:06:53 [0 0 0 0 0]
2021/03/30 00:06:53 arr的地址:0xc000004078
2021/03/30 00:06:53 arr1的地址:0xc0000040f0
2021/03/30 00:06:53 arr的地址:0xc000004078
2021/03/30 00:06:53 [1 2 0 0 0]

上面代码中,arr与arr1虽然指向了不同的地址,但是change函数改变了传入参数arr1的值,导致了arr的值也发生了变化,这也说明arr,arr1其实指向的还是一个地址,切片数据类型内有一个字段用来保存数组的地址,所以在数组不需要扩容的时,导致指向的是一个地址。

注意二:当切片指向的数组需要扩容时

package main

import "log"

func main() {
	arr := make([]int, 5, 10)
	log.Println(arr)
	log.Printf("arr的地址:%p",&arr)
	change(arr)
	log.Printf("arr的地址:%p",&arr)
	log.Println(arr)

}

func change(arr1 []int) {
	arr1[0]=111
	arr1 = append(arr1, 1,2,3,4,5,6)
	log.Println("arr1:",arr1)
	log.Printf("arr1的地址:%p", &arr1)
}
# out
2021/03/30 00:18:15 [0 0 0 0 0]
2021/03/30 00:18:15 arr的地址:0xc000004078
2021/03/30 00:18:15 arr1: [111 0 0 0 0 1 2 3 4 5 6]
2021/03/30 00:18:15 arr1的地址:0xc0000040f0
2021/03/30 00:18:15 arr的地址:0xc000004078
2021/03/30 00:18:15 [111 0 0 0 0]

从上面代码我们可以看到,当arr1修改arr1[0]时影响了arr,但后续的append与arr1[2]却没有影响arr,这也就说明了,其实在扩容前,aar与arr1中的数组地址是一样的,但是arr1扩容后,指向的数组地址发生了变化,所以无法影响arr中的值。

总结

仅针对Go语言的实现

  1. 数组和切片都是值类型

  2. 数组是固定大小的,不可扩容

  3. 切片是不固定大小的,可以扩容,切片内其实还是包涵了一个数组

  4. 切片被传递时,如果不改变容量大小时,指向的数组地址传递前后一致,否则,改变大小的切片会指向新的数组

  5. 使用切片前要明确提前预知被传递的切片会发生什么样的变化

  6. 尽量将切片变量作为全局变量使用,当切片在代码断中使用时,尽量避免被传递