Go指針的使用限制和突破之路
這篇文章跟大家聊一下 Go 語言指針這個話題,相較于 C 而言,Go 語言在設(shè)計(jì)時(shí)為了使用安全給指針在類型和運(yùn)算上增加了限制,這讓Go程序員既可以享受指針帶來的便利,又避免了指針的危險(xiǎn)性。除了常規(guī)的指針外,Go 語言在 unsafe 包里其實(shí)還通過 unsafe.Pointer 提供了通用指針,通過這個通用指針以及 unsafe 包的其他幾個功能又讓使用者能夠繞過 Go 語言的類型系統(tǒng)直接操作內(nèi)存進(jìn)行例如:指針類型轉(zhuǎn)換,讀寫結(jié)構(gòu)體私有成員這樣操作。網(wǎng)管覺得正是因?yàn)楣δ軓?qiáng)大同時(shí)伴隨著操作不慎讀寫了錯誤的內(nèi)存地址即會造成的嚴(yán)重后果所以 Go 語言的設(shè)計(jì)者才會把這些功能放在 unsafe 包里。其實(shí)也沒有想得那么不安全,掌握好了使用得當(dāng)還是能帶來很大的便利的,在一些偏向底層的源碼中 unsafe 包使用的頻率還是不低的。對于勵志成為高階 Gopher 的各位,這也是一項(xiàng)必不可少需要掌握的技能啦。接下來網(wǎng)管就帶大家從基本的指針使用方法和限制開始看看怎么用 unsafe 包跨過這些限制直接讀寫內(nèi)存。
基礎(chǔ)知識
指針保存著一個值的內(nèi)存地址,類型 *T代表指向T 類型值的指針。其零值為nil。
&操作符為它的操作數(shù)生成一個指針。
i?:=?42
p?=?&i
*操作符則會取出指針指向地址的值,這個操作也叫做“解引用”。
fmt.Println(*p)?//?通過指針p讀取存儲的值
*p?=?21?????????//?通過指針p設(shè)置p執(zhí)行的內(nèi)存地址存儲的值
為什么需要指針類型呢?參考一個從go101網(wǎng)站上看到的例子 :
package?main
import?"fmt"
func?double(x?int)?{?
???x?+=?x
}
func?main()?{?
???var?a?=?3?
???double(a)
????fmt.Println(a)?//?3
}
在 double 函數(shù)里將 a 翻倍,但是例子中的函數(shù)卻做不到。因?yàn)?Go 語言的函數(shù)傳參都是值傳遞。double 函數(shù)里的 x 只是實(shí)參 a 的一個拷貝,在函數(shù)內(nèi)部對 x 的操作不能反饋到實(shí)參 a。
把參數(shù)換成一個指針就可以解決這個問題了。
package?main
import?"fmt"
func?double(x?*int)?{?
???*x?+=?*x
????x?=?nil
}
func?main()?{
????var?a?=?3
????double(&a)
????fmt.Println(a)?//?6
????p?:=?&a
????double(p)
????fmt.Println(a,?p?==?nil)?//?12?false
}
上面的程序乍一看你可能對下面這一行代碼有些疑惑
x?=?nil
稍微思考一下上面說的Go語言里面參數(shù)都是值傳遞,你就會知道這一行代碼根本不影響外面的變量 a。因?yàn)閰?shù)都是值傳遞,所以函數(shù)內(nèi)的 x 也只是對 &a 的一個拷貝。
*x?+=?*x
這一句把 x 指向的值(也就是 &a 指向的值,即變量 a)變?yōu)樵瓉淼?2 倍。但是對 x 本身(一個指針)的操作卻不會影響外層的 a,所以在double函數(shù)內(nèi)部的 x=nil 不會影響外面。
指針的限制
相較于 C 語言指針的靈活,Go 語言里指針多了不少限制,不過這讓我們:既可以享受指針帶來的便利,又避免了指針的危險(xiǎn)性。下面就簡單說一下 Go 對指針操作的一些限制
限制一:指針不能參與運(yùn)算
來看一個簡單的例子:
package?main
import?"fmt"
func?main()?{
?a?:=?5
?p?:=?a
?fmt.Println(p)
?p?=?&a?+?3
}
上面的代碼將不能通過編譯,會報(bào)編譯錯誤:
invalid?operation:?&a?+?3?(mismatched?types?*int?and?int)
也就是說 Go 不允許對指針進(jìn)行數(shù)學(xué)運(yùn)算。
限制二:不同類型的指針不允許相互轉(zhuǎn)換。
下面的程序同樣也不能編譯成功:
package?main
func?main()?{
?var?a?int?=?100
?var?f?*float64
?f?=?*float64(&a)
}
限制三:不同類型的指針不能比較和相互賦值
這條限制同上面的限制二,因?yàn)橹羔樦g不能做類型轉(zhuǎn)換,所以也沒法使用==或者!=進(jìn)行比較了,同樣不同類型的指針變量相互之間不能賦值。比如下面這樣,也是會報(bào)編譯錯誤。
package?main
func?main()?{
?var?a?int?=?100
?var?f?*float64
?f?=?&a
}
Go語言的指針是類型安全的,但它有很多限制,所以 Go 還有提供了可以進(jìn)行類型轉(zhuǎn)換的通用指針,這就是 unsafe 包提供的 unsafe.Pointer。在某些情況下,它會使代碼更高效,當(dāng)然如果掌握不好就使用,也更容易讓程序崩潰。
unsafe 包
unsafe 包用于編譯階段可以繞過 Go 語言的類型系統(tǒng),直接操作內(nèi)存。例如,利用 unsafe 包操作一個結(jié)構(gòu)體的未導(dǎo)出成員。unsafe 包讓我可以直接讀寫內(nèi)存的能力。
unsafe包只有兩個類型,三個函數(shù),但是功能很強(qiáng)大。
type?ArbitraryType?int
type?Pointer?*ArbitraryType
func?Sizeof(x?ArbitraryType)?uintptr
func?Offsetof(x?ArbitraryType)?uintptr
func?Alignof(x?ArbitraryType)?uintptr
ArbitraryType是int的一個別名,在 Go 中ArbitraryType有特殊的意義。代表一個任意Go表達(dá)式類型。Pointer是int指針類型的一個別名,在 Go 中可以把任意指針類型轉(zhuǎn)換成unsafe.Pointer類型。
三個函數(shù)的參數(shù)均是ArbitraryType類型,就是接受任何類型的變量。
Sizeof接受任意類型的值(表達(dá)式),返回其占用的字節(jié)數(shù),這和c語言里面不同,c語言里面sizeof函數(shù)的參數(shù)是類型,而這里是一個值,比如一個變量。Offsetof:返回結(jié)構(gòu)體成員在內(nèi)存中的位置距離結(jié)構(gòu)體起始處的字節(jié)數(shù),所傳參數(shù)必須是結(jié)構(gòu)體的成員(結(jié)構(gòu)體指針指向的地址就是結(jié)構(gòu)體起始處的地址,即第一個成員的內(nèi)存地址)。Alignof返回變量對齊字節(jié)數(shù)量,這個函數(shù)雖然接收的是任何類型的變量,但是有一個前提,就是變量要是一個struct類型,且還不能直接將這個struct類型的變量當(dāng)作參數(shù),只能將這個struct類型變量的值當(dāng)作參數(shù),具體細(xì)節(jié)咱們到以后聊內(nèi)存對齊的文章里再說。
注意以上三個函數(shù)返回的結(jié)果都是 uintptr 類型,這和 unsafe.Pointer 可以相互轉(zhuǎn)換。三個函數(shù)都是在編譯期間執(zhí)行
unsafe.Pointer
unsafe.Pointer稱為通用指針,官方文檔對該類型有四個重要描述:
任何類型的指針都可以被轉(zhuǎn)化為Pointer Pointer可以被轉(zhuǎn)化為任何類型的指針 uintptr可以被轉(zhuǎn)化為Pointer Pointer可以被轉(zhuǎn)化為uintptr
unsafe.Pointer是特別定義的一種指針類型(譯注:類似C語言中的void類型的指針),在Go 語言中是用于各種指針相互轉(zhuǎn)換的橋梁,它可以持有任意類型變量的地址。
什么叫"可以持有任意類型變量的地址"呢?意思就是使用 unsafe.Pointer 轉(zhuǎn)換的變量,該變量一定要是指針類型,否則編譯會報(bào)錯。
a?:=?1
b?:=?unsafe.Pointer(a)?//報(bào)錯
b?:=?unsafe.Pointer(&a)?//?正確
和普通指針一樣,unsafe.Pointer 指針也是可以比較的,并且支持和 nil 比較判斷是否為空指針。
unsafe.Pointer 不能直接進(jìn)行數(shù)學(xué)運(yùn)算,但可以把它轉(zhuǎn)換成 uintptr,對 uintptr 類型進(jìn)行數(shù)學(xué)運(yùn)算,再轉(zhuǎn)換成 unsafe.Pointer 類型。
//?uintptr、unsafe.Pointer和普通指針之間的轉(zhuǎn)換關(guān)系
uintptr?<==>?unsafe.Pointer?<==>?*T
uintptr
uintptr是 Go 語言的內(nèi)置類型,是能存儲指針的整型,在64位平臺上底層的數(shù)據(jù)類型是 uint64。
//?uintptr?is?an?integer?type?that?is?large?enough?to?hold?the?bit?pattern?of
//?any?pointer.
type?uintptr?uintptr
typedef?unsigned?long?long?int??uint64;
typedef?uint64??????????uintptr;
一個unsafe.Pointer指針也可以被轉(zhuǎn)化為uintptr類型,然后保存到uintptr類型的變量中(注:這個變量只是和當(dāng)前指針有相同的一個數(shù)字值,并不是一個指針),然后用以做必要的指針數(shù)值運(yùn)算。(uintptr是一個無符號的整型數(shù),足以保存一個地址)這種轉(zhuǎn)換雖然也是可逆的,但是隨便將一個 uintptr 轉(zhuǎn)為 unsafe.Pointer指針可能會破壞類型系統(tǒng),因?yàn)椴⒉皇撬械臄?shù)字都是有效的內(nèi)存地址。
還有一點(diǎn)要注意的是,uintptr 并沒有指針的語義,意思就是存儲 uintptr 值的內(nèi)存地址在Go發(fā)生GC時(shí)會被回收。而 unsafe.Pointer 有指針語義,可以保護(hù)它不會被垃圾回收。
聊了這么多概念性的話題,接下來網(wǎng)管帶大家一起看看怎么使用 unsafe.Pointer 進(jìn)行指針轉(zhuǎn)換以及結(jié)合 uintptr 讀寫結(jié)構(gòu)體的私有成員。
應(yīng)用示例
使用unsafe.Pointer進(jìn)行指針類型轉(zhuǎn)換
import?(
????"fmt"
????"reflect"
????"unsafe"
)
?
func?main()?{
?
????v1?:=?uint(12)
????v2?:=?int(13)
?
????fmt.Println(reflect.TypeOf(v1))?//uint
????fmt.Println(reflect.TypeOf(v2))?//int
?
????fmt.Println(reflect.TypeOf(&v1))?//*uint
????fmt.Println(reflect.TypeOf(&v2))?//*int
?
????p?:=?&v1
????p?=?(*uint)(unsafe.Pointer(&v2))?//使用unsafe.Pointer進(jìn)行類型的轉(zhuǎn)換
?
????fmt.Println(reflect.TypeOf(p))?//?*unit
????fmt.Println(*p)?//13
}
使用unsafe.Pointer 讀寫結(jié)構(gòu)體的私有成員
通過 Offsetof 方法可以獲取結(jié)構(gòu)體成員的偏移量,進(jìn)而獲取成員的地址,讀寫該地址的內(nèi)存,就可以達(dá)到改變成員值的目的。
這里有一個內(nèi)存分配相關(guān)的事實(shí):結(jié)構(gòu)體會被分配一塊連續(xù)的內(nèi)存,結(jié)構(gòu)體的地址也代表了第一個成員的地址。
package?main
?
import?(
????"fmt"
????"unsafe"
)
?
func?main()?{
?
????var?x?struct?{
????????a?int
????????b?int
????????c?[]int
????}
?
????// unsafe.Offsetof 函數(shù)的參數(shù)必須是一個字段, ?比如 x.b, ?方法會返回 b 字段相對于 x 起始地址的偏移量, 包括可能的空洞。
????//?指針運(yùn)算 uintptr(unsafe.Pointer(&x))?+ unsafe.Offsetof(x.b)。
????
????//?和?pb?:=?&x.b?等價(jià)
????pb?:=?(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&x))?+?unsafe.Offsetof(x.b)))
????*pb?=?42
????fmt.Println(x.b)?//?"42"
}
上面的寫法盡管很繁瑣,但在這里并不是一件壞事,因?yàn)檫@些功能應(yīng)該很謹(jǐn)慎地使用。不要試圖引入一個uintptr類型的臨時(shí)變量,因?yàn)樗赡軙茐拇a的安全性
如果改為下面這種用法是有風(fēng)險(xiǎn)的:
tmp?:=?uintptr(unsafe.Pointer(&x))?+?unsafe.Offsetof(x.b)
pb?:=?(*int16)(unsafe.Pointer(tmp))
*pb?=?42
隨著程序執(zhí)行的進(jìn)行,goroutine 會經(jīng)常發(fā)生棧擴(kuò)容或者棧縮容,會把舊棧內(nèi)存的數(shù)據(jù)拷貝到新棧區(qū)然后更改所有指針的指向。一個 unsafe.Pointer 是一個指針,因此當(dāng)它指向的數(shù)據(jù)被移動到新棧區(qū)后指針也會被更新。但是uintptr 類型的臨時(shí)變量只是一個普通的數(shù)字,所以其值不會該被改變。上面錯誤的代碼因?yàn)橐胍粋€非指針的臨時(shí)變量 tmp,導(dǎo)致系統(tǒng)無法正確識別這個是一個指向變量 x 的指針。當(dāng)?shù)诙€語句執(zhí)行時(shí),變量 x 的數(shù)據(jù)可能已經(jīng)被轉(zhuǎn)移,這時(shí)候臨時(shí)變量tmp也就不再是現(xiàn)在的 &x.b 的地址。第三個語句向之前無效地址空間的賦值語句將讓整個程序崩潰。
string 和 []byte 零拷貝轉(zhuǎn)換
這是一個非常經(jīng)典的例子。實(shí)現(xiàn)字符串和 bytes 切片之間的零拷貝轉(zhuǎn)換。
string和[]byte 在運(yùn)行時(shí)的類型表示為reflect.StringHeader和reflect.SliceHeader
type?SliceHeader?struct?{
?Data?uintptr
?Len??int
?Cap??int
}
type?StringHeader?struct?{
?Data?uintptr
?Len??int
}
只需要共享底層 []byte 數(shù)組就可以實(shí)現(xiàn)零拷貝轉(zhuǎn)換。
代碼比較簡單,不作詳細(xì)解釋。通過構(gòu)造reflect.StringHeader和reflect.SliceHeader,來完成 string 和 []byte 之間的轉(zhuǎn)換。
import?(
?"fmt"
?"reflect"
?"unsafe"
)
func?main()?{
?s?:=?"Hello?World"
?b?:=?string2bytes(s)
?fmt.Println(b)
?s?=?bytes2string(b)
?fmt.Println(s)
}
func?string2bytes(s?string)?[]byte?{
?stringHeader?:=?(*reflect.StringHeader)(unsafe.Pointer(&s))
?bh?:=?reflect.SliceHeader{
??Data:?stringHeader.Data,
??Len:?stringHeader.Len,
??Cap:?stringHeader.Len,
?}
?return?*(*[]byte)(unsafe.Pointer(&bh))
}
func?bytes2string(b?[]byte)?string?{
?sliceHeader?:=?(*reflect.SliceHeader)(unsafe.Pointer(&b))
?sh?:=?reflect.StringHeader{
??Data:?sliceHeader.Data,
??Len:??sliceHeader.Len,
?}
?return?*(*string)(unsafe.Pointer(&sh))
}
總結(jié)
Go 的源碼中也在大量使用 unsafe 包,通過 unsafe 包繞過 Go 指針的限制,達(dá)到直接操作內(nèi)存的目的,使用它有一定的風(fēng)險(xiǎn)性,但是在一些場景下,可以提升代碼的效率。
參考資料
unsafe.Pointer 和 uintptr :https://www.cnblogs.com/echojson/p/10743530.html
推薦閱讀
