Swift a été conçu pour combler le fossé entre les abstractions sans coût de C++ et la flexibilité dynamique de Objective-C. Les premières versions reposaient fortement sur l'héritage de classe et les tables de méthodes virtuelles, mais l'introduction de la programmation orientée protocole dans Swift 2.0 nécessitait un modèle de dispatch plus nuancé. L'équipe de compilation a opté pour une approche hybride où les exigences de protocole (méthodes déclarées dans le corps du protocole) utilisent des tables de témoins pour le polymorphisme à l'exécution, tandis que les méthodes définies uniquement dans des extensions sont résolues statiquement. Cette décision de conception découle de la nécessité de soutenir la modélisation rétroactive et les types de valeur sans sacrifier les caractéristiques de performance du dispatch statique.
Les développeurs supposent souvent qu'en fournissant une implémentation de méthode dans une extension de protocole, cela crée un comportement "par défaut" que les types conformes peuvent remplacer de manière polymorphique. Cependant, Swift dispatches des méthodes d'extension statiquement en fonction du type à la compilation de la référence, et non du type à l'exécution de l'instance. Lors de l'utilisation de boîtes existentielles (any Protocol), le type à la compilation est le conteneur existentiel lui-même, ce qui fait que les invocations se résolvent à l'implémentation de l'extension, quelles que soient les substitutions dans les types concrets. Cela crée des bugs insidieux où les implémentations personnalisées dans des sous-classes ou des structures sont silencieusement contournées dans des collections hétérogènes.
Pour permettre un véritable polymorphisme dynamique, la méthode doit être déclarée comme une exigence de protocole dans la déclaration du protocole elle-même. Cela force le compilateur à allouer une entrée de table de témoins pour la méthode, permettant à l'exécution de rechercher la bonne implémentation via la table de témoins du type. Pour des algorithmes critiques en termes de performance où le polymorphisme n'est pas nécessaire, les méthodes doivent rester dans les extensions pour permettre au compilateur de les inclure directement ou d'effectuer d'autres optimisations statiques. Swift 5.6+ a introduit la syntaxe any explicite pour rendre l'effacement des types existentiels plus visible, servant de rappel que l'information de type est perdue et que le dispatch statique revient à l'extension.
protocol Drawable { func draw() // Exigence : dispatch dynamique via table de témoins } extension Drawable { func draw() { print("Par défaut") } func render() { print("Rendu statique") } // Extension : dispatch statique seulement } struct Circle: Drawable { func draw() { print("Cercle") } func render() { print("Rendu du cercle") } } let shape: any Drawable = Circle() shape.draw() // Imprime "Cercle" (dispatch dynamique) shape.render() // Imprime "Rendu statique" (dispatch statique - ignore la version de Circle !)
Nous développions un moteur de graphiques vectoriels où diverses formes se conformaient à un protocole RenderCommand. Nous avons d'abord ajouté une méthode generatePreview() exclusivement dans une extension de protocole pour fournir une vignette rasterisée par défaut pour toutes les formes. Des types concrets comme BezierCurve et Polygon ont implémenté leurs propres méthodes generatePreview() optimisées qui utilisaient leurs propriétés géométriques spécifiques pour un rendu net. Lorsque nous avons stocké ces formes dans un tableau [any RenderCommand] pour traiter le pipeline de rendu, nous avons découvert qu'en appelant generatePreview() sur chaque élément, l'image par défaut floue était produite, plutôt que les vignettes personnalisées de haute qualité.
Nous avons envisagé trois solutions distinctes. Tout d'abord, nous pourrions déplacer generatePreview() dans la déclaration de protocole RenderCommand en tant qu'exigence formelle. Cette approche garantirait le dispatch dynamique via la table de témoins, assurant la bonne résolution de méthode à l'exécution. Cependant, cela obligerait chaque type de forme à déclarer explicitement la méthode dans sa conformité, bien que nous puissions atténuer le code standard en gardant l'implémentation par défaut dans l'extension pour les types qui n'avaient pas besoin de personnalisation.
Deuxièmement, nous pourrions refactoriser notre pipeline pour utiliser des génériques avec une signature de fonction comme func process<T: RenderCommand>(commands: [T]) au lieu d'utiliser l'existentiel [any RenderCommand]. Cela préserverait le dispatch statique à la bonne implémentation, car Swift monomorphise les génériques à la compilation, préservant l'information de type. L'inconvénient était que nous ne pouvions plus stocker des types de forme hétérogènes (mélangeant BezierCurve et Polygon) dans un seul tableau sans implémenter un wrapper d'effacement de type, ce qui augmenterait considérablement la complexité du code.
Troisièmement, nous pourrions implémenter le patron Visiteur pour router manuellement les appels de méthode au type concret approprié. Cela éviterait de modifier complètement la définition du protocole tout en atteignant un comportement polymorphe. Cependant, cette solution introduisait un code standard substantiel et créait une charge de maintenance chaque fois que de nouveaux types de forme étaient ajoutés au système.
Nous avons finalement choisi la première solution car le protocole était interne à notre module, et la clarté du comportement polymorphe était essentielle pour l'exactitude du moteur de rendu. Ajouter l'exigence avait un impact négligeable sur la taille binaire de notre code, et le léger surcoût de l'indirection de table de témoins était imperceptible par rapport aux calculs de rendu. Après avoir mis en œuvre ce changement, la génération de vignettes utilisa correctement l'implémentation optimisée de chaque forme, éliminant les artefacts visuels de l'interface utilisateur.
Pourquoi un sous-classe ne peut-elle pas remplacer une méthode qui a été définie uniquement dans une extension de protocole ?
Lorsqu'une méthode est définie uniquement dans une extension de protocole et non déclarée dans le protocole lui-même, Swift n'alloue pas d'entrée de table de témoins pour elle. Le dispatch est résolu statiquement à la compilation en fonction du type de référence. Si une classe se conforme au protocole et définit une méthode avec la même signature, cela crée une nouvelle méthode non liée qui ombre la méthode de l'extension plutôt que de la remplacer. Cela signifie que lorsqu'elle est accessible via un existentiel de protocole (any Protocol), l'implémentation de l'extension de protocole est toujours appelée, ignorant la version de la classe. Pour obtenir un comportement polymorphe, la méthode doit être déclarée dans la déclaration du protocole pour devenir une exigence avec dispatch dynamique.
Comment l'utilisation de some (types de résultats opaques) au lieu de any affecte-t-elle le dispatch pour les méthodes d'extension de protocole ?
Avec some Drawable, le type concret est connu à la compilation grâce à la monomorphisation des génériques par Swift. Lors de l'appel d'une méthode d'extension sur un type opaque, le compilateur peut dispatcher statiquement vers l'implémentation du type concret car l'information de type est préservée dans les coulisses, même si elle est cachée de l'appelant. En revanche, any Drawable est une boîte existentielle qui efface le type concret, forçant le compilateur à utiliser l'implémentation par défaut de l'extension pour les méthodes non requises. La différence clé est que some préserve le polymorphisme statique, permettant au compilateur de inclure directement ou de lier correctement à la méthode, tandis que any force une recherche de vtable à l'exécution uniquement pour les exigences et revient à l'extension pour tout le reste.
Quel est l'impact sur la taille binaire et la performance de la conversion d'une méthode d'extension en exigence de protocole ?
La conversion d'une méthode d'extension en exigence de protocole ajoute une entrée à la table de témoins du protocole, augmentant la taille binaire d'environ 8 octets par conformité dans les architectures 64 bits. Chaque type conforme doit désormais peupler cet emplacement dans sa table de témoins, ajoutant un léger surcoût mémoire par type. En termes de performance, les exigences entraînent un surcoût d'appel indirect via la table de témoins (une dereference de pointeur supplémentaire et un saut), tandis que les méthodes d'extension peuvent être incluses directement ou appelées sans aucun surcoût. Cependant, la perte de l'inclusion pour les exigences est souvent compensée par le prédicteur de branche CPU, et le bénéfice d'un comportement polymorphe correct l'emporte généralement sur le coût en nanosecondes de l'appel indirect dans la plupart des codes d'application.