Swift 4.2基礎 ---Swift 記憶體安全
預設情況下,Swift可以防止程式碼中出現不安全行為。例如,Swift確保變數在使用之前被初始化,記憶體在被釋放後不被訪問,陣列索引被檢查是否越界。
Swift還確保對同一記憶體區域的多次訪問不會發生衝突,這是因為只有需要修改記憶體中某個位置的程式碼才擁有對該記憶體的訪問許可權。因為Swift是自動管理記憶體的,通常情況下根本不需要考慮訪問記憶體。但是,瞭解潛在衝突可能發生在哪裡是很重要的,這樣可以避免編寫訪問記憶體的衝突程式碼。如果程式碼中確實包含衝突,則在編譯或執行時發生錯誤。
1.記憶體訪問衝突
當執行諸如設定變數值或向函式傳遞引數等操作時,就會在程式碼中發生對記憶體的訪問。例如,以下程式碼包含讀訪問和寫訪問:
// A write access to the memory where one is stored. var one = 1 // A read access from the memory where one is stored. print("We're number \(one)!")
當代碼的不同部分試圖同時訪問記憶體中的相同位置時,可能會發生記憶體訪問衝突。同時多次訪問記憶體中的某個位置會產生不可預測或不一致的行為。在Swift中,有一些方法可以修改一個跨越多行程式碼的值,從而可以嘗試在自己的修改過程中訪問一個值。
通過思考如何更新寫在紙上的預算,你可以看到類似的問題。更新預算需要兩個步驟:首先新增商品的名稱和價格,然後更改總額以反映當前清單上的商品。在更新之前和之後,您可以從預算中讀取任何資訊並得到正確的答案,如下圖所示。

memory_shopping_2x.png
當你向預算中新增商品時,它處於臨時的、無效的狀態,因為總金額尚未更新來反映新新增的商品。在新增一個商品的過程中,讀取總金額會獲取錯誤的資訊。
這個例子還演示了在修復記憶體的訪問衝突時可能遇到的一個挑戰:有時有多種方法可以解決衝突,同時也產生不同的答案,而且無法確定哪個答案是正確的。在本例中,根據您想要原始的總額還是更新後的總額,5美元或320美元可能是正確的答案。在修復衝突訪問之前,你必須確定要執行的操作。
注意:
如果你編寫過併發或多執行緒程式碼,則對記憶體的衝突訪問可能是一個熟悉的問題。但是,此處討論的衝突訪問可能發生在單個執行緒上, 並且不 涉及併發或多執行緒程式碼。
如果在單執行緒中存在記憶體訪問衝突,Swift會保證在編譯時或執行時都會收到錯誤。對於多執行緒程式碼,請使用 ofollow,noindex">Thread Sanitizer 幫助檢測跨執行緒的衝突訪問。
2.記憶體訪問的特徵
在衝突訪問的上下文中,需要考慮記憶體訪問的三個特徵:訪問是讀還是寫,訪問的持續時間,以及在記憶體中的訪問位置。具體來說,如果您有兩個滿足以下所有條件的訪問,就會發生衝突:
- 至少有一個是寫訪問。
- 它們訪問記憶體中的相同位置。
- 它們的時間重疊。
讀訪問和寫訪問之間的區別通常很明顯:寫訪問改變記憶體中的位置,但讀訪問不會。記憶體中的位置是指正在訪問的內容 - 例如,變數,常量或屬性。記憶體訪問的持續時間可以是瞬時的,也可以是長期的。
如果在訪問開始之後但在訪問結束之前不能執行其他程式碼,則訪問是瞬時的。從本質上講,兩次瞬時訪問不可能同時發生。大多數記憶體訪問是瞬時的。例如,下面程式碼清單中的所有讀寫訪問都是瞬時的:
func oneMore(than number: Int) -> Int { return number + 1 } var myNumber = 1 myNumber = oneMore(than: myNumber) print(myNumber) // Prints "2"
但是,有幾種訪問記憶體的方法,稱為長期訪問,可以跨越其他程式碼的執行。瞬時訪問和長期訪問的區別在於,其他程式碼可以在長期訪問開始後結束之前執行,這稱為重疊。長期訪問可以與其他長期訪問和瞬時訪問重疊。
重疊訪問主要出現在在函式和方法中使用in-out引數的程式碼中,或者是結構體的可變方法中。使用長期訪問的特定Swift程式碼型別將在下面的部分中討論。
3.對In-Out引數的訪問衝突
函式擁有對其所有in-out引數的長期寫訪問權。一個in-out引數的寫訪問在所有非in-out引數被計算之後開始,並持續到整個函式呼叫期間。如果有多個in-out引數,則寫訪問的開始順序與引數出現的順序相同。
這種長期寫訪問的一個後果是,您不能訪問作為in-out傳遞的原始變數,即使範圍規則和訪問控制允許這樣做——任何對原始變數的訪問都會產生衝突。例如:
var stepSize = 1 func increment(_ number: inout Int) { number += stepSize } increment(&stepSize) // Error: conflicting accesses to stepSize
在上面的程式碼中, stepSize
是一個全域性變數,通常可以從 increment(_:)
訪問它。但是,對 stepSize
的讀訪問與對 number
的寫訪問重疊。如下圖所示, number
和 stepSize
都指向記憶體中的同一個位置。讀和寫訪問引用相同的記憶體,它們重疊,產生衝突。

