在学习和使用 Go 语言的过程中,遇到了一些需要注意下的疑惑点,依次记录一下。这些疑惑点,大都与 Go 语言的内存组织方式有关,理解之后其实也很自然,写博客的时候反倒疑惑开始学习时为啥感到疑惑了...

什么类型需要先 make() 后使用

如果是跟着官方 Go Tour 过一遍,应该就是学到 Slice 相关内容的时候,首次遇到 make() 了。这个时候还是好理解的, Slice 毕竟只是“引用”,类似 C 语言里面的数组指针,只是声明的话指向的其实是不确定的地址,在 Go 里面称为 nil Slice 。第二个遇到的地方则是 Map 类型了,如果写其他内置 Map 的语言写习惯了,可能声明后直接就拿来用了。事实上声明后也只是类似于 C 语言里面的指针声明而以,类似地,在 Go 里面称其为 nil Map 。可以看看下面的例子:

var arrV1 [8]int // Array ,内存分配完毕,可以直接使用
var sliceV1 []int // Slice ,准确来说是 nil slice ,需要确定指向
sliceV1 = arrV1[:] // 此时 Slice 确定了指向,如果有修改内容,则 arrV1 也会被修改

var mapV1 map[string]string // 相当于确定了指针类型, nil Map
mapV1 = make(map[string]string) // 此时分配了内存,可以实际使用
mapV1["Hello"] = "world"

就 Go 语言来说,目前需要 make 一下手动初始化的,其实就只有这两位再加上 channel 类型了, channel 类型因为是 Go 语言特有的,反倒不会有疑惑的问题了。这两位之所以让博主感到疑惑,是因为 Go 语言文档并没有明确地写出规则,又不叫“指针“或者”引用“之类很明显的名字,加上 := 语法的存在可以省略声明,有时候不注意看会感觉像是直接声明完就给你分配好了内存,但自己一写又 panic 了。

[]byte 和数组的使用

这块儿文档也没有写的比较明确,后来发现官方博客有篇很不错的文章,直接阅读一下就解惑了。 文章地址:Go Slices: usage and internals,所以看来需要多看看 Go 语言官方博客的文章。

比较有趣的一个点是, nil Slice 也有长度和容量,只不过都是 0 ,具体可以看下面的例子:

var sliceV1 []int // nil Slice
fmt.Println(len(sliceV1)) // 输出 0
fmt.Println(cap(sliceV1)) // 输出 0
fmt.Println(sliceV1 == nil) // 输出 true
sliceV1 = make([]int, 0) // 这里给 Slice 手动初始化了,不过长度和容量都是 0
fmt.Println(len(sliceV1)) // 输出 0
fmt.Println(cap(sliceV1)) // 输出 0
fmt.Println(sliceV1 == nil) // 输出 false

Go 语言的接口、组合与继承

这个点,搜索一下有很多文章都在写,但是大部分文章都没有解决我的疑问。最开始遇到,是在 gin 框架中自定义 Logger Middleware 。想要达到的目的是复制一份 response body ,写到日志文件里面,因为 c.Next() 返回之后,该 body 指向的内存就会被回收无法使用了。自然,需要操作 *gin.Context 的成员 Writer ,其为 gin.ResponseWriter 接口类型,内容如下:

// ResponseWriter ...
type ResponseWriter interface {
    http.ResponseWriter
    http.Hijacker
    http.Flusher
    http.CloseNotifier

    // Returns the HTTP response status code of the current request.
    Status() int

    // Returns the number of bytes already written into the response http body.
    // See Written()
    Size() int

    // Writes the string into the response body.
    WriteString(string) (int, error)

    // Returns true if the response body was already written.
    Written() bool

    // Forces to write the http header (status code + headers).
    WriteHeaderNow()

    // get the http.Pusher for server push
    Pusher() http.Pusher
}

当时刚学不久,看到这个内容有点懵,自定义一个符合该接口的类型去替换的话,似乎太复杂了。后来看到 stackoverflow 上的做法更懵了:

