Ponteiros e referências em C++ parte 1

Ponteiros e referências são dois conceitos importantíssimos em ciência da computação. Eles aparecem em muitas linguagens de programação com uma roupagem ou pouco diferente, mas basicamente o tratamento é o mesmo. Em C++, habilidades com ponteiros são fundamentais.

Ponteiro é um tipo especial de dado, cujas variáveis declaradas desse tipo podem apontar (ou não) para algum outro dado na memória. Uma variável do tipo ponteiro, guarda o endereço de um outra coisa na memória. Essa coisa pode ser alguma variável, constante, início de um conjunto de dados, uma função, entre outras.

Declarando Ponteiros

A declaração de um ponteiro é normalmente feita acrescentando-se um * antes do nome da variável. Uma notação muito utilizada cola o * ao nome do tipo para dizer simbolicamente que se está declarando um "ponteiro para o tipo", ao invés de uma "variável ponteiro", mas elas são equivalentes. Por exemplo:

// Ponteiro para um int.
int* ptr_num1;
 
// Ou assim.
int *ptr_num2;
 
// Ponteiro para um double.
double* ptr_double1;
 
// Ou assim.
double *ptr_double2;

Depois de declarado, o ponteiro existe, possui tamanho e consequentemente ocupa lugar na memória. O tamanho de um ponteiro geralmente é igual ao número de bits da máquina/sistema em questão, por exemplo 4 bytes num sistema 32 bits.

char    c, *pc;
int     i, *pi;
double  d, *pd;

// O operador sizeof diz o tamanho do tipo do seu argumento.
cout << "Size of char:    " << sizeof(c) << " bytes" << endl;
cout << "Size of int:     " << sizeof(i) << " bytes" << endl;
cout << "Size of double:  " << sizeof(d) << " bytes" << endl;

cout << endl;

cout << "Size of char*:   " << sizeof(pc) << " bytes" << endl;
cout << "Size of int*:    " << sizeof(pi) << " bytes" << endl;
cout << "Size of double*: " << sizeof(pd) << " bytes" << endl;

Utilizando Ponteiros

Após a sua declaração, o conteúdo de um ponteiro é um valor aleatório, lixo de memória, como qualquer variável. Para que ele seja útil precisamos atribuir algo a ele. Como um ponteiro guarda um endereço de memória, precisamos atribuir a ele um endereço válido.

int x, y;
int *px, *py;

x = 13;
y = 10;

px = &x;
py = &y;

*px = 42;
(*py)++;

cout << *px << endl;
cout << *py << endl;
cout << (*px) * (*py) << endl;

O operador & (ê comercial ou ampersand) antes do nome de uma variável significa "o endereço de", até porque o seu nome é "endereço de".

O que está à esquerda da igualdade tem que poder guardar exatamente o mesmo tipo do que estiver do outro lado da igualdade. Um ponteiro guarda um endereço. Portanto para atribuir um endereço a um ponteiro, neste caso, é preciso obter um endereço com o operador &.

Exceto nos caso em que há conversão implícita de tipos (como entre tipos numéricos), atribuir a uma variável algo com um tipo diferente do seu, causa um erro de compilação. Felizmente!!!

Depois que um ponteiro já apontar para um endereço válido, poderemos acessar o conteúdo desse endereço com o operador * (Asterisco), cujo nome formal é "operador de de-referência".

Note que o asterisco em uma declaração significa "ponteiro para o tipo" e no contexto da leitura ou escrita do conteúdo de um ponteiro, significa "o conteúdo do endereço" ou "o conteúdo apontado por". Também cuidado para não confundir com o símbolo para produto aritmético. Para não confundir, lembre-se daquela tabela enorme de precedência dos operadores, que você só passou o olho e deixou cair no esquecimento. Nela você vai encontrar que o operador de de-referência tem precedência sobre o operador de multiplicação. Feito isso você vai se lembrar que o operador de de-referência é avaliado ANTES do operador de multiplicação.

int a, b, c, *pa, *pb;
a = 2;
b = 3;

pa = &a;
pb = &b;

