go基础语法总结
0、HelloWorld
1 | package main |
这就是go语言,现在来逐行认识一下它
第1行:我们的Go程序是由包——
package
构成的,包的声明形式为:package <包名>
。该行的意思是:当前HelloWorld.go
文件属于main
包。第3行:如果你使用过Java或Python,那你对import肯定不陌生。该行的意思是:导入一个名为
fmt
的包。如果需要导入多个包,有两种写法:1
2
3//第一种
import "fmt"
import "math"或者使用分组形式同时导入多个包
1
2
3
4
5//第二种
import (
"fmt"
"math/rand"
)显然第二种使用括号,以分组的形式导入更加方便。
第5行:我们使用
func
关键字来声明一个函数,在这个例子中,我们声明的是main
函数。如果你有过其他语言的编程经验,肯定熟悉main
函数的作用:程序的入口。(注意本行的左括号{
,按照go语法标准,不能像C语言那样子单独占一行)1
2
3
4func main()
{ // 错误写法,原因: go语言函数定义时 `{` 不可以单独占一行
fmt.Println("Hello, World!")
}第6行:我们的函数内容放在函数的
{}
中,在该例中,调用了fmt
包中的打印方法,由此可见,Golang语言调用函数的方法和Java、Python一样使用小数点:<包名>.<函数名>
。
看完这几行简单代码,我们发现:在Go语言中,并不需要分号;
来结束语句。
1、数据结构
1.1、基本数据类型
1.1.1、布尔类型
布尔类型为bool
,值可取true
或false
,默认值为false
。
1.1.2、字符串类型
字符串类型为string
,默认为空字符串""
。
1.1.3、数值类型
整数类型分为:
有符号数:
int
、int8
、int16
、int32 (rune)
、int64
无符号数:
uint
、uint8 (byte)
、uint16
、uint32
、uint64
、
其中int
和uint
的两种类型的长度相同,取决于具体的编译器,比如在32位系统上通常为32位,在64位系统上通常为64位。
像int8
、uint8
这些类型则是Go语言直接定义好位数的类型。rune
、byte
是int32
、uint8
的别名。
当我们需要使用整数时,应当尽量使用
int
类型。当然,如果你有特殊的理由使用其他整数类型,便另当他论。
浮点数类型有两种:float32
和float64
,注意没有所谓的float
类型。
复数类型也有两种:complex64
和complex128
1.1.4、指针
指针是用来指向地址的引用类型。其本身也有地址, 会套娃。 不开玩笑了, 不懂c语言的此部分可绕开。
C 和 Go 都是有指针概念的语言,此部分主要借这两者之间的异同来加深对 Go 指针的理解和使用。
运算符
C 和 Go 都相同:
&
运算符取出变量所在的内存地址*
运算符取出指针变量所指向的内存地址里面的值,也叫 “ 解引用 ”
C 语言版示例:
1 |
|
Go 语言版示例:
1 | package main |
Go 还可以使用 new
关键字来分配内存创建指定类型的指针。
1 | // 声明一个指向 int 类型的值的指针 |
数组名和数组首地址
对于一个数组
1 | // C |
在 C 中,数组名 arr
代表的是数组首元素的地址,相当于 &arr[0]
而 &arr
代表的是整个数组 arr 的首地址
1 | // C |
运行程序可以发现 arr
和 &arr
的输出值是相同的,但是它们的意义完全不同。
首先数组名 arr
作为一个标识符,是 arr[0]
的地址,从 &arr[0]
的角度去看就是一个指向 int 类型的值的指针。
而 &arr
是一个指向 int[5]
类型的值的指针。
可以进一步对其进行指针偏移验证
1 | // C |
这里涉及到偏移量的知识:一个类型为 T
的指针的移动,是以 sizeof(T)
为移动单位的。
arr+1
: arr 是一个指向int
类型的值的指针,因此偏移量为1*sizeof(int)
&arr+1
: &arr 是一个指向int[5]
的指针,它的偏移量为1*sizeof(int)*5
到这里相信你应该可以理解 C 语言中的 arr
和 &arr
的区别了吧,接下来看看 Go 语言
1 | // 尝试将数组名 arr 作为地址输出 |
&arr[0]
和 &arr
与 C 语言一致。
但是数组名 arr
在 Go 中已经不是数组首元素的地址了,代表的是整个数组的值,所以输出时会提示 %!p([5]int=[1 2 3 4 5])
指针运算
指针本质上就是一个无符号整数,代表了内存地址。
指针和整数值可以进行加减法运算,比如上文的指针偏移例子:
加
n
: 一个类型为T
的指针,以n*sizeof(T)
为单位向高位移动。减
n
: 一个类型为T
的指针,以n*sizeof(T)
为单位向低位移动。
其中 sizeof(T)
代表的是数据类型占据的字节,比如 int
在 32 位环境下为 4 字节,64 位环境下为 8 字节
C 语言示例:
1 |
|
在这里 ptr++
从 0061FF08
移动了 sizeof(int) = 4
个字节到 0061FF0C
,指向了下一个数组元素的地址
Go 语言示例:
1 | package main |
编译报错 *uint32
非数字类型,不支持运算,说明 Go 是不支持指针运算的。
这个为什么不支持, 官方有解答GO FAQ。
因此, Go 有指针但不支持指针运算。
另辟蹊径
那还有其他办法吗?答案当然是有的。
在 Go 标准库中提供了一个 unsafe
包用于编译阶段绕过 Go 语言的类型系统,直接操作内存。
我们可以利用 unsafe
包来实现指针运算。
1 | func Alignof(x ArbitraryType) uintptr |
核心介绍:
uintptr
: Go 的内置类型。是一个无符号整数,用来存储地址,支持数学运算。常与unsafe.Pointer
配合做指针运算unsafe.Pointer
: 表示指向任意类型的指针,可以和任何类型的指针互相转换(类似 C 语言中的void*
类型的指针),也可以和uintptr
互相转换unsafe.Sizeof
: 返回操作数在内存中的字节大小,参数可以是任意类型的表达式,例如fmt.Println(unsafe.Sizeof(uint32(0)))
的结果为4unsafe.Offsetof
: 函数的参数必须是一个字段x.f
,然后返回 f 字段相对于 x 起始地址的偏移量,用于计算结构体成员的偏移量
原理:
Go 的 uintptr
类型存储的是地址,且支持数学运算
*T
(任意指针类型) 和 unsafe.Pointer
不能运算,但是 unsafe.Pointer
可以和 *T
、 uintptr
互相转换
因此,将 *T
转换为 unsafe.Pointer
后再转换为 uintptr
,uintptr
进行运算之后重新转换为 unsafe.Pointer
=> *T
即可
代码实现:
1 | package main |
甚至还可以更改结构体的私有成员:
1 | // model/model.go |
小Tips
Go的底层slice
切片源码就使用了unsafe
包
1 | // slice 切片的底层结构 |
总结
Go 可以使用 & 运算符取地址,也可以使用 new 创建指针
Go 的数组名不是首元素地址
Go 的指针不支持运算
Go 可以使用 unsafe 包打破安全机制来操控指针,但对我们开发者而言,是 “unsafe” 不安全的
1.2、自定义数据类型
1.2.1、数组、切片
与type
关键字
往往用来自定义一些可以存储相同数据类型的集合类型结构。
切片可以说是专门用来指向此种数据类型结构的指针。
1 | // C |
还是没概念的, 可查看&5、数组、$6、切片, 后继续观看。
我们可以通过type
关键子, 来自定义类型:
1 | const n = 30 |
1.2.2、结构体
往往用来自定义一些可以存储不同数据类型的集合类型结构。
1.2.2.1、定义规范
结构体定义需要使用 type 和 struct 语句。
1 | type typeName struct { |
实例如下:
1 | package main |
1.2.2.2、访问结构体成员
结构体变量名.成员名
1 | package main |
1.2.2.3、结构体作为函数参数
你可以像其他数据类型一样将结构体类型作为参数传递给函数。并以以上实例的方式访问结构体变量:
1 | package main |
1.2.2.4、结构体指针
&
运算符取出变量所在的内存地址*
运算符取出指针变量所指向的内存地址里面的值,也叫 “ 解引用 ”
接下来让我们使用结构体指针重写以上实例,代码如下:
1 | package main |
1.2.2.5、结构体成员函数
我们做过c语言开发的朋友都清楚, c语言也是可以面向对象编程的; 比如我就常用 “结构体 + 函数指针” 的组合 来实现最基本的面向对象。
当然, go语言作为最接近c的语言, 同样是支持面向对象编程的, 通过go的设计, 在实现成员函数时,简化了类似c语言中 结构体+函数指针 这个自定义组合, 提供了更易用的语法, 规范如下:
1 | package main |
成员函数的声明 与 普通函数的声明类似,区别在于方法的声明在函数名字前加了其所属的结构体名(例子中为typeName
和tne typeName
)
- 这个
tne
, 只是我们随便取的名字(可以自定义,我尝试用this或self来命名);- 此参数变量作用: 是让我们在成员函数内部可以访问此结构体内部的成员
即自行定义此处的参数变量名, 此变量在成员函数内部的做用: 相当于C++的this指针, 用于调用对象结构体内部所包含的所有成员。
- 此参数变量作用: 是让我们在成员函数内部可以访问此结构体内部的成员
这种普通的结构体成员函数 相当于实现了空接口; 如想了解接口的使用, 可继续前往7、接口小节进行阅读, 以更顺手的姿势在go语言中运用你的面向对象思维。
1.2.2.6、结构体标签
1.2.2.6.1、将结构体标签
配合json
来使用
Tag是结构体中某个字段别名, 可以定义多个, 空格分隔[7]
1 | type Student struct { |
使用空格来区分多个tag,所以格式要尤为注意
tag的作用相当于该字段的一个属性标签, 在Go语言中, 一些包通过tag来做相应的判断
举个例子, 比如我们有一个结构体
1 | type Student struct { |
然后我们将一个该结构体实例化一个 s1
1 | s1 := Student{ |
再将 s1 序列化
1 | v, err := json.Marshal(s1) // json.Marshal方法,json序列化,返回值和报错信息 |
此时 string(v) 为
1 | { |
因为在 Go 语言中, 结构体字段要想为外部所用就必须首字母大写, 但是如果这个 s1 是返回给前端的, 那每个字段都首字母大写就很怪, 此时我们可以给 Student 加tag解决
结构体修改为
1 | type Student struct { |
序列化时, 会自己找到名为 json 的tag, 根据值来进行json后的赋值
因此,此时的 string(v) 为
1 | { |
下面是一个完整的例子:
1 | package main |
常用tag记录:
1 | json json序列化或反序列化时字段的名称 |
1.2.2.6.2、将结构体标签
配合反射
来使用
下方是一个例子:
Tips: 如不了解Go的反射机制,可先阅读<9、反射>章节
1 | package main |
1.2.2.7、 结构体的继承
虽然Go结构体没有继承, 但是我们可以通过在某个结构体中写入其他结构体的名字来达到继承目的。
Go中的继承, 没有像C++中所谓的保护
、 公有
、 私有
等一系列机制。由于接口的概念, 子类(结构体)也是可以重写父类(结构体)方法的。
1 | type Father struct{ |
1.3、其他数据结构
1.3.1、枚举
1.3.1.1、定义规范
iota
,常常结合常量来用于枚举。
1 | const ( |
第一个 iota
等于 0,每当 iota
在新的一行被使用时(后续的iota
可省略不写),它的值都会自动加 1;所以 a=0, b=1, c=2 。
1.3.1.2、用法
例子1:
1 | package main |
例子2:
1 | package main |
iota 表示从 0 开始自动加 1,所以 i=1<<0
, j=3<<1
(<< 表示左移的意思),即:i=1, j=6,这没问题,关键在 k 和 l,从输出结果看 k=3<<2
,l=3<<3
。
简单表述:
- i=1:左移 0 位,不变仍为 1。
- j=3:左移 1 位,变为二进制 110,即 6。
- k=3:左移 2 位,变为二进制 1100,即 12。
- l=3:左移 3 位,变为二进制 11000,即 24。
1.3.2、Map
1.3.2.1、介绍
作用相当于C++中stl库里的map
, python中的字典。
在go中, map
时一种无序的键值对集合。可以向迭代数组和切片那样迭代它。不过,map
是无序的,我们无法决定他的返回顺序,这是因为map
是使用hash表来实现的。
1.3.2.2、定义规范
仅定义时(不初始化), 如果不用make给其分配内存地址, 后续将无法正常使用, 规范如下例:
1 | var mapName map[keyType]valueType //声明一个map变量,此时只是一个nil指针,还需要初始化才能使用 |
如想避免使用make
, 可直接在定义时顺便进行初始化,规范如下例:
1 | // 定义并初始化 |
1.3.2.3、用法
1 | package main |
delete()
函数用于删除集合的元素, 实例如下:
1 | package main |
1.4、”空值”、默认值
虽然Golang中没有空值, 但本小节可以基于此概念来展开叙述。
go在编译器安全规则中, 对指针概念做了深度封装, 把c/c++指针的常用场景, 区分后封装为对应语法使得编程新手也能写出内存安全且相对漂亮的代码
因此下文中很多概念, 其实只是在C语言中一个指针走天下时的常用组合的深度封装; 虽是万变不离其宗, 但在封装时出于内存访问的安全考虑, 还是做了很多限制的(很多在c语言中的允许用法在此处会被编译器违规报错), 不过正是基于这些规范, 语法整体才做到了更规范易用, 使得没经验的新手也能写出漂亮的代码,使得编程越来越亲民。
go中所谓的”空值”, 可以是:
nil
指针、channel、func、interface、map、slice(切片)
类型的变量, 未初始化时的默认值为nil
tips2: 初始值为
nil
的map
无法正常使用(因为map使用时需要初始化 -> 若不想初始化则需要通过make给其分配内存地址,才能不影响后续使用)
tips3: go 语言中的结构体变量, 未初始化时的默认值为空接口
(即必须是有地址的interface
实现)(**不能直接通过nil
对其赋值(编译规则不允许), 只能通过new(结构体类型名)
对其分配内存,来作为结构体的”空值”)**\int、string、float32、float64、struct...
等 ,都实现了空接口(interface{}
) ; 因此,空接口(interface{}
)可作为万能类型 来引用 任意数据类型。- nil可以赋值给
指针、channel、func、interface、map、slice
类型的变量tips1:
nil
不能赋值给string
的变量编译器会报错。
tips2:nil
不能赋值给自定义结构体类型
的变量编译器会报错。
""
string
字符串类型的变量, 未初始化时的默认值为""
。
0
slice(切片)
类型的变量, “0值”指的是是长度容量都为0的空切片, 这个空切片不为nil。- 其他类型的变量, 未初始化时的默认值为
0
- 在go中, nil不一定等于nil。
2、变量和常量
2.1、变量
2.1.1、规范
Go语言要求标识符可以为字母或下划线_
以及他们的组合, 且不能使用关键字作为标识符。
区分大小写,变量num和变量Num是两个变量。
2.1.2、变量声明
变量声明创建一个或多个变量,将相应的标识符绑定到它们,并给每个变量一个类型和一个初始值。
1 | var i int |
如果给出了表达式列表,则使用遵循赋值语句规则的表达式初始化变量。否则,每个变量都被初始化为零值。
如果存在类型,则为每个变量赋予该类型。否则,每个变量都被赋予对应的初始化值的类型。如果该值是无类型常量,则首先将其隐式转换为其默认类型;如果它是一个无类型的布尔值,它首先被隐式转换为布尔类型。预声明的值 nil 不能用于初始化没有明确类型的变量。
1 | var d = math.Sin(0.5) // d is float64 |
如果一个变量声明了但没有使用, 请在编译器编译过程中报错时检查此变量, 若确定多余, 请删除。(对于导入的包同样有此约束规范,即: 导入的包必须使用)
2.1.3、简短变量声明
在本章的 $ 2.2 小节中我们介绍了变量声明相关的语法规范; 其实除此之外,规范中还有一种更为简洁方便的变量声明方式,经常被使用:
它进行声明及初始化时省去了部分关键字(只能在函数内部使用)。
1
2
3
4
5i, j := 0, 10
f := func() int { return 7 }
ch := make(chan int)
r, w, _ := os.Pipe() // os.Pipe() 返回一对连接的文件描述符和一个错误,如果有的话
_, y, _ := coord(p) // coord() 有三个返回值;但只对y坐标感兴趣如上方代码块, 当我们使用
:=
声明变量时,不用写var
也不用写类型。
在某些情况下,如 “if”、”for “或 “switch “语句初始化时,用来声明本地临时变量。
2.1.4、变量的类型转换
不同类型的变量之间不能直接进行赋值或其他运算。
但也不是没有办法, 比如我们可以先进行类型转换: 比如类型分别为int8
和int
的变量, 可将int8
类型转换为int
类型,这样就可以间接地进行赋值和其他运算。
使用表达式T(v)
将变量v
的值的类型转换为T
。注意是转换的是变量的值,变量本身的类型不变。
1 | package main |
注意:Go语言中的类型转换是显示的,表达式T()
是必须的,不能省略。
2.1.4.1、string和int类型的相互转换
1 | // string转成int: |
2.1.4.2、int 和 []byte之间的转换
我们都知道, 字符串
转[]byte
,只需要 str_test := []byte("哈哈")
即可;
那么如果是字符串形式的端口号,如"443"
端口, 如何转成对应只占 2byte 的[]byte
呢?
示例如下:
1 | // 先转成int类型 |
如果需要反向解析, 则如下示例:
1 | c = int(binary.BigEndian.Uint16(d)), |
2.1.4.3、 按base64协议编解码的[]byte与string
在go语言中, 一般 string
字符串 转换为 []byte
, 或是[]byte
转换为string
时, 我们通常直接 []byte(string)
或是 string([]byte)
即可。
但在某些场景(如网络传输等), 需要针对二进制格式的文件(根证书, 电子邮件附件…)做序列化与反序列化以进行网络传输, 我们的一般流程是:
先按照 base64
协议把这些不可见字节数据(调用编码API时需要传入的参数)编码为可见字节数据(编码过程封装在API内部),进而能够转为字符串类型(调用编码API得到结果字符串),接着将获得的字符串通过 json 进行序列化后, 编码为字节流通过网络发送出去, 直到完全发送。 而对方收到数据后,要先把字节流解码,然后再反序列化为所需字符串(调用解码API时需要传入的参数),接着转为可见字节类型数据(解码过程封装在API内部),最后再使用 base64 解码还原成原始字节数据(调用解码API时得到的结果字节数据)。
关于base64协议介绍, 可查看 Base64 介绍
go语言中, 常用的base64编解码API示例如下:
1 | //编码 把不可见字节数据,编码为, 可见的字符串类型 |
2.2、常量
常量是固定的值,值在程序运行期间不会改变。
常量可以定义为数值、字符串、布尔类型
常量的声明方式和变量差不多,区别在于常量需要用const
关键字修饰,不能使用:=
进行声明。
1 | package main |
常量可以用len()
, cap()
, unsafe.Sizeof()
函数计算表达式的值。常量表达式中,函数必须是内置函数,否则编译不过:
1 | package main |
常量配合iota
可以用作枚举, 已经在1.3.1、枚举已经提到过了, 此处不再过多赘述。
3、函数
如果你之前学习过C或者Java等语言,肯定已经对函数(方法)有了一定的认识。
简单地来说,函数是对能完成某个功能的部分代码的抽象。当以后再需要该功能,我们只需要调用其对用的函数即可,不必再重复编写代码。
3.1、函数的声明
已介绍过了, 声明函数的关键字func
1 | func func_name() { |
3.2、函数的 “参数” “返回值” 定义规范
Go语言中,函数可以有0个或多个参数, 并且还可以有0个或多个返回值。与C语言不同的是,go语言的返回值类型在函数名之后。
1 | package main |
go语言规范还提供了另一种函数返回的方式: 命名返回值。
顾名思义,我们通过给返回值进行命名
,使用空return
语句,这样会直接返回已命名的返回值。如上例的sumAndDiff
函数可以写为:
1 | func sumAndDiff(x, y int) (sum int, diff int) {//提前命名返回值 |
3.3、总结
符合go函数规范的有两种写法
1.一般写法
1 | // 一般写法 |
2.命名返回值写法
1 | // 命名返回值写法 |
3.4、函数的递归
Go 语言支持递归。但我们在使用递归时,开发者需要设置退出条件,否则递归将陷入无限循环中。
递归函数对于解决数学上的问题是非常有用的,就像计算阶乘,生成斐波那契数列等。
3.4.1、用递归实现阶乘
1 | package main |
3.4.2、用递归实现斐波那契数列
数学上对该数列的的定义: F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*)
1 | package main |
3.5、函数导出名
前面我们已经使用了import
导入功能,比如improt "fmt"
,该行代码可以让我们在本包中使用其他包里的函数。
那么我们如何让其他包能够使用到本包的方法或变量呢?答案是:将方法或变量导出。
在Go语言中,如果一个名字以大写字母开头,那么它就是已导出的,这意味着别的包可以使用它。(相当于Java中的public
的作用)
比如我们常用的打印函数fmt.Println(...)
,可以看到Println()
的首字母是大写的,所以我们能够导入fmt
包后使用该方法。
3.6、init()函数
init()函数就像main函数一样,不带任何参数也不返回任何东西。 每个包(package
)中都存在此函数,并且在初始化包(import
)时将调用此函数。 该函数是隐式声明的,因此您不能从任何地方引用它,并且可以在同一程序中创建多个init()函数,并且它们将按照创建顺序执行。
您可以在程序中的任何位置创建init()函数,并且它们以词汇文件名顺序(字母顺序)调用。 并允许在init()函数中放置语句,但始终记住init()函数执行的时间点是”在main()函数调用之前”,因此它不依赖于main()函数。 init()函数的主要目的是初始化无法在全局上下文中初始化的全局变量。
1 | package main |
此例子是单文件内的, 涉及多个文件时init函数会以词汇文件名顺序(字母顺序)调用, 此处不进行演示。
4、控制语句: “条件” “循环”等
4.1、if语句
if语句是条件判断语句,用来判断是否满足某种条件,如果满足,则执行某段代码;如果不满足,则不执行。
1 | if ... { |
注意格式:和c语言不同,go语言的条件判断语句 不需要使用小括号()
。
下面是几个例子:
1 | if a > 0 {//如果满足a>0,则打印Hello, World |
Go语言的if语句有一个特性:可以在条件表达式前执行一个简单的语句。下面是一个例子:
1 | package main |
在if语句中,使用sum(x, y int)
函数计算出i的值,再进行判断。注意:变量i的作用域只在if语句中有效。
4.2、for语句
for语句是Go语言中的循环控制语句。它有几种形式:
(一)基本形式
1 | for 初始化语句; 条件表达式; 后置语句 { |
- 初始化语句:在第一次循环前之前,且只执行这一次
- 条件表达式:每次循环都会计算该表达式,如果满足(值为true)则继续循环;如果不满足(值为false),则跳出循环
- 后置语句:每次循环执行完都会执行该语句
下面是一个例子,循环打印5次”Hello,World”
1 | for i := 0; i < 5; i++ { |
(二)省略形式
for循环中的初始化语句和后置语句是可以省略的。
1 | // 省略初始化语句 |
1 | // 省略后置语句 |
从某种意义上来讲,上面两个例子并没有省略初始化语句或后置语句,只是改变了位置。
(三)while形式
诸如C、Java等语言中都有while循环,但是Go语言中没有while循环,但是我们可以使用for循环来实现“while循环”。
其实(二)省略形式
中的第二个for循环例子就已经可以看做是while循环了。我们再稍做改进:
1 | i := 0 |
(四)无限循环形式
1 | //打印无限多个Hello, World! |
4.3、break和continue
上面提到的循环语句只有当条件表达式的值为false时,才会停止循环。但实际开发中,我们可能在条件表达式的值为true的情况下,需要退出循环。这种时候,就需要使用break
或continue
语句。
break
语句用来跳出当前循环,continue
语句用来跳过本次循环。
下面是两个实例(改进上面循环打印5次”Hello,World!”的例子):
实例1:增加需求,当打印完第2遍
Hello,World!
时,停止打印1
2
3
4
5
6for i := 0; i < 5; i++ {
if i == 2 {
break
}
fmt.Println("Hello, World!", i)
}实例2:增加需求,不打印第3遍Hello,World!
1
2
3
4
5
6for i := 0; i < 5; i++ {
if i == 2 {
continue
}
fmt.Println("Hello, World!", i)
}
4.4、switch语句
我们可以使用if…else if…else if…进行一连串的条件判断,但是这样过于繁杂。switch语句就是用来简化这个问题的。
1 | switch 变量 { |
switch
语句中有许多case
和一个default
,只有当变量和case
的选项相匹配时,才会执行对应的操作代码。如果没有case
的选项可以匹配,则默认执行default
的代码。
下面是一个例子:
1 | package main |
从上面的例子可以看出,Go语言中switch
的case
支持常量(不必为整数)、表达式、多个值聚合。注意:不论是常量、表达式,还是多个值聚合,都要保证常量、表达式的值、聚合多个值的类型和switch做判断的变量相同。
switch
语句的匹配顺序是自上到下。Go语言自动为每个case
提供了break
语句,所以在众多选项中只能执行1个case
或default
,然后结束,剩余的不再执行。
但是可以使用fallthrough
强制执行之后的case(直接强制执行,无需判断case是否匹配,不执行前面已经运行按流程case过了的):
1 | result := sum(1, 1) |
4.5、select语句
请先了解go语言的并发 | 8、并发编程后, 再继续阅读select使用 | 8.2.5、select。
5、数组
5.1、规范
数组和前文提到的结构体一样,用于自定义数据类型(所定义出的数据类型中,存在多个类型相同的变量)。数组中每个变量称为数组的元素,每个元素都有一个数字编号——数组下标,该下标从0开始,用于区别各个元素。数组中可容纳的元素个数称为数组的长度。
数组的长度是其类型的一部分(即数组不能改变长度)。如下例:
1 | var a [4]int //将变量a声明为拥有4个整数的数组 |
变量a和b 的类型分别为[4]int和[5]int,是不同的类型。
5.2、数组声明
1 | var arr_name [length]type |
var
:不必多说,声明变量时都会用到该关键字。
arr_name
:数组名称,本质是个变量
length
:数组的长度
type
:数组的类型
[]
:通过它来进行对数组元素的读取、赋值
下面是一个例子:
1 | package main |
声明的数组,如果不初始化,则数组中的所有元素值都为“零值”。如下例:
1 | package main |
对数组元素进行初始化时,若只初始化了部分元素,则剩余的仍是零值。:
1 | package main |
在声明数组时同时初始时,可以通过...
代替具体长度指定,来让go编译器自动计算数组长度:
1 | var a = [...]int {1, 2, 3} //初始化,数组长度为3 |
5.3、简短数组声明
数组声明时可以采用:=
来省略var关键字
,而且与一般声明方式比, 通过:=
声明的方式如果不想初始化, 则必须带上{}
,如下例:
1 | package main |
5.3、二维数组
二维数组当中的元素仍是数组:
1 | var ab = [2][4]int {[4]int {1, 2, 3, 4}, [4]int {4, 5, 6, 7}} |
可以省去数组元素的类型:
1 | var ab = [2][4]int {{1, 2, 3, 4}, {4, 5, 6, 7}} |
5.4、遍历数组
1.使用数组长度
- 可以使用
len(array)
函数获取数组长度,然后遍历。
1 | arr := [5]string {"a", "b", "c", "d", "e"} |
2.使用range
关键字(常用)
range
关键字用于for循环中遍历数组时,每次迭代都会返回两个值,第一个值为当前元素的下标,第二值为该下标所对应的元素值。如果这两个值的其中一个你不需要,只需使用下划线_
代替即可。
1 | arr := [5]string {"a", "b", "c", "d", "e"} |
tip: 两种遍历方式同样适用于后文的遍历切片
6、切片 (slice)
6.1、概念
前面提到:Go中的数组的长度是固定的。这样就会在实际应用中带来不方便,因为很多时候在声明数组前并不明确该数组要存储多少个元素。声明太多,浪费;声明太少,不够用。
Go中的切片
解决了这个问题, 即为我们提供了类似C++stl库中“动态数组”的功能。
6.2、切片声明
声明切片和声明数组类似,但是不指定长度:
1 | var sli_name []type |
比如,声明一个元素集合为int的切片类型、并将其变量名定义为a:
1 | var a []int |
可以在声明它的时候直接初始化:
1 | var a = []int {1, 2, 3, 4} |
可以从一个已有的数组或者已有的切片中获取切片(常用)。
获取切片的方式是通过两个下标来获取,即开始下标(startIndex
)和结束下标(endIndex
),二者以冒号分隔。包括startIndex
,不包括endIndex
:
1 | a[startIndex : endIndex] |

如下例:
1 | a := [5]string {"a", "b", "c", "d", "e"} //声明并初始化一个数组 |
6.3、简短切片声明
支持
-> 在6.2最后的例子中就是用的:=
, 注意事项同数组(若仅声明不初始化则必须加{}
)。
6.4、切片的本质
前面提到:切片为我们提供了“动态数组”。但“动态数组”只是我们从表面来看的结果。更准确的说,从本质来看它更接近c语言中的”数组指针”,并且是对此概念高度封装后的开箱即用产物,因此他的本质是指针,是一个引用类型。
可以说
切片
是即兼顾了c数组指针使用时的性能、内存安全等问题,避免了使用的繁琐; 又适配了c++动态数组的功能和易用性, 避免了概念太多所面临的学习与使用门槛提高。tips: 由此可见,go对c/c++中常用的一些抽象概念, 重新设计后在语言层级封装好, 还自动gc的一系列做法, 确实可以做到让编程语言的使用难度直线下滑(后续的语言层并发更是重量级), 同时也会吸引更多的开发者使用go;
切片并不存储任何数据,它只是一个引用类型,切片总是指向一个底层的数组,描述这个底层数组的一段。
所以我们在声明数组时需要指定长度,而声明切片时不需要:
1 | var arr = [4]int {1, 2, 3, 4} //声明数组 |
由于切片的底层引用的是数组,所以更改切片中的元素会修改其底层数组中对应的元素,如果还有其他切片也引用了该底层数组,那么这些切片也能观测到这些修改。如图:

下面是一个例子:
1 | package main |
6.5、切片的相关操作
6.5.1、长度
切片的长度指切片所包含的元素个数。通过函数len(slice)
获取切片slice
的长度。
6.5.2、容量
切片的容量指切片的第一个元素到其底层数组的最后一个元素的个数。通过函数cap(slice)
获取切片slice
的容量。
下面是一个例子:
1 | package main |
下面是长度和容量的示意图:

有了容量这个概念,我们就可以通过重新切片来改变切片的长度:
1 | package main |
6.5.3、追加元素
使用func append(slice []Type, elems ...Type) []Type
可以向切片slice的末尾追加类型为Type
的元素elems
。
该函数的结果是一个包含原切片所有元素加上新添加元素的切片。由于改变切片内容了,所以底层数组也会被改变。
1 | package main |
当切片中容量已经用完时(len(s) == cap(s)
),也即底层数组容纳不了追加的元素时,Go会分配一个更大的底层数组,返回的切片指向这个新分配的数组,原数组的内容不变。
1 | package main |
6.5.4、复制切片
func copy(dst []Type, src []Type) int
dst是目标切片,src是源切片,该函数会将src中的元素复制到dst中,并返回复制的元素个数(该返回值是两个切片长度中的小值)
1 | package main |
1 | package main |
6.5.5、切片默认行为
切片的默认开始下标是0,默认结束下标是切片的长度。
而且在使用时,也会用此默认值来代替未明确表示的值, 举个例子, 如下:
1 | // 对于数组a |
6.6、特殊切片
6.6.1、nil
切片
切片的零值是 nil
,当声明一个切片,但不出初始化它,该切片便为nil
切片。nil
切片的长度和容量为0且没有底层数组。( 此处的nil
证实了之前对应切片本质的说明)
1 | func main() { |
6.6.2、切片的切片
切片中的元素可以是切片
1 | package main |
6.7、使用make函数创建切片
使用make
函数可以在创建切片时指定长度和容量。make
函数会分配一个元素为零值的数组并返回一个引用了它的切片
该函数接受三个参数,分别用来指定切片的类型、长度、容量
。当不传入容量参数时,容量默认和长度相同。容量参数不能小于长度参数。
1 | package main |
6.8、遍历切片
因为切片是对数组的引用,所以遍历切片也就是在遍历数组。
7、接口
7.1、接口的定义与使用
7.1.1、普通使用
Go语言中的接口定义使用 interface
关键字,定义形式与结构体类似。
接口的实现是”隐式”的, 没有关键词, 可以先看个例子后,再进行解释, 规范如下:
1 | package main |
功能与C#中接口一样, 或着 说跟 c++中 “虚函数+多态” 组合方式的 功能一样, 可容易写出通用的代码框架, 实现设计模式(如工厂模式)。
重写实现接口定义采用的方式不同于C#的继承方式, 因为go中没有继承。
go语言 是通过 结构体定义成员函数的方式 来直接实现对接口中所含虚函数的定义。故前面说接口的实现是”隐式”的。
即 将 定义结构体成员函数 时的 普通成员”自定义函数名” 改为 接口中某个已有的”虚成员的函数名” 来实现接口中成员方法的实际定义
7.1.2、多态使用
go的接口也是可以实现多态的, 就让我们再举一个例子, 来感受一下吧, 代码如下:
1 | package main |
7.1.3 接口的继承
接口和接口之间, 结构体和接口之间都是可以继承使用的。
如下示例:
1 | package main |
7.2、空接口与类型断言
如果一个接口里面没有定义任何方法, 那么他就是空接口, 前面我们提到过结构体成员函数就是通过空接口实现的,换句话说:任意结构体都隐式的实现了空接口。
什么是隐式实现? 即 Go语言为了避免用户重复定义很多空接口,它自己内置了空接口:interface{}
空接口里面没有方法,因此不具备任何能力,但是它的作用在于 可以容纳任意对象,它是一个万能容器。例如实现一个字典,字典的key是字符串,但是希望value可以容纳 任意类型的对象,这时就可以使用空接口来实现, 规范如下:
1 | package main |
tips: int、string、float32、float64、struct...
等都实现了空接口(interface{}
);因此 空接口(interface{}
) 可以作为万能类型 来引用 任意数据类型(即相当于指针
)(若以接口做形参即指针做形参
,调用时需所需数据类型变量的地址进去); 空接口形参在函数内使用时,也时常会用类型断言
机制来判断具体类型。
下面是一个空接口与类型断言结合使用的案例:
1 | package main |
8、并发编程
协程并发
协程:coroutine。也叫轻量级线程。比如lua语言就支持协程。
与传统的系统级线程和进程相比,协程最大的优势在于“轻量级”。可以轻松创建上万个而不会导致系统资源衰竭。而线程和进程通常很难超过1万个。这也是协程别称“轻量级线程”的原因。
一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源。
多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供协程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。
在协程中,调用一个任务就像调用一个函数一样,消耗的系统资源最少!但能达到进程、线程并发相同的效果。
在一次并发任务中,进程、线程、协程均可以实现。从系统资源消耗的角度出发来看,进程相当多,线程次之,协程最少。
Go并发
Go 在语言级别支持协程,叫goroutine。Go 语言标准库提供的所有系统调用操作(包括所有同步IO操作),都会出让CPU给其他goroutine。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不需要依赖于CPU的核心数量。
有人把Go比作21世纪的C语言。第一是因为Go语言设计简单,第二,21世纪最重要的就是并行程序设计,而Go从语言层面就支持并发。同时,并发程序的内存管理有时候是非常复杂的,而Go语言提供了自动垃圾回收机制。
Go语言为并发编程而内置的上层API基于顺序通信进程模型CSP(communicating sequential processes)。这就意味着显式锁都是可以避免的,因为Go通过相对安全的通道发送和接受数据以实现同步,这大大地简化了并发程序的编写。
Go语言中的并发程序主要使用两种手段来实现。goroutine和channel。
什么是Goroutine
goroutine是Go语言并行设计的核心,有人称之为go程。 Goroutine从量级上看很像协程,它比线程更小,十几个goroutine可能体现在底层就是五六个线程,Go语言内部帮你实现了这些goroutine之间的内存共享。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine比thread更易用、更高效、更轻便。
一般情况下,一个普通计算机跑几十个线程就有点负载过大了,但是同样的机器却可以轻松地让成百上千个goroutine进行资源竞争。
8.1、go程(goroutine)
Go是直接语言层支持并发的, Go的并发需要用到 go
关键字。
go程(goroutine)
可看作是轻量级线程,goroutine
的调度是由 Golang 运行时进行管理的, 在go程种的代码可以和其他代码并发执行。
为方便解释, go程
这个词在本文的某些位置依旧由线程
这个词来表达。
goroutine 语法格式:
go 函数名( 参数列表 )
-> 使用 go 语句开启一个新的运行期go程(即goroutine) 来执行一个函数。 同一个程序中的所有 goroutine 共享同一个地址空间。
1 | package main |
通过两种不同的运行结果, 可以看出输出的 hello 和 world 是没有固定先后顺序。因为它们是两个 goroutine 在执行。
8.2、通道(channel)
8.2.1、并发概念与锁的介绍
通道(channel)用来传递数据的一个数据结构,用于数据交互。而且通道本身保证了多线程场景下绝对安全的数据交互。
- 绝对安全指的是: c/c++/c#等语言, 在多线程场景下容易造成的
各种死锁
、越权访问
、临界区设置不够精确而浪费掉部分性能
… 等问题, 在用通道来实现数据交互时,统统不存在。正是这一机制, 使得新手程序员也能实现内存安全且高性能的代码, 这也是为什么我前面说go是语言层支持并发的。 - go写起来有多方便,我来举个例子: 我们写c/c++代码时, 出于性能考虑, 一般会采用多线程, 那么就不可避免地要面临”临界区如何设定范围, 以及对各种锁的选择问题”, 同时这些问题地处理,往往是很大的性能优化点, 有着很大地可优化空间。 (比如,临界区把范围全包的话,还不如单线程,简直浪费资源; 当同时有多个读操作时,需要读写锁,可一旦使用,过程中也就避免不了对正在访问数据以及如何访问的关注)。 使用了通道后,很多场景下的此类情况,也就没有必要再过多地考虑了; 与此同时,这个可优化空间也已被Go通道给榨干了, 直接在语言层解决基于此类问题地所有优化项。
- 如果你的应用场景的某些复杂全局项需要用到锁(如一些全局的非通道的,需并发使用的变量,还是离不开锁的), 或是应用场景过于简单时,不想使用高封装化的通道传递数据, 即想针对简单场景做性能优化。那么Go中同样也提供了
sync.Mutex
和sync.RWMutex
等基本锁功能。(其中sync.Mutex
锁, 其实不光有Lock
和Unlock
方法,也提供了RLock
和RLock
方法,这里的R代表Read。)
通道可用于两个 goroutine
之间通过传递一个指定类型的值来同步运行和通讯。操作符 <-
用于指定通道的方向(即发送或接收)。如果未指定方向,则为双向通道。
1 | ch <- v // 把 v 发送到通道 ch |
声明一个通道很简单,我们使用chan
关键字即可,通道在使用前必须先创建:
ch := make(chan int)
8.2.2、无缓冲区的通道
注意: 默认情况下,通道是不带缓冲区的。发送端发送数据,同时必须有接收端相应的接收数据。
下面是一个例子:
1 | package main |
运行结果种数太多, 文章只给出一个,其他结果不再列举, 这里直接说结果:
- 现象:
- 此例子中使用了无缓存通道, 因此必须通过协程并发来保证 “发送同时,必须有接受者”, 否则报错。
- 此例子中两个输入者协程一定阻塞在 , “开始接收”之前
- 此例子中两个输入者协程一定在”接受完毕后””解除阻塞”,继续执行其剩余代码
- 结论: 通道是自带锁机制的(比如互斥锁,同步锁,读写锁等…)。
因此, 如果本例子中没有采用并发的形式,当执行到向通道发送值的代码行实,直接就因阻塞卡死了。 编译器会提前报错来避免这种现象的。
8.2.3、带缓冲区的通道
通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:
ch := make(chan int, 100)
前面我们说了, 通道是自带互斥锁的, 那若是我们的通道有了缓冲区后, 其临界区会如何呢,如下所述:
- 带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态
就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。
- 如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内
- 如果缓冲区没有数据, 接收方在有值可以接收之前会一直阻塞。
- 缓冲区的大小是有限的, 如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。
下面是一个例子:
1 | package main |
8.2.4、通道的遍历与关闭
可以通过关闭通道close(channelName)
, 从而实现有限通道的遍历, 代码如下:
1 | package main |
上面只是简单举个例子, 至于 channel
关闭 的相关特点(注意事项), 有如下几条:
channel不像文件一样需要经常去关闭,只有当你确实没有任何发送数据了,或者你想显式的结束range循环之类的,才去关闭channel;
关闭channel后,无法向channel 再发送数据(若违规发送,将引发 panic 错误后导致接收立即返回零值);
关闭channel后,可以继续从channel接收数据;
对于nil channel,无论收发都会被阻塞。(因此使用时一定要通过
make(chan type, 缓冲大小值)
来定义通道)
8.2.5、select
select
是 Go 中的一个控制结构,类似于对C语言中的select与epoll
这种I/O多路复用的轮询机制的模仿, 不过Go中select
是用来轮询通道(channel
)是否可用的, 常配合一个无限的for
循环来使用。(若对C不了解, 可以把go的select
类比为用于通信的 switch 语句。每个 case 必须是一个IO通道操作,要么是发送要么是接收), 大致结构如下:
1 | for { |
select
可等待多个通道操作。将goroutine
和channel
与select
结合是Go语言的一个强大功能。
也就是说, 一般单流程下(即 单go程
下)只能监控一个channel
的状态; 而用了select
后可以完成用一个go程
来监控多个channel的状态。
示例代码:
1 | package main |
在 golang 中,谁也无法保证某些情况下的 select
是否会永久阻塞。很多时候都需要设置一下 select
的超时时间,可以借助 time
包的 After()
实现select超时时间的设置。参考自< Golang time after >
示例代码:
1 | import ( |
1 | package main |
9、反射
本小节(以及其他小节中的部分内容)转载自刘丹冰原创文章<8小时转职Golang工程师>[10]
可返回本文前面的小节中<1.2.2.6.2、将’结构体标签’配合’反射’来使用>,查看反射
与结构体标签
配合使用的示例。
9.1、编程语言中反射的概念
在计算机科学领域,反射是指一类应用,它们能够自描述和自控制。也就是说,这类应用通过采用某种机制来实现对自己行为的描述(self-representation)和监测(examination),并能根据自身行为的状态和结果,调整或修改应用所描述行为的状态和相关的语义。
每种语言的反射模型都不同,并且有些语言根本不支持反射。Golang语言实现了反射,反射机制就是在运行时动态的调用对象的方法和属性,官方自带的reflect
包就是反射相关的,只要包含这个包就可以使用。
多插一句,Golang的gRPC
也是通过反射实现的。
9.2、interface 和 反射
在讲反射之前,先来看看Golang关于类型设计的一些原则
- 变量包括
(type, value)
两部分 type
包括static type
和concrete type
. 简单来说static type
是你在编码是看见的类型(如int
、string
),concrete type
是runtime
系统看见的类型- 类型断言能否成功,取决于变量的
concrete type
,而不是static type
. 因此,一个reader
变量如果它的concrete type
也实现了write
方法的话,它也可以被类型断言为writer
.
接下来要讲的反射,就是建立在类型之上的,Golang的指定类型的变量的类型是静态的(也就是指定int、string这些的变量,它的type是static type
),在创建变量的时候就已经确定,反射主要与Golang的interface
类型相关(它的type是concrete type
),只有interface
类型才有反射一说。
在Golang的实现中,每个interface
变量都有一个对应pair
,pair
中记录了实际变量的类型和值:
1 | <type, value> // pair |

type
是实际变量的类型, value
是实际变量值。一个interface{}
类型的变量包含了2个指针,一个指针指向值的类型【对应concrete type
】,另外一个指针指向实际的值【对应value
】。
例如,创建类型为*os.File
的变量,然后将其赋给一个接口变量r
:
1 | tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) |
接口变量r
的pair
中将记录如下信息:<value: tty, type: *os.File>
,这个pair
在接口变量的连续赋值过程中是不变的,将接口变量r
赋给另一个接口变量w
:
1 | var w io.Writer |
接口变量w
的pair
与r
的pair
相同,都是:<value: tty, type: *os.File>
,即使w
是空接口类型,pair
也是不变的。
interface
及其pair
的存在,是Golang中实现反射的前提,理解了pair
,就更容易理解反射。反射就是用来检测存储在接口变量内部(值value
;类型concrete type
) pair
对的一种机制。
1 | package main |
再比如:
1 | package main |
9.3、reflect
reflect的基本功能TypeOf和ValueOf
既然反射就是用来检测存储在接口变量内部(值value;类型concrete type) pair对的一种机制。那么在Golang的reflect反射包中有什么样的方式可以让我们直接获取到变量内部的信息呢? 它提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf(),看看官方的解释
1 | // ValueOf returns a new Value initialized to the concrete value |
reflect.TypeOf()是获取pair中的type,reflect.ValueOf()获取pair中的value,示例如下:
1 | package main |
说明
- reflect.TypeOf: 直接给到了我们想要的type类型,如float64、int、各种pointer、struct 等等真实的类型
- reflect.ValueOf:直接给到了我们想要的具体的值,如1.2345这个具体数值,或者类似&{1 “Allen.Wu” 25} 这样的结构体struct的值
- 也就是说明反射可以将“接口类型变量”转换为“反射类型对象”,反射类型指的是reflect.Type和reflect.Value这两种
从relfect.Value中获取接口interface的信息
当执行reflect.ValueOf(interface)之后,就得到了一个类型为”relfect.Value”变量,可以通过它本身的Interface()方法获得接口变量的真实内容,然后可以通过类型判断进行转换,转换为原有真实类型。不过,我们可能是已知原有类型,也有可能是未知原有类型,因此,下面分两种情况进行说明。
已知原有类型【进行“强制转换”】
已知类型后转换为其对应的类型的做法如下,直接通过Interface方法然后强制转换,如下:
1 | realValue := value.Interface().(已知的类型) |
示例如下:
1 | package main |
说明
- 转换的时候,如果转换的类型不完全符合,则直接panic,类型要求非常严格!
- 转换的时候,要区分是指针还是指
- 也就是说反射可以将“反射类型对象”再重新转换为“接口类型变量”
未知原有类型【遍历探测其Filed】
很多情况下,我们可能并不知道其具体类型,那么这个时候,该如何做呢?需要我们进行遍历探测其Filed来得知,示例如下:
1 | package main |
说明
通过运行结果可以得知获取未知类型的interface的具体变量及其类型的步骤为:
- 先获取interface的reflect.Type,然后通过NumField进行遍历
- 再通过reflect.Type的Field获取其Field
- 最后通过Field的Interface()得到对应的value
通过运行结果可以得知获取未知类型的interface的所属方法(函数)的步骤为:
- 先获取interface的reflect.Type,然后通过NumMethod进行遍历
- 再分别通过reflect.Type的Method获取对应的真实的方法(函数)
- 最后对结果取其Name和Type得知具体的方法名
- 也就是说反射可以将“反射类型对象”再重新转换为“接口类型变量”
- struct 或者 struct 的嵌套都是一样的判断处理方式
9.3.1、通过reflect.Value设置实际变量的值
reflect.Value是通过reflect.ValueOf(X)获得的,只有当X是指针的时候,才可以通过reflec.Value修改实际变量X的值,即:要修改反射类型的对象就一定要保证其值是“addressable”的。
示例如下:
1 | package main |
说明
- 需要传入的参数是* float64这个指针,然后可以通过pointer.Elem()去获取所指向的Value,注意一定要是指针。
- 如果传入的参数不是指针,而是变量,那么
○ 通过Elem获取原始值对应的对象则直接panic
○ 通过CanSet方法查询是否可以设置返回false - newValue.CantSet()表示是否可以重新设置其值,如果输出的是true则可修改,否则不能修改,修改完之后再进行打印发现真的已经修改了。
- reflect.Value.Elem() 表示获取原始值对应的反射对象,只有原始对象才能修改,当前反射对象是不能修改的
- 也就是说如果要修改反射类型对象,其值必须是“addressable”【对应的要传入的是指针,同时要通过Elem方法获取原始值对应的反射对象】
- struct 或者 struct 的嵌套都是一样的判断处理方式
9.3.2、通过reflect.ValueOf来进行方法的调用
这算是一个高级用法了,前面我们只说到对类型、变量的几种反射的用法,包括如何获取其值、其类型、如果重新设置新值。但是在工程应用中,另外一个常用并且属于高级的用法,就是通过reflect来进行方法【函数】的调用。比如我们要做框架工程的时候,需要可以随意扩展方法,或者说用户可以自定义方法,那么我们通过什么手段来扩展让用户能够自定义呢?关键点在于用户的自定义方法是未可知的,因此我们可以通过reflect来搞定
示例如下:
1 | package main |
说明
- 要通过反射来调用起对应的方法,必须要先通过reflect.ValueOf(interface)来获取到reflect.Value,得到“反射类型对象”后才能做下一步处理
- reflect.Value.MethodByName这.MethodByName,需要指定准确真实的方法名字,如果错误将直接panic,MethodByName返回一个函数值对应的reflect.Value方法的名字。
- []reflect.Value,这个是最终需要调用的方法的参数,可以没有或者一个或者多个,根据实际参数来定。
- reflect.Value的 Call 这个方法,这个方法将最终调用真实的方法,参数务必保持一致,如果reflect.Value’Kind不是一个方法,那么将直接panic。
- 本来可以用u.ReflectCallFuncXXX直接调用的,但是如果要通过反射,那么首先要将方法注册,也就是MethodByName,然后通过反射调用methodValue.Call
9.4、Golang的反射reflect性能
Golang的反射很慢,这个和它的API设计有关。在 java 里面,我们一般使用反射都是这样来弄的。
1 | Field field = clazz.getField("hello"); |
这个取得的反射对象类型是 java.lang.reflect.Field。它是可以复用的。只要传入不同的obj,就可以取得这个obj上对应的 field。
但是Golang的反射不是这样设计的:
1 | type_ := reflect.TypeOf(obj) |
这里取出来的 field 对象是 reflect.StructField 类型,但是它没有办法用来取得对应对象上的值。如果要取值,得用另外一套对object,而不是type的反射
1 | type_ := reflect.ValueOf(obj) |
这里取出来的 fieldValue 类型是 reflect.Value,它是一个具体的值,而不是一个可复用的反射对象了,每次反射都需要malloc这个reflect.Value结构体,并且还涉及到GC。
Golang reflect慢主要有两个原因
- 涉及到内存分配以及后续的GC;
- reflect实现里面有大量的枚举,也就是for循环,比如类型之类的.
9.5、总结
上述详细说明了Golang的反射reflect的各种功能和用法,都附带有相应的示例,相信能够在工程应用中进行相应实践,总结一下就是:
- 反射可以大大提高程序的灵活性,使得interface{}有更大的发挥余地
- 反射必须结合interface才玩得转
- 变量的type要是concrete type的(也就是interface变量)才有反射一说
- 反射可以将“接口类型变量”转换为“反射类型对象”
- 反射使用 TypeOf 和 ValueOf 函数从接口中获取目标对象信息
- 反射可以将“反射类型对象”转换为“接口类型变量
- reflect.value.Interface().(已知的类型)
- 遍历reflect.Type的Field获取其Field
- 反射可以修改反射类型对象,但是其值必须是“addressable”
- 想要利用反射修改对象状态,前提是 interface.data 是 settable,即 pointer-interface
- 通过反射可以“动态”调用方法
- 因为Golang本身不支持模板,因此在以往需要使用模板的场景下往往就需要使用反射(reflect)来实现
9.6、反射的基本原理

10、其他
10.1、defer关键字
defer关键字通常用于清洁目的。 defer关键字将函数或语句的执行推迟到调用函数的结尾。 [8]
实际作用再封闭函数在右花括号}
后面,晚于return执行。如果在执行封闭功能期间发生错误,也同样不影响该命令最终的执行。
总结:
如果一个函数中有多个defer语句,它们会以LIFO(后进先出)的顺序执行。
defer会在return之后执行。
如果在执行封闭功能期间发生错误,也同样不影响该命令最终的执行。
1 | package main |
10.2、错误处理
10.2.1、fmt.Errorf( )
fmt.Errorf()[官方] 根据格式说明符进行格式化,并将字符串作为满足错误的值返回。
1 | package main |
如果想在报错时直接退出程序, 不如直接使用 log.Fatalf()
10.2.2、log.Fatalf( )
log.Fatalf[官方] 等价于 Print(),然后调用 os.Exit(1)。
10.2.3、os.IsExist 与 os.IsNotExist
os.IsNotExist 不等于 !os.IsExist[9]; 虽是乍一看好像是互补的两个函数, 但是它们的功能完全不同, 看下面解释即可了解全貌:
假设您正在尝试创建一个文件。您期望的行为是文件不存在,因此如果文件存在,则会引发相应的错误,您可以使用 os.IsExist 来检查它。
相反,os.IsNotExist 检查文件不存在时可能发生的错误。
10.3、Go modules
10.3.1、介绍
Go modules 是 Go 语言的依赖解决方案,发布于 Go1.11,成长于 Go1.12,丰富于 Go1.13,正式于 Go1.14 推荐在生产上使用。
Go moudles 目前集成在 Go 的工具链中,只要安装了 Go,自然而然也就可以使用 Go moudles 了,而 Go modules 的出现也解决了在 Go1.11 前的几个常见争议问题:
- Go 语言长久以来的依赖管理问题。
- “淘汰”现有的 GOPATH 的使用模式。
- 统一社区中的其它的依赖管理工具(提供迁移功能)。
GOPATH已被Gomodules淘汰,故本文不再对其做过多解释: 只需要知道, GOPATH时代常用的go get
命令在GoModules的加持下,变得更加好用了(即 若项目是通过go mod init
初始化后的, 则在执行go get
时,会自动配置两个文件go.mod和go.sum文件
的内容, 这两个文件(go.mod和go.sum文件
)用于管理当前项目的所有依赖)。
可以说,现在如果使用Go语言(从1.14版本起), 就离不开GoModules。
10.3.2、go mod 命令
命令 | 作用 |
---|---|
go mod init | 生成 go.mod 文件 |
go mod download | 下载 go.mod 文件中指明的所有依赖 |
go mod tidy | 整理现有的依赖, 添加新的依赖, 删除未使用的依赖 |
go mod graph | 查看现有的依赖结构 |
go mod edit | 编辑 go.mod 文件 |
go mod vendor | 导出项目所有的依赖到vendor目录 |
go mod verify | 校验一个模块是否被篡改过 |
go mod why | 查看为什么需要依赖某模块 |
其中用到最多的就是go mod init
这个命令了。其次是go mod tidy
。同时我们可以使用go help mod
调出上述表格内容, 查看帮助信息。
10.3.3、go mod的相关环境变量
可以通过go env
命令来进行查看
1 | $ go env |
对于每个变量的作用不再进行详细的说明, 这里只介绍我们需要做的设置以及为什么这样设置:
GO111MODULE
-> 是否开启go modules模式- 建议go V1.14之后, 都设置成on
- 命令
go env -w GO111MODULE=on
- 还可以在用户脚本中该配置文件, 在文件底部添加
export GO111MODULE=on
- 命令
- 建议go V1.14之后, 都设置成on
GOPROXY
-> 项目的第三方依赖库的下载源地址,默认是https://proxy.golang.org,direct
(需代理)- 建议设置为国内地址
- 七牛云 ->
https://goproxy.cn,direct
- 阿里云 ->
https://mirrors.aliyun.com/goproxy/,direct
- 七牛云 ->
direct
-> 此参数用于指示Go在设置地址中找不到模块时,自动回源到模块版本的源地址去抓取(比如 GitHub 等)
- 建议设置为国内地址
GOSUMDB
-> 用来校验拉取的第三方库是否是完整的- 默认也是国外的网站, 如果设置了
GOPROXY
, 此项就不用设置了
- 默认也是国外的网站, 如果设置了
GONOPROXY
-> 代表私有依赖库的下载地址, 通过设置GOPRIVATE
即可。GONOSUMDB
-> 代表私有依赖库的校验地址, 通过设置GOPRIVATE
即可。GOPRIVATE
-> 代表私有依赖库地址,”对于用到私有依赖库的项目,配置此项即可”(不用去配置GONOSUMDB
和GONOPROXY
)go env -w GOPRIVATE="git.example.com,github.com/eddycjy/mquote"
- 依赖库的值是一个以英文逗号 “,” 分割的模块路径前缀,也就是可以设置多个
- 即 git.example.com 和 github.com/eddycjy/mquote 是我们设置的两个私有仓库; 故这两个私有的依赖不会进行
GOPROXY
地址的下载和校验
- 即 git.example.com 和 github.com/eddycjy/mquote 是我们设置的两个私有仓库; 故这两个私有的依赖不会进行
- 如果不想每次都重新设置,我们也可以利用通配符,例如:
go env -w GOPRIVATE="*.example.com"
- 依赖库的值是一个以英文逗号 “,” 分割的模块路径前缀,也就是可以设置多个
10.3.4、初始化项目
命令:
go mod init 包名称
“包名称”用于项目代码文件的import使用 -> 当前项目中所有本地”package名”, 需要文件之间互相依赖调用时,需要在书写代码前这样引入模块 import "包名称"/"package名"
- 如果你的项目是开源项目,一般将”包名称”设置成当前项目所在的github仓库地址, 如
github.com/用户名/仓库名
- 如果你的项目是私有的, 一般将”包名称”设置成当前项目的”自定义项目名称”
- 如果你的项目名称没有想好, 可随意设置”包名称”, 一般同项目根目录名。 可在想好名称后,删除原有的
go.mod以及so.sum
文件, 重新使用go mod init 包名称
设置项目名。
go mod init
命令会在项目中生成go.mod文件来自动管理项目依赖。(自动管理依赖的文件有两个go.mod
、go.sum
,第二个在初次go get
时生成)
已初始化的项目可以使用 go get
命令自动配置相关依赖。 -> 使用后, 会自动对go.mod文件以及go.sum文件做相应配置。(如果没有go.sum文件,会自动生成并配置)
10.4、网络编程
10.4.1、介绍
有网络编程基础的同志直接跳过即可
主要的形式为套接字(socket)编程, 当前的网络时代,可以说网络中的进程通信无处不在”一切皆socket”。
常用的 Socket 类型有两种:流式Socket(SOCK_STREAM)和 数据报式 Socket(SOCK_DGRAM)。
无论那种语言, 都会去遵从协议标准来实现这两种主流的套接字(Socket)通信, “其中TCP所属类型为面向连接的流式套接字“, “UDP所属类型为无连接的数据报式套接字“。
不管是TCP还是UDP, 在现有的体系下, 往往被狭义分地为客户端(Client)和服务端(Server),就是我们常说的C/S。近些年,基于Http协议的B/S架构服务器已渐渐成为主流, 而Http协议就是基于TCP协议之上的应用层协议
有兴趣的朋友可以按照协议标准,自己动手基于TCP实现一个HTTP协议的web服务端框架,来作编程训练哈。 现阶段,无论哪种编程语言, 都已经有非常成熟的HTTP框架供大家使用了。
浏览器(一个承载http协议的TCP客户端)的 前端(web端),即为大前端( 随着硬件性能瓶颈的突破, 其浏览器自身的天然跨平台优势发挥的淋漓尽致, 除去少数对客户端性能有要求的场景, 几乎一切场景都可以简单的通过web端来满足, 而伴随着chrome浏览器内核魔改的nodejs发布,js也可以写服务端代码了,甚至这种通过浏览器内核映射的部分宿主操作系统API,使得js也可以直接打包客户端程序, 总之这一系列技术支撑, 使得个人全栈门槛大大降低, 也正式拉开了大前端的时代序幕 )
TCP由于需要建立连接, 因此Server端往往需要在绑定Listen后先建立连接, 成功accept连接后, 开开始真正地收发通信数据, 随后地通信过程没有必要时刻包含用于回发的addr信息。
- server端, 需要有固定的服务的
Listen
来实时监听将要到来的连接请求, 并在按照协议完成三次握手后,accept
这个连接 ( c语言中会返回代表连接通信可以收发数据的文件描述符”fd”< type: int >, go语言中会返回代表连接通信可以收发数据的”conn”< type: net.Conn > ) - client端, 只需要提供需要请求的服务器addr信息, 即可选择对应协议的socket来发起连接请求, 获得 “conn” < type: net.Conn >
- server端, 需要有固定的服务的
UDP由于无连接地数据报特性,因此Server端往往在绑定addr后直接Listen监听通信数据, 其直接Listen得到的 “conn” < type: net.Conn >并非一个固定连接的”conn”, 因此其中包含有对端(Client)的addr信息, 我们可以基于此信息回发数据。
- server端, 需要有固定的服务的
Listen
来实时监听将要到来的数据信息, 并返回承载此信息的 “conn”< type: net.UDPConn > ), 此信息中含有对端(Client)的addr信息,用于回发 - client端, 只需要提供需要请求的服务器addr信息, 即可选择对应协议的socket来发起连接请求, 获得 “conn” < type: net.UDPConn >
- server端, 需要有固定的服务的
10.4.2、go语言网络编程示例
go语言的套接字通信中(无论是TCP还是UDP), 拿到conn后, 就可以去处理相关业务逻辑了。
这些业务逻辑的代码, 在TCP中, 常常被我们工程师自定义封装到一个业务处理函数”handler(conn net.Conn)”中, 交给其他线程(go程)去并发处理(往往新建一个go程, 或直接扔给已有线程池, 需具体问题具体抉择)。
一般为了降低新go程的创建与GC损耗, 高并发的场景中常常利用对应的池化复用技术来解决(线程池)
c语言中往往需要自己写线程池,或是找到其他人分享的开源线程池代码, 而go中有一个转移对象池类,官方的解决了这个需求
TCP 拿到的conn 是一个个固定连接的conn, 通过并发机制来面向此连接收发数据即可。
server TCP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// 下面是伪代码
func main(){
listener, err := net.Listen("tcp", "ip:port")
for{
conn, err := listener.Accept()
go process(conn)
}
}
func process(conn net.Conn) {
defer conn.Close()
for {
if n, err := conn.Read(buf[:]); err != nil{
fmt.Printf("read from connect failed, err: %v\n", err)
break
}
// ...
// 业务代码
// ...
if _,err = conn.Write([]byte("Send From Server")); err != nil{
fmt.Printf("write to client failed, err: %v\n", err)
break
}
}
}
// 当然,TCP的 conn 也是可以获取对端地址信息的。
Addr := conn.RemoteAddr().String()client TCP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 下面是伪代码
conn, err := net.Dial("tcp", "ServerIP:port")
defer conn.Close()
for{
if _, err = conn.Write([]byte("要发送的数据")); err != nil {
fmt.Printf("write failed , err : %v\n", err)
break
var recvData = make([]byte, 1024)
if _, err = conn.Read(recvData); err != nil{
fmt.Printf("Read failed , err : %v\n", err)
break
}
}
}
UDP 拿到的conn 是一个无固定连接的conn, 但伴随其绑定端口接收的数据中, 会带有发送者的addr信息, 回发时填入对应的addr信息即可针对性回发。对于短消息,数据报机制天生无需考虑并发,单个循环足够; 若消息处理需要涉及时间过长的运算,就会造成回发前的阻塞时间过长, 此时可以考虑建立多个UDPConn,来并发处理,或直接将此业务改用TCP。
server UDP
udp常用server
1
2
3
4
5
6
7
8
9
10
11
12
13// 下面是伪代码
addr, err := net.ResolveUDPAddr("udp", "Ip:Port")
conn, err := net.ListenUDP("udp", addr) // 第二个参数为, 上一步解析出的addr, 返回的conn为单个带有addr的 net.UDPAddr 数据报, 处理此绑定端口下的所有消息。
defer conn.Close()
for{
data := make([]byte, 1024)
_, rAddr, err := conn.ReadFromUDP(data)
//...
// 业务代码
//...
_, err = conn.WriteToUDP([]byte("回发的数据"), rAddr)
}udp多线程并发server不作举例, 建议直接上Tcp
client UDP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 下面是伪代码
conn, err := net.Dial("udp", "serverIP:Port")
defer conn.Close()
for{
if _, err := conn.Write([]byte("需要发送给服务端的数据")); err != nil{
fmt.Println("Wrtie err =", err)
return
}
data := make([]byte, 1024)
if _, err = conn.Read(data); err != nil{
fmt.Println("Read err =", err)
return
}
}
10.5、go语言安装
10.5.1、 Linux系统中如何安装
下载
通过WinScp(或其它ftp工具), 将文件移动到 Linux 服务器中。
进入对应目录, 使用此命令解压
tar -zxvf go1.21.6.linux-amd64.tar.gz -C /usr/local/
-C 后跟的是解压到的目录
在 家目录 下, 编辑 .bashrc 文件, 在其末尾添加如下信息:
1
2
3
4
5
6
7# 设置Go语言的路径
export GOROOT=/usr/local/go
export GOPATH=$HOME/go
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin
# 启动Go Modules
export GO111MODULE=on使用 source .bashrc 来刷新配置
使用 go version 检查是否配置成功
使用 go env -w GOPROXY=”https://goproxy.cn,direct" 将包代理配置成国内的七牛云。
自此, go语言就安装完毕了
10.5.1、 Win系统中如何安装以及更新
安装: 直接在官网下载安装包即可, 全部按照默认安装完毕即可。
更新: 直接在官网下载最新版本(或你需要版本)的安装包即可, 安装过程中会提示你选择卸载已有的go版本, 选择卸载就好, 之后依旧是全部选择默认。
10.6、 go语言中获取环境变量
在 Go 语言中,os.Getenv
函数会按照以下顺序查找环境变量:
- 首先,它会检查当前进程的环境变量。如果在当前进程中设置了该环境变量(即通过
os.Setenv
设置的值),os.Getenv
将返回该值。 - 如果当前进程中没有设置该环境变量,它会继续检查用户级别的环境变量。如果在用户级别设置了该环境变量,
os.Getenv
将返回该值。 - 如果在用户级别也没有设置该环境变量,它会继续检查系统级别的环境变量。如果在系统级别设置了该环境变量,
os.Getenv
将返回该值。 - 如果在系统级别和用户级别都没有设置该环境变量,
os.Getenv
将返回一个空字符串。
因此,如果你在 Go 项目中使用了 .env
文件来存储环境变量,那么它的优先级会低于 os.Setenv
函数设置的环境变量,但高于系统级别和用户级别的环境变量。
注意 .env 文件需要使用 godotenv 这个项目库的Load()函数来加载使用(“github.com/joho/godotenv” 项目 已验证安全)
1
2
3
4
5
6
7
8 import "github.com/joho/godotenv"
// 从 .env 文件中读取环境变量
err := godotenv.Load()
if err != nil {
fmt.Println("无法加载 .env 文件:", err)
return
}
10.7、 go语言中的日志库
在go1.21之前, 我们往往使用zap 或 zerolog 等第三方日志库, 虽然不是官方的, 但是它们也是足够优秀的。
如zerolog在性能方面无语伦比, 甚至1.21版本后的go官方slog库, 都没有zerolog的性能高, zerolog要快slog大约2倍。
如slog库, 主要就是借鉴zap来开发的, 旨在比zap更简洁的api, 以及更高的性能, 并且slog也确实做到了这两点。
目前, 我的项目已经全部改用slog, 下面说下使用。
引入
1 | // logger/logger.go |
使用
1 | // otherfile.go |
带 文件 以及 行数等详细信息的使用方式如下:
可能带来一定的性能损耗, 但是这个相比于带来的好处完全可以忽略不计:
1 | package main |
因此, 我们可以自定义我们的slog输出如下:
1 | // 自定义的 Debug 函数 |
如果对应Info和Debug, 你不想让其在生产环境中打印的信息太详细, 可以进一步通过环境变量来控制
本人不在乎这点性能损耗, 更想要详细的信息。因此一般不怎么用这种方式。
1 | // 自定义的 Info 函数 |
在 slog 中,日志级别主要有以下几种:
请注意,slog 默认的日志级别是 Info,也就是说,默认情况下,Debug 级别的日志信息是不会被输出的。如果你想改变日志级别,你需要自定义 Handler 来实现日志级别的判断。
此外,slog 不支持 Fatal API,也就是说,你不能使用 slog 来终止进程。 比如, 可以将其配合 os.Exit(1)来实现Error输出后, 直接终止进程这一需求。
- Debug:用于输出调试信息。这是最低的日志级别,会输出所有日志信息。
- Info:用于输出一般信息。这是默认的日志级别,只会输出 Info 级别及以上的日志信息。
- Warn:用于输出警告信息。会输出 Warn 级别及以上的日志信息。
- Error:用于输出错误信息。会输出 Error 级别及以上的日志信息。
我们可以自行指定所需要的日志级别:
1 | // logger.go |
或
1 | // logger.go |
11、go开发零碎
11.1、引用第三方库时, 若遇到bug, 如何使用pr中未被作者合入主分支的修复
当我们做开发时, 难免引入一些第三方库, 那么当这些库的维护者最近刚好再忙, 而你刚好发现这个库的一些问题, 且这个问题刚好被其它人通过pr修复了, 只是还未合入主分支的情况下。我们如何在其合入主分支前, 利用这个修复bug的pr, 来从本地解决遇到的问题呢。
如果仅是测试这个pr或是暂时使用, 没想要协助维护反馈上游的话, 没必要在github上创建此库的Fork仓库,我们只需要将这个仓库克隆下来, 本地使用即可。
本地解决
步骤如下:
克隆存储库:
如果你还没有克隆存储库,可以使用以下命令:1
2git clone <repository_url>
cd <repository_directory>获取PR的分支:
找到PR的分支名称,然后切换到该分支。你可以在GitHub上的PR页面找到分支名称。使用以下命令切换到PR分支:
1
2git fetch origin pull/<PR_number>/head:<branch_name>
git checkout <branch_name>在你的项目中使用本地库:
在你的项目中,修改
go.mod
文件,将依赖指向你本地的库(一般添加在go.mod文件的末尾)。例如:1
replace github.com/username/repository => path_to_local_repository
注意:
- 此路径最好用系统的绝对路径。
- linux和win上, 对于路径的使用格式在此处需要区分。
如在win上, /d/safe/beep/beep是错误的路径, 正确的路径应该使用 D:/safe/beep/beep
更新依赖:
在你的项目根目录下运行以下命令,以确保你的项目使用的是本地库的最新版本:
1
go mod tidy
测试更改:
运行你的项目,看看PR的更改是否解决了你的问题。你可以运行项目中的测试,或者手动检查功能是否正常。
提交反馈:
如果PR解决了你的问题,可以在GitHub上给PR作者提供反馈,感谢他们的帮助。如果还有问题,可以在PR页面上继续讨论。
当项目方的主分支完成合并后, 重新依赖项目方仓库
是的,你的import
文件不需要修改,只需要修改go.mod
文件即可。
当作者合并了这个PR后,你可以按照以下步骤切换回官方主分支:
移除
replace
指令:将这一行删除。 或注释掉。
更新依赖:
在你的项目根目录下运行以下命令,以确保你的项目使用的是官方主分支的最新版本:
1
go mod tidy
验证更改:
运行你的项目,确保一切正常。
这样,你的项目就会使用官方主分支的最新版本。
参考
声明: 本篇是多篇文章的转载的整合文, 无法单一标明链接。 故本站按奉行协议, 在此标明所有的内容来源链接。
在本篇的整合过程中,所发现不完美的地方,均会按自认更深层次的理解及叙述对其做出修复重构。整合之外,也有一些自己的原创内容(通篇占比不高于30%)。
非商业化转载,请附上本文链接 及下方的 参考链接。商业话转载, 请自行自行考究下方参考链接中,内容的原始出处, 并与对应内容的原作者沟通版权授予义务。
[1] Go语言编程规范
[3] go的指针跟c的指针区别
[5] Go 语言教程 | 菜鸟教程
[7] Go 结构体标签
[8] Go 基础教程 | 无涯教程
[9] Golang Error Checkers: os.IsExist vs os.IsNotExist
[10] 8小时转职Golang工程师
[11] Go 语言教程 | 嗨客网