如何在Go中使用指针

1,651 阅读8分钟

近年来,Go的受欢迎程度呈爆炸式增长。2020年HackerEarth开发者调查发现,Go是经验丰富的开发者和学生中最受欢迎的编程语言。2021年Stack Overflow开发者调查报告了类似的结果,Go是开发者希望使用的前四种语言之一。

鉴于它的受欢迎程度,网络开发者掌握Go是很重要的,而Go最关键的组成部分之一也许是它的指针。本文将解释创建指针的不同方式以及指针解决的问题类型。

什么是Go?

Go是一种静态类型的编译语言,由Google制作。Go之所以成为构建健壮、可靠和高效软件的热门选择,有很多原因。其中最大的吸引力是 Go 编写软件的方法简单扼要,这一点在语言中指针的实现上很明显。

在 Go 中传递参数

在用任何语言编写软件时,开发人员必须考虑哪些代码会在他们的代码库中发生变异。

当你开始组成函数和方法,并在代码中传递所有不同类型的数据结构时,你需要注意哪些应该以值传递,哪些应该以引用传递。

以值传递参数就像传递一个打印出来的东西的副本。如果副本的持有者在上面乱涂乱画或者毁坏了它,你所拥有的原始副本就不会有任何变化。

通过引用传递就像与别人分享一份原始副本。如果他们改变了什么,你可以看到--并且必须处理--他们所做的改变。

让我们从一段非常基本的代码开始,看看你是否能发现为什么它可能没有做我们所期望的事情。

package main

import (
  "fmt"
)

func main() {
  number := 0
  add10(number)
  fmt.Println(number) // Logs 0
}

func add10(number int) {
  number = number + 10 
}

在上面的例子中,我试图让add10() 函数增加number 10 ,但它似乎并没有工作。它只是返回0 。这正是指针所要解决的问题。

在Go中使用指针

如果我们想让第一个代码片断工作,我们可以利用指针。

在Go中,每个函数参数都是通过值传递的,这意味着值被复制和传递,通过改变函数主体中的参数值,底层变量没有任何变化。

这一规则的唯一例外是切片和地图。它们可以通过值传递,由于它们是引用类型,对它们传递的地方进行的任何改变都会改变底层变量。

向其他语言认为是 "通过引用 "的函数传递参数的方法是利用指针。

让我们来修正我们的第一个例子,并解释发生了什么。

package main

import (
  "fmt"
)

func main() {
  number := 0
  add10(&number)
  fmt.Println(number) // 10! Aha! It worked!
}

func add10(number *int) {
  *number = *number + 10 
}

寻址指针的语法

第一个代码片断和第二个代码片断的唯一主要区别是使用了*& 。这两个操作符执行的操作被称为取消引用/定向(*) 和引用/内存地址检索(&)。

引用和内存地址检索使用&

如果你跟着代码片段从main 函数开始,我们改变的第一个运算符是在我们传入add10 函数的number 参数前面使用安培号&

这可以得到我们在CPU中存储变量的内存地址。如果你在第一个代码片断中添加一个日志,你会看到一个用十六进制表示的内存地址。它看起来会像这样:0xc000018030 (每次记录时都会改变)。

这个略显神秘的字符串本质上是指向CPU上存储变量的地址。这就是Go共享变量引用的方式,因此可以让所有可以访问指针或内存地址的其他地方看到变化。

解除对内存的引用*

如果我们现在只有一个内存地址,把10 加到0xc000018030 可能不是我们需要的。这就是解引用内存的用处。

我们可以使用指针,将内存地址递延到它所指向的变量中,然后进行计算。我们可以在上面第14行的代码片段中看到这一点。

*number = *number + 10 

在这里,我们将我们的内存地址解除引用到0 ,然后将10

现在,这个代码例子应该像最初预期的那样工作。我们共享一个单一的变量,而不是通过复制值来反映变化。

在我们创建的心理模型上有一些扩展,对进一步理解指针会有帮助。

在Go中使用nil 指针

Go中的所有东西在第一次初始化时都会被赋予一个0 的值。

例如,当你创建一个字符串时,它默认为一个空字符串(""),除非你给它分配了什么。

