Go 语言学习总结(7)—— 大厂 Go 编程规范总结

本文介绍了Go语言中接口的使用,强调指针传递以修改基础数据,以及方法接收者与调用者的关系。讨论了互斥锁(mutex)的零值有效性、封装性和defer的安全性。探讨了slice和map的原理,以及在并发和数据安全方面的注意事项。此外,文章还涵盖了时间处理、错误处理的优雅方式,以及类型嵌入、性能优化和代码规范等最佳实践。
摘要由CSDN通过智能技术生成

一、接口使用

1、如果希望接口方法修改基础数据,则必须使用指针传递

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

var f1 F = S1{}
var f2 F = &S2{}

// f1.f() 无法修改底层数据
// f2.f() 可以修改底层数据,给接口变量 f2 赋值时使用的是对象指针

只有方法的接收者是一个指针,才能修改底层数据。无论方法的调用者是否是指针,底层数据能否被修改取决于 “方法的接收者” 是否是指针。上面的 S2 方法接收者是指针,所以可以完成数据的修改。

2、方法接收者是值,调用者可以是值也可以是指针,但如果接收者是指针,只能指针调用

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}

var i F
i = s1Val
i = s1Ptr
i = s2Ptr

//  下面代码无法通过编译。因为 s2Val 是一个值,而 S2 的 f 方法中没有使用值接收器
//   i = s2Val

上面的代码,因为 S2 函数的接收者是指针,则只能通过指针调用。这个其实非常容易理解,对于值接收者,需要的是值,如果直接传值肯定没有问题,如果传递的指针,通过指针隐式转化获取对应的值,然后再调用即可。

3、接口编译检测

这个一个好习惯,先看下面的 bad case。

// 如果 Handler 没有实现 http.Handler,会在运行时报错
type Handler struct {
  // ...
}
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  ...
}

如果我们提前判断,就可以在编译期间提前发现问题了。

type Handler struct {
  // ...
}
// 用于触发编译期的接口的合理性检查机制
// 如果 Handler 没有实现 http.Handler,会在编译期报错
var _ http.Handler = (*Handler)(nil)

func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

通过接口转化,便可以检查是否实现对应的接口。如果接收者是值,则可以通过 “{}” 初始化一个对象用于检测。

var _ http.Handler = LogHandler{}
func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

二、mutex 

mutex 是 golang 的互斥锁,可以保障在多协程的情况下,数据访问的安全。

1、零值有效

我们并不需要 mutex 指针

mu := new(sync.Mutex)
mu.Lock()

直接可以使用 mutex 的零值。

var mu sync.Mutex
mu.Lock()

2、mutex 可见性

go 的 map 非线程安全,所以我们经常会通过 mutex 给 map 加一个锁,大家先看一下第一种方式:

type SMap struct {
  sync.Mutex

  data map[string]string
}
func (m *SMap) Get(k string) string {
  m.Lock()
  defer m.Unlock()

  return m.data[k]
}

然后我们看一下第二种方式

