Construire de grands objets immuables sans utiliser de constructeurs ayant de longues listes de paramètres

96

J'ai de gros objets (plus de 3 champs) qui peuvent et doivent être immuables. Chaque fois que je rencontre ce cas, j'ai tendance à créer des abominations de constructeur avec de longues listes de paramètres.

Cela ne semble pas correct, il est difficile à utiliser et la lisibilité en souffre.

C'est encore pire si les champs sont une sorte de type de collection comme des listes. Un simple addSibling(S s)faciliterait tellement la création d'objet mais rendrait l'objet mutable.

Qu'est-ce que vous utilisez dans de tels cas?

Je suis sur Scala et Java, mais je pense que le problème est indépendant du langage tant que le langage est orienté objet.

Des solutions auxquelles je peux penser:

  1. "Abominations de constructeur avec de longues listes de paramètres"
  2. Le modèle du constructeur
Malax
la source
5
Je pense que le modèle de constructeur est la meilleure et la plus standard des solutions à cela.
Zachary Wright
@Zachary: Ce que vous préconisez ne fonctionne que pour une forme spéciale du modèle de constructeur, comme expliqué ici par Joshua Bloch: drdobbs.com/java/208403883?pgno=2 Je préfère utiliser le terme "interface fluide" pour appeler cette "forme spéciale du modèle de constructeur" (voir ma réponse).
SyntaxeT3rr0r

Réponses:

76

Eh bien, vous voulez à la fois un objet plus facile à lire et immuable une fois créé?

Je pense qu'une interface fluide CORRECTEMENT FAITE vous aiderait.

Cela ressemblerait à ceci (exemple purement inventé):

final Foo immutable = FooFactory.create()
    .whereRangeConstraintsAre(100,300)
    .withColor(Color.BLUE)
    .withArea(234)
    .withInterspacing(12)
    .build();

J'ai écrit «CORRECTEMENT FAIT» en gras parce que la plupart des programmeurs Java se trompent d'interface couramment et polluent leur objet avec la méthode nécessaire pour construire l'objet, ce qui est bien sûr complètement faux.

L'astuce est que seule la méthode build () crée réellement un Foo (donc vous Foo peut être immuable).

FooFactory.create () , whereXXX (..) et withXXX (..) créent tous «autre chose».

Ce quelque chose d'autre peut être une FooFactory, voici une façon de le faire ...

Votre FooFactory ressemblerait à ceci:

// Notice the private FooFactory constructor
private FooFactory() {
}

public static FooFactory create() {
    return new FooFactory();
}

public FooFactory withColor( final Color col ) {
    this.color = color;
    return this;
}

public Foo build() {
    return new FooImpl( color, and, all, the, other, parameters, go, here );
}
SyntaxeT3rr0r
la source
11
@ all: merci de ne pas vous plaindre du suffixe "Impl" dans "FooImpl": cette classe est cachée dans une fabrique et personne ne la verra jamais à part la personne qui écrit l'interface fluide. Tout ce qui intéresse l'utilisateur, c'est qu'il obtient un "Foo". J'aurais pu appeler "FooImpl" "FooPointlessNitpick" ainsi;)
SyntaxeT3rr0r
5
Vous vous sentez préventif? ;) Vous avez été pinaillé dans le passé à ce sujet. :)
Greg D
3
Je crois que l'erreur courante à laquelle il fait référence est que les gens ajoutent les méthodes "withXXX" (etc.) à l' Fooobjet, plutôt que d'en avoir une autre FooFactory.
Dean Harding
3
Vous avez toujours le FooImplconstructeur avec 8 paramètres. Quelle est l'amélioration?
Mot du
4
Je n'appellerais pas ce code immutableet j'aurais peur que les gens réutilisent des objets d'usine parce qu'ils pensent que c'est le cas. Je veux dire: FooFactory people = FooFactory.create().withType("person"); Foo women = people.withGender("female").build(); Foo talls = people.tallerThan("180m").build();tallsne contiendrait plus que des femmes. Cela ne devrait pas se produire avec une API immuable.
Thomas Ahle
60

