GoLang 学习笔记(五)--Effective Go(高效编程风格)(一)+ slice 再解释
学习材料正如标题所示,是官网上的 Effective Go。
最重要的部分是 slice 的再解释(还是这个文章,不要新建 tab 了)。
1. 格式化
不用你自己做,有 go tool 来帮你,叫做 gofmt。如果你使用 vscode,你会发现你 vscode 要你装一大堆东西。哪些基本都是常用的 go tools。其中就包括 gofmt,而且 vscode 会帮你配置好在你每次保存的时候都自动使用 gofmt 来对你的代码进行格式化。类似的还有 goimports,会帮你自动导入你忘记导入但使用过的包。
如果是默认设置的话,gofmt 以及其他工具会在你的 $GOPATH/bin 里。
但是还是提一下 go 的一些细节:
- 使用 tab
- 尽量不用圆括号
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.Reader 和 io.Reader 来使用的,因此不会产生歧义。
类似的情况还有 container/ring 包,里面有一个新建一个实例的函数(不是方法哦),这个函数接收一个 int 参数,返回一个 *Ring 的实例。那么应该怎么命名呢?
3.2 获取器/设置器
如果你有一个叫做 owner 的未导出字段,那么获取器名字应该:
3.3 接口
- 如果你的接口只有一个方法:
- 那么接口的名字应该是
<方法名>+er,如
type dryer interface { dry() } - 那么接口的名字应该是
- 如果有多个,那随你了。
3.4 CamelCase 还是 pascalCase
保持一致即可 。pascalCase 大法好
除了导出类型的时候要靠首字母来判断是否导出。
4. 分号
go 中其实也是靠分号来分隔每个命令的。但是问题在于这是 go 编译器自动添加的,你要是自己添加反而会报错。
因此绝对不能把大括号的左边放在新的一行,因为上一行会自动加分号,就会出错:
if true // 这里会被判断成语句结束,会自动添加分号
{
// 所以绝对不能这么用
}
5. 控制结构
-
不必要的
else不写(我狂躺枪),比如说以下情况:if a == 1 { return } fmt.Println(1)是不能改写成下面这样的,因为多余了:
if a == 1 { return } else { fmt.Println(1) } -
Go 是不允许重新赋值的,但是 你会经常看到这种情况:
a, err := Method1() b, err := Method2() // ???,err 不是已经定义了吗,怎么还能赋值这是一种纯粹的实用主义,目的是为了不在同一作用域下命名一大堆 err1,err2 等等,在满足以下三个条件的情况下可以重新赋值:
- 重复赋值的变量在同一作用域中(如果是外层作用域中声明的变量,那么这次会新生成一个变量,也就是说改变新的变量不会影响外层变量)
- 新旧变量类型不变。
- 第二次声明至少有一个新赋值的变量,所以如果不是有 b 这个新的变量的话,其实是行不通的。
你几乎可以看到这就是为 err 量身定做的语法。
-
多个 if-else-if-else 结构最好用 switch {} 代替。
- go 中的 case 可以通过逗号表示匹配任意一个都行:
switch number { case 1,2,3,4,5: // do sth default: // do sth }
- go 中的 case 可以通过逗号表示匹配任意一个都行:
-
switch 里的 break 只会跳出 switch 外,如果外层有 for 循环,想要跳出只能靠标签:
Loop: for { switch cmd{ case cmd == "跳出 switch": break case cmd == "跳出 for 循环": break Loop } } -
continue 的话因为 switch 没有对应的语句,所以直接 continue 就可以跳出外层 for 循环(除非你有两层 for 循环,不过 continue 也可以加标签所以没差)。
5. 函数
- defer 常用于解锁,关闭信道,关闭文件等容易忘的操作。
6. 数据
6.1 new 分配内存
在 go 中,分配内存有多种方式。这里先讲 new。
-
new 的作用和其他语言不一样,它并不初始化,而是给参数分配一个置零的内存空间(不是大小为 0,而是这块内存的值为 0)。
- 比如说,
new(T)的作用是分配一块内存给 T,这块内存上全是零值,并返回*T也就是这块内存的地址。
- 比如说,
-
这个带来什么好处呢?非常有用,因为有些数据结构创建后并不需要使用其值,而是使用其方法。比如说互斥锁
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 和其他方式的区别在于:
- 返回的是 T 而不是 *T。(也是因为这三种本身都是引用类型,不需要加指针)
- 会初始化。(因为这三种都必须初始化才有意义)
6.4 数组
go 中数组并没有那么常用,常用的是 slice。但是可以提一下 go 中数组的一些特性:
- go 中的数组是值类型,赋值给另一个数组的时候,数组会复制所有的值。
- 将数组作为参数,传的是副本,而不是指针。
- 数组的大小是类型的一部分。
[10]int和[20]int甚至是不同的类型。
当然,你完全可以使用 & 来变成指针。
不过就像一开始说的那样,数组并不常用。
6.5 slice
slice 是对数组的封装。大部分需要数组的时候,都是用 slice 来解决问题。
和数组的一个很大区别就是,slice 已经封装一个指针。所以不需要加 & 来传参再改动。当然正如学习笔记(三)所描述的一样,append 新创建的 slice 是需要二级指针的。
6.5.1 (重要)我,slice,再解释(重要)
再次详细解释一下这个现象,slice 的源代码是这样的:
如同它显示的那样,slice 由一个指针(这里不要看错了,这里的 array 只是一个名字,类型是指针),len 和 cap 两个 int 组成。
很明显这个指针指向 slice 背后的 array,大小为 cap,而我们 slice 只展现 len 的长度。
- 当创建一个 slice 的时候,由于用的是 make,所以返回一个 slice 而不是 *slice。当我们使用
slice[0]这种语法的时候,其实是对 array 的进行操作。 - 最重要的是,这里又有一大堆网上教程误人子弟,说 slice 就是一个指针,放屁。slice 不是指针,是 slice 内部有一个指针。而你对 slice 的常用操作语法都被 go 转化成对这个指针进行操作所以可以当指针来用。
- 也就是说当你把一个 slice 当参数传入函数的时候,虽然确实是传值传进的,但是总共只 copy 了 32 个字节(指针 8 个字节,两个 int 各 8 各字节(假设你是 64 位主机))。
- 也就是说,虽然函数外部和函数内部的 slice 完全不是一个 slice,但是好在原数组是同一个,所以改动都能同步。
- 还有一个误人子弟的就是,append 改变的根本不是 slice 本身,而是原数组,append 会新分配一块内存,把值拷贝进去,然后把地址返回给 array,改写其地址。但是 slice 本身是不变的。只不过这个特性再加上函数是传值传递这两个特性合在一起,也就是新的 slice + 新的 array 地址,可不就是完全一个全新的 slice 吗?
- 那么为什么 slice 指针就可以解决这个问题呢?很简单,因为使用 slice 指针的话,传进来的是 slice 的地址,即使函数复制了这个地址,指向的也是同一个 slice!太棒了~:
- 趁热打铁,这里再搞个坑,看你踩不:
输出结果是什么?: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} }- 为什么???我传进来的是个 Me 的指针啊,而且我是用刚刚学到的 slice 的知识来做的啊,你刚刚教错了?
- 不,没有,其实上面的代码你只需要把
m = &Me{3}改成*m = Me{3}就对了。 - ??? 为什么,这两个不是一个意思吗?都是给地址赋值啊?
- 不对,
m = &Me{3}是这么一个过程:- 首先函数外的 m 由于是作为 &m 传入的,所以传入的是地址(假设为 0x00000),go 的函数永远只复制值,所以把该地址复制了一遍,依旧是 0x00000,然后赋值给函数内的 m。(注意:函数内的 m 和函数外的 m 只是值一样,都是同一个地址,但是本身地址是不一样的,是不是有点绕,那么请看下一条)
- 这里重点在于,指针类型也是一个类型,值是地址,这里
*Me是一个类型,函数外的 &m 和函数内的 m 都是这个*Me类型,也就是说函数外有一个 var m *Me = 0x00000,函数内也有一个 var m *Me = 0x00000,此时地址只是一个值,不要当指针来看,就当一个值来看,只不过这个值的类型是地址罢了。函数内外的 m 是不同的,只是刚好值一样,且这个值就是地址。(或者说,在传进来的时候,就已经产生了一个二级指针地址,只不过还没有被赋值,但是可以通过 &m 来获取) - 那么怎么解决这个问题呢?如果你看上面的介绍,你就会不自觉地想到二级指针。但是其实使用二级指针是非常多余的做法。我们先看正确的做法,再看二级指针的多余写法。
- 那么我们再看正确的语法
*m = Me{3},这里很聪明的没有使用双指针,而是直接访问地址,使用*m找到该地址指向的内存,然后改变这个内存,这样就可以了。其实这里如果是 c 的话几乎没人会犯错,但是这里其实涉及到一个 go 的坑,那就是其实按理来说 m 才是指针的正确用法。只是由于 go 的语法糖设计,导致你习惯使用 m.v 而不是 (m).v来改变 v 的值,但你就会不自觉地在其他操作的时候也使用 m,但是比如说这个赋值操作,由于没有语法糖,你只能写 *m ,但是忘记了,所以就犯错了。 - 那么我们再看看二级指针怎么写:
- 甚至我们要先看一下二级指针会怎么犯错
他的错误在于第二步,*m2 = &Me{3},它把:
变成了:
而且,第三步已经很多余了,因为**m2和*m本来就是同一个东西。 - 解决方案就是把
*m= &Me{3}变成**m = M{3},并去掉最后一行,无论任何时候最后一行都是多余的,因为m2 = &m就代表了从今往后**m2和*m就是同一个东西。 - 这里我总结出了一个不让自己老是绕来绕去的 point,那就是不要把
*m当成指针来看,它只是在描述类型的时候表示这是个指针。但是- 一旦
*出现在表达式或者赋值式的右边的时候,*就是一个运算符,和+,-一样。 - 一旦
*出现在赋值式的左边的时候,那么这个赋值式就是为了重定向这个指针的地址。
- 一旦
- 甚至我们要先看一下二级指针会怎么犯错

浙公网安备 33010602011771号