Go高階特性 16 | 非型別安全:unsafe

Swenson1992發表於2021-03-01

顧名思義,unsafe 是不安全的。Go 將其定義為這個包名,也是為了儘可能地不使用它。不過雖然不安全,它也有優勢,那就是可以繞過 Go 的記憶體安全機制,直接對記憶體進行讀寫。所以有時候出於效能需要,還是會冒險使用它來對記憶體進行操作。

指標型別轉換

Go 的設計者為了編寫方便、提高效率且降低複雜度,將其設計成一門強型別的靜態語言。強型別意味著一旦定義了,型別就不能改變;靜態意味著型別檢查在執行前就做了。同時出於安全考慮,Go 語言是不允許兩個指標型別進行轉換的。

一般使用 *T 作為一個指標型別,表示一個指向型別 T 變數的指標。為了安全的考慮,兩個不同的指標型別不能相互轉換,比如 *int 不能轉為 *float64。

來看下面的程式碼:

func main() {
   i:= 10
   ip:=&i
   var fp *float64 = (*float64)(ip)
   fmt.Println(fp)
}

這個程式碼在編譯的時候,會提示 cannot convert ip (type * int) to type * float64,也就是不能進行強制轉型。那如果還是需要轉換呢?這就需要使用 unsafe 包裡的 Pointer 了。下面介紹 unsafe.Pointer 是什麼,然後再介紹如何轉換。

unsafe.Pointer

unsafe.Pointer 是一種特殊意義的指標,可以表示任意型別的地址,類似 C 語言裡的 void* 指標,是全能型的。

正常情況下,*int 無法轉換為 *float64,但是通過 unsafe.Pointer 做中轉就可以了。在下面的示例中,通過 unsafe.Pointer 把 *int 轉換為 *float64,並且對新的 *float64 進行 3 倍的乘法操作,會發現原來變數 i 的值也被改變了,變為 30。

func main() {
   i:= 10
   ip:=&i
   var fp *float64 = (*float64)(unsafe.Pointer(ip))
   *fp = *fp * 3
   fmt.Println(i)
}

這個例子沒有任何實際意義,但是說明了通過 unsafe.Pointer 這個萬能的指標,我們可以在 *T 之間做任何轉換。那麼 unsafe.Pointer 到底是什麼?為什麼其他型別的指標可以轉換為 unsafe.Pointer 呢?這就要看 unsafe.Pointer 的原始碼定義了,如下所示:

// ArbitraryType is here for the purposes of documentation
// only and is not actually part of the unsafe package. 
// It represents the type of an arbitrary Go expression.
type ArbitraryType int
type Pointer *ArbitraryType

按 Go 語言官方的註釋,ArbitraryType 可以表示任何型別(這裡的 ArbitraryType 僅僅是文件需要,不用太關注它本身,只要記住可以表示任何型別即可)。 而 unsafe.Pointer 又是 *ArbitraryType,也就是說 unsafe.Pointer 是任何型別的指標,也就是一個通用型的指標,足以表示任何記憶體地址。

uintptr 指標型別

uintptr 也是一種指標型別,它足夠大,可以表示任何指標。它的型別定義如下所示:

// uintptr is an integer type that is large enough 
// to hold the bit pattern of any pointer.
type uintptr uintptr

既然已經有了 unsafe.Pointer,為什麼還要設計 uintptr 型別呢?這是因為 unsafe.Pointer 不能進行運算,比如不支援 +(加號)運算子操作,但是 uintptr 可以。通過它,可以對指標偏移進行計算,這樣就可以訪問特定的記憶體,達到對特定記憶體讀寫的目的,這是真正記憶體級別的操作。

在下面的程式碼中,通過指標偏移修改 struct 結構體內的欄位為例,演示 uintptr 的用法。

func main() {
   p :=new(person)
   //Name是person的第一個欄位不用偏移,即可通過指標修改
   pName:=(*string)(unsafe.Pointer(p))
   *pName="Golang"
   //Age並不是person的第一個欄位,所以需要進行偏移,這樣才能正確定位到Age欄位這塊記憶體,才可以正確的修改
   pAge:=(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p))+unsafe.Offsetof(p.Age)))
   *pAge = 20
   fmt.Println(*p)
}
type person struct {
   Name string
   Age int
}

這個示例不是通過直接訪問相應欄位的方式對 person 結構體欄位賦值,而是通過指標偏移找到相應的記憶體,然後對記憶體操作進行賦值。

