So warnen Sie andere Programmierer vor der Implementierung von Klassen

96

Ich schreibe Klassen, die "auf eine bestimmte Art und Weise verwendet werden müssen" (ich denke, alle Klassen müssen ...).

Zum Beispiel erstelle ich die fooManagerKlasse, für die ein Aufruf erforderlich ist, zum Beispiel Initialize(string,string). Und um das Beispiel ein wenig voranzutreiben, wäre die Klasse nutzlos, wenn wir nicht auf ihre ThisHappenedAktion hören.

Mein Punkt ist, die Klasse, die ich schreibe, erfordert Methodenaufrufe. Aber es wird gut kompiliert, wenn Sie diese Methoden nicht aufrufen und am Ende einen leeren neuen FooManager erhalten. Irgendwann funktioniert es entweder nicht oder stürzt ab, je nach Klasse und Einsatzzweck. Der Programmierer, der meine Klasse implementiert, würde offensichtlich hineinschauen und feststellen, dass "Oh, ich habe Initialize nicht aufgerufen!", Und es wäre in Ordnung.

Aber das gefällt mir nicht. Was ich im Idealfall möchte, ist der Code NICHT zu kompilieren, wenn die Methode nicht aufgerufen wurde; Ich vermute, das ist einfach nicht möglich. Oder etwas, das sofort sichtbar und klar sein würde.

Ich bin beunruhigt über die derzeitige Vorgehensweise, die ich hier verfolge:

Fügen Sie einen privaten Booleschen Wert in die Klasse ein und überprüfen Sie überall, ob die Klasse initialisiert ist. Wenn nicht, werde ich eine Ausnahme auslösen, die besagt: "Die Klasse wurde nicht initialisiert, sind Sie sicher, dass Sie anrufen .Initialize(string,string)?".

Ich bin mit diesem Ansatz einverstanden, aber es führt zu viel Code, der kompiliert wird und letztendlich für den Endbenutzer nicht notwendig ist.

Manchmal ist es sogar noch mehr Code, wenn es mehr Methoden als nur einen InitiliazeAufruf gibt. Ich versuche, meine Klassen mit nicht zu vielen öffentlichen Methoden / Aktionen zu belassen, aber das löst das Problem nicht, es bleibt nur vernünftig.

Was ich hier suche, ist:

  • Ist mein Ansatz korrekt?
  • Gibt es eine bessere?
  • Was tut / berät ihr?
  • Versuche ich, ein Problem zu lösen, das nicht auftaucht? Ich habe von Kollegen erfahren, dass der Programmierer die Klasse überprüfen soll, bevor er versucht, sie zu verwenden. Ich bin mit Respekt anderer Meinung, aber das ist eine andere Sache, die ich glaube.

Einfach ausgedrückt, ich versuche einen Weg zu finden, um nie zu vergessen, Aufrufe zu implementieren, wenn diese Klasse später oder von jemand anderem wiederverwendet wird.

Erklärungen:

Um hier viele Fragen zu klären:

  • Ich spreche definitiv NICHT nur über den Initialisierungsteil einer Klasse, sondern es ist das ganze Leben. Verhindern Sie, dass Kollegen eine Methode zweimal aufrufen, und stellen Sie sicher, dass sie X vor Y usw. aufrufen. Alles, was am Ende obligatorisch und in der Dokumentation wäre, aber ich möchte, dass Code so einfach und klein wie möglich ist. Die Idee von Asserts hat mir sehr gut gefallen, obwohl ich mir ziemlich sicher bin, dass ich einige andere Ideen mischen muss, da Asserts nicht immer möglich sein werden.

  • Ich benutze die C # -Sprache! Wie habe ich das nicht erwähnt ?! Ich arbeite in einer Xamarin-Umgebung und erstelle mobile Apps in der Regel mit 6 bis 9 Projekten in einer Lösung, einschließlich PCL-, iOS-, Android- und Windows-Projekten. Ich bin seit ungefähr anderthalb Jahren Entwickler (Schule und Arbeit kombiniert), daher meine manchmal lächerlichen Aussagen \ Fragen. All das ist hier wahrscheinlich irrelevant, aber zu viele Informationen sind nicht immer eine schlechte Sache.

  • Ich kann aufgrund von Plattformbeschränkungen und der Verwendung von Dependency Injection nicht immer alle obligatorischen Elemente in den Konstruktor einfügen, da andere Parameter als Schnittstellen nicht in der Tabelle enthalten sind. Oder vielleicht reicht mein Wissen nicht aus, was durchaus möglich ist. Meistens handelt es sich nicht um ein Initialisierungsproblem, sondern um mehr

Wie kann ich sicherstellen, dass er sich für diese Veranstaltung angemeldet hat?

wie kann ich sicherstellen, dass er nicht vergaß, "den Prozess irgendwann anzuhalten"

Hier erinnere ich mich an eine Klasse zum Abrufen von Werbung. Solange die Ansicht, in der die Anzeige sichtbar ist, sichtbar ist, ruft die Klasse jede Minute eine neue Anzeige ab. Diese Klasse benötigt bei der Erstellung eine Ansicht, in der die Anzeige angezeigt werden kann, die offensichtlich in einem Parameter enthalten sein kann. Sobald die Ansicht verschwunden ist, muss StopFetching () aufgerufen werden. Andernfalls würde die Klasse weiterhin Anzeigen für eine Ansicht abrufen, die noch nicht vorhanden ist, und das ist schlecht.

Diese Klasse hat auch Ereignisse, die abgehört werden müssen, wie zum Beispiel "AdClicked". Alles funktioniert einwandfrei, wenn es nicht abgehört wird, aber wir verlieren die Verfolgung von Analysen, wenn keine Abgriffe registriert sind. Die Anzeige funktioniert jedoch weiterhin, sodass der Nutzer und der Entwickler keinen Unterschied bemerken und die Analyse nur falsche Daten enthält. Das muss vermieden werden, aber ich bin nicht sicher, wie Entwickler wissen können, dass sie sich für das Tao-Ereignis registrieren müssen. Das ist zwar ein vereinfachtes Beispiel, aber die Idee ist, "stellen Sie sicher, dass er die öffentliche Aktion verwendet, die verfügbar ist" und natürlich zum richtigen Zeitpunkt!

