Go 语言入门指南:基础语法和常用特性解析 | 青训营

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
       }
    }
}

我们可以发现,它实际上是分成三种情况的:

  1. 当新的长度newLen大于原来的oldCap*2时,newCap=newLen
  2. 当oldCap<256时,newCap=OldCap*2
  3. 当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
相关推荐
why1513 小时前
腾讯(QQ浏览器)后端开发
开发语言·后端·golang
浪裡遊3 小时前
跨域问题(Cross-Origin Problem)
linux·前端·vue.js·后端·https·sprint
声声codeGrandMaster3 小时前
django之优化分页功能(利用参数共存及封装来实现)
数据库·后端·python·django
呼Lu噜4 小时前
WPF-遵循MVVM框架创建图表的显示【保姆级】
前端·后端·wpf
bing_1584 小时前
为什么选择 Spring Boot? 它是如何简化单个微服务的创建、配置和部署的?
spring boot·后端·微服务
学c真好玩4 小时前
Django创建的应用目录详细解释以及如何操作数据库自动创建表
后端·python·django
Asthenia04124 小时前
GenericObjectPool——重用你的对象
后端
Piper蛋窝4 小时前
Go 1.18 相比 Go 1.17 有哪些值得注意的改动?
后端
excel4 小时前
招幕技术人员
前端·javascript·后端
盖世英雄酱581365 小时前
什么是MCP
后端·程序员