Go語言核心36講(Go語言進階技術九)--學習筆記

MingsonZheng發表於2021-10-28

15 | 關於指標的有限操作

在前面的文章中,我們已經提到過很多次“指標”了,你應該已經比較熟悉了。不過,我們那時大多指的是指標型別及其對應的指標值,今天我們講的則是更為深入的內容。

讓我們先來複習一下。

type Dog struct {
  name string
}

func (dog *Dog) SetName(name string) {
  dog.name = name
}

對於基本型別Dog來說,*Dog就是它的指標型別。而對於一個Dog型別,值不為nil的變數dog,取址表示式&dog的結果就是該變數的值(也就是基本值)的指標值。

如果一個方法的接收者是*Dog型別的,那麼該方法就是基本型別Dog的指標方法。

在這種情況下,這個方法的接收者,實際上就是當前的基本值的指標值。

我們可以通過指標值無縫地訪問到基本值包含的任何欄位,以及呼叫與之關聯的任何方法。這應該就是我們在編寫 Go 程式的過程中,用得最頻繁的“指標”了。

從傳統意義上說,指標是一個指向某個確切的記憶體地址的值。這個記憶體地址可以是任何資料或程式碼的起始地址,比如,某個變數、某個欄位或某個函式。

我們剛剛只提到了其中的一種情況,在 Go 語言中還有其他幾樣東西可以代表“指標”。其中最貼近傳統意義的當屬uintptr型別了。該型別實際上是一個數值型別,也是 Go 語言內建的資料型別之一。

根據當前計算機的計算架構的不同,它可以儲存 32 位或 64 位的無符號整數,可以代表任何指標的位(bit)模式,也就是原始的記憶體地址。

再來看 Go 語言標準庫中的unsafe包。unsafe包中有一個型別叫做Pointer,也代表了“指標”。

unsafe.Pointer可以表示任何指向可定址的值的指標,同時它也是前面提到的指標值和uintptr值之間的橋樑。也就是說,通過它,我們可以在這兩種值之上進行雙向的轉換。這裡有一個很關鍵的詞——可定址的(addressable)。在我們繼續說unsafe.Pointer之前,需要先要搞清楚這個詞的確切含義。

今天的問題是:你能列舉出 Go 語言中的哪些值是不可定址的嗎?

這道題的典型回答是以下列表中的值都是不可定址的。

  • 常量的值。
  • 基本型別值的字面量。
  • 算術操作的結果值。
  • 對各種字面量的索引表示式和切片表示式的結果值。不過有一個例外,對切片字面量的索引結果值卻是可定址的。
  • 對字串變數的索引表示式和切片表示式的結果值。
  • 對字典變數的索引表示式的結果值。
  • 函式字面量和方法字面量,以及對它們的呼叫表示式的結果值。
  • 結構體字面量的欄位值,也就是對結構體字面量的選擇表示式的結果值。
  • 型別轉換表示式的結果值。
  • 型別斷言表示式的結果值。
  • 接收表示式的結果值。

問題解析

初看答案中的這些不可定址的值好像並沒有什麼規律。不過別急,我們一起來梳理一下。你可以對照著 demo35.go 檔案中的程式碼來看,這樣應該會讓你理解起來更容易一些。

package main

type Named interface {
	// Name 用於獲取名字。
	Name() string
}

type Dog struct {
	name string
}

func (dog *Dog) SetName(name string) {
	dog.name = name
}

func (dog Dog) Name() string {
	return dog.name
}

