Wie würde ich eine Schnittstelle so gestalten, dass klar ist, welche Eigenschaften ihren Wert ändern können und welche konstant bleiben?

12

Ich habe ein Designproblem in Bezug auf .NET-Eigenschaften.

interface IX
{
    Guid Id { get; }
    bool IsInvalidated { get; }
    void Invalidate();
}

Problem:

Diese Schnittstelle verfügt über zwei schreibgeschützte Eigenschaften Idund IsInvalidated. Die Tatsache, dass sie schreibgeschützt sind, ist jedoch keine Garantie dafür, dass ihre Werte konstant bleiben.

Nehmen wir an, ich wollte klarstellen, dass ...

  • Id stellt einen konstanten Wert dar (der daher sicher zwischengespeichert werden kann), während
  • IsInvalidatedkann seinen Wert während der Lebensdauer eines IXObjekts ändern (und sollte daher nicht zwischengespeichert werden).

Wie könnte ich interface IXdiesen Vertrag so ändern , dass er ausreichend explizit ist?

Meine eigenen drei Lösungsversuche:

  1. Die Schnittstelle ist bereits gut gestaltet. Das Vorhandensein einer aufgerufenen Methode Invalidate()lässt einen Programmierer darauf schließen, dass der Wert der gleichnamigen Eigenschaft IsInvalidateddavon betroffen sein könnte.

    Dieses Argument gilt nur in Fällen, in denen die Methode und die Eigenschaft ähnlich benannt sind.

  2. Ergänzen Sie diese Schnittstelle mit einem Ereignis IsInvalidatedChanged:

    bool IsInvalidated { get; }
    event EventHandler IsInvalidatedChanged;

    Das Vorhandensein eines …ChangedEreignisses für IsInvalidatedbesagt, dass diese Eigenschaft ihren Wert ändern kann, und das Fehlen eines ähnlichen Ereignisses für Idist ein Versprechen, dass diese Eigenschaft ihren Wert nicht ändern wird.

    Ich mag diese Lösung, aber es gibt viele zusätzliche Dinge, die möglicherweise überhaupt nicht verwendet werden.

  3. Ersetzen Sie die Eigenschaft IsInvalidateddurch eine Methode IsInvalidated():

    bool IsInvalidated();

    Dies könnte eine zu subtile Änderung sein. Es soll ein Hinweis sein, dass ein Wert jedes Mal neu berechnet wird - was nicht nötig wäre, wenn es eine Konstante wäre. Das MSDN-Thema "Auswählen zwischen Eigenschaften und Methoden" enthält Folgendes:

    Verwenden Sie in den folgenden Situationen eine Methode anstelle einer Eigenschaft. […] Die Operation gibt bei jedem Aufruf ein anderes Ergebnis zurück, auch wenn sich die Parameter nicht ändern.

Welche Antworten erwarte ich?

  • Ich interessiere mich am meisten für ganz andere Lösungen des Problems, zusammen mit einer Erklärung, wie sie meine obigen Versuche schlagen.

  • Wenn meine Versuche logisch fehlerhaft sind oder wesentliche, noch nicht erwähnte Nachteile aufweisen, so dass nur eine (oder keine) Lösung übrig bleibt, würde ich gerne erfahren, wo ich einen Fehler gemacht habe.

    Wenn die Mängel geringfügig sind und mehr als eine Lösung nach Berücksichtigung verbleibt, kommentieren Sie bitte.

  • Zumindest würde ich gerne eine Rückmeldung darüber erhalten, welche Lösung Sie bevorzugen und aus welchem ​​Grund.

