Summentypen vs Polymorphismus

10

Im vergangenen Jahr habe ich den Sprung gewagt und eine funktionale Programmiersprache (F #) gelernt. Eines der interessantesten Dinge, die ich gefunden habe, ist, wie sich dies auf die Art und Weise auswirkt, wie ich OO-Software entwerfe. Die beiden Dinge, die mir in OO-Sprachen am meisten fehlen, sind Mustervergleich und Summentypen. Überall, wo ich hinschaue, sehe ich Situationen, die mit einer diskriminierten Gewerkschaft trivial modelliert würden, aber ich zögere es, bei einer OO DU-Implementierung, die sich für das Paradigma unnatürlich anfühlt, eine Brechstange zu bilden.

Dies führt mich im Allgemeinen dazu, Zwischentypen zu erstellen, um die orBeziehungen zu behandeln, die ein Summentyp für mich behandeln würde. Es scheint auch zu einer starken Verzweigung zu führen. Wenn ich Leute wie Misko Hevery lese , schlägt er vor, dass ein gutes OO-Design die Verzweigung durch Polymorphismus minimieren kann.

Eines der Dinge, die ich im OO-Code so weit wie möglich vermeide, sind Typen mit nullWerten. Natürlich kann die orBeziehung durch einen Typ mit einem nullWert und einem Nichtwert modelliert werden null, aber dies bedeutet nullTests überall. Gibt es eine Möglichkeit, heterogene, aber logisch assoziierte Typen polymorph zu modellieren? Entwurfsstrategien oder -muster wären sehr hilfreich oder einfach Möglichkeiten, über heterogene und assoziierte Typen im Allgemeinen im OO-Paradigma nachzudenken.

Patrick D.
quelle
3
"Gutes OO-Design kann die Verzweigung durch Polymorphismus minimieren" : Es verschiebt die Verzweigung von der eigentlichen Geschäftslogik zum Initialisierungs- / Konfigurationscode. Der Vorteil besteht normalerweise darin, dass "Initialisierung und Konfiguration" viel weniger erfolgt (ab dem Auftreten im Code, nicht im Hinblick auf die "Ausführung"), als eine explizite Verzweigung in der Geschäftslogik erforderlich wäre. Der Nachteil ist, dass es keinen Platz für oder abhängig vom Typ der Zielobjekte innerhalb der Geschäftslogik gibt ...
Timothy Truckle
3
Dies kann für Sie von Interesse sein (im Grunde modelliert der Autor einen Summentyp als Hierarchie mit einer Reihe überschriebener Methoden in den Unterklassen, um den Musterabgleich zu modellieren). Außerdem können in OO Nullprüfungen mithilfe des Nullobjektmusters vermieden werden (nur ein Objekt, das für eine bestimmte polymorphe Operation nichts tut).
Filip Milovanović
Das zusammengesetzte Muster ist möglicherweise eine Lektüre wert.
candied_orange
1
Können Sie ein Beispiel geben, was Sie verbessern möchten?
JimmyJames
@ TimothyTruckle Gute Erklärung, aber es ist nicht immer "Initialisierung / Konfiguration". Die Verzweigung tritt auf, wenn Sie die Methode unsichtbar aufrufen. In einer dynamischen Sprache können Sie jedoch möglicherweise Klassen dynamisch hinzufügen. In diesem Fall ändert sich die Verzweigung auch dynamisch.
Frank Hileman

Antworten:

15

Wie Sie wünsche ich mir, dass diskriminierte Gewerkschaften häufiger auftreten. Der Grund, warum sie in den meisten funktionalen Sprachen nützlich sind, ist, dass sie einen umfassenden Mustervergleich bieten. Ohne diesen sind sie nur eine hübsche Syntax: nicht nur ein Mustervergleich: ein erschöpfender Mustervergleich, sodass der Code nicht kompiliert wird, wenn Sie ihn nicht verwenden. Nicht alle Möglichkeiten abdecken: Dies gibt Ihnen Kraft.

Die einzige Möglichkeit, mit einem Summentyp etwas Nützliches zu tun, besteht darin, ihn zu zerlegen und je nach Typ zu verzweigen (z. B. durch Mustervergleich). Das Tolle an Schnittstellen ist, dass es Ihnen egal ist, um welchen Typ es sich handelt, denn Sie wissen, dass Sie es wie folgt behandeln können iface: Keine eindeutige Logik für jeden Typ erforderlich: keine Verzweigung.

Dies ist kein "Funktionscode hat mehr Verzweigungen, OO-Code hat weniger", dies ist eine "Funktionssprachen", die besser für Domänen geeignet sind, in denen Sie Gewerkschaften haben - die eine Verzweigung vorschreiben - und "OO-Sprachen" sind besser für Code geeignet Hier können Sie allgemeines Verhalten als gemeinsame Schnittstelle verfügbar machen - was sich möglicherweise weniger verzweigt anfühlt. " Die Verzweigung ist eine Funktion Ihres Designs und der Domäne. Ganz einfach, wenn Ihre "heterogenen, aber logisch zugeordneten Typen" keine gemeinsame Schnittstelle verfügbar machen können, müssen Sie über sie verzweigen / Muster abgleichen. Dies ist ein Domain- / Designproblem.

Was Misko gemeint sein kann , ist die allgemeine Vorstellung , dass , wenn Sie können Ihre Typen als gemeinsame Schnittstelle aussetzen, dann mit OO - Funktionen (Schnittstellen / Polymorphismus) Ihr Leben besser typspezifische Verhalten machen , indem sie in der Art statt in der verzehr Code.

Es ist wichtig zu erkennen, dass Schnittstellen und Gewerkschaften das Gegenteil voneinander sind: Eine Schnittstelle definiert einige Dinge, die der Typ implementieren muss, und die Gewerkschaft definiert einige Dinge, die der Verbraucher berücksichtigen muss. Wenn Sie einer Schnittstelle eine Methode hinzufügen, haben Sie diesen Vertrag geändert, und jetzt muss jeder zuvor implementierte Typ aktualisiert werden. Wenn Sie einer Gewerkschaft einen neuen Typ hinzufügen, haben Sie diesen Vertrag geändert, und jetzt muss jedes umfassende Muster, das über die Gewerkschaft hinweg übereinstimmt , aktualisiert werden. Sie besetzen unterschiedliche Rollen, und obwohl es manchmal möglich ist, ein System in beide Richtungen zu implementieren, ist dies eine Entwurfsentscheidung: Beides ist von Natur aus nicht besser.

Ein Vorteil von Interfaces / Polymorphismus besteht darin, dass der konsumierende Code erweiterbarer ist: Sie können einen Typ übergeben, der zur Entwurfszeit nicht definiert wurde, solange die vereinbarte Schnittstelle verfügbar gemacht wird. Auf der anderen Seite können Sie mit einer statischen Vereinigung Verhaltensweisen ausnutzen, die zur Entwurfszeit nicht berücksichtigt wurden, indem Sie neue umfassende Musterübereinstimmungen schreiben, solange sie sich an den Vertrag der Vereinigung halten.


In Bezug auf die ‚Pattern Null - Objekt‘: dies ist nicht kein Allheilmittel, und hat nicht ersetzen nullSchecks. Alles, was es tut, bietet eine Möglichkeit, einige "Null" -Prüfungen zu vermeiden, bei denen das "Null" -Verhalten hinter einer gemeinsamen Schnittstelle angezeigt werden kann. Wenn Sie das "Null" -Verhalten hinter der Schnittstelle des Typs nicht offenlegen können, werden Sie denken "Ich wünschte wirklich, ich könnte dieses Muster vollständig anpassen" und am Ende eine "Verzweigungs" -Prüfung durchführen.

VisualMelon
quelle
4
im Zusammenhang mit dem vorletzten Absatz: en.wikipedia.org/wiki/Expression_problem
jk.
"Eine Schnittstelle definiert einige Dinge, die der Typ implementieren muss, und die Union definiert einige Dinge, die der Verbraucher berücksichtigen muss" - Sie müssen Schnittstellen nicht so betrachten. Eine Komponente kann eine erforderliche Schnittstelle definieren - was eine andere Komponente implementieren muss; und eine bereitgestellte Schnittstelle - eine, die eine Verbraucherkomponente berücksichtigen muss (dh gegen die programmiert werden muss).
Filip Milovanović
@ FilipMilovanović aye, ich war dort nicht sehr genau. Ich habe versucht zu vermeiden, in das "Dreieck" der Abhängigkeiten mit Schnittstellen (Verbraucher -> Schnittstelle <- Implementierer / Typ) zu geraten, anstatt in die "linearen" Abhängigkeiten mit einer Union (Verbraucher -> Union -> Typen), weil ich es bin Ich versuche wirklich nur auszudrücken, wo die 'Entscheidungsfindung' stattfindet (z. B. wo definieren wir, was zu tun ist, wenn uns dieser Typ präsentiert wird)
VisualMelon
3

Es gibt eine ziemlich "Standard" -Methode zum Codieren von Summentypen in eine objektorientierte Sprache.

Hier sind zwei Beispiele:

type Either<'a, 'b> = Left of 'a | Right of 'b

In C # könnten wir dies wie folgt rendern:

interface Either<A, B> {
    C Match<C>(Func<A, C> left, Func<B, C> right);
}

class Left<A, B> : Either<A, B> {
    private readonly A a;
    public Left(A a) { this.a = a; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return left(a);
    }
}

class Right<A, B> : Either<A, B> {
    private readonly B b;
    public Right(B b) { this.b = b; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return right(b);
    }
}

F # nochmal:

type List<'a> = Nil | Cons of 'a * List<'a>

Wieder C #:

interface List<A> {
    B Match<B>(B nil, Func<A, List<A>, B> cons);
}

class Nil<A> : List<A> {
    public Nil() {}
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return nil;
    }
}

