这是我参与【第五届青训营】伴学笔记创作活动的第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的特性会导致程序中的错误比较难以跟踪,有了 wrap 和 unwrap 操作后,我们返回的每一个错误都可以包含一个内部错误,这样我们就可以沿着内部错误找到错误的根源。
错误的 Wrap 实际上提供了一个 error 嵌套另外一个 error 的能力,从而生成一个error的跟踪链
如何wrap error
- 使用
error.new()去创建一个error - 使用
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.Is 和 Error.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{}
总结
- 避免常见的性能陷阱可以保证大部分程序的性能
- 普通应用代码,不要一味的追求程序的性能
- 越高级的性能优化的手段约容易出现问题
- 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能