TOP 20 Go最佳实践

原文: Golang Best Practices (Top 20)

简介

在本教程中,我们将探讨Golang中的前20个最佳编码实践。这将帮助你编写有效的Go代码。

#20: 使用适当的缩进

良好的缩进使你的代码易读。一致地使用制表符或空格(最好是制表符),并遵循Go的缩进标准。

1
2
3
4
5
6
7
8
9
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println("Hello, World!")
}
}

运行gofmt以根据Go标准自动格式化(缩进)你的代码。

1
$ gofmt -w your_file.go

#19: 正确导入包

仅导入你需要的包,并格式化导入部分以将标准库包、第三方包和你自己的包分组。

1
2
3
4
5
6
7
package main
import (
"fmt"
"math/rand"
"time"
)

#18: 使用描述性的变量和函数名

  1. 有意义的名称:使用传达变量目的的名称。
  2. 驼峰命名法:以小写字母开头,并在名称中的每个后续单词的首字母大写。
  3. 短名称:对于生命周期短、范围小的变量,可以使用简洁的名称。
  4. 不要使用缩写:避免使用难以理解的缩写和首字母缩写,而使用描述性名称。
  5. 一致性:在整个代码库中保持命名一致性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import "fmt"
func main() {
// 使用有意义的名称声明变量
userName := "John Doe" // 驼峰命名法:以小写字母开头,并在名称中的每个后续单词的首字母大写。
itemCount := 10 // 短名称:短小而简洁,适用于生命周期短、范围小的变量。
isReady := true // 不使用缩写:避免使用缩写。
// 显示变量值
fmt.Println("User Name:", userName)
fmt.Println("Item Count:", itemCount)
fmt.Println("Is Ready:", isReady)
}
// 对于包级别的变量使用mixedCase
var exportedVariable int = 42
// 函数名应该具有描述性
func calculateSumOfNumbers(a, b int) int {
return a + b
}

// 保持代码库中的命名一致性。

#17: 限制行长度

尽可能保持代码行长度在80个字符以下,以提高可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main
import (
"fmt"
"math"
)
func main() {
result := calculateHypotenuse(3, 4)
fmt.Println("Hypotenuse:", result)
}
func calculateHypotenuse(a, b float64) float64 {
return math.Sqrt(a*a + b*b)
}

#16: 使用常量代替魔术值

避免在代码中使用魔术值,即散布在代码中的硬编码数字或字符串,缺乏上下文,使其难以理解目的。为其定义常量,以使代码更易维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main
import "fmt"
const (
// 定义最大重试次数的常量
MaxRetries = 3
// 定义默认超时时间(秒)的常量
DefaultTimeout = 30
)
func main() {
retries := 0
timeout := DefaultTimeout
for retries < MaxRetries {
fmt.Printf("Attempting operation (Retry %d) with timeout: %d seconds\n", retries+1, timeout)
// ... 在此处添加你的代码逻辑 ...
retries++
}
}

#15: 错误处理

Go鼓励开发者显式处理错误,有以下原因:

  • 安全性:错误处理确保意外问题不会导致程序突然崩溃。
  • 清晰性:显式的错误处理使代码更易读,有助于确定错误可能发生的位置。
  • 调试:处理错误为调试和故障排除提供了有价值的信息。

让我们创建一个简单的程序,它读取一个文件并正确处理错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main
import (
"fmt"
"os"
)
func main() {
// 打开一个文件
file, err := os.Open("example.txt")
if err != nil {
// 处理错误
fmt.Println("Error opening the file:", err)
return
}
defer file.Close() // 当完成时关闭文件
// 从文件中读取
buffer := make([]byte, 1024)
_, err = file.Read(buffer)
if err != nil {
// 处理错误
fmt.Println("Error reading the file:", err)
return
}
// 打印文件内容
fmt.Println("File content:", string(buffer))
}

#14: 避免使用全局变量

最小化使用全局变量。全局变量可能导致不可预测的行为,使调试变得困难,并阻碍代码重用。它们还可能在程序的不同部分之间引入不必要的依赖关系。相反,通过函数参数和返回值传递数据。

让我们编写一个简单的Go程序来说明避免使用全局变量的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import (
"fmt"
)
func main() {
// 在main函数中声明并初始化变量
message := "Hello, Go!"
// 调用使用局部变量的函数
printMessage(message)
}
// printMessage是一个带参数的函数
func printMessage(msg string) {
fmt.Println(msg)
}

#13: 使用结构体处理复杂数据

使用结构体将相关的数据字段和方法组合在一起。它们允许你将相关变量组合在一起,使你的代码更有组织性和可读性。