Gil Sand
quelle
13
Es ist im Allgemeinen nicht möglich, sicherzustellen, dass Aufrufe der Methoden einer Klasse in einer bestimmten Reihenfolge erfolgen . Dies ist eines von vielen, vielen Problemen, die dem Stopp-Problem entsprechen. In einigen Fällen ist es zwar möglich, die Nichteinhaltung als Fehler bei der Kompilierung zu betrachten , es gibt jedoch keine allgemeine Lösung. Dies ist vermutlich der Grund, warum nur wenige Sprachen Konstrukte unterstützen, mit denen Sie zumindest die offensichtlichen Fälle diagnostizieren können, obwohl die Datenflussanalyse inzwischen recht komplex ist.
Kilian Foth
6
Mögliches Duplikat einer einzelnen Methode mit vielen Parametern im
5
Die Antwort auf die andere Frage ist irrelevant, die Frage ist nicht dieselbe, daher handelt es sich um unterschiedliche Fragen. Wenn jemand nach dieser Diskussion sucht, würde er den Titel der anderen Frage nicht eingeben.
Gil Sand
6
Was hindert zu fusionieren , den Inhalt initialize im Ctor? Muss der Aufruf initializeerst spät nach der Objekterstellung erfolgen? Wäre der ctor zu "riskant" in dem Sinne, dass er Ausnahmen werfen und die Schöpfungskette durchbrechen könnte?
Gepunktet
7
Dieses Problem nennt man zeitliche Kopplung . Wenn Sie können, versuchen Sie zu vermeiden, das Objekt in einen ungültigen Zustand zu versetzen, indem Sie Ausnahmen im Konstruktor auslösen, wenn Sie auf ungültige Eingaben stoßen. Auf diese Weise können Sie den Aufruf von nie beenden, newwenn das Objekt nicht zur Verwendung bereit ist. Wenn Sie sich auf eine Initialisierungsmethode verlassen, werden Sie immer wieder verfolgt und sollten diese vermeiden, es sei denn, dies ist absolut notwendig. Das ist zumindest meine Erfahrung.
Seralize

Antworten:

161

In solchen Fällen ist es am besten, das Typensystem Ihrer Sprache zu verwenden, um Sie bei der richtigen Initialisierung zu unterstützen. Wie können wir verhindern, FooManagerdass a verwendet wird, ohne initialisiert zu werden? Durch eine Verhinderung FooManagervon wird erstellt , ohne die notwendigen Informationen , um es richtig zu initialisieren. Insbesondere liegt die gesamte Initialisierung in der Verantwortung des Konstrukteurs. Sie sollten niemals zulassen, dass Ihr Konstruktor ein Objekt in einem unzulässigen Zustand erstellt.

Aufrufer müssen jedoch eine FooManagererstellen, bevor sie sie initialisieren können, z. B. weil die FooManagerals Abhängigkeit weitergegeben wird.

Erstellen Sie keine, FooManagerwenn Sie keine haben. Sie können stattdessen ein Objekt übergeben, mit dem Sie eine vollständig erstellte Datei abrufen könnenFooManager , jedoch nur mit den Initialisierungsinformationen. (In der funktionalen Programmierung schlage ich vor, dass Sie eine Teilanwendung für den Konstruktor verwenden.) Bsp .:

ctorArgs = ...;
getFooManager = (initInfo) -> new FooManager(ctorArgs, initInfo);
...
getFooManager(myInitInfo).fooMethod();

Das Problem dabei ist, dass Sie die Init-Informationen jedes Mal angeben müssen, wenn Sie auf die zugreifen FooManager.

Wenn dies in Ihrer Sprache erforderlich ist, können Sie die getFooManager()Operation in eine Factory- oder Builder-ähnliche Klasse einbinden.

Ich möchte wirklich zur Laufzeit prüfen, ob die initialize()Methode aufgerufen wurde, anstatt eine Lösung auf Typsystemebene zu verwenden.

Es ist möglich, einen Kompromiss zu finden. Wir erstellen eine Wrapper-Klasse MaybeInitializedFooManagermit einer get()Methode, die die zurückgibt FooManager, aber auslöst, wenn die FooManagernicht vollständig initialisiert wurde. Dies funktioniert nur, wenn die Initialisierung über den Wrapper erfolgt oder wenn es eine FooManager#isInitialized()Methode gibt.

class MaybeInitializedFooManager {
  private final FooManager fooManager;

  public MaybeInitializedFooManager(CtorArgs ctorArgs) {
    fooManager = new FooManager(ctorArgs);
  }

  public FooManager initialize(InitArgs initArgs) {
    fooManager.initialize(initArgs);
    return fooManager;
  }

  public FooManager get() {
    if (fooManager.isInitialized()) return fooManager;
    throw ...;
  }
}

Ich möchte die API meiner Klasse nicht ändern.

In diesem Fall sollten Sie die if (!initialized) throw;Bedingungen in jeder Methode vermeiden . Glücklicherweise gibt es ein einfaches Muster, um dies zu lösen.

Das Objekt, das Sie Benutzern zur Verfügung stellen, ist lediglich eine leere Shell, die alle Aufrufe an ein Implementierungsobjekt delegiert. Standardmäßig gibt das Implementierungsobjekt für jede Methode, die nicht initialisiert wurde, einen Fehler aus. Die initialize()Methode ersetzt jedoch das Implementierungsobjekt durch ein vollständig erstelltes Objekt.

class FooManager {
  private CtorArgs ctorArgs;
  private Impl impl;

  public FooManager(CtorArgs ctorArgs) {
    this.ctorArgs = ctorArgs;
    this.impl = new UninitializedImpl();
  }

  public void initialize(InitArgs initArgs) {
    impl = new MainImpl(ctorArgs, initArgs);
  }

  public X foo() { return impl.foo(); }
  public Y bar() { return impl.bar(); }
}

interface Impl {
  X foo();
  Y bar();
}

