La gestion des erreurs est un aspect fondamental de la programmation en Swift. Lorsque l’on travaille avec des opérations qui peuvent échouer, il est essentiel d’avoir des mécanismes en place pour capturer et gérer ces erreurs de manière efficace. Dans cet exemple, nous explorons plusieurs techniques de gestion des erreurs et de propagation, en abordant également des concepts comme les expressions de tentative forcée et les blocs de nettoyage via defer.

Dans l'exemple suivant, nous utilisons plusieurs instructions catch pour capturer différents types d'erreurs. Chacune de ces instructions correspond à un motif distinct qui permet de traiter une condition d'erreur spécifique :

swift
do {
try myTeam.addPlayer(player:("David", "Ortiz", 34)) } catch PlayerNumberError.numberTooHigh(let description) { print("Erreur : \(description)") } catch PlayerNumberError.numberTooLow(let description) { print("Erreur : \(description)") } catch PlayerNumberError.numberAlreadyAssigned { print("Erreur : numéro déjà attribué") } catch { print("Erreur : Erreur inconnue") }

Ici, chaque condition d’erreur a une valeur associée, ce qui nous permet de récupérer cette valeur en utilisant l'instruction let dans les parenthèses. Cette méthode garantit que chaque type d’erreur est capturé avec une description spécifique, ce qui rend le code plus compréhensible et facile à maintenir. L'instruction catch sans condition à la fin est particulièrement utile pour capturer toutes les erreurs qui ne sont pas couvertes par les conditions précédentes, ce qui permet de s’assurer que tout échec est traité, même si son type exact n'est pas connu.

Il existe également une technique de propagation des erreurs, où les erreurs ne sont pas immédiatement capturées mais laissées se propager vers le code appelant. Pour cela, on utilise le mot-clé throws dans la définition de la fonction. Dans l'exemple suivant, l'erreur n'est pas gérée dans la fonction elle-même, mais est renvoyée au niveau supérieur :

swift
func myFunc() throws {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
}

Cette approche permet de déléguer la responsabilité de la gestion des erreurs à un niveau supérieur, ce qui est particulièrement utile dans des applications plus complexes où une gestion d'erreur centralisée est préférable.

Lorsqu'on est certain qu'aucune erreur ne surviendra, on peut utiliser l'expression try!, qui ignore la gestion des erreurs et force l'exécution. Toutefois, cette approche peut entraîner des erreurs d'exécution si une erreur imprévue survient. Par conséquent, l'utilisation de try! est fortement déconseillée en production car elle pourrait provoquer un crash de l’application :

swift
let player = try! myTeam.getPlayerByNumber(number: 34)

Il existe aussi une approche plus souple pour les cas où l’on ne veut pas gérer les erreurs immédiatement. L'usage de try? permet d’attraper l’erreur et de la convertir en une valeur optionnelle. Cela peut rendre le code plus propre et plus facile à comprendre. Par exemple :

swift
if let player = try? myTeam.getPlayerByNumber(number: 34) {
print("Le joueur est \(player.firstName) \(player.lastName)") }

Ce code permet d'éviter des blocs catch vides et rend l’intention du programmeur plus claire : on tente de récupérer un joueur, et si l’erreur survient, on obtient simplement une valeur nulle sans bloquer l'exécution du programme.

Un autre mécanisme important à comprendre est l’utilisation du protocole LocalizedError. Ce protocole permet d'ajouter des descriptions plus détaillées et personnalisées aux erreurs, ce qui peut être utile pour fournir des informations plus claires à l'utilisateur final. Le protocole LocalizedError permet de définir des propriétés comme errorDescription, failureReason, recoverySuggestion et helpAnchor qui enrichissent l'expérience utilisateur. Par exemple :

swift
enum PlayerNumberError: Error, LocalizedError { case numberTooHigh(description: String) case numberTooLow(description: String) case numberAlreadyAssigned case numberDoesNotExist var errorDescription: String? { switch self { case .numberTooHigh(let description): return description case .numberTooLow(let description): return description case .numberAlreadyAssigned: return "Le numéro du joueur est déjà attribué" case .numberDoesNotExist: return "Le numéro du joueur n'existe pas" } } }

Dans ce code, chaque erreur est décrite de manière détaillée, ce qui permet de fournir des messages plus précis à l'utilisateur. Ensuite, le message d'erreur peut être récupéré et affiché comme suit :

swift
do {
let player = try myTeam.getPlayerByNumber(number: 34) print("Le joueur est \(player.firstName) \(player.lastName)") } catch let error as PlayerNumberError { print("Erreur : " + error.localizedDescription) }

