Чтобы сохранить типизированные ошибки при параллельных вычислениях в Swift, возвращайте типизированный Result из каждой дочерней задачи и извлекайте его после сбора результатов. В этом примере для Mac вычисляется количество пикселей в условных прямоугольниках с сохранением порядка входных данных, явной обработкой ошибок и правилом «всё или ничего» для пакета.

Зачем использовать типизированные результаты на границе группы задач?

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

В Swift 6.0 появились типизированные выбрасываемые ошибки. Объявление вроде throws(CalculationError) ограничивает выбрасываемые функцией значения ошибками указанного типа. Это полезно для небольшого, закрытого контракта вычислений, реализация и вызывающий код которого развиваются согласованно. См. SE-0413: Typed throws.

Важна граница API параллелизма. В документированной Apple сигнатуре withThrowingTaskGroup телу передаётся ThrowingTaskGroup<ChildTaskResult, any Error>. Несмотря на обобщённый вид типа группы, эта фабричная функция не предоставляет конкретный тип ошибки предметной области. Поэтому добавление типизированных выбрасываемых ошибок только к вычислению не задаёт конкретный тип ошибки во всём API этой группы. См. withThrowingTaskGroup.

Поддерживаемый вариант — использовать withTaskGroup и возвращать из каждой дочерней задачи Result<Int, CalculationError>. Задача завершается штатно, передавая значение, которое представляет успех или неудачу. Собрав эти значения, функция обработки пакета вызывает get(), чья документированная сигнатура — throws(Failure). Так типизированная обработка ошибок восстанавливается без приведения типов во время выполнения. См. документацию Apple по Result и get().

Определите контракт обработки пакета до написания кода параллелизма

В этом примере каждый вход содержит целочисленные ширину и высоту в пикселях. Результат вычисления — их произведение. Все данные условные и остаются в памяти; программа не визуализирует изображение и не загружает и не сохраняет данные приложения.

Следующие правила — проектные решения для этого примера. Они делают возвращаемые значения и выбранную ошибку предсказуемыми:

Ситуация Поведение при обработке пакета
Все прямоугольники допустимы Вернуть количество пикселей в порядке входных данных
Ширина или высота равна нулю или отрицательна Сообщить, что прямоугольник недопустим
Произведение не помещается в Int Сообщить о переполнении для этого прямоугольника
Несколько прямоугольников завершаются с ошибкой Сообщить об ошибке с наименьшим индексом входных данных
Отмена обнаружена в контрольной точке пакета Сообщить .cancelled
Массив входных данных пуст Вернуть пустой массив, если не обнаружена отмена

Чтобы выбрать наименьший индекс входных данных, нужно собрать результаты всех дочерних задач, прежде чем выбирать ошибку вычисления. Swift не гарантирует порядок планирования задач в группе, а порядок завершения может отличаться от порядка отправки. Индекс, добавленный к каждому результату, позволяет сборщику восстановить нужный порядок. См. TaskGroup.

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

Создайте полноценный локальный пример на Mac

Используйте установленную цепочку инструментов Swift 6 или новее. В Terminal проверьте её командой swift --version. Swift.org описывает эту проверку в разделе Getting Started и предоставляет инструкции по установке на Mac в разделе Install Swift — macOS.

Создайте небольшой исполняемый пакет, используя описанный на странице установки порядок действий:

mkdir TypedCalculations
cd TypedCalculations
swift package init --name TypedCalculations --type executable

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

Определите входные данные и конкретные ошибки

Перечисление ошибок сохраняет структурированные сведения: вариант указывает на проблему, а связанный с ним индекс — на входные данные. Протокол Foundation LocalizedError предоставляет необязательное errorDescription для отображения. Приведённые ниже сообщения на английском — примеры строк; соответствие протоколу не переводит их автоматически. См. LocalizedError.

import Foundation

struct Rectangle: Sendable {
    let width: Int
    let height: Int
}

enum CalculationError: Error, Sendable, LocalizedError {
    case invalidDimensions(index: Int)
    case overflow(index: Int)
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidDimensions(let index):
            return "Rectangle \(index + 1) needs positive dimensions."
        case .overflow(let index):
            return "Rectangle \(index + 1) has too many pixels for Int."
        case .cancelled:
            return "Calculation cancelled."
        }
    }
}

struct IndexedOutcome: Sendable {
    let index: Int
    let result: Result<Int, CalculationError>
}

Вычислите, соберите результаты и восстановите типизированную обработку ошибок

Для арифметики используется multipliedReportingOverflow(by:). Apple документирует, что значение partialValue содержит полное произведение, только если overflow имеет значение false; иначе оно содержит усечённое произведение. Приведённая ниже проверка не позволяет усечённому значению стать успешным результатом подсчёта пикселей. См. multipliedReportingOverflow(by:).

func pixelCount(
    for rectangle: Rectangle,
    index: Int
) throws(CalculationError) -> Int {
    if Task.isCancelled {
        throw .cancelled
    }

    guard rectangle.width > 0, rectangle.height > 0 else {
        throw .invalidDimensions(index: index)
    }

    let product = rectangle.width.multipliedReportingOverflow(
        by: rectangle.height
    )
    guard !product.overflow else {
        throw .overflow(index: index)
    }
    return product.partialValue
}

