主页

索引

模块索引

搜索页面

TonyBai Go语言第一课

课程设计

课程总共分为五个阶段,分别是前置篇、入门篇、基础篇、核心篇,以及实战篇。 第一个阶段:前置篇,“心定”建立认同感。在这一部分,Tony Bai 老师会带你了解 Go 的前世今生和设计哲学。这部分会让你从设计目标、设计哲学,以及演化进程等方面,全面建立起你对 Go 语言的认同感,避免出现“Hello-and-Bye”的情况,这是学好 Go 的前提。 第二个阶段:入门篇,“手勤”多动手实践。在这一部分中,Tony Bai 老师会让你的第一个 Go 程序跑起来,看看一些实用 Go 程序都有哪些语法元素和结构。这部分会建立你“照猫画虎”的能力,让你可以随心所欲地进行实践。 第三个阶段:基础篇,“脑勤”多理解,夯实基础。这一部分,Tony Bai 老师会围绕着“程序 = 数据 + 算法”的逻辑,从变量、常量等基本概念,到数据类型,再到广义的算法,让你可以用 Go 建立对现实世界的抽象认知,也能明白 Go 程序运行的基本逻辑。 第四个阶段:核心篇,“脑勤 +”建立自己的 Go 应用设计意识。在这一部分,Tony Bai 老师会跟你介绍 Go 语言独有的,或者是有比较大创新的接口类型与 goroutine 等并发原语类型,这些语法元素是 Go 语言的核心。从这部分开始,你会树立自己的 Go 应用“设计意识”。 第五个阶段:实战篇,攻克 Go 开发的“最后一公里”。在这一部分,Tony Bai 老师会通过一个实战的例子,展示怎么做好学习与使用之间的衔接,帮助你走完“使用 Go 进行生产级开发”这“最后一公里”。

00开篇

https://img.zhaoweiguo.com/uPic/2023/06/rlMBzq.jpg

01| Go的历史和现状

https://img.zhaoweiguo.com/uPic/2023/06/vSKy9e.jpg

Go 语言之父们(从左到右分别是 Robert Griesemer、Rob Pike 和 Ken Thompson)分别是图灵奖获得者、C 语法联合发明人、Unix 之父肯・汤普森(Ken Thompson),Plan 9 操作系统领导者、UTF-8 编码的最初设计者罗伯・派克(Rob Pike),以及 Java 的 HotSpot 虚拟机和 Chrome 浏览器的 JavaScript V8 引擎的设计者之一罗伯特・格瑞史莫(Robert Griesemer)。

https://img.zhaoweiguo.com/uPic/2023/06/6sqyPr.jpg

Go 语言第一版特性设计稿:主要思路是,在 C 语言的基础上,修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能,比如,使用 import 替代 include、去掉宏、增加垃圾回收、支持接口等。

https://img.zhaoweiguo.com/uPic/2023/06/8AFsjr.jpg

2007 年 9 月 25 日,罗伯・派克在一封回复电邮中把这门新编程语言命名为 “go”

  • 2008 年初,Unix 之父肯・汤普森实现了第一版 Go 编译器,用于验证之前的设计。这个编译器先将 Go 代码转换为 C 代码,再由 C 编译器编译成二进制文件。

  • 2008 年年中,Go 的第一版设计就基本结束了。这时,同样在谷歌工作的伊恩・泰勒(Ian Lance Taylor)为 Go 语言实现了一个 gcc 的前端,这也是 Go 语言的第二个编译器。随后,伊恩・泰勒以团队的第四位成员的身份正式加入 Go 语言开发团队,后面也成为了 Go 语言,以及其工具设计和实现的核心人物之一。

  • 罗斯・考克斯(Russ Cox)是 Go 核心开发团队的第五位成员,也是在 2008 年加入的。进入团队后,罗斯・考克斯利用函数类型是 “一等公民”,而且它也可以拥有自己的方法这个特性巧妙设计出了 http 包的 HandlerFunc 类型。这样,我们通过显式转型就可以让一个普通函数成为满足 http.Handler 接口的类型了。还在当时设计的基础上提出了一些更泛化的想法,比如 io.Reader 和 io.Writer 接口,这就奠定了 Go 语言的 I/O 结构模型。后来,罗斯・考克斯成为 Go 核心技术团队的负责人,推动 Go 语言的持续演化。

https://img.zhaoweiguo.com/uPic/2023/06/CZLj3J.jpg

2009 年 10 月 30 日,罗伯・派克在 Google Techtalk 上做了一次有关 Go 语言的演讲 “The Go Programming Language”,这也是 Go 语言第一次公之于众。十天后,也就是 2009 年 11 月 10 日,谷歌官方宣布 Go 语言项目开源,之后这一天也被 Go 官方确定为 Go 语言的诞生日。

https://img.zhaoweiguo.com/uPic/2023/06/9OKOLx.jpg

