terça-feira, 19 de novembro de 2013

Matemática de Ponteiros

Hoje vou tratar de um tema que aflinge a maioria dos programadores iniciantes em C e C++: os malvados ponteiros. O objetivo é fazer uma revisão do assunto tratando algumas propriedades que podem passar despercebidas do iniciado, mas que fazem toda a diferença na hora de compreender aquele "bug maluco" que aparece de vez enquando. Entender a fundo como funcionam os ponteiros pode ser a diferença entre passar dias ou meses debugando um código para nunca achar aquele erro, ou escrever um código menos suceptível a erros logo de cara.

Bom, para começar, o que é um ponteiro afinal? Um ponteiro nada mais é do que uma variável que contém um endereço de memória. O que muda de ponteiro para ponteiro é o significado deste endereço. 

O ponteiro é representado pelo símbolo asterísco (*). Também vamos utilizar bastante o operador de endereço (&), que retorna o endereço de memória de uma variável.

Vamos começar com alguns exemplos básicos para ilustrar:

#include <iostream>

using namespace std;

int main()
{
  int  a = 0; // Declaração de uma variável tipo int
  int  *p;    // Declaraçào de um ponteiro para uma variável inteira
  p = &a;     // Atribui a p o endereço de a

  cout << " a = " <<  a << "\t\t *p = " << *p << endl;
  cout << "&a = " << &a << "\t  p = "   <<  p << endl;
  cout << endl;

  return 0;
}

A saída esperada deste programa é a seguinte (os endereços vão variar de máquina para máquina):

 a = 0           *p = 0
&a = 0x28fef4     p = 0x28fef4

O objetivo deste exemplo é mostrar a operação normal com ponteiros. Na primeira parte, declaramos uma variável inteira "a" inicializada com o valor zero e um ponteiro "p" para um inteiro. Na seqüência, fazemos a atribuição do endereço de "a" ao ponteiro "p". Em seguida, utilizamos a saída padrão para mostrar que "p" e "a" são duas visões do mesmo dado, dadas as seguintes ressalvas:

1) para enxergarmos o valor armazenado em "a", basta referenciar a variável pelo nome.
2) para enxergarmos o endereço de "a", precisamos utilizar o operador de endereço &.
3) para enxergarmos o valor que "p" aponta, precisamos desreferenciar o ponteiro, com o operador *.
4) para enxergarmos o endereço contido em "p", basta referenciar "p" pelo nome.

Note que, na observação 4, falamos sobre o "endereço contido em p", e não "o endereço de p". Eu fiz essa diferenciação de propósito, pois "p" também é uma variável que está armazenada no seu próprio endereço. Vamos ver o exemplo modificado:

int main()
{
  int  a = 0; // Declaração de uma variável tipo int
  int  *p;    // Declaraçào de um ponteiro para uma variável inteira
  p = &a;     // Atribui a p o endereço de a

  cout << " a = " <<  a << "\t\t *p = " << *p << endl;
  cout << "&a = " << &a << "\t  p = "   <<  p << endl;
  cout << endl;

  cout << "&p = " << &p << endl;

  return 0;
}

Observe a saída:

 a = 0           *p = 0
&a = 0x28fefc     p = 0x28fefc

&p = 0x28fef8

Basicamente, o valor de "p" é o endereço de "a" (0x28fefc), e o endereço de "p" (0x28fef8) é o local da memória onde está guardado este valor.

Além disso, quando declaramos "p" não atribuímos nenhum valor a ele. Assim como qualquer outra variável declarada sem valor, ele irá conter neste momento um "lixo" qualquer. Considerando que este lixo pode ser um endereço válido, usar este ponteiro sem atribuir-lhe o valor correto pode ter conseqüências catastróficas como os lendários bugs não-determinísticos. Por este motivo, é uma boa prática ao declarar ponteiros sem valor atribuir-lhe o valor zero ou NULL (NULL = 0), pois os sistemas operacionais modernos são capazes de lançar exceções (null pointer exception) quando tentamos acessar o endereço zero, mas eles não necessariamente conseguem diferenciar um endereço "lixo" válido de um endereço "bom" e, acredite, é muito melhor varrer o código em busca de um null pointer exception do que de um erro aleatório.

int *p = NULL; // Sempre!

Uma última observação antes de seguir no tema, ainda dentro da parte básica, é com relação a declaração dos ponteiros. Você observou que o operador * tem duplo significado: se utilizado na declaração da variável, ele nos diz que a variável é um ponteiro, e; se utilizado no escopo do programa, em uma variável de ponteiro, ele nos diz que queremos desreferenciar o ponteiro, ou seja, utilizar o valor para o qual ele aponta, ao invés do seu próprio valor (que é um endereço).

