Go语言入门详细教程


Go

一、简介

Go语言最初由Google公司的Robert Griesemer、Ken Thompson和Rob Pike三个大牛于2007年开始设计发明,他们最终的目标是设计一种适应网络和多核时代的C语言。所以Go语言很多时候被描述为“类C语言”,或者是“21世纪的C语言”,Go 富有表现力、简洁、干净且高效。其并发性机制使编写充分利用多核的程序变得容易,而其新颖的类型系统可实现灵活和模块化程序构建。Go 可以快速编译为机器代码,且具有垃圾回收的便利性和运行时反射的强大功能。是一个快速的静态类型的编译语言,但感觉就像动态类型的解释性语言。
在这里插入图片描述

二、Go语言基本语法与使用

变量

声明变量

标准格式
var a int
var 变量名 变量类型

变量声明以关键字var开头,后置变量类型,行尾无须分号。

批量格式
var (
	a int
    b string
    c []float32
    d func() bool
    e struct {
    
    
        x int
    }
    
    
)

使用关键字var和括号,可以将一组变量定义放在一起

初始化变量

Go语言在声明变量时,自动对变量对应的内存区域进行初始化操作。每个变量会初始化其类型的默认值,例如:

扫描二维码关注公众号,回复: 15997033 查看本文章
  • 整型和浮点型变量的默认值为0
  • 字符串变量的默认值为空字符串
  • 布尔类型变量默认值为false
  • 切片、函数、指针变量的默认值为nil

标准格式
var hp int = 100
var 变量名 类型 = 表达式

上面代码中,100和int同为int类型,int可以认为冗余信息,因此可以进一步简化初始化的写法。

编译器推导类型的格式

在标准格式的基础上,将int省略后,编译器会尝试根据等号右边的表达式推导hp变量的类型。

var hp = 100

短变量声明并初始化

var 声明的变量声明还有一种更为精准的写法,例如:

hp := 100

这是Go语言的推到声明写法,编译器会自动根据右值类型推断出左值的对应类型。

注意:由于使用了":=“,而不是赋值的”=",因此推导声明写法的左值变量必须是没有定义过的变量。若定义过,将会发生变异错误。

var hp int
hp := 10

编译报错:no new variables on left side of :=

在多个短变量声明和赋值中,至少有一个新声明的变量出现在左值中,即便其他变量名可能是重复声明的,编译器也不会报错。

多个变量同时赋值

传统变量交换

var a int = 100
var b int = 200 
var t int
t = a
a = b 
b = t

在计算机刚发明时,内存非常"精贵"。这种变量交换往往时非常奢侈的。到了Go语言时,内存不再是紧缺资源,而且写法可以更简单。

var a int = 100
var b int = 200
b, a = a, b
fmt.Println(a,b)

匿名变量

在使用多重赋值时,如果不需要在左值中接受变量,可以使用匿名变量。

匿名变量的表现是一个"_"下画线,使用匿名变量时,只需要在变量声明的地方使用下画线替换即。

例如:

a , _ := GetData();

匿名变量不占用命名空间,不会分配内存。匿名变量与匿名变量之间也不会因为多次声明而无法使用。

数据类型

整型

按照长度分:int8、int16、int32、int64

还有对应的无符号整型:uint8、uint16、uint32、uint64。

uint8–>byte

int16–>short

int32–>int

int64–>long

浮点型

float32和float64,这两种浮点型数据格式遵循IEEE754标准。

float32的浮点数的最大范围约为3.4e38,float64的浮点数的最大范围约为1.8e308。

打印浮点数时,可以使用fmt包配合动词"%f"

package main

import (
	"fmt"
	"math"
)

func main() {
    
    
	fmt.Printf("%f\n", math.Pi)
	fmt.Printf("%.2f\n", math.Pi)
}
//结果:3.141593  3.14

布尔型

在Go语言中吗,布尔型只有true和false两值

Go语言不允许将整形强制转换为布尔型,代码如下:

var n bool
fmt.Println(int(n)*2)

编译错误,输出如下:

cannot convert n (type bool) to type int

布尔型无法参与数值运算,也无法与其他类型进行转化

字符串

字符串在Go语言中以原生数据类型出现,使用字符串就像使用其他原生数据类型(int、bool等)一样。

str := "hello"
ch := "中文"

Go语言里的字符串的内部实现使用UTF-8编码。通过rune类型,可以方便地对每个UTF-8字符进行访问。当然,Go语言也支持按传统的ADCII码方式进行逐字符访问。

定义多行字符
func main() {
    
    
	const str = `diyihang
dierhang
disanhang
`
	fmt.Print(str)
}
//输出:
//diyihang
//dierhang
//disanhang

字符

Go语言的字符有以下两种:

  • 一种是unint8类型,或者叫byte型,代表了ASCII码的一个字符。
  • 另一种是rune类型,代表一个UTF-8字符。当需要处理中文、日文或者其他复合字符时,则需要用到rune类型。rune类型实际是一个int32.

使用fmt.Printf中的"%T"动词可以输出变量的实际类型,使用这个方法可以查看byte和rune的本来类型,代码如下:

func main() {
    
    
	var a byte = 'a'
	fmt.Printf("%d %T\n", a, a)
	var b rune = '你'
	fmt.Printf("%d %T\n", b, b)
}
输出
97 uint8
20320 int32

切片

切片是一个拥有相同类型元素的可变长度的序列。切片的声明方式如下:

var name  [] T

其中,T代表切片元素类型,可以是整型、浮点型、布尔型、切片、map、函数等。

切片的元素使用"[]"进行访问,在方括号中提供切片的索引即可访问元素,索引的范围从0开始,且不超过切片的最大容量。代码如下:

func main() {
    
    
	a := make([]int, 3)

	a[0] = 1
	a[1] = 2
	a[2] = 3
	fmt.Println(a[0], a[1], a[2])x

	str := "hello world"
	fmt.Print(str[6:])
}

切片还可以在其元素集合内连续地选取一段区域作为新的切片,就像其名字"切片"一样,切出一块区域,形成新的切片。

字符串也可以按切片的方式进行操作。

转换不同的数据类型

Go语言使用类型前置加括号的方式进行类型转换,一般格式如下:

T(表达式)

T代表要转换的类型。表达式包括变量、复杂算子和函数返回值等。

类型转换时,需要考虑两种类型的关系和范围,是否会发生数值截断

func main() {
    
    
	fmt.Println("8", math.MaxInt8, math.MinInt8)
	fmt.Println("16", math.MaxInt16, math.MinInt16)
	fmt.Println("32", math.MaxInt32, math.MinInt32)
	fmt.Println("64", math.MaxInt64, math.MinInt64)

	var a int32 = 1047483647
	fmt.Printf("int32: 0x%x %d\n", a, a)
	b := int16(a)
	fmt.Printf("int16: 0x%x %d\n", b, b)
	var c float32 = math.Pi
	fmt.Println(int(c))
}
输出:
16 32767 -32768                            
32 2147483647 -2147483648                  
64 9223372036854775807 -9223372036854775808
int32: 0x3e6f54ff 1047483647               
int16: 0x54ff 21759                        
3

指针

指针概念在Go语言中被拆分为两个核心概念:

  • 类型指针,允许对这个指针类型的数据进行修改。传递数据使用指针,而无须拷贝数据。类型指针不能进行偏移和运算。
  • 切片,由指向起始元素的原始指针、元素数量和容量组成。

受益于这样的约束和拆分,Go语言的指针类型变量拥有指针的高效访问,但又不会发生指针偏移,从而避免非法修改关键性数据问题。同时,垃圾回收也比较容易对不会发生偏移的指针进行检索和回收。

切片比原始指针具备更强大的特性,更为安全。切片发生越界时,运行时会报出宕机,并打出堆栈,而原始指针只会崩溃。

什么是指针

一个指针变量指向了一个值的内存地址。

类似于变量和常量,在使用指针前你需要声明指针。指针声明格式如下:

var var_name *var-type

var-type 为指针类型,var_name 为指针变量名,* 号用于指定变量是作为一个指针。以下是有效的指针声明:

var ip *int        /* 指向整型*/
var fp *float32    /* 指向浮点型 */

指针使用流程:

  • 定义指针变量。
  • 为指针变量赋值。
  • 访问指针变量中指向地址的值。

在指针类型前面加上 * 号(前缀)来获取指针所指向的内容。

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

认识指针地址和指针类型

每个变量在运行时都拥有一个地址,这个地址代表变量在内存中的位置。Go语言中使用"&"操作符放在变量前面对变量进行"取地址"操作。

格式:

ptr := &v

其中v代表被取地址的变量,被取地址的v使用ptr变量进行接收,ptr的类型就为"_T",称做T的指针类型。" _ "代表指针

package main

import "fmt"

func main() {
    
    

	var cat int = 1
	var str string = "banana"
	fmt.Printf("%p %p", &cat, &str)
}
输出:
0xc00001c0a8 0xc000052270

变量、指针和地址三者的关系是:每个变量都拥有地址,指针的值就是地址。

从地址获取指针指向的值

在对普通变量使用"&“操作符取地址获得这个变量的指针后,可以对指针使用” * "操作,也就是指针取值。

func main() {
    
    
	var house = "cong 103"
	ptr := &house//ptr为house的地址
	value := *ptr//value为ptr指向的值·
	fmt.Print(value)
}
输出:
cong 103

变量、指针地址、指针变量、取地址、取值的相互关系和特性如下:

  • 对变量进行取地址(&)操作,可以获得这个变量的指针变量
  • 指针变量的值是指针地址
  • 对指针变量进行取值(*)操作,可以获得指针变量指向的原变量的值。

使用指针修改值

通过指针不仅可以取值,也可以修改值

package main

import "fmt"

func swap(a, b *int) {
    
    
	t := *a
	*a = *b
	*b = t
}
func main() {
    
    
	x, y := 1, 2
	swap(&x, &y)
	fmt.Print(x, y)
}
输出:
2 1

变量生命期

变量能够使用的代码范围

栈分配

栈可用于内存分配,栈的分配和回收速度非常快。

func calc(a, b int) int {
    
    
	var c int
	c = a * b
	var x int
	x = c * 10
	return x
}

上面代码没进行任何优化的情况下,会进行c和x变量的分配过程。Go语言默认情况下会将c和x分配在栈上,这两个变量在calc()函数退出时就不再使用,函数结束时,保存c和x的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速。

堆分配

堆再内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小。分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家,房间里的空间会变得乱七八糟,此时再往空间里摆放家具会存在虽然有足够的空间,但各空间分布在不同的区域,无法有一段连续的空间来摆放家具的问题,此时,内存分配器就需要对这些空间进行调整优化。

堆分配内存和栈分配内存相比,堆适合不可预知大小的内存分配。但是为此付出的代价时分配速度较慢,而且会形成内存碎片。

变量逃逸

自动决定变量分配方式,提高运行效率

在C/C++语言中,需要开发者自己学习如何进行内存分配,选用怎样的内存分配方式来适应不同的算法需求。

Go语言将这个过程整合到编译器中,命名为"变量逃逸分析"。这个技术由编译器分析代码的特征和代码生命期,决定应该如何堆还是栈进行内存分配,即使程序员使用Go语言完成了整个工程后也不会感受到这个过程。

编译器觉得变量应该分配在堆和栈上的原则是:

  • 变量是否被取地址
  • 变量是否发生逃逸

字符串应用

计算字符串长度

ASCII字符串长度使用len()函数。

Unicode字符串长度使用utf8.RuneCountInString()函数。

	tip1 := "dssgagsd"
	fmt.Println(len(tip1))
	tip2 := "哈数"
	fmt.Println(utf8.RuneCountInString(tip2))

遍历字符串

ASCII字符串遍历直接使用下标

	theme := "狙击 start"
	for i := 0; i < len(theme); i++ {
    
    
		fmt.Printf("ascii: %c %d\n", theme[i], theme[i])
	}
//输出:
ascii: ç 231
ascii:   139
ascii:   153
ascii: å 229
ascii:   135
ascii: » 187
ascii:   32
ascii: s 115
ascii: t 116
ascii: a 97
ascii: r 114
ascii: t 116

Unicode字符串遍历用for range

	theme := "狙击 start"
	for _, s := range theme {
    
    
		fmt.Printf("Unicode: %c %d\n", s, s)
	}
//输出
Unicode:29401
Unicode:20987
Unicode:   32    
Unicode: s 115   
Unicode: t 116   
Unicode: a 97    
Unicode: r 114   
Unicode: t 116
  1. 使用普通循环时,得到的类型是uint8.
  2. 在使用for range循环时,得到的类型是int32.
  3. Go语言中byte和rune实质上就是uint8和int32类型.

获取字符串的某一段字符

strings.Index:正向搜索子字符串

strings.LastIndex:反向搜索子字符串

搜索的起始位置可以通过切片偏移制作

修改字符串

Go语言的字符串无法直接修改每一个字符元素,只能通过重新构造新的字符串并赋给原来的字符串变量实现

字符串不可变 有很多好处,如天生线程安全,大家使用的都是只读对象,无须加锁;再者,方便内存共享,而不必使用写时复制(Copy On Write)等技术;字符串hash值也只需要制作一份。

  • Go语言的字符串是不可变的
  • 修改字符串时,可以将字符串转换为[]byte进行修改。
  • []byte和string可以通过强制类型转换互换

连接字符串

Go语言中也有类似于StringBuilder的机制来进行高效的字符串连接。

	hammer := "吃完"
	sickle := "四八"

	var stringBuilder bytes.Buffer

	stringBuilder.WriteString(hammer)
	stringBuilder.WriteString(sickle)

	fmt.Print(stringBuilder.String())

bytes.Buffer是可以缓冲并可以往里面写入各种字节数组的。字符串也是一种字符数组,使用WriteString()方法进行写入

将需要连接的字符串,通过调用WriteString()方法,写入stringBuilder中,然后再通过stringBuilder.String()方法将缓冲转换为字符串。

格式化

fmt.Sprintf(格式化样式,参数列表..)
  • 格式化样式:字符串形式,格式化动词以%开头。
  • 参数列表:多个参数以逗号分隔,个数必须与格式化样式中的个数一一对应,否则运行时会报错。
	var progress = 2
	var target = 8
	title := fmt.Sprintf("已采集%d个草药,还需要%d个完成任务", progress, target)
	fmt.Print(title)

字符串格式化时常用动词及功能

动词 功能
%v 按值的本来值输出
%+v 在%v基础上,对结构体字段名和值进行展开
%#v 输出Go语言语法格式的值
%T 输出Go语言语法格式的类型和值
%% 输出%本体
%b 整型以二进制方式显示
%o 整型以八进制方式显示
%d 整型以十进制方式显示
%x 整型以十六进制方式显示
%X 整型以十六进制、字母大写方式显示
%U Unicode字符
%f 浮点数
%p 指针,十六进制 方式显示0

常量

相对于变量,常量是恒定不变的值

声明:

const pi=3.141592
const e =2.718281

多个变量可以一起声明,类似的,常量也是可以多个一起声明的

const (
	pi = 3.141592
     e = 2.718281
)

类型别名(Type Alias)

类型别名的写法为:

type TypeAlias = Type

TypeAlias只是Type的别名,本质上TypeAlias与Type是同一个类型

区别类型别名与类型定义

package main

import "fmt"

type NewInt int
type IntAlias = int

func main() {
    
    
	var a NewInt
	fmt.Printf("a type: %T\n", a)
	var a2 IntAlias
	fmt.Printf("a2 type: %T\n", a2)
}
输出:
a type: main.NewInt
a2 type: int

通过type关键字的定义,NewInt会形成一种新的类型。NewInt本身依然具备int的特性

结果显示a的类型是main.NewInt,表示main包下定义的NewInt类型。a2类型是int。IntAlias类型只会在代码中存在,编译完成时,不会有IntAlias类型

非本地类型不能定义方法

package main

import "time"

type MyDuration = time.Duration

func (m MyDuration) EasySet(a string)  {
    
    
}
func main() {
    
    
 
}

编译器提示:不能再一个非本地的类型time.Duration上定义新方法。非本地方法指的就是使用time.Duration的代码所在的包,也就是main包。因为time.Duration是在time包中定义的,在main包中使用。time.Duration包与main包不在同一个包中,因此不能为不在一个包中的类型定义方法

在结构体成员嵌入时使用别名

type  Brand struct {
    
    
	
}

func  (t Brand) Show()  {
    
    
	
}
type FakeBrand = Brand
type Vehicle struct {
    
    
	FakeBrand
	Brand
}

三、容器:存储和组织数据的方式

数组

数组是一段固定长度的连续内存区域。

声明数组

var 数组变量名 [元素数量]T

T可以是任意基本类型,包括T为数组本身。但类型为数组本身时,可以实现多维数组。

初始化数组

数组可以在声明时使用初始化列表进行元素设置,参考下面的代码:

var team = [3]string{
    
    "hammer","soldier","mum"}

这种方式编写时,需要保证大括号后面的元素数量与数组大小一致。但一般情况下,这个过程可以交给编译器,让编译器在编译时,根据元素个数确定数组大小。

var team = [...]string{
    
    "hammer","soldier","mum"}

"…"表示让编译器确定数组大小。上面例子中,编译器会自动为这个数组设置元素个数为3。

切片

