译文 Go 语言中常见的几种反模式

bigwhite-github · 2021年03月31日 · 372 次阅读
本帖已被设为精华帖!

本文翻译自 Saif Sadiq 的文章《Common anti-patterns in Go》

众所周知,编码是一门艺术,就像每个拥有精湛艺术并为之感到骄傲的工匠一样,我们作为开发人员也为我们编写的代码感到自豪。为了获得最佳效果,艺术家不断寻找可提高其手艺的方法和工具。同样,作为开发人员,我们也在不断提高自己的技能,并对"如何写出好的代码"这个最重要的问题的答案保持好奇。

弗雷德里克·布鲁克斯(Frederick P. Brooks)在他的书《人月神话》中写道:

“程序员和诗人一样,工作时只是稍稍脱离了纯粹的思维定式。他在空气中建造他的城堡,通过发挥想象力进行创作。很少有一种创作媒介是如此灵活,如此容易打磨和重做,如此容易实现宏大的概念结构”。

图片来源:https://xkcd.com/844

这篇文章试图探索上面漫画中大问号的答案。编写良好代码的最简单方法是避免在我们编写的代码中包含反模式。

0. 什么是反模式

一个简单的反模式示例就是编写一个 API,而无需考虑该 API 的使用者如何使用它,如下面的示例 1 所述。意识到反模式并有意识地避免在编程时使用它们,这无疑是朝着更具可读性和可维护性的代码库迈出的重要一步。在本文中,让我们看一下 Go 中一些常见的反模式。

当编写代码时没有未来的因素做出考虑时,就会出现反模式。反模式最初可能看起来是一个适当的问题解决方案,但是,实际上,随着代码库的扩大,这些反模式会变得模糊不清,并给我们的代码库添加 “技术债务”。

反模式的一个简单例子是,在编写 API 时不考虑 API 的消费者如何使用它,就如下面例 1 那样。意识到反模式,并在编程时有意识地避免使用它们,肯定是迈向更可读和可维护的代码库的重要一步。在这篇文章中,我们来看看 Go 中常见的几种反模式。

1. 从导出函数 (exported function) 返回未导出类型 (unexported type) 的值

在 Go 中,要导出 (export) 任何一个字段 (field) 或变量 (variable),我们都需要确保其名称是以大写字母开头。导出 (export) 它们的动机是使它们对其他包可见。例如,如果要使用 math 包中的 Pi 函数,我们将其定义为 math.Pi。而使用 math.pi 将无法正常工作,并且会报错。

以小写字母开头的名称(结构字段,函数或变量)不会被导出,并且仅在定义它们的包内可见。

使用返回未导出类型值的导出函数或方法可能会令人沮丧,因为其他包中的该函数的调用者将不得不再次定义一个类型才能使用它。

// 反模式
type unexportedType string

func ExportedFunc() unexportedType { 
    return unexportedType("some string")
} 

// 推荐
type ExportedType string
func ExportedFunc() ExportedType { 
    return ExportedType("some string")
}

2. 空白标识符的不必要使用

在各种情况下,将值赋值给空白标识符是不需要,也没有必要的。如果在 for 循环中使用空白标识符,Go 规范中提到:

如果最后一个迭代变量是空白标识符,则 range 子句等效于没有该标识符的同一子句。

// 反模式
for _ = range sequence { 
    run()
} 
x, _ := someMap[key] 
_ = <-ch 

// 推荐
for range something { 
    run()
} 

x := someMap[key] 
<-ch

3. 使用循环/多次 append 连接两个切片

将多个切片附加到一个切片时,无需遍历切片并一个接一个地附加 (append) 每个元素。相反,使用一个 append 语句执行此操作会更好,更有效率。

例如,下面的代码段通过迭代遍历元素逐个附加元素来连串连接 sliceOne 和 sliceTwo:

for _, v := range sliceTwo { 
    sliceOne = append(sliceOne, v)
}

但是,由于我们知道 append 是一个变长参数函数,我们可以使用零个或多个参数来调用它。因此,可以仅使用一个 append 函数调用来以更简单的方式重写上面的示例,如下所示:

sliceOne = append(sliceOne, sliceTwo…)

4. make 调用中的冗余参数

该 make 函数是一个特殊的内置函数,用于分配和初始化 map、slice 或 chan 类型的对象。为了使用 make 初始化切片,我们必须提供切片的类型、切片的长度以及切片的容量作为参数。在使用 make 初始化 map 的情况下,我们需要传递 map 的大小作为参数。

但是,make 的这些参数已经具有默认值:

  • 对于 channel,缓冲区容量默认为零(不带缓冲)。
  • 对于 map,分配的大小默认为较小的起始大小。
  • 对于切片,如果省略容量,则容量参数的值默认为与长度相等。

所以,

ch = make(chan int, 0)
sl = make([]int, 1, 1)

可以改写为:

ch = make(chan int)
sl = make([]int, 1)

但是,出于调试或方便数学计算或平台特定代码的目的,将具名常量与 channel 一起使用不被视为反模式。

const c = 0
ch = make(chan int, c) // 不是反模式

5. 函数中无用的 return

return 在没有返回值的函数中作为最终语句不是一种好习惯。