Go 1.0 版本正式发布

https://img.zhaoweiguo.com/uPic/2023/06/pmUuMU.jpg

Go 语言大事记

02| Go 语言的设计哲学

01入门篇: 勤加练手 (7 讲)

  • Go 核心团队在 Go 1.5 版本中增加了 vendor 构建机制;Go 语言项目自身也在 Go 1.6 版本中增加了 vendor 目录以支持 vendor 构建。vendor 机制与目录的引入,让 Go 项目第一次具有了可重现构建(Reproducible Build)的能力

  • 在 Go 1.11 版本中,引入了 Go Module 构建机制,也就是在项目引入 go.mod 以及在 go.mod 中明确项目所依赖的第三方包和版本,项目的构建就将摆脱 GOPATH 的束缚,实现精准的可重现构建。Go 语言项目自身在 Go 1.13 版本引入 go.mod 和 go.sum 以支持 Go Module 构建机制

Go 可执行程序项目的典型结构布局:

$tree -F exe-layout
exe-layout
├── cmd/
│   ├── app1/
│   │   └── main.go
│   └── app2/
│       └── main.go
├── go.mod
├── go.sum
├── internal/
│   ├── pkga/
│   │   └── pkg_a.go
│   └── pkgb/
│       └── pkg_b.go
├── pkg1/
│   └── pkg1.go
├── pkg2/
│   └── pkg2.go
└── vendor/
https://img.zhaoweiguo.com/uPic/2023/06/EC8C82.jpg

myproject 有两个直接依赖 A 和 B,A 和 B 有一个共同的依赖包 C,但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为 C v1.7.0。Go 会在该项目依赖项的所有版本中,选出符合项目整体要求的 “最小版本”。此例中,C v1.3.0 是符合项目整体要求的版本集合中的版本最小的那个,于是 Go 命令选择了 C v1.3.0,而不是最新最大的 C v1.7.0。

https://img.zhaoweiguo.com/uPic/2023/06/te602u.jpg

Go 包的初始化次序

init 函数的常用用途:

1. 重置包级变量值
2. 实现对包级变量的复杂初始化
3. 在 init 函数中实现 “注册模式”

02基础篇: “脑勤” 多理解 (20 讲)

数据类型

X进制的字面值与格式化输出:

c1 := 0xaabbcc // 十六进制,以 "0x" 为前缀
c2 := 0Xddeeff // 十六进制,以 "0X" 为前缀
d1 := 0b10000001 // 二进制,以 "0b" 为前缀
d2 := 0B10000001 // 二进制,以 "0B" 为前缀
e1 := 0o700      // 八进制,以 "0o" 为前缀
e2 := 0O700      // 八进制,以 "0O" 为前缀

为提升字面值的可读性,Go 1.13 版本还支持在字面值中增加数字分隔符 “_”:
a := 5_3_7   // 十进制: 537
b := 0b_1000_0111  // 二进制位表示为 10000111
c1 := 0_700  // 八进制: 0700
c2 := 0o_700 // 八进制: 0700
d1 := 0x_5c_6d // 十六进制:0x5c6d

格式化输出:
var a int8 = 59
fmt.Printf("%b\n", a) // 输出二进制:111011
fmt.Printf("%d\n", a) // 输出十进制:59
fmt.Printf("%o\n", a) // 输出八进制:73
fmt.Printf("%O\n", a) // 输出八进制 (带 0o 前缀):0o73
fmt.Printf("%x\n", a) // 输出十六进制 (小写):3b
fmt.Printf("%X\n", a) // 输出十六进制 (大写):3B

Float的字面值与格式化输出:

3.1415
.15  // 整数部分如果为 0,整数部分可以省略不写
82. // 小数部分如果为 0,小数点后的 0 可以省略不写

科学计数法形式:
6674.28e-2 // 6674.28 * 10^(-2) = 66.742800
.12345E+5  // 0.12345 * 10^5 = 12345.000000

十六进制科学计数法形式:
0x2.p10  // 2.0 * 2^10 = 2048.000000
0x1.Fp+0 // 1.9375 * 2^0 = 1.937500
    整数部分、小数部分用的都是十六进制形式,但指数部分依然是十进制形式
    字面值中的 p/P 代表的幂运算的底数为 2

格式化输出:
var f float64 = 123.45678
fmt.Printf("%f\n", f) // 123.456780
fmt.Printf("%e\n", f) // 1.234568e+02
fmt.Printf("%x\n", f) // 0x1.edd3be22e5de1p+06
    % e 输出的是十进制的科学计数法形式
    % x 输出的则是十六进制的科学计数法形式

复数类型:

var c = 5 + 6i
var d = 0o123 + .12345E+5i // 83+12345i
var c = complex(5, 6) // 5 + 6i
var d = complex(0o123, .12345E+5) // 83+12345i

