GoLang基础(续)
12.结构体
结构体可以存储一组不同类型的数据,是一种复合类型。Go抛弃了类与继承,同时也抛弃了构造方法,刻意弱化了面向对象的功能,Go并非是一个传统OOP的语言,但是Go依旧有着OOP的影子,通过结构体和方法也可以模拟出一个类。下面是一个简单的结构体的例子:
type Programmer struct {Name stringAge intJob stringLanguage []string
}
定义
结构体定义需要使用 type 和 struct 语句。struct 语句定义一个新的数据类型,结构体中有一个或多个成员。type 语句设定了结构体的名称。结构体的格式如下:
type struct_variable_type struct {member definitionmember definition...member definition
}
一旦定义了结构体类型,它就能用于变量的声明,语法格式如下:
variable_name := structure_variable_type {value1, value2...valuen} 或 variable_name := structure_variable_type { key1: value1, key2: value2..., keyn: valuen}
实例
package main
import "fmt"
type Books struct {title stringauthor stringsubject stringbook_id int
}
func main() {
// 创建一个新的结构体fmt.Println(Books{"Go 语言", "www.Li.com", "Go 语言笔记", 6495407})
// 也可以使用 key => value 格式fmt.Println(Books{title: "Go 语言", author: "www.Li.com", subject: "Go 语言笔记", book_id: 6495407})
// 忽略的字段为 0 或 空fmt.Println(Books{title: "Go 语言", author: "www.Li.com"})
}
输出结果为
{Go 语言 www.Li.com Go 语言笔记 6495407} {Go 语言 www.Li.com Go 语言笔记 6495407} {Go 语言 www.Li.com 0}
实例化
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模式。
访问结构体成员
如果要访问结构体成员,需要使用点号 . 操作符,格式为:
结构体.成员名
结构体类型变量使用 struct 关键字定义,实例如下:
实例
package main
import "fmt"
type Books struct {title stringauthor stringsubject stringbook_id int
}
func main() {var Book1 Books /* 声明 Book1 为 Books 类型 */var Book2 Books /* 声明 Book2 为 Books 类型 */
/* book 1 描述 */Book1.title = "Go 语言"Book1.author = "www.Li.com"Book1.subject = "Go 语言笔记"Book1.book_id = 6495407
/* book 2 描述 */Book2.title = "Python 笔记"Book2.author = "www.Li.com"Book2.subject = "Python 语言笔记"Book2.book_id = 6495700
/* 打印 Book1 信息 */fmt.Printf( "Book 1 title : %s\n", Book1.title)fmt.Printf( "Book 1 author : %s\n", Book1.author)fmt.Printf( "Book 1 subject : %s\n", Book1.subject)fmt.Printf( "Book 1 book_id : %d\n", Book1.book_id)
/* 打印 Book2 信息 */fmt.Printf( "Book 2 title : %s\n", Book2.title)fmt.Printf( "Book 2 author : %s\n", Book2.author)fmt.Printf( "Book 2 subject : %s\n", Book2.subject)fmt.Printf( "Book 2 book_id : %d\n", Book2.book_id)
}
以上实例执行运行结果为:
Book 1 title : Go 语言 Book 1 author : www.Li.com Book 1 subject : Go 语言笔记 Book 1 book_id : 6495407 Book 2 title : Python 笔记 Book 2 author : www.Li.com Book 2 subject : Python 语言笔记 Book 2 book_id : 6495700
结构体作为函数参数
你可以像其他数据类型一样将结构体类型作为参数传递给函数。并以以上实例的方式访问结构体变量:
实例
package main
import "fmt"
type Books struct {title stringauthor stringsubject stringbook_id int
}
func main() {var Book1 Books /* 声明 Book1 为 Books 类型 */var Book2 Books /* 声明 Book2 为 Books 类型 */
/* book 1 描述 */Book1.title = "Go 语言"Book1.author = "www.Li.com"Book1.subject = "Go 语言笔记"Book1.book_id = 6495407
/* book 2 描述 */Book2.title = "Python 笔记"Book2.author = "www.Li.com"Book2.subject = "Python 语言笔记"Book2.book_id = 6495700
/* 打印 Book1 信息 */printBook(Book1)
/* 打印 Book2 信息 */printBook(Book2)
}
func printBook( book Books ) {fmt.Printf( "Book title : %s\n", book.title)fmt.Printf( "Book author : %s\n", book.author)fmt.Printf( "Book subject : %s\n", book.subject)fmt.Printf( "Book book_id : %d\n", book.book_id)
}
以上实例执行运行结果为:
Book title : Go 语言 Book author : www.Li.com Book subject : Go 语言笔记 Book book_id : 6495407 Book title : Python 笔记 Book author : www.Li.com Book subject : Python 语言笔记 Book book_id : 6495700
选项模式
选项模式是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)
指针
你可以定义指向结构体的指针类似于其他指针变量,格式如下:
var struct_pointer *Books
以上定义的指针变量可以存储结构体变量的地址。查看结构体变量地址,可以将 & 符号放置于结构体变量前:
struct_pointer = &Book1
使用结构体指针访问结构体成员,使用 "." 操作符:
struct_pointer.title
接下来让我们使用结构体指针重写以上实例,代码如下:
实例
package main
import "fmt"
type Books struct {title stringauthor stringsubject stringbook_id int
}
func main() {var Book1 Books /* 声明 Book1 为 Books 类型 */var Book2 Books /* 声明 Book2 为 Books 类型 */
/* book 1 描述 */Book1.title = "Go 语言"Book1.author = "www.Li.com"Book1.subject = "Go 语言笔记"Book1.book_id = 6495407
/* book 2 描述 */Book2.title = "Python 笔记"Book2.author = "www.Li.com"Book2.subject = "Python 语言笔记"Book2.book_id = 6495700
/* 打印 Book1 信息 */printBook(&Book1)
/* 打印 Book2 信息 */printBook(&Book2)
}
func printBook( book *Books ) {fmt.Printf( "Book title : %s\n", book.title)fmt.Printf( "Book author : %s\n", book.author)fmt.Printf( "Book subject : %s\n", book.subject)fmt.Printf( "Book book_id : %d\n", book.book_id)
}
以上实例执行运行结果为:
Book title : Go 语言 Book author : www.Li.com Book subject : Go 语言笔记 Book book_id : 6495407 Book title : Python 笔记 Book author : www.Li.com Book subject : Python 语言笔记 Book book_id : 6495700
标签
结构体标签是一种元编程的形式,结合反射可以做出很多奇妙的功能,格式如下
`key1:"val1" key2:"val2"`
标签是一种键值对的形式,使用空格进行分隔。结构体标签的容错性很低,如果没能按照正确的格式书写结构体,那么将会导致无法正常读取,但是在编译时却不会有任何的报错,下方是一个使用示例。
type Programmer struct {Name string json:"name"Age int yaml:"age"Job string toml:"job"Language []string properties:"language" }
结构体标签最广泛的应用就是在各种序列化格式中的别名定义,标签的使用需要结合反射才能完整发挥出其功能。
内存对齐
Go结构体字段的内存分布遵循内存对齐的规则,这么做可以减少CPU访问内存的次数,相应的占用的内存要多一些,属于空间换时间的一种手段。假设有如下结构体
type Num struct {A int64B int32C int16D int8E int32 }
已知这些类型的占用字节数
-
int64
占8个字节 -
int32
占4个字节 -
int16
占2字节 -
int8
占一个字节
整个结构体的内存占用似乎是8+4+2+1+4=19个字节吗,当然不是这样,根据内存对齐规则而言,结构体的内存占用长度至少是最大字段的整数倍,不足的则补齐。该结构体中最大的是int64
占用8个字节,那么内存分布如下图所示
所以实际上是占用24个字节,其中有5个字节是无用的。
再来看下面这个结构体
type Num struct {A int8B int64C int8 }
明白了上面的规则后,可以很快的理解它的内存占用也是24个字节,尽管它只有三个字段,足足浪费了14个字节。
但是我们可以调整字段,改成如下的顺序
type Num struct {A int8C int8B int64 }
如此一来就占用的内存就变为了16字节,浪费了6个字节,减少了8个字节的内存浪费。
从理论上来说,让结构体中的字段按照合理的顺序分布,可以减少其内存占用。不过实际编码过程中,并没有必要的理由去这样做,它不一定能在减少内存占用这方面带来实质性的提升,但一定会提高开发人员的血压和心智负担,尤其是在业务中一些结构体的字段数可能多大几十个或者数百个,所以仅做了解即可。
提示
如果你真的想通过此种方法来节省内存,可以看看这两个库
-
BetterAlignopen in new window
-
go-toolsopen in new window
他们会检查你的源代码中的结构体,计算并重新排布结构体字段来最小化结构体占用的内存。
空结构体
空结构体没有字段,不占用内存空间,我们可以通过unsafe.SizeOf
函数来计算占用的字节大小
func main() {type Empty struct {}fmt.Println(unsafe.Sizeof(Empty{})) }
输出
0
空结构体的使用场景有很多,比如之前提到过的,作为map
的值类型,可以将map
作为set
来进行使用,又或者是作为通道的类型,表示仅做通知类型的通道。
13.数组
Go 语言提供了数组类型的数据结构。
数组是具有相同唯一类型的一组已编号且长度固定的数据项序列,这种类型可以是任意的原始类型例如整型、字符串或者自定义类型。
相对于去声明 number0, number1, ..., number99 的变量,使用数组形式 numbers[0], numbers[1] ..., numbers[99] 更加方便且易于扩展。
数组元素可以通过索引(位置)来读取(或者修改),索引从 0 开始,第一个元素索引为 0,第二个索引为 1,以此类推。
声明数组
Go 语言数组声明需要指定元素类型及元素个数,语法格式如下:
var arrayName [size]dataType
其中,arrayName 是数组的名称,size 是数组的大小,dataType 是数组中元素的数据类型。
以下定义了数组 balance 长度为 10 类型为 float32:
var balance [10]float32
初始化数组
以下演示了数组初始化:
以下实例声明一个名为 numbers 的整数数组,其大小为 5,在声明时,数组中的每个元素都会根据其数据类型进行默认初始化,对于整数类型,初始值为 0。
var numbers [5]int
还可以使用初始化列表来初始化数组的元素:
var numbers = [5]int{1, 2, 3, 4, 5}
以上代码声明一个大小为 5 的整数数组,并将其中的元素分别初始化为 1、2、3、4 和 5。
另外,还可以使用 := 简短声明语法来声明和初始化数组:
numbers := [5]int{1, 2, 3, 4, 5}
以上代码创建一个名为 numbers 的整数数组,并将其大小设置为 5,并初始化元素的值。
注意:在 Go 语言中,数组的大小是类型的一部分,因此不同大小的数组是不兼容的,也就是说 [5]int 和 [10]int 是不同的类型。
以下定义了数组 balance 长度为 5 类型为 float32,并初始化数组的元素:
var balance = [5]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
我们也可以通过字面量在声明数组的同时快速初始化数组:
balance := [5]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
如果数组长度不确定,可以使用 ... 代替数组的长度,编译器会根据元素个数自行推断数组的长度:
var balance = [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0} 或 balance := [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
如果设置了数组的长度,我们还可以通过指定下标来初始化元素:
// 将索引为 1 和 3 的元素初始化 balance := [5]float32{1:2.0,3:7.0}
初始化数组中 {} 中的元素个数不能大于 [] 中的数字。
如果忽略 [] 中的数字不设置数组大小,Go 语言会根据元素的个数来设置数组的大小:
balance[4] = 50.0
以上实例读取了第五个元素。数组元素可以通过索引(位置)来读取(或者修改),索引从 0 开始,第一个元素索引为 0,第二个索引为 1,以此类推。
访问数组元素
数组元素可以通过索引(位置)来读取。格式为数组名后加中括号,中括号中为索引的值。例如:
var salary float32 = balance[9]
以上实例读取了数组 balance 第 10 个元素的值。
以下演示了数组完整操作(声明、赋值、访问)的实例:
实例 1
package main
import "fmt"
func main() {var n [10]int /* n 是一个长度为 10 的数组 */var i,j int
/* 为数组 n 初始化元素 */* for i = 0; i < 10; i++ {n[i] = i + 100 /* 设置元素为 i + 100 */}
/* 输出每个数组元素的值 */for j = 0; j < 10; j++ {fmt.Printf("Element[%d] = %d\n", j, n[j] )}
}
以上实例执行结果如下:
Element[0] = 100 Element[1] = 101 Element[2] = 102 Element[3] = 103 Element[4] = 104 Element[5] = 105 Element[6] = 106 Element[7] = 107 Element[8] = 108 Element[9] = 109
实例 2
package main
import "fmt"
func main() {var i,j,k int// 声明数组的同时快速初始化数组balance := [5]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
/* 输出数组元素 */ for i = 0; i < 5; i++ {fmt.Printf("balance[%d] = %f\n", i, balance[i] )}balance2 := [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0}/* 输出每个数组元素的值 */for j = 0; j < 5; j++ {fmt.Printf("balance2[%d] = %f\n", j, balance2[j] )}
// 将索引为 1 和 3 的元素初始化balance3 := [5]float32{1:2.0,3:7.0} for k = 0; k < 5; k++ {fmt.Printf("balance3[%d] = %f\n", k, balance3[k] )}
}
以上实例执行结果如下:
balance[0] = 1000.000000 balance[1] = 2.000000 balance[2] = 3.400000 balance[3] = 7.000000 balance[4] = 50.000000 balance2[0] = 1000.000000 balance2[1] = 2.000000 balance2[2] = 3.400000 balance2[3] = 7.000000 balance2[4] = 50.000000 balance3[0] = 0.000000 balance3[1] = 2.000000 balance3[2] = 0.000000 balance3[3] = 7.000000 balance3[4] = 0.000000
多维数组
Go 语言支持多维数组,以下为常用的多维数组声明方式:
var variable_name [SIZE1][SIZE2]...[SIZEN] variable_type
以下实例声明了三维的整型数组:
var threedim [5][10][4]int
二维数组
二维数组是最简单的多维数组,二维数组本质上是由一维数组组成的。二维数组定义方式如下:
var arrayName [ x ][ y ] variable_type
variable_type 为 Go 语言的数据类型,arrayName 为数组名,二维数组可认为是一个表格,x 为行,y 为列,下图演示了一个二维数组 a 为三行四列:
二维数组中的元素可通过 a i 来访问。
实例
package main
import "fmt"
func main() {// Step 1: 创建数组values := [][]int{}
// Step 2: 使用 append() 函数向空的二维数组添加两行一维数组row1 := []int{1, 2, 3}row2 := []int{4, 5, 6}values = append(values, row1)values = append(values, row2)
// Step 3: 显示两行数据fmt.Println("Row 1")fmt.Println(values[0])fmt.Println("Row 2")fmt.Println(values[1])
// Step 4: 访问第一个元素fmt.Println("第一个元素为:")fmt.Println(values[0][0])
}
以上实例运行输出结果为:
Row 1 [1 2 3] Row 2 [4 5 6] 第一个元素为: 1
初始化二维数组
多维数组可通过大括号来初始值。以下实例为一个 3 行 4 列的二维数组:
a := [3][4]int{ {0, 1, 2, 3} , /* 第一行索引为 0 */{4, 5, 6, 7} , /* 第二行索引为 1 */{8, 9, 10, 11}, /* 第三行索引为 2 */ }
注意:
以上代码中倒数第二行的}必须要有逗号,因为最后一行的}不能单独一行,也可以写成这样:
a := [3][4]int{ {0, 1, 2, 3} , /* 第一行索引为 0 */ {4, 5, 6, 7} , /* 第二行索引为 1 */ {8, 9, 10, 11}} /* 第三行索引为 2 */
实例
以下实例初始化一个 2 行 2 列 的二维数组:
package main
import "fmt"
func main() {// 创建二维数组sites := [2][2]string{}
// 向二维数组添加元素sites[0][0] = "Google"sites[0][1] = "Li"sites[1][0] = "Taobao"sites[1][1] = "Weibo"
// 显示结果fmt.Println(sites)
}
以上实例运行输出结果为:
[[Google Li] [Taobao Weibo]]
访问二维数组
二维数组通过指定坐标来访问。如数组中的行索引与列索引,例如:
val := a[2][3] 或 var value int = a[2][3]
以上实例访问了二维数组 val 第三行的第四个元素。
二维数组可以使用循环嵌套来输出元素:
实例
package main
import "fmt"
func main() {/* 数组 - 5 行 2 列*/var a = [5][2]int{ {0,0}, {1,2}, {2,4}, {3,6},{4,8}}var i, j int
/* 输出数组元素 */for i = 0; i < 5; i++ {for j = 0; j < 2; j++ {fmt.Printf("a[%d][%d] = %d\n", i,j, a[i][j] )}}
}
以上实例运行输出结果为:
a[0][0] = 0 a[0][1] = 0 a[1][0] = 1 a[1][1] = 2 a[2][0] = 2 a[2][1] = 4 a[3][0] = 3 a[3][1] = 6 a[4][0] = 4 a[4][1] = 8
以下实例创建各个维度元素数量不一致的多维数组:
实例
package main
import "fmt"
func main() {// 创建空的二维数组animals := [][]string{}
// 创建三一维数组,各数组长度不同row1 := []string{"fish", "shark", "eel"}row2 := []string{"bird"}row3 := []string{"lizard", "salamander"}
// 使用 append() 函数将一维数组添加到二维数组中animals = append(animals, row1)animals = append(animals, row2)animals = append(animals, row3)
// 循环输出for i := range animals {fmt.Printf("Row: %v\n", i)fmt.Println(animals[i])}
}
以上实例运行输出结果为:
Row: 0 [fish shark eel] Row: 1 [bird] Row: 2 [lizard salamander]
向函数传递数组
Go 语言中的数组是值类型,因此在将数组传递给函数时,实际上是传递数组的副本。
如果你想向函数传递数组参数,你需要在函数定义时,声明形参为数组,我们可以通过以下两种方式来声明:
方式一
形参设定数组大小:
func myFunction(param [10]int) {.... }
方式二
形参未设定数组大小:
func myFunction(param []int) {.... }
如果你想要在函数内修改原始数组,可以通过传递数组的指针来实现。
实例
让我们看下以下实例,实例中函数接收整型数组参数,另一个参数指定了数组元素的个数,并返回平均值:
func getAverage(arr []int, size int) float32 {var i intvar avg, sum float32 for i = 0; i < size; ++i {sum += arr[i]} avg = sum / size return avg; }
实例
接下来我们来调用这个函数:
package main
import "fmt"
func main() {/* 数组长度为 5 */var balance = [5]int {1000, 2, 3, 17, 50}var avg float32
/* 数组作为参数传递给函数 */avg = getAverage( balance, 5 ) ;
/* 输出返回的平均值 */fmt.Printf( "平均值为: %f ", avg );
}
func getAverage(arr [5]int, size int) float32 {var i,sum intvar avg float32
for i = 0; i < size;i++ {sum += arr[i]}
avg = float32(sum) / float32(size)
return avg;
}
以上实例执行输出结果为:
平均值为: 214.399994
以上实例中我们使用的形参并未设定数组大小。
浮点数计算输出有一定的偏差,你也可以转整型来设置精度。
实例
package main
import ("fmt"
)
func main() {a := 1.69b := 1.7c := a * b // 结果应该是2.873fmt.Println(c) // 输出的是2.8729999999999998
}
设置固定精度:
实例
package main
import ("fmt"
)
func main() {a := 1690 // 表示1.69b := 1700 // 表示1.70c := a * b // 结果应该是2873000表示 2.873fmt.Println(c) // 内部编码fmt.Println(float64(c) / 1000000) // 显示
}
如果你想要在函数内修改原始数组,可以通过传递数组的指针来实现。
以下实例演示如何向函数传递数组,函数接受一个数组和数组的指针作为参数:
实例
package main
import "fmt"
// 函数接受一个数组作为参数
func modifyArray(arr [5]int) {for i := 0; i < len(arr); i++ {arr[i] = arr[i] * 2}
}
// 函数接受一个数组的指针作为参数
func modifyArrayWithPointer(arr *[5]int) {for i := 0; i < len(*arr); i++ {(*arr)[i] = (*arr)[i] * 2}
}
func main() {// 创建一个包含5个元素的整数数组
myArray := [5]int{1, 2, 3, 4, 5}
fmt.Println("Original Array:", myArray)
// 传递数组给函数,但不会修改原始数组的值
modifyArray(myArray)fmt.Println("Array after modifyArray:", myArray)
// 传递数组的指针给函数,可以修改原始数组的值
modifyArrayWithPointer(&myArray)fmt.Println("Array after modifyArrayWithPointer:", myArray)
}
在上面的例子中,modifyArray 函数接受一个数组,并尝试修改数组的值,但在主函数中调用后,原始数组并未被修改。相反,modifyArrayWithPointer 函数接受一个数组的指针,并通过指针修改了原始数组的值。
以上实例执行输出结果为:
Original Array: [1 2 3 4 5] Array after modifyArray: [1 2 3 4 5] Array after modifyArrayWithPointer: [2 4 6 8 10]
14.切片
在Go中,数组和切片两者看起来长得几乎一模一样,但功能有着不小的区别,数组是定长的数据结构,长度被指定后就不能被改变,而切片是不定长的,切片在容量不够时会自行扩容。
Go 语言切片是对数组的抽象。
Go 数组的长度不可改变,在特定场景中这样的集合就不太适用,Go 中提供了一种灵活,功能强悍的内置类型切片("动态数组"),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
初始化
切片的初始化方式有以下几种
var nums []int // 值 nums := []int{1, 2, 3} // 直接初始化切片,[] 表示是切片类型,{1,2,3} 初始化值依次是 1,2,3,其 cap=len=3。 nums := make([]int, 0, 0) // 值 nums := new([]int) // 指针
可以看到切片与数组在外貌上的区别,仅仅只是少了一个初始化长度。通常情况下,推荐使用make
来创建一个空切片,只是对于切片而言,make
函数接收三个参数:类型,长度len,容量capacity。举个例子解释一下长度与容量的区别,假设有一桶水,水并不是满的,桶的高度就是桶的容量,代表着总共能装多少高度的水,而桶中水的高度就是代表着长度,水的高度一定小于等于桶的高度,否则水就溢出来了。所以,切片的长度代表着切片中元素的个数,切片的容量代表着切片总共能装多少个元素,切片与数组最大的区别在于切片的容量会自动扩张,而数组不会。
提示:
切片的底层实现依旧是数组,是引用类型,可以简单理解为是指向底层数组的指针。
通过var nums []int
这种方式声明的切片,默认值为nil
,所以不会为其分配内存,而在使用make
进行初始化时,建议预分配一个足够的容量,可以有效减少后续扩容的内存消耗。
s := arr[:]
初始化切片 s,是数组 arr 的引用。
s := arr[startIndex:endIndex]
将 arr 中从下标 startIndex 到 endIndex-1 下的元素创建为一个新的切片。
s := arr[startIndex:]
默认 endIndex 时将表示一直到arr的最后一个元素。
s := arr[:endIndex]
默认 startIndex 时将表示从 arr 的第一个元素开始。
s1 := s[startIndex:endIndex]
通过切片 s 初始化切片 s1。
s :=make([]int,len,cap)
通过内置函数 make() 初始化切片s,[]int 标识为其元素类型为 int 的切片。
len() 和 cap() 函数
切片是可索引的,并且可以由 len() 方法获取长度。
切片提供了计算容量的方法 cap() 可以测量切片最长可以达到多少。
实例
package main
import "fmt"
func main() {var numbers = make([]int,3,5)printSlice(numbers)
}
func printSlice(x []int){fmt.Printf("len=%d cap=%d slice=%v\n",len(x),cap(x),x)
}
以上实例运行输出结果为:
len=3 cap=5 slice=[0 0 0]
空(nil)切片
一个切片在未初始化之前默认为 nil,长度为 0,实例如下:
实例
package main
import "fmt"
func main() {var numbers []int
printSlice(numbers)
if(numbers == nil){fmt.Printf("切片是空的")}
}
func printSlice(x []int){fmt.Printf("len=%d cap=%d slice=%v\n",len(x),cap(x),x)
}
以上实例运行输出结果为:
len=0 cap=0 slice=[] 切片是空的
切片截取
可以通过设置下限及上限来设置截取切片 [lower-bound:upper-bound],实例如下:
实例
package main
import "fmt"
func main() {/* 创建切片 */numbers := []int{0,1,2,3,4,5,6,7,8} printSlice(numbers)
/* 打印原始切片 */fmt.Println("numbers ==", numbers)
/* 打印子切片从索引1(包含) 到索引4(不包含)*/fmt.Println("numbers[1:4] ==", numbers[1:4])
/* 默认下限为 0*/fmt.Println("numbers[:3] ==", numbers[:3])
/* 默认上限为 len(s)*/fmt.Println("numbers[4:] ==", numbers[4:])
numbers1 := make([]int,0,5)printSlice(numbers1)
/* 打印子切片从索引 0(包含) 到索引 2(不包含) */number2 := numbers[:2]printSlice(number2)
/* 打印子切片从索引 2(包含) 到索引 5(不包含) */number3 := numbers[2:5]printSlice(number3)
}
func printSlice(x []int){fmt.Printf("len=%d cap=%d slice=%v\n",len(x),cap(x),x)
}
执行以上代码输出结果为:
len=9 cap=9 slice=[0 1 2 3 4 5 6 7 8] numbers == [0 1 2 3 4 5 6 7 8] numbers[1:4] == [1 2 3] numbers[:3] == [0 1 2] numbers[4:] == [4 5 6 7 8] len=0 cap=5 slice=[] len=2 cap=9 slice=[0 1] len=3 cap=7 slice=[2 3 4]
append() 和 copy() 函数
如果想增加切片的容量,我们必须创建一个新的更大的切片并把原分片的内容都拷贝过来。 下面描述了从拷贝切片的 copy 方法和向切片追加新元素的 append 方法。
使用
切片的基本使用与数组完全一致,区别只是切片可以动态变化长度,下面看几个例子。
切片可以通过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 可以看到长度与容量并不一致。
新 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
拷贝
切片在拷贝时需要确保目标切片有足够的长度,例如
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]
实例
package main
import "fmt"
func main() {var numbers []intprintSlice(numbers)
/* 允许追加空切片 */numbers = append(numbers, 0)printSlice(numbers)
/* 向切片添加一个元素 */numbers = append(numbers, 1)printSlice(numbers)
/* 同时添加多个元素 */numbers = append(numbers, 2,3,4)printSlice(numbers)
/* 创建切片 numbers1 是之前切片的两倍容量*/numbers1 := make([]int, len(numbers), (cap(numbers))*2)
/* 拷贝 numbers 的内容到 numbers1 */copy(numbers1,numbers)printSlice(numbers1)
}
func printSlice(x []int){fmt.Printf("len=%d cap=%d slice=%v\n",len(x),cap(x),x)
}
以上代码执行输出结果为:
len=0 cap=0 slice=[] len=1 cap=1 slice=[0] len=2 cap=2 slice=[0 1] len=5 cap=6 slice=[0 1 2 3 4] len=5 cap=12 slice=[0 1 2 3 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) // []
遍历
切片的遍历与数组完全一致,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)} }
多维切片
先来看下面的一个例子,官方文档也有解释:Effective Go - 二维切片open in new window
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) }
输出结果为
[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] [] [] [] [] []
可以看到,同样是二维的数组和切片,其内部结构是不一样的。数组在初始化时,其一维和二维的长度早已固定,而切片的长度是不固定的,切片中的每一个切片长度都可能是不相同的,所以必须要单独初始化,切片初始化部分修改为如下代码即可。
slices := make([][]int, 5) for i := 0; i < len(slices); i++ {slices[i] = make([]int, 5)fmt.Println(slices[i]) }
最终输出结果为
[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]
拓展表达式
提示:只有切片才能使用拓展表达式
切片与数组都可以使用简单表达式来进行切割,但是拓展表达式只有切片能够使用,该特性于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 = 6
那么这么做就会有一个明显的问题,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 // 添加新元素,由于容量为6.所以没有扩容,直接修改底层数组 s2 = append(s2, 1) fmt.Println(s2) fmt.Println(s1)
最终的输出为
[4 1] [1 2 3 4 1 6 7 8 9]
可以看到明明是向s2
添加元素,却连s1
也一起修改了,拓展表达式就是为了解决此类问题而生的,只需要稍微修改一下就能解决该问题
func main() {s1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} // cap = 9s2 := s1[3:4:4] // cap = 4 - 3 = 1// 容量不足,分配新的底层数组s2 = append(s2, 1)fmt.Println(s2)fmt.Println(s1) }
现在得到的结果就是正常的
[4 1] [1 2 3 4 5 6 7 8 9]
clear
在go1.21新增了clear
内置函数,clear会将切片内所有的值置为零值,
package main
import ("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) }
限制了切割后的容量,这样可以避免覆盖原切片的后续元素。
15.Map(映射)
一般来说,映射表数据结构实现通常有两种,哈希表(hash table)和搜索树(search tree),区别在于前者无序,后者有序。在Go中,map
的实现是基于哈希桶(也是一种哈希表),所以也是无序的。
Map 是一种无序的键值对的集合。
Map 最重要的一点是通过 key 来快速检索数据,key 类似于索引,指向数据的值。
Map 是一种集合,所以我们可以像迭代数组和切片那样迭代它。不过,Map 是无序的,遍历 Map 时返回的键值对的顺序是不确定的。
在获取 Map 的值时,如果键不存在,返回该类型的零值,例如 int 类型的零值是 0,string 类型的零值是 ""。
Map 是引用类型,如果将一个 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, }
第二种方法是使用内置函数make
,对于map
而言,接收两个参数,分别是类型与初始容量,格式如下:
map_variable := make(map[KeyType]ValueType, initialCapacity)
其中 KeyType
是键的类型,ValueType
是值的类型,initialCapacity
是可选的参数,用于指定 Map
的初始容量。Map
的容量是指 Map
中可以保存的键值对的数量,当 Map
中的键值对数量达到容量时,Map
会自动扩容。如果不指定 initialCapacity
,Go 语言会根据实际情况选择一个合适的值。
实例如下:
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时应当尽量分配一个合理的容量,以减少扩容次数。
访问
访问一个map的方式就像通过索引访问一个数组一样。
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}fmt.Println(mp["a"]) //0fmt.Println(mp["b"]) //1fmt.Println(mp["d"]) //3fmt.Println(mp["f"]) //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) //falsefmt.Println(mp) //map[NaN:c NaN:a NaN:b] }
通过结果可以观察到相同的键值并没有覆盖,反而还可以存在多个,也无法判断其是否存在,也就无法正常取值。因为NaN
是IEE754标准所定义的,其实现是由底层的汇编指令UCOMISD
完成,这是一个无序比较双精度浮点数的指令,该指令会考虑到NaN
的情况,因此结果就是任何数字都不等于NaN
,NaN
也不等于自身,这也造成了每次哈希值都不相同。关于这一点社区也曾激烈讨论过,但是官方认为没有必要去修改,所以应当尽量避免使用NaN
作为map
的键。
删除
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) //map[a:0 b:1 c:2 d:3]delete(mp, "a")fmt.Println(mp) //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) //map[NaN:c NaN:a NaN:b]delete(mp, math.NaN())fmt.Println(mp) //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:{}]
提示: 一个空的结构体不会占用内存
实例1:
package main
import "fmt"
func main() {var siteMap map[string]string /*创建集合 */siteMap = make(map[string]string)
/* map 插入 key - value 对,各个国家对应的首都 */siteMap [ "Google" ] = "谷歌"siteMap [ "Baidu" ] = "百度"siteMap [ "Wiki" ] = "维基百科"
/*使用键输出地图值 */for site := range siteMap {fmt.Println(site, "首都是", siteMap [site])}
/*查看元素在集合中是否存在 */name, ok := siteMap [ "Facebook" ] /*如果确定是真实的,则存在,否则不存在 *//*fmt.Println(name) *//*fmt.Println(ok) */if (ok) {fmt.Println("Facebook 的 站点是", name)} else {fmt.Println("Facebook 站点不存在")}
}
以上实例运行结果为:
Wiki 首都是 维基百科 Google 首都是 谷歌 Baidu 首都是 百度 Facebook 站点不存在
实例2:
package main
import "fmt"
func main() {/* 创建map */countryCapitalMap := map[string]string{"France": "Paris", "Italy": "Rome", "Japan": "Tokyo", "India": "New delhi"}
fmt.Println("原始地图")
/* 打印地图 */for country := range countryCapitalMap {fmt.Println(country, "首都是", countryCapitalMap[country])}
/*删除元素*/delete(countryCapitalMap, "France")fmt.Println("法国条目被删除")
fmt.Println("删除元素后地图")
/*打印地图*/for country := range countryCapitalMap {fmt.Println(country, "首都是", countryCapitalMap [ country ])}
}
以上实例运行结果为:
原始地图 India 首都是 New delhi France 首都是 Paris Italy 首都是 Rome Japan 首都是 Tokyo 法国条目被删除 删除元素后地图 Italy 首都是 Rome Japan 首都是 Tokyo India 首都是 New delhi
注意
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
来替代。
16.指针
Go 语言中指针是很容易学习的,Go 语言中使用指针可以更简单的执行一些任务。
接下来让我们来一步步学习 Go 语言指针。
我们都知道,变量是一种使用方便的占位符,用于引用计算机内存地址。
Go 语言的取地址符是 &,放到一个变量前使用就会返回相应变量的内存地址。
以下实例演示了变量在内存中地址:
实例
package main
import "fmt"
func main() {var a int = 10
fmt.Printf("变量的地址: %x\n", &a )
}
执行以上代码输出结果为:
变量的地址: 20818a220
什么是指针
现在我们已经了解了什么是内存地址和如何去访问它。接下来我们将具体介绍指针。什么是指针
一个指针变量指向了一个值的内存地址。
类似于变量和常量,在使用指针前你需要声明指针。指针声明格式如下:
var var_name *var-type
var-type 为指针类型,var_name 为指针变量名,* 号用于指定变量是作为一个指针。以下是有效的指针声明:
var ip *int /* 指向整型*/ var fp *float32 /* 指向浮点型 */
本例中这是一个指向 int 和 float32 的指针。
创建
解引用符则有两个用途,第一个是访问指针所指向的元素,也就是解引用,例如
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] []
如何使用指针
指针使用流程:
-
定义指针变量。
-
为指针变量赋值。
-
访问指针变量中指向地址的值。
在指针类型前面加上 * 号(前缀)来获取指针所指向的内容。
实例
package main
import "fmt"
func main() {var a int= 20 /* 声明实际变量 */var ip *int /* 声明指针变量 */
ip = &a /* 指针变量的存储地址 */
fmt.Printf("a 变量的地址是: %x\n", &a )
/* 指针变量的存储地址 */fmt.Printf("ip 变量储存的指针地址: %x\n", ip )
/* 使用指针访问值 */fmt.Printf("*ip 变量的值: %d\n", *ip )
}
以上实例执行输出结果为:
a 变量的地址是: 20818a220 ip 变量储存的指针地址: 20818a220 *ip 变量的值: 20
指针数组
在我们了解指针数组前,先看个实例,定义了长度为 3 的整型数组:
实例
package main
import "fmt"
const MAX int = 3
func main() {
a := []int{10,100,200}var i int
for i = 0; i < MAX; i++ {fmt.Printf("a[%d] = %d\n", i, a[i] )}
}
以上代码执行输出结果为:
a[0] = 10 a[1] = 100 a[2] = 200
有一种情况,我们可能需要保存数组,这样我们就需要使用到指针。
以下声明了整型指针数组:
var ptr [MAX]*int;
ptr 为整型指针数组。因此每个元素都指向了一个值。以下实例的三个整数将存储在指针数组中:
实例
package main
import "fmt"
const MAX int = 3
func main() {a := []int{10,100,200}var i intvar ptr [MAX]*int;
for i = 0; i < MAX; i++ {ptr[i] = &a[i] /* 整数地址赋值给指针数组 */}
for i = 0; i < MAX; i++ {fmt.Printf("a[%d] = %d\n", i,*ptr[i] )}
}
以上代码执行输出结果为:
a[0] = 10 a[1] = 100 a[2] = 200
指向指针的指针
如果一个指针变量存放的又是另一个指针变量的地址,则称这个指针变量为指向指针的指针变量。
当定义一个指向指针的指针变量时,第一个指针存放第二个指针的地址,第二个指针存放变量的地址:
指向指针的指针变量声明格式如下:
var ptr **int;
以上指向指针的指针变量为整型。
访问指向指针的指针变量值需要使用两个 * 号,如下所示:
package main
import "fmt"
func main() {
var a intvar ptr *intvar pptr **int
a = 3000
/* 指针 ptr 地址 */ptr = &a
/* 指向指针 ptr 地址 */pptr = &ptr
/* 获取 pptr 的值 */fmt.Printf("变量 a = %d\n", a )fmt.Printf("指针变量 *ptr = %d\n", *ptr )fmt.Printf("指向指针的指针变量 **pptr = %d\n", **pptr)
}
以上实例执行输出结果为:
变量 a = 3000 指针变量 *ptr = 3000 指向指针的指针变量 **pptr = 3000
指针作为函数参数
Go 语言允许向函数传递指针,只需要在函数定义的参数上设置为指针类型即可。
以下实例演示了如何向函数传递指针,并在函数调用后修改函数内的值,:
实例
package main
import "fmt"
func main() {/* 定义局部变量 */var a int = 100var b int= 200
fmt.Printf("交换前 a 的值 : %d\n", a )fmt.Printf("交换前 b 的值 : %d\n", b )
/* 调用函数用于交换值* &a 指向 a 变量的地址* &b 指向 b 变量的地址*/swap(&a, &b);
fmt.Printf("交换后 a 的值 : %d\n", a )fmt.Printf("交换后 b 的值 : %d\n", b )
}
func swap(x *int, y *int) {var temp inttemp = *x /* 保存 x 地址的值 */*x = *y /* 将 y 赋值给 x */*y = temp /* 将 temp 赋值给 y */
}
以上实例允许输出结果为:
交换前 a 的值 : 100 交换前 b 的值 : 200 交换后 a 的值 : 200 交换后 b 的值 : 100
禁止指针运算
在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的通道
17.Range(范围)
Go 语言中 range 关键字用于 for 循环中迭代数组(array)、切片(slice)、通道(channel)或集合(map)的元素。在数组和切片中它返回元素的索引和索引对应的值,在集合中返回 key-value 对。
for 循环的 range 格式可以对 slice、map、数组、字符串等进行迭代循环。格式如下:
for key, value := range oldMap {newMap[key] = value }
以上代码中的 key 和 value 是可以省略。
如果只想读取 key,格式如下:
for key := range oldMap
或者这样:
for key, _ := range oldMap
如果只想读取 value,格式如下:
for _, value := range oldMap
数组和切片
遍历简单的切片,2**%d 的结果为 2 对应的次方数:
实例
package main
import "fmt"
// 声明一个包含 2 的幂次方的切片var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}
func main() {// 遍历 pow 切片,i 是索引,v 是值for i, v := range pow {// 打印 2 的 i 次方等于 vfmt.Printf("2**%d = %d\n", i, v)}
}
以上实例运行输出结果为:
2**0 = 1 2**1 = 2 2**2 = 4 2**3 = 8 2**4 = 16 2**5 = 32 2**6 = 64 2**7 = 128
字符串
range 迭代字符串时,返回每个字符的索引和 Unicode 代码点(rune)。
实例
package main
import "fmt"
func main() {for i, c := range "hello" {fmt.Printf("index: %d, char: %c\n", i, c)}
}
以上实例运行输出结果为:
index: 0, char: h index: 1, char: e index: 2, char: l index: 3, char: l index: 4, char: o
映射(Map)
for 循环的 range 格式可以省略 key 和 value,如下实例:
实例
package main
import "fmt"
func main() {// 创建一个空的 map,key 是 int 类型,value 是 float32 类型 map1 := make(map[int]float32)// 向 map1 中添加 key-value 对 map1[1] = 1.0map1[2] = 2.0map1[3] = 3.0map1[4] = 4.0// 遍历 map1,读取 key 和 valuefor key, value := range map1 {// 打印 key 和 value fmt.Printf("key is: %d - value is: %f\n", key, value)}
// 遍历 map1,只读取 key for key := range map1 {// 打印 key fmt.Printf("key is: %d\n", key)}
// 遍历 map1,只读取 value for _, value := range map1 {// 打印 value fmt.Printf("value is: %f\n", value)}
}
以上实例运行输出结果为:
key is: 4 - value is: 4.000000 key is: 1 - value is: 1.000000 key is: 2 - value is: 2.000000 key is: 3 - value is: 3.000000 key is: 1 key is: 2 key is: 3 key is: 4 value is: 1.000000 value is: 2.000000 value is: 3.000000 value is: 4.000000
忽略值
在遍历时可以使用 _ 来忽略索引或值。
实例
package main
import "fmt"
func main() {nums := []int{2, 3, 4}// 忽略索引for _, num := range nums {fmt.Println("value:", num)}// 忽略值for i := range nums {fmt.Println("index:", i)}
}
以上实例运行输出结果为:
value: 2 value: 3 value: 4 index: 0 index: 1 index: 2
其他
range 遍历其他数据结构:
实例
package main
import "fmt"
func main() {//这是我们使用 range 去求一个 slice 的和。使用数组跟这个很类似nums := []int{2, 3, 4}sum := 0for _, num := range nums {sum += num}fmt.Println("sum:", sum)//在数组上使用 range 将传入索引和值两个变量。上面那个例子我们不需要使用该元素的序号,所以我们使用空白符"_"省略了。有时侯我们确实需要知道它的索引。for i, num := range nums {if num == 3 {fmt.Println("index:", i)}}//range 也可以用在 map 的键值对上。kvs := map[string]string{"a": "apple", "b": "banana"}for k, v := range kvs {fmt.Printf("%s -> %s\n", k, v)}
//range也可以用来枚举 Unicode 字符串。第一个参数是字符的索引,第二个是字符(Unicode的值)本身。for i, c := range "go" {fmt.Println(i, c)}
}
以上实例运行输出结果为:
sum: 9 index: 1 a -> apple b -> banana 0 103 1 111
18.类型转换
类型转换用于将一种数据类型的变量转换为另外一种类型的变量。
Go 语言类型转换基本格式如下:
type_name(expression)
type_name 为类型,expression 为表达式。
数值类型转换
将整型转换为浮点型:
var a int = 10 var b float64 = float64(a)
以下实例中将整型转化为浮点型,并计算结果,将结果赋值给浮点型变量:
实例
package main
import "fmt"
func main() {var sum int = 17var count int = 5var mean float32mean = float32(sum)/float32(count)fmt.Printf("mean 的值为: %f\n",mean)
}
以上实例执行输出结果为:
mean 的值为: 3.400000
字符串类型转换
将一个字符串转换成另一个类型,可以使用以下语法:
var str string = "10" var num int num, _ = strconv.Atoi(str)
以上代码将字符串变量 str 转换为整型变量 num。
注意,strconv.Atoi 函数返回两个值,第一个是转换后的整型值,第二个是可能发生的错误,我们可以使用空白标识符 _ 来忽略这个错误。
以下实例将字符串转换为整数
实例
package main
import ("fmt""strconv"
)
func main() {str := "123"num, err := strconv.Atoi(str)if err != nil {fmt.Println("转换错误:", err)} else {fmt.Printf("字符串 '%s' 转换为整数为:%d\n", str, num)}
}
以上实例执行输出结果为:
字符串 '123' 转换为整数为:123
整数转换为字符串
实例
package main
import ("fmt""strconv"
)
func main() {num := 123str := strconv.Itoa(num)fmt.Printf("整数 %d 转换为字符串为:'%s'\n", num, str)
}
以上实例执行输出结果为:
整数 123 转换为字符串为:'123'
字符串转换为浮点数
实例
package main
import ("fmt""strconv"
)
func main() {str := "3.14"num, err := strconv.ParseFloat(str, 64)if err != nil {fmt.Println("转换错误:", err)} else {fmt.Printf("字符串 '%s' 转为浮点型为:%f\n", str, num)}
}
以上实例执行输出结果为:
字符串 '3.14' 转为浮点型为:3.140000
浮点数转换为字符串
实例
package main
import ("fmt""strconv"
)
func main() {num := 3.14str := strconv.FormatFloat(num, 'f', 2, 64)fmt.Printf("浮点数 %f 转为字符串为:'%s'\n", num, str)
}
以上实例执行输出结果为:
浮点数 3.140000 转为字符串为:'3.14'
接口类型转换
接口类型转换有两种情况:类型断言和类型转换。
类型断言
类型断言用于将接口类型转换为指定类型,其语法为:
value.(type) 或者 value.(T)
其中 value 是接口类型的变量,type 或 T 是要转换成的类型。
如果类型断言成功,它将返回转换后的值和一个布尔值,表示转换是否成功。
实例
package main
import "fmt"
func main() {var i interface{} = "Hello, World"str, ok := i.(string)if ok {fmt.Printf("'%s' is a string\n", str)} else {fmt.Println("conversion failed")}
}
以上实例中,我们定义了一个接口类型变量 i,并将它赋值为字符串 "Hello, World"
。然后,我们使用类型断言将 i 转换为字符串类型,并将转换后的值赋值给变量 str
。最后,我们使用 ok
变量检查类型转换是否成功,如果成功,我们打印转换后的字符串;否则,我们打印转换失败的消息。
类型转换
类型转换用于将一个接口类型的值转换为另一个接口类型,其语法为:
T(value)
T 是目标接口类型,value 是要转换的值。
在类型转换中,我们必须保证要转换的值和目标接口类型之间是兼容的,否则编译器会报错。
实例
package main
import "fmt"
// 定义一个接口 Writer
type Writer interface {Write([]byte) (int, error)
}
// 实现 Writer 接口的结构体 StringWriter
type StringWriter struct {str string
}
// 实现 Write 方法
func (sw *StringWriter) Write(data []byte) (int, error) {sw.str += string(data)return len(data), nil
}
func main() {// 创建一个 StringWriter 实例并赋值给 Writer 接口变量var w Writer = &StringWriter{}// 将 Writer 接口类型转换为 StringWriter 类型sw := w.(*StringWriter)// 修改 StringWriter 的字段sw.str = "Hello, World"// 打印 StringWriter 的字段值fmt.Println(sw.str)
}
解析:
-
定义接口和结构体:
-
Writer
接口定义了Write
方法。 -
StringWriter
结构体实现了Write
方法。
-
-
类型转换:
-
将
StringWriter
实例赋值给Writer
接口变量w
。 -
使用
w.(*StringWriter)
将Writer
接口类型转换为StringWriter
类型。
-
-
访问字段:
-
修改
StringWriter
的字段str
,并打印其值。
-
空接口类型
空接口 interface{} 可以持有任何类型的值。在实际应用中,空接口经常被用来处理多种类型的值。
实例
package main
import ("fmt"
)
func printValue(v interface{}) {switch v := v.(type) {case int:fmt.Println("Integer:", v)case string:fmt.Println("String:", v)default:fmt.Println("Unknown type")}
}
func main() {printValue(42)printValue("hello")printValue(3.14)
}
在这个例子中,printValue 函数接受一个空接口类型的参数,并使用类型断言和类型选择来处理不同的类型。
19.接口
接口是一个非常重要的概念,它描述了一组抽象的规范,而不提供具体的实现。对于项目而言会使得代码更加优雅可读,对于开发者而言也会减少很多心智负担,代码风格逐渐形成了规范,于是就有了现在人们所推崇的面向接口编程。
概念
Go关于接口的发展历史有一个分水岭,在Go1.17及以前,官方在参考手册中对于接口的定义为:一组方法的集合。
An interface type specifies a method set called its interface.
接口实现的定义为
A variable of interface type can store a value of any type with a method set that is any superset of the interface. Such a type is said to implement the interface
翻译过来就是:当一个类型的方法集是一个接口的方法集的超集时,且该类型的值可以由该接口类型的变量存储,那么称该类型实现了该接口。
不过在Go1.18时,关于接口的定义发生了变化,接口定义为:一组类型的集合。
An interface type defines a type set.
接口实现的定义为
A variable of interface type can store a value of any type that is in the type set of the interface. Such a type is said to implement the interface
翻译过来就是:当一个类型位于一个接口的类型集内,且该类型的值可以由该接口类型的变量存储,那么称该类型实现了该接口。并且还给出了如下的额外定义。
当如下情况时,可以称类型T实现了接口I
T不是一个接口,并且是接口I类型集中的一个元素
T是一个接口,并且T的类型集是接口I类型集的一个子集
如果T实现了一个接口,那么T的值也实现了该接口。
Go在1.18最大的变化就是加入了泛型,新接口定义就是为了泛型而服务的,不过一点也不影响之前接口的使用,同时接口也分为了两类,
-
基本接口(
Basic Interface
):只包含方法集的接口就是基本接口 -
通用接口(
General Interface
):只要包含类型集的接口就是通用接口
什么是方法集,方法集就是一组方法的集合,同样的,类型集就是一组类型的集合。
提示
这一堆概念很死板,理解的时候要根据代码来思考。
基本接口
前面讲到了基本接口就是方法集,就是一组方法的集合。
声明
先来看看接口长什么样子。
type Person interface {Say(string) stringWalk(int) }
这是一个Person
接口,有两个对外暴露的方法Walk
和Say
,在接口里,函数的参数名变得不再重要,当然如果想加上参数名和返回值名也是允许的。
初始化
仅仅只有接口是无法被初始化的,因为它仅仅只是一组规范,并没有具体的实现,不过可以被声明。
func main() {var person Personfmt.Println(person) }
输出
<nil>
实现
先来看一个例子,一个建筑公司想一种特殊规格的起重机,于是给出了起重机的特殊规范和图纸,并指明了起重机应该有起重和吊货的功能,建筑公司并不负责造起重机,只是给出了一个规范,这就叫接口,于是公司A接下了订单,根据自家公司的独门技术造出了绝世起重机并交给了建筑公司,建筑公司不在乎是用什么技术实现的,也不在乎什么绝世起重机,只要能够起重和吊货就行,仅仅只是当作一台普通起重机来用,根据规范提供具体的功能,这就叫实现,。只根据接口的规范来使用功能,屏蔽其内部实现,这就叫面向接口编程。过了一段时间,绝世起重机出故障了,公司A也跑路了,于是公司B依据规范造了一台更厉害的巨无霸起重机,由于同样具有起重和吊货的功能,可以与绝世起重机无缝衔接,并不影响建筑进度,建筑得以顺利完成,内部实现改变而功能不变,不影响之前的使用,可以随意替换,这就是面向接口编程的好处。
接下来会用Go描述上述情形
// 起重机接口
type Crane interface { JackUp() stringHoist() string
}
// 起重机A
type CraneA struct {work int //内部的字段不同代表内部细节不一样
}
func (c CraneA) Work() {fmt.Println("使用技术A")
}
func (c CraneA) JackUp() string {c.Work()return "jackup"
}
func (c CraneA) Hoist() string {c.Work()return "hoist"
}
// 起重机B
type CraneB struct {boot string
}
func (c CraneB) Boot() {fmt.Println("使用技术B")
}
func (c CraneB) JackUp() string {c.Boot()return "jackup"
}
func (c CraneB) Hoist() string {c.Boot()return "hoist"
}
type ConstructionCompany struct {Crane Crane // 只根据Crane类型来存放起重机
}
func (c *ConstructionCompany) Build() {fmt.Println(c.Crane.JackUp())fmt.Println(c.Crane.Hoist())fmt.Println("建筑完成")
}
func main() {// 使用起重机Acompany := ConstructionCompany{CraneA{}}company.Build()fmt.Println()// 更换起重机Bcompany.Crane = CraneB{}company.Build()
}
输出
使用技术A jackup 使用技术A hoist 建筑完成 使用技术B jackup 使用技术B hoist 建筑完成
上面例子中,可以观察到接口的实现是隐式的,也对应了官方对于基本接口实现的定义:方法集是接口方法集的超集,所以在Go中,实现一个接口不需要implements
关键字显式的去指定要实现哪一个接口,只要是实现了一个接口的全部方法,那就是实现了该接口。有了实现之后,就可以初始化接口了,建筑公司结构体内部声明了一个Crane
类型的成员变量,可以保存所有实现了Crane
接口的值,由于是Crane
类型的变量,所以能够访问到的方法只有JackUp
和Hoist
,内部的其他方法例如Work
和Boot
都无法访问。
之前提到过任何自定义类型都可以拥有方法,那么根据实现的定义,任何自定义类型都可以实现接口,下面举几个比较特殊的例子。
type Person interface {Say(string) stringWalk(int)
}
type Man interface {Exercise()Person
}
Man
接口方法集是Person
的超集,所以Man
也实现了接口Person
,不过这更像是一种"继承"。
type Number int
func (n Number) Say(s string) string {return "bibibibibi"
}
func (n Number) Walk(i int) {fmt.Println("can not walk")
}
类型Number
的底层类型是int
,虽然这放在其他语言中看起来很离谱,但Number
的方法集确实是Person
的超集,所以也算实现。
type Func func()
func (f Func) Say(s string) string {//未使用f()return "bibibibibi"
}
func (f Func) Walk(i int) { //未使用f()fmt.Println("can not walk")
}
func main() {var function Funcfunction = func() {fmt.Println("do somthing")}function()
}
同样的,函数类型也可以实现接口。
输出结果为
do something
实例 1
以下两个实例演示了接口的使用:
package main
import ("fmt"
)
type Phone interface {call()
}
type NokiaPhone struct {
}
func (nokiaPhone NokiaPhone) call() {fmt.Println("I am Nokia, I can call you!")
}
type IPhone struct {
}
func (iPhone IPhone) call() {fmt.Println("I am iPhone, I can call you!")
}
func main() {var phone Phone
phone = new(NokiaPhone)phone.call()
phone = new(IPhone)phone.call()
}
在上面的例子中,我们定义了一个接口 Phone
,接口里面有一个方法call()
。然后我们在main
函数里面定义了一个Phone
类型变量,并分别为之赋值为 NokiaPhone
和 IPhone
。然后调用 call() 方法,输出结果如下:
I am Nokia, I can call you! I am iPhone, I can call you!
实例2
package main
import "fmt"
type Shape interface {area() float64
}
type Rectangle struct {width float64height float64
}
func (r Rectangle) area() float64 {return r.width * r.height
}
type Circle struct {radius float64
}
func (c Circle) area() float64 {return 3.14 * c.radius * c.radius
}
func main() {var s Shape
s = Rectangle{width: 10, height: 5}fmt.Printf("矩形面积: %f\n", s.area())
s = Circle{radius: 3}fmt.Printf("圆形面积: %f\n", s.area())
}
以上实例中,我们定义了一个 Shape
接口,它定义了一个方法 area()
,该方法返回一个 float64
类型的面积值。然后,我们定义了两个结构体 Rectangle
和 Circle
,它们分别实现了 Shape
接口的area()
方法。在 main()
函数中,我们首先定义了一个 Shape
类型的变量 s
,然后分别将 Rectangle
和Circle
类型的实例赋值给它,并通过 area()
方法计算它们的面积并打印出来,输出结果如下:
矩形面积: 50.000000 圆形面积: 28.260000
需要注意的是,接口类型变量可以存储任何实现了该接口的类型的值。在示例中,我们将 Rectangle
和 Circle
类型的实例都赋值给了 Shape
类型的变量 s
,并通过 area()
方法调用它们的面积计算方法。
空接口
type Any interface{ }
Any
接口内部没有方法集合,根据实现的定义,所有类型都是Any
接口的的实现,因为所有类型的方法集都是空集的超集,所以Any
接口可以保存任何类型的值。
func main() {var anything Any
anything = 1println(anything)fmt.Println(anything)
anything = "something"println(anything)fmt.Println(anything)anything = complex(1, 2)println(anything)fmt.Println(anything)
anything = 1.2println(anything)fmt.Println(anything)
anything = []int{}println(anything)fmt.Println(anything)
anything = map[string]int{}println(anything)fmt.Println(anything)
}
输出
(0xe63580,0xeb8b08) 1 (0xe63d80,0xeb8c48) something (0xe62ac0,0xeb8c58) (1+2i) (0xe62e00,0xeb8b00) 1.2 (0xe61a00,0xc0000080d8) [] (0xe69720,0xc00007a7b0) map[]
通过输出会发现,两种输出的结果不一致,其实接口内部可以看成是一个由(val,type)
组成的元组,type
是具体类型,在调用方法时会去调用具体类型的具体值。
interface{}
这也是一个空接口,不过是一个匿名空接口,在开发时通常会使用匿名空接口来表示接收任何类型的值,例子如下
func main() {DoSomething(map[int]string{}) } func DoSomething(anything interface{}) interface{} {return anything }
在后续的更新中,官方提出了另一种解决办法,为了方便起见,可以使用any
来替代interace{}
,两者是完全等价的,因为前者仅仅只是一个类型别名,如下
type any = interface{}
在比较空接口时,会对其底层类型进行比较,如果类型不匹配的话则为false
,其次才是值的比较,例如
func main() {var a interface{}var b interface{}a = 1b = "1"fmt.Println(a == b)a = 1b = 1fmt.Println(a == b) }
输出为
false true
如果底层的类型是不可比较的,那么会panic
,对于Go而言,内置数据类型是否可比较的情况如下
类型 | 可比较 | 依据 |
---|---|---|
数字类型 | 是 | 值是否相等 |
字符串类型 | 是 | 值是否相等 |
数组类型 | 是 | 数组的全部元素是否相等 |
切片类型 | 否 | 不可比较 |
结构体 | 是 | 字段值是否全部相等 |
map类型 | 否 | 不可比较 |
通道 | 是 | 地址是否相等 |
指针 | 是 | 指针存储的地址是否相等 |
接口 | 是 | 底层所存储的数据是否相等 |
在Go中有一个专门的接口类型用于代表所有可比较类型,即comparable
type comparable interface{ comparable }
提示:如果尝试对不可比较的类型进行比较,则会panic
(无法预知的异常,表示十分严重的程序问题,程序需要立即停止来处理该问题,否则程序立即停止运行并输出堆栈信息)21章有详细说明
20.泛型
示例
在开始之前,先来看一个简单的例子。
func Sum(a, b int) int {return a + b }
这是一个功能十分简单的函数,作用就是将两个int
类型的整数相加并返回结果,倘若想要传入两个float64
类型的浮点数求和的话,显然是不可以的,因为类型不匹配。一种解决办法就是再定义一个新的函数,如下
func SumFloat64(a, b float64) float64 {return a + b }
那么问题来了,如果开发一个数学工具包,计算所有数字类型的两数之和,难道要每一个类型都要编写一个函数吗?显然是不太可能的,或者也可以使用any
类型加反射来判断,如下
func SumAny(a, b any) (any, error) {tA, tB := reflect.ValueOf(a), reflect.ValueOf(b)if tA.Kind() != tB.Kind() {return nil, errors.New("disMatch type")} switch tA.Kind() {case reflect.Int:case reflect.Int32:...} }
但是这样写会显得十分复杂,而且性能低下。但是Sum
函数的逻辑都是一模一样的,都只不过是将两个数相加而已,这时候就需要用到了泛型,所以为什么需要泛型,泛型是为了解决执行逻辑与类型无关的问题,这类问题不关心给出的类型是什么,只需要完成对应的操作就足够。所以泛型的写法如下
func Sum[T int | float64](a, b T) T {return a + b }
类型形参:T就是一个类型形参,形参具体是什么类型取决于传进来什么类型
类型约束:int | float64
构成了一个类型约束,这个类型约束内规定了哪些类型是允许的,约束了类型形参的类型范围
类型实参:Sum[int](1,2)
,手动指定了int
类型,int
就是类型实参。
第一种用法,显式的指明使用哪种类型,如下
Sum[int](2012, 2022)
第二种用法,不指定类型,让编译器自行推断,如下
Sum(3.1415926, 1.114514)
看到这里后,应该对为什么要使用泛型,以及泛型解决了哪种问题有了一个大概的了解。将泛型引入项目后,开发上确实会比较方便,随之而来的是项目复杂度的增加,毫无节制的使用泛型会使得代码难以维护,所以应该在正确的地方使用泛型,而不是为了泛型而泛型。
泛型结构
这是一个泛型切片,类型约束为int | int32 | int64
type GenericSlice[T int | int32 | int64] []T
这里使用时就不能省略掉类型实参
GenericSlice[int]{1, 2, 3}
这是一个泛型哈希表,键的类型必须是可比较的,所以使用comparable
接口,值的类型约束为V int | string | byte
type GenericMap[K comparable, V int | string | byte] map[K]V
使用
gmap1 := GenericMap[int, string]{1: "hello world"} gmap2 := make(GenericMap[string, byte], 0)
这是一个泛型结构体,类型约束为T int | string
type GenericStruct[T int | string] struct {Name stringId T }
使用
GenericStruct[int]{Name: "jack",Id: 1024, } GenericStruct[string]{Name: "Mike",Id: "1024", }
这是一个泛型切片形参的例子
type Company[T int | string, S []T] struct {Name stringId TStuff S } //也可以如下 type Company[T int | string, S []int | string] struct {Name stringId TStuff S }
使用
Company[int, []int]{Name: "lili",Id: 1,Stuff: []int{1}, }
提示
在泛型结构体中,更推荐这种写法
type Company[T int | string, S int | string] struct {Name stringId TStuff []S
}
SayAble是一个泛型接口,Person实现了该接口。type SayAble[T int | string] interface {Say() T
}
type Person[T int | string] struct {msg T
}
func (p Person[T]) Say() T {return p.msg
}
func main() {var s SayAble[string]s = Person[string]{"hello world"}fmt.Println(s.Say())
}
泛型结构注意点
泛型不能作为一个类型的基本类型
以下写法是错误的,泛型形参T是不能作为基础类型的
type GenericType[T int | int32 | int64] T
虽然下列的写法是允许的,不过毫无意义而且可能会造成数值溢出的问题,虽然并不推荐
type GenericType[T int | int32 | int64] int
泛型类型无法使用类型断言
对泛型类型使用类型断言将会无法通过编译,泛型要解决的问题是类型无关的,如果一个问题需要根据不同类型做出不同的逻辑,那么就根本不应该使用泛型,应该使用interface{}
或者any
。
func Sum[T int | float64](a, b T) T {ints,ok := a.(int) // 不被允许switch a.(type) { // 不被允许case int:case bool:...}return a + b }
匿名结构不支持泛型
匿名结构体是不支持泛型的,如下的代码将无法通过编译
testStruct := struct[T int | string] {Name stringId T }[int]{Name: "jack",Id: 1 }
匿名函数不支持自定义泛型
以下两种写法都将无法通过编译
var sum[T int | string] func (a, b T) T sum := func[T int | string](a,b T) T{... }
但是可以使用已有的泛型类型,例如闭包中
func Sum[T int | float64](a, b T) T {sub := func(c, d T) T {return c - d}return sub(a,b) + a + b }
不支持泛型方法
方法是不能拥有泛型形参的,但是receiver
可以拥有泛型形参。如下的代码将会无法通过编译
type GenericStruct[T int | string] struct {Name stringId T } func (g GenericStruct[T]) name[S int | float64](a S) S {return a }
类型集
在1.18以后,接口的定义变为了类型集(type set)
,含有类型集的接口又称为General interfaces
即通用接口。
An interface type defines a type setopen in new window
类型集主要用于类型约束,不能用作类型声明,既然是集合,就会有空集,并集,交集,接下来将会讲解这三种情况。
并集
接口类型SignedInt
是一个类型集,有符号整数类型的并集就是SignedInt
,反过来SignedInt
就是它们的超集。
type SignedInt interface {int8 | int16 | int | int32 | int64 }
基本数据类型如此,对待其它通用接口也是如此
type SignedInt interface {int8 | int16 | int | int32 | int64 } type UnSignedInt interface {uint8 | uint16 | uint32 | uint64 } type Integer interface {SignedInt | UnSignedInt }
交集
非空接口的类型集是其所有元素的类型集的交集,即如果一个接口包含多个非空类型集,那么该接口就是这些类型集的交集,例子如下
type SignedInt interface {int8 | int16 | int | int32 | int64 } type Integer interface {int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 } type Number interface {SignedIntInteger }
例子中的交集肯定就是SignedInt
,
func Do[T Number](n T) T {return n } Do[int](2) DO[uint](2) //无法通过编译
空集
空集就是没有交集,例子如下,下面例子中的Integer
就是一个类型空集。
type SignedInt interface {int8 | int16 | int | int32 | int64 } type UnsignedInt interface {uint8 | uint16 | uint | uint32 | uint64 } type Integer interface {SignedIntUnsignedInt }
因为无符号整数和有符号整数两个肯定没有交集,所以交集就是个空集,下方例子中不管传什么类型都无法通过编译。
Do[Integer](1) Do[Integer](-100)
空接口
空接口与空集并不同,空接口是所有类型集的集合,即包含所有类型。
func Do[T interface{}](n T) T {return n } func main() {Do[struct{}](struct{}{})Do[any]("abc") }
底层类型
当使用type
关键字声明了一个新的类型时,即便其底层类型包含在类型集内,当传入时也依旧会无法通过编译。
type Int interface {int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 } type TinyInt int8 func Do[T Int](n T) T {return n } func main() {Do[TinyInt](1) // 无法通过编译,即便其底层类型属于Int类型集的范围内 }
有两种解决办法,第一种是往类型集中并入该类型,但是这毫无意义,因为TinyInt
与int8
底层类型就是一致的,所以就有了第二种解决办法。
type Int interface {int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 | TinyInt }
使用~
符号,来表示底层类型,如果一个类型的底层类型属于该类型集,那么该类型就属于该类型集,如下所示
type Int interface {~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 }
修改过后就可以通过编译了。
func main() {Do[TinyInt](1) // 可以通过编译,因为TinyInt在类型集Int内 }
类型集注意点
带有方法集的接口无法并入类型集
只要是带有方法集的接口,不论是基本接口,泛型接口,又或者是通用接口,都无法并入类型集中,同样的也无法在类型约束中并入。以下两种写法都是错误的,都无法通过编译。
type Integer interface {Sum(int, int) intSub(int, int) int } type SignedInt interface {int8 | int16 | int | int32 | int64 | Integer } func Do[T Integer | float64](n T) T {return n }
类型集无法当作类型实参使用
只要是带有类型集的接口,都无法当作类型实参。
type SignedInt interface {int8 | int16 | int | int32 | int64 } func Do[T SignedInt](n T) T {return n } func main() {Do[SignedInt](1) // 无法通过编译 }
类型集中的交集问题
对于非接口类型,类型并集中不能有交集,例如下例中的TinyInt
与~int8
有交集。
type Int interface {~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 无法通过编译 } type TinyInt int8
但是对于接口类型的话,就允许有交集,如下例
type Int interface {~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 可以通过编译 } type TinyInt interface {int8 }
类型集不能直接或间接的并入自身
以下示例中,Floats
直接的并入了自身,而Double
又并入了Floats
,所以又间接的并入了自身。
type Floats interface { // 代码无法通过编译Floats | Double } type Double interface {Floats }
comparable
接口无法并入类型集
同样的,也无法并入类型约束中,所以基本上都是单独使用。
func Do[T comparable | Integer](n T) T { //无法通过编译return n } type Number interface { // 无法通过编译Integer | comparable } type Comparable interface { // 可以通过编译但是毫无意义comparable }
使用
数据结构是泛型最常见的使用场景,下面借由两个数据结构来展示下泛型如何使用。
队列
下面用泛型实现一个简单的队列,首先声明队列类型,队列中的元素类型可以是任意的,所以类型约束为any
type Queue[T any] []T
总共只有四个方法Pop
,Peek
,Push
,Size
,代码如下。
type Queue[T any] []T func (q *Queue[T]) Push(e T) {*q = append(*q, e) } func (q *Queue[T]) Pop(e T) (_ T) {if q.Size() > 0 {res := q.Peek()*q = (*q)[1:]return res}return } func (q *Queue[T]) Peek() (_ T) {if q.Size() > 0 {return (*q)[0]}return } func (q *Queue[T]) Size() int {return len(*q) }
在Pop
和Peek
方法中,可以看到返回值是_ T
,这是具名返回值的使用方式,但是又采用了下划线_
表示这是匿名的,这并非多此一举,而是为了表示泛型零值。由于采用了泛型,当队列为空时,需要返回零值,但由于类型未知,不可能返回具体的类型,借由上面的那种方式就可以返回泛型零值。也可以声明泛型变量的方式来解决零值问题,对于一个泛型变量,其默认的值就是该类型的零值,如下
func (q *Queue[T]) Pop(e T) T {var res Tif q.Size() > 0 {res = q.Peek()*q = (*q)[1:]return res}return res }
堆
上面队列的例子,由于对元素没有任何的要求,所以类型约束为any
。但堆就不一样了,堆是一种特殊的数据结构,它可以在O(1)的时间内判断最大或最小值,所以它对元素有一个要求,那就是必须是可以排序的类型,但内置的可排序类型只有数字和字符串,并且go的泛型约束不允许存在带方法的接口,所以在堆的初始化时,需要传入一个自定义的比较器,比较器由使用者提供,比较器也必须使用泛型,如下
type Comparator[T any] func(a, b T) int
下面是一个简单的二项最小堆的实现,先声明泛型结构体,依旧采用any
进行约束,这样可以存放任意类型
type Comparator[T any] func(a, b T) int type BinaryHeap[T any] struct {s []Tc Comparator[T] }
几个方法实现
func (heap *BinaryHeap[T]) Peek() (_ T) {if heap.Size() > 0 {return heap.s[0]}return
}
func (heap *BinaryHeap[T]) Pop() (_ T) {size := heap.Size()if size > 0 {res := heap.s[0]heap.s[0], heap.s[size-1] = heap.s[size-1], heap.s[0]heap.s = heap.s[:size-1]heap.down(0)return res}return
}
func (heap *BinaryHeap[T]) Push(e T) {heap.s = append(heap.s, e)heap.up(heap.Size() - 1)
}
func (heap *BinaryHeap[T]) up(i int) {if heap.Size() == 0 || i < 0 || i >= heap.Size() {return}for parentIndex := i>>1 - 1; parentIndex >= 0; parentIndex = i>>1 - 1 {// greater than or equal toif heap.compare(heap.s[i], heap.s[parentIndex]) >= 0 {break}heap.s[i], heap.s[parentIndex] = heap.s[parentIndex], heap.s[i]i = parentIndex}
}
func (heap *BinaryHeap[T]) down(i int) {if heap.Size() == 0 || i < 0 || i >= heap.Size() {return}size := heap.Size()for lsonIndex := i<<1 + 1; lsonIndex < size; lsonIndex = i<<1 + 1 {rsonIndex := lsonIndex + 1
if rsonIndex < size && heap.compare(heap.s[rsonIndex], heap.s[lsonIndex]) < 0 {lsonIndex = rsonIndex}
// less than or equal toif heap.compare(heap.s[i], heap.s[lsonIndex]) <= 0 {break}heap.s[i], heap.s[lsonIndex] = heap.s[lsonIndex], heap.s[i]i = lsonIndex}
}
func (heap *BinaryHeap[T]) Size() int {return len(heap.s)
}
使用起来如下
type Person struct {Age intName string
}
func main() {heap := NewHeap[Person](10, func(a, b Person) int {return cmp.Compare(a.Age, b.Age)})heap.Push(Person{Age: 10, Name: "John"})heap.Push(Person{Age: 18, Name: "mike"})heap.Push(Person{Age: 9, Name: "lili"})heap.Push(Person{Age: 32, Name: "miki"})fmt.Println(heap.Peek())fmt.Println(heap.Pop())fmt.Println(heap.Peek())
}
输出
{9 lili} {9 lili} {10 John}
有泛型的加持,原本不可排序的类型传入比较器后也可以使用堆了,这样做肯定比以前使用interface{}
来进行类型转换和断言要优雅和方便很多。
小结
go的一大特点就是编译速度非常快,编译快是因为编译期做的优化少,泛型的加入会导致编译器的工作量增加,工作更加复杂,这必然会导致编译速度变慢,事实上当初go1.18刚推出泛型的时候确实导致编译更慢了,go团队既想加入泛型又不想太拖累编译速度,开发者用的顺手,编译器就难受,反过来编译器轻松了(最轻松的当然是直接不要泛型),开发者就难受了,现如今的泛型就是这两者之间妥协后的产物。
21.错误处理
error
error属于是一种正常的流程错误,它的出现是可以被接受的,大多数情况下应该对其进行处理,当然也可以忽略不管,error的严重级别不足以停止整个程序的运行。error
本身是一个预定义的接口,该接口下只有一个方法Error()
,该方法的返回值是字符串,用于输出错误信息。
type error interface {Error() string }
error在历史上也有过大改,在1.13版本时Go团队推出了链式错误,且提供了更加完善的错误检查机制,接下来都会一一介绍。
创建
创建一个error有以下几种方法,第一种是使用errors
包下的New
函数。
err := errors.New("这是一个错误")
第二种是使用fmt
包下的Errorf
函数,可以得到一个格式化参数的error。
err := fmt.Errorf("这是%d个格式化参数的的错误", 1)
下面是一个完整的例子
func sumPositive(i, j int) (int, error) {if i <= 0 || j <= 0 {return -1, errors.New("必须是正整数")}return i + j, nil }
大部分情况,为了更好的维护性,一般都不会临时创建error,而是会将常用的error当作全局变量使用,例如下方节选自os\erros.go
文件的代码
var (ErrInvalid = fs.ErrInvalid // "invalid argument" ErrPermission = fs.ErrPermission // "permission denied"ErrExist = fs.ErrExist // "file already exists"ErrNotExist = fs.ErrNotExist // "file does not exist"ErrClosed = fs.ErrClosed // "file already closed" ErrNoDeadline = errNoDeadline() // "file type does not support deadline"ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout" )
可以看到它们都是被var
定义的变量
自定义错误
通过实现Error()
方法,可以很轻易的自定义error,例如erros
包下的errorString
就是一个很简单的实现。
func New(text string) error {return &errorString{text} } // errorString结构体 type errorString struct {s string } func (e *errorString) Error() string {return e.s }
因为errorString
实现太过于简单,表达能力不足,所以很多开源库包括官方库都会选择自定义error,以满足不同的错误需求。
传递
在一些情况中,调用者调用的函数返回了一个错误,但是调用者本身不负责处理错误,于是也将错误作为返回值返回,抛给上一层调用者,这个过程叫传递,错误在传递的过程中可能会层层包装,当上层调用者想要判断错误的类型来做出不同的处理时,可能会无法判别错误的类别或者误判,而链式错误正是为了解决这种情况而出现的。
type wrapError struct {msg stringerr error } func (e *wrapError) Error() string {return e.msg } func (e *wrapError) Unwrap() error {return e.err }
wrappError
同样实现了error
接口,也多了一个方法Unwrap
,用于返回其内部对于原error的引用,层层包装下就形成了一条错误链表,顺着链表上寻找,很容易就能找到原始错误。由于该结构体并不对外暴露,所以只能使用fmt.Errorf
函数来进行创建,例如
err := errors.New("这是一个原始错误") wrapErr := fmt.Errorf("错误,%w", err)
使用时,必须使用%w
格式动词,且参数只能是一个有效的error。
处理
错误处理中的最后一步就是如何处理和检查错误,errors
包提供了几个方便函数用于处理错误。
func Unwrap(err error) error
errors.Unwrap()
函数用于解包一个错误链,其内部实现也很简单
func Unwrap(err error) error {u, ok := err.(interface { // 类型断言,是否实现该方法Unwrap() error})if !ok { //没有实现说明是一个基础的errorreturn nil}return u.Unwrap() // 否则调用Unwrap }
解包后会返回当前错误链所包裹的错误,被包裹的错误可能依旧是一个错误链,如果想要在错误链中找到对应的值或类型,可以递归进行查找匹配,不过标准库已经提供好了类似的函数。
func Is(err, target error) bool
errors.Is
函数的作用是判断错误链中是否包含指定的错误,例子如下
var originalErr = errors.New("this is an error") func wrap1() error { // 包裹原始错误return fmt.Errorf("wrapp error %w", wrap2()) } func wrap2() error { // 原始错误return originalErr } func main() {err := wrap1()if errors.Is(err, originalErr) { // 如果使用if err == originalErr 将会是falsefmt.Println("original")} }
所以在判断错误时,不应该使用==
操作符,而是应该使用errors.Is()
。
func As(err error, target any) bool
errors.As()
函数的作用是在错误链中寻找第一个类型匹配的错误,并将值赋值给传入的err
。有些情况下需要将error
类型的错误转换为具体的错误实现类型,以获得更详细的错误细节,而对一个错误链使用类型断言是无效的,因为原始错误是被结构体包裹起来的,这也是为什么需要As
函数的原因。例子如下
type TimeError struct { // 自定义errorMsg stringTime time.Time //记录发生错误的时间
}
func (m TimeError) Error() string {return m.Msg
}
func NewMyError(msg string) error {return &TimeError{Msg: msg,Time: time.Now(),}
}
func wrap1() error { // 包裹原始错误return fmt.Errorf("wrapp error %w", wrap2())
}
func wrap2() error { // 原始错误return NewMyError("original error")
}
func main() {var myerr *TimeErrorerr := wrap1()// 检查错误链中是否有*TimeError类型的错误if errors.As(err, &myerr) { // 输出TimeError的时间fmt.Println("original", myerr.Time)}
}
target
必须是指向error
的指针,由于在创建结构体时返回的是结构体指针,所以error
实际上*TimeError
类型的,那么target
就必须是**TimeError
类型的。
不过官方提供的errors
包其实并不够用,因为它没有堆栈信息,不能定位,一般会比较推荐使用官方的另一个增强包
github.com/pkg/errors
例子
import ("fmt""github.com/pkg/errors"
)
func Do() error {return errors.New("error")
}
func main() {if err := Do(); err != nil {fmt.Printf("%+v", err)}
}
输出
some unexpected error happened main.DoD:/WorkSpace/Code/GoLeran/golearn/main.go:9 main.mainD:/WorkSpace/Code/GoLeran/golearn/main.go:13 runtime.mainD:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267 runtime.goexitD:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650
通过格式化输出,就可以看到堆栈信息了,默认情况下是不会输出堆栈的。这个包相当于是标准库errors
包的加强版,同样都是官方写的,不知道为什么没有并入标准库。
实例
package main
import ("fmt"
)
// 定义一个 DivideError 结构
type DivideError struct {dividee intdivider int
}
// 实现 `error` 接口
func (de *DivideError) Error() string {strFormat := `Cannot proceed, the divider is zero.dividee: %ddivider: 0
`return fmt.Sprintf(strFormat, de.dividee)
}
// 定义 `int` 类型除法运算的函数
func Divide(varDividee int, varDivider int) (result int, errorMsg string) {if varDivider == 0 {dData := DivideError{dividee: varDividee,divider: varDivider,}errorMsg = dData.Error()return} else {return varDividee / varDivider, ""}
}
func main() {
// 正常情况if result, errorMsg := Divide(100, 10); errorMsg == "" {fmt.Println("100/10 = ", result)}// 当除数为零的时候会返回错误信息if _, errorMsg := Divide(100, 0); errorMsg != "" {fmt.Println("errorMsg is: ", errorMsg)}
}
执行以上程序,输出结果为:
100/10 = 10 errorMsg is: Cannot proceed, the divider is zero.dividee: 100divider: 0
panic
panic
中文译为恐慌,表示十分严重的程序问题,程序需要立即停止来处理该问题,否则程序立即停止运行并输出堆栈信息,panic
是Go是运行时异常的表达形式,通常在一些危险操作中会出现,主要是为了及时止损,从而避免造成更加严重的后果。不过panic
在退出之前会做好程序的善后工作,同时panic
也可以被恢复来保证程序继续运行。
下方是一个向nil
的map写入值的例子,肯定会触发panic
func main() {var dic map[string]intdic["a"] = 'a' } panic: assignment to entry in nil map
提示
只要任一协程发生panic
,如果不将其捕获的话,整个程序都会崩溃
创建
显式的创建panic
十分简单,使用内置函数panic
即可,函数签名如下
func panic(v any)
panic
函数接收一个类型为any
的参数v
,当输出错误堆栈信息时,v
也会被输出。使用例子如下
func main() {initDataBase("", 0) } func initDataBase(host string, port int) {if len(host) == 0 || port == 0 {panic("非法的数据链接参数")}// ...其他的逻辑 }
当初始化数据库连接失败时,程序就不应该启动,因为没有数据库程序就运行的毫无意义,所以此处应该抛出panic
panic: 非法的数据链接参数
善后
程序因为panic
退出之前会做一些善后工作,例如执行defer
语句。
func main() {defer fmt.Println("A")defer fmt.Println("B")fmt.Println("C")panic("panic")defer fmt.Println("D") }
输出为
C B A panic: panic
并且上游函数的defer
语句同样会执行,例子如下
func main() {defer fmt.Println("A")defer fmt.Println("B")fmt.Println("C")dangerOp()defer fmt.Println("D") } func dangerOp() {defer fmt.Println(1)defer fmt.Println(2)panic("panic")defer fmt.Println(3) }
输出
C 2 1 B A panic: panic
defer
中也可以嵌套panic
,下面是一个比较复杂的例子
func main() {defer fmt.Println("A")defer func() {func() {panic("panicA")defer fmt.Println("E")}()}()fmt.Println("C")dangerOp()defer fmt.Println("D") } func dangerOp() {defer fmt.Println(1)defer fmt.Println(2)panic("panicB")defer fmt.Println(3) }
defer
中嵌套的panic
执行顺序依旧一致,发生panic
时后续的逻辑将无法执行。
C 2 1 A panic: panicB panic: panicA
综上所述,当发生panic
时,会立即退出所在函数,并且执行当前函数的善后工作,例如defer
,然后层层上抛,上游函数同样的也进行善后工作,直到程序停止运行。
当子协程发生panic
时,不会触发当前协程的善后工作,如果直到子协程退出都没有恢复panic
,那么程序将会直接停止运行。
var waitGroup sync.WaitGroup func main() {demo() } func demo() {waitGroup.Add(1)defer func() {fmt.Println("A")}()fmt.Println("C")go dangerOp()waitGroup.Wait() // 父协程阻塞等待子协程执行完毕defer fmt.Println("D") } func dangerOp() {defer fmt.Println(1)defer fmt.Println(2)panic("panicB")defer fmt.Println(3)waitGroup.Done() }
输出为
C 2 1 panic: panicB
可以看到demo()
中的defer
语句一个都没有执行,程序就直接退出了。需要注意的是,如果没有waitGroup
来阻塞父协程的话,demo()
的执行速度可能会快于子协程的执行速度,输出的结果就会变得非常有迷惑性,下面稍微修改一下代码
func main() {demo() } func demo() {defer func() {// 父协程善后工作要花费20mstime.Sleep(time.Millisecond * 20)fmt.Println("A")}()fmt.Println("C")go dangerOp()defer fmt.Println("D") } func dangerOp() {// 子协程要执行一些逻辑,要花费1mstime.Sleep(time.Millisecond)defer fmt.Println(1)defer fmt.Println(2)panic("panicB")defer fmt.Println(3) }
输出为
C D 2 1 panic: panicB
在本例中,当子协程发生panic
时,父协程早已完成的函数的执行,进入了善后工作,在执行最后一个defer
时,碰巧遇到了子协程发生panic
,所以程序就直接退出运行。
恢复
标准公式如下:
defer func() {if r := recover(); r != nil {if err, ok := r.(string); ok {fmt.Println("捕获到错误:", err)} else {fmt.Println("捕获到未知错误:", r)}} }()
当发生panic
时,使用内置函数recover()
可以及时的处理并且保证程序继续运行,必须要在defer
语句中运行,使用示例如下。
func main() {dangerOp()fmt.Println("程序正常退出") } func dangerOp() {defer func() {if err := recover(); err != nil {fmt.Println(err)fmt.Println("panic恢复")}}()panic("发生panic") }
调用者完全不知道dangerOp()
函数内部发生了panic
,程序执行剩下的逻辑后正常退出,所以输出如下
发生panic panic恢复 程序正常退出
但事实上recover()
的使用有许多隐含的陷阱。例如在defer
中再次闭包使用recover
。
func main() {dangerOp()fmt.Println("程序正常退出") } func dangerOp() {defer func() {func() {if err := recover(); err != nil {fmt.Println(err)fmt.Println("panic恢复")}}()}()panic("发生panic") }
闭包函数可以看作调用了一个函数,panic
是向上传递而不是向下,自然闭包函数也就无法恢复panic
,所以输出如下。
panic: 发生panic
除此之外,还有一种很极端的情况,那就是panic()
的参数是nil
。
func main() {dangerOp()fmt.Println("程序正常退出") } func dangerOp() {defer func() {if err := recover(); err != nil {fmt.Println(err)fmt.Println("panic恢复")}}()panic(nil) }
这种情况panic
确实会恢复,但是不会输出任何的错误信息。
输出
程序正常退出
总的来说recover
函数有几个注意点
-
必须在
defer
中使用 -
多次使用也只会有一个能恢复
panic
-
闭包
recover
不会恢复外部函数的任何panic
-
panic
的参数禁止使用nil
fatal
fatal
是一种极其严重的问题,当发生fatal
时,程序需要立刻停止运行,不会执行任何善后工作,通常情况下是调用os
包下的Exit
函数退出程序,如下所示
func main() {dangerOp("") } func dangerOp(str string) {if len(str) == 0 {fmt.Println("fatal")os.Exit(1)}fmt.Println("正常逻辑") }
输出
fatal
fatal
级别的问题一般很少会显式的去触发,大多数情况都是被动触发。