class UninitializedImpl implements Impl {
  public X foo() { throw ...; }
  public Y bar() { throw ...; }
}

class MainImpl implements Impl {
  public MainImpl(CtorArgs c, InitArgs i);
  public X foo() { ... }
  public Y bar() { ... }
}

Dies extrahiert das Hauptverhalten der Klasse in die MainImpl.

amon
quelle
9
Ich mag die ersten beiden Lösungen (+1), aber ich denke nicht, dass Ihr letzter Ausschnitt mit seiner Wiederholung der Methoden in FooManagerund in UninitialisedImplbesser ist als das Wiederholen if (!initialized) throw;.
Bergi
3
@Bergi Manche Leute lieben den Unterricht zu sehr. Obwohl FooManageres viele Methoden gibt, ist es möglicherweise einfacher, als einige der if (!initialized)Überprüfungen zu vergessen . Aber in diesem Fall sollten Sie es wahrscheinlich vorziehen, die Klasse zu trennen.
immibis
1
Ein MaybeInitializedFoo scheint mir nicht besser zu sein als ein initialisiertes Foo, aber +1 für einige Optionen / Ideen.
Matthew James Briggs
7
+1 Die Fabrik ist die richtige Option. Sie können ein Design nicht verbessern, ohne es zu ändern, daher wird das letzte bisschen der Antwort, wie zur Hälfte es ist, dem OP nicht helfen.
Nathan Cooper
6
@Bergi Ich habe die im letzten Abschnitt vorgestellte Technik als äußerst nützlich empfunden (siehe auch die Refactoring-Technik „Ersetzen von Bedingungen durch Polymorphismus“ und das Zustandsmuster). Es ist leicht, das Einchecken einer Methode zu vergessen, wenn wir eine if / else-Methode verwenden. Es ist viel schwieriger, das Implementieren einer für die Schnittstelle erforderlichen Methode zu vergessen. Vor allem entspricht das MainImpl genau einem Objekt, bei dem die initialize-Methode mit dem Konstruktor zusammengeführt wird. Dies bedeutet, dass die Implementierung von MainImpl die stärkeren Garantien genießen kann, die dies bietet, was den Hauptcode vereinfacht. …
amon
79

Die effektivste und hilfreichste Methode, um zu verhindern, dass Clients ein Objekt "missbrauchen", besteht darin, es unmöglich zu machen.

Die einfachste Lösung ist das Zusammenführen Initializemit dem Konstruktor. Auf diese Weise ist das Objekt im nicht initialisierten Zustand niemals für den Client verfügbar, sodass der Fehler nicht möglich ist. Falls Sie die Initialisierung nicht im Konstruktor selbst durchführen können, können Sie eine Factory-Methode erstellen. Wenn für Ihre Klasse beispielsweise die Registrierung bestimmter Ereignisse erforderlich ist, kann der Ereignis-Listener als Parameter in der Konstruktor- oder Factory-Methodensignatur erforderlich sein.

Wenn Sie vor der Initialisierung auf das nicht initialisierte Objekt zugreifen müssen, können Sie die beiden Status als separate Klassen implementieren. Beginnen Sie also mit einer UnitializedFooManagerInstanz, die eine Initialize(...)Methode hat, die zurückgibt InitializedFooManager. Die Methoden, die nur im initialisierten Zustand aufgerufen werden können, existieren nur am InitializedFooManager. Dieser Ansatz kann bei Bedarf auf mehrere Status erweitert werden.

Im Vergleich zu Laufzeitausnahmen ist es aufwendiger, Zustände als unterschiedliche Klassen darzustellen, aber Sie erhalten auch die Garantie, dass Sie keine Methoden aufrufen, die für den Objektzustand ungültig sind, und die Zustandsübergänge werden deutlicher im Code dokumentiert.

Generell ist es jedoch die ideale Lösung, Ihre Klassen und Methoden ohne Einschränkungen zu entwerfen, z. B. indem Sie Methoden zu bestimmten Zeiten und in einer bestimmten Reihenfolge aufrufen müssen. Möglicherweise ist dies nicht immer möglich, kann jedoch in vielen Fällen durch Verwendung eines geeigneten Musters vermieden werden.

Wenn Sie eine komplexe zeitliche Kopplung haben (eine Reihe von Methoden müssen in einer bestimmten Reihenfolge aufgerufen werden), kann eine Lösung darin bestehen, die Steuerung umzukehren. Sie erstellen also eine Klasse, die die Methoden in der entsprechenden Reihenfolge aufruft, jedoch Vorlagenmethoden oder -ereignisse verwendet Damit der Client benutzerdefinierte Vorgänge in geeigneten Phasen des Ablaufs ausführen kann. Auf diese Weise wird die Verantwortung für die Ausführung von Vorgängen in der richtigen Reihenfolge vom Kunden auf die Klasse selbst übertragen.

Ein einfaches Beispiel: Sie haben ein File-Objekt, mit dem Sie aus einer Datei lesen können. Der Client muss jedoch aufgerufen werden, Openbevor die ReadLineMethode aufgerufen werden kann, und muss sich daran erinnern, immer Close(auch wenn eine Ausnahme auftritt) aufzurufen, wonach die ReadLineMethode nicht mehr aufgerufen werden darf. Dies ist eine zeitliche Kopplung. Es kann vermieden werden, indem eine einzelne Methode verwendet wird, die einen Rückruf oder einen Delegaten als Argument verwendet. Die Methode verwaltet das Öffnen der Datei, das Aufrufen des Rückrufs und das Schließen der Datei. Es kann eine eindeutige Schnittstelle mit einer ReadMethode an den Rückruf übergeben. Auf diese Weise kann der Client nicht vergessen, Methoden in der richtigen Reihenfolge aufzurufen.

Schnittstelle mit zeitlicher Kopplung:

class File {
     /// Must be called on a closed file.
     /// Remember to always call Close() when you are finished
     public void Open();

     /// must be called on on open file
     public string ReadLine();

     /// must be called on an open file
     public void Close();
}

Ohne zeitliche Kopplung:

