高质量编程与性能调优入门 | 豆包MarsCode AI刷题

86 阅读8分钟

编写的代码能够达到正确可靠,简洁清晰的目标可称之为高质量代码。

参考链接

参考文档

Go注释规范 | 技术论坛

统一规范版

Effective Go

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 扩容机制是,当长度要超过容量时是重新开辟一个连续空间,然后将原切片和添加元素拷贝到整个连续空间。如果我们一开始设定好容量就可以明显减少开销。

image.png

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{} 没有数据存储的开销,更加轻量。