Swift-Collections auf dem Cloud-Mac in Blöcke teilen und testen

Swift-Collections auf dem Cloud-Mac in Blöcke teilen und testen

Swift-Collections auf dem Cloud-Mac in Blöcke teilen und testen

Beim gebündelten Hochladen von Datensätzen, beim Aufteilen von Datenbankabfragen oder beim schrittweisen Aktualisieren von Listen kommt in Swift-Projekten häufig eine Hilfsfunktion zum Einsatz, die jeweils eine bestimmte Anzahl von Elementen entnimmt. Auf einem vollständigen Array funktioniert sie korrekt. Mit einem von dropFirst zurückgegebenen Slice kann sie dagegen die Indexgrenzen überschreiten oder die letzten Elemente auslassen. Wird diese reine Logik auf einem Cloud-Mac vor dem Build geprüft, lassen sich Regressionen in der Stapelverarbeitung frühzeitig abfangen — ganz ohne Simulator.

Zuerst den Vertrag für die Blockbildung festlegen

Hier prüfen wir ausschließlich die Aufteilung in aufeinanderfolgende Blöcke: Jeder Block enthält höchstens die angegebene Anzahl von Elementen, die Eingabereihenfolge bleibt erhalten, und der letzte Block darf kleiner sein. Es wird weder aufgefüllt noch etwas verworfen. Eine leere Collection liefert ein leeres Ergebnis; bei einer Blockgröße kleiner oder gleich null wird ein Fehler ausgelöst.

Fünf Elemente, aufgeteilt in Zweiergruppen, müssen beispielsweise drei Blöcke mit zwei, zwei und einem Element ergeben — nicht nur zwei vollständige Blöcke. Ist die Blockgröße größer als die Eingabelänge, bildet die gesamte nicht leere Eingabe den einzigen Block.

Die Funktion legt fest, welche Elemente zu welchem Block gehören. Sie begrenzt weder die Anzahl paralleler Uploads noch bestätigt sie, dass die entfernte API die Daten erfolgreich angenommen hat. Den Abschlussstatus der einzelnen Batches muss die aufrufende Schicht separat erfassen.

Die Tests sollten prüfen, ob die wieder zusammengefügten Blöcke exakt der Eingabe entsprechen. Nur die Anzahl der Blöcke zu kontrollieren, reicht nicht aus: Ein doppelt enthaltenes Element und ein gleichzeitig fehlendes anderes Element blieben dabei unentdeckt.

Blockgrenzen mit Collection-Indizes verschieben

ArraySlice behält die Indizes des ursprünglichen Arrays bei. Wird auf ein Array mit zehn Elementen dropFirst(5) angewendet, beginnt der resultierende Slice nicht bei Index null. Bei einer generischen Collection müssen Indizes nicht einmal Ganzzahlen sein. Ein direkter Subskriptzugriff mit Werten aus 0..<count ist deshalb nicht zulässig.

Die folgende Implementierung bewegt die Indizes schrittweise weiter. Sie berechnet weder „Startindex plus Blockgröße“ noch setzt sie wahlfreien Zugriff voraus. Speichern Sie sie als 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
}

Vor jedem Weiterschalten wird geprüft, ob endIndex noch nicht erreicht ist. Anschließend wird der Slice über einen halboffenen Bereich entnommen. Selbst bei einer Blockgröße von Int.max durchläuft der Algorithmus nur tatsächlich vorhandene Elemente und versucht nicht, ganzzahlige Indizes außerhalb der Collection zu erzeugen.

Die verbleibende Länge wird hier nicht wiederholt mit distance ermittelt. Bei Collections ohne wahlfreien Zugriff könnte das zu unnötigen mehrfachen Durchläufen führen. In dieser Implementierung entspricht die Anzahl der Indexschritte der Anzahl der Eingabeelemente.

Slices und unvollständige Schlussblöcke gemeinsam testen

Ergänzen Sie die Tests am Ende derselben Datei. Zunächst legen wir ein leicht von Hand überprüfbares Beispiel fest. Danach durchlaufen wir verschiedene Eingabelängen und Blockgrößen, damit der Test nicht nur einen einzelnen Sonderfall bestätigt.

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 {
    }
}

Die erwartete Blockanzahl wird aus Quotient und Rest berechnet, nicht mit der verbreiteten Formel „Länge plus Blockgröße minus eins, anschließend durch die Blockgröße teilen“. Bei sehr großen Parametern kann diese Formel bereits vor der Division einen Ganzzahlüberlauf verursachen.