class File {
    /// Opens the file, executes the callback, and closes the file again.
    public void Consume(Action<IOpenFile> callback);
}

interface IOpenFile {
    string ReadLine();
}

Eine schwergewichtigere Lösung wäre die Definition Fileeiner abstrakten Klasse, bei der Sie eine (geschützte) Methode implementieren müssen, die für die geöffnete Datei ausgeführt wird. Dies wird als Vorlagenmethodenmuster bezeichnet.

abstract class File {
    /// Opens the file, executes ConsumeOpenFile(), and closes the file again.
    public void Consume();

    /// override this
    abstract protected ConsumeOpenFile();

    /// call this from your ConsumeOpenFile() implementation
    protected string ReadLine();
}

Der Vorteil ist derselbe: Der Client muss nicht daran denken, Methoden in einer bestimmten Reihenfolge aufzurufen. Es ist einfach nicht möglich, Methoden in der falschen Reihenfolge aufzurufen.

JacquesB
quelle
2
Ich erinnere mich auch an Fälle, in denen es einfach nicht möglich war, vom Konstrukteur wegzugehen, aber ich habe momentan kein Beispiel zu zeigen
Gil Sand,
2
Das Beispiel beschreibt nun ein Factory-Muster - aber der erste Satz beschreibt tatsächlich das RAII- Paradigma. Welche soll diese Antwort also empfehlen?
Ext3h
11
@Ext3h Ich glaube nicht, dass sich das Factory-Pattern und RAII gegenseitig ausschließen.
Pieter B
@PieterB Nein, das sind sie nicht, eine Fabrik kann RAII sicherstellen. Aber nur mit der zusätzlichen Implikation, dass die Template / Konstruktor-Argumente (aufgerufen UnitializedFooManager) keinen Zustand mit den Instanzen ( InitializedFooManager) teilen dürfen , wie Initialize(...)es tatsächlich Instantiate(...)semantisch ist. Andernfalls führt dieses Beispiel zu neuen Problemen, wenn dieselbe Vorlage von einem Entwickler zweimal verwendet wird. Und das lässt sich auch nicht statisch verhindern / validieren, wenn die Sprache die moveSemantik nicht explizit unterstützt , um die Vorlage zuverlässig zu nutzen.
Ext3h
2
@Ext3h: Ich empfehle die erste Lösung, da es die einfachste ist. Wenn der Client jedoch vor dem Aufruf von Initialize auf das Objekt zugreifen muss , können Sie es nicht verwenden, möglicherweise jedoch die zweite Lösung.
JacquesB
39

Normalerweise prüfe ich nur auf Initialisierung und werfe (sprich) ein, IllegalStateExceptionwenn Sie versuchen, es zu verwenden, während es nicht initialisiert ist.

Wenn Sie jedoch während der Kompilierung sicher sein möchten (und das ist lobenswert und vorzuziehen), warum behandeln Sie die Initialisierung nicht als eine Factory-Methode, die ein erstelltes und initialisiertes Objekt zurückgibt, z

ComponentBuilder builder = new ComponentBuilder();
Component forClients = builder.initialise(); // the 'Component' is what you give your clients

Auf diese Weise steuern Sie die Erstellung und den Lebenszyklus von Objekten, und Ihre Kunden erhalten das ComponentErgebnis Ihrer Initialisierung. Es ist effektiv eine faule Instanziierung.

Brian Agnew
quelle
1
Gute Antwort - 2 gute Optionen in einer schönen prägnanten Antwort. Andere Antworten über dem gegenwärtigen Typ-System-Turnen, die (theoretisch) gültig sind; In den meisten Fällen sind diese beiden einfacheren Optionen jedoch die ideale Wahl für die Praxis.
Thomas W
1
Hinweis: In .NET wird InvalidOperationException von Microsoft als das eingestuft, was Sie genannt haben IllegalStateException.
Miroxlav
1
Ich habe diese Antwort nicht herabgestimmt, aber es ist keine wirklich gute Antwort. Durch einfaches Werfen IllegalStateExceptionoder InvalidOperationExceptionaus heiterem Himmel wird ein Benutzer nie verstehen, was er falsch gemacht hat. Diese Ausnahmen sollten nicht zur Umgehung des Konstruktionsfehlers werden.
displayName 16.04.16
Sie werden feststellen, dass das Auslösen von Ausnahmen eine mögliche Option ist, und ich werde dann auf meine bevorzugte Option eingehen - um diese Kompilierungszeit sicher zu machen. Ich habe meine Antwort bearbeitet, um dies zu betonen
Brian Agnew
14

Ich werde ein wenig von den anderen Antworten abweichen und nicht zustimmen: Es ist unmöglich, dies zu beantworten, ohne zu wissen, in welcher Sprache Sie arbeiten Ihre Benutzer hängen vollständig von den Mechanismen ab, die Ihre Sprache bereitstellt, und den Konventionen, denen andere Entwickler dieser Sprache tendenziell folgen.

Wenn Sie eine FooManagerin Haskell haben, wäre es strafbar , Ihren Benutzern zu erlauben, eine zu erstellen, die nicht in der Lage ist, Foos zu verwalten, da das Typsystem dies so einfach macht und dies die Konventionen sind, die Haskell-Entwickler erwarten.

Auf der anderen Seite, wenn Sie C schreiben, haben Ihre Mitarbeiter das Recht, Sie zurückzunehmen und Sie für die Definition von separaten struct FooManagerund struct UninitializedFooManagerTypen zu erschießen , die verschiedene Operationen unterstützen, da dies zu unnötig komplexem Code mit sehr geringem Nutzen führen würde .

Wenn Sie Python schreiben, gibt es in der Sprache keinen Mechanismus, mit dem Sie dies tun können.

Sie schreiben wahrscheinlich nicht Haskell, Python oder C, aber sie sind anschauliche Beispiele dafür, wie viel / wie wenig Arbeit das Typsystem erwartet und leisten kann.

