Ist der semantische Vertrag einer Schnittstelle (OOP) aussagekräftiger als eine Funktionssignatur (FP)?

16

Einige sagen, wenn Sie SOLID-Prinzipien auf die Spitze treiben, landen Sie in der funktionalen Programmierung . Ich bin mit diesem Artikel einverstanden, aber ich denke, dass einige Semantiken beim Übergang von der Schnittstelle / dem Objekt zur Funktion / zum Abschluss verloren gehen, und ich möchte wissen, wie funktionale Programmierung den Verlust mindern kann.

Aus dem Artikel:

Wenn Sie das Prinzip der Schnittstellentrennung (ISP - Interface Segregation Principle) konsequent anwenden, sollten Sie Rollenschnittstellen gegenüber Header-Schnittstellen bevorzugen.

Wenn Sie Ihr Design in Richtung kleinerer und kleinerer Schnittstellen weiterentwickeln, erhalten Sie schließlich die ultimative Rollenschnittstelle: eine Schnittstelle mit einer einzigen Methode. Das passiert mir sehr oft. Hier ist ein Beispiel:

public interface IMessageQuery
{
    string Read(int id);
}

Wenn ich eine Abhängigkeit von einem annehme, besteht ein IMessageQueryTeil des impliziten Vertrages darin, dass ein Anruf Read(id)nach einer Nachricht mit der angegebenen ID sucht und diese zurückgibt.

Vergleichen Sie dies damit, dass Sie eine Abhängigkeit von der entsprechenden funktionalen Signatur annehmen int -> string. Ohne zusätzliche Hinweise könnte diese Funktion eine einfache sein ToString(). Wenn Sie IMessageQuery.Read(int id)mit einem implementiert haben, ToString()kann ich Sie beschuldigen, absichtlich subversiv zu sein!

Was können funktionierende Programmierer also tun, um die Semantik einer benannten Schnittstelle beizubehalten? Ist es üblich, beispielsweise einen Datensatztyp mit einem einzelnen Element zu erstellen?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
quelle
5
Eine OOP-Schnittstelle ähnelt eher einer FP- Typenklasse , nicht einer einzelnen Funktion.
9000,
2
Sie können zu viel von einer guten Sache haben, ISP 'rigoros' anzuwenden, um 1 Methode pro Schnittstelle zu erhalten, geht einfach zu weit.
Gbjbaanb
3
@gbjbaanb tatsächlich hat die Mehrheit meiner Schnittstellen nur eine Methode mit vielen Implementierungen. Je öfter Sie SOLID-Prinzipien anwenden, desto mehr können Sie die Vorteile erkennen. Aber das ist für diese Frage
kein
1
@jk .: Nun, in Haskell sind es Typklassen, in OCaml verwenden Sie entweder ein Modul oder einen Funktor, in Clojure verwenden Sie ein Protokoll. In jedem Fall beschränken Sie Ihre Schnittstellenanalogie normalerweise nicht auf eine einzelne Funktion.
9000,
2
Without any additional clues... vielleicht ist die Dokumentation deshalb Bestandteil des Vertrages ?
SJuan76,

Antworten:

8

Wie Telastyn sagt, vergleichen Sie die statischen Definitionen von Funktionen:

public string Read(int id) { /*...*/ }

zu

let read (id:int) = //...

Sie haben von OOP bis FP nichts wirklich verloren.

Dies ist jedoch nur ein Teil der Geschichte, da Funktionen und Schnittstellen nicht nur in ihren statischen Definitionen erwähnt werden. Sie werden auch herumgereicht . Angenommen, unser MessageQueryCode wurde von einem anderen Code gelesen: a MessageProcessor. Dann haben wir:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Jetzt können wir den Methodennamen oder dessen Parameter nicht direkt sehen , aber wir können es sehr einfach über unsere IDE erreichen. Im Allgemeinen bedeutet die Tatsache, dass wir nicht nur eine Schnittstelle mit einer Methode, sondern eine Funktion von int an string übergeben, dass wir die dieser Funktion zugeordneten Metadaten des Parameternamens beibehalten.IMessageQuery.Readint idIMessageQueryid

Andererseits haben wir für unsere funktionale Version:

let read (id:int) (messageReader : int -> string) = // ...

Was haben wir also behalten und verloren? Nun, wir haben immer noch den Parameternamen messageReader, was wahrscheinlich den Typnamen (das Äquivalent zu IMessageQuery) überflüssig macht. Aber jetzt haben wir den Parameternamen idin unserer Funktion verloren.


