任性的语言-Golang

Go 现在名声越来越大,很多大型互联网公司都在过渡到 Go,也不用多介绍了,我对其的印象就是:Google 出品、21世纪编程语言、logo 非常萌、纯编译型、对标 C(C+Python=Go)、面向并发高效计算(毕竟出生的时候已经多核时代)、万物皆异步、函数一等公民(函数式编程,CSP 并发模型)、语法简洁(没有继承、泛型、异常处理和对象的概念,但是有接口即面向接口)、效率高(更少的存储空间和更少的内存写操作)、企业级编程语言(另一个是 Java)承诺保证向后兼容、完全开源。
兴趣使然,简单看一下作为后来者到底解决了前人的那些痛点,取取经(我才发现原来 Python 的出生比 Java 早)。

还有个好处就是自带 GC,这一点可真是太贴心了,性能方面基本可以赶上 Java(Scala、JIT 优化后的代码),不要问为什么纯编译型语言还不如 Java,Java 慢是很久很久之前的观点了,硬要说是臃肿的第三方库(有利有弊,结果就是一个项目打个包就过百 M 了),另外 JVM 的 JIT 确实很厉害的,优化后与 C 基本无异;JIT 还帮你擦屁股(比如各种锁优化、逃逸分析、无效代码消除等等),优化人写出来的烂代码,这点 Go 是做不到的,你写成什么样就什么样了,所以你写的代码烂真的还不如 JVM 这种 runtime 的优化。
Golang 国内镜像站:https://golang.google.cn/
package 文档:https://golang.google.cn/pkg/
另外,Go 已成为区块链的主流开发语言。

入门

安装完成后使用 go env 可以检查环境,然后新建 GOPATH 环境变量,设置代码工作区,新版本支持多个,也可以不建,一般这个目录下会有三个文件夹,src、pkg、bin,分别对应源码、中间文件、可执行文件。
然后把 $GOPATH/bin 添加到系统环境变量中,方便直接在命令行中执行。
作为个人来说,src 目录下使用类似网址来区分项目即可,这里正序即可不需要像 Java 那样 com 倒序开头,文件命名使用小写,多单词使用下划线分割。
常用命令(其他命令用到自行搜索):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 编译
go build
# 其他路径下编译,编译后的可执行文件保存在当前目录下
# go build {src下的路径,到最后的文件夹}
go build github.com/xxx
# 重命名
go build -o demo

# 运行
./xx
# 直接运行
go run xx.go

# 编译、复制到 bin 下
go install

# 交叉编译\跨平台编译
GOOS=linux GOARCH=amd64 go build hello.go # linux 64 位
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build # win 64 位

SET CGO_ENABLED=0 # 禁用 CGO,使用了 cgo 的代码是不支持跨平台编译的
SET GOOS=linux # 目标平台是 linux
SET GOARCH=amd64 # 目标处理器架构是 amd64

入门代码:

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello, 世界")
}

需要注意的是,package 是必须的,如果包名是 main,代表是编译为可执行文件,需要有个 main 函数作为入口。
函数外部无法使用语句,只能定义变量。
Go 语言中,有 25 个关键字,37 个保留字,可以说是非常精简了。

go get

单独把这个命令拿出来是因为用的非常频繁,go get 命令可以借助代码管理工具通过远程拉取或更新代码包及其依赖包,并自动完成编译和安装。整个过程就像安装一个 App 一样简单。
这个命令在内部实际上分成了两步操作:第一步是下载源码包,第二步是执行 go install。使用 -d 参数可以只下载不安装。
因为 Go 语言代码托管在 Github,所以自然默认使用 git 作为版本控制工具,使用 go get 之前需要安装 git,虽然它支持 SVN 等其他工具和其他的类似 Github 的托管站点,但是主流一般都是 Github。

工具

Go 语言提供了不少工具来高效率编程,如果使用 VSC 它会提示你安装这些工具,以 gofmt 来说,体现了 Go 对格式的强硬态度,这个工具是对代码进行格式化对,并且没有任何参数可以调整,这表示对编程风格的高度统一,不存在因为格式而撕逼对情况了。

风格

Go 中单行语句结尾不需要分号,编译器会主动把换行符转换为分号,在某些情况下不会添加,例如括号、+ 等符号,也就意味着语句太长换行的时候是有规则的,不能在普通字符后换行。
在方法调用等情况中,如果太长折行的话,一定是从左括号开始,否则因为自动添加分号会编译不通过,而括号作为特殊字符不会进行处理,同样为了保持风格,Go 允许最后以 , 结尾,逗号作为特殊符,后面也可以进行换行。
其他的,例如注释也有相应的规范,包注释写在第一行,多个文件的话只写一个即可,文档工具会自行处理,Go 的规范网上可以搜到很多,例如这篇看一下即可,如果使用了 golint 等工具,不规范直接会给你提示。