class Cons<A> : List<A> {
    private readonly A head;
    private readonly List<A> tail;
    public Cons(A head, List<A> tail) {
        this.head = head;
        this.tail = tail;
    }
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return cons(head, tail);
    }
}

Die Codierung ist vollständig mechanisch. Diese Codierung führt zu einem Ergebnis, das die meisten Vor- und Nachteile algebraischer Datentypen aufweist. Sie können dies auch als Variation des Besuchermusters erkennen. Wir könnten die Parameter Matchzusammen in einer Schnittstelle sammeln, die wir als Besucher bezeichnen könnten.

Auf der Vorteilsseite erhalten Sie eine prinzipielle Codierung von Summentypen. (Es ist die Scott-Codierung .) Sie erhalten einen umfassenden "Mustervergleich", obwohl jeweils nur eine "Ebene" des Abgleichs vorhanden ist. Matchist in gewisser Weise eine "vollständige" Schnittstelle für diese Typen, und alle zusätzlichen Operationen, die wir möglicherweise wünschen, können in Bezug darauf definiert werden. Es bietet eine andere Perspektive auf viele OO-Muster wie das Null-Objektmuster und das Zustandsmuster, wie ich in Ryathals Antwort angegeben habe, sowie das Besuchermuster und das zusammengesetzte Muster. Der Typ Option/ Maybeähnelt einem generischen Nullobjektmuster. Das zusammengesetzte Muster ähnelt der Codierung type Tree<'a> = Leaf of 'a | Children of List<Tree<'a>>. Das Zustandsmuster ist im Grunde eine Kodierung einer Aufzählung.

