Go语言系统学习笔记(二):进阶篇

1. 写在前面

公司的新业务开发需要用到go语言,虽然之前没接触过这门语言,但在大模型的帮助下,边看项目边写代码也能进行go的项目开发,不过,写了一段时间代码之后,总感觉对go语言本身,我的知识体系里面并没有一个比较完整的架子,学习到的知识零零散散,不成体系,虽然能完成工作,但心里比较虚,没有沉淀下知识。所以想借着这个机会,用两周的时间系统的学习下go语言, 在知识体系里面搭一个属于go语言的知识框架,把知识拎起来, 也便于后续知识的扩充与回看。

此次学习,依然是业余时间看文档的方式搭建知识框架(工作之后发现看视频比较慢,没时间看), 参看的文档是C语言中文网go教程, 上面内容整理的很详细,非常适合初学者搭建知识体系。只不过内容比较多, 这次还是和之前一样, 整体过一遍教程, 把我觉得现阶段比较关键的知识梳理出来,过于简单的知识作整合,对重点知识,用其他一些资料补充,再用一些实验作为辅助理解,先跳过一些demo示例实践, 基础知识作整理,关键知识作扩展搭建基础框架,后面再从项目中提炼新知识作补充完善框架

PS: 由于我有C/C++、Java、Python等语言基础,所以针对教程的前面常识部分作了整合和删减,小白的话建议去原网站学习哈。

Go语言系统部分打算用3篇文章搭建知识框架,基础篇、进阶篇和杂项篇,每一篇里面的内容各个模块划分的比较清晰,这样后面针对新知识方便补充。这篇是go语言的进阶部分,主要介绍go里面的接口、并发以及反射。 算是很重点的内容了,尤其是并发那块,开始。

大纲如下:

  • 接口
  • 并发
  • 反射

Ok, let’s go!

2 接口

2.1 接口初识

Go语言不是一种 “传统” 的面向对象编程语言:它里面没有类和继承的概念。但是Go语言里有非常灵活的接口概念,通过它可以实现很多面向对象的特性

接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力。

接口是双方约定的一种合作协议。接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。

type 接口类型名 interface{
    方法名1( 参数列表1 ) 返回值列表1
    方法名2( 参数列表2 ) 返回值列表2}
// 接口类型名:使用 type 将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加 er,如有写操作的接口叫 Writer,有字符串功能的接口叫 Stringer,有关闭功能的接口叫 Closer 等。
// 方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
// 参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以被忽略
type Writer interface {
    Write(p []byte) (n int, err error)
}

// 接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用
// 1. 接口的方法与实现接口的类型方法格式一致  在类型中添加与接口签名一致的方法就可以实现该方法。签名包括方法中的名称、参数列表、返回参数列表
// 2. 当一个接口中有多个方法时,只有这些方法都被实现了,接口才能被正确编译并使用

package main
import (
    "fmt"
)
// 定义一个数据写入器
type DataWriter interface {
    WriteData(data interface{}) error
    CanWrite() bool
}
// 定义文件结构,用于实现DataWriter
type file struct {
}
// 实现DataWriter接口的WriteData方法, 参数名称, 参数列表和返回值名称必须一样
// interface{}是空接口类型,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。
// 空接口的内部实现保存了对象的类型和指针。使用空接口保存一个数据的过程会比直接用数据对应类型的变量保存稍慢。因此在开发中,应在需要的地方使用空接口,而不是在所有地方使用空接口。
func (d *file) WriteData(data interface{}) error {
    // 模拟写入数据
    fmt.Println("WriteData:", data)
    return nil
}
func (d *file) CanWrite() bool{
    return true
}
func main() {
    // 实例化file
    f := new(file)
    // 声明一个DataWriter的接口
    var writer DataWriter
    // 将接口赋值f,也就是*file类型
    // 这行代码, 编译器会检查f是否已经实现了接口里面定义的所有方法,如果没有实现, 将会报错
    // Go语言的接口实现是隐式的,无须让实现接口的类型写出实现了哪些接口(类似implements的那种写法)。这个设计被称为非侵入式设计
    writer = f
    // 使用DataWriter接口进行数据写入
    writer.WriteData("data")
}

// 一个类型可以实现多个接口
// 定义一个数据写入器
type DataWriter interface {
    WriteData(data interface{}) error
}
type Closer interface{
    Close() error
}
// 定义文件结构,用于实现DataWriter
type file struct {
}

// 实现DataWriter接口的WriteData方法
func (d *file) WriteData(data interface{}) error {
    // 模拟写入数据
    fmt.Println("file WriteData:", data)
    return nil
}
func (d *file) Close() error{
    fmt.Println("file close")
    return nil
}
func main() {
    // 实例化file
    f := new(file)
    // 声明一个DataWriter的接口
    var writer DataWriter
    var closer Closer
    // 将接口赋值f,也就是*file类型
    writer = f
    writer.WriteData("data")
    
    // 这个和上面的接口互相独立
    closer = f
    closer.Close()
    
}

// 一个接口也可以被多个类型使用
// 定义一个数据写入器
type DataWriter interface {
    WriteData(data interface{}) error
}
// 定义文件结构,用于实现DataWriter
type file struct {
}
type Socket struct{
}
// 实现DataWriter接口的WriteData方法
func (d *file) WriteData(data interface{}) error {
    // 模拟写入数据
    fmt.Println("file WriteData:", data)
    return nil
}
func (s *Socket) WriteData(data interface{}) error{
    fmt.Println("socket WriteData:", data)
    return nil
}
func main() {
    // 实例化file
    f := new(file)
    s := new(Socket)
    // 声明一个DataWriter的接口
    var writer_f, writer_s DataWriter
    // 将接口赋值f,也就是*file类型
    writer_f = f
    writer_s = s
    // 使用DataWriter接口进行数据写入
    writer_f.WriteData("data")
    writer_s.WriteData("data")
}

// 接口之间的嵌套组合
// 接口之间也能嵌套,如果一个类型想用该接口,需要实现接口内所有的方法,包括嵌套接口的方法
type Writer interface {
    Write(p string) (n int, err error)
}
type Closer interface {
    Close() error
}
type WriteCloser interface {
    Writer
    Closer
}

// 声明一个设备结构
type device struct {
}
// 实现io.Writer的Write()方法
func (d *device) Write(p string) (n int, err error) {
    fmt.Println("Write method....")
    return 0, nil
}
// 实现io.Closer的Close()方法
func (d *device) Close() error {
    fmt.Println("Close method....")
    return nil
}

func main() {
	// 声明写入关闭器, 并赋予device的实例
    var wc = new(device)
    wc.Write("hello")
    wc.Close()   
} 

2.2 类型断言

go语言类型断言: 类型断言(Type Assertion)是一个使用在接口值上的操作,用于检查接口类型变量所持有的值是否实现了期望的接口或者具体的类型, 这个类似于python的isinstance, 但不是一个概念哈

value, ok := x.(T)   // x 表示一个接口的类型,T 表示一个具体的类型(也可为接口类型)

// demo
func getType(a interface{}) {
    switch a.(type) {            // a.(type)这个用法只能在switch里面用, 这个属于类型判断分支
    case int:
        fmt.Println("the type of a is int")
    case string:
        fmt.Println("the type of a is string")
    case float64:
        fmt.Println("the type of a is float")
    default:
        fmt.Println("unknown type")
    }
}

func main() {
   var x, y interface{}
   x = "hello"
   y = 10
   value_x, ok_x := x.(int)
   value_y, ok_y := y.(int)
   fmt.Println(value_x, ",", ok_x)   // 0 , false
   fmt.Println(value_y, ",", ok_y)   // 10,true
   getType(x)    // the type of a is string
}

// Go语言中使用接口断言(type assertions)将接口转换成另外一个接口,也可以将接口转换为另外的类型
t := i.(T)  // i 代表接口变量,T 代表转换的目标类型,t 代表转换后的变量

// 接口转成其他接口
// 定义一个飞行动物接口
type Flyer interface{
    Fly()
}

// 定义一个行走动物的接口
type Walker interface{
    Walk()
}

// 定义鸟类
type bird struct {
}
// 实现飞行动物接口
func (b *bird) Fly() {
    fmt.Println("bird: fly")
}
// 为鸟添加Walk()方法, 实现行走动物接口
func (b *bird) Walk() {
    fmt.Println("bird: walk")
}
// 定义猪
type pig struct {
}
// 为猪添加Walk()方法, 实现行走动物接口
func (p *pig) Walk() {
    fmt.Println("pig: walk")
}

