跳过正文
  1. 面试题库/

01|基础相关

·7139 字·15 分钟
目录
Golang面试题库 - 这篇文章属于一个选集。
§ 1: 本文

1. 相比较于其他语言,Go 有什么优势或者特点?
#

分析

这是一个开放性题目,言之有理即可,主要考察对go语言整体的一个理解和感受,看看对go语言了解是否全面,可以从语法层面,是否支持跨平台编译,以及对并发编程的支持方式以及协程支持等几个方面来分析

回答

  • Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行。
  • Go 在语言层次上天生支持高并发,通过 goroutine 和 channel 实现。channel 的理论依据是 CSP 并发模型,即所谓的通过通信来共享内存;Go 在 runtime 运行时里实现了属于自己的调度机制GMP,降低了内核态和用户态的切换成本。
  • Go 的语法简单,代码风格比较统一

2. Go 是面向对象的吗
#

分析

Go官网的回答中提到,Yes or No。也就是说Go不是面向对象语言,但也可以进行面向对象风格的编程,Go可以看作是一种泛化的面向对象,他不像Java那样那么规范,Go的对象没有层次结构,但也使得Go的对象比Java中的对象更轻量级。

回答

我之前有阅读过Go官方QA文档,答案是Yes and No,也就是说Go不是面向对象语言,但也可以进行面向对象风格的编程,在Go里面,实现面向对象三大特性是这样的:

  • 封装: Go语言里面字段首字母大小写来决定字段是否可以被外包访问
  • 继承: Go语言里面用组合结构体的方式 或 接口继承来实现
  • 多态: Go语言中通过接口来实现多态,不同的类型实现对应接口,然后调用接口变量的方法,结果取决于接口存储的对应类型的方法

3. Go 中 make 和 new 的区别?(基本必问)
#

分析

考察go语言的基础,对象的创建方式,make和new都可以用来创建对象,但是make创建对象有一定的限制,回回我们平常写代码的过程,只是针对某些特定的数据类型一般用make来创建,比如slice,map,还有channel,以及建完之后,返回值是什么?回答的要点要突出创建对象的区别以及返回值类型。

回答

  • make 只能用来分配及初始化类型为 slice、map、chan 的数据。new 可以分配任意类型的数据;
  • new 分配返回的是指针,即类型 *Type。make 返回数据类型本身,即 Type;
  • new 分配的空间被清零。make 分配空间后,会进行初始化。

4. 数组和切片的区别(基本必问),切片怎么扩容
#

区别
#

分析

数组和切片在编程中都会用到,都是用来存储一组相同数据的内存连续的数据结构,主要区别在于长度是否固定且数据类型的性质,是否是引用数据类型,比如在做函数参数传递时、是否会影响到原数据(数组或切片),这就需要了解切片的底层实现,回顾下go语言切片的底层实现。

切片的底层数据结构定义如下:

type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

切片

slice 结构包含三个字段,array 类型为 unsafe.Pointer,还有两个int类型的字段len和cap。

  • array: 是一个指针变量,指向一块连续的内存空间,即底层数组结构
  • len: 当前切片中数据长度
  • cap: 切片的容量

注意:cap是大于等于len的,当cap大于len的时候,说明切片未满,它们之间的元素并不属于当前切片。

回答

  • 数组是值类型,长度固定
  • 切片是引用类型,长度不固定,可以动态扩容

怎么扩容
#

分析

这个问题其实是对上个问题的补充提问,因为切片的长度不固定,可以动态扩容,所以需要了解其具体的扩容策略是怎样的,这里回答的要点是需要区分go的版本,在go117之前和之后扩容策略是不一样的

回答

1.17及以前:

  • 如果期望容量大于当前容量的两倍就会使用期望容量;
  • 如果当前切片的容量小于 1024 就会将容量翻倍;
  • 如果当前切片的容量大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

1.18之后:

扩容

扩容公式

newcap = oldcap + (oldcap + 3*256) / 4

5. for i,v := range切片,v地址会变化吗
#

分析

for range是面试中经常会出现的一个问题,用于考察面试者语言基础是否扎实。主要会考察写代码时经常遇到的的些坑,这里就要对for range整个语法糖有一个深层次的了解,for range在遍历的时候,其实它的底层实现是这样的,会对原切片做一次拷贝,确定其值和长度,遍历数组中每个元素的时候都把这个值赋给同一个临时变量,所以每次遍历拿到的是同一个地址,但是值不同

回答

地址不会发生变化,但是该地址的值是变化的,每遍历到一个元素,就把该元素值就会写到该地址。

PS: 在最新版本Go1.22中,v的地址会变化的,也就是不再共享变量了,知道这点的话,在面试一定要提出来,展示自己的技术面广