stakx
quelle
Hat nicht IsInvalidatedein benötigen private set?
James
2
@James: Nicht unbedingt, weder in der Interface-Deklaration noch in einer Implementierung:class Foo : IFoo { private bool isInvalidated; public bool IsInvalidated { get { return isInvalidated; } } public void Invalidate() { isInvalidated = true; } }
stakx
@James Eigenschaften in Interfaces stimmen nicht mit automatischen Eigenschaften überein, obwohl sie dieselbe Syntax verwenden.
Svick
Ich habe mich gefragt: Haben Schnittstellen "die Macht", ein solches Verhalten aufzuzwingen? Sollte dieses Verhalten nicht an jede Implementierung delegiert werden? Ich denke, wenn Sie Dinge auf dieser Ebene erzwingen möchten, sollten Sie überlegen, eine abstrakte Klasse zu erstellen, damit Subtypen von ihr erben und eine bestimmte Schnittstelle implementieren können. (übrigens macht meine Begründung Sinn?)
Heltonbiker
@heltonbiker Leider erlauben die meisten Sprachen (ich weiß) nicht, solche Einschränkungen für Typen auszudrücken, aber sie sollten es definitiv . Dies ist eine Frage der Spezifikation, nicht der Implementierung. Was meiner Meinung nach fehlt, ist die Möglichkeit, Klasseninvarianten und Nachbedingungen direkt in der Sprache auszudrücken . Im Idealfall sollten wir uns InvalidStateExceptionzum Beispiel nie um s kümmern müssen , aber ich bin mir nicht sicher, ob dies überhaupt theoretisch möglich ist. Wäre aber nett.
proskor

Antworten:

2

Ich würde Lösung 3 vor 1 und 2 bevorzugen.

Mein Problem mit Lösung 1 ist : Was passiert, wenn es keine InvalidateMethode gibt? Nehmen Sie eine Schnittstelle mit einer zurückgegebenen IsValidEigenschaft an DateTime.Now > MyExpirationDate. Möglicherweise benötigen Sie hier keine explizite SetInvalidMethode. Was ist, wenn sich eine Methode auf mehrere Eigenschaften auswirkt? Nehmen Sie einen Verbindungstyp mit IsOpenund IsConnectedEigenschaften an - beide sind von der CloseMethode betroffen .

Lösung 2 : Wenn der einzige Punkt des Ereignisses darin besteht, den Entwicklern mitzuteilen, dass eine ähnlich benannte Eigenschaft bei jedem Aufruf andere Werte zurückgeben kann, würde ich dringend davon abraten. Sie sollten Ihre Schnittstellen kurz und übersichtlich halten. Darüber hinaus ist möglicherweise nicht jede Implementierung in der Lage, dieses Ereignis für Sie auszulösen. Ich werde mein IsValidBeispiel von oben wiederverwenden : Sie müssten einen Timer implementieren und ein Ereignis auslösen, wenn Sie erreichen MyExpirationDate. Wenn dieses Ereignis Teil Ihrer öffentlichen Schnittstelle ist, erwarten Benutzer der Schnittstelle, dass dieses Ereignis funktioniert.

Davon abgesehen sind diese Lösungen nicht schlecht. Das Vorhandensein einer Methode oder eines Ereignisses wird anzeigen, dass eine ähnlich benannte Eigenschaft bei jedem Aufruf unterschiedliche Werte zurückgeben kann. Ich sage nur, dass sie allein nicht ausreichen, um diese Bedeutung immer wieder zu vermitteln.

Lösung 3 ist das, wonach ich streben würde. Wie bereits erwähnt, funktioniert dies möglicherweise nur für C # -Entwickler. Für mich als C # -Dev bedeutet die Tatsache, dass IsInvalidatedes sich nicht um eine Eigenschaft handelt, sofort, dass es sich nicht nur um einen einfachen Accessor handelt, sondern dass hier etwas vor sich geht. Dies gilt jedoch nicht für alle, und wie MainMa hervorhob, ist das .NET-Framework selbst hier nicht konsistent.

Wenn Sie Lösung 3 verwenden möchten, würde ich empfehlen, sie als Konvention zu deklarieren und das gesamte Team zu beauftragen. Dokumentation ist auch wichtig, glaube ich. Meiner Meinung es ist eigentlich ziemlich einfach Hinweis Werte ändern: „ true zurück , wenn der Wert immer noch gültig “ „ zeigt an, ob die Verbindung ist bereits geöffnet “ vs. „ gibt true zurück , wenn dieses Objekt ist gültig “. Es würde überhaupt nicht schaden, in der Dokumentation genauer zu sein.

