目录
基础语法部分相关概念
基础语法部分概念详解
可见性
导包
内部包
运算符
转义字符
函数
风格
函数花括号换行
代码缩进
代码间隔
花括号省略
三元表达式
数据类型部分相关概念
数据类型部分概念详解
布尔类型
整型
浮点型
复数类型
字符类型
派生类型
零值
nil
常量
初始化
iota
枚举
变量
声明
赋值
匿名
交换
比较
代码块
输入输出
输出
stdout
fmt
bufio
格式化
输入
read
fmt
bufio
bufio.Reader
bufio.Scanner
条件控制
if else
else if
switch
label
goto
循环控制
for
打印九九乘法表
for range
break
continue
切片
数组
初始化
使用
切割
切片
初始化
使用
插入元素
删除元素
拷贝
遍历
clear
字符串
字面量
普通字符串
原生字符串
访问
转换
长度
拷贝
拼接
遍历
映射表
初始化
访问
存值
删除
遍历
清空
Set
注意
指针
创建
禁止指针运算
new和make
函数
声明
匿名函数
闭包
延迟调用
循环
结构体
声明
实例化
选项模式
组合
指针
标签
空结构体
方法
Go学习的相关资料
// 学习使用的文档
// 中文文档:golang.halfiisland.com/essential/base/0.ready.html
// 官方文档:go.dev/doc
案例一:使用Go打印Hello World
// 打印Hello World
// 需要导包,使用输出函数,输出Hello World// package是当前文件属于那个包,入口文件都必须声明为main包,入口函数是main函数,在自定义包和函数时命名应尽量避免与之重复
package main// import是导入关键字,后面跟着的是被导入的包名
import "fmt"// func是函数声明关键字,用于声明一个函数
func main() {// 调用fmt包下的Println函数进行输出fmt.Println("Hello World")
}
基础语法部分相关概念
包 | 在Go中,程序是通过将包链接在一起来构建的。在Go中进行导入的最小单位是包而不是.go文件; 包其实就是一个文件夹,英文就是package,包内共享所有变量,常量,以及所有定义的类型; 包的命名风格建议都是小写,并且要尽量简短。 |
可见性 | 包内共享所有变量,常量,以及所有定义的类型,但对于包外而言并不是这样,有时候你并不想让别人访问某一个类型,所以就需要控制可见性; 如C语言中公共变量为Public,私有变量是Pravite等关键字进行定义; 但是在Go中没有这些,在Go中控制可见性的方式非常简单,规则为: 名称大写字母开头,即为公有类型/变量/常量 名称小写字母或下划线开头,即为私有类型/变量/常量 |
导入 | 导入一个包,就是导入这个包的所有共有的类型/变量/常量; 导入的语法: import "包名"(导入一个包) import ( “包1” “包2” ) (导入多个包) |
内部包 | Go中约定,一个包内名为internal 包为内部包,外部包将无法访问内部包中的任何内容,否则的话编译不通过 |
标识符 | 标识符就是一个名称,用于包命名,函数命名,变量命名等等,命名规则如下:
|
字面量 | 字面量,就是用于表达源代码中一个固定值的符号,也叫字面值 整型字面量,允许使用下划线 浮点数字面量,通过不同的前缀可以表达不同进制的浮点数 复数字面值 字符字面量,字符字面量必须使用单引号括起来 转移字符 字符串字面量,字符串字面量必须使用双引号""括起来或者反引号(反引号不允许转义) |
转义字符 | Go中允许使用转义字符 |
函数 | Go中的函数声明方式通过func关键字来进行; 在Go中的函数参数类型后置,如func hello(name string); 函数允许多返回值的情况,而且可以带有名字 |
风格 | 关于编码风格这一块 Go 是强制所有人统一同一种风格,Go 官方提供了一个格式化工具 在详解部分,有关于Go代码风格的详解。 |
基础语法部分概念详解
可见性
// Go中变量的定义,用const定义常量,用var定义变量,变量类型可以省略,默认是int类型
// 定义一个公共常量MyName和一个私有常量myName
// 我理解的这里就像电视剧台词,在家叫吕子乔,出门叫吕小布
const MyName = "吕小布"
const myName = "吕子乔"
导包
// 导包
// 导入一个包时,包名必须是唯一的,不能与当前文件中的变量名冲突
import "fmt"// 导入多个包
import ("fmt""os"
)// 如果导入的包名与当前文件中的变量名冲突,可以使用别名
import (f "fmt"o "os"
)// 匿名导入,另一种特殊的使用方法就是匿名导入包,匿名导入的包无法被使用,这么做通常是为了加载包下的init函数,但又不需要用到包中的类型,例如一个常见的场景就是注册数据库驱动
import (f "fmt"_ "mysql-driver" // 假设这是一个MySQL驱动包,导入后可以使用其init函数进行注册
)
内部包
文件结构中可知,crash
包无法访问baz
包中的类型,baz包为内部包
/home/user/go/src/crash/bang/ (go code in package bang)b.gofoo/ (go code in package foo)f.gobar/ (go code in package bar)x.gointernal/baz/ (go code in package baz)z.goquux/ (go code in package main)y.go
运算符
下面是 Go 语言中支持的运算符号的优先级排列,也可以前往参考手册-运算符查看更多细节。
Precedence Operator5 * / % << >> & &^4 + - | ^3 == != < <= > >=2 &&1 ||
有一点需要稍微注意下,go 语言中没有选择将~
作为取反运算符,而是复用了^符号,当两个数字使用^
时,例如a^b
,它就是异或运算符,只对一个数字使用时,例如^a
,那么它就是取反运算符。go 也支持增强赋值运算符,如下。
a += 1
a /= 2
a &^= 2
自增、自减问题
Go 语言中没有自增与自减运算符,它们被降级为了语句statement
,并且规定了只能位于操作数的后方,所以不用再去纠结i++
和++i
这样的问题。
a++ // 正确
++a // 错误
a-- // 正确
还有一点就是,它们不再具有返回值,因此a = b++
这类语句的写法是错误的。
自增、自减不具有返回值。
转义字符
\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 双引号转义 (该转义仅在字符串内有效)
函数
Go 中的函数声明方式通过func
关键字来进行,跟大多数语言类似
func main() {println(1)
}
不过 Go 中的函数有两个不同的点,第一个是参数类型后置,像下面这样
func Hello(name string) {fmt.Println(name)
}
第二个不同的点就是多返回值,而且可以带名字
func Pos() () (x, y float64) {...
}
风格
函数花括号换行
关于函数后的花括号到底该不该换行,几乎每个程序员都能说出属于自己的理由,在 Go 中所有的花括号都不应该换行
// 正确示例
func main() {fmt.Println("Hello 世界!")
}
如果你真的这么做了,像下面这样
// 错误示例
func main()
{fmt.Println("Hello 世界!")
}
这样的代码连编译都过不了,所以 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
数据类型部分相关概念
布尔类型 | 布尔类型只有真值和假值,bool类型只有true(真值)、false(假值) 数值无法替代布尔值进行逻辑判断,bool和数值是两种不同类型 |
整型 | Go 中为不同位数的整数分配了不同的类型,主要分为无符号整型与有符号整型 |
浮点型 | IEEE-754 浮点数,主要分为单精度浮点数与双精度浮点数 |
复数类型 | |
字符类型 | Go语言字符类型完全兼容UTF-8 |
派生类型 | |
零值 | 官方文档中零值称为zero value ,零值并不仅仅只是字面上的数字零,而是一个类型的空值或者说默认值更为准确 |
nil | nil 类似于其它语言中的none 或者null ,但并不等同。nil 仅仅只是一些引用类型的零值,并且不属于任何类型,从源代码中的nil 可以看出它仅仅只是一个变量 |
数据类型部分概念详解
布尔类型
布尔类型只有真值和假值。
类型 | 描述 |
---|---|
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 // 常量表达式
如果仅仅只是声明而不指定值,将会无法通过编译(常量定义时就要进行赋值)
const name string
编译器报错
missing init expr for name
批量声明常量可以用()
括起来以提升可读性,可以存在多个()
达到分组的效果。
const (Count = 1Name = "Jack"
)const (Size = 16Len = 25
)
在同一个常量分组中,在已经赋值的常量后面的常量可以不用赋值,其值默认就是前一个的值,比如
const (A = 1B // 1C // 1D // 1E // 1
)
iota
内置的常量标识符,用于表示一个常量声明的无类型整数序数,一般都是在括号中使用。
iota
是一个内置的常量标识符,通常用于表示一个常量声明中的无类型整数序数,一般都是在括号中使用。
const iota = 0
看几个使用案例
const (Num = iota // 0Num1 // 1Num2 // 2Num3 // 3Num4 // 4
)
也可以这么写
const (Num = iota*2 // 0Num1 // 2Num2 // 4Num3 // 6Num4 // 8
)
还可以
const (Num = iota << 2*3 + 1 // 1Num1 // 13Num2 // 25Num3 = iota // 3Num4 // 4
)
通过上面几个例子可以发现,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 第七行
)
例子中使用了匿名标识符_
占了一行的位置,可以看到iota
的值本质上就是iota
所在行相对于当前const
分组的第一行的差值。而不同的const
分组则相互不会影响
枚举
Go 语言没有为枚举单独设计一个数据类型,不像其它语言通常会有一个enum
来表示。一般在 Go 中,都是通过自定义类型 + const + iota 来实现枚举,下面是一个简单的例子
type Season uint8const (Spring Season = iotaSummerAutumnWinter
)
这些枚举实际上就是数字,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 ""
}
这样一来就是一个简单的枚举实现了。你也可以通过官方工具Stringer来自动生成枚举。
不过它有以下缺点:
-
类型不安全,因为
Season
是自定义类型,可以通过强制类型转换将其他数字也转换成该类型Season(6)
-
繁琐,字符串表现形式需要自己实现
-
表达能力弱,因为
const
仅支持基本数据类型,所以这些枚举值也只能用字符串和数字来进行表示
变量
变量是用于保存一个值的存储位置,变量的声明会用到var关键字,允许其存储的值在运行时动态的变化。每声明一个变量,都会为其分配一块内存以存储对应类型的值
声明
go 中的类型声明是后置的,变量的声明会用到var
关键字,格式为var 变量名 类型名
,变量名的命名规则必须遵守标识符的命名规则。
Go语言中变量定义时,变量类型名在变量名的后面。
var intNum int
var str string
var char byte
当要声明多个相同类型的变量时,可以只写一次类型
var numA, numB, numC int
当要声明多个不同类型的变量时,可以使用 () 进行包裹,可以存在多个()
var (name stringage intaddress string
)var (school stringclass int
)
一个变量如果只是声明而不是赋值,那么变量存储的值就是对应类型的零值。
赋值
赋值会用到运算符 =
var name string
name = "jack"
也可以声明的时候直接赋值
var name string = "jack"
或者这样也可以
var name string
var age int
name, age = "jack", 1
第二种方式每次都要指定类型,可以使用官方提供的语法糖:短变量初始化,可以省略掉var
关键字和后置类型,具体是什么类型交给编译器自行推断。
name := "jack" // 字符串类型的变量。
虽然可以不用指定类型,但是在后续赋值时,类型必须保持一致,下面这种代码无法通过编译。
a := 1
a = "1"
还需要注意的是,短变量初始化不能使用nil
,因为nil
不属于任何类型,编译器无法推断其类型。
name := nil // 无法通过编译
短变量声明可以批量初始化
name, age := "jack", 1
短变量声明方式无法对一个已存在的变量使用,比如
// 错误示例
var a int
a := 1// 错误示例
a := 1
a := 2
但是有一种情况除外,那就是在赋值旧变量的同时声明一个新的变量,比如
a := 1
a, b := 2, 2
这种代码是可以通过编译的,变量a
被重新赋值,而b
是新声明的。
在 go 语言中,有一个规则,那就是所有在函数中的变量都必须要被使用,比如下面的代码只是声明了变量,但没有使用它
func main() {a := 1
}
那么在编译时就会报错,提示你这个变量声明了但没有使用
a declared and not used
这个规则仅适用于函数内的变量,对于函数外的包级变量则没有这个限制,下面这个代码就可以通过编译。
var a = 1func main() {}
匿名
用下划线可以表示不需要某一个变量
比如os.Open
函数有两个返回值,我们只想要第一个,不想要第二个,可以按照下面这样写
file, _ := os.Open("readme.txt")
未使用的变量是无法通过编译的,当你不需要某一个变量时,就可以使用下划线_
代替
交换
在Go中,如果想要交换两个变量的值,不需要使用指针,可以使用赋值运算符直接进行交换,语法上看起来非常直观
num1, num2 := 25, 36
num1, num2 = num2, num1
三个变量也是同样如此
num1, num2, num3 := 25, 36, 49
num1, num2, num3 = num3, num2, num1
比较
变量之间的比较有一个大前提,那就是它们之间的类型必须相同,go 语言中不存在隐式类型转换,像下面这样的代码是无法通过编译的
func main() {var a uint64var b int64fmt.Println(a == b)
}
编译器会告诉你两者之间类型并不相同
invalid operation: a == b (mismatched types uint64 and int64)
所以必须使用强制类型转换
func main() {var a uint64var b int64fmt.Println(int64(a) == b)
}
在没有泛型之前,早期 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)
}
代码块
在函数内部,可以通过花括号建立一个代码块,代码块彼此之间的变量作用域是相互独立的。例如下面的代码
func main() {a := 1{a := 2fmt.Println(a)}{a := 3fmt.Println(a)}fmt.Println(a)
}
它的输出是
2
3
1
块与块之间的变量相互独立,不受干扰,无法访问,但是会受到父块中的影响。
func main() {a := 1{a := 2fmt.Println(a)}{fmt.Println(a)}fmt.Println(a)
}
它的输出是
2
1
1
输入输出
输出
文件描述符 | 在
Go 中的输入输出都离不开它们 |
stdout | |
stdout
因为标准输出本身就是一个文件,所以你可以直接将字符串写入到标准输出中
package mainimport "os"func main() {os.Stdout.WriteString("hello world!")
}
Go 有两个内置的函数print
,println
,他们会将参数输出到标准错误中,仅做调试用,一般不推荐使用。
package mainfunc main() {print("hello world!\n")println("hello world")
}
fmt
最常见的用法是使用fmt
包,它提供了fmt.Println
函数,该函数默认会将参数输出到标准输出中。
package mainimport "fmt"func main() {fmt.Println("hello world!")
}
它的参数支持任意类型,如果类型实现了String
接口也会调用String
方法来获取其字符串表现形式,所以它输出的内容可读性比较高,适用于大部分情况,不过由于内部用到了反射,在性能敏感的场景不建议大量使用。
bufio
bufio
提供了可缓冲的输出方法,它会先将数据写入到内存中,积累到了一定阈值再输出到指定的Writer
中,默认缓冲区大小是4KB
。在文件 IO,网络 IO 的时候建议使用这个包。
func main() {writer := bufio.NewWriter(os.Stdout)defer writer.Flush()writer.WriteString("hello world!")
}
你也可以把它和fmt
包结合起来用
func main() {writer := bufio.NewWriter(os.Stdout)defer writer.Flush()fmt.Fprintln(writer, "hello world!")
}
格式化
Go 中的格式化输出功能基本上由fmt.Printf
函数提供,如果你学过 C 系语言,一定会觉得很熟悉,下面是一个简单的例子。
func main() {fmt.Printf("hello world, %s!", "jack")
}
下面是 Go 目前所有的格式化动词。
0 | 格式化 | 描述 | 接收类型 |
---|---|---|---|
1 | %% | 输出百分号% | 任意 |
2 | %s | 输出string /[] byte 值 | string ,[] byte |
3 | %q | 格式化字符串,输出的字符串两端有双引号"" | string ,[] byte |
4 | %d | 输出十进制整型值 | 整型 |
5 | %f | 输出浮点数 | 浮点 |
6 | %e | 输出科学计数法形式 ,也可以用于复数 | 浮点 |
7 | %E | 与%e 相同 | 浮点 |
8 | %g | 根据实际情况判断输出%f 或者%e ,会去掉多余的 0 | 浮点 |
9 | %b | 输出整型的二进制表现形式 | 数字 |
10 | %#b | 输出二进制完整的表现形式 | 数字 |
11 | %o | 输出整型的八进制表示 | 整型 |
12 | %#o | 输出整型的完整八进制表示 | 整型 |
13 | %x | 输出整型的小写十六进制表示 | 数字 |
14 | %#x | 输出整型的完整小写十六进制表示 | 数字 |
15 | %X | 输出整型的大写十六进制表示 | 数字 |
16 | %#X | 输出整型的完整大写十六进制表示 | 数字 |
17 | %v | 输出值原本的形式,多用于数据结构的输出 | 任意 |
18 | %+v | 输出结构体时将加上字段名 | 任意 |
19 | %#v | 输出完整 Go 语法格式的值 | 任意 |
20 | %t | 输出布尔值 | 布尔 |
21 | %T | 输出值对应的 Go 语言类型值 | 任意 |
22 | %c | 输出 Unicode 码对应的字符 | int32 |
23 | %U | 输出字符对应的 Unicode 码 | rune ,byte |
24 | %p | 输出指针所指向的地址 | 指针 |
使用fmt.Sprintf
或者fmt.Printf
来格式化字符串或者输出格式化字符串,看几个例子
fmt.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 stringage intaddress 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{})
使用其它进制时,在%
与格式化动词之间加上一个空格便可以达到分隔符的效果,例如
func main() {str := "abcdefg"fmt.Printf("%x\n", str)fmt.Printf("% x\n", str)
}
该例输出的结果为
61626364656667
61 62 63 64 65 66 67
在使用数字时,还可以自动补零。比如
fmt.Printf("%09d", 1)
// 000000001
二进制同理
fmt.Printf("%09b", 1<<3)
// 000001000
错误情况
格式化字符数量 < 参数列表数量
fmt.Printf("", "") //%!(EXTRA string=)
格式化字符数量 > 参数列表数量
fmt.Printf("%s%s", "") //%!s(MISSING)
类型不匹配
fmt.Printf("%s", 1) //%!s(int=1)
缺少格式化动词
fmt.Printf("%", 1) // %!(NOVERB)%!(EXTRA int=1)
输入
read
你可以像直接读文件一样,读取输入内容,如下
func main() {var buf [1024]byten, _ := os.Stdin.Read(buf[:])os.Stdout.Write(buf[:n])
}
这样用起来太麻烦了,一般不推荐使用
fmt
我们可以使用fmt
包提供的几个函数,用起来跟 C 差不多。主要包括Scan、Scanln、Scanf
// 扫描从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)
读取两个数字
func main() {var a, b intfmt.Scanln(&a, &b)fmt.Printf("%d + %d = %d\n", a, b, a+b)
}
读取固定长度的数组
func main() {n := 10s := make([]int, n)for i := range n {fmt.Scan(&s[i])}fmt.Println(s)
}
1 2 3 4 5 6 7 8 9 10
[1 2 3 4 5 6 7 8 9 10]
bufio
bufio.Reader | 有大量输入需要读取时,使用bufio.Reader进行内容读取 |
bufio.Scanner | 于Reader类似,但是Scanner是按行读取 |
bufio.Reader
在有大量输入需要读取的时候,就建议使用bufio.Reader
来进行内容读取
func main() {reader := bufio.NewReader(os.Stdin)var a, b intfmt.Fscanln(reader, &a, &b)fmt.Printf("%d + %d = %d\n", a, b, a+b)
}
bufio.Scanner
bufio.Scanner
与bufio.Reader
类似,不过它是按行读取的。
func main() {scanner := bufio.NewScanner(os.Stdin)for scanner.Scan() {line := scanner.Text()if line == "exit" {break}fmt.Println("scan", line)}
}
结果如下
first line
scan first line
second line
scan second line
third line
scan third line
exit
条件控制
Go 中,条件控制语句总共有三种if
,switch
,select
if else
if else至多两个判断分支,语句格式如下
if expression {}
或者
if expression {}else {}
expression
必须是一个布尔表达式,即结果要么为真要么为假,必须是一个布尔值,例子如下:
func main() {a, b := 1, 2if a > b {b++} else {a++}
}
也可以把表达式写的更复杂些,必要时为了提高可读性,应当使用括号来显式的表示谁应该优先计算。
func main() {a, b := 1, 2if a<<1%100+3 > b*100/20+6 { // (a<<1%100)+3 > (b*100/20)+6b++} else {a++}
}
同时if
语句也可以包含一些简单的语句,例如:
func main() {if x := 1 + 1; x > 2 {fmt.Println(x)}
}
else if
else if
语句可以在if else
的基础上创建更多的判断分支,语句格式如下:
if expression1 {}else if expression2 {}else if expression3 {}else {}
在执行的过程中每一个表达式的判断是从左到右,整个if
语句的判断是从上到下 。一个根据成绩打分的例子如下,第一种写法
func main() {score := 90var ans stringif 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)
}
第二种写法利用了if
语句是从上到下的判断的前提,所以代码要更简洁些。
func main() {score := 90var ans stringif 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)
}
switch
switch
语句也是一种多分支的判断语句,语句格式如下:
switch expr {case case1:statement1case case2:statement2default:default statement
}
一个简单的例子如下
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)
}
还可以在表达式之前编写一些简单语句,例如声明新变量
func main() {switch num := f(); { // 等价于 switch num := f(); true {case num >= 0 && num <= 1:num++case num > 1:num--fallthroughcase num < 0:num += num}
}func f() int {return 1
}
switch
语句也可以没有入口处的表达式。
func main() {num := 2switch { // 等价于 switch true {case num >= 0 && num <= 1:num++case num > 1:num--case num < 0:num *= num}fmt.Println(num)
}
通过fallthrough
关键字来继续执行相邻的下一个分支。
func main() {num := 2switch {case num >= 0 && num <= 1:num++case num > 1:num--fallthrough // 执行完该分支后,会继续执行下一个分支case num < 0:num += num}fmt.Println(num)
}
label
标签语句,给一个代码块打上标签,可以是goto
,break
,continue
的目标。例子如下:
func main() {A:a := 1B:b := 2
}
单纯的使用标签是没有任何意义的,需要结合其他关键字goto来进行使用
goto
goto
将控制权传递给在同一函数中对应标签的语句,示例如下:
func main() {a := 1if a == 1 {goto A} else {fmt.Println("b")}
A:fmt.Println("a")
}
在实际应用中goto
用的很少,跳来跳去的很降低代码可读性,性能消耗也是一个问题
循环控制
Go 中,有仅有一种循环语句:for
,Go 抛弃了while
语句,for
语句可以被当作while
来使用
for
语句格式如下
for init statement; expression; post statement {execute statement
}
当只保留循环条件时,就变成了while
。
for expression {execute statement
}
这是一个死循环,永远也不会退出
for {execute statement
}
打印[0,20]之间的数字
/*打印[0,20]之间的数字
*/
import "fmt"var i intfunc main() {for i = 0; i <= 20; i++ {fmt.Println(i)}
}
打印九九乘法表
/*
打印九九乘法表
*/
var i, j intfunc main() {for i = 1; i <= 9; i++ {for j = 1; j <= 9; j++ {if i <= j {fmt.Printf("%d*%d =%d ", i, j, i*j)}}fmt.Println()}
}
for range
for range
可以更加方便的遍历一些可迭代的数据结构,如数组,切片,字符串,映射表,通道。语句格式如下:
for index, value := range iterable {// body
}
index
为可迭代数据结构的索引,value
则是对应索引下的值,例如使用for range
遍历一个字符串。
func main() {sequence := "hello world"for index, value := range sequence {fmt.Println(index, value)}
}
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)
}
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)}}
}
输出
1 0
2 0
2 1
3 0
3 1
3 2
...
9 6
9 7
9 8
continue
continue
关键字会跳过最内层循环的本次迭代,直接进入下一次迭代,结合标签使用可以达到跳过外层循环的效果,例子如下
func main() {for i := 0; i < 10; i++ {for j := 0; j < 10; j++ {if i > j {continue}fmt.Println(i, j)}}
}
输出
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 9
切片
在Go中,切片和数组看起来长得一模一样,但是在功能上有区别:
数组是固定长度的数据结构,长度被指定后是不能被改变的
切片不是定长的,切片在容量不够时会自行扩容
数组
数组作为值类型,将数组作为参数传递给函数时,而Go语言中函数是值传递的,所以会将整个数组拷贝
如果事先已知要存放的数据的长度,且后续使用过程中不会有扩容的需求,就可以考虑使用数组,数组是值类型。而非引用,并不是指向头部的指针
初始化
数组在声明是长度只能是一个常量,不能是变量
// 正确示例
var a [5]int// 错误示例
l := 1
var b [l]int
先来初始化一个长度为 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]int
length
是一个变量,因此无法用于初始化数组长度,如下是正确示例:
const length = 5
var nums [length]int // 常量
var nums2 [length + 1]int // 常量表达式
var nums3 [(1 + 2 + 3) * 5]int // 常量表达式
var nums4 [5]int // 最常用的
使用
只要有数组名和下标,就可以访问数组中对应的元素。
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[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]
数组在切割后,就会变为切片类型
func main() {arr := [5]int{1, 2, 3, 4, 5}fmt.Printf("%T\n", arr)fmt.Printf("%T\n", arr[1:2])
}
输出
[5]int
[]int
若要将数组转换为切片类型,不带参数进行切片即可,转换后的切片与原数组指向的是同一片内存,修改切片会导致原数组内容的变化
func main() {arr := [5]int{1, 2, 3, 4, 5}slice := arr[:]slice[0] = 0fmt.Printf("array: %v\n", arr)fmt.Printf("slice: %v\n", slice)
}
输出
array: [0 2 3 4 5]
slice: [0 2 3 4 5]
如果要对转换后的切片进行修改,建议使用下面这种方式进行转换
func main() {arr := [5]int{1, 2, 3, 4, 5}slice := slices.Clone(arr[:])slice[0] = 0fmt.Printf("array: %v\n", arr)fmt.Printf("slice: %v\n", slice)
}
输出
array: [1 2 3 4 5]
slice: [0 2 3 4 5]
切片
切片在Go中应用的范围比数组广泛的多,用于存放不知道长度的数据,且后续使用过程中可能会频繁插入和删除元素
初始化
切片的初始化方式有以下集中
var nums []int // 值
nums := []int{1, 2, 3} // 值
nums := make([]int, 0, 0) // 值
nums := new([]int) // 指针
切片在外貌上与数组的区别仅仅只是初始化长度的区别。
数组在定义时,就要使用常量对数组长度进行固定,但是切片在初始化的时候不需要对长度进行限定,可以根据使用进行扩充
通常情况下,推荐使用make
来创建一个空切片,只是对于切片而言,make
函数接收三个参数:类型,长度,容量
切片在底层逻辑上的实现依旧是数组,是引用类型,可以简单理解为指向底层数组的指针。
通过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 = append(nums, 1, 2, 3, 4, 5, 6, 7)fmt.Println(len(nums), cap(nums)) // 7 8 可以看到长度与容量并不一致。
新 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]
从中间下标 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]
从尾部插入元素,就是append
最原始的用法
nums = append(nums, 99, 100)
fmt.Println(nums) // [1 2 3 4 5 6 7 8 9 10 99 100]
删除元素
切片元素的删除需要结合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]
从尾部删除 n 个元素
nums = nums[:len(nums)-n]
fmt.Println(nums) //n=3 [1 2 3 4 5 6 7]
从中间指定下标 i 位置开始删除 n 个元素
nums = append(nums[:i], nums[i+n:]...)
fmt.Println(nums)// i=2,n=3,[1 2 6 7 8 9 10]
删除所有元素
nums = nums[:0]
fmt.Println(nums) // []
拷贝
切片在拷贝时需要确保目标切片有足够的长度,例如
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)
}
[1 2 3 4 5 6 7 8 9] []
0
[1 2 3 4 5 6 7 8 9] []
将长度修改为 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]
遍历
切片的遍历与数组完全一致,for
循环
func main() {slice := []int{1, 2, 3, 4, 5, 7, 8, 9}for i := 0; i < len(slice); i++ {fmt.Println(slice[i])}
}
for range
循环
func main() {slice := []int{1, 2, 3, 4, 5, 7, 8, 9}for index, val := range slice {fmt.Println(index, val)}
}
clear
Go1.21新增clear内置函数,clear会将切片内的所有值置为零值
package mainimport ("fmt"
)func main() {s := []int{1, 2, 3, 4}clear(s)fmt.Println(s)
}
输出
[0 0 0 0]
如果想要清空切片,可以
func main() {s := []int{1, 2, 3, 4}// 清空切片s = s[:0:0]fmt.Println(s)
}
限制了切割后的容量,这样可以避免覆盖原切片的后续元素。
字符串
字面量
字符串有两种字面表达方式,分别为普通字符串、原生字符串
普通字符串
普通字符串由 " " 双引号表示,支持转义,但是不支持多行书写
"这是一个普通字符串\n"
"abcdefghijlmn\nopqrst\t\\uvwxyz"
这是一个普通字符串
abcdefghijlmn
opqrst \uvwxyz
原生字符串
原生字符串由 ` ` 反引号表示,不支持转义,支持多行书写,原生字符串里面所有的字符都会原封不动的输出,包括换行和缩进
`这是一个原生字符串,换行tab缩进,\t制表符但是无效,换行"这是一个普通字符串"结束
`
这是一个原生字符串,换行tab缩进,\t制表符但是无效,换行"这是一个普通字符串"结束
访问
字符串本质上就是字节数组,所以字符串的访问形式跟数组切片完全一致,例如访问第一个元素
func main() {str := "this is a string"fmt.Println(str[0])
}
输出是字节而不是字符
116
切割字符串
func main() {str := "this is a string"fmt.Println(string(str[0:4]))
}
this
尝试修改字符串元素
func main() {str := "this is a string"str[0] = 'a' // 无法通过编译fmt.Println(str)
}
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)
}
that is a string
转换
字符串可以转换为字节切片,而字节切片或字节数组也可以转换为字符串,例子如下:
func main() {str := "this is a string"// 显式类型转换为字节切片bytes := []byte(str)fmt.Println(bytes)// 显式类型转换为字符串fmt.Println(string(bytes))
}
字符串的内容是只读的不可变的,无法修改,但是字节切片是可以修改的。
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)
}
将字符串转换成字节切片以后,两者之间毫无关联,因为 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))
}
0xe27bb2 0xe27bb2
长度
字符串的长度是字节数组的长度,而不是字面量的长度,只是大多数时候都是ANSCII字符,刚好用一个字节表示,所以恰好与字面量长度相等,求字符串长度使用内置函数len
func main() {str := "this is a string" // 看起来长度是16str2 := "这是一个字符串" // 看起来长度是7fmt.Println(len(str), len(str2))
}
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]))
}
t // 字母t
è // 意大利语
这 // 中文汉字
拷贝
类似数组切片的拷贝方式,字符串拷贝其实是字节切片拷贝,使用内置函数copy
func main() {var dst, src stringsrc = "this is a string"desBytes := make([]byte, len(src))copy(desBytes, src)dst = string(desBytes)fmt.Println(src, dst)
}
也可以使用strings.clone
函数,但其实内部实现都差不多
func main() {var dst, src stringsrc = "this is a string"dst = strings.Clone(src)fmt.Println(src, dst)
}
拼接
字符串的拼接使用+
操作符
func main() {str := "this is a string"str = str + " that is a int"fmt.Println(str)
}
也可以转换为字节切片再进行添加元素
func main() {str := "this is a string"bytes := []byte(str)bytes = append(bytes, "that is a int"...)str = string(bytes)fmt.Println(str)
}
以上两种拼接方式性能都很差,一般情况下可以使用,但如果对应性能有更高要求,可以使用strings.Builder
func main() {builder := strings.Builder{}builder.WriteString("this is a string ")builder.WriteString("that is a int")fmt.Println(builder.String())
}
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]))}
}
例子中分别输出了字节的十进制形式和十六进制形式。
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,!
由于例子中的字符都是属于 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]))}
}
通常情况下,一个中文字符会占用 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,!
按照字节来遍历会把中文字符拆开,这显然会出现乱码。Go 字符串是明确支持 utf8 的,应对这种情况就需要用到rune
类型,在使用for range
进行遍历时,其默认的遍历单位类型就是一个rune
,例如下方代码
func main() {str := "hello 世界!"for _, r := range str {fmt.Printf("%d,%x,%s\n", r, r, string(r))}
}
输出如下
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
19990,4e16,世
30028,754c,界
33,21,!
rune
本质上是int32
的类型别名,unicode 字符集的范围位于 0x0000 - 0x10FFFF 之间,最大也只有三个字节,合法的 UTF8 编码最大字节数只有 4 个字节,所以使用int32
来存储是理所当然,上述例子中将字符串转换成[]rune
再遍历也是一样的道理,如下
func main() {str := "hello 世界!"runes := []rune(str)for i := 0; i < len(runes); i++ {fmt.Println(string(runes[i]))}
}
还可以使用uft8
包下的工具,例如
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}
}
这两个例子的输出都是相同的。
映射表
映射表数据结构实现通常有两种,哈希表(hash table)和搜索树(search tree),区别在于前者无序,后者有序。在 Go 中,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,
}
第二种方法是使用内置函数make
,对于 map 而言,接收两个参数,分别是类型与初始容量,例子如下:
mp := make(map[string]int, 8)mp := make(map[string][]int, 10)
map 是引用类型,零值或未初始化的 map 可以访问,但是无法存放元素,所以必须要为其分配内存。
func main() {var mp map[string]intmp["a"] = 1fmt.Println(mp)
}
panic: assignment to entry in nil 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"])
}
0
1
3
0
通过代码可以观察到,即使 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不存在")}
}
对 map 求长度
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}fmt.Println(len(mp))
}
存值
map 存值的方式也类似数组存值一样,例如:
func main() {mp := make(map[string]int, 10)mp["a"] = 1mp["b"] = 2fmt.Println(mp)
}
存值时使用已存在的键会覆盖原有的值
func main() {mp := make(map[string]int, 10)mp["a"] = 1mp["b"] = 2if _, exist := mp["b"]; exist {mp["b"] = 3}fmt.Println(mp)
}
但是也存在一个特殊情况,那就是键为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)
}
false
map[NaN:c NaN:a NaN:b]
通过结果可以观察到相同的键值并没有覆盖,反而还可以存在多个,也无法判断其是否存在,也就无法正常取值。因为 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)
}
map[a:0 b:1 c:2 d:3]
map[b:1 c:2 d:3]
需要注意的是,如果值为 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)
}
map[NaN:c NaN:a NaN:b]
map[NaN:c NaN:a NaN:b]
遍历
通过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)}
}
c 2
d 3
a 0
b 1
可以看到结果并不是有序的,也印证了 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)}
}
NaN a
NaN c
NaN b
清空
在 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)
}
但是 go1.21 更新了 clear 函数,就不用再进行之前的操作了,只需要一个 clear 就可以清空
func main() {m := map[string]int{"a": 1,"b": 2,}clear(m)fmt.Println(m)
}
输出
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)
}
map[0:{} 18:{} 25:{} 40:{} 47:{} 56:{} 59:{} 81:{} 87:{}]
提示
一个空的结构体不会占用内存
注意
map 并不是一个并发安全的数据结构,Go 团队认为大多数情况下 map 的使用并不涉及高并发的场景,引入互斥锁会极大的降低性能,map 内部有读写检测机制,如果冲突会触发fatal error
。例如下列情况有非常大的可能性会触发fatal
。
func main() {group.Add(10)// mapmp := 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()
}
fatal error: concurrent map writes
在这种情况下,需要使用sync.Map
来替代。
指针
Go 保留了指针,在一定程度上保证了性能,同时为了更好的 GC 和安全考虑,又限制了指针的使用
创建
关于指针的两个常用操作符,一个是取地址符 & ,另一个是解引用符 * 。
对一个变量进行取地址,会返回对应类型的指针。
func main() {num := 2p := &numfmt.Println(p)
}
指针存储的是变量num
的地址
0xc00001c088
解引用符则有两个用途,第一个是访问指针所指向的元素,也就是解引用,例如
func main() {num := 2p := &numrawNum := *pfmt.Println(rawNum)
}
p
是一个指针,对指针类型解引用就能访问到指针所指向的元素。还有一个用途就是声明一个指针,例如:
func main() {var numPtr *intfmt.Println(numPtr)
}
<nil>
*int
即代表该变量的类型是一个int
类型的指针,不过指针不能光声明,还得初始化,需要为其分配内存,否则就是一个空指针,无法正常使用。要么使用取地址符将其他变量的地址赋值给该指针,要么就使用内置函数new
手动分配,例如:
func main() {var numPtr *intnumPtr = new(int)fmt.Println(numPtr)
}
更多的是使用短变量
func main() {numPtr := new(int)fmt.Println(numPtr)
}
new
函数只有一个参数那就是类型,并返回一个对应类型的指针,函数会为该指针分配内存,并且指针指向对应类型的零值,例如:
func main() {fmt.Println(*new(string))fmt.Println(*new(int))fmt.Println(*new([5]int))fmt.Println(*new([]float64))
}
0
[0 0 0 0 0]
[]
禁止指针运算
在 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;
}
0x31d99ff880
0x31d99ff880
0x31d99ff884
0x31d99ff884
可以看出数组的地址与数字第一个元素的地址一致,并且对指针加一运算后,其指向的元素为数组第二个元素。Go 中的数组也是如此,不过区别在于指针无法偏移,例如
func main() {arr := [5]int{0, 1, 2, 3, 4}p := &arrprintln(&arr[0])println(p)// 试图进行指针运算p++fmt.Println(p)
}
这样的程序将无法通过编译,报错如下
main.go:10:2: invalid operation: p++ (non-numeric type *[5]int)
提示
标准库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的通道
函数
在Go中,函数是Go的基础组成部分,也是核心
声明
func 函数名([参数列表]) [返回值] {函数体
}
声明函数有两种办法,一种是通过func
关键字直接声明,另一种就是通过var
关键字来声明,如下所示
func sum(a int, b int) int {return a + b
}var sum = func(a int, b int) int {return a + b
}
函数签名由函数名称,参数列表,返回值组成,下面是一个完整的例子,函数名称为Sum
,有两个int
类型的参数a
,b
,返回值类型为int
。
func Sum(a int, b int) int {return a + b
}
Go中的函数不支持重载。Go 的理念便是:如果签名不一样那就是两个完全不同的函数,那么就不应该取一样的名字,函数重载会让代码变得混淆和难以理解。这种理念是否正确见仁见智,至少在 Go 中你可以仅通过函数名就知道它是干什么的,而不需要去找它到底是哪一个重载
匿名函数
匿名函数就是没有签名的函数,例如下面的函数func (a,b int) int,这个函数是没有名称的,所以我们只能在它的函数体后紧跟括号来进行调用
func main() {func(a, b int) int {return a + b}(1, 2)
}
闭包
闭包(Closure)在一些语言里被称为Lambda表达式,与匿名函数一起使用,闭包=函数+环境引用
利用闭包,可以非常简单的实现一个求费波那契数列的函数,代码如下
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, 2i := 0return func() (int, bool) {if i >= n {return 0, false} else if i < 2 {f := ii++return f, true}a, b = b, cc = a + bi++return a, true}
}
输出为
0
1
1
2
3
5
8
13
21
34
延迟调用
defer
关键字可以使得一个函数延迟一段时间调用,在函数返回之前这些 defer 描述的函数最后都会被逐个执行,看下面一个例子
func main() {Do()
}func Do() {defer func() {fmt.Println("1")}()fmt.Println("2")
}
输出
2
1
因为 defer 是在函数返回前执行的,你也可以在 defer 中修改函数的返回值
func main() {fmt.Println(sum(3, 5))
}func sum(a, b int) (s int) {defer func() {s -= 10}()s = a + breturn
}
当有多个 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)
}
0
2
5
4
3
1
延迟调用通常用于释放文件资源,关闭网络连接等操作,还有一个用法是捕获panic
,不过这是错误处理一节中才会涉及到的东西
循环
一般建议不要在 for 循环中使用 defer
在 Go 中,每创建一个 defer,就需要在当前协程申请一片内存空间。假设在上面例子中不是简单的 for n 循环,而是一个较为复杂的数据处理流程,当外部请求数突然激增时,那么在短时间内就会创建大量的 defer,在循环次数很大或次数不确定时,就可能会导致内存占用突然暴涨,这种我们一般称之为内存泄漏
结构体
结构体可以存储一组不同类型的数据,是一种复合类型。Go 抛弃了类与继承,同时也抛弃了构造方法,刻意弱化了面向对象的功能,Go 并非是一个传统 OOP 的语言,但是 Go 依旧有着 OOP 的影子,通过结构体和方法也可以模拟出一个类。下面是一个简单的结构体的例子:
type Programmer struct {Name stringAge intJob stringLanguage []string
}
声明
结构体声明代码:
type Person struct {name stringage int
}
结构体本身以及其内部的字段都遵守大小写命名的暴露方式。对于一些类型相同的相邻字段,可以不需要重复声明类型,如下:
type Rectangle struct {height, width, area intcolor string
}
提示:在声明结构体字段时,字段名不能与方法名重复
实例化
Go中不存在构造方法,大多数情况下采用如下的方式进行实例化结构体,初始化的时候像 map 一样指定字段名称再初始化字段值
programmer := Programmer{Name: "jack",Age: 19,Job: "coder",Language: []string{"Go", "C++"},
}
不过也可以省略字段名称,当省略字段名称时,就必须初始化所有字段,通常不建议使用这种方式,因为可读性很糟糕。
programmer := Programmer{"jack",19,"coder",[]string{"Go", "C++"}}
如果实例化过程比较复杂,你也可以编写一个函数来实例化结构体,就像下面这样,你也可以把它理解为一个构造函数
type Person struct {Name stringAge intAddress stringSalary float64
}func NewPerson(name string, age int, address string, salary float64) *Person {return &Person{Name: name, Age: age, Address: address, Salary: salary}
}
不过 Go 并不支持函数与方法重载,所以你无法为同一个函数或方法定义不同的参数。如果你想以多种方式实例化结构体,要么创建多个构造函数,要么建议使用 options 模式
选项模式
选项模式是 Go 语言中一种很常见的设计模式,可以更为灵活的实例化结构体,拓展性强,并且不需要改变构造函数的函数签名。假设有下面这样一个结构体
type Person struct {Name stringAge intAddress stringSalary float64Birthday string
}
声明一个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}
}
实际声明的构造函数签名如下,它接受一个可变长PersonOptions
类型的参数。
func NewPerson(options ...PersonOptions) *Person {// 优先应用optionsp := &Person{}for _, option := range options {option(p)}// 默认值处理if p.Age < 0 {p.Age = 0}......return p
}
这样一来对于不同实例化的需求只需要一个构造函数即可完成,只需要传入不同的 Options 函数即可
func main() {pl := NewPerson(WithName("John Doe"),WithAge(25),WithAddress("123 Main St"),WithSalary(10000.00),)p2 := NewPerson(WithName("Mike jane"),WithAge(30),)
}
函数式选项模式在很多开源项目中都能看见,gRPC Server 的实例化方式也是采用了该设计模式。函数式选项模式只适合于复杂的实例化,如果参数只有简单几个,建议还是用普通的构造函数来解决
组合
在 Go 中,结构体之间的关系是通过组合来表示的,可以显式组合,也可以匿名组合,后者使用起来更类似于继承,但本质上没有任何变化。例如:
显式组合的方式
type Person struct {name stringage int
}type Student struct {p Personschool string
}type Employee struct {p Personjob string
}
在使用时需要显式的指定字段p
student := Student{p: Person{name: "jack", age: 18},school: "lili school",
}
fmt.Println(student.p.name)
而匿名组合可以不用显式的指定字段
type Person struct {name stringage int
}type Student struct {Personschool string
}type Employee struct {Personjob string
}
匿名字段的名称默认为类型名,调用者可以直接访问该类型的字段和方法,但除了更加方便以外与第一种方式没有任何的区别。
student := Student{Person: Person{name: "jack",age: 18},school: "lili school",
}
fmt.Println(student.name)
指针
对于结构体指针而言,不需要解引用就可以直接访问结构体的内容,例子如下:
p := &Person{name: "jack",age: 18,
}
fmt.Println(p.age,p.name)
在编译的时候会转换为(*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"`
}
结构体标签最广泛的应用就是在各种序列化格式中的别名定义,标签的使用需要结合反射才能完整发挥出其功能
空结构体
空结构体没有字段,不占用内存空间,我们可以通过unsafe.SizeOf
函数来计算占用的字节大小
func main() {type Empty struct {}fmt.Println(unsafe.Sizeof(Empty{}))
}
输出
0
空结构体的使用场景有很多,比如之前提到过的,作为map
的值类型,可以将map
作为set
来进行使用,又或者是作为通道的类型,表示仅做通知类型的通道
方法
方法与函数的区别在于,方法拥有接收者,而函数没有,且只有自定义类型能够拥有方法。先来看一个例子。
type IntSlice []intfunc (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)
}
先声明了一个类型IntSlice
,其底层类型为[]int
,再声明了三个方法Get
,Set
和Len
,方法的长相与函数并无太大的区别,只是多了一小段(i IntSlice)
。i
就是接收者,IntSlice
就是接收者的类型,接收者就类似于其他语言中的this
或self
,只不过在 Go 中需要显示的指明。
func main() {var intSlice IntSliceintSlice = []int{1, 2, 3, 4, 5}fmt.Println(intSlice.Get(0))intSlice.Set(0, 2)fmt.Println(intSlice)fmt.Println(intSlice.Len())
}
方法的使用就类似于调用一个类的成员方法,先声明,再初始化,再调用
接收者分为两种类型:值接收者 和 指针接收者