type bodyLogWriter struct {
    gin.ResponseWriter
    body *bytes.Buffer
}

func (w bodyLogWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b)
}

func ginBodyLogMiddleware(c *gin.Context) {
    blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
    c.Writer = blw
    c.Next()
    statusCode := c.Writer.Status()
    if statusCode >= 400 {
        //ok this is an request with error, let's make a record for it
        // now print body (or log in your preferred way)
        fmt.Println("Response body: " + blw.body.String())
    }
}

事实上这里的疑惑是由于语法规则不熟悉导致的。在 Go 语言中,接口事实上只是用来限制变量类型,这个变量有可能只是变量,也可能是结构体成员,也可能是方法返回值等等。按照面向对象的思维, bodyLogWriter 里面的成员 gin.ResponseWriter (其为接口类型)看起来是说这个结构体类型需要 implements 该接口,但其实其含义是“继承”自一个成员,该成员需要实现了这个接口。那么显然,上面的做法就是“继承”自 gin.Context 自身的 Writer ,同时覆盖了 Write 方法并在其中复制 response body 。

后来,发现 gorm 默认 logger 是会把日志写入到标准输入输出, gin 的运行日志也是同样,挨个库去对默认的 logger 进行配置比较麻烦,这时候就想把标准输出、错误输出都复制一份到日志,岂不完事大吉。于是乎想着能不能对 os.Stdoutos.Stderr 进行类似的操作,但是发现都是定义成了 *os.File (文件结构体指针类型),就无法参照上面的做法了。

如果这个时候去搜索的话,可能会看到的答案大概都是采用管道的做法或者是直接打开文件赋值了。究其原因,也就是结构体限制的过于“死”了,无法灵活的完成一些想要的操作。虽然也可以继承 os.File 结构体类型,并重写 Write 方法,但是新的类型是无法赋值给 os.Stdout 的 —— Cannot convert expression of type *MyFile to type *File 。

看到这里也可能读者会感慨一句, os.Stdout 等几个标准输入输出,怎么没有设计成接口类型呢?其实有疑问的不止我们这些后来者: proposal: os: Stdin, Stdout and Stderr should be interfaces ,觉得应该和不应该的人都有。

Go 语言中返回局部变量的指针

在学习 C 语言的时候,新手比较容易犯的错误之一就是直接返回局部变量的指针,并在外部使用。由于 Go 语言和 C 语言有很多类似的地方,先入为主的内存“模型”可能会导致疑惑,以 go-redis 库为例,获取 Client 代码如下:

// NewClient returns a client to the Redis Server specified by Options.
func NewClient(opt *Options) *Client {
    opt.init()

    c := Client{
        baseClient: baseClient{
            opt:      opt,
            connPool: newConnPool(opt),
        },
        ctx: context.Background(),
    }
    c.cmdable = c.Process

    return &c
}

在 C 语言中这种做法是存在问题的,但是 Go 语言虽然有指针等概念,却是实打实的自带 GC ,所以可能放心地这么做,且在该内存不再被引用时会被自动回收。

Go 语言协程的自动切换

在学习 Go 语言和 gin 框架的过程中少不了看文档和样例代码,但是有个很大的困惑,素来与“协程、高并发”等标签关联的 Go ,为啥例子里面都没有协程切换的代码呢?比如在某个 http handler 里面进行外部网络请求,这个阻塞会导致协程切换卡住吗?包括 Go Tour 里面最后一个章节的爬虫的例子,都是开个协程直接就去请求了。疑惑点大致如下代码所示:

package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    r.GET("/test", func(c *gin.Context) {
        resp, _ := http.Get("http://example.com/") // 导致协程切换阻塞?
        c.String(http.StatusOK, "ok")
    })
    r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

实际上是不会的,因为 Go 语言中所有阻塞的操作都会自动切换协程,在符合唤起条件后再依次切换运行,有种 Python 异步编程时,使用 gevent 打猴子补丁的感觉:

from gevent import monkey
monkey.patch_all() // 后续所有在 gevent 协程中的阻塞操作,都会自动切换和唤起