golang的型別轉換

apocelipes發表於2024-09-30

今天我們來說說一個大家每天都在做但很少深入思考的操作——型別轉換。

本文索引

  • 一行奇怪的程式碼
  • go的型別轉換
    • 數值型別之間互相轉換
    • unsafe相關的轉換
    • 字串到byte和rune切片的轉換
    • slice轉換成陣列
    • 底層型別相同時的轉換
  • 別的語言裡是個啥情況
  • 總結

一行奇怪的程式碼

事情始於年初時我對標準庫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拾遺:實現一個不可複製型別詳細講解過。

引起我注意的地方是_ [0]*T,它是個匿名欄位,且長度為零的陣列不會佔用記憶體。這並不影響我要修改的程式碼,但它的作用是什麼引起了我的好奇。

還好這個欄位自己的註釋給出了答案:這個欄位是為了防止錯誤的型別轉換。什麼樣的型別轉換需要加這個欄位來封鎖呢。帶著疑問我點開了給出的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
	}
}

例子程式會導致記憶體錯誤,在Linux環境上它會有很大機率導致段錯誤。為什麼呢?因為big的索引值大大超過了small的範圍,而我們實際上在Pointer只存了一個small物件,所以在最後的迴圈那裡我們發生了索引越界,而且go並沒有檢測到這個越界。

當然,go也沒有義務去檢測這種越界,因為用了unsafe(atomic.Pointer是對unsafe.Pointer的包裝)之後型別安全和記憶體安全就只能靠使用者自己來負責了。

這裡根本上的問題在於,atomic.Pointer[small]atomic.Pointer[big]之間沒有任何關聯,它們應該是完全不同的型別不應該發生轉換(如果對此有疑惑,可以搜尋下型別構造器相關的資料,通常這種泛型的型別構造器產生的型別之間是不應該有任何關聯性的),尤其是go是一門強型別語言,類似的事情在c++無法透過編譯而在python裡則會執行時報錯。

但事實是在沒新增開頭的那個欄位前這種轉換是合法的而且在泛型型別中很容易出現。

到這裡你可能還是有點雲裡霧裡,不過沒關係,看完下一節你會雲開霧散的。

go的型別轉換

golang裡不存在隱式型別轉換,因此想要將一個型別的值轉換成另一個型別,只能用這樣的表示式Type(value)。表示式會把value複製一份然後轉換成Type型別。

對於無型別常量規則要稍微靈活一些,它們可以在上下文裡自動轉換成相應的型別,詳見我的另一篇文章golang中的無型別常量

拋開常量和cgo,golang的型別轉換可以分為好幾類,我們先來看一些比較常見的型別。

數值型別之間互相轉換

這是相當常見的轉換。

這個其實沒什麼好說的,大家應該每天都會寫類似的程式碼:

c := int(a+b)
d := float64(c)

數值型別之間可以相互轉換,整數和浮點之間也會按照相應的規則進行轉換。數值在必要的時候會發生迴繞/截斷。

這個轉換相對來說也比較安全,唯一要注意的是溢位。

unsafe相關的轉換

unsafe.Pointer和所有的指標型別之間都可以互相轉換,但從unsafe.Pointer轉換回來不保證型別安全。

unsafe.Pointeruintptr之間也可以互相轉換,後者主要是一些系統級api需要使用。

這些轉換在go的runtime以及一些重度依賴系統程式設計的程式碼裡經常出現。這些轉換很危險,建議非必要不使用。

字串到byte和rune切片的轉換

這個轉換的出現頻率應該僅次於數值轉換:

fmt.Println([]byte("hello"))
fmt.Println(string([]byte{104, 101, 108, 108, 111}))

這個轉換go做了不少最佳化,所以有時候行為和普通的型別轉換有點出入,比如很多時候資料複製會被最佳化掉。

rune就不舉例了,程式碼上沒有太大的差別。

slice轉換成陣列

go1.20之後允許slice轉換成陣列,在複製範圍內的slice的元素會被複制:

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]

如果陣列的長度超過了slice的長度(注意不是cap),則會panic。轉換成陣列的指標也是可以的,規則完全相同。

底層型別相同時的轉換

上面討論的幾種雖然很常見,但其實都可以算是特例。因為這些轉換隻限於特定的型別之間且編譯器會識別這些轉換並生成不同的程式碼。

但go其實還允許一類更寬泛的不需要那麼多特殊處理的轉換:底層型別相同的型別之間可以互相轉換。

舉個例子:

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)是各種內建型別(int,string,slice,map,...)以及struct{...}(欄位名和是否export會被考慮進去)。內建型別和struct{...}的底層型別就是自己。

只要底層型別相同,型別之間就能互相轉換:

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還能算有點關係,但和A是真的八竿子打不著,我們的程式可以編譯並且執行的很好。這就是底層型別相同的型別之間可以互相轉換的規則導致的。

另外struct tag在轉換中是會被忽略的,因此只要欄位名字和型別相同,不管tag是不是相同的都可以進行轉換。

這條規則允許了一些沒有關係的型別進行雙向的轉換,咋一看好像這個規則是在亂來,但這玩意兒也不是完全沒用:

type IP []byte

考慮這樣一個型別,IP可以表示為一串byte的序列,這是RFC文件上明確說明的,所以我們這麼定義合情合理(事實上大家也都是這麼幹的)。因為是byte的序列,所以我們自然會把一些處理byte切片的方法/函式用在IP上以實現程式碼複用和簡化開發。

