GO指针使用拾遗

Go中的指针类型和值类型并不是新鲜东西,学过c或者c++得人恐怕不用专门看相关书籍,都能指出来,什么”指针就是存储该类型对象的结构体,指针可以用来修改结构体状态什么“。
当然我起初也是这么想的,但是当我实际开始写go的项目的时候,我马上发现,关于指针的问题似乎会若隐若现的困扰着我:

  1. 在使用API的时候,我们函数调用应该传入S还是*S,当结构体需要存储类型的时候,我们又应该存储S还是*S?
  2. 换过来,我们自己在设计接口的时候,我们应该采用指针接收者还是类型作为接收者?我们的构造器应该返回的是类型S还是*S?

对于上述第一个问题,一个比较简洁的答案是:遵循结构体本身的规则,如果你所使用的结构体如A,其方法定义都是*S,那么我们无论是在我们的结构体中存储还是函数调用,都应该使用*S,反之,如果接收者都是S,那么我们就应该使用S来存储或者函数调用。
也许有人会捣蛋,比方说,我就不遵循又会如何呢?我们不妨分别举例子

当类型声明使用值但是却以指针的方式存储(以time.Time为例子)

type Stupid struct {
	t *time.Time
}

func addTime(s *Stupid, duration time.Duration) {
	s.t.Add(duration)
}
func main() {
	t := time.Now()
	s := Stupid{t: &t}
	before:=s.t
	addTime(&s, time.Minute*1999)
	fmt.Println(before==s.t)// true
}

在这里,我们构造了一个Stupid类,虽然time声明自己是值类型应该用复制的形式,但是Stupid结构体仍然倔强的存储值并希望能够改变状态,它在addtime中传入了指针试图修改状态,但是显然没有成功,因为Add函数并不会修改time结构体,而是会返回一个新的结构体
当然stupid类不会服气,我们难道就不能真的更改状态吗?于是便继续往下写

type Stupid struct {
	t *time.Time
}

func addTime(s *Stupid, duration time.Duration) {
	newTime := s.t.Add(duration)
	s.t = &newTime
}
func main() {
	t := time.Now()
	s := Stupid{t: &t}
	before := s.t
	addTime(&s, time.Minute*1999)
	fmt.Println(before.UnixNano() == s.t.UnixNano()) // false
	fmt.Println(before == s.t) // false
}

终于,我们发现before与s.t并不相同,看来,我们的确成功改变了time的状态了,stupid大获成功!等会为什么before会不等于s.t呢?
如果你再仔细琢磨,你就发现,Stupid并没有改变time(最开始的s.t),stupid之所以改变,只不过是引用了新的time
当然,还有些精神小伙会不同意,“如果我们持有指针,那么我们s.t.blah=1234的方式不就成功更改了s.t的状态吗”
当然我们如果看一下time的源码,我们会发现time中的状态变量都是小写,意味着你并不能这么引用来修改,另一方面,如果我们对面向对象很熟悉的话,我们应该明白,状态是对象的实现细节,我们对于结构体的访问应该用方法来完成从而屏蔽实现细节,因此即便time的状态是公开的,我们也绝不应该轻易用这种方式修改
因此我们可以总结一句:对于一个值类型,任何试图存储指针来达到修改值类型本身的想法是不靠谱的,你除了让自己写起来更麻烦(为了获取指针,你得声明每一个值类型变量)不会得到任何收益

当声明用指针访问却用S来存储的时候
先立即给一个结论,这会是一个灾难,你会发现很多光怪陆离的现象,比方说“为什么我调用这个方法却没有得到一个应当的结果”
我们不妨拿熟悉的mutex作为例子

type Counter struct {
	counter int64
}

func (c *Counter) AddOne() {
	c.counter++
}

type superCounter struct {
	c Counter
}

func (s superCounter) AddOneAndLog() {
	s.c.AddOne()
	fmt.Println(s.c.counter)
}

