Estrutura de Dados I

Tipo Abstrato de Dados

Interface, implementação e cliente. Como esconder os campos de uma struct, separar um programa em módulos e compilar cada parte separadamente.

Um tipo de dado define um conjunto de valores e as operações que podem ser feitas com eles: um int guarda inteiros em uma certa faixa e pode ser somado, comparado, impresso. A linguagem C também oferece formas de estruturar dados a partir dos tipos básicos, como vetores, struct e enum.

Muitas vezes isso não basta. Um programa que lida com alunos, frações ou datas precisa de um tipo novo e de um conjunto de operações para manipulá-lo, e precisa que os valores desse tipo nunca fiquem em um estado inválido (um CR negativo, uma data 31 de abril, uma fração com denominador zero).

Tipo Abstrato de Dados (TAD)

Um tipo abstrato de dados é um tipo definido pelo programador, junto com as operações que podem ser feitas sobre ele. Ele é descrito pela sua finalidade e pelas suas operações, não pela forma como é implementado. Abstrato quer dizer exatamente isso: abstraída a forma de implementação.

Quem usa um TAD só precisa conhecer as operações disponíveis, como faz com os tipos da própria linguagem: você soma dois double sem saber como os bits estão organizados. Quem implementa o TAD escolhe as estruturas e escreve as operações. Para quem usa, o TAD é uma caixa-preta.

Por que um TAD?

Considere um programa que guarda nome, matrícula e CR de um aluno. O CR deve ficar sempre entre 0 e 10, então as funções que mexem nele limitam o valor a esse intervalo:

aluno.cC

As funções fazem o trabalho certo: alteraCR(a, 12.0) deixa o CR em 10. Mas nada impede o código que usa a struct de escrever direto nos campos, como a->cr = -999, e deixar o aluno em um estado que as funções nunca produziriam. Em um programa grande, com várias pessoas mexendo, esse tipo de atalho aparece, e o erro só é percebido muito longe de onde aconteceu.

Por que criaAluno usa strncpy em vez de strcpy? E por que a linha seguinte escreve '\0' em nome[MAX - 1]?

strcpy copia até encontrar o '\0' da origem, sem saber o tamanho do destino: um nome com mais de 49 caracteres escreveria além do vetor nome (estouro de buffer). strncpy(a->nome, nome, MAX - 1) copia no máximo MAX - 1 caracteres. Só que, quando a origem é maior que isso, strncpy não coloca o '\0' no fim. Por isso a linha seguinte o coloca explicitamente na última posição.

A solução é que só as funções do TAD possam mexer nos campos. Em C, isso é feito separando o programa em arquivos.

Interface, implementação e cliente

Um TAD em C costuma ser dividido em dois arquivos, e o programa que o usa fica em um terceiro:

  • Interface (Aluno.h): declara o tipo e os protótipos das operações. É tudo o que o usuário do TAD vê.
  • Implementação (Aluno.c): define a struct com os campos e o código das operações. Fica escondida do usuário.
  • Cliente (main.c): o programa que usa o TAD. Inclui só a interface.
Três caixas: main.c (cliente) chama as funções declaradas em Aluno.h (interface), que são definidas em Aluno.c (implementação), onde fica a struct aluno
O cliente só conhece a interface. Os campos da struct e o código das funções ficam na implementação.

Essa divisão em módulos (unidades com um propósito bem definido, que podem ser compiladas separadamente) tem várias vantagens:

  • Encapsulamento: o usuário não precisa saber como o TAD funciona por dentro, só quais operações existem.
  • Integridade: como os campos só são alterados pelas operações, o TAD nunca fica em um estado inválido.
  • Manutenção: dá para mudar a implementação (trocar um vetor por outra estrutura, acrescentar um campo) sem mudar uma linha do cliente.
  • Reutilização: o mesmo TAD pode ser usado em vários programas, sem copiar e colar código.
  • Compilação separada: ao alterar um módulo, só ele precisa ser recompilado (veja Compilação e Make).

As operações variam de TAD para TAD, mas quase sempre existem operações para criar e destruir (liberar) o TAD, e para inserir, remover e consultar os dados guardados nele.

