Fork me on GitHub

Go语言开发-集合类型-值、指针和引用类型

4.1.值、指针和引用类型

本节我们将讨论变量保存的是什么(值、指针和引用,包括指向数组、切片和映射的引用),并在接下来的小结中介绍如何使用数组、切片和映射。

通常情况下,Go语言的变量保存的是相应的值,也就是说,我们可以认为变量可以作为其所保存的值,但不包括通道、函数、方法、映射和切片引用,因为它们保存的是引用,也就是说变量保存的是指针。

传递给函数或方法的值会被复制,这对于布尔值或数值来说是廉价的,因为它们只占用1至8个字节。通过值传递字符串也是非常廉价的,因为Go语言编译器在传递它们的时候可以对其安全的优化,所以在传递字符串的时候实际上只传递了少量的数据,而不论实际的字符串占用的字节有多少,这是因为Go语言的字符串是不可变的。(在64位机器上,每个字符串实际传递的是16字节;在32位机器上是8字节。)当然,如果修改了传入的字符串(例如,使用了 + = 运算符),Go语言必须在后台对所传入的字符串做一个拷贝,这对于长度很长的字符串来说代价可能非常大,但是这种代价对于其它语言来说也一样。

与 C 或 C++不同的是,Go语言的数组是通过值传递的,所以传递大数组的代价是非常大的。幸运的是,数组很少被用到,因为我们可以使用切片来代替,我们将在下一节介绍切片。传递一个切片的代价和传递传递一个字符串的代价一样(即在64位机器上是16 个字节,在 32 位机器上的 12 个字节),而无论切片的长度或容量。如果切片被修改,也没有在传递时拷贝的开销,因为切片和字符串不一样,切片是可变的。(即,如果一个切片被修改,这对于其它所有指向该切片的变量引用都是可见的。)

图 4.1 阐明了变量和它们所占用的内存之间的关系。在图中,内存地址以灰色显示,因为它们会发生变化,粗体表示变化。

qwertyuio图4.1内存中的值及其地址

从概念上讲,变量是保存有特定类型的值的一块内存的名称。因此,如果我们声明一个变量y:= 1.5,Go语言就会分配一块足够大的能够存储一个float64类型(8个字节)的内存并将值1.5保存到该内存块中。从此时起,只要y还在作用域中,Go语言就会认为变量y等同于这个保存有float64类型的y的内存块,所以如果我们接着添加一条y++语句,Go语言就会将变量y对应的内存块中的值做加法操作。然而,如果我们将y传递给一个函数或方法,Go语言传递的是y的拷贝;换言之,Go语言会创建一个与被调用函数或方法的参数名相关联的新的变量并将y值的复制到为该新变量分配的内存块中。

有时我们需要函数来修改我们传入的变量,对于引用类型,我们可以很容易的做到这一点,但是值类型是拷贝的,所以任何修改只作用于其副本而其原始值保持不变。此外,传值的成本可能非常高,因为它们可能非常大(例如,一个数组或一个拥有很多字段的结构体)。再者,本地变量在不再使用时(例如,当它们不再被引用或不在作用域范围时)会被垃圾回收,然而在许多情况下我们创建变量后希望由自己来管理该变量的生命周期而不是由它们的作用域决定。

如果我们希望传递参数成本低廉,参数值可被修改,变量的生命周期独立于其作用域,这些都可以通过使用指针来实现。指针是保存了另一个变量的内存地址的变量,我们创建指针用于指向特定类型的变量,这样可以确保Go语言能够知道所指向的值占用多少内存空间(即,多少字节)。接下来我们将会看到通过指针可以修改指针所指向的变量。传递指针的成本比较低廉(在64位机器上是8字节,在32位机器上是4字节),而无论它们指向的值的大小,同时,对于内存中的变量,只要至少有一个指针指向它,该变量便会一直呆在内存中,所以它们的生命周期独立于它们的作用域。

Go语言中的 & 操作符有多种用处,当用作二元操作符时,它执行按位与操作;当用作一元操作符时,它返回操作数的内存地址,即由指针存储的内存地址。对于图 4.2中的第三个语句,我们将int 类型的变量 x 的地址赋值给*int类型的变量pi。一元&操作符有时也被称为取址操作符。如图4.2中的箭头所示,术语指针指的是保存另一个变量的内存地址的变量被认为是 “指向”了另一个变量。

*操作符也有多重用处,当用作二元操作符时,它将操作数相乘;当用作一元操作符时,它返回其所作用的变量指向的值。所以,在图 4.2 中,*pi 和 x 可以在pi := &x语句后(但必须在pi被赋值为指向其它的变量之前)互换使用。因为它们都关联到同一块内存,所以对其中一个的任何更改都会影响另一个。一元*操作符有时也被称为内容操作符、间接寻址操作符或解引用操作符。

