尝试解答 《Go 语言笔试面试题汇总》

题目来自 《Go 语言笔试面试题汇总 | 极客面试 | 极客兔兔》

尽管写了一段时间的 Go,但是很多概念还是一知半解的,所以结合自己理解,以及相应的答案,检索了一些资料完成了这套题目。

基础语法

01 = 和 := 的区别?

=只有赋值作用,而:=除去赋值外,还包含声明的作用
因此,在变量第一次声明,可以使用var a = 0,也可以使用a := 0,两者等价(因为是0,所以可以省略类型int
在其他赋值的时候,只能使用=
可以近似认为,:等价于var关键字

02 = 指针的作用

记录某个数据存放在哪块内存中。如,var a int32 = 0,实际上是申请了一块 4 字节的内存,并置为全空。并使用&a来记录这块内存的地址。在任何时候,需要访问这块地址时,就可以使用a来获取内容了。

  • 对于指针p,可以使用*p获取该指针对应内存的内容
  • 对于变量a,可以使用&a获取该变量对应的内存地址(指针)

指针对应的可能是变量,也可能是一个函数,或是其他资源。

在实际使用中,可以通过传输一个指针作为变量来实现非引用传参。这样在函数内部可以直接修改外部变量内容(也即实现了修改和访问其他作用域的内容),同时可以避免在函数调用时额外的内存申请与拷贝消耗;但是这种做法可能会在并发执行中引入问题。

03 Go 允许多个返回g值吗?

允许,可以使用类似下面的方式返回多个值

package main

func Sum(a, b int) (c int, isOdd bool) {
    c = a + b
    isOdd = c % 2 == 1
    return 
}

func main() {
    c, isOdd := Sum(a, b)
    fmt.Printf("%d + %d = %d, odd %v", a, b, c, isOdd)
}
04 Go 有异常类型吗?

Go 没有单独的异常类型,其异常使用panic()抛出,可接受的参数为interface{}

Go 使用错误和异常两种概念,来对应其他语言中的异常(Exception),前者使用error作为函数的返回值来解决,后者则通过panic()recover()defer来实现

更多见另一篇文章 探讨 Go 错误机制

05 什么是协程(Goroutine)

Goroutine 是一种轻量级的线程,其开销很小,通过 goroutine 可以在非常短的时间内切换执行上下文,实现并发执行。在大量 IO 的情况下,这么做可以提升执行效率。

06 如何高效地拼接字符串

几乎在所有的语言中,字符串拼接都有多种方案,如直接使用+,或是使用构造器。

对于大量的字符串拼接,应该使用strings.Builder或是bytes.Buffer

在 Go 中字符串本身是个不可变的数据结构,具体体现为无法修改指定下标的内容
由于无法修改字符串,下面的代码实际上是无法执行的

package main

import "fmt"

func main() {
    s := "abc"
    s[1] = 'd'
    fmt.Println(s)
}

使用下面的 benchmark 测试可以看到不同拼接方法的具体执行效率

package main

import (
    "bytes"
    "fmt"
    "strings"
    "testing"
)

const (
    sa          = "abcdefg"
    sb          = "1234567"
    concatTimes = 1000
)

func concat1(a, b string) string {
    s := ""
    for i := 0; i < concatTimes; i++ {
        s += a + b
    }
    return s
}

func concat2(a, b string) string {
    s := ""
    for i := 0; i < concatTimes; i++ {
        s = fmt.Sprintf("%s%s%s", s, a, b)
    }
    return s
}

func concat3(a, b string) string {
    var str strings.Builder
    for i := 0; i < concatTimes; i++ {
        str.WriteString(a)
        str.WriteString(b)
    }
    return str.String()
}

func concat4(a, b string) string {
    var str bytes.Buffer
    for i := 0; i < concatTimes; i++ {
        str.WriteString(a)
        str.WriteString(b)
    }
    return str.String()
}
func BenchmarkPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        concat1(sa, sb)
    }
}

