Estrutura de Dados I

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:

sem_callback.cC

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

Definição 1(Função 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, ...);
ponteiro.cC

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 */
Por que (void) e não ()

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.

Não confunda com função que devolve ponteiro
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.

filtro.cC

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.

Passe a função, não a chamada

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:

calculadora.cC

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.

opcional.cC
Pergunta

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.

contexto.cC

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 contexto precisa existir durante as chamadas

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.

despacho.cC

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çãoO que fazCallback
mapaplica uma transformação a cada elemento, gerando um vetor novo do mesmo tamanhoint transformar(int)
filtergera um vetor novo só com os elementos que satisfazem um predicadobool condicao(int)
reducecombina todos os elementos em um único valorint acumular(int, int)
hof.cC

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 .

Quem libera?

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.

Pergunta

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 elementos, então podemos alocar posições, copiar em uma única passada e, no fim, opcionalmente encolher o vetor com realloc. É uma troca. Com duas passadas, a memória alocada é exata, mas o predicado é chamado vezes, o que pesa se ele for caro e muda o resultado se ele tiver efeitos colaterais (como o ehMultiplo, que conta os aceitos e contaria tudo em dobro). Com uma passada, o predicado é chamado vezes, ao custo de alocar mais memória do que o necessário (ou de um realloc no fim).

Pergunta

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: e . Com 0 no produto, o resultado seria sempre 0. Para , o laço não executa, e 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.

Vetores vazios

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.

ordenacao.cC

Alguns detalhes deste exemplo:

  • Os parâmetros são const void *: o comparador só lê os elementos, e o const deixa isso explícito (é também a assinatura que o qsort exige).
  • crescente devolve (x > y) - (x < y), que vale , ou . O atalho return x - y parece mais simples, mas pode estourar (com x = INT_MAX e y = -1, por exemplo) e devolver o sinal errado.
  • decrescente reaproveita crescente, trocando a ordem dos argumentos.
  • Para structs, cada critério é um comparador diferente. porNome usa strcmp, que já devolve negativo, zero ou positivo.
O qsort não é estável

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 apareceExemplos
Biblioteca padrão de Cqsort e bsearch (comparadores), atexit (funções chamadas no fim do programa), signal (tratadores de sinais)
Estruturas de dados genéricaslistas, árvores de busca, tabelas hash e filas de prioridade que recebem funções para comparar, imprimir ou liberar os elementos
Sistemas de eventosinterfaces gráficas (GTK, Qt), sistemas embarcados (interrupções), servidores web (tratadores de requisições)
Processamento de dadosmap, filter, reduce e percursos com visita
Algoritmos configuráveisordenaçã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

calculadora.cC
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

opcional.cC
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

busca.cC
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

reduce.cC
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

filtro.cC
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 ). Além das respostas, os testes contam quantas vezes cada predicado é chamado: algum, todos e primeiroQue devem parar assim que a resposta estiver decidida.

Quando parar?

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.

Funções com predicadoC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

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.

Que algoritmo?

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.

Ordenação com comparadorC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

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.

O que vai no contexto?

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.

Percorrer a árvore com callbackC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Referências

  • A. Backes. Linguagem C: Completa e Descomplicada. 2ª ed. Elsevier, 2018.
  • B. W. Kernighan e D. M. Ritchie. C: A Linguagem de Programação Padrão ANSI. Campus, 1989. Seção 5.11 (ponteiros para funções).
  • qsort e bsearch. cppreference.com.

Nesta página