Go 语言笔试面试题
基础语法
1. = 和 := 的区别?
1. = 和 := 的区别?:= 声明+赋值
= 仅赋值
var foo int
foo = 10
// 等价于
foo := 102. 指针的作用
指针用来保存变量的地址。
例如
var x = 5
var p *int = &x
fmt.Printf("x = %d", *p) // x 可以用 *p 访问*运算符,也称为解引用运算符,用于访问地址中的值。&运算符,也称为地址运算符,用于返回变量的地址。
3. Go 有异常类型吗?
Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。
4. 什么是协程(Goroutine)
Go 协程,也称为 Goroutine,是 Go 语言中一种轻量级的线程实现方式,它可以在单个线程中运行成千上万个协程,每个协程都是一个独立的执行单元,可以并发执行不同的任务。
与传统线程相比,协程的优势在于其轻量级、高效、可扩展和易于使用。在 Go 语言中,协程的创建和销毁非常快速,可以在更小的内存空间中运行,而且可以通过通道(Channel)进行通信和同步,避免了线程之间的竞争和锁的使用。
在 Go 语言中,通过 go 关键字可以创建一个协程,例如:
5. 如何高效地拼接字符串
在 Go 语言中,字符串是一种不可变的数据类型,因此每次修改字符串都会创建一个新的字符串对象,这样会导致不必要的内存分配和性能损失。为了高效地拼接字符串,我们可以使用以下几种方法:
使用 strings.Builder 类型
strings.Builder 类型是 Go 语言中专门用于字符串拼接的类型,它支持高效的字符串缓冲区操作,可以避免不必要的内存分配和复制。使用 strings.Builder 类型拼接字符串的示例如下:
使用 bytes.Buffer 类型
bytes.Buffer 类型是 Go 语言中用于缓存字节序列的类型,它也可以用于高效地拼接字符串。使用 bytes.Buffer 类型拼接字符串的示例如下:
使用 fmt.Sprintf 函数
fmt.Sprintf 函数可以将多个字符串格式化为一个字符串,可以用于高效地拼接字符串。使用 fmt.Sprintf 函数拼接字符串的示例如下:
总之,以上三种方法都可以高效地拼接字符串,具体使用哪种方法取决于具体的场景和需求。
6. 什么是 rune 类型
ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。
Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。例如下面的例子中 语 和 言 使用 UTF-8 编码后各占 3 个 byte,因此 len("Go语言") 等于 8,当然我们也可以将字符串转换为 rune 序列。
7. 如何判断 map 中是否包含某个 key ?
可以使用以下方式判断 map 中是否包含某个 key:
其中,value 为 map 中对应 key 的值,ok 为 bool 类型,表示是否存在该 key。如果存在,则 ok 为 true,否则为 false。
8. Go 支持默认参数或可选参数吗?
Go 语言不支持默认参数或可选参数的语法(python 支持),也不支持方法重载(java支持)。
在函数定义中,需要明确指定每个参数的类型和名称,并且在函数调用时必须传入所有参数。如果需要实现类似默认参数或可选参数的功能,可以通过函数重载或使用结构体参数来实现。
使用结构体参数可以将多个参数封装为一个结构体,并在结构体中定义默认值。这样,在函数调用时只需要传入需要修改的参数即可。例如:
9. defer 的执行顺序
在 Go 中,使用 defer 语句可以将函数推迟到当前函数返回之前执行。defer 语句通常用于释放资源、解锁锁、记录日志等操作。当函数中包含多个 defer 语句时,它们的执行顺序是“后进先出”的,即最后一个 defer 语句会最先执行,而第一个 defer 语句会最后执行。
例如,下面的代码中包含了三个 defer 语句:
在执行 foo 函数时,先输出 foo,然后依次执行三个 defer 语句,输出结果如下:
因为最后一个 defer 语句会最先执行,所以输出结果中先输出了 defer 3,依次类推。需要注意的是,defer 语句中的函数参数会在执行 defer 语句时被计算,而不是在函数返回时被计算,这也是 defer 语句的一个常见坑点。
10. 如何交换 2 个变量的值?
在 Go 中,可以通过使用中间变量来交换两个变量的值。例如:
在上面的代码中,我们首先将变量 a 的值赋给了 temp,然后将变量 b 的值赋给了 a,最后将 temp 的值赋给了 b,从而完成了变量值的交换。
除此之外,还可以使用 Go 语言中的多重赋值特性来交换两个变量的值,例如:
在上面的代码中,我们利用了 Go 语言中的多重赋值特性,将变量 b 的值赋给了 a,同时将变量 a 的值赋给了 b,从而完成了变量值的交换。
11. Go 语言 tag 的用处?
在 Go 语言中,可以在结构体的字段上添加 tag(标签),用于给字段添加元数据。tag 是一个字符串,可以包含多个键值对,每个键值对之间使用逗号分隔。tag 的一些常见用途包括:
序列化和反序列化:可以使用 tag 指定结构体字段在序列化和反序列化时的名称、类型、顺序等信息,以便于进行数据转换。
数据校验:可以使用 tag 指定结构体字段的校验规则,例如数据类型、长度、必填等信息,以便于进行数据校验。
ORM 映射:可以使用 tag 指定结构体字段在数据库中的映射关系,例如表名、字段名、索引等信息,以便于进行 ORM 映射。
下面是一个使用 tag 的示例:
在上面的代码中,我们定义了一个名为 User 的结构体,其中的每个字段都添加了 tag。该结构体中的 ID 字段使用了两个 tag,json:"id" 表示在序列化和反序列化时使用 id 作为字段名称,db:"user_id" 表示在 ORM 映射时使用 user_id 作为字段名。Username 和 Password 字段都使用了 json、db 和 validate 三个 tag,分别表示序列化和反序列化、ORM 映射、数据校验时的信息。
12. 如何判断 2 个字符串切片(slice) 是相等的?
go 语言中可以使用反射 reflect.DeepEqual(a, b) 判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。
通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。
13. 字符串打印时,%v 和 %+v 的区别
%v 和 %+v 的区别%v 和 %+v 都可以用来打印 struct 的值,区别在于 %v 仅打印各个字段的值,%+v 还会打印各个字段的名称。
但如果结构体定义了 String() 方法,%v 和 %+v 都会调用 String() 覆盖默认值。
14. Go 语言中如何表示枚举值(enums)?
Go 语言中没有枚举类型,但可以使用 const 常量来模拟枚举值。例如:
在上面的例子中,我们使用 const 定义了一组常量,每个常量代表一周的某一天。我们可以将这些常量看作是枚举值,通过常量名来表示某个枚举值。
15. 空 struct{} 的用途
使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。
比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。
再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。
再比如,声明只包含方法的结构体。
实现原理
01 init() 函数是什么时候执行的?
init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。
一句话总结: import –> const –> var –> init() –> main()
示例:
02 Go 语言的局部变量分配在栈上还是堆上?
由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。
foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。
03 2 个 interface 可以比较吗 ?
Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况
两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
类型 T 相同,且对应的值 V 相等。
看下面的例子:
stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。
04 2 个 nil 可能不相等吗?
可能。
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
两个接口值比较时,会先比较 T,再比较 V。
接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
上面这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil。
但是当 i 与 nil 比较时,会将 nil 转换为接口 (T=nil, V=nil),与i (T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。
05 简述 Go 语言GC(垃圾回收)的工作原理
最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。
标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。
标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。
三色标记算法将程序中的对象分成白色、黑色和灰色三类。
白色:不确定对象。
灰色:存活对象,子对象待处理。
黑色:存活对象。
标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。
三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。
三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:
正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。
为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。
一次完整的 GC 分为四个阶段:
1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
2)使用三色标记法标记(Marking, 并发)
3)标记结束(Mark Termination,需 STW),关闭写屏障。
4)清理(Sweeping, 并发)
参考 fullstack
06 函数返回局部变量的指针是否安全?
这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。
07 非接口非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?
*T 的方法吗?反过来呢?一个T类型的值可以调用为
*T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T声明的方法。反过来,一个
*T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T自动隐式声明一个同名和同签名的方法。
哪些值是不可寻址的呢?
字符串中的字节;
map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
常量;
包级别的函数等。
举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。
01 无缓冲的 channel 和有缓冲的 channel 的区别?
对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。
例如:
02 什么是协程泄露(Goroutine Leak)?
协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:
缺少接收器,导致发送阻塞
这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。
缺少发送器,导致接收阻塞
那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。
死锁(dead lock)
两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。
无限循环(infinite loops)
这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。
03 Go 可以限制运行时操作系统线程的数量吗?
The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.
可以使用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(num int) 设置,例如:
从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。
变量与常量
下列代码的输出是:
答案:
golang 100 true
在同一个 const group 中,如果常量定义与前一行的定义一致,则可以省略类型和值。编译时,会按照前一行的定义自动补全。即等价于
下列代码的输出是:
答案:
编译失败:cannot use M (type int32) as type int in assignment
Go 语言中,常量分为无类型常量和有类型常量两种,const N = 100,属于无类型常量,赋值给其他变量时,如果字面量能够转换为对应类型的变量,则赋值成功,例如,var x int = N。但是对于有类型的常量 const M int32 = 100,赋值给其他变量时,需要类型匹配才能成功,所以显示地类型转换:
下列代码的输出是:
答案:
-128
int8 能表示的数字的范围是 [-2^7, 2^7-1],即 [-128, 127]。-128 是无类型常量,转换为 int8,再除以变量 -1,结果为 128,常量除以变量,结果是一个变量。变量转换时允许溢出,符号位变为1,转为补码后恰好等于 -128。
对于有符号整型,最高位是是符号位,计算机用补码表示负数。补码 = 原码取反加一。
例如:
下列代码的输出是:
答案:
编译失败:constant 128 overflows int8
-128 和 a 都是常量,在编译时求值,-128 / a = 128,两个常量相除,结果也是一个常量,常量类型转换时不允许溢出,因而编译失败。
作用域
下列代码的输出是:
答案:
1 err
:= 表示声明并赋值,= 表示仅赋值。
变量的作用域是大括号,因此在第一个 if 语句 if err == nil 内部重新声明且赋值了与外部变量同名的局部变量 err。对该局部变量的赋值不会影响到外部的 err。因此第二个 if 语句 if err != nil 不成立。所以只打印了 1 err。
defer 延迟调用
下列代码的输出是:
答案:
132
defer 延迟调用时,需要保存函数指针和参数,因此链式调用的情况下,除了最后一个函数/方法外的函数/方法都会在调用时直接执行。也就是说 t.f(1) 直接执行,然后执行 fmt.Print(3),最后函数返回时再执行 .f(2),因此输出是 132。
下列代码的输出是:
答案:
1
打印 1 而不是 101。defer 语句执行时,会将需要延迟调用的函数和参数保存起来,也就是说,执行到 defer 时,参数 n(此时等于1) 已经被保存了。因此后面对 n 的改动并不会影响延迟函数调用的结果。
下列代码的输出是:
答案:
101
匿名函数没有通过传参的方式将 n 传入,因此匿名函数内的 n 和函数外部的 n 是同一个,延迟执行时,已经被改变为 101。
下列代码的输出是:
答案:
先打印 101,再打印 1。defer 的作用域是函数,而不是代码块,因此 if 语句退出时,defer 不会执行,而是等 101 打印后,整个函数返回时,才会执行。
Last updated
Was this helpful?