64

Go 语言快速入门

Go 语言是一种开源编程语言,旨在提高编程效率。本文将介绍 Go 语言的基本语法和特性。

The Go Programming Language

介绍

Go 语言是一门并发支持、垃圾回收的编译型系统编程语言,旨在创造一门具有静态类型、高效、可移植、可靠性和简单的编程语言。

Go 由 Robert Griesemer, Rob Pike, Ken Thompson 在 2007 年 9 月开始设计和实现,后来还加入了 Ian Lance Taylor, Russ Cox 等人,并最终于 2009 年 11 月开源,在 2012 年早些时候发布了 Go 1 稳定版本。现在 Go 的开发已经是完全开放的,并且拥有一个活跃的社区。

Go 依托于 Google ,被认为是未来极具潜力的语言之一。它已经被很多大厂使用。

特性

  • 自动垃圾回收
  • 更丰富的内置类型
  • 函数多返回值
  • 错误处理
  • 内存安全

官网

https://golang.org/

基础

环境搭建

下载安装包

下载地址

下载完成后,可以使用 go version 查看版本信息。

$ go version
go version go1.15.6 darwin/amd64

环境变量

Go 语言中环境变量主要有两个:

  • GOROOT:Go 语言的安装目录。GOROOT/src 目录下的函数都可以被导入和使用。例如 fmt.Println
  • GOPATH:除了 Go 语言的安装目录外的源码目录,同样可以调用目录下的函数。

可以使用 go env 查看环境变量。

$ go env GOROOT
/usr/local/go
$ go env GOPATH
/Users/username/go

可以使用 export 命令设置环境变量。

$ export GOROOT=/usr/local/go
$ export GOPATH=/Users/username/go

IDE

通常使用 GoLand 进行开发。下载地址:GoLand

Go 的跨平台

Java 一直遵循一次编译,到处运行的原则,虽然 Go 不是一次编译,到处运行的语言,但是能做到一次编写,到处运行。这是因为 Go 不像 Java 拥有自己的虚拟机,Go 语言的编译器会将 Go 代码编译成机器码,然后直接运行在操作系统上。虽然牺牲了一定的便利性,但是保障了性能。

Go 语言会区分运行程序的操作系统,主要通过操作系统CPU 架构来区分。

$ go env GOOS
windows
$ go env GOARCH
amd64

常见的 Goos 和 Goarch 如下:

GOOSGOARCH说明
darwinamd64Mac OS
linuxamd64Linux
windowsamd64Windows

可以使用 go tool dist list 查看所有支持的操作系统和 CPU 架构。

$ go tool dist list

我们无需进行任何显式的操作,Go 语言会自动识别当前的操作系统和 CPU 架构,然后编译成对应的可执行文件。

不过当我们尝试在 MacOS 上编译 Windows 的可执行文件时,需要进行一些额外的操作。

$ export GOOS=windows GOARCH=amd64
$ go build hello.go

如果要恢复原来的运行状态,需要对这两个变量进行清空。

$ export GOOS= GOARCH=

编译执行过程

我们通过 go help build 查看编译命令的帮助信息。

$ go help build
- a
    force rebuilding of packages that are already up-to-date.
- n
    print the commands but do not run them.
...

有三个值得我们关注的参数:

  • -n:打印编译的详细过程,但不执行。相当于预览。
  • -x:打印编译的详细过程,并执行。
  • --work:打印编译的详细过程,并且保留生成的中间文件和目录,方便我们进行调试。
$ go build -x hello.go
WORK=/var/folders/28/1z5z1j7d7zq7z1z7z1z7z1z7z1z7z1z/T/go-build1234567890
mkdir -p $WORK/b001/
cat >$WORK/b001/_gomod_.go << 'EOF' ## internal
packagefile command-line-arguments=/Users/username/Libraries/Caches/go-build/12/34567890abcdefg
packagefile fmt=/usr/local/go/src/fmt.a
packagefile runtime=/usr/local/go/src/runtime.a
...
...
...
packagefile runtime=/usr/local/go/src/path.a
modinfo "0w\xaf\x1f\x.."
EOF
mkdir -p $WORK/b001/exe/
cd .
/usr/local/go/pkg/tool/darwin_amd64/compile -o $WORK/b001/exe/hello -trimpath $WORK -p main -complete -buildid 1234567890abcdefg -D _/Users/username/Libraries/Caches/go-build -I $WORK/b001 -pack ./hello.go
/usr/local/go/pkg/tool/darwin_amd64/link -o $WORK/b001/exe/a.out -L $WORK/b001 -extld=clang -buildmode=exe -buildid=1234567890abcdefg $WORK/b001/exe/hello
mv $WORK/b001/exe/a.out hello

快速开始

Hello World

$ mkdir hello
$ cd hello
$ go mod init hello
go: creating new go.mod: module hello

通过 go mod init 命令初始化一个新的模块,会在当前目录下生成一个 go.mod 文件,内容如下:

module hello
 
go 1.15

然后创建一个 hello.go 文件,内容如下:

package main
 
import "fmt"
 
func main() {
    fmt.Println("Hello, World!")
}

运行 go run hello.go,可以看到输出结果:

$ go run hello.go
Hello, World!

Main 函数

Go 程序的入口是 main 函数。

Go 程序执行时,将先从 fmt 包中导入 Println 函数以及其他的函数,并在 main 函数中调用。

编译

$ go build
$ ./hello
Hello, World!

这里 go build 命令会生成一个可执行文件,然后执行该文件。

在 Windows 系统下,会生成一个 hello.exe 文件,然后执行该文件。

注释和转义符

注释

Go 语言中的注释有两种,分别是单行注释和多行注释。

单行注释以 // 开头,多行注释以 /* 开头,以 */ 结尾。

// 这是单行注释
 
/*
这是多行注释
*/

不过不推荐使用多行注释,Go 在源代码中均使用单行注释。

转义符

Go 语言中的转义符有以下几种:

转义符含义
\r回车符(返回行首)
\n换行符(直接跳到下一行的同列位置)
\t制表符
\\反斜杠
\'单引号
\"双引号

转义符的使用十分麻烦,推荐使用后面的原义字符

原义字符

类似于 JS 中的模板字符串,Go 语言中也有原义字符。

package main
 
import "fmt"
 
func main() {
    a := `"hello
    world"`
    fmt.Println(a)
}

输出结果:

"hello
    world"

Go 的分号

Go 语言中的语句结尾不需要分号,如果写了分号,编译器会自动忽略。

fmt 格式化字符

格式化字符含义
%v值的默认格式
%+v类似 %v,但输出结构体时会添加字段名
%#v值的 Go 语法表示
%T值的类型的 Go 语法表示
%%百分号
%b二进制表示
%c相应 Unicode 码点所表示的字符
%d十进制表示
%o八进制表示
%q单引号围绕的字符字面值,由 Go 语法安全地转义
%x十六进制表示,字母形式为小写 a-f
%X十六进制表示,字母形式为大写 A-F
%UUnicode 格式:U+1234,等同于 "U+%04X"
%e科学计数法表示
%E科学计数法表示
%f浮点数表示
%F等价于 %f
%g根据情况选择 %e 或 %f 以产生更紧凑的(无末尾的 0)输出
%G根据情况选择 %E 或 %F 以产生更紧凑的(无末尾的 0)输出
%s字符串表示

运算符

算术运算符