通过 Go 提供的预定义的函数 real 和 imag,来获取一个复数的实部与虚部,返回值为一个浮点类型
var c = complex(5, 6) // 5 + 6i
r := real(c) // 5.000000
i := imag(c) // 6.000000

创建自定义的数值类型:

type MyInt int32
var n int32 = 6
var a MyInt = m // 错误:在赋值中不能将 m(int 类型)作为 MyInt 类型使用
var a MyInt = MyInt(m) // ok

类型别名(Type Alias)
type MyInt = int32
var n int32 = 6
var a MyInt = n // ok
说明:这种类型定义方式通常用在项目的渐进式重构,还有对已有包的二次封装方面

Unicode 专用的转义字符 u 或 U 作为前缀:

'\u4e2d'     // 字符:中
'\U00004e2d' // 字符:中
'\u0027'     // 单引号字符

方法vs函数

方法选择 receiver 参数类型的原则:

1. 如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 *T 作为 receiver 参数的类型。
2. 如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,那么我们应该选择 *T 作为 receiver 参数的类型。
3. T 类型是否要实现某一接口

receiver 参数类型是否使用指针类型的“语法糖”:

1. Go 判断 t1 的类型为 T,也就是与方法 M2 的 receiver 参数类型 *T 不一致后,会自动将 t1.M2() 转换为 (&t1).M2()
2. Go 判断 t2 的类型为 *T,与方法 M1 的 receiver 参数类型 T 不一致,就会自动将 t2.M1() 转换为 (*t2).M1()

示例:
type T struct {
      a int
  }

  func (t T) M1() {
      t.a = 10
  }

 func (t *T) M2() {
     t.a = 11
 }

 func main() {
     var t1 T
     println(t1.a) // 0
     t1.M1()
     println(t1.a) // 0
     t1.M2()
     println(t1.a) // 11

     var t2 = &T{}
     println(t2.a) // 0
     t2.M1()
     println(t2.a) // 0
     t2.M2()
     println(t2.a) // 11
 }

*T/T 类型是否实现某接口的不同点:

type Interface interface {
    M1()
    M2()
}
type T struct{}
func (t T) M1()  {}
func (t *T) M2() {}
func main() {
    var t T
    var pt *T
    var i Interface
    i = pt
    i = t // cannot use t (type T) as type Interface in assignment: T does not implement Interface (M2 method has pointer receiver)
}
  • 所谓的方法集合决定接口实现的含义就是:如果某类型 T 的方法集合与某接口类型的方法集合相同,或者类型 T 的方法集合是接口类型 I 方法集合的超集,那么我们就说这个类型 T 实现了接口 I。

  • 类型嵌入指的就是在一个类型的定义中嵌入了其他类型。Go 语言支持两种类型嵌入,分别是接口类型的类型嵌入和结构体类型的类型嵌入。

03核心篇: “脑勤 +” 洞彻核心 (5 讲)

接口

  • Go 接口是构建 Go 应用骨架的重要元素。

  • 接口的静态特性:意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。

  • 接口的动态特性:体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型被称为接口类型变量的动态类型。(右值可理解为是struct结构变量对应值)

动静皆备的优点:接口类型变量在程序运行时可以被赋值为不同的动态类型变量,每次赋值后,接口类型变量中存储的动态类型信息都会发生变化,这让 Go 语言可以像动态语言(比如 Python)那样拥有使用Duck Typing(鸭子类型)的灵活性。

扩展

  • Go 语言中每种类型都会有唯一的 _type 信息,无论是内置原生类型,还是自定义类型都有。Go 运行时会为程序内的全部类型建立只读的共享 _type 信息表

  • 接口类型变量在运行时表示为 eface 和 iface,eface 用于表示空接口类型变量,iface 用于表示非空接口类型变量

  • 只有两个接口类型变量的类型信息(eface._type/iface.tab._type)相同,且数据指针(eface.data/iface.data)所指数据相同时,两个接口类型变量才是相等的。

  • 参考: 29|接口:为什么nil接口不等于nil 值的再看(在深入研究interface{}内部结构时)

并发

  • 并发不是并行,并发关乎结构,并行关乎执行——Go 语言之父 Rob Pike

  • 不要通过共享内存来通信,应该通过通信来共享内存(Don’t communicate by sharing memory, share memory by communicating)”——Go 语言之父 Rob Pike

  • 在新并发模型设计中借鉴了著名计算机科学家Tony Hoare提出的 CSP(Communicationing Sequential Processes,通信顺序进程)并发模型。

  • 参考: 32|并发:聊聊Goroutine调度器的原理

G-P-M 模型

  • G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;

  • P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;

  • M: M 代表着真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

Goroutine 调度器模型与演化过程

