go面试
基础
=
和 :=
的区别?
**Go语言中,=
操作符用于赋值,而 :=
操作符可以用于声明及赋值。 Go 语言支持短变量声明(针对局部变量),以 :=
为标志,这里要注意的是,Go 语言中会优先选择 :=
,而不是 =
,但在赋值的情况下,两者的效果是相同的。 **
Go语言中, =
和 :=
之间的主要区别在于使用 :=
将变量声明时,它会自动分配类型,而 =
不会
指针的作用
Go 语言中的指针是一种特殊的变量类型,用于存储变量的内存地址。通过指针,可以间接地访问和修改存储在内存中的变量,这在某些情况下非常有用。以下是指针在 Go 语言中的主要作用:
- 传递变量的内存地址:当需要将一个变量传递给函数时,如果直接传递变量的值,那么函数内部对变量的修改并不会影响原来的变量。但如果将变量的地址传递给函数,那么函数就可以通过指针来访问和修改变量,从而实现对原来变量的修改。
- 动态分配内存:通过指针,可以在程序运行时动态地分配内存,这在一些需要动态管理内存的应用程序中非常有用。例如,可以使用
new
函数来创建一个新的变量,并返回它的地址。 - 优化内存和性能:通过指针,可以直接访问和修改存储在内存中的变量,而不需要进行复制和传递值,这可以在一些对内存和性能要求较高的应用程序中提高程序的效率。
在 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() {
// 执行另外一些任务
}
在上面的例子中,foo
和 bar
函数都是使用 go
关键字创建的协程。这两个协程将会并发地执行,互不干扰。同时,main
函数本身也是一个协程,可以与其他协程并发执行。
协程通常比线程更加轻量级,因为它们不需要独立的内存空间和操作系统级别的线程调度器。Go 语言的运行时环境会在多个协程之间进行自动的调度,使得程序可以高效地利用多核 CPU,并实现高并发的执行模型。
需要注意的是,协程与线程不同的是,它们的执行顺序是不确定的。因此,在编写使用协程的程序时,需要考虑如何进行同步和互斥,以避免数据竞争和其他并发问题。在 Go 语言中,可以使用通道(channel)等同步机制来实现协程之间的通信和协作。
如何高效的拼接字符串
在 Go 语言中,字符串是不可变的,即一旦创建之后就不能被修改。因此,在需要频繁拼接字符串的情况下,使用简单的字符串拼接操作可能会导致性能问题,因为每次拼接字符串都需要创建一个新的字符串对象。为了高效地拼接字符串,可以使用以下几种方法:
- 使用
strings.Builder
类型。strings.Builder
是一个可变字符串类型,提供了多种方法来高效地拼接字符串,例如WriteString
、WriteByte
、WriteRune
等。使用strings.Builder
类型时,可以减少因为频繁创建字符串对象而导致的内存分配和拷贝操作,从而提高程序的性能。例如:
var builder strings.Builder
builder.WriteString("Hello, ")
builder.WriteString("world!")
result := builder.String()
- 使用
bytes.Buffer
类型。bytes.Buffer
类型是一个可变的字节数组类型,同样提供了多种方法来高效地拼接字符串。与strings.Builder
类型类似,使用bytes.Buffer
时可以减少内存分配和拷贝操作,提高程序性能。例如:
var buffer bytes.Buffer
buffer.WriteString("Hello, ")
buffer.WriteString("world!")
result := buffer.String()
- 使用
fmt.Sprintf
函数。fmt.Sprintf
函数可以格式化字符串并返回一个字符串结果。该函数支持多种格式化选项,例如%d
、%s
等。虽然使用fmt.Sprintf
可能会产生额外的字符串拷贝操作,但在大多数情况下,这种操作的影响很小,而且代码更加简洁易懂。例如:
result := fmt.Sprintf("%s%s", "Hello, ", "world!")
需要注意的是,使用以上方法时,应该尽量避免在循环中频繁拼接字符串,因为这样可能会导致内存分配和拷贝操作过多,从而影响程序的性能。如果需要拼接大量字符串时,建议使用 strings.Builder
或 bytes.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
:
- 使用
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
值为 true
,if
语句中的条件表达式为 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
语句的执行顺序如下:
- 当执行到
defer
语句时,将defer
语句后面的函数或方法压入一个栈中,并记录函数的参数值。 - 在函数或方法返回之前,依次执行栈中的所有函数或方法,即后进先出(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 defer
、2nd defer
和 1st defer
。因此,defer
语句的执行顺序是后进先出的。
需要注意的是,defer
语句中记录的函数参数在 defer
语句执行时就已经确定,因此如果在 defer
语句后面修改参数值,对 defer
语句中的函数没有影响。因此,建议在 defer
语句中不要修改参数值。
go语言实现变量交换
在 Go 语言中,交换两个变量的值可以通过中间变量或使用多重赋值的方式来实现。下面是两种实现方式的示例:
- 中间变量实现:
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
,然后通过中间变量交换 a
和 b
的值。在 main
函数中,我们通过多重赋值的方式来交换 x
和 y
的值,并打印结果。
- 多重赋值实现:
func main() {
x, y := 1, 2
x, y = y, x
fmt.Println(x, y)
}
在上面的代码中,我们使用多重赋值的方式来交换 x
和 y
的值。通过 x, y = y, x
的方式,先将 y
的值赋给 x
,然后将 x
的值赋给 y
,从而交换了 x
和 y
的值。
无论使用中间变量还是多重赋值的方式,都可以很容易地实现两个变量的值交换。需要注意的是,在使用多重赋值的方式时,两个变量的类型必须相同。
go语言中tag的作用
在 Go 语言中,结构体(struct)类型的字段可以使用 tag(标签)来指定一些额外的信息,这些信息通常用于反射(reflection)或序列化(serialization)等场景。tag 是一个字符串,写在结构体字段的后面,用反引号(`)括起来。tag 的格式如下:
`key1:"value1" key2:"value2" ...`
其中,每个键值对之间使用空格分隔,键和值之间使用冒号(:)分隔。tag 中的键必须是非空字符串,值可以是任意字符串。
Go 语言中内置的 reflect
包可以使用 tag 来获取结构体字段的额外信息。例如,可以使用 reflect
包中的 Type
和 Field
函数来获取结构体类型和字段的信息。下面是一个使用 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
的结构体类型,其中每个字段都使用了 json
和 xml
两个 tag。在 main
函数中,我们创建了一个 User
类型的实例,并使用 json
包中的 Marshal
函数将其序列化为 JSON 格式的字符串。在序列化过程中,json
包会自动识别 User
结构体的 tag,将其转换为 JSON 字段名。因此,最终输出的 JSON 字符串中的字段名为 name
、age
和 address
。
除了序列化之外,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
另外,如果字符串切片中的元素是可比较类型(例如 string
、int
等),则可以使用循环来逐个比较每个元素是否相等。示例如下:
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
输出结果与上面的示例相同,为:
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("%vn", p)
}
输出结果为:
{Alice 30}
%+v
表示输出更详细的信息,对于结构体类型,除了递归地输出其所有的字段和值之外,还会输出字段的名称。示例如下:
type Person struct {
Name string
Age int
}
func main() {
p := Person{"Alice", 30}
fmt.Printf("%+vn", 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() {
再比如,声明只包含方法的结构体。
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 种情况
- 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
- 类型 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
}
stu1
和 stu2
对应的类型是 *Stu
,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3
和 stu4
对应的类型是 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
后,a
和 b
都是 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)
什么是协程泄露(Goroutine Leak)?
协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:
- 缺少接收器,导致发送阻塞
这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。
func query() int {
ch := make(chan int)
for i := 0; i
- 缺少发送器,导致接收阻塞
那同样的,如果启动 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
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)
设置,例如:
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 打印后,整个函数返回时,才会执行。