侧边栏壁纸
博主头像
雲先生 博主等级

心死翼未伤,亦可去远方!

  • 累计撰写 46 篇文章
  • 累计创建 40 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录
Go

Go 简易教程

Administrator
2022-12-10 / 0 评论 / 0 点赞 / 1041 阅读 / 0 字

Go语言学习常用文档
Golang标准库文档
Go语言圣经(中文版)
w3cschool教程
基础语法在线运行

进阶
Go语言高级编程
Go语言定制指南

关于本书

许可

本书使用署名-非商业性使用-相同方式共享4.0许可。你不需要为本书付费。你可以自由的拷贝、发布、修改或展示本书。但是,我要求本书必须用我本人(Karl Seguin)的署名,同时不能作为经济用途。

你可在以下链接中查看到该许可的所有内容:

http://creativecommons.org/licenses/by-nc-sa/4.0/

最新版本

本书最新源码的放在:http://github.com/karlseguin/the-little-go-book

引言

每当我学习一门新语言的时候总是爱恨交加。一方面,语言是如此的重要,以至于一点小的变化对我们产生的可估量的影响。在你的程序和可以重新定义你对其他语言的期望的时候,你会有一个持久的效果。同时,语言的设计是增量的。学习新的关键字、类型体系、编码方式以及新类库、通讯和范式需要很多工作,但又很难评估。相对学习其他必学的东西,学习新语言让我们常常感觉是对时间的投入很大。

也就是说,我们要进步。我们必须愿意采用渐进的方式,又一次因为,语言是我们的基础。虽然变化是增量的,但它们往往范围很广,它们影响效率,可读性、性能、可测试性、依赖管理、错误处理、文档、分析(监控?)、通讯、标准库等等。除了说千刀万剐我们还能说什么?

这留给我们一个重要的问题:为什么选Go?对我来说,有两个令人信服的理由。首先它是一门相对简单的语言,还自带相对简单的标准库。在很多方面,Go的增量本质简化了我们已经看到的在过去几十年引入的语言的复杂性。另一个原因对于很多开发者来说,它会完善你的军火库。

Go被构建为一个系统语言(比如操作系统、设备驱动)并且面向C和C++开发人员。纵观Go的社群,我非常确信,应用开发者,而非系统开发者已经成为Go的主要使用者。为什么?我不能代表系统开发者,但是我们建设的网站、服务、桌面应用等等,这些面向新兴需求可归结为一类介于低层次的系统应用程序和更高级别的应用程序之间的系统。

也许它是一个消息,缓存,大数据分析,命令行接口,日志或监控。我不知如何标记它,但是在我的职业身涯中,由于系统的复杂性不断和频繁并发数以万计的增长,定制的基础设施类系统成为一个不断增长的需求。你可以用Ruby或Python或别的东西(确实很多人这么做)来构建这样的系统,如果使用Go,这些系统可以有一个更严格的类型系统和更高的性能优势。同样,您可以使用Go建立网站(确实很多人这样做),但,为了更大的回旋余地,我还是喜欢使用Node或Ruby的来构建这类系统。

还有一些Go的长处。比如,运行Go程序时没有依赖。你不需要担心你的用户是否已经安装了Ruby或者JVM和它们的版本。因为这个原因,Go作为命令行程序或者需要分发的其他类型的实用程序(比如日志收集器)开发语言,越来越流行了。

简单地说,学习Go是一种有效利用你的时间。你将不必花费很长时间来学习甚至掌握go,你可以通过一些实践来达成。

关于作者

我曾犹豫写这本小册子有几个原因。首先Go有自已的文档,特别是高效Go,它很实在(实用?)。

另一个原因是我写一本关于语言的书时的不适。当我写MongoDB小册子的时候,你可以假设很多读者理解基本的关系型数据库和模型。写Redis小册子的时候,你可以假设从一个熟悉的键值存储开始。

当想到摆在面前的段落和章节的时候,我知道我不能做这样的假设。你要花多少时间来讲解接口,因为对于一些人来说这是一个新概念,而另个一些人已经不需用再了解了。最终,我会感到欣慰如果你让我知道有些部分是太浅或过于详细。也算是我对读者们的小小要求了。

第一章 - 基础

Go是一门静态类型、编译型语言,有类C风格的语法和垃圾回收机制。这意味着什么呢?

编译

