Generics vs Common Interface?

20

Ich kann mich nicht erinnern, wann ich das letzte Mal einen Sammelkurs geschrieben habe. Jedes Mal, wenn ich denke, ich brauche es, nachdem ich nachgedacht habe, komme ich zu dem Schluss, dass ich es nicht tue.

Die zweite Antwort auf diese Frage veranlasste mich zur Klärung (da ich noch keinen Kommentar abgeben kann, habe ich eine neue Frage gestellt).

Nehmen wir also den angegebenen Code als Beispiel für den Fall, dass Generika benötigt werden:

public class Repository<T> where T : class, IBusinessOBject
{
  T Get(int id)
  void Save(T obj);
  void Delete(T obj);
}

Es gibt Typenbeschränkungen: IBusinessObject

Meine übliche Denkweise ist: Die Klasse ist auf die Verwendung beschränkt IBusinessObject, ebenso die Klassen, die dies verwenden Repository. Das Repository speichert diese IBusinessObjects, wahrscheinlich Repositorymöchten Clients Objekte über die IBusinessObjectSchnittstelle abrufen und verwenden . Warum also nicht einfach so?

public class Repository
{
  IBusinessOBject Get(int id)
  void Save(IBusinessOBject obj);
  void Delete(IBusinessOBject obj);
}

Das Beispiel ist jedoch nicht gut, da es sich lediglich um eine andere Sammlungsart handelt und es sich bei der generischen Sammlung um Klassiker handelt. In diesem Fall sieht die Typeinschränkung ebenfalls seltsam aus.

Tatsächlich class Repository<T> where T : class, IBusinessbBjectsieht das Beispiel class BusinessObjectRepositorymir ziemlich ähnlich . Welches ist das, was Generika reparieren sollen.

Der springende Punkt ist: Sind Generika für alles gut, außer für Sammlungen, und machen Typeinschränkungen generisch nicht so spezialisiert wie die Verwendung dieser Typeinschränkung anstelle von generischen Typparametern innerhalb der Klasse?

jungle_mole
quelle

Antworten:

24

Sprechen wir zuerst über den reinen parametrischen Polymorphismus und später über den beschränkten Polymorphismus.

Parametrischer Polymorphismus

Was bedeutet parametrischer Polymorphismus? Nun, es bedeutet, dass ein Typ oder vielmehr ein Typkonstruktor durch einen Typ parametrisiert wird. Da der Typ als Parameter übergeben wird, können Sie nicht im Voraus wissen, um welchen Typ es sich handelt. Darauf aufbauend können Sie keine Annahmen treffen. Nun, wenn Sie nicht wissen, was es sein könnte, wozu dient es dann? Was kannst du damit machen?

Nun, Sie können es zum Beispiel speichern und abrufen. Das ist der Fall, den Sie bereits erwähnt haben: Sammlungen. Um ein Element in einer Liste oder einem Array zu speichern, muss ich nichts über das Element wissen. Die Liste oder das Array kann den Typ völlig vergessen.

Aber was ist mit dem MaybeTyp? Wenn Sie nicht damit vertraut sind, Maybehandelt es sich um einen Typ, der vielleicht einen Wert hat und vielleicht auch nicht. Wo würdest du es verwenden? Nun, zum Beispiel, wenn Sie ein Element aus einem Wörterbuch entfernen: Die Tatsache, dass ein Element möglicherweise nicht im Wörterbuch enthalten ist, ist keine Ausnahmesituation. Sie sollten also wirklich keine Ausnahme auslösen, wenn das Element nicht vorhanden ist. Stattdessen geben Sie eine Instanz eines Subtyps von zurück Maybe<T>, der genau zwei Subtypen hat: Noneund Some<T>. int.Parseist ein weiterer Kandidat für etwas, das wirklich Maybe<int>eine Ausnahme oder den ganzen int.TryParse(out bla)Tanz zurückgeben sollte, anstatt zu werfen .

Nun könnte man argumentieren, dass dies ein Maybebisschen wie eine Liste ist, die nur null oder ein Element enthalten kann. Und so irgendwie eine Sammlung.

Was ist dann mit Task<T>? Es ist ein Typ, der verspricht, irgendwann in der Zukunft einen Wert zurückzugeben, aber momentan nicht unbedingt einen Wert hat.

Oder was ist mit Func<T, …>? Wie würden Sie das Konzept einer Funktion von einem Typ zu einem anderen Typ darstellen, wenn Sie nicht über Typen abstrahieren können?

