100 Techniques for Writing Readable Go Code (系列 1)

94 阅读18分钟

系列 1(1~20)

1 使用清晰且描述性的变量和函数名称

选择能够清楚描述变量和函数的目的和用法的名称。

使用清晰且具有描述性的变量和函数名称可让您的代码更具可读性和可维护性。它可以帮助其他开发人员理解代码的意图,而无需大量注释。

Good

// CalculateTotalPrice calculates the 
// total price of items in a cart.
func CalculateTotalPrice(items []Item) float64 {
    var totalPrice float64
    for _, item := range items {
        totalPrice += item.Price * float64(item.Quantity)
    }
    return totalPrice
}

Bad

// CalcTP calculates something.
func CalcTP(i []item) float64 {
    var tp float64
    for _, x := range i {
        tp += x.Price * float64(x.Quantity)
    }
    return tp
}

在好的例子中,函数名称 CalculateTotalPrice 清楚地表明了其用途。变量 totalPrice 具有描述性,使代码更易于理解。在坏的例子中,CalcTP 和 tp 含糊不清,使得更难理解函数的意图。

Go 的命名规则建议在变量名和函数名中使用混合大写或混合大写加首字母缩写(如 HTTPServer),以提高可读性。

2 利用 Go 内置的多个返回值错误处理

使用 Go 从函数返回多个值的功能来有效地处理错误。

Go 允许函数返回多个值,这对于错误处理特别有用。通过将错误作为第二个值返回,您可以优雅地处理错误并保持代码整洁。

Good

// ReadFile reads the content of a file and returns it as a string.
func ReadFile(filename string) (string, error){
    data, err := os.ReadFile(filename)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

content, err := ReadFile("exmaple.txt")
if err != nil {
    log.Fatalf("Failed to read file: %v", err)
}
fmt.Println(content)

Bad

// ReadFile reads the content of a file and returns it as stirngs.
func ReadFile(filename string) string {
    data, err := os.ReadFile(filename)
    if err != nil {
        log.Fatalf("Failed to read file: %v", err)
    }
    return string(data)
}

content := ReadFile("example.txt")
fmt.Println(content)

在好的例子中,Read File 函数返回文件内容和错误。这允许调用者适当地处理错误。在坏的例子中,该函数仅返回文件内容,并使用 log.Fatalf 在内部处理错误,这不太灵活,并且可能会意外终止程序。

Go 的错误处理理念鼓励明确的错误检查和处理,从而使代码更健壮、更易维护。

3 有效利用 Go 的并发特性,如 goroutines 和 channels

利用 goroutines 和通道编写易于理解和维护的并发程序。

在 Go 中使用 goroutines 和 channel 可以实现高效且易读的并发编程。正确使用这些功能可以显著提高代码的性能和清晰度。

Good

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("Worker %s started job %d\n", id, j)
        time.Sleep(time.Second)
        fmt.Printf("Worder %d finished job %d\n", id, j)
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)
    for w := 1; w <= 3; w++ {
        go wroker(w, jobs, results)
    }
    for j := 1;  j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    for a := 1; a <= 5; a++ {
        <-results
    }
}

Bad

func worker(id int, jobs <-chan int, results chan<- int) {
    for {
        j, more := <-jobs
        if more {
            fmt.Printf("Worker %s started job %d\n", id, j)
            time.Sleep(time.Second)
            fmt.Printf("Worder %d finished job %d\n", id, j)
            results <- j * 2
        } else {
            return
        }
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)
    for w := 1; w <= 3; w++ {
        go wroker(w, jobs, results)
    }
    for j := 1;  j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    for a := 1; a <= 5; a++ {
        <-results
    }
}

在好的例子中,worker 函数使用范围循环从 jobs 通道读取数据,这种方式更加符合语言习惯,也更加简洁。坏的例子使用了一种更加冗长且容易出错的方法,需要手动检查通道的关闭状态。好的例子更易于阅读,也更不容易出错。

