12

Ponteiros e Alocação Dinâmica

Ponteiros e Alocação Dinâmica12.1

Nas aulas anteriores, aprendemos a usar variáveis para armazenar dados, funções para organizar a lógica e struct para modelar entidades como Aluno. Vimos que algumas funções conseguiam modificar os dados originais enquanto outras não, e usamos símbolos como &, * e -> sem desmontar completamente a lógica por trás deles. Também notamos que o vetor da turma tinha tamanho fixo, Aluno turma[100], mesmo que o número real de alunos só aparecesse durante a execução.

Esta aula resolve essas duas questões de uma vez. Primeiro, vamos entender o que é um endereço de memória, o que é um ponteiro e por que &, * e -> aparecem no código. Depois, vamos usar esses mesmos ponteiros para criar e redimensionar dados dinamicamente com malloc, calloc, realloc e free.

Ideia central

Ponteiros localizam dados na memória. Alocação dinâmica cria esses dados durante a execução. As duas ideias andam juntas: sem ponteiros não há alocação dinâmica, e sem alocação dinâmica os ponteiros só apontam para variáveis que já existem.

O ponto de partida: variáveis vivem na memória12.2

Quando declaramos uma variável em C, o programa reserva um espaço na memória para armazenar o seu conteúdo. Esse espaço não é apenas uma ideia abstrata. Ele ocupa uma posição concreta, e essa posição tem um endereço.

Veja este exemplo:

int idade = 20;

printf("Valor: %d\n", idade);
printf("Endereco: %p\n", (void*)&idade);

O primeiro printf exibe o valor guardado na variável. O segundo exibe o endereço onde esse valor está armazenado.

No codigo: idade
               |
               v
Na memoria: endereco 0x100, conteudo 20

Leia esse diagrama assim:

  • no código, usamos o nome idade
  • na memória, essa variável ocupa um lugar
  • o valor guardado nesse lugar é 20
  • o endereço desse lugar pode ser obtido com &idade

O ponto central aqui é este:

  • idade representa o valor da variável
  • &idade representa o endereço da variável

Essas duas coisas estão relacionadas, mas não são a mesma coisa.

Endereços nos exemplos

Valores como 0x100 aparecem aqui apenas como exemplos ilustrativos. Em um programa real, os endereços exatos dependem da execução e do ambiente.

Um ponteiro é uma variável que guarda endereço12.3

Agora podemos introduzir a ideia principal. Um ponteiro é uma variável cujo conteúdo não é um número comum do problema, mas um endereço de memória.

Exemplo:

int idade = 20;
int *p = &idade;

Leia essa declaração assim:

  • idade guarda o valor 20
  • &idade produz o endereço de idade
  • p guarda esse endereço
  • portanto, p aponta para idade
PonteiroBasicopVariavel pconteudo: 0x100idadeVariavel idadeendereco: 0x100conteudo: 20p->idadeaponta para

Esse diagrama ajuda a evitar uma confusão muito comum: p não guarda 20. Quem guarda 20 é idade. O que p guarda é o endereço de idade.

O tipo do ponteiro importa. Se escrevemos:

int *p;

isso significa que p aponta para um int. Da mesma forma:

float *q;
Aluno *a;
char *texto;

significam, respectivamente, ponteiro para float, ponteiro para Aluno e ponteiro para char. O tipo informa o que esperamos encontrar no endereço apontado e quantos bytes devem ser lidos ou escritos a partir dali.

O operador * também serve para acessar o conteúdo apontado12.4

O símbolo * aparece em dois contextos diferentes nesta aula:

  • na declaração, ele faz parte do tipo do ponteiro
  • em uma expressão, ele significa acesso ao conteúdo apontado

Veja:

int idade = 20;
int *p = &idade;

printf("Endereco guardado em p: %p\n", (void*)p);
printf("Conteudo apontado por p: %d\n", *p);
PonteiroConteudoppvalor de p: 0x100idadeendereco 0x100idade = 20p->idadevai ate 0x100conteudo*p = 20idade->conteudovalor encontrado la

Leia assim:

  • p é o endereço guardado no ponteiro
  • *p é o valor encontrado nesse endereço

Por isso, se fizermos:

*p = 35;

o valor de idade passa a ser 35, porque p aponta justamente para a posição onde idade está armazenada.

int idade = 20;
int *p = &idade;

*p = 35;
printf("%d\n", idade);

Saída:

35
Leitura que precisa ficar firme
p e *p não são a mesma coisa. p é endereço. *p é o conteúdo encontrado nesse endereço.

