Golang基础面试题

目录

1732.Go语言中如何访问私有成员?

重点回答

在 Go 语言中,以小写字母开头的标识符是私有成员。私有成员(字段、方法、函数等)遵循语言的可见性规则,仅在定义它的包内可见,包外无法访问这些私有成员。如果想要访问私有成员,主要包括以下三种方式:

  • 在同一个包内,可以直接访问小写字母开头的私有成员。
  • 在其他包中,无法直接访问私有成员,但可以通过公开的接口来间接访问私有成员。
  • 使用反射来绕过 Go 语言的封装机制访问和修改私有字段。(不建议使用)

扩展知识

访问私有成员的规则

可见性规则:

  • 私有成员:以小写字母开头的标识符是私有的,仅在定义它的包内可见。包外无法访问这些私有成员。
  • 公开成员:以大写字母开头的标识符是公开的,可以在任何包中访问。

示例代码

1) 私有成员的访问(包内)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package example

// 结构体定义,字段 age 是私有的
type Person struct {
    name string
    age  int
}

// 包内函数,能够访问私有字段
func NewPerson(name string, age int) Person {
    return Person{name: name, age: age}
}

func GetPersonAge(p Person) int {
    return p.age
}

2) 通过公开方法访问私有成员(包外)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
    "fmt"
    "example" // 假设 example 是定义 Person 的包
)

func main() {
    p := example.NewPerson("John", 30)

    // 不能直接访问 p.age,因为 age 是私有的
    // fmt.Println(p.age) // 编译错误

    // 可以通过包内公开的函数访问私有成员
    age := example.GetPersonAge(p)
    fmt.Println("Age:", age) // 输出: Age: 30
}

3) 通过反射访问私有成员

在 Go 语言中,可以使用包 reflect 来访问和修改私有字段。虽然直接访问私有字段违背了封装原则,但反射提供了这种能力。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "fmt"
    "reflect"
)

type Person struct {
    name string
    age  int
}

func main() {
    p := Person{name: "John", age: 30}

    // 获取指向 p 的指针的反射值,Elem 方法用于获取指针指向的值。
    v := reflect.ValueOf(&p).Elem()

    // 获取私有字段 name
    nameField := v.FieldByName("name")

    fmt.Println("name (private):", nameField.String())
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

type Person struct {
    name string
    age  int
}

func main() {
    p := Person{name: "John", age: 30}

    // 获取指向 p 的指针的反射值,Elem 方法用于获取指针指向的值。
    value := reflect.ValueOf(&p).Elem()

    // 通过 FieldByName 方法获取私有字段的值
    field := value.FieldByName("name")

    // 使用 unsafe.Pointer 和反射来操作私有字段
    realField := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()

    // 输出私有字段的值
    fmt.Println("name (private):", realField.String())
}

注意点:

  • 安全性:虽然可以通过反射访问和修改私有字段,但这种做法可能导致程序设计上的问题,破坏了封装性。因此,应谨慎使用,并尽量避免在生产代码中使用这种技术,除非确实有必要。
  • 性能:反射操作通常比直接访问字段要慢,因此在性能敏感的代码中应避免频繁使用反射。

1733.Go语言使用断言时会发生拷贝吗?

回答重点

在 Go 语言中,类型断言是否发生拷贝只取决于接口内部持有的数据类型:

  • 值类型:当接口持有的是值类型(例如:int、float、struct 等),进行类型断言时会发生拷贝,因为接口存储的是这个值的副本,断言后得到的是该值的拷贝。
  • 引用类型:当接口持有的是引用类型(例如指针、切片、映射、通道等),进行类型断言时不会发生拷贝,因为接口存储的是一个引用,断言得到的也是相同的引用。

因此,如果接口中存储的是一个结构体实例,通过断言得到的是结构体的拷贝,修改断言后的变量不会影响接口中的值;而如果接口中存储的是指针,通过断言得到的依然是指针引用,修改断言后的指针值会影响接口内的数据。

扩展知识

什么是类型断言

类型断言用于将接口类型的值转换为具体类型的值。如果你有一个接口类型的变量,可以使用类型断言来提取其动态类型和值。

其基本的格式为:

1
<目标类型的值>, <布尔值> := <表达式>.(<目标类型>)

可以把类型的值(x),转换成类型 T,代码表示为:x.(T)

要正确使用断言,对 x 和 T 的类型有限制如下:

  • 类型断言的必要条件就是接口类型,非接口类型的 x 不能做类型断言;
  • T 可以是非接口类型,如果想断言合法,则 T 必须实现 x 的接口;
  • T 也可以是接口,则 x 的动态类型也应该实现接口 T;

示例:

1
2
var x interface{} = 10
y := x.(int) // 将 x 断言为 int 类型

在这个例子中,x 是 [interface{}] 类型的变量,包含一个动态类型 int 和一个 int 类型的值 10。使用类型断言将其转换为 int 类型,并赋值给变量 y。

注意类型断言错误处理

类型断言如果失败会被 panic。如果不确定接口值的动态类型,可以如下代码写法来避免运行时错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
    "fmt"
)

func main() {
    var x interface{}
    x = 100
    value, ok := x.(int) // 输出100
    if ok {
        fmt.Println(value) // 输出100
    }

    value2, ok := x.(string)
    if ok { // ok=false,所以不进入这个分支
        fmt.Println(value2)
    }
}

这种方式可以安全地检查类型并避免程序崩溃。

为什么值类型会发生拷贝

Go 的接口是一种特殊的类型,用来存储实现了接口的任何数据。接口存储数据时会包含两部分:类型信息(Type)和值信息(Value)。当接口持有值类型时,接口内部存储的就是该值的拷贝,因此类型断言会复制出一个新的副本,而不会影响原接口中的数据。

1
2
3
4
var i interface{} = 12 // 接口存储有一个 int 类型的值
v := i.(int)          // 断言后 v 是 i 中 int 的拷贝
v = 100               // 修改 v 不会影响接口 i 中的值
fmt.Println(i)        // 输出 12

在上述中,v 是 i 中 int 的一个拷贝,因此修改 v 不会影响 i 中的值。

为什么引用类型不会发生拷贝

对于引用类型,接口中存储的是指向该数据的引用,因此类型断言得到的仍然是相同的引用,无论接口是否通过类型断言引用,最终都是指向同一个数据,因此不会产生拷贝。

1
2
3
4
5
6
7
8
type MyStruct struct {
    Field int
}

var i interface{} = &MyStruct{Field: 12} // 接口存储有一个 *MyStruct 指针
v := i.(*MyStruct)                       // 断言后 v 是同一个 *MyStruct 指针
v.Field = 100                            // 修改 v 会影响接口 i 中的数据
fmt.Println(i.(*MyStruct).Field)          // 输出 100

在这个例子中,v 是指向 MyStruct 的指针,与接口 i 中的指针指向相同的地址,因此修改 v 的值会影响接口 i 中的数据。

对比值类型与引用类型的类型断言示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
    "fmt"
)

type MyStruct struct {
    Field int
}

func main() {
    var i interface{} = MyStruct{Field: 12}

    if ms, ok := i.(MyStruct); ok {
        // 使用 &p 显示内存地址
        fmt.Printf("Address of ms: %p\n", &ms)
        fmt.Printf("Address of i: %p\n", &i)
        fmt.Println(ms.Field) // 输出:12
    }

    // 尝试修改 ms 的 Field 值
    ms.Field = 123

    // 无法修改,依然输出12
    if ms2, ok := i.(MyStruct); ok {
        fmt.Println(ms2.Field) // 输出:12
    }

    // 如果我们想要通过接口修改原始值,需要确保接口持有的是引用类型(如指针)
    var ip interface{} = &MyStruct{Field: 12}
    if msp, ok := ip.(*MyStruct); ok {
        msp.Field = 1234567
    }

    // 现在通过断言再次获取,验证修改
    if ms3, ok := ip.(*MyStruct); ok {
        fmt.Println(ms3.Field) // 输出:1234567
    }
}

输出结果如下:

1
2
3
4
5
Address of ms: 0xc0000ec098
Address of i: 0xc0000ec299
12
12
1234567

类型断言和类型转换

类型断言和类型转换在使用场景和方式上有所不同。类型断言是用在接口变量上,而类型转换则是在具有相同底层数据结构的不同类型之间进行转换。

1
2
var a int = 10
var b int32 = int32(a) // 类型转换

1734.Go语言的接口是怎么实现的?

回答重点

在 Go 语言中,接口(interface)是一种动态类型,允许定义对象的行为,而不需要指定具体的实现。

它本质上是一个动态类型动态值的组合:

  • 动态类型:接口持有的具体数据的类型。
  • 动态值:接口持有的具体数据的值或引用。

接口通过这两部分,实现对不同类型的统一操作。

Go 采用鸭子类型的设计哲学,不需要显式声明实现关系。只要一个类型的方法集满足接口的所有方法,编译器自动认为该类型实现了该接口。也就是说,一个类型只需要定义接口要求的方法,就可以被视为实现了该接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//定义接口
type Speaker interface {
    Speak()
}

// 实现接口的类型
type Duck struct {}

// 定义接口要求的方法
func (d Duck) Speak() {
    fmt.Println("mianshiya!")
}

func main() {
    var s Speaker = Duck{} // 接口赋值
    s.Speak()             // 接口方法调用
}

扩展知识

Go 接口的底层实现

Go 的接口底层数据结构是一个包含两个字段的 iface 结构(非空接口)或 eface 结构(空接口)。

iface:非空接口的数据结构
1
2
3
4
5
// 非空接口
type iface struct {
    tab *itab         // 指向类型信息和方法表
    data unsafe.Pointer // 指向实际的数据
}
  • tab *itabitab 是一个指向方法表的指针,包含接口类型和实际类型的配对信息。itab 中存储了具体实现类型的方法地址,这样当接口调用方法时,可以直接通过 tab 访问具体类型的方法。
  • data unsafe.Pointerdata 是一个指针,指向实际的数据。这是一个不安全指针(unsafe.Pointer),能够指向任意类型的内存地址。
eface:空接口的数据结构
1
2
3
4
5
// 空接口
type eface struct {
    _type *_type         // 数据类型信息
    data  unsafe.Pointer  // 指向实际的数据
}
  • _type *_type:空接口中 _type 字段指向数据的类型信息,用于描述接口的动态类型。
  • data unsafe.Pointerdata 指向具体的数据,与 iface 中的 data 类似,用于存储实际的值或指针。
类型表(itab)的结构

在非空接口中,itab 是实现接口动态分派的核心数据结构。itab 中包含接口类型和实际类型的关联信息及其方法地址。

1
2
3
4
5
6
7
type itab struct {
    inter *interfacetype // 接口类型信息
    _type *_type         // 实现接口的具体类型信息
    hash  uint32         // 类型 hash 值
    _     [4]byte
    fun   [1]uintptr     // 实现接口方法的函数地址
}
  • inter *interfacetype:接口的类型信息。
  • _type *_type:具体类型的类型信息。
  • fun [1]uintptr:这是一个函数指针数组,用于存储实际类型实现的接口方法地址。当接口调用某个方法时,会根据 fun 中存储的地址直接找到具体的实现。
1
2
3
4
5
type interfacetype struct {
	typ     _type
	pkgpath name
	mhdr    []imethod // 具体方法列表
}

源码解析

在 Go 中,接口赋值的过程会初始化这些底层结构。源码中实现接口的关键部分如下(简化版):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func convT2I(inter *interfacetype, tab *itab, t *_type, v unsafe.Pointer) (iface, bool) {
    var i iface
    if tab == nil {
        tab = getitab(inter, t, false)
    }
    if tab == nil {
        return i, false
    }
    i.tab = tab
    i.data = v
    return i, true
}

convT2I 函数用于将具体类型转换为接口类型。

步骤

  • 获取 itab 表,通过 getitab 函数找到 inter(接口类型)和 t(具体类型)对应的 itab 表。
  • 将接口的 tab 字段设置为 itab,将 data 指向实际的数据 v

在实际调用接口方法时,会根据 itab 中存储的函数地址调用具体实现。例如,当我们调用 i.Method() 时,Go 会通过 i.tab.fun[methodIndex] 获取函数地址,然后进行方法调用。

类型断言的底层实现

类型断言的底层机制也依赖于接口的数据结构,通过检查接口的 Type,判断断言类型是否与接口的实际类型匹配。

1
2
3
4
5
6
func assertE2I(inter *interfacetype, e eface) (i iface) {
    tab := getitab(inter, e._type, true)
    i.tab = tab
    i.data = e.data
    return
}
  • assertE2I:用于将空接口断言为非空接口。
  • getitab 会返回匹配的 itab,确认断言类型是否与接口的类型一致。如果一致,则将 eface 转换为 iface,这样类型断言就成功了。

示例:接口底层实现过程

拿上述代码举例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//定义接口
type Speaker interface {
    Speak()
}

// 实现接口的类型
type Duck struct {}

// 定义接口要求的方法
func (d Duck) Speak() {
    fmt.Println("mianshiya!")
}

func main() {
    var s Speaker = Duck{} // 接口赋值
    s.Speak()             // 接口方法调用
}
  • var s Speaker = Duck{} 中,Go 语言会初始化 siface 结构,tab 指向 Duck 类型对应的 itab 表,data 指向 Duck{} 的值。
  • 调用 s.Speak() 时,会通过 s.tab.fun 中的地址直接调用 Duck.Speak 方法,输出 "mianshiya!"

接口的类型断言与类型判断

  • 类型断言i.(T) 用于断言接口 i 是否为类型 T,如果接口的 TypeT 匹配,返回对应的 Value,否则引发 panic
  • 类型判断(type Switch):使用 switch i.(type) 可以匹配接口持有的具体类型。Go 通过底层的 Type 信息支持这种模式匹配,实现接口多态调用。

接口的零值与 nil 判断

接口的零值为 nil,当且仅当接口的 TypeValue 都为 nil 时,接口本身才是 nil

如果一个接口的 Type 不为 nilValuenil,则接口并不等于 nil。(这种情况常出现在接口赋值时,例如将一个指针值为 nil 的结构体赋值给接口变量时)

1735.Go语言中怎么实现闭包?闭包的主要应用场景是什么?

回答重点

在 Go 语言中,闭包(Closure)是一个函数值,它可以引用其外部作用域中的变量。在 Go 中实现闭包的方法非常简单,我们可以通过在一个函数内部定义另一个函数,并让其访问外部函数的变量来实现。

即函数可以访问被引用的变量并对其赋值,函数被“绑定”到变量上。

下面是一个简单的 Go 语言闭包示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 实现闭包的函数
func adder() func(int) int {
    sum := 0                   // 定义外部作用域的变量
    return func(x int) int {   // 返回一个匿名函数
        sum += x               // 操作外部作用域的变量
        return sum             // 返回结果
    }
}
func main() {
    pos, neg := adder(), adder()   // 创建两个闭包
    for i := 0; i < 10; i++ {
        fmt.Println(pos(i), neg(-2*i))  // 调用闭包
    }
}

在这个示例中,adder 函数返回一个闭包,该闭包内部引用并操作了 sum 这个外部变量。

闭包在实际编程中具有广泛的应用,其主要应用场景包括但不限于:

1)伪全局变量:由于闭包可以捕获外部变量,因此通过闭包可以实现类似全局变量的效果。而相比全局变量,使用闭包可以通过变量的作用域限制变量的范围。

2)函数工厂: 根据不同的配置参数来动态创建函数。

3)装饰器模式:通过闭包在不修改原有函数的情况下,动态地添加新的功能,实现装饰器模式。

3)回调函数:将一个函数作为参数传递给另一个函数,通过闭包,捕获一些上下文信息并执行该函数

4)并发编程:可以安全地在多个goroutine中共享和修改变量,一种简洁的方式

5)保存中间态:用于累加器或者计数器,以保存中间状态。

扩展知识

既然我们已经了解了如何在 Go 语言中实现闭包,下面我们来扩展一下对闭包的理解和它的一些更高级的应用场景。

1)保存中间态: 闭包可以用来持久化某些状态,比如用作计数器或累加器。你只需每次调用它,它就会“记住”上一次的运行状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func incrementer() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}
func main() {
    inc := incrementer()
    fmt.Println(inc()) // 1
    fmt.Println(inc()) // 2
    fmt.Println(inc()) // 3
}

2)工厂模式:比如下例子中,分别提供加法、减法、乘法和除法的闭包函数,并封装为函数工厂,使得函数的创建更加灵活和可定制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func CalcFactory(operation string) func(int, int) int {
	switch operation {
	case "add":
		return func(a, b int) int {
			return a + b
		}
	case "subtract":
		return func(a, b int) int {
			return a - b
		}
	case "multiply":
		return func(a, b int) int {
			return a * b
		}
	case "divide": // 这里是向下取整了,因为函数签名返回是int
		return func(a, b int) int {
			if b != 0 {
				return a / b
			}
			return 0
		}
	default:
		return nil
	}
}
func main() {
	addFunc := CalcFactory("add")
	subtractFunc := CalcFactory("subtract")
	multiplyFunc := CalcFactory("multiply")
	divideFunc := CalcFactory("divide")

	fmt.Println(addFunc(4, 5)) //9
	fmt.Println(subtractFunc(4, 5)) // -1
	fmt.Println(multiplyFunc(4, 5))// 20
	fmt.Println(divideFunc(5, 4))// 1
}

3)实现装饰器模式: 装饰器模式通过闭包可以方便地实现,即在不修改原有函数的情况下,动态地添加新的功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func doubleAndPrint(fn func(int) int) func(int) int {
    return func(x int) int {
        result := fn(x)
        fmt.Println("Result:", result*2)
        return result
    }
}
func square(x int) int {
    return x * x
}
func main() {
    doubleSquare := doubleAndPrint(square)
    doubleSquare(3) // 18 (3*3*2)
}

4)回调函数:可以用于我们需要等待某个长时间的操作或者某个事件触发之后的场景。比如下面例子,使用匿名函数创建了一个闭包,用于做回调函数。在执行异步操作时将计算结果传递给回调函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func TaskAsync(input int, callback func(int)) {
    go func() {
       time.S leep(1 * time.Second)// 模拟运算操作
       result := input * 3
       callback(result)
    }()
}
func main() {
    callback := func(result int) {
       fmt.Println("操作结果:", result) // 输出30
    }
    TaskAsync(10, callback) 
    time.S leep(5 * time.Second) // 让主协程hang主,避免程序结束。
}

5)并发编程:比如下例子中,使用for循环为每个任务创建一个匿名函数。这些匿名函数使用闭包来捕获循环变量i和任务函数task。在每个匿名函数内部,我们调用任务函数,并将结果存储在相应的位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func main() {
	var wg sync.WaitGroup
	var urls = []string{
		"http://www.google.com/",
		"http://golang.org/",
		"http://m.baidu.com/",
	}
	for _, url := range urls {
		wg.Add(1)
		go func(url string) {
			defer wg.Done()
			// http.Get(url)
			fmt.Println(url)
		}(url)
	}
	wg.Wait()
}

6)封装逻辑: 通过闭包可以将某些逻辑封装起来,使得代码更加简洁和易读,尤其是在处理回调函数或事件处理程序时。

7)模拟私有变量: 闭包可以模拟一些语言中私有变量的功能,从而实现数据的封装和隐藏。

1736.Go语言中触发异常的场景有哪些?

重点回答

在 Go 语言中,使用 error 类型来处理错误,并通过 panic 和 recover 来处理程序的异常情况。以下是一些可能触发 panic(即异常)的场景:

  1. 数组或切片越界
  2. 空指针解引用
  3. 调用 panic 函数
  4. 非法类型断言
  5. 数学错误
  6. 内存越界或非法操作
  7. 运行时错误
  8. 使用不安全的库或代码

在上述1、2、4和5是在写代码中最常遇见的异常场景。

扩展知识

1. 数组或切片越界

访问数组或切片时,如果索引超出范围,会触发 panic。例如:

1
2
arr := []int{1, 2, 3}
fmt.Println(arr[5]) // 触发 panic: runtime error: index out of range

2. 空指针解引用

解引用一个 nil 指针会导致 panic。例如:

1
2
3
4
5
var ptr *int
fmt.Println(*ptr) // 触发 panic: runtime error: invalid memory address or nil pointer dereference

var m map[string]string
m["a"] = "3123" // 触发 panic: assignment to entry in nil map

3. 调用 panic 函数

可以手动触发 panic,通常用于程序中的非预期情况:

1
panic("something went wrong") // 触发 panic

4. 非法类型断言

进行非法类型断言时会触发 panic。例如:

1
2
var i interface{} = "string"
num := i.(int) // 触发 panic: interface conversion: interface {} is string, not int

5. 数学错误

某些数学操作可能触发 panic,例如除以零:

1
result := 1 / 0 // 触发 panic: runtime error: integer divide by zero

6. 内存越界或非法操作

使用 unsafe 包进行非法内存操作可能会导致 panic,例如:

1
2
3
4
import "unsafe"

var p unsafe.Pointer
*(*int)(p) = 42 // 触发 panic: runtime error: invalid memory address or nil pointer dereference

7. 运行时错误

有些运行时错误(如堆栈溢出)可能会导致 panic。这类错误通常很难预测和处理。

8. 使用不安全的库或代码

某些第三方库或不安全的代码片段可能会引发 panic,尤其是那些直接操作内存的库。

如何处理 Panic

Go 语言提供了 defer 和 recover 来处理 panic。defer 可以用来确保某些清理操作总是会执行,而 recover 可以用来捕获 panic 并防止程序崩溃。例如:

1
2
3
4
5
6
7
8
9
func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()
    result = a / b
    return
}

在这个例子中,如果 a / b 触发了 panic(例如 b 为 0),recover 可以捕获这个 panic 并将其转换为一个错误。

1737.Go语言中通过指针变量p访问其成员变量title,有哪几种方式?

回答重点

在 Go 语言中,通过指针变量 p 访问其成员变量 title 主要有以下两种方式:

1)使用 (*p).title 访问成员变量。

2)由于 Go 提供了指针的简写支持,还可以直接使用 p.title 来访问成员变量。

这两种方式其实是等价的,Go 编译器会帮你处理其中的细节。

有 4 种情况可以使用简洁支持:

1)通过解引用访问成员变量

2)通过解引用访问方法接受者为指针类型的方法

3)通过解引用访问结构体切片中的指针元素

4)通过解引用访问嵌套结构体中的变量

