Ponteiros sem medo: o que acontece na memória quando você escreve p = p->prox
Ponteiro não é bicho de sete cabeças: é uma variável que guarda um endereço. Veja, linha a linha e com endereços de verdade, o que &, * e -> fazem em C++, e os três erros que mais derrubam quem está começando.
5 min de leitura
Neste artigo
- Toda variável mora num endereço
- Ponteiro é só uma variável que guarda um endereço
- O asterisco tem dois papéis
- Ponteiro para struct e a famosa seta
- Agora sim: p = p->prox
- Os três erros clássicos
- 1. Acessar o campo de um ponteiro nulo
- 2. Perder o nó por trocar a ordem das ligações
- 3. Esquecer o delete, ou usar depois dele
- Veja os endereços de verdade
- Conclusão
Se existe um assunto que tira o sono de quem estuda programação, é ponteiro. Já vi aluno ótimo travar por semanas numa lista encadeada, olhando para p = p->prox como quem olha para um feitiço.
E eu entendo o motivo. Ponteiro costuma ser ensinado como um conjunto de símbolos (&, *, ->) com regras para decorar. Decorar símbolo não funciona. O que funciona é desenhar a memória.
Por isso, nas minhas aulas, a regra é uma só: quem não desenha, não entende ponteiro. Pegue papel e caneta e venha comigo.
Toda variável mora num endereço
A memória do computador é uma sequência enorme de caixinhas numeradas. O número de cada caixinha é o seu endereço.
Quando você escreve:
int x = 42;
o compilador escolhe uma caixinha livre, chama ela de x e guarda 42 lá dentro. Você pode perguntar onde ela ficou com o operador &, o “endereço de”:
cout << x << endl; // 42
cout << &x << endl; // algo como 0x7ffe14
O endereço muda a cada execução, e tudo bem. O que importa é a ideia: x é o conteúdo, &x é onde ele mora.
Ponteiro é só uma variável que guarda um endereço
Pronto, essa é a definição inteira. Não tem mais nada escondido:
int x = 42;
int* p = &x; // p guarda o endereço de x
Desenhando, com endereços inventados para facilitar:
| Variável | Endereço | Conteúdo |
|---|---|---|
x | 0x7ffe14 | 42 |
p | 0x7ffe20 | 0x7ffe14 |
Repare: p também é uma variável, também mora num endereço (0x7ffe20) e também tem um conteúdo. A única diferença é que o conteúdo dela é o endereço de outra variável. Por isso a gente diz que p “aponta para” x, e desenha uma seta de p até x.
A seta é só um desenho. Na memória, o que existe é um número.
O asterisco tem dois papéis
Aqui mora metade da confusão. O * aparece em dois contextos diferentes, com significados diferentes:
1. Na declaração, ele diz o tipo:
int* p; // "p é um ponteiro para int"
2. Numa expressão, ele quer dizer “vá até o endereço e pegue o que tem lá” (o nome técnico é desreferenciar):
cout << *p << endl; // 42: vai até 0x7ffe14 e lê
*p = 50; // vai até 0x7ffe14 e escreve 50
cout << x << endl; // 50!
Esse último trecho é o momento “ahá” de muita gente. Ninguém mexeu em x diretamente, mas x mudou. Faz sentido no desenho: *p e x são a mesma caixinha, acessada por dois caminhos.
Ponteiro para struct e a famosa seta
Nas estruturas de dados, os ponteiros quase sempre apontam para structs (ou objetos), como o nó de uma lista:
struct No {
int item;
No* prox;
};
No* p = new No; // cria um nó em algum lugar livre e guarda o endereço em p
Para acessar um campo do nó apontado por p, você poderia escrever (*p).item: “vá até o nó e pegue o campo item”. Funciona, mas é feio e fácil de errar o parêntese. Por isso a linguagem oferece um atalho:
p->item = 10; // exatamente o mesmo que (*p).item = 10
A seta -> é só isso: desreferenciar e acessar o campo, numa tacada.
Agora sim: p = p->prox
Com tudo isso no lugar, vamos dissecar a linha mais temida das estruturas de dados. Imagine uma lista com três nós:
| Nó | Endereço | item | prox |
|---|---|---|---|
| primeiro | 0xa0 | 10 | 0xc8 |
| segundo | 0xc8 | 20 | 0xb4 |
| terceiro | 0xb4 | 30 | NULL |
Repare que os endereços não estão em ordem. Os nós foram criados com new e cada um caiu onde havia espaço livre. É assim mesmo.
Agora fazemos No* p = head;. O head guarda 0xa0, então p passa a guardar 0xa0.
Execute p = p->prox; em câmera lenta:
- Lado direito primeiro.
p->proxsignifica: vá até o endereço que está emp(0xa0) e leia o campoprox. Lá dentro está o número0xc8. - Atribuição. Esse número é copiado para dentro de
p. Agorapguarda0xc8.
Só isso. O nó não se moveu, nenhum dado foi copiado, nada foi criado. p simplesmente recebeu um endereço novo, que ele foi buscar dentro do nó para onde apontava. No desenho, parece que a seta de p “andou” para o próximo nó. Na memória, um número foi trocado por outro.
Repita a linha e p vai para 0xb4. Repita mais uma e p vira NULL, o sinal de que a lista acabou. É exatamente por isso que o laço clássico de percurso é:
No* p = head;
while (p != NULL) {
cout << p->item << endl;
p = p->prox;
}
Os três erros clássicos
Quem entende o desenho evita quase todos os bugs com ponteiro. Os mais comuns, que eu vejo toda prova:
1. Acessar o campo de um ponteiro nulo
No* p = head; // lista vazia: head é NULL
cout << p->item; // vai até o endereço NULL... e o programa morre
NULL quer dizer “não aponta para lugar nenhum”. Pedir o campo de lugar nenhum derruba o programa (o famoso segmentation fault). Antes de usar p->, pergunte: p pode ser NULL aqui? Por isso o while (p != NULL) vem antes do p->item.
2. Perder o nó por trocar a ordem das ligações
// ERRADO: inserir no início
head = n; // head agora aponta para o novo...
n->prox = head; // ...e o novo aponta para ele mesmo. O resto da lista sumiu.
Ao sobrescrever head primeiro, o endereço do antigo primeiro nó se perdeu para sempre. A ordem certa é ligar o novo ao resto antes de mudar o head: n->prox = head; e só depois head = n;. Na dúvida, desenhe as setas antes de escrever o código.
3. Esquecer o delete, ou usar depois dele
Todo nó criado com new precisa de um delete quando sai da estrutura. Sem ele, a memória fica ocupada até o programa terminar (vazamento de memória). E o contrário também é perigoso: depois do delete, o ponteiro continua guardando o endereço antigo, mas a caixinha já não é mais sua. Usar esse ponteiro é comportamento indefinido: às vezes funciona, às vezes não, e isso é o pior tipo de bug.
No* saira = head;
head = head->prox;
delete saira; // libera o nó
// daqui em diante, não use saira->...
Veja os endereços de verdade
Se você quer ver esse desenho se mexendo, a ferramenta gratuita de Estruturas de Dados tem uma visão com endereços de memória: cada ponteiro aparece com o endereço escrito e, no getElemento, você vê o p = p->prox acontecer em dois tempos, primeiro o campo prox sendo lido, depois o endereço sendo copiado para p. Exatamente os dois passos que descrevemos aqui.
E se você ainda está decidindo entre vetor e lista para um problema, escrevi sobre isso em Vetor ou lista encadeada? O que muda de verdade na memória.
Conclusão
Ponteiro deixa de assustar no dia em que você para de enxergar símbolos e passa a enxergar caixinhas com endereços:
&xé ondexmora;- um ponteiro é uma variável que guarda esse endereço;
*pé “vá até lá”;p->campoé “vá até lá e pegue o campo”;p = p->proxé “troque o endereço que eu guardo pelo endereço que está no nó”.
Desenhe sempre. Programar é um ato de pensar, e com ponteiros, pensar é desenhar a memória antes de escrever a linha.