Dans Scala 2.8, vous pouvez utiliser des paramètres nommés et par défaut ainsi que la copyméthode sur une classe de cas. Voici un exemple de code:

case class Person(name: String, age: Int, children: List[Person] = List()) {
  def addChild(p: Person) = copy(children = p :: this.children)
}

val parent = Person(name = "Bob", age = 55)
  .addChild(Person("Lisa", 23))
  .addChild(Person("Peter", 16))
Martin Odersky
la source
31
+1 pour avoir inventé le langage Scala. Ouais, c'est un abus du système de réputation mais ... aww ... j'aime tellement Scala que j'ai dû le faire. :)
Malax
1
Oh, mec ... je viens de répondre à quelque chose de presque identique! Eh bien, je suis en bonne compagnie. :-) Je me demande que je n'ai pas vu votre réponse avant, cependant ... <shrug>
Daniel C. Sobral
20

Eh bien, considérez ceci sur Scala 2.8:

case class Person(name: String, 
                  married: Boolean = false, 
                  espouse: Option[String] = None, 
                  children: Set[String] = Set.empty) {
  def marriedTo(whom: String) = this.copy(married = true, espouse = Some(whom))
  def addChild(whom: String) = this.copy(children = children + whom)
}

scala> Person("Joseph").marriedTo("Mary").addChild("Jesus")
res1: Person = Person(Joseph,true,Some(Mary),Set(Jesus))

Cela a son lot de problèmes, bien sûr. Par exemple, essayez de faire espouseet Option[Person], puis de marier deux personnes. Je ne peux pas penser à un moyen de résoudre cela sans recourir à un private varet / ou un privateconstructeur plus une usine.

Daniel C. Sobral
la source
11

Voici quelques autres options:

Option 1

Rendez l'implémentation elle-même mutable, mais séparez les interfaces auxquelles elle expose mutable et immuable. Ceci est tiré de la conception de la bibliothèque Swing.

public interface Foo {
  X getX();
  Y getY();
}

public interface MutableFoo extends Foo {
  void setX(X x);
  void setY(Y y);
}

public class FooImpl implements MutableFoo {...}

public SomeClassThatUsesFoo {
  public Foo makeFoo(...) {
    MutableFoo ret = new MutableFoo...
    ret.setX(...);
    ret.setY(...);
    return ret; // As Foo, not MutableFoo
  }
}

Option 2

Si votre application contient un ensemble important mais prédéfini d'objets immuables (par exemple, des objets de configuration), vous pouvez envisager d'utiliser le framework Spring .

Petites tables Bobby
la source
3
L'option 1 est intelligente (mais pas trop intelligente), donc je l'aime bien.
Timothy
4
Je l'ai fait plus tôt, mais à mon avis c'est loin d'être une bonne solution car l'objet est toujours mutable, seules les méthodes de mutation sont "cachées". Peut-être que je suis trop pointilleux sur ce sujet ...
Malax
une variante de l'option 1 consiste à avoir des classes séparées pour les variantes immuables et mutables. Cela offre une meilleure sécurité que l'approche d'interface. On peut soutenir que vous mentez au consommateur de l'API chaque fois que vous lui donnez un objet mutable nommé Immutable qui doit simplement être casté sur son interface mutable. L'approche de classe séparée nécessite des méthodes pour effectuer une conversion dans les deux sens. L'API JodaTime utilise ce modèle. Voir DateTime et MutableDateTime.
toolbear
6

Il est utile de se rappeler qu'il existe différents types d'immuabilité . Pour votre cas, je pense que l'immuabilité "popsicle" fonctionnera très bien:

Immuabilité popsicle: c'est ce que j'appelle fantasque un léger affaiblissement de l'immuabilité à écriture unique On pourrait imaginer un objet ou un champ qui est resté modifiable pendant un petit moment lors de son initialisation, puis s'est «figé» à jamais. Ce type d'immuabilité est particulièrement utile pour les objets immuables qui se référencent circulairement les uns les autres, ou les objets immuables qui ont été sérialisés sur le disque et lors de la désérialisation doivent être «fluides» jusqu'à ce que tout le processus de désérialisation soit terminé, auquel point tous les objets peuvent être congelé.

Donc, vous initialisez votre objet, puis définissez un indicateur de "gel" d'une sorte indiquant qu'il n'est plus accessible en écriture. De préférence, vous cacheriez la mutation derrière une fonction afin que la fonction soit toujours pure pour les clients consommant votre API.

Juliette
la source
1
Des votes négatifs? Quelqu'un veut-il laisser un commentaire sur pourquoi ce n'est pas une bonne solution?
Juliette
+1. Peut-être que quelqu'un a refusé cela parce que vous faites allusion à l'utilisation de clone()pour dériver de nouvelles instances.
finnw
Ou peut-être était-ce parce que cela pouvait compromettre la sécurité des threads en Java: java.sun.com/docs/books/jls/third_edition/html/memory.html#17.5
finnw
Un inconvénient est que cela oblige les futurs développeurs à faire attention à gérer l'indicateur «gel». Si une méthode mutatrice est ajoutée plus tard et oublie d'affirmer que la méthode n'est pas gelée, il pourrait y avoir des problèmes. De même, si un nouveau constructeur est écrit qui devrait, mais n'appelle pas, la freeze()méthode, les choses pourraient devenir laides.
stalepretzel
5

Vous pouvez également faire en sorte que les objets immuables exposent des méthodes qui ressemblent à des mutateurs (comme addSibling) mais les laisser renvoyer une nouvelle instance. C'est ce que font les collections immuables Scala.

L'inconvénient est que vous pouvez créer plus d'instances que nécessaire. Il n'est également applicable que lorsqu'il existe des configurations intermédiaires valides (comme certains nœuds sans frères, ce qui est correct dans la plupart des cas) à moins que vous ne souhaitiez pas gérer des objets partiellement construits.

Par exemple, une arête de graphe qui n'a pas encore de destination n'est pas encore une arête de graphe valide.

ziggystar
la source
L'inconvénient allégué - créer plus d'instances que nécessaire - n'est pas vraiment un problème. L'allocation d'objets est très bon marché, tout comme le garbage collection d'objets de courte durée. Lorsque l'analyse d'échappement est activée par défaut, ce type «d'objets intermédiaires» est susceptible d'être alloué par pile et ne coûte littéralement rien à créer.
gustafc
2
@gustafc: Oui. Cliff Click a raconté une fois comment ils ont comparé la simulation Clojure Ant Colony de Rich Hickey sur l'une de leurs grandes boîtes (864 cœurs, 768 Go de RAM): 700 threads parallèles fonctionnant sur 700 cœurs, chacun à 100%, générant plus de 20 Go de déchets éphémères par seconde . Le GC n'a même pas transpiré.
Jörg W Mittag
5

Considérez quatre possibilités:

new Immutable(one, fish, two, fish, red, fish, blue, fish); /*1 */

params = new ImmutableParameters(); /*2 */
params.setType("fowl");
new Immutable(params);

factory = new ImmutableFactory(); /*3 */
factory.setType("fish");
factory.getInstance();

Immutable boringImmutable = new Immutable(); /* 4 */
Immutable lessBoring = boringImmutable.setType("vegetable");

Pour moi, chacun des 2, 3 et 4 est adapté à une situation de différence. Le premier est difficile à aimer, pour les raisons citées par l'OP, et est généralement le symptôme d'un design qui a subi un certain fluage et a besoin d'être refactorisé.

