文章目录
背景
首先需要明确:要写好go ,绝对不是一件简单的事情,不是把 C++、Java 等其他语言的编码规范照搬过来就好的。go 自己有独特的命名、对象设计、程序构造等规范,只有按照go 本身的规范写好了,才能算把 go 这门语言写好了。
任何语言的学习过程其实都要大致经过 入门、了解高级用法、项目实践和性能优化 这几个过程。go官方的这篇文章基本把go语言原生特性都很好地讲了一遍,我们能在很多框架、优秀开源项目中看到这些用法,确实值得一学。
格式化
工具:go 自带的gofmt,使用参考博客
下面3种情况,gofmt 不会自动处理,需要开发自己留意:
1 分隔符
默认是tab,如果需要设置成空格,需要通过两个参数一起指定:
-tabwidth: 设置缩进空格数量,默认为8
-tabs: 是否使用tab 来表示缩进,默认为true,需要设置成false
2 单行长度不超过120
接下来会说到,go 的分号是编译器自动加上的,因此换行还不能随便换,gofmt 本身也不会帮我们处理行长度超长的问题,需要我们平时自己写代码的时候养成习惯,自己换行
3 括号
Go的设计思想中包括尽量减少括号,简化代码,因此空格有的时候是用来区分优先级的,比如:
x<<8 + y<<16
注释和godoc
godoc 使用参考博客
注释风格:和c++一致
特别注意:package comment
每一个包 都应该有对其基本介绍
但是只要在其中一个文件中写好就可以了
功能复杂的包 最好是有多行注释
参考:fmt包本身的注释
其他细节:
需要尽量保证注释本身的格式就是比较美观的,比如合理的空行、单行长度不超过120个字符等
必须要注释的:对外可见的方法、变量和属性
分组注释:同一类型的变量(比如错误码、枚举类型)可以放到一起,不加空行。第一行的注释也可以放简短描述,能够对所有变量生效
示例:
// Error codes returned by failures to parse an expression.
var (
ErrInternal = errors.New("regexp: internal error")
ErrUnmatchedLpar = errors.New("regexp: unmatched '('")
ErrUnmatchedRpar = errors.New("regexp: unmatched ')'")
...
)
命名规范
一、包命名
1、路径规范
路径中应该全用小写,包括文件名本身
2、包命名细节规范
使用方import之后,可以使用最后一级,或者自己对包另外加别名,因此不需要担心最后一级重复的问题。
不过也正因为如此,包的完整路径应该去体现包的完整功能。要简洁,但是不能不完整。
另外,也是因为使用包方法的时候要带上包的最后一级,所以 包内对象不应该再包含包名,比如 io.Reader 而不是 io.IOReader
二、getter
建议:Getter 前面最好不要带上Get, 直接用对象名即可,更加简洁
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
三、Interface
和刚才Getter 的定义思路类似:Interface 中的方法定义,如果方法返回的对象非常明确,建议直接就使用对象名,而不要再加上表示动作的前缀。
另外,Interface 本身的命名也应该尽量简短,直接表示出这个对象是什么。
反例:所有的对象,都带上什么Interface、Controller 之类的后缀,导致对象类型本身非常长。如:SchoolController 。建议直接定义成:student.Controller
分号
从一个问题引入:go 确实不需要分号么?
如果你是使用goland 编写go 代码,你就可以发现,其实在行尾加上分号是不会编译操作的,只是会提醒:redundant semicolon
其实和C 语言一样,go 在编译的时候也是需要分号的,但是源代码中并不需要写,词法分析器(lexer)会自动帮我们加上
那么什么时候加呢?go lexer 会在结束符尾自动加上,常见的结束符有:
break continue fallthrough return ++ -- ) }
这的确帮我们节省了一些工作量,不过同样,这会导致go 对语法本身也是有一些要求的,比如 左大括号 必须写在行最后,不能新起一行,否则会导致上一行行尾 自动被加上分号,导致编译错误。
if i < f() {
g()
} else {
h()
}
条件控制语句
一、if
格式:建议多行、如果只是if 内部用,变量可以和if 语句在同一行初始化、尽量减少else 的使用(if 里面放异常情况,一般是直接退出)
示例代码:
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
二、再声明和再赋值(Redeclaration and Reassignment)
参考:考虑下面两行语句,第二行中的err 虽然是通过 := 设置的,但是也只是赋值
达到这种再赋值(reassignment) 需要2个条件:
1)之前已经声明过这个变量
2)重新赋值的时候至少还有另一个新创建的变量
示例:
f, err := os.Open(name)
...
d, err := f.Stat()
三、for
1、常见for 循环格式
// Like a C for
for init; condition; post { }
// Like a C while
for condition { }
// Like a C for(;;)
for { }
2、使用下划线忽略不需要关注的对象
在编译map/slice 的时候还是很有用的,比如slice 大部分时候其实我们只关系值,不关心下标,就可以用 下划线 忽略下标:
sum := 0
for _, value := range array {
sum += value
}
3、其他细节
for 循环遍历字符串,会按照具体编码的格式来展示,比如中文,就是一个个汉字;
++、-- 是语句而不是表达式,本身没有返回值
四、switch
1、特点
和 C 的switch 不同,go 的 switch 不仅可以写 bool 类型的表达式,还可以设置 equal 的条件,甚至还可以通过逗号分隔,用“或”的方式判断多个条件是否满足其一,如下:
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
2、switch 中的 break
由于 switch 中 走具体分支之后,其他的分支就不会走了(包括default),因此break 的使用场景其实不多,一般就用在刚才在介绍if 的时候说的:异常场景提前退出的时候才需要用到
3、实战:通过switch 实现更美观的字符串对比方法
这里其实就只是代码风格的问题了,见仁见智,如果有else if 这样的条件出现,确实switch 开起来会更美观一些,单个if…else 就不是特别必要
// Compare returns an integer comparing the two byte slices,
// lexicographically.
// The result will be 0 if a == b, -1 if a < b, and +1 if a > b
func Compare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
switch {
case len(a) > len(b):
return 1
case len(a) < len(b):
return -1
}
return 0
}
4、实战:type switch
go 的type 由于也是一个变量,可以通过.(type) 的方式强转来获取。也正因为switch 可以放任何类型的变量,所以对type 的多分支判断也可以使用switch:
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t) // %T prints whatever type t has
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool
case int:
fmt.Printf("integer %d\n", t) // t has type int
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}
方法定义
一、多个返回值
背景:C语言去定义需要返回异常的场景的时候会遇到一个问题:比如从数据库获取一个实例对象,本来是可以直接返回这个对象,但是因为存在数据为空的情况,变成只能返回指针了。上层解析又要针对特殊情况做处理,以及对象转换。
Go的方法可以直接定义多个返回值,常见的格式如下:
func (file *File) Write(b []byte) (n int, err error)
代码规范:方法主要返回放在前面,错误信息error 放最后
也正是因为这种可以返回error的设计,go不需要异常处理机制(再结合刚才说的if 对错误的处理,以及后面要说到的defer recover)
除了返回 业务数据+error,还有一种场景是返回当前数据+下一个标志位,类似redis的 scanner
参考代码(最好用官方库的代码)
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
二、返回值命名
是一种设置返回值的特殊形式:不通过return,而是直接给返回值变量赋值。其实就是简化代码,比较取巧的一种方式,个人不是很推荐使用,加大了源代码理解成本,只有很特殊的场景才比较有可能用到
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
三、defer
最经典的用法就是资源释放,资源类对象在申请之后就紧接着defer,也是代码规范要求的
// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close will run when we're finished.
var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append is discussed later.
if err != nil {
if err == io.EOF {
break
}
return "", err // f will be closed if we return here.
}
}
return string(result), nil // f will be closed if we return here.
}
其他使用场景:耗时计算(defer一个方法并执行)、调用链
特别注意: defer对方法传参的存储
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
最后输出:4,3,2,1,0
因为defer 存入是一个栈的模式,因此FILO(先进后出)
还有一点要留意:由于defer 中保留的局部变量是存值(其实和方法传参一样,指针类型就传地址),所以for 循环中释放局部变量对应的资源其实是不合理的,资源类对象往往都有指针类型的对象,for each 循环定义的都是同一个临时变量,因此可能会导致最后defer 释放的是同一个资源。
我们在最后讲channel 的时候还会回来看这个defer + channel 的用法,并说明正确的释放资源方式。
对象操作(声明、初始化等)
一、new
按传入的对象类型申请空间,并返回这个类型的对象对应的指针。
和直接通过var 初始化的结果一样,这个对象里面的成员都会被初始化成0值,指针类型的话是空,但是诸如sync包、bytes.Buffer对象,初始化0值之后是可以直接使用的,因为它们没有指针类型的属性。
比如下面这个syncedbuffer对象,new之后可以直接使用,内部对象不需要再初始化一样
type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}
// mutex
type Mutex struct {
state int32
sema uint32
}
// buffer
type Buffer struct {
buf []byte
off int
lastRead readOp(int8)
}
不过对于一些本身初始化就需要比较多参数的变量,还是应该通过var 方式初始化,相比new 来说,格式更简洁
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
return &File{fd, name, nil, 0}
}
二、make
New一般用来初始化struct对象,但是对于 slice、map和channel 这种容器对象来说,它们内部是有指针对象的,因此直接用new 初始化肯定不行,不能直接使用。需要通过make 来初始化
顺便来了解一下slice和map 的结构:
Slice 结构:
type slice struct {
array unsafe.Pointer
len int
cap int
}
map 结构:
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
make 的源码可以参考 makeslice、makemap,过程还是挺复杂的,感兴趣可以了解一下
三、数组(array)
数组相当于刚才看到的slice 数据结构中的内部对象 array
和slice 不同的地方:
1)必须在声明的时候初始化大小
2)不能扩容
3)对数组赋另一个数组,直接拷贝所有元素,两个数组的实际地址不同。同样传递到方法中也会拷贝一份
4)数组的大小在Type 中体现,因此[10]int 和 [20]int 类型不同,不能相互赋值
实际使用array 的场景其实不多,slice 更多一些(扩容更方便)
但是还是有一个跟GC 相关的优化细节:如果只要用到 超长Slice 的一部分元素,可以通过子array 来拷贝一份数组出来,而不是用子slice (子slice 会导致原大slice 依然被引用,不会被GC)
使用示例如下:
touselist = make([]int, 3)
copy(touselist, sublist)
四、slice
相比较array 在类型上的限制,slice 的使用就比较灵活了:不限大小、可以自动扩容、类型统一。因此go 底层传递数组 绝大多数都是 slice 实现的,而不是array
另外slice 是通过指针管理实际的数组的,因此slice 可以传递到方法中,并且方法内部对元素的修改在外部可见。
最后是 slice 最长用的append 方法,由于添加元素之后 slice 可能会扩容,导致后续的 slice 和原来的 slice 地址不同,因此需要接收 append 返回的新slice。
当然,效率更高的方式还是要预先给数组申请足够的capacity
arr = make([]int, 0, cap);
arr = append(arr, ele...)
五、二维数组
初始化:常见的方式依然是使用slice,只指定首层的大小,第二层先不初始化,同时这样每一层的数组大小也可以不同。
在 图像处理 类似的数据处理场景可以用到
text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}
六、map
map 也是go 里面的基本容器数据类型之一
1、key
类型可以是任何按 == 可比较的对象(比如基本类型、指针、interface 和 属性都是可比较的struct 其实都可以),slice 虽然可对比,但是== 其实是浅对比,并不是对比内部所有的元素,因此不适合作为key
注:切片 或者是包含了切片的 struct 如果要对比,可以使用reflect.DeepEqual 方法来比较,这个方法本质上就是对slice、array、struct 等复合结构体进行递归匹配所有属性是否相同,感兴趣可以直接看源码或者参考博客
2、添加value
map 虽然和slice 都是复合结构,但是和 slice 不同,扩容之后存储的地址还是不变的,因此可以放心地传递到方法内部并修改
3、获取
用法:和其他语言基本一致,也是分直接打印(print)、按format打印(printf)和指定流打印(fprint)这几种
因此如果value 是基础类型,不能直接从返回值直接确认到底key 是否存在,而应该通过这种方式判断:
_, ok := testMap[myFooVar]
if !ok {
log.Printf("[test] get value failed")
}
或者是直接通过 if 判断:
if attended[person] { // will be false if person is not in the map
fmt.Println(person, "was at the meeting")
}
更常见的其实是第一种,这种方式在go 中叫“comma ok”写法,类似的写法还有类型强转:
fooVar, ok := barVar.(Foo)
七、打印
1、基本用法
用法:和其他语言基本一致,也是分直接打印(print)、按format打印(printf)和指定流打印(fprint)这几种
fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))
其中,fPrint的第一个参数必须实现io.Writer接口
Printf和C语言的不同:由于go的对象类型是可以直接获取的,因此数字类型不需要指定长度。具体可参考 fmt/print.go 中的实现。
var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
所有对象都可以通过%v来format。对于struct类型的对象,默认打印其所有对象;
2、其他常用format:
%T:对象类型
%g:浮点数和整数,不设精度
%q:按各个字符打印字符串,不会按特殊字符截断,比如换行符也会按转义前格式来打印
%s:一般用来打印字符串或者是实现了String方法的struct,如果未实现就按照%v 方式打印对象。后面还会更详细讲到String 这种 pointer receiver的用法
3、小技巧:arbitrary type 的用法
来看看printf的方法定义:
func Printf(format string, v …interface{}) (n int, err error) {
其中,v就是arbitrary type,表示不定长参数,只能作为方法的最后一个参数传入
什么场景下会用到呢?比如printf 需要传递多个format参数,但是不确定参数个数,传递数组有点麻烦,在调用之前还得再多声明一个变量。
类似的,马上要说到的append方法也用到了这种类型。
到了方法内部,arbitrary type的用法就和数组没有区别了,可以直接用for遍历
4、扩展:日志包的选用
官方提供的log包没有日志等级区分,真的不能算好用。开源项目中推荐使用 uber/zap 来定制化自己的logger
参考博客
八、append
slice 专用的添加元素方法,直接看示例:
// example 1
x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)
// example 2
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)