Goroutine 是 Go 运行时管理的轻量级线程,比传统线程更高效。通道为 Goroutine 提供了一种无需显式锁定即可安全通信的方法。

4 利用 Go 简单轻量的语法提高可读性

使用 Go 的简单语法来编写清晰且可维护的代码。

Go 的语法设计简洁易读。通过遵循最佳实践,你可以编写高效且易于他人理解的代码。

Good

package main

import "fmt"

func add(a, b int) int {
    return a + b
}

func main() {
    result := add(3, 4)
    fmt.Println("Result:", result)
}

Bad

package main

import "fmt"

func add(a, b int) int {
    return a + b
}

func main() {
    result := add(3, 4)
    fmt.Println("Result:", result)
}

在好的例子中,函数参数以简洁的方式声明,这是 Go 的常见习惯。坏的例子使用了 Go 中不必要的更冗长的语法。好的例子更具可读性,符合 Go 的简洁哲学。

Go 的设计理念是简洁,受到 C 等语言的影响,但具有现代功能。这种简洁性可帮助开发人员快速编写简洁高效的代码。

5 使用 Go 强大的标准库完成常见任务

利用 Go 的广泛标准库来简化常见的编程任务并提高代码的可读性。

无需重新发明轮子,利用 Go 标准库提供的功能进行常见操作。

Good

func main() {
    // Use sort.Ints to sort a slice of integers
    nums := []int{5, 2, 8, 1, 9}
    sort.Ints(nums)
    fmt.Println(nums) // Output: [1 2 5 8 9]
}

Bad

func main() {
    // Custom implementation of bubble sort
    nums := []int{5, 2, 8, 1, 9}
    for i := 0; i < len(nums)-1; i++ {
        for j := 0; j < len(nums)-i-1; j++ {
            if nums[j] > nums[j+1] {
                nums[j], nums[j+1] = nums[j+1], nums[j]
            }
        }
    }
    fmt.Println(nums) // Output: [1 2 5 8 9]
}

标准库提供了各种常见任务的软件包,例如排序、文件 I/O、网络等。使用这些软件包可以节省时间和精力,并且通常可以使代码更易读、更易于维护。在良好的代码示例中,我们使用 sort.Ints 函数对整数切片进行排序,这比实现自定义排序算法更简洁、更容易理解。

Go 标准库以其简单性、一致性和高性能而闻名。

6 选择能揭示意图的名称

对变量、函数和类型使用清晰且描述性的名称,以提高代码的可读性和可维护性。

命名是编写可读代码的关键方面。选择能够传达代码元素目的和意图的名称。

Good

// calculateArea calculates the area of a rectangle
func calculateArea(length, width float64) float64 {
    return length * length
}

func main() {
    length := 5.0
    width := 3.0
    area := calculateArea(length, width)
    fmt.Printf("The area of a rectangle with length %.2f and width %.2f\n", length, width, area)
}

Bad

func calc(a, b float64) float64 {
    return a * b
}

func main() {
    x := 5.0
    y := 3.0
    z := calc(x, y)
    fmt.Printf("The result is %.2f\n", z)
}

在好的代码示例中,函数名 calculateArea 清楚地表明了其目的,变量名 length、width 和 area 也不言自明。相比之下,糟糕的代码示例使用了一些含糊不清的名称,如 calc、a、b、x、y 和 z,使人难以理解代码的意图。选择描述性的名称可以大大提高代码的可读性和可维护性,尤其是在大型项目中。

Go 编程语言强调简单性和可读性,命名约定在实现这些目标中起着至关重要的作用。

7 使用可发音的名字

选择容易发音的变量和函数名称。

使用可发音的名称可以让其他人更容易阅读和讨论您的代码。

Good

// Calculate the average of an array of numbers
func calculateAverage(numbers []int) float64 {
    total := 0
    for _, number := range numbers {
        total += number
    }
    return float64(total) / float64(numbers)
}

Bad