动态分配大小的连续空间

Go语言切片的内部结构包含地址、大小和容量。切片一般用于快速地操作一块数据集合。如果将数据集合比作切糕的话,切片一般用于快速地操作一块数据集合。如果将数据集合比作切糕的话,切片就是你要的"那一块"。切的过程包含从哪里开始(这个就是切片的地址)及切多大(这个就是切片的大小)。容量可以理解为装切片的口袋大小。

从数组或切片生产新的切片

切片默认指向一段连续内存区域,可以是数组,也可以是切片本身

格式如下:

slice [开始位置:结束位置]

从数组生成切片、

var a = [3]int{
    
    1,2,3}
fmt.Println(a,a[1:2])

a是拥有3个整型元素的数组,被初始化数值1到3。使用a[1:2]可以生成一个新的切片。

代码结果:

[1 2 3] [2]

[2]就是a[1:2]切片操作的结果

从数组或切片生成新的切片拥有如下特性:

  • 取出的元素数量为:结束位置-开始位置
  • 取出的元素不包含结束位置对应的索引,切片最后一个元素使用slice[len(slice)]获取
  • 当缺省开始位置时,表示从连续区域开头到结束位置。
  • 当缺省结束位置时,表示从开始位置到整个连续区域末尾
  • 两者同时缺省时,与切片本身同效
  • 两者同时为0时,等效于空切片,一般用于切片复位
  • 根据索引位置取切片slice元素值时,取值范围是(0~len(slice)-1),超界会报运行时错误。生成切片时,结束位置可以填写ken(slice)但不会报错
  1. 从指定范围中生成切片

切片和数组密不可分。如果将数组理解为一栋办公楼,那么切片就是把不同的连续楼层出租给使用者。出租的过程需要选择开始楼层和结束楼层,这个过程就会生成切片。

func main() {
    
    
	var highRiseBuilding [30]int

	for i := 0; i < 30; i++ {
    
    
		highRiseBuilding[i] = i + 1
	}
	fmt.Println(highRiseBuilding[10:15])

	fmt.Println(highRiseBuilding[20:])

	fmt.Println(highRiseBuilding[:2])

}
结果:
[11 12 13 14 15]
[21 22 23 24 25 26 27 28 29 30]
[1 2]

切片有点像C语言里的指针。指针可以做运算,但代价是内存操作越界。切片在指针的基础上增加了大小,约束了切片对应的内存区域,切片使用中无法对切片内部的地址和大小进行手动调整,因此切片比指针更安全、强大

  1. 表示原有的切片

生成切片的格式中,当开始和结束的范围都被忽略,则生成的切片将表示和原切片一致的切片,并且生成的切片与原切片在数据内容上是一致的

a:=[]int{
    
    1,2,3}
fmt.Println(a[:])
  1. 重置切片,清空所有元素

把切片的开始和结束位置都设为0时,生成的切片将变空

a := []int{
    
    1,2,3}
fmt.Println(a[0:0])

声明切片

每一种类型都可以拥有其切片类型,表示多个类型元素的连续集合。因此切片类型也可以被声明

var name []T
  • name表示切片类型的变量名。
  • T表示切片类型对应的元素类型。

使用make()函数构造切片

如果需要动态地创建一个切片,可以使用make()内建函数,格式如下:

make([]T,size,cap)
  • T:切片的元素类型
  • size:就是为这个类型元分配多少个元素
  • cap:预分配的元素数量,这个值设定后不影响size,只是能提前分配空间,降低多次分配空间造成的性能问题。
a :=make([]int,2)
b :=make([]int,2,10)

fmt.Println(a,b)
fmt.Println(len(a),len(b))

输出:
[0,0][0,0]
2 2

a和b均是预分配2个元素的切片,只是b的内部存储空间已经分配了10个,但实际使用了2个元素。

容量不会影响当前的元素个数,因此a和b取len都是2.

使用make()函数生成的切片一定发生了内存分配操作。但给定开始与结束位置(包括切片复位)的切片只是将新的切片结构指向已经分配好的内存区域,设定开始与结束位置,不会发生内存分配操作。

切片不一定必须经过make()函数才能使用。生成切片、声明后使用append()函数均可以正常使用切片。

使用append()函数为切片添加元素

Go语言的内建函数append()可以为切片动态添加元素。每个切片会指一片内存空间 ,这篇内存空间能容纳一定数量的元素。当空间不能容纳足够多的元素时,切片就会进行"扩容"。"扩容"操作往往发生在append()函数调用时。

容量的扩容规律按容量的2倍数扩充

var numbers []int
for i := 0;i<10;i++ {
    
    
    numbers = append(numbers,i)
    fmt.Printf("len:%d  cap: %d  pointer:  %p\n",len(numbers),cap(numbers),numbers)
}
输出:
len1  cap: 1  pointer:  0xc00001c0a8
len2  cap: 2  pointer:  0xc00001c0f0
len3  cap: 4  pointer:  0xc000014180
len4  cap: 4  pointer:  0xc000014180
len5  cap: 8  pointer:  0xc000018440
len6  cap: 8  pointer:  0xc000018440
len7  cap: 8  pointer:  0xc000018440
len8  cap: 8  pointer:  0xc000018440
len9  cap: 16  pointer:  0xc000020180
len10  cap: 16  pointer:  0xc000020180

每次搬家都需要将所有的人员转移到新的办公地点。

复制切片元素到另一个切片

使用Go语言内建的copy()函数,可以迅速地将一个切片的数据复制到另外一个切片空间中,copy()函数的使用格式如下:

copy(destSlice,srcSlice []T) int
  • srcSlice为数据来源切片。
  • destSlice为复制的目标。目标切片必须分配过空间且足够承载复制的元素个数。来源和目标的类型一致,copy的返回值表示实际发生复制的元素个数。

从切片中删除元素

Go语言中切片删除元素的本质是:以被删除元素为分界点,将前后两个部分的内存重新连接起来。

	seq := []string{
    
    "a", "b", "c", "d", "e"}

	index := 2

	fmt.Println(seq[:index], seq[index+1:])

	seq = append(seq[:index], seq[index:]...)

	fmt.Println(seq)

映射

Go语言提供的映射关系容器为map。map使用散列表(hash)实现。

添加关联到map并访问关联和数据

定义 :

map[KeyType]ValueType
  • KeyType为键类型
  • ValueType是键对应的值类型

一个map里,符合KeyType和ValueType的映射总是成对出现。

	scene := make(map[string]int)

	scene["route"] = 66

	fmt.Println(scene["route"])

	v := scene["route2"]

	fmt.Println(v)

某些情况下,需要明确知道查询中某个键是否在map中存在,可以使用一种特殊的写法来实现,如下代码:

v,ok := scene["route"] //如果键不存在,ok的值为false,v的值为该类型的零值

在默认获取键值的基础上,多取了一个变量ok,可以判断键route是否存在于map中。

遍历map的"键值对"

遍历对于Go语言的很多对象来说都是差不多的,直接使用for range语法。

	scene := make(map[string]int)
	scene["route"] = 66
	scene["brazil"] = 4
	scene["china"] = 960
	for k, v := range scene {
    
    
		fmt.Println(k, v)
	}

只遍历值:

	scene := make(map[string]int)
	scene["route"] = 66
	scene["brazil"] = 4
	scene["china"] = 960
	for _, v := range scene {
    
    
		fmt.Println(v)
	}

只遍历键:

	scene := make(map[string]int)
	scene["route"] = 66
	scene["brazil"] = 4
	scene["china"] = 960
	for k := range scene {
    
    
		fmt.Println(k)
	}

使用delete()函数从map中删除键值对

使用delete()内建函数从map中删除一组键值对,delete()函数的格式如下:

delete(map,键)
  • map为要删除的map实例
  • 键为要删除的map键值对中的键
	scene := make(map[string]int)
	scene["route"] = 66
	scene["brazil"] = 4
	scene["china"] = 960

	delete(scene, "brazil")
	
	for k := range scene {
    
    
		fmt.Println(k)
	}

并发环境中使用map-sync.Map

Go语言中的map在并发情况下,只读是线程安全的,同时读写线程不安全

需要并发读写时,一般的做法是加锁,但这样性能并不高。Go语言在1.9版本中提供了一种效率较高的并发安全的sync.Map。sync.Map和map不同,不是以语言原生形态提供而是在sync包下的特殊结构。

sync.Map有一下特性:

  • 无须初始化,直接声明即可。
  • sync.Map不能使用map的方式进行取值和设置等操作,而是使用sync.Map的方法进行调用。Store表示存储,Load表示获取,Delete表示删除。
  • 使用Range配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值。Range参数中的回调函数的返回值功能是:需要继续迭代遍历时,返回true;终止迭代遍历时,返回false

func main() {
    
    
	var scene sync.Map

	scene.Store("freece", 97)
	scene.Store("london", 100)
	scene.Store("egypt", 200)

	fmt.Println(scene.Load("london"))

	scene.Delete("london")

	scene.Range(func(k, v interface{
    
    }) bool {
    
    
		fmt.Println("iterate:", k, v)
		return true
	})
}
//输出
100 true
iterate: freece 97
iterate: egypt 200

sync.Map没有提供获取map数量的方法,替代方法时获取时遍历自行计算数量。sync.Map为了保证并发安全有一些性能损失,因此在非并发情况下,使用map相比使用sync.Map会有更好的性能。

列表

列表(List)-可以快速增删的非连续空间的容器

列表是一种非连续存储的容器,由多个节点组成,节点通过一些变量记录彼此之间的关系。列表有多种实现方法,如单链表、双链表等。

在Go语言中,将列表使用container/list包来实现,内部的实现原理是双链表。列表能够高效地进行任意位置的元素插入和删除操作。

初始化列表

list的初始化有两种方法:New和声明。两种方法的初始化效果都是一致的。

  1. 通过container/list包的New方法初始化list
变量名 := list.New()
  1. 通过声明初始化list
var 变量名 list.List

列表与切片和map不同的是,列表并没有具体元素类型的限制。因此,列表的元素可以是任意类型。这样既带来便利,也会引来一些问题。给一个列表放入了非期望类型的值,在取出值后,将interface{}转换为期望类型时将会发生宕机。

在列表中插入元素

双链表支持从队列前方或后方插入元素,分别对应的方法是PushFront和PushBack

l :=list.New()

l.PushBack("fist")
l.PushFront(67)
InsertAfter(v interface{
    
    },mark *Element) *Element
//在mark点之后插入元素,mark点由其他插入函数提供
InsertBefore(v interface{
    
    },mark *Element) *Element
//在mark点之前插入元素,mark点由其他插入函数提供
PushBackList(other *List)
//添加other列表元素到尾部
PushFrontList(other *List)
//添加other列表元素到头部

从列表中删除元素

列表的插入函数的返回值会提供一个*list.Element结构,这个结构记录着列表元素的值及和其他节点之间的关系等信息。从列表中删除元素时,需要用到这个结构进行快速删除。

package main

import (
	"container/list"
)

func main() {
    
    
	l := list.New()
	l.PushBack("canon")
	l.PushFront(67)
	element := l.PushFront("first")
    l.InsertAfter("high",element)
	l.InsertBefore("noon",element)
	l.Remove(element)
}

遍历列表

遍历双链表需要配合Front()函数获取头元素,遍历时只要元素不为空就可以继续进行。每一次遍历调用元素的Next,如代码中第9行所示。

package main

import (
	"container/list"
	"fmt"
)

func main() {
    
    
	l := list.New()

	l.PushBack("canon")

	l.PushFront(67)

	element := l.PushFront("first")

	l.InsertAfter("high", element)

	l.InsertBefore("noon", element)

	l.Remove(element)
	for i := l.Front(); i != nil; i = i.Next() {
    
    
		fmt.Println(i.Value)
	}

}

四、流程控制

条件判断(if)

格式:

if 表达式1 {
    
    
    分支1
}else if 表达式2{
    
    
    分支2
}else {
    
    
    分支3
}

Go语言规定与if匹配的左括号"{“必须与if和表达式放在同一行,如果尝试将”{"放在其他位置,将会触发编译错误。

同理,与else匹配的"{"也必须与else在同一行,else也必须与上一个if或else if的右边的大括号在一行。

var ten int = 11
	if ten > 10 {
    
    
		fmt.Println(">10")
	} else {
    
    
		fmt.Println("<=10")
	}

特殊写法

if还有一种特殊的写法,可以在if表达式之前添加一个执行语句,再根据变量值进行判断,代码如下:

if err := Connect();err != nil {
    
    
    fmt.Println(err)
    return
}

Connect是一个带有返回值的函数,err := Connect()是一个语句,执行Connect后,将错误保存到err变量中。

err !=nil 才是if的判断表达式,当err不为空时,打印错误并返回。

这种写法可以将返回值与判断放在一行进行处理,而且返回值的作用范围被限制在if、else语句组合中。

构建循环(for)

Go语言中的所有循环类型均可以使用for关键字来完成。

基于语句和表达式的基本for循环格式如下:

for 初始语句;条件表达式;结束语句{
    
    
    循环体代码
}

for循环可以通过break、goto、return、panic语句强制退出循环。

键值循环(for range)

Go 语言可以使用for range遍历数组、切片、字符串、map及通道(channel)。通过for range遍历的返回值有一定的规律:

  • 数组、切片、字符串返回索引和值
  • map返回键和值
  • 通道(channel)只返回通道内的值

遍历数组、切片-获得索引和元素

在遍历代码中,key和value分别代表切片的下标及下标对应的值

func main() {
    
    
	for key, value := range []int{
    
    1, 2, 3, 4, 5} {
    
    
		fmt.Printf("key:%d value:%d\n", key, value)
	}
}
//输出
key:0 value:1
key:1 value:2
key:2 value:3
key:3 value:4
key:4 value:5

遍历字符串-获得字符

Go语言和其他语言类似,可以通过for range的组合,对字符串进行遍历,遍历时,key和value分别代表字符串的索引(base0)和字符串中的每一个字符。

	str := "hello,你好"
	for key, value := range str {
    
    
		fmt.Printf("key:%d value:0x%x\n", key, value)
	}
//输出:
key:0 value:0x68
key:1 value:0x65  
key:2 value:0x6c  
key:3 value:0x6c  
key:4 value:0x6f  
key:5 value:0x2c  
key:6 value:0x4f60
key:9 value:0x597d

代码中的value变量,实际类型是rune,实际上就是int32,以十六进制打印出来就是字符的编码。

遍历map-获得map的键和值

对于map类型来说,for range遍历时,key和value分别代表map的索引键key和索引对应的值,一般被称为map的键值对,因为它们总是一对一对的出现。

	m := map[string]int{
    
    
		"hello": 100,
		"world": 200,
	}
	for key, value := range m {
    
    
		fmt.Println(key, value)
	}
//代码输出
hello 100
world 200

对map遍历时,遍历输出的键值是无序的,如果需要有序的键值对输出,需要对结果进行排序

遍历通道(channel)-接收通道数据

for range可以遍历通道(channel),但是通道在遍历时,只输出一个值,即管道内的类型对应的数据。

c := make(chan int)
	go func() {
    
    
		c <- 1
		c <- 2
		c <- 3
		close(c)
	}()
	for v := range c {
    
    
		fmt.Println(v)
	}

在遍历中选择希望获得的变量(匿名变量)

在使用for range循环遍历某个对象时,一般不会同时需要key或者value,这个时候可使用匿名变量

	m := map[string]int{
    
    
		"hello": 100,
		"world": 200,
	}
	for _, value := range m {
    
    
		fmt.Println(value)
	}
//输出
100
200

匿名变量:

  • 可以理解为一种占位符
  • 本身这种变量不会进行空间分配,也不会占用一个变量的名字。
  • 在for range可以对key使用匿名变量,也可以对value使用匿名变量

分支选择(switch)

基本写法

Go语言改进了switch的语法设计,避免人为造成失误。Go语言的switch中的每一个case与case之间是独立的代码块,不需要通过break语句跳出当前case代码块以避免执行到下一行。

	var a = "hello"
	switch a {
    
    
	case "hello":
		fmt.Println(1)
	case "world":
		fmt.Println(2)
	default:
		fmt.Println(3)
	}
//输出
1

每一个case均是字符串格式,且使用了defaut分支,Go语言规定每个switch只能有一个default分支。

一分支多值

当多个case要放在一起的时候,如下

var a = "num"
switch a {
    
    
    case "num","daddy":
    fmt.Print(1)
}

分支表达式

case后不仅仅只是常量,还可以和if一样添加表达式,代码如下:

var r int = 11
switch {
    
    
    case r>10 && r<20:
    fmt.Println(r)
}

注意,这种情况switch后面不再跟判断变量,连判断的目标都没有了

跨越case的fallthrough

在Go语言中case是一个独立的代码块,执行完毕后不会像C语言那样紧接着下一个case执行。但是为了兼容一些移植代码,依然加入了fall through关键字来实现这一功能。

var s ="hello"
switch {
    
    
  case s == "hello"
    fmt.Println("hello")
    fallthrough
  case s != "world" :
    fmt.Println("world")
}

跳转到指定代码标签(goto)

goto语句通过标签进行代码间的无条件跳转。goto语句可以在快速跳出循环、避免重复退出上有一定的帮助。Go语言中使用goto语句能简化一些代码的实现过程。