以下是一个完整的演示在Go中使用结构体的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"fmt"
)
// 定义一个名为Person的结构体,表示一个人的信息。
type Person struct {
FirstName string // 人的名字
LastName string // 人的姓氏
Age int // 人的年龄
}
func main() {
// 创建一个Person结构体的实例并初始化其字段。
person := Person{
FirstName: "John",
LastName: "Doe",
Age: 30,
}
// 访问并打印结构体字段的值。
fmt.Println("First Name:", person.FirstName) // 打印名字
fmt.Println("Last Name:", person.LastName) // 打印姓氏
fmt.Println("Age:", person.Age) // 打印年龄
}

#12: 为你的代码添加注释

添加注释以解释代码的功能,特别是对于复杂或不明显的部分。

单行注释
单行注释以//开头。用于解释特定行的代码。

1
2
3
4
5
6
7
8
package main
import "fmt"
func main() {
// 这是一条单行注释
fmt.Println("Hello, World!") // 打印问候语
}

多行注释
多行注释在/* */中。用于较长的解释或跨多行的注释。

1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"
func main() {
/*
这是一条多行注释。
它可以跨越多行。
*/
fmt.Println("Hello, World!") // 打印问候语
}

函数注释
为函数添加注释,明确其用途、参数和返回值。使用 godoc 风格的函数注释可以使代码更易读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import "fmt"
// greetUser 通过用户名向用户表示问候。
// 参数:
// name (string): 要问候的用户的名字。
// 返回:
// string: 问候消息。
func greetUser(name string) string {
return "Hello, " + name + "!"
}
func main() {
userName := "Alice"
greeting := greetUser(userName)
fmt.Println(greeting)
}

包注释
在 Go 文件的顶部添加注释,描述包的用途。使用相同的 godoc 风格。

1
2
3
4
5
6
7
8
9
package main
import "fmt"
// 这是我们 Go 程序的主要包。
// 它包含入口点(main)函数。
func main() {
fmt.Println("Hello, World!")
}

#11: 使用 goroutines 进行并发操作

高效地利用 goroutine 进行并发操作。goroutine 是 Go 中轻量级的、并发的执行线程。它们使您能够在没有传统线程开销的情况下并发运行函数。这使您能够编写高度并发和高效的程序。

让我们通过一个简单的例子来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main
import (
"fmt"
"time"
)
// 并发运行的函数
func printNumbers() {
for i := 1; i <= 5; i++ {
fmt.Printf("%d ", i)
time.Sleep(100 * time.Millisecond)
}
}
// 运行在主 goroutine 中的函数
func main() {
// 启动 goroutine
go printNumbers()
// 继续执行主函数
for i := 0; i < 2; i++ {
fmt.Println("Hello")
time.Sleep(200 * time.Millisecond)
}
// 在退出之前确保 goroutine 完成
time.Sleep(1 * time.Second)
}

#10: 使用 Recover 处理 panic

使用 recover 函数优雅的处理 panic,并防止程序崩溃。在 Go 中,panic 是意外的运行时错误,可能导致程序崩溃。然而,Go 提供了一种称为 recover 的机制来优雅的处理 panic

让我们通过一个简单的例子来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
import "fmt"
// 可能会 panic 的函数
func riskyOperation() {
defer func() {
if r := recover(); r != nil {
// 从 panic 中恢复并 gracefully 处理它
fmt.Println("Recovered from panic:", r)
}
}()
// 模拟 panic 条件
panic("Oops! Something went wrong.")
}
func main() {
fmt.Println("Start of the program.")
// 在一个能从 panic 中恢复的函数中调用 riskyOperation
riskyOperation()
fmt.Println("End of the program.")
}

#9: 避免使用 init 函数

避免使用 init 函数,除非必要,因为它可能使代码更难理解和维护。

一个更好的方法是将初始化逻辑移到常规函数中,您可以从主函数中显式调用它,通常更易于控制,增强代码的可读性,并简化测试。

以下是演示避免使用 init 函数的简单 Go 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"fmt"
)
// InitializeConfig 初始化配置。
func InitializeConfig() {
// 在这里初始化配置参数。
fmt.Println("Initializing configuration...")
}
// InitializeDatabase 初始化数据库连接。
func InitializeDatabase() {
// 在这里初始化数据库连接。
fmt.Println("Initializing database...")
}
func main() {
// 显式调用初始化函数。
InitializeConfig()
InitializeDatabase()
// 主程序逻辑在这里。
fmt.Println("Main program logic...")
}

#8: 使用 defer 进行资源清理

