原文地址
我第一次接触指针是在高二的时候,当时我正在学习早期版本的《21天自学C》。第二年,我开始在计算机课上学习c++,并在大四和整个大学期间继续在计算机科学中学习c++。我毕业时获得了计算机科学学士学位,知道指针是什么以及如何使用它。我甚至可以做指针运算……
但是没有人教过我什么时候使用指针,什么时候不使用。
毕业后,我进入了保险行业,在我职业生涯的前10年里,我主要用Java编程。 感谢“编程语言对比”课程上的一位优秀教授,让我明白,任何说“Java没有指针”的人,要么是无知,要么是在编一个善意的谎言,以减轻年轻程序员可能被指针吓倒的恐惧。 在Java中,对象名称实际上是一个引用(即指针),这种认识通常是很有用的。向Java的转变在当时是一个足够新的趋势,因为还没有大量的Java知识。我经常需要告诉更高级的开发人员,为什么使用值传递的语言能够改变作为参数传递给方法的对象的属性。
尽管指针仍然留在我的脑海中,但是Java并没有给我太多关于何时使用指针和何时不使用指针的深思熟虑的选择,所以这个问题在很大程度上变得没有定论。几年后,我有了一段时间接触Ruby,其中指针的情况与Java非常相似。
幸运的是,对于什么时候使用指针,什么时候不使用指针,我已经有了相当好的直觉或经验,如果有人问我为什么选择使用指针,我通常可以为自己选择使用或不使用指针的原因进行解释。但这是通过多年的经验和相当数量的错误总结得来的。
我经常遇到Go代码出现(我认为是)随意使用指针的情况。这并不是要批评那些编写此类代码的人——毕竟,我一直在这里强调,关于这个话题的明智结论是很难获得。因此,这篇博客是我想真诚的分享一些我希望自己能早点学习到的见解。
指针的危害
在我们开始讨论何时使用指针,何时不使用指针之前,我们必须承认使用指针的一些危险。也就是说,我们需要弄清楚为什么过度使用指针可能是一件不好的事。
对我来说,使用指针最大的两个危险是:
- 意外的空指针引用
- 无意中改变的了不该改变的数据
如果这些对你来说已经很明显了,你可以跳过这部分。
让我们先来讨论一下nil指针引用。
nil指针引用
考虑一下这段简短的代码:
package main
import "fmt"
type S struct {
Name string
}
func main() {
var s *S
fmt.Println(s.Name)
}
在这里,变量s被初始化为nil,因为这是指针的默认值,无论它指向什么类型。在fmt.Println(s.Name)语句中,当我们试图访问s所指向的结构体的Name属性时,指针s被自动解引用——没有指向任何对象。
在非内存安全的语言中,这种情况下的运行时行为没有明确定义。这可能导致缓冲区溢出、难以预测的行为和危险。然而,Go是内存安全的,这意味着在这些情况下的运行时行为是定义良好的。Go程序在这种情况下会panic并停止执行。
运行上面的程序会产生:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x1091487]
goroutine 1 [running]:
main.main()
/Users/kent/foo.go:11 +0x27
exit status 2
当然,这种panic比不确定的行为要好,但它也会带来不好的结果。
考虑如下示例:
package main
import (
"fmt"
"time"
)
type S struct {
Name string
}
func main() {
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
for i := 0; i < 10; i++ {
fmt.Println(i)
<-time.After(time.Second)
}
}()
go func() {
<-time.After(5 * time.Second)
var s *S
fmt.Println(s.Name)
}()
<-doneCh
}
在这个程序中,我们在一个goroutine中慢慢地从0计数到9,而在另一个goroutine中等待5秒,然后通过nil指针解引用来故意触发panic。注意,这将终止整个程序,第一个goroutine不再继续计数。当然,这是最好的或最安全的行为,因为假设一个正常运行的goroutine依赖于另一个遇到nil指针解引用并死亡的goroutine发送或接收相关channel。这可能会使程序迅速陷入死锁。
如果你正在为一个高并发应用编写程序—空指针解引用不仅影响对应的goroutine,甚至会终止整个程序。假设您的程序是一个web应用程序或API服务器。很多正在进行的请求很有可能成功的,但可能因为其中一个遇到了nil指针解引用而失败。
当然,到目前为止我们所看到的例子都是比较明显的,而且非正常的nil指针解引用应该很容易被发现。然而,在实际应用中,这个问题可能不是那么明显。
让我们看一段假设的代码,其调用了一个函数,目的是从某些底层数据存储中检索数据。
// ...
func main() {
// ...
s := storage.GetS("foobar")
fmt.Println(s.Name)
}
其中,storage.GetS(…)的函数签名如下:
GetS(string id) *S
Gets函数返回一个指针,该指针可能为空即nil。 假如在底层数据存储中找不到S的值,函数就会返回nil。这里比较负责的做法是需要检查GetS函数返回值是否是nil类型:
func main() {
// ...
s := storage.GetS("foobar")
if s == nil {
// Do something
} else {
fmt.Println(s.Name)
}
}
如果您使用的是goland IDE编写go代码,IDE会对返回nil的函数提示检查。检查空指针是一种负责的做法。但是对开发者来说,忘记检查是很容易发生的。
在本文稍后的部分,我将演示如何以不同的方式编写store.GetS(…)函数,以帮助用户避免意外的nil指针解引用。
偶然的修改
我不建议过度使用指针的第二个原因就是意外的修改变量:
看如下代码:
package main
import (
"fmt"
"strings"
)
type S struct {
Name string
}
func printUpper(s *S) {
s.Name = strings.ToUpper(s.Name)
fmt.Println(s.Name)
}
func main() {
s := &S{
Name: "foo",
}
printUpper(s)
fmt.Println(s.Name)
}
如果你阅读了前面的内容的话,你会发现ToUpper函数没有检查s是否为nil,尽管这个示例不是为了说明这个问题。ToUpper做了一些让函数调用者出乎意料的事情。它改变了s所指向的结构中的Name字段的值,而不是使用一个局部变量来存储大写字符串。当然,这是一个实现得很糟糕的函数,但最糟糕的结果是,因为调用者传递了一个指向s的指针,调用者和接收者共享一个公共结构体,对Name字段的修改将持续到函数调用结束之后。
程序的输出是这样的:
FOO
FOO
printUpper(…)的实现很糟糕。重点是当您将指针传递给函数调用时,您对函数如何处理指针引用的数据是十分信任的。
下面是一个类似于前面例子的程序:
package main
import (
"fmt"
"strings"
)
type S struct {
T *T
}
type T struct {
Name string
}
func printUpper(s S) {
s.T.Name = strings.ToUpper(s.T.Name)
fmt.Println(s.T.Name)
}
func main() {
s := S{
T: &T{
Name: "foo",
},
}
printUpper(s)
fmt.Println(s.T.Name)
}
在这里,从主程序传递给printUpper(…)的变量s不再是一个指针,而是一个类型为s的变量。printUpper函数接收到s的一个拷贝。很容易以为这种方式已经解决了前面的问题。然而s的字段T是一个指针。当s的拷贝传给函数,T指针也会拷贝,但是指针的拷贝指向的内容和原指针是一样的。因此对T指向内容Name的修改,也会导致原s对应的T中的Name字段发生改变。
和前面一样,程序的输出是:
FOO
FOO
我使用指针第一条原则
我使用指针的第一条规则是不使用指针。
我已经听到一些读者反对这个建议。虽然此规则和其他一些遵循的规则都要注意不要使用指针,但其他一些规则列举了您应该考虑它们的情况。我声明“不要使用指针”作为我的第一条规则的目的是不鼓励默认使用指针。
指针的一个神话
许多程序员倾向于相信一个谬论,即指针总是更高率。当我说这句话的时候,我是在针对Go而言,而不是评论其他语言。在Go中,指针有时更有效率。
因为Go在函数调用中利用了值传递语义,所以函数的所有参数在调用函数时都被复制。如果传递给函数的结构占用了很多的内存,那么所有这些参数都将被复制。 相反,如果将指向相同结构的指针传递给函数,则当复制该指针时(假设您使用的是64位操作系统),只复制8个字节。这八个字节引用了存储原始结构体的内存位置。
基于上面所述,我们很容易得出结论传递指针总是更高效——特别是当这些指针引用大型结构时。(顺便说一下,这些语义同样适用于函数的返回值。你返回的任何东西都是要复制的。在使用大型结构的情况下,返回一个指针似乎更直观。)
然而,为了打破这个神话,我邀请您进行一些基准测试——这超出了本文的范围。如果这样做,您将惊讶地发现,在许多情况下,传递需要复制的结构体(甚至是一个比8个字节大得多的结构体)的性能比传递指向相同结构体的指针要好。这怎么可能呢?
理解这一点的关键在于理解内存的两个不同区域——堆和堆栈之间的区别。这两者之间一个非常简单的区别是,堆栈上的内存由CPU有效地管理,而堆上的内存由程序管理,或者可能由语言运行时管理(取决于所涉及的语言)。
一般来说,用于函数参数(或返回值)副本的内存通常分配在堆栈上。当函数返回时,副本将超出作用域,CPU回收内存。相反,指向结构体的指针(指针本身可能存在于堆栈中)引用分配在堆上的内存。为什么?因为当给定的函数调用返回时,指针引用的值不一定超出作用域——这意味着它不应该在栈上。即使一个结构的内存最初是在栈上分配的,当获得指向该结构的指针时,它也可能被移到堆中。当然,我说过我说得很宽泛。需要注意的是,编译器优化有时会改变这些规则。
堆带来了额外的开销——至少在Go中是这样的。 Go语言运行时(与C之类的语言相反)利用垃圾收集从堆中自动回收不再被任何指针引用的内存。垃圾收集开销很大的,而且垃圾收集消耗的CPU周期与堆的使用成比例增长。指针使用的增加等同于堆使用的增加,从而导致更多的CPU周期用于垃圾收集,而更少的CPU周期用于执行应用程序逻辑。
这是否意味着避免指针总是更有效?当然不是。例如,如果您正在处理非常大的结构体,指针仍然更有效。最终,只有基准测试将决定哪种方法对于给定的用例更有效。然而,我揭穿这一神话的目的并不是断言使用指针永远不会更有效。我的观点是,没有做基准测试直接使用指针,往好了说,是一种过早的优化,往坏了说,实际上可能会降低性能。
重申下第一条规则。开始时不要使用指针,先证明你的方法是正确的。
指针并非你想象的那么需要
在这里,我将强调指针的一个常见且诱人的用法,它可能会给您带来麻烦。事实上,我们已经提到过了。回想一下从底层数据存储中检索数据的函数调用例子。
// ...
func main() {
// ...
s := storage.GetS("foobar")
fmt.Println(s.Name)
}
其中,storage.GetS(…)的函数签名如下:
GetS(string id) *S
Gets函数可能返回一个空指针。在一些情况下,可能是作者有意这么设计。假如确实从底层没有读到数据,就需要返回指针来体现。如下代码可以解决忘记检查空指针的情况:
func GetS(id string) (S, bool) {
// Look for the item
// ...
if found {
return item, true
}
return S{}, false
}
在这里,我们总是返回一个结构体S,尽管在没有找到相应的数据情况下,返回S的零值。为了帮助调用者区分正结果和零值,新改进的函数还返回一个bool值,指示是否找到了经过搜索的数据。
乍一看,这可能不像是一个改进,因为这个函数的作者所完成的只是将调用者从可能忘记检查nil值转变为可能忘记检查返回的bool值。但是,必须注意的是,编译器将阻止调用者调用GetS(…),将多个返回值赋值给一个变量。例如,下面会产生一个编译错误:
s := GetS("foo")
然而,这样将编译通过:
s, ok := GetS("foo")
当一个变量被声明并赋值,但却从未被使用时,编译器会认为它是错误的,所以实际上,调用者也会被编译器强制使用ok变量(即检查它的值),或者主动忽略它,就像这样:
s, _ := GetS("foo")
这么处理就不会存在忘记检查。而且会强制要求使用者对结果进行检查。
下面是我推荐使用指针的情况
一般来说,我在三种特定情况下使用指针:
- 当没有其他选择时
- 函数需要修改它的接收器内容
- 在任何我希望使用单例的地方
什么时候别无选择
标准库中的json.Unmarshal(…)函数提供了一个具有指导性的函数示例,该函数需要一个指针参数,因为没有其他选项。下面是它的用法:
s := S{}
err := json.Unmarshal(jsonBytes, &s)
if err != nil {
// ...
}
json.Unmarshal函数签名如下:
Unmarshal(data []byte, v interface{}) error
对于这个函数的用户来说,如果签名看起来像这样,可能会更直观:
Unmarshal(data []byte) (interface{}, error)
但这永远不可能工作,因为它把实例化结构的责任放在JSON . unmarshal(…)上的话,它就不知道你实际上打算将JSON解析成什么类型的结构。函数签名是解决这个问题的方法。调用者通过实例化一个结构并将其传入来确定他们想要将其解析成哪种结构……但是,因为调用者希望填充结构体字段,并且这些更改在函数调用完成后仍然存在,所以它最好是一个指针。
函数修改接收者
考虑如下代码:
package main
import "fmt"
type S struct {
Name string
}
func (s S) SetName(name string) {
s.Name = name
}
func main() {
s := S{
Name: "foo",
}
s.SetName("bar")
fmt.Println(s.Name)
}
这将产生以下输出:
foo
结果会奇怪吗?
当主程序调用s.SetName(…)时,它将在结构体的副本上调用。即函数接收者(在这种情况下)与函数的其他参数一样,遵循相同的值传递语义。因此,对s.Name的修改作用于副本而不是原始值。
对SetName(…)函数做一个小小的修改,使用一个指针接收器将使程序按预期工作。
func (s *S) SetName(name string) {
s.Name = name
}
这种修改不会导致我们在不需要使用指针的情况有所更改。main函数没有任何改变,编译器会自动识别并使用指针来调用结构体的方法:
func main() {
s := S{
Name: "foo",
}
s.SetName("bar")
fmt.Println(s.Name)
}
在main函数中,我们没有使用到指针,go编译器自动转换来完成。
单例模式
在许多情况下,可能希望创建某种类型的一个实例,并在许多地方使用该实例,并且故意不复制该实例。这样做可能有很多原因。一些很容易想到的情况有:
- 状态组件
- 组件使用有限的或重要的资源
状态组件的一个典型例子:内存中的数据存储。假设系统中的多个组件使用相同的数据存储,但是在不使用指针的同时,Go的传值特点导致多个组件拥有各自不同的数据存储副本。各副本的状态会立即开始分化,会导致意想不到的结果。
组件使用重要资源的一个例子:实现数据库连接(或数据库连接池)的组件。由于程序连接的数据库可能支持有限数量的连接,如果避免使用指针,而Go的传值语义,无意中创建了多个连接对象的副本,可能会导致不必要地消耗有限资源。
这样的情况,我总是传递指针。对于这种重要的组件,我会创建可导出的接口来操作不可导出的结构体,并用指针来引用对象,进行数据的操作。如下所示:
type S struct {
// ...
}
type Cache interface {
Add(key string, val S)
Get(key string) (S, bool)
Clear()
}
type cache struct {
// ...
}
func NewCache() Cache { // <--- This is what the caller sees
return &cache{} // <--- It's a pointer, but they don't need to know
}
func (c *cache) Add(key string, val S) {
// ...
}
func (c *cache) Get(key string) (S, bool) {
// ...
}
func (c *cache) Clear() {
// ...
}
总结
以上就是个人总结的一些指针使用建议,读者可借鉴后根据实际情况来决定何时使用指针。