for x := 0; x < 10; x++ {
    
    
		for y := 0; y < 10; y++ {
    
    
			if y == 2 {
    
    
				goto breakHere
			}
		}
	}
//手动返回
	return
breakHere:
	fmt.Println("done")

标签只能被goto使用,但不影响代码执行流程,此处如果不手动返回,在不满足条件时,也会执行第24行代码。

跳出指定循环(break)

可以跳出多层循环

break语句可以结束for、switch和select的代码块。break语句还可以在语句后面添加标签,表示退出某个标签对应的代码块,标签要求必须定义在对应的for、switch和select的代码块上

OuterLoop:
	for x := 0; x < 10; x++ {
    
    
		for y := 0; y < 10; y++ {
    
    
			switch y {
    
    
			case 2:
				fmt.Println(1)
				break OuterLoop
			case 1:
				fmt.Println(2)
				break OuterLoop
			default:
				fmt.Println(3)
			}
		}
	}

继续下一次循环(continue)

continue语句可以结束当前循环,开始下一次的循环迭代过程,仅限在for循环内使用。在continue语句后添加标签时,表示开始标签对应的循环。

OuterLoop:
   for x := 0; x < 10; x++ {
    
    
      for y := 0; y < 10; y++ {
    
    
         switch y {
    
    
         case 2:
            fmt.Println(1)
            continue OuterLoop
         case 1:
            fmt.Println(2)
            break OuterLoop
         default:
            fmt.Println(3)
         }
      }
   }

五、函数(function)

函数是组织好的、可重复使用的、用来实现单一或相关联功能的代码块,其可以提高应用的模块性和代码的重复利用率。

Go语言支持普通函数、匿名函数和闭包,从设计上对函数进行了优化和改进,让函数使用起来更加方便。

Go语言的函数属于"一等公民"

  • 函数本身可以作为值进行传递
  • 支持匿名函数和闭包(closure)
  • 函数可以满足接口。

声明函数

普通函数需要先声明才能调用。一个函数的声明包括参数和函数名等,编译器通过声明才能了解函数应该怎样在调用代码和函数体之间传递参数和返回参数。

普通函数的声明形式

Go语言的函数声明以func标识,后面紧接着函数名、参数列表、返回参数列表及函数体,具体形式如下:

func 函数名(参数列表) (返回参数列表) {
    
    
    函数体
}

函数名:由字母、数字、下画线组成。其中,函数名的第一个字母不能为数字。在同一个包内,函数名称不能重名。

参数列表:一个参数由参数变量和参数类型组成,例如:

func foo(a int,b string)

其中,参数列表中的变量作为函数的局部变量而存在。

返回参数列表:可以是返回值类型列表,也可以是类似参数列表中变量名和类型名的组合。函数在声明有返回值时,必须在函数体中使用return语句提供返回值列表。

函数体:能够被重复调用的代码片段。

参数类型的简写

在参数列表中,如有多个参数变量,则以逗号分隔;如果相邻变量是同类型,则可以将类型省略。例如:

func add(a,b int) int {
    
    
    return a + b
}

函数的返回值

.Go语言支持多返回值,多返回值能方便地获得函数执行后的多个返回参数,Go语言经常使用多返回值中的最后一个返回参数返回函数执行中可能发生的错误。实例如下:

conn,err :=connectToNetwork()

上面代码中,connectToNetwork返回两个参数,conn表示连接对象,err返回错误。

Go语言既支持安全指针,但支持多返回值,因此在使用函数进行逻辑编写时更为方便。

  1. 同一类型返回值

如果返回值是同一种类型,则用括号将多个返回值类型括起来,用括号分隔每个返回值的类型。

使用return语句返回时,值列表的顺序需要与函数声明的返回值类型一致。实例代码如下:

func typedTwoValues() (int,int) {
    
    
    return 1,2
}
a,b := typedTwoValues()
fmt.Println(a,b)
//输出
1 2
  1. 带有变量名的返回值

Go语言支持对返回值进行命名,这样返回值就和参数一样拥有参数变量名和类型。

命名的返回值变量的默认值为类型的默认值,即数值为0,字符串为空字符串,布尔类型为false、指针为nil等。

下面代码中的函数拥有两个整型返回值,函数声明时将返回值命名为a和b,因此可以在函数体中直接对函数返回值进行赋值。在命名的返回值方式的函数体中,在函数结束前需要显式地使用return语句进行返回,代码如下:

func namedRetValues() (a,b int) {
    
    
    a = 1
    b = 2
    return
}

下面代码的执行效果和上面代码的效果一样:

func namedRetValues() (a,b int) {
    
    
    a = 1
    return a,2
}

同一种类型返回值和命名返回值两种形式只能二选一,混用时将会发生编译错误。

func named RetValues() (a,b int,int)
错误提示:
mixed named and unnamed function parameters
意思是:在函数参数中混合使用了命名和非命名参数。

函数调用

函数在定义后,可以通过调用的方式,让当前代码跳转到被调用的函数中进行执行。调用前的函数局部变量都会被保存起来不会丢失;被调用的函数结束后,恢复到被调用函数的下一行继续执行代码,之前的局部变量也能继续访问。

函数内的局部变量只能在函数体中使用,函数调用结束后,这些局部变量都会被释放并且失效。

格式:

返回值变量列表 = 函数名(参数列表)
  • 函数名:需要调用的函数名。
  • 参数列表:参数变量以逗号分隔,尾部无须以分号结尾。
  • 返回值变量列表:多个返回值使用逗号分隔。

例如,加法函数调用样式如下:

result := add(1,1)

函数变量

把函数作为值保存到变量中

在Go语言中,函数也是一种类型,可以和其他类型一样被保存在变量中。

func fire() {
    
    
	fmt.Println("fire")
}
func main() {
    
    

	var f func()
	f = fire
	f()
}

下面代码定义了一个函数变量f,并将一个函数名fire()赋给函数变量f,这样调用函数变量f时,实际调用的就是fire()函数。

匿名函数

没有函数名字的函数

Go语言支持匿名函数,即在需要使用函数时,再定义函数,匿名函数没有函数名,只有函数体,函数可以被作为一种类型被赋值给函数类型的变量,匿名函数也往往以变量方式被传递。

匿名函数经常被用于实现回调函数、闭包等。

格式:

func(参数列表) (返回参数列表) {
    
    
    函数体
}

匿名函数的定义就是没有名字的普通函数定义。

  1. 在定义时调用匿名函数

匿名函数可以在声明后调用,例如:

	func(data int) {
    
    
		fmt.Println("hello", data)
	}(100)
  1. 将匿名函数赋值给变量

匿名函数体可以被赋值,例如:

f := func(data int) {
    
    
		fmt.Println("hello", data)
	}
f(100)
//输出
hello,100

匿名函数作为回调函数

package main

import "fmt"

func visit(list []int, f func(int2 int)) {
    
    
	for _, v := range list {
    
    
		f(v)
	}
}
func main() {
    
    
	visit([]int{
    
    1, 2, 3, 4}, func(v int) {
    
    fmt.Println(v)})
}
//输出
1 2 3 4

使用visit()函数将整个遍历过程进行封装,当要获取遍历期间的切片值时,只需要给visit()传入一个回调函数即可。

准备一个整型切片[]int{1,2,3,4}传入visit()函数作为遍历的数据。

定义了一个匿名函数,作用是将遍历的每个值打印出来。

使用匿名函数实现操作封装

下面这段代码将匿名函数作为map的键值,通过命令行参数动态调用匿名函数

package main

import (
	"flag"
	"fmt"
)
//定义命令行参数skill,从命令行输入--skill 可以将空格后的字符串传入skillParam指针变量。
var skillParam = flag.String("skill", "", "skill to perform")

func main() {
    
    
    //解析命令行参数,解析完成后,skillParam指针变量将指向命令行传入的值。
	flag.Parse()
    //定义一个从字符串映射带func()的map,然后填充这个map。
    //初始化map的键值对,值为匿名函数
	var skill = map[string]func(){
    
    
		"fire": func() {
    
    
			fmt.Println("chicken fire")
		},
		"run": func() {
    
    
			fmt.Println("solider run")
		},
		"fly": func() {
    
    
			fmt.Println("angel fly")
		},
	}
    //skillParam是一个*string类型的指针变量,使用*skillParam获取到命令行传过来的值,并在map中查找对应命令行参数指定的字符串的函数。
    //如果在map定义中存在这个参数就调用;否则打印“skill not found”
	if f, ok := skill[*skillParam]; ok {
    
    
		f()
	} else {
    
    
		fmt.Println("skill not found")
	}
}

函数类型实现接口

把函数作为接口来调用

函数和其他类型一样都属于"一等公民",其他类型可以实现接口,函数也可以。

//调用器接口
type Invoker interface {
    
    
    //需要实现一个Call()方法
    Call(interface{
    
    })
}

这个接口需要实现Call()方法,调用时会传入一个interface{}类型的变量,这种类型的变量表示任意类型的值。

结构体实现接口

//结构体实现接口
type Struct struct {
    
    
    
}
//实现Invoker的Call
func (s *struct) Call(p interface{
    
    }) {
    
    
    fmt.Println("from struct",p)
}

将定义的Struct类型实例化,并传入接口中进行调用

//声明接口变量
var invoker Invoker
//实例化结构体
s := new(Struct)
//将实例化的结构体赋值到接口
invoker = s
//使用接口调用实例化结构体的方法Struct.Call
invoker.Call("hello")
//输出:from struct hello

函数体实现接口

函数的声明不能直接实现接口,需要将函数定义为类型后,使用类型实现结构体。当类型方法被调用时,还需要调用函数本体。

//函数定义为类型
type FuncCaller func(interface{
    
    })

//实现Invoker的Call
func (f FuncCaller) Call(p interface{
    
    }) {
    
    
    //调用函数体本体
    f(p)
}

.上面代码只是定义了函数类型,需要函数本身进行逻辑处理。FuncCaller无须被实例化,只需要将函数转换为FuncCaller类型即可,函数来源可以是命名函数、匿名函数或闭包,参见下列代码:

//声明接口变量
var invoker Invoker
//将匿名函数转为FuncCaller类型,再赋值给接口
invoker = FuncCaller(func(v interface{
    
    }){
    
    
    fmt.Println("from function",v)
})
//使用接口调用FuncCaller.Call,内部会调用函数本体
invoker.Call("hello")

将func(v interface{}){}匿名函数转换为FuncCaller类型(函数签名才能转换),此时FuncCaller类型实现了Invoker的Call()方法,赋值给invoker接口时成功的。

闭包

引用了外部变量的匿名函数

闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量。

函数+引用环境=闭包。

image-20230410162600207.png

一个函数类型就像结构体一样,可以被实例化。函数本身不存储任何信息,只有与引用环境结合后形成的闭包才具有"记忆性"。函数是编译期静态的概念,而闭包是运行期动态的概念。

在闭包内部修改引用的变量

闭包对它作用域上部变量的引用可以进行修改,修改引用的变量就会进行实际修改。

str := "hello"

foo := func() {
    
    
    str = "hell"
}
foo()

闭包的记忆效应

被捕获到闭包中的变量让闭包本身拥有了记忆效应,闭包中的逻辑可以修改闭包捕获的变量,变量会跟随闭包生命期一直存在,闭包本省就如同变量一样拥有了记忆效应。

package main

import "fmt"

func Accumulate(value int) func() int {
    
    
	return func() int {
    
    
		value++
		return value
	}
}
func main() {
    
    
	accumulate := Accumulate(1)
	fmt.Println(accumulate())
	fmt.Println(accumulate())
	accumulator2 := Accumulate(10)
	fmt.Println(accumulator2())
}

每调用一次accumulator都会自动对引用的变量进行累加。

闭包实现生成器

闭包的记忆效应进程被用于实现类似于设计模式中的工厂模式的生成器。

package main

import "fmt"

func playerGen(name string) func() (string, int) {
    
    
	hp := 150
	return func() (string, int) {
    
    
		return name, hp
	}
}
func main() {
    
    
	generator := playerGen("high noon")

	name, hp := generator()
	fmt.Prinln(name, hp)
}

闭包还具有一定的封装性,hp:=150是playerGen的局部变量,playerGen的外部无法直接访问及修改这个变量,这种特性也与面向对象中的封装性类似。

可变参数

参数数量不固定的函数形式

Go语言支持可变参数特性,函数声明和调用时没有固定数量的参数,同时也提供了一套方法进行可变参数的多级传递。

func 函数名(固定参数列表,v...T)(返回参数列表) {
    
    
    函数体
}
  • 可变参数一般被放置在函数列表的末尾,前面时固定参数列表,当没有固定参数时,所有变量就将是可变参数。
  • v为可变参数变量,类型为[]T,也就是拥有多个T元素的T类型切片,v和T之间由"…"即3个点组成。
  • T为可变参数的类型,当T为interface{}时,传入的可以时任意类型。

fmt包中的例子

可变参数有两种形式:所有参数都是可变参数的形式,如fmt.Println,以及部分是可变参数的形式,如fmt.Printf,可变参数只能出现在参数的后半部分,因此不可变的参数只能放在参数的前半部分。

  1. 所有参数都是可变参数:fmt.Println
func Println(a ...any) (n int, err error) {
    
    
	return Fprintln(os.Stdout, a...)
}

fmt.Println在使用时,传入的值类型不受限制,例:

fmt.Println(5,"hello",&struct{
    
    a int }{
    
    1},true)
  1. 部分参数是可变参数:fmt.Printf

fmt.Printf的第一个参数为参数列表,后面的参数是可变参数,fmt.Printf函数的格式如下:

func Printf(format string, a ...any) (n int, err error) {
    
    
	return Fprintf(os.Stdout, format, a...)
}

fmt.Prinf()函数在调用时,第一个函数始终必须传入字符串中,对应参数format,后面的参数数量可以变化,使用时,代码如下:

fmt.Printf("pure string\n")
fmt.Printf("value: %v %f\n",true,math.Pi)

遍历可变参数列表

可变参数列表的数量不固定,传入的参数是一个切片。如果需要获得每一个参数的具体值时,可以对可变参数变量进行遍历。

package main

import (
	"bytes"
	"fmt"
)

//定义一个函数,参数数量为0-n,类型约束为字符串
func joinStrings(slist ...string) string {
    
    
	//定义一个字节缓冲,快速地连接字符串
	var b bytes.Buffer
	//遍历可变参数列表slist,类型为[]string
	for _, s := range slist {
    
    
		//将遍历出的字符串连续写入字节数组
		b.WriteString(s)
	}
	//将连接好的字节数组转换为字符串并输出
	return b.String()

}
func main() {
    
    
	//输入3个字符串,将它们连成一个字符串
	fmt.Println(joinStrings("pig", "and", "rat"))
	fmt.Println(joinStrings("hammer", "mom", "and", "hawk"))
}
//输出如下:
pig and rat
hammer mom and hawk

如果要获取可变参数的数量,可以使用len()函数对可变参数变量对应的切片进行求长度操作,以获得可变参数数量。

获得可变参数类型

当可变参数为interface{}类型时,可以传入任何类型的值。此时,如果需要获得变量的类型,可以通过switch类型分支获得变量的类型。

package main

import (
	"bytes"
	"fmt"
)

func printTypeValue(slist ...interface{
    
    }) string {
    
    
	//字节缓存作为快速字符串连接
	var b bytes.Buffer
	//遍历参数
	for _, s := range slist {
    
    
		//将interface{}类型格式化为字符串
		str := fmt.Sprintf("%v", s)
		//类型的字符串描述
		var typeString string
		//对s进行类型断言
		switch s.(type) {
    
    
		case bool:
			typeString = "bool"
		case string:
			typeString = "string"
		case int:
			typeString = "int"
		}
		//写值字符串前缀
		b.WriteString("value: ")
		//写入值
		b.WriteString(str)
		//写类型前缀
		b.WriteString(" type: ")
		//写类型字符串
		b.WriteString(typeString)
		//写入换行符
		b.WriteString("\n")
	}
    //将连接好的字节数组转换为字符串输出
	return b.String()
}

func main() {
    
    
	//将不同类型的变量通过printTypeValue()
	fmt.Println(printTypeValue(100, "str", true))

}

在多个可变参数函数中传递参数

可变参数变量是一个包含所有参数的切片,如果要在多个可变参数中传递参数,可以在传递时在可变参数变量中默认添加"…",将切片中的元素进行传递,而不是传递可变参数变量本身。

package main

import "fmt"

//实际打印的参数
func rawPrint(rawList ...interface{
    
    }) {
    
    
	//遍历可变参数切片
	for _, a := range rawList {
    
    
		//打印参数
		fmt.Println(a)
	}
}

//打印函数封装
func print(slist ...interface{
    
    }) {
    
    
	rawPrint(slist...)
}
func main() {
    
    
	print(1, 2, 3)

}

延迟执行语句(defer)

Go语言的defer语句会将其后面跟随的语句进行延迟处理。在defer归属的函数即将返回时,将延迟处理的语句按defer的逆序进行执行,也就是说,先被defer的语句最后被执行,最后被defer的语句,最先被执行。

多个延迟执行语句的处理顺序

package main

import "fmt"

