go语言常见面试题(持续更新中)

1. 基础语法篇

1.1 =:=的区别是什么?

:=简短变量声明语句,用于在函数内部快速声明一个局部变量,无法用在函数外部,作用在编译阶段
=是赋值语句,用于给已经声明的变量赋值,作用在运行时

1.2 golang中make和new的相同点和不同点?

相同点:

makenew都是golang内建函数,可以直接在代码中使用,无需导入其他包;makenew都用于分配内存,但分配的方式和用途有所不同。

不同点:
使用类型: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]

如何避免:

  1. 使用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]
  1. 给原切片扩容,原切片经过一次扩容后,会有一次分配内存的操作,扩容后使用的数组不是原来的数组了。(不推荐使用)
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,也不包含任何信息。作用如下:

  1. 实现set集合:在某些场景下,可以使用map[KeyType]struct{}的形式实现一个set集合,因为其大小为0,可以避免不必要的内存损耗。
  2. 实现通道信号:在并发编程中,如果在不同的goroutine之间进行状态传递场景下,可以使用struct{}作为通道元素类型,用作通道信号。
  3. 只有方法的结构体:结合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实现面向对象的两个关键是structinterface

  1. 封装:对于同一个包,对象对包内的文件可见,对于不同的包,需要将对象以大写开头(导出)才是可见的。
  2. 继承:继承是编译时特征,在struct中内嵌需要继承的类即可。
  3. 多态:多态是运行时特征,Go多态通过interface实现,类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。

Go支持多重继承,可以在类型中嵌入所有必要的父类型。

参考文档:

go语言圣经(中文版)
Go 语言设计与实现
Go 1.18 全新的切片扩容机制
Go 语言数组和切片的区别
Go常见面试题【由浅入深】2022版

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值