Go光速入门
Go语言基本语法与使用
一、变量
变量声明
Go中的变量声明和Java中有所不同,主要有一下几种格式
- 标准格式
1 | var 变量名 变量类型 |
以关键字var开头,最后不用加分号
- 批量声明
如果我们要声明多个变量,一个一个的声明会比较繁琐,这时就有了批量声明
1 | var( |
变量初始化
Go语言在声明变量时,自动对变量对应的内存区域进行初始化工作。也就是说,如果我们声明了变量而没有赋值,那么**编译器**会给变量赋各个类型对应的零值。和Java不同,Java只会对成员变量和常量赋默认零值,对于局部变量则会报错
- 标准格式
1 | var 变量名 变量类型 = 表达式 |
- 类型推导
由于Go是强类型语言,其实我们可以通过给变量赋的值来推导出变量的类型,这就是所谓的类型推导,于是就有了我们下面的这种写法:
1 | var 变量名 = 表达式 |
- 短变量声明并初始化(只能声明局部变量)
其实上面的方法还不够简单,这是因为每次我们都要写var,那么我们能不能省略这个关键字呢?当然是可以的,于是就有了下面这种更加简单的写法:
1 | 变量名 := 表达式 |
要注意的是,我们使用这种写法时,必须保证右边的变量至少有一个没有被定义过,否则会出错
多个变量同时赋值
Go中的变量赋值功能比Java语言的更加强大,它可以允许 = 两边有多个变量同时进行赋值,例如:
1 | var a int = 200 |
通过上述功能,我们很容易就实现了变量的交换。当我们使用多重赋值时,变量的左值和右值按从左到右的顺序赋值
变量交换
说到变量交换,就不得不说两个很巧妙的不使用额外空间的算法
- 使用加减运算
1 | a = a + b |
- 使用异或运算
该算法利用了两个相同的数异或为0实现的变量交换
1 | a = a ^ b |
匿名变量
Go语言的函数可以有多个返回值,但有时候我们只需要其中的一些返回值,而Go语言要求局部变量定义了就必须被使用,否则会报错,那我们怎么处理这些我们不使用的变量呢?答案就是匿名变量。匿名变量的表现是一个 ““ 下划线,只需要将我们不使用的变量用 ““替换即可。
匿名变量不占用**命名空间**,不会分配内存,匿名变量与匿名变量之间也不会因为多次声明而无法使用
二、 数据类型
Go语言有各种各样的数据类型,除了基本的整型、浮点型、布尔型、字符串之外,还有切片、结构体、函数、map、channel等。
整型
Go中的整型也分为两大类
按长度分: int8 int16 int32 int64
对应的无符号:uint8 uint16 uint32 uint64
如果要和Java中的数据类型做一个对比,如下表所示
| Go | Java |
| int8 | byte |
| int16 | short |
| int32 | int |
| int64 | long |
- Go中的int和uint长度并不是固定的,而是随着平台的差异而有所不同,在64位机器上int = int64;在32位机器上int = int32
浮点型
Go中支持两种浮点型,分别是float32和float64。类比Java中的float和double,两者异曲同工
布尔型
Go中的布尔型和Java的也很相似,只有true和false两个值,并且不允许和其他任何类型相互转换
字符串
和Java不同,Go语言的字符串是原生类型,和其他诸如int,float类型一样。Go语言里字符串的内部实现使用UTF-8编码,通过rune类型(后面会说到,rune类型实际上就是int32)可以方便的表示UTF-8字符,Go同时也支持访问传统的ASCII字符,string本质上是一个字节切片
定义多行字符串
我们一般使用双引号来表示字符串,这种双引号的字面量是不能跨行的,如果我们要在代码中定义一个多行字符串,就要使用 `` (反引号)来表示
1 | var str=` |
在这种方式下,反引号间的换行将被作为字符串中的换行,但是所有的转义字符均无效,文本将会原样输出,如果之间有代码,那么之间的代码均不会被编译器识别,而是作为字符串的一部分
字符
字符串中的每一个元素叫做字符,在遍历或者单个获取字符串元素时可以获得字符,Go中的字符有一下两种
- byte 类型:该类型实际上是uint8类型,byte只是一个类型别名(后面会介绍),代表了ACSCII码表的一个字符
1 | b := byte(10) |
- rune 类型:该类型实际上是int32类型,同样的rune只是一个类型别名,当需要处理UTF-8字符时需要用到rune类型
1 | r := rune(134) |
Unicode和UTF-8的区别
前面多次提到了这两个概念,那么他们之间有什么区别和联系呢?
Unicode是字符集而UTF-8是一种编解码规则。字符集为每一个字符分配一个独一无二的ID,而编码规则就是将字符集转换为字符。
三、 类型转换
Go中的类型转换比较像Java中的构造方法,所以我第一次接触的时候还挺不习惯,下面是Go中类型转换的一般格式
1 | T (表达式) |
类型转换需要注意的是:当大范围数转换为小范围数字或者是浮点数转换为整型时,会发生截断现象
浮点数转整型时会将小数点部分直接去掉(而不是四舍五入),只保留整数部分
当int16转换为int8时,会将高8位去掉,只保留低8位,其余转换以此类推
四、 指针
Go中的指针和C、C++不同,要简单很多,这主要是因为Go中不支持令人头疼的指针运算,Go中的指针仅支持两种运算,下面简单介绍一下
取地址操作(&)
格式如下
1 | pointer := &value |
取值操作(*)
格式如下
1 | data := *pointer |
指针的本质
对于有一些语言基础的人来说指针其实很好理解,指针本质上也是一个普通的变量,和其他的变量唯一的区别在于,指针中存放的是其他变量的内存地址,通过这个内存地址我们能够找到其他变量。如图所示

为什么需要指针
就我目前的理解来看,Go语言采用的是值传递的方式。也就是说,我们进行函数传参数时,传递进去的是原值的拷贝,我们在函数里面的任何修改都是给予拷贝的修改,都不会影响原值。如果我们使用指针参数,能够保证内外修改的是同一个对象,保证了内外一致性。
new()函数
除了使用&操作符来获取指针之外,Go语言还提供了另外一种方法来创建指针变量:使用new()函数。
1 | *ptr : =new (T) |
五、 字符串应用
计算字符串长度
Go语言中内置函数len(),可以用来获取切片、数组、字符串、通道等长度。但要注意的是,len()函数返回的是字节长度,返回int类型的值。
当我们用len()函数来获取string的长度时,如果string中含有utf-8字符(如中文),那么一个utf-8的字符长度为3,因为它占3-4个字节。
1 | str1 := "fighting" |
遍历字符串
我们遍历字符串有两种方法
使用for循环
格式如下
1 | for i:=0; i<len(str); i++{ |
这种方式只有在字符串中全部是ASCII码字符时才能使用,因为该方法是按照字节遍历的,对于utf-8字符,会乱码
使用for-range循环
当字符串中含有中文等字符时,我们要使用for-range循环,格式如下
1 | for index, value := range str{ |
该循环有两个返回值,index是当前字符的字节下标,value是当前字符的值,类型为rune。
修改字符串
Go中的字符串是无法修改的,我们只能通过重新构造一个字符串来实现
1 | 1. 将字符串转换成字节数组(使用类型转换) |
连接字符串
在Go中,最直接的连接字符串的方法就是使用 “ + “ 完成,但是Go还提供了类似于Java的StringBulder来实现字符串的连接
1 | str1 := "你好" |
扩展:Base64编码
Base64编码是常见的对8bit字节码的编码方式,电子邮件使用的就是这种编码方式,看了一篇文章觉得写得不错,可以参考一下:https://blog.csdn.net/wo541075754/article/details/81734770
扩展:Unicode和UTF-8编码
前面对于Unicode和UTF-8的介绍比较简单,这里贴一片知乎问答,写的挺好:
https://www.zhihu.com/question/23374078
对于字符串的补充
前面说了,字符串本质上是一个只读的字节切片,那么我们就以一个汉字 “知” 为例,介绍一下字符串是如何存储该汉字的。
1 | "知" Unicode编码:U+77e5 |
- 前面说过,字符串本质上是一个字节切片,那么我们打印它的每一个字节
1 | str := "知" |
可以看到,string存储的是汉字的UTF-8编码
- 我们在使用for-range来遍历string并打印
1 | str := "知" |
六、常量
常量对于我们来说肯定不陌生,常量就是恒定不变的量。Go中常量的定义也非常简单
1 | const pi = 3.14 |
只是将var关键字改成了const。
常量是在**编译**期确定的,因此可以用于数组声明
1 | const size=10 |
iota简介
Go中常量也可以批量声明
1 | const( |
批量声明的常量,第一个必须赋值,后面的可以不赋值,没有赋值的常量和它上以行的常量值相同
在批量声明时,我们可以用到iota这种常量计数器,iota只能用于常量的批量声明中,并且仅在当前批量声明中有效,从0开始计数,每多一行常量,iota++
1 |
|
Go中的容器
和Java一样,Go也提供了一些容器供开发者直接使用,这里主要介绍数组、切片、map三种容器的使用
一、 数组
Go中的数组是一段固定大小的连续空间 。在Go语言中,数组从声明时就确定,使用时可以修改数组成员的值但是数组的大小不可改变
数组声明
Go中数组声明格式如下
1 | var 数组名 [数组大小] T |
- 数组声明后可以直接使用,其中元素均为对应类型的零值
数组初始化
数组可以在声明时进行初始化,如下所示
1 | var array = [3]int{1, 2, 3} |
也可以使用推导的方式
1 | var array = [...]int{1, 2, 3} |
数组重要补充
- Go语言中,数组是一个值类型,这是和其他语言不同的地方,这会导致什么后果呢?我们看看下面一段代码
1 | arr := [...]int{1, 2, 3} |
当我们进行数组赋值或者数组传参时,实际上都会拷贝一份新的数组。我们在拷贝数组上做任何改变都不会影响到原数组
- 数组的类型也包括长度,通俗地说:两个int数组的长度不同,那么它们不是同一个类型
1 | var arr1 [2]int |
可以发现,数组的类型包括了数组的长度,这也是Go和其他语言不同的地方
遍历数组
数组的遍历很简单,直接使用for-range或者简单for循环即可
1 | arr := [...]int{1, 2, 3, 4, 5, 6} |
二、 切片
Go语言切片的内部结构包含地址、大小和容量,是建立在数组类型之上的抽象,提供了更加强大的功能。底层是一个数组,切片是对数组连续片段的一个引用,所以切片是引用类型。
从数组或切片生成新的切片
切片指向一块连续的内存区域,可以是数组,也可以是切片本身。从连续内存生成切片的操作格式:
1 | slices[start : end] |
start:生成切片的第一个元素
end:生成切片的最后一个元素的后一个位置。
即这种方式得到切片是含头不含尾的。例如:
1 | var a = [3]int{1, 2, 3} |
除此之外,还有几种特殊情况
1 | //缺省起始位置,表明从a的开头往后切到结束位置 |
声明切片
切片的声明和数组非常类似,但是它们是完全不同的,Go语言中的数组是基本类型,声明后就会开辟一块内存空间并初始化为零值,是可以直接使用的。而切片是一个引用类型,声明后并没有开辟内存空间,此时是nil(Java中的null),必须初始化之后才能使用。因此一定要分清楚切片和数组的区别。切片声明格式如下:
1 | var name []T |
切片的声明看起来和数组比较相似,但是声明切片时[ ]里不需要写任何东西,而数组必须要指明数组的大小,或者能够推导出数组的大小。这其实是很好理解的,因为前面说过,数组的类型是包括它的长度的,如果不指明数组长度,那么就无法确定数组的类型。
1 | //切片声明 |
要注意的是,切片只能和nil比较来判断切片是否为nil,切片之间不能互相比较,否则会报错。
使用make()函数
除了前面说到的通过数组或者切片获得切片以外,我们还可以通过内建函数make()来直接获取切片,格式如下:
1 | make([]T, len, cap) T |
[ ]T:要创建的切片类型
len:要创建的切片长度
cap:要创建的切片容量
我们使用make()函数生成的切片一定发生了内存分配操作。
使用append()函数
Go语言内建的append()方法可以为切片动态的添加元素。每个切片都会指向一块内存空间,这片空间能够容纳一定数量的元素(cap),当空间不够时,切片就会进行扩容。
1 | //声明一个切片 |
从上述输出,我们可以发现
切片是2倍速扩容的,但其实扩容的规律远不止于此,具体参考https://www.liwenzhou.com/posts/Go/06_slice/,上面详细介绍了int切片的扩容规律
当切片发生扩容后,切片的内存地址会发生变化,说明扩容的本质就是:开辟一块新的连续空间,将当前内存的元素全部拷贝过去。
使用copy()函数text
我们使用Go语言的内建函数copy()时,可以进行切片的复制,使用格式如下
1 | copy(des []T,src []T) int |
我们在使用copy函数的时候要注意:两个切片的类型必须一致,否则会出现错误。当des切片的长度(len)小于src的长度(len)时(是长度而不是容量),只会拷贝des能接收的最大数量的元素
1 | //切片拷贝 |
可以看到,虽然des切片的容量(cap)能够容纳5个元素,但是它的长度(len)为3,因此实际上只拷贝了3个元素
删除切片元素
Go语言并没有提供删除切片中元素的方法,要删除元素,我们需要使用切片本身的特性
1 | //切片删除 |
三、 map
说到map容器,学过Java的肯定会第一时间想到HashMap,Go也提供了map容器,使用散列表实现。和切片一样,map也是一个引用类型,声明之后必须初始化分配内存才能使用。
声明及初始化
map的声明很简单,格式如下
1 | var name map[KeyType]ValueType |
这样声明后的map仅仅是一个指针,为nil,并没有分配内存,因此不能直接使用,要先对map进行初始化操作,初始化操作有两
- 使用make()函数初始化
- 格式如下
1 | var m1 map[int]string |
- 声明时初始化
1 | m1:=map[int]string{ |
遍历map
同样的,我们可以使用for-range循环来遍历map,格式如下
1 | //遍历map |
delete()函数
我们可以使用Go的内建函数delete()来删除map中的键值对,delete格式如下
1 | delete(map,key) |
delete()函数只能删除指定的一个键值对,如果我们要清空map中的元素,唯一的方法就是重新make一个新的map
扩展
上面介绍的map在并发环境下是不安全的,如果要想保证并发下的安全,我们要使用sync.Map,这个map等到介绍完并发之后再进行介绍
四、 列表(list)
列表是一种非连续的容器,由多个节点组成,Go中的列表是通过双向链表实现的
列表初始化
list的初始化有两种方法
- 使用New()方法
1 | 变量名 := list.New() |
- 通过声明初始化list
1 | var 变量名 list.List |
通过出售哈我们发现,列表和切片与map有一点不同:他没有规定列表中存放元素的类型,也就是说列表对于具体元素的类型没有限制,列表元素可以是任意类型。
插入元素
列表提供的插入方法如下
| 方法 | 功能 |
| func (l List) PushFront(v interface{}) Element | 向列表头部添加元素,返回添加节点的指针 |
| func (l List) PushBack(v interface{}) Element | 向列表尾部添加元素,返回添加节点的指针 |
| func (l List) InsertAfter(v interface{}, mark Element) *Element | 在给定节点之后插入元素,返回插入节点的指针 |
| func (l List) InsertBefore(v interface{}, mark Element) *Element | 在给定节点之前插入元素,返回插入节点的指针 |
| func (l List) PushBackList(other List) | 将other列表添加到当前列表尾部 |
| func (l List) PushFrontList(other List) | 将other列表添加到当前列表头部 |
删除元素
通过前面添加元素的方法我们发现,这些添加方法大多都会返回插入节点的指针,从列表中删除节点时,需要用到该节点
1 | func (l *List) Remove(e *Element) interface{} |
上面就是删除方法,传递一个节点的指针,返回删除的节点值
遍历列表
当我们要遍历列表时,就不能使用for-range循环了,我们要先使用Front()方法拿到列表的头节点,然后向后遍历
1 | for ele := l.Front(); ele != nil; ele=ele.Next(){ |
函数(function)
Go语言支持普通函数、匿名函数和闭包,使得函数使用起来更加方便,Go语言的函数有三个特点
函数本身可以作为值进行传递
支持匿名函数和闭包
函数可以满足接口
一、 声明函数
Go语言函数的声明和Java大不相同,我们来看一下格式
1 | func 函数名(参数列表) (返回参数列表){ |
函数名:由字母、数字和下划线组成,其中第一个字母不能为数字
下面就是常见的函数声明的方法
1 | func fun1(x int, y int, z string) { |
后面会说到,在有返回值函数中,带返回参数和不带返回参数的区别
二、 调用函数
函数调用比较简单,和Java不同的是,Go的函数可以有多个返回值,格式如下:
1 | 返回值变量列表 = 函数名(传入参数) |
值传递
Go中没有引用传递,全部都是值传递,当参数为指针时同样是如此。
当参数为值类型时,函数内部得到的是值的拷贝,函数内部的任何修改不会影响原来的变量
当参数为指针类型时,函数内部得到的是指针的拷贝,我们虽然可以通过指针修改变量的值,但是当我们修改指针时,不会影响到函数外部
三、 函数进阶
函数类型
在Go语言中,我们可以使用type来定义一个函数类型,具体格式如下
1 | type calculation func(int int) int |
我们定义了一个calculation类型,它是一个函数类型,这种类型的函数接受两个int参数并返回一个int值。也就说:所有满足这个条件的函数都是calculation类型函数
1 | func add(x, y int) int { |
上面两个函数都可以看作是calculation类型
1 | var c calculation |
函数变量
我们可以定义一个函数类型变量并为它赋值
1 | func main() { |
函数参数和返回值
函数还可以作为参数和函数的返回值,就像其他普通类型一样,我们看下面这个示例
- 函数作为参数
1 | func add(x, y int) int { |
- 函数作为返回值
1 | //将函数作为返回值 |
四、 匿名函数和闭包
匿名函数
Go语言还有匿名函数,顾名思义,匿名函数就是没有名字的函数,因此匿名函数不能像普通函数一样被调用,他需要保存到变量中或者立刻执行
1 | func main() { |
闭包
闭包听起来很难懂,但其实很简单,闭包就是一个函数和与其相关的引用环境组合而成的实体。简单来说:闭包=函数+引用环境
1 | func adder() func(int) int { |
变量f是一个函数并且它引用了其外部作用域中的x变量,此时f就是一个闭包。 在f的生命周期内,变量x也一直有效
1 | func adder2(x int) func(int) int { |
这里函数f引用的是外部函数的参数,此时f也构成了一个闭包
1 | func calc(base int) (func(int) int, func(int) int) { |
这里的f1和f2都引用了外部函数的base变量,所以f1和f2都是闭包,并且在本示例中,f1和f2由同一个函数得到,因此它们引用的是同一个base,其中任何一个改变了base,另一个也会受到影响
五、 延迟执行—defer
Go语言中的defer语句会将其后面跟随的语句进行延迟处理,在函数即将返回时,将所有defer语句按照定义的顺序逆序执行(栈),也就是说,最先被定义的defer语句最后执行
1 | func main() { |
在使用时我们主要用defer语句在函数退出时来释放资源,如关闭文件、释放链接等
关于defer,这篇文章讲的还可以:https://www.liwenzhou.com/posts/Go/09_function/
1 | func f1() int { |
还有下面的示例
1 | func calc(index string, a, b int) int { |
从上面的示例我们得到结论:defer注册要延迟执行的函数时该函数所有的参数都需要确定其值
六、 错误处理
Go语言中没有类似Java一样的异常处理机制,它的错误处理思想包含以下特点
一个可能造成错误的函数,需要返回一个错误接口(error),如果调用成功,错误接口将返回nil,否则返回错误
在函数调用后需要检查错误,如果发生错误,可以尽心错误处理
错误接口的定义格式
error是Go语言声明的一个接口类型,源码如下
1 | type error interface { |
也即是说,只要符合Error() string格式的方法,都能实现这个错误接口
自定义错误
返回错误之前,需要定义会产生哪些可能的错误,Go语言使用errors包进行错误的定义,我们看下errors包的源码
1 | package errors |
可以看到,errors包的源码十分简单,定义了一个结构体errorString,该结构体用于保存发生的错误,并且实现了Error() string方法,因此该结构体实现了error接口。
New方法用于传递我们定义的错误并发挥一个errorString类型,也就是error接口类型
- 简单的自定义错误
对于简单的自定义错误,我们只需要使用errors.New方法将我们需要定义的错误传递进行即可
1 | func main() { |
- 功能更多的自定义错误
有时候,上面那种简单的自定义错误无法满足我们的要求,这时我们就要定义复杂一点的错误,我们只需要模仿上面errors包来做就行了
1 | //定义一个结构体,保存error信息 |
panic/recover
Go语言中目前是没有异常机制,但是使用panic/recover模式来处理错误。 panic可以在任何地方引发,但recover只有在defer调用的函数中有效。 首先来看一个例子:
1 | func funcA() { |
可以看到,在B中触发panic后,程序执行到这里就会终止,后面的C函数无法继续执行,如果我们想继续执行后面的函数,可以在B中使用recover恢复机制
1 | func funcA() { |
可以看到,在B中使用recover恢复后,后面的C可以正常执行。其实这很像Java中的try-catch和异常机制。深入了解panic/recover
结构体(struct)
Go语言可以通过用自定义的方式形成新的类型,结构体是类型中带有成员的复合属性。结构体和数组一样,是一个复合类型,而不是引用类型。
定义结构体
结构体的定义比较简单,格式如下
1 | type name struct{ |
有一点需要注意:当字段名首字母小写时,该字段仅在包内可见。字段首字母大写时表示该字段是公共的。
实例化结构体
要注意的是,结构体定义只是一种内存布局的描述,类似于Java中的类,是没有分配内存的。只有当结构体实例化时,才会真正的分配内存。实例化就是根据结构体定义的格式创建出一份与格式一样的内存布局,结构体实例与实例之间是独立的
- 基本实例化方式
1 | var name T |
这种实例化方式得到的结构体中所有字段都是零值,我们还需要给结构体的字段手动赋值
- 创建指针类型结构体
我们还可以使用new()关键字对类型(结构体、整型、浮点型、字符串等)进行实例化,该关键字会返回一个对应类型的指针
1 | name := new(T) |
同样的,这种方式得到的结构体实例各个字段都是零值
- 取地址实例化
在Go语言中,对结构体进行 & 取地址操作时,视为对该类型进行一次new的实例化操作
1 | name := &T{ |
在使用这种方式时,能够同时对结构体的字段进行初始化(也可以选择不初始化),事实上这种方式用的比较频繁,可以封装成构造函数
1 | type myStruct struct { |
如上所示,我们自己写了一个构造函数,能够返回一个指针类型的结构体
初始化结构体
在上一小节,我们创建出来的结构体实例的字段都是零值,是没有初始化的(除了最后一种)。那么下面就来介绍一下如何初始化
- 键值对初始化
1 | //以该结构体为例 |
在这种方式下,我们可以选择性的初始化字段
- 值列表初始化
1 | //以该结构体为例 |
使用这种方式时,我们必须按序的初始化所有字段
匿名结构体
匿名结构体没有名字,无需通过type关键字就可以直接使用
1 | //获取匿名结构体但不初始化 |
在实例化匿名结构体时,无论是否初始化,后面的{ }不能省略
方法
Go语言中的方法是一种作用于特定类型变量的函数,只能由特定类型的变量调用。这种特定类型变量叫做接收器,Go语言中,接收器可以是任何类型,不仅仅是结构体,任何类型都可以拥有方法。定义方法格式如下
1 | func (接收器变量 接收器类型) 方法名 (参数列表) (返回值列表){ |
我们举例说明:
1 | type myStruct struct { |
接收器又分为两种:值类型和指针类型,当我们使用值类型时,调用方法的时候会将接收器复制一份,方法中对接收器的修改无法影响原值,当我们使用指针类型时,调用方法的时候传递进去的是指针的拷贝,因此对接收器的任何修改都是有效的。
自定义类型和类型别名
- 自定义类型
在Go语言中有一些基本的数据类型,如string、整型、浮点型、布尔等数据类型, Go语言中可以使用type关键字来定义自定义类型。
自定义类型是定义了一个全新的类型。我们可以基于内置的基本类型定义,也可以通过struct定义。
1 | //将MyInt定义为int类型 |
可以看到,自定义类型my``Int就是一种新的类型,它具有int的特性。
- 类型别名
类型别名和原类型同一个类型。就像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。类型别名仅在编译期存在,定义格式如下
1 | type TypeAlias = Type |
我们之前见过的rune和byte就是类型别名,他们的定义如下:
1 | //Go中的类型别名 |
我们看下面代码
1 | type myInt = int |
可以看到,myInt本质上还是int类型,只是改变了叫法而已
类型内嵌和结构体内嵌
我们在定义结构体字段时,可以不声明字段名只声明类型,这种形式的字段被称为类型的内嵌或匿名字段
1 | type myStruct struct { |
在这种情况下,字段的名称其实就是字段的类型,并且显而易见的,一个结构体中同一个类型的匿名字段只能有一个,我们考虑下面这种情况
1 | type myInt = int |
我们定义了一个类型别名myInt,但是实际上myInt仍然是int类型,这时候int和myInt在同一个结构体中是可以共存的。前面说过,匿名字段的名字就是类型名,上面这种情况等价于
1 | type myInt = int |
结构体内存布局
Go语言的结构体实例占用一块连续的内存,那么其中的字段是如何排列布局的呢?这篇文章讲的挺好:
扩展
附两个结构体博文:
接口
Go中的接口和Java中的接口在某些方面很像:比如实现了接口方法的类型可以被当作该接口使用。但是又有不同,在Java中,类如果要想和一个接口关联,那么必须使用implements关键字来显式声明,而Go中却完全不需要,只要类型实现了某个接口的方法,那么该类型就可以被当作该接口使用。达到了接口和实现解耦的效果
声明接口
接口的声明也比较简单,同样要使用type关键字,格式如下
1 | type name interface{ |
Go语言接口在命名时,一般会在单词后面添加er。
当方法名首字母是大写并且接口名首字母也是大写时,这个方法是public的
参数列表、返回值列表中参数变量名称可以省略
Go中一个常见的接口,如下
1 | type Stringer interface{ |
该接口在Go语言中的使用频率非常高类似于Java的toString()方法,只要实现了这个接口的类型,在调用String()方法时都可以获得对象对应的字符串
如何实现一个接口?
一个类型要实现一个接口,只有一个条件:该类型实现了接口所有的方法。这和Java中实现接口一样。但是Go中没有类的概念,就更别说抽象类了,因此如果类型没有实现接口的所有方法,那么这个类型就没有实现该接口,就不能被当作该接口使用。
类型和接口的关系
当类型实现了接口后,类型可以被当作接口使用(在Java中叫做向上造型,父类引用指向子类对象),这里和Java也比较相似,当类型被当作接口使用时,接口没有的而类型有的方法就不能使用,如下所示:
1 | //接口中又一个方法 |
一个类型可以实现多个接口,一个接口可以被多个类型实现,一个接口同样可以嵌套多个接口,类型可以转换为任意它所实现的接口。这和Java中类和接口的规则十分相似。实际上学到这里,我对Go有了一个可能不够成熟的观点:在Go中,结构体+方法 = Java中的类。Go中的接口 = Java中的接口。
空接口
Go语言的空接口和Java中的Object类有异曲同工之妙,那就是:所有的类型都可以被当作空接口使用。空接口在Go中应用的非常广泛,比如我们常使用的fmt包里面的print系列方法,接受的参数就是空接口,这样不管我们输入什么,这些方法都能够接受并打印。
值接受者和指针接受者的区别
前面将方法的时候说过,接收器既可以是值类型,又可以是指针类型,那么当我们使用值类型来实现接口与用指针类型来实现接口又有什么区别呢?我们来举例说明
1 | type People interface { |
类型断言
Go语言使用接口断言将接口转换为另外一个接口,也可以将接口转换为另外一个类型,接口的转换比较常见,基本格式如下
1 | t := i.(T) |
上面的这种格式,如果我们想要转换的类型没有完全实现接口的方法,就会报错使程序终止,因此我们可以使用下面这种更安全的方法
1 | t,ok := i.(T) |
该方式比上面的方式多了一个返回值:bool类型的ok,当ok为true时候表明转换成功,否则转换失败
包(package)
Go语言的源码复用建立在包基础之上,Go语言的入口main()函数所在的包叫main,main包如果要引用别的代码,必须同样以包的方式进行引用。具体请见
并发
关于并发和并行的概念在这里就不赘述,Go语言的并发通过goroutine实现。goroutine类似于线程,属于用户态的线程,我们可以根据需要创建成千上万个goroutine并发工作。goroutine是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成。Go语言还提供channel在多个goroutine间进行通信。goroutine和channel是 Go 语言秉承的 CSP并发模式的重要实现基础。(Communicating Sequential Process)
goroutine
在使用Java进行多线程编程时,我们一般需要自己维护一个线程池,自己包装一个一个的任务并且去调度执行。这在Go中完全不同,只需要使用go关键字定义任务,剩下的全部由Go智能的帮我们分配到cpu上去执行。Go中的goroutine就是这种机制,goroutine的概念类似于线程,但是比线程更轻,它是由Go的运行时(runtime)调度和管理的,Go会智能的将任务分配给每个cpu,这也就是Go天生适合并发的原因。
操作系统的线程一般具有固定大小的栈(一般2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
goroutine的调度管理
下面几篇文章介绍的挺好,贴上来:
goroutine的用法
在go中,要想启动一个goroutine非常简单,只需要使用 go 关键字即可,格式如下:
1 | go funcName(参数列表){ |
我们只需要go + 函数即可开启一个新的goroutine,但是该函数的是不会返回任何结果,即使该函数本身有返回值,也会被丢弃掉。即我们不能通过函数返回值来获取任务执行结果,如下所示:
1 | func add(x, y int) int { |
我们定义了一个add()函数,该函数用于加法计算并返回和,在main goroutine中我们开启了两个线程,分别执行两个add任务。通过代码可以看到,我们无法在main goroutine中通过返回值获取两外两个add任务的执行结果。并且go语言的goroutine还有一个特点:当main goroutine结束时,不管其他goroutine是否执行完毕,都会被终止(这类似于Java中守护线程的概念)。
goroutine间的交互
前面说了,无法通过返回值来获取一个任务的执行结果,那就一定要通过别的方法实现goroutine之间的通信,否则多线程就变得毫无意义。Go提供了两种通信方式
通信共享方式—-channel
内存共享方式—-加锁同步
channel—通信共享
Go语言提倡通过通信共享的方式实现数据交换,如果说goroutine是一个Go程序并发的一个执行体,那么channel就是连接不同执行体的通道,数据能够通过通道在执行体之间传输。channel是一种特殊的类型,类似于一个队列,遵循着FIFO的规则,保证了数据的收发顺序。
channel的声明和创建
channel的声明非常简单,格式如下
1 | var name chan T |
通道和切片一样,是一个引用类型(Go中引用类型和值类型),声明后要使用make函数初始化才能使用:
1 | ch := make(chan int ,cap int) |
channel操作
channel有三个操作,发送和接收都是相对于goroutine的。
- 发送
goroutine将数据放入通道中,格式如下
1 | ch <- data |
- 接收
goroutine从通道中获取数据,格式如下
1 | //阻塞接收 |
- 关闭
1 | close(ch) |
不带缓冲的通道
对于不带缓冲的通道
goroutine从通道接收数据,如果当前通道没有数据,那么该goroutine将会阻塞
goroutine向通道发送数据,如果当前没有goroutine从通道接收数据,那么该goroutine将会阻塞
可以看到,对于无缓冲的通道,goroutine之间的交互是同步的
带缓冲的通道
对于带缓冲的通道
goroutine从通道接收数据,如果缓冲区为空,那么该goroutine将会阻塞
goroutine向通道发送数据,如果当前routine的缓冲区没有满,则会讲数据发送到缓冲区中。否则,该goroutine将会阻塞
通道使用演示
下面演示几种channel的使用
- 使用channel实现Java的 wait/signal
1 | func main() { |
这里我们利用无缓冲通道的阻塞特点,实现了Java中wait/signal机制,但是通道的作用远不止于此
- 使用通道传递数据
1 | func add(start, end int, ch chan int, wg *sync.WaitGroup) { |
由于goroutine是不会返回任务结果的,因此我们可以通过来获取数据
for-range循环获取通道中的数据
上面的案例用到了for-range循环,那么这里就介绍一下for-range循环,一般使用使用for-range循环遍历带缓冲的通道,此时又分为两种情况:
此时通道已关闭
当通道关闭后,无法在向通道中发送数据,否则会panic;但是却可以继续从通道中取数据,知道通道为空,结束for-range循环
1 | func main() { |
可以看到,当通道中没有数据后,for-range自动结束。程序正常退出
- 此时通道未关闭
这时要注意,容易造成死锁
1 | //该示例和上面唯一的不同在于,goruntine推出时没有关闭通道 |
可以看到,上述代码段正确的输出了通道的数据,但是最后出现了panic:出现了死锁,这是什么原因呢?来分析一下
当goroutine1结束后,没有关闭通道,因此main goroutine遍历通道,当通道为空时,此时main goroutine再次从通道获取数据会被阻塞,但是此时goroutine已经结束,并且没有任何其他goroutine会向通道添加数据,此时main goroutine就会永远阻塞发生死锁,Go语言能够智能的检测到这种情况,从而引发panic()并退出。
单向通道
前面介绍的通道都是既能接收数据又能发送数据的,Go语言还提供了单向通道:只能接收的和只能发送的通道。下面就来看一看
- 单向通道的获取
单向通道的获取很形象直观
1 | //发送通道 |
- 单向通道的使用
我们通过下面这个例子来看一下单向通道可以怎么使用
1 | func counter(out chan<- int) { |
双向通道可以被当作任意一种单向通道来使用。
select多路复用
到目前为止,所有的举例都是一个goroutine只接收一个通道的数据,但是有时候,我们需要从多个通道接收数据,这时候可以这样写:
1 | for { |
但是这样写存在一个问题:在通道中没有数据时,获取操作会被阻塞,这样后面有数据的通道也无法被读取,性能比较差。为了解决这种情况,Go语言内置了select关键字,可以同时响应多个通道的操作。select的使用类似于switch-case。格式如下
1 |
|
本来准备自己总结一下select的用法,但是偶然看到一篇文章,讲的很棒,所以就贴了过来(本节标题链接)
Mutex—内存共享
前面花大量篇幅介绍了Go语言的channel,这是因为channel是Go高并发的重要基础结构,下面就来介绍一下Go语言的内存共享机制,也就是锁机制。对于这方面只介绍三个内容,对于实现原理等更深层次如果有兴趣跳转本节标题链接
Mutex
WaitGroup
原子操作
Mutex
Go中的锁分为两种
- 互斥锁
互斥锁可以和Java的锁类比一下,在同一时刻仅允许一个goroutine访问临界区资源,通过这种方式实现了并发安全。下面通过代码演示Go语言的Mutex
1 | var x int64 |
可以看到,Go语言的锁是可以通过sync.Mutex直接获取得到的。
读写互斥锁
上面介绍的锁,无论是读还是写临界资源都会阻塞,在读多写少的场景下,我们可以使用读写互斥锁。读goroutine可以并发执行。当一个goroutine获取到读锁之后,其他获取读锁的线程还可以继续获得锁,获取写锁的线程将会被阻塞。通过代码来演示一下
1 | var ( |
通过上面的代码演示可以发现,在读多写少的场景下,使用读写互斥锁能够大大的提高并发性能。
sync.WaitGroup
Go语言的sync.WaitGroup可以类比于Java中的闭锁(CountDownLatch),有三个方法
Done()
Wait()
Add()
通过Add方法设置一个初始值,当初始值不为0时Wait方法会阻塞。每调用一次Done() 方法时初始值-1,当初始值为0时阻塞在Wait方法上的goroutine会被唤醒。
1 | func main() { |
在使用WaitGroup之前,一定要先初始化值(使用Add方法),否则默认为0值,那么Wait就不会阻塞
原子操作
和Java语言一样,Go还提供了性能更好的原子操作,Go语言的原子操作通过sync/atomic实现。具体参见https://www.liwenzhou.com/posts/Go/14_concurrence/