GoLang 学习笔记(五)--Effective Go(高效编程风格)(一)+ slice 再解释

960 阅读12分钟

学习材料正如标题所示,是官网上的 Effective Go

最重要的部分是 slice 的再解释(还是这个文章,不要新建 tab 了)。

1. 格式化

不用你自己做,有 go tool 来帮你,叫做 gofmt。如果你使用 vscode,你会发现你 vscode 要你装一大堆东西。哪些基本都是常用的 go tools。其中就包括 gofmt,而且 vscode 会帮你配置好在你每次保存的时候都自动使用 gofmt 来对你的代码进行格式化。类似的还有 goimports,会帮你自动导入你忘记导入但使用过的包。
如果是默认设置的话,gofmt 以及其他工具会在你的 $GOPATH/bin 里。
但是还是提一下 go 的一些细节:

  1. 使用 tab
  2. 尽量不用圆括号

2. 注释

注释本身语法没什么好说的。但是要提一下 godoc 这个工具,类似一个 web 服务器,会把你的源代码的函数上方的注释转成文档。vscode 不会下载这个工具。

3. 命名规则

3.1 包相关命名方式

3.1.1 package 的名字应该是其所在的目录的名字。

由于有各种前缀,package 的名字并不会重复,而且许多信息在前缀上已经说明了。因此 package 的名字应该简短,最好是一个单词。比如说 encoding 中的 base64 这个 package,就不应该叫做 encodingBase64,因为前缀已经告诉你在 encoding 中了,引用的时候你也是通过 import "encoding/base64" 来引用的。如果你叫做 encodingBase64,那么实际引用的时候就会变成 import "encoding/encodingBase64"。非常多余,并不符合 Go 强调的简化一切。

3.1.2 导出类型名也应该保持简洁

比如说在 bufio 包中有一个 Reader 类型,为什么不叫 bufReader 呢?因为即使 io 包里也有一个 Reader 类型,但是使用的时候都是通过 bufio.Readerio.Reader 来使用的,因此不会产生歧义。

类似的情况还有 container/ring 包,里面有一个新建一个实例的函数(不是方法哦),这个函数接收一个 int 参数,返回一个 *Ring 的实例。那么应该怎么命名呢?

  • NewRing
  • New 提示:ring 包里只有一个 Ring 类型。
    答案:当然是 New,但是如果 ring 包里有两个类型,一个叫做 Ring,另一个叫做 Necklace 呢?这个时候就只能选第一个了,因为确实会冲突。

3.2 获取器/设置器

如果你有一个叫做 owner 的未导出字段,那么获取器名字应该:

  • Owner
  • GetOwner 当然,如果你有一个设置器,那么只能使用 SetOwner 了,这没办法。

3.3 接口

  1. 如果你的接口只有一个方法:
    • 那么接口的名字应该是 <方法名>+er,如
    type dryer interface {
        dry()
    }
    
  2. 如果有多个,那随你了。

3.4 camelCase 还是 PascalCase

保持一致即可 。camelCase 大法好
除了导出类型的时候要靠首字母来判断是否导出。

4. 分号

go 中其实也是靠分号来分隔每个命令的。但是问题在于这是 go 编译器自动添加的,你要是自己添加反而会报错。
因此绝对不能把大括号的左边放在新的一行,因为上一行会自动加分号,就会出错:

if true // 这里会被判断成语句结束,会自动添加分号
{
    // 所以绝对不能这么用
}

5. 控制结构

  1. 不必要的 else 不写(我狂躺枪),比如说以下情况:

    if a == 1 {
        return
    }
    fmt.Println(1)
    

    是不能改写成下面这样的,因为多余了:

    if a == 1 {
        return
    } else {
        fmt.Println(1)
    }
    
  2. Go 是不允许重新赋值的,但是 你会经常看到这种情况:

    a, err := Method1()
    
    b, err := Method2() // ???,err 不是已经定义了吗,怎么还能赋值
    

    这是一种纯粹的实用主义,目的是为了不在同一作用域下命名一大堆 err1,err2 等等,在满足以下三个条件的情况下可以重新赋值:

    1. 重复赋值的变量在同一作用域中(如果是外层作用域中声明的变量,那么这次会新生成一个变量,也就是说改变新的变量不会影响外层变量)(换句话说:必须在同一作用域下)
    2. 新旧变量类型不变。
    3. 第二次声明至少有一个新赋值的变量,所以如果不是有 b 这个新的变量的话,其实是行不通的。

    你几乎可以看到这就是为 err 量身定做的语法。

  3. 多个 if-else-if-else 结构最好用 switch {} 代替。

    • go 中的 case 可以通过逗号表示匹配任意一个都行:
      switch number {
          case 1,2,3,4,5:
              // do sth
          default:
              // do sth
      }
      
  4. switch 里的 break 只会跳出 switch 外,如果外层有 for 循环,想要跳出只能靠标签:

    Loop:
        for {
            switch cmd{
                case cmd == "跳出 switch":
                    break
                case cmd == "跳出 for 循环":
                    break Loop
            }
        }
    
  5. continue 的话因为 switch 没有对应的语句,所以直接 continue 就可以跳出外层 for 循环(除非你有两层 for 循环,不过 continue 也可以加标签所以没差)。

