Dans cet article, j’explorerai certains des détails graveleux des initialiseurs Swift. Pourquoi aborder un sujet aussi passionnant? Principalement parce que je me suis parfois retrouvé confus par des erreurs de compilation en essayant de créer une sous-classe d’UIView, ou en essayant de créer une version Rapide d’une ancienne classe Objective-C, par exemple. J’ai donc décidé de creuser plus profondément pour mieux comprendre les nuances des initialiseurs Swift. Étant donné que mon expérience a principalement consisté à travailler avec Groovy, Java et Javascript, je comparerai certains des mécanismes d’initialisation de Swift à ceux de Java & Groovy. Je vous encourage à ouvrir un terrain de jeu dans XCode et à jouer avec les exemples de code vous-même. Je vous encourage également à lire la section d’initialisation du guide du langage Swift 2.1, qui est la principale source d’informations pour cet article. Enfin, je me concentrerai sur les types de référence (classes) plutôt que sur les types de valeur (structures).
- Qu’Est-Ce Qu’Un Initialiseur ?
- Les sous-classes n’héritent généralement pas des initialiseurs
- Initialiseurs de commodité vs désignés
- Phases d’initialisation
- Remplacer un initialiseur
- Lorsque les Initialiseurs Sont Hérités
- Initialiseurs échouables
- Initialiseurs requis
- Cas particulier: Extension de UIView
Qu’Est-Ce Qu’Un Initialiseur ?
Un initialiseur est un type spécial de méthode dans une structure, une classe ou une énumération Swift qui est responsable de s’assurer qu’une instance nouvellement créée de la structure, de la classe ou de l’énumération est entièrement initialisée avant qu’elle ne soit prête à être utilisée. Ils jouent le même rôle que celui d’un « constructeur » en Java et Groovy. Si vous êtes familier avec Objective-C, vous devez noter que les initialiseurs Swift diffèrent des initialiseurs Objective-C en ce qu’ils ne renvoient pas de valeur.
Les sous-classes n’héritent généralement pas des initialiseurs
L’une des premières choses à garder à l’esprit est que « Les sous-classes Swift n’héritent pas de leurs initialiseurs de superclasse par défaut », selon le guide de langage. (Le guide explique qu’il existe des scénarios où les initialiseurs de superclasse sont automatiquement hérités. Nous couvrirons ces scénarios exceptionnels plus tard). Ceci est cohérent avec le fonctionnement de Java (et par extension, Groovy). Considérez ce qui suit:
Tout comme avec Java & Groovy, il est logique que cela ne soit pas autorisé (bien que, comme pour la plupart des choses, il puisse y avoir un argument sur ce point. Voir cet article StackOverflow). Si cela était autorisé, l’initialisation de la propriété « instrument » de Musician serait contournée, laissant potentiellement votre instance de musicien dans un état invalide. Cependant, avec Groovy, je ne me gênerais normalement pas pour écrire des initialiseurs (c’est-à-dire des constructeurs). Au contraire, j’utiliserais simplement le constructeur de carte que Groovy fournit implicitement, ce qui vous permet de choisir librement les propriétés que vous souhaitez définir lors de la construction. Par exemple, ce qui suit est un code Groovy parfaitement valide:
Notez que vous pouvez inclure n’importe quelle propriété, y compris celles fournies par les superclasses, mais vous n’avez pas à spécifier toutes (ou aucune) d’entre elles, et elles n’ont pas à être spécifiées dans un ordre particulier. Ce type d’initialiseur ultra-flexible n’est pas fourni par Swift. La chose la plus proche dans Swift est les initialiseurs par membres fournis automatiquement pour les structures. Mais l’ordre des arguments dans un initialiseur membre est significatif, même s’ils sont nommés, et dépend de l’ordre dans lequel ils sont définis:
De toute façon, revenons aux classes – La philosophie de Groovy concernant la validité des objets post-construction est clairement très différente de celle de Swift. Ce n’est qu’une des nombreuses façons dont Groovy diffère de Swift.
Initialiseurs de commodité vs désignés
Avant d’aller trop loin dans le trou du lapin, nous devrions clarifier un concept important: Dans Swift, un initialiseur est classé comme étant un initialiseur désigné ou un initialiseur pratique. Je vais essayer d’expliquer la différence à la fois conceptuellement et syntaxiquement. Chaque classe doit avoir au moins un initialiseur désigné, mais peut en avoir plusieurs (« désigné » n’implique pas « unique »). Un initialiseur désigné est considéré comme un initialiseur principal. Ce sont les honchos de tête. Ils sont en fin de compte responsables de s’assurer que toutes les propriétés sont initialisées. En raison de cette responsabilité, il peut parfois devenir pénible d’utiliser un initialiseur désigné tout le temps, car ils peuvent nécessiter plusieurs arguments. Imaginez travailler avec une classe qui a plusieurs propriétés, et vous devez créer plusieurs instances de cette classe qui sont presque identiques à l’exception d’une ou deux propriétés. (Pour des raisons d’argument, supposons également qu’il n’y a pas de valeurs par défaut sensées qui auraient pu être attribuées à des propriétés lorsqu’elles sont déclarées). Par exemple, disons que notre classe de personnes avait également des aliments et des propriétés musicales. Bien sûr, ces deux choses seraient vérifiées la plupart du temps, mais on ne sait jamais. Jetez un oeil:
Maintenant, notre classe Person a quatre propriétés qui doivent être définies, et nous avons un initialiseur désigné qui peut faire le travail. L’initialiseur désigné est le premier, celui qui prend 4 arguments. La plupart du temps, ces deux derniers arguments auront la valeur « true ». Ce serait pénible de devoir continuer à les spécifier chaque fois que nous voulons créer une personne typique. C’est là que les deux derniers initialiseurs entrent en jeu, ceux marqués du modificateur de commodité. Ce modèle devrait sembler familier à un développeur Java. Si vous avez un constructeur qui prend plus d’arguments que vous n’avez vraiment besoin de traiter tout le temps, vous pouvez écrire des constructeurs simplifiés qui prennent un sous-ensemble de ces arguments et fournissent des valeurs par défaut raisonnables pour les autres. Les initialiseurs de commodité doivent déléguer soit à un autre initialiseur de commodité, peut-être moins pratique, soit à un initialiseur désigné. En fin de compte, un initialiseur désigné doit s’impliquer. De plus, s’il s’agit d’une sous-classe, l’initialiseur désigné doit appeler un initialiseur désigné à partir de sa superclasse immédiate.
Un exemple concret d’utilisation du modificateur de commodité provient de la classe UIBezierPath d’UIKit. Je suis sûr que vous pouvez imaginer qu’il existe plusieurs façons de spécifier un chemin. En tant que tel, UIBezierPath fournit plusieurs initialiseurs de commodité :
initialisation de commodité publique (rect: CGRect)
initialisation de commodité publique (ovalInRect rect: CGRect)
initialisation de commodité publique (roundedRect rect: CGRect, cornerRadius: CGFloat)
initialisation de commodité publique (roundedRect rect: CGRect, byRoundingCorners corners: UIRectCorner, cornerRadii: CGSize)
initialisation de la commodité publique (centre arcCenter: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, dans le sens horaire: Bool)
initialisation de commodité publique (CGPath: CGPath)
Plus tôt, j’ai dit qu’une classe peut avoir plusieurs initialiseurs désignés. Alors à quoi ça ressemble ? Une différence importante, imposée par le compilateur, entre les initialiseurs désignés et les initialiseurs de commodité est que les initialiseurs désignés ne peuvent pas déléguer à un autre initialiseur de la même classe (mais doivent déléguer à un initialiseur désigné dans sa superclasse immédiate). Regardez le deuxième initialiseur de Person, celui qui prend un seul argument nommé « Mort-vivant ». Comme cet initialiseur n’est pas marqué avec le modificateur de commodité, Swift le traite comme un initialiseur désigné. En tant que tel, il ne peut pas déléguer à un autre initialiseur en personne. Essayez de commenter les quatre premières lignes et de décommenter la dernière ligne. Le compilateur se plaindra, et XCode devrait essayer de vous aider en vous suggérant de le réparer en ajoutant le modificateur de commodité.
Considérons maintenant la sous-classe de Musicien de Personne. Il a un seul initialiseur, et il doit donc s’agir d’un initialiseur désigné. En tant que tel, il doit appeler un initialiseur désigné de la superclasse immédiate, Person. Rappelez-vous: bien que les initialiseurs désignés ne puissent pas déléguer à un autre initialiseur de la même classe, les initialiseurs de commodité doivent le faire. En outre, un initialiseur désigné doit appeler un initialiseur désigné de sa superclasse immédiate. Voir le guide linguistique pour plus de détails (et de jolis graphismes).
Phases d’initialisation
Comme l’explique le guide du langage Swift, il existe deux phases d’initialisation. Les phases sont délimitées par l’appel à l’initialiseur désigné par la superclasse. La phase 1 est antérieure à l’appel à l’initialiseur désigné par la superclasse, la phase 2 est postérieure. Une sous-classe doit initialiser toutes ses PROPRES propriétés en phase 1, et elle ne peut définir aucune propriété définie par la superclasse avant la phase 2.
Voici un exemple de code, adapté d’un exemple fourni dans le guide de langage, montrant que vous devez initialiser les PROPRES propriétés d’une sous-classe avant d’appeler l’initialiseur désigné par la superclasse. Vous ne pouvez accéder aux propriétés fournies par la superclasse qu’après avoir appelé l’initialiseur désigné par la superclasse. Enfin, vous ne pouvez pas modifier les propriétés stockées constantes après l’appel de l’initialiseur désigné par la superclasse.
Remplacer un initialiseur
Maintenant que nous sommes convaincus que les sous-classes n’héritent généralement pas des initialiseurs, et que nous sommes clairs sur la signification et la distinction entre les initialiseurs désignés et les initialiseurs de commodité, considérons ce qui se passe lorsque vous souhaitez qu’une sous-classe remplace un initialiseur de sa superclasse immédiate. Il y a quatre scénarios que j’aimerais couvrir, étant donné qu’il existe deux types d’initialiseur. Prenons-les donc un par un, avec des exemples de code simples pour chaque cas:
Un initialiseur désigné qui correspond à un initialiseur désigné par superclasse
C’est un scénario typique. Lorsque vous faites cela, vous devez appliquer le modificateur override. Notez que ce scénario est en vigueur même lorsque vous « remplacez » un initialiseur par défaut fourni automatiquement (c’est-à-dire lorsque la superclasse ne définit aucun initialiseur explicite. Dans ce cas, Swift en fournit un implicitement. Les développeurs Java doivent connaître ce comportement). Cet initialiseur par défaut fourni automatiquement est toujours un initialiseur désigné.
Un initialiseur désigné qui correspond à un initialiseur de commodité de superclasse
Supposons maintenant que vous souhaitiez ajouter un initialiseur désigné à votre sous-classe qui correspond à un initialiseur de commodité dans le parent. Selon les règles de délégation de l’initialiseur énoncées dans le guide linguistique, votre initialiseur désigné de sous-classe doit déléguer jusqu’à un initialiseur désigné de la superclasse immédiate. Autrement dit, vous ne pouvez pas déléguer à l’initialiseur de commodité correspondant du parent. Pour des raisons d’argument, supposons également que la sous-classe ne se qualifie pas pour hériter des initialiseurs de superclasse. Ensuite, comme vous ne pourriez jamais créer une instance de votre sous-classe en invoquant directement l’initialiseur de commodité de superclasse, cet initialiseur de commodité correspondant n’est pas, et ne pourrait jamais être, impliqué dans le processus d’initialisation de toute façon. Par conséquent, vous ne le remplacez pas vraiment et le modificateur de remplacement ne s’applique pas.
Un initialiseur pratique qui correspond à un initialiseur désigné par superclasse
Dans ce scénario, imaginez que vous ayez une sous-classe qui ajoute ses propres propriétés dont la valeur par défaut peut être (mais n’a pas à être) calculée à partir des valeurs attribuées à une ou plusieurs propriétés de classe parente. Supposons que vous souhaitiez également n’avoir qu’un initialiseur désigné pour votre sous-classe. Vous pouvez ajouter un initialiseur pratique à votre sous-classe dont la signature correspond à celle d’un initialiseur désigné de la classe parente. Dans ce cas, votre nouvel initialiseur aurait besoin à la fois des modificateurs de commodité et de substitution. Voici un exemple de code valide pour illustrer ce cas :
Un initialiseur de commodité qui correspond à un initialiseur de commodité de superclasse
Si vous souhaitez ajouter un initialiseur de commodité à votre sous-classe qui correspond à la signature d’un initialiseur de commodité de votre superclasse, allez-y. Comme je l’ai expliqué ci-dessus, vous ne pouvez pas vraiment remplacer les initialiseurs de commodité de toute façon. Vous inclurez donc le modificateur de commodité, mais omettez le modificateur de remplacement et traitez-le comme n’importe quel autre initialiseur de commodité.
Un élément clé à retenir de cette section est que le modificateur override n’est utilisé et doit l’être que si vous remplacez un initialiseur désigné par superclasse. (Clarification mineure à apporter ici: si vous remplacez un initialiseur requis, vous utiliserez le modificateur requis au lieu du modificateur de remplacement. Le modificateur requis implique le modificateur override. Voir la section Initialiseurs requis ci-dessous).
Lorsque les Initialiseurs Sont Hérités
Maintenant pour les scénarios susmentionnés où les initialiseurs de superclasse sont hérités. Comme l’explique le Guide du langage Swift, si votre sous-classe fournit des valeurs par défaut pour toutes ses propres propriétés lors de la déclaration et ne définit aucun de ses propres initialiseurs désignés, elle héritera automatiquement de tous ses initialiseurs désignés par la superclasse. Ou, si votre sous-classe fournit une implémentation de tous les initialiseurs désignés par la superclasse, elle hérite automatiquement de tous les initialiseurs de commodité de la superclasse. Ceci est cohérent avec la règle de Swift selon laquelle l’initialisation des classes (et des structures) ne doit pas laisser les propriétés stockées dans un état indéterminé.
Je suis tombé sur un comportement « intéressant » en expérimentant avec les initialiseurs de commodité, les initialiseurs désignés et les règles d’héritage. J’ai trouvé qu’il est possible de créer un cercle vicieux par inadvertance. Prenons l’exemple suivant :
La classe RecipeIngredient remplace tous les initialiseurs désignés par la classe alimentaire et hérite donc automatiquement de tous les initialiseurs de commodité de la superclasse. Mais l’initialiseur de commodité alimentaire délègue raisonnablement à son propre initialiseur désigné, qui a été remplacé par la sous-classe RecipeIngredient. Ce n’est donc pas la version Alimentaire de cet initialiseur init (name:String) qui est invoquée, mais la version remplacée dans RecipeIngredient. La version remplacée tire parti du fait que la sous–classe a hérité de l’initialiseur de commodité de la nourriture, et voilà – vous avez un cycle. Je ne sais pas si cela serait considéré comme une erreur de programmeur ou un bogue de compilateur (je l’ai signalé comme un bogue: https://bugs.swift.org/browse/SR-512). Imaginez que Food est une classe d’une tierce partie et que vous n’avez pas accès au code source, vous ne savez donc pas comment il est réellement implémenté. Vous ne sauriez pas (jusqu’à l’exécution) que l’utiliser de la manière indiquée dans cet exemple vous piégerait dans un cycle. Donc je pense que ce serait mieux si le compilateur nous aidait ici.
Initialiseurs échouables
Imaginez que vous avez conçu une classe qui a certains invariants et que vous souhaitez appliquer ces invariants à partir du moment où une instance de la classe est créée. Par exemple, vous modélisez peut-être une facture et vous voulez vous assurer que le montant est toujours non négatif. Si vous avez ajouté un initialiseur qui prend un argument amount de type Double, comment pourriez-vous vous assurer que vous ne violez pas votre invariant? Une stratégie consiste simplement à vérifier si l’argument n’est pas négatif. Si c’est le cas, utilisez-le. Sinon, la valeur par défaut est 0. Par exemple :
Cela fonctionnerait et pourrait être acceptable si vous documentez ce que fait votre initialiseur (surtout si vous prévoyez de mettre votre classe à la disposition d’autres développeurs). Mais vous pourriez avoir du mal à défendre cette stratégie, car elle balaie en quelque sorte le problème sous le tapis. Une autre approche prise en charge par Swift serait de laisser l’initialisation échouer. Autrement dit, vous rendriez votre initialiseur défaillant.
Comme le décrit cet article, des initialiseurs défaillants ont été ajoutés à Swift afin d’éliminer ou de réduire le besoin de méthodes d’usine, « qui étaient auparavant le seul moyen de signaler un échec » lors de la construction de l’objet. Pour faire échouer un initialiseur, vous ajoutez simplement le ? ou ! caractère après le mot-clé init (c’est-à-dire init? ou init! ). Ensuite, une fois que toutes les propriétés ont été définies et que toutes les autres règles concernant la délégation ont été satisfaites, vous ajouteriez une logique pour vérifier que les arguments sont valides. S’ils ne sont pas valides, vous déclenchez un échec d’initialisation avec un retour nul. Notez que cela n’implique pas que l’initialiseur retourne jamais quoi que ce soit. Voici à quoi pourrait ressembler notre classe de facture avec un initialiseur défaillant:
Remarquez quelque chose de différent sur la façon dont nous utilisons le résultat de la création de l’objet? C’est comme si nous le traitions comme une option, n’est-ce pas? Eh bien, c’est exactement ce que nous faisons! Lorsque nous utilisons un initialiseur défaillant, nous obtenons soit nil (si un échec d’initialisation a été déclenché), soit une option (Facture). Autrement dit, si l’initialisation a réussi, nous nous retrouverons avec une option qui enveloppe l’instance de facture qui nous intéresse, nous devons donc la dérouler. (En aparté, notez que Java a également des options à partir de Java 8).
Les initialiseurs à échec sont comme les autres types d’initialiseurs dont nous avons discuté en ce qui concerne la substitution et la délégation, la commodité vs désignée, etc. En fait, vous pouvez même remplacer un initialiseur à échec par un initialiseur non disponible. Vous ne pouvez cependant pas remplacer un initialiseur non disponible par un initialiseur échouable.
Vous avez peut-être remarqué des initialiseurs échouables en traitant UIView ou UIViewController, qui fournissent tous deux une initialisation d’initialiseur échouable ?(codeur aDecoder: NSCoder). Cet initialiseur est appelé lorsque votre View ou ViewController est chargé à partir d’une plume. Il est important de comprendre comment fonctionnent les initialiseurs défaillants. Je vous recommande fortement de lire la section Initialiseurs échouables du guide de langage Swift pour une explication approfondie.
Initialiseurs requis
Le modificateur requis est utilisé pour indiquer que toutes les sous-classes doivent implémenter l’initialiseur affecté. À première vue, cela semble assez simple et direct. Cela peut parfois devenir un peu déroutant si vous ne comprenez pas comment les règles concernant l’héritage de l’initialiseur discutées ci-dessus entrent en jeu. Si une sous-classe répond aux critères selon lesquels les initialiseurs de superclasse sont hérités, l’ensemble des initialiseurs hérités inclut ceux marqués requis. Par conséquent, la sous-classe satisfait implicitement au contrat imposé par le modificateur requis. Il implémente le ou les initialiseurs requis, vous ne le voyez tout simplement pas dans le code source.
Si une sous-classe fournit une implémentation explicite (c’est-à-dire non héritée) d’un initialiseur requis, elle remplace également l’implémentation de la superclasse. Le modificateur requis implique un remplacement, donc le modificateur de remplacement n’est pas utilisé. Vous pouvez l’inclure si vous le souhaitez, mais cela serait redondant et XCode vous en parlera.
Le guide de langage Swift ne dit pas grand-chose sur le modificateur requis, j’ai donc préparé un exemple de code (voir ci-dessous) avec des commentaires pour expliquer son objectif et décrire son fonctionnement. Pour plus d’informations, consultez cet article d’Anthony Levings.
Cas particulier: Extension de UIView
L’une des choses qui m’a incité à approfondir les initialiseurs Swift a été ma tentative de trouver un moyen de créer un ensemble d’initialiseurs désignés sans dupliquer la logique d’initialisation. Par exemple, je travaillais sur ce tutoriel UIView de Ray Wenderlich, convertissant son code Objective-C en Swift au fur et à mesure (vous pouvez jeter un œil à ma version Swift ici). Si vous regardez ce tutoriel, vous verrez que sa sous-classe RateView d’UIView a une méthode baseInit que les deux initialiseurs désignés utilisent pour effectuer des tâches d’initialisation communes. Cela semble être une bonne approche pour moi – vous ne voulez pas dupliquer ce genre de choses dans chacun de ces initialiseurs. Je voulais recréer cette technique dans ma version Swift de RateView. Mais j’ai trouvé cela difficile car un initialiseur désigné ne peut pas déléguer à un autre initialiseur de la même classe et ne peut pas appeler de méthodes de sa propre classe avant de déléguer à l’initialiseur de superclasse. À ce stade, il est trop tard pour définir des propriétés constantes. Bien sûr, vous pouvez contourner cette limitation en n’utilisant pas de constantes, mais ce n’est pas une bonne solution. J’ai donc pensé qu’il était préférable de simplement fournir des valeurs par défaut pour les propriétés stockées où elles sont déclarées. C’est toujours la meilleure solution que je connaisse actuellement. Cependant, j’ai trouvé une technique alternative qui utilise des initialiseurs.
Regardez l’exemple suivant. Ceci est un extrait de RateViewWithConvenienceInits.swift, qui est une version alternative de mon portage Swift de RateView. Étant une sous-classe d’UIView qui ne fournit pas de valeurs par défaut pour toutes ses propres propriétés stockées lors de la déclaration, cette version alternative de RateView doit au moins fournir une implémentation explicite de l’initialisation requise d’UIView ?(codeur aDecoder: NSCoder) initialiseur. Nous voudrons également fournir une implémentation explicite de l’init (frame: CGRect) initialiseur pour s’assurer que le processus d’initialisation est cohérent. Nous voulons que nos propriétés stockées soient configurées de la même manière quel que soit l’initialiseur utilisé.
Notez que j’ai ajouté le modificateur de commodité aux versions remplacées des initialiseurs d’UIView. J’ai également ajouté un initialiseur désigné échouable à la sous-classe, auquel les deux initialiseurs remplacés (commodité) délèguent. Cet initialiseur désigné unique se charge de configurer toutes les propriétés stockées (y compris les constantes – je n’ai pas eu à utiliser var pour tout). Ça marche, mais je pense que c’est assez compliqué. Je préférerais simplement fournir des valeurs par défaut pour mes propriétés stockées où elles sont déclarées, mais il est bon de savoir que cette option existe si nécessaire.