Golang面试题详解(一):数组、切片、Channel与继承
Golang题库(一)
golang里的数组和切片有了解过吗?
值传递和引用传递
在函数传参中,数组是值传递,切片则是引用传递。即函数内修改数组,外不变,而切片则相反。
逻辑运算
数组能比较大小,切片则只能与nil
比较。
容量和长度
数组是连续地址的储存相同类型元素的序列,初始化容量之后,不可变。
切片是指向数组的拥有相同类型元素的可变长序列,可以扩容和传递,比数组更加灵活。
Go切片(slice)的实现可以在源码包src/runtime/slice.go
中找到。在源码中,slice的数据结构定义如下。
1 | type slice struct { |
容量表示能够储存的元素数量,长度表示已储存的元素数量。
切片拷贝
使用copy
内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片的最小值。
例如长度为10的切片拷贝到长度为5的切片时,将拷贝5个元素。也就是说,拷贝过程中不会发生扩容。
copy函数有返回值,它返回实际上复制的元素个数,这个值就是两个slice长度的较小值。
删除元素
很遗憾,Go语言中并没有提供直接删除指定位置元素的方式。不过根据切片的性质,我们可以通过巧妙的拼接切片来达到删除指定数据的目的。
1 | a = []int{1, 2, 3} |
切片陷阱
无法做比较
和数组不同的是,slice无法做比较,因此不能用==来测试两个slice是否拥有相同的元素。标准库里面提供了高度优化的函数
bytes.Equal
来比较两个字节slice。但是对于其它类型的slice,就必须要自己写函数来比较。slice唯一允许的比较操作是和nil进行比较,例如
1
if slice == nil {/*...*/}
空切片和nil切片
空切片和nil切片是不同的。
- nil切片中,切片的指针指向的是空地址,其长度和容量都为零。nil切片和nil相等。
- 空切片,切片的指针指向了一个地址,但其长度和容量也为0,和nil不相等,通常用来表示一个空的集合。
1
2
3
4
5var s []int // s == nil
var s = nil // s == nil
var s = []int{nil} // s == nil
var s = []int{} // s != nil
s := make([]int,0) // s != nil使用range进行切片迭代
当使用range进行切片迭代时,range创建了每个元素的副本,而不是直接返回对该元素的引用。如果使用该值变量的地址作为每个元素的指针,就会造成错误。
1
2
3
4
5
6
7func main() {
a := []int{1, 2, 3, 4, 5}
for i, v := range a {
fmt.Printf("Value: %d, v-addr: %X, Elem-addr: %X",v, &v, &a[i])
}
}
1 | output |
从结果中可以看出,使用range进行迭代时,v的地址是始终不变的,它并不是切片中每个变量的实际地址。而是在使用range进行遍历时,将切片中每个元素都复制到了同一个变量v中。如果错误的将v的地址当作切边元素的地址,将会引发错误。
切片扩容引发的问题
正因为有扩容机制。所以我们无法保证原始的slice和用append后的结果slice指向同一个底层数组,也无法证明它们就指向不同的底层数组。同样,我们也无法假设旧slice上对元素的操作会或者不会影响新的slice元素。所以,通常我们将append的调用结果再次赋给传入append的slice。
内置append函数在向切片追加元素时,如果切片存储容量不足以存储新元素,则会把当前切片扩容并产生一个新的切片。
append函数每次追加元素都有可能触发切片扩容,即有可能返回一个新的切片,这正是append函数声明中返回值为切片的原因,使用时应该总是接收该返回值。
建议
使用append函数时,谨记append可能会产生新的切片,并谨慎的处理返回值。
append函数误用
使用append函数时,需要考虑append返回的切片是否跟原切片共享底层的数组。下面这段程序片段,来看看函数返回的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//示例来源:Go专家编程
func AppendDemo() {
x := make([]int, 0, 10)
x = append(x, 1, 2, 3)
y := append(x, 4)
z := append(x, 5)
fmt.Println(x)
fmt.Println(y)
fmt.Println(z)
}
//output
[1 2 3]
[1 2 3 5]
[1 2 3 5]题目首先创建了一个长度为0,容量为10的切片x,然后向切片x追加了1,2,3三个元素。其底层的数组结构如下图所示
创建切片y为切片x追加一个元素4后,底层数组结构如下图所示
需要注意的是切片x仍然没有变化,切片x中记录的长度仍为3。继续向x追加元素5后,底层数组结构如下图所示
至此,答案已经非常明确了。当向x继续追加元素5后,切片y的最后一个元素被覆盖掉了。
此时切片x仍然为[1 2 3],而切片y和z则为[1 2 3 5]。
建议
一般情况下,使用append函数追加新的元素时,都会用原切片变量接收返回值来获得更新
1
a = append(a, elems...)
函数传参
Go语言中将切片作为函数参数传递会有什么神奇的现象,一起来看看下面这个示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26package main
import "fmt"
func main(){
a := []int{1, 2, 3} //长度为3,容量为3
b := make([]int, 1, 10) //长度为1,容量为10
test(a,b)
fmt.Println("main a =", a)
fmt.Println("main b =", b)
}
func test(a,b []int){
a = append(a, 4) //引发扩容,此时返回的a是一个新的切片
b = append(b, 2) //没有引发扩容,仍然是原切片
a[0] = 3 //改变a切片元素
b[0] = 3 //改变b切片元素
fmt.Println("test a =", a) //打印函数内的a切片
fmt.Println("test b =", b) //打印函数内的b切片
}
//output
test a = [3 2 3 4]
test b = [3 2]
main a = [1 2 3]
main b = [3]首先,我们创建了两个切片,a切片长度和容量均为3,b切片长度为1,容量为10。将a切片和b切片作为函数参数传入test函数中。
在test函数中,对a切片和b切片做了如下两点改动
- 分别使用append函数在a切片和b切片中追加一个元素
- 分别对a切片和b切片的第一个元素做了修改
分别在主函数中和test函数中输出两个切片,会发现在主函数中和test函数中两个切片好像改了,又好像没改,下面我们就来分析一下。
理论分析
当我们将一个切片作为函数参数传递给函数的时候,采用的是值传递,因此我们传递给函数的参数其实是上面这个切片三元组的值拷贝。当我们对切片结构中的指针进行值拷贝的时候,得到的指针还是指向了同一个底层数组。因此我们通过指针对底层数组的值进行修改,从而修改了切片的值。
但是,当我们以值传递的方式传递上面的结构体的时候,同时也是传递了
len
和cap
的值拷贝,因为这两个成员并不是指针,因此,当我们从函数返回的时候,外层切片结构体的len
和cap
这两个成员并没有改变。所以当我们传递切片给函数的时候,并且在被调函数中通过
append
操作向切片中增加了值,但是当函数返回的时候,我们看到的切片的值还是没有发生变化,其实底层数组的值是已经改变了的(如果没有触发扩容的话),但是由于长度len
没有发生改变,所以我们看到的切片的值也没有发生改变。题目再分析
有了前面的理论基础,我们再来分析一下a,b切片的返回结果。
a切片作为参数传至test函数中,在test中向a切片追加一个元素后,此时触发扩容机制,返回的切片已经不再是原切片,而是一个新的切片。后续对a切片中的第一个元素进行修改也是对新切片进行修改,对老切片不会产生任何影响。
所以,最终在主函数中a切片仍然为[1 2 3],而在test函数中a切片变成了[3 2 3 4]。
b切片作为参数传至test函数中,在test中向b切片追加一个元素后,不会触发扩容机制,返回的仍然是原切片,所以在后续对b切片的修改都是在原切片中进行的修改。故在test函数中b切片为[3 2]。但是在主函数中确为[3],可以看出在test中对切片进行修改确实反应到主函数中了,但是由于其len和cap没有改变,len仍为1,所以最终就只输出切片中的第一个元素[3],但其底层数组的值其实已经改变了。
扩容
- Go 语言中的数据和 C语言中的类似, 是一片连续的内存空间, 申请时需指定长度, 不能按需扩容
- 切片实际上是对数组的引用, 使用 make()函数创建,也可以直接赋值使用, 可以按需扩容, 一次扩容的量为caps 的两倍(<=1.17版本为len小于1024 时, 扩容为caps的两倍, len大于1024时, 会形成一个循环, 每次扩容caps的25%,知道满足容量需求. Go1.18 改变了这个机制)
Go1.18不再以1024为临界点,而是设定了一个值为256的threshold,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3256)/4;
■ 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
■ 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
■ 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3threshold)/4。
- 需要注意切片是对数组的引用, 所以当切片被赋值给别的切片变量时, 改变新的切片变量中的值, 会连带改变原切片值
对已经关闭的channel进行读写操作会发生什么?
读
- 读已经关闭的channel无影响。
- 如果在关闭前,通道内部有元素,会正确读到元素的值;如果关闭前通道无元素,则会读取到通道内元素类型对应的零值。
- 若遍历通道,如果通道未关闭,读完元素后,会报死锁的错误。
1 | fatal error: all goroutines are asleep - deadlock! |
写
- 写已关闭的通道
1 | /*[Output]: panic: send on closed channel*/ |
- 关闭已关闭的通道
1 | /*[Output]: panic: close of closed channel */ |
Go语言中是如何实现继承的?
Go
与C++
、Java
这样的面向对象语言不同,使用另外一种方式实现类似继承的效果。Go
的method
能够指定接收者,它可以是一种结构体,并且它可以是任意类型。
结构体嵌套
在Go语言中,可以通过结构体组合来实现继承,示例如下:
1 | // 这里Student继承了People,具有People的属性 |
与继承不一样的是,结构体能够通过组合选择所需要继承的方法。
接口封装
Go 中的接口是一个抽象类型,描述了对象可以接受的行为。通过实现接口,可以让不同的类型拥有相同的方法,从而实现多态性。接口与继承类似,但是接口是基于行为的而不是基于类型的。因此,通过接口实现的多态性可以更加灵活和动态。