Material Extra - Conceitos de Linguagens de Programação
Antes de avançarmos para as estruturas de dados propriamente ditas (pilhas, filas, listas, árvores), vale a pena entender um pouco melhor como um programa se organiza na memória. Esses conceitos explicam por que algumas estruturas de dados são mais rápidas ou mais lentas que outras, o que será importante quando começarmos a falar de complexidade.
Memória: stack × heap
Em C, variáveis comuns (locais, vetores de tamanho fixo) ficam na pilha (stack), e memória alocada com malloc() fica no heap. As duas regiões diferem no gerenciamento e no tempo de vida dos dados:
| Região | Gerenciamento | Tempo de vida do dado |
|---|---|---|
| Pilha | Automático — associado ao início/fim de cada função. | Termina quando a função retorna. |
| Heap | Manual, através de malloc() e free(). |
Dura até que free() seja chamado explicitamente. |
Essa diferença de tempo de vida é o motivo pelo qual estruturas de dados dinâmicas (listas, pilhas, filas, árvores) precisam viver no heap: uma variável da pilha deixa de existir assim que a função retorna, mas uma estrutura de dados normalmente precisa continuar existindo depois disso.
A pilha de chamadas de função
Cada vez que uma função é chamada, um novo bloco (chamado quadro de ativação, ou stack frame) é empilhado, contendo os parâmetros e variáveis locais daquela chamada. Quando a função retorna, esse bloco é desempilhado e suas variáveis deixam de existir. Já vimos isso na prática na função recursiva contarAprovados do laboratório de revisão de linguagem C (Material Extra): cada chamada recursiva empilha um novo quadro com o seu próprio n, até chegar ao caso base:
contarAprovados(turma, 3) // empilha quadro com n=3 contarAprovados(turma, 2) // empilha quadro com n=2 contarAprovados(turma, 1) // empilha quadro com n=1 contarAprovados(turma, 0) // caso base, retorna e desempilha
Uma recursão sem caso base (ou com profundidade excessiva) empilha quadros indefinidamente e esgota o espaço da pilha, causando um erro conhecido como stack overflow.
Variáveis globais
Nem toda variável vive na pilha ou no heap: variáveis globais (declaradas fora de qualquer função) ficam em uma região separada da memória, e existem durante toda a execução do programa — não são criadas nem destruídas a cada chamada de função:
int totalAlunosCadastrados = 0; // global: existe do início ao fim do programa void lerAluno(Aluno *aluno) { // ... totalAlunosCadastrados++; // acessível de qualquer função }
| Aviso. Apesar de existirem, variáveis globais não devem ser usadas nos programas desta disciplina (nem, em geral, na prática). Como podem ser acessadas e alteradas por qualquer função, elas tornam o comportamento do programa mais difícil de entender e de depurar. Prefira sempre passar dados entre funções através de parâmetros e retornos. |
Valor × ponteiro
Quando copiamos uma struct por valor (ex: void imprimirAluno(Aluno aluno)), copiamos todos os seus bytes. Quando copiamos um ponteiro (Aluno *aluno), copiamos apenas o endereço — poucos bytes, independente do tamanho da struct apontada.
Essa diferença de custo é um dos motivos pelos quais estruturas de dados costumam ser manipuladas por ponteiro: uma lista encadeada, por exemplo, nada mais é do que structs contendo um dado e um ponteiro para o próximo nó.
Vimos exatamente essa diferença no laboratório de revisão de linguagem C (Material Extra): imprimirAluno(Aluno aluno) recebe uma cópia inteira da struct (nome, matrícula e média), enquanto atualizarMedia(Aluno *aluno, float media) recebe apenas o endereço do aluno original — por isso consegue alterá-lo de verdade, e a um custo bem menor de cópia.
Localidade de memória
Um vetor (estático ou dinâmico) ocupa posições contíguas na memória, o que é muito eficiente para o processador. Já uma lista encadeada, formada por nós ligados por ponteiros, pode ter seus elementos espalhados em qualquer lugar do heap.
Essa diferença de organização é uma das razões pelas quais o desempenho real de uma estrutura de dados pode variar mesmo quando duas operações têm a mesma complexidade teórica.
É o caso do vetor turma usado no laboratório de revisão de linguagem C (Material Extra), na Aula 1b e na Aula 3. Tanto faz se ele é estático (Aluno turma[MAX];, na pilha) ou alocado dinamicamente (Aluno *turma = malloc(n * sizeof(Aluno));, no heap): em ambos os casos, o vetor inteiro é reservado de uma vez, então seus elementos ficam lado a lado na memória, em endereços consecutivos:
Aluno turma[3]; // vetor estático, na pilha Aluno *turma = malloc(3 * sizeof(Aluno)); // vetor dinâmico, no heap Endereço Conteúdo E1000 turma[0] // sizeof(Aluno) = 60 bytes, por exemplo E1060 turma[1] // E1000 + 60 E1120 turma[2] // E1060 + 60
A única diferença entre as duas versões é onde esse bloco contíguo fica (pilha ou heap) e quem o libera (automático ou free()) — a forma de acessar turma[i] é idêntica nos dois casos, porque em ambos o compilador só precisa somar i * sizeof(Aluno) ao endereço inicial.
Isso é bem diferente do que acontece quando cada elemento é alocado separadamente, um de cada vez, como faríamos ao montar uma lista encadeada — nesse caso, nada garante que os blocos fiquem próximos na memória:
Aluno *a0 = malloc(sizeof(Aluno)); // primeira chamada a malloc() Aluno *a1 = malloc(sizeof(Aluno)); // segunda chamada a malloc() Aluno *a2 = malloc(sizeof(Aluno)); // terceira chamada a malloc() Endereço Conteúdo E1000 *a0 E19000 *a1 // bem longe de a0 E8500 *a2 // entre a0 e a1, sem relação fixa
Para "andar" de a0 até a1 não basta somar um deslocamento fixo ao endereço, como no vetor — é preciso guardar explicitamente o endereço do próximo elemento (um ponteiro dentro da struct), que é exatamente a ideia por trás de uma lista encadeada.
Tipagem em C
C é uma linguagem de tipagem estática: o tipo de cada variável é fixado em tempo de compilação e nunca muda durante a execução.
int x = 10; x = "dez"; // erro de compilação: "x" é (e sempre será) int
Ainda assim, o programador pode forçar conversões entre tipos através de casts, mesmo entre ponteiros de tipos diferentes. É esse recurso que sustenta o uso de void *: um ponteiro genérico, sem tipo definido, que precisa ser convertido (cast) para o tipo correto antes de ser desreferenciado. Foi assim que implementamos imprimirVetor na Aula 3, recebendo qualquer vetor como void *vetor e fazendo o cast correto dentro da função imprimirInt:
void imprimirInt(void *elemento) { printf("%d ", *((int *) elemento)); // cast de void * para int * }
Resumo
|
Exercícios
- Explique por que uma função nunca deve retornar um ponteiro para uma variável local (alocada na pilha) dessa mesma função.
- Considere uma função recursiva sem caso base (ou com um caso base que nunca é alcançado). O que acontece com a pilha de chamadas nesse cenário? Como se chama o erro resultante?
- Por que variáveis globais não devem ser usadas, mesmo sendo tecnicamente permitidas em C? Dê um cenário concreto em que uma variável global tornaria um bug mais difícil de encontrar.
-
Explique, em termos de bytes copiados, por que
void atualizarMedia(Aluno *aluno, float media)é mais barata de chamar do que seria uma versão hipotéticaAluno atualizarMedia(Aluno aluno, float media)que recebesse e retornasse o aluno por valor. -
Por que um vetor de structs tem melhor localidade de memória do que uma coleção de structs alocadas uma a uma, individualmente, com várias chamadas separadas de
malloc()? - C é uma linguagem de tipagem estática. Isso significa que o compilador sempre impede erros relacionados a tipos? Justifique usando o conceito de cast.
-
Escreva uma função em C,
void imprimirFloat(void *elemento), análoga aimprimirInt, capaz de ser usada com a funçãoimprimirVetorvista na Aula 3 para imprimir um vetor defloat. -
Escreva um pequeno trecho de código em C que declare um vetor de 4 posições de
doublena pilha, e imprima o endereço de cada posição (usando&vetor[i]eprintfcom%p). O que se espera observar na diferença entre os endereços impressos?
Sugestões de Respostas dos Exercícios
Exercício 1
Porque uma variável local vive em um quadro de ativação da pilha, que é destruído automaticamente assim que a função retorna. Um ponteiro para essa variável continuaria "apontando" para aquele endereço, mas o conteúdo ali pode ser sobrescrito a qualquer momento pela próxima função chamada — resultando em um comportamento indefinido (um caso de dangling pointer, como vimos nas Aulas 1d e 3b).
Exercício 2
Cada chamada recursiva empilha um novo quadro de ativação, e como o caso base nunca é alcançado, esses quadros continuam se acumulando indefinidamente, sem nunca serem desempilhados. Isso esgota o espaço reservado para a pilha, causando um erro conhecido como stack overflow.
Exercício 3
Porque uma variável global pode ser lida e alterada por qualquer função do programa, sem que isso apareça na assinatura dessas funções (parâmetros e retorno). Isso torna difícil saber, só de olhar para uma função, quais dados ela realmente utiliza ou modifica.
Um cenário concreto: imagine uma variável global int totalAlunosCadastrados sendo incrementada tanto em lerAluno quanto, por engano, em uma outra função imprimirAluno (que deveria apenas exibir dados, não alterar contadores). O valor final ficaria incorreto, e como a alteração pode ocorrer em qualquer lugar do código, encontrar o ponto exato do bug exigiria revisar o programa inteiro, em vez de apenas a função responsável.
Exercício 4
A versão com ponteiro (Aluno *aluno) copia apenas o endereço do aluno — tipicamente 8 bytes em uma máquina de 64 bits — independentemente do tamanho da struct Aluno.
Já a versão hipotética por valor precisaria copiar a struct inteira (nome, matrícula, média) na entrada da função, e copiá-la novamente por completo no retorno — duas cópias de todos os bytes da struct, em vez de uma única cópia de um endereço pequeno e de tamanho fixo.
Exercício 5
Um vetor de structs (estático ou alocado com um único malloc()) reserva um bloco contíguo de memória de uma só vez, então todos os seus elementos ficam lado a lado, em endereços consecutivos.
Já quando cada struct é alocada separadamente (um malloc() por elemento), cada chamada pode devolver um endereço em qualquer lugar do heap, sem relação fixa com as demais — os elementos ficam potencialmente espalhados pela memória, prejudicando a localidade de acesso do processador.
Exercício 6
Não. A tipagem estática garante apenas que o tipo declarado de cada variável seja fixo e conhecido em tempo de compilação — mas C permite casts explícitos entre tipos de ponteiros diferentes (como converter um void * para int *), mesmo quando o dado apontado não é realmente do tipo para o qual se está convertendo. O compilador não impede esse tipo de erro; ele só garante que, sem um cast explícito, o tipo declarado de uma variável não muda sozinho.
Exercício 7
void imprimirFloat(void *elemento) { printf("%.2f ", *((float *) elemento)); // cast de void * para float * }
Uso esperado: imprimirVetor(vf, 5, sizeof(float), imprimirFloat);
Exercício 8
int main() { double vetor[4]; int i; for (i = 0; i < 4; i++) { printf("Endereco de vetor[%d]: %p\n", i, (void *) &vetor[i]); } return 0; }
Espera-se que os endereços impressos cresçam em incrementos fixos, iguais a sizeof(double) (tipicamente 8 bytes) — por exemplo, 0x1000, 0x1008, 0x1010, 0x1018 — confirmando que o vetor ocupa posições contíguas na memória, como discutido em "Localidade de memória".