Go 语言入门(一)基础语法

Go 语言入门(一)基础语法

写在前面

在学习 Go 语言之前,我自己是有一定的 Java 和 C++ 基础的,这篇文章主要是基于A tour of Go编写的,主要是希望记录一下自己的学习历程,加深自己的理解

本地安装 Go 语言环境

关于如何安装 Go 语言的编程环境,我推荐大家看这篇文章

编写 Hello, World

学习语言时,首先编写一个「Hello, World」已经成了程序员的习惯,这里我们也编写一个,顺便测试一下 Go语言环境是否搭建成功了:

首先创建一个名为hello.go的文件,编写代码如下:

package main

import "fmt"

func main() {
    fmt.Printf("Hello, World\n")
}

接着我们在命令行中使用 go 工具运行它:

$ go run hello.go
Hello, World

如果像上面一样看到了「Hello, World」信息,则表示我们已经迈出学习 Go 语言的第一步了。

关于更多 Go 语言的编程方法以及相应命令,我们可以看这里,它为我们清楚地介绍了 Go 语言的环境变量等相关设置。

在都搭建完成之后,我们就可以进入 Go 语言的语法学习了。

包、变量、常量和函数

学习 Go 语言语法之前,我们要知道他是在「C语言」的基础之上发展的,所以他们之间很多语法都是相似的。

Go 的行尾不需要使用分号

在上面的「HelloWorld」程序中,我们可以看到 Go 并不需要在语句或者声明后面使用分号结尾,除非有多个语句或声明出现在同一行。

事实上,是 Go 将跟在特定符号后面的换行符转换成分号,因此在 Go 语言中在不同地方换行会对程序产生影响

