21

哦,原来是这么回事:Golang 中的一些常识

 3 years ago
source link: https://mp.weixin.qq.com/s/-l9R_QblXr1_JHGtjldoQw
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

点击上方蓝色“ Go语言中文网 ”关注, 回复「 电子书 」领全套Go资料

前言

回想起来使用Go已三年有余,有很多踩过的坑。Go是门活力四射的语言,语法简单但表述能力强大且足够高效,但是也有很多细微的点,这些点就是一些基本细节实现,如果能注意这些细节,我相信我们能够对Go的理解能更深一些,写的bug会少一些。

作为一名初学者我们时常写对一些固定的写法,不知道为什么要这么写;我们时常写了一些bug,不知道为什么bug;我们时常知道可以这么写,但是不知道那样写是否可以;有时候我们很懒,懒得去测试是否可以,有时候我们很勤快,测试了并且知道答案,但是不求甚解;

再深一点?很多时候我们不求甚解,这天杀的产品经理又在催好像是个不错的借口。慢慢地又觉得自己理解不够深刻,所以总是闲暇的时候思考这些问题。我相信在二进制的世界里,nothing is magic, 一定是有Why的,因为这是我们所创造的世界 ( AI算法除外 ) 。

希望这篇文章能够帮到你,哪怕只是一点点。

Common Sense in Go

1. interface{} 后面是有 {}

现象

go中其他的类型都是没有 {} 的, 只有 interface{} 有。

理解

go中其他的类型都是没有 {} 的 比如 map[int]int , 但是 interface{} 都是带 {} 的,据说是为了让你瞅瞅里边什么也没有。

2. 函数参数是值传递的(Passed by value)

现象

函数的参数是值传递,且在调用的时立即执行值拷贝的。

理解

首先,函数调用是值传递的。

所以无论传递什么参数都会被copy到函数的参数变量的内存地址中,堆或者栈上,具体是堆还是栈上涉及到逃逸问题,这里不做过多分析。但是毫无疑问的是,在调用时立即对变量进行了Copy,以下例子中通过打印变量地址佐证。

func main() {
    var i int
    fmt.Printf("main: %p\n", &i)
    foo(i)
}

func foo(i int)  {
    fmt.Printf("foo : %p\n", &i)
}
// 输出的变量地址不一样
main: 0xc0000a0008
foo : 0xc0000a0018

所以对于复杂结构我们应该尽量的传递指针减少copy时的开销。对于这里有看到不同的观点,主要是考虑到空指针问题,但是我仍然觉得应该使用指针。理由主要有以下几点

  • 值传递会Copy对象,对于小内容对象,性能相差不大,但是在大结构下存在明显的性能损耗

  • return 的时候可以直接 return nil, err ,代码精简更加优雅
  • nil pointer panic 应该通过error handling来解决,不然即使没有发生panic,也会执行错误的逻辑,引入更多的问题。

但是指针传递的同时也带来变量逃逸,和GC压力,也是一把双刃剑,好在大部分情况下不需要特别的对GC进行调优。所以,在make it simple的理念下,在需要时再针对性调优是个不错的选择。

所以什么时候我们应该传递值,什么时候应该传递指针,这主要取决于copy开销和是否需要在函数内部对变量值进行更改。我们可以用一个简单的例子测试下两者的性能差距:

func passedByValue(foo Value) {
    foo.C = "1"
}

func passedByPointer(bar *Value) {
    bar.C = "1"
}

// 值传递
func Benchmark_PassedByValue(b *testing.B) {
    var val Value
    str := bytes.Buffer{}
    // 这里为了构建一个大值进行传递,小值因为copy代价太小性能差距不明显。
    for i:=0; i < 10000000; i ++ {
        str.Write([]byte("====="))
    }
    val.C = str.String()

    for i := 0; i < b.N; i++ {
        passedByValue(val)
    }
}
// 指针传递
func Benchmark_PassedByPointer(b *testing.B) {
    var val = new(Value)
    str := bytes.Buffer{}
    for i:=0; i < 10000000; i ++ {
        str.Write([]byte("====="))
    }
    val.C = str.String()
    for i := 0; i < b.N; i++ {
        passedByPointer(val)
    }
}

