什麼是 Noncopyable Types?

等等…之前有 Copyable types 嗎 🤔

Richard Lu
Seekrtech-dev
15 min readJul 26, 2024

--

在開始說什麼是 Noncopyable 之前,我們先退一步想想,那 Copyable 又是什麼?Copyable 就字面上的意思來看是 可複製的 ,但是在日常的開發上好像沒有印象在什麼樣的地方會需要特別注意或意識到某個物件是不是 Copyable ,複製這件事情在 Swift 中感受上是很自然的。先看一段很簡單的 code,現在有一個 struct 型別的Suit ,裡面帶有一個變數 color

struct Suit {
var color: Color
}

接下來我們宣告一個變數 officeSuit 並生成一個 Suit 物件指派給它時,該物件會被創建出來並存在內存裡的 Stack 區域上(因為是 value type)。我們這時候將 officeSuit 指派給另一個新宣告的變數 weekendSuit ,這時候系統將會將原本的物件複製一份存放在 Stack 上,所以 officeSuitweekendSuit 所指向的物件是兩個獨立的物件,後續兩個物件怎麼樣被修改都不會互相影響,各自有獨立的物件以及所有權(ownership)。

var officeSuit = Suit(color: .black)
var weekendSuit = officeSuit
weekendSuit.color = .blue
print("offcie suit color: \(officeSuit.color)") //offcie suit color: black
print("weekend suit color: \(weekendSuit.color)") //weekend suit color: blue

如同在以前網路雲端服務尚未完善時,在做一份團隊的報告時,可能會是複製一份後用 email 寄副本給其他團隊成員,而如果有其他成員後續下載了副本,並對內容做了些調整,自己手上的原始檔案並不會有任何變化,因為每個人手上的報告都是獨立的一份檔案,獨立的所有權。(光想像當時整理這種檔案有多麻煩就覺得頭皮發麻)。但現在我們已經很習慣的線上共同編輯的雲端功能,幫我們解決了這個問題,可以讓多人對同一份檔案都擁有所有權,在線上對同一份檔案編輯。

在 Swift 裡, class 型別就是體現這種概念。當我們把 Suit 的型別從 struct 改成 class 後,當把 officeSuit 的值也指派給 weekendSuit 時,這時系統複製過去的,是被創建在內存中 Heap 區域(因為 class 是 reference type)的物件位址,而不是複製出一個新的物件存放在內存中,所以兩個變數對於同一個 Suit 物件都有所有權,而這時候兩個變數對該 Suit 物件做任何更動時,會同時反應在兩個物件上。

class Suit {
var color: Color
init(color: Color) {
self.color = color
}
}

var officeSuit = Suit(color: .black)
var weekendSuit = officeSuit
weekendSuit.color = .blue
print("offcie suit color: \(officeSuit.color)") //offcie suit color: blue
print("weekend suit color: \(weekendSuit.color)") //weekend suit color: blue

其實一直到了 Swift 5.9,才有 Copyable 這個 protocol,並且預設所有的型別,包含 protocol 和 generic types 都預設帶有這個協定,不需要特別寫出來。而它代表的就是能讓系統執行上前例中看到的自動複製( Automatic copying) 行為(value types 複製物件,reference types 複製物件的位址)。系統預設我們都希望,且喜歡物件是可以被自動複製的,因為處理這樣的物件相對容易(系統也額外做了相對應的優化,如 Copy-on-Write, Automatic Reference Counting 等等機制)。

Screenshot from the WWDC24 session: Consume noncopyable types in Swift

但目前我們看到的兩種對於物件所有權的情況,似乎無法滿足我們不希望一個物件可以被自動複製出多份物件,且帶有獨立的所有權(N to N),或是對同一個物件自動複製出很多所有權的場景(N to 1 )。如果我們想要一個物件最多只能有一個所有權(1 to 1),就像一幅畢卡索大師的真跡,世界上獨一無二,如果自己是所有權人,應該不希望有多份被複製出來的複製品同時被很多人收藏,也不希望與很多人共享同等的所有權,這樣無法控管誰要對這幅畫做什麼樣的處置。

能不能不要複製