// 没用的return,不推荐
func alwaysPrintFoofoo() { 
    fmt.Println("foofoo") 
    return
} 

// 推荐
func alwaysPrintFoo() { 
    fmt.Println("foofoo")
}

但是,具名返回值的 return 不应与无用的 return 相混淆。下面的 return 语句实际上返回了一个值。

func printAndReturnFoofoo() (foofoo string) { 
    foofoo := "foofoo" 
    fmt.Println(foofoo) 
    return
}

6. switch 语句中无用的 break 语句

在 Go 中,switch 语句不会自动 fallthrough。在像 C 这样的编程语言中,如果前一个 case 语句块中缺少 break 语句,则执行将进入下一个 case 语句中。但是,人们发现,fallthrough 的逻辑在 switch-case 中很少使用,并且经常会导致错误。因此,包括 Go 在内的许多现代编程语言都将 switch-case 的默认逻辑改为不 fallthrough。

因此,在一个 case case 语句中,不需要将 break 语句作为最终语句。以下两个示例的行为相同。

反模式:

switch s {
case 1: 
    fmt.Println("case one") 
    break
case 2: 
    fmt.Println("case two")
}

好的模式:

switch s {
case 1: 
    fmt.Println("case one")
case 2: 
    fmt.Println("case two")
}

但是,为了在 Go 中 switch-case 中实现 fallthrough 机制,我们可以使用 fallthrough 语句。例如,下面给出的代码段将打印 23。

switch 2 {
case 1: 
    fmt.Print("1") 
    fallthrough
case 2: 
    fmt.Print("2") 
    fallthrough
case 3: fmt.Print("3")
}

7. 不使用辅助函数执行常见任务

对于一组特定的参数,某些函数具有一些特定表达方式,可以用来简化效率,并带来更好的理解/可读性。

例如,在 Go 中,要等待多个 goroutine 完成,可以使用 sync.WaitGroup。通过将计数器的值-1 直至 0,以表示所有 goroutine 都已经执行完毕:

wg.Add(1) // ...some code
wg.Add(-1)

但使用 sync 包提供的辅助函数 wg.Done() 可以使代码更简单并容易理解。因为它本身会通知 sync.WaitGroup 所有 goroutine 即将完成,而无需我们手动将计数器减到 0。

wg.Add(1)
// ...some code
wg.Done()

8. nil 切片上的冗余检查

nil 切片的长度为零。因此,在计算切片的长度之前,无需检查切片是否为 nil 切片。

例如,下面的 nil 检查是不必要的。

if x != nil && len(x) != 0 { // do something
}

上面的代码可以省略 nil 检查,如下所示:

if len(x) != 0 { // do something
}

9. 太复杂的函数字面量

可以删除仅调用单个函数且对函数内部的值没有做任何修改的函数字面量,因为它们是多余的。可以改为在外部函数直接调用被调用的内部函数。

例如:

fn := func(x int, y int) int { return add(x, y) }

可以简化为:

add(x, y)

译注:原文少了简化后的代码,这里根据译者的理解补充的。

10. 使用仅有一个 case 语句的 select 语句

select 语句使 goroutine 等待多个通信操作。但是,如果只有一个 case 语句,实际上我们不需要使用 select 语句。在这种情况下,使用简单 send 或 receive 操作即可。如果我们打算在不阻塞地发送或接收操作的情况处理 channel 通信,则建议在 select 中添加一个 default case 以使该 select 语句变为非阻塞状态。

// 反模式
select {
    case x := <-ch: fmt.Println(x)
} 

// 推荐
x := <-ch
fmt.Println(x)

使用 default:

select {
    case x := <-ch: 
        fmt.Println(x)
    default: 
        fmt.Println("default")
}

11. context.Context 应该是函数的第一个参数

context.Context 应该是第一个参数,一般命名为 ctx.ctx 应该是 Go 代码中很多函数的(非常)常用参数,由于在逻辑上把常用参数放在参数列表的第一个或最后一个比较好。为什么这么说呢?因为它的使用模式统一,可以帮助我们记住包含该参数。在 Go 中,由于变量可能只是参数列表中的最后一个,因此建议将 context.Context 作为第一个参数。各种项目,甚至 Node.js 等都有一些约定,比如错误先回调。因此,context.Context 应该永远是函数的第一个参数,这是一个惯例。

// 反模式
func badPatternFunc(k favContextKey, ctx context.Context) {    
    // do something
}

// 推荐
func goodPatternFunc(ctx context.Context, k favContextKey) {    
    // do something
}

Go 技术专栏 “改善 Go 语⾔编程质量的 50 个有效实践” 正在慕课网火热热销中!本专栏主要满足>广大 gopher 关于 Go 语言进阶的需求,围绕如何写出地道且高质量 Go 代码给出 50 条有效实践建议,上线后收到一致好评!欢迎大家订 阅!

我的网课 “Kubernetes 实战:高可用集群搭建、配置、运维与应用” 在慕课网热卖>中,欢迎小伙伴们订阅学习!

Gopher Daily(Gopher 每日新闻) 归档仓库 - https://github.com/bigwhite/gopherdaily

我的联系方式:

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 04月01日 02:52
DennisMao GoCN 每日新闻 (2021-04-01) 中提及了此贴 04月01日 05:49
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册