func main() {
    
    
	fmt.Println("defer begin")
	//将defer放入延迟调用栈
	defer fmt.Println(1)
	defer fmt.Println(2)
	//最后一个放入,位于栈顶,最先调用
	defer fmt.Println(3)

	fmt.Println("defer end")
}
//代码输出:
defer begin
defer end
3        
2        
1

代码的延迟顺序与最终的执行顺序是反向的。

延迟调用是在defer所在函数结束时进行,函数结束可以是正常返回时,也可以是发生宕机时。

使用延迟执行语句在函数退出时释放资源

defer语句是在函数退出时执行的语句,所以使用defer能非常方便地处理资源释放问题。

  1. 使用延迟并发解锁

在下面例子中会在函数中并发使用map,为防止静态问题,使用sync.Mutex进行加锁。

var{
    
    
	//一个演示用的映射
	valueByKey = make(map[string] int)
	//保证使用映射时的并发安全的互斥锁
	valueByKeyGuard sync.Mutex
}
func readValue(key string) int {
    
    
	//对共享资源加锁
	valueByKeyGuard.lock()
	//取值
	v := valueByKey(key)
	//对共享资源解锁
	valueByKeyGuard.Unlock()
	//返回值
	return v
}

map默认不是并发安全的,准备一个sync.Mutex互斥量保护map的访问。

readValue()函数给定一个键,从map中获得值后 返回,该函数会在并发环境中使用,需要保证并发中使用,需要保证并发安全。

用defer语句对上面的语句进行简化

func readValue(key string) int {
    
    
	//对共享资源加锁
	valueByKeyGuard.lock()
	//取值
	v := valueByKey(key)
	//对共享资源解锁
	 defer valueByKeyGuard.Unlock()
	//返回值
	return v
}

使用defer语句添加解锁,该语句不会马上执行,而是等readValue()返回时才会被执行。

从map查询值并返回的过程中,与不使用互斥量的写法一样,对比上面代码,这种写法更简单。

  1. 使用延迟释放文件句柄

文件的操作需要经过打开文件、获取和操作文件资源、关闭资源几个过程,如果在操作完毕后不关闭文件资源,进程将一直无法释放文件资源,在下面的例子中将实现根据文件名获取文件大小的函数,函数中需要打开文件、获取文件大小和关闭文件等操作,由于每一步系统操作都需要进行错误处理,而每一步处理都会造成一次可能的退出,因此就需要在退出时释放资源,而我们需要密切关注在函数退出处正确地释放文件资源,参考下面的代码:

// 根据文件名查询其大小
func fileSize(filename string) int64 {
    
    
    // 根据文件名打开文件, 返回文件句柄和错误
    f, err := os.Open(filename)
    // 如果打开时发生错误, 返回文件大小为0
    if err != nil {
    
    
        return 0
    }
    // 取文件状态信息 此时文件句柄 f 可以正常使用,使用 f 的方法 Stat() 来获取文件的信息,获取信息时,可能也会发生错误。
    info, err := f.Stat()
   
    // 如果获取信息时发生错误, 关闭文件并返回文件大小为0
    if err != nil {
    
    
        f.Close()
        return 0
    }
    // 取文件大小
    size := info.Size()
    // 关闭文件
    f.Close()
   
    // 返回文件大小
    return size
}

下面使用 defer 对代码进行简化

func fileSize(filename string) int64 {
    
    
    f, err := os.Open(filename)
    if err != nil {
    
    
        return 0
    }
    // 延迟调用Close, 此时Close不会被调用
    defer f.Close()
    info, err := f.Stat()
    if err != nil {
    
    
        // defer机制触发, 调用Close关闭文件
        return 0
    }
    size := info.Size()
    // defer机制触发, 调用Close关闭文件
    return size
}

处理运行时发生的错误

Go语言的错误处理思想及设计包含以下特征:

  • 一个可能造成错误的函数,需要返回值中返回一个错误接口(error)。如果调用是成功的,错误接口将返回nil,否则返回错误。
  • 在函数调用后需要检查错误,如果发生错误,进行必要的错误处理

Go语言希望开发者将错误处理视为正常开发必须实现的环节,正确地处理每一个可能发生错误的函数,同时,Go语言使用返回值返回错误的机制,也能大幅降低编译器、运行时处理错误的复杂度,让开发者真正地掌握错误的处理。

net包中的例子

net.Dial() 是Go语言系统包 net 即中的一个函数,一般用于创建一个 Socket 连接。

net.Dial 拥有两个返回值,即 Conn 和 error,这个函数是阻塞的,因此在 Socket 操作后,会返回 Conn 连接对象和 error,如果发生错误,error 会告知错误的类型,Conn 会返回空。

根据Go语言的错误处理机制,Conn 是其重要的返回值,因此,为这个函数增加一个错误返回,类似为 error,参见下面的代码:

func Dial(network, address string) (Conn, error) {
    
    
    var d Dialer
    return d.Dial(network, address)
}

在 io 包中的 Writer 接口也拥有错误返回,代码如下:

type Writer interface {
    
    
    Write(p []byte) (n int, err error)
}

io 包中还有 Closer 接口,只有一个错误返回,代码如下:

type Closer interface {
    
    
    Close() error
}

错误接口的定义格式

error是Go系统声明的接口类型

type error interface {
    
    
    Error() string
}

所有符合Error() string格式的方法,都能实现错误接口

Error()方法返回错误的具体描述,使用者可以通过这个字符串知道发生了什么错误。

自定义一个错误

返回错误前,需要定义会产生哪些可能的错误。在Go语言中,使用errors进行错误的定义,格式如下:

var err = errors.New("this is an error")

错误字符串由于相对固定,一般在包作用域声明,应尽量减少在使用时直接使用errors.New返回。

  1. errors包
// 创建错误对象
func New(text string) error {
    
    
    return &errorString{
    
    text} //将 errorString 结构体实例化,并赋值错误描述的成员。
}
// 错误字符串 
type errorString struct {
    
     //声明 errorString 结构体,拥有一个成员,描述错误内容。
    s string
}
// 返回发生何种错误
func (e *errorString) Error() string {
    
     //实现 error 接口的 Error() 方法,该方法返回成员中的错误描述。
    return e.s
}
  1. 在代码中使用错误定义

下面的代码会定义一个除法函数,当除数为0时,返回一个预定义的除数为0的错误。

package main
import (
    "errors"
    "fmt"
)
// 定义除数为0的错误
var errDivisionByZero = errors.New("division by zero")
func div(dividend, divisor int) (int, error) {
    
    
    // 判断除数为0的情况并返回
    if divisor == 0 {
    
    
        return 0, errDivisionByZero
    }
    // 正常计算,返回空错误
    return dividend / divisor, nil
}
func main() {
    
    
    fmt.Println(div(1, 0))
}
//输出如下:
0 division by zero

示例:在解析中使用自定义错误

使用 errors.New 定义的错误字符串的错误类型是无法提供丰富的错误信息的,那么,如果需要携带错误信息返回,就需要借助自定义结构体实现错误接口。

下面代码将实现一个解析错误(ParseError),这种错误包含两个内容,分别是文件名和行号,解析错误的结构还实现了 error 接口的 Error() 方法,返回错误描述时,就需要将文件名和行号返回。

package main
import (
    "fmt"
)
// 声明一个解析错误
type ParseError struct {
    
    
    Filename string // 文件名
    Line     int    // 行号
}
// 实现error接口,返回错误描述
func (e *ParseError) Error() string {
    
    
    return fmt.Sprintf("%s:%d", e.Filename, e.Line)
}
// 创建一些解析错误
func newParseError(filename string, line int) error {
    
    
    return &ParseError{
    
    filename, line}
}
func main() {
    
    
    var e error
    // 创建一个错误实例,包含文件名和行号
    e = newParseError("main.go", 1)
    // 通过error接口查看错误描述
    fmt.Println(e.Error())
    // 根据错误接口具体的类型,获取详细错误信息
    switch detail := e.(type) {
    
    
    case *ParseError: // 这是一个解析错误
        fmt.Printf("Filename: %s Line: %d\n", detail.Filename, detail.Line)
    default: // 其他类型的错误
        fmt.Println("other error")
    }
}
//代码输出如下:
main.go:1
Filename: main.go Line: 1

错误对象都要实现 error 接口的 Error() 方法,这样,所有的错误都可以获得字符串的描述,如果想进一步知道错误的详细信息,可以通过类型断言,将错误对象转为具体的错误类型进行错误详细信息的获取。

宕机(panic)

程序终止运行

宕机不是一件很好的事情,可能造成体验停止、服务中断,就像没有人希望在取钱时遇到ATM机蓝屏一样。但是,如果在损失发生时,程序没有因为宕机而停止,那么用户将会付出更大的代价,这种代价可以是金钱、时间甚至生命。因此,宕机有时是一种合理的止损方法。

手动触发宕机

Go语言可以在程序中手动你触发宕机,让程序崩溃,这样开发者可以及时地发现错误,同时减少可能的损失。

Go语言程序在宕机时,会见堆栈和goroutine信息输出到控制台,所以宕机也可以方便地知晓发生错误的位置。如果在编译时加入的调试信息甚至连崩溃现场的变量值、运行状态都可以获取,那么如何触发宕机呢?

func main() {
    
    
	panic("crash")
}

输出如下:

panic: crash                                              
                                                          
goroutine 1 [running]:                                    
main.main()                                               
        D:/Code/Back-end/Go/project/HelloWorld.go:16 +0x27

以上代码中只用了一个内建的函数panic()就可以造成崩溃,panic()的声明如下:

func panic(v interface())

panic()的参数可以是任意类型,后文将提到的recover参数会接收从panic()中发出的 内容。

在运行依赖的必备资源缺失时主动触发宕机

regexp是Go语言的正则表达式包,正则表达式需要编译后才能使用,而且编译必须是成功的,表示正则表达式可用。

编译正则表达式函数有两种,具体如下。

func Compile(expr string)(*Regexp,error)

编译正则表达式,发生错误时返回编译错误同时返回 Regexp 为 nil,该函数适用于在编译错误时获得编译错误进行处理,同时继续后续执行的环境。

func MustCompile(str string) *Regexp

当编译正则表达式发生错误时,使用 panic 触发宕机,该函数适用于直接使用正则表达式而无须处理正则表达式错误的情况。

MustCompile的代码如下:

func MustCompile(str string) *Regexp {
    
    
    regexp, error := Compile(str)
    if error != nil {
    
    
        panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
    }
    return regexp
}

手动宕机进行报错的方式不是一种偷懒的方式,反而能迅速报错,终止程序继续运行,防止更大的错误产生。不过,如果任何错误都使用宕机处理,也不是一种良好的设计。因此应根据需要来决定是否使用宕机进行报错。

在宕机时触发延迟执行语句

当panic()触发的宕机发生时,panic()后面的代码将不会被运行,但是在panic()函数前面已经运行过的defer语句依然会在宕机发生时发生作用。

func main() {
    
    
	defer fmt.Println("宕机后要做的事1")
	defer fmt.Println("宕机后要做的事情2")
	panic("宕机")
}

输出结果:

宕机后要做的事情2
宕机后要做的事1                                           
panic: 宕机

宕机前,defer 语句会被优先执行,由于第 7 行的 defer 后执行,因此会在宕机前,这个 defer 会优先处理,随后才是第 6 行的 defer 对应的语句,这个特性可以用来在宕机发生前进行宕机信息处理。

宕机恢复(recover)

防止程序崩溃

无论事代码运行错误由Runtime层抛出的panic崩溃,还是主动触发的panic崩溃,都可以配合defer和recover实现错误捕捉和恢复,让代码在发生崩溃后允许继续运行。

让程序在崩溃时继续执行

下面的代码实现了 ProtectRun() 函数,该函数传入一个匿名函数或闭包后的执行函数,当传入函数以任何形式发生 panic 崩溃后,可以将崩溃发生的错误打印出来,同时允许后面的代码继续运行,不会造成整个进程的崩溃。

package main

import (
   "fmt"
   "runtime"
)

// 崩溃时需要传递的上下文信息  声明描述错误的结构体,保存执行错误的函数。
type panicContext struct {
    
    
   function string // 所在函数 
}

// 保护方式允许一个函数
func ProtectRun(entry func()) {
    
    
   // 延迟处理的函数
   defer func() {
    
     //使用 defer 将闭包延迟执行,当 panic 触发崩溃时,ProtectRun() 函数将结束运行,此时 defer 后的闭包将会发生调用。
      // 发生宕机时,获取panic传递的上下文并打印
      err := recover()//recover() 获取到 panic 传入的参数。
      switch err.(type) {
    
    //使用 switch 对 err 变量进行类型断言。
      case runtime.Error: // 运行时错误 如果错误是有 Runtime 层抛出的运行时错误,如空指针访问、除数为 0 等情况,打印运行时错误。
         fmt.Println("runtime error:", err)
      default: // 非运行时错误  其他错误,打印传递过来的错误数据。
         fmt.Println("error:", err)
      }
   }()
   entry()
}
func main() {
    
    
   fmt.Println("运行前")
   // 允许一段手动触发的错误
   ProtectRun(func() {
    
    
      fmt.Println("手动宕机前")
      // 使用panic传递上下文
      panic(&panicContext{
    
     //使用 panic 手动触发一个错误,并将一个结构体附带信息传递过去,此时,recover 就会获取到这个结构体信息,并打印出来。
         "手动触发panic",
      })
      fmt.Println("手动宕机后")
   })
   // 故意造成空指针访问错误
   ProtectRun(func() {
    
    
      fmt.Println("赋值宕机前")
      var a *int
      *a = 1  //模拟代码中空指针赋值造成的错误,此时会由 Runtime 层抛出错误,被 ProtectRun() 函数的 recover() 函数捕获到。
      fmt.Println("赋值宕机后")
   })
   fmt.Println("运行后")
}

输出:

运行前
手动宕机前
error: &{
    
    手动触发panic}
赋值宕机前
runtime error: runtime error: invalid memory address or nil pointer dereference
运行后

panic和recover的关系

panic 和 recover 的组合有如下特性:

  • 有 panic 没 recover,程序宕机。
  • 有 panic 也有 recover,程序不会宕机,执行完对应的 defer 后,从宕机点退出当前函数后继续执行。

提示

虽然 panic/recover 能模拟其他语言的异常机制,但并不建议在编写普通函数时也经常性使用这种特性。

在 panic 触发的 defer 函数内,可以继续调用 panic,进一步将错误外抛,直到程序整体崩溃。

如果想在捕获错误时设置当前函数的返回值,可以对返回值使用命名返回值方式直接进行设置。

六、结构体

Go语言通过用自定义方式形成新的类型,结构体是类型中带有成员的复合类型。Go语言使用结构体和结构体成员来描述真实世界的实体和实体对应的各种属性。

Go语言中的类型可以被实例化,使用new或&构造的类型实例的类型是类型的指针。

结构体成员是由一系列的成员变量构成,这些成员变量也被称为"字段"。字段有以下特性:

  • 字段拥有自己的类型和值。
  • 字段名必须唯一。
  • 字段的类型也可以是结构体,甚至是字段所在的结构体的类型。

定义结构体

Go语言的关键字type可以将各种基本类型定义为自定义类型,基本类型包括整型、字符串、布尔等。结构体是一种复合的基本类型,通过type定义为自定义类型后,使结构体更方便于使用。

type 类型名 struct {
    
    
    字段1 字段1类型
    字段2 字段2类型
}
  • 类型名:标识自定义结构体的名称,在同一个包内不能重复
  • struct{}:标识结构体类型,type类型名struct{}可以理解为将struct{}结构体定义为类型名的类型。
  • 字段1、字段2…:表示结构体字段名。结构体中的字段名必须唯一。
  • 字段1类型、字段2类型…:表示结构体字段的类型。

实例化结构体

结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存。因此必须在定义结构体并实例化后才能使用结构体的字段。

实例化就是根据结构体定义的格式创建一份与格式一致的内存区域,结构体实例与实例间的内存是完全独立的。

Go语言可以通过多种方式实例化结构体,根据实际需要可以选用不同的写法。

基本的实例化类型

结构体本身是一种类型,可以像整型、字符串等类型一样,以var的方式声明结构体即可完成实例化。

基本实例化格式如下:

var ins T
  • T为结构体类型。
  • ins为结构体的实例

用结构体表示的点结构(Point)的实例化过程参见下面的代码:

type Point  struct {
    
    
    X int
    Y int 
}
var p Point
p.X=10
p.Y=20

结构体帮成员变量的赋值方法与普通变量一致。

创建指针类型的结构体

Go语言中,还可以使用new关键字对类型(包括结构体、整型、浮点数、字符串等)进行实例化,结构体在实例化后会形成指针类型的结构体。

使用new的格式如下:

ins :=new (T)
  • T为类型,可以是结构体、整型、字符串等。
  • ins:T类型被实例化后保存到ins变量中,ins的类型为*T,属于指针。
type Player struct {
    
    
    Name string
    HealthPoint int 
    MagicPoint int 
}
tank := new (Player)
tank.Name="Canon"
tank.HealthPoint = 300

经过new实例化的结构体实例在成员赋值上与基本实例化的写法一致。

取结构体的地址实例化

在Go语言中,对结构体进行"&"取地址操作时,视为对该类型进行依次new的实例化操作。