6. go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?(defer 和return)
#

6.1 defer 的执行顺序
#

分析

关于go语言中的defer,需要明确defer的作用和执行机制,一般用defer来做什么,优势在什么地方,defer在函数返回前执行过程又是怎样的?在回答的时候要突出顺序是LIFO这个特性,接着可以简单介绍下defer底层实现是怎么实现的,可以回顾defer的底层实现。

底层存储如下图:

defer

defer函数在注册的时候,创建的_defer 结构会依次插入到_defer 链表的表头,在当前函数return的时候,依次从_defer链表的表头取出_defer结构执行里面的fn函数,所以执行顺序是LIFO。

回答

defer的执行顺序类似于栈,是LIFO,先调用的defer语句后执行

6.2 defer 在什么时机会修改返回值
#

分析

defer在return的时候有机会修改返回值,return的过程可以被分解为以下三步:

  1. 设置返回值
  2. 执行defer语句
  3. 将结果返回

defer recover 的问题?(主要是能不能捕获)

  1. 用recover捕获异常时,只能捕获当前goroutine的panic,不能捕获其他goroutine发生的panic
  2. 一个recover只能捕获一次panic,且一一对应

7. uint 类型溢出
#

分析

关于整形溢出主要是考察go语言基本数据类型的大小范围是个否了解,以及各种数据类型的占用和空间大小,理了go语言基本数据类型的大小范围,溢出主要是关注无符号整数的溢出

类型有无符号占用存储空间表数范围备注
int32位系统4个字节
64位系统8个字节
-2^31 ~ 2^31-1
-2^63 ~ 2^63-1
uint32位系统4个字节
64位系统8个字节
0 ~ 2^32-1
0 ~ 2^64-1
rune与int32一样-2^31 ~ 2^31-1等价于int32,表示Unicode码
byte与uint8等价0 ~ 255当要存储字符时选用byte

uint8大小为1个字节,占8位,byte其实就是uint8的别名,uint8的溢出情况举例

var a uint8 = 255
var b uint8 = 1
a + b = 0 // 溢出

8. 介绍rune 类型
#

分析

主要考察对go语言基本数据类型有没有细致的了解,rune类型是go语言一种特殊的数据类型,回答的时候重点要突出rune类型具体在底层对应什么数据类型(int32),以及它的作用是用作处理字符的

  • uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符
  • rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。
package main
import "fmt"

func main(){

var str ="hello 你好" //思考下 len(str)的长度是多少? //golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算 //所以一个汉字占3个字节算了3个长度
fmt.Printin("len(str):"len(str)) //len(str):12 //通过rune类型处理unicode字符 
fmt.Println("rune:",len([]rune(str))) //rune: 8
}

回答

rune类型是 Go 语言的一种特殊数字类型。在builtin/builtin.go文件中,它的定义: type rune =int32;官方对它的解释是:rune是类型int32的别名,在所有方面都等价于它,用来区分字符值跟整数值。使用单引号定义,返回采用 UTF-8 编码的 Unicode 码点。Go 语言通过rune处理中文,支持国际化多语言。

Go中两个Ni可能不相等吗?
#

分析

两个数据要进行比较,首先得明白数据类型,对于两个nil的比较同样如此,这里主要得注意interface类型,因为interface类型是类型T和值V二者的综合,只有在类型T和值V都相等的情况下,两个interface才会相等.

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含2个字段,类型丅和 值 V。一个接口等于nil,当且仅当T和V处于 unset 状态(T=nil,V is unset)。

两个接口值比较时,会先比较T,再比较V。接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。

func main() {
    var p *int = nil
    var i interface{} = p

    fmt.Println(i == p)   // true
    fmt.Println(p == nil) // true
    fmt.Println(i == nil) // false
}
  • 例子中,将一个nil非接口值p赋值给接口i,此时,i的内部字段为(T=*int,V=nil),ij与p作比较时,将p转换为接口后再比较,因此i == p,p 与 nil 比较,直接比较值,所以p == nil。

  • 但是当i与nil比较时,因为i为接口指,会将nil转换为接口(T=nil, V=nil),与i(T=*int, V=nil)不相等,因此i != nil。因此V为 nil ,但T不为 nil 的接口不等于 nil。

回答

Go中两个Nil可能不相等,当一个接口类型的变量为nil和一个非接口类型的变量也为nil的时候,虽然两者都为nil,但是却不相等。

10. golang 中解析 tag 是怎么实现的?反射原理是什么?(问的很少,但是代码中用的多)
#

解析 tag
#

分析

首先要明白,tag是什么以及tag的规则,go语言中的tag就是结构体中的各个字段的一个标签, Tag本身是一个字符串,它是 以空格分隔的 key:value 对,

  • key:必须是非空字符串,不能包含控制字符、空格、引号、冒号
  • value:以双引号标记的字符串
  • 注意:冒号前后不能有空格

