Golang 什么时候使用指针(Pointer)?什么时候使用值(Value)?对于go开发者来说是一件头疼的事情, 而且这个问题似乎没有绝对的答案,那是否代表我们可以随意使用呢?答案当然是否定的。本文我将试图总结什么场景使用指针更合理。 在开始阅读前,建议读者先能够清晰理解 Golang 指针、类型和值等概念。

本文并不是标准更不是唯一答案,而是自己根据使用经验和社区的一些讨论而总结的实践

有下几种情形,我们是否需要考虑使用指针:

  1. 结构体定义的字段
  2. 方法中接受者
  3. 函数传参
  4. 函数和方法返回值

这里我先给出使用一般准则,后面详细介绍不同场景的细节。

  • 方法通常使用指针作为接受者, 官方文档的建议是:如果犹豫,请使用指针;
  • Slices,maps,channels,strings,function value and interface value 这些类型内部通过指针实现,再定一个指针指向这些类型的变量是多余的;
  • 当一个结构体很复杂或者需要修改结构体使用指针,其他情况使用值,因为滥用指针会出现一些不可预料的情况;

什么不需要使用指针

  1. CodeReviewComments中建议传输(pass)小型结构体, 如type Point struct{ latitude,longtitude float64 },使用原始类型,除非需要修改它们,理由有以下几点
  • 值语义可以避免歧义,因为指针类型的赋值;
  • 牺牲干净的语义换取速度并不是Golang所推荐的做法,有时值传递性能更好,因为值传递能够避免缓存遗漏和队分配;
  1. 对于切片,不需要使用指针指向它,仍然可以改变其元素,这是因为切片内部是通过指针指向底层数组实现的, 标准库中io.Reader.Read(p []byte)函数中即通过传递值类型实现对切片 p 的修改。其实切片也可以当作是小型结构体,其定义如下, 在 64 位系统一个切片变量只占用 24 个字节。同样地,对于 map 和 channel 类型,通常也不需要使用指针
1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
  1. 对于slices you'll reslice(更改其起始元素位置/长度/容量),如内置函数func append(slice []Type, elems ...Type) []Type, 接受一个切片,返回新的切片。返回一个新数组会让调用者更清晰地理解函数的语义。

什么时候必须使用指针

对于以下场景,使用指针是必须的:

  1. 如果结构体中包含sync.Mutex获取类似其他同步字段时,由于这类字段类型是禁止拷贝的,所以无论其方法的接受者, 还是其作为参数和返回值都应该使用指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type FIFO struct {
lock sync.RWMutex
cond sync.Cond
items map[string]interface{}
queue []string
populated bool
initialPopulationCount int
keyFunc KeyFunc
closed bool
}

// Close the queue.
func (f *FIFO) Close() {
f.lock.Lock()
defer f.lock.Unlock()
f.closed = true
f.cond.Broadcast()
}

// NewFIFO returns a Store which can be used to queue up items to
// process.
func NewFIFO(keyFunc KeyFunc) *FIFO {
f := &FIFO{
items: map[string]interface{}{},
queue: []string{},
keyFunc: keyFunc,
}
f.cond.L = &f.lock
return f
}


结构中定义

结构体中,除了需要考虑是否内存的占用之外,还需要考虑结构体的用途,一般主要分为工具结构体资源结构体资源结构体很容易理解,主要包括VO,DAO,Entity等,这类结构体一般用于模块或分层之间的通信。对于这类结构体, 如用于序列化的结构体,根据序列化协议和库可能有所区别,如Golang默认的Json序列化协议对于是否显示字段, 定义为指针可以可好解决。下面是kubernetes IngressSpec的定义。

1
2
3
4
5
6
7
8
// IngressSpec describes the Ingress the user wishes to exist.
type IngressSpec struct {
IngressClassName *string `json:"ingressClassName,omitempty" protobuf:"bytes,4,opt,name=ingressClassName"`
Backend *IngressBackend `json:"backend,omitempty" protobuf:"bytes,1,opt,name=backend"`
TLS []IngressTLS `json:"tls,omitempty" protobuf:"bytes,2,rep,name=tls"`
Rules []IngressRule `json:"rules,omitempty" protobuf:"bytes,3,rep,name=rules"`
}

我们可以看到字段IngressClassName定义为指针类型,就是为了序列化时更方便处理。 工具结构体通常指非资源结构体,主要是controller,config,factory和自定义数据结构类型, 这些结构体往往更需要考虑内存占用。

性能

使用指针并不是总能提升性能。使用指针可以避免值拷贝,减少栈内存的占用,由于堆内存的分配会导致GC频繁地执行,从而降低性能, 而值传递则不会。我们通过下面的实例Demo验证。

以下两个函数分别通过值拷贝和指针共享结构体:

分别进行进行基准测试:

执行基准测试,benchstat工具需要下载,链接为perf

1
2
go test ./... -bench=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name          time/op
MemoryHeap-4 55.7ns ± 5%

name alloc/op
MemoryHeap-4 96.0B ± 0%

name allocs/op
MemoryHeap-4 1.00 ± 0%
---
name time/op
MemoryStack-4 8.37ns ± 9%

name alloc/op
MemoryStack-4 0.00B

name allocs/op
MemoryStack-4 0.00

可以看出,这时候通过值传递的方式执行更快,内存占用也更少。关于如何分析Golang程序和代码段性能, 后续我会总结一篇博客单独介绍。

总结

本篇博文,简单介绍怎样如何使用指针更合理,其实很多场景都没有标准答案,更多的是性能语义两者的权衡。 掌握Golang中类型,值,指针类型等基础概念才能优雅地使用指针。遇到疑惑的参考标准库中的实现和借鉴一些成熟的项目中的实践;

参考

  1. stackoverflow
  2. 使用指针还是结构的 Copy
  3. Go: Are pointers a performance optimization?