Builder-Muster: Wann sollte ein Fehler auftreten?

45

Bei der Implementierung des Builder-Musters stelle ich häufig fest, wann das Erstellen fehlschlagen soll, und es gelingt mir sogar, alle paar Tage andere Standpunkte in dieser Angelegenheit zu vertreten.

Zunächst einige Erklärungen:

  • Mit frühzeitig fehlschlagen meine ich, dass das Erstellen eines Objekts fehlschlagen sollte, sobald ein ungültiger Parameter übergeben wird SomeObjectBuilder.
  • Mit Verspätung meine ich, dass das Erstellen eines Objekts nur bei dem build()Aufruf fehlschlagen kann, der implizit einen Konstruktor des zu erstellenden Objekts aufruft.

Dann einige Argumente:

  • Zu Gunsten eines verspäteten Scheiterns: Eine Builder-Klasse sollte nicht mehr als eine Klasse sein, die einfach Werte enthält. Darüber hinaus führt dies zu weniger Code-Duplikationen.
  • Für ein frühzeitiges Scheitern: Ein allgemeiner Ansatz bei der Softwareprogrammierung besteht darin, Probleme so früh wie möglich zu erkennen. Daher sollte die Builder-Klasse "Konstruktor", "Setter" und letztendlich die Build-Methode am logischsten überprüft werden.

Was ist die allgemeine Übereinstimmung darüber?

Skiwi
quelle
8
Ich sehe keinen Vorteil darin, zu spät zu scheitern. Was jemand sagt, dass eine Builder-Klasse "sollte", hat keinen Vorrang vor gutem Design, und das frühe Erkennen von Fehlern ist immer besser als das späte Erkennen von Fehlern.
Doval
3
Eine andere Möglichkeit, dies zu betrachten, besteht darin, dass der Builder möglicherweise nicht weiß, welche Daten gültig sind. In diesem Fall bedeutet ein frühzeitiges Fehlschlagen eher ein Fehlschlagen, sobald Sie wissen, dass ein Fehler vorliegt. Nicht früh versagt, wäre der Erbauer eine Rückkehr nullObjekt , wenn es ein Problem in build().
Chris
Wenn Sie keine Möglichkeit hinzufügen, eine Warnung auszugeben, und keine Möglichkeit zum Beheben von Problemen im Builder anbieten, ist es sinnlos, zu spät zu versagen.
Mark

Antworten:

34

Schauen wir uns die Optionen an, in denen wir den Validierungscode platzieren können:

  1. Innerhalb der Setter in Builder.
  2. Innerhalb der build()Methode.
  3. Innerhalb der erstellten Entität: Sie wird in der build()Methode aufgerufen , wenn die Entität erstellt wird.

Option 1 ermöglicht es uns, Probleme früher zu erkennen, aber es kann komplizierte Fälle geben, in denen wir Eingaben nur mit dem vollständigen Kontext validieren können, wodurch zumindest ein Teil der Validierung in der build()Methode durchgeführt wird. Daher führt die Auswahl von Option 1 zu inkonsistentem Code, wobei ein Teil der Validierung an einer Stelle und ein anderer Teil an einer anderen Stelle ausgeführt wird.

Option 2 ist nicht wesentlich schlechter als Option 1, da die Setter in Builder normalerweise direkt vor den build()Interfaces aufgerufen werden , insbesondere bei fließenden Interfaces. Somit ist es in den meisten Fällen immer noch möglich, ein Problem früh genug zu erkennen. Wenn der Builder jedoch nicht die einzige Möglichkeit ist, ein Objekt zu erstellen, wird der Validierungscode dupliziert, da Sie ihn überall dort benötigen, wo Sie ein Objekt erstellen. Die logischste Lösung besteht in diesem Fall darin, die Validierung so nah wie möglich am erstellten Objekt, dh innerhalb des Objekts, zu platzieren. Und das ist die Option 3 .

Unter SOLID-Gesichtspunkten verstößt das Versetzen der Validierung in Builder auch gegen SRP: Die Builder-Klasse ist bereits dafür verantwortlich, die Daten zu aggregieren, um ein Objekt zu erstellen. Bei der Validierung werden Verträge für den eigenen internen Status erstellt. Es ist eine neue Verantwortung, den Status eines anderen Objekts zu überprüfen.