Die Tests verlangen zugleich, dass alle Blöcke außer dem letzten vollständig gefüllt sind, der letzte Block nicht leer ist und das Zusammenfügen der Blöcke die ursprüngliche Sequenz unverändert ergibt. Bei ungültigen Parametern wird nur der vorgesehene Fehler akzeptiert. Andere unerwartete Fehler lassen das Skript unmittelbar fehlschlagen und dürfen nicht durch ein zu allgemein gehaltenes catch verschluckt werden.

Die Prüfung vor dem Build auf dem Cloud-Mac einbinden

Führen Sie auf einem Cloud-Mac mit eingerichteter Swift-Toolchain im Stammverzeichnis des Repositorys Folgendes aus:

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

Legen Sie die Datei im Verzeichnis Scripts des Repositorys ab und führen Sie diesen Schritt vor dem zeitaufwendigen App-Build aus. Im standardmäßigen Ausführungsmodus führt jede verletzte Vorbedingung zu einem Exitcode ungleich null. Keine Ausgabe bedeutet nicht, dass das Skript nicht ausgeführt wurde: Maßgeblich sind der Jobstatus und der Exitcode.

Führen Sie diese Prüfungen nicht mit -Ounchecked aus, da dieser Modus Sicherheitsprüfungen entfernt. Wenn das Team die Logik in ein Test-Target überführt, sollten die Vorbedingungsprüfungen durch Test-Assertions ersetzt werden. Die Fehlermeldungen können dann die konkrete Eingabelänge, Blockgröße und Batchposition nennen.

Ein eigenständiges Skript eignet sich für ein minimales reproduzierbares Beispiel. Nach der regulären Einbindung sollte es jedoch die tatsächlich im Projekt verwendete Implementierung aufrufen. Andernfalls enthält die Testdatei womöglich eine korrekte Kopie, während im Anwendungscode weiterhin der alte Algorithmus steckt.

Speicherverhalten und Geschäftslogik getrennt betrachten

Slices zurückzugeben, kann sofortige Kopiervorgänge reduzieren. Das bedeutet aber nicht, dass der Speicher der ursprünglichen Eingabe freigegeben wird. Selbst wenn nur ein kleiner letzter Block übrig bleibt, kann dessen Slice weiterhin den gesamten zugrunde liegenden Array-Speicher halten. Sollen nur wenige Batches langfristig aufbewahrt werden, lassen sie sich mit blocks.map { Array($0) } in eigenständige Arrays umwandeln. Werden dagegen alle Batches behalten, sinkt dadurch die Gesamtzahl der Elemente nicht.

Bei sehr großen Eingaben ist es ebenfalls ungünstig, sämtliche Slices auf einmal zu sammeln. Dieselbe Logik zum Verschieben der Blockgrenzen lässt sich als Callback pro Batch oder als verzögerte Iteration umsetzen. Der aufrufende Code verarbeitet dann einen Block und fordert erst anschließend den nächsten an. Dabei müssen Backpressure und die Strategie für Wiederholungsversuche nach Fehlern ausdrücklich festgelegt werden.

Am Ende bleiben drei Prüfpunkte: Eingaben mit einem Startindex ungleich null funktionieren, der letzte Block geht nicht verloren, und nach dem Zusammenfügen bleibt die Reihenfolge erhalten. Die Blockbildung muss zunächst zuverlässig klären, welche Elemente zu welchem Batch gehören. Netzwerk-Retries und die Steuerung paralleler Abläufe werden separat getestet, damit sich Fehler einer klar abgegrenzten Schicht zuordnen lassen.

Häufig gestellte Fragen

Kann ich das erste Element eines ArraySlice mit Index null lesen?

Nicht grundsätzlich. ArraySlice behält die Indizes der ursprünglichen Collection. Nutze startIndex und Indexoperationen oder wandle den Slice für nullbasierte Indizes in ein Array um.

Gibt ein kleiner Slice den Speicher des ursprünglichen Arrays frei?

Das ist nicht garantiert. Ein Slice kann den zugrunde liegenden Speicher halten. Kopiere kleine, langlebige Ergebnisse in eigene Arrays und begrenze auch die gesamte Ergebnismenge.

Den ganzen Mac nur für Builds Ihres Teams nutzen

Mieten Sie einen exklusiven Apple Silicon M4 Mac mini und behalten Sie Ihre eigene Toolchain und Runner-Konfiguration. Abrechnung pro Tag, Woche, Monat oder Quartal. Die aktuelle Verfügbarkeit sehen Sie in Echtzeit in der Konsole.

Build-Node mieten

Konfiguration passend zum Build wählen

Prüfen Sie zuerst Arbeitsspeicher und Speicherplatz. Wählen Sie anschließend Abrechnungszeitraum und Node aus.