之前一直有了解 Go 语言,但没系统的学习,最近兴趣盎然生计所迫,得学一学了。整体来说 Go 语法很简洁,有其它语言基础的话学起来很快的。本文记录了一些学习笔记,加深理解。
目录
基础语法
学语言,最重要的就是官方教程(https://go.dev/tour/list),建议不要一开始就跟着杂七杂八的教程学习。
变量
我们都知道 Go 语言是强类型的语言,所以定义变量的时候需要定义其类型,但像 C、Java,都是把类型放在前面:
1 2 3 4 5 6 | // C int i = 1; // Java int i = 1; // Go var i int = 1 |
咋一看有些别扭,但这有讲究的,比如符合英语系自然语言习惯,还有写起来就很方便,更突出变量名而不是类型(而且类型可以省略),官方博客有更多的介绍:Go's Declaration Syntax。还有,Go 和 Python一样,不需要写分号。
也还有简写的方式,因为定义变量的时候其实类型是确定的,编译器很容易推导,可以不用写类型。
1 2 3 4 5 | var i int = 1 // 去掉类型 var i = 1 // 进一步简写 i := 1 |
看到var i = 1
是不是有 JS 和 Java lombok/JDK10 的感觉,这样就很亲切了。
Methods are functions
一个函数定义:func add(x int, y int) int {}
,参数类型,返回值类型一目了然,这里可以看出还是类型后置,这点其实后面 Python 3.5 加入类型提示(限制)也是这样:def add(x: int, y: int) -> int:
。这里顺带提一下,Python、PHP 这些无类型声明的语言并不是真的无类型(动态类型,运行时检查),而且新版都增加了类型检查的语法,解决了重构等痛点问题。
方法和函数其实一样,只是接收方参数不一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | type Vertex struct { X, Y float64 } // 方法 func (v Vertex) Abs() float64 { return math.Sqrt(v.X*v.X + v.Y*v.Y) } func main() { v := Vertex{3, 4} fmt.Println(v.Abs()) } // 函数 func Abs(v Vertex) float64 { return math.Sqrt(v.X*v.X + v.Y*v.Y) } func main() { v := Vertex{3, 4} fmt.Println(Abs(v)) } |
不过 Go 的函数没有 Python 的那么强大,没有默认参数、关键字参数等,虽然说 Go 讲究简洁,但这些语法糖还是很有用的,典型场景就是 Python requests 库:https://docs.python-requests.org/en/latest/api/#requests.request。
Defer
延迟调用,这个比较特殊,defer
定义的操作会在方法 return 后执行,但比如传的参数就是定义时那样,官方例子修改下:
1 2 3 4 5 6 7 8 9 10 11 | package main import "fmt" func main() { var i = 0 defer fmt.Println(i) i = 1 fmt.Println("hello") } |
输出
1 2 | hello 0 |
这个有什么作用呢,其实类似 Java 中 try/catch 中的 finally,可以用于资源的清理(经典应用就是释放锁)。官方博客有介绍:Defer, Panic, and Recover。
Pointers
Go 的指针语法上来看,比 C 简单些,没有特殊的指针变量,&
就是取地址,*
就是取值(或者定义指针类型),也没有指针运算(其实也可以)。说到指针,又会牵扯到一个话题,传值还是传引用,简单来说 Go 传的是值,但并不代表改动不影响原数据,因为 map、slice等,可以理解本身数据结构就是指针,这些类型修改会影响原有数据,表现和其它语言一致。还有一个就是性能问题,本能的觉得传引用(指针)性能更高(其实还会带来 GC 等问题),比如字符串。
1 2 3 4 5 6 7 8 9 10 11 12 | package main import "fmt" func main() { s1 := "abc" s2 := "abc" s3 := s1 s4 := &s1 fmt.Println(s1, s2, s3, *s4) fmt.Printf("%p %p %p %p", &s1, &s2, &s3, s4) } |
输出为:
1 2 | abc abc abc abc 0xc00018a050 0xc00018a060 0xc00018a070 0xc00018a050 |
咋一看 s2, s3 都是一个新的内存地址,这不影响性能嘛。但再看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | package main import "fmt" import "unsafe" import "reflect" func main() { s1 := "abc" s2 := "abc" s3 := s1 s4 := &s1 fmt.Println(s1, s2, s3, *s4) fmt.Printf("%p %p %p %p\n", &s1, &s2, &s3, s4) fmt.Printf("%p %p %p %p\n", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Data, (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data, (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data, (*reflect.StringHeader)(unsafe.Pointer(s4)).Data ) s1 = "abcd" fmt.Printf("%p %p %p %p\n", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Data, (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data, (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data, (*reflect.StringHeader)(unsafe.Pointer(s4)).Data ) } |
输出:
1 2 3 4 5 | abc abc abc abc 0xc000010240 0xc000010250 0xc000010260 0xc000010240 %!p(uintptr=4966888) %!p(uintptr=4966888) %!p(uintptr=4966888) %!p(uintptr=4966888) %!p(uintptr=4967058) %!p(uintptr=4966888) %!p(uintptr=4966888) %!p(uintptr=4967058) |
可以看出 Go 对字符串等有两个优化:
1. 定义变量 literals(字面量)一致时(s1 和 s2),会指向同一个地址。
2. 变量传值时(s3 := s1)复制的是 string 这个数据结构。
1 2 3 4 | struct String { char *data; int length; }; |
data 指针指向的内容没有复制。因为要存储这个新的结构体,所以 &s3 是这个结构体的内存地址,而 s1,s3 结构体中 data 指向的地址是一样的。
指针这块细节可能理解不是太准确,但意思是语言层面已经做了很多优化,正常使用即可,不要过度优化到处使用指针。
Arrays
var a [10]int
,Go 中,数组的长度(定长)算作类型的一部分(两个长度不一样的数组类型是不一样的,传参会报错)。可以分别赋值,也可以直接初始化[6]int{2, 3, 5, 7, 11, 13}
(大括号看着有点别扭)。
Slices
切片,make([]int, len, cap)
或 var a []int
,和 array 很像,但需要注意的是,slice 本身是个结构体,是在 array 上封了一层(A slice is a descriptor of an array segment),数据是 array 的指针,不管怎么切片,数据还是那个 array(不触发扩容的情况下),改变值的时候其它切片也很受“影响”。
1 2 3 4 5 | type slice struct { array unsafe.Pointer len int cap int } |
1 2 3 4 5 6 | d := []byte{'r', 'o', 'a', 'd'} e := d[2:] // e == []byte{'a', 'd'} e[1] = 'm' // e == []byte{'a', 'm'} // d == []byte{'r', 'o', 'a', 'm'} |
当然这里说的很简单,特别是关于 len、cap 以及和 array 的区别,有一大堆“考点”,个人建议可以暂时忽略,后面再回过头来看看。https://blog.golang.org/slices-intro
Map
Go 的 Map 和其它语言没啥大的差别,正常用就行。Go 的 Map 支持遍历时删除,这在其它语言是要被打死的,还有个小坑是 Map 删除 key 后不会缩容,会造成内存占用着不释放(这让我想起了 MySQL 的 InnoDB)。
Range
这个语法比 Python 好多了,既有 index 也有 value,还能只使用其中一个。
closure
Go 闭包和 JS 差不多。但这个概念比较抽象,我们先看一个简单的例子,用闭包实现从 1 加到 10:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | package main import "fmt" func adder() func(int) int { sum := 0 return func(x int) int { sum += x return sum } } func main() { pos := adder() for i := 1; i <= 10; i++ { fmt.Println(pos(i)) } } |
输出
1 2 3 4 5 6 7 8 9 10 | 1 3 6 10 15 21 28 36 45 55 |
语法上简单理解,adder()
返回了一个函数,所以pos
可以理解为是一个函数,接受一个 x 参数:
1 2 3 4 | func(x int) int { sum += x return sum } |
但会发现有一个 sum 变量,这个变量哪里来的呢,adder()
方法内的,因为还在作用域内,可以使用 sum 这个变量,而且还在调用期间,sum 这个变量会一直存在,不会被 gc,所以这个值还可以累加。再简单理解可以认为 sum 是类似 JAVA 类的一个成员变量。那这样有什么好处呢?直接的好处就是不需要全局变量,代码更加简洁,可以看看不使用闭包的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | package main import "fmt" var sum = 0 func add(x int) int { sum += x return sum } func main() { for i := 1; i <= 10; i++ { fmt.Println(add(i)) } } |
这样会有一个问题,就是 sum 全局变量谁都能修改,造成全局变量污染。我们把,sum 放 main()
内的话,就需要把 add
方法再加个参数,用于传递当前的累加值,看到这里是不是又想到什么,对,闭包可以优化递归的书写方式。总结就是闭包能让代码变得简单,特别是像 Go、JS 这种没有类的“函数式”语言。
包管理
现代的语言都有成熟的包管理工具,Go也不例外。关键词都一样,都是 import,一行写一个简直就像 Python、Java,但推荐是使用 () 批量 import(而且不允许 import 了不使用)。使用上经过几轮迭代,目前类似 Node、Python、PHP 这种命令行式的,生成 go.mod,也支持语义版本。详细的:https://go.dev/ref/mod
不过 go mod 有个特点就是非中心化的,和代码管理平台放在了一起,一般都是 go get github.com/user/repo
,没有其它语言还要发布产出的概念。看似方便,但会带来另一个问题,在企业内部,一般代码库都是有权限的,导致编译依赖需要申请一堆权限(有一些安全风险,比如代码库里面还有别的信息),要是版本产出集中管理就没这个问题了。
前面说的是导入第三方包的情况,还有本地包,这个有些地方需要注意下。首先,代码库(模块)的名称在 go.mod 的第一行 module 定义。这个一般就是网络资源(比如github)的地址module github.com/go-sql-driver/mysql
,需要保证不重复,如果你这个不发布,本地使用可以叫一个简单点的名字,比如 module mydemo
。还有 Go 的 import 是包所在的目录路径,而且 Go 限定了一个目录下,只有一个包名,这个包名一般和最后一级目录一样(方便知道是哪个包),但也可以不一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 | // user目录相对代码库路径handler/user // user目录下的 user.go package userhandler func Login(w http.ResponseWriter, r *http.Request) {} // main.go调用的地方 import ( // 这里import的和物理路径一致 "mydemo/handler/user" "net/http" ) // 使用的时候是package名称 http.HandleFunc("/login", userhandler.Login) |
详细可见:https://github.com/iyaozhen/go-message-board/blob/main/main.go
其它
其它语法都大同小异,比如for
循环,不用写 (),但 {} 必须要写,只有双引号,没有单引号,还兼并了while
语法。switch
不用写 break ,默认每个分支都 break。而且 Go 一直也在做改进,2.0 总会来的,但还是谨慎点好,不要又是下一个 Python3。
语言特性
每个语言都有自己的语言特性,也是吵的最厉害的地方。
Interfaces
Go 的接口含义上,和其它语言类似,主要是方便代码的抽象。不过 Go 接口的实现是隐式的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | type I interface { M() } type T struct { S string } // 感觉像是偷偷的实现了 func (t T) M() { fmt.Println(t.S) } func main() { var i I = T{"hello"} i.M() } |
这样做有什么好处呢?就得提一下鸭子类型:当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。我定义了一个 interface,这个 interface 只有一个 M 方法,如果其它类型(不一定是 struct)实现了 M 方法(所有方法),那么这个类型就是实现了这个接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | type I interface { M() } type MyInt int func (t MyInt) M() { fmt.Printf("%v\n", t) } func main() { var j I = MyInt(100) j.M() } |
还有,在 JAVA 等语言中,有时需要实现多个接口,在 Go 可以通过 embedding(嵌入组合)的方式实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 | type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } // ReadWriter is the interface that combines the Reader and Writer interfaces. type ReadWriter interface { Reader Writer } |
说到 interface,就不得不提泛型了。那为什么需要泛型呢?比如要实现一个 print() 函数,要能支持打印各种类型,那就需要一种泛化的、通用的类型(弱类型/动态语言就没这问题了)。不然只能写很多 print 函数,比如:printInt(int)、printString(string) 等,如果有泛型那么写一个函数就行 print(T)。
Go 虽然没有泛型(不过已经狼来了几次了),但我们有 interface 呀,interface 同时也是一种类型。特别的空接口 interface{}
没有方法,那也说明所有其它类型都实现了空接口,所以可以接收一个 interface{}
类型的参数,然后内部判断传入的类型进行不同的处理,可以看到 Go 源码中的 print 就是这么实现的:https://github.com/golang/go/blob/release-branch.go1.16/src/fmt/print.go#L634。
Errors
主流语言错误处理大概有两种,一种是类似 C 语言返回 errno,调用方先判断下错误码,再进一步处理。一种就是 JAVA 为代表的抛异常,然后 try catch 处理。我之前 PHP 用的多,常在两种方式间横跳,深有体会,总的来说 try catch 的方式好一点,至少有错误堆栈,定位问题比较方便。Go 原生的方式是靠多参数返回来实现的:
1 2 3 4 5 6 | i, err := strconv.Atoi("42") if err != nil { fmt.Printf("couldn't convert number: %v\n", err) return } fmt.Println("Converted integer:", i) |
怎么说呢,基本够用但不够爽。这也是吐槽比较多的地方,满屏幕的 err != nil。这块可以使用一些第三方库(https://github.com/pkg/errors),不过慎用,Go 2.0 会有改动,导致兼容问题。
除了 error 还有个 panic,这是当程序出现致命异常时抛出的,比如程序启动发现端口监听失败,和错误不一样,这个时候也没有其它办法了,只能让当前 goroutine 优雅退出。
Concurrency
并发是个永恒的话题,特别是互联网时代,没几万 qps 都不好意思说话。
在 PHP 时代,使用的的是多进程,FPM 管理这一堆进程,请求来了会初始化(类似池子,循环利用)一个进程,处理完了会被回收(不一定立即销毁)。可以充分利用多核,程序简单。但问题很突出,初始化进程耗时长(特别是还要动态加载扩展),进程也很重,内存占用以 MB 计。还不支持并发,比如要请求多个 RPC 服务拿数据(忽略 curl_multi),只能一个个来。
在 JAVA 时代,是多线程,每个请求来了是一个线程(有很多优化,不完全是这样),再加上 Spring 框架的流行,基本上统治了 Web 开发的半壁江山。虽然并发请求的问题可以通过线程池来,但线程还是比较重,也要以 KB 计算,线程间切换也比较耗资源。受限历史包袱,Java 虽然有 NIO,但是相关生态还不太流行。
后来出现了 Node,发扬光大了异步回调(Event Loop)。结合代码说明:
1 2 3 4 5 6 7 8 | const getFirstUserData = async () => { const response = await fetch('//static.iyaozhen.com/users.json') // get users list const users = await response.json() // parse JSON const user = users[0] // pick first user const userResponse = await fetch(`/users/${user.name}`) // get user data const userData = await userResponse.json() // parse JSON return userData } |
现代化的 Node 已经用 await 等语法屏蔽了回调的细节。当 await 时,当前线程会让出,执行其它请求,当获取到数据后,回调回此线程,继续执行。示例中,如果两个请求没有相关性,可以使用 Promise.all
并发请求,获取结果。因为执行 JS 的只有一个线程(Node 整体不是单线程),那么也就没有线程切换的损失,在互联网 I/O 密集型的场景性能非常好。而且 Node 天生异步,没有包袱(这也是为什么新搞一个语言的重要原因),使得整个生态比较繁荣。但使用 Node 的心智负担还是比较高,需要避免阻塞主线程(比如加解密、大量循环)。
最后是 Go,Go 选择了协程。
A goroutine is a lightweight thread managed by the Go runtime.
最直接的优点,就是轻量,内存占用小,切换开销小。但这不是决定性因素,重点是后半句 managed by the Go runtime(Go 的调度模型)。首先有一个全局队列,里面是要执行的 goroutines(G),还会有几个 Processor(P)提供 G 的执行资源(内存分配就在这层上),P 还有一个本地队列,预分配这个要执行的 G 到这个队列,和 Machine 操作系统线程(M)绑定,一般是 CPU 核数。
协程的协是协作的协,就是运行的代码主动让出执行权,类似其它语言的 yied,只是这些在 go 中是自动的,比如在发起一个 http 请求(I/O 操作了不需要再占用 CPU),就会被调度走,执行下一个 G,但现实往往很复杂,比如类似之前 Node 的问题,阻塞事件循环等会阻塞主系统线程,后续的任务无法执行。所以 Go 也支持抢占式调度,有一个 sysmon,会监控执行较长的 G,然后调度走,让其它的 G 也执行下。还有在 GC 的时候也是抢占式调度。
还有一些其它的优化,比如图上 P1 队首的 G 刚好都比较慢(或者 M 本身比较忙),P2 的 G 运行完了,它会偷 P1 里面的 G 过来执行(work stealing)。这块就比 Node 好很多。Python 也意识到这个问题,3.9 给用户暴露了一个接口,asyncio.to_thread 可以将预计会阻塞的 IO,手动调度到另一个线程去处理,不要阻塞主线程的事件循环。简单来说 Go 的调度就是尽量让协程都跑起来,榨干系统 CPU。
协程这个概念并不新鲜,很多语言都有实现(比如 Python、Kotlin),但 Go 的成功一方面是实现的好,另一方面也是因为没有历史包袱,整个 runtime 就是为协程而生的,go func 用起来太爽了(都快泛滥了),当然还有 Google 这个“好爹”。其它语言还得兼顾线程模型、传统阻塞式的写法,造成写起来不是那么轻松,比如 Python 还得手动创建 event_loop 手动去执行协程,没有 runtime 层的协程调度,而且很多第三方库不支持协程,导致生态很差。
最后 Go 还有一个 Channel 的概念,这个简单理解就是为了协程间共享数据(不使用共享内存啥的),搞的一个队列,类似多线程中的 Queue。因为是 Runtime 实现,做了很多优化,性能和使用体验很好。不过需要注意 Channel 是有锁的,并不适合超高性能、并发的场景。
GC
这年头 GC 都快成为 Java 的代名词了,Go 的 GC 介绍很少,基本只听说过其暂停时间很短,为什么呢?因为 Go 的 GC 实现的很搓,是赶工出来的,按本来设计是准备搞一个牛逼的,类似 Java ZGC,但时间紧任务重,那怎么办呢,只能专攻一个卖点,那就是暂停时间,让用起来至少还行。还诞生了一门新的学问——逃逸分析,编译器把一个对象分配在堆还是栈的分析过程,对应的优化就是尽量让代码中的对象分配在栈上,这样调用完成就自然回收掉了,减少 GC 的压力。但这个人觉得一般业务程序还是不要太过度优化,不然就开倒车了,和写 C 一样操心内存了,再说一方面 Go 编译器在优化,很可能下个版本就变了,另外更可能的是分析来分析去上线压测发现瓶颈在一个不必要的锁上。
练习
基本语法过完了,但是感觉走马观花,马上就会忘了,需要不断的练习。之前还想练习语法有什么好方式,突然想到 LeetCode,上面练习基本的数据结构,选个 easy,然后 tag 选上 Array
Hash Table
String
Sort
等,开始刷题吧:https://leetcode.com/problemset/?difficulty=Easy&topicSlugs=array%2Chash-table%2Cstring%2Csort
实践
基础打牢了,还是需要一些项目实践。想起之前学习 PHP 的经历,当时是做了个留言板,虽然很小,但涵盖了基本的 CRUD 操作,非常适合入门。
最后,学习一门新语言的时候往往会和之前的语言对比、印证,这个可以有,加深印象。但是不要陷入对比魔障中,Go 就是 Go,需要用新的思维来组织代码。
参考资料
https://go.dev/doc/effective_go
Go - 使用 defer 函数 要注意的几个点,https://segmentfault.com/a/1190000021362837
【Go】string 优化误区及建议,https://blog.thinkeridea.com/201902/go/string_ye_shi_yin_yong_lei_xing.html
golang 的指针地址问题,https://v2ex.com/t/749359
https://stackoverflow.com/questions/8532127/does-go-language-use-copy-on-write-for-strings
golang的官方字符串包里为什么都用string类型传入参数而不是*string?https://golangtc.com/t/577e923bb09ecc02f7000230
为什么要使用闭包和如何使用闭包,https://segmentfault.com/a/1190000013243680
何处安放我们的 Go 代码,https://liujiacai.net/blog/2019/10/24/go-modules/
golang拾遗:为什么我们需要泛型,https://www.cnblogs.com/apocelipes/p/13832224.html
Golang: 深入理解panic and recover,https://ieevee.com/tech/2017/11/23/go-panic.html
GO 编程模式:错误处理,https://coolshell.cn/articles/21140.html
https://www.toptal.com/back-end/server-side-io-performance-node-php-java-go
https://www.ardanlabs.com/blog/2014/02/the-nature-of-channels-in-go.html
常见线程模型,https://github.com/apache/brpc/blob/master/docs/cn/threading_overview.md
https://docs.python.org/3/library/asyncio.html
深度解密Go语言之scheduler,https://qcrao.com/post/dive-into-go-scheduler/
https://medium.com/rungo/the-anatomy-of-slices-in-go-6450e3bb2b94
Go夜读,https://talkgo.org/c/night/5
Golang 进阶训练营系列课程(字节内部,强烈推荐)