Enfin, pour les cas où nous devons effectuer une action de nettoyage, indépendamment de l'issue de l'opération, nous pouvons utiliser le mot-clé defer. Ce mécanisme permet d'exécuter un bloc de code juste avant que l'exécution quitte la portée actuelle, que l'opération ait échoué ou non. C'est particulièrement utile pour des tâches comme la fermeture de fichiers ou la libération de ressources. Par exemple :

swift
func deferFunction() throws { print("Début de la fonction") var myTeam = BaseballTeam() var str: String? defer { print("Dans le bloc defer") if let s = str { print("str est \(s)") } } str = "Test String"
try myTeam.addPlayer(player:("David", "Ortiz", 34))
print("Fin de la fonction") }

Si aucune erreur n'est levée, le résultat de l'exécution serait le suivant :

rust
Début de la fonction Fin de la fonction Dans le bloc defer str est Test String

Si une erreur est levée dans la fonction, le bloc defer est tout de même exécuté avant que l'exécution ne quitte la portée, ce qui garantit que certaines actions, comme le nettoyage, soient toujours réalisées.

Ce mécanisme est particulièrement utile lorsque des actions doivent être effectuées indépendamment du succès ou de l'échec des opérations principales.

Comment résoudre les cycles de références fortes en gestion de mémoire ?

Les cycles de références sont des situations où deux objets se référencent mutuellement de manière forte, empêchant ainsi leur destruction par le mécanisme de gestion de mémoire automatique, l’ARC (Automatic Reference Counting). Ces cycles peuvent entraîner des fuites de mémoire et dégrader la performance des applications, jusqu’à provoquer des crashs dans certains cas. Dans cet article, nous allons explorer comment ces cycles de références se forment et comment les résoudre en utilisant des références faibles (weak) et non possédées (unowned) dans le langage Swift.

Prenons un exemple pour mieux comprendre cette problématique. Imaginons deux classes, MyClass1_Strong et MyClass2_Strong. La classe MyClass1_Strong contient une référence forte à une instance de MyClass2_Strong, et inversement, la classe MyClass2_Strong contient une référence forte à une instance de MyClass1_Strong. Cela crée un cycle de références, car aucune des deux instances ne peut être détruite tant que l'autre existe. Ce problème devient évident lorsque nous exécutons le code suivant :

swift
var class1: MyClass1_Strong? = MyClass1_Strong(name: "Class1_Strong")
var class2: MyClass2_Strong? = MyClass2_Strong(name: "Class2_Strong") class1?.class2 = class2 class2?.class1 = class1 print("Setting classes to nil") class2 = nil class1 = nil

Lorsque ce code est exécuté, même si nous mettons les objets class1 et class2 à nil, les deux objets ne sont pas libérés de la mémoire. En effet, la gestion de mémoire automatique d’ARC ne peut pas supprimer ces objets car leurs compteurs de références ne peuvent jamais atteindre zéro.

Ce genre de situation peut entraîner une fuite de mémoire où la mémoire est utilisée sans être libérée, ce qui, à long terme, affecte les performances de l’application et peut conduire à des plantages.

Les références non possédées (unowned)

Pour résoudre ce problème, il est nécessaire de libérer l’une des références fortes entre les deux objets. Swift permet d’utiliser deux types de références : faibles (weak) et non possédées (unowned). La principale différence entre une référence faible et une référence non possédée réside dans le fait que la référence faible peut devenir nil si l’objet auquel elle se réfère est détruit, tandis qu’une référence non possédée assume que l’objet restera en mémoire et ne sera jamais nil.

Une référence non possédée doit être utilisée dans les situations où nous savons que l’objet référencé vivra plus longtemps que la référence qui le maintient. Si une référence non possédée est utilisée et que l’objet référencé est détruit, tenter d’accéder à cette référence entraînera une erreur d’exécution.

Prenons un autre exemple pour illustrer l’utilisation d’une référence non possédée. Imaginons deux nouvelles classes MyClass1_Unowned et MyClass2_Unowned :

swift
class MyClass1_Unowned {
var name = "" unowned let class2: MyClass2_Unowned init(name: String, class2: MyClass2_Unowned) { self.name = name self.class2 = class2 print("Initializing class1_Unowned with name \(self.name)") } deinit { print("Releasing class1_Unowned with name \(self.name)") } } class MyClass2_Unowned { var name = "" var class1: MyClass1_Unowned? init(name: String) { self.name = name print("Initializing class2_Unowned with name \(self.name)") } deinit { print("Releasing class2_Unowned with name \(self.name)") } }