func main() {
    animals := map[string]interface{}{
        "bird": new(bird),
        "pig":  new(pig),
    }
    
    for name, obj := range animals{
        
        fmt.Println(name)
        
        // 判断对象是不是飞行动物
        f, isFlyer := obj.(Flyer)
        // 判断对象是不是行走动物
        w, isWalker := obj.(Walker)
        
        if isFlyer{
            f.Fly()
        }
        if isWalker{
            w.Walk()
        }
    }
} 
// output
bird
bird: fly
bird: walk
pig
pig: walk

// 接口转成其他类型
p1 := new(pig)
var a Walker = p1   // pig实现了Walker接口, 所以可以被隐式转换为 Walker 接口类型保存于 a 中
p2 := a.(*pig)   // a 中保存的本来就是 *pig 本体,因此可以转换为 *pig 类型
// p2 := a.(*bird)   // 会报错,panic: interface conversion: main.Walker is *main.pig, not *main.bird
fmt.Printf("p1=%p p2=%p", p1, p2)  // p1=0x57db60 p2=0x57db60

// 再谈空接口
// 空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值
// 空接口的内部实现保存了对象的类型和指针。使用空接口保存一个数据的过程会比直接用数据对应类型的变量保存稍慢。因此在开发中,应在需要的地方使用空接口,而不是在所有地方使用空接口

// 将对象保存到空接口
var any interface{}
any = 1
any = "hello"
any = false

// 从空接口获取值,得需要类型断言转一层才可以,不能直接赋值
// 声明a变量, 类型int, 初始值为1
var a int = 1
// 声明i变量, 类型为interface{}, 初始值为a, 此时i的值变为1
var i interface{} = a
// 声明b变量, 尝试赋值i
var b int = i    // 报错
var b int = i.(int)  

// 空接口的值比较
// 空接口在保存不同的值后,可以和其他变量值一样使用==进行比较操作
// 1. 类型不同的空接口间的比较结果不相同
// a保存整型
var a interface{} = 100
// b保存字符串
var b interface{} = "hi"
// 两个空接口不相等
fmt.Println(a == b) // false

// 2.当接口中保存有动态类型的值时(切片或者map这种),运行时将触发错误
// c保存包含10的整型切片
var c interface{} = []int{10}
// d保存包含20的整型切片
var d interface{} = []int{20}
// 这里会发生崩溃
fmt.Println(c == d)  // panic: runtime error: comparing uncomparable type []int

2.3 error接口

  • 在C语言中通过返回 -1 或者 NULL 之类的信息来表示错误,但是对于使用者来说,如果不查看相应的 API 说明文档,根本搞不清楚这个返回值究竟代表什么意思,比如返回 0 是成功还是失败?
  • 针对这样的情况,Go语言中引入 error 接口类型作为错误处理的标准模式,如果函数要返回错误,则返回值类型列表中肯定包含 error。error 处理过程类似于C语言中的错误码,可逐层返回,直到被处理
type error interface {
    Error() string
}

// 创建一个 error 最简单的方法就是调用 errors.New 函数,它会根据传入的错误信息返回一个新的 error
import (
    "errors"
    "fmt"
    "math"
)
func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return -1, errors.New("math: square root of negative number")
    }
    return math.Sqrt(f), nil
}
func main() {
    result, err := Sqrt(-13)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println(result)
    }
}
// 也可以自定义error的格式, 不用它自带的
type dualError struct {
    Num     float64
    problem string
}
func (e dualError) Error() string {
    return fmt.Sprintf("Wrong!!!,because \"%f\" is a negative number", e.Num)
}
func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return -1, dualError{Num: f}
    }
    return math.Sqrt(f), nil
}

3 并发

并发指在同一时间内可以执行多个任务,包含多线程编程、多进程编程及分布式程序等。go里面的并发属于多线程编程。Go 语言的并发通过goroutine

特性完成。goroutine 类似于线程,但是可以根据需要创建多个 goroutine 并发工作。goroutine 是由 Go 语言的运行时调度完成,而线程是由操作系统调度完成。

Go 语言还提供channel在多个 goroutine 间进行通信。goroutine 和 channel 是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础

3.1 重要概念

进程 VS 线程 VS 协程:

  • 进程:程序在操作系统中的一次执行过程,系统资源分配和调度的一个独立单位。它代表着一个运行中的程序实例,具有独立的地址空间。一个进程可以包含一个或多个线程。进程之间相互独立,一个进程崩溃通常不会直接影响到其他进程。相比线程和协程,进程之间的资源共享更加复杂,需要特定的Inter-Process Communication (IPC)机制,如管道、消息队列、共享内存等。
  • 线程:进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个进程内可以并发多个线程,每个线程共享进程的内存空间和资源,但每个线程有自己的执行栈和程序计数器。这意味着线程间的通信和资源共享更加容易,但也容易引起数据同步和安全问题。线程比进程更轻量级,创建、销毁和切换的开销相对较小
    • demo: 假设有一个软件要从网络上下载多个文件。这个软件可以为每个文件下载任务创建一个线程,所有线程并行运行。每个线程独立完成自己的下载任务,但是它们可能共享一些资源,比如更新相同的界面元素来显示下载进度
  • 协程:协程是一种”用户态”的轻量级线程,它完全由应用程序控制,不需要操作系统参与调度。协程可以实现在单个线程内的多个任务“并发”执行,通过任务之间主动协作和切换来实现。与线程相比,协程更轻量级,创建、切换的成本更低,协程适用于IO密集型任务、高并发应用等场景。
    • demo: 假设有个网络服务器,它需要处理大量的用户请求。使用协程的方式,服务器可以为每个请求启动一个协程。当一个请求需要等待IO操作(如读取数据库)时,执行该请求的协程会暂停,并将控制权交回服务器,服务器随即切换到其他协程继续工作。这样,即使在单个或少数线程上,也能高效地处理大量请求

总结:

  • 运行环境: 进程在操作系统级别进行管理;线程由操作系统进行更细致的调度;协程则依赖特定的编程语言或库,在用户态实现调度。
  • 资源共享与通信: 进程间通信需要特殊的IPC机制,而线程内存共享更加方便,但需要注意数据的同步问题;协程之间也容易共享数据,但调度更为灵活。
  • 调度与切换开销: 进程切换开销最大,线程切换相对较小,协程切换开销最低。
  • 适用场景: 进程适用于大型、独立的应用程序之间的分隔;线程适合实现程序内部的并发;协程适合大量的、密集的、短暂的并发任务。

并发 VS 并行

  • 并发:指系统具备处理多个任务的能力,但不一定要同时进行。在一个并发系统中,多个任务交替执行,一次只处理一个任务的一小部分,然后切换到另一个任务,类似于多线程环境下的时间分片。这种交替可以发生在单核处理器上也可以是多核处理器上**。**
    • 一个厨师准备多道菜。他开始煮汤,当汤煮的同时,开始切蔬菜。汤煮沸,停下切蔬菜来搅拌汤,随后继续切蔬菜。厨师在处理多个任务,但在任何给定时刻只做一件事**。**
  • 并行:指的是多个任务同时执行。这通常发生在多核或多处理器系统上,其中每个核或处理器同时执行不同的任务。通过并行执行,任务的处理速度可以大大加快,因为它们真正地同时发生,而不是交替执行。
    • 多个厨师在一个厨房里同时工作,一个厨师煮汤,另一个厨师切蔬菜。任务在同一时间发生,每个厨师都在同时工作

并发与并行并不相同,并发主要由切换时间片来实现“同时”运行并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。

3.2 goroutine

goroutine : 是Go语言并发设计的核心

goroutine 是 Go 语言中的并发执行体,是一种比线程更加轻量级的协程实现。每个 goroutine 占用的内存远比线程少(通常在几 kb 级别),因此在同一程序中可以同时运行数以千计的 goroutine。

goroutine 的调度是由 Go 语言的运行时(runtime)进行管理的,而非操作系统。这意味着 goroutine 的创建、销毁和切换成本非常低。goroutine 在执行时可以与其他 goroutine 同时运行(在多核处理器上实际实现并行),或者在单个核上交替运行来实现并发。

// go关键字可以创建 goroutine,将go声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程
// 这种线程在Go语言中则被称为 goroutine

//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
go GetThingDone(param1, param2);
//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
    //do someting...
}

// goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU
// Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine

// 普通函数创建goroutine
// go 函数名( 参数列表 )
// go 关键字创建 goroutine 时,被调用函数的返回值会被忽略, 如果需要在goroutine中返回数据需要通过通道把数据从goroutine中作为返回值传出
package main
import (
    "fmt"
    "time"
)
func running() {
    var times int
    // 构建一个无限循环
    for i:=0; i<=10; i++{
        times++
        fmt.Println("tick", times)
        // 延时1秒
        time.Sleep(time.Second)
    }
}
func main() {
    // 并发执行程序
    // 接受命令行输入, 不做任何事情
    fmt.Println("主程序在运行")
    go running()
    time.Sleep(5*time.Second)
    fmt.Println("主程序退出")
}
// Go 程序在启动时,运行时(runtime)会默认为 main() 函数创建一个 goroutine。
// 在 main() 函数的 goroutine 中执行到 go running 语句时,归属于 running() 函数的 goroutine 被创建,running() 函数开始在自己的 goroutine 中执行。
// 此时,main() 继续执行,两个 goroutine 通过 Go 程序的调度机制同时运作。 但如果main函数执行完了,此时running函数也就没法执行了。

// 匿名函数创建goroutine
// 使用匿名函数或闭包创建 goroutine 时,除了将函数定义部分写在 go 的后面之外,还需要加上匿名函数的调用参数
go func( 参数列表 ){
    函数体
}( 调用参数列表 )

func main() {
    // 并发执行程序
    // 接受命令行输入, 不做任何事情
    fmt.Println("主程序在运行")
    go func() {
        var times int
        for {
            times++
            fmt.Println("tick", times)
            time.Sleep(time.Second)
        }
    }()
    time.Sleep(5*time.Second)
    fmt.Println("主程序退出")
}
// 所有 goroutine 在 main() 函数结束时会一同结束

与线程的区别:

  • 资源消耗: goroutine 在内存消耗方面比线程更少,可以轻松创建成千上万个而不会耗尽系统资源。
  • 调度: 线程由操作系统内核进行调度,上下文切换比较重;而 goroutine 由 Go 运行时管理,上下文切换轻便。
  • 启动速度: goroutine 启动速度远快于线程。
  • 共享内存: 线程共享进程的内存空间;goroutine 在并发过程中,通常通过 channel 来进行通信,避免了共享内存带来的复杂性。
  • 同步机制: 线程同步可采用多种机制如互斥锁、信号量等;goroutine 推荐使用 channel 进行同步,提倡 “不通过共享内存来通信,而通过通信来共享内存”。
    • 共享数据和消息是工程上常用的两种并发通信模型,共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享,最常见的共享内存,消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存
    • Go语言使用的是后一种通信模型,即以消息机制而非共享内存作为通信方式。

3.3 channel

3.3.1 初识

上面提到了goroutine之间的通信方式,就是channel, 这是个啥东西呢?

Channel 是 Go 并发中的另一个核心概念,用于实现在 goroutine 之间的通信。在 Go 语言的并发模型中,channel 起着至关重要的作用,它提供了一种安全的方法使得数据在不同的 goroutine 之间传递而无需担心并发的问题。

一个 channel 类似于一个传递信息的管道,你可以从一个端点向管道发送数据(使用 channel <- data 语句),然后在管道的另一端接收数据(使用 data <- channel 语句)。这个过程可以保证同一时间只有一个 goroutine 可以访问数据,从而避免了数据竞争的情况发生。

通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序

特点:

  • 同步性: 发送操作和接收操作都是同步的,即在数据没有被接收之前,发送者会被阻塞,在数据被发送之前,接收者也会被阻塞。因此,channel 也可以被用作不同 goroutine 之间的同步工具。
  • 安全性: 使用 channel 传递数据是安全的,它避免了共享内存访问的复杂性和风险,因此不需要使用锁等同步机制就能保证数据的一致性。
  • 有缓冲和无缓冲: Channel 可以是无缓冲的,也可以是有缓冲的。无缓冲 channel 在发送操作和接收操作准备好之前会阻塞;有缓冲 channel 则为数据提供了一个缓冲空间,只有当缓冲区满的时候,发送操作才会阻塞,当缓冲区为空的时候,接收操作会阻塞。
  • 方向性: Channel 可以是双向的,也可以是单向的。你可以限定一个 channel 只用于发送或者只用于接收,这样可以提供更清晰的使用意图和更好的类型安全。
  • 类型性:一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定
// 创建channel 需要定义发送到 channel 的值的类型
// 注意,必须使用 make 创建 channel
ci := make(chan int)  // 无缓冲的整型channel
ci := make(chan int, 100) // 有缓冲的整型channel, 缓冲大小100
cs := make(chan string)
cf := make(chan interface{}) // 创建一个空接口类型的通道, 可以存放任意格式
type Equip struct{ /* 一些字段 */ }
ch2 := make(chan *Equip)             // 创建Equip指针类型的通道, 可以存放*Equip

// 使用通道发送数据   通道变量<-值   值的类型必须与ch通道的元素类型一致
// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"

// 发送阻塞
// 把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示
func main() {
    ch := make(chan int)
    ch <- 0
} // fatal error: all goroutines are asleep - deadlock! 运行时发现所有的 goroutine(包括main)都处于等待 goroutine。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。

// 接收数据  两个特性
// 1. 通道的收发操作在不同的两个 goroutine 间进行
		// 由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行
// 2. 接收将持续阻塞直到发送方发送数据
	// 如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止
// 3. 每次接收一个元素
	// 通道一次只能接收一个数据元素

// 接收数据4中写法
// 阻塞接收数据
data := <- ch   // 执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量
// 非阻塞接收数据, 可能造成高的 CPU 占用,因此使用非常少
data, ok := <-ch  // 不会阻塞 data表示接收到的数据。未接收到数据时,data 为通道类型的零值, ok表示是否收到数据
// 接收任意数据,忽略接收的数据
<-ch  // 执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步

// demo: 使用通道做并发同步
func main() {
    // 创建一个无缓冲的整型 channel,用于在不同的 goroutine 之间进行通信
    ch := make(chan int)
    // 启动一个新的匿名 goroutine
    go func() {
        fmt.Println("start goroutine")
        // 通过通道通知main的goroutine
        // 这一行代码会向 channel ch 中发送一个整数值 0。因为 ch 是无缓冲的,所以这个发送操作会阻塞当前 goroutine,直到这个值被另一个 goroutine 接收。
        ch <- 0
        fmt.Println("exit goroutine")
    }()
    fmt.Println("wait goroutine")
    // 等待匿名goroutine
    // main goroutine 尝试从 channel ch 中接收数据。
    // 由于 channel 是无缓冲的,这个操作会阻塞,直到匿名goroutine中有数据被发送。这确保了main goroutine会等待匿名goroutine执行到ch<-0这一行
    <-ch
    fmt.Println("all done")
}

// 循环接收通道的数据接收可以借用 for range 语句进行多个元素的接收操作
// 通道的数据接收可以借用 for range 语句进行多个元素的接收操作
// 通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个
func main() {
    // 构建一个通道
    ch := make(chan int)
    // 开启一个并发匿名函数
    go func() {
        // 从3循环到0
        for i := 3; i >= 0; i-- {
            // 发送3到0之间的数值
            ch <- i
            // 每次发送完时等待
            time.Sleep(time.Second)
        }
    }()
    // 遍历接收通道数据
    for data := range ch {
        // 打印通道数据
        fmt.Println(data)
        // 当接收到数值 0 时,停止接收。如果继续发送,由于接收 goroutine 已经退出,没有 goroutine 发送到通道,因此运行时将会触发宕机报错。
        if data == 0 {
            break
        }
    }
}

使用无缓冲通道往里面装入数据时,装入方将被阻塞,直到另外通道在另外一个 goroutine 中被取出。同样,如果通道中没有放入任何数据,接收方试图从通道中获取数据时,同样也是阻塞。发送和接收的操作是同步完成的。

下面展示一个如何使用 channel 在 goroutines 之间同步数据和控制信号 , 简单的典型的生产者和消费者

// 消费者
func printer(c chan int) {
    // 开始无限循环等待数据
    for {
        // 从channel中获取一个数据
        data := <-c
        // 将0视为数据结束
        if data == 0 {
            break
        }
        // 打印数据
        fmt.Println(data)
    }
    // 接收到0后,printer函数会向channel发送一个值0, 通知main()它已完成工作
    c <- 0
}
func main() {
    // 创建一个channel
    c := make(chan int)
    // 并发执行printer, 传入channel
    go printer(c)
    for i := 1; i <= 10; i++ {
        // 将数据通过channel投送给printer
        c <- i
    }
    // 通知并发的printer结束循环(没数据啦!)
    c <- 0
    // main通过从 channel c 中接收数据来等待 printer 函数发送完成信号。这是一个阻塞调用,直到 printer goroutine 发送数据为止
    <-c
}

main goroutine 中的数据发送完毕时,它通过发送一个特殊值 0 来告知 printer goroutine 所有的数据都已经发送完毕,之后 printer 也通过发送 0 来响应 main goroutine 执行已经完成。