printf() e scanf() ajudam a enxergar a memória12.5

Desde o início da disciplina, você já usa printf() e scanf(). Agora podemos usá-los para entender melhor o que acontece com os dados na memória.

printf() pode mostrar valor e endereço12.5.1

int idade = 20;

printf("Valor: %d\n", idade);
printf("Endereco: %p\n", (void*)&idade);

O primeiro printf() lê o conteúdo da variável para exibir. O segundo mostra a localização da variável na memória.

scanf() precisa receber um endereço12.5.2

Agora observe:

int idade;
scanf("%d", &idade);

scanf() não foi chamado para apenas observar a variável. Ele precisa escrever nela. Para fazer isso, precisa saber onde essa variável está.

ScanfMemoriaentradaEntrada do usuario"25"scanfscanf("%d", &idade)entrada->scanfleendereco&idade = 0x100scanf->enderecorecebe o enderecoidadeMemoriaendereco: 0x100conteudo final: 25endereco->idadeescreve nesse local

Esse diagrama revela o motivo real do & em scanf():

  • o usuário digita um valor
  • scanf() interpreta esse valor
  • o programa precisa saber onde gravá-lo
  • &idade entrega o endereço correto
  • a escrita ocorre na posição correspondente à variável

Portanto:

scanf("%d", &idade);   // correto: entrega o endereco
scanf("%d", idade);   // errado: idade e um valor int, nao um endereco

Passagem por valor e passagem por endereço12.6

Na aula de funções, vimos que parâmetros são variáveis locais. Isso significa que, quando passamos um valor comum para uma função, o que a função recebe é uma cópia.

Veja:

void tentar_alterar(int x){
    x = 100;
}

int main(void){
    int n = 10;
    tentar_alterar(n);
    printf("%d\n", n);
    return 0;
}

A saída continua sendo 10, porque a função alterou apenas a sua cópia local.

PassagemValormainmainn = 10funcfuncaox = 10main->funccopia do valor

Agora compare com uma função que recebe endereço:

void alterar(int *p){
    *p = 100;
}

int main(void){
    int n = 10;
    alterar(&n);
    printf("%d\n", n);
    return 0;
}

Nesse caso, a saída passa a ser 100.

PassagemReferenciapfuncaop = 0x100nmainendereco: 0x100n = 10p->nmesma variavel original

O que mudou foi isto:

  • antes, a função recebia apenas uma cópia do valor
  • agora, ela recebe o endereço da variável original
  • usando *p, ela acessa exatamente o conteúdo dessa variável

Foi exatamente isso que aconteceu na aula 11 quando usamos funções como:

void aplicar_bonus(Aluno *a, float bonus);

Essa função não recebia uma cópia inteira do aluno. Ela recebia o endereço do aluno real, e por isso conseguia modificar a estrutura armazenada no vetor da turma.

Ponteiros para struct e o operador ->12.7

Na aula anterior, você viu dois tipos de acesso a campos de estrutura:

aluno.matricula

e

a->matricula

Agora a diferença pode ser explicada com precisão.

Quando temos a estrutura diretamente, usamos .:

Aluno aluno;
aluno.matricula = 1001;

Quando temos um ponteiro para a estrutura, usamos ->:

Aluno aluno;
Aluno *a = &aluno;

a->matricula = 1001;
PonteiroStructponteiroAluno *aconteudo: 0x200alunoAluno em 0x200nome: Anamatricula: 1001notas[0]: 8.0notas[1]: 7.0notas[2]: 9.0faltas: 3ponteiro->alunoaponta paracampoa->faltas acessafaltas dentro da structaluno->campo

Leia esse diagrama assim:

  • a guarda o endereço do aluno
  • o programa usa esse endereço para localizar a estrutura
  • depois disso, acessa o campo desejado

Portanto:

  • aluno.faltas acessa o campo quando temos a estrutura diretamente
  • a->faltas acessa o campo quando temos o endereço dessa estrutura

Se quisermos, podemos escrever também:

(*a).faltas

que significa a mesma coisa que:

a->faltas

Na prática, usamos -> porque a leitura fica mais natural.

NULL significa ausência de endereço válido12.8

Nem todo ponteiro precisa apontar para uma variável válida o tempo todo. Às vezes, o programa precisa representar justamente o contrário: a ideia de que nenhum endereço útil foi encontrado.

É para isso que usamos NULL.

int *p = NULL;

Esse comando significa que p existe como variável, mas no momento não aponta para nenhum objeto válido do programa.

