Warum eine Klasse erben und keine Eigenschaften hinzufügen?

39

Ich habe in unserer (ziemlich großen) Codebasis einen Vererbungsbaum gefunden, der ungefähr so ​​aussieht:

public class NamedEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class OrderDateInfo : NamedEntity { }

Soweit ich das beurteilen kann, wird dies hauptsächlich zum Binden von Frontend-Inhalten verwendet.

Für mich ist dies sinnvoll, da es der Klasse einen konkreten Namen gibt, anstatt sich auf das Generikum zu verlassen NamedEntity. Andererseits gibt es eine Reihe solcher Klassen, die einfach keine zusätzlichen Eigenschaften haben.

Gibt es Nachteile bei diesem Ansatz?

Robotron
quelle
25
Oberseite nicht erwähnt: Sie können Methoden, die nur für OrderDateInfos relevant sind, von denen unterscheiden, die für andere NamedEntitys relevant sind
Caleth
8
Aus demselben Grund , den Sie nicht verwenden würden , wenn Sie beispielsweise eine IdentifierKlasse hätten, die nichts damit zu tun hat, NamedEntitysondern sowohl ein Idals auch eine NameEigenschaft benötigt NamedEntity. Der Kontext und die ordnungsgemäße Verwendung einer Klasse sind mehr als nur die Eigenschaften und Methoden, die sie enthält.
Neil

Antworten:

15

Für mich ist dies sinnvoll, da es der Klasse einen konkreten Namen gibt, anstatt sich auf die generische NamedEntity zu verlassen. Andererseits gibt es eine Reihe solcher Klassen, die einfach keine zusätzlichen Eigenschaften haben.

Gibt es Nachteile bei diesem Ansatz?

Der Ansatz ist nicht schlecht, aber es gibt bessere Lösungen. Kurz gesagt, eine Schnittstelle wäre dafür eine viel bessere Lösung. Der Hauptgrund , warum Schnittstellen und Vererbung hier anders ist , weil man nur von einer Klasse erben kann, aber man kann viele Schnittstellen implementieren .

Angenommen, Sie haben Entitäten und geprüfte Entitäten benannt. Sie haben mehrere Entitäten:

Oneist weder eine geprüfte noch eine benannte Stelle. Das ist einfach:

public class One 
{ }

Twoist eine benannte Stelle, aber keine geprüfte Stelle. Das ist im Wesentlichen, was Sie jetzt haben:

public class NamedEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Two : NamedEntity 
{ }

Threeist sowohl ein benannter als auch ein geprüfter Eintrag. Hier stößt man auf ein Problem. Sie können eine AuditedEntityBasisklasse erstellen , aber nicht beideThree erben und : AuditedEntity NamedEntity

public class AuditedEntity
{
    public DateTime CreatedOn { get; set; }
    public DateTime UpdatedOn { get; set; }
}

public class Three : NamedEntity, AuditedEntity  // <-- Compiler error!
{ }

Sie könnten jedoch auch von einer Vermeidung des Problems denken , indem AuditedEntityvererben NamedEntity. Dies ist ein cleverer Hack, um sicherzustellen, dass jede Klasse nur (direkt) von einer anderen Klasse erben muss.

public class AuditedEntity : NamedEntity
{
    public DateTime CreatedOn { get; set; }
    public DateTime UpdatedOn { get; set; }
}

public class Three : AuditedEntity
{ }

