当前位置: 首页 > news >正文

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个字节,那么内存分布如下图所示

img

所以实际上是占用24个字节,其中有5个字节是无用的。

再来看下面这个结构体

type Num struct {A int8B int64C int8
}

明白了上面的规则后,可以很快的理解它的内存占用也是24个字节,尽管它只有三个字段,足足浪费了14个字节。

img

但是我们可以调整字段,改成如下的顺序

type Num struct {A int8C int8B int64
}

如此一来就占用的内存就变为了16字节,浪费了6个字节,减少了8个字节的内存浪费。

img

从理论上来说,让结构体中的字段按照合理的顺序分布,可以减少其内存占用。不过实际编码过程中,并没有必要的理由去这样做,它不一定能在减少内存占用这方面带来实质性的提升,但一定会提高开发人员的血压和心智负担,尤其是在业务中一些结构体的字段数可能多大几十个或者数百个,所以仅做了解即可。

提示

如果你真的想通过此种方法来节省内存,可以看看这两个库

  • 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]

lowhigh依旧是原来的含义不变,而多出来的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

那么这么做就会有一个明显的问题,s1s2是共享的同一个底层数组,在对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的键类型必须是可比较的,比如stringint是可比较的,而[]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的情况,因此结果就是任何数字都不等于NaNNaN也不等于自身,这也造成了每次哈希值都不相同。关于这一点社区也曾激烈讨论过,但是官方认为没有必要去修改,所以应当尽量避免使用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

在前面的几节已经很多次提到过内置函数newmake,两者有点类似,但也有不同,下面复习下。

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)
}

解析:

  1. 定义接口和结构体

    • Writer 接口定义了 Write 方法。

    • StringWriter 结构体实现了 Write 方法。

  2. 类型转换

    • StringWriter 实例赋值给 Writer 接口变量 w

    • 使用 w.(*StringWriter)Writer 接口类型转换为 StringWriter 类型。

  3. 访问字段

    • 修改 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接口,有两个对外暴露的方法WalkSay,在接口里,函数的参数名变得不再重要,当然如果想加上参数名和返回值名也是允许的。

初始化

仅仅只有接口是无法被初始化的,因为它仅仅只是一组规范,并没有具体的实现,不过可以被声明。

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 类型的变量,所以能够访问到的方法只有JackUpHoist,内部的其他方法例如WorkBoot都无法访问。

之前提到过任何自定义类型都可以拥有方法,那么根据实现的定义,任何自定义类型都可以实现接口,下面举几个比较特殊的例子。

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 类型变量,并分别为之赋值为 NokiaPhoneIPhone。然后调用 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 类型的面积值。然后,我们定义了两个结构体 RectangleCircle,它们分别实现了 Shape 接口的area() 方法。在 main() 函数中,我们首先定义了一个 Shape 类型的变量 s,然后分别将 RectangleCircle 类型的实例赋值给它,并通过 area() 方法计算它们的面积并打印出来,输出结果如下:

矩形面积: 50.000000
圆形面积: 28.260000

需要注意的是,接口类型变量可以存储任何实现了该接口的类型的值。在示例中,我们将 RectangleCircle 类型的实例都赋值给了 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类型集的范围内
}

有两种解决办法,第一种是往类型集中并入该类型,但是这毫无意义,因为TinyIntint8底层类型就是一致的,所以就有了第二种解决办法。

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

总共只有四个方法PopPeekPushSize,代码如下。

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)
}

PopPeek方法中,可以看到返回值是_ 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函数有几个注意点

  1. 必须在defer中使用

  2. 多次使用也只会有一个能恢复panic

  3. 闭包recover不会恢复外部函数的任何panic

  4. 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级别的问题一般很少会显式的去触发,大多数情况都是被动触发。

相关文章:

  • 腾讯云服务器技术全景解析:从基础架构到行业赋能​
  • 人员睡岗玩手机检测数据集VOC+YOLO格式3853张3类别
  • 第13章:陈默再访海奥华
  • 2024年第十五届蓝桥杯省赛B组Python【 简洁易懂题解】
  • QT开发工具对比:Qt Creator、Qt Designer、Qt Design Studio
  • 详细案例,集成算法
  • 技术部测试规范
  • 工业AI质检:从传统算法到多模态大模型应用
  • 大模型实践:图文解锁Ollama在个人笔记本上部署llm
  • 学习黑客红队模拟演练报告
  • 如何克服情绪拖延症?
  • 《算法导论(原书第3版)》下载
  • 【Java学习笔记】方法重载
  • Redis 过期与淘汰机制全解析
  • 【操作系统】吸烟者问题
  • 【深度解析】DCN-V2:Google新一代特征交叉网络,如何实现推荐系统精准度飞跃?
  • python hasattr()
  • C++基础算法9:Dijkstra
  • Spring AI 实战:第七章、Spring AI Advisor机制之记忆大师
  • 前端面试每日三题 - Day 24
  • 新华社评论员:在推进中国式现代化的宽广舞台上绽放青春光彩
  • 跳水世界杯总决赛:程子龙/朱子锋夺男子双人10米台冠军
  • 全红婵/陈芋汐夺得跳水世界杯总决赛女子双人10米台冠军
  • 长三角铁路持续迎五一出行高峰:今日预计发送旅客418万人次
  • 张建华评《俄国和法国》|埃莲娜·唐科斯的俄法关系史研究
  • 长三角铁路今日预计发送418万人次,持续迎来出行客流高峰