Isso aparece naturalmente em funções de busca. Lembre da aula 11:

Aluno *a = buscar_aluno_por_matricula(turma, n, matricula);

if(a != NULL){
    aplicar_bonus(a, bonus);
}
BuscaNullbuscabuscar_aluno_por_matricula(...)okcaso 1a = 0x200busca->okencontrounullcasecaso 2a = NULLbusca->nullcasenao encontroualunoAluno encontradoendereco: 0x200ok->alunovazionenhum aluno encontradonullcase->vazio

Esse diagrama mostra dois cenários possíveis:

  • a busca encontrou um aluno e devolveu um endereço válido
  • a busca não encontrou ninguém e devolveu NULL

É por isso que o teste vem antes do uso. Se a == NULL, não faz sentido tentar acessar a->faltas, porque não existe uma estrutura válida naquele caminho.

Arrays e endereços: uma ponte com o que já vimos12.9

Ponteiros também aparecem o tempo todo quando trabalhamos com vetores. Considere:

int v[3] = {10, 20, 30};

O vetor ocupa posições consecutivas de memória. O nome v, em muitos contextos, se comporta como o endereço do primeiro elemento.

printf("%d\n", v[0]);
printf("%d\n", *v);

Nessa situação, v[0] e *v acessam o mesmo primeiro elemento.

ArrayMemorianomevendereco do primeiro elementoe0v[0]endereco: 0x300conteudo: 10nome->e0aponta para o primeiro elementoe1v[1]endereco: 0x304conteudo: 20e0->e1leitura*v = 10e0->leituraconteudo lidoe2v[2]endereco: 0x308conteudo: 30e1->e2

Nesse diagrama, o nome v está ligado ao primeiro elemento do vetor. Por isso, *v acessa o conteúdo armazenado em v[0]. Ao mesmo tempo, o desenho deixa claro que o vetor não é um valor único: ele é uma sequência de posições vizinhas de memória.

Essa ideia ajuda a reler vários trechos já usados na disciplina. Por exemplo, na aula 11:

Aluno turma[100];
ler_aluno(&turma[i]);

Aqui, turma[i] é o aluno da posição i, enquanto &turma[i] é o endereço desse aluno. Como ler_aluno() recebe Aluno *, o que precisamos entregar para a função é justamente o endereço do elemento correto.

Reflita

Quando o programa percorre a turma com um for, ele está apenas repetindo contas ou está caminhando por várias posições de memória que guardam alunos distintos?

O problema do tamanho fixo12.10

Agora que entendemos ponteiros, temos a ferramenta para localizar e modificar dados na memória. Mas ainda existe uma limitação importante: todos os vetores que usamos até agora tinham tamanho fixo, decidido antes da execução.

Na aula 11, nosso sistema de boletim armazenava os alunos assim:

Aluno turma[100];

Esse vetor sempre ocupa espaço para 100 alunos, mesmo que a turma tenha apenas 3. Se a turma tiver 150 alunos, o programa simplesmente não funciona, ou pior, funciona mas corrompe memória porque o vetor não tem espaço suficiente.

O incômodo real não é o número 100. É que o número exato de alunos só aparece na primeira linha da entrada, depois que o programa já está rodando:

3
Ana 1001 8.0 7.0 9.0 2
Bruno 1002 5.0 6.0 4.0 5
Caio 1003 3.0 4.0 5.0 1

O programa lê n = 3, mas o vetor já foi declarado com 100 posições antes mesmo de main começar. Em outras palavras, o tamanho do vetor foi decidido em tempo de compilação, enquanto o tamanho real do problema só aparece em tempo de execução.

int n;
scanf("%d", &n);
Aluno turma[n];   // isso nem sempre funciona em C

Em C, declarar um vetor com tamanho vindo de uma variável como Aluno turma[n] funciona em alguns compiladores (os que suportam VLA, Variable Length Array), mas não é portátil. Além disso, ainda não resolve o segundo problema: e se o programa, depois de ler a turma, precisar acrescentar mais um aluno? Um vetor declarado em tempo de compilação não cresce.

Reflita

Se o tamanho da turma fosse n = 10000, faria sentido o programa ocupar memória para 10000 alunos desde o início, mesmo que a maior parte das execuções use apenas 3 ou 4?

Duas regiões de memória12.11

Até aqui, todas as variáveis que criamos foram alocadas automaticamente. Quando a execução entra em um bloco de código, as variáveis daquele bloco ganham espaço. Quando a execução sai do bloco, esse espaço é automaticamente devolvido.

