Golang 才是学习指针的安全之地,关于指针的二三事
本章学习目标
- 学会声明和使用指针
- 理解指针和随机访问存储器(RAM)之间的关系
- 了解指针的使用时机
当我们在街上散步的时候,常常会看到一些用于指引方位的地址和街道标识。你可能曾经遇到过这样一种情况,一家大门紧闭的商店在它的橱窗上贴出了道歉标语“抱歉,本店已乔迁新址!”,并在标语的下方给出新的地址。指针就有点儿像这个给出新地址的标语,它会把你指引至不同的地址。
指针是指向另一变量的地址的变量。在计算机科学中,指针是间接访问的一种形式,它是一种强有力的工具。
计算机科学的所有问题都可以通过另一层级的间接访问来解决。
——David Wheeler
指针虽然有用,但多年以来它们也引起了不少麻烦。以C语言为典型的旧式编程语言通常并不强调安全性,而许多崩溃事件和安全漏洞又都与滥用指针有着千丝万缕的关系,这最终导致了一些语言选择不将指针暴露给程序员。
Go语言确实提供了指针,但同时也强调内存安全,它不会受到诸如迷途指针(也称野指针)等问题的困扰。这就好比你在根据新地址前往自己喜欢的商店时,不会莫名其妙地到了电影院的停车场一样。
如果你以前就了解过指针,那么请不要担心,因为Go语言的指针并没有你想象中的那么糟糕。但如果这是你第一次接触指针,那么也请不要紧张,因为Go语言是学习指针的安全之地。
请考虑这一点
就跟商店通过标识将客人指引至新地址一样,指针也会指引计算机应该在何处找到指定的值。除此之外,你还遇到过这种被指引至其他地方的场景吗?
26.1 &和*
Go的指针采用了历史悠久并且广为人知的C语言指针语法。在这种语法中,我们需要特别关注&(与符号)和*(星号),并且正如后续内容所介绍的那样,星号具有两种用途。
变量会将它们的值存储在计算机的随机访问存储器里面,而值的存储位置则是该变量的内存地址。通过使用&表示的地址操作符,我们可以得到指定变量的内存地址。例如,在代码清单26-1中,我们就以十六进制数的形式打印出了变量answer的内存地址,尽管这个地址在你的计算机中可能会有所不同。
代码清单26-1 地址操作符:memory.go
answer := 42 fmt.Println(&answer) ←--- 打印出“0x1040c108”
程序打印出的数字就是计算机在内存中存储42的位置。幸运的是,我们只需要通过变量名answer就可以检索到这个值,而不必像计算机那样通过内存地址进行检索。
注意 地址操作符无法取得字符串字面量、数字字面量和布尔值字面量的地址,诸如&42和&"anotherlevel of indirection"这样的语句将导致Go编译器报错。
地址操作符(&)提供值的内存地址,而它的反向操作解引用则提供内存地址指向的值。作为例子,代码清单26-2就通过在变量address的前面放置星号(*)来对其进行解引用。
代码清单26-2 解引用操作符:memory.go
answer := 42 fmt.Println(&answer) ←--- 打印出“0x1040c108” address := &answer fmt.Println(*address) ←--- 打印出“42”
在代码清单26-2和图26-1中,address变量虽然没有直接持有answer变量的值42,但因为它持有answer变量的内存地址,所以知道在哪里能找到这个值。
图26-1 address指向answer
注意 C语言中的内存地址可以通过诸如address++这样的指针运算进行操作,但Go语言不允许这种不安全的操作。
速查26-1
1.在代码清单26-2中执行fmt.Println(*&answer)将打印出什么结果?
2.乘法运算和解引用都需要用到星号(*),Go编译器是如何区分这两种操作的?
指针类型
指针存储的是内存地址。
代码清单26-2定义的address变量实际上就是一个*int类型的指针,代码清单26-3使用格式化变量%T打印了它。
代码清单26-3 指针类型:type.go
answer := 42 address := &answer fmt.Printf("address is a %T\n", address) ←--- 打印出“address is a int”
*int中的星号表示这是一种指针类型。在这个例子中,它可以指向类型为int的其他变量。
指针类型可以跟其他普通类型一样,出现在所有需要用到类型的地方,如变量声明、函数形参、返回值类型、结构字段类型等。作为例子,代码清单26-4声明了一个指针类型的home变量。
代码清单26-4 声明指针:home.go
canada := "Canada" var home *string fmt.Printf("home is a %T\n", home) ←--- 打印出“home is a string” home = &canada fmt.Println(*home) ←--- 打印出“Canada”
提示 将星号放在类型前面表示要声明指针类型,而将星号放在变量前面则表示解引用变量指向的值。
代码清单26-4中的home变量可以指向类型为string的任何变量,但与此同时,Go编译器不会允许home指向除string类型之外的其他类型,如int类型。
注意 C语言的类型系统可以轻而易举地使用同一个内存地址存储不同的类型。虽然这种做法在某些情况下可能会有用,但跟之前提到过的一样,Go会避免这种潜在的不安全操作。
速查26-2
1.你会使用什么代码来声明一个指向整数的名为address的变量?
2.你是如何区分代码清单26-4中声明指针变量和解引用指针这两个操作的?
26.2 指针的作用就是指向
Charles Bolden于2009年7月17日成为美国国家航空航天局(NASA)局长,该职位的前任为Christopher Scolese。通过使用指针表示局长一职,代码清单26-5可以将administrator指向任何当前正在供职的人,如图26-2所示。
代码清单26-5 美国国家航空航天局局长:nasa.go
var administrator *string scolese := "Christopher J. Scolese" administrator = &scolese fmt.Println(*administrator) ←--- 打印出“Christopher J. Scolese” bolden := "Charles F. Bolden" administrator = &bolden fmt.Println(*administrator) ←--- 打印出“Charles F. Bolden”
图26-2 administrator指向bolden
因为局长指针指向的是bolden变量,而不是存储该变量的副本,所以针对bolden变量的修改在同一个地方生效:
bolden = "Charles Frank Bolden Jr." fmt.Println(*administrator) ←--- 打印出“Charles Frank Bolden Jr. ”
通过解引用administrator来间接改变bolden的值也是可以的:
*administrator = "Maj. Gen. Charles Frank Bolden Jr." fmt.Println(bolden) ←--- 打印出“Maj. Gen. Charles Frank Bolden Jr. ”
把administrator赋值给major将产生一个同样指向bolden的字符串指针,如图26-3所示:
major := administrator *major = "Major General Charles Frank Bolden Jr." fmt.Println(bolden) ←--- 打印出“Major General Charles Frank Bolden Jr. ”
图26-3 administrator和major现在都指向bolden
因为administrator和major两个指针现在都持有相同的内存地址,所以它们是相等的:
fmt.Println(administrator == major) ←--- 打印出“true”
Charles Bolden的后任Robert M. Lightfoot Jr.于2017年1月20日开始任职。如图26-4所示,在发生这一变化之后,administrator和major将不再指向同一内存地址:
lightfoot := "Robert M. Lightfoot Jr." administrator = &lightfoot fmt.Println(administrator == major) ←--- 打印出“false”
图26-4 administrator现在指向lightfoot
把解引用major的结果赋值给另一个变量将产生一个字符串副本。在克隆完成之后,直接或间接修改bolden将不会影响charles的值,反之亦然:
charles := *major *major = "Charles Bolden" fmt.Println(charles) ←--- 打印出“Major General Charles Frank Bolden Jr. ” fmt.Println(bolden) ←--- 打印出“Charles Bolden”
正如接下来这段代码中的charles和bolden所示,即使两个变量持有不同的内存地址,但只要它们包含相同的字符串,它们就是相等的:
charles = "Charles Bolden" fmt.Println(charles == bolden) ←--- 打印出“true” fmt.Println(&charles == &bolden) ←--- 打印出“false”
在本节,我们通过解引用administrator指针和major指针来间接修改bolden的值,借此展示指针的作用,但实际上这些修改也可以通过直接赋值给bolden来完成。
速查26-3
1.代码清单26-5中使用指针的好处是什么?
2.请说明语句major := administrator和charles := *major的作用。
26.2.1 指向结构的指针
因为指针经常会跟结构一同使用,所以Go语言的设计者为指向结构的指针提供了少量人体工程学设施。
与字符串和数字不一样,在复合字面量的前面可以放置地址操作符。例如,在代码清单26-6里面,timmy变量持有指向person结构的内存地址。
代码清单26-6 person结构:struct.go
type person struct { name, superpower string age int } timmy := &person{ name: "Timothy", age: 10, }
此外,在访问字段时对结构进行解引用并不是必需的。例如,代码清单26-7中的做法就比写下(*timmy).superpower更可取。
代码清单26-7 复合字面量:struct.go
timmy.superpower = "flying" fmt.Printf("%+v\n", timmy) ←--- 打印出“&{name:Timothy superpower:flying age:10}”
速查26-4
1.以下哪些是地址操作符的合法使用方式?
a.放置在字符串字面量的前面,如&"Timothy"
b.放置在整数字面量的前面,如&10
c.放置在复合字面量的前面,如&person{name: "Timothy"}
d.以上全部都是
2.语句timmy.superpower和(*timmy).superpower有何区别?
26.2.2 指向数组的指针
跟结构的情况一样,我们也可以通过将地址操作符(&)放置在数组复合字面量的前面来创建指向数组的指针。正如代码清单26-8所示,Go也为数组提供了自动的解引用特性。
代码清单26-8 指向数组的指针:superpowers.go
superpowers := &[3]string{"flight", "invisibility", "super strength"} fmt.Println(superpowers[0]) ←--- 打印出“flight” fmt.Println(superpowers[1:2]) ←--- 打印出“[invisibility]”
正如代码清单26-8所示,数组在执行索引或是切片操作的时候将自动实施解引用,我们没有必要写出更麻烦的(*superpowers)[0]。
注意 与C语言不一样,Go语言中的数组和指针是两种完全独立的类型。
切片和映射的复合字面量前面也可以放置地址操作符(&),但Go语言并没有为它们提供自动的解引用特性。
速查26-5
当superpowers是一个指针或者数组时,有什么语句可以和(*superpowers)[2:]具有相同的执行效果?
26.3 实现修改
通过指针可以实现跨越函数和方法边界的修改。
26.3.1 将指针用作形参
Go语言的函数和方法都以传值方式传递形参,这意味着函数总是基于被传递实参的副本进行操作。当指针被传递至函数时,函数将接收到传入内存地址的副本,在此之后,函数就可以通过解引用内存地址来修改指针指向的值。
代码清单26-9中的birthday函数接受一个类型为person的形参,这个形参使得函数可以在函数体中解引用指针并修改指针指向的值。跟代码清单26-7一样,birthday函数在访问age字段的时候并不需要显式地解引用变量p,它现在的做法比具有同等效果的(p).age++更可取。
代码清单26-9 函数形参:birthday.go
type person struct { name, superpower string age int } func birthday(p *person) { p.age++ }
正如代码清单26-10所示,为了让birthday函数能够正常运作,调用者需要向其传递一个指向person结构的指针。
代码清单26-10 函数实参:birthday.go
rebecca := person{ name: "Rebecca", superpower: "imagination", age: 14, } birthday(&rebecca) fmt.Printf("%+v\n", rebecca) ←--- 打印出“{name:Rebecca superpower:imagination age:15}”
速查26-6
1.对代码清单26-6来说,以下哪行代码会返回Timothy 11?
a.birthday(&timmy)
b.birthday(timmy)
c.birthday(*timmy)
2.对代码清单26-9和代码清单26-10来说,如果birthday(p person)函数不使用指针,那么Rebecca的岁数(age)将是多少?
26.3.2 指针接收者
方法的接收者和形参在处理指针方面是非常相似的。代码清单26-11中的birthday方法使用了指针作为接收者,使得方法可以对person结构的属性进行修改,这一行为与代码清单26-9中的birthday函数别无二致。
代码清单26-11 指针接收者:method.go
type person struct { name string age int } func (p *person) birthday() { p.age++ }
作为例子,代码清单26-12演示了如何通过声明指针并调用它的birthday方法来增加Terry的年龄。
代码清单26-12 使用指针执行方法调用:method.go
terry := &person{ name: "Terry", age: 15, } terry.birthday() fmt.Printf("%+v\n", terry) ←--- 打印出“&{name:Terry age:16}”
另外,虽然代码清单26-13中的方法调用并没有用到指针,但它仍然可以正常运行。这是因为Go语言在变量通过点标记调用方法的时候会自动使用&取得变量的内存地址,所以我们就算不写出(&nathan).birthday(),代码也可以正常运行。
代码清单26-13 无须指针执行方法调用:method.go
nathan := person{ name: "Nathan", age: 17, } nathan.birthday() fmt.Printf("%+v\n", nathan) ←--- 打印出“{name:Nathan age:18}”
需要注意的是,无论调用方法的变量是否为指针,代码清单26-11中声明的birthday方法都必须使用指针作为接收者,否则age字段将无法实现自增。
因为结构经常会通过指针进行传递,所以像birthday方法这样通过指针修改结构属性而不是新建整个结构的做法是有意义的,但这并不意味着所有结构都应该被修改,例如,标准库中的time包就提供了一个非常好的例子。正如代码清单26-14所示,该包中的time.Time类型的方法并没有使用指针作为接收者,而是选择了在每次调用之后都返回一个新的时间。毕竟从时间的角度来看,每分每秒都是独一无二的。
代码清单26-14 明天又是新的一天:day.go
const layout = "Mon, Jan 2, 2006" day := time.Now() tomorrow := day.Add(24 * time.Hour) fmt.Println(day.Format(layout)) ←--- 打印出“Tue, Nov 10, 2009” fmt.Println(tomorrow.Format(layout)) ←--- 打印出“Wed, Nov 11, 2009”
提示 使用指针作为接收者的策略应该是始终如一的。如果一种类型的某些方法需要用到指针作为接收者,就应该为这种类型的所有方法都使用指针作为接收者。
速查26-7
怎样才能判断time.Time类型的所有方法是否都没有使用指针作为接收者?
26.3.3 内部指针
Go语言提供了一种名为内部指针的便利特性,用于确定结构中指定字段的内存地址。例如,因为代码清单26-15中的levelUp函数会对stats结构进行修改,所以它需要将形参设置为指针类型。
代码清单26-15 levelUp函数:interior.go
type stats struct { level int endurance, health int } func levelUp(s *stats) { s.level++ s.endurance = 42 + (14 * s.level) s.health = 5 * s.endurance }
正如代码清单26-16所示,Go语言的地址操作符不仅可以获取结构的内存地址,还可以获取结构中指定字段的内存地址。
代码清单26-16 内部指针:interior.go
type character struct { name string stats stats } player := character{name: "Matthias"} levelUp(&player.stats) fmt.Printf("%+v\n", player.stats) ←--- 打印出“{level:1 endurance:56 health:280}”
尽管character类型并没有在它的结构定义中包含任何指针,但我们还是可以在有需要时获取任意字段的内存地址。类似于&plater.stats这样的语句将提供指向结构内部的指针。
速查26-8
什么是内部指针?
26.3.4 修改数组
虽然我们更倾向于使用切片而不是数组,但数组也适用于一些不需要修改长度的场景,第16章提到的国际象棋棋盘就是一个很好的例子。代码清单26-17展示了函数通过指针对数组元素进行修改的方法。
代码清单26-17 重置棋盘:array.go
func reset(board *[8][8]rune) { board[0][0] = ‘r‘ // ... } func main() { var board [8][8]rune reset(&board) fmt.Printf("%c", board[0][0]) ←--- 打印出“r” }
在第20章中,尽管世界的大小是固定的,但我们还是使用了切片来实现康威生命游戏。在学习了指针的相关知识之后,我们现在可以考虑使用数组重新实现这个游戏了。
速查26-9
什么情况下应该使用指向数组的指针?
26.4 隐式指针
并非所有修改都需要显式地使用指针,Go语言也会为一些内置的收集器暗中使用指针。
26.4.1 映射也是指针
第19章曾经提到过,映射在被赋值或者被作为实参传递的时候不会被复制。因为映射实际上就是一种隐式指针,所以像下面这条语句那样,使用指针指向映射将是多此一举的:
func demolish(planets *map[string]string) ←--- 多余的指针
尽管映射的键或者值都可以是指针类型,但需要将指针指向映射的情况并不多。
速查26-10
映射是指针吗?
26.4.2 切片指向数组
第17章曾经说过切片是指向数组的窗口,实际上切片在指向数组元素的时候也的确使用了指针。
每个切片在内部都会被表示为一个包含3个元素的结构,这3个元素分别是指向数组的指针、切片的容量以及切片的长度。当切片被直接传递至函数或者方法的时候,切片的内部指针就可以对底层数据进行修改。
指向切片的显式指针的唯一作用就是修改切片本身,包括切片的长度、容量以及起始偏移量。在接下来的代码清单26-18中,reclassify函数将修改planets切片的长度,但如果这个函数不使用指针,那么调用者函数main将不会察觉这一修改。
代码清单26-18 修改切片:slice.go
func reclassify(planets *[]string) { *planets = (*planets)[0:8] } func main() { planets := []string{ "Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", } reclassify(&planets) fmt.Println(planets) ←--- 打印出“[Mercury Venus Earth Mars Jupiter Saturn Uranus Neptune]” }
除了像代码清单26-18那样直接修改传入的切片,reclassify函数也可以选择返回一个新的切片,这无疑是一种更为清晰的做法。
速查26-11
如果函数和方法想要修改它们接收到的数据,那么它们应该使用指向哪两种数据类型的指针?
26.5 指针和接口
正如下面的代码清单26-19所示,无论是martian还是指向martian的指针,都可以满足talker接口。
代码清单26-19 指针和接口:martian.go
type talker interface { talk() string } func shout(t talker) { louder := strings.ToUpper(t.talk()) fmt.Println(louder) } type martian struct{} func (m martian) talk() string { return "nack nack" } func main() { shout(martian{}) ←--- 打印出“NACK NACK” shout(&martian{}) }
但是正如代码清单26-20所示,如果方法使用的是指针接收者,那么情况将会有所不同。
代码清单26-20 指针和接口:interface.go
type laser int func (l *laser) talk() string { return strings.Repeat("pew ", int(*l)) } func main() { pew := laser(2) shout(&pew) ←--- 打印出“PEW PEW” }
在代码清单26-20里面,&pew的类型为*laser,它满足shout函数需要的talker接口。但如果把函数调用换成shout(pew),那么程序将无法运行,因为laser在这种情况下是无法满足接口的。
速查26-12
指针在什么情况下才能满足接口?
26.6 明智地使用指针
指针虽然有用,但是也会增加额外的复杂性。毕竟如果值可能会在多个地方发生变化,那么追踪代码就会变得更为困难。
应该合理地使用指针而不要过度使用它们。那些不暴露指针的编程语言通常会在组合多个对象为类等情况下隐式地使用指针,但是在使用Go语言的时候,是否使用指针将由你来决定。
速查26-13
为什么不要过度使用指针?
26.7 小结
- 指针存储的是内存地址。
- 地址操作符(&)用于提供变量的内存地址。
- 指针可以通过解引用(*)来获取或者修改被指向的值。
- 指针的类型声明前面都带有星号(),如int。
- 使用指针可以跨越函数和方法的边界对值进行修改。
- 指针与结构或者数组搭配使用时最为有用。
- 映射和切片会隐式地使用指针。
- 内部指针可以在无须将字段声明为指针的情况下指向结构中的字段。
- 应该合理地使用指针而不要过度使用它们。
为了检验你是否已经掌握了上述知识,请尝试完成以下实验。
实验:turtle.go
请编写一个可以让海龟上下左右移动的程序。程序中的海龟需要存储一个位置(x, y),正数坐标表示向下或向右,并通过使用方法对相应的变量实施自增和自减来实现移动。请使用main函数测试这些方法并打印出海龟的最终位置。
提示 为了修改海龟的x值和y值,你需要将方法的接收者设置为指针。
速查26-1答案
1.因为该语句首先会使用&取得answer变量的内存地址,然后再使用*对该地址进行解引用,所以语句最终将打印出answer变量的值42。
2.乘法运算符是一个需要两个值的中缀操作符,而解引用操作符则会被放在单个变量的前面。
速查26-2答案
1.var address *int
2.将星号放置在类型前面表示声明指针类型,而将星号放置在指针变量的前面则表示解引用该变量指向的值。
速查26-3答案
1.因为administrator变量指向bolden变量的内存地址,而不是存储bolden变量的副本,所以使用指针可以让修改在同一个地方生效。
2.变量major是一个新创建的*string指针,它持有和administrator相同的内存地址。至于charles则是一个字符串变量,它的值复制自major指针的解引用结果。
速查26-4答案
1.地址操作符可以合法地放置在变量和复合字面量前面,但不能放置在字符串字面量或整数字面量前面。
2.因为Go会为字段自动实施指针解引用,所以上述两个语句在功能上没有任何区别,不过由于timmy.superpower更易读,因此它更可取一些。
速查26-5答案
基于Go为数组提供的自动解引用特性,语句superpowers[2:]将具有相同的效果。
速查26-6答案
1.因为timmy变量已经是一个指针,所以正确的答案应该是b:birthday(timmy)。
2.如果birthday函数不使用指针,那么Rebecca将永远保持14岁。
速查26-7答案
因为Go的点标记法对于指针变量和非指针变量的处理方式是一样的,所以光从代码清单26-14中的Add方法是无法判断time.Time类型是否使用了指针接收者的。要弄清楚这一点,更好的做法是直接查看time.Time类型各个方法的文档。
速查26-8答案
内部指针即是指向结构内部字段的指针。这一点可以通过在结构字段的前面放置地址操作符来完成,如&player.stats。
速查26-9答案
数组适用于像棋盘那样固定大小的数据。在不使用指针的情况下,数组在每次传递至函数或者方法时都需要进行复制,而使用指向数组的指针可以避免这一点。除此之外,函数或者方法通过指针可以对传入的数组进行修改,这一点在不使用指针的情况下是无法做到的。
速查26-10答案
是的,尽管映射在语法上和指针并无相似之处,但它们实际上就是指针。使用不是指针的映射是不可能的。
速查26-11答案
结构和数组。
速查26-12答案
如果类型的非指针版本能够满足接口,那么它的指针版本也能够满足。
速查26-13答案
因为不使用指针的代码更容易理解。