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

66 阅读18分钟

系列 3(41~60)

41 使用提前返回或保护,实现简单的控制流

通过使用早期返回或保护子句来提前处理边缘情况和错误,从而简化您的代码。

使用早期返回或保护子句有助于保持函数的主要逻辑清晰,并减少嵌套条件的需要。

Good

func process(input int) error {
    // Guard clause for invalid input 无效输入的保护子句
    if input <= 0 {
        return fmt.Errorf("invalid input: %d", input)
    }
    // Main logic (核心逻辑)
    result := input * 2
    fmt.Println("Processed result:", result)
    return nil
}

Bad

func process(input int) error {
    if input > 0 {
        // Main logic
        result := input * 2
        fmt.Println("Processed result:", result)
        return nil
    } else {
        return fmt.Errorf("invalid input: %d", input)
    }
}

在好的例子中,保护子句会立即处理无效输入的情况,从而使主逻辑更具可读性和直观性。在坏的例子中,主逻辑嵌套在 if 语句中,使其更难理解。

保护子句在具有多个出口点的函数中特别有用,因为它们有助于尽早处理错误和特殊情况,保持主逻辑的清晰和集中。

42 避免深度嵌套的条件语句

避免使用深度嵌套的条件语句来降低复杂性,因为深度嵌套的条件语句会使代码难以阅读和维护。

深度嵌套的条件可能会掩盖代码的主要逻辑。扁平化这些结构可提高可读性和可维护性。

Good

func checkAndProcess(input int) {
    if input <= 0 {
        fmt.Println("Invalid input")
        return
    }
    if input % 2 != 0 {
        fmt.Println("Input is not even")
    }
    // Main logic
    result := input * 2
    fmt.Println("Processed result:", result)
}

Bad

func checkAndProcess(input int) {
    if input > 0 {
        if input%2 == 0 {
            // Main logic
            result := input * 2
            fmt.Println("Processed result:", result)
        } else {
            fmt.Println("Input is not even")
        }
    } else {
        fmt.Println("Invalid input")
    }
}

在这个好的例子中,每个条件都被立即检查和处理,这使主逻辑保持在相同的缩进级别。在这个糟糕的例子中,主要逻辑被隐藏在嵌套的if语句中,使其更难阅读和理解。

扁平化嵌套条件通常可以通过使用保护子句、提前返回或将逻辑分解为更小、更集中的函数来实现。

43 使用 switch 语句来处理多个条件

Switch 语句简化了多种条件的处理。

在 Go 中使用 switch 语句可以使你的代码在处理多种条件时更具可读性和可维护性。

Good

package main

import "fmt"

func main() {
    day := "Tuesday"
    switch day {
    case "Monday":
        fmt.Println("Start of the work week.")
    case  "Thesday":
        fmt.Println("Second day of the woek week.")
    case "Wednesday":
        fmt.Println("Midweek.")
    case "Thursday":
        fmt.Println("Almost there.")
    case "Friday":
        fmt.Println("Last work day of the week.")
    case "Saturday", "Sunday":
        fmt.Println("Weekend!")
    default:
        fmt.Println("Invalid day.")
    }
}

Bad

package main

import "fmt"

func main() {
    day := "Tuesday"
    if day == "Monday" {
        fmt.Println("Start of the work week.")
    } else if day == "Tuesday" {
        fmt.Println("Second day of the work week.")
    } else if day == "Wednesday" {
        fmt.Println("Midweek.")
    } else if day == "Thursday" {
        fmt.Println("Almost there.")
    } else if day == "Friday" {
        fmt.Println("Last work day of the week.")
    } else if day == "Saturday" || day == "Sunday" {
        fmt.Println("Weekend!")
    } else {
        fmt.Println("Invalid day.")
    }
}

好的例子使用了 switch 语句,在处理多个条件时,该语句更简洁,更易读。坏的例子使用了一系列 if - else 语句,随着条件数量的增加,这些语句会变得繁琐且更难维护。

在 Go 中,switch 语句也可以不带表达式使用,从而允许您在每种情况下处理多个条件。

44 使用 Go 内置的控制流结构,如 for 和 range

使用 Go 的 for 和 range 结构实现清晰高效的循环。

Go 提供了强大的控制流结构,例如 for 和 range 来迭代集合,从而使您的代码更具可读性和效率。

Good

package main

import "fmt"

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    for index, value := range numbers {
        fmt.Printf("Index: %d, Value: %d\n", index, value)
    }
}

Bad

package main

import "fmt"

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    for i := 0; i < len(numbers); i++ {
        fmt.Printf("Index: %d, Value: %d\n", i, numbers[i])
    }
}

