批次上傳紀錄、拆分資料庫查詢條件或分批更新清單時,Swift 專案常會用到「每次取出若干元素」的輔助函式。這類函式在完整陣列上運作正常,換成 dropFirst 傳回的切片後,卻可能發生索引越界或遺漏末批元素。將這類純邏輯檢查納入雲端 Mac 的建置前檢查,不必啟動模擬器,也能及早攔下分批處理的回歸問題。
先確立分批契約
這次只驗證連續分批:每批最多包含指定數量的元素,維持輸入順序,最後一批可以不足,不補齊,也不捨棄。空集合傳回空結果,批次大小小於或等於零時則拋出錯誤。
例如,五個元素每兩個分成一批,應得到長度為二、二、一的三批,而不是只傳回兩個完整批次。批次大小超過輸入長度時,整份非空輸入就是唯一的一批。
分批函式負責劃定元素邊界,不負責限制上傳的並行數,也不代表遠端 API 已成功接收資料。批次是否完成,必須由呼叫端另外記錄。
測試應確認攤平後的結果與輸入完全一致。若只檢查批次數量,就無法發現某個元素重複、同時另一個元素遺漏的問題。
以集合索引推進邊界
ArraySlice 會保留原陣列的索引。對十個元素執行 dropFirst(5) 後,取得的切片並不是從索引零開始。泛型 Collection 的索引甚至不一定是整數,因此不能直接用 0..<count 作為下標存取元素。
以下實作會逐一推進索引,既不計算「起點加上批次大小」,也不依賴隨機存取能力。將它儲存為 ChunkChecks.swift:
enum ChunkError: Error {
case invalidSize
}
func chunks<C: Collection>(
_ input: C,
size: Int
) throws -> [C.SubSequence] {
guard size > 0 else {
throw ChunkError.invalidSize
}
var result: [C.SubSequence] = []
var start = input.startIndex
while start != input.endIndex {
var end = start
var taken = 0
while end != input.endIndex && taken < size {
input.formIndex(after: &end)
taken += 1
}
result.append(input[start..<end])
start = end
}
return result
}
每次推進索引前,都會先確認尚未到達 endIndex,最後再以半開區間擷取切片。即使批次大小是 Int.max,也只會走過實際存在的元素,不會嘗試產生超出集合邊界的整數索引。
這裡不會反覆呼叫剩餘集合的 distance。對不支援隨機存取的集合而言,反覆計算剩餘長度可能造成不必要的重複走訪;目前實作推進索引的次數與輸入元素數量相同。
涵蓋切片與末批的組合情境
接著在同一個檔案末尾加入測試。先確立一個可以人工核對的範例,再遍歷多種輸入長度與批次大小,避免只證明某個特例能正常運作。
let base = Array(0..<23)
let slice = base.dropFirst(5).prefix(5)
precondition(slice.startIndex == 5)
let example = try chunks(slice, size: 2)
precondition(example.map { Array($0) } == [
[5, 6], [7, 8], [9]
])
for length in 0...17 {
let input = base.dropFirst(5).prefix(length)
for size in 1...8 {
let output = try chunks(input, size: size)
let expectedCount =
length / size + (length % size == 0 ? 0 : 1)
precondition(output.count == expectedCount)
precondition(output.flatMap { Array($0) } == Array(input))
for (position, block) in output.enumerated() {
precondition(!block.isEmpty)
precondition(block.count <= size)
if position < output.count - 1 {
precondition(block.count == size)
}
}
}
}
let huge = try chunks(slice, size: Int.max)
precondition(huge.map { Array($0) } == [Array(slice)])
for size in [0, -1, Int.min] {
do {
_ = try chunks(slice, size: size)
preconditionFailure("Expected invalidSize")
} catch ChunkError.invalidSize {
}
}
預期批次數以商和餘數計算,而不是採用常見的「長度加上批次大小減一,再相除」公式。後者在參數極大時,可能還沒進行除法就先發生整數溢位。
測試同時要求所有非末批都填滿、末批不為空,而且攤平後的結果不變。無效參數只接受指定的錯誤;其他非預期錯誤會直接讓腳本失敗,不能用範圍過廣的錯誤捕捉將問題吞掉。
納入雲端 Mac 的建置前檢查
在已設定好 Swift 工具鏈的雲端 Mac 上,從儲存庫根目錄執行:
set -eu
xcrun swift --version
xcrun swift Scripts/ChunkChecks.swift
將檔案放進儲存庫的 Scripts 目錄,並將這個步驟安排在耗時的應用程式編譯之前。在預設執行模式下,只要任一前置條件檢查失敗,就會產生非零結束狀態;腳本沒有輸出,不代表沒有執行,應以工作狀態和結束碼判定是否通過。
不要使用會移除安全檢查的 -Ounchecked 來執行這些斷言。若團隊將邏輯移入測試目標,應將前置條件檢查改為測試斷言,讓失敗訊息能指出具體的輸入長度、批次大小與批次位置。
獨立腳本適合用來建立最小重現案例。正式整合後,應呼叫專案實際使用的實作,避免測試檔案複製了一份正確程式碼,業務程式碼卻仍沿用舊演算法。
釐清儲存空間與業務邏輯的邊界
傳回切片可以減少當下的複製,但不代表原始輸入占用的儲存空間已經釋放。即使只留下最後一小批,切片仍可能保留整個陣列的底層儲存空間。需要長期保存少量批次時,可以用 blocks.map { Array($0) } 轉成獨立陣列;若所有批次都保留,元素總量並不會因此減少。
超大型輸入也不適合一次收集所有切片。可以將同一套邊界推進邏輯改為逐批回呼或惰性迭代,讓呼叫端處理完一批後再要求下一批,並明確訂定背壓與失敗重試策略。
最後保留三個檢查項目:非零起始索引的輸入能通過、末批沒有遺漏、批次攤平後順序不變。分批驗證先確保「哪些元素屬於哪一批」,網路重試與並行排程再分別測試,才能將問題定位到明確的層次。
常見問題
ArraySlice 的第一個元素可以用索引零存取嗎?
不能直接假設。ArraySlice 會保留原集合的索引,應使用 startIndex、endIndex 與集合索引操作;需要從零開始的獨立陣列時,再明確轉成 Array。
只保存少量切片就會釋放原陣列嗎?
不保證。切片可能繼續保留原陣列的底層儲存空間;少量結果若要長期保存,應轉成獨立陣列,並限制所有結果累積占用的空間。
讓整台 Mac 專心執行團隊的建置工作
選擇 Apple Silicon M4 獨享實體 Mac mini,保留團隊原有的工具鏈與 runner 設定。提供日租、週租、月租與季租,實際可用狀態請以控制台即時資訊為準。
租用建置節點