Essa região da memória onde as variáveis locais e os parâmetros vivem é chamada de pilha (stack). Toda variável declarada da forma que usamos até agora, como int x;, float v[10]; e Aluno a;, fica na pilha.

StackHeapmemoriaMemoria do programastackPilha (stack)Gerenciada automaticamenteEntrada e saida de funcoesmemoria->stackheapHeap (monte)Gerenciada pelo programadormalloc, realloc, freememoria->heap

A pilha é rápida e automática, mas tem uma limitação importante: o tamanho de cada variável precisa ser conhecido em tempo de compilação. É por isso que Aluno turma[100] funciona, mas Aluno turma[n] não funciona em todos os compiladores.

Existe outra região da memória chamada heap (monte). Diferentemente da pilha, o heap não é gerenciado automaticamente pela entrada e saída de funções. É o programador quem decide quando pedir memória ao heap e quando devolvê-la. Essa é a base da alocação dinâmica.

Pilha (stack) Heap (monte)
Gerenciada automaticamente Gerenciada pelo programador
Tamanho decidido em compilação Tamanho decidido em execução
Variáveis locais e parâmetros Blocos criados com malloc
Liberada ao sair do escopo Liberada com free
Rápida e limitada Flexível, mas exige cuidado
Por que "dinâmica"

Dinâmica significa que a quantidade de memória pode ser decidida durante a execução, reagindo ao que o programa encontra nos dados de entrada, em vez de ser fixada antes da compilação.

malloc(): pedindo memória durante a execução12.12

A função malloc() (memory allocate) pede um bloco de memória ao heap e devolve o endereço desse bloco. Ela está declarada em <stdlib.h>.

O protótipo é este:

void *malloc(size_t tamanho);

A função recebe a quantidade de bytes desejada e retorna um ponteiro do tipo void *. Em C, void * é um ponteiro genérico que pode ser atribuído a qualquer tipo de ponteiro sem conversão explícita.

Alocando um elemento12.12.1

O uso mais simples de malloc é pedir espaço para uma única variável:

int *p = malloc(sizeof(int));

Vamos ler esse comando parte por parte:

  • sizeof(int) calcula quantos bytes um int ocupa (normalmente 4)
  • malloc(sizeof(int)) pede exatamente esse número de bytes ao heap
  • malloc devolve o endereço do bloco alocado
  • p guarda esse endereço
MallocSimplesheap_regionHeappp(na pilha)endereco guardado: 0x500blocoBloco alocadoendereco: 0x500tamanho: sizeof(int)conteudo: ? (lixo)p->blocomalloc devolveueste endereco

Depois de alocado, podemos usar *p normalmente:

int *p = malloc(sizeof(int));
*p = 42;
printf("%d\n", *p);   // imprime 42

O que mudou em relação ao que fazíamos antes? Antes, faríamos:

int x = 42;
int *p = &x;

Agora, a variável x nem precisa existir. O espaço foi obtido diretamente do heap, e p aponta para ele. Se p sair de escopo sem que a memória tenha sido liberada, o bloco continua existindo no heap, e isso é tanto uma vantagem (a memória sobrevive ao fim da função) quanto um perigo (se ninguém mais tiver o endereço, o bloco fica permanentemente ocupado).

Alocando um vetor12.12.2

Pedir memória para vários elementos é igualmente simples. Basta multiplicar o tamanho de um elemento pela quantidade desejada:

int n;
scanf("%d", &n);

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

Agora o programa lê n do teclado e aloca exatamente o espaço necessário para n inteiros. Se n = 3, o heap recebe um pedido de 3 * sizeof(int) bytes. Se n = 10000, recebe um pedido de 10000 * sizeof(int) bytes.

MallocVetorvvendereco do primeiro elemento: 0x600e0v[0]endereco: 0x600v->e0aponta parae1v[1]endereco: 0x604e0->e1e2v[2]endereco: 0x608e1->e2

Observe que, depois de alocar, podemos usar v exatamente como um vetor comum. A notação com colchetes continua funcionando:

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

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

A diferença é que agora n veio da entrada. Se o usuário digitar 5, o heap terá 5 posições. Se digitar 50000, terá 50000 posições. Em nenhum momento o programa precisou decidir esse número antes de compilar.

sizeof não é uma função executada
sizeof é resolvido em tempo de compilação. Ele não mede o tamanho de um bloco alocado dinamicamente, e sim o tamanho do tipo da expressão. sizeof(v) onde v é int * devolve o tamanho do ponteiro, não do bloco apontado.

