作者:Nemo,iOS 開發者,目前就職于位元組跳動
Session:https://developer.apple.com/videos/play/wwdc2020/10167/
在 Swift 中,我們通過 Unsafe 字首來辨別那些輸入後可能發生未定義行為的操作,詳情可以回顧 WWDC20 - Unsafe Swift[1]。而本文則會更深入地探讨在非安全範圍内編寫 Swift 的一些細節,日常開發中比較少接觸到的部分。
想要更安全地管理指針,意味着需要了解各種導緻不安全的方式。指針的安全性可以分為不同級别來讨論,越往底層,程式員越需要為代碼的正确性負責。是以日常開發中建議盡量使用頂層的 API 編寫代碼。
安全級别
安全性可以被分為四個級别。
- 最頂層的是安全級别,Swift 的主要目标之一就是無需任何不安全的資料結構就能編寫代碼。Swift 擁有健壯的類型系統,提供了強大的靈活性和性能。完全不使用指針對于代碼安全來說是一個很好的選擇。
- 但是 Swift 另一個重要目标是和不安全語言的高性能互操性。是以在第二層 Swift 提供了字首為 Unsafe 的類型或函數。
可以在不用擔心類型安全的情況下使用指針。UnsafePointer
- 如果需要使用原始記憶體作為位元組序處理,那麼就要使用第三層的
,使用它來加載和存儲值到原始記憶體中,需要熟悉類型的記憶體布局。UnsafeRawPointer
- 在最底層中,Swift 提供了綁定記憶體類型的記憶體綁定 API,隻有在這一層才需要完全保證指針的類型安全。
注意,安全代碼并不一定意味着正确代碼,但它的行為是可預測的。大多數情況下,編譯器會捕獲代碼導緻的不可預測行為。對于編譯時無法捕獲的錯誤,運作時會檢查并讓程式立刻崩潰并給出診斷。安全代碼實際上增強了錯誤的表現。線上程安全且不使用 Unsafe 字首 API 的情況下,代碼會強制産生可預測行為。
反之,在不安全的 Swift 代碼中,可預測行為并不是完全強制的,是以需要程式員承擔額外的風險。Xcode 的測試提供了一些有用的診斷,但診斷的級别取決于選擇的安全級别。标準庫中的不安全 API 可以通過斷言和調試編譯來捕獲一些無效輸入。添加先決條件來驗證不安全的假設也是一個不錯的實踐。還可以通過 Xcode 的 Address Sanitizer[2] 在運作時檢查,但它也無法捕獲所有的未定義行為。如果測試期間沒有發現錯誤,則可能在運作時發生難以調試的崩潰,或者更可怕的是執行錯誤行為導緻使用者資料被破壞。
指針安全
Swift 設計上是無須指針的程式設計語言,了解指針的安全性能更清楚為何應該避免使用它們。同時,如果确實需要使用底層 API 來通路記憶體,這塊知識也是值得掌握的。
生命周期
在需要指向變量的存儲空間、數組元素或者直接配置設定的記憶體時,需要穩定的記憶體位置。但這塊穩定的存儲空間生命周期是有限的。可能因為它超出了作用域,也可能你需要直接配置設定記憶體,就會導緻超出了生命周期。然而,你使用的指針有着自己的生命周期,當你的指針的生命周期超過對應存儲空間的生命周期時,指針通路就會變成未定義行為。這是指針不安全的主要原因,但不是唯一原因。
對象邊界
對象可以由一組元素組成。指針可以通過偏移來通路不同的記憶體位址,這是一種處理不同元素的位址的有效方式。但是偏移的過大或過小都會導緻通路的不是對應的對象。指針通路超過對象邊界的行為也是未定義的。
指針類型
還有一個方面的安全問題容易被忽視,指針本身的類型和記憶體裡的值類型不一緻。比如本來有一個指向
Int16
的指針,當記憶體區域被覆寫為
Int32
時,通路
Int16
舊指針就會産生未定義行為。
下面有一個非常不安全的例子,你可能會被從 C 移植而來的 Swift 代碼調用部分舊 C 代碼的樣子吓到。
addImages(:)
調用時将圖像資料寫入并更新圖像數量,
Collage
結構中的 imageCount 是
Int
,但
addImages(:)
實參需要的是
UInt32
。安全的做法是建立比對的新變量并使用 Swift 的整數轉換。然而這裡直接建立了指向結構體的指針,那麼在之後運作時讀取這個數量時,就可能為 0。這裡的不同類型告訴了編譯器這兩個值會屬于不同的記憶體對象中,是以編譯器不會更新
Int
的值。也就是說編譯器會根據類型資訊進行假設,一旦假設有誤,會蔓延到編譯管道中,最後可能産生意料之外的結果。不同版本的編譯器也可能導緻不同的結果。
指針類型導緻的 Bug:
- 可能導緻意料之外的行為
- 可能長時間難以被發現
- 可能在意外的時間爆發
- 在看起來無害的源碼改動後
- 編譯器更新後
C 指針有着 [嚴格别名](https://zh.wikipedia.org/wiki/别名_(計算 "嚴格别名")) 和 類型雙關[3] 規則。幸運的是,不需要了解這些規則也能在 Swift 中安全地使用指針。Swift 指針因為需要傳遞到 C,是以至少和 C 一樣嚴格以便安全地互操作。
類型指針
UnsafePointer
是類型指針,提供了 C 指針大部分底層功能,且不需要擔心指針類型安全問題,隻需要管理對象的生命周期和對象邊界就好。
泛型參數 T 表示存儲在記憶體裡的期望類型,Swift 對于類型指針是嚴格但簡單的。記憶體狀态包括該記憶體位址對應的類型,該記憶體位置隻能儲存該類型的值。類型指針隻讀寫該類型的值。在 C 中轉換指針類型的情況并不少見,且兩個指針都繼續引用同一記憶體。在 Swift 中,通路指針類型和記憶體類型不比對的指針會産生未定義行為,是以不允許轉換指針。這樣,編譯時強制使用該指針類型,而不需要在記憶體中儲存額外的運作時資訊或類型資訊,也無須執行額外的運作時檢查。
複合類型也是類似的,裡面的結構以正确的類型綁定。
擷取
UnsafePointer
的方式有兩種。
1. 通過已有變量擷取
這樣擷取的指針類型就和原來變量的類型一緻。數組則傳回數組元素類型的指針。
2. 直接配置設定記憶體
直接配置設定記憶體将綁定類型,傳回一個類型指針,但這時還未構造,可以通過
initalzize
進行構造,
assgin
進行重新配置設定,
deinitialize
進行析構。Swift 會保證這一過程的指針類型安全,而記憶體構造狀态由程式員來管理。
原始指針
如果需要将記憶體裡的位元組轉換成其他類型,則需要使用無類型的
UnsafeRawPointer
。原始指針會忽略記憶體綁定類型。
擷取
UnsafeRawPointer
的方式有兩種。
1. 通過已有 UnsafePointer
擷取
UnsafePointer
可以傳入
UnsafePointer
來構造
UnsafeRawPointer
。
然後通過
load(as:)
來指定讀取類型所對應的位元組數。例如,指定
UInt32
時,就會加載目前位址前 4 個位元組,生成
UInt32
值。
寫入通過
storeBytes(of:as:)
并指定類型。和類型指針不一樣,原始指針不會析構先前在記憶體裡的值。是以之前的引用依舊有效。比如給
Int64
記憶體區域的寫入
UInt32
位元組,寫入後依舊是
Int64
綁定類型。是以原來的類型指針依舊可以通路,而不會被自動轉換。
2. 通過已有變量擷取
擷取到的
UnsafeRawBufferPointer
和
UnsafeBufferPointer
類似,都是位元組的集合。
count
是變量類型的記憶體大小,索引是位元組的偏移量,索引的值是對應位元組的
UInt
值。
修改可以通過
withUnsafeMutableBytes
的方式擷取
UnsafeMutableRawBufferPointer
進行修改。
數組也有類似的方法,
count
對應的是數組數量x元素 跨步[4]。其中一些位元組會被填充用于元素對齊。
3. 從 Data
中擷取
Data
Foundation 中的
Data
有
withUnsafeBytes
方法,通過閉包傳回原始指針。
4. 直接配置設定記憶體
直接配置設定記憶體需要負責計算記憶體大小和位元組對齊方式。配置設定後和類型指針不一樣,不會綁定類型,也沒有進行構造。通過指定記憶體綁定的值和類型進行構造,就會傳回類型指針。這個過程是單向的,是以沒法使用原始指針進行析構,而要通過類型指針。而使用原始指針釋放時需要保證它處于未構造的狀态。
一般來說,類型指針更安全和友善,是以應該優先選用。某些情況下需要原始指針,比如将不相關的類型存儲在同一連續的可變長度記憶體區域裡。可以通過構造一部分記憶體為頭部,偏移頭部後的指針就指向裡面的元素,這種記憶體配置設定方式非常适合實作标準庫類型例如
Set
和
Dictionary
,但日常較少用到。原始指針是一種實作高性能資料結構的利器,但要注意的是,位元組偏移和資料對齊并不是一件簡單的事情。
原始指針通常還會用于将外部生成的位元組緩沖區(比如網絡的流)解碼為 Swift 類型。通過讀取前面位元組裡的值來确定後續需要讀取的類型資訊和大小。
原始指針依舊是類型安全的,雖然使用時需要保證記憶體布局,但其他方面并不會比類型指針更危險。
記憶體綁定 API
在最底層,Swift 給
UnsafeRawPointer
提供了記憶體綁定類型的 API。在使用它之前,再三考慮是否能盡量使用上層 API,因為需要對指針的類型安全完全負責。
顯式調用記憶體綁定相關的 API 時,你将會清晰地知道繞過指針類型安全的時機。這樣做的危險之處在于,代碼很容易在執行已有的類型指針時導緻未定義行為。唯一需要遵循的規則是:使用類型指針通路時需要和記憶體綁定的類型比對。雖然這個規則很簡單,但是遵循起來并不容易,因為不同的代碼在記憶體類型上隻是口頭約定,而編譯器并不會給出指引。
下面介紹了使用一些必須使用如此危險的 API 的例子,注意這些用法是如何保證安全的。
assumingMemoryBound(to:)
assumingMemoryBound(to:)
極少數情況下,代碼沒有保留類型指針,隻有原始指針,但明确知道記憶體綁定的類型,這時候就需要
assmuingMemoryBound(to:)
來告訴編譯器預期類型,轉換成對應的類型指針(注意:這裡的轉換隻是讓編譯器繞過類型檢查,并沒有實際發生轉換)。
比如這個例子中已知放在
RawContainer
容器裡類型指針一定是綁定
Int
類型,那麼就可以通過
assumingMemoryBound(to:)
來轉換成類型指針。使用它的前提是它的确已經被綁定為該類型了,運作時并不會進行檢查,是以正确性由使用者來保證。
比如在調用 C API
phread_create
時,構造了自定義線程上下文類型的
contextPtr
。但是閉包裡傳回的是抹去類型資訊的原始指針(該指針就是傳入的指針),這時也需要進行轉換。這是因為 C 函數聲明裡它是一個
void*
類型的實參,在 Swift 中被轉換為
UnsafeMutableRawPointer
。這種情況在使用 C 的 API 時偶爾會出現,因為沒有辦法確定類型安全。
比如在使用元組擷取類型指針時,類型是元組的類型。Swift 實作上確定了元組記憶體綁定的類型實際上是其元素的類型(元組元素相同的情況下),并在元素跨度的基礎上按照元素順序排列。是以如果需要使用元組中元素的類型指針,就可以通過手動類型擦除再轉換得到比對的類型指針。
結構體也是類似的,擷取的類型指針時,類型是結構體的類型。這個時候就可以通過轉換為原始指針并加上該屬性在結構體中的偏移量(通過
MemoryLayout
的
offset(of:)
)來獲得屬性的原始指針,然後再轉換得到比對的類型指針。通常,結構體屬性的記憶體布局是不确定的,是以擷取的指針隻能使用于該屬性。由于指向結構體屬性比較常用,是以 Swift 提供了一種更簡單的做法來避免使用不安全的 API。隻需要将結構體的屬性作為 inout 實參傳入,編譯器會隐式轉換為該函數實參所聲明的不安全指針類型。
bindMemory(to:capacity:)
bindMemory(to:capacity:)
bindMemory(to:capacity:)
可以用于更改記憶體綁定的類型。如果記憶體還沒有類型綁定,則将首次綁定為該類型。如果記憶體已經進行類型綁定,則将重新綁定為該類型,并且記憶體裡所有值都會變成該類型。
假設配置設定了一塊容納兩個
UInt16
的記憶體,通過原始指針調用
bindMemory(to:capacity:)
來改變為單個
UInt32
。這時隻發生了按位轉換,并沒有任何普通的類型轉換的安全檢查,在運作時也沒有做任何事情。這實際上隻是向編譯器聲明該記憶體位置更改了類型。傳回的新指針用于通路記憶體,舊指針通路時将會産生未定義行為,因為每個記憶體位置隻能綁定到一種類型上。
更改記憶體綁定的類型并不會在實體上修改記憶體,但需要把它當作改變記憶體的全局狀态來考慮。
這并不是類型安全的。首先,它轉換了已有的原始位元組,是以像原始指針一樣,Swift 已經不能確定記憶體布局裡的類型了。其次它比使用原始指針還要危險,因為它會讓已有的原類型指針失效。雖然那些指針的位址還有效,但是記憶體綁定的類型卻是不比對的,是以通路就會無法确定。而且那些指針可能被其他對象(如變量,集合)存儲,那麼它們也會間接受到影響。它屬于 Swift 底層的 原語[5] 之一,而非通常的應用層代碼。
其中一種
bindMemory(to:capacity:)
的常見錯誤用法是将它從記憶體中讀取不同類型的值,這樣可能會影響其他已有指針。
withMemoryRebound(to:capacity:body:)
withMemoryRebound(to:capacity:body:)
當有幾個外部的 API 的實參類型有資料類型上的差異,但又想避免來回複制資料時,就可以使用
withMemoryRebound(to:capacity:body)
來臨時更改記憶體綁定類型。這種情況在使用 C 的 API 時經常出現。
比如
Int8
的類型指針無法直接調用實參為
UInt8
的函數,這裡雖然可以重新配置設定一塊比對類型的記憶體并複制資料,但是這樣速度比較慢。隻需要在調用時臨時轉換一下就好了,這時候就可以使用
withMemoryRebound(to:capacity:body)
臨時綁定為對應類型的指針,它的作用域隻在閉包内。閉包傳回時,将會重新綁定為原始類型。這可以将臨時類型指針的通路和其他代碼的作用域分開。
但是它有一些嚴格的限制:
- 需要有原始的指針
- 轉換的類型和原始的類型需要有相同的跨度
是以,無法使用
withMemoryRebound(to:capacity:body)
的情況下,不得不使用
bindMemory(to:capacity:)
時,請遵循類似的規範:
- 限制更改綁定後指針的作用域
- 在作用域結束時重新綁定回原始類型
總結
assmuingMemoryBound(to:)
:從原始指針恢複預期類型的方法,需要已知記憶體綁定的類型才可以使用。
bindMemory(to:capacity:)
:更改記憶體綁定類型的狀态,是底層原語,會影響其他已有的類型指針。
withMemory(to:capacity:body)
:臨時更改記憶體綁定類型的狀态,在不得不更改記憶體綁定類型時優先考慮。在需要調用不比對類型的 C 的 API 時可以避免額外複制。
從原始記憶體中區分類型
如果記憶體區域的底層存儲隻暴露了原始指針,但又想用作不同特定類型元素的序列,可以使用下面介紹的技術。
可以為該類型元素建立一個包裝器,并引入記憶體邊界來友善調試,構造時根據元素跨度來計算緩沖區中的元素數量,還添加了
precondition
來驗證位元組對齊的正确性。這樣在使用索引讀取時,就可以通過計算偏移量來從原始緩沖區加載對應類型的元素了。由于原始記憶體中加載對應指針類型的行為是安全的,是以不需要擔心其他代碼通路該記憶體。這是一種不需要使用類型指針的又能安全地轉換位元組序列的方式。
最後
總結一下在 Swift 中使用指針的最佳實踐:
- 避免使用指針
- 不得不轉換記憶體綁定的類型時,不要使用類型指針(而 C 經常使用轉換指針類型的做法)
- 使用原始指針可以:
- 轉換原始位元組到不同的類型
- 把位元組流解碼成 Swift 類型
- 實作在連續記憶體裡持有不同類型的容器
推薦閱讀
✨ 讓 Objective-C 架構與 Swift 友好共存的秘籍
✨ 十年過去了,Swift 發展的怎麼樣了?
了解 Swift 中的數值計算
關注我們
我們是「老司機技術周報」,每周會釋出一份關于 iOS 的周報,也會定期分享一些和 iOS 相關的技術。歡迎關注。
另外,老司機技術周報周邊商店 正式上線,歡迎大家前往訂購~
支援作者
這篇文章的内容來自于 《WWDC20 内參》。在這裡給大家推薦一下這個專欄,專欄目前已經創作了 102 篇文章,隻需要 29.9 元。點選【閱讀原文】,就可以購買繼續閱讀 ~
WWDC 内參 系列是由老司機周報、知識小集合以及 SwiftGG 幾個技術組織發起的。已經做了幾年了,口碑一直不錯。 主要是針對每年的 WWDC 的内容,做一次精選,并号召一群一線網際網路的 iOS 開發者,結合自己的實際開發經驗、蘋果文檔和視訊内容做二次創作。
參考資料
[1]
WWDC20 - Unsafe Swift: https://developer.apple.com/videos/play/wwdc2020/10648
[2]
Address Sanitizer: https://developer.apple.com/videos/play/wwdc2015/413/
[3]
類型雙關: https://zh.wikipedia.org/wiki/類型雙關
[4]
跨步: https://en.wikipedia.org/wiki/Stride_of_an_array
[5]
原語: https://en.wikipedia.org/wiki/Language_primitive