对于错误或者异常,Go 语言习惯是在 if 中(if err != nil)处理并且直接返回(return),error 也一般是作为返回值返回,放在最后,另一个选择是 panic,相比之下用的不多。

变量与常量

Go 既然是静态语言,变量的声明是要确定类型的,Go 中的声明标识符分为四类:var、const、type、func,先说前两个,用代码示例来展示一下用法:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
var name string
// 批量声明
var (
age int
isOk bool
)
var a, b int = 3, 4
var c, d = "str", 5
var desc string = "desc"
// 函数变量/定义函数
var fun1 = func() {
fmt.Println("func...")
}

func main() {
// 短变量声明,只能在函数中使用
s1 := "Goooo"
fmt.Println(s1)
// 字符串、数值(任何类型)、增加双引号(%q)
// 另外 %b %o %x %T %p,二、八、十六进制,类型,地址&
// 浮点数 %f %.2f
fmt.Printf("%s, %v, %#v", s1, s1, s1)
}

// 匿名变量
x, _ = func1()

// 声明常量
const info = "MMP"
const (
// 都是 1
code1 = 1
code2
code3
)
const (
c1 = iota // 0
c2 // 1
c3 // 2
_
c4 // 4
c5 = 100 // 100
c6 // 100
c7 // 7
)
const (
a1, a2 = iota + 1, iota + 2 // 1, 2
a3, a4 = iota + 1, iota + 2 // 2, 3
)
// 定义数量级
const (
_ = iota
KB = 1 << (10 * iota)
MB = 1 << (10 * iota)
GB
TB
)

变量的定义顺序比较『反人类』是先名字后类型,同时,变量声明后会进行默认初始化,文件也统一使用 UTF-8 编码,推荐使用驼峰命名;
函数中声明的变量必须使用,否则编译不通过,go 文件必须使用 package 声明包名,可以与文件夹不一致;
当想要忽略某个值的时候,可以使用匿名变量(_),匿名变量不占用命名空间,不会分配内存,所以也不存在重复声明。
iota 是常量计数器,在 const 出现的时候重置为 0,每新增一行声明就加一,注意是从 0 开始,批量声明中使用,可以当作枚举来使用。
常量的命名不要使用全大些,那个有另外的用途,总之像普通变量那样使用就好了。

经典数据类型

这里并没有按照 Go 的分类(基础类型、复合类型、引用类型、接口类型),其中部分复杂的复合类型会单独介绍,基本数据类型方面与其他语言基本一致,也就是整数、浮点数、字符串、布尔这些,数字又分为有符号和无符号:

类型描述
uint8无符号 8 位整型 (0 到 255)
uint16无符号 16 位整型 (0 到 65535)
uint32无符号 32 位整型 (0 到 4294967295)
uint64无符号 64 位整型 (0 到 18446744073709551615)
int8有符号 8 位整型 (-128 到 127)
int16有符号 16 位整型 (-32768 到 32767)
int32有符号 32 位整型 (-2147483648 到 2147483647)
int64有符号 64 位整型 (-9223372036854775808 到 9223372036854775807)
uint32 位操作系统上就是uint32,64 位操作系统上就是uint64
int32 位操作系统上就是int32,64 位操作系统上就是int64
uintptr无符号整型,用于存放一个指针,大小根据操作系统而定
rune可理解为字符型(char),解决多语言问题,大小是可变的 byte

其中,uint8就是我们熟知的byte型,int16对应C语言中的short型,int64对应C语言中的long型。

获取对象的长度的内建 len() 函数返回的长度可以根据不同平台的字节长度进行变化。
实际使用中,切片或 map 的元素数量等都可以用 int 来表示。
在涉及到二进制传输、读写文件的结构描述时,为了保持文件的结构不会受到不同编译目标平台字节长度的影响,不要使用intuint

不显式声明的情况下,默认是 int 类型,而 x := int8(16) 就是显式使用 int8 类型;浮点数的话默认是 float64 类型。
布尔类型在 Go 中是独立的,不能转换成 0 和 1。GO 中只有显式类型转换没有隐式类型转换(不包括字面量)。
PS:Go 中还设置了实数部分与虚数部分的复杂数据类型(复数),可能是想进军科学计算领域,一般用不到。

字符串

字符串在 Go 中的实现是 UTF-8 编码的,可以使用反引号进行多行字符串的输入;字符串相关处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func str() {
str := "Mps"
fmt.Println(len(str))
fmt.Println(str + "拼接1")
ss := fmt.Sprintf("%s%s", str, ",拼接2")

s1 := strings.Split(ss, ",")
fmt.Println(s1)
// 遍历 - 中文
for i, c := range []rune(str) {
fmt.Println(string(c))
}

// 字符数统计
fmt.Println(utf8.RuneCountInString(str))

// 字符串转成 byte 数组
bs := []byte(str)
}