Dans cet exemple, MyClass1_Unowned garde une référence non possédée à MyClass2_Unowned. Cela signifie que la référence ne maintient pas une référence forte à l'objet class2, et donc ARC pourra libérer les instances lorsque leur utilisation sera terminée. En exécutant ce code :

swift
let class2 = MyClass2_Unowned(name: "Class2_Unowned")
let class1: MyClass1_Unowned? = MyClass1_Unowned(name: "Class1_Unowned", class2: class2)
class2.class1
= class1 print("Classes going out of scope")

Nous voyons que les deux objets sont correctement libérés lorsqu’ils ne sont plus nécessaires. L’ARC est en mesure de détruire les deux instances, évitant ainsi tout risque de fuite de mémoire.

Les références faibles (weak)

Les références faibles sont similaires aux références non possédées, mais elles peuvent devenir nil si l’objet référencé est détruit. Cela les rend utiles dans les situations où l’objet référencé peut être libéré avant la référence. Contrairement aux références non possédées, les références faibles doivent toujours être optionnelles, car il est possible que l’objet référencé devienne nil au cours du cycle de vie de l’application.

Voici un exemple d’utilisation de références faibles dans deux nouvelles classes, MyClass1_Weak et MyClass2_Weak :

swift
class MyClass1_Weak { var name = "" var class2: MyClass2_Weak? init(name: String) { self.name = name print("Initializing class1_Weak with name \(self.name)") } deinit { print("Releasing class1_Weak with name \(self.name)") } } class MyClass2_Weak { var name = "" weak var class1: MyClass1_Weak? init(name: String) { self.name = name print("Initializing class2_Weak with name \(self.name)") } deinit { print("Releasing class2_Weak with name \(self.name)") } }

Dans cet exemple, class2 de MyClass1_Weak fait référence de manière faible à MyClass2_Weak. Cela permet à ARC de libérer correctement la mémoire lorsque l’un des objets devient inutile, même si une autre référence existe. Lorsqu’on exécute le code suivant :

swift
let class1: MyClass1_Weak? = MyClass1_Weak(name: "Class1_Weak")
let class2: MyClass2_Weak? = MyClass2_Weak(name: "Class2_Weak")
class1
?.class2 = class2 class2?.class1 = class1 print("Classes going out of scope")

Nous obtenons la sortie suivante, montrant que les deux objets sont libérés sans causer de fuites de mémoire :

pgsql
Initializing class2_Weak with name Class2_Weak Initializing class1_Weak with name Class1_Weak Classes going out of scope Releasing class2_Weak with name Class2_Weak Releasing class1_Weak with name Class1_Weak

Conclusion

La gestion des cycles de références dans Swift repose sur l’utilisation appropriée des références faibles et non possédées pour éviter les fuites de mémoire. En maîtrisant ces concepts, vous pourrez garantir que votre application gère efficacement la mémoire et évite les problèmes de performances. Il est essentiel de bien comprendre quand utiliser chaque type de référence en fonction de la durée de vie des objets impliqués dans une relation circulaire.

Comment utiliser Swift Testing efficacement dans vos projets

Swift Testing est un outil puissant qui facilite l'intégration de tests dans les projets Swift, permettant aux développeurs de s'assurer de la fiabilité et de la robustesse de leurs applications. Afin d'exploiter pleinement les capacités de Swift Testing, il est essentiel de comprendre quelques éléments clés et les bonnes pratiques pour les mettre en œuvre dans vos projets.

Pour commencer à utiliser Swift Testing, il faut ajouter le package Swift Testing à votre fichier Package.swift comme dépendance. L'ajout de ce code permet d'inclure le package à votre projet :

swift
dependencies: [ .package(url: "https://github.com/swiftlang/swift-testing.git", branch: "main"), ],

Ensuite, ajoutez la cible de test à la section des cibles (targets), en utilisant le code suivant :

swift
testTarget(
name: "MyProjectTests", dependencies: [ "MyProject", .product(name: "Testing", package: "swift-testing"), ] )

Une fois cette configuration terminée, vous êtes prêts à plonger plus profondément dans l’utilisation de Swift Testing et à explorer ses fonctionnalités essentielles.

Les bases du Swift Testing

Le principal composant pour écrire des tests dans Swift Testing est la fonction marquée avec l'attribut @Test. Cette annotation permet de signaler à Xcode qu'une fonction est un test, ce qui lui permet de l'exécuter avec un simple clic sur le bouton "Run" qui apparaît à côté du test dans l'interface de Xcode. L'attribut @Test peut être utilisé avec des fonctions ou des méthodes dans une classe, ce qui vous donne une grande flexibilité dans l'organisation de vos tests. De plus, les fonctions de test peuvent être asynchrones (async) ou marquées comme pouvant générer des erreurs (throws), ce qui permet d'effectuer des tests sur des opérations asynchrones ou de gérer des erreurs dans les tests.