最初的 G-M 模型
  • 2012 年 3 月 28 日,Go 1.0 正式发布。在这个版本中,Go 开发团队实现了一个简单的 Goroutine 调度器。在这个调度器中,每个 Goroutine 对应于运行时中的一个抽象结构:G(Goroutine) ,而被视作“物理 CPU”的操作系统线程,则被抽象为另外一个结构:M(machine)。调度器的工作就是将 G 调度到 M 上去运行。为了更好地控制程序中活跃的 M 的数量,调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。

G-P-M 调度模型
  • 《Scalable Go Scheduler Design》

  • 前英特尔黑带级工程师、现谷歌工程师德米特里 - 维尤科夫亲自操刀改进了 Go 调度器,在 Go 1.1 版本中实现了 G-P-M 调度模型和``work stealing 算法``

  • P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。

  • 缺点:不支持抢占式调度,这导致一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现“饿死”的情况。

https://img.zhaoweiguo.com/uPic/2023/06/ORcui5.jpg

G-P-M 调度模型和work stealing 算法

“抢占式”调度
  • 《Go Preemptive Scheduler Design》

  • 德米特里 - 维尤科夫在 Go 1.2 中实现了基于协作的“抢占式”调度

  • 抢占式调度的原理就是,Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。

  • 缺点:这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致 GC 在等待所有 Goroutine 停止时的等待时间过长,从而导致 GC 延迟,内存占用瞬间冲高;甚至在一些特殊情况下,导致在 STW(stop the world)时死锁。

对非协作的抢占式调度
  • Go 在 1.14 版本中接受了奥斯汀 - 克莱门茨(Austin Clements)的提案,增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。

未来
  • 德米特里 - 维尤科夫在 2014 年 9 月提出了一个新的设计草案文档:《NUMA‐aware scheduler for Go》,作为对未来 Goroutine 调度器演进方向的一个提议,不过至今似乎这个提议也没有列入开发计划。

goroutine pool

https://img.zhaoweiguo.com/uPic/2023/06/LMwz82.jpg

04实战篇: 打通“最后一公里” (4讲)

大咖助阵

曹春晖: 聊聊 Go 语言的 GC 实现

  • 写的比较深入,待后续研读

加餐

Go语言学习资料

Go 官方文档

Go 相关博客

Go 播客

Go 技术演讲

Go 日报 / 周刊邮件列表

其他

私有的Go Module

GOPROXY 实现

拉取私有 module 方案

https://img.zhaoweiguo.com/uPic/2023/06/XT7eab.jpg

【直接】所有代码,都放在公共 vcs 托管服务商那里(比如 github.com),私有 Go Module 则直接放在对应的公共 vcs 服务的 private repository(私有仓库)中。

https://img.zhaoweiguo.com/uPic/2023/06/Jrb70D.jpg

【直接】将私有 Go Module 放在公司 / 组织内部的 vcs(代码版本控制)服务器上。在每个开发机上,配置公共 GOPROXY 服务拉取公共 Go Module,同时再把私有仓库配置到 GOPRIVATE 环境变量,就可以了。这样,所有私有 module 的拉取,都会直连代码托管服务器,不会走 GOPROXY 代理服务,也不会去 GOSUMDB 服务器做 Go 包的 hash 值校验。

https://img.zhaoweiguo.com/uPic/2023/06/tSmyui.jpg

【in-house goproxy】这样做有两个目的,一是为那些无法直接访问外网的开发机器,以及 ci 机器提供拉取外部 Go Module 的途径,二来,由于 in-house goproxy 的 cache 的存在,这样做还可以加速公共 Go Module 的拉取效率。【说明】如何配置GOPROXY=goproxy.io或goproxy.cn的也相当于这种方式。

https://img.zhaoweiguo.com/uPic/2023/06/ox2ZM2.jpg

【in-house goproxy】开发者只需要把 GOPROXY 配置为 in-house goproxy,就可以统一拉取外部 Go Module 与私有 Go Module,将所有复杂性都交给 in-house goproxy 这个节点。

泛型

备注

Go 泛型是 Go 开源以来在语法层面的最大一次变动

