Come modellare questo esempio
Come potrebbe essere modellato con la monade Reader?
Non sono sicuro che questo debba essere modellato con il Reader, ma può essere da:
- codificare le classi come funzioni che rendono il codice più piacevole con Reader
- comporre le funzioni con Reader in un per la comprensione e il suo utilizzo
Appena prima dell'inizio ho bisogno di parlarti di piccoli aggiustamenti del codice di esempio che ho ritenuto vantaggioso per questa risposta. Il primo cambiamento riguarda il FindUsers.inactive
metodo. Lascio che ritorni in List[String]
modo che l'elenco di indirizzi possa essere utilizzato nel UserReminder.emailInactive
metodo. Ho anche aggiunto semplici implementazioni ai metodi. Infine, l'esempio utilizzerà una seguente versione manuale della monade Reader:
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)
}
Fase di modellazione 1. Codifica delle classi come funzioni
Forse è facoltativo, non ne sono sicuro, ma in seguito migliora l'aspetto della comprensione. Nota, quella funzione risultante è curry. Accetta anche gli argomenti precedenti del costruttore come primo parametro (elenco di parametri). Quel modo
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
diventa
object Foo {
def bar: Dep => Arg => Res = ???
}
Tenere presente che ciascuna Dep
, Arg
, Res
tipi possono essere del tutto arbitraria: una tupla, una funzione o un tipo semplice.
Ecco il codice di esempio dopo le regolazioni iniziali, trasformato in funzioni:
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()
}
}
Una cosa da notare qui è che particolari funzioni non dipendono dall'intero oggetto, ma solo dalle parti utilizzate direttamente. Dove nella versione OOP l' UserReminder.emailInactive()
istanza chiamerebbe userFinder.inactive()
qui, chiama semplicemente inactive()
- una funzione passata ad essa nel primo parametro.
Tieni presente che il codice mostra le tre proprietà desiderabili dalla domanda:
- è chiaro che tipo di dipendenze necessita ogni funzionalità
- nasconde le dipendenze di una funzionalità da un'altra
retainUsers
non dovrebbe essere necessario conoscere la dipendenza Datastore
Fase di modellazione 2. Utilizzo del lettore per comporre funzioni ed eseguirle
Reader monad ti consente di comporre solo funzioni che dipendono tutte dallo stesso tipo. Questo spesso non è un caso. Nel nostro esempio
FindUsers.inactive
dipende da Datastore
e UserReminder.emailInactive
da EmailServer
. Per risolvere questo problema si potrebbe introdurre un nuovo tipo (spesso indicato come Config) che contiene tutte le dipendenze, quindi modificare le funzioni in modo che dipendano tutte da esso e trarre da esso solo i dati rilevanti. Questo ovviamente è sbagliato dal punto di vista della gestione delle dipendenze perché in questo modo si rendono queste funzioni anche dipendenti da tipi che non dovrebbero conoscere in primo luogo.
Fortunatamente si scopre che esiste un modo per far funzionare la funzione Config
anche se accetta solo una parte di essa come parametro. È un metodo chiamato local
, definito in Reader. Deve essere fornito un modo per estrarre la parte rilevante dal file Config
.
Questa conoscenza applicata all'esempio in esame sarebbe simile a quella:
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)()
}
Vantaggi rispetto all'utilizzo dei parametri del costruttore
In quali aspetti sarebbe meglio utilizzare Reader Monad per una simile "applicazione aziendale" rispetto al semplice utilizzo dei parametri del costruttore?
Spero che preparando questa risposta sia stato più facile giudicare da soli in quali aspetti avrebbe battuto i semplici costruttori. Tuttavia, se dovessi elencarli, ecco la mia lista. Dichiarazione di non responsabilità: ho un background OOP e potrei non apprezzare completamente Reader e Kleisli poiché non li uso.
- Uniformità - non importa quanto sia breve / lunga la comprensione, è solo un Reader e puoi comporlo facilmente con un'altra istanza, magari introducendo solo un altro tipo di configurazione e aggiungendovi
local
sopra alcune chiamate. Questo punto è IMO piuttosto una questione di gusti, perché quando usi i costruttori nessuno ti impedisce di comporre le cose che ti piacciono, a meno che qualcuno non faccia qualcosa di stupido, come lavorare in un costruttore che è considerato una cattiva pratica in OOP.
- Reader è una monade, quindi ottiene tutti i vantaggi relativi a questo
sequence
, traverse
metodi implementati gratuitamente.
- In alcuni casi potresti trovare preferibile costruire il Reader solo una volta e usarlo per un'ampia gamma di configurazioni. Con i costruttori nessuno ti impedisce di farlo, devi solo ricostruire l'intero oggetto grafico di nuovo per ogni configurazione in arrivo. Anche se non ho problemi con questo (preferisco persino farlo su ogni richiesta all'applicazione), non è un'idea ovvia per molte persone per ragioni su cui posso solo speculare.
- Reader ti spinge a utilizzare maggiormente le funzioni, che giocheranno meglio con applicazioni scritte prevalentemente in stile FP.
- Il lettore separa le preoccupazioni; puoi creare, interagire con tutto, definire la logica senza fornire dipendenze. Effettivamente fornire in seguito, separatamente. (Grazie Ken Scrambler per questo punto). Questo è spesso sentito trarre vantaggio da Reader, ma è anche possibile con semplici costruttori.
Vorrei anche dire cosa non mi piace in Reader.
- Marketing. A volte ho l'impressione che Reader sia commercializzato per tutti i tipi di dipendenze, senza distinzione se si tratta di un cookie di sessione o di un database. Per me ha poco senso usare Reader per oggetti praticamente costanti, come il server di posta elettronica o il repository di questo esempio. Per tali dipendenze trovo che i semplici costruttori e / o le funzioni parzialmente applicate siano molto migliori. Essenzialmente Reader ti offre flessibilità in modo da poter specificare le tue dipendenze ad ogni chiamata, ma se non ne hai davvero bisogno, paghi solo le sue tasse.
- Pesantezza implicita: l'utilizzo di Reader senza impliciti renderebbe l'esempio difficile da leggere. D'altra parte, quando nascondi le parti rumorose usando gli impliciti e commetti qualche errore, il compilatore a volte ti darà dei messaggi difficili da decifrare.
- Cerimonia con
pure
, local
e la creazione di classi Config / usando tuple per questo. Reader ti obbliga ad aggiungere del codice che non riguarda il dominio del problema, introducendo quindi un po 'di rumore nel codice. D'altra parte, un'applicazione che utilizza i costruttori spesso utilizza il modello di fabbrica, che è anche al di fuori del dominio del problema, quindi questa debolezza non è così grave.
E se non volessi convertire le mie classi in oggetti con funzioni?
Tu vuoi. Tecnicamente puoi evitarlo, ma guarda cosa succederebbe se non convertissi la FindUsers
classe in oggetto. La rispettiva riga di per la comprensione sarebbe simile a:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
che non è leggibile, vero? Il punto è che Reader opera sulle funzioni, quindi se non le hai già, devi costruirle in linea, il che spesso non è così carino.