go语言面试

发布时间 2023-03-22 19:12:02作者: Steam残酷

go面试

基础

= 和 := 的区别?

**Go语言中,= 操作符用于赋值,而 := 操作符可以用于声明及赋值。 Go 语言支持短变量声明(针对局部变量),以 := 为标志,这里要注意的是,Go 语言中会优先选择 :=,而不是 =,但在赋值的情况下,两者的效果是相同的。 **

Go语言中, = 和 := 之间的主要区别在于使用 := 将变量声明时,它会自动分配类型,而 = 不会

指针的作用

Go 语言中的指针是一种特殊的变量类型,用于存储变量的内存地址。通过指针,可以间接地访问和修改存储在内存中的变量,这在某些情况下非常有用。以下是指针在 Go 语言中的主要作用:

  1. 传递变量的内存地址:当需要将一个变量传递给函数时,如果直接传递变量的值,那么函数内部对变量的修改并不会影响原来的变量。但如果将变量的地址传递给函数,那么函数就可以通过指针来访问和修改变量,从而实现对原来变量的修改。
  2. 动态分配内存:通过指针,可以在程序运行时动态地分配内存,这在一些需要动态管理内存的应用程序中非常有用。例如,可以使用 new 函数来创建一个新的变量,并返回它的地址。
  3. 优化内存和性能:通过指针,可以直接访问和修改存储在内存中的变量,而不需要进行复制和传递值,这可以在一些对内存和性能要求较高的应用程序中提高程序的效率。

在 Go 语言中,指针可以通过 & 运算符来取得变量的地址,通过 * 运算符来访问指针指向的变量。

go语言允许多返回值吗?

是的,Go 语言允许函数返回多个值,这是 Go 语言的一项非常有用的特性。通过返回多个值,可以让函数在不同的情况下返回不同的值,这使得代码更加简洁和易读,并且减少了程序员的工作量。

go语言有异常类型吗?

Go 语言没有像 Java 和 C# 中那样的异常类型,而是采用了另一种错误处理机制,即错误类型。在 Go 中,错误是一个普通的接口类型,定义如下:

type error interface {
    Error() string
}

这个接口只有一个方法 Error(),该方法返回一个字符串,表示错误的详细信息。如果一个函数遇到了错误,可以返回一个实现了 error 接口的对象,用来表示错误的类型和信息。

在调用一个可能会返回错误的函数时,通常需要使用条件语句来判断是否发生了错误。例如,下面的代码演示了如何调用 os.Open 函数来打开一个文件,并处理可能出现的错误:

f, err := os.Open("filename.txt")
if err != nil {
    fmt.Println("Failed to open file:", err)
    return
}
defer f.Close()

// 从文件中读取数据

在上面的代码中,os.Open 函数用于打开一个文件,并返回一个 *os.File 类型的指针和一个 error 类型的错误对象。如果 err 不为 nil,则表示打开文件出现了错误,需要进行相应的错误处理。否则,文件打开成功,可以使用 defer 语句来确保文件在函数结束前关闭。

需要注意的是,虽然 Go 中没有异常类型,但错误处理机制可以达到类似的效果,使得程序可以更加灵活地处理各种异常情况。同时,Go 的错误处理机制也可以使得程序更加简洁和可读,避免了 Java 和 C# 中异常滥用的问题。

go语言什么是协程(Goroutine)

在 Go 语言中,协程(Goroutine)是一种轻量级的线程,由 Go 语言运行时环境(runtime)管理。与传统的线程相比,协程的创建和销毁开销较小,且协程之间的切换开销也较小,因此可以轻松地创建数以千计的协程。

协程可以看作是一种并发的设计模式,可以用来处理大量的任务或者事件。在 Go 语言中,协程可以通过 go 关键字来创建,例如:

func main() {
    go foo()
    go bar()
}

func foo() {
    // 执行某些任务
}

func bar() {
    // 执行另外一些任务
}