扩展知识

1)使用显式解引用

通过显式解引用指针,用 (*p).title 访问。这个语法看起来有点繁琐,但是在某些情况下明确的解引用可以让代码更直观。(实际代码开发中不推荐这样操作)

1
2
3
4
5
6
7
8
9
type Book struct {
    title string
}
func main() {
    b := Book{title: "Go Programming"}
    p := &b
    // 显式解引用
    fmt.Println((*p).title)
}

2)简写方式

2.1 通过解引用访问成员变量

Go 提供了简化语法,允许直接使用 p.title 访问指针成员,这样代码更简洁,同时也更符合其他主流编程语言的习惯:

1
2
3
4
5
6
7
8
9
type Book struct {
    title string
}
func main() {
    b := Book{title: "Go Programming"}
    p := &b
    // 简写方式
    fmt.Println(p.title)
}

2.2 通过解引用访问方法接受者为指针类型的方法

Go 会自动解引用指针,因此 p.title 实际上是 (*p).title 的简写形式。这种设计意图是为了让开发者写代码时更加简洁,而不需要频繁地解引用。此外,这种自动解引用在方法调用中也适用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Book struct {
    title string
}
func (b *Book) printTitle() {
    fmt.Println(b.title)
}
func main() {
    b := Book{title: "Go Programming"}
    p := &b
    // 调用方法时,Go 会自动解引用
    p.printTitle()  // 等价于 (*p).printTitle()
}

2.3 通过解引用访问结构体切片中的指针元素

在下面示例中,通过解引用操作 people[0].Name 来访问结构体切片中的第一个元素的 Name 字段。

1
2
3
4
5
6
7
8
type Person struct {
	Name string
	Age  int
}
func main() {
	people := []*Person{{"Alice", 30}, {"Bob", 31}}
	fmt.Println(people[0].Name) // 输出:Alice
}

2.4 通过解引用访问嵌套结构体中的变量

在下面示例中,通过解引用操作 p.Address.City 来访问嵌套的 Address 结构体中的 City 字段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type Address struct {
	City string
}
type Person struct {
	Name    string
	Age     int
	Address *Address
}
func main() {
	p := &Person{
		Name: "Alice",
		Age:  30,
		Address: &Address{
			City: "Beijing",
		},
	}
	fmt.Println(p.Address.City) // 输出:Beijing
}

简洁与明确都是编程中的重要原则,在不同场景下选择合适的方式是重要的。在平时我们可以更多地使用简写语法 p.title 来让代码更易读,但在一些复杂的代码中显式解引用 (*p).title 也可能会使逻辑更清晰。

1738.Go语言中defer的变量快照在什么情况下会失效?

重点回答

在 Go 语言中,defer 的变量快照是指在 defer 语句定义时所捕获的变量的状态。但有些情况下,defer 语句中的变量快照可能会失效,导致不如预期那样行为,如下:

1)匿名函数闭包:当 defer 语句中使用的匿名函数捕获了外部变量时。如果变量的值在 defer 语句定义后发生变化,defer 执行时会使用变化后的值。

2)引用类型:当 defer 引用持有引用类型的变量(如指针、切片、映射、通道和函数)时,虽然引用本身的地址不会变,但指向的内容可能变化,这也会导致最终的结果和预期的快照结果不同。

扩展知识

在编程的时候,经常需要打开一些资源,比如数据库连接、文件、锁等,这些资源需要在用完之后释放掉,否则会造成内存泄漏。在 Go 中 defer 一般用于资源清理、文件关闭、解锁互斥量等操作。

1)defer常见用法

在函数开始时定义 defer ,确保退出时资源能够正确释放。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    file, err := os.Open("example.txt")
    if err != nil {
        fmt.Println("Error opening file:", err)
        return
    }
    defer file.Close()

    // 处理文件的逻辑
}

2)闭包的defer变量

1
2
3
4
5
6
func main() {
    x := 0
    defer func() { fmt.Println("deferred x:", x) }() // 这里捕获的是x的引用
    x = 1
    fmt.Println("Normal x:", x)
}

输出结果:

1
2
Normal x: 1
deferred x: 1

3)defer+引用类型

defer 引用持有引用类型的变量时,如果变量指向的内容发生变化,这也会导致最终的结果和预期的快照结果不同。

1
2
3
4
5
6
func main() {
    x := []int64{0}
    defer fmt.Println("deferred x:", x) // 这里捕获的是x的引用
    x[0] = 1
    fmt.Println("Normal x:", x)
}

输出结果:

1
2
Normal x: [1]
deferred x: [1]

4)defer+函数参数

把变量作为函数的参数传递给匿名函数,defer 后面跟的就是一个函数调用了。

1
2
3
4
5
6
7
func main() {
    for i := 0; i < 3; i++ {
        defer func(item int) {  // 函数调用
                fmt.Println(item)
        }(i)
    }
}

输出结果:

1
2
3
2
1
0

1739.不分配内存的指针类型能在Go语言中使用吗?

回答重点

在 Go 语言中,不分配内存的指针类型可以使用,但是只能用该指针本身,不可以用*去解引用出具体的值,会导致 panic 。这是因为 Go 允许声明指针变量,但如果不分配内存(没有指向有效的地址),该指针会是 nil。访问 nil 指针会导致运行时错误。

简单的说,Go 中声明一个指针变量是非常直接的,你可以使用 *Type 来声明一个指针类型,但注意在你对指针进行解引用操作之前,你必须确保它指向了一个有效的地址。否则,会引发 panic。

下面代码中,p 是一个指向 int 类型的指针变量。Go 语言会自动为其分配8字节的内存空间来存储一个指针值。但是 p 并不指向任何有效的 int 值,因为还没有为其分配或指定一个 int 类型的内存区域。此时 p 被称为空指针或未初始化的指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import "fmt"

func main() {
    var p *int // 声明指针变量,但不分配内存,此时 p 是 nil

    if p == nil {
        fmt.Println("p 是 nil 指针")
    }

    // 这行代码会引发运行时错误
    // fmt.Println(*p)
}

扩展知识

推荐使用姿势

在 Go 语言中,如果要使用指针,为了避免导致程序运行出错(比如 panic),最好在使用之前判空处理一下,并且,在如果是结构体指针,则需要多重判空,示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"fmt"
)

type Address struct {
	City string
}

type Person struct {
	Name    string
	Age     int
	Address *Address  // Person 结构体中含有一个 Address指针 成员
}

func main() {
	var p *Person  // p 是一个指向Person的指针

        //访问 City 成员前,必须确认 p 和 p.Address 都不为nil
	if p != nil && p.Address != nil {
		fmt.Println(p.Address.City)  
	} else {
		fmt.Println("Nil pointer detected.")
	}
}

指针使用方式

Go 语言中的指针使用方式其实与 C、C++ 等传统语言基本相似,但也有一些独特之处:

1)分配内存: 可以使用 new 函数或者通过 & 操作符来分配内存,使指针指向一个有效的地址。举例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import "fmt"

func main() {
    // 使用 new 分配内存
    p := new(int)
    *p = 42
    fmt.Println(*p) // 输出:42

    // 使用 & 操作符分配内存
    var x int = 10
    px := &x
    fmt.Println(*px) // 输出:10
}

2)nil 指针: 如同前述,如果一个指针没有被分配内存,那么它的值就是 nil。试图解引用这样的指针会引起运行时错误。类似于下面的代码:

1
2
3
4
5
6
7
8
9
package main

func main() {
    var p *int
    if p != nil {
        // 这种情况不会发生,因为 p 是 nil
        println(*p)
    }
}

3)指针和切片、映射: Go 语言的切片和映射本质上包含了对底层数组和哈希表的指针,因此在传递这些数据结构时,不需要显式使用指针来避免拷贝。它们已自带指针语义。

4)方法接收者:在定义方法时,可以用值接收者或者指针接收者。使用指针接收者可以避免拷贝,且可以改变接收者的状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

type Rect struct {
    Width, Height int
}

// 指针接收者方法
func (r *Rect) Area() int {
    return r.Width * r.Height
}

// 值接收者方法
func (r Rect) Perimeter() int {
    return 2 * (r.Width + r.Height)
}

func main() {
    r := Rect{Width: 10, Height: 5}
    fmt.Println("Area:", r.Area())
    fmt.Println("Perimeter:", r.Perimeter())
}

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

回答重点

Go 语言中的局部变量既可能分配在栈上,也可能分配在堆上

如果变量的生命周期局限于函数作用域,并且不会逃逸到函数外,则分配在栈上。

如果局部变量的生命周期超出函数作用域(如通过指针返回给外部使用),编译器会将变量分配在堆上,确保变量在作用域外仍然有效,这种机制称为“逃逸分析”。

扩展知识

栈和堆的区别

栈分配:

  • 栈是线程私有的,分配和释放内存由编译器管理。
  • 分配速度快,通常用于函数的局部变量。

堆分配:

  • 堆是全局共享的,内存的分配和释放由垃圾回收器(GC)管理。
  • 适用于动态分配和长生命周期的内存,但性能较慢。

逃逸分析的工作原理

什么是逃逸分析?

逃逸分析是一种编译时优化技术,编译器通过分析变量的使用场景,确定变量的生命周期及其存储位置是在堆上还是在栈上。

核心逻辑是判断变量的引用是否超出了函数的生命周期

触发堆分配的场景:

1)返回指针: 如果局部变量的地址被返回,则变量会逃逸到堆上。

1
2
3
4
func createPointer() *int {
    x := 10
    return &x // x 逃逸到堆上
}

2)闭包捕获: 如果局部变量被闭包捕获,其生命周期可能超过函数范围,因此需要堆分配。

1
2
3
4
5
6
func closure() func() int {
    x := 10
    return func() int { // x 逃逸到堆上
        return x
    }
}

3)跨 Goroutine 传递: 如果局部变量被传递到另一个 Goroutine,则无法保证其安全存储在栈上。

1
2
3
4
5
6
func goroutine() {
    x := 10
    go func() {
        fmt.Println(x) // x 逃逸到堆上
    }()
}
如何查看逃逸分析结果?

使用 go buildgo run-gcflags="-m" 参数可以查看逃逸分析结果:

1
go build -gcflags="-m" main.go

示例输出:

1
main.go:5:6: moved to heap: x

栈分配与堆分配代码示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func stackAllocation() {
    x := 42 // x 分配在栈上
    fmt.Println(x)
}
func heapAllocation() *int {
    x := 42 // x 分配在堆上,因为返回了指针
    return &x
}
func main() {
    stackAllocation()

    p := heapAllocation()
    fmt.Println(*p)
}

1741.Go语言中所有的T类型都有*T类型吗?

重点回答

不是。在 Go 语言中,几乎所有的类型 T 都可以有一个对应的指针类型 *T不过接口类型的指针是无效的

扩展知识

1)普通情况

对于大多数类型(包括基础类型、自定义类型、结构体、切片、映射、通道等),你可以使用 *T 来表示类型 T 的指针。以下是一些示例:

  • 基础类型:

    1
    2
    
    var a int = 10
    var p *int = &a
  • 结构体:

    1
    2
    3
    4
    5
    6
    
    type Person struct {
        Name string
        Age  int
    }
    var p Person
    var pPtr *Person = &p
  • 切片、映射、通道:

    1
    2
    3
    4
    5
    6
    7
    8
    
    var s []int
    var sPtr *[]int = &s
    
    var m map[string]int
    var mPtr *map[string]int = &m
    
    var ch chan int
    var chPtr *chan int = &ch

2) 特殊情况

  • 数组: 数组类型 T 也可以有一个对应的指针类型 *T。在 Go 中,数组的指针类型 *[N]int(例如 *[5]int)表示一个固定大小为 N 的数组的指针。
  • 接口: 接口类型的指针 *interface{} 是无效的。接口是引用类型,它们本身就可以直接引用其他对象,没有必要使用指针类型。
  • 函数: 函数类型也可以有一个指针类型。例如,func() int 类型的函数可以有一个 *func() int 类型的指针,但通常我们不常见函数指针类型的直接使用。

1742.Go语言中数组与切片有什么异同?

回答重点

数组

  • 大小:数组的长度是固定的,编译时确定,无法在运行时改变。
  • 内存:数组是值类型,存储在栈上(或在堆上,根据上下文)。整个数组在内存中是连续的。
  • 传递方式:数组作为参数传递时会进行值传递,复制整个数组的内容。
  • 操作:对数组的任何修改都会影响当前的数组副本。

切片

  • 大小:切片是动态大小的,支持在运行时扩展。由三部分组成:指向底层数组的指针、切片的长度和切片的容量。
  • 内存:切片是引用类型,存储的是对底层数组的引用。当切片扩容时,会创建新的底层数组,并将数据复制到新数组中。
  • 传递方式:切片作为参数传递时是引用传递,传递的是切片的元数据(指针、长度、容量)。
  • 操作:切片可以动态扩展、裁剪等操作,不会改变原数组。
特性 数组 切片
长度 固定,编译时确定 动态,可以扩展
类型 数组的类型包括元素类型和长度(如 [3]int 切片是引用类型,类型为 []T(无长度)
内存管理 数组在内存中是连续的,存储在栈上或堆上 切片是对底层数组的引用
传递方式 值传递,复制整个数组内容 引用传递,传递的是切片的元数据(指针、长度、容量)
灵活性 不灵活,大小固定 灵活,可以通过 append 动态增长
扩展性 不支持扩展,大小在编译时固定 支持动态扩展,append 会扩展切片容量
性能 性能较好,尤其在不需要扩展时 扩展切片时会有一定的性能开销,但更适应动态数据操作
操作简便性 不能直接扩展或裁剪 提供了丰富的内建函数(如 appendcopy

小结

  • 数组:适用于固定大小的集合,不需要扩展或修改大小的场景。内存管理较为简单,但不灵活,且会带来值传递的性能开销。
  • 切片:适用于动态大小的集合,灵活性高,支持扩展和裁剪。需要注意扩容带来的性能损失,使用时需要谨慎管理容量和内存。

扩展知识

数组和切片的实现进一步分析

数组的实现

数组是值类型,其元素在内存中是紧凑且连续的。当数组作为参数传递时,Go 会通过值传递进行完整的复制,复制的是整个数组的内容。数组长度是数组类型的一部分,例如 [3]int[4]int 是不同类型,编译器在编译时就会决定数组的大小。

切片的实现

切片的底层结构包含三个元素:指针、长度和容量(array, len, cap)。切片的指针指向底层数组的起始位置。切片的长度是当前有效元素的数量,容量是底层数组的总大小。由于切片是引用类型,因此切片本身并不存储数据,只是一个对底层数组的视图。

当切片的容量不足时,Go 会通过 append 操作扩容。扩容时,Go 会分配一个新的更大的底层数组,并将原有数据复制到新数组中。通常,扩容的容量会翻倍,直到内存不足时会按一定策略调整容量。在源码层面,append 函数中会检查切片的当前容量,若不足则调用 grow 函数扩展容量:

1
2
3
4
5
6
7
8
func grow(slice []T, newCap int) []T {
    if newCap <= cap(slice) {
        return slice
    }
    newSlice := make([]T, len(slice), newCap)
    copy(newSlice, slice)
    return newSlice
}

grow 函数通过 make 创建了一个新的切片并复制数据。这一扩容操作带来了内存开销。

数组的常见用法

常用于需要固定大小、无法变化的情况。比如,实现固定大小的缓冲区或储存特定数量的数据。

1
2
var arr [5]int // 定义一个长度为5的数组
arr[0] = 10    // 可以直接访问和修改元素

由于数组是值类型,传递时会复制整个数组。在函数参数中使用数组时,需要考虑到性能开销。如果数组非常大,可能会带来不必要的复制开销。

1
2
3
func modifyArray(arr [5]int) {
  arr[0] = 100 // 修改数组不会影响原数组
}

切片的常见用法

切片的最大优势之一是它的动态扩展。可以通过 append 函数向切片添加元素,Go 会自动处理底层数组的扩容。

1
2
slice := []int{1, 2, 3}
slice = append(slice, 4, 5) // 切片会自动扩容

通过切片操作,可以从一个切片中获取子切片。这不会复制底层数据,只是创建了一个新的切片指向原数组的某一部分。

1
2
slice := []int{1, 2, 3, 4, 5}
subSlice := slice[1:4] // 获取一个子切片,指向原数组的一部分

切片的内存泄漏问题:由于切片引用底层数组,如果切片被泄露或超出了预期范围,底层数组可能会占用不必要的内存。要避免这种情况,应及时切割掉不需要的部分,或者通过手动管理内存来避免泄漏。

切片扩容机制

当扩容时,Go 会为新的切片分配一个更大的底层数组,并将原数据复制过去。这个操作可能会带来性能上的开销,特别是在处理大量数据时。

扩容逻辑示意代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func append(slice []int, value int) []int {
    if len(slice) == cap(slice) {
        // 如果切片满了,则扩容
        newSlice := make([]int, len(slice), 2*cap(slice)) // 容量翻倍
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = append(slice, value)
    return slice
}

扩容的大小

  • 在 1.18 以前:容量小于 1024 时为 2 倍扩容,大于等于 1024 时为 1.25 倍扩容。
  • 在 1.18 及以后:容量小于 256 时为 2 倍扩容,大于等于 256 时的扩容因子逐渐从 2 减低为 1.25。
starting cap growth factor
256 2.0
512 1.63
1024 1.44
2048 1.35
4096 1.30
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 1.18 及之后版本
// nextslicecap 计算下一个合适的切片容量。
// 参数 newLen 是目标切片的长度,oldCap 是当前切片的容量。
// 返回值是根据新的长度和当前容量计算出的最适合的容量。
func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap                   // 初始化新的容量为当前容量
	doublecap := newcap + newcap       // 计算当前容量的两倍
	if newLen > doublecap {            // 如果目标长度大于两倍当前容量,则返回目标长度
		return newLen
	}

	const threshold = 256              // 定义一个阈值,当容量小于阈值时,采用2倍增长
	if oldCap < threshold {            // 如果当前容量小于阈值,则返回当前容量的2倍
		return doublecap
	}

	for {
		// 对于较小的切片,容量增长为2倍;对于较大的切片,容量增长为1.25倍。
		// 这个公式实现了在两者之间的平滑过渡。
		newcap += (newcap + 3*threshold) >> 2 // 增长1.25倍

		// 需要检查 newcap 是否大于等于 newLen,并且检查 newcap 是否溢出。
		// 因为 newLen 保证大于零,因此当 newcap 溢出时,uint(newcap) 会大于 uint(newLen)。
		// 这样我们可以通过相同的比较来检查这两个条件。
		if uint(newcap) >= uint(newLen) {
			break // 如果新容量大于等于目标长度,跳出循环
		}
	}

	// 如果 newcap 计算溢出,设置 newcap 为目标长度。
	// 因为容量溢出时,newcap 可能变为负数或 0,因此返回目标长度。
	if newcap <= 0 {
		return newLen
	}

	return newcap // 返回计算出的合适容量
}

延伸问题:如何避免切片频繁扩容?

可以通过预先分配合适的容量来减少扩容的次数。

1
slice := make([]int, 0, 1000) // 预分配1000个容量,减少扩容

1743.Go语言中init()函数在什么时候执行?

回答重点

init() 函数在 Go 程序执行之前自动调用,会在 main() 函数执行之前。

用于初始化包级别的变量,用来设置初始状态或者执行一次性初始化操作(它不能有参数,也不能返回值)。每个包中的 init() 函数在该包的其他代码执行之前运行,每个包可以有多个 init() 函数。

执行顺序

  1. 包的初始化顺序:如果一个包被多个包依赖,Go 会首先执行所有导入的包的 init() 函数,依赖的包 init() 函数会先执行。
  2. 同一个包内的多文件多 init() 函数:以文件名的顺序调用 init(),同一个文件内的多个 init() 则是以出现的顺序依次调用。
  3. 程序入口:最终在 main() 函数开始执行之前,所有相关包的 init() 函数都已经执行完毕。

以下图片来自《Go语言高级编程》:

init()函数执行顺序.png
init()函数执行顺序

扩展知识

注意点

main.main 函数执行之前所有代码都运行在同一个 Goroutine 中,即主系统线程中。

但是如果某个 init() 函数内部用启动了新的 Goroutine ,那么新的 Goroutinemain.main 函数是并发执行的。

使用场景与实际例子

初始化数据库连接init() 可以用于初始化数据库连接或进行其他资源的配置。

1
2
3
4
5
6
7
func init() {
  db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/dbname")
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("数据库连接成功")
}

注册自定义的日志记录器:当程序启动时,init() 可用于设置全局的日志记录配置。

1
2
3
func init() {
  log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}

1744.GO语言中非接口的任意类型T都能调用*T的方法么?反过来呢?

回答重点

都可以的。在Go语言中,对于非接口的任意类型T,确实可以调用 * T(指向T的指针)的方法。这是因为当你尝试在一个T类型的值上调用一个 * T 方法时,Go编译器会隐式地获取该值的地址,然后调用相应的方法。这种行为被称为指针接收者的方法调用的自动解引用。

示例代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type MyStruct struct {
	field int
}

func (ms MyStruct) ValueReceiverMethod() {
	fmt.Println("ValueReceiverMethod")
}

func (ms *MyStruct) PointReceiverMethod() {
	fmt.Println("PointReceiverMethod")
}

func main() {
	var s MyStruct
	s.ValueReceiverMethod() // 输出 ValueReceiverMethod
	s.PointReceiverMethod() // 输出 PointReceiverMethod
	tmpS := &s
	tmpS.ValueReceiverMethod() // 输出 ValueReceiverMethod
	tmpS.PointReceiverMethod() // 输出 PointReceiverMethod
}

扩展知识

  1. 方法集(Method Sets):在Go中,每个类型都有一个与之关联的方法集。对于类型T,其方法集包含所有接收者类型为T的方法;而对于* T,其方法集包含所有接收者类型为T和* T的方法。这意味着,如果你有一个* T类型的值,你可以调用T和* T两种类型的方法,而如果你只有T类型的值,你只能调用T类型的方法,但Go编译器会隐式地获取该值的地址,然后调用相应的方法。
  2. 接口(Interfaces):Go的接口是一种类型,它定义了一组方法签名,任何实现了这些方法的具体类型都被认为实现了该接口。当你将一个具体类型的值赋给一个接口类型的变量时,Go会检查该具体类型的方法集是否包含了接口定义的所有方法。
  3. 值接收者和指针接收者:在定义方法时,可以选择使用值接收者(T)或指针接收者(* T)。使用值接收者时,方法内部对接收者的修改不会影响到原始值;而使用指针接收者时,方法内部对接收者的修改会影响到原始值。
  4. 多态(Polymorphism):通过接口,Go实现了多态。这意味着你可以使用接口类型的变量来持有实现了该接口的任何具体类型的值,并调用接口定义的方法,而不需要关心具体类型是什么。
  5. 嵌入类型(Embedded Types):Go支持类型嵌入,这是一种将一个类型嵌入到另一个类型中,使得被嵌入类型的方法成为外部类型的方法的机制。这提供了一种类似于继承的代码重用方式。

1745.Go语言中函数返回局部变量的指针是否安全?

回答重点

在 Go 语言中,函数返回局部变量的指针是安全的。这是由于Go的编译器和运行时系统通过逃逸分析可以意识到该局部变量将在函数外被引用,它们会在堆(而不是栈)上为它分配内存。这样,即使函数结束,局部变量的内存位置仍会保留。

在 Go 编译阶段,Go 编译器对每个局部变量执行逃逸分析。如果它发现局部变量的使用范围超过其所在函数,那么该变量的内存不会在栈上分配,而是在堆上。由于这些变量位于堆区,它们在函数结束后仍然保持原状。

编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况。

虽然在 Go 中返回局部变量的指针是安全的,但对返回局部变量的指针进行操作不一定安全。例如,对指向 slice 类型对象的指针进行了扩容操作,这个指针可能由于底层数组的改变就会变得无效。

扩展知识

关于逃逸

在一些编程语言(如 C/C++)中,返回局部变量的指针是不安全的,因为当函数返回后,局部变量的存储空间会被释放。然而,在 Go 中,情况不同。Go 语言的编译器会根据变量的逃逸分析(escape analysis)决定变量的存储位置。

1)逃逸分析:逃逸分析可以确定哪些变量超出了函数的作用范围。在调用函数时,如果一个变量的地址被传递出去,或者函数返回一个局部变量的地址,该变量会被视为“逃逸”。

2)内存逃逸:当编译器检测到一个变量“逃逸”出函数作用域时,会将该变量分配到堆上而不是栈上。堆上分配的变量其生命周期会超出函数调用,因而安全可靠。