例如,{符号必须和关键字func在同一行,不能单独成行;并且在「x+y」这个表达式中,换行符可以在操作符+后面,但不能在+前面。

Go 中使用开头字母的大小写表示是否能够在包外使用

不同于其他语言的修饰符,Go 根据变量、函数等开头字母是否大写判断能不能在包外进行访问,开头字母大写,则表示可以在包外进行访问;反之则不行。

声明包和导入包

每个 Go 语言都是由「包」构成的,所有的 Go 程序都是从main包开始运行的;同时,他也和 Java 中一样,需要一个main函数作为「程序入口」。

// 声明当前程序为 main 包
package main

我们使用import语句来导入我们需要的包,我们可以一次 import 导入一个包,也可以一次 import 同时导入多个包:

// 导入一个包
import "fmt"
import "math/rand"

// 同时导入多个包
import (
    "fmt"
    "math/rand"
)

需要注意的是,「包名」和「导入路径的最后一个元素」一定要是一致的。例如前文导入的import "math/rand",它的对应源码中,第一行的包声明应该是package rand

导出(getter)

在 Go 中,如果一个名字以大写字母开头,那么他就是已导出的(可以理解为 Java 中的 getter)。

例如下述代码中,我们导出「math」包中的Pi变量并打印:

package main

import (
    "fmt"
    "math"
)

func main() {
    fmt.Println(math.Pi)
}

从上面我们可以看出,虽然 Go 是发展自 C语言,但 Go 语言语句结尾不加分号

同时,Go 语言中的输出语句和「Java」中很类似:

Go 语言中导入了fmt包,引用它的Println函数,和 Java 中一样,这个输出语句会在输出结束之后自动换行,但 Go 中语句拼接是使用,而不是+,例如下面的语句:

const World = "世界"
fmt.Println("Hello", World)

同样的,我们还可以使用fmt.Print()fmt.Printf()来打印我们想要的信息,后两者使用方法都是和 Java 类似的。

自定义包

有时我们希望能够自定义一个函数包,来方便其他程序调用,,我们需要做两步:

  • 新建一个和包名同名的「文件夹」

  • 在文件夹中编写这个包的具体代码,当然,需要声明为这个包的包名:package 包名

例如,我们先在自己的 GOPATH 后创建一个$GOPATH/src/github.com/Bylight/tempconv目录,显然,这里包名应该是「tempconv」;然后我们可以在这个文件夹中编写需要的 go 文件,当然,文件第一行为package tempconv

上述步骤完成后,如果我们需要导入这个自定义的包,我们只需要import src/github.com/Bylight/tempconv即可。


Printf() 中的变量通配符

Go 中Printf()的通配符和其他类 C 语言大致相同,不过 Go 中有两个万能的通配符:

  • %v,value,可以打印内置格式的任何值

  • %T,type,打印变量的类型

更多的通配符,可以看这里

变量

在接触函数之前,我们需要先看看 Go 语言中变量的相关知识

Go 语言中对变量主要有四种声明方式:变量var、常量const、类型type和函数func;或者说,我们也可以将其数据类型分为四种:基础类型、聚合类型、引用类型和接口类型。大致来说,它们是这样的:

  • 基础类型:数字、字符串和布尔。

  • 聚合类型:数组和结构体,也就是通过组合简单类型得到的复杂类型。

  • 引用类型:诸如指针、切片、映射、函数和通道,他们都是间接指向程序变量或状态。

  • 接口类型:也就是接口,会在之后进行详细介绍。

这一小节我们主要介绍「基础类型」的变量。

声明变量及其初始化

下面我们声明三个全局bool类型变量,再声明局部int类型变量,并对部分变量进行赋值,并打印出它们:

package main

import "fmt"

var c, python, java bool

func main() {
    var i int
	var num int = 50
	// 多重赋值
    var num1, num2 int = 51, 52
    fmt.Println(i, c, python, java)
    fmt.Println(num, num1, num2)
}

运行后,结果如下:

0 false false false
50 51 52

从上面示例代码中,我们可以知道如下情况:

  • go 语言中声明变量时,以var 变量名 变量类型的形式进行,我们可以在一个语句中同时声明多个变量

  • go 语言中变量的声明包含默认的初始值,我们也可以在声明的同时为他赋予初始值:var num int = 50

上面代码中我们可以看到用到了「多重赋值」,这是 Go 中的一个语法糖,在实际对左侧变量进行更新之前,右侧的所有表达式会自左向右完成计算。这是我们可以简洁地完成很多操作。例如交换两个变量的值:

x, y = y, x
a[i], a[j] = a[j], a[i]

又或者计算两个数的最大公约数:

func gcd(x, y int) int {
	for y != 0 {
		x, y = y, x%y
	}
	return x
}

我们后续还会在一些地方用到多重赋值来指示一些错误情况,这里先做归纳,之后会仔细进行学习:

v, ok = m[key]	// map 查询并判断是否存在键值对
v, ok = x.(T)	// 类型断言
v, ok = <-ch	// 检测信道是否关闭
_, err = io.Copy(dst, src) // 检测恐慌

至于为什么使用var num int而不是int num,是因为语言发明者认为这样可以让 Go 的语句不容易出现歧义,增加程序的可读性。有兴趣的可以查看这篇关于 Go 语法声明的文章进行详细的了解。

短变量声明和类型推导

函数中,我们可以使用简洁赋值语句:=在类型明确的地方代替var声明,Go 能够自动确定合适的类型并帮我们完成变量的声明,我们将这种行为称为「类型推导」:

func main() {
    // 只能用于函数中,也就是不能用于声明全局变量
    k := 3
}

我们需要记住,:=表示声明,而=表示赋值,这两者是有所不同的。

而很容易忽略的一个地方是:「短变量声明」不需要声明所有在左边的变量:如果部分变量在同一个词法块中已经被声明了,那短声明的行为对它来说等同于赋值。

因为要求是「同一个词法块」,所以下面的程序是错误的:

var cwd string

func init() {
	cwd, err := os.Getwd()	// 错误:全局变量 cwd 并未初始化
	if err != nil {
		log.Fatalf("os.Getwd failed: %v", err)
	}
	log.Printf("Working directory = %s", cwd)
}

上面是一个很不容易被发现的 bug,最直接的解决方法就是避免使用短变量声明:

var cwd string

func init() {
	var err error
	cwd, err = os.Getwd()
	if err != nil {
		log.Fatalf("os.Getwd failed: %v", err)
	}
}

注意,「短变量声明」最少声明一个新变量

因为存在「类型推导」,所以在有初始值的情况下,我们可以省略变量的类型:

package main

import "fmt"

var v = 1

func main() {
	fmt.Printf("v=%v is of type %T\n", v, v)
}

它的输出结果如下:

v=1 is of type int

通常来说,局部变量的声明和初始化主要使用「短变量声明」。「var声明」通过用于与初始化表达式类型不一致的局部变量保留的,如var num float64 = 100;或是用于后面才对变量赋值以及变量初始值不重要的情况。

变量的基本类型

Go 之中的变量基本类型如下:

bool

string

int  int8  int16  int32  int64          // 带数字的为指定位宽的 int
uint uint8 uint16 uint32 uint64 uintptr // 上面 int 的无符号形式

byte // uint8 的别名

rune // int32 的别名,表示 Unicode 的一个码点

float32 float64 // 指定位宽的浮点数

complex64 complex128 // 指定位宽的复数

上面便是 Go 中的基本类型,对于int``uintuinptr,它们在 32 位系统上位 32 位宽,64 位系统上为 64 位宽。没有特定要求的情况下,如果我们需要声明一个整形变量,只需要直接使用int就好了。

对于基本变量类型,它们默认值的情况如下:

  • 数值类型,默认为0,包括intfloat

  • 布尔类型,默认为false

  • 字符串类型,默认为""(空字符串)

同时,我们还要注意,Go 中没有char类型的变量,所以类似v := 'a'这样的语句,v 的类型将会是int32

示例代码:

package main

import "fmt"

func main() {
    // 类型初始值
	var i int
	var f float64
	var b bool
	var s string
    fmt.Printf("%v %v %v %q\n", i, f, b, s)
    // 注意 Go 中不存在 char 类型
	v := 'a'
	fmt.Printf("v=%v is of type %T\n", v, v)
	// 不过我们可以使用谓词 %c 来打印符号,使用 %q 打印带单引号的符号
	// %后面的 [1] 表示 Printf 重复使用第一个参数
	fmt.Printf("%c %[1]q", v)
}

运行结果如下:

0 0 false ""
v=97 is of type int32
a 'a'

变量递增和递减

Go 中也存在自增i++和自减i--,但不同于其他的 C 族语言,这些是「语句」而不是「表达式」,因此j = i++是不合法的;并且只支持后缀,因此++i也是不合法的。

强制类型转换

Go 语言中,我们使用表达式T(v)将值v转换为类型T

var i int = 42
var f float64 = float64(i)
var u uint = uint(f)

如果是在函数中,我们也可以使用短变量声明的形式:

i := 42
f := float64(i)
u := uint(f)

但需要注意的是,和其他语言不同,Go 在不同类型的项之间赋值时,必须显式转换,它并不会对变量进行自动转型。

常量

常量可以在编译时完成计算,这样能够减免运行时的工作量并让其他编译器的优化得以实现。

常量的声明和变量类似,不过我们使用const关键字来代替var,常量可以为全局或者变量,但是他不能使用:=语法声明。

// 这里类型推导能够为 Pi 自动选择 folat64 的类型
const Pi = 3.14

如果同时声明一组常量,除了第一项之外,其他项在等号右侧的表达式都可以省略,这意味着复用前一项的表达式及其类型

const (
	a = 1
	b
	c = 2
	d
)

fmt.Println(a, b, c, d) // 输出:1 1 2 2

常量生成器 iota

我们可以使用常量生成器 iota 来对常量进行声明,它创建一系列相关值,而不是逐个值显式写出。常量声明中,iota 从 0 开始取值,逐项加 1。

下例是time包中的源码,它定义了 Weekday 的具名类型,并声明每周的 7 天为该类型的常量,从 Sunday 开始,其值为 0。这种类型通常被称为枚举类型。

type Weekday int

const (
	Sunday Weekday = iota
	Monday
	Tuesday
	Wednesday
	Thursday
	Friday
	Saturday
)

在上面的声明中,Sunday 的值为 0,Monday 的值为 1,以此类推。

我们也可以在表达式中使用 iota。下面是net包的源码,无符号整数最低五位数中的每一个都逐一命名,并解释为布尔值。

type Flags uint

const (
	FlagUp Flags = 1 << iota // 向上
	FlagBroadcast
	FlagLoopback
	FlagPointToPoint
	FlagMulticast
)

随着 iota 递增,每个常量都会按1<<iota递增,等价于 2 的连续次幂。

然而,iota 机制存在它的局限,例如,因为不存在指数运算符,所以不能生成更为人熟知的 1000 的幂(KB、MB 等)。

无类型常量

与其他语言不同,Go 中常量可以不从属某一具体类型(也就是让编译器自己进行类型推导的常量)。编译器将这些从属类型特定的常量表示成某些值,这些值的数字精度比基本类型的数字精度更高,且算数精度高于原生的机械精度,可以认为其精度至少达到 256 位。

这种从属类型待定的常量无类型常量共 6 种:无类型布尔、无类型整数、无类型文字符号、无类型浮点数、无类型复数、无类型字符串。

下面的就是无类型整数:

const (
	_ = 1 << (10 * iota)
	KiB
	MiB
	GiB
	TiB // 超过 1 << 32
	PiB
	EiB
	ZiB // 超过 1 << 64
	YiB
)

借助推迟确定从属类型,无类型常量不仅可以暂时维持更高的精度,与类型以确定的常量相比,它们还可以写进更多表达式而无需转换类型。例如,上例中 ZiB 和 YiB 的值过大,无论用哪种整形都无法存储,但它们都是合法常量且可以用于下面的表达式:

fmt.Println(YiB/ZiB) // 输出: 1024

再例如,浮点型常量math.Pi可以用于任何需要浮点值或复数的地方:

var x float32 = math.Pi
var y float64 = math.Pi
var z complex128 = math.Pi

如果一开始math.Pi就确定了从属类型,如 float64,就会导致结果的精度下降,且如果需要 float32 或 complex128 类型,则需要转换类型:

const Pi64 float64 = math.Pi

var x float32 = float64(Pi64)
var y float64 = Pi64
var z complex128 = complex128(Pi64)

对于无类型常量,我们在用它声明变量时,如果没有显式指定类型,它会隐式转换称该变量的默认类型(如短变量声明):

i := 0 // 无类型整数;隐式 int(0)
r := '\000' // 无类型文字字符;隐式 rune('\000)
f := 0.0 // 无类型浮点数;隐式 float64(0.0)
c := 0i // 无类型复数;隐式 complex128(0i)

这里我们要需要注意各类型的不对称性:无类型整数可以转换成 int,其大小不确定;但无类型浮点数和无类型复数都会被转换成大小明确的 float64 和 complex128。因为如果浮点类型的数据大小不明,就很难写出正确的数值算法。

如果我们需要常量将其转换成明确的类型,那么我们就应该使用显式转换,指明想要的类型:

i := int8(0)

在之后我们讨论到将无符号常量转换为「接口值」的时候,这些默认类型就十分重要了,因为它们决定了接口值的动态类型。

函数

对于 Go 中的函数(以及之后要学习的「方法」),如果名称以「大写字母」开头,则意味这它是导出的,对包外是可见和可访问的,可以被自己包以外的程序所引用,例如fmt包中的Printf

Go 中的函数需要用func显式声明,其中的行参和之前的变量声明一样,类型是在参数名之后的,这也意味着我们可以从变量的角度来看待和使用函数;类似的,函数的返回值也在整个函数的声明之后,我们用下面的例子可以直观地理解:

package main

import "fmt"

func main() {
    fmt.Println(add(42, 13))
}

func add(a int, b int) int {
    return a + b
}

同时,从上面我们还可以看出,不同于 C 语言需要在使用函数之前显式声明或者定义函数,Go 可以在任意地方(可以在使用之前,也可以在使用之后)定义函数。

在 Go 之中,如果当连续两个或多个函数的已命名形参类型相同时,除最后一个类型以外,其它都可以省略。

例如上面的add()函数:

func add(a, b int) int {
    return a + b
}

多值返回

Go 语言中,函数可以返回任意数量的返回值,例如下面的swap()函数返回两个字符串:

func swap(a, b string) (string, string) {
    return b, a
}

func main() {
    a, b := swap("hello", "world")
    fmt.Println(a, b)
}

上面的程序运行后应该是输出word hello

为函数的返回值命名

Go 之中函数的返回值可被命名,它们会被视作定义在函数顶部的变量。

下面的例子中,我们为函数的返回值命名为xy

func split(sum int) (x, y int) {
    x = sum * 4
    y = sum - 1
    // 这里会返回 x 和 y
    return
}

上面的函数中,我们在函数顶部定义了int类型的变量xy,并在函数体中对他们进行赋值,最后的return语句则将xy的值直接返回。

在实际使用这种命名的返回值时,应要遵循下面两条约定:

  • 返回值的名称应当具有一定的意义,它可以作为文档使用;

  • 「直接返回语句」应当仅用在前面这样的短函数中,在长的函数中它们会影响代码的可读性。

函数,一种特殊的变量

前面提到,我们可以从变量的角度来看待和使用函数,那么,作为一种特殊的变量,函数自身也可以作为「参数」或者「返回值」在函数中出现:

用例子可以很好地说明:

// 传入了一个「返回值为 float64 的函数」作为参数
func compute(fn func(float64, float64) float64) float64 {
	return fn(3, 4)
}

func main() {
	hypot := func(x, y float64) float64 {
		return math.Sqrt(x*x + y*y)
	}
    fmt.Println(hypot(5, 12))

    // 将 hypot 函数作为参数传入
	fmt.Println(compute(hypot))
    // 将 Pow 函数作为参数传入
	fmt.Println(compute(math.Pow))
}

函数的闭包

「闭包」这个特性有一些抽象,我们用例子说明会比较好理解:

func adder() func(int) int {
	sum := 0
	return func(x int) int {
		sum += x
		return sum
	}
}

func main() {
    // 获取两个函数
	pos, neg := adder(), adder()
	for i := 0; i < 10; i++ {
		fmt.Println("i=", i)
		fmt.Println(
			"neg(-2*i)=", neg(-2*i),
			"pos(i)=", pos(i),
		)
	}
}

在上面的代码中,adder()函数的返回了一个「返回值为 int 的函数」,我们能够发现,函数func(int)会使用它函数体之外的参数sum,并对sum进行计算并修改,然后返回。那么,在 Go 语言中,每次调用adder()所返回的函数时,函数对 sum 的修改将会保存

我们先看看上面代码的执行结果:

i= 0
neg(-2*i)= 0 pos(i)= 0
i= 1
neg(-2*i)= -2 pos(i)= 1
i= 2
neg(-2*i)= -6 pos(i)= 3
i= 3
neg(-2*i)= -12 pos(i)= 6
i= 4
neg(-2*i)= -20 pos(i)= 10
i= 5
neg(-2*i)= -30 pos(i)= 15
i= 6
neg(-2*i)= -42 pos(i)= 21
i= 7
neg(-2*i)= -56 pos(i)= 28
i= 8
neg(-2*i)= -72 pos(i)= 36
i= 9
neg(-2*i)= -90 pos(i)= 45

可以看到,在i=2时,pos(2)的返回值为 3,因为在执行完pos(1)后,sum 的值已经变成 1 了,所以在pos(2)中,sum += 2实际上是sum = 1 + 2。同样地,在neg()中,它也有一个自己独立的「sum」。

也就是,在「pos」和「neg」函数被初始化的时候,「sum」就相当于成为了它们两个函数的静态变量一样,每次对它进行的赋值都会保存。像「pos」和「neg」这两个函数,它们就是adder()返回的两个闭包,它们各自都有一个绑定的变量「sum」。

变量「逃逸」

上面的闭包函数adder中,局部变量 sum 可以被多次访问而没有被回收,这里涉及到 Go 中变量的「生命周期」。

Go 之中局部变量有一个动态的生命周期:变量一直生存到它变得不可访问。对于局部变量,它可能被分配在「栈」上,有可能被分配在「堆」上,这都是由编译器来决定的,与是否用var或者new声明变量无关。

var global *int

func f() {
	x := 1
	global = &x
}

func g() {
	y := new(int)
	*y = 1
}

在这里,x尽管是一个「局部变量」,但一定使用分配,因为它在 f 函数返回后还可以从global变量访问,这种情况我们称 x 从 f 中逃逸;相反,当 g 函数返回时,变量*y就变得不可访问,可回收(因为 *y 没有从 g 中逃逸),因此 *y 将被编译器分配在上。

「逃逸」这一概念让 Go 程序员不需要额外费心来写正确的代码,但它能帮助我们对程序进行性能优化:因为每一次变量逃逸都需要一次额外的内存分配。

不过我们需要注意,作用域和生命周期是不同的。作用域是一个「编译时属性」,而生命周期是一个「运行时属性」,这也意味着,即使发生变量逃逸,我们也不能在其作用域外引用它,否则无法通过编译。

总的来说,Go 中自动的垃圾回收对写出正确的程序有巨大的帮助,但免不了考虑内存的负担。我们不必显式考虑如何分配和释放内存,但变量的生命周期是写出高效程序所必须了解的。例如,长生命周期的对象(例如全局变量)中保持短生命周期不必要的指针,会阻止垃圾回收器回收短生命周期中的对象空间。

初始化函数

这里我们需要了解一种特殊的函数「初始化函数」,我们需要先对包的初始化有所了解。

其实包的初始化,就是让包在被调用或者运行时,先进行一系列的初始化操作,一般情况下, 它会按照声明顺序对变量进行初始化;但如果变量之间存在相互依赖,那么编译器会按照依赖的顺序进行:

var a = b + c // 3. 最后,把 a 初始化为 3
var b = f() // 2. 通过调用 f 把 b 初始化为 2
var c = 1	// 1. 初始化为 1

func f() int { return c + 1 }

上面我们可以看到,我们可以进行简单的变量初始化,但有时,初始化需要我们不仅是简单地设置它的初始化值,这是,init函数就派上用场了。

文件中可以包含任意数量的如下函数:

func init() { /* .. */ }

这个init函数不能被调用和被引用,在程序启动时,init 函数会按照它们声明的顺序自动执行。

总结一下,在初始化时,go 工具能够保证整个初始化过程是自下而上的,依照依赖顺序进行,main 包最后初始化,这样一来,在 main 函数开始执行之前,所有的包一定已经初始化完毕了。

流程控制语句:for、if、else、switch 和 defer

for 语句

Go 之中只有一种循环结构:for 循环。

基本的「for循环」和 Java、C 等语言相似:它们都是由三部分组成,每个部分之中用分号;隔开:

  • 初始化语句:在第一次迭代前执行

  • 条件表达式:在每次迭代前求值

  • 后置语句:在每次迭代的结尾执行

但Go 的 for 语句后面的三个构成部分外没有小括号, 大括号{ }则是必须的。所以正确的 for循环 是下面这样的:

// 打印 0 ~ 9 这十个数字
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

在 Go 之中,我们可以将 for 的三个部分都省略,这样就变成了下面这个样子:

sum := 1
for ; ; {
    sum += sum
    if sum > 1000 {
        break
    }
}

不难发现,这其实就是一个while(true)循环,此时,我们可以去掉分号,也就是能够用for实现while语句;那么,上面的while(true)我们可以这样更加简洁地表示:

sum := 1
for {
    sum += sum
    if sum > 1000 {
        break
    }
}
  • for之后可以只保留「条件表达式」部分,也就是while的形式
// for 就是 go 语言中的 while 循环
sum := 1
for sum <= 1000 {
    sum += sum
}

if、else 语句

其实「if语句」在之前的 for 循环中我们已经使用过了。它和 for 的语法一样:

  • 表达式外无需小括号( )

  • 大括号{ }是必须的。

不过,Go 之中的 if 有一个特殊的语法:可以在条件表达式前执行一个简单的初始化语句。

这种情况下初始化的变量的作用域仅在if、else语句之中

func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
    } else {
		fmt.Printf("%g >= %g\n", v, lim)
	}
    // 这里就不能使用 v 了
    // return v 就会报错

	return lim
}