func main() {
	// 示例1。
	const num = 123
	//_ = &num // 常量不可定址。
	//_ = &(123) // 基本型別值的字面量不可定址。

	var str = "abc"
	_ = str
	//_ = &(str[0]) // 對字串變數的索引結果值不可定址。
	//_ = &(str[0:2]) // 對字串變數的切片結果值不可定址。
	str2 := str[0]
	_ = &str2 // 但這樣的定址就是合法的。

	//_ = &(123 + 456) // 算術操作的結果值不可定址。
	num2 := 456
	_ = num2
	//_ = &(num + num2) // 算術操作的結果值不可定址。

	//_ = &([3]int{1, 2, 3}[0]) // 對陣列字面量的索引結果值不可定址。
	//_ = &([3]int{1, 2, 3}[0:2]) // 對陣列字面量的切片結果值不可定址。
	_ = &([]int{1, 2, 3}[0]) // 對切片字面量的索引結果值卻是可定址的。
	//_ = &([]int{1, 2, 3}[0:2]) // 對切片字面量的切片結果值不可定址。
	//_ = &(map[int]string{1: "a"}[0]) // 對字典字面量的索引結果值不可定址。

	var map1 = map[int]string{1: "a", 2: "b", 3: "c"}
	_ = map1
	//_ = &(map1[2]) // 對字典變數的索引結果值不可定址。

	//_ = &(func(x, y int) int {
	//	return x + y
	//}) // 字面量代表的函式不可定址。
	//_ = &(fmt.Sprintf) // 識別符號代表的函式不可定址。
	//_ = &(fmt.Sprintln("abc")) // 對函式的呼叫結果值不可定址。

	dog := Dog{"little pig"}
	_ = dog
	//_ = &(dog.Name) // 識別符號代表的函式不可定址。
	//_ = &(dog.Name()) // 對方法的呼叫結果值不可定址。

	//_ = &(Dog{"little pig"}.name) // 結構體字面量的欄位不可定址。

	//_ = &(interface{}(dog)) // 型別轉換表示式的結果值不可定址。
	dogI := interface{}(dog)
	_ = dogI
	//_ = &(dogI.(Named)) // 型別斷言表示式的結果值不可定址。
	named := dogI.(Named)
	_ = named
	//_ = &(named.(Dog)) // 型別斷言表示式的結果值不可定址。

	var chan1 = make(chan int, 1)
	chan1 <- 1
	//_ = &(<-chan1) // 接收表示式的結果值不可定址。

}

常量的值總是會被儲存到一個確切的記憶體區域中,並且這種值肯定是不可變的。基本型別值的字面量也是一樣,其實它們本就可以被視為常量,只不過沒有任何識別符號可以代表它們罷了。

第一個關鍵詞:不可變的。由於 Go 語言中的字串值也是不可變的,所以對於一個字串型別的變數來說,基於它的索引或切片的結果值也都是不可定址的,因為即使拿到了這種值的記憶體地址也改變不了什麼。

算術操作的結果值屬於一種臨時結果。在我們把這種結果值賦給任何變數或常量之前,即使能拿到它的記憶體地址也是沒有任何意義的。

第二個關鍵詞:臨時結果。這個關鍵詞能被用來解釋很多現象。我們可以把各種對值字面量施加的表示式的求值結果都看做是臨時結果。

我們都知道,Go 語言中的表示式有很多種,其中常用的包括以下幾種。

  • 用於獲得某個元素的索引表示式。
  • 用於獲得某個切片(片段)的切片表示式。
  • 用於訪問某個欄位的選擇表示式。
  • 用於呼叫某個函式或方法的呼叫表示式。
  • 用於轉換值的型別的型別轉換表示式。
  • 用於判斷值的型別的型別斷言表示式。
  • 向通道傳送元素值或從通道那裡接收元素值的接收表示式。

我們把以上這些表示式施加在某個值字面量上一般都會得到一個臨時結果。比如,對陣列字面量和字典字面量的索引結果值,又比如,對陣列字面量和切片字面量的切片結果值。它們都屬於臨時結果,都是不可定址的。

一個需要特別注意的例外是,對切片字面量的索引結果值是可定址的。因為不論怎樣,每個切片值都會持有一個底層陣列,而這個底層陣列中的每個元素值都是有一個確切的記憶體地址的。

你可能會問,那麼對切片字面量的切片結果值為什麼卻是不可定址的?這是因為切片表示式總會返回一個新的切片值,而這個新的切片值在被賦給變數之前屬於臨時結果。

你可能已經注意到了,我一直在說針對陣列值、切片值或字典值的字面量的表示式會產生臨時結果。如果針對的是陣列型別或切片型別的變數,那麼索引或切片的結果值就都不屬於臨時結果了,是可定址的。

這主要因為變數的值本身就不是“臨時的”。對比而言,值字面量在還沒有與任何變數(或者說任何識別符號)繫結之前是沒有落腳點的,我們無法以任何方式引用到它們。這樣的值就是“臨時的”。