memory_increment_2x.png
stepSize
:
// Make an explicit copy. var copyOfStepSize = stepSize increment(©OfStepSize) // Update the original. stepSize = copyOfStepSize // stepSize is now 2
當在呼叫 increment(_:)
之前複製一個 stepSize
時,很明顯 copyOfStepSize
的值是由當前 stepSize
遞增的。讀訪問在寫訪問開始之前結束,所以沒有衝突。
對in-out引數進行長期寫訪問的另一個後果是,將單個變數作為同一個函式的多個in-out引數的引數傳遞會產生衝突。例如:
func balance(_ x: inout Int, _ y: inout Int) { let sum = x + y x = sum / 2 y = sum - x } var playerOneScore = 42 var playerTwoScore = 30 balance(&playerOneScore, &playerTwoScore)// OK balance(&playerOneScore, &playerOneScore) // Error: conflicting accesses to playerOneScore
上面 balance(_:_:)
函式修改了它的兩個引數,使它們之間的值相等。用 playerOneScore
和 playerTwoScore
呼叫它不會產生衝突——有兩個寫訪問在時間上重疊,但它們訪問的是不同的記憶體位置。相反,將 playerOneScore
作為兩個引數的值傳遞會產生衝突,因為它試圖同時執行對記憶體中相同位置的兩次寫訪問。
注意:
由於操作符是函式,它們也可以長期訪問它們的in-out引數。
因為運算子是函式,所以它們也可以長期訪問其in-out引數。例如,如果 balance(_:_:)
是一個名為 <^>
的運算子函式,則寫入 playerOneScore <^> playerOneScore
將導致與 balance(&playerOneScore, &playerOneScore)
相同的衝突。
4.方法中 self
的訪問衝突
結構體上的可變方法在方法呼叫期間對 self
具有寫訪問權。例如,考慮這樣一個遊戲,每個玩家都有一個生命值(在受到傷害時減少)和一個能量值(在使用特殊技能時減少)。
struct Player { var name: String var health: Int var energy: Int static let maxHealth = 10 mutating func restoreHealth() { health = Player.maxHealth } }
在上面的 restoreHealth()
方法中,對 self
的寫訪問從方法的開頭開始,一直持續到方法返回為止。在本例中, restoreHealth()
中沒有其他程式碼可以重疊訪問 Player
例項的屬性。下面的 shareHealth(with:)
方法將另一個 Player
例項作為一個in-out引數,建立了重疊訪問的可能性。
extension Player { mutating func shareHealth(with teammate: inout Player) { balance(&teammate.health, &health) } } var oscar = Player(name: "Oscar", health: 10, energy: 10) var maria = Player(name: "Maria", health: 5, energy: 10) oscar.shareHealth(with: &maria)// OK
在上面的例子中,呼叫Oscar玩家的 shareHealth(with:)
方法與Maria 玩家共享生命值不會引起衝突。在方法呼叫期間有對 oscar
的寫訪問,因為 oscar
是可變方法中的 self
值,在相同的時間內也有對 maria
的寫訪問,因為 maria
是作為 in-out
引數傳遞的。如下圖所示,它們訪問記憶體中的不同位置。儘管兩個寫訪問在時間上是重疊的,但它們並不衝突。

