Pour préserver les erreurs typées dans les calculs Swift concurrents, renvoyez un Result typé depuis chaque tâche enfant, puis décompactez-le après la collecte. Cet exemple sur Mac calcule des nombres de pixels synthétiques pour des rectangles tout en conservant l’ordre des entrées, les échecs explicites et un contrat de lot tout ou rien.

Pourquoi utiliser des résultats typés à la frontière du groupe de tâches ?

Supposons que vous créiez un aperçu graphique sur ordinateur qui calcule le nombre de pixels de plusieurs rectangles. Chaque calcul peut rejeter des dimensions invalides ou signaler un dépassement de capacité d’un entier. L’appelant a besoin d’un tableau complet et ordonné ou d’une erreur de calcul précise afin de pouvoir traiter explicitement chaque échec pris en charge.

Swift a introduit les erreurs typées dans Swift 6.0. Une déclaration telle que throws(CalculationError) limite les valeurs levées par la fonction à ce type d’erreur. Cette approche est utile pour un contrat de calcul restreint et fermé, dont l’implémentation et les appelants évoluent ensemble. Voir SE-0413 : erreurs typées.

La frontière de l’API de concurrence est importante. La signature documentée par Apple pour withThrowingTaskGroup fournit un ThrowingTaskGroup<ChildTaskResult, any Error> à son corps. Le type de groupe, qui semble générique, ne fournit pas de paramètre concret d’erreur du domaine via cette fabrique. Par conséquent, ajouter des erreurs typées à votre seul calcul n’établit pas un type concret pour les erreurs levées dans toute cette API de groupe. Voir withThrowingTaskGroup.

Une solution prise en charge consiste à utiliser withTaskGroup et à faire en sorte que chaque tâche enfant renvoie un Result<Int, CalculationError>. La tâche se termine normalement en fournissant une valeur qui représente une réussite ou un échec. Après avoir collecté ces valeurs, la fonction de lot appelle get(), dont la signature documentée est throws(Failure). Cela rétablit les erreurs typées sans conversion à l’exécution. Voir la documentation de Result et la documentation de get() d’Apple.

Définir le contrat du lot avant d’écrire le code de concurrence

Dans cet exemple, chaque entrée contient une largeur et une hauteur entières, mesurées en pixels. Le calcul consiste à les multiplier. Tout est synthétique et reste en mémoire ; le programme ne génère pas d’image et ne charge ni n’enregistre de données d’application.

Les politiques suivantes sont des choix de conception propres à cet exemple. Elles rendent prévisibles les valeurs renvoyées et l’erreur sélectionnée :

Situation Comportement du lot
Tous les rectangles sont valides Renvoyer les nombres de pixels dans l’ordre des entrées
La largeur ou la hauteur vaut zéro ou est négative Signaler ce rectangle comme invalide
Le produit ne tient pas dans Int Signaler un dépassement de capacité pour ce rectangle
Plusieurs rectangles échouent Signaler l’échec dont l’index d’entrée est le plus bas
Une annulation est détectée à un point de contrôle du lot Signaler .cancelled
Le tableau d’entrée est vide Renvoyer un tableau vide, sauf si une annulation est détectée

La sélection de l’index d’entrée le plus bas exige de collecter le résultat de chaque tâche enfant avant de choisir un échec de calcul. Swift ne garantit pas l’ordre de planification du groupe de tâches, et l’ordre d’achèvement peut différer de l’ordre de soumission. En joignant un index à chaque résultat, le collecteur peut rétablir l’ordre voulu. Voir TaskGroup.

Cette politique convient à un petit lot d’aperçus pour lequel des retours reproductibles sont importants. Elle attend délibérément la fin de tous les calculs. Une conception qui signale le premier échec observé pendant l’exécution ferait un autre compromis et pourrait sélectionner un rectangle différent d’une exécution à l’autre.

Créer un exemple local complet sur votre Mac

Utilisez une chaîne d’outils Swift 6 ou plus récente déjà installée. Dans Terminal, vérifiez-la avec swift --version. Swift.org documente cette vérification dans Getting Started et fournit les instructions d’installation sur Mac dans Install Swift — macOS.

Créez un petit paquet exécutable en suivant la procédure documentée sur la page d’installation :

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

Remplacez le fichier d’entrée Swift généré sous Sources par les trois blocs de code suivants, dans l’ordre, en les réunissant dans un seul fichier. Ne conservez qu’un seul point d’entrée exécutable. Cet exemple utilise l’isolation d’acteur par défaut habituelle du paquet ; dans un projet configuré avec l’isolation par défaut de l’acteur principal, placez les types et fonctions de calcul en dehors de cette isolation.

Définir les entrées et les erreurs concrètes

L’énumération d’erreurs conserve des informations structurées : le cas identifie le problème et sa valeur associée d’index identifie l’entrée. Le protocole LocalizedError de Foundation fournit un errorDescription facultatif à afficher. Les messages anglais ci-dessous sont des exemples ; la conformité au protocole ne les traduit pas automatiquement. Voir 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>
}

Calculer, collecter et rétablir les erreurs typées

