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



Aula 1b - Alocação Dinâmica de Memória e Ponteiros

Até agora, todas as variáveis e vetores utilizados possuíam tamanho fixo, definido em tempo de compilação. Essa abordagem, embora simples, é limitada: muitas vezes não sabemos, antes da execução do programa, quantos elementos serão realmente necessários.

Nesta aula estudaremos a alocação dinâmica de memória, que permite reservar e liberar espaço na memória durante a execução do programa, de acordo com a necessidade real dos dados. Esse conceito é essencial para o restante da disciplina, pois praticamente todas as estruturas de dados dinâmicas (listas encadeadas, pilhas, filas, árvores, grafos) dependem dele. Veremos também um conjunto de recursos complementares da linguagem C — ponteiro para ponteiro, argumentos de linha de comando, const, bool e assert — que serão úteis ao longo de toda a disciplina.

Atenção — muito importante.

A partir daqui, esta disciplina assume que todos já sabem programar bem em C — sintaxe básica, funções, vetores, structs, ponteiros, passagem de parâmetros, arquivos, laços, condicionais, recursão — e que já dominam os fundamentos de algoritmos, tudo isso visto no primeiro semestre do curso. Infelizmente não temos tempo hábil para revisar esses conceitos; seguiremos a partir deles.

Se você sentiu qualquer dificuldade com o conteúdo desta aula, ou tem dúvidas sobre C básico, é essencial revisar o conteúdo e tirar todas as dúvidas antes de continuar. Todo o material de apoio está disponível em:


Memória estática versus memória dinâmica

De forma simplificada, um programa em execução utiliza duas regiões principais de memória para armazenar dados: a pilha (stack) e o heap.

Região Características
Pilha (stack) Armazena variáveis locais e parâmetros de funções. O tamanho precisa ser conhecido em tempo de compilação. O espaço é reservado e liberado automaticamente conforme as funções são chamadas e retornam.
Heap Região de memória controlada manualmente pelo programador. O espaço é reservado (alocado) e liberado explicitamente através de funções específicas, podendo ter seu tamanho definido em tempo de execução.

Todas as variáveis e vetores vistos até agora (como int idade; ou int vetor[10];) são alocados na pilha. A partir de agora, aprenderemos a alocar memória diretamente no heap, através de ponteiros.

Vantagens e desvantagens de cada região.
  • Pilha: alocação e liberação muito mais rápidas (o compilador só ajusta um ponteiro de topo), e o programador não precisa se preocupar em liberar nada. Em troca, o tamanho precisa ser conhecido em tempo de compilação, o espaço disponível é limitado (recursões muito profundas ou vetores muito grandes causam stack overflow), e os dados não sobrevivem ao retorno da função onde foram criados.
  • Heap: tamanho definido em tempo de execução, e os dados sobrevivem ao retorno da função — essencial para estruturas que crescem ou que precisam ser compartilhadas entre funções, como veremos ao longo da disciplina. Em troca, a alocação e a liberação são mais lentas, e a responsabilidade de liberar cada bloco alocado é inteiramente do programador — esquecer um free() causa vazamento de memória, e usar um ponteiro depois de liberá-lo causa comportamento indefinido.

Alocação dinâmica: a função malloc()

A função malloc() (memory allocation), definida na biblioteca <stdlib.h>, reserva um bloco de memória no heap com o tamanho (em bytes) especificado, e retorna um ponteiro para o início desse bloco.

tipo *ponteiro = (tipo *) malloc(quantidade * sizeof(tipo));

O operador sizeof é utilizado para calcular corretamente o número de bytes necessários para armazenar um valor do tipo desejado. Considere o exemplo abaixo, que aloca dinamicamente um vetor de n inteiros:

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

