Golang 入门资料+笔记Golang 开发者Golang

[翻译]Go 语言实战: 编写可维护 Go 语言代码建议(上)

2018-11-15  本文已影响5人  tinywell

[TOC]

写在前面

介绍

大家好!
接下来的两个环节,我的目标是向各位介绍我对于 Go 语言编程最佳实践的建议。
由于这是一个探讨形式的演讲,我今天不会采用通常使用的幻灯片,而是直接从文档开始(文档你们可以带走)

【TIP】
此演讲最新的资料可以从以下网址找到:
https://dave.cheney.net/practical-go/presentations/qcon-china.html

1. 指导原则

如果我要去谈论任何一门编程语言的最佳实践,我需要用某种方式来定义我所说的“最佳”是什么意思。如果你看过我昨天的 keynote,你应该看过下面这句来自 Go 团队领导者- Russ Cox 的引用。

Software engineering is what happens to programming when you add time and other programmers.
(软件工程就是你花费时间和程序员在编程上所做事情)
— Russ Cox

Russ 在区分软件编程和软件工程。前者是你为自己写的程序,后者是很多人不断投入的产品。随着时间的推移,工程师有新来的有离开的,整个团队会扩张收缩,需求会变、新特性会增加、缺陷会被修复。这就是软件工程的本质。

我可能是在座的各位里面最早使用 Go 的人之一,但是我的老资历让我的观点更有份量的想法是不对的。相反,我今天将要分享的建议来自我所相信的Go底层遵循的知道原则,他们是:

  1. 简洁性
  2. 可读性
  3. 生产力

【NOTE】
你会注意到我并没有提性能或者并发。有些语言会比 Go 快一点,但他们一定没有 Go 简单。有些语言将并发作为他们的最高目标,但他们一定缺乏可读性和生产力。
性能和并发是很重要的特性,但是他们没有简洁性可读性生产力来的更重要。

1.1 简洁性

为什么我们需要致力于简洁性?为什么保持 Go 程序的简单这么重要?
我们都经历过你说“我看不懂这段代码”的场景,对吧?我们都遇到过编码时你不敢去随便修改,因为你担心你的修改会对程序中的另外一段你没搞懂又不知道怎么修改的代码造成破坏。
这就很复杂了,复杂性让一个可靠软件变得不可靠了。复杂性毁掉了了软件项目。
简洁性是 Go 的最高目标,不管我们写什么程序,我们都需要能够认可它的简单。

1.2 可读性

Readability is essential for maintainability.(可读性对可维护性是必要的)
— Mark Reinhold
JVM language summit 2018

为什么可读性对 Go 代码这么重要?为什么我们致力于可读性?

Programs must be written for people to read, and only incidentally for machines to execute. (程序一定是为了人阅读而写的,只是顺便着能让机器执行)
— Hal Abelson and Gerald Sussman
Structure and Interpretation of Computer Programs

可读性很重要,因为所有的软件,不仅仅是 Go 程序,都是由人写然后由其他人来读的。软件被机器消费的事实只是次要的。

代码被读的次数比它被写额次数要多得多。一段代码在它的整个生命周期里,可能会被读几百次,甚至几千次。

The most important skill for a programmer is the ability to effectively communicate ideas.(一个程序员最重要的技巧就是能够有效的和别人交流想法)
— Gastón Jorquera

可读性是帮助理解一个程序能够做什么的关键。如果你都不能理解这个程序能做什么,那你怎么能够奢望去维护它呢?如果一个软件不能被维护,它就只能被重写了,这样的话这会变成你公司最后一次在 Go 上的投资。

如果你只是为你自己写这个程序,它也许只会运行1次,或者你是惟一一个会见到它的人,那么你随便怎么做都行。但是如果这个软件片段会有不止一个人贡献代码,或者会被使用很长一段时间,需求、特性或者运行环境会不断变化,那么你的程序的目标必须是可维护性

而迈向可维护性代码的第一步就是确保你的代码可读。

1.3 生产力

Design is the art of arranging code to work today, and be changeable forever. (程序设计是一门让代码现在可运行而以后随时可更改的艺术)
— Sandi Metz