3)垃圾回收:Go 语言使用垃圾回收机制(Garbage Collector, GC)来管理内存。GC 会定期扫描堆内存,并回收不再使用的对象。在垃圾回收机制下,程序不需要过多关心内存管理问题,能够安全且高效地处理局部变量指针。

下面是一个简单的代码示例,展示了函数返回局部变量指针的安全性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func createPointer() *int {
    var localVar int = 42
    return &localVar
}

func main() {
    ptr := createPointer()
    fmt.Println(*ptr)  // 输出 42
}

在这个例子中,createPointer 函数返回了一个局部变量 localVar 的指针。这是安全的,因为 Go 编译器会自动处理内存分配,将 localVar 放在堆中,从而确保其在 main 函数中仍然有效。

1746.Go语言切片的容量是如何增长的?

回答重点

在 Go 语言中,切片的容量是一种动态增长的机制。当切片的长度达到或超过容量时,Go 语言会自动扩展其底层数组的容量,一般由append触发。切片容量增长(growslice)的具体规则在不同版本的规则不同。

对于 go1.18 之前来说:

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

对于 go1.18 之后来说,减小了倍增阈值,但是在后续25%的幅度增加的时候把阈值作为基准的一部分,来避免扩容次数过多的问题:

  • 如果期望容量大于当前容量的两倍就会使用期望容量;
  • 如果当前切片的长度小于阈值 256的话,growslice 时就会将容量翻倍;
  • 如果当前切片的长度大于等于256,就会每次增加 (newcap + 3*threshold)/4的容量,直到新容量大于期望容量。

此外,在扩容之后还会进行一步roundupsize,这一步主要是靠内存对齐的优化,来计算出最终的容量。

扩展知识

要深入了解 Go 语言切片的容量增长机制,得从它的底层实现说起。

1)1.18之前和之后的源码实现

1.18之前,可以发现在大于 1024 阈值之后,是每次按照1/4的容量幅度增长的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func growslice(et *_type, old slice, cap int) slice {
    // ...
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.cap < 1024 {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // ...
    return slice{p, old.len, newcap}
}

go 1.18 之后,可以看到在大于阈值 256 之后,每次是按照(newcap + 3*threshold)/4的幅度增长。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func growslice(et *_type, old slice, cap int) slice {
    // ...
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        const threshold = 256
        if old.cap < threshold {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                // Transition from growing 2x for small slices
                // to growing 1.25x for large slices. This formula
                // gives a smooth-ish transition between the two.
                newcap += (newcap + 3*threshold) / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // ...
    return slice{p, old.len, newcap}
}

1)切片与底层数组的关系:

  • 切片本质上是一个对底层数组的抽象。一个切片包含三个部分:指向底层数组的指针、切片的长度和切片的容量。
  • 切片是动态数组,它的大小并非固定不变,而是可以根据需要动态扩展。

2)切片容量增大的过程:

  • 当我们向切片中追加元素,如果已有的容量不足以容纳新的元素,Go 语言会自动分配一个更大的底层数组,并将旧的元素复制到新的数组中。
  • 在容量小于 1024 时,新的容量至少是旧容量的两倍。而当容量达到或超过 1024 时,增长因子减小为 1.25。

3)示例代码:

  • 这段代码展示了切片容量的自动增长。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
  var s []int

  // 初始值为5
  s = append(s, 1, 2, 3, 4, 5)
  fmt.Printf("Length: %d, Capacity: %d\n", len(s), cap(s)) // Length: 5, Capacity: 8

  // 添加更多元素,会使容量增长
  s = append(s, 6, 7, 8, 9, 10)
  fmt.Printf("Length: %d, Capacity: %d\n", len(s), cap(s)) // Length: 10, Capacity: 16
}

4)什么是内存分配和复制?

  • 当切片的容量扩展时,Go 运行时会新分配一块更大的内存空间,这涉及到内存分配操作。
  • 然后,运行时将旧的底层数组的内容复制到新的内存空间。复制操作虽然是透明的,但是它的开销不容忽视,尤其是在大规模数组操作时。因此,为了减少频繁的容量扩展,通常我们可以在初始化切片时就指定一个合理的容量。

5)Go 语言优化措施:

  • Go 语言中的切片设计考虑了性能和灵活性,它采用了倍增和低倍增的机制来实现容量的自动成长。
  • 这种设计使得切片既能在小规模数据处理时保持高效,又能在处理大规模数据时避免频繁的内存分配和数据复制开销。

1747.Go语言切片作为函数参数,有哪些注意事项?

回答重点

在 Go 语言中,把切片作为函数参数时,有以下几个注意事项:

1)函数内使用 for 循环修改数组值时(比如对 s 这个切片),需要取下标然后对s[i]操作,不可以直接for _, v := range s然后对v进行修改,这样无法修改到 s 的值。

2)如果想要在函数内部对切片进行追加、删除等操作时,依然希望可以影响到函数外的原切片的话,可以把切片的指针作为参数传递进来。

2)切片是引用类型,因此传递到函数时不会拷贝数据,而是传递对底层数组的引用。

3)由于切片具有动态大小,所以在函数内部对切片进行追加、删除等操作时,可能会引发底层数组的重新分配,从而导致原切片和函数内部切片指向不同的底层数组。

4)如果函数需要修改切片本身的结构(例如增加或减少元素),最好通过返回新的切片来传递修改后的结果,避免意外修改外部切片。

5)如果函数只需要读取切片数据,可以明确将切片参数声明为 const 或者只读,虽然 Go 语言并不支持真正的只读属性,但通过命名约定可以明确意图。

扩展知识

在 Go 语言中,切片是对数组的一个引用,这意味着传递切片参数实际上是传递引用,这与大多数程序员习惯的传值、传引用概念有一些区别。我们从以下几个方面进行扩展说明:

1)函数内修改slice的值的推荐写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
	s := []int{0, 0}
	f(s)
	fmt.Println(s) // [10,10,10]
}
func f(s []int) {
	// 这样不能改变s中元素的值
	//for _, v := range s {
	//	v=10
	//}
	
        // 这样可以
	for i := range s {
		s[i] = 10
	}
}

2)通过传递指针来在函数内对切片进行append

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func f(s []int) []int {
	// 这里 s 是改变了,但是不会影响外层函数的 s。因为s本身就是一个值拷贝。
	s = append(s, 100)
	return s
}
func fPtr(s *[]int) {
	// 会改变外层 s 本身
	*s = append(*s, 10)
	return
}
func main() {
	s := []int{0,0}
	newS := f(s)

	fmt.Println(s) //[0,0]
	fmt.Println(newS)//[0,0]

	s = newS

	fPtr(&s)
	fmt.Println(s) //[0,0,10]
}

3)切片传递示例

1
2
3
4
5
6
7
8
func modify(slice []int) {
	slice[0] = 100
}
func main() {
	s := []int{1, 2, 3}
	modify(s)
	fmt.Println(s) // 输出 [100, 2, 3]
}

在这个例子中,modify 函数中修改了第一个元素。因为切片是引用传递,所以原始切片 s 在 main 函数中也被修改了。

4)切片扩容与重新分配

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func appendAndPrint(slice []int) {
	fmt.Printf("slice start: %p\n", slice)
	slice = append(slice, 4)
	fmt.Printf("slice end: %p\n", slice)
}
func main() {
	s := make([]int, 3, 3)
	appendAndPrint(s)
	fmt.Printf("main slice: %p\n", s) // 地址不同了
}

在这个例子中,我们看到切片在 append 操作后,可能会重新分配底层数组,因此函数内部和外部的切片可能会指向不同的底层数组,从而导致改动不一致。

5)返回新切片

为了解决上述问题,可以在函数中返回新的切片:

1
2
3
4
5
6
7
8
9
func addElement(slice []int, element int) []int {
	slice = append(slice, element)
	return slice
}
func main() {
	s := []int{1, 2, 3}
	s = addElement(s, 4)
	fmt.Println(s) // 输出 [1, 2, 3, 4]
}

通过函数返回新的切片,我们明确了函数的修改,避免了意外的副作用。同时,这种方式也是 Go 语言推荐的一种函数设计模式。

1748.Go语言中的=和:=有什么区别?

回答重点

在 Go 语言中,=:= 都是用来进行赋值的,但它们的用法和场景有所不同。它们的主要区别如下:

1) = 是用来给已声明的变量赋新值,变量必须是已经声明过且有类型的。

1
2
3
4
5
var y int = 20 // 函数内声明+赋值变量
fmt.Println("y:", y) // 输出:y: 20

y = 50 // 重新赋值
fmt.Println("y:", y) // 输出:y: 50

2):= 是用来声明并初始化新变量,变量在使用时必须是全新的,不能用于已有变量的重新赋值。

1
2
3
4
y := 20 // 声明+赋值新变量
fmt.Println("y:", y) // 输出:20

// y := 30 // 不允许对已有变量赋值

扩展知识

1) 包级别变量

在声明包级别变量时,只能使用 = 声明初始化包级别变量,而不能使用:=:= 只能在函数内部使用。

代码例子:

1
2
3
4
5
6
var x = 10 // 声明并初始化包级变量 x
// x := 10 // 不允许

func main() {
    fmt.Println("x:", x) // 输出:x: 10
}

2)Go 语言会自动推断变量的类型

在 Go 中可以直接使用=:= 来赋值,而无需带变量的类型,Go 语言会自动推断变量的类型。

代码例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    var x = 10
    y := 20
    z := "hello"
    fmt.Println("x:", x) // 输出:10
    fmt.Println("y:", y) // 输出:20
    fmt.Println("z:", z) // 输出:hello

    fmt.Println("x type:", reflect.TypeOf(x)) // 输出:int
    fmt.Println("y type:", reflect.TypeOf(y)) // 输出:int
    fmt.Println("z type:", reflect.TypeOf(z)) // 输出:string
}

在上述例子中,我们可以看到在对 x、y、z 变量进行赋值时,并没有声明变量类型,但我们通过 reflect 包中的 TypeOf()方法可以获取到变量的类型。由此可见,Go 语言会自动推断变量的类型。

3)多重赋值

在 Go 语言中,支持多重赋值方式,可以同时为多个变量赋值。这种语法特别适用于交换变量值、从函数返回多个值等场景。

代码例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
    var x, y int = 10, 20
    fmt.Println("x:", x) // 输出:10
    fmt.Println("y:", y) // 输出:20

    // 场景:交换变量值
    y, x = x, y
    fmt.Println("x:", x) // 输出:20
    fmt.Println("y:", y) // 输出:10

    x, z := 30, 40       // 允许,z 是新变量
    fmt.Println("x:", x) // 输出:30
    fmt.Println("z:", z) // 输出:40

    // x, y := 1, 2      // 不允许,至少要有一个未声明过的变量才能使用 :=
}

注意:在使用:=进行多重赋值时,至少要有一个未声明过的变量才能使用。针对已经声明过的变量,就是赋值操作;未声明过的变量,就是声明+赋值。

1749.Go语言中的指针的意义是什么?

重点回答

在 Go 语言中,指针允许你直接操作和修改内存地址中的值。通过指针,可以在函数间传递引用,减少内存消耗,或者修改函数外部的变量

扩展知识

1)基本概念
  • 什么是指针?:指针是编程中的一种变量,它存储了另一个变量的内存地址。在 Go 语言中,指针的默认值是nil,表示它不指向任何有效的内存地址。
  • 取址运算符:使用 & 符号可以获取变量的地址,例如 p := &v,这里 p 是一个指向v 变量地址的指针。
  • 解引用运算符:使用 * 符号可以访问指针指向的值,例如 *p 可以得到 p 指向的变量的值。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    var x = 10
    var p *int // 声明一个int类型的指针变量

    fmt.Println("p:", p) // 指针默认值是nil,未指向任何地址 输出 p: <nil>

    p = &x  // p指针指向x变量的内存地址

    fmt.Println("&x:", &x) // 获取x变量的地址 输出 &x: 0x14000020090
    fmt.Println("p:", p)   // 获取p指向的变量x的地址 输出 p: 0x14000020090
    fmt.Println("*p:", *p) // 获取p指向的变量的值 输出 *p: 10
}
2)主要作用

1)允许在函数之间共享数据

指针使得你可以在函数之间传递数据的引用,而不是数据的副本。这样可以避免在函数调用时复制大块数据,从而提高性能和节省内存。

代码示例:

1
2
3
4
5
6
7
8
func increment(x *int) {
    *x++
}
func main() {
    value := 10
    increment(&value)
    fmt.Println(value) // 输出 11
}

在 increment 函数中,通过解引用指针 *x来修改 value 的值。

2)减少内存使用

通过使用指针,特别是对于大型结构体或数组,你可以减少内存使用,因为你只需传递指向数据的指针,而不是整个数据结构。这在处理大型数据结构时特别有用。

3)作为方法的接受者

Go 语言的方法可以是值接收者或指针接收者。使用指针接收者可以避免复制整个结构体,提高性能,并允许方法修改接收者对象的状态。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Person struct {
    Name string
}
func (p *Person) SetName(name string) {
    p.Name = name
}
func main() {
    p := &Person{}
    p.SetName("Alice")
    fmt.Println(p.Name) // 输出 Alice
}

在这个例子中,SetName 是一个指针接收者方法,这意味着它接收 *Person 类型的指针,并且可以修改 Person 结构体的 Name 字段。

4)实现链式调用

指针可以让你链式调用方法,因为方法可以返回指向同一对象的指针。这使得流式编程变得更加自然和简洁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Counter struct {
    count int
}
func (c *Counter) Increment() *Counter {
    c.count++
    return c
}
func (c *Counter) GetCount() int {
    return c.count
}
func main() {
    c := &Counter{}
    c.Increment().Increment().Increment()
    fmt.Println(c.GetCount()) // 输出 3
}
3)其他注意项
  • 1)避免空指针:使用 nil 指针访问数据会导致运行时错误
  • 2)安全性:与 C/C++ 不同,Go 不支持指针运算。这使得 Go 的指针更加安全,不容易引起内存崩溃等问题。
  • 3)数据结构与算法: 在实现链表、树等数据结构时,指针必不可少。它们允许你在数据结构中动态地插入、删除和修改节点,而不是依赖于固定大小的数组或结构体。
  • 4)系统编程: 在与底层硬件或系统进行交互时,指针用来访问特定内存地址是不可避免的。

1750.Go语言的多值返回有什么用?

回答重点

在 Go 语言中,函数可以返回多个值,这使它有别于许多其它编程语言。多值返回的主要作用包括:

1)简化代码:减少了为返回多个值而创建的临时结构体或对象的需求。

2)错误处理:由于 Go 语言里没有类似try...catch的逻辑,defer又不适用于接受任何错误,因此常见的用法是在函数中返回值的同时返回错误信息,从而实现更好的错误处理和控制流。

3)提高可读性:在业务需要使用一个函数返回多个值时,多值返回会使得返回值类型更清晰,也可以同时给这多个值起名字,避免了使用结构体等嵌套结构。

对于多值返回,如果此次函数调用不需要其他参数,go 语言支持使用 _ 来忽略其中某个参数,以此来避免返回值过多时,会定义过多无用变量的问题。但是要注意如果函数返回的多个接收值中,只要有一个是新变量,就需要使用 :=符号来接收,反之,只有所有函数接收值都是已定义的变量时,才可以使用=来接受返回结果。

扩展知识

多值返回是 Go 语言的一大特色,它的作用和示例如下:

1)接受错误

1
2
3
4
func readFile(filename string) ([]byte, error) {
    // 读取文件的逻辑
    return content, err
}

在这个例子中,readFile 函数返回了文件的内容和一个错误(如果有的话)。这样调用者可以很方便地判断函数是否成功执行,并做出相应处理:

1
2
3
4
5
6
7
content, err := readFile("example.txt")
if err != nil {
    // 处理错误
    log.Fatal(err)
}
// 使用文件内容
fmt.Println(string(content))

多值返回让错误处理变得更加直观,避免了其他语言中常见的异常处理模式。

2)提高可读性:比如下面这段代码中,通过直接返回4个值,且通过变量命名,十分清晰的描述了函数做的事情与返回值,简洁明了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func calc(a int, b int) (sum int, sub int, mul int, div int) {
    sum = a + b
    sub = a - b
    mul = a * b
    div = a / b
    return
}
func main() {
    var a, b, c, d = calc(20, 5)
}

此外,Go 语言的多值返回实现也与其内存模型十分契合。变量返回时仅使用返回值指针(若有),因此对性能影响较小。这是因为 Go 编译器在底层构建返回值时,通过栈分配或逃逸分析来决定最佳的内存分配策略。

扩展思路

1)应用场景:除了文件处理,多值返回在网络编程、数据库查询、并发编程等场景中都非常有用。例如在 HTTP 请求中可以同时返回响应体和错误信息:

1
2
3
4
func makeRequest(url string) (*http.Response, error) {
    resp, err := http.Get(url)
    return resp, err
}

2)封装:通过封装函数,可以更好地利用多值返回特性。例如用于数据库操作中,封装查询逻辑返回查询结果和错误。

1
2
3
4
func queryDB(query string) (result []Record, err error) {
    // 查询数据库逻辑
    return results, err
}

3)组合:多值返回与 Go 语言接口、类型系统配合使用,能够写出更加类型安全和可扩展的代码。例如返回自定义类型和错误以处理不同的业务逻辑:

1
2
3
4
5
6
7
8
9
type User struct {
    ID    int
    Name  string
    Email string
}
func getUserByID(id int) (*User, error) {
    // 数据库查询逻辑
    return &user, err
}

1751.Go语言有异常类型吗?

重点回答

Go 语言没有传统的异常机制,而是使用 error 类型和 panic/recover 机制来处理错误和异常情况。(Go 更推荐使用显式错误处理,而不是通过 panic 和 recover 来处理常规的错误情况)

扩展知识

1) 错误处理 (error 类型)

Go 语言中的错误处理是通过显式返回值 error 类型来实现的( Go 语言建议方式)。函数通常返回一个 error 类型的值来表示操作是否成功,如果返回的 error 不为 nil,则表示发生了错误。调用方需要检查这个返回值以确定是否有错误发生,并做出相应的处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}

2)panic 和 recover

  • panic:用于表示程序中的严重问题,这通常是程序的逻辑错误或不可恢复的错误。调用 panic 会导致程序的控制流中断,并开始逐层向上传播,直到程序崩溃或被 recover 捕获。常使用于程序启动初始化错误处理
  • recover:用于从 panic 中恢复,允许程序在发生 panic 后继续执行。recover 必须在 defer 函数中调用才能有效。通常用于处理一些异常情况,而不是常规的错误处理。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func causePanic() {
    panic("something went wrong")
}
func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    causePanic()
}
func main() {
    safeCall()
    fmt.Println("Program continues after panic")
}

1752.Go语言中的rune类型是什么?

回答重点

在 Go 语言中,rune 类型是用来表示 Unicode 码点的。它本质上是 int32 类型的别名,用来区分字符值跟整数值

rune类型定义在builtin/builtin.go文件中,它的定义为:

1
type rune = int32

由于Go语言中采用的是统一的UTF-8编码,英文字母在底层占1个字节,特殊字符和中文汉字则占用1~3个字节,这样设计的目的是为了让开发者更容易地处理 Unicode 字符,处理中文的计数和分割问题,特别是在处理多语言文本时。

扩展知识

如何使用

1)如果要不区分中英文的统计字符串中的字符数量的时候,需要借用rune类型,例子如下:

1
2
3
4
5
func main() {
	s := "今天happy"
	fmt.Println(len(s))         //输出11
	fmt.Println(len([]rune(s))) //输出7
}

2)当在一段包含中英文的字符串中,要想截取某字符的时候,需要借用rune类型,例子如下:

1
2
3
4
5
func main() {
	s := "今天happy"
	fmt.Println(s[:2])                 //输出乱码�
	fmt.Println(string([]rune(s)[:2])) //输出今天
}

其他扩展展示

1)Unicode 和码点:

Unicode 是一种字符编码标准,它为全球的所有字符和符号设定了唯一的数字编码,称为码点(code point)。 每个 Unicode 码点通常表示一个字符,比如字母、数字或者符号。

2)为何使用 rune:

Go 语言的字符串实际上是 UTF-8 编码的字节序列。在这种情况下,一个字符可能需要多个字节来表示。作为 byte(uint8)的补充,rune 可以帮助处理和操作单个 Unicode 字符。 rune 可以直接表示一个字符;这对于处理和操作全世界范围内的字符是非常有用的。

3)byte vs rune:

byte 是 uint8 类型的别名,用来表示单个字节;通常用于操作原始的二进制数据。 rune 是 int32 类型的别名,用来表示一个 Unicode 码点;可以用于处理字符和操作字符串。

4)示例代码:

1
2
3
4
5
6
7
8
9
func main() {
    var r rune = '世'
    fmt.Printf("Rune: %c, Unicode Code Point: %U\n", r, r)

    s := "你好"
    for i, r := range s {
        fmt.Printf("Character %d: %c, Unicode Code Point: %U\n", i, r, r)
    }
}

在这段代码中,rune 被用来表示字符 ‘世’ 和字符串 “你好” 中的每个字符。可以看到 rune 是如何便利地表示和操作 Unicode 字符的。

5)rune 和字符串:

在 Go 语言中,字符串实际上是一个 byte 切片([]byte),用来存储 UTF-8 编码的字节序列。 使用 range 循环字符串时,Go 会自动将每个字符解码为 rune,这简化了多字节字符的处理。 如果直接转换字符串为 []rune,可以方便地按字符访问字符串:

1
2
3
4
5
s := "你好,世界"
runes := []rune(s)
for _, r := range runes {
    fmt.Printf("%c ", r)
}

1753.Go语言中的rune和byte有什么区别?

回答重点

在 Go 语言中,byte 和 rune 都是预定义的类型别名,用于表示不同类型的字符数据。

1)byte 是 uint8 的别名,byte 代表 8 位数据,主要用来处理单个字节的数据,通常用于表示 ASCII 字符。

2)rune 是 int32 的别名,代表 32 位数据,主要用来处理 Unicode 字符,一个 rune 可以表示一个完整的 Unicode 码点。

设计rune类型的必要性:

Unicode 是一个可以表示世界范围内的绝大部分字符的编码,这张编码表里几乎包含了全世界的所有的字符,包括中文。Unicode的最大编码数量是 1114112 个字符,而 ASCII 表里只有 256 个字符,只能表示常规英文和符号字符。因此对于 go 这个广泛使用的语言来说,肯定要支持多种语言和符号的展示,所以需要设计rune类型。

扩展知识

1)byte类型和rune类型的占用内存空间不同: byte 是 uint8 的别名,占用1个字节;rune 是 int32 的别名,占用4个字节。

注意把汉字赋值给byte类型的数据会直接报错。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
	var a byte = 'a'
	var b rune = 'b'
	//var c byte = '我' // cannot use '我' (untyped rune constant 25105) as byte value in variable declaration (overflows)
	var c []byte = []byte("我")
	var d rune = '我'
	fmt.Println(a, b, c, d, len(c)) // 97 98 [230 136 145] 25105 3
	fmt.Printf("a 占用 %d 个字节\n", unsafe.Sizeof(a)) // a 占用 1 个字节
	fmt.Printf("b 占用 %d 个字节\n", unsafe.Sizeof(b)) // b 占用 4 个字节
	fmt.Printf("c 占用 %d 个字节\n", unsafe.Sizeof(c)) // c 占用 24 个字节
	fmt.Printf("d 占用 %d 个字节\n", unsafe.Sizeof(d)) // d 占用 4 个字节
}

2)byte:

一般用于处理和存储原始数据,例如文件内容、网络数据包等。 在字符串处理上,经常用来表示和处理 ASCII 字符,因为 ASCII 字符只需要 1 字节。 示例代码:

1
2
3
4
var b byte = 'A'
var str string = "Hello"
// 字符串转为字节切片
byteSlice := []byte(str)

3)rune:

适用于处理 Unicode 字符,因为 Unicode 字符集可以包含多语言字符、表情符号等。 在 Go 语言中字符串是 UTF-8 编码的,但有时候需要以便于操作 Unicode 码点的方式处理字符串时就会使用 rune。

示例代码:

1
2
3
4
var r rune = '世'  // '世' 的 Unicode 编码为 U+4E16
var str string = "你好"
// 字符串转为 rune 切片
runeSlice := []rune(str)
字符串编码和转换

在 Go 语言中,虽然字符串本质上是字节序列,但处理多语言文本时,需要考虑不同 Unicode 字符的情况。

1)UTF-8 编码:

默认情况下,Go 使用 UTF-8 编码保存字符串。每个字符可以由 1 到 4 个字节来表示。

1
2
str := "你好"
b := []byte(str) // UTF-8 编码下,每个汉字占3个字节

2)Unicode 码点:

rune 类型便于直接处理单个 Unicode 码点。

1
2
3
4
5
str := "你好"
r := []rune(str) // '你' 和 '好' 各占用1个rune,也就是32位

fmt.Printf("Byte representation: %v\n", b)
fmt.Printf("Rune representation: %v\n", r)

1754.什么是Go语言中的深拷贝和浅拷贝?

回答重点

在 Go 语言中,深拷贝和浅拷贝是两种不同的拷贝方式。

1)深拷贝:深拷贝会复制所有的字段以及它们所引用的内存,从而保证和源数据完全独立。即使源数据被修改,深拷贝产生的数据也不会受到影响。

2)浅拷贝:浅拷贝只是复制了引用或指针,但不复制引用的数据本身。当你进行浅拷贝时,如果你后续修改了引用的数据,浅拷贝后的数据也会随之改变。

Go 语言的类型中,默认为深拷贝的类型有bool、int、float、string、array、struct等值类型。注意如果是 struct 类型,需要保证其嵌套的所有字段都是值类型。

Go 语言的类型中,默认为浅拷贝的类型有 slice、map、指针 ptr、函数 func、通道 chan、接口 interface 等引用类型

扩展知识

深入了解深拷贝和浅拷贝,我们可以讨论一下如何在 Go 中实现这两种拷贝方式。

1)深拷贝的实现:

在 Go 语言中,大多数内置基本类型的拷贝操作默认都是深拷贝。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type Person struct {
    Name string
    Age  int
}

func main() {
    p1 := Person{"Alice", 30}
    p2 := p1 // 深拷贝
    p2.Name = "Bob"
    
    fmt.Println(p1) // {Alice 30}
    fmt.Println(p2) // {Bob 30}
    
    s1 := "hello"
    s2 := s1
    s2 = "world"

    fmt.Println(s1) // hello
    fmt.Println(s2) // world
}

在上述例子中,p2 是 p1 的深拷贝,当我们修改 p2.Name 时,p1 保持不变。

对于嵌套类型,我们需要手动实现深拷贝:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type Person struct {
	Name    string
	Age     int
	Friends []string
}
func deepCopy(p Person) Person {
	copyP := p
	copyP.Friends = make([]string, len(p.Friends))
	copy(copyP.Friends, p.Friends)
	return copyP
}
func main() {
	s := "Alice"
	p1 := Person{s, 30, []string{"Bob", "Charlie"}}
	p2 := deepCopy(p1) // 深拷贝
	p2.Friends[0] = "David"

	fmt.Println(p1.Friends) // [Bob Charlie]
	fmt.Println(p2.Friends) // [David Charlie]
}

在上述例子中,deepCopy 函数实现了 Person 结构的深拷贝,即使修改 p2.Friends,p1 也不会受到影响。注意,如果结构体中还有嵌套(或比如结构体里 Friends 字段的类型定义是[]*string),则也需要递归解析每一层结构体进行深拷贝,并且每一层都确认实现了deepCopy

2)浅拷贝的实现:

对于复杂数据结构,比如嵌套的结构体、切片、映射、指针等,如果不主动实现深拷贝,那么就会影响原来的值,即形成浅拷贝。例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Person struct {
	Name    *string
	Age     int
	Friends []string
}
func main() {
	s := "Alice"
	p1 := Person{&s, 30, []string{"Bob", "Charlie"}}
	p2 := p1 // 浅拷贝
        
    // 比如对 slice 类型,如果只实现浅拷贝,则会对原数据造成影响
	p2.Friends[0] = "David"

	fmt.Println(p1.Friends) // [David Charlie]
	fmt.Println(p2.Friends) // [David Charlie]

	// 比如对指针类型,如果只实现浅拷贝,则会对原数据造成影响
	*p2.Name = "Bob"
	fmt.Println(*p1.Name) // Bob
	fmt.Println(*p2.Name) // Bob

}

在上述例子中,只实现了浅拷贝,因此对 p2 的 Name 和 Friends 字段做修改,会影响到 p1 的值。

1755.什么是Go语言中的字面量和组合字面量?

回答重点

在 Go 语言中,字面量(literal)是指程序中直接表示常量值的形式,也就是在代码中直接写出来的值,比如数字、字符串、布尔值等等。

组合字面量是指通过内联(in-line)形式定义复杂数据结构的字面量,比如数组、切片、映射(map)、结构体等。组合字面量可以让你非常直观地定义和初始化复杂的数据结构。

简单来说,字面量是直接表示值,组合字面量是直接表示复杂数据结构

扩展知识

常见的普通字面量的例子:

1)整数字面量:表示整型数值。

1
var a int = 10 // 10 是整数字面量

2)浮点数字面量:表示浮点型数值。

1
var b float64 = 3.14 // 3.14 是浮点数字面量

3)字符字面量:表示单个字符,包含在单引号内。

1
var c rune = 'A' // 'A' 是字符字面量

4)字符串字面量:表示字符串,包含在双引号内。

1
var d string = "Hello" // "Hello" 是字符串字面量

5)布尔字面量:表示布尔值 truefalse

1
var e bool = true // true 是布尔字面量

再看看一些常见的组合字面量的例子:

1)数组字面量:通过写出数组元素的直接表示来初始化数组。

1
var arr = [3]int{1, 2, 3} // [1, 2, 3] 是数组字面量

2)切片字面量:类似于数组,但没有固定大小。

1
var slice = []int{1, 2, 3} // []int{1, 2, 3} 是切片字面量

3)映射(map)字面量:通过键值对的直接表示来初始化映射。

1
var m = map[string]int{"one": 1, "two": 2} // {"one": 1, "two": 2} 是映射字面量

4)结构体字面量:通过字段名和值的直接表示来初始化结构体。

1
2
3
4
5
type Person struct {
    Name string
    Age  int
}
var p = Person{Name: "Alice", Age: 30} // {Name: "Alice", Age: 30} 是结构体字面量

1756.如何使用Go语言中的对象选择器自动解引用?

回答重点

在 Go 语言中,对象选择器(Selector)是通过点号 . 来访问结构体的字段或方法的。当我们有一个指向结构体的指针时,Go 语言会自动帮我们解引用,而我们不需要显式地写出 * 来解引用指针。

简单来讲,当你有一个结构体指针 personPtr,你可以直接用 personPtr.Name 来访问字段,而不需要写成 (*personPtr).Name(编译器会自动将其转换为 (*personPtr).Name)。

扩展知识

这个自动解引用机制在实际应用中非常方便,让我们来看看具体的例子:

1)定义一个结构体 Person

1
2
3
4
type Person struct {
    Name string
    Age  int
}

2)创建一个 Person 的实例和它的指针:

1
2
p := Person{Name: "Alice", Age: 30}
pPtr := &p

3)访问结构体字段时,Go 语言会自动解引用:

1
2
fmt.Println(p.Name)   // 输出: Alice
fmt.Println(pPtr.Name) // 输出: Alice,这里 pPtr 是指向 Person 的指针,Go 会自动解引用

自动解引用只限于字段访问和方法调用

自动解引用仅在访问指针类型的字段或调用方法时生效。它不会对指针的其他操作(如赋值)产生影响。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Person struct {
   Name string
}

func (p *Person) Greet() {
   fmt.Println("Hello, " + p.Name)
}

func main() {
   var p *Person = nil
   p.Greet()  // 自动解引用 p,调用 Greet 方法
}

在此示例中,Go 会自动解引用 p,直接调用 p.Greet(),无须手动解引用。

然而,对于非字段访问或非方法调用,Go 编译器并不会自动解引用。例如:

1
p.Name = "John"  // 无法自动解引用,必须显式解引用 p

在这种情况下,编译器并不会自动为 p 解引用,必须显式地解引用 p 或确保 p 是一个非 nil 的有效指针。

无法自动解引用 nil 指针

虽然自动解引用简化了代码,但它并不会在 nil 指针上自动进行解引用操作。如果指针为 nil,Go 会在解引用时抛出运行时错误(panic)。因此,必须确保指针在解引用之前已被正确初始化,或者使用 nil 检查来避免这种错误:

1
2
3
4
var p *Person = nil
if p != nil {
   p.Greet()  // 不会触发 panic
}

1757.Go语言中map的值不可寻址,如何修改值的属性?

回答重点

  1. 值是基本类型时:可以直接修改。
  2. 值是指针类型时:可以直接修改指针指向的数据。
  3. 值是结构体类型时可以通过获取副本并修改,再将副本放回 map

扩展知识

几种类型修改代码示例

基本类型
1
2
3
m := make(map[string]string)
m["key1"] = "mianshi"
m["key1"] = "mianshiya" //直接修改

指针类型

如果 map 中的值是一个指向结构体的指针,可以通过修改结构体的属性来达到修改 map 中值的目的

1
2
3
4
5
6
7
8
9
type Person struct {
   Name string
   Age  int
}

m := make(map[int]*Person)
m[1] = &Person{"Alice", 30}
m[1].Age = 31  // 修改结构体的属性
fmt.Println(m[1].Age)  // 输出 31

在这个例子中,m[1] 是一个指向 Person 结构体的指针,你可以通过 m[1].Age = 31 修改结构体的属性。这里并不直接修改 map 的值,而是通过结构体指针的引用修改其内部的字段。

结构体类型

可以使用中间变量的方式,修改 map 中存储的结构体的属性。具体操作是,先通过键获取 map 中的值(实际上是值的副本),然后修改副本的属性,最后再将副本放回 map 中。

1
2
3
4
5
6
7
8
m := make(map[int]Person)
m[1] = Person{"Alice", 30}

// 修改中间变量
temp := m[1]
temp.Age = 31
m[1] = temp  // 将修改后的值放回 map
fmt.Println(m[1].Age)  // 输出 31

这里 tempm[1] 的副本,修改副本的 Age 后,再把它放回 map 中,从而达到修改 map 值的目的。

为什么 map 的值不可寻址?

Go 语言规定,map 的值不可寻址是为了避免一些并发访问的错误。Go 的 map 并不是线程安全的,当你直接通过指针操作 map 中的值时,可能会遇到数据竞争的问题。为了避免这一问题,Go 语言禁止了通过指针访问 map 中的值的做法。

举个例子,下面的代码会编译报错:

1
2
3
m := make(map[int]int)
m[1] = 10
p := &m[1]  // 错误:无法取map值的地址

Go 编译器会报错:

map的值不可寻址.png
map的值不可寻址

1758.Go语言的有类型常量和无类型常量有什么区别?

回答重点

常量(constant)是指在程序运行时不可修改的值

主要区别如下

  • 类型推断:无类型常量由编译器根据上下文推断出类型,而有类型常量在定义时已经明确指定了类型。
  • 类型安全:有类型常量的类型是严格指定的,编译器会检查类型是否匹配;而无类型常量可能在不同的上下文中进行类型推断,可能会发生不符合预期的类型转换。
  • 灵活性:无类型常量更加灵活,适应性更强,能够根据具体场景推断类型;而有类型常量则相对固定,适用于明确类型要求的场景。

扩展知识

代码示例

有类型常量是指常量在定义时明确指定了类型。例如,定义一个有类型常量时:

1
const pi float64 = 3.14  // 有类型常量

在上面的代码中,pi 是一个 float64 类型的常量。它只能作为 float64 类型使用,不能直接赋值给其他类型的变量。

优点:

  • 可以明确常量的类型,避免类型推断带来的不确定性。
  • 提供类型安全性,避免类型错误。

限制:

  • 有类型常量不能在不同类型之间进行直接转换,必须显式转换。

无类型常量是指常量在定义时没有指定类型。Go 编译器会根据常量的使用场景推断出其类型。这种常量在使用时可以根据上下文自动适应合适的类型。

例如,定义一个无类型常量时:

1
const pi = 3.14  // 无类型常量

在上面的代码中,pi 是一个无类型常量,Go 编译器会根据它的使用环境推断出它的类型。比如,如果将它赋值给一个 float64 变量,Go 会自动推断它是 float64 类型。如果赋值给 int 类型变量,Go 会自动将其转换为 int 类型。

优点:

  • 提供更大的灵活性,常量可以根据需要自动转换类型。
  • 减少了开发者显式声明类型的工作。

限制:

  • 由于类型是由编译器推断的,可能会出现一些意外的类型转换,导致潜在的精度丢失或类型不匹配。

1759.为什么在Go语言中传参使用切片而不是数组?

回答重点

在 Go 语言中,传参时更倾向于使用切片而不是数组,主要原因有以下几点:

1)效率更高:数组在传参时是按值传递,也就是说传递的是整个数组的副本,这会消耗更多的内存和时间。而切片是引用类型,传递的是底层数组的地址,开销少得多。

2)灵活性更强:切片的长度是动态的,可以在运行时调整。因此,它们可以更灵活地处理变长列表,而数组长度在定义时就已经固定,灵活性较差。

3)内置功能更丰富:切片提供了许多内置的方法和函数,例如 appendcopy 等,这些功能使操作切片更方便,而数组并没有这些内置功能。

扩展知识

什么时候适合使用数组而不是切片?

1)明确要求固定大小的情况下

例如,在实现一个固定大小的环形缓冲区时,数组可以避免切片动态扩展所带来的内存重分配问题。

1
var buffer [1024]int  // 固定大小的环形缓冲区

2)防止在函数中修改原始数据

数组传递时会创建副本,因此修改函数内的数组不会影响原始数组。而切片是引用类型,传递切片时传递的是对原始数组的引用,修改切片的内容会直接影响底层数组。

所以为了实现原始数据不可变性,此时使用数组而不是切片。

你提到的现象是 Go 切片扩容后的行为,这是 Go 语言切片的一种特性。为了解释清楚,我们先理解切片的底层实现和扩容机制,然后分析为什么切片扩容后,函数内外的切片可能不再共享底层数组。

切片扩容行为

切片的一个核心特性是可以动态扩展。当切片的容量不足以容纳新元素时,Go 会自动为切片分配一个新的、更大的底层数组,并将原数组的数据复制到新数组中。

这时,切片的底层数组已经发生了变化,原切片和新切片不再共享同一个底层数组。

示例:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func modifySlice(s []int) {
    s = append(s, 4) // 触发扩容,底层数组改变
    s[0] = 99       // 修改的是新数组
    fmt.Println("Inside function:", s)
}

func main() {
    slice := []int{1, 2, 3}    // 长度 3,容量 3
    fmt.Println("Before:", slice)

    modifySlice(slice)          // 扩容后,函数内和函数外的底层数组不一致
    fmt.Println("After:", slice)
}
  1. 切片 slicemain 函数中定义,长度为 3,容量也为 3。
  2. 传递到 modifySlice 后,通过 append 向切片添加元素时,切片容量不足,触发了扩容机制。
  3. 扩容时,Go 创建了一个新的底层数组,将原数组的数据复制到新数组中,并更新了函数内切片的指针。
  4. 此时,modifySlice 内的切片指向新数组,而 main 函数中的切片仍然指向旧的数组。因此,modifySlice 内的修改对 main 中的切片没有影响。

底层数组不再共享

  • 如果切片在函数中发生了扩容,那么函数内和函数外的切片将指向不同的底层数组。
  • 函数对切片的修改只会影响函数内的新底层数组,而不会影响函数外的旧底层数组。

1760.Go语言中的引用类型和指针有什么不同?

回答重点

主要区别:指针是一个直接存储地址的类型(本身是值类型),而引用类型包含指向数据的指针并附带其他元数据。指针支持间接操作和修改内存,而引用类型则通过值传递(引用类型的拷贝并不是拷贝底层的数据,而是拷贝了底层数据的引用结构)的方式简化了内存管理。

  • 引用类型:引用类型是指在内存中存储数据的引用(地址),而非直接存储数据。例如切片、映射、通道等,它们包含指向底层数据的指针、长度(在某些情况下)和容量等信息,并由 Go 的垃圾回收机制管理内存。
  • 指针:用于存储其他变量的内存地址。指针提供对内存的直接访问,但不会像引用类型那样自动管理内存。

扩展知识

引用类型的实现

Go 中的引用类型(切片、映射、通道等)并非直接存储数据,它们内部有一个指向底层数据的指针。以 切片(Slice) 为例,切片底层的结构如下:

1
2
3
4
5
type Slice struct {
    array unsafe.Pointer // 指向底层数组的指针
    len   int            // 切片的长度
    cap   int            // 切片的容量
}

当切片被赋值或传递时,复制的是切片的指针、长度和容量,而底层的数组保持不变。切片的扩容机制会创建一个新的数组,因此一个切片的扩容会使得新的切片指向新的数组,原来的切片指向的数组不再共享。

指针的底层实现

指针在 Go 语言中的实现相对简单。Go 语言中使用 * 来声明指针类型,使用 & 来获取变量的地址。指针类型的变量本质上是一个内存地址,Go 通过指针提供对其他变量的访问。例如:

1
2
3
var p *int  // p 是一个指向 int 类型的指针
x := 10
p = &x      // p 指向 x 的地址

指针在 Go 中不支持指针算术(即不能直接通过指针进行加减操作),这与 C 语言等有很大不同。指针传递时只传递地址本身,而不是数据。

如何使用指针避免拷贝

在 Go 中,指针经常用于避免不必要的拷贝。例如,当传递一个大对象(如大型数组、结构体)给函数时,可以通过指针传递,而不是直接传递对象本身,避免了性能开销。举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Person struct {
    Name string
    Age  int
}
func updatePerson(p *Person) {
    p.Name = "Alice" // 修改的是指针指向的数据
}
func main() {
    p := &Person{Name: "Bob", Age: 30}
    updatePerson(p)
    fmt.Println(p.Name) // 输出 Alice
}

通过指针,我们能直接修改传递给函数的结构体的字段,而不需要拷贝整个结构体对象。

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

回答重点

