GoLang语言大揭秘!

本篇文章旨在解密GoLang中的零零散散的小知识,为面试或者日常开发打下坚实的基础。

为什么选择GoLang

在一切开始之前我们先来聊聊GoLang相较于其他语言的优劣势。

优势 劣势
  1. 简单易学:Go可以在很短的时间内上手开发,但了解底层机制需要一些时间。
  2. 跨平台编译:结果为二进制可执行文件
  3. 简洁的语法:代码风格统一
  4. 天生支持高并发:Go通过Goroutine和Channel实现不同于其他语言的并发机制。
    Channel的理论依据是CSP并发模型,即通过通信来共享内存;通过runtime运行时实现了GMP模型作为自己的调度模型,一个用户线程对应一个系统线程,同时在GMP的调度下可以将多个协程在一个用户线程中并发运行,极大的减少了用户态到内核态的切换成本。
  5. 行之有效的GC:Go语言最新的三色标记法+混合写屏障使得其GC效果相当不错,尽可能的减少了STW的时间。
  1. “Yes and No”的面对对象编程思想,不利于实现代码的统一规范和设计模式。
  2. 缺乏一些高级特性,如:泛型编程,异常处理机制,多态等。

正因上述的诸多优势,Go语言才能得到越来越多开发者的青睐。

GoLang是面向对象的语言吗

官方的QA给出的回答是yes and no。也就是说“是也不是”,因为Go不会强制要求你用面向对象来写,但是它确实可以用结构体+组合等方式实现面向对象形式的编程。

为了介绍在GoLang中的面向对象编程,我们要了解面向对象的三大特性在GO中的表现:

  1. 封装: Go语言实现封装和其他语言的private、public等关键字不同,Go语言通过首字母大小写来控制访问权限。首字母大写表示公有,首字母小写表示私有。而对于包级别的变量和函数,只有在同一个包内才能访问。值得注意的是结构体中的小写字段对于同一个包内是可以访问的。
  2. 继承: Go语言没有继承的概念,但是可以通过结构体嵌套来实现类似继承的效果。或者也可以通过组合的方式,子结构体可以访问父结构体的字段和方法。
  3. 多态: Go语言通过接口来实现多态。接口是一组方法的集合,任何类型只要实现了接口中的所有方法,就可以被视为该接口类型。这样就可以实现不同类型之间的多态性。

嵌入和组合的区别

有很多人不知道嵌入和组合的区别,这里澄清一下。

请看如下代码:


type s1 struct {
    a int
}

type s2 struct {
    s1  // 嵌入
    b int
}

type s3 struct {
    x s1  // 组合
    c int
}
            

嵌入的情况下s2可以直接调用s2.a来访问对应的值,也可以调用s2.s1.a来访问a

组合的情况下s3只能通过s3.x.a来访问a

如果结构体有方法,那么也是同理。这些都可以用来实现继承。

make和new的区别

  1. make用于创建slice、map、channel,new用于创建基本数据类型和结构体
  2. make返回的是引用类型,new返回的是指针类型
  3. make分配内存后会进行初始化,new分配内存后不会进行初始化而是会清零

make和new的底层实现都是通过runtime.makemap和runtime.newobject来实现的。

你可能会好奇如果用new来创建一个slice、map、channel会怎么样?

答案是:会报空指针错误。除非你new完后又用make来初始化。

slice和array的区别

  1. slice是所谓的“引用类型”,而array是值类型。

    实际上Go语言中没有类似于C++中的引用类型,所有的引用类型的底层其实都是指针。

    slice的底层实现包含三个字段:

    • array:指向底层数组的指针
    • len:切片的长度
    • cap:切片的容量

  2. slice和array的初始化方式不同。

    slice的初始化方式是:

    
    slice := []int{1, 2, 3}
                    

    array的初始化方式是:

    
    array := [3]int{1, 2, 3}
                    

    区别在于数组初始化时需要指定长度,而slice不需要。并且slice可以通过make初始化,但是array则是可以用new来生成新对象。

  3. slice可以动态扩容,而array不可以。