好的例子使用了 range 关键字,它简化了集合的迭代并直接提供了索引和值。坏的例子使用了传统的 for 循环,这在 Go 中更加冗长且不太符合习惯用法。

在 Go 中,range 关键字可用于迭代各种数据结构,包括数组、切片、映射和通道。

45 将复杂逻辑分解为更小的可重复使用函数

将大型函数拆分为执行特定任务的较小的模块化函数,提高代码的可读性和可维护性。

具有复杂逻辑的大型函数可能难以理解和维护。通过将它们分解为更小、可重复使用的函数,代码变得更加模块化,也更易于推理。

Good

// calculateTotal calculates the total cost of an order
func calculateTotal(items []Item, discount float64) float64 {
    total := calculateSubTotal(items)
    discountedTotal := applyDiscount(total, discount)
    return discountedTotal
}

func calculateSubTotal(items []Item) float64 {
    var subTotal float64
    for _, item := range items {
        subTotal += item.Price * float64(item.Quantity)
    }
    return subTotal
}

// applyDiscount applies a discount to a given amount
func applyDiscount(amount, discount float64) float64 {
    return amount * (1 - discount)
}

Bad

func calculateTotal(items []Item, discount float64) float64 {
    var total float64
    for _, item := range items {
        total += item.Price * float64(item.Quantity)
    }
    if discount > 0 {
        total = total * (1 - discount)
    }
    return total
}

好的代码示例将计算小计、应用折扣和计算总计的逻辑分成单独的函数。这使得代码更加模块化、更易于阅读和维护。每个函数都有明确的职责,可以独立测试。糟糕的代码示例将所有逻辑都放在一个函数中,使其更难理解和维护。(这个例子还不是特别具有代表性,在实际生产中,如果遇到一个函数中同时做了许多不同的事,而且无法使用一句话说清楚这个大函数用来做什么的时候,就可以通过这个方法拆分大函数了)。

单一职责原则 (SRP) 是面向对象编程的基本原则,它规定一个类或函数应该只有一个改变的原因。

46 尽可能靠近变量的使用位置声明变量

尽可能在使用变量的位置附近声明变量,从而缩小变量范围,提高代码可读性。

在更接近变量用法的地方声明变量可以减少变量的作用范围,从而更容易理解变量的用途和生命周期,提高代码的可读性。

Good

func processData(data []byte) (int, error) {
    var count int
    for _, b := range data {
        if isValid(b) {
            count++
        }
    }
    return count, nil
}

Bad

func processData(data []byte) (int, error) {
    var count int
    var isValid bool
    for _, b := range data {
        isValid = isValid(b)
        if isValid {
            count++
        }
    }
}

func isValid(b byte) bool {
    // Validation logic...
    return true
}

在好的代码示例中,count 变量在 process Data 函数内部声明,靠近使用它的位置。这清楚地表明该变量的作用域是函数,用于计数有效字节。在坏的代码示例中,is Valid 变量在循环外部声明,即使它只在循环内使用。这增加了变量的作用域,使代码更难阅读。

尽可能靠近变量的使用位置声明变量的原则有时被称为"最小惊讶原则"(Principle of Least Astonishment)或"最小意外原则"(Principle of Least Surprise)。

47 避免不必要的变量赋值

尽量减少不必要的变量赋值,使代码更简洁、更高效。

避免不必要的变量赋值有助于减少混乱并提高代码的可读性。它还可以通过最小化内存使用来提高性能。

Good

func add(a, b int) int {
    return a + b // Directly return the sum
}

Bad

func add(a, b int) {
    sum := a + b // Unnecessary variable assignment
    return sum
}

在好的例子中,函数直接返回加法的结果,使得代码更简洁易读。在坏的例子中,中间变量 sum 是不必要的,并且增加了额外的代码行而没有任何好处。

不必要的变量分配也会导致内存使用量增加和潜在的性能问题,尤其是在大型应用程序中。

48 使用有意义且描述性的变量名

选择能够清楚描述其目的和内容的变量名。

使用有意义且描述性的变量名使得代码更易于理解和维护,从而让其他开发人员能够快速掌握每个变量的用途。

Good

这里这是为了演示变量命名,违背了 47 规则。

func calculateArea(width int, height int) int {
    // 'area' clearly describes the purpose
    area := width * height
    return width * height
}

Bad

func calculateArea(w int, h int) int {
    a := w * h // 'a' is not descriptive
    return a
}