在上面的例子中,foobar 函数都是使用 go 关键字创建的协程。这两个协程将会并发地执行,互不干扰。同时,main 函数本身也是一个协程,可以与其他协程并发执行。

协程通常比线程更加轻量级,因为它们不需要独立的内存空间和操作系统级别的线程调度器。Go 语言的运行时环境会在多个协程之间进行自动的调度,使得程序可以高效地利用多核 CPU,并实现高并发的执行模型。

需要注意的是,协程与线程不同的是,它们的执行顺序是不确定的。因此,在编写使用协程的程序时,需要考虑如何进行同步和互斥,以避免数据竞争和其他并发问题。在 Go 语言中,可以使用通道(channel)等同步机制来实现协程之间的通信和协作。

如何高效的拼接字符串

在 Go 语言中,字符串是不可变的,即一旦创建之后就不能被修改。因此,在需要频繁拼接字符串的情况下,使用简单的字符串拼接操作可能会导致性能问题,因为每次拼接字符串都需要创建一个新的字符串对象。为了高效地拼接字符串,可以使用以下几种方法:

  1. 使用 strings.Builder 类型。strings.Builder 是一个可变字符串类型,提供了多种方法来高效地拼接字符串,例如 WriteStringWriteByteWriteRune 等。使用 strings.Builder 类型时,可以减少因为频繁创建字符串对象而导致的内存分配和拷贝操作,从而提高程序的性能。例如:
var builder strings.Builder
builder.WriteString("Hello, ")
builder.WriteString("world!")
result := builder.String()
  1. 使用 bytes.Buffer 类型。bytes.Buffer 类型是一个可变的字节数组类型,同样提供了多种方法来高效地拼接字符串。与 strings.Builder 类型类似,使用 bytes.Buffer 时可以减少内存分配和拷贝操作,提高程序性能。例如:
var buffer bytes.Buffer
buffer.WriteString("Hello, ")
buffer.WriteString("world!")
result := buffer.String()
  1. 使用 fmt.Sprintf 函数。fmt.Sprintf 函数可以格式化字符串并返回一个字符串结果。该函数支持多种格式化选项,例如 %d%s 等。虽然使用 fmt.Sprintf 可能会产生额外的字符串拷贝操作,但在大多数情况下,这种操作的影响很小,而且代码更加简洁易懂。例如:
result := fmt.Sprintf("%s%s", "Hello, ", "world!")

需要注意的是,使用以上方法时,应该尽量避免在循环中频繁拼接字符串,因为这样可能会导致内存分配和拷贝操作过多,从而影响程序的性能。如果需要拼接大量字符串时,建议使用 strings.Builderbytes.Buffer 等可变类型,以便高效地拼接字符串。

go语言什么是 rune 类型

在 Go 语言中,rune 类型是一个 32 位的 Unicode 字符,用于表示 Unicode 码点。rune 类型是一个别名类型,本质上等价于 int32 类型,但在语义上表示 Unicode 字符。

由于 rune 类型可以表示任意一个 Unicode 码点,因此它可以用来处理多语言和国际化应用中的字符数据。在 Go 语言中,可以使用 string 类型来表示字符串,而每个字符都可以表示为一个 rune 类型的值。例如:

str := "Hello, 世界"
for _, r := range str {
    fmt.Printf("%c", r)
}

在上面的例子中,str 是一个包含英文字符和中文字符的字符串,可以通过 range 关键字遍历字符串中的每个字符,并使用 %c 格式化选项输出字符。

**需要注意的是,虽然 rune 类型本质上等价于 int32 类型,但在语义上它表示一个 Unicode 字符,因此不应该将其与普通的整数类型混用。如果需要处理整数数据,应该使用 int 或其他适当的整数类型。

小兔

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 序列。

fmt.Println(len("Go语言")) // 8
fmt.Println(len([]rune("Go语言"))) // 4

go语言如何判断 map 中是否包含某个 key ?