Es gibt zwei Hauptwege, um dies zu umgehen:

  • Erstens können Sie beim Lesen dieser Unterschrift bereits ziemlich gut erraten, was passieren wird. Indem Sie die Funktionen kurz, einfach und zusammenhängend halten und eine gute Benennung verwenden, können Sie diese Informationen viel einfacher erkennen oder finden. Sobald wir die eigentliche Funktion gelesen haben, ist sie noch einfacher.

  • Zweitens wird es in vielen funktionalen Sprachen als idiomatisches Design angesehen , kleine Typen zum Umschließen von Primitiven zu erstellen. In diesem Fall ist das Gegenteil der Fall - anstatt einen Typnamen durch einen Parameternamen ( IMessageQuerybis messageReader) zu ersetzen, können wir einen Parameternamen durch einen Typnamen ersetzen. Könnte zum Beispiel intin einen Typ mit dem Namen gewickelt werden Id:

    type Id = Id of int
    

    Jetzt wird unsere readUnterschrift:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Welches ist genauso informativ wie das, was wir zuvor hatten.

    Als Randnotiz bietet uns dies auch einen Teil des Compilerschutzes, den wir in OOP hatten. Während die OOP-Version sicherstellte, dass wir IMessageQuerynicht nur irgendeine alte int -> stringFunktion übernommen haben, haben wir hier einen ähnlichen (aber unterschiedlichen) Schutz, den wir Id -> stringanstelle irgendeiner alten verwenden int -> string.


Ich würde nur ungern mit hundertprozentiger Sicherheit sagen, dass diese Techniken immer genauso gut und informativ sind wie die vollständigen Informationen, die auf einer Benutzeroberfläche verfügbar sind, aber ich denke, anhand der obigen Beispiele können Sie sagen, dass wir dies die meiste Zeit tun kann wahrscheinlich genauso gut arbeiten.

Ben Aaronson
quelle
1
Dies ist meine Lieblingsantwort - dass FP die Verwendung semantischer Typen
fördert
10

Wenn ich FP mache, neige ich dazu, spezifischere semantische Typen zu verwenden.

Zum Beispiel würde Ihre Methode für mich ungefähr so ​​aussehen:

read: MessageId -> Message

Dies kommuniziert viel mehr als der OO (/ java) ThingDoer.doThing()-Stil

Daenyth
quelle
2
+1. Darüber hinaus können Sie in typisierten FP-Sprachen wie Haskell weitaus mehr Eigenschaften in das Typsystem kodieren. Wenn das ein haskell-Typ wäre, würde ich wissen, dass er überhaupt keine E / A ausführt (und daher wahrscheinlich irgendwo auf eine speicherinterne Zuordnung von IDs zu Nachrichten verweist). In OOP-Sprachen habe ich diese Informationen nicht - diese Funktion kann eine Datenbank beim Aufrufen aufrufen.
Jack
1
Mir ist nicht genau klar, warum dies mehr als der Java-Stil kommunizieren würde . Was read: MessageId -> Messagesagt dir das string MessageReader.GetMessage(int messageId)nicht?
Ben Aaronson
@BenAaronson das Signal-Rausch-Verhältnis ist besser. Wenn es sich um eine OO-Instanzmethode handelt, von der ich keine Ahnung habe, was sie unter der Haube tut, kann sie jede Art von Abhängigkeit aufweisen, die nicht zum Ausdruck kommt. Wie Jack erwähnt, haben Sie mehr Sicherheiten, wenn Sie stärkere Typen haben.
Daenyth
9

Was können funktionierende Programmierer also tun, um die Semantik einer benannten Schnittstelle beizubehalten?

Verwenden Sie gut benannte Funktionen.

IMessageQuery::Read: int -> stringwird einfach ReadMessageQuery: int -> stringoder so ähnlich.

Das Wichtigste ist, dass Namen nur Verträge im weitesten Sinne des Wortes sind. Sie funktionieren nur, wenn Sie und ein anderer Programmierer dieselben Implikationen aus dem Namen ableiten und ihnen gehorchen. Aus diesem Grund können Sie wirklich jeden Namen verwenden, der dieses implizite Verhalten kommuniziert. OO und funktionale Programmierung haben ihre Namen an leicht unterschiedlichen Stellen und in leicht unterschiedlichen Formen, aber ihre Funktion ist dieselbe.

