Go语言作为C语言家族的年轻一员,最初Google的设计者站在前人的肩膀上,其目标就是能够给程序员带来快乐、匹配未来硬件发展趋势,且适合开发大型软件系统。
其核心思想就是:
- 追求简单,少即是多
- 偏好组合,正交解耦
- 原生并发,轻量高效
- 面向工程,自带电池
一些最佳实践:
1. 使用gofmt
格式化代码
当代码开发人员还在争论不同代码风格时,Rob Pike告诉你:gofmt
的代码风格不是某个人的最爱,而是所有人的最爱。通过这种”独裁”的方式,终结了关于
代码风格好坏的争论。因此团队开发中,提交代码前一定记得使用gofmt
格式化代码。
2. 命名相关
- 包名使用小写字母
- 包名尽量与导入路径最后一个路径分段保持一直
- 变量名使用小驼峰命名法,如需导出使用大驼峰
- 变量声明与使用的距离尽量短
- 常量使用大写字母
3. 变量声明的方式
推荐声明聚类和就近,对于局部逻辑需要使用的变量,他们之间可能存在一定关联,可以如下一起声明。
var (
v1 int64
v2 string
v3 []int
)
延迟初始化,使用var
关键字:
var val int
for i := 0; i < 10; i++ {
if vals[i] > target {
val = i
}
}
声明且显式初始化,使用短变量声明形式:
val1 := 12
val2 := "test"
4. 保持零值可用
var s []int
s = append(s, 1)
s = append(s, 2)
如上代码段,声明之后没有初始化就使用,在很多语言中是报错的,但是在go的设计中,是提倡零值可用的。原生类型都有默认零值。
我们在定义自己的类型时也推荐零值可用。但是也要注意,零值并不是所有操作都可用,对于以上代码,如果初始化后根据下表取值,那么就会报错了。
5. 使用复合字面值作为初始化构造器
如下所示,初始化一个结构体:
var s Student
s.Name = "Jack"
s.Age = 20
s.Address = "xxx"
...
实际上更推荐以下方式:
s := &Student{
Name : "Jack",
Age : 20,
Address : "xxx",
...
}
常用的集合如切片、map都有类似的简化方式,表达能力更强且书写简单。
6. 正确使用切片
和其他编程语言一样,go语言也有数组类型,不同于C语言,go中数组变量并不是指向第一个元素的指针,而是代表整个数组,因此作为函数参数
传递时,会进行值拷贝,这个性能开销是很大的。日常开发中,更多场景是使用切片,数组更多的是作为底层存储结构,同时,切片的动态扩容也是
日常使用必不可少的能力。
我们经常使用切片的append
操作向切片中添加元素,但需要特别注意元素长度超过底层数组容量后产生的数据复制,因此推荐使用切片时提前初始化
合适的容量,这将显著提升程序性能。
s := make([]int64, len, cap) // 提前确定cap
7. 正确使用map
map
的key应该严格定义了==
和!=
两个操作符的行为,因此函数、map、切片不能作为key使用;
- 习惯使用
comma ok
:
if value,ok := m["key"]; ok {
// key存在map中
}
- 不要依赖遍历map得到的元素顺序,这是不确定的。
map
并发写时不安全的,并发场景可以使用sync.Map
类型。
- 和切片一样,由于扩容有成本,推荐初始化map传入容量。
8. 遵循快乐路径
原则
这不是go语言独有的,是一种通用的编程原则,比较一下一目了然:
\\ bad
if condition1 {
if condition2 {
if condition3 {
// do something
}
}
}
\\ good
if !conditon1 {
return
}
if !condition2 {
return
}
if !condition3 {
return
}
// do something
9. 使用for-range
避坑
// wrong
for i,v := range mylist {
go func() {
time.Sleep(time.Second * 3)
fmt.PrintLn(i, v)
}
// ...
// right
for i,v := range mylist {
go func(i, v int) {
time.Sleep(time.Second * 3)
fmt.PrintLn(i, v)
}(i, v)
}
10. 注意defer
关键字后表达式的求值时机
defer
后面表达式的求值是在将函数注册到延迟堆栈的时候。
注意以下函数的输出区别:
func foo1() {
for i := 0; i <= 3; i++ {
defer fmt.Println(i)
}
}
func foo2() {
for i := 0; i <= 3; i++ {
defer func(n int) {
fmt.Println(n)
}(i)
}
}
func foo3() {
for i := 0; i <= 3; i++ {
defer func() {
fmt.Println(i)
}()
}
}
11. 选择合适的的方法receiver
类型
type T struct {
a int
}
func (t T) M1() {
}
func (t *T) M2() {
t.a = 11
}
以上两种receiver方式都可以,需要注意
- 如果要对实例做修改,需要使用指针类型
- 需要考虑对象size,对象复制有性能损耗
12. 推荐面向接口编程
这条原则也是语言无关的编程思路,构建大型软件系统,都需要有这种思路。
go语言中,自定义类型与接口的关系是松耦合的,即某个类型实现了接口的所有方法集合,那么就认为该类型实现了接口,
不需要显式使用implements
。
type XXXService interface {
Open()
Close()
}
type MyXXXService struct {}
func (s *XXXService) Open() {
// open
}
func (s *XXXService) Close() {
// close
}
13. 组合大于继承
偏好组合是go的设计哲学之一,使用类型嵌入实现OOP领域的继承机制。
类型嵌入包括:
type Interface1 interface {
M1()
}
type Interface2 interface {
M1()
M2()
}
type Interface3 interface {
Interface1
Interface2
}
type Interface4 interface {
Interface2
M2()
}
type Interface interface {
M1()
M2()
}
type T struct {
Interface
}
type T1 struct{}
func (T1) T1M1() { println("T1's M1") }
func (T1) T1M2() { println("T1's M2") }
func (*T1) PT1M3() { println("PT1's M3") }
type T2 struct{}
func (T2) T2M1() { println("T2's M1") }
func (T2) T2M2() { println("T2's M2") }
func (*T2) PT2M3() { println("PT2's M3") }
type T struct {
T1
*T2
}
14. 不建议使用函数重载
很多编程语言支持函数重载(overload
),go设计者认为实践中会造成混淆和脆弱,因此不支持函数重载,但是go支持可变参数列表,
一定程度上可以实现函数重载,但实际生产中不建议使用。
—
15. 推荐Option
模式
实际开发中有这样的场景,尝试将函数的参数设置成可选项。可以使用一些已经设置默认配置和开箱即用的对象,
同时您也可以使用一些更为详细的配置,例如初始化一个客户端,提供了超时时间、日志开关、重试策略等默认值,
但使用方也需要自由设置。这种场景推荐使用Option模式。
var defaultStuffClientOptions = StuffClientOptions{
Retries: 3,
Timeout: 2,
}
type StuffClientOption func(*StuffClientOptions)
type StuffClientOptions struct {
Retries int //number of times to retry the request before giving up
Timeout int //connection timeout in seconds
}
func WithRetries(r int) StuffClientOption {
return func(o *StuffClientOptions) {
o.Retries = r
}
}
func WithTimeout(t int) StuffClientOption {
return func(o *StuffClientOptions) {
o.Timeout = t
}
}
type StuffClient interface {
DoStuff() error
}
type stuffClient struct {
conn Connection
timeout int
retries int
}
type Connection struct {}
func NewStuffClient(conn Connection, opts ...StuffClientOption) StuffClient {
options := defaultStuffClientOptions
for _, o := range opts {
o(&options)
}
return &stuffClient{
conn: conn,
timeout: options.Timeout,
retries: options.Retries,
}
}
func (c stuffClient) DoStuff() error {
return nil
}
16. nil error
判断避坑
先看如下代码:
type MyError struct {
error
}
var ErrBad = MyError{
error: errors.New("bad error"),
}
func bad() bool {
return false
}
func returnsError() error {
var p *MyError = nil
if bad() {
p = &ErrBad
}
return p
}
func main() {
e := returnsError()
if e != nil {
fmt.Printf("error: %+v\n", e)
return
}
fmt.Println("ok")
}
需要注意这里returnError
返回的error
不是nil
,原因在于,error
是一个接口,接口类型变量
的数据结构包含了接口类型信息以及赋值给该接口的动态类型信息,因此这里返回的动态类型信息虽然为空,
但接口类型信息是有的,因此返回值并不是nil
。
—
17. 适配器函数类型
适配器模式通常用于接口转换和兼容,是一种常用设计模式。
比如有一个People
类型和一个SayHello
接口:
type People struct {
name string
}
func (p *People) Greet() {
fmt.Printf("Hello, I am %s.\n", p.name)
}
type Hello interface {
SayHello()
}
func SayHello(s Hello) {
s.SayHello()
}
如果要将People
原有的Greet
方法适配到SayHello
接口,通常我们可以定义一个新的类型作为适配器,
type PeopleSayHello struct {
*People
}
func (p *PeopleSayHello) SayHello() {
p.Greet()
}
对于go来说,函数和其他类型一样都属于“一等公民”,其他类型能够实现接口,函数也可以。因此
就有另一种实现方式,即为函数定义方法:
type Hello interface {
SayHello()
}
func SayHello(s Hello) {
s.SayHello()
}
// 适配器
type PeopleSayHello func()
func (f PeopleSayHello) SayHello() {
f()
}
func main() {
p = People{"Adam"}
SayHello(PeopleSayHello(p.Greet))
}
这样就可以将Greet
方法适配到SayHello
接口,这种方式并不常见,特别是对于熟悉java
和C++
语言的开发者,第一种方式更加通用易理解。