ins := &T{
    
    }
  • T表示结构体类型
  • ins为结构体的实例,类型为*T,是指针类型
type Command struct {
    
     //定义 Command 结构体,表示命令行指令
    Name string
    var *int  //命令绑定的变量,使用整型指针绑定一个指针,指令的值可以与绑定的值随时保持同步。
    Comment string
}
var version int = 1 //命令绑定的目标整型变量:版本号。
cmd := &Command{
    
    } //对结构体取地址实例化。
cmd.Name= "version"
cmd.Var = &version
cmd.Comment = "show version"

取地址实例化是最广泛的一种结构体实例化方式。可以使用函数封装上面的初始化过程。

func newCommand(name string, varref *int, comment string) *Command {
    
    
    return &Command{
    
    
        Name:    name,
        Var:     varref,
        Comment: comment,
    }
}
cmd = newCommand(
    "version",
    &version,
    "show version",
)

初始化结构体的成员变量

结构体在实例化时可以直接对成员变量进行初始化。初始化有两种形式:字段"键值对"形式及多个值的列表形式。

键值对形式的初始化适合选择性填充字段较多的结构体;

多个值的列表形式适合填充字段较少的结构体。

使用"键值对"初始化结构体

结构体可以使用“键值对”(Key value pair)初始化字段,每个“键”(Key)对应结构体中的一个字段,键的“值”(Value)对应字段需要初始化的值。

键值对的填充是可选的,不需要初始化的字段可以不填入初始化列表中。

结构体实例化后字段的默认值是字段类型的默认值,例如 ,数值为 0、字符串为 “”(空字符串)、布尔为 false、指针为 nil 等。

键值对初始化结构体的书写格式
ins := 结构体类型名 {
    
    
    字段1 :字段1的值,
    字段2 :字段2的值,
    ...
}

键值之间以":“分隔;键值对之间以”,"分隔

type People struct {
    
     //定义 People 结构体。
    name string 
    child *People  //结构体的结构体指针字段,类型是 *People。
}
relation := &People {
    
     //relation 由 People 类型取地址后,形成类型为 *People 的实例。
    name : "爷爷",
    child : &People {
    
     //child 在初始化时,需要 *People 类型的值,使用取地址初始化一个 People。
        name : "爸爸",
        child : &People {
    
     
            name : "我"
        }
    }
}

使用多个值的列表初始化结构体

Go语言可以在"键值对"初始化的基础上忽略"键"。也就是说,可以使用多个值的列表初始化结构体的字段。

多个值列表初始化结构体的书写格式

多个值使用逗号分隔初始化结构体。

ins := 结构体类型名 {
    
    
    字段1的值,
    字段2的值,
    ...
}
  • 必须初始化结构体的所有字段
  • 每一个初始值的填充顺序必须与字段在结构体中的声明顺序一致
  • 键值对与值列表的初始化形式不能混用
type Address struct {
    
    
    Province    string
    City        string
    ZipCode     int
    PhoneNumber string
}
addr := Address{
    
    
    "四川",
    "成都",
    610000,
    "0",
}
fmt.Println(addr)
//输出:
{
    
    四川 成都 610000 0}

初始化匿名结构体

匿名结构体没有类型名称,无须通过type关键字定义就可以直接使用。

  1. 匿名结构体定义格式和初始化写法

匿名结构体的初始化写法由结构体定义和键值对初始化两部分组成,结构体定义时没有结构体类型名,只有字段和类型定义,键值对初始化部分由可选的多个键值对组成,如下格式所示:

ins := struct {
    
    
    // 匿名结构体字段定义
    字段1 字段类型1
    字段2 字段类型2}{
    
    
    // 字段值初始化
    初始化字段1: 字段1的值,
    初始化字段2: 字段2的值,}

键值对初始化部分时可选的,不初始化成员时,匿名结构体的格式变为:

ins := struct {
    
    
    字段1 字段类型1
    字段2 字段类型2
    ...
}{
    
    }
package main

import "fmt"

func printMsgType(msg *struct {
    
    
	id   int
	data string
}) {
    
    
	fmt.Printf("%T\n", msg)
}
func main() {
    
    
	msg := &struct {
    
    
		id   int
		data string
	}{
    
    
		1024,
		"hello",
	}
	printMsgType(msg)
}
//*struct { id int; data string }

匿名结构体的类型名是结构体包含字段成员的详细描述。匿名结构体在使用时需要重新定义,造成大量重复的代码,因此开发时较少使用。

构造函数

结构体和类型的一系列初始化操作的函数封装

Go语言的类型或结构体没有构造函数的功能。结构体的初始化过程可以使用函数封装实现。

模拟构造函数重载

type Cat struct {
    
    
   Color string
   Name string
}
func NewCatByName(name string) *Cat {
    
    
   return &Cat {
    
    
      Name:name,
   }
}
func NewCatByColor(color string) *Cat  {
    
    
   return &Cat {
    
    
      Color:color,
   }
}

在这个例子中,颜色和名字两个属性的类型都是字符串。由于Go语言中没有函数重载,为了避免函数名字冲突,使用NewCatByName()和NewCatByColor()两个不同的函数名表示不同的Cat的构造过程。

模拟父级构造调用

黑猫是一种猫,猫是黑猫的一种泛称,同时描述这两种概念时,就是派生,黑猫派生自猫的种类,使用结构体描述猫和黑猫的关系时,将猫(Cat)的结构体嵌入到黑猫(BlackCat)中,表示黑猫拥有猫的特性,然后再使用两个不同的构造函数分别构造出黑猫和猫两个结构体实例,参考下面的代码:

type Cat struct {
    
    
    Color string
    Name string 
}
type BlackCat struct {
    
    
    Cat 
}
//构造基类
func NewCat(name string) *Cat {
    
    
    return &Cat {
    
    
        Name : name,
    }
}
//构造子类
func NewBlackCat(color string) *BlackCat {
    
    
    cat := &BlackCat{
    
    }
    cat.Color = color
    return cat 
}

Go语言中没有提供构造函数相关的特殊机制,用户根据自己的需求,将参数使用函数传递到结构体构造参数中即可完成构造函数的任务。

方法

Go语言中的方法(Method)是一种作用于特定类型变量的函数。这种特定类型变量叫做接收器(Receiver)。

如果将特定类型理解为结构体或“类”时,接收器的概念就类似于其他语言中的this或者self。

在Go语言中,接收器的类型可以时任何类型,不仅仅是结构体,任何类型都可以拥有方法。

Go语言的结构体方法

type Bag struct {
    
    
    items []int
}
func (b *Bag) Insert(itemid int) {
    
    
    b.items = append(b.items,itemid)
}
func main() {
    
    
    b :=new(Bag)
    b.Insert(1001)
}

Insert(itemid int) 的写法与函数一致。(b*Bag)表示接收器,即Insert作用的对象实例。

每个方法只能有一个接收器

在Insert()转换为方法后,我们就可以愉快地像其他语言一样,用面向对象的方法来调用b的Insert

接收器

方法作用的目标

func (接收器变量 接收器类型) 方法名(参数列表) (返回参数) {
    
    
    函数体
}
  • 接收器变量:接收器中的参数变量名在命名时,官方建议使用接收器类型名的第一个小写字母,而不是self、this之类的命名。例如,Socket类型的接收器变量应该命名为s,Connector类型的接收器变量应该命名为c等。
  • 接收器类型:接收器类型和参数类似,可以是指针类型和非指针类型
  • 方法名、参数列表、返回参数:格式与函数定义一致。

接收器根据接收器的类型可以分为指针接收器、非指针接收器。两种接收器在使用时会产生不同的效果。根据效果的不同,两种接收器会被用于不同性能和功能要求的代码中。

指针类型的接收器

指针类型的接收器由一个结构体的指针组成,更接近于面向对象中的this或者self。

由于指针的特性,调用方法时,修改接收器指针的任意成员变量,在方法结束后,修改都是有效的。

package main

import "fmt"

type Property struct {
    
    
	value int
}

func (p *Property) SetValue(v int) {
    
    
	p.value = v
}
func (p *Property) Value() int {
    
    
	return p.value
}
func main() {
    
    
	p := new(Property)
	p.SetValue(100)
	fmt.Println(p.value)
}

非指针类型的接收器

当方法作用于非指针接收器时,Go语言会在代码运行时将接收器的值复制一份。在非指针接收器的方法中可以获取接收器的成员值,但修改后无效。

点(Point)使用结构体描述时,为点添加A得到Add()方法,这个方法不能修改Point的成员X、Y变量,而是在计算后返回新的Point对象。Point属于小内存对象,在函数返回值的复制过程中可以极大地提高代码运行效率。

package main

import "fmt"

//定义点的结构
type Point struct {
    
    
	X int
	Y int
}

//非指针接收器的加方法
func (p Point) Add(other Point) Point {
    
    
	//成员值于参数相加后返回新的结构
	return Point{
    
    p.X + other.Y, p.Y + other.Y}
}
func main() {
    
    
	p1 := Point{
    
    1, 1}
	p2 := Point{
    
    2, 2}
	//与另外一个点相加
	result := p1.Add(p2)
	//输出结果
	fmt.Println(result)
}
//代码输出:
{
    
    3 3}

指针和非指针接收器的使用

在计算机中,小对象由于值复制时的速度较快,所以适合使用非指针接收器。大对象因为复制性能较低,适合使用指针接收器,在接收器和参数间传递时不进行复制,只是传递指针。

为类型添加方法

Go语言可以对任何类型添加方法。给一种类型添加方法就像给结构体添加方法一样,因为结构体也是一种类型。

1、为基本类型添加方法

package main

import "fmt"

// 将int定义为MyInt类型
type MyInt int

// 为MyInt添加IsZero()方法
func (m MyInt) IsZero() bool {
    
    
	return m == 0
}

// 为MyInt添加IsZero()方法
func (m MyInt) Add(other int) int {
    
    
	return other + int(m)
}
func main() {
    
    
	var b MyInt
	fmt.Println(b.IsZero())
	b = 1
	fmt.Println(b.Add(2))
}
//代码输出:
true 3

事件系统

事件系统基本原理

事件系统可以将事件派发者与事件处理者解耦。

一个事件系统拥有如下特性:

能够实现事件的一方,可以根据事件ID或名字注册对应的事件。

事件发起者,会根据注册信息通知这些注册者。

一个事件可以有多个实现方响应。

事件注册

事件系统需要为外部提供一个注册入口。这个注册入口传入注册的事件名称和对应事件名称的响应函数,事件注册的过程就是将事件名称和响应函数关联并保存起来。

//实例化一个通过字符串映射函数切片的map  这个map通过事件名(string)关联回调列表([]func(interface{})),
//同一个事件名称可能存在多个事件回调,因此使用回调列表保存。回调的函数声明为func(interface{})
var eventByName = make(map[string][]func(interface{
    
    }))

//注册事件,提供事件名和回调函数 提供给外部的通过事件名注册响应函数的入口
func RegisterEvent(name string,callback func(interface{
    
    }))  {
    
    
	//通过名字查找事件列表
	list :=eventByName[name]
	//在列表切片中添加函数
	list = append(list,callback)
	//保存修改的事件列表切片
	eventByName[name] = list
}

拥有事件名和事件回调函数列表的关联关系后,就需要开始准备事件调用的入口了。

事件调用

事件调用和注册方是事件处理中完全不同的两个角色。事件调用方是事发现场,负责将事件和事件发生的参数通过事件系统派发出去,而不关心事件到底由谁处理;事件注册方通过系统注册应该响应哪些事件及如何使用回调函数处理这些事件。

//调用事件  调用 事件的入口,提供事件名称name和参数param。事件的参数表示描述事件的具体的细节,例如门打开的事件触发时,参数可以传入谁进来了
func CallEvent(name string,param interface{
    
    }){
    
    
    //通过名字找到事件列表 
    list :=eventByName[name]
    //遍历这个事件的所有回调
    for  _,callback := range list {
    
    
        //传入 参数调用回调 将每个函数回调传入事件参数并调用,就会触发事件实现方的逻辑处理
        callback(param)
    }
}

使用事件系统
package main

import "fmt"

//声明角色的结构体
type Actor struct {
    
    
   
}
//为角色添加一个事件处理函数
func (a *Actor)OnEvent(param interface{
    
    })  {
    
    
   fmt.Println("actor event:",param)
}
//全局事件
func GlobalEvent(param interface{
    
    })  {
    
    
   fmt.Println("global event:",param)
}
func main() {
    
    
   //实例化一个角色
   a := new(Actor)
   //注册名为OnSkill的回调
   RegisterEvent("OnSkill",a.OnEvent)
   //再次在OnSkill上注册全局事件
   RegisterEvent("OnSkill",GlobalEvent)
   //调用事件,所有注册的同名函数都会被调用
   CallEvent("OnSkill",100)
}

一般来说,事件系统不保证同一个事件实现方多个函数列表中的调用顺序,事件系统认为所有实现函数都是平等的。也就是说,无论例子中的a.OnEvent先注册,还是GlobalEvent()函数先注册,最终谁先被调用,都是无所谓的,开发者不应该关注和要求保证调用的顺序。

一个完善的事件系统还会提供移除单个和所有事件的方法。

类型内嵌和结构体内嵌

结构体允许其成员字段在声明时没有字段名而只有类型,这种形式的字段你被称为类型内嵌或匿名字段

类型内嵌写法:

type Data struct {
    
    
    int 
    float32
    bool
}
ins := &Data {
    
    
    int : 10,
    float32:3.14,
    bool:true,
}

类型内嵌其实仍然拥有自己的字段名,只是字段名就是其类型本身而已,结构体要求字段名称必须唯一,因此一个结构体中同种类型的匿名字段只能有一个。

结构体实例化后,如果匿名的字段类型为结构体,那么可以直接访问匿名结构体里的所有成员,这种方式被称为结构体内嵌。

package main

import "fmt"

//基础颜色
type BasicColor struct {
    
    
   R, G, B float32
}
//完整颜色定义
type Color struct {
    
    
   Basic BasicColor
   Alpha float32
}

func main() {
    
    
   var c Color

   //设置基本颜色变量
   c.Basic.R = 1
   c.Basic.G = 1
   c.Basic.B = 1
   //设置透明度
   c.Alpha = 1
   //显示整个结构体内容
   fmt.Printf("%+v", c)

}

使用Go语言的结构体内嵌写法重新调整代码如下:

package main

import "fmt"

// 基础颜色
type BasicColor struct {
    
    
	R, G, B float32
}

// 完整颜色定义
type Color struct {
    
    
	BasicColor
	Alpha float32
}

func main() {
    
    
	var c Color

	//设置基本颜色变量
	c.R = 1
	c.G = 1
	c.B = 1
	//设置透明度
	c.Alpha = 1
	//显示整个结构体内容
	fmt.Printf("%+v", c)

}

上面代码,将BasicColor结构体嵌入到Color结构体中,BasicColor没有字段名而只有类型,这种写法就叫结构体内嵌。

编译器通过Color的定义知道R、G、B成员来自BasicColor内嵌的结构体

结构内嵌特性

  1. 内嵌的结构体可以直接访问其成员变量

嵌入结构体的成员,可以通过外部结构体的实例直接访问。如果结构体有多层嵌入结构体,结构体实例访问任意一级的嵌入结构体成员时都只用给出字段名,而无须像传统结构体字段一样,通过一层层的结构体字段访问到最终的字段。例如,ins.a.b.c的访问可以简化为ins.c。

  1. 内嵌结构体的字段名是它的类型名

内嵌结构体字段仍然可以使用详细的字段进行一层层访问,内嵌结构体的字段名就是它的类型名,代码如下:

var c Color
c.BasicColor.R = 1
c.BasicColor.G = 1
c.BasicColor.B = 0

一个结构体只能嵌入一个同类型的成员,无须担心结构体重名和错误赋值的情况,编译器在发现可能的赋值歧义时会报错。

使用组合思想描述对象特性

Go语言的结构体内嵌特性就是一种组合特性,使用组合特性可以快速构建对象的不同特性。

package main

import "fmt"

type Flying struct{
    
    }

func (f *Flying) Fly() {
    
    
   fmt.Println("can fly")
}

type Walkable struct{
    
    }

func (f *Walkable) Walk() {
    
    
   fmt.Println("can calk")
}

// 人类
type Human struct {
    
    
   Walkable
}

// 鸟类
type Bird struct {
    
    
   Walkable
   Flying
}

func main() {
    
    
   //实例化鸟类
   b := new(Bird)
   fmt.Println("Bird: ")
   b.Fly()
   b.Walk()
   //实例化人类
   h := new(Human)
   fmt.Println("Human: ")
   h.Walk()
}
Bird:
can fly 
can calk
Human:  
can calk

使用Go语言的内嵌结构体实现对象特性,可以自由地在对象中增、删、改各种特性。

Go语言会在编译时检查能否使用这些特性。

初始化结构体内嵌

结构体内嵌初始化时,将结构体内嵌的类型作为字段名像普通结构体一样进行初始化。

package main

import "fmt"

// 车轮
type Wheel struct {
    
    
	Size int
}

// 引擎
type Engine struct {
    
    
	Power int
	Type  string
}

// 车
type Car struct {
    
    
	Wheel
	Engine
}