問題是這些程式碼都假定自己的引數/返回值是[]byte而不是IP,我們知道IP其實就是[]byte,但go不允許隱式型別轉換,所以直接拿IP的值去掉這些函式是不行的。考慮一下如果沒有底層型別相同的型別之間可以相互轉換這個規則,我們要怎麼複用這些函式呢,肯定只能走一些unsafe的歪門邪道了。與其這樣不如允許[]byte(ip)IP(bytes)的轉換。

為啥不限制住只允許像IP[]byte之間這樣的轉換呢?因為這樣會導致型別檢查變得複雜還要拖累編譯速度,go最看重的就是編譯器程式碼簡單以及編譯速度快,自然不願意多檢查這些東西,不如直接放開標準讓底層型別相同型別的互相轉換來的簡單快捷。

但這個規則是很危險的,正是它導致了前面說的atomic.Pointer的問題。

我們看下初版的atomic.Pointer的程式碼:

type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

型別引數只是在StoreLoad的時候用來進行unsafe.Pointer到正常指標之間的型別轉換的。這會導致一個致命缺陷:所有atomic.Pointer都會有相同的底層型別struct{_ noCopy;v unsafe.Pointer;}

所以不管是atomic.Pointer[A]atomic.Pointer[B]還是atomic.Pointer[small]atomic.Pointer[big],它們都有相同的底層型別,它們之間可以任意進行轉換。

這下就徹底亂了套,雖說使用者得自己為unsafe負責,但這種明擺著的甚至本來就不該編譯透過的錯誤現在卻可以在使用者毫無防備的情況下出現在程式碼裡——普通開發者可不會花時間關心標準庫是怎麼實現的所以不知道atomic.Pointer和unsafe有什麼關係。

go的開發者最後新增了_ [0]*T,這樣對於例項化的每一個atomic.Pointer,只要T不同,它們的底層型別就會不同,上面的錯誤的型別轉換就不可能發生。而且選用*T還能防止自引用導致atomic.Pointer[atomic.Pointer[...]]這樣的程式碼編譯報錯。

現在你應該也能理解為什麼我說泛型型別最容易遇見這種問題了:只要你的泛型型別是個結構體或者其他複合型別,但在欄位或者複合型別中沒有使用到泛型型別引數,那麼從這個泛型型別例項化出來的所有型別就有可能有相同的底層型別,從而允許issue裡描述的那種完全錯誤的型別轉換出現。

別的語言裡是個啥情況

對於結構化型別語言,像go這樣底層型別相同就可以互相轉換屬於基操,不同語言會適當放寬/限制這種轉換。說白了就是隻認結構不認其他的,結構相同的東西你怎麼折騰都算是同一類。因此issue描述的問題在這些語言裡屬於not even wrong這個級別,需要改變設計來回避類似的問題。

對於使用名義型別系統的語言,名字相同的算同一類不同的哪怕結構上一樣也是不同型別。順帶一提,c++、golang、rust都屬於這一型別。golang的底層型別雖然在型別轉換和型別約束上表現得像結構化型別,但總體行為上仍然偏向於名義型別,官方並沒有明確定義自己到底是哪種型別系統,所以權當是我的一家之言也行。

完全的結構化型別語言不怎麼多見,我們就以常見的名義型別語言c++和使用鴨子型別的python為例。

在python中我們可以自定義型別的建構函式,因此可以在建構函式中實現型別轉換的邏輯,如果我們沒有自定義建構函式或者其他的可以返回新型別的類方法,那兩個型別之間預設是無法進行轉換。所以在python中是不會出現和go一樣的問題的。

c++和python類似,使用者不自定義的話預設不會存在任何轉換途徑。和python不一樣的地方在於c++除了建構函式之外還有轉換運算子並且支援在規則限制下的隱式轉換。使用者需要自己定義轉換建構函式/轉換運算子並且在語法規則的限制下才能實現兩個不同型別間的轉換,這個轉換是單向還是雙向和python一樣由使用者自己控制。所以c++中也不存在go的問題。

還有rust、Java、...我就不一一列舉了。

總而言之這也是go大道至簡的一個側面——創造一些別的語言裡很難出現的問題然後用簡潔的手段去修復。

總結

我們複習了go裡的型別轉換,還順便踩了一個相關的坑。

在這裡給幾個建議:

  • 想用泛型又不想踩坑:儘量在結構體欄位或者複合型別裡使用泛型型別引數,使用_ [0]*T這樣的欄位不僅使程式碼難以理解,還會讓型別的初始化變麻煩,不到atomic.Pointer這樣萬不得以的時候我並不推薦使用。
  • 不用泛型但害怕別的型別和自己的型別有相同的底層型別:不用怕,在自定義型別上少用型別轉換的語法就行了,如果你真的需要在相關自定義型別之間轉換,定義一些toTypeA之類的方法,這樣轉換過程就是你控制的不再是go預設的了。
  • 在內建型別和基於這些型別的自定義型別之間轉換:這個沒啥好擔心的,因為本就是你就是我我就是你的關係。實在覺得不舒服可以不用type T []int,把型別定義換成type T struct { data []int },代價除了程式碼變囉嗦外還有很多接受切片引數的函式和range迴圈沒法直接用了。

像go這樣在簡單的語法規則裡暗藏殺機的語言還是挺有意思的,如果只想著速成的話指不定什麼時候就踩到地雷了。

相關文章