Un aspect intéressant de @Test est qu’il prend en charge les tests paramétrés, ce qui signifie que vous pouvez appeler la même fonction de test plusieurs fois, avec différents arguments, afin de tester divers scénarios d’entrée. Cela simplifie grandement le processus de couverture complète des tests. Par exemple :

swift
@Test func myTest() { // Code de test ici }

Cela crée un test que vous pouvez exécuter directement dans Xcode. Lorsque vous l'exécutez, un diamant apparaîtra à côté du test dans l'interface de Xcode, indiquant le résultat du test, qu’il soit réussi ou échoué.

Attentes et assertions dans les tests

L'un des mécanismes essentiels de Swift Testing est le macro #expect, qui sert à exprimer des attentes et à effectuer des assertions dans les cas de test. Ce macro remplace efficacement les fonctions d'assertion multiples utilisées dans XCTest, en vous permettant d’écrire des conditions sous forme d'expressions booléennes, ce qui est plus simple et plus flexible. Il peut être utilisé pour vérifier diverses conditions comme l’égalité, la gestion des erreurs, ou des conditions personnalisées. Par exemple, dans un test de validation basique :

swift
@Test func validExpectation() async throws {
#expect(1 == 1) }

Lorsque vous exécutez ce test dans Xcode, vous verrez un diamant à côté de la fonction de test, et un signe vert ou rouge apparaîtra pour indiquer si le test a réussi ou échoué.

Macro #require pour des conditions incontournables

Un autre outil précieux dans Swift Testing est la macro #require. Celle-ci est principalement utilisée pour déballer des valeurs optionnelles et vérifier qu’elles ne sont pas nil dans un test. Si une valeur optionnelle est nil, le test échouera immédiatement. Par exemple :

swift
let one: Int? = 10
let two: String? = nil let willSucceed = try #require(one) let willFail = try #require(two)

Dans cet exemple, le test passera pour one car il n'est pas nil, mais échouera pour two qui est nil, provoquant ainsi l'arrêt prématuré du test.

Vérifications de confirmation avec confirmation(expectedCount:)

Une autre fonctionnalité puissante introduite avec Swift 6.1 est la fonction confirmation(expectedCount:), qui permet de vérifier que le nombre d'éléments produits ou traités dans un test est correct. Plutôt que de vérifier manuellement les compteurs ou de créer des messages d'erreur personnalisés, cette fonction permet d’exprimer clairement les attentes et d’obtenir un retour précis en cas d’échec. Par exemple, si vous attendez un nombre spécifique d'éléments dans un tableau, vous pouvez procéder ainsi :

swift
@Test func testRandomArrayCountInRange() async throws {
let range = 5...10
let count = Int.random(in: 0...15)
let values = Array(repeating: "Item", count: count)
await confirmation(expectedCount: range) { confirm in for _ in values { confirm() } } }

Ici, le test génère un tableau aléatoire et confirme que le nombre d'éléments se situe bien dans une plage spécifiée. Si ce n'est pas le cas, Swift produira un message d’erreur explicite.

Tests de sortie avec Swift 6.2

Une des limitations majeures des anciennes versions de Swift Testing était l’incapacité de tester des erreurs critiques comme celles qui entraînent l'arrêt immédiat d'un processus, telles que preconditionFailure ou fatalError. Swift 6.2 a introduit les tests de sortie (exit tests), qui permettent de vérifier que le code qui génère un crash fonctionne comme prévu, en l’exécutant dans un sous-processus et en vérifiant si le processus se termine correctement. Cela permet d’implémenter des tests robustes pour les scénarios où le code doit échouer de manière contrôlée.

Avec cette fonctionnalité, vous pouvez tester les situations où votre application doit rencontrer un échec, tout en garantissant que ce comportement est géré de manière prévisible et contrôlée.

Conclusion

Swift Testing est un outil riche et puissant qui permet aux développeurs d’intégrer des tests automatisés dans leurs projets Swift de manière fluide et efficace. Les composants clés comme l’attribut @Test, les macros #expect, #require, et confirmation(expectedCount:), ainsi que les tests de sortie introduits avec Swift 6.2, offrent une gamme de fonctionnalités qui facilitent grandement l’écriture et l’exécution de tests. Pour tirer pleinement parti de cet outil, il est essentiel de comprendre la manière dont ces différents éléments interagissent et comment les utiliser de manière cohérente pour garantir la fiabilité et la robustesse de vos applications.