1. 基础语法篇
1.1 =
和:=
的区别是什么?
:=
是简短变量声明语句,用于在函数内部快速声明一个局部变量,无法用在函数外部,作用在编译阶段。
=
是赋值语句,用于给已经声明的变量赋值,作用在运行时。
1.2 golang中make和new的相同点和不同点?
相同点:
make
和new
都是golang
的内建函数,可以直接在代码中使用,无需导入其他包;make
和new
都用于分配内存,但分配的方式和用途有所不同。
不同点:
使用类型:new
类型用于为 值类型(如基本数据结构,结构体等) 分配内存并返回其指针,而make
用于为引用类型(如切片、map、通道等) 分配内存并初始化(new只分配内存,不初始化)。
参数不同: new
函数只接受一个参数,即一个类型,返回一个指向该类型零值的指针;而make函数可以接受多个参数,具体取决于所创建的引用类型。
返回值不同: new函数返回一个指向新分配的零值的指针,而make函数返回一个初始化后的引用类型(如切片、映射、通道),而不是指针。
1.2.1 如果用new创建一个引用类型的变量会发生什么?
mpPtr := new([]int)
slcPtr := new(map[string]int)
fmt.Println((*mpPtr) == nil) // true
fmt.Println((*slcPtr) == nil) // true
1.3 for range的时候它的地址会发生变化么?
在for a,b := range c
遍历中, a 和 b 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,a,b 的内存地址始终不变。由于有这个特性,for 循环里面如果开协程,不要直接把 a 或者 b 的地址传给协程。解决办法:在每次循环时,创建一个临时变量。
同理,for i := 0; i < len(dirs); i++
遍历中的i
变量也会有相同的问题。
2. 内置数据结构篇
2.1. slice相关
2.1.1 slice的原理是什么?底层是如何实现的?
slice
的底层由数组实现。一个slice
由三个部分构成:指针、长度和容量。底层数据结构如下:
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片当前的长度
cap int // 切片的容量
}
指针指向slice
的起始元素地址(这里需要注意的是起始元素地址并不一定是底层数组的起始元素);
长度对应的是slice
中元素的数目,容量一般是指从slice
开始位置到底层数组的结尾位置的大小。
长度不能超过容量。
内置的len
函数可以用来返回slice
的长度,cap
函数可以用来返回slice
的容量。
s := []int{0, 1, 2, 3, 4, 5, 6, 7}
fmt.Println(len(s), cap(s)) // 8 8
2.1.2 在向slice添加元素时,slice是如何扩容的?
go<=1.17
- 在go 1.17版本以及之前的版本中,如果数组容量小于1024,则判断所需容量是否大于原来容量的两倍,如果大于的话,当前容量+所需容量,如果小于,当前容量乘2。
- 如果当前容量大于1024,则会起一个循环,每次增加25%的容量,直到扩容后的容量大于所需容量。
go>=1.18
在go 1.18之后,以256为临界点。
- 当新切片需要的容量大于两倍扩容的容量时,则直接按照新切片需要的容量进行扩容;
- 当原来切片的容量小于256,新的容量变为之前的2倍。
- 当原来的切片容量大于256,进入一个循环,每次容量增加(旧容量+3*256)/4;
2.1.3 使用切片时需要注意什么?
基于一个base slice
切片创建一个新的slice
时,两个slice
共享同一个底层数组,对于其中一个的元素做修改会影响到另一个。
s := []int{0, 1, 2, 3, 4, 5, 6, 7}
s2_5 := s[2:5]
reverse(s2_5) // 对切片中的元素做反转
fmt.Println(s) // [0 1 4 3 2 5 6 7]
fmt.Println(s2_5) // [4 3 2]
如何避免:
- 使用
copy
函数深拷贝slice
。
s := []int{0, 1, 2, 3, 4, 5, 6, 7}
s2_5 := make([]int, 3)
copy(s2_5, s[2:5])
reverse(s2_5) // 对切片中的元素做反转
fmt.Println(s) // [0 1 2 3 4 5 6 7] 原切片没变
fmt.Println(s2_5) // [4 3 2]
- 给原切片扩容,原切片经过一次扩容后,会有一次分配内存的操作,扩容后使用的数组不是原来的数组了。(不推荐使用)
s := []int{0, 1, 2, 3, 4, 5, 6, 7}
s2_5 := s[2:5]
s = append(s, 8) // 切片s扩容,指向新的数组
// 对切片中的元素做反转
fmt.Println(s) // [0 1 2 3 4 5 6 7] 原切片没变
fmt.Println(s2_5) // [4 3 2]
2.1.4 数组和切片的区别是什么?
- 从定义上来说:数组是长度固定的同一种数据类型的集合,其长度不可变;切片是一个引用类型的长度可变的数据类型,包含长度、容量特性,支持动态扩容。
- 定义时:数组需要指定长度;切片在定义时长度可以为空,也可以指定一个初始长度。
- 作为函数参数时:数组作为函数参数时,采用拷贝的形式,对参数数组的修改不会影响原数组;切片作为函数参数时,对切片的操作会影响到原始数据。
2.1.5 空切片和nil的区别
nil切片:声明为切片,但是没有分配内存,切片的指针是nil
var s []int
fmt.Println(s == nil) // true
空切片:切片指针指向了一个数组内存地址,但是数组是空的
s1 := []int{} //1.空切片,没有任何元素
s2 := make([]int, 0) //2.make 切片,没有任何元素
nil切片和空切片的本质区别就是: nil切片没有分配内存,空切片是有分配内存但底层指向的是一个空数组
2.2 Map相关
2.2.1 Map底层是怎么实现的?
Map
是用于存储键值对的集合,底层通过哈希表实现。哈希表是一种使用哈希函数将键映射到存储位置的数据结构,他使用一个数组(bucket或桶)来存储键值对,当我们插入一个键值对时,会使用哈希函数计算出键的哈希值,并根据哈希值来选择数组中的一个位置来存储值。
Go语言的map底层结构如下:
type hmap struct {
count int // 当前map中存储的键值对数量
flags uint8 // 保存一些标志位,如迭代器的状态等
B uint8 // bucket的位数,表示底层数组的长度为2^B
noverflow uint16 // 溢出桶的数量
hash0 uint32 // 哈希种子,用于增加哈希的随机性,防止哈希碰撞攻击
buckets unsafe.Pointer // 指向bucket数组的指针
oldbuckets unsafe.Pointer // 指向旧bucket数组的指针,用于map扩容时的迁移
nevacuate uintptr // 用于map扩容时迁移的标志位
extra *mapextra // 一些额外的字段,如迭代器指针等
}
其中,buckets指向一个bucket数组的指针,bucket是一个存储键值对的容器,每个bucket里面有一个或多个键值对。当插入新的键值对时,根据哈希值计算索引,找到对应的bucket,然后将键值对插入到bucket中。如果哈希值冲突,即多个键映射到同一个索引,这些键值对会按照链表形式存储在同一个bucket中。
当map进行扩容时,会创建一个新的bucket数组(通常是原数组大小的两倍),然后将所有键值对重新哈希并放入新的数组中,这个过程是比较耗时的。为了减少迁移带来的性能损耗,Go语言采用增量式迁移策略,即在多次操作中逐步完成迁移。
总结:
Go语言的map底层是通过哈希表实现的,使用了数组和链表结构来存储键值对。
当插入或查找键值对时,使用哈希函数计算键的哈希值,根据哈希值找到对应的位置。
扩容时,会创建新的数组,将键值对从旧数组迁移到新数组,以减少扩容带来的性能损耗。
2.2.2 空Map和nil的区别
nil:Map的零值,没有引用任何哈希表,map上的大部分操作,包括查找、删除、len和range循环都可以安全工作在nil值的map上,它们的行为和一个空的map类似。但是向一个nil值的map存入元素将导致一个panic异常。
var mp map[string]int
fmt.Println(mp == nil) // true
空map:空的map,指向一个大小为0哈希表
mp1 := make(map[string]int)
mp2 := map[string]int{}
fmt.Println(mp1 == nil) // false
fmt.Println(mp2 == nil) // false
在向map存值时必须创建map。
2.3 结构体
2.3.1 空struct有什么作用?
空结构体: 结构体没有任何成员的话就叫空结构体,写作struct{}
,它的大小是0,也不包含任何信息。作用如下:
- 实现
set
集合:在某些场景下,可以使用map[KeyType]struct{}
的形式实现一个set
集合,因为其大小为0,可以避免不必要的内存损耗。 - 实现通道信号:在并发编程中,如果在不同的
goroutine
之间进行状态传递场景下,可以使用struct{}
作为通道元素类型,用作通道信号。 - 只有方法的结构体:结合
type
可以实现只有方法的结构体。
2.4 函数相关
2.4.1 defer语句的执行顺序
defer
语句的执行顺序与声明顺序相反,类似与栈LIFO
(后进先出)。
2.4.2 在defer语句中修改return的返回值,return的值会发生变化吗?
这个需要区分情况:
函数无名
时,也就是返回值只有返回数据类型,没有指定返回值名称,函数签名如func test() int
,这种情况下,不会更改返回值,原因是:在执行return语句后,Go会创建一个临时变量保存返回值。 下面举个例子:
func test() int {
i := 0
defer func() {
fmt.Println("defer1")
}()
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}
func main() {
fmt.Println("return", test())
}
// defer2
// defer1
// return 0
函数有名时,也就是返回值指定了返回数据类型和返回值名称,函数签名如func test() (i int)
,这种情况下,返回值会被更改,原因是:在执行return语句后,Go并不会再创建临时变量,而是继续使用当前的变量。 下面举个例子:
func test() (i int) {
i = 0
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}
func main() {
fmt.Println("return", test())
}
// defer2
// return 1
2.4.3 init()函数是什么时候执行的?
init()
函数是golang
初始化的一部分,由runtime初始化每个导入的包,初始化是按照包之间依赖关系,最先初始化没有依赖的包。
每个包首先初始化包作用域内的常量和变量(常量优先于变量),然后执行init()
函数。
执行顺序:import
–> const
–> var
–>init()
–>main()
2.5 接口相关
2.5.1 go面向对象如何实现?
Go实现面向对象的两个关键是struct
和interface
。
- 封装:对于同一个包,对象对包内的文件可见,对于不同的包,需要将对象以大写开头(导出)才是可见的。
- 继承:继承是编译时特征,在
struct
中内嵌需要继承的类即可。 - 多态:多态是运行时特征,Go多态通过
interface
实现,类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。
Go支持多重继承,可以在类型中嵌入所有必要的父类型。
参考文档:
go语言圣经(中文版)
Go 语言设计与实现
Go 1.18 全新的切片扩容机制
Go 语言数组和切片的区别
Go常见面试题【由浅入深】2022版