Ist der semantische Vertrag einer Schnittstelle (OOP) aussagekräftiger als eine Funktionssignatur (FP)?

Nicht in diesem Beispiel. Wie ich oben erklärt habe, ist eine einzelne Klasse / Schnittstelle mit einer einzelnen Funktion nicht aussagekräftiger als eine ähnlich gut benannte Standalone-Funktion.

Sobald Sie mehr als eine Funktion / ein Feld / eine Eigenschaft in einer Klasse erhalten, können Sie weitere Informationen über diese ableiten, da Sie deren Beziehung sehen können. Es ist fraglich, ob dies informativer ist als eigenständige Funktionen, die dieselben / ähnliche Parameter oder eigenständige Funktionen verwenden, die nach Namespace oder Modul organisiert sind.

Persönlich glaube ich nicht, dass OO selbst in komplexeren Beispielen wesentlich informativer ist.

Telastyn
quelle
2
Was ist mit Parameternamen?
Ben Aaronson
@BenAaronson - was ist mit ihnen? Sowohl in OO- als auch in funktionalen Sprachen können Sie Parameternamen angeben. Ich verwende hier nur die Kurzform der Typensignatur, um mit der Frage übereinzustimmen. In einer echten Sprache würde es so aussehenReadMessageQuery id = <code to go fetch based on id>
Telastyn
1
Wenn eine Funktion eine Schnittstelle als Parameter verwendet und dann eine Methode für diese Schnittstelle aufruft, sind die Parameternamen für die Schnittstellenmethode leicht verfügbar. Wenn eine Funktion eine andere Funktion als Parameter übernimmt, ist es nicht einfach, Parameternamen für die übergebene Funktion zuzuweisen
Ben Aaronson,
1
Ja, @BenAaronson Dies ist eine der Informationen, die in der Funktionssignatur verloren gehen. In let consumer (messageQuery : int -> string) = messageQuery 5ist der idParameter beispielsweise nur ein int. Ich denke, ein Argument ist, dass Sie eine Id, nicht eine übergeben sollten int. In der Tat wäre das eine anständige Antwort für sich.
AlexFoxGill
@AlexFoxGill Eigentlich war ich gerade dabei, etwas in diese Richtung zu schreiben!
Ben Aaronson
0

Ich stimme nicht zu, dass eine einzelne Funktion keinen "semantischen Vertrag" haben kann. Betrachten Sie diese Gesetze für foldr:

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

Inwiefern ist das keine Semantik oder kein Vertrag? Sie müssen keinen Typ für einen "Foldrer" definieren, insbesondere, weil er foldrdurch diese Gesetze eindeutig bestimmt wird. Sie wissen genau, was es tun wird.

Wenn Sie eine Funktion eines Typs aufnehmen möchten, können Sie dasselbe tun:

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Sie müssen diesen Typ nur benennen und erfassen, wenn Sie denselben Vertrag mehrmals benötigen:

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

Die Typprüfung erzwingt nicht die Semantik, die Sie einem Typ zuweisen. Die Erstellung eines neuen Typs für jeden Vertrag ist also nur ein Boilerplate.

Jonathan Cast
quelle
1
Ich glaube nicht, dass Ihr erster Punkt die Frage anspricht - es ist eine Funktionsdefinition, keine Signatur. Natürlich durch bei der Implementierung einer Klasse suchen oder eine Funktion können Sie sagen , was es tut - die Frage stellt , kann man noch die Semantik auf einer Abstraktionsebene erhalten (eine Schnittstelle oder Funktionssignatur)
AlexFoxGill
@AlexFoxGill - es ist keine Funktionsdefinition, obwohl sie eindeutig bestimmt foldr. Hinweis: Die Definition von foldrhat zwei Gleichungen (warum?), Während die oben angegebene Spezifikation drei hat.
Jonathan Cast
Was ich meine ist, dass Foldr eine Funktion ist, keine Funktionssignatur. Die Gesetze oder Definitionen sind nicht Teil der Signatur
AlexFoxGill
-1

Nahezu alle statisch typisierten funktionalen Sprachen haben eine Möglichkeit, Basistypen so zu aliasisieren, dass Sie Ihre semantische Absicht explizit deklarieren müssen. Einige der anderen Antworten haben Beispiele gegeben. In der Praxis benötigen erfahrene Funktionsprogrammierer sehr gute Gründe für die Verwendung dieser Wrapper-Typen, da sie die Kompositionsfähigkeit und Wiederverwendbarkeit beeinträchtigen.