Go 泛型设计的简史

  • 2009 年 12 月 3 日 Russ Cox 在其博客站点上发表的一篇文章开始的。在这篇叫“ 泛型窘境 “的文章中,Russ Cox 提出了 Go 泛型实现的三个可遵循的方法,以及每种方法的不足,也就是三个 slow(拖慢):

    1、拖慢程序员:不实现泛型,不会引入复杂性,但就像前面例子中那样,需要程序员花费精力重复实现 AddInt、AddInt64 等;
    2、拖慢编译器:就像 C++ 的泛型实现方案那样,通过增加编译器负担为每个类型实例生成一份单独的泛型函数的实现,这种方案产生了大量的代码,其中大部分是多余的,有时候还需要一个好的链接器来消除重复的拷贝;
    3、拖慢执行性能:就像 Java 的泛型实现方案那样,通过隐式的装箱和拆箱操作消除类型差异,虽然节省了空间,但代码执行效率低。
    
  • 伊恩・泰勒主要负责跟进 Go 泛型方案的设计。从 2010 到 2016 年,伊恩・泰勒先后提出了几版泛型设计方案,它们是:
  • 2017 年 7 月,Russ Cox 在 GopherCon 2017 大会上发表演讲“ Toward Go 2 “,正式吹响 Go 向下一个阶段演化的号角,包括重点解决泛型、包依赖以及错误处理等 Go 社区最广泛关注的问题。

  • 2018 年 8 月,也就是 GopherCon 2018 大会结束后不久,Go 核心团队发布了 Go2 draft proposal,这里面涵盖了由伊恩·泰勒和罗伯特·格瑞史莫操刀主写的 Go 泛型的第一版 draft proposal

  • 示例:

    // 第一版泛型技术草案中的典型泛型语法
    // 这版设计草案引入了 contract 关键字来定义泛型类型参数(type parameter)的约束、
    // 类型参数放在普通函数参数列表前面的小括号中,并用 type 关键字声明
    contract stringer(x T) {
        var s string = x.String()
    }
    
    func Stringify(type T stringer)(s []T) (ret []string) {
    }
    
  • 2019 年 7 月,伊恩·泰勒在 GopherCon 2019 大会上发表演讲“ Why Generics ”,并更新了泛型的`技术草案 <https://github.com/golang/proposal/blob/4a54a00950b56dd0096482d0edae46969d7432a6/design/go2draft-contracts.md>`_,简化了 contract 的语法设计,下面是简化后的 contract 语法

  • 示例:

    contract stringer(T) {
        T String() string
    }
    
  • 2020 年 6 月,一篇叫《Featherweight Go》论文发表在 arxiv.org 上,这篇论文出自著名计算机科学家、函数语言专家、Haskell 语言的设计者之一、Java 泛型的设计者菲利普·瓦德勒(Philip Wadler)之手。Rob Pike 邀请他帮助 Go 核心团队解决 Go 语言的泛型扩展问题,这篇论文就是菲利普·瓦德对这次邀请的回应。这篇论文为 Go 语言的一个最小语法子集设计了泛型语法 Featherweight Generic Go(FGG),并成功地给出了 FGG 到 Feighterweight Go(FG)的可行性实现的形式化证明。这篇论文的形式化证明给 Go 团队带来了很大信心,也让 Go 团队在一些泛型语法问题上达成更广泛的一致。

  • 2020 年 6 月末,伊恩·泰勒和罗伯特·格瑞史莫在 Go 官方博客发表了文章《The Next Step for Generics》,介绍了 Go 泛型工作的最新进展。Go 团队放弃了之前的技术草案,并重新编写了一个 新草案

  • 在这份新技术方案中,Go 团队放弃了引入 contract 关键字作为泛型类型参数的约束,而采用扩展后的 interface 来替代 contract:

    type Stringer interface {
        String() string
    }
    
    func Stringify(type T Stringer)(s []T) (ret []string) {
        ... ...
    }
    
  • 2020 年 11 月的 GopherCon 2020 大会,罗伯特·格瑞史莫与全世界的 Gopher 同步了 Go 泛型的最新进展和 roadmap,在最新的技术草案版本中,包裹类型参数的小括号被方括号取代,类型参数前面的 type 关键字也不再需要了:

    func Stringify[T Stringer](s []T) (ret []string) {
        ... ...
    }
    
  • 2021 年 1 月,Go 团队正式提出将泛型加入 Go 的 proposal,2021 年 2 月,这个提案被正式接受。

  • 2021 年 4 月,伊恩·泰勒在 GitHub 上发布issue,提议去除原 Go 泛型方案中置于 interface 定义中的 type list 中的 type 关键字,并引入 type set 的概念

  • 下面是相关示例代码:

    // 之前使用type list的方案
    type SignedInteger interface {
      type int, int8, int16, int32, int64
    }
    
    // type set理念下的新语法
    type SignedInteger interface {
      ~int | ~int8 | ~int16 | ~int32 | ~int64
    }
    
  • 2021 年 12 月 14 日,Go 1.18 beta1 版本发布,这个版本包含了对 Go 泛型的正式支持。Go 泛型的 最后一版 技术提案长达数十页

Go 泛型的基本语法

  • Go 泛型也称为类型参数

类型参数type parameter

普通函数的参数列表是这样的:

func Foo(x, y aType, z anotherType)

泛型函数的类型参数(type parameter)列表:

func GenericFoo[P aConstraint, Q anotherConstraint](x,y P, z Q)

// P、Q 是类型形参的名字,也就是类型
// aConstraint,anotherConstraint 代表类型参数的约束(constraint),我们可以理解为对类型参数可选值的一种限定
  • 在泛型函数声明时,我们并不知道 P、Q 两个类型参数具体代表的究竟是什么类型,要等到泛型函数具化(instantiation)时才能确定。

  • 按惯例,类型参数(type parameter)的名字都是首字母大写的,通常都是用单个大写字母命名。

