Comment modéliser cet exemple
Comment cela pourrait-il être modélisé avec la monade Reader?
Je ne sais pas si cela doit être modélisé avec le Reader, mais cela peut être par:
- encoder les classes comme des fonctions qui rend le code plus agréable avec Reader
- composition des fonctions avec Reader en vue de leur compréhension et utilisation
Juste avant le début, je dois vous parler de petits exemples d'ajustements de code qui m'ont semblé utiles pour cette réponse. Le premier changement concerne la FindUsers.inactive
méthode. Je le laisse revenir List[String]
pour que la liste d'adresses puisse être utilisée en UserReminder.emailInactive
méthode. J'ai également ajouté des implémentations simples aux méthodes. Enfin, l'exemple utilisera une version roulée à la main suivante de Reader monad:
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)
}
Étape de modélisation 1. Codage des classes en tant que fonctions
Peut-être que c'est facultatif, je ne suis pas sûr, mais plus tard, cela rendra la compréhension meilleure. Notez que la fonction résultante est curry. Il prend également les anciens arguments du constructeur comme premier paramètre (liste de paramètres). De cette façon
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
devient
object Foo {
def bar: Dep => Arg => Res = ???
}
Gardez à l' esprit que chacun Dep
, Arg
, Res
types peuvent être complètement arbitraire: un tuple, une fonction ou d' un type simple.
Voici l'exemple de code après les ajustements initiaux, transformé en fonctions:
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()
}
}
Une chose à noter ici est que des fonctions particulières ne dépendent pas des objets entiers, mais seulement des parties directement utilisées. Où, dans la version OOP, l' UserReminder.emailInactive()
instance appellerait userFinder.inactive()
ici, elle appelle simplement inactive()
- une fonction qui lui est passée dans le premier paramètre.
Veuillez noter que le code présente les trois propriétés souhaitables de la question:
- le type de dépendances dont chaque fonctionnalité a besoin est clair
- masque les dépendances d'une fonctionnalité à une autre
retainUsers
ne devrait pas avoir besoin de connaître la dépendance du magasin de données
Étape de modélisation 2. Utilisation du Reader pour composer des fonctions et les exécuter
Reader monad vous permet de ne composer que des fonctions qui dépendent toutes du même type. Ce n'est souvent pas un cas. Dans notre exemple
FindUsers.inactive
dépend de Datastore
et UserReminder.emailInactive
de EmailServer
. Pour résoudre ce problème, on pourrait introduire un nouveau type (souvent appelé Config) qui contient toutes les dépendances, puis changer les fonctions afin qu'elles en dépendent toutes et n'en tirent que les données pertinentes. Cela est évidemment faux du point de vue de la gestion des dépendances, car de cette façon, vous rendez ces fonctions également dépendantes de types qu'elles ne devraient pas connaître en premier lieu.
Heureusement, il s'avère qu'il existe un moyen de faire fonctionner la fonction Config
même si elle n'en accepte qu'une partie comme paramètre. C'est une méthode appelée local
, définie dans Reader. Il doit être fourni avec un moyen d'extraire la partie pertinente du fichier Config
.
Cette connaissance appliquée à l'exemple en question ressemblerait à ceci:
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)()
}
Avantages par rapport à l'utilisation des paramètres du constructeur
Dans quels aspects l'utilisation du Reader Monad pour une telle "application métier" serait-elle meilleure que la simple utilisation de paramètres de constructeur?
J'espère qu'en préparant cette réponse, je vous ai rendu plus facile de juger par vous-même sous quels aspects cela battrait-il les constructeurs simples. Pourtant, si je devais les énumérer, voici ma liste. Clause de non-responsabilité: j'ai une expérience en POO et je n'apprécie peut-être pas pleinement Reader et Kleisli car je ne les utilise pas.
- Uniformité - peu importe la longueur / courte de la compréhension, c'est juste un lecteur et vous pouvez facilement le composer avec une autre instance, peut-être en introduisant seulement un autre type de configuration et en saupoudrant quelques
local
appels dessus. Ce point est plutôt une question de goût à l'OMI, car lorsque vous utilisez des constructeurs, personne ne vous empêche de composer ce que vous voulez, à moins que quelqu'un ne fasse quelque chose de stupide, comme travailler dans un constructeur, ce qui est considéré comme une mauvaise pratique en POO.
- Reader est une monade, il obtient tous les avantages liés à cette -
sequence
, les traverse
méthodes mises en œuvre gratuitement.
- Dans certains cas, il peut être préférable de créer le Reader une seule fois et de l'utiliser pour une large gamme de configurations. Avec les constructeurs, personne ne vous empêche de faire cela, il vous suffit de créer à nouveau le graphe d'objets entier pour chaque configuration entrante. Bien que cela ne me pose aucun problème (je préfère même le faire à chaque demande de candidature), ce n'est pas une idée évidente pour beaucoup de gens pour des raisons sur lesquelles je ne peux que spéculer.
- Reader vous pousse à utiliser davantage les fonctions, qui fonctionneront mieux avec des applications écrites principalement dans le style FP.
- Le lecteur sépare les préoccupations; vous pouvez créer, interagir avec tout, définir la logique sans fournir de dépendances. En fait, fournir plus tard, séparément. (Merci Ken Scrambler pour ce point). On entend souvent cet avantage de Reader, mais c'est également possible avec des constructeurs simples.
Je voudrais également dire ce que je n'aime pas dans Reader.
- Commercialisation. Parfois, j'ai l'impression que Reader est commercialisé pour toutes sortes de dépendances, sans distinction s'il s'agit d'un cookie de session ou d'une base de données. Pour moi, l'utilisation de Reader pour des objets pratiquement constants n'a guère de sens, comme le serveur de messagerie ou le référentiel de cet exemple. Pour de telles dépendances, je trouve que les constructeurs simples et / ou les fonctions partiellement appliquées sont bien meilleurs. Essentiellement, Reader vous offre une flexibilité afin que vous puissiez spécifier vos dépendances à chaque appel, mais si vous n'en avez pas vraiment besoin, vous ne payez que sa taxe.
- Lourdeur implicite - l'utilisation de Reader sans implication rendrait l'exemple difficile à lire. D'un autre côté, lorsque vous masquez les parties bruyantes à l'aide d'implicits et que vous faites des erreurs, le compilateur vous donnera parfois des messages difficiles à déchiffrer.
- Cérémonie avec
pure
, local
et la création de classes propres / Config à l' aide tuples pour cela. Reader vous oblige à ajouter du code qui ne concerne pas le domaine problématique, introduisant ainsi du bruit dans le code. D'un autre côté, une application qui utilise des constructeurs utilise souvent un modèle d'usine, qui provient également de l'extérieur du domaine du problème, donc cette faiblesse n'est pas si grave.
Que faire si je ne souhaite pas convertir mes classes en objets avec des fonctions?
Tu veux. Vous pouvez techniquement éviter cela, mais regardez ce qui se passerait si je ne convertissais pas la FindUsers
classe en objet. La ligne respective de pour la compréhension ressemblerait à:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
ce qui n'est pas si lisible, n'est-ce pas? Le fait est que Reader fonctionne sur des fonctions, donc si vous ne les avez pas déjà, vous devez les construire en ligne, ce qui n'est souvent pas si joli.