Uma introdução ao uso de ponteiros na linguagem C
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
.
“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 denum
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áveltempo
!) - Finalmente, na linha 15, imprimimos o valor salvo no endereço apontado por
ptr
(que é o valor da variáveltempo
!)
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.