slice的扩容机制

在讲述具体的扩容机制的时候我们要先定义几个概念:

扩容的逻辑如图:

slice扩容逻辑

图中所说的最后一步的内存对齐,实际上是Go语言为了减少CPU的内存访问次数,将内存通过一定的逻辑对齐到特殊值的操作。

这就是为什么有时候的实际扩容值和期望扩容值不一样。

for range的坑

for range其实有很多坑。。。。我们一个一个展开。

临时变量

在go 1.22.0 之前for i,v :=range slice 中,i,v的地址是不会发生改变的!也就是说整个循环用到的i,v都是同一个。

这就导致如果你在循环中用一个数组存储i,v的地址,那么这个数组中的所有元素的地址都是相同的。

举个例子:


package main
import "fmt"
func main() {
    map1 := make(map[string]int)
    map1["a"] = 1
    map1["b"] = 2
    map1["c"] = 3
    keys := make([]*string, 0, len(map1))
    values := make([]*int, 0, len(map1))
    for key, value := range map1 {
        fmt.Println(key, value)
        keys = append(keys, &key)
        values = append(values, &value)
    }
    fmt.Println(keys)
    fmt.Println(values)
}
            

Go 1.22.0 输出结果是:


a 1
b 2
c 3
[0xc00008a030 0xc00008a050 0xc00008a070]
[0xc000096068 0xc0000960a0 0xc0000960b0]
            

Go 1.22.0 之前输出结果是:


b 2
a 1
c 3
[0xc000010200 0xc000010200 0xc000010200]
[0xc000016070 0xc000016070 0xc000016070]
            

值得一提的是两次顺序不同是因为遍历map是随机顺序的。

在go 1.22.0 之后,修复了这个“特性”,i,v的变量发生了改变,所以不会出现上述问题。

值得注意的是不仅仅是遍历slice,map,array,channel都会出现这个问题。当然这些问题也都在Go 1.22.0 之后得到了修复。

遍历map

实际上在遍历map的时候一定是无序的,因为Go语言的map底层实现是hash表,而hash表的特性就是无序的。

所以如果你需要有序的map,那么你需要使用slice来存储key,然后对slice进行排序。

值得一提的是map的无序遍历实际上是go官方刻意实现的,每次遍历都会随机决定一个初始bucket,从这个桶开始遍历,每次也会随机决定一个桶内遍历的起始位置,来增加随机性。但是一次遍历中每个桶的初始遍历位置是一样的。

遍历slice时插入值

在遍历slice时插入值,不会导致死循环。

因为每次for range的时候都会拷贝一份slice,注意不是深拷贝,而是浅拷贝。所以每次你遍历slice的时候插入值也不影响其len和cap自然不会导致死循环。

举个例子:


package main
import "fmt"
func main() {
    slice := []int{1, 2, 3}
    for i, v := range slice {
        slice = append(slice, i)
        fmt.Println(i, v)
    }
    fmt.Println(slice)
}
            

输出结果是:


0 1
1 2
2 3
[1 2 3 0 1 2]
            

可以看出遍历的时候插入值不会造成影响。

遍历slice时改变值

在遍历slice时改变值,会影响原slice!

这时因为for range做的是浅拷贝和原slice共用一个底层数组,所以改变值会影响原slice。

举个例子:


package main

import "fmt"

func main() {
    // 遍历的时候改变值
    slice := []int{1, 2, 3}
    for i, v := range slice {
        if i < len(slice)-1 {
            slice[i+1] = v
        }
        fmt.Println(i, v)
    }
    fmt.Println(slice)
}
            

输出结果是:


0 1
1 1
2 1
[1 1 1]
            

可以看出遍历的时候改变值会影响原slice。

