Este artigo foi inicialmente um apêndice em nossa Programação Reativa com RxJava livro. No entanto, uma introdução às mônadas, embora muito relacionada à Programação Reativa, não se adequava muito bem. Então eu decidi tirá-lo e publicar isso separadamente como um post no blog. Estou ciente de que” minha própria explicação, meio correta e meio completa das mônadas “é o novo” Olá, mundo ” em blogs de programação. No entanto, o artigo analisa functors e mônadas de um ângulo específico de estruturas de dados e bibliotecas Java. Assim, eu pensei que vale a pena compartilhar.
RxJava foi projetado e construído sobre conceitos muito fundamentais como functores, monóides e mônadas. Embora o Rx tenha sido modelado inicialmente para a linguagem imperativa C# e estejamos aprendendo sobre o RxJava, trabalhando em cima de uma linguagem igualmente imperativa, a biblioteca tem suas raízes na programação funcional. Você não deve se surpreender depois de perceber o quão compacta é a API RxJava. Há praticamente apenas um punhado de classes principais, tipicamente imutáveis, e tudo é composto usando principalmente funções puras.
com um recente aumento da programação funcional (ou estilo funcional), mais comumente expresso em linguagens modernas como Scala ou Clojure, as mônadas se tornaram um tópico amplamente discutido. Há muito folclore em torno deles:
uma mônada é um monóide na categoria de endofunctores, Qual é o problema?James Iry A maldição da mônada é que, uma vez que você obtém a Epifania, uma vez que você entende – “oh, isso é o que é” – você perde a capacidade de explicá-la a qualquer um.
Douglas Crockford
a grande maioria dos programadores, especialmente aqueles sem formação em programação funcional, tendem a acreditar que as mônadas são algum conceito Arcano de ciência da computação, tão teórico que não pode ajudar em sua carreira de programação. Essa perspectiva negativa pode ser atribuída a dezenas de artigos e postagens de blog sendo muito abstratas ou muito estreitas. Mas acontece que as mônadas estão ao nosso redor, mesmo em uma biblioteca Java padrão, especialmente desde Java Development Kit (JDK) 8 (mais sobre isso mais tarde). O que é absolutamente brilhante é que, uma vez que você entenda mônadas pela primeira vez, de repente várias classes e abstrações não relacionadas, servindo a propósitos totalmente diferentes, se tornam familiares.
as mônadas generalizam vários conceitos aparentemente independentes, de modo que aprender mais uma encarnação da mônada leva muito pouco tempo. Por exemplo, você não precisa aprender como Completamentefuture funciona em Java 8 – Depois de perceber que é uma mônada, você sabe exatamente como funciona e o que você pode esperar de sua semântica. E então você ouve sobre RxJava que soa muito diferente, mas porque Observável é uma mônada, não há muito a acrescentar. Existem inúmeros outros exemplos de mônadas que você já encontrou sem saber disso. Portanto, esta seção será uma atualização útil mesmo se você não conseguir realmente usar RxJava.
Functors
Antes de explicarmos o que é uma mônada, vamos explorar uma construção mais simples chamada functor . Um functor é uma estrutura de dados digitada que encapsula algum(s) Valor (s). De uma perspectiva sintática, um functor é um contêiner com a seguinte API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
mas mera sintaxe não é suficiente para entender o que é um functor. A única operação que o functor fornece é map () que assume uma função F. Esta função recebe o que está dentro de uma caixa, transforma-a e envolve o resultado como-é em um segundo functor. Por favor, leia isso com atenção. Functor < T > é sempre um contêiner imutável, portanto, o map nunca muda o objeto original em que foi executado. Em vez disso, ele retorna o resultado(ou resultados – seja paciente) envolto em um novo functor, possivelmente de tipo diferente R. além disso, os functores não devem executar nenhuma ação quando a função de identidade é aplicada, ou seja, map (x -> x). Esse padrão deve sempre retornar o mesmo functor ou uma instância igual.
frequentemente Functor < T > é comparado a uma instância de retenção de caixa de T onde a única maneira de interagir com esse valor é transformando-o. No entanto, não existe uma maneira idiomática de desembrulhar ou escapar do functor. O(S) Valor (s) sempre ficam dentro do contexto de um functor. Por que os functors são úteis? Eles generalizam vários idiomas comuns, como coleções, promessas, opcionais, etc. com uma API única e uniforme que funciona em todos eles. Deixe-me apresentar alguns functors para torná – lo mais fluente com esta API:
interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}
um parâmetro de tipo F extra foi necessário para fazer a compilação de identidade. O que você viu no exemplo anterior foi o functor mais simples que tinha apenas um valor. Tudo o que você pode fazer com esse valor é transformá-lo dentro do método map, mas não há como extraí-lo. Isso é considerado além do escopo de um functor puro. A única maneira de interagir com o functor é aplicando sequências de transformações seguras para tipos:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
ou fluentemente, assim como você compõe funções:
Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);
dessa perspectiva, o mapeamento sobre um functor não é muito diferente do que apenas invocar funções encadeadas:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
por que você se preocuparia com esse envolvimento detalhado que não apenas não fornece nenhum valor agregado, mas também não é capaz de extrair o conteúdo de volta? Bem, acontece que você pode modelar vários outros conceitos usando essa abstração functor bruta. Por exemplo, a partir do Java 8 opcional é um functor com o método map (). Vamos implementá-lo do zero:
class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}
agora torna-se interessante. Um functor FOptional<T>
pode conter um valor, mas também pode estar vazio. É uma maneira segura de codificar null
. Existem duas maneiras de construir FOptional
– fornecendo um valor ou criando uma instância empty()
. Em ambos os casos, assim como com Identity
,FOptional
é imutável e só podemos interagir com o valor de dentro. O que difereFOptional
é que a função de transformação f
pode não ser aplicada a nenhum valor se estiver vazia. Isso significa que o functor pode não necessariamente encapsular exatamente um valor do tipo T
. Ele também pode envolver um número arbitrário de valores, assim como List
… functor:
import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}
a API permanece a mesma: você pega um functor em uma transformação – mas o comportamento é muito diferente. Agora aplicamos uma transformação em cada item do FList, transformando declarativamente toda a lista. Então, se você tem uma lista de clientes e você quer uma lista de suas ruas, é tão simples como:
import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);
Ele não é mais tão simples como dizer customers.getAddress().street()
, você não pode chamargetAddress()
em uma coleção de clientes, você deve chamar getAddress()
em cada cliente individual e, em seguida, colocá-lo de volta em uma coleção. A propósito, Groovy achou esse padrão tão comum que ele realmente tem um açúcar de sintaxe para isso: customer*.getAddress()*.street()
. Este operador, conhecido como spread-dot, é na verdade um map
disfarçado. Talvez você esteja se perguntando Por que eu iTero list
manualmente dentro map
em vez de usar Stream
s do Java 8:list.stream().map(f).collect(toList())
? Isto toca um sino? E se eu lhe dissessejava.util.stream.Stream<T>
em Java também é um functor? E a propósito, também uma mônada?
agora você deve ver os primeiros benefícios dos functors-eles abstraem a representação interna e fornecem API consistente e fácil de usar em várias estruturas de dados. Como o último exemplo, deixe-me apresentar o functor promise, semelhante a Future
. Promise
“promete” que um valor se tornará disponível um dia. Ainda não está lá, talvez porque algum cálculo de fundo foi gerado ou estamos esperando por um evento externo. Mas aparecerá em algum momento no futuro. A mecânica de completar um Promise<T>
não é interessante, mas a natureza functor é:
Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);
parece familiar? Esse é o ponto! A implementação do functor está além do escopo deste artigo e nem mesmo é importante. O suficiente para dizer que estamos muito perto de implementar CompletableFuture de Java 8 e quase descobrimos Observable de RxJava. Mas voltando aos functors. Promise < cliente > ainda não possui um valor de cliente. Promete ter esse valor no futuro. Mas ainda podemos mapear esse functor, assim como fizemos com FOptional e FList – a sintaxe e a semântica são exatamente as mesmas. O comportamento segue o que o functor representa. Invocando o cliente.mapa (cliente::getAddress) rende promessa<endereço>, o que significa que o mapa não está bloqueando. cliente.mapa() o cliente promete concluir. Em vez disso, ele retorna outra promessa, de um tipo diferente. Quando a promessa upstream for concluída, a promessa downstream aplica uma função passada para map() e passa o resultado downstream. De repente, nosso functor nos permite pipeline cálculos assíncronos de maneira não bloqueadora. Mas você não precisa entender ou aprender isso – porque Promise é um functor, ele deve seguir a sintaxe e as leis.
existem muitos outros grandes exemplos de functores, por exemplo, representando valor ou erro de forma composicional. Mas é hora de olhar para mônadas.
de Functors a mônadas
presumo que você entenda como os functors funcionam e por que eles são uma abstração útil. Mas os functores não são tão universais quanto se poderia esperar. O que acontece se sua função de transformação (aquela passada como um argumento para map()) retornar a instância do functor em vez de um valor simples? Bem, um functor também é apenas um valor, então nada de ruim acontece. Tudo o que foi retornado é colocado de volta em um functor para que todos se comportem de forma consistente. No entanto, imagine que você tenha esse método útil para analisar Strings:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
exceções são efeitos colaterais que prejudicam o tipo de sistema e a pureza funcional. Em linguagens funcionais puras, não há lugar para exceções. Afinal, nunca ouvimos falar em lançar exceções durante as aulas de matemática, certo? Erros e Condições ilegais são representados explicitamente usando valores e wrappers. Por exemplo, triparse () pega uma String, mas não retorna simplesmente um int ou lança silenciosamente uma exceção em tempo de execução. Nós explicitamente dizemos, através do sistema de tipo, que triparse() pode falhar, não há nada excepcional ou errôneo em ter uma string malformada. Esta Semi-falha é representada por um resultado opcional. Curiosamente, o Java verificou exceções, as que devem ser declaradas e tratadas, portanto, em algum sentido, o Java é mais puro a esse respeito, não esconde efeitos colaterais. Mas, para melhores ou piores exceções verificadas, muitas vezes são desencorajadas em Java, então vamos voltar para triparse(). Parece útil compor triparse com String já envolvida em FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
isso não deve ser uma surpresa. Se tryParse()
retornasse um int
você receberiaFOptional<Integer> num
, mas porque map()
a função retorna FOptional<Integer>
em si, ela é envolvida duas vezes em inábil FOptional<FOptional<Integer>>
. Por favor, olhe atentamente para os tipos, você deve entender por que temos este invólucro duplo aqui. Além de procura horrível, ter um functor em functor ruínas composição e fluente encadeamento:
FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));
Aqui vamos tentar mapear o conteúdo de FOptional
rodando int
em +Data+. Tendo uma função de int -> Date
podemos facilmente transformar de Functor<Integer>
para Functor<Date>
, sabemos como funciona. Mas no caso de num2
A situação se torna complicada. O que num2.map()
recebe como entrada não é mais um int
, mas um FOoption<Integer>
e obviamentejava.util.Date
não tem esse construtor. Quebramos nosso functor envolvendo-o duas vezes. No entanto, ter uma função que retorna um functor em vez de um valor simples é tão comum (comotryParse()
) que não podemos simplesmente ignorar esse requisito. Uma abordagem é introduzir um método especial sem parâmetros join()
que “achata” functores aninhados:
FOptional<Integer> num3 = num2.join()
funciona, mas como esse padrão é tão comum, um método especial chamado flatMap()
foi introduzido. flatMap()
é muito semelhante ao map
mas espera que a função recebe como argumento para retornar um functor – ou mônada para ser mais preciso:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
Nós simplesmente concluiu que flatMap
é apenas um açúcar sintático para permitir uma melhor composição. Mas o métodoflatMap
(frequentemente chamado bind
ou >>=
de Haskell) faz toda a diferença, pois permite que transformações complexas sejam compostas em um estilo puro e funcional. Se FOptional
foi uma instância de mônada, de análise, de repente, funciona como esperado:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
Mônadas não precisa implementar map
, ele pode ser implementado em cima de flatMap()
facilmente. Na verdade, flatMap
é o operador essencial que permite todo um novo universo de transformações. Obviamente, assim como com os functors, a conformidade sintática não é suficiente para chamar alguma mônada de classe a, o operador flatMap()
tem que seguir as leis da mônada, mas elas são bastante intuitivas como a associatividade de flatMap()
e identidade. Este último requer que m(x).flatMap(f)
seja o mesmo quef(x)
para qualquer mônada com um valor x
e qualquer função f
. Não vamos mergulhar muito fundo na teoria das mônadas, em vez disso, vamos nos concentrar em implicações práticas. As mônadas brilham quando sua estrutura interna não é trivial, por exemplo Promise
mônada que terá um valor no futuro. Você pode adivinhar a partir do sistema de tipos como Promise
se comportará no seguinte programa? Primeiro, todos os métodos que potencialmente pode levar algum tempo para concluir um retorno Promise
:
import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}
agora podemos compor essas funções como se fossem todos bloqueio de uso monádico operadores:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
Isso se torna interessante. flatMap()
deve preservar o tipo monádico, portanto, todos os objetos intermediários são Promise
S. Não se trata apenas de manter os tipos em ordem-o programa anterior é repentinamente totalmente assíncrono! loadCustomer()
retorna um Promise
para que ele não bloqueie. readBasket()
leva o que o Promise
tem (terá) e aplica uma função retornando outro Promise
e assim por diante e assim por diante. Basicamente, construímos um pipeline assíncrono de computação em que a conclusão de uma etapa em segundo plano aciona automaticamente a próxima etapa.
explorando flatMap ()
é muito comum ter duas mônadas e combinar o valor que elas envolvem. No entanto, tanto os functores quanto as mônadas não permitem acesso direto aos seus internos, o que seria impuro. Em vez disso, devemos aplicar cuidadosamente a transformação sem escapar da mônada. Imagine que você tem duas mônadas e quer combiná – las:
import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));
por favor, tome seu tempo para estudar o pseudo-código anterior. Não Uso nenhuma implementação de mônada real como Promise
ou List
para enfatizar o conceito principal. Temos duas mônadas independentes, uma do tipo Month
e a outra do tipo Integer
. Para construir LocalDate
deles, devemos construir uma transformação aninhada que tenha acesso aos internos de ambas as mônadas. Trabalhe com os tipos, especialmente certificando-se de entender por que usamos flatMap
em um lugar emap()
no outro. Pense em como você estruturaria esse código se tivesse um terceiro Monad<Year>
também. Este padrão da aplicação de uma função de dois argumentos (m
e d
no nosso caso) é tão comum que, em Haskell é função de auxiliar chamado liftM2
que faz exatamente essa transformação, implementado em cima de map
e flatMap
. Na pseudo-sintaxe Java, ficaria um pouco assim:
Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}
você não precisa implementar este método para cada mônada, flatMap()
é suficiente, além disso, funciona de forma consistente para todas as mônadas. liftM2
é extremamente útil quando você considera como ele pode ser usado com várias mônadas. Por exemplo, listM2(list1, list2, function)
será aplicado function
em cada par possível de itens de list1
e list2
(produto cartesiano). Por outro lado, para Opções, ele aplicará uma função somente quando ambas as opções não estiverem vazias. Melhor ainda, para uma mônada Promise
, uma função será executada de forma assíncrona quando ambos os Promise
s forem concluídos. Isso significa que acabamos de inventar um mecanismo de sincronização simples (join()
em algoritmos fork-join) de duas etapas assíncronas.
outro operador útil que podemos construir facilmente em cima de flatMap()
é filter(Predicate<T>)
que pega o que está dentro de uma mônada e a descarta inteiramente se não atender a determinado predicado. De certa forma, é semelhante a map
, mas em vez de mapeamento de 1 Para 1, temos 1 para 0 ou 1. Novamente filter()
tem a mesma semântica para cada mônada, mas funcionalidade bastante incrível, dependendo de qual mônada realmente usamos. Obviamente, permite filtrar certos elementos de uma lista:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
mas funciona tão bem, por exemplo, para opções. Nesse caso, podemos transformar opcional não vazio em vazio se o conteúdo do Opcional não atender a alguns critérios. As opções vazias são deixadas intactas.
da lista de mônadas para Mônada da lista
outro operador útil que se origina de flatMap() é sequence(). Você pode facilmente adivinhar o que ele faz simplesmente observando o tipo de assinatura:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
muitas Vezes temos um monte de mônadas do mesmo tipo e queremos ter uma única mônada de uma lista desse tipo. Isso pode parecer abstrato para você, mas é impressionantemente útil. Imagine que você queria carregar alguns clientes do banco de dados simultaneamente por ID para que você usasse o método loadCustomer(id)
várias vezes para IDs diferentes, cada invocação retornando Promise<Customer>
. Agora você tem uma lista de Promise
s, mas o que você realmente quer é uma lista de clientes, por exemplo, para ser exibido no navegador da web. O operador sequence()
(em RxJava sequence()
é chamado concat()
ou merge()
, dependendo do caso de uso) é construído apenas para isso:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
tendo um FList<Integer>
representando IDs de clientes nós map
sobre ele (você vê como isso ajuda que FList
é um functor?) chamando database.loadCustomer(id)
para cada ID. Isso leva a uma lista bastante inconveniente de Promise
S. sequence()
salva o dia, mas mais uma vez isso não é apenas um açúcar sintático. O código anterior é totalmente sem bloqueio. Para diferentes tipos de mônadas sequence()
ainda faz sentido, mas em um contexto computacional diferente. Por exemplo, ele pode mudar FList<FOptional<T>>
para FOptional<FList<T>>
. E, a propósito, você pode implementarsequence()
(assim como map()
) em cima de flatMap()
.
esta é apenas a ponta do iceberg quando se trata da utilidade de flatMap()
e mônadas em geral. Apesar de vir de uma teoria de categoria Obscura, as mônadas provaram ser abstrações extremamente úteis, mesmo em linguagens de programação orientadas a objetos, como Java. Ser capaz de compor funções retornando mônadas é tão universalmente útil que dezenas de classes não relacionadas seguem o comportamento monádico.Além disso, depois de encapsular dados dentro de uma mônada, muitas vezes é difícil tirá-los explicitamente. Tal operação não faz parte do comportamento da mônada e muitas vezes leva a código não idiomático. Por exemplo, Promise.get()
on Promise<T>
pode tecnicamente retornar T
, mas apenas bloqueando, enquanto todos os operadores baseados em flatMap()
não estão bloqueando. Outro exemplo é FOptional.get()
, mas isso pode falhar porque FOptional
pode estar vazio. Mesmo FList.get(idx)
que espreita elemento particular de uma lista parece estranho porque você pode substituir for
loops com map()
com bastante frequência.
espero que agora você entenda por que as mônadas são tão populares nos dias de hoje. Mesmo em uma linguagem orientada a objetos (- ish) como Java, eles são uma abstração bastante útil.