[SWIFT] La généricité et les boilerplates qu'elle implique.
Salut à tous. Une de mes passions dans la vie est d'écrire du code généraliste et en faire des APIs que j'utilise après dans mes projets. Ce qui est super là-dedans c'est la généricité parce qu'elle permet de couvrir tout un tas de cas de figure avec un code unique. Mais pas tout le temps...
Récemment j'ai écrit un @propertyWrapper
pour lier une propriété d'instance avec une entrée dans les UserDefaults
. Il est un peu fouillé et ajoute le support des enum
RawRepresentable
et des objets Codable
. Je vous raconte ça parce que je me suis retrouvé à écrire et dupliquer beaucoup de code à cause de la généricité qui pourtant apporte elle-même la solution.
Par exemple
Si on prend ce code :
struct Wrapper<T> {
var value: Int = 1
init(wrapped: T) {
self.value += self.increment()
}
func increment() -> Int { 1 }
}
On est devant une structure générique et relativement inutile qui contient une valeur incrémentée automatiquement.
Le côté générique est intéressant parce qu'il permet de faire varier l'incrément selon le type d'objet passé à l'initialisation. Par exemple si on passe une valeur numérique l'incrément est de 2 :
extension Wrapper where T: Strideable {
func increment() -> Int { 2 }
}
Maintenant si on teste tout ça on peut partir de ce postulat :
Wrapper(wrapped: "Hello").value == 2
Wrapper(wrapped: 42).value == 3
Mais en fait non tous deux renvoient 2. Dans la mesure où l'init
est appelé dans un contexte où T == Any
c'est la méthode increment
qui renvoie 1 qui est appelée. Pour palier à ça il faut un init
qui sera appelé dans le bon contexte et modifier notre extension :
extension Wrapper where T: Strideable {
init(wrapped: T) {
self.value += self.increment()
}
func increment() -> Int { 2 }
}
Maintenant notre postulat de départ est vérifié mais on a écrit le code deux fois.
Écrire le code deux fois n'est pas dramatique en soit sauf que :
- Quand on doit dupliquer tout une charrette de méthodes le code devient vite long.
- Il faut faire attention à bien répercuter les modifications faites sur une méthode dans toutes ses copies.
Un cas pratique parmi beaucoup d'autres
J'ai des structures de données de type array 2D et 3D que j'ai codées pour mes besoins. Ces structures étant de taille fixe il vaut mieux correctement initialiser l'espace mémoire soit en donnant une valeur par défaut soit en ayant des types qui offrent directement une valeur par défaut. Typiquement Bool
qui aura false
comme valeur par défaut et tout ce qui est ExpressibleByNilLiteral
—les Optionals quoi—, dont la valeur par défaut est nil
. En prime j'ai aussi un protocole Zeroable
qui déclare un var zero: Self
, je pense que vous avez compris son utilité.
J'ai quasiment 300 lignes en rab', identiques par paquet de trois, juste pour qu'à la toute fin la bonne méthode soit appelée en ayant baladé le contexte générique tout au long du chemin. ~300 lignes qu'il faut maintenir bien entendu.
Le cas épineux de @propertyWrapper
Si on reprend le code d'avant, qu'on le simplifie et qu'on le dynamise un peu on peut avoir une chose telle :
struct Wrapper<T> {
var value: Int { 1 + self.increment() }
init(wrapped: T) { }
func increment() -> Int { 1 }
}
L'init
ne sert à rien (on pourrait stocker wrapped
pour la forme mais la RAM coûte cher sur un Mac...) mais comme il n'appelle rien qui ait besoin d'un contexte générique il n'est pas utile de le dupliquer cette fois-ci. Par contre la propriété value
, elle, oui. Ce qui donne cette extension :
extension Wrapper where T: Strideable {
var value: Int { 1 + self.increment() }
func increment() -> Int { 2 }
}
Et là tout fonctionne correctement. (Oui ça marche aussi avec les propriétés, ça m'a scié aussi au début)
Maintenant testons avec @propertyWrapper
en utilisant ce qui marchait bien jusqu'à présent:
@propertyWrapper
struct Wrapper<T> {
var wrappedValue: Int { 1 + self.increment() }
init(wrapped: T) { }
func increment() -> Int { 1 }
}
extension Wrapper where T: Strideable {
var wrappedValue: Int { 1 + self.increment() }
func increment() -> Int { 2 }
}
Le problème
Testons maintenant le code avec un cas de test simple et un postulat de résultat:
public struct Wrapped {
@Wrapper(wrapped: "Hello")
var anyValue: Int
@Wrapper(wrapped: 42)
var strideableValue: Int
}
let w = Wrapped()
w.anyValue == 2
w.strideableValue == 3
Ben non ! Bug ou intention la propriété wrappedValue
n'est pas appelée dynamiquement selon le contexte générique, ce qui est très embêtant, très... Heureusement que Swift est un langage plein de resources...
Une solution
Une solution ici est de tout capturer une bonne fois pour toute dans une closure quand on a chopé le bon contexte, le la sorte :
@propertyWrapper
struct Wrapper<T> {
private var resultProvider: () -> Int = { 1 }
var wrappedValue: Int { self.resultProvider() }
init(wrapped: T) {
self.resultProvider = { [self] in 1 + self.increment() }
}
func increment() -> Int { 1 }
}
extension Wrapper where T: Strideable {
init(wrapped: T) {
self.resultProvider = { [self] in 1 + self.increment() }
}
func increment() -> Int { 2 }
}
Là ça fonctionne selon le postulat de départ mais c'est l'expression même de ce que les anglo-saxons nomment shenanigans. Je n'ai pas fait de test plus poussés mais cette capture de self
sans qu'elle ne puisse être ni unowned
ni weak
me fait dire qu'un leak est possible.
Bref notre cas de test passe mais la solution est dégueulasse et reste à n'être employée que dans des cas où on est sûr qu'on ne fera pas plus de mal que de bien. Comme par exemple un cas qui ne capture pas self
, ça serait déjà un bon début.
Conclusion
À la base j'avais surtout envie de partager tout ça avec vous parce que j'ai beaucoup travaillé dessus ces derniers temps et qu'il me paraissait opportun de formaliser tout ça. Ouais, j'ai peut être un peu utilisé le forum comme un blog sur le coup-là (si ça me reprends j'ouvre mon blog, promis).
J'aimerai bien ouvrir une discussion cependant en vous demandant comment vous gérez ces cas où la généricité est à la fois le seul recours pour régler une problématique tout en étant aussi une punition. Et aussi est-ce que vous avez une autre solution pour le cas du @propertyWrapper
?
Merci à ceux qui sont arrivés jusque là 😃
Réponses
C'est le seul truc que j'ai compris .. SceneKit ou une autre API graphique 3D ?
J'ai peur que tu n'aies pas compris 😧 Je parle de structures de données de type tableau à 2 (matrice) ou 3 (cube) dimensions pour éviter d'avoir à utiliser
[[Element]]
ou[[[Element]]]
.Je suis un marteau, pour moi tout ce qui ressemble (même vaguement) à un clou est un clou !
Il y a un truc qui me chipote dans ta solution: tu ne profites pas vraiment du compilateur pour repérer des erreurs potentielles. Renomme ta fonction
increment()
dans l'extension, et ton code fonctionnera différemment sans qu'il n'y ait d'avertissement.Passer par un protocole pour spécialiser le comportement ne serait pas plus élégant (et explicite)?
Sinon, chouette initiative que de lancer des discussions avancées!
J'avoue ne pas comprendre ce que tu veux dire ici. J'ai relu le code et je ne vois pas où j'aurai pu faire une erreur..
Là pour le coup je ne trouve pas ça très pratique, pour commencer ta solution part du principe qu'on connait à l'avance toutes les extensions de
Wrapped
. Même si c'est vrai ici une fois bundlé dans une API réutilisable c'est foutu. Ce qui fait quevar wrappedValue: Int { self.value + ((self as? Incrementable)?.increment() ?? 1) }
est sûr de ne jamais finir dans du code productif chez moi. Si je ne peux pas rajouter d'extension à un@propertyWrapper
qui vient d'une lib ça m'intéresse moins.Je ne suis pas fan non plus de la disparition de
increment
quandT == Any
. Là c'est1
donc c'est facile mais si tu as quelque chose de plus complexe avec un calcul ou autre c'est plus compliqué et ça nécessite une méthode. D'autant que plus tu ajoute de spécialisation par protocole plus tu rends la syntaxe dewrappedValue
compliquée.J'ai un peu amélioré le principe en utilisant des méthodes statiques qui font que
self
n'est plus requis pour les utiliser:On peut imaginer passer des variables d'instance en paramètre si besoin mais on ne couvre pas tous les cas de figure, une solution encore imparfaite.
C'est un peu l'idée, je trouve que ça manque un peu d'autant que je n'ai plus de collègues pour parler code maintenant...
Je vais préparer en petit post sur mon implémentation du
@propertyWrapper
qui facilite lesUserDefaults
, qui est à l'origine de ce thread. Et je bloguerai en anglais je pense, sauf si je fais EN-FR c'est plus naturel pour moi d'écrire en français et ensuite de traduire.Ton code ne contient pas d'erreur en soi, je me faisais cette réflexion car il n'y a pas de contrôle niveau compilation sur l'égalité des méthodes utilisées.
Prends l'exemple ci-dessous:
Il manque le "n" de increment dans l'extension (par erreur, ou parce que ça a été mal recopié,..). Il n'y a pas le moindre avertissement au moment de la compilation (ni à l'exécution), mais tous les
increment()
dans l'extension vont se référer à l'implémentation dustruct
original plutôt qu'à celui de l'extension. Ça me semble contraire à l'esprit de Swift, avec un typage fort pour que ce genre de problème soit détecté à la compilation plutôt qu'à l'exécution.N'est-ce pas aussi le cas de ta proposition?
Chez moi non plus (le rôle d'une extension pour moi est de compléter, pas de spécialiser). C'était une proposition sur base de ce que j'avais compris du problème, mais visiblement je l'avais mal compris. Ce que j'avais compris est que tu voulais pouvoir spécialiser la méthode
increment()
dans les extensions, limiter les redondances et qu'il soit possible d'utiliser un propertyWrapper (c'est un peu le problème avec les exemples ultra-simplifiées, le lecteur n'a pas toujours une idée claire de ce qui est désiré). D'où la suggestion de passer par un protocole pour marquer explicitement les contraintes. Mais c'était dans tous les cas un mauvaise suggestion: on ne peut avoir qu'une seule extension qui déclare la conformité à un protocole (même si tu limites la portée de l'extension avec des conditions).Du coup j'ai du mal à comprendre où tu veux en venir avec la fonction
increment()
. Tu as au final deux fonctionsincrement()
qui sont différentes, et chaque implémentation utilise la version de son espace de définition (et si tu utilises ce code dans une librairie,increment()
sera marqué implicitement commeinternal
, donc inaccessible aux utilisateurs de la libraire). Vis à vis dupropertyWrapper
, tout repose au final sur le bloc passé àresultProvider
(qui accessoirement estprivate
, donc impossible à redéfinir si l'extension est dans un autre fichier). Tu pourrais renommer la fonctionincrement()
de l'extension, ou marquer celle deWrapper
commeprivate
, le résultat serait le même, l'ambiguïté en moins. Ou alors il y a quelque chose qui m'échappe?Bon j'ai voulu faire un petit projet pour illustrer mon propos. Le projet n'est plus si petit et ça m'a pris plus de temps que prévu entre autres choses : je l'ai appelé DefaultsWrapper et c'est sur GitHub.
Bref le soucis que j'ai eu est dans le fichier Default.swift j'ai du créer 7
init
différents. Je te laisse regarder on en reparle @RenaudBeau travail, et bien documenté. Pas grand chose à dire: les points que j'avais évoqués ne sont plus vraiment d'application puisque la liste des types supportés est limitée (et ce n'est pas un mal).
Sinon, pourquoi est-ce un problème d'avoir eu à créer 7
init
différents? Si c'est ce qu'il faut pour couvrir les cas de figure que tu veux couvrir, c'est bon non? Parfois à vouloir faire trop succinct, le code peut devenir indigeste et il faut passer plus de temps à comprendre quand on retombe dessus (ou qu'on découvre le code pour essayer de le comprendre).Un petit détail: pour les conversions vers les types
CoreGraphics
,NSCoder
fournit une série de fonctions pour convertir ces types de et versNSString
(pour CGPoint:NSCoder.cgPoint(for: "{0,0}"
/NSCoder.string(for: CGPoint.zero
). Si tu ne connaissais pas, je ne dirais pas que c'est 'mieux' que ce que tu as fait: le surplus de code peut être compensé par l'absence d'ambiguïté (en utilisantNSCoder
,CGPoint
etCGSize
sont par exemple encodés de la même manière —"{0,0}"
). Je ne sais pas si beaucoup de monde utilise ces fonctions pour coder des typesCG
dans les préférences: l'argument de l'intégration à un code existant est donc discutable, mais peut être évoqué