int main() {
    int n = 5;
    int *vetor;

    vetor = (int *) malloc(n * sizeof(int));

    if (vetor == NULL) {
        printf("Erro: memória insuficiente.\n");
        return 1;
    }

    for (int i = 0; i < n; i++) {
        vetor[i] = i * 10;
    }

    for (int i = 0; i < n; i++) {
        printf("%d ", vetor[i]);
    }
    printf("\n");

    free(vetor);
    return 0;
}

Observe que, uma vez alocado, o vetor dinâmico vetor pode ser acessado exatamente como um vetor comum, utilizando colchetes (vetor[i]), pois um vetor nada mais é do que um ponteiro para o início de uma sequência de posições de memória contíguas.

Observação. A conversão (int *) antes de malloc() é chamada de cast e converte o ponteiro genérico retornado por malloc() (do tipo void *) para o tipo desejado. Em C, esse cast não é estritamente obrigatório, mas é uma boa prática mantê-lo, deixando explícito o tipo de dado armazenado.

Nem sempre há memória disponível suficiente para atender a uma solicitação. Quando isso ocorre, malloc() retorna NULL em vez de um endereço válido — por isso o exemplo acima já verifica if (vetor == NULL) logo após a alocação.

Diretriz de Projeto.
  • Sempre verifique o retorno de uma alocação: Antes de utilizar um ponteiro retornado por malloc(), verifique se ele é diferente de NULL. Utilizar um ponteiro NULL como se apontasse para memória válida causa Segmentation Fault.

A função free()

Toda memória alocada dinamicamente com malloc() deve ser explicitamente liberada pelo programador quando não for mais necessária, através da função free(). Diferentemente das variáveis da pilha, a memória do heap não é liberada automaticamente quando a função termina.

free(ponteiro);

Caso a memória alocada nunca seja liberada, o programa passa a consumir memória desnecessariamente enquanto estiver em execução, um problema conhecido como vazamento de memória (memory leak). Em programas que executam por muito tempo, ou que alocam memória repetidamente, esse problema pode esgotar toda a memória disponível no sistema.

Cuidados importantes com free().
  • Ponteiro solto (dangling pointer): Após chamar free(ponteiro), a memória apontada por ponteiro deixa de ser válida, mas a variável ponteiro continua armazenando o mesmo endereço. Utilizar esse endereço após o free() resulta em comportamento indefinido. É uma boa prática atribuir ponteiro = NULL; logo após liberar a memória.
  • Double free: Chamar free() duas vezes sobre o mesmo ponteiro é um erro grave, que também resulta em comportamento indefinido. Atribuir NULL após o primeiro free() também ajuda a evitar esse problema, pois free(NULL) não faz nada.
  • Toda alocação precisa de uma liberação correspondente: Para cada chamada de malloc(), deve existir, mais adiante, uma chamada correspondente de free().

Exemplo: vetores dinâmicos de structs

A alocação dinâmica é especialmente útil quando combinada com structs, permitindo criar vetores de registros cujo tamanho só é conhecido durante a execução do programa.

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

typedef struct {
    char nome[50];
    float media;
} Aluno;

int main() {
    int n;
    printf("Quantos alunos? ");
    scanf("%d", &n);

    Aluno *turma = (Aluno *) malloc(n * sizeof(Aluno));
    if (turma == NULL) {
        return 1;
    }

    // Acesso idêntico ao de um vetor comum de structs
    for (int i = 0; i < n; i++) {
        turma[i].media = 0.0;
    }

    free(turma);
    return 0;
}

Note que turma é um ponteiro para Aluno, mas pode ser utilizado com a mesma notação de vetor (turma[i]) e com o operador ponto (turma[i].media), já que turma[i] acessa diretamente o registro, e não um ponteiro para ele.


Ponteiro para ponteiro

Um ponteiro para ponteiro é uma variável que armazena o endereço de outro ponteiro. Ele é declarado utilizando dois asteriscos (**) e é útil, por exemplo, quando uma função precisa alterar, por referência, o próprio ponteiro passado pela função chamadora (e não apenas o valor para o qual ele aponta).

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