3.3.2 带缓冲的channel

带缓冲的通道:一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

// 无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。
// 通道实例 := make(chan 通道类型, 缓冲大小)
func main() {
    // 创建一个3个元素缓冲大小的整型通道
    ch := make(chan int, 3)    // 如果不加缓冲大小, 会报fatal error: all goroutines are asleep - deadlock!的崩溃
    // 查看当前通道的大小
    fmt.Println(len(ch))   // 0    
    // 发送3个整型元素到通道
    // 因为使用了缓冲通道。即便没有 goroutine 接收,发送者也不会发生阻塞
    ch <- 1
    ch <- 2
    ch <- 3
    // 查看当前通道的大小
    fmt.Println(len(ch))    // 3
    
    ch <- 4   // 这里如果再发个4, 由于超出了缓冲大小, 发送被阻塞,会报fatal error: all goroutines are asleep
}

// 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
// 带缓冲通道为空时,尝试接收数据时发生阻塞

为什么Go语言对通道要限制长度而不提供无限长度的通道?

通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据

3.3.3 超时限制

超时限制:所谓超时可以理解为当我们上网浏览一些网站时,如果一段时间之后不作操作,就需要重新登录

// select 来设置超时
// 每个 case 语句里必须是一个 IO 操作
select {
    case <-chan1:
    // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
    // 如果成功向chan2写入数据,则进行该case处理语句
    default:
    // 如果上面都没有成功,则进入default处理流程
}
// 在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。
// 如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用
// 如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:
	// 如果给出了 default 语句,那么就会执行 default 语句,同时程序的执行会从 select 语句后的语句中恢复;
	// 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去
func main() {
    // 两个通道
    ch := make(chan int)  // 用来传输int类型的数据
    quit := make(chan bool)  // 用来控制结束
    
    //新开一个协程
    go func() {
        for {
            select {
                case num := <-ch:  // 处理ch信道的数据传入。当有数据发送到ch通道,就会打印该数据。
                    fmt.Println("num = ", num)
                case <-time.After(3 * time.Second): // 3秒没有任何信道操作时执行time.After会在指定的时间后(3秒)返回一个时间点,在这个时间点将可以从返回的通道读取到时间数据。如果3秒内ch通道都没有收到数据,将执行这个分支并打印“超时”,然后向quit通道发送一个标记值。
                    fmt.Println("超时")
                    quit <- true
            }
        }
    }()
    for i := 0; i < 5; i++ {
        ch <- i
        time.Sleep(time.Second)
    }
    <-quit   // 这行代码会阻止main goroutine,直到它从quit信道接收到一个值。这样做可以让main goroutine等待其他goroutine完成
    fmt.Println("程序结束")
}

// 这个程序新建一个goroutine,这个goroutine会每秒钟从ch通道接收一个值并打印出来。如果3秒内没有收到任何数据,它将打印“超时”,并发送一个值到quit通道,使main goroutine结束其等待,程序随后打印出"程序结束"并退出

3.3.4 select关键字

Go语言中的 select 关键字,还可以同时响应多个通道的操作, 多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。

select{
    case 操作1:
        响应操作1
    case 操作2:
        响应操作2default:
        没有操作情况
}
// 每个 case 语句里必须是一个 IO 操作
// 操作1、操作2:包含通道收发语句 接收任意数据	case <- ch;  接收变量	case d :=  <- ch   发送数据	case ch <- 100;
// 响应操作1、响应操作2:当操作发生时,会执行对应 case 的响应操作
// default:当没有任何操作时,默认执行 default 中的语句

// 通道计时器的demo: 巧妙使用select和time.Afterfunc
func main() {
    // 声明一个退出用的通道
    exit := make(chan int)
    // 打印开始
    fmt.Println("start")
    // 过1秒后, 调用匿名函数
    // 调用 time.AfterFunc() 函数,传入等待的时间和一个回调。回调使用一个匿名函数,在时间到达后,匿名函数会在另外一个 goroutine 中被调用。
    time.AfterFunc(time.Second, func() {   // time.AfterFunc() 函数是在 time.After 基础上增加了到时的回调,方便使用
        // 1秒后, 打印结果
        fmt.Println("one second after")    
        // 通知main()的goroutine已经结束
        exit <- 0
    })
    // 等待结束
    <-exit   
}

// 定点记时功能
func main() {
    // 创建一个打点器, 每500毫秒触发一次
    ticker := time.NewTicker(time.Millisecond * 500)
    // 创建一个计时器, 2秒后触发
    stopper := time.NewTimer(time.Second * 2)
    // 声明计数变量
    var i int
    // 不断地检查通道情况
    for {
        // 多路复用通道
        select {
        case <-stopper.C:  // 计时器到时了
            fmt.Println("stop")
            // 跳出循环
            goto StopHere
        case <-ticker.C:  // 打点器触发了
            // 记录触发了多少次
            i++
            fmt.Println("tick", i)
        }
    }
// 退出的标签, 使用goto跳转
StopHere:
    fmt.Println("done")
}

3.3.5 通道关闭

通道关闭: 通道是一个引用对象,和 map 类似。map 在没有任何外部引用时,Go语言程序在运行时(runtime)会自动对内存进行垃圾回收(Garbage Collection, GC)。类似的,通道也可以被垃圾回收,但是通道也可以被主动关闭

// 主动关闭
close(ch)  
// 关闭的通道依然可以被访问,访问被关闭的通道将会发生一些问题

// 1. 关闭的通道不支持继续发送数据, 此时会引起宕机
func main() {
    // 创建一个整型的通道
    ch := make(chan int)
    // 关闭通道
    close(ch)
    // 打印通道的指针, 容量和长度
    fmt.Printf("ptr:%p cap:%d len:%d\n", ch, cap(ch), len(ch))  // ptr:0xc00005a060 cap:0 len:0
    // 给关闭的通道发送数据
    ch <- 1          // panic: send on closed channel
}

// 2. 从已关闭的通道接收数据时将不会发生阻塞
func main() {
    // 创建一个整型带两个缓冲的通道
    ch := make(chan int, 2)
   
    // 给通道放入两个数据
    ch <- 0
    ch <- 1
   
    // 关闭缓冲
    close(ch)
    // 遍历缓冲所有数据, 且多遍历1个
    for i := 0; i < cap(ch)+1; i++ {
   
        // 从通道中取出数据
        v, ok := <-ch
       
        // 打印取出数据的状态
        fmt.Println(v, ok)
    }
}
// 结果 
0 true
1 true
0 false   // 没有数据了

// 运行结果前两行正确输出带缓冲通道的数据,表明缓冲通道在关闭后依然可以访问内部的数据
// 在通道关闭后,即便通道没有数据,在获取时也不会发生阻塞,但此时取出数据会失败

3.3.6 多核并行化

多核并行化:Go语言具有支持高并发的特性,可以很方便地实现多线程运算,充分利用多核心 cpu 的性能

// demo 
package main
import (
    "fmt"
    "time"
    "runtime"
)
func main() {
    
    cpuNum := runtime.NumCPU() //获得当前设备的cpu核心数
    fmt.Println("cpu核心数:", cpuNum)
    runtime.GOMAXPROCS(cpuNum) //设置需要用到的cpu数量
    
    
    for i := 0; i < 5; i++ {
        go AsyncFunc(i)
    }
    
    time.Sleep(10*time.Second)
}
func AsyncFunc(index int) {
    sum := 0
    for i := 0; i < 10000; i++ {
        sum += 1
    }
    fmt.Printf("线程%d, sum为:%d\n", index, sum)
}

等待组:Go语言中除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务

  • 等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。
// 实现: sync.WaitGroup
// 方法: (wg * WaitGroup) Add(delta int) 计数器+1, (wg * WaitGroup) Done() 计数器-1, (wg * WaitGroup) Wait() 当等待组计数器不等于 0 时阻塞直到变 0
package main
import (
    "fmt"
    "net/http"
    "sync"
)
func main() {
    // 声明一个等待组
    var wg sync.WaitGroup
    // 准备一系列的网站地址
    var urls = []string{
        "http://www.github.com/",
        "https://www.qiniu.com/",
        "https://www.golangtc.com/",
    }
    // 遍历这些地址
    for _, url := range urls {
        // 每一个任务开始时, 将等待组增加1
        wg.Add(1)
        // 开启一个并发
        go func(url string) {
            // 使用defer, 表示函数完成时将等待组值减1
            defer wg.Done()
            // 使用http访问提供的地址
            _, err := http.Get(url)
            // 访问完成后, 打印地址和可能发生的错误
            fmt.Println(url, err)
            // 通过参数传递url地址
        }(url)
    }
    // 等待所有的任务完成
    wg.Wait()
    fmt.Println("over")
}