其中 len 是求的 byte 的数量,对于非英文文字,例如中文在 U8 中一般是 3 个字节(也可能是 4 个),Go 中使用了一种 rune 的类型(实际为 int32)来保存非 ASCII 字符,可以理解为其他语言中的 char 类型,对应的是 Unicode,所以 rune 大小是一致的,即使是 ASCII;
另外,字符串是不允许修改的,底层存储使用的是 byte,非 ASCII 字符要转换成 []rune 然后再进行操作,一个 rune 可以由 1 个或者多个 byte 保存。
使用 RuneCountInString 方法统计字符的时候,它会有一层转换或者说解码,先 byte 到 rune 之后再计算。
字符串相关操作 API 在 strings 包下。

nil

nil 不属于关键字,就像其他语言中的 null 也不是关键字(你甚至可以更改 nil 的值),在 Go 中 nil 是一个预声明标识符,使用频率很高,除了其他语言的 null 用途还有其他的特点,例如 Go 中的 nil 是非常安全的,即使是 nil 也可以调用方法,只要不牵扯具体的内部变量,是完全没问题的。
PS:Go 的一个特点就是即使是使用 nil 值,也不会抛异常,因为它表示的是 zero value,或者说对象的默认值。

数组和切片

数组定义同样也是反着的,大概是特色吧一般都是反着的,切片基于数组,或者看作是可以自动扩容对数组,可变长度意味着切片非常灵活,代码示例:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 定义数组
var arr1 [3]int
var arr2 = [3]int{1, 2, 3}
var arr3 = [...]int{1, 2, 3}
var arr4 = [3]int{1:1, 2:3} // 指定索引初始化
var arr5 = [...]{99: -1} // len = 100

// [3]int 和 [5]int 是不同类型
func arrEcho(arr [3]int) {
for _, v := range arr {
fmt.Println(v)
}
}
// 使用指针,可以进行修改
func editArr(arr *[3]int) {
arr[0] = 100
}

// 切片
var arr = [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
func sp(){
// 其他语法 [:4] [2:] [:]
s := arr[2:4] // [2,3]
s2 := s[2,4] // [4,5] 向后扩展
s = append(s, 233)
fmt.Println("s / arr : ", s, arr)

// 定义一个切片
var ss []int
// 默认值 nil
fmt.Println(ss == nil)
ss = append(ss, 1, 2, 3)
fmt.Println(ss)

// 其他定义方式
sp2 := []int{1, 2, 3}
sp3 := make([]int, 10) // len = 10
sp4 := make([]int, 10, 16) // len = 10, cap = 16
sp(sp2)
sp(sp3)
sp(sp4)

// sp2 拷贝到 sp3
copy(sp3, sp2)
fmt.Println(sp3)

// 通过 append 来删除,也可以使用 copy 来删除
sp3 = append(sp3[:2], sp3[3:]...)
fmt.Println(sp3)
}

// 函数中使用切片
func sp(s []int) {
fmt.Printf("len: %d, cap: %d", len(s), cap(s))
}

需要注意的是数组是值类型,意味着函数传递的时候会进行完全的拷贝,而不是传递地址(引用),当然你可以使用指针来传递,但是太麻烦了,Go 中一般不会直接使用数组,而是会用切片。
切片在 Go 中使用 []T 定义,与数组很像,只是没有固定长度,如果接触过 python,那么应该很好理解,这里的切片和 python 中的切片还是很相似的,区间都是 [x,y),对数组切片返回的是数组的视图,也就是如果切片修改,原来的数组也会跟着修改;也正是这个原因,我们直接往函数里传递切片,就相当于传递的引用了。
使用下标获取超出切片大小的内容会报错,但是再次使用切片来处理却没有这个问题,可以说切片是可以向后扩展的(向前取不行),在切片的实现中,使用 ptr 来记录头部,使用一个 len 记录长度,所以使用下标是取不到的,会跟 len 做比较,但是使用切片会跟 cap 做对比,它记录了 ptr 到数组最后一个元素的长度,所以只要不是物理的越界,是可以进行切片的。
如果使用 append 来向切片追加数据,在不超过 cap 的情况下会覆盖原数组的内容,如果 append 后超过了 cap,那么 Go 会新创建一个数组,并由 append 返回,旧数组如果没有引用,就会被 GC。

切片的底层就是一个数组对象,由三部分组成,指针、长度和容量,长度不能超过容量(数值上,数据饱可以自动扩容),多个切片之间可能有重叠部分,因为底层可能是指向了同一个数组;切片之间不能直接比较(毕竟是间接引用),只能一个个的来,虽然效率并不高,唯一合法的比较是跟 nil,判断切片是不是空应该用 len 函数而不是使用 nil。
PS:上例使用了类似展开运算符的 sp... 语法来适配可变参数,相比其他语言同样也是倒着的;具体的扩容机制不展开说了,毕竟是隐式无法调整的,见拓展里的文章,在 1024 之前一般是 2 倍,之后是 1/4,不同类型的处理也不太一样。

Map

Map 这个数据类型肯定不陌生,不过陌生的是 Go 中的定义方法。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
m := map[string]string{
"k1": "v1",
"k2": "v2",
}
fmt.Println(m)
m["k3"] = "v3"

// 遍历
for k, v := range m {
fmt.Println(k, v)
}

// 判断是否存在
if val, ok := m["name"]; ok {
fmt.Println("存在:", val, ok)
} else {
fmt.Println("不存在", val, ok)
}

// 删除
delete(m, "k1")
fmt.Println(m)
}

嵌套定义也一样,就是繁琐,或者你还可以使用 make 函数来创建(make(map[string]string)),创建出来的是个空 Map,而 var 定义出来的是 nil;无论是那个,都是可以直接运算不会抛出异常。
map 也是 hash 实现,是无序的;如果需要排序需要加入到切片中,然后通过对 key 的排序来达到有序。
当获取的 k 不存在时,获取的是 v 类型的默认值,字符串的话就是空串了;这种情况下我们需要通过返回的第二个参数手动判断;
除 slice、map、func 的内建类型基本都可以作为 key,自定义的 Struct 类型只要不包含前面说的类型也可以作为 key,换句话说 key 必须是可以进行 == 比较运算的。
使用 key 来获取 map 的内容,最好是通过 ok 来判断一下,大部分情况下即使是空也是安全的,因为空的话会默认取零值,或者说默认值。
禁止对 map 进行取地址操作,因为随着内容的变化,地址也会随之变化,哈希实现就是如此。
当 map 是 nil 的时候,添加元素会导致 panic,但是用 make 创建的空 map 就不会有这个问题。

Go 中没有提供 set 类型,但是使用 map 的 key 特性可以做到这一点,不过也有一定的限制,因为 key 要求是可比较类型,切片就不满足这个条件,你可以定义一个函数将其转换为 string。

流程控制

编程语言我认为最核心的两块,数据类型和流程控制,流程控制基本就是指条件判断与循环,以及延伸的 switch 和 goto;因为 where 这个可以用 for 来替代,或者说 where 是 for 的语法糖,在 Go 中也没有 where,循环就是 for(三个部分都可省略,可完美替代 where):

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func main() {
num := 12
if num > 10 {
fmt.Println("Get")
}
if n1 := 2; n1 > 1 {
fmt.Println("Go")
}

for i := 0; i < 10; i++ {
fmt.Println("for - " + string(i))
if i == 4 {
break
// continue
// return
}
}
// 无限循环
for {
fmt.Println("for...")
}
// where 用法
x := funcName()
for x.isTrue() {
// ...
}

// range 遍历,v 不要的话可以省略,i 不要使用 _ 丢弃
str := "abc中文字符"
for i, v := range str {
// fmt.Println(i, v)
fmt.Printf("index: %d, val: %c\n", i, v)
}

// switch
switch f := 2; f {
case 1:
fmt.Println("sw - 1")
case 2, 3:
fmt.Println("sw - 2,3")
default:
fmt.Println("sw - def")
}
n := 3
switch {
case n < 5:
fmt.Println("sw - (< 5)")
}
}

语法上确实不太一样,没有括号了,range 关键字跟其他语言的 foreach 类似,可以遍历可迭代的数据类型,返回两个值:坐标和具体的值,不过需要注意遍历字符串的时候 index 是按字节来的,虽然输出不会乱码,但是一个汉字会跳 3 个 index。
switch 也不需要手动调用 break,符合匹配后它不会向下执行的(自动 break);另外为了可读性,还是尽量少使用 goto,虽然在跳出多层循环中 goto 很好用,但是建议使用多层 break + if 来代替。

使用 for 进行迭代的时候,一定要注意声明域(使用 := 的要注意),它记录的是循环变量的内存地址, 而不是某一时刻的值,如果需要某一时刻的值,请声明(:=)一个临时变量进行保存。

函数

函数作为 Go 语言的一等公民,这个肯定是重点,在返回值上面刚接触也有点『反人类』也就是返回值写在最后,这样倒是确实有好处,如果没有返回值直接可以省略,不需要 void 了,通过代码来解释就是:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
func eval(a, b int, op string) int {
switch op {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
default:
// 抛出异常
panic("unsupported")
}
}
func eval2(a, b int, op string) (int, error) {
switch op {
case "+":
return a + b, nil
case "-":
return a - b, nil
case "*":
return a * b, nil
case "/":
return a / b, nil
default:
return 0, fmt.Errorf("unsupported op %s", op)
}
}

// 多返回值
func div(a, b int) (q, r int) {
return a / b, a % b
}
// 不推荐的方式
func div2(a, b int) (q, r int) {
q = a / b
r = a % b
return
}

// 使用
// q, r := div2(3, 4)

// 接收函数,或者直接将函数的定义写在参数上
func apply(op func(int, int) int, a, b int) int {
return op(a, b)
}

// 可变参数
func sum(nums ...int) int {
sum := 0
for i := range nums {
sum += nums[i]
}
return sum
}

函数的返回值定义在后面,同时支持多返回类型(参考 py),多返回类型可以起名,例如 div2 的方式,但是不推荐,如果不需要全部的返回值,可以使用 _ 匿名变量来丢弃;
处理异常建议使用 Go 的推荐方式使用返回值(eval2),而不是直接抛出;函数只能与 nil 进行比较;
既然是函数式编程,必然支持高阶函数,那么函数的方法参数也可以接收函数,只需要说明函数的签名就行,不需要写函数的参数名称,只需要类型;另一种就是类似其他语言的匿名类的方式,直接把函数定义在参数上。
另外,Go 中的函数不支持重载,这样倒是简化了方法调度。

内建函数

内建函数是上面说的保留字中的一部分,包括 new、make、len、cap 等函数,介绍常用的几个:

  • new(T)
    它将创建一个指定类型 T 的匿名变量,初始化为对应的零值(默认值),然后返回地址,指针类型为 *T
    它与普通的变量声明没有太大区别,它类似一个语法糖,不需要名字的匿名变量。
    一般情况下,new 用的并不是很多,并且它不是关键字,所以你甚至可以改变它的定义。

    1
    2
    3
    4
    5
    func main() {
    num := new(int)
    fmt.Println(num)
    fmt.Println(*num)
    }

    使用的时候要注意,new 返回的是地址而不是值

  • make([]T, len, cap)
    它会创建一个指定的类型、长度、容量的切片,容量可以省略,默认与 len 相等。
    同样,它创建的也是匿名变量,除了切片,其他类型也可以创建,例如 map make(map[string]string)
    make 只用于 slice、map 以及 channel 的初始化,并且返回的是引用本身;为了避免 panic,这三种类型尽量使用 make 来创建。

  • append
    用于向切片追加数据(可以是多个),容量饱和会自动扩容(通过复制),并返回。
    安全起见,请尽量将它的返回值赋予原变量。

  • copy(target, source)
    将一个类型复制到另一个相同的类型变量上,例如上面的 append 函数中。
    另外,在 IO 中用的也很频繁。

一览表:

内置函数介绍
close主要用来关闭channel
len用来求长度,比如string、array、slice、map、channel
new用来分配内存,主要用来分配值类型,比如int、struct。返回的是指针
make用来分配内存,主要用来分配引用类型,比如chan、map、slice
append用来追加元素到数组、slice中
panic和recover用来做错误处理

匿名函数与闭包

匿名函数因为没有名字,所以无法显式调用,所以匿名函数要么保存到一个变量,要么就立即执行,多用于回调和闭包;
Go 使用闭包技术实现函数值,很多人也将函数值称为闭包;
闭包指的是一个函数和与其相关的引用环境组合而成的实体。简单来说,闭包=函数+引用环境

1
2
3
4
5
6
7
8
9
10
11
12
13
func adder() func(int) int {
var x int
return func(y int) int {
x += y
return x
}
}

unc main() {
var f = adder()
fmt.Println(f(10)) //10
fmt.Println(f(20)) //30
}

变量 f 是一个函数,并且引用了外部变量 x,此时 f 就是一个闭包,在 f 有效的生命周期中,x 一直有效;
闭包的概念与 js、py 中基本一致。

Deferred函数

defer 语句会将其后面跟随的语句进行延迟处理;也就是说,先被defer的语句最后被执行,最后被defer的语句,最先被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
fmt.Println("start")
defer fmt.Println(1)
defer fmt.Println(2)
defer fmt.Println(3)
fmt.Println("end")
}
// out:
// start
// end
// 3
// 2
// 1

正是因为这个特性,非常方便的处理资源释放问题。比如:资源清理、文件关闭、解锁及记录时间等。
在 Go 语言的函数中 return 语句在底层并不是原子操作,它分为给返回值赋值和 RET 指令两步。而 defer 语句执行的时机就在返回值赋值操作后,RET 指令执行前。

Panic

Go 没有异常机制,但是可以使用 panic/recover 模式来处理错误。 panic 可以在任何地方引发,但 recover 只有在 defer 调用的函数中有效。

1
2
3
4
5
6
7
8
9
func funcB() {
defer func() {
// 如果程序出出现了 panic 错误,可以通过 recover 恢复过来
if err := recover(); err != nil {
fmt.Println("recover in B")
}
}()
panic("panic in B")
}

当 panic 发生时,程序会中断执行,并立即执行该 goroutine (理解为线程)中被延迟的函数(defer)

指针

Go 中的指针相比 C 已经简化了很多了,虽然进行了简化,但是在 Go 中还是非常重要的一个东西;
在 Go 中,指针不能参与运算,只有值传递一种方式,不用特意的去理解,跟 Java 其实也没多大区别,地址也是值。

1
2
3
4
5
6
7
8
9
10
func swap(a, b *int) {
*a, *b = *b, *a
}

// call
swap(&a, &b)

func editArr(arr *[3]int) {
arr[0] = 100
}

当然,上面的交换使用返回值的方式也可以做到,这里仅仅是单纯的演示指针。
在 editArr 中接收的是数组的地址(指针),下面可以直接使用 arr 这个名字来进行操作,不需要在带着 * 这个还是非常方便的。
简单来说,& 就是取地址,* 就是获取指针指向的变量内容,例如指向 int 类型的指针的类型就是 *int,如果 p 指向的是一个地址(&name)那么通过指针修改变量值就需要使用 *p

结构体和方法

这里本来想说面向对象的,但是 Go 它不是面向对象的语言,它只有封装,没有继承和多态,这也算是好消息吧;所以它没有 class 只有 struct,结构体是一个聚合类型,由零个或者任意多个任意类型组合的实体。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func main() {
// 定义
var root treeNode
fmt.Println(root)

root = treeNode{value: 3}
root.left = &treeNode{}
root.right = &treeNode{4, nil, nil}
// 指针可以直接用 . 调用
root.right.left = new(treeNode)

root.print()
root.setVal(233)
root.print()
}

// 工厂函数
func createNode(val int) *treeNode {
return &treeNode{value: val}
}

// 定义结构体
type treeNode struct {
value int
left, right *treeNode
}

// 定义结构体的方法
func (node treeNode) print() {
fmt.Println(node.value)
}
func (node *treeNode) setVal(val int) {
node.value = val
}

// 因为经常使用指针操作结构体,一般定义
func show(){
pp := &treeNode{3}
// 等价于
pp := new(treeNode)
*pp = treeNode{3}
}

结构体中不需要有构造函数,就是指针类型也可以直接用『点』调用,使用 new 函数也可以创建一个空结构体返回地址(区别 make 是创建空间),对结构体指针可以直接使用点调用,其实是个语法糖,会自动转换为 (*var).xxx

命名类型(type)可以让我们方便的给一个特殊类型一个名字,因为 struct 的声明非常长,一般使用 type 来取一个别名。

我们可以通过工厂函数来创建一个结构体,并且可以将地址返回给调用方用,至于对象是在堆还是栈分配,这个不需要太过在意,由编译器决定,当你返回一个地址,那么会被认为需要给其他地方用,就会在堆分配,接受 GC 的管理,反之可能就直接在栈分配。
需要注意的是,结构体中的方法是定义中外面的,与普通函数相比,多了一个接受者参数,放在最前面;本质上与普通函数并没有什么区别;也正是这样,在修改的函数中,因为 Go 只有值传递(变量拷贝),所以需要定义参数为指针,其他的该怎么用就怎么用。
如果结构体比较大,为了避免复制,应该考虑优先使用指针接收;结构体通常是使用指针处理,所以定义的时候一般会直接定义成地址,如代码的最后;结构体内的定义如果都是可以比较的,那么结构体本身也是可以比较的。

定义结构体时,是按行来定义内部类型;有特殊情况,就是可以省去名字,只写类型,这样就是匿名成员,匿名成员的特点就是可以直接点出其内部的元素(或者是叶子属性)而不需要给出完整路径,有点继承的感觉,也算是一个语法糖;
因为匿名成员也会有一个隐式的名字,所以匿名成员类型只允许有一个;同时因为没有名字,权限也就根据子类型的定义来了。
在初始化的时候,匿名成员就有问题了,不能直接通过名字或者字面值直接赋值,只能通过完整的类型名来赋值,代码参考 Github,当不同的匿名成员有相同的字段时,就需要使用完整的路径来指定。
结构体的内存布局是连续的,空的结构体是不占空间的。