Oder allgemeiner: Wenn man bedenkt, dass Abstraktion und Wiederverwendung die beiden grundlegenden Operationen des Software-Engineerings sind, warum möchten Sie dann nicht in der Lage sein, über Typen hinweg zu abstrahieren?

Begrenzter Polymorphismus

Sprechen wir jetzt über den beschränkten Polymorphismus. Bei begrenztem Polymorphismus treffen parametrischer Polymorphismus und Subtyp-Polymorphismus aufeinander: Anstatt dass ein Typkonstruktor seinen Typparameter vollständig ignoriert, können Sie den Typ so binden (oder einschränken), dass er ein Subtyp eines bestimmten Typs ist.

Kehren wir zu den Sammlungen zurück. Nimm einen Hash-Tisch. Wir haben oben gesagt, dass eine Liste nichts über ihre Elemente wissen muss. Nun, eine Hashtabelle tut es: Sie muss wissen, dass sie sie hashen kann. (Hinweis: In C # sind alle Objekte hashbar, genau wie alle Objekte auf Gleichheit verglichen werden können. Dies gilt jedoch nicht für alle Sprachen und wird manchmal sogar in C # als Konstruktionsfehler angesehen.)

Sie möchten also Ihren Typparameter auf den Schlüsseltyp in der Hashtabelle beschränken, um eine Instanz von IHashable:

class HashTable<K, V> where K : IHashable
{
  Maybe<V> Get(K key);
  bool Add(K key, V value);
}

Stellen Sie sich vor, Sie hätten stattdessen Folgendes:

class HashTable
{
    object Get(IHashable key);
    bool Add(IHashable key, object value);
}

Was würdest du damit machen, wenn du da valuerauskommst? Sie können nichts damit anfangen, Sie wissen nur, dass es sich um ein Objekt handelt. Und wenn du darüber iterierst, erhältst du nur ein Paar von etwas, von dem du weißt, dass es ein ist IHashable(was dir nicht viel hilft, weil es nur eine Eigenschaft hat Hash) und etwas, von dem du weißt, dass es ein ist object(was dir noch weniger hilft).

Oder etwas basierend auf Ihrem Beispiel:

class Repository<T> where T : ISerializable
{
    T Get(int id);
    void Save(T obj);
    void Delete(T obj);
}

Das Element muss serialisierbar sein, da es auf der Festplatte gespeichert werden soll. Aber was ist, wenn Sie dies stattdessen haben:

class Repository
{
    ISerializable Get(int id);
    void Save(ISerializable obj);
    void Delete(ISerializable obj);
}

Mit dem allgemeinen Fall, wenn Sie ein setzten BankAccountin, erhalten Sie einen BankAccountzurück, mit Methoden und Eigenschaften wie Owner, AccountNumber, Balance, Deposit, Withdrawetc. Etwas , das man arbeiten kann. Nun, der andere Fall? Sie setzen in ein , BankAccountaber man bekommt wieder ein Serializable, die nur eine der Eigenschaften hat: AsString. Was wirst du damit machen?

Es gibt auch einige nette Tricks, die Sie mit beschränktem Polymorphismus machen können:

F-begrenzter Polymorphismus

Bei der f-gebundenen Quantifizierung erscheint die Typvariable im Prinzip wieder in der Nebenbedingung. Dies kann unter bestimmten Umständen nützlich sein. Wie schreibt man zB ein ICloneableInterface? Wie schreibt man eine Methode, bei der der Rückgabetyp der Typ der implementierenden Klasse ist? In einer Sprache mit einer MyType- Funktion ist das ganz einfach:

interface ICloneable
{
    public this Clone(); // syntax I invented for a MyType feature
}

In einer Sprache mit beschränktem Polymorphismus können Sie stattdessen Folgendes tun:

interface ICloneable<T> where T : ICloneable<T>
{
    public T Clone();
}

class Foo : ICloneable<Foo>
{
    public Foo Clone()
    {
        // …
    }
}

Beachten Sie, dass dies nicht so sicher ist wie die MyType-Version, da nichts jemanden davon abhält, einfach die "falsche" Klasse an den Typkonstruktor zu übergeben:

class EvilBar : ICloneable<SomethingTotallyUnrelatedToBar>
{
    public SomethingTotallyUnrelatedToBar Clone()
    {
        // …
    }
}

Abstract-Typ-Mitglieder

Wie sich herausstellt, können Sie mit abstrakten Typmitgliedern und Subtypisierung sogar ganz ohne parametrischen Polymorphismus auskommen und trotzdem die gleichen Dinge tun. Scala geht in diese Richtung. Es ist die erste Hauptsprache, die mit Generika begann und dann versuchte, sie zu entfernen, was genau umgekehrt ist, z. B. bei Java und C #.

Grundsätzlich können Sie in Scala, genau wie Sie Felder, Eigenschaften und Methoden als Mitglieder haben können, auch Typen haben. Ebenso wie Felder, Eigenschaften und Methoden abstrakt bleiben können, um später in einer Unterklasse implementiert zu werden, können auch Typmember abstrakt bleiben. Kehren wir zurück zu den Kollektionen, einer einfachen List, die ungefähr so ​​aussehen würde, wenn sie in C # unterstützt würden:

class List
{
    T; // syntax I invented for an abstract type member
    T Get(int index) { /* … */ }
    void Add(T obj) { /* … */ }
}

class IntList : List
{
    T = int;
}
// this is equivalent to saying `List<int>` with generics
Jörg W. Mittag
quelle
Ich verstehe, dass Abstraktion über Typen nützlich ist. Ich sehe seine Verwendung einfach nicht im "wirklichen Leben". Func <> und Task <> und Action <> sind Bibliothekstypen. und danke, an den ich mich interface IFoo<T> where T : IFoo<T>auch erinnerte . Das ist offensichtlich eine reale Anwendung. Das Beispiel ist großartig. aber aus irgendeinem grund bin ich nicht zufrieden. Ich möchte lieber darüber nachdenken, wann es angebracht ist und wann nicht. Antworten hier haben einen gewissen Beitrag zu diesem Prozess, aber ich fühle mich immer noch unwohl. Es ist seltsam, weil mich Sprachprobleme schon lange nicht mehr stören.
jungle_mole
Tolles Beispiel. Ich fühlte mich wie zurück im Klassenzimmer. +1
Chef_Code
1
@Chef_Code: Ich hoffe das ist ein Kompliment :-P
Jörg W Mittag
Ja ist es. Ich dachte später darüber nach, wie es wahrgenommen werden könnte, nachdem ich es bereits kommentiert hatte. Also, um die Aufrichtigkeit zu bestätigen ... Ja, es ist ein Kompliment, sonst nichts.
Chef_Code
14

Der springende Punkt ist: Sind Generika für alles gut, außer für Sammlungen, und machen Typeinschränkungen generisch nicht so spezialisiert wie die Verwendung dieser Typeinschränkung anstelle von generischen Typparametern innerhalb der Klasse?

Nein. Du denkst zu viel darüber nach Repository, wo es so ziemlich dasselbe ist. Aber dafür sind die Generika nicht da. Sie sind für die Benutzer da .

Der entscheidende Punkt hierbei ist nicht, dass das Repository selbst allgemeiner ist. Es ist , dass die Benutzer sind besser specialized- dh dass Repository<BusinessObject1>und Repository<BusinessObject2>verschiedene Arten sind, und darüber hinaus, dass , wenn ich eine nehmen Repository<BusinessObject1>, ich weiß , dass ich bekommen BusinessObject1von der Rückseite aus Get.

Sie können diese starke Typisierung durch einfache Vererbung nicht anbieten. Die von Ihnen vorgeschlagene Repository-Klasse hindert die Benutzer nicht daran, Repositorys für verschiedene Arten von Geschäftsobjekten zu verwechseln oder sicherzustellen, dass die richtige Art von Geschäftsobjekt wiederhergestellt wird.

DeadMG
quelle
Danke, das macht Sinn. Aber ist die Verwendung dieser hochgelobten Sprachfunktion so einfach wie die Unterstützung von Benutzern, bei denen IntelliSense ausgeschaltet ist? (Ich übertreibe ein bisschen, aber ich bin sicher, dass Sie den Punkt bekommen)
jungle_mole
@zloidooraque: Auch IntelliSense weiß nicht, welche Art von Objekten in einem Repository gespeichert sind. Aber ja, Sie könnten alles ohne Generika machen, wenn Sie stattdessen Casts verwenden möchten.
Gexizid
@gexicide das ist der springende Punkt: Ich sehe nicht, wo ich Casts verwenden muss, wenn ich Common Interface verwende. Ich habe nie "Gebrauch Object" gesagt . Ich verstehe auch, warum beim Schreiben von Sammlungen Generika verwendet werden (DRY-Prinzip). Wahrscheinlich sollte meine erste Frage etwas über die Verwendung von Generika außerhalb des Sammlungskontexts gewesen sein.
jungle_mole
@ zloidooraque: Es hat nichts mit der Umwelt zu tun. Intellisense kann Ihnen nicht sagen, ob IBusinessObjecta BusinessObject1oder a ist BusinessObject2. Es kann keine Überlastungen basierend auf dem abgeleiteten Typ beheben, den es nicht kennt. Es kann keinen Code ablehnen, der den falschen Typ übergibt. Es gibt eine Million Bits der stärkeren Typisierung, gegen die Intellisense absolut nichts ausrichten kann. Bessere Werkzeugunterstützung ist ein netter Vorteil, hat aber nichts mit den Hauptgründen zu tun.
DeadMG
@DeadMG und das ist mein Punkt: Intellisense kann es nicht: Verwenden Sie generische, so könnte es? spielt es eine rolle Wenn Sie das Objekt über die Schnittstelle erhalten, warum sollten Sie es dann ablehnen? Wenn Sie es tun, ist es schlechtes Design, nicht wahr? und warum und was ist "Überlastungen beheben"? Der Benutzer muss nicht entscheiden, ob die Aufrufmethode auf dem abgeleiteten Typ basiert oder nicht, wenn er den Aufruf der richtigen Methode an das System delegiert (welcher Polymorphismus vorliegt). und das führt mich wieder zu einer frage: sind generika nützlich außerhalb von behältern? Ich streite nicht mit dir, das muss ich wirklich verstehen.
jungle_mole
12

"Sehr wahrscheinlich möchten Kunden dieses Repository Objekte über die IBusinessObject-Schnittstelle abrufen und verwenden."

Nein, werden sie nicht.

Angenommen, das IBusinessObject hat die folgende Definition:

public interface IBusinessObject
{
  public int Id { get; }
}

Sie definiert lediglich die ID, da dies die einzige gemeinsame Funktionalität aller Geschäftsobjekte ist. Und Sie haben zwei eigentliche Geschäftsobjekte: Person und Adresse (da Personen keine Straßen und Adressen haben, die keine Namen haben, können Sie beide nicht auf eine gemeinsame Schnittstelle mit der Funktionalität von beiden beschränken. Das wäre ein schreckliches Design, das die Prinzip der Schnittstellenverschlechterung (das "I" in SOLID )

public class Person : IBusinessObject
{
  public int Id { get; private set; }
  public string Name { get; private set; }
}

public class Address : IBusinessObject
{
  public int Id { get; private set; }
  public string City { get; private set; }
  public string StreetName { get; private set; }
  public int Number { get; private set; }
}

Was passiert nun, wenn Sie die generische Version des Repositorys verwenden:

public class Repository<T> where T : class, IBusinessObject
{
  T Get(int id)
  void Save(T obj);
  void Delete(T obj);
}

Wenn Sie die Get-Methode für das generische Repository aufrufen, wird das zurückgegebene Objekt stark typisiert, sodass Sie auf alle Klassenmitglieder zugreifen können.

Person p = new Repository<Person>().Get(1);
int id = p.Id;
string name = p.Name;

Address a = new Repository<Address>().Get(1);
int id = a.Id;
string cityName = a.City;
int houseNumber = a.Number;

Auf der anderen Seite, wenn Sie das nicht generische Repository verwenden:

public class Repository
{
  IBusinessOBject Get(int id)
  void Save(IBusinessOBject obj);
  void Delete(IBusinessOBject obj);
}

Sie können nur über die IBusinessObject-Oberfläche auf die Mitglieder zugreifen:

IBussinesObject p = new Repository().Get(1);
int id = p.Id; //OK
string name = p.Name; //Oooops, you dont have "Name" defined on the IBussinesObject interface.

Daher wird der vorherige Code aufgrund der folgenden Zeilen nicht kompiliert:

string name = p.Name;
string cityName = a.City;
int houseNumber = a.Number;

Sicher, Sie können das IBussinesObject in die eigentliche Klasse umwandeln, aber Sie verlieren die gesamte Kompilierungszeit, die Generika erlauben (was später zu InvalidCastExceptions führt), und leiden unnötig unter unnötigem Overhead ... und selbst wenn Sie dies nicht tun Achten Sie darauf, dass Sie weder die Leistung noch die Kompilierungszeit überprüfen (Sie sollten dies tun). Ein Casting nach wird Ihnen definitiv keinen Vorteil gegenüber der erstmaligen Verwendung von Generika bringen.

Lucas Corsaletti
quelle