這是golang拾遺系列的第六篇,這個系列主要用來記錄一些平時不常見的知識點,偶爾也會實作些有意思的小功能,比如這篇,
golang拾遺系列目錄:
- golang拾遺:指標和介面
- golang拾遺:為什么我們需要泛型
- golang拾遺:嵌入型別
- golang拾遺:內置函式len的小知識
- golang拾遺:自定義型別和方法集
- golang拾遺:實作一個不可復制型別
在本篇中我們將實作一個無法被復制的型別,順便加深對參考型別、值傳遞以及指標的理解,
閱讀本文前需要你擁有一定的前置知識,包括掌味訓本的golang語法,能理解并應用介面,對sync包下的內容有粗略的了解,如果你準備好了,就可以接著往下看了,
本文索引
- 如何復制一個物件
- 為什么要禁止復制
- 運行時檢測實作禁止復制
- 初步嘗試
- 更好的實作
- 性能
- 優點和缺點
- 靜態檢測實作禁止復制
- 利用Locker介面不可復制實作靜態檢測
- 優點和缺點
- 更進一步
- 利用package和interface進行封裝
- 優點和缺點
- 總結
如何復制一個物件
不考慮IDE提供的代碼分析和go vet之類的靜態分析工具,golang里幾乎所有的型別都能被復制,
// 基本標量型別和指標
var i int = 1
iCopy := i
str := "string"
strCopy := str
pointer := &i
pointerCopy := pointer
iCopy2 := *pointer // 解參考后進行復制
// 結構體和陣列
arr := [...]int{1, 2, 3}
arrCopy := arr
type Obj struct {
i int
}
obj := Obj{}
objCopy := obj
除了這些,golang還有函式和參考型別(slice、map、interface),這些型別也可以被復制,但稍有不同:
func f() {...}
f1 := f
f2 := f1
fmt.Println(f1, f2) // 0xabcdef 0xabcdef 列印出來的值是一樣的
fmt.Println(&f1 == &f2) // false 雖然值一樣,但確實是兩個不同的變數
這里并沒有真正復制處三份f的代碼,f1和f2均指向f,f的代碼始終只會有一份,map、slice和interface與之類似:
m := map[int]string{
0: "a",
1: "b",
}
mCopy := m // 兩者參考同樣的資料
mCopy[0] := "unknown"
m[0] == "unknown" // True
// slice的復制和map相同
interface是比較另類的,它的行為要分兩種情況:
s := "string"
var i1 any = s
var i2 any = s
// 當把非指標和介面型別的值賦值給interface,會導致原來的物件被復制一份
s := "string"
var i1 any = s
var i2 any = i2
// 當把介面賦值給介面,底層參考的資料不會被復制,i1會復制s,i2此時和i1共有一個s的副本
ss := "string but pass by pointer"
var i3 any = &ss
var i4 any = i3
// i3和i4均參考ss,此時ss沒有被復制,但指向ss的指標的值被復制了兩次
上面的結果會一定程度上被編譯優化干擾,比如少數情況下編譯器可以確認賦值給介面的值從來沒被修改并且生命周期不比源物件長,則可能不會進行復制,
所以這里有個小提示:如果要賦值給介面的資料比較大,那么最好以指標的形式賦值給介面,復制指標比復制大量的資料更高效,
為什么要禁止復制
從上一節可以看到,允許復制時會在某些情況下“闖禍”,比如:
- 淺拷貝的問題很容易出現,比如例子里的map和slice的淺拷貝問題,這可能會導致資料被意外修改
- 意外復制了大量資料,導致性能問題
- 在需要共享狀態的地方錯誤的使用了副本,導致狀態不一致從而產生嚴重問題,比如
sync.Mutex,復制一個鎖并使用其副本會導致死鎖 - 根據業務或者其他需求,某型別的物件只允許存在一個實體,這時復制顯然是被禁止的
顯然在一些情況下禁止復制是合情合理的,這也是為什么我會寫這篇文章,
但具體情況具體分析,不是說復制就是萬惡之源,什么時候該支持復制,什么時候應該禁止,應該結合自己的實際情況,
運行時檢測實作禁止復制
想在別的語言禁止某個型別被復制,方法有很多,用c++舉一例:
struct NoCopy {
NoCopy(const NoCopy &) = delete;
NoCopy &operator=(const NoCopy &) = delete;
};
可惜在golang里不支持這么做,
另外,因為golang沒有運算子多載,所以很難在賦值的階段就進行攔截,所以我們的側重點在于“復制之后可以盡快檢測到”,
所以我們先實作在物件被復制后報錯的功能,雖然不如c++編譯期就可以禁止復制那樣優雅,但也算實作了功能,至少不什么都沒有要強一些,
初步嘗試
那么如何直到物件是否被復制了?很簡單,看它的地址就行了,地址一樣那必然是同一個物件,不一樣了那說明復制出一個新的物件了,
順著這個思路,我們需要一個機制來保存物件第一次創建時的地址,并在后續進行比較,于是第一版代碼誕生了:
import "unsafe"
type noCopy struct {
p uintptr
}
func (nc *noCopy) check() {
if uintptr(unsafe.Pointer(nc)) != nc.p {
panic("copied")
}
}
邏輯比較清晰,每次呼叫check來檢查當前的呼叫者的地址和保存地址是否相同,如果不同就panic,
為什么沒有創建這個型別的方法?因為我們沒法得知自己被其他型別創建時的地址,所以這塊得讓其他使用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物件的地址系結進去,現在可以實作運行時檢測了:
func main() {
s1 := NewSomethingCannotCopy()
pointer := s1
s1Copy := *s1 // 這里實際上進行了復制,但需要呼叫方法的時候才能檢測到
pointer.DoWork() // 正常列印出資訊
s1Copy.DoWork() // panic
}
解釋下原理:當SomethingCannotCopy被復制的時候,noCopy也會被復制,因此復制出來的noCopy的地址和原先的那個是不一樣的,但他們內部記錄的p是一樣的,這樣當被復制出來的noCopy物件呼叫check方法的時候就會觸發panic,這也是為什么不要用指標形式嵌入它的原因,
功能實作了,但代碼實在是太丑,而且耦合嚴重:只要用了noCopy,就必須在創建物件的同時初始化noCopy的實體,noCopy的初始化邏輯會侵入到其他物件的初始化邏輯中,這樣的設計是不能接受的,
更好的實作
那么有沒有更好的實作?答案是有的,而且在標準庫里,
標準庫的信號量sync.Cond是禁止復制的,而且比Mutex更為嚴格,因為復制它比復制鎖更容易導致死鎖和崩潰,所以標準庫加上了運行時的動態檢查,
主要代碼如下:
type Cond struct {
// L is held while observing or changing the condition
L Locker
...
// 復制檢查
checker copyChecker
}
// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
return &Cond{L: l}
}
func (c *Cond) Signal() {
// 檢查自己是否被復制
c.checker.check()
runtime_notifyListNotifyOne(&c.notify)
}
checker實作了運行時檢測是否被復制,但初始化的時候并不需要特殊處理這個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")
}
}
看著很復雜,連原子操作都來了,這都是啥啊,但別怕,我給你捋一捋就明白了,
首先是checker初始化之后第一次呼叫:
- 當check第一次被呼叫,c的值肯定是0,而這時候c是有真實的地址的,所以
step 1失敗,進入step 2; - 用原子操作把c的值設定成自己的地址值,注意只有c的值是0的時候才能完成設定,因為這里c的值是0,所以交換成功,
step 2是False,判斷流程直接結束; - 因為不排除還有別的goroutine拿著這個checker在做檢測,所以
step 2是會失敗的,這是要進入step 3; step 3再次比較c的值和它自己的地址是否相同,相同說明多個goroutine共用了一個checker,沒有發生復制,所以檢測通過不會panic,- 如果
step 3的比較發現不相等,那么說明被復制了,直接panic
然后我們再看其他情況下checker的流程:
- 這時候c的值不是0,如果沒發生復制,那么
step 1的結果是False,判斷流程結束,不會panic; - 如果c的值和自己的地址不一樣,會進入
step 2,因為這里c的值不為0,所以運算式結果一定是True,所以進入step 3; step 3和step 1一樣,結果是True,地址不同說明被復制,這時候if里面的陳述句會執行,因此panic,
搞得這么麻煩,其實就是為了能干干凈凈地初始化,這樣任何型別都只需要帶上checker作為自己的欄位就行,不用關心它是這么初始化的,
還有個小問題,為什么設定checker的值需要原子操作,但讀取就不用呢?
因為讀取一個uintptr的值,在現代的x86和arm處理器上只要一個指令,所以要么讀到過時的值要么讀到最新的值,不會讀到錯誤的或者寫了一半的不完整的值,對于讀到舊值的情況(主要出現在第一次呼叫check的時候),還有step 3做進一步的檢查,因此不會影響整個檢測邏輯,而“比較并交換”顯然一條指令做不完,如果在中間步驟被打斷那么整個操作的結果很可能就是錯的,從而影響整個檢測邏輯,所以必須要用原子操作才行,
那么在讀取的時候也使用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()
}
}
}
測驗結果如下:
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
幾乎可以忽略不計,因為我們這里沒有發生復制,所以幾乎每次檢測都是通過的,這對cpu的分支預測非常友好,所以性能損耗幾乎可以忽略,
所以我們給cpu添點堵,讓分支預測沒那么容易:
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()
}
}
}
現在分支預測沒那么容易了而且要多付出初始化時使用atomic的代價,測驗結果會變成這樣:
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%,當然,實際的代碼不會有這么極端,所以最壞可能也只會產生20%的影響,通常不太會成為性能瓶頸,運行時檢測是否有影響還需結核profile,
優點和缺點
優點:
- 只要呼叫check,肯定能檢查出是否被復制
- 簡單
缺點:
- 所有的方法里都需要呼叫check,新加方法忘了呼叫的話就無法檢測
- 只能在被復制出來的新物件那檢測到復制操作,原先那個物件上check始終是沒問題的,這樣不是嚴格禁止了復制,但大多數時間沒問題,可以接受
- 如果只復制了物件沒呼叫任何物件上的方法,也無法檢測到復制,這種情況比較少見
- 有潛在性能損耗,雖然很多時候可以得到充分優化損耗沒那么夸張
靜態檢測實作禁止復制
動態檢測的缺點不少,能不能像c++那樣編譯期就禁止復制呢?
利用Locker介面不可復制實作靜態檢測
也可以,但得配合靜態代碼檢測工具,比如自帶的go vet,看下代碼:
// 實作sync.Locker介面
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type SomethingCannotCopy struct {
noCopy
}
這樣就行了,不需要再添加其他的代碼,解釋下原理:任何實作了sync.Locker的型別都不應該被拷貝,靜態代碼檢測會檢測出這些情況并報錯,
所以類似下邊的代碼都是無法通過靜態代碼檢測的:
func f(s SomethingCannotCopy) {
// 報錯,因為引數會導致復制
// 回傳SomethingCannotCopy也是不行的
}
func (s SomethingCannotCopy) Method() {
// 報錯,因為非指標型別接收器會導致復制
}
func main() {
s := SomethingCannotCopy{}
sCopy := s // 報錯
sInterface := any(s) // 報錯
sPointer := &s // OK
sCopy2 := *sPointer // 報錯
sInterface2 := any(sPointer) // OK
sCopy3 := *(sInterface2.(*SomethingCannotCopy)) // 報錯
}
基本上涵蓋了所以會產生復制操作的地方,基本能在編譯期完成檢測,
如果跳過go vet,直接使用go run或者go build,那么上面的代碼可以正常編譯并運行,
優點和缺點
因為只有靜態檢測,因此沒有什么運行時開銷,所以性能這節就不需要費筆墨了,主要來看下這種方案的優缺點,
優點:
- 實作非常簡單,代碼很簡練,基本無侵入性
- 依賴靜態檢測,不影響運行時性能
- golang自帶檢測工具:go vet
- 可檢測到的case比運行時檢測多
缺點:
- 最大的缺點,盡管靜態檢測會報錯,但仍然可以正常編譯執行
- 不是每個測驗環境和CI都配備了靜態檢測,所以很難強制保證型別沒有被復制
- 會導致型別實作
sync.Locker,然而很多時候我們的型別并不是類似鎖的資源,使用這個介面只是為了靜態檢測,這會帶來代碼被誤用的風險
標準庫也使用的這套方案,建議仔細閱讀這個issue里的討論,
更進一步
看過運行時檢測和靜態檢測兩種方案之后,我們會發現這些做法多少都有些問題,不盡如人意,
所以我們還是要追求一種更好用的,更符合golang風格的做法,幸運的是,這樣的做法是存在的,
利用package和interface進行封裝
首先我們創建一個worker包,里面定義一個Worker介面,包中的資料對外以Worker介面的形式提供:
package worker
import (
"fmt"
)
// 對外只提供介面來訪問資料
type Worker interface {
Work()
}
// 內部型別不匯出,以介面的形式供外部使用
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,用戶不需要關心具體的回傳型別,只要使用得到的Worker介面即可,
這么做的話,在worker包之外是看不到normalWorker和specialWorker這兩個型別的,所以沒法靠反射和型別斷言取出介面參考的資料;因為我們傳給介面的是指標,因此源資料不會被復制;同時我們在第一節提到過,把一個介面賦值給另一個介面(worker包之外你只能這么做),底層被參考的資料不會被復制,因此在包外始終不會在這兩個型別上產生復制的行為,
因此下面這樣的代碼是不可能通過編譯的:
func main() {
w := worker.NewSpecialWorker()
// worker.specialWorker 在worker包以外不可見,因此編譯錯誤
wCopy := *(w.(*worker.specialWorker))
wCopy.Work()
}
優點和缺點
這樣就實作了worker包之外的禁止復制,下面來看看優缺點,
優點:
- 不需要額外的靜態檢查工具在編譯代碼前執行檢查
- 不需要運行時動態檢測是否被復制
- 不會實作自己不需要的介面型別導致污染方法集
- 符合golang開發中的習慣做法
缺點:
- 并沒有讓型別本身不可復制,而是靠封裝屏蔽了大部分可能導致復制的情況
- 這些worker型別在包內是可見的,如果在包內修改代碼時不注意可能會導致復制這些型別的值,所以要么包內也都用Woker介面,要么參考上一節添加靜態檢查
- 有些場景下不需要介面或者因為性能要求苛刻而使用不了介面,這種做法就行不通了,比如標準庫sync里的型別為了性能大部分都是暴露出來給外部直接使用的
綜合來說,這種方案是實作成本最低的,
總結
現在我們有三種方式防止我們的型別被復制:
- 運行時檢測
- 靜態代碼檢測
- 通過介面封裝避免暴露型別,從而避免被復制
一共三種方案,選擇困難癥仿佛要發作了,別著急,我們一起看看標準庫是怎么做的:
- 標準庫的
sync.Cond同時使用了方案一和方案二,因為設計者確實很不希望條件變數被復制 sync.Mutex、sync.Pool和sync.WaitGroup使用了方案二,需要配合go vet- 方案三在標準庫中應用最廣泛,然而多數是處于設計和封裝的考慮,并不是為了禁止copy,但復制
crypto包下的那些Hash和Cipher確實沒什么意義會帶來誤用,正好借著方案三避免了這些問題
綜合來看首選的應該是方案三;但也有需要使用方案二的時候,比如sync包中的那些同步機構;使用最少的是方案一,盡可能地不要設計出類似的代碼,
還有一點需要注意,如果你的型別里有欄位是sync.Pool、sync.WaitGroup、sync.RWMutex、sync.Mutex、sync.Cond、sync.Map或sync.Once,那么這個型別本身也是不可復制的,也不需要額外實作禁止復制的功能,因為那些欄位自帶了,
最后,我只想說golang的語言技能實在是太簡陋了,想只依賴語言特性實作禁止復制的功能不太現實,更多的還是需要靠“設計”,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/544417.html
標籤:其他
下一篇:Python繪制神經網路模型圖
