[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

  • J'ai des structures de données de type array 2D et 3D que j'ai codées pour mes besoins.

    C'est le seul truc que j'ai compris .. SceneKit ou une autre API graphique 3D ?

  • @Draken a dit :

    J'ai des structures de données de type array 2D et 3D que j'ai codées pour mes besoins.

    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 !

  • RenaudRenaud Membre
    janvier 2020 modifié #5

    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)?

    protocol Incrementable {
        func increment() -> Int
    }
    
    @propertyWrapper
    struct Wrapper<T> {
        var wrappedValue: Int { self.value + ((self as? Incrementable)?.increment() ?? 1) }
        var value: Int = 1
    
        init(wrapped: T) {}
    }
    
    extension Wrapper : Incrementable where T : Strideable {
        func increment() -> Int { 2 }
    }
    
    public struct Wrapped {
        @Wrapper(wrapped: "Hello")
        var anyValue: Int
    
        @Wrapper(wrapped: 42)
        var strideableValue: Int
    }
    
    let w = Wrapped()
    w.anyValue == 2
    w.strideableValue == 3
    
    Wrapper(wrapped: "Hello").wrappedValue == 2
    Wrapper(wrapped: 42).wrappedValue == 3
    

    Sinon, chouette initiative que de lancer des discussions avancées!

  • @Renaud a dit :
    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.

    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..

    Passer par un protocole pour spécialiser le comportement ne serait pas plus élégant (et explicite)?

    Code

    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 que var 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 quand T == Any. Là c'est 1 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 de wrappedValue 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:

    @propertyWrapper
    struct Wrapper<T> {
        private var resultProvider: () -> Int = { 1 }
        var wrappedValue: Int { self.resultProvider() }
    
        init(wrapped: T) {
            self.resultProvider = { 1 + Self.increment() }
        }
    
        static func increment() -> Int { 1 }
    }
    
    extension Wrapper where T: Strideable {
        init(wrapped: T) {
            self.resultProvider = { 1 + Self.increment() }
        }
    
        static func increment() -> Int { 2 }
    }
    
    public struct Wrapped {
        @Wrapper(wrapped: "Hello")
        var anyValue: Int
    
        @Wrapper(wrapped: 42)
        var strideableValue: Int
    }
    

    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.

    Sinon, chouette initiative que de lancer des discussions avancées!

    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 les UserDefaults, 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.

  • RenaudRenaud Membre
    janvier 2020 modifié #7

    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..

    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:

    @propertyWrapper
    struct Wrapper<T> {
        private var resultProvider: () -> Int = { 1 }
        var wrappedValue: Int { self.resultProvider() }
    
        init(wrapped: T) {
            self.resultProvider = { 1 + Self.increment() }
        }
    
        static func increment() -> Int { 1 }
    }
    
    extension Wrapper where T: Strideable {
        init(wrapped: T) {
            self.resultProvider = { 1 + Self.increment() }
        }
    
        static func incremet() -> Int { 2 }
    }
    

    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 du struct 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.

    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

    N'est-ce pas aussi le cas de ta proposition?

    Ce qui fait que var wrappedValue: Int { self.value + ((self as? Incrementable)?.increment() ?? 1) } est sûr de ne jamais finir dans du code productif chez moi.

    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 fonctions increment() 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 comme internal, donc inaccessible aux utilisateurs de la libraire). Vis à vis du propertyWrapper, tout repose au final sur le bloc passé à resultProvider (qui accessoirement est private, donc impossible à redéfinir si l'extension est dans un autre fichier). Tu pourrais renommer la fonction increment() de l'extension, ou marquer celle de Wrapper comme private, le résultat serait le même, l'ambiguïté en moins. Ou alors il y a quelque chose qui m'échappe?

  • PyrohPyroh Membre
    février 2020 modifié #8

    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 @Renaud

  • RenaudRenaud Membre
    février 2020 modifié #9

    Beau 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 vers NSString (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 utilisant NSCoder, CGPoint et CGSize 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 types CG dans les préférences: l'argument de l'intégration à un code existant est donc discutable, mais peut être évoqué ;)

Connectez-vous ou Inscrivez-vous pour répondre.