Estrutura de Dados I

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ó.

Até aqui, cada estrutura de dados que implementamos guardava um tipo fixo: a lista guardava int, a árvore de busca guardava pares de int. Se o programa precisar de uma lista de strings, ou de uma árvore de alunos, a saída mais direta é copiar a implementação inteira e trocar o tipo. Nesta página, vamos juntar duas ferramentas já vistas, o ponteiro void * e as funções de callback, para escrever uma única implementação de cada estrutura, que sirva para qualquer tipo de dado.

O problema: uma estrutura para cada tipo

Uma lista duplamente encadeada de inteiros tem um nó assim:

typedef struct noInt {
    int dado;
    struct noInt *anterior;
    struct noInt *proximo;
} NoInt;

void insereFimListaInt(ListaInt *lista, int dado);
void imprimeListaInt(const ListaInt *lista);   /* printf("%d", ...) */

Para uma lista de double, como NoInt guarda um int, não há o que reaproveitar: é preciso copiar NoInt, ListaInt e todas as funções, trocando int por double em cada uma. Para strings, mais uma cópia, agora alocando memória para o texto. Para um struct Aluno, mais uma.

Com tipos e cerca de 15 funções por lista, são funções quase idênticas. Um erro na lógica (esquecer de atualizar fim em uma remoção, por exemplo) precisa ser corrigido em todas as cópias. E o mesmo vale para pilhas, filas, árvores, tabelas de símbolos...

Pergunta

Entre as versões para int, double, string e Aluno, o que muda de uma implementação para outra? E o que fica exatamente igual?

Ver respostaEsconder resposta

Fica igual toda a lógica de encadeamento: criar e ligar nós, atualizar inicio, fim e tamanho, percorrer a lista. Mudam só as operações que precisam conhecer o tipo do dado: guardá-lo no nó, comparar dois dados (na busca), imprimir um dado e liberar um dado (a string e o nome do aluno precisam de free, o int não). É exatamente essa separação que as estruturas genéricas exploram.

A ideia: void * e callbacks

A estrutura não precisa saber o que guarda. O nó passa a guardar um void *, um ponteiro para um dado de qualquer tipo, que fica fora do nó. E as operações que dependem do tipo (comparar, imprimir, liberar) chegam como callbacks, escritos por quem usa a estrutura e sabe o que há por trás do ponteiro.

(a) Uma lista duplamente encadeada de int com os valores 10, 20 e 30 guardados dentro dos nós. (b) Uma lista duplamente encadeada genérica com três nós. O campo do meio de cada nó é um ponteiro que aponta para um struct Aluno fora do nó, com matrícula 101, 102 e 103 e nota 8.5. O campo nome de cada aluno aponta, por sua vez, para uma string alocada à parte: João, Maria e Ana
Na lista genérica, o nó guarda só o endereço do dado. O dado pode ser de qualquer tipo e até apontar para outras áreas de memória.
Definição 1(Estrutura genérica)

Uma estrutura de dados genérica é uma implementação que pode guardar elementos de qualquer tipo. Em C, ela guarda ponteiros genéricos (void *) para os dados e recebe por callbacks as operações que dependem do tipo, como comparar, imprimir e liberar.

A biblioteca padrão já usa essa ideia: o qsort ordena vetores de qualquer tipo porque recebe o tamanho de cada elemento e um comparador. A diferença é que, aqui, a ideia vai para dentro de uma estrutura de dados inteira.

A lista genérica

O TAD abaixo é a lista duplamente encadeada das páginas anteriores, com void *dado no lugar de int dado. Os tipos dos callbacks ficam no .h, com typedef:

typedef int (*Comparador)(const void *a, const void *b);
typedef void (*Impressor)(const void *dado);
typedef void (*Liberador)(void *dado);

Quase todo o código é igual ao da lista de inteiros. As diferenças aparecem em poucos lugares. A busca recebe a chave como const void * e um comparador, e devolve o dado encontrado (e não o nó, que é interno ao TAD):