Ce que j'énumère comme (2) est bon quand il n'y a pas d'état derrière «l'usine», alors que (3) est la conception de choix quand il y a un état. Je me retrouve à utiliser (2) plutôt que (3) lorsque je ne veux pas m'inquiéter des threads et de la synchronisation, et je n'ai pas à m'inquiéter d'amortir une configuration coûteuse sur la production de nombreux objets. (3), en revanche, est appelé quand un vrai travail entre dans la construction de l'usine (mise en place depuis un SPI, lecture des fichiers de configuration, etc.).

Enfin, la réponse de quelqu'un d'autre a mentionné l'option (4), où vous avez beaucoup de petits objets immuables et le modèle préférable est d'obtenir des nouveaux à partir d'anciens.

Notez que je ne suis pas membre du `` fan club de modèles '' - bien sûr, certaines choses valent la peine d'être imitées, mais il me semble qu'ils prennent une vie inutile une fois que les gens leur donnent des noms et des chapeaux amusants.

bmargulies
la source
6
Ceci est le modèle Builder (option 2)
Simon Nickerson
Ne serait-ce pas un objet d'usine («constructeur») qui émet ses objets immuables?
bmargulies
cette distinction semble assez sémantique. Comment le haricot est-il fabriqué? En quoi cela diffère-t-il d'un constructeur?
Carl
Vous ne voulez pas du package de conventions Java Bean pour un constructeur (ou pour bien d'autres choses).
Tom Hawtin - tackline
4

Une autre option potentielle consiste à refactoriser pour avoir moins de champs configurables. Si des groupes de champs ne fonctionnent (principalement) qu'entre eux, rassemblez-les dans leur propre petit objet immuable. Les constructeurs / générateurs de ce "petit" objet devraient être plus faciles à gérer, tout comme le constructeur / constructeur de ce "gros" objet.

Carl
la source
1
Remarque: le kilométrage pour cette réponse peut varier en fonction du problème, de la base de code et des compétences du développeur.
Carl
2

J'utilise C #, et ce sont mes approches. Considérer:

class Foo
{
    // private fields only to be written inside a constructor
    private readonly int i;
    private readonly string s;
    private readonly Bar b;

    // public getter properties
    public int I { get { return i; } }
    // etc.
}

Option 1. Constructeur avec paramètres optionnels

public Foo(int i = 0, string s = "bla", Bar b = null)
{
    this.i = i;
    this.s = s;
    this.b = b;
}

Utilisé comme par exemple new Foo(5, b: new Bar(whatever)). Pas pour les versions Java ou C # antérieures à 4.0. mais cela vaut la peine d'être montré, car c'est un exemple de la façon dont toutes les solutions ne sont pas indépendantes du langage.

Option 2. Constructeur prenant un seul objet paramètre

public Foo(FooParameters parameters)
{
    this.i = parameters.I;
    // etc.
}

class FooParameters
{
    // public properties with automatically generated private backing fields
    public int I { get; set; }
    public string S { get; set; }
    public Bar B { get; set; }

    // All properties are public, so we don't need a full constructor.
    // For convenience, you could include some commonly used initialization
    // patterns as additional constructors.
    public FooParameters() { }
}

Exemple d'utilisation:

FooParameters fp = new FooParameters();
fp.I = 5;
fp.S = "bla";
fp.B = new Bar();
Foo f = new Foo(fp);`

C # à partir de 3.0 rend cela plus élégant avec la syntaxe d'initialisation d'objet (sémantiquement équivalente à l'exemple précédent):

FooParameters fp = new FooParameters { I = 5, S = "bla", B = new Bar() };
Foo f = new Foo(fp);

Option 3:
Reconcevez votre classe pour ne pas avoir besoin d'un si grand nombre de paramètres. Vous pouvez diviser ses responsabilités en plusieurs classes. Ou transmettez les paramètres non pas au constructeur mais uniquement à des méthodes spécifiques, à la demande. Pas toujours viable, mais quand c'est le cas, ça vaut le coup.

Joren
la source