defer 允许你延迟执行函数,直到包围它的函数返回。它通常用于执行诸如关闭文件、解锁互斥锁或释放其他资源等任务。

这确保即使在出现错误的情况下,清理操作也会被执行。

让我们创建一个简单的程序,从文件中读取数据,并使用 defer 确保文件在发生任何错误时都能正确关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"fmt"
"os"
)
func main() {
// 打开文件(将 "example.txt" 替换为你的文件名)
file, err := os.Open("example.txt")
if err != nil {
fmt.Println("Error opening the file:", err)
return // 出现错误时退出程序
}
defer file.Close() // 确保函数退出时文件被关闭
// 读取并打印文件的内容
data := make([]byte, 100)
n, err := file.Read(data)
if err != nil {
fmt.Println("Error reading the file:", err)
return // 出现错误时退出程序
}
fmt.Printf("Read %d bytes: %s\n", n, data[:n])
}

#7: 推荐使用复合字面值而非构造函数

使用复合字面值来创建结构体的实例,而不是使用构造函数。

为什么使用复合字面值?
复合字面值提供了几个优势:

  • 简洁性
  • 可读性
  • 灵活性

让我们通过一个简单的例子来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
import (
"fmt"
)
// 定义一个表示个人信息的结构体类型
type Person struct {
FirstName string // 个人的名字
LastName string // 个人的姓氏
Age int // 个人的年龄
}
func main() {
// 使用复合字面值创建一个 Person 实例
person := Person{
FirstName: "John", // 初始化 FirstName 字段
LastName: "Doe", // 初始化 LastName 字段
Age: 30, // 初始化 Age 字段
}
// 打印个人信息
fmt.Println("个人详情:")
fmt.Println("名字:", person.FirstName) // 访问并打印名字字段
fmt.Println("姓氏:", person.LastName) // 访问并打印姓氏字段
fmt.Println("年龄:", person.Age) // 访问并打印年龄字段
}

#6: 减少函数参数

在Go中,编写干净高效的代码是至关重要的。其中一种方法是减少函数参数的数量,这可以导致更易维护和可读的代码。

让我们通过一个简单的例子来探讨这个概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main
import "fmt"
// Option 结构体用于保存配置选项
type Option struct {
Port int
Timeout int
}
// ServerConfig 是一个接受 Option 结构体的函数
func ServerConfig(opt Option) {
fmt.Printf("服务器配置 - 端口:%d,超时:%d 秒\n", opt.Port, opt.Timeout)
}
func main() {
// 创建一个具有默认值的 Option 结构体
defaultConfig := Option{
Port: 8080,
Timeout: 30,
}
// 使用默认选项配置服务器
ServerConfig(defaultConfig)
// 使用新的 Option 结构体修改端口
customConfig := Option{
Port: 9090,
}
// 使用自定义端口值和默认超时配置服务器
ServerConfig(customConfig)
}

在这个例子中,我们定义了一个Option结构体,用于保存服务器的配置参数。与将多个参数传递给ServerConfig函数不同,我们使用一个单独的Option结构体,使得代码更易于维护和扩展。这种方法在处理具有大量配置参数的函数时特别有用。

#5: 使用显式返回值而不是具名返回值以提高清晰度

在Go中,通常使用具名返回值,但它们有时会使代码不够清晰,尤其是在较大的代码库中。

让我们通过一个简单的例子来看看它们之间的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main
import "fmt"
// namedReturn 演示具名返回值。
func namedReturn(x, y int) (result int) {
result = x + y
return
}
// explicitReturn 演示显式返回值。
func explicitReturn(x, y int) int {
return x + y
}
func main() {
// 具名返回值
sum1 := namedReturn(3, 5)
fmt.Println("具名返回值:", sum1)
// 显式返回值
sum2 := explicitReturn(3, 5)
fmt.Println("显式返回值:", sum2)
}

在上面的示例程序中,我们有两个函数,namedReturnexplicitReturn。它们的区别如下:

namedReturn 使用了具名返回值 result。虽然清楚函数返回的是什么,但在更复杂的函数中可能不够直观。
explicitReturn 直接返回结果。这更简单、更明确。

#4: 保持函数复杂性最小化

函数复杂性指的是函数代码中的错综复杂度、嵌套和分支程度。保持函数复杂性的低水平使得你的代码更易读、更易维护,且更不容易出错。