// 感觉这东西好用啊, 就不用担心main 运行完了,但还有其他goroutine没有完成的情况了

4 反射

4.1 简述

Go语言提供了一种机制在运行时更新和检查变量的值、调用变量的方法和变量支持的内在操作,但是在编译时并不知道这些变量的具体类型,这种机制被称为反射。

反射是指在程序运行期对程序本身进行访问和修改的能力,程序在编译时变量被转换为内存地址,变量名不会被编译器写入到可执行部分,在运行程序时程序无法获取自身的信息。

支持反射的语言(Java,go)可以在程序编译期将变量的反射信息,如字段名称、类型信息、结构体信息等整合到可执行文件中,并给程序提供接口访问反射信息,这样就可以在程序运行期获取类型的反射信息,并且有能力修改它们

Go语言中的反射是由 reflect 包提供支持的,它定义了两个重要的类型 Type 和 Value 任意接口值在反射中都可以理解为由 reflect.Type 和 reflect.Value 两部分组成,并且 reflect 包提供了 reflect.TypeOf 和 reflect.ValueOf 两个函数来获取任意对象的 Value 和 Type

// reflect.TypeOf() 函数可以获得任意值的类型对象(reflect.Type)
// 程序通过类型对象可以访问任意值的类型信息
import (
    "fmt"
    "reflect"
)
func main() {
    var a int
    typeOfA := reflect.TypeOf(a)
    fmt.Println(typeOfA.Name(), typeOfA.Kind())  // 类型名 int   种类 int
}

// Go语言程序中的类型(Type)指的是系统原生数据类型,如 int、string、bool、float32 等类型,以及使用 type 关键字定义的类型,这些类型的名称就是其类型本身的名称。例如使用 type A struct{} 定义结构体时,A 就是 struct{} 的类型
// 种类(Kind)指的是对象归属的品种 int64, int8, float64, array, chan, map, list, struct,func, ptr, interface等这种
// 定义一个Enum类型
type Enum int
const (
    Zero Enum = 0
)
func main() {
    // 声明一个空结构体
    type cat struct {
    }
    // 获取结构体实例的反射类型对象
    typeOfCat := reflect.TypeOf(cat{})
    // 显示反射类型对象的名称和种类
    fmt.Println(typeOfCat.Name(), typeOfCat.Kind())   // cat  struct
    // 获取Zero常量的反射类型对象
    typeOfA := reflect.TypeOf(Zero)   
    // 显示反射类型对象的名称和种类
    fmt.Println(typeOfA.Name(), typeOfA.Kind()) // Enum  int
}

4.2 指针和指针指向的元素

// Go语言程序中对指针获取反射对象时,可以通过 reflect.Elem() 方法获取这个指针指向的元素类型,这个获取过程被称为取元素,等效于对指针类型变量做了一个*操作
func main() {
    // 声明一个空结构体
    type cat struct {
    }
    // 创建cat的实例
    ins := &cat{}
    // 获取结构体实例的反射类型对象
    typeOfCat := reflect.TypeOf(ins)
    // 显示反射类型对象的名称和种类
    // 指针变量的类型名称是空,不是 *cat。
    // Go语言的反射中对所有指针变量的种类都是 Ptr
    fmt.Printf("name:'%v' kind:'%v'\n", typeOfCat.Name(), typeOfCat.Kind())  // ''  'ptr' 
    
    // 取类型的元素
    typeOfCat = typeOfCat.Elem()
    // 显示反射类型对象的名称和种类
    fmt.Printf("element name: '%v', element kind: '%v'\n", typeOfCat.Name(), typeOfCat.Kind())  // cat  struct
}

4.3 获取结构体成员类型

// 任意值通过 reflect.TypeOf() 获得反射对象信息后,如果它的类型是结构体,可以通过反射值对象 reflect.Type 的 NumField() 和 Field() 方法获得结构体成员的详细信息。
// relfect.Type的相关方法
	// Field(i int) StructField	根据索引返回索引对应的结构体字段的信息,当值不是结构体或索引超界时发生宕机
	// NumField() int	返回结构体成员字段数量,当类型不是结构体或索引超界时发生宕机
	// FieldByName(name string) (StructField, bool)	根据给定字符串返回字符串对应的结构体字段的信息,没有找到时 bool 返回 false,当类型不是结构体或索引超界时发生宕机
	// FieldByIndex(index []int) StructField	多层成员访问时,根据 []int 提供的每个结构体的字段索引,返回字段的信息,没有找到时返回零值。当类型不是结构体或索引超界时发生宕机
	// FieldByNameFunc(match func(string) bool) (StructField,bool)	根据匹配函数匹配需要的字段,当值不是结构体或索引超界时发生宕机

// reflect.Type 的 Field() 方法返回 StructField 结构,这个结构描述结构体的成员信息
// 通过这个信息可以获取成员与结构体的关系,如偏移、索引、是否为匿名字段、结构体标签(StructTag)等,而且还可以通过 StructField 的 Type 字段进一步获取结构体成员的类型信息
type StructField struct {
    Name string          // 字段名
    PkgPath string       // 字段路径
    Type      Type       // 字段反射类型对象
    Tag       StructTag  // 字段的结构体标签
    Offset    uintptr    // 字段在结构体中的相对偏移
    Index     []int      // Type.FieldByIndex中的返回的索引值
    Anonymous bool       // 是否为匿名字段
}

// demo
func main() {
    // 声明一个空结构体
    type cat struct {
        Name string
        // 带有结构体tag的字段   后面的``是tag
        // 结构体标签是对结构体字段的额外信息标签。
				// JSON、BSON 等格式进行序列化及对象关系映射(Object Relational Mapping,简称 ORM)系统都会用到结构体标签,这些系统使用标签设定字段在处理时应该具备的特殊属性和可能发生的行为。这些信息都是静态的,无须实例化结构体,可以通过反射获取到。
        Type int `json:"type" id:"100"`   // 格式`key1:"value1" key2:"value2"`  键值对之间空格分开, :后面不能有空格
    }
    // 创建cat的实例
    ins := cat{Name: "mimi", Type: 1}
    // 获取结构体实例的反射类型对象
    typeOfCat := reflect.TypeOf(ins)
    // 遍历结构体所有成员
    for i := 0; i < typeOfCat.NumField(); i++ {
        // 获取每个成员的结构体字段类型
        fieldType := typeOfCat.Field(i)
        // 输出成员名和tag
        fmt.Printf("name: %v  tag: '%v'  type: %v\n", fieldType.Name, fieldType.Tag, fieldType.Type)
    }
    // 通过字段名, 找到字段类型信息
    if catType, ok := typeOfCat.FieldByName("Type"); ok {
        // 从tag中取出需要的tag
        fmt.Println(catType.Tag.Get("json"), catType.Tag.Get("id"))
    }
    
    // 当已知 reflect.Type 时,可以动态地创建这个类型的实例,实例的类型为指针
    catIns := reflect.New(typeOfCat)
    fmt.Println(catIns.Type(), catIns.Kind())   // *main.cat ptr
    catInsEle := catIns.Elem()         // 获取指向的value对象
    fmt.Println(catInsEle.FieldByName("Name").Type(), catInsEle.FieldByName("Name").Interface())  // string ""
    fmt.Println(catInsEle.FieldByName("Name").Type(), catInsEle.FieldByName("Name").String())  // string ""
}

4.4 获取值信息

// value := reflect.ValueOf(rawValue)

// 声明整型变量a并赋初值
var a int = 1024
// 获取变量a的反射值对象
valueOfA := reflect.ValueOf(a)
// 获取interface{}类型的值, 通过类型断言转换
var getA int = valueOfA.Interface().(int)
// 获取64位的值, 强制类型转换为int类型
var getA2 int = int(valueOfA.Int())

// 访问结构体成员的值
// 反射值对象(reflect.Value)提供对结构体访问的方法,通过这些方法可以完成对结构体任意值的访问
	// Field(i int) Value	根据索引,返回索引对应的结构体成员字段的反射值对象。当值不是结构体或索引超界时发生宕机
	// NumField() int	返回结构体成员字段数量。当值不是结构体或索引超界时发生宕机
	// FieldByName(name string) Value	根据给定字符串返回字符串对应的结构体字段。没有找到时返回零值,当值不是结构体或索引超界时发生宕机
	//FieldByIndex(index []int) Value	多层成员访问时,根据 []int 提供的每个结构体的字段索引,返回字段的值。 没有找到时返回零值,当值不是结构体或索引超界时发生宕机
	//FieldByNameFunc(match func(string) bool) Value	根据匹配函数匹配需要的字段。找到时返回零值,当值不是结构体或索引超界时发生宕机