func BenchmarkPrintf(b *testing.B) {
    for i := 0; i < b.N; i++ {
        concat2(sa, sb)
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        concat3(sa, sb)
    }
}

func BenchmarkBytes(b *testing.B) {
    for i := 0; i < b.N; i++ {
        concat4(sa, sb)
    }
}

$ go test -bench=.
goos: linux
goarch: amd64
BenchmarkPlus-4              465           2520003 ns/op
BenchmarkPrintf-4            397           3015618 ns/op
BenchmarkBuilder-4         36073             31416 ns/op
BenchmarkBytes-4           38354             30013 ns/op
PASS

可以看出,拼接速度相差了 100 倍
(如果只是简单的一次 a+b,也即concatTimes = 1,那么实际上直接相加更快,不过别的也没有很慢就是了)

07 什么是 rune 类型

rune 是 Go 中的字符类型,其等价于 int32(和 C 中的 char 不一样)

具体表现可以看这段代码

package main

import (
    "bytes"
    "fmt"
)

func main() {
    s := "Hello 世界"
    for _, c := range s {
        buf := bytes.NewBuffer([]byte{})
        buf.WriteRune(c)
        fmt.Printf("%T %c %+v %+v\n", c, c, buf.Bytes(), byte(c))
    }
    fmt.Println(len(s), len([]rune(s)))
    fmt.Println([]rune(s))
    fmt.Println([]byte(s))
}

其输出如下,可以看出,rune 实际上是一个 int32 类型,对应的是 Unicode。

int32 H [72] 72
int32 e [101] 101
int32 l [108] 108
int32 l [108] 108
int32 o [111] 111
int32   [32] 32
int32 世 [228 184 150] 22
int32 界 [231 149 140] 76
12 8
[72 101 108 108 111 32 19990 30028]
[72 101 108 108 111 32 228 184 150 231 149 140]

这里的 19990、30028 是 Unicode 原始字码,非 UTF-8 编码

对于所有非纯英文操作,如涉及中文,应该先转换为[]rune后再处理

08 如何判断 map 中是否包含某个 key ?

直接使用m[key]访问对应的内容,共有两个返回值,第一个是对应的 value(如果存在),第二个是是否存在该值

package main

import (
    "fmt"
)

func main() {
    m := map[string]bool{
        "a": true,
        "b": false,
        "c": true,
    }

    _, exists := m["a"]
    fmt.Println(exists)

    _, exists = m["d"]
    fmt.Println(exists)
}
09 Go 支持默认参数或可选参数吗?

不支持

虽然可以骚操作一波变相实现,但是并不友好

package main

import "fmt"

// (a + b) * k
func run(a, b int, args ...map[string]int) int {
    arg := map[string]int{
        "mul": 2,
    }
    if len(args) != 0 {
        for k, v := range args[0] {
            arg[k] = v
        }
    }
    return (a + b) * arg["mul"]
}

func main() {
    fmt.Println(run(2, 3))
    fmt.Println(run(2, 3, map[string]int{"mul": 100}))
}
10
500
10 defer 的执行顺序

当前函数退出时(包括发生异常等各种情况),在 return 语句后执行,因此可以在这里统一处理并修改返回值(比如对 error 进行二次包裹)

如果包含多个defer,则会逆序执行,先defer的后执行

11 如何交换 2 个变量的值?
package main

import "fmt"

func main() {
	a := 1
	b := 2
	a, b = b, a
	fmt.Println(a, b)
}
2 1
12 Go 语言 tag 的用处?

标记结构体,如声明该结构体在 json 中应该是哪个字段,在 MySQL 中应该是哪个字段

13 如何判断 2 个字符串切片(slice) 是相等的?

无脑reflect.DeepEqual()即可

要比较两个切片是否相等,有如下几种方法:

  1. 使用reflect.DeepEqual,从底层比较
  2. 使用for循环逐个比较
  3. 使用%+v将其输出成字符串比较

从实现上来说,1 和 3 只需要简单的一行代码,而 2 则需要一段不算太长的代码。

而性能上,可以使用 benchmark 进行比较

