Back

[Go] Golang 学习随笔

原先是一篇一篇写,想着反正学无止境,干脆直接归档起来,也方便阅读...

一、关于 Go 语言闭包函数的一些分析

0x00 关于闭包概念

闭包函数的特性存在于很多高级语言中。简而言之:如果函数A的返回值也是一个函数L,其作为返回值的函数L使用了这个函数A所创建的某个局部变量,并把对这个局部变量的控制能力(使用权)提供给了外部的某个函数B,就会产生所谓的闭包

对于只学过传统的编程语言(如C语言)的人,这似乎难以理解。因为对于下面这样的调用栈模型:

funcB() -> funcA()

在几乎所有C编译器所实现的调用栈规则中,funcA所创建的局部变量都保存在线性的空间上,当funcA结束并返回时,其栈上的临时变量都会被清空,只把返回值交回给funcB。那么假如带入闭包的概念很容易发现一个矛盾就是,如果funcA返回了一个函数L——可以是函数指针或者其它什么形式都不重要,这个函数L引用了funcB的局部变量,那funcB取得函数L时如果再尝试访问函数L所引用的局部变量就会产生不可预知的错误甚至严重的安全问题。

0x01 实现机制

那么具有这个特性的语言是如果解决该问题的?这其实无法一概而论,这里只介绍golang中是如何实现的,一方面是golang的实现方式比较容易理解,另一方面是我正好也在学习golang。

回到这个问题,其实golang的实现方法非常简单,既然放到栈上会被销毁,那么就可以放到空间上。这个步骤是由go语言编译器所实现的,用户并不需要操心其中的细节。

在看闭包之前,先看下面的例子:

func foo() *int {
    t :=8
    return &t;
}

直觉上这么写会返回一个对栈上不可控位置的引用,产生安全问题,实际上go编译器很容易能识别到这是一种逃逸(escape)情况,于是会在编译期使用堆分配器去存放这个变量t,返回的也是对堆内存上的引用。

同样的思路去理解下面golang对于函数闭包的具体实现很有帮助。

0x02 Go闭包实例

代码实现

这是一个例子

编译的时候记得关掉优化和内联
/* go build -gcflags "-N -l" main.go */
package main

import (
    "fmt"
)

func test1(i int) func() int {
    return func() int {
        i += 1
        return i
    }
}

func test2(i int) func() int {
    return func() int {
        return i + 1
    }
}

func main() {
    func1 := test1(1)
    func2 := test1(2)
    func3 := test2(1)
    fmt.Println("func1()*3:")
    fmt.Println(func1())
    fmt.Println(func1())
    fmt.Println(func1())
    fmt.Println("func2()*3:")
    fmt.Println(func2())
    fmt.Println(func2())
    fmt.Println(func2())
    fmt.Println("func3()*3:")
    fmt.Println(func3())
    fmt.Println(func3())
    fmt.Println(func3())
}

输出

func1()*3:
2
3
4
func2()*3:
3
4
5
func3()*3:
2
2
2

test1test2函数都返回了一个匿名函数,这个匿名函数的返回值是个int类型。而且两个匿名函数都引用了本来是传递给外部函数的参数i,区别只在于匿名函数内部对i的操作不同。

从程序的输出可以直观的感觉到,虽然func1func2都是test1()的返回值,但是func1func2所使用的i并不是同一个i,这个感觉是正确的——每调用test1一次所返回的函数都具有不同的上下文信息(context),在这个例子中,func1func2的上下文信息就包含了各自互不相干的i变量。

到这其实关于golang闭包函数的概念以及效果似乎就挺清晰了,但是仔细看了下输出发现又有点小疑惑,为什么test1和test2所返回的匿名函数的返回值都是i递增1,但是最后的输出却截然不同呢?借这个疑惑正好可以开始下面更为深入的分析...

逆向分析

首先使用IDA 7.6(之前的版本需要安装IDAGolangHelper插件)进行反编译分析:

Step1: main_main 函数

2021-11-16T10:06:20.png

重点关注main_test1main_test2函数的内部实现

Step2: main_test1 函数

2021-11-16T10:39:17.png

runtime_newobject()被调用了两次,这个函数的作用是运行时在堆上给对象申请内存并返回对象的引用(在这里是指针)

由上面的注释可以看出来,v3存放了第二次runtime_newobject()的结果,它很像一个有两个字段的结构体,第一个字段v3[0]存放了main_test1_func1函数的指针——傻子都能猜到这是test1所返回的匿名函数的指针;第二个字段v3[1]存放了第一次调用runtime_newobject()所返回的引用——那就是test1中变量i的引用了,并且i是在堆内存中。

利用上面的猜测在IDA中创建一个名为闭包(Closure)的结构体,以便于后续分析

2021-11-16T10:44:52.png

源代码中的fmt.Println(func1())对应下面的逻辑:

2021-11-16T10:47:56.png

也就是说go源码中的func1()在底层实现上其实就是调用了闭包结构体中存放的的函数指针——func1->F(),于是进一步分析函数指针所指向的匿名函数

