Une introduction à la programmation orientée protocole dans Swift
Publié: 2022-03-11Le protocole est une fonctionnalité très puissante du langage de programmation Swift.
Les protocoles sont utilisés pour définir un "plan de méthodes, de propriétés et d'autres exigences qui conviennent à une tâche ou à une fonctionnalité particulière".
Swift vérifie les problèmes de conformité du protocole au moment de la compilation, permettant aux développeurs de découvrir des bogues fatals dans le code avant même d'exécuter le programme. Les protocoles permettent aux développeurs d'écrire du code flexible et extensible dans Swift sans avoir à compromettre l'expressivité du langage.
Swift pousse la commodité d'utilisation des protocoles un peu plus loin en fournissant des solutions de contournement à certaines des bizarreries et limitations les plus courantes des interfaces qui affligent de nombreux autres langages de programmation.
Dans les versions antérieures de Swift, il était possible d'étendre uniquement les classes, les structures et les énumérations, comme c'est le cas dans de nombreux langages de programmation modernes. Cependant, depuis la version 2 de Swift, il est également devenu possible d'étendre les protocoles.
Cet article examine comment les protocoles de Swift peuvent être utilisés pour écrire du code réutilisable et maintenable et comment les modifications apportées à une grande base de code orientée protocole peuvent être consolidées en un seul endroit grâce à l'utilisation d'extensions de protocole.
Protocoles
Qu'est-ce qu'un protocole ?
Dans sa forme la plus simple, un protocole est une interface qui décrit certaines propriétés et méthodes. Tout type conforme à un protocole doit remplir les propriétés spécifiques définies dans le protocole avec les valeurs appropriées et implémenter les méthodes requises. Par exemple:
protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }
Le protocole Queue décrit une file d'attente contenant des éléments entiers. La syntaxe est assez simple.
À l'intérieur du bloc de protocole, lorsque nous décrivons une propriété, nous devons spécifier si la propriété est uniquement gettable { get }
ou à la fois gettable et settable { get set }
. Dans notre cas, la variable Count (de type Int
) est accessible uniquement.
Si un protocole exige qu'une propriété soit accessible et paramétrable, cette exigence ne peut pas être satisfaite par une propriété stockée constante ou une propriété calculée en lecture seule.
Si le protocole exige uniquement qu'une propriété soit accessible, l'exigence peut être satisfaite par n'importe quel type de propriété, et il est valide que la propriété soit également modifiable, si cela est utile pour votre propre code.
Pour les fonctions définies dans un protocole, il est important d'indiquer si la fonction va changer le contenu avec le mot-clé mutating
. En dehors de cela, la signature d'une fonction suffit comme définition.
Pour se conformer à un protocole, un type doit fournir toutes les propriétés d'instance et implémenter toutes les méthodes décrites dans le protocole. Ci-dessous, par exemple, une structure Container
conforme à notre protocole Queue
. La structure stocke essentiellement des Int
poussés dans un tableau privé items
.
struct Container: Queue { private var items: [Int] = [] var count: Int { return items.count } mutating func push(_ element: Int) { items.append(element) } mutating func pop() -> Int { return items.removeFirst() } }
Notre protocole de file d'attente actuel présente cependant un inconvénient majeur.
Seuls les conteneurs qui traitent des Int
s peuvent se conformer à ce protocole.
Nous pouvons supprimer cette limitation en utilisant la fonctionnalité "types associés". Les types associés fonctionnent comme des génériques. Pour démontrer, changeons le protocole Queue pour utiliser les types associés :
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Désormais, le protocole Queue permet le stockage de tout type d'éléments.
Dans l'implémentation de la structure Container
, le compilateur détermine le type associé à partir du contexte (c'est-à-dire, le type de retour de méthode et les types de paramètres). Cette approche nous permet de créer une structure Container
avec un type d'éléments génériques. Par exemple:
class Container<Item>: Queue { private var items: [Item] = [] var count: Int { return items.count } func push(_ element: Item) { items.append(element) } func pop() -> Item { return items.removeFirst() } }
L'utilisation de protocoles simplifie l'écriture de code dans de nombreux cas.
Par exemple, tout objet qui représente une erreur peut se conformer au protocole Error
(ou LocalizedError
, au cas où nous voudrions fournir des descriptions localisées).
La même logique de gestion des erreurs peut ensuite être appliquée à n'importe lequel de ces objets d'erreur dans votre code. Par conséquent, vous n'avez pas besoin d'utiliser un objet spécifique (comme NSError en Objective-C) pour représenter les erreurs, vous pouvez utiliser n'importe quel type conforme aux protocoles Error
ou LocalizedError
.
Vous pouvez même étendre le type String pour le rendre conforme au protocole LocalizedError
et lancer des chaînes en tant qu'erreurs.
extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }
Extensions de protocole
Les extensions de protocole s'appuient sur la génialité des protocoles. Ils nous permettent de :
Fournir une implémentation par défaut des méthodes de protocole et des valeurs par défaut des propriétés de protocole, les rendant ainsi « facultatives ». Les types conformes à un protocole peuvent fournir leurs propres implémentations ou utiliser celles par défaut.
Ajoutez la mise en œuvre de méthodes supplémentaires non décrites dans le protocole et « décorez » tous les types conformes au protocole avec ces méthodes supplémentaires. Cette fonctionnalité nous permet d'ajouter des méthodes spécifiques à plusieurs types déjà conformes au protocole sans avoir à modifier chaque type individuellement.
Implémentation de la méthode par défaut
Créons un autre protocole :
protocol ErrorHandler { func handle(error: Error) }
Ce protocole décrit les objets qui sont chargés de gérer les erreurs qui se produisent dans une application. Par exemple:
struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Ici, nous imprimons simplement la description localisée de l'erreur. Avec l'extension de protocole, nous sommes en mesure de faire de cette implémentation la valeur par défaut.
extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Cela rend la méthode handle
facultative en fournissant une implémentation par défaut.
La possibilité d'étendre un protocole existant avec des comportements par défaut est assez puissante, permettant aux protocoles de se développer et d'être étendus sans avoir à se soucier de la rupture de compatibilité du code existant.
Extensions conditionnelles
Nous avons donc fourni une implémentation par défaut de la méthode handle
, mais l'impression sur la console n'est pas très utile pour l'utilisateur final.
Nous préférerions probablement leur montrer une sorte de vue d'alerte avec une description localisée dans les cas où le gestionnaire d'erreurs est un contrôleur de vue. Pour ce faire, nous pouvons étendre le protocole ErrorHandler
, mais nous pouvons limiter l'extension pour qu'elle ne s'applique qu'à certains cas (c'est-à-dire lorsque le type est un contrôleur de vue).
Swift nous permet d'ajouter de telles conditions aux extensions de protocole en utilisant le mot-clé where
.
extension ErrorHandler where Self: UIViewController { func handle(error: Error) { let alert = UIAlertController(title: nil, message: error.localizedDescription, preferredStyle: .alert) let action = UIAlertAction(title: "OK", style: .cancel, handler: nil) alert.addAction(action) present(alert, animated: true, completion: nil) } }
Self (avec un "S" majuscule) dans l'extrait de code ci-dessus fait référence au type (structure, classe ou enum). En spécifiant que nous n'étendons le protocole que pour les types qui héritent de UIViewController
, nous sommes en mesure d'utiliser des méthodes spécifiques à UIViewController
(telles que present(viewControllerToPresnt: animated: completion)
).

Désormais, tous les contrôleurs de vue conformes au protocole ErrorHandler
ont leur propre implémentation par défaut de la méthode handle
qui affiche une vue d'alerte avec une description localisée.
Implémentations de méthodes ambiguës
Supposons qu'il existe deux protocoles, qui ont tous deux une méthode avec la même signature.
protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }
Les deux protocoles ont une extension avec une implémentation par défaut de cette méthode.
extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }
Supposons maintenant qu'il existe un type conforme aux deux protocoles.
struct S: P1, P2 { }
Dans ce cas, nous avons un problème avec la mise en œuvre de la méthode ambiguë. Le type n'indique pas clairement quelle implémentation de la méthode il doit utiliser. En conséquence, nous obtenons une erreur de compilation. Pour résoudre ce problème, nous devons ajouter l'implémentation de la méthode au type.
struct S: P1, P2 { func method() { print("Method S") } }
De nombreux langages de programmation orientés objet sont en proie à des limitations entourant la résolution de définitions d'extension ambiguës. Swift gère cela de manière assez élégante grâce aux extensions de protocole en permettant au programmeur de prendre le contrôle là où le compilateur échoue.
Ajout de nouvelles méthodes
Jetons un coup d'œil au protocole de Queue
d'attente une fois de plus.
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Chaque type conforme au protocole Queue
possède une propriété d'instance count
qui définit le nombre d'éléments stockés. Cela nous permet, entre autres, de comparer ces types pour décider lequel est le plus grand. Nous pouvons ajouter cette méthode via l'extension de protocole.
extension Queue { func compare<Q>(queue: Q) -> ComparisonResult where Q: Queue { if count < queue.count { return .orderedDescending } if count > queue.count { return .orderedAscending } return .orderedSame } }
Cette méthode n'est pas décrite dans le protocole de Queue
d'attente lui-même car elle n'est pas liée à la fonctionnalité de file d'attente.
Il ne s'agit donc pas d'une implémentation par défaut de la méthode protocolaire, mais plutôt d'une nouvelle implémentation de méthode qui « décore » tous les types conformes au protocole Queue
. Sans les extensions de protocole, nous aurions dû ajouter cette méthode à chaque type séparément.
Extensions de protocole vs classes de base
Les extensions de protocole peuvent sembler assez similaires à l'utilisation d'une classe de base, mais l'utilisation d'extensions de protocole présente plusieurs avantages. Ceux-ci incluent, mais ne sont pas nécessairement limités à :
Étant donné que les classes, les structures et les énumérations peuvent se conformer à plusieurs protocoles, elles peuvent adopter l'implémentation par défaut de plusieurs protocoles. Ceci est conceptuellement similaire à l'héritage multiple dans d'autres langages.
Les protocoles peuvent être adoptés par les classes, les structures et les énumérations, tandis que les classes de base et l'héritage ne sont disponibles que pour les classes.
Extensions de bibliothèque standard Swift
En plus d'étendre vos propres protocoles, vous pouvez étendre les protocoles de la bibliothèque standard Swift. Par exemple, si nous voulons trouver la taille moyenne de la collection de files d'attente, nous pouvons le faire en étendant le protocole Collection
standard.
Les structures de données de séquence fournies par la bibliothèque standard de Swift, dont les éléments peuvent être parcourus et accessibles via un indice indexé, sont généralement conformes au protocole Collection
. Grâce à l'extension de protocole, il est possible d'étendre toutes ces structures de données de bibliothèque standard ou d'en étendre quelques-unes de manière sélective.
Remarque : Le protocole anciennement connu sous le nom de
CollectionType
dans Swift 2.x a été renomméCollection
dans Swift 3.
extension Collection where Iterator.Element: Queue { func avgSize() -> Int { let size = map { $0.count }.reduce(0, +) return Int(round(Double(size) / Double(count.toIntMax()))) } }
Nous pouvons maintenant calculer la taille moyenne de n'importe quelle collection de files d'attente ( Array
, Set
, etc.). Sans les extensions de protocole, nous aurions dû ajouter cette méthode à chaque type de collection séparément.
Dans la bibliothèque standard Swift, les extensions de protocole sont utilisées pour implémenter, par exemple, des méthodes telles que map
, filter
, reduce
, etc.
extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }
Extensions de protocole et polymorphisme
Comme je l'ai dit plus tôt, les extensions de protocole nous permettent d'ajouter des implémentations par défaut de certaines méthodes et d'ajouter également de nouvelles implémentations de méthodes. Mais quelle est la différence entre ces deux fonctionnalités ? Revenons au gestionnaire d'erreurs et découvrons-le.
protocol ErrorHandler { func handle(error: Error) } extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } } struct Handler: ErrorHandler { func handle(error: Error) { fatalError("Unexpected error occurred") } } enum ApplicationError: Error { case other } let handler: Handler = Handler() handler.handle(error: ApplicationError.other)
Le résultat est une erreur fatale.
Supprimez maintenant la déclaration de méthode handle(error: Error)
du protocole.
protocol ErrorHandler { }
Le résultat est le même : une erreur fatale.
Cela signifie-t-il qu'il n'y a aucune différence entre l'ajout d'une implémentation par défaut de la méthode de protocole et l'ajout d'une nouvelle implémentation de méthode au protocole ?
Non! Une différence existe, et vous pouvez la voir en changeant le type du handler
de variables de Handler
en ErrorHandler
.
let handler: ErrorHandler = Handler()
Maintenant, la sortie vers la console est : L'opération n'a pas pu être terminée. (Erreur ApplicationError 0.)
Mais si nous renvoyons la déclaration de la méthode handle(error: Error) au protocole, le résultat reviendra à l'erreur fatale.
protocol ErrorHandler { func handle(error: Error) }
Regardons l'ordre de ce qui se passe dans chaque cas.
Lorsque la déclaration de méthode existe dans le protocole :
Le protocole déclare la méthode handle(error: Error)
et fournit une implémentation par défaut. La méthode est remplacée dans l'implémentation du Handler
. Ainsi, l'implémentation correcte de la méthode est invoquée au moment de l'exécution, quel que soit le type de la variable.
Lorsque la déclaration de méthode n'existe pas dans le protocole :
Étant donné que la méthode n'est pas déclarée dans le protocole, le type ne peut pas la remplacer. C'est pourquoi l'implémentation d'une méthode appelée dépend du type de la variable.
Si la variable est de type Handler
, l'implémentation de la méthode à partir du type est appelée. Si la variable est de type ErrorHandler
, l'implémentation de la méthode à partir de l'extension de protocole est invoquée.
Code orienté protocole : sûr mais expressif
Dans cet article, nous avons démontré une partie de la puissance des extensions de protocole dans Swift.
Contrairement à d'autres langages de programmation avec des interfaces, Swift ne restreint pas les protocoles avec des limitations inutiles. Swift contourne les bizarreries courantes de ces langages de programmation en permettant au développeur de résoudre l'ambiguïté si nécessaire.
Avec les protocoles Swift et les extensions de protocole, le code que vous écrivez peut être aussi expressif que la plupart des langages de programmation dynamiques et toujours être de type sûr au moment de la compilation. Cela vous permet d'assurer la réutilisabilité et la maintenabilité de votre code et d'apporter des modifications à la base de code de votre application Swift avec plus de confiance.
Nous espérons que cet article vous sera utile et nous vous invitons à nous faire part de vos commentaires ou de vos idées supplémentaires.