让我们通过一个简单的例子来探讨这个概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import (
"fmt"
)
// CalculateSum 返回两个数字的和。
func CalculateSum(a, b int) int {
return a + b
}
// PrintSum 打印两个数字的和。
func PrintSum() {
x := 5
y := 3
sum := CalculateSum(x, y)
fmt.Printf("%d 和 %d 的和是 %d\n", x, y, sum)
}
func main() {
// 调用 PrintSum 函数来演示最小函数复杂性。
PrintSum()
}

在上面的示例程序中:

  1. 我们定义了两个函数,CalculateSumPrintSum,各自负责特定的任务。
  2. CalculateSum 是一个简单的函数,用于计算两个数字的和。
  3. PrintSum 利用 CalculateSum 计算并打印出 53 的和。
  4. 通过保持函数简洁并专注于单一任务,我们保持了较低的函数复杂性,提高了代码的可读性和可维护性。

#3: 避免变量的屏蔽

变量的屏蔽(shadowing)发生在在更小的作用域内声明了一个同名的新变量,这可能导致意外的行为。它隐藏了同名的外部变量,在该作用域内无法访问。避免在嵌套作用域内屏蔽变量,以防止混淆。

让我们看一个示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import "fmt"
func main() {
// 声明并初始化一个外部变量 'x',其值为 10。
x := 10
fmt.Println("外部 x:", x)
// 进入一个内部作用域,其中新变量 'x' 屏蔽了外部的 'x'。
if true {
x := 5 // 屏蔽发生在这里
fmt.Println("内部 x:", x) // 打印内部的 'x',其值为 5。
}
// 外部的 'x' 保持不变且仍然可访问。
fmt.Println("内部作用域后的外部 x:", x) // 打印外部的 'x',其值为 10。
}

#2: 使用接口进行抽象

抽象
抽象是 Go 语言中的一个基本概念,允许我们定义行为而不指定实现细节。

接口
在 Go 中,接口是一组方法签名。

在泛型功能增加后,接口的是一组方法签名和类型约束,也就是一组类型的集合。不过这里介绍的还是原始的接口功能,所以上面的描述也每问题。

任何实现接口所有方法的类型都会隐式满足该接口。

这使我们能够编写能够与不同类型一起工作的代码,只要它们遵循相同的接口。

下面是 Go 中的一个示例程序,演示了使用接口进行抽象的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main
import (
"fmt"
"math"
)
// 定义 Shape 接口
type Shape interface {
Area() float64
}
// 矩形结构体
type Rectangle struct {
Width float64
Height float64
}
// 圆形结构体
type Circle struct {
Radius float64
}
// 为矩形实现 Area 方法
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 为圆形实现 Area 方法
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
// 打印任意 Shape 的面积的函数
func PrintArea(s Shape) {
fmt.Printf("面积: %.2f\n", s.Area())
}
func main() {
rectangle := Rectangle{Width: 5, Height: 3}
circle := Circle{Radius: 2.5}
// 在矩形和圆形上调用 PrintArea,它们都实现了 Shape 接口
PrintArea(rectangle) // 打印矩形的面积
PrintArea(circle) // 打印圆形的面积
}

在这个单一的程序中,我们定义了 Shape 接口,创建了两个结构体 RectangleCircle,它们都实现了 Area() 方法,并使用 PrintArea 函数来打印满足 Shape 接口的任何形状的面积。

这演示了在 Go 中如何使用接口进行抽象,以使用一个共同的接口处理不同类型。

#1: 避免混淆库包和可执行文件

在 Go 语言中,保持库包和可执行文件之间清晰的分离是至关重要的,以确保代码清晰和可维护。

以下是演示库和可执行文件分离的示例项目结构:

1
2
3
4
myproject/
├── main.go
├── myutils/
└── myutils.go

myutils/myutils.go:

1
2
3
4
5
6
7
8
package myutils
import "fmt"
// 导出的打印消息的函数
func PrintMessage(message string) {
fmt.Println("来自 myutils 的消息:", message)
}

main.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"fmt"
"myproject/myutils" // 导入自定义包
)
func main() {
message := "你好,Golang!"
// 调用自定义包 myutils 中的导出函数
myutils.PrintMessage(message)
// 演示主程序逻辑
fmt.Println("来自 main 的消息:", message)
}

在上面的示例中,我们有两个独立的文件:myutils.gomain.go
myutils.go 定义了一个名为 myutils 的自定义包。它包含一个打印消息的导出函数 PrintMessage
main.go 是可执行文件,使用相对路径("myproject/myutils")导入了自定义包 myutils
main.go 中的 main 函数调用 myutils 包中的 PrintMessage 函数并打印一条消息。这种关注点分离使代码保持有序和可维护。

快乐编码!

点个赞吧,表示对原作者的支持。