À quel moment les classes immuables deviennent-elles un fardeau?

16

Lors de la conception de classes pour contenir votre modèle de données, j'ai lu qu'il peut être utile de créer des objets immuables, mais à quel moment la charge des listes de paramètres de constructeur et des copies complètes devient-elle trop lourde et vous devez abandonner la restriction immuable?

Par exemple, voici une classe immuable pour représenter une chose nommée (j'utilise la syntaxe C # mais le principe s'applique à tous les langages OO)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

Les choses nommées peuvent être construites, interrogées et copiées dans de nouvelles choses nommées mais le nom ne peut pas être changé.

Tout cela est bien, mais que se passe-t-il lorsque je veux ajouter un autre attribut? Je dois ajouter un paramètre au constructeur et mettre à jour le constructeur de copie; ce qui n'est pas trop de travail mais les problèmes commencent, autant que je sache, quand je veux rendre immuable un objet complexe .

Si la classe contient de nombreux attributs et collections, contenant d'autres classes complexes, il me semble que la liste des paramètres du constructeur deviendrait un cauchemar.

Alors à quel moment une classe devient-elle trop complexe pour être immuable?

Tony
la source
Je fais toujours un effort pour rendre les cours de mon modèle immuables. Si vous avez d'énormes et longues listes de paramètres de constructeur, alors votre classe est peut-être trop grande et elle peut être divisée? Si vos objets de niveau inférieur sont également immuables et suivent le même modèle, vos objets de niveau supérieur ne devraient pas en souffrir (trop). Je trouve BEAUCOUP plus difficile de changer une classe existante pour qu'elle devienne immuable que de rendre un modèle de données immuable quand je pars de zéro.
Personne
1
Vous pouvez regarder le modèle Builder suggéré dans cette question: stackoverflow.com/questions/1304154/…
Ant
Avez-vous consulté MemberwiseClone? Vous n'avez pas à mettre à jour le constructeur de copie pour chaque nouveau membre.
kevin cline
3
@Tony Si vos collections et tout ce qu'elles contiennent sont également immuables, vous n'avez pas besoin d'une copie complète, une copie superficielle suffit.
mjcopple
2
En passant, j'utilise couramment des champs "set-once" dans les classes où la classe doit être "assez" immuable, mais pas complètement. Je trouve que cela résout le problème des énormes constructeurs, mais fournit la plupart des avantages des classes immuables. (à savoir, votre code de classe interne n'a pas à vous soucier d'un changement de valeur)
Earlz

Réponses:

23