func calculateBatch(
    _ rectangles: [Rectangle]
) async throws(CalculationError) -> [Int] {
    if Task.isCancelled {
        throw .cancelled
    }

    let outcomes: [IndexedOutcome] = await withTaskGroup(
        of: IndexedOutcome.self,
        returning: [IndexedOutcome].self
    ) { group in
        for (index, rectangle) in rectangles.enumerated() {
            group.addTask {
                let result = Result<Int, CalculationError> {
                    () throws(CalculationError) -> Int in
                    try pixelCount(for: rectangle, index: index)
                }
                return IndexedOutcome(index: index, result: result)
            }
        }

        var collected: [IndexedOutcome] = []
        for await outcome in group {
            collected.append(outcome)
        }
        return collected.sorted { $0.index < $1.index }
    }

    if Task.isCancelled {
        throw .cancelled
    }

    var counts: [Int] = []
    counts.reserveCapacity(outcomes.count)
    for outcome in outcomes {
        counts.append(try outcome.result.get())
    }
    return counts
}

Явная аннотация throws(CalculationError) в замыкании Result делает контракт ошибок видимым и не полагается на вывод типа замыкания. В SE-0413 указано, что изначально запланированные изменения для полноценного вывода типизированных выбрасываемых ошибок не были реализованы в Swift 6.0. См. примечание о реализации в разделе Typed throws.

Только родительский сборщик добавляет элементы в collected. Каждая дочерняя задача получает прямоугольник и индекс и возвращает собственный результат, соответствующий Sendable. Это соответствует требованию группы задач, согласно которому значения результатов дочерних задач должны быть Sendable; тело группы, не выбрасывающее ошибок, собирает их с помощью for await. См. withTaskGroup.

Добавьте исполняемую точку входа

Этот последний блок задаёт все примеры входных данных и вызывает функцию обработки пакета. Явное присваивание внутри catch также проверяет контракт API во время компиляции: перехваченное значение должно быть CalculationError.

@main
struct TypedCalculations {
    static func main() async {
        let examples: [(name: String, rectangles: [Rectangle])] = [
            (
                name: "Valid",
                rectangles: [
                    Rectangle(width: 640, height: 480),
                    Rectangle(width: 320, height: 200)
                ]
            ),
            (
                name: "Multiple failures",
                rectangles: [
                    Rectangle(width: 0, height: 20),
                    Rectangle(width: Int.max, height: 2)
                ]
            ),
            (
                name: "Overflow",
                rectangles: [
                    Rectangle(width: Int.max, height: 2)
                ]
            ),
            (name: "Empty", rectangles: [])
        ]

        for example in examples {
            do {
                let counts = try await calculateBatch(example.rectangles)
                print("\(example.name): \(counts)")
            } catch {
                let failure: CalculationError = error
                print("\(example.name): \(failure.errorDescription ?? "Calculation failed.")")
            }
        }
    }
}

Запустите исполняемый файл из каталога пакета:

swift run TypedCalculations

Ожидаемый вывод приложения, без сообщений сборки, показан ниже. В исследовательской среде не было компилятора Swift, поэтому код сверили с найденной документацией API, но не компилировали и не запускали.

Valid: [307200, 64000]
Multiple failures: Rectangle 1 needs positive dimensions.
Overflow: Rectangle 1 has too many pixels for Int.
Empty: []

Корректные значения следуют непосредственно из примеров входных данных: 640 умножить на 480 — 307 200, а 320 умножить на 200 — 64 000. В примере с несколькими ошибками выбирается первый прямоугольник, поскольку результаты проверяются в порядке входных данных, даже если вторая дочерняя задача завершилась раньше.

Как ведут себя отмена и частичные результаты?

В Swift отмена выполняется кооперативно. Задача должна обнаружить отмену и отреагировать на неё; запрос отмены не прерывает вычисление автоматически. Структурированные группы задач также ожидают завершения дочерних задач перед возвратом. См. главу Concurrency руководства по языку Swift и документацию Apple по withTaskGroup.

Здесь Task.isCancelled проверяется перед отправкой задач, внутри каждого вычисления и после сбора результатов. Когда в контрольной точке обнаруживается отмена, код представляет её существующим вариантом .cancelled. Чтение логического свойства позволяет не вводить в этот закрытый контракт ещё один тип выбрасываемых ошибок. Apple описывает это свойство в разделе Task.isCancelled.

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

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

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

Диагностируйте потерю типа ошибки, не скрывая сбои

Если адаптированный код перестал компилироваться, проследите за типом на каждой границе в следующем порядке:

  1. Убедитесь, что вычисление объявляет throws(CalculationError).
  2. Проверьте, что значение дочерней задачи имеет тип Result<Int, CalculationError>, а не содержит в качестве ошибки any Error.
  3. Оставьте в замыкании явную аннотацию типизированной обработки ошибок, если вывод типа расширяет тип ошибки.
  4. Для передачи этих значений результатов используйте не выбрасывающий ошибки withTaskGroup.
  5. Извлекайте результат с помощью get() внутри типизированной функции обработки пакета.
  6. Проверьте, не вводит ли какая-либо новая операция с возможностью выброса ошибок другой тип ошибки.

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

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

Источники