遍历slice时删除值

在遍历slice时删除值,会对结果有影响,但是是可以预测的。

因为for range做的是浅拷贝,所以其len和cap是固定的,所以删除值会影响原slice的输出,但是不会影响输出的个数。

举个例子:


package main

import "fmt"

func main() {
    // 遍历的时候删除值
    slice := []int{1, 2, 3}
    for i, v := range slice {
        if i < len(slice)-1 { 
            slice=append(slice[:i+1], slice[i+2:]...) 
        }
        fmt.Println(i, v)
    }
    fmt.Println(slice)
}
            

输出结果是:


0 1
1 3
2 3
[1 3]
            

可以看出遍历的时候删除值会影响原slice。

之所以会输出两个3,是因为在输出0 1的时候,已经用append覆盖了2,但是临时切片的len和cap不变,所以会输出最后一个数,自然也就是是3。

又一次印证,for range 生成的临时数组和原数组是共用一个底层数组的,所以改变值会影响原数组。

Go语言的三种指针类型

大家都知道go语言有指针类型可以指向变量的内存地址。

但是go语言有三种指针类型,分别是:

他们有各自的区别和使用场景。

普通指针类型

普通指针类型就是我们常用的*int,*string等。

  1. 用于传递对应类型对象的地址,不能进行指针运算。

  2. 可以访问内存的值。

  3. 只能在底层类型一样的指针之间进行类型转化。

  4. 指向的内容不会被GC回收。

第三点可能不好理解,举个例子:


package main

import (
    "fmt"
)

type myint int

func main() {
    var a myint = 1
    ap := &a
    fmt.Println(*ap)
    var app *int
    app = (*int)(ap)
    fmt.Println(*app)
}
            

这段代码中ap的静态类型是*myint,app的静态类型是*int,所以虽然ap和app的底层类型都是int,但是他们的静态类型不同,必须进行类型转化后才能赋值。

但是如果二者的底层类型不一样,就完全不能赋值。

举个例子:


package main

import (
    "fmt"
)

type myint int

func main() {
    var a myint = 1
    ap := &a
    fmt.Println(*ap)
    var app *float64
    app = (*float64)(ap)
    fmt.Println(*app)
}

            

这段代码会报错,因为底层类型不一样,不能进行类型转化。

unsafe.Pointer

unsafe.Pointer是go语言的通用指针类型,可以指向任何类型的内存地址。

  1. 可以保存任何类型对象的地址,不能进行指针运算。

  2. 不能访问内存的值。

  3. 用于底层类型不同的指针之间的类型转化。

  4. 指向的内容不会被GC回收。

第三点其实指的是,如果你非要把两个指向完全不同底层类型的指针相互赋值的话,就需要用unsafe.Pointer来做中间桥梁。

例如,上面说到的普通类型指针会报错的代码可以这样改写:


package main

import (
    "fmt"
    "unsafe"
)

type myint int

func main() {
    var a myint = 1
    ap := &a
    fmt.Println(*ap)
    var app *float64
    app = (*float64)(unsafe.Pointer(ap))
    fmt.Println(*app)
}
            

可以看到通过unsafe.Pointer的转化就可以将一个int类型作为底层的指针赋值给float64类型的指针。

请注意,其余的三个特点也非常重要,这是unsafe.Pointer与普通类型指针和uintptr的主要区别。

uintptr

很多人以为uintptr是指针,其实不然,它不过是一个uint,和指针类型的大小相等罢了,它是用来指针运算的工具。

  1. 可以和unsafe.Pointer相互转化,可以进行指针运算。

  2. 不可以访问内存的值。

  3. 不算指针,自然不存在所谓的指针类型转化,它就是一个整数类型。

  4. 指向的内容会被GC回收。

所以其实uintptr就是一个和指针大小相等的整数类型。可以进行指针运算,并且不能持有对象,指向的内容会被回收。

接口底层原理

