Como fazer callbacks em Java? Uma análise nas interfaces funcionais padrões
Recebi uma chamada do Camilo @lixeletto.bsky.social clamando por socorro no BlueSky:
Javeires desse site, to tentando fazer um tipo pra um parâmetro de uma função Java que é um callback, se fosse Javascript eu faria uma interface ou type assim, no Java isso se faz com @FunctionalInterface?
Bem, isso significa que nosso amigo Camilo está perdido em callbacks no Java. Bora ajudar ele?
O callback simples
Vou fazer uma ação longa e depois preciso alertar o usuário que ela terminou. Como que fazemos isso? Imagina que estamos no browser e que queremos lançar um popup na melhor estética de alertas de site do começo dos anos 2000.
async function doSomething(params) {
//... código longo e complicado
}
O que precisamos nesse caso é algo que lance o alerta para o usuário no final do processo. Poderia fazer isso aqui:
await doSomething(myArgs);
alert('oie, a ação acabou e eu queria te avisar isso');
Tá, mas e sem async/await ou promises, como se faria isso? Basicamente
passando a ação para a função. Ficaria mais ou menos assim:
// declaração
async function doSomething(params, callback) {
//... código longo e complicado
callback();
}
// uso
doSomething(myArgs, () => alert('oie, a ação acabou e eu queria te avisar isso'));
No mundo Java, como seria? Bem, a parte de uso seria bem semelhante:
doSomething(myArgs, () -> Popup.alert("oie, a ação acabou e eu queria te avisar isso"));
considerando aqui um Popup.alert(String) como sendo a maneira correta de se
criar um alerta qualquer. Certo, mas tem algum outro exemplo? Na real, tem sim.
Inclusive muito semelhante a este.
Tome aqui o exemplo de hello, world do Express.js:
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
No exemplo ele simplesmente imprime no terminal que está levantado, mas isso poderia ser feito para avisar ao sistema que o sistema está no ar, e então começar a fazer o tear-down da versão anterior na fleet. Ou então avisar ao loadbalancer que tem mais um nó disponível e pronto para receber tráfego.
No Java, damos o nome de Runnable para a ação que simplesmente é executada.
Todo o contexto de um Runnable está dentro dele, ele não pode ser executado
de modo parametrizado. Voltando ao caso da função doSomething, ela ficaria
mais ou menos assim:
void doSomething(Params params, Runnable callback) {
//... código longo e complicado
callback.run();
}
Parâmetros
Ok, resolvida a questão de rodar um comando arbitrário. Agora, e se eu precisar lidar com um evento? Que tal adicionar algo que seja disparado por um ecento de clique?
O clique em si vai criar um evento de clique que é um objeto a ser consumido, e
em cima dessa informação eu posso tomar alguma ação. Podemos dizer que eu
vou… consumir… essa informação. Para isso, podemos usar o Consumer<T>:
onClick(c -> System.out.println("clicou " + c));
Normalmente Consumers vão reagir a algo que vai ser fornecido para ele. Um
exemplo clássico é o forEach, que permite tomar uma ação para cada elemento
de uma lista. Por exemplo:
List.of("one", "two", "three").forEach(s -> System.out.println(s))
Se você precisa chamar um Consumer<T>, só usar o accept:
private Consumer<String> changeState = s -> {}; // noop
public void addChangeStateListener(Consumer<String> changeState) {
final var oldChangeState = this.changeState;
this.changeState = newState -> {
oldChangeState.accept(newState);
changeState.accept(newState);
};
}
public void doLongProcess() {
this.changeState.accept("iniciando");
// ...
this.changeState.accept("fim");
}
Existe também a variação BiConsumer, que recebe 2 argumentos.
Criação de dados
Existe também uma opção bem curiosa: no lugar de consumir dados, a de produzir dados. Sim, como se estivesse tirando eles do nada, materializando do ar. Por exemplo, quando queremos fazer uma computação postergada.
Para pegar exemplo de computação postergada, vamos recorrer rapidinho aqui ao
Optional do Java, que implementamos algo disso com orElseGet lá em
Reinventando a roda: como escrever streams sem usar stream como base.
A ideia do orElseGet é basicamente pegar um valor padrão caso o Optional
esteja vazio. Mas, veja… e se esse valor for computado? E se essa computação
for cara?
Para esse tipo de computação, quando queremos postergar ao máximo computar o valor, de modo que só o computemos caso estritamente necessário. Não queremos disperdiçar uma ida ao banco de dados só pra pegar um valor que talvez nem iremos usar, né?
Optional.of(pedido)
.map(p -> p.getFrete())
.orElseGet(() -> consultaValorFrete(pedido));
Outro caso de uso para computação postergada foi com trampolim, tanto em Trampolim, exemplo em Java quanto no Trampolim para funções além do primitivo recursivo? Implementação para a função de Ackermann Peter. Nesses casos, o trampolim é justamente algo que se divide em “já computei” e “ainda não computei, mas sei computar”, e o laço principal do trampolim é justamente lidando com isso:
public static <IN, R> R trampoline(IN input,
Function<IN, TrampolineStep<R>> trampolinebootStrap) {
TrampolineStep<R> nextStep = trampolinebootStrap.apply(input);
while (!nextStep.gotValue()) {
nextStep = nextStep.runNextStep();
}
return nextStep.value();
}
Onde o runNextValue é simplesmente a resolução de um valor que pode computar
ou gerar outro valor a ser computado ainda:
TrampolineStep<Integer> fib_trampoline(int a, int b, int i, int n) {
if (i == n) {
return TrampolineStep.valueFound(a);
}
return TrampolineStep.goonStep(() -> fib_trampoline(b, a+b, i+1, n));
}
Existem também outros motivos além de computação postergada para se usar essa criação de dados a partir do nada: podemos ter uma fábrica de valores!
De novo, usamos isso lá no
reinventando a roda,
mas dessa vez aqui o foco ao coletar em uma lista: precisamos informar algo que
crie nosso container, como () -> new ArrayList<>().
E também podemos ter uma espécie de abstração para uma espécie de iteração: conforme for aparecendo valores, ele vai produzindo. Por exemplo, eu posso querer pegar os primeiro 6 pedidos e produzo a média do seus fretes:
Supplier<Pedido> s = ...;
double acc = 0;
for (int i = 0; i < 6; i++) {
final var p = s.get();
final var frete = Optional.of(p)
.map(p -> p.getFrete())
.orElseGet(() -> consultaValorFrete(p));
acc += frete;
}
return acc/6;
Esse tipo de coisa a gente resolve com um Supplier: não recebe nenhum
argumento, produz algo útil.
Transformação
Além de poder sumir com valores (Consumer) e fazer eles aparecerem do nada,
também posso manipular esses valores: pego algo e transformo em outro algo.
Isso é papel do Function! E ele tem apenas esse papel: transformar dados.
Inclusive, já usamos isso mais cedo: p -> p.getfrete(). Aqui estamos
simplesmente transformando um pedido em um valor de frete: Pedido -> valor.
Essa lógica de mapeamento de tipos foi analizada no post
Somando valores sem laços,
em que se pegava as compras e mapeava para produtos, que por sua vez eram
mapeados em valores, algo assim:
pedidos.stream()
.flatMap(c -> c.produtos().stream()) // mapeio pedidos para uma stream de produtos
.map(p -> p.qtd() * p.valorUnitario())
.reduce(0, (acc, el) -> acc + el);
Aqui também podemos pegar dois valores e transformar em apena um usando um
BiFunction.
Quando o tipo não muda
No Java, algumas Functions tem uma característica importante: elas são do
tipo T para o tipo T. Ou seja, elas mantém o tipo do dado: o mesmo tipo que
entra é o tipo que sai.
Para esse tipo de Function, damos o nome de Operator.
E no caso de pegar dois elementos do tipo T e retornar um terceiro e novo
elemento do tipo T? Aqui o Java abriu mão da consistência e no lugar de usar
BiFunction como em todo canto, passou a usar BinaryOperator.
Decisões
As vezes precisamos definir se vamos para frente ou não. É simples: dado esse
objeto, preciso tomar uma decisão. Para isso usamos o Predicate. Por exemplo,
para saber se um pedido é muito caro:
Predicate<Pedido> ehCaro = p -> p.getValor() > 100;
Muito comum ver predicados em situações que exigem filtros. Podemos pegar uma variante da soma sem laços e colocar um filtro:
pedidos.stream()
.flatMap(c -> c.produtos().stream()) // mapeio pedidos para uma stream de produtos
.filter(p -> p.vendidoPromocao())
.map(p -> p.qtd() * p.valorUnitario())
.reduce(0, (acc, el) -> acc + el);
Variações primitivas
Além das opções que trabalham com objetos, existem também variações para
trabalhar apenas com primitivos, especificamente int, long e double. Se
você precisar usar algo para fazer um ShortSupplier, por exemplo, você não
vai encontrar apoio na biblioteca padrão da linguagem Java. O máximo que vai
encontrar é um IntSupplier, aí você abre a mão do controle dos 16 bits e
passa a ter fé que nos 32 bits vai funcionar.
Para o caso de funções, existem tanto o caso de objeto -> primitivo como
primitivo -> objeto. Por exemplo, IntFunction vai pegar um inteiro e
produzir um objeto. Já um ToIntFunction vai pegar um objeto e extrair um
inteiro de dentro dele. E também a variante primitivo -> primitivo, como o
DoubleToIntFunction.
A lista mais completa pode ser procurada na documentação oficial.
Notação
Em Java, lambdas são criadas usando a setinha fina, ->. Porém, em algumas
situações podemos inferir um comportamento a partir da referência do método.
Por exemplo, para pegar o valor do frete do pedido, foi usado
p -> p.getFrete(). Porém, podemos simplesmente dizer que isso é uma funçào do
Pedido: Pedido::getFrete.
Aqui, ::getFrete é a referência do método getFrete de um objeto do tipo
Pedido. É como se isso fosse um Function<Pedido, Double>.
Para usar referência de método, o correto é usar o ::. No exemplo do frete
não estava preso que seria a um pedido específico, por isso que aquilo era uma
referência à classe pedido. Agora, se por acaso eu quisesse buscar de modo
lazy o frete daquele pedido em específico, eu poderia fazer p::getFrete.
Tanto no caso de referência de método como no caso de lambda mais explícita com setinha, o Java necessita saber explicitamente qual o tipo que está sendo trabalhado. Por exemplo, eu não posso fazer
final var calculaFrete = Pedido::getFrete;
// Cannot infer type: method reference requires an explicit target type
mas eu posso fazer
final Function<Pedido, Double> calculaFrete = Pedido::getFrete;
E o @FunctionalInterface?
Bem, isso é uma anotação. E aqui essa anotação serve para quebrar compilação. Especificamente, o Java determina que uma interface é uma “interface funcional” quando ela tem um único “método aberto”. Isso é importante identificar porque “método aberto” é aquele que não tem implementação concreta, então o lambda dessa interface vai cuidar apenas da parte aberta.
Por exemplo, a interface Predicate. Ela tem um único método aberto,
test(T). Mas ela tem alguns métodos que vem com implementações default, como
por exemplo and(Predicate<T>), cuja implementação pode ser algo assim:
default Predicate<T> and(Predicate<T> other) {
return t -> test(t) && other.test(t);
}
No caso, essa anotação força o compilador a dar erro caso alguém, ao dar manutenção em uma interface dessas, resolva colocar mais um método:
@FunctionalInterface
interface X {
void p();
void n();
}
//Multiple non-overriding abstract methods found in interface X
Resumão
| Interface | FQDN | Descrição | Lambda |
|---|---|---|---|
Runnable |
java.lang.Runnable |
Executa uma ação | () -> System.out.println("execução acabou") |
Consumer |
java.util.function.Consumer |
Executa uma ação para um argumento recebido | obj -> System.out.println(obj + ": execução chegou ao fim") |
BiConsumer |
java.util.function.BiConsumer |
Executa uma ação para dois argumentos recebidos | (first, second) -> System.out.printf("recebi %s, %s\n", first, second) |
Supplier |
java.util.function.Supplier |
Retorna um novo valor do nada | () -> 42 |
Function |
java.util.function.Function |
Processa um dado em outro dado | prod -> prod.qtd() * prod.valor() |
Predicate |
java.util.function.Predicate |
Preciso de uma decião com base em um dado | p -> p.getValor() > 100 |