range
range是go中的一种循环,被广泛应用于slice,map,channel的遍历中,但是很多时候,它运行的结果似乎和我们预期不一样。
下面是一个例子:
go
v := []int{1, 2, 3}
for _, value := range v {
v = append(v, value)
}
fmt.Println(v)//[1 2 3 1 2 3]
你可能会觉得这个程序无法停下来,因为在遍历的过程中不断向其中添加元素,实际上是不会的,range的底层是一个for循环,for的次数在range执行的那一刻就被确定了,下面是go编译器的slice range源码中的注释
ini
for_temp := range
len_temp := len(for_temp)
for index_temp = 0; index_temp < len_temp; index_temp++ {
value_temp = for_temp[index_temp]
index = index_temp
value = value_temp
original body
}
再来一个例子:
go
items := []Item{Item{1}, Item{2}, Item{3}}
var all []*Item
for _, item := range items {
all = append(all, &item)
}
fmt.Println(all)
for _, item := range all {
fmt.Println(item.value)
}
//Output:
//[0xc00001e088 0xc00001e088 0xc00001e088]
//3
//3
//3
你可能觉得all中应该是1,2,3,实际上不是的,因为range中使用的item实际上每次变量都是同一个,只不过这个item被多次赋值了而已,底层没有每次循环都开辟一个新的空间,因此获取的item的地址实际上是同一个item.
最后一个例子:
go
var prints []func()
for _, v := range []int{1, 2, 3} {
prints = append(prints, func() { fmt.Println(v) })
}
for _, print := range prints {
print()
}
//Output:
//3
//3
//3
你可能会奇怪为什么不是1,2,3,实际上这也涉及到一点闭包的知识,在闭包中如果直接使用闭包外的值,实际上闭包会捕获这个值,在闭包中对这个值的修改会直接影响到闭包外的变量,也就是说闭包获得了v的地址,而range中使用的v实际上都是同一个,自然最后都是输出3.
但以上问题都可以在使用中通过下面这样的方式解决:
go
for _,v:=range []int{1,2,3}{
v:=v
//Todo something on v
}
以上代码所在的工作就是声明了一个局部的v,并进行了重新赋值,本质是声明一个局部变量覆盖外层的v,外面的for那一行的v实际上是和for所在的区域属于同一个作用域,只声明一次,被反复赋值。
slice
切片区别于数组,数组的长度固定,不可改变,切片可以通过内存分配的方式改变其长度,切片可以简单理解成一个数组的指针。
其"/src/runtime/slice.go"中定义的底层结构是这样的:
go
type slice struct {
array unsafe.Pointer
len int
cap int
}
显然,array是一个指向了底层的数组的指针,len表示当前切片的长度,cap表示切片的容量。
切片创建
- 引用自数组
go
var array [10]int slice := array[1:8]
fmt.Println(cap(slice))//9
slice1 := array[5:6:7]
fmt.Println(cap(slice1))//2
array[start:end]方式获取切片slice的默认容量是从start开始直至array末尾 array[start:end:max],max表示切片的容量结束位置,start<=end<=max
- make
go
s := make([]int, 2) fmt.Println(s) // [0 0]
fmt.Println(cap(s)) //2
s1 := make([]int, 2, 3)
fmt.Println(s1) // [0 0]
fmt.Println(cap(s1)) //3
make分配内存时可以指定len和cap,当不指定cap时,cap=len
- append
go
s2 := append([]int{}, 2, 3)
fmt.Println(s2) // [0 0]
fmt.Println(cap(s2)) //3
append会在已有slice的基础上追加一些元素,返回一个新的slice.
也可以使用缺省参数'...'合并两个slice生成一个新的slice
go
s3 := []int{1, 2, 3}
s2 := append([]int{}, s3...)
新slice的cap遵循其扩容策略
扩容策略
我们通常熟知的扩容策略是以1024为界,cap小于1024的时候,每次扩容是newCap=oldCap*2,但是当cap不小于1024的时候,扩容是newCap=oldCap*1.25.
但是在go 1.18之后,发生了改变,go 1.18 slice不再以1024为界,新的扩容机制相对比较复杂,我们可以查看下go中关于slice的cap相关的源码:
go
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < newLen {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = newLen
}
}
}
我们可以发现,它实际上是分成三种情况的:
- 当新的长度newLen大于原来的oldCap*2时,newCap=newLen
- 当oldCap<256时,newCap=OldCap*2
- 当oldCap>=256时,执行循环,在oldCap的基础上每次加上(oldCap+3*256)/4,直到newCap>=newLen.
显然,新的扩容机制以256为基准,实现了一种更为平滑的扩容方式。
Module和包管理
引用自己远程仓库的代码
想要引用自己远程仓库,如github,gitee的代码,需要注意以下几点。
- go.mod中定义的模块名是github.com/<your repository>/<your module>的格式,所以我们也建议大家模块命名最好也是这个格式,可以方便自己的代码复用
- 代码仓库的代码是公开的
- 默认只能get上一次的commit,如果想get最新的一次commit,可以配合git tag和go.mod修改版本进行手动拉取。
Golang中的相对路径
Golang中的相对路径不是固定的,会根据不同情况发生变化,有可能启动的命令位置不同,相对路径的起始点也会发生变化。
一般情况下,golang的代码中文件操作使用的相对路径起始点是go.mod的位置。此时,我们一般都是直接在go.mod所在目录下使用命令
go
go run .
或者
go run main.go
或者
go run ./cmd/server
或者
go run ./cmd/server/main.go
在以上情况下一般相对路径起始点都是go.mod所在位置,不会出错。
但是也有一种情况会是main.go所在目录才是相对路径起始点。
如果我们执行命令
bash
cd cmd/server
切换到main函数所在目录,然后执行
go
go run main.go
或者
go run .
这时候相对路径的起始点会变成main.go所在目录。
另外在我们执行测试函数的时候,相对路径的起始点会变成测试文件所在的目录。
Const 常量枚举
首先,我们都知道在像java,rust中都是有类似enum这样的关键字可以声明枚举变量的,那golang有没有枚举呢?答案是有的,但是golang的枚举是通过常量来实现的。
常量声明(枚举)
go
// 常量声明行
const MyConst =0
const MyConst int32= 0//指定类型
// 常量声明块
const (
Animal = "animal"
)
const (
a = iota //0
b //1
c //2
d //3
)
// 统一值缺省
const (
a1 = 1 //1
b1 //1
c1 //1
d1 //1
)
//常规的枚举方式,type <枚举变量> int
type Week int
const (
Monday Week = iota + 1 //1
Tuesday //2
Wednesday //3
Thursday //4
Friday //5
Saturday //6
Sunday //7
)
//常量枚举的缺省
const (
aaa =iota//0
bbb //1
_ //2
ccc //3
)
// 1<<iota声明状态枚举(Bit状态)
const (
state1 =1<<iota//1
state2 //2
state3 //4
state4 //8
)
从以上示例代码可以看出,常量的声明使用const关键字,和import一样,分为单行的常量声明和常量块的声明,常量块的声明本质是一个常量的数组。
而在golang中枚举主要是通过const配合iota实现的,并且要保证其变量必须是整数类型,最常见的做法是通过type声明一个新的int自定义类型,然后用const的iota枚举这个自定义类型,time包中的Week,Month就是采用的该种方式进行的枚举
另外还有一个比较常见的做法是使用1<<iota进行枚举声明,这样的好处就是每一个枚举都代表整形上的 一个bit位为1,其余都为0的情况,这样可以配合位运算进行多状态的管理,sync包中的Mutex互斥锁的状态控制就是采取该种方式。
在这里有一个需要注意的点,type Week int之后,Week类型的变量和int类型的变量不可以使用'=='进行比较,哪怕他们的底层类型是相同的。所以Monday==1是错误的,会报错。
iota
这个关键字的本质是获得常量块中的每一条语句对应的行号(数组下标),这个下标是从0开始的,因为const中iota的源码可以得知,iota本质就是在对const块进行遍历的时候获得的index,类似于for index,value range collection中的index,它会把这个index赋值给iota所在的变量。
玩转位运算
golang中有6种位运算操作符(拆解一下的话,实际只有5种,第六种实际是&和^的组合,&^自然也可以拆解成&和^),分别如下:
操作符 | 含义 | 示例 | 特点 |
---|---|---|---|
& | 与 | 0b_0100_1011 & 0b_0000_0111 //0b_0000_0011 //相当于选中最后三位,其余置0 |
11为1,其余为0,一个数&另一个数,可以选中指定位的值进行接下来的操作,有点像select语句,第二个数用于表示选中哪些值。 |
| | 或 | `0b_0101 | 0b_0010 //0b_0111` |
^ | 异或 | 0b_0011 ^ 0b_0010 //0b_0001 |
不同的位为1,能够两个数判断哪些位存在差异,通用一个数是基准数,另一个数是需要比较的数.也可以用于指定位取反 |
<< | 逻辑左移 | 0b_0011 << 1 //0b_0110 |
左移,右补0 |
>> | 算术右移 | a=0b_1000 a=a>>1 //0b_1100 0b_0010 >> 1 //0b_0001 |
带符号的左移,正数,左补0,负数,左补1 |
&^ | 位清除 | 0b_1111 &^ 0b_0101 //0b_1010 |
清楚左边的数对应右边的数为1的位,使其归0 |
位运算之状态管理
Golang中的状态,通常大家更多的叫做flag,这里我习惯于叫做state,状态管理更多第是配合golang中的枚举使用,但是得保证枚举值一定是整数类型。
假设我们定义了一个int32类型的枚举,用1<<iota生成每一个枚举,我们会发现每一种枚举值都正好代表int32的32位上的某一位为1,其余位为0的情况,所以实际上,一个int32类型可以表示32种状态(如果每一位都表示一种状态的话)
下面我们以sync.Mutex的state属性为例,来学习一下如何使用位运算来实现状态管理的。
go
type state int32
const(
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
)
我们主要看mutexLocked、mutexWoken、mutexStarving这三个状态,,mutexWaiterShift的作用是将state的前3位表示状态,但是它又不想浪费剩余的位数,就将剩余的位数用来计数,统计当前waiter,也就是排队的goroutine的数量。
接下来我们看看mutex中涉及的一些和这三个状态相关的位运算操作。
go
if old&(mutexLocked|mutexStarving) == mutexLocked {...}
这个例子很好理解,mutexLocked|mutexStarving就是取这两种状态的并集,然后通过&,选中了old中mutexLocked和mutexStarving的位(选中的意思就是让其他位置0),然后比较它是否==mutexLocked,也就是说判断这两个位是否是处于mutexLocked状态,但是又不处于mutexStarving状态。
所以整个操作就是判断多个状态中是否只有指定的状态处于1的状态。
接下来我们看下一个例子:
arduino
new |= mutexStarving
这个就是将mutexStarving置1
下面这个例子就是将mutexWoken清0
arduino
new &^= mutexWoken
指定位清0还可以这样分步完成
go
select:=new & mutexWOken
new=new^select
以上操作相当于先选中new中mutexWoken的位,然后将该位的值异或上它自己的值,我们都知道,一个数异或它自己,会让原来为0的数保持0,原来为1的数也变成0,达到了指定位清0的效果。
从上面的例子我们可以看到,其实位运算进行状态管理的核心就是:
&选中指定位,|取多位的并集,同时使用|进行置1,&^进行清零操作,另外还可以使用<<和>>排除掉首部以及尾部的n位进行运算,以免被排除的位受到影响。
结合一些原子操作我们可以封装一个简单的状态管理结构体:
go
package state
import "sync/atomic"
type BitState32 struct {
state uint32
}
func NewBitState32Zero() *BitState32 {
return &BitState32{}
}
// 设置指定状态为1 state.SetTrue(state1|state2|state3) 设置state1,state2,state3为1
func (f *BitState32) SetTrue(flags uint32) {
for {
old := atomic.LoadUint32(&f.state)
if old&flags != flags {
// Flag is 0, need set it to 1.
n := old | flags
if atomic.CompareAndSwapUint32(&f.state, old, n) {
return
}
continue
}
return
}
}
// SetFalse 设置指定状态为0
// 例如 state.SetFalse(state1|state2|state3)
// 就是设置state1,state2,state3的状态为0
func (f *BitState32) SetFalse(flags uint32) {
for {
old := atomic.LoadUint32(&f.state)
check := old & flags
if check != 0 {
// Flag is 1, need set it to 0.
n := old ^ check
if atomic.CompareAndSwapUint32(&f.state, old, n) {
return
}
continue
}
return
}
}
func (f *BitState32) Get(flag uint32) bool {
return (atomic.LoadUint32(&f.state) & flag) != 0
}
//获取check中的指定位并比较是否等于expect
func (f *BitState32) MGet(check, expect uint32) bool {
return (atomic.LoadUint32(&f.state) & check) == expect
}
Brian Kernighan's算法
该算法主要用于计算一个二进制数中1的数量,其核心思想就是每次去除末尾的一个1,每去除一个就计数+1,一直到所有的1都去除了等于0为止
以下是其简单示例:
go
for value != 0 {
value = value & (value - 1)
count++
}
我们可以发现BK算法的主要步骤就是每次去&自己原来的数-1,直到自己变成0为止,那为什么要这样呢,我们可以这样理解
a&b相当于选中b中为1的位在a中的位置,a&a就是选中a中为1的位,那如果a-1,会导致末尾的1变成0,末尾的1之后的0变成1,如果末尾的1就是最后一个数,则不会有0变成1,但是不管哪种情况,每次a&(a-1)都会选中自己种为1的数,并排除掉末尾的一个数,这就会导致每次选中位的时候都排除末尾的一个1.
位运算之Bitmap
Bitmap常见于redis中提供的一种数据类型,存储的是一系列的二进制位信息,所以也叫位图。
我们可以用一个int数组来表示一个bitmap,其中bitmap的大小就是每个int的大小乘以int数组的长度。结合前面的位运算知识,我们可以简单实现一个bitmap:
go
package bitmap
const (
BitSize = 32 // 每个整数的位数,一般为32或64,根据需求自行调整
)
type Bitmap struct {
data []uint32 // 用于存储位图数据的整数数组
size int // Bitmap的大小,表示总共可以存储的位数
}
func New(size int) *Bitmap {
// 计算所需的整数数组大小
//加上bitSize-1是为了保证在整除的情况下不额外分配一个BitSize,在向下取整时额外分配一个BitSize,参考size为0和1这两种情况,0的话需要分配0个整数,1需要分配1个整数。
//加上BitSize-1正好合适,不多也不少,完全不浪费也不至于空间不足,但是加上BitSize的话,0也会分配1个整数
dataSize := (size + BitSize - 1) / BitSize
data := make([]uint32, dataSize)
return &Bitmap{
data: data,
size: size,
}
}
// SetBit 设置指定位置pos处的bit位值为1
func (bm *Bitmap) SetBit(pos int) {
//越界处理
if pos < 0 || pos >= bm.size {
return
}
//index定位到pos位于哪一个BitSize里面
index := pos / BitSize
//offset表示起对应的偏移
offset := pos % BitSize
//<<逻辑左移,末尾补0,>>算术右移,高位用符号位填充,左移扩大2倍,右移减小2倍,go中低端在前,高端在后,1,2,3的数组,1排在最后面
//将 0000 0000 0000 0000 0000 0000 0000 0001 左移到偏移的地方,并且进行或运算,0与其他数或等于原数,1与其他数或等于1
bm.data[index] |= (1 << uint(offset))
}
// ClearBit 清楚pos处的bit位,相当于设置pos处的bit为0
func (bm *Bitmap) ClearBit(pos int) {
if pos < 0 || pos >= bm.size {
return
}
index := pos / BitSize
offset := pos % BitSize
//&^位清楚操作符,将有操作数中为1的位对应的做操作数置0
bm.data[index] &^= (1 << uint(offset))
}
// GetBit 获取指定位置pos处的bit的值,false代表0,true代表1
func (bm *Bitmap) GetBit(pos int) bool {
if pos < 0 || pos >= bm.size {
return false
}
index := pos / BitSize
offset := pos % BitSize
//将pos位置的数移动到最右边的位置上,然后将最右边以外的数清0,看等于1还是0
return (bm.data[index]>>uint(offset))&1 == 1
}
// 将整个Bitmap恢复为全0状态
func (bm *Bitmap) Reset() {
for i := 0; i < len(bm.data); i++ {
bm.data[i] = 0
}
}
// 扩容Bitmap,将旧数据复制到新Bitmap中
func (bm *Bitmap) Resize(newSize int) {
newDataSize := (newSize + BitSize - 1) / BitSize
newData := make([]uint32, newDataSize)
//复制旧数据
copy(newData, bm.data)
bm.data = newData
bm.size = newSize
}
// Count 统计bitmap中1的个数,当参数个数为0时,统计范围为整个bitmap,且使用Brian Kernighan's算法,当参数个数为1且为正数时(假设这个参数为n),
// 统计0到n(包括0,不包括n,即[0,n)中1的个数),当n为负数时,统计[-n,bitmap.size),当具有两个参数n和m时,两者为...
// 例子:10为bitmap的大小
// Count() -> [0,10)
// Count(0)->[0,0)返回值恒为0,相当于不统计
// Count(5)->[0,5)
// Count(5,7)->[5,7)
// Count(-5) ->[10-5,10)
// Count(-1,-7)->[10-7,10-1]
// Count(4,12)->[4,10)
func (bm *Bitmap) Count(pos ...int) int {
count := 0
if len(pos) == 0 {
for _, value := range bm.data {
// 使用 Brian Kernighan's 算法统计一个整数中1的个数
//-1会使得从右往左数开始的最近一个1的位置原来的1变成0,该位置之后的值之前是0,减去1之后变成1,然后与之前的数进行与,
//由于除了右数最近的一个1前面的都不变,所以进行与运算不会改变,但是之后的都会变成0,所以就会造成一种现象,每次都取最右侧的一个1变为0,
//直到取完所有1.
for value != 0 {
value = value & (value - 1)
count++
}
}
return count
} else if len(pos) == 1 {
if pos[0] == 0 {
return 0
} else if pos[0] > 0 {
if pos[0] > bm.size {
pos[0] = bm.size
}
return bm.countBits(0, pos[0])
} else {
if -pos[0] < bm.size {
pos[0] = -bm.size
}
return bm.Count(bm.size+pos[0], bm.size)
}
} else {
if pos[0] >= 0 && pos[1] > 0 && pos[1] > pos[0] {
return bm.countBits(pos[0], pos[1])
} else if pos[0] <= 0 && pos[1] < 0 && pos[0] > pos[1] {
return bm.countBits(bm.size+pos[1], bm.size+pos[0])
} else {
return bm.Count(pos[0])
}
}
}
func (bm *Bitmap) countBits(n, m int) int {
count := 0
for i := n; i < m; i++ {
if bm.GetBit(i) {
count++
}
}
return count
}
func (bm *Bitmap) Invert() {
for i := 0; i < len(bm.data); i++ {
bm.data[i] = ^bm.data[i]
}
}
func WithSliceByte(data []byte) Bitmap {
// 计算所需的位图大小
size := len(data) * 8
// 根据计算得到的位图大小创建位图对象
bitmap := Bitmap{
data: make([]uint32, (size+31)/32),
size: size,
}
// 将字节数据转换为位图数据
for i, b := range data {
offset := i * 8
for j := 0; j < 8; j++ {
if b&(1<<j) != 0 {
bitmap.data[(offset+j)/32] |= 1 << ((offset + j) % 32)
}
}
}
return bitmap
}
结构体、接口与面向对象
首先我们需要明确的一点是Golang不是一门专业的面向对象的语言,Golang从语法和风格上来说更像是C+Python,但是同样的,Golang也通过其他的方式实现了对面向对象三大特性(封装,继承,多态)的支持,但是这种支持只是简单的实现,并不完全。
结构体
我们通过struct声明一个结构体,例如:
go
package main
import "fmt"
var a struct{
name string
}
type Person struct {
name string
}
func main() {
//临时的匿名局部结构体变量
obj1 := struct {
name string
}{name: "ZhangSan"}
//匿名结构体变量obj2
var obj2 struct {
age int
}
//对a进行赋值
a=struct{
name string
}{name: "张三"}
p:=Person{
name: "李四",
}
fmt.Println(obj1)
fmt.Println(obj2)
fmt.Println(a)
fmt.Println(p)
}
从以上的结构体声明中我们可以看到,在使用一个结构体的时候最常规的做法是用type声明一个结构体类型,然后再用这个新类型去声明一个变量,但是也可以在每次声明一个变量时指出结构体的结构,但这样比较麻烦,其次也可以声明一个临时的结构体,这在某些场景下可能有用,比如解析某个字符串进行反序列化时需要一个临时结构体来承载。
结构体可以绑定一些方法,这种方法的绑定和Python的语法比较相似,Golang中于结构体绑定的方法实际上有点像是语法糖,会自动帮你进行参数的注入。
我们可以声明一些结构体绑定的方法就像下面这样:
go
func (p Person) Hello() {
fmt.Println("Hello")
}
func (Person) Hello01() {
fmt.Println("hello01")
}
func (p *Person) Hello02() {
fmt.Println("Hello02")
}
func (*Person) Hello03() {
fmt.Println("Hello03")
}
我们可以发现,这些绑定的方法大致有四类
其中带*的方法表示我们可以对p进行修改,并且这种需改会因为到p,因为p接收的是结构体对象的地址
而没有*的则表示p接收的是结构体对象的副本,对p的修改不会对原来的p产生任何影响。
另外就是func (Person) Hello01()和func (*Person) Hello03()的Person都没有名字,这会导致我们既读不到Person的值也修改不了Person,仅仅是绑定一个方法到Person上,但这个方法不会对Person执行任何操作。
接口
Golang中的接口与我们所常规理解的接口一样,它的含义是一组方法的集合。一个结构体如果实现了一个接口中的所有方法,那么就称这个结构体实现了该接口。
接口通常可以用来作为函数参数或者结构体参数,用来实现一些多态的效果。我们常见的any类型本质就是空接口类型interface{}.因为空接口类型不需要实现任何方法,所有任何结构体实际上都实现了空接口类型。interface{}这一特质在某些场景下非常重要。。
我们可以这样声明一个接口:
go
type Human interface {
Eat()
Say()string
}
当我们将接口作为参数时,可以这样写:
go
func Do00(h Human){
//Do something ...
}
也可以这样写:
go
func Do01(
h interface {
Eat()
Say() string
}) {
//Do something ...
}
第二种方法显然并不常规,它就像是interface{}和any的关系一样,但当你看到一个这样的函数定义时请不要惊讶,它是可以通过编译的并且合乎语法的。
那么什么叫做实现了某个接口呢,在Golang中并不需要像其他语言那样显示的声明实现,它的实现关系是一种隐式的实现,尽管这在很多情况下对于代码的阅读理解来说增大了难度,因为你不知道他是否实现了某个接口,但这也给Golang带来了一些灵活性。以下是一个实现了Human接口的例子:
go
type ModernHuman struct {
}
func (mh *ModernHuman) Eat() {
}
func (mh *ModernHuman) Say() string {
return "Hello"
}
需要注意的是实现某一接口一定是实现其全部方法,实现部分方法不能称之为该接口的实现。
接口作为参数的时候默认接收的是接口对应结构体的地址,因此不能声明一个接口类型的指针
H *Human是错误的。
接口断言
我们可以通过interface.(Type)的方式进行接口断言,将一个接口类型转换成具体的类型,当然,断言的前提是断言是准确的,也就是该接口类型所代表的变量实际上确实是Type类型才行。
组合式继承
基于"组合优于继承"的理念,Golang并没有提供结构体的继承,但是却提供了一种组合的语法,达到了类似于继承的效果,需要注意的是,这种组合真的只是简单的组合。
以下是Golang中组合的用法:
go
type Father struct {
name string
}
func (f *Father) Eat() {
fmt.Println("Father Eat")
}
type Son struct {
Father
age int
}
func (s *Son) Play() {
fmt.Println(s.name)
fmt.Println(s.age)
}
func (s *Son) Eat() {
fmt.Println("son eat")
}
func (*Father) Hello() {
fmt.Println("Hello")
}
从上面的代码中我们注意到,组合式继承是存在重写的,而这种重写后的方法调用也有一定的规律,即 1.子类对象调用方法时,重写后的对象会调用自己重写的方法,未重写的方法会调用组合继承来的结构体的默认方法。
2.父类对象无法调用子类方法,这涉及到一个场景,就是子类对象调用父类的默认方法,然后默认方法里面调用了被重写的另一个方法,但是实际上被重写的方法不会被调用,如下:
go
type Entity interface{
Run()
Startup()
Update()
}
type Father struct{
}
func(f Father)Run(){
fmt.Println("Father Run")
f.Startup()
f.Update()
}
func(Father)Startup(){
fmt.Println("Father Startup")
}
func(Father)Update(){
fmt.Println("Father update")
}
type Son struct{
Father
}
func(Son)Startup(){
fmt.Println("Son Startup")
}
func(Son)Update(){
fmt.Println("son update")
}
func main(){
s:=Son{}
s.Run()
//Output:
//Father Run
//Father Startup
//Father uodate
}
首先我们来看一下这段代码,Son组合继承了Father,Father实现了Entity接口,Son重写了Father的Startup和Update方法,然后我们创建了一个Son的对象,这个对象接着调用了Run()方法。因为Son没有重写Run方法,所以这里Run执行的是Father的Run方法,接着在Father的Run方法中会调用Startup和Update,那这里对这两个方法的调用会调用Son重写的Startup和Update吗,毕竟整个调用的最初发起者是Son,哪怕中间发生了转型,但是实际上不会,它还是会调用Father的Startup和Update,因为f的Father类型,最终它会被绑定到Father的Startup和Update,这和其他语言有点不一样。
有聪明的小伙伴可能知道Golang中底层类型和实际类型是不一致的,那能不能将f先转型成Entity再调用呢,那它最后会不会绑定到重写后的Son的Startup和Update上去呢
比如像下面这样写:
go
package main
import "fmt"
type Entity interface {
Startup()
Update()
}
type Father struct {
}
func (f Father) Run() {
fmt.Println("father Run")
e := Entity(f)
e.Startup()
e.Update()
}
func (f Father) Startup() {
fmt.Println("father startup")
}
func (f Father) Update() {
fmt.Println("father update")
}
type Son struct {
Father
}
func (s Son) Startup() {
fmt.Println("son startup")
}
func (s Son) Update() {
fmt.Println("son update")
}
func main() {
s := Son{}
s.Run()
}
//Output:
//father Run
//father startup
//father update
实际上不会,它最终调用的还是Father的Startup和Update.似乎在转型的过程中丢失了原本的信息,那到底是怎么回事呢,这就涉及到Golang类型相关的知识,这里不做过多叙述。
原子操作
Golang中原子操作主要有5类,分别是Load,Store,Add,Swap,CompareAndSwap
- Load 加载值
- Store 存储值
- Add 加上指定值
- Swap 交换值,相当于加载旧的值出来并将新的值存储进内存
- CompareAndSwap 又称CAS,常用于加锁操作,比较旧值和指定值,如果相等则执行Swap操作,常用于并发环境下的赋新值操作,通常会先取旧值存储在变量old中,然后执行一些操作后,需要改变原来的值,但是有可能这个值已经被其他的协程改变了,那自己可能就不需要再次改变了,这个时间就比较当前值与之前存的old,是否相等,如果相等,说明这个变量还没被其他的协程所改变,如果不等了,那说明被其他的协程已经改变了。
比如以下这个改变f.state的值为n的代码片段:
go
for {
old := atomic.LoadUint32(&f.state)
check := old & flags
if check != 0 {
// Flag is 1, need set it to 0.
n := old ^ check
if atomic.CompareAndSwapUint32(&f.state, old, n) {
return
}
continue
}
return
}
需要注意的是原子操作主要是用于并发环境下的操作,因为原子操作彼此都是互斥的,不存在脏读幻读情况,相当于最小单元的互斥操作,,非并发环境下频繁使用原子操作可能会导致性能降低,同时原子操作又比互斥锁的性能要高。
另外需要注意的就是指针类型的原子操作的特殊性,如果是对指针类型进行原子操作,需要先声明一个unsafe.Pointer类型,然后使用new分配给指针一片空间,然后对该变量进行Store操作进行赋值,后面进行各种操作都没有问题,但是切忌不能先声明一个Go中的常规结构体变量,然后取地址,转成unsafe.Pointer,接着对这个变量进行赋值,这似乎会出一些问题,就是你通过Store对其赋值之后,发现实际上这个结构体变量的值并没有发生改变,那为什么呢,这又涉及到Golang复杂的类型系统,我们在将结构体变量转成*unsafe.Pointer过程中似乎是新开辟了一个空间,并将旧值拷贝了过来,所以Golang的转型其实并不简单,包括int32转int64,实际上是新分配了一个空间,然后将int32的值拷贝到了int64这个变量这里,前后指向了不同的空间,所以转型实际上都是值拷贝。
类型系统
底层类型与实际类型的差别
首先,我们需要清楚"类型"的概念,在计算机中,什么是类型呢?我理解的类型就是内存中一段数据的信息汇总,从类型中我们可以清晰地了解到这段内存空间的大小,其内部的组成。而最重要的就是其大小,因为当我们在获取一段内存空间的数据的时候,我们首先需要获取其地址,其次获取其数据的偏移,也就是我们需要从地址开始取多大的空间,类型就告诉了我们其从地址开始的偏移信息。
为什么我们会说"底层类型"和"实际类型"呢,二者的区别是什么呢?
底层类型我理解的就是物理上的类型,对应的是实际内存中存储的数据。而实际类型是逻辑上的类型,代表的是一个与实际概念相关的物理空间。实际类型是底层类型的上层封装。
举个例子:
go
type Person struct{
age int32
}
type Animal struct{
age int32
}
Person和Animal是同一种类型吗,显然不是,但是他们的底层类型是一样的吗,从物理空间上来看,他们是一样的,也就是说Person和Animal实际上是同一种底层类型,但是实际类型不一样,再看一个例子
go
type age int32
age和int32是同一种类型吗,有些同学可能犯迷糊了,实际上如果声明一个age和int32的变量,用'=='操作符比较他们是否相等,这会报错,因为他们不是同一种类型,不能比较,哪怕他们底层是同一种int32类型。
那底层类型和实际类型有什么关联呢?
实际上是有的,底层类型相同的两种类型,是可以进行类型转换的,也就是我们常说的"转型"。
转型
底层类型相同的两个类型,我们可以进行转型,但是需要注意的是转型的本质是基于旧类型的值创建一个新的类型,转型后会分配一块新空间,并不是在旧的地址空间上做的操作,转型后的新变量实际上是一个副本。影响不到旧值。这也是在后面使用原子类操作指针类型的时候是不能先转型再Store的,因为转型后实际上开辟了新的空间,需要谨慎使用。
转型的语法格式:
scss
T(a) //T是需要转换成的类型,a是实际的变量
下面是一个简单的例子
go
func main() {
a := A{}
b := B{}
c := B(a)
fmt.Println(b == c)
println(&a)
println(&b)
println(&c)
//Output:
//true
//0xc0000c9f5c
//0xc0000c9f58
//0xc0000c9f54
}
转型的前提是底层类型一致。
并发控制
信号量实现Spin与程序优雅退出
我们经常需要运行一个协程,但是如果我们直接go一个协程,还没等这个协程执行,整个程序就结束了,这时候我们往往需要等待协程执行完毕再退出主程序。
针对这个问题,我们有多种解决方案,一种解决方案是通过channel去控制多个协程,一种是通过context包去控制,另一种是通过WaitGroup去控制,或者就是让主协程sleep足够的时间,但是还有一种方式就是让你的主协程Spin并且在接收到终断信号量时自然退出,Hertz采用的就是这种方式,那么让我们来简单实现以下吧。
首先,我们需要声明一个信号量的管道
go
close:=make(chan os.Signal,1)
接着,我们需要通知系统将中断信号发送到这个close
go
signal.Notify(close, os.Interrupt, syscall.SIGTERM)
其中,close是接收信号量的channel,os.Interrupt表示系统中断信号,syscall.SIGTERM表示系统的终止信号(CTRL+C)
最后,就是让我们的系统自旋起来,也就是阻塞(执行这条命令时会被阻塞,所以要确保这行代码在非退出代码后被执行,不用影响正常的代码逻辑)
go
<-close