// Aloca um vetor de n inteiros e devolve o ponteiro através de 'vetor'
void alocarVetor(int **vetor, int n) {
    *vetor = (int *) malloc(n * sizeof(int));
}

int main() {
    int *p = NULL;

    alocarVetor(&p, 5); // Passamos o endereço do ponteiro p

    if (p != NULL) {
        p[0] = 42;
        printf("%d\n", p[0]);
        free(p);
    }

    return 0;
}

Sem o segundo nível de indireção (int **vetor), a função alocarVetor receberia apenas uma cópia do ponteiro p, e a alocação feita dentro dela seria perdida ao final da função, exatamente pelo mesmo motivo pelo qual a passagem por valor não altera variáveis simples.

Lendo a declaração de dentro para fora. Em int **vetor, leia da direita para a esquerda a partir do nome: vetor é um ponteiro (*) para um ponteiro (*) para int. Dentro da função, *vetor nos dá de volta o ponteiro original (o que era p em main), permitindo alterá-lo de verdade. Esse mesmo mecanismo será usado mais adiante na disciplina sempre que uma função precisar "criar" ou "substituir" um ponteiro do chamador — por exemplo, ao inicializar a raiz de uma árvore.

Argumentos de linha de comando (argc e argv)

Até agora, sempre lemos dados através de scanf(), com o programa já em execução. Também é possível passar informações para um programa no momento em que ele é iniciado, através de argumentos de linha de comando. Para isso, a função main() pode receber dois parâmetros:

int main(int argc, char *argv[]) {
    // ...
}
Parâmetro Significado
argc argument count: quantidade de argumentos recebidos, incluindo o nome do próprio programa.
argv argument vector: vetor de strings, uma para cada argumento. argv[0] é sempre o nome do programa.
#include <stdio.h>

int main(int argc, char *argv[]) {
    int i;

    printf("Numero de argumentos: %d\n", argc);

    for (i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }

    return 0;
}

Compilando e executando esse programa com dois argumentos:

gcc -Wall main.c -o programa
./programa turma.txt 10

produz a saída:

Numero de argumentos: 3
argv[0] = ./programa
argv[1] = turma.txt
argv[2] = 10
Atenção ao tipo. Todo elemento de argv é uma string (char *), mesmo que pareça um número. Para usar argv[2] como um valor numérico, é preciso convertê-lo explicitamente, por exemplo com atoi(argv[2]) (de <stdlib.h>).

Liberdades de estilo a partir de agora

A programação estruturada surgiu para combater o "código espaguete" — programas com fluxo tão emaranhado (cheio de saltos e desvios) que se tornavam quase impossíveis de entender ou corrigir. Algumas regras eram muito rígidas porque ajudavam programadores iniciantes a internalizar essa disciplina, evitando exatamente esse tipo de bagunça.

Hoje, a maioria delas virou uma recomendação de legibilidade, não uma proibição absoluta: usamos break, múltiplos return e declarações locais quando tornam o código mais claro, mas continuamos evitando construções que dificultam entender o fluxo do programa (como goto, por exemplo).

Múltiplos pontos de retorno. Uma função pode ter mais de um return, encerrando assim que o resultado é conhecido:

int ehPositivo(int n) {
    if (n > 0) return 1;
    return 0;
}

break em laços. Um for ou while pode ser interrompido antes de terminar, assim que a busca não precisar mais continuar:

for (i = 0; i < n; i++) {
    if (vetor[i] == alvo) break;
}

Variáveis declaradas perto do uso. Uma variável pode ser declarada no meio da função, próxima de onde é usada pela primeira vez, em vez de todas juntas no topo:

printf("Processando...\n");
int soma = 0; // declarada só aqui, quando passa a ser necessária

Isso vale inclusive para a variável de controle do for, declarada diretamente dentro do próprio laço:

for (int i = 0; i < n; i++) {
    printf("%d ", vetor[i]);
}
Com bom senso. Essas liberdades existem para deixar o código mais direto, não mais confuso. Use-as quando simplificarem a leitura — evite, por exemplo, uma função cheia de returns espalhados de forma desorganizada.

O qualificador const

Quando uma função recebe um ponteiro apenas para ler os dados apontados, sem nunca modificá-los, é uma boa prática declarar o parâmetro com o qualificador const. Isso comunica a intenção da função (tanto para quem a lê quanto para o compilador) e faz com que o próprio compilador acuse um erro caso o corpo da função tente, por engano, alterar esse dado.

void imprimirAluno(const Aluno *aluno) {
    printf("%s: %.2f\n", aluno->nome, aluno->media);

    aluno->media = 10.0; // erro de compilação: não é permitido alterar *aluno
}

Repare que imprimirAluno recebe um ponteiro (evitando a cópia de toda a struct), mas o const garante, em tempo de compilação, que essa função apenas lê o aluno apontado, sem risco de alterá-lo por engano — o melhor dos dois mundos: o desempenho de passar por ponteiro, com a segurança de passar por valor.

É por isso que funções como strlen e strcmp, da biblioteca padrão, recebem seus parâmetros como const char *: elas prometem apenas ler a string recebida, nunca modificá-la.


O tipo bool em C

Historicamente, C não possuía um tipo booleano nativo — condições eram sempre representadas por int (0 para falso, qualquer valor diferente de 0 para verdadeiro). Desde o padrão C99, porém, o cabeçalho <stdbool.h> disponibiliza o tipo bool e as constantes true/false, deixando o código mais legível:

#include <stdio.h>
#include <stdbool.h>

bool ehPar(int n) {
    return n % 2 == 0;
}

int main() {
    bool vetorVazio = true;

    if (ehPar(4)) {
        printf("4 e par\n");
    }

    printf("vetorVazio = %d\n", vetorVazio); // imprime 1

    return 0;
}
Por baixo dos panos. bool continua sendo, na prática, um inteiro pequeno (true vale 1, false vale 0), e por isso printf com %d funciona normalmente sobre uma variável bool. A vantagem de usar bool em vez de int é puramente de legibilidade: deixa explícito, para quem lê o código, que aquela variável ou retorno representa um valor lógico (verdadeiro/falso), e não uma quantidade qualquer.

assert: verificando erros do programador

A macro assert, definida em <assert.h>, verifica se uma condição é verdadeira e, caso não seja, encerra o programa imediatamente com uma mensagem de erro indicando o arquivo e a linha da falha.

#include <assert.h>

float calcularMedia(float notas[], int n) {
    assert(n > 0); // nunca deveria ser chamada com n <= 0

    float soma = 0;
    for (int i = 0; i < n; i++) {
        soma += notas[i];
    }
    return soma / n;
}
assert verifica erros do programador. Um assert que falha significa que o próprio código chamou a função de um jeito que nunca deveria acontecer, se todo o programa estiver correto — um bug interno, uma premissa (invariante) que foi quebrada em algum outro ponto do código. Por isso, é comum desativar os asserts na versão final do programa (compilando com a macro NDEBUG definida), já que eles servem para ajudar a encontrar bugs durante o desenvolvimento, não para lidar com situações esperadas em produção.

Isso é bem diferente de um if comum, que continua sendo a ferramenta certa para tratar entradas inválidas vindas do usuário do programa — uma situação perfeitamente esperada, que o programa deve saber lidar sem simplesmente encerrar de forma abrupta:

int n;
printf("Quantas notas? ");
scanf("%d", &n);

if (n <= 0) { // erro do usuário: entrada inválida, situação esperada
    printf("Erro: a quantidade de notas deve ser positiva.\n");
    return 1;
}
Resumindo a diferença:
  • assert verifica erros do programador — condições que, se o código estiver certo, nunca deveriam ser falsas. Ao falhar, o programa é encerrado, pois há um bug a ser corrigido no código-fonte.
  • if trata erros do usuário — situações inválidas, porém esperadas, que vêm de fora do controle do programador (entrada digitada errada, arquivo inexistente, etc.), e que o programa deve tratar de forma controlada, sem encerrar abruptamente.
Mais adiante, em outras linguagens de programação, vocês verão o mecanismo de exceções, que cumpre esse mesmo papel de tratar erros do usuário/do ambiente de forma mais estruturada do que um simples if — mas o princípio por trás da distinção é o mesmo: erro do programador (assert) versus erro do usuário/ambiente (if, ou exceções).

Resumo: alocação estática versus dinâmica

Aspecto Alocação estática Alocação dinâmica
Onde é alocada Pilha (stack) Heap
Quando o tamanho é definido Em tempo de compilação Em tempo de execução
Quem libera a memória Automático (ao final da função) Manual, através de free()
Principais riscos Stack overflow em recursões muito profundas Vazamento de memória, ponteiros soltos, double free
Resumo: Boas Práticas com Alocação Dinâmica.
  • Sempre verifique se a alocação retornou NULL: antes de utilizar o ponteiro alocado.
  • Sempre libere o que foi alocado: para cada malloc(), deve existir um free() correspondente.
  • Nunca utilize um ponteiro após liberá-lo: atribua NULL ao ponteiro logo após o free().
  • Use const em parâmetros ponteiro que só devem ser lidos, nunca alterados.
  • assert é para erros do programador; if é para erros do usuário.
Quer ir além? Tópicos mais avançados de C (matrizes dinâmicas, ponteiros genéricos void *, ponteiros para função, Makefile) estão disponíveis no Material Extra — C Avançado, opcional. Mais adiante, na Aula 15b, veremos uma introdução obrigatória a C++ para quem já sabe C.

Exercícios

  1. Explique a diferença entre alocação estática e alocação dinâmica, indicando em qual região de memória cada uma ocorre e quando o tamanho de cada uma é definido.
  2. Por que é necessário verificar se o retorno de malloc() é NULL antes de utilizar o ponteiro alocado? O que acontece se essa verificação for esquecida e a alocação falhar?
  3. O que é um dangling pointer? Por que atribuir NULL a um ponteiro logo após chamar free() sobre ele é uma boa prática?
  4. Escreva uma função void alocarInteiro(int **p, int valor) que aloque dinamicamente um único int, atribua valor a ele, e devolva o ponteiro alocado através do parâmetro p. Escreva também um main() que a utilize corretamente (incluindo a liberação da memória).
  5. O que representam argc e argv na função main(int argc, char *argv[])? Se um programa for executado com ./programa a b c, qual é o valor de argc, e o que contém argv[0]?
  6. Considere a função void imprimirAluno(const Aluno *aluno). O que o qualificador const impede que essa função faça? O que aconteceria se, dentro dela, tentássemos executar aluno->media = 10.0;?
  7. Qual é a diferença entre const Aluno *aluno e Aluno * const aluno?
  8. Explique, com um exemplo próprio (diferente dos vistos em aula), uma situação em que faria sentido usar assert, e outra em que faria sentido usar if. Justifique por que cada uma se encaixa no papel de "erro do programador" ou "erro do usuário".
  9. Reescreva a função abaixo utilizando o tipo bool (de <stdbool.h>) como tipo de retorno, no lugar de int:
    int estaCheia(int ocupados, int capacidade) {
        return ocupados == capacidade;
    }
    

Sugestões de Respostas dos Exercícios

Exercício 1

A alocação estática ocorre na pilha (stack), e seu tamanho precisa ser conhecido em tempo de compilação (por exemplo, int vetor[10];). A alocação dinâmica ocorre no heap, através de malloc(), e seu tamanho pode ser definido em tempo de execução, de acordo com valores lidos ou calculados enquanto o programa já está rodando.


Exercício 2