再說一個例外。我們通過對字典型別的變數施加索引表示式,得到的結果值不屬於臨時結果,可是,這樣的值卻是不可定址的。原因是,字典中的每個鍵 - 元素對的儲存位置都可能會變化,而且這種變化外界是無法感知的。

我們都知道,字典中總會有若干個雜湊桶用於均勻地儲存鍵 - 元素對。當滿足一定條件時,字典可能會改變雜湊桶的數量,並適時地把其中的鍵 - 元素對搬運到對應的新的雜湊桶中。

在這種情況下,獲取字典中任何元素值的指標都是無意義的,也是不安全的。我們不知道什麼時候那個元素值會被搬運到何處,也不知道原先的那個記憶體地址上還會被存放什麼別的東西。所以,這樣的值就應該是不可定址的。

第三個關鍵詞:不安全的。“不安全的”操作很可能會破壞程式的一致性,引發不可預知的錯誤,從而嚴重影響程式的功能和穩定性。

再來看函式。函式在 Go 語言中是一等公民,所以我們可以把代表函式或方法的字面量或識別符號賦給某個變數、傳給某個函式或者從某個函式傳出。但是,這樣的函式和方法都是不可定址的。一個原因是函式就是程式碼,是不可變的。

另一個原因是,拿到指向一段程式碼的指標是不安全的。此外,對函式或方法的呼叫結果值也是不可定址的,這是因為它們都屬於臨時結果。

至於典型回答中最後列出的那幾種值,由於都是針對值字面量的某種表示式的結果值,所以都屬於臨時結果,都不可定址。

好了,說了這麼多,希望你已經有所領悟了。我來總結一下。

1、不可變的值不可定址。常量、基本型別的值字面量、字串變數的值、函式以及方法的字面量都是如此。其實這樣規定也有安全性方面的考慮。

2、絕大多數被視為臨時結果的值都是不可定址的。算術操作的結果值屬於臨時結果,針對值字面量的表示式結果值也屬於臨時結果。但有一個例外,對切片字面量的索引結果值雖然也屬於臨時結果,但卻是可定址的。

3、若拿到某值的指標可能會破壞程式的一致性,那麼就是不安全的,該值就不可定址。由於字典的內部機制,對字典的索引結果值的取址操作都是不安全的。另外,獲取由字面量或識別符號代表的函式或方法的地址顯然也是不安全的。

最後說一句,如果我們把臨時結果賦給一個變數,那麼它就是可定址的了。如此一來,取得的指標指向的就是這個變數持有的那個值了。

知識擴充套件

問題 1:不可定址的值在使用上有哪些限制?

首當其衝的當然是無法使用取址操作符&獲取它們的指標了。不過,對不可定址的值施加取址操作都會使編譯器報錯,所以倒是不用太擔心,你只要記住我在前面講述的那幾條規律,並在編碼的時候提前注意一下就好了。

我們來看下面這個小問題。我們依然以那個結構體型別Dog為例。

func New(name string) Dog {
  return Dog{name}
}

我們再為它編寫一個函式New。這個函式會接受一個名為name的string型別的引數,並會用這個引數初始化一個Dog型別的值,最後返回該值。我現在要問的是:如果我呼叫該函式,並直接以鏈式的手法呼叫其結果值的指標方法SetName,那麼可以達到預期的效果嗎?

New("little pig").SetName("monster")

如果你還記得我在前面講述的內容,那麼肯定會知道呼叫New函式所得到的結果值屬於臨時結果,是不可定址的。

可是,那又怎樣呢?別忘了,我在講結構體型別及其方法的時候還說過,我們可以在一個基本型別的值上呼叫它的指標方法,這是因為 Go 語言會自動地幫我們轉譯。

更具體地說,對於一個Dog型別的變數dog來說,呼叫表示式dog.SetName("monster")會被自動地轉譯為(&dog).SetName("monster"),即:先取dog的指標值,再在該指標值上呼叫SetName方法。

發現問題了嗎?由於New函式的呼叫結果值是不可定址的,所以無法對它進行取址操作。因此,上邊這行鏈式呼叫會讓編譯器報告兩個錯誤,一個是果,即:不能在New("little pig")的結果值上呼叫指標方法。一個是因,即:不能取得New("little pig")的地址。