void *buscaLista(const Lista *lista, const void *chave, Comparador comparar) {
    if (lista == NULL || chave == NULL || comparar == NULL)
        return NULL;
    for (No *p = lista->inicio; p != NULL; p = p->proximo)
        if (comparar(p->dado, chave) == 0)
            return p->dado;
    return NULL;
}

A impressão chama um Impressor para cada dado. E a liberação recebe um Liberador, porque nem sempre um free(dado) basta (um aluno tem o nome alocado à parte):

void liberaLista(Lista *lista, Liberador liberar) {
    if (lista == NULL)
        return;
    No *atual = lista->inicio;
    while (atual != NULL) {
        No *proximo = atual->proximo;
        if (liberar != NULL)
            liberar(atual->dado);       /* só quem conhece o tipo sabe liberar */
        free(atual);
        atual = proximo;
    }
    free(lista);
}

Com liberar igual a NULL, só os nós são liberados, e os dados ficam com quem os criou. Veremos mais adiante quando isso é necessário.

As remoções (removeInicioLista, removeFimLista, removePosicaoLista) devolvem o void * que estava no nó removido e não liberam o dado: quem chamou pode querer usá-lo antes de decidir o que fazer com ele.

O programa abaixo usa a mesma implementação para uma lista de inteiros e uma lista de strings. Explore os arquivos Lista.h e Lista.c: nenhuma linha deles menciona int ou char como tipo dos dados.

Uma lista, dois tiposC

Para cada tipo, o main escreve só os callbacks: comparar e imprimir. Para liberar, free serve tanto para o int quanto para a string, porque cada dado é um único bloco alocado com malloc. O próprio free pode ser passado como Liberador: a assinatura void free(void *) é exatamente a que o tipo pede.

A chave da busca

buscaLista(numeros, &chave, compararInt) passa um ponteiro para um int com o valor procurado, porque o comparador espera dois ponteiros para int. Na lista de strings, a chave é a própria string. A chave precisa ter o tipo que o comparador espera (veja o Programa 2 dos exercícios para o que acontece quando não tem).

NULL antes de ->

As funções do TAD começam testando lista == NULL. Em lista == NULL || lista->inicio == NULL, a ordem importa: como || para assim que o resultado está decidido, lista->inicio só é avaliado quando lista não é NULL. São duas perguntas diferentes: a primeira protege contra um ponteiro inválido, e a segunda pergunta se a lista está vazia.

Uma lista de structs

O caso mais comum na prática é guardar structs. O Aluno tem o nome alocado dinamicamente, com o tamanho certo. Os callbacks do aluno ficam em um módulo próprio, Aluno.h e Aluno.c, que vamos reaproveitar na árvore genérica.

Lista de alunosC

Dois detalhes:

  • A chave da busca é um Aluno em que só a matrícula foi preenchida ({.matricula = 102}), porque compararMatricula só olha esse campo.
  • liberarAluno libera primeiro o nome e depois o struct. Passar free para liberaLista liberaria só o struct, e os nomes vazariam.

Quem é o dono do dado?

Numa estrutura genérica, a estrutura guarda ponteiros, e não cópias. Isso levanta uma pergunta que a lista de inteiros nunca teve: quem é responsável por liberar cada dado? A regra que seguimos é a de um único dono:

  1. Ao inserir, o dado (alocado por quem chamou) passa a ser da estrutura.
  2. Ao remover, a estrutura devolve o dado, que volta a ser de quem chamou. Ele precisa ser liberado (ou inserido em outro lugar).
  3. Ao destruir a estrutura, o liberador recebido libera os dados que ainda estavam nela.
Ponteiro para variável local
for (int i = 0; i < 5; i++) {
    int x = 10 * i;
    insereFimLista(lista, &x);      /* ERRADO */
}

Todos os nós guardam o mesmo endereço, o de x, que deixa de existir ao fim de cada volta do laço (e, se a lista for usada fora da função, ao fim da função). Além disso, um liberaLista(lista, free) tentaria liberar uma variável que não foi alocada com malloc. Cada dado inserido precisa da sua própria área, alocada com malloc.