package main

import (
	"fmt"
	"math/rand"
	"reflect"
	"testing"
	"time"
)

var (
	l  = 100000
	s1 = generate(l)
	s2 = generate(l)
	s3 = generate(l * 2)
)

var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")

func randStringRunes(n int) string {
	rand.Seed(time.Now().UnixNano())
	b := make([]rune, n)
	for i := range b {
		b[i] = letterRunes[rand.Intn(len(letterRunes))]
	}
	return string(b)
}

func generate(n int) []string {
	s := make([]string, n)
	for i := 0; i < n; i++ {
		s[i] = randStringRunes(10)
	}
	return s
}

func cmp1(a, b []string) bool {
	return reflect.DeepEqual(a, b)
}

func cmp2(a, b []string) bool {
	la := len(a)
	lb := len(b)
	if la != lb {
		return false
	}
	for i := 0; i < la; i++ {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

func cmp3(a, b []string) bool {
	return fmt.Sprintf("%+v", a) == fmt.Sprintf("%+v", b)
}

func BenchmarkReflect(b *testing.B) {
	for i := 0; i < b.N; i++ {
		cmp1(s1, s2)
		cmp1(s1, s3)
		cmp1(s1, s1)
	}
}

func BenchmarkFor(b *testing.B) {
	for i := 0; i < b.N; i++ {
		cmp2(s1, s2)
		cmp2(s1, s3)
		cmp2(s1, s1)
	}
}

func BenchmarkString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		cmp3(s1, s2)
		cmp3(s1, s3)
		cmp3(s1, s1)
	}
}

结果如下,可以看出,反射性能远高于另两种

$ go test -bench=.
goos: linux
goarch: amd64
BenchmarkReflect-4        942232              1463 ns/op
BenchmarkFor-4              3004            417853 ns/op
BenchmarkString-4              9         112793444 ns/op
PASS

但这个结果是切片很长,而切片中的元素长的情况,如果切片本身不长,但是元素较长呢?
将上面代码修改为l=100randStringRunes(1000000),则新的结果为

go test -bench=.
goos: linux
goarch: amd64
BenchmarkReflect-4        965967              1172 ns/op
BenchmarkFor-4           3006084               434 ns/op
BenchmarkString-4              1        1144034000 ns/op
PASS

可以看出,无论如何,使用%+v的方案完全可以舍弃,其与另两种方案差距非常之大。
对于切片长度较长,而字符串本身并不长的情况,使用反射可以达到很好的效果。
对于切片长度本身补偿,而字符串本身很长的情况,应该使用for循环来进行判断。

不过考虑到性能差距,无脑用反射即可,与目前网络上生成的反射效率低下似乎并不太一致。

14 字符串打印时,%v 和 %+v 的区别
  • %v: 输出结构体各项的值
  • %+v: 输出结构体字段和各项值

各个格式化输出占位符含义

