Go語言類型轉(zhuǎn)換及問題探討
今天我們來說說一個大家每天都在做但很少深入思考的操作——類型轉(zhuǎn)換。
一行奇怪的代碼
事情始于年初時我對標準庫sync做一些改動的時候。
改動會用到標準庫在1.19新添加的atomic.Pointer
,出于謹慎,我在進行變更之前泛泛通讀了一遍它的代碼,然而一行代碼引起了我的注意:
// A Pointer is an atomic pointer of type *T. The zero value is a nil *T. type Pointer[T any] struct { // Mention *T in a field to disallow conversion between Pointer types. // See go.dev/issue/56603 for more details. // Use *T, not T, to avoid spurious recursive type definition errors. _ [0]*T _ noCopy v unsafe.Pointer }
并不是noCopy,這個我在golang拾遺:實現(xiàn)一個不可復(fù)制類型詳細講解過。
引起我注意的地方是_ [0]*T
,它是個匿名字段,且長度為零的數(shù)組不會占用內(nèi)存。這并不影響我要修改的代碼,但它的作用是什么引起了我的好奇。
還好這個字段自己的注釋給出了答案:這個字段是為了防止錯誤的類型轉(zhuǎn)換。什么樣的類型轉(zhuǎn)換需要加這個字段來封鎖呢。帶著疑問我點開了給出的issue鏈接,然后看到了下面的例子:
package main import ( "math" "sync/atomic" ) type small struct { small [64]byte } type big struct { big [math.MaxUint16 * 10]byte } func main() { a := atomic.Pointer[small]{} a.Store(&small{}) b := atomic.Pointer[big](a) // type conversion big := b.Load() for i := range big.big { big.big[i] = 1 } }
例子程序會導(dǎo)致內(nèi)存錯誤,在Linux環(huán)境上它會有很大概率導(dǎo)致段錯誤。為什么呢?因為big的索引值大大超過了small的范圍,而我們實際上在Pointer只存了一個small對象,所以在最后的循環(huán)那里我們發(fā)生了索引越界,而且go并沒有檢測到這個越界。
當然,go也沒有義務(wù)去檢測這種越界,因為用了unsafe(atomic.Pointer是對unsafe.Pointer的包裝)之后類型安全和內(nèi)存安全就只能靠用戶自己來負責了。
這里根本上的問題在于,atomic.Pointer[small]
和atomic.Pointer[big]
之間沒有任何關(guān)聯(lián),它們應(yīng)該是完全不同的類型不應(yīng)該發(fā)生轉(zhuǎn)換(如果對此有疑惑,可以搜索下類型構(gòu)造器相關(guān)的資料,通常這種泛型的類型構(gòu)造器產(chǎn)生的類型之間是不應(yīng)該有任何關(guān)聯(lián)性的),尤其是go是一門強類型語言,類似的事情在c++無法通過編譯而在python里則會運行時報錯。
但事實是在沒添加開頭的那個字段前這種轉(zhuǎn)換是合法的而且在泛型類型中很容易出現(xiàn)。
到這里你可能還是有點云里霧里,不過沒關(guān)系,看完下一節(jié)你會云開霧散的。
go的類型轉(zhuǎn)換
golang里不存在隱式類型轉(zhuǎn)換,因此想要將一個類型的值轉(zhuǎn)換成另一個類型,只能用這樣的表達式Type(value)
。表達式會把value復(fù)制一份然后轉(zhuǎn)換成Type類型。
對于無類型常量規(guī)則要稍微靈活一些,它們可以在上下文里自動轉(zhuǎn)換成相應(yīng)的類型,詳見我的另一篇文章golang中的無類型常量。
拋開常量和cgo,golang的類型轉(zhuǎn)換可以分為好幾類,我們先來看一些比較常見的類型。
數(shù)值類型之間互相轉(zhuǎn)換
這是相當常見的轉(zhuǎn)換。
這個其實沒什么好說的,大家應(yīng)該每天都會寫類似的代碼:
c := int(a+b) d := float64(c)
數(shù)值類型之間可以相互轉(zhuǎn)換,整數(shù)和浮點之間也會按照相應(yīng)的規(guī)則進行轉(zhuǎn)換。數(shù)值在必要的時候會發(fā)生回繞/截斷。
這個轉(zhuǎn)換相對來說也比較安全,唯一要注意的是溢出。
unsafe相關(guān)的轉(zhuǎn)換
unsafe.Pointer
和所有的指針類型之間都可以互相轉(zhuǎn)換,但從unsafe.Pointer
轉(zhuǎn)換回來不保證類型安全。
unsafe.Pointer
和uintptr
之間也可以互相轉(zhuǎn)換,后者主要是一些系統(tǒng)級api需要使用。
這些轉(zhuǎn)換在go的runtime以及一些重度依賴系統(tǒng)編程的代碼里經(jīng)常出現(xiàn)。這些轉(zhuǎn)換很危險,建議非必要不使用。
字符串到byte和rune切片的轉(zhuǎn)換
這個轉(zhuǎn)換的出現(xiàn)頻率應(yīng)該僅次于數(shù)值轉(zhuǎn)換:
fmt.Println([]byte("hello")) fmt.Println(string([]byte{104, 101, 108, 108, 111}))
這個轉(zhuǎn)換go做了不少優(yōu)化,所以有時候行為和普通的類型轉(zhuǎn)換有點出入,比如很多時候數(shù)據(jù)復(fù)制會被優(yōu)化掉。
rune就不舉例了,代碼上沒有太大的差別。
slice轉(zhuǎn)換成數(shù)組
go1.20之后允許slice轉(zhuǎn)換成數(shù)組,在復(fù)制范圍內(nèi)的slice的元素會被復(fù)制:
s := []int{1,2,3,4,5} a := [3]int(s) a[2] = 100 fmt.Println(s) // [1 2 3 4 5] fmt.Println(a) // [1 2 100]
如果數(shù)組的長度超過了slice的長度(注意不是cap),則會panic。轉(zhuǎn)換成數(shù)組的指針也是可以的,規(guī)則完全相同。
底層類型相同時的轉(zhuǎn)換
上面討論的幾種雖然很常見,但其實都可以算是特例。因為這些轉(zhuǎn)換只限于特定的類型之間且編譯器會識別這些轉(zhuǎn)換并生成不同的代碼。
但go其實還允許一類更寬泛的不需要那么多特殊處理的轉(zhuǎn)換:底層類型相同的類型之間可以互相轉(zhuǎn)換。
舉個例子:
type A struct { a int b *string c bool } type B struct { a int b *string c bool } type B1 struct { a1 int b *string c bool } type A1 B type C int type D int
A和B是完全不同的類型,但它們的底層類型都是struct{a int;b *string;c bool;}
。C和D也是完全不同的類型,但它們的底層類型都是int。A1派生自B,A1和B有著相同的底層類型,所有A1和A也有相同的底層類型。B1因為有個字段的名字和別人都不一樣,所以沒人和它的底層類型相同。
粗暴一點說,底層類型(underlying type)是各種內(nèi)置類型(int,string,slice,map,...)以及struct{...}
(字段名和是否export會被考慮進去)。內(nèi)置類型和struct{...}
的底層類型就是自己。
只要底層類型相同,類型之間就能互相轉(zhuǎn)換:
func main() { text := "hello" a := A{1, &text, false} a1 := A1(a) fmt.Printf("%#v\n", a1) // main.A1{a:1, b:(*string)(0xc000014070), c:false} }
A1和B還能算有點關(guān)系,但和A是真的八竿子打不著,我們的程序可以編譯并且運行的很好。這就是底層類型相同的類型之間可以互相轉(zhuǎn)換的規(guī)則導(dǎo)致的。
另外struct tag在轉(zhuǎn)換中是會被忽略的,因此只要字段名字和類型相同,不管tag是不是相同的都可以進行轉(zhuǎn)換。
這條規(guī)則允許了一些沒有關(guān)系的類型進行雙向的轉(zhuǎn)換,咋一看好像這個規(guī)則是在亂來,但這玩意兒也不是完全沒用:
type IP []byte
考慮這樣一個類型,IP可以表示為一串byte的序列,這是RFC文檔上明確說明的,所以我們這么定義合情合理(事實上大家也都是這么干的)。因為是byte的序列,所以我們自然會把一些處理byte切片的方法/函數(shù)用在IP上以實現(xiàn)代碼復(fù)用和簡化開發(fā)。
問題是這些代碼都假定自己的參數(shù)/返回值是[]byte
而不是IP,我們知道IP其實就是[]byte
,但go不允許隱式類型轉(zhuǎn)換,所以直接拿IP的值去掉這些函數(shù)是不行的??紤]一下如果沒有底層類型相同的類型之間可以相互轉(zhuǎn)換這個規(guī)則,我們要怎么復(fù)用這些函數(shù)呢,肯定只能走一些unsafe的歪門邪道了。與其這樣不如允許[]byte(ip)
和IP(bytes)
的轉(zhuǎn)換。
為啥不限制住只允許像IP
和[]byte
之間這樣的轉(zhuǎn)換呢?因為這樣會導(dǎo)致類型檢查變得復(fù)雜還要拖累編譯速度,go最看重的就是編譯器代碼簡單以及編譯速度快,自然不愿意多檢查這些東西,不如直接放開標準讓底層類型相同類型的互相轉(zhuǎn)換來的簡單快捷。
但這個規(guī)則是很危險的,正是它導(dǎo)致了前面說的atomic.Pointer
的問題。
我們看下初版的atomic.Pointer
的代碼:
type Pointer[T any] struct { _ noCopy v unsafe.Pointer }
類型參數(shù)只是在Store
和Load
的時候用來進行unsafe.Pointer
到正常指針之間的類型轉(zhuǎn)換的。這會導(dǎo)致一個致命缺陷:所有atomic.Pointer
都會有相同的底層類型struct{_ noCopy;v unsafe.Pointer;}
。
所以不管是atomic.Pointer[A]
,atomic.Pointer[B]
還是atomic.Pointer[small]
和atomic.Pointer[big]
,它們都有相同的底層類型,它們之間可以任意進行轉(zhuǎn)換。
這下就徹底亂了套,雖說用戶得自己為unsafe負責,但這種明擺著的甚至本來就不該編譯通過的錯誤現(xiàn)在卻可以在用戶毫無防備的情況下出現(xiàn)在代碼里——普通開發(fā)者可不會花時間關(guān)心標準庫是怎么實現(xiàn)的所以不知道atomic.Pointer
和unsafe有什么關(guān)系。
go的開發(fā)者最后添加了_ [0]*T
,這樣對于實例化的每一個atomic.Pointer
,只要T不同,它們的底層類型就會不同,上面的錯誤的類型轉(zhuǎn)換就不可能發(fā)生。而且選用*T
還能防止自引用導(dǎo)致atomic.Pointer[atomic.Pointer[...]]
這樣的代碼編譯報錯。
現(xiàn)在你應(yīng)該也能理解為什么我說泛型類型最容易遇見這種問題了:只要你的泛型類型是個結(jié)構(gòu)體或者其他復(fù)合類型,但在字段或者復(fù)合類型中沒有使用到泛型類型參數(shù),那么從這個泛型類型實例化出來的所有類型就有可能有相同的底層類型,從而允許issue里描述的那種完全錯誤的類型轉(zhuǎn)換出現(xiàn)。
別的語言里是個啥情況
對于結(jié)構(gòu)化類型語言,像go這樣底層類型相同就可以互相轉(zhuǎn)換屬于基操,不同語言會適當放寬/限制這種轉(zhuǎn)換。說白了就是只認結(jié)構(gòu)不認其他的,結(jié)構(gòu)相同的東西你怎么折騰都算是同一類。因此issue描述的問題在這些語言里屬于not even wrong這個級別,需要改變設(shè)計來回避類似的問題。
對于使用名義類型系統(tǒng)的語言,名字相同的算同一類不同的哪怕結(jié)構(gòu)上一樣也是不同類型。順帶一提,c++、golang、rust都屬于這一類型。golang的底層類型雖然在類型轉(zhuǎn)換和類型約束上表現(xiàn)得像結(jié)構(gòu)化類型,但總體行為上仍然偏向于名義類型,官方并沒有明確定義自己到底是哪種類型系統(tǒng),所以權(quán)當是我的一家之言也行。
完全的結(jié)構(gòu)化類型語言不怎么多見,我們就以常見的名義類型語言c++和使用鴨子類型的python為例。
在python中我們可以自定義類型的構(gòu)造函數(shù),因此可以在構(gòu)造函數(shù)中實現(xiàn)類型轉(zhuǎn)換的邏輯,如果我們沒有自定義構(gòu)造函數(shù)或者其他的可以返回新類型的類方法,那兩個類型之間默認是無法進行轉(zhuǎn)換。所以在python中是不會出現(xiàn)和go一樣的問題的。
c++和python類似,用戶不自定義的話默認不會存在任何轉(zhuǎn)換途徑。和python不一樣的地方在于c++除了構(gòu)造函數(shù)之外還有轉(zhuǎn)換運算符并且支持在規(guī)則限制下的隱式轉(zhuǎn)換。用戶需要自己定義轉(zhuǎn)換構(gòu)造函數(shù)/轉(zhuǎn)換運算符并且在語法規(guī)則的限制下才能實現(xiàn)兩個不同類型間的轉(zhuǎn)換,這個轉(zhuǎn)換是單向還是雙向和python一樣由用戶自己控制。所以c++中也不存在go的問題。
還有rust、Java、...我就不一一列舉了。
總而言之這也是go大道至簡的一個側(cè)面——創(chuàng)造一些別的語言里很難出現(xiàn)的問題然后用簡潔的手段去修復(fù)。
總結(jié)
我們復(fù)習了go里的類型轉(zhuǎn)換,還順便踩了一個相關(guān)的坑。
在這里給幾個建議:
- 想用泛型又不想踩坑:盡量在結(jié)構(gòu)體字段或者復(fù)合類型里使用泛型類型參數(shù),使用
_ [0]*T
這樣的字段不僅使代碼難以理解,還會讓類型的初始化變麻煩,不到atomic.Pointer
這樣萬不得以的時候我并不推薦使用。 - 不用泛型但害怕別的類型和自己的類型有相同的底層類型:不用怕,在自定義類型上少用類型轉(zhuǎn)換的語法就行了,如果你真的需要在相關(guān)自定義類型之間轉(zhuǎn)換,定義一些
toTypeA
之類的方法,這樣轉(zhuǎn)換過程就是你控制的不再是go默認的了。 - 在內(nèi)置類型和基于這些類型的自定義類型之間轉(zhuǎn)換:這個沒啥好擔心的,因為本就是你就是我我就是你的關(guān)系。實在覺得不舒服可以不用
type T []int
,把類型定義換成type T struct { data []int }
,代價除了代碼變啰嗦外還有很多接受切片參數(shù)的函數(shù)和range循環(huán)沒法直接用了。
像go這樣在簡單的語法規(guī)則里暗藏殺機的語言還是挺有意思的,如果只想著速成的話指不定什么時候就踩到地雷了。
到此這篇關(guān)于Go語言類型轉(zhuǎn)換及問題探討的文章就介紹到這了,更多相關(guān)Go語言類型轉(zhuǎn)換內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go語言中int、float、string類型之間相互的轉(zhuǎn)換
golang是強類型語言,在應(yīng)用過程中類型轉(zhuǎn)換基本都會用到,下面這篇文章主要給大家介紹了關(guān)于Go語言中int、float、string類型相互之間的轉(zhuǎn)換,需要的朋友可以參考下2022-01-01Go語言基礎(chǔ)Json序列化反序列化及文件讀寫示例詳解
這篇文章主要為大家介紹了Go語言基礎(chǔ)Json序列化反序列化以及文件讀寫的示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助2021-11-11Golang多線程下載器實現(xiàn)高效快速地下載大文件
Golang多線程下載器是一種高效、快速地下載大文件的方法。Golang語言天生支持并發(fā)和多線程,可以輕松實現(xiàn)多線程下載器的開發(fā)。通過使用Golang的協(xié)程和通道,可以將下載任務(wù)分配到多個線程中并行處理,提高了下載的效率和速度2023-05-05