func calcAvg(arr []int) float64 {
    ttl := 0
    for _, num := range arr {
        ttl += num
    }
    return float64(ttl) / float64(len(arr))
}

在好的例子中,函数名称 calculateAverage 和变量名称 numbers 和 total 都易于发音和理解。在坏的例子中,calcAvg、arr 和 ttl 等缩写使代码更难阅读和讨论。

可发音的名称可提高代码的可读性和可维护性,使团队更容易协作,也使新开发人员更容易理解代码库。

8 除非广为人知,否则避免使用缩略语和首字母缩写词

除非缩写或首字母缩略词得到普遍认可,否则请使用完整的单词作为名称。

避免使用晦涩难懂的缩写和首字母缩略词有助于确保更多受众能够理解您的代码。

Good

// Fetch user information form the database
func fetchUserInformation(userID int) (User, error) {
    // Implementation
}

Bad

// Fetch usr info from db
func fetchUsrInfo(uid int) (Usr, err) {
    // Implementation
}

在好的例子中,获取用户信息、用户 ID 和用户都清晰且具有描述性。在坏的例子中,usr、info、db 和 uid 等缩写可能会让不熟悉这些术语的读者感到困惑。

在代码中使用完整的单词会稍微增加打字时间,但可以显著提高可读性并减少读者的认知负荷。

9 使用一致的命名约定

在整个代码库中采用统一的命名约定以增强可读性和可维护性。

一致的命名约定可以帮助开发人员快速理解变量、函数和其他标识符的用途和用法。

Good

// Consistent camelCase naming convention
var userName string
var userAge int
func getUserInfo() (string, int) {
    return userName, userAge
}

Bad

// Inconsistent naming conventions
var UserName string
var user_age int
func GetUserInfo() (string, int) {
    return UserName, uesr_age
}

在好的例子中,变量名和函数名一致使用驼峰式命名法,使代码更易于阅读和理解。在坏的例子中,混合使用了驼峰式命名法和蛇形命名法,这可能会让读者感到困惑,并使代码更难维护。

Go 语言通常使用驼峰式命名法来命名变量和函数名称,使用帕斯卡式命名法(大驼峰式命名法)来命名类型名称。遵循这些约定有助于与更广泛的 Go 社区实践保持一致。

10 更喜欢较长、描述性的名称,而不是较短、隐晦的名称

使用较长的描述性名称,使代码不言自明,更容易理解。

描述性名称提供了上下文和含义,减少了附加注释的需要,使代码更易读。

Good

// Descriptive variable and function names
var userFirstName string
var userLastName string

func calculateUserAge(birthYear int) int {
    currentYear := 2024
    return currentYear - birthYear
}

Bad

// Short, cryptic variable and function names
var fn string
var in string
func calcAge(by int) int {
    cy := 2024
    return cy - by
}

在好的例子中,变量和函数名称清楚地描述了它们的用途,使代码一目了然。在坏的例子中,简短而隐晦的名称掩盖了含义,需要额外的努力才能理解代码。

描述性名称可以显著减少注释的需要,因为代码本身变得更易于理解。这种做法符合自文档化代码(self-documenting code)的原则,这在软件开发中备受重视。

11 避免使用“数据”或“值”等含糊不清的名称

对变量和函数使用描述性和具体的名称来提高代码的可读性。

选择清晰、具体的名称有助于其他开发人员理解变量或函数的用途,而无需额外的上下文。

Good

// Calculate the total price of items in a shopping cart
func calculateTotalPrice(items []Item) float64 {
    var totalPrice float64
    for _, item := range items {
        totalPrice += item.Price
    }
    return totalPrice
}

Bad

// Calculate something
func calculate(data []Item) float64 {
    var value float64
    for _, d := range data {
        value += d.Price
    }
    return value
}

在好的例子中,计算总价和总价清楚地表明了函数和变量代表什么。在坏的例子中,计算、数据和值太过通用,使得代码的用途更难理解。

描述性名称还可以帮助调试和维护,因为它们可以更轻松地跟踪数据流和识别问题。