Quand ils deviennent un fardeau? Très rapidement (spécialement si la langue de votre choix ne fournit pas un support syntaxique suffisant pour l'immuabilité.)

L'immuabilité est vendue comme la solution miracle pour le dilemme multicœur et tout cela. Mais l'immuabilité dans la plupart des langages OO vous oblige à ajouter des artefacts et des pratiques artificielles dans votre modèle et votre processus. Pour chaque classe complexe immuable, vous devez avoir un constructeur également complexe (au moins en interne). Peu importe comment vous le concevez, il introduit toujours un couplage fort (nous avons donc mieux une bonne raison de les introduire.)

Il n'est pas nécessairement possible de tout modéliser dans de petites classes non complexes. Donc, pour les grandes classes et les structures, nous les partitionnons artificiellement - non pas parce que cela a du sens dans notre modèle de domaine, mais parce que nous devons gérer leur instanciation complexe et les constructeurs dans le code.

C'est encore pire quand les gens poussent trop loin l'idée d'immuabilité dans un langage à usage général comme Java ou C #, ce qui rend tout immuable. Ensuite, en conséquence, vous voyez des gens forcer des constructions d'expression s dans des langages qui ne prennent pas facilement en charge de telles choses.

L'ingénierie est l'acte de modéliser à travers des compromis et des compromis. Rendre tout immuable par édit parce que quelqu'un a lu que tout est immuable en langage fonctionnel X ou Y (un modèle de programmation complètement différent), ce n'est pas acceptable. Ce n'est pas une bonne ingénierie.

De petites choses, éventuellement unitaires, peuvent devenir immuables. Des choses plus complexes peuvent être rendues immuables quand cela a du sens . Mais l'immuabilité n'est pas une solution miracle. La capacité de réduire les bugs, d'augmenter l'évolutivité et les performances, ceux-ci ne sont pas la seule fonction de l'immuabilité. C'est une fonction de bonnes pratiques d'ingénierie . Après tout, les gens ont écrit de bons logiciels évolutifs sans immuabilité.

L'immuabilité devient un fardeau très rapidement (elle ajoute à la complexité accidentelle) si elle est effectuée sans raison, lorsqu'elle est effectuée en dehors de ce qu'elle a de sens dans le contexte d'un modèle de domaine.

Pour ma part, j'essaie de l'éviter (sauf si je travaille dans un langage de programmation avec un bon support syntaxique pour cela.)

luis.espinal
la source
6
Luis, avez-vous remarqué que les réponses bien écrites et pragmatiques, écrites avec une explication de principes d'ingénierie simples mais solides, ont tendance à ne pas obtenir autant de votes que celles utilisant des modes de codage de pointe? C'est une excellente, excellente réponse.
Huperniketes
3
Merci :) J'ai moi-même remarqué les tendances des représentants, mais ça va. Fad fanboys churn code que nous arriverons plus tard à réparer à de meilleurs taux horaires, hahah :) jk ...
luis.espinal
4
Balle en argent? non. Vaut le peu de maladresse en C # / Java (ce n'est pas vraiment si mal)? Absolument. De plus, le rôle du multicœur dans l'immuabilité est assez mineur ... le véritable avantage est la facilité de raisonnement.
Mauricio Scheffer
@Mauricio - si vous le dites (cette immuabilité en Java n'est pas si mauvaise). Ayant travaillé sur Java de 1998 à 2011, je vous prie de différer, il n'est pas trivial exempt dans des bases de code simples. Cependant, les gens ont des expériences différentes et je reconnais que mon POV n'est pas exempt de subjectivité. Désolé, je ne suis pas d'accord là-dessus. Je suis d'accord, cependant, avec la facilité de raisonnement étant la chose la plus importante en matière d'immuabilité.
luis.espinal
13

J'ai traversé une phase d'insistance pour que les cours soient immuables autant que possible. Avait des constructeurs pour à peu près tout, des tableaux immuables, etc., etc. J'ai trouvé la réponse à votre question simple: à quel moment les classes immuables deviennent-elles un fardeau? Très rapidement. Dès que vous voulez sérialiser quelque chose, vous devez pouvoir désérialiser, ce qui signifie qu'il doit être modifiable; dès que vous souhaitez utiliser un ORM, la plupart insistent pour que les propriétés soient mutables. Etc.

J'ai finalement remplacé cette politique par des interfaces immuables vers des objets mutables.

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

Maintenant, l'objet a de la flexibilité, mais vous pouvez toujours dire au code appelant qu'il ne doit pas modifier ces propriétés.

pdr
la source
8
Mis à part quelques petites chicanes, je conviens que les objets immuables peuvent devenir très rapidement une douleur dans le cul lors de la programmation dans un langage impératif, mais je ne suis pas vraiment sûr si une interface immuable résout vraiment le même problème, ou tout problème du tout. La principale raison d'utiliser un objet immuable est que vous pouvez le jeter n'importe où à tout moment et ne jamais avoir à vous soucier de la corruption de votre état par quelqu'un d'autre. Si l'objet sous-jacent est mutable, vous n'avez pas cette garantie, surtout si vous l'avez gardé mutable parce que diverses choses doivent le muter.
Aaronaught
1
@Aaronaught - Point intéressant. Je suppose que c'est une chose psychologique plus qu'une protection réelle. Cependant, votre dernière ligne a une fausse prémisse. La raison de le garder mutable est plus que diverses choses doivent l'instancier et se peupler par réflexion, pour ne pas muter une fois instancié.
pdr
1
@Aaronaught: La même manière IComparable<T>garantit que si X.CompareTo(Y)>0et Y.CompareTo(Z)>0, alors X.CompareTo(Z)>0. Les interfaces ont des contrats . Si le contrat pour IImmutableList<T>spécifie que les valeurs de tous les éléments et propriétés doivent être "figées" avant qu'une instance ne soit exposée au monde extérieur, toutes les implémentations légitimes le feront. Rien n'empêche une IComparableimplémentation de violer la transitivité, mais les implémentations qui le font sont illégitimes. Si un SortedDictionarydysfonctionnement lorsqu'il est donné un illégitime IComparable, ...
supercat
1
@Aaronaught: Pourquoi devrais-je croire que les implémentations de IReadOnlyList<T>seront immuables, étant donné que (1) aucune exigence de ce type n'est indiquée dans la documentation de l'interface, et (2) l'implémentation la plus courante List<T>, n'est-elle même pas en lecture seule ? Je ne sais pas très bien ce qui est ambigu dans mes termes: une collection est lisible si les données qu'elle contient peuvent être lues. Il est en lecture seule s'il peut promettre que les données contenues ne peuvent pas être modifiées à moins qu'une référence externe ne soit conservée par un code qui le changerait. Il est immuable s'il peut garantir qu'il ne peut pas être changé, point final.
supercat
1
@supercat: Incidemment, Microsoft est d'accord avec moi. Ils ont publié un package de collections immuables et remarquent qu'il s'agit de types concrets, car vous ne pouvez jamais garantir qu'une classe ou une interface abstraite est vraiment immuable.
Aaronaught
8

Je ne pense pas qu'il y ait de réponse générale à cela. Plus une classe est complexe, plus il est difficile de raisonner sur ses changements d'état et plus il est coûteux d'en créer de nouvelles copies. Ainsi, au-dessus d'un certain niveau (personnel) de complexité, il deviendra trop pénible de rendre / maintenir une classe immuable.

Notez qu'une classe trop complexe ou une longue liste de paramètres de méthode sont des odeurs de conception en soi, indépendamment de l'immuabilité.

Donc, généralement, la solution préférée serait de diviser une telle classe en plusieurs classes distinctes, chacune pouvant être rendue mutable ou immuable par elle-même. Si cela n'est pas possible, il peut être transformé en mutable.

Péter Török
la source
5

Vous pouvez éviter le problème de copie si vous stockez tous vos champs immuables dans un intérieur struct. Il s'agit essentiellement d'une variation du modèle de souvenir. Ensuite, lorsque vous voulez faire une copie, copiez simplement le souvenir:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}
Scott Whitlock
la source
Je pense que la structure intérieure n'est pas nécessaire. C'est juste une autre façon de faire MemberwiseClone.
Codisme
@Codism - oui et non. Il y a des moments où vous pourriez avoir besoin d'autres membres que vous ne voulez pas cloner. Et si vous utilisiez l'évaluation paresseuse dans l'un de vos getters et mettiez le résultat en cache dans un membre? Si vous effectuez un MemberwiseClone, vous clonerez la valeur mise en cache, puis vous modifierez l'un de vos membres dont dépend la valeur mise en cache. Il est plus propre de séparer l'état du cache.
Scott Whitlock
Il peut être utile de mentionner un autre avantage de la structure interne: elle permet à un objet de copier facilement son état vers un objet auquel d'autres références existent . Une source d'ambiguïté courante dans la POO est de savoir si une méthode qui renvoie une référence d'objet renvoie une vue d'un objet qui pourrait changer en dehors du contrôle du destinataire. Si au lieu de renvoyer une référence d'objet, une méthode accepte une référence d'objet de l'appelant et copie l'état dans celle-ci, la propriété de l'objet sera beaucoup plus claire. Une telle approche ne fonctionne pas bien avec les types librement héritables, mais ...
supercat
... il peut être très utile avec les détenteurs de données mutables. L'approche permet également d'avoir des classes mutables et immuables "parallèles" (dérivées d'une base abstraite "lisible") et de permettre à leurs constructeurs de copier les données les uns des autres.
supercat
3

Vous avez deux ou trois choses à l'œuvre ici. Les ensembles de données immuables sont parfaits pour une évolutivité multithread. Essentiellement, vous pouvez optimiser votre mémoire un peu afin qu'un ensemble de paramètres soit une instance de la classe - partout. Parce que les objets ne changent jamais, vous n'avez pas à vous soucier de la synchronisation autour de l'accès à ses membres. C'est une bonne chose. Cependant, comme vous le faites remarquer, plus l'objet est complexe, plus vous avez besoin d'une certaine mutabilité. Je commencerais par raisonner dans ce sens:

  • Y a-t-il une raison commerciale pour laquelle un objet peut changer son état? Par exemple, un objet utilisateur stocké dans une base de données est unique en fonction de son ID, mais il doit pouvoir changer d'état au fil du temps. D'un autre côté, lorsque vous modifiez des coordonnées sur une grille, elle cesse d'être la coordonnée d'origine et il est donc logique de rendre les coordonnées immuables. Pareil pour les cordes.
  • Certains attributs peuvent-ils être calculés? En bref, si les autres valeurs de la nouvelle copie d'un objet sont fonction d'une valeur de base que vous transmettez, vous pouvez les calculer dans le constructeur ou à la demande. Cela réduit la quantité de maintenance car vous pouvez initialiser ces valeurs de la même manière lors de la copie ou de la création.
  • Combien de valeurs composent le nouvel objet immuable? À un moment donné, la complexité de la création d'un objet devient non triviale et à ce stade, le fait d'avoir plus d'instances de l'objet peut devenir un problème. Les exemples incluent des structures arborescentes immuables, des objets avec plus de trois paramètres passés, etc. Plus il y a de paramètres, plus il y a de possibilité de perturber l'ordre des paramètres ou d'annuler le mauvais.

Dans les langues qui ne prennent en charge que les objets immuables (comme Erlang), s'il existe une opération qui semble modifier l'état d'un objet immuable, le résultat final est une nouvelle copie de l'objet avec la valeur mise à jour. Par exemple, lorsque vous ajoutez un élément à un vecteur / liste:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

Cela peut être une façon sensée de travailler avec des objets plus compliqués. Lorsque vous ajoutez un nœud d'arbre par exemple, le résultat est un nouvel arbre avec le nœud ajouté. La méthode de l'exemple ci-dessus renvoie une nouvelle liste. Dans l'exemple de ce paragraphe, le tree.add(newNode)retournerait une nouvelle arborescence avec le nœud ajouté. Pour les utilisateurs, il devient facile de travailler avec. Pour les rédacteurs de bibliothèque, cela devient fastidieux lorsque le langage ne prend pas en charge la copie implicite. Ce seuil dépend de votre propre patience. Pour les utilisateurs de votre bibliothèque, la limite la plus saine que j'ai trouvée est d'environ trois à quatre sommets de paramètres.

Berin Loritsch
la source
Si l'on est enclin à utiliser une référence d'objet mutable comme valeur [ce qui signifie qu'aucune référence n'est contenue dans son propriétaire et jamais exposée], la construction d'un nouvel objet immuable qui contient le contenu "modifié" souhaité équivaut à modifier directement l'objet, mais est probablement plus lent. Cependant, les objets mutables peuvent également être utilisés comme entités . Comment pourrait-on faire des choses qui se comportent comme des entités sans objets mutables?
supercat
0

Si vous avez plusieurs membres de la classe finale et que vous ne voulez pas qu'ils soient exposés à tous les objets qui doivent le créer, vous pouvez utiliser le modèle de générateur:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

l'avantage est que vous pouvez facilement créer un nouvel objet avec seulement une valeur différente d'un nom différent.

Salandur
la source
Je pense que votre constructeur étant statique n'est pas correct. Un autre thread peut changer le nom statique ou la valeur statique après les avoir définis, mais avant d'appeler newObject.
ErikE
Seule la classe de générateur est statique, mais pas ses membres. Cela signifie que pour chaque générateur que vous créez, ils ont leur propre ensemble de membres avec des valeurs correspondantes. La classe doit être statique pour pouvoir être utilisée et instanciée en dehors de la classe contenante ( NamedThingdans ce cas)
Salandur
Je vois ce que vous dites, j'envisage juste un problème avec cela car cela ne conduit pas un développeur à "tomber dans le gouffre du succès". Le fait qu'il utilise des variables statiques signifie que si a Builderest réutilisé, il y a un risque réel que la chose se produise que j'ai mentionnée. Quelqu'un pourrait construire beaucoup d'objets et décider que puisque la plupart des propriétés sont les mêmes, pour simplement réutiliser le Builder, et en fait, faisons-en un singleton global qui est injecté dans les dépendances! Oups. Bugs majeurs introduits. Je pense donc que ce modèle de mélange instancié et statique est mauvais.
ErikE
1
@Salandur Les classes internes en C # sont toujours "statiques" au sens de la classe interne Java.
Sebastian Redl
0

Alors à quel moment une classe devient-elle trop complexe pour être immuable?

À mon avis, cela ne vaut pas la peine de rendre les petites classes immuables dans des langues comme celle que vous montrez. J'utilise petit ici et pas complexe , car même si vous ajoutez dix champs à cette classe et qu'il y a vraiment des opérations fantaisistes sur eux, je doute que ça va prendre des kilo-octets et encore moins des mégaoctets et encore moins des gigaoctets, donc toute fonction utilisant des instances de votre La classe peut simplement faire une copie bon marché de l'objet entier pour éviter de modifier l'original si elle veut éviter de provoquer des effets secondaires externes.

Structures de données persistantes

Là où je trouve que l'utilisation personnelle de l'immuabilité est pour les grandes structures de données centrales qui regroupent un tas de données minuscules comme des instances de la classe que vous montrez, comme celle qui en stocke un million NamedThings. En appartenant à une structure de données persistante qui est immuable et en se trouvant derrière une interface qui ne permet qu'un accès en lecture seule, les éléments qui appartiennent au conteneur deviennent immuables sans que la classe d'élément ( NamedThing) n'ait à y faire face.

Copies bon marché

La structure de données persistante permet à certaines de ses régions d'être transformées et rendues uniques, en évitant les modifications de l'original sans avoir à copier la structure de données dans son intégralité. C'est ça la vraie beauté. Si vous vouliez écrire naïvement des fonctions qui évitent les effets secondaires qui entrent dans une structure de données qui prend des gigaoctets de mémoire et ne modifie que la valeur d'un mégaoctet de mémoire, alors vous devrez copier la totalité de la chose pour éviter de toucher l'entrée et renvoyer une nouvelle production. Il s'agit soit de copier des gigaoctets pour éviter les effets secondaires ou de provoquer des effets secondaires dans ce scénario, ce qui vous oblige à choisir entre deux choix désagréables.

Avec une structure de données persistante, il vous permet d'écrire une telle fonction et d'éviter de faire une copie de la structure de données entière, ne nécessitant environ un mégaoctet de mémoire supplémentaire pour la sortie que si votre fonction a seulement transformé la valeur de la mémoire d'un mégaoctet.

Fardeau

Quant au fardeau, il y en a un au moins dans mon cas. J'ai besoin de ces constructeurs dont les gens parlent ou de "transitoires" comme je les appelle pour pouvoir exprimer efficacement les transformations de cette structure de données massive sans y toucher. Code comme celui-ci:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

... doit alors être écrit comme ceci:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

Mais en échange de ces deux lignes de code supplémentaires, la fonction est désormais sûre d'appeler à travers des threads avec la même liste d'origine, elle ne provoque aucun effet secondaire, etc. Cela rend également très facile de faire de cette opération une action utilisateur annulable puisque le annuler peut simplement stocker une copie peu profonde bon marché de l'ancienne liste.

Sécurité d'exception ou récupération d'erreur

Tout le monde ne pourrait pas bénéficier autant que moi des structures de données persistantes dans des contextes comme ceux-ci (j'ai trouvé tellement d'utilité pour eux dans les systèmes d'annulation et l'édition non destructive qui sont des concepts centraux dans mon domaine VFX), mais une chose applicable à peu près tout le monde à considérer est la sécurité d'exception ou la récupération d'erreur .

Si vous souhaitez sécuriser la fonction de mutation d'origine sans exception, il faut une logique de restauration, pour laquelle la mise en œuvre la plus simple nécessite de copier la liste entière :

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

À ce stade, la version mutable sans exception est encore plus coûteuse en termes de calcul et sans doute encore plus difficile à écrire correctement que la version immuable utilisant un "générateur". Et beaucoup de développeurs C ++ négligent simplement la sécurité des exceptions et c'est peut-être bien pour leur domaine, mais dans mon cas, j'aime m'assurer que mon code fonctionne correctement même en cas d'exception (même en écrivant des tests qui lèvent délibérément des exceptions pour tester l'exception) sécurité), et cela fait que je dois pouvoir annuler tous les effets secondaires qu'une fonction provoque à mi-chemin de la fonction si quelque chose se déclenche.

Lorsque vous voulez être protégé contre les exceptions et récupérer des erreurs avec élégance sans que votre application ne se bloque et ne brûle, vous devez annuler / annuler les effets secondaires qu'une fonction peut provoquer en cas d'erreur / d'exception. Et là, le constructeur peut réellement économiser plus de temps programmeur qu'il n'en coûte avec du temps de calcul car: ...

Vous n'avez pas à vous soucier de faire reculer les effets secondaires dans une fonction qui n'en provoque pas!

Revenons donc à la question fondamentale:

À quel moment les classes immuables deviennent-elles un fardeau?

Ils sont toujours un fardeau dans les langages qui tournent plus autour de la mutabilité que de l'immuabilité, c'est pourquoi je pense que vous devriez les utiliser lorsque les avantages l'emportent largement sur les coûts. Mais à un niveau suffisamment large pour des structures de données suffisamment grandes, je pense qu'il existe de nombreux cas où c'est un compromis valable.

Toujours dans le mien, je n'ai que quelques types de données immuables, et ce sont toutes d'énormes structures de données destinées à stocker un nombre massif d'éléments (pixels d'une image / texture, entités et composants d'un ECS, et sommets / bords / polygones de un maillage).


la source