封装与扩展

对于封装的一些规约,首字母大写表示是 Public 的,首字母小写表示 Private,命名都是采用驼峰。
每一个目录只有一个包(package)其中 main 包比较特殊,它是程序的执行入口;
为结构体定义的方法必须放在同一个包内,可以是不同的文件;跨包引用的时候用 包名.结构体 这样来调用,当然需要使用 import 关键字来进行导入。
不过既然限制结构体方法定义必须同一个包,那么扩展别人的库就有点难受来,因为 Go 没有继承,解决方案是使用别名或者使用组合的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main(){
// 调用扩展的结构体
extRoot := myTreeNode{&root}
extRoot.echo()
}

// 组合的方式进行扩展 treeNode
type myTreeNode struct {
node *treeNode
}

func (myNode *myTreeNode) echo() {
if myNode == nil {
return
}
left := myTreeNode{myNode.node.left}
right := myTreeNode{myNode.node.right}

fmt.Println("扩展...")
left.node.print()
myNode.node.print()
right.node.print()
}

组合就是设计模式中的组合方式,以上代码要对比结构体中的 treeNode 定义来看;
而另一种别名就是使用 type 关键字 + 函数指针的方式来通过改变原始对象达到目的,省略相关代码。
封装后也可以像 Java 一样提供 getter 和 setter,Go 中的习惯是 getter 方法会直接省略前面的 get;Go 的编程风格不禁止直接导出字段的行为,但是这样意味着你之后不能随意的删除,为了兼容。

import 关键字默认会从标准库(GOROOT)和 GOPATH 下面寻找依赖,所以导入之前要确认是否存在;定义包名要尽量短小,因为给别人用的时候需要使用包名来确定范围,参考 fmt。
导入的包就必须要使用,如果不用编译会失败,这也体现出为了性能,真的是任性。

因为一个 package 可以有多个文件,默认按照文件名排序后依次初始化,因为函数外不允许进行逻辑计算,还可以在其中定义一个或多个 init 函数用来做逻辑初始化(类似其他语言的构造),它与普通的函数没有多大区别,仅仅是会在加载时自动调用,多个的话就按照定义的顺序来,并且 init 函数无法被手动调用和引用。
还有就是 main 包总是在最后被加载。

接口

Go 中既想要 python、c++ 中的灵活性(只需要有这个方法,就能调用),又想要 java 中的类型检查(不需要等到运行或者编译时才能确定是否具有相应的方法);
传统语言中的具体逻辑是由实现者来定义的,Go 中也反过来来,接口由使用者来定义,并且实现是隐式的,你只要有相应的方法就可以;同时,Go 是面向接口编程,Go 中的接口是一种类型,一种抽象的类型。

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
// 使用者
type Retriever interface {
// 接口中默认就是 func 声明
Get(url string) string
}

func down(r Retriever) string {
return r.Get("https://bfchengnuo.com")
}

func main() {
fake := mock.Retriever{"Mps....."}

fmt.Println(down(fake))
}


// 实现者
package mock

type Retriever struct {
Content string
}

func (r Retriever) Get(url string) string {
return r.Content
}

接口的定义是跟使用者一起的,而实现看起来跟一般的结构体并没有什么不同,所以说是隐式的;
接口可以简单看作是一组方法的集合,是duck-type programming的一种体现,接口本来就是用来抽象的,看到一个接口,我们并不知道它具体是啥,但是知道它能干啥,这跟 Java 中的概念差不多;
按照规范,接口的名称一般会以 er 结尾,参数名、返回值名可以省略,只写类型。
值接收者实现的接口,具体的结构体可以是值类型也可以是指针,Go 会自动处理;而如果是指针接收者的实现,那么只能传入指针类型;不管选择那一种,都是实现者来决定的。
同样,一个类型可以实现多个接口,接口也可以进行嵌套。同时还存在空接口的类型,使用空接口可以实现接收任意类型的函数参数;空接口作为 map 的值就可以实现保存任意值的字典。

如果使用接口参数,尤其是空接口,我们根本不知道具体是什么类型,类型断言就是解决这个问题的,类型断言用法:x.(T),空接口使用的非常广泛;

1
2
3
4
5
6
7
8
9
10
func main() {
var x interface{}
x = "Hello 沙河"
v, ok := x.(string)
if ok {
fmt.Println(v)
} else {
fmt.Println("类型断言失败")
}
}

如果需要多次断言,可以使用 switch 写法会舒服一点。
只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要定义接口。不要为了接口而写接口,那样只会增加不必要的抽象,导致不必要的运行时损耗。

类型-type