约束constraint

  • 约束(constraint)规定了一个类型实参(type argument)必须满足的条件要求。如果某个类型满足了某个约束规定的所有条件要求,那么它就是这个约束修饰的类型形参的一个合法的类型实参。

  • 在 Go 泛型中,我们使用 interface 类型来定义约束。

  • Go 接口类型的定义也进行了扩展,我们既可以声明接口的方法集合,也可以声明可用作类型实参的类型列表。

示例:

type C1 interface {
    ~int | ~int32
    M1()
}

type T struct{}
func (T) M1() {
}

type T1 int
func (T1) M1() {
}

func foo[P C1](t P)() {
}

func main() {
    var t1 T1
    foo(t1)
    var t T
    foo(t) // 编译器报错:T does not implement C1
}

备注

做约束的接口类型与做传统接口的接口类型最好要分开定义,除非约束类型真的既需要方法集合,也需要类型列表。

类型具化instantiation

示例:

func Sort[Elem interface{ Less(y Elem) bool }](list []Elem) {
}

type book struct{}
func (x book) Less(y book) bool {
        return true
}

func main() {
    var bookshelf []book
    Sort[book](bookshelf) // 泛型函数调用
}

上面的泛型函数调用 Sort[book](bookshelf)会分成两个阶段:

第一个阶段就是具化(instantiation),整个具化过程如下:
    1. Sort[book],发现要排序的对象类型为 book
    2. 检查 book 类型是否满足模具的约束要求(也就是是否实现了约束定义中的 Less 方法)
        如果满足,就将其作为类型实参替换 Sort 函数中的类型形参,结果为 Sort[book]
        如果不满足,编译器就会报错
    3. 将泛型函数 Sort 具化为一个新函数,这里我们把它起名为 booksort,其函数原型为 func([]book)。本质上 booksort := Sort[book]

第二阶段是调用(invocation)
    一旦“排序机器”被生产出来,那么它就可以对目标对象进行排序了,这和普通的函数调用没有区别。
    相当于调用 booksort(bookshelf),整个过程只需要检查传入的函数实参(bookshelf)的类型与 booksort 函数原型中的形参类型([]book)是否匹配

用伪代码来表述上面两个过程:

Sort[book](bookshelf)

<=>

具化:booksort := Sort[book]
调用:booksort(bookshelf)

还可以像普通函数那样只传入普通参数实参,不用传入类型参数实参,Go 编译器会根据传入的实参变量,进行实参类型参数的自动推导(Argument type inference):

Sort(bookshelf)

泛型类型

除了函数可以携带类型参数变身为“泛型函数”外,类型也可以拥有类型参数而化身为“泛型类型”:

type Vector[T any] []T

使用泛型类型,我们也要遵循先具化,再使用的顺序:

type Vector[T any] []T

func (v Vector[T]) Dump() {
    fmt.Printf("%#v\n", v)
}

func main() {
    var iv = Vector[int]{1,2,3,4}
    var sv Vector[string]
    sv = []string{"a","b", "c", "d"}
    iv.Dump()
    sv.Dump()
}

什么情况适合使用泛型

  • 当编写的函数的操作元素的类型为 slice、map、channel 等特定类型的时候。如果一个函数接受这些类型的形参,并且函数代码没有对参数的元素类型作出任何假设,那么使用类型参数可能会非常有用。在这种场合下,泛型方案可以替代反射方案,获得更高的性能。

  • 编写通用数据结构。所谓的通用数据结构,指的是像切片或 map 这样,但 Go 语言又没有提供原生支持的类型。比如一个链表或一个二叉树

  • 在一些场合,使用类型参数替代接口类型,意味着代码可以避免进行类型断言(type assertion),并且在编译阶段还可以进行全面的类型静态检查。

什么情况不适合使用泛型

  • 如果你要对某一类型的值进行的全部操作,仅仅是在那个值上调用一个方法,请使用 interface 类型,而不是类型参数。比如,io.Reader 易读且高效,没有必要像下面代码中这样使用一个类型参数像调用 Read 方法那样去从一个值中读取数据:

    func ReadAll[reader io.Reader](r reader) ([]byte, error) // 错误的作法
    func ReadAll(r io.Reader) ([]byte, error) // 正确的作法
    
  • 当不同的类型使用一个共同的方法时,如果一个方法的实现对于所有类型都相同,就使用类型参数;相反,如果每种类型的实现各不相同,请使用不同的方法,不要使用类型参数。

  • 如果你发现自己多次编写完全相同的代码(样板代码),各个版本之间唯一的差别是代码使用不同的类型,那就请你考虑是否可以使用类型参数。反之,在你注意到自己要多次编写完全相同的代码之前,应该避免使用类型参数。