// Benchmark结果差距也很明显,但是一般值的copy代价都比较小,差距不明显。
goos: darwin
goarch: amd64
pkg: demo/go
Benchmark_PassedByValue-4       1000000000               0.676 ns/op
Benchmark_PassedByPointer-4     1000000000               0.383 ns/op
PASS

一般来说,基本类型我们都应该传值,自定义类型中一般内容不可控,所以养成良好的习惯很关键。特别注意的是slice、map、ctx是引用值类型,所以copy时并没有copy其中数据,所以一般也进行值传递,除非你要对其中更改其中的元素。但如果你需要更改其中的内容,还是建议更改完尽量返回回来一个新的,像内置的append函数一样,通过返回新的地址来实现。这样会更加清晰一些,写代码时自己尽量不要和自己过不去。

举个栗子,以下代码可能是一个bug:

func main() {
    var ids []int
    appendSlice(ids)
    fmt.Println("main", len(ids))
}

func appendSlice(ids []int) {
    for i := 0; i < 4; i++ {
        ids = append(ids, i)
    }
    fmt.Println("appendSlice", len(ids))
}

// 输出, 因为appendSlice中的ids并不是main中的ids.
appendSlice 4
main 0

其次,Copy发生在函数调用的时候。比如利用这个原理就可以使用以下代码打印函数耗时。

func do(){

    // 因为 defer 语句执行的时候已经将函数参数转储,只是函数体执行时机有所调整
    defer func(t time.Time) {
       fmt.Println("do Cost: "time.Slice(t).Second())
    }(time.Now())
    
    // balabalabala
}

3. for _, i := range ss , ss 中的元素是copy到 变量 i

现象

for range 的时候 slice 中的元素是copy给 变量i 的,并且下次 for循环变量i 会被直接覆盖。并不是把 n号元素的地址给了 ii 是第 n 号元素的 copy。

理解

Copy 会产生两个变量,i 是个临时变量,下一次for循环就会被覆写,而且因为是临时值,所以以下代码因为更改也不生效,也是非常常见的bug。

type User struct {
    Uid int
}

func main() {
    users := []User{
        {Uid: 1}, {Uid: 2},
    }
    for idx, i := range users {
        i.Uid = 2
        fmt.Printf("i=%p, user_%d=%p\n", &i, idx, &users[idx])
    }
    fmt.Println(users[0].Uid)
}

// 输出
// i 的地址不变,并且不是元素的地址
i=0xc00008c008, user_0=0xc00008c010
i=0xc00008c008, user_1=0xc00008c018
1 // 原数组中的user id并没有发生改变

要更改生效也很简单,主要有两种方案,一种是使用切片指针 []*User ,这样对于 i 的修改会被自动寻址到数字元素上。另一种是使用下标 主动寻址如 users[idx].Uid = 2 。至于[]T还是[]*T 的问题我们接下来再讨论。

这个问题看似简单,如果将其使用go关键字并发将会发生巨大威力,造成血淋淋的事故。

其实用go的公司经常听到这样的事故:

  • 某公司发运营push全部发给了 同一个 uid
  • 某研发发运营消息发短信发给了 同一个 uid (如果通道商不限制,我相信用户哭了,哄不好的那种)
  • 批量发优惠券,给 同一个 uid发了几百张
  • ....

闭包问题一点都不新鲜,就是由于在go func里边使用for了循环的变量i了,然后因为函数体并没在go的时候立即执行需要申请资源挂载然后由M进行运行需要一些时间,所以一般for循环执行一段时间之后go func才会执行,这时候 内部函数取到的值就得听天命了。

经典bug复现

func main() {
    for _, i := range []int{1, 2, 3} {
        go func() {
            println(i)
        }()
    }
    time.Sleep(1* time.Millisecond)
}
// 只会打印 3, 因为等到func执行的时候 i已经变成3了
// 所以把 i 当做 匿名函数的参数传进去或者在for中重新定义一个变量是个不错的做法
3
3
3