Mein Rat wäre also:

Erklären Sie Lösung 3 zu einer Konvention und befolgen Sie sie konsequent. Machen Sie in der Dokumentation der Eigenschaften deutlich, ob eine Eigenschaft sich ändernde Werte aufweist. Entwickler, die mit einfachen Eigenschaften arbeiten, gehen korrekterweise davon aus, dass sie sich nicht ändern (dh sie ändern sich nur, wenn der Objektstatus geändert wird). Entwickler , ein Verfahren zu begegnen , dass klingt wie es eine Eigenschaft sein könnte ( Count(), Length(), IsOpen()) wissen , dass some ist hier los und (hoffentlich) die Methode docs lesen , zu verstehen , was genau die Methode funktioniert und wie es sich verhält.

enzi
quelle
Diese Frage hat sehr gute Antworten erhalten, und es ist schade, dass ich nur eine davon akzeptieren kann. Ich habe Ihre Antwort gewählt, weil sie eine gute Zusammenfassung einiger Punkte enthält, die in den anderen Antworten angesprochen wurden, und weil ich mich mehr oder weniger dazu entschlossen habe. Vielen Dank an alle, die eine Antwort gepostet haben!
Stakx
8

Es gibt eine vierte Lösung: Verlassen Sie sich auf Dokumentation .

Woher weißt du, dass diese stringKlasse unveränderlich ist? Sie wissen das einfach, weil Sie die MSDN-Dokumentation gelesen haben. StringBuilderAuf der anderen Seite ist es veränderlich, weil die Dokumentation Ihnen das wieder sagt.

/// <summary>
/// Represents an index of an element stored in the database.
/// </summary>
private interface IX
{
    /// <summary>
    /// Gets the immutable globally unique identifier of the index, the identifier being
    /// assigned by the database when the index is created.
    /// </summary>
    Guid Id { get; }

    /// <summary>
    /// Gets a value indicating whether the index is invalidated, which means that the
    /// indexed element is no longer valid and should be reloaded from the database. The
    /// index can be invalidated by calling the <see cref="Invalidate()" /> method.
    /// </summary>
    bool IsInvalidated { get; }

    /// <summary>
    /// Invalidates the index, without forcing the indexed element to be reloaded from the
    /// database.
    /// </summary>
    void Invalidate();
}

Ihre dritte Lösung ist nicht schlecht, aber .NET Framework folgt ihr nicht . Beispielsweise:

StringBuilder.Length
DateTime.Now

sind Eigenschaften, aber erwarten Sie nicht, dass sie jedes Mal konstant bleiben.

In .NET Framework selbst wird durchweg Folgendes verwendet:

IEnumerable<T>.Count()

ist eine Methode, während:

IList<T>.Length

ist eine Eigenschaft. Im ersten Fall kann das Ausführen von Aufgaben zusätzliche Arbeit erfordern, z. B. das Abfragen der Datenbank. Diese zusätzliche Arbeit kann einige Zeit in Anspruch nehmen . Es wird erwartet, dass eine Eigenschaft nur eine kurze Zeit benötigt: Die Länge kann sich während der Lebensdauer der Liste zwar ändern, für die Rückgabe ist jedoch praktisch nichts erforderlich.


Bei Klassen zeigt ein einfacher Blick auf den Code, dass sich der Wert einer Eigenschaft während der Lebensdauer des Objekts nicht ändert. Zum Beispiel,

public class Product
{
    private readonly int price;

    public Product(int price)
    {
        this.price = price;
    }

    public int Price
    {
        get
        {
            return this.price;
        }
    }
}

ist klar: der preis bleibt gleich.

Leider können Sie nicht dasselbe Muster auf eine Schnittstelle anwenden, und da C # in diesem Fall nicht aussagekräftig genug ist, sollte Dokumentation (einschließlich XML-Dokumentation und UML-Diagramme) verwendet werden, um die Lücke zu schließen.