编译将你写的源代码转换成一种更低级的语言————可能是汇编(如Go就是这样),或者其他中间语言(如Java和C#)的过程。

因为编译可能很慢,使用编译型语言可能不是个令人愉快的事情。很难实现快速迭代因为你不得不花几分钟甚至几个小时的时间来等待编绎完成。编译速度是Go设计时的一个主要目标。这对于大项目的开发人员来说是个好消息,就像我们可以使用解释语言提供的快速反馈周期。

编译型语言往往运行得更快,不需要额外的依赖也可以正常运行(至少,像C、C++和Go这样直接编译成汇编的语言来说,就是如此。)

静态类型

静态类型是指变量必须指定一个类型(整型、字符串、布尔、字节数组等等)。可以在申明变量的时候指定数据类型,也可以,大多数情况是让编译器来推断类型(我们将会在接下来的例子中看到)。

关于静态类型还有很多可以介绍,但我相信理解它更好的方式是阅读代码。如果你习惯于动态语言,你可能觉得这比较麻烦。没错,不过静态类型也有优势,尤其是和编译相结合的时候。静态类型和编译这两者经常被混为一谈。虽然这不是硬性的规定,但通常情况下,有其一就必有其二。在严格类型系统中,编译器除了能够检测出单纯的语法错误问题还能做出进一步的优化。

类C语法

说一门语言有一个类C的语法意味着,如果你使用的任何其他类似C语言,如C,C ++,Java,JavaScript以及C#,那么你会发现Go的相似之处————至少从表面上看。比如,&&表示逻辑与,==表示相等判断,{}}是作用域的开始和结束,以及数组从0开始索引。

类C语法也往往使用分号结束行和条件表达式用括号括起来。Go没用使用这两种方式,尽管依然使用括号来控制优先权。比如,一个if表达式看起来像这样:

if name == "Leto" {
  print("the spice must flow")
}

在更复杂的情况下,括号依然有用:

if (name == "Goku" && power > 9000) || (name == "gohan" && power < 4000)  {
  print("super Saiyan")
}

除此之外,Go比C#或者Java更接近C,不仅在语法方面,还在用途方面。这体现在语言风格的简洁和简单,随着不断深入学习,你会越来越明显的体会到这种特性。

垃圾回收机制

一些变量,在创建时就有明确的生命周期。如函数内的局部变量,当函数结束时就消失了。在另一些情况下,就没有这么明显了,起码对编译器来说是这样。比如函数中返回的变量,变量的引用和对象的引用的生命周期就很难判断了。没有垃圾回收机制的情况下,这依赖于开发人员在不需要这些变量时进行内存的释放。怎么实现?例如在c中,你需要正确的去释放一个变量的内存free(str);

有垃圾回收机制的语言(如Ruby、Python、Java、JavaScript、C#、Go)能记录变量并在不使用时进行释放。垃圾回收机制增加了开销,但也杜绝了一些破坏性的bug。

运行Go代码

让我们创建一个简单的例子来学习如何编译和运行它,来开始我们的Go学习之旅。打开你最喜欢的文本编辑器,输入如下的代码:

package main

func main() {
  println("it's over 9000!")
}

将文件保存为main.go。开始,你可以将它保存在任何你想要的地方;作为简单的例子,我们还不需要深入理解Go的工作区。

接下来,打开一个shell/命令行,然后将目录切换到你保存文件的位置。对我来,输入cd ~/code就可以了。

最后,能过输入如下命令来运行程序:

go run main.go

如果一切正常,你会看到 it’s over 9000!

等等,那编译过程呢?go run是一个方便的编译和执行代码的命令。它使用临时目录来生成程序和运行,然后清理。通过下面的代码你可以查看临时文件所在位置:

go run --work main.go

要显示的编译代码,使用go build:

go build main.go

这会生成一个可执行的main程序。在Linux/OSX中,不要忘记在可执行文件前面加上点和反斜杠,所有你需要输入./main

在开发的时候,你可以使用go run或者go build。但当你发布的时候,你需要使用go build来生成可执行文件并运行它。

Main

但愿,我们刚刚的执行的代码是可以理解的。我们创建了一个函数,它调用内置的println函数打印一个字符串。难道是因为只有一个选择,所以go run才知道要执行什么吗?不是的,在Go语言中,程序的入口是main包中的main函数。

后续章节我们会介绍更多包的内容。现在,为了我们着重理解Go的基础知识,我们只在main包中写代码。

如果你愿意,你也可以修改代码并改变包名89。并使用go run去执行,你会得到一个错误信息。然后,将包名改成main,但是函数名不叫main,再次运行代码,你会得到一个不同的错误信息。使用go build进行相同的操作,注意编译代码时,这里没有运行代码的入口点。这是很正常的,例如当你编译一个库时。

包导入

Go有一些内建函数是不需要引入就可以直接使用,如println。不利用Go标准库和第三方类库的话,我们不能走得很远。在Go中,使用import关键字来申明代码中使用的包。

让我们来修改下程序:

package main

import (
  "fmt"
  "os"
)

func main() {
  if len(os.Args) != 2 {
    os.Exit(1)
  }
  fmt.Println("It's over ", os.Args[1])
}

通过下面的命令来运行它:

go run main.go 9000

我们用了两个Go的标准包:fmtos。我们引入了另一个内建函数lenlen返加字符串的长度,或者字典的个数,再或者,如这个例子,数组元素的个数。如果你想知道为什么我们期望是两个参数,这是因为索引为0的第一个参数是当前可执程序的路径(你可以自己修改代码将它打印出来看看)。

你可能已经注意到了函数名之前的包名了,比如:fmt.Println,这和其他很多语言不同。后续章节我们会学习更多包的内容。现在,知道如何导入和使用包就好了。

Go对包导入很严格。如果导入了包,但没有使用是不能通过编译的。试试运行下面的代码:

package main

import (
  "fmt"
  "os"
)

func main() {
}

你会看到两个错误信息,显示fmtos包被导入但是没有被使用。这会让人烦吗?绝对的。随着时间的推移,你会习惯(虽然还是烦人)。Go之所以在这点上这么严格是因为导入未使用的包会影响编译速度。不可否认的是,我们大多数人都没有这个深度。

令一个值得注意的地方就是Go的标准包的文档很完善。你可以通过http://golang.org/pkg/fmt/#Println 来学习更多我们用到过的Println的内容。你可以点击章节标题来查看源码。也可以滚动到顶部来查看更多关于Go格式化的功能。

如果你不能访问网络,你可以通过下面的方面运行本地的文档:

godoc -http=:6060

然后通过http://localhost:6060来浏览。

变量和声明

通过x = 4就能声明和赋值变量 ,对你来说可能是一个好的开始和结束。不幸的是,Go中要复杂一些。我们将通过简单的例子来开始我们的话题。然后,我们会在下一章节中,在讲解分创建的使用结构体的时候,我们会展开来讲解。但是,你可以需要花一些时间来适应它。

你可能会觉得 *哇!为什么这么复杂?*让我们来看些例子吧。

在Go中最直接也是最繁索的变量声明和赋值方式是:

package main

import (
  "fmt"
)

func main() {
  var power int
  power = 9000
  fmt.Printf("It's over %d\n", power)
}

这里,我们声明了一个int类型的变量power。默认情况下,Go给变量赋为0值。整型赋为0,布尔型赋为false,字符串赋为""等等。接着,我们给变量power赋值为9000。我们可以合并开始的这两行:

var power int = 9000

依然,还是需要很多的输入。Go有更方便的变量声明操作符,:=,它可以推断类型。

power := 9000

这很方便,对函数也同样适用:

func main() {
  power := getPower()
}

func getPower() int {
  return 9001
}

记住:=用于声明和赋值变量这点很重要。为什么呢?因为一个变量不能被声明两次(在同一个作用域中)。如果你尝试运行下面的代码,你会看到一个错误信息。

func main() {
  power := 9000
  fmt.Printf("It's over %d\n", power)

  // COMPILER ERROR:
  // no new variables on left side of :=
  power := 9001
  fmt.Printf("It's also over %d\n", power)
}

编译器会抱错提示 :=左边不是新的变量。这就是说我们一开始用:=来声明一个变量,接下来我们需要用=来给变量赋值。这很用意义,但是对你的记忆力来说是一个负担,因为你要记住这两者之间切换的时机。

如果你仔细看错误信息,你会发现 变量用了复数形式。因为Go支持多个变量同时赋值(使用=或者:=):

func main() {
  name, power := "Goku", 9000
  fmt.Printf("%s's power is over %d\n", name, power)
}

只要有一个变量是新的就可以使用:= 操作符。例如:

func main() {
  power := 1000
  fmt.Printf("default power is %d\n", power)

  name, power := "Goku", 9000
  fmt.Printf("%s's power is over %d\n", name, power)
}

虽然变量power使用了:=,但是编译器不会在第2次使用:=时报错,因为这里有一个新变量name,允许使用:=。但你不能改变power的类型。它已经被声明(隐式的)为整型,所以只能用整数来赋值。

最后,和包导入一样,Go不允许未使用的变量。例如:

func main() {
  name, power := "Goku", 1000
  fmt.Printf("default power is %d\n", power)
}

不会被编译因为变量name声明了但没有使用。和包导入一样会带来一些挫败感,但总的来说,这是为了代码的简洁和可读性。

声明和赋值还有内容需要学习。现在,只要记住,用var NAME TYPE来声明变量并赋0值,用NAME := VALUE声明变量并赋值,和用NAME = VALUE给已声明的变量赋值。

函数声明

现在是一个好的时机来指出函数是可以有多返回值的。让我们来看3个函数:一个没有返回值,一个有一个返回值,另一个有两个返回值。

func log(message string) {
}

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

func power(name string) (int, bool) {
}

我们像这样来使用最后一个函数:

value, exists := power("goku")
if exists == false {
  // handle this error case
}

有时,你可能只关心其中一个返回值。在这种情况下,你可以把其他值赋为_:

_, exists := power("goku")
if exists == false {
  // handle this error case
}

这不仅仅是一个约定。_,空白标识符,尤其在用在返回值时它没有真正的赋值。无论返回值是什么类型你都可以使用_

最后,你可能遇到一些不同的函数声明方式。如果函数的参数类型都相同,那么可以用以下更简洁的方式:

func add(a, b int) int {

}

你会常常用到函数多返回值这个特性。你也会经常使用_去舍弃一个返回值。具名返回值和无名参数声明并不常见。但是迟早你都会遇到,最好对他们都有所了解。

继续之前

现在我们已经学习了许多的小知识点,你可能会觉得有点脱节。我们会逐步构建一个更大的例子,有望将这些小知识点串联起来。

如果你是来自动态类型语言的开发人员,你可能会觉得Go的变量类型和声明的复杂是一种倒退。我同意你的看法。对于一些系统,动态类型的语言绝对更有效率。

如果你是来自静态类型语言的开发人员,你可能会习惯使用Go。类型推断和多返回值是如此的美好(尽管这不是Go独有的)。希望随着我们不断深入的学习,你会喜欢上Go干净和简洁的语法。

第二章 - 结构体

和C++, Java, Ruby以及C#不一样,Go并不是面向对象的语言。它没有对象、继承和其他一些和面向对象相关的概念,比各多态和重载。

Go有的就是结构体,可以直接绑定方法。Go支持简单便高效的组合。总的来说,它带来更简洁的代码,但在一些场合中失去OO的一些特性。(有必要指出 组合优于继承 是一个老早的争议,但Go是我用过的这么多语言中第一个立场这么坚定的。)

虽然Go确实不是你用过的OO样,但是你会发现结构体和类之间的很多相似之处。来看一个简单的例子,结构体Saiyan

type Saiyan struct {
  Name string
  Power int
}

很快我们就会看到怎么往这个结构体中添加方法,就像你要类中添加方法一样。在那之前,我们先细看下结构体的声明.

声明和初始化

我们最初学习变量和声明的时候,我们只用到内建类型,比如整形和字符串。现在我们讲的是结构体,我们要深入这个话题,包括指针。

创建一个结构体的值最简单的方式是:

goku := Saiyan{
  Name: "Goku",
  Power: 9000,
}

*注意:*结构体中最后一个,是必需的。没有的话,编译器会报错。你会喜欢这种一致性要求,特别是如果你使用了强制性相反的语言或格式

我们可以不给所有或者任何一个字段赋值。下面两种方式都是正确的:

goku := Saiyan{}

// or

goku := Saiyan{Name: "Goku"}
goku.Power = 9000

和没有赋值的变量一样,没有赋值的字段默认为0值。

再者,你也可以省略字段的名字,按字段的顺序进行声明(尽管为了简洁起见,你尽量在结构体只有少量字段时才使用这种方式):

goku := Saiyan{"Goku", 9000}

上面所有的例子所做的事件就是声明一个变量goku并给它赋一个值。

很多时候,我们不想变量直接相关的值,而是一个指向指针的变量。指针是内存地址,它可以定位实际的值有哪里。这是一种间接层。简单点说,这好比是在房子里还是有房子地址的区别。

为什么我们确实需要指针,而不是实际的值?这是因为Go在函数中参数的传递方式是:值。了解了这个,下面的程序会打出来什么?

func main() {
  goku := Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s Saiyan) {
  s.Power += 10000
}

答案是9000,而示是19000。为什么呢?因为Super改变的是goku的一个拷贝的值,Super中的改变不会在调用者中显示出来。要如你期望的方式运行,我们需要传入一个指针:

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s.Power += 10000
}

我们做了两处修改。第一处是使用了&操作符来获取值的地址(它被称为 取址操作符)。接下来,我们修改Super期望的参数类型。原来它期望的是Saiyan值类型,而现在期望的是*Saiyan的地址类型,此处*X是指 指向X类型的指针Saiyan*Saiyan的类型有一些明显的关联,但是它们两是不同的类型。

需要指出的是,我们现在传递给Super参数的仍然是goku的值拷贝。只是现在goku的值变成了一个地址。这个地址拷贝和源地址相同。可以认为它类似一个指向餐厅方向的拷贝,这就间接服务于我们。虽然是一个拷贝,但是和源地址一样,也指向同一个餐厅。

我们可以通过改变它的指向来证明这是个拷贝(虽然不是你想要的):

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s = &Saiyan{"Gohan", 1000}
}

