编写的代码能够达到正确可靠,简洁清晰的目标可称之为高质量代码。
参考链接
1 代码风格
一个良好的代码风格将有助于后续的开发。本节涉及注释、命名规范、错误异常处理编写等部分的描述。
1.1 注释
在 Go 语言中,官方推荐了一套注释规范,特别是针对包、文件、函数、结构体、方法等代码元素。Go 的注释规范提倡清晰、简洁和自解释性,主要遵循 Effective Go 中的建议。以下是具体规范:(Go有 /**/ 的块注释和 // 的单行注释两种注释风格。但实际上推荐只使用 // 来注释)
1.1.1 包注释
- 每个包应包含描述包功能的注释,通常位于包的
package声明上方。 - 包注释应以
Package <包名>开头,说明该包的用途和主要功能。 - 如果包的内容较多,可以在包声明文件(通常命名为
doc.go)中详细描述。
示例:
// Package calculator 提供了基础的算术运算,包括加、减、乘、除等。
package calculator
1.1.2 函数和方法注释
- 函数和方法的注释应位于函数定义的上方,直接描述该函数的功能、参数和返回值。
- 注释开头应以
函数名开头,然后描述其作用。 - 如果函数较复杂,注释应详细说明参数、返回值和异常情况。
示例:
// Add 返回两个整数的和。
func Add(a, b int) int {
return a + b
}
// Divide 返回两个整数相除的结果和一个错误,
// 如果 b 为 0,则返回错误。
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
1.1.3 结构体(Struct)注释
- 结构体注释应放在结构体定义的上方,以结构体名开头,说明该结构体的用途。
- 可以简要描述结构体的主要字段和可能的使用场景。
示例:
// Person 表示一个人的信息,包含姓名和年龄。
type Person struct {
Name string // 姓名
Age int // 年龄
}
1.1.4 接口(Interface)注释
- 接口的注释放在接口定义上方,简要说明接口的用途和预期的实现内容。
- 接口中的方法通常不需要单独写注释,除非方法本身含义不清晰。
示例:
// Reader 是一个接口,定义了读取数据的方法。
type Reader interface {
Read(p []byte) (n int, err error)
}
1.1.5 字段注释
- 字段注释应写在字段定义的行尾,用于解释字段的作用。
- 如果结构体中的字段较多且含义复杂,建议逐个字段写明注释。
示例:
type Config struct {
Hostname string // 服务主机名
Port int // 服务端口
Timeout int // 请求超时时间(秒)
}
1.2 命名规范
1.2.1 变量命名
-
简洁胜于冗长
-
缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写。
- 例如使用ServerHTTP而不是ServerHTTP
- 使用 XMLHTTPRequest(可导出) 或者 xmlHTTPRequest(不导出)
-
变量距离其被使用的地方越远,则需要携带越多的上下文信息
- 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义。
1.2.2 函数命名
- 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
- 函数名尽量简短
- 当名为 foo 的包某个函数返回类型 Foo 时,可以省略类型信息而不导致歧义
- 当名为 foo 的包某个函数返回类型 T 时(T并不是 Foo),可以在函数名加入类型信息
例如: http 包中创建服务的函数如何命名更好?
func Serve(I net.Listener,handler Handler) error
func ServeHTPP(I net.Listener,handler Handler) error
1.2.3 包命名
只有小写字母组成。不包含大写字母和下划线等字符。
简短并包含一定的上下文信息。例如 schema、task 等
不要与标准库同名。例如不要使用 sync 或者 strings
以下规则尽量满足,以标准库包名为例
- 不使用常用变量名作为包名。例如使用 bufio 而不是 buf
- 使用单数而不是复数。例如使用 encoding 而不是 encodings
- 谨慎地使用缩写。例如使用 fmt 在不破坏上下文的情况下比 format 更加简短。
1.2.4 异常错误处理
最常见的正常流程的路径被嵌套在两个 if 条件内
成功的退出条件是 return nil,必须仔细匹配大括号来发现
函数最后一行返回一个错误,需要追溯到匹配的左括号,才能了解何时会触发错误
如果后续正常流程需要增加一步操作,调用新的函数,则又会增加一层嵌套。
例如:
// bad example
func OneFunc() error{
err := doSomething()
if err == nil{
err := doAnotherThing()
if err == nil{
return nil // normal case
}
return err
}
return err
}
// Good example
func OneFunc() error{
if err := doSomething();err != nil{
return err
}
if err := doAnotherThing();err != nil{
return err
}
return nil
}
简单错误
简单的错误指的是仅出现一次的错误,且在其它地方不需要捕获该错误。
优先使用 errors.New 来创建匿名变量直接表示简单错误
如果有格式化的需求,使用 fmt.Errorf
func defaultCheckRedirect(req *Request,via []*Request) error{
if len(via) >= 10{
return errors.New("stopped after 10 redirects")
}
return nil
}
恐慌与恢复
panic 注意事项:
- 不建议在业务代码中使用panic
- 调用函数不包含 recover 会造成程序崩溃
- 若问题可以被屏蔽或解决,建议使用 error 代替 panic
- 当程序启动阶段发生不可逆转的错误时,看可以在 init 或 main 函数中使用 panic
recover 注意事项:
recover 只能在被 defer 的函数中使用
嵌套无法生效
只在当前 goroutine 生效
defer 的语句是后进先出(栈)
2.性能调优
2.1 slice
1.预分配内存
尽可能在使用 make() 初始化切片时提供容量信息。这是因为 slice 扩容机制是,当长度要超过容量时是重新开辟一个连续空间,然后将原切片和添加元素拷贝到整个连续空间。如果我们一开始设定好容量就可以明显减少开销。
2.大内存未分配
在已有切片基础上创建切片,不会创建新的底层数组。
也就是当我们使用[2:10]这种形式去创建新切片时,而[:2]和[11:]内存没有释放!因此实际上我们不推荐使用[]而是copy来创建新切片
eg1:
origin[len(origion)-2:]
eg2:
result :=make([][]int,2)
copy(result,origion[len(origion)-2:])
推荐 eg2 来进行新切片生成
2.2 map
预分配内存:尽可能在使用 make() 初始化切片时提供容量信息。
eg1:
data := make(map[int]int)
eg2:
data := make(map[int]int, size)
2.3 string
在 Go 中,直接用 + 可以拼接两个或多个字符串,但性能较低,不推荐在循环中频繁使用。
str1 := "Hello"
str2 := "World"
result := str1 + " " + str2 // 输出: "Hello World"
1. 使用 strings.Join
strings.Join 是 Go 中高效拼接字符串的推荐方法,尤其适合拼接多个字符串(如切片中的内容)。
import "strings"
strs := []string{"Hello", "World"}
result := strings.Join(strs, " ") // 输出: "Hello World"
如果是处理一个现有的切片,strings.Join 是非常简洁和高效的,但它不适合逐步拼接的场景,因为每次都需要重建切片。
2. 使用 strings.Builder
strings.Builder 适合需要频繁拼接的情况,比 + 运算符效率更高,因为它会动态扩容,而不是每次创建新的字符串。
import "strings"
var builder strings.Builder
builder.WriteString("Hello")
builder.WriteString(" ")
builder.WriteString("World")
result := builder.String() // 输出: "Hello World"
3. 使用 bytes.Buffer
如果需要拼接不同类型的数据(比如字符串和字节数据)或需要在二进制数据和字符串间频繁切换时,bytes.Buffer 是更灵活的选择。
-
实现效率:
bytes.Buffer提供类似strings.Builder的动态扩容机制,并且更通用。 -
示例:
import "bytes" var buffer bytes.Buffer buffer.WriteString("Hello") buffer.WriteString(" ") buffer.WriteString("World") result := buffer.String() // 输出: "Hello World" -
优缺点:
bytes.Buffer适用于多类型拼接,并支持并发读写(在某些场景需要锁定);但它的 API 比strings.Builder稍复杂且主要面向字节数据的拼接。
2.4 空结构体
1.空结构体 struct{} 的特点
- 零内存占用:空结构体
struct{}不占用内存空间,因为它不包含任何字段。在需要标识状态但不需要存储数据时,使用空结构体可以节省内存。 - 不可寻址:空结构体没有字段,因此它没有可寻址的数据,不能直接通过字段访问其内容。
2.实现集合(Set) Go 中没有内置的集合类型,但可以使用 map[T]struct{} 来模拟集合。使用 struct{} 作为 map 的值类型,可以节省内存,因为 struct{} 不占用空间。(即使使用bool作为值也要占用1字节空间)
package main
import "fmt"
func main() {
set := make(map[string]struct{})
set["apple"] = struct{}{}
set["banana"] = struct{}{}
// 检查元素是否存在
if _, exists := set["apple"]; exists {
fmt.Println("apple is in the set")
}
// 输出集合中的元素
for k := range set {
fmt.Println(k)
}
}
在这个例子中,map[string]struct{} 模拟了一个字符串集合。使用 struct{} 作为值类型,不会增加额外的内存开销。
3.信号通道(Channel)中的占位符
在并发编程中,空结构体常用于通过无缓冲通道传递信号。这种方式可以降低信号量的内存开销。
package main
import (
"fmt"
"time"
)
func worker(done chan struct{}) {
fmt.Println("Working...")
time.Sleep(2 * time.Second)
fmt.Println("Done")
// 发送信号
done <- struct{}{}
}
func main() {
done := make(chan struct{})
go worker(done)
// 等待信号
<-done
fmt.Println("All done!")
}
在这个例子中,done 通道传递的是 struct{} 类型,用于表示任务完成的信号。相比其他数据类型,struct{} 没有数据存储的开销,更加轻量。