func main() {
    
    
	c := Car{
    
    
		//初始化轮子
		Wheel: Wheel{
    
    
			Size: 18,
		},
		//初始化引擎
		Engine: Engine{
    
    
			Type:  "1.4T",
			Power: 143,
		},
	}
	fmt.Printf("%+v\n", c)
}

初始化内嵌匿名结构体

在前面描述车辆和引擎的例子中,有时考虑编写代码的便利性,会将结构体直接定义在嵌入的的结构体中,就是说,结构体的定义不会被外部引用到。在初始化这个被嵌入的结构体时,就需要再次声明结构才能赋予数据

package main

import (
	"fmt"
)

// 车轮
type Wheel struct {
    
    
	Size int
}

// 车
type Car struct {
    
    
	Wheel
	//引擎
	Engine struct {
    
    
		Power int
		Type  string
	}
}

func main() {
    
    
	c := Car{
    
    
		//初始化轮子
		Wheel: Wheel{
    
    
			Size: 18,
		},
		//初始化引擎
		Engine: struct {
    
    
			Power int
			Type  string
		}{
    
    
			Type:  "1.4T",
			Power: 143,
		},
	}
	fmt.Printf("%+v\n", c)
}

成员名字冲突

嵌入结构体内部可能拥有相同的成员名。

package main

import "fmt"

type A struct {
    
    
	a int
}
type B struct {
    
    
	a int
}
type C struct {
    
    
	A
	B
}

func main() {
    
    
	c := &C{
    
    }
	c.A.a = 1
	fmt.Println(c)
}
//输出结果
&{
    
    {
    
    1} {
    
    0}}

修改main方法

func main() {
    
    
	c := &C{
    
    }
	c.a = 1
	fmt.Println(c)
}

此时编译报错:.\HelloWorld.go:18:4: ambiguous selector c.a

编译器告知C的选择器a引起歧义,也就是说,编译器无法决定将1赋给C中的A还是B里的字段a。

在使用内嵌结构体时,Go语言的编译器会非常只能拿提醒我们可能发生的歧义和错误。

使用匿名结构体分离JSON数据

手机拥有屏幕、电池、指纹识别等信息,将这些信息填充为JSON格式的数据。如果需要选择性地分离JSON中的数据则较为麻烦。Go语言中的匿名结构体可以方便地完成这个操作。

  1. 定义数据结构

首先,定义手机的各种数据结构体,如屏幕和电池

// 定义手机屏幕
type Screen struct {
    
    
	Size       float32
	ResX, ResY int
}

// 定义电池
type Battery struct {
    
    
	Capacity int
}
  1. 准备JSON数据

准备手机数据结构,填充数据,将数据序列化为JSON格式的字节数组

func genJsonData() []byte {
    
    
	//完整数据结构
	raw := &struct {
    
    
		Screen
		Battery
		HasTouchID bool
	}{
    
    
		//屏幕参数
		Screen: Screen{
    
    
			Size: 5.5,
			ResX: 1920,
			ResY: 1080,
		},
		//电池参数
		Battery: Battery{
    
    
			2910,
		},
		//是否有指纹识别
		HasTouchID: true,
	}
	//将数据序列化为JSON
	jsonData, _ := json.Marshal(raw)
	return jsonData
}
  1. 分离JSON数据

调用genJsonData获得JSON数据,将需要的字段填充到匿名结构体中,通过json.Unmarshal反序列化JSON数据达成分离JSON数据效果。

func main() {
    
    
	//生成一段JSON数据
	jsonData := genJsonData()
	fmt.Println(string(jsonData))
	//只需要屏幕和指纹识别信息的结构和实例
	screenAndTouch := struct {
    
    
		Screen
		HasTouchID bool
	}{
    
    }
	//反序列到screenAndTouch中
	json.Unmarshal(jsonData, &screenAndTouch)
	//输出screenAndTouch的详细结构
	json.Unmarshal(jsonData, &screenAndTouch)
	//只需要电池和指纹识别信息的结构和实例
	batteryAndTouch := struct {
    
    
		Battery
		HasTouchID bool
	}{
    
    }
	//反序列化到batteryAndTouch
	json.Unmarshal(jsonData, &batteryAndTouch)
	//输出screenAndTouch的详细结构
	fmt.Printf("%+v\n", batteryAndTouch)
}

输出结果:

{
    
    "Size":5.5,"ResX":1920,"ResY":1080,"Capacity":2910,"HasTouchID":true}
{
    
    Screen:{
    
    Size:5.5 ResX:1920 ResY:1080} HasTouchID:true}
{
    
    Battery:{
    
    Capacity:2910} HasTouchID:true}

七、接口(interface)

接口本身是调用方和实现方均需要遵守的一种协议,大家按照统一的方法命名参数类型和数量来协调逻辑处理的过程。

Go语言使用组合实现对象特性的描述。对象的内部使用结构体内嵌组合对象应该具有的特性,对外通过接口暴露能使用的特性。

Go语言的接口设计是非侵入式的,接口编写者无须知道接口被哪些类型实现。而接口实现者只需要知道实现的是什么样的接口,但无须指明实现哪一个接口。编译器知道最终编译时使用哪个类型实现哪个接口,或者接口应该由谁来实现。

非侵入式设计师Go语言设计师经过多年的大项目经验总结出来的设计之道。只有让接口和实现者真正解耦,编译速度才能真正提高,项目之间的耦合度也会降低不少。

声明接口

接口是双方约定的一种合作协议。接口实现者不需要关心接口会被怎样调用,调用者也不需要关心接口的实现细节。接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。

接口声明的格式

每个接口类型都是由数个方法组成。

type 接口类型名 interface {
    
    
    方法名1(参数列表1) 返回值列表1
    方法名2(参数列表2) 返回值列表2
    ...
}
  • 接口类型名:使用type将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加 er,如有写操作的接口叫Writer,有字符串功能的接口叫Stringer,有关闭功能的接口叫Closer等。
  • 方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
  • 参数列表、返回值列表:参数列表和返回值列表中的参数变量可以被忽略,例如
type writer interface {
    
    
    Write([]byte) error
}

开发中常见的接口及写法

Go语言提供的很多包中都有接口,例如io包中提供的Writer接口:

type Writer interface {
    
    
    Write(p []byte) (n int,err error)
}

这个接口可以调用Write() 方法写入一个字节数组([]byte),返回值告知写入字节数(n int)和可能发生的错误(err error)。

类似的,还有将一个对象以字符串形式展现的接口,只要实现了这个接口的类型,在调用String()方法时,都可以获得对象对应的字符串。在fmt包中定义如下:

type Stringer interface {
    
    
    String() string
}

Stringer接口在Go语言中的使用斌率非常高,功能类似于Java或者C#语言里的ToString的操作。

Go语言的每个接口中的方法数量不会很多。Go语言希望通过一个接口精准描述它自己的功能,而通过多个接口的嵌入和组合的方式将简单的接口扩展为复杂的接口。

实现接口的条件

接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用。

接口被实现的条件一:接口的方法与实现接口的类型方法格式一致

在类型中添加与接口签名一致的方法就可以实现该方法。签名包括方法中的名称、参数列表、返回参数列表。也就是说,只要实现接口类型中的方法的名称、参数列表、返回参数列表中的任意一项与接口要实现的方法不一致,那么接口的这个方法就不会被实现。

package main

import "fmt"

//定义一个数据写入器
type DataWriter interface {
    
    
	WriteData(data interface{
    
    }) error
}
//定义文件结构,用于实现DataWriter
type file struct {
    
    
}
//实现DataWriter接口的WriteData()方法
func (d *file) WriteData(data interface{
    
    }) error {
    
    
	//模拟写入数据
	fmt.Println("WriteData", data)
	return nil
}
func main() {
    
    
	//实例化file
	f := new(file)
	//声明一个DataWriter的接口
	var writer DataWriter
	//将接口赋值f,也就是*file类型
	writer = f
	//使用DataWriter接口进行数据写入
	writer.WriteData("data")
}

条件二:接口中所有方法均被实现

当一个接口中有多个方法时,只有这些方法都被实现了,接口才能被正确编译并使用。

//定义一个数据写入器
type DataWriter interface {
    
    
    WriteData(data ioterface{
    
    }) error
    //能否写入
    CanWrite() bool
}

新增CanWrite()方法,返回bool。此时再次编译代码,报错:

cannot use f (variable of type _file) as DataWriter value in assignment: _file does not implement DataWriter (missing method CanWrite)

Go语言的接口实现是隐式的,无须让实现接口的类型写出实现了哪些接口。这个设计被称为非侵入式设计。

实现者在编写方法时,无法预测未来哪些方法会变成接口。一旦某个接口创建出来,要求旧的代码来实现这个接口时,就需要修改旧的代码的派生部分,这一般会造成雪崩式的重新编译。

理解类型与接口的关系

类型和接口之间有一对多和多对一的关系,下面将列举出这些常见的概念,以方便读者理解接口与类型在复杂环境下的实现关系。

一个类型可以实现多个接口

一个类型可用同时实现多个接口,而接口间彼此独立,不知道对方的实现。

type Socket struct {
    
    
    
}
func (s *Socket) Write(p []byte)(n int,err error) {
    
    
    return 0,nil
}
func (s *Socket) Close() error {
    
    
    
}

Socket结构的Write()方法实现了io.Writer接口:

type Writer interface {
    
    
    Write(p []byte)(n int,err error)
}
type Closer interface {
    
    
    Close() error
}

使用Scoket实现的Writer接口的代码,无须了解Writer接口的实现者是否具备Closer接口的特性。同样,使用Closer接口的代码也并不知道Socket已经实现了Writer接口。

多个类型可以实现相同的接口

一个接口的方法,不一定需要由一个类型完全实现,接口的方法可用通过在类型中嵌入其他类型或者结构体来实现。也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的。

type Service interface {
    
    
    Start()
    Log(string)
}
type Logger struct {
    
    
    
}
func (g *Logger) Log(l string) {
    
    
    
}
type GameService struct {
    
    
    Logger
}
func (g *GameService) Start() {
    
    
    
}

Service接口定义了两个方法:一个是开启服务的方法(Start()),一个是输出日志的方法(Log())。使用GameService结构体来实现Service,GameService自己的结构只能实现Start()方法,而Service接口中的Log()方法已经被一个能输出日志的日志器(Logger)实现了,无须再进行GameService封装,或者重新实现一遍。所以,选择将Logger嵌入到GameService能最大程度地避免代码冗余,简化代码结构。

接口的嵌套组合

将多个接口放在一个接口内。

在Go语言中,不仅结构体与结构体之间可以嵌套,接口与接口间也可以通过嵌套创造出新的接口。

接口与接口嵌套组合而成了新接口,只要接口的所有方法被实现,则这个接口中的所有嵌套 接口的方法均可以被实现。

  1. 系统包中的接口嵌套组合

Go语言的io包中定义了写入器(Writer)、关闭器(Closer)和写入关闭器(WriteCloser)3个接口。

type Writer interface {
    
    
    Write(p []byte) (n int,err error)
}
type Closer interface {
    
    
    Close() error
}
type WriteCloser interface {
    
    
    Writer
    Closer
}
  1. 在代码中使用接口嵌套组合

在代码中使用io.Writer、io.Closer和io.WriteCloser这3个接口时,只需要按照接口实现的规则实现io.Writer接口和io.Closer接口即可。而io.WriteCloser接口在使用时,编译器会根据接口的实现者确认它们是否同时实现了io.Writer和io.Closer接口。

package main

import "io"

//声明一个设备结构
type device struct {
    
    
}

//实现io.Writer 的Write()方法
func (receiver *device) Write(p []byte) (n int, err error) {
    
    
	return 0, nil
}

//实现io.Closer的Close()方法
func (d *device) Close() error {
    
    
	return nil
}
func main() {
    
    
	//声明写入关闭器,并赋予device的实例
	var wc io.WriteCloser = new(device)
	//写入数据
	wc.Write(nil)
	//关闭设备
	wc.Close()
	//声明写入器,并赋予device的新实例
	var writeOnly io.Writer = new(device)
	//写入数据
	writeOnly.Write(nil)
}

在接口和类型间转换

Go语言中使用断言(type assertions)将接口转换成另外一个接口,也可以将 接口转换为另外的类型。接口的转换在开发中非常常见,使用也非常频繁。

类型断言的格式

类型断言的基本格式如下:

t := i.(T)
  • i代表接口变量
  • T代表转换的目标类型
  • t代表转换后的变量

如果i没有完全实现T接口的方法,这个语句将会触发宕机。触发宕机不是很友好,因此上面的语句还有一种写法:

t,ok := i.(T)

这种写法下,如果发生接口未实现时,将会把ok置为false,t置为T类型的0值。正常实现时,ok为true。这里ok可以被认为是:i接口是否实现T类型的结果。

package main

import "fmt"

//定义飞行动物接口
type Flyer interface {
    
    
   Fly()
}
//定义行走动物接口
type Walker interface {
    
    
   Walk()
}
//定义鸟类
type bird struct {
    
    
}
//实现飞行动物接口
func (b *bird) Fly()  {
    
    
   fmt.Println("bird: fly")
}
//实现行走动物接口
func (b *bird ) Walk()  {
    
    
   fmt.Println("bird: walk")
}
//定义猪
type pig struct {
    
    
   
}
//为猪添加Walk()方法,实现行走动物接口
func (p *pig) Walk()  {
    
    
   fmt.Println("pig: walk")
}
func main() {
    
    
   //创建动物的名字到实例的映射
   animals := map[string]interface{
    
    }{
    
    
      "bird":new(bird),
      "pig":new(pig),
   }
   //遍历映射
   for name,obj := range animals {
    
    
      //判断对象是否为飞行动物
      f,isFlyer := obj.(Flyer)
      //判断对象是否为行走动物
      w,isWalker := obj.(Walker)
      
      fmt.Printf("name:%s isFlyer:%v isWalker:%v\n",name,isFlyer,isWalker)
      //如果是飞行动物则调用飞行动物接口
      if isFlyer{
    
    
         f.Fly()
      }
      //如果是行走动物则调用行走动物接口
      if isWalker {
    
    
         w.Walk()
      }
   }
}
name:bird isFlyer:true isWalker:true
bird: fly                           
bird: walk                          
name:pig isFlyer:false isWalker:true
pig: walk

将接口转换为其他类型

可以实现将接口转换为普通的指针类型。例如将Walker接口转换为*pig类型。

package main

import "fmt"

func main() {
    
    
	p1 := new(pig) 
	var a Walker = p1 //由于 pig 实现了 Walker 接口,因此可以被隐式转换为 Walker 接口类型保存于 a 中。
	p2 := a.(*pig) //由于 a 中保存的本来就是 *pig 本体,因此可以转换为 *pig 类型。
	fmt.Printf("p1=%p p2=%p", p1, p2) //对比发现,p1 和 p2 指针是相同的。
}

将Walker类型的a转换为*bird类型,将会发出运行时错误。

func main() {
    
    
	p1 := new(pig)
	var a Walker = p1
	p2 := a.(*bird)
	fmt.Printf("p1=%p p2=%p", p1, p2)
}

运行时报错:

panic: interface conversion: main.Walker is *main.pig, not *main.bird

报错意思是:接口转换时,main.Walker 接口的内部保存的是 _main.pig,而不是 _main.bird。

因此,接口在转换为其他类型时,接口内保存的实例对应的类型指针,必须是要转换的对应的类型指针。

空接口类型(interface{})

能保存所有值的类型

空接口时接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。

将值包保存到空接口

	var any interface{
    
    }
	any = 1
	fmt.Println(any)
	any = "hello"
	fmt.Println(any)
	any = false
	fmt.Println(any)

代码输出如下:

1
hello
false

从空接口获取值

保存到空接口的值,如果直接取出指定类型的值时,会发生编译错误。

	var a int = 1
	var i interface{
    
    } = a
	var b int = i

编译报错:

cannot use i (variable of type interface{
    
    }) as int value in variable declaration: need type assertion

编译器告诉我们,不能将i变量视为int类型赋值给b

在代码第15行中,将a的值赋值给i时,虽然i在赋值完成后的内部值为int,但i还是一个interface{}类型的变量。类似于无论集装箱装的是茶叶还是烟草,集装箱依然是金属做的,不会因为所装物的类型改变而改变

为了让第8行的操作能够完成,编译器提示我们得使用type assertion,意思就是类型断言。

使用类型断言修改第8行代码如下:

var b int = i.(int)

修改后,代码可以编译通过,并且b可以获得i变量保存的a变量的值:1。

空接口的值比较

空接口在保存不同的值后,可以和其他变量值一样使用"=="进行比较操作。空接口的比较有以下几种特性。

  1. 类型不同的空接口间的比较结果不相同

保存有类型不同的值的空接口进行比较时,GO语言会优先比较值的类型,因此类型不同,比较结果也是不相同的。

//a保存数据
var a interface{
    
    } = 100
//b保存字符串
var b interface{
    
    } = "hi"
//两个空接口不相等
fmt.Println(a == b)

代码输出如下:

false
  1. 不能比较空接口中的动态值

当接口中保存有动态类型的值时,运行时将触发错误

//c保存包含10的整型切片
var c interface{
    
    } = []int{
    
    10}
//d保存包含20的整型切片
var d interface{
    
    }=[]int{
    
    20}