下面是所有的零值

  • 0 适用于所有的int类型
  • 0.0 适用于float32, float64, complex64, 和 complex128
  • false 适用于bool
  • "" 适用于字符串
  • nil ,用于接口、片断、通道、地图、指针和函数

这对指针来说是一样的。如果你创建了一个指针,但没有把它指向任何内存地址,它将是nil

package main

import (
  "fmt"
)

func main() {
  var pointer *string
  fmt.Println(pointer) // <nil>
}

使用和解除对指针的引用

package main

import (
  "fmt"
)

func main() {
  var ageOfSon = 10
  var levelInGame = &ageOfSon
  var decade = &levelInGame

  ageOfSon = 11
  fmt.Println(ageOfSon)
  fmt.Println(*levelInGame)
  fmt.Println(**decade)
}

你可以看到在这里,我们试图在代码中的许多地方重复使用ageOfSon 这个变量,所以我们就可以一直把东西指向其他指针。

但是在第15行,我们必须先解除对一个指针的引用,然后再解除对它所指向的下一个指针的引用。

这是在利用我们已经知道的操作符,* ,但它也是在链动下一个要被解除引用的指针。

这可能看起来很混乱,但是当你看其他的指针实现时,你已经看到了这种** 语法,这将会有所帮助。

用另一种指针语法创建 Go 指针

创建指针最常见的方法是使用我们前面讨论的语法。但是也有另一种语法,你可以使用 new() 函数来创建指针

让我们来看看一个例子的代码片段

package main

import (
  "fmt"
)

func main() {
  pointer := new(int) // This will initialize the int to its zero value of 0
  fmt.Println(pointer) // Aha! It's a pointer to: 0xc000018030
  fmt.Println(*pointer) // Or, if we dereference: 0
}

语法只是略有不同,但我们已经讨论过的所有原则都是一样的。

常见的围棋指针错误概念

回顾我们所学到的一切,在使用指针时有一些经常重复的误解,讨论这些误解是很有用的。

每当讨论指针时,有一句话经常被重复,那就是指针的性能更强,从直觉上看,这是有道理的。

比如说,如果你把一个大的结构体传递给多个不同的函数调用,你可以看到把这个结构体多次复制到不同的函数中可能会减慢程序的性能。

但是在Go中传递指针往往比传递复制的值要

这是因为当指针被传递到函数中时,Go需要进行转义分析,以确定该值是否需要存储在堆栈或堆中。

按值传递允许所有的变量存储在栈上,这意味着可以跳过该变量的垃圾收集。

请看这里的例子程序。

func main() {
  a := make([]*int, 1e9)

  for i := 0; i < 10; i++ {
    start := time.Now()
    runtime.GC()
    fmt.Printf("GC took %s\n", time.Since(start))
  }

  runtime.KeepAlive(a)
}

当分配10亿个指针时,垃圾收集器可能需要超过半秒。这还不到每个指针的一纳秒。但是,它可能会增加,特别是当指针在一个具有强烈内存需求的巨大代码库中被如此大量地使用时。

如果你使用上述相同的代码而不使用指针,垃圾收集器的运行速度可以快1000多倍。

请测试你的用例的性能,因为没有硬性规定。只要记住 "指针总是更快 "这句口头禅,并不是在所有情况下都是如此。

结语

我希望这是个有用的总结。其中,我们介绍了什么是Go指针,创建指针的不同方法,它们解决了什么问题,以及在其使用情况下需要注意的一些问题。

当我第一次了解指针时,我在GitHub上阅读了大量写得很好的大型代码库(例如Docker),试图了解何时和何时不使用指针,我鼓励你也这样做。

这非常有助于巩固我的知识,并以实践的方式理解团队采取的不同方法,以充分发挥指针的潜力。

有许多问题需要考虑,例如。

  • 我们的性能测试表明了什么?
  • 在更广泛的代码库中的整体惯例是什么?
  • 这对这个特定的用例有意义吗?
  • 是否可以简单地阅读和理解这里发生的事情?

决定何时和如何使用指针是在个案的基础上进行的,我希望你现在对何时在你的项目中最好地利用指针有了全面的了解。

The postHow to use pointers in Goappeared first onLogRocket Blog.