Befolgen Sie die vernünftigen Erwartungen eines Entwicklers in Ihrer Sprache und widersetzen Sie sich dem Drang, eine Lösung zu überarbeiten, die keine natürliche, idiomatische Implementierung aufweist (es sei denn, der Fehler ist so leicht zu begehen und so schwer zu fassen, dass es sich lohnt, extrem zu werden) Längen, um es unmöglich zu machen). Wenn Sie nicht genug Erfahrung in Ihrer Sprache haben, um zu beurteilen, was angemessen ist, befolgen Sie den Rat von jemandem, der es besser kennt als Sie.

Patrick Collins
quelle
Ich bin ein wenig verwirrt von "Wenn Sie Python schreiben, gibt es in der Sprache keinen Mechanismus, mit dem Sie dies tun können." Python hat Konstruktoren und es ist ziemlich trivial, Factory-Methoden zu erstellen. Also verstehe ich vielleicht nicht, was du mit "dies" meinst?
AL Flanagan
3
Mit "dies" meine ich einen Kompilierungsfehler im Gegensatz zu einem Laufzeitfehler.
Patrick Collins
Schauen Sie einfach vorbei, um zu erwähnen, dass Python 3.5, die aktuelle Version, Typen über ein steckbares Typsystem hat. Es ist definitiv möglich, Python auf Fehler zu bringen, bevor der Code ausgeführt wird, nur weil er importiert wurde. Bis 3.5 war es allerdings völlig richtig.
Benjamin Gruenbaum
Oh ok. Nachträglich +1. Selbst verwirrt war dies sehr aufschlussreich.
AL Flanagan
Ich mag deinen Stil Patrick.
Gil Sand
4

Da Sie den Code-Check anscheinend nicht an den Kunden senden möchten (aber für den Programmierer in Ordnung zu sein scheinen), können Sie Assert- Funktionen verwenden, wenn diese in Ihrer Programmiersprache verfügbar sind.

Auf diese Weise haben Sie die Überprüfungen in der Entwicklungsumgebung (und alle Tests, die ein Entwicklerkollege vorhersagen würde, scheitern), aber Sie werden den Code nicht an den Kunden senden, da Asserts (zumindest in Java) nur selektiv kompiliert werden.

Eine Java-Klasse, die dies verwendet, würde folgendermaßen aussehen:

/** For some reason, you have to call init and connect before the manager works. */
public class FooManager {
   private int state = 0;

   public FooManager () {
   }

   public synchronized void init() {
      assert state==0 : "you called init twice.";
      // do something
      state = 1;
   }

   public synchronized void connect() {
      assert state==1 : "you called connect before init, or connect twice.";
      // do something
      state = 2;
   }

   public void someWork() {
      assert state==2 : "You did not properly init FooManager. You need to call init and connect first.";
      // do the actual work.
   }
}

Assertions sind ein großartiges Werkzeug, um den Laufzeitstatus Ihres Programms zu überprüfen, das Sie benötigen, aber erwarten Sie nicht, dass jemand in einer realen Umgebung jemals etwas falsch macht.

Außerdem sind sie ziemlich schlank und nehmen keine großen Teile der if () throw ... -Syntax ein, sie müssen nicht abgefangen werden usw.

Angelo Fuchs
quelle
3

Betrachten Sie dieses Problem allgemeiner als die aktuellen Antworten, die sich größtenteils auf die Initialisierung konzentriert haben. Betrachten Sie ein Objekt mit zwei Methoden a()und b(). Voraussetzung ist, dass a()immer vorher gerufen wird b(). Sie können beim Kompilieren überprüfen, ob dies der Fall ist, indem Sie ein neues Objekt zurückgeben a()und b()auf das neue Objekt anstatt auf das ursprüngliche verschieben. Beispielimplementierung:

class SomeClass
{
   private int valueRequiredByMethodB;

   public IReadyForB a (int v) { valueRequiredByMethodB = v; return new ReadyForB(this); }

   public interface IReadyForB { public void b (); }

   private class ReadyForB : IReadyForB
   {
      SomeClass owner;
      private ReadyForB (SomeClass owner) { this.owner = owner; }
      public void b () { Console.WriteLine (owner.valueRequiredByMethodB); }
   }
}

Jetzt ist es unmöglich, b () aufzurufen, ohne zuerst a () aufzurufen, da es in einer Schnittstelle implementiert ist, die ausgeblendet ist, bis a () aufgerufen wird. Zugegeben, das ist ein ziemlicher Aufwand, daher würde ich diesen Ansatz normalerweise nicht verwenden, aber es gibt Situationen, in denen er von Vorteil sein kann (insbesondere, wenn Ihre Klasse in vielen Situationen von Programmierern wiederverwendet wird, die dies möglicherweise nicht tun) vertraut mit seiner Implementierung oder in Code, in dem Zuverlässigkeit von entscheidender Bedeutung ist). Beachten Sie auch, dass dies eine Verallgemeinerung des Builder-Musters ist, wie viele der vorhandenen Antworten nahe legen. Es funktioniert so ziemlich genauso, der einzige wirkliche Unterschied besteht darin, wo die Daten gespeichert sind (im ursprünglichen Objekt und nicht im zurückgegebenen) und wann sie verwendet werden sollen (zu jeder Zeit im Vergleich zu nur während der Initialisierung).

Jules
quelle
2

Wenn ich eine Basisklasse implementiere, die zusätzliche Initialisierungsinformationen oder Verknüpfungen zu anderen Objekten benötigt, bevor sie nützlich werden, tendiere ich dazu, diese Basisklasse abstrakt zu machen und dann mehrere abstrakte Methoden in dieser Klasse zu definieren, die im Fluss der Basisklasse verwendet werden (wie abstract Collection<Information> get_extra_information(args);und abstract Collection<OtherObjects> get_other_objects(args);die laut Vererbungsvertrag von der konkreten Klasse implementiert werden muss, wodurch der Benutzer dieser Basisklasse gezwungen wird, alle von der Basisklasse benötigten Dinge bereitzustellen.

Wenn ich also die Basisklasse implementiere, weiß ich sofort und explizit, was ich schreiben muss, damit sich die Basisklasse richtig verhält, da ich nur die abstrakten Methoden implementieren muss und das war's.

EDIT: Zur Verdeutlichung entspricht dies fast der Bereitstellung von Argumenten für den Konstruktor der Basisklasse, aber die Implementierungen der abstrakten Methode ermöglichen der Implementierung, die an die Aufrufe der abstrakten Methode übergebenen Argumente zu verarbeiten. Selbst wenn keine Argumente verwendet werden, kann es dennoch nützlich sein, wenn der Rückgabewert der abstrakten Methode von einem Zustand abhängt, den Sie im Methodentext definieren können. Dies ist nicht möglich, wenn die Variable als Argument an übergeben wird der Konstruktor. Zugegeben, Sie können immer noch ein Argument übergeben, dessen dynamisches Verhalten auf demselben Prinzip basiert, wenn Sie die Komposition anstelle der Vererbung verwenden möchten.

klaar
quelle
2

Die Antwort lautet: Ja, nein und manchmal. :-)

