go 语言学习笔记 1——Hello, World 和基本集合类型

不会有 2 了,没有学 go 的动机了。

—— 2021.12.27


师爷,屁股在树上呢!

定下决心——我当前有三门语言需要学习:GO,Haskell,英语(嘿嘿)。Haskell 是为了学懂弄通函数式编程,英语是为了能看懂英文文献以及其他,而 GO…见下。


来学点 go。最初看的书籍是《GO 语言圣经》,实在难以看下去——行文和我期待的差太远,缺少和其他语言的比较之类的。现在试图看《GO 语言实战》,当前感觉还好。

为什么是 GO?

为什么是 GO 语言?首要目的当然是增强自身的……竞争力了。当今互联网方向,除了 Java,也就只有 GO 语言有市场了。而 GO 语言方面的竞争强度……当然远远比 Java 小了。这是最大的目的。

再从 GO 语言本身来看,它的并发支持,基于组合的更优雅,更具有复用性的面向对象,简洁优雅的语法(老实说,我讨厌它)使对它的学习有一定意义——了解一种新的并发模式;了解一种新的形式的面向对象范式,或者说新的代码组织,代码复用的方式。

这些是 GO 所值得学习的地方。而这语言也有我不喜欢的地方——对函数式编程支持很差。函数是一等公民?确实,但仅此而已。没有 map,filter,reduce,没有泛型(至少目前没有。没有泛型也意味着前面说的函数都无法轻易被用户实现),没有方便的 lambda 表达式(但是有函数类型这一点已经走在 Java 前面)。这让它的吸引力实在大打折扣。但是出于上面的原因,它仍旧值得学习。

当然,像并发或是面向对象之类的东西,至少得在熟悉 GO 语言的基础的前提下再进行。变量,函数啥的概念我们早就驾轻就熟,我们直接从更实践的地方——GO 的集合类型以及控制流开始。而在此之前,这里先来一个 Hello World 以及一个 fib 的实现,以一窥那些最基本的东西。

Hello, World. It’s a neo-future or no future?

下面是一个典型的 go 的 hello world 实现。main 包下的 main 函数是程序的真正入口。而 init 函数在包初始化的时候执行。所有依赖包的 init 函数将先执行,然后才是 main。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hello world!
package main

import ( // 导入依赖包
"fmt" // i.e. format
)

func init() {
fmt.Println("program initializing...") // 依赖包中的函数
}

func main() {
fmt.Println("Hello, World. It's a neo-future or no future?") // world 并不 happy,所以这里也不 happy
}

下面是一个 fib 函数的实现。需要注意的是,go 语言没有尾递归优化,所以定义尾递归形式的 fib 是不经济的。且函数变量无法递归调用,令人感叹。

1
2
3
4
5
6
7
8
9
// 函数大写意味着其将被包导出,就像 public
func Fib(n int) int {
a := 0
b := 1
for i := 0; i < n; i++ {
a, b = b, a + b // 右边的表达式先求值,之后再按顺序赋给左边的变量
}
return a
}