//这里会发生崩溃
fmt.Println(c == d)
panic: runtime error: comparing uncomparable type []int

这是一个运行时错误,提示[]int是不可比较的类型。

image-20230414161740245.png

类型分支

批量判断空接口中变量的类型

Go语言的switch不仅可以像其他语言一样实现数值、字符串的判断,还有一种特殊的用途–判断一个接口内保存或实现的类型。

类型断言的书写格式

switch 接口变量.(type){
    
    
    case 类型1//变量是类型1时的处理
    case 类型2//变量是类型2时的处理
    ...
    default:
    //变量不是所有case中列举的类型时的处理
}
  • 接口变量:表示需要判断的接口类型的变量。
  • 类型1、类型2…:表示接口变量可能具有的类型列表,满足时,会指定case对应的分支进行处理。、

使用类型分支判断基本类型

package main

import "fmt"

func printType(v interface{
    
    }) {
    
    
	switch v.(type) {
    
    
	case int:
		fmt.Println(v, "is int")
	case string:
		fmt.Println(v, "is string")
	case bool:
		fmt.Println(v, "is bool")
	}
}
func main() {
    
    
	printType(1024)
	printType("pig")
	printType(true)
}

代码输出如下:

1024 is int
pig is string
true is bool

代码经过switch时,会判断v这个interface{}的具体类型从而进行类型分支跳转、

switch的default也是可以使用的,功能和其他的switch一致。

使用类型分支判断接口类型

多个接口进行类型断言时,可以使用类型分支简化判断过程。

package main

import "fmt"

// 电子支付方式
type Alipay struct {
    
    
}

// 为Alipay添加CanUseFaceID()方法,表示电子支付方式支持刷脸
func (a *Alipay) CanUseFaceID() {
    
    
}

// 现金支付方式
type Cash struct {
    
    
}

// 为Cash添加Stolen()方法,表示现金支付方式会出现偷窃情况
func (a *Cash) Stolen() {
    
    
}

// 具备刷量特性的接口
type CantainCanUseFaceID interface {
    
    
   CanUseFaceID()
}

// 具备被偷特性的接口
type ContainStolen interface {
    
    
   Stolen()
}

func print(payMethod interface{
    
    }) {
    
    
   switch payMethod.(type) {
    
    
   case CantainCanUseFaceID:
      fmt.Printf("%T can use faceid\n", payMethod)
   case ContainStolen:
      fmt.Printf("%T may be stolen\n", payMethod)
   }
}
func main() {
    
    
   //使用电子支付判断
   print(new(Alipay))
   //使用现金判断
   print(new(Cash))
}

输出如下:

*main.Alipay can use faceid
*main.Cash may be stolen

八、包(package)

Go语言的源码复用建立在包(package)基础之上。Go语言的入口main()函数所在的包(package)叫main。main包想要引用别的代码,必须同样以包的方式进行引用。

Go语言的包与文件夹–一 一对应,所有与包相关的操作,必须依赖于工作目录(GOPATH)。

工作目录(GOPATH)

GOPATH是GO语言中使用的一个环境变量,它使用绝对路径提供项目的工作目录。工作目录是一个工程开发的相对参考目录。好比当你要在公司编写一套服务器代码,你的工位所包含的桌面、计算机及椅子就是你都工作区。工作区的概念与工作目录的概念也是类似的。如果不使用工作目录的概念,在多人开发时,每个人有一套自己的工作目录结构,读取配置文件的位置不统一,输出的二进制运行文件也不统一,这样会导致开发的标准不统一,影响开发效率。

GOPATH适合处理大量Go语言源码、多个包组合而成的复杂工程。

使用命令行查看GOPATH信息

PS D:\Code\Back-end\Go\project> go env
set GO111MODULE=on
set GOARCH=amd64     //表示目标处理器架构                                  
set GOBIN=             //表示编译器和链接器的安装位置                               
set GOCACHE=C:\Users\wanglicong\AppData\Local\go-build
set GOENV=C:\Users\wanglicong\AppData\Roaming\go\env  
set GOEXE=.exe                                        
set GOEXPERIMENT=                                     
set GOFLAGS=                                          
set GOHOSTARCH=amd64                                  
set GOHOSTOS=windows                                  
set GOINSECURE=                                       
set GOMODCACHE=D:\Code\Back-end\Go\go\pkg\mod         
set GONOPROXY=                                        
set GONOSUMDB=                                        
set GOOS=windows   //表示目标操作系统                                  
set GOPATH=D:\Code\Back-end\Go\go   //表示当前工作目录                  
set GOPRIVATE=                                        
set GOPROXY=https://proxy.golang.org,direct           
set GOROOT=D:\develop_tools\Go    //表示Go开发包的安装目录。                    
set GOSUMDB=sum.golang.org
set GOTMPDIR=
set GOTOOLDIR=D:\develop_tools\Go\pkg\tool\windows_amd64
set GOVCS=
set GOVERSION=go1.20.2
set GCCGO=gccgo
set GOAMD64=v1
set AR=ar
set CC=gcc
set CXX=g++
set CGO_ENABLED=0
set GOMOD=D:\Code\Back-end\Go\project\go.mod
set GOWORK=
set CGO_CFLAGS=-O2 -g
set CGO_CPPFLAGS=
set CGO_CXXFLAGS=-O2 -g
set CGO_FFLAGS=-O2 -g
set CGO_LDFLAGS=-O2 -g
set PKG_CONFIG=pkg-config
set GOGCCFLAGS=-m64 -fno-caret-diagnostics -Qunused-arguments -Wl,--no-gc-sectio
ns -fmessage-length=0 -fdebug-prefix-map=C:\Users\WANGLI~1\AppData\Local\Temp\go
-build223939501=/tmp/go-build -gno-record-gcc-switches

使用GOPATH的工程结构

在GOPATH指定的工作目录下,代码总是会保存在 G O P A T H / s r c 目录下。在工程经过 g o b u i l d 、 g o i n s t a l l 或 g o g e t 等指令后,会将产生的二进制可执行文件放在 GOPATH / src目录下。在工程经过go build、go install或go get等指令后,会将产生的二进制可执行文件放在 GOPATH/src目录下。在工程经过gobuildgoinstallgoget等指令后,会将产生的二进制可执行文件放在GOPATH/bin目录下,生成的中间缓存文件会被保存在$GOPATH/pkg下。

如果需要将整个源码添加到版本管理工具(Version Control System,VCS)中时,只需要添加$GOPATH/src目录的源码即可。bin和pkg目录的内容都可以由src目录生成。

设置和使用GOPATH

  1. 设置当前目录为GOPATH

选择一个目录,在目录中的命令行中执行下面的指令:

export GOPATH=`pwd`
  1. 建立GOPATH中的源码目录

使用下面的指令创建GOPATH中的src目录,在src目录下还有一个hello目录,该目录用于保存源码。

mkdir -p src/hello

mkdir指令的-p可以连续创建一个路径。

  1. 添加main.go源码文件

将下面的源码保存为main.go并保存到$GOPATH/src/hello目录下。

package main

import "fmt"
func main() {
    
    
    fmt.Println("hello")
}
  1. 编译源码并运行

此时我们已经设定了GOPATH,因此在GO语言中可以通过GOPATH找到工程的位置。

在命令行中执行如下指令编译源码:、

go install hello

编译完成的可执行文件会保存在$GOPATH/bin目录下。

在bin目录中执行./hello,命令行输出如下:

hello world

在多项目工程中使用GOPATH

在很多与 Go语言相关的书籍、文章中描述的 GOPATH 都是通过修改系统全局的环境变量来实现的。然而,根据笔者多年的 Go语言使用和实践经验及周边朋友、同事的反馈,这种设置全局 GOPATH 的方法可能会导致当前项目错误引用了其他目录的 Go 源码文件从而造成编译输出错误的版本或编译报出一些无法理解的错误提示。

比如说,将某项目代码保存在 /home/davy/projectA 目录下,将该目录设置为 GOPATH。随着开发进行,需要再次获取一份工程项目的源码,此时源码保存在 /home/davy/projectB 目录下,如果此时需要编译 projectB 目录的项目,但开发者忘记设置 GOPATH 而直接使用命令行编译,则当前的 GOPATH 指向的是 /home/davy/projectA 目录,而不是开发者编译时期望的 projectB 目录。编译完成后,开发者就会将错误的工程版本发布到外网。

因此,建议大家无论是使用命令行或者使用集成开发环境编译 Go 源码时,GOPATH 跟随项目设定。在 Jetbrains 公司的 GoLand 集成开发环境(IDE)中的 GOPATH 设置分为全局 GOPATH 和项目 GOPATH,如下图所示。

image-20230417144954435.png

图中的 Global GOPATH 代表全局 GOPATH,一般来源于系统环境变量中的 GOPATH;Project GOPATH 代表项目所使用的 GOPATH,该设置会被保存在工作目录的 .idea 目录下,不会被设置到环境变量的 GOPATH 中,但会在编译时使用到这个目录。建议在开发时只填写项目 GOPATH,每一个项目尽量只设置一个 GOPATH,不使用多个 GOPATH 和全局的 GOPATH。

创建包package

编写自己的代码扩展

包(package)是多个Go源码的集合,是一种高级的代码复用方案,Go语言默认为我们提供了很多包,如fmt、os、io包等,开发者可以根据自己的需要创建自己的包。

包要求在同一个目录下的所有文件的第一行添加如下代码,以标记该文件归属的包:

package 包名

包的特性如下:

  • 一个目录下的同级文件归属一个包
  • 包名可以与其目录不重名
  • 包名为main的包为应用程序的入口包,编译源码没有main包时,将无法编译输出可执行的文件。

导出标识符

让外部访问包的类型和值

在GO语言中,如果想在一个包里引用另外一个包里的标识符(如类型、变量、常量等)时,必须首先将被引用的标识符导出,将要导出的标识符的首字母大写就可以让引用者可以访问这些标识符了。

导出包内标识符

下面代码中包含一系列未导出标识符,它们的首字母都为小写,这些标识符可以在包内自由使用,但是包外无法访问它们,代码如下:

package mypkg
var myVar = 100
const myConst = "hello"
type myStruct struct {
    
    
    
}

将myStruct和myConst首字母大写,导出这些标识符,修改后代码如下:

package mypkg
var myVar = 100
const MyConst = "hello"
type MyStruct struct {
    
    
}

此时,MyConst和MyStruct可以被外部访问,而myVar由于首字母是小写,因此只能在mypkg包内使用,不能被外部包使用。

导出结构体及接口成员

在被导出的结构体或接口中,如果它们的字段或方法首字母是大写,外部可以访问这些字段和方法,代码如下:

type MyStruct struct {
    
    
    //包外可以访问的字段
    ExportedField int
    //仅限包内访问的字段
    privateField int
}
type MyInterface interface {
    
    
    //包内可以访问的方法
    ExportedMethod()
    //仅限包内访问的方法
    privateMethod()
}

在代码中,MyStruct的ExportedFiled和MyInterface的ExportedMethod()可以被包外访问。

导入包(import)

在代码中使用其他的代码

要引用其他包的标识符,可以使用import关键字,导入的包名使用双引号包围,包名是从GOPATH开始计算的路径,使用"/"进行路径分隔。

默认导入的写法

导入有两种基本格式,即单行导入和多行导入,两种导入方法的导入代码效果是一致的。

  1. 单行导入

单行导入格式如下:

import "包1"
import "包2"
  1. 多行导入

当多行导入时,包名在import中的顺序不影响导入效果,格式如下:

import(
	"包1"
    "包2"
    ...
)

导入包后自定义引用的包名

在默认导入包的基础上,在导入包路径前添加标识符即可形成自定义引用包,格式如下:

customName "path/to/package"
  • path/to/package:为要导入的包路径
  • customName:自定义的包名
package main
import(
	renameLib "chapter08/importadd/mylib"
    "fmt"
)
func main(){
    
    
    fmt.Println(renameLib.Add(1,2))
}

匿名导入包

只导入包但不使用包内类型和数值

如果只希望导入包,而不使用任何包内的结构和类型,也不调用包内的任何函数时,可以使用匿名导入包,格式如下:

import (
	_ "path/to/package"
)
  • path/to/package 表示要导入的包名
  • 下画线:表示匿名导入包

匿名导入的包与其他方式导入包一样会让导入包编译到可执行文件中,同时,导入包也会触发init()函数调用。

包在程序启动前的初始化入口:init

在某些需求的设计上需要在程序启动时统一调用程序引用到的所有包的初始化函数,如果需要通过开发者手动调用这些初始化函数,那么这个过程可能会发生错误或者遗漏。我们希望在被引用的包内部,由包的编写者获得代码启动的通知,在程序启动时做一些自己包内代码的初始化工作。

Go 语言为以上问题提供了一个非常方便的特性:init() 函数。

init()函数的特性如下:

  • 每个源码可以使用1个init()函数
  • init()函数会在程序执行前(main() 函数执行前)被自动调用
  • 调用顺序为main()中引用的包,以深度优先顺序初始化。

例如,假设有这样的包引用关系:main-A-B-C,那么这些包的init()函数调用顺序为:

C.init→B.init→A.init→main

说明:

  • 同一个包中的多个init()函数的调用顺序不可预期
  • init()函数包内被其他函数调用

九、并发

下面来介绍几个概念:

进程/线程

进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。

并发/并行

多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。

并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。

协程/线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

线程:一个线程上可以跑多个协程,协程是轻量级的线程。

优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。使用Go语言开发服务器程序时,就需要对它的并发机制有深入的了解。

轻量级线程(goroutine)

根据需要随时创建的"线程"

goroutine 是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。

说到底 goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。

使用 go 关键字就可以创建 goroutine,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在Go语言中则被称为 goroutine。

使用普通函数创建goroutine

Go程序中使用go关键字为一个函数创建一个goroutine。一个函数可以被创建多个goroutine,一个goroutine必定对应一个函数。

  1. 格式

为一个普通函数创建goroutine的写法如下:

go 函数名(参数列表)
  • 函数名:要调用的函数名
  • 参数列表:调用函数需要传入的参数

使用go关键字创建goroutine时,被调用函数的返回值会被忽略。

eg:

使用go关键字,将running()函数并发执行,每隔一秒打印一次计数器,而main的goroutine则等待用户输入,两个行为同时进行。

package main

import (
	"fmt"
	"time"
)

func running() {
    
    
	var times int
	//构建一个无限循环
	for {
    
    
		times++
		fmt.Println("tick", times)
		//延时1秒
		time.Sleep(time.Second)
	}
}
func main() {
    
    
	//并发执行程序
	go running()
	//接收命令行输入,不做任何事情
	var input string
	fmt.Scanln(&input)
}

代码执行后,命令行会不断地输出tick,同时可以使用fmt.Scanln()接受用户输入。两个环节可以同时进行。

使用匿名函数创建goroutine

go关键字也可以为匿名函数或闭包启动goroutine

  1. 使用匿名函数创建goroutine的格式

使用匿名函数或闭包创建goroutine时,除了将函数定义部分写在go的后面之外,还需要加上匿名函数的调用参数,格式如下:

go func(参数列表) {
    
    
    函数体
}(调用参数列表)
  • 参数列表:函数体内的参数变量列表。
  • 函数体:匿名函数的代码。
  • 调用参数列表:启动goroutine时,需要向匿名函数传递的调用参数。

eg:

func main() {
    
    
	//并发执行程序
	go func() {
    
    
		var times int
		for {
    
    
			times++
			fmt.Println("tick", times)
			//延时1秒
			time.Sleep(time.Second)
		}
	}()
	//接收命令行输入,不做任何事情
	var input string
	fmt.Scanln(&input)
}

所有 goroutine 在 main() 函数结束时会一同结束。

goroutine 虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的 goroutine 调度器的实现和运行环境。

终止 goroutine 的最好方法就是自然返回 goroutine 对应的函数。虽然可以用 golang.org/x/net/context 包进行 goroutine 生命期深度控制,但这种方法仍然处于内部试验阶段,并不是官方推荐的特性。

截止 Go 1.9 版本,暂时没有标准接口获取 goroutine 的 ID。

调整并发的运行性能(GOMAXPROCS)

在 Go语言程序运行时(runtime)实现了一个小型的任务调度器。这套调度器的工作原理类似于操作系统调度线程,Go 程序调度器可以高效地将 CPU 资源分配给每一个任务。传统逻辑中,开发者需要维护线程池中线程与 CPU 核心数量的对应关系。同样的,Go 地中也可以通过 runtime.GOMAXPROCS() 函数做到,格式为:

runtime.GOMAXPROCS(逻辑CPU数量)

这里的逻辑CPU数量可以有如下几种数值:

  • <1:不修改任何数值。
  • =1:单核心执行。
  • 1:多核并发执行。

一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置,例如:

runtime.GOMAXPROCS(runtime.NumCPU())

理解并发和并行

在讲解并发概念时,总会涉及另外一个概念并行。

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。

并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。

在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导 Go语言设计的哲学。

Go语言的协作程序(goroutine)和普通的协作程序(coroutine)

C#、Lua、Python 语言都支持 coroutine 特性。coroutine 与 goroutine 在名字上类似,都可以将函数或者语句在独立的环境中运行,但是它们之间有两点不同:

  • goroutine 可能发生并行执行;
  • 但 coroutine 始终顺序执行。

