Cómo modelar este ejemplo
¿Cómo podría modelarse esto con la mónada Reader?
No estoy seguro de si esto debería modelarse con el Reader, pero puede ser así:
- codificar las clases como funciones, lo que hace que el código funcione mejor con Reader
- componiendo las funciones con Reader en un para su comprensión y uso
Justo antes del comienzo, necesito informarle sobre pequeños ajustes de código de muestra que me parecieron beneficiosos para esta respuesta. El primer cambio tiene que ver con el FindUsers.inactive
método. Dejo que regrese List[String]
para que la lista de direcciones se pueda usar en el UserReminder.emailInactive
método. También agregué implementaciones simples a los métodos. Finalmente, la muestra utilizará una siguiente versión enrollada a mano de Reader mónada:
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)
}
Paso de modelado 1. Codificar clases como funciones
Tal vez sea opcional, no estoy seguro, pero luego hace que la comprensión se vea mejor. Tenga en cuenta que la función resultante se curry. También toma los argumentos del constructor anterior como primer parámetro (lista de parámetros). De esa manera
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
se convierte en
object Foo {
def bar: Dep => Arg => Res = ???
}
Tenga en cuenta que cada una de Dep
, Arg
, Res
tipos pueden ser completamente arbitraria: una tupla, una función o un tipo simple.
Aquí está el código de muestra después de los ajustes iniciales, transformado en funciones:
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 a tener en cuenta aquí es que las funciones particulares no dependen de todos los objetos, sino solo de las partes que se usan directamente. Donde en la versión OOP la UserReminder.emailInactive()
instancia llamaría userFinder.inactive()
aquí, solo llama inactive()
: una función que se le pasa en el primer parámetro.
Tenga en cuenta que el código presenta las tres propiedades deseables de la pregunta:
- está claro qué tipo de dependencias necesita cada funcionalidad
- oculta las dependencias de una funcionalidad de otra
retainUsers
El método no debería necesitar conocer la dependencia de Datastore
Modelado del paso 2. Uso del Reader para componer funciones y ejecutarlas
Reader monad le permite componer funciones que dependen todas del mismo tipo. A menudo, este no es un caso. En nuestro ejemplo
FindUsers.inactive
depende de Datastore
y UserReminder.emailInactive
de EmailServer
. Para resolver ese problema, uno podría introducir un nuevo tipo (a menudo denominado Config) que contenga todas las dependencias, luego cambiar las funciones para que todas dependan de él y solo tomen de él los datos relevantes. Obviamente, eso es incorrecto desde la perspectiva de la administración de dependencias porque de esa manera hace que estas funciones también dependan de tipos que no deberían conocer en primer lugar.
Afortunadamente, resulta que existe una forma de hacer que la función funcione Config
incluso si acepta solo una parte de ella como parámetro. Es un método llamado local
, definido en Reader. Debe contar con una forma de extraer la parte relevante del archivo Config
.
Este conocimiento aplicado al ejemplo en cuestión se vería así:
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)()
}
Ventajas sobre el uso de parámetros de constructor
¿En qué aspectos sería mejor utilizar Reader Monad para una "aplicación empresarial" de este tipo que simplemente utilizar parámetros de constructor?
Espero que al preparar esta respuesta haya hecho que sea más fácil juzgar por sí mismo en qué aspectos vencería a los constructores simples. Sin embargo, si tuviera que enumerarlos, aquí está mi lista. Descargo de responsabilidad: tengo experiencia en programación orientada a objetos y es posible que no aprecie completamente a Reader y Kleisli ya que no los uso.
- Uniformidad: no importa cuán corto / largo sea el para la comprensión, es solo un lector y puede componerlo fácilmente con otra instancia, quizás solo introduciendo un tipo de configuración más y agregando algunas
local
llamadas encima. Este punto es en mi opinión más bien una cuestión de gustos, porque cuando usas constructores nadie te impide componer lo que quieras, a menos que alguien haga algo estúpido, como trabajar en constructor, lo cual se considera una mala práctica en OOP.
- Reader es una mónada, por lo que obtiene todos los beneficios relacionados con eso
sequence
, traverse
métodos implementados de forma gratuita.
- En algunos casos, puede que sea preferible compilar el Reader solo una vez y usarlo para una amplia gama de configuraciones. Con los constructores, nadie le impide hacer eso, solo necesita construir todo el gráfico de objetos de nuevo para cada configuración entrante. Si bien no tengo ningún problema con eso (incluso prefiero hacer eso en cada solicitud a la solicitud), no es una idea obvia para muchas personas por razones sobre las que solo puedo especular.
- Reader lo empuja a usar más funciones, lo que funcionará mejor con aplicaciones escritas predominantemente en estilo FP.
- El lector separa preocupaciones; puedes crear, interactuar con todo, definir la lógica sin proporcionar dependencias. En realidad, suministre más tarde, por separado. (Gracias Ken Scrambler por este punto). Esto a menudo se escucha como una ventaja de Reader, pero también es posible con constructores simples.
También me gustaría contar lo que no me gusta en Reader.
- Márketing. A veces tengo la impresión de que Reader se comercializa para todo tipo de dependencias, sin distinción de si es una cookie de sesión o una base de datos. Para mí, tiene poco sentido usar Reader para objetos prácticamente constantes, como el servidor de correo electrónico o el repositorio de este ejemplo. Para tales dependencias, encuentro mejores constructores simples y / o funciones parcialmente aplicadas. Esencialmente, Reader le brinda flexibilidad para que pueda especificar sus dependencias en cada llamada, pero si realmente no lo necesita, solo paga sus impuestos.
- Pesadez implícita: usar Reader sin implícitos haría que el ejemplo fuera difícil de leer. Por otro lado, cuando oculta las partes ruidosas usando implícitos y comete algún error, el compilador a veces le dará mensajes difíciles de descifrar.
- Ceremonia con
pure
, local
y la creación de propias clases config / tuplas usando para ello. Reader te obliga a agregar un código que no se trata del dominio del problema, por lo que introduce algo de ruido en el código. Por otro lado, una aplicación que usa constructores a menudo usa un patrón de fábrica, que también es externo al dominio del problema, por lo que esta debilidad no es tan grave.
¿Qué pasa si no quiero convertir mis clases en objetos con funciones?
Usted quiere. Técnicamente puedes evitar eso, pero mira lo que sucedería si no convirtiera la FindUsers
clase en objeto. La línea respectiva de para la comprensión se vería así:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
que no es tan legible, ¿verdad? El punto es que Reader opera con funciones, por lo que si aún no las tiene, debe construirlas en línea, lo que a menudo no es tan bonito.