Auf der Nachteilsseite, wie ich es geschrieben habe Match, legt die Methode einige Einschränkungen fest, welche Unterklassen sinnvoll hinzugefügt werden können, insbesondere wenn wir die Liskov-Substituierbarkeitseigenschaft beibehalten möchten. Wenn Sie diese Codierung beispielsweise auf einen Aufzählungstyp anwenden, können Sie die Aufzählung nicht sinnvoll erweitern. Wenn Sie die Aufzählung erweitern möchten, müssen Sie alle Aufrufer und Implementierer überall so ändern, als würden Sie enumund verwenden switch. Diese Codierung ist jedoch etwas flexibler als das Original. Zum Beispiel können wir einen AppendImplementierer hinzufügen , der Listnur zwei Listen enthält, die uns einen zeitlich konstanten Anhang geben. Dies würde sich wie die zusammengehängten Listen verhalten, aber auf andere Weise dargestellt werden.

Natürlich haben viele dieser Probleme damit zu tun, dass sie Matchetwas (konzeptionell, aber absichtlich) an die Unterklassen gebunden sind. Wenn wir Methoden verwenden, die nicht so spezifisch sind, erhalten wir traditionellere OO-Designs und wir gewinnen die Erweiterbarkeit wieder, aber wir verlieren die "Vollständigkeit" der Schnittstelle und damit die Fähigkeit, Operationen für diesen Typ in Bezug auf die zu definieren Schnittstelle. Wie an anderer Stelle erwähnt, ist dies eine Manifestation des Ausdrucksproblems .

Möglicherweise können Designs wie das oben genannte systematisch verwendet werden, um die Notwendigkeit einer Verzweigung, die jemals ein OO-Ideal erreicht, vollständig zu eliminieren. Smalltalk verwendet dieses Muster beispielsweise häufig, auch für Boolesche Werte. Aber wie die vorangegangene Diskussion nahe legt, ist diese "Beseitigung der Verzweigung" ziemlich illusorisch. Wir haben die Verzweigung gerade auf eine andere Art und Weise implementiert und sie hat immer noch viele der gleichen Eigenschaften.

Derek Elkins verließ SE
quelle
1

Die Behandlung von Null kann mit dem Null-Objektmuster erfolgen . Die Idee ist, eine Instanz Ihrer Objekte zu erstellen, die Standardwerte für jedes Mitglied zurückgibt und Methoden enthält, die nichts tun, aber auch keine Fehler verursachen. Dadurch werden Nullprüfungen nicht vollständig eliminiert, sondern Sie müssen nur bei der Objekterstellung nach Nullen suchen und Ihr Nullobjekt zurückgeben.

Das Zustandsmuster ist eine Möglichkeit, die Verzweigung zu minimieren und einige der Vorteile des Mustervergleichs aufzuzeigen. Wiederum wird die Verzweigungslogik zur Objekterstellung verschoben. Jeder Status ist eine separate Implementierung einer Basisschnittstelle, sodass der gesamte verbrauchende Code nur DoStuff () aufrufen muss und die richtige Methode aufgerufen wird. Einige Sprachen fügen auch Pattern Matching als Feature hinzu, C # ist ein Beispiel.

Ryathal
quelle
(Un?) Ironischerweise sind dies beide Beispiele für die "Standard" -Methode zum Codieren diskriminierter Vereinigungstypen in OOP.
Derek Elkins verließ SE