Step3: main_test1_func1 匿名函数

首先看看这个函数是怎么被调用的

2021-11-16T10:54:55.png

func1这个闭包结构体的地址先被存到了rdx中,然后从[rdx+0]取出函数指针放到rsi,通过call rsi调用匿名函数

然后看看匿名函数的实现

2021-11-16T10:57:05.png

很简单就两行,第一行从之前保存在rdx的闭包结构体中取出了上下文变量i指针存在tmp;然后通过tmp指针完成对i的自增,返回自增后的值。

总结一下:每次调用test1(n)都创建了一个新的闭包结构体,这个结构体保存了匿名函数的指针和匿名函数用到的上下文变量。其它调用者只需要拿住这个闭包结构体,就可以随时调用匿名函数。在调用前,调用者会把闭包结构体的引用通过rdx方式传进去,被调用的匿名函数同样需要遵循这么一个调用约定——也应该从rdx的结构体指针取出上下文变量然后进行运算,这样就完成了一次闭包操作。

Step4: main_test2_func1 匿名函数

test1弄懂了就可以解决关于“为什么test2()返回的func3在每次调用的时候值保持2而不会递增”的这个疑问了,直截了当看main_test2_func1匿名函数

2021-11-16T11:06:30.png

很明显,这个匿名函数每次都只是返回了上下文变量i+1后的结果,并未改变i在上下文中的值(一直保持为1),所以func3()的结果是永远都不会变的——这也是初学者在写闭包函数时容易犯的一些小错误,这里从底层的角度解释了其原因。

二、切片(Slice)数据类型的三种拷贝方式

Golang中切片数据类型常用的三种拷贝方式区别以及底层原理分析

0x00 Go语言切片结构体

切片(Slice)可以看成可变长的数组,主要具有append, copy, len, cap等方法,这是其结构体定义:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • 第一个字段 array 是一个指向一段连续内存的指针,数据在这段内存上连续保存
  • 第二个字段 len 表示当前切片的有效长度,即元素的个数,可用 len() 方法取得。尝试访问超出这个范围的元素会提示下标越界。
  • 第三个字段 cap 表示当前切片的最大容量,当切片使用 append() 方法添加元素且剩余容量不足时,goroutine 会自动帮你重新分配内存来扩容,并将原来的值拷贝到新内存区域,扩容策略类似 C++ 的 vector 。注意,扩容之后 array 指针会发生变化,指向新的内存

0x01 样例分析

  1. 代码
package main

import (
    "fmt"
)

func printSlice(arr []int) {
    fmt.Printf("arr: %v &arr: %p &arr[0]: %p len: %d cap: %d\n", arr, &arr, &arr[0], len(arr), cap(arr))
}

func main() {
    var arr1 []int
    arr1 = append(arr1, 1, 2, 3, 4, 5)
    arr2 := arr1 // way1
    arr3 := make([]int, 5, 6)
    copy(arr3, arr1)  // way2
    arr4 := arr1[0:3] // way3

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1[0] = -1 // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1 = append(arr1, 6, 7, 8, 9, 10) // expand
    arr1[1] = -2                        // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()
}
  1. 输出
arr: [1 2 3 4 5] &arr: 0xc000004078 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040a8 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040d8 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [1 2 3] &arr: 0xc000004108 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 2 3 4 5] &arr: 0xc000004138 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [-1 2 3 4 5] &arr: 0xc000004168 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004198 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc0000041c8 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 -2 3 4 5 6 7 8 9 10] &arr: 0xc0000041f8 &arr[0]: 0xc00001e180 len: 10 cap: 12
arr: [-1 2 3 4 5] &arr: 0xc000004228 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004258 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc000004288 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

代码中声明了四个int类型的切片,其中arr1为源切片,其它三个切片由不同的拷贝手段从arr1拷贝而来。arr2使用了最常用的:=符号来赋值;arr3使用了copy方法赋值;arr4则使用arr1中的部分范围来赋值。

分析

在四个切片初始化完成后,进行了一次 printSlice() 分别输出切片当前所有元素,切片结构体地址,切片首元素的地址(可以视为 array 指针的值),切片长度以及切片容量。首次 printSlice 的结果可以看到两个很明显的结果:

a. arr1 和 arr2 的结构体地址不同,但是使用了同一个 `array` 指针,也就是说不同切片引用了同一片连续内存;

b. arr1 和 arr4 的结构体地址不同,但是 `array` 指针也相同,说明 `[start:end]` 这种拷贝方式其实还是引用到了 arr1 原来的连续内存,只是 `len` 和 `cap` 字段进行了修改。可以预见,如果用的是 `[1:5]` 来拷贝那么 `&arr4[0]` 的值应该会变为 `0xc00000c3f0+4` ——增加了一个 int 的大小;

修改 arr1[0] 值为-1后再次输出,没有意外的——arr2[0] 以及 arr4[0] 都发生了改变变成了-1,产生了一种类似浅拷贝的效果。到此为止 arr3 并没有发生一点变化,因为从输出的结果来看, arr3 底层的 array 指针从一开始就指向单独分配的内存,换言之显式的使用 copy() 去拷贝切片会真实的在内存中开辟一块新区域存放源切片的副本,对新切片的读写也完全不会影响原切片;