type SMap struct {
  mu sync.Mutex

  data map[string]string
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

感觉差别不大,有啥区别?从封装的角度来看,第二种方法更加优秀。因为第一种方式,SMap 中的 mutex 是大写的,意味着,外部可以直接调用 lock 和 unlock 方法,破坏了内部封装原则,所以方法二更好。

3、defer更安全

虽然我们可以通过下面的代码,按照需求unlock

p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount

但上面的代码存在两个问题,一是如果分支太多很容易导致unlock ,二是可读性较差,到处是unlock。所以更加推荐下面的写法

p.Lock()
defer p.Unlock()

if p.count < 10 {
  return p.count
}

p.count++
return p.count

defer 的损耗非常少,大家不必纠结。

三、Slices 和 Maps

slice 和 map 的原理类型。我们先看 slice 定义

type SliceHeader struct {
        Pointer uintptr
        Len  int
        Cap  int
}

包含了一个指向数据的指针以及 slice 的长度(len)和容量(capacity)。

所以我们将 slice 当做参数传递的时候,底层共享的是同一份数据。比如下面的代码,我们先定义一个 SetTrips 方法,传入一个  slice 给 driver。

func (d *Driver) SetTrips(trips []Trip) {
  d.trips = trips
}

trips := ... 
d1.SetTrips(trips)

// 你是要修改 d1.trips 吗?
trips[0] = ...

然后我们在外部修改 trips,那么 driver 里面 trips 也会跟着发生变化,这是我们不希望看到的。所以更加安全的方式是,在方法里面创建一个新的 slice,然后逐一拷贝原生数据,这样外部的数据变化就不会影响到 driver 了。如下:

func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

// 这里我们修改 trips[0],但不会影响到 d1.trips
trips[0] = ..

回看上一篇通过 mutex 创建线程安全 map 的文章,如果想返回整个 map 的内容,可以通过下面的方式。

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

但这样直接返回 map 的方式,会导致调用者获取了一个非安全的 map的。如果在调用的地方修改这个 map,就会发生数据冲突。更加安全的做法是

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  result := make(map[string]int, len(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

创建一个新的 map 返回,这样只是返回这个 map 此时的快照,后续对 result 的修改,并不会影响 stats 中 map 的内容。

四、时间处理

1、time

go time 是基于 int 所以可以通过直接对比 int 大小确定时间早晚

func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}

但时间的对比,最好使用 time,如下

func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

可读性更好。另外,encoding/json 通过其 UnmarshalJSON method 方法支持将 time.Time 编码为 RFC 3339 字符串。

2、Duration

时间段处理也是类似,下面的代码 poll 方法传入 10

func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}
poll(10) 

但谁能知道传入的 10 代表的是 10s 和 10ms ,所以更推荐的做法就是直接传入 Duration

func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}
poll(10*time.Second)

这样方法调用者,就可以根据自己的需求传入对应的时间段。而且 flag 通过 time.ParseDuration 已经支持 time.Duration 类型。最后,如果外部系统不支持 time 类型的时候,比如需要将 duration json 的时候,这种命名方式让使用者很难了解 interval 的单位。

type Config struct {
  Interval int `json:"interval"`
}

所以更加推荐这种写法

// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

这样调用者就很清晰地了解到单位是毫秒了。

五、错误处理

在错误处理的时候,我们经常会通过 fmt.Errorf 或者 errors.New 随意定义各种错误,但这将导致错误治理非常麻烦。如下:

func Open() error {
  return errors.New("could not open")
}

if err := foo.Open(); err != nil {
  // 无法针对不同的错误进行特殊处理
  panic("unknown error")
}

或者下面的这个 bad case

func Open(file string) error {
  return fmt.Errorf("file %q not found", file)
}

if err := foo.Open("testfile.txt"); err != nil {
  // 无法针对不同的错误进行特殊处理
  panic("unknown error")
}

所以更加建议我们通过提前定义错误的方式,统一进行错误处理,针对上面两个 bad case,看下面两种优雅的处理方式

var ErrCouldNotOpen = errors.New("could not open")
func Open() error {
  return ErrCouldNotOpen
}
if err := foo.Open();err != nil {
   if errors.Is(err, foo.ErrCouldNotOpen) {
   // 处理文件不存在的场景
   } else {
   panic("unknown error")
   }
}

如果想返回更多的信息,可以定义一个 error 结构体,实现 Error 方法。如下

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // 处理文件不存在的场景
  } else {
    panic("unknown error")
  }
}

上面检查的错误的目的是为了,优雅的针对不同错误进行处理,而非为了抓住它。这个和我们Java 里面写了各种 catch 是一个道理,(Exception e) 只是用于兜底。关于 err 的命令还有一个小细节,上面的 New 的 error 通常以 Err 或者 err 开头,如下:

  ErrBrokenLink = errors.New("link is broken")
  ErrCouldNotOpen = errors.New("could not open")