上例,依然,打印9000。这和很多语言的行为是一样的,包括Ruby,Python,Java和C#。Go和C#一定程度是要样的,让这个更显而易见。

很明显拷贝一个指针比拷贝一个复杂的结构体开销小多了。在64位的机器上,一个指针是64位的大小。如果我们有一个有很多字段的结构体,创建一份拷贝开销是比较大的。指针的真正价值是通过它可以共享值。我们想通过Super去改变goku的拷贝或者改变共享的goku值本身?

所有这些不是说你一直要用指针。本章末尾,当我们学到更多结构体的内容后,我们会重新审视指针和值类型的问题。

结构体上的函数(结构体的方法)

我们可以为结构体关联一个方法:

type Saiyan struct {
  Name string
  Power int
}

func (s *Saiyan) Super() {
  s.Power += 10000
}

上面的代码,我们说*SaiyanSuper方法的 接收器 。我们能过这样的方式调用Super方法:

goku := &Saiyan{"Goku", 9001}
goku.Super()
fmt.Println(goku.Power) // will print 19001

构造器

结构体没有构造器。你可创建一个函数来返回一个期望类型的实例来替代(像工厂一样):

func NewSaiyan(name string, power int) *Saiyan {
  return &Saiyan{
    Name: name,
    Power: power,
  }
}

这种方式导致很多开发者犯错。一方面,它有一些轻微的语法变化;另一方面,它有一点让人感觉不好区分。

我们的工厂没有必要返回指针;下面的代码完全正确:

func NewSaiyan(name string, power int) Saiyan {
  return Saiyan{
    Name: name,
    Power: power,
  }
}

创建

尽管没有构造器,但是Go有内置的new函数可以用来分配一下指定类弄的内存。new(X)&X{}的效果是一样的:

goku := new(Saiyan)
// same as
goku := &Saiyan{}

使有哪种方式看你自己的喜好,但是你会发现当字段需要初始化时,大多数人喜欢使用后一种方式,因为这样更易读:

goku := new(Saiyan)
goku.name = "goku"
goku.power = 9001

//vs

goku := &Saiyan {
  name: "goku",
  power: 9000,
}

无论你使用哪种方式,如果你使用上面的工厂模式,接下来的代码中你可以不要了解和担心任何分配的细节。

结构体字段

目前为止我们看到的例子中,Saiyan有两个字段,一个字符串类型的Name和一个整型Power。字段可以是任何类型————包括其他的结构体和暂时我们没有讲到的类型,例如数组、字典、接口和函数。

例如,我们可以这样扩展Saiyan的定义:

type Saiyan struct {
  Name string
  Power int
  Father *Saiyan
}

我们可以通过下面的方式初始化:

gohan := &Saiyan{
  Name: "Gohan",
  Power: 1000,
  Father: &Saiyan {
    Name: "Goku",
    Power: 9001,
    Father: nil,
  },
}

组合

Go支持组合,就是将一个结构体包含在另一个之中。在一些语言中,这被叫特性或混入。没有明确的组合机制的语言,要实现这个特性就比较繁杂。在Java中:

public class Person {
  private String name;

  public String getName() {
    return this.name;
  }
}

public class Saiyan {
  // Saiyan is said to have a person
  private Person person;

  // we forward the call to person
  public String getName() {
    return this.person.getName();
  }
  ...
}

这样会相当的冗长。每个Person的方法都需要在Saiyan中复制一遍。Go可以避免这种冗长:

type Person struct {
  Name string
}

func (p *Person) Introduce() {
  fmt.Printf("Hi, I'm %s\n", p.Name)
}

type Saiyan struct {
  *Person
  Power int
}

// and to use it:
goku := &Saiyan{
  Person: &Person{"Goku"},
  Power: 9001,
}
goku.Introduce()

Saiyan结构体中有一个*Person类型的字段。因为我们没有给他一个显示的字段名,我们可以隐示的访问组合类型的所有字段和函数。
但出于完全有效的考虑,Go编辑器确实有给它分配一个字段名。

goku := &Saiyan{
  Person: &Person{"Goku"},
}
fmt.Println(goku.Name)
fmt.Println(goku.Person.Name)

上面的两个输出都是"Goku"。
组合是不优于继承?很多人认为这是一种更健壮的共享代码的方式。当使用继承时,你的类和超类捆绑在一起,你最终关注继承而不是行为。

重载

值得指出的是,结构体没有重载。简而言之,Go不支持重载。因为这个原因,你会看到(和写)很多像 Load, LoadById, LoadByName这样的函数。
但是,因为非显示的组合是一个编辑器技巧,我们可以“重写”组合类型的函数。比如, 我们的Saiyan结构体可以有自己的Introduce方法:

func (s *Saiyan) Introduce() {
  fmt.Printf("Hi, I'm %s. Ya!\n", s.Name)
}

组合版本中使用s.Person.Introduce()也是一样的。

指针和值

当你写Go代码的时候,你很自然的就会问你自己*这应该是要用值还是要用指针?*下面是两个好消息。首先,下面讨论的这些话题是没有什么差别的:

  • 局部变量赋值
  • 结构体中的字段
  • 函数的返回值
  • 函数的参数
  • 方法的接收者

其次,如果你不确定,就用指针好了。

就如我们看到的那样,传值是一个让值不可变的好方法(函数内的改变不会影响调用代码中的值)。有些时候,我们却时希望如此,可常常不是这样的。

就算你不想改变值,想一下创建一个大结构体拷贝的开销。相反地,你可能有一个小结构体,例如:

type Point struct {
  X int
  Y int
}

在这种情况下,拷贝结构体的开销可以通过偏移量来直接访问XY,而不是间接访问。
再次指出,这些只是非常微妙的情况。除非你要访问成千上百个这样的点,否则你不会察觉有任何的不同。

继续之前

本章从实践的角度来看,介绍了结构体,以及如何创建方法接收器的结构体实例,并在我们现有的Go知识体系中引入了指针。
下面的章节将基于我们所知道的结构体知识来探讨其内部运行机制。

第三章 - 字典 ,数组和切片

目前为止我们看了些简单类型和结构体。现在是时候来看看数组,切片和字典了。

数组

如果你从Python、Ruby、Perl、JavaScript或者PHP(还有更多),你可能使用过动态数组。这些数组当数据添加进来可以调整大小。在Go中,和其他语言一样,数组是固定大小的。申请一个数组需要我们指定它的大小。申明一个数组时需要指定大小,一旦大小指定了,就不能增长了:

var scores [10]int
scores[0] = 339

上面的这个数组从scores[0]scores[9]可以容纳10个分数。尝试访问超出数组索引的会报编译或者运行时错误。

我们可以带值初始化数组:

scores := [4]int{9001, 9333, 212, 33}

我们可能通过len来获取数组的长度。可以用range来迭代数组。

for index, value := range scores {

}

数组高效但不灵活。我们常常不能预先知道有多少元素需要处理。因此,我们使用切片。

切片

在Go中,你很少,或者根本不,直接使用数组。反而,你使用切片。切片是对数组一个轻量型封装。有几种方式来创建一个切片,我们会全部过一遍。第一种方式和创建数组有一点小小的变化。

scores := []int{1,4,293,4,9}

和申请数组不同,我们的切片没有在中括号内指定长度。要理解这两者的不同,我们用另一种方式创建切片,使用make

scores := make([]int, 10)

我们使用make来替代new因为创建一个切片比只是分配内存(new做的事情)要复杂一些。特别的,我们需要分配底层数组和初始化切片。上面的例子,我们初始化了一个长度和容量为10的切片。长度是切片的大小,容量时底层数组的大小。使用make时可以分开提定这两个值:

scores := make([]int, 0, 10)

这创建了一个长度为0但容量为10的切片。(如果你留心,你会注意到makelen重载的。Go有的时候令人沮丧,一些在使用的功能没有暴露给开发者使用。)

为了更好的理解长度和容量之间的相互作用,让我们来看一个例子:

func main() {
  scores := make([]int, 0, 10)
  scores[5] = 9033
  fmt.Println(scores)
}

我们的第一个程序崩溃了。为什么呢?因我们的切片的长度为0。是的,底层数组有10个元素,但是我们需要显示的扩展切片来访问这些元素。一种扩展方式是使用append

func main() {
  scores := make([]int, 0, 10)
  scores = append(scores, 5)
  fmt.Println(scores) // prints [5]
}

上面的修改改变了我们的原始代码的意图。扩展一个长度为0的切片会设置第一个元素。无论什么原因,我们崩溃的代码想要的是修改索引为5的元素。 我们可以重切片一次我们的切片:

func main() {
  scores := make([]int, 0, 10)
  scores = scores[0:6]
  scores[5] = 9033
  fmt.Println(scores)
}

我们调整切片最大是多少?这是由它的容量决定的,在本例中,是10。你可能会想这没有从本质上解决数能固定和长度的问题。事实上,append是非常特殊的。当底层数组满了,它会创建一个新的数组,并把数值拷贝过来(PHP, Python, Ruby, JavaScript等也是这么做的)。这也就是为什么,我们上面的代码,使用了append之后,我们需要把append的返回值重新赋值给scores的原因:append会产生一个新的值如果原始的空间不足。

如果我告诉你说Go是近两倍的算法来增长数组,那下面的代码会输出什么?

func main() {
  scores := make([]int, 0, 5)
  c := cap(scores)
  fmt.Println(c)

  for i := 0; i < 25; i++ {
    scores = append(scores, i)

    // if our capacity has changed,
    // Go had to grow our array to accommodate the new data
    if cap(scores) != c {
      c = cap(scores)
      fmt.Println(c)
    }
  }
}

scores最初的容量是5。为了容纳20个值,它将会扩展3次,分别是10,20和40。

来思考下最后一个例子:

func main() {
  scores := make([]int, 5)
  scores = append(scores, 9332)
  fmt.Println(scores)
}

这里,输出是[0, 0, 0, 0, 0, 9332]。可能你会想它应该是[9332, 0, 0, 0, 0]?对于人来说这可能符合逻辑。对于编译器来说,你告诉它的就是要扩展一个已经有5个值的切片。

最后,有四种常用的方式来初始化一个切片:

names := []string{"leto", "jessica", "paul"}
checks := make([]bool, 10)
var names []string
scores := make([]int, 0, 20)

何时用哪一种呢?第一种不需要过多的解释。当你知道所有的值并且你要的是数组头的时候使用。

当你需要写入切片的指定索引时,第二种就很有用。比如:

func extractPowers(saiyans []*Saiyans) []int {
  powers := make([]int, len(saiyans))
  for index, saiyan := range saiyans {
    powers[index] = saiyan.Power
  }
  return powers
}

当知道有多少元素的时候,就可使用第三种是一个空切片和append配合使用。

当我们对需要多少元素有多少了解时使用最后一种方式来指定初始容量。

即使你知道了大小,append依然可以使用。这很大程度上是一个偏好问题:

func extractPowers(saiyans []*Saiyans) []int {
  powers := make([]int, 0, len(saiyans))
  for _, saiyan := range saiyans {
    powers = append(powers, saiyan.Power)
  }
  return powers
}

切片做为数组的封装是一个很有用的概念。许多语言有数组切片的概念。JavaScript和Ruby的数组都有slice方法。你可以对通过[START..END]或者在Python中用[START:END]的方式取得一个切片。但是,在这些语言中切片是对原数组的拷贝。如果我们使用Ruby,下面的代码会输出什么?

scores = [1,2,3,4,5]
slice = scores[2..4]
slice[0] = 999
puts scores

答案是[1, 2, 3, 4, 5]。那是因为slice是一个复制了所有值的全新数组。现在,来看下Go相同的代码:

scores := []int{1,2,3,4,5}
slice := scores[2:4]
slice[0] = 999
fmt.Println(scores)

输出是[1, 2, 999, 4, 5]

这改变了你的编码方式。比如,一些函数需要一个位置参数。在JavaScript中,如果我们需要找到字符串中第五个字符之后的第一个空格(是的,切片对字符串也是有效的!), 我们这样写:

haystack = "the spice must flow";
console.log(haystack.indexOf(" ", 5));

在Go中,我们使用切片:

strings.Index(haystack[5:], " ")

上面的例子,我们可以看到,[X:]从X到结束的简写,就如[:X]从开始到X的简写一样。和其他语言不同的是,Go不支持反向取值(这边感觉不对)。如果我们需要切片除了最后一个值以外的所有值,我们这样来写:

scores := []int{1, 2, 3, 4, 5}
scores = scores[:len(scores)-1]

以上是一种快速删除未排序的切片中的某个值的方法的开头:

func main() {
  scores := []int{1, 2, 3, 4, 5}
  scores = removeAtIndex(scores, 2)
  fmt.Println(scores)
}

func removeAtIndex(source []int, index int) []int {
  lastIndex := len(source) - 1
  //swap the last value and the value we want to remove
  source[index], source[lastIndex] = source[lastIndex], source[index]
  return source[:lastIndex]
}

最后,既然我们是学习了切片,我们来看另一个常用的内置函数: copycopy是能突出切片是如何改变我们的编码方式的函数之一。通常,数组间拷贝需要5个参数:source, sourceStart, count, destinationdestinationStart。使用切片只要两个:

import (
  "fmt"
  "math/rand"
  "sort"
)

func main() {
  scores := make([]int, 100)
  for i := 0; i < 100; i++ {
    scores[i] = int(rand.Int31n(1000))
  }
  sort.Ints(scores)

  worst := make([]int, 5)
  copy(worst, scores[:5])
  fmt.Println(worst)
}

花一些时间来执行上面的代码。多试几次。看看会发生什么,如果将代码改为copy(worst[2:4], scores[:5]),或者要拷贝比5更多或少的值到worst

Map映射

Go中的映射在其他语言中叫哈希表或者字典。它们都如你想的一样:你定义一个键和值,然后你可以通过映射来删改查这些值。

映射,和切片一样,是通过make函数来创建的。让我们来看一个例子:

func main() {
  lookup := make(map[string]int)
  lookup["goku"] = 9001
  power, exists := lookup["vegeta"]

  // prints 0, false
  // 0 is the default value for an integer
  fmt.Println(power, exists)
}

我们使用len来获取有多少键。要通过键来删除一个值,我们用delete

// returns 1
total := len(lookup)

// has no return, can be called on a non-existing key
delete(lookup, "goku")

映射是动态增长的。但是,我们可以通过make的第二个参数来设置它的初始大小:

lookup := make(map[string]int, 100)

如果知道有多少值,指定初始大小可以有更好的性能表现。

如果想要把映射做为结构体的一个字段,我们这样定义:

type Saiyan struct {
  Name string
  Friends map[string]*Saiyan
}

初始化的一种方式:

goku := &Saiyan{
  Name: "Goku",
  Friends: make(map[string]*Saiyan),
}
goku.Friends["krillin"] = ... //todo load or create Krillin

在Go中有另一种申明和初始化的方式。和make一样,这对映射和数组都是有效的。我们可以像组合文字一样申明:

lookup := map[string]int{
  "goku": 9001,
  "gohan": 2044,
}

我们可以通过forrange关键字来迭代映射:

for key, value := range lookup {
  ...
}

映射的迭代器是无序的。每个迭代器随机查找键值对。

指针和值

通过了解什么时候传值或指针我们结束了第二章。在数组和映射的值方面我们将碰到相同的问题。我们应该用哪一种?

a := make([]Saiyan, 10)
//or
b := make([]*Saiyan, 10)

许多开发人员会想对于一个函数来说是传b还是将它做为返回值更高效。然而,这里传递或者返回的都是一个切片的拷贝,它本身就是一个引用。所以就传递或者返回这个切片而言,没有什么区别。

当你改变一个切片或者映射的值时,你会看见不同。在这点上,同样的逻辑,我们在第二章看到已经适用。所以是否定义一个数组指针还是一个数组值主要归结于如何使用单个值,而不是你如何使用数组或者映射本身。

继续之前

在Go中数组和映射的工作方式与其他语言非常像。如果你用过动态数组,可能有会有一些需要调整,但是通过append可以解决所有的不适。如果我们抛开数组表面的语法,我们就会发现切片。切片是相当强大的,使用切片对你代码的整洁性有着非常巨大的影响。

这里有一些边界例子我们没有涉及到,但是你不太可能遇见这些例子。另外,如果你遇到了,希望我们已经打下的基础能让你理解这是怎么回事。

第四章 - 代码组织和接口

现在是时候来看看我们是怎么组织代码了。

为了组织更复杂的类库和系统,我们需要了解包。在Go中,包名紧跟在工作目录结构之下。如果我们构建一个电商系统,我们可能以"shopping"命名包和把源文件存在$GOPATH/src/shopping/下。

显然我们不想反所有的东西都放在这个目录。例如,我们希望在数据库目录下关联一些数据库的逻辑。要达到这个目的,我们创建了一个子目录$GOPATH/src/shopping/db。这个目录下的包名可以是简单的db,但是其他包需要访问这个包时,就需要包含shopping,我们需要这样导入shopping/db

换名话来说,当你命名一个包时,可以通过package关键字,你提供一个单一的值,不是完整的层级(比如,“shopping"或者"db”)。当你导入包时,你需要指定完整的路径。

让我们来试一试,在我们的Go的工作目录下的src文件夹中(我们在入门介绍中设置的),创建一个shopping的文件夹并创建一个db的子文件夹。

shopping/db目录下,创建一个名为db.go的文件并添加如下代码:

package db

type Item struct {
  Price float64
}

func LoadItem(id int) *Item {
  return &Item{
    Price: 9.001,
  }
}

注意下包名和文件夹的名字是一样的。显然,我们也没有真下的访问数据库。我们只是用这个例子来演示如何组织代码。

现在,在主目录shopping下创建一个名为pricecheck.go的文件。它的代码如下:

package shopping

import (
  "shopping/db"
)

func PriceCheck(itemId int) (float64, bool) {
  item := db.LoadItem(itemId)
  if item == nil {
    return 0, false
  }
  return item.Price, true
}

这很容易理解,导入shopping/ db有些特殊,因为我们是shopping包/文件夹中了。实际上,要导入$ GOPATH/src/shopping/db,这意味着你可以很容易地导入test/db,只要工作空间下的src/test/db的文件夹有一个db的包。

如果你想建一个包,只需要我们看到的这些内容就可以了。要创建一个可执行程序,我们还需要一个main函数。我喜欢在shopping文件夹下建一个main文件夹并新增一个main.go的文件。它的内容如下:

package main

import (
  "shopping"
  "fmt"
)

func main() {
  fmt.Println(shopping.PriceCheck(4343))
}

进入shopping目录输入如下内容可以运行代码:

go run main/main.go

循环导入

当你开始写一些更复杂的系统时,你不免会碰到循环导入的问题。当包A的导入了包B但是包B又导入了包A(无论是直接还是间接通过其他包导入)循环导入就发生了。这是编译器不允许的。

让我们来修改一下shopping的结构来引发这个错误。

Item的定义从shopping/db/db.go移到shopping/pricecheck.go中。你的pricecheck.go应该看起来像这样:

package shopping

import (
  "shopping/db"
)

type Item struct {
  Price float64
}

func PriceCheck(itemId int) (float64, bool) {
  item := db.LoadItem(itemId)
  if item == nil {
    return 0, false
  }
  return item.Price, true
}

如果你尝试运行代码,你会从db/db.go中收到一些错误信息说Item未定义。这好理解。Item已经不在db包下了;这被移到了shopping包。我们需要修改shopping/db/db.go的代码为:

package db

import (
  "shopping"
)

func LoadItem(id int) *shopping.Item {
  return &shopping.Item{
    Price: 9.001,
  }
}

现在当你尝试运行代码时,你会收到一个令人害怕的错误import cycle not allowed。我们通过引入一个包含共享结构的另一个包来解决这个问题。你的目录结构看起来像这样:

$GOPATH/src
  - shopping
    pricecheck.go
    - db
      db.go
    - models
      item.go
    - main
      main.go

pricecheck.go一样是导入shopping/db,但是db.go现在要导入shopping/models来替换shopping,这样打破了循环。因为将共享结构体Item移到了shopping/models/item.go,我们需要修改shopping/db/db.gomodel包中引用结构体Item

package db

import (
  "shopping/models"
)

func LoadItem(id int) *models.Item {
  return &models.Item{
    Price: 9.001,
  }
}

你常常需要的共享结构不仅仅是models,所以你可能还有一些类似utilities这样的文件夹。最重要的原则是这些共享对象,不能导入shopping包或者它的子包。要不了几个章节,我们会看到接口是如何解开这些依赖的。

可见性

Go用一个非常简单的原则来决定一个包的类型和函数是否在包外可见。如果类型或者函数是以大写字母开头,就是可见的,如果是小写字母开头,就是不可见的。

这对结构体的字段也是一样适用的。如果一个结构体的字段名是以小写字母开头,只有在同一个包里的代码可以访问它们。

例如:在我们的items.go文件中有这样的一个函数:

func NewItem() *Item {
  // ...
}

可以通过models.NewItem()来调用。但是如果这个函数被命名为newItem,我们就不能在其他包里面来调用它了。

继续修改在shopping中修改函数,类型和字段的名字。例如,如果你修改ItemPriceprice,你会收到一个编译错误。

包管理

我们用来runbuildgo命令,它还一个get的子命令用来获取第三方的类库。go get支持多种协议,但是这里,我们将从Github上获取一个类库。这是说,你要在你的电脑上安装好git

假设你已经安装好了git,打开一个shell/命令提示符,输入:

go get github.com/mattn/go-sqlite3

go get获取远程文件并把它们存在你的工作目录中。到$GOPATH/src目录中检查一下。除了我们自己创建的shopping项目外,还会看到一个github.com文件夹。里面你会看到一个包含go-sqlite3文件夹的mattn文件夹。

我们刚介绍了在工作区中如何导入包。要用我们刚刚获取到的go-sqlite3包,我们需要像这样来导入它:

import (
  "github.com/mattn/go-sqlite3"
)

我知道这看起来像是一个URL,但实际上,如果知道是在$GOPATH/src/github.com/mattn/go-sqlite3目录,导入go-sqlite3包是很简单的。

依赖管理

go get有一些其他的戏法。如果我们在一个项目中执行go get,它会扫描所有文件并查找所有导入的第三方库,然后下载这些第三方库。某种程度上说,我们自己的源代码变成一个Gemfile或者package.json

执行go get -u将更新你的包(或者你可以通过go get -u FULL_PACKAGE_NAME更新指定的包)

最后,你可能发现了go get的一些不足。首先,它不能指定一个修订,它会一直指向master/head/trunk/default。这是一个严重的问题,尤其当你有2个项目需要同一个库的不同版本时。

为了解决这个问题,你可以使用一个第三方的依赖管理工具。虽然还不太成熟,但是有2个依赖管理工具比较有前景,即goopgodep。更完整的列表可以参考go-wiki

接口

接中是一种定义了协议但没有实现的类型。这是一个例子:

type Logger interface {
  Log(message string)
}

你可能会想知道这么做有什么目的。接口可以帮的的代码从特定的实现中解藕出来。例如,我们可能有多种类型的日志:

type SqlLogger struct { ... }
type ConsoleLogger struct { ... }
type FileLogger struct { ... }

是的,能过接口而不是这些具体的实现来编程,我们可以很容易的在不影响我们的代码的基础上修改(和测试)。

你要如何来使用?就像其他类型一样,它可以是一个结构体的字段:

type Server struct {
  logger Logger
}

或者是一个函数的参数(或者访回值):

func process(logger Logger) {
  logger.Log("hello!")
}

在像C#或者Java的语言中,我们必须显示的申请明一个类实现了一个接口:

public class ConsoleLogger : Logger {
  public void Logger(message string) {
    Console.WriteLine(message)
  }
}

在Go中,这是隐式的。如果的结构体有一个名为Log的函数,有一个string的参数和没有返回值,那么它就可以当作Logger来使用。这减少了使用接口时的繁索:

type ConsoleLogger struct {}
func (l ConsoleLogger) Log(message string) {
  fmt.Println(message)
}

这也会倾向于促进接口的小巧和单一。标准库中到处都是接口。在io包中有一些流行的接口,如io.Reader, io.Writer, 和io.Closer。如果你要写一个函数需要一个参数但只调用它的Close()方法,你绝对可以使用io.Closer接口需不是任何具体的类型。

接口也可以组合。也就是说接口可以有其他接口组成。例如,io.ReadCloser就是由接口io.Readerio.Closer接口组成。

最后,接口常用于避免循环导入。由于接口没有实现,他们的依赖关系有限。

继续之前

最后,当你试着用go写一些简单的项目之后,你会习惯在go语言的工作区中组织代码的方式。最重要是的记住go语言中的包名和你的目录结构有密切关系(不仅仅在一个项目中,在整个工作空间都如此)。

go语言处理类型的可见性方法是简单有效的。也是一致的。还有一些内容我们没有介绍,例如常量和全局变量,但是不用担心,它们的可见性也是遵循同样的规则。

最后,如果你不熟悉go语言中的接口,你可能需要花一些时间去感受它们。无论如何,当你首次看见一个函数需要例如io.Reader之类的参数时,你会发现你自己感激作者的要求不是太苛刻。

第五章 - 花絮

在这一章是,我们会来介绍一些Go的特性的杂烩,这些内容不太适合放在其他章节中。

错误处理

Go更喜欢用返回值而不是异常的方式来处理错误。例如strconv.Atoi函数将一个字符串转换成一个整数:

package main

import (
  "fmt"
  "os"
  "strconv"
)

func main() {
  if len(os.Args) != 2 {
    os.Exit(1)
  }

  n, err := strconv.Atoi(os.Args[1])
  if err != nil {
    fmt.Println("not a valid number")
  } else {
    fmt.Println(n)
  }
}

你可以创建你自己的错误类型;唯一的要求就是需要实现内置接口error:

type error interface {
  Error() string
}

更为常见的是,我们可以通过导入errors包,并通过它的New函数来创建自己的错误:

import (
  "errors"
)


func process(count int) error {
  if count < 1 {
    return errors.New("Invalid count")
  }
  ...
  return nil
}

在GO的标准库中,使用错误变量是一个常用的模式。例如, 在io包中有一个EOF变量是这样定义的:

var EOF = errors.New("EOF")

这是一个公共(大写字母开关)的包变量(它定义有函数之外)。如果我们从文件或者标准输入时失败时,我们可以返回这个错误。为了更容易理解,你也应该用这个错误。作为使用者,我们可以用这个单件:

package main

import (
  "fmt"
  "io"
)

func main() {
  var input int
  _, err := fmt.Scan(&input)
  if err == io.EOF {
    fmt.Println("no more input!")
  }
}

最后要注意的是,Go有panicrecover函数。panic像是抛出异常,而recover是捕获异常;它们不常使用。

Defer

虽然Go有垃圾回收机制,但是有些资源需要显示的释放它们。比如,当我们使用文件完了之后,需要调用Close()来关闭它们。这类代码总是很危险。其一,我们写一下函数的时候,如果申请一个资源超过10行,就很容易忘记Close。其二,一个函数可能会有多个返回点。Go的解决方案是使用defer关键字:

package main

import (
  "fmt"
  "os"
)

func main() {
  file, err := os.Open("a_file_to_read")
  if err != nil {
    fmt.Println(err)
    return
  }
  defer file.Close()
  // read the file
}

如果你尝试运行上面的代码,你可能收到一个错误(文件不存在)。这里展示的是defer是如何工作的。无论如何在函数返回时defer都会被执行,虽然这样有点极端。但这可以让你在初始化附近释放资源和不要操心多个返回点的问题。

go fmt

绝大多数Go写的代码遵守有一个相同的格式化规则,也就是说,使用Tab来缩进和花括号与语句同一行。

我知道你有自己的代码风格并且严格遵守它。我一直以来也是这么做的,但是我最终还是放弃了。一个最在的原因就是go fmt命令。它很容用且权威(所以没有人会为了毫无意义的偏好而争论)

当你在一个工程目录下,你可以通过下面的命令将工程下所有文件使用相同的格式化规则:

go fmt ./...

尝试一下吧。除了缩进代码,它还会自动对齐你的声明语句并将包导入按字母顺序排序。

If初始化

Go提供了一种稍有不同的if声明,一个可以在条件执行之前声明和初始化:

if x := 10; count > x {
  ...
}

这是一个非常简单的例子。更实际的例子是,你可能是这样做的:

if err := process(); err != nil {
  return err
}

有趣的是,if语句中定义并初始化的值在if语句之外是不可用的,仅可以在else ifelse语句中使用。

空接口和转换

在大多数面向对象语言中,都有一种内置的基类,叫object,它是所有其他类的超类。但是go语言不支持继承,所以没有类似的超类。Go确实有一个没有任何方法的空接口:interface{}。因为接口都是隐式实现,每种类型都实现了空接口的0个方法,所以每种类型都实现了空接口的协议。

如果我们愿意,我们可以通过下面声明方式写一个add函数:

func add(a interface{}, b interface{}) interface{} {
  ...
}

将一个空接口变量转换成一个指定的类型,你可以使用.(TYPE):

return a.(int) + b.(int)

需要注意如果底层类型示是一个int,上面的代码会导致一个错误。

你也可以通过switch使用强大的类型转换:

switch a.(type) {
  case int:
    fmt.Printf("a is now an int and equals %d\n", a)
  case bool, string:
    // ...
  default:
    // ...
}

你会发现,空接口的使用会超出你的预期。诚然,这不会让代码变得简洁。来回转换值是丑陋和危险的,但有时候在静态类型语言中,这是唯一的选择。

字符串和字节数组

字符串和字节数组有密切关系,我们可以轻易的将它们转换成对方:

stra := "the spice must flow"
byts := []byte(stra)
strb := string(byts)

事实上,这也是大多数类型的转换方式。一些函数明确指定一个int32或者int64或者相应的无符号类型。你可能会发现你自己不得不像下面这样做:

int64(count)

然而,当涉及到字节和字符串时,这可能是你会经常做的事。当你使用[]byte(X)或者string(X)时务必注意,你创建了数据的拷贝。这是因为字符串的不可变性。

当字符串有由unicode字符码runes组成时。如果你计算字符串的长度时,可能得到的结果和你期待的不同。下面结果是输出3:

fmt.Println(len("椒"))

如果你使用range迭代一个字符串,你得到的是runes,而不是bytes。当然,你将一个字符串转换为[]byte时,你得到的数据还是正确的。

函数类型

函数是第一类类型:

type Add func(a int, b int) int

可以在任何地方使用————可以做为一个字段,参数,返回值。

package main

import (
  "fmt"
)

type Add func(a int, b int) int

func main() {
  fmt.Println(process(func(a int, b int) int{
      return a + b
  }))
}

func process(adder Add) int {
  return adder(1, 2)
}

像这样使用函数可以使你在一些特定实现时减少代码的耦合性,就像使用接口实现那样。

继续之前

我们已经学习了Go编程的很多内容。显而易见,我们看见了错误处理的行为和资源释放如连接或者打开文件。很多人不喜欢Go的错误处理方式。它让人觉得这是一种退步。有些时候,我同意这种说法。然而,我也发现这会导致代码更易读。defer是一种不常见但很实用的资源管理手段。事实上,它不仅仅可以进行资源管理。你可以使用defer完成任何目的,例如当一个函数退出时打印日志记录。

Certainly, we haven’t looked at all of the tidbits Go has to offer. But you should be feeling comfortable enough to tackle whatever you come across.

当然,我们还没有学习Go提供的所有花絮。但是无论你遇到什么你应该可以轻松应对。

第六章 - 并发

Go常被描述为是一种适用于并发的语言。是因为它在两个强大的机制提供了简法的语法支持:go协程通道

Go协程

一个Go协程和一个线程类似,只不这它是由Go,而不是系统来调度的。在协程中的代码可以和其他代码并发执行。让我们看一个例子:

package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}

这里有几个有趣的地方,但最重要的是我们如何开启一个Go协程。我们只是简单的使用了go关键字后紧跟我们需的执行的函数。如果我们只是要运行一小段代码,比如上面的例子,我们可以使用匿名函数。但是记住,匿名函数不只适用于Go协程。

go func() {
  fmt.Println("processing")
}()

Go协程创建简单和开销小。多个Go协程最终会运行在一个系统线程中。这通常称为M:N线程模型,因为我们有M个应用线程(Go协程)运行在N个系统线程上。结果就是,一个Go协程的开销比系统线程小(一般都是几KB)。在现代的硬件上,有可能创建成千上万个Go协程。

此外,因为隐藏了映射和调度的复杂性。我们只需要说这段代码需要并发执行,然后让Go自己来运行它。

回到我们的例子中,你将会注意到我们使用了Sleep让程序等待了几毫秒。这是让主进程在退出前有机会去执行协程(主进程退出时不会等待所有协程都执行结束)。为了解决这个问题,我们必须让代码协同。

同步

创建Go协程是容易的,而且他们的开销很小,所以我们可以开启很多Go协程;但是并发代码需要协同。为了帮助我们解决这个问题,Go提供了通道。在我们继续通道之前,我觉得有必要先了解一些并发编程的基础知识。

在编写并发执行的代码时,你需要特别的注意在哪里和如何读写一个值。出于某些原因,例如没有垃圾回收的语言,需要你从一个新的角度去考虑你的数据,总是警惕着可能存在的危险。例如:

package main

import (
  "fmt"
  "time"
)

var counter = 0

func main() {
  for i := 0; i < 2; i++ {
    go incr()
  }
  time.Sleep(time.Millisecond * 10)
}

func incr() {
  counter++
  fmt.Println(counter)
}

你觉得输出的会是什么呢?

如果你觉得输出是12,不能说你对或者错。如果你运行上面的代码,你很有可能得到那样的输出。但是,实际上这个输出是不确定的。为什么?因为我们可能有多个(这里是2个)Go协程同时写同一个变量counter。或者更糟的情况是一个协程正在读counter,而另一个协程正在写counter

这很危险吗?是的,绝对的。counter++似乎看起来只是一行简单的代码,但是实际上它被拆分为很多汇编指令————具体依赖于你运行的软件和硬件平台。是的,在上面的例子中,确实在大多数情况下运行良好。但是,其他一些平台可能的输出结果是1, 1,因为两个协程看到的counter都是0。还有更糟的情况是,比如系统崩溃或者访问到一个随机值并递增它。

在并发编程中维一安全的事情就是读一个变量。无论你想读多少次都可以,但是写变量时必须是同步的。有几种方式来实现,包括一些在特定CPU架构上真正的原子操作。但是,最常见的方式就是用互斥锁:

package main

import (
  "fmt"
  "time"
  "sync"
)

var (
  counter = 0
  lock sync.Mutex
)

func main() {
  for i := 0; i < 2; i++ {
    go incr()
  }
  time.Sleep(time.Millisecond * 10)
}

func incr() {
  lock.Lock()
  defer lock.Unlock()
  counter++
  fmt.Println(counter)
}

互斥锁会顺序化有锁的代码的访问。因为sync.Mutex默认值是未锁状态,所以我们简单的定义了一个锁lock sync.Mutex

看起来足够简单?上面的例子有欺骗性。在并发编程时,会碰到一系列很严重的bug。首先,那些需要被保护代码通常都不是这么明显。虽然它可能是想使用一个粗锁(涵盖了大量代码的锁),但这破坏了并发编程首要原则。我们需要适度的锁,或者说,我们最终由一个10快车道的突然转变成一个单车道。

另一个问题是如何处理死锁。只有一个锁的时候,这不是问题,但是如果你在相同的代码中使用2个或者更多的锁,就很容易出现一种危险的情况,即协程A拥有锁lockA,想去访问锁lockB,同时协程B拥有lockB并需要访问锁lockA

实际上使用一个锁也有可能发生死锁,如果我们忘记释放它时。但是这和多个锁引起的死锁为比起来,危害性不大(因为这真的很少出现),但只是想让你看会发生什么,试试下面的代码:

package main

import (
  "time"
  "sync"
)

var (
  lock sync.Mutex
)

func main() {
  go func() { lock.Lock() }()
  time.Sleep(time.Millisecond * 10)
  lock.Lock()
}

接下来我们会介绍更多的并发编程。一方面,另一个常见的互斥锁叫读写互斥锁。它主要提供2中锁功能:一个读锁定和一个写锁定。在Go中,sync.RWMutex就是这种锁。另外sync.Mutex结构不但提供了LockUnlock方法,也提供了RLockRLock方法,这里的R代表。虽然读写锁很常用,但是他们也给开发者带来一些额外的负担:我们不但要关注我们何时访问数据,而且也要关注如何访问。

此外,部分并发编程不只是通过为数不多代码按顺序的访问变量,也需要协调多个go协程。例如,休眠10毫秒不是一种优雅的方法。如果一个Go协程运行的时间超过10毫秒呢?如果Go协程运行时间少于10毫秒,我们只是浪费了cpu?又或者可以等待Go协程运行完毕,我们告诉另外一个Go协程嗨,我有一些新数据给你处理?

所有的这些事在不使用通道的情况下也都是可以实现的。当然,对于更简单的例子,我认为你应该使用基本的功能例如sync.Mutexsync.RWMutex,但是在下一节我们将看到,通道的目的是为了使并发编程更清晰和不易出错。

通道

并发编程的最在挑战来自共享数据。如果你的Go协程没有共享数据,你不需要担心他们之间的同步。但是这不是所有系统的选择。事实上,许多系统的构建就是为了:在多个请求中共享数据。内存缓存或者数据库,都是很好的例子。这也成为越来越普遍的事实。

通过共享数据规划,通道使并发编程更清晰。一个通道是一个通信管道用于Go协程之间的数据传递。换一句话来说。一个Go协程可以通过通道来把数据传递给另一个Go协程。这样做的结果就是,无论什么时间节点,都只有一个Go协程可以访问共享数据。

通道和其他类型一样有类型。这个类型就是我们将在通道中传递的数据类型。例如,创建一个用来传递整数的通道,我们这样做:

c := make(chan int)

The type of this channel is chan int. Therefore, to pass this channel to a function, our signature looks like:

这个通道的类型是chan int。因此,将这个通道传递给一个函数是,可以这样声明:

func worker(c chan int) { ... }

通道支持2种操作:接收和发送。我们可以使用下面方式往通道发送数据:

CHANNEL <- DATA

然后可以使用下面方式从通道接收数据:

VAR := <-CHANNEL

箭头的方向就是数据的流动方向。当发送数据时,数据流入通道。当发送数据时,数据是流出通道。

最后,在看我们的第一个例子之前,从一个通道接收或者发送数据时会阻塞。也就是说,当我们从一个通道接收数据时,直到数据可用Go协程才会继续执行。类似的,往一个通道发送数据时,在数据被接收之前Go协程也不会继续执行。

假设这样的一个系统,我们想通过不同的协程来处理输入数据。这是一个常见的需求。如果通过Go协程接收输入的数据并进行数据密集型处理,那么在客户端会有超时风险。首先,我们将写出我们的处理器。这是一个简单的函数,但是我会让它变成一个结构体的部分,因为我们之前从来没有这样使用过Go协程:

type Worker struct {
  id int
}

func (w Worker) process(c chan int) {
  for {
    data := <-c
    fmt.Printf("worker %d got %d\n", w.id, data)
  }
}

我们的处理器很简单。它会一直等待直到数据可用并“处理”它。它通过一个循环来实现,永久等待更多的数据来处理。

为了使用上面的代码,我们首先要做的是启动一些处理器:

c := make(chan int)
for i := 0; i < 4; i++ {
  worker := Worker{id: i}
  go worker.process(c)
}

然后我们可以给他们一些工作:

for {
  c <- rand.Int()
  time.Sleep(time.Millisecond * 50)
}

下面是完整的可执行代码:

package main

import (
  "fmt"
  "time"
  "math/rand"
)

func main() {
  c := make(chan int)
  for i := 0; i < 5; i++ {
    worker := &Worker{id: i}
    go worker.process(c)
  }

  for {
    c <- rand.Int()
    time.Sleep(time.Millisecond * 50)
  }
}

type Worker struct {
  id int
}

func (w *Worker) process(c chan int) {
  for {
    data := <-c
    fmt.Printf("worker %d got %d\n", w.id, data)
  }
}

我们不知道哪个处理器将获得数据。我们所知道的是,Go确保了往一个通道发送数据时,仅有一个单独的接收器可以接受。

需要指出的是通道是唯一的共享方式,通过通道我们可以并发安全的发送和接收数据。通道提供了我们需要的所有同步代码,并且也确保在任意的特定时刻只有一个Go协程可以访问一个特定的数据。

带缓存的通道

在上面的代码中,如果输入的数据超过我们可以处理的数据会发生什么?你可以模拟这种场景,在处理器收到数据后执行time.Sleep

for {
  data := <-c
  fmt.Printf("worker %d got %d\n", w.id, data)
  time.Sleep(time.Millisecond * 500)
}

main函数中会发什么呢?接收用户的输入数据(这里通过一个随机的数字生成器模拟)会被阻塞,因为往通道发送数据时没有可用的接收者。

在这种情况下,你需要确保数据被处理,你可能想要让客户端阻塞。在其他情况下,你可能愿意不确保数据被处理。这里有一些流行的策略能完成此事。首先是将数据缓存起来。如果没有处理器可用,我们想将数据暂时存放在一个有序的队列中。通道内置缓存能力。当我们使用make创建一个通道时,我们可以指定通道的长度:

c := make(chan int, 100)

你可以做这样的修改,但是你将注意到处理过程仍然震荡。缓冲通道没有增加处理能力,他们只是为挂起的工作提供了一个队列和应对突发尖峰的好方法。在我们示例中,我们持续不断的发送超出我们处理器可以处理的数据。

尽管如此,事实上,我们可以查看通道的len,来了解到带缓存的通道的缓冲情况:

for {
  c <- rand.Int()
  fmt.Println(len(c))
  time.Sleep(time.Millisecond * 50)
}

你可以看到它的长度在不断增大,直到装满为止,此时,往通道发送的数据又开始被阻塞。

选择

即使借助缓存,有一点需要指出的是,我们需要开始丢弃一些消息,我们不能使用一个无限大的内存,并指望人工的释放它。所以我们使用Go的select

在语法结构上,select看起来有点类似switch。通过select,我们能写出一些针对通道不可写情况下的代码。首先,让我们去掉我们通道的缓存,这样可以更清晰的看到select是如何工作的。

c := make(chan int)

接下来,我们修改for循环:

for {
  select {
  case c <- rand.Int():
    //optional code here
  default:
    //this can be left empty to silently drop the data
    fmt.Println("dropped")
  }
  time.Sleep(time.Millisecond * 50)
}

我们每秒往通道中发送20个信息,但是我们的处理器每秒只能处理10个信息;因此,有一半的信息被丢弃。

这仅仅只是我们使用select完成一些事的开始。使用select的最主要目的是通过它管理多个通道。给定多个通道,select将阻塞直到有一个通道可用。如果没有可用的通道,当提供了default语句时,执行该分支。当多个通道都可用时,选择其中的一个通道是随机的。

很难想出一个简单的例子来证明这种行为,因为这是一种高级特性。在下一小节可能有助于说明这个问题。

超时

我们已经学习了缓存消息和简单丢弃消息。另外一种比较流行的做法是使用超时。我们将阻塞一段时间,但不是一直阻塞。在Go中这很容易实现。老实说,这个语法有点难于接受,但是它是比较灵活和有用的特性,我基本不能没有它。

为了达到阻塞的最大时限,我们可以使用time.After函数。让我们看看它,并试着看出其中的魔法。为了使用这种方式,我们的发送器需要修改为:

for {
  select {
  case c <- rand.Int():
  case <-time.After(time.Millisecond * 100):
    fmt.Println("timed out")
  }
  time.Sleep(time.Millisecond * 50)
}

time.After返回一个通道,所以我们可以对它使用select语法。当指定的时间到期时这个通道被写入。就是如此。没有其他更多的魔法了。如果你还是好奇,这里有一个after的实现:

func after(d time.Duration) chan bool {
  c := make(chan bool)
  go func() {
    time.Sleep(d)
    c <- true
  }()
  return c
}

回到我们的select中来,还一些内容可以研究。首先,如果添加了default条件会发生会什么呢?你可以猜猜?试试。如果你不确定会发生什么,记住如果没有可用的通道default会立即被触发。

同时,time.After的通道类型是chan time.Time。上面的例子中,我们简单的丢弃了发送给通道的值。如果你相要,你可以这样接收它:

case t := <-time.After(time.Millisecond * 100):
  fmt.Println("timed out at", t)

更近一步的看我们的select。注意我们向c发送数据,但是从time.After接收数据。select对无论是接收数据,发送数据,还是其他通道的组合,都是一样对待的:

  • 第一个可用的通道被选择。
  • 如果有多个通道可用,随机选择一个。
  • 如果没有通道可用,默认条件被执行。
  • 如果没有默认条件,选择阻塞。

最后,select通常在for循环中使用。例如:

for {
  select {
  case data := <-c:
    fmt.Printf("worker %d got %d\n", w.id, data)
  case <-time.After(time.Millisecond * 10):
    fmt.Println("Break time")
    time.Sleep(time.Second)
  }
}

继续之前

如果你是并发编程的新手,它可能显得相当庞大。它绝对是需要相当多的重视和关注。 Go的目标就是使其更容易。

Go协程有效的抽象了需要并发执行的代码。通道协助消除了可能在数据共享时的严重Bug。这不只是消除了Bug,更是改变了并发编程的开发方式。你开始使用消息传递的方式来考虑并发,而不是危险的共享代码。

虽然这么说,我仍然广泛使用的各种同步原语中发现的syncsync/atomic包。我觉得这两种情况都要适应是很重要的。我鼓励你先聚焦在通道上,但是如果你碰到只是需要短暂的多锁,建议你使用互斥锁或者读写互斥锁。

结论

我最近听说Go被描述为一门单调的语言。单调是因为它很容易学习,很容易编写,最为重要的是,很容易读。也许,我这是在帮倒忙,我确实花了三个章节来介绍类型和如何申请变量。

如果你在静态类型语言的背景,大多数我们看到的,充其量只是复习。同时Go的指针可见性和切片的轻量封装对经验丰富的Java的C#开发人员来说不算什么。

如果你更多的是使用动态语言,你可能会觉得有点不同。这一点公平的学习。不过其中最重要的是各种声明和初始化的语法。虽然是一个Go粉,我发现所有的努力都是为了简单,还有一些致简的东西。仍然,它也有一些基本的规则(比如变量申明一次和:=已经申明了变量)和基本的了解(比如new(X)&X{}只是分配了内存,但切片,字典和通道就需要使用make来分配内存和初始化)。

除此之外,Go提供了一个简洁但又高效的方式来组织我们的代码。接口,基于返回值的错误处理,用于资源管理的defer和简单的实现组合。

最后但是最重要的是它内置了对并发的支持。还有一点关于Go协程的要说就是它们高效和简单(反正使用简单)。这是很好的抽象。通道会更复杂一点。我一直认为在学习高级封装之前要掌握好基础。我确认认为不使用通道来进行并发编程是有益的。但是,通道的实现方式,对我来说,不太像是一个简单的抽象。它们有自己的基础构建。我这么说是因为它们改变了你对并发编程的思考和书写方式。鉴于并发编程的难度,这绝对是一个好事。

0

评论区