Go语言编码规范和性能调优(三) | 青训营笔记

383 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第三篇笔记。

编码规范

注释

Good code has lots of comments,bad code requires lots of comments.

不必要的注释

场景一

image.png

如上图所示,第一个Open函数应该解释代码作用,而第二个函数这样的作用解释则毫无必要,因为它的函数名就已经解释了。

场景二

image.png

第一个函数的逻辑较为复杂,很多情况没法看懂,需要注释,而第二个则完全没必要。

需要的注释

公共符号始终要注释

这里的公共符号包括全局可见的函数和变量,而方法则不包含在内。

image.png

小结

  • 代码是最好的注释。
  • 注释应提供代码未表达出的上下文信息。

变量命名

  • 简洁
  • 缩略词都大写,比如HTTP不要Http
  • 变量定义的位置距离使用的地方越远,命名需要越详细,特别是全局变量,有时需要注释

函数内

例如for循环时:

for i:=0; i<size;i++{ //good
    ...
}
for index:=0;index<size;index++{//bad
    ...
}

而作为函数参数时:

//good 
func (c *Client) send(req *Request, deadline time.Time)
//bad
func (c *Client) send(req *Request, t time.Time)

由于第一个变量仅在for循环这个作用域内,它的作用也很清晰,如果命名的更详细,反而会影响阅读。

第二个变量作为函数的参数,作用域很大,且需要作为提供给使用者的名字,这个需要带有详细信息描述。

包内

  • 函数名应该不携带报名的上下文信息。

例如在http包中有个Serve方法和ServeHTTP方法这两个命名,我们应该选择Serve去命名而不是ServeHTTP。因为我们使用的时候会携带包名。类比于C++的命名空间,Java的包名。