switch 语句

「switch 语句」在 C,Java 中均有出现,Go 中的 switch 和它们主要有以下两点不同:

  • Go 的每个case语句后已经隐式地加好了break语句,因此 Go 只会运行选定的 case

  • Go 中switchcase无需为常量,且取值不必为整数。

下面的示例程序可以很好地帮助我们理解这两条特性:

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("When's Saturday?")
    today := time.Now().Weekday()

	switch time.Saturday {
    // 这里每个 case 都是一个表达式,且值不是整数
	case today + 0:
        fmt.Println("Today.")
        // 在 case 后已经隐式的存在 break 了
	case today + 1:
		fmt.Println("Tomorrow.")
	case today + 2:
		fmt.Println("In two days.")
	default:
		fmt.Println("Too far away.")
	}
}

不带条件的 switch 语句

没有条件的 switch 同 switch true 一样。

这种形式能将一长串if-then-else写得更加清晰:

func main() {
	t := time.Now()
	switch {
	case t.Hour() < 12:
		fmt.Println("Good morning!")
	case t.Hour() < 17:
		fmt.Println("Good afternoon.")
	default:
		fmt.Println("Good evening.")
	}
}

使用 fallthrough 强制执行剩余 case

Go 里面 switch 默认相当于每个 case 最后带有 break ,匹配成功后不会自动向下执行其他 case,而是跳出整个 switch, 但是可以使用fallthrough强制执行后面的 case 代码。

integer := 6
switch integer {
case 4:
	fmt.Println("The integer was <= 4")
	fallthrough
case 5:
	fmt.Println("The integer was <= 5")
	fallthrough
case 6:
	fmt.Println("The integer was <= 6")
	fallthrough
case 7:
	fmt.Println("The integer was <= 7")
	fallthrough
case 8:
	fmt.Println("The integer was <= 8")
	fallthrough
default:
	fmt.Println("default case")
}

上面的程序将输出:

The integer was <= 6
The integer was <= 7
The integer was <= 8
default case

defer 语句

「defer 语句」能让标示的语句延迟执行:它会将函数推迟到外层函数返回之后执行。推迟调用的函数其参数会立即求值,但直到外层函数返回前该函数都不会被调用。

例如下面的示例程序:

func main() {
	defer fmt.Println("world")

	fmt.Println("hello")
}

运行后,我们得到的打印结果是这样的:

hello
world

也就是在main()函数执行完毕之后才打印了 world。

defer 栈

如果一个函数中有多个defer语句,那么它们会按照执行顺序被压入一个栈中,在外层函数返回之后,所有 defer 语句会按照后进先出的顺序被调用。

所以下面的程序应该是在 main() 函数返回后,从10到1顺序打印:

func main() {
	fmt.Println("counting")

    // defer 语句将依次入栈
	for i := 0; i < 10; i++ {
		defer fmt.Println(i)
	}

	fmt.Println("done")
}

我们可以从这篇博文中获取更多关于defer的信息。

指针、结构体、数组、切片和映射

在学习这一部分的语法之前,我们需要先了解 Go 语言之中的「指针」。

指针

和 C 类似的,Go 中也拥有指针,它保存了变量的内存地址,Go 之中将nil作为空指针

在一些语言(例如 C)中,指针是没有任何约束的;在其他语言中,指针被称为「引用」,只能用于传递。而 Go 中做了一个这种,指针显式可见。我们可以获取或者改变指针的值,但是指针不支持算术运算。

当我们需要声明指针时,*T即标示T类型的指针:

// 这里声明了一个 int 类型的指针
var p *int

类似于 C 的,取地址符&会生成一个指向该变量地址的指针:

i := 42
// 这样指针 p 就指向了 i
p = &i