在 Go 语言中,可以使用以下两种方法判断 map 中是否包含某个 key

  1. 使用 if 语句和 ok 值:可以使用 if 语句和 ok 值来判断 map 中是否包含某个 key。具体地,当 map 中包含指定的 key 时,ok 值为 true,否则为 false。例如:
m := make(map[string]int)
m["foo"] = 1

if value, ok := m["foo"]; ok {
    fmt.Println("m[\"foo\"]=", value)
} else {
    fmt.Println("m does not contain key \"foo\"")
}

在上面的例子中,当 map m 中包含 key"foo" 时,if 语句中的条件表达式为 true,因此执行 if 语句中的语句块。在语句块中,value 变量被赋值为 m["foo"] 的值,即 1

2.使用 _, ok := m[key] 语句:可以使用 _, ok := m[key] 语句来判断 map 中是否包含某个 key。具体地,当 map 中包含指定的 key 时,ok 值为 true,否则为 false。例如:

m := make(map[string]int)
m["foo"] = 1

if _, ok := m["foo"]; ok {
    fmt.Println("m contains key \"foo\"")
} else {
    fmt.Println("m does not contain key \"foo\"")
}

在上面的例子中,_, ok := m["foo"] 语句判断 map m 中是否包含 key"foo" 的键值对。由于 map 中确实包含这个 key,因此 ok 值为 trueif 语句中的条件表达式为 true,因此执行 if 语句中的第一条语句。

Go 支持默认参数或可选参数吗?

Go 语言不支持默认参数或可选参数。在函数定义中,必须指定所有参数的类型和名称,调用函数时也必须按照定义的顺序传递参数。

这是因为 Go 语言的设计哲学之一是尽可能简单和直接。在语言设计中,避免复杂性是一个重要的目标。默认参数和可选参数等功能虽然可以减少代码量,但同时也会增加语言的复杂性和不确定性。因此,Go 语言选择了不支持这些功能。

在实际使用中,如果需要定义一个函数,它可以接受不同数量的参数或不同类型的参数,可以使用不定参数列表的语法,即使用 ... 语法来定义一个参数的不定数量的列表。例如:

func myFunc(args ...string) {
    for _, arg := range args {
        fmt.Println(arg)
    }
}

在上面的例子中,myFunc 函数使用不定参数列表来接受不同数量的字符串参数。在函数内部,可以通过 args 参数来访问参数列表中的所有元素。这样,调用方可以传递任意数量的参数给函数,并且函数也可以接受不同数量的参数。但是,需要注意的是,这些参数在函数内部都被视为同一类型,这意味着在函数内部需要进行类型检查和转换,以确保参数类型的正确性。

go语言defer 的执行顺序

在 Go 语言中,defer 语句用于延迟函数或方法的执行,以便在函数或方法返回之前执行一些清理或收尾工作。在一个函数或方法中,可以使用多个 defer 语句来延迟多个函数或方法的执行。defer 语句的执行顺序如下:

  1. 当执行到 defer 语句时,将 defer 语句后面的函数或方法压入一个栈中,并记录函数的参数值。
  2. 在函数或方法返回之前,依次执行栈中的所有函数或方法,即后进先出(LIFO)的顺序执行。

下面是一个例子,演示了 defer 语句的执行顺序:

func main() {
    defer fmt.Println("1st defer")
    defer fmt.Println("2nd defer")
    defer fmt.Println("3rd defer")

    fmt.Println("Hello, world!")
}

在上面的代码中,我们使用了三个 defer 语句来延迟三个 fmt.Println 函数的执行。运行上面的代码,输出结果如下:

Hello, world!
3rd defer
2nd defer
1st defer

从输出结果可以看出,Hello, world! 语句先被执行,然后依次执行了栈中的三个 defer 语句,即 3rd defer2nd defer1st defer。因此,defer 语句的执行顺序是后进先出的。

需要注意的是,defer 语句中记录的函数参数在 defer 语句执行时就已经确定,因此如果在 defer 语句后面修改参数值,对 defer 语句中的函数没有影响。因此,建议在 defer 语句中不要修改参数值。