运算符含义
+相加
-相减
*相乘
/相除
%求余

关系运算符

运算符含义
==相等
!=不等
>大于
<小于
>=大于等于
<=小于等于

逻辑运算符

运算符含义
&&逻辑与
`
!逻辑非

位运算符

运算符含义
&按位与
``
^按位异或
<<左移
>>右移

赋值运算符

运算符含义
=简单赋值
+=相加后赋值
-=相减后赋值
*=相乘后赋值
/=相除后赋值
%=求余后赋值
<<=左移后赋值
>>=右移后赋值
&=按位与后赋值
|=按位或后赋值
^=按位异或后赋值

其他运算符

运算符含义
&取地址
*取指针的值
++自增
--自减
.结构体成员

命令行参数的接收

Go 中可以通过 os 包来接收命令行参数。

package main
 
import (
    "fmt"
    "os"
)
 
func main() {
    for i, arg := range os.Args {
        fmt.Printf("arg[%d]: %s\n", i, arg)
    }
}

执行 go run main.go hello world,可以看到输出结果:

arg[0]: /var/folders/28/1z5z1j7d7zq7z1z7z1z7z1z7z1z7z1z/T/go-build1234567890/b001/exe/main
arg[1]: hello
arg[2]: world

这是顺序获得命令行参数的方法,实际上还能通过参数名来获取参数。

package main
 
import (
    "flag"
    "fmt"
)
 
func main() {
    var name = flag.String("name", "everyone", "The greeting object.") // 参数名,默认值,参数说明
    flag.Parse()  // 解析参数
    fmt.Println("Hello, ", *name) // *name 表示获取 name 的值, 如果直接使用 name 会输出指针地址
}

执行 go run main.go -name=world,可以看到输出结果:

Hello, world

变量与数据类型

变量与常量

变量(Variable)

变量即一段或多段内存空间,用于存储数据。

Go 语言中的变量声明格式为:

var 变量名 变量类型

例如:

var a int
var b string
var c bool
a = 1
b = "hello"
c = true
var d int = 2
// Go 具有类型推导,可以不指定类型
var d = 2 // int
var e string = "world"
var f bool = false
// 类型推导
var g = 3 // int
// 简短声明
h:= 4 // int
// 避免对已声明的变量进行重复声明
h:= 5 // 报错

输出结果:

fmt.Print("a=%v, b=%v, c=%v\n", a, b, c)
fmt.Print("d=%v, e=%v, f=%v\n", d, e, f)
fmt.Print("g=%v, h=%v\n", g, h)

此处使用了 fmt.Print 函数,%v 为占位符,表示输出变量的值。

当然,可以同时声明多个变量:

var a, b, c int
a, b, c = 1, 2, 3
var d, e, f int = 4, 5, 6
var g, h, i = 7, 8, 9
j, k, l := 10, 11, 12

对于不同类型的变量必须要分开声明,但是 var 关键字可以共享。

var (
    a int = 1
    b string = "hello"
    c bool = true
)

在 Go 中,一个变量只进行了声明而没有赋值,那么它的值就是该类型的零值。以下是 Go 中的一些零值:

  • 整型的零值是 0
  • 浮点型的零值是 0.0
  • 布尔型的零值是 false
  • 字符串的零值是 ""
  • 数组的零值是一个全是零值的数组
  • 接口(interface)、切片(slice)、通道(channel)、字典(map)、指针(pointer)、函数(func)的零值是 nil(空指针)
  • 结构体的零值是每个字段都是零值

常量(Constant)

常量即固定值,不可修改。

Go 语言中的常量声明格式为:

const 常量名 常量类型 = 常量值

例如:

const a int = 1
const b string = "hello"
const c bool = true

还有一种写法

const (
    a int = 1
    b string = "hello"
    c bool = true
)

还可以使用 iota 来简化常量的定义:

const (
    a = iota  // 0
    b // 1
    c // 2
)

iota 是一个特殊的常量,它的值默认为 0,每次使用后自动加 1。第一行使用后,后面行可以省略 iota

iota 计数不会中断

const (
    a = iota  // 0
    b // 1
    c = 100 // 100
    d = iota // 3
    e // 4
)

但是如果在中断后不再使用 iota,则会重新计数:

const (
    a = iota  // 0
    b // 1
    c = 100 // 100
    d // 100, 未显式赋值,将使用最近的赋值
    e = 200 // 200
    f // 200
)

iota 需要谨慎使用,因为如果插入一个值将会产生骨牌效应,例如数据库中一张表使用枚举值表示状态,如果插入一个状态,将会导致后面的状态值全部改变,这将与之前的业务表达不符。例如:

cosnt {
    INIT = iota
    START
    STOP
    PAUSE
    FINISH
}

在数据库中,上述的状态值分别对应 0、1、2、3、4。当某天程序员需要在 STARTSTOP 之间插入一个状态,例如为 RUNNING,那么 STOP 的值将变为 3,PAUSE 的值将变为 4,FINISH 的值将变为 5。这将导致数据库中的状态值全部改变,这是不可接受的。

最佳使用 iota 的场景是在枚举类型中,例如:

const (
    MONDAY = iota
    TUESDAY
    WEDNESDAY
    THURSDAY
    FRIDAY
    SATURDAY
    SUNDAY
)

但是如果我们希望从 1 开始计数,可以使用占位符 _

const (
    _ = iota
    MONDAY
    TUESDAY
    WEDNESDAY
    THURSDAY
    FRIDAY
    SATURDAY
    SUNDAY
)

当然也可以使用 iota+1

const (
    MONDAY = iota + 1
    TUESDAY
    WEDNESDAY
    THURSDAY
    FRIDAY
    SATURDAY
    SUNDAY
)

全局变量/常量

变量通常的作用域为当前代码块,如果想要在整个包中使用,可以使用全局变量。全局变量和函数具有相同的作用域。

全局变量的声明格式为:

var 变量名 变量类型

注意,全局变量的声明不能使用 :=,只能使用 var

全局跨包变量/常量

如果想要在其他包中使用全局变量,需要将变量名首字母大写。

例如:

// demo/note/note.go
package note
 
var Note string = "hello"
// demo/main.go
 
import (
    "demo/note"
    "fmt"
)
 
func main() {
    fmt.Println(note.Note)
}

没有多余的局部变量

Go 语言中,如果声明了一个局部变量,但是没有使用,编译器会报错。

func main() {
    var a int
}

这里声明了一个变量 a,但是没有使用,编译器会报错。这是因为 Go 力求保持代码整洁。

全局变量不会出现这种情况,未使用的全局变量不会报错。

常量也可以不使用,不会报错。这是因为常量在编译器就已经决定,导致的副作用较小。

基本数据类型

Go 中,所有的值类型变量常量都会在声明时被分配空间并赋予默认值。

bit: 位,计算机中最小的数据单位,只有 0 和 1 两种状态。 Byte:字节,计算机中基本的存储单位,1 Byte = 8 bit。

整型

整型分为有符号整型和无符号整型。有符号整型包括 int8、int16、int32、int64 和 int。无符号整型包括 uint8、uint16、uint32、uint64 和 uint。

类型位数取值范围
int88-128 ~ 127
uint880 ~ 255
int1616-32768 ~ 32767
uint16160 ~ 65535
int3232-2147483648 ~ 2147483647
uint32320 ~ 4294967295
int6464-9223372036854775808 ~ 9223372036854775807
uint64640 ~ 18446744073709551615
int32/64与操作系统有关,根据操作系统的位数决定
uint32/64与操作系统有关

为什么有了这么多版本的 int 和 unit 之后,还要设计单独的 int 和 unit 类型呢?

  • 能够避免选择,程序员通常并不关注数据长度的具体大小,如果 32 或者 64 都可以满足需求,那么就可以使用 int 或者 unit。
  • 提升效率。CPU 按照自己的字长(word size)的整数倍来处理数据肯定是效率最高的,所以 int 和 unit 会根据 CPU 的字长来选择 32 或者 64 以提升效率。
var a int8 = 1
var b uint8 = 2
var c int16 = 3
var d int = 4

支持二进制、八进制、十进制、十六进制的表示方式:

var a int = 0b1010 // 二进制
var b int = 0O12 // 八进制
var c int = 10 // 十进制
var d int = 0xA // 十六进制

精度转换:

// 低精度转高精度
var a int8 = 1
var b int16 = int16(a)
var c int32 = int32(b)
var d int64 = int64(c)
var e int = int(d)
// 高精度转低精度
var f int = 1
var g int64 = int64(f)
var h int32 = int32(g)
var i int16 = int16(h)
var j int8 = int8(i)

浮点型

浮点型分为单精度浮点型和双精度浮点型。单精度浮点型为 float32,双精度浮点型为 float64。

类型位数
float3232
float6464
var a float32 = 1.1
var b float64 = 2.2

浮点型将导致精度丢失

在计算机中,整数是能够准确表达的,因为十进制整数转化为二进制是因为所有的整数的基石 1 是能够通过二进制准确表达的,但是浮点数的小数部分利用 2 的幂次方来表示,例如 2 的 -1 次方表示 0.5,2 的 -2 次方表示 0.25,但是 0.1 无法通过有限的二进制数来准确表示,这就导致了浮点数的精度丢失。

浮点数在计算机中表示示意如下:

  • IEEE 32
符号位指数位尾数位
1bit8bit23bit
  • IEEE 64
符号位指数位尾数位
1bit11bit52bit

不同于 Int 和 Uint 类型,Go 语言中没有 float 关键字来声明 32 位的浮点数或者与平台匹配的浮点数,只有 float32 和 float64。这可能是因为浮点数并不能准确表达一个数字,所以在这里程序员需要关注精度,所以需要明确声明。

由于精度缺失,浮点数不能使用 == > < 来比较,而应使用 big 包中的函数。

import "math/big"
 
func main() {
    a := 3.14
    b := 3.14
    result := big.NewFloat(a).Cmp(big.NewFloat(b))
 
    if result == 0 {
        fmt.Println("a == b")
    } else if result == 1 {
        fmt.Println("a > b")
    } else {
        fmt.Println("a < b")
    }
}

字符型

类型位数取值范围
byte8
rune32
var a byte = 'a'
var b rune = 'b'
// 格式化打印字符
fmt.Printf("%c\n", a)
// 直接打印字符
fmt.Println(b)
// 强制转换为字符串
fmt.Println(string(a))

这里的 byterune 都是别名,实际上是 uint8int32 来存储的。

布尔型

类型位数
bool1
var a bool = true
var b bool = false

字符串

字符串是一串固定长度的字符连接起来的字符序列。

var a string = "hello"
var b string = "world"

字符串在内存中存储时,实际上是一个字节数组 []byte,每个字符占用一个字节。字符串的长度是字符的个数,而不是字节数。

字符串的长度可以通过 len 函数获取,但是其用于获得变量的实际的存储空间,即底层字节数组的长度。

package main
 
import {
  "fmt"
}
 
func main{
  s:= "字符串"
 
  len:= len(s)
  fmt.Println(len) // 9
 
  for i:= 0; i < len; i++ {
    fmt.Printf("%c\n", s[i])
  }
}

执行结果:

9
E5 AD 97 E7 AC A6 E4 B8 B2

在这里打印的“字符串”的长度为 9,这对应每个汉字使用 3 个字节来存储。打印出来的内容也不是字符本身,而是字符的 UTF-8 编码。

rune 类型

如果我们希望以自然语言的视角来处理字符串,我们需要使用 rune 类型。

package main
 
import {
  "fmt"
}
 
func main{
  s:= "字符串"
 
  fmt.Println(utf8.RuneCountInString(s)) // 3
 
  for _, r:= range s {
      fmt.Printf("%T, %X\n", r, r)
  }
}

打印结果:

3
int32, 5B57
int32, 7B26
int32, 4E32

复数

类型位数取值范围
complex6464
complex128128
var a complex64 = 1 + 2i
var b complex128 = 3 + 4i

辨认数据类型

Go 语言中可以使用 reflect 包来辨认数据类型。

import "reflect"
 
func main() {
    var a int = 1
    var b string = "hello"
    var c bool = true
    var d float32 = 1.1
    var e float64 = 2.2
    var f byte = 'a'
    var g rune = 'b'
    var h complex64 = 1 + 2i
    var i complex128 = 3 + 4i
 
    fmt.Println(reflect.TypeOf(a))
    fmt.Println(reflect.TypeOf(b))
    fmt.Println(reflect.TypeOf(c))
    fmt.Println(reflect.TypeOf(d))
    fmt.Println(reflect.TypeOf(e))
    fmt.Println(reflect.TypeOf(f))
    fmt.Println(reflect.TypeOf(g))
    fmt.Println(reflect.TypeOf(h))
    fmt.Println(reflect.TypeOf(i))
}

数组和指针

数组

数组是一种数据结构,用于存储固定长度的相同类型的元素。

数组的声明

Go 语言中数组的声明格式为:

var 数组名 [数组长度]数组类型

示例如下:

package main
 
import "fmt"
 
func main() {
    a := [3]int{1, 2, 3}
    // 或
    var b [3]int
    b = [3]int{4, 5, 6}
    // 或
    var c [3]int = [3]int{7, 8, 9}
}

访问数组

数组的访问方式为 数组名[索引]

package main
 
import "fmt"
 
func main() {
    a := [3]int{1, 2, 3}
    fmt.Println(a[0]) // 1
    fmt.Println(a[1]) // 2
    fmt.Println(a[2]) // 3
}

数组传入函数

数组作为参数传入函数时,会传入数组的副本,而不是数组的指针。

package main
 
import "fmt"
 
func modify(a [3]int) {
    a[0] = 4
    fmt.Println(a) // [4 2 3]
}
 
func main() {
    a := [3]int{1, 2, 3}
    modify(a)
    fmt.Println(a) // [1 2 3]
}

这表明,数组作为参数传入函数时,数组将被全量复制,而不是传入原数组的指针

数组无法改变大小

Go 语言中的数组是固定长度的,无法改变大小。这是因为数组在内存中是连续存储的,如果改变大小,将会导致内存的重新分配,这将导致性能问题。

修改数组的值

数组的值可以通过索引来修改。

package main
 
import "fmt"
 
func main() {
    a := [3]int{1, 2, 3}
    a[0] = 4
    fmt.Println(a) // [4 2 3]
}

指针

指针的基本概念

在许多语言如 Python、Java 中,指针概念被屏蔽,但是在 Go 语言中,指针被保留了下来。

指针是存储另一个变量的内存地址的变量。

var a int = 1
var b *int = &a

这里的 *int 表示指向 int 类型的指针,&a 表示变量 a 的内存地址。 & 表示取地址符,* 表示取指针的值。

一个例子:

package main
 
import "fmt"
 
func main() {
    var a = 65336
    fmt.Println("值类型 a 的值为:", a)
 
    var b = 65336
    var c = &b
    fmt.Println("指针类型 c 的值为:", c)
}

输出结果:

值类型 a 的值为: 65336
指针类型 c 的值为: 0xc0000b6010

在上述案例中,c 得到的值是通过 & 符号(取地址符)得到的,这个值是一个内存地址,因此打印的结果是一个内存地址。

为什么区分指针和值类型

如我们在数组中提到的,数组作为参数传入函数时,会传入数组的副本,而不是数组的指针。同样地,传入的参数都会被复制:

func tryChange(account Account) {
    account.Modify()
}

但是上面的代码中,account 是一个结构体,结构体是值类型,所以 account 会被复制一份,而不是传入的指针。如果我们想要修改原始的 account,这将无法实现。

所以,我们可以使用指针来传递参数:

func tryChange(account *Account) {
    account.Modify()
}

这样,account 就是一个指针,指向原始的 account,这样就可以修改原始的 account

在 Java 中,对于对象类型的参数传入函数时都默认传入的是指针。

切片(Slice)

正如上面数组提到的,数组是固定长度的,无法改变大小。但是这无法满足大多数的需求,所以 Go 语言中提供了切片。切片可以看作大小可变的数组

切片的原理

切片在底层封装了一个数组指针,打开 slice.go 文件可以看到:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • array:指向数组的指针
  • len:切片的长度,即切片中元素的个数
  • cap:切片的容量,本质上是底层数组的长度

切片的内部结构的示意图如下:

     +-------+-------+-------+
Slice| array |  len  |  cap  |
     +-------+-------+-------+
          |
          v
     +-------+-------+-------+-------+-------+-------+
Array|   1   |   2   |   3   |   4   |   5   |   6   |
     +-------+-------+-------+-------+-------+-------+

切片的声明和定义

切片的声明和定义格式为:

var 切片名 []切片类型

与数组的声明类似,但是不需要指定切片的长度。

可以调用 make 函数来创建切片:

s:= make([]int, 5, 10)
 
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 10

make 函数适用于创建对象的通用方法,例如创建切片、字典、通道等。在这里第一个参数是类型,第二个参数是长度,第三个参数是容量。

切片的访问

切片的访问方式与数组类似,通过索引来访问。

s:= make([]int, 5, 10)
 
s[0] = 1
s[1] = 2
 
for i:= 0; i < len(s); i++ {
  fmt.Println(s[i])
}

执行结果:

1
2
0
0
0

这表示,未经初始化的切片的值为当且类型的零值

如果使用 for-range 循环,可以更加简洁地访问切片:

s:= make([]int, 5, 10)
 
s[0] = 1
s[1] = 2
 
for _, v:= range s {
  fmt.Println(v)
}

for-range 循环是由切片的长度而非容量决定的。

拓展切片长度

我们能否通过索引来拓展切片的长度呢?

s:= make([]int, 5, 10)
 
for i:= 0; i < 10; i++ {
  s[i] = i
}
 
fmt.Println(s)

执行结果:

panic: runtime error: index out of range [5] with length 5

不行。Go 抛出了错误提示,表示索引超出了切片的长度(越界)。

正确的做法是使用 append 函数:

s:= make([]int, 5, 10)
 
for i:= 0; i < len(s); i++ {
  s[i] = i
}
 
s = append(s, 5, 6, 7, 8, 9)

append 函数第一个参数为切片,后面的参数为要追加的元素。追加后的切片长度为原始切片长度加上追加的元素的个数。使用 append 函数时,如果将返回值传递给原始切片,那么原始切片将被替换,但如果传递给其他变量,那么原始切片将不会被替换。

s:= make([]int, 5, 10)
 
for i:= 0; i < len(s); i++ {
  s[i] = i
}
 
a:= append(s, 5, 6, 7, 8, 9)
 
fmt.Println(s) // [0 1 2 3 4]
fmt.Println(a) // [0 1 2 3 4 5 6 7 8 9]

拓展切片容量

切片的容量是底层数组的长度,当新的元素超出容量时,Go 会重建底层的数组。

s:= make([]int, 5, 10)
 
for i:= 0; i < len(s); i++ {
  s[i] = i
}
 
fmt.Println(len(s)) // 5
 
s = append(s, 5, 6, 7, 8, 9)
 
fmt.Println(len(s)) // 10
 
fmt.Println(cap(s)) // 10
 
// 打印指针
fmt.Printf("%p\n", s) // 0xc0000b6010
 
// 继续追加
s = append(s, 10)
 
fmt.Println(len(s)) // 11
 
fmt.Println(cap(s)) // 20
 
// 打印指针
fmt.Printf("%p\n", s) // 0xc0000b60b0

可以观察得到,当切片的容量不足时,Go 会重新分配底层数组,并将原始切片的值复制到新的切片中。这也导致了切片的指针发生了变化。

利用数组创建切片

a:= [5]int{1, 2, 3, 4, 5}
 
s:= a[1:3]
 
m:= a[:3]
 
n:= a[1:]
 
k:= a[:]
 
fmt.Println(s) // [2 3]
fmt.Println(m) // [1 2 3]
fmt.Println(n) // [2 3 4 5]
fmt.Println(k) // [1 2 3 4 5]

array[m:n] 表示从数组的第 m 个元素到第 n-1 个元素,array[:n] 表示从数组的第一个元素到第 n-1 个元素,array[m:] 表示从数组的第 m 个元素到最后一个元素,array[:] 表示整个数组。

这和 Python 中的切片操作类似。

进一步观察底层,让我们打印数组 a、切片 s、切片 m、切片 n、切片 k 的指针:

fmt.Printf("%p\n", a) // 0xc0000b6010
fmt.Printf("%p\n", s) // 0xc0000b6018
fmt.Printf("%p\n", m) // 0xc0000b6010
fmt.Printf("%p\n", n) // 0xc0000b6018
fmt.Printf("%p\n", k) // 0xc0000b6010

可以看出切片 s 的底层数组比原数组地址增长了 8 个字节,而切片 m 和切片 k 的底层数组地址和原数组地址相同,这是因为切片 m 和切片 k 的起始索引为 0。之所以是 8 个字节是因为运行环境是 64 位的,一个 int 类型占用 8 个字节。

利用切片创建切片

s:= make([]int, 5, 10)
 
for i:= 0; i < len(s); i++ {
  s[i] = i
}
 
m:= s[1:3]
 
n:= s[:3]
 
fmt.Println(m) // [1 2]
fmt.Println(n) // [0 1 2]

同样地,切片 m 和切片 n 的底层数组地址和原切片地址不同,切片 m 的底层数组地址比原切片地址增长了 8 个字节,而切片 n 的底层数组地址和原切片地址相同。

切片元素的修改

由于可能多个切片共享同一个底层数组,所以修改一个切片的元素可能会影响到其他切片。

s := []int{1, 2, 3, 4, 5}
 
m := s[1:3]
 
m[0] = 6
 
fmt.Println(s) // [1 6 3 4 5]
fmt.Println(m) // [6 3]

Map

Map(映射)是一种键值对的数据结构,和切片中的有序存储不同,Map是无序的。Map 的主要优势在于能够通过 key 快速检索到对应的 value。

声明和创建

  • 通过 var 声明一个 map
var mapName map[keyType]valueType

keyType 是键的类型,valueType 是值的类型。

  • 通过 make 创建一个 map
mapName := make(map[keyType]valueType)

元素的添加

mapName[key] = value

如:

var m map[string]int
 
m["one"] = 1
m["two"] = 2

元素的遍历

for key, value := range mapName {
    fmt.Println(key, value)
}

元素查找

若元素存在,则返回对应的值,否则返回零值。

charCountMap := make(map[string]int)
 
charCountMap["a"] = 1
charCountMap["b"] = 2
 
fmt.Println(charCountMap["a"]) // 1
fmt.Println(charCountMap["c"]) // 0

但是返回 0 值就出现了问题,因为 0 有可能是元素的值。为了区分元素是否存在,可以使用多返回值的方式:

value, ok := charCountMap["c"]
 
if ok {
    fmt.Println(value)
} else {
    fmt.Println("key not found")
}

元素的删除

delete(mapName, key)

该函数不会返回任何与执行结果相关的信息,如果 key 不存在,也不会抛出异常。

存储结构*

hmap

Map 的查找效率非常高。Map 的数据结构主要采用 hmap 结构,其中 hmap 结构体定义如下:

type hmap struct {
    count int // 元素个数
    flags uint8
    B     uint8 // 桶的数量,意味着有 2^B 个桶
    noverflow uint16  // 溢出桶的数量
    hash0 uint32  // 随机数种子
 
    buckets    unsafe.Pointer // Map 底层是一个桶的数组,buckets 指向第一个桶的指针
    oldbuckets unsafe.Pointer // 扩容时,buckets 指向旧的桶数组
    nevacuate  uintptr // 扩容时,已经迁移的桶的数量(迁移进度)
    extra      *mapextra
}

分区、分桶操作往往出现于数据库设计和变成语言中,本质上都是对 Key 进行哈希运算,然后将哈希值映射到不同的桶中(运算获得桶号)。

以 B=4 为例,会产生 16 个桶。一个 key 值将先进行 hash 运算,然后对获得 hash 值取前 B 位,得到的结果就是桶号。

桶在 Go 语言中主要使用 bmap 结构体表示,定义如下:

type bmap struct {
    tophash [bucketCnt]uint8
}

在编译期,bmap 会被追加字段:

type bmap struct {
    tophash [bucketCnt]uint8
    // 以下字段在编译期追加
    // keys    [8]keytype
    // values  [8]valuetype
    // overflow *[]*bmap
    // pad     uintptr
}

一个桶的示意如下:

        +----------------------------+
tophash |64|15|17|135|156|171|201|138|
        +----------------------------+
        |            key0            |
        +----------------------------+
        |            key1            |
        +----------------------------+
        |            key2            |
        +----------------------------+
        |            ....            |
        +----------------------------+
        |            key7            |
        +----------------------------+
        |           value0           |
        +----------------------------+
        |           ......           |
        +----------------------------+
        |           value7           |
        +----------------------------+
overflow|             nil            |
        +----------------------------+

而在一个 hmap 中将包含多个桶。

元素定位

例如 key 为 "key1" ,假设获得的哈希值为:

00001111 | 0000111101101100100011110010100100010010110010101010 | 0000

若 B=4 ,则低 4 位(末尾4位)为桶号,即 0000 为桶号,对应的桶为第 0 个桶。

接着取高 8 位 00001111 作为 tophash 的索引,即 tophash[15],若 tophash[15] 为 0,则表示桶中没有元素,否则表示桶中有元素。

在上面桶的示意中,索引为 1.

接着计算 keys[1] 的内存地址,为 bmap[0] + 8 + len(key_slot) * 1,即为 bmap[0] + 8 + 16 * 1

len(key_slot) 表示一个 key 所占用的空间。

计算出 keys[1] 的值后,与输入的 key 进行比较,若相等,则返回对应的 value。

通道

通道(Channel)是 Go 中一个重要的数据结构。Channel 可以视作为消息队列线程同步而创建的数据结构。

创建通道

通道是一个引用类型,需要使用 make 函数来创建:

ch := make(chan int)

或者:

ch := make(chan int, 100)
  • chan 是关键字,表示通道类型。
  • int 是通道中元素的类型。
  • 100 是通道的缓冲区大小,如果不指定,则表示是无缓冲通道。

Channel 可以看作一个消息通道,通道的主要功能是保证数据通过,缓冲区的意义在于缓存数据,以便发送者和接收者之间的速度不一致时能够缓冲一部分数据。如果没有缓冲区,那么需要读操作就绪时,写操作才能继续,这样就会造成阻塞。这如同一个没有仓库的货站,必须有等待接收货物的车辆,送货的车辆才能顺畅送货,否则送货车辆只能等待。缓冲区可以看作是一个仓库,送货车辆可以把货物放在仓库里,等待接收货物的车辆再从仓库里取货。

通道的读写

写入数据:

ch <- i

读取数据:

i := <-ch

打印数据:

count := 5
 
ch := make(chan int, count)
 
for i := 0; i < count; i++ {
    ch <- i
}
 
for i := 0; i < count; i++ {
    fmt.Println(<-ch)
}

打印结果:

0
1
2
3
4

通道的实现原理*

TODO 74

结构体

Go 中的 slice、map 和 channel 等复杂数据类型都有对应的结构体类型,这些结构体类型是 Go 语言内建的类型,可以直接使用。同时我们也可以使用 struct 关键字来定义自己的结构体类型。

自定义数据类型

Go 语言中可以使用 type 关键字来定义自定义数据类型。

type newType existType

新的类型 newType 将继承已存在的类型 existType 的数据结构,同时适用于 existType 的函数也将适用于 newType。但是方法不会继承。

结构体的创建

type newType struct {
    field1 type1
    field2 type2
    ...
}

例如创建一个 Person 结构体:

type Person struct {
    name string
    age int
}

这让我们想到了 C 语言中的结构体。

struct Person {
    char name[20];
    int age;
};

以及 TypeScript 中的接口。

type Person {
    name: string;
    age: number;
}

使用结构体

package main
 
import "fmt"
 
type Person struct {
    Name string
    Age int
}
 
func main() {
    var p1 = Person{
        Name: "张三",
        Age: 18,
    }
    fmt.Println(p1)
}

或者初始时不进行赋值,使用默认值。

var p1 = Person{}
p1.Name = "张三"
p1.Age = 18

可以使用 new 关键字创建一个结构体实例,返回的是一个指向结构体的指针。

var p2 = new(Person)

权限控制

Go 语言中,结构体的字段名首字母的大小写决定了该字段的访问权限。小写的字段名表示私有字段,只能在定义该结构体的包中访问,在其让包中无法访问。

流程控制

分支控制

if

if 条件 {
    // do something
} else if 条件 {
    // do something
} else {
    // do something
}

这里的条件不需要使用括号括起来。

switch

switch 变量 {
case 值1:
    // do something
case 值2:
    // do something
default:
    // do something
}

注意,与其他语言不同,Go 中的 switch 语句不需要 break,一旦条件符合自动终止。

但是如果需要继续执行下一个条件,可以使用 fallthrough 关键字。

switch 变量 {
case 值1:
    // do something
    fallthrough
case 值2:
    // do something
default:
    // do something
}

对于相同结论的条件,可以使用逗号分隔。

switch 变量 {
case 值1, 值2:
    // do something
default:
    // do something
}

case 中使用布尔表达式是常用的技巧。

switch {
case a > 0:
    // do something
case a < 0:
    // do something
default:
    // do something
}

避免嵌套 if

可以使用下面的技巧来避免嵌套 if

  • 尽快返回:让条件不满足的分支直接 return。

  • 提取函数:将条件判断提取到函数中。

循环控制

for

Go 中只提供了 for 循环,没有 while 循环。

但是提供了三种 for 循环的写法。

无限循环
for {
    // do something
}

例如:

for {
    fmt.Println("Hello, World!")
}

如果想要终止循环,可以使用 break 关键字。

for {
    fmt.Println("Hello, World!")
    break
}
条件循环
for 条件 {
    // do something
}

例如:

var i int = 0
 
for i < 10 {
    fmt.Println(i)
    i++
}
计数循环
for 初始语句; 条件; 结束语句 {
    // do something
}

例如:

for i := 0; i < 10; i++ {
    fmt.Println(i)
}
循环中的控制语句

break:终止循环。

continue:跳过当前循环,继续下一次循环。

for i := 0; i < 10; i++ {
    if i == 5 {
        continue
    }
    fmt.Println(i)
}

表示当 i 等于 5 时,跳过当前循环,继续下一次循环。

for-range

for-range 可以用来遍历数组、切片、字符串、map 等。

for index, value := range array {
    // do something
}

忽略 index 参数:

for _, value := range array {
    // do something
}

跳转控制

Go 中可以使用 labelgoto 来实现跳转。

label:
    // do something
goto label

例如:

var i int = 0
 
label:
    fmt.Println(i)
    i++
    if i < 10 {
        goto label
    }

表示当 i 小于 10 时,跳转到 label 标签处。

也可以使用 goto 来跳出多层循环。

for i := 0; i < 10; i++ {
    for j := 0; j < 10; j++ {
        if j == 5 {
            goto breakTag
        }
    }
}
 
breakTag:
    fmt.Println("done")

模块和包

  • 一个模块(module)可以视作一个项目。一个模块中所有的 Go 文件可以共用一份依赖配置,模块可以看作顶层文件夹。
  • 文件夹:位于模块之下,文件夹可以有若干层级。
  • .go 文件:位于文件夹之下,一个文件夹可以有若干个 .go 文件。
  • 函数:位于 .go 文件之下,一个 .go 文件可以有若干个函数。函数是业务逻辑的实现主体。

模块与文件夹

模块是一个独立项目的顶层文件夹,相对于普通的文件夹,模块文件夹有一个配置文件 go.mod

我们可以通过 go mod init 命令来初始化一个模块。

$ go mod init hello
go: creating new go.mod: module hello

打开 go.mod 文件,可以看到内容如下:

module hello
 
go 1.15

分别展示了模块的名称和 Go 的版本。

若我们在 hello 文件夹下创建一个 main.go 文件,利用 beego 框架创建一个简单的 Web 服务。

package main
 
import "github.com/astaxie/beego"
 
func main() {
    beego.Run()
}

在这里:

  • github.com/astaxie/beego 是一个第三方包,我们可以通过 go get 命令来下载。
$ go get github.com/astaxie/beego

执行这个命令后,会在 go.mod 文件中添加一行:

require {
    ...
    github.com/astaxie/beego v1.12.3
}

除了这一行之外,还会出现其他的依赖包,这些包是由 beeego 间接引入的。

同一包下不同文件中的函数调用

在同一包下的不同文件中,可以直接调用其他文件中的函数:

// /demo/main.go
package main
 
import "fmt"
 
func main() {
    printHello()
}
 
// /demo/hello.go
package main
 
import "fmt"
 
func printHello() {
    fmt.Println("Hello, World!")
}

执行 go run demo,可以看到输出结果:

$ go run demo
Hello, World!

或者可以用 go run . 来执行当前目录下的所有文件。

注意,这里的 import "fmt" 用于导入其他包,fmt 并不是包名,而是一个相对路径,意即扫描目录下的所有代码。

不同包下的函数调用

在不同包下的文件中,需要通过 import 导入其他包,然后通过 包名.函数名 的方式调用其他包中的函数:

// demo/note/note.go
package note
 
import "fmt"
 
func PrintHello() {
    fmt.Println("Hello, World!")
}
// demo/main.go
package main
 
import "demo/note"  // 导入 note 包的所在文件夹
 
func main() {
    note.PrintHello() // 这里的 note 是导入的包名,不一定和文件夹名一致
}

注意,包名和文件夹名可以不一致。包名最终是由 package 关键字指定的。

包的别名

在导入包时,可能会出现包名相同的情况,这时可以使用别名来区分。

// demo/main.go
 
import (
    "fmt"
    m "demo/note"  // 使用 m 作为别名
)
 
func main() {
    m.PrintHello()
}

init 函数

在 Go 语言中,每个包中都可以包含一个 init 函数,用于在包被导入时执行一些初始化操作。

// demo/note/note.go
package note
 
import "fmt"
 
func init() {
    fmt.Println("note 包被导入")
}
 
func PrintHello() {
    fmt.Println("Hello, World!")
}
// demo/main.go
 
import (
    "fmt"
    "demo/note"
)
 
func main() {
    fmt.Println("Hello, World!")
}

执行 go run main.go,可以看到输出结果:

note 包被导入
Hello, World!

匿名包

在导入包时,如果不需要使用包中的函数,可以使用 _ 来代替包名。

// demo/main.go
 
import (
    "fmt"
    _ "demo/note"  // 使用 _ 代替包名
)
 
func main() {
    fmt.Println("Hello, World!")
}

为什么不使用函数却导入包?这是因为在导入包时,包中的 init 函数会被执行,这样可以在 init 函数中进行一些初始化操作。

为什么不直接 import "demo/note",而是使用 _ 代替包名?这是因为 Go 语言中不允许导入包而不使用,使用 _ 代替包名可以避免这个问题。

函数

函数在 Go 语言中地位非常重要。

函数的声明

Go 中的函数定义格式为:

func 函数名(参数列表) (返回值列表) {
    // do something
}

例如:

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

如果参数类型相同,可以简写为:

func add(a, b int) int {
    return a + b
}
 
func main() {
    fmt.Println(add(1, 2))
}

Go 支持多个返回值,例如:

// 求和并求差
func sumAndSub(a, b int) (int, int) {
    return a + b, a - b
}
 
func main() {
    sum, sub := sumAndSub(1, 2)
    fmt.Println(sum, sub)
}

和 JS 类似,我们可以通过声明变量的形式来声明函数:

func main() {
    add := func(a, b int) int {
        return a + b
    }
    fmt.Println(add(1, 2))
}

这个函数被称为匿名函数,因为没有函数名。

函数的参数

正如之前提到的,函数的参数传入时的策略是完全复制传入的参数。

对于值类型,传入的是值的拷贝。对于引用类型,传入的是引用的拷贝。在函数中修改值类型的参数不会影响原值,但是修改引用类型的参数会影响原值。

package main
 
import "fmt"
 
func changeElement(arr [5]int) {
    arr[0] = 10
}
 
func changeElementByPointer(arr *[5]int) {
    arr[0] = 10
}
 
func main() {
    arr := [5]int{1, 2, 3, 4, 5}
    changeElement(arr)
    fmt.Println("changeElement:", arr) // [1 2 3 4 5]
 
    changeElementByPointer(&arr)
    fmt.Println("changeElementByPointer:", arr) // [10 2 3 4 5]
}

在考虑修改值时,我们可以依然传入值类型,无非是将修改后的内容返回。

package main
 
import "fmt"
 
func changeElement(arr [5]int) [5]int {
    arr[0] = 10
    return arr
}
 
func main() {
    arr := [5]int{1, 2, 3, 4, 5}
    arr = changeElement(arr)
    fmt.Println(arr) // [10 2 3 4 5]
}

函数的返回值

Go 中的函数支持多个返回值,例如在这个求最大值和最小值的函数中:

func maxAndMin(arr []int) (int, int) {
    max, min := arr[0], arr[0]
    for _, v := range arr {
        if v > max {
            max = v
        }
        if v < min {
            min = v
        }
    }
    return max, min
}
 
func main() {
    arr := []int{1, 2, 3, 4, 5}
    max, min := maxAndMin(arr)
    fmt.Println(max, min)
}

返回的多个值可以被忽略:

func main() {
    max, _ := maxAndMin(arr)
    fmt.Println(max)
}

我们也可以在返回的声明中提前声明返回值的变量名,这样我们就不用操心需要返回的变量名了:

func maxAndMin(arr []int) (max int, min int) {  // 这里声明了返回值的变量名
    max, min = arr[0], arr[0]
    for _, v := range arr {
        if v > max {
            max = v
        }
        if v < min {
            min = v
        }
    }
    return  // 这里不需要再写返回值
}

很多编程语言不支持多个返回值,然而 Go 天然支持多个返回值。

将函数作为变量

有返回值的函数可以被赋值给变量:

func add(a, b int) int {
    return a + b
}
 
func main() {
    addFunc := add  // 将 add 函数赋值给 addFunc 变量,变量就具有了函数的功能
    fmt.Println(addFunc(1, 2))  // 3
}

这样看上去毫无意义!但是在日常开发中我们常常提到开闭原则,即对于扩展我们是开放的,对于修改我们是封闭的。这样的设计可以让我们在不修改原有代码的情况下,扩展功能。试想有这样的场景:

我们希望完成一个 operate 函数,这个函数接受两个参数,一个是操作符,一个是操作数,然后根据操作符进行操作。一般来讲我们可以这样写:

package main
 
import "fmt"
 
func add(a, b int) int {
    return a + b
}
 
func sub(a, b int) int {
    return a - b
}
 
func mul(a, b int) int {
    return a * b
}
 
func div(a, b int) int {
    return a / b
}
 
func operate(op string, a, b int) (res int) {
    switch op {
      case "+":
          res = add(a, b)
      case "-":
          res = sub(a, b)
      case "*":
          res = mul(a, b)
      case "/":
          res = div(a, b)
    }
    return
}

现在的代码毫无问题,但是如果我们希望添加一个 mod 函数,我们就需要修改 operate 函数,这样就违反了开闭原则。我们的修改可能会影响到其他地方,这是我们不希望看到的。

我们可以将函数暂存到一个 map 中,然后根据操作符取出对应的函数

package main
 
import "fmt"
 
var operateFuncs make(map[string]func(x, y int) int)  // 定义一个 map,key 为 string,value 为函数(func(x, y int) int)
 
func init() {
    operateFuncs["+"] = add
    operateFuncs["-"] = sub
    operateFuncs["*"] = mul
    operateFuncs["/"] = div
    operateFuncs["%"] = mod
}
 
func add(a, b int) int {
    return a + b
}
 
func sub(a, b int) int {
    return a - b
}
 
func mul(a, b int) int {
    return a * b
}
 
func div(a, b int) int {
    return a / b
}
 
func mod(a, b int) int {
    return a % b
}
 
func operate(op string, a, b int) int {
    if f, ok := operateFuncs[op]; ok {
        return f(a, b)
    }
    return 0
}
 
func main() {
    fmt.Println(operate("+", 1, 2))  // 3
    fmt.Println(operate("-", 1, 2))  // -1
    fmt.Println(operate("*", 1, 2))  // 2
    fmt.Println(operate("/", 1, 2))  // 0
    fmt.Println(operate("%", 1, 2))  // 1
}

这样我们就可以在不修改 operate 函数的情况下,添加新的操作符。

注:上面的 if f, ok := operateFuncs[op]; ok 是 Go 语言中的一个特殊写法,foperateFuncs[op] 的值,ok 是一个布尔值,表示是否存在这个值这是 map 会携带的一个返回值。其写法可以拆解为:

f, ok := operateFuncs[op]
if ok {
    return f(a, b)
}
return 0

匿名函数和闭包

如果一个函数只在特定位置出现且不用考虑复用,我们可以使用匿名函数。

func main() {
    add := func(a, b int) int {
        return a + b
    }
    fmt.Println(add(1, 2))
}

或者:

func invoke(f func()) {
    fmt.Println("before")
    f()
    fmt.Println("after")
}
 
func main() {
    invoke(func() {
        fmt.Println("invoke")
    })
}
 
// before
// invoke
// after

闭包是匿名函数的一个重要特性,它会引用外部环境中的变量,其内部操作会对外部环境产生副作用。闭包相当于封装了一个环境。例如可通过闭包修改局部变量的值:

func main() {
    i := 0
    add := func() {
        i++
    }
    add()
    fmt.Println(i)  // 1
}

如果变量 i 只与闭包函数有关,那么我们可以抽取闭包函数:

func getAnonymouseFunc() func() {
    i := 0
    return func() {
        i++
        fmt.Println(i)
    }
}
 
func main() {
    add := getAnonymouseFunc()
    add()  // 1
    add()  // 2
}

这与我们想象的不同,我们可能会认为每次调用 add 函数都会初始化 i 为 0,但是这里我们接连调用两次 add 函数,i 的值会保留且递增。这里就能凸显闭包的意义

即闭包中的局部变量与普通函数不同,它内部内存分配在堆上,而不是栈上,所以不会随着函数的调用而销毁。由于匿名函数一致引用了 i,所以 i 的也不会被垃圾回收。正如“闭包”一词所言,它封闭了一个环境,形成了一个独立的小王国,具有一直驻留在内存的环境变量的特性。

举一个更贴近实际的例子,当我们需要一个计数器时,例如用户访问一次网站,计数器加一,我们可以使用闭包:

func counter() func() int {
    i := 0
    return func() int {
        i++
        return i
    }
}
 
func main() {
    c := counter()
    fmt.Println(c())  // 1
    fmt.Println(c())  // 2
    fmt.Println(c())  // 3
}

这和直接使用全局变量有什么区别呢?闭包的好处在于封装,我们可以将计数器的逻辑封装在一个函数中,而不用担心外部环境对计数器的影响。

强制转换

Go 提供了函数的强制转换语法。

package main
 
import "fmt"
 
type add func(a, b int) int
 
func addFunc(a, b int) int {
    return a + b
}
 
func main() {
    var f add = addFunc
    fmt.Println(f(1, 2))
}

这里我们定义了一个 add 类型,它是一个函数类型,接受两个 int 类型的参数,返回一个 int 类型的值。然后我们定义了一个 addFunc 函数,它符合 add 类型的定义。最后我们将 addFunc 函数赋值给 f 变量,这样 f 就具有了 add 类型的功能。

假设有下面的函数类型 F1:

type F1 func(int, int) int

创建一个函数 A1:

func A1(c,d int) int {
    return c+d
}

A1 遵循 F1 的签名。

a1 := F1(A1)
a2 := F1(A1)

通过上面的操作,我们得到了两个函数对象 a1 和 a2,它们都是 F1 类型的。我们可以对 a1 和 a2 分别进行调用:

fmt.Println(a1(1,2)) // 3
 
fmt.Println(a2(3,4)) // 7

有了类型和强制转换之后,我们不仅可以像传递普通变量一样传递函数对象,还可与为函数绑定方法,而绑定后的方法可以被函数对象调用,相当于为函数拓展了功能。如:

type F1 func(a,b int) int
 
func (f F1) show(a,b,c int) {
    fmt.Println("Show Called")
}
 
func (f F1) calc(a,b,c int) int {
    f(a,b)
}

我们为 F1 类型绑定了两个方法 show 和 calc,每个方法具有三个参数,都不能返回值。show 方法只是打印一行信息,而 calc 方法调用了 F1 类型的函数对象。

package main
 
import "fmt"
 
type F1 func(a,b int) int
 
func (f F1) show(a,b,c int) {
    fmt.Println("Show Called")
}
 
func (f F1) calc(a,b,c int) int {
    f(a,b)
    fmt.Println("Calc Called")
}
 
func A1(c,d int) int {
    return c+d
}
 
func main() {
    // 因为 A1 遵循 F1 的签名,所以可以强制转换
    a1 := F1(A1)
    // 变量 a1 是 F1 类型的,那么就自动拥有了 F1 类型的方法
    a1.show(1,2,3)
    a1.calc(1,2,3)
}

还是回到之前的加减乘除的例子,四个函数的定义如下:

func add(a, b int) int {
    return a + b
}
 
func sub(a, b int) int {
    return a - b
}
 
func mul(a, b int) int {
    return a * b
}
 
func div(a, b int) int {
    return a / b
}

这四个函数的声明类型是相同的,我们可以定义一个二元运算的函数类型:

type BinaryOperationFunc func(a, b int) int

然后给该类型绑定一个 calc 方法:

func (f BinaryOperationFunc) calc(a, b int) int {
    return f(a, b)
}

这样我们就可以通过强制转换将四个函数转换为 BinaryOperationFunc 类型,然后调用 calc 方法:

package main
 
import "fmt"
 
type BinaryOperationFunc func(a, b int) int
 
func (f BinaryOperationFunc) calc(a, b int) int {
    return f(a, b)
}
 
func add(a, b int) int {
    return a + b
}
 
func sub(a, b int) int {
    return a - b
}
 
func mul(a, b int) int {
    return a * b
}
 
func div(a, b int) int {
    return a / b
}
 
func main() {
    addFunc := BinaryOperationFunc(add)
    subFunc := BinaryOperationFunc(sub)
    mulFunc := BinaryOperationFunc(mul)
    divFunc := BinaryOperationFunc(div)
 
    fmt.Println(addFunc.calc(1, 2))  // 3
    fmt.Println(subFunc.calc(1, 2))  // -1
    fmt.Println(mulFunc.calc(1, 2))  // 2
    fmt.Println(divFunc.calc(1, 2))  // 0
}

这样的话,我们就可以通过一个函数类型来统一管理四个函数,而不用为每个函数定义一个计算方法。

defer

Go 中的 defer 关键字用于延迟执行函数。

func main() {
    defer fmt.Println("Hello, World!")
    fmt.Println("Hello, Go!")
}

输出结果:

Hello, Go!
Hello, World!

defer 关键字会将函数推迟到外层函数返回之后执行。

延迟函数的参数会被立即计算,但是不会被执行。

func main() {
    i := 0
    defer fmt.Println(i)
    i++
}

输出结果:

0

而不是 1。

异常处理

异常是指程序在执行过程中发生的错误,会导臹程序终止。Go 语言提供了一种机制来处理运行时错误,这种机制被称为异常处理。

创建异常

Go 内置了一个 error 类型,用于表示异常。error 类型是一个接口类型,定义如下:

type error interface {
    Error() string  // Error 方法,返回异常信息
}

使用 errors 包的 New 函数可以创建一个异常:

package main
 
import (
    "errors"
    "fmt"
)
 
func main() {
    err := errors.New("this is an error")
    fmt.Println(err)
}

抛出异常

Go 语言没有提供 throw 关键字来抛出异常,而是使用 panic 函数来抛出异常。panic 函数接收一个任意类型的参数,通常是一个字符串,用于描述异常信息。

func handleError() {
    panic(errors.New("this is an error"))
}
 
func main() {
 
    fmt.Println("start")
 
    handleError()
 
    fmt.Println("end")
}

输出结果:

start
panic: this is an error

goroutine 1 [running]:
main.handleError()
        /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:8 +0x3b
main.main()
          /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:13 +0x2d
exit status 2

自定义异常

Panic 函数允许用户自定义异常。

func panic(v any)

即 panic 函数接收任意类型的参数,可以是任意类型的值。

package main
 
import (
    "fmt"
)
 
func main() {
    panic("this is an error")
}

倘若想要携带错误码等更多的信息,可以使用结构体。

package main
 
import (
    "fmt"
)
 
type MyError struct {
    Code int
    Msg  string
}
 
func handleError() {
    e := MyError{Code: 100, Msg: "this is an error"}
 
    panic(e)
}
 
func main() {
 
    fmt.Println("start")
 
    handleError()
 
    fmt.Println("end")
}

输出结果:

start

panic: (*main.MyError) 0xc0000b2000

goroutine 1 [running]:
main.handleError()
        /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:10 +0x3b
main.main()
          /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:15 +0x2d
exit status 2

可以看到,输出的异常信息是 (*main.MyError) 0xc0000b2000,这是因为 panic 函数只能接收字符串类型的参数,没有按照我们希望的方式输出异常信息。我们可以实现 error 接口来自定义异常。

package main
 
import (
    "fmt"
)
 
type MyError struct {
    Code int
    Msg  string
}
 
func (e MyError) Error() string {
    return fmt.Sprintf("Code: %d, Msg: %s", e.Code, e.Msg)
}
 
func handleError() {
    e := MyError{Code: 100, Msg: "this is an error"}
 
    panic(e)
}
 
func main() {
 
    fmt.Println("start")
 
    handleError()
 
    fmt.Println("end")
}

输出结果:

start

panic: Code: 100, Msg: this is an error

goroutine 1 [running]:
main.handleError()
        /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:10 +0x3b
main.main()
          /Users/jetzihan/Code/go/src/github.com/jetzihan/go-note/note/golang/error.go:20 +0x2d
exit status 2

捕获异常

Go 语言提供了 recover 函数来捕获异常。recover 函数只能在 defer 函数中调用,用于捕获异常。

func recover() any

recover 函数返回一个 interface{} 类型的值,表示捕获的异常信息。如果没有异常被抛出,recover 函数返回 nil。正确的做法是在 defer 函数中调用 recover 函数,然后判断返回值是否为 nil,如果不为 nil,则表示捕获到了异常。

package main
 
import (
    "fmt"
)
 
func handleError() {
    panic("this is an error")
}
 
func main() {
 
    fmt.Println("start")
 
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("recover:", err)
        }
    }()
 
    handleError()
 
    fmt.Println("end")
}