goroutines 意味着并行(或者可以以并行的方式部署),coroutines 一般来说不是这样的,goroutines 通过通道来通信;coroutines 通过让出和恢复操作来通信,goroutines 比 coroutines 更强大,也很容易从 coroutines 的逻辑复用到 goroutines。

狭义地说,goroutine 可能发生在多线程环境下,goroutine 无法控制自己获取高优先度支持;coroutine 始终发生在单线程,coroutine 程序需要主动交出控制权,宿主才能获得控制权并将控制权交给其他 coroutine。

goroutine 间使用 channel 通信,coroutine 使用 yield 和 resume 操作。

goroutine 和 coroutine 的概念和运行机制都是脱胎于早期的操作系统。

coroutine 的运行机制属于协作式任务处理,早期的操作系统要求每一个应用必须遵守操作系统的任务处理规则,应用程序在不需要使用 CPU 时,会主动交出 CPU 使用权。如果开发者无意间或者故意让应用程序长时间占用 CPU,操作系统也无能为力,表现出来的效果就是计算机很容易失去响应或者死机。

goroutine 属于抢占式任务处理,已经和现有的多线程和多进程任务处理非常类似。应用程序对 CPU 的控制最终还需要由操作系统来管理,操作系统如果发现一个应用程序长时间大量地占用 CPU,那么用户有权终止这个任务。

通道(channel)

在多个goroutine间通信的管道

通道的特性

Go语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个goroutine访问通道进行发送和获取数据。goroutine间通过通信就可以通信。

通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

声明通道类型

通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明如下:

var 通道变量 chan 通道类型
  • 通道类型:通道内的数据类型
  • 通道变量:保存通道的变量。

chan类型的空值是nil,声明后需要配合make后才能使用。

创建通道

通道是引用类型,需要使用make进行创建。

通道实例 := make(chan 数据类型)
  • 数据类型,通道内传输的元素类型
  • 通道实例,通过make创建的通道句柄
ch1 := make(chan int) //创建一个整型类型的通道
ch2 := make(chan interface{
    
    }) //创建一个空接口类型的通道,可以存放任意格式

type Equip struct {
    
    ...} 
ch2 := make(chan *Equip) //创建Equip指针类型的通道,可以存放*Equip

使用通道发送数据

通道创建后,就可以使用通道进行发送和接收数据

  1. 通道发送数据的格式

通道的发送使用特殊的操作符"<-",将数据通过通道发送的格式为:

通道变量<-
  • 通道变量:通过make创建好的通道实例
  • 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致
  1. 通过通道发送数据的例子

使用make创建一个通道后,就可以使用"<-"向通道发送数据,代码如下:

//创建一个空接口通道
ch  := make(chan interface{
    
    })
//将0放入通道中
ch <- 0
//将hello字符串放入通道中
ch <-"hello"
  1. 发送将持续阻塞直到数据被接收

把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续堵塞。

Go程序运行时能智能地发现一些永远无法发送成功的语句并做出指示:

package main
func main () {
    
    
    //创建一个整型通道
    ch := make(chan int)
    //尝试将0通过通道发送
    ch <- 0
}

报错:

fatal error: all goroutines are asleep - deadlock!

报错的意思为:运行时发现所有的goroutine(包括main)都处于等待goroutine。也就是说所有goroutine中的channel并没有形成发送和接收对应的代码。

使用通道接收数据

通道接收同样使用"<-"操作符,通道接收有如下特型:

① 通道的收发操作在不同的两个 goroutine 间进行。

由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。

② 接收将持续阻塞直到发送方发送数据。

如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。

③ 每次接收一个元素。
通道一次只能接收一个数据元素。

通道接收一共有以下4种写法:

  1. 阻塞接收数据

阻塞模式接收数据时,将接收变量作为"<-"操作符的左值,格式如下:

data := <- ch

执行该语句时将会阻塞,直到接收到数据并赋值给data变量。

  1. 非阻塞接收数据

使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:

data,ok := <-ch
  • data:表示接收到的数据。未接收到数据时,data为通道类型的零值。
  • ok:表示是否接收到数据。

非阻塞的通道接收方法可能造成高的CPU占用,因此使用非常少。如果需要实现接收超时检测,可以配合select和计时器channel进行。

  1. 接收任意数据,忽略接收的数据

阻塞接收数据后,忽略从通道返回的数据,格式如下:

<- ch

执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在goroutine阻塞收发实现并发同步。

package main

import (
	"fmt"
)

func main() {
    
    
	ch := make(chan int)
	//开启一个并发匿名函数
	go func() {
    
    
		fmt.Println("start goroutine")
		//通过通道通知main的goroutine
		ch <- 0
		fmt.Println("exit goroutine")
	}()
	fmt.Println("wait goroutine")
	//等待匿名goroutine
	<-ch
	fmt.Println("all done")
}

输出代码:

wait goroutine
start goroutine
exit goroutine 
all done
  1. 循环接收

通道的数据接收可以借用for range语句进行多个元素的接收操作,格式如下:

for data := range ch {
    
    
    
}

通道ch是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。

package main

import (
	"fmt"
	"time"
)

func main() {
    
    
	//构建一个通道
	ch := make(chan int)
	//开启一个并发匿名函数
	go func() {
    
    
		//从3循环到0
		for i := 3; i >= 0; i-- {
    
    
			//发送3到0之间的数值
			ch <- i
			//每次发送完时等待
			time.Sleep(time.Second)
		}
	}()
	//遍历接收通信数据
	for data := range ch {
    
    
		//打印通道数据
		fmt.Println(data)
		//当遇到数据0时,退出接收循环
		if data == 0 {
    
    
			break
		}
	}
}

输出:

3
2
1
0

单向通道

通道中的单行道

Go的通道可以在声明时约束其操作方向,如只发送或是只接收。这种被约束方向的通道被称做单向通道。

  1. 单向通道的声明格式

只能发送的通道类型为chan<-,只能接收的通道类型为<-chan,格式如下:

var 通道实例 chan<- 元素类型
var 通道实例 <-chan 元素类型
  • 元素类型:通道包含的元素类型
  • 通道实例:声明的通道变量

eg:

ch := make(chan int)
//声明一个只能发送的通道类型,并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能接收的通道类型,并赋值为ch
var chRecvOnly <-chan int = ch

上面的例子中,chSendOnly只能发送数据,如果尝试接收数据,将会出现如下报错:

invalid operation:<-chSendOnly (receive from send-only type chan<-int)

当然,使用make创建通道时,也可以创建一个只发送或只读取的通道:

var chReadOnly<-chan int = cn
<-chReadOnly

上面代码编译正常,运行也是正确的。但是,一个不能填充数据(发送)只能读取的通道是毫无意义的。

带缓冲的通道

Go语言中有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。

无缓冲通道保证收发过程同步。无缓冲收发过程类似于快递员给你电话让你下楼取快递,整个递交快递的过程是同步发生的,你和快递员不见不散。但这样做快递员就必须等待所有人下楼完成操作后才能完成所有投递工作。如果快递员将快递放入快递柜中,并通知用户来取,快递员和用户就成了异步收发过程,效率可以有明显的提升。带缓冲的通道就是这样的一个“快递柜”。

  1. 创建带缓冲通道

如何创建带缓冲的通道呢?

通道实例 :=make(chan 通道类型,缓冲大小)
  • 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型。
  • 缓冲大小:决定通道最多可以保存的元素数量
  • 通道实例:被创建出的通道实例。
package main

import (
	"fmt"
)

func main() {
    
    
	//创建一个3个元素缓冲大小的整型通道
	ch := make(chan int, 3)
	//查看当前通道的大小
	fmt.Println(len(ch))
	//发送3个整型元素到通道
	ch <- 1
	ch <- 2
	ch <- 3
	//查看当前通道的大小
	fmt.Println(len(ch))
}
  1. 阻塞条件

带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作是长度永远为0的带缓冲通道。因此根据这个特性,带缓冲通道在下面列举的情况下依然会发生阻塞:

(1)带缓冲通道被填满时,尝试再次发送数据时发生阻塞。

(2)带缓冲通道为空时,尝试接收数据时发生阻塞。

为什么Go语言对通道要限制长度而不提供无限长度的通道?

我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当

提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因

此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处

理数据。

通道的多路复用

同时处理接收和发送多个通道的数据

多路复用是通信和网络中的一个专业术语。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。

报话机同一时刻只能有一边进行收或者发的单边通信,报话机需要遵守的通信流程如下:
说话方在完成时需要补上一句“完毕”,随后放开通话按钮,从发送切换到接收状态,收听对方说话。
收听方在听到对方说“完毕”时,按下通话按钮,从接收切换到发送状态,开始说话。

电话可以在说话的同时听到对方说话,所以电话是一种多路复用的设备,一条通信线路上可以同时接收或者发送数据。同样的,网线、光纤也都是基于多路复用模式来设计的,网线、光纤不仅可支持同时收发数据,还支持多个人同时收发数据。

Go语言中提供了select关键字,可以同时响应多个通信的操作。select的每个case都会对应一个通道的收发过程。当收发完成时,就会触发case中响应的语句。多个操作在每次select中挑选一个进行响应。

selectcase 操作1:
	   响应操作1
	case 操作2:
	   响应操作2
	...
    default:
	没有操作情况
}
  • 操作1、操作2:包含通信收发语句。
    | 操 作 | 语句示例 |
    | — | — |
    | 接收任意数据 | case <- ch; |
    | 接收变量 | case d := <- ch; |
    | 发送数据 | case ch <- 100; |

  • 响应操作1、响应操作2:当操作发生时,会执行对应case的响应操作。

  • default:当没有任何操作时,默认执行default中的语句。

关闭通道后继续使用通道

通道是一个引用对象,和map类似。map在没有任何外部引用时,Go程序在运行时(runtime)会自动对内存进行垃圾回收(Garbage Collection,GC)。类似的,通道也可以被垃圾回收,但是通道也可以被主动关闭。

  1. 格式

使用close()来关闭一个通道

close(ch)

关闭的通道依然可以被访问,访问被关闭的通道会发生一些问题。

  1. 给被关闭通道发送数据将会触发panic

被关闭的通道不会被置为nil。如果尝试对已经关闭的通道进行发送,将会触发宕机。

package main

import "fmt"

func main() {
    
    
	//创建一个整型的通道
	ch := make(chan int)
	//关闭通道
	close(ch)
	//打印通道的指针,容量和长度
	fmt.Printf("ptr:%p cap:%d len:%d\n", ch, cap(ch), len(ch))
	//给关闭的通道发送数据
	ch <- 1
}

代码运行后触发宕机:

ptr:0xc00001a180 cap:0 len:0
panic: send on closed channel

提示触发宕机的原因是给一个已经关闭的通道发送数据。

  1. 从已关闭的通道接收数据时将不会发生阻塞

从已经关闭的通道接收数据或者正在接收数据时,将会接收到通道类型的零值,然后停止阻塞并返回。

package main

import (
	"fmt"
)

func main() {
    
    
	//创建一个整型带两个缓冲的通道
	ch := make(chan int, 2)
	//给通道放入两个数据
	ch <- 0
	ch <- 1
	//关闭缓冲
	close(ch)
	//遍历缓冲所有数据,且多遍历1个
	for i := 0; i < cap(ch)+1; i++ {
    
    
		//从通道中取出数据
		v, ok := <-ch
		//打印取出数据的状态
		fmt.Println(v, ok)
	}
}
0 true
1 true 
0 false

运行结果前两行正确输出带缓冲通道的数据,表明缓冲通道在关闭以后依然可以访问内部的数据。

运行结果第三行的"0 false"表示通信在关闭状态下取出的值。0表示这个通道的默认值,false表示没有获取成功,因为此时通信已经空了。我们发现,在通道关闭后,即便通道没有数据,在获取时也不会发生阻塞,但此时取出数据会失败。

同步

保证并发环境下数据访问的正确性

Go程序可以使用通道进行多个goroutine间的数据交换,但这仅仅是数据同步中的一种方法。通道内部的实现依然使用了各种锁,因此优雅代码的代价是性能。在某些轻量级的场合,原子访问(atomic包)、互斥锁(sync.Mutex)以及等待组(sync.WaitGroup)能最大程度满足需求。

竟态检测

检测代码在并发环境下可能出现的问题

当多线程并发运行的程序竞争访问和修改同一块资源时,会发生竟态问题。

package main

import (
	"fmt"
	"sync/atomic"
)

// 序列号
var (
	seq int64
)

// 序列号生成器
func GenID() int64 {
    
    
	// 尝试原子的增加序列号
	atomic.AddInt64(&seq, 1)
	return seq
}
func main() {
    
    
	//生成10个并发序列号
	for i := 0; i < 10; i++ {
    
    
		go GenID()
	}
	fmt.Println(GenID())
}

在运行程序时,为运行参数加入-race参数,开启运行时(runtime)对竞态问题的分析,命令如下:

% go run -race  channel11.go
WARNING: DATA RACE
Write at 0x0000011f38a8 by goroutine 8:
  sync/atomic.AddInt64()
      /usr/local/go/src/runtime/race_amd64.s:287 +0xb
  sync/atomic.AddInt64()
      <autogenerated>:1 +0x1b
  main.main.func1()
      /Users/xxxxxx/work/go-practice/channel11.go:22 +0x2b

Previous read at 0x0000011f38a8 by goroutine 7:
  main.GenID()
      /Users/xxxxxx/work/go-practice/channel11.go:17 +0x3a
  main.main.func1()
      /Users/xxxxxx/work/go-practice/channel11.go:22 +0x2b

Goroutine 8 (running) created at:
  main.main()
      /Users/xxxxxx/work/go-practice/channel11.go:22 +0x39

Goroutine 7 (finished) created at:
  main.main()
      /Users/xxxxxx/work/go-practice/channel11.go:22 +0x39
==================
10
Found 1 data race(s)
exit status 66

根据报错信息,第 16 行有竞态问题,根据 atomic.AddInt64() 的参数声明,这个函数会将修改后的值以返回值方式传出。下面代码对加粗部分进行了修改:

// 序列号生成器
func GenID() int64 {
    
    
	// 尝试原子的增加序列号
	return atomic.AddInt64(&seq, 1)
}

再次运行:

go run -race channel11.go

代码输出如下:
10

没有发生竞态问题,程序运行正常。

本例中只是对变量进行增减操作,虽然可以使用互斥锁(sync.Mutex)解决竞态问题,但是对性能消耗较大。在这种情况下,推荐使用原子操作(atomic)进行变量操作。

互斥锁(sync.Mutex)

保证同时只有一个goroutine可以访问共享资源

互斥锁是一种常用的控制共享资源访问的方法。在Go程序中的使用非常简单

package main

import (
	"fmt"
	"sync"
)
var (
	//逻辑中使用的某个变量
	count int
	//与变量对应的使用互斥锁
	countGuard sync.Mutex
)
func GetCount() int {
    
    
	//锁定
	countGuard.Lock()
	//在函数退出时解除锁定
	defer countGuard.Unlock()
	return count
}
func SetCount(c int) {
    
    
	countGuard.Lock()
	count = c
	countGuard.Unlock()
}
func main() {
    
    
	//可以进行并发安全的设置
	SetCount(1)
	//可以进行并发安全的索取
	fmt.Println(GetCount())
}

读写互斥锁(sync.RWMutex)

在读写多的环境下比互斥锁更高效

在读多写少的环境光中,可以优先使用读写互斥锁,sync包中的RWMutex提供了读写互斥锁的封装


var (
	//逻辑中使用的某个变量
	count int
	//与变量对应的使用互斥锁
	countGuard sync.RWMutex
)

func GetCount() int {
    
    
	//锁定
	countGuard.RLock()
	//在函数退出时解除锁定
	defer countGuard.RUnlock()
	return count
}

等待组(sync.WaitGroup)

保证在并发环境中完成指定数量的任务

Go语言中除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务

在 sync.WaitGroup(等待组)类型中,每个 sync.WaitGroup 值在内部维护着一个计数,此计数的初始默认值为零。

等待组的方法

方法名 功能
(wg * WaitGroup) Add(delta int) 等待组的计数器 +1
(wg * WaitGroup) Done() 等待组的计数器 -1
(wg * WaitGroup) Wait() 当等待组计数器不等于 0 时阻塞直到变 0。

等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。

package main

import (
	"fmt"
	"net/http"
	"sync"
)

func main() {
    
    
	//声明一个等待组
	var wg sync.WaitGroup
	//准备一系列的网站地址
	var urls = []string{
    
    
		"http://www.github.com/",
		"https://www.qiniu.com/",
		"https://www.golangtc.com/",
	}
	//遍历这些地址
	for _, url := range urls {
    
    
		//每一个任务开始时,将等待组增加1
		wg.Add(1)
		//开启一个并发
		go func(url string) {
    
    
			//使用defer,表示函数完成时将等待组值减1
			defer wg.Done()
			//使用http访问提供的地址
			_, err := http.Get(url)
			//访问完成后,打印和可能发生的错误
			fmt.Println(url, err)
			//通过参数传递url地址
		}(url)
		wg.Wait()
		fmt.Println("over")
	}
}

猜你喜欢

转载自blog.csdn.net/m0_53328239/article/details/131493346
今日推荐