第十五章:指針類型
- 2021 年 11 月 28 日
- 筆記
- Go, Practical Go Lessons
本篇翻譯自《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 函數或方法將切片作為參數或接收器:小心
9.2.0.1 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 函數或方法將切片指針作為參數或接收器
如果使用切片指針,你就可以在函數中修改這個切片了:
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
就會被修改