Go SDK:context、sync、reflect、errors
Go 语言类库要点整理 📄
context
协程如何退出
一个协程启动后,大部分情况需要等待里面的代码执行完毕,协程才会自行退出,如何让协程提前退出?
1 | package main |
实现了通过 select + channel 发送指令让监控狗停止,进而达到协程退出的目的。
初识 Context
如果希望做到同时取消很多个协程或定时取消协程又该怎么办?这时候 select + channel 的局限性就凸现出来了,即使定义了多个 channel 解决问题,代码逻辑也会非常复杂、难以维护。
通过 Context 重写上面的示例,实现让监控狗停止的功能:
1 | func main() { |
相比 select+channel 的方案,Context 方案主要有 4 个改动点:
- watchDog 的 stopCh 参数换成了 ctx,类型为 context.Context。
- 原来的
case <-stopCh改为case <-ctx.Done(),用于判断是否停止。 - 使用
context.WithCancel(context.Background())函数生成一个可以取消的 Context,用于发送停止指令。这里的context.Background()用于生成一个空 Context,一般作为整个 Context 树的根节点。 - 原来的
stopCh <- true停止指令,改为context.WithCancel函数返回的取消函数stop()。
这和修改前的整体代码结构一样,只不过从 channel 换成了 Context。
什么是 Context
一个任务会有很多个协程协作完成,一次 HTTP 请求也会触发很多个协程的启动,而这些协程有可能会启动更多的子协程,并且无法预知有多少层协程、每一层有多少个协程。
如果因为某些原因导致任务终止了,HTTP 请求取消了,那么它们启动的协程怎么办?该如何取消呢?因为取消这些协程可以节约内存,提升性能,同时避免不可预料的 Bug。
Context 就是用来简化解决这些问题的,并且是并发安全的。Context 是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦取消指令下达,那么被 Context 跟踪的这些协程都会收到取消信号,就可以做清理和退出操作。
Context 接口只有四个方法:
1 | type Context interface { |
- Deadline 方法可以获取设置的截止时间,第一个返回值 deadline 是截止时间,到了这个时间点,Context 会自动发起取消请求,第二个返回值 ok 代表是否设置了截止时间。
- Done 方法返回一个只读的 channel,类型为 struct{}。在协程中,如果该方法返回的 chan 可以读取,则意味着 Context 已经发起了取消信号。通过 Done 方法收到这个信号后,就可以做清理操作,然后退出协程,释放资源。
- Err 方法返回取消的错误原因,即因为什么原因 Context 被取消。
- Value 方法获取该 Context 上绑定的值,是一个键值对,所以要通过一个 key 才可以获取对应的值。
Context 接口的四个方法中最常用的就是 Done 方法,它返回一个只读的 channel,用于接收取消信号。当 Context 取消的时候,会关闭这个只读 channel,也就等于发出了取消信号。
Context 树
Go 语言提供了函数可以帮助生成不同的 Context,通过这些函数可以生成一颗 Context 树,这样 Context 才可以关联起来,父 Context 发出取消信号的时候,子 Context 也会发出,这样就可以控制不同层级的协程退出。
从使用功能上分,有四种实现好的 Context。
- 空 Context:不可取消,没有截止时间,主要用于 Context 树的根节点。
- 可取消的 Context:用于发出取消信号,当取消的时候,它的子 Context 也会取消。
- 可定时取消的 Context:多了一个定时的功能。
- 值 Context:用于存储一个 key-value 键值对。
在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context,有了根节点 Context 后,使用 Go 语言提供的四个函数生成这棵 Context 树:
- **WithCancel(parent Context)**:生成一个可取消的 Context。
- **WithDeadline(parent Context, d time.Time)**:生成一个可定时取消的 Context,参数 d 为定时取消的具体时间。
- **WithTimeout(parent Context, timeout time.Duration)**:生成一个可超时取消的 Context,参数 timeout 用于设置多久后取消
- **WithValue(parent Context, key, val interface{})**:生成一个可携带 key-value 键值对的 Context。
以上四个生成 Context 的函数中,前三个都属于可取消的 Context,它们是一类函数,最后一个是值 Context,用于存储一个 key-value 键值对。
取消多个协程
要取消多个协程,把 Context 作为参数传递给协程即可,还是以监控狗为例,如下所示:
1 | func main() { |
Context 传值
Context 不仅可以取消,还可以传值,通过这个能力,可以把 Context 存储的值供其他协程使用。
1 | func main() { |
通过 context.WithValue() 函数存储一个 userId 为 2 的键值对,就可以在 getUser 函数中通过 ctx.Value("userId") 方法把对应的值取出来,达到传值的目的。
Context 使用原则
Context 是一种非常好的工具,使用它可以很方便地控制取消多个协程。在 Go 语言标准库中也使用了它们,比如 net/http 中使用 Context 取消网络的请求。
要更好地使用 Context,有一些使用原则需要尽可能地遵守。
- Context 不要放在结构体中,要以参数的方式传递。
- Context 作为函数的参数时,要放在第一位,也就是第一个参数。
- 要使用
context.Background()函数生成根节点的 Context,也就是最顶层的 Context。 - Context 传值要传递必须的值,而且要尽可能地少,不要什么都传。
- Context 多协程安全,可以在多个协程中放心使用。
以上原则是规范类的,Go 语言的编译器并不会做这些检查,要靠自己遵守。
sync
channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁 来保证并发的安全。在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们可以更加灵活地 控制数据的同步和多协程的并发。
资源竞争
1 | //共享的资源 |
小技巧:使用 go build、go run、go test 这些 Go 语言工具链提供的命令时,添加 -race 标识可以帮检查 Go 语言代码是否存在资源竞争。
同步原语
sync.Mutex
互斥锁 指的是在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。
1 | var( |
以上被加锁保护的 sum+= i 代码片段又称为 临界区。在同步的程序设计中,临界区段指的是一个访问共享资源的程序片段,而这些共享资源又有无法同时被多个协程访问的特性。当有协程进入临界区段时,其他协程必须等待,这样就保证了临界区的并发安全。
sync.RWMutex
1 | func main() { |
解决了多个 goroutine 同时读写的资源竞争问题,但是又遇到另外一个问题——性能。因为每次读写共享资源都要加锁,所以性能低下,有以下几种情况:
- 写的时候不能同时读,因为这个时候读取的话可能读到脏数据(不正确的数据);
- 读的时候不能同时写,因为也可能产生不可预料的结果;
- 读的时候可以同时读,因为数据不会改变,所以不管多少个 goroutine 读都是并发安全的。
通过读写锁 sync.RWMutex 来优化这段代码,提升性能:
1 | var mutex sync.RWMutex |
把锁的声明换成读写锁 sync.RWMutex,把函数 readSum 读取数据的代码换成读锁,也就是 RLock 和 RUnlock,这样性能就会有很大的提升,因为多个 goroutine 可以同时读数据,不再相互等待。
sync.WaitGroup
上面代码的 time.Sleep(2 * time.Second),这是为了防止主函数 main 返回使用,一旦 main 函数返回了,程序也就退出了,因为不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕,所以设置了一个比较长的等待时间,也就是两秒。
有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。
1 | func main() { |
通过 sync.WaitGroup 可以很好地跟踪协程。在协程执行完毕后,整个 run 函数才能执行完毕,时间不多不少,正好是协程执行的时间。
sync.Once
让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。针对这种情形,Go 语言为提供了 sync.Once 来保证代码只执行一次
1 | func main() { |
sync.Cond
sync.WaitGroup 用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕;而 sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待 sync.Cond 唤醒才能执行。
sync.Cond 从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。
1 | //10个人赛跑,1个裁判发号施令 |
sync.Cond 有三个方法,它们分别是:
- Wait,阻塞当前协程,直到被其他协程调用 Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用 sync.Cond 中的锁即可,也就是 L 字段。
- Signal,唤醒一个等待时间最长的协程。
- Broadcast,唤醒所有等待的协程。
在调用 Signal 或者 Broadcast 之前,要确保目标协程处于 Wait 阻塞状态,不然会出现死锁问题。
reflect
反射提供了一种可以在运行时操作任意类型对象的能力,比如查看一个接口变量的具体类型、看看一个结构体有多少字段、修改某个字段的值等。
Go 语言是静态编译类语言,比如在定义一个变量的时候,已经知道了它是什么类型,那么为什么还需要反射呢?比如定义了一个函数,它有一个 interface{} (any)类型的参数,这也就意味着调用者可以传递任何类型的参数给这个函数。在这种情况下,如果想知道调用者传递的是什么类型的参数,就需要用到反射。如果想知道 一个结构体有哪些字段和方法,也需要反射。
在 Go 语言的反射定义中,任何接口都由两部分组成:接口的具体类型,以及具体类型对应的值。
interface{} 是空接口,可以表示任何类型,也就是说可以把任何类型转换为空接口,它通常用于 反射、类型断言,以减少重复代码,简化编程。
1 | func main() { |
reflect.Value
在 Go 语言中,reflect.Value 被定义为一个 struct 结构体:
1 | type Value struct { |
它的常用方法有三类:
- 一类用于获取和修改对应的 值;
- 一类和 struct 类型的 字段 有关,用于获取对应的字段;
- 一类和类型上的 方法集 有关,用于获取对应的方法。
获取原始类型
reflect.Value 和 int 类型互转
1 | func main() { |
修改对应的值
因为 reflect.ValueOf 函数返回的是一份值的拷贝,所以要传入变量的指针,因为传递的是一个指针,所以需要调用 Elem 方法找到这个指针指向的值,这样才能修改。
1 | // 修改一个变量的值 |
修改 struct 结构体字段的值,可总结出以下步骤:
- 传递一个 struct 结构体的指针,获取对应的 reflect.Value;
- 通过 Elem 方法获取指针指向的值;
- 通过 Field 方法获取要修改的字段;
- 通过 Set 系列方法修改成对应的值。
1 | // 修改 struct 结构体字段的值 |
在程序运行时通过反射修改一个变量或字段的值的规则:
- 可被寻址,通俗地讲就是要向 reflect.ValueOf 函数传递一个指针作为参数。
- 如果要修改 struct 结构体字段值的话,该字段需要是可导出的,而不是私有的,也就是该字段的首字母为大写。
- 记得使用 Elem 方法获得指针指向的值,这样才能调用 Set 系列方法进行修改。
获取对应的底层类型
底层类型对应的主要是基础类型,比如接口、结构体、指针……因为可以通过 type 关键字声明很多新的类型。变量 p 的实际类型是 person,但是 person 对应的底层类型是 struct 这个结构体类型,而 &p 对应的则是指针类型。
1 | func main() { |
reflect.Type
reflect.Value 可以用于与值有关的操作中,而如果是和变量类型本身有关的操作,则最好使用 reflect.Type,比如要获取结构体对应的字段名称或方法。
接口定义
和 reflect.Value 不同,reflect.Type 是一个接口,而不是一个结构体,所以也只能使用它的方法。对比 reflect.Value 的方法功能, reflect.Type 几个特有的方法如下:
- Implements 方法用于判断是否实现了接口 u;
- AssignableTo 方法用于判断是否可以赋值给类型 u,其实就是是否可以使用 =,即赋值运算符;
- ConvertibleTo 方法用于判断是否可以转换成类型 u,其实就是是否可以进行类型转换;
- Comparable 方法用于判断该类型是否是可比较的,其实就是是否可以使用关系运算符进行比较。
遍历结构体的字段和方法
1 | func main() { |
可以通过 FieldByName 方法获取指定的字段,也可以通过 MethodByName 方法获取指定的方法,这在需要获取某个特定的字段或者方法时非常高效,而不是使用遍历。
是否实现某接口
1 | func main() { |
尽可能通过类型断言的方式判断是否实现了某接口,而不是通过反射。
string ↔️ struct
字符串和结构体互转的场景中,使用最多的就是 JSON 和 struct 互转,还有配置文件和 struct 的转换
JSON 和 Struct 互转
Go 语言的标准库有一个 json 包,通过 json.Marshal 函数,可以把一个 struct 转为 JSON 字符串。通过 json.Unmarshal 函数,可以把一个 JSON 字符串转为 struct。
1 | func main() { |
JSON 字符串的 Key 和 struct 结构体的字段名称一样
Struct Tag
struct tag 是一个添加在 struct 字段上的标记,使用它进行辅助,可以完成一些额外的操作,比如 json 和 struct 互转。如果想把输出的 json 字符串的 Key 改为小写的 name 和 age,可以通过为 struct 字段添加 tag 的方式。
1 | type person struct { |
json 作为 Key,是 Go 语言自带的 json 包解析 JSON 的一种约定,它会通过 json 这个 Key 找到对应的值,用于 JSON 的 Key 值。
这个 tag 就像是为 struct 字段起的别名,那么 json 包是如何获得这个 tag 的呢?这就需要反射了。
1 | //遍历person字段中key为json的tag |
结构体的字段可以有多个 tag,用于不同的场景,比如 json 转换、bson 转换、orm 解析等。如果有多个 tag,要使用空格分隔。采用不同的 Key 可以获得不同的 tag:
1 | //遍历person字段中key为json、bson的tag |
1 | 字段Name上,key为json的tag为name |
通过不同的 Key,使用 Get 方法就可以获得自定义的不同的 tag。
实现 Struct 转 JSON
自定义的 jsonBuilder 负责 json 字符串的拼接,通过 for 循环把每一个字段拼接成 json 字符串。
1 | func main() { |
json 字符串的转换只是 struct tag 的一个应用场景,完全可以把 struct tag 当成结构体中字段的元数据配置,使用它来做想做的任何事情,比如 orm 映射、xml 转换、生成 swagger 文档等。
反射定律
反射是计算机语言中程序 检视其自身结构 的一种方法,它属于元编程的一种形式。反射灵活、强大,但也存在不安全。它可以绕过编译器的很多静态检查,如果过多使用便会造成混乱。Go 语言的作者在博客上总结了 反射的三大定律。
- 任何接口值 interface{} 都可以反射出反射对象,也就是 reflect.Value 和 reflect.Type,通过函数 reflect.ValueOf 和 reflect.TypeOf 获得。
- 反射对象也可以还原为 interface{} 变量,也就是第 1 条定律的可逆性,通过 reflect.Value 结构体的 Interface 方法获得。
- 要修改反射的对象,该值必须可设置,也就是 可寻址。
errors
错误是通过内置的 error 接口表示,它只有一个 Error 方法用来返回具体的错误信息:
1 | type error interface { |
一般而言,error 接口用于当方法或者函数执行遇到错误时进行返回,而且是第二个返回值。通过这种方式,可以让调用者自己根据错误信息决定如何进行下一步处理。
1 | func main() { |
1 | func Atoi(s string) (int, error) |
error 工厂函数
自己定义的函数也可以返回错误信息给调用者:
1 | func add(a, b int) (int, error){ |
1 | sum,err := add(-1,2) |
自定义 error
采用工厂返回错误信息的方式只能传递一个字符串,也就是携带的信息只有字符串,如果想要携带更多信息(比如错误码信息)就需要自定义 error 。
1 | type commonError struct { |
1 | return 0, &commonError { |
error 断言
需要先把返回的 error 接口转换为自定义的错误类型
1 | sum, err := add(-1, 2) |
错误嵌套
调用一个函数,返回了一个错误信息 error,在不想丢失这个 error 的情况下,又想添加一些额外信息返回新的 error。
1 | type MyError struct { |
让 MyError 这个 struct 实现 error 接口,然后在初始化 MyError 的时候传递存在的 error 和新的错误信息:
1 | func (e *MyError) Error() string { |
Error Wrapping
这种方式可以满足的需求,但是非常烦琐,因为既要定义新的类型还要实现 error 接口。所以从 Go 语言 1.13 版本开始,Go 标准库新增了 Error Wrapping 功能,可以基于一个存在的 error 生成新的 error,并且可以保留原 error 信息:
1 | e := errors.New("原始错误e") |
Go 语言没有提供 Wrap 函数,而是扩展了 fmt.Errorf 函数,然后加了一个 %w,通过这种方式,便可以生成 wrapping error。
errors.Unwrap 函数
Go 语言提供了 errors.Unwrap 用于获取被嵌套的 error,比如以上例子中的错误变量 w ,就可以对它进行 unwrap,获取被嵌套的原始错误 e。
1 | fmt.Println(errors.Unwrap(w)) |
errors.Is 函数
有了 Error Wrapping 后,会发现原来用的判断两个 error 是不是同一个 error 的方法失效了,比如 Go 语言标准库经常用到的如下代码中的方式:
1 | if err == os.ErrExist |
于是 Go 语言为提供了 errors.Is 函数,用来判断两个 error 是否是同一个:
1 | func Is(err, target error) bool |
以上就是 errors.Is 函数的定义,可以解释为:
- 如果 err 和 target 是同一个,那么返回 true。
- 如果 err 是一个 wrapping error,target 也包含在这个嵌套 error 链中的话,也返回 true。
1 | fmt.Println(errors.Is(w,e)) |
errors.As 函数
有了 error 嵌套后,error 断言也不能用了,因为不知道一个 error 是否被嵌套,又嵌套了几层。所以 Go 语言为解决这个问题提供了 errors.As 函数,比如前面 error 断言的例子,可以使用 errors.As 函数重写,效果是一样的:
1 | var cm *commonError |
所以在 Go 语言提供的 Error Wrapping 能力下,写的代码要尽可能地使用 Is、As 这些函数做判断和转换。
defer
- defer 调用栈
- return x 不是一个原子语句
Go 语言为提供了 defer 函数,可以保证文件关闭(资源的释放)后一定会被执行,不管自定义的函数出现异常还是错误。
下面的代码是 Go 语言标准包 ioutil 中的 ReadFile 函数,它需要打开一个文件,然后通过 defer 关键字确保在 ReadFile 函数执行结束后,f.Close() 方法被执行,这样文件的资源才一定会释放。
1 | func ReadFile(filename string) ([]byte, error) { |
defer 关键字用于修饰一个函数或者方法,使得该函数或者方法在返回前才会执行,也就说被延迟,但又可以保证一定会执行。
以上面的 ReadFile 函数为例,被 defer 修饰的 f.Close 方法延迟执行,也就是说会先执行 readAll(f, n),然后在整个 ReadFile 函数 return 之前执行 f.Close 方法。
defer 语句常被用于成对的操作,如文件的打开和关闭,加锁和释放锁,连接的建立和断开等。不管多么复杂的操作,都可以保证资源被正确地释放。
panic
Go 语言是一门静态的强类型语言,很多问题都尽可能地在编译时捕获,但是有一些只能在运行时检查,比如数组越界访问、不相同的类型强制转换等,这类运行时的问题会引起 panic 异常。
除了运行时可以产生 panic 外,自己也可以抛出 panic 异常。假设需要连接 MySQL 数据库,可以写一个连接 MySQL 的函数 connectMySQL,如下面的代码所示:
1 | func connectMySQL(ip,username,password string){ |
在 connectMySQL 函数中,如果 ip 为空会直接抛出 panic 异常。这种逻辑是正确的,因为数据库无法连接成功的话,整个程序运行起来也没有意义,所以就抛出 panic 终止程序的运行。
panic 是 Go 语言内置的函数,可以接受 interface{} 类型的参数,也就是任何类型的值都可以传递给 panic 函数,如下所示:
1 | func panic(v interface{}) |
小提示:interface{} 是空接口的意思,在 Go 语言中代表任意类型。
panic 异常是一种非常严重的情况,会让程序中断运行,使程序崩溃,所以 如果是不影响程序运行的错误,不要使用 panic,使用普通错误 error 即可。
recover
通常情况下,不对 panic 异常做任何处理,因为既然它是影响程序运行的异常,就让它直接崩溃即可。但是也的确有一些特例,比如在程序崩溃前做一些资源释放的处理,这时候就需要从 panic 异常中恢复,才能完成处理。
在 Go 语言中,可以通过内置的 recover 函数恢复 panic 异常。因为在程序 panic 异常崩溃的时候,只有被 defer 修饰的函数才能被执行,所以 recover 函数要结合 defer 关键字使用才能生效。
下面的示例是通过 defer 关键字 + 匿名函数 + recover 函数 从 panic 异常中恢复的方式。
1 | func main() { |
运行这个代码,可以看到如下的打印输出,这证明 recover 函数成功捕获了 panic 异常。
1 | ip 不能为空 |
通过这个输出的结果也可以发现,recover 函数返回的值就是通过 panic 函数传递的参数值。
unsafe
Go 将其定义为这个包名,也是为了让尽可能地不使用它。不过虽然不安全,它也有优势,那就是可以绕过 Go 的内存安全机制,直接对内存进行读写。所以有时候出于性能需要,还是会冒险使用它来对内存进行操作。
指针类型转换
Go 是强类型的静态语言,强类型意味着一旦定义了,类型就不能改变;静态意味着类型检查在运行前就做了。同时出于安全考虑,Go 语言是不允许两个指针类型进行转换的。
1 | func main() { |
在编译的时候,会提示 cannot convert ip (type * int) to type * float64,也就是不能进行强制转型。那如果还是需要转换呢?这就需要使用 unsafe 包里的 Pointer 了。
unsafe.Pointer
unsafe.Pointer 是一种特殊意义的指针,可以表示任意类型的地址,类似 C 语言里的 void * 指针,是全能型的。正常情况下,* int 无法转换为 *float64,但是通过 unsafe.Pointer 做中转就可以了。
1 | func main() { |
按 Go 语言官方的注释,ArbitraryType 可以表示任何类型,而 unsafe.Pointer 又是 *ArbitraryType,也就是说 unsafe.Pointer 是任何类型的指针,也就是一个通用型的指针,足以表示任何内存地址。
uintptr 指针类型
uintptr 也是一种指针类型,它足够大,可以表示任何指针。unsafe.Pointer 不能进行运算,比如不支持 +(加号)运算符操作,但是 uintptr 可以对指针偏移进行计算,这样就可以访问特定的内存,达到对特定内存读写的目的,这是真正内存级别的操作。
1 | func main() { |
指针转换规则
graph LR A[*T] <--互转---> B(unsafe.Pointer) B <--互转---> C(uintptr)
Go 语言中存在三种类型的指针,它们分别是常用的 *T、unsafe.Pointer 及 uintptr,这三者的转换规则:
- 任何类型的 *T 都可以转换为 unsafe.Pointer;
- unsafe.Pointer 也可以转换为任何类型的 *T;
- unsafe.Pointer 可以转换为 uintptr;
- uintptr 也可以转换为 unsafe.Pointer。
unsafe.Pointer 主要用于指针类型的转换,而且是各个指针类型转换的桥梁。uintptr 主要用于指针运算,尤其是通过偏移量定位不同的内存。
others
String 和 [] byte
字符串 string 也是一个不可变的字节序列,所以可以直接转为字节切片 [] byte:
1 | s:="Hello 世界" |
string 不止可以直接转为 [] byte,还可以使用 [] 操作符获取指定索引的字节值:
1 | s:="Hello 世界" |
new 和 make
Go 语言程序所管理的虚拟内存空间会被分为两部分:堆内存和栈内存。栈内存主要由 Go 语言来管理,开发者无法干涉太多,堆内存才是开发者发挥能力的舞台,因为程序的数据大部分分配在堆内存上,一个程序的大部分内存占用也是在堆内存上。常说的 Go 语言的内存垃圾回收是针对 堆内存 的垃圾回收。
变量的声明
1 | var s string // 声明并未初始化,string 的零值:""(空字符串) |
变量的赋值
如果在声明一个变量的时候就给这个变量赋值,这种操作就称为变量的初始化。指针类型的变量 如果没有分配内存,就默认是零值 nil,它没有指向的内存,所以无法使用,强行使用就会得到 nil 指针错误。而对于 值类型的变量 来说,即使只声明一个变量,没有对其初始化,该变量也会有分配好的内存。
struct 结构体,是一个值类型,Go 语言自动分配了内存,所以可以直接使用,不会报 nil 异常。
1 | var s1 string = "Go" |
如果要对一个变量赋值,这个变量必须有对应的分配好的内存,这样才可以对这块内存操作,完成赋值的目的。所以一个变量必须要经过 声明、内存分配 才能 赋值,才可以在声明的时候进行 初始化。指针类型在声明的时候,Go 语言并没有自动分配内存,所以不能对其进行赋值操作,这和值类型不一样。
map 和 chan 也一样,因为它们本质上也是指针类型。
new 函数
1 | // The new built-in function allocates memory. The first argument is a type, |
new 函数的作用是根据传入的类型申请一块内存,然后返回指向这块内存的指针,指针指向的数据就是该类型的 零值。
1 | var sp *string |
变量的初始化
当声明了一些类型的变量时,这些变量的零值并不能满足要求,这时就需要在变量声明的同时进行赋值(修改变量的值),这个过程称为变量的初始化。
1 | var s string = "Go" |
指针变量初始化
new 函数可以申请内存并返回一个指向该内存的指针,但是这块内存中数据的值默认是该类型的零值,在一些情况下并不满足业务需求。可以自定义一个函数,对指针变量进行初始化:
1 | func NewPerson(name string, age int) *person{ |
NewPerson 函数就是工厂函数,除了使用 new 函数创建一个 person 指针外,还对它进行了赋值,也就是初始化。
make 函数
在使用 make 函数创建 map 的时候,其实调用的是 makemap 函数:
1 | // makemap implements Go map creation for make(map[k]v, hint). |
makemap 函数返回的是 *hmap 类型,而 hmap 是一个结构体,它的定义如下面的代码所示:
1 | // A header for a Go map. |
要想使用这样的 hmap,不是简单地通过 new 函数返回一个 *hmap 就可以,还需要对其进行初始化,这就是 make 函数要做的事情,如下所示:
1 | m := make(map[string]int, 10) |
其实 make 函数就是 map 类型的工厂函数,它可以根据传递它的 K-V 键值对类型,创建不同类型的 map,同时可以初始化 map 的大小。
make 函数不只是 map 类型的工厂函数,还是 chan、slice 的工厂函数。它同时可以用于 slice、chan 和 map 这三种类型的初始化。
new 函数只用于分配内存,并且把内存清零,也就是返回一个指向对应类型零值的指针。new 函数一般用于需要显式地返回指针的情况,不是太常用。
make 函数只用于 slice、chan 和 map 这三种内置类型的创建和初始化,因为这三种类型的结构比较复杂,比如 slice 要提前初始化好内部元素的类型,slice 的长度和容量等,这样才可以更好地使用它们。
闭包(匿名函数)
1 | func main() { |
闭包对其作用域内变量的 捕获和持有。闭包捕获并持有了变量 i 的引用,而不是其值,因此每次调用闭包时,它都操作同一个 i 变量,而
不是每次重新初始化 i。
loopvar
1 | // go1.22 |
1 | func rightLoop() { |
- go1.22 版本之后解决了 for 循环变量共享的问题,注意必须 go 版本和 gomod 版本都 >= 1.22 才会使用新的 loopvar
- 虽然之前版本的变量共享,但在协程里可能会输出不同的值,不该想当然以为值永远是最后一个。