占位符 含义 # # 含义 + + 含义
%v 输出内容默认格式 %#v 输出类型及详细格式 %+v 输出内容详细格式
(输出结构体字段名)
%T 输出类型
%% 输出%
%t 输出布尔类型
%b 输出二进制数字 %#b 输出十进制数字
(带前导 0b)
%+b 输出十进制数字
(带正负号)
%d 输出十进制数字 %+d 输出十进制数字
(带正负号)
%o 输出八进制数字 %#o 输出八进制数字
(带前导 0)
%+o 输出八进制数字
(带正负号)
%x 输出十六进制数字
(或十六进制表示字符串)
%#x 输出十六进制数字
(带前导 0x)
%+x 输出十六进制数字
(带正负号)
%X 输出大写十六进制数字
(或十六进制表示字符串)
%#X 输出大写十六进制数字
(带前导 0X)
%+X 输出大写十六进制数字
(带正负号)
%U 输出 Unicode 编码
(格式为U+0000
%#U 输出 Unicode 编码及对应字符
(带单引号)
%e 按照科学计数法输出数字 %+e 按照科学计数法输出
(带正负号)
%E 按照科学计数法输出数字
(大写 e)
%+e 按照科学计数法输出
(大写 E,带正负号)
%f 输出浮点数 %+f 输出浮点数
(带正负号)
%g 根据合适输出%e%f %+g 自动选择合适的输出
(带正负号)
%G 根据合适输出%E%f %+G 自动选择合适的输出
(带正负号)
%c 输出数字对应的 Unicode 字符
%s 输出字符串
%q 输出字符串
(使用引号包裹并转义)
%#q 输出字符串
(使用反引号包裹,
需要转义时,按%q输出)
%+q 输出 Unicode 编码
(格式为\u0000
%p 输出的地址指针
(前导为 0x)
%#p 输出地址指针
(不带前导)
15 Go 语言中如何表示枚举值(enums)?

Go 没有默认的枚举值,但可以通过定义结构体实现

通常可以在定义结构体后,使用 stringer 工具生成对应的内容

package weekday

//go:generate stringer -type=Weekday

// Weekday 星期
type Weekday byte

const (
	// Monday 星期一
	Monday Weekday = iota + 1
	// Tuesday 星期二
	Tuesday
	// Wedesday 星期三
	Wedesday
	// Thursday 星期四
	Thursday
	// Friday 星期五
	Friday
	// Saturday 星期六
	Saturday
	// Sunday 星期日
	Sunday
)

使用上面的工具,可以生成出如下的代码

// Code generated by "stringer -type=Weekday"; DO NOT EDIT.

package weekday

import "strconv"

func _() {
	// An "invalid array index" compiler error signifies that the constant values have changed.
	// Re-run the stringer command to generate them again.
	var x [1]struct{}
	_ = x[Monday-1]
	_ = x[Tuesday-2]
	_ = x[Wedesday-3]
	_ = x[Thursday-4]
	_ = x[Friday-5]
	_ = x[Saturday-6]
	_ = x[Sunday-7]
}

const _Weekday_name = "MondayTuesdayWedesdayThursdayFridaySaturdaySunday"

var _Weekday_index = [...]uint8{0, 6, 13, 21, 29, 35, 43, 49}

func (i Weekday) String() string {
	i -= 1
	if i >= Weekday(len(_Weekday_index)-1) {
		return "Weekday(" + strconv.FormatInt(int64(i+1), 10) + ")"
	}
	return _Weekday_name[_Weekday_index[i]:_Weekday_index[i+1]]
}

可以按照下面的方式进行使用

package main

import (
	"fmt"

	"./weekday"
)

func main() {
	fmt.Println(weekday.Saturday, weekday.Saturday.String(), weekday.Monday+weekday.Tuesday, weekday.Sunday+weekday.Monday)
	fmt.Printf("%d %s %d %d\n", weekday.Saturday, weekday.Saturday.String(), weekday.Monday+weekday.Tuesday, weekday.Sunday+weekday.Monday)
}
Saturday Saturday Wedesday Weekday(8)
6 Saturday 3 8
16 空 struct{} 的用途?

struct{}不占据任何空间,因此可以用在所有不需要体现数值本身,但是需要体现存在这个数据的情况

如:

  • 使用map[string]struct{}实现集合,这时值本身不会浪费任何空间
  • 在 channel 只需要传输数据,对数据本身并不关心时,可以使用chan struct{},并传输c <- struct{}{}

实现原理

01 init() 函数是什么时候执行的?

当一个编译好的 Go 程序运行时,首先会构建依赖关系,没有任何依赖的包优先处理,按照 常量 → 变量 → 各个init() 的顺序进行初始化,接着会继续对其他包进行初始化。当所有初始化完成后,执行main()

同一个文件内,可以包含多个init(),但运行顺序不做保证

02 Go 语言的局部变量分配在栈上还是堆上?

如果编译器检查到该变量可能会在外部使用(如返回了指针),则会分配在堆上,否则分配在栈上

03 2 个 interface 可以比较吗 ?

可以比较,且在如下情况时两者相等:
两者类型相同,且数值相同(类型相同指类型一致,或为别名)

04 2 个 nil 可能不相等吗?

有可能,要比较两个变量是否相同,实际上如同前面的比较interface{}一样,先比较类型再比较内容。
因此,两个 nil 如果是不同类型指针的 nil,那么两者的比较结果是不同的。

不同类型的数据是无法比较的,因此需要使用interface{}进行比较(直接给interface{}赋值nil,是无类型nil,与其他有类型的nil也不同)

package main

import (
	"fmt"
)

func main() {
	var a, b interface{}
	var c *int = nil
	a = c
	b = c
	fmt.Println(a == b, a == nil, b == nil)
	fmt.Printf("%#v %#v %#v %#v\n", a, b, c, nil)

}

上面的代码输出如下:

true false false
(*int)(nil) (*int)(nil) (*int)(nil) <nil>
05 简述 Go 语言GC(垃圾回收)的工作原理

Go 的垃圾回收机制为带三色标记的标记清除,其包含两个阶段:

  • 标记: 从根对象开始查找所有存活的对象
  • 清除: 遍历所有对象,将未被标记的对象回收

常规的标记清除策略,要求程序暂停,因此通过三色抽象,来实现更复杂的流程。

三色标记算法的对象包含三种颜色:

  • 白色: 潜在的垃圾,可能会被回收
  • 黑色: 活跃的对象,根对象可达或未引用外部指针
  • 灰色: 活跃的对象,存在指向对象兑现的指针

通过不确定的白色对象,来实现垃圾回收的同时执行用户程序

三色标记法的流程如下:

  1. 暂停用户程序
  2. 将根对象标记为灰色,其他对象标记为白色
  3. 继续用户程序
  4. 从灰色集选择一个灰色对象,将灰色对象标记为黑色
  5. 将黑色对象指向的所有子对象标记为灰色
  6. 重复 4,直到灰色集为空

但如果在标记阶段中,一个对象被使用,建立了 A 对象到 B 对象的引用。如果 A 对象已经被标记为黑色,那么 B 对象可能会保留白色,并被错误释放。这种情况称为悬挂指针,属于非常严重的错误。

要解决该问题,需要使用屏障技术,确保 CPU 执行的顺序性。要保证并发、增量标记正确性,需要达成下面两种三色不变性中的一种:

  • 强三色不变性: 黑色对象不会指向白色对象
  • 弱三色不变性: 黑色对象指向的白色对象必须:source包含一条从灰色经由多个白色的可达路径

Go 使用了两种写屏障技术,在写操作时,执行相应的逻辑。

  • 插入写屏障: Dijkstra 在 1978 年提出,当需要执行*slot = ptr时,首先会通过shade(ptr)尝试将新的对象改为灰色。该方案保证了强三色不变性,但是并未清除原本的老对象,这些需要释放的内存需要等到下一个循环才会被回收
  • 删除写屏障: Yuasa 在 1990 年提出, 当需要执行*slot = ptr时,将会使用shade(*shade)将老的对象改为灰色,确保了弱三色不变性
06 函数返回局部变量的指针是否安全?

安全,Go 会对局部变量进行逃逸分析,如果变量作用域超过该函数,则会将其分配在堆上

07 非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?

*T必然可以调用所有T*T的方法,但是T未必可以调用*T的方法,只有T可寻址时,才可行。

由于将T变成*T,本质上引入了修改其内容的可能性,因此所有的常量、包级别的函数、map元素、字符串中的内容这些“不可变”内容都无法寻址。

并发编程

01 无缓冲的 channel 和有缓冲的 channel 的区别?

缓冲区可以允许通道内存储缓冲区个数个数据。

如无缓冲的 channel(等价于make(chan T,0)),每当发送方发送一条数据时,发送方将会被阻塞,直到接收方取出数据后才会继续运行

而有缓冲 channel 则允许存储多条数据不阻塞发送方。

可以参考下面的代码,修改通道的缓冲长度,查看被阻塞的主函数

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println(1)
	c := make(chan byte, 1)
	fmt.Println(2)
	go func() {
		for {
			time.Sleep(time.Second * 5)
			select {
			case <-c:
				fmt.Println("recv")
			}
		}
	}()
	c <- 0x1
	fmt.Println(3)
	c <- 0x2
	fmt.Println(4)
}
02 什么是协程泄露(Goroutine Leak)?

协程大量创建却未释放,导致内存耗尽,程序崩溃,就是协程泄露

除去代码本身的问题外,如果未正确处理通道,也可能导致该问题。如大量协程等待写入通道或等待从通道读出、竞争资源导致死锁、无限循环……

03 Go 可以限制运行时操作系统线程的数量吗?

使用GOMAXPROCSruntime.GOMAXPROCS(num int)可以限制线程数目

该数值默认为 CPU 的逻辑核心数,同一时间一个核心只能绑定一个线程,运行被调度的的协程。
在 CPU 密集型任务中,若该值过大,则会增加线程切换的开销;
在 I/O 密集型任务中,调大该值则可以提高 I/O 吞吐率

代码输出

变量与常量

package main

import "fmt"

func main(){
    const (
        a, b = "golang", 100
        d, e
        f bool = true
        g
    )
    fmt.Println(d, e, g)
}
输出与解析
golang 100 true

如果常量定义与上一行一致,可以省略类型和值

package main

import "fmt"

func main() {
	const N = 100
	var x int = N

	const M int32 = 100
	var y int = M
	fmt.Println(x, y)
}
输出与解析
# command-line-arguments
./main.go:10:6: cannot use M (type int32) as type int in assignment

常量本身可能带类型,也可能不带类型(如 100 既可以认为是int,也可以认为是int32),如果是带类型的常量,则只允许赋值给同类型常量

package main

import "fmt"

func main() {
	var a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}
输出与解析
-128

int8的表示范围为[128,127][-128, 127]

下面给出涉及的几个数字的各个二进制表示

原码 反码 补码
11 0000000100000001
1-1 0000000100000001 1111111011111110 1111111111111111
127127 0111111101111111
127-127 0111111101111111 1000000010000000 1000000110000001
128128 1000000010000000
128-128 1000000010000000 0111111101111111 1000000010000000

如上表,(128)÷(1)=128(-128) \div (-1) = 128,但是由于int8无法表示128128,因此其实际上是-128

package main

import "fmt"

func main() {
	const a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}
输出与解析
# command-line-arguments
./main.go:7:20: constant 128 overflows int8

与上题类似,但这里是常量除以常量,而常量运算在编译期计算,结果也是常量,其禁止溢出,因此编译失败

作用域

package main

import "fmt"

func main() {
	var err error
	if err == nil {
		err := fmt.Errorf("err")
		fmt.Println(1, err)
	}
	if err != nil {
		fmt.Println(2, err)
	}
}
输出与解析
1 err

第一个iferr是重新声明的,因此第二个if判断时err仍然是nil

defer 延迟调用

package main

import "fmt"

type T struct{}

func (t T) f(n int) T {
	fmt.Print(n)
	return t
}

func main() {
	var t T
	defer t.f(1).f(2)
	fmt.Print(3)
}
输出与解析
132

首先执行到defer t.f(1).f(2),将会在该函数结束时,实际调用.f(2),但此时将计算t.f(1)部分,因此这里会输出1,接下来 fmt 输出3,最后函数运行结束,调用.f(2),输出2

package main

import "fmt"

func main() {
	n := 1
	defer func() {
		fmt.Println("0", n)
	}()
	defer func(n int) {
		fmt.Println("1", n)
	}(n)
	n += 100
}
输出与解析
1 1
0 101

首先n=1,第一个 defer 将会在函数结束时实际执行,第二个 defer 也将会在函数结束时执行,并传入参数n(1)。
n+=100后,得到n=101,函数结束,开始执行 defer 内容

defer 将逆序执行,首先执行第二个 defer,该函数输出传入的参数n,故输出1 1
接着执行第一个 defer,输出局部变量n,输出0 101