在任何语言中,都会有这样的情况:一些变量有着相同的内部结构,但是表示的却是完全不同的概念,例如 int 可以表示循环的控制变量,也可以表示时间戳;而 type 就是用来分割不同的概念类型;
使用方法为:type 类型名字 底层类型
声明语句一般出现在包一级,如果使用大写字母开头,表示包外也可以访问。
这样声明后,虽然两个类型可能有相同的底层数据结构,但是它们却是完全不同的数据类型,多用在结构体中;底层类型想要转换为包装的别名必须通过显式类型转换,类型转换可以使用 name(x) 的通用方式进行。
经过类型定义后,你可以给这个类型添加方法,也可以使用它原始类型的特性,例如如果底层类型是 int,你就可以直接进行基本的逻辑运算。

另外,可以定义函数类型,只要符合签名就可以被赋值:

1
2
3
4
5
6
7
8
type calculation func(int, int) int

func add(x, y int) int {
return x + y
}

var c calculation
c = add

以上也体现了一定的动态性,可以对比接口来看。


在 1.9 版本出现了另一种用法,就是别名,区别于类型定义,别名只是换了个名称,本质还是原来的类型;
定义:type TypeAlias = Type
使用上与原始类型 Type 一样,仅仅是名字不同。

依赖管理

最早的时候,Go 语言所依赖的所有的第三方库都放在 GOPATH 这个目录下面,这就导致了同一个库只能保存一个版本的代码。如果不同的项目依赖同一个第三方的库的不同版本,应该怎么解决?
Go 语言从 v1.5 开始开始引入 vendor 模式,如果项目目录下有 vendor 目录,那么 go 工具链会优先使用 vendor 内的包进行编译、测试等。
godep 是一个通过 vender 模式实现的 Go 语言的第三方依赖管理工具,类似的还有由社区维护准官方包管理工具 dep

go module 是 Go1.11 版本之后官方推出的版本管理工具,并且从 Go1.13 版本开始,go module 将是 Go 语言默认的依赖管理工具。

所以接下来只说 go module,要启用 go module 支持首先要设置环境变量 GO111MODULE ,通过它可以开启或关闭模块支持,它有三个可选值:offonauto,默认值是auto

  1. GO111MODULE=off禁用模块支持,编译时会从GOPATHvendor文件夹中查找包。
  2. GO111MODULE=on启用模块支持,编译时会忽略GOPATHvendor文件夹,只根据 go.mod下载依赖。
  3. GO111MODULE=auto,当项目在$GOPATH/src外且项目根目录有go.mod文件时,开启模块支持。

简单来说,设置GO111MODULE=on之后就可以使用go module了,以后就没有必要在 GOPATH 中创建项目了,并且还能够很好的管理项目依赖的第三方包信息。
使用 go module 管理依赖后会在项目根目录下生成两个文件go.mod(记录了项目所有的依赖信息)和go.sum(记录每个依赖库的版本和哈希值)。
常用命令:

命令作用
go mod download下载依赖包到本地(默认为 GOPATH/pkg/mod 目录)
go mod edit编辑 go.mod 文件
go mod graph打印模块依赖图
go mod init初始化当前文件夹,创建 go.mod 文件
go mod tidy增加缺少的包,删除无用的包
go mod vendor将依赖复制到 vendor 目录下
go mod verify校验依赖
go mod why解释为什么需要依赖

因为 Go 是谷歌的,包管理也肯定在谷歌的服务器,所以对大陆肯定不友好,所以建议设置代理:目前公开的代理服务器的地址有:

  • goproxy.io 开源,为 Go 模块而生
  • goproxy.cn 由国内的七牛云提供。

Windows 下设置 GOPROXY 的命令为:go env -w GOPROXY=https://goproxy.cn,direct
MacOS 或 Linux 下设置 GOPROXY 的命令为:export GOPROXY=https://goproxy.cn
更改完毕可以使用 go env 查看,使用 go module 非常简单,只需要两步命令:

1
2
3
4
# 生成 go.mod 文件
go mod init
# 自动发现依赖
go get

在项目中执行go get命令可以下载依赖包,并且还可以指定下载的版本([email protected]),如果下载所有依赖可以使用 go mod download 命令。

包使用

包的声明一般写当前文件夹名,一个文件夹下不允许有不同的 package 定义,同一个 package 的文件也不允许出现在多个文件夹下,导包的时候要写全路径(对于 GOPATH);
给包设置别名:import 别名 "包的路径"
匿名导包(不使用,但是会被编译到可执行文件中):import _ "包的路径"

更多内容请到 Github 仓库查看。

拓展

Go语言学习之路
Golang-100-Days(文档,未看)
新版本Golang的包管理入门教程

喜欢就请我吃包辣条吧!

评论框加载失败,无法访问 Disqus

你可能需要魔法上网~~