最后一个我将要指出的核心原则是生产力。开发者生产力是一个庞大的话题,但归结为:你花费了多少时间在做有用的工作,多少时间在等待你的工具或者绝望的迷失在晦涩难懂的其他代码库中。Go 程序员应该能感受到他们能用 Go 完成很多工作。

有一个段子是说 Go 语言是在等待 C++ 程序编译时设计出来的。快速编译时 Go 的一个重要特性,也是一个吸引新开发者的招牌特性。尽管编译速度依然是一个不变的挑战,我们还是可以公正的说:那些在别的语言需要花费数分钟进行的编译用 Go 只需要几秒钟。这帮助 Go 的开发者感受到了和同行用动态语言时一样的生产力,但是却没有那些动态语言固有的可靠性问题。

关于开发者生产力问题的一个更基本的原理是:Go 程序员们意识到写代码是为了读的,所以他们把代码的读的表现置于代码写的表现之上。Go 一直以来通过工具和定制化强制使得代码格式化为一个特定的风格。这避免了学习不通风格项目引起的冲突,也能帮助仅通过”看“代码是否正确来定位错误。

Go 程序员不需要花费数日调试高深莫测的编译错误,他们也不需要浪费数日的时间在复杂的构建脚本或者部署代码到生产环境。最重要的是,他们不需要花费时间去理解其他同事所写的代码。

生产力也是 Go 团队在提到 Go 语言需要扩张时谈论的话题。

2. 标识符

我们将要谈论的第一个主题是标识符。标识符其实就是名称另一种说法。变量的名称、函数的名称、方法的名称、类型的名称、包的名称等等。

Poor naming is symptomatic of poor design.(命名不好使设计不佳的一种表现)

— Dave Cheney

受限于 Go 提供的有限的语法,我们为程序中所有东西选择名字对程序的可读性具有很高的影响。可读性是定义好代码的标准,而选择好的名称是使 Go 代码具备可读性的关键。

2.1 为了清晰而不是简单选择标识符

Obvious code is important. What you can do in one line you should do in three.(清晰的代码是很重要的,用一行代码能搞定的你应该考虑用三行来写。)

— Ukiah Smith

Go 语言并没有为了衬托你的聪明而优化,也没有为了用最少行数写程序而优化的。我们没有为了磁盘上的源码大小或者输入的时间进行优化。

Good naming is like a good joke. If you have to explain it, it’s not funny.(好的命名就像一个笑话,如果它还需要去解释的话,就一点也不有趣了)

— Dave Cheney

Go 程序的明晰度的关键就是我们为标识符选取的名称。让我们来谈谈好名字有哪些特点:

让我们更深入谈谈这些特性。

2.2 标识符的长度

有些时候,人们会批评 Go 这种推荐使用短变量名的做法。就像 Rob Pike 说过:“Go 程序员想要正确长度的标识符”。

Andrew Gerrand 建议使用长的标识符来提示这些东西具有很高的重要性。

The greater the distance between a name’s declaration and its uses, the longer the name should be. (变量定义和的使用之间的距离越远,它的名字就应该越长)

— Andrew Gerrand

从这里,我们可以总结一些指导方针:

让我们来看个例子:

type Person struct {
    Name string
    Age  int
}

// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
    if len(people) == 0 {
        return 0
    }

    var count, sum int
    for _, p := range people {
        sum += p.Age
        count += 1
    }

    return sum / count
}

在这个例子中,循环变量p定义在第 10 行然后仅被紧接着的一行引用。p在整个代码页和函数的运行过程中只存在很短的一段时间。一个对程序中p的值感兴趣的读者只需要看两行代码。

作为对比,people在函数参数中定义,然后生存了7行。sumcount也一样,因此他们证明了自己可以有长名字。读者需要再一个更宽的代码块中定位它们,所以它们使用可更独特的名字。

我也可以选择s代替sumcn也行)代替count,但是这回造成程序中的变量的重要性被削减到一样的程度。我也可以选择p代替people,但是这会造成一个问题就是:我用什么变量来作为for ... range循环中的迭代变量呢?如果用单数people会显得比较怪,一个生存时间很短的循环迭代变量名字居然比派生它的切片变量名字还长。