asdfghjkl

图4.2 指针和值

图4.2也阐述了如果我们修改了指针指向的值(如,使用x++),则其值确实如我们期望的那样被修改,并且当我们解引用指针(*pi)时,它返回修改后的值。我们还可以通过指针修改其值。例如,*pi++意味将指针指向的值做加法;当然,这只有在该值的类型支持++操作符时才会通过编译,正如Go语言内置的数值一样。

一个指针并不必始终指向同一个值。例如,在图4.2的下面部分我们将指针设置为指向一个不同的值(pi := &y),然后通过指针修改y的值。我们可以很容易的直接(如,使用y++)修改y的值,然后使用*pi返回修改后的y值。

指针也可以指向其它指针(或指向指针的指针的指针,等等)。使用指针来指向一个值被称为间接引用,而使用指向指针的指针被称为多重间接引用。这在C和C++中十分常见,但是由于Go语言对引用类型的使用,所以我们无需过多使用间接引用。下面是一个非常简单的例子。

在上面的代码中,pi是一个指向int类型的变量z的*int类型的指针,ppi是一个指向pi的**int类型的指针。对于每一层的间接引用,我们都使用*操作符将其解引用,所以*ppi解引用ppi产生一个*int类型的指针,即一个内存地址,接着第二次使用*操作符(**ppi),我们就得到了指向int的变量值。

除了作为乘法和解引用操作符,*操作符也可做其它用途——作为类型修改符。当一个*操作符位于类型名的左边时,它会改变该类型名的意义,即由给定类型的值修改为给定类型值的指针,这可由图4.2的“Type”列看出。

让我们用一个简短的例子来解释下目前为止所讨论的内容。

这里我们创建了3个int类型的变量并将它们初始化,然后我们调用了自定义的swapAndProduct1()函数,该函数接收3个int类型的指针并确保前两个整数以升序排列且让第三个指针的值为前两个指针的乘积。因为该函数接受的是指针而不是值类型的参数,所以我们必须传入int类型值的地址而不是它们本身。每当我们看到&取址操作符被用于函数调用时,我们应该认为相应的变量值可能在函数内被修改。下面是swapAndProduct1()函数。

该函数的参数声明*int使用*类型修改符来指定所有参数都是指向整型数的指针。当然,这意味着我们只能传入整型变量的地址(使用&取址操作符),而不是整型变量本身或整型数值常量。

在函数内部,我们关心的是指针指向的值,所以我们一直都在使用*解引用操作符。在函数最后,我们将两个指针所指向的值相乘,并把结果赋值给另一个指针所指向的值。当有两个连续的*出现时,Go语言可以根据上下文将其识别为乘法和一个解引用而不是两个解引用。在函数中,指针是x、y和product,但是在函数调用处,它们所指向的值是整型变量i、j和p。

在C和早期的C++代码中以这种方式编写函数是十分常见的,但是在Go语言中却无需这样。在Go语言中,如果我们只有一个或几个值,更地道的做法是直接返回它们,而如果我们要传递大量的值,常见的做法是使用切片或映射(我们很快就会看到,不使用指针也可以廉价地传递参数),如果它们具有不同的类型,我们就可以使用指针指向的结构体来作为参数。下面是一个不使用指针传递参数的例子。

下面是对应的swapAndProduct2()函数。

这个函数比前一个函数条理清晰;但是却没有使用指针,不便之处便是不能就地交换数据。

在C和C++中,函数接受一个指向布尔类型的指针作为参数来表示成功或失败的做法是十分常见的。这在Go语言中可以通过在函数签名中使用*bool就可以很容易的做到;但是更方便的做法是直接以最后一个(或唯一一个)返回值的形式返回一个布尔型的成功标志(或最起码是一个error值),这是Go语言的标准做法。

到目前为止所示的代码片段中,我们使用&取址操作符来获得函数参数或本地变量的地址。得益于Go语言的自动内存管理,这样做始终是非常安全的,因为只要有一个指针指向变量,则该变量就会一直保留在内存中。这也是为什么返回指向由函数内部创建的本地变量的指针是安全的(对于C和C++中的非静态变量,这样操作将会发生灾难性的错误)。

在那些我们需要传递非引用类型的可修改的值或需要高效的传递大类型的值的情况下,我们就需要使用指针。Go语言提供了两种用于创建变量的语法并同时获得指向它们的指针,一种是使用内置的new()函数,另一种是使用取址操作符。作为对比,我们会介绍这两种语法并会分别使用它们创建一个自定义结构体的值。

