本篇翻译自《Practical Go Lessons》 Chapter 15: Pointer type
1 你在本章将学到什么?
- 什么是指针?
- 什么时指针类型?
- 如何去创建并使用一个指针类型的变量。
- 指正类型变量的零值是什么?
- 什么是解除引用?
- slices, maps, 和 channels 有什么特殊的地方?
2 涵盖的技术概念
- 指针
- 内存地址
- 指针类型
- 解除引用
- 引用
3 什么是指针?
指针是“是一个数据项,它存储另外一个数据项的位置”。
在程序中,我们不断地存储和检索数据。例如,字符串、数字、复杂结构…。在物理层面,数据存储在内存中的特定地址,而指针存储的就是这些特定内存地址。
记住指针变量,就像其他变量一样,它也有一个内存地址。
4 指针类型
Go 中的指针类型不止一种,每一种普通类型就对应一个指针类型。相应地,指针类型也限定了它自己只能指向对应类型的普通变量(地址)。
指针类型的语法为:
*BaseType
BaseType
指代的是任何普通类型。
我们来看一下例子:
-
*int
表示指向int
类型的指针 -
*uint8
表示指向uint8
类型的指针
type User struct {
ID string
Username string
}
-
*User
表示指向User
类型的指针
5 如何去创建一个指针类型变量?
下面的语法可以创建:
var p *int
这里我们创建了一个类型为 *int
的变量 p
。*int
是指针类型(基础类型是 int
)。
让我们来创建一个名为 answer
的整型变量。
var answer int = 42
现在我们给变量 p
分配一个值了:
p = &answer
使用 &
符号我们就能得到变 answer
的地址。来打印出这个地址~
fmt.Println(p)
// 0xc000012070
0xc000012070
是一个十六进制数字,因为它的以 0x
为前缀。内存地址通常是以十六进制格式表示。你也可以使用二进制(用 0 和 1)表示,但不易读。
6 指针类型的零值
指针类型的零值都是 nil
,也就是说,一个没有存储地址的指针等于 nil
var q *int
fmt.Println(q == nil)
// true
7 解除引用
一个指针变量持有另一个变量的地址。如果你想通过指针去访问地址背后的变量值该怎么办?你可以使用解除引用操作符 *
。
来举个例子,我们定义一个结构体类型 Cart
:
type Cart struct {
ID string
Paid bool
}
然后我们创建一个 Cart
类型的变量 cart
,我们可以得到这个变量的地址,也可以通过地址找到这个变量:
- 使用
*
操作符,你可以通过地址找到变量值 - 使用
&
操作符,你可以得到变量的地址
7.1 空指针解引用:运行时 panic
每个 Go 程序员都会遇到这个 panic(报错):
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x1091507]
为了更好地理解它,我们来复现一下:
package main
import "fmt"
func main() {
var myPointerVar *int
fmt.Println(*myPointerVar)
}
在程序里,我们的定义了一个指针变量 myPointerVar
,这个变量的类型是 *int
(指向整型)。
然后我尝试对它进行解引用,myPointerVar
变量持有一个尚未初始化的指针,因此该指针的值为 nil
。因为我们尝试去寻找一个不存在的地址,程序将会报错!我们尝试找到空地址,而空地址在内存中不存在。
8 Maps 和 channels
Maps 和 channels 变量里保存了对内部结构的指针。因此,即便向一个函数或方法传递的 map 或 channel 不是指针类型,也开始对这个 map 或 channel 进行修改。让我们看一个例子:
func addElement(cities map[string]string) {
cities["France"] = "Paris"
}
- 这个函数将一个 map 作为输入
- 它向 map 中添加一项数据(key = "France", value = "Paris")
package main
import "log"
func main() {
cities := make(map[string]string)
addElement(cities)
log.Println(cities)
}
- 我们初始化一个名为
cities
的 map - 然后调用函数
addElement
- 程序打印出:
map[France:Paris]
我们将在专门的部分中更广泛地介绍 channels 和 maps。
9 切片
9.1 切片定义
切片是相同类型元素的集合。在内部,切片是一个具有三个字段的结构:
- length:长度
- capacity:容量
- pointer:执向内部数组的指针
下面是一个关于切片EUcountries
的例子:
package main
import "log"
func main() {
EUcountries := []string{"Austria", "Belgium", "Bulgaria"}
log.Println(EUcountries)
}
9.2 函数或方法将切片作为参数或接收器:小心
Example1: 向切片添加元素
package main
import "log"
func main() {
EUcountries := []string{"Austria", "Belgium", "Bulgaria"}
addCountries(EUcountries)
log.Println(EUcountries)
}
func addCountries(countries []string) {
countries = append(countries, []string{"Croatia", "Republic of Cyprus", "Czech Republic", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Ireland", "Italy", "Latvia", "Lithuania", "Luxembourg", "Malta", "Netherlands", "Poland", "Portugal", "Romania", "Slovakia", "Slovenia", "Spain", "Sweden"}...)
}
- 函数
addCountries
将一个字符串类型切片作为参数 - 它通过内建函数
append
向切片添加字符串来修改切片 - 它将缺失的欧盟国家附加到切片中
问题:依你看,程序的输出将会是下面的哪个?
[Austria Belgium Bulgaria Croatia Republic of Cyprus Czech Republic Denmark Estonia Finland France Germany Greece Hungary Ireland Italy Latvia Lithuania Luxembourg Malta Netherlands Poland Portugal Romania Slovakia Slovenia Spain Sweden]
[Austria Belgium Bulgaria]
答案:这个函数实际输出:
[Austria Belgium Bulgaria]
9.2.0.2 解释
- 这个函数将
[]string
类型元素作为参数 - 当函数被调用时,Go 会将切片
EUcountries
拷贝一份传进去 - 函数将得到一个拷贝的切片数据:
- 长度
- 容量
- 指向底层数据的指针
- 在函数内部,缺失的国家被添加了进去
- 切片的长度会增加
- 运行时将分配一个新的内部数组
让我们在函数中添加一个日志来可视化它:
func addCountries(countries []string) {
countries = append(countries, []string{"Croatia", "Republic of Cyprus", "Czech Republic", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Ireland", "Italy", "Latvia", "Lithuania", "Luxembourg", "Malta", "Netherlands", "Poland", "Portugal", "Romania", "Slovakia", "Slovenia", "Spain", "Sweden"}...)
log.Println(countries)
}
日志打印出:
[Austria Belgium Bulgaria Croatia Republic of Cyprus Czech Republic Denmark Estonia Finland France Germany Greece Hungary Ireland Italy Latvia Lithuania Luxembourg Malta Netherlands Poland Portugal Romania Slovakia Slovenia Spain Sweden]
- 这里的改变只会影响拷贝的版本
9.2.0.3 Example2:更新元素
package main
import (
"log"
"strings"
)
func main() {
EUcountries := []string{"Austria", "Belgium", "Bulgaria"}
upper(EUcountries)
log.Println(EUcountries)
}
func upper(countries []string) {
for k, _ := range countries {
countries[k] = strings.ToUpper(countries[k])
}
}
- 我们添加新函数
upper
,它将把一个字符串切片的每个元素都转换成大写
问题:依你看,程序将传输下面哪个?
[AUSTRIA BELGIUM BULGARIA]
[Austria Belgium Bulgaria]
答案:这个函数将返回:
[AUSTRIA BELGIUM BULGARIA]
9.2.0.4 解释
- 函数
upper
获取切片 EUcountries 的副本(和上面一样) - 在函数内部,我们更改切片元素的值
countries[k] = strings.ToUpper(countries[k])
- 切片副本仍然有对底层数组的引用
- 我们可以修改!
- .. 但只有已经在切片中的切片元素。
9.2.0.5 结论
- 当你将切片传递给函数时,它会获取切片的副本。
- 这并不意味着你不能修改切片。
- 你只可以修改切片中已经存在的元素。
9.3 9.2 函数或方法将切片指针作为参数或接收器
如果使用切片指针,你就可以在函数中修改这个切片了:
package main
import (
"log"
)
func main() {
EUcountries := []string{"Austria", "Belgium", "Bulgaria"}
addCountries2(&EUcountries)
log.Println(EUcountries)
}
func addCountries2(countriesPtr *[]string) {
*countriesPtr = append(*countriesPtr, []string{"Croatia", "Republic of Cyprus", "Czech Republic", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Ireland", "Italy", "Latvia", "Lithuania", "Luxembourg", "Malta", "Netherlands", "Poland", "Portugal", "Romania", "Slovakia", "Slovenia", "Spain", "Sweden"}...)
}
这个程序将输出:
[Austria Belgium Bulgaria Croatia Republic of Cyprus Czech Republic Denmark Estonia Finland France Germany Greece Hungary Ireland Italy Latvia Lithuania Luxembourg Malta Netherlands Poland Portugal Romania Slovakia Slovenia Spain Sweden]
- 函数
addCountries2
将字符串切片的指针([]string
)作为参数 - 函数
append
调用时的第一个参数是*countriesPtr
(即我们通过指针countriesPtr
去找到原值) -
append
的第二个参数没有改变 - 函数
addCountries2
的结果会影响到外部的变量
10 指向结构体的指针
有一个快捷方式可以让你直接修改 struct 类型的变量而无需使用*
运算符:
type Item struct {
SKU string
Quantity int
}
type Cart struct {
ID string
CreatedDate time.Time
Items Item
}
cart := Cart{
ID: "115552221",
CreatedDate: time.Now(),
}
cartPtr := &cart
cartPtr.Items = []Item{
{SKU: "154550", Quantity: 12},
{SKU: "DTY8755", Quantity: 1},
}
log.Println(cart.Items)
// [{154550 12} {DTY8755 1}]
-
cart
是一个Cart
类型变量 -
cartPtr := &cart
会获取变量 cart 的地址然后将其存储到cartPtr
中 - 使用变量
cartPtr
,我们可以直接修改变量cart
的Item
字段 - 这是因为运行时自动通过结构体指针找到了原值进行了修改,以下是等价的写法
(*carPtr).Items = []Item{
{SKU: "154550", Quantity: 12},
{SKU: "DTY8755", Quantity: 1},
}
(这也有效,但更冗长)
11 使用指针作为方法的接收器
指针通常用作方法的接收器,让我们以 Cat
类型为例:
type Cat struct {
Color string
Age uint8
Name string
}
你可以定义一个方法,使用指向 Cat
的指针作为方法的接收器(*Cat
):
func (cat *Cat) Meow(){
fmt.Println("Meooooow")
}
Meow
方法没有做任何有实际意义的事吗;它只是打印了字符串"Meooooow"
。我们没有修改比变量的值。我们来看另一个方法,它修改了 cat 的 Name
:
func (cat *Cat) Rename(newName string){
cat.Name = newName
}
此方法将更改猫的名称。通过指针,我们修改了 Cat 结构体的一个字段。
当然,如果你不想使用指针作为接收器,你也可以:
func (cat Cat) RenameV2(newName string){
cat.Name = newName
}
在这个例子中,变量 cat
是一个副本。接收器被命名为“值接收器”。因此,你对 cat 变量所做的任何修改都将在 cat 副本上完成:
package main
import "fmt"
type Cat struct {
Color string
Age uint8
Name string
}
func (cat *Cat) Meow() {
fmt.Println("Meooooow")
}
func (cat *Cat) Rename(newName string) {
cat.Name = newName
}
func (cat Cat) RenameV2(newName string) {
cat.Name = newName
}
func main() {
cat := Cat{Color: "blue", Age: 8, Name: "Milow"}
cat.Rename("Bob")
fmt.Println(cat.Name)
// Bob
cat.RenameV2("Ben")
fmt.Println(cat.Name)
// Bob
}
在主函数的第一行,我们创建了一个 Cat
类型的变量 cat,它的 Name 是 "Millow"
。
当我们调用具有值接收器的 RenameV2
方法时,函数外部变量 cat 的 Name 没有发生改变。
当我们调用 Rename
方法时,cat 的 Name 字段值会发生变化。
11.1 何时使用指针接收器,何时使用值接收器
- 以下情况使用指针接收器:
- 你的结构体很大(如果使用值接收器,Go 会复制它)
- 你想修改接收器(例如,你想更改结构变量的名称字段)
- 你的结构包含一个同步原语(如sync.Mutex)字段。如果你使用值接收器,它还会复制互斥锁,使其无用并导致同步错误。
- 当接收器是一个 map、func、chan、slice、string 或 interface值时(因为在内部它已经是一个指针)
- 当你的接收器是持有指针时
12 随堂测试
12.1 问题
- 如何去表示一个持有指向
Product
指针的变量? - 指针类型的零值是多少?
- "解引用(dereferencing)" 是什么意思?
- 如何解引用一个指针?
- 填空: ____ 在内部是一个指向 ____ 的指针。
- 判断正误:当我想函数中修改 map 时,我的函数需要接收一个指向 map 的指针作为参数,我还需要返回修改后的 map?
12.2 答案
- 如何去表示一个持有指向
Product
指针的变量?*Product
- 指针类型的零值是多少?
nil - "解引用(dereferencing)" 是什么意思?
- 指针是指向存储数据的内存位置的地址。
- 当我们解引用一个指针时,我们可以访问存储在该地址的内存中的数据。
- 如何解引用一个指针?
使用解引用操作符*
- 填空: ____ 在内部是一个指向 ____ 的指针。
slice 在内部是一个指向 array 的指针。 - 判断正误:当我想函数中修改 map 时,我的函数需要接收一个指向 map 的指针作为参数,我还需要返回修改后的 map
错, 函数中只要接收一个 map 类型参数就行,也不需要返回更改后的map,因为 map 变量内部存储了指向底层数据的指针
关键要点
- 指针是指向数据的地址
- 类型
*T
表示所有指向T
类型变量的指针集合 - 创建指针变量,可以使用运算符
&
。它将获取一个变量的地址
userId := 12546584
p := &userId
`userId` 是 `int` 类型的变量
`p` 是 `*int` 类型变量
`*int` 表示所有指向 `int` 类型变量的指针
- 具有指针类型的参数/接收器的函数可以修改指针指向的值。
- map 和 channel 是“引用类型”
- 接收 map 或 channel 的函数/方法可以修改内部存储在这两个数据结构中的值(无需传递指向 map 的指针或指向 channel 的指针)
- 切片在内部保存对数组的引用;任何接收切片的函数/方法都可以修改切片元素。
- 当你想在函数中修改切片长度和容量时,你应该向该函数传递一个指向切片的指针 (
*[]string
) - 解引用允许你访问和修改存储在指针地址处的值。
- 要对指针进行解引用操作,请使用运算符
*
userId := 12546584
p := &userId
*p = 4
log.Println(userId)
p
是一个指针
- 我们使用
*p
来对指针p
进行解引用- 我们用指令
*p = 4
修改userId
的值- 在代码片段的末尾,userId 的值为 4(不再是 12546584)
- 当你有一个指向结构的指针时,你可以直接使用你的指针变量访问一个字段(不需要使用解引用运算符)
- 例子:
type Cart struct {
ID string
}
var cart Cart
cartPtr := &cart
- 不需要这样写:
(*cartPtr).ID = "1234"
- 你可直接这样写:
cartPtr.Items = "1234"
- 变量
cart
就会被修改