除此之外,我們都知道,Go 語言中的++和--並不屬於操作符,而分別是自增語句和自減語句的重要組成部分。

雖然 Go 語言規範中的語法定義是,只要在++或--的左邊新增一個表示式,就可以組成一個自增語句或自減語句,但是,它還明確了一個很重要的限制,那就是這個表示式的結果值必須是可定址的。這就使得針對值字面量的表示式幾乎都無法被用在這裡。

package main

type Dog struct {
	name string
}

func New(name string) Dog {
	return Dog{name}
}

func (dog *Dog) SetName(name string) {
	dog.name = name
}

func (dog Dog) Name() string {
	return dog.name
}

func main() {
	// 示例1。
	//New("little pig").SetName("monster") // 不能呼叫不可定址的值的指標方法。

	// 示例2。
	map[string]int{"the": 0, "word": 0, "counter": 0}["word"]++
	map1 := map[string]int{"the": 0, "word": 0, "counter": 0}
	map1["word"]++
}

不過這有一個例外,雖然對字典字面量和字典變數索引表示式的結果值都是不可定址的,但是這樣的表示式卻可以被用在自增語句和自減語句中。

與之類似的規則還有兩個。一個是,在賦值語句中,賦值操作符左邊的表示式的結果值必須可定址的,但是對字典的索引結果值也是可以的。

另一個是,在帶有range子句的for語句中,在range關鍵字左邊的表示式的結果值也都必須是可定址的,不過對字典的索引結果值同樣可以被用在這裡。以上這三條規則我們合併起來記憶就可以了。

與這些定死的規則相比,我剛剛講到的那個與指標方法有關的問題,你需要好好理解一下,它涉及了兩個知識點的聯合運用。起碼在我面試的時候,它是一個可選擇的考點。

問題 2:怎樣通過unsafe.Pointer操縱可定址的值?

前邊的基礎知識很重要。不過現在讓我們再次關注指標的用法。我說過,unsafe.Pointer是像*Dog型別的值這樣的指標值和uintptr值之間的橋樑,那麼我們怎樣利用unsafe.Pointer的中轉和uintptr的底層操作來操縱像dog這樣的值呢?

首先說明,這是一項黑科技。它可以繞過 Go 語言的編譯器和其他工具的重重檢查,並達到潛入記憶體修改資料的目的。這並不是一種正常的程式設計手段,使用它會很危險,很有可能造成安全隱患。

我們總是應該優先使用常規程式碼包中提供的 API 去編寫程式,當然也可以把像reflect以及go/ast這樣的程式碼包作為備選項。作為上層應用的開發者,請謹慎地使用unsafe包中的任何程式實體。

不過既然說到這裡了,我們還是要來一探究竟的。請看下面的程式碼:

dog := Dog{"little pig"}
dogP := &dog
dogPtr := uintptr(unsafe.Pointer(dogP))

我先宣告瞭一個Dog型別的變數dog,然後用取址操作符&,取出了它的指標值,並把它賦給了變數dogP。

最後,我使用了兩個型別轉換,先把dogP轉換成了一個unsafe.Pointer型別的值,然後緊接著又把後者轉換成了一個uintptr的值,並把它賦給了變數dogPtr。這背後隱藏著一些轉換規則,如下:

  • 一個指標值(比如*Dog型別的值)可以被轉換為一個unsafe.Pointer型別的值,反之亦然。
  • 一個uintptr型別的值也可以被轉換為一個unsafe.Pointer型別的值,反之亦然。
  • 一個指標值無法被直接轉換成一個uintptr型別的值,反過來也是如此。

所以,對於指標值和uintptr型別值之間的轉換,必須使用unsafe.Pointer型別的值作為中轉。那麼,我們把指標值轉換成uintptr型別的值有什麼意義嗎?

namePtr := dogPtr + unsafe.Offsetof(dogP.name)
nameP := (*string)(unsafe.Pointer(namePtr))

這裡需要與unsafe.Offsetof函式搭配使用才能看出端倪。unsafe.Offsetof函式用於獲取兩個值在記憶體中的起始儲存地址之間的偏移量,以位元組為單位。