L’arithmétique utilise multipliedReportingOverflow(by:). Apple indique que son partialValue est le produit complet uniquement lorsque overflow est faux ; sinon, il contient un produit tronqué. La vérification ci-dessous empêche cette valeur tronquée de devenir un nombre de pixels considéré comme valide. Voir 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
}

L’annotation explicite throws(CalculationError) de la fermeture Result rend son contrat d’erreur visible au lieu de s’appuyer sur l’inférence des fermetures. SE-0413 précise que les modifications initialement prévues pour l’inférence complète des erreurs typées n’ont pas été implémentées dans Swift 6.0. Voir la note d’implémentation dans Typed throws.

Seul le collecteur parent ajoute des éléments à collected. Chaque tâche enfant reçoit un rectangle et un index, puis renvoie son propre résultat Sendable. Cela respecte l’exigence du groupe de tâches selon laquelle les valeurs de résultat des tâches enfants doivent être Sendable ; le corps non lançant du groupe les collecte avec for await. Voir withTaskGroup.

Ajouter un point d’entrée exécutable

Ce dernier bloc fournit toutes les entrées d’exemple et appelle la fonction de lot. L’affectation explicite dans catch vérifie également le contrat d’API attendu à la compilation : la valeur interceptée doit être un 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.")")
            }
        }
    }
}

Lancez l’exécutable depuis le répertoire du paquet :

swift run TypedCalculations

La sortie attendue du programme, sans les messages de compilation, est présentée ci-dessous. Aucun compilateur Swift n’était disponible dans l’environnement de recherche ; le programme a donc été vérifié au regard de la documentation d’API obtenue, mais n’y a été ni compilé ni exécuté.

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

Les nombres de pixels valides découlent directement des entrées illustratives : 640 multiplié par 480 donne 307 200, et 320 multiplié par 200 donne 64 000. Dans l’exemple avec plusieurs échecs, le premier rectangle l’emporte car les résultats sont examinés dans l’ordre des entrées, même si la deuxième tâche enfant se termine plus tôt.

Comment l’annulation et les résultats partiels se comportent-ils ?

Dans Swift, l’annulation est coopérative. Une tâche doit détecter l’annulation et réagir ; demander une annulation n’interrompt pas automatiquement son calcul. Les groupes de tâches structurés attendent également que leurs tâches enfants se terminent avant de renvoyer un résultat. Voir le chapitre Concurrency du guide du langage Swift et la documentation de withTaskGroup d’Apple.

Ici, Task.isCancelled est vérifié avant la soumission, au cours de chaque calcul et après la collecte. Lorsqu’un point de contrôle détecte une annulation, le code la représente au moyen du cas .cancelled existant. La lecture de la valeur booléenne évite d’introduire un autre type d’erreur levée dans ce contrat fermé. Apple documente cette propriété dans Task.isCancelled.

La vérification après la collecte donne la priorité à l’annulation détectée du lot sur les échecs de rectangles. Ces vérifications sont des points de contrôle, et non une garantie atomique : une annulation demandée après la dernière vérification peut ne pas influer sur le résultat renvoyé. Pour un calcul plus long comportant des opérations répétées, ajoutez des vérifications au sein de ces opérations afin que l’annulation puisse être détectée plus rapidement.

Dans cette implémentation, l’échec d’une tâche enfant ne déclenche pas d’annulation. Il renvoie une valeur Result.failure normale, et le collecteur continue. Si chaque tâche enfant réussit, l’appelant reçoit tous les nombres de pixels. Si le décompactage rencontre un échec, la fonction lève une erreur et son tableau local counts, assemblé en partie, n’est jamais renvoyé.

Si l’interface de bureau a plutôt besoin d’un état de réussite ou d’échec pour chaque rectangle, modifiez le contrat de lot public pour renvoyer les résultats ordonnés. Associez chaque échec à son index. Le comportement présenté au lecteur diffère alors du tableau tout ou rien implémenté ici.

Diagnostiquer la perte d’un type d’erreur sans masquer les échecs

Si le code adapté cesse de compiler, suivez le type à chaque frontière dans l’ordre suivant :

  1. Vérifiez que le calcul déclare throws(CalculationError).
  2. Vérifiez que la valeur enfant est Result<Int, CalculationError>, plutôt qu’un résultat dont le type d’échec est any Error.
  3. Conservez l’annotation explicite d’erreurs typées de la fermeture si l’inférence élargit son type d’erreur.
  4. Utilisez withTaskGroup, qui ne lève pas d’erreur, pour transporter ces valeurs de résultat.
  5. Décompactez avec get() dans la fonction de lot typée.
  6. Vérifiez si une nouvelle opération qui lève une erreur introduit un type d’erreur différent.

Une conversion forcée ne peut pas prouver ce contrat. De même, ignorer les résultats en échec ou remplacer la valeur par zéro changerait le sens du calcul. Conservez les échecs sous forme de valeurs jusqu’à ce que la politique de lot choisie décide comment les signaler.

Pour ces multiplications minuscules, la concurrence sert de démonstration architecturale ; aucune amélioration des performances n’est revendiquée. Le résultat utile est un contrat précis : les nombres valides conservent l’ordre des entrées, plusieurs échecs suivent une règle de sélection déterministe et les appelants reçoivent une énumération d’erreurs concrète qu’ils peuvent traiter directement.

Sources