语法基础
基本语法
Go 的基本语法十分简单易懂,让我们从一个最简单的例子开始。
package main
import "fmt"
func main() {
fmt.Println("Hello 世界!")
}2
3
4
5
6
7
package关键字声明了是当前 go 文件属于哪一个包,入口文件都必须声明为main包,入口函数是main函数,在自定义包和函数时命名应当尽量避免与之重复。import是导入关键字,后面跟着的是被导入的包名。func是函数声明关键字,用于声明一个函数。fmt.Println("Hello 世界!")是一个语句,调用了fmt包下的Println函数进行输出。
以上就是一个简单的语法介绍,下面就来略微细致地去了解里面的概念。
包
在 Go 中,程序是通过将包链接在一起来构建的。Go 中进行导入的最基本单位是一个包,而不是.go文件。包其实就是一个文件夹,英文名 package,包内共享所有变量,常量,以及所有定义的类型。包的命名风格建议都是小写字母,并且要尽量简短。
可见性
前面提到过包内共享所有变量,常量,以及所有定义的类型,但对于包外而言并不是这样,有时候你并不想让别人访问某一个类型,所以就需要控制可见性。你可能在其它 OOP 语言中见过public,private等关键字,不过在 Go 语言中没有这些,它控制可见性的方式非常简单,规则如下
- 名称大写字母开头,即为公有类型/变量/常量
- 名字小写或下划线开头,即为私有类型/变量/常量
比如下面的一个例子,常量MyName就是公开的,而常量mySalary就是私有的。
package example
// 公有
const MyName = "jack"
// 私有
const mySalary = 20_0002
3
4
5
6
7
这个可见性的规则适用于整个 Go 语言的任何地方。
导入
导入一个包来使用这个包中的类型/方法/函数/变量,导入的语法就是import加上包名
package main
import "example"2
3
当导入多个包时,你可以这么写
package main
import "example"
import "example1"2
3
4
也可以用括号括起来,下面这种方法在实践中更加常用。
package main
import (
"example"
"example1"
)2
3
4
5
6
如果有包名重复了,或者包名比较复杂,你也可以给它们起别名
package main
import (
e "example"
e1 "example1"
)2
3
4
5
6
别名为下划线_时就是匿名导入,匿名导入的包无法被使用,这么做通常是为了加载包下的init函数,但又不需要用到包中的类型,一个常见的例子就是注册数据库驱动,但是你并不需要去手动使用驱动。
package main
import (
e "example"
_ "mysql-driver-go"
)2
3
4
5
6
当你导入后,想要访问包中的类型时,通过包名.标识符去访问即可,比如下面这个例子,若你尝试去访问一个私有的类型,编译器就会告诉你无法访问。
package main
import (
"example"
"fmt"
)
func main() {
fmt.Println(example.MyName)
}2
3
4
5
6
7
8
9
10
有一种特殊的导入方式就是将该包中的所有类型都导入到当前包作用域,以这种方法导入的类型不再需要.运算符去访问,但是如果有重名的类型将会无法通过编译。
package main
import (
. "example"
)2
3
4
5
Go 中无法进行循环导入,不管是直接的还是间接的。例如包 A 导入了包 B,包 B 也导入了包 A,这是直接循环导入,包 A 导入了包 C,包 C 导入了包 B,包 B 又导入了包 A,这就是间接的循环导入,存在循环导入的话将会无法通过编译。
内部包
go 中约定,一个包内名为internal包为内部包,外部包将无法访问内部包中的任何内容,否则的话编译不通过,下面看一个例子。
└─ /home/user/go/
└─ src/
├─ crash/
│ └─ bang/ (go code in package bang)
│ └─ b.go
└─ foo/ (go code in package foo)
├─ f.go
├─ bar/ (go code in package bar)
│ └─ x.go
├─ internal/
│ └─ baz/ (go code in package baz)
│ └─ z.go
└─ quux/ (go code in package main)
└─ y.go2
3
4
5
6
7
8
9
10
11
12
13
14
由文件结构中可知,crash包无法访问baz包中的类型。
注释
Go 支持单行注释和多行注释,注释与内容之间建议隔一个空格,例如
// 这是main包
package main
// 导入了fmt包
import "fmt"
// 这是启动函数 main函数
func main() {
// 这是一个语句
fmt.Println("Hello 世界!")
}2
3
4
5
6
7
8
9
10
11
标识符
标识符就是一个名称,用于包命名,函数命名,变量命名等等,命名规则如下:
- 只能由字母,数字,下划线组成
- 只能以字母和下划线开头
- 严格区分大小写
- 不能与任何已存在的标识符重复,即包内唯一的存在
- 不能与 Go 任何内置的关键字冲突
下方列出所有的内置关键字,也可以前往参考手册-标识符查看更多细节
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var2
3
4
5
运算符
下面是 Go 语言中支持的运算符号的优先级排列,也可以前往参考手册-运算符查看更多细节。
Precedence Operator
5 * / % << >> & &^
4 + - | ^
3 == != < <= > >=
2 &&
1 ||2
3
4
5
6
有一点需要稍微注意下,go 语言中没有选择将~作为取反运算符,而是复用了^符号,当两个数字使用^时,例如a^b,它就是异或运算符,只对一个数字使用时,例如^a,那么它就是取反运算符。go 也支持增强赋值运算符,如下。
a += 1
a /= 2
a &^= 22
3
自增与自减
Go 语言中没有自增与自减运算符,它们被降级为了语句statement,并且规定了只能位于操作数的后方,所以不用再去纠结i++和++i这样的问题。
a++ // 正确
++a // 错误
a-- // 正确2
3
还有一点就是,它们不再具有返回值,因此a = b++这类语句的写法是错误的。
字面量
字面量,按照计算机科学的术语来讲是用于表达源代码中一个固定值的符号,也叫字面值。两个叫法都是一个意思,写了什么东西,值就是什么,值就是“字面意义上“的值。
整型字面量
为了便于阅读,允许使用下划线_来进行数字划分,但是仅允许在前缀符号之后和数字之间使用。
24 // 24
024 // 24
2_4 // 24
0_2_4 // 24
10_000 // 10k
100_000 // 100k
0O24 // 20
0b00 // 0
0x00 // 0
0x0_0 // 02
3
4
5
6
7
8
9
10
浮点数字面量
通过不同的前缀可以表达不同进制的浮点数
0.
72.40
072.40 // == 72.40
2.71828
1.e+0
6.67428e-11
1E6
.25
.12345E+5
1_5. // == 15.0
0.15e+0_2 // == 15.0
0x1p-2 // == 0.25
0x2.p10 // == 2048.0
0x1.Fp+0 // == 1.9375
0X.8p-0 // == 0.5
0X_1FFFP-16 // == 0.1249847412109375
0x15e-2 // == 0x15e - 2 (integer subtraction)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复数字面量
0i
0123i // == 123i
0o123i // == 0o123 * 1i == 83i
0xabci // == 0xabc * 1i == 2748i
0.i
2.71828i
1.e+0i
6.67428e-11i
1E6i
.25i
.12345E+5i
0x1p-2i // == 0x1p-2 * 1i == 0.25i2
3
4
5
6
7
8
9
10
11
12
字符字面量
字符字面量必须使用单引号括起来'',Go 中的字符完全兼容utf8。
'a'
'ä'
'你'
'\t'
'\000'
'\007'
'\377'
'\x07'
'\xff'
'\u12e4'
'\U00101234'2
3
4
5
6
7
8
9
10
11
转义字符
Go 中可用的转义字符
\a U+0007 响铃符号
\b U+0008 回退符号
\f U+000C 换页符号
\n U+000A 换行符号
\r U+000D 回车符号
\t U+0009 横向制表符号
\v U+000B 纵向制表符号
\\ U+005C 反斜杠转义
\' U+0027 单引号转义 (该转义仅在字符内有效)
\" U+0022 双引号转义 (该转义仅在字符串内有效)2
3
4
5
6
7
8
9
10
字符串字面量
字符串字面量必须使用双引号""括起来或者反引号``(反引号字符串不允许转义)
`abc` // "abc"
`\n
\n` // "\\n\n\\n"
"\n"
"\"" // `"`
"Hello, world!\n"
"今天天气不错"
"日本語"
"\u65e5本\U00008a9e"
"\xff\u00FF"2
3
4
5
6
7
8
9
10
函数
Go 中的函数声明方式通过func关键字来进行,跟大多数语言类似
func main() {
println(1)
}2
3
不过 Go 中的函数有两个不同的点,第一个是参数类型后置,像下面这样
func Hello(name string) {
fmt.Println(name)
}2
3
第二个不同的点就是多返回值,而且可以带名字
func Pos() (x, y float64) {
...
}2
3
风格
关于编码风格这一块 Go 是强制所有人统一同一种风格,Go 官方提供了一个格式化工具gofmt,通过命令行就可以使用,该格式化工具没有任何的格式化参数可以传递,仅有的两个参数也只是输出格式化过程,所以完全不支持自定义,也就是说所有通过此工具的格式化后的代码都是同一种代码风格,这会极大的降低维护人员的心智负担,所以在这一块追求个性显然是一个不太明智的选择。
下面会简单列举一些规则,平时在编写代码的时候也可以稍微注意一下。
函数花括号换行
关于函数后的花括号到底该不该换行,几乎每个程序员都能说出属于自己的理由,在 Go 中所有的花括号都不应该换行
// 正确示例
func main() {
fmt.Println("Hello 世界!")
}2
3
4
如果你真的这么做了,像下面这样
// 错误示例
func main()
{
fmt.Println("Hello 世界!")
}2
3
4
5
这样的代码连编译都过不了,所以 Go 强制所有程序员花函数后的括号不换行。
代码缩进
Go 默认使用Tab也就是制表符进行缩进,仅在一些特殊情况会使用空格。
代码间隔
Go 中大部分间隔都是有意义的,从某种程度上来说,这也代表了编译器是如何看待你的代码的,例如下方的数学运算
2*9 + 1/3*2众所周知,乘法的优先级比加法要高,在格式化后,*符号之间的间隔会显得更紧凑,意味着优先进行运算,而+符号附近的间隔则较大,代表着较后进行运算。
花括号省略
在其它语言中的 if 和 for 语句通常可以简写,像下面这样
for (int i=0; i < 10; i++) printf("%d", i)但在 Go 中不行,你可以只写一行,但必须加上花括号
for i := 0; i < 10; i++ {fmt.Println(i)}三元表达式
Go 中没有三元表达式,所以像下面的代码是无法通过编译的
var c = a > b ? a : b数据类型
下面列出 Go 语言中全部的内置类型,以供参考,更多细节可以前往参考手册-类型。
布尔类型
布尔类型只有真值和假值。
| 类型 | 描述 |
|---|---|
| bool | true为真值,false为假值 |
数字无法代替布尔值
在 Go 中,整数 0 并不代表假值,非零整数也不能代表真值,即数字无法代替布尔值进行逻辑判断,两者是完全不同的类型。
整型
Go 中为不同位数的整数分配了不同的类型,主要分为无符号整型与有符号整型。
| 类型 | 描述 |
|---|---|
| uint8 | 无符号 8 位整型 |
| uint16 | 无符号 16 位整型 |
| uint32 | 无符号 32 位整型 |
| uint64 | 无符号 64 位整型 |
| int8 | 有符号 8 位整型 |
| int16 | 有符号 16 位整型 |
| int32 | 有符号 32 位整型 |
| int64 | 有符号 64 位整型 |
| uint | 无符号整型 至少 32 位 |
| int | 整型 至少 32 位 |
| uintptr | 等价于无符号 64 位整型,用于存储内存地址的数值 |
浮点型
IEEE-754浮点数,主要分为单精度浮点数与双精度浮点数。
| 类型 | 描述 |
|---|---|
| float32 | IEEE-754 32 位浮点数 |
| float64 | IEEE-754 64 位浮点数 |
复数类型
| 类型 | 描述 |
|---|---|
| complex128 | 64 位实数和虚数 |
| complex64 | 32 位实数和虚数 |
字符类型
go 语言字符串完全兼容 UTF-8
| 类型 | 描述 |
|---|---|
| byte | 等价 uint8 可以表达 ANSCII 字符 |
| rune | 等价 int32 可以表达 Unicode 字符 |
| string | 字符串即字节序列,可以转换为[]byte类型即字节切片 |
派生类型
| 类型 | 例子 |
|---|---|
| 数组 | [5]int,长度为 5 的整型数组 |
| 切片 | []float64,64 位浮点数切片 |
| 映射表 | map[string]int,键为字符串类型,值为整型的映射表 |
| 结构体 | type Gopher struct{},Gopher 结构体 |
| 指针 | *int,一个整型指针 |
| 函数 | type f func(),一个没有参数,没有返回值的函数类型 |
| 接口 | type Gopher interface{},Gopher 接口 |
| 通道 | chan int,整型通道 |
零值
官方文档中零值称为zero value,零值并不仅仅只是字面上的数字零,而是一个类型的空值或者说默认值更为准确。
| 类型 | 零值 |
|---|---|
| 数字类型 | 0 |
| 布尔类型 | false |
| 字符串类型 | "" |
| 数组 | 固定长度的对应类型的零值集合 |
| 结构体 | 内部字段都是零值的结构体 |
| 切片,映射表,函数,接口,通道,指针 | nil |
nil
nil类似于其它语言中的none或者null,但并不等同。nil仅仅只是一些引用类型的零值,并且不属于任何类型,从源代码中的nil可以看出它仅仅只是一个变量。
var nil Type并且nil == nil这样的语句是无法通过编译的。
常量
常量的值无法在运行时改变,一旦赋值过后就无法修改,其值只能来源于:
- 字面量
- 其他常量标识符
- 常量表达式
- 结果是常量的类型转换
- iota
常量只能是基本数据类型,不能是:
- 除基本类型以外的其它类型,如结构体,接口,切片,数组等
- 函数的返回值
常量的值无法被修改,否则无法通过编译。
初始化
常量的声明需要用到const关键字,常量在声明时就必须初始化一个值,并且常量的类型可以省略,例如
const name string = "Jack" // 字面量
const msg = "hello world" // 字面量
const num = 1 // 字面量
const numExpression = (1+2+3) / 2 % 100 + num // 常量表达式2
3
4
5
6
7
如果仅仅只是声明而不指定值,将会无法通过编译
const name string编译器报错
missing init expr for name批量声明常量可以用()括起来以提升可读性,可以存在多个()达到分组的效果。
const (
Count = 1
Name = "Jack"
)
const (
Size = 16
Len = 25
)2
3
4
5
6
7
8
9
在同一个常量分组中,在已经赋值的常量后面的常量可以不用赋值,其值默认就是前一个的值,比如
const (
A = 1
B // 1
C // 1
D // 1
E // 1
)2
3
4
5
6
7
iota
iota是一个内置的常量标识符,通常用于表示一个常量声明中的无类型整数序数,一般都是在括号中使用。
const iota = 0看几个使用案例
const (
Num = iota // 0
Num1 // 1
Num2 // 2
Num3 // 3
Num4 // 4
)2
3
4
5
6
7
也可以这么写
const (
Num = iota*2 // 0
Num1 // 2
Num2 // 4
Num3 // 6
Num4 // 8
)2
3
4
5
6
7
还可以
const (
Num = iota << 2*3 + 1 // 1
Num1 // 13
Num2 // 25
Num3 = iota // 3
Num4 // 4
)2
3
4
5
6
7
通过上面几个例子可以发现,iota是递增的,第一个常量使用iota值的表达式,根据序号值的变化会自动的赋值给后续的常量,直到用新的const重置,这个序号其实就是代码的相对行号,是相对于当前分组的起始行号,看下面的例子
const (
Num = iota<<2*3 + 1 // 1 第一行
Num2 = iota<<2*3 + 1 // 13 第二行
_ // 25 第三行
Num3 //37 第四行
Num4 = iota // 4 第五行
_ // 5 第六行
Num5 // 6 第七行
)2
3
4
5
6
7
8
9
例子中使用了匿名标识符_占了一行的位置,可以看到iota的值本质上就是iota所在行相对于当前const分组的第一行的差值。而不同的const分组则相互不会影响。
枚举
Go 语言没有为枚举单独设计一个数据类型,不像其它语言通常会有一个enum来表示。一般在 Go 中,都是通过自定义类型 + const + iota 来实现枚举,下面是一个简单的例子
type Season uint8
const (
Spring Season = iota
Summer
Autumn
Winter
)2
3
4
5
6
7
8
这些枚举实际上就是数字,Go 也不支持直接将其转换为字符串,但我们可以通过给自定义类型添加方法来返回其字符串表现形式,实现Stringer接口即可。
func (s Season) String() string {
switch s {
case Spring:
return "spring"
case Summer:
return "summer"
case Autumn:
return "autumn"
case Winter:
return "winter"
}
return ""
}2
3
4
5
6
7
8
9
10
11
12
13
这样一来就是一个简单的枚举实现了。你也可以通过官方工具Stringer来自动生成枚举。
不过它有以下缺点:
- 类型不安全,因为
Season是自定义类型,可以通过强制类型转换将其他数字也转换成该类型goSeason(6)1 - 繁琐,字符串表现形式需要自己实现
- 表达能力弱,因为
const仅支持基本数据类型,所以这些枚举值也只能用字符串和数字来进行表示
为什么不在语言层面支持枚举是笔者非常不能理解的一件事,我认为这绝对是利大于弊的。
变量
变量是用于保存一个值的存储位置,允许其存储的值在运行时动态的变化。每声明一个变量,都会为其分配一块内存以存储对应类型的值,前往参考手册-变量以查看更多细节。
声明
在 go 中的类型声明是后置的,变量的声明会用到var关键字,格式为var 变量名 类型名,变量名的命名规则必须遵守标识符的命名规则。
var intNum int
var str string
var char byte2
3
当要声明多个相同类型的变量时,可以只写一次类型
var numA, numB, numC int当要声明多个不同类型的变量时,可以使用()进行包裹,可以存在多个()。
var (
name string
age int
address string
)
var (
school string
class int
)2
3
4
5
6
7
8
9
10
一个变量如果只是声明而不赋值,那么变量存储的值就是对应类型的零值。
赋值
赋值会用到运算符=,例如
var name string
name = "jack"2
也可以声明的时候直接赋值
var name string = "jack"或者这样也可以
var name string
var age int
name, age = "jack", 12
3
第二种方式每次都要指定类型,可以使用官方提供的语法糖:短变量初始化,可以省略掉var关键字和后置类型,具体是什么类型交给编译器自行推断。
name := "jack" // 字符串类型的变量虽然可以不用指定类型,但是在后续赋值时,类型必须保持一致,下面这种代码无法通过编译。
a := 1
a = "1"2
还需要注意的是,短变量初始化不能使用nil,因为nil不属于任何类型,编译器无法推断其类型。
name := nil // 无法通过编译短变量声明可以批量初始化
name, age := "jack", 1短变量声明方式无法对一个已存在的变量使用,比如
// 错误示例
var a int
a := 1
// 错误示例
a := 1
a := 22
3
4
5
6
7
但是有一种情况除外,那就是在赋值旧变量的同时声明一个新的变量,比如
a := 1
a, b := 2, 22
这种代码是可以通过编译的,变量a被重新赋值,而b是新声明的。
在 go 语言中,有一个规则,那就是所有在函数中的变量都必须要被使用,比如下面的代码只是声明了变量,但没有使用它
func main() {
a := 1
}2
3
那么在编译时就会报错,提示你这个变量声明了但没有使用
a declared and not used这个规则仅适用于函数内的变量,对于函数外的包级变量则没有这个限制,下面这个代码就可以通过编译。
var a = 1
func main() {
}2
3
4
5
匿名
用下划线可以表示不需要某一个变量
Open(name string) (*File, error)比如os.Open函数有两个返回值,我们只想要第一个,不想要第二个,可以按照下面这样写
file, _ := os.Open("readme.txt")未使用的变量是无法通过编译的,当你不需要某一个变量时,就可以使用下划线_代替。
交换
在 Go 中,如果想要交换两个变量的值,不需要使用指针,可以使用赋值运算符直接进行交换,语法上看起来非常直观,例子如下
num1, num2 := 25, 36
num1, num2 = num2, num12
三个变量也是同样如此
num1, num2, num3 := 25, 36, 49
num1, num2, num3 = num3, num2, num12
思考下面这一段代码,这是计算斐波那契数列的一小段代码,三个变量在计算后的值分别是什么
a, b, c := 0, 1, 1
a, b, c = b, c, a+b2
答案是
1 1 1你可能会疑惑为什么不是
1 1 2明明 a 已经被赋予 b 的值了,为什么 a+b 的结果还是 1?go 在进行多个变量赋值运算时,它的顺序是先计算值再赋值,并非从左到右计算。
a, b, c = b, c, a+b你可能会以为它会被展开成下面这段
a = b
b = c
c = a + b2
3
但实际上它会将 a, b, c 三个数的值分别计算好再赋给它们,就等同于下面这段代码
a, b, c = 1, 1, 0+1当涉及到函数调用时,这个效果就更为明显,我们有一个函数sum可以计算两个数字的返回值
func sum(a, b int) int {
return a + b
}2
3
通过函数来进行两数相加
a, b, c := 0, 1, 1
a, b, c = b, c, sum(a, b)2
结果没有变化,在计算sum函数返回值时,它的入参依旧是 0 和 1
1 1 1所以代码应该这样分开写。
a, b = b, c
c = a + b2
比较
变量之间的比较有一个大前提,那就是它们之间的类型必须相同,go 语言中不存在隐式类型转换,像下面这样的代码是无法通过编译的
func main() {
var a uint64
var b int64
fmt.Println(a == b)
}2
3
4
5
编译器会告诉你两者之间类型并不相同
invalid operation: a == b (mismatched types uint64 and int64)所以必须使用强制类型转换
func main() {
var a uint64
var b int64
fmt.Println(int64(a) == b)
}2
3
4
5
在没有泛型之前,早期 go 提供的内置min,max函数只支持浮点数,到了 1.21 版本,go 才终于将这两个内置函数用泛型重写,现在可以使用min函数比较最小值
minVal := min(1, 2, -1, 1.2)使用max函数比较最大值
maxVal := max(100, 22, -1, 1.12)它们的参数支持所有的可比较类型,go 中的可比较类型有
- 布尔
- 数字
- 字符串
- 指针
- 通道 (仅支持判断是否相等)
- 元素是可比较类型的数组(切片不可比较)(仅支持判断是否相等)(仅支持相同长度的数组间的比较,因为数组长度也是类型的一部分,而不同类型不可比较)
- 字段类型都是可比较类型的结构体(仅支持判断是否相等)
除此之外,还可以通过导入标准库cmp来判断,不过仅支持有序类型的参数,在 go 中内置的有序类型只有数字和字符串。
import "cmp"
func main() {
cmp.Compare(1, 2)
cmp.Less(1, 2)
}2
3
4
5
6
代码块
在函数内部,可以通过花括号建立一个代码块,代码块彼此之间的变量作用域是相互独立的。例如下面的代码
func main() {
a := 1
{
a := 2
fmt.Println(a)
}
{
a := 3
fmt.Println(a)
}
fmt.Println(a)
}2
3
4
5
6
7
8
9
10
11
12
13
14
它的输出是
2
3
12
3
块与块之间的变量相互独立,不受干扰,无法访问,但是会受到父块中的影响。
func main() {
a := 1
{
a := 2
fmt.Println(a)
}
{
fmt.Println(a)
}
fmt.Println(a)
}2
3
4
5
6
7
8
9
10
11
12
13
输入输出
package main
import "fmt"
func main() {
fmt.Println("Hello 世界!")
}2
3
4
5
6
7
以上案例是输出一个字符串,这一节就来讲一下在 Go 中如何进行输入输出。
文件描述符
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)2
3
4
5
在os包下有三个外暴露的文件描述符,其类型都是*os.File,分别是:
os.Stdin- 标准输入os.Stdout- 标准输出os.Stderr- 标准错误
Go 中的输入输出都离不开它们。
输出
在 Go 中输出有很多中方法,下面几个比较常见的
stdout
因为标准输出本身就是一个文件,所以你可以直接将字符串写入到标准输出中
gopackage main import "os" func main() { os.Stdout.WriteString("hello world!") }1
2
3
4
5
6
7print
Go 有两个内置的函数
print,println,他们会将参数输出到标准错误中,仅做调试用,一般不推荐使用。gopackage main func main() { print("hello world!\n") println("hello world") }1
2
3
4
5
6fmt
最常见的用法是使用
fmt包,它提供了fmt.Println函数,该函数默认会将参数输出到标准输出中。gopackage main import "fmt" func main() { fmt.Println("hello world!") }1
2
3
4
5
6
7它的参数支持任意类型,如果类型实现了
String接口也会调用String方法来获取其字符串表现形式,所以它输出的内容可读性比较高,适用于大部分情况,不过由于内部用到了反射,在性能敏感的场景不建议大量使用。bufio
bufio提供了可缓冲的输出方法,它会先将数据写入到内存中,积累到了一定阈值再输出到指定的Writer中,默认缓冲区大小是4KB。在文件 IO,网络 IO 的时候建议使用这个包。gofunc main() { writer := bufio.NewWriter(os.Stdout) defer writer.Flush() writer.WriteString("hello world!") }1
2
3
4
5你也可以把它和
fmt包结合起来用gofunc main() { writer := bufio.NewWriter(os.Stdout) defer writer.Flush() fmt.Fprintln(writer, "hello world!") }1
2
3
4
5格式化
Go 中的格式化输出功能基本上由
fmt.Printf函数提供,如果你学过 C 系语言,一定会觉得很熟悉,下面是一个简单的例子。gofunc main() { fmt.Printf("hello world, %s!", "jack") }1
2
3下面是 Go 目前所有的格式化动词。
格式化 描述 接收类型 %% 输出百分号 %任意 %s 输出 string/[] byte值string,[] byte%q 格式化字符串,输出的字符串两端有双引号 ""string,[] byte%d 输出十进制整型值 整型 %f 输出浮点数 浮点 %e 输出科学计数法形式 ,也可以用于复数 浮点 %E 与 %e相同浮点 %g 根据实际情况判断输出 %f或者%e,会去掉多余的 0浮点 %b 输出整型的二进制表现形式 数字 %#b 输出二进制完整的表现形式 数字 %o 输出整型的八进制表示 整型 %#o 输出整型的完整八进制表示 整型 %x 输出整型的小写十六进制表示 数字 %#x 输出整型的完整小写十六进制表示 数字 %X 输出整型的大写十六进制表示 数字 %#X 输出整型的完整大写十六进制表示 数字 %v 输出值原本的形式,多用于数据结构的输出 任意 %+v 输出结构体时将加上字段名 任意 %#v 输出完整 Go 语法格式的值 任意 %t 输出布尔值 布尔 %T 输出值对应的 Go 语言类型值 任意 %c 输出 Unicode 码对应的字符 int32%U 输出字符对应的 Unicode 码 rune,byte%p 输出指针所指向的地址 指针 使用
fmt.Sprintf或者fmt.Printf来格式化字符串或者输出格式化字符串,看几个例子gofmt.Printf("%%%s\n", "hello world") fmt.Printf("%s\n", "hello world") fmt.Printf("%q\n", "hello world") fmt.Printf("%d\n", 2<<7-1) fmt.Printf("%f\n", 1e2) fmt.Printf("%e\n", 1e2) fmt.Printf("%E\n", 1e2) fmt.Printf("%g\n", 1e2) fmt.Printf("%b\n", 2<<7-1) fmt.Printf("%#b\n", 2<<7-1) fmt.Printf("%o\n", 2<<7-1) fmt.Printf("%#o\n", 2<<7-1) fmt.Printf("%x\n", 2<<7-1) fmt.Printf("%#x\n", 2<<7-1) fmt.Printf("%X\n", 2<<7-1) fmt.Printf("%#X\n", 2<<7-1) type person struct { name string age int address string } fmt.Printf("%v\n", person{"lihua", 22, "beijing"}) fmt.Printf("%+v\n", person{"lihua", 22, "beijing"}) fmt.Printf("%#v\n", person{"lihua", 22, "beijing"}) fmt.Printf("%t\n", true) fmt.Printf("%T\n", person{}) fmt.Printf("%c%c\n", 20050, 20051) fmt.Printf("%U\n", '码') fmt.Printf("%p\n", &person{})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34使用其它进制时,在%与格式化动词之间加上一个空格便可以达到分隔符的效果,例如
gofunc main() { str := "abcdefg" fmt.Printf("%x\n", str) fmt.Printf("% x\n", str) }1
2
3
4
5该例输出的结果为
go61626364656667 61 62 63 64 65 66 671
2在使用数字时,还可以自动补零。比如
gofmt.Printf("%09d", 1) // 0000000011
2二进制同理
gofmt.Printf("%09b", 1<<3) // 0000010001
2错误情况
格式化字符数量 < 参数列表数量
gofmt.Printf("", "") //%!(EXTRA string=)1格式化字符数量 > 参数列表数量
gofmt.Printf("%s%s", "") //%!s(MISSING)1类型不匹配
gofmt.Printf("%s", 1) //%!s(int=1)1缺少格式化动词
gofmt.Printf("%", 1) // %!(NOVERB)%!(EXTRA int=1)1
输入
下面介绍常见的输入方法
read
你可以像直接读文件一样,读取输入内容,如下
gofunc main() { var buf [1024]byte n, _ := os.Stdin.Read(buf[:]) os.Stdout.Write(buf[:n]) }1
2
3
4
5这样用起来太麻烦了,一般不推荐使用。
fmt
我们可以使用
fmt包提供的几个函数,用起来跟 C 差不多。go// 扫描从os.Stdin读入的文本,根据空格分隔,换行也被当作空格 func Scan(a ...any) (n int, err error) // 与Scan类似,但是遇到换行停止扫描 func Scanln(a ...any) (n int, err error) // 根据格式化的字符串扫描 func Scanf(format string, a ...any) (n int, err error)1
2
3
4
5
6
7
8读取两个数字
gofunc main() { var a, b int fmt.Scanln(&a, &b) fmt.Printf("%d + %d = %d\n", a, b, a+b) }1
2
3
4
5读取固定长度的数组
gofunc main() { n := 10 s := make([]int, n) for i := range n { fmt.Scan(&s[i]) } fmt.Println(s) }1
2
3
4
5
6
7
81 2 3 4 5 6 7 8 9 10 [1 2 3 4 5 6 7 8 9 10]1
2bufio
在有大量输入需要读取的时候,就建议使用
bufio.Reader来进行内容读取gofunc main() { reader := bufio.NewReader(os.Stdin) var a, b int fmt.Fscanln(reader, &a, &b) fmt.Printf("%d + %d = %d\n", a, b, a+b) }1
2
3
4
5
6scanner
bufio.Scanner与bufio.Reader类似,不过它是按行读取的。gofunc main() { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { line := scanner.Text() if line == "exit" { break } fmt.Println("scan", line) } }1
2
3
4
5
6
7
8
9
10结果如下
first line scan first line second line scan second line third line scan third line exit1
2
3
4
5
6
7
条件控制
在 Go 中,条件控制语句总共有三种if,switch,select。select相对前两者而言比较特殊,本节不会讲解,将会留到并发那一节再做介绍。
if else
if else 至多两个判断分支,语句格式如下
if expression {
}2
3
或者
if expression {
} else {
}2
3
4
5
expression必须是一个布尔表达式,即结果要么为真要么为假,必须是一个布尔值,例子如下:
func main() {
a, b := 1, 2
if a > b {
b++
} else {
a++
}
}2
3
4
5
6
7
8
也可以把表达式写的更复杂些,必要时为了提高可读性,应当使用括号来显式的表示谁应该优先计算。
func main() {
a, b := 1, 2
if a<<1%100+3 > b*100/20+6 { // (a<<1%100)+3 > (b*100/20)+6
b++
} else {
a++
}
}2
3
4
5
6
7
8
同时if语句也可以包含一些简单的语句,例如:
func main() {
if x := 1 + 1; x > 2 {
fmt.Println(x)
}
}2
3
4
5
else if
else if 语句可以在if else的基础上创建更多的判断分支,语句格式如下:
if expression1 {
} else if expression2 {
} else if expression3 {
} else {
}2
3
4
5
6
7
8
9
在执行的过程中每一个表达式的判断是从左到右,整个if语句的判断是从上到下 。一个根据成绩打分的例子如下,第一种写法
func main() {
score := 90
var ans string
if score == 100 {
ans = "S"
} else if score >= 90 && score < 100 {
ans = "A"
} else if score >= 80 && score < 90 {
ans = "B"
} else if score >= 70 && score < 80 {
ans = "C"
} else if score >= 60 && score < 70 {
ans = "E"
} else if score >= 0 && score < 60 {
ans = "F"
} else {
ans = "nil"
}
fmt.Println(ans)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
第二种写法利用了if语句是从上到下的判断的前提,所以代码要更简洁些。
func main() {
score := 90
var ans string
if score >= 0 && score < 60 {
ans = "F"
} else if score < 70 {
ans = "D"
} else if score < 80 {
ans = "C"
} else if score < 90 {
ans = "B"
} else if score < 100 {
ans = "A"
} else if score == 100 {
ans = "S"
} else {
ans = "nil"
}
fmt.Println(ans)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
switch
switch语句也是一种多分支的判断语句,语句格式如下:
switch expr {
case case1:
statement1
case case2:
statement2
default:
default statement
}2
3
4
5
6
7
8
一个简单的例子如下
func main() {
str := "a"
switch str {
case "a":
str += "a"
str += "c"
case "b":
str += "bb"
str += "aaaa"
default: // 当所有case都不匹配后,就会执行default分支
str += "CCCC"
}
fmt.Println(str)
}2
3
4
5
6
7
8
9
10
11
12
13
14
还可以在表达式之前编写一些简单语句,例如声明新变量
func main() {
switch num := f(); { // 等价于 switch num := f(); true {
case num >= 0 && num <= 1:
num++
case num > 1:
num--
fallthrough
case num < 0:
num += num
}
}
func f() int {
return 1
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
switch语句也可以没有入口处的表达式。
func main() {
num := 2
switch { // 等价于 switch true {
case num >= 0 && num <= 1:
num++
case num > 1:
num--
case num < 0:
num *= num
}
fmt.Println(num)
}2
3
4
5
6
7
8
9
10
11
12
通过fallthrough关键字来继续执行相邻的下一个分支。
func main() {
num := 2
switch {
case num >= 0 && num <= 1:
num++
case num > 1:
num--
fallthrough // 执行完该分支后,会继续执行下一个分支
case num < 0:
num += num
}
fmt.Println(num)
}2
3
4
5
6
7
8
9
10
11
12
13
label
标签语句,给一个代码块打上标签,可以是goto,break,continue的目标。例子如下:
func main() {
A:
a := 1
B:
b := 2
}2
3
4
5
6
单纯的使用标签是没有任何意义的,需要结合其他关键字来进行使用。
goto
goto将控制权传递给在同一函数中对应标签的语句,示例如下:
func main() {
a := 1
if a == 1 {
goto A
} else {
fmt.Println("b")
}
A:
fmt.Println("a")
}2
3
4
5
6
7
8
9
10
在实际应用中goto用的很少,跳来跳去的很降低代码可读性,性能消耗也是一个问题。
循环控制
在 Go 中,有仅有一种循环语句:for,Go 抛弃了while语句,for语句可以被当作while来使用。
for
语句格式如下
for init statement; expression; post statement {
execute statement
}2
3
当只保留循环条件时,就变成了while。
for expression {
execute statement
}2
3
这是一个死循环,永远也不会退出
for {
execute statement
}2
3
示例
这是一段输出[0,20]区间数字的代码
for i := 0; i <= 20; i++ {
fmt.Println(i)
}2
3
你可以同时初始化多个变量,然后将其递增
for i, j := 1, 2; i < 100 && j < 1000; i, j = i+1, j+1 {
fmt.Println(i, j)
}2
3
当成while来使用
num := 1
for num < 100 {
num *= 2
}2
3
4
双循环打印九九乘法表,这是一个很经典的循环案例
func main() {
for i := 1; i <= 9; i++ {
for j := 1; j <= 9; j++ {
if i <= j {
fmt.Printf("%d*%d = %2d ", i, j, i*j)
}
}
fmt.Println()
}
}2
3
4
5
6
7
8
9
10
输出如下
1*1 = 1 1*2 = 2 1*3 = 3 1*4 = 4 1*5 = 5 1*6 = 6 1*7 = 7 1*8 = 8 1*9 = 9
2*2 = 4 2*3 = 6 2*4 = 8 2*5 = 10 2*6 = 12 2*7 = 14 2*8 = 16 2*9 = 18
3*3 = 9 3*4 = 12 3*5 = 15 3*6 = 18 3*7 = 21 3*8 = 24 3*9 = 27
4*4 = 16 4*5 = 20 4*6 = 24 4*7 = 28 4*8 = 32 4*9 = 36
5*5 = 25 5*6 = 30 5*7 = 35 5*8 = 40 5*9 = 45
6*6 = 36 6*7 = 42 6*8 = 48 6*9 = 54
7*7 = 49 7*8 = 56 7*9 = 63
8*8 = 64 8*9 = 72
9*9 = 812
3
4
5
6
7
8
9
for range
for range可以更加方便的遍历一些可迭代的数据结构,如数组,切片,字符串,映射表,通道。语句格式如下:
for index, value := range iterable {
// body
}2
3
index为可迭代数据结构的索引,value则是对应索引下的值,例如使用for range遍历一个字符串。
func main() {
sequence := "hello world"
for index, value := range sequence {
fmt.Println(index, value)
}
}2
3
4
5
6
for range也可以迭代一个整型值,字面量,常量,变量都是有效的。
for i := range 10 {
fmt.Println(i)
}
n := 10
for i := range n {
fmt.Println(i)
}
const n = 10
for i := range n {
fmt.Println(i)
}2
3
4
5
6
7
8
9
10
11
12
13
对于每一个种数据结构,for range的实现都有所不同,后续也会讲到,你可以前往Go - for statement以了解更多细节。
break
break关键字会终止最内层的for循环,结合标签一起使用可以达到终止外层循环的效果,例子如下:这是一个双循环
func main() {
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if i <= j {
break
}
fmt.Println(i, j)
}
}
}2
3
4
5
6
7
8
9
10
输出
1 0
2 0
2 1
3 0
3 1
3 2
...
9 6
9 7
9 82
3
4
5
6
7
8
9
10
使用标签来中断外层循环
func main() {
Outer:
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if i <= j {
break Outer
}
fmt.Println(i, j)
}
}
}2
3
4
5
6
7
8
9
10
11
输出
continue
continue关键字会跳过最内层循环的本次迭代,直接进入下一次迭代,结合标签使用可以达到跳过外层循环的效果,例子如下
func main() {
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if i > j {
continue
}
fmt.Println(i, j)
}
}
}2
3
4
5
6
7
8
9
10
输出
0 0
0 1
0 2
0 3
0 4
0 5
0 6
0 7
0 8
0 9
...
7 7
7 8
7 9
8 8
8 9
9 92
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用标签
func main() {
Out:
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if i > j {
continue Out
}
fmt.Println(i, j)
}
}
}2
3
4
5
6
7
8
9
10
11
输出
0 0
0 1
0 2
0 3
0 4
0 5
0 6
0 7
0 8
0 92
3
4
5
6
7
8
9
10
切片
在 Go 中,数组和切片两者看起来长得几乎一模一样,但功能有着不小的区别,数组是定长的数据结构,长度被指定后就不能被改变,而切片是不定长的,切片在容量不够时会自行扩容。
数组
如果事先就知道了要存放数据的长度,且后续使用中不会有扩容的需求,就可以考虑使用数组,Go 中的数组是值类型,而非引用,并不是指向头部元素的指针。
数组是值类型
数组作为值类型,将数组作为参数传递给函数时,由于 Go 函数是传值传递,所以会将整个数组拷贝。
初始化
数组在声明时长度只能是一个常量,不能是变量,你不能在声明一个变量让后用变量作为数组的长度值
// 正确示例
var a [5]int
// 错误示例
l := 1
var b [l]int2
3
4
5
6
先来初始化一个长度为 5 的整型数组
var nums [5]int也可以用元素初始化
nums := [5]int{1, 2, 3}可以让编译器自动推断长度
nums := [...]int{1, 2, 3, 4, 5} //等价于nums := [5]int{1, 2, 3, 4, 5},省略号必须存在,否则生成的是切片,不是数组还可以通过new函数获得一个指针
nums := new([5]int)以上几种方式都会给nums分配一片固定大小的内存,区别只是最后一种得到的值是指针。
在数组初始化时,需要注意的是,长度必须为一个常量表达式,否则将无法通过编译,常量表达式即表达式的最终结果是一个常量,错误例子如下:
length := 5 // 这是一个变量
var nums [length]int2
length是一个变量,因此无法用于初始化数组长度,如下是正确示例:
const length = 5
var nums [length]int // 常量
var nums2 [length + 1]int // 常量表达式
var nums3 [(1 + 2 + 3) * 5]int // 常量表达式
var nums4 [5]int // 最常用的2
3
4
5
使用
只要有数组名和下标,就可以访问数组中对应的元素。
fmt.Println(nums[0])同样的也可以修改数组元素
nums[0] = 1还可以通过内置函数len来访问数组元素的数量
len(nums)内置函数cap来访问数组容量,数组的容量等于数组长度,容量对于切片才有意义。
cap(nums)切割
切割数组的格式为arr[startIndex:endIndex],切割的区间为左闭右开。且数组在切割后,就会变为切片类型。例子如下:
nums := [5]int{1, 2, 3, 4, 5}
nums[:] // 子切片范围[0,5) -> [1 2 3 4 5]
nums[1:] // 子切片范围[1,5) -> [2 3 4 5]
nums[:5] // 子切片范围[0,5) -> [1 2 3 4 5]
nums[2:3] // 子切片范围[2,3) -> [3]
nums[1:3] // 子切片范围[1,3) -> [2 3]2
3
4
5
6
7
func main() {
arr := [5]int{1, 2, 3, 4, 5}
fmt.Printf("%T\n", arr)
fmt.Printf("%T\n", arr[1:2])
}2
3
4
5
输出
[5]int
[]int2
若要将数组转换为切片类型,不带参数进行切片即可,转换后的切片与原数组指向的是同一片内存,修改切片会导致原数组内容的变化
func main() {
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[:]
slice[0] = 0
fmt.Printf("array: %v\n", arr)
fmt.Printf("slice: %v\n", slice)
}2
3
4
5
6
7
输出
array: [0 2 3 4 5]
slice: [0 2 3 4 5]2
如果要对转换后的切片进行修改,建议使用下面这种方式进行转换
func main() {
arr := [5]int{1, 2, 3, 4, 5}
slice := slices.Clone(arr[:])
slice[0] = 0
fmt.Printf("array: %v\n", arr)
fmt.Printf("slice: %v\n", slice)
}2
3
4
5
6
7
输出
array: [1 2 3 4 5]
slice: [0 2 3 4 5]2
切片
切片在 Go 中的应用范围要比数组广泛的多,它用于存放不知道长度的数据,且后续使用过程中可能会频繁的插入和删除元素。
初始化
切片的初始化方式有以下几种
var nums []int // 值
nums := []int{1, 2, 3} // 值
nums := make([]int, 0, 0) // 值
nums := new([]int) // 指针2
3
4
可以看到切片与数组在外貌上的区别,仅仅只是少了一个初始化长度。通常情况下,推荐使用make来创建一个空切片,只是对于切片而言,make函数接收三个参数:类型,长度,容量。举个例子解释一下长度与容量的区别,假设有一桶水,水并不是满的,桶的高度就是桶的容量,代表着总共能装多少高度的水,而桶中水的高度就是代表着长度,水的高度一定小于等于桶的高度,否则水就溢出来了。所以,切片的长度代表着切片中元素的个数,切片的容量代表着切片总共能装多少个元素,切片与数组最大的区别在于切片的容量会自动扩张,而数组不会,更多细节前往参考手册 - 长度与容量。
切片是引用类型,底层实现是数组
切片的底层实现依旧是数组,是引用类型,可以简单理解为是指向底层数组的指针(本质上切片在Go中是一个结构体,包含指向底层数组的指针、长度值、容量值)。因此切片作为函数参数传递时不复制底层数组,函数内对传入切片的修改会反映在原切片中。
通过var nums []int这种方式声明的切片,默认值为nil,所以不会为其分配内存,而在使用make进行初始化时,建议预分配一个足够的容量,可以有效减少后续扩容的内存消耗。
使用
切片的基本使用与数组完全一致,区别只是切片可以动态变化长度,下面看几个例子。
切片可以通过append函数实现许多操作,函数签名如下,slice是要添加元素的目标切片,elems是待添加的元素,返回值是添加后的切片。
func append(slice []Type, elems ...Type) []Type首先创建一个长度为 0,容量为 0 的空切片,然后在尾部插入一些元素,最后输出长度和容量。
nums := make([]int, 0, 0)
nums = append(nums, 1, 2, 3, 4, 5, 6, 7)
fmt.Println(len(nums), cap(nums)) // 7 8 可以看到长度与容量并不一致。2
3
新 slice 预留的 buffer 容量 大小是有一定规律的。
在 golang1.18 版本更新之前网上大多数的文章都是这样描述 slice 的扩容策略的: 当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍。
在 1.18 版本更新之后,slice 的扩容策略变为了: 当原 slice 容量(oldcap)小于 256 的时候,新 slice(newcap)容量为原来的 2 倍;原 slice 容量超过 256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4。
插入元素
切片元素的插入也是需要结合append函数来使用,现有切片如下
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}从头部插入元素
nums = append([]int{-1, 0}, nums...)
fmt.Println(nums) // [-1 0 1 2 3 4 5 6 7 8 9 10]2
从中间下标 i 插入元素
nums = append(nums[:i+1], append([]int{999, 999}, nums[i+1:]...)...)
fmt.Println(nums) // i=3,[1 2 3 4 999 999 5 6 7 8 9 10]2
从尾部插入元素,就是append最原始的用法
nums = append(nums, 99, 100)
fmt.Println(nums) // [1 2 3 4 5 6 7 8 9 10 99 100]2
删除元素
切片元素的删除需要结合append函数来使用,现有如下切片
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}从头部删除 n 个元素
nums = nums[n:]
fmt.Println(nums) //n=3 [4 5 6 7 8 9 10]2
从尾部删除 n 个元素
nums = nums[:len(nums)-n]
fmt.Println(nums) //n=3 [1 2 3 4 5 6 7]2
从中间指定下标 i 位置开始删除 n 个元素
nums = append(nums[:i], nums[i+n:]...)
fmt.Println(nums)// i=2,n=3,[1 2 6 7 8 9 10]2
删除所有元素
nums = nums[:0]
fmt.Println(nums) // []2
拷贝
切片在拷贝时需要确保目标切片有足够的长度,例如
func main() {
dest := make([]int, 0)
src := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println(src, dest)
fmt.Println(copy(dest, src))
fmt.Println(src, dest)
}2
3
4
5
6
7
[1 2 3 4 5 6 7 8 9] []
0
[1 2 3 4 5 6 7 8 9] []2
3
将长度修改为 10,输出如下
[1 2 3 4 5 6 7 8 9] [0 0 0 0 0 0 0 0 0 0]
9
[1 2 3 4 5 6 7 8 9] [1 2 3 4 5 6 7 8 9 0]2
3
遍历
切片的遍历与数组完全一致,for循环
func main() {
slice := []int{1, 2, 3, 4, 5, 7, 8, 9}
for i := 0; i < len(slice); i++ {
fmt.Println(slice[i])
}
}2
3
4
5
6
for range循环
func main() {
slice := []int{1, 2, 3, 4, 5, 7, 8, 9}
for index, val := range slice {
fmt.Println(index, val)
}
}2
3
4
5
6
多维切片
先来看下面的一个例子,官方文档也有解释:Effective Go - 二维切片
var nums [5][5]int
for _, num := range nums {
fmt.Println(num)
}
fmt.Println()
slices := make([][]int, 5)
for _, slice := range slices {
fmt.Println(slice)
}2
3
4
5
6
7
8
9
输出结果为
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[]
[]
[]
[]
[]2
3
4
5
6
7
8
9
10
11
可以看到,同样是二维的数组和切片,其内部结构是不一样的。数组在初始化时,其一维和二维的长度早已固定,而切片的长度是不固定的,切片中的每一个切片长度都可能是不相同的,所以必须要单独初始化,切片初始化部分修改为如下代码即可。
slices := make([][]int, 5)
for i := 0; i < len(slices); i++ {
slices[i] = make([]int, 5)
}2
3
4
最终输出结果为
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]2
3
4
5
6
7
8
9
10
11
拓展表达式
TIP
只有切片才能使用拓展表达式
切片与数组都可以使用简单表达式来进行切割,但是拓展表达式只有切片能够使用,该特性于 Go1.2 版本添加,主要是为了解决切片共享底层数组的读写问题,主要格式为如下,需要满足关系low<= high <= max <= cap,使用拓展表达式切割的切片容量为max-low
slice[low:high:max]low与high依旧是原来的含义不变,而多出来的max则指的是最大容量,例如下方的例子中省略了max,那么s2的容量就是cap(s1)-low
s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9
s2 := s1[3:4] // cap = 9 - 3 = 62
那么这么做就会有一个明显的问题,s1与s2是共享的同一个底层数组,在对s2进行读写时,有可能会影响的s1的数据,下列代码就属于这种情况
s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9
s2 := s1[3:4] // cap = 9 - 3 = 6
s2 = append(s2, 1) // 添加新元素,由于容量为6.所以没有扩容,直接修改底层数组
fmt.Println(s2)
fmt.Println(s1)2
3
4
5
最终的输出为
[4 1]
[1 2 3 4 1 6 7 8 9]2
可以看到明明是向s2添加元素,却连s1也一起修改了,拓展表达式就是为了解决此类问题而生的,只需要稍微修改一下就能解决该问题
func main() {
s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9
s2 := s1[3:4:4] // cap = 4 - 3 = 1
s2 = append(s2, 1) // 容量不足,分配新的底层数组
fmt.Println(s2)
fmt.Println(s1)
}2
3
4
5
6
7
现在得到的结果就是正常的
[4 1]
[1 2 3 4 5 6 7 8 9]2
clear
在 go1.21 新增了clear内置函数,clear 会将切片内所有的值置为零值
package main
import (
"fmt"
)
func main() {
s := []int{1, 2, 3, 4}
clear(s)
fmt.Println(s)
}2
3
4
5
6
7
8
9
10
11
输出
[0 0 0 0]如果想要清空切片,可以
func main() {
s := []int{1, 2, 3, 4}
s = s[:0:0]
fmt.Println(s)
}2
3
4
5
限制了切割后的容量,这样可以避免覆盖原切片的后续元素。
字符串
在 Go 中,字符串本质上是一个不可变的、只读的字节序列(byte sequence),这里“字节序列”指的是字符串的底层数据由一串按顺序排列的字节组成,这些字节占用一片连续的内存空间。
字面量
前面提到过字符串有两种字面量表达方式,分为普通字符串和原生字符串。
普通字符串
普通字符串由""双引号表示,支持转义,不支持多行书写,下列是一些普通字符串
"这是一个普通字符串\n"
"abcdefghijlmn\nopqrst\t\\uvwxyz"2
这是一个普通字符串
abcdefghijlmn
opqrst \uvwxyz2
3
原生字符串
原生字符串由反引号表示,不支持转义,支持多行书写,原生字符串里面所有的字符都会原封不动的输出,包括换行和缩进。
`这是一个原生字符串,换行
tab缩进,\t制表符但是无效,换行
"这是一个普通字符串"
结束
`2
3
4
5
6
这是一个原生字符串,换行
tab缩进,\t制表符但是无效,换行
"这是一个普通字符串"
结束2
3
4
5
访问
因为字符串本质是字节序列,其索引操作str[i]被设计为返回第 i 个字节,语法上与切片一致,例如访问字符串第一个元素
func main() {
str := "this is a string"
fmt.Println(str[0])
}2
3
4
输出是字节编码值而不是字符
116切割字符串
func main() {
str := "this is a string"
fmt.Println(string(str[0:4]))
}2
3
4
this尝试修改字符串元素
func main() {
str := "this is a string"
str[0] = 'a' // 无法通过编译
fmt.Println(str)
}2
3
4
5
main.go:7:2: cannot assign to str[0] (value of type byte)虽然没法修改字符串,但是可以覆盖
func main() {
str := "this is a string"
str = "that is a string"
fmt.Println(str)
}2
3
4
5
that is a string转换
字符串可以转换为字节切片,而字节切片或字节序列也可以转换为字符串,例子如下:
func main() {
str := "this is a string"
// 显式类型转换为字节切片
bytes := []byte(str)
fmt.Println(bytes)
// 显式类型转换为字符串
fmt.Println(string(bytes))
}2
3
4
5
6
7
8
字符串的内容是只读的不可变的,无法修改,但是字节切片是可以修改的。
func main() {
str := "this is a string"
fmt.Println(&str)
bytes := []byte(str)
// 修改字节切片
bytes = append(bytes, 96, 97, 98, 99)
// 赋值给原字符串
str = string(bytes)
fmt.Println(str)
}2
3
4
5
6
7
8
9
10
将字符串转换成字节切片以后,两者之间毫无关联,因为 Go 会新分配一片内存空间给字节切片,再将字符串的内存复制过去,对字节切片进行修改不会对原字符串产生任何影响,这么做是为了内存安全。
在这种情况下,如果要转换的字符串或字节切片很大,那么性能开销就会很高。不过你也可以通过unsafe库来实现无复制转换,不过背后的安全问题需要自己承担,比如下面的例子,b1 和 s1 的地址是一样的。
func main() {
s1 := "hello world"
b1 := unsafe.Slice(unsafe.StringData(s1), len(s1))
fmt.Printf("%p %p", unsafe.StringData(s1), unsafe.SliceData(b1))
}2
3
4
5
0xe27bb2 0xe27bb2长度
字符串的长度,其实并不是字符的个数,而是字节序列的长度。只是大多数时候我们处理的是 ASCII 字符,每个字符恰好能用一个字节表示,所以字节长度与字符个数恰好相等。求字符串长度使用内置函数len,例子如下:
func main() {
str := "this is a string" // 看起来长度是16
str2 := "这是一个字符串" // 看起来长度是7
fmt.Println(len(str), len(str2))
}2
3
4
5
16 21看起来中文字符串比英文字符串短,但是实际求得的长度却比英文字符串长。这是因为在unicode编码中,一个汉字在大多数情况下占 3 个字节,一个英文字符只占一个字节,通过输出字符串第一个元素可以看出结果:
func main() {
str := "this is a string"
str2 := "这是一个字符串"
fmt.Println(string(str[0]))
fmt.Println(string(str2[0]))
fmt.Println(string(str2[0:3]))
}2
3
4
5
6
7
t // 字母t
è // 一个中文字符的“碎片”(第一个字节)的编码值,碰巧与意大利语字符 è 的编码值相同
这 // 中文汉字2
3
拷贝
类似数组切片的拷贝方式,字符串拷贝其实是字节切片拷贝,使用内置函数copy
func main() {
var dst, src string
src = "this is a string"
desBytes := make([]byte, len(src))
copy(desBytes, src)
dst = string(desBytes)
fmt.Println(src, dst)
}2
3
4
5
6
7
8
也可以使用strings.clone函数,但其实内部实现都差不多
func main() {
var dst, src string
src = "this is a string"
dst = strings.Clone(src)
fmt.Println(src, dst)
}2
3
4
5
6
拼接
字符串的拼接使用+操作符
func main() {
str := "this is a string"
str = str + " that is a int"
fmt.Println(str)
}2
3
4
5
也可以转换为字节切片再进行添加元素
func main() {
str := "this is a string"
bytes := []byte(str)
bytes = append(bytes, "that is a int"...)
str = string(bytes)
fmt.Println(str)
}2
3
4
5
6
7
以上两种拼接方式性能都很差,一般情况下可以使用,但如果对应性能有更高要求,可以使用strings.Builder
func main() {
builder := strings.Builder{}
builder.WriteString("this is a string ")
builder.WriteString("that is a int")
fmt.Println(builder.String())
}2
3
4
5
6
this is a string that is a int遍历
在本文开头就已经提到过,Go 中的字符串就是一个只读的字节切片,也就是说字符串的组成单位是字节而不是字符。这种情况经常会在遍历字符串时遇到,例如下方的代码
func main() {
str := "hello world!"
for i := 0; i < len(str); i++ {
fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
}
}2
3
4
5
6
例子中分别输出了字节的十进制形式和十六进制形式。
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
119,77,w
111,6f,o
114,72,r
108,6c,l
100,64,d
33,21,!2
3
4
5
6
7
8
9
10
11
12
由于例子中的字符都是属于 ASCII 字符,只需要一个字节就能表示,所以结果恰巧每一个字节对应一个字符。但如果包含非 ASCII 字符结果就不同了,如下
func main() {
str := "hello 世界!"
for i := 0; i < len(str); i++ {
fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
}
}2
3
4
5
6
通常情况下,一个中文字符会占用 3 个字节,所以就可能会看到以下结果
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
228,e4,ä
184,b8,¸
150,96,
231,e7,ç
149,95,
140,8c,
33,21,!2
3
4
5
6
7
8
9
10
11
12
13
按照字节来遍历会把中文字符拆开,这显然会出现乱码。Go 字符串是明确支持 UTF-8 的,应对这种情况就需要用到rune类型,在使用for range进行遍历时,其默认的遍历单位类型就是一个rune,例如下方代码
func main() {
str := "hello 世界!"
for _, r := range str {
fmt.Printf("%d,%x,%s\n", r, r, string(r))
}
}2
3
4
5
6
输出如下
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
19990,4e16,世
30028,754c,界
33,21,!2
3
4
5
6
7
8
9
rune本质上是int32的类型别名,unicode 字符集的范围位于 0x0000 - 0x10FFFF 之间,最大也只有三个字节,合法的 UTF-8 编码最大字节数只有 4 个字节,所以使用int32来存储是理所当然,上述例子中将字符串转换成[]rune再遍历也是一样的道理,如下
func main() {
str := "hello 世界!"
runes := []rune(str)
for i := 0; i < len(runes); i++ {
fmt.Println(string(runes[i]))
}
}2
3
4
5
6
7
还可以使用utf8包下的工具,例如
func main() {
str := "hello 世界!"
for i, w := 0, 0; i < len(str); i += w {
r, width := utf8.DecodeRuneInString(str[i:])
fmt.Println(string(r))
w = width
}
}2
3
4
5
6
7
8
这两个例子的输出都是相同的。
TIP
关于字符串的更多细节,可以前往Strings, bytes, runes and characters in Go了解。
映射表
一般来说,映射表数据结构实现通常有两种,哈希表(hash table)和搜索树(search tree),区别在于前者无序,后者有序。在 Go 中,map的实现是基于哈希桶(也是一种哈希表),所以也是无序的,本篇不会对实现原理做过多的讲解,这超出了基础的范围,后续会进行深入分析。
TIP
想要了解 map 的原理可以前往map 实现
初始化
在 Go 中,map 的键类型必须是可比较的,比如string ,int是可比较的,而[]int是不可比较的,也就无法作为 map 的键。初始化一个 map 有两种方法,第一种是字面量,格式如下:
map[keyType]valueType{}举几个例子
mp := map[int]string{
0: "a",
1: "a",
2: "a",
3: "a",
4: "a",
}
mp := map[string]int{
"a": 0,
"b": 22,
"c": 33,
}2
3
4
5
6
7
8
9
10
11
12
13
第二种方法是使用内置函数make,对于 map 而言,接收两个参数,分别是类型与初始容量,例子如下:
mp := make(map[string]int, 8)
mp := make(map[string][]int, 10)2
3
map 是引用类型,零值或未初始化的 map 可以访问,但是无法存放元素,所以必须要为其分配内存。
func main() {
var mp map[string]int
mp["a"] = 1
fmt.Println(mp)
}2
3
4
5
panic: assignment to entry in nil mapTIP
在初始化 map 时应当尽量分配一个合理的容量,以减少扩容次数。
访问
访问一个 map 的方式就像通过索引访问一个数组一样。
func main() {
mp := map[string]int{
"a": 0,
"b": 1,
"c": 2,
"d": 3,
}
fmt.Println(mp["a"])
fmt.Println(mp["b"])
fmt.Println(mp["d"])
fmt.Println(mp["f"])
}2
3
4
5
6
7
8
9
10
11
12
0
1
3
02
3
4
通过代码可以观察到,即使 map 中不存在"f"这一键值对,但依旧有返回值。map 对于不存的键其返回值是对应类型的零值,并且在访问 map 的时候其实有两个返回值,第一个返回值对应类型的值,第二个返回值一个布尔值,代表键是否存在,例如:
func main() {
mp := map[string]int{
"a": 0,
"b": 1,
"c": 2,
"d": 3,
}
if val, exist := mp["f"]; exist {
fmt.Println(val)
} else {
fmt.Println("key不存在")
}
}2
3
4
5
6
7
8
9
10
11
12
13
对 map 求长度
func main() {
mp := map[string]int{
"a": 0,
"b": 1,
"c": 2,
"d": 3,
}
fmt.Println(len(mp))
}2
3
4
5
6
7
8
9
存值
map 存值的方式也类似数组存值一样,例如:
func main() {
mp := make(map[string]int, 10)
mp["a"] = 1
mp["b"] = 2
fmt.Println(mp)
}2
3
4
5
6
存值时使用已存在的键会覆盖原有的值
func main() {
mp := make(map[string]int, 10)
mp["a"] = 1
mp["b"] = 2
if _, exist := mp["b"]; exist {
mp["b"] = 3
}
fmt.Println(mp)
}2
3
4
5
6
7
8
9
但是也存在一个特殊情况,那就是键为math.NaN()时
func main() {
mp := make(map[float64]string, 10)
mp[math.NaN()] = "a"
mp[math.NaN()] = "b"
mp[math.NaN()] = "c"
_, exist := mp[math.NaN()]
fmt.Println(exist)
fmt.Println(mp)
}2
3
4
5
6
7
8
9
false
map[NaN:c NaN:a NaN:b]2
通过结果可以观察到相同的键值并没有覆盖,反而还可以存在多个,也无法判断其是否存在,也就无法正常取值。因为 NaN 是 IEE754 标准所定义的,其实现是由底层的汇编指令UCOMISD完成,这是一个无序比较双精度浮点数的指令,该指令会考虑到 NaN 的情况,因此结果就是任何数字都不等于 NaN,NaN 也不等于自身,这也造成了每次哈希值都不相同。关于这一点社区也曾激烈讨论过,但是官方认为没有必要去修改,所以应当尽量避免使用 NaN 作为 map 的键。
删除
func delete(m map[Type]Type1, key Type)删除一个键值对需要用到内置函数delete,例如
func main() {
mp := map[string]int{
"a": 0,
"b": 1,
"c": 2,
"d": 3,
}
fmt.Println(mp)
delete(mp, "a")
fmt.Println(mp)
}2
3
4
5
6
7
8
9
10
11
map[a:0 b:1 c:2 d:3]
map[b:1 c:2 d:3]2
需要注意的是,如果值为 NaN,甚至没法删除该键值对。
func main() {
mp := make(map[float64]string, 10)
mp[math.NaN()] = "a"
mp[math.NaN()] = "b"
mp[math.NaN()] = "c"
fmt.Println(mp)
delete(mp, math.NaN())
fmt.Println(mp)
}2
3
4
5
6
7
8
9
map[NaN:c NaN:a NaN:b]
map[NaN:c NaN:a NaN:b]2
遍历
通过for range可以遍历 map,例如
func main() {
mp := map[string]int{
"a": 0,
"b": 1,
"c": 2,
"d": 3,
}
for key, val := range mp {
fmt.Println(key, val)
}
}2
3
4
5
6
7
8
9
10
11
c 2
d 3
a 0
b 12
3
4
可以看到结果并不是有序的,也印证了 map 是无序存储。值得一提的是,NaN 虽然没法正常获取,但是可以通过遍历访问到,例如
func main() {
mp := make(map[float64]string, 10)
mp[math.NaN()] = "a"
mp[math.NaN()] = "b"
mp[math.NaN()] = "c"
for key, val := range mp {
fmt.Println(key, val)
}
}2
3
4
5
6
7
8
9
NaN a
NaN c
NaN b2
3
清空
在 go1.21 之前,想要清空 map,就只能对每一个 map 的 key 进行 delete
func main() {
m := map[string]int{
"a": 1,
"b": 2,
}
for k, _ := range m {
delete(m, k)
}
fmt.Println(m)
}2
3
4
5
6
7
8
9
10
但是 go1.21 更新了 clear 函数,就不用再进行之前的操作了,只需要一个 clear 就可以清空
func main() {
m := map[string]int{
"a": 1,
"b": 2,
}
clear(m)
fmt.Println(m)
}2
3
4
5
6
7
8
输出
map[]Set
Set 是一种无序的,不包含重复元素的集合,Go 中并没有提供类似的数据结构实现,但是 map 的键正是无序且不能重复的,所以也可以使用 map 来替代 set。
func main() {
set := make(map[int]struct{}, 10)
for i := 0; i < 10; i++ {
set[rand.Intn(100)] = struct{}{}
}
fmt.Println(set)
}2
3
4
5
6
7
map[0:{} 18:{} 25:{} 40:{} 47:{} 56:{} 59:{} 81:{} 87:{}]TIP
一个空的结构体不会占用内存
注意
map 并不是一个并发安全的数据结构,Go 团队认为大多数情况下 map 的使用并不涉及高并发的场景,引入互斥锁会极大的降低性能,map 内部有读写检测机制,如果冲突会触发fatal error。例如下列情况有非常大的可能性会触发fatal。
func main() {
group.Add(10)
// map
mp := make(map[string]int, 10)
for i := 0; i < 10; i++ {
go func() {
// 写操作
for i := 0; i < 100; i++ {
mp["helloworld"] = 1
}
// 读操作
for i := 0; i < 10; i++ {
fmt.Println(mp["helloworld"])
}
group.Done()
}()
}
group.Wait()
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fatal error: concurrent map writes在这种情况下,需要使用sync.Map来替代。
指针
Go 保留了指针,在一定程度上保证了性能,同时为了更好的 GC 和安全考虑,又限制了指针的使用。
创建
关于指针有两个常用的操作符,一个是取地址符&,另一个是解引用符*。对一个变量进行取地址,会返回对应类型的指针,例如:
func main() {
num := 2
p := &num
fmt.Println(p)
}2
3
4
5
指针存储的是变量num的地址
0xc00001c088解引用符则有两个用途,第一个是访问指针所指向的元素,也就是解引用,例如
func main() {
num := 2
p := &num
rawNum := *p
fmt.Println(rawNum)
}2
3
4
5
6
p是一个指针,对指针类型解引用就能访问到指针所指向的元素。还有一个用途就是声明一个指针,例如:
func main() {
var numPtr *int
fmt.Println(numPtr)
}2
3
4
<nil>*int即代表该变量的类型是一个int类型的指针,不过指针不能光声明,还得初始化,需要为其分配内存,否则就是一个空指针,无法正常使用。要么使用取地址符将其他变量的地址赋值给该指针,要么就使用内置函数new手动分配,例如:
func main() {
var numPtr *int
numPtr = new(int)
fmt.Println(numPtr)
}2
3
4
5
更多的是使用短变量
func main() {
numPtr := new(int)
fmt.Println(numPtr)
}2
3
4
new函数只有一个参数那就是类型,并返回一个对应类型的指针,函数会为该指针分配内存,并且指针指向对应类型的零值,例如:
func main() {
fmt.Println(*new(string))
fmt.Println(*new(int))
fmt.Println(*new([5]int))
fmt.Println(*new([]float64))
}2
3
4
5
6
0
[0 0 0 0 0]
[]2
3
禁止指针运算
在 Go 中是不支持指针运算的,也就是说指针无法偏移,先来看一段 C++代码:
int main() {
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int *p = &arr[0];
cout << &arr << endl
<< p << endl
<< p + 1 << endl
<< &arr[1] << endl;
}2
3
4
5
6
7
8
0x31d99ff880
0x31d99ff880
0x31d99ff884
0x31d99ff8842
3
4
可以看出数组的地址与数字第一个元素的地址一致,并且对指针加一运算后,其指向的元素为数组第二个元素。Go 中的数组也是如此,不过区别在于指针无法偏移,例如
func main() {
arr := [5]int{0, 1, 2, 3, 4}
p := &arr
println(&arr[0])
println(p)
// 试图进行指针运算
p++
fmt.Println(p)
}2
3
4
5
6
7
8
9
这样的程序将无法通过编译,报错如下
main.go:10:2: invalid operation: p++ (non-numeric type *[5]int)TIP
标准库unsafe提供了许多用于低级编程的操作,其中就包括指针运算,前往标准库-unsafe了解细节。
new 和 make
在前面的几节已经很多次提到过内置函数new和make,两者有点类似,但也有不同,下面复习下。
func new(Type) *Type- 返回值是类型指针
- 接收参数是类型
- 专用于给指针分配内存空间
func make(t Type, size ...IntegerType) Type- 返回值是值,不是指针
- 接收的第一个参数是类型,不定长参数根据传入类型的不同而不同
- 专用于给切片,映射表,通道分配内存。
下面是一些例子:
new(int) // int指针
new(string) // string指针
new([]int) // 整型切片指针
make([]int, 10, 100) // 长度为10,容量100的整型切片
make(map[string]int, 10) // 容量为10的映射表
make(chan int, 10) // 缓冲区大小为10的通道2
3
4
5
6
函数
在 Go 中,函数是一等公民,函数是 Go 最基础的组成部分,也是 Go 的核心。
声明
函数的声明格式如下
func 函数名([参数列表]) [返回值] {
函数体
}2
3
声明函数有两种办法,一种是通过func关键字直接声明,另一种就是通过var关键字来声明,如下所示
func sum(a int, b int) int {
return a + b
}
var sum = func(a int, b int) int {
return a + b
}2
3
4
5
6
7
函数签名由函数名称,参数列表,返回值组成,下面是一个完整的例子,函数名称为Sum,有两个int类型的参数a,b,返回值类型为int。
func Sum(a int, b int) int {
return a + b
}2
3
还有一个非常重要的点,即 Go 中的函数不支持重载,像下面的代码就无法通过编译
type Person struct {
Name string
Age int
Address string
Salary float64
}
func NewPerson(name string, age int, address string, salary float64) *Person {
return &Person{Name: name, Age: age, Address: address, Salary: salary}
}
func NewPerson(name string) *Person {
return &Person{Name: name}
}2
3
4
5
6
7
8
9
10
11
12
13
14
Go 的理念便是:如果签名不一样那就是两个完全不同的函数,那么就不应该取一样的名字,函数重载会让代码变得混淆和难以理解。这种理念是否正确见仁见智,至少在 Go 中你可以仅通过函数名就知道它是干什么的,而不需要去找它到底是哪一个重载。
参数
Go 中的参数名可以不带名称,一般这种是在接口或函数类型声明时才会用到,不过为了可读性一般还是建议尽量给参数加上名称
type ExWriter func(io.Writer) error
type Writer interface {
ExWrite([]byte) (int, error)
}2
3
4
5
对于类型相同的参数而言,可以只需要声明一次类型,不过条件是它们必须相邻
func Log(format string, a1, a2 any) {
...
}2
3
变长参数可以接收 0 个或多个值,必须声明在参数列表的末尾,最典型的例子就是fmt.Printf函数。
func Printf(format string, a ...any) (n int, err error) {
return Fprintf(os.Stdout, format, a...)
}2
3
值得一提的是,Go 中的函数参数是传值传递,即在传递参数时会拷贝实参的值。如果你觉得在传递切片或 map 时会复制大量的内存,我可以告诉你大可不必担心,因为这两个数据结构本质上都是指针。
返回值
下面是一个简单的函数返回值的例子,Sum函数返回一个int类型的值。
func Sum(a, b int) int {
return a + b
}2
3
当函数没有返回值时,不需要void,不带返回值即可。
func ErrPrintf(format string, a ...any) {
_, _ = fmt.Fprintf(os.Stderr, format, a...)
}2
3
Go 允许函数有多个返回值,此时就需要用括号将返回值围起来。
func Div(a, b float64) (float64, error) {
if a == 0 {
return math.NaN(), errors.New("0不能作为被除数")
}
return a / b, nil
}2
3
4
5
6
Go 也支持具名返回值,不能与参数名重复,使用具名返回值时,return关键字可以不需要指定返回哪些值。
func Sum(a, b int) (ans int) {
ans = a + b
return
}2
3
4
和参数一样,当有多个同类型的具名返回值时,可以省略掉重复的类型声明
func SumAndMul(a, b int) (c, d int) {
c = a + b
d = a * b
return
}2
3
4
5
不管具名返回值如何声明,永远都是以return关键字后的值为最高优先级。
func SumAndMul(a, b int) (c, d int) {
c = a + b
d = a * b
// c,d将不会被返回
return a + b, a * b
}2
3
4
5
6
匿名函数
匿名函数就是没有签名的函数,例如下面的函数func(a, b int) int,它没有名称,所以我们只能在它的函数体后紧跟括号来进行调用。
func main() {
func(a, b int) int {
return a + b
}(1, 2)
}2
3
4
5
在调用一个函数时,当它的参数是一个函数类型时,这时名称不再重要,就可以直接传递一个匿名函数,如下所示
type Person struct {
Name string
Age int
Salary float64
}
func main() {
people := []Person{
{Name: "Alice", Age: 25, Salary: 5000.0},
{Name: "Bob", Age: 30, Salary: 6000.0},
{Name: "Charlie", Age: 28, Salary: 5500.0},
}
slices.SortFunc(people, func(p1 Person, p2 Person) int {
if p1.Name > p2.Name {
return 1
} else if p1.Name < p2.Name {
return -1
}
return 0
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这是一个自定义排序规则的例子,slices.SortFunc接受两个参数,一个是切片,另一个就是比较函数,不考虑复用的话,我们就可以直接传递匿名函数。
闭包
闭包(Closure)这一概念,在一些语言中又被称为 Lambda 表达式,与匿名函数一起使用,闭包 = 函数 + 环境引用吗,看下面一个例子:
func main() {
grow := Exp(2)
for i := range 10 {
fmt.Printf("2^%d=%d\n", i, grow())
}
}
func Exp(n int) func() int {
e := 1
return func() int {
temp := e
e *= n
return temp
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
输出
2^0=1
2^1=2
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^7=128
2^8=256
2^9=5122
3
4
5
6
7
8
9
10
Exp函数的返回值是一个函数,这里将称成为grow函数,每将它调用一次,变量e就会以指数级增长一次。grow函数引用了Exp函数的两个变量:e和n,它们诞生在Exp函数的作用域内,在正常情况下随着Exp函数的调用结束,这些变量的内存会随着出栈而被回收。但是由于grow函数引用了它们,所以它们无法被回收,而是逃逸到了堆上,即使Exp函数的生命周期已经结束了,但变量e和n的生命周期并没有结束,在grow函数内还能直接修改这两个变量,grow函数就是一个闭包函数。
利用闭包,可以非常简单的实现一个求费波那契数列的函数,代码如下
func main() {
// 10个斐波那契数
fib := Fib(10)
for n, next := fib(); next; n, next = fib() {
fmt.Println(n)
}
}
func Fib(n int) func() (int, bool) {
a, b, c := 1, 1, 2
i := 0
return func() (int, bool) {
if i >= n {
return 0, false
} else if i < 2 {
f := i
i++
return f, true
}
a, b = b, c
c = a + b
i++
return a, true
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
输出为
0
1
1
2
3
5
8
13
21
342
3
4
5
6
7
8
9
10
延迟调用
defer关键字可以使得一个函数延迟一段时间调用,在函数返回之前这些 defer 描述的函数最后都会被逐个执行,看下面一个例子
func main() {
Do()
}
func Do() {
defer func() {
fmt.Println("1")
}()
fmt.Println("2")
}2
3
4
5
6
7
8
9
10
2
12
因为 defer 是在函数返回前执行的,你也可以在 defer 中修改函数的返回值
func main() {
fmt.Println(sum(3, 5))
}
func sum(a, b int) (s int) {
defer func() {
s -= 10
}()
s = a + b
return
}2
3
4
5
6
7
8
9
10
11
当有多个 defer 描述的函数时,就会像栈一样先进后出的顺序执行。
func main() {
fmt.Println(0)
Do()
}
func Do() {
defer fmt.Println(1)
fmt.Println(2)
defer fmt.Println(3)
defer fmt.Println(4)
fmt.Println(5)
}2
3
4
5
6
7
8
9
10
11
12
0
2
5
4
3
12
3
4
5
6
延迟调用通常用于释放文件资源,关闭网络连接等操作,还有一个用法是捕获panic,不过这是错误处理一节中才会涉及到的东西。
循环
虽然没有明令禁止,一般建议不要在 for 循环中使用 defer,如下所示
func main() {
n := 5
for i := range n {
defer fmt.Println(i)
}
}2
3
4
5
6
输出如下
4
3
2
1
02
3
4
5
这段代码结果是正确的,但过程也许不对。在 Go 中,每创建一个 defer,就需要在当前协程申请一片内存空间。假设在上面例子中不是简单的 for n 循环,而是一个较为复杂的数据处理流程,当外部请求数突然激增时,那么在短时间内就会创建大量的 defer,在循环次数很大或次数不确定时,就可能会导致内存占用突然暴涨,这种我们一般称之为内存泄漏。
参数预计算
对于延迟调用有一些反直觉的细节,比如下面这个例子
func main() {
defer fmt.Println(Fn1())
fmt.Println("3")
}
func Fn1() int {
fmt.Println("2")
return 1
}2
3
4
5
6
7
8
9
这个坑还是非常隐晦的,笔者以前就因为这个坑,半天排查不出来是什么原因,可以猜猜输出是什么,答案如下
2
3
12
3
可能很多人认为是下面这种输出
3
2
12
3
按照使用者的初衷来说,fmt.Println(Fn1())这部分应该是希望它们在函数体执行结束后再执行,fmt.Println确实是最后执行的,但Fn1()是在意料之外的,下面这个例子的情况就更加明显了。
func main() {
var a, b int
a = 1
b = 2
defer fmt.Println(sum(a, b))
a = 3
b = 4
}
func sum(a, b int) int {
return a + b
}2
3
4
5
6
7
8
9
10
11
12
它的输出一定是 3 而不是 7,如果使用闭包而不是延迟调用,结果又不一样了
func main() {
var a, b int
a = 1
b = 2
f := func() {
fmt.Println(sum(a, b))
}
a = 3
b = 4
f()
}2
3
4
5
6
7
8
9
10
11
闭包的输出是 7,那如果把延迟调用和闭包结合起来呢
func main() {
var a, b int
a = 1
b = 2
defer func() {
fmt.Println(sum(a, b))
}()
a = 3
b = 4
}2
3
4
5
6
7
8
9
10
这次就正常了,输出的是 7。下面再改一下,没有闭包了
func main() {
var a, b int
a = 1
b = 2
defer func(num int) {
fmt.Println(num)
}(sum(a, b))
a = 3
b = 4
}2
3
4
5
6
7
8
9
10
输出又变回 3 了。通过对比上面几个例子可以发现这段代码
defer fmt.Println(sum(a,b))其实等价于
defer fmt.Println(3)go 不会等到最后才去调用sum函数,sum函数早在延迟调用被执行以前就被调用了,并作为参数传递了fmt.Println。总结就是,对于defer直接作用的函数而言,它的参数是会被预计算的,这也就导致了第一个例子中的奇怪现象,对于这种情况,尤其是在延迟调用中将函数返回值作为参数的情况尤其需要注意。
结构体
结构体可以存储一组不同类型的数据,是一种复合类型。Go 抛弃了类与继承,同时也抛弃了构造方法,刻意弱化了面向对象的功能,Go 并非是一个传统 OOP 的语言,但是 Go 依旧有着 OOP 的影子,通过结构体和方法也可以模拟出一个类。下面是一个简单的结构体的例子:
type Programmer struct {
Name string
Age int
Job string
Language []string
}2
3
4
5
6
声明
结构体的声明非常简单,例子如下:
type Person struct {
name string
age int
}2
3
4
结构体本身以及其内部的字段都遵守大小写命名的暴露方式。对于一些类型相同的相邻字段,可以不需要重复声明类型,如下:
type Rectangle struct {
height, width, area int
color string
}2
3
4
TIP
在声明结构体字段时,字段名不能与方法名重复
实例化
Go 不存在构造方法,大多数情况下采用如下的方式来实例化结构体,初始化的时候就像map一样指定字段名称再初始化字段值
programmer := Programmer{
Name: "jack",
Age: 19,
Job: "coder",
Language: []string{"Go", "C++"},
}2
3
4
5
6
不过也可以省略字段名称,当省略字段名称时,就必须初始化所有字段,通常不建议使用这种方式,因为可读性很糟糕。
programmer := Programmer{
"jack",
19,
"coder",
[]string{"Go", "C++"}}2
3
4
5
如果实例化过程比较复杂,你也可以编写一个函数来实例化结构体,就像下面这样,你也可以把它理解为一个构造函数
type Person struct {
Name string
Age int
Address string
Salary float64
}
func NewPerson(name string, age int, address string, salary float64) *Person {
return &Person{Name: name, Age: age, Address: address, Salary: salary}
}2
3
4
5
6
7
8
9
10
不过 Go 并不支持函数与方法重载,所以你无法为同一个函数或方法定义不同的参数。如果你想以多种方式实例化结构体,要么创建多个构造函数,要么建议使用 options 模式。
选项模式
选项模式是 Go 语言中一种很常见的设计模式,可以更为灵活的实例化结构体,拓展性强,并且不需要改变构造函数的函数签名。假设有下面这样一个结构体
type Person struct {
Name string
Age int
Address string
Salary float64
Birthday string
}2
3
4
5
6
7
声明一个PersonOptions类型,它接受一个*Person类型的参数,它必须是指针,因为我们要在闭包中对 Person 赋值。
type PersonOptions func(p *Person)接下来创建选项函数,它们一般是With开头,它们的返回值就是一个闭包函数。
func WithName(name string) PersonOptions {
return func(p *Person) {
p.Name = name
}
}
func WithAge(age int) PersonOptions {
return func(p *Person) {
p.Age = age
}
}
func WithAddress(address string) PersonOptions {
return func(p *Person) {
p.Address = address
}
}
func WithSalary(salary float64) PersonOptions {
return func(p *Person) {
p.Salary = salary
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
实际声明的构造函数签名如下,它接受一个可变长PersonOptions类型的参数。
func NewPerson(options ...PersonOptions) *Person {
// 优先应用options
p := &Person{}
for _, option := range options {
option(p)
}
// 默认值处理
if p.Age < 0 {
p.Age = 0
}
......
return p
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
这样一来对于不同实例化的需求只需要一个构造函数即可完成,只需要传入不同的 Options 函数即可
func main() {
pl := NewPerson(
WithName("John Doe"),
WithAge(25),
WithAddress("123 Main St"),
WithSalary(10000.00),
)
p2 := NewPerson(
WithName("Mike jane"),
WithAge(30),
)
}2
3
4
5
6
7
8
9
10
11
12
13
函数式选项模式在很多开源项目中都能看见,gRPC Server 的实例化方式也是采用了该设计模式。函数式选项模式只适合于复杂的实例化,如果参数只有简单几个,建议还是用普通的构造函数来解决。
组合
在 Go 中,结构体之间的关系是通过组合来表示的,可以显式组合,也可以匿名组合,后者使用起来更类似于继承,但本质上没有任何变化。例如:
显式组合的方式
type Person struct {
name string
age int
}
type Student struct {
p Person
school string
}
type Employee struct {
p Person
job string
}2
3
4
5
6
7
8
9
10
11
12
13
14
在使用时需要显式的指定字段p
student := Student{
p: Person{name: "jack", age: 18},
school: "lili school",
}
fmt.Println(student.p.name)2
3
4
5
而匿名组合可以不用显式的指定字段
type Person struct {
name string
age int
}
type Student struct {
Person
school string
}
type Employee struct {
Person
job string
}2
3
4
5
6
7
8
9
10
11
12
13
14
匿名字段的名称默认为类型名,调用者可以直接访问该类型的字段和方法,但除了更加方便以外与第一种方式没有任何的区别。
student := Student{
Person: Person{name: "jack",age: 18},
school: "lili school",
}
fmt.Println(student.name)2
3
4
5
指针
对于结构体指针而言,不需要解引用就可以直接访问结构体的内容,例子如下:
p := &Person{
name: "jack",
age: 18,
}
fmt.Println(p.age,p.name)2
3
4
5
在编译的时候会转换为(*p).name ,(*p).age,其实还是需要解引用,不过在编码的时候可以省去,算是一种语法糖。
标签
结构体标签是一种元编程的形式,结合反射可以做出很多奇妙的功能,格式如下
`key1:"val1" key2:"val2"`标签是一种键值对的形式,使用空格进行分隔。结构体标签的容错性很低,如果没能按照正确的格式书写结构体,那么将会导致无法正常读取,但是在编译时却不会有任何的报错,下方是一个使用示例。
type Programmer struct {
Name string `json:"name"`
Age int `yaml:"age"`
Job string `toml:"job"`
Language []string `properties:"language"`
}2
3
4
5
6
结构体标签最广泛的应用就是在各种序列化格式中的别名定义,标签的使用需要结合反射才能完整发挥出其功能。
内存对齐
Go 结构体字段的内存分布遵循内存对齐的规则,这么做可以减少 CPU 访问内存的次数,相应的占用的内存要多一些,属于空间换时间的一种手段。假设有如下结构体
type Num struct {
A int64
B int32
C int16
D int8
E int32
}2
3
4
5
6
7
已知这些类型的占用字节数
int64占 8 个字节int32占 4 个字节int16占 2 字节int8占一个字节
整个结构体的内存占用似乎是 8+4+2+1+4=19 个字节吗,当然不是这样,根据内存对齐规则而言,结构体的内存占用长度至少是最大字段的整数倍,不足的则补齐。该结构体中最大的是int64占用 8 个字节,那么内存分布如下图所示

所以实际上是占用 24 个字节,其中有 5 个字节是无用的。
再来看下面这个结构体
type Num struct {
A int8
B int64
C int8
}2
3
4
5
明白了上面的规则后,可以很快的理解它的内存占用也是 24 个字节,尽管它只有三个字段,足足浪费了 14 个字节。

但是我们可以调整字段,改成如下的顺序
type Num struct {
A int8
C int8
B int64
}2
3
4
5
如此一来就占用的内存就变为了 16 字节,浪费了 6 个字节,减少了 8 个字节的内存浪费。

从理论上来说,让结构体中的字段按照合理的顺序分布,可以减少其内存占用。不过实际编码过程中,并没有必要的理由去这样做,它不一定能在减少内存占用这方面带来实质性的提升,但一定会提高开发人员的血压和心智负担,尤其是在业务中一些结构体的字段数可能多大几十个或者数百个,所以仅做了解即可。
空结构体
空结构体没有字段,不占用内存空间,我们可以通过unsafe.SizeOf函数来计算占用的字节大小
func main() {
type Empty struct {}
fmt.Println(unsafe.Sizeof(Empty{}))
}2
3
4
输出
0空结构体的使用场景有很多,比如之前提到过的,作为map的值类型,可以将map作为set来进行使用,又或者是作为通道的类型,表示仅做通知类型的通道。
方法
方法与函数的区别在于,方法拥有接收者,而函数没有,且只有自定义类型能够拥有方法。先来看一个例子。
type IntSlice []int
func (i IntSlice) Get(index int) int {
return i[index]
}
func (i IntSlice) Set(index, val int) {
i[index] = val
}
func (i IntSlice) Len() int {
return len(i)
}2
3
4
5
6
7
8
9
10
11
12
先声明了一个类型IntSlice,其底层类型为[]int,再声明了三个方法Get,Set和Len,方法的长相与函数并无太大的区别,只是多了一小段(i IntSlice) 。i就是接收者,IntSlice就是接收者的类型,接收者就类似于其他语言中的this或self,只不过在 Go 中需要显式的指明。
func main() {
var intSlice IntSlice
intSlice = []int{1, 2, 3, 4, 5}
fmt.Println(intSlice.Get(0))
intSlice.Set(0, 2)
fmt.Println(intSlice)
fmt.Println(intSlice.Len())
}2
3
4
5
6
7
8
方法的使用就类似于调用一个类的成员方法,先声明,再初始化,再调用。
值接收者
接收者也分两种类型,值接收者和指针接收者,先看一个例子
type MyInt int
func (i MyInt) Set(val int) {
i = MyInt(val) // 修改了,但是不会造成任何影响
}
func main() {
myInt := MyInt(1)
myInt.Set(2)
fmt.Println(myInt)
}2
3
4
5
6
7
8
9
10
11
上述代码运行过后,会发现myInt的值依旧是 1,并没有被修改成 2。方法在被调用时,会将接收者的值传入方法中,上例的接收者就是一个值接收者,可以简单的看成一个形参,而修改一个形参的值,并不会对方法外的值造成任何影响,那么如果通过指针调用会如何呢?
func main() {
myInt := MyInt(1)
(&myInt).Set(2)
fmt.Println(myInt)
}2
3
4
5
遗憾的是,这样的代码依旧不能修改内部的值,为了能够匹配上接收者的类型,Go 会将其解引用,解释为(*(&myInt)).Set(2)。
指针接收者
稍微修改了一下,就能正常修改myInt的值。
type MyInt int
func (i *MyInt) Set(val int) {
*i = MyInt(val)
}
func main() {
myInt := MyInt(1)
myInt.Set(2)
fmt.Println(myInt)
}2
3
4
5
6
7
8
9
10
11
现在的接收者就是一个指针接收者,虽然myInt是一个值类型,在通过值类型调用指针接收者的方法时,Go 会将其解释为(&myint).Set(2)。所以方法的接收者为指针时,不管调用者是不是指针,都可以修改内部的值。
函数的参数传递过程中,是值拷贝的,如果传递的是一个整型,那就拷贝这个整型,如果是一个切片,那就拷贝这个切片,但如果是一个指针,就只需要拷贝这个指针,显然传递一个指针比起传递一个切片所消耗的资源更小,接收者也不例外,值接收者和指针接收者也是同样的道理。在大多数情况下,都推荐使用指针接收者,不过两者并不应该混合使用,要么都用,要么就都不用,看下面一个例子。
TIP
需要先了解接口
type Animal interface {
Run()
}
type Dog struct {
}
func (d *Dog) Run() {
fmt.Println("Run")
}
func main() {
var an Animal
an = Dog{}
// an = &Dog{} 正确方式
an.Run()
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这一段代码将会无法通过编译,编译器将会输出如下错误
cannot use Dog{} (value of type Dog) as type Animal in assignment:
Dog does not implement Animal (Run method has pointer receiver)2
翻译过来就是,无法使用Dog{}初始化Animal类型的变量,因为Dog没有实现Animal ,解决办法有两种,一是将指针接收者改为值接收者,二是将Dog{}改为&Dog{},接下来逐个讲解。
type Dog struct {
}
func (d Dog) Run() { // 改为了值接收者
fmt.Println("Run")
}
func main() { // 可以正常运行
var an Animal
an = Dog{}
// an = &Dog{} 同样可以
an.Run()
}2
3
4
5
6
7
8
9
10
11
12
13
在原来的代码中,Run 方法的接收者是*Dog ,自然而然实现Animal接口的就是Dog指针,而不是Dog结构体,这是两个不同的类型,所以编译器就会认为Dog{}并不是Animal的实现,因此无法赋值给变量an,所以第二种解决办法就是赋值Dog指针给变量an。不过在使用值接收者时,Dog指针依然可以正常赋值给animal,这是因为 Go 会在适当情况下对指针进行解引用,因为通过指针可以找到Dog结构体,但是反过来的话,无法通过Dog结构体找到Dog指针。如果单纯的在结构体中混用值接收者和指针接收者的话无伤大雅,但是和接口一起使用后,就会出现错误,倒不如无论何时要么都用值接收者,要么就都用指针接收者,形成一个良好的规范,也可以减少后续维护的负担。
还有一种情况,就是当值接收者是可寻址的时候,Go 会自动的插入指针运算符来进行调用,例如切片是可寻址,依旧可以通过值接收者来修改其内部值。比如下面这个代码
type Slice []int
func (s Slice) Set(i int, v int) {
s[i] = v
}
func main() {
s := make(Slice, 1)
s.Set(0, 1)
fmt.Println(s)
}2
3
4
5
6
7
8
9
10
11
输出
[1]但这样会引发另一个问题,如果对其添加元素的话,情况就不同了。看下面的例子
type Slice []int
func (s Slice) Set(i int, v int) {
s[i] = v
}
func (s Slice) Append(a int) {
s = append(s, a)
}
func main() {
s := make(Slice, 1, 2)
s.Set(0, 1)
s.Append(2)
fmt.Println(s)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[1]它的输出还是和之前一样,append函数是有返回值的,向切片添加完元素后必须覆盖原切片,尤其是在扩容后,在方法中对值接收者修改并不会产生任何影响,这也就导致了例子中的结果,改成指针接收者就正常了。
type Slice []int
func (s *Slice) Set(i int, v int) {
(*s)[i] = v
}
func (s *Slice) Append(a int) {
*s = append(*s, a)
}
func main() {
s := make(Slice, 1, 2)
s.Set(0, 1)
s.Append(2)
fmt.Println(s)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
输出
[1 2]
评论区留言准则:
1. 本评论区禁止传播封建迷信、吸烟酗酒、低俗色情、赌博诈骗等任何违法违规内容。
2. 当他人以不正当方式诱导打赏、私下交易,请谨慎判断,以防人身财产损失。
3. 请勿轻信各类招聘征婚、代练代抽、私下交易、购买礼包码、游戏币等广告信息,谨防网络诈骗。