文章

关于golang中的"引用"与指针的理解

最近在独立开发一些golang服务,在实践中纠正了一些错误的认知,记录如下。

golang和很多语言(不含c++)一样,没有显式的引用。很多包括笔者在内的写pythonjava的朋友可能会认为或者希望golang的复杂类型或者至少mapslice是以引用的方式进行传递的。但是事实上与pythonjava不同的是,golang连隐式引用都不存在(或者说golang不存在任何奇怪的隐式语法)。甚至函数接收器本质上都是一个指针值拷贝。因此我们必须在这个方面将golang看成有gcc语言。即任何的赋值、传参都是以值拷贝的方式进行的。

这就要求我们理清楚看似为值传递的一些现象的本质,并探索指针使用的优秀范式。因为笔者在golang之外,大量使用c++进行工作与研究,因此c味很重,希望读者们可以忍受或者、指出本文的一些错误。

看似为引用的一些现象

map、slice

slice为例。本质上slice是一个特殊的结构体,共有容量、长度、指针三个变量。写过c++的朋友可以将它想象成魔法更少一些的vector。当我们复制一个slice时,会对这三个成员进行浅拷贝,然后我们就得到了一个容量、长度、指向真正数据的指针都相同的slice。当不涉及修改slice时(不含修改slice内含数据),他的功能与原slice是完全一致的,而且他的复制非常的轻量。因此slice的复制往往被滥用。事实上,golang官方也非常推荐这种行为,例如appenddelete都是以这种方式实现的。

但是我们需要注意当我们以参数或者值接收器的形式传递了一个slice时,对他再进行修改是毫无意义的。

例如

func f(a []int)
{
	a = append(a,1)
	a[len(a)-1]=2
}

以上f中的两行代码都无法真正对传入前的实参产生作用。在网络上存在大量低质量的教程阐述,mapslicechannal是引用类型,当遇到这些类型时无需使用指针传参或使用指针接收器。这是很不负责任的一种说法。

此外,有的开发者会试图利用这种特性进行各种奇怪的操作,例如传入一个cap足够大的a,在f中进行某种操作试图影响实参slice扩容后的值。。。这种被c++之父成为"太过于聪明反而显得有点愚蠢"的行为往往是由前cc++程序员干出来的。但是需要注意的是,与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) ),对slicemap进行复制是危险以及意义不大的行为。因此笔者建议,除非是append等将老变量销毁或隐藏的特殊情况,任何情况应禁止对slicemap进行浅拷贝,在设计接口时也严禁设计直接返回成员slicemap的函数。也就是说不能在同一生命周期内存在两个即以上的备份。

当然也不建议直接返回这些成员的指针,用起来麻烦且不安全。应该将相关功能包进类的方法中,而不是将成员暴露在外。如果存在必须将所有数据get出来的情况,也建议返回一个深拷贝后的新slice

channel

为了让开发者可以直观地使用管道技术,golang在设计阶段对channel进行了大量优化,赋值、传参操作所构造出的管道与原管道的功能是完全相同的。当然这和引用没有任何关系,而是channel剥离出了两个接口<-->,这两个接口有神奇的实现,事实上channel本身是一种语言魔法的造物,复制时本质上是编辑器复制了一个指针。并且这与协程机制深度关联。因此,没有在语言的框架下讨论语言魔法本身的必要性。

mutex、sync.map

sync包里的东西基本上低层都涉及到大量的mutex。需要特别指出的是,mutexsync.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()

编辑器会非常智能的对ab求、解地址来自动适配相对应的方法。这个是强大而又危险的特性。他会混淆我们对于变量和指针的直观认知。同时,虽然golang对指针有严格的溢出检测,但是空指针调用依然会发生panic。根据大多数框架逻辑并行、任其崩溃的设计理念,这个都是会打断全流程的。这个有时是有必要的,因为空指针意味着逻辑有问题,缺少必要资源,本来就应该立刻中断全流程。当然视设计而定可以优化为返回err以中断。

如此方便且相对安全的指针,导致golangc一样充斥着漫天飞舞的指针。提供的非常方便的语法糖又在很多地加强了对指针的滥用,因此必须严格进行规范。

笔者建议:

  • 任何指针需要显式的匈牙利命名法指出这个是个指针。
  • 任何指针作为参数的地方都需要显式对指针进行判空。
  • 小类型尽量全部使用指针进行操作(也方便兼容protobuf
  • 以空指针判断取代变量的默认值判断(不然懒加载时难以确认是否加载
  • 以资源所有权的概念理解指针
  • 参数、返回、接收器(除非是没有内部变量的)尽量使用指针以优化效率。golang没有右值转移机制的。

此外。在设计上需要注意指针尽量封装在模块内部,模块则以组合加载的形式封装在大模块内部,不要过度使用裸指针。

License:  CC BY 4.0