這兩個值一個是某個欄位的值,另一個是該欄位值所屬的那個結構體值。我們在呼叫這個函式的時候,需要把針對欄位的選擇表示式傳給它,比如dogP.name。

有了這個偏移量,又有了結構體值在記憶體中的起始儲存地址(這裡由dogPtr變數代表),把它們相加我們就可以得到dogP的name欄位值的起始儲存地址了。這個地址由變數namePtr代表。

此後,我們可以再通過兩次型別轉換把namePtr的值轉換成一個*string型別的值,這樣就得到了指向dogP的name欄位值的指標值。

你可能會問,我直接用取址表示式&(dogP.name)不就能拿到這個指標值了嗎?幹嘛繞這麼大一圈呢?你可以想象一下,如果我們根本就不知道這個結構體型別是什麼,也拿不到dogP這個變數,那麼還能去訪問它的name欄位嗎?

答案是,只要有namePtr就可以。它就是一個無符號整數,但同時也是一個指向了程式內部資料的記憶體地址。它可能會給我們帶來一些好處,比如可以直接修改埋藏得很深的內部資料。

但是,一旦我們有意或無意地把這個記憶體地址洩露出去,那麼其他人就能夠肆意地改動dogP.name的值,以及周圍的記憶體地址上儲存的任何資料了。

即使他們不知道這些資料的結構也無所謂啊,改不好還改不壞嗎?不正確地改動一定會給程式帶來不可預知的問題,甚至造成程式崩潰。這可能還是最好的災難性後果;所以我才說,使用這種非正常的程式設計手段會很危險。

好了,現在你知道了這種手段,也知道了它的危險性,那就謹慎對待,防患於未然吧。

package main

import (
	"fmt"
	"unsafe"
)

type Dog struct {
	name string
}

func (dog *Dog) SetName(name string) {
	dog.name = name
}

func (dog Dog) Name() string {
	return dog.name
}

func main() {
	// 示例1。
	dog := Dog{"little pig"}
	dogP := &dog
	dogPtr := uintptr(unsafe.Pointer(dogP))

	namePtr := dogPtr + unsafe.Offsetof(dogP.name)
	nameP := (*string)(unsafe.Pointer(namePtr))
	fmt.Printf("nameP == &(dogP.name)? %v\n",
		nameP == &(dogP.name))
	fmt.Printf("The name of dog is %q.\n", *nameP)

	*nameP = "monster"
	fmt.Printf("The name of dog is %q.\n", dogP.name)
	fmt.Println()

	// 示例2。
	// 下面這種不匹配的轉換雖然不會引發panic,但是其結果往往不符合預期。
	numP := (*int)(unsafe.Pointer(namePtr))
	num := *numP
	fmt.Printf("This is an unexpected number: %d\n", num)

}

總結

我們今天集中說了說與指標有關的問題。基於基本型別的指標值應該是我們最常用到的,也是我們最需要關注的,比如*Dog型別的值。怎樣得到一個這樣的指標值呢?這需要用到取址操作和操作符&。

不過這裡還有個前提,那就是取址操作的操作物件必須是可定址的。關於這方面你需要記住三個關鍵詞:不可變的、臨時結果和不安全的。只要一個值符合了這三個關鍵詞中的任何一個,它就是不可定址的。

但有一個例外,對切片字面量的索引結果值是可定址的。那麼不可定址的值在使用上有哪些限制呢?一個最重要的限制是關於指標方法的,即:無法呼叫一個不可定址值的指標方法。這涉及了兩個知識點的聯合運用。

相比於剛說到的這些,unsafe.Pointer型別和uintptr型別的重要性好像就沒那麼高了。它們的值同樣可以代表指標,並且比前面說的指標值更貼近於底層和記憶體。

雖然我們可以利用它們去訪問或修改一些內部資料,而且就靈活性而言,這種要比通用的方式高很多,但是這往往也會帶來不容小覷的安全隱患。

因此,在很多時候,使用它們操縱資料是弊大於利的。不過,對於硬幣的背面,我們也總是有必要去了解的。

思考題

今天的思考題是:引用型別的值的指標值是有意義的嗎?如果沒有意義,為什麼?如果有意義,意義在哪裡?

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章