Porque nem sempre há memória disponível para atender à solicitação — nesse caso, malloc() retorna NULL em vez de um endereço válido. Se essa verificação for esquecida e o programa tentar usar esse ponteiro NULL como se apontasse para memória válida (por exemplo, vetor[0] = 10;), o resultado é um comportamento indefinido, tipicamente uma falha de segmentação (Segmentation Fault), que encerra o programa abruptamente.


Exercício 3

Um dangling pointer é um ponteiro que continua armazenando um endereço de memória que já foi liberado (com free()), mas que a variável ainda "aponta" para lá, como se fosse válido. Atribuir NULL logo após o free() evita que esse ponteiro solto seja usado por engano mais adiante no código — qualquer tentativa de desreferenciar um ponteiro NULL falha de forma óbvia e imediata, em vez de causar um comportamento indefinido e potencialmente silencioso.


Exercício 4
#include <stdio.h>
#include <stdlib.h>

void alocarInteiro(int **p, int valor) {
    *p = (int *) malloc(sizeof(int));
    if (*p != NULL) {
        **p = valor;
    }
}

int main() {
    int *x = NULL;

    alocarInteiro(&x, 7);

    if (x != NULL) {
        printf("%d\n", *x);
        free(x);
    }

    return 0;
}

O parâmetro precisa ser int **p (e não int *p) porque a função precisa alterar o próprio ponteiro x de main, fazendo-o apontar para a memória recém-alocada — com apenas um nível de indireção, a função receberia só uma cópia do ponteiro, e a alocação seria perdida ao final da função.


Exercício 5

argc é a quantidade de argumentos recebidos pelo programa, incluindo o nome do próprio programa. argv é um vetor de strings, uma para cada argumento, sendo argv[0] sempre o nome (ou caminho) usado para executar o programa.

Para ./programa a b c, temos argc == 4 (o nome do programa mais três argumentos), e argv[0] contém a string "./programa".


Exercício 6

O const impede que a função modifique, direta ou indiretamente, o Aluno apontado por aluno — ou seja, ela pode ler os campos (aluno->nome, aluno->media), mas não pode escrever neles.

Se tentássemos executar aluno->media = 10.0; dentro dessa função, o compilador acusaria um erro de compilação, pois isso violaria a promessa feita pelo const na assinatura da função.


Exercício 7

const Aluno *aluno significa que o Aluno apontado não pode ser alterado, mas o próprio ponteiro aluno pode ser reatribuído para apontar para outro Aluno. Já Aluno * const aluno significa o oposto: o ponteiro aluno em si é constante (não pode ser feito apontar para outro endereço depois de inicializado), mas o Aluno para o qual ele aponta pode ser livremente modificado através dele.


Exercício 8

Exemplo de uso de assert: em uma função float dividir(float a, float b) que, pela regra de negócio do programa, nunca deveria ser chamada com b == 0 (pois essa verificação já deveria ter sido feita antes, em outro ponto do código), poderíamos usar assert(b != 0); logo no início — se esse assert falhar, é sinal de um bug em outro lugar do programa, que permitiu uma chamada inválida.

Exemplo de uso de if: ao ler, via scanf, a idade de uma pessoa digitada pelo próprio usuário do programa, é perfeitamente esperado que ele digite um valor inválido (negativo, ou um texto em vez de um número) — isso não é um bug do programa, e sim uma entrada externa que precisa ser validada com um if, exibindo uma mensagem de erro amigável em vez de encerrar o programa abruptamente.


Exercício 9
#include <stdbool.h>

bool estaCheia(int ocupados, int capacidade) {
    return ocupados == capacidade;
}

A mudança é só no tipo de retorno (int para bool) e na inclusão de <stdbool.h>; o corpo da função permanece idêntico, já que a expressão ocupados == capacidade já produzia um valor 0/1, compatível com bool. A vantagem é deixar explícito, para quem lê a assinatura da função, que ela retorna um valor lógico (verdadeiro/falso), e não uma quantidade qualquer.