第十五章:指標型別

Zioyi發表於2021-11-28

image

本篇翻譯自《Practical Go Lessons》 Chapter 15: Pointer type

1 你將在本章將學到什麼?

  • 什麼是指標?
  • 什麼時指標型別?
  • 如何去建立並使用一個指標型別的變數。
  • 指正型別變數的零值是什麼?
  • 什麼是解除引用?
  • slices, maps, 和 channels 有什麼特殊的地方?

2 涵蓋的技術概念

  • 指標
  • 記憶體地址
  • 指標型別
  • 解除引用
  • 引用

3 什麼是指標?

指標是“是一個資料項,它儲存另外一個資料項的位置”。
在程式中,我們不斷地儲存和檢索資料。例如,字串、數字、複雜結構…。在物理層面,資料儲存在記憶體中的特定地址,而指標儲存的就是這些特定記憶體地址。

image

記住指標變數,就像其他變數一樣,它也有一個記憶體地址。

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,我們可以得到這個變數的地址,也可以通過地址找到這個變數:
image
image

  • 使用 * 操作符,你可以通過地址找到變數值
  • 使用 & 操作符,你可以得到變數的地址

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,我們可以直接修改變數 cartItem 欄位
  • 這是因為執行時自動通過結構體指標找到了原值進行了修改,以下是等價的寫法
(*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 欄位值會發生變化。
image

11.1 何時使用指標接收器,何時使用值接收器

  • 以下情況使用指標接收器:
    • 你的結構體很大(如果使用值接收器,Go 會複製它)
    • 你想修改接收器(例如,你想更改結構變數的名稱欄位)
    • 你的結構包含一個同步原語(如sync.Mutex)欄位。如果你使用值接收器,它還會複製互斥鎖,使其無用並導致同步錯誤。
    • 當接收器是一個 map、func、chan、slice、string 或 interface值時(因為在內部它已經是一個指標)
    • 當你的接收器是持有指標時

12 隨堂測試

12.1 問題

  1. 如何去表示一個持有指向 Product 指標的變數?
  2. 指標型別的零值是多少?
  3. "解引用(dereferencing)" 是什麼意思?
  4. 如何解引用一個指標?
  5. 填空: ____ 在內部是一個指向 ____ 的指標。
  6. 判斷正誤:當我想函式中修改 map 時,我的函式需要接收一個指向 map 的指標作為引數,我還需要返回修改後的 map?

12.2 答案

  1. 如何去表示一個持有指向 Product 指標的變數?
    *Product
  2. 指標型別的零值是多少?
    nil
  3. "解引用(dereferencing)" 是什麼意思?
    • 指標是指向儲存資料的記憶體位置的地址。
    • 當我們解引用一個指標時,我們可以訪問儲存在該地址的記憶體中的資料。
  4. 如何解引用一個指標?
    使用解引用操作符 *
  5. 填空: ____ 在內部是一個指向 ____ 的指標。
    slice 在內部是一個指向 array 的指標。
  6. 判斷正誤:當我想函式中修改 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 就會被修改

相關文章