在好的例子中,变量 area 清楚地表明它保存的是矩形的面积,使代码一目了然。在坏的例子中,变量 a 含糊不清,没有传达任何有关其用途的有意义的信息。

使用描述性变量名是所有编程语言的最佳实践,因为它显著提高了代码的可读性和可维护性。

49 将变量的范围限制在其预期用途内

将变量保持在尽可能最小的范围内以增强可读性和可维护性。

在尽可能小的范围内声明变量有助于防止意外的副作用,并使代码更容易理解。

Good

func processItems(items []string) {
    for _, item := range items {
        processedItem := process(item) // 'processedItem' is limited to the loop scope
        fmt.Println(processedItem)
    }
}

func process(item string) string {
    return "Processed: " + item
}

Bad

func processItems(items []string) {
    var processedItem string
    for _, item := range items {
        processedItem = process(item) // 'processedItem' is limited to the loop scope
        fmt.Println(processedItem)
    }
}

func process(item string) string {
    return "Processed: " + item
}

在好的例子中,processedItem 在循环内声明,将其范围限制在循环块内。这清楚地表明,processedItem 仅在循环内使用。在坏的例子中,processedItem 在循环外声明,这不必要地扩大了其范围,如果在函数的其他地方错误地使用了变量,则会导致混淆或错误。

限制变量范围是许多编程语言的常见做法,不仅仅是 Go。它通过明确变量的使用位置和方式,有助于减轻开发人员的认知负担。

50 利用 Go 对多重赋值和元组解包的支持

使用 Go 的多重赋值功能来简化代码并提高可读性。

Go 允许在单个语句中分配多个变量,这可以使代码更简洁、更易读。

Good

func swap(a, b int) (int, int) {
    return b, a
}

func main() {
    x, y := 1, 2
    x, y = swap(x, y) // 用于交换值的多个赋值
    fmt.Println(x, y) // Output: 2 1
}

交换值可以直接使用 x, y = y, x,这个例子是演示多重赋值和多返回值。

Bad

func swap(a, b int) (int, int) {
    return b, a
}

func main() {
    x, y := 1, 2
    temp := x // Using a temporary variable to swap values
    x = y
    y = temp
    fmt.Println(x, y) // Output: 2 1
}

在好的例子中,Go 的多重赋值功能用于在一行中交换 x 和 y 的值,使代码简洁易懂。在坏的例子中,使用临时变量 temp 来实现相同的结果,这会使代码更长且更难读。

多重赋值和元组解包是多种编程语言(例如 Python 和 JavaScript)中的功能,它们有助于编写更干净、更高效的代码。

51 利用 Go 的 defer 语句进行资源清理

使用 defer 语句确保资源在使用后得到正确清理。

Go 中的 defer 语句用于安排函数调用在函数完成后运行。这对于资源清理任务(例如关闭文件或释放锁)特别有用。

Good

func main() {
    file, err := os.Open("example.txt")
    if err != nil {
        fmt.Println("Error opening file:", err)
        return
    }
    // Ensure the file is closed after the function completes.
    defer file.Close()
    // Perform file operations
    fmt.Println("File operations completed")
}

Bad

package main