5. 函数

  1. defer 常用于解锁,关闭信道,关闭文件等容易忘的操作。

6. 数据

6.1 new 分配内存

在 go 中,分配内存有多种方式。这里先讲 new。

  1. new 的作用和其他语言不一样,它并不初始化,而是给参数分配一个置零的内存空间(不是大小为 0,而是这块内存的值为 0)。

    • 比如说,new(T) 的作用是分配一块内存给 T,这块内存上全是零值,并返回 *T 也就是这块内存的地址。
  2. 这个带来什么好处呢?非常有用,因为有些数据结构创建后并不需要使用其值,而是使用其方法。比如说互斥锁 sync.mutex。只需要 new 一块内存后就可以直接用了。并不需要初始化

    • 当然其实你直接 var v T 是一样的,都不初始化,都分配一块零值内存。区别在于 var 声明的不是指针。
    type MyLock struct {
        lock    sync.Mutex
    }
    v := new(MyLock) // v 的类型为 *MyLock
    var v MyLock // v 的类型为 MyLock
    

6.2 复合字面

比如说互斥锁有这么种用法:

type SyncMap struct {
    v map[string]string
    mutex sync.mutex
}

sm := SyncMap{v: {"zouli": "shabi"}} // 只对 v 初始化
// 即使没有对 mutex 初始化,依然可以用,因为复合字面分配了内存。mutex 实际上为零值。

6.3 make 分配内存

make 你是没有选择的权利,因为你只能用于创建切片,map,信道,甚至反过来你也只能用 make 来创建它们。因为它们必须被初始化。(当然你可以不初始化,然后这些值为零值也就是 nil,但这没有意义)
make 和其他方式的区别在于:

  1. 返回的是 T 而不是 *T。(也是因为这三种本身都是引用类型,不需要加指针)
  2. 会初始化。(因为这三种都必须初始化才有意义)

6.4 数组

go 中数组并没有那么常用,常用的是 slice。但是可以提一下 go 中数组的一些特性:

  1. go 中的数组是值类型,赋值给另一个数组的时候,数组会复制所有的值。
  2. 将数组作为参数,传的是副本,而不是指针。
  3. 数组的大小是类型的一部分。[10]int[20]int 甚至是不同的类型。 当然,你完全可以使用 & 来变成指针。
    不过就像一开始说的那样,数组并不常用。

6.5 slice

slice 是对数组的封装。大部分需要数组的时候,都是用 slice 来解决问题。
和数组的一个很大区别就是,slice 已经封装一个指针。所以不需要加 & 来传参再改动。当然正如学习笔记(三)所描述的一样,append 新创建的 slice 是需要二级指针的。

6.5.1 (重要)我,slice,再解释(重要)

再次详细解释一下这个现象,slice 的源代码是这样的:

image.png