// 定义结构体
type dummy struct {
    a int
    b string
    // 嵌入字段
    float32
    bool
    next *dummy
}
func main() {
    // 值包装结构体
    d := reflect.ValueOf(dummy{
            next: &dummy{},
    })
    // 获取字段数量
    fmt.Println("NumField", d.NumField())  // 5
    // 获取索引为2的字段(float32字段)
    floatField := d.Field(2)   
    // 输出字段类型
    fmt.Println("Field", floatField.Type())  // float32
    // 根据名字查找字段
    fmt.Println("FieldByName(\"b\").Type", d.FieldByName("b").Type())  // string
    // 根据索引查找值中, next字段的int字段的值
    fmt.Println("FieldByIndex([]int{4, 0}).Type()", d.FieldByIndex([]int{4, 0}).Type())   // int

4.5 反射的规则

// Go语言中使用反射可以在编译时不知道类型的情况下更新变量,在运行时查看值、调用方法以及直接对他们的布局进行操作
// 反射是建立在类型系统上的
// Go语言是一门静态类型的语言,每个变量都有一个静态类型,类型在编译的时候确定下来
var i int
type myint int
var j myint
// 变量 i 的类型是 int,变量 j 的类型是 MyInt,虽然它们有着相同的基本类型,但静态类型却不一样,在没有类型转换的情况下,它们之间无法互相赋值。

// 接口是一个重要的类型,它意味着一个确定的方法集合,一个接口变量可以存储任何实现了接口的方法的具体值
// interface{} 表示了一个空的方法集,一切值都可以满足它,因为它们都有零值或方法
// But 接口依然有自己的静态类型,虽然在运行时中,接口变量存储的值也许会变,但接口变量的类型是不会变的

// 基于这些背景,看go语言的3条反射定律
// 1. 反射可以将“接口类型变量”转换为“反射类型对象”  这里反射类型指 reflect.Type 和 reflect.Value, 这两种类型可以访问接口变量内的数据
	// 基于reflect.TypeOf 和 reflect.ValueOf两个方法
func main() {
    var x float64 = 3.4
    fmt.Println("type:", reflect.TypeOf(x))   // float64
    // 调用 reflect.TypeOf(x) 时,x 被存储在一个空接口变量中被传递过去,然后 reflect.TypeOf 对空接口变量进行拆解,恢复其类型信息
    
    fmt.Println("value:", reflect.ValueOf(x)) // 3.4 
}
// Value对象还有一些常用的其他方法
	//Type() 返回一个 reflect.Type 类型的对象
	// Kind(),它会返回一个常量,表示底层数据的类型,常见值有:Uint、Float64、Slice 
	// Int、Float 的方法,用来提取底层的数据, Int() 提取int64的数据, Float()提取float64的数据
func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(x)   // 返回一个Value对象
    fmt.Println("type:", v.Type())   // float64
    fmt.Println("kind is float64:", v.Kind() == reflect.Float64)   // true
    fmt.Println("value:", v.Float())   // 3.4  提取值
    
    // 注意: 反射对象的 Kind 方法描述的是基础类型,而不是静态类型。这俩概念是不一样的。如果一个反射对象包含了用户定义类型的值
    // Kind 方法不会像 Type 方法一样区分 MyInt 和 int
    type MyInt int
    var x MyInt = 7
    v := reflect.ValueOf(x)
    fmt.Println(v.Type())  // main.MyInt
    fmt.Println(v.Kind())  // int
}

// 2. 反射可以将“反射类型对象”转换为“接口类型变量” 根据一个 reflect.Value 类型的变量,我们可以使用 Interface 方法恢复其接口类型的值
// Interface returns v's value as an interface{}.
// func (v Value) Interface() interface{}
var x MyInt = 7
v := reflect.ValueOf(x)
fmt.Println(v.Interface())   // 7   Interface 方法和 ValueOf 函数作用恰好相反,唯一一点是,返回值的静态类型是 interface{}  
// 可以转换成其他类型了
var y int = v.Interface().(int)

// 3. 如果要修改“反射类型对象”其值必须是“可写的”
// 比如, 我想用一些用来修改数据的方法,比如 SetInt、SetFloat 来修改反射类型对象的值, 前提是反射类型的值必须可写,这啥意思?
var x float64 = 3.4
v := reflect.ValueOf(x)   // v是反射类型对象
v.SetFloat(7.1) // Error: will panic  原因是变量v是“不可写的”
fmt.Println("settability of v:", v.CanSet())  // false
// why? “可写性”最终是由一个反射对象是否存储了原始值而决定的, v:=reflect.ValueOf(x)是一个值传递,是把x拷贝了一份,传递给ValueOf函数得到的v,所以
// v.SetFloat(7.1) 看起来变量 v 是根据 x 创建的,相反它会更新 x 存在于反射对象 v 内部的一个拷贝,而变量 x 本身完全不受影响。这会造成迷惑,并且没有任何意义,所以是不合法的。“可写性”就是为了避免这个问题而设计的。
// 想通过反射修改变量 x,就要把想要修改的变量的指针传递给反射库
var x float64 = 3.4
p := reflect.ValueOf(&x) // Note: take the address of x.
fmt.Println("type of p:", p.Type())   // type of p: *float64
fmt.Println("settability of p:", p.CanSet())  // false  这个依然不可写,原因是p是指针变量, 我们得通过指针变量得到指向的变量
v := p.Elem()
fmt.Println("settability of v:", v.CanSet())  // true  这个就可写了

// 所以如果想通过反射修改值,需要把变量的值通过地址传入,得到反射对象,然后再拿到反射对象指向的变量,修改变量才行, 完整代码如下:
var x float64 = 3.4
p := reflect.ValueOf(&x) // Note: take the address of x.
v := p.Elem()
v.SetFloat(7.1)
fmt.Println(v.Interface())  // 7.1
fmt.Println(x)   // 7.1
// 所以反射对象要修改它们表示的对象,就必须获取它们表示的对象的地址。
// 其他方法
	//Setlnt(x int64)	使用 int64 设置值。当值的类型不是 int、int8、int16、 int32、int64 时会发生宕机
	//SetUint(x uint64)	使用 uint64 设置值。当值的类型不是 uint、uint8、uint16、uint32、uint64 时会发生宕机
	//SetFloat(x float64)	使用 float64 设置值。当值的类型不是 float32、float64 时会发生宕机
	//SetBool(x bool)	使用 bool 设置值。当值的类型不是 bod 时会发生宕机
	//SetBytes(x []byte)	设置字节数组 []bytes值。当值的类型不是 []byte 时会发生宕机
	//SetString(x string)	设置字符串值。当值的类型不是 string 时会发生宕机

// 如果是通过反射修改结构体字段的值,那需要值可导出
type dog struct {
        legCount int            
}
// 获取dog实例地址的反射值对象
valueOfDog := reflect.ValueOf(&dog{})
// 取出dog实例地址的元素
valueOfDog = valueOfDog.Elem()
// 获取legCount字段的值
vLegCount := valueOfDog.FieldByName("legCount")
// 尝试设置legCount的值(这里会发生崩溃)
vLegCount.SetInt(4)               // panic: reflect: reflect.Value.SetInt using value obtained using unexported field
fmt.Println(vLegCount.Int())

// 成员变量首字母大写才可以导出,把legCount改成LegCount

值的修改从表面意义上叫可寻址,换一种说法就是值必须“可被设置”。那么,想修改变量值,一般的步骤是:
1. 取这个变量的地址或者这个变量所在的结构体已经是指针类型。
2. 使用 reflect.ValueOf 进行值包装。
3. 通过 Value.Elem() 获得指针值指向的元素值对象(Value),因为值对象(Value)内部对象为指针时,使用 set 设置时会报出宕机错误。
4. 使用 Value.Set 设置值。
5. 如果是修改结构体成员字段, 则结构体成员字段名首字母需要大写,才能被反射机制访问(可导出)

4.6 判断反射值的空和有效性