Aus meiner Sicht ist es daher nicht nur besser, aus Designsicht spät zu scheitern, sondern auch besser, innerhalb der konstruierten Entität zu scheitern, als im Builder selbst.

UPD: Dieser Kommentar erinnerte mich an eine weitere Möglichkeit, wenn die Validierung im Builder (Option 1 oder 2) sinnvoll ist. Es ist sinnvoll, wenn der Builder eigene Verträge für die von ihm erstellten Objekte hat. Angenommen, wir haben einen Builder, der eine Zeichenfolge mit einem bestimmten Inhalt erstellt, z. B. eine Liste von Nummernbereichen 1-2,3-4,5-6. Dieser Builder hat möglicherweise eine Methode wie addRange(int min, int max). Die resultierende Zeichenfolge weiß nichts über diese Zahlen und sollte es auch nicht wissen müssen. Der Builder selbst definiert das Format der Zeichenfolge und die Einschränkungen für die Zahlen. Daher muss die Methode addRange(int,int)die eingegebenen Zahlen validieren und eine Ausnahme auslösen, wenn max kleiner als min ist.

Die allgemeine Regel ist jedoch, nur die vom Bauherrn selbst definierten Verträge zu validieren.

Ivan Gammel
quelle
Ich denke, dass es erwähnenswert ist, dass Option 1 zwar zu "inkonsistenten" Überprüfungszeiten führen kann, aber dennoch als konsistent angesehen werden kann, wenn alles "so früh wie möglich" ist. Es ist ein bisschen einfacher, "so früh wie möglich" eindeutiger zu machen, wenn stattdessen die Variante des Builders, der StepBuilder, verwendet wird.
Joshua Taylor
Handelt es sich um eine Verletzung von SOLID, wenn ein URI-Builder eine Ausnahme auslöst, wenn eine Nullzeichenfolge übergeben wird? Müll
Gusdor
@Gusdor ja, wenn es selbst eine Ausnahme auslöst. Aus Benutzersicht sehen jedoch alle Optionen so aus, als würde ein Builder eine Ausnahme auslösen.
Ivan Gammel
Warum also nicht ein validate () haben, das von build () aufgerufen wird? Auf diese Weise kommt es zu wenig Duplizierung, Konsistenz und keiner SRP-Verletzung. Sie können die Daten auch validieren, ohne zu versuchen, sie zu erstellen, und die Validierung steht kurz vor der Erstellung.
StellarVortex
@StellarVortex wird in diesem Fall zweimal überprüft - einmal in builder.build () und, wenn die Daten gültig sind und wir zum Konstruktor des Objekts übergehen, in diesem Konstruktor.
Ivan Gammel
34

Wenn Sie Java verwenden, beachten Sie die maßgeblichen und detaillierten Anleitungen von Joshua Bloch im Artikel Erstellen und Löschen von Java-Objekten (die fett gedruckte Schrift im folgenden Zitat stammt von mir):

Wie ein Konstruktor kann ein Builder seinen Parametern Invarianten auferlegen. Die Erstellungsmethode kann diese Invarianten überprüfen. Es ist wichtig, dass sie überprüft werden, nachdem die Parameter vom Builder in das Objekt kopiert wurden, und dass sie in den Objektfeldern und nicht in den Builder-Feldern (Element 39) überprüft werden . Wenn Invarianten verletzt werden, sollte die Erstellungsmethode einen IllegalStateException(Item 60) auslösen. Die Detailmethode der Ausnahme sollte angeben, gegen welche Invariante verstoßen wird (Punkt 63).

Eine andere Möglichkeit, Invarianten mit mehreren Parametern zu erzwingen, besteht darin, dass Setter-Methoden ganze Gruppen von Parametern verwenden, für die einige Invarianten gelten müssen. Wenn die Invariante nicht erfüllt ist, wirft die Setter-Methode ein IllegalArgumentException. Dies hat den Vorteil, dass der invariante Fehler sofort nach Übergabe der ungültigen Parameter erkannt wird, anstatt auf den Aufruf des Builds zu warten.