如果是自定义错误类型,以 Error 结尾。

type NotFoundError struct {
  File string
}

 六、类型嵌套

Go 允许 类型嵌入 作为继承和组合之间的折衷。但隐式的嵌套泄漏实现细节、禁止类型演化。看下面的例子,我们首先定义一个list

type AbstractList struct {}
// 添加将实体添加到列表中。
func (l *AbstractList) Add(e Entity) {
  // ...
}
// 移除从列表中移除实体。
func (l *AbstractList) Remove(e Entity) {
  // ...
}

当面扩展这个结构体的时候,使用直接嵌套的话

type ConcreteList struct {
  *AbstractList
}

将会导致之前介绍 mutex 那篇文章中说的问题,破坏了内部封装,而且如果后续再有子类想扩展 ConcreteList 添加一个Add 方法的时候,就无法再调用 AbstractList 的 Add 方法了,影响了后续的扩展。即便我们嵌入的是一个接口(interface),也不建议直接嵌入

type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}
// ConcreteList 是一个实体列表。
type ConcreteList struct {
  AbstractList
}

而是应该通过下面这种方式:

type ConcreteList struct {
  list AbstractList
}
// 添加将实体添加到列表中。
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// 移除从列表中移除实体。
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

总结一句,不要匿名嵌入!

七、性能

1、初始化 slice 容量

对比

for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s

for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkGood-4   100000000    0.21s

我们可以发现,尽量在初始化 slice 的时候确定好容量,避免频繁申请内存和拷贝数据。map 的创建也是类似,尽量在 make 的时候确定好容量。

2、数组转字符使用 strconv 替换 fmt

对比

for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}
BenchmarkFmtSprint-4    143 ns/op    2 allocs/op

for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}
BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

strconv 性能明显优于 fmt。

3、避免反复的字节转化

对比每次执行 write 都执行一次字符串转化

for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}
BenchmarkBad-4   50000000   22.2 ns/op

使用下面的一次性转化

data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}
BenchmarkGood-4  500000000   3.25 ns/op

性能好要很多。

八、代码规范

1、使用 goimport 分组

这样 import 可以分组,显得比较整洁。

import (
  "fmt"
  "os"

  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)

2、相同的类型放到一组

const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)

3、包名

当命名包时,请按下面规则选择一个名称:

  • 全部小写。没有大写或下划线。
  • 大多数使用命名导入的情况下,不需要重命名。
  • 简短而简洁。请记住,在每个使用的地方都完整标识了该名称。
  • 不用复数。例如 net/url,而不是 net/urls。
  • 不要用 “common”,“util”,“shared” 或 “lib”。这些是不好的,信息量不足的名称。

4、不要使用别名

只有在包名冲突的情况下才需要使用别名,不要滥用别名

import (
  "fmt"
  "os"
  "runtime/trace"

  nettrace "golang.net/x/trace"
)

5、减少嵌套

这个应该是各种语言都要遵守的规范,避免出现多层 if else 嵌套。应该将下面的代码

for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}

改造成下面的方式

for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

6、减少没有必要的 else

应该将下面的代码

var a int
if b {
  a = 100
} else {
  a = 10
}

改造成

a := 10
if b {
  a = 100
}

7、使用字段名初始化结构体

不要为了省事

k := User{"John", "Doe", true}

而应该写全

k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

8、空 slice

检查空的 slice 不应该使用

func isEmpty(s []string) bool {
  return s == nil
}

而应该通过 len 方法。

func isEmpty(s []string) bool {
  return len(s) == 0
}

9、缩小变量作用域

如下写法 err 返回是整个函数

err := ioutil.WriteFile(name, data, 0644)
if err != nil {
 return err
}

改成如下写法就可以控制在 if 函数之内。

if err := ioutil.WriteFile(name, data, 0644); err != nil {
 return err
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一杯甜酒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值