Tous les articles
11 min·

5 idées reçues sur Swift 6 démontées directement par Apple

Notes d'une session Q&A Apple sur Swift Concurrency : 5 idées reçues sur la migration Swift 6 contredites par les ingés du compilateur, de Foundation et de SwiftUI.

Par Carolane Lefebvre
iOSSwiftConcurrency
5 idées reçues sur Swift 6 démontées directement par Apple

Apple a organisé une session Q&A sur Swift Concurrency il y a deux semaines. Le panel était musclé : Holly de l'équipe compilateur Swift (focus sur le design des diagnostics de data race safety), Jeremy de l'équipe Foundation, Seema de l'équipe SwiftUI (et contributrice externe au modèle de concurrence avant son arrivée chez Apple), et Alan, ancien framework developer côté compilo.

Les développeurs avaient soumis et upvoté une trentaine de questions. Ce qui m'a frappée pendant cette session, c'est le nombre de fois où les ingés Apple ont contredit des choses que j'entends répéter en boucle depuis un an dans la communauté iOS. Pas par malveillance, juste parce que la doc et les blogs ont du mal à suivre l'évolution du langage.

J'ai gardé les 5 idées reçues qui changent le plus la façon de migrer un projet existant. Si tu repousses ta migration Swift 6 ou si tu galères dessus en ce moment, lis jusqu'au bout. Il y a de bonnes chances qu'un de ces 5 points te débloque.


Idée reçue n°1 : Migrer en Swift 6, c'est un seul gros chantier

C'est probablement le malentendu le plus coûteux que j'ai vu dans des codebases iOS cette année. Tout le monde parle de "passer à Swift 6" comme s'il s'agissait d'une seule action. En réalité, et Holly l'a martelé plusieurs fois pendant la session, ce sont deux transitions complètement indépendantes :

  1. Moderniser le code pour utiliser les features natives de Swift Concurrency. Concrètement, transformer ses APIs pour qu'elles utilisent async/await, des actors, etc. Ça implique souvent de repenser une partie de l'architecture.

  2. Activer le strict concurrency checking et basculer en mode langage Swift 6. Ça active la vérification statique des data races par le compilateur.

Le piège classique, c'est de vouloir tout faire en même temps. Tu te retrouves à essayer de résoudre des centaines de diagnostics du compilateur pendant que tu changes fondamentalement la façon dont ton code est modélisé. Tu n'arrives plus à savoir si un bug vient de la migration ou du refactor. C'est exactement le scénario qui pousse les équipes à abandonner et à reporter de 6 mois.

Holly recommande deux stratégies viables :

  • Soit tu modernises ton code d'abord (sans toucher au mode langage), tu vérifies que le comportement reste correct via tes tests, puis tu actives Swift 6.

  • Soit tu actives les diagnostics en premier et tu utilises des opt-outs unsafe comme @preconcurrency ou nonisolated(unsafe) pour exprimer ce qui est vrai aujourd'hui de ton code, en sachant que tu reviendras auditer ces points plus tard.

L'astuce de Seema qui change tout, et que je n'avais lue nulle part : avant de basculer en Swift 6, active d'abord le strict concurrency checking en Swift 5. Tu obtiens un aperçu complet de tous les diagnostics qui te tomberont dessus en Swift 6, sans casser ton build. C'est une cartographie du chantier avant de commencer.

SWIFT_STRICT_CONCURRENCY = complete
SWIFT_VERSION = 5.10

Lance un build complet, regarde les warnings dans le Issue Navigator, catégorise-les. Tu sauras en une heure si ta migration vaut 2 jours ou 2 semaines.

Autre point libérateur de la session : tu peux activer puis désactiver. Personne ne t'oblige à finir en une fois. Activer les diagnostics, faire un peu de progrès, désactiver le temps d'une release qui doit sortir, c'est OK. Et le code legacy stable qui marche depuis trois ans n'a pas besoin d'être migré tout de suite. Le vrai gain de Swift 6 est d'avoir des garanties statiques sur le nouveau code que tu écris.


Idée reçue n°2 : Une Task est annulée automatiquement quand son owner disparaît

Celle-là m'a coûté un crash en prod il y a quelques mois, et apparemment je ne suis pas la seule.

Le réflexe naturel, surtout quand on vient de Combine ou des callbacks, c'est de penser qu'une Task suit le cycle de vie de l'objet qui l'a lancée. Quand self est déalloué, la Task devrait s'arrêter, non ?

Non. Les ingés Apple ont été très clairs sur ce point pendant la session. Deux mécaniques se combinent.

D'abord, l'annulation en Swift Concurrency est coopérative. C'est volontaire et c'est un point central à comprendre. Quand tu appelles .cancel() sur une Task, ça ne stoppe pas immédiatement le code qui tourne. C'est au code de vérifier régulièrement s'il a été annulé, via Task.isCancelled ou try Task.checkCancellation(), et d'agir en conséquence (généralement en levant CancellationError).