接口在Go语言中是一种类型,它定义了一组方法。任何实现了这些方法的类型都可以认为是实现了接口。

本章节将详细讲述interface的底层原理。

Interface在底层实现其实对应两个结构体:

eface

所谓空接口,就是没有定义任何方法的接口。
所有类型都实现了空接口,所以空接口可以存储任何类型。

eface的结构体定义如下:


type eface struct {
    _type *_type
    data unsafe.Pointer
}
            

其中_type是类型信息,data是数据指针。

请注意_type字段在go 1.21.0之前和之后不太一样,本文章基于1.22.0版本。

在1.22.0版本中type字段如下:


type Type struct {
    // 类型的大小(字节数)
    Size_       uintptr
    
    // 类型中包含指针的字节数(从开始位置算起)
    // 用于 GC 扫描对象时知道需要扫描多少字节的指针
    PtrBytes    uintptr 
    
    // 类型的哈希值,用于快速比较类型是否相同
    // 避免在哈希表中重复计算
    Hash        uint32  
    
    // 类型的额外标记信息,比如:
    // - 是否是命名类型
    // - 是否有 uncommon type 信息
    // - 是否可以用规则内存比较
    TFlag       TFlag   
    
    // 类型的对齐要求(字节数)
    Align_      uint8   
    
    // 当此类型作为结构体字段时的对齐要求
    FieldAlign_ uint8   
    
    // 类型的具体分类(如 int、string、struct 等)
    Kind_       uint8   
    
    // 用于比较该类型两个值是否相等的函数
    Equal func(unsafe.Pointer, unsafe.Pointer) bool
    
    // GC 相关的类型数据:
    // - 如果设置了 KindGCProg,则指向 GC 程序
    // - 否则是指针位图,用于标记哪些字节包含指针
    GCData    *byte
    
    // 类型名称的偏移量,用于获取类型的字符串表示
    Str       NameOff 
    
    // 指向该类型的指针类型的偏移量
    // 例如: 如果这是 int 的类型信息,PtrToThis 指向 *int 的类型信息
    PtrToThis TypeOff 
}

可以看出,空接口其实就是只存储了类型信息和数据指针。

所以空接口可以存储任何类型,因为任何类型都实现了空接口。

iface

非空接口,就是定义了方法的接口。

iface的结构体定义如下:


type iface struct {
    tab *itab
    data unsafe.Pointer
}
            

其中tab是接口表,data是数据指针。

tab的结构体定义如下:


type itab struct {
    // 指向接口类型信息的指针
    // 包含了接口定义的所有方法信息
    inter *interfacetype

    // 指向具体类型信息的指针
    // 包含了实现接口的具体类型的信息
    _type *_type

    // 类型哈希值,从 _type.hash 复制而来
    // 用于类型断言时快速判断类型是否匹配
    hash uint32

    // 4字节填充,用于内存对齐
    _ [4]byte

    // 方法表,存储具体类型实现的接口方法地址
    // 虽然声明为 [1]uintptr,但实际大小是可变的
    // 包含了接口所有方法的具体实现地址
    // fun[0]==0 表示该类型没有实现这个接口
    fun [1]uintptr
}

type InterfaceType struct {
    // 嵌入基础的 Type 结构
    // 包含了类型的通用信息如大小、对齐方式等
    // 同上面我们提到的_type结构体
    Type

    // 接口定义所在的包路径
    // 例如: "encoding/json"
    // 用于区分不同包中的同名接口
    PkgPath Name

    // 接口定义的所有方法列表
    // 按方法名的哈希值排序,便于快速查找
    Methods []Imethod
}
            

与空接口最大的区别就是,非空接口用itab存储一个接口表,接口表中有接口类型的详细定义,也有具体实现类型的详细定义。

在接口类型的详细定义中包含接口的类型信息(接口也是一种类型)(InterfaceType.Type),接口的包路径(InterfaceType.PkgPath)和接口定义的未实现的方法(InterfaceType.Methods)。

在具体实现的类型的定义中包含该类型的全部类型信息(itab._type)和所有具体实现的函数内容(itab.fun)。

接口实现

通过了解两种接口的结构体定义,我们可以更好地理解接口的本质。实际上,接口是一个包含了实现类型、接口类型、实现类型数据、实现方法和定义方法的数据结构。

在go语言中实现接口时隐性的。只需要实现所有接口定义的方法,就视为实现了这个接口

任何一个实现了接口的数据结构,都可以赋值给静态类型为对应接口类型的变量。

这个变量只能调用接口定义的方法,不能调用实现类型的方法。也不能访问实现类型的数据。

itab缓存

其实可以发现,同一个接口+同一个实现类型对应的itab是唯一的。所以如果你创建了多个变量赋值到接口类型,每一次都创建一个新的itab的话,是不是太浪费了?

例如:


package main

import "fmt"

type ai interface {
    show()
}

type a struct {
    a int
}

func (a *a) show() {
    fmt.Println(a.a)
}

func main() {
    var a1 a = a{a: 1}
    var a1i ai = &a1
    var a2 a = a{a: 2}
    var a2i ai = &a2
    var a3 a = a{a: 3}
    var a3i ai = &a3
    a1i.show()
    a2i.show()
    a3i.show()

}

            

这段代码中,如果每次将a类型赋值给ai类型都要创建新的itab的话就太麻烦了。

所以go语言中,如果两个接口类型和实现类型都相同,那么就会使用同一个itab。

这就是itab缓存。

具体而言,在go语言中itab的缓存用这样一个结构体存储:


type itabTableType struct {
    // entries 数组的长度
    size uintptr

    // 当前已填充的条目数量
    count uintptr

    // itab 指针数组
    entries [itabInitSize]*itab
}
            

entries虽说时数组其实实现方式类似于哈希表,他是通过接口类型和实际类型分别hash后的异或值来为key进行存储的。hash冲突处理方式则是采用开放寻址法。

当查找一个itab是否存在的时候,步骤如下:

  1. 通过接口类型和实际类型分别hash后的异或值来计算出在entries数组中的索引。

  2. 如果entries[index]为空,则说明该itab不存在。

  3. 如果entries[index]不为空,则说明该itab存在。注意此时有可能发生hash冲突,要再次比较itab记录的type值和现在正在查找的type值是否相同。

接口比较


接口之间比较

不同接口之间比较会报错。除了空接口,空接口可以和任何一个接口值或者类型值比较,此时只比较实际数据类型和实际数据值。

举个例子:


package main

import "fmt"

type ai interface {
    showa()
}

type bi interface {
    showb()
}

type a struct {
    a int
}

func (a a) showa() {
    fmt.Println(a.a)
}

func (a a) showb() {
    fmt.Println(a.a)
}

type b struct {
    b int
}

func (b b) showa() {
    fmt.Println(b.b)
}

func (b b) showb() {
    fmt.Println(b.b)
}

func main() {
    var aa ai = a{a: 1}
    var bb bi = b{b: 1}
    fmt.Println(aa == bb)
}

            

最后一句会报错,因为ai和bi都不是空接口,并且ai和bi的接口类型不一样。

再来看这个代码:


package main

import "fmt"

type ai interface {
    showa()
}

type bi interface {
    showb()
}

type a struct {
    a int
}

func (a a) showa() {
    fmt.Println(a.a)
}

func (a a) showb() {
    fmt.Println(a.a)
}

type b struct {
    b int
}

func (b b) showa() {
    fmt.Println(b.b)
}

func (b b) showb() {
    fmt.Println(b.b)
}

func main() {
    var aa ai = a{a: 1}
    var bempty interface{} = b{b: 1}
    fmt.Println(aa == bempty)
    var aempty interface{} = a{a: 1}
    fmt.Println(aa == aempty)
}

            