所以 Swift 團隊在 SE-0390 的提案中,提出了 noncopyable 的實作提案(這個概念並不是緣起於 Swift,在 Rust這個語言中就跟 Swift 相反,如果希望物件要是可以被自動複製的話,需要額外處理)。要將一個物件宣告為 noncopyable ,寫法是在物件宣告遵循哪些協定的地方寫上 ~Copyable (波浪符的英文為 tilde,讀作: /ˈtɪldə/),可以把它想成是讓物件缺少 Copyable 的協定內容,也就代表這個物件不能被系統自動複製。因為不能被複製,當這個物件需要被傳進 function 做額外處理,或是需要移轉所有權到其他變數身上時,就會透過另一種機制來做管理: consume 。這邊我們就沿用上述舉例的畢卡索大師的傑作來實作。

首先宣告一個 struct 型別的 PiccasoMasterpiece 帶有 yearname 兩個基本資訊,最重要的是我們讓它遵循這次介紹的 ~Copyable 協定,代表它的物件不能被自動複製。

struct PiccasoMasterpiece: ~Copyable {
let year: Int
let name: String
}

接下來我們來看看 ~Copyable 對所有權的管理是如何處理的。

borrowing

如果今天我想要寫一個 exhibit function,目的是借出某幅畢卡索的畫作在某個博物館裡展出,如果沒有定義清楚 function 對於傳進來的 PiccasoMasterpiece物件的所有權是什麼,且我在 function 裡嘗試改動該物件的所有權,compiler 會跳出錯誤顯示: Noncopyable parameter must specify its ownership

func exhibit(_ piccasoMasterpiece: PiccasoMasterpiece, in _museum: Museum) {
let masterPieceToExhibit = piccasoMasterpiece //error: Noncopyable parameter must specify its ownership
//.....
}

既然我們是借出這個所有權,展場方獲得的所有權僅限於展示這幅畫作(如同 Read-Only),並不能對其修改或是移轉所有權,且展示結束後須歸還。這邊我們可以用 borrowing flag 來做到這件事。在這樣的過程中,我們不能做任何 comsume 的動作(這邊可以簡單想成任何會需要做自動複製的動作),因為我們明確的標示了傳進來的物件所有權只是 borrowing

func exhibit(_ piccasoMasterpiece: borrowing PiccasoMasterpiece, in _museum: Museum) {
let _temp = piccasoMasterpiece //error: 'piccasoMasterpiece' is borrowed and cannot be consumed
print("name: \(piccasoMasterpiece.name)") // This is OK.
//...
}

inout

如果今天我們是要把這幅畫送去做修復或保養,由專業的修復師來對畫作做必要的更動,但是完成後仍須歸還,這樣的過程可以使用我們較為熟悉的 inout flag 來處理。 inout 提供的所有權是可以 consume ,但是最後必須得再次將物件指定回傳進來的參數,compiler 會在編譯時就檢查,如果忘了指定回傳進來的參數,將會顯示錯誤: Missing reinitialization of inout parameter 'piccasoMasterpiece' after consume

func repair(_ piccasoMasterpiece: inout PiccasoMasterpiece) {
var _temp = piccasoMasterpiece // This is OK.
//...
piccasoMasterpiece = _temp // This is a MUST!
}

consuming

收藏家或許某天突然察覺,大師的畫作與其放在家裡獨自欣賞,捐贈給博物館讓全世界更多人可以一睹大師的畫作,也是種至高榮譽。這將會是完整的所有權轉移,而這種情況下使用的就會是 consuming 。當物件被 consume後,再嘗試去取用它時,compiler 會跳出錯誤訊息。

struct PiccasoMuseum: ~Copyable {
var receivedDonation: PiccasoMasterpiece?
}

func donate(_ piccasoMasterpiece: consuming PiccasoMasterpiece, to museum: inout PiccasoMuseum) {
museum.receivedDonation = piccasoMasterpiece
print(piccasoMasterpiece.name) //error: 'piccasoMasterpiece' used after consume
}

func main() {
let mp = PiccasoMasterpiece(year: 1932, name: "Femme à la montre")
var museum = PiccasoMuseum()
donate(mp, to: &museum) //'mp' is comsumed here
print(mp.name) //error: 'mp' used after consume
}

~Copyable 作為型別限制

在還沒有 ~Copyable 的出現時,Swift 裡的東西都是現在所謂的 Copyable ,也就是支援自動複製的行為。而因為 ~Copyable 的出現,Swift 所囊括的東西有了新的擴張,整個 ~Copyable 代表的是一個包含 Copyable 的新宇宙。

Edited screenshot from the WWDC24 session: Consume noncopyable types in Swift