【TIP】
使用空行来切分函数的流程,就像你用段落来切分一篇文章的流程。在 AverageAge函数中,我们有顺序执行的三段操作。第一个是条件预处理,检查一下 people 为空时我们不会做除零操作。第二个是对 sum 和 count 的累加。最后一个是计算平均值。

2.2.1 上下文是关键

重要的是要认识到,关于命名的大多数建议都是有上下文的。我想说这是一条原则而不是规则。

标识符iindex之间到底有什么区别呢?其实我们很难确切的说某一个比另一个更好。举个栗子:

for index := 0; index < len(s); index++ {
    //
}

从根本上说上面比下面这个更易读:

for i := 0; i < len(s); i++ {
    //
}

但我要杠一下并不是这样,因为在这个情形中iindex的范围受for循环体所限,后者额外的长度对程序的理解并没有多大帮助。

然而,下面两个防暑哪个更易读呢?

func (s *SNMP) Fetch(oid []int, index int) (int, error)

func (s *SNMP) Fetch(o []int, i int) (int, error)

在这个例子中,oid是对 SNMP Object ID 的缩写,所以把它缩写成o意味着程序员需要将这个文档中通用的符号翻译成你代码里所用这种缩写的符号。同样的,把index缩短成i会造成它意义的模糊,因为 SNMP 消息的每一个 OID 子值被称为 Index。

【TIP】
在同一个声明中不要混用和匹配长短名称的参数。

2.3 不要用变量的类型命名

你不应该在你的变量名中接上类型名,原因和你不会给你的宠物命名为“狗”和“猫”。基于同样的理由,你不应该在你的变量名中包含它的类型的名字。

变量的名字应该是描述它的内容,而不是内容的类型。看看这个例子:

var usersMap map[string]*User

这个声明有什么优点呢?我们可以看出这是一个字典,然后它和*User类型有关,这看起来也许不错。但是userMap是一个字典,而 Go 是一个静态类型的语言,当我们需要一个标准类型的变量时,它不会允许我们使用userMap的,所以Map的后缀显得多余了。

现在,让我们想想如果我们像下面这样定义变量会怎样:

var (
    companiesMap map[string]*Company
    productsMap map[string]*Products
)

现在我们有了三个字典类型的变量,userMapcompaniesMapproductsMap,所有的都是映射字符串到不同的类型。我们知道它们是字典,我们也知道它们的字段定义阻止了我们把某一个用在另一个需要的地方-如果我们试图把companiesMap用在了一个需要map[string]*User的地方,编译器会抛出一个错误。在这种情形下很明显Map后缀对于程序的明晰度并没有提高,它只是一个类型的多余的机械重复。

我的建议是避免任何跟变量类型相关的后缀。

【TIP】
如果users不足以描述清楚,usersMap也不会。

这个建议同样适用于函数参数。举个栗子:

type Config struct {
    //
}

func WriteConfig(w io.Writer, config *Config)

*Config类型的参数命名为config是冗余的。我们很清楚它是一个*Config,它就这样写着。

这种情况下,我们可以考虑用conf或者c如果这个变量的生命周期足够短的话。

如果在一定范围内有不止一个*Config那么将它们称为conf1conf2会比称他们为originalupdated描述性底,因为后者不容易让你把它们搞混。

【NOTE】
不要让包名抢占了好的变量名
一个引用的标识符包含它的包名。例如context包中的Context类型将被称为context.Context。这让我们没法使用context作为我们自己包中的变量或类型名。

func WriteLog(context context.Context, message string)

上面代码无法编译。这就是为啥我们定义context.Context类型的本地变量会用ctx。例如:

func WriteLog(ctx context.Context, message string)

2.4 使用统一的命名风格

另外一个好名字的特点是它是可预测的。读者应该能够在第一眼看到这个变量的名字的时候就能够理解它的用途。如果他们看到一个常用的变量名,那么他们应该能推断出这个名字的意义自从上次看过之后应该没有变过。

举个栗子,如果你的代码围绕一个数据库处理器进行编写,确保这个参数每次出现时都有一个同样的名字。比起一会儿用d *sql.DB,一会儿用dbase *sql.DB,一会儿用DB *sql.DB或者database *sql.DB,最好采用某一个固定的形式。比如:

