在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
本章学习目标
当我们在街上散步的时候,常常会看到一些用于指引方位的地址和街道标识。你可能曾经遇到过这样一种情况,一家大门紧闭的商店在它的橱窗上贴出了道歉标语“抱歉,本店已乔迁新址!”,并在标语的下方给出新的地址。指针就有点儿像这个给出新地址的标语,它会把你指引至不同的地址。 指针是指向另一变量的地址的变量。在计算机科学中,指针是间接访问的一种形式,它是一种强有力的工具。
指针虽然有用,但多年以来它们也引起了不少麻烦。以C语言为典型的旧式编程语言通常并不强调安全性,而许多崩溃事件和安全漏洞又都与滥用指针有着千丝万缕的关系,这最终导致了一些语言选择不将指针暴露给程序员。 Go语言确实提供了指针,但同时也强调内存安全,它不会受到诸如迷途指针(也称野指针)等问题的困扰。这就好比你在根据新地址前往自己喜欢的商店时,不会莫名其妙地到了电影院的停车场一样。 如果你以前就了解过指针,那么请不要担心,因为Go语言的指针并没有你想象中的那么糟糕。但如果这是你第一次接触指针,那么也请不要紧张,因为Go语言是学习指针的安全之地。
26.1 &和*Go的指针采用了历史悠久并且广为人知的C语言指针语法。在这种语法中,我们需要特别关注&(与符号)和*(星号),并且正如后续内容所介绍的那样,星号具有两种用途。 变量会将它们的值存储在计算机的随机访问存储器里面,而值的存储位置则是该变量的内存地址。通过使用&表示的地址操作符,我们可以得到指定变量的内存地址。例如,在代码清单26-1中,我们就以十六进制数的形式打印出了变量answer的内存地址,尽管这个地址在你的计算机中可能会有所不同。 代码清单26-1 地址操作符:memory.go answer := 42 fmt.Println(&answer) ←--- 打印出“0x1040c108” 程序打印出的数字就是计算机在内存中存储42的位置。幸运的是,我们只需要通过变量名answer就可以检索到这个值,而不必像计算机那样通过内存地址进行检索。
地址操作符(&)提供值的内存地址,而它的反向操作解引用则提供内存地址指向的值。作为例子,代码清单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
指针类型指针存储的是内存地址。 代码清单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类型。
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.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.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]。
切片和映射的复合字面量前面也可以放置地址操作符(&),但Go语言并没有为它们提供自动的解引用特性。
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.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.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.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.4 隐式指针并非所有修改都需要显式地使用指针,Go语言也会为一些内置的收集器暗中使用指针。 26.4.1 映射也是指针第19章曾经提到过,映射在被赋值或者被作为实参传递的时候不会被复制。因为映射实际上就是一种隐式指针,所以像下面这条语句那样,使用指针指向映射将是多此一举的: func demolish(planets *map[string]string) ←--- 多余的指针 尽管映射的键或者值都可以是指针类型,但需要将指针指向映射的情况并不多。
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.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.6 明智地使用指针指针虽然有用,但是也会增加额外的复杂性。毕竟如果值可能会在多个地方发生变化,那么追踪代码就会变得更为困难。 应该合理地使用指针而不要过度使用它们。那些不暴露指针的编程语言通常会在组合多个对象为类等情况下隐式地使用指针,但是在使用Go语言的时候,是否使用指针将由你来决定。
26.7 小结
为了检验你是否已经掌握了上述知识,请尝试完成以下实验。 实验:turtle.go 请编写一个可以让海龟上下左右移动的程序。程序中的海龟需要存储一个位置(x, y),正数坐标表示向下或向右,并通过使用方法对相应的变量实施自增和自减来实现移动。请使用main函数测试这些方法并打印出海龟的最终位置。
速查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答案 因为不使用指针的代码更容易理解。 本文摘自《Go语言趣学指南》,内森·扬曼(Nathan Youngman),罗杰·佩珀(Roger Peppé) 著,黄健宏 译 《Go语言趣学指南》是一本面向Go语言初学者的书,循序渐进地介绍了使用Go语言所必需的知识,展示了非常多生动有趣的例子,并通过提供大量练习来加深读者对书中所述内容的理解。本书共分8个单元,分别介绍变量、常量、分支和循环等基础语句,整数、浮点数和字符串等常用类型,类型、函数和方法,数组、切片和映射,结构和接口,指针、nil和错误处理方法,并发和状态保护,并且每个单元都包含相应的章节和单元测试。
|
请发表评论