C'est volontaire parce que du code synchrone qui fait du nettoyage atomique ne doit pas pouvoir être interrompu n'importe quand. Imagine une transaction CoreData en train d'être committée et qu'on l'arrête au milieu : c'est l'incohérence garantie.

Ensuite, et c'est le piège : les Tasks ne sont pas annulées automatiquement quand le scope qui les possède est désalloué. Si tu lances une Task depuis un ViewModel et que le ViewModel meurt, la Task continue tranquillement à tourner en arrière-plan. Si elle capture self, elle empêche même la déallocation.

Si tu veux un comportement de cancellation automatique au deinit, il faut le coder à la main :

@MainActor
final class MyViewModel {
    private var loadTask: Task<Void, Never>?

    func startLoading() {
        loadTask?.cancel()
        loadTask = Task {
            await loadEverything()
        }
    }

    deinit {
        loadTask?.cancel()
    }
}

À noter : le cas particulier de SwiftUI. Le .task modifier annule automatiquement sa task quand la vue disparaît. Mais SwiftUI n'expose pas la handle de cette task. Donc si tu as besoin de contrôle fin sur l'annulation (par exemple garantir qu'aucun update UI ne se produit après dismiss), tu dois renoncer à .task et utiliser .onAppear + .onDisappear avec ta propre Task stockée en @State.


Idée reçue n°3 : @MainActor garantit que mon code tourne sur le main thread

Sur iOS et macOS, oui. Pour 99% de ce que tu écris, tu peux considérer que c'est vrai.

Mais cette session a mis en lumière deux nuances importantes que peu de devs iOS connaissent.

Premièrement, @MainActor n'est pas synonyme de main thread dans le langage. C'est un actor particulier qui, sur les plateformes Apple, est mappé au main dispatch queue. Sur d'autres plateformes (Linux côté serveur Swift par exemple), @MainActor pourrait techniquement être un autre thread. Si tu écris une lib cross-platform, ce détail compte.

Deuxièmement, et c'est le vrai piège : tu peux contourner la garantie via interop C, Objective-C ou C++, où le strict checking ne s'applique pas. Si tu appelles une fonction @MainActor isolée depuis un contexte C sans strict checking (typiquement un callback bas niveau, une notification CoreFoundation, un appel POSIX), tu peux te retrouver à l'exécuter en dehors du main thread, ce qui crée potentiellement une data race que le compilateur n'a pas pu voir.

C'est rare mais c'est exactement le type de bug qui crashe sous TSan en CI sans qu'on comprenne pourquoi. Swift fournit des outils de dynamic checking pour intercepter ces cas en runtime si tu en as besoin, notamment via MainActor.assertIsolated() ou les flags Thread Sanitizer dans le scheme.

Si tu maintiens du code legacy avec beaucoup de bridges C ou Obj-C (typiquement des wrappers autour d'APIs Audio, Video, ou Network bas niveau), c'est un point à auditer en priorité.


Idée reçue n°4 : Il faut toujours mettre weak self dans une Task

Réflexe Combine que je vois recopié partout, y compris dans des codes review où je suis intervenue récemment. Et c'est Alan qui a été le plus tranchant pendant la session : ça dépend, et c'est souvent un bug.

Le pattern weak self dans les tasks vient d'une habitude héritée des callbacks et de Combine, où les subscriptions pouvaient survivre indéfiniment et créer des cycles de référence. Dans Swift Concurrency, ce n'est pas systématiquement nécessaire ni même bénéfique.

Les cas où weak self est légitime :

  • Une task lancée depuis une vue, qui ne devrait pas la garder vivante après sa disparition.

  • Plus généralement, quand tu sais que tu ne veux pas que la task prolonge la vie de self.

Les cas où weak self est inadapté et peut créer des bugs subtils :

  • Une task qui doit absolument finir son travail avant que self ne disparaisse. Typiquement : un cleanup, un save, une transaction, un upload critique. Tu veux justement qu'elle garde self vivant le temps de finir.

Exemple concret. Dans un ViewModel de checkout, tu lances une task pour finaliser une commande :

func confirmOrder() {
    Task { [weak self] in
        guard let self else { return }
        try await api.submit(self.order)
        self.state = .confirmed
    }
}

Si l'utilisateur ferme l'écran pendant la requête, self peut être déalloué, le guard échoue, et ta commande n'est jamais soumise. Sans weak self, la Task aurait gardé le ViewModel en vie le temps de finir, et la commande serait passée. C'est exactement l'inverse de ce que tu veux.

Le piège complémentaire mentionné par Seema : les tasks infinies. Si tu as une boucle infinie dans une Task et que tu stockes la Task sur self, tu crées un cycle de référence. weak self peut aider, mais la vraie solution est souvent de structurer différemment, avec une cancellation explicite au moment opportun.


Idée reçue n°5 : nonisolated async se déplace sur le concurrent pool

Si tu lis du code Swift de 2022-2023 ou des articles de cette époque, c'était vrai. Aujourd'hui, c'est l'inverse. Et beaucoup de gens n'ont pas reçu le mémo.

Apple a fini par juger que le comportement par défaut historique (les fonctions async se déplaçaient automatiquement sur le concurrent thread pool quand on les appelait) était une mauvaise idée, parce qu'il introduisait de la concurrence implicite. Tu avais l'impression d'écrire du code linéaire, mais en réalité chaque await pouvait te faire changer d'isolation sans que tu le réalises.

Le nouveau comportement par défaut quand tu actives les approachable concurrency settings est : nonisolated(nonsending). Ta fonction async reste dans l'isolation du caller, sauf raison explicite de la quitter. C'est un changement énorme dans la sémantique du langage.

Pour exprimer l'ancien comportement (offload explicite sur le pool concurrent), il faut désormais marquer la fonction @concurrent :

@MainActor
final class MyViewModel {
    var items: [Item] = []

    @concurrent
    func processFiles(_ files: [URL]) async {
        // Tourne sur le concurrent pool, pas sur le main actor.
        // Le compilateur empêche d'accéder directement à `items` ici.
    }
}

Le compilateur t'empêchera d'accéder directement aux propriétés mutables @MainActor du type depuis cette méthode @concurrent, ce qui garantit l'absence de data race. Pour mettre à jour items, tu devras explicitement repasser sur le main actor.

C'est exactement le genre de pattern qui rend Swift 6 viable pour les apps qui ont des ViewModels lourds : tu n'es plus obligée de tout déplacer hors du main actor, tu marques juste la méthode IO ou parsing qui en a besoin.

Recommandation Apple : active toujours les approachable concurrency settings. Et utilise @concurrent quand tu veux vraiment offloader.


Plan d'action concret pour appliquer tout ça

Cette session m'a fait revoir ma façon d'aborder Swift 6 sur mes projets. Voici les 5 actions que je recommande dans l'ordre, si tu veux transformer cette lecture en compétence solide.

1. Audite ton projet sans rien casser

Active dans tes build settings le strict concurrency checking en Swift 5 (sans passer en Swift 6). Lance un build complet. Compte les warnings, catégorise-les. Tu obtiens ta carte du terrain. Si tu en as 0 ou 5, tu es en bonne forme. Si tu en as 200, c'est qu'il y a du travail mais l'effort est mesurable.

2. Identifie ton plus gros actor inutile

Cherche dans ton code si tu as introduit des actors par habitude, pas par nécessité. Pour chaque actor, demande-toi : a-t-il un état mutable interne qui est vraiment partagé entre tâches concurrentes ? Si la réponse est non, l'actor est probablement de trop. Le simplifier en struct ou en classe @MainActor réduira ton boilerplate de await partout.

3. Apprivoise @concurrent

Prends un de tes ViewModels @MainActor qui contient une méthode IO ou parsing un peu lourde. Ajoute @concurrent sur cette méthode spécifique. Observe ce que le compilateur t'oblige à faire : impossible d'accéder directement aux propriétés mutables, donc tu dois explicitement remonter sur @MainActor pour update l'UI. C'est un excellent exercice mental qui ancre la sémantique de l'isolation.

4. Refactore une closure @Sendable problématique

Si tu as une closure que tu as marquée @Sendable mais où tu galères avec les captures, regarde si tu peux la remplacer par une closure sending (one-shot transfer) plutôt que @Sendable (callable multiple fois). C'est souvent la bonne réponse quand la closure n'est appelée qu'une seule fois, comme dans les Tasks.

5. Lis deux Swift Evolution proposals

Va sur swift.org/swift-evolution et regarde les proposals "implemented in Swift 6.4". Lis en entier la proposal withDeadline (tasks avec timeout) et celle qui ajoute le diagnostic pour les errors avalées par les tasks. Pas pour les apprendre par cœur, mais pour t'habituer au format des proposals. Le langage évolue chaque trimestre, lire les proposals te garde à jour mieux que n'importe quel article.


Pour aller plus loin

Si tu te lances dans une migration Swift 6 sur un projet conséquent et que tu veux un regard extérieur sur ton audit ou sur tes choix d'architecture, c'est exactement le genre de mission sur laquelle j'interviens en freelance. Écris-moi, on en parle.

Et si cet article t'a évité une journée de galère, partage-le à un autre dev iOS dans la même situation. Ça lui rendra service.

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

Voir d'autres articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI