go语言从零入门学习笔记(持续更新中)
go语言
学习教程:Go语言中文文档,本文基本上是该文档的干货版。
go语言(或 Golang)是Google开发的开源编程语言,在多核并发上拥有原生的设计优势,从底层原生支持并发。Go语言的并发是基于 goroutine 的,goroutine 类似于线程,但并非线程。可以将 goroutine 理解为一种虚拟线程。
安装:All releases - The Go Programming Language
安装后,确保环境变量正确,新建工作区,在vscode中添加go插件。
直接运行示例程序:
1 | go run main.go |
输出.exe可执行文件:
1 | go build main.go |
基础
常用包
1 | import ( |
init函数和main函数
init函数用于包(package)的初始化。
默认入口函数(主函数)是func main(),函数体用{}一对括号包裹。
相同点:
两个函数在定义时不能有任何的参数和返回值,且Go程序自动调用。
不同点:
init可以应用于任意包中,且可以重复定义多个。
main函数只能用于main包中,且只能定义一个。
下划线
在import中:只执行包中的init函数。
1 | import _ "./hello" |
在函数代码中:匿名变量,表示忽略某变量,不分配内存。
1 | f, _ := os.Open("/Users/***/Desktop/text.txt") |
变量和常量
需要用关键字var进行声明:(需要注意变量类型在变量名后面,与c语言不同)
1 | var 变量名 变量类型 |
可以批量声明:
1 | var ( |
声明时会将没有指定内容的值初始化为默认值(0、false、nil(空指针))。可以同时初始化多个变量:
1 | var name, sex = "pprof.cn", 1 |
函数内部可以用“:=”来声明并初始化变量,常量不行。
常量用const声明,不能改变值。同时声明多个常量时,如果省略了值则表示和上面一行的值相同。
iota是常量计数器,const关键词出现时重置为0。
基本类型
1 | bool |
布尔型无法参与数值运算,也无法与其他类型进行转换。
字符有以下两种:
1 | uint8类型,或者叫 byte 型,代表了ASCII码的一个字符。 |
只有强制类型转换,没有隐式类型转换:
1 | T(表达式) |
T表示要转换的类型。
数组Array
初始化:
1 | 局部: |
值拷贝行为会造成性能问题,通常会建议使用 slice,或数组指针。内置函数 len 和 cap 都返回数组长度 (元素数量)。
多维数组遍历:
1 | func main() { |
输出结果:
1 | (0,0)=1 (0,1)=2 (0,2)=3 |
切片Slice
切片是数组的一个引用,因此切片是引用类型。但自身是结构体,值拷贝传递。
切片的长度可以改变,可以用len()求长度,表示可用元素数量,读写操作不能超过该限制。
cap可以求出slice最大扩张容量,不能超出数组限制。0 <= len(slice) <= len(array)。如果 slice == nil,那么 len、cap 结果都等于 0。
定义:var 变量名 []类型,比如 var str []string
创建:
1 | // 3.make() |
可直接创建 slice 对象,自动分配底层数组。使用 make 动态创建slice,避免了数组必须用常量做长度的麻烦。还可用指针直接访问底层数组。
append :向 slice 尾部添加数据,返回新的 slice 对象。
超出原 slice.cap 限制,就会重新分配底层数组。 通常以 2 倍容量重新分配底层数组。
copy :函数 copy 在两个 slice 间复制数据,复制长度以 len 小的为准。
两个 slice 可指向同一底层数组,允许元素区间重叠。
string也可以进行切片操作,但其本身是不可变的。要改变string中字符,需要如下操作:
1 | s := []byte(str) //中文字符需要用[]rune(str) |
a[x:y:z] 切片内容 [x:y] 切片长度: y-x 切片容量:z-x
data[:6:8] 每个数字前都有个冒号, slice内容为data从0到第6位,长度len为6,最大扩充项cap设置为8。
指针
不能进行偏移和运算,是安全指针。指针作为引用类型需要初始化后才会拥有内存空间,才可以给它赋值。
new是一个内置的函数,它的函数签名如下:
1 | func new(Type) *Type |
其中,
1.Type表示类型,new函数只接受一个参数,这个参数是一个类型
2.*Type表示类型指针,new函数返回一个指向该类型内存地址的指针。
new函数不太常用,使用new函数得到的是一个类型的指针,并且该指针对应的值为该类型的零值。
make也是用于内存分配的,区别于new,它只用于slice、map以及chan的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型,因为这三种类型就是引用类型。
映射Map
定义:
1 | map[KeyType]ValueType |
默认初始值为nil,需要使用make()函数来分配内存。
判断map中键是否存在:
1 | value, ok := map[key] |
使用for range遍历map:
1 | for k, v := range scoreMap { |
遍历map时的元素顺序与添加键值对的顺序无关。
使用delete()内建函数从map中删除一组键值对:
1 | delete(map, key) |
结构体
没有“类”的概念。使用type关键字来定义自定义类型。也可以通过struct定义,struct可以封装多个基本数据类型,实现面向对象。
1 | //将MyInt定义为int类型 |
同样类型的字段也可以写在一行。
类型别名:本质上是同一个类型。
1 | type TypeAlias = Type |
匿名结构体
1 | func main() { |
使用&对结构体进行取地址操作相当于对该结构体类型进行了一次new实例化操作。p3.name = “博客”其实在底层是(*p3).name = “博客”,这是Go语言帮我们实现的语法糖。
1 | p3 := &person{} |
可以使用键值对初始化,也可以对结构体指针进行键值对初始化,当某些字段没有初始值的时候可以不写。初始化的时候可以不写键,直接写值,此时必须初始化结构体的所有字段。可以自己实现构造函数。
方法
方法(Method)是一种作用于任意特定类型变量,即接收者(Receiver),的函数。类似于其他语言中的this或self。
方法的定义格式如下:
1 | func (接收者变量 接收者类型) 方法名(参数列表) (返回参数) { ... } |
指针类型的接收者由一个结构体的指针组成,方法结束后修改都是有效的。
类型接收者的方法中可以获取接收者的成员值,会在代码运行时将接收者的值复制一份。但修改操作只是针对副本,无法修改接收者变量本身。
匿名字段:结构体允许其成员字段在声明时没有字段名而只有类型。匿名字段默认采用类型名作为字段名,结构体要求字段名称必须唯一,因此一个结构体中同种类型的匿名字段只能有一个。
一个结构体中可以嵌套包含另一个结构体或结构体指针。
当访问结构体成员时会先在结构体中查找该字段,找不到再去匿名结构体中查找。
嵌套结构体内部可能存在相同的字段名,这时为了避免歧义需要指定具体的内嵌结构体的字段。
使用结构体可以实现其他编程语言中面向对象的继承。
结构体中字段大写开头表示可公开访问,小写表示私有(仅在定义当前结构体的包中可访问)。
JSON键值对是用来保存JS对象的一种方式:键/值对组合中的键名写在前面并用双引号””包裹,使用冒号:分隔,然后紧接着值;多个键值之间使用英文,分隔。
1 | //JSON序列化:结构体-->JSON格式的字符串 |
结构体标签(Tag)
Tag是结构体的元信息,可以在运行的时候通过反射的机制读取出来。
1 | `key1:"value1" key2:"value2"` |
结构体标签由一个或多个键值对组成。键与值使用冒号分隔,值用双引号括起来。键值对之间使用一个空格分隔。 注意事项: 为结构体编写Tag时,必须严格遵守键值对的规则。结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任何错误,通过反射也无法正确取值。例如不要在key和value之间添加空格。
流程控制
条件语句if (else)
该语句声明的变量作用域仅在 if 之内。在 if 的简短语句中声明的变量同样可以在对应的任何 else 块中使用。
1 | func main() { |
条件语句switch
1 | switch marks { |
执行的过程从上至下,直到找到匹配项;没有表达式会匹配true。
switch默认相当于每个case最后带有break,匹配成功后不会自动向下执行其他case,而是跳出整个switch,但是可以使用fallthrough强制执行后面的case代码。
条件语句select
用于处理异步IO操作。select会监听case语句中channel的读写操作,当case中channel读写操作为非阻塞状态(即能读写)时,将会触发相应的动作。 select中的case语句必须是一个channel操作(通信操作,IO操作),要么是发送要么是接收。 所有channel、被发送的表达式都会被求值。
如果任意某个通信可以进行,它就执行;其他被忽略。
如果有多个case都可以运行,Select会随机公平地选出一个执行。其他不会执行。
否则:
如果有default子句,则执行该语句。默认的子句应该总是可运行的。
如果没有default字句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
select可以监听channel的数据流动
1 | select { //不停的在这里检测 |
退出
1 | case <-c.shouldQuit: |
判断channel是否阻塞
(channel缓存满了)
1 | ch := make (chan int, 5) |
循环语句for
1 | for init; condition; post { } |
循环内部赋值不影响循环外,init赋值传入的是副本。可以实现无限循环。
range
for 循环的 range 格式可以对 slice、map、数组、字符串等进行迭代循环,类似迭代器操作,返回 (索引, 值) 或 (键, 值)。
循环控制
Goto、Break、Continue,都可以配合标签(label)使用,标签名区分大小写,定义后若不使用会造成编译错误。
配合标签(label),Break、Continue可用于多层循环跳出,Goto是调整执行位置。
函数
声明
关键字 func 定义函数,包含一个函数名,(参数列表)(返回值列表)和函数体。左大括号依旧不能另起一行。
函数是第一类对象,可作为参数传递。
有返回值的函数,必须有明确的终止语句,否则会引发编译错误。
没有函数体的函数声明表示该函数不是以Go实现的。这样的声明定义了函数标识符。
参数
不定参数传值(可变参数)就是函数的参数不是固定的,后面的类型是固定的。本质上就是 slice。只能有一个,且必须是最后一个。使用 slice 对象做变参时,必须展开。(slice...)
1 | func myfunc(args ...int) { //0个或多个参数 |
args是一个slice,我们可以通过arg[index]依次访问所有参数,通过len(arg)来判断传递参数的个数.
任意类型的不定参数: 就是函数的参数和每个参数的类型都不是固定的。Go语言用interface{}传递任意类型数据,interface{}是类型安全的。Go1.18 更新后增加关键字any等同于interface{}。
返回值
返回值可以在函数体开头被命名。
没有参数的 return 语句返回各个返回变量的当前值。这种用法被称作“裸”返回。
不能用容器对象接收多返回值。只能用多个变量,或 "_" 忽略。多返回值可直接作为其他函数调用实参。
命名返回参数可被同名局部变量遮蔽,此时需要显式返回。
命名返回参数允许 defer 延迟调用通过闭包读取和修改。显式 return 返回前,会先修改命名返回参数。
1 | return z + 200 // 执行顺序: (z = z + 200) -> (call defer) -> (return) |
匿名函数
匿名函数可赋值给变量,做为结构字段,或者在 channel 里传送。
1 | func main() { |
闭包
匿名函数可作为闭包。
闭包是由函数及其相关引用环境组合而成的实体(闭包=函数+引用环境),指的是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。支持闭包的语法都有垃圾回收(GC)机制。
具体来说就是函数1的返回值是函数2,那么函数1中定义的变量则没有被垃圾回收,还能通过调用函数2的方式继续对其进行操作。每次调用函数1定义的变量不互通。
1 | func add(base int) func(int) int { |
递归
函数在运行的过程中调用自己。
延迟调用(defer)
直到 return 前才被执行,可以用来做资源清理(关闭文件句柄、锁资源释放、数据库连接释放)。
按先进后出的方式执行。哪怕函数或某个延迟调用发生错误,这些调用依旧会被执行。
defer后面的语句在执行的时候,函数调用的参数会被保存起来,但是不执行,也就是复制了一份。
但是go语言并没有把明确写出来的struct的this指针当作参数来看待。我的理解:defer后面跟的表达式会在声明的时候将调用到的参数直接拷贝保存,不会随着后面代码的执行而更新,但struct的this指针会更新。
错误(输出c c c):
1 | func (t *Test) Close() { |
正确(输出c b a):
1 | func Close(t Test) { |
1 | func main() { |
延迟调用参数在注册时求值或复制,可用指针或闭包 “延迟” 读取:
1 | func test() { |
滥用 defer 可能会导致性能问题,尤其是在一个 “大循环” 里。
闭包
如果 defer 后面跟的不是一个闭包,最后执行的时候我们得到的并不是最新的值:
1 | func foo(a, b int) (i int, err error) { |
输出结果:
1 | third defer err divided by zero! |
返回值
1 | func foo() (i int) { |
在有具名返回值的函数中(这里具名返回值为 i),执行 return 2 的时候实际上已经将 i 的值重新赋值为 2。所以defer closure 输出结果为 2 而不是 1。
为避免defer使用空指针导致错误,总是在一次成功的资源分配下面使用 defer ,即为defer加一个条件判断。defer能保证在返回值非空但遇到错误导致return时仍然执行资源释放。
释放资源时要注意完备的错误处理,要记得在defer执行f.Close()时检查是否有错误。
1 | func do() (err error) { |
如果要使用相同的变量释放不同的资源,应该进行传参,来避免重复释放。
1 | f, err = os.Open("another-book.txt") |
异常处理
go语言没有结构化异常,一般流程:抛出一个panic的异常,在defer中通过recover捕获这个异常,正常处理。
panic:执行到panic语句,会终止其后要执行的代码,在panic所在函数F内执行derfer,返回函数F的调用者G,在G中调用函数F语句之后的代码不会执行,执行G中的derfer,直到goroutine整个退出,并报告错误。
recover:一般在defer函数中,获取通过panic传递的error,恢复正常代码的执行。
defer 必须放在 panic 之前定义,recover 只有在 defer 调用的函数中才有效,逻辑并不会恢复到 panic 那个点去,函数跑到 defer 之后的那个点。
panic、recover 参数类型为 interface{},可抛出任何类型对象。
1 | println(err.(string)) // 将 interface{} 转型为具体类型。 |
向已关闭的通道发送数据会引发panic。
defer中引发的错误,可被后续defer捕获,但仅最后一个错误可被捕获。
捕获函数 recover 只有在defer内直接调用才会终止错误(要形成闭包),否则总是返回 nil。任何未捕获的错误都会沿调用堆栈向外传递。
1 | func test() { |
除用 panic 引发中断性错误外,还可返回 error 类型错误对象来表示函数调用状态。标准库 errors.New 和 fmt.Errorf 函数用于创建实现 error 接口的错误对象。通过判断错误对象实例来确定具体错误类型。
惯例是:导致关键流程出现不可修复性错误的使用 panic,其他使用 error。
1 | var ErrDivByZero = errors.New("division by zero") |
类似 try catch 的异常处理:
1 | func Try(fun func(), handler func(interface{})) { |
单元测试
go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。
在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。
| 类型 | 格式 | 作用 |
|---|---|---|
| 测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
| 基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
| 示例函数 | 函数名前缀为Example | 为文档提供示例文档 |
go test命令会遍历所有的*_test.go文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。
(未完待续)
方法
定义
方法就是一个包含了接受者的函数。接受者可以是命名类型或者结构体类型的一个值或者是一个指针。(与函数不同)
方法总是绑定对象实例,并隐式将实例作为第一实参 (receiver,接受者)。只能为当前包内命名类型定义方法。
1 | func (recevier type) methodName(参数列表)(返回值列表){} |
当接受者不是一个指针时,操作的是副本而不是其本身。
当接受者是指针时,使用值类型和指针类型的调用时,函数内部都是对指针的操作。
匿名字段
只提供类型而不写字段名,也即嵌入字段。
1 | type Manager struct { |
以“%p”的形式输出时,传入值为&m,输出m的地址;传入值为m,调用m.User的函数输出时,输出m.User的地址(与传入&m相同)。
以“%v”的形式输出时:传入值为m或m.User(方法经过覆写),调用m.User的函数输出时,以&{参数1 参数2}形式输出User;传入值为m,调用m的函数输出时,以& { { 参数1 参数2 } 参数3 }形式输出m;
通过匿名字段,可获得和继承类似的复用能力。依据编译器查找次序,只需在外层定义同名方法,就可以实现 “override”。
方法集
类型 *T 方法集包含全部 receiver T + *T 方法。
- 如类型 S 包含匿名字段 T,则 S 方法集包含 T 方法, *S 方法集包含 T + *T 方法。 (方法提升:当我们嵌入一个类型,嵌入类型的接受者为值类型的方法将被提升,可以被外部类型的值和指针调用。)
- 如类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 T + *T 方法。
表达式
根据调用者不同,方法分为两种表现形式:
1 | instance.method(args...) ---> <type>.func(instance, args...) |
前者称为 method value,绑定实例,会复制 receiver,不受后续修改影响;后者 method expression,须显式传参。
1 | mValue := u.Test |
因此,接受者为指针类型的方法可以接受空指针,接受者为值类型的方法不能。
自定义error
抛出异常:系统抛出、自写代码检查抛出panic
返回异常:err = errors.New(“半径不能为负”)
自定义error:
1 | func (p *PathError) Error() string { |
面向对象
接口
定义了一个对象的行为规范但不实现,由具体的对象来实现规范的细节。
接口(interface)是一种抽象的类型,是一组method的集合,接口命名习惯以 er 结尾。
定义:
1 | type 接口类型名 interface{ |
参数列表和返回值列表中的参数变量名可以省略。
一个对象只要全部实现了接口中的方法,那么就实现了这个接口。换句话说,接口就是一个需要实现的方法列表。
接口类型变量能够存储所有实现了该接口的实例。 接口类型可以接受值类型和指针类型。
面试题:给*Stduent类型定义Speak方法,并不能算是给Stduent类型定义Speak方法(方法集的定义),Stduent类型没有完成接口实例化。
接口与类型
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。不同的类型可以实现同一接口 。
一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。
接口与接口间可以通过嵌套创造出新的接口,嵌套得到的接口的使用与普通接口一样。
空接口
空接口是指没有定义任何方法的接口,因此任何类型都实现了空接口,空接口类型的变量可以存储任意类型的变量。
应用:作为函数的参数,可以接收任意类型;作为map的值,可以保存任意值。
一个接口的值(简称接口值)是由一个具体类型和具体类型的值两部分组成的。这两部分分别称为接口的动态类型和动态值。
类型断言:表示断言接口x可能是T类型。返回两个参数,第一个参数是x转化为T类型后的变量,第二个值是一个布尔值,若为true则表示断言成功,为false则表示断言失败。
1 | x.(T) |
只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要定义接口,不要为了接口而写接口。
网络编程
socket编程
TCP编程
服务端
1.监听端口
2.接收客户端请求建立链接
3.创建goroutine处理链接。
1 | // tcp/server/main.go |
客户端
1.建立与服务端的链接
2.进行数据收发
3.关闭链接
1 | // tcp/client/main.go |
先启动server端再启动client端。
UDP编程
服务端
用net包实现:
1 | // UDP/server/main.go |
客户端
1 | // UDP 客户端 |
TCP黏包
tcp数据传递模式是流模式,在保持长连接的时候可以进行多次的收和发,导致客户端的多条数据在服务端“粘”到了一起。关键在于接收方不确定将要传输的数据包的大小,因此我们可以对数据包进行封包和拆包的操作。
封包就是给一段数据加上包头,数据包就分为包头和包体两部分内容了(过滤非法包时封包会加入”包尾”内容)。包头部分的长度是固定的,并且它存储了包体的长度,根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包。
协议
可以自己定义一个协议,比如数据包的前4个字节为包头,里面存储的是发送的数据的长度:
1 | // socket_stick/proto/proto.go |
在服务端和客户端分别使用上面定义的proto包的Decode和Encode函数处理数据。
服务端
1 | // socket_stick/server2/main.go |
客户端
1 | // socket_stick/client2/main.go |
http编程
服务端
1 | package main |
客户端
1 | package main |
WebSocket编程
允许服务端主动向客户端推送数据。浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
- cmd中安装:go get -u -v github.com/gorilla/websocket
聊天室样例
并发编程
并发
并发:多线程程序在一个核的cpu上运行。还是在一个核上运行,主要由切换时间片来实现”同时”运行,本质上是对这一个核的资源分配。
并行:多线程程序在多个核的cpu上运行。
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
线程:一个线程上可以跑多个协程,协程是轻量级的线程。
goroutine
goroutine 奉行通过通信来共享内存,而不是共享内存来通信。
概念类似于线程,但 goroutine 是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言在语言层面已经内置了调度和上下文切换的机制。
使用:只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。
在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。当main()函数返回的时候,所有在main()函数中启动的goroutine会一同结束。
让main函数等待hello函数最简单粗暴的方式就是time.Sleep:
1 | func main() { |
可以用sync.WaitGroup来实现goroutine的同步:
1 | var wg sync.WaitGroup |
OS线程一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),且不是固定的,可按需增大和缩小,可以达到1GB。所以Go语言中可以一次创建十万左右的goroutine。
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统,区别于OS调度OS线程。