c = *pa * *pb;
c = *pa * * pb;
c = * pa * * pb;
c = *pa**pb;
c = (*pa) * (*pb);

cout << "Valor da Pegadinha: " << c << endl;

Assim quando aquela prova de algoritmos te perguntar o que faz qualquer uma das últimas 5 linhas do exemplo, você vai dizer que ela multiplica os conteúdos apontados por pa e pb. Não vai mais precisar pegar DP por causa disso! E de quebra na vida real, se você realmente precisar fazer isso, você vai salvar suas 4 gerações passadas e as 5 futuras da excomunhão, se escolher usar a última linha.

Tudo que é possível fazer com uma variável comum é possível fazer com o conteúdo apontado por um ponteiro, seja leitura ou escrita (a menos é claro que o ponteiro seja const, mas isso é outro papo...).

Endereços e Ponteiros

Tomemos como exemplo o código abaixo:

int a, b;
int *pa, *pb;

printf("Antes de qualquer atribuição:\n");

printf("&a  = %p\n", &a); // Endereço válido dado pelo SO.
printf("&b  = %p\n", &b); // Endereço válido dado pelo SO.
printf("&pa = %p\n", &pa);// Endereço válido dado pelo SO.
printf("&pb = %p\n", &pb);// Endereço válido dado pelo SO.

printf("\n");

printf("a   = %d\n", a);  // Conteúdo não inicializado (aleatório).
printf("b   = %d\n", b);  // Conteúdo não inicializado (aleatório).
printf("pa  = %p\n", pa); // Conteúdo não inicializado (aleatório).
printf("pb  = %p\n", pb); // Conteúdo não inicializado (aleatório).

// Tente descomentar as linhas abaixo, recompilar e rodar.
//printf("*pa  = %d\n", *pa); // O que será (*aleatório)?
//printf("*pb  = %d\n", *pb); // O que será (*aleatório)?

printf("\nApós as inicializações dos ponteiros:\n");

pa = &a;
pb = &b;

printf("&a  = %p\n", &a); // Mesmo endereço válido dado pelo SO.
printf("&b  = %p\n", &b); // Mesmo endereço válido dado pelo SO.
printf("&pa = %p\n", &pa);// Mesmo endereço válido dado pelo SO.
printf("&pb = %p\n", &pb);// Mesmo endereço válido dado pelo SO.

printf("\n");

printf("a   = %d\n", a);  // Conteúdo não inicializado (aleatório).
printf("b   = %d\n", b);  // Conteúdo não inicializado (aleatório).
printf("pa  = %p\n", pa); // Conteúdo inicializado (&a).
printf("pb  = %p\n", pb); // Conteúdo inicializado (&b).

printf("*pa = %d\n", *pa);// Conteúdo apontado por pa (*pa == a).
printf("*pb = %d\n", *pb);// Conteúdo apontado por pb (*pb == b).

printf("\nApós as inicializações dos inteiros:\n");

a = 10;
b = 13;

printf("&a  = %p\n", &a); // Mesmo endereço válido dado pelo SO.
printf("&b  = %p\n", &b); // Mesmo endereço válido dado pelo SO.
printf("&pa = %p\n", &pa);// Mesmo endereço válido dado pelo SO.
printf("&pb = %p\n", &pb);// Mesmo endereço válido dado pelo SO.

printf("\n");

printf("a   = %d\n", a);  // Conteúdo inicializado (10).
printf("b   = %d\n", b);  // Conteúdo inicializado (13).
printf("pa  = %p\n", pa); // Conteúdo inicializado (&a).
printf("pb  = %p\n", pb); // Conteúdo inicializado (&b).

printf("*pa = %d\n", *pa);// Conteúdo apontado por pa (*pa == a == 10).
printf("*pb = %d\n", *pb);// Conteúdo apontado por pb (*pb == b == 13).

printf("\nAlterando os valores através dos ponteiros:\n");

*pa = 7;
*pb = 42;

printf("&a  = %p\n", &a); // Mesmo endereço válido dado pelo SO.
printf("&b  = %p\n", &b); // Mesmo endereço válido dado pelo SO.
printf("&pa = %p\n", &pa);// Mesmo endereço válido dado pelo SO.
printf("&pb = %p\n", &pb);// Mesmo endereço válido dado pelo SO.

