Como modelar este exemplo
Como isso poderia ser modelado com a mônada do Reader?
Não tenho certeza se isso deve ser modelado com o Reader, mas pode ser por:
- codificar as classes como funções que tornam o código mais agradável com o Reader
- compondo as funções com o Reader em um para compreensão e usando-o
Um pouco antes de começar, preciso falar sobre pequenos ajustes de código de amostra que achei benéficos para esta resposta. A primeira mudança é sobre o FindUsers.inactive
método. Deixo retornar List[String]
para que a lista de endereços possa ser usada no UserReminder.emailInactive
método. Também adicionei implementações simples aos métodos. Por fim, o exemplo usará a seguinte versão enrolada à mão do 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)
}
Etapa de modelagem 1. Classificando classes como funções
Talvez seja opcional, não tenho certeza, mas depois faz com que o for compreensão pareça melhor. Observe que a função resultante é curry. Também leva os argumentos do construtor anteriores como primeiro parâmetro (lista de parâmetros). Dessa maneira
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)
torna-se
object Foo {
def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
Tenha em mente que cada um Dep
, Arg
, Res
tipos podem ser completamente arbitrária: a tupla, uma função ou um tipo simples.
Aqui está o código de amostra após os ajustes iniciais, transformado em funções:
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()
}
}
Uma coisa a notar aqui é que funções específicas não dependem dos objetos inteiros, mas apenas das partes diretamente usadas. Onde na versão OOP a UserReminder.emailInactive()
instância chamaria userFinder.inactive()
aqui, ela apenas chama inactive()
- uma função passada a ele no primeiro parâmetro.
Observe que o código exibe as três propriedades desejáveis da questão:
- é claro que tipo de dependências cada funcionalidade precisa
- esconde as dependências de uma funcionalidade de outra
retainUsers
método não precisa saber sobre a dependência do Datastore
Etapa de modelagem 2. Usando o Reader para compor funções e executá-las
A mônada do leitor permite apenas compor funções que dependam do mesmo tipo. Isso geralmente não é o caso. Em nosso exemplo
FindUsers.inactive
depende de Datastore
e UserReminder.emailInactive
para EmailServer
. Para resolver esse problema, pode-se introduzir um novo tipo (freqüentemente referido como Config) que contém todas as dependências e, em seguida, alterar as funções para que todas dependam dele e retirar apenas os dados relevantes. Obviamente, isso está errado do ponto de vista do gerenciamento de dependência, porque dessa forma você torna essas funções também dependentes de tipos que elas não deveriam conhecer em primeiro lugar.
Felizmente, verifica-se que existe uma maneira de fazer a função funcionar, Config
mesmo que ela aceite apenas uma parte dela como parâmetro. É um método chamado local
, definido no Reader. Ele precisa ser fornecido com uma maneira de extrair a parte relevante do Config
.
Esse conhecimento aplicado ao exemplo em questão seria assim:
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)()
}
Vantagens sobre o uso de parâmetros do construtor
Em quais aspectos usar o Reader Monad para tal "aplicativo de negócios" seria melhor do que apenas usar parâmetros do construtor?
Espero que, ao preparar esta resposta, tenha tornado mais fácil julgar por si mesmo em quais aspectos ela superaria os construtores simples. No entanto, se eu fosse enumerá-los, aqui está minha lista. Isenção de responsabilidade: Eu tenho experiência OOP e posso não apreciar o Reader e o Kleisli totalmente, pois não os uso.
- Uniformidade - não importa quão curto / longo seja o for compreensão, é apenas um Reader e você pode facilmente compor com outra instância, talvez apenas introduzindo mais um tipo de Config e polvilhando algumas
local
chamadas em cima dele. Este ponto é IMO mais uma questão de gosto, porque quando você usa construtores ninguém o impede de compor qualquer coisa que você queira, a menos que alguém faça algo estúpido, como trabalhar no construtor que é considerado uma prática ruim em OOP.
- Reader é uma mônada, por isso, recebe todos os benefícios relacionados a isso -
sequence
, traverse
métodos implementados de forma gratuita.
- Em alguns casos, pode ser preferível construir o Reader apenas uma vez e usá-lo para uma ampla variedade de configurações. Com construtores, ninguém impede que você faça isso, você só precisa construir todo o gráfico do objeto novamente para cada Config recebido. Embora eu não tenha nenhum problema com isso (prefiro até mesmo fazer isso em cada solicitação de inscrição), não é uma ideia óbvia para muitas pessoas, por razões sobre as quais posso apenas especular.
- O Reader incentiva você a usar mais funções, que funcionarão melhor com aplicativos escritos predominantemente no estilo FP.
- O leitor separa as preocupações; você pode criar, interagir com tudo, definir lógica sem fornecer dependências. Na verdade, forneça mais tarde, separadamente. (Obrigado Ken Scrambler por este ponto). Isso costuma ser uma vantagem do Reader, mas também é possível com construtores simples.
Também gostaria de dizer o que não gosto no Reader.
- Marketing. Às vezes tenho a impressão de que o Reader é comercializado para todos os tipos de dependências, sem distinção se é um cookie de sessão ou um banco de dados. Para mim, não faz sentido usar o Reader para objetos praticamente constantes, como servidor de e-mail ou repositório deste exemplo. Para tais dependências, acho construtores simples e / ou funções parcialmente aplicadas muito melhores. Essencialmente, o Reader oferece flexibilidade para que você possa especificar suas dependências em cada chamada, mas se você realmente não precisar disso, você só paga seus impostos.
- Peso implícito - usar o Reader sem implícito tornaria o exemplo difícil de ler. Por outro lado, quando você oculta as partes barulhentas usando implícitos e comete alguns erros, o compilador às vezes deixa você difícil de decifrar mensagens.
- Cerimônia com
pure
, local
e criando próprias classes de Config / usando tuplas para isso. O Reader força você a adicionar algum código que não seja sobre o domínio do problema, portanto, introduzindo algum ruído no código. Por outro lado, um aplicativo que usa construtores geralmente usa o padrão de fábrica, que também vem de fora do domínio do problema, então esse ponto fraco não é tão sério.
E se eu não quiser converter minhas classes em objetos com funções?
Você quer. Você pode evitar tecnicamente isso, mas veja o que aconteceria se eu não convertesse FindUsers
classe em objeto. A respectiva linha de compreensão seria semelhante a:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
que não é tão legível, não é? A questão é que o Reader opera em funções, então, se você ainda não as tem, precisa construí-las embutidas, o que geralmente não é muito bonito.