在Go语言中,可以通过多重赋值的方式,直接使用 value, ok := map[key] 的语法来判断一个 map 中是否包含某个 key。

这里的 ok 是一个布尔值,表示该 key 是否存在于 map 中。具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
myMap := map[string]int{
    "apple":  1,
    "banana": 2,
    "cherry": 3,
}
key := "banana"
if _, ok := myMap[key]; ok {
    fmt.Println("Key exists in the map")
} else {
    fmt.Println("Key does not exist in the map")
}

除此之外可以通过 for 循环判断 key 是否存在,如果存在则直接 break,这种方式很低效,不推荐。

扩展知识

多重 map 嵌套

对于多重 map 的情况,比如 map 中嵌套了 map,可以按以下方式判断嵌套的 key 是否存在:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
nestedMap := map[string]map[string]int{
    "fruits": {
        "apple":  1,
        "banana": 2,
    },
}
if innerMap, exists := nestedMap["fruits"]; exists {
    if _, ok := innerMap["banana"]; ok {
        fmt.Println("Key 'banana' exists in 'fruits'")
    } else {
        fmt.Println("Key 'banana' does not exist in 'fruits'")
    }
} else {
    fmt.Println("Key 'fruits' does not exist in nestedMap")
}

1762.Go语言的map如何实现两种get操作?

回答重点

在 Go 语言中,map 的 get 操作有两种方式,一种仅返回值,另一种返回值和布尔值来标识键是否存在:

1)仅返回值:

1
value := myMap[key]

这种方式适用于我们确信键存在的情况。如果键不存在,返回的值将是该类型的零值。

2)返回值和布尔值:

1
value, exists := myMap[key]

这种方式可以让我们明确地知道键是否存在。布尔值 existstrue 表示键存在,为 false 表示键不存在。

扩展知识

零值问题

当我们使用第一种方式时,如果键不存在,返回的是该值类型的零值。比如 map 的 value 类型是 int,未找到对应键时返回 0,这可能会令人混淆,如果 value 的值就是 0 呢?

所以我们推荐使用第二种方式,可以避免这个问题,因为 exists 明确告诉我们键是否存在。

nil map 问题

如果 map 是 nil,那么无论使用哪种 get 操作,查询都会返回该类型的零值,并且 ok 为 false。在 Go 中,未初始化的 map 默认为 nil,这种情况下直接访问该 map 也不会导致 panic,而是返回零值。

1
2
3
var nilMap map[string]int
value, ok := nilMap["key"]
fmt.Println(value, ok)  // 输出: 0 false

1763.Go语言中map的key为什么是无序的?

回答重点

Go 语言中的 map 底层使用哈希表实现,哈希表的设计本身不关心元素存储的顺序。哈希算法旨在通过对 key 进行散列,生成一个均匀分布的哈希值。因此,哈希值的分布并不保证任何顺序,所以 key 的存储是无序的。

扩展知识

遍历的无序性

1
2
3
4
5
6
7
8
9
myMap := map[string]int{
    "apple":  3,
    "banana": 2,
    "cherry": 5,
}

for key, value := range myMap {
    fmt.Println(key, value)
}

例如以上的代码,每次遍历时,输出的顺序都可能不同。

为什么遍历都是无序的?

主要是为了避免顺序依赖

如果哈希表的桶按固定顺序遍历,程序员可能无意中依赖该顺序。这样,如果产生哈希扩容,顺序就会改变,可能会引发不同的行为,甚至导致潜在的错误。因此,随机化遍历顺序可以降低这类风险

如果需要有序的 map,如何操作?

可以先将 map 中的所有 key 提取到一个切片中,然后对切片进行排序,再按照排序后的 key 顺序遍历 map。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
    myMap := map[string]int{
        "apple":  3,
        "banana": 2,
        "cherry": 5,
    }

    // 将 map 的 key 提取到切片中
    keys := make([]string, 0, len(myMap))
    for key := range myMap {
        keys = append(keys, key)
    }

    // 排序
    sort.Strings(keys)

    // 按照排序后的 key 遍历 map
    for _, key := range keys {
        fmt.Println(key, myMap[key])
    }
}

1764.Go语言支持默认参数或可选参数吗?

回答重点

在 Go 语言中,不支持默认参数和可选参数。所有函数的参数必须显式指定,函数调用时,必须传递定义中声明的所有参数。

这是因为 Go 语言设计的目标之一是简洁性和明确性,避免隐式行为可能导致的歧义。

扩展知识

如何模拟默认参数和可选参数

虽然 Go 不直接支持默认参数或可选参数,但可以通过以下方式间接实现:

1)通过函数组合

Go 不支持函数重载,但可以通过定义多个函数实现不同的参数组合。

1
2
3
4
5
6
func greetWithDefault(name string) string {
   return greet(name, "Hello")
}
func greet(name string, greeting string) string {
   return greeting + ", " + name
}

调用方式:

1
2
fmt.Println(greetWithDefault("Alice")) // 输出: Hello, Alice
fmt.Println(greet("Alice", "Hi"))     // 输出: Hi, Alice

2)通过可变参数实现可选参数

Go 支持使用 ... 定义可变参数,允许传递零个或多个参数。这可以用来模拟可选参数的效果。

1
2
3
4
5
6
7
func greet(name string, greetings ...string) string {
   greeting := "Hello" // 默认值
   if len(greetings) > 0 {
       greeting = greetings[0]
   }
   return greeting + ", " + name
}

调用方式:

1
2
fmt.Println(greet("Alice"))          // 输出: Hello, Alice
fmt.Println(greet("Alice", "Hi"))    // 输出: Hi, Alice

3)使用结构体和方法

可以通过定义一个结构体,并为结构体定义方法的方式来实现更灵活的参数配置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Greeter struct {
   Greeting string
}

func (g Greeter) Greet(name string) string {
   if g.Greeting == "" {
       g.Greeting = "Hello" // 默认值
   }
   return g.Greeting + ", " + name
}

调用方式:

1
2
3
4
5
g := Greeter{}
fmt.Println(g.Greet("Alice")) // 输出: Hello, Alice

g = Greeter{Greeting: "Hi"}
fmt.Println(g.Greet("Alice")) // 输出: Hi, Alice

4)函数选项模式

函数选项模式依赖于 Go 中的闭包机制,通过传递一个配置结构体或一组函数来实现。每个函数会设置结构体中的一个字段或对函数的行为进行调整,通常还会支持默认值的设置。

假设我们有一个创建用户的函数,通常它会接受一些参数,比如用户名、密码等。如果不传递某些参数,就可以使用默认值。我们可以通过函数选项来实现这一功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// UserConfig 定义一个用户配置结构体
type UserConfig struct {
    Username string
    Password string
    Age      int
}

// Option 类型是一个函数,它接受一个 *UserConfig 参数
type Option func(*UserConfig)

// WithUsername 设置用户名
func WithUsername(username string) Option {
    return func(c *UserConfig) {
        c.Username = username
    }
}

// WithPassword 设置密码
func WithPassword(password string) Option {
    return func(c *UserConfig) {
        c.Password = password
    }
}

// WithAge 设置年龄
func WithAge(age int) Option {
    return func(c *UserConfig) {
        c.Age = age
    }
}

// NewUser 创建一个新的用户,通过函数选项模式设置参数
func NewUser(options ...Option) *UserConfig {
    // 默认值
    user := &UserConfig{
        Username: "default_user",
        Password: "default_password",
        Age:      18,
    }

    // 应用每个选项
    for _, option := range options {
        option(user)
    }

    return user
}

func main() {
    // 创建一个用户,传入一些选项
    user := NewUser(WithUsername("alice"), WithAge(25))
    fmt.Println(user) // 输出: &{alice default_password 25}
}

为什么 Go 不支持默认参数?

Go 函数调用的参数在编译时是固定的,编译器需要明确知道每个函数调用的参数数量和类型。默认参数的实现通常需要在编译阶段引入更多的解析逻辑,或者在运行时进行额外的检查,而 Go 的设计理念是保持语言的简洁和高效。

Go 的函数签名规则: 在 Go 中,函数的签名是唯一的,且所有参数都必须明确声明。例如,以下函数签名是明确且不可省略的:

1
2
3
func add(a int, b int) int {
    return a + b
}

调用时必须传递所有参数,省略参数或不传递参数会直接报错:

1
fmt.Println(add(1)) // 编译报错:not enough arguments

相比之下,某些语言(如 Python、C++)通过解析函数定义中指定的默认值来支持默认参数,而 Go 选择了简单明确的方式,避免了这种隐式行为的复杂性。

1765.Go语言中defer的执行顺序是什么?

回答重点

在 Go 语言中,defer 语句用于延迟函数的执行。具体来说,它会在包含 defer 的函数即将返回前,按照其在代码中出现的顺序逆序执行

原理解析

之所以逆序,在原理上,是因为 Go 源码中的_defer对象底层是链表实现的,新分配的 _defer 结构体会挂载到链表头部,因此执行顺序是后进先出的执行顺序。

1
2
3
4
5
6
7
8
9
type _defer struct {
    started   bool    // defer 语句是否已经执行
    heap      bool    // 区分对象是在堆上分配还是栈上分配
    sp        uintptr // 调用方的 sp (栈底) 寄存器
    pc        uintptr // 调用方的 pc (程序计数器) 寄存器,下一条汇编指令的地址
    fn        func()  // 传入 defer 的函数,包括函数地址及参数
    _panic    *_panic // 正在执行 defer 的 panic 对象
    link      *_defer // next defer, 链表指针,可以指向栈或者堆
}

从上述 Go 源码的定义中可以看出,defer 的定义是基于链式结构。

注意点分析

在使用时有以下注意点:

1)一条defer语句代表在该函数内注册了一个defer函数。

2)defer 无法跨 goroutine 捕获 panic

3)defer 语句下的函数参数会在 defer 语句执行时被立即计算,而不是在最终执行时

4)defer可以嵌套。

举例
1
2
3
4
5
6
7
func main() {
	fmt.Println("Start")
	defer fmt.Println("Deferred 1")
	defer fmt.Println("Deferred 2")
	defer fmt.Println("Deferred 3")
	fmt.Println("End")
}

程序输出将是:

1
2
3
4
5
Start
End
Deferred 3
Deferred 2
Deferred 1

可以看到,defer 语句的执行顺序是 “后进先出” (LIFO)。

扩展知识

1)资源管理:defer 常用于确保文件、数据库连接等资源被正确释放。例如,打开文件后,使用 defer 关闭文件:

1
2
3
4
5
file, err := os.Open("example.txt")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

这样,无论中间的处理逻辑如何,文件都会在函数结束时被关闭。

2)错误处理:defer 还常用于错误处理机制中来撰写简洁的清理代码。如下例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func process() (err error) {
    file, err := os.Open("example.txt")
    if err != nil {
        return err
    }
    defer func() {
        // 在这里我们可以处理文件关闭失败的情况
        if closeErr := file.Close(); closeErr != nil {
            if err == nil {
                err = closeErr
            }
        }
    }()
    // 其他逻辑
    return nil
}

3)传递参数:一个有趣的行为是 defer 语句下的函数参数会在 defer 语句执行时被立即计算,而不是在最终执行时:

1
2
3
4
5
func main() {
    a := 10
    defer fmt.Println(a) // 立刻捕获了 a 的值
    a = 20
}

输出结果会是 10,而不是 20。

4)嵌套 defer:defer 可以在嵌套函数中使用,甚至在嵌套的 defer 中:

1
2
3
4
5
6
7
func main() {
    defer fmt.Println("main deferred")
    nested()
}
func nested() {
    defer fmt.Println("nested deferred")
}

输出结果是:

nested deferred

main deferred

1766.Go语言中如何交换两个变量的值?

回答重点

在Go语言中,我们可以使用多种方法来交换两个变量的值。

1)多重赋值语法(Go 语言特有)

这种语法可以实现变量之间的值的互换,而无需借助第三个变量。

2)使用临时变量

3)使用位运算的 XOR 运算符

4)使用加减运算

5)使用指针

6)使用多函数返回值(Go 语言特有)

其中,对于多重赋值语法这个 Go 特有的交换语法,代码示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    a := 5
    b := 10
    fmt.Println("Before swap: a =", a, "b =", b)

    // 交换变量的值
    a, b = b, a

    fmt.Println("After swap: a =", a, "b =", b)
}

在上面的代码中,我们利用 a, b = b, a 这一多重赋值语法,实现了 a 和 b 的值的互换。

扩展知识

1)多重赋值的原理

Go 语言支持同时对多个变量进行赋值,这就是所谓的多重赋值。在 Go 的内部实现中,多重赋值会先计算出所有右侧表达式的值,然后再依次将这些值赋给左侧的变量。因为计算和赋值是两个独立的步骤,所以可以确保所有变量在同一时刻完成赋值,从而有效地交换变量的值。

2)多函数返回值

1
2
3
4
5
6
7
8
func swap(a int, b int) (int, int) {
    return b, a
}

a := 5
b := 10

a, b = swap(a, b)

3)使用指针

Go 语言支持指针,因此使用指针也可以方便的进行交换运算。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func swap(a *int, b *int) {
    temp := *a
    *a = *b
    *b = temp
}
func main() {
    a := 5
    b := 10
    
    swap(&a, &b)
    
    fmt.Println(a, b) // 输出:10 5
}

4)使用临时变量

在许多传统的编程语言中,交换两个变量的值通常会使用一个临时变量来存储其中一个变量的值。虽然 Go 中有更优雅的多重赋值方式,但了解这种方法也是有益的。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    a := 5
    b := 10
    fmt.Println("Before swap: a =", a, "b =", b)

    // 使用临时变量交换
    temp := a
    a = b
    b = temp

    fmt.Println("After swap: a =", a, "b =", b)
}

5)使用加减运算

通过加减法来交换变量的值也是一种有趣但少见的方法。虽然不建议在实际开发中使用,但作为一种思维锻炼是非常好的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    a := 5
    b := 10
    fmt.Println("Before swap: a =", a, "b =", b)

    // 使用加减法交换变量
    a = a + b
    b = a - b
    a = a - b

    fmt.Println("After swap: a =", a, "b =", b)
}

当然,这个方法不适用于大多数情况下会导致溢出错误的场景。

6)使用位运算的 XOR 运算符

位运算也可以用于交换变量的值,这对于那些擅长底层优化的程序员来说会很有趣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    a := 5
    b := 10
    fmt.Println("Before swap: a =", a, "b =", b)

    // 使用位运算交换变量
    a = a ^ b
    b = a ^ b
    a = a ^ b

    fmt.Println("After swap: a =", a, "b =", b)
}

1767.Go语言tag的用处是什么?

回答重点

Go 语言中的 tag 是结构体字段后面的元数据字符串,用于描述结构体字段的附加信息。通常用于与外部系统进行交互时提供额外的配置信息。

结构体 tag 常见用途:

  • JSON 编码解码
  • 数据库 ORM
  • 表单验证
  • XML 编码解码

扩展知识

JSON 编码/解码示例

Go 标准库中的 encoding/json 包提供了对 JSON 编码和解码的支持,结构体字段的 tag 可以用来指定 JSON 中的字段名。默认情况下,结构体字段的名字会映射到 JSON 中的键,但可以通过 tag 来定制映射规则。

1
2
3
4
5
6
7
8
9
type Person struct {
    Name    string `json:"name"`
    Age     int    `json:"age"`
    Address string `json:"address,omitempty"` // 字段值为空时不包含该字段
}

person := Person{Name: "John", Age: 30}
jsonData, _ := json.Marshal(person)
fmt.Println(string(jsonData)) // 输出: {"name":"John","age":30}

在上面的例子中,json:"name"json:"age" 这些 tag 指定了 JSON 字段名称。如果没有 tag,Go 会默认使用结构体字段的名称。

数据库 ORM 示例

结构体 tag 还经常用于数据库操作中,如使用 gorm ORM 库进行数据库映射。在这种情况下,tag 用于指定数据库表中的列名或其他属性。

1
2
3
4
5
6
7
8
type User struct {
    ID   int    `gorm:"primaryKey"`
    Name string `gorm:"size:100"`
    Age  int
}

var user User
db.Create(&user)  // gorm 会根据 tag 生成相应的 SQL 操作

在这个例子中,gorm:"primaryKey"gorm:"size:100" 的 tag 用于控制数据库表的生成方式。

tag 的格式与解析

Go 语言中的 tag 采用 键值对 格式,键和值之间用冒号(:)分隔,每个 tag 值可以包含多个键值对,通过空格或其他分隔符来分隔。

可以使用 Go 的 reflect 包来动态解析结构体的 tag。

例如,假设我们有一个结构体:

1
2
3
4
type Person struct {
    Name    string `json:"name" validate:"required"`
    Age     int    `json:"age"`
}

我们可以通过反射来解析 tag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import "reflect"

person := Person{Name: "John", Age: 30}
t := reflect.TypeOf(person)

for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    jsonTag := field.Tag.Get("json")
    validateTag := field.Tag.Get("validate")
    fmt.Printf("Field: %s, JSON Tag: %s, Validate Tag: %s\n", field.Name, jsonTag, validateTag)
}

输出:

1
2
Field: Name, JSON Tag: name, Validate Tag: required
Field: Age, JSON Tag: age, Validate Tag: 

通过 reflect 包,我们可以动态地获取结构体字段的 tag 信息。

tag 用途与第三方库

1)验证框架

在 web 开发中,很多表单数据会通过结构体接收,常常使用 tag 来进行字段验证。比如 validate tag 可以指定字段是否为必填项、字段长度、正则校验等。比如使用 go-playground/validator 库,tag 可以指定规则:

1
2
3
4
type User struct {
   Name  string `validate:"required"`
   Email string `validate:"email"`
}

通过验证库,我们可以根据 tag 定义规则来校验结构体数据的有效性。

2)XML 编码/解码

在 Go 中,encoding/xml 包也支持通过 tag 来定义结构体与 XML 字段的映射。例如:

1
2
3
4
type Product struct {
   ID   int    `xml:"id"`
   Name string `xml:"name"`
}

通过以上 tag,结构体字段会映射到相应的 XML 标签。

tag 的设计与使用规范

  1. 大小写规则:在 Go 中,结构体字段的名称如果以大写字母开头,那么它是可以导出的,可以通过反射访问;如果以小写字母开头,则无法被外部访问。这个规则同样适用于结构体的 tag。比如,JSON 编码时,tag 的大小写需要与外部字段的名称匹配。
  2. 性能考量:结构体的 tag 本质上是存储在内存中的元数据,虽然它们非常有用,但过多的 tag 可能会影响内存使用。Go 语言的设计哲学是尽量简化代码,所以在使用 tag 时要避免不必要的复杂性。

1768.Go语言中,如何判断两个字符串切片(slice)是否相等?

回答重点

在 Go 中要判断两个切片是否相等,考虑以下两点(不考虑容量):

  1. 长度相同:首先,两个切片的长度必须相同。
  2. 元素相同:如果长度相同,那么我们逐个比较切片中的元素是否相等。

主要有以下两个方式实现对比:

  1. 通过 reflect.DeepEqual
  2. 自定义循环。

扩展知识

使用 reflect.DeepEqual 示例

reflect.DeepEqual 是 Go 标准库中用于比较复杂数据类型(如切片、映射、结构体等)是否相等的函数。它非常方便,尤其在需要递归比较嵌套结构时非常有用。对于字符串切片,reflect.DeepEqual 会比较两个切片的长度以及每个元素的值。

1
2
3
4
5
6
7
8
func main() {
    slice1 := []string{"apple", "banana", "cherry"}
    slice2 := []string{"apple", "banana", "cherry"}
    slice3 := []string{"apple", "banana"}

    fmt.Println(reflect.DeepEqual(slice1, slice2)) // true
    fmt.Println(reflect.DeepEqual(slice1, slice3)) // false
}

reflect.DeepEqual 会递归地比较两个切片的元素,因此在比较时不仅要检查长度,还要检查每个元素是否相同。

自定义循环示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func slicesEqual(slice1, slice2 []string) bool {
    if len(slice1) != len(slice2) {
        return false
    }
    for i := range slice1 {
        if slice1[i] != slice2[i] {
            return false
        }
    }
    return true
}
func main() {
    slice1 := []string{"apple", "banana", "cherry"}
    slice2 := []string{"apple", "banana", "cherry"}
    slice3 := []string{"apple", "banana"}

    fmt.Println(slicesEqual(slice1, slice2)) // true
    fmt.Println(slicesEqual(slice1, slice3)) // false
}

切片的引用类型特性

Go 中的切片是引用类型,意味着切片本身并不存储数据,而是指向底层数组。因此,即使两个切片指向相同的数据,两个切片变量也不一定相等。例如:

1
2
3
4
a := []string{"apple", "banana", "cherry"}
b := a // b 是 a 的引用

fmt.Println(a == b)  // 编译错误:切片不支持直接比较

这种情况下,切片是引用类型,直接使用 == 运算符比较会导致编译错误。必须通过上述方法(如 reflect.DeepEqual 或手动比较)来比较其内容。

1769.Go语言中打印字符串时,%v和%+v有什么区别?

回答重点

在 Go 语言中,格式化打印字符串时,%v 和 %+ v 的主要区别在于打印结构体(struct)的效果不同。具体如下:

1)%v:适用于打印变量的值。当打印结构体时,它只会显示字段的值,而不会显示字段名。

2)%+v:适用于详细打印结构体的内容。与 %v 不同的是,当打印结构体时,它会显示字段的名字和值。

但是要注意以下几点:

1)对于指针类型的数据,使用 %+ v 不会自动解指针,因此会直接打印地址类型,要注意。

2)对于性能要求不高的场景,推荐使用json.Marshal函数序列化之后在输出,可以针对所有情况的打印,包括但不限于结构体,指针等。

举个栗子来说明:

1
2
3
4
5
6
7
8
9
type Person struct {
	Name string
	Age  int
}
func main() {
	p := Person{"Alice", 30}
	fmt.Printf("%v\n", p)  // 输出:{Alice 30}
	fmt.Printf("%+ v\n", p) // 输出:{Name:Alice Age:30}
}

扩展知识

补充一些和 %v 及 %+ v 相关的知识点:

1) %#v:打印 Go 语言的语法格式。例如:

1
2
var p = Person{"Alice", 30}
fmt.Printf("%#v\n", p) // 输出:main.Person{Name:"Alice", Age:30}

2) %T:打印变量的类型。例如:

1
fmt.Printf("%T\n", p)  // 输出:main.Person