printf("\n");

printf("a   = %d\n", a);  // Conteúdo inicializado (a == *pa ==  7).
printf("b   = %d\n", b);  // Conteúdo inicializado (b == *pb == 42).
printf("pa  = %p\n", pa); // Conteúdo inicializado (&a).
printf("pb  = %p\n", pb); // Conteúdo inicializado (&b).

printf("*pa = %d\n", *pa);// Conteúdo apontado por pa ( 7).
printf("*pb = %d\n", *pb);// Conteúdo apontado por pb (42).

As linhas 18 e 19 declaram respectivamnete dois inteiros e dois ponteiros para inteiros.

As linhas 23 a 26 mostram os endereços das minhas variáveis. Esses endereços foram dados pelo sistema operacional e são fixos enquanto essas variáveis existirem. Uma variável não muda de endereço durante a execução do programa, mas pode mudar entre uma execução e outra. Quem define qual é esse endereço é o sistema operacional no momento da execução do programa. O compilador só sabe que serão necessários x bytes para cada variável, e qual a interpretação (tipo) que o programa vai dar a esse espaço.

As linhas 30 a 33 mostram os conteúdos dessas variáveis, que ainda não foram inicializadas. É lixo, o último valor que alguém (não sei quem) colocou naqueles lugares da memória do computador e que agora pertencem às minhas variáveis.

Note que não é seguro descomentar as linhas 36 e 37 pois elas tentam acessar os conteúdos apontados por pa e pb, que ainda não foram inicializados. Durante essa tentativa, o programa vai tentar interpretar aqueles valores de lixo aleatório como se fossem endereços de variáveis int válidos. Dependendo do compilador, do SO utilizado e da sua sorte, pode ser algo inofensivo ou não. O certo é que o resultado dessas linhas é imprevisível e fonte certa de problemas.

Nas linhas 41 e 42 os ponteiros são inicializados com os endereços das variáveis inteiras a e b. A partir daí os prints são repetidos.

Veja que os endereços dos ponteiros não mudam, o que já era esperado. O que mudam são os seus conteúdos. As variáveis pa e pb recebem os endereços de a e b respectivamente. Note que o conteúdo dos ponteiros é exatamente o valor do endereço das variáveis a e b que foram dados pelo SO no início do programa. Confira com os prints anteriores. O conteúdo apontado por pa e pb é o mesmo conteúdo de a e b, que ainda não foram inicializados, ou seja, lixo. Porém, diferentemente da etapa anterior, agora os ponteiros apontam para endereços válidos (os endereços de a e b) e ainda que os conteúdos de a e b sejam lixo de memória, os ponteiros agora apontam para variáveis que já foram alocadas.

Nas linhas 61 e 62 as variáveis inteiras são inicializadas com os valores 10 e 13, e os prints repetidos denovo.

Desta vez os coteúdos de a e b não são mais lixo. São valores devidamente conhecidos. Note que não há nenhuma alteração no endereçamento das variáveis em relação às etapas anteriores, apenas nos valores.

Veja também que depois que a e b foram inicializados, o conteúdo apontado por pa e pb também foram alterados automaticamente. Isto acontece justamente porque pa e pb apontam para as mesmas áreas de memória utilizadas pelas variáveis pa e pb. Isso significa que o conteúdo apontado por um ponteiro pode ser modificado alterando-se o conteúdo das variáveis originais apontadas por ele. E finalmente as linhas 81 a 97 mostram que o inverso também vale, ou seja, alterando-se o conteúdo apontado por um ponteiro, altera-se automaticamente o conteúdo das variáveis originais apontadas por eles.

É mais fácil entender isso pensando nas frases em português do que olhando para o código.

Por enquanto...

Este foi o básico da operação com ponteiros. Ainda existem coisas interessantes para serem vistas, como ponteiros para funções, por exemplo, mas isso já é considerado um tópico mais avançado. Na sequência desta série, que está planejada para 4 posts, veremos referências, diferenças e semelhanças entre ponteiros e referências e por último ponteiros para funções.

Links