memory_share_health_maria_2x.png
但是,如果將 oscar
作為引數傳遞給 shareHealth(with:)
,則存在衝突:
oscar.shareHealth(with: &oscar) // Error: conflicting accesses to oscar
在該方法的持續時間內,可變方法需要對 self
的寫訪問。而在相同的持續時間內,in-out引數需要對 teammate
的寫訪問。在方法中, self
和 teamate
引用記憶體中的相同位置—如下圖所示。這兩個寫訪問引用相同的記憶體,它們重疊,產生衝突。

memory_share_health_oscar_2x.png
5.屬性的訪問衝突
結構體、元組和列舉等型別由單個組成值組成,例如結構體的屬性或元組的元素。由於這些都是值型別,因此改變值的任何部分會改變整個值,這意味著對其中一個屬性的讀或寫訪問需要對整個值進行讀或寫訪問。例如,對元組元素的重疊寫訪問會產生衝突:
var playerInformation = (health: 10, energy: 20) balance(&playerInformation.health, &playerInformation.energy) // Error: conflicting access to properties of playerInformation
在上面的例子中,呼叫元組元素上的 balance(_:_:)
會產生衝突,因為對 playerInformation
的寫訪問有重疊。 playerInformation.health
和 playerInformation.energy
作為in-out引數傳遞,這意味著 balance(_:_:)
在函式呼叫期間需要對它們進行寫訪問。在這兩種情況下,對tuple元素的寫訪問都需要對整個tuple進行寫訪問。這意味著有兩個對 playerInformation
的寫訪問,它們的持續時間重疊,導致衝突。
下面的程式碼顯示,對儲存在全域性變數中的結構體屬性的重疊寫訪問,會出現相同的錯誤。
var holly = Player(name: "Holly", health: 10, energy: 10) balance(&holly.health, &holly.energy)// Error
實際上,大多數對結構體屬性的訪問都可以安全地重疊。例如,如果上例中的變數 holly
更改為區域性變數而不是全域性變數,則編譯器可以證明對結構體儲存屬性的重疊訪問是安全的:
func someFunction() { var oscar = Player(name: "Oscar", health: 10, energy: 10) balance(&oscar.health, &oscar.energy)// OK }
在上面的例子中, oscar
的 health
和 energy
作為兩個輸入引數傳遞給 balance(_:_:)
。編譯器可以證明保留了記憶體安全性,因為這兩個儲存屬性不以任何方式互動。
反對重疊訪問結構體屬性的限制並不總是保證記憶體安全的必要條件。記憶體安全是理想的保證,但唯一的訪問是比記憶體安全更嚴格的要求 - 這意味著一些程式碼可以維護記憶體安全,即使它違反了對記憶體的唯一訪問許可權。如果編譯器可以證明對記憶體的非單獨訪問仍然是安全的,那麼Swift允許這種記憶體安全的程式碼。具體而言,如果滿足以下條件,則可以證明對結構屬性的重疊訪問是安全的:
- 只訪問例項的儲存屬性,而不是計算屬性或類屬性。
- 結構體是區域性變數的值,而不是全域性變數。
- 該結構體要麼不被任何閉包捕獲,要麼僅由非逃離閉包捕獲。
如果編譯器無法證明訪問是安全的,則不允許訪問。