类型参数type parameter

Go 泛型设计方案已经 明确不支持的若干特性

1. 不支持泛型特化(specialization)
    即不支持编写一个泛型函数针对某个具体类型的特殊版本
2. 不支持元编程(metaprogramming)
    即不支持编写在编译时执行的代码来生成在运行时执行的代码
3. 不支持操作符方法(operator method)
    即只能用普通的方法(method)操作类型实例(比如:getIndex(k))
    而不能将操作符视为方法并自定义其实现,比如一个容器类型的下标访问 c[k]
4. 不支持变长的类型参数(type parameters)
...

Go 泛型方案的实质是对类型参数(type parameter)的支持,包括:

1. 泛型函数(generic function):带有类型参数的函数
2. 泛型类型(generic type):带有类型参数的自定义类型
3. 泛型方法(generic method):泛型类型的方法
https://img.zhaoweiguo.com/uPic/2023/06/k42BOj.jpg
https://img.zhaoweiguo.com/uPic/2023/06/U9S3Tw.jpg

泛型函数实例化(instantiation):Go首先会对泛型函数进行实例化(instantiation),即根据自动推断出的类型实参生成一个新函数(当然这一过程是在编译阶段完成的,不会对运行时性能产生影响),然后才会调用这个新函数对输入的函数参数进行处理。

  1. 泛型函数(generic function):带有类型参数的函数:

    具体格式:
        func genericsFunc[T1 constraint1, T2, constraint2, ..., Tn constraintN](ordinary parameters list) (return values list)
    示例:
        func maxGenerics[T ordered](sl []T) T {
             // ... ...
        }
    使用:
        m := maxGenerics[int]([]int{1, 2, -4, -6, 7, 0})
    
  2. 泛型类型(generic type):带有类型参数的自定义类型:

    格式:
        type TypeName[T1 constraint1, T2 constraint2, ..., Tn constraintN] TypeLiteral
    
    示例:
        type maxableSlice[T ordered] struct {
             elems []T
        }
    使用:
        var sl = maxableSlice[int]{
            elems: []int{1, 2, -4, -6, 7, 0},
        }
    

示例:

// 泛型类型中的类型参数作为:类型声明中字段的类型
type Set[T comparable] map[T]struct{}

type sliceFn[T any] struct {
  s   []T
  cmp func(T, T) bool
}

type Map[K, V any] struct {
  root    *node[K, V]
  compare func(K, K) int
}

// 泛型类型中的类型参数作为:复合类型的元素类型
type element[T any] struct {
  next *element[T]
  val  T
}

// 泛型类型中的类型参数作为:方法的参数和返回值类型
type Numeric interface {
  ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64 |
    ~complex64 | ~complex128
}

type NumericAbs[T Numeric] interface {
  Abs() T
}
  1. 泛型方法(generic method):泛型类型的方法:

    func (sl *maxableSlice[T]) max() T {
        if len(sl.elems) == 0 {
            panic("slice is empty")
        }
    
        max := sl.elems[0]
        for _, v := range sl.elems[1:] {
            if v > max {
                max = v
            }
        }
        return max
    }
    

定义约束constraints

  • 原计划在 Go 1.18 版本加入 Go 标准库的一些泛型约束的定义暂放在了Go 实验仓库中

内置约束:

最宽松的约束:any
支持比较操作的内置约束:comparable

自定义约束

凡是接口类型均可作为类型参数的约束:

func Stringify[T fmt.Stringer](s []T) (ret []string) {
    for _, v := range s {
        ret = append(ret, v.String())
    }
    return ret
}
type MyString string
func (s MyString) String() string {
    return string(s)
}
func main() {
    sl := Stringify([]MyString{"I", "love", "golang"})
    fmt.Println(sl) // 输出:[I love golang]
}

Go 接口类型声明语法做了扩展,支持在接口类型中放入类型元素(type element)信息:

type ordered interface {
  ~int | ~int8 | ~int16 | ~int32 | ~int64 |
  ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
  ~float32 | ~float64 | ~string
}
https://img.zhaoweiguo.com/uPic/2023/06/p5a6EG.jpg

Go 接口类型语法的扩展

类型元素:

不带有“~”符号的类型就代表其自身
带有“~”符号的类型则代表以该类型为底层类型

type Ia interface {
  int | string  // 仅代表int和string
}
type Ib interface {
  ~int | ~string  // 代表以int和string为底层类型的所有类型
}

Go 将接口类型分成了两类:

1. 基本接口类型(basic interface type)
    只包含方法元素,而不包含类型元素
    不仅可以当做常规接口类型来用,还可以作为泛型类型参数的约束
2. 非基本接口类型
    直接或间接(通过嵌入其他接口类型)包含了类型元素的接口类型
    仅可以用作泛型类型参数的约束,或被嵌入到其他仅作为约束的接口类型中