Tratando falhas de alocação12.12.3

malloc pode falhar. Se o heap não tiver memória suficiente para atender ao pedido, a função retorna NULL. Um programa correto deve verificar isso:

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

if(v == NULL){
    printf("Erro: memoria insuficiente.\n");
    return 1;
}

Ignorar essa verificação significa arriscar acessar *v ou v[i] quando v vale NULL, o que provoca comportamento indefinido e tipicamente encerra o programa de forma abrupta.

A turma dinâmica12.12.4

Agora podemos voltar ao sistema de alunos da aula 11 e substituir o vetor fixo por um vetor dinâmico:

int n;
scanf("%d", &n);

Aluno *turma = malloc(n * sizeof(Aluno));

if(turma == NULL){
    printf("Erro ao alocar turma.\n");
    return 1;
}

for(i = 0; i < n; i++){
    ler_aluno(&turma[i]);
}

Compare com o que tínhamos antes:

Antes (fixo) Depois (dinâmico)
Aluno turma[100]; Aluno *turma = malloc(n * sizeof(Aluno));
Sempre 100 posições Exatamente n posições
n não pode passar de 100 n limitado apenas pela memória disponível
Não precisa de free Precisa de free quando terminar

O programa continua funcionando da mesma forma. turma[i] acessa o aluno da posição i, &turma[i] entrega o endereço desse aluno, e todas as funções da biblioteca da aula 11 continuam compatíveis.

Reflita

Se turma agora é um ponteiro, por que turma[i] continua funcionando como se fosse um vetor comum?

calloc(): alocando e zerando12.13

malloc entrega um bloco de memória sem alterar o conteúdo que já existia nele. Isso significa que, logo após a alocação, os valores dentro do bloco são imprevisíveis, ou seja, são "lixo" de memória.

Se quisermos que todas as posições comecem zeradas, usamos calloc (clear allocation):

int *v = calloc(n, sizeof(int));

A diferença em relação a malloc está nos argumentos e no comportamento:

Função Argumentos Inicialização
malloc(n * sizeof(int)) Total de bytes Não limpa (lixo)
calloc(n, sizeof(int)) Quantidade separada do tamanho de cada Zera tudo
int *v1 = malloc(5 * sizeof(int));   // conteudo: ?, ?, ?, ?, ?
int *v2 = calloc(5, sizeof(int));   // conteudo: 0, 0, 0, 0, 0

Para a maioria dos programas que usam alocação dinâmica, começar com valores zerados é mais seguro e conveniente, especialmente em vetores que serão preenchidos parcialmente ou usados como acumuladores. A contrapartida é que calloc pode ser ligeiramente mais lento que malloc, porque precisa percorrer o bloco inteiro escrevendo zeros.

Exceção com malloc

Em alguns compiladores e situações específicas de depuração, malloc pode acabar entregando memória zerada por coincidência. Mas isso não é garantido pelo padrão da linguagem. Contar com esse comportamento é um erro.

realloc(): redimensionando um bloco que já existe12.14

Até aqui, resolvemos o problema de saber o tamanho exato durante a execução. Mas ainda existe outro problema: e se o tamanho mudar depois que o bloco já foi alocado?

Imagine que o sistema de boletim, depois de ler a turma, precise acrescentar mais um aluno porque uma nova matrícula chegou. A função que resolve isso é realloc (reallocate):

void *realloc(void *bloco, size_t novo_tamanho);

realloc recebe um bloco previamente alocado e o novo tamanho desejado em bytes. Ela tenta redimensionar o bloco no próprio local. Se não for possível (porque a memória vizinha já está ocupada), realloc aloca um novo bloco maior, copia os dados antigos para ele, libera o bloco antigo e devolve o endereço do novo.

int *v = malloc(3 * sizeof(int));
v[0] = 10;
v[1] = 20;
v[2] = 30;

// Agora preciso de 5 posicoes
int *temp = realloc(v, 5 * sizeof(int));
if(temp != NULL){
    v = temp;
    v[3] = 40;
    v[4] = 50;
}
ReallocantesAntes do reallocv aponta para 3 posicoes[ 10 | 20 | 30 ]depoisDepois do reallocv aponta para 5 posicoes[ 10 | 20 | 30 | 40 | 50 ]As 3 primeiras foram preservadasantes->depoisrealloc(v, 5 * sizeof(int))