12 避免使用过于相似且容易混淆的名称

为变量和函数选择不同的名称,以防止混淆和错误。

使用过于相似的名称可能会导致错误,并使代码更难阅读和维护。

Good

// Process user input and store the result
func processUserInput(input string) string {
    processedInput := strings.TrimSpace(input)
    return processInput
}

// Validate user input
func validateUserInput(input string) bool {
    return len(input) > 0
}

Bad

// Process user input and store the result
func processInput(input string) string {
    processed := strings.TrimSpace(input)
    return processed
}
// Validate user input
func validateInput(input string) bool {
    reutrn len(input) > 0
}

在好的例子中,处理用户输入和验证用户输入是截然不同的,并且清楚地表明了它们的目的。在坏的例子中,处理输入和验证输入太过相似,这可能会导致混淆和潜在的错误。

使用不同的名称不仅可以提高可读性,还可以降低因误读或误解变量和函数名称而导致错误的可能性。

13 避免使用 "isNotFound "或 "disableCache "等否定名称

使用肯定的名称来提高代码的可读性并避免混淆。

否定名称可能会造成混淆并导致误解。肯定名称更清晰,更直观。

Good

// Good example: Using positive names
var isFound bool
var enableCache bool
if isFound {
    // Handle found case
}
if enableCache {
    // Enable caching
}

Bad

// Bad example: Using negated names
var isNotFound bool
var disableCache bool
if !isNotFound {
    // Handle found case
}
if !disableCache {
    // Enable caching
}

使用肯定名称(如 isFound 和 enableCache)可使代码一目了然。否定名称需要额外的认知努力来解释,尤其是在条件语句中使用时。

否定名称可能导致代码中出现双重否定,这尤其难以阅读和理解。例如,if !isNotFound 不如 if isFound 清晰。

14 避免使用太宽泛或太狭窄的名称

选择能够准确描述变量或函数的目的和范围的名称。

太宽泛或太狭窄的名称都会误导读者理解代码的用途。命名时应力求平衡。

Good

// Good example: Balanced naming
var userCount int
var fetchUserData func(userID int) User
// Function to fetch user data
func fetchUserData(userID int) User {
    // Implementation
}

Bad

// Bad example: Too broad or too narrow names
var x int
var fetch func(id int) User
// Function to fetch user data
func fetch(id int) User {
    // Implementation
}

userCount 和 fetchUserData 等名称清楚地表明了它们的目的和范围。相比之下,像 x 和 fetch 这样的名称要么过于模糊,要么过于具体,使得代码更难理解和维护。

在多个开发人员在同一代码库中工作的协作环境中,良好的命名规范至关重要。清晰和描述性的名称有助于确保每个人都能理解代码的意图。

15 避免使用误导性或不准确的名称

对变量、函数和包使用清晰准确的名称,以提高代码的可读性和可维护性。

在 Go 编程中,为变量、函数和包选择合适的名称至关重要。误导性或不准确的名称可能会使其他开发人员感到困惑,并使代码更难理解和维护。

Good

// Good example: Clear and accurate names
package main

import "fmt"

// CalculateSum adds two integers and returns the result.
func CalculateSum(a, b int) int {
    return a + b
}

func main() {
    result := CalculateSum(3, 4)
    fmt.Println("Sum:", result)
}

Bad

// Bad example: Misleading and inaccurate names
package main

import "fmt"

// DoSomething performs an unspecified operatioin.
func DoSomething(x, y int) int {
    return x + y
}

func main() {
    z := DoSomething(3, 4)
    fmt.Println("Result:", z)
}

在好的示例中,函数名称 CalculateSum 清楚地表明它计算两个整数之和。而在糟糕的示例中,函数名称 DoSomething 含糊不清,没有表达函数的目的,使代码更难理解。

Go 的命名约定建议对变量名和函数名使用混合大写字母或混合大写字母和下划线。这有助于保持整个代码库的一致性和可读性。

