Warum wurde es nicht zu einem gängigen Muster, Setter im Konstruktor zu verwenden?

23

Accessoren und Modifikatoren (auch Setter und Getter genannt) sind aus drei Hauptgründen nützlich:

  1. Sie schränken den Zugriff auf die Variablen ein.
    • Zum Beispiel kann auf eine Variable zugegriffen, aber nicht geändert werden.
  2. Sie validieren die Parameter.
  3. Sie können einige Nebenwirkungen verursachen.

Universitäten, Online-Kurse, Tutorials, Blog-Artikel und Codebeispiele im Internet betonen die Wichtigkeit der Zugriffs- und Modifizierungsfunktionen. Sie fühlen sich heutzutage fast wie ein "Muss" für den Code an. Man kann sie also auch dann finden, wenn sie keinen zusätzlichen Wert liefern, wie im folgenden Code.

public class Cat {
    private int age;

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

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

Es ist jedoch üblich, weitere nützliche Modifikatoren zu finden, die die Parameter tatsächlich validieren und eine Ausnahme auslösen oder einen Booleschen Wert zurückgeben, wenn ungültige Eingaben eingegeben wurden.

/**
 * 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;
}

Aber selbst dann sehe ich fast nie, dass die Modifikatoren von einem Konstruktor aufgerufen wurden. Das häufigste Beispiel für eine einfache Klasse, mit der ich konfrontiert bin, ist das folgende:

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;
    }

}

Aber man würde denken, dass dieser zweite Ansatz viel sicherer ist:

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;
    }

}

Sehen Sie ein ähnliches Muster in Ihrer Erfahrung oder bin es nur ich, der Pech hat? Und wenn ja, was ist Ihrer Meinung nach der Grund dafür? Gibt es einen offensichtlichen Nachteil bei der Verwendung von Modifikatoren der Konstrukteure oder werden sie nur als sicherer angesehen? Ist es etwas anderes

Vlad Spreys
quelle
1
@stg, danke, interessanter Punkt! Könnten Sie es erweitern und es mit einem Beispiel für eine schlechte Sache beantworten, die vielleicht passieren würde?
Vlad Spreys
8
@stg Tatsächlich ist es ein Anti-Pattern, überschreibbare Methoden im Konstruktor zu verwenden, und viele Tools für die Codequalität weisen darauf hin, dass dies ein Fehler ist. Sie wissen nicht, was die überschriebene Version bewirken wird, und es kann böse Dinge bewirken, wie "dies" zu entkommen, bevor der Konstruktor fertig ist, was zu allen möglichen seltsamen Problemen führen kann.
Michał Kosmulski
1
@gnat: Ich glaube nicht, dass dies ein Duplikat von Sollte die Methode einer Klasse ihre eigenen Getter und Setter nennen? Aufgrund der Art der Konstruktoraufrufe, die für ein Objekt aufgerufen werden, das nicht vollständig gebildet ist.
Greg Burghardt
3
Beachten Sie auch, dass Ihre "Validierung" nur das Alter uninitialisiert lässt (oder Null oder ... etwas anderes, abhängig von der Sprache). Wenn der Konstruktor fehlschlagen soll, müssen Sie den Rückgabewert überprüfen und bei einem Fehler eine Ausnahme auslösen. So wie es aussieht, hat Ihre Validierung gerade einen anderen Fehler ausgelöst.
Nutzlos

Antworten:

37

Sehr allgemeines philosophisches Denken

In der Regel bitten wir einen Konstruktor (als Nachbedingung) um einige Garantien für den Zustand des konstruierten Objekts.

In der Regel gehen wir auch davon aus, dass Instanzmethoden (als Vorbedingungen) davon ausgehen können, dass diese Garantien bereits gelten, wenn sie aufgerufen werden, und dass sie nur sicherstellen müssen, dass sie nicht zerstört werden.

Das Aufrufen einer Instanzmethode aus dem Konstruktor heraus bedeutet, dass einige oder alle dieser Garantien möglicherweise noch nicht festgelegt wurden. Daher ist es schwierig zu beurteilen, ob die Voraussetzungen für die Instanzmethode erfüllt sind. Selbst wenn Sie es richtig machen, kann es sehr zerbrechlich sein, z. Neuanordnen von Instanzmethodenaufrufen oder anderen Vorgängen.

Sprachen unterscheiden sich auch darin, wie sie Aufrufe an Instanzmethoden auflösen, die von Basisklassen geerbt / von Unterklassen überschrieben werden, während der Konstruktor noch ausgeführt wird. Dies fügt eine weitere Komplexitätsebene hinzu.

Spezifische Beispiele

  1. Ihr eigenes Beispiel dafür, wie dies Ihrer Meinung nach aussehen sollte, ist an sich falsch:

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

    Dies prüft nicht den Rückgabewert von setAge . Offensichtlich ist ein Anruf beim Einrichter doch keine Garantie für die Richtigkeit.

  2. Sehr einfache Fehler, wie abhängig von der Initialisierungsreihenfolge, wie zum Beispiel:

    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;
      }
    };

    wo meine vorübergehende Protokollierung alles brach. Hoppla!

Es gibt auch Sprachen wie C ++, in denen das Aufrufen eines Setters aus dem Konstruktor eine verschwendete Standardinitialisierung bedeutet (was zumindest für einige Mitgliedsvariablen zu vermeiden ist).

Ein einfacher Vorschlag

Es ist wahr, dass der meiste Code nicht so geschrieben ist, aber wenn Sie Ihren Konstruktor sauber und vorhersehbar halten und trotzdem Ihre Vor- und Nachbedingungslogik wiederverwenden möchten, ist die bessere Lösung:

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;
  }
};

oder noch besser, wenn möglich: Codieren Sie die Einschränkung im Eigenschaftstyp und lassen Sie sie ihren eigenen Wert bei der Zuweisung validieren:

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

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

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

Und schließlich der Vollständigkeit halber ein selbstvalidierender Wrapper in C ++. Beachten Sie, dass die Überprüfung relativ einfach ist , obwohl sie immer noch mühsam ist, da diese Klasse nichts anderes tut

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, es ist nicht wirklich vollständig, ich habe verschiedene Kopier- und Verschiebungskonstruktoren und -zuweisungen weggelassen.

Nutzlos
quelle
4
Stark unterschätzte Antwort! Lassen Sie mich hinzufügen, nur weil das OP die beschriebene Situation in einem Lehrbuch gesehen hat, ist es keine gute Lösung. Wenn es in einer Klasse zwei Möglichkeiten gibt, ein Attribut festzulegen (nach Konstruktor und Setter) und eine Einschränkung für dieses Attribut erzwungen werden soll, müssen alle beiden Möglichkeiten die Einschränkung überprüfen. Andernfalls handelt es sich um einen Konstruktionsfehler .
Doc Brown
Würden Sie also in Betracht ziehen, Setter-Methoden in einer schlechten Konstruktorpraxis zu verwenden, wenn 1) die Setter keine Logik enthalten oder 2) die Setter-Logik unabhängig vom Status ist? Ich mache das nicht oft, aber ich persönlich habe Setter-Methoden in einem Konstruktor genau aus dem letzteren Grund verwendet (wenn es eine Bedingung in Setter gibt, die immer für die Member-Variable gelten muss
Chris Maggiulli
Wenn es eine Bedingung gibt, die immer zutreffen muss, ist dies die Logik im Setter. Ich denke sicherlich, es ist besser, wenn möglich , diese Logik im Member zu codieren, so dass es gezwungen ist, in sich geschlossen zu sein, und keine Abhängigkeiten vom Rest der Klasse erlangen kann.
Useless
13

Wenn ein Objekt konstruiert wird, ist es per Definition nicht vollständig ausgebildet. Überlegen Sie, wie der von Ihnen angegebene Setter:

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;
}

Was wäre, wenn ein Teil der Validierung setAge()eine Überprüfung der ageImmobilie beinhalten würde, um sicherzustellen, dass das Objekt nur im Alter zunehmen könnte? Diese Bedingung könnte folgendermaßen aussehen:

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

Das scheint eine ziemlich unschuldige Veränderung zu sein, da Katzen sich nur in einer Richtung durch die Zeit bewegen können. Das einzige Problem ist, dass Sie damit nicht den Anfangswert von festlegen können, this.ageda dies vorausgesetzt wirdthis.age bereits ein gültiger Wert vorliegt.

Das Vermeiden von Setzern in Konstruktoren macht deutlich, dass der Konstruktor nur die Instanzvariable setzt und jedes andere Verhalten, das im Setzer auftritt, überspringt.

Caleb
quelle
OK ... aber wenn Sie die Objektkonstruktion nicht tatsächlich testen und die potenziellen Fehler aufdecken, gibt es in beiden Fällen potenzielle Fehler . Und jetzt haben Sie zwei Probleme: Sie haben diesen versteckten potenziellen Fehler und müssen an zwei Stellen Altersgruppenüberprüfungen durchführen.
Svidgen
Dies ist kein Problem, da in Java / C # alle Felder vor dem Konstruktoraufruf auf Null gesetzt werden.
Deduplizierer
2
Ja, das Aufrufen von Instanzmethoden von Konstruktoren kann schwierig sein und wird im Allgemeinen nicht bevorzugt. Wenn Sie nun Ihre Validierungslogik in eine private, endgültige Klasse / statische Methode verschieben und sie sowohl vom Konstruktor als auch vom Setter aus aufrufen möchten, ist das in Ordnung.
Nutzlos
1
Nein, Nicht-Instanz-Methoden vermeiden die Schwierigkeit von Instanz-Methoden, da sie eine teilweise konstruierte Instanz nicht berühren. Natürlich müssen Sie das Ergebnis der Validierung noch überprüfen, um zu entscheiden, ob die Erstellung erfolgreich war.
Nutzlos
1
Diese Antwort ist meiner Meinung nach der springende Punkt. "Wenn ein Objekt konstruiert wird, ist es per Definition nicht vollständig ausgebildet" - natürlich mitten im Konstruktionsprozess, aber wenn der Konstruktor fertig ist, müssen alle beabsichtigten Einschränkungen für Attribute gelten, andernfalls kann man diese Einschränkung umgehen. Ich würde das einen schwerwiegenden Konstruktionsfehler nennen.
Doc Brown
6

Einige gute Antworten hier bereits, aber bisher schien niemand bemerkt zu haben, dass Sie hier zwei Dinge in einem fragen, was einige Verwirrung stiftet. IMHO wird Ihr Beitrag am besten als zwei separate Fragen gesehen:

  1. Wenn eine Einschränkung in einem Setter validiert wird, sollte sie sich nicht auch im Konstruktor der Klasse befinden, um sicherzustellen, dass sie nicht umgangen werden kann?

  2. warum nicht den setter dazu direkt vom konstruktor aus aufrufen?

Die Antwort auf die ersten Fragen lautet eindeutig "Ja". Ein Konstruktor sollte, wenn er keine Ausnahme auslöst, das Objekt in einem gültigen Zustand belassen, und wenn bestimmte Werte für bestimmte Attribute verboten sind, ist es absolut sinnlos, den ctor dies umgehen zu lassen.

Die Antwort auf die zweite Frage lautet jedoch in der Regel "nein", solange Sie keine Maßnahmen ergreifen, um zu vermeiden, dass der Setter in einer abgeleiteten Klasse überschrieben wird. Daher besteht die bessere Alternative darin, die Gültigkeitsprüfung für Einschränkungen in einer privaten Methode zu implementieren, die sowohl vom Setter als auch vom Konstruktor aus wiederverwendet werden kann. Ich werde hier nicht das Beispiel @Useless wiederholen, das genau diesen Entwurf zeigt.

Doc Brown
quelle
Ich verstehe immer noch nicht, warum es besser ist, Logik aus dem Setter zu extrahieren, damit der Konstruktor sie verwenden kann. ... Wie ist das wirklich anders als einfach die Setter in einer vernünftigen Reihenfolge anzurufen? Insbesondere wenn man bedenkt, dass, wenn Ihre Setter so einfach sind, wie sie "sein sollten", der einzige Teil des Setters, den Ihr Konstruktor zu diesem Zeitpunkt nicht aufruft, die tatsächliche Einstellung des zugrunde liegenden Felds ist ...
svidgen
2
@svidgen: siehe JimmyJames Beispiel: Kurz gesagt, es ist keine gute Idee, überschreibbare Methoden in einem ctor zu verwenden, die Probleme verursachen. Sie können den Setter in ctor verwenden, wenn er final ist und keine anderen überschreibbaren Methoden aufruft. Darüber hinaus bietet die Trennung der Validierung von der Art und Weise, wie sie behandelt werden soll, wenn sie fehlschlägt, die Möglichkeit, sie im ctor und im setter unterschiedlich zu behandeln.
Doc Brown
Nun, ich bin jetzt noch weniger überzeugt! Aber danke, dass Sie mich auf die andere Antwort
hingewiesen
2
Mit in einer vernünftigen Reihenfolge meinen Sie eine spröde, unsichtbare Ordnungsabhängigkeit, die leicht durch unschuldig aussehende Änderungen gebrochen werden kann , oder?
Useless
@useless gut, nein ... Ich meine, es ist lustig, dass Sie die Reihenfolge vorschlagen, in der Ihr Code ausgeführt wird, sollte keine Rolle spielen. Aber das war nicht wirklich der Punkt. Abgesehen von den erfundenen Beispielen kann ich mich nicht an einen Fall erinnern, in dem ich es für eine gute Idee gehalten habe, in einem Setter immobilienübergreifende Validierungen durchzuführen - so etwas führt zwangsläufig zu "unsichtbaren" Anforderungen an die Aufrufreihenfolge . Unabhängig davon, ob diese
Aufrufe
2

Hier ist ein blöder Java-Code, der die Art von Problemen demonstriert, mit denen Sie in Ihrem Konstruktor mit nicht endgültigen Methoden konfrontiert werden können:

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;
  }
}

Wenn ich dies ausführe, erhalte ich eine NPE im NextProblem-Konstruktor. Dies ist natürlich ein triviales Beispiel, aber es kann schnell kompliziert werden, wenn Sie mehrere Vererbungsebenen haben.

Ich denke, der größere Grund, warum dies nicht üblich wurde, ist, dass es den Code ein bisschen schwieriger macht, ihn zu verstehen. Persönlich habe ich fast nie Setter-Methoden und meine Mitgliedsvariablen sind fast immer (Wortspiel beabsichtigt) endgültig. Aus diesem Grund müssen die Werte im Konstruktor festgelegt werden. Wenn Sie unveränderliche Objekte verwenden (und es gibt viele gute Gründe dafür), ist die Frage umstritten.

In jedem Fall ist es ein gutes Ziel, die Validierung oder andere Logik wiederzuverwenden, und Sie können sie in eine statische Methode einfügen und sie sowohl vom Konstruktor als auch vom Setter aus aufrufen.

JimmyJames
quelle
Wie ist das überhaupt ein Problem der Verwendung von Setzern im Konstruktor anstelle eines Problems der schlecht implementierten Vererbung? Mit anderen Worten, wenn Sie den Basiskonstruktor effektiv ungültig machen, warum sollten Sie ihn dann überhaupt aufrufen?
Svidgen
1
Ich denke, der Punkt ist, dass das Überschreiben eines Setters den Konstruktor nicht ungültig machen sollte.
Chris Wohlert
@ChrisWohlert Ok ... aber wenn Sie Ihre Setterlogik so ändern, dass sie mit Ihrer Konstruktorlogik in Konflikt steht, ist dies ein Problem, unabhängig davon, ob Ihr Konstruktor den Setter verwendet. Entweder scheitert es zur Konstruktionszeit, es scheitert später oder es scheitert unsichtbar (b / c Sie haben Ihre Geschäftsregeln umgangen).
Svidgen
Sie wissen oft nicht, wie der Konstruktor der Basisklasse implementiert ist (möglicherweise befindet er sich irgendwo in einer Bibliothek eines Drittanbieters). Das Überschreiben einer überschreibbaren Methode sollte jedoch nicht den Vertrag der Basisimplementierung unterbrechen (und dies wird in diesem Beispiel möglicherweise dadurch erreicht, dass das nameFeld nicht gesetzt wird).
Hulk
@svidgen Das Problem hierbei ist, dass das Aufrufen überschreibbarer Methoden aus dem Konstruktor die Nützlichkeit des Überschreibens verringert. Sie können in den überschriebenen Versionen keine Felder der untergeordneten Klasse verwenden, da sie möglicherweise noch nicht initialisiert wurden. Schlimmer noch, Sie dürfen eine this-Referenz nicht an eine andere Methode übergeben, da Sie noch nicht sicher sind, ob sie vollständig initialisiert ist.
Hulk
1

Es hängt davon ab, ob!

Wenn Sie eine einfache Überprüfung durchführen oder im Setter unerwünschte Nebenwirkungen auslösen, die bei jedem Festlegen der Eigenschaft auftreten müssen: Verwenden Sie den Setter. Die Alternative besteht im Allgemeinen darin, die Setterlogik aus dem Setter zu extrahieren und die neue Methode aufzurufen, gefolgt von der tatsächlichen Menge sowohl vom Setter als auch vom Setter vom Konstruktor. (Nur dass Sie jetzt Ihren zugegebenermaßen kleinen Setter mit zwei Zeilen an zwei Stellen warten.)

Allerdings , wenn Sie komplexe Operationen ausführen , um das Objekt anzuordnen , bevor eine bestimmte Setter (oder zwei!) Konnte erfolgreich ausgeführt werden , nicht die Setter verwenden.

Und in jedem Fall Testfälle haben . Unabhängig davon, auf welcher Route Sie sich befinden, haben Sie bei Unit-Tests eine gute Chance, die mit beiden Optionen verbundenen Fallstricke aufzudecken.


Ich füge hinzu, wenn mein Gedächtnis von damals auch nur annähernd korrekt ist, ist dies die Art von Argumentation, die mir auch am College beigebracht wurde. Und es steht in keiner Weise im Widerspruch zu erfolgreichen Projekten, an denen ich arbeiten durfte.

Wenn ich raten müsste, hätte ich irgendwann ein Memo mit "sauberem Code" verpasst, in dem einige typische Fehler identifiziert wurden, die bei der Verwendung von Setzern in einem Konstruktor auftreten können. Aber ich für meinen Teil bin noch keinem solchen Fehler zum Opfer gefallen ...

Ich würde also argumentieren, dass diese spezielle Entscheidung nicht pauschal und dogmatisch sein muss.

Svidgen
quelle