如果我们需要获取指针指向变量的值,我们便需要使用*操作符来读取对应的变量值:

// 打印结果为 42
fmt.Println(*p)
// 等价于 i = 21
*p = 21
// 打印结果为 21
fmt.Println(i)

那么,我们总结一下,就能归纳出指针的用法了:

  • *T:声明特定类型T的指针,var p = *int

  • &var:获取指向变量var的指针,p = &i

  • *p:获取指针p所指向变量的值,*p = 21

可以看出,Go 的指针语法和 C 几乎一样,但有一点与 C 不同,Go 没有指针运算。

使用 new 函数创建变量

我们可以使用内置的「new函数」来创建变量。表达式new(T)创建一个未命名的 T 类型变量,初始化为 T 的零值,并返回其地址(即*T):

p := new(int)	// *int类型的 p,指向未命名的 int 变量
fmt.Println(*p) // 输出"0"
*p = 2
fmt.Println(*p) // 输出"2"

使用new创建的变量实际上就是一个匿名的局部变量,这只是一种语法上的便利,不是一个基础概念。所以,下面的两个newInt函数是等价的:

func newInt() *int {
	return new(int)
}

func newInt() *int {
	var dummy int
	return &dummy
}

同样的,因为使用new是创建一个新的变量,所以每次调用new返回的一般都是一个具有唯一地址的不同变量:

p := new(int)
q := new(int)
fmt.Println(p == q) // false

new是一个预声明的函数,不是关键字。因此它可以当作变量名使用,例如:

func delta(old, new int) int {
	return new - old
}

当然,在上面的delta中,我们就不能使用new函数了。

结构体

除了指针之外,接下去这一部分讨论的都是「复合数据类型」,它是由之前介绍的基础数据类型聚合而成。

其中,数组和结构体的长度都是固定的,而切片和映射都是动态数据结构。

定义结构体

结构体在 C 语言中也有相应的定义,实际上,一个结构体struct就是一组字段field。我们使用关键字type而不是var来定义它,结构体的类型是struct,因此,一个结构体的定义应该是这样的:

type Vertex struct {
    X int
    Y int
}

创建一个结构体

Go 语言中,我们可以通过以下方式来创建一个已经定义过的结构体:

  • 通过直接列出字段的值来新分配一个结构体

    例如v1 = Vertex{1, 2},它创建一个 Vertex 类型的结构体,结构体的 X=1、Y=2

  • 使用Name:语法可以仅列出部分字段,没有被列出的字段则被赋予默认值

    v2 = Vertex{X: 1},Y:0 被隐式地赋予

    v3 = Vertex{}, X:0 Y:0

指向结构体的指针

对于一个已创建的结构体,我们应该将其看作一个变量,所以我们可以使用取地址符&来获取指向它的指针:

p = &Vertex{}

访问结构体的字段

在上面的结构体「Vertex」中,我们声明了 X 和 Y 两个 int 类型的字段,和 C 中一样,我们可以通过点号.来访问结构体中的字段:

package main

import "fmt"

type Vertex struct {
	X int
	Y int
}

func main() {
	// 初始化一个 X = 1, Y = 2 的结构体
	// 这里赋值的顺序和结构体中变量顺序严格对应
    v := Vertex{1, 2}
    v.X = 4
    // 打印结构为 {4 2}
    fmt.Println(v)
    
    // 结构体指针
	p := &v
	p.X = 1e9
    // 打印结构为 {1000000000 2}
	fmt.Println(v)
}

上面的例子中,我们可以看到:对于指向结构体的指针,和 C 语言中相同,我们可以使用(*p).X来访问结构体中的 X 字段,而 Go 中为了简化代码,所以我们可以直接使用p.X来访问 X 字段。

不过我们有一个需要注意的地方:

func newv() *Vertex {
	return &Vertex{X: 1}
}

func main() {
	newv().Y = 2
}

上述代码运行是没有问题的,但如果我们将函数 newv 的返回值改为 Vertex 而不是它的指针,那么代码将无法通过编译,因为赋值表达式的左侧无法识别出一个变量(这个我也不是很理解什么意思)。

用结构体实现递归数据结构

一个结构体中,不能包含它自己作为变量,但它可以包含自己的指针类型。下面我们使用二叉树来实现插入排序:

type tree struct {
	value int
	left, right *tree
}

// 就地排序
func Sort(values []int) {
	// 利用二叉排序树进行排序
	var root *value
	for _, v := range values {
		root = add(root, v)
	}
	// 将二叉排序树转换为数组
	appendValues(valuse[:0], root)
}

// appendValues 将元素按照顺序追加到 valuse 中,然后返回结果的切片
func appendValues(values []int, t *tree) []int {
	if t != nil {
		values = appendValues(values, t.left)
		values = append(values, t.vale)
		values = appendValues(values, t.right)
	}
	return values
}

func add(t *tree, value int) *tree {
	if t == nil {
		// 等价于返回 &tree{value: value}
		t = new(tree)
		t.value = value
	}
	if value < t.value {
		t.left = add(t.left, value)
	} else {
		t.right = add(t.right, value)
	}
}

结构体的比较

如果结构体中的所有成员变量都是可比较的,那么这个结构体类型也是可比较的。我们可以使用==或者!=来进行比较,其中==按顺序比较结构体中的成员变量。

结构体嵌套和匿名成员