Einige der von Ihnen beschriebenen Probleme lassen sich zumindest in vielen Fällen leicht lösen.

Die Prüfung zur Kompilierungszeit ist der Laufzeitprüfung, die überhaupt keiner Prüfung vorzuziehen ist, mit Sicherheit vorzuziehen.

RE-Laufzeit:

Es ist zumindest konzeptionell einfach, das Aufrufen von Funktionen in einer bestimmten Reihenfolge usw. mit Laufzeitprüfungen zu erzwingen. Fügen Sie einfach Flags ein, die angeben, welche Funktion ausgeführt wurde, und lassen Sie jede Funktion mit so etwas wie "Wenn nicht Vorbedingung-1 oder nicht Vorbedingung-2, dann werfen Sie eine Ausnahme" beginnen. Wenn die Prüfungen komplex werden, können Sie sie in eine private Funktion verschieben.

Sie können einige Dinge automatisch geschehen lassen. Um ein einfaches Beispiel zu nennen: Programmierer sprechen oft von "faulen Populationen" eines Objekts. Wenn die Instanz erstellt wird, setzen Sie ein gefülltes Flag auf false oder einen Schlüsselobjektverweis auf null. Wenn dann eine Funktion aufgerufen wird, die die relevanten Daten benötigt, prüft sie das Flag. Wenn es falsch ist, werden die Daten gefüllt und das Flag auf wahr gesetzt. Wenn dies zutrifft, wird einfach davon ausgegangen, dass die Daten vorhanden sind. Mit anderen Voraussetzungen können Sie dasselbe tun. Setzen Sie ein Flag im Konstruktor oder als Standardwert. Wenn Sie dann einen Punkt erreichen, an dem eine Vorbedingungsfunktion hätte aufgerufen werden sollen, rufen Sie sie auf, falls sie noch nicht aufgerufen wurde. Dies funktioniert natürlich nur, wenn Sie die Daten haben, um sie an diesem Punkt aufzurufen, aber in vielen Fällen können Sie alle erforderlichen Daten in die Funktionsparameter der Funktion "

RE-Kompilierungszeit:

Wie bereits erwähnt, wird die Initialisierung im Konstruktor abgelegt, wenn Sie eine Initialisierung durchführen müssen, bevor ein Objekt verwendet werden kann. Dies ist ein ziemlich typischer Rat für OOP. Wenn möglich, sollten Sie den Konstruktor veranlassen, eine gültige, verwendbare Instanz des Objekts zu erstellen.

Ich sehe eine Diskussion darüber, was zu tun ist, wenn Sie Verweise auf das Objekt weitergeben müssen, bevor es initialisiert wird. Ich kann mir kein wirkliches Beispiel dafür vorstellen, aber ich nehme an, es könnte passieren. Es wurden Lösungen vorgeschlagen, aber die Lösungen, die ich hier gesehen habe, und alle, die mir einfallen, sind chaotisch und komplizieren den Code. Irgendwann müssen Sie sich die Frage stellen, ob es sich lohnt, hässlichen, schwer verständlichen Code zu erstellen, damit wir die Kompilierungszeit und nicht die Laufzeit überprüfen können. Oder mache ich mir und anderen viel Arbeit für ein Ziel, das nett, aber nicht erforderlich ist?

Wenn zwei Funktionen immer zusammen ausgeführt werden sollen, besteht die einfache Lösung darin, sie zu einer Funktion zu machen. Wenn Sie beispielsweise jedes Mal, wenn Sie einer Seite eine Anzeige hinzufügen, einen Kennzahlen-Handler hinzufügen, fügen Sie den Code zum Hinzufügen des Kennzahlen-Handlers in die Funktion "Anzeige hinzufügen" ein.

