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
相关推荐
林太白1 小时前
❤Node09-用户信息token认证
数据库·后端·mysql·node.js
骆晨学长2 小时前
基于Springboot的助学金管理系统设计与实现
java·spring boot·后端
蒙娜丽宁2 小时前
深入理解Go语言中的接口定义与使用
开发语言·后端·golang·go
AskHarries3 小时前
java使用ByteBuffer进行多文件合并和拆分
java·后端
不染_是非3 小时前
Django学习实战篇六(适合略有基础的新手小白学习)(从0开发项目)
后端·python·学习·django
代码对我眨眼睛3 小时前
springboot从分层到解耦
spring boot·后端
The Straggling Crow4 小时前
go 战略
开发语言·后端·golang
ai安歌4 小时前
【JavaWeb】利用IDEA2024+tomcat10配置web6.0版本搭建JavaWeb开发项目
java·开发语言·后端·tomcat·web·intellij idea
尘浮生4 小时前
Java项目实战II基于Java+Spring Boot+MySQL的作业管理系统设计与实现(源码+数据库+文档)
java·开发语言·数据库·spring boot·后端·mysql·spring
程序员阿鹏5 小时前
ArrayList 与 LinkedList 的区别?
java·开发语言·后端·eclipse·intellij-idea