Uma introdução ao uso de ponteiros na linguagem C

Alejandro Druetta
PermaLink Univesp

--

Para quem deu seus primeiros passos em programação com uma linguagem como Python, PHP ou Java, o contato com ponteiros em C/C++ pode resultar traumático. Acontece que essas linguagens fazem com que a gente não precise se preocupar muito com assuntos mais próximos do hardware como: endereços de memoria, alocação e liberação dinâmica de memória, stack, heap, e por aí vai.

Esse artigo é uma continuação desse outro aqui:

O escopo dele continua o mesmo, uma introdução ao conceito e uso de ponteiros. Ele não pretende ser, nem poderia ser, exaustivo.

Ponteiro é um tipo especial de variável

Geralmente a gente começa aprendendo que ponteiro é um tipo especial de variável em que o valor atribuído a ela é um endereço de memória.

Um endereço de memória é um número, normalmente bem grande e representado em formato hexadecimal, que identifica um byte (uma sequência de 8 bits) na memória principal do computador. Por exemplo: 0x7ffee3733b3c, que em base 10 representa 140732714400572, é um endereço de memória no meu computador.

Se seu computador tiver 8 GB de memória RAM, isso vai dar pouco mais de 8 bilhões de bytes, e cada um deles precisa ter um endereço, por um motivo muito simples, se não tivesse seria impossível identificar e acessar o conteúdo da memória.

Mas, por que alguém estaria interessado em salvar endereços de memória dentro de variáveis “especiais”?

Nesse artigo vamos tentar responder essa pergunta, começando com um exemplo simples:

int num = 7;

O que temos aí é uma variável num do tipo inteiro, que vai conter o valor 7. Este valor está em algum lugar da memória, e pelo fato da variável ser do tipo inteiro, muito provavelmente vai ocupar 4 bytes ou 32 bits (isso vai depender do hardware, claro). Esse “algum lugar na memória” é identificado por um endereço, o endereço do primeiro daqueles 4 bytes.

Se a gente quisesse imprimir esse endereço, teríamos que fazer algo do tipo:

Perceba que utilizei o modificador %p para formatar a saída, e o símbolo & precedendo o nome da variável. Isso porque o que eu quero imprimir é o endereço da variável e não o valor 7 que foi salvo nela.

Então, vamos agora salvar esse endereço:

int *ptr = #

Agora, temos uma outra variável, ptr. Está na cara que ela é um ponteiro, não é? É sim, é um ponteiro e aponta para o endereço da variável num, que como já sabemos, é do tipo inteiro e tem sido atribuído a ela o valor 7.

O ponteiro aponta para um endereço de memória.

“Apontar” quer dizer que ela conhece o endereço de uma outra variável. Como é que “conhece” esse endereço. Bom, porque esse endereço é o valor salvo na variável ponteiro.

Vamos analisar essa variável ponteiro com mais calma. Como toda variável em C:

  • Ela tem nome: ptr;
  • Tem tipo: é um ponteiro apontando para um inteiro int*;
  • Tem valor: 0x7ffee3733b3c (o endereço de num ou &num);
  • E até um modificador específico para formatar sua saída: %p.

O asterisco antes do nome *ptr está aí para declarar que essa não é uma variável comum do tipo int, ela é um ponteiro que aponta para um endereço na memória onde está armazenado um inteiro. Em português: o valor da variável ponteiro é o endereço de uma outra variável, a qual tem tamanho o suficiente para salvar na memória um valor inteiro.

um ponteiro que aponta para um endereço na memória onde está armazenado um inteiro.

O & antes de num está aí para dizer que não é o valor 7 de num que vai ser salvo em ptr, e sim o endereço de memória de num, ou seja: 0x7ffee3733b3c.

Veja o resultado quando tentamos imprimir num, &num, ptr e &ptr:

O ponteiro ptr é uma variável, e como tal, ela também possui um endereço em memória. Se ele não tivesse, seria inútil do ponto de vista da execução do nosso programa. E veja também que o valor de ptr e o endereço de num são iguais, o que significa que o primeiro “aponta” para o segundo.

Mas, qual a utilidade disso tudo?

O que faltou dizer é que eu posso fazer o seguinte:

Aí que está o segredo dos ponteiros. A gente conseguiu acessar o valor de num de forma indireta, através do ponteiro ptr. Essa operação é chamada dereferenciação ou indireção, e logo vamos ver exemplos que mostram como isso pode ser útil.

