クラウド Mac で Swift コレクション分割とスライス境界を検証する

クラウド Mac で Swift コレクション分割とスライス境界を検証する

クラウド Mac で Swift コレクション分割とスライス境界を検証する

レコードの一括アップロード、データベースの検索条件の分割、リストの段階的な更新など、Swift プロジェクトでは「一定数の要素を取り出す」ヘルパー関数がよく使われます。配列全体では正しく動いていても、dropFirst が返すスライスに置き換えると、範囲外アクセスが起きたり、末尾の要素が抜け落ちたりすることがあります。このような純粋なロジックをクラウド Mac のビルド前チェックに組み込めば、シミュレータを起動せずにバッチ処理のリグレッションを検出できます。

まず分割の仕様を固める

今回検証するのは、連続した要素の分割だけです。各ブロックの要素数は指定したサイズ以下とし、入力の順序を維持します。最後のブロックは指定サイズに満たなくてもよく、要素を補ったり切り捨てたりはしません。空のコレクションには空の結果を返し、ブロックサイズがゼロ以下の場合はエラーをスローします。

たとえば、5 個の要素を 2 個ずつに分けるなら、要素数が 2、2、1 の 3 ブロックになる必要があります。サイズを満たす 2 ブロックだけを返してはいけません。ブロックサイズが入力の要素数より大きい場合、空でない入力全体が唯一のブロックになります。

分割関数の役割は、要素をどこで区切るかを決めることです。アップロードの同時実行数を制限するものでも、リモート API での受信成功を保証するものでもありません。各バッチの完了状態は、呼び出し側で別途記録する必要があります。

テストでは、分割結果をフラット化したものが入力と完全に一致することを確認します。ブロック数だけの確認では、途中の要素が重複し、代わりに別の要素が欠けていても検出できません。

コレクションのインデックスで境界を進める

ArraySlice は元の配列のインデックスを引き継ぎます。10 個の要素に対して dropFirst(5) を実行した場合、返されるスライスの開始インデックスはゼロではありません。さらに、ジェネリックな Collection のインデックスは整数とは限らないため、0..<count をそのまま添字アクセスに使うことはできません。

次の実装は、インデックスを 1 つずつ進めます。「開始位置 + ブロックサイズ」を計算せず、ランダムアクセスにも依存しません。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 {
    }
}

期待するブロック数は、商と余りから求めています。よく使われる「長さにブロックサイズを足して 1 を引き、それをブロックサイズで割る」という式では、引数が非常に大きい場合、割り算の前に整数オーバーフローが起きる可能性があります。

このテストでは、最後以外のブロックがすべて指定サイズを満たすこと、最後のブロックが空でないこと、フラット化した結果が入力と変わらないことを同時に確認します。不正な引数に対しては、指定したエラーだけを受け入れます。それ以外の想定外のエラーはスクリプトをそのまま失敗させ、広範な catch で不具合を握りつぶさないようにします。

クラウド Mac のビルド前チェックに組み込む

Swift ツールチェーンを設定済みのクラウド Mac で、リポジトリのルートから実行します。

set -eu
xcrun swift --version
xcrun swift Scripts/ChunkChecks.swift

ファイルをリポジトリの Scripts ディレクトリに配置し、時間のかかるアプリのビルドより前にこのステップを実行します。デフォルトの実行モードでは、いずれかの事前条件が満たされないと、終了ステータスが非ゼロになります。スクリプトに出力がないからといって、実行されていないわけではありません。ジョブの状態と終了コードで成否を判断してください。

安全性チェックを取り除く -Ounchecked を指定して、これらのアサーションを実行してはいけません。チームでこのロジックをテストターゲットに移す場合は、事前条件のチェックをテスト用のアサーションに置き換え、失敗時に入力の長さ、ブロックサイズ、バッチの位置が分かるようにします。

独立したスクリプトは、最小限の再現ケースを作るのに適しています。本格的に組み込んだ後は、プロジェクトで実際に使っている実装を呼び出してください。テストファイル内の正しいコピーだけを検証し、業務コードには古いアルゴリズムが残る、といった状態を避けるためです。

ストレージと業務ロジックの責務を分ける

スライスを返すと、その場でのコピーを減らせますが、元の入力が解放されるとは限りません。最後の小さなブロックだけを残した場合でも、スライスが配列全体のバッキングストレージを保持し続けることがあります。少数のバッチを長期間保存するなら、blocks.map { Array($0) } で独立した配列に変換できます。ただし、すべてのバッチを保持する場合、変換しても要素の総量は減りません。

入力が非常に大きい場合は、すべてのスライスを一度に収集する方法も適していません。同じ境界の前進ロジックをバッチごとのコールバックや遅延イテレーションに置き換えれば、呼び出し側が 1 ブロックの処理を終えてから次を要求できます。その際は、バックプレッシャーと失敗時のリトライ方針も明確にします。

最後に、ゼロ以外のインデックスから始まる入力で動作すること、最終ブロックが欠けないこと、バッチをフラット化しても順序が変わらないことの 3 点を確認します。分割の検証では、まず「どの要素がどのバッチに属するか」を保証し、ネットワークのリトライと並行実行の制御は別々にテストします。そうすることで、不具合が起きた層を明確に切り分けられます。

よくある質問

ArraySlice の先頭は添字ゼロで参照できますか。

常に参照できるわけではありません。元のコレクションの添字が維持されるため、startIndex と添字操作を使います。ゼロ始まりが必要なら Array に変換します。

小さなスライスだけ残せば元の配列のメモリは解放されますか。

保証されません。元のストレージを保持する場合があります。少量の結果を長期間保存するなら独立した配列に変換し、保存する結果の総量も制限します。

チーム専用のビルド環境をMac 1台に集約

Apple Silicon M4搭載の専有Mac miniを選び、チーム独自のツールチェーンとRunner設定をそのまま利用できます。日単位・週単位・月単位・四半期単位で契約でき、利用可能状況はコンソールでリアルタイムに確認できます。

ビルドノードをレンタル

ビルド要件に合った構成を選ぶ

まずメモリとストレージを確認し、課金期間とノードを選択してください。