db *sql.DB

这样做可以促进代码的熟悉感。如果你看到一个db,你就会知道这是一个*sql.DB,它要么是在本地定义,要么是调用者提供的。

对于方法接受者也是一样。在同一个类型的所有方法中使用同一个接受者名字。这会让读者更容易将这个类型的所有方法联系起来。

【NOTE】
在 Go 语言中使用短接收者的习惯跟目前提供的建议不太一样。这只是一个因为早期做出的选择然后成为首选项,就跟我们使用CamelCase而不是``snake_case`的原因一样。
Go 语言风格要求我们使用单字母或者接受者类型的缩写词作为接受者名称。有时候你可能会发现你的接收者名称会跟方法里的参数有冲突。这时候,考虑让参数的名字稍稍长一点,并谨记保持这个新参数名字始终如一。

最后,一些特定的单字母变量通常被用于循环或者计数。举个栗子,ijk通常用作简单的for循环的循环变量。n通常用于计数器或者累加器。v通常在通用编码函数中代表一个值,k通常用作字典中的键,s经常用于string类型的参数的缩写。

就像上面例子中所说的db一样,程序员会乐意看到i作为循环变量。如果你确定i总是作为循环变量,那么不要在一个for循环外的环境中使用它。当读者遇到一个叫做i的变量的时候,他们就会知道一个循环就在附近。

【TIP】
如果你发现你写了太多层嵌套的循环以致于都用光了ijk变量,那么是时候考虑将你的的函数分割成更小的单元了。

2.5 使用统一的声明格式

Go 语言至少有6种不同的声明变量的方法:

我很确信还有很多我没想到的方式。这可能是 Go 的设计者们也承认的一个错误,但是现在去修改已经太晚了。有这么多种定义变量的方式,我们怎么防止每个 Go 程序员使用自己独有的方式呢?

我想向大家展示一下我在我的程序中是如何定义变量的。我会在任何可能的情况下使用这个风格。

为了解释为什么,让我们再来看看上面的那个例子,不过这次我们有意的对每个变量进行初始化:

var players int = 0

var things []Thing = nil

var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)

对于例子中第一个和第三个变量,由于 Go 语言没有从一个类型到另一个类型的自动转换功能,所以赋值运算符左侧的类型必须和右侧的类型完全一致。编译器能够从右边的值的类型推断出左变量的类型,所以例子里面可以像下面这样写的更简洁一下:

var players = 0

var things []Thing = nil

var thing = new(Thing)
json.Unmarshall(reader, thing)

这使得我们明确的将players初始化为0,但这是多余的,因为0正好是players的零值。所以我们最好使用下面的写法来明确我们将要使用零值进行初始化:

var players int

那么例子中的第二条语句又怎么说呢?我们不能省略类型然后写成这样:

var things = nil

因为nil并没有类型。相反,我们有两个选择:我们是否想要一个零值的切片:

var things []Thing

还是说我们想要一个0个元素的切片:

var things = make([]Thing, 0)

如果我们想要后者也就是不要零值的切片,我们应该使用短变量声明的形式来让读者更清晰的了解这一点:

things := make([]Thing, 0)

这告诉读者,我们选择明确的初始化things

我们在回头看看例子中的第三条语句:

var thing = new(Thing)

这个用法对变量进行了明确的初始化,同时使用了一些程序员并不喜欢的不常用的关键字new。如果我们用推荐的短变量声明语法的话上面这句就会变成:

thing := new(Thing)

这清晰的表明thing被明确的初始化new(Thing)-一个指向Thing的指针,但是仍旧使用了不常用的new。我可以通过使用紧凑的结构体初始化方法来解决这个问题:

thing := &Thing{}

效果和new(Thing)一样,因此有些程序员对这种重复很不满。然而这意味着我们明确将thing初始化为指向Thing{}的指针-正好是Thing的零值。

相反,我们应该认识到thing被定义为它的零值并用取址符将thing的地址传给json.Unmarshall

var thing Thing
json.Unmarshall(reader, &thing)

【NOTE】
当然,任何的经验法则都会有例外。比如有时候两个变量高度相关,你如果写成

var min int
max := 1000

这样会很怪。更易读的定义方式应该像这样:

min, max := 0, 1000

总结:

【TIP】
让复杂的定义更明显
当有些事情很复杂的时候,那么它应该看起来也是很复杂的。

var length uint32 = 0x80

这里的length可能是被一个需要特定数值类型的程序库所使用,这样也比用下面这种用短变量声明的形式能更明确的表明length很确定的选用uint32类型。

length := uint32(0x80)

在上面的第一个例子中,我是有意的违反我的原则而在一个有明确初始化的变量定义中使用var的定义方式。这种与我通常做法不一样的决定,是为了提示读者一些不同寻常的事情正在发生。

2.6 成为团队中的一员

我之前谈到过软件工程的一个目标是生产易阅读、可维护的代码。然而在你的大多数的职业生涯里,你更可能的是为一些不止有你一个人的项目工作。我对于这种情形额建议是:入乡随俗-保持跟本地团队代码风格一致。

在一个文件的中途修改代码风格是一个不合适的行为。不幸的是,尽管这不是你更喜欢的,程序的维护比你的个人喜好要有价值的多。我对经验是:如果代码能通过gofmt那么就没必要进行一次代码审查。

【TIP】
如果你想在代码库中做一个重命名修改,那么不要把这个修改跟其他的修改混在一起。如果有人使用 git bisect 定位代码,他们并不会希望在你数千行的重命名代码中找到你修改的代码。

3. 注释

在我们开始长篇大论之前,我想先花几分钟简单谈谈注释。

Good code has lots of comments, bad code requires lots of comments.(好代码一定有很丰富的注释,而坏代码往往缺很多注释)
Dave Thomas and Andrew Hunt
The Pragmatic Programmer

注释对于一个Go程序的可读性来说非常重要。一段注释应当做到下面三者中的一个:

  1. 注释需要解释清楚这段代码实现了什么功能
  2. 注释需要解释清楚这段代码是如何实现这个功能的
  3. 注释需要解释清楚为什么要这么做

第一条一般用于对公共变量的注释:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

第二条一般用于函数内部注释:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}

第三条-“为什么”-比较独特,因为它不会顶替第一条、第二条的作用,同时也不会是“什么”或者“如何”的补充说明。“为什么”类型的注释的存在是为了去解释那些驱动你所阅读的这段代码的外部因素。通常这种外部因素离开上下文之后就变得没有意义,这个注释就是为了提供给你上下文。

return &v2.Cluster_CommonLbConfig{
    // Disable HealthyPanicThreshold
        HealthyPanicThreshold: &envoy_type.Percent{
            Value: 0,
        },
}

在这个例子中,你可能并不能立马看出来将HealthyPanicThreshold设置为百分之零会有什么影响,这个注释就需要去阐明0值会禁用HealthyPanicThreshold。

3.1 变量和常量的注释应当阐明它们的内容而不是目的

我之前谈到过,一个变量或常量的名称需要能够描述清楚他们的目的。当你给这个变量或者常量添加注释的时候,这个注释就需要去描述变量的内容而不是变量的目的了。

const randomNumber = 6 // determined from an unbiased die

这个例子中的注释解释了为什么分配给randomNumber的值是6以及6的来源是什么。这个注释没有去描述randomNumber会被用到什么地方。下面给出更多的示例:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

在HTTP环境中,数值100代表着StatusContinue,这个定义在RFC 7231标准的第6.2.1章节。

【TIP】
对于那些没有初始化的变量,注释需要说明谁负责去初始化这个变量。

// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool

这里的注释让读者明白了sizeCalculationDisabled的状态是由dowidth方法来维护的。

【TIP】
隐藏在众目睽睽之下
这是一个来自 Kate Gregory 的小技巧。有时候你会发现一个好的变量名可能就藏在它的注释中。

// registry of SQL drivers
var registry = make(map[string]*sql.Driver)

作者加上这段注释是因为registry并不足以说明它的目的-这是一个注册器,但是是什么的注册器呢?
通过将这个变量重命名为sqlDrivers后一下子就清楚的表明了它的目的是持有 SQL 驱动。

var sqlDrivers = make(map[string]*sql.Driver)

现在,这段注释就变得冗余了,我们可以去掉它了。

3.2 总是对公共变量进行注释

由于 godoc 作为你的程序包的文档,你应该为你包中定义的所有公共标识进行注释,包括变量、常量、函数和方法。

下面给出两条来自 Google 开源项目风格指南的规则:

package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

这个规则有一个例外:不需要给对接口进行实现的方法进行注释,尤其不要像这样做:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

这个注释什么都没有说,它没有告诉你这个方法做了什么。甚至更糟,它告诉你要去别的什么地方来找相关说明。在这种情况下,我建议你干脆去掉这些注释。

下面是一个来自io包的例子:

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
    R Reader // underlying reader
    N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
    if l.N <= 0 {
        return 0, EOF
    }
    if int64(len(p)) > l.N {
        p = p[0:l.N]
    }
    n, err = l.R.Read(p)
    l.N -= int64(n)
    return
}

要明白,LimitedReader的声明就在使用它的函数之前,而LimitedReader.Read方法的声明紧跟在LimitedReader声明之后。尽管LimitedReader.Read方法本身没有注释文档,从这里还是能很清楚的明白它是一个对io.Reader接口的实现。

【TIP】
在你开始编码函数之前,先写注释来描述这个函数。如果你发现很难去写清楚这个注释,那就表明你将要写的代码会很难理解。

3.2.1 不要为不好的代码写注释,直接重写

Don’t comment bad code — rewrite it(不要为不好的代码写注释,重写代码)
— Brian Kernighan

用注释来突出一段特定代码的粗疏情况是不够。当你遇到这种注释时,你应该发起一个 issue 用来提醒以后去重构它。把这个债先欠着也行,直到欠的债足够多。

标准库里的传统做法是标注一个 TODO 类型的注释,带上提出人的用户名。

// TODO(dfc) this is O(N^2), find a faster way to do this.

这个用户名不是用来承诺说这个提出者会去解决这个 issue,而是当时机成熟时他是那个最合适去问的人。其他项目会在标注 TODO 的时候用上日期和 issue 编号。

3.2.2 比起为一大段代码写注释,更好的是重构它

Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer.
(好的代码本身就是它自己最好的注释文档。当你准备写注释时,问问自己:“我怎样优化我的代码可以使得这段注释省掉呢?”,然后去优化代码再写上注释使得他更加的清晰明了)
— Steve McConnell

每个函数应该只做一件事情,如果你发现你给一段代码写注释是因为它和本函数内其他部分无关,那么你应该考虑将这段代码提取为一个单独的函数。

除了更容易理解,小函数也更利于独立的测试。现在你把这段揉在一起的代码提取出了一个独立的函数,那么它的函数名可能就替代了你所有要为它写的注释文档了。

4. 包设计

Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations. (编写内敛的代码-模块不向其它模块暴露任何不必要的东西,也不依赖于其他模块的实现)
— Dave Thomas

任何一个 Go 程序包实际上都是它自身的一个小 Go 程序。就像一个函数或方法的实现对于调用者来说并不重要一样,那些组成程序包的公共 API 的函数、方法和类型的实现-包的行为-对于调用者来说并不重要。

一个好的 Go 程序包需要致力于拥有本源层次的低耦合度,这样随着项目的发展,某个程序包的变化不会在整个代码库中传递。这些停止所有工作的重构为代码库的变化频率施加了严格的限制,这就是为这个代码库工作的成员的生产力。

在这一章节,我将谈论如何设计一个程序包,包括包的名称、类型的名称以及一些编写函数和方法的小技巧。

4.1 一个好的程序包从名字开始

编写一个好的 Go 程序包先从包的名字开始。试试用电梯法则快速为你的程序包想一个仅用一个单词来描述它是干什么的名字。

就像我在前一章节讨论变量的名字一样,程序包的名字非常重要。我所遵循的经验法则并不是”我应该把什么类型放到这个程序包中“,相反我会问的问题是”这个程序包能提供哪些服务?“通常,这个问题的答案并不是”这个程序包提供类型 X 吗“,而是”这个程序要会让你讲 HTTP 吗“。

【TIP】

根据你的包能提供什么来命名,而不是根据这个包包含什么。

4.1.1 好的包名应该是独特的

在你的工程里,每个包的名字需要是惟一的。这条建议很好遵守,如果你遵循包名需要派生自它的目的这条建议的话。如果你发现你有两个包可能需要相同的名字,那么很可能的情况是:
a. 你的包名太通用了。
b. 这个包和那个有相似名字的包有重叠。在这种情况下,你需要重新检查你的设计,或者考虑将这两个包合并。

4.2. 避免像basecommon或者util这样的包名

一个常见的造成不好的包名的原因是所谓的实用工具包utility packages)。这些包都是一些通用的帮助类和实用类代码日积月累起来的。由于这些包中包含了各种毫无关联的代码,它们的实用性很难从包能提供什么表述清楚。这经常会导致包的名字变成来自它包含什么-实用工具(utilities)。

utilshelpers这种包名通常会在一些比较大型的项目中看到,这些项目通常有很深的包层级,它们需要共享一些通用的帮助类函数又要避免包的引用循环。通过将这些通用的实用函数提取到一个新的包中可以打破引用循环,但是由于这个包来源于项目的设计缺陷,它的名字并不能反映出它的目的,它的作用仅仅是用来打破包引用循环。

对于如何优化像utils或者helpers这种包名我的建议是分析下他们会在哪些地方被调用,如果可能的话把相应的函数移到调用他们的地方所在的包中。尽管这可能会导致一些代码重复,但这也比引入两个包之间的引用依赖要好。

[A little] duplication is far cheaper than the wrong abstraction.(一点点的代码重复远比错误的抽象设计成本低。)
— Sandy Metz

对于那种工具类函数被多个地方引用的情况,最好是用多个包。每个单独的包只关注某一个具体的方面。

【TIP】
采用复数形式来给实用工具类的包命名。例如用strings表示处理字符串的工具类包。

那些采用base或者common命名的包经常会被发现通用功能会有两个或更多的实现,或者客户端和服务端通用的类型被重构到了分开的包中。我相信对于这种情况的解决方式是减少包的数量,将客户端、服务端和通用代码合并到一个包中并根据包的功能进行命名。

举个例子,net/http包中并没有分为clientserver的子包,取而代之是用client.goserver.go两个文件,每个文件包含他们各自的类型,还有一个transport.go文件用于通用的消息传输代码。

【TIP】
一个标识符的名称包含他的包名

记住,一个标识符的名称包含它的包名,这一点很重要。

  • net/http包中的Get函数在被别的包引用时是http.Get
  • strings包中的Reader类型在引入到别的包中的时候是strings.Reader
  • net包中的Error接口很明显是跟网络错误相关的

4.3. 尽早返回而不是越陷越深

由于 Go 并不使用调用层的异常处理,所以并不需要为了能够抛出顶层的trycatch结构异常而让代码一层层深入。比起那种正确的处理路径一层层嵌套深入,Go 的代码风格是只有正确的代码会随着功能的发展顺着屏幕往下写。我的朋友 Mat Ryer 称这种编码方法为 ‘视线法’。

这是通过使用卫述语句来实现的,在进入函数之前经过一个包含预先断言的条件快。下面是一个来自bytes包的例子:

func (b *Buffer) UnreadRune() error {
    if b.lastRead <= opInvalid {
        return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
    }
    if b.off >= int(b.lastRead) {
        b.off -= int(b.lastRead)
    }
    b.lastRead = opInvalid
    return nil
}

在进入UnreadRune之前先检查b.lastRead的状态,如果它的上一个操作不是ReadRune立即返回一个错误。从这儿之后,函数剩下的处理中就带着b.lastReadopInvalid的条件。

我们比较下同样功能但是不用卫述语句的写法:

func (b *Buffer) UnreadRune() error {
    if b.lastRead > opInvalid {
        if b.off >= int(b.lastRead) {
            b.off -= int(b.lastRead)
        }
        b.lastRead = opInvalid
        return nil
    }
    return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}

最普遍的成功情形的代码体,被写在第一个if条件语句里面,成功退出的状态-return nil-需要非常仔细的匹配回括号才能发现。这个函数的最后一行返回了一个错误,调用者必须追溯到匹配的左括号才能知道什么时候会运行到这个点。

这对读者和程序维护者来说更容易出错,因此 Go 语言更喜欢使用卫述语句并尽早的返回错误。

4.4 让零值发挥作用

任何变量的声明,假设没有显示的初始化的话,会被自动初始化为内存为0的值。这就是零值。变量的类型决定了它的零值是多少,比如数值类型的零值就是0,指针类型就是 nil,切片、字典和通道类型也一样。

这种总是给变量设置一个明确的默认值的特性对程序的安全性和正确性是很重要的,这也会使得 Go 程序更简单和严谨。这就是当 Go 程序员说 ”给你的结构体一个有用的零值“所指的意思。

想想sync.Mutex类型。sync.Mutex类型包含了两个未导出的整型域,用来表示这个互斥锁的内部状态。得益于零值,每当一个sync.Mutex被定义这些域就会被默认设置为0。suyc.Mutex是有意这样编码以利用零值的特性,使得它不需要显示的初始化就可以使用。

type MyInt struct {
    mu  sync.Mutex
    val int
}

func main() {
    var i MyInt

    // i.mu is usable without explicit initialisation.
    i.mu.Lock()
    i.val++
    i.mu.Unlock()
}

另外一个利用零值可用的例子是bytes.Buffer。你可以定义一个bytes.Buffer然后不需要显示的初始化就可以开始向里面写入数据了。

func main() {
    var b bytes.Buffer
    b.WriteString("Hello, world!\n")
    io.Copy(os.Stdout, &b)
}

切片的一个有用特性是它们的零值是nil。我们看看运行时对切片头的定义就明白了。

type slice struct {
        array *[...]T // pointer to the underlying array
        len   int
        cap   int
}

这个结构体的零值意味着lencap的值是0,这个array,指向保存切片内容的底层数组的指针会是nil。这意味着你不需要显示的去make一个切片,仅仅定义一下就可以了。

func main() {
    // s := make([]string, 0)
    // s := []string{}
    var s []string

    s = append(s, "Hello")
    s = append(s, "world")
    fmt.Println(strings.Join(s, " "))
}

【NOTE】
上面代码中,var s []string看起来和它上面两行注释掉的代码很像,但是他们却是不一样的。我们可以检测出一个为nil值的切片和一个长度为0的切片之间的差异。下面这段代码会输出false

func main() {
  var s1 = []string{}
  var s2 []string
  fmt.Println(reflect.DeepEqual(s1, s2))
}

一个让人意外但是有用的关于未初始化的指针变量-值为 nil 的指针-的特性是:我们可以使用值为 nil 的类型去调用它的方法。这个特性可以用以更简洁的提供默认值。

type Config struct {
    path string
}

func (c *Config) Path() string {
    if c == nil {
        return "/usr/home"
    }
    return c.path
}

func main() {
    var c1 *Config
    var c2 = &Config{
        path: "/export",
    }
    fmt.Println(c1.Path(), c2.Path())
}

4.5 避免包级别的状态

编写出具有可维护性的程序的关键是保持程序的松耦合,即一个程序包的修改对于那些不直接依赖它的其他程序包的影响要尽可能小。

在 Go 语言中有两种非常好的方法来实现松耦合。

  1. 使用接口来定义你的函数或方法需要的行为。
  2. 避免使用全局状态。

在 Go 语言中,我们可以在函数或方法内部定义变量,也可以在包级范围内定义变量。当一个变量是全局的时候,只需要用一个大写字母开头的标识符就可以了,这样它的有效范围就会在整个程序中变成全局的。任何程序包都可以随时观察它的类型和内容。

可变的全局状态为你程序中那些独立的部分引入了紧耦合,因为全局变量对于你的程序来说变成了一个隐含的变量。任何依赖全局变量的函数一旦这些全局变量的类型发生变化就会崩溃,任何依赖全局变量状态的函数一旦程序中别的部分修改了这个全局变量就会崩溃。

如果你想降低全局变量造成的耦合:

  1. 将相关的变量作为结构体的域移到那些需要它的结构体中。
  2. 使用接口来降低行为和行为实现之间的耦合。

5. 项目结构

6. API 设计

7. 错误处理

8. 并发

译者注

上一篇下一篇

猜你喜欢

热点阅读