Beachten Sie , dass sich "Elemente" in obigem Zitat laut der Erläuterung des Editors zu diesem Artikel auf Regeln beziehen, die in Effective Java, Second Edition, vorgestellt werden .

Der Artikel erklärt nicht ausführlich, warum dies empfohlen wird, aber wenn Sie darüber nachdenken, sind die Gründe ziemlich offensichtlich. Ein allgemeiner Tipp zum Verständnis dieses Problems finden Sie direkt im Artikel, in der Erklärung, wie das Builder-Konzept mit dem des Konstruktors verbunden ist. Es wird erwartet, dass Klasseninvarianten im Konstruktor überprüft werden und nicht in einem anderen Code, der dem Aufruf vorangeht / ihn vorbereitet.

Um zu verstehen, warum das Prüfen von Invarianten vor dem Aufrufen eines Builds falsch ist, können Sie ein beliebtes Beispiel für CarBuilder betrachten . Builder-Methoden können in einer beliebigen Reihenfolge aufgerufen werden, und daher kann man nicht genau wissen, ob ein bestimmter Parameter bis zum Build gültig ist.

Wenn man bedenkt, dass ein Sportwagen nicht mehr als zwei Sitze haben kann, wie kann man dann wissen, ob setSeats(4)es in Ordnung ist oder nicht? Es ist nur am Aufbau, wenn man sicher wissen kann, ob setSportsCar()aufgerufen wurde oder nicht, was bedeutet, ob zu werfen TooManySeatsExceptionoder nicht.

Mücke
quelle
3
+1 für die Empfehlung, welche Ausnahmetypen zu werfen sind, genau das, wonach ich gesucht habe.
Xantix
Ich bin mir nicht sicher, ob ich die Alternative finde. Es scheint nur zu reden, wenn Invarianten nur in Gruppen validiert werden können. Der Builder akzeptiert einzelne Attribute, wenn keine anderen beteiligt sind, und nur Attributgruppen, wenn die Gruppe eine Invariante für sich hat. Sollte in diesem Fall das einzelne Attribut vor dem Build eine Ausnahme auslösen?
Didier A.
19

Ungültige Werte, die ungültig sind, weil sie nicht toleriert werden, sollten meiner Meinung nach sofort bekannt gegeben werden. Mit anderen Worten, wenn Sie nur positive Zahlen akzeptieren und eine negative Zahl übergeben wird, müssen Sie nicht warten, bis der build()Anruf eingeht. Ich würde dies nicht als die Art von Problemen betrachten, von denen Sie "erwarten", dass sie auftreten, da dies eine Grundvoraussetzung für den Aufruf der Methode ist. Mit anderen Worten, Sie sind wahrscheinlich nicht darauf angewiesen , dass bestimmte Parameter nicht eingestellt werden. Es ist wahrscheinlicher, dass Sie davon ausgehen, dass die Parameter korrekt sind, oder dass Sie selbst eine Überprüfung durchführen würden.

Bei komplizierteren Problemen, die nicht so einfach zu überprüfen sind, sollten Sie jedoch beim Anruf darauf aufmerksam machen build(). Ein gutes Beispiel hierfür ist die Verwendung der von Ihnen angegebenen Verbindungsinformationen, um eine Verbindung zu einer Datenbank herzustellen. In diesem Fall , während Sie technisch könnte für solche Bedingungen zu überprüfen, ist es nicht mehr intuitiv und es erschwert nur den Code. Aus meiner Sicht sind dies auch die Arten von Problemen, die tatsächlich auftreten können und die Sie erst dann wirklich antizipieren können, wenn Sie es versuchen. Es ist eine Art des Unterschiedes zwischen einem String mit einem regulären Ausdruck entsprechen , um zu sehen , ob es könnte als int analysiert werden und einfach versuchen , es zu analysieren, mögliche Behandlung von Ausnahmen , die als Folge auftreten können.