O TAD Aluno em módulos

Clique nas abas para ver cada arquivo. Execute e, depois, tire o comentário da linha a->cr = -999; em main.c e execute de novo.

TAD AlunoC

Agora o acesso direto não compila:

main.c:11:6: error: invalid use of incomplete typedef 'Aluno' {aka 'struct aluno'}
   11 |     a->cr = -999;
      |      ^~

O que garante isso é a linha typedef struct aluno Aluno; em Aluno.h. Ela diz que existe uma struct aluno, mas não diz quais campos ela tem: para quem inclui só o .h, Aluno é um tipo incompleto (ou opaco). O cliente pode declarar ponteiros Aluno * e passá-los para as funções, mas não pode acessar campos, nem declarar uma variável Aluno (sem ponteiro), nem usar sizeof(Aluno). Os campos só são conhecidos dentro de Aluno.c, onde a struct aluno é definida. A ocultação deixa de ser uma convenção ("por favor, não mexa nos campos") e passa a ser garantida pelo compilador.

Repare em mais três detalhes da implementação:

  • Aluno.c inclui Aluno.h. Primeiro, porque precisa do tipo Aluno. Segundo, porque assim o compilador confere se cada função implementada tem o mesmo tipo do protótipo da interface.
  • limitaCR é static. Uma função static só é visível dentro do próprio arquivo. Ela é um detalhe da implementação e não aparece na interface: o cliente não pode chamá-la.
  • #define MAX 50 também ficou em Aluno.c. O tamanho do vetor nome é um detalhe da implementação.
Por que as funções levam o nome do TAD (criaAluno, liberaAluno) em vez de apenas cria e libera?

Em C não existe sobrecarga de funções: duas funções não podem ter o mesmo nome no mesmo programa, mesmo que estejam em arquivos diferentes e recebam parâmetros diferentes. Um programa que usa o TAD Ponto e o TAD Círculo precisa de liberaPonto e liberaCirculo; se os dois se chamassem libera, a ligação falharia com multiple definition. O nome do TAD no fim (ou no começo) de cada função evita esses conflitos e deixa claro, no cliente, a qual TAD a operação pertence.

TAD Ponto

Um ponto no plano, com as operações:

  • criaPonto: cria um ponto com coordenadas ;
  • liberaPonto: libera a memória do ponto;
  • acessaPonto: devolve as coordenadas do ponto (por meio de ponteiros);
  • atribuiPonto: atribui novas coordenadas ao ponto;
  • distanciaPonto: calcula a distância entre dois pontos.

Note que acessaPonto precisa devolver dois valores. Como uma função só tem um valor de retorno, ela recebe os endereços de duas variáveis do cliente e escreve nelas, como scanf faz.

TAD PontoC

Lendo só Ponto.h, não dá para saber se o ponto guarda as coordenadas em dois campos double, em um vetor de duas posições ou em coordenadas polares. A implementação poderia trocar uma representação por outra, e main.c continuaria funcionando sem nenhuma alteração.

Diretivas do pré-processador

Antes de compilar, o gcc passa cada arquivo .c pelo pré-processador, que trata as linhas que começam com #. Um #include "Ponto.h", por exemplo, é substituído pelo conteúdo inteiro de Ponto.h.

Include guard

Um mesmo cabeçalho pode acabar incluído mais de uma vez no mesmo arquivo, quase sempre de forma indireta. No TAD Círculo (próxima seção), Circulo.h inclui Ponto.h, porque uma das operações recebe um Ponto. Um cliente que usa os dois TADs inclui Ponto.h e Circulo.h, e o conteúdo de Ponto.h aparece duas vezes. Repetir protótipos não é problema, mas definições como a de uma struct não podem aparecer duas vezes, e o compilador acusa redefinition.

Para evitar isso, todo .h é cercado por um include guard:

#ifndef PONTO_H
#define PONTO_H

/* conteúdo da interface */

#endif

Na primeira vez que o arquivo é processado, o símbolo PONTO_H não está definido: o #ifndef (if not defined) é verdadeiro, o símbolo é definido e o conteúdo é considerado. Da segunda vez em diante, PONTO_H já existe, e tudo até o #endif é ignorado. O nome do símbolo pode ser qualquer um; o costume é usar o nome do arquivo em maiúsculas.

Compilação condicional

As mesmas diretivas servem para ligar e desligar trechos de código. #if 0 ... #else ... #endif desativa o primeiro trecho (troque por #if 1 para inverter). #ifdef DEBUG ... #endif só mantém o trecho se o símbolo DEBUG estiver definido, o que é útil para mensagens de depuração:

debug.cC

Comente a linha #define DEBUG e execute de novo: as mensagens somem, sem apagar nenhum printf. Com a linha comentada, também dá para ligar as mensagens na hora de compilar, sem mexer no código: gcc -DDEBUG debug.c define o símbolo DEBUG para aquele arquivo.

TAD Círculo

Um círculo é dado pelo centro e pelo raio, com as operações criaCirculo, liberaCirculo, areaCirculo e interiorCirculo (verifica se um ponto está dentro do círculo). Ele usa o TAD Ponto: o centro é um Ponto *, e interiorCirculo recebe um Ponto. Mesmo assim, Circulo.c não conhece os campos do ponto: ela usa criaPonto, distanciaPonto e liberaPonto, como qualquer outro cliente.

TAD CírculoC

main.c inclui Ponto.h e Circulo.h, que inclui Ponto.h de novo; os include guards fazem a segunda inclusão ser ignorada. Repare também em liberaCirculo: ela libera o centro antes de liberar o círculo. Na ordem inversa, c->centro seria lido depois do free(c).

Suponha que você acrescente uma função imprimePonto ao TAD Ponto e passe a usá-la em main.c. O que precisa ser feito para gerar o executável de novo?

Declarar o protótipo em Ponto.h e implementar a função em Ponto.c. Como Ponto.c e main.c mudaram, os dois precisam ser recompilados. Circulo.c não mudou, mas inclui Ponto.h (por meio de Circulo.h), que mudou; por segurança, ele também deve ser recompilado. Por fim, os arquivos objeto são ligados de novo. Fazer isso à mão é fácil de errar; o make faz por você (veja Compilação e Make).

Compilando um programa com vários arquivos

Aqui na página, os botões compilam tudo para você. No terminal, a primeira tentativa costuma ser compilar só o cliente:

gcc main.c -lm -o TADPonto
/usr/bin/ld: /tmp/ccGosmEZ.o: in function `main':
main.c:(.text+0x34): undefined reference to `criaPonto'
main.c:(.text+0x4d): undefined reference to `criaPonto'
main.c:(.text+0x64): undefined reference to `distanciaPonto'
...
collect2: error: ld returned 1 exit status

(Em um sistema em português, a mensagem é referência não definida para "criaPonto".) O erro não é do compilador, é do ligador (ld). main.c compilou sem problemas: o #include "Ponto.h" trouxe os protótipos, e isso basta para compilar as chamadas. Mas o código das funções está em Ponto.c, que não foi compilado, e o ligador não tem de onde tirar criaPonto.

Um programa com vários módulos é construído em duas etapas:

  1. Compilação: cada .c é compilado separadamente, gerando um arquivo objeto (.o) com o código de máquina das funções daquele arquivo. A opção -c diz ao gcc para parar aqui, sem gerar executável.
  2. Ligação: o ligador junta os arquivos objeto, e o código das bibliotecas usadas (como a matemática, com -lm), em um único executável. É só nessa etapa que se confere se cada função chamada existe em algum lugar.
gcc -c Ponto.c                       # gera Ponto.o
gcc -c main.c                        # gera main.o
gcc main.o Ponto.o -lm -o TADPonto   # liga os dois e gera o executável
./TADPonto
main.c e Aluno.c passam, cada um, pelo pré-processador (que insere o conteúdo de Aluno.h) e pelo compilador, gerando main.o e Aluno.o; o ligador junta os dois arquivos objeto com as bibliotecas e gera o executável TADAluno
Cada .c é pré-processado e compilado separadamente (gcc -c); o ligador junta os arquivos objeto e as bibliotecas em um executável.

Também dá para fazer tudo de uma vez, com gcc main.c Ponto.c -lm -o TADPonto: o gcc compila os dois arquivos e liga o resultado, sem guardar os .o.

Se os dois jeitos geram o mesmo executável, qual a vantagem de compilar separadamente?

Recompilar só o que mudou. Se apenas Ponto.c foi alterado, basta gerar Ponto.o de novo e refazer a ligação; main.o continua valendo. Em um projeto com dezenas de arquivos, compilar tudo a cada alteração é lento. O difícil é lembrar exatamente o que precisa ser recompilado, e é isso que o make automatiza: veja Compilação e Make.

Exercícios

Encontre e corrija o erro

Cada projeto tem um erro. Execute, leia as mensagens (de compilação, de ligação ou de execução), corrija e execute de novo.

Projeto 1

TAD ContadorC
Resposta

A função separador está definida (com corpo) em Contador.h. Como main.c e Contador.c incluem o cabeçalho, cada arquivo objeto ganha a sua cópia da função, e o ligador acusa multiple definition of 'separador'. O include guard não ajuda: ele evita a repetição dentro de um mesmo .c, não entre arquivos diferentes. No .h fica só o protótipo (void separador(void);); o corpo vai para Contador.c.

Projeto 2

Pontos e círculosC
Resposta

Os cabeçalhos não têm include guard. main.c inclui Ponto.h e depois Circulo.h, que inclui Ponto.h de novo, e a struct ponto é definida duas vezes: redefinition of 'struct ponto'. A correção é cercar Ponto.h com #ifndef PONTO_H / #define PONTO_H / #endif (e fazer o mesmo em Circulo.h, por hábito).

Projeto 3

A saída deveria ser Distancia: 2.9155 e q = (4.5, 6.5), distancia: 5.0000.

TAD PontoC
Resposta

Ponto.c não inclui Ponto.h e implementa criaPonto(float x, float y), enquanto a interface promete criaPonto(double x, double y). Como cada .c é compilado separadamente, o compilador nunca vê os dois juntos: main.c passa dois double, criaPonto lê dois float, e os pontos são criados com coordenadas erradas. Nem o ligador percebe, porque em C ele só compara nomes, não tipos. Incluindo Ponto.h em Ponto.c, a divergência vira erro de compilação (conflicting types for 'criaPonto') e a correção fica óbvia.

Projeto 4

TAD CírculoC
Resposta

Em liberaCirculo, o círculo é liberado antes do centro: depois de free(c), a leitura de c->centro acessa memória já liberada (heap-use-after-free). Versões recentes do gcc (a partir da 12) também avisam já na compilação: pointer 'c' used after 'free'. A ordem certa é liberar primeiro o que está dentro da struct e depois a struct:

void liberaCirculo(Circulo *c) {
    liberaPonto(c->centro);
    free(c);
}

Implemente

Nos dois exercícios, a interface (.h) já está pronta e não pode ser alterada. Os campos da struct ficam no .c, e toda operação precisa deixar o TAD em um estado válido.

TAD Fração. Uma fração é guardada sempre na forma canônica: simplificada, com o denominador positivo e com o zero representado como 0/1. Por exemplo, criaFracao(4, -6) representa e criaFracao(10, 4) representa . Soma e multiplicação devolvem uma fração nova, também canônica.

TAD FraçãoC
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.

TAD Data. Uma data do calendário entre 01/01/0001 e 31/12/9999. A criação recusa datas que não existem (31/04, 29/02 de um ano não bissexto, mês 13), e as operações avançam dias e contam os dias entre duas datas.

TAD DataC
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

  • W. Celes, R. Cerqueira e J. L. Rangel. Introdução a Estruturas de Dados: com técnicas de programação em C. 2ª ed. Grupo GEN, 2016.
  • A. Backes. Estrutura de Dados Descomplicada em Linguagem C. Elsevier, 2016.
  • F. Varejão. Introdução à Programação. Grupo GEN, 2014.

Nesta página