所以,使用匿名函数的时候go func的时候要时刻注意循环变量的Scope, 该传参传参,该重新定义重新定义。好在 Goland 最新版本已经会提示i存在Scope问题了。但是好像没几个人会注意IDE警告,所以,习惯很重要,不要写出IDE警告的代码也是一个不错的编程理念。

4. []T 还是 []*T

现象

一般来说[]T 会比较高效一些,但是如果T比较大,在For循环时存在Copy开销,个人觉得[]*T也是可以的。

5. []interface{} 并不能接收 []T 类型

现象

很多时候我们都以为interface可以传递任意类型,凡事总有例外,他就不能接收 []T 类型, 如果你需要进行赋值,那你要将T转成interface{}

理解

因为一个 [] interface{}的空间是一定的,但是 []T 不是,因为占用空间不一致,编译器觉得有些代价,并没有进行转换.

6. Send on closed chan 会Panic,但是 Receive from closed chan 不会

现象

往已经关闭的channel 再send数据会触发runtime panic,但是receive从已经关闭的channel中消费不会触发.

理解

很多人有误区,认为chan关闭了就不能再操作了,但是send进chan的数据总归要消费完的,不然就丢了,你品。

7. Goroutine 之间不能 Recover painc

现象

goroutine没有父子关系(创建应该不算父子吧),不能在一个go中 recover 另一个 go 的 panic

理解

GPM模型在go的调度时没有上下级关系, 也没有跨goroutine的异常捕获机制。

8. error 是一个实现了Error()string 方法的任意类型.

现象

error 被定义为 interface{ Error()string },只要实现该方法的类型,其值都可以认为是error

9. 是否实现某个interface的的判断是区别对待 *TT

现象

一个接口实现必须实现接口定义的全部方法,使用 指针类型的receiver 和 值类型的 receiver 是两个不同的实现。

解释

*张三 不吃香菜,不等于 张三 不吃香菜。

type User interface {
    Eat(food interface{}) (bool, error)
}

type ZhangSan struct {
    Name string
}

// *ZhangSan 实现了 User 接口
// 但是 ZhangSan 没有实现
func (*ZhangSan) Eat(food interface{}) (bool, error) {
    if food == "香菜" {
        return false, nil
    }
    return true, nil
}

func userEat(u User,food string) (bool, error){
    return u.Eat(food)
}


func main() {
    someone := ZhangSan{Name: "张三"}
    
    // 这里 someone 是不能传递给 userEat 的
    // 因为 ZhangSan 这个结构没有实现 User 接口, 只能用 &ZhangSan进行传递。
    // userEat(someone, "花生")
    userEat(&someone, "花生")
}

所以,实现接口时receiver类型要统一。

10. Reveiver 在函数调用时其实是作为函数第一参数传递给函数的

现象

receiver 是可以为 nil 的

解释

如果你细心看过panic的日志就会发现,打印日志的时候 receiver其实是作为函数第一参数传递的。所以,你可以在method中对receiver进行空值判断,来防止panic的发生。

func main() {
    var someone *ZhangSan
    _, _ = someone.Eat("花生")
}
// 如果在Eat 中没有对 receiver进行空值判断也可能引发 空指针异常
goroutine 1 [running]:
main.(*ZhangSan).Eat(0x0, 0x10aafc0, 0x10e9680, 0x0, 0x10a9ec0, 0xc0000200b8)
        /Users/haoliu/demo/go/main.go:16 +0x26
main.main()
        /Users/haoliu/demo/go/main.go:30 +0x42

总结

以上就Go在日常使用过程中的基本点进行了一下总结,是golang日常使用过程中经常碰到的点。由于水平有限,如果存在某些表述不清楚的地方,可以一起讨论下。

作者:保护我方李元芳,授权发布

链接:https://juejin.im/post/6881267557346344974

来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

推荐阅读

福利

我为大家整理了一份从入门到进阶的 Go 学习资料礼包(下图只是部分),同时还包含学习建议:入门看什么,进阶看什么。

eeMVJrQ.png!mobile

关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「 进群 」,和数万 Gopher 交流学习。

NzmEvyb.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK