So modellieren Sie dieses Beispiel
Wie könnte dies mit der Reader-Monade modelliert werden?
Ich bin mir nicht sicher, ob dies mit dem Reader modelliert werden soll, aber es kann sein durch:
- Codierung der Klassen als Funktionen, wodurch der Code mit Reader besser abgespielt werden kann
- Zusammenstellen der Funktionen mit Reader zum Verständnis und Verwenden
Kurz vor dem Start muss ich Ihnen über kleine Anpassungen des Beispielcodes berichten, die ich für diese Antwort als vorteilhaft empfunden habe. Bei der ersten Änderung geht es um die FindUsers.inactive
Methode. Ich lasse es zurückgeben, List[String]
damit die Liste der Adressen in der UserReminder.emailInactive
Methode verwendet werden kann. Ich habe auch einfache Implementierungen zu Methoden hinzugefügt. Schließlich wird im Beispiel eine folgende handgerollte Version der Reader-Monade verwendet:
case class Reader[Conf, T](read: Conf => T) { self =>
def map[U](convert: T => U): Reader[Conf, U] =
Reader(self.read andThen convert)
def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))
def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
Reader[BiggerConf, T](extractFrom andThen self.read)
}
object Reader {
def pure[C, A](a: A): Reader[C, A] =
Reader(_ => a)
implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
Reader(read)
}
Modellierungsschritt 1. Codieren von Klassen als Funktionen
Vielleicht ist das optional, ich bin mir nicht sicher, aber später sieht das Verständnis dadurch besser aus. Beachten Sie, dass die resultierende Funktion Curry ist. Es werden auch frühere Konstruktorargumente als erster Parameter (Parameterliste) verwendet. Dieser Weg
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)
wird
object Foo {
def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
Beachten Sie, dass jeder Dep
, Arg
, Res
Typen völlig willkürlich sein kann: ein Tupel, eine Funktion oder einen einfachen Typen.
Hier ist der Beispielcode nach den ersten Anpassungen, der in Funktionen umgewandelt wurde:
trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }
object FindUsers {
def inactive: Datastore => () => List[String] =
dataStore => () => dataStore.runQuery("select inactive")
}
object UserReminder {
def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}
object CustomerRelations {
def retainUsers(emailInactive: () => Unit): () => Unit =
() => {
println("emailing inactive users")
emailInactive()
}
}
Hierbei ist zu beachten, dass bestimmte Funktionen nicht von den gesamten Objekten abhängen, sondern nur von den direkt verwendeten Teilen. Wo in der OOP-Versionsinstanz hier nur UserReminder.emailInactive()
aufgerufen wird userFinder.inactive()
, wird nur aufgerufen inactive()
- eine Funktion, die im ersten Parameter an sie übergeben wird.
Bitte beachten Sie, dass der Code die drei wünschenswerten Eigenschaften aus der Frage aufweist:
- Es ist klar, welche Art von Abhängigkeiten jede Funktionalität benötigt
- verbirgt Abhängigkeiten einer Funktionalität von einer anderen
retainUsers
Methode sollte nicht über die Datenspeicherabhängigkeit wissen müssen
Modellierungsschritt 2. Verwenden des Readers zum Erstellen und Ausführen von Funktionen
Mit Reader Monad können Sie nur Funktionen erstellen, die alle vom selben Typ abhängen. Dies ist oft nicht der Fall. In unserem Beispiel
ist FindUsers.inactive
abhängig von Datastore
und UserReminder.emailInactive
auf EmailServer
. Um dieses Problem zu lösen, könnte man einen neuen Typ (oft als Config bezeichnet) einführen, der alle Abhängigkeiten enthält, und dann die Funktionen so ändern, dass sie alle davon abhängen und nur die relevanten Daten daraus entnehmen. Dies ist aus Sicht des Abhängigkeitsmanagements offensichtlich falsch, da Sie auf diese Weise diese Funktionen auch von Typen abhängig machen, über die sie überhaupt nichts wissen sollten.
Glücklicherweise stellt sich heraus, dass es eine Möglichkeit gibt, die Funktion zum Arbeiten zu bringen, Config
auch wenn nur ein Teil davon als Parameter akzeptiert wird. Es ist eine Methode namens local
, definiert in Reader. Es muss eine Möglichkeit bereitgestellt werden, das relevante Teil aus dem zu extrahieren Config
.
Dieses Wissen, das auf das vorliegende Beispiel angewendet wird, würde folgendermaßen aussehen:
object Main extends App {
case class Config(dataStore: Datastore, emailServer: EmailServer)
val config = Config(
new Datastore { def runQuery(query: String) = List("john.doe@fizzbuzz.com") },
new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
)
import Reader._
val reader = for {
getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
} yield retainUsers
reader.read(config)()
}
Vorteile gegenüber der Verwendung von Konstruktorparametern
In welchen Aspekten wäre die Verwendung der Reader-Monade für eine solche "Geschäftsanwendung" besser als nur die Verwendung von Konstruktorparametern?
Ich hoffe, dass ich es durch die Vorbereitung dieser Antwort leichter gemacht habe, selbst zu beurteilen, in welchen Aspekten es einfache Konstrukteure schlagen würde. Wenn ich diese jedoch aufzählen würde, wäre hier meine Liste. Haftungsausschluss: Ich habe OOP-Hintergrund und kann Reader und Kleisli möglicherweise nicht vollständig schätzen, da ich sie nicht verwende.
- Einheitlichkeit - egal wie kurz / lang das Verständnis ist, es ist nur ein Reader und Sie können es leicht mit einer anderen Instanz zusammenstellen, indem Sie möglicherweise nur einen weiteren Konfigurationstyp einführen und einige
local
Aufrufe darüber streuen . Dieser Punkt ist IMO eher Geschmackssache, denn wenn Sie Konstruktoren verwenden, hindert Sie niemand daran, alles zu komponieren, was Sie möchten, es sei denn, jemand tut etwas Dummes, wie die Arbeit im Konstruktor, was in OOP als schlechte Praxis angesehen wird.
- Reader ist eine Monade, so dass es alle Vorteile wird auf die in Verbindung stehend -
sequence
, traverse
Methoden kostenlos umgesetzt.
- In einigen Fällen ist es möglicherweise vorzuziehen, den Reader nur einmal zu erstellen und für eine Vielzahl von Konfigurationen zu verwenden. Bei Konstruktoren hindert Sie niemand daran. Sie müssen lediglich das gesamte Objektdiagramm für jede eingehende Konfiguration neu erstellen. Ich habe zwar kein Problem damit (ich bevorzuge es sogar, dies bei jeder Bewerbung zu tun), aber es ist für viele Menschen aus Gründen, über die ich möglicherweise nur spekuliere, keine offensichtliche Idee.
- Der Reader bringt Sie dazu, Funktionen stärker zu nutzen, was bei Anwendungen, die überwiegend im FP-Stil geschrieben sind, besser funktioniert.
- Leser trennt Bedenken; Sie können erstellen, mit allem interagieren, Logik definieren, ohne Abhängigkeiten bereitzustellen. Eigentlich später separat liefern. (Danke Ken Scrambler für diesen Punkt). Dies wird oft als Vorteil von Reader gehört, aber das ist auch mit einfachen Konstruktoren möglich.
Ich möchte auch sagen, was ich in Reader nicht mag.
- Marketing. Manchmal habe ich den Eindruck, dass Reader für alle Arten von Abhängigkeiten vermarktet wird, ohne Unterschied, ob es sich um ein Sitzungscookie oder eine Datenbank handelt. Für mich macht es wenig Sinn, Reader für praktisch konstante Objekte wie E-Mail-Server oder Repository aus diesem Beispiel zu verwenden. Für solche Abhängigkeiten finde ich einfache Konstruktoren und / oder teilweise angewendete Funktionen viel besser. Im Wesentlichen bietet Ihnen Reader Flexibilität, sodass Sie Ihre Abhängigkeiten bei jedem Anruf angeben können. Wenn Sie dies jedoch nicht wirklich benötigen, zahlen Sie nur die Steuer.
- Implizite Schwere - Die Verwendung von Reader ohne Implizite würde das Beispiel schwer lesbar machen. Wenn Sie andererseits die verrauschten Teile mit impliziten Elementen ausblenden und Fehler machen, gibt Ihnen der Compiler manchmal schwer zu entschlüsselnde Nachrichten.
- Zeremonie mit
pure
, local
und die Schaffung von eigenen Config - Klassen / Tupel für die Verwendung. Der Reader zwingt Sie, Code hinzuzufügen, bei dem es nicht um Problemdomänen geht, und führt daher zu Rauschen im Code. Andererseits verwendet eine Anwendung, die Konstruktoren verwendet, häufig ein Factory-Muster, das auch außerhalb des Problembereichs liegt, sodass diese Schwachstelle nicht so schwerwiegend ist.
Was ist, wenn ich meine Klassen nicht in Objekte mit Funktionen konvertieren möchte?
Sie wollen. Sie können das technisch vermeiden, aber schauen Sie, was passieren würde, wenn ich die FindUsers
Klasse nicht in ein Objekt konvertieren würde . Die jeweilige Zeile zum Verständnis würde folgendermaßen aussehen:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
was ist nicht so lesbar, oder? Der Punkt ist, dass Reader Funktionen ausführt. Wenn Sie sie also noch nicht haben, müssen Sie sie inline erstellen, was oft nicht so schön ist.