6 编写易读的Go代码
# 6 编写易读的Go代码
学习如何编写易读的Go代码是掌握这门语言的重要部分。Go语言的开发者们借鉴了他们编写其他语言的经验,打造出了一门他们认为清晰简洁的语言。在描述使用这门语言的正确编程方式时,有个常用的说法叫 “地道的Go语言风格(idiomatic Go)” 。这个说法用于描述用Go进行编程的正确方法。风格通常是主观的,但Go语言团队努力以一种明确的方式设计这门语言,以提高开发者的开发速度、代码可读性以及协作效率。在本章中,我们将讨论如何遵循Go语言的一些核心原则:
- 简洁性
- 可读性
- 包的使用
- 命名
- 格式化
- 接口
- 方法
- 继承
- 反射
理解这些模式和习惯用法将帮助你编写更易于团队之间阅读和操作的Go代码。能够编写地道的Go代码有助于提高代码质量,并保证项目开发的进度。
# 保持Go语言代码的简洁性
Go语言从一开始就没有遵循其他编程语言使用的特定模式。为了保持语言的简洁明了,对于以下一些语言结构,Go语言的开发者选择了不同的习惯用法。对语言开发者来说,保持Go语言的简洁性并非易事。在确保具备工具支持、丰富的库、快速执行和编译速度的同时,还要维持语言的简洁性,这一直是Go语言开发的首要目标。Go语言开发者通过 “共识设计” 模式来推进这些决策,即在添加语言特性时达成普遍共识,以确保这些特性的添加方式对语言的发展至关重要。
Go语言的维护者们活跃于GitHub的问题页面,如果你提交了拉取请求,他们很乐意进行审核。从其他Go语言开发者那里获得反馈,能帮助语言维护者在为语言添加新特性和功能的同时,保持代码的可读性和简洁性。
下一节将介绍Go语言的另一个基本特性:可读性。
# 保持Go语言代码的可读性
可读性是Go语言的另一核心原则。能够快速理解一个新的代码库并掌握其细微之处,对任何编程语言来说都很重要。随着分布式系统的不断发展,第三方库和应用程序编程接口(API)越来越常见,能够轻松阅读引入的代码并理解其含义,有助于项目的推进。这也使得修复有问题的代码变得更加容易 。
具体的数据类型、接口、包、并发机制、函数和方法,这些都推动着Go语言不断向前发展。可读性是长期维护大型代码库的重要因素之一,也是Go语言与其他竞争对手相比的一大优势。Go语言在设计时就将可读性放在了重要位置。
Go语言有许多看似复杂的底层内部结构,但实际上并非如此。像简单定义的常量、接口、包、垃圾回收机制以及易于实现的并发功能,这些在底层其实都很复杂,但对终端用户来说却是透明的。这些特性的存在促使Go语言蓬勃发展。
接下来,让我们了解一下Go语言中包的概念。
# 探索Go语言中的包
包是Go语言的基础组成部分。每个Go程序都必须在程序的第一行声明包。这有助于提高代码的可读性、可维护性、可引用性和组织性。
Go程序中的主包使用main
声明。这个main
声明会调用程序的主函数。之后,在主函数中可以通过导入其他包来使用它们。我们应尽量保持主包的简洁,以便将程序中的所有依赖进行模块化管理。接下来,我们将讨论包的命名规则。
# 包的命名
在给包命名时,开发者应遵循以下规则:
- 包名不应包含下划线、连字符或混合大小写。
- 包名不应采用通用的命名方式,如
common
、util
、base
或helper
等。 - 包名应与包所执行的功能相关。
- 包应具有适当的作用域,包中的所有元素应具有相似的目标和功能。
- 在将新包整合到公共API之前进行审查时,使用内部包会有所帮助。
# 包的布局
在讨论Go程序的布局时,我们有一些需要遵循的流程。一种常见的约定是将主程序放在名为cmd
的文件夹中。从主函数调用执行的其他包应放在pkg
目录下。这种分离有助于提高包的可重用性。在下面的示例中,如果我们想在命令行界面(CLI)和Web主程序中都重用notification
包,通过一次导入就能轻松实现。下面的截图展示了这种分离:
Go语言中的一种反模式是包与文件一一对应。我们编写Go代码时,应在特定的目录结构中考虑驱动常见用例。例如,我们可以为每个文件创建一个单独的目录并进行测试,如下所示:
然而,我们应该按如下方式创建包:
所有这些不同的通知策略都有一个共同的实践方法。我们应尽量将相似的功能组合在同一个包中。这将有助于其他人理解notification
包中具有相似功能的上下文。
# 内部包
许多Go程序会使用内部包的概念,以表示尚未准备好供外部使用的应用程序编程接口(API)。内部包的概念在Go 1.4版本中首次引入,用于在程序组件周围设置边界。这些内部包不能从其所在子树之外的地方导入。如果你想维护内部包,且不将其暴露给程序的其他部分,这种方式就很有用。一旦你以自己认为合适的方式审查了内部包,就可以更改文件夹名称,将之前的内部包暴露出来。
让我们看一个示例:
在前面的示例中,我们可以看到有一个internal
目录。这个目录只能在该项目内部访问。然而,pkg
和cmd
目录可以从其他项目访问。这一点很重要,因为在我们持续开发新产品和功能时,有些内容可能还不适合在其他项目中导入使用。
# 供应商目录(Vendor directory)
供应商目录的概念始于Go 1.5版本的发布。vendor
文件夹用于将外部和内部源代码的编译版本存储在项目内的一个目录中。这意味着代码编写者不再需要将依赖包复制到源代码树中。当GOPATH
查找依赖项时,会搜索vendor
文件夹。这样做有很多好处:
- 我们可以在项目中保留外部依赖的本地副本。如果我们想在网络连接受限或无网络连接的环境中运行程序,这会很有帮助。
- 这能加快Go程序的编译速度。在本地存储所有这些供应商依赖项意味着我们在构建时无需下载依赖。
- 如果你想使用第三方代码,但又针对特定用例进行了调整,那么可以将该代码放入
vendor
文件夹,并在内部发布时进行修改。
# Go模块(Go modules)
Go模块在Go 1.11版本中被引入。它们能够在Go代码库中跟踪有版本号的依赖项。Go模块是一组Go包,作为一个整体存储在项目目录中的go.mod
文件里。
我们可以通过以下步骤初始化一个新模块:
- 首先执行
go mod init repository
:
go mod init github.com/bobstrecansky/HighPerformanceWithGo
go: creating new go.mod: module github.com/bobstrecansky/HighPerformanceWithGo
2
- 初始化新模块后,你可以像往常一样构建和执行Go包。项目中导入的所有模块都会保存在项目目录下的
go.mod
文件中。
例如,如果我们想使用Gin框架(https://github.com/gin-gonic/gin)创建一个简单的Web服务器,我们可以在项目结构中按如下方式创建一个目录:
/home/bob/git/HighPerformanceWithGo/6-composing-readable-go-code/goModulesExample
- 接下来,创建一个简单的Web服务器,当接收到对
/foo
的请求时,返回包含bar
的响应:
package main
import "github.com/gin-gonic/gin"
func main() {
server := gin.Default()
server.GET("/foo", func(c *gin.Context) {
c.JSON(200, gin.H{
"response": "bar",
})
})
server.Run()
}
2
3
4
5
6
7
8
9
10
11
12
13
- 之后,我们可以在新创建的目录中创建一个新的Go模块:
- 接着,我们可以执行Go程序,必要的依赖项会被自动引入:
现在我们可以看到,简单Web服务器的依赖项存储在项目目录下的go.sum
文件中(我使用了head
命令将列表截断为前10项):
Go模块有助于保持Go仓库中供应商依赖项(vendored items)的整洁和一致性。如果我们希望所有依赖项都在项目本地,也可以使用供应商仓库(vendored repository)。
对于在仓库中使用供应商依赖项,人们的看法往往大相径庭。一些人喜欢使用供应商仓库,因为它可以减少构建时间,并降低无法从外部仓库拉取包的风险。而另一些人则认为,使用供应商依赖项会阻碍包的更新和安全补丁的应用。是否在程序中选择使用供应商目录(vendored directory)取决于你自己,但Go模块在语言中包含这一功能,确实为我们提供了便利。如下输出说明了这一点:
能够使用内置编译工具管理供应商目录,使得设置和配置都变得很容易。
在下一节中,我们将讨论Go语言中的命名规范。
# 理解Go语言中的命名
为了保持代码的可读性和可维护性,Go语言程序员通常会遵循许多一致的做法。Go语言的命名方案往往具有一致性、准确性和简洁性。在命名时,我们应该牢记以下习惯用法:
- 迭代器的局部变量应简短且简单:
- 用
i
表示单个迭代器;如果是二维迭代器,则用i
和j
。 - 用
r
表示读取器(reader)。 - 用
w
表示写入器(writer)。 - 用
ch
表示通道(channels)。
- 用
- 全局变量名应简短且具有描述性:如
RateLimit
、Log
、Pool
。 - 首字母缩写词应遵循全部大写的惯例:如
FooJSON
、FooHTTP
。 - 避免与包名重复:例如,使用
log.Error()
,而不是log.LogError()
。 - 只有一个方法的接口应遵循方法名加
-er
后缀的命名方式:如Stringer
、Reader
、Writer
、Logger
。 - Go语言中的名称应遵循帕斯卡命名法(Pascal case)或混合大小写(mixedCaps)的方式:如
var ThingOne
、var thingTwo
。
需要注意的是,如果一个名称以大写字母开头,它就是导出的,可以在其他函数中使用。在制定自己的命名方案时,请记住这一点。
遵循这些命名约定有助于编写可读性强、易于使用且可复用的代码。另一个好的做法是使用一致的命名风格。如果你实例化相同类型的参数,请确保它遵循一致的命名约定。这将使新的代码使用者更容易理解你编写的代码。
在下一节中,我们将讨论Go语言代码的格式化。
# 理解Go语言中的格式化
正如第1章“Go语言性能入门”中提到的,gofmt
是一个用于Go语言代码格式化的工具,它有着特定的格式化风格。它会按照语言维护者期望的阅读方式对代码进行缩进和对齐。如今,许多流行的代码编辑器都可以在保存文件时执行gofmt
命令。这样做,再加上持续集成软件的验证,你就无需过多关注正在编写的代码的格式,因为语言本身对输出格式有明确规定。使用这个工具可以使Go代码在多人协作时更易于阅读、编写和维护。它还消除了语言中关于空格、制表符和大括号位置的诸多争议,因为这些都是自动设置的。
我们还可以在Git仓库的.git/hooks/pre - commit
文件中添加一个提交前钩子(pre - commit hook),以确保提交到仓库的所有代码都符合预期的格式。以下代码块展示了这一过程:
#!/bin/bash
FILES=$(/usr/bin/git diff --cached --name-only --diff-filter=dr | grep '\.go$')
[ -z "$FILES" ] && exit 0
FORMAT=$(gofmt -l $FILES)
[ -z "$FORMAT" ] && exit 0
echo >&2 "gofmt should be used on your source code. Please execute:"
for gofile in $FORMAT; do
echo >&2 " gofmt -w $PWD/$gofile"
done
exit 1
2
3
4
5
6
7
8
9
10
添加这个提交前钩子后,我们可以通过在仓库中的一个文件中添加一些错误的空格来确认它是否正常工作。当我们这样做并执行git commit
提交代码时,会看到如下警告信息:
git commit -m "test"
//gofmt should be used on your source code. Please execute:
gofmt -w /home/bob/go/example/badformat.go
2
3
gofmt
还有一个不太为人所知但非常有用的simplify
方法,它会在可能的情况下对源代码进行转换。它会处理一些复合、切片和范围复合字面量,并缩短它们。simplify
格式化命令会对以下代码进行处理:
package main
import "fmt"
func main() {
var tmp = []int{1, 2, 3}
b := tmp[1:len(tmp)]
fmt.Println(b)
for i, _ := range tmp {
fmt.Println(tmp[i])
}
}
2
3
4
5
6
7
8
9
10
11
12
使用gofmt -s gofmtSimplify.go
命令处理后,代码会简化为:
package main
import "fmt"
func main() {
var tmp = []int{1, 2, 3}
b := tmp[1:]
fmt.Println(b)
for i := range tmp {
fmt.Println(tmp[i])
}
}
2
3
4
5
6
7
8
9
10
11
12
请注意,上述代码片段中的变量b
定义变得更简单了,并且range
定义中的空变量被gofmt
工具删除了。这个工具可以帮助你在仓库中编写更清晰定义的代码。它还可以作为一种编写代码的机制,让编写者可以先按自己的思路编写代码,而经过gofmt
处理后的代码则可以以紧凑的形式存储在共享仓库中。
在下一节中,我们将讨论Go语言中的接口。
# Go语言接口简介
Go语言的接口系统与其他语言的接口系统不同。接口是方法的命名集合。接口在编写可读性强的Go代码中非常重要,因为它们使代码具有可扩展性和灵活性。接口还使我们能够在Go语言中实现多态性(为不同类型的对象提供统一的接口)。接口的另一个优点是它们是隐式实现的——编译器会检查特定类型是否实现了特定接口。
我们可以如下定义一个接口:
type example interface {
foo() int
bar() float64
}
2
3
4
如果我们想要实现一个接口,只需要实现接口中引用的方法即可。编译器会验证接口的方法,因此我们无需手动进行这项操作。
我们还可以定义一个空接口,即没有任何方法的接口,用interface{}
表示。空接口在Go语言中很有价值且很实用,因为我们可以向它们传递任意值,如下代码块所示:
package main
import "fmt"
func main() {
var x interface{}
x = "hello Go"
fmt.Printf("(%v, %T)\n", x, x)
x = 123
fmt.Printf("(%v, %T)\n", x, x)
x = true
fmt.Printf("(%v, %T)\n", x, x)
}
2
3
4
5
6
7
8
9
10
11
12
13
当我们执行这个空接口示例时,可以看到,随着我们改变(最初为空的)接口的定义,x
接口的类型和值也会发生变化:
空的、可变的接口很方便,因为它们为我们提供了灵活性,使我们能够以对代码编写者有意义的方式操作数据。
在下一节中,我们将讨论理解Go语言中的方法。
# 理解Go语言中的方法
Go语言中的方法是一种特殊类型的函数,它有一个被称为接收者(receiver)的特殊类型,位于function
关键字和与之相关的方法名之间。Go语言不像其他编程语言那样有类的概念。结构体(Structs)通常与方法结合使用,以便以类似于其他语言中类的构造方式,将数据及其相应的方法组合在一起。当我们实例化一个新方法时,可以添加结构体值来丰富函数调用。
我们可以如下实例化一个结构体和一个方法:
package main
import "fmt"
type User struct {
uid int
name string
email string
phone string
}
func (u User) displayEmail() {
fmt.Printf("User %d Email: %s\n", u.uid, u.email)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
完成上述定义后,我们可以使用这个结构体和方法来显示用户信息,如下所示:
func main() {
userExample := User{
uid: 1,
name: "bob",
email: "bob@example.com",
phone: "123-456-7890",
}
userExample.displayEmail()
}
2
3
4
5
6
7
8
9
这将返回userExample.displayEmail()
的结果,在方法调用时,它会打印出结构体中相关部分的内容,如下所示:
当我们处理更大的数据结构体时,能够轻松有效地引用这些结构体中存储的数据。如果我们决定编写一个方法来查找终端用户的电话号码,利用现有的数据类型并编写一个类似于displayEmail
的方法来返回终端用户的电话号码是很简单的。
到目前为止,我们看到的方法都只有值接收者(value receiver)。方法也可以有指针接收者(pointer receiver)。当你希望就地更新数据,并使调用函数能够获取更新结果时,指针接收者会很有帮助。
考虑对前面的示例进行一些修改。我们将编写两个方法,一个用于更新用户的电子邮件地址,另一个用于更新用户的电话号码。电子邮件地址更新方法将使用值接收者,而电话号码更新方法将使用指针接收者。
我们在下面的代码块中创建这些函数,以便能够轻松更新终端用户的信息:
package main
import "fmt"
type User struct {
uid int
name string
email string
phone string
}
func (u User) updateEmail(newEmail string) {
u.email = newEmail
}
func (u *User) updatePhone(newPhone string) {
u.phone = newPhone
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
接下来,我们在main
函数中创建示例终端用户,如下代码块所示:
func main() {
userExample := User{
uid: 1,
name: "bob",
email: "bob@example.com",
phone: "123-456-7890",
}
2
3
4
5
6
7
然后,我们在下面的代码块中更新终端用户的电子邮件和电话号码:
userExample.updateEmail("bob.strecansky@example.com")
(userExample).updatePhone("000-000-0000")
fmt.Println("Updated User Email: ", userExample.email)
fmt.Println("Updated User Phone: ", userExample.phone)
}
2
3
4
5
从结果输出中可以看到,从接收者的角度来看,用户的电子邮件没有更新,但电话号码更新了:
在方法调用中尝试修改状态时,记住这一点很重要。方法在Go程序中处理数据时非常有用。
现在,让我们来了解一下Go语言中的继承是怎么回事。
# 理解Go语言中的继承
Go语言没有继承机制。在Go中,通过组合(composition)的方式将不同的元素(主要是结构体)相互嵌套。当你有一个基础结构体用于多种不同的功能,而其他结构体基于这个初始结构体构建时,这种方式就很方便。
我们可以通过描述厨房里的一些物品来说明继承在Go语言中的实现方式。
我们可以按照下面的代码块来初始化程序。在这个代码块中,我们创建了两个结构体:
Utensils
:用于表示我厨房里抽屉中的餐具。Appliances
:用于表示我厨房里的电器。
package main
import "fmt"
func main() {
type Utensils struct {
fork string
spoon string
knife string
}
type Appliances struct {
stove string
dishwasher string
oven string
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
接下来,我们可以使用Go语言的嵌套结构体来创建一个Kitchen
结构体,它包含所有的餐具和电器,如下所示:
type Kitchen struct {
Utensils
Appliances
}
2
3
4
然后,我们可以用我拥有的餐具和电器来填充Kitchen
结构体:
bobKitchen := new(Kitchen)
bobKitchen.Utensils.fork = "3 prong"
bobKitchen.Utensils.knife = "dull"
bobKitchen.Utensils.spoon = "deep"
bobKitchen.Appliances.stove = "6 burner"
bobKitchen.Appliances.dishwasher = "3 rack"
bobKitchen.Appliances.oven = "self cleaning"
fmt.Printf("%+v\n", bobKitchen)
}
2
3
4
5
6
7
8
9
完成这些操作后,我们可以看到输出结果,其中我厨房里的物品(餐具和电器)都被组织在Kitchen
结构体中。之后在其他方法中也可以很方便地引用这个Kitchen
结构体。
嵌套结构体对于未来的扩展非常实用。如果我决定在这个结构中添加房子里的其他元素,我可以创建一个House
结构体,并将Kitchen
结构体嵌套在House
结构体中。我还可以为房子里的其他房间创建结构体,并将它们添加到House
结构体中。
在下一节中,我们将探索Go语言中的反射(reflection)。
# 探索Go语言中的反射
Go语言中的反射是一种元编程形式。通过使用反射,程序能够理解自身的结构。有时,你可能希望在运行时使用程序编写时不存在的变量。我们使用反射来检查存储在接口变量中的键值对。反射的使用并不总是那么直观,所以要谨慎使用 —— 应该只在必要的特殊情况下使用。它只在运行时进行检查(而非编译时检查),所以我们需要合理地使用反射。
需要记住,Go语言中的变量是静态类型的。在Go中,我们可以使用多种不同的变量类型,如符文(rune)、整数(int)、字符串(string)等等。我们可以如下声明特定类型的变量:
type foo int
var x int
var y foo
2
3
变量x
和y
都是int
类型的变量。
为了获取相关信息,反射有三个重要的组成部分:
- 类型(Types)
- 种类(Kinds)
- 值(Values)
这三个不同的部分协同工作,来推断你可能需要了解的与接口相关的信息。让我们分别来看一下它们,了解它们是如何协同工作的。
# 类型
在Go语言中,能够判断变量的类型非常重要。在我们的示例中,我们可以验证一个字符串类型的变量确实是字符串,如下代码块所示:
package main
import (
"fmt"
"reflect"
)
func main() {
var foo string = "Hi Go!"
fooType := reflect.TypeOf(foo)
fmt.Println("Foo type: ", fooType)
}
2
3
4
5
6
7
8
9
10
11
12
13
程序的输出将向我们展示,反射类型能够准确地推断出foo
的字符串类型:
虽然这个示例很简单,但理解其底层原理很重要:如果不是验证字符串,而是处理传入的网络调用、外部库调用的返回值,或者尝试构建一个能够处理不同类型的程序,反射库中的TypeOf
定义可以帮助我们正确识别这些类型。
# 种类
种类(Kind)用作占位符,用于定义特定类型所代表的类型种类。它用于表示类型的构成。这在确定已定义的结构类型时非常有用。让我们看一个示例:
package main
import (
"fmt"
"reflect"
)
func main() {
i := []string{"foo", "bar", "baz"}
ti := reflect.TypeOf(i)
fmt.Println(ti.Kind())
}
2
3
4
5
6
7
8
9
10
11
12
在我们的示例中,我们创建了一个包含foo
、bar
和baz
的字符串切片。从这里开始,我们可以使用反射来查找i
的类型,并使用Kind()
函数来确定这个类型的构成 —— 在我们的例子中,它是一个切片,如下所示:
如果我们想要推断特定接口的类型,这会很有用。
# 值
反射中的值(Values)有助于读取、设置和存储特定变量的结果。在下面的示例中,我们可以看到,我们设置了一个示例变量foo
,并使用反射包在打印语句中推断出这个示例变量的值,如下所示:
package main
import (
"fmt"
"reflect"
)
func main() {
example := "foo"
exampleVal := reflect.ValueOf(example)
fmt.Println(exampleVal)
}
2
3
4
5
6
7
8
9
10
11
12
从输出中,我们可以看到示例变量foo
的值被返回:
反射系统中的这三个不同函数帮助我们推断出可以在代码库中使用的类型。
# 总结
在本章中,我们学习了如何运用Go语言的一些核心原则来编写易读的代码。我们了解了简单性和可读性的重要性,以及包(package)、命名和格式对于编写易读Go代码的关键作用。此外,我们还学习了接口(interface)、方法、继承(在Go语言中通过组合实现类似功能)和反射如何用于编写易于他人理解的代码。能够有效地运用这些Go语言的核心概念将帮助你编写出更高效的代码。
在下一章中,我们将学习Go语言中的内存管理,以及如何优化我们手头的内存资源。