根据此结构定义,我们可以创建composer值或指向composer值的指针,即* composer类型的变量。在两种情况下我们都可以利用Go语言对结构体初始化的支持使用大括号进行初始化。

当Go语言打印指向结构体的指针时,它会打印解引用后的的结构体,但会使用前缀&取址操作符来表示它是一个指针。上面的代码中创建的agnes 和Julia指针说明了只要其类型是可以使用大括号来初始化,那么它们就是等效的。

这两种语法都分配一个Type类型的零值并返回一个指向该值的指针。如果Type不是一个可以使用大括号初始化的类型,那么我们就只能使用内置的new()函数进行初始化。当然,我们不需要担心该值的生命周期或究竟何时删除它们,因为Go语言的内存管理系统会帮我们处理这些。

对于结构体,使用&Type{}语法的一个好处是我们可以指定初始字段值,就像我们创建augusta指针所做的那样。(稍后我们将看到,我们甚至可以只为选定的字段指定其初始值,而将其它字段初始为其零值。)

除了值和指针,Go语言也有引用类型。(Go语言也有接口类型,但是以实用而言,我们可以认为接口是一种引用类型)引用类型的变量指向内存中保存实际数据的一个隐藏的值。传递(在64位机器上,对于切片是16字节,对于映射是8字节)保存有引用类型的变量是廉价的,且其和值有相同的使用语法(即,我们不需要得到引用类型的地址或将其解引用就可以访问到其指向的值。)

一旦我们需要从一个函数或方法中返回多于四个或五个值,如果这些值是同一类型的,最好是使用一个切片来传递,而如果这些值不是同一类型的,则最好是使用指向结构体的指针来传递。传递一个切片或一个指向结构体的指针是比较廉价的,同时也允许我们就地修改数据。接下来我们将通过一些简单的例子来说明这点。

这里我们对一个int型切片中的所有整数执行一些操作,映射和切片都是引用类型,对它们直接的或在函数内部的任何更改,对于引用它们的所有变量都是可见的。

这里将grades切片作为numbers参数传入函数中,但与传值不同的是,对numbers的任何更改都会反应到grades切片中,因为它们都指向同一个的底层切片。

因为我们想要就地修改切片的值,所以我们使用了一个循环计数器依次访问切片中的每个项。我们并没有使用for index,item … range循环,因为它操作的是切片的副本,这样将导致每次副本与因数相乘之后丢弃该值,而原始切片则保持不变。我们本可以使用for循环(如,for i:=0;i<len(numbers);i++)),这在其它语言中非常常见,但是这里我们使用了更方便的for index:=range语法。(for循环语法将在下章讲解)

现在我们假设有一个矩形类型,保存的是左上角和右下角的x,y坐标和填充的颜色。我们可以使用结构体来表示该矩形的数据。

现在我们创建一个rectangle类型的值,将其打印出来,调整大小,再次打印。

我们在前面章节提到过,即使Go语言对我们自定义的rectangle类型一无所知,但它仍然能以合适的格式将其打印出来。代码下面的输出清楚地显示出自定义的resizeRect()函数能够正确的工作。我们传递的并不是整个rectangle(整型至少占16字节),而是仅传递其地址(在64位系统中都是8字节,而无论结构体有多大)。

为了演示底层发生的操作,该函数中第一个语句使用了显式解引用。(*rect)引用的是该指针指向的实际rectangle值,. x1引用的是rectangle的x1字段。第二个语句展示了处理结构体值或结构体指针的常用方法,而对于结构体指针,我们需要依靠Go语言来为我们解引用,之所以这样是因为Go语言的.(点)选择器操作符能够自动地将指针解引用为所指向的结构体。

在Go语言中有些类型是引用类型:映射、切片、通道、函数和方法。不同于指针是,引用类型没有特殊的语法,因为它们的使用方式就像值一样。指针也可以指向引用类型,虽然这只对切片有用,但有时却是极其重要的(我们将在下一章介绍指向切片的指针)。

如果我们声明一个变量来保存一个函数,该变量实际上得到的是函数的引用。函数引用知道它们所引用的函数的签名,所以不能传递一个签名不正确的函数引用,这样就可以消除那些可以传递指针但却不保证其签名正确的语言中可能发生的令人讨厌的错误和崩溃。我们己经介绍了几个传递函数引用的例子,例如,我们向strings.Map()函数传递一个映射函数。我们会在本书的剩余部分介绍更多的使用指针和引用类型的例子。


目录


作者:Johnson
原创文章,版权所有,转载请保留原文链接。

发表评论

电子邮件地址不会被公开。 必填项已用*标注