INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Material Extra - C Avançado

Esta aula é EXTRA. O conteúdo abaixo não é cobrado nas avaliações da disciplina e não é pré-requisito para as próximas aulas. Ele existe para quem quiser aprofundar um pouco mais em C: matrizes dinâmicas, ponteiros genéricos, ponteiros para função e compilação com Makefile. Uma introdução (obrigatória) a C++ para quem já sabe C será vista mais adiante, na Aula 15b.

Matrizes dinâmicas (vetores de ponteiros)

Diferentemente de um vetor unidimensional, uma matriz dinamicamente alocada normalmente é construída como um vetor de ponteiros, onde cada ponteiro aponta para um vetor (uma linha da matriz), alocado separadamente.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int linhas = 3, colunas = 4;

    // 1. Aloca o vetor de ponteiros (uma posição por linha)
    int **matriz = (int **) malloc(linhas * sizeof(int *));

    // 2. Aloca cada linha individualmente
    for (int i = 0; i < linhas; i++) {
        matriz[i] = (int *) malloc(colunas * sizeof(int));
    }

    // 3. A matriz pode ser utilizada normalmente, com dois índices
    for (int i = 0; i < linhas; i++) {
        for (int j = 0; j < colunas; j++) {
            matriz[i][j] = i + j;
        }
    }

    // 4. Libera cada linha e, por último, o vetor de ponteiros
    for (int i = 0; i < linhas; i++) {
        free(matriz[i]);
    }
    free(matriz);

    return 0;
}
Observação. A liberação de uma matriz dinâmica deve seguir a ordem inversa da alocação: primeiro liberamos cada linha individualmente e, somente depois, o vetor de ponteiros que as armazenava. Liberar o vetor de ponteiros antes das linhas faria perdermos o único acesso a elas, causando vazamento de memória.

Ponteiros genéricos (void *)

Um ponteiro do tipo void * é chamado de ponteiro genérico: ele armazena um endereço de memória, mas não possui um tipo associado, não podendo ser diretamente desreferenciado. É esse o tipo de ponteiro retornado por malloc(), justamente para que a função possa ser utilizada com qualquer tipo de dado.

void *ponteiroGenerico;

int x = 10;
ponteiroGenerico = &x;

// Para utilizar o valor, é necessário convertê-lo (cast) para o tipo correto
printf("%d\n", *((int *) ponteiroGenerico));

Esse recurso será revisitado mais adiante na disciplina, ao implementarmos estruturas de dados genéricas, capazes de armazenar diferentes tipos de dados através de ponteiros void *.


Ponteiros para função

Assim como podemos ter um ponteiro que armazena o endereço de uma variável, também é possível ter um ponteiro que armazena o endereço de uma função. Esse recurso é bastante utilizado para tornar o comportamento de uma função configurável, permitindo, por exemplo, indicar qual critério de comparação deve ser utilizado em um algoritmo de ordenação.

#include <stdio.h>

int soma(int a, int b) {
    return a + b;
}

int subtracao(int a, int b) {
    return a - b;
}

int main() {
    // 'operacao' é um ponteiro para uma função que recebe (int, int) e retorna int
    int (*operacao)(int, int);

    operacao = soma;
    printf("%d\n", operacao(4, 3)); // Imprime: 7

    operacao = subtracao;
    printf("%d\n", operacao(4, 3)); // Imprime: 1

    return 0;
}

Este é apenas um primeiro contato com o conceito; ponteiros para função voltarão a ser úteis mais adiante na disciplina, especialmente ao implementarmos estruturas de dados genéricas.


Compilação e execução em linha de comando

Um programa em C passa por duas etapas até virar algo executável: a compilação (o código-fonte é traduzido para um arquivo executável) e a execução (o sistema operacional roda esse executável):

gcc -Wall main.c -o programa   # compila main.c e gera o executável "programa"
./programa                    # executa o programa (Linux/macOS)
programa.exe                  # executa o programa (Windows)

Quando o programa está dividido em vários arquivos-fonte (como vimos ao separar interface e implementação de um TAD, na Aula 2b), todos precisam ser informados ao compilador de uma só vez:

gcc -Wall main.c aluno.c turma.c -o programa

Digitar esse comando à mão, listando manualmente todo arquivo .c do projeto, é cansativo e cresce junto com o projeto — e é exatamente esse problema que o Makefile resolve.

Automatizando com Makefile

Um Makefile é um arquivo de configuração lido pelo programa make, que descreve como construir um projeto: quais arquivos dependem de quais, e qual comando executar para gerar cada um. Em vez de lembrar (e digitar) o comando completo do gcc, basta rodar:

make

Um Makefile simples para o exemplo com main.c, aluno.c e turma.c poderia ser:

programa: main.c aluno.c turma.c
	gcc -Wall main.c aluno.c turma.c -o programa

clean:
	rm -f programa
Estrutura de uma regra. Cada regra do Makefile segue o formato alvo: dependências, seguido de uma linha de comando (que deve começar com um caractere de tabulação, não espaços). A regra programa: main.c aluno.c turma.c diz que o alvo programa depende desses três arquivos; se qualquer um deles for modificado, rodar make novamente recompila o projeto. A regra clean é uma convenção comum para remover os arquivos gerados, executada com make clean.

Para projetos maiores, o make também evita recompilar arquivos que não mudaram, tornando a compilação mais rápida — mas essa otimização (usando arquivos .o intermediários) foge do escopo desta disciplina. Por ora, o importante é reconhecer que um Makefile existe para automatizar exatamente o comando de compilação que já conhecemos.


Exercícios

  1. Explique por que a liberação de uma matriz dinâmica (vetor de ponteiros) precisa seguir a ordem inversa da alocação.
  2. Por que um Makefile é útil mesmo sendo possível compilar um projeto digitando o comando gcc completo manualmente? O que aconteceria se a linha de comando de uma regra do Makefile começasse com espaços em vez de uma tabulação?

Sugestões de Respostas dos Exercícios

Exercício 1

Porque, para acessar qualquer linha da matriz, é preciso primeiro passar pelo vetor de ponteiros (matriz) para depois chegar a cada linha (matriz[i]). Se liberássemos matriz antes de liberar cada matriz[i], perderíamos a única referência que tínhamos para essas linhas, tornando-as inacessíveis e impossíveis de liberar — um vazamento de memória. Por isso, é preciso liberar primeiro as linhas, e só depois o vetor de ponteiros.


Exercício 2

Um Makefile é útil porque evita ter que lembrar e redigitar o comando de compilação completo (com todos os arquivos .c do projeto) toda vez que algo precisar ser recompilado — especialmente à medida que o projeto cresce. Com o Makefile, basta digitar make.

Se a linha de comando de uma regra começasse com espaços em vez de uma tabulação, o programa make não reconheceria aquela linha como o comando da regra, e o processo de compilação falharia com um erro — o caractere de tabulação faz parte da sintaxe exigida pelo formato do Makefile.