func main() {
	sc:=superCounter{c:Counter{}}
	sc.AddOneAndLog() //1
	fmt.Println(sc.c.counter) //0
}

我们可以看到有一个Counter类型,它有个了不起的方法,每次都能给自己+1,当然你看到了接收者是*S,显然,我们应该用指针的方式存储和调用。伟大的程序员小A觉得Counter实在太过于平平无奇了,于是增加了一个superCounter类型,它不仅仅能够addOne,还能够打印出来这个counter的值,简直是不可思议的功能
于是小A运行了一下,看到AddOneAndLog顺利打印出来1,他得意地笑了。等会,为什么sc.c.counter却还是0呢?我们不是成功修改了状态吗?
当然你不难分析出来,核心的原因在于s.AddOneAndLog被调用的时候,由于接收者是类型,所以被复制了一个新的出来,原先的counter没有被修改
因此我们可以得到第二个结论:当一个类型是引用类型的时候,其意在被修改,而如果被用值类型存储或者传参等行为从而触发了复制,那么你就再也不是在修改类型本身状态,一般就意味着你错用了
总而言之,当我们在使用其他类型的时候,我们可以不妨参照下其方法实现到底是指针还是类型从而挑选恰当的方式使用

当然,第二个问题还没有得到解决,我们自己又是怎么决定是否使用指针还是类型来实现方法呢?
无论是书籍还是大牛语录,其实这个问题的解答都相对简单:如果你的结构体都是数字,或者引用(切片,map,channel,指针),或者说域特别少,那么就用值传递吧,否则就用指针
这样的结论其实着眼点在于复制的成本(值传递的时候一般充满大量的复制过程),而且言语中似乎透露了这个问题的答案在于成本而不在于必要性(也就是说指针和类型本身都可以),我们不妨自行尝试下

type SyncCounter struct {
	mutex   *sync.Mutex
	counter int64
}

func (s SyncCounter) AddOne() {
	s.mutex.Lock()
	defer s.mutex.Unlock()
	s.counter++
}

我们可以看到上述一个例子,mutex基于我们之前提到的原则用指针存储,然后其本身的作用是在于原子性计数,采用了值传递的方式,我的问题是,这个类型,顺利实现了吗?
可能很多人立刻意识到,答案是没有,问题在于,AddOne在被调用的时候发生了复制,mutex当然顺利的被复制(因此不至于出现该锁的没锁上的问题),但是counter却被分隔,导致每次AddOne添加的counter不是原先的counter
在第一个例子里面,我们可以看到,如果我们要改变状态,那么我们就要用指针
我们不妨在看第二个例子

type Syncer struct {
	mutex sync.Mutex
}

func (s *Syncer) Exec(f func()) {
	s.mutex.Lock()
	defer s.mutex.Unlock()
	f()
}

这次类型Syncer没有了counter功能,而是试图序列化执行一串函数,不过我们发现,在这里,mutex似乎不像我们之前所说,用指针来保存,“这难道不会导致复制而mutex上锁失败吗?”,如果你试图运行这段程序,你会发现并不会产生担心的情况,如果仔细一分析,你会发现道理很简单,虽然mutex一旦被复制就会上锁失败(也适用于修改状态的类型一旦复制就失去了修改的功能),但是我可以通过传入指针的方式,避免复制
因此我们得到了第二条结论:如果类型S包含的类型T需要修改状态,但是在S中却是以T(而不是*T)的形式出现,那么S要用接收者为*S的方法
当然,如果你的类型S存储的方式恰当, 本身也没有引入任何会修改状态的方法(此时更像是一个容器),那么是否采用S还是*S为接收者,你就可以依赖于之前对于成本的分析

参考:https://www.ardanlabs.com/blog/2014/12/using-pointers-in-go.html

扫描二维码关注公众号,回复: 10939724 查看本文章
发布了31 篇原创文章 · 获赞 32 · 访问量 748

猜你喜欢

转载自blog.csdn.net/a348752377/article/details/103144863