包命名

  • 只有小写字母组成(不包含下划线等字母
  • 简短并包含一定的信息
  • 不要和标准库的包冲突,标准库的很多包名喜欢用复数,我们应该避免使用复数形式为包名,比如strings是标准库的一个包名。

控制流程

这里我用之前在其他地方学到的两个优化代码的方式来讲。

嵌套条件校验链

嵌套两层以上的if

if a>10 {
    if b>10 {
        if c>10 {
            ...
        }
    }
}

优化如下:

for {
    if !(a>10) {
        break
    }
    if !(b>10) {
        break
    }
    if !(c>10) {
        break
    }
    ...
}

互斥条件表驱动

比如有以下并列的if嵌套逻辑:

func CalculateByCmd(cmd string,a,b int)(int,error){
	if strings.EqualFold(cmd,"add"){
		return a+b,nil
	}
	if strings.EqualFold(cmd,"sub"){
		return a-b,nil
	}
	if strings.EqualFold(cmd,"mul"){
		return a*b,nil
	}
	return 0,errors.New("cmd not exist")
}

这段代码是根据给出的字符串命令,得出对应的计算结果。但是我们发现,这个代码的可读性虽然还行,但由于最终的计算和这个函数的耦合性太强,实现功能拓展有点拖后腿。

我们通过表驱动做出以下优化:

var mapCalculate = map[string]func(a,b int) int{
	"add": func(a, b int) int {
		return a+b
	},
	"sub": func(a, b int) int {
		return a-b
	},
	"mul": func(a, b int) int {
		return a*b
	},
}

func CalculateByCmd(cmd string,a,b int)(int,error){
	if v,ok := mapCalculate[cmd];ok{
		return v(a,b),nil
	}
	return 0,errors.New("cmd not exist")
}

错误处理

error相关的函数

errors.New()

return errors.New("需要的错误信息描述")

errors.Is()

用于判断错误断言,不同于简单的==,它能够判断错误链中是否包含它。

image.png

errors.As()

从错误链中提取想要的错误。

image.png

panic和recover和defer

这几样东西,语法就那样,真要理解原理可以看看下面这些视频链接。

老版本go derfer实现

新版本go defer实现

panic和recover

性能调优

benchmark测试

这个benchmark,之前在(二)里面讲了如何去使用,这里直接贴图看如何看懂测试结果。

image.png

slice的阴暗面

对于之前使用C++的同学,这里slice的预分配应该不用多讲。主要就是学会避坑。

最大坑点

  1. 更新slice后持有的底层数组相同。 具体而言:有时我们只是想要底层数组的一小部分,结果因为简单切个片,然后就和他共用了同一片底层数组。go的垃圾回收机制在某种程度上和C++智能指针(引用计数)很相似,如果此时大的数组实际上已经没用了,而有用的只有小数组,而它们是共用同一片底层数组,此时这整个底层数组的空间会得不到释放,因为引用计数不为零!算是意外延长了生命周期。
  2. 更新slice后持有的底层数组不同。 具体而言:如果一个map映射的值是slice类型,那么我们每次更新这个slice里的元素时,我们还得更新map映射的这个slice,这是为了防止底层数组发生了变化。所以一般slice作为值最好是使用slice指针。

关于string可变与不可变的优化

不可变string的优化

go语言和Java等等语言都把string设置为了不可变,我觉得这样设置是非常合理的,毕竟字符串在使用过程中,多数情况下都是传递,而且可以利用不可变做很多优化,比如内存池之类的。

string不可变的缺陷

一旦string不可变,就意味着,每次得到一个新的字符串就需要申请一片新的内存,那么这样的话,多次字符串拼接的过程中将会有严重的性能损失!

如下,每次 += 右边的值都会引起内存的分配。

s := "aa"
for i:=0;i<size;i++{
    s += "ccc"
}

解决方案

  • 使用strings.Builder。
var s strings.Builder
for i:=0;i<size;i++{
    s.WriteString("cc")
}
s.String()
  • 使用bytes.Builder。
var s bytes.Buffer
for i:=0;i<10;i++{
    s.WriteString("cc")
}
s.String()

第一种解决方案比第二种要快。

因为在最后的String()阶段bytes包的处理方式是再进行一次切片处理。

而strings包则是直接指针强转。

可变string的优化

可变string的代表便是C++。

C++的string是可变的,而且因为实现了重载=号实现的深拷贝,导致很多情况下string的使用是需要进行拷贝的,但为了解决这个问题C++可以使用 const& 来引用字符串以防止拷贝,但这就有一个问题:由于string是可变的万一我在使用的过程中string被外界改变了怎么办?这是可变string需要面临的最大问题,所以可变string一般都是会实现深拷贝,但使用起来大多数人还是会通过引用传递,毕竟拷贝太耗时了!但这时使用起来就得遵守编码规范唯唯诺诺了。

回到正题,即便是使用了 const& 进行字符串的传递,从实践来看,它至少有以下几方面问题:

  1. 字符串字面值、字符数组、字符串指针的传递仍要数据拷贝 这三类低级数据类型与string类型不同,传入时,编译器需要做隐式转换,即需要拷贝这些数据生成string临时对象。const string&指向的实际上是这个临时对象。通常字符串字面值较小,性能损耗可以忽略不计;但字符串指针和字符数组某些情况下可能会比较大(比如读取文件的内容),此时会引起频繁的内存分配和数据拷贝,会严重影响程序的性能。
  2. substr O(n)复杂度 这是一个特别常用的函数,好在std::string提供了这个函数,美中不足的是其每次都返回一个新生成的子串,很容易引起性能热点。实际上我们本意并不是要改变原字符串,为什么不在原字符串基础上返回呢?

说说可变字符串类型的好处,内存的开辟问题不会太多,因为虽然在拷贝,但基本上都是复用的同一片内存。

解决方案

方案一:SSO优化

SSO,全称为:小字符串优化。

这个优化简单粗暴,就是根据字符串的长度来决定内存的开辟情况。

比如字符串长度如果小于128字节,那么内存就开辟在栈上面,众所周知,栈内存开辟比堆内存开辟的代价小很多!

方案二:string_view(C++17引入)

通过提供一个新的类型,这个类型和不可变的字符串的类型类似,它是不可变的,只能看,不然怎么叫view🤭

每次string赋值给它,代价都很小,不是直接拷贝字符串,而是指针的赋值而已。

而且string_view重写了substr,这个方法返回string_view而且你会发现通过它再构建string性能会比string调用substr快很多。

string_view虽然解决了拷贝问题,但是依旧没有解决C++的内存安全问题,string_view内部是原始指针,不会意外延长生命周期,所以要非常注意它所观察的字符串内存是否被释放了,如果被释放string_view将失效,将会产生严重的内存安全问题。

关于string_view,可以看看我的这篇博客string_view

空结构体的使用

空结构体,不占内存,仅作为占位符,所以可以作为map实现set的理想工具。

image.png

atomic包

用atomic保护变量的并发安全,用sync.Mutex保护一段代码逻辑的并发安全。

image.png

对于非数值变量,可以使用atomic.Value来承载一个空接口。

性能调优实战

pprof工具的使用

这个东西暂时用不来,感觉暂时这个阶段也很难用上。用上了再研究(主要是感觉很多东西都还完全不会分析,只会工具操作很麻木的感觉

pprof