Ich mag es im Allgemeinen nicht, beim Festlegen von Parametern Ausnahmen auszulösen, da dies bedeutet, dass jede ausgelöste Ausnahme abgefangen werden muss build(). Aus diesem Grund bevorzuge ich die Verwendung von RuntimeException, da Fehler in den übergebenen Parametern im Allgemeinen nicht auftreten sollten.

Dies ist jedoch mehr als alles andere eine bewährte Methode. Ich hoffe das beantwortet deine Frage.

Neil
quelle
11

Soweit ich weiß, besteht die allgemeine Praxis (nicht sicher, ob Konsens besteht) darin, so früh wie möglich einen Fehler zu entdecken. Dies macht es auch schwieriger, Ihre API unbeabsichtigt zu missbrauchen.

Wenn es sich um ein triviales Attribut handelt, das bei der Eingabe überprüft werden kann, z. B. eine Kapazität oder Länge, die nicht negativ sein darf, sollten Sie am besten sofort versagen. Wenn Sie den Fehler unterdrücken, vergrößert sich der Abstand zwischen Fehler und Feedback, wodurch es schwieriger wird, die Ursache des Problems zu finden.

Wenn Sie das Unglück haben, in einer Situation zu sein, in der die Gültigkeit eines Attributs von anderen abhängt, haben Sie zwei Möglichkeiten:

  • Es ist erforderlich, dass beide (oder mehrere) Attribute gleichzeitig angegeben werden (dh ein einzelner Methodenaufruf).
  • Testen Sie die Gültigkeit, sobald Sie wissen, dass keine Änderungen mehr eingehen: wann build()oder so wird aufgerufen.

Wie bei den meisten Dingen ist dies eine Entscheidung, die in einem Kontext getroffen wird. Wenn der Kontext ein frühzeitiges Scheitern schwierig oder umständlich macht, kann ein Kompromiss geschlossen werden, um Überprüfungen auf einen späteren Zeitpunkt zu verschieben. Die Standardeinstellung lautet jedoch "Fehlerfrei".

JvR
quelle
Zusammenfassend sagen Sie also, dass es sinnvoll ist, so früh wie möglich alles zu validieren, was in einem Objekt / primitiven Typ hätte abgedeckt werden können? Like unsigned, @NonNulletc.
Skiwi
2
@skiwi So ziemlich ja. Domain-Checks, Null-Checks, so etwas. Ich würde nicht befürworten, mehr als das zu tun: Bauherren sind im Allgemeinen einfache Dinge.
JvR
1
Es kann erwähnenswert sein, dass, wenn die Gültigkeit eines Parameters vom Wert eines anderen abhängt, man einen Parameterwert nur dann legitim ablehnen kann, wenn man weiß, dass der andere "wirklich" festgelegt ist . Wenn es zulässig ist, einen Parameterwert mehrmals festzulegen (wobei die letzte Einstellung Vorrang hat), besteht die natürlichste Möglichkeit, ein Objekt einzurichten, in einigen Fällen darin, den Parameter Xauf einen Wert festzulegen, der angesichts des aktuellen Werts von ungültig Yist. aber vor dem Aufruf build()Satz Yauf einen Wert, würde Xgültig.
Supercat
Wenn zum Beispiel ist ein Aufbau Shapeund der Bauherr hat WithLeftund WithRightEigenschaften, und man wünscht einen Builder anzupassen ein Objekt an einem anderen Ort zu konstruieren, zu verlangen , dass WithRightzuerst aufgerufen werden , wenn ein Objekt rechts bewegt, und WithLeftbeim Bewegen sie, links unnötige Komplexität würde im Vergleich zu erlauben WithLeft, die linke Kante rechts von der alten rechten Kante zu setzen, vorausgesetzt, dass WithRightdie rechte Kante fixiert, bevor buildaufgerufen wird.
Supercat
0

Die Grundregel lautet "Früh scheitern".

Die etwas weiter fortgeschrittene Regel lautet "so früh wie möglich scheitern".

Wenn eine Eigenschaft ist intrinsisch ungültig ...

CarBuilder.numberOfWheels( -1 ). ...  

... dann lehnen Sie es sofort ab.

In anderen Fällen müssen die Werte möglicherweise in Kombination überprüft und besser in die build () -Methode eingefügt werden:

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
Phill W.
quelle