Antes disso, algumas questões para levar em consideração: o asterisco * antes do nome da variável ponteiro *ptr é quem permite recuperar o valor salvo no endereço apontado pelo ponteiro, ou seja, o valor de num.

Isso, lamentavelmente é bastante confuso, porque como já vimos antes, o asterisco também é usado para declarar que ptr é um ponteiro no momento de declará-lo. Ou seja, o mesmo símbolo tem duas funções muito diferentes segundo o contexto em que é usado:

O exemplo das listas

Quando construímos uma lista estática, como a que estamos estudando em Estrutura de Dados, temos que poder modificar essa lista para adicionar, ordenar ou deletar elementos. Os items da lista podem ser registros, na forma de estruturas, organizados sequencialmente como elementos de um array:

E as mudanças na lista podem ser feita por meio de funções que recebam como parâmetros a própria lista e, o registro ou a chave, segundo seja o caso:

Mas, e por que não passar a lista diretamente como parâmetro para a função? Por que passar ela indiretamente através de ponteiros?

Não poderia ter sido feito dessa outra forma?:

bool adicionar (Lista lst, Registro reg) { ... }

Sim, mas teríamos dois problemas: perda de eficiência pelo uso desnecessário de recursos de processamento e por duplicação de objetos na memória. Isso por um lado. Por outro lado, também temos o problema do ciclo de vida das variáveis. Vamos tentar explicar.

Valor ou referência

Veja a situação representada no exemplo acima. Quando passamos a lista a como argumento para a função foo(), essa lista (e por tanto o array dentro dela) será copiada no parâmetro p da função. Por tanto, na memória irão coexistir, duplicadas, as listas a e p. Se você pensar agora que a nossa lista pode ter 10 mil registros, esses 10 mil registros não apenas vão ocupar o dobro do espaço necessário, senão que além disso precisam ser copiados da lista passada como argumento para o parâmetro que recebe ela dentro da função. Sempre que passemos argumentos como valor, irá acontecer isso, representando um custo em recursos e afetando o rendimento e a eficiência do nosso código.

A alternativa para isso é passar a lista como referência, na forma de um ponteiro. Como o ponteiro permite acessar um endereço de memória de forma indireta, quando modificarmos *p, indiretamente estaremos modificando a. Não precisa haver duplicação nem copia:

Escopo da função

O segundo problema surge devido a que o ciclo de vida de uma variável (não global) começa e termina dentro do escopo de uma função. Por tanto, a partir do momento em que a variável a for copiada de forma local em p, todas as alterações que fizermos em p só serão feitas, também, de forma local. E quando a execução do programa retornar para a função main() (ou qualquer outro escopo em que foo() for invocada), todas essas mudanças vão se perder e a lista original a vai continuar idêntica ao que era antes de ser chamada a função foo().

Recapitulando

Esse seria um exemplo clássico onde os ponteiros podem otimizar recursos computacionais disponíveis, fazendo o nosso código mais eficiente. Não é o único, claro, mas pelo menos dá uma ideia de por que alguém gostaria de se complicar a vida com uma sintaxe rebuscada como a dos ponteiros para armazenar endereços de memória dentro de outros endereços de memoria, como aquelas bonequinhas russas (mamuschkas) que encaixam umas dentro das outras.

Se você conseguiu entender o porquê do uso dos ponteiros na linguagem C, o que precisa agora é se familiarizar com a sintaxe, por isso vamos relembrar ela:

Linha por linha:

  • Na linha 1 declaramos uma variável “normal” tempo do tipo double e atribuímos um valor para ela.
  • Na linha 4 declaramos uma variável ponteiro para double e atribuímos o endereço da variável tempo para ela.
  • Na linha 7 imprimimos o valor da variável ptr. O valor de um ponteiro é sempre o endereço de memória ao qual ele está apontando.
  • Na linha 11 imprimimos o endereço em memória da variável ptr (não da variável tempo!)
  • Finalmente, na linha 15, imprimimos o valor salvo no endereço apontado por ptr (que é o valor da variável tempo!)

Deu para pegar o básico? Sim, o básico, porque há muita coisa ainda por aprender para dominar o quesito ponteiros. Mas por algum lugar a gente precisa começar. Espero ter ajudado.

--

--