Avant Swift 5.9, les développeurs faisaient face à une limitation expressive significative lors de l'écriture de code générique qui opérait sur des collections hétérogènes de types. Les fonctions nécessitant un nombre variable d'arguments avec des types distincts et préservés étaient contraintes de recourir à l'effacement de type via Any ou des conteneurs existentiels (any P), sacrifiant la sécurité à la compilation et entraînant des frais d'allocation sur le tas. L'introduction des Paquets de Paramètres (SE-0393, SE-0398 et SE-0399) a apporté des génériques variadiques à Swift, permettant au langage d'exprimer des motifs nécessitant auparavant la métaprogrammation de modèles de C++ ou les traits variadiques de Rust. Cette évolution a comblé des lacunes fondamentales dans la programmation générique, permettant des abstractions sûres pour le type et sans coût sur des données hétérogènes sans génération de surcharge manuelle.
Le défi central résidait dans la mise en œuvre d'un mécanisme capable d'accepter un nombre arbitraire d'arguments génériques, chacun pouvant être un type distinct, tout en conservant l'information de type statique à travers la chaîne d'appels. Les solutions pré-paquet de paramètres utilisant [Any] nécessitaient des conversions au moment de l'exécution et échouaient à préserver les relations de type, empêchant des optimisations du compilateur comme l'inlining et le dispatch spécialisé. Alternativement, générer manuellement des surcharges pour les arités 1 à N (par exemple, <T1>, <T1, T2>, <T1, T2, T3>) créait un gonflement binaire et imposait des limites arbitraires sur le nombre d'arguments. La solution devait prendre en charge l'itération des paquets à la compilation, où le compilateur générait un code monomorphisé spécifique à la signature de type de chaque site d'appel, sans introduire de boxage à l'exécution ou d'indirection de table de témoin pour les types de valeur simples.
Swift implémente des paquets de paramètres grâce à l'expansion de paquet, considérant le motif repeat each T comme un modèle à la compilation pour la génération de code. Lorsqu'une fonction déclare un paquet de paramètres de type <each T> et accepte un paquet de valeurs repeat each T, le compilateur effectue une monomorphisation au site d'appel, élargissant le corps générique en code concret pour chaque élément du paquet. Cela diffère des variadiques homogènes (par exemple, Int...) car chaque élément maintient son identité de type unique. Le mot-clé repeat signale à la phase de génération de SIL (Swift Intermediate Language) que l'expression suivante doit être dupliquée pour chaque élément du paquet, avec des types substitués en conséquence. Cette transformation élimine le boxage car les types de valeur demeurent sur la pile dans leur agencement concret, et les appels de fonction sont dispatchés statiquement sans le surcoût du conteneur existentiel.
// Fonction acceptant un paquet de paramètres hétérogène func describeValues<each T>(_ values: repeat each T) { // Le compilateur élargit cette boucle à la compilation repeat print("Type: \(type(of: each values)), Value: \(each values)") } // L'utilisation génère un code spécialisé équivalent à : // describeValues(Int, String, Double) describeValues(42, "Swift", 3.14)
Notre équipe concevait un cadre de pipeline de données haute performance pour iOS, où les utilisateurs devaient enchaîner des étapes de transformation hétérogènes (par exemple, DecodeJSON<T>, Validate<U>, Map<V>) en un seul graphe d'exécution. L'API nécessitait une fonction pipeline acceptant un nombre quelconque de ces étapes, chacune avec des types d'entrées et de sorties distincts, tout en maintenant la connaissance du flux de données à la compilation pour permettre les passes d'optimisation.
Nous avons d'abord implémenté des surcharges pour 1 à 6 arguments génériques (par exemple, func pipeline<T1, T2>(_: T1, _: T2)). Cela préservait les types statiques et permettait à LLVM d'inliner la chaîne entière. Cependant, cette approche était verbeuse et non maintenable, nécessitant des centaines de lignes de code quasi identique. Elle limitait artificiellement les utilisateurs à six étapes, et chaque arité supplémentaire augmentait exponentiellement la taille binaire en raison de la duplication de code. Lorsque les exigences ont changé pour supporter huit étapes, l'effort de refactoring était substantiel.
Ensuite, nous avons essayé de définir un protocole AnyPipelineStep avec des types associés, puis d'utiliser [any AnyPipelineStep] comme paramètre. Cela a supporté un nombre illimité d'étapes mais a contraint chaque type de valeur (structures portant des données décodées) dans des conteneurs existants alloués sur le tas. Le profilage de performance a révélé que 30% du temps CPU était passé dans les opérations swift_retain et swift_release sur ces boîtes. De plus, le compilateur ne pouvait plus optimiser à travers les limites des étapes parce que les types associés étaient effacés, nécessitant un casting dynamique à chaque jonction.
Avec Swift 5.9, nous avons refactorisé l'API pour utiliser func pipeline<each Step: PipelineStep>(steps: repeat each Step). Cela a permis au compilateur de générer une spécialisation unique pour chaque composition de pipeline distincte rencontrée dans la base de code. Chaque étape a conservé son type concret, permettant un inlining agressif et une allocation sur la pile pour des structures de données éphémères. Le mot-clé repeat nous a permis d'itérer sur le paquet pour vérifier la compatibilité des types entre les étapes adjacentes à la compilation.
Nous avons adopté les paquets de paramètres car ils ont éliminé la limitation d'arité sans sacrifier la performance. Contrairement aux existentiels, les paquets ont préservé la signature générique pour l'optimiseur de Swift, résultant en une abstraction sans coût. Le refactor a réduit la taille binaire du cadre de 35% par rapport à l'approche de surcharge et a amélioré le débit par 4x par rapport à l'approche existante. Les développeurs pouvaient désormais composer des pipelines de longueur arbitraire avec un support complet de l'autocomplétion pour chaque type d'entrée/sortie spécifique de chaque étape, attrapant les incompatibilités de données au moment de la compilation plutôt que lors des tests d'intégration.
Les candidats supposent souvent que les contraintes de paquet se comportent comme des contraintes génériques uniques, mais Swift exige des motifs repeat explicites dans les clauses where. Lorsqu'on contraint chaque élément du paquet T à se conformer à Container avec des types associés Item différents, la syntaxe devient func process<each T: Container>(_ items: repeat each T) where repeat each T.Item: Equatable. Le compilateur effectue une résolution de contraintes structurelles, élargissant la clause where élément par élément à travers le paquet. Un mode de défaillance courant consiste à tenter d'utiliser une seule contrainte de type associé pour l'ensemble du paquet, ce qui échoue parce que chaque T.Item est un type distinct. Comprendre que les contraintes de paquet génèrent une conjonction de conditions par élément, plutôt qu'une seule contrainte unifiée, est essentiel pour déboguer les erreurs d'inférence.
Les développeurs croient souvent que les paquets de paramètres garantissent une abstraction sans coût dans tous les contextes, mais le franchissement des frontières ABI ou l'utilisation de types de résultats opaques peuvent forcer le boxage. Spécifiquement, lorsqu'un paquet de paramètres est capturé dans une fermeture échappant passée à une fonction dans un domaine de résilience différent (par exemple, une interface de bibliothèque publique), Swift peut émettre une instantiation générique à l'exécution utilisant des tables de témoins plutôt que des spécialisations statiques. De même, retourner some Collection depuis une itération de paquet force le compilateur à utiliser un conteneur existentiel parce que le type de retour concret varie avec chaque élément de paquet. Cela impacte la disposition en mémoire en introduisant une allocation sur le tas pour le tampon en ligne de l'existentiel (trois mots) et en ajoutant une indirection par le biais de la table de témoins de protocole. Reconnaître que l'expansion de paquet nécessite une visibilité statique de l'ensemble du paquet au site d'appel est crucial pour maintenir la performance.
Cette limitation confond les candidats qui s'attendent à ce que struct Storage<each T> { repeat var item: each T } déclare des propriétés stockées distinctes pour chaque élément de paquet. Swift interdit cela car les propriétés stockées nécessitent des décalages et des pas fixes connus de la table de témoins de valeur pour la gestion de la mémoire. Un nombre varié de propriétés créerait des structures de tailles variables, violant les exigences de stabilité ABI pour les types génériques—la table de témoins de valeur s'attend à un agencement statique pour copier, déplacer et détruire des instances. En exigeant une agrégation dans (repeat each T), le compilateur traite le paquet comme une seule valeur composite avec un agencement dérivé du produit cartésien de ses éléments. Cela garantit que chaque spécialisation de Storage a un agencement binaire déterministe, permettant au runtime de sélectionner les fonctions de témoins de valeur appropriées sans recherches de métadonnées dynamiques. Comprendre cette distinction entre les paquets de paramètres transitoires (arguments de fonction) et le stockage persistant (champs de structure) clarifie pourquoi les paquets doivent être "gelés" dans des tuples pour le stockage persistant.