Go学习笔记
1. 安装
新版本的 Go 编译器,是使用 go 自己实现的,不是用 C ,所以直接下载源码编译不了,一般也不用折腾了。可以直接到官网下载二进制可执行文件,比如 https://golang.org/dl/go1.17.3.linux-amd64.tar.gz 。解压后使用 bin
里面的 go
命令即可。
过程中,要使用第三方的包,或者要找官方包的文档,可以在 https://pkg.go.dev/ 搜索。(这里的“包”,说成是“模块”更合适)
2. Hello World
创建一个 demo.go
文件:
package main func main() { println("Hello World!") }
注意两点,要直接可执行,需要有 main
包,它里面要有 main
函数。
之后可以直接:
go run demo.go
也可以:
go build demo.go ./demo
-o
参数可以指定编译出的文件名。
go build -o a demo.go ./a
3. 变量声明与赋值
go 有不同的数据类型,变量声明的类型与数据类型需要匹配。
var text string text = "Hello World!"
这样可以,也可以直接:
var text = "hello world"
go 会自动推断类型,知道 text
类型是 string
。
还有一种声明方式,涉及“重声明”机制:
先看:
var text = "1" var text = "2"
这样写,不能通过编译,因为 text
已经声明过。你可以重新赋值,但是不能重新声明:
var text = "1" text = "2"
本来声明和赋值是很分得很清楚的,但是当赋值的能力涉及到“模式匹配”,或者说“多值赋值”(我现在还不知道 go 有没有模式匹配能力)时,问题就麻烦一点了。
var a, b = "1", "2" a, b = "3", "3"
如果多值当中,有新声明的变量要“声明且赋值”,有旧的变量要“重新赋值”,那怎么办?
var a, b = "1", "2" var c, b = "3", "3"
语法上选择了 a, b
可以省略多余的 var
,那么第二行就不能解释成只新声明 c
,不声明 b
了。
也许是为了解决这种状况, go
搞了一个 :=
出来:
func main() { var a, b = "1", "2" c, b := "3", "3" e := "s" fmt.Printf("%v %v %v %v\n", a, b, c, e) }
:=
的规则就是,左侧至少有一个新声明的变量,这样,它就会自动对旧变量作不声明,只重赋值。嗯,感觉是在给编译器擦屁股。
4. 数据类型
4.1. 基本静态类型
基本静态类型,个人把它们看成四大类:
- 布尔型
- 整数型
- 浮点型
- 字节,字符,字符串
说具体类型之前,先介绍一个工具, unsafe.Sizeof
,它可以输出变量所占的“字节数”。(有些变量是“引用类型”的,所以它的值发生的变化时本身所占的字节数并不会变)
package main import ( "fmt" "unsafe" ) func main() { var b bool b = true fmt.Printf("%v\n", unsafe.Sizeof(b)) }
能看到 1
的输出,所以 布尔型的数据,会占 1 个 Byte ,即 8 Bits 。
布尔型,整数,浮点,都比较简单,直接列出下面的表格就行:
类型名 | 名称 | 占用字节 | 范围 | |
---|---|---|---|---|
bool | 布尔量 | 1 | true 和 false | |
uint8, uint16, uint32, uint64 | 无符号整数 | 1,2,4,8 | 255, 65535, 42亿, MAX | |
int8, int16, int32, int64 | 有符号整数 | 1,2,4,8 | 127, 32767, 21亿, MAX | |
float32, float64 | 有符号浮点 | 4,8 | MAX |
另外还有一个 int
类型,会因为操作系统的位数不同,而使用 int32
或者 int64
。在我的机器上是 int64
,占 8 个字节。
4.2. 类型别名
使用 type
,可以指定一个自定义的类型名字:
type myInt int32 var n myInt
4.3. 字节,字符,字符串
go 中,仍然有双引号表示“字符串”,单引号表示“字符”的传统方法。不过,这里说的单引号的“字符”指的是 Unicode 真正的字符抽象表示(不是字节那种具体形式),占 4 个字节。
func main() { var b = 'a' var c = b + 1 var d = '\xFF' var e = "abcdefghikk918298371jkjfkhh832" fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b) fmt.Printf("%v\n", c) fmt.Printf("%v\n", d) fmt.Printf("%v\n", unsafe.Sizeof(e)) fmt.Printf("%v\n", e) }
上面的例子,可以看出:
'a'
是一个字符,占 4 个字节。本身是uint32
。它有一个专门的类型名叫rune
。- 可以使用
'\xFF'
来表示“字符”。 e
这个字符串占了 16 个字节,所以,字符串应该不是一个基本类型,更像是一个内置的对象。
rune
是字符, go 中自然有对应的“字节”类型,就是 byte
。
func main() { var b byte b = 'a' var c = b + 1 var d = '\xFF' fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b) fmt.Printf("%v\n", c) fmt.Printf("%v\n", d) }
直接把 b
声明成 byte
之后,上面的代码都是完全没问题的, byte
本身是 uint8
(这里有一隐式的类型转换)。
对于 rune
,它的数字值是字符对应的 Unicode 编码的直接数字表达值:
func main() { var b rune b = '邹' var c = b + 1 fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b) fmt.Printf("%v\n", c) }
邹
的 Unicode 是 \x90B9
,直接看成数字就是 37049 。
简单来说,就是对于 byte
和 rune
,都可以直接用数字来处理。
字符串我现在搞不懂,暂时把它当成内置对象看了。
4.4. 数组与切片
其实我一直“数组”的这个名字比较纠结,因为它里面不一定是“数字”啊,所以一般我喜欢叫它们“列表”。
go 中,列表是一种静态类型,即大小固定,值传递。
func main() { var b [3]bool b = [3]bool{true, true} fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b[0]) }
这种写法下,需要把 [3]bool
整体看成是一种类型,而不是只看 []bool
。
在 go 中, []bool
这类长度留空的“列表”,不是列表类型,而是“切片”类型!( slice
和 array
或者说 list
不是一回事)
func main() { type myBoolList [3]bool var b myBoolList b = myBoolList{true, true} fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b[0]) }
这样处理是正确的。
func main() { type myBoolList []bool var b myBoolList b = myBoolList{true, true} fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b[0]) }
这样处理也正确,但是你会看到 []bool
的对象,会占用 24 个字节,所以“切片”已经不是一种静态结构了。
如果要实现“自动分配固定长度”的列表,需要使用 [...]
语法:
var b = [...]bool{true, true} fmt.Printf("%v\n", unsafe.Sizeof(b)) fmt.Printf("%v\n", b[0])
这样, b
就只占 2 个字节。但是不能使用:
type myBoolList [...]bool
这种 myBoolList
算啥?没法解释。
“列表”和“切片”都支持切片操作。
func main() { var b = [...]int32{1,2,3,4} var a = b[1:2] fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a[0]) }
区间取值是前闭后开,前后都可以省略:
var a = b[:]
不过不支持负值索引。
使用 len()
函数可以获取数组和切片的长度(注意,数组是定长,它的长度不一定等于成员个数):
var l = [5]int{1,2,3} println(len(l))
var l = []int{1,2,3} println(len(l))
var l = [...]int{1,2,3} println(len(l))
切片可变化,使用 append()
可以在尾部添加成员,并返回新的引用:
var l = []int{1,2,3} println(len(l)) l = append(l, 2, 3, 4) println(len(l))
4.5. 映射
映射,也叫字典, map 。这种类型,或者说这种对象, go 的封装程度比较高:
func main() { var a map[int32]int32 a = map[int32]int32{} a[1] = 123 fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a[1]) }
声明时,通过直观的语法声明 key
和 value
的类型即可。
还可以声明和赋值一起:
var a = map[int32]int32{1: 123} var b = map[string]int32{"1": 123}
个人猜测任何 hashable 的对象都可以作为 key
,后面验证一下。
获取一个不存在的 key
,不会引发错误:
var m = map[string]string{} m["a"] = "123" var s string = m["b"] println(s == "")
没有办法直接判断 key
是否存在,只能取值,通过第二个返回值判断:
var m = map[string]string{} m["a"] = "123" s, ok := m["b"] println(s == "") println(ok)
使用 delete
函数删除 key
:
var m = map[string]string{} var s string var ok bool m["a"] = "123" s, ok = m["a"] println(s == "") println(ok) delete(m, "a") s, ok = m["a"] println(s == "") println(ok)
4.6. 结构体与函数
和 C 一样,通过 type
可以声明一个类型的“别名”,也可以直接通过 type
声明新的类型:
func main() { type point struct { x int32 y int32 } var a = point{x: 1, y: 2} fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a.x) }
可以:
var a point a.x = 1; a.y = 2
但是不可以:
var a point = {x: 1, y: 2}
还可以使用 new
,像指针那样操作:
var a = new(point) a.x = 1; a.y = 2 fmt.Printf("%v\n", (*a).y)
这里把“函数”和结构体一起讲,是希望突出一个关键的语言特性,即我们常看到的一种说法——“函数是一等公民”。
在 go 中,函数是一种基本类型,可以被用于成员定义,参数传入,返回。
func main() { type point struct { x int32 y int32 add func(int32, int32) int32 } var a point a.x = 1; a.y = 2 a.add = func(a int32, b int32) int32 { return a + b } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a.add(a.x, a.y)) }
point
的 add
成员,是一个函数,函数签名是两个 int32
的参数,返回 int32
。
实际的使用中,我们可以直接给 a.add
赋值一个匿名函数。
4.7. 函数与可变参数
上一部分说到了函数是基本类型,现在细看一下函数的行为。
作为类型声明:
func main() { type onePFunc func(int32) string var a onePFunc = func(a int32) string { return "hello" } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a(1)) }
这里的 a
参数即使没有用到,编译器也不会提醒的。但是某个 import
的东西没有用到,却会提醒。(个人很烦这个限制)
作为结构体成员:
func main() { type hasFunc struct { f func(int32) string } var a = hasFunc{f: func(a int32) string {return "hello"}} fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a.f(1)) }
作为参数:
func main() { var a = func(f func(int32) int32) int32{ return f(1) } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a(func(n int32) int32 { return n+1})) }
作为函数返回:
func main() { var a = func(n int32) func() int32{ return func() int32 { return n + 1 } } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a(2)()) }
立即执行:
func main() { var a = 123; (func(){ a = 456 })() fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a) }
go 中处理函数参数可变的方式,是使用可变参数,而不是使用“同名但是签名不同”。但是我没有找到返回可变类型的办法,如果需要返回的类型可变,那只能通过“接口”之类的机制再做一层抽象。
可变参数在 go 中的处理方式,是对于同种类型,最后一个参数,统一作为一个“切片”:
func main() { var a = func(a int32, b ...int32) int32 { return a + b[1] }; fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a(1, 2, 3, 4)) }
4.8. 接口
go 中没有“类”,但是它有“接口”的机制。对应地,“接口的实现”变成放到 struct
中去做。
还有,接口不能写在函数中。
package main import ( "fmt" "unsafe" ) type Runable interface { run(string) string } type Person struct { name string } func (p Person) run(name string) string { return p.name + ":" + name + ":" + "running" } func main() { var a = Person{name: "abc"} fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", a.run("haha")) }
上面代码中:
func (p Person) run(name string) string { return p.name + ":" + name + ":" + "running" }
这个语法在个人看来很丑。它的作用是给 Person
这种类型添加了一个 run()
的方法。
“方法”本身和“接口”没有必然的联系,上面的代码中即使不定义 Runable
的接口,也不影响 Person
类型多一个 run()
方法。
还有, Person
的类型中,它的成员可以直接有一个名为 run
的函数:
type Person struct { name string run func(string) string }
这种情况下, Person
算不算是实现了 Runable
接口呢?(对 go 来说不算)
个人观点,struct
本身应该是一个完整的结构,但它的方法却要和其它一些成员分开来写,这太别扭了。
还有,从上面的流程看,“接口”除了在编译期做一些检查,在运行期是完全没必要存在的。不知道 go 中怎么处理动态接口,或者一些运行时加载的功能怎么处理。
接口定义之后,就可以面向接口做函数的签名了:
func main() { var a = Person{name: "abc"} var f = func(r Runable) string { return r.run("waa") } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", f(a)) }
顺便可以检查一下,对于:
type Person struct { name string run func(string) string }
这种,即使给了 run
的一个实现,编译器也不认为 Person
实现了 Runable
接口:
package main import ( "fmt" "unsafe" ) type Runable interface { run(string) string } type Person struct { name string run func(string) string } func main() { var a = Person{name: "abc", run: func(name string) string {return name + " is running"}} var f = func(r Runable) string { return r.run("waa") } fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v\n", f(a)) }
编译会报错,提示说 Person.run
是一个 field ,不是一个 method 。
同时,你也不能给 int
添加一个方法:
func (i int) run() string { return "running" }
报的错是,不能给一个 non-local
类型添加新的方法。
4.9. 结构体,值使用和指针使用
我们可以以“值”,或者以“指针”来使用结构体,它们之间是参数传递的东西不同的区别:
package main import "fmt" type Add interface { add(int, int) int add2(int, int) int } type point struct { x int32 y int32 } func (p point) add(a int, b int) int { p.x = 9 return a + b } func (p *point) add2(a int, b int) int { p.x = 9 return a + b } func main() { var p point = point{} p.x = 1; p.y = 2 p.add(2, 3) fmt.Println(p) var p2 *point = &point{} p2.x = 1; p2.y = 2 p2.add2(2, 3) fmt.Println(p2) }
上面示例中, add2
因为使用了指针,所以可以把 p2
的 x
改了。而 add
因为是直接使用的值,传递时已经复制了对象,所以改的 p.x
已经不是 main()
中的 p
了。
当然,这里接口的实现,跟 p
和 p2
使用两种不同的初始化方式没有关系。在这里,不同的初始化方式,只是单纯为了演示。
4.10. 类型断言,类型转换
go 专门有一种后置的“类型断言”语法,用于通用的 interface{}
往特定的类型转换(基本类型,像整数,字符串,有专门的函数做转换):
package main import "fmt" type Point struct { x int32 y int32 } type Location struct { x int32 y int32 z int32 } func getType() interface{} { return Point{x: 1, y: 2} } func getType2() interface{} { return &Point{x: 1, y: 2} } func getType3() interface{} { return Location{x: 1, y: 2} } func main() { var p0 = getType() var p1 interface{} = getType() var p2 Point = getType().(Point) var p3 *Point = getType2().(*Point) var p4 interface{} = getType3() var p5 = p4.(Location) fmt.Println(p0, p1, p2, p3, p4, p5) switch value := p4.(type) { case Location: fmt.Println("Location", value) case Point: fmt.Println("Point", value) } }
通过 p4.(Location)
来完成类型的转换(具象化)。
5. 流程控制和操作符
go 的流程控制语句很简单,一共只有:
if
else
for
,break
,continue
,range
switch
goto
还专门提供了 goto
,嗯,很暴力。
5.1. if
if
后面不用加括号:
func main() { if true { println("hello") } }
if
后面强制需要 bool
类型,给个 1
是不行的。同时,各种类型的转换,也是使用特定的函数,比较死板。
运算符没有什么特别的,“与否非”分别是 &&
, ||
, !
:
func main() { if (1 > 1) && (2 > 0) { println("hello") } else { println("world") } }
5.2. for
for
是一个通用的迭代实现,可以看成是流程控制,同时兼具传统的 while
和 for
的作用:
传统 for
的形式是典型的三段:
for i := 0; i < 10; i++ { println(i) }
for
里面的变量声明及赋值,只能用 :=
。
三段可以任意省略。
全省,就是一个死循环:
var count int = 0; for { println(count) count++ if count == 10 { break } }
for
后面只跟一个表达式,则它的行为跟传统的 while
一样:
var count int = 0; for count < 10 { println(count) count++ }
再来看 for
的迭代表现。这里,感觉更像在语法层面给 for
和 range
开的后门。
是的, range
是一个 statement ,但同时,又可以像函数那么用它(有点像 Python2.x 的 print)。
var l = [3]int{2,3,4} for a, n := range l { fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v: %v\n", a, n) }
遍历“列表”,两个值,一个是索引,另一个是列表成员。
“切片”的行为同“列表”一样。
如果是 map
的话,则会迭代 key
和 vlaue
:
func main() { var l = map[string]int{"a": 3, "b": 9, "c": 4} for a, n := range l { fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v: %v\n", a, n) } }
对于 string
则是字符索引和字符值(整数):
func main() { var l = "hello" for a, n := range l { fmt.Printf("%v\n", unsafe.Sizeof(a)) fmt.Printf("%v: %v\n", a, n) } }
range
还可以持续读取一个通道,行为像生成器:
package main import "fmt" import "time" func gen() chan int { var ch = make(chan int) var i = 0 go func(){ for { i += 1 ch <- i time.Sleep(1 * time.Second) } }() return ch } func main() { for i := range gen() { fmt.Println(i) } }
5.3. switch
switch
是结构化的多路 if
,我在想,在 map
简单好用的情况下它会有多少的出场率。
可以针对一个变量:
func main() { var l = 3; switch l { case 1: println("1") case 2: println("2") default: println("other") } }
针对单个变量时也可以做多值判断:
func main() { var l = 1; switch l { case 1,2,3: println("2") default: println("other") } }
可以把逻辑表达式放在 case
中:
func main() { var l = 3; switch { case l == 1: println("1") case l == 2: println("2") default: println("other") } }
可以通过 fallthrough
实现 next
的功能,同时会跳过 case
的逻辑判断:
func main() { var l = 1; switch { case l == 1: println("1") fallthrough case l == 2: println("2") break case l == 3: println("3") default: println("other") } }
5.4. goto
func main() { var l = [3]int{1,2,3} for _, i := range l { if i == 3 { goto exit } println(i) } exit: println("exit") }
注意一下 label
的语法,以 :
结尾。在行前加一个 label
对它之后的语句并没有额外影响。
6. 错误和异常
6.1. 错误
go 中官方提供了专门的 errors
工具包,和 error
接口:
type error interface { Error() string }
所以,错误本身,并没有什么特别,用它或者不用它,不影响你写代码:
package main import ( "errors" ) func getError() error { return errors.New("this is a error") } type Cls struct { name string } func (c Cls) Error() string { return c.name } func main() { var e error = getError() var c Cls = Cls{name: "haha"} println(e.Error()) println(c.Error()) }
6.2. 异常 panic, recover, defer
go 中的异常,不像传统的 throw
catch
那套,至少名字上不是。
看一个异常的例子:
package main func div(i int) int { return 10 / i } func main() { var i int = div(0) println(i) }
说到异常,最先想到的就是 N / 0
(当然,js 中有 Infinity
或者 NaN
人家就是不用异常) ,但是,不能直接写 1 / 0
,这种编译器还是可以发现的,得用函数包装一下编译器就不和你墨迹了。
成功编译,在执行的时候,就能看到运行时报错,及调用栈。
要捕捉异常,在 go 中使用 recover()
函数,是的,一个函数。但是现在问题变成,这个函数如果放在前面,那么调用它时还没有异常。如果放在后面,因为异常中断了程序,无法调用。
因此, go 搞了一个 defer
语句,来把指定的语句推到当前函数执行完后再执行,有点 js 中 setTimeout
的感觉。
func main() { defer func(){ var p = recover() println("here xxxx", p) }() var i int = div(0) println(i) }
这样一加,你就等于把异常处理了,能看到正常的 here xxxx
输出。
要手动抛出异常,可以使用 panic()
函数,它接受任何参数(反正是一个空接口):
func main() { defer func(){ var p = recover() println("here xxxx", p) }() panic("hello") println("over") }
看起来没异常的样子,倒像是一个 pipe
, panic
的调用值通过 recover()
传递出去了。
go 没有 try
的结构,异常只会延函数调用往上传播,直到被捕获:
package main func aaa() { panic("throw in aaa") } func aa() { defer func(){ var p = recover() if p != nil { println("aa", p) panic(p) } }() aaa() } func a() { defer func(){ var p = recover() if p != nil { println("a", p) } }() aa() } func main() { a() println("over") }
7. goroutine, 通道, 并发
这些被看成是 go 的特点。 goroutine ,有些人称其为 go程,或者“协程”,我喜欢叫它“独立上下文”。
通道 是一种数据类型,听说是用于不同独立上下文的信息交换,行为类似操作系统的 pipe 。
goroutine 肯定是很轻量的东西,随时创建,创建销毁。但是我目前不清楚,它们到底是不是可以“并行”, go 本身是不是可以在多个进程间调度它们,在保持看起来是同一个运行时的前提下。
go
语句可以开启一个 goroutine :
func main() { go func(){ println("in go") }() println("over") }
直接运行,会发现看不到 in go
,打印 over
之后就结束了,嗯……,暂停一下才能看到 go func()
的输出:
package main import ( "time" ) func main() { go func(){ println("in go") }() println("over") time.Sleep(5 * time.Second) }
go 原生提供了 goroutine ,但不像 js 那种会默认自己控制一个 event loop
,也不像 Python 的某些工具,会显式地提供 event.start()
。
不过 通道 倒是提供了默认的“阻塞”特性:
package main func main() { var ch = make(chan string) var callback = func() { println("in go") var s string = <-ch println(s) println("here") } go callback() ch <- "write" println("over") }
这段代码可以确定地得到:
in go write here over
这样的输出,看起来就像是 ch <- "write"
之后,再执行 callback()
一样。
如果不把 ch
的读放在 callback()
中,在外面就立即读出:
go callback() ch <- "write" var s string = <-ch println(s) println("over")
那么编译时就会给出死锁错误。
关于死锁,我试过,先读或者先写,都会死,似乎只有通过 goroutine “同时”读写才行。
但是,如果从来就不往 ch
里写任何东西,又可以正常编译:
func main() { var ch = make(chan string) var callback = func() { println("in go") var s string = <-ch println(s) println("here") } go callback() println("over") }
最终只会有 over
输出就是了。
挻矛盾的,至少 <-ch
会阻塞的说法不准确。
func main() { var ch = make(chan string) var callback = func() { println("in go") var s string = <-ch println(s) println("here") } go callback() time.Sleep(5 * time.Second) ch <- "write" println("over") }
这段代码,最终只会输出:
in go over
目前搞不懂。
下面来求证最开始的那个问题, goroutine 是否在多进程上调度,写个死循环:
package main func main() { var count int = 0 for { go func(){ count += 1 println(count) for {} }() } println("over") }
通过操作系统的监控,可以发现能跑满所有 CPU 核心,哈,这个机制还是很可以的,特别是针对计算密集的场景。
不过剩下的问题是,go 中哪些数据类型是进程并行安全的?
8. 指针,空间分配和存续
go 里面有像 C 中一样的指针,同时可以使用 new()
来分配一块指定类型所需大小的空间:
var p *int = new(int) *p = 123 println(*p) println(p) return
语法上,使用 *
对指针进行求值,使用 &
可以获取地址(并赋值给指针):
var m = map[string]string{"a": "123"} var p *map[string]string p = &m fmt.Println((*p)["a"])
new
分配的空间,由 go 统一管理,即使退出函数,也不会直接释放:
func alloc() *int { var p *int = new(int) return p } func main() { var p *int = alloc() *p = 123 println(p) println(&p) println(*p) }
仅对于 切片, 映射, 通道 ,可以使用 make()
来分配空间(但是也可以不单独调用 make
,而是声明时直接完成初始化了,这时,空间的分配由系统自动处理,可能会比初始值大):
var s []int = []int{1,2,3} var s2 []int s2 = make([]int, 20) fmt.Println(s) fmt.Println(s2) return
go 里没有单独的指向函数的指针,也不需要,函数本身就是“第一公民”。
9. 链表
链表的实现在 container/list
中的两个结构, List 和 Element 。
Element 比较简单,成员有:
Value interface{}
Next() *Element
Prev() *Element
List 的方法多一些:
- static
list.New() *List
初始化一个列表 Init() *List
初始化或者清空列表Back() *Element
最后一个元素Front() *Element
第一个元素InsertAfter(v interface{}, mark *Element) *Element
在mark
后面添加InsertBefore(v interface{}, mark *Element) *Element
在mark
前面添加Len() int
链表长度MoveAfter(e *Element, mark *Element)
把e
移动到mark
后面MoveBefore(e *Element, mark *Element)
把e
移动到mark
前面MoveToBack(e *Element)
把e
放到最后MoveToFront(e *Element)
把e
放到最前PushBack(v interface{}) *Element
在末尾追加PushFront(v interface{}) *Element
在最前面添加PushBackList(l *List)
复制l
并在末尾连接PushFrontList(l *List)
复制l
并在最前面连接Remove(e *Element) interface{}
删除指定元素,同时返回元素的Value
简单的例子:
package main import ( "container/list" "fmt" ) func show(l *list.List) { var e *list.Element e = l.Front() if e == nil { return } for { fmt.Print(e.Value) e = e.Next() if e == nil { break } fmt.Print(" -> ") } } func main() { var p *list.List = list.New() (*p).PushBack(2) p.PushBack("3") show(p) }
用自己 PushBackList
的例子:
func main() { var p *list.List = list.New() var e *list.Element = (*p).PushBack(2) p.PushBack("3") show(p) println("\n\n") p.PushBackList(p) e.Value = 88 show(p) }
最后的输出是: 88 -> 3 -> 2 -> 3
,能看出 p
是复制了一份添加的。
10. 字符与字节
10.1. rune, byte, string
先说一个限制,或者说一个前提,go 的源码被限制为必须使用 UTF-8 编码。
前面说过, rune 是“字符”, byte 类型是字节,而 “字符串” 是 string ,所以注意,这里其实有三种数据类型。
func main() { var s = "中文" fmt.Println(len(s)) }
上面的输出是 6
,显然,“字符串”应该被叫作“字节串”。
这几个基本类型的转换在 go 中,倒是很直观,直接“声明”就可以完成转换了:
func main() { var s = "中文go" var ss = []rune(s) fmt.Println(len(ss)) }
转换成 []rune
就可以正确得到“字符个数”。 byte
同理:
func main() { var s = "中文go" var ss = []byte(s) fmt.Println(len(ss), len(s)) }
转成字符串也是直接的:
func main() { var b = []byte{'\x01', '\x64'} var ss = []rune{'中', '文'} fmt.Println(ss) var s = string(ss) var bs = string(b) fmt.Println(s, bs) }
这些转换,都是基于 UTF-8 这个前提。对于其它编码的情况怎么处理呢?那只能依赖额外的模块了。
目前使用的是 golang.org/x/text
:
package main import ( "fmt" "golang.org/x/text/encoding/simplifiedchinese" ) func UTF8toGBK(s []byte) []byte { b, err := simplifiedchinese.GBK.NewEncoder().Bytes(s) if err != nil { panic(err) } return b } func GBKtoUTF8(s []byte) []byte { b, err := simplifiedchinese.GBK.NewDecoder().Bytes(s) if err != nil { panic(err) } return b } func main() { var b []byte = UTF8toGBK([]byte("中文")) fmt.Println(b) var u []byte = GBKtoUTF8(b) fmt.Println(u) var s = string(u) fmt.Println(s) }
基本上,go 中的做法都是基于 UTF-8 的字节数据做直接的转换操作。
相较而言, Python3.x 中的字符串是抽象的真正的“字符串”,和字节没有直接关系的处理方式,我觉得是更现代的做法。
10.2. strings 更多及“零值可用”
string 在 go 中,本来是一块不可变的字节内容,但是字符串的处理却又是很常见的场景,所以 go 提供了额外的工具来处理字符串的读写。
首先一定要记住的一点,就是“字节串”,而不是“字符串”:
package main import ( "fmt" ) func main() { var s string = "中文" fmt.Println(s[:3]) fmt.Println(string([]rune(s)[:1])) }
上面的代码都会输出“中”字。
如果对字符串的读写处理,有更多的一些需求,比如性能(普通的字符串拼接因为要重新分配空间,所以频繁的操作成本还是比较大的), 那么 go 中有专门的工具:
package main import ( "strings" ) func main() { var s strings.Builder println(s.String(), s.Len(), s.Cap()) s.WriteByte('a') s.WriteString("哈哈") s.WriteRune('中') s.Write([]byte("文")) println(s.String(), s.Len(), s.Cap()) s.Grow(100) s.WriteString("123455669990") println(s.String(), s.Len(), s.Cap()) println(strings.ToTitle(s.String())) s.Reset() println(s.String(), s.Len(), s.Cap()) }
关于上面的代码:
strings.Builder
不需要显式的初始化,直接就可以用。这是 go 的 零值可用 机制。至于哪些东西是 零值可用 的,不一定。strings
这个包,除了Builder
,还提供了其它针对字符串的工具,但是这些工具不能作用于Builder
对象。- 要获取
Builder
的长度,需要用Len()
方法,你不能用len(builder)
,len()
没办法通用(说好的“面向接口”呢)。 Cap()
是获取当前对象的空间大小信息,Write()
的东西超出了预分配的大小,builder
会自动“整理重分配”。- 你可以通过
Grow()
自己预先调整空间,结果是“至少”那么多。Reset()
是重置,看起来可能也会直接释放空间。
从这里看 go 的种种,真的谈不上美感,也许是实用吧。
11. 文件IO
11.1. 文件读写
go 中的文件相关功能,由 os
模块提供支持,但同时,还有像 io/ioutil
之类的工具封装。
os
中,有对 File , fd,及各种具体的文件类型,文件权限的处理。
我去翻 os
的 API 时,找到了两个“打开文件”的方法,一个是 Open()
另一个 OpenFile()
,第一个只是为“读”打开,第二个有“读写”。这时我才知道, go 中,是不支持“参数默认值”机制的。如果一定要做,可以通过“可变参数”自己折腾。
不支持“参数默认值”是一种设计上的选择,无疑会带来很多的不便,但是我并不认为会带来多少“意图清晰”的收益。
当然,如果你在网上搜索一下为什么 go 不支持参数默认值,就会看到很多“屁话”,这种感觉,就像很多人无脑说 OSX 的设计是多么好一样,好到把 Enter 用成“改名”都是一种优越感(吐)。
直接用 os
读取完整的文件,还是有些麻烦的:
package main import ( "fmt" "os" "strings" "io" ) func main() { file, err := os.Open("/home/zys/temp/demo.go") if err != nil { fmt.Println(err) panic(err) } var data strings.Builder for { var buff []byte = make([]byte, 10) _, err := file.Read(buff) if err == io.EOF { break } data.Write(buff) } fmt.Println(data.String()) }
每次只能读取具体大小的字节,通过判断 EOF
决定下一步行为。
直接使用 ioutil
会方便一些:
package main import ( "fmt" "os" "io/ioutil" ) func main() { file, err := os.Open("/home/zys/temp/demo.go") if err != nil { fmt.Println(err) panic(err) } content, err := ioutil.ReadAll(file) fmt.Println(string(content)) content2, err := ioutil.ReadFile("/home/zys/temp/demo.go") fmt.Println(string(content2)) }
写文件也是类似的:
package main import ( "fmt" "os" ) func main() { file, err := os.OpenFile("/home/zys/temp/demo.txt", os.O_RDWR|os.O_CREATE, 0755) if err != nil { fmt.Println(err) panic(err) } n, err := file.Write([]byte("890")) n2, err := file.WriteString("中文") println(n, n2) }
11.2. 标准输入输出
os.Stdin
, os.Stdout
, os.Stderr
是三个 File Like
的对象。
标准输出和错误输出都很简单:
package main import ( "os" ) func main() { os.Stdout.WriteString("123") os.Stderr.WriteString("123") }
输入处理直接地可以是:
package main import ( "os" "fmt" "strings" ) func main() { var s strings.Builder for { var buff []byte = make([]byte, 1) _, err := os.Stdin.Read(buff) if err != nil { break } if buff[0] == '\n' { fmt.Println(s.String()) break } s.Write(buff) } }
11.3. File Like
(暂时没找到现成的方案)
12. 命令行参数
完善一些的工具,就是 flag
这个模块。不过最直接的处理是使用 os.Args
。
package main import ( "os" ) func main() { for idx, args := range os.Args { println(idx, args) } }
编译后执行:
./demo --abc = 123 -b 1 "1 2 3"
能看到输出的内容是:
0 ./demo 1 --abc 2 = 3 123 4 -b 5 1 6 1 2 3
基本上就是以空格分割,但是额外处理了引号。
13. 日志
go 官方自带了一个 log
模块,有基本的配置能力。但是最重要的 Level 没有,这就有点尴尬。自己处理的话,只能不同的 Level 单独定义一个“实例”,然后通过配置再处理 output 。当然,第三方有一些功能更完整的模块。
package main import ( "os" "log" ) func main() { InfoLog := log.New(os.Stdout, "INFO ", log.Ldate|log.Ltime|log.Lshortfile) ErrorLog := log.New(os.Stdout, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile) InfoLog.Print("here") ErrorLog.Print("here") }
INFO
部分是 Prefix , log.Ldate...
是 Flags ,就提供了有限的几个配置。
14. 测试
go 有自带测试支持,主要在两个方面。一是 go
这个命令行工具,专门有一组 go test
功能,这套功能配合约定的“文件名”,“函数”等,可以直接一键运行项目中的测试用例。另一方面,官方提供了 testing
这个包,里面有测试的基本的功能实现,但是没有断言……
14.1. 功能测试
这里的几个规则包括:
- 文件名以
_test
结束。 - 函数以
Test
开头,参数是*testing.T
。 - 或者一个
TestMain
的函数,参数是*testing.M
。
下面的代码写在 demo_test.go
文件中。
package main import "testing" func add(a int, b int) int { return a + b } func TestAdd(t *testing.T) { t.Run("first", func(t *testing.T){ if add(1, 2) == 3 { t.Fail() } }) t.Run("second", func(t *testing.T){ if add(1, 2) == 3 { } }) } func TestXX(t *testing.T) { t.Run("first", func(t *testing.T){ if add(1, 2) == 3 { } }) t.Run("second", func(t *testing.T){ t.Run("another", func(t *testing.T){ if add(1, 2) == 3 { t.Fail() } }) }) } func setup() { println("setup") } func teardown() { println("teardown") } func TestMain(m *testing.M) { setup() m.Run() teardown() }
执行的时候:
go test demo_test.go
这样只能看到 Fail 的用例。要看全部的测试用例,可以:
go test demo_test.go -test.v
14.2. 性能测试
它的几个规则包括:
- 文件名以
_test
结束。 - 函数以
Benchmark
开头,参数是*testing.B
。 - 或者一个
TestMain
的函数,参数是*testing.M
。 - 在命令行执行的时候,需要加上
bench
参数。
package main import "testing" import "strings" func add(a string, b string) string { return a + b } func add2(a string, b string) string { var s strings.Builder s.WriteString(a) s.WriteString(b) return s.String() } func BenchmarkAdd(b *testing.B) { b.Run("add", func (b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { add("1", "2") } }) b.Run("add2", func (b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { add2("1", "2") } }) } func TestMain(t *testing.M) { t.Run() }
和功能测试一样的,只是 *testing.B
提供了不同的功能。
执行的时候:
go test -bench=. demo_test.go -test.v
15. 并发与锁
作为一个把并发并行作为特性来设计的语言,却还有如此复杂多样的处理机制。同时在语法层面,对于是否“并行安全”也没有单独的设计,这样,当你使用某个数据结构,或者某个方法的时候,除非看文档,否则,你是不知道它是否是并行安全的。如此,我觉得,可能是会存在层层加锁的问题。
15.1. go 的调度能力
这个前面提到过。下面的死循环代码是可以跑满所有 CPU 核心的:
package main func main() { var count int = 0 for { go func(){ count += 1 println(count) for {} }() } println("over") }
但是不用 goroutine 的编译后的 go 程序,就没有特别之处了:
package main func main() { var count int64 = 0 var a float64 = 3.1415926 var m = map[float64]float64{} for { for { count += 1 a = a * float64(count) / float64(count) m[float64(count)] = a + 0.0001 if count % 10000 == 0 { println(count, a) } } } println("over") }
可以看到,程序会在多个 CPU 核心之间调度。
15.2. 竞态
即使是最简单的数据结构,最简单的操作,也不要以为它们是并行安全的:
package main import "fmt" import "time" func main() { var count int = 0 for i := 0; i < 100; i++ { go func(){ for j := 0; j < 100; j++ { count += 1 } }() } time.Sleep(5 * time.Second) fmt.Println("count is", count) }
多执行几次,一定会看到 count
最后的结果是会比 10000 小。 go 虽然可以在多 CPU 核心之间并行调度,也可以帮你把所有进程同步的事做好,但是,数据结构及操作行为的并行同步的工作,必须要自己处理。也许有的数据结构是并行安全的,那也需要你自己确认。
15.3. 原子操作
“原子操作”的意思,是 CPU 指令中本身提供了并行安全的计算指令,直接在相应的内存地址上使用这些指令,那么 CPU 会保证它们的操作是原子性的,不会遇到并行计算问题。然而,这些操作基本只支持整数,也只支持有限的一些简单运算。
go 通过 sync/atomic
包来提供这方面的支持。
package main import "fmt" import "sync/atomic" import "time" func main() { var count int32 = 0 for i := 0; i < 100; i++ { go func(){ for j := 0; j < 100; j++ { atomic.AddInt32(&count, 1) } }() } time.Sleep(2 * time.Second) fmt.Println("count is", count) }
通过 atomic.AddInt32
执行的加 1,就不会有并行问题,所以,结果必然是 10000
。
atomic
,提供了针对整数和指针的这几类操作:
- 加法,Add 。(记得可以加一个负数)
- 比较和交换, CompareAndSwap ,如果当前值和指定值相同,则赋值为新值,返回
true
。 - getter, Load
- setter, Store
同时, atomic
还提供了一个比如通用的 Value
类型,我猜,可能是内部使用指针实现了一些操作吧。
Load 和 Store 单独看别以为是字面上那么简单,它后面还涉及“指令序”等知识。
所以,原子操作的功能,像我们这种对底层不太了解的人,尽量少碰了。
15.4. 互斥锁
互斥锁就是通常我们理解的“锁”,这里要注意,它是一种工具,而不是一种保证机制。工具的意思是,大家都用 Lock()
,那么可以达到同步的目的。而如果有些并行内容它本身不使用 Lock()
,那么它就可以无视其它并行使用了的 Lock()
。
go 中通过 sync.Mutex
提供锁的功能。
package main import "fmt" import "sync" import "time" func main() { var count int = 0 var m sync.Mutex for i := 0; i < 100; i++ { go func(){ for j := 0; j < 100; j++ { m.Lock() count += 1 m.Unlock() } }() } time.Sleep(2 * time.Second) fmt.Println("count is", count) }
同样是锁, go 中还有控制得更细一些的 RWMutex
,区分了读写的锁,它的规则是:
- 同一时间只能一个写。
- 同一时间可以多个读。
- 读写互斥。
方法上子 Mutex
多两个:
Lock()
写锁定Unlock()
写释放RLock()
读锁定RUnlock()
读释放
前面的例子,加“写锁”可以,只加“读锁”是没用的:
package main import "fmt" import "sync" import "time" func main() { var count int = 0 var m sync.RWMutex for i := 0; i < 100; i++ { go func(){ for j := 0; j < 100; j++ { m.RLock() count += 1 m.RUnlock() } }() } time.Sleep(2 * time.Second) fmt.Println("count is", count) }
下面的例子,是模拟一个写要很长时间,而且中途还会写入不完整内容,读也要很长时间,而且还有多个读的场景:
package main import "fmt" import "sync" import "time" import "io" func writer(callback func(string, error)) { for { fmt.Println("I am writing...") callback("xxx", nil) time.Sleep(5 * time.Second) callback(time.Now().Format("2006-01-02 15:04:05"), io.EOF) } } func main() { var current *string = new(string) var m sync.RWMutex go writer(func(s string, err error){ if err == nil { m.Lock() } *current = s fmt.Println("Writed", s) if err == io.EOF { m.Unlock() } }) for i := 0; i < 3; i++ { go func(){ for { m.RLock() fmt.Println("I am reading...") time.Sleep(2 * time.Second) fmt.Println("Read out ", *current) m.RUnlock() } }() } time.Sleep(30 * time.Second) }
不加锁,基本上永远只能读到不完整的 xxx
。RLock()
换成 Lock()
的话,读的部分就严重阻塞了,没有了并发能力。
15.5. 并发计数器
并发计数器,可以看成是一个简单的计数器实现,只是额外处理了并行安全。
go 中在 sync.WaitGroup
,主要有 3 个方法:
Add()
Done()
Wait()
package main import "fmt" import "sync" func main() { var counter sync.WaitGroup counter.Add(10) for i := 0; i < 10; i++ { var n = i go func(){ defer counter.Done() fmt.Println(n) }() } counter.Wait() }
使用还是比较简单的,不过要注意,不要在 goroutine 中调用 Add()
,可能在 Add()
之前 Wait()
都结束了,所以记得把 Add()
和 Wait()
保持在同一个上下文中。
另外,这个工具和 goroutine 也没有必然联系,如果你想,可以在任何场景下使用。
15.6. 锁的条件应用
这里说的“条件应用”,指的是 sync.Cond
。它是基于锁的一套上层工具,提供了一种跨上下文的“通知机制”,并且这个“通知”过程是伴随锁状态交替的。
sync.Cond
主要提供三个方法:
sync.NewCond(l Lock)
初始化。cond.Wait()
阻塞等待通知。cond.Signal() / cond.Broadcase()
通知使用Wait()
阻塞的地方。
一个例子:
package main import "fmt" import "sync" import "time" func main() { var cond = sync.NewCond(new(sync.Mutex)) var counter sync.WaitGroup var c = 10 counter.Add(c) for i := 0; i < c; i++ { var n = i go func(){ cond.L.Lock() cond.Wait() defer counter.Done() fmt.Println("start", n) time.Sleep(10 * time.Second) fmt.Println("end", n) cond.L.Unlock() }() } go func(){ for { time.Sleep(5 * time.Second) fmt.Println("out") cond.Signal() //cond.Broadcast() } }() counter.Wait() }
- 初始化
cond
使用:sync.Newcond(new(sync.Mutex))
。 - 代码的执行行为,就是“通知”之后, 10 个 goroutine 共花 100 秒全部执行一遍输出,然后结束。
cond.L.Lock()
/cond.L.Unlock()
都需要显式手动控制不太理解。Wait()
的实现里面,其实有对L
的加锁,释放的操作。Signal()
/Broadcast()
不会阻塞,它们跟锁没关系。只是可能得不到任何响应就是了。- 与其说是
Wait()
阻塞了,不如说是Wait()
前一行的Lock()
阻塞了。 - 与锁有关的
Wait()
配合与锁无关的Signal()
/Broadcast()
,在锁之上实现了新的抽象层作为多个上下文的同步工具。 - 不说
channel
,这个例子如果不直接用cond
,要实现竞态的一组 goroutine ,被不关心竞态的一个 goroutine 调度,你自己做出来的工具估计也就是像cond
这样的实现了。
15.7. 层级 Context
前面介绍了 WaitGroup
,它是一个简单,限制比较大的工具。
Context
则是一个使用“通道”实现的,比较灵活的工具。它功能也很简单,就是在合适的时候,手动发出“取消信号”。这个“合适的时候”,包括内置的计时器。
package main import "context" import "fmt" import "time" func main() { var root = context.Background() var current = 0 context, cancel := context.WithCancel(root) for i := 0; i < 10; i++ { go func() { for { current += 1 time.Sleep(1 * time.Second) fmt.Println(current) if current > 20 { cancel() fmt.Println("cancel") } } }() } <-context.Done() fmt.Println("complete") }
context
本身是层级结构, Background()
可以得到一个“根”, WithCancel()
又继续得到一个“子”。当 cancel()
的时候,这个信息会往父级传递,然后 context.Done()
这个通道会关闭。
注意,上面的例子,只是 main()
执行完毕,并没有显式地去处理 goroutine
的结束问题。
要显式处理 goroutine
,我搜索到的方式都是把 context
传递给一个函数,函数内部再处理 Done()
,感觉挻别扭的。
除了 WithCancel()
, context
还提供了 WithDeadline()
和 WithTimeout()
,它们两个其实是一样的,前者是给一个绝对时间,后者是给一个相对时间(绝对时间等于当前时间加相对时间嘛)。
package main import "context" import "fmt" import "time" func main() { var root = context.Background() var current = 0 context, cancel := context.WithTimeout(root, time.Second * 2) for i := 0; i < 10; i++ { go func() { for { current += 1 time.Sleep(5 * time.Second) fmt.Println(current) if current > 20 { cancel() fmt.Println("cancel") } } }() } <-context.Done() fmt.Println("here") for { time.Sleep(5 * time.Second) break } fmt.Println("complete") }
上面的例子,能看到 2 秒之后, Done()
通道就有动作了,这里 go func()
里还在 Sleep
。同时也能看到,虽然代码已经跳到 here
那里了,但是上面的 goroutine
其实仍然不受影响地继续执行着的。
要处理好 go func()
的退出,可以传入 context
,并使用 select
:
package main import "context" import "fmt" import "time" func main() { var root = context.Background() var current = 0 ctx, cancel := context.WithTimeout(root, time.Second * 2) for i := 0; i < 1; i++ { go func(ctx context.Context) { for { select { case <-ctx.Done(): fmt.Println("select return") return default: fmt.Println("select default") current += 1 time.Sleep(5 * time.Second) fmt.Println(current) if current > 20 { cancel() fmt.Println("cancel") } } } }(ctx) } <-ctx.Done() fmt.Println("here") for { time.Sleep(5 * time.Second) break } fmt.Println("complete") }
虽然时间上没有办法在 2 秒时就马上中断 go func()
,但是 select
中的 return
是总会执行到的,对于有死循环的 goroutine
可以避免泄漏。
15.8. 缓存共享池
这里的关键点不是“池”,而是“缓存”,所以在使用时要记得,它可能随时消失。
go 通过 sync.Pool
结构体提供了一个简单直接的并行安全的“单对象内存块缓存”方案。
它通过“复用”来减轻内存变动的消耗,对于应用来说,只要得到一个可用的“对象”,本身也不必在乎这个对象是新的,还是二手的。
package main import "fmt" type Foo struct { name string value string } func main() { var f *Foo = &Foo{} f.name = "123" f.value = "333" fmt.Printf("%v %p\n", f, f) var f2 *Foo = &Foo{} f2.name = "321" f2.value = "889" fmt.Printf("%v %p\n", f2, f2) }
(这里我们通过指针的方式使用结构体,而不是直接引用,是为了避免“值传递”时的内容复制行为)
上面这个原始的例子是要拿两次 Foo
,然后每次给它们赋值 name
和 value
之后,打印出来。
通过 f
和 f2
的地址值,可以看出来它们在两块内存上。
package main import "fmt" import "sync" type Foo struct { name string value string } func main() { var cache sync.Pool cache = sync.Pool{ New: func() interface{} { fmt.Println("New") return &Foo{} }, } var f *Foo = cache.Get().(*Foo) f.name = "123" f.value = "333" fmt.Printf("%v %p\n", f, f) f.name = "" f.value = "" cache.Put(f) var f2 *Foo = cache.Get().(*Foo) f2.name = "321" f2.value = "889" fmt.Printf("%v %p\n", f2, f2) }
这个改进后的使用 sync.Pool
的例子,能看到 f
和 f2
是在同一块内存位置,我们在 Put()
前把 Foo
的属性手动重置掉的话,对于 f2
来说,是用的旧东西,还是全新的东西,不重要。
16. 项目模块,包,名字空间
16.1. 安装环境
最开始说过如何安装,在安装完之后,可以使用:
go env
来查看当前的环境,比如:
GO111MODULE="" ... GOBIN="" GOCACHE="/home/zys/.cache/go-build" GOENV="/home/zys/.config/go/env" ... GOOS="linux" GOPATH="/home/zys/go" ... GOPROXY="https://proxy.golang.org,direct" GOROOT="/opt/go" ... GOTMPDIR="" GOTOOLDIR="/opt/go/pkg/tool/linux_amd64" ... GOVERSION="go1.17.2" ... PKG_CONFIG="pkg-config" GOGCCFLAGS="-fPIC -m64 -pthread -fmessage-length=0 -fdebug-prefix-map=/tmp/go-build2853245378=/tmp/go-build -gno-record-gcc-switches"
GOPATH 是后面安装的包会放的地方。 GOROOT 是整个环境的根目录。
16.2. 项目模块
go 中的 mod ,我理解就是一个“项目”的意思。类似于 Node 中的 package.json
, go 中也有 go.mod
。
创建一个名为 foo
的目录,然后进入目录,键入:
go mod init zys.me/foo
就可以把当前目录做成一个 go 模块,它会在当前目录创建一个 go.mod
的文件,内容是:
module zys.me/foo go 1.17
这里的 module
后面就是模块名,也算是一个顶级的名字空间。
之后,通过 go get
安装的依赖包,也会写入到 go.mod
中,比如:
go get github.com/fatih/color
这是一个终端的颜色封装,安装之后,查看 go.mod
会看到:
module zys.me/foo go 1.17 require ( github.com/fatih/color v1.13.0 // indirect github.com/mattn/go-colorable v0.1.9 // indirect github.com/mattn/go-isatty v0.0.14 // indirect golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect )
依赖的内容就添加上了。记得结尾那些 indirect 。
我们再新添加一个 foo/main.go
文件:
package main import "github.com/fatih/color" func main() { color.Cyan("你好") }
执行能看到蓝色的字。
执行一下:
go mod tidy
整理依赖。再查看 go.mod
,结果变成了:
module zys.me/foo go 1.17 require github.com/fatih/color v1.13.0 require ( github.com/mattn/go-colorable v0.1.9 // indirect github.com/mattn/go-isatty v0.0.14 // indirect golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect )
从现在的配置能看出,项目直接依赖了 color 。其它三个,估计是 color 它依赖的,所以对我们来说是“间接”依赖。
刚才下载的 color 包,文件会保存到 /home/zys/go/pkg/mod/github.com/fatih/[email protected]/ 。
能看出,依赖的处理,也考虑了版本号。
16.3. 包 package
在学习的过程中,只用到一个 main
的包就可以了。但是正式的项目,就可能会有多个包存在。
一个模块,看成是一个项目的话,那么一个模块中,就可能有多个包。
包通过在代码文件开头的 package
结构写明。一个包,可以来自多个文件,但是一个包的文件只能放在同一个目录中,反之也限制了一个目录只能有一个包。
如果我们要新加一个 text.go
的文件,并且把它放到新的 text
的包中,那么之前的代码结构就要做一些调整,直接地:
foo/main.go foo/text.go
这各目录结构肯定不行,会报找不到包的错误。
需要变成:
foo/main/main.go foo/text/text.go
这样, main
目录下是 main
包, text
目录下是 text
包,就没有问题。
text.go
的内容是:
package text import "github.com/fatih/color" func Echo() { color.Cyan("haha") }
main.go
的内容是:
package main import "zys.me/foo/text" func main() { text.Echo() }
能看到使用自己的 text
包,也是一个绝对地址引用。
这个地址,其实是“模块名+目录”,跟包名没有关系,跟源代码文件名也没有关系。先通过目录定位到了包,之后 main()
中的那个 text
才是包名。
如果我们把代码结构改成:
. ├── a │ └── 123 │ └── t.go ├── go.mod └── main └── main.go
那 main.go
也需要改成:
package main import "zys.me/foo/a/123" func main() { text.Echo() }
这里还要注意一点, Echo()
这个方法名,必须首字大写,否则这个方法在 main
包中找不到。(首字大写表示 public)
多说一点,这里的 zys.me/foo/a/123
起的是包的名字空间的作用,也是按目录起包的定位作用。如果 123
目录还有子目录,那么 123
和 123/sub
就是不同的定位,表示的也是不同的包。不过即使是不同的包,也可能具有相同的名字。
说到这里,可以看出,项目依赖的东西( go get
安装的那些),应该说依赖的是“模块”,不是“包”。一个“模块”中可能有多个“包”。
而在使用时,却只是以“包”为粒度,先 import
,然后再用。
17. 最后自己的一点看法
作为比较新的静态编译类型的语言, go 并没有带来新的理念,它也不纠结是否自己有完备的一些模型。感觉很多地方,都是向着实用的方向去设计的。所以社区中也有声音认为 go 是在开历史的倒车。同时,相较于经常被拿来两者比较的 Rust , go 的上手难度要小很多,可能也是因为这个原因,很多公司会选择用 go 做应用层的开发。我个人对此,是不太能理解的。
go 相较于 C ,那肯定方便不少。但是应用层,对比的是 jvm 那套啊。即使先不论 jvm 系的多了虚拟机和容器两层抽象,在服务管理方面有先天的优势。go 没有 try/catch ,没有 class 的 OO ,没有泛型。是,没有这些不影响实现功能,但是要考虑开发人员的心智嘛。纯函数式的机制,我认为确实有比 class 的 OO 更高的抽象能力,但是代价却是开发人员的额外设计投入,模型推演上花的额外的时间。对于应用层开发,这点上显然是得不偿失的。业务本身的变化又不会看技术设计的上限。所以简单直接,方法论完整的 class 的 OO 就是一个最好的选择。
另外一点, go 提供了 goroutine 这个工具,如果用到网络编程的场景,它本质上跟“线程/进程”模型,是一样的。不会因为资源代价更小,就改变了的这个模型先天的问题。现在都 2021 年了,异步 IO 的模型应该已经成为大家的共识,现在又倒回“线程/进程”模型(go 的底层虽然用了 epoll ,但是官方包的上层仍然是使用 goroutine 做的同步模型)。有人做过测算,如果一个 goroutine 占 4KB 的内存,那么单机收到 100 万连接请求的话,就会占到 8G 的内存(IO的读写,go 中好像是分成 2 个 goroutine 处理的)。问题是,应用层收纳了 100 万的请求连接,除了占内存,并没有什么用啊。CPU 的核心只有 8 个或者 16 个,数据库的连接也是瓶颈。不要让连接过早进入应用服务,留在网络层调度要容易得多。当然,如果这 100 万都是需要长期保持连接的直连长连接,那 go 确实是非常合适的方案。除了官方库,其它的第三方网络相关的实现,也能找到异步 IO 的。
同时, go 提供了很容易创建的 goroutine ,但是“并行同步”都是传统那一套,加锁。“通道”不加锁,但它本身也是阻塞的。应用层开发几乎用不到了“并行编程”的,但是 go 的机制却很可能给很多人提供了一个犯错的机会——并行编程真的很难很难, go 没有把它变得简单。
所以,我认为 go 适合做计算密集偏向的那些网络服务,比如,各种底层基础设施的服务端。但是不适合应用层开发。