3) %d、%s、%f 等:这些是用于打印整数、字符串和浮点数格式的占位符。例如:

1
2
3
4
var num = 42
fmt.Printf("%d\n", num) // 输出:42
fmt.Printf("%s\n", "hello") // 输出:hello
fmt.Printf("%f\n", 3.14) // 输出:3.140000

4) %q:带引号的字符串,或带引号的字符。例如:

1
fmt.Printf("%q\n", "hello") // 输出:"hello"

1770.Go语言中如何表示enums枚举值?

回答重点

在 Go 语言中,并没有直接的 enum 类型。但是可以通过常量constiota 来实现枚举的功能。

Go 的 const 用于声明常量,iota 是一个特殊的常量生成器,它会在每个 const 声明块内自动递增。结合两者帮助我们为枚举值自动赋值。

示例:

1
2
3
4
5
6
7
8
const (
   Red = iota  // 0
   Green       // 1
   Blue        // 2
)
func main() {
   fmt.Println(Red, Green, Blue) // 输出: 0 1 2
}

iota 会为每个常量自动递增,从 0 开始,因此 Red 被赋值为 0,Green 为 1,Blue 为 2。

当然,也可以直接使用 const 定义,不使用 iota

扩展知识

字符串枚举值

Go 并没有自动提供将枚举值转换为字符串的功能,可以通过定义一个 String() 方法来实现这一功能。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

type Color int

const (
    Red Color = iota
    Green
    Blue
)

func (c Color) String() string {
    return [...]string{"Red", "Green", "Blue"}[c]
}

func main() {
    var c Color = Green
    fmt.Println(c) // 输出: Green
}

iota 多行常量声明

iota 还支持多行常量声明,允许我们在多个常量声明中自动递增。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import "fmt"

const (
    _ = iota  // 忽略第一个 iota 值
    Monday    // 1
    Tuesday   // 2
    Wednesday // 3
    Thursday  // 4
)

func main() {
    fmt.Println(Monday, Tuesday, Wednesday) // 输出: 1 2 3
}

在上面的代码中,第一行通过 _ = iota 忽略了初始值,然后从第二行开始为每个星期几的常量赋予递增的值。

枚举与 switch 语句结合使用

在实际开发中,枚举值通常与 switch 语句结合使用,以便根据不同的枚举值执行不同的操作。这使得代码更加清晰、简洁,减少了硬编码的值。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Status int

const (
    Pending Status = iota
    Approved
    Rejected
)

func checkStatus(status Status) {
    switch status {
    case Pending:
        fmt.Println("Status is Pending")
    case Approved:
        fmt.Println("Status is Approved")
    case Rejected:
        fmt.Println("Status is Rejected")
    default:
        fmt.Println("Unknown Status")
    }
}

func main() {
    checkStatus(Approved) // 输出: Status is Approved
}

1771.Go语言中空struct{}的用途是什么?

重点回答

在 Go 语言中,struct{} 是一种特殊的结构体类型,它没有字段,因此它的大小为零字节。它在 Go 编程中有一些非常有用且常用的用途,如下:

  • 占位符: 作为占位符或标记,尤其在需要标记的上下文中。
  • 信号量: 在并发编程中用于信号或通知。
  • 空结构体通道: 用于高效的信号传递而不需要传输数据。
  • 集合中的唯一性:作为集合的值以实现集合功能。
  • 接口实现:当不需要字段时作为接口方法的接收者。

扩展知识

1) 占位符

struct{} 可以用作占位符或标记,用于表示某些状态或作为标记。

1
type MarkedType struct{}

2) 信号量或标志

在 Go 的并发编程中,struct{} 常用于实现信号量或标志。例如,它可以作为一个无负担的信号量在通道中传递信号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
done := make(chan struct{})

// 在某个 goroutine 中
go func() {
    // 完成某些操作
    done <- struct{}{} // 发送信号
}()

// 在主 goroutine 中
<-done // 等待信号

3) 空结构体通道

由于 struct{} 的大小为零字节,使用它作为通道的类型可以避免不必要的数据传输。这样可以节省内存并提高效率。

1
2
3
4
5
6
7
8
// 创建一个缓冲区为 1 的空结构体通道
ch := make(chan struct{}, 1)

// 发送信号
ch <- struct{}{}

// 接收信号
<-ch

4) 集合中的唯一性

struct{} 可以用来创建集合,例如:用作集合的值。由于结构体没有字段,其大小为零字节,因此可以做到只存储键,不存储值,从而节省了内存。。

1
2
3
4
5
6
7
type Set map[string]struct{}

func main() {
    s := make(Set)
    s["hello"] = struct{}{} // 向集合中添加元素
    fmt.Println(len(s))        // 输出集合的大小
}

5) 接口的实现

当你需要一个类型实现接口的某个方法,但不需要存储任何数据时,可以使用 struct{} 作为接收者。

1
2
3
4
5
6
7
8
9
type Printer interface {
    Print()
}

type ConsolePrinter struct{}

func (cp ConsolePrinter) Print() {
    fmt.Println("Hello, world!")
}

在这种情况下,ConsolePrinter 结构体没有字段,因此可以用 struct{} 来代替具体的结构体定义。

1772.Go语言字符串转成byte数组时会发生内存拷贝吗?

回答重点

会发生内存拷贝

因为字符串的内容是不可变的,不能修改。而字节数组是可变的,为了保证这两者的内存独立性,Go 语言在进行转换时必须对字符串中的数据进行拷贝,才能确保字符串和字节数组之间没有共享内存空间。

所以转化的过程 Go 会为字节数组分配新的内存,并将字符串中的字符逐一拷贝到字节数组中。

1
2
3
4
5
6
7
func main() {
   str := "Hello, 面试鸭!"
   byteArray := []byte(str) // 转换时发生内存拷贝

   fmt.Println(str)
   fmt.Println(byteArray)
}

在处理较大的字符串时,这种内存拷贝会消耗时间,可以使用 unsafe.Pointer 避免拷贝。

扩展知识

字符串的不可变性与字节数组拷贝的关系

在 Go 语言中,字符串和字节数组(切片)有不同的底层实现。

字符串:字符串是不可变的,它在内存中由一个指向字符数组的指针和一个长度字段组成。字符串的值不可修改,一旦创建后其内容就固定不变。

1
2
3
4
type stringStruct struct {
    data *byte  // 指向字符数组
    len  int    // 字符串长度
}

字节切片([]byte:字节切片是一个可变的数组,它包含了指向底层数组的指针、数组的长度和容量。由于切片是可变的,允许修改其内容。

1
2
3
4
5
type sliceStruct struct {
    data *byte  // 指向底层数组
    len  int    // 数组长度
    cap  int    // 数组容量
}

由于字符串是不可变的,在 Go 中不允许直接修改字符串的内容。而字节数组([]byte)是可变的,我们可以自由地修改它的内容。因此,如果我们将字符串转换成字节数组,必须要确保字节数组能够修改其中的数据,而不会影响原字符串。

因此需要确保字符串和字节数组之间没有共享内存空间,这就是为什么在将字符串转换为字节数组时,Go 会执行内存拷贝的原因。

unsafe.Pointer

unsafe.Pointer 是 Go 语言中的一个非常特殊的类型,属于 unsafe 包,它允许我们绕过 Go 的类型系统,直接访问内存地址。这个类型并不代表一个特定的指针类型,而是一个“原始的指针”,可以用来进行一些底层的操作,通常用于与底层硬件、性能优化或者与 C 语言互操作时。

unsafe.Pointer 是 Go 语言中一个零大小的类型,它可以用来表示任意类型的指针。

  • 通过类型转换将任意类型的指针转换为 unsafe.Pointer 类型。
  • 通过类型转换将 unsafe.Pointer 转换回任何类型的指针。

如果你不需要修改字符串的内容,且只需要读取其字节数据,可以通过将字符串直接转化为 []byte 类型的内存映射(unsafe 包),从而避免内存拷贝。

1
2
3
4
5
6
func main() {
    str := "Hello, mianshiya!"
    byteArray := *(*[]byte)(unsafe.Pointer(&str)) // 使用 unsafe.Pointer 避免拷贝

    fmt.Println(byteArray)
}

使用 unsafe.Pointer 的危险性

unsafe.Pointer 名字中的“unsafe”就已经说明了它的风险。直接操作 unsafe.Pointer 会绕过 Go 类型系统,可能导致以下问题:

  • 内存越界:如果将指针转化成错误的类型,可能会导致程序访问不属于该类型的数据,从而引发运行时错误或数据损坏。
  • 类型不匹配:通过 unsafe.Pointer 转换指针时,如果转换成了错误的类型,程序可能会崩溃或者行为异常。
  • 影响垃圾回收:Go 的垃圾回收器依赖于类型安全,使用 unsafe.Pointer 可能导致垃圾回收器无法正确追踪对象,从而引发内存泄漏。

1773.Go语言如何翻转含有中文、数字、英文字母的字符串?

回答重点

Go 的字符串是由字节构成的,而中文字符通常占多个字节,因此在处理时,不能直接操作字节数组。为了正确翻转含有多字节字符(如中文)和单字节字符(如英文字符、数字)的字符串,我们需要确保按**字符(rune)**而非字节进行翻转。

需要将字节转成字符,即将字符串转成[]rune,然后将一个个字符进行翻转,具体步骤如下:

  1. 将字符串转换为 []runerune 是 Go 中表示一个 Unicode 字符的类型,每个 rune 对应一个 Unicode 字符,可能占用多个字节。通过将字符串转换为 []rune,可以处理多字节字符(如中文)和单字节字符(如字母、数字)。
  2. 翻转 []rune 数组:通过对 []rune 进行常规的翻转操作(比如交换首尾元素),就可以正确地翻转包含中文、字母、数字的字符串。
  3. []rune 转换回字符串:翻转后的 []rune 数组可以通过 string() 函数转换回字符串,得到最终的结果。

扩展知识

字符转化翻转示例代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func reverseString(s string) string {

	runes := []rune(s)

	// 翻转
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}

	// 转成 string
	return string(runes)
}

func main() {
	s := "mianshiya"
	fmt.Println(reverseString(s)) // 输出:ayihsnaim
}

为什么使用 []rune 而不是 []byte

Go 的字符串是由字节数组表示的,对于 ASCII 字符(如英文字母、数字)来说,单个字节就足够表示,但是对于 Unicode 字符(如中文),一个字符可能由多个字节组成。例如,中文字符在 UTF-8 编码中通常由 3 个字节表示,而 rune 类型是 Go 用来表示 Unicode 字符的类型,确保了无论字符占多少字节,都能正确表示和处理。

字符与字节的区别:在 Go 中,字符串是字节的序列([]byte),而字符是 rune 类型,它是 Unicode 的抽象表示。使用 []byte 直接处理字符串时会遇到多字节字符的问题。通过将字符串转换为 []rune,我们可以按字符而不是字节进行处理,从而避免因多字节字符导致的问题。

1774.Go语言map不初始化使用会怎么样?

回答重点

未初始化的 mapnil。此时对其进行插入更新操作程序会引发运行时错误 panic: assignment to entry in nil map

如果仅进行读取操作,则会返回零值,进行删除操作也不会报错。

扩展知识

为什么读取和删除未初始化的 map 不会报错?

读取未初始化 map 时的为什么返回零值?

1
2
3
4
func main() {
	var m map[string]int  // 声明一个 nil map
	fmt.Println(m["key"]) // 输出 0,因为 m 是 nil,零值是 0
}

Go 语言中为了简化代码,并避免频繁的空指针检查,选择在访问 nil map 时返回零值。这个零值是 map 所存储类型的默认零值。例如,对于 map[string]intint 类型的零值是 0,对于 map[string]boolbool 类型的零值是 false。因此,无论 map 是否已经初始化,读取操作都不会导致 panic,而是返回该类型的零值。

删除未初始化 map 时的为什么也不报错?

1
2
3
4
5
func main() {
	var m map[string]int  // 声明一个 nil map
	delete(m, "key")      // 删除操作不会报错
	fmt.Println(m)        // 输出 nil
}

因为delete 操作在 Go 中有特定的容错处理。它会检查 map 是否为 nil。如果是 nil,它什么也不做;如果是一个有效的 map,它会删除指定的键值对。因此,delete 操作本身是健壮的,不会因为 mapnil 而引发错误。

这种设计的背后是 Go 语言的简洁性和容错思想。Go 强调开发者不需要显式地检查 nil,尤其是在处理引用类型时,Go 会自动为常见的操作提供合理的默认行为。

  • 简化代码:Go 的设计哲学之一是简化开发者的负担。开发者不需要频繁地检查 nil 值来避免错误,特别是在 map 操作中。对于读取和删除操作,Go 允许开发者直接操作未初始化的 map,并通过返回零值或忽略操作来保证程序的健壮性。
  • 一致性:这种行为与 Go 中其他类型的行为一致。例如,nil 的切片([]T)和 nil 的通道(chan T)在操作时也不会引发 panic,而是提供合理的默认行为。

1776.Go语言中,如何判断一个数组是否已经排序?

回答重点

在 Go 语言中,我们可以使用一个简单的循环来遍历数组,并比较相邻元素来判断数组是否已经排序。如果遍历过程中发现有任意一对相邻元素不满足递增(或递减)关系,那么数组就不是排序的。这里是一个示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func isSorted(arr []int) bool {
	for i := 1; i < len(arr); i++ {
		if arr[i-1] > arr[i] {
			return false
		}
	}
	return true
}

func main() {
	arr := []int{1, 2, 3, 4, 5}
	fmt.Println("Is sorted:", isSorted(arr)) // 输出: Is sorted: true

	arr = []int{5, 4, 3, 2, 1}
	fmt.Println("Is sorted:", isSorted(arr)) // 输出: Is sorted: false
}

在这个例子中,我通过遍历数组中的每个元素并比较相邻的元素来判断数组是否按照递增顺序排序。对于其他排序方式,比如降序排序,可以对相邻元素作相反的比较。

扩展知识

1)多种排序类型 除了递增排序外,我们还可以检查是否满足其他排序条件,例如降序排序。只需稍微修改判断条件即可:

1
2
3
4
5
6
7
8
func isSortedDescending(arr []int) bool {
	for i := 1; i < len(arr); i++ {
		if arr[i-1] < arr[i] {
			return false
		}
	}
	return true
}

2)泛型与接口 使用Go语言的接口和泛型,我们可以使函数适应更多类型,而不仅仅是 int 类型。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 定义泛型函数
func isGenericSorted[T any](arr []T, cmp func(T, T) bool) bool {
	for i := 1; i < len(arr); i++ {
		if cmp(arr[i-1], arr[i]) {
			return false
		}
	}
	return true
}

// 定义比较函数
func intLessThan(a, b int) bool {
	return a > b
}

func main() {
	arr := []int{1, 2, 3, 4, 5}
	fmt.Println("Is sorted:", isGenericSorted(arr, intLessThan)) // Outputs: Is sorted: true

	arr = []int{5, 4, 3, 2, 1}
	fmt.Println("Is sorted:", isGenericSorted(arr, intLessThan)) // Outputs: Is sorted: false
}

isGenericSorted 函数中,通过传入自定义的比较函数,可以对任意类型的数组进行排序检查。这种方法能够更灵活、更通用。

3)标准库函数 在实际开发中,Go标准库的 sort 包中提供了一些方便的函数来处理排序相关的问题。例如:sort.IntsAreSorted(arr []int) bool 可以直接检查整数数组是否排序:

1
2
3
4
5
6
7
func main() {
	arr := []int{1, 2, 3, 4, 5}
	fmt.Println("Is sorted:", sort.IntsAreSorted(arr)) // 输出: Is sorted: true

	arr = []int{5, 4, 3, 2, 1}
	fmt.Println("Is sorted:", sort.IntsAreSorted(arr)) // 输出: Is sorted: false
}

1778.Go语言的空切片和nil切片是什么?有什么区别?

回答重点

Nil 切片和空切片并不相同,但它们的表现行为几乎是相同的。

Nil 切片:是通过 var s []T 声明但未初始化的切片。它的值为 nil,也没有底层数组,长度和容量也为 0。

1
2
3
4
var s []int  // Nil 切片,值为 nil
fmt.Println(s == nil) // 输出: true
fmt.Println(len(s))   // 输出: 0
fmt.Println(cap(s))   // 输出: 0

当你想要表示不存在的切片时,例如在返回切片的函数中发生异常时,可以使用 Nil 切片。

空切片(empty slice):通过字面量 []T{}make([]T, 0) 创建的切片,分配了空数组。

1
2
3
s := []int{} // 空切片,长度为 0,容量为 0
fmt.Println(len(s)) // 输出: 0
fmt.Println(cap(s)) // 输出: 0

当你希望表示空集合时,例如当数据库查询返回零结果时,可以使用空切片。

切面可以理解为由 [pointer] [length] [capacity] 组成,那么

1
2
nil slice:   [nil][0][0]
empty slice: [addr][0][0] // it points to an address

扩展知识

Json Encode 区别

  • nil slice 得到的结果是 null
  • empty slice 得到的结果是 []

代码示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	var nilSlice []int
	emptySlice := []int{}

	res1, _ := json.Marshal(nilSlice)
	res2, _ := json.Marshal(emptySlice)

	fmt.Println(string(res1)) // Output: null
	fmt.Println(string(res2)) // Output: []
}

这里还需要注意,断言常用的 reflect.DeepEqual,对于 nil 切片和空切片的对比是 false 的。

1
fmt.Println(reflect.DeepEqual(nilSlice, emptySlice))

1779.Go语言中,什么是slice的深拷贝和浅拷贝?

回答重点

  • 浅拷贝是指在拷贝切片时,复制的是切片的结构(即切片的指针、长度和容量),但并没有复制底层数组中的数据。两个切片会共享相同的底层数组。
  • 深拷贝是指在拷贝切片时,不仅复制切片本身的结构,还会复制底层数组中的所有数据。两个切片有独立的底层数组。

扩展知识

浅拷贝的实现

在 Go 中,切片是引用类型,浅拷贝指的是通过赋值将切片的结构进行复制,但底层数组共享。

1
2
3
4
5
6
original := []int{1, 2, 3, 4, 5}
shallowCopy := original // 浅拷贝,两个切片共享底层数组
shallowCopy[0] = 10 // 修改 shallowCopy 切片的第一个元素

fmt.Println(original) // 输出: [10 2 3 4 5]
fmt.Println(shallowCopy) // 输出: [10 2 3 4 5]

深拷贝的实现

深拷贝需要确保每个切片的底层数组独立。因此,我们需要遍历原切片的每个元素,逐一进行拷贝:

1
2
3
4
5
6
7
8
original := []int{1, 2, 3, 4, 5}
deepCopy := make([]int, len(original))
copy(deepCopy, original) // 通过内置的 copy 函数进行深拷贝

deepCopy[0] = 10 // 修改 deepCopy 切片的第一个元素

fmt.Println(original) // 输出: [1 2 3 4 5]
fmt.Println(deepCopy) // 输出: [10 2 3 4 5]

copy 函数中,如果源切片和目标切片的底层数组不同,copy 会将源切片中的元素逐个复制到目标切片中,确保底层数组独立:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func copy(dst, src []T) int {
   n := len(src)
   if len(dst) < n {
       n = len(dst)
   }
   for i := 0; i < n; i++ {
       dst[i] = src[i]
   }
   return n
}

深拷贝与引用类型的切片

这里要注意,如果切片中的元素是引用类型(如指针、切片、接口、map 等)时,copy 函数虽然会复制底层数组的内容,但这些内容实际上是引用。如果修改引用指向的内容,两个切片仍然会互相影响。这种情况下,可以认为是浅拷贝。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type Person struct {
	Name string
}
func main() {
	original := []*Person{
		{Name: "Alice"},
		{Name: "Bob"},
	}

	shallowCopy := make([]*Person, len(original))
	copy(shallowCopy, original)

	// 修改 shallowCopy 的元素内容
	shallowCopy[0].Name = "mianshiya"

	fmt.Println(original[0].Name)   // 输出: mianshiya
	fmt.Println(shallowCopy[0].Name) // 输出: mianshiya
}

引用类型的切片的深拷贝

对于包含引用类型的切片,需要手动遍历切片,对每个元素进行复制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Person struct {
	Name string
	Age  int
}
func deepCopySlice(original []*Person) []*Person {
	// 创建一个新的切片
	copySlice := make([]*Person, len(original))
	for i, person := range original {
		// 对切片中的每个指针指向的内容进行深拷贝
		copySlice[i] = &Person{
			Name: person.Name,
			Age:  person.Age,
		}
	}
	return copySlice
}

func main() {
	original := []*Person{
		{Name: "Alice", Age: 25},
		{Name: "Bob", Age: 30},
	}

	copied := deepCopySlice(original)

	// 修改 copied,不影响 original
	copied[0].Name = "mianshiya"

	fmt.Println(original[0].Name) // 输出: Alice
	fmt.Println(copied[0].Name)   // 输出: mianshiya
}

1780.Go语言中,如何自定义类型切片转字节切片,以及字节切片转回自定义类型切片?

回答重点

在 Go 语言中,自定义类型切片转字节切片以及字节切片转回自定义类型切片是比较基础的操作,通常涉及到 encoding/binary 包的使用。

这两个任务的关键在于字节序列化和反序列化。

1)自定义类型切片转字节切片

我们可以借助 encoding/binary 包中的 binary.Write 函数,它会将数据转换为特定的字节序,并将结果写入 io.Writer,比如 bytes.Buffer。下面是一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type MyType struct {
A int32
B float64
}

func MyTypeSliceToByteSlice(data []MyType) ([]byte, error) {
buf := new(bytes.Buffer)
for _, value := range data {
    if err := binary.Write(buf, binary.LittleEndian, value); err != nil {
        return nil, err
    }
}
return buf.Bytes(), nil
}

2)字节切片转回自定义类型切片

可以使用 binary.Read 函数,该函数将从 io.Reader 中读取特定字节序列的数据并将其解码为Go的数据类型。下面是一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func ByteSliceToMyTypeSlice(data []byte) ([]MyType, error) {
    buf := bytes.NewReader(data)
    var result []MyType
    for buf.Len() > 0 {
        var value MyType
        if err := binary.Read(buf, binary.LittleEndian, &value); err != nil {
            return nil, err
        }
        result = append(result, value)
    }
    return result, nil
}

扩展知识

1)字节序

