Go 语言中的通道(channel)是用于在多个 goroutine 之间通信,它就像是一个传送带或者队列,遵循先入先出(FIFO)的规则,保证数据收发的顺序。同时,每一个通道都是一个具体类型的管道,所以在声明 channel 的时候需要为其指定元素类型。
声明以及初始化 channel
在 Go 语言中,声明 channel 的语法为:
var 变量名称 chan 元素类型
// 如
var ch1 chan int // 声明一个传递整型的通道
var ch2 chan string // 声明一个传递字符串的通道
var ch3 chan struct{} // 声明一个传递结构体的通道
只声明而未初始化的 channel,其默认零值是 nil,此时是不能够被使用的,只有对它初始化后方可使用。使用 make 关键字初始化 channel,格式如下:
make(chan 元素类型, [缓冲大小])
这个缓冲大小是可选的,其值是一个整型。所以我们可以这样定义 channel:
ch1 := make(chan int) // 定义无缓冲的通道
ch2 := make(chan int, 1) // 定义一个缓冲区大小为 1 的通道
channel 操作
channel 有三种操作:发送(send)、接收(receive)和关闭(close)。而发送和接收操作,都是使用 <- 符号来完成。
当 channel 变量在 <- 左边时,表示将右边的数发送到通道中,当 channel 变量在 <- 右边时,表示从通道中接收数据。例如:
func main() {
ch := make(chan int, 1) // 定义一个缓冲区大小为 1 的通道
ch <- 3 // 将数字 3 发送到通道中
n := <-ch // 从通道中接收数字并赋值给变量 n
fmt.Println(n) // 输出:3
// 如果不用变量接收值,也可以忽略接收值
<- ch
}
使用 close 方法关闭通道:
close(ch)
对已关闭的通道执行操作时,它的表现会有一些不同:
对已关闭的通道再发送值会导致 panic,如:
ch := make(chan int, 2) // 为了证明不是缓冲大小导致的panic,这里设置缓冲大小为2
ch <- 3
close(ch)
// 由于已经关闭通道,再发送值会导致 panic
ch <- 4 // panic: send on closed channel
对已关闭的通道进行接收,会一直获取值直到通道为空,此时再接收会得到对应类型的零值,如:
ch := make(chan int, 2)
ch <- 3
ch <- 4
close(ch)
fmt.Println(<-ch) // 3
fmt.Println(<-ch) // 4
fmt.Println(<-ch) // 0
fmt.Println(<-ch) // 0
对已关闭的通道再次执行关闭,也会导致 panic,如:
ch := make(chan int, 1)
ch <- 3
close(ch) // 正常关闭通道
close(ch) // 再次关闭,导致panic: close of closed channel
无缓冲通道
无缓冲通道也叫阻塞的通道,使用 make 初始化 channel 时,不指定缓冲大小,即 make(chan int),这样就创建了一个无缓冲的通道。
无缓冲通道的核心作用是通过强制同步机制来实现协程间的精准协作,特点就是要求发送和接收双方必须同时准备好,否则数据传递就会阻塞。其实也好理解,两个协程之间没有了缓冲区,数据的传递就需要两边同时做好准备。例如:
ch := make(chan int)
ch <- 3 // fatal error: all goroutines are asleep - deadlock!
由于 ch 通道没有缓冲区,同时又没有接收者,所以 3 就无法被传递,导致程序死锁错误了。解决办法就是添加一个接收者,且必须在一个新的协程中添加接收者,代码:
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
// 创建一个 goroutine 从通道接收值
go func() {
defer wg.Done()
ret := <-ch
fmt.Printf("接收到值:%v\n", ret)
}()
ch <- 3 // 发送数据到通道中
wg.Wait()
}
上面代码中,数据 3 被正确发送和接收,因为我们先创建了一个接收者,然后在数据发送时,接收者就已经准备好接收了。这里最重要的就是先创建的接收者必须要在一个新协程中,因为如果在同一协程中,不管是先接收还是先发送,都会导致程序阻塞,无法继续执行。
综上所述,无缓冲通道是同步的“桥梁”,适合实时数据的传递,避免延迟处理。
有缓冲通道
有缓冲通道顾名思义就是通道中有了缓冲区,就可以存放一些数据。使用 make 初始化 channel 时,只要指定了缓冲容量大于零,那么该通道就是有缓冲通道。
因为缓冲区的存在,可以允许收发存在时间差,是一个异步过程,能减少阻塞,更适合高频操作。但需要注意缓冲区满或者空时的操作,仍会发生阻塞。
有缓冲通道,只要通道有数据就可以随时接收。如:
ch := make(chan int, 1)
ch <- 1
fmt.Println(<-ch) // 1
需要注意2个会发生阻塞的场景:
当缓冲区满时,发送方阻塞;
当缓冲区空时,接收方阻塞。
ch1 := make(chan int, 1)
ch1 <- 1
ch1 <- 2 // 缓冲区已被填满,无法再发送,导致阻塞。
ch2 := make(chan int, 1)
ret := <- ch2 // 缓冲区为空,无法再接收,导致阻塞。
多返回值模式
通道支持多返回值模式,考虑到通道关闭了但还有值的情况,可以接收两个值,第一个值是通道的值,第二个值是通道是否关闭。如:
value, ok := <- ch
其中:
value :从通道中取出的值,如果通道关闭则返回对应类型的零值。
ok :通道关闭也无值时,返回 false,否则返回 true。
例如:
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for {
v, ok := <-ch
if !ok {
fmt.Println("channel is closed")
break
}
fmt.Println(v)
}
}
// 运行结果://
// 1
// 2
// channel is closed
for range 接收值
可以使用 for range 循环从通道中接收值,当通道被关闭后,会在通道内的值全部被接收后自动退出循环。上面的例子可以改成如下:
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println(v)
}
}
单向通道
单向通道用于限制通道的操作权限,使其仅支持发送或接收数据。这种通过类型系统强制约束程序的行为可以提高代码的安全性和可读性。
单向通道分为两种类型:
发送通道(chan <- T):只能向通道发送数据,无法接收。
接收通道(<- chan T):只能从通道接收数据,无法发送。
箭头 <- 和关键字 chan 的相对位置表明了当前通道允许的操作。这里举例生产者和消费者的角色分工:
package main
import "fmt"
// 生产者,仅发送数据
func producer(out chan<- int) {
for i := 0; i < 3; i++ {
out <- i
}
close(out)
}
// 发送者,仅接收数据
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}
select
select 关键字是专为并发编程设计的控制结构,主要用于在多个通道操作之间进行非阻塞选择,实现协程间的高效同步与通信。
说白了就是select可以同时监听多个通道的读写操作,当任意一个通道就绪(数据可读或可写)时,自动执行对应的 case 分支。没错,select 也是搭配 case 使用,与 switch 结构一样。
以下是一个简单的例子,从两个协程分别读取通道值:
package main
import (
"fmt"
"time"
)
func g1(ch chan struct{}) {
time.Sleep(1 * time.Second)
ch <- struct{}{}
}
func g2(ch chan struct{}) {
time.Sleep(2 * time.Second)
ch <- struct{}{}
}
func main() {
ch1 := make(chan struct{})
ch2 := make(chan struct{})
go g1(ch1)
go g2(ch2)
select {
case <-ch1:
fmt.Println("ch1")
case <-ch2:
fmt.Println("ch2")
}
}
上例中打印结果永远是 ch1,因为 g1 执行时间更短,ch1 总是会先到达。而如果 g1 和 g2 内部的等待时间设为一致,即触发多个 case 同时就绪的情况,此时 select 会随机选择一个执行,而不会执行先 case 的 ch1,这样设定是为了避免“饥饿”,确保程序行为的不可预测性和公平性。
select 还支持 default 分支,当没有 case 分支就绪时,default 分支会执行,避免阻塞。例如:
func main() {
ch := make(chan int, 1)
ch <- 1
select {
case ch <- 2:
fmt.Println("发送成功")
default:
fmt.Println("通道已满")
}
}
上例中,当通道 ch 满时,default 分支会执行,这样就可以检测通道状态。
但是使用 default 分支会带来另一个问题,就是永远只执行 default 分支了。有些场景中,比如获取请求响应,一般会有些耗时的,我们希望程序等待响应,但是又不想程序等不到结果而阻塞住,此时更适合的方式是加上超时处理,如下:
package main
import (
"fmt"
"time"
)
func reponseData(ch chan struct{}) {
time.Sleep(3 * time.Second)
ch <- struct{}{}
}
func main() {
ch := make(chan struct{})
go reponseData(ch)
select {
case <-ch:
fmt.Println("reponse success")
case <-time.After(5 * time.Second): // 设置 5 秒超时
fmt.Println("timeout")
}
}
使用 time.After() 可以设置超时分支,程序中设置了 5 秒的超时,如果协程 reponseData 中响应时间超过 5 秒,则会执行超时分支,否则会获取响应。
Go 语言中“锁”的作用是保护共享资源。当多个协程(goroutine)同一时间访问(或修改)同一个数据资源时,会出现数据竞争的问题,导致最终获取的效果与期望不一致。所以需要一把“锁”,锁住这个数据,让它同一时刻只能被一个协程访问(或修改)。
Go 语言中提供了两种锁:互斥锁(Mutex)和读写锁(RWMutex)。本文主要介绍一下这两种锁机制的应用。阅读本文前需要先了解 Go 协程的知识。
互斥锁(Mutex)
“互斥”的意思是协程之间对共享资源的独占访问控制,一旦某个协程获取锁,其他尝试获取锁的协程会被阻塞,直到锁被释放。
用简单的算术例子来演示一下锁的作用。首先看下面代码场景,循环 1000 个协程对变量 num 进行累加,运行代码后,会发现最后的输出结果不一定是 1000,且每次运行结果都不一样。
package main
import (
"fmt"
)
var (
num int
wg sync.WaitGroup
)
func add() {
num += 1
wg.Done()
}
func main() {
wg.Add(1000)
for i := 0; i < 1000; i++ {
go add()
}
wg.Wait()
fmt.Print(num)
}
此例子就是并发数据竞争导致的结果,多个协程同时修改共享变量 num时没有进行同步保护。就比如两个协程同时读取到 num=100,各自加 1 后都写回 101,实际只增加了一次。我们可以使用 -race 参数来运行代码来检测数据竞争:
// 使用 -race 参数来运行代码
go run -race main.go
会输出类似下面这样的结果:
==================
WARNING: DATA RACE
Read at 0x000100802e10 by goroutine 10:
main.add()
/Users/wungjyan/self/goDemo/main.go:14 +0x28
Previous write at 0x000100802e10 by goroutine 6:
main.add()
/Users/wungjyan/self/goDemo/main.go:14 +0x40
Goroutine 10 (running) created at:
main.main()
/Users/wungjyan/self/goDemo/main.go:21 +0x44
Goroutine 6 (finished) created at:
main.main()
/Users/wungjyan/self/goDemo/main.go:21 +0x44
==================
这就表示发生了数据竞争(DATA RACE),代码第 14 行处,goroutine 10 进行了“读”,而 goroutine 6 进行了“写”,两个操作针对同一内存地址 0x000100802e10,对应代码中的共享变量 num。
针对这个场景,就要使用互斥锁来解决了。互斥锁需要引入包 sync,并使用 sync.Mutex 类型来创建锁,提供了两个方法:Lock() 和 Unlock(),分别用来获取锁和释放锁。使用很简单,来修改下代码,加上锁机制:
package main
import (
"fmt"
"sync"
)
var (
num int
wg sync.WaitGroup
mu sync.Mutex // 定义全局锁
)
func add() {
mu.Lock() // 加锁
defer mu.Unlock() // defer 延迟解锁
num += 1
// mu.Unlock() // 如果不用 defer,在这里解锁是一样的
wg.Done()
}
func main() {
wg.Add(1000)
for i := 0; i < 1000; i++ {
go add()
}
wg.Wait()
fmt.Print(num)
}
如代码中的注释,简单就加上了锁,现在再运行代码,结果永远是输出 1000。我们用锁来保护 num += 1 这个操作,当多个协程访问时,只允许一个协程持有锁,其他协程被阻塞,直到锁被释放再继续让下一个协程获取锁,这样来保证了结果的正确计算。
说到这里,我想到了一个问题,既然锁会让协程阻塞,那是否就是去了协程多并发的优势?上面这个例子只是为了演示锁的作用,如果不使用协程和锁,程序计算其实要快得多。实际开发的场景,应该是要综合考虑,平衡执行效率,本文就不多探究了。
读写锁(RWMutex)
读写锁,也可以说是“读写互斥锁”,本质是区分了“写锁”和“读锁”。为什么要区分呢?上面说到的互斥锁,是完全互斥的,但实际场景是读多写少的场景多,当我们并发去读取一个资源而不修改资源时,是没有必要加互斥锁的,因为读操作本身是线程安全的,不管同时多少个“读”,获取的结果是一样的。
而读写锁的优势就是,当协程获取到读锁后,其他的协如果是获取读锁则继续获得锁,如果是获取写锁就会等待;当一个协程获取写锁后,其他的协程无论是获取读锁还是写锁都会等待。总结就是:
读读不互斥,多个协程同时获取读锁,无阻塞并行执行;
读写互斥,读时禁止写,写时禁止读;
写写互斥,写操作串行执行,保证数据一致性。
读写锁使用 sync.RWMutex 类型来创建锁,包含四个方法:
Lock():获取写锁;
Unlock():释放写锁;
RLock():获取读锁;
RUnlock():释放读锁。
读写锁的使用方式和互斥锁的使用基本一致,为了证明在“读多写少”的场景下,读写锁更具性能优势,我参考了网上一段测试代码,同样的逻辑,使用互斥锁和读写锁分别测试耗时,代码如下:
package main
import (
"fmt"
"sync"
"time"
)
var (
num int
wg sync.WaitGroup
mu sync.Mutex
rwMu sync.RWMutex
)
// 使用互斥锁的 写操作
func writeWithLock() {
mu.Lock()
defer mu.Unlock()
num += 1
time.Sleep(10 * time.Millisecond) // 假设写操作耗时 10 毫秒
wg.Done()
}
// 使用互斥锁的 读操作
func readWithLock() {
mu.Lock()
defer mu.Unlock()
_ = num // 显示读操作
time.Sleep(time.Millisecond) // 假设写操作耗时 1 毫秒
wg.Done()
}
// 使用读写锁的 写操作
func writeWithRWLock() {
rwMu.Lock() // 加写锁
defer rwMu.Unlock() // 释放写锁
num += 1
time.Sleep(10 * time.Millisecond) // 假设写操作耗时 10 毫秒
wg.Done()
}
// 使用读写锁的 读操作
func readWithRWLock() {
rwMu.RLock() // 加读锁
defer rwMu.RUnlock() // 释放读锁
_ = num // 显示读操作
time.Sleep(time.Millisecond) // 假设写操作耗时 1 毫秒
wg.Done()
}
func runTest(wf, rf func(), wc, rc int) {
start := time.Now()
defer func() {
fmt.Printf("程序耗时: %v\n", time.Since(start))
}()
// 循环 wc 个写操作协程
for i := 0; i < wc; i++ {
wg.Add(1)
go wf()
}
// 循环 rc 个读操作协程
for i := 0; i < rc; i++ {
wg.Add(1)
go rf()
}
wg.Wait()
}
func main() {
// 互斥锁,10个写,1000个读
// runTest(writeWithLock, readWithLock, 10, 1000) // 程序耗时: 1.255126125s
// 读写锁,10个写,1000个读
runTest(writeWithRWLock, readWithRWLock, 10, 1000) // 程序耗时: 109.684875ms
}
从上面代码的测试结果中,不难看出,在读多写少的场景下(读的单位耗时一般比写的单位耗时少),读写锁比互斥锁的性能高很多。可以修改这段代码来测试,如果读操作和写操作数量级差别不大,那么测试结果的差距则不明显。
总结
本文简单介绍了一下互斥锁和读写锁的区别和使用方式,它们的特性与使用场景总结如下:
互斥锁:
完全排他:无论是读还是写操作,同一时间只允许一个协程持有锁。
简单直接:无需区分操作类型,直接加锁/解锁。
适用场景:主要适用写操作频繁的场景(写占比超过 20%),如实时日志写入。
读写锁:
读并行,写排他:允许多个读协程同时持有锁,但写锁完全排他。
性能优势:在读多写少的场景下,显著减少锁竞争。
适用场景:适合读操作远多于写操作的场景(读占比超过90%),如数据库查询。
Go 中的 defer 是一种延迟执行机制,用于在函数返回前执行特定操作。它在资源管理、错误处理和性能调试等场景中具有重要作用。本文详细记录 defer 的用法。
基本用法
defer 的使用方法很简单,放在要执行的操作之前即可,会在函数返回前执行,如:
func main() {
defer fmt.Print("我是defer执行~")
fmt.Println("我是main函数")
// 执行结果
// 我是main函数
// 我是defer执行~
}
注意 defer 后面要接的是一个函数调用,如下代码:
func main() {
// 错误,这里应该是函数调用
// defer func(){
// fmt.Print("defer")
// }
// 修改为函数调用
defer func() {
fmt.Print("defer")
}()
fmt.Print("main")
}
执行顺序
多个 defer语句按后进先出(LIFO) 的顺序执行:
func main() {
defer fmt.Println(1)
defer fmt.Println(2)
defer fmt.Println(3)
fmt.Println("main")
// 执行结果
// main
// 3
// 2
// 1
}
循环 defer,遵循后进先出原则:
func main() {
for i := 0; i < 5; i++ {
defer fmt.Println(i)
}
// 执行结果
// 4
// 3
// 2
// 1
// 0
}
参数预计算
defer 执行方法的参数,在声明时即被固定,无法执行时动态取值,如:
func main() {
n := 10
defer fmt.Println(n) // 10
n = 20
}
代码 fmt.Println(n) 的 n在 defer 声明时已经固定为了 10,即使后面修改了值,也无法改变 defer 的执行结果。若需要动态取值,可以使用闭包:
func main() {
n := 10
defer func() {
fmt.Println(n) // 20
}()
n = 20
}
修改返回值
注意 defer 可以修改命名返回值,会影响最终结果:
func getNum() (x int) {
defer func() { x++ }()
return 1
}
func main() {
fmt.Print(getNum()) // 2
}
代码中一个误区就是以为 return 1 之前执行 defer,其实是先将 x 赋值为 1,再执行 defer,所以 x 的值是 2。getNum代码等价以下形式:
func getNum() (x int) {
x = 1
func() { x++ }()
return
}
场景案例
资源释放
在资源管理场景中,defer 可以用于释放资源,如文件、数据库连接等,避免内存泄漏。如:
func readFile() error {
file, _ := os.Open("test.txt")
defer file.Close() // 这里用defer延迟关闭,一目了然
// 下面执行文件操作
}
错误处理
defer 可以结合 recover 捕获 panic:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获异常:", r) // 捕获异常: panic~
}
}()
panic("panic~") // 这里主动 panic
}
性能分析
记录函数执行时间或调试信息:
func main() {
start := time.Now()
defer func() {
fmt.Printf("程序执行时间:%v\n", time.Since(start))
}()
time.Sleep(1 * time.Second)
}
Go语言中的切片类型,在作为函数参数传递时,是“值传递”还是“引用传递”?
答案正如标题所言,是值传递。但是有时候的实际表现,会让人误以为是引用传递。本文详细分析一下这个问题。
引用传递的效果
之所以很多人觉得切片作为函数参数传递时是引用传递,原因就是在函数内部修改参数时,影响了原切片的值,这里有个例子:
func changeSlice(s []int){
s[0] = 100
}
func main(){
s1 := []int{1, 2, 3}
changeSlice(s1)
fmt.Print(s1) // [100 2 3]
}
代码中,切片 s1 作为参数传递给函数 changeSlice,函数内部修改了切片的第一项值,最后打印切片 s1,发现确实是被修改了。这妥妥的就是引用传递的效果,但实际真的如此吗?来改一下函数changeSlice:
func changeSlice(s []int){
s = append(s, 4)
}
此时再打印 s1,就会发现其值还是 [1 2 3],并没有被追加元素,这显然表现出了值传递的效果。至此,人们对于切片作为函数参数传递时,是值传递还是引用传递,就有了分歧。这里我可以明确告知,本质就是值传递。
值传递的本质
切片作为函数参数传递时,传递的其实是切片本身的副本,而非原始切片的内存地址。切片底层结构包含了三个字段:
array:指向底层数组的指针;
len:当前元素的个数;
cap:底层数组的容量。
当切片作为参数传递时,其实传递的是这三个字段的副本,而非整个底层数组的副本。函数修改切片元素时,由于是同一份底层数组的指针,所以会直接影响原切片,但是修改切片元信息(如扩容)是不会影响原切片的。
所以上面例子中,直接修改切片第一项元素值s[0] = 100时,由于切片副本的 array字段与原切片指向同一底层数组,所以修改元素影响了原数据。
而在给切片副本扩容时,即上例中 s = append(s, 4),由于扩容会修改底层数组地址,此时 s 的 array字段指向了新的地址,所以原切片 s1 没有被影响。
案例分析
上面说到,扩容会改变底层数组地址,此时原数据不会被影响。那如果在 append 时,切片的容量还够时呢?例如:
func changeSlice(s []int) {
s = append(s, 4)
}
func main() {
s1 := make([]int, 3, 5)
s1[0], s1[1], s1[2] = 1, 2, 3
changeSlice(s1)
fmt.Print(s1)
}
代码中使用 make 初始化了一个切片,指定了长度是 3,容量是 5,当三个长度被填充后,使用函数给切片追加第 4 个元素,此时 s1 的值是多少呢?
按照之前说的,只有扩容时才会改变底层数组,但此时追加一个元素时,容量还够,无需扩容,所以改变的还是原底层数组,所以 s1应该是 [1 2 3 4]。但其实最后 s1的值还是 [1 2 3]。
作为初学者,我就在这里困惑过,为什么不扩容还是不能修改原切片数据呢?原因其实是我们忽略了一个字段,那就是切片长度 len。此例中修改的确实还是同一份底层数组,但是原切片中的长度还是 3,它的可见范围还是前 3 个元素,所以 s1 最后的值还是 [1 2 3]。其实 s1 的底层数组已经被修改了,证明:
fmt.Print(s1[:5]) // [1 2 3 4 0]
使用 s1[:5]截取底层数组所有元素,即可以看到被追加的元素 4,以及最后一个零值。
断断续续地在看 Go 语言的语法,看到空接口这块,感觉有些意思,记录一下。
空接口
Go 语言中空接口的表示方式是 interface{},它最大的作用就是可以存储任意类型的值,可以理解为 TypeScript 中的 any。
作为一个前端开发,Go 中这种表示任意类型的方式让我觉得有些怪异,但它确实是很有用的。比如我想存储一组用户信息,但具体多少信息是不确定的,我希望可以无限被扩展,那我可以这样写:
func main() {
userInfo := make(map[string]interface{}) // 信息的值是可以任意类型的
userInfo["name"] = "Tom"
userInfo["isAdmin"] = true
userInfo["age"] = 18
userInfo["hobby"] = []string{"reading", "running"}
}
如果不使用空接口,那么我可能需要定义一个结构体来存储这些信息:
type UserInfo struct {
name string
isAdmin bool
age int
hobby []string
}
func main() {
userInfo := UserInfo{
name: "Tom",
isAdmin: true,
age: 18,
hobby: []string{"reading", "running"},
}
}
这样每当我想要新增一个字段,就需要去结构体中添加一个字段类型,虽说加类型不算什么大问题,但无法做到添加未知类型的字段。所以空接口的作用在这个场景下就体现出来了。其实空接口在 Go 标准库的源码中也是被大量使用的。
类型断言
说到空接口,那不得不提下类型断言,一些被定义为空接口的值,如果想要访问其身上的特定方法时,就必须要用到类型断言。
还是上面的例子,如果想要访问 hobby 中的第一个值,我们的直觉应该是通过索引去访问,如:
func main() {
userInfo := make(map[string]interface{})
userInfo["hobby"] = []string{"reading", "running"}
fmt.Println(userInfo["hobby"][0]) // 通过索引访问
}
但这样写是错误的,会报错 invalid operation: cannot index userInfo["hobby"] (map index expression of type interface{})。
其实仔细想想也很正常,userInfo["hobby"] 可能是任意一种类型的值,它不一定具备索引访问的特性。所以这里就需要用到类型断言,必须先断言出它的具体类型,才可以去访问它的值。
不过 Go 中的类型断言写法,也是让我觉得有点怪异,写法是 n.(type),n 代表要断言的变量,type 代表断言的类型。如上我想要获取 hobby 中的第一个值,可以这样写:
v, ok := userInfo["hobby"].([]string)
if ok {
fmt.Println(v[0])
} else {
fmt.Println("no hobby")
}
这样写可能看着有些繁琐,但确实能提高程序的健壮性,作为初学者,我只能去适应接受。