关于golang中的"引用"与指针的理解
最近在独立开发一些golang
服务,在实践中纠正了一些错误的认知,记录如下。
golang
和很多语言(不含c++
)一样,没有显式的引用。很多包括笔者在内的写python
、java
的朋友可能会认为或者希望golang
的复杂类型或者至少map
、slice
是以引用的方式进行传递的。但是事实上与python
、java
不同的是,golang
连隐式引用都不存在(或者说golang
不存在任何奇怪的隐式语法)。甚至函数接收器本质上都是一个指针值拷贝。因此我们必须在这个方面将golang
看成有gc
的c语言
。即任何的赋值、传参都是以值拷贝的方式进行的。
这就要求我们理清楚看似为值传递的一些现象的本质,并探索指针使用的优秀范式。因为笔者在golang
之外,大量使用c++
进行工作与研究,因此c
味很重,希望读者们可以忍受或者、指出本文的一些错误。
看似为引用的一些现象
map、slice
以slice
为例。本质上slice
是一个特殊的结构体,共有容量、长度、指针三个变量。写过c++
的朋友可以将它想象成魔法更少一些的vector
。当我们复制一个slice
时,会对这三个成员进行浅拷贝,然后我们就得到了一个容量、长度、指向真正数据的指针都相同的slice
。当不涉及修改slice
时(不含修改slice
内含数据),他的功能与原slice
是完全一致的,而且他的复制非常的轻量。因此slice
的复制往往被滥用。事实上,golang
官方也非常推荐这种行为,例如append
和delete
都是以这种方式实现的。
但是我们需要注意当我们以参数或者值接收器的形式传递了一个slice
时,对他再进行修改是毫无意义的。
例如
func f(a []int)
{
a = append(a,1)
a[len(a)-1]=2
}
以上f
中的两行代码都无法真正对传入前的实参产生作用。在网络上存在大量低质量的教程阐述,map
、slice
、channal
是引用类型,当遇到这些类型时无需使用指针传参或使用指针接收器。这是很不负责任的一种说法。
此外,有的开发者会试图利用这种特性进行各种奇怪的操作,例如传入一个cap
足够大的a
,在f
中进行某种操作试图影响实参slice
扩容后的值。。。这种被c++
之父成为"太过于聪明反而显得有点愚蠢"的行为往往是由前c
、c++
程序员干出来的。但是需要注意的是,与c
不同,golang
在简单的语法背后,有着极为复杂的保护与默认逻辑,因此不推荐这种行为(事实上也是危险的)。这种行为会导致一些非常奇怪的现象例如
s1 := make([]int, 0, 10)
s1 = append(s1, 1)
s1 = append(s1, 2)
s2 := s1
s2 = append(s2, 3)
s1 = append(s1, 4)
for _, v := range s1 {
print(v, " ")
}
print("\n")
for _, v := range s2 {
print(v, " ")
}
print("\n")
这段看似符合golang
语法与规范的代码,事实上会导致在c系语言中很常见的内存问题。他的输出实际上是
1 2 4
1 2 4
map的原理差不多,就不赘述了。
综上,可以得出结论,在非转移的情况下(例如 a = append(a,1)
),对slice
、map
进行复制是危险以及意义不大的行为。因此笔者建议,除非是append
等将老变量销毁或隐藏的特殊情况,任何情况应禁止对slice
、map
进行浅拷贝,在设计接口时也严禁设计直接返回成员slice
、map
的函数。也就是说不能在同一生命周期内存在两个即以上的备份。
当然也不建议直接返回这些成员的指针,用起来麻烦且不安全。应该将相关功能包进类的方法中,而不是将成员暴露在外。如果存在必须将所有数据get
出来的情况,也建议返回一个深拷贝后的新slice
。
channel
为了让开发者可以直观地使用管道技术,golang
在设计阶段对channel
进行了大量优化,赋值、传参操作所构造出的管道与原管道的功能是完全相同的。当然这和引用没有任何关系,而是channel
剥离出了两个接口<-
,->
,这两个接口有神奇的实现,事实上channel
本身是一种语言魔法的造物,复制时本质上是编辑器复制了一个指针。并且这与协程机制深度关联。因此,没有在语言的框架下讨论语言魔法本身的必要性。
mutex、sync.map
sync
包里的东西基本上低层都涉及到大量的mutex
。需要特别指出的是,mutex
和sync.map
复制后的对象与原对象不共享任何同步性。因此从设计的角度,这两个东西应该在模块的底层充当核心,而不是反过来要求外部调用去依赖他们。这个是违反高内联、低耦合的初衷的。
闭包
闭包中的捕获变量,从编辑器的角度上和原变量是同一个符号。从定义上来看是不能说是引用的。
有关于指针
golang
是有指针的,因此被很多人视为modern clang
。在概念的理解上需要注意*指针
应该被视为一个引用,与原变量完全一直,而指针
与原变量无关,只有承载地址的功能(当然golang
的原生语法会混淆这一点),更改指针本身的值对别的任何变量都是完全无损的。
对于以下类型A。
type A int
func (receiver *A) ptr() {
}
func (receiver A) noptr() {
}
以下的调用都是合法的。
a := A(0)
a.ptr()
a.noptr()
b := &a
b.ptr()
b.noptr()
编辑器会非常智能的对a
、b
求、解地址来自动适配相对应的方法。这个是强大而又危险的特性。他会混淆我们对于变量和指针的直观认知。同时,虽然golang
对指针有严格的溢出检测,但是空指针调用依然会发生panic
。根据大多数框架逻辑并行、任其崩溃的设计理念,这个都是会打断全流程的。这个有时是有必要的,因为空指针意味着逻辑有问题,缺少必要资源,本来就应该立刻中断全流程。当然视设计而定可以优化为返回err
以中断。
如此方便且相对安全的指针,导致golang
和c
一样充斥着漫天飞舞的指针。提供的非常方便的语法糖又在很多地加强了对指针的滥用,因此必须严格进行规范。
笔者建议:
- 任何指针需要显式的匈牙利命名法指出这个是个指针。
- 任何指针作为参数的地方都需要显式对指针进行判空。
- 小类型尽量全部使用指针进行操作(也方便兼容
protobuf
- 以空指针判断取代变量的默认值判断(不然懒加载时难以确认是否加载
- 以资源所有权的概念理解指针
- 参数、返回、接收器(除非是没有内部变量的)尽量使用指针以优化效率。
golang
没有右值转移机制的。
此外。在设计上需要注意指针尽量封装在模块内部,模块则以组合加载的形式封装在大模块内部,不要过度使用裸指针。