Angenommen, ein Client wollte eine Implementierung einer Nachrichtenabfrage, die von einer Liste unterstützt wurde. In Haskell könnte die Implementierung so einfach sein:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

Mit newtype Message = Message Stringdieser wäre viel weniger einfach sein, so dass diese Implementierung sucht so etwas wie:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Das mag nicht so schlimm erscheinen, aber entweder müssen Sie diese Art der Konvertierung überall hin und her machen , oder Sie richten eine Grenzschicht in Ihrem Code ein, in die alles, was darüber liegt Int -> String, konvertiert wird Id -> Message, um zur darunter liegenden Schicht überzugehen. Angenommen, ich wollte Internationalisierung hinzufügen oder in Großbuchstaben formatieren oder den Protokollierungskontext hinzufügen, oder was auch immer. Diese Operationen sind alle kinderleicht mit einem zu komponieren Int -> Stringund nerven mit einem Id -> Message. Es ist nicht so, dass es niemals Fälle gibt, in denen die erhöhten Typeinschränkungen wünschenswert sind, aber die Belästigung sollte den Kompromiss wert sein.

Sie können ein Typ- Synonym anstelle eines Wrappers (in Haskell typeanstelle von newtype) verwenden. Dies ist weitaus häufiger der Fall und erfordert keine allumfassenden Konvertierungen, bietet jedoch keine statischen Typgarantien wie Ihre OOP-Version. nur ein bisschen Verkapselung. Typ - Wrapper wird meist verwendet , wenn der Client nicht erwartet , dass der Wert überhaupt zu manipulieren, es ist einfach zu speichern und weitergeben zurück. Zum Beispiel ein Datei-Handle.

Nichts kann einen Kunden davon abhalten, "subversiv" zu sein. Sie erstellen nur Rahmen, durch die Sie springen können, für jeden einzelnen Kunden. Ein einfacher Schein für einen allgemeinen Komponententest erfordert oft seltsames Verhalten, das in der Produktion keinen Sinn ergibt. Deine Interfaces sollten so geschrieben sein, dass es ihnen egal ist, wenn es überhaupt möglich ist.

Karl Bielefeldt
quelle
1
Dies fühlt sich eher wie ein Kommentar zu Ben / Daenyths Antworten an - dass Sie semantische Typen verwenden können, aber aufgrund der Unannehmlichkeiten nicht? Ich habe dich nicht abgelehnt, aber es ist keine Antwort auf diese Frage.
AlexFoxGill
-1 Es würde die Kompositionsfähigkeit oder Wiederverwendbarkeit überhaupt nicht beeinträchtigen. Wenn Idund Messageeinfache Wrapper für Intund sind String, ist es trivial , zwischen ihnen zu konvertieren.
Michael Shaw
Ja, es ist trivial, aber immer noch ärgerlich, es überall zu tun. Ich habe ein Beispiel hinzugefügt und ein wenig den Unterschied zwischen der Verwendung von Typ-Synonymen und Wrappern beschrieben.
Karl Bielefeldt,
Dies scheint eine Größe Sache. In kleinen Projekten sind diese Wrapper-Typen wahrscheinlich sowieso nicht so nützlich. In großen Projekten ist es natürlich so, dass die Grenzen einen kleinen Anteil des gesamten Codes ausmachen. Daher ist es nicht allzu schwierig, diese Arten von Konvertierungen an der Grenze vorzunehmen und dann umgebrochene Typen an allen anderen Stellen weiterzugeben. Wie Alex sagte, sehe ich auch keine Antwort auf die Frage.
Ben Aaronson
Ich erklärte, wie es geht und warum funktionierende Programmierer es nicht tun würden. Das ist eine Antwort, auch wenn es nicht die ist, die die Leute wollten. Es hat nichts mit Größe zu tun. Diese Idee, dass es irgendwie gut ist, die Wiederverwendung von Code absichtlich zu erschweren, ist definitiv ein OOP-Konzept. Erstellen Sie einen Produkttyp, der Mehrwert bringt? Die ganze Zeit. Erstellen eines Typs genau wie ein weit verbreiteter Typ, außer dass Sie ihn nur in einer Bibliothek verwenden können? Praktisch unbekannt, außer vielleicht in FP-Code, der stark mit OOP-Code interagiert oder von jemandem geschrieben wurde, der mit OOP-Idiomen vertraut ist.
Karl Bielefeldt,