golang的類型轉換

来源:https://www.cnblogs.com/apocelipes/p/18441034
-Advertisement-
Play Games

今天我們來說說一個大家每天都在做但很少深入思考的操作——類型轉換。 本文索引 一行奇怪的代碼 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這樣在簡單的語法規則里暗藏殺機的語言還是挺有意思的,如果只想著速成的話指不定什麼時候就踩到地雷了。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 測試工作在Java工程項目中的作用不可或缺。測試驅動和模型驅動以及迭代開發。項目的測試工作分為黑盒測試和白盒測試。黑盒測試並不會讓你知道很多讓你不應該知道的細節。白盒測試透明,項目組的開發人員也是不能觸碰。程式設計的編寫開發人員主要工作是編寫項目的源代碼,完成需求說明書分配下來的項目排期計劃。開發分 ...
  • 退休模式需求分析 退休模式2+1無限鏈動模式詳細分析: 退休模式是商城平臺基於投資者用戶開發的一套區塊鏈市場投資返利方式。 投資者用戶的註冊額度:499元 投資者的推薦費用:100元 1指的是老闆投資者 2 指的是老闆所推薦的代理投資者 2+1 無限鏈動模式的資金籌集方式 平臺註冊的投資者老闆推薦代 ...
  • Java中的操作日誌模塊的開發和運行維護都是十分耗時耗力。操作日誌的收集涉及到公司的項目或者是上市產品的用戶體驗和反饋。後端和前端開發工程師的日常工作就是對運行維護工程師收集回來的項目和產品的反饋進行系統級別的分析以及需求下發迭代開發。操作日誌的列印方式分為線下列印和線上的日誌列印。線下的系統操作日 ...
  • 版本:rustc 1.81.0 (eeb90cda1 2024-09-04) 報錯情況如下圖: 摸索了後,總結一下關鍵解決方法: 從微軟體官網: https://visualstudio.microsoft.com/zh-hans/downloads/ 找到選項“用於 Visual Studio 的 ...
  • Java中的Date 為什麼用類表示日期,而不是像其他語言中那樣用一個內置(built-in)類型來表示?例如,Visual Basic 中有一個內置的 date 類型,程式員可以採用#12/31/1999格式指定日期。看起來這似乎很方便,程式員只需要使用內置的 date 類型而不用考慮類。但實際上 ...
  • 五,MyBatis-Plus 當中的 “ActiveRecord模式”和“SimpleQuery工具類”(詳細實操) @目錄五,MyBatis-Plus 當中的 “ActiveRecord模式”和“SimpleQuery工具類”(詳細實操)1. ActiveRecord 模式2. ActiveRec ...
  • Spring Security 是一個強大且可擴展的框架,用於保護 Java 應用程式,尤其是基於 Spring 的應用。它提供了身份驗證(驗證用戶身份)、授權(管理用戶許可權)和防護機制(如 CSRF 保護和防止會話劫持)等功能。 Spring Security 允許開發者通過靈活的配置實現安全控制 ...
  • 移動支付業務起源於銀行的電子支付業務。銀行的網上支付功能剛開始稱為電子銀行。網上支付涉及到很多的支付安全問題。移動支付是銀行支持手機移動支付功能。移動的銀行應用程式靈活程度更高,但是會增加伺服器追蹤客戶端應用程式和相關設備聯網互聯的複雜程度。現在的支付業務類型有銀行的支付應用程式APP和第三方支付平 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...