Swift 中動手封裝字節
今天,我想嘗試封裝 Float32 類型數據到 SQLite 二進制大對象?Binary Large Object (BLOB)? 組中。當然,我可以使用 JSON,protobuf,或是其他一些編碼方式。除此之外, NSNumber , NSArray , NSCoder 和 plist 文件也是不錯的選擇。
不過,我希望以更加 Swift 的方式來實現,有點類似 C 語言風格,實現迅速且不會引入任何相關性,解碼器(decoder)也非常簡單,可以在任何平臺上實現。
PointerEncoder
我們將在 PointEncoder 結構體中實現最終的接口:
struct PointEncoder {
// 解碼過程中,如果我們得到一個相當大的數值,可以假定為服務器拒絕或是損壞的數據
static let MaxPoints = 1_310_719
// 容量大小
private static let _sizeOfCount = sizeof(Int64.self)
// 一個點由兩個 Float32 類型數據組成,所占內存大小如下
private static let _sizeOfPair = 2 * sizeof(Float32.self)
static func encodePoints(points: [CGPoint]) -> NSData? {
static func decodePoints(data: NSData) -> [CGPoint]
}
點數組的最大容量 MaxPoints 被限制在大約 10 MB,這已經遠遠滿足示例中的限定值。試想在移動蜂窩網絡或是信號不佳環境下的 WiFi,傳遞如此數量的點給服務器,會迫使服務器斷開連接。當然你可以視根據自身情況選擇合適的大小。
接下來,我們需要獲取以上類型所占內存大小。計算公式非常簡單,一旦明確了不同類型所占內存的大小,就能集中在一個地方定義它們,而不是分散在各地調用 sizeof() 函數。
編碼(encoding)
下面讓我們看看 encodePoints 的實現
guard !points.isEmpty && points.count < MaxPoints else { return nil }
// 緩存區的最大容量值
let bufferLength = _sizeOfCount + (points.count * _sizeOfPair)
precondition(bufferLength >= (_sizeOfCount + _sizeOfPair), "Empty buffer?")
precondition(bufferLength < megabytes(10), "Buffer would exceed 10MB")
第一步確保編碼內容不為空,且不超過容量最大值。
第二步計算緩存區的大小,不宜過大或過小。注意第一步中的 isEmpty 理論上來說排除了緩存區為空的可能,不過倘若之后有人重構了代碼,那就不一定了。緊接著我們會檢查緩存區分配過多內存的可能。
以上是我喜歡進行的額外安全檢查之一,主要考慮到一些二把刀程序員的尿性。試想之后有人了重構代碼,并意外引入一個錯誤,但優秀的程序員不太可能會刪除 precondition 斷言語句。 precondition 語句之后緊跟著是分配內存,請時刻注意“這里可能發生危險,要格外小心!”。
let rawMemory = UnsafeMutablePointer<Void>.alloc(bufferLength)
// Failed to allocate memory
guard rawMemory != nil else { return nil }
下一步開始真正創建緩存區,一旦創建失敗就跳出。
控制程序應對內存不足的情況非常困難。如果是因為內存不足造成創建類實例失敗,程序應該調用 abort() 方法,因為簡單的日志輸出或 print 語句依舊涉及一些內存分配操作,這就無法以日志的形式通知失敗結果(會使得所有的構造方法失敗)。
考慮另外一種情況,分配大的緩存區有會可能失敗,但堆碎片(heap fragmentation)可能還存在額外可用的內存。因此,如何優雅地處理它是一門學問(尤其像在 iOS 這種受限的環境中)。
UnsafeMutablePointer<Int64>(rawMemory).memory = Int64(points.count)
let buffer = UnsafeMutablePointer<Float32>(rawMemory + sizeOfCount)
這里有一點要注意。等式右邊將 points.count 類型轉換成了 64 位類型的整數,因此不隨平臺變化而發生改變(Swift 的 Int 類型在編譯時會自適應平臺,32 位平臺下為 32 位整數,同理 64 位平臺下為 64 位整數)。我們可不希望用戶在升級設備后,引發崩潰或數據損壞問題。
等式左側將 rawMemory 強轉成 Int64 指針類型,然后將其指向的內存內容賦值為 Int64(points.count) 。64 位整數占 8 個字節,因此分配的前 8 個字節內存包含了點個數( sizeOfCount )信息。
最后,我們將指針偏移 8 個字節(正如前面所說的),指針指向緩存區首地址。
for (index, point) in points.enumerate() {
let ptr = buffer + (index * 2)
// Store the point values.
ptr.memory = Float32(point.x)
ptr.advancedBy(1).memory = Float32(point.y)
}
接下來進行遍歷 points 點數組操作。我們對 UnsafeMutablePointer 指針進行簡單偏移量計算,得到緩沖區中的相關位置。值得注意的是,swift 中的不安全指針僅知道當前所使用的類型大小,所有指針偏移量都是以當前類型為單位,而非字節!(不過 Void 類型指針無法確定類型的大小,所以這種情況是以字節為單位的)。
因此,通過對基址進行 index * 2 偏移累加,得到下一對點成員(注:即x,y點坐標)的地址。然后我們為當前指針指向的內存區域作賦值操作。
接著我使用了 ptr.advancedBy() 方法,并未保留指針的引用,同時也沒有設置 ptr 為可變指針。這僅僅是我個人喜好。你可以使用 + 或 advancedBy() ,這兩者作用一致。
return NSData(
bytesNoCopy: rawMemory,
length: bufferLength,
deallocator: { (ptr, length) in
ptr.destroy(length)
ptr.dealloc(length)
})
最后要注意的,我們將數據返回給調用者。此時已經分配了一個合適的緩存區,接著使用 bytesNoCopy 進行初始化操作,將適當的長度以及閉包作為參數傳遞給函數。
為什么要傳遞一個用作釋放的閉包參數(deallocator)呢?從技術上講,你或許可以使用 NSData(bytesNoCopy:length:freeWhenDone:) 僥幸實現,但無法保證沒有意外發生。倘若 Swift runtime 沒有使用系統默認的 malloc/free 方法,而是采用其他內存分配方式,那么你將得到一個報錯。
如果我們的緩存區恰巧需要存儲一些復雜的 Swift 類型,適時的釋放操作是必須的:你必須調用 ptr.destroy(count) 來進行釋放,需要借助引用類型,遞歸枚舉用例等等,否則會造成內存泄露。在本例中,我們知道 Float32 和 Int64 類型所占位數,從技術正確角度來講,調用 destroy 方法能夠更好的保證這一點。
解碼(decoding)
guard
data.bytes != nil &&
data.length > (_sizeOfCount + _sizeOfPair)
else { return [] }
首先,我們確保 NSData 中的指針不為 nil ,并且足夠容納 Int64 數量的點數組。這為接下來的操作鋪平了道路,不需要再進行一些額外的安全檢查。
let rawMemory = data.bytes
let buffer = rawMemory + _sizeOfCount
// 從內存中獲取到 Int64 類型的點個數
let pointCount64 = UnsafePointer<Int64>(rawMemory).memory
precondition(
Int64(MaxPoints) < Int64(Int32.max),
"MaxPoints would overflow on 32-bit platforms")
precondition(
pointCount64 > 0 && pointCount64 < Int64(MaxPoints),
"Invalid pointCount = \(pointCount64)")
let pointCount = Int(pointCount64)
接下來設置我們的指針。再次將原始指針強制轉換成 Int64 類型的指針,此時我們使用了非可變指針,這是出于只讀操作的考慮。
注意到前面代碼中我將點個數類型設置為 64 位,這樣確保了 Int32.max 不會溢出或下溢;C 語言中經常使用 if(value + x > INT_MAX) 判斷檢查是否溢出,屬于未定義行為之一。現在請放下手上工作思考一分鐘:計算機是如何處理 value + x 超出整型最大值的情況呢?答案是:無法繼續累加,轉而變成一個負值。那么當我們使用一個超大的負值進行類似 malloc 或 is_admin() 操作時會發生什么情況呢?這是我留給讀者的一個課后小作業。
末行代碼將點個數轉換成 Int 類型。 32 位平臺上一旦值超過 Int32.max ,我們將會陷入“萬劫不復”。Swift 相對于 C 語言要安全的多 —— 我們必須時刻警惕值溢出或下溢的情況發生。一旦此類情況發生,程序就會在運行時崩潰,值得慶幸的是,程序在掛掉之前會給出清晰的錯誤提示。
64 位平臺上,絕對有可能超過 4GB 容量點數組的情況(數值超過大約42億),代碼需要進一步重構。不過對于我的需求來說無關緊要,所以這里采用了硬編碼限制了容量。這也使得在 64 位系統上創建的值無法加載到 32 位系統當中(這只是理論上最大值的情況,實際我所使用的容量將會小得多)。
var points: [CGPoint] = []
points.reserveCapacity(pointCount)
for ptr in (0..<pointCount).map({
UnsafePointer<Float32>(buffer) + (2 * $0)
}) {
points.append(
CGPoint(
x: CGFloat(ptr.memory),
y: CGFloat(ptr.advancedBy(1).memory))
)
}
return points
代碼也很簡單。我們設定數組的備用容量,以避免重新分配。這不會對性能造成太大影響,畢竟我們已經知道了最大限制容量,所以這么做沒什么問題。
另外,指針類型為 Float32 ,Swift 知道該類型所占內存大小。我們只需要將索引值乘以2( 2 * $0 )得到下一對坐標點的指針,然后從指針指向的內存區域讀取數值。
關于測試
毫無疑問,類似這種類型都應該使用 Address Sanitizer 內存檢測用具來幫助捕獲任何濫用內存的問題,并且在產品發布前應該進行大量的代碼審查(或借助 AFL fuzzing 同樣能夠方便揭露一些問題)。
我從來不敢 100% 保證代碼中任何有關線程或內存的部分不會出現紕漏。我甚至無法 100% 確定本文用例沒有 bug。不過我使用 Addess Sanitizer 工具并沒有發現任何問題,但我堅信一個好的程序員應該有敬畏之心。時刻警惕那些可能出現的錯誤或失誤(如果你發現本文有任何紕漏,請留言告知我!)
包括你在內,沒有人優秀到寫代碼可以完全避免緩沖區溢出。
總結
Swift 編譯器始終重視安全問題,但它有時也令人心寒。如果你保證不做一些調皮的事情,它會完全信任你。如果你有必要做一些字節或 void 指針操作,請重新創建一個 .swift 文件然后在里面使用。
最終實現
我已經在最終實現的用例 gist 中嵌入了要點和詳細注釋。如果對你有幫助的話,請盡情使用它。
// Written by Russ Bishop
// MIT licensed, use freely.
// No warranty, not suitable for any purpose. Use at your own risk!
struct PointEncoder {
// When parsing if we get a wildly large value we can
// assume denial of service or corrupt data.
static let MaxPoints = 1_310_719
// How big an Int64 is
private static let _sizeOfCount = sizeof(Int64.self)
// How big a point (two Float32s are)
private static let _sizeOfPair = 2 * sizeof(Float32.self)
static func encodePoints(points: [CGPoint]) -> NSData? {
guard !points.isEmpty && points.count < MaxPoints else { return nil }
// Total size of the buffer
let bufferLength = _sizeOfCount + (points.count * _sizeOfPair)
precondition(bufferLength >= (_sizeOfCount + _sizeOfPair), "Empty buffer?")
precondition(bufferLength < megabytes(10), "Buffer would exceed 10MB")
let rawMemory = UnsafeMutablePointer<Void>.alloc(bufferLength)
// Failed to allocate memory
guard rawMemory != nil else { return nil }
// Store the point count in the first portion of the buffer
UnsafeMutablePointer<Int64>(rawMemory).memory = Int64(points.count)
// The remaining bytes are for the Float32 pairs
let buffer = UnsafeMutablePointer<Float32>(rawMemory + _sizeOfCount)
// Store the points
for (index, point) in points.enumerate() {
// Since buffer is UnsafeMutablePointer<Float32>, addition counts
// the number of Float32s, *not* the number of bytes!
let ptr = buffer + (index * 2)
// Store the point values.
ptr.memory = Float32(point.x)
ptr.advancedBy(1).memory = Float32(point.y)
}
// We can tell NSData not to bother copying memory.
// For consistency and since we can't guarantee the memory allocated
// by UnsafeMutablePointer can just be freed, we provide a deallocator
// block.
return NSData(
bytesNoCopy: rawMemory,
length: bufferLength,
deallocator: { (ptr, length) in
// If ptr held more complex types, failing to call
// destroy will cause lots of leakage.
// No one wants leakage.
ptr.destroy(length)
ptr.dealloc(length)
})
}
static func decodePoints(data: NSData) -> [CGPoint] {
// If we don't have at least one point pair
// and a size byte, bail.
guard
data.bytes != nil &&
data.length > (_sizeOfCount + _sizeOfPair)
else { return [] }
let rawMemory = data.bytes
let buffer = rawMemory + _sizeOfCount
// Extract the point count as an Int64
let pointCount64 = UnsafePointer<Int64>(rawMemory).memory
// Swift is safer than C here; you can't
// accidentally overflow/underflow and not
// trigger a trap, but I am still checking
// to provide better error messages.
// In all cases, better to kill the process
// than corrupt memory.
precondition(
Int64(MaxPoints) < Int64(Int32.max),
"MaxPoints would overflow on 32-bit platforms")
precondition(
pointCount64 > 0 && pointCount64 < Int64(MaxPoints),
"Invalid pointCount = \(pointCount64)")
// On 32-bit systems this would trap if
// MaxPoints were too big and we didn't
// check above.
let pointCount = Int(pointCount64)
precondition(
_sizeOfPair + (_sizeOfCount * pointCount) <= data.length,
"Size lied or buffer truncated")
var points: [CGPoint] = []
// Small optimization since
// we know the array size
points.reserveCapacity(pointCount)
for ptr in (0..<pointCount).map({
// buffer points past the size header
// Again, since the pointer knows we are
// counting Float32 values we want the
// number of Float32s, *not* their size
// in bytes!
UnsafePointer<Float32>(buffer) + (2 * $0)
}) {
points.append(
CGPoint(
x: CGFloat(ptr.memory),
y: CGFloat(ptr.advancedBy(1).memory))
)
}
return points
}
}
func kilobytes(value: Int) -> Int {
return value * 1024
}
func megabytes(value: Int) -> Int {
return kilobytes(value * 1024)
}
func gigabytes(value: Int) -> Int {
return megabytes(value * 1024)
}
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問http://swift.gg。
來自:http://swift.gg/2016/09/01/packing-bytes-in-swift/