Go 高级概念

前一讲我们了解了 Go 的基本入门知识,这一次我们就来聊聊 Go 中的并发、面向对象与函数的高级用法。


更新记录:

  • 2016.12.13: 完成初稿(例子很少,暂时略过)

任务目标

  1. 了解函数的执行流程,学会使用闭包和递归
  2. 了解如何把函数当做变量使用以及实现泛型
  3. 了解 Go 中的面向对象思想,并能够据此设计对应的数据结构
  4. 深入理解 Go 中的并发编程

函数

init 与 main

在 Go 中 init()main() 函数比较特别,其中 init() 可以出现在任何包中,但每个包最好只有一个;而 main() 则只能在 main 包中出现。在执行 main 的时候,会依次导入所依赖的包,在导入时会执行对应包中的 init() 函数,但需要注意的是,在 init() 中不应该依赖任何在 main() 中创建的内容

闭包

在 Go 中所有的函数字面量(即匿名函数)都是闭包。闭包的创建方法和普通函数在语法上几乎一致,但有一个关键的区别:闭包没有名字,所以 func 关键字之后紧跟着左括号。闭包的一个简单的用法就是利用包装函数来为被包装的函数预定义一到多个参数。比如说为文件添加后缀名,一个可能的用法是:

addPng := func(name string) string { return name + ".png" }
addJpg := func(name string) string { return name + ".jpg" }
fmt.Println(addPng("filename"), addJpg("filename"))

但是这种写法还是有很多的重复,我们可以用一个工厂函数来生成我们所需要的函数并返回,比如:

func MakeAddSuffix(suffix string) func(string) string {
return func(name string) string {
if !strings.HasSuffix(name, suffix) {
return name + suffix
}
return name
}
}
// 具体使用时
addZip := MakeAddSuffix(".zip")
addTgz := MakeAddSuffix(".tar.gz")
fmt.Println(addTgz("filename"), addZip("filename"), addZip("gobook.zip"))

运行时选择函数

程序的动态性虽然会增加理解的难度,但是在面对大量重复代码的时候,是非常好的解决方案,因为在 Go 中函数是可以保存的引用,所以实际上就是一个指针,也就意味着我们可以把它保存在各种数据结构中,也可以在执行时动态绑定。

  • 如果需要大量的分支,无论是用 if 还是 switch 实现都很麻烦也不好维护,这时候可以考虑使用映射和函数引用来制造分支
  • 如果我们需要灵活变化函数具体的执行方式,可以考虑新建一个和这个函数签名相同的包级别的变量,然后利用分支语句进行动态绑定。

泛型函数

Go 语言中比较为人诟病的是不直接提供对泛型函数的支持,但是我们实际上可以通过类型断言来曲线救国。不过我个人感觉如果函数设计得当,用简单的类型转换就可以完成泛型函数的操作,没必要拘泥与其他语言的概念。

另外我们还可以使用高阶函数(即一个或者多个其他函数是自己的参数)来更加灵活地实现泛型函数所能够达到的效果。

面向对象

Go 只支持聚合,因为没有了继承,也就没有了虚函数,而是使用类型安全的鸭子类型(duck type)。参数可以被声明为一个具体类型,也可以是接口,对于一个声明为接口的参数,我们可以传入任意值,只要该值包含该接口所声明的方法。

在 Go 中接口、值和方法都相互保持独立。接口用于声明方法签名,结构体用于声明聚合或者嵌入的值,而方法用于声明在自定义类型(通常为结构体)上的操作。

一种按 Go 语言的方式思考的方法是,把 is-a 关系看成是由接口来定义,也就是方法的签名。虽然没法为内置类型添加方法,但可以很容易地基于内置类型创建自定义的类型,然后为其添加任何我们想要的方法。

方法是作用在自定义类型的值上的一类特殊函数,通常自定义类型的值会被传递给函数。对于任何一个给定的类型,每个方法名必须唯一,也不支持重载,Go 推荐使用名字唯一的函数,比如:

type Count int
func (count *Count) Increment() { *count++ }
func (count *Count) Decrement() { *count-- }
func (count Count) IsZero() bool { return count == 0 }

前两个声明为接受一个指针类型的接收者,因为这两个函数都修改了它们的值。在 C++/Java 中接收者统一叫做 this,Python 中叫做 self,在 Go 中我们可以根据需要给出更加有意义的名称。

接口

在 Go 中,接口是一个自定义类型,它声明了一个或多个方法签名。接口是完全抽象,因此不能将其实例化。然而,可以创建一个其类型为接口的变量,它可以被赋值为任何满足该接口类型的实际类型的值。

一个简单的接口为:

type Exchanger interface {
Exchange()
}

根据 Go 的惯例,定义接口时接口名字需要以 er 结尾。一个非空接口自身并没有什么用处,需要有实现这个接口的自定义类型才行。另外,接口可以嵌入其他的接口,比如:

type LowerCaser interface {
LowerCase()
}
type UpperCaser interface {
UpperCase()
}
type LowerUpperCaser interface {
LowerCaser
UpperCaser
}

结构体

主要是两种,聚合与嵌入。这里需要注意的是结构体中的每一个字段的名字都必须是唯一的,比如:

type Person struct {
Title string // 聚合
Forenames []string // 聚合
Surname string // 聚合
}
type Author1 struct {
Names Person // 聚合
Title []string // 聚合
YearBorn int // 聚合
}
type Author2 struct {
Person // 嵌入
Title []string // 聚合
YearBorn int // 聚合
}

如果一个嵌入字段带有方法,那我们就可以在外部结构体中直接调用它,并且只有嵌入的字段会作为接收者传递给这这些方法,如:

type Tasks struct {
slice []string // 聚合
Count // 嵌入
}
func (tasks *Tasks) Add(task string) {
task.slice = append(task.slice, task)
task.Increment()
}
func (tasks *Tasks) Tally() int {
return int(tasks.Count)
}

并发

Go 对于并发的解决方案有 3 个优点:语言支持、goroutine 和自动垃圾回收机制。

在并发编程里,我们通常想将一个过程切分成几块,然后让每个 goroutine 各自负责一块工作,除此之外还有 main() 函数也是由一个单独的 goroutine 来执行的。这里需要注意的是,当主 goroutine 退出后,其他的工作 goroutine 也会自动退出,所以我们必须非常小新地保证所有工作 goroutine 都完成后才让主 goroutine 退出。

另一个可能发生的情况就是死锁,比如工作完成了,但是主 goroutine 无法获知工作 goroutine 的完成状态。为了避免程序提前退出或不能正常退出,常见的做法是让主 goroutine 在一个 done 通道上等待,根据接收到的消息来判断工作是否完成了。然而,就算只使用通道,在 Go 语言里仍然可能发生死锁。

本质上说,在通道里传输布尔类型、整型或者浮点数类型的值都是安全的,因为都是值传递;而因为字符串也是不可变的,所以也是安全的。但是 Go 并不能保证在通道里发送指针或者引用类型(如切片或映射)的安全性,因为指针指向的内容或者所引用的值可能在对方接收到时已经被发送方修改。所以当涉及指针和引用时,我们必须保证这些值在任何时候只能被一个 goroutine 访问得到,也就是说,对这些值的访问必须是串行进行的。

对于通道的使用有俩经验:

  1. 只有在后面要检查通道是否关闭的时候才需要显式地关闭通道
  2. 应该由发送端的 goroutine 关闭通道,而不是由接收端的 goroutine 来完成。如果通道并不需要检查是否被关闭,那么不关闭这些通道并没有什么问题,因为通道非常轻量。

这里重点需要理解的几个编程范式是:管道、独立并发任务和相互依赖的并发任务。

捧个钱场?