Go高质量编程 | 青训营笔记

96 阅读4分钟

这是我参与【第五届青训营】伴学笔记创作活动的第3天

Go高质量编程

编写高质量的Go代码时,要尤其注意编码规范,如代码格式、注释的填写、命名规范、控制流程的简化、错误和异常处理等。

这里对错误和异常处理做重点讨论:

错误和异常处理

简单错误

  • 简单的错误指的是仅仅出现一次的错误,且在其他地方不需要捕获该错误
  • 优先使用 errors.New 来创建匿名变量来直接表示简单错误,其参数只能传入[字符串常量]
  • 如果由格式化需求,使用fmt.Errorf

对于大多数函数,如果要返回错误,可以将error作为多返回值的最后一个

func defaultCheckRedirect(req *Request, via []*Request) error{
    if len(via) >= 10 {
        return errors.New("stopped after 10 redirects" )
    }
    return nil
}

错误的wrap和unwrap

Go中error 没有stack trace的特性会导致程序中的错误比较难以跟踪,有了 wrapunwrap 操作后,我们返回的每一个错误都可以包含一个内部错误,这样我们就可以沿着内部错误找到错误的根源。

错误的 Wrap 实际上提供了一个 error 嵌套另外一个 error 的能力,从而生成一个error的跟踪链

如何wrap error

  1. 使用 error.new() 去创建一个error
  2. 使用fmt.Errorf("%w",str) 去wrap这个error (用于将一个错误关联至错误链中)
package main
​
import (
    "errors"
    "fmt"
)
​
func f1() error {
    err := f2()
    return fmt.Errorf("f1 error: %s", err)
}
​
func f2() error {
    err := f3()
    return fmt.Errorf("f2 error: %s", err)
}
​
func f3() error {
    return errors.New("initial error")
}
​
func main() {
    err := f1()
​
    fmt.Println(err)
}
​
// 输出:// f1 error: f2 error: initial error

上面的代码模拟了一个函数嵌套调用过程,f1()调用返回的err,是重新生成的一个error,与原始的error已经没有任何关系。但是如果我们需要记录原始的error

package main
​
import (
    "errors"
    "fmt"
)
​
func f1() error {
    err := f2()
    return fmt.Errorf("f1 error: %w", err) // %s -> %w
}
​
func f2() error {
    err := f3()
    return fmt.Errorf("f2 error: %w", err) // %s -> %w
}
​
func f3() error {
    return errors.New("initial error")
}
​
func main() {
    err := f1()
​
    fmt.Println(err)
​
    fmt.Println(errors.Unwrap(err)) // 调用Unwrap
    fmt.Println(errors.Unwrap(errors.Unwrap(err))) // 调用Unwrap
}
​
// 输出如下:// f1 error: f2 error: initial error
// f2 error: initial error
// initial error

面的代码修改了fmt.Errorf方法里面的%s%w,这样就可以将传入的err,进行链接。后面通过errors.Unwrap方法,可以将err解析出来。通过这种方法,我们就可以对需要跟踪或记录的error进行保存,以便后续使用

错误判定

  • 判断一个错误是否为特定错误,使用errors.ls
  • 不同与 ==,使用该方法可以判断错误链上的所有错误是否含有特定的错误

同时对于 Wrap 后的error,如果需要判断其原始的error,我们需要一层一层的进行Unwrap,这显然很麻烦,这时候Error.IsError.As就可以很方便的处理这个问题。

data, err = lockedfile.Read( targ )
if errors.Is(err, fs.ErrNotExist) {
    return []byte{},nil
}
return data,err

在错误链上获取特定种类的错误,使用 errors.As

if _,err := os.Open("non-existing"); err != nil {
    var pathError *fs.PathError
    if errors.As(err, &pathError) {
        fmt.Println("Failed at path:", pathError.Path)
    }else {
        fmt.PrintIn(err)
    }
}

panic

panic()函数用于抛出异常

  • 不建议在业务代码中使用 panic
  • 调用函数不包含 recover 会造成程序崩溃
  • 若问题可以被屏蔽或解决,建议使用error 代替 panic
  • 当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用panic

recover

recover()函数用于捕获异常。

  • recover 只能在被 defer 的函数中使用
  • 嵌套无法生效
  • 只在当前 goroutine 生效
  • defer 的语句是后进先出

Go语言错误和异常是可以互相转换的:

  • 错误转异常,如程序尝试请求某个URL,最多尝试三次,尝试三次的过程中请求失败是错误,尝试完第三次还不成功的话,失败就被提升为异常了
  • 异常转错误,如panic触发的异常被recover恢复后,将返回值中error类型的变量进行赋值,以便上层函数继续走错误处理流程

性能优化建议

Slice

slice预分配内存

尽可能在使用 make()初始化切片时提供容量信息

