Go 高级概念
本页目录
这篇笔记讨论 Go 中的并发、面向对象与函数的高级用法。
任务目标
- 了解函数的执行流程,学会使用闭包和递归
- 了解如何把函数当做变量使用以及实现泛型
- 了解 Go 中的面向对象思想,并能够据此设计对应的数据结构
- 深入理解 Go 中的并发编程
函数
init 与 main
包初始化先处理依赖包,再初始化包级变量,最后执行该包的 init 函数;全部初始化完成后才运行主包的 main。同一包可以有多个 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 的函数是一等值,可以作为参数、返回值,也可以保存在数据结构中,供运行时选择。函数值可以与 nil 比较,不能任意比较两个函数是否相等。
- 如果需要大量的分支,无论是用 if 还是 switch 实现都很麻烦也不好维护,这时候可以考虑使用映射和函数引用来制造分支
- 如果我们需要灵活变化函数具体的执行方式,可以考虑新建一个和这个函数签名相同的包级别的变量,然后利用分支语句进行动态绑定。
泛型函数
旧稿所说“不直接支持泛型”已经过时。现在可以声明带类型参数的函数;类型断言不等于泛型,也不提供同样的编译期约束。例如,保留切片元素的类型并处理空切片:
func First[T any](items []T) (T, bool) {
if len(items) == 0 {
var zero T
return zero, false
}
return items[0], true
}
面向对象
Go 用组合、嵌入和接口来组织行为,没有类继承体系。普通接口由方法集隐式满足。参数可以被声明为一个具体类型,也可以是接口,对于一个声明为接口的参数,我们可以传入任意值,只要该值包含该接口所声明的方法。
在 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 中我们可以根据需要给出更加有意义的名称。
接口
这里讨论作为值类型使用的普通接口,它可以声明方法,也可以是没有方法要求的空接口 any。接口是完全抽象,因此不能将其实例化。然而,可以创建一个其类型为接口的变量,它可以被赋值为任何满足该接口类型的实际类型的值。
一个简单的接口为:
type Exchanger interface {
Exchange()
}
单方法接口常按方法命名并加上 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) {
tasks.slice = append(tasks.slice, task)
tasks.Increment()
}
func (tasks *Tasks) Tally() int {
return int(tasks.Count)
}
并发
Go 对于并发的解决方案有 3 个优点:语言支持、goroutine 和自动垃圾回收机制。
在并发编程里,我们通常想将一个过程切分成几块,然后让每个 goroutine 各自负责一块工作,除此之外还有 main() 函数也是由一个单独的 goroutine 来执行的。这里需要注意的是,当主 goroutine 退出后,程序就会结束,不会等待其他 goroutine 完成,因此要通过通道或 sync.WaitGroup 等机制协调结束。
另一个可能发生的情况就是死锁,比如工作完成了,但是主 goroutine 无法获知工作 goroutine 的完成状态。为了避免程序提前退出或不能正常退出,常见的做法是让主 goroutine 在一个 done 通道上等待,根据接收到的消息来判断工作是否完成了。然而,就算只使用通道,在 Go 语言里仍然可能发生死锁。
本质上说,在通道里传输布尔类型、整型或者浮点数类型的值都是安全的,因为都是值传递;而因为字符串也是不可变的,所以也是安全的。但是 Go 并不能保证在通道里发送指针或者引用类型(如切片或映射)的安全性,因为指针指向的内容或者所引用的值可能在对方接收到时已经被发送方修改。因此,发送切片不等于深拷贝其底层数组;共享数据的并发写入需要同步。可以约定所有权交接,也可以用锁保护;只读共享不必一概串行化。
通道通常由发送方在确定不会再发送后关闭。close 表示发送结束,供接收方的 range 等逻辑判断终点,并不是释放内存的动作。不是每个通道都必须关闭,但所有可能等待的 goroutine 都应该有明确的退出路径。
这里重点需要理解的几个编程范式是:管道、独立并发任务和相互依赖的并发任务。
以上更新按 Go 语言规范 校订。闭包例子使用标准库 strings,输出使用 fmt,放入完整程序时需要导入相应包。
相关文章
- Python 编程思想:名字、对象与资源边界2026-11-06
- 【Lua 之旅】01 语言简介2022-03-27
- 【Golang 之旅】01 语言简介2020-09-25
- RESTful API 框架 HUG 指南2017-03-09