Alguns cuidados importantes com realloc:

  • O conteúdo original até o mínimo entre o tamanho antigo e o novo é preservado
  • Se o novo tamanho for maior, os bytes adicionais não são inicializados (comportamento igual ao malloc)
  • Se realloc falhar, retorna NULL, mas o bloco original continua válido. Por isso, nunca faça p = realloc(p, ...) diretamente sem um ponteiro auxiliar
Padrão seguro com ponteiro auxiliar
int *temp = realloc(v, novo_n * sizeof(int));
if(temp == NULL){
    printf("Erro ao redimensionar.\n");
    // v ainda e valido; podemos continuar com o tamanho antigo
}
else {
    v = temp;
}

Se o primeiro argumento de realloc for NULL, a função se comporta exatamente como malloc. Se o novo tamanho for 0, o comportamento é dependente de implementação.

Acrescentando um aluno à turma12.14.1

No nosso sistema de boletim, podemos usar realloc para adicionar um novo aluno:

void turma_adicionar(Aluno **turma, int *n, Aluno aluno){
    Aluno *temp = realloc(*turma, (*n + 1) * sizeof(Aluno));

    if(temp == NULL){
        printf("Erro ao expandir turma.\n");
        return;
    }

    *turma = temp;
    (*turma)[*n] = aluno;
    (*n)++;
}

Repare no duplo ponteiro Aluno **turma. A função precisa modificar o ponteiro que a main está usando, e para isso ela recebe o endereço desse ponteiro, exatamente a mesma lógica de passagem por endereço que estudamos na primeira metade desta aula. Para alterar um Aluno *, passamos Aluno **. É a mesma lógica de scanf("%d", &idade): para modificar idade, passamos &idade.

free(): devolvendo memória ao sistema12.15

Toda memória alocada com malloc, calloc ou realloc precisa ser explicitamente devolvida com free. Enquanto a pilha é gerenciada automaticamente, o heap depende do programador.

int *v = malloc(100 * sizeof(int));
// usa o vetor...
free(v);

Depois de free(v), o bloco volta a pertencer ao sistema. Qualquer acesso a v[i] depois disso é comportamento indefinido. O ponteiro v continua guardando o endereço antigo, mas esse endereço não é mais válido.

Ponteiro pendente

Depois de free(v), o ponteiro v ainda contém o endereço do bloco liberado, mas esse endereço não deve mais ser usado. Acessar *v nesse ponto é tão errado quanto acessar *p quando p == NULL. Uma prática defensiva comum é fazer v = NULL; logo após free(v);, porque free(NULL) é garantidamente seguro.

Vazamento de memória12.15.1

Memória alocada e não liberada continua ocupada até o fim do programa. Se o programa aloca repetidamente sem liberar, o consumo de memória cresce sem parar. Esse problema é chamado de vazamento de memória (memory leak).

// ERRO: vazamento de memoria
for(i = 0; i < 1000; i++){
    int *v = malloc(1000000 * sizeof(int));
    // usa v...
    // esqueceu de chamar free(v)
}

Nesse exemplo, a cada iteração malloc reserva um bloco novo e o endereço antigo (guardado em v) é perdido. Como ninguém mais sabe onde o bloco antigo está, ele nunca será liberado. Em menos de um segundo, o programa pode consumir gigabytes de memória desnecessariamente.

Em programas pequenos que terminam rápido, o sistema operacional recupera toda a memória quando o processo encerra. Mas em programas que rodam por horas ou dias (servidores, editores, navegadores), vazamentos de memória se acumulam e degradam o sistema.

Regra de ouro

Toda chamada de malloc, calloc ou realloc bem-sucedida deve ter uma chamada de free correspondente. Se o programa perde o endereço de um bloco antes de liberá-lo, essa memória fica permanentemente inacessível.

O sistema de alunos completo com alocação dinâmica12.16

Agora podemos reescrever o sistema de boletim da aula 11 usando alocação dinâmica em vez de vetor fixo. A mudança afeta principalmente a main, enquanto as funções da biblioteca aluno.h / aluno.c continuam praticamente as mesmas.

Entrada12.16.1

A entrada continua igual:

3
Ana 1001 8.0 7.0 9.0 2
Bruno 1002 5.0 6.0 4.0 5
Caio 1003 3.0 4.0 5.0 1
2
1001 1.0
9999 2.0

A primeira linha traz n. Depois vêm n alunos. Depois um inteiro m com a quantidade de operações de bônus e os pares de matrícula com valor.

Programa principal12.16.2

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