type BasicInterface interface { // 基本接口类型
    M1()
}
type NonBasicInterface interface { // 非基本接口类型
    BasicInterface
    ~int | ~string // 包含类型元素
}
type MyString string
func (MyString) M1() {
}

func foo[T NonBasicInterface](a T) { // 非基本接口类型作为约束
}

func bar[T BasicInterface](a T) { // 基本接口类型作为约束
}

func main() {
    var s = MyString("hello")
    var bi BasicInterface = s // 基本接口类型支持常规用法
    var nbi NonBasicInterface = s // cannot use type NonBasicInterface outside a type constraint: interface contains type constraints
    bi.M1()
    nbi.M1()
    foo(s)
    bar(s)
}

类型集合type set

  • Go 泛型落地时引入的新概念:类型集合(type set),类型集合将作为后续判断类型是否满足约束的基本手段。

  • 类型集合(type set)的概念是 Go 核心团队在 2021 年 4 月更新 Go 泛型设计方案时引入的。在那一次方案变更中,原方案中用于接口类型中定义类型元素的 type 关键字被去除了,泛型相关语法得到了进一步的简化。

每个类型都有一个类型集合
非接口类型的类型的类型集合中仅包含其自身,比如非接口类型 T,它的类型集合为{T}

* 空接口类型(any 或 interface{})的类型集合是一个无限集合,该集合中的元素为所有非接口类型。
* 非空接口类型的类型集合则是其定义中接口元素的类型集合的交集


* 当接口元素为其他嵌入接口类型时,该接口元素的类型集合就为该嵌入接口类型的类型集合;
* 当接口元素为常规方法元素时,接口元素的类型集合就为该方法的类型集合。

* Go 规定一个方法的类型集合为所有实现了该方法的非接口类型的集合,这显然也是一个无限集合
* => 包含多个方法的常规接口类型的类型集合,就是这些方法元素的类型集合的交集,即所有实现了这三个方法的类型所组成的集合。

示例:

type Intf1 interface {
    ~int | string
  F1()
  F2()
}
type Intf2 interface {
  ~int | ~float64
}
type I interface {
    Intf1
    M1()
    M2()
    int | ~string | Intf2
}

示例:

type I interface { // 独立于泛型函数外面定义
    ~int | ~string
}
func doSomething1[T I](t T)
func doSomething2[T interface{~int | ~string}](t T) // 以接口类型字面值作为约束

func doSomething2[T ~int | ~string](t T) // 简化版的约束形式

Go 泛型的使用时机

  • 场景一:编写通用数据结构时

  • 场景二:函数操作的是 Go 原生的容器类型时

  • 场景三:不同类型实现一些方法的逻辑相同时

  • 负责泛型实现设计的Keith Randall 博士一口气提交了三个实现方案

Stenciling 方案

https://img.zhaoweiguo.com/uPic/2023/06/NxjMRg.jpg

“单态化(monomorphization)”。单态是相对于泛型函数的参数化多态(parametric polymorphism)而言的。

Dictionaries 方案

https://img.zhaoweiguo.com/uPic/2023/06/9JHQtv.jpg

Dictionaries 方案与 Stenciling 方案的实现思路正相反,它不会为每个类型实参单独创建一套代码,反之它仅会有一套函数逻辑,但这个函数会多出一个参数 dict,这个参数会作为该函数的第一个参数,这和 Go 方法的 receiver 参数在方法调用时自动作为第一个参数有些类似。这个 dict 参数中保存泛型函数调用时的类型实参的类型相关信息。

GC Shape Stenciling 方案

  • https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md

  • Go 最终采用的方案:GC Shape Stenciling 方案

  • GC Shape Stenciling 方案顾名思义,它基于 Stenciling 方案,但又没有为所有类型实参生成单独的函数代码,而是以一个类型的 GC shape 为单元进行函数代码生成。一个类型的 GC shape 是指该类型在 Go 内存分配器 / 垃圾收集器中的表示,这个表示由类型的大小、所需的对齐方式以及类型中包含指针的部分所决定。

  • 该方案同样在每个实例化后的函数代码中自动增加了一个 dict 参数,用于区别 GC shape 相同的不同类型。可见,GC Shape Stenciling 方案本质上是 Stenciling 方案和 Dictionaries 方案的混合版,它也是 Go 1.18 泛型最终采用的实现方案,

https://img.zhaoweiguo.com/uPic/2023/06/PraXUk.jpg

GC Shape Stenciling 方案的示意图

https://img.zhaoweiguo.com/uPic/2023/06/9gRnG5.jpg

从上图我们看到,Go 编译器为每个底层类型相同的类型生成一份函数代码,像 MyInt 和 int、rune 和 int32;对于所有指针类型,像上面的 *float64 、int 和int32,仅生成一份名为 main.f[go.shape.*uint8_0] 的函数代码。

主页

索引

模块索引

搜索页面