~Copyable 在 Swift6 支援用在 protocol 或是 generic type 的型別限制(type constraints)上。上圖中,我們熟悉的 String 是屬於 Copyable ,但它一樣是被 ~Copyable 宇宙所包含在內。 當今天我們宣告一個 protocol 並且帶有 ~Copyable 的型別限制時,我們實際上是放寬了型別限制(預設是只接受 Copyable),讓不管有沒有支援 Copyable 的物件都能被囊括進來。

我們這邊就以 session 中提供的範例來看。首先定義一個 protocol Runnable: ~Copyable ,代表這個協定的型別限制可以接受比原本 Copyable 更廣的 ~Copyable 限制。後續定義兩個 struct,一個是可自動複製的而另一個不行,但兩個都遵循 Runnable 協定。

protocol Runnable: ~Copyable {
consuming func run()
}

struct Command: Runnable {
func run() { /* ... */ }
}

struct BankTransfer: ~Copyable, Runnable {
consuming func run() { /* ... */ }
}

接下來定義一個新的 struct Job 帶有 generic type ActionAction 的型別限制為 Runnable & ~Copyable 。當物件要持有 ~Copyable 的物件時,有兩種方式:一種是把自己宣告為 class ,因為 class 在被複製時只會複製位址,不會複製整個物件; 另一種方式則是讓自己也遵循 ~Copyable ,而以下舉例是使用後者。

struct Job<Action: Runnable & ~Copyable>: ~Copyable {
var action: Action?
}

不過現在可能會遇到一個問題是, Job 本身是 ~Copyable,但它身上帶的 action 有可能是 Copyable 。我們可以透過以下 extension 的方式讓我們的 Job 針對這種情況做更彈性的支援,代表的是當我今天指定的 Action 型別是 Copyable 的情況下,我也讓我的 Job 遵循Copyable 協定。

extension Job: Copyable where Action: Copyable {}

接下來定義一個簡單的 protocol Cancellable ,裡面帶有一個 func cancel() 。我們讓 Job 透過 extension 的方式來遵循 Cancellable 協定,並實作 func cancel() ,把身上帶的 action 變數設定為 nil

protocol Cancellable { // Conforms to `Copyable` by default
mutating func cancel()
}

extension Job: Cancellable {
mutating func cancel() {
action = nil
}
}

後續我們宣告了一個 var commandJob = Job<Command>() ,而對他呼叫 cancel() 。這樣的寫法是沒有問題的,因為 protocol Cancellable 預設是帶有 Copyable 型別限制的,而我們的 Command 本身也是 Copyable ,所以沒有問題。但是如果今天我們也宣告了一個 var bankTransferCommand = Job<BankTransfer>() 並且對他呼叫 cancel(),這時候 compiler 就會跳出錯誤訊息: Noncopyable type 'BankTransfer' cannot be substituted for copyable generic parameter 'Action' in 'Job’

var commandJob = Job<Command>()
commandJob.cancel() // This is OK.

var bankTransferCommand = Job<BankTransfer>()
bankTransferCommand.cancel() //error: Noncopyable type 'BankTransfer' cannot be substituted for copyable generic parameter 'Action' in 'Job'

因為 BankTransfer 本身是 ~Copyable 物件,所以在這邊就會出問題。那我們應該怎麼調整呢?我們可以模仿前面對 Job 做的型別限制調整,來放寬 Cancellable 的型別限制。在對 Job 做 extension 時,如果我們沒有特別寫出 where Action: ~Copyable 的限制,預設就會是 where Action: Copyable ,這樣的預設行為在 extension 上會套用在該型別身上所有 generic type,包含 protocol 中的 Self。所以在這邊我們一樣需要把這邊的 extension 套用的範圍擴大到包含不能自動複製的 Action 型別。

protocol Cancellable: ~Copyable {
mutating func cancel()
}

extension Job: Cancellable where Action: ~Copyable {
mutating func cancel() {
action = nil
}
}

結語

這個 Noncopyable 的概念對於我個人來說是一個全新的概念,實際在反思工作上目前有哪些地方會是有利的使用場景時,還沒有一個很確定的或很明顯的答案,畢竟這個對於物件的所有權的管理方式是跟之前習慣的預設可自動複製是差很多的,後續還會需要做更多更深入的探索。歡迎大家與我分享曾經使用過這樣的物件管理方式的心得感想,以上如果有任何問題也歡迎留言交流,謝謝閱讀。

--

--

Richard Lu
Seekrtech-dev

🌞iOS Developer | 🌛2 kids' Daddy | Striving for being awesome day and night