int main(void){
    int n, i;

    scanf("%d", &n);

    Aluno *turma = malloc(n * sizeof(Aluno));

    if(turma == NULL){
        printf("Erro ao alocar turma.\n");
        return 1;
    }

    for(i = 0; i < n; i++){
        ler_aluno(&turma[i]);
    }

    int m;
    scanf("%d", &m);

    for(i = 0; i < m; i++){
        int matricula;
        float bonus;
        scanf("%d %f", &matricula, &bonus);

        Aluno *a = buscar_aluno_por_matricula(turma, n, matricula);

        if(a != NULL){
            aplicar_bonus(a, bonus);
        }
    }

    imprimir_relatorio(turma, n);

    free(turma);
    return 0;
}

Compare com a versão anterior:

  • Aluno turma[100] virou Aluno *turma = malloc(n * sizeof(Aluno))
  • O tamanho n agora pode ser qualquer valor inteiro positivo
  • O programa verifica se malloc funcionou
  • No final, free(turma) devolve a memória

O restante do código, as funções em aluno.h e aluno.c, não mudou absolutamente nada. buscar_aluno_por_matricula, aplicar_bonus, aluno_media, imprimir_relatorio continuam recebendo um ponteiro Aluno *turma e um inteiro n, exatamente como antes. Isso mostra uma qualidade importante dos ponteiros: funções que recebem Aluno * não se importam se o vetor veio da pilha ou do heap.

E se a turma crescer durante a execução12.16.3

Agora suponha que, além das operações de bônus, a entrada também possa conter novos alunos para acrescentar à turma. Podemos tratar isso com realloc:

Aluno *turma = malloc(n * sizeof(Aluno));
// ... preenche a turma ...

// Mais tarde, precisa acrescentar um aluno
Aluno novo;
ler_aluno(&novo);

Aluno *temp = realloc(turma, (n + 1) * sizeof(Aluno));

if(temp == NULL){
    printf("Erro ao expandir.\n");
}
else {
    turma = temp;
    turma[n] = novo;
    n++;
}

Antes, com Aluno turma[100], isso simplesmente não era possível. O vetor fixo não cresce, e a única saída era definir um limite arbitrário alto o suficiente para "nunca estourar", o que é uma aposta, não uma solução.

O ciclo completo12.17

As últimas aulas formam uma progressão que vale a pena enxergar como um todo:

Aula Problema central Ferramenta
11. Tipos Definidos pelo Programador Dados soltos, sem modelo struct, typedef, enum
12. Ponteiros e Alocação Dinâmica Localizar, modificar e criar dados &, *, ->, malloc, free

A aula 11 nos deu estruturas para modelar entidades como Aluno. Esta aula nos deu ponteiros para localizar e modificar essas estruturas na memória e alocação dinâmica para criar e redimensionar esses dados durante a execução.

Juntas, essas ideias formam a base para programas em C que não apenas funcionam, mas que se adaptam aos dados que encontram.

Questões12.18

1. No trecho int idade = 20;, explique a diferença entre idade e &idade.

2. O que significa dizer que um ponteiro é uma variável que armazena endereços? Dê um exemplo.

3. Em int *p = &idade;, o que está guardado em p? E em *p? Explique por que não são a mesma coisa.

4. Se int *p = &idade; e depois executamos *p = 50;, qual será o novo valor de idade? Justifique.

5. Por que scanf("%d", &idade) está correto, mas scanf("%d", idade) está errado?

6. Explique a diferença entre tentar_alterar(int x) e alterar(int *p) em relação à modificação da variável original.

7. Qual é a diferença entre aluno.faltas e a->faltas? Em que situação cada um é usado?

8. O que NULL comunica quando uma função de busca o retorna? Por que devemos testar if(a != NULL) antes de usar a->faltas?

9. Explique a diferença entre declarar int v[10] e usar int *v = malloc(10 * sizeof(int)).

10. Por que precisamos multiplicar por sizeof(Aluno) ao chamar malloc para alocar a turma? O que acontece se escrevermos apenas malloc(n)?

11. Qual é a diferença entre malloc e calloc? Quando calloc é mais conveniente?

12. O que realloc faz quando não consegue expandir o bloco no mesmo local? Por que devemos usar um ponteiro auxiliar em vez de fazer v = realloc(v, ...)?

13. O que é um vazamento de memória? Escreva um exemplo pequeno que provoca esse problema.

14. No sistema de boletim, por que free(turma) aparece apenas uma vez no final da main, e não dentro de funções como buscar_aluno_por_matricula ou aplicar_bonus?