这里我们要学习 Go 中特有的结构体嵌套,它使得我们可以将一个命名结构体当作另一个结构体类型的「匿名成员」使用,并提供了一种方便的语法,使用简单的表达式就可以代表连续的成员(例如用x.f表示x.d.e.f

想象下面这种情况,我们定义「圆形」和「轮胎」两个结构体:

type Circle struct {
	X, Y, Radius int
}

type Wheel struct {
	X, Y, Radius, Spokes int
}

上例中,圆形和轮胎都具有圆心的坐标 X 和 Y 以及半径 Radius,而轮胎额外具有一个车轮的条幅数量这一属性,即 Spokes。如果我们需要创建一个 Wheel 类型对象:

var w Wheel
w.X = 8
w.Y = 8
w.Radius = 10
w.Spokes = 20

如果我们需要创建多个圆心为(8, 8)的车轮,我们很自然地意识到它们关于「圆心」的部分存在重复,而它们的「圆形」部分也可能会出现重复,因此我们将其重构:

type Point struct {
	X, Y int
}
type Circle struct {
	Center Point
	Radius int
}
type Wheel struct {
	Circle Circle
	Spokes int
}

这样整个程序变得清晰了很多,而且大大提高了代码的复用性,但是访问 Wheel 的成员变量就变得很麻烦:

var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
...

因此,Go 中允许我们定义不带名称的结构体成员,只需要指定类型即可,这种结构体成员就是「匿名成员」。

下面我们改写上面的代码,令 Circle 和 Wheel 都拥有一个匿名成员,这里我们称 Point 被嵌套到 Circle 中,而 Circle 被嵌套到 Wheel 中:

type Point struct {
	X, Y int
}
type Circle struct {
	Point
	Radius int
}
type Wheel struct {
	Circle
	Spokes int
}

在「结构体嵌套」的情况下,我们可以直接访问需要的最终变量,而省略中间变量:

var w Wheel
w.X = 8 // 等价于 w.Circle.Center.X = 8
w.Y = 8 // 等价于 w.Circle.Center.Y = 8
...

不过这里有一个问题:如果 Circle 有一个字段叫 Radius, Wheel 也有一个字段叫 Radius,我们如何访问呢?

Go 中很简单的解决了这个问题,「最外层优先访问」,也就是通过Wheel.Radius访问时,是访问 Wheel 中的 Radius 字段。

这样就允许我们去重载通过匿名字段继承的一些字段,当然如果我们想访问重载后对应匿名类型里面的字段,可以通过匿名字段名来访问。请看下面的例子

package main

import "fmt"

type Human struct {
	name string
	age int
	phone string  // Human类型拥有的字段
}

type Employee struct {
	Human  // 匿名字段Human
	speciality string
	phone string  // 雇员的phone字段
}

func main() {
	Bob := Employee{Human{"Bob", 34, "777-444-XXXX"}, "Designer", "333-222"}
	fmt.Println("Bob's work phone is:", Bob.phone)
	// 如果我们要访问Human的phone字段
	fmt.Println("Bob's personal phone is:", Bob.Human.phone)
}

但遗憾的是,Go 中没有初始化结构体的快捷方式,因此下面的语句都是无法通过编译的:

w = Wheel{8, 8, 5, 20} // 编译错误: 未知成员变量
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // 编译错误: 未知成员变量

如果希望正确的初始化结构体,以下两种方式都是正确且等价的:

// 方式1
w = Wheel{Circle{Point{8, 8}, 5}, 20}

// 方式2
w = Wheel{
	Circle: Circle{
		Point: Point{X: 8, Y: 8},
		Radius: 5,
	},
	Spokes: 20,
}
fmt.Printf("%#v\n", w)
// 输出:
// Wheel{Circle:Circle{Point:Point{X:8,Y:8},Radius:5},Spokes:20}

w.X = 42
fmt.Printf("%#v\n", w)
// 输出:
// Wheel{Circle:Circle{Point:Point{X:42,Y:8},Radius:5},Spokes:20}

上面我们在 Printf 中增加了副词#,令格式化符号%v能够以类似 Go 语法的形式输出变量 w。

在定义匿名成员时,我们还需要注意下面两点:

  • 「匿名成员」根据其类型拥有隐式名字,因此我们不能在同一个结构体中定义两个相同类型的匿名成员;

  • 匿名成员的「可导出性」也是由它们自身的类型决定的

    假设上例中我们的 point 和 circle 都是不可导出的形式(也就是首字母小写),我们仍然可以使用快捷方式访问

    w.X = 8 // 等价于 w.circle.center.X = 8
    

    我们这里可以直接使用w.X = 8的快捷方式访问,但是对于注释中的语句,不能在声明 point 和 circle 之外的包中使用。

对于上述的快捷访问匿名成员内部变量的方法同样适用于访问其内部方法,这个机制是从「简单类型」变量组合成复杂的「复合类型」的主要方式。在 Go 中,组合是面向对象编程的核心,这个我们在之后进行学习。

数组

声明和初始化数组

Go 中定义数组的语法比较特殊,使用类型[n]T表示一个含 n 个元素的,类型为 T 的数组。

// 声明一个长度为 10 的 int 数组(这时数组中的元素为默认值)
var nums [10]int
// 定义一个长度为 6 的数组并为其赋值
primes := [6]int{2, 3, 5, 7, 11, 13}

Go 中,我们在声明数组的时候,可以在长度位置使用...,那么数组的长度有初始化的数组元素个数决定:

nums := [...]int{1, 2, 3}
fmt.Printf("%T\n", nums) // 输出:[3]int

Go 语言的索引有时可以按照任意顺序出现:

type Currency int

const (
    USD Currency = iota
    EUR
    GBP
    RMB
)

func main() {
    symbol := [...]string{RMB: "3", USD: "0", GBP: "2", EUR: "1"}
    fmt.Println(RMB, symbol[RMB]) // 输出:3 3
}

我们甚至可以省略索引,这是没有指定值的索引位置会默认被赋予元素类型的默认值:

r := [...]int{99: -1}
fmt.Println(len(r), r[0], r[99]) // 输出:100 0 -1

上例中,我们指定索引 99 的值为 -1,因此除最后一个元素值(r[99])是 -1 外,其余元素都是 0。

比较两个数组

如果数组的元素类型是可比较的,那么这个数组也是可比较的,也就是我们可以用==操作符来比较两个数组,比较的结果是两边元素的值是否完全相同;使用!=来比较两个数组是否不同。

a := [2]int{1, 2}
b := [...]int{1, 2}
c := [2]int{1, 3}
fmt.Println(a==b, a==c, b==c) // 输出:true false false
d := [3]int{1, 3}
fmt.Println(c==d) // 编译错误:无法比较 [2]int == [3]int

这里有一个更有意义的例子,crypto/sha256包里面的函数Sum256用来为存储在任意字节slice中的消息使用 SHA256 加密散列算法生成一个「摘要」。摘要的信息是 256 位,也就是[32]byte;如果摘要相同,那么很可能两条原始信息是相同的;如果不同,那么它们一定是不同的。下面的程序比较了 x 和 X 的 SHA256 散列值。

import (
    "crypto/sha256"
    "fmt"
)

func main() {
    c1 := sha256.Sum256([]byte("x"))
    c2 := sha256.Sum256([]byte("X"))
	fmt.Printf("%x\n%x\n%t\n%T\n", c1, c2, c1 == c2, c1)
	// 输出:
	// 2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
	// 4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015
	// false
	// [32]uint8
}

数组指针

在其他语言中,数组是隐式地使用引用传递,而在 Go 中,所有类型都是值传递,这令我们函数内部对数组的任何修改都是会影响副本而不是原始数组,但如果数组较大时,这种传递方式会变得很低效(需要创建一个数组的副本),所以我们也可以传递一个数组的指针给函数。

下面是一个将 [32]byte 数组清零的程序:

func zero(ptr *[32]byte) {
	for i := range ptr {
		ptr[i] = 0
	}
}

同时,我们也应该注意到,对于数组指针,我们也可以直接使用形如ptr[i]的方式来直接访问对应索引的元素。

数组长度不可变

Go 中的数组长度是它类型的一部分,因此一旦定义,长度便固定了

q := [3]int{1, 2, 3}
q = [4]int{1, 2, 3, 4} // 编译错误:不能将 [4]int 赋值给 [3]int

因为其长度不可变,除了在特殊情况下,我们很少使用数组,仅在类似上面 SHA256 例子中,结果拥有固定长度的情况下使用;因为 Go 中为我们提供了一种便利的方式来灵活地使用数组:切片

切片(slice)

什么是切片

「切片」本质上是数组的引用,它描述了底层数组中的一段。因此更改切片的元素会修改其底层数组中对应的元素,与它共享底层数组的切片都会观测到这些修改。

func main() {
    var nums [3]int
    n := nums[:]
    one(n)
    for i, v := range nums {
        fmt.Println(i, v)
	}
	// 输出:
	// 0 1
	// 1 1
	// 2 1
}

func one(p []int) {
    for i := range p {
        p[i] = 1
    }
}

切片相当于为我们提供了使用动态大小数组的方法,在实践中,切片比数组更加常用。

如何使用

我们可以将「切片」看作一种特殊的元素,它的类型符是[]T;而一个数组的切片由两个下标来界定,分别代表上界和下界,两者以冒号分割a[low:high],需要注意的是,和大多数编程语言一样,切片的上下界是左闭右开的,这样我们可以直接通过high-low来算出切片的长度。

因此,如果我们需要定义一个对数组 nums 的切片,其中包含下标 1~3 的元素,那么我们应该这样做:

// 需要注意是左闭右开
var part []int = nums[1:4]

我们也可以直接创建一个切片,我们在初始化切片时,可以给他任意个数的元素,就像一个不定长的数组:

这是初始化一个数组的语法:

[3]bool{true, true, false}

这是初始化切片的文法,本质上,Go 会创建一个和上面相同的数组,然后构建一个引用了它的切片

[]bool{true, true, false}

访问切片元素时,我们直接用下标访问切片就好了part[0]

我们也可以像使用多维数组一样,创建切片的切片:

package main

import (
	"fmt"
	"strings"
)

func main() {
	board := [][]string{
		[]string{"_", "_", "_"},
		[]string{"_", "_", "_"},
		[]string{"_", "_", "_"},
	}
	for i := 0; i < len(board); i++ {
		fmt.Printf("%s\n", strings.Join(board[i], " "))
	}
	
	fmt.Println()
	fmt.Println("==更改元素==")
	fmt.Println()

	// 更改切片元素
	board[0][0] = "X"
	board[0][2] = "X"
	board[1][1] = "X"
	board[2][0] = "X"
	board[2][2] = "X"
	for i := 0; i < len(board); i++ {
		fmt.Printf("%s\n", strings.Join(board[i], " "))
	}
}

运行结果如下:

_ _ _
_ _ _
_ _ _

==更改元素==

X _ X
_ X _
X _ X

上面例子中我们可以看出,切片的使用和数组很像,但切片不用指定长度。和数组一样,切片也按照顺序指定元素,也可以通过索引指定元素,或者两者结合。

但有一点和数组不同,切片无法做比较,也就是不能用==操作符来测试两个切片是否有相同的元素。(因为切片是数组的引用,在比较出结果后,数组的改变可能会让原来的结果不正确,也就是这样是不安全的)。因此对于切片,唯一允许的操作符比较是和 nil 比较if nums == nil {},因为值为 nil 的切片没有底层数组。

但需要注意,有非 nil 的切片长度和容量为 0,例如make([]int)[]int{},因此如果希望判断一个切片是否为空,应该用len(s) == 0而不是s == nil

标准库中提供了高度优化的bytes.Equals函数来比较两个字节切片([]byte),但对于其他类型的切片,我们必须自己写函数来进行比较:

func equal(x, y []string) bool {
	if len(x) != len(y) {
		return false
	}
	for i := range x {
		if x[i] != y[i] {
			return false
		}
	}
	return true
}

切片的默认行为

在创建切片的时候,如果我们没有明确给定上界或下界,那么切片会为其赋予默认值:

  • 「默认下界」为0

  • 「默认上界」为数组长度

所以,对于数组var nums [10]int来说,下面的四个切片是等价的:

a[0:10]
a[:10]
a[0:]
a[:]

切片的三个属性:「长度」和「容量」

和数组只拥有「长度length」这一属性不同,切片拥有指针point长度length容量capacity这三个属性:

  • 「指针」:指向数组中第一个可以从切片访问的元素;

  • 「长度」:切片当前的元素个数;使用len(a)可获取切片长度;

  • 「容量」:从切片第一个元素开始,到底层数组末尾元素的个数;使用cap(a)可获取切片容量。

前面提到,切片的本质实际上是底层数组的一段引用,由此便能很好的理解「容量」的概念了;同时,如果切片长度超过了底层数组的长度,那么自然会产生数组越界

下面我们用一个例子说明:

package main

import "fmt"

func main() {
	s := []int{2, 3, 5, 7, 11, 13}
	printSlice(s)

	// 截取切片使其长度为 0
	s = s[:0]
	printSlice(s)

	// 拓展其长度
	s = s[:4]
	printSlice(s)

    // 舍弃前两个值
    // 注意,这里容量会减小
	s = s[2:]
	printSlice(s)
}

func printSlice(s []int) {
	fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

运行结果如下:

// 从 0 开始切片,容量为数组长度 6
len=6 cap=6 [2 3 5 7 11 13]
len=0 cap=6 []
len=4 cap=6 [2 3 5 7]
// 这里因为切片下界是2,因此容量为 6-2=4
len=2 cap=4 [5 7]

特别地,如果切片并没有指向任何的底层数组,那么它是一个空的切片,用nil表示,它的长度和容量都为 0。

func main() {
	var s []int
	fmt.Println(s, len(s), cap(s))
	if s == nil {
		fmt.Println("nil!")
	}
}

运行结果:

[] 0 0
nil!

使用「make()」函数快捷地创建切片

我们可以使用内建函数make()来快捷地创建切片:

a := make([]int, 5)  // len(a)=5

它实际上做了两件事:

  • 新建一个元素为默认值的数组

  • 返回一个引用了该数组的切片

所以如果我们打印上面的切片 a 的长度、容量等信息,我们可以得到以下结果:

a len=5 cap=5 [0 0 0 0 0]

实际上,make()函数拥有三个参数,分别代表底层数组的元素类型、切片长度、切片容量:

b := make([]int, 0, 5) // len(b)=0, cap(b)=5

b = b[:cap(b)] // len(b)=5, cap(b)=5
b = b[1:]      // len(b)=4, cap(b)=4

为切片追加元素

前面我们评价切片时,说到它是动态使用数组的方法;但切片本质上是指向底层数组的一个引用,我们可以通过[low:high]来获取当前切片的子集,以获取更小的数组;但使用上下界获取的切片长度和容量都不能超过底层数组的长度,不能获取更长的数组,那我们如何让切片满足「动态数组」这一使用要求呢?

为了满足获取更长数组的要求,Go 语言为我们提供了append()函数,它能够为切片追加新的元素,内建函数的文档对此函数有详细的介绍。

func append(s []T, vs ...T) []T

append()的第一个参数s是一个元素类型为 T 的切片,其余类型为 T 的值将会追加到该切片的末尾。它会返回一个包含原切片所有元素加上新添加元素的切片,根据底层数组长度和目标切片长度,会有下面两种情况:

  • 底层数组长度满足目标切片长度要求:相当于对底层数组相应索引的元素重新赋值,再让切片长度增加。

  • 底层数组长度过短,不满足目标切片长度要求:分配一个新的、更大的数组,再让切片指向这个新数组(之后对切片做的修改也不会影响到原数组了)。

下面我们用一个例子来说明:

package main

import "fmt"

func main() {
    // 原数组为 [1, 2, 3]
    var nums [3]int = [3]int{1, 2, 3}

    // 此时切片 s 指向数组 nums,为 [1]
    s := nums[:1]
    // 1. 因为原数组长度满足 append 后的要求长度,这里会对原数组也产生修改
    s = append(s, 0)
    fmt.Println("nums: ", nums)
    fmt.Println("s: ", s)
    // --输出如下--
    // nums: [1, 0, 3]
    // s: [1, 0]

    // 2. 这里原数组的长度明显过短,满足不了目标切片要求
    // append 会分配一个新的数组,让 s 指向新数组
    s = append(s, 51, 52, 53, 55)
    fmt.Println("nums: ", nums)
    fmt.Println("s: ", s)
    // --输出如下--
    // nums: [1, 0, 3]
    // s: [1 0 51 52 53 55]

    // 3. 这里让 s 的长度缩小至 1,也就是现在是 [1]
    s = s[:1]
    // 因为 s 已经指向新数组而不是 nums 了,所以修改不会影响 nums
    s = append(s, -1)
    fmt.Println("nums: ", nums)
    fmt.Println("s: ", s)
    // --输出如下--
    // nums: [1, 0, 3]
    // s: [1, -1]
}

更多关于切片的内容,可以查看Go 切片:用法和本质进行深入的理解。

使用「for-range」来遍历数组或切片

Go 中我们可以使用「for-range」的形式来遍历数组或者切片:

func main() {
    nums := []int{0, 1, 2, 3}
    for i, v := range nums {
        fmt.Println("index=", i, ", value=", v)
    }
}

每次range函数会返回两个值,第一个值为「当前元素下标」,第二个值为「当前元素值」,我们可以使用_来忽略它们(Go 中不允许出现无用的临时变量):

for i, _ := range nums 
for _, v := range nums

特别地,如果我们只需要索引,那么我们可以只保留第一个变量:

for i := range nums

用切片实现栈

我们可以使用切片来实现「栈」,给定一个空的 slice 元素 stack,可以使用append向 slice 尾部追加值:

stack = append(stack, v) // push v

栈顶部是最后一个元素:

top = stack[len(stack)-1] // 栈顶

通过染出最后一个元素来缩减栈:

stack = stack[:len(stack)-1] // pop

为了从 slice 的中间删除一个元素,并保留剩余元素的顺序,我们可以使用copy函数来将高位元素向前移动以覆盖被移除元素的所在位置:

func remove(slice []int, i int) []int {
	copy(slice[:i], slice[i+1:])
	return slice[:len(slice)-1]
}

映射(键值对)

映射,我习惯将其称为键值对,也就是在一个映射中维护键key和对应值value的唯一映射关系的数据结构,我们使用map[key]value语法来声明它:

var m map[string]int

对于键值对map,我们希望:

  • 「键」是其值能够进行相等==比较的任意类型,例如上面的string

  • 「值」可以是任意类型

在仅声明的情况下,映射是一个空指针nil,即是一个空的引用,我们可以使用make函数来对它进行初始化:

m := make(map[string]int)
// 初始化后就可以赋值了
m["a"] = 1

或是在声明时同时对它进行初始化:

var m map[string]int = map[string]int{
    "a" : 1,
    // 因为这里右括号 } 在下一行,所以这里行尾需要逗号
    "b" : 2, 
}

// 所以这样最后没有逗号,也是可以的
var m map[string]int = map[string]int{"a" : 1, "b" : 2}

访问、修改映射

1.在映射m插入元素

m[key] = elem

2.获取元素

elem = m[key]

这里我们需要注意,map 元素不是一个变量,不可以获取它的地址,所以这样是不对的:

_ = &m[key] // 编译错误,无法获取 map 元素的地址

3.删除元素

delete(m, key)

4.检测元素是否存在

这里我们需要使用「双赋值」:

elem, ok = m[key]
  • 如果key存在,「ok」为true;「elem」为 key 对应的元素值

  • 如果key不存在,「ok」为false;「elem」为其类型的默认值

如果「elem」和「ok」未声明,我们可以使用「短变量声明」:

elem, ok := m[key]

5.使用「for-range」遍历映射

在数组和切片中,range的参数分别是「索引」和「值」;而对于映射,range的参数为「键」和「值」。

for key, value := range m {
	fmt.Printf("key=%v\tvalue=%v\n", key, value)
}

不过对于映射,它的迭代顺序是随机的,每次运行都不一致。这是有意设计的,以防止程序依赖某种特定的序列,此处不对排序做任何保证。

6.比较两个映射是否相同

和切片一样,map 也不可以用==进行比较(除了和 nil 进行比较),为了判断两个 map 是否拥有相同的键和值,必须写一个循环:

func equal(x, y map[string]int) bool {
	if len(x) != len(y) {
		return false
	}
	for k, xv := range x {
		if yv, ok := y[k]; !ok || yv != xv {
			return false
		}
	}
	return true
}

注意我们使用!ok区分「元素不存在」和「元素存在但为默认值」这两种情况,如果我们只是简单写成了xv != y[k],那么equal(map[string]int{"A": 0}, map[string]int{"B": 1})会错误地报告两个 map 相等。

7.用 map 实现集合

Go 中没有提供集合类型,但我们可以使用 map 实现集合(Java 中也是用 HashMap 实现 HashSet 的):利用 map 的键唯一这个特性,将应存在集合中的元素当作 map 的键进行存储即可。

下例实现了一个字符串集合:

func main() {
	seen := make(map[string]bool) // 字符串集合
	input := bufio.NewScanner(os.Stdin)
	for input.Scan() {
		line := input.Text()
		if !seen[line] {
			seen[line] = true
			fmt.Println(line)
		}
	}
	if err := input.Err(); err != nil {
		fmt.Fprintf(os.Srderr, "dedup: %v\n", err)
		os.Exit(1)
	}
}

Go 程序员通常将这种使用 map 的方式描述成字符串集合。

8.实现以切片作为键的映射

有时,我们需要一个以 slice 为键的 map,但 map 的键必须是可比较的,所以这个功能不能直接实现,然而,我们可以分成两步间接实现这个功能:

  1. 实现一个函数,将 slice 映射为 string

  2. 创建一个键为字符串的 map,每次访问都将 slice 通过上面的函数转换为 string,再间接地访问映射。

下面的例子中,我们通过一个字符串列表使用一个 map 来记录 Add 函数被调用的次数,使用谓词%q来转换 slice 并记录每个字符串边界:

var m = make(map[string]int)

func k(list []string) string {
	return fmt.Sprintf("%q", list)
}

func Add(list []string) {
	m[k(list)]++
}

func Count(list []string) {
	return m[k(list)]
}

同样的方法适用于任何不可直接比较的键类型,不仅仅局限于 slice

posted @ 2019-11-28 20:10  Bylight  阅读(579)  评论(0编辑  收藏  举报