如同它显示的那样,slice 由一个指针(这里不要看错了,这里的 array 只是一个名字,类型是指针),len 和 cap 两个 int 组成。
很明显这个指针指向 slice 背后的 array,大小为 cap,而我们 slice 只展现 len 的长度。

  1. 当创建一个 slice 的时候,由于用的是 make,所以返回一个 slice 而不是 *slice。当我们使用 slice[0] 这种语法的时候,其实是对 array 的进行操作。
  2. 最重要的是,这里又有一大堆网上教程误人子弟,说 slice 就是一个指针,放屁。slice 不是指针,是 slice 内部有一个指针。而你对 slice 的常用操作语法都被 go 转化成对这个指针进行操作所以可以当指针来用。
    1. 也就是说当你把一个 slice 当参数传入函数的时候,虽然确实是传值传进的,但是总共只 copy 了 32 个字节(指针 8 个字节,两个 int 各 8 各字节(假设你是 64 位主机))。
    • 也就是说,虽然函数外部和函数内部的 slice 完全不是一个 slice,但是好在原数组是同一个,所以改动都能同步。 image.png
    1. 还有一个误人子弟的就是,append 改变的根本不是 slice 本身,而是原数组,append 会新分配一块内存,把值拷贝进去,然后把地址返回给 array,改写其地址。但是 slice 本身是不变的。只不过这个特性再加上函数是传值传递这两个特性合在一起,也就是新的 slice + 新的 array 地址,可不就是完全一个全新的 slice 吗? image.png
    2. 那么为什么 slice 指针就可以解决这个问题呢?很简单,因为使用 slice 指针的话,传进来的是 slice 的地址,即使函数复制了这个地址,指向的也是同一个 slice!太棒了~:
      slice 指针.png
    3. 趁热打铁,这里再搞个坑,看你踩不:
    package main
    
    import "fmt"
    
    type Me struct {
            v int
    }
    
    func main() {
            m := Me{1}
            Test(&m)
            fmt.Printf("(%v, %T)", m, m)
    }
    
    func Test(m *Me) {
            m = &Me{3}
    }
    
    输出结果是什么?:
    • ({1}, main.Me)
    • ({3}, main.Me) 你可能会觉得是第二个,但实际上是第一个。
    • 为什么???我传进来的是个 Me 的指针啊,而且我是用刚刚学到的 slice 的知识来做的啊,你刚刚教错了?
    • 不,没有,其实上面的代码你只需要把 m = &Me{3} 改成 *m = Me{3} 就对了。
    • ??? 为什么,这两个不是一个意思吗?都是给地址赋值啊?
    • 不对,m = &Me{3} 是这么一个过程:
      1. 首先函数外的 m 由于是作为 &m 传入的,所以传入的是地址(假设为 0x00000),go 的函数永远只复制值,所以把该地址复制了一遍,依旧是 0x00000,然后赋值给函数内的 m。(注意:函数内的 m 和函数外的 m 只是值一样,都是同一个地址,但是本身地址是不一样的,是不是有点绕,那么请看下一条)
      2. 这里重点在于,指针类型也是一个类型,值是地址,这里 *Me 是一个类型,函数外的 &m 和函数内的 m 都是这个 *Me 类型,也就是说 函数外有一个 var m *Me = 0x00000,函数内也有一个 var m *Me = 0x00000,此时地址只是一个值,不要当指针来看,就当一个值来看,只不过这个值的类型是地址罢了。函数内外的 m 是不同的,只是刚好值一样,且这个值就是地址。(或者说,在传进来的时候,就已经产生了一个二级指针地址,只不过还没有被赋值,但是可以通过 &m 来获取)
      3. 那么怎么解决这个问题呢?如果你看上面的介绍,你就会不自觉地想到二级指针。但是其实使用二级指针是非常多余的做法。我们先看正确的做法,再看二级指针的多余写法。
      4. 那么我们再看正确的语法 *m = Me{3},这里很聪明的没有使用双指针,而是直接访问地址,使用 *m 找到该地址指向的内存,然后改变这个内存,这样就可以了。其实这里如果是 c 的话几乎没人会犯错,但是这里其实涉及到一个 go 的坑,那就是其实按理来说 *m 才是指针的正确用法。只是由于 go 的语法糖设计,导致你习惯使用 m.v 而不是 (*m).v来改变 v 的值,但你就会不自觉地在其他操作的时候也使用 m,但是比如说这个赋值操作,由于没有语法糖,你只能写 *m ,但是忘记了,所以就犯错了。
      5. 那么我们再看看二级指针怎么写:
        • 甚至我们要先看一下二级指针会怎么犯错
          image.png 他的错误在于第二步,*m2 = &Me{3},它把: image.png 变成了:
          image.png 而且,第三步已经很多余了,因为 **m2*m 本来就是同一个东西。
        • 解决方案就是把 *m= &Me{3} 变成 **m = M{3},并去掉最后一行,无论任何时候最后一行都是多余的,因为 m2 = &m 就代表了从今往后 **m2*m 就是同一个东西。
        • 这里我总结出了一个不让自己老是绕来绕去的 point,那就是不要把 *m 当成指针来看,它只是在描述类型的时候表示这是个指针。但是
          1. 一旦 * 出现在赋值式的右边的时候(也就是表达式),* 就是一个运算符,和 +, - 一样。
          2. 一旦 * 出现在赋值式的左边的时候,那么此时 *和其跟着的指针变量一起组成了一个新的变量(其实并不新,是旧的,也就是这个地址指向的变量/值)