Map, filter e para cada

As funções de alta ordem da página anterior também funcionam sobre a lista genérica. mapLista e filterLista devolvem uma lista nova. paraCadaLista chama um callback para cada dado, com um contexto, e faz o papel do reduce: o contexto acumula o resultado, sem nenhuma alocação.

Lista *mapLista(const Lista *lista, Transformadora transformar);
Lista *filterLista(const Lista *lista, Predicado condicao);
void paraCadaLista(const Lista *lista, Visita visita, void *contexto);

Há uma diferença importante entre map e filter. O map cria dados novos (o callback transformar aloca cada um). Já o filter coloca na lista nova os mesmos ponteiros da lista original.

Em cima, a lista numeros, com cinco nós que apontam para os dados 3, 8, 5, 2 e 6. Embaixo, a lista pares, com três nós que apontam para os mesmos dados 8, 2 e 6, destacados
Depois do filter, as duas listas apontam para os mesmos dados pares. Só uma delas pode liberá-los.
map, filter e para cadaC

O contexto de paraCadaLista funciona como na página anterior. Em somar, ele é um long long * com a soma. Em guardarMaior, é o endereço de um ponteiro (int **) que, no fim, aponta para o maior dado, sem copiá-lo.

No fim do main, cada lista é liberada de um jeito, de acordo com quem é dono dos dados: numeros libera os seus, pares libera só os nós (os dados são de numeros) e dobrados libera os dados que o map criou.

Questão

Uma lista turma guarda alunos criados com criaAluno, e aprovados = filterLista(turma, aprovado). Como liberar tudo corretamente?

Callback da operação ou da estrutura?

Na lista, cada operação recebe o callback de que precisa: buscaLista recebe um comparador, imprimeLista recebe um impressor. Isso é flexível: a mesma lista de alunos pode ser buscada por matrícula em um momento e por nome em outro, porque a busca na lista olha todos os elementos, um a um.

Em uma árvore de busca, isso não funciona. A posição de cada dado na árvore depende do comparador usado na inserção. Se cada insere ou busca pudesse receber um comparador diferente, a busca seguiria o caminho errado e não encontraria dados que estão na árvore. O comparador faz parte da definição da árvore: ele é escolhido na criação, guardado na estrutura e usado em todas as operações.

CallbackOnde ficaPor quê
comparador da busca na listaparâmetro da operaçãoa busca é sequencial, qualquer critério funciona
comparador da árvore de buscaguardado na estruturaa ordem da árvore depende dele
liberador da árvoreguardado na estruturausado ao destruir a árvore (e ao remover)
visita do percursoparâmetro da operaçãocada percurso faz uma coisa diferente

A árvore de busca genérica

A árvore genérica é a árvore binária de busca com void *dado nos nós e com o comparador e o liberador guardados na estrutura:

struct arvore {
    No *raiz;
    int tamanho;
    Comparador comparar;        /* os callbacks ficam guardados na árvore */
    Liberador liberar;
};

Arvore *criaArvore(Comparador comparar, Liberador liberar);

A busca é a mesma da árvore de inteiros. A única mudança é que as comparações < e > viram uma chamada ao comparador, e o sinal do resultado decide o caminho:

void *buscaArvore(const Arvore *arvore, const void *chave) {
    No *p = arvore->raiz;
    while (p != NULL) {
        int c = arvore->comparar(chave, p->dado);
        if (c == 0)
            return p->dado;
        p = c < 0 ? p->esq : p->dir;
    }
    return NULL;
}

O programa abaixo cria duas árvores com os mesmos alunos: uma em ordem de nome e outra em ordem de matrícula. Os módulos Aluno.h e Aluno.c são os mesmos da lista, sem nenhuma mudança.

Árvore de busca genéricaC