下面的 Counter 例子演示了 go 的匿名函数和闭包。函数定义的语法为func 函数名(参数列表) 返回值 {… 在这里,返回值是另一个函数,这个函数的类型是func() int,意味着它接受 0 个参数,返回一个 int 值。用户也可以定义变量为这种匿名函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Counter() func() int {
var count int

return func() int {
count++
return count
}

// 等价于——
fn := func() int {
count++
return count
}
return fn
}

这里同时也展示了另一种定义变量的方式——前面使用:=定义变量同时赋值。这时变量的类型将自动判断。这里显式地指定了变量 count 的类型 int,其将默认被赋予该变量的零值(zero value)——对数值,是0,对字符串,是"",对布尔是false,其他类型(一般来说是引用类型)则为nil,即其他语言的 null。也有var count = 0这种形式。或许写 JS 的人喜欢这样。

nil is a predeclared identifier representing the zero value for a pointer, channel, func, interface, map, or slice type.

go 的函数可以返回多个值。这时候必须使用对应数量的变量来接受函数的返回值。一种常见设计是把结果值和可能的错误返回。当错误值不为 nil 时说明发生错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func someOpt(n int) (int, error) {
if n > 100 {
return -1, fmt.Errorf("值 n 不能大于 100!当前值:%d", n) // Errorf 函数通过格式化的字符串构造一个 error
}
return n + 10, nil
}

func fn() {
res, err := someOpt(10)
if err != nil {
panic(err)
}
fmt.Println(res)

}

我在网络上看到对该种形式的错误处理的批判——其认为不应该使用积类型来表示结果和可能的错误(即将结果和错误包裹在同一个对象中来返回,积类型指将两个类型(的值的集合)做笛卡尔积所得到的新的类型),而是使用和类型,即类似 Haskell 中的 Either 类型来进行处理。毕竟,结果和错误其中必有一个值为空或无意义。

集合类型

go 语言提供了如下集合类型——数组,slice,map,其中,数组设计和其他语言的不同,需要特别注意;slice 代表可变长数组;map 即哈希表。这些基本集合类型虽不算多,但已能够满足所有需求。

数组

在 go 的世界里,数组代表着定长数组,且其长度必须是编译期可知的,就像 C 语言所做的那样,但是它比 C 语言更严格——定长数组的长度是包含在类型签名中的,它无法通过类型转换改变长度,无法转换为不定长数组。这意味着它的表现类似于(数学上的而非 python 上的)元组,但是其只能有同样的类型。数组的类型为[length]type,比如[3]int,意为长度为 3 的数组。

构造数组则采用[length]type{elems}的语法,如果元素的数量少于长度,剩余的以 0 值补全,比如[3]int{1,2}将得到{1,2,0}。同时可以使用...来替代长度,使长度和元素数量一致,如[...]int{1,2,3,4}得到长度为 4 的数组。

上面说到数组的行为类似于元组,这也表现为其如果作为函数参数,则函数参数和值必须为同样长度,同样类型的数组。如下面的代码是不合法的——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 试图接受一个长度为 2 的定长数组
func algo0(arr [2]int) {
// ...
}

// 试图接受一个长度不定的数组
func algo1(arr []int) {
// ...
}

func main() {
arr := [...]int{1,2,3}
algo0(arr) //不合法!错误信息是:Cannot use 'arr' (type [3]int) as the type [2]int
algo1(arr) // 仍然不合法,即使这看上去很合理。错误信息是:Cannot use 'arr' (type [3]int) as the type []int
}

上面的 algo1 调用居然不合法,这显然是有些违背直觉的。这是因为,不定长数组的类型是 slice 而非数组。可见,将定长数组当作同类型的元组看待是合情合理的。

slice

上面的例子证明定长数组的使用范围是比较窄的。看来更多的我们得使用不定长数组。而不定长数组在 go 里被称为切片(slice)。很有趣的名字,但从行为上来说这个名字很合适——每个切片代表内存(一个巨大数组)的一部分,每个切片可以对边界进行扩充,多个切片可以重叠……

切片的类型签名同数组基本相同,唯一不同在于其长度处为空,比如[]int代表保存 int 值的切片。[]int{1,2,3}为一个包含三个值的切片。

切片也可以通过类似 python 中的[start:end]语法构建(python 的该语法中 start 和 end 必须为常量,go 语言中的可以为变量),其前闭后开以简化使用。下面的代码实现了 tail 函数——获取数组除头部外的其他元素。

1
2
3
4
5
6
func tail(arr []int) []int {
if len(arr) == 0 {
panic(fmt.Errorf("you bad bad"))
}
return arr[1:] // 等价于 arr[1:len(arr)]
}

但这种语法是需要极为注意的——该操作并非是另开辟一个内存空间并将结果切片的值复制到该内存空间中,而是直接重用原切片的内存位置!这虽然极大地提高性能,但是也意味着对新切片的操作会改变原切片!下面的代码将证明这一点——

1
2
3
4
arr := []int{1,2,3} // 切片和数组均可
tailArr := tail(arr)
tailArr[0] = 1000
fmt.Println(arr) // [1 1000 3]

可见,对 tailArr 进行操作改变了 arr 的值。这种自由度是有些恐怖的,一定需要某种最佳实践来约束对[:]的使用。

slice 的可变性体现在哪里?当然是对它长度的变化了!go 语言并未提供像 Java 那样的 add,remove 等方法(它也做不到),而是提供了 append 和[:],两者都并非是对一个 slice 的值进行改变(即基于副作用),而是返回一个新的 slice。

比如当我们需要向数组尾部插入元素,就使用形如arr = append(arr, 100)的方式,当我们需要从头部删除元素,就使用arr = arr[1:]。使用这种形式的好处在于,其能够改变 arr 指向的内存地址(这是基于副作用的方法办不到的),从而提供更加丰富的功能。

append 操作也是需要注意的——如果这个切片为某个切片/数组的子切片的话,append 行为也可能会导致原切片/数组被更改!考虑下面的代码——

1
2
3
4
5
arr := []int{1,2,3}
subArr := arr[0:1]
arrr := append(subArr, 10)
fmt.Println(subArr) // [1 10]
fmt.Println(arr) // [1 10 3]

可见,append 操作把原切片的值覆盖掉了。老实说这行为太 evil 了。

再考虑下面的代码,猜猜输出结果是什么?

1
2
3
4
5
arr := []int{1,2,3}
subArr := arr[1:]
subArr = append(subArr, 1000)
subArr[0] = 100
fmt.Println(arr)

结果有点出人意料,但又是合情合理的,不是吗?当 subArr 的尾部已到达 arr 的尾部,append 操作就无法再试图在 arr 内部找到生存空间了。它必须另寻他路,也就是再另外创建一个更大的数组,把原 arr 的所有值都 copy 进去并在尾部添上新值。

这就引入了切片的 cap 属性——切片的最大“容积”,当切片的长度(即 len 函数所获得的那个玩意)和 cap 相等的时候,再想要 append 就必须要找新的市场了(市场,哈哈哈),即开辟一片更大的新的内存空间将所有值都复制一份过去。显然,从一个数组/切片中切出来的切片,其 cap 小于等于其原数组的 cap。

我真的十分好奇 slice 这种构造该如何正确避免内存泄漏,如何正确使用,它看上去和指针一样危险。

map

go 语言只提供了 Map,没有提供 Set,但是 Set 借助 Map 和 go 的零值初始化机制是容易实现的(事实上 Java 中的 HashSet 也是通过 HashMap 实现的)。

map 的类型签名形如map[key 类型]value 类型,如map[int]string为键为 int 型,值为 string 型的 map。

map 的初始化有两种方式——第一种是使用内置的 make 函数,第二种是使用字面量进行初始化。

1
2
3
4
5
6
aMap := make(map[int]string) // 严重抗议——凭什么标准库有范型,默认却不提供范型?

anotherMap := map[int]string{
13 : "devil", // 通过字面量初始化
42 : "answer",
}

对于 get,put 方法,go 语言完全使用[]操作符来进行。作为左值时即为 put,作为右值时即为 get。如果试图 get 一个不存在的 key,则其将返回 value 类型的零值。同时也可以给定第二个返回值来知道是否取到值。

1
2
3
4
5
6
7
8
aMap := make(map[int]string)
aMap[13] = "devil" // put
someStr := aMap[13] // get
aMap[42] == "" // true
_, ok := aMap[42]
if !ok {
fmt.Println("not found")
}

至于 set 的实现,使用map[...]bool即可。当我们试图将元素加入 set 中,put 一个 true 值进去即可。而当我们试图检查元素是否在 set 中,直接使用 get 即可——如果不存在,根据零值初始化规则,其将返回 false;如果存在,则返回 true。

下一节我将学习 go 语言中的控制流和函数。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!