Arseni Mourzenko
quelle
Auch die Richtlinie „Keine zusätzliche Arbeit“ wird nicht absolut konsequent angewendet. Beispielsweise Lazy<T>.Valuekann die Berechnung sehr lange dauern (wenn zum ersten Mal darauf zugegriffen wird).
Svick
1
Meiner Meinung nach spielt es keine Rolle, ob das .NET-Framework der Konvention von Lösung 3 folgt. Ich erwarte, dass Entwickler klug genug sind, um zu wissen, ob sie mit internen Typen oder Framework-Typen arbeiten. Entwickler, die nicht wissen, dass sie keine Dokumente lesen, und daher würde Lösung 4 auch nicht helfen. Wenn Lösung 3 als Unternehmens- / Teamkonvention implementiert wird, ist es meines Erachtens egal, ob andere APIs dieser Konvention folgen oder nicht (obwohl dies natürlich sehr geschätzt wird).
Enzi
4

Meine 2 Cent:

Option 3: Als Nicht-C # -Er wäre dies kein großer Hinweis. Anhand des Zitats, das Sie vorlegen, sollte es jedoch erfahrenen C # -Programmierern klar sein, dass dies etwas bedeutet. Sie sollten jedoch weiterhin Dokumente dazu hinzufügen.

Option 2: Gehen Sie nicht dorthin, es sei denn, Sie planen, Ereignisse zu allen Änderungen hinzuzufügen.

Andere Optionen:

  • Umbenennen der Schnittstelle IXMutable, damit klar ist, dass sie ihren Status ändern kann. Der einzige unveränderliche Wert in Ihrem Beispiel ist der id, von dem normalerweise angenommen wird, dass er auch in veränderlichen Objekten unveränderlich ist.
  • Ohne es zu erwähnen. Schnittstellen können Verhalten nicht so gut beschreiben, wie wir es uns wünschen. Dies liegt zum Teil daran, dass Sie in der Sprache Dinge wie "Diese Methode gibt immer den gleichen Wert für ein bestimmtes Objekt zurück" oder "Wenn Sie diese Methode mit einem Argument x <0 aufrufen, wird eine Ausnahme ausgelöst."
  • Außer, dass es einen Mechanismus gibt, mit dem die Sprache erweitert und genauere Informationen zu Konstrukten bereitgestellt werden können - Anmerkungen / Attribute . Sie benötigen manchmal etwas Arbeit, aber Sie können beliebig komplexe Dinge über Ihre Methoden festlegen. Suchen oder erfinden Sie eine Anmerkung mit der Aufschrift "Der Wert dieser Methode / Eigenschaft kann sich während der Lebensdauer eines Objekts ändern" und fügen Sie sie der Methode hinzu. Möglicherweise müssen Sie einige Streiche spielen, um die Implementierungsmethoden zum "Erben" zu bewegen, und Benutzer müssen möglicherweise das Dokument für diese Annotation lesen, aber es ist ein Tool, mit dem Sie genau sagen können, was Sie sagen möchten, anstatt darauf hinzuweisen daran.
aviv
quelle
3

Eine andere Möglichkeit wäre, die Schnittstelle zu teilen: Angenommen, nach dem Aufruf Invalidate()jedes Aufrufs von IsInvalidatedsollte derselbe Wert zurückgegeben werden true, scheint es keinen Grund zu geben, IsInvalidatedfür denselben aufgerufenen Teil aufzurufen Invalidate().

Daher schlage ich vor, dass Sie entweder auf Ungültigkeit prüfen oder es verursachen , aber es erscheint unvernünftig, beides zu tun. Daher ist es sinnvoll, eine Schnittstelle, die die Invalidate()Operation enthält, für den ersten Teil und eine andere Schnittstelle zum Prüfen ( IsInvalidated) für den anderen Teil anzubieten . Da es ziemlich offensichtlich aus der Signatur der ersten Schnittstelle ist , dass es die Instanz bewirkt , dass für ungültig erklärt werden, ist die verbleibende Frage (für die zweite Schnittstelle) in der Tat ganz allgemein: Wie bestimmen Sie, ob der angegebene Typ ist unveränderlich .