16 使用 Go 的内置测试框架编写单元测试

利用 Go 的内置测试框架编写单元测试,确保您的代码可靠且按预期运行。

Go 提供了一个内置的测试框架,可以轻松编写和运行单元测试。使用此框架有助于确保您的代码正确无误,并防止将来的更改引入错误。

Good

// Good example: Using Go's buint-in testing framework
package main

import (
    "fmt"
)

// CalculateSum adds two integers and returns the result.
func CalculateSum(a, b int) int {
    return a + b
}

func TestCalculateSum(t *testing.T) {
    result := CalculateSum(3, 4)
    expected := 7
    if result != expected {
        t.Errorf("CalculateSum(3, 4) = %d; want %d", result, expected)
    }
}

Bad

// Bad example: No unit tests
package main

// CalculateSum adds two integers and returns the result.
func CalculateSum(a, b int) int {
    return a + b
}

// No tests provided to verify the correctness of CalculateSum function.

在好的例子中,使用 Go 的测试包提供了单元测试来验证 Calculate Sum 函数的正确性。这确保了该函数按预期工作,并有助于捕获任何可能破坏其功能的未来更改。在坏的例子中,没有提供任何测试,这使得很难验证该函数的正确性并增加了未检测到的错误的风险。

Go 的测试框架简单而强大。它包括基准测试、示例测试和并行运行测试等功能,使其成为确保代码质量的综合工具。

17 利用 Go 的接口系统实现代码重用和抽象

在 Go 中使用接口可以通过抽象实现细节来实现灵活且可重用的代码。

Go 中的接口使您可以定义任何类型必须实现的方法,从而提高代码的可重用性和抽象性。这使您的代码更加模块化且更易于维护。

Good

package main

import "fmt"

// Define an interface
type Speaker interface {
    Speak() string
}

// Implement the interface with a struct
type Dog struct{}

func (d Dog) Speak() string {
    return "Woof!"
}

// Implement the interface with another struct
type Cat struct {}

func (c Cat) Speak() string {
    return "Meow!"
}

func main() {
    var s Speaker
    s = Dog{}
    fmt.Println(s.Speak()) // Output: Woof!
    s = Cat{}
    fmt.Println(s.Speak()) // Output: Meow!
}

Bad

// No Interface, direct implementation
type Dog struct{}

func (d Dog) Speak() string {
    return "Woof!"
}

type Cat struct {}

func (c Cat) Speak() string {
    return "Meow!"
}

func main() {
    d := Dog{}
    fmt.Println(d.Speek()) // Output: Woof!
    c := Cat{}
    fmt.Println(c.Speak()) // Output: Meow!
}

在好的例子中,Speaker 接口抽象了 Speak 方法,允许不同的类型实现它。这使得代码更加灵活,更容易扩展新类型。在坏的例子中,缺少接口意味着必须单独处理每种类型,从而降低了代码的可重用性并增加了维护工作量。

Go 接口是隐式满足的,这意味着类型不需要显式声明它实现了接口。此功能促进了松散耦合并增强了代码灵活性。

18 合理地利用 Go 强大的反射功能

Go 中的反射允许动态类型检查和操作,但由于其复杂性和潜在的性能成本,应谨慎使用。

Go 中的反射提供了在运行时检查类型并动态操作对象的能力。虽然功能强大,但应谨慎使用,因为它会使代码更难理解和维护。

Good

func PrintFields(v interface{}) {
    val := reflect.ValueOf(v)
    typ := reflect.TypeOf(v)
    for i := 0; i < val.NumField(); i++ {
        fmt.Printf("Field: %s, Value: %v\n", typ.Field(i).Name, val.Field(i).Interface())
    }
}

type Person struct {
    Name string
    Age int
}

func main() {
    p := Person{Name: "Alice", Age: 30}
    PrintFields(p)
}

Bad

package main

import (
    "fmt"
    "reflect"
)

