在 Windows 上寫好資料接收邏輯,轉到雲端 Mac 執行 iOS 測試後,環形緩衝區可能只有在連續寫滿、讀出一部分、再度寫入時才會遺失資料。單次入列、出列看不出這類錯誤。以下將緩衝區抽成純 Swift 型別,先在 OSXMesh 雲端 Mac 的終端機驗證,再將同一組斷言移入應用程式的測試目標。
先明確定義滿空狀態與失敗語意
本文採用固定容量、先進先出的整數緩衝區:容量必須大於零;已滿時拒絕新元素,不覆寫舊資料;空白時讀取傳回 nil。批次寫入允許部分成功,傳回實際接收的數量,其餘輸入留給呼叫端處理。
這不是唯一的設計,但必須先選定一種。若產品需要覆寫最舊的資料,應另行實作型別與斷言,不能讓同一個 append 在不同呼叫位置暗中切換語意。
環形緩衝區的正確性不只是「不越界」:失敗的操作不能改變狀態,成功讀取的順序必須與接收順序一致。
以 head 表示下一次讀取的位置,以 count 區分滿空狀態。邏輯尾端由兩者推導,因此所有儲存槽都能使用。範例也限制了容量上限,避免索引相加時溢位,同時防止測試誤傳過大的容量。
寫出可獨立執行的最小實作
建立 RingCheck.swift,放入以下程式碼。它只依賴標準函式庫,不需要模擬器,也不需要專案簽署。先執行 swift --version,留下工具鏈資訊;若無法使用這個指令,請先在圖形介面完成開發工具的安裝與初始化。
struct IntRing {
let capacity: Int
private var storage: [Int?]
private var head = 0
private(set) var count = 0
init(capacity: Int) {
precondition((1...1_000_000).contains(capacity))
self.capacity = capacity
storage = Array(repeating: nil, count: capacity)
}
mutating func append(_ value: Int) -> Bool {
guard count < capacity else { return false }
storage[(head + count) % capacity] = value
count += 1
return true
}
mutating func pop() -> Int? {
guard count > 0 else { return nil }
let value = storage[head]!
storage[head] = nil
head = (head + 1) % capacity
count -= 1
return value
}
mutating func appendBatch(_ values: [Int]) -> Int {
var accepted = 0
for value in values {
guard append(value) else { break }
accepted += 1
}
return accepted
}
func snapshot() -> [Int] {
(0..<count).map {
storage[(head + $0) % capacity]!
}
}
}
這裡的強制解包依賴內部不變量:邏輯有效範圍內一定有值。若實作破壞了這項不變量,測試就應直接失敗,而不是用預設值掩蓋錯誤。讀取後清空儲存槽,也方便未來改用參考型別時及時釋放物件。
用小容量逼出繞回錯誤
將以下程式碼接在檔案末尾。容量為三的情境會先寫滿,再讀出兩個元素,最後跨越陣列尾端寫入。容量為一則能揭露將「預留一個空槽」的做法與目前容量契約混用的問題。
var ring = IntRing(capacity: 3)
precondition(ring.appendBatch([10, 20, 30]) == 3)
precondition(!ring.append(99))
precondition(ring.snapshot() == [10, 20, 30])
precondition(ring.pop() == 10)
precondition(ring.pop() == 20)
precondition(ring.appendBatch([40, 50, 60]) == 2)
precondition(ring.snapshot() == [30, 40, 50])
precondition(ring.pop() == 30)
precondition(ring.pop() == 40)
precondition(ring.pop() == 50)
precondition(ring.pop() == nil)
precondition(ring.count == 0)
var single = IntRing(capacity: 1)
precondition(single.pop() == nil)
precondition(single.append(7))
precondition(!single.append(8))
precondition(single.pop() == 7)
precondition(single.appendBatch([]) == 0)
要特別檢查拒絕寫入後的快照。只斷言傳回 false 並不足夠:錯誤的實作可能先推進索引,才發現容量已滿。空緩衝區的讀取也一樣,不能悄悄移動下一次有效讀取的位置。
用固定種子逐步比對參考佇列
邊界測試案例通過後,再加入模型測試。參考模型使用一般陣列,雖然移除首個元素較慢,但這裡容量很小,應優先確保程式碼容易檢查。每一步都比對內容與數量,比起只核對最終結果,更容易找出第一次出現差異的位置。
var seed: UInt64 = 0x5EED
func next() -> UInt64 {
seed = seed &* 6364136223846793005 &+ 1
return seed
}
for capacity in [1, 2, 3, 7] {
var tested = IntRing(capacity: capacity)
var model: [Int] = []
for step in 0..<20_000 {
if (next() >> 32) % 2 == 0 {
let value = Int(next() % 10_000)
let accepted = model.count < capacity
precondition(tested.append(value) == accepted)
if accepted { model.append(value) }
} else {
let expected = model.isEmpty ? nil : model.removeFirst()
precondition(tested.pop() == expected)
}
precondition(
tested.snapshot() == model,
"capacity=\(capacity), step=\(step)"
)
precondition(tested.count == model.count)
}
}
執行 swift RingCheck.swift,正常結束且沒有斷言失敗才算通過。這裡不對效能作任何結論;迴圈次數只代表測試規模。批次寫入路徑還應補上剩餘空間為零、剛好足夠、少一個儲存槽這三組明確案例,並核對傳回數量與未接收的輸入後綴。
產生器使用高位元決定操作,避免簡單產生器的低位元規律造成機械式交替。要重現問題,應保留初始種子、容量與失敗步驟,不要只記錄失敗當下的種子。
整合至專案並守住適用範圍
獨立指令稿通過後,將型別移入業務模組,並將斷言改成測試框架的相等斷言。至少保留以下三類檢查:
- 狀態約束:數量始終介於零與容量之間,失敗操作前後的快照完全相同。
- 順序約束:重複值與負數也必須依接收順序傳回,不能以集合比較取代陣列比較。
- 批次約束:傳回值等於實際寫入數量,呼叫端不能將未接收的輸入後綴視為已消費。
若業務邏輯提供清空介面,還要分別在空白、已滿及已繞回的狀態下清空,再重新寫滿,確認舊資料不會重新出現。若元素改為泛型,應另外檢查參考釋放,不能因為整數測試通過,就認定生命週期也正確。
這個實作只適合循序存取,不是無鎖佇列,也不適合直接放進有即時性要求的音訊回呼。要讓多個任務共用時,應先明確界定同步邊界,再測試並行行為。最後應一併提交實作、邊界測試案例與固定種子的模型測試,讓下次調整索引演算法時,仍能得到相同的驗收結論。
常見問題
為什麼保留 head 與 count,而不是只用兩個索引?
兩個索引相同時可能代表空,也可能代表滿。保留 head 與 count 後,count 為零就是空,等於容量就是滿,不必浪費一個儲存位置。
固定種子的隨機測試能取代邊界案例嗎?
不能。先明確測試容量為一、空讀、滿寫與跨尾端繞回,再以固定種子操作序列對照參考佇列;失敗時保留種子和操作編號。
多個任務可以同時修改這個緩衝區嗎?
不可以。範例沒有同步機制。若要共用,應透過 actor 或其他明確的同步邊界集中管理狀態,並另外驗證並行存取契約。