Alguns pontos para observar:

  • Duas árvores, um dono. porNome é criada com liberarAluno e é a dona dos alunos. porMatricula é criada com NULL: ela funciona como um índice, que só aponta para os alunos. É assim que bancos de dados mantêm vários índices sobre os mesmos registros.
  • Inserção recusada. insereArvore devolve false quando já existe um dado equivalente (para compararNome, um aluno com o mesmo nome). Nesse caso, a árvore não guarda o dado, e ele continua sendo de quem chamou, que precisa liberá-lo.
  • Percurso com contexto. percorreArvore visita os alunos em ordem, e o contexto acumula a soma das notas para a média. É o mesmo padrão do exercício de percurso com callback.
Pergunta

Por que insereArvore devolve false em vez de, como na árvore de inteiros, simplesmente substituir o dado antigo pelo novo?

Ver respostaEsconder resposta

Por causa do dono. Na árvore de inteiros, substituir um valor não envolve memória. Aqui, se a árvore trocasse o dado antigo pelo novo, precisaria decidir o que fazer com o antigo: liberá-lo (mas ele pode estar em uso por quem chamou, ou em outra estrutura, como na árvore porMatricula) ou devolvê-lo (o que complicaria a interface). Devolver false e deixar o novo dado com quem chamou mantém a regra simples: cada dado tem um único dono, e a árvore só libera o que aceitou.

Uma lista com tipos diferentes em cada nó

Em Python, uma lista pode misturar tipos: [42, "olá", 3.14] é válida. Nas estruturas acima, cada uma guarda vários dados, mas todos do mesmo tipo: quem chama imprimeLista sabe se deve passar imprimirInt ou imprimirAluno. Dá para misturar tipos na mesma lista?

Um void * sozinho não basta. Ele não carrega nenhuma informação sobre o tipo do dado: essa informação se perde quando o ponteiro original é convertido para void *. Olhando só para o ponteiro, um callback não tem como saber se deve convertê-lo para int *, double * ou char *.

A solução é guardar o tipo junto com o dado. Em vez do dado, a lista recebe um pequeno pacote, o Elemento, com uma etiqueta (tag) que diz o tipo e um ponteiro para o valor:

Uma lista genérica com três nós. Cada nó aponta para um Elemento com dois campos: a etiqueta e um ponteiro para o valor. O primeiro tem a etiqueta TIPO_INT e aponta para 42. O segundo tem TIPO_TEXTO e aponta para a string olá, mundo. O terceiro tem TIPO_DOUBLE e aponta para 3.14
Uma lista heterogênea: a lista é a mesma de antes, e cada dado é um Elemento com a etiqueta do tipo.
Lista heterogêneaC

A lista não mudou nada. Os callbacks consultam a etiqueta (com um switch) antes de converter o ponteiro. Essa é uma versão simplificada do que o Python faz por dentro: todo objeto Python carrega uma referência para o seu tipo, e é assim que o interpretador sabe, durante a execução, o que cada elemento é.

Pergunta

O que acontece se um Elemento tiver a etiqueta TIPO_DOUBLE, mas o seu valor for, na verdade, um int? O compilador consegue detectar esse erro?

Ver respostaEsconder resposta

O compilador não detecta: para ele, valor é só um void *, e a etiqueta é um número qualquer. Durante a execução, imprimirElemento converte o ponteiro para double * e lê 8 bytes onde há um int de 4: o resultado é um número sem sentido (e o AddressSanitizer ainda acusa a leitura fora da área alocada). Em Python, um erro de tipo gera uma exceção controlada. Em C, nada além do cuidado de quem escreve o código garante que a etiqueta corresponde ao valor.

União com etiqueta

O Elemento acima faz uma alocação extra por valor. Uma variação comum guarda o valor dentro do próprio struct, com uma union (que reserva espaço para o maior dos tipos e usa o mesmo espaço para qualquer um deles). A combinação de uma etiqueta com uma union é chamada de união com etiqueta (tagged union).

Vantagens e desvantagens

Vantagens:

  • Uma única implementação para todos os tipos: um erro corrigido na lista genérica fica corrigido para todos os usos.
  • Um tipo novo exige só os seus callbacks (comparar, imprimir, liberar), e não uma reimplementação.
  • A mesma ideia vale para qualquer estrutura: listas, pilhas, filas, árvores, tabelas de símbolos, filas de prioridade.

Desvantagens:

  • Menos verificação de tipos. O compilador não confere se um void * aponta para o tipo que o callback espera. Um erro vira acesso inválido à memória ou resultado errado durante a execução.
  • Gerenciamento de memória mais delicado. É preciso definir quem é o dono de cada dado, e o liberador certo precisa chegar a cada estrutura.
  • Custo extra. Cada dado é uma alocação à parte, e cada comparação é uma chamada de função por ponteiro. Para um único tipo, com desempenho crítico, uma estrutura escrita para aquele tipo é mais rápida.

Outras linguagens resolvem o mesmo problema com recursos próprios, como os templates de C++ e os generics de Java e C#, em que o compilador gera ou verifica o código para cada tipo e mantém a verificação de tipos. Em C, void * com callbacks é a forma padrão.

Exercícios

Encontre e corrija o erro

Os programas abaixo usam a lista genérica desta página. Cada um tem um erro diferente. Execute, leia a mensagem e encontre a causa.

Programa 1

ParesC
Resposta

As listas são impressas corretamente, mas o programa termina com attempting double-free dentro de liberaLista. A lista pares foi criada por filterLista, que guarda os mesmos ponteiros da lista numeros. Depois de liberaLista(numeros, free), os dados pares já foram liberados, e liberaLista(pares, free) tenta liberá-los de novo. A lista pares não é dona dos dados e deve liberar só os nós:

liberaLista(numeros, free);
liberaLista(pares, NULL);

Programa 2

Busca por nomeC
Resposta

O programa termina com global-buffer-overflow em compararNome, chamada por buscaLista. A chave da busca é a string "Ana", mas compararNome espera dois ponteiros para Aluno: ela converte a string para Aluno * e lê o campo nome, que está 8 bytes depois do início do struct, além do fim dos 4 bytes de "Ana". O compilador não reclama, porque qualquer ponteiro vira void * sem aviso. A chave precisa ser um Aluno, com o campo que o comparador usa:

Aluno chave = {.nome = (char *)procurados[i]};
Aluno *achou = buscaLista(turma, &chave, compararNome);

Programa 3

Liberando os alunosC
Resposta

O programa termina com heap-use-after-free em liberarAluno, na primeira vez que ela é chamada (no aluno removido da posição 2). A função libera o struct antes do nome: depois de free(aluno), ler aluno->nome é acessar memória já liberada. O que está dentro do struct precisa ser liberado primeiro:

void liberarAluno(void *dado) {
    Aluno *aluno = dado;
    free(aluno->nome);
    free(aluno);
}

Programa 4

Este programa acrescenta à lista a função removeSeLista, que remove todos os dados que satisfazem um predicado. O erro está nela, no arquivo Lista.c.

Remover os reprovadosC
Resposta

A lista final está correta, mas o programa termina com vazamentos de memória: 38 bytes em 3 blocos, todos alocados em criaAluno, na linha do malloc do nome. São os nomes dos três alunos removidos. Os quatro casos de remoção estão certos, mas o dado é liberado com free(atual->dado), que libera só o struct. O parâmetro liberar, que sabe liberar um Aluno inteiro, nunca é usado. Com uma lista de inteiros, o erro não apareceria, e é por isso que removeSeLista precisa receber o liberador:

if (liberar != NULL)
    liberar(atual->dado);
free(atual);

Implemente

Inserção ordenada e remoção com predicado. Acrescente à lista genérica duas funções:

  • insereOrdenadoLista(lista, dado, comparar), que insere o dado mantendo a lista em ordem crescente segundo comparar. Se houver dados equivalentes ao novo, ele entra depois deles.
  • removeSeLista(lista, condicao, liberar), que remove todos os dados que satisfazem condicao, libera cada um com liberar (se ele não for NULL) e devolve quantos foram removidos.