15. No código da função turma_adicionar, explique por que o parâmetro turma é declarado como Aluno **turma (ponteiro duplo) em vez de Aluno *turma.

Gabarito

1. idade representa o valor guardado na variável (o número 20). &idade representa o endereço onde esse valor está armazenado na memória (algo como 0x100). Um é o conteúdo, o outro é a localização.

2. Significa que o conteúdo do ponteiro é uma localização de memória, não um valor comum como idade ou matrícula. Exemplo: int *p = &idade; faz p guardar o endereço de idade, não o valor 20.

3. Em p fica guardado o endereço de idade. *p é o valor encontrado nesse endereço, ou seja, 20. p e *p não são a mesma coisa: um é o endereço, o outro é o conteúdo localizado por esse endereço.

4. O novo valor de idade será 50, porque p aponta para a posição de idade na memória. *p = 50 escreve 50 diretamente nessa posição, alterando a variável original.

5. scanf() precisa de um endereço para saber onde gravar o valor lido do teclado. &idade fornece esse endereço. idade sozinho representa apenas um valor inteiro, não uma localização.

6. tentar_alterar(int x) recebe uma cópia do valor de n; alterar x não afeta n. alterar(int *p) recebe o endereço de n; usar *p modifica diretamente a variável original.

7. aluno.faltas acessa o campo faltas quando temos a estrutura diretamente (variável do tipo Aluno). a->faltas acessa o mesmo campo quando temos um ponteiro para a estrutura (variável do tipo Aluno *).

8. NULL comunica que nenhum endereço válido foi encontrado. O teste if(a != NULL) é necessário porque, se a == NULL, tentar acessar a->faltas tentaria acessar memória em um endereço inválido, causando falha de segmentação.

9. int v[10] aloca o vetor na pilha com tamanho fixo decidido em tempo de compilação, e a memória é liberada automaticamente ao sair do escopo. int *v = malloc(10 * sizeof(int)) aloca no heap, o tamanho pode vir de uma variável, e a memória só é liberada com free.

10. malloc recebe a quantidade de bytes, não de elementos. sizeof(Aluno) informa quantos bytes cada Aluno ocupa. Multiplicando por n, obtemos o total de bytes para n alunos. malloc(n), sem sizeof, alocaria apenas n bytes, insuficientes para armazenar os alunos.

11. malloc entrega o bloco sem limpar o conteúdo (valores imprevisíveis). calloc entrega o bloco com todos os bytes zerados. calloc é mais conveniente quando o vetor será preenchido parcialmente, usado como acumulador, ou quando é importante que todas as posições comecem com zero.

12. realloc aloca um novo bloco maior em outro endereço, copia os dados antigos para ele, libera o bloco antigo e devolve o endereço do novo. Usamos ponteiro auxiliar porque, se realloc falhar, retorna NULL mas o bloco original continua válido. Se fizéssemos v = realloc(v, ...), perderíamos o endereço original em caso de falha.

13. Vazamento de memória ocorre quando um bloco alocado no heap perde todas as referências antes de ser liberado, tornando-se permanentemente inacessível. Exemplo:

for(i = 0; i < 100; i++){
    int *v = malloc(1000 * sizeof(int));
}

A cada iteração, o endereço anterior é perdido e nenhum free é chamado.

14. A main é quem alocou a turma (malloc), então a responsabilidade de liberá-la (free) é dela. As funções buscar_aluno_por_matricula e aplicar_bonus apenas consultam e modificam elementos que já existem no bloco alocado. Elas não são donas da memória, portanto não devem liberá-la.

15. turma_adicionar precisa modificar o ponteiro que a main está usando, pois após realloc o endereço do bloco pode ter mudado. Para alterar uma variável de fora da função, precisamos do endereço dessa variável. Se a variável na main é Aluno * (um ponteiro), seu endereço é Aluno **. É a mesma lógica de scanf("%d", &idade): para modificar idade, passamos &idade; para modificar turma, passamos &turma.

Próximos passos12.19

Agora sabemos localizar dados com ponteiros e criar e redimensionar dados com alocação dinâmica. Com struct, ponteiros e alocação dinâmica, temos as ferramentas centrais para construir programas em C que se adaptam ao tamanho real dos dados.

Na próxima aula, vamos estudar recursão, uma técnica em que uma função chama a si mesma para resolver problemas que podem ser decompostos em versões menores de si próprios, e que se apoia diretamente na pilha de chamadas que começamos a entender nesta aula. Continuaremos com exercícios práticos para consolidar a leitura e a escrita de código, porque é programando que o entendimento realmente se firma.