GO语言基础
GO语言基础
一、绪论
1 Go语言介绍
Go 即Golang,是Google公司2009年11月正式对外公开的一门编程语言。
Go是静态强类型语言,是区别于解析型语言的编译型语言(静态:类型固定 强类型:不同类型不允许直接运算)。
解析型语言——源代码是先翻译为中间代码,然后由解析器对代码进行解释执行。
编译型语言——源代码编译生成机器语言,然后由机器直接执行机器码即可执行。
2 Go语言特性
-
跨平台的编译型语言
-
交叉编译(在win平台可编译出mac平台的可执行文件)
-
语法接近C语言
-
管道(channel),切片(slice),并发(routine)
-
有垃圾回收的机制
-
支持面向对象和面向过程的编程模式
3 go适合做什么
- 服务端开发
- 分布式系统,微服务
- 网络编程
- 区块链开发
- 内存KV数据库,例如boltDB、levelDB
- 云平台
4 下载和安装
开发环境:
-
官网下载地址为:https://golang.org/dl/
-
如果打不开可以使用这个地址:https://golang.google.cn/dl/
IDE:
-
GoLand
-
vscode
5 配置GOPATH和GOROOT
- GOPATH:代码存放路径,该目录下有三个文件夹(如果没有,要手动创建),
windows和mac默认在用户名下的go文件夹(GOPATH=“/Users/用户名/go”),所有代码必须写在这个路径下的src文件夹下,否则无法编译,可以手动修改。
1 | -src——源码(包含第三方的和自己项目的) |
-
GOROOT:go开发工具包的安装路径,默认:C:\go
将GOROOT下的bin路径加入环境变量(默认已处理),这样任意位置敲 go 都能找到该命令
6 命令介绍
直接在终端中输入 go help
即可显示所有的 go 命令以及相应命令功能简介,主要有下面这些:
- build: 编译包和依赖
- clean: 移除对象文件(go clean :删除编译的可执行文件)
- doc: 显示包或者符号的文档
- env: 打印go的环境信息
- bug: 启动错误报告
- fix: 运行go tool fix
- fmt: 运行gofmt进行格式化(go fmt :自动将代码格式)
- generate: 从processing source生成go文件
- get: 下载并安装包和依赖(go get github.com/astaxie/beego:下载beego框架)
- install: 编译并安装包和依赖(go install 项目名:会将go编译,并放到bin路径下)
- list: 列出包
- run: 编译并运行go程序
- test: 运行测试
- tool: 运行go提供的工具
- version: 显示go的版本
- vet: 运行go tool vet
6.1 build 和 run 命令
就像其他静态类型语言一样,要执行 go 程序,需要先编译,然后在执行产生的可执行文件。go build
命令就是用来编译 go程序生成可执行文件的。但并不是所有的 go 程序都可以编译生成可执行文件的, 要生成可执行文件,go程序需要满足两个条件:
- 该go程序需要属于main包
- 在main包中必须还得包含main函数
也就是说go程序的入口就是 main.main
, 即main包下的main函数, 例子(test.go):
编译hello.go,然后运行可执行程序:
1 | $ go build test.go # 将会生成可执行文件 test |
上面就是 go build 的基本用法,另外如果使用 go build 编译的不是一个可执行程序,而是一个包,那么将不会生成可执行文件。
而 go run
命令可以将上面两步并为一步执行(不会产生中间文件)。
1 | $ go run test.go |
上面两个命令都是在开发中非常常用的。
此外 go clean 命令,可以用于将清除产生的可执行程序:
1 | $ go clean # 不加参数,可以删除当前目录下的所有可执行文件 |
6.2 get 命令
这个命令同样也是很常用的,我们可以使用它来下载并安装第三方包, 使用方式:
1 | go get src |
从指定源上面下载或者更新指定的代码和依赖,并对他们进行编译和安装,例如我们想使用 beego 来开发web应用,我们首先就需要获取 beego:
1 | go get github.com/astaxie/beego |
这条命令将会自动下载安装 beego 以及它的依赖,然后我们就可以使用下面的方式使用:
1 | package main |
7 第一个go程序
1 | // go语言的注释 |
bulid命令编译,再执行exe:
或者使用run命令,编译并执行:
1 | go run 1.go |
8 命名规范
Go语言中的函数名、变量名、常量名、类型名、语句标号和包名等所有的命名,都遵循一个简单的命名规则:
1 一个名字必须以一个字母(Unicode字母)或下划线开头,后面可以跟任意数量的字母、数字或下划线
2 大写字母和小写字母是不同的:Name和name是两个不同的变量
3 关键字和保留字都不建议用作变量名
Go语言中关键字有25个;关键字不能用于自定义名字,只能在特定语法结构中使用。
1 | break default func interface select |
go语言中有37个保留字,主要对应内建的常量、类型和函数
1 | 内建常量: true false iota nil |
注意:
- 这些保留字并不是关键字,可以在定义中重新使用它们。在一些特殊的场景中重新定义它们也是有意义的,但是也要注意避免过度而引起语义混乱
- 如果一个名字是在函数内部定义,那么它就只在函数内部有效。如果是在函数外部定义,那么将在当前包的所有文件中都可以访问。名字的开头字母的大小写决定了名字在包外的可见性。如果一个名字是大写字母开头的(必须是在函数外部定义的包级名字;包级函数名本身也是包级名字),那么它将是导出的,也就是说可以被外部的包访问,例如fmt包的Printf函数就是导出的,可以在fmt包外部访问。包本身的名字一般总是用小写字母
- 名字的长度没有逻辑限制,但是Go语言的风格是尽量使用短小的名字,对于局部变量尤其是这样;你会经常看到i之类的短名字,而不是冗长的theLoopIndex命名。通常来说,如果一个名字的作用域比较大,生命周期也比较长,那么用长的名字将会更有意义
- 在习惯上,Go语言程序员推荐使用 驼峰式 命名,当名字由几个单词组成时优先使用大小写分隔,而不是优先用下划线分隔。因此,在标准库有QuoteRuneToASCII和parseRequestLine这样的函数命名,但是一般不会用quote_rune_to_ASCII和parse_request_line这样的命名。而像ASCII和HTML这样的缩略词则避免使用大小写混合的写法,它们可能被称为htmlEscape、HTMLEscape或escapeHTML,但不会是escapeHtml
- go文件的名字,建议用下划线的方式命名(参见go源码)
二、变量和常量
1 定义单个变量
1 | func main(){ |
2 定义多个变量
Go 能够通过一条语句声明多个变量。
1 | // 定义多个变量var name1, name2 type = initialvalue1, initialvalue |
在有些情况下,我们可能会想要在一个语句中声明不同类型的变量。其语法如下:
1 | var ( |
使用上述语法,下面的程序声明不同类型的变量。
1 | package main |
3 注意
简短声明的语法要求 := 操作符的左边至少有一个变量是尚未声明的。考虑下面的程序:
1 | package main |
在上面程序中的第 8 行,由于 b 已经被声明,而 c 尚未声明,因此运行成功
但是如果我们运行下面的程序
1 | package main |
变量也可以在运行时进行赋值。考虑下面的程序:
1 | package main |
在上面的程序中,c 的值是运行过程中计算得到的,即 a 和 b 的最小值
4 常量
常量是一个简单值的标识符,在程序运行时,不会被修改的量。
常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。
常量的定义格式:
1 | const identifier [type] = value |
你可以省略类型说明符 [type],因为编译器可以根据变量的值来推断其类型。
- 显式类型定义:
const b string = "abc"
- 隐式类型定义:
const b = "abc"
多个相同类型的声明可以简写为:
1 | const c_name1, c_name2 = value1, value2 |
顾名思义,常量不能再重新赋值为其他的值。因此下面的程序将不能正常工作,它将出现一个编译错误: cannot assign to a.
。
1 | package main |
常量的值会在编译的时候确定。因为函数调用发生在运行时,所以不能将函数的返回值赋值给常量。
5 iota
iota,特殊常量,可以认为是一个可以被编译器修改的常量。
iota 在 const关键字出现时将被重置为 0(const 内部的第一行之前),const 中每新增一行常量声明将使 iota 计数一次(iota 可理解为 const 语句块中的行索引)。
iota 可以被用作枚举值:
1 | const ( |
第一个 iota 等于 0,每当 iota 在新的一行被使用时,它的值都会自动加 1;所以 a=0, b=1, c=2 可以简写为如下形式:
1 | const ( |
实例:
1 | package main |
以上实例运行结果为:
1 | 0 1 2 ha ha 100 100 7 8 |
三、运算符
1 算数运算符
下表列出了所有Go语言的算术运算符。假定 A 值为 10,B 值为 20。
运算符 | 描述 | 实例 |
---|---|---|
+ | 相加 | A + B 输出结果 30 |
- | 相减 | A - B 输出结果 -10 |
* | 相乘 | A * B 输出结果 200 |
/ | 相除 | B / A 输出结果 2 |
% | 求余 | B % A 输出结果 0 |
++ | 自增 | A++ 输出结果 11 |
– | 自减 | A-- 输出结果 9 |
2 关系运算符
下表列出了所有Go语言的关系运算符。假定 A 值为 10,B 值为 20。
运算符 | 描述 | 实例 |
---|---|---|
== | 检查两个值是否相等,如果相等返回 True 否则返回 False。 | (A == B) 为 False |
!= | 检查两个值是否不相等,如果不相等返回 True 否则返回 False。 | (A != B) 为 True |
> | 检查左边值是否大于右边值,如果是返回 True 否则返回 False。 | (A > B) 为 False |
< | 检查左边值是否小于右边值,如果是返回 True 否则返回 False。 | (A < B) 为 True |
>= | 检查左边值是否大于等于右边值,如果是返回 True 否则返回 False。 | (A >= B) 为 False |
<= | 检查左边值是否小于等于右边值,如果是返回 True 否则返回 False。 | (A <= B) 为 True |
3 逻辑运算符
下表列出了所有Go语言的逻辑运算符。假定 A 值为 True,B 值为 False。
运算符 | 描述 | 实例 |
---|---|---|
&& | 逻辑 AND 运算符。 如果两边的操作数都是 True,则条件 True,否则为 False。 | (A && B) 为 False |
|| | 逻辑 OR 运算符。 如果两边的操作数有一个 True,则条件 True,否则为 False。 | (A || B) 为 True |
! | 逻辑 NOT 运算符。 如果条件为 True,则逻辑 NOT 条件 False,否则为 True。 | !(A && B) 为 True |
4 位运算符
位运算符对整数在内存中的二进制位进行操作。
下表列出了位运算符 &, |, 和 ^ 的计算:
p | q | p & q | p | q | p ^ q |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
0 | 1 | 0 | 1 | 1 |
1 | 1 | 1 | 1 | 0 |
1 | 0 | 0 | 1 | 1 |
Go 语言支持的位运算符如下表所示。假定 A 为60,B 为13:
运算符 | 描述 | 实例 |
---|---|---|
& | 按位与运算符"&"是双目运算符。 其功能是参与运算的两数各对应的二进位相与。 | (A & B) 结果为 12, 二进制为 0000 1100 |
| | 按位或运算符"|"是双目运算符。 其功能是参与运算的两数各对应的二进位相或 | (A | B) 结果为 61, 二进制为 0011 1101 |
^ | 按位异或运算符"^"是双目运算符。 其功能是参与运算的两数各对应的二进位相异或,当两对应的二进位相异时,结果为1。 | (A ^ B) 结果为 49, 二进制为 0011 0001 |
<< | 左移运算符"<<“是双目运算符。左移n位就是乘以2的n次方。 其功能把”<<“左边的运算数的各二进位全部左移若干位,由”<<"右边的数指定移动的位数,高位丢弃,低位补0。 | A << 2 结果为 240 ,二进制为 1111 0000 |
>> | 右移运算符">>“是双目运算符。右移n位就是除以2的n次方。 其功能是把”>>“左边的运算数的各二进位全部右移若干位,”>>"右边的数指定移动的位数。 | A >> 2 结果为 15 ,二进制为 0000 1111 |
5 赋值运算符
下表列出了所有Go语言的赋值运算符。
运算符 | 描述 | 实例 |
---|---|---|
= | 简单的赋值运算符,将一个表达式的值赋给一个左值 | C = A + B 将 A + B 表达式结果赋值给 C |
+= | 相加后再赋值 | C += A 等于 C = C + A |
-= | 相减后再赋值 | C -= A 等于 C = C - A |
*= | 相乘后再赋值 | C *= A 等于 C = C * A |
/= | 相除后再赋值 | C /= A 等于 C = C / A |
%= | 求余后再赋值 | C %= A 等于 C = C % A |
<<= | 左移后赋值 | C <<= 2 等于 C = C << 2 |
>>= | 右移后赋值 | C >>= 2 等于 C = C >> 2 |
&= | 按位与后赋值 | C &= 2 等于 C = C & 2 |
^= | 按位异或后赋值 | C ^= 2 等于 C = C ^ 2 |
|= | 按位或后赋值 | C |= 2 等于 C = C | 2 |
6 其它运算符
下表列出了Go语言的其他运算符。
运算符 | 描述 | 实例 |
---|---|---|
& | 返回变量存储地址 | &a; 将给出变量的实际地址。 |
* | 指针变量。 | *a; 是一个指针变量 |
7 运算符优先级
有些运算符拥有较高的优先级,二元运算符的运算方向均是从左至右。下表列出了所有运算符以及它们的优先级,由上至下代表优先级由高到低:
优先级 | 运算符 |
---|---|
5 | * / % << >> & &^ |
4 | + - | ^ |
3 | == != < <= > >= |
2 | && |
1 | || |
当然,你可以通过使用括号来临时提升某个表达式的整体运算优先级。
四、数据类型
下面是 Go 支持的基本类型:
- bool 布尔型
- 数字类型
- int8, int16, int32, int64, int 整型
- uint8, uint16, uint32, uint64, uint 无符号整型
- float32, float64 浮点型
- complex64, complex128 复数
- byte 类似 uint8
- rune 类似 int32
- string 字符串类型
1 bool
bool 类型表示一个布尔值,值为 true 或者 false
2 有符号整型
int8:表示 8 位有符号整型
大小:8 位
范围:-128~127
int16:表示 16 位有符号整型
大小:16 位
范围:-32768~32767
int32:表示 32 位有符号整型
大小:32 位
范围:-2147483648~2147483647
int64:表示 64 位有符号整型
大小:64 位
范围:-9223372036854775808~9223372036854775807
int:根据不同的底层平台(Underlying Platform),表示 32 或 64 位整型。除非对整型的大小有特定的需求,否则你通常应该使用 int 表示整型。
大小:在 32 位系统下是 32 位,而在 64 位系统下是 64 位。
范围:在 32 位系统下是 -2147483648~2147483647,而在 64 位系统是 -9223372036854775808~9223372036854775807。
3 无符号整型
uint8:表示 8 位无符号整型
大小:8 位
范围:0~255
uint16:表示 16 位无符号整型
大小:16 位
范围:0~65535
uint32:表示 32 位无符号整型
大小:32 位
范围:0~4294967295
uint64:表示 64 位无符号整型
大小:64 位
范围:0~18446744073709551615
uint:根据不同的底层平台,表示 32 或 64 位无符号整型。
大小:在 32 位系统下是 32 位,而在 64 位系统下是 64 位。
范围:在 32 位系统下是 0~4294967295,而在 64 位系统是 0~18446744073709551615。
4 浮点型
float32:32 位浮点数
float64:64 位浮点数
5 复数类型
complex64:实部和虚部都是 float32 类型的的复数。
complex128:实部和虚部都是 float64 类型的的复数。
内建函数 complex用于创建一个包含实部和虚部的复数。complex 函数的定义如下:
1 | func complex(r, i FloatType) ComplexType |
该函数的参数分别是实部和虚部,并返回一个复数类型。实部和虚部应该是相同类型,也就是 float32 或 float64。如果实部和虚部都是 float32 类型,则函数会返回一个 complex64 类型的复数。如果实部和虚部都是 float64 类型,则函数会返回一个 complex128 类型的复数。
还可以使用简短语法来创建复数:
1 | c := 6 + 7i |
6 string 类型
在 Golang 中,字符串是字节的集合。如果你现在还不理解这个定义,也没有关系。我们可以暂且认为一个字符串就是由很多字符组成的。我们后面会在一个教程中深入学习字符串。 下面编写一个使用字符串的程序。
1 | package main |
上面程序中,first 赋值为字符串 “Naveen”,last 赋值为字符串 “Ramanathan”。+ 操作符可以用于拼接字符串。我们拼接了 first、空格
和 last,并将其赋值给 name。上述程序将打印输出 My name is Naveen Ramanathan
。
7 类型转换
Go 有着非常严格的强类型特征。Go 没有自动类型提升或类型转换。
1 | package main |
上面的代码在 C 语言中是完全合法的,然而在 Go 中,却是行不通的。i 的类型是 int ,而 j 的类型是 float64 ,我们正试图把两个不同类型的数相加,Go 不允许这样的操作。如果运行程序,你会得到 main.go:10: invalid operation: i + j (mismatched types int and float64)
。
要修复这个错误,i 和 j 应该是相同的类型。在这里,我们把 j 转换为 int 类型。把 v 转换为 T 类型的语法是 T(v)。
1 | package main |
现在,当你运行上面的程序时,会看见输出 122
。
赋值的情况也是如此。把一个变量赋值给另一个不同类型的变量,需要显式的类型转换。下面程序说明了这一点。
1 | package main |
在第 9 行,i 转换为 float64 类型,接下来赋值给 j。如果不进行类型转换,当你试图把 i 赋值给 j 时,编译器会抛出错误。
8 数据类型的默认值
如果只定义不赋值,则:
- 数字类型是0
- 字符串类型是空字符串
- 布尔类型是false
1 | func main() { |
五、条件语句
1 if…else语句
1 | func main() { |
2 switch语句
switch 语句用于基于不同条件执行不同动作,每一个 case 分支都是唯一的,从上至下逐一测试,直到匹配为止。
switch 语句执行的过程从上至下,直到找到匹配项,与c语言不同,匹配项后面也不需要再加 break。
switch 默认情况下 case 最后自带 break 语句,匹配成功后就不会执行其他 case,如果我们需要执行后面的 case,可以使用
fallthrough 。
1 | func main() { |
六、循环语句
Go没有while循环语句,只有for循环。
for 循环是一个循环控制结构,可以执行指定次数的循环。
Go 语言的 For 循环有 3 种形式,只有其中的一种使用分号。
和 C 语言的 for 一样:
1 | for init; condition; post { } |
和 C 的 while 一样:
1 | for condition { } |
和 C 的 for(;;) 一样:
1 | for { } |
- init: 一般为赋值表达式,给控制变量赋初值;
- condition: 关系表达式或逻辑表达式,循环控制条件;
- post: 一般为赋值表达式,给控制变量增量或减量。
for 循环的range格式可以对 slice、map、数组、字符串等进行迭代循环。格式如下:
1 | for key, value := range oldMap { |
计算 1 到 10 的数字之和:
1 | package main |
init 和 post 参数是可选的,我们可以直接省略它,类似 While 语句。
1 | func main() { |
无限循环
1 | func main() { |
-
关键字break
break 语句用于在完成正常执行之前突然终止 for 循环,之后程序将会在 for 循环下一行代码开始执行。
-
关键字continue
continue 语句用来跳出 for 循环中当前循环。在 continue 语句后的所有的 for 循环语句都不会在本次循环中执行。循环体会在一下次循环中继续执行。
七、函数
函数是基本的代码块,用于执行一个任务。
Go 语言至少有一个入口main() 函数。
1 函数定义
函数定义的格式如下:
1 | func function_name( [parameter list] ) [return_types] { |
函数定义解析:
- func:函数由 func 开始声明
- function_name:函数名称,参数列表和返回值类型构成了函数签名。
- parameter list:参数列表,参数就像一个占位符,当函数被调用时,你可以将值传递给参数,这个值被称为实际参数。参数列表指定的是参数类型、顺序、及参数个数。参数是可选的,也就是说函数也可以不包含参数。声明一个参数的语法采用 参数名 参数类型 的方式,多个参数的声明用逗号隔开。
- return_types:返回类型,规定了函数的返回值类型return_types。有些功能不需要返回值,这种情况下 return_types 不是必须的。
- 函数体:函数定义的代码集合。
举例:
1 | // 定义一个函数 |
2 函数调用
调用函数,(如果需要参数)向函数传递参数,(如果有返回值)并得到返回值,例如:
1 | func main() { |
得到输出:b比a大\n20
3 其它
调用函数,可以通过两种方式来传递参数:值传递和引用传递。
默认情况下,Go 语言使用的是值传递,即在调用过程中不会影响到实际参数。
Go 语言支持一个函数可以有多个返回值。如果一个函数有多个返回值,那么这些返回值必须用括号括起来。
1 | func swap(x, y string) (string, string) { //这些返回值必须用括号括起来 |
还可以在定义的时候命名返回值,从函数中可以返回一个命名值。
1 | func rectProps(length, width float64)(area, perimeter float64) { |
下划线_
在 Go 中被用作空白符,可以用作表示任何类型的任何值。
1 | func swap(x, y string) (string, string) { |
4 函数作为另外一个函数的实参
Go 语言可以很灵活的创建函数,并作为另外一个函数的实参。
1 | func square(a int) int { |
5 闭包和匿名函数
Go 语言支持匿名函数,可作为闭包。匿名函数是一个"内联"语句或表达式。匿名函数的优越性在于可以直接使用函数内的变量,不必申明。
1 | func getSequence() func() int { |
以上代码执行结果为:
1 | 1 |
八、数组
Go 语言提供了数组类型的数据结构。
数组是同一类型元素的集合,这种类型可以是任意的原始类型例如整型、字符串或者自定义类型。例如,整数集合 5,8,9,79,76 形成一个
数组。Go 语言中不允许混合不同类型的元素,例如包含字符串和整数的数组。(当然,如果是 interface{} 类型数组,可以包含任意类
型)
1 声明数组
Go 语言数组声明需要指定元素类型及元素个数,语法格式如下:
1 | var variable_name [SIZE] variable_type |
以上为一维数组的定义方式。举例:
1 | var a [3]int |
var a[3]int 声明了一个长度为 3 的整型数组。数组中的所有元素都被自动赋值为数组类型的零值,在这种情况下,a
是一个整型数组,
因此 a
的所有元素都被赋值为 0
,即 int 型的零值。运行上述程序将 输出 [0 0 0]
。
以下定义了数组 balance 长度为 10 类型为 float32:
1 | var balance [10] float32 |
数组的索引从 0
开始到 length - 1
结束。
2 初始化数组
先声明,再赋值:
1 | var arr [5]int // 声明数组 |
声明并赋值:
1 | var arr = [5]int{1,2,3,4,5} // 全定义 |
如果数组长度不确定,可以使用 ...
代替数组的长度,编译器会根据元素个数自行推断数组的长度:
1 | var balance = [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0} |
如果设置了数组的长度,我们还可以通过指定下标来初始化元素:
1 | // 将索引为 1 和 3 的元素初始化 |
初始化数组中 {}
中的元素个数不能大于[]
中的数字。
如果忽略[]
中的数字不设置数组大小,Go 语言会根据元素的个数来设置数组的大小:
1 | // []内可以不写,自动推算 |
数组一经声明 ,长度固定,无法改变。
Go 中的数组是值类型而不是引用类型。这意味着当数组赋值给一个新的变量时,该变量会得到一个原始数组的一个副本。如果对新变量
进行更改,则不会影响原始数组。
同样,当数组作为参数传递给函数时,它们是按值传递,而原始数组保持不变。
3 访问数组元素
数组元素可以通过索引(位置)来读取。格式为数组名后加中括号,中括号中为索引的值(从0开始)。
1 | arr := []int{1,2,3,4,5} |
4 多维数组
Go 语言支持多维数组,以下为常用的多维数组声明方式:
1 | var variable_name [SIZE1][SIZE2]...[SIZEN] variable_type |
以下实例声明了三维的整型数组:
1 | var threedim [5][10][4]int |
多维数组可通过大括号来初始值。以下实例为一个 3 行 4 列的二维数组:
1 | a := [3][4]int{ |
5 数组长度
通过将数组作为参数传递给 len
函数,可以得到数组的长度。
1 | arr := []int{1,2,3,4,5} |
6 数组的遍历
我们可以使用类c语言经典的遍历方法:
1 | arr := [5]int{1,2,3,4,5} |
Go 提供了一种更好、更简洁的方法,通过使用 for
循环的 range
方法来遍历数组。range
返回索引和该索引处的值。让我们使用 range 重写上面的代码。我们还可以获取数组中所有元素的总和。
1 | arr := [5]int{11,22,33,44,55} |
如果你只需要值并希望忽略索引,则可以通过用 _
空白标识符替换索引来执行。
九、包
到目前为止,我们看到的 Go 程序都只有一个文件,文件里包含一个 main 函数和几个其他的函数。在实际中,这种把所有源代码编写在
一个文件的方法并不好用。以这种方式编写,代码的重用和维护都会很困难。而包(Package)解决了这样的问题。
包用于组织 Go 源代码,提供了更好的可重用性与可读性。由于包提供了代码的封装,因此使得 Go 应用程序易于维护。
1 main包
所有可执行的 Go 程序都必须包含一个 main 函数。这个函数是程序运行的入口。main 函数应该放置于 main 包中。
1 | package main |
2 创建包
首先,go的所有代码必须放在gopath下的src目录下,包的导入也是从scr开始搜索。
我们在src下新建一个test文件夹,下面创建两个go文件。
1 | - src |
两个go文件内部的第一行,定义包:
1 | // 1.go |
注意:同一个文件夹下的文件只能有一个包名,否则编译报错,这里1.go和2.go的包名必须相同。
当定义了包之后,同一个包的变量名和函数名是唯一的,它们也可以直接使用。
在 Go 中,任何以大写字母开头的变量或者函数都是被导出的名字。其它包只能访问被导出的函数和变量。
我们把abc.go定义为main包,在main函数内使用定义好的test包:
1 | // abc.go |
3 init函数
所有包都可以包含一个 init
函数。init 函数不应该有任何返回值类型和参数,在我们的代码中也不能显式地调用它。init 函数的形式如下:
1 | func init() { |
init 函数可用于执行初始化任务,也可用于在开始执行之前验证程序的正确性。
包的初始化顺序如下:
- 首先初始化包级别(Package Level)的变量
- 紧接着调用 init 函数。包可以有多个 init 函数(在一个文件或分布于多个文件中),它们按照编译器解析它们的顺序进行调用。
如果一个包导入了另一个包,会先初始化被导入的包。
尽管一个包可能会被导入多次,但是它只会被初始化一次。
4 其它
导入了包,却不在代码中使用它,这在 Go 中是非法的。当这么做时,编译器是会报错的。其原因是为了避免导入过多未使用的包,从而
导致编译时间显著增加。
然而,在程序开发的活跃阶段,又常常会先导入包,而暂不使用它。遇到这种情况就可以使用空白标识符 _
。
1 | package main |
有时候我们导入一个包,只是为了确保它进行了初始化,而无需使用包中的任何函数或变量,这种情况也可以使用空白标识符。
十、Go包管理
1 使用GO111MODULE
在1.11版本后,推出 modules 机制,简称 mod,用于包管理。
使用 Go modules 之前需要环境变量 GO111MODULE,命令行输入go env
可以 打印go的环境信息。
- GO111MODULE=off: 不使用 modules 功能,查找vendor和GOPATH目录
- GO111MODULE=on: 使用 modules 功能,不会去 GOPATH 下面查找依赖包。
- GO111MODULE=auto: Golang 自己检测是不是使用 modules 功能,如果当前目录不在$GOPATH 并且 当前目录(或者父目录)下有go.mod文件,则使用 GO111MODULE, 否则仍旧使用 GOPATH mode
我们这里将GO111MODULE设置为on
1 | set GO111MODULE=on //windows |
我们在gopath之外,新建一个demo文件夹作为我们的项目根目录
切换到根目录下执行命令,然后会生成一个 go.mod
1 | go mod init demo |
这里比较关键的就是这个 go.mod 文件,这个文件中标识了我们的项目的依赖的 package 的版本。执行 init 暂时还没有将所有的依赖管
理起来。我们需要将程序 run 起来(比如执行 go run/test),或者 build(执行命令 go build)的时候,才会触发依赖的解析。
这里我们下载一个第三方包作为测试:
1 | go get github.com/beego/beego/v2@v2.0.0 |
当开启了mod模式之后,包不会下载到src目录下
go.mod文件内会有 module 和 require 模块,除此之外还可以包含 replace 和 exclude 模块。
下载的 package 并不是直接存储到 GOPATH/src,而是存储到 GOPATH/pkg/mod 下面。
1 | // go.mod |
有indirect注释的代表间接依赖,没有的代表直接依赖,这里是版本号+时间戳+hash
2 自己写的包如何导入
直接在go.mod中写包名即可
3 go vender
1.5 版本推出了 vendor 机制。所谓 vendor 机制,就是每个项目的根目录下可以有一个 vendor 目录,里面存放了该项目的依赖的 package。go build
的时候会先去 vendor 目录查找依赖,如果没有找到会再去 GOPATH 目录下查找。
1 | go mod vendor |
4 使用代理GoProxy
有些 Golang 的 package 在国内是无法直接 go get 的。
GoProxy 相当于官方提供了一种 proxy 的方式让用户来进行包下载。要使用 GoProxy 只需要设置环境变量 GOPROXY
即可。目前公开的
GOPROXY 有:
- goproxy.io 在goland–>Go–>Go Modules(ego)中配置这个地址
- goproxy.cn: 由七牛云提供,参考 github repo
这里介绍Goland使用代理:
进入GoLand—>settings—>Go—>Go modules
完成。
十一、切片
Go 语言切片是对数组的抽象,因此切片是引用类型。但自身是结构体,值拷贝传递。
Go 数组的长度不可改变,在特定场景中这样的集合就不太适用,Go 中提供了一种灵活,功能强悍的内置类型切片(“动态数组”),与数组
相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
切片本身不拥有任何数据,它是对现有数组的引用。
切片遍历方式和数组一样,可以用len()求长度。表示可用元素数量,读写操作不能超过该限制。
1 切片的定义
你可以声明一个未指定大小的数组来定义切片,切片不需要说明长度。
1 | var 变量名 []类型 |
或使用 make() 函数来创建切片:
1 | var slice []type = make([]type, len) |
2 创建切片的各种方式
1 | func main() { |
一个切片在未初始化之前默认为 nil,长度为 0。
3 切片初始化
1 | s :=[] int {1,2,3 } |
直接初始化切片,[] 表示是切片类型,{1,2,3} 初始化值依次是 1,2,3,其 cap=len=3。
1 | var arr = [...]int{0,1,2,3,4,5,6,7,8,9,} |
举例:
1 | var arr = [...]int{0,1,2,3,4,5,6,7,8,9,} |
4 len() 和 cap() 函数
切片是可索引的,并且可以由 len() 方法获取长度。
切片提供了计算容量的方法 cap() 可以测量切片最长可以达到多少。
以下为具体实例:
1 | package main |
5 追加切片元素
正如我们已经知道数组的长度是固定的,它的长度不能增加。 切片是动态的,使用 append
可以将新元素追加到切片上。append 函数的定义是
1 | func append(s[]T,x ... T)[]T |
如果切片由数组支持,并且数组本身的长度是固定的,那么切片如何具有动态长度。以及内部发生了什么?
当新的元素被添加到切片时,会创建一个新的数组。现有数组的元素被复制到这个新数组中,并返回这个新数组的新切片引用。
新切片的容量是旧切片的两倍。
举例:
1 | cars := []string{"Ferrari", "Honda", "Ford"} |
6 切片的长度和容量
切片的长度是切片中的元素数。
切片的容量是从创建切片索引位置开始的底层数组中元素数。
7 copy() 函数和内存优化
切片持有对底层数组的引用。只要切片在内存中,数组就不能被垃圾回收。在内存管理方面,这是需要注意的。让我们假设我们有一个非
常大的数组,我们只想处理它的一小部分。然后,我们由这个数组创建一个切片,并开始处理切片。这里需要重点注意的是,在切片引用
时数组仍然存在内存中。
一种解决方法是使用 [copy] 函数 func copy(dst,src[]T)int
来生成一个切片的副本。这样我们可以使用新的切片,原始数组可以被
垃圾回收。
1 | func countries() []string { |
8 切片的函数传递
切片在内部可由一个结构体类型表示。这是它的表现形式,
1 | type slice struct { |
切片包含长度、容量和指向数组第零个元素的指针。当切片传递给函数时,即使它通过值传递,指针变量也将引用相同的底层数组。因
此,当切片作为参数传递给函数时,函数内所做的更改也会在函数外可见。
十二、map
1 map集合
Map 是一种无序的键值对的集合。Map 最重要的一点是通过 key 来快速检索数据。Map 是一种集合,所以我们可以像迭代数组和切片那样迭代它。
2 创建map
声明一个map集合
1 | var map_variable map[key_data_type]value_data_type |
上面的例子中,如果声明但不初始化 map,那么就会创建一个 nil map
。nil map
不能用来存放键值对,如果你添加键值,编译器会提示panic: assignment to entry in nil map
错误,所以需要使用 make
函数初始化。
1 | var countryCapitalMap map[string]string // 声明一个nil集合 |
可以使用类型推导,省去声明的步骤,直接用make函数创建集合
1 | map_variable := make(map[key_data_type]value_data_type) |
你也可以在声明时初始化集合,比如
1 | money := map[string]int { |
3 添加元素
你可以使用中括号为map赋值,要注意key不能重复
1 | countryCapitalMap["k1"] = "v1" |
也可以声明时初始化赋值
1 | status := map[int]string{ |
4 获取元素
获取map中元素的方法很简单,使用中括号取值即可。
1 | status := map[int]string{ |
如果获取一个不存在的元素,会返回一个0
而不会返回错误。
1 | student := make(map[string]int) |
要查看元素在集合中是否存在,可以这样做:
1 | value, ok := student["bbb"] |
5 遍历map
可以使用for range
语法遍历map,但是每次遍历都是无序的,我们无法决定它的返回顺序,这是因为 Map 是使用哈希表来实现的。
1 | countryCapitalMap := map[string]string{"France": "Paris", "Italy": "Rome", "Japan": "Tokyo", "India": "New delhi"} |
6 delete函数删除元素
要删除map中的元素,使用delete函数,语法是 delete(map, key)
1 | student := make(map[string]int) |
7 获取map的长度
使用len函数获取map长度
1 | countryCapitalMap := map[string]string{"France": "Paris", "Italy": "Rome", "Japan": "Tokyo", "India": "New delhi"} |
8 map的函数传递
集合map可以作为参数传递。map和切片一样是引用类型,当map作为参数传递给函数时,即使是值传递,也将引用相同的底层数据结构,函数内所做的更改也会在函数外可见。
1 | func add(m map[string]int) { |
十三、指针
相比于c或c++,go语言的指针是很容易学习的,它可以更简单地执行一些任务。
1 取地址操作符&
变量是一种使用方便的占位符,用于引用计算机内存地址,使用&
符号获取变量的内存地址,如下:
1 | func main() { |
2 指针的定义
指针是一个变量,这个变量存储了另一个变量的内存地址。
在使用指针前,你需要声明指针。指针声明格式如下
1 | var var_name *var-type |
举例:
1 | var ptr1 *string // 指向字符串 |
当一个指针被定义后没有分配到任何变量时,它的初始值为nil,也被称为空指针
1 | func main() { |
3 使用指针
定义指针后,需要给指针变量赋值,使用*
操作符可以获取指针指向的内容(也称解引用)。
1 | func main() { |
4 指针的函数传递
指针也是一个变量,可以向函数传递。
1 | func add(ptr *int) { |
5 指针运算
与c和c++不同,go中的指针不支持对指针运算,比如指针的递增和递减。
1 | a := 10 |
6 多级指针
已知指针本身也是变量,指针存放的是变量的内存地址。所以,可以让一个指针存放另一个指针变量的地址,此时称这个指针变量为指向指针的指针(二级指针)。
1 | a := 10 |
如上面的例子,二级指针解引用时,需要使用两个*
同理,你可以“无限套娃”下去,定义多级指针。
下面是一个三级指针、四级指针的例子:
1 | a := 10 |
十四、结构体
结构体是由一系列具有相同类型或不同类型的数据构成的数据集合。比如你需要定义一些互相有联系的变量,而变量之间的类型不同。
举个实际的例子,保存图书馆的书籍记录,每本书有以下属性:title
,author
,price
,此时就可以使用结构体类型。
1 定义结构体
结构体定义需要使用type
和struct
语句。struct
语句定义一个新的数据类型,结构体中有一个或多个成员。type
语句设定了结构体的名称。
1 | type struct_variable_type struct { |
一旦定义了结构体,就可以将它作为变量的声明
1 | variable_name := structure_variable_type { key1: value1, key2: value2..., keyn: valuen} |
示例:
1 | type Books struct { |
2 访问结构体成员
果要访问结构体成员,需要使用点.
操作符。
1 | type Books struct { |
3 结构体成员赋值
可以在声明时赋值;也可以先声明,再使用.
操作符赋值。
1 | type Books struct { |
你可以为结构体的部分成员赋值,此时剩下的成员会被赋值为零值。
4 结构体的导出
定义结构体及其成员时,仍遵循大写导出,小写不导出的规则。如下:
1 | type Books struct { // 大写结构体名字,可以导出,外部包可以访问 |
如果想要让这个结构体所有成员被外部访问,需要将它们大写。
5 结构体的函数传递
你可以像其他数据类型一样将结构体类型作为参数传递给函数,并且可以访问内部成员。
1 | type Books struct { |
在函数内修改结构体,原来的结构体不会改变。
6 结构体指针与结构体的函数引用传递
要想在函数内更改结构体,可以利用结构体指针。相当于结构体的引用传递。
1 | type Books struct { |
7 匿名结构体
我们之前定义的结构体称为命名结构体,因为我们创建了新的类型;匿名结构体是是一个变量,不创建新的类型。
1 | func main() { |
也可以在声明同时赋值
1 | func main() { |
8 结构体嵌套
结构体内部还可以定义结构体。示例:
1 | type Book struct { |
9 匿名字段
定义结构体时,成员字段可以只有类型而没有字段名,这种形式的结构体字段为匿名字段。
1 | // 定义匿名字段 |
如果字段没有名称,那么默认它的名称为它的类型。比如string
字段和int
字段。
10 结构体比较
结构体是值类型(通过函数传递也可以看出来),如果它的所有字段都可比较,则该结构体也是可比较的;如果结构体包含不可比较的字段,则结构体也不可比较。
11 字段提升
如果结构体中有匿名结构体类型的字段,该匿名结构体里的字段就称为提升字段。这些字段自动提升,可以用外部结构体直接访问。
1 | type Book struct { |
如上,Book
中有一个匿名字段author
,author
是结构体类型,包含name
和age
两个成员,那么我们通过Book就可以访问到它们。
十五、方法
1 方法定义
go语言中同时有函数和方法。一个方法就是一个包含了接收器的函数,接收器一般是结构体类型中的字段,也可以是非结构体。它的定义如下:
1 | func (variable_name variable_data_type) function_name() [return_type]{ |
通俗来说就是定义一个函数,指定一个接收器来接收这个函数。
通过一个示例学习方法的使用:
1 | type Book struct { |
在上面的示例中,我们定义一个结构体含两个字段;然后定义一个函数,指定接收器为book,此时相当于在Book类型上绑定了一个方法。我们在main函数中可以通过.
操作符调用这个方法。
2 值接收器和指针接收器
在上面的示例中我们发现,printprice
方法想让price
自增2,但实际上原结构体并不会被改变,这是因为我们使用了值接收器。
我们如果想要在方法内更改字段并使得它对调用者可见,需要使用指针接收器。比如下面的例子:
1 | type Book struct { |
使用指针接收器后,在 printprice
方法中对 Book
结构体的字段 price
所做的改变对调用者是可见的,换句话说在方法内修改值,原来的值也会随之改变。
另外补充一点,如果你有c编程的经验,你可能注意到,在操作结构体的字段时,如果在c或c++中,需要先对指针解引用;而在go中不同,指针直接使用.
操作符就可以取到字段。
另外,go可以直接使用指针操作,而c/c++不可以,对比c++代码来理解:
1 | struct Book { |
那么该如何选择值接收器与指针接收器?如果你想要方法所做的改变对调用者可见,那么就使用指针接收器;否则就使用值接收器。另外,值接收器需要对原结构体拷贝,如果结构体字段很大,那么付出的内存消耗也大。
3 字段方法提升
和结构体的字段提升一样,接收器是匿名字段的结构体,可以直接在外部使用它的方法。
1 | type Book struct { |
十六、接口
1 定义接口
在面向对象中,接口规范了一个对象的行为,接口指定对象必须实现的方法,而实现的细节由对象自己去完成。go语言中同样提供接口,它把所有的具有共性的方法定义在一起,只要任何其他类型只要实现了这些方法就是实现了这个接口。
接口的定义语法如下:
1 | type interface_name interface { |
接口是方法的集合,要实现这个接口就必须实现这些方法。示例:
1 | // 定义接口 |
上面定义了一个Animal
接口,要求实现两个方法。然后定义一个Dog
结构体,为它绑定这两个方法,go与java不同,不需要显式声明实现的接口,只要这个类型实现了接口的所有方法,就默认实现了这个接口。但是,这样定义与之前为结构体绑定方法没有区别,把接口去掉程序仍然可以正常工作。
2 接口的作用
接口的实际应用是实现多态。接口是一种数据类型,所以我们可以定义接口类型的变量,然后将其它实现接口的结构赋值给它,实现多态。
示例:
1 | // 定义接口 |
接口是动态的,就像切片是对数组的动态引用一样,接口也是类似的工作方式。
上面的程序中,我们定义了Animal
接口,且Bird
和Dog
都实现了这个接口,然后我们在main函数中,先定义一个未赋值的接口。首先将Bird
指定给它,此时animal的动态类型就是Bird
,此时访问animal
接口时,只会返回动态值的类型(eat方法),它的静态类型(Name
字段)保持隐藏。然后,我们将Dog
指定给它,此时的动态类型就是Dog
,我们访问的eat
方法就是绑定给Dog
的方法,由此实现了多态。
3 类型断言
先介绍类型断言的语法:
1 | value, ok := i.(Type) |
上节提到接口不能访问低层的静态字段,我们可以使用类型断言来获取这些基础值。
1 | // 定义接口 |
其中,类型断言如果正确,就将此时的底层值赋值给animal
,将true
赋值给ok
;反之,animal
为该类型的零值,ok=false
。
4 多接口
一个类型可以实现多个接口。例:
1 | // 定义接口 |
上面例子中,Dog实现了两个接口,也就意味着可以将Dog
类型的值赋给Animal
或Habit
接口类型的变量。
5 空接口
当接口没有方法时,它被称为空接口。 除了有名的空接口,还可以定义匿名的。
匿名空接口的定义为 interface{}
,一般用于函数的形式参数。空接口不规范任何方法,也就意味着所有类型都实现了空接口。举例说明:
1 | type str string |
我们自定义一个str
类型,还有一个int
类型的数据,将它传递给foo
,由于任何类型都实现空接口,空接口作为函数的参数可以保存任何类型的值,所以这个函数可以接收所有类型的数据。
6 接口嵌套
顾名思义,举例
1 | // 定义接口 |
定义的Dog
接口里,包含Animal
和Habit
,所以要实现Dog
必须实现这些接口(类似于面向对象的继承)
十七、面向对象
go不是一个完全面向对象的语言,甚至没有类的概念。但是,这并不意味着go不支持面向对象,因为面向对象只是一种实现形式,只要实现了封装继承和多态,在使用上与其他面向对象语言就没有不同。
在go中,可以使用结构体取代类,使用接口实现多态,使用匿名字段提升实现继承。所以,go是完全可以做到面型对象编程的,毕竟面向对象只是编程思想。
1 | type Animal struct { |
我们可以这样定义,定义一个结构体,New
函数返回这个结构体类型,直接调用New
创建对象,达到与java等语法类似的效果。
十八、并发编程
1 并发和并行
并发是指多个事件在同一时间间隔内发生,或者说并发是具有同时处理多个任务的能力。
并行是指多个事件在同一时刻发生,注意同一时刻
和同一时间间隔
的区别。并发在宏观上看上去,一段时间内多个任务同时执行,但是在每一时刻,单处理机只能有一道程序执行,只是操作系统会不断的切换多个任务。
举个例子,如果你在0-1分钟切菜,1-2分钟洗水果,2-3分钟切菜,那么在0-3分钟的时间间隔里,切菜和洗水果是并发的;如果你在0-3分钟的每一时刻,左手切菜,右手洗水果,那么这两个动作就是并行的。下图可以帮助你更好地理解:
2 go协程
线程又被称为轻量级进程,它是程序执行的最小单元,线程不拥有系统资源(只拥有一点运行中必须的资源),它可以与同属于一个进程的其他线程共享进程资源,线程的切换由操作系统调度。
协程又称微线程,协程运行在用户态下,由于在进程切换时会消耗很多资源,协程则避免了这个问题,协程完全由程序自己控制,操作系统并不会感知。在go中,创建一个go协程的开销很小,你可以创建千百个协程并发运行。
go协程称为goroutine,go通过goroutine支持并发,goroutine是go中最基本的执行单元。
生成一个goroutine的方式非常简单,使用go
关键字就创建了:
1 | import ( |
我们创建了10个go协程,它们与main函数并发执行,main函数运行在主协程上。正如上文所说,go协程的开销很小(几kb),所以可以创建很多goroutine,多个goroutine调度的顺序是乱序的。
另外,在主进程中我们sleep
等待了三秒,如果不等待会发生什么呢?尝试把它去掉,你会发现程序什么都没有输出。这就是go协程的性质,主协程不会等待go协程执行完毕。在调用协程之后,主协程会立即执行代码的下一行,忽略该协程的任何返回值。如果主协程结束运行,则其他协程也不会继续运行。所以我们暂且使用sleep
等待其他协程执行完毕,才结束主协程,这样就能看到输出。
3 goroutine的GMP调度模型
goroutine的整体调度模型如下图:
其中:
- G指goroutine,在全局有一个Global队列,每当创建一个goroutine就把它入队
- M指machine,它代表一个用户级线程,所有的G任务,最终还是在M上执行
- P指processor处理器,它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine。它处在G和M之间,直接与M交互。P默认就是CPU可用的核数,可以通过
runtime.GOMAXPROCS
修改(一般不修改)。
调度的过程是这样的,当通过go
关键字创建一个新的goroutine的时候,它会优先被放入P的本地队列。为了运行goroutine,M需要持有(绑定)一个P,接着M会启动一个内核级线程,循环从P的本地队列里取出一个goroutine并执行。当M执行完了当前P的Local队列里的所有G后,P会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。
4 channel定义
channel(信道/通道)是go的一种重要特性,类似于管道,其本质是一个先进先出的队列,它实现了goroutine之间的通信,多个goroutine可同时修改一个channel。
channel是一个变量,使用chan
来定义。
1 | var 变量名 chan 类型 |
channel是引用类型,和其他引用类型一样,channel 的空值为nil。channel一定要初始化后才能进行读写操作,否则会永久阻塞,所以一般使用make创建channel。
1 | ch := make(chan int) |
5 使用channel进行通信
初始化一个channel,就可以在里面取值放值,
1 | ch := make(chan int) |
默认情况下向channel发送数据和接收数据会阻塞,举例:
1 | func foo(ch chan int) { |
读取数据推荐方法:
1 | a, ok := <-ch |
6 关闭channel
go提供了内置的close函数对channel进行关闭操作。
1 | ch := make(chan int) |
注意以下几点:
-
关闭一个未初始化(nil) 的channel会产生panic
-
重复关闭同一个channel会产生 panic
-
向一个已关闭的channel中发送消息会产生 panic
-
从已关闭的channel读取消息不会产生panic,且能读出channel中还未被读取的消息,若消息均已读出,则会读到类型的零值。从一个已关闭的channel中读取消息永远不会阻塞,并且会返回一个为 false的ok-idiom,可以用它来判断 channel 是否关闭。
-
关闭channel会产生一个广播机制,所有向channel读取消息的goroutine都会收到消息。接收方可以用ok来检查信道是否已经关闭
7 死锁
当go协程给一个channel发送数据时,如果没有其他go协程来接收数据,就会形成死锁。同理,当有go协程等待从一个channel接收数据时,如果没有人发送,也会死锁。
8 单向channel
channel类型是可以带有方向的:
1 | var readOnlyChan <-chan int // 只读chan,只能读channel,不可写入 |
可能你有疑问,前面说过不管读写都会阻塞,如果定义只读或者只写channel不会发生死锁吗?实际上,单独定义确实没有意义,所以一般在主goroutine中定义读写channel,然后在一些函数参数传递时候指明可读还是可写。比如:
1 | func foo(ch <-chan int) { // 定义只读channel 只负责读,不能写 |
9 遍历channel
可以使用for range
遍历信道:
1 | func producer(ch chan int) { |
1 | // 上述代码执行结果为: |
可以看到,for range
一直从信道中尝试接收数据,如果信道没有关闭也没有数据就阻塞等待,一旦某个goroutine发送数据,就立刻取出。直到信道关闭时,循环会自动结束。
10 缓冲channel
带缓冲区channel:定义声明时候制定了缓冲区长度,可以保存多个数据。只在缓冲已满的情况,发送数据时才会阻塞;同样,只有在缓冲为空的时候,接收数据才会阻塞。
不带缓冲区channel:发送数据就阻塞,直到有goroutine读;读取时也会阻塞,直到有goroutine写。
定义一个带缓冲的channel很简单:
1 | ch := make(chan type, [capacity])// 创建channel信道的语法,capacity可选,如果不写默认为0 |
要让一个信道有缓冲,上面语法中的 capacity
应该大于 0,一旦定义了缓冲channel,在缓冲满之前不会阻塞。
11 waitgroup
使用waitgroup
可以实现同步,即等待所有的goroutine执行完成。举个例子:
1 | func foo(i int) { |
如果直接执行,主协程不会等待go协程执行完毕,所以看不到任何输出。我们可以定义一个waitgroup
:
1 | var wg sync.WaitGroup |
WaitGroup
对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait()
用来控制计数器的数量。我们在上述代码的基础上使用WaitGroup
:
1 | func foo(i int, wg *sync.WaitGroup) { |
输出结果为
1 | // 顺序是不固定的 |
另外,Add(delta int)
函数中,参数delta可以为负值;但是如果计数器为负值,会提示panic: sync: negative WaitGroup counter
错误。
12 Mutex
go提供了mutex用多个goroutine的互斥访问。互斥问题的场景很多,比如并发更新同一个资源、并发更新计数器、秒杀活动等等,这些都需要保证数据准确。首先要明确临界资源的概念。不论是进程、线程还是goroutine,互斥问题的概念都是相通的:我们把一次仅允许一个goroutine使用的资源称为临界资源。为了保证临界资源的正确使用,可以划分为四部分
- 进入区:在进入临界区之前,负责阻止其它goroutine同时进入临界区
- 临界区:使用临界资源
- 退出区:某个goroutine退出临界区之后,负责更改临界区状态,允许其它goroutine访问临界区
- 剩余区:其余部分
实现互斥访问的方式有很多,mutex是互斥锁,可确保在某时刻只有一个goroutine在临界区运行,以防止出现竞态条件。
在go的标准库中,package sync
提供了锁相关的一系列同步原语,还定义了一个Locker
的接口,mutex就实现了这个接口。
1 | type Locker interface { |
使用mutex解决互斥问题:
1 | func incr(wg *sync.WaitGroup, m *sync.Mutex) { // mutex需要引用传递 |
十九、错误、异常处理
本章吐槽:go的异常处理实在是太难用了,一度让我写得十分反胃。
1 error
go中我们通常会在函数或方法中返回error
结构对象来判断是否有异常出现。go内置的错误类型error
是一个接口类型,自定义的错误类型必须实现它:
1 | type error interface { |
使用errors.New
可返回一个错误信息:
1 | err := errors.New("错误消息") |
例:
1 | func Sqrt(f float64) (float64, error) { |
在上面代码中,我们定义一个简单的开方函数,如果被开方数为负就输出一个错误。
一般按照如下方法使用:
- 如果函数需要处理异常,通常将error作为多值返回的最后一个值,返回的error值为nil则表示无异常,非nil则是有异常。
- 一般先用if语句处理
error!=nil
,把正常逻辑写在后面。
另外你会发现,返回错误并不会终止程序的运行。
2 panic
error是错误,panic是异常。错误指的是可能出现问题的地方出现了问题,比如打开一个文件时失败,这种情况在人们的意料之中 ;而异常指的是不应该出现问题的地方出现了问题,比如引用了空指针,这种情况在人们的意料之外。可见,错误是业务过程的一部分,而异常不是 。与error不同,panic会终止程序的运行,在执行完所有的defer函数后,程序控制返回到该函数的调用方。这样的过程会一直持续下去,直到当前goroutine的所有函数都返回退出,然后程序会打印出 panic 信息,接着打印出堆栈跟踪(Stack Trace),最后程序终止。
1 | func main() { |
上述代码中,使用panic
抛出异常后,第三个打印语句不会执行。
3 defer
defer是go语言中的延迟执行语句,用来添加函数结束时执行的代码。
1 | func main() { |
当一个函数内多次调用defer时,go会把defer调用放入到一个栈中,在函数结束之前依次出栈,因此输出结果是逆序。
与panic结合使用:
1 | func main() { |
在抛出异常之前,再逆序执行所有的defer。
4 recover
panic会使后面的语句终止,recover可以让程序能正常运行下去。只有在defer函数的内部,调用 recover才有用。在defer函数内调用 recover,可以取到 panic的错误信息,并且停止 panic 续发事件(Panicking Sequence),程序运行恢复正常。recover的使用如下:
1 | func f1() { |