Проверка разбиения коллекций Swift на облачном Mac

Проверка разбиения коллекций Swift на облачном Mac

Проверка разбиения коллекций Swift на облачном Mac

При пакетной загрузке записей, разбиении условий запросов к базе данных или поэтапном обновлении списка в 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

На облачном Mac с настроенным инструментарием Swift выполните из корневого каталога репозитория:

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

Поместите файл в каталог Scripts репозитория и запускайте этот шаг перед длительной сборкой приложения. В стандартном режиме выполнения нарушение любого предусловия приводит к ненулевому коду завершения. Отсутствие вывода не означает, что скрипт не запускался: результат следует оценивать по статусу задачи и коду завершения.

Не запускайте эти проверки с -Ounchecked: этот режим удаляет проверки безопасности. Если команда перенесёт логику в тестовый таргет, вызовы проверки предусловий следует заменить тестовыми утверждениями, чтобы сообщения об ошибках указывали конкретную длину входных данных, размер блока и позицию пакета.

Отдельный скрипт удобен для минимального воспроизводимого примера. После полноценной интеграции он должен вызывать реализацию, которую действительно использует проект. Иначе в тестовом файле окажется копия правильного кода, а в рабочем коде останется старый алгоритм.

Разделяем вопросы хранения и бизнес-логики

Возврат срезов позволяет сократить немедленное копирование, но не означает, что память исходной коллекции освобождается. Даже если оставить только небольшой последний блок, срез всё ещё может удерживать всё базовое хранилище массива. Если нужно надолго сохранить лишь несколько пакетов, их можно преобразовать в независимые массивы с помощью blocks.map { Array($0) }. Если же сохраняются все пакеты, общее число элементов от этого не уменьшается.

Для очень больших входных данных собирать все срезы сразу тоже не стоит. Ту же логику продвижения границ можно использовать с обратным вызовом для каждого пакета или в ленивом итераторе: вызывающий код обрабатывает один блок и только затем запрашивает следующий. При этом необходимо явно определить механизм обратного давления и стратегию повторных попыток при сбоях.

В итоге оставьте три контрольных пункта: входные данные с ненулевым начальным индексом обрабатываются правильно, последний блок не теряется, а после объединения блоков порядок элементов остаётся прежним. Сначала проверяйте, какие элементы относятся к каждому пакету, а сетевые повторные попытки и управление параллелизмом тестируйте отдельно. Тогда причину сбоя можно будет локализовать на конкретном уровне.

Часто задаваемые вопросы

Можно ли обращаться к первому элементу ArraySlice по индексу ноль?

Не всегда. Срез сохраняет индексы исходной коллекции. Используйте startIndex и операции с индексами либо преобразуйте срез в Array для индексации с нуля.

Освобождается ли память исходного массива, если оставить небольшой срез?

Это не гарантируется: срез может удерживать исходное хранилище. Для длительного хранения небольшого результата создайте отдельный массив и ограничьте общий объём результатов.

Выделенный Mac для сборок вашей команды

Выберите выделенный физический Mac mini на Apple Silicon M4 и используйте собственный набор инструментов и настройки runner. Оплата доступна за день, неделю, месяц или квартал. Актуальная доступность отображается в панели управления в реальном времени.

Арендовать узел для сборки

Подберите конфигурацию для сборок

Сначала определитесь с объёмом памяти и хранилища, затем выберите период оплаты и узел.