動態

詳情 返回 返回

Go 1.26 內置函數 new 新特性 - 動態 詳情

目前golang 1.26的各種新特性還在開發中,不過其中一個在開發完成之前就已經被官方拿到枱面上進行宣傳了——內置函數new功能擴展。

每個新特性其實都有它的背景故事,沒有需求的驅動也就不會有新特性的誕生。所以在介紹這個新特性之前我們先來了解下是什麼樣的場景催生了這個功能。

如果你經常瀏覽一些大型的go項目,尤其是那些需要頻繁和JSON、GRPC或者yaml打交道的項目,比如k8s,你會發現這些代碼庫會提供一些和下面代碼類似的幫助函數:

func getPointerValue[T any](v T) *T {
	return &v
}

這個是我用泛型改寫的,代碼庫裏通常都是getIntPointerValue(int) *int這樣非泛型函數。函數的作用很簡單,返回指向自己參數的指針。但這樣簡單的三行代碼有什麼用呢?

用處有好幾個,第一個是在json或者rpc裏有時候我們會用指針的nil來表示這個值沒有生效,和字段類型的零值做區分,但這使得給字段賦值變麻煩了:

type Data struct {
    Num *uint
}

d := &Data{}
d.Num = &12345 // 編譯錯誤
d.Num = getPointerValue(12345)

這行代碼d.Num = &12345是語法錯誤,因為在golang裏規定不能對字面量以及常量取地址。不僅如此,類似d.Num = &getNum()這樣的代碼也是無法編譯的,因為go也規定了不能對右值取地址。

如果沒有幫助函數,我們需要用一箇中間變量接住這些值,然後再把這個中間變量的指針賦值給結構體的字段。

第二個作用在於防止潛在的內存泄漏:

type BigStruct struct {
    // 100個其他字段
    Num int
}

bigObj := &BigStruct{....}
bigSlice := make([]int, 1024)

d1.Num = &bigObj.Num
d2.Num = &bigSlice[1000]

猜猜如果d1d2需要很長時間才能被釋放會發生什麼。答案是bigObjbigSlice也會一直存在不被釋放,因為golang中結構體、數組/切片只要還有指針指向自己的字段或者元素,那麼整個結構體和數組/切片的內存都不能被釋放。換句話説因為你的Data結構體持有了一個8字節的指針,會導致它背後十幾KB的內存一直沒法釋放,儘管這些內存中的99%你完全用不到。這在比較寬泛的定義上已經屬於是內存泄漏了。

所以這時候幫助函數就起作用了。getPointerValue的參數不是指針,因此會把傳進來的值拷貝一份,然後再取拷貝出來的新變量的指針,這樣就不會有指針指向那些大對象的字段或者元素了,這些大對象也可以儘快得到釋放從而不會浪費內存。

背景故事到此結束,到這裏其實你也能猜出new被擴展的新功能大致是什麼了。

new在1.26中獲得的新功能是可以接受一個表達式,它會複製表達式的結果到同類型的變量裏並返回指向這個變量的指針。

看個例子:

new(1234) // *int, 指向的值是1234

func getString() string {
    return "apocelipes"
}
new(getString()) // *string, 指向的值是"apocelipes"

s := "Hello, "
new(s + getString() + "!") // *string, 指向的值是表達式的結果"Hello, apocelipes!"

功能很簡單,相當於把上面的幫助函數getPointerValue集成到了現有的內置函數new裏。這能讓我們簡化一些代碼。

不過按照go團隊以往的做法,如果只是簡化代碼的話其實是不會在原有的內置函數上新增功能的。現在這麼做了説明還有額外的好處——性能。

我們看個性能測試:

func BenchmarkOld(b *testing.B) {
	for b.Loop() {
		p := getPointerValue(123)
		if p == nil || *p != 123 {
			b.Fatal()
		}
	}
}

func BenchmarkNew(b *testing.B) {
	for b.Loop() {
		p := new(123)
		if p == nil || *p != 123 {
			b.Fatal()
		}
	}
}

這段代碼需要master分支上的go編譯器才能正常編譯運行,我使用的版本是go1.26-devel_d7a38adf4c

結果:

image

可以看到使用幫助函數要額外多分配一次內存,速度也更慢。這是因為golang的逃逸分析主要保證內存安全,而在優化上比較保守,所以在處理我們的幫助函數時哪怕這個函數已經被內聯,編譯器還是會選擇分配一塊堆內存再返回指向這塊內存的指針。換句話説,編譯器不夠“聰明”。

但內置函數就不一樣了,內置函數是被編譯器特殊處理的,new會被編譯器改寫:

p1 := new(int)
// 改寫成
// var tmp int
// p1 := &tmp

p2 := new(12345)
// 改寫成
// var tmp int
// tmp = copy 12345
// p2 := &tmp

可以看到new是先在當前作用域裏創建一個臨時變量,然後再把表達式的結果複製進去的。全程沒有其他的函數調用。

對於改寫後的代碼,逃逸分析有充足的信息來決定改寫產生的tmp應該分配在棧上還是堆上,比起幫助函數來説獲得了更多的優化機會,因此性能也更好。

所以官方才有底氣提前宣傳,畢竟不僅解決了痛點,還有額外的收穫。

總結

1.26開始內置函數new的參數除了能接受一個類型名稱,現在還可以接收任意的表達式了。

在新版本中我們可以直接利用內置函數new不需要寫幫助函數了,同時還能收穫更高的性能。

當然,1.26的新特性開發窗口還沒結束,不能保證最終發佈的功能和文章裏介紹的一模一樣,但看官方這架勢這個新特性大概率是板上釘釘了,先用這篇文章嚐個鮮也未嘗不可。

user avatar vanve 頭像 free_like_bird 頭像 ayuan01 頭像 soroqer 頭像 ligaai 頭像 kubeexplorer 頭像 yian 頭像 yuzhoustayhungry 頭像 god23bin 頭像 aigoto 頭像 kuaidi100api 頭像 ximinghui 頭像
點贊 31 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.