interface Invalidatable
{
    bool IsInvalidated { get; }
}

Ich weiß, das beantwortet die Frage nicht direkt, aber es reduziert sie zumindest auf die Frage, wie man einen unveränderlichen Typ markiert , was ein weit verbreitetes und verstandenes Problem ist. Wenn Sie beispielsweise annehmen, dass alle Typen, sofern nicht anders definiert, veränderbar sind, können Sie daraus schließen, dass die zweite Schnittstelle ( IsInvalidated) veränderbar ist und daher den Wert von IsInvalidatedvon Zeit zu Zeit ändern kann . Angenommen, die erste Schnittstelle sieht folgendermaßen aus (Pseudosyntax):

[Immutable]
interface IX
{
    Guid Id { get; }
    void Invalidate();
}

Da es als unveränderlich markiert ist, wissen Sie , dass sich die ID nicht ändert. Natürlich führt der Aufruf von invalidate zu einer Änderung des Zustands der Instanz, aber diese Änderung kann über diese Schnittstelle nicht beobachtet werden.

proskor
quelle
Keine schlechte Idee! Ich würde jedoch argumentieren, dass es falsch ist, eine Schnittstelle zu markieren, [Immutable]solange sie Methoden umfasst, deren einziger Zweck darin besteht, eine Mutation zu verursachen. Ich würde die Invalidate()Methode auf die InvalidatableSchnittstelle verschieben.
Stakx
1
@ Stakx Ich bin anderer Meinung. :) Ich denke, Sie verwechseln die Konzepte der Unveränderlichkeit und Reinheit (keine Nebenwirkungen). Tatsächlich gibt es aus Sicht der vorgeschlagenen Schnittstelle IX überhaupt keine beobachtbare Mutation. Wann immer Sie anrufen Id, erhalten Sie jedes Mal den gleichen Wert. Gleiches gilt für Invalidate()(Sie bekommen nichts, da der Rückgabetyp ist void). Daher ist der Typ unveränderlich. Natürlich, werden Sie haben Nebenwirkungen , aber Sie werden nicht in der Lage zu beobachten , sie über die Schnittstelle IX, so werden sie an den Kunden keine Auswirkungen haben IX.
proskor
Okay, wir können uns darauf einigen, dass dies IXunveränderlich ist. Und dass sie Nebenwirkungen sind; Sie werden einfach auf zwei aufgeteilte Schnittstellen verteilt, sodass sich die Clients nicht darum kümmern müssen (im Fall von IX) oder offensichtlich sind, was die Nebenwirkung sein könnte (im Fall von Invalidatable). Aber warum nicht bewegen Invalidatezu über Invalidatable? Das würde IXunveränderlich und rein machen und Invalidatablewürde weder veränderlicher noch unreiner werden (weil es bereits beides ist).
Stakx
(Ich verstehe, dass in einem realen Szenario die Art der Schnittstellenaufteilung wahrscheinlich mehr von praktischen Anwendungsfällen als von technischen Fragen abhängt, aber ich versuche, Ihre Argumentation hier vollständig zu verstehen.)
stakx
1
@stakx Zunächst haben Sie nach weiteren Möglichkeiten gefragt, um die Semantik Ihres Interfaces irgendwie zu verdeutlichen. Meine Idee war es, das Problem auf das der Unveränderlichkeit zu reduzieren, was eine Aufteilung der Schnittstelle erforderte. Die Aufteilung war jedoch nicht nur erforderlich, um eine Schnittstelle unveränderlich zu machen, sondern auch sehr sinnvoll, da es keinen Grund gibt, beide Invalidate()und IsInvalidateddenselben Benutzer der Schnittstelle aufzurufen . Wenn Sie anrufen Invalidate(), sollten Sie wissen, dass es ungültig wird. Warum sollten Sie das überprüfen? Somit können Sie zwei unterschiedliche Schnittstellen für unterschiedliche Zwecke bereitstellen.
Proskor