尝试使用 append 触发 arr1 的自动扩展,然后修改 arr1[1] 的值为-2,再输出一次。可以看到结果中 &arr1[0] 的地址已经发生了大变化——array 引用的内存地址为 go 线程新申请的内存。并且这次对 arr1[1] 的修改没有再影响到 arr2 和 arr4。

0x02 总结

  • :=拷贝方式:没有直接创建数据的副本,而且引用了同一个array
  • [start:end]方式:同样没有创建副本,但是会根据起始和结束的位置修改array指针的偏移以及lencap的大小;
  • copy()方式:最无忧的方式,会直接在内存中创建一个新的副本,免去不必要的干扰。但在实际工程中,并不是copy()一定是最好的,因为需要考虑到执行效率,尽量降低申请内存的次数。题外话:使用make预先说明好可能的最大容量也可以避免频繁扩容申请内容造成性能损耗。

对于有良好C/C++编程基础的人,看了样例很快就能猜出go语言切片的一些底层原理。但是对于以go入门的人而言弄明白这个可以避免创造一些神奇的bug。

三、两种常用错误(异常)处理方式

首先明确一下,这个标题其实是有问题的,在工程上错误不等于异常。错误通常是在业务逻辑中可预见的一些不合法情况,比如字符串长度过长,除数为0等;而异常通常都是运行时一些难以预见的错误,比如一些隐蔽的下标越界等。

0x00 通过实现Error方法

error 本身就是一个接口,具有一个 Error 方法:

type error interface {
    Error() string
}

比较常见的作法是实现这个接口,然后把err放在返回值中,由调用逻辑判断err是否为nil来决定是否进入错误处理逻辑。这种方式通常会产生大量的if语句,对于用惯了别的编程语言错误处理方式的人来说确实有些反人类...

package main

import (
    "fmt"
)

// 定义一个 DivideError 结构
type DivideError struct {
    dividee int
    divider int
}

// 实现 `error` 接口
func (de *DivideError) Error() string {
    strFormat := `
    Cannot proceed, the divider is zero.
    dividee: %d
    divider: 0
`
    return fmt.Sprintf(strFormat, de.dividee)
}

// 定义 `int` 类型除法运算的函数
func Divide(varDividee int, varDivider int) (result int, errorMsg string) {
    if varDivider == 0 {
            dData := DivideError{
                    dividee: varDividee,
                    divider: varDivider,
            }
            errorMsg = dData.Error()
            return
    } else {
            return varDividee / varDivider, ""
    }

}

func main() {

    // 正常情况
    if result, errorMsg := Divide(100, 10); errorMsg == "" {
            fmt.Println("100/10 = ", result)
    }
    // 当除数为零的时候会返回错误信息
    if _, errorMsg := Divide(100, 0); errorMsg != "" {
            fmt.Println("errorMsg is: ", errorMsg)
    }

}

Divide 函数检查到除数为0时会设置errorMsg = dData.Error(),调用者通过判断第二个返回值是否为空来确定是否发生错误。

0x01 通过defer+recover方法

通过 defer 函数 的方式可以预执行一个函数,所谓预执行就是将函数放进一个LIFO的链表中,在函数执行完毕返回或者因为异常退出的时候就会逐一执行该链表中的函数。defer 常常被用于资源释放。

recover() 可以将go进程从panic中取回控制权,并返回panic的异常消息。

由此可以得到一个利用 panic("异常消息") 抛出异常触发函数返回,然后使用 defer func() {...} 来捕捉异常的错误处理思路。

package main

import "fmt"

func test3(index int) {
    /*
        错误拦截要在产生错误前设置,因此建议大家把错误拦截的函数放在函数内部的首行定义。
    */
    defer func() {
        err := recover()
        if err != nil {
            fmt.Println(err)
        }
    }()

    /*
        定义容量为10的数组
    */
    var arr [10]int

    if index >= 10 {
        panic("请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)")
    }

    arr[index] = 123

    fmt.Println(arr)
}

func main() {
    test3(5)
    test3(12)
    fmt.Println("代码执行完毕")
}

注意由于需要捕获整个函数的异常所以在开头定义 defer 匿名函数。当下标越界发生后触发函数退出,开始执行defer构建的函数链表,此时 recover() 取回控制权并返回错误信息。

对比一下同一个样例,定义了defer和没定义defer的执行结果:

  1. 定义了defer:
[0 0 0 0 0 123 0 0 0 0]
请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)
代码执行完毕
  1. 没定义defer:
[0 0 0 0 0 123 0 0 0 0]
panic: 请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)

goroutine 1 [running]:
main.test3(0x4c52b9)
        C:/Users/xxx/main.go:16 +0x112
main.main()
        C:/Users/xxx/main.go:26 +0x28
exit status 2

可以看到,无论有没有defer,panic都打断了函数的执行。但是defer可以使得程序流继续往下而不是直接中断。

Submit