Golang拾遺之實現(xiàn)一個不可復(fù)制類型詳解
如何復(fù)制一個對象
不考慮IDE提供的代碼分析和go vet之類的靜態(tài)分析工具,golang里幾乎所有的類型都能被復(fù)制。
// 基本標量類型和指針 var i int = 1 iCopy := i str := "string" strCopy := str pointer := &i pointerCopy := pointer iCopy2 := *pointer // 解引用后進行復(fù)制 // 結(jié)構(gòu)體和數(shù)組 arr := [...]int{1, 2, 3} arrCopy := arr type Obj struct { i int } obj := Obj{} objCopy := obj
除了這些,golang還有函數(shù)和引用類型(slice、map、interface),這些類型也可以被復(fù)制,但稍有不同:
func f() {...} f1 := f f2 := f1 fmt.Println(f1, f2) // 0xabcdef 0xabcdef 打印出來的值是一樣的 fmt.Println(&f1 == &f2) // false 雖然值一樣,但確實是兩個不同的變量
這里并沒有真正復(fù)制處三份f的代碼,f1和f2均指向f,f的代碼始終只會有一份。map、slice和interface與之類似:
m := map[int]string{ 0: "a", 1: "b", } mCopy := m // 兩者引用同樣的數(shù)據(jù) mCopy[0] := "unknown" m[0] == "unknown" // True // slice的復(fù)制和map相同
interface是比較另類的,它的行為要分兩種情況:
s := "string" var i1 any = s var i2 any = s // 當把非指針和接口類型的值賦值給interface,會導(dǎo)致原來的對象被復(fù)制一份 s := "string" var i1 any = s var i2 any = i2 // 當把接口賦值給接口,底層引用的數(shù)據(jù)不會被復(fù)制,i1會復(fù)制s,i2此時和i1共有一個s的副本 ss := "string but pass by pointer" var i3 any = &ss var i4 any = i3 // i3和i4均引用ss,此時ss沒有被復(fù)制,但指向ss的指針的值被復(fù)制了兩次
上面的結(jié)果會一定程度上被編譯優(yōu)化干擾,比如少數(shù)情況下編譯器可以確認賦值給接口的值從來沒被修改并且生命周期不比源對象長,則可能不會進行復(fù)制。
所以這里有個小提示:如果要賦值給接口的數(shù)據(jù)比較大,那么最好以指針的形式賦值給接口,復(fù)制指針比復(fù)制大量的數(shù)據(jù)更高效。
為什么要禁止復(fù)制
從上一節(jié)可以看到,允許復(fù)制時會在某些情況下“闖禍”。比如:
1.淺拷貝的問題很容易出現(xiàn),比如例子里的map和slice的淺拷貝問題,這可能會導(dǎo)致數(shù)據(jù)被意外修改
2.意外復(fù)制了大量數(shù)據(jù),導(dǎo)致性能問題
3.在需要共享狀態(tài)的地方錯誤的使用了副本,導(dǎo)致狀態(tài)不一致從而產(chǎn)生嚴重問題,比如sync.Mutex,復(fù)制一個鎖并使用其副本會導(dǎo)致死鎖
4.根據(jù)業(yè)務(wù)或者其他需求,某類型的對象只允許存在一個實例,這時復(fù)制顯然是被禁止的
顯然在一些情況下禁止復(fù)制是合情合理的,這也是為什么我會寫這篇文章。
但具體情況具體分析,不是說復(fù)制就是萬惡之源,什么時候該支持復(fù)制,什么時候應(yīng)該禁止,應(yīng)該結(jié)合自己的實際情況。
運行時檢測實現(xiàn)禁止復(fù)制
想在別的語言中禁止某個類型被復(fù)制,方法有很多,用c++舉一例:
struct NoCopy { NoCopy(const NoCopy &) = delete; NoCopy &operator=(const NoCopy &) = delete; };
可惜在golang里不支持這么做。
另外,因為golang沒有運算符重載,所以很難在賦值的階段就進行攔截,所以我們的側(cè)重點在于“復(fù)制之后可以盡快檢測到”。
所以我們先實現(xiàn)在對象被復(fù)制后報錯的功能。雖然不如c++編譯期就可以禁止復(fù)制那樣優(yōu)雅,但也算實現(xiàn)了功能,至少不什么都沒有要強一些。
初步嘗試
那么如何直到對象是否被復(fù)制了?很簡單,看它的地址就行了,地址一樣那必然是同一個對象,不一樣了那說明復(fù)制出一個新的對象了。
順著這個思路,我們需要一個機制來保存對象第一次創(chuàng)建時的地址,并在后續(xù)進行比較,于是第一版代碼誕生了:
import "unsafe" type noCopy struct { p uintptr } func (nc *noCopy) check() { if uintptr(unsafe.Pointer(nc)) != nc.p { panic("copied") } }
邏輯比較清晰,每次調(diào)用check來檢查當前的調(diào)用者的地址和保存地址是否相同,如果不同就panic。
為什么沒有創(chuàng)建這個類型的方法?因為我們沒法得知自己被其他類型創(chuàng)建時的地址,所以這塊得讓其他使用noCopy的類型代勞。
使用的時候需要把noCopy嵌入自己的struct,注意不能以指針的形式嵌入:
type SomethingCannotCopy struct { noCopy ... } func (s *SomethingCannotCopy) DoWork() { s.check() fmt.Println("do something") } func NewSomethingCannotCopy() *SomethingCannotCopy { s := &SomethingCannotCopy{ // 一些初始化 } // 綁定地址 s.noCopy.p = unsafe.Pointer(&s.noCopy) return s }
注意初始化部分的代碼,在這里我們需要把noCopy對象的地址綁定進去?,F(xiàn)在可以實現(xiàn)運行時檢測了:
func main() { s1 := NewSomethingCannotCopy() pointer := s1 s1Copy := *s1 // 這里實際上進行了復(fù)制,但需要調(diào)用方法的時候才能檢測到 pointer.DoWork() // 正常打印出信息 s1Copy.DoWork() // panic }
解釋下原理:當SomethingCannotCopy被復(fù)制的時候,noCopy也會被復(fù)制,因此復(fù)制出來的noCopy的地址和原先的那個是不一樣的,但他們內(nèi)部記錄的p是一樣的,這樣當被復(fù)制出來的noCopy對象調(diào)用check方法的時候就會觸發(fā)panic。這也是為什么不要用指針形式嵌入它的原因。
功能實現(xiàn)了,但代碼實在是太丑,而且耦合嚴重:只要用了noCopy,就必須在創(chuàng)建對象的同時初始化noCopy的實例,noCopy的初始化邏輯會侵入到其他對象的初始化邏輯中,這樣的設(shè)計是不能接受的。
更好的實現(xiàn)
那么有沒有更好的實現(xiàn)?答案是有的,而且在標準庫里。
標準庫的信號量sync.Cond是禁止復(fù)制的,而且比Mutex更為嚴格,因為復(fù)制它比復(fù)制鎖更容易導(dǎo)致死鎖和崩潰,所以標準庫加上了運行時的動態(tài)檢查。
主要代碼如下:
type Cond struct { // L is held while observing or changing the condition L Locker ... // 復(fù)制檢查 checker copyChecker } // NewCond returns a new Cond with Locker l. func NewCond(l Locker) *Cond { return &Cond{L: l} } func (c *Cond) Signal() { // 檢查自己是否被復(fù)制 c.checker.check() runtime_notifyListNotifyOne(&c.notify) }
checker實現(xiàn)了運行時檢測是否被復(fù)制,但初始化的時候并不需要特殊處理這個checker,這是用了什么手法做到的呢?
看代碼:
type copyChecker uintptr func (c *copyChecker) check() { if uintptr(*c) != uintptr(unsafe.Pointer(c)) && // step 1 !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) && // step 2 uintptr(*c) != uintptr(unsafe.Pointer(c)) { //step 3 panic("sync.Cond is copied") } }
看著很復(fù)雜,連原子操作都來了,這都是啥啊。但別怕,我給你捋一捋就明白了。
首先是checker初始化之后第一次調(diào)用:
- 當check第一次被調(diào)用,c的值肯定是0,而這時候c是有真實的地址的,所以step 1失敗,進入step 2;
- 用原子操作把c的值設(shè)置成自己的地址值,注意只有c的值是0的時候才能完成設(shè)置,因為這里c的值是0,所以交換成功,step 2是False,判斷流程直接結(jié)束;
- 因為不排除還有別的goroutine拿著這個checker在做檢測,所以step 2是會失敗的,這是要進入step 3;
- step 3再次比較c的值和它自己的地址是否相同,相同說明多個goroutine共用了一個checker,沒有發(fā)生復(fù)制,所以檢測通過不會panic。
- 如果step 3的比較發(fā)現(xiàn)不相等,那么說明被復(fù)制了,直接panic
然后我們再看其他情況下checker的流程:
- 這時候c的值不是0,如果沒發(fā)生復(fù)制,那么step 1的結(jié)果是False,判斷流程結(jié)束,不會panic;
- 如果c的值和自己的地址不一樣,會進入step 2,因為這里c的值不為0,所以表達式結(jié)果一定是True,所以進入step 3;
- step 3和step 1一樣,結(jié)果是True,地址不同說明被復(fù)制,這時候if里面的語句會執(zhí)行,因此panic。
搞得這么麻煩,其實就是為了能干干凈凈地初始化。這樣任何類型都只需要帶上checker作為自己的字段就行,不用關(guān)心它是這么初始化的。
還有個小問題,為什么設(shè)置checker的值需要原子操作,但讀取就不用呢?
因為讀取一個uintptr的值,在現(xiàn)代的x86和arm處理器上只要一個指令,所以要么讀到過時的值要么讀到最新的值,不會讀到錯誤的或者寫了一半的不完整的值,對于讀到舊值的情況(主要出現(xiàn)在第一次調(diào)用check的時候),還有step 3做進一步的檢查,因此不會影響整個檢測邏輯。而“比較并交換”顯然一條指令做不完,如果在中間步驟被打斷那么整個操作的結(jié)果很可能就是錯的,從而影響整個檢測邏輯,所以必須要用原子操作才行。
那么在讀取的時候也使用atomic.Load行嗎?當然行,但一是這么做仍然避免不了step 3的檢測,可以思考下是為什么;二是原子操作相比直接讀取會帶來性能損失,在這里不使用原子操作也能保證正確性的情況下這是得不償失的。
性能
因為是運行時檢測,所以我們得看看會對性能帶來多少影響。我們使用改進版的checker。
type CheckBench struct { num uint64 checker copyChecker } func (c *CheckBench) CheckCopy() { c.checker.check() c.num++ } // 不進行檢測 func (c *CheckBench) NoCheck() { c.num++ } func BenchmarkCheckBench_NoCheck(b *testing.B) { c := CheckBench{} for i := 0; i < b.N; i++ { for j := 0; j < 50; j++ { c.NoCheck() } } } func BenchmarkCheckBench_WithCheck(b *testing.B) { c := CheckBench{} for i := 0; i < b.N; i++ { for j := 0; j < 50; j++ { c.CheckCopy() } } }
測試結(jié)果如下:
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_NoCheck-8 17689137 68.36 ns/op
BenchmarkCheckBench_WithCheck-8 17563833 66.04 ns/op
幾乎可以忽略不計,因為我們這里沒有發(fā)生復(fù)制,所以幾乎每次檢測都是通過的,這對cpu的分支預(yù)測非常友好,所以性能損耗幾乎可以忽略。
所以我們給cpu添點堵,讓分支預(yù)測沒那么容易:
func BenchmarkCheckBench_WithCheck(b *testing.B) { for i := 0; i < b.N; i++ { c := &CheckBench{} for j := 0; j < 50; j++ { c.CheckCopy() } } } func BenchmarkCheckBench_NoCheck(b *testing.B) { for i := 0; i < b.N; i++ { c := &CheckBench{} for j := 0; j < 50; j++ { c.NoCheck() } } }
現(xiàn)在分支預(yù)測沒那么容易了而且要多付出初始化時使用atomic的代價,測試結(jié)果會變成這樣:
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_WithCheck-8 15552717 74.84 ns/op
BenchmarkCheckBench_NoCheck-8 26441635 44.74 ns/op
差不多會慢40%。當然,實際的代碼不會有這么極端,所以最壞可能也只會產(chǎn)生20%的影響,通常不太會成為性能瓶頸,運行時檢測是否有影響還需結(jié)核profile。
優(yōu)點和缺點
優(yōu)點:
- 只要調(diào)用check,肯定能檢查出是否被復(fù)制
- 簡單
缺點:
- 所有的方法里都需要調(diào)用check,新加方法忘了調(diào)用的話就無法檢測
- 只能在被復(fù)制出來的新對象那檢測到復(fù)制操作,原先那個對象上check始終是沒問題的,這樣不是嚴格禁止了復(fù)制,但大多數(shù)時間沒問題,可以接受
- 如果只復(fù)制了對象沒調(diào)用任何對象上的方法,也無法檢測到復(fù)制,這種情況比較少見
- 有潛在性能損耗,雖然很多時候可以得到充分優(yōu)化損耗沒那么夸張
靜態(tài)檢測實現(xiàn)禁止復(fù)制
動態(tài)檢測的缺點不少,能不能像c++那樣編譯期就禁止復(fù)制呢?
利用Locker接口不可復(fù)制實現(xiàn)靜態(tài)檢測
也可以,但得配合靜態(tài)代碼檢測工具,比如自帶的go vet。看下代碼:
// 實現(xiàn)sync.Locker接口 type noCopy struct{} func (*noCopy) Lock() {} func (*noCopy) Unlock() {} type SomethingCannotCopy struct { noCopy }
這樣就行了,不需要再添加其他的代碼。解釋下原理:任何實現(xiàn)了sync.Locker的類型都不應(yīng)該被拷貝,靜態(tài)代碼檢測會檢測出這些情況并報錯。
所以類似下邊的代碼都是無法通過靜態(tài)代碼檢測的:
func f(s SomethingCannotCopy) { // 報錯,因為參數(shù)會導(dǎo)致復(fù)制 // 返回SomethingCannotCopy也是不行的 } func (s SomethingCannotCopy) Method() { // 報錯,因為非指針類型接收器會導(dǎo)致復(fù)制 } func main() { s := SomethingCannotCopy{} sCopy := s // 報錯 sInterface := any(s) // 報錯 sPointer := &s // OK sCopy2 := *sPointer // 報錯 sInterface2 := any(sPointer) // OK sCopy3 := *(sInterface2.(*SomethingCannotCopy)) // 報錯 }
基本上涵蓋了所以會產(chǎn)生復(fù)制操作的地方,基本能在編譯期完成檢測。
如果跳過go vet,直接使用go run或者go build,那么上面的代碼可以正常編譯并運行。
優(yōu)點和缺點
因為只有靜態(tài)檢測,因此沒有什么運行時開銷,所以性能這節(jié)就不需要費筆墨了。主要來看下這種方案的優(yōu)缺點。
優(yōu)點:
- 實現(xiàn)非常簡單,代碼很簡練,基本無侵入性
- 依賴靜態(tài)檢測,不影響運行時性能
- golang自帶檢測工具:go vet
- 可檢測到的case比運行時檢測多
缺點:
- 最大的缺點,盡管靜態(tài)檢測會報錯,但仍然可以正常編譯執(zhí)行
- 不是每個測試環(huán)境和CI都配備了靜態(tài)檢測,所以很難強制保證類型沒有被復(fù)制
- 會導(dǎo)致類型實現(xiàn)sync.Locker,然而很多時候我們的類型并不是類似鎖的資源,使用這個接口只是為了靜態(tài)檢測,這會帶來代碼被誤用的風(fēng)險
標準庫也使用的這套方案,建議仔細閱讀這個issue里的討論。
更進一步
看過運行時檢測和靜態(tài)檢測兩種方案之后,我們會發(fā)現(xiàn)這些做法多少都有些問題,不盡如人意。
所以我們還是要追求一種更好用的,更符合golang風(fēng)格的做法。幸運的是,這樣的做法是存在的。
利用package和interface進行封裝
首先我們創(chuàng)建一個worker包,里面定義一個Worker接口,包中的數(shù)據(jù)對外以Worker接口的形式提供:
package worker import ( "fmt" ) // 對外只提供接口來訪問數(shù)據(jù) type Worker interface { Work() } // 內(nèi)部類型不導(dǎo)出,以接口的形式供外部使用 type normalWorker struct { // data members } func (*normalWorker) Work() { fmt.Println("I am a normal worker.") } func NewNormalWorker() Worker { return &normalWorker{} } type specialWorker struct { // data members } func (*specialWorker) Work() { fmt.Println("I am a special worker.") } func NewSpecialWorker() Worker { return &specialWorker{} }
worker包對外只提供Worker接口,用戶可以使用NewNormalWorker和NewSpecialWorker來生成不同種類的worker,用戶不需要關(guān)心具體的返回類型,只要使用得到的Worker接口即可。
這么做的話,在worker包之外是看不到normalWorker和specialWorker這兩個類型的,所以沒法靠反射和類型斷言取出接口引用的數(shù)據(jù);因為我們傳給接口的是指針,因此源數(shù)據(jù)不會被復(fù)制;同時我們在第一節(jié)提到過,把一個接口賦值給另一個接口(worker包之外你只能這么做),底層被引用的數(shù)據(jù)不會被復(fù)制,因此在包外始終不會在這兩個類型上產(chǎn)生復(fù)制的行為。
因此下面這樣的代碼是不可能通過編譯的:
func main() { w := worker.NewSpecialWorker() // worker.specialWorker 在worker包以外不可見,因此編譯錯誤 wCopy := *(w.(*worker.specialWorker)) wCopy.Work() }
優(yōu)點和缺點
這樣就實現(xiàn)了worker包之外的禁止復(fù)制,下面來看看優(yōu)缺點。
優(yōu)點:
- 不需要額外的靜態(tài)檢查工具在編譯代碼前執(zhí)行檢查
- 不需要運行時動態(tài)檢測是否被復(fù)制
- 不會實現(xiàn)自己不需要的接口類型導(dǎo)致污染方法集
- 符合golang開發(fā)中的習(xí)慣做法
缺點:
- 并沒有讓類型本身不可復(fù)制,而是靠封裝屏蔽了大部分可能導(dǎo)致復(fù)制的情況
- 這些worker類型在包內(nèi)是可見的,如果在包內(nèi)修改代碼時不注意可能會導(dǎo)致復(fù)制這些類型的值,所以要么包內(nèi)也都用Woker接口,要么參考上一節(jié)添加靜態(tài)檢查
- 有些場景下不需要接口或者因為性能要求苛刻而使用不了接口,這種做法就行不通了,比如標準庫sync里的類型為了性能大部分都是暴露出來給外部直接使用的
綜合來說,這種方案是實現(xiàn)成本最低的。
總結(jié)
現(xiàn)在我們有三種方式防止我們的類型被復(fù)制:
- 運行時檢測
- 靜態(tài)代碼檢測
- 通過接口封裝避免暴露類型,從而避免被復(fù)制
一共三種方案,選擇困難癥仿佛要發(fā)作了。別著急,我們一起看看標準庫是怎么做的:
- 標準庫的sync.Cond同時使用了方案一和方案二,因為設(shè)計者確實很不希望條件變量被復(fù)制
- sync.Mutex、sync.Pool和sync.WaitGroup使用了方案二,需要配合go vet
- 方案三在標準庫中應(yīng)用最廣泛,然而多數(shù)是處于設(shè)計和封裝的考慮,并不是為了禁止copy,但復(fù)制crypto包下的那些Hash和Cipher確實沒什么意義會帶來誤用,正好借著方案三避免了這些問題
綜合來看首選的應(yīng)該是方案三;但也有需要使用方案二的時候,比如sync包中的那些同步機構(gòu);使用最少的是方案一,盡可能地不要設(shè)計出類似的代碼。
還有一點需要注意,如果你的類型里有字段是sync.Pool、sync.WaitGroup、sync.RWMutex、sync.Mutex、sync.Cond、sync.Map或sync.Once,那么這個類型本身也是不可復(fù)制的,也不需要額外實現(xiàn)禁止復(fù)制的功能,因為那些字段自帶了。
最后,我只想說golang的語言技能實在是太簡陋了,想只依賴語言特性實現(xiàn)禁止復(fù)制的功能不太現(xiàn)實,更多的還是需要靠“設(shè)計”。
以上就是Golang拾遺之實現(xiàn)一個不可復(fù)制類型詳解的詳細內(nèi)容,更多關(guān)于Golang不可復(fù)制類型的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
GoFrame?gtree樹形結(jié)構(gòu)的使用技巧示例
這篇文章主要為大家介紹了GoFrame?gtree樹形結(jié)構(gòu)的使用技巧示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-06-06go使用SQLX操作MySQL數(shù)據(jù)庫的教程詳解
sqlx 是 Go 語言中一個流行的操作數(shù)據(jù)庫的第三方包,它提供了對 Go 標準庫 database/sql 的擴展,簡化了操作數(shù)據(jù)庫的步驟,下面我們就來學(xué)習(xí)一下go如何使用SQLX實現(xiàn)MySQL數(shù)據(jù)庫的一些基本操作吧2023-11-11go將request?body綁定到不同的結(jié)構(gòu)體中教程
這篇文章主要為大家介紹了go將request?body綁定到不同的結(jié)構(gòu)體中教程,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-10-10golang利用redis和gin實現(xiàn)保存登錄狀態(tài)校驗登錄功能
這篇文章主要介紹了golang利用redis和gin實現(xiàn)保存登錄狀態(tài)校驗登錄功能,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-01-01golang使用OpenTelemetry實現(xiàn)跨服務(wù)全鏈路追蹤詳解
這篇文章主要為大家介紹了golang使用OpenTelemetry實現(xiàn)跨服務(wù)全鏈路追蹤詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-09-09GoLang strings.Builder底層實現(xiàn)方法詳解
自從學(xué)習(xí)go一個月以來,我多少使用了一下strings.Builder,略有心得。你也許知道它,特別是你了解bytes.Buffer的話。所以我在此分享一下我的心得,并希望能對你有所幫助2022-10-10