Проверка кольцевого буфера iOS на облачном Mac

Проверка кольцевого буфера iOS на облачном Mac

Проверка кольцевого буфера iOS на облачном Mac

После разработки логики приёма данных на Windows и переноса тестов iOS на облачный Mac может обнаружиться, что кольцевой буфер теряет данные лишь при определённой последовательности действий: его заполняют до конца, считывают часть элементов, а затем снова записывают данные. Одиночное добавление и извлечение такого дефекта не выявляют. Ниже мы выделим буфер в отдельный тип на чистом Swift, проверим его в терминале облачного Mac от OSXMesh, а затем перенесём те же проверки в тестовый таргет приложения.

Сначала определим поведение при заполнении, опустошении и отказе

В этой статье используется целочисленный FIFO-буфер фиксированной ёмкости. Ёмкость должна быть больше нуля; заполненный буфер отклоняет новые элементы, не перезаписывая старые; чтение из пустого буфера возвращает nil. Пакетная запись допускает частичный успех и возвращает число фактически принятых элементов. Обработка оставшихся входных данных остаётся за вызывающим кодом.

Это не единственный вариант реализации, но сначала нужно выбрать конкретный контракт. Если продукту требуется перезапись самых старых данных, для этого следует создать отдельный тип и отдельные проверки. Нельзя допускать, чтобы один и тот же append незаметно менял семантику в зависимости от места вызова.

Корректность кольцевого буфера — это не только отсутствие выхода за границы массива. Неудачная операция не должна менять состояние, а успешно считанные элементы должны идти в порядке их приёма.

Переменная head указывает на позицию следующего чтения, а count позволяет различать полное и пустое состояния. Логический конец вычисляется по этим двум значениям, поэтому доступны все ячейки хранилища. В примере задан верхний предел ёмкости: он предотвращает переполнение при сложении индексов и защищает от случайной передачи огромной ёмкости в тесте.

Напишем минимальную реализацию для самостоятельного запуска

Создайте файл RingCheck.swift и добавьте в него следующий код. Он использует только стандартную библиотеку, поэтому не требует ни симулятора, ни настройки подписи проекта. Сначала выполните swift --version, чтобы сохранить сведения об инструментарии. Если команда недоступна, предварительно установите и инициализируйте инструменты разработки через графический интерфейс.

struct IntRing {
    let capacity: Int
    private var storage: [Int?]
    private var head = 0
    private(set) var count = 0

    init(capacity: Int) {
        precondition((1...1_000_000).contains(capacity))
        self.capacity = capacity
        storage = Array(repeating: nil, count: capacity)
    }

    mutating func append(_ value: Int) -> Bool {
        guard count < capacity else { return false }
        storage[(head + count) % capacity] = value
        count += 1
        return true
    }

    mutating func pop() -> Int? {
        guard count > 0 else { return nil }
        let value = storage[head]!
        storage[head] = nil
        head = (head + 1) % capacity
        count -= 1
        return value
    }

    mutating func appendBatch(_ values: [Int]) -> Int {
        var accepted = 0
        for value in values {
            guard append(value) else { break }
            accepted += 1
        }
        return accepted
    }

    func snapshot() -> [Int] {
        (0..<count).map {
            storage[(head + $0) % capacity]!
        }
    }
}

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

Выявим ошибки перехода через конец массива на малой ёмкости

Добавьте следующий код в конец файла. В сценарии с ёмкостью три буфер сначала заполняется, затем из него извлекаются два элемента, после чего запись переходит через конец массива. Ёмкость один помогает выявить смешение текущего контракта ёмкости с подходом, при котором одну ячейку всегда оставляют свободной.

var ring = IntRing(capacity: 3)
precondition(ring.appendBatch([10, 20, 30]) == 3)
precondition(!ring.append(99))
precondition(ring.snapshot() == [10, 20, 30])
precondition(ring.pop() == 10)
precondition(ring.pop() == 20)
precondition(ring.appendBatch([40, 50, 60]) == 2)
precondition(ring.snapshot() == [30, 40, 50])
precondition(ring.pop() == 30)
precondition(ring.pop() == 40)
precondition(ring.pop() == 50)
precondition(ring.pop() == nil)
precondition(ring.count == 0)

var single = IntRing(capacity: 1)
precondition(single.pop() == nil)
precondition(single.append(7))
precondition(!single.append(8))
precondition(single.pop() == 7)
precondition(single.appendBatch([]) == 0)

Особенно важно проверить снимок содержимого после отказа в записи. Одной проверки возвращаемого значения false недостаточно: ошибочная реализация может сначала сдвинуть индекс и лишь затем обнаружить, что буфер заполнен. То же относится к чтению из пустого буфера: оно не должно незаметно менять позицию следующего успешного чтения.

Пошагово сравним буфер с эталонной очередью при фиксированном начальном значении

Когда граничные сценарии пройдут проверку, добавьте тестирование по модели. Эталонной моделью служит обычный массив. Удаление первого элемента в нём относительно медленное, но при такой малой ёмкости важнее, чтобы код было легко проверить. Сравнение содержимого и количества элементов после каждого шага позволяет быстрее найти первое расхождение, чем проверка только итогового результата.

var seed: UInt64 = 0x5EED
func next() -> UInt64 {
    seed = seed &* 6364136223846793005 &+ 1
    return seed
}

for capacity in [1, 2, 3, 7] {
    var tested = IntRing(capacity: capacity)
    var model: [Int] = []

    for step in 0..<20_000 {
        if (next() >> 32) % 2 == 0 {
            let value = Int(next() % 10_000)
            let accepted = model.count < capacity
            precondition(tested.append(value) == accepted)
            if accepted { model.append(value) }
        } else {
            let expected = model.isEmpty ? nil : model.removeFirst()
            precondition(tested.pop() == expected)
        }

        precondition(
            tested.snapshot() == model,
            "capacity=\(capacity), step=\(step)"
        )
        precondition(tested.count == model.count)
    }
}

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

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

Встроим код в проект и обозначим границы применения

После успешного запуска отдельного скрипта перенесите тип в модуль бизнес-логики, а проверки замените утверждениями о равенстве из тестового фреймворка. Сохраните как минимум следующие три группы проверок:

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

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

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

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

Зачем хранить head и count вместо двух индексов?

Совпадение двух индексов может означать и пустой, и полный буфер. Счётчик count однозначно различает эти состояния и позволяет использовать все ячейки.

Заменяет ли тест с фиксированным seed граничные проверки?

Нет. Сначала отдельно проверяют ёмкость один, пустое чтение, запись в полный буфер и переход через конец. Затем сверяют длинную последовательность с моделью, сохраняя seed и номер сбойной операции.

Можно ли изменять этот буфер из нескольких задач одновременно?

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

Перенесите следующую сборку в облачный Mac

Даже без собственного Mac вы можете работать с macOS и Xcode прямо в браузере. Выберите выделенный физический Mac и аренду на день, неделю, месяц или квартал.

Арендовать облачный Mac

Откройте облачный Mac в браузере

Выделенный физический Mac, не виртуальная машина. Выберите модель, регион и срок аренды, чтобы начать работу в macOS.