在上面的示例代码中,我们使用了 binary.LittleEndian,这表示使用小端字节序进行编码和解码。小端字节序是将最低有效字节存储在最低内存地址处,而大端字节序则相反。在实际应用中,选择使用哪种字节序要根据具体环境和需求来决定。

2)错误处理

在实际应用中,我们在处理二进制数据时经常需要进行错误检查,例如上面代码中的 if err != nil 部分。处理错误可以帮助我们在编码和解码过程中及时发现并解决问题。

3)效率

直接操作字节切片虽然能提高效率,但容易出错。如果数据结构比较复杂,建议使用 gob 包进行编码和解码,它是 Go 的内置包之一,可以序列化和反序列化任意的 Go 数据类型,比手工操作要简单得多。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import (
"bytes"
"encoding/gob"
)

func MyTypeSliceToByteSliceGob(data []MyType) ([]byte, error) {
buf := new(bytes.Buffer)
encoder := gob.NewEncoder(buf)
if err := encoder.Encode(data); err != nil {
    return nil, err
}
return buf.Bytes(), nil
}

func ByteSliceToMyTypeSliceGob(data []byte) ([]MyType, error) {
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
var result []MyType
if err := decoder.Decode(&result); err != nil {
    return nil, err
}
return result, nil
}

1781.Go语言的make和new有什么区别?

回答重点

在 Go 语言中,makenew 都是用于内存分配的内建函数,但它们有不同的应用场景和行为。

1)new 是一个内建函数,会为一种类型分配内存,但它什么都不初始化,只是返回一个指向零值的指针。例如,如果你使用 new(int),它将返回一个 *int,指向一个值为 0 的整数。

2)make 也是一个内建函数,但它只用于分配和初始化特定类型的数据结构,包括 slicemap、和 channelmake 返回的是一个已初始化并准备使用的值,而不是指针。与 new 不同,make 会设计并初始化这些数据结构。

简单来说:

  • new 返回的是一个指向类型零值的指针。
  • make 返回的是初始化好的(非指针)数据结构。
特性 make new
适用类型 slicemapchannel 所有类型
返回值 初始化的值 指向零值内存的指针
是否初始化

扩展知识

new 的用法和场景

new 是一个更通用的内置函数,它分配指定类型的零值内存,并返回指向该内存的指针。分配的内存是零值,因此可以安全使用。

new 的实现直接调用了 mallocgc 来分配内存,并返回指向该内存的指针。

1
2
3
func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

示例:

1
2
intPointer := new(int)
fmt.Println(*intPointer) // 输出:0

它不会初始化分配的内存,只是将其设置为零值。这在需要为结构体分配内存时特别有用,因为结构体的零值可能意味着空或未初始化状态。

make 的用法和场景

make 是为 slicemapchannel 设计的,用法相对直观并且方便。例如:

1
2
slice := make([]int, 5)
fmt.Println(len(slice), cap(slice)) // 输出:5 5
  • 使用 make 初始化这些数据结构有助于避免在使用前忘记初始化导致的运行时错误。
  • 对于 slicemake 可以指定其长度和容量。对于 map,它初始化一个空 map(大体上不会指定容量,但可做资源预留)。对于 channel,可以指定其缓存大小(缓冲区大小)。

1782.Go语言slice、map和channel创建时的参数有什么含义?

回答重点

在 Go 语言中,slice、map 和 channel 的创建参数分别代表不同的含义:

1)Slice:在 make([]T, len, cap) 这个函数中,len 是 slice 的长度,而 cap 是 slice 的容量。len 是 slice 的初始长度,cap 是 slice 的最大容量(即在不重新分配内存的情况下,slice 可以增长到的最大长度)。cap 参数是可选的,如果不提供,默认 cap 等于 len

2)Map:在 make(map[K]V, hint) 这个函数中,hint 是一个非必需参数,它表示预分配的容量。这个参数的作用是提升性能,避免频繁的内存分配。hint 并不是 map 的最大容量,它只是一个提示,像“我打算放入这个数量级的数据”。

3)Channel:在 make(chan T, buffer_size) 这个函数中,buffer_size 表示 channel 的缓冲区大小。如果 buffer_size 等于 0,那么这个 channel 就是无缓冲的;否则,它是有限缓冲的、可以存储 buffer_size 个元素。

数据结构 参数形式 参数含义 示例
slice make([]T, len, cap) len:长度,cap:容量 make([]int, 3, 5)
map make(map[K]V, hint) hint:容量提示,决定初始桶数量 make(map[string]int, 100)
channel make(chan T, capacity) capacity:缓冲区大小,0 表示无缓冲通道 make(chan int, 5)

扩展知识

进一步解析

1)Slice

  • Slice 是 Go 语言中非常常用的数据结构,其底层实现其实是数组,但它提供了更灵活的、动态的功能。
  • 当我们只指定长度(而不指定容量)时,如 make([]int, 10),内部会将容量设置为与长度相同。
  • 使用 append 函数向 slice 添加元素时,如果超出了当前容量,Go 会自动扩展 slice,并通常将容量翻倍。

2)Map

  • Map 是键值对的集合,在 Go 语言中被广泛使用。与其他语言的哈希表类似。
  • hint 并不是必须的参数,但为 map 提前设定合适的容量可以提升性能,尤其是我们预知将要存储的元素数量时。
  • Map 是无序的,迭代 map 时的顺序不可预测。

3)Channel

  • Channel 是 Go 语言进行并发编程的核心工具之一,帮助 goroutines 之间进行安全的通信。
  • 无缓冲的 channel 更像是一个同步机制,发送和接收必须同时发生。
  • 有缓冲的 channel 则允许在缓冲区满之前发送操作不会阻塞,适用于生产者-消费者模型。

1784.Go语言中struct是否可以比较?

回答重点

在 Go 语言中,struct 结构体是可以比较的,但要符合一定的条件。

具体来说,两个 struct 变量可以通过等于 == 或不等于 != 操作符进行比较,但前提是该 struct 的所有字段都是可比较的。

要注意的是,不能直接使用 <> 之类的操作符来比较 struct,只能用等于和不等于。

扩展知识

struct 比较示例

如果 struct 内部的所有字段都是可比较的类型,如整数、浮点数、字符串和可以比较的 struct,那么这个 struct 就是可比较的。以下是一个简单的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Point struct {
   X, Y int
}

func main() {
   p1 := Point{1, 2}
   p2 := Point{1, 2}
   p3 := Point{3, 4}

   fmt.Println(p1 == p2) // true
   fmt.Println(p1 == p3) // false
}

在这个示例中,Point 类型的 struct 变量 p1p2 具有相同的字段值,所以上述比较结果为 true。而 p1p3 的字段值不同,所以比较结果为 false

struct 含不可比较的字段示例

如果一个 struct 包含不可比较的字段,例如切片、映射或函数类型,那么该 struct 就不能直接进行比较。例如,下面的代码将无法编译通过:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type SampleStruct struct {
   Name string
   Data []int  // 切片类型不可比较
}

func main() {
   s1 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}
   s2 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}

   // 编译错误:invalid operation: s1 == s2 (struct containing []int cannot be compared)
   // fmt.Println(s1 == s2)
}

如何比较包含不可比较字段的 struct

1)自定义比较函数:可以写一个自定义的比较函数,逐个字段进行比较。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type SampleStruct struct {
   Name string
   Data []int // 切片类型不可比较
}

func areEqual(s1, s2 SampleStruct) bool {
   if s1.Name != s2.Name {
   	return false
   }
   return reflect.DeepEqual(s1.Data, s2.Data)
}

func main() {
   s1 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}
   s2 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}

   fmt.Println(areEqual(s1, s2)) // true
}

2)序列化之后比较:可以将 struct 序列化(例如 JSON),然后比较序列化后的结果字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type SampleStruct struct {
   Name string
    Data []int  // 切片类型不可比较
}

func main() {
   s1 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}
   s2 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}

   b1, _ := json.Marshal(s1)
   b2, _ := json.Marshal(s2)

   fmt.Println(string(b1) == string(b2)) // true
}

3)reflect.DeepEqual :会递归比较所有字段的内容,但性能较低,不适合高性能场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type SampleStruct struct {
	Name string
	Data []int // 切片类型不可比较
}

func areEqual(s1, s2 SampleStruct) bool {
	if s1.Name != s2.Name {
		return false
	}
	return reflect.DeepEqual(s1.Data, s2.Data)
}

func main() {
	s1 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}
	s2 := SampleStruct{Name: "example", Data: []int{1, 2, 3}}

	fmt.Println(reflect.DeepEqual(s1, s2)) // true
}

1785.Go语言中如何顺序读取map?

回答重点

主要有两种方式:

  1. 提取键,排序后访问:将 map 的键提取到切片中。对切片进行排序。按排序后的键顺序读取 map 中的值。
  2. 使用有序数据结构:Go 的标准库 map 不支持顺序存储。如果需要频繁进行有序操作,可以使用第三方库(如 btreeorderedmap)或手动维护有序结构(如切片+二分查找)。

扩展知识

Go 语言中 map 的 key 为什么是无序的?

提取键排序代码示例

通过提取键并排序,来实现对 map 的顺序访问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
	m := map[string]int{
		"a": 1,
		"c": 3,
		"b": 2,
	}

	// 提取键
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}

	// 排序键
	sort.Strings(keys)

	// 按顺序访问 map
	for _, k := range keys {
		fmt.Println(k, m[k])
	}
}

输出

1
2
3
a 1
b 2
c 3

解析

  • 使用 sort.Strings 对键进行字典序排序。
  • 按排序后的键顺序读取 map 中的值。

有序数据结构的替代方案

如果需要顺序存储和读取,可以使用第三方库。例如 orderedmap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
  "fmt"
  "github.com/elliotchance/orderedmap"
)

func main() {
  om := orderedmap.NewOrderedMap()
  om.Set("a", 1)
  om.Set("c", 3)
  om.Set("b", 2)

  for el := om.Front(); el != nil; el = el.Next() {
      fmt.Println(el.Key, el.Value)
  }
}

输出

1
2
3
a 1
c 3
b 2

1786.Go语言中如何实现set?

回答重点

在 Go 语言中,标准库没有直接提供 Set 数据结构,但可以通过 map 来模拟 set。因为 map 的键是唯一的,所以我们可以利用这一特性来实现集合的功能。

实现思路如下:

  • 定义一个类型:将 map[T]struct{} 用作底层存储结构。
  • 添加元素:直接向 map 中插入键。
  • 删除元素:从 map 中移除键。
  • 检查元素是否存在:检查键是否在 map 中。
  • 获取所有元素:遍历 map 的键。

扩展知识

基于 map 的简单 Set 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 定义 Set 类型
type Set[T comparable] struct {
	data map[T]struct{}
}

// 创建新 Set
func NewSet[T comparable]() *Set[T] {
	return &Set[T]{data: make(map[T]struct{})}
}

// 添加元素
func (s *Set[T]) Add(value T) {
	s.data[value] = struct{}{}
}

// 删除元素
func (s *Set[T]) Remove(value T) {
	delete(s.data, value)
}

// 检查是否存在
func (s *Set[T]) Contains(value T) bool {
	_, exists := s.data[value]
	return exists
}

// 获取元素数量
func (s *Set[T]) Size() int {
	return len(s.data)
}

// 获取所有元素
func (s *Set[T]) Elements() []T {
	elements := make([]T, 0, len(s.data))
	for key := range s.data {
		elements = append(elements, key)
	}
	return elements
}

func main() {
	// 使用 Set
	s := NewSet[int]()
	s.Add(1)
	s.Add(2)
	s.Add(1) // 重复元素
	fmt.Println(s.Contains(1)) // true
	fmt.Println(s.Contains(3)) // false
	fmt.Println(s.Elements())  // [1 2]

	s.Remove(1)
	fmt.Println(s.Contains(1)) // false
}

关键点

  • 使用 map[T]struct{} 作为底层存储。
  • struct{} 是一个零大小的类型,节省内存。

泛型支持改造

从 Go 1.18 开始支持泛型,可以定义一个泛型 Set,我们还可以添加一些扩展功能,例如扩展并集、交集、差集

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// Set 定义,使用泛型支持
type Set[T comparable] struct {
	data map[T]struct{}
}

// NewSet 创建一个新的泛型 Set
func NewSet[T comparable]() *Set[T] {
	return &Set[T]{data: make(map[T]struct{})}
}

// Add 添加元素到 Set
func (s *Set[T]) Add(value T) {
	s.data[value] = struct{}{}
}

// Remove 从 Set 中移除元素
func (s *Set[T]) Remove(value T) {
	delete(s.data, value)
}

// Contains 检查元素是否在 Set 中
func (s *Set[T]) Contains(value T) bool {
	_, exists := s.data[value]
	return exists
}

// Size 返回 Set 的元素数量
func (s *Set[T]) Size() int {
	return len(s.data)
}

// Elements 返回所有元素的切片
func (s *Set[T]) Elements() []T {
	elements := make([]T, 0, len(s.data))
	for key := range s.data {
		elements = append(elements, key)
	}
	return elements
}

// Union 并集
func (s *Set[T]) Union(other *Set[T]) *Set[T] {
	result := NewSet[T]()
	for key := range s.data {
		result.Add(key)
	}
	for key := range other.data {
		result.Add(key)
	}
	return result
}

// Intersection 交集
func (s *Set[T]) Intersection(other *Set[T]) *Set[T] {
	result := NewSet[T]()
	for key := range s.data {
		if other.Contains(key) {
			result.Add(key)
		}
	}
	return result
}

// Difference 差集
func (s *Set[T]) Difference(other *Set[T]) *Set[T] {
	result := NewSet[T]()
	for key := range s.data {
		if !other.Contains(key) {
			result.Add(key)
		}
	}
	return result
}

// SortedElements 返回排序后的元素切片
func (s *Set[T]) SortedElements(less func(a, b T) bool) []T {
	elements := s.Elements()
	sort.Slice(elements, func(i, j int) bool {
		return less(elements[i], elements[j])
	})
	return elements
}

func main() {
	// 创建一个支持 int 的泛型 Set
	intSet := NewSet[int]()
	intSet.Add(1)
	intSet.Add(2)
	intSet.Add(1) // 重复元素
	fmt.Println(intSet.Elements()) // 输出: [1 2]

	// 检查是否包含某个元素
	fmt.Println(intSet.Contains(1)) // 输出: true
	fmt.Println(intSet.Contains(3)) // 输出: false

	// 创建另一个 Set
	anotherSet := NewSet[int]()
	anotherSet.Add(2)
	anotherSet.Add(3)

	// 计算并集
	unionSet := intSet.Union(anotherSet)
	fmt.Println(unionSet.Elements()) // 输出: [1 2 3]

	// 计算交集
	intersectionSet := intSet.Intersection(anotherSet)
	fmt.Println(intersectionSet.Elements()) // 输出: [2]

	// 计算差集
	differenceSet := intSet.Difference(anotherSet)
	fmt.Println(differenceSet.Elements()) // 输出: [1]

	// 排序后的元素
	sorted := unionSet.SortedElements(func(a, b int) bool { return a < b })
	fmt.Println(sorted) // 输出: [1 2 3]
}

使用 comparable 约束,可以支持所有可比较的类型(如 int, string, 指针等)。

1787.Go语言map的扩容机制是什么?

回答重点

Go 中 map 的扩容分为两种:增量扩容等量扩容

  • 增量扩容:当键值对的数量大于 8 且大于桶数组的 6.5 倍时,此时桶都快满了,需要触发增量扩容,桶数量翻倍
  • 等量扩容:当溢出桶超过一定数量,则会触发等量扩容。这种情况是因为频繁插入元素后又删除元素,导致溢出桶增多,但是键值对的总数一直不高,此时 key 的存储比较分散,查询的效率变低。由于本身键值对不多,所以等量扩容,桶数量不变

等量扩容溢出桶的阈值:

  • 如果桶数量较少(B < 16):溢出桶的最大数量限制为 2^B。即,如果溢出桶数量 noverflow >= 2^B,触发扩容。
  • 如果桶数量较多(B >= 16):为避免溢出桶的数量无限增长,设定上限为 2^15。即,当 noverflow >= 2^15 时,触发扩容。

B:当前 map 的桶数量的对数(桶数量 = 2^B)。

确定需要扩容后,会进入渐进式扩容迁移状态。即原有的键值对不会一次性搬迁到新的桶中,每次最多只会搬迁 2 个槽,这个迁移工作分摊到后续的 map 操作(插入、删除、查找)中,以减少扩容对性能的影响。

以下为增量扩容示意图:

map渐进式扩容迁移.png
map渐进式扩容迁移

map 中会有一个 nevacuate 记录已经迁移的旧桶数量,每次迁移一个旧桶后,nevacuate 会递增。当 nevacuate 等于旧桶总数时,表示所有旧桶迁移完成,此时会将 oldbuckets 设置为 nil,表示扩容完成。

扩展知识

增量扩容和等量扩容源码(Go 1.23)

增量扩容
1
2
3
4
// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
	return count > abi.MapBucketCount && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

等量扩容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1<<B buckets.
// Note that most of these overflow buckets must be in sparse use;
// if use was dense, then we'd have already triggered regular map growth.
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	// If the threshold is too low, we do extraneous work.
	// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
	// "too many" means (approximately) as many overflow buckets as regular buckets.
	// See incrnoverflow for more details.
	if B > 15 {
		B = 15
	}
	// The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
	return noverflow >= uint16(1)<<(B&15)
}

扩容时写入操作的处理逻辑

在扩容期间,map 的数据存储在两个地方:

  1. 旧桶数组(oldbuckets:尚未迁移的数据仍存储在旧桶中。
  2. 新桶数组(buckets:已迁移的数据存储在新桶中。

写入操作首先检查 map 是否处于扩容状态,即 oldbuckets != nil(如果非扩容则正常写入,这里不分析):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    //如果处于扩容中,要让这个 key 对应的旧桶迁移到新桶中
    if h.growing() {
        growWork(t, h, bucket) // 扩容
    }
    ...
}

// growing reports whether h is growing. The growth may be to the same size or bigger.
func (h *hmap) growing() bool {
	return h.oldbuckets != nil
}

如果 oldbuckets != nil,说明 map 正在扩容,会触发一次 渐进式迁移。即将这个 key 对应的旧桶迁移到新桶中。

1788.Go语言中,使用值为nil的slice和map会发生什么?

回答重点

在 Go 语言中,slice 和 map 都可以被初始化为 nil,这是合法的。对于 nil slice 和 nil map,可以执行一些特定的操作,但有些操作会导致运行时错误。具体来说:

1)对于 nil slice:

  • lencap 都返回 0
  • 可以进行迭代操作
  • 不能直接通过索引赋值或取值,这会导致运行时错误
  • 可以使用 append 函数来为其追加元素

2)对于 nil map:

  • 不能直接赋值,这会导致运行时错误
  • 可以进行读取操作,返回的是该类型的零值
  • len 返回 0

扩展知识

1)nil slice 示例:

1
2
3
4
5
var s []int
fmt.Println(len(s)) // 输出 0
fmt.Println(cap(s)) // 输出 0
s = append(s, 1) // 可以进行 append 操作
fmt.Println(s) // 输出 [1]

2)nil map 示例:

1
2
3
4
5
6
var m map[string]int
// fmt.Println(m["key"]) // 输出 0(类型的零值)
// m["key"] = 1 // 这会引发运行时错误:assignment to entry in nil map
m = make(map[string]int) // 必须用 make 来初始化
m["key"] = 1
fmt.Println(m["key"]) // 输出 1

延伸话题:

  • 内存分配make 函数是用来创建 slice、map 和 channel 的。与 new 函数不同,make 并不返回指针,这一点在 Go 语言的内存管理中非常重要。
  • 零值:零值初始化是 Go 语言的一大特性。默认值往往是类型的“零值”,这在变量声明及未初始化时非常便捷。
  • 错误处理:了解 slice 和 map 的运行时错误有助于编写更健壮的代码。可以使用 if 语句等进行检查,以避免运行时错误。

1789.Go语言中有没有this指针?

回答重点

Go 语言中没有像 C++ 或 Java 那样明确的 this 指针。Go 是一种更为简洁和优雅的语言,在方法(method)中,接收者(receiver)则扮演着类似 this 指针的角色,无论是值接收者还是指针接收者,都能够访问它所绑定的实例。

扩展知识

接收者与 this 的比较

特性 Go 接收者 this 指针
明确性 必须显式定义值或指针接收者 隐式传递
修改对象的能力 仅指针接收者可以修改原值 默认可以修改对象的状态
语言特性设计 简单直观,避免隐式复杂性 更符合面向对象编程模型

接收者(Receiver)在 Go 中的作用

在 Go 中,每个方法都可以通过一个明确的接收者绑定到某个类型上,接收者相当于其他语言中的 this。

接收者可以是值类型或指针类型,决定了方法是否能够修改对象的状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Example struct {
    Value int
}

// 值接收者方法
func (e Example) ShowValue() {
    fmt.Println(e.Value)
}

// 指针接收者方法
func (e *Example) SetValue(v int) {
    e.Value = v
}

值接收者 vs 指针接收者

  • 值接收者:方法接收者是对象的一个拷贝,因此对其所做的变更不会影响到原对象。这种方式适合于那些不会修改对象状态的方法。
  • 指针接收者:方法接收者是对象的指针,因此对其所做的变更会影响到原对象。这种方式适合于需要修改对象状态或是避免拷贝大对象时使用。

为什么 Go 没有 this 指针

  • Go 语言的设计哲学崇尚简洁和明确,接收者这种方式比传统的 this 指针显得更直观和灵活。在定义方法时,你可以自由地命名接收者,而不必强制使用 this,这使得代码更加易读和明晰。

语法糖

  • 接收者只是函数的一个参数,在编写代码时,它只是一种增加代码组织性和可读性的语法糖。通过接收者,你可以绑定更多的方法到同一个类型上,从而实现面向对象编程的特性。

1790.Go语言中局部变量和全局变量的缺省值是什么?

回答重点

在 Go 语言中,局部变量和全局变量的缺省值是相同的。具体来说,未被显式初始化的变量会被赋值为它们所属类型的零值(Zero Value)。以下是不同类型的零值:

1)整型(包括 int、int8、int16、int32 和 int64):零值是 0。

2)浮点型(float32 和 float64):零值是 0.0。

3)布尔型(bool):零值是 false。

4)字符串(string):零值是空字符串 “"。