举个例子:

type Student struct {
    Name string `key1:"value1" key2:"value2"` //名字
    Age  uint   `key3:"value3"` //年龄
}

``之间的就是一个tag,第一个问题就是要回答怎样获取这个tag的值,一般是通过反射来实现。

回答

Go 中解析的 tag 是通过反射实现的。

反射的实现原理
#

分析

主要考察反射的实现机制,即获取数据的动态类型和动态值,联想一下我们学习的知识,在哪一节讲过动态类型和动态值,就是interface,所以这题回答的关键点就是要点出interface,然后介绍下,interface的底层实现。

回答

反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力。

Go语言反射是通过接口来实现的,通过隐式转换,普通的类型被转换成interface类型,这个过程涉及到类型转换的过程,首先从Golang类型转为interface类型,再从interface类型转换成反射类型,再从反射类型得到想得的类型和值的信息。

11. go struct 能不能比较?
#

分析

主要考察对go语言基本数据类型的掌握程度。其实本质就是考察哪些数据机构不能比较,哪些可以比较。在go语言中,回答的时候,要明确可比较的范围,很明显,两个不同类型的数据类型不能进行比较,struct包含不可比较的字段也是不可比较的。

回答

  1. 对于不同类型的struct无法进行比较;而同一个struct的两个实例可比较也不可比较。
  2. 在Go中,Slice、map、func无法比较,当一个struct的成员是这三种类型中的任意一个,就无法进行比较。反之,struct是可以进行比较的。

12. 结构体打印时,%v 和 %+v 的区别
#

分析

看下面代码示例:

package main
import "fmt"

type student struct {
    id   int32
    name string
}

func main() {
    a := &student{id: 1, name: "张三"}
    
    fmt.Printf("a=%v \n", a)  // a=&{1 张三}
    fmt.Printf("a=%+v \n", a) // a=&{id:1 name:张三}
    fmt.Printf("a=%#v \n", a) // a=&main.student{id:1, name:"张三"}
}

回答

  • %v 输出结构体各成员的值
  • %+v 输出结构体各成员的名称和值
  • %#v 输出结构体名称和结构体各成员的名称和值

13. 空 struct{} 占用空间么?
#

分析

可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数:

package main

import ("fmt""unsafe")

func main() {
    fmt.Println(unsafe.Sizeof(struct{}{})) //0
}

回答

空结构体 struct{} 实例不占据任何的内存空间。

14. go语言中空 struct{} 的用途?
#

分析

这个题目可以看做是上一题的12题的补充提问,通过上一题我们知道空结构体 struct{} 不占用任何内存,那么这题就可以联想到思考,不占用内存的struct有什么用处,正因为不占用内存,所以空struct被广泛作为各种场景下的占位符使用

回答

  1. 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
type Set map[string]struct{}

func (s Set) Has(key string) bool {
    _, ok := s[key]
    return ok
}

func (s Set) Add(key string) {
    s[key] = struct{}{}
}

func (s Set) Delete(key string) {
    delete(s, key)
}

func main() {
    s := make(Set)
    s.Add("Tom")
    s.Add("Sam")
    fmt.Println(s.Has("Tom"))
    fmt.Println(s.Has("Jack"))
}
  1. 不发送数据的信道(channel)

使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发。

func worker(ch chan struct{}) {
    <-ch
    fmt.Println("do something")
    close(ch)
}

func main() {
    ch := make(chan struct{})
    go worker(ch)
    ch <- struct{}{}
}
  1. 结构体只包含方法,不包含任何的字段
type Door struct{}

func (d Door) Open() {
    fmt.Println("Open the door")
}

func (d Door) Close() {
    fmt.Println("Close the door")
}

回答

  1. 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
  2. 使用在不发送数据的信道(channel)上,使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
  3. 用作接口的实现,结构体只包含方法,不包含任何的字段

15. go中"_“的作用
#

分析

在Go语言中可以出现在不同的位置,可以在import中,也可以在代码中出现,在不同的场合其作用是不一样的,在回答的时候要凸显出在不同场景下,回答全面

  1. import中的下划线

此时”_“的作用是:当导入一个包的时候,不需要把所有的包都导进来,只需要执行使用该包下的文件里所有的init()的函数。

package main

import _ "hello/imp"

func main() {
    //imp.Print() //编译报错,说: undefined: imp
}
  1. 下划线在代码中

作用是:下划线在代码中是忽略这个变量

也可以理解为占位符,那个位置上本应该某个值,但是我们不需要这个值,所以就把该值给下划线,意思是丢掉不要。这样编译器可以更好的优化,任何类型的单个值都可以丢给下划线。