Das funktioniert immer noch. Aber was Sie hier getan haben, ist, dass jede geprüfte Entität von Natur aus auch eine benannte Entität ist . Das bringt mich zu meinem letzten Beispiel. Fourist eine geprüfte Entität, aber keine benannte Entität. Aber Sie können nicht von Fourerben lassen, AuditedEntityda Sie es dann auch NamedEntityaufgrund der Vererbung zwischen AuditedEntity andNamedEntity` machen würden.

Wenn Sie die Vererbung verwenden, gibt es keine Möglichkeit, beides zu erreichen Threeund zu Fourfunktionieren, es sei denn, Sie beginnen mit dem Duplizieren von Klassen (was eine ganze Reihe neuer Probleme aufwirft).

Mit Hilfe von Schnittstellen kann dies leicht erreicht werden:

public interface INamedEntity
{
    int Id { get; set; }
    string Name { get; set; }
}

public interface IAuditedEntity
{
    DateTime CreatedOn { get; set; }
    DateTime UpdatedOn { get; set; }
}

public class One 
{ }

public class Two : INamedEntity 
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Three : INamedEntity, IAuditedEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
    DateTime CreatedOn { get; set; }
    DateTime UpdatedOn { get; set; }
}

public class Four : IAuditedEntity
{
    DateTime CreatedOn { get; set; }
    DateTime UpdatedOn { get; set; }
}

Der einzige kleine Nachteil hierbei ist, dass Sie die Schnittstelle noch implementieren müssen. Sie profitieren jedoch von allen Vorteilen eines gemeinsamen wiederverwendbaren Typs, ohne die Nachteile zu haben, die sich ergeben, wenn Sie Variationen mehrerer gemeinsamer Typen für eine bestimmte Entität benötigen.

Ihr Polymorphismus bleibt jedoch erhalten:

var one = new One();
var two = new Two();
var three = new Three();
var four = new Four();

public void HandleNamedEntity(INamedEntity namedEntity) {}
public void HandleAuditedEntity(IAuditedEntity auditedEntity) {}

HandleNamedEntity(one);    //Error - not a named entity
HandleNamedEntity(two);
HandleNamedEntity(three);  
HandleNamedEntity(four);   //Error - not a named entity

HandleAuditedEntity(one);    //Error - not an audited entity
HandleAuditedEntity(two);    //Error - not an audited entity
HandleAuditedEntity(three);  
HandleAuditedEntity(four);

Andererseits gibt es eine Reihe solcher Klassen, die einfach keine zusätzlichen Eigenschaften haben.

Dies ist eine Variation des Markierungsschnittstellenmusters , bei dem Sie eine leere Schnittstelle implementieren, um anhand des Schnittstellentyps zu überprüfen, ob eine bestimmte Klasse mit dieser Schnittstelle "markiert" ist.
Sie verwenden geerbte Klassen anstelle von implementierten Schnittstellen, aber das Ziel ist das gleiche, daher werde ich es als "markierte Klasse" bezeichnen.

Mit dem Nennwert ist nichts falsch an Marker-Interfaces / -Klassen. Sie sind syntaktisch und technisch gültig und haben keine inhärenten Nachteile, vorausgesetzt, der Marker ist universell gültig (zur Kompilierungszeit) und nicht bedingt .

Genau so sollten Sie zwischen verschiedenen Ausnahmen unterscheiden, auch wenn diese Ausnahmen im Vergleich zur Basismethode keine zusätzlichen Eigenschaften / Methoden aufweisen.

Es ist also von Natur aus nichts Falsches daran, aber ich würde raten, dies vorsichtig zu verwenden, um sicherzustellen, dass Sie nicht nur versuchen, einen vorhandenen Architekturfehler mit schlecht entworfenem Polymorphismus zu vertuschen.

Flater
quelle
4
"Sie können nur von einer Klasse erben, aber Sie können viele Schnittstellen implementieren." Das gilt jedoch nicht für jede Sprache. Einige Sprachen erlauben die Vererbung mehrerer Klassen.
Kenneth K.
Wie Kenneth sagte, haben zwei sehr beliebte Sprachen die Fähigkeit zur Mehrfachvererbung, C ++ und Python. Klasse threein Ihrem Beispiel für die Fallstricke, keine Schnittstellen zu verwenden, ist beispielsweise in C ++ tatsächlich gültig. Tatsächlich funktioniert dies für alle vier Beispiele ordnungsgemäß. Tatsächlich scheint dies alles eine Einschränkung von C # als Sprache zu sein und keine Warnung, wenn Sie keine Vererbung verwenden, wenn Sie Schnittstellen verwenden sollten.
Opa
63

Dies ist etwas, das ich benutze, um zu verhindern, dass Polymorphismus verwendet wird.

Angenommen, Sie haben 15 verschiedene Klassen, die NamedEntityirgendwo in ihrer Vererbungskette eine Basisklasse haben , und Sie schreiben eine neue Methode, die nur auf anwendbar ist OrderDateInfo.

Sie "könnten" einfach die Signatur schreiben als

void MyMethodThatShouldOnlyTakeOrderDateInfos(NamedEntity foo)

Und hoffe und bete, dass niemand das Typensystem missbraucht, um ein einzuschieben FooBazNamedEntity.

Oder du "könntest" einfach schreiben void MyMethod(OrderDateInfo foo). Nun wird das vom Compiler erzwungen. Einfach, elegant und nicht darauf angewiesen, dass Menschen keine Fehler machen.

Auch als @candied_orange wies darauf hin , Ausnahmen sind ein großer Fall dafür. Sehr selten (und ich meine sehr, sehr, sehr selten) möchten Sie jemals alles mit fangen catch (Exception e). Mit größerer Wahrscheinlichkeit möchten Sie eine SqlExceptionoder eine FileNotFoundExceptionoder eine benutzerdefinierte Ausnahme für Ihre Anwendung abfangen . Diese Klassen bieten oftmals nicht mehr Daten oder Funktionen als die Basisklasse Exception, aber sie ermöglichen es Ihnen, zu unterscheiden, was sie darstellen, ohne sie untersuchen und ein Typfeld überprüfen oder nach spezifischem Text suchen zu müssen.

Insgesamt ist es ein Trick, das Typensystem so zu gestalten, dass Sie eine engere Menge von Typen verwenden können als mit einer Basisklasse. Ich meine, Sie könnten alle Ihre Variablen und Argumente so definieren Object, dass sie den Typ haben , aber das würde Ihre Arbeit nur erschweren, nicht wahr?

Becuzz
quelle
4
Verzeihen Sie meine Neuheit, aber ich bin neugierig, warum es nicht besser wäre, OrderDateInfo HAVE als Eigenschaft zu einem NamedEntity-Objekt zu machen.
Adam B
9
@AdamB Das ist eine gültige Designauswahl. Ob es besser ist oder nicht, hängt unter anderem davon ab, wofür es verwendet wird. Im Ausnahmefall kann ich keine neue Klasse mit einer Ausnahmeeigenschaft erstellen, da ich sie dann in der Sprache (C # für mich) nicht auslösen kann. Möglicherweise gibt es andere Überlegungen zum Design, die die Verwendung der Komposition anstelle der Vererbung ausschließen. Oft ist die Komposition besser. Es hängt nur von Ihrem System ab.
Becuzz
17
@AdamB Aus dem gleichen Grund, wenn Sie von einer Basisklasse erben und Methoden oder Eigenschaften hinzufügen, die der Basis fehlen, erstellen Sie keine neue Klasse und setzen die Basisklasse als Eigenschaft. Es gibt einen Unterschied zwischen einem Menschen als Säugetier und einem Säugetier.
Logarr
8
@Logarr stimmt, es gibt einen Unterschied zwischen mir als Säugetier und einem Säugetier. Aber wenn ich meinem inneren Säugetier treu bleibe, werden Sie den Unterschied nie erkennen. Sie fragen sich vielleicht, warum ich so viele verschiedene Arten von Milch aus einer Laune heraus geben kann.
candied_orange
5
@AdamB Sie können dies tun, und dies wird als Kompositionsüberschreitung bezeichnet. Dafür würden Sie aber NamedEntityzB auf umbenennen EntityName, damit die Komposition bei der Beschreibung Sinn macht.
Sebastian Redl
28

Dies ist meine Lieblingsvererbung. Ich benutze es meistens für Ausnahmen, die bessere, spezifischere Namen verwenden könnten

Das übliche Problem der Vererbung, die zu langen Ketten führt und das Jojo-Problem verursacht, trifft hier nicht zu, da es nichts gibt, das Sie zum Verketten motiviert.

kandierte_orange
quelle
1
Hmm, das sind gute Ausnahmen. Ich wollte sagen, dass es schrecklich war, das zu tun. Aber Sie haben mich mit einem exzellenten Anwendungsfall überredet.
David Arno
Jo-Jo Ho und eine Flasche Rum. Es ist selten, dass der Orlop ein Jahr alt ist. Es leckt oft aus Mangel an Eiche zwischen den Brettern. Zu Davey Jones Schließfach mit den Landräubern, was es gebaut hat. ÜBERSETZUNG: Es ist selten, dass eine Vererbungskette so gut ist, dass die Basisklasse abstrakt / effektiv ignoriert werden kann.
Radarbob
Ich denke , es ist erwähnenswert, dass eine neue Klasse von einer anderen erbt hat eine neue Eigenschaft hinzufügen - den Namen der neuen Klasse. Genau das verwenden Sie, wenn Sie Ausnahmen mit besseren Namen erstellen.
Logan Pickup
1

IMHO ist das Klassendesign falsch. Es sollte sein.

public class EntityName
{
    public int Id { get; set; }
    public string Text { get; set; }
}

public class OrderDateInfo
{
    public EntityName Name { get; set; }
}

OrderDateInfoHAS A Nameist eine natürlichere Beziehung und erzeugt zwei leicht verständliche Klassen, die die ursprüngliche Frage nicht provoziert hätten.

Jede Methode, die NamedEntityals Parameter akzeptiert wurde , sollte nur an den Eigenschaften Idund interessiert sein. NameDaher sollten solche Methoden so geändert werden, dass sie EntityNamestattdessen akzeptieren .

Der einzige technische Grund, den ich für das ursprüngliche Design akzeptieren würde, ist die Eigentumsbindung, die das OP erwähnte. Ein Mist-Framework wäre nicht in der Lage, mit der zusätzlichen Eigenschaft fertig zu werden und sich daran zu binden object.Name.Id. Aber wenn Ihr verbindliches Framework damit nicht zurechtkommt, müssen Sie der Liste weitere technische Schulden hinzufügen.

Ich würde der Antwort von @ Flater zustimmen, aber mit vielen Schnittstellen, die Eigenschaften enthalten, schreiben Sie am Ende eine Menge Code, selbst mit den schönen automatischen Eigenschaften von C #. Stellen Sie sich vor, Sie machen es in Java!

Batwad
quelle
1

Klassen legen das Verhalten offen, und Datenstrukturen legen Daten offen.

Ich sehe die Schlüsselwörter der Klasse, aber ich sehe kein Verhalten. Dies bedeutet, dass ich diese Klasse als Datenstruktur anzeigen würde. In diesem Sinne werde ich Ihre Frage umformulieren als

Warum eine gemeinsame Datenstruktur der obersten Ebene?

Sie können also den Datentyp der obersten Ebene verwenden. Dies ermöglicht die Nutzung des Typsystems, um eine Richtlinie für große Mengen unterschiedlicher Datenstrukturen zu gewährleisten, indem sichergestellt wird, dass alle Eigenschaften vorhanden sind.

Warum eine Datenstruktur, die die oberste Ebene enthält, aber nichts hinzufügt?

Sie können also den Datentyp der unteren Ebene verwenden. Dies ermöglicht das Einfügen von Hinweisen in das Typisierungssystem, um den Zweck der Variablen auszudrücken

Top level data structure - Named
   property: name;

Bottom level data structure - Person

In der obigen Hierarchie ist es zweckmäßig anzugeben, dass eine Person benannt ist, damit Personen ihren Namen erhalten und ändern können. Es mag zwar sinnvoll sein, der PersonDatenstruktur zusätzliche Eigenschaften hinzuzufügen , das Problem, das wir lösen, erfordert jedoch keine perfekte Modellierung der Person. Daher haben wir es versäumt, gemeinsame Eigenschaften wie ageusw. hinzuzufügen .

Es ist also eine Hebelwirkung des Schreibsystems, um die Absicht dieses benannten Elements in einer Weise auszudrücken, die nicht mit Aktualisierungen (wie Dokumentation) bricht und zu einem agespäteren Zeitpunkt problemlos erweitert werden kann (wenn Sie feststellen, dass Sie das Feld später wirklich benötigen) ).

Edwin Buck
quelle