Agora, pensando nisso, como fazemos para declarar vários ponteiros em uma única linha? Num primeiro momento, pensando em variáveis normais, declaramos assim:

int a, b, c;

Então, por conseqüência, declarar ponteiros deve ser igual, certo?

int* p, q, r;

Errado! Na verdade, o operador * faz parte do nome da variável, e não do tipo. Embora seja correto escrever tanto int* p como int *p (mudou apenas a posição do espaço), o * é considerado parte do nome e, portanto, a declaração acima irá produzir um ponteiro para um inteiro "p" e duas variáveis inteiras "q" e "r". A declaração correta é:

int *p, *q, *r;

Uma forma de demonstrar isto seria com o seguinte código:

int main() {
  char* c, d, e;

  cout << "sizeof: c = " << sizeof(c) << " d = " << sizeof(d) << " e = " << sizeof(e) << endl;

  return 0;
}

Saída:

sizeof: c = 4 d = 1 e = 1

Escolhi um tipo char desta vez por que com tipos inteiros não veríamos diferenças no sizeof. Uma maneira de corrigir este problema, além de declarar todos os ponteiros com o devido operador *, seria utilizar um typedef:

typedef char* CharPtr;

int main() {
  CharPtr c, d, e;

  cout << "sizeof: c = " << sizeof(c) << " d = " << sizeof(d) << " e = " << sizeof(e) << endl;

  return 0;
}

Saída:

sizeof: c = 4 d = 4 e = 4

Logo, o typedef garante que todas as variáveis sejam do mesmo tipo.

Equivalência com Arrays

Antes de entrar mais a fundo nas operações com ponteiros, existe uma última questão de sintaxe que vale a pena ser mencionada: a equivalência com arrays. Um array em C/C++ é declarado da seguinte forma:

int a[N];

Onde "a" é o nome do array e "N" é o seu tamanho.

Uma das propriedades do array é que, se utilizarmos apenas o seu nome, sem um índice referenciado, estamos tratando do endereço do primeiro elemento do array. Veja:

int main()
{
  int  a[10] = {0,1,2,3,4,5,6,7,8,9}; // Declaração de uma variável tipo array de int com tamanho 10

  cout << " a[0] = " << a[0] << "\t\t &a[0] = " << &a[0] << endl;
  cout << " a    = " << a    << "\t &a    = " << &a    << endl;
  cout << endl;

  return 0;
}

Saída:

 a[0] = 0                &a[0] = 0x28fed8
 a    = 0x28fed8         &a    = 0x28fed8


Na primeira linha pedimos para imprimir o valor do primeiro elemento (a[0]) e o endereço do primeiro elemento (&a[0]). Na linha seguinte, utilizamos duas notações equivalentes: tanto "a" como "&a" trazem o endereço do primeiro elemento.

Operações com Ponteiros

Espero que esteja tudo tranquilo até agora, pois agora vamos entrar na parte mais complexa. Lembra que o valor de um ponteiro é um endereço, certo? Então observe o seguinte:

int main() {
  int       *a = 0;
  char      *b = 0;
  long long *c = 0;
  float     *d = 0;
  double    *e = 0;

  cout << "sizeof:\n a = " << sizeof(a) << " *a = " << sizeof(*a) << endl
              << " b = " << sizeof(b) << " *b = " << sizeof(*b) << endl
              << " c = " << sizeof(c) << " *c = " << sizeof(*c) << endl
              << " d = " << sizeof(d) << " *d = " << sizeof(*d) << endl
              << " e = " << sizeof(e) << " *e = " << sizeof(*e) << endl;

  return 0;
}

Saída:

sizeof:
 a = 4 *a = 4
 b = 4 *b = 1
 c = 4 *c = 8
 d = 4 *d = 4
 e = 4 *e = 8

Ou seja, embora os tipos de dados que estes ponteiros apontem são totalmente distintos, os tamanhos dos ponteiros são exatamente os mesmos: 4 bytes. Isto se deve ao fato de que eu estou compilando este programa em modo 32 bits (4 bytes * 8 bits = 32 bits) e, portanto, todos os endereços estão dentro deste espaço. Note que se estivéssemos compilando em 64 bits, cada ponteiro teria 8 bytes.

Além disso, tanto em 32 bits como 64 bits nós conseguimos endereçar toda a memória como um bloco contínuo, mas se voltarmos um pouco no tempo e pensarmos em programas de 16 bits, isso não é totalmente verdade. Os processadores x86 em modo 16 bits utilizam uma forma mais complexa de endereçamento, composta por segmentos e offsets que compõem um endereço efetivo de 24 bits. Não vou entrar no detalhe, mas fica aqui como uma curiosidade para quem quiser pesquisar um pouco mais.

Para começar a trabalhar com operações em ponteiros, vamos trabalhar com a associação com arrays, pois desta forma estaremos trabalhando dentro de um espaço válido de endereçamento. Estas operações são possíveis em qualquer ponteiro, mas lembre-se sempre que a responsabilidade destas operações é totalmente do programador, o compilador não tem como checar se os limites estão sendo respeitados ou não, e bugs bizarros podem acontecer. Usando o array sabemos que temos o controle dos endereços de memória dentro daquela faixa.

A primeira operação é a adição:

int main() {
  int a[10] = {0,1,2,3,4,5,6,7,8,9};

  int *p = a;

  cout << "p = " << p << " *p = " << *p << endl;

  p = p + 1;

  cout << "p = " << p << " *p = " << *p << endl;

  return 0;
}

Saída:

p = 0x28fed4 *p = 0
p = 0x28fed8 *p = 1

Note que somar 1 ao ponteiro não somou 1 ao endereço do ponteiro, mas sim 4. Por quê? Porque o compilador sabe que aquele ponteiro aponta para um inteiro que ocupa 4 bytes, e ao somar 1 você está pedindo para o compilador que quer o próximo elemento alinhado com aquele ponteiro.

Por exemplo, se fizessemos o mesmo com um tipo char:

int main() {
  char a[10] = {'0','1','2','3','4','5','6','7','8','9'};

  char *p = a;

  cout << "p = " << (void*) p << " *p = " << *p << endl;

  p = p + 1;

  cout << "p = " << (void*) p << " *p = " << *p << endl;

  return 0;
}

Saída:

p = 0x28fef2 *p = 0
p = 0x28fef3 *p = 1

A diferença entre os endereços é apenas 1 byte, ou o tamanho do char.

Que outras operações são válidas em ponteiros? Basicamente todas operações relacionadas a adição e subtração:

int main() {
  int a[10] = {0,1,2,3,4,5,6,7,8,9};

  int *p = a;

  cout << "p = " << p << " *p = " << *p << endl;

  p++;
  ++p;

  cout << "p = " << p << " *p = " << *p << endl;

  p--;
  p -= 1;
  p += 3;

  cout << "p = " << p << " *p = " << *p << endl;

  return 0;
}

Saída:

p = 0x28fed4 *p = 0
p = 0x28fedc *p = 2
p = 0x28fee0 *p = 3

Sempre observando que o tamanho da variável apontada pelo ponteiro tem efeito direto nos cálculos dos endereços.

Note que, o endereço de um elemento "N" no array será sempre:

e = e0 + N * sizeof(T)

Onde "e0" é o endereço inicial do array e "T" é o tipo de dado do array.

Seguindo esta lógica, podemos ver que o operador [] nada mais é do que um operador de endereçamento, que abstrai estes cálculos para o programador.

Para testar assertiva, vamos fazer o seguinte (cuidado!):

int main() {
  int *p = 0;

  cout << "&p[1] = " << &p[1] << endl;
  cout << "&p[2] = " << &p[2] << endl;
  cout << "&p[3] = " << &p[3] << endl;
  cout << "&p[4] = " << &p[4] << endl;

  return 0;
}

Saída:

&p[1] = 0x4
&p[2] = 0x8
&p[3] = 0xc
&p[4] = 0x10

Note que mesmo declarando a variável "p" como um ponteiro (e não um array), podemos utilizar normalmente o operador []. O importante aqui é ressaltar que embora o programa tenha calculado os endereços corretamente, qualquer tentativa de acessar este espaço de memória pode ser desastrosa (por isso estou apenas imprimindo os endereços, não faço nenhum acesso a valor), logo, todo cuidado é pouco!

Conclusões

O meu objetivo com este artigo era fazer um panorama geral de operações com ponteiros. Eu acredito que uma vez que fique claro que trabalhando com ponteiros estamos trabalhando com endereços de memória grande parte da "mística" que envolve os ponteiros se desfaz e eles deixam de ser instrumentos obscuros para se tornarem ferramentas poderosas para o programador.

Como nota mental, fico devendo alguns tópicos interessantes para uma próxima edição deste artigo: chamadas de funções com ponteiros, arrays de arrays e ponteiros de funções.

Nenhum comentário:

Postar um comentário