Pourquoi n'est-il pas devenu un modèle courant d'utiliser des setters dans le constructeur?

23

Les accesseurs et modificateurs (alias setters et getters) sont utiles pour trois raisons principales:

  1. Ils restreignent l'accès aux variables.
    • Par exemple, une variable est accessible, mais pas modifiée.
  2. Ils valident les paramètres.
  3. Ils peuvent provoquer certains effets secondaires.

Les universités, les cours en ligne, les didacticiels, les articles de blog et les exemples de code sur le Web soulignent tous l'importance des accesseurs et des modificateurs, ils se sentent presque comme un "must have" pour le code de nos jours. On peut donc les trouver même s'ils ne fournissent aucune valeur supplémentaire, comme le code ci-dessous.

public class Cat {
    private int age;

    public int getAge() {
        return this.age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Cela dit, il est très courant de trouver des modificateurs plus utiles, ceux qui valident réellement les paramètres et lèvent une exception ou retournent un booléen si une entrée non valide a été fournie, quelque chose comme ceci:

/**
 * Sets the age for the current cat
 * @param age an integer with the valid values between 0 and 25
 * @return true if value has been assigned and false if the parameter is invalid
 */
public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

Mais même alors, je ne vois presque jamais les modificateurs appelés depuis un constructeur, donc l'exemple le plus courant d'une classe simple avec laquelle je suis confronté est le suivant:

public class Cat {
    private int age;

    public Cat(int age) {
        this.age = age;
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

Mais on pourrait penser que cette deuxième approche est beaucoup plus sûre:

public class Cat {
    private int age;

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

Voyez-vous un schéma similaire dans votre expérience ou est-ce juste que je suis malchanceux? Et si vous le faites, alors qu'est-ce qui, selon vous, est à l'origine de cela? Y a-t-il un inconvénient évident à utiliser des modificateurs des constructeurs ou sont-ils simplement considérés comme plus sûrs? Est-ce autre chose?

Vlad Spreys
la source
1
@stg, merci, point intéressant! Pourriez-vous développer cela et le mettre dans une réponse avec un exemple d'une mauvaise chose qui pourrait arriver?
Vlad Spreys
8
@stg En fait, c'est un anti-modèle d'utiliser des méthodes remplaçables dans le constructeur et de nombreux outils de qualité de code le signaleront comme une erreur. Vous ne savez pas ce que la version surchargée fera et cela pourrait faire des choses désagréables comme laisser "ceci" s'échapper avant la fin du constructeur, ce qui peut conduire à toutes sortes de problèmes étranges.
Michał Kosmulski
1
@gnat: Je ne crois pas que ce soit un doublon de Est-ce que les méthodes d'une classe doivent appeler leurs propres getters et setters? , en raison de la nature des appels constructeurs invoqués sur un objet qui n'est pas complètement formé.
Greg Burghardt
3
Notez également que votre "validation" laisse simplement l'âge non initialisé (ou zéro, ou ... autre chose, selon la langue). Si vous voulez que le constructeur échoue, vous devez vérifier la valeur de retour et lever une exception en cas d'échec. En l'état, votre validation vient d'introduire un bug différent.
inutile

Réponses:

37

Raisonnement philosophique très général

Typiquement, nous demandons à un constructeur de fournir (comme post-conditions) quelques garanties sur l'état de l'objet construit.

En règle générale, nous nous attendons également à ce que les méthodes d'instance puissent supposer (comme conditions préalables) que ces garanties sont déjà valables lorsqu'elles sont appelées, et elles doivent seulement s'assurer de ne pas les rompre.

L'appel d'une méthode d'instance à l'intérieur du constructeur signifie que certaines ou toutes ces garanties peuvent ne pas encore avoir été établies, ce qui rend difficile de déterminer si les conditions préalables de la méthode d'instance sont remplies. Même si vous le faites bien, il peut être très fragile face, par exemple. réorganiser les appels de méthode d'instance ou d'autres opérations.

Les langages varient également dans la façon dont ils résolvent les appels aux méthodes d'instance héritées des classes de base / remplacées par les sous-classes, alors que le constructeur est toujours en cours d'exécution. Cela ajoute une autre couche de complexité.

Exemples spécifiques

  1. Votre propre exemple de la façon dont vous pensez que cela devrait ressembler est lui-même faux:

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    cela ne vérifie pas la valeur de retour de setAge. Apparemment, appeler le passeur n'est pas une garantie d'exactitude après tout.

  2. Erreurs très faciles comme en fonction de l'ordre d'initialisation, telles que:

    class Cat {
      private Logger m_log;
      private int m_age;
    
      public void setAge(int age) {
        // FIXME temporary debug logging
        m_log.write("=== DEBUG: setting age ===");
        m_age = age;
      }
    
      public Cat(int age, Logger log) {
        setAge(age);
        m_log = log;
      }
    };

    où mon enregistrement temporaire a tout cassé. Oups!

Il existe également des langages comme C ++ où l'appel d'un setter à partir du constructeur signifie une initialisation par défaut gaspillée (ce qui vaut au moins pour certaines variables membres)

Une proposition simple

Il est vrai que la plupart du code n'est pas écrit comme ça, mais si vous voulez garder votre constructeur propre et prévisible, tout en réutilisant votre logique de pré et post-condition, la meilleure solution est:

class Cat {
  private int m_age;
  private static void checkAge(int age) {
    if (age > 25) throw CatTooOldError(age);
  }

  public void setAge(int age) {
    checkAge(age);
    m_age = age;
  }

  public Cat(int age) {
    checkAge(age);
    m_age = age;
  }
};

ou encore mieux, si possible: encodez la contrainte dans le type de propriété, et faites-lui valider sa propre valeur lors de l'affectation:

class Cat {
  private Constrained<int, 25> m_age;

  public void setAge(int age) {
    m_age = age;
  }

  public Cat(int age) {
    m_age = age;
  }
};

Et enfin, pour une complétude complète, un wrapper auto-validant en C ++. Notez que bien qu'il fasse toujours la validation fastidieuse, car cette classe ne fait rien d'autre , il est relativement facile de vérifier

template <typename T, T MAX, T MIN=T{}>
class Constrained {
    T val_;
    static T validate(T v) {
        if (MIN <= v && v <= MAX) return v;
        throw std::runtime_error("oops");
    }
public:
    Constrained() : val_(MIN) {}
    explicit Constrained(T v) : val_(validate(v)) {}

    Constrained& operator= (T v) { val_ = validate(v); return *this; }
    operator T() { return val_; }
};

OK, ce n'est pas vraiment complet, j'ai laissé de côté divers constructeurs et affectations de copie et de déplacement.

Inutile
la source
4
Réponse largement sous-estimée! Permettez-moi d'ajouter simplement parce que le PO a vu la situation décrite dans un manuel ne fait pas une bonne solution. S'il y a deux façons dans une classe de définir un attribut (par constructeur et setter), et que l'on veut appliquer une contrainte sur cet attribut, toutes les deux doivent vérifier la contrainte, sinon c'est un bug de conception .
Doc Brown
Envisagez-vous donc d'utiliser les méthodes de définition dans une mauvaise pratique de constructeur s'il n'y a 1) aucune logique dans les paramètres, ou 2) la logique dans les paramètres est indépendante de l'état? Je ne le fais pas souvent mais j'ai personnellement utilisé des méthodes de définition dans un constructeur exactement pour cette dernière raison (quand il y a un certain type de condition dans la définition qui doit toujours s'appliquer à la variable membre
Chris Maggiulli
S'il y a un certain type de condition qui doit toujours appliquer, c'est logique dans le poseur. Je pense certainement qu'il vaut mieux si possible coder cette logique dans le membre, donc il est forcé d'être autonome et ne peut pas acquérir de dépendances sur le reste de la classe.
inutile
13

Lorsqu'un objet est en cours de construction, il n'est par définition pas entièrement formé. Considérez comment le setter que vous avez fourni:

public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

Et si une partie de la validation setAge()comprenait une vérification par rapport à la agepropriété pour s'assurer que l'objet ne pouvait que vieillir? Cette condition pourrait ressembler à:

if (age > this.age && age < 25) {
    //...

Cela semble être un changement assez innocent, car les chats ne peuvent se déplacer dans le temps que dans une seule direction. Le seul problème est que vous ne pouvez pas l'utiliser pour définir la valeur initiale de this.agecar il suppose quethis.age déjà une valeur valide.

Éviter les setters dans les constructeurs montre clairement que la seule chose que fait le constructeur est de définir la variable d'instance et d'ignorer tout autre comportement qui se produit dans le setter.

Caleb
la source
OK ... mais, si vous ne testez pas réellement la construction des objets et n'exposez pas les bogues potentiels, il y a des bogues potentiels dans les deux cas . Et maintenant, vous avez deux deux problèmes: vous avez ce bug potentiel caché et vous devez appliquer des vérifications de tranches d'âge à deux endroits.
svidgen
Il n'y a pas de problème, car en Java / C # tous les champs sont mis à zéro avant l'invocation du constructeur.
Déduplicateur
2
Oui, appeler des méthodes d'instance à partir de constructeurs peut être difficile à raisonner et n'est généralement pas préféré. Maintenant, si vous voulez déplacer votre logique de validation dans une méthode classe / statique finale et privée, et l'appeler depuis le constructeur et le setter, ce serait bien.
inutile
1
Non, les méthodes non-instance évitent la complexité des méthodes d'instance, car elles ne touchent pas une instance partiellement construite. Bien sûr, vous devez toujours vérifier le résultat de la validation pour décider si la construction a réussi.
inutile
1
Cette réponse est IMHO manque le point. "Lorsqu'un objet est en cours de construction, il n'est par définition pas entièrement formé" - au milieu du processus de construction, bien sûr, mais lorsque le constructeur est terminé, toutes les contraintes voulues sur les attributs doivent être respectées, sinon on peut contourner cette contrainte. J'appellerais cela un grave défaut de conception.
Doc Brown
6

Quelques bonnes réponses ici déjà, mais jusqu'à présent, personne ne semblait avoir remarqué que vous posiez ici deux choses en une, ce qui crée une certaine confusion. À mon humble avis, votre message est mieux vu comme deux questions distinctes :

  1. s'il y a une validation d'une contrainte dans un setter, ne devrait-elle pas aussi être dans le constructeur de la classe pour s'assurer qu'elle ne peut pas être contournée?

  2. pourquoi ne pas appeler directement le setter à cet effet depuis le constructeur?

La réponse aux premières questions est clairement "oui". Un constructeur, quand il ne lève pas d'exception, devrait laisser l'objet dans un état valide, et si certaines valeurs sont interdites pour certains attributs, alors cela n'a absolument aucun sens de laisser le ctor contourner cela.

Cependant, la réponse à la deuxième question est généralement «non», tant que vous ne prenez pas de mesures pour éviter de remplacer le setter dans une classe dérivée. Par conséquent, la meilleure alternative est d'implémenter la validation des contraintes dans une méthode privée qui peut être réutilisée à partir du setter ainsi que du constructeur. Je ne vais pas ici pour répéter l'exemple @Useless, qui montre exactement cette conception.

Doc Brown
la source
Je ne comprends toujours pas pourquoi il vaut mieux extraire la logique du setter pour que le constructeur puisse l'utiliser. ... En quoi est-ce vraiment différent de simplement appeler les colons dans un ordre raisonnable ?? Surtout si l'on considère que si vos setters sont aussi simples qu'ils "devraient" être, la seule partie du setter que votre constructeur, à ce stade,
n'invoque
2
@svidgen: voir l'exemple de JimmyJames: en bref, ce n'est pas une bonne idée d'utiliser des méthodes redéfinissables dans un ctor, cela causera des problèmes. Vous pouvez utiliser le setter dans ctor s'il est final et n'appelle aucune autre méthode remplaçable. De plus, séparer la validation de la façon de la gérer en cas d'échec vous offre la possibilité de la gérer différemment dans le ctor et dans le setter.
Doc Brown
Eh bien, je suis encore moins convaincu maintenant! Mais, merci de me pointer vers l'autre réponse ...
svidgen
2
Par dans un ordre raisonnable, vous voulez dire avec une dépendance de commande fragile et invisible facilement brisée par des changements d'apparence innocente , non?
Inutile
@ bien inutile, non ... Je veux dire, c'est drôle que vous suggériez que l'ordre dans lequel votre code s'exécute ne devrait pas avoir d'importance. mais ce n'était pas vraiment le problème. Au-delà des exemples artificiels, je ne me souviens tout simplement pas d'un cas dans mon expérience où j'ai pensé que c'était une bonne idée d'avoir des validations de propriétés croisées dans un setter - ce genre de chose conduit nécessairement à des exigences d'ordre d'invocation "invisibles" . Indépendamment du fait que ces invocations se produisent dans un constructeur ..
svidgen
2

Voici un peu stupide de code Java qui illustre les types de problèmes que vous pouvez rencontrer avec l'utilisation de méthodes non finales dans votre constructeur:

import java.util.regex.Pattern;

public class Problem
{
  public static final void main(String... args)
  {
    Problem problem = new Problem("John Smith");
    Problem next = new NextProblem("John Smith", " ");

    System.out.println(problem.getName());
    System.out.println(next.getName());
  }

  private String name;

  Problem(String name)
  {
    setName(name);
  }

  void setName(String name)
  {
    this.name = name;
  }

  String getName()
  {
    return name;
  }
}

class NextProblem extends Problem
{
  private String firstName;
  private String lastName;
  private final String delimiter;

  NextProblem(String name, String delimiter)
  {
    super(name);

    this.delimiter = delimiter;
  }

  void setName(String name)
  {
    String[] parts = name.split(Pattern.quote(delimiter));

    this.firstName = parts[0];
    this.lastName = parts[1];
  }

  String getName()
  {
    return firstName + " " + lastName;
  }
}

Lorsque j'exécute cela, j'obtiens un NPE dans le constructeur NextProblem. C'est un exemple trivial bien sûr, mais les choses peuvent se compliquer rapidement si vous avez plusieurs niveaux d'héritage.

Je pense que la principale raison pour laquelle cela n'est pas devenu courant est qu'il rend le code un peu plus difficile à comprendre. Personnellement, je n'ai presque jamais de méthodes de définition et mes variables membres sont presque toujours (jeu de mots) finales. Pour cette raison, les valeurs doivent être définies dans le constructeur. Si vous utilisez des objets immuables (et il existe de nombreuses bonnes raisons de le faire), la question est théorique.

Dans tous les cas, c'est un bon objectif de réutiliser la validation ou une autre logique et vous pouvez la mettre dans une méthode statique et l'invoquer à la fois du constructeur et du setter.

JimmyJames
la source
En quoi est-ce un problème d'utilisation de setters dans le constructeur au lieu d'un problème d'héritage mal implémenté ??? En d'autres termes, si vous invalidez effectivement le constructeur de base, pourquoi l'appelleriez-vous même!?
svidgen
1
Je pense que le fait est que remplacer un setter ne devrait pas invalider le constructeur.
Chris Wohlert
@ChrisWohlert Ok ... mais, si vous changez la logique de votre setter pour entrer en conflit avec la logique de votre constructeur, c'est un problème que votre constructeur utilise le setter. Soit il échoue au moment de la construction, il échoue plus tard, soit il échoue de manière invisible (car vous avez contourné vos règles métier).
svidgen
Souvent, vous ne savez pas comment le constructeur de la classe de base est implémenté (il peut être quelque part dans une bibliothèque tierce). Remplacer une méthode remplaçable ne devrait cependant pas rompre le contrat de l'implémentation de base (et sans doute cet exemple le fait en ne définissant pas le namechamp).
Hulk
@svidgen, le problème ici est que l'appel de méthodes remplaçables à partir du constructeur réduit l'utilité de les remplacer - vous ne pouvez pas utiliser les champs de la classe enfant dans les versions substituées car ils ne sont peut-être pas encore initialisés. Pire encore, vous ne devez pas transmettre de thisréférence à une autre méthode, car vous ne pouvez pas être sûr qu'elle soit complètement initialisée.
Hulk
1

Ça dépend!

Si vous effectuez une validation simple ou émettez des effets secondaires coquins dans le setter qui doivent être frappés à chaque fois que la propriété est définie: utilisez le setter. L'alternative consiste généralement à extraire la logique du setter du setter et à invoquer la nouvelle méthode suivie de l'ensemble réel à la fois du setter et du constructeur - ce qui est en fait la même chose que d'utiliser le setter! (Sauf que maintenant vous maintenez votre setter, certes petit, à deux lignes à deux endroits.)

Cependant , si vous devez effectuer des opérations complexes pour organiser l'objet avant qu'un setter particulier (ou deux!) Puisse s'exécuter avec succès, n'utilisez pas le setter.

Et dans les deux cas, ayez des cas de test . Quelle que soit la voie que vous empruntez, si vous avez des tests unitaires, vous aurez de bonnes chances d'exposer les pièges associés à l'une ou l'autre option.


J'ajouterai également, si ma mémoire de cette époque est même à distance exacte, c'est le genre de raisonnement qu'on m'a aussi enseigné à l'université. Et ce n'est pas du tout en décalage avec les projets réussis sur lesquels j'ai eu la chance de travailler.

Si je devais deviner, j'ai raté un mémo "code propre" à un moment donné qui a identifié certains bogues typiques pouvant survenir en utilisant des setters dans un constructeur. Mais, pour ma part, je n'ai pas été victime de tels bugs ...

Donc, je dirais que cette décision particulière n'a pas besoin d'être radicale et dogmatique.

svidgen
la source