Einige Dinge könnten zur Kompilierungszeit kaum überprüft werden. Wie eine Anforderung, dass eine bestimmte Funktion nicht mehr als einmal aufgerufen werden kann. (Ich habe viele Funktionen wie diese geschrieben. Ich habe kürzlich eine Funktion geschrieben, die nach Dateien in einem magischen Verzeichnis sucht, alle gefundenen verarbeitet und sie dann löscht. Natürlich ist es nicht möglich, die Datei erneut auszuführen, sobald sie gelöscht wurde. Ich kenne keine Sprache mit einer Funktion, mit der Sie verhindern können, dass eine Funktion beim Kompilieren zweimal auf derselben Instanz aufgerufen wird. Ich weiß nicht, wie der Compiler definitiv herausfinden konnte, dass dies geschah. Alles, was Sie tun können, ist die Laufzeitüberprüfung. Diese besondere Laufzeitprüfung ist offensichtlich, nicht wahr? msgstr "wenn schon hier = wahr, dann wirf eine Ausnahme, sonst setze schon hier = wahr".

RE unmöglich durchzusetzen

Es ist nicht ungewöhnlich, dass eine Klasse eine Bereinigung benötigt, wenn Sie fertig sind: Schließen von Dateien oder Verbindungen, Freigeben von Speicher, Schreiben der endgültigen Ergebnisse in die Datenbank usw. Es gibt keine einfache Möglichkeit, dies zu erzwingen oder Laufzeit. Ich denke, dass die meisten OOP-Sprachen eine Art "Finalizer" -Funktion enthalten, die aufgerufen wird, wenn eine Instanz überflüssig wird, aber ich denke, die meisten sagen auch, dass sie nicht garantieren können, dass dies jemals ausgeführt wird. OOP-Sprachen enthalten häufig eine "dispose" -Funktion mit einer Art von Bestimmung, um einen Bereich für die Verwendung einer Instanz festzulegen, eine "using" - oder "with" -Klausel, und wenn das Programm diesen Bereich verlässt, wird die dispose ausgeführt. Dafür muss der Programmierer jedoch den entsprechenden Bereichsspezifizierer verwenden. Es zwingt den Programmierer nicht, es richtig zu machen,

In Fällen, in denen es unmöglich ist, den Programmierer zur korrekten Verwendung der Klasse zu zwingen, versuche ich, sie so zu gestalten, dass das Programm in die Luft sprengt, wenn er es falsch macht, anstatt falsche Ergebnisse zu liefern. Einfaches Beispiel: Ich habe viele Programme gesehen, die eine Reihe von Daten mit Dummy-Werten initialisieren, sodass der Benutzer niemals eine Nullzeiger-Ausnahme erhält, selbst wenn er die Funktionen zum korrekten Auffüllen der Daten nicht aufruft. Und ich frage mich immer warum. Sie tun alles, um das Auffinden von Fehlern zu erschweren. Wenn ein Programmierer die Funktion "Daten laden" nicht aufrief und dann mühsam versuchte, die Daten zu verwenden, erhielt er eine Nullzeiger-Ausnahme, die ihm schnell zeigte, wo das Problem lag. Wenn Sie es in solchen Fällen jedoch ausblenden, indem Sie die Daten leer lassen oder auf Null setzen, wird das Programm möglicherweise vollständig ausgeführt, führt jedoch zu ungenauen Ergebnissen. In einigen Fällen merkt der Programmierer möglicherweise nicht einmal, dass ein Problem vorliegt. Wenn er es bemerkt, muss er möglicherweise einem langen Pfad folgen, um herauszufinden, wo es schief gegangen ist. Lieber früh scheitern als mutig weitermachen.

Im Allgemeinen ist es immer gut, wenn Sie einen falschen Gebrauch eines Objekts einfach unmöglich machen können. Es ist gut, wenn Funktionen so strukturiert sind, dass der Programmierer einfach keine Möglichkeit hat, ungültige Aufrufe zu tätigen, oder wenn ungültige Aufrufe zu Fehlern bei der Kompilierung führen.

Es gibt aber auch einen Punkt, an dem Sie sagen müssen: Der Programmierer sollte die Dokumentation zur Funktion lesen.

Jay
quelle
1
In Bezug auf das Erzwingen einer Bereinigung gibt es ein Muster, das verwendet werden kann, wenn eine Ressource nur durch Aufrufen einer bestimmten Methode zugewiesen werden kann (z. B. in einer typischen OO-Sprache könnte sie einen privaten Konstruktor haben). Diese Methode weist die Ressource zu, ruft eine Funktion (oder eine Methode einer Schnittstelle) auf, die mit einem Verweis auf die Ressource an sie übergeben wird, und zerstört die Ressource, sobald diese Funktion zurückgegeben wird. Abgesehen von einer abrupten Thread-Beendigung stellt dieses Muster sicher, dass die Ressource immer zerstört wird. Es ist ein gängiger Ansatz in funktionalen Sprachen, aber ich habe auch gesehen, dass er in OO-Systemen erfolgreich eingesetzt wird.
Jules
@Jules aka "Disposers"
Benjamin Gruenbaum
2

Ja, Ihre Instinkte sind genau richtig. Vor vielen Jahren schrieb ich Artikel in Magazinen (so ähnlich wie hier, aber auf Dead-Tree und einmal im Monat veröffentlicht) über Debugging , Abugging und Antibugging .

Wenn Sie den Compiler dazu bringen können, den Missbrauch zu erkennen, ist dies der beste Weg. Welche Sprachfunktionen verfügbar sind, hängt von der Sprache ab, die Sie nicht angegeben haben. Spezifische Techniken werden auf dieser Site ohnehin für einzelne Fragen detailliert beschrieben.

Ein Test zum Erkennen der Verwendung zur Kompilierungszeit statt zur Laufzeit ist jedoch die gleiche Art von Test: Sie müssen immer noch wissen, wie die richtigen Funktionen zuerst aufgerufen und richtig verwendet werden.

Das Entwerfen der Komponente, um zu vermeiden, dass es überhaupt zu Problemen kommt, ist viel subtiler, und ich finde, dass der Puffer dem Element- Beispiel gleicht . Teil 4 (veröffentlicht vor zwanzig Jahren! Wow!) Enthält ein Beispiel mit einer Diskussion, die Ihrem Fall ziemlich ähnlich ist: Diese Klasse muss sorgfältig verwendet werden, da buffer () möglicherweise nicht aufgerufen wird, nachdem Sie read () verwendet haben. Vielmehr darf es unmittelbar nach dem Bau nur einmal verwendet werden. Wenn die Klasse den Puffer automatisch und für den Aufrufer unsichtbar verwaltet, wird das Problem konzeptionell vermieden.

Sie fragen, ob Tests zur Laufzeit durchgeführt werden können, um die ordnungsgemäße Verwendung sicherzustellen. Sollten Sie dies tun?

Ich würde ja sagen . Dies erspart Ihrem Team eines Tages viel Debugging-Arbeit, wenn der Code beibehalten wird und die spezifische Verwendung gestört wird. Das Testen kann als formale Gruppe von Einschränkungen und Invarianten eingerichtet werden , die getestet werden können. Dies bedeutet nicht, dass bei jedem Anruf zusätzlicher Speicherplatz für die Verfolgung des Status und die Durchführung der Überprüfungen verbleibt. Es ist in der Klasse , so dass es kann genannt werden, und der Code dokumentiert die tatsächlichen Einschränkungen und Annahmen.

Möglicherweise wird die Überprüfung nur in einem Debugbuild durchgeführt.

Vielleicht ist die Prüfung komplex (denken Sie beispielsweise an Heapcheck), aber sie ist vorhanden und kann gelegentlich durchgeführt werden, oder es werden hier und da Aufrufe hinzugefügt, wenn der Code bearbeitet wird und ein Problem auftritt oder spezifische Tests durchgeführt werden müssen Stellen Sie sicher, dass in einem Unit-Test- oder Integrationstestprogramm , das mit derselben Klasse verknüpft ist, keine derartigen Probleme auftreten .

Sie können entscheiden, dass der Overhead schließlich trivial ist. Wenn die Klasse Datei-E / A ausführt, ist ein zusätzliches Statusbyte und ein Test dafür nichts. Gängige Iostream-Klassen prüfen, ob der Stream fehlerhaft ist.

Dein Instinkt ist gut. Mach weiter!

JDługosz
quelle
0

Das Prinzip des Versteckens von Informationen (Encapsulation) besteht darin, dass Entitäten außerhalb der Klasse nicht mehr wissen sollten, als für die ordnungsgemäße Verwendung dieser Klasse erforderlich ist.

Ihr Fall scheint so, als würden Sie versuchen, ein Objekt einer Klasse zur ordnungsgemäßen Ausführung zu bringen, ohne den externen Benutzern genügend Informationen über die Klasse zu geben. Sie haben mehr Informationen versteckt, als Sie sollten.

  • Fragen Sie entweder explizit nach den erforderlichen Informationen im Konstruktor.
  • Oder lassen Sie die Konstruktoren intakt und ändern Sie die Methoden, sodass Sie zum Aufrufen der Methoden die fehlenden Daten angeben müssen.

Worte der Weisheit:

* In beiden Fällen müssen Sie Klasse und / oder Konstruktor und / oder Methoden neu entwerfen.

* Indem Sie nicht richtig designen und die Klasse von den richtigen Informationen abhalten, riskieren Sie, dass Ihre Klasse nicht an einer, sondern möglicherweise an mehreren Stellen unterbrochen wird.

* Wenn Sie die Ausnahmen und Fehlermeldungen schlecht geschrieben haben, wird Ihre Klasse noch mehr über sich selbst verraten.

* Wenn der Außenseiter schließlich wissen soll, dass er a, b und c initialisieren und dann d (), e (), f (int) aufrufen muss, liegt eine Undichtigkeit in Ihrer Abstraktion vor.

Anzeigename
quelle
0

Da Sie angegeben haben, dass Sie C # verwenden, besteht eine praktikable Lösung für Ihre Situation darin, die Roslyn Code Analyzer zu nutzen . Auf diese Weise können Sie Verstöße sofort abfangen und sogar Codekorrekturen vorschlagen .

Eine Möglichkeit zur Implementierung besteht darin, die zeitlich gekoppelten Methoden mit einem Attribut zu versehen, das die Reihenfolge angibt, in der die Methoden aufgerufen werden müssen 1 . Wenn der Analysator diese Attribute in einer Klasse findet, überprüft er, ob die Methoden in der richtigen Reihenfolge aufgerufen werden. Dadurch würde Ihre Klasse ungefähr so ​​aussehen:

public abstract class Foo
{
   [TemporallyCoupled(1)]
   public abstract void Init();

   [TemporallyCoupled(2)]
   public abstract void DoBar();

   [TemporallyCoupled(3)]
   public abstract void Close();
}

1: Ich habe noch nie einen Roslyn Code Analyzer geschrieben, daher ist dies möglicherweise nicht die beste Implementierung. Die Idee, Roslyn Code Analyzer zu verwenden, um zu überprüfen, ob Ihre API korrekt verwendet wird, ist jedoch 100% einwandfrei.

Erik
quelle
-1

Ich habe dieses Problem zuvor mit einem privaten Konstruktor und einer statischen MakeFooManager()Methode innerhalb der Klasse gelöst . Beispiel:

public class FooManager
{
     public string Foo { get; private set; }
     public string Bar { get; private set; }

     private FooManager(string foo, string bar)
     {
         Foo = foo;
         Bar = bar;
     }

     public static FooManager MakeFooManager(string foo, string bar)
     {
         // Can do other checks here too.
         if(foo == null || bar == null)
         {
             return null;
         }
         else
         {
             return new FooManager(foo, bar);
         }
     }
}

Da ein Konstruktor implementiert ist, kann niemand eine Instanz von erstellen, FooManagerohne dies zu durchlaufen MakeFooManager().

WillB3
quelle
1
dies scheint nur zu wiederholen Punkt gemacht und erklärte in einer vor Antwort , die mehrere Stunden vor dem gebucht wurde
gnat
1
hat das überhaupt einen vorteil gegenüber dem null-checken im ctor? Es scheint, als hätten Sie gerade eine Methode erstellt, die nichts anderes tut, als eine andere Methode einzuschließen. Warum?
Sara
Warum sind foo- und bar-Member-Variablen?
Patrick M
-1

Es gibt verschiedene Ansätze:

  1. Bilden Sie die Klasse einfach, richtig zu verwenden und hart, falsch zu verwenden. Einige der anderen Antworten konzentrieren sich ausschließlich auf diesen Punkt, daher erwähne ich einfach RAII und das Builder-Muster .

  2. Achten Sie darauf, wie Sie Kommentare verwenden. Wenn die Klasse selbsterklärend ist, verfassen Sie keine Kommentare. * Auf diese Weise werden Ihre Kommentare mit größerer Wahrscheinlichkeit gelesen, wenn die Klasse sie tatsächlich benötigt. Und wenn Sie einen dieser seltenen Fälle haben, in denen eine Klasse schwer korrekt zu verwenden ist und Kommentare benötigt, geben Sie Beispiele an.

  3. Verwenden Sie Asserts in Ihren Debugbuilds.

  4. Ausnahmen auslösen, wenn die Verwendung der Klasse ungültig ist.

* Dies sind die Arten von Kommentaren, die Sie nicht schreiben sollten:

// gets Foo
GetFoo();
Peter
quelle