Funções de callback
Ponteiros para função, callbacks e funções de alta ordem em C, com contexto, tabelas de despacho, map, filter, reduce e comparadores para o qsort.
Muitas funções fazem quase a mesma coisa: percorrem um vetor, visitam os nós de uma árvore, ordenam elementos. O que muda de uma para outra é um detalhe, como o critério de seleção, a operação aplicada ou a forma de comparar. Nesta página, veremos como escrever a parte comum uma única vez e receber o detalhe que muda como parâmetro, na forma de uma função. Essa função passada como parâmetro é uma função de callback.
O problema: código duplicado
Suponha que queremos imprimir, de um vetor de inteiros, só os pares, só os ímpares, só os positivos e só os maiores que 10. A forma mais direta é escrever uma função para cada critério:
As quatro funções são idênticas, exceto pela condição do if e pelo título.
Isso traz alguns problemas:
- Duplicação. O laço é sempre o mesmo, e só a condição muda.
- Pouca extensibilidade. Cada novo critério exige uma função inteira nova.
- Manutenção difícil. Uma mudança no laço (contar quantos elementos passaram, por exemplo) precisa ser repetida em todas as funções.
- Nenhum reúso. Cada função serve para um único critério.
O mesmo acontece com operações. Uma "calculadora" que mostra a conta antes do
resultado teria uma função para cada operação, todas com o mesmo printf:
int calcularSoma(int a, int b) {
printf("Calculando %d + %d = ", a, b);
return a + b;
}
int calcularSubtracao(int a, int b) {
printf("Calculando %d - %d = ", a, b);
return a - b;
}
/* ... e o mesmo para multiplicação e divisão */Nos dois casos, a parte que muda é um comportamento: um teste ou uma conta. Para escrever a parte comum uma só vez, precisamos passar esse comportamento como parâmetro.
A solução: funções de callback
Uma função de callback é uma função passada como argumento para outra função, que a "chama de volta" (call back) em algum momento da sua execução. Uma função que recebe outras funções como parâmetro (ou que devolve uma função) é chamada de função de alta ordem (higher-order function, HOF).
Os dois termos descrevem a mesma ideia de ângulos diferentes. Função de alta ordem é o conceito: uma função que trata outras funções como dados, podendo recebê-las, guardá-las e devolvê-las. Callback é a função que é passada. Em C, isso é feito com ponteiros para função.
Com callbacks, o código comum (o laço, a impressão) é escrito uma única vez, e um novo comportamento exige só uma função pequena, sem tocar no que já existe.
Ponteiro para função
Em C, uma função também tem um endereço na memória: o endereço onde começa o seu código. Um ponteiro para função guarda esse endereço e permite chamar a função indiretamente. A declaração tem a forma
tipoRetorno (*nomePonteiro)(tipoParam1, tipoParam2, ...);O nome de uma função, usado sem os parênteses de chamada, já é o seu endereço.
Por isso ptr = somar e ptr = &somar são equivalentes. Do mesmo modo, a
chamada pode ser escrita como ptr(5, 3) ou como (*ptr)(5, 3). Repare que
o mesmo ponteiro chamou duas funções diferentes: quem decide qual função roda
é o valor guardado em ptr, e não o código da chamada.
O ponteiro só pode guardar funções com a mesma assinatura (os mesmos tipos de parâmetros e de retorno). Alguns exemplos:
void (*notificar)(void); /* sem parâmetros, sem retorno */
int (*operacao)(int, int); /* dois int, devolve int */
void (*processar)(void *); /* um ponteiro genérico */
int (*comparar)(const void *, const void *); /* compara dois elementos */Em C (até o C17), uma lista de parâmetros vazia, como em void (*f)(),
não quer dizer "sem parâmetros". Quer dizer "parâmetros não
especificados", e o compilador aceita chamadas com qualquer número de
argumentos sem reclamar. Para dizer "nenhum parâmetro", escreva (void).
É por isso que usamos int main(void) nos exemplos.
int (*funcao)(int, int); /* ponteiro para função que devolve int */
int *funcao(int, int); /* função que devolve um ponteiro para int */Os parênteses em volta de *funcao mudam tudo. Sem eles, o * se junta ao
tipo de retorno, e a declaração passa a ser de uma função comum que devolve
int *.
Simplificando com typedef
A sintaxe de ponteiros para função fica difícil de ler, principalmente quando
o ponteiro é parâmetro de outra função. Um typedef dá um nome ao tipo:
/* Sem typedef */
void aplicar(int *v, int n, void (*processar)(int *));
/* Com typedef */
typedef void (*Processador)(int *);
void aplicar(int *v, int n, Processador processar);No typedef, o nome novo (Processador) fica onde ficaria o nome do
ponteiro. A partir daí, Processador é um tipo como outro qualquer: serve
para declarar parâmetros, variáveis, campos de structs e vetores.
Resolvendo os exemplos com callback
Um único filtro
Em vez de quatro funções quase iguais, escrevemos uma função que recebe o critério como parâmetro. O critério é um predicado: uma função que recebe um elemento e diz se ele satisfaz uma condição.
imprimirFiltrado não sabe qual critério está usando. Ela só chama
filtro(v[i]) e confia na resposta. O critério "negativos" foi acrescentado
sem nenhuma mudança em imprimirFiltrado: bastou escrever ehNegativo.
O argumento é ehPar, sem parênteses. Escrever ehPar() tentaria
chamar a função ali mesmo (sem o argumento que ela exige) e passar o seu
resultado, um bool, no lugar de uma função.
Uma calculadora genérica
Na calculadora, o callback é a operação, e o símbolo vem junto para a mensagem:
A mensagem Calculando ... é escrita uma única vez, em calcular. Uma nova
operação (o resto da divisão, por exemplo) é só mais uma função com a
assinatura int (int, int).
Callbacks opcionais
Um ponteiro para função é um ponteiro, e pode valer NULL. Isso permite
callbacks opcionais: quem chama passa uma função quando quer ser avisado
de algo, e NULL quando não quer. A função que recebe o callback precisa
então verificar o ponteiro antes de chamá-lo.
Em executarTarefa, em que situação faz sentido passar NULL para um dos
avisos, em vez de exigir sempre uma função (mesmo que ela não faça nada)?
Ver respostaEsconder resposta
Quando quem chama simplesmente não tem interesse naquele evento. Com
NULL, ele não precisa escrever uma função vazia só para preencher o
parâmetro, e executarTarefa pode até evitar trabalho que só serviria ao
aviso (como preparar uma mensagem). O custo é a verificação antes de cada
chamada. A alternativa, exigir sempre uma função, elimina a verificação,
mas obriga quem chama a escrever funções vazias. As duas convenções
existem em bibliotecas reais. O importante é que a convenção esteja
documentada e que a função que recebe o callback a respeite.
Callbacks com contexto
Os predicados de filtro.c têm um limite: ehMaiorQueDez só sabe comparar
com 10. Para "maiores que 3", seria preciso outra função, e para "maiores que
um valor lido do teclado", nenhuma função escrita de antemão resolve. O
predicado precisa de um dado além do elemento.
A solução usual em C é acrescentar ao callback um parâmetro void *, o
contexto. A função de alta ordem não sabe o que ele é: só o recebe de quem
a chamou e o repassa, sem mexer, a cada chamada do callback. Quem escreve o
callback sabe o tipo verdadeiro e faz a conversão.
O contexto pode ser um único valor (ehMaiorQue recebe um int *), uma
struct com vários dados (estaEntre recebe os dois limites) e até um lugar
onde o callback guarda resultados: ehMultiplo conta quantos elementos
aceitou, e o main lê a contagem depois do filtro. Como o contexto é um
void *, a mesma imprimirFiltrado serve para todos esses casos.
Esse par "ponteiro para função mais void *contexto" aparece em muitas
bibliotecas de C. É assim que um percurso em uma árvore, por exemplo, pode
somar valores, contar chaves ou copiá-las para um vetor sem que o código do
percurso saiba de nada disso (veja o terceiro exercício desta página).
O callback recebe um ponteiro para o contexto. Se o contexto for uma
variável local que já deixou de existir quando o callback é chamado, o
ponteiro aponta para memória inválida. Nos exemplos acima, o contexto é
local ao main, e o callback é chamado durante imprimirFiltrado, então
não há problema.
Funções que devolvem funções
Uma função de alta ordem também pode devolver uma função. Em
despacho.c, operacaoPara recebe um símbolo e devolve a operação
correspondente, ou NULL se o símbolo for desconhecido. O main lê
expressões e usa a função devolvida sem precisar de um switch próprio.
Com o typedef, a declaração Operacao operacaoPara(char simbolo) é fácil de
ler. Sem ele, ela seria int (*operacaoPara(char simbolo))(int, int): uma
função que recebe um char e devolve um ponteiro para uma função que recebe
dois int e devolve int. Esse é um bom motivo para sempre usar typedef
com ponteiros para função.
A segunda parte do programa mostra outra forma de associar nomes a comportamentos: uma tabela de despacho, um vetor de structs em que cada entrada guarda um nome e a ação correspondente. Para acrescentar um comando, basta uma nova linha na tabela. É o mecanismo por trás de menus, de interpretadores de comandos e de sistemas de eventos, em que cada evento ("conectou", "recebeu dados", "erro") tem o seu callback.
Map, filter e reduce
Três funções de alta ordem clássicas, vindas da programação funcional, são
map, filter e reduce. A imprimirFiltrado que escrevemos já é um
filter especializado. Vamos generalizar:
| Função | O que faz | Callback |
|---|---|---|
map | aplica uma transformação a cada elemento, gerando um vetor novo do mesmo tamanho | int transformar(int) |
filter | gera um vetor novo só com os elementos que satisfazem um predicado | bool condicao(int) |
reduce | combina todos os elementos em um único valor | int acumular(int, int) |
O reduce começa com o valor inicial e, para cada elemento, faz
resultado = acumular(resultado, v[i]). Para a soma de (1, 2, 3), com
inicial 0, o cálculo é somar(somar(somar(0, 1), 2), 3).
A última linha do main mostra a principal vantagem dessas funções: elas se
compõem. A saída de uma vira a entrada da próxima. Filtrando os pares de 1
a 10, dobrando e somando, obtemos
map e filter devolvem um vetor alocado com malloc. Quem chama é
responsável por liberá-lo, como em qualquer função que devolve memória
alocada. Na composição, cada etapa intermediária gera um vetor que também
precisa ser liberado.
O filter acima percorre o vetor duas vezes: uma para contar quantos
elementos passam e outra para copiá-los. Daria para fazer em uma única
passada? O que se ganha e o que se perde?
Ver dicaEsconder dica
Quantas posições o resultado pode ter, no máximo?
Ver respostaEsconder resposta
Daria. O resultado tem no máximo realloc. É uma troca. Com duas passadas, a memória alocada
é exata, mas o predicado é chamado ehMultiplo,
que conta os aceitos e contaria tudo em dobro). Com uma passada, o predicado
é chamado realloc no fim).
Em reduce(v, n, somar, 0), o valor inicial é 0. Em
reduce(v, n, multiplicar, 1), é 1. Por que o valor inicial muda conforme
o acumulador? E qual é o resultado de reduce para um vetor vazio
(
Ver respostaEsconder resposta
O valor inicial precisa ser o elemento neutro da operação, aquele que
não altera o resultado: reduce
devolve o próprio valor inicial: a soma de nada é 0, e o produto de nada
é 1. No maximo, não há elemento neutro entre os int (a não ser
INT_MIN), e por isso o exemplo usa v[0], o que exige um vetor com
pelo menos um elemento.
Para map chama malloc(0), e filter faz o mesmo quando nenhum
elemento passa. O padrão permite que malloc(0) devolva NULL ou um
ponteiro válido que não pode ser acessado. Os dois casos funcionam com o
código acima (nada é acessado, e free(NULL) não faz nada), mas quem
chama não pode usar o NULL devolvido como sinal de erro de alocação sem
antes olhar o tamanho.
Ordenação com vários comparadores
O exemplo mais conhecido de callback na biblioteca padrão é o qsort, visto
na página de ponteiros void.
O qsort sabe ordenar, mas não sabe comparar: quem diz se um elemento vem
antes de outro é o comparador, um callback que recebe dois ponteiros e
devolve um número negativo (o primeiro vem antes), zero (empate) ou positivo
(o primeiro vem depois). Trocando o comparador, o mesmo qsort ordena em
qualquer critério.
Alguns detalhes deste exemplo:
- Os parâmetros são
const void *: o comparador só lê os elementos, e oconstdeixa isso explícito (é também a assinatura que oqsortexige). crescentedevolve(x > y) - (x < y), que vale, ou . O atalho return x - yparece mais simples, mas pode estourar (comx = INT_MAXey = -1, por exemplo) e devolver o sinal errado.decrescentereaproveitacrescente, trocando a ordem dos argumentos.- Para structs, cada critério é um comparador diferente.
porNomeusastrcmp, que já devolve negativo, zero ou positivo.
Uma ordenação é estável quando elementos empatados mantêm a ordem em
que estavam. O padrão C não garante que o qsort seja estável. Quando a
estabilidade importa (ordenar por nota mantendo a ordem alfabética entre
notas iguais, por exemplo), é preciso um algoritmo estável ou um
comparador que desempate. Um dos exercícios desta página pede uma
ordenação estável com comparador.
Outras aplicações
Callbacks aparecem sempre que uma função precisa de um comportamento que só quem a chama conhece:
| Onde aparece | Exemplos |
|---|---|
| Biblioteca padrão de C | qsort e bsearch (comparadores), atexit (funções chamadas no fim do programa), signal (tratadores de sinais) |
| Estruturas de dados genéricas | listas, árvores de busca, tabelas hash e filas de prioridade que recebem funções para comparar, imprimir ou liberar os elementos |
| Sistemas de eventos | interfaces gráficas (GTK, Qt), sistemas embarcados (interrupções), servidores web (tratadores de requisições) |
| Processamento de dados | map, filter, reduce e percursos com visita |
| Algoritmos configuráveis | ordenação e busca com critérios variáveis, validação de dados |
A segunda linha da tabela é o assunto da próxima página, Estruturas
Genéricas: combinando void * com callbacks, uma única implementação de uma
estrutura de dados passa a servir para qualquer tipo de elemento.
Boas práticas e armadilhas
Use typedef. Um nome para o tipo do callback deixa as assinaturas
legíveis, principalmente em funções que recebem ou devolvem callbacks.
Dê nomes descritivos. Predicado, Comparador e Acumulador dizem o que
o callback faz. Nomes como Func ou CB não dizem nada.
Use const no que o callback só lê. Um comparador recebe
const void *, e um callback de impressão também deveria receber.
Valide callbacks opcionais. Se NULL é um valor permitido, verifique
antes de chamar. Se não é, documente isso.
Passe a função, não a chamada. filtrar(v, n, ehPar), e não
filtrar(v, n, ehPar()).
Siga o contrato do callback. O comparador do qsort precisa devolver
negativo, zero ou positivo, e não só verdadeiro ou falso. O valor inicial do
reduce precisa ser o elemento neutro. Um callback que não cumpre o
contrato pode produzir resultados errados sem nenhuma mensagem de erro (veja
os exercícios a seguir).
Cuidado com o tempo de vida do contexto. O void *contexto precisa
apontar para algo que ainda exista quando o callback for chamado.
Exercícios
Encontre e corrija o erro
Cada programa abaixo é uma versão de um exemplo desta página, com um erro. Execute, observe o que acontece e encontre a causa.
Programa 1
Resposta
O compilador avisa (-Wformat) e o programa termina com um erro de acesso à
memória dentro do printf, no endereço 5. Em calcular, os argumentos do
printf estão fora de ordem: o formato pede %d %s %d (inteiro, string,
inteiro), mas recebe a, b, simbolo. O %s recebe o int 5 e o trata como
o endereço de uma string, e o printf tenta ler a memória no endereço 5. Os
argumentos precisam seguir a ordem do formato:
printf("Calculando %d %s %d = ", a, simbolo, b);Avisos do compilador sobre printf quase sempre indicam um erro real. Não os
ignore.
Programa 2
Resposta
A primeira tarefa roda normalmente, mas a segunda termina com um acesso ao
endereço 0 (pc points to the zero page). O main passa NULL para indicar
"sem aviso", mas executarTarefa chama aoIniciar() sem verificar o
ponteiro. Chamar um ponteiro para função nulo é tentar executar código no
endereço 0. Os callbacks opcionais precisam ser verificados:
if (aoIniciar != NULL)
aoIniciar();
printf(" [executando a tarefa...]\n");
if (aoFinalizar != NULL)
aoFinalizar();Programa 3
Resposta
O vetor sai ordenado, mas as buscas estão erradas: 1, 5, 11 e 4 "encontram"
o 16, e só 16, 27 e 42 dão a resposta certa. O comparador devolve x > y,
que vale 0 ou 1, e nunca é negativo. Quando x < y, ele devolve 0, que
significa empate. O bsearch começa pelo meio do vetor (o 16, na posição
6), compara a chave 1 com o 16, recebe 0 e conclui que achou. A ordenação
funcionou por acaso: a implementação do qsort desta biblioteca só pergunta
se o resultado é positivo, mas outra implementação (ou outra versão) poderia
ordenar errado. O comparador precisa devolver negativo, zero ou positivo:
return (x > y) - (x < y);Este erro é traiçoeiro porque não produz nenhuma mensagem. O programa termina normalmente com uma resposta errada.
Programa 4
Resposta
A soma e a soma dos quadrados estão certas, mas o produto sai 0. A chamada
reduce(v, n, multiplicar, 0) começa o produto com 0, e qualquer número
multiplicado por 0 é 0. O valor inicial precisa ser o elemento neutro da
multiplicação:
printf("produto (10!): %lld\n", reduce(v, n, multiplicar, 1));Com isso, o programa imprime 3628800.
Programa 5
Resposta
O programa não compila: too few arguments to function 'ehPar'. No
primeiro imprimirFiltrado, o argumento é ehPar(), uma chamada da
função (sem o int que ela exige), e não a função. Mesmo que a chamada fosse
válida, o resultado seria um bool, e não um ponteiro para função. O
argumento é o nome da função, sem parênteses:
imprimirFiltrado(numeros, n, ehPar, "Pares");Implemente
Predicados sobre vetores. Implemente as funções de predicados.h:
contaSe (quantos elementos satisfazem o predicado), algum (algum
satisfaz?), todos (todos satisfazem?) e primeiroQue (índice do primeiro
que satisfaz, ou algum, todos e primeiroQue devem parar
assim que a resposta estiver decidida.
Em que momento cada uma das três funções já sabe a resposta? E quanto
valem algum e todos para um vetor vazio?
Ver dica 1Esconder dica 1
algum pode responder "sim" no primeiro elemento que satisfaz, e todos
pode responder "não" no primeiro que não satisfaz. Um return dentro
do laço resolve.
Ver dica 2Esconder dica 2
No vetor vazio, nenhum elemento satisfaz o predicado, então algum é
falso. E nenhum elemento deixa de satisfazê-lo, então todos é
verdadeiro. Os laços acima já dão essas respostas sem nenhum caso
especial.
Ordenação estável com comparador. Implemente ordena(v, n, cmp), que
ordena um vetor de inteiros segundo o comparador cmp (negativo se a vem
antes de b, positivo se vem depois, zero se tanto faz). A ordenação precisa
ser estável: elementos empatados mantêm a ordem original. Os testes usam
comparadores com muitos empates, como "pelo valor absoluto" e "pelo último
dígito", e vetores com até 3000 elementos.
Qual algoritmo de ordenação simples é estável? E que cuidado a comparação exige para não perder a estabilidade?
Ver dica 1Esconder dica 1
A ordenação por inserção é estável: cada elemento é levado para a esquerda, deslocando os maiores, até encontrar o seu lugar. A ordenação por seleção, com trocas, não é: uma troca pode passar um elemento por cima de outro igual a ele.
Ver dica 2Esconder dica 2
Na inserção, desloque enquanto cmp(v[j], x) > 0, ou seja, só quando o
elemento da esquerda vem estritamente depois de x. Com >= 0,
elementos empatados trocariam de ordem.
Percurso com callback na árvore de busca. O TAD da árvore binária de busca ganhou uma função de percurso:
typedef void (*Visita)(Chave chave, Valor valor, void *contexto);
void percorreEmOrdem(const Arvore *arvore, Visita visita, void *contexto);Implemente percorreEmOrdem, que chama visita para cada par, em ordem
crescente de chave, repassando o contexto sem alterá-lo. Depois, em
usos.c, implemente somaValores, contaNoIntervalo e copiaChaves
usando só percorreEmOrdem: o arquivo usos.c enxerga apenas a
interface pública (Arvore.h) e não tem acesso aos nós. O último teste usa
uma árvore com cerca de 50 mil chaves.
Cada uma das três funções de usos.c precisa de um callback. Que dados
esse callback precisa receber, e onde ele guarda o resultado?
Ver dica 1Esconder dica 1
Para a soma, o contexto pode ser um ponteiro para um acumulador
(long long *), declarado em somaValores e lido depois do percurso.
Ver dica 2Esconder dica 2
Para o intervalo e a cópia, crie uma struct com tudo o que o callback
precisa: os limites e o contador, ou o vetor de saída e a próxima
posição livre. Passe o endereço da struct como contexto e converta
void * de volta para o tipo da struct dentro do callback.
Referências
Fila de prioridade
O TAD fila de prioridade, as implementações elementares e o heap binário, com inserção, remoção do máximo, alteração de prioridade e construção a partir de um vetor.
Estruturas genéricas
Uma única implementação para qualquer tipo de dado com void * e callbacks, com a lista e a árvore de busca genéricas, o cuidado com o dono de cada dado e uma lista com tipos diferentes em cada nó.