如果方法返回两个值,只想要其中的一个结果,那另一个就用_占位

package main

import "fmt"

v1, v2, _ := function(...)

回答

  1. import中的下滑线用于执行导入包下的所有init函数
  2. 代码体中的下划线用于忽略返回值

16. Go 闭包
#

分析

主要考察对go语言对匿名函数的支持,回答的时候点出匿名函数关键字即可,面试种不常见

回答

  1. 匿名函数也可以被称为闭包

  2. 闭包实际上就是匿名函数 + 引用环境(捕获的变量)

在《深度探索Go语言》一书中提到,从语义角度来讲,闭包捕获变量并不是复制一个副本,变量无论被捕获与否都应该是唯一的,所谓捕获只是编译器为闭包函数访问外部环境中的变量搭建了一个桥梁。这个桥梁可以复制变量的值,也可以存储变量的地址。只有在变量的值不会再改变的前提下,才可以复制变量的值,否则就会出现不一致错误

17. Go 多返回值怎么实现的?
#

分析

主要考察go语言中对函数栈帧的了解程度,要清楚go语言中函数调用过程中,函数栈帧是怎样保存各个寄存器值的,以及栈帧的布局是怎样的,回答这个问题要突出go语言函数调用是通过fp寄存器+offset来实现的,

回顾一下函数栈帧布局

fp

回答

Go函数传参是通过fp+offset来实现的,而多个返回值也是通过fp+offset存储在调用函数的栈帧中

18. Go 语言中不能比较的类型如何比较是否相等?
#

分析

这个题目主要考察对reflect.DeepEqual的了解,因为基本类型都可以用==来比较,但是涉及到像不能比较的类型,比如slice,map怎么比较,所以回回答的时候要点出关键字reflect.DeepEqual

回答

  1. 像 string,int,float interface 等可以通过 reflect.DeepEqual 和等于号进行比较

  2. 像 slice,struct,map 则一般使用 reflect.DeepEqual 来检测是否相等。

19. Go中 init 函数的特征?
#

分析

主要考察go语言的初始化过程,初始化过程中分为全局变量,init函数,在初始化过程中主要是要明确他们的初始化顺序,所以回答要点要突出各个包下的全局变量,init函数,它们的执行顺序

init

回答

  1. 每个包下可以有多个 init 函数,每个文件也可以有多个 init 函数。多个 init 函数按照它们的文件名顺序逐个初始化。

  2. 应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到 main 包。

    a. 不管包被导入多少次,包内的 init 函数只会执行一次。

    b. 而且包级别变量的初始化先于包内 init 函数的执行。

20. Go 中 uintptr 和 unsafe.Pointer 的区别?
#

分析

考察对go语言中指针的了解,go语言中指针分为普通指针类型、unsafe.Pointer、uintptr(本质不是指针,下面会进行说明)。三者的功能各不相同

  • 类型: 普通指针类型,用于传递对象地址,不能进行指针运算。
  • unsafe.Pointer: 通用指针类型,用于转换不同类型的指针,不能进行指针运算,不能读取内存存储的值(必须转换到某一类型的普通指针)。
  • uintptr: 用于指针运算,GC 不把 uintptr 当指针,uintptr 无法持有对象。uintptr 类型的目标会被回收。

在回答unsafe.Pointer和uintptr的区别时,重点突出指针运算上,unsafe.Pointer用于指针类型转换,不能参与运算,而uintptr 可以运算。

回答

  1. unsafe.Pointer 是通用指针类型,它不能参与计算,任何类型的指针都可以转化成unsafe.Pointer,unsafe.Pointer 可以转化成任何类型的指针

    a. 当我们想让普通指针类型之间进行转换的时候,就需要unsafe.Pointer作为中间指针

    var b *float64
    var a int = 100
    var c *int = &a
    b = (*float64)(unsafe.Pointer(c))
    
  2. uintptr 可以转换为 unsafe.Pointer,unsafe.Pointer 可以转换为 uintptr。uintptr 是指针运算的工具,但是它不能持有指针对象(意思就是它跟指针对象不能互相转换),unsafe.Pointer 是指针对象进行运算(也就是 uintptr)的桥梁。

    a. 很多人都认为uintptr是个指针,其实不然。不要对这个名字感到疑惑,它只不过是个uint,大小与当前平台的指针宽度一致。因为unsafe.Pointer可以跟uintptr互相转换,所以Go语言中可以把指针转换为uintptr进行数值运算,然后转换回原类型,以此来模拟C语言中的指针运算。

    b. unsafe.Pointer类似于C语言中的void*,虽然未指定元素类型,但是本身类型就是个指针。

Golang面试题库 - 这篇文章属于一个选集。
§ 1: 本文