Pular para o conteúdo
blog

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.

Prof. José Rui

5 min de leitura

Neste artigo
  1. Toda variável mora num endereço
  2. Ponteiro é só uma variável que guarda um endereço
  3. O asterisco tem dois papéis
  4. Ponteiro para struct e a famosa seta
  5. Agora sim: p = p->prox
  6. Os três erros clássicos
  7. 1. Acessar o campo de um ponteiro nulo
  8. 2. Perder o nó por trocar a ordem das ligações
  9. 3. Esquecer o delete, ou usar depois dele
  10. Veja os endereços de verdade
  11. 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ávelEndereçoConteúdo
x0x7ffe1442
p0x7ffe200x7ffe14

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çoitemprox
primeiro0xa0100xc8
segundo0xc8200xb4
terceiro0xb430NULL

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:

  1. Lado direito primeiro. p->prox significa: vá até o endereço que está em p (0xa0) e leia o campo prox. Lá dentro está o número 0xc8.
  2. Atribuição. Esse número é copiado para dentro de p. Agora p guarda 0xc8.

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 é onde x mora;
  • 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.

# continue lendo

Mais artigos para você