func PrintFields(v interface{}) {
    val := reflect.ValueOf(v)
    typ := reflect.TypeOf(v)
    for i := 0; i < val.NumField(); i++ {
        // Using reflection without checking kind
        if typ.Field(i).Type.Kind() == reflect.String {
            fmt.Printf("Field: %s, Value: %s\n", typ.Field(i).Name, val.Field(i).String())
        } else if typ.Field(i).Type.Kind() == reflect.Int {
            fmt.Printf("Field: %s, Value: %d\n", typ.Field(i).Name, val.Field(i).Int())
        }
    }
}

type Person struct {
    Name string
    Age  int
}

func main() {
    p := Person{Name: "Alice", Age: 30}
    PrintFields(p)
}

在好的例子中,反射用于动态打印结构体的字段而不假设其类型,从而使函数更加通用。在坏的例子中,代码假设字段的特定类型,这会降低灵活性,并且如果结构发生变化,可能会导致运行时错误。

Go 中的反射功能强大,但可能比直接类型断言更慢、更容易出错。应谨慎使用反射,尤其是在对性能要求较高的代码中。

19 利用 Go 简单高效的内存管理功能

Go 的内存管理是自动且高效的,使开发人员无需手动分配和释放内存。

Go 的内存管理系统使用并发的 "标记-清扫" 垃圾回收器,它能自动回收未使用的内存,无需程序员干预。

Good

package main

import "fmt"

type Person struct {
    Name string
    Age int
}

func main() {
    // Create a new Person struct
    p := Person{Name: "Alice", Age: 30}
    // Use the struct
    fmt.Printf("Name: %s, Age: %d\n", p.Name, p.Age)
    // No need to manually free memory
}

Bad

func main() {
    // Manually allocate memory
    ptr := unsafe.Pointer(uintptr(0x12345678))
    // Use the allocated memory
    fmt.Println(*(*int)(ptr))
    // Manually free memory (not recommended)
    // This can lead to memory leak or double frees
    // ptr = nil
}

好的代码示例演示了 Go 如何自动管理结构体和其他数据类型的内存。开发人员无需担心手动分配或释放内存。坏的代码示例显示了使用不安全指针手动管理内存会导致错误和内存泄漏,这在 Go 中通常不推荐。

Go 的垃圾收集器是一种非分代的、并发的、并行的标记和清除收集器。

20 使用 Go 的内置分析工具进行性能优化

Go 提供了内置的分析工具,可帮助识别和优化代码中的性能瓶颈。

Go 的分析工具包括 pprof 包,它可以为您的程序生成 CPU、内存和 goroutine 配置文件。您可以分析这些配置文件以识别性能问题并相应地优化代码。

Good

func expensiveOperation() {
    // Simulate an expensive operation
    var result int
    for i := 0; i < 100000000; i++ {
        result += i
    }
}

func main() {
    // Create a CPU profile file
    cpuFile, err := os.Create("cpu.pprof")
    if err != nil {
        fmt.Println("Error creating CPU profile:", err)
        return
    }
    defer cpuFile.Close()
    
    // Start CPU profiling
    pprof.StartCPUProfile(cpuFile)
    defer pprof.StopCPUProfile()
    // Call the expensive operation
    expensiveOperation()
    // Analyze the CPU profile using the pprof tool
    // $ go tool pprof cpu.pprof
}

Bad

package main
func expensiveOperation() {
    // Simulate an expensive operation
    var result int
    for i := 0; i < 1000000000; i++ {
        result += i
    }
}

func main() {
    // Call the expensive operation without profiling
    expensiveOperation()
}

好的代码示例演示了如何使用 Go 的内置 pprof 包为昂贵的操作生成 CPU 配置文件。可以使用 go tool pprof 命令分析生成的配置文件以识别性能瓶颈。坏的代码示例只是调用昂贵的操作而没有任何分析,这使得识别和优化性能问题变得困难。

Go 的分析工具还可以生成内存和 goroutine 配置文件,这对于识别内存泄漏和优化并发代码很有用。