下面詳細介紹操作步驟。

  1. 先使用 new 函式宣告一個 *person 型別的指標變數 p。
  2. 然後把 *person 型別的指標變數 p 通過 unsafe.Pointer,轉換為 *string 型別的指標變數 pName。
  3. 因為 person 這個結構體的第一個欄位就是 string 型別的 Name,所以 pName 這個指標就指向 Name 欄位(偏移為 0),對 pName 進行修改其實就是修改欄位 Name 的值。
  4. 因為 Age 欄位不是 person 的第一個欄位,要修改它必須要進行指標偏移運算。所以需要先把指標變數 p 通過 unsafe.Pointer 轉換為 uintptr,這樣才能進行地址運算。既然要進行指標偏移,那麼要偏移多少呢?這個偏移量可以通過函式 unsafe.Offsetof 計算出來,該函式返回的是一個 uintptr 型別的偏移量,有了這個偏移量就可以通過 + 號運算子獲得正確的 Age 欄位的記憶體地址了,也就是通過 unsafe.Pointer 轉換後的 *int 型別的指標變數 pAge。
  5. 然後需要注意的是,如果要進行指標運算,要先通過 unsafe.Pointer 轉換為 uintptr 型別的指標。指標運算完畢後,還要通過 unsafe.Pointer 轉換為真實的指標型別(比如示例中的 *int 型別),這樣可以對這塊記憶體進行賦值或取值操作。
  6. 有了指向欄位 Age 的指標變數 pAge,就可以對其進行賦值操作,修改欄位 Age 的值了。

執行以上示例,可以看到如下結果:

{Golang 20}

這個示例主要是為了講解 uintptr 指標運算,所以一個結構體欄位的賦值才會寫得這麼複雜,如果按照正常的編碼,以上示例程式碼會和下面的程式碼結果一樣。

func main() {
   p :=new(person)
   p.Name = "Golang"
   p.Age = 20
   fmt.Println(*p)
}

指標運算的核心在於它操作的是一個個記憶體地址,通過記憶體地址的增減,就可以指向一塊塊不同的記憶體並對其進行操作,而且不必知道這塊記憶體被起了什麼名字(變數名)。

指標轉換規則

已經知道 Go 語言中存在三種型別的指標,它們分別是:常用的 *T、unsafe.Pointer 及 uintptr。通過以上示例講解,可以總結出這三者的轉換規則:

  1. 任何型別的 *T 都可以轉換為 unsafe.Pointer;
  2. unsafe.Pointer 也可以轉換為任何型別的 *T;
  3. unsafe.Pointer 可以轉換為 uintptr;
  4. uintptr 也可以轉換為 unsafe.Pointer。

Go高階特性 16 | 非型別安全:unsafe

可以發現,unsafe.Pointer 主要用於指標型別的轉換,而且是各個指標型別轉換的橋樑。uintptr 主要用於指標運算,尤其是通過偏移量定位不同的記憶體。

unsafe.Sizeof

Sizeof 函式可以返回一個型別所佔用的記憶體大小,這個大小隻與型別有關,和型別對應的變數儲存的內容大小無關,比如 bool 型佔用一個位元組、int8 也佔用一個位元組。

通過 Sizeof 函式你可以檢視任何型別(比如字串、切片、整型)佔用的記憶體大小,示例程式碼如下:

fmt.Println(unsafe.Sizeof(true))
fmt.Println(unsafe.Sizeof(int8(0)))
fmt.Println(unsafe.Sizeof(int16(10)))
fmt.Println(unsafe.Sizeof(int32(10000000)))
fmt.Println(unsafe.Sizeof(int64(10000000000000)))
fmt.Println(unsafe.Sizeof(int(10000000000000000)))
fmt.Println(unsafe.Sizeof(string("Golang")))
fmt.Println(unsafe.Sizeof([]string{"Golang","張三"}))

對於整型來說,佔用的位元組數意味著這個型別儲存數字範圍的大小,比如 int8 佔用一個位元組,也就是 8bit,所以它可以儲存的大小範圍是 -128~127,也就是 −2^(n-1) 到 2^(n-1)−1。其中 n 表示 bit,int8 表示 8bit,int16 表示 16bit,以此類推。

對於和平臺有關的 int 型別,要看平臺是 32 位還是 64 位,會取最大的。比如本地測試以上輸出,會發現 int 和 int64 的大小是一樣的,因為用的是 64 位平臺的電腦。

小提示:一個 struct 結構體的記憶體佔用大小,等於它包含的欄位型別記憶體佔用大小之和。

總結

unsafe 包裡最常用的就是 Pointer 指標,通過它可以在 *T、uintptr 及 Pointer 三者間轉換,從而實現自己的需求,比如零記憶體拷貝或通過 uintptr 進行指標運算,這些都可以提高程式效率。

unsafe 包裡的功能雖然不安全,但的確很香,比如指標運算、型別轉換等,都可以幫助提高效能。不過還是建議儘可能地不使用,因為它可以繞開 Go 語言編譯器的檢查,可能會因為操作失誤而出現問題。當然如果是需要提高效能的必要操作,還是可以使用,比如 []byte 轉 string,就可以通過 unsafe.Pointer 實現零記憶體拷貝。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
golang

相關文章