go语言实现变量交换

在 Go 语言中,交换两个变量的值可以通过中间变量或使用多重赋值的方式来实现。下面是两种实现方式的示例:

  1. 中间变量实现:
func swap(a, b int) (int, int) {
    tmp := a
    a = b
    b = tmp
    return a, b
}

func main() {
    x, y := 1, 2
    x, y = swap(x, y)
    fmt.Println(x, y)
}

在上面的代码中,我们定义了一个 swap 函数来交换两个整数类型的变量。在 swap 函数中,我们定义了一个中间变量 tmp,然后通过中间变量交换 ab 的值。在 main 函数中,我们通过多重赋值的方式来交换 xy 的值,并打印结果。

  1. 多重赋值实现:
func main() {
    x, y := 1, 2
    x, y = y, x
    fmt.Println(x, y)
}

在上面的代码中,我们使用多重赋值的方式来交换 xy 的值。通过 x, y = y, x 的方式,先将 y 的值赋给 x,然后将 x 的值赋给 y,从而交换了 xy 的值。

无论使用中间变量还是多重赋值的方式,都可以很容易地实现两个变量的值交换。需要注意的是,在使用多重赋值的方式时,两个变量的类型必须相同。

go语言中tag的作用

在 Go 语言中,结构体(struct)类型的字段可以使用 tag(标签)来指定一些额外的信息,这些信息通常用于反射(reflection)或序列化(serialization)等场景。tag 是一个字符串,写在结构体字段的后面,用反引号(`)括起来。tag 的格式如下:

`key1:"value1" key2:"value2" ...`

其中,每个键值对之间使用空格分隔,键和值之间使用冒号(:)分隔。tag 中的键必须是非空字符串,值可以是任意字符串。

Go 语言中内置的 reflect 包可以使用 tag 来获取结构体字段的额外信息。例如,可以使用 reflect 包中的 TypeField 函数来获取结构体类型和字段的信息。下面是一个使用 tag 的示例:

type User struct {
    Name    string `json:"name" xml:"name"`
    Age     int    `json:"age" xml:"age"`
    Address string `json:"address" xml:"address"`
}

func main() {
    user := User{
        Name:    "Alice",
        Age:     30,
        Address: "New York",
    }
    b, err := json.Marshal(user)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b))
}

在上面的代码中,我们定义了一个名为 User 的结构体类型,其中每个字段都使用了 jsonxml 两个 tag。在 main 函数中,我们创建了一个 User 类型的实例,并使用 json 包中的 Marshal 函数将其序列化为 JSON 格式的字符串。在序列化过程中,json 包会自动识别 User 结构体的 tag,将其转换为 JSON 字段名。因此,最终输出的 JSON 字符串中的字段名为 nameageaddress

除了序列化之外,tag 还可以用于其他场景,例如验证、ORM 等。通过使用 tag,可以为结构体字段添加额外的元数据,使其在不同的场景下发挥更大的作用。

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

在 Go 语言中,判断两个字符串切片(slice)是否相等,需要先判断它们的长度是否相等,然后再依次比较它们的每个元素是否相等。

可以使用 reflect.DeepEqual 函数来比较两个字符串切片是否相等,该函数可以比较任意类型的值是否相等,但是效率相对较低,因此不推荐在大型数据集上使用。示例如下:

package main

import (
    "fmt"
    "reflect"
)

func main() {
    s1 := []string{"foo", "bar", "baz"}
    s2 := []string{"foo", "bar", "baz"}

    if reflect.DeepEqual(s1, s2) {
        fmt.Println("s1 and s2 are equal")
    } else {
        fmt.Println("s1 and s2 are not equal")
    }
}

输出结果为:

s1 and s2 are equal

另外,如果字符串切片中的元素是可比较类型(例如 stringint 等),则可以使用循环来逐个比较每个元素是否相等。示例如下:

goCopy code
package main

import "fmt"

func main() {
    s1 := []string{"foo", "bar", "baz"}
    s2 := []string{"foo", "bar", "baz"}

    if len(s1) != len(s2) {
        fmt.Println("s1 and s2 are not equal")
        return
    }

    for i := 0; i < len(s1); i++ {
        if s1[i] != s2[i] {
            fmt.Println("s1 and s2 are not equal")
            return
        }
    }

    fmt.Println("s1 and s2 are equal")
}

输出结果与上面的示例相同,为:

s1 and s2 are equal

字符串打印时,%v 和 %+v 的区别

在 Go 语言中,%v%+v 都是用于格式化字符串的占位符,用于打印结构体等数据类型。

%v 表示按照默认格式输出,对于结构体类型,将会递归地输出其所有的字段和值。示例如下:

type Person struct {
    Name string
    Age  int
}

func main() {
    p := Person{"Alice", 30}
    fmt.Printf("%v\n", p)
}

输出结果为:

{Alice 30}

%+v 表示输出更详细的信息,对于结构体类型,除了递归地输出其所有的字段和值之外,还会输出字段的名称。示例如下:

type Person struct {
    Name string
    Age  int
}

func main() {
    p := Person{"Alice", 30}
    fmt.Printf("%+v\n", p)
}

输出结果为:

{Name:Alice Age:30}

因此,可以根据需要选择 %v%+v 来格式化打印字符串。

Go 语言中如何表示枚举值(enums)?

Go 语言中没有枚举类型(enum),但是可以使用常量来实现类似枚举的功能。常量是一种恒定不变的值,可以是数值、布尔值或字符串等类型。

常量的值可以是数值、字符、字符串或布尔值等类型,如果多个常量具有相同的类型和值,则它们可以被分组在一起定义。示例如下:

package main

import "fmt"

const (
    Sunday = iota // 0
    Monday        // 1
    Tuesday       // 2
    Wednesday     // 3
    Thursday      // 4
    Friday        // 5
    Saturday      // 6
)

func main() {
    fmt.Println(Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
}

输出结果为:

0 1 2 3 4 5 6

在上面的示例中,使用 const 关键字定义了一组常量,并使用 iota 表示自动递增的枚举值。在这个示例中,Sunday 的值为 0,后面的常量值自动递增。

在 Go 语言中,使用常量定义枚举值具有较高的可读性,同时也保证了常量值的不可变性。

go语言空 struct{} 的用途

在 Go 语言中,空的 struct{} 类型也被称为“空结构体”或“零宽度结构体”,它没有任何字段,也不占用任何内存空间。

空结构体的主要用途是在实现某些算法或数据结构时,作为占位符或标记使用。例如,可以将空结构体作为 map 中的值类型,用于表示该 map 只关心键的存在与否,而不关心键的值是什么。示例如下:

goCopy code
// 用空结构体作为 map 的值类型
var m map[string]struct{}

// 添加元素
m["key1"] = struct{}{}
m["key2"] = struct{}{}
m["key3"] = struct{}{}

// 判断元素是否存在
if _, ok := m["key1"]; ok {
    fmt.Println("key1 exists")
}

在上面的示例中,使用空结构体作为 map 的值类型,将 map 定义为 map[string]struct{},并使用 struct{}{} 添加元素。在判断元素是否存在时,使用 _ 忽略掉返回值,只判断 ok 值是否为真即可。

由于空结构体不占用任何内存空间,因此使用它作为 map 的值类型时,可以大大减少内存占用,提高程序的性能。

小兔

使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。

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

比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。

type Set map[string]struct{}

func main() {
	set := make(Set)

	for _, item := range []string{"A", "A", "B", "C"} {
		set[item] = struct{}{}
	}
	fmt.Println(len(set)) // 3
	if _, ok := set["A"]; ok {
		fmt.Println("A exists") // A exists
	}
}

再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。

func main() {
	ch := make(chan struct{}, 1)
	go func() {
		<-ch
		// do something
	}()
	ch <- struct{}{}
	// ...
}

再比如,声明只包含方法的结构体。

type Lamp struct{}

func (l Lamp) On() {
        println("On")

}
func (l Lamp) Off() {
        println("Off")
}

实现原理

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

在 Go 语言中,每个包都可以包含一个或多个 init() 函数。init() 函数用于在程序运行之前执行一些必要的初始化操作,例如初始化全局变量、检查环境变量、注册驱动程序等等。

init() 函数是在程序运行之前自动执行的,没有任何参数和返回值,也不能手动调用。在一个包中,init() 函数的执行顺序是从上到下的,也就是说,先定义的 init() 函数会先执行。

在一个程序中,如果一个包被引用多次,那么它的 init() 函数只会被执行一次。如果一个程序中引用了多个包,它们的 init() 函数的执行顺序是按照包导入的依赖关系来确定的,也就是说,被依赖的包的 init() 函数会先被执行。

以下是一个示例程序,其中包含一个 init() 函数:

package main

import "fmt"

func init() {
    fmt.Println("Initialization...")
}

func main() {
    fmt.Println("Hello, world!")
}

在上面的示例中,定义了一个 init() 函数,在程序运行之前会被自动调用,输出 "Initialization..." 字符串。在 main() 函数执行之前,init() 函数已经完成了初始化操作。执行上述代码将输出如下结果:

Initialization...
Hello, world!

小兔

init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

一句话总结: import –> const –> var –> init() –> main()

示例:

package main

import "fmt"

func init()  {
	fmt.Println("init1:", a)
}

func init()  {
	fmt.Println("init2:", a)
}

var a = 10
const b = 100

func main() {
	fmt.Println("main:", a)
}
// 执行结果
// init1: 10
// init2: 10
// main: 10

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

Go 语言中的局部变量(例如在函数内部定义的变量)的分配位置既可以是栈上,也可以是堆上,具体取决于该变量的类型和其生命周期。

一般来说,对于较小的局部变量(例如 int、float 等基本类型),Go 编译器会将它们分配在栈上。因为这些变量的生命周期较短,不需要在堆上分配内存。栈的分配和释放非常快,不需要进行垃圾回收,因此可以提高程序的性能。

对于较大的局部变量(例如数组、结构体等复合类型),Go 编译器会将它们分配在堆上。因为这些变量的大小不确定,分配在栈上可能导致栈溢出等问题。此外,这些变量的生命周期可能比较长,需要在函数返回后继续存在,因此需要在堆上分配内存,并由垃圾回收器来管理内存的释放。

需要注意的是,在 Go 语言中,无论变量是在栈上还是堆上分配,都可以使用指针来访问它们的值,而且在使用时无需考虑变量的分配位置。这一点与 C/C++ 等语言有所不同,可以减少程序员的负担。

小兔

由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

func foo() *int {
	v := 11
	return &v
}

func main() {
	m := foo()
	println(*m) // 11
}

foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值

2 个 interface 可以比较吗 ?

Go 语言中,两个接口的比较并不是直接可行的,因为接口是动态类型,比较接口需要比较接口变量的动态值,这是不可靠的。但是,如果两个接口的类型和值都相同,那么这两个接口就是相等的。这可以通过类型断言来实现,例如:

a := SomeInterface{...}
b := SomeOtherInterface{...}

if reflect.TypeOf(a) == reflect.TypeOf(b) {
    if reflect.ValueOf(a).Interface() == reflect.ValueOf(b).Interface() {
        // a 和 b 相等
    }
}

上面的代码使用了反射库 reflect 来比较两个接口变量的类型和动态值,如果它们都相同,则认为这两个接口相等。

需要注意的是,使用反射来比较接口会导致性能问题,并且需要谨慎使用。如果可能的话,最好避免比较接口,而是使用其他方式来实现需求。

小兔

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 ==!= 比较。2 个 interface 相等有以下 2 种情况

  1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
  2. 类型 T 相同,且对应的值 V 相等。

看下面的例子:

type Stu struct {
	Name string
}

type StuInt interface{}

func main() {
	var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
	var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
	fmt.Println(stu1 == stu2) // false
	fmt.Println(stu3 == stu4) // true
}

stu1stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

2 个 nil 可能不相等吗?

在 Go 语言中,通常情况下,两个 nil 是相等的,无论它们是哪种类型的 nil。例如,两个空的切片或映射的 nil 是相等的,它们可以使用 == 运算符进行比较。

但是,如果一个值的类型是接口类型,并且它的动态值为 nil,则该值不等于 nil。这是因为接口类型的值包括类型和值两个部分,即使值为 nil,类型也不为空。因此,两个接口类型的值即使都是 nil,它们的类型可能不同,因此不相等。例如:

var a io.Reader
var b *bytes.Buffer

if a == nil && b == nil {
    fmt.Println("a and b are equal") // 不会执行
}

a = b
if a == nil && b == nil {
    fmt.Println("a and b are equal") // 执行
}

在上面的示例中,a 是一个空接口类型,b 是一个指向 bytes.Buffer 的空指针。在将 b 赋值给 a 后,ab 都是 nil,但它们的类型不同,因此第一个比较结果为 false,第二个比较结果为 true。

需要注意的是,在 Go 语言中,nil 不是关键字,而是预定义的常量,可以用于表示空指针或空引用。因此,在使用 nil 进行比较时,必须使用 == 运算符,而不是 = 运算符,后者用于赋值操作。

小兔

可能。

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 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),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。

简述 Go 语言GC(垃圾回收)的工作原理

最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

  • 白色:不确定对象。
  • 灰色:存活对象,子对象待处理。
  • 黑色:存活对象。

标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。

三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:

A (黑) -> B (灰) -> C (白) -> D (白)

正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。

A (黑) -> B (灰) -> C (白) 
  ↓
 D (白)

为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。

一次完整的 GC 分为四个阶段:

  • 1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
  • 2)使用三色标记法标记(Marking, 并发)
  • 3)标记结束(Mark Termination,需 STW),关闭写屏障。
  • 4)清理(Sweeping, 并发)

函数返回局部变量的指针是否安全?

这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

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

  • 一个T类型的值可以调用为*T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T声明的方法。
  • 反过来,一个*T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T自动隐式声明一个同名和同签名的方法。

哪些值是不可寻址的呢?

  • 字符串中的字节;
  • map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
  • 常量;
  • 包级别的函数等。

举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。

type T string

func (t *T) hello() {
	fmt.Println("hello")
}

func main() {
	var t1 T = "ABC"
	t1.hello() // hello
	const t2 T = "ABC"
	t2.hello() // error: cannot call pointer method on t
} 

并发编程

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

对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。

对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

例如:

func main() {
	st := time.Now()
	ch := make(chan bool)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true  // 无缓冲,发送方阻塞直到接收方接收到数据。
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds())
	time.Sleep(time.Second * 5)
}
func main() {
	st := time.Now()
	ch := make(chan bool, 2)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true
	ch <- true // 缓冲区为 2,发送方不阻塞,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 0.0 s
	ch <- true // 缓冲区使用完,发送方阻塞,2s 后接收方接收到数据,释放一个插槽,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 2.0 s
	time.Sleep(time.Second * 5)
}

什么是协程泄露(Goroutine Leak)?

协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:

  • 缺少接收器,导致发送阻塞

这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。

func query() int {
	ch := make(chan int)
	for i := 0; i < 1000; i++ {
		go func() { ch <- 0 }()
	}
	return <-ch
}

func main() {
	for i := 0; i < 4; i++ {
		query()
		fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
	}
}
// goroutines: 1001
// goroutines: 2000
// goroutines: 2999
// goroutines: 3998
  • 缺少发送器,导致接收阻塞

那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。

  • 死锁(dead lock)

两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

  • 无限循环(infinite loops)

这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

func request(url string, wg *sync.WaitGroup) {
	i := 0
	for {
		if _, err := http.Get(url); err == nil {
			// write to db
			break
		}
		i++
		if i >= 3 {
			break
		}
		time.Sleep(time.Second)
	}
	wg.Done()
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go request(fmt.Sprintf("https://127.0.0.1:8080/%d", i), &wg)
	}
	wg.Wait()
}

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.

可以使用环境变量 GOMAXPROCSruntime.GOMAXPROCS(num int) 设置,例如:

runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

代码

常量与变量

下列代码的输出是:

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

golang 100 true

在同一个 const group 中,如果常量定义与前一行的定义一致,则可以省略类型和值。编译时,会按照前一行的定义自动补全。即等价于

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

下列代码的输出是:

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

	const M int32 = 100
	var y int = M
	fmt.Println(x, y)
}

编译失败:cannot use M (type int32) as type int in assignment

Go 语言中,常量分为无类型常量和有类型常量两种,const N = 100,属于无类型常量,赋值给其他变量时,如果字面量能够转换为对应类型的变量,则赋值成功,例如,var x int = N。但是对于有类型的常量 const M int32 = 100,赋值给其他变量时,需要类型匹配才能成功,所以显示地类型转换:

var y int = int(M)

下列代码的输出是:

func main() {
	var a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}

-128

int8 能表示的数字的范围是 [-2^7, 2^7-1],即 [-128, 127]。-128 是无类型常量,转换为 int8,再除以变量 -1,结果为 128,常量除以变量,结果是一个变量。变量转换时允许溢出,符号位变为1,转为补码后恰好等于 -128。

对于有符号整型,最高位是是符号位,计算机用补码表示负数。补码 = 原码取反加一。

例如:

-1 :  11111111
00000001(原码)    11111110(取反)    11111111(加一)
-128:    
10000000(原码)    01111111(取反)    10000000(加一)

-1 + 1 = 0
11111111 + 00000001 = 00000000(最高位溢出省略)
-128 + 127 = -1
10000000 + 01111111 = 11111111

下列代码的输出是:

func main() {
	const a int8 = -1
	var b int8 = -128 / a
	fmt.Println(b)
}

编译失败:constant 128 overflows int8

-128 和 a 都是常量,在编译时求值,-128 / a = 128,两个常量相除,结果也是一个常量,常量类型转换时不允许溢出,因而编译失败

作用域

下列代码的输出是:

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

:= 表示声明并赋值,= 表示仅赋值。

变量的作用域是大括号,因此在第一个 if 语句 if err == nil 内部重新声明且赋值了与外部变量同名的局部变量 err。对该局部变量的赋值不会影响到外部的 err。因此第二个 if 语句 if err != nil 不成立。所以只打印了 1 err

defer 延迟调用

下列代码的输出是:

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) 直接执行,然后执行 fmt.Print(3),最后函数返回时再执行 .f(2),因此输出是 132。

下列代码的输出是:

func f(n int) {
	defer fmt.Println(n)
	n += 100
}

func main() {
	f(1)
}

1

打印 1 而不是 101。defer 语句执行时,会将需要延迟调用的函数和参数保存起来,也就是说,执行到 defer 时,参数 n(此时等于1) 已经被保存了。因此后面对 n 的改动并不会影响延迟函数调用的结果。

下列代码的输出是:

func main() {
	n := 1
	defer func() {
		fmt.Println(n)
	}()
	n += 100
}

101

匿名函数没有通过传参的方式将 n 传入,因此匿名函数内的 n 和函数外部的 n 是同一个,延迟执行时,已经被改变为 101。

下列代码的输出是:

func main() {
	n := 1
	if n == 1 {
		defer fmt.Println(n)
		n += 100
	}
	fmt.Println(n)
}
101
1

先打印 101,再打印 1。defer 的作用域是函数,而不是代码块,因此 if 语句退出时,defer 不会执行,而是等 101 打印后,整个函数返回时,才会执行。