func main() {
    file, err := os.Open("example.txt")
    if err != nil {
    fmt.Println("Error opening file:", err)
        return
    }

    // Perform file operations
    // ...
    fmt.Println("File operations completed")
    // Manually closing the file
    file.Close()

在好的例子中,defer 语句确保无论函数如何退出,都会调用 file.Close(),从而使代码更健壮且更易于维护。在坏的例子中,如果函数提前退出或发生错误,则文件可能无法正确关闭,从而导致资源泄漏。

defer 语句可用于堆叠多个延迟调用。当周围函数返回时,它们会按照后进先出 (LIFO) 的顺序执行。

52 使用 Go 内置的具有多个返回值的错误处理

利用 Go 从函数返回多个值(包括错误值)的能力来有效地处理错误。

Go 函数通常返回多个值,最后一个值是错误。这种模式允许直接进行错误检查和处理,使代码更具可读性和可维护性。

Good

func stringToInt(s string) (int, error) {
    i, err := strcon.Atoi(s)
    if err != nil {
        return 0, err
    }
    return i, nil
}

func main() {
    str := "123"
    num, err := stringToInt(str)
    if err != nil {
        fmt.Println("Error converting string to int:", err)
        return
    }
    fmt.Println("Converted number:", num)
}

Bad

package main

import (
    "fmt"
    "strconv"
)

func stringToInt(s string) int {
    i, _ := strconv.Atoi(s) // Ignoring the error
    return i
}

func main() {
    str := "123"
    num := stringToInt(str)
    fmt.Println("Converted number:", num)
}

在好的例子中,函数字符串 To Int 返回整数值和错误。调用者检查错误并进行适当处理。在坏的例子中,错误被忽略,如果转换失败,这可能会导致意外行为。

Go 的错误处理理念鼓励显式的错误检查和处理,这有助于编写稳健可靠的代码。这与其他语言中使用的异常形成鲜明对比,后者可能更难跟踪和管理。

53 使用 Go 的 context 包进行取消和超时

使用 Go 中的上下文包有助于管理取消和超时,使您的代码更加健壮且更易于阅读。

Go 中的 context 包对于处理并发编程中的取消信号和超时至关重要。它允许您跨 API 边界和进程传播截止时间、取消信号和其他请求范围的值。

Good

func main() {
    // Create a context with a timeout of 2 seconds
    ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
    defer cancel() // Ensure the cancel functoin is called to release resources
    // Simulate a long-running operation
    select {
    case <-time.After(3 * time.Second):
        fmt.Println("Opeartion completed")
    case <-ctx.Done():
        fmt.Println("Operation timed out:", ctx.Err())
    }
}

Bad

func main() {
    done := make(chan bool)
    go func() {
        time.Sleep(3 * time.Second)
        done <- true
    }()
    select {
    case <-done:
        fmt.Println("Opeartion completed")
    case time.After(2 * time.Second):
        fmt.Println("Operation timed out")
    }
}

好的例子中,使用了 context 包来处理超时,使得代码更易读且更易于管理。坏的例子使用了手动超时机制,这降低了代码的可读性,也更难维护。

context 包是在 Go 1.7 中引入的,从那时起它就成为处理 Go 程序中取消和超时的标准方法。

54 利用 Go 简单轻量的语法提高可读性 Task advantage of Go's simple and lightweight syntax for readability

Go 的语法设计简单、轻量,有助于编写清晰、可读的代码。

Go 的语法避免了不必要的复杂性,使其更易于阅读和理解。通过遵循 Go 的约定和习惯用法,您可以编写既高效又易于维护的代码。

Good

// add returns the sum of two integres
func add(a, b int) int {
    return a + b
}

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

Bad

// This function adds two integers and returns the result
func add(a int, b int) int {
    // Return the sum of a and b
    return a + b
}
func main() {
    // Call the add function and store the result
    result := add(3, 4)
    // Print the result
    fmt.Println("Sum:", result)
}

好的示例使用了 Go 简单直接的语法,使代码易于阅读和理解。坏的示例虽然功能正确,但包含不必要的注释,使代码变得混乱并降低了可读性。

Go 由 Google 工程师设计,是一种简单、高效且易读的语言,它从 C 中汲取灵感,但具有现代特性并注重并发性。

55 Leverage Go's powerful standard library for common tasks 利用 Go 强大的标准库完成常见任务

利用 Go 的广泛标准库高效、有效地处理常见任务。

Go 的标准库提供了各种包,可以简化许多常见的编程任务,例如处理 I/O、字符串操作和使用数据结构。使用这些内置包可以使您的代码更具可读性和可维护性。

Good

func main() {
    // Using strings package from Go's standard library to manipulate strings.
    input := "hello, world"
    upperCased := strings.ToUpper(input)
    fmt.Println(upperCased) // Output: HELLO, WORLD
}

Bad

func toUpperCase(s string) string {
  result := ""
  for _, char := range s {

    if char >= 'a' && char <= 'z' {
        result += string(char - 32)
    } else {
        result += string(char)
    }
  }
    return result
}

func main() {
    // Manually converting string to uppercase
    input := "hello, world"
    uppercased := toUpperCase(input)
    fmt.Println(uppercased) // Output: HELLO, WORLD
}

好的例子利用了 Go 标准库中的 strings 包,该包针对字符串操作任务进行了优化和充分测试。另一方面,坏的例子手动实现了字符串转换,这很容易出错,而且更难阅读。使用标准库可以使代码更简洁、更可靠。

Go 的标准库设计简约但功能强大,无需外部依赖即可提供基本工具。这种设计理念有助于编写简洁高效的代码。

56 Declare variables as close to their usage as possbile 尽可能靠近变量的使用位置声明变量

将变量声明放在首次使用附近,以提高代码的可读性和可维护性。

在靠近使用变量的地方声明变量有助于理解代码流程并减少读者的认知负担。它还可以最小化变量的作用域,从而可以防止潜在的错误。

Good

func main() {
    // Declaring and using the variable immediately
    message := "Hello, Go!"
    fmt.Println(message)
}

Bad

func main() {
    // Declaring the variable far from its usage
    var message string
    message = "Hello, Go!"
    fmt.Println(message)
}

在好的例子中,变量 message 在使用前声明并初始化,使代码更易于理解。在坏的例子中,变量在函数开头声明,这会使代码更难阅读和维护,尤其是在较大的函数中。

让变量声明靠近其使用位置是许多编程语言(不仅仅是 Go)的常见做法。通过减少变量的范围和生命周期,它有助于编写更干净、更易于维护的代码。

57 Avoid unnecessary variable assignments 避免不必要的变量赋值

尽量减少使用不必要的变量赋值,使得代码更清晰,更高效。

不必要的变量赋值会使代码混乱,难以阅读。通过避免它们,您可以编写更简洁、更高效的代码。

Good

// Directly returning the reuslt
func add(a, b int) int {
    return a + b
}

Bad

func add(a, b int) int {
    sum := a + b // Unnecessary variable assignment
    return sum
}

在好的例子中,函数直接返回加法的结果,使代码更简洁易读。在坏的例子中,使用不必要的变量 sum 增加了额外的行数和复杂性,却没有任何好处。

避免不必要的变量分配也可以稍微提高性能,因为它减少了额外内存分配的开销。

58 Use meaningful and descriptive variable name 使用有意义且描述性的变量名

选择能够清楚描述其用途的变量名,以提高代码的可读性和可维护性。

使用有意义且描述性的变量名可以帮助其他开发人员更轻松地理解代码,从而减少理解其功能所需的时间。

Good

// Using descriptive variable names
func calculateArea(width, height float64) float64 {
    return width * height // Clearly indicates what the variables represent
}

Bad

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

在好的例子中,变量名 width 和 height 清楚地描述了它们的用途,使代码更容易理解。在坏的例子中,非描述性的名称 a 和 b 使得在没有额外上下文的情况下更难掌握函数的意图。

在整个代码库中一致使用有意义的变量名可以显著减少新开发人员加入项目的学习曲线。

59 将变量的范围限制在其预期用途范围内 Limit the scope of variables to their intended use

最小化变量的范围,使代码更具可读性和可维护性。

限制变量的范围有助于减少读者的认知负担,因为这样可以清楚地了解变量的使用位置和方式。这种做法还可以防止意外的副作用,并使代码更易于调试。

Good

func processItems(items []string) {
    for _, item := range items {
        processedIetm := process(item) // 'processedItem' is limited to this block
        fmt.Println(processedItem)
    }
}

func process(item string) string {
    return "Processed: " + item
}

Bad

func processItems(items []string) {
    var processedItem string // 'processedItem' has a broader scope than necessary
    for _, item := range items {
        processedItem := process(item)
        fmt.Println(processedItem)
    }
}

func process(item string) string {
    return "Processed: " + item
}

在好的例子中,变量 processed Item 在循环内声明,将其范围限制在循环块内。这清楚地表明 processed tem 仅在循环内使用,并在每次迭代时重新初始化。在坏的例子中,processedItem 在循环外声明,使其范围比必要的更广,这可能会导致混乱和潜在的错误。

在 Go 中,在块内声明的变量(例如,在循环或 if 语句中)不能在该块之外访问,这有助于有效地管理变量范围。

Leverage Go's support for multiple assignment and tuple unpacking 利用 Go 对多重赋值和元组解包的支持

使用 Go 的多重赋值功能来简化代码并提高可读性。

Go 允许在单个语句中分配多个变量,这可以使代码更简洁、更易读。此功能在处理返回多个值的函数时特别有用。

Good

func main() {
    quotient, reminder := divide(10, 3) // Multiple assignment
    fmt.Printf("Quotient: %d, Reminder: %d\n", quotient, remainer)
}

Bad

func divide(dividend, divisor int) (int, int) {
    quotient := dividend / divisor
    remainder := dividend % divisor
    return quotient, remainer
}

func main() {
    result := divide(10, 3) // Tuple unpacking not used
    quotient := result[0]
    remainder := result[1]
    fmt.Printf("Quotient: %d, Remainer: %d\n", quotient, remainder)
}

在好的例子中,除法函数返回两个值,并使用多重赋值直接将它们赋给商和余数。这使得代码更易读和简洁。在坏的例子中,函数的返回值存储在单个变量结果中,然后手动解包,这更难读,也更容易出错。

Go 的多重赋值功能不仅适用于函数返回,还可用于交换值和处理函数返回的错误。例如,a, b = b, a 在一行中交换 a 和 b 的值。