雲端 Mac 上的 Swift 集合分批驗證:切片索引與末批邊界

雲端 Mac 上的 Swift 集合分批驗證:切片索引與末批邊界

批次上傳紀錄、拆分資料庫查詢條件或分批更新清單時,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 設定。提供日租、週租、月租與季租,實際可用狀態請以控制台即時資訊為準。

租用建置節點

依建置需求選擇規格

先確認記憶體與儲存空間,再選擇計費週期與節點。