func NoPreAlloc(size int) {
    data := make([]int, 0)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}
​
func PreAlloc(size int) {
    data := make([]int, 0, size)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}
  • 切片本质是一个数组片段的描述

    • 包括数组指针
    • 片段的长度
    • 片段的容量(不改变内存分配情况下的最大长度)
  • 切片操作并不复制切片指向的元素

  • 创建一个新的切片会复用原来切片的底层数组

另外一个陷阱:大内存未释放

  • 在已有的切片基础上创建切片,不会创建新的底层数组

  • 场景

    • 原切片较大,代码在原切片基础上新建小切片
    • 原底层数组在内存中由引用,得不到释放
  • 可使用 copy 替代 re-slice

func GetLastBySlice(origin []int) []int {
    return origin[len(origin)-2:]
}
​
func GetLastByCopy(origin []int) []int {
    result := make([]int, 2)
    copy(result, origin[len(origin)-2:])
    return result
}

Map

map 预分配内存

func NoPreAlloc(size int) {
   data := make(map[int]int)
   for i := 0; i < size; i++ {
      data[i] = 1
   }
}
​
func PreAlloc(size int) {
   data := make(map[int]int, size)
   for i := 0; i < size; i++ {
      data[i] = 1
   }
}

分析

  • 不断向 map 中添加元素的操作会触发 map 的扩容
  • 提前分配好空间可以减少内存拷贝和Rehash的消耗
  • 建议根据实际需求提前预估好需要的空间

字符串处理

使用strings.Builder

  • 常见的字符串拼接方式
func Plus(n int, str string) string {
    s := ""
    for i := 0; i < n; i++ {
        s += str
    }
    return s
}
​
func StrBuilder(n int, str string) string {
    var builder strings.Builder
    for i := 0; i < n; i++ {
        builder.WriteString(str)
    }
    return builder.String()
}
​
func ByteBuffer(n int, str string) string {
    buf := new(bytes.Buffer)
    for i := 0; i < n; i++ {
        buf.WriteString(str)
    }
    return buf.String()
}
  • 使用 + 拼接性能最差,strings.Builder, bytes.Buffer相近,strings.Buffer 更快

  • 分析

    • 字符串在Go语言中是不可变类型,占用内存是固定的
    • 使用 + 每次都会重新分配内存
    • strings.Builder, bytes.Buffer 底层都是 []byte 数组
    • 内容扩容策略,不需要每次拼接重新分配内存
  • bytes.Buffer 转化为字符串时重新申请了一块空间

  • strings.Builder 直接将底层的 []byte 转换成了字符串返回类型

空结构体

使用空结构体节省内存

  • 空结构体 struct{} 实例不占据任何的内存空间

  • 可作为各种场景下的占位符使用

    • 节省资源
    • 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符
func EmptyStructMap(n int) {
    m := make(map[int]struct{})
​
    for i := 0; i < n; i++ {
        m[i] = struct{}{}
    }
}
​
func BoolMap(n int) {
    m := make(map[int]bool)
​
    for i := 0; i < n; i++ {
        m[i] == false
    }
}
  • 实现Set,可以考虑 map 来代替
  • 对于这个场景,只需要用到map的键,而不需要值
  • 即使是将 map 的值 设置位 bool 类型,也会多占据一个字节空间

atomic包

atomic包是go中在并发情况下必用到的包,可以基于原子性对数值进行操作,所以经常用来加减锁操作。

go语言通过内置sync/atomic包提供对原子操作的支持,包括(以下XXType为:int32、int64、uint32、uint64、uintptr):

  • 增减操作(AddXXType):保证对操作数的原子增减;
  • 载入操作(LoadXXType):保证读取到操作数前,没有其他routine对其进行更改操作;
  • 存储操作(StoreXXType):保证存储时的原子性(避免被其他线程读取到修改一半的数据);
  • 比较并交互操作(CompareAndSwapXXType):保证交换的CAS,只有原有值没被更改时才会交换;
  • 交换操作(SwapXXType):直接交换,不关心原有值
type atomicCounter struct {
   i int32
}
​
func AtomicAddOne(c *atomicCounter) {
   atomic.AddInt32(&c.i, 1)
}
​
type mutexCounter struct {
   i int32
   m sync.Mutex
}
​
func MutexAddOne(c *mutexCounter) {
   c.m.Lock()
   c.i++
   c.m.Unlock()
}
  • 锁的实现是通过操作系统来实现,属于系统调用
  • atomic 操作是通过硬件实现,效率比锁高
  • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}

总结

  • 避免常见的性能陷阱可以保证大部分程序的性能
  • 普通应用代码,不要一味的追求程序的性能
  • 越高级的性能优化的手段约容易出现问题
  • 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能

文章参考:www.jianshu.com/p/064de9421…