输出结果为:


false
true
            

这是因为,此时bempty是空接口类型其对应的实际类型是b值为1,aempty是空接口类型其对应的实际类型是a值为1。他们和aa比较都不会报错,因为空接口类型可以和任何一个值比较(不论是接口值还是实际类型值)。

由于bempty和aa的实际类型不同,即使值一样也会返回false,相反aempty和aa的实际类型相同,所以返回true。

所以可见,接口之间的比较不仅仅要比较接口类型,也要比较实际类型和实际值。当然空接口是个特例,它和任何东西都能比较。

例子:


func main() {
    var a = 1
    var b interface{} = func() {
        fmt.Println("aaa")
    }
    fmt.Println(a == b)
}
            

输出结果为:


false
            

可以看出,虽然结果是显而易见的false,但是这个比较却并没有报错!

值得注意的是,接口之间虽然可以比较是否相等,但是却不能比较大小。

接口和类型之间比较

接口和类型之间的比较,会把类型赋值给interface{}空接口后执行两个接口的比较。进而就转化为了实现类型的类型与数值比较。

例如:


package main

import "fmt"

type ai interface {
    showa()
}

type a struct {
    a int
}

func (a a) showa() {
    fmt.Println(a.a)
}

func main() {
    var a1 ai = a{a: 1}
    var a2 = a{a: 1}
    var a3 = 1

    fmt.Println(a1 == a2)
    fmt.Println(a1 == a3)
}
            

输出结果为:


true
false
            

可以看出,a1和a2的实际类型和实际值都相同,所以返回true。

a1和a3的实际类型和实际值不同,所以返回false。

比较的奥秘

Go语言中有这么多种酷酷的数据类型,你还可以自定义无数结构体和接口,难道他们都可以相互比较吗?!

事实上,并非如此!

本章节将聚焦相等比较和大小比较两个方面,来讲解Go语言中比较的奥秘。

相等比较

相等比较,就是比较两个值是否相等。

在上一节中,已经讲述了接口的比较奥秘,这一届则聚焦其他的数据类型。

事实上,对于大家耳熟能详的数据结构中有那么几个是不能通过"==""!="比较的,那就是:

这些数据结构在比较的时候,会报错。

同理,包含这些数据结构的struct也无法比较!

是不是很简单呢?

但是如果你真的想比较他们,应该怎么做?通过反射!reflect.DeepEqual()!

但是,反射的性能开销很大,所以如果可以,尽量避免使用反射。

事实上,DeepEqual()可以比较任何一个类型,不仅仅是map、slice、func。

而且都是深度比较,即不仅比较值,还比较值的结构。

这里给出DeepEqual()的判断标准:

类型 深度相等情形
Array 相同索引处的元素“深度”相等
Struct 相应字段,包含导出和不导出,“深度”相等
Func 只有两者都是 nil 时
Interface 两者存储的具体值“深度”相等
Map 1、都为 nil;2、非空、长度相等,指向同一个 map 实体对象,或者相应的 key 指向的 value “深度”相等
Pointer 1、使用 == 比较的结果相等;2、指向的实体“深度”相等
Slice 1、都为 nil;2、非空、长度相等,首元素指向同一个底层数组的相同元素,即 &x[0] == &y[0] 或者 相同索引处的元素“深度”相等
numbers, bools, strings, and channels 使用 == 比较的结果为真

上述规则来自于:Go 程序员面试笔试宝典© 2022 饶全成, 欧长坤, 楚秦等

大小比较

大小比较,就是比较两个值的大小。

对于数值类型,和字符串类型可以直接用比较符号进行比较。

对于结构体,可以自定义比较函数,然后通过比较函数进行比较。

对于接口,则不能比较大小,当然你也可以自定义比较函数比较。

除了结构体、接口还有func等类型,都可以自定义比较函数比较。