As duas funções ficam em extras.c, que tem acesso à estrutura interna da lista (ListaInterno.h). Os testes conferem os encadeamentos (anterior, proximo, inicio, fim) e o tamanho depois de cada operação, quais dados foram liberados, e removem elementos de uma lista com 200 mil elementos.

Por onde começar?

Em removeSeLista, quais casos de remoção existem? E como percorrer a lista sem perder o próximo nó?

Ver dica 1Esconder dica 1

Guarde atual->proximo antes de liberar o nó atual. Depois da remoção, continue a partir dele.

Ver dica 2Esconder dica 2

Em vez de quatro casos (único, primeiro, último, meio), trate os dois lados separadamente: se o nó tem anterior, o anterior passa a apontar para o próximo, senão inicio muda. Se o nó tem próximo, o próximo passa a apontar para o anterior, senão fim muda. Não use removePosicaoLista dentro do laço: cada chamada percorre a lista desde o início, e a remoção ficaria quadrática.

Ver dica 3Esconder dica 3

Em insereOrdenadoLista, avance enquanto comparar(p->dado, dado) <= 0. Com < 0, o novo entraria antes dos equivalentes.

Inserção ordenada e remoção 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.

Remoção na árvore de busca genérica. Implemente removeArvore(arvore, chave), que remove da árvore genérica o dado equivalente à chave e o libera com o liberador da árvore (se ele não for NULL), devolvendo false se não havia dado equivalente. O restante do TAD é o desta página. Os testes conferem a ordem da árvore e o tamanho, qual dado foi liberado em cada remoção, e fazem até 200 mil remoções.

Qual dado liberar?

Na remoção de um nó com dois filhos, o dado do sucessor vem para o nó removido. Qual dado precisa ser liberado, e quando?

Ver dica 1Esconder dica 1

A estrutura da remoção é a da árvore de busca, com arvore->comparar(chave, r->dado) no lugar de < e >.

Ver dica 2Esconder dica 2

O dado liberado é o que sai da árvore, o do nó encontrado. Libere-o antes de sobrescrever r->dado com o dado do sucessor. O nó do sucessor sai da árvore, mas o dado dele continua nela: libere só o nó.

Remoção na árvore de busca genéricaC
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.

Contador de palavras. Usando apenas a interface da árvore genérica (Arvore.h), implemente o contador de palavras de palavras.h. Cada Palavra guarda o texto (uma cópia alocada) e quantas vezes ele apareceu. contaPalavra acrescenta uma ocorrência, imprimeContagem imprime as palavras em ordem alfabética com as suas contagens e maisFrequente devolve a palavra que mais apareceu (no empate, a primeira em ordem alfabética). Os testes contam 400 mil palavras e conferem que toda a memória é liberada no fim.

Que callbacks a árvore precisa?

Que comparador e que liberador criaContador deve passar para a árvore? E como buscar uma palavra a partir de um const char *?

Ver dica 1Esconder dica 1

O comparador compara os campos texto com strcmp. O liberador libera o texto e depois o struct. Os dois podem ser funções static em palavras.c.

Ver dica 2Esconder dica 2

Para buscar, monte uma Palavra só com o texto (como a chave da busca por nome desta página). Se a palavra não existir, crie uma com uma cópia do texto (malloc e strcpy): quem chamou reaproveita o seu texto depois da chamada.

Ver dica 3Esconder dica 3

imprimeContagem e maisFrequente são percursos com percorreArvore. Em maisFrequente, o contexto pode ser o endereço de um const Palavra * com a melhor até agora. Como o percurso é em ordem alfabética, troque a melhor só quando a contagem for estritamente maior.

Contador de palavras com a árvore genéricaC
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.
  • D. R. Hanson. C Interfaces and Implementations: Techniques for Creating Reusable Software. Addison-Wesley, 1996.
  • B. W. Kernighan e R. Pike. A Prática da Programação. Campus, 2000. Capítulo 2.

Nesta página