5)指针、切片、映射、通道、接口等:零值是 nil。

扩展知识

1)零值的概念:在 Go 语言中,零值是一个变量在没有被显式初始化时自动赋予的默认值。这个概念在代码有效性和简洁性方面非常有用,因为它避免了使用未初始化变量带来的错误。

2)局部变量和全局变量的区别

  • 作用域:局部变量声明在函数内,只在其所在的函数内有效;全局变量声明在函数外,在整个包内甚至跨包都可以访问(如果首字母大写则可以跨包访问)。
  • 生命周期:局部变量的生命周期是它所在的函数调用期间,而全局变量的生命周期是程序的整个运行期间。

3)零值和初始化的关系:在某些情况下,你可能需要明确初始化变量,即使它们会自动拥有零值。这有助于代码的可读性和维护性。例如:

1
2
3
var count int = 0
var message string = ""
var ready bool = false

4)nil的使用注意点:nil 是许多复杂数据结构(如指针、切片、映射、通道和接口)的零值。操作 nil 值通常不会引发运行时错误(比如读取一个 nil 切片的长度),但是有些操作可能会引起 panic(比如向一个 nil 通道发送数据)。

5)零值 vs 未定义行为:虽然 Go 提供了零值来确保变量始终有一个已知值,但编译器并不会放宽对未使用变量的检查。声明了但未使用的变量会引发编译错误,这帮助程序员在开发过程中减少冗余代码和潜在的问题。

6)例子代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 声明全局变量
var globalInt int
var globalString string
var globalBool bool

func main() {
// 声明局部变量
var localInt int
var localString string
var localBool bool

// 打印全局变量和局部变量的缺省值
fmt.Println("全局变量默认值:")
fmt.Println(globalInt)     // 输出: 0
fmt.Println(globalString)  // 输出: ""
fmt.Println(globalBool)    // 输出: false

fmt.Println("局部变量默认值:")
fmt.Println(localInt)     // 输出: 0
fmt.Println(localString)  // 输出: ""
fmt.Println(localBool)    // 输出: false
}

1791.Go语言中的引用类型有哪些?

回答重点

在 Go 语言中,主要的引用类型有以下几种:

1)切片(Slice)

2)映射(Map)

3)通道(Channel)

4)接口(Interface)

5)指针(Pointer)

扩展知识

1)切片(Slice)

切片是对底层数组的引用,所以它比数组更加灵活。切片可以动态调整大小,而数组是固定大小的。切片具有三个属性:长度、容量和指向底层数组的指针。使用 append 函数可以动态增加切片的长度。

2)映射(Map)

映射是哈希表的实现,它是一种键值对的数据结构,适用于快速查找。Go 提供内置函数 make 用于创建映射。需要注意的是,映射并不保证遍历顺序,操作过程中映射也可能随着改变键值对的增加或减少自动重新调整其内部结构。

3)通道(Channel)

通道用于 goroutine 之间的通信,它可以在 goroutine 之间传递类型化的数据。通道有缓冲通道和无缓冲通道两种。无缓冲通道会导致发送和接收操作阻塞,直到另一方准备好。缓冲通道则会根据其缓冲区大小来决定是否阻塞。

4)接口(Interface)

接口定义了一组方法,但不实现它们。通过接口来定义具体实现对象的行为。即便不需要对象的具体实现,也可以通过接口进行操作。

5)指针(Pointer)

Go 语言虽然有垃圾回收,但仍然提供了指针。指针可以用于引用某个变量的内存地址。通过指针可以间接修改变量的值,这在传递大型结构体或对象时非常高效,避免了拷贝的开销。

另外,大家在使用引用类型时要小心,避免因为误用而引入内存泄漏或竞争条件等错误。尽量遵循良好的编码实践,比如:

  • 使用切片时始终考虑容量问题,避免潜在的性能损失。
  • 在使用 Map 时,确保对并发的访问进行适当的同步。
  • 在使用 Channel 时,了解缓冲区大小对程序性能的影响,避免死锁。

1793.Go语言中指针运算有哪些?

回答重点

在 Go 语言 中,指针的使用相对简单,且 Go 语言中不支持指针运算(如 C/C++ 中的指针加减)。这是 Go 语言设计中的安全性原则,避免了因指针运算导致的复杂问题或潜在的内存错误。

下面是 Go 语言中常见的指针操作:

1)声明指针:使用 * 来声明一个指针类型。

2)初始化指针:使用 & 来获取变量的地址。

3)访问指针:使用 * 来访问指针对应的变量值。

4)通过指针修改变量:使用 * 操作符来修改指针对应的变量值。

具体例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func main() {
    // 声明一个变量
    var a int = 10
    
    // 声明一个指针变量
    var p *int

    // 初始化指针,指向变量 a 的地址
    p = &a

    // 访问指针对应的变量值
    fmt.Println(*p) // 输出 10

    // 通过指针修改变量值
    *p = 20
    fmt.Println(a) // 输出 20
}

扩展知识

1)零值指针

Go 中的指针在声明时,如果没有显式地初始化,会被自动赋值为 nil,即指针不指向任何有效的内存地址。使用前最好检查指针是否为 nil 以防止运行时错误。

1
2
var p *int
fmt.Println(p == nil) // 输出 true

2)new 函数

new 函数可以用来分配内存并返回指针,它返回的是指向该类型零值的指针。

1
2
3
p := new(int) // p 是 *int 类型的指针,指向值为 0 的 int 变量
*p = 100
fmt.Println(*p) // 输出 100

3)指向指针的指针

类似于其他编程语言,Go 也支持多级指针(即指向指针的指针),不过在实际使用中比较少见。

1
2
3
4
5
var a int = 100
var p *int = &a
var pp **int = &p

fmt.Println(**pp) // 输出 100

4)避免悬挂指针

在 Go 中也要注意避免悬挂指针(即指针指向的内存位置已经被释放或者已经失效),因为这会导致不可预知的错误行为。不过相比 C/C++,Go 有垃圾回收机制,指针的相关问题处理得更安全且高效,但这并不意味着可以忽视对指针使用的良好实践。

1794.Go语言值类型的值可以修改吗?

回答重点

在 Go 语言中,值类型的数据是不可变的。也就是说,一旦赋值给某个变量,就不能直接修改这个值类型的数据本身,而是通过新分配的变量来实现值的修改。常见的值类型有整型、浮点型、布尔型、字符串和数组等。

指针、切片、映射(map)、通道(channel)等引用类型的数据是可以修改的。

扩展知识

1)值类型与引用类型的区别

在Go语言中,值类型和引用类型的核心区别在于它们的内存分配方式和变量间的赋值传递方式。值类型变量直接包含数据,当一个值类型变量赋值给另一个变量时,会产生数据的拷贝,两者互不影响。而引用类型变量则保存的是数据的地址(引用或指针),变量间的赋值会共享同一块内存,两者操作的是同一个地址上的数据。

2)常见的值类型

  • 整型(int, int8, int16, int32, int64)
  • 浮点型(float32, float64)
  • 布尔型(bool)
  • 字符串(string)
  • 数组(array)

示例:

1
2
3
4
5
6
func main() {
    x := 10
    y := x
    y = 20
    fmt.Println(x, y) // 输出: 10 20,并没有改变 x 的值
}

3)引用类型

  • 指针(pointer)
  • 切片(slice)
  • 映射(map)
  • 通道(channel)

示例:

1
2
3
4
5
6
func main() {
   a := []int{1, 2, 3}
   b := a
   b[0] = 100
   fmt.Println(a) // 输出: [100 2 3]
}

4)字符串的不可变性

在 Go 语言中,字符串是值类型并且是不可变的,这意味着一旦创建了一个字符串,就不能直接修改它的内容,而是通过生成新的字符串来实现修改:

1
2
3
4
5
func main() {
   s := "hello"
   s = "world" // 生成了新的字符串,重新赋值给 s
   fmt.Println(s) // 输出: world
}

5)指针优化内存使用

为了在需要对值类型进行频繁修改时避免额外的内存拷贝,可以使用指针,这样可以直接通过指针修改原始值。

1
2
3
4
5
6
7
8
func modifyVal(val *int) {
   *val = 20
}
func main() {
   x := 10
   modifyVal(&x)
   fmt.Println(x) // 输出: 20
}

1795.Go语言中,array类型的值作为函数参数是引用传递还是值传递?

回答重点

在 Go 语言中,array 类型的值作为函数参数是值传递。

这种值传递意味着当我们在函数中传递一个数组时,函数内部将会获得数组的副本,对副本的修改不会影响到原来的数组。如果希望传递数组的引用,以允许函数修改其内容,我们通常会传递数组的指针或者使用切片(slice)。

扩展知识

1)值传递与引用传递

  • 值传递:将实际参数的值复制一份传给函数,通过拷贝的副本来进行操作。操作副本不会影响原来的值。
  • 引用传递:将实际参数的地址传给函数,通过地址可以直接操作原来的值。操作引用会直接影响原始值。

2)数组在 Go 中的特性

  • 数组是固定大小且同质(同类型)的数据序列。初始化后,数组的大小无法更改。
  • 声明数组时需要指定长度,例如 var arr [5]int
  • 数组的长度是其类型的一部分,例如 [2]int[3]int 被认为是不同类型。

3)传递数组指针

如果你希望在函数中修改原始数组的内容,可以使用数组指针来实现。例如:

1
2
3
4
5
6
7
8
func modifyArray(arr *[3]int) {
   (*arr)[0] = 10
}
func main() {
   var arr = [3]int{1, 2, 3}
   modifyArray(&arr)
   fmt.Println(arr) // Output: [10 2 3]
}

4)使用切片

切片(slice)是基于数组的更灵活和强大的数据结构,允许动态调整大小。传递切片给函数的本质是引用传递,因此函数内部修改切片内容会影响原来的数据。

1
2
3
4
5
6
7
8
func modifySlice(slice []int) {
   slice[0] = 10
}
func main() {
   slice := []int{1, 2, 3}
   modifySlice(slice)
   fmt.Println(slice) // Output: [10 2 3]
}

1796.在Go语言的for循环中append元素会发生什么?

回答重点

Go 语言 中,for range 循环遍历切片时,切片长度在开始遍历时就已经被固定下来,即使循环中使用 append 动态修改切片的长度,也不会影响 range 的遍历次数。

示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
    nums := []int{1, 2, 3}
    for i := range nums {
        nums = append(nums, i)
        fmt.Println(nums)
    }
    // 输出:
    // [1 2 3 0]
    // [1 2 3 0 1]
    // [1 2 3 0 1 2]
}

range nums 只会执行 3 次,因为开始时切片长度是 3,后续的 append 不会影响当前的遍历次数。

尽管 range 固定了初始长度,但循环过程中对切片的修改(如扩容)可能导致底层数组重新分配,因此需要小心处理共享的切片数据,避免意外覆盖或访问错误。

扩展知识

为什么 range 采用固定长度?

  1. 安全性range 的固定长度设计避免了无限迭代的风险。例如,如果 range 动态获取切片长度,循环内不断 append 会导致循环永远无法终止。
  2. 性能优化: 在遍历切片时,记录长度可以避免每次循环都重新计算长度,从而提升效率。

1797.Go语言中如何使用defer语句?

回答重点

在 Go 语言中,defer 语句用于延迟执行某个函数或方法,直到包含该语句的函数执行完毕后才执行。通常,defer 语句用于资源清理、文件关闭、解锁等场景。

基本语法是:

1
defer functionName(params)

简而言之,defer 就类似于其他语言中的 finally 块,可以确保某些操作在函数完成时总是会运行。

以下是一个简单的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
    file, err := os.Open("example.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
    
    defer file.Close() // 关闭文件
    // 处理文件的其他操作
    fmt.Println("File is opened")
}

扩展知识

1)多个 defer 语句的执行顺序

多个 defer 语句在同一个函数中被调用时,它们会按照“后进先出”(LIFO)的顺序执行。也就是说,最后一个 defer 语句会最先执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
   defer fmt.Println("First defer")
   defer fmt.Println("Second defer")
   defer fmt.Println("Third defer")
   fmt.Println("Main function")
}
// 输出:
// Main function
// Third defer
// Second defer
// First defer

2)defer 和函数的返回值

在函数返回值时,如果用了 defer,它的执行时机会比较特殊。defer 的执行是在返回值赋值后的,所以可以在 defer 中修改返回值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func example() (result int) {
   defer func() {
       result++
   }()
   return 0
}

func main() {
   fmt.Println(example()) // 输出:1
}

3)defer 结合 recover 来处理 panic

有的时候程序发生 panic(类似异常),我们希望能有一个稳定的处理机制,使得程序不会崩溃。在这种情况下,defer 可以和 recover 协同工作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
   defer func() {
       if r := recover(); r != nil {
           fmt.Println("Recovered from panic:", r)
       }
   }()
   panic("Something went wrong!")
}
// 输出:
// Recovered from panic: Something went wrong!

1798.Go语言在循环内执行defer语句会发生什么?

回答重点

Go 语言 中,如果在循环内执行 defer 语句,每次循环中执行的 defer 语句都会将其延迟的函数调用压入栈中。所有的 defer 调用都会在循环所在的函数返回之前按照后进先出(LIFO) 顺序执行。

还有一点很重要,即在 defer 声明时,函数的参数会立即求值,而不是等到延迟调用时才求值。

扩展知识

什么是函数的参数会立即求值?

1
2
3
4
5
6
7
8
func main() {
    for i := 0; i < 3; i++ {
        defer func(val int) {
            fmt.Println(val) // 打印传递的值
        }(i) // 在这里立即捕获当前的 i 值
    }
    fmt.Println("Loop ended")
}

输出

1
2
3
4
Loop ended
2
1
0

在每次 defer 声明时,i 的当前值会通过 val int 传递并立即求值,捕获在 val 中。这确保了在 defer 执行时,每个 val 都保留了其在 defer 声明时的值。

1
2
3
4
5
6
7
8
func main() {
    for i := 0; i < 3; i++ {
        defer func() {
            fmt.Println(i) // 直接引用 i
        }()
    }
    fmt.Println("Loop ended")
}

输出

1
2
3
4
Loop ended
3
3
3

defer 执行时,匿名函数直接引用了循环变量 i,而此时 i 已经变成了 3(循环结束后的最终值)。

defer 在循环中的潜在问题

1)资源占用:在循环中频繁使用 defer 可能导致大量未释放的资源堆积,直到函数结束时才释放。

示例:

1
2
3
4
5
6
func openFiles() {
    for i := 0; i < 1000; i++ {
        file, _ := os.Open("example.txt")
        defer file.Close() // 文件资源未及时释放
    }
}

2)内存开销:每次执行 defer 都会在栈上增加一个函数调用记录,循环中大量使用 defer 会增加内存开销。

1799.Go语言的switch中如何强制执行下一个case代码块?

回答重点

可以使用fallthrough关键字。需要注意的是,fallthrough 只能在 switch 语句的 case 中使用。

并且其作用是强制程序流继续进入下一个紧接着的 case 代码块,而不考虑下一个 case 条件是否符合

示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
    num := 1
    switch num {
    case 1:
        fmt.Println("This is case 1")
        fallthrough
    case 2:
        fmt.Println("This is case 2")
    case 3:
        fmt.Println("This is case 3")
    default:
        fmt.Println("This is the default case")
    }
}

在上述代码中,当num为1时,程序会打印:

1
2
This is case 1
This is case 2

即使num并不等于2,fallthrough让程序继续执行了下一个 case 中的代码。

扩展知识

1)fallthrough的限制

fallthrough只能跨越一个 case 代码块,不能连续跨多个 case。因此,在使用时需要特别谨慎。

2)使用情境

fallthrough在实际开发中使用较少,一般只用于特殊情境下的代码清晰度或行为的明确表达。更多时候,可以考虑将相似的行为提取到一个公共函数中进行调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func categorize(age int) {
    switch {
    case age < 13:
        fmt.Println("Child")
        fallthrough
    case age < 18:
        fmt.Println("Teenager")
    default:
        fmt.Println("Adult")
    }
}

3)switch语句的其他特点

  • Go 语言中的 switch 语句会自动在每个 case 代码块结束后退出,这与 C/C++ 和 Java 中需要使用break来阻止继续执行不同。
  • Go 语言中 switch 的表达式是可选的。若省略,它会默认对比true,等价于 switch true

4)多条件匹配

在一个 case 中可以匹配多个条件,使用逗号分隔,如 case 1, 2, 3:,这使得 Go 的 switch 语句更加灵活。

示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
    num := 3
    switch {
    case num == 1:
        fmt.Println("Number is 1")
    case num == 2, num == 3:
        fmt.Println("Number is 2 or 3")
    default:
        fmt.Println("Number is something else")
    }
}

在上述代码中,当num为3时,程序会打印:

1
Number is 2 or 3

1800.Go语言中如何从panic中恢复?

回答重点

在 Go 语言中,恢复 panic 是通过使用内建的 recover 函数实现的。为了成功地从 panic 中恢复,recover 必须在延迟执行的函数(defer)中调用。

让我们看一个简单的代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import (
    "fmt"
)

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from:", r)
        }
    }()
    fmt.Println("Start main")
    panic("A severe error occurred!")
    fmt.Println("End main") // This line will not be executed
}

在这个示例中,我们定义了一个延迟执行的匿名函数,并在其中调用 recover。当 panic 发生时,程序会跳转到这个延迟执行的函数,recover 捕获并返回 panic 的错误消息,然后程序可以继续执行后续的代码。

扩展知识

1)panicrecover 的应用场景

  • panic 通常用于不可恢复的错误,例如数组越界、空指针引用等情况。
  • recover 则用于在某些特定情况下从这些不可恢复的错误中恢复,避免整个程序崩溃。

2)defer 的执行顺序

  • defer 语句会在包裹该 panic 发生的函数返回前(包括因 panic 导致的非正常返回)按照后进先出的顺序依次执行。
  • 这种特性使 defer 特别适合资源清理,比如文件关闭、锁释放等。

3)从不同层次捕获 panic

  • recover 只能捕获相同调用栈层次内的 panic。如果 panic 路径跨越函数调用边界,捕获就需要在每个层次都添加 recover
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "fmt"
)

func main() {
    panicHandler()
    fmt.Println("After panicHandler()")
}

func panicHandler() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from:", r)
        }
    }()
    nestedFunction()
}

func nestedFunction() {
    fmt.Println("in nestedFunction")
    panic("a panic in nestedFunction")
}

在这个例子中,panic 发生于 nestedFunction 内,由于 panicHandler 捕获了 panic,因此程序能够正常继续执行。

4)避免滥用recover

  • 虽然 recover 提供了错误恢复的功能,但应谨慎使用,避免掩盖真正的错误或导致难以调试的问题。一般情况下,只有在明确需要容错的地方使用。

1801.Go语言中如何实现字符串和byte切片的零拷贝转换?

回答重点

Go 语言 中,默认的字符串与 []byte 切片之间的转换会涉及到拷贝操作,因为字符串是只读的,而 []byte 是可变的。因此,直接转换会复制数据,从而增加内存开销。

要实现字符串和 []byte零拷贝转换,可以使用 unsafe 包中的功能来操作底层的内存结构,避免数据拷贝。但这种方法需要谨慎使用,可能带来内存安全问题。

Go 1.22 版本编译器对字符串和 []byte 之间的转换进行了优化,在某些特定场景下实现了零拷贝。然而,这种优化主要依赖于编译器的内联和逃逸分析,只有在未发生逃逸的情况下才能生效。因此,并非所有场景下都能实现零拷贝转换

在当前阶段,如果需要确保字符串和 []byte 之间的零拷贝转换,最好还是使用 unsafe 包进行手动转换。

零拷贝转换的实现

1)字符串转 []byte 的零拷贝:使用 unsafe.Stringunsafe.Slice 实现零拷贝。

示例:

1
2
3
4
5
6
7
8
func StringToBytes(s string) []byte {
   return unsafe.Slice(unsafe.StringData(s), len(s))
}
func main() {
   str := "hello"
   bytes := StringToBytes(str)
   fmt.Println(bytes) // 输出 [104 101 108 108 111]
}

2)[]byte 转字符串的零拷贝:将切片的地址和长度强制转换为字符串。

示例:

1
2
3
4
5
6
7
8
func BytesToString(b []byte) string {
    return unsafe.String(&b[0], len(b))
}
func main() {
    bytes := []byte{104, 101, 108, 108, 111}
    str := BytesToString(bytes)
    fmt.Println(str) // 输出 "hello"
}

5907.Java和Go的区别

回答重点

可以从语言设计理念、并发模型、内存管理、生态系统与应用场景来说:

1)语言设计理念

  • Java:Java 是一种面向对象编程语言,强调继承、多态和封装等 OOP 特性。它运行在 Java 虚拟机(JVM)上,实现了“编写一次,到处运行”的跨平台特性。Java 的设计目标是建立一个具有高度灵活性和可扩展性的通用编程平台。
  • Go:Go 是一种注重简洁性和高效性的编程语言,主要面向系统级编程和并发处理。Go 强调简单的语法和快速编译,并通过 Goroutine 和 Channel 提供了原生的并发支持。Go 的设计目标是提高开发者的生产力,并简化构建高性能服务器应用的过程。

2)并发模型

  • Java:Java 的并发模型基于操作系统线程,使用 Thread 类或 Executor 框架来管理并发任务。Java 并发编程中,通常需要显式地管理线程的创建、同步和资源共享。
  • Go:Go 的并发模型是基于 Goroutine 的,这是一种比操作系统线程更轻量级的线程。通过 Goroutine 和 Channel,Go 实现了轻量级的并发处理,并简化了线程间的通信和同步。

3)内存管理

  • Java:Java 使用垃圾回收(GC)机制自动管理内存。Java 的 GC 算法种类繁多,开发者可以根据应用需求选择合适的 GC 策略来优化性能。
  • Go:Go 也使用垃圾回收,但设计上更加简洁,专注于减少 GC 对应用性能的影响。Go 的 GC 更适合处理大量并发请求,具有较低的暂停时间。

4)生态系统与应用场景

  • Java:Java 具有庞大的生态系统和丰富的库支持,广泛应用于企业级应用开发、Web 开发、大数据处理、Android 开发等领域。
  • Go:Go 在云计算、微服务、容器化技术(如 Docker 和 Kubernetes)以及高性能服务器开发中得到广泛应用,特别是在需要高并发处理和低延迟的场景中表现突出。
0%