// IsNil() bool	返回值是否为 nil。如果值类型不是通道(channel)、函数、接口、map、指针或 切片时发生 panic,类似于语言层的v== nil操作
// IsValid() bool	判断值是否有效。 当值本身非法时,返回 false,例如 reflect Value不包含任何值,值为 nil 等。
func main() {
    // *int的空指针
    var a *int
    fmt.Println("var a *int:", reflect.ValueOf(a).IsNil())  // true
    // nil值
    fmt.Println("nil:", reflect.ValueOf(nil).IsValid())  // false
    // *int类型的空指针
    fmt.Println("(*int)(nil):", reflect.ValueOf((*int)(nil)).Elem().IsValid())  // false
    // 实例化一个结构体
    s := struct{}{}
    // 尝试从结构体中查找一个不存在的字段
    fmt.Println("不存在的结构体成员:", reflect.ValueOf(s).FieldByName("").IsValid())  // false
    // 尝试从结构体中查找一个不存在的方法
    fmt.Println("不存在的结构体方法:", reflect.ValueOf(s).MethodByName("").IsValid())  // false
    // 实例化一个map
    m := map[int]int{}
    // 尝试从map中查找一个不存在的键
    fmt.Println("不存在的键:", reflect.ValueOf(m).MapIndex(reflect.ValueOf(3)).IsValid())  // false
}

// IsNil() 常被用于判断指针是否为空;IsValid() 常被用于判定返回值是否有效

通过反射调用函数:如果反射值对象(reflect.Value)中值的类型为函数时,可以通过 reflect.Value 调用该函数。使用反射调用函数时,需要将参数使用反射值对象的切片 []reflect.Value 构造后传入 Call() 方法中,调用完成时,函数的返回值通过 []reflect.Value 返回

// 普通函数
func add(a, b int) int {
    return a + b
}
func main() {
    // 将函数包装为反射值对象
    funcValue := reflect.ValueOf(add)
    // 构造函数参数, 传入两个整型值
    paramList := []reflect.Value{reflect.ValueOf(10), reflect.ValueOf(20)}
    // 反射调用函数
    retList := funcValue.Call(paramList)
    // 获取第一个返回值, 取整数值
    fmt.Println(retList[0].Int())  // 30
}

// 反射调用函数的过程需要构造大量的 reflect.Value 和中间变量,对函数参数值进行逐一检查,还需要将调用参数复制到调用函数的参数内存中。
// 调用完毕后,还需要将返回值转换为 reflect.Value,用户还需要从中取出调用值。因此,反射调用函数的性能问题尤为突出,不建议大量使用反射函数调用

4.7 反射小总

在Go语言中,反射是一种强大且复杂的特性。下面介绍其主要的用途:

  • 动态函数调用:我们可以通过反射,得到一个函数的反射值对象后,动态调用这个函数。— 依赖注入
  • 动态创建对象:给定类型,我们可以通过反射创建这个类型的对象。 — 依赖注入
  • 在程序运行期间动态操作对象的字段和方法:反射可以在运行时动态获取并操作对象的字段和方法。例如,标准库中的 encoding/jsonencoding/xml 包就是通过反射来实现的。
  • 支持’类型检查和转换‘:反射有助于检查未知类型的变量并对其进行类型断言。这个在很多应用中非常实用,比如可以用在数据库操作的ORM框架中映射和转换不同数据类型,或者GUI编程中处理用户输入的数据类型。
  • 实现’泛型编程‘:Go语言不直接支持泛型编程,但可以通过反射来模拟。反射可以允许我们在运行时检查和操作变量的类型和值,从而写出能适应多种数据类型的函数。

总的来说,反射的主要价值就在于能够让我们在程序运行期间操作对象和变量,使我们有能力处理灵活的数据结构和更通用的函数及方法。但是同时需要注意,反射操作相对直接的读写操作要慢,复杂度也较高,非必要情况下应尽量避免使用。

使用建议:

  • 能使用原生代码时,尽量避免反射操作。
  • 提前缓冲反射值对象,对性能有很大的帮助。
  • 避免反射函数调用,实在需要调用时,先提前缓冲函数参数列表,并且尽量少地使用返回值。

不过学习到这里, 我其实还是没明白反射到底用来干什么, 到底啥东西非得到运行时去动态改? 啥函数或者对象非得运行时去创建? 带着这俩疑问, 我开始问chatgpt, 根据回答, 我大概知道反射可能好使的两个场景了,如果其他再有,我再补充:

  1. 动态结构体标签(Struct Tag)的处理

    第一个场景,就是结构体的字段标签处理,Go语言中,结构体的字段可以通过标签(Tag)来提供额外的元数据,这些标签与结构体字段定义一起,通常用于为字段提供编解码、验证规则或其他的说明信息,比如JSON序列化/反序列化时指定字段的名称。这些标签本身是静态定义在代码中的,本质上是一种编译时的元数据标注,并不需要通过反射来添加。标签的使用是在结构体字段声明的时候直接加上的

    
    type Example struct {
        FieldName FieldType `tagname:"tagvalue"`
    }
    

    But 所有关于标签的处理,包括读取它们的值,需要通过reflect包来完成,这也意呸着标签的解析和使用是在运行时发生的。也就是说标签在源代码中是静态定义的,我们需要在运行时利用反射来读取它们的值。这是因为结构体的字段标签属于类型元数据的一部分,在编译阶段就已经确定,而反射提供了在运行时查询和操作这些元数据的能力

    // 假设我们有一个用于存储用户数据的结构体,其字段根据结构体标签来决定是否需要被加密保存。
    // 在这种情况下,使用反射来动态读取字段的标签并根据标签值处理数据变得尤为重要。
    package main
    
    import (
        "fmt"
        "reflect"
    )
    
    type User struct {
        Name   string `security:"none"`
        Email  string `security:"encrypt"`
        Age    int    `security:"none"`
    }
    
    // EncryptFields 加密有"encrypt"标签的字段
    func EncryptFields(u *User) {
        val := reflect.ValueOf(u).Elem()
        for i := 0; i < val.NumField(); i++ {
            field := val.Type().Field(i)
            tag := field.Tag.Get("security")
            if tag == "encrypt" {
                // 假设这里是一个加密函数
                val.Field(i).SetString("encrypted")
            }
        }
    }
    
    func main() {
        user := User{
            Name:   "John Doe",
            Email:  "john@example.com",
            Age:    30,
        }
    
        fmt.Println("Before encryption:", user)  // Before encryption: {John Doe john@example.com 30}
        EncryptFields(&user)
        fmt.Println("After encryption:", user)  // After encryption: {John Doe encrypted 30}
    }
    
  2. 读取动态配置给结构体的字段赋值(动态创建对象)

    这个场景是,我们定义了一个配置结构体,包含多个字段,同时每个字段都有一个标签定义了对应的配置项名称。

    我们可以动态地读取这些标签,并根据配置项名称从配置源(比如环境变量)中获取对应的值来填充这个结构体。

    package main
    import (
        "fmt"
        "reflect"
        "strings"
    )
    
    // Configuration 是程序配置信息的结构体
    type Configuration struct {
        Host     string `config:"APP_HOST"`
        Port     int    `config:"APP_PORT"`
        DebugMode bool   `config:"DEBUG_MODE"`
    }
    
    // LoadConfiguration 从环境变量加载配置信息
    func LoadConfiguration(config *Configuration) error {
        val := reflect.ValueOf(config).Elem()  // 获取指向结构体的反射值对象
        typ := val.Type()                      // 获取结构体的类型信息
    
        for i := 0; i < typ.NumField(); i++ {
            field := typ.Field(i)      // 结构体的字段
    
            // 假设configValue为从配置源获取的值,这里为了演示,我们将字段名转为大写模拟环境变量值
            // 实际中可以从环境变量里面读取值, 环境变量的取值是可以动态修改的
            configValue := strings.ToUpper(field.Name)
            fieldVal := val.Field(i)   // 获取字段的反射值
    
            // 确保这里可以设置字段的值  运行时去设置结构体字段的值
            if fieldVal.CanSet() {
                switch fieldVal.Kind() {
                case reflect.String:
                    fieldVal.SetString(configValue)
                case reflect.Int:
                    // 假设都设置为8080
                    fieldVal.SetInt(8080)
                case reflect.Bool:
                    // 假设都设置为true
                    fieldVal.SetBool(true)
                // ...其他类型
                }
            }
        }
        return nil
    }
    
    func main() {
        config := &Configuration{}
        err := LoadConfiguration(config)
        if err != nil {
            fmt.Println("Failed to load configuration:", err)
            return
        }
        fmt.Printf("Loaded configuration: %+v\n", config)  // Loaded configuration: &{Host:HOST Port:8080 DebugMode:true}
    }
    

    这个实际使用我理解可以写一个初始化函数, 把相关的配置参数写到一个配置文件里面,这个初始化函数运行时加载这个配置文件, 拿到参数赋值到一个公共的结构体全局变量,后面的程序就共用这个结构体就可以了。

5. 小总

内容先整理这么多啦, go进阶篇的东西也是不少, 并且非常重要, go的并发很重要,也是面试非常喜欢考的内容,goroutine以及channel, 反射这块这次我也重点学习了下, 并且根据gpt扩展了一些内容帮助理解, 原因是这块的知识我是第一次接触,c/C++当时没有学习反射,python的话是动态语言,也没有反射,所以由于好奇重点学习了下

到这里, 整个go就摸排的差不多了, 后面还有一篇文章想整理一点杂项了,比如常用的包,以及对文件的读写操作等, 继续Rush 😃

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/607365.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

python代码无法点击进入,如何破???

python代码无法点击进入&#xff0c;如何破&#xff1f;&#xff1f;&#xff1f; 举个栗子&#xff1a; model.chat是无法进入的&#xff0c;这时可以使用如下的命令进行操作&#xff1a; ?model.chat

Faiss核心解析:提升推荐系统的利器【AI写作免费】

首先&#xff0c;这篇文章是基于笔尖AI写作进行文章创作的&#xff0c;喜欢的宝子&#xff0c;也可以去体验下&#xff0c;解放双手&#xff0c;上班直接摸鱼~ 按照惯例&#xff0c;先介绍下这款笔尖AI写作&#xff0c;宝子也可以直接下滑跳过看正文~ 笔尖Ai写作&#xff1a;…

今天又发现一个有意思的问题:SQL Server安装过程中下载报错,证明GPT是可以解决问题的

我们在安装数据库的时候&#xff0c;都会有报错问题&#xff0c;无论是Oracle、SQL Server、还是MySQL&#xff0c;都会遇到各种各样的报错&#xff0c;这归根到底还是因为电脑环境的不同&#xff0c;和用户安装的时候&#xff0c;操作习惯的不一样导致的问题。今天的问题是&am…

当前主机使用的磁盘以及带宽情况

今日看到有用户在论坛留言反馈他买了Hostease Linux虚拟主机&#xff0c;想要查看当前主机使用的磁盘以及带宽情况&#xff0c;但是不知道如何查看。因为这边也是对于Hostease的虚拟主机产品是有所了解的&#xff0c;知道他们都是默认带管理面板的操做起来很方便的&#xff0c;…

漫威争锋Marvel Rivals申请测试资格教程 最新获取测试资格教程

即将震撼登场的《漫威争锋》&#xff08;Marvel Rivals&#xff09;标志着PvP射击游戏领域的全新突破&#xff0c;由漫威官方网站全力支持推出。这款游戏定于5月11日早晨9点启幕其封闭Alpha测试章节&#xff0c;这场测试盛宴将持续整整十天。在首波测试浪潮中&#xff0c;玩家有…

如何使用IntelliJ IDEA SSH连接本地Linux服务器远程开发

文章目录 1. 检查Linux SSH服务2. 本地连接测试3. Linux 安装Cpolar4. 创建远程连接公网地址5. 公网远程连接测试6. 固定连接公网地址7. 固定地址连接测试 本文主要介绍如何在IDEA中设置远程连接服务器开发环境&#xff0c;并结合Cpolar内网穿透工具实现无公网远程连接&#xf…

今天是二叉树~

本文为博客&#xff1a;东哥带你刷二叉树&#xff08;纲领篇&#xff09; | labuladong 的算法笔记的笔记 前言 将二叉树的思想传递至动态规划&#xff0c;回溯算法&#xff0c;分治算法&#xff0c;图论算法&#xff01; 对于二叉树的每一个结点&#xff0c;我们需要思考的是…

数据分析必备:一步步教你如何用numpy改变数据处理(8)

1、Numpy 数组操作 Numpy 中包含了一些函数用于处理数组&#xff0c;大概可分为以下几类&#xff1a; 修改数组形状 翻转数组 修改数组维度 连接数组 分割数组 数组元素的添加与删除 1.1、修改数组形状 numpy.reshape numpy.reshape 函数可以在不改变数据的条件下修改形状&a…

【热门话题】如何通过AI技术提升内容生产的效率与质量

&#x1f308;个人主页: 鑫宝Code &#x1f525;热门专栏: 闲话杂谈&#xff5c; 炫酷HTML | JavaScript基础 ​&#x1f4ab;个人格言: "如无必要&#xff0c;勿增实体" 文章目录 如何通过AI技术提升内容生产的效率与质量引言一、自然语言处理&#xff08;NLP&…

win11安装SQL Server 2012 企业版

系列文章目录 提示&#xff1a;这里可以添加系列文章的所有文章的目录&#xff0c;目录需要自己手动添加 提示&#xff1a;写完文章后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 系列文章目录前言一、硬件要求二、软件安装参考&#xff1…

uniapp开发的小程序toast被键盘遮挡提示内容无法完全显示问题解决

文章目录 问题描述问题解决参考链接&#xff1a; 问题描述 在开发抖音小程序后&#xff0c;当用户提交反馈后&#xff0c;调用了系统的toast来显示是否提交成功&#xff0c;结果被系统的键盘给盖住&#xff0c;无法显示完全。 即&#xff0c;简单来说&#xff1a;Toast会被弹…

韩顺平0基础学Java——第4天

p45—p71 老天鹅&#xff0c;居然能中断这么久&#xff0c;唉...学不完了要 API API:application programing interface应用程序编程接口 www.matools.com 可以理解成Python的调包...c的头文件对吧 字符型 char用单引号 String用双引号 char本质上是个整数&#xff0c…

AutoTable, Hibernate自动建立表替代方案

痛点 之前一直使用JPA为主要ORM技术栈&#xff0c;主要是因为Mybatis没有实体逆向建表功能。虽然Mybatis有从数据库建立实体&#xff0c;但是实际应用却没那么美好&#xff1a;当实体变更时&#xff0c;往往不会单独再建立一个数据库重新生成表&#xff0c;然后把表再逆向为实…

Pygame简单入门教程(绘制Rect、控制移动、碰撞检测、Github项目源代码)

Pygame简明教程 引言&#xff1a;本教程中的源码已上传个人Github: GItHub链接 视频教程推荐&#xff1a;YouTube教程–有点过于简单了 官方文档推荐&#xff1a;虽然写的一般&#xff0c;但还是推荐&#xff01; Navigator~ Pygame简明教程安装pygame一、代码框架二、案件输入…

小红书释放被封手机号 无限注册

前几年抖音也可以释放被封手机号 那时候都不重视 导致现在被封手机号想释放 基本不可能的 或者就是最少几百块 有专业的人帮你通过某些信息差释放 本教程是拆解 小红书被封手机号怎么释放&#xff0c;从今年开始&#xff0c;被封的手机号无法注销了 所以很困扰 那么本教程来…

如何区分APP页面是H5还是原生页面?

刚刚接触手机测试的同学&#xff0c;或多或少都有过这样的疑问&#xff1a;APP页面哪些是H5页面&#xff1f;哪些是原生页面?单凭肉眼&#xff0c;简直太难区分了&#xff01;我总结了6个小技巧&#xff0c;希望能帮大家答疑解惑。 1、看断网的情况 断开网络&#xff0c;显示…

【生信技能树】拿到表达矩阵之后,如何使用ggplot2绘图系统绘制箱线图?

拿到表达矩阵之后&#xff0c;如何使用ggplot2绘图系统绘制箱线图&#xff1f; 目录 预备知识 绘制箱线图示例 预备知识 1.pivot_longer函数 pivot_longer 是tidyr包中的一个函数&#xff0c;用于将数据框&#xff08;data frame&#xff09;从宽格式转换为长格式。在宽格…

CPU、GPU,那NPU是,神经网络到底能做什么!

人工智能时代即将到来。随着人工智能的不断推进&#xff0c;英特尔、AMD和高通等公司也在着眼于各种硬件配置方面。随着NPU&#xff08;神经网络处理器&#xff09;的引入&#xff0c;人工智能的应用过程将被加快。 苹果在其芯片中使用NPU已经很多年了&#xff0c;所以NPU并不是…

《深入Linux内核架构》第4章 进程虚拟内存(2)

目录 4.3 内存映射原理 4.4 数据结构 4.4.1 树和链表 4.4.2 虚拟内存区域VMA的表示 4.4.3 相关数据结构 本专栏文章将有70篇左右&#xff0c;欢迎关注&#xff0c;查看后续文章。 本节讲VMA结构体struct vm_area_struct和struct address_space。 4.3 内存映射原理 所有进…

k8s概述及核心组件

一、k8s概述 1.1 引言 docker compose 单机编排工具 有企业在用 docker swarm 能够在多台主机中构建一个docker集群 基本淘汰集群化管理处理工具 容器 微服务封装 dockerfile 编写成镜像 然后进行发布 dockerfile 可以写成shell脚本&#xff08;函数做调…