AVISO

AVISO: ESTE É MEU ANTIGO BLOG, QUE NÃO É MAIS ESCRITO DESDE 2011. O CONTEÚDO AQUI EXPRESSO PODE NÃO REPRESENTAR MEUS PENSAMENTOS E OPINIÕES DE IDADE ADULTA. PARA CONTEÚDOS NOVOS E RELEVANTES ACESSE BLOG.BRUNO.TODAY




domingo, 19 de outubro de 2008

Desmistificando os Ponteiros em C/C++

Tags: ponteiros, processadores, C, C++

Programadores C/C++ iniciantes, professores de faculdade, alunos de faculdade, programadores de outras linguagens... Muitas pessoas destas "categorias" tem uma coisa em comum: a "síndrome de pânico de ponteiros".

Os programadores iniciantes pela pouca familiaridade com a arquitetura de computadores em geral; os alunos de faculdade da área técnológica porque geralmente começam a dar os primeiros passos na programação antes de conhecer razoavelmente a arquitetura de um processador; os professores, por sua vez, morrem de medo de ter que explicar algo complexo para quem talvez não esteja ainda preparado para compreender o assunto; os programadores de outras linguagens por acharem que estão livres deles.

Escrevi este artigo para explicar como utilizar ponteiros, quais são seus possíveis problemas, e em que situações seu uso pode ser interessante.



O destaque é dos programas feitos em C/C++ por serem as linguagens de alto nível que mais utilizam estes recursos(por isso alguns autores as chamam de linguagens de "médio nível", mas não vamos entrar nesta discussão).

Como meu amigo David, que me sugeriu este post, havia me pedido para mostrar as utilidades dos ponteiros, então vou começar com uma lista de situações(a nível de programação em C/C++) aonde os ponteiros são FUNDAMENTAIS:
  • O trivial: na alocação dinâmica
  • Em seqüencias de dados alocados
  • Quando precisamos passar ou retornar a referência a um objeto de memória(em C, pois o C++ possui a passagem de referência)
Agora vamos falar sobre outras situações aonde temos os tais dos "ponteiros". Além dos relógios, medidores de combustível, velocímetros, e outros aparelhos analógicos, alguns aparelhos - que por sinal não possuem nada de analógico - também tem ponteiros. É o caso dos computadores: diversos registradores de um processador são apenas ponteiros. Por exemplo, BASE POINTER, STACK POINTER, INSTRUCTION POINTER, etc. A computação é baseada em ponteiros.

Precisamos conceituar um ponteiro. Tanto no relógio da cozinha, quanto no marcador de gasolina, e até nos ponteiros do processador, um ponteiro é algo que APONTA para alguma coisa. Podemos fazer uma analogia do relógio a um "ponteiro para endereços de tempo". É isso mesmo, quando definimos a forma como o nosso tempo "anda" organizando-a em Horas, Minutos e Segundos, criamos um sistema para endereçamento das fatias de tempo.

Da mesma forma funcionam os ponteiros dos processadores: a única diferença é que estes apontam para endereços de memória. Mas como assim "endereço de memória"?? Para que isso serve?? Bom, explicando para o pessoal de nível técnico menor, a memória do computador é muito grande. Para organizar tudo isso, ela é dividida em "blocos". Imagine a memória como uma parede de tijolos a vista. Cada tijolo é um bloco, ou segmento de memória. Cada um destes tijolos, é dividido em muitos bytes, além de ter o seu "número de identificação".

Agora, imaginem que criamos em nosso programa uma variável chamada 'a', do tipo inteiro(em uma arquitetura de 32bits normal e com SO que a utiliza devidamente, 4bytes). Como o nosso programa acha o local da memória aonde o valor de 'a' está armazenado?? Quando nosso programa inicia, o SO reserva segmentos de memória para ele. Apartir daí, o programa possui um endereço de seu "tijolo inicial", armazenado em um registrador. Mas ainda não é suficiente para acharmos 'a'. Precisamos saber em qual byte de um destes tijolos 'a' está.

Em linguagens de baixo nível, aonde trabalhamos com paginação de memória(especialmente em ambientes DOS, por exemplo, aonde temos acesso a manipular a memória diretamente), normalmente trabalhamos com 2 ponteiros: o ponteiro que indica o segmento de memória que estamos trabalhando, e o ponteiro que indica o offset dentro deste segmento(o número do byte procurado dentro daquele segmento).

Mas como esse post foca em C/C++, mais especificamente para sistemas operacionais completos, vamos deixar os detalhes de baixo nível "de lado", pois o compilador e o SO são nossos amigos e trata de fazer este trabalho para nós. Mas aí como fica?? Bom, imagine que todos esses blocos estão "mapeados"(entre aspas mas é isso mesmo, quem quiser entender melhor leia sobre a system call mmap dos sistemas POSIX). Para nosso programa tudo é um bloco único de memória, só precisamos indicar o byte aonde o objeto de memória se encontra.

Agora vamos para a parte prática. Quando trabalhamos com strings em C, qual é a melhor maneira para armazená-las?? Simples: PONTEIROS. "Mas como assim?? Eu armazendo elas em arrays(vetores) de caracteres!!"

Quando temos um array(vide exemplo abaixo), não temos nada mais que um ponteiro. É isso mesmo, apesar de muita gente imaginar que são duas coisas bem distintas. Quando declaramos um array, uma área de memória é definida na imagem do executável do programa para aquela variável(carregada na STACK, um segmento estático de memória, quando o programa inicia). O símbolo(variável) declarado como array é apenas um ponteiro para esta região de memória. Por outro lado, quando declaramos um ponteiro simples, nenhuma região de memória é pré-atribuida a ele.

Em C/C++ os ponteiros são tipados, e quando os acessamos, ele fará a leitura do número de bytes do tipo de dado ao qual ele pertence. Existem duas maneiras de acessar uma área de memória guardada por um ponteiro. A primeira é a derreferência normal, que ocorre quando utilizamos um '*' antes da variável do ponteiro. A segunda é a derreferência por índice, aonde colocamos um offset de deslocamento entre colchetes após o nome da variável.

int *ponteiro = funcao_que_retorna_um_ponteiro_de_int();
printf("%d\n", *ponteiro); //derreferência simples
printf("%d\n", ponteiro[0]); //derreferência por índice

Graças a tipagem dos ponteiros que a linguagem nos trás, podemos trabalhar com aritmética de ponteiros. Apesar de limitada, permitindo apenas adições e subtrações, ela nos ajuda em muitas situações. Seu uso normalmente é através da incrementação ou da decrementação de ponteiros. Vejamos o exemplo de derreferência por indexação abaixo:

int *ponteiro = funcao_que_retorna_um_ponteiro_para_um_array_de_int();
for (int i = 0; ponteiro[i] != 10; i++) {
/*Percorre um array até encontrar um número 10 nele*/
...
}


O código acima percorre um array de inteiros até aonde tiver um número '10'. Agora veja como isto poderia ser feito utilizando a aritmética de ponteiros:


int *ponteiro = funcao_que_retorna_um_ponteiro_para_um_array_de_int();
while(*ponteiro++ != 10) {
...
}


Quando somamos 1 a um ponteiro, ele soma, na verdade, o número de bytes do tipo de dado do ponteiro. Por exemplo, em um ponteiro de int, ao somarmos com "ponteiro++", estamos somando sizeof(int) ao ponteiro. O mesmo vale para a subtração. Por este motivo a tipagem de ponteiros é extremamente importante. Com a fraca tipagem do C temos que ter este tipo de cuidado sempre!!

Muitos iniciantes, porém, de certa forma se 'iludem' ao fazer isso. É preciso ter um certo cuidado ao escolher entre as duas opções. Neste caso, por exemplo, temos um convite para um memory leak("vazamento" de memória, quando temos uma área de memória dinamicamente alocada e não possuimos uma referência a ela para efetuar a sua liberação) caso o array retornado pela função seja dinamicamente alocado. Quando a área retornada pelo array for estáticamente alocada(e isso podemos nem ter como saber), ainda temos um possível problema: o o conteúdo retornado poder ser local da função chamada. Por isso, nunca retorne um array local em uma função.

Observe agora o exemplo abaixo:

int ponteiro[10]; //array alocado estaticamente
... /* algum código preenchendo valores no array */
while(*ponteiro++ != 10) {
... /*a nossa "busca" do exemplo anterior*/
}


Neste exemplo, corremos o risco de perder a referência à área estaticamente alocada. Isto pode causar uma série de confusões e dores de cabeça. Imagine por exemplo, que algo depois deste loop utilize o acesso por índice ao ponteiro. As chances de não sabermos se o offset acessado está dentro do array original é muito alta, a menos que façamos um controle rígido de quantas posições o ponteiro foi incrementado. Porém, neste tipo de situação o esforço para usar a aritmética de ponteiros de forma segura é grande demais, não compensando o trabalho e fazendo ser mais válido utilizar o acesso por índice em um ponteiro fixo.

Normalmente, o uso de ponteiros com derreferência simples combinado à aritmética de ponteiros se dá mais em funções, ou quando o ponteiro é uma "cópia", ou seja, um ponteiro igualado a um já existente. Veja o programa de exemplo abaixo:




#include <stdio.h>

int tamanho_string(const char *string);

int main() {
 int ponteiro1[10]; /* ponteiro p/ area estatica,
         aritmetica nao recomendada */

 int *ponteiro2; /* ponteiro nao inicializado, recomendavel
      para aritmetica */
 ponteiro2 = ponteiro1;
 printf("%d %d\n", *ponteiro2++, *ponteiro2);
 /* Exibe os 2 primeiros itens do array, usando a aritmetica
    de ponteiros */
 printf("%d %d\n", ponteiro1[0], *(ponteiro1+1));
 /* Mesma coisa, porem usa a aritmetica de ponteiros de uma
    maneira que eh segura mesmo com o ponteiro1 */

 ponteiro1++; /* Acabamos de perder a referencia para
                 o primeiro item do array!!! */
 ponteiro1[0] = tamanho_string("Ola Mundo");
 /* Estamos definindo este valor para o segundo item do array,
    pois o ponteiro aponta para o segundo apos seu incremento!! */
 printf("%p: %d\n", ponteiro1, *ponteiro1);
 return *ponteiro1;
}


int tamanho_string(const char *string) {
 /* Exemplo de uso de aritmetica de ponteiros em funcoes */
 /* Eh importante manter a referencia inicial, pois podemos
  * precisar dela ao longo da funcao. Porem, perder a referencia 
  * somente dentro do escopo local de uma funcao que nao eh a
  * criadora da area de memoria nao eh tao prejudicial.*/
 char *s = string;
 while (*s++);
 return (s - string) - 1;
}


No exemplo acima temos um mini-programa de teste, mostrando algumas situações comentadas anteriormente. Recomendo ao leitor interessado compilar o programa, rodar, e logo após editar o código inicializando as variáveis, adicionando alguns "printf"s, e fazendo experiências em geral. Isso vai facilitar muito a compreensão do assunto!!

Observe que a função para contar caracteres de uma string utiliza um elemento não comentado da aritmética de ponteiros, que é a subtração. A lógica é simples: incrementa o ponteiro enquanto o caractere apontado por ele não for nulo. Logo após, a subtração indicará o deslocamentoÉ assim que a função strlen da libc trabalha.

Agora vamos à alocação dinâmica, que é o uso mais comum dos ponteiros. Não vou falar muito em detalhes sobre ela, apenas explicarei sua utilidade e exemplificarei. Quem quiser saber mais sobre o assunto, veja os links que achei no Google e considerei satisfatórios: Alocação Dinâmica em C e Alocação Dinâmica em C++.

A utilidade da alocação dinâmica é simplesmente alocar quantidades de memória variáveis. Estas quantidades podem variar de acordo com o fluxo do programa. Um exemplo prático seria, por exemplo, se desenvolvermos uma agenda que grave os dados em um arquivo de texto e carregue-os na memória, além de permitir novos cadastros pelo usuário. Saiba que neste caso não sabemos quantos registros possuimos na agenda, e nem quantos o usuário vai inserir. Você utilizaria uma agenda com limite de cadastros, que se você cadastrasse além do limite causaria um estouro de memória?? Certamente NÃO!! Para seu usuário não sofrer com este tipo de limitação, é necessário dominar a alocação dinâmica de memória.

Vejamos um exemplo de alocação dinâmica em C e um em C++ respectivamente abaixo:




#include <stdio.h>

#include <stdlib.h>
#include <string.h>

int main() {
 int tamanho, cur_chr;
 char *str_ptr = NULL, *p = NULL;
 
 scanf("%d", &tamanho);
 str_ptr = malloc(tamanho+1); // Aloca a memoria...
 if (!str_ptr) {
  printf("Erro de alocacao\n");
  exit(1);
 }

 memset(str_ptr, 0, tamanho+1);

 p = str_ptr;

 while(tamanho--) {
  cur_chr = getchar();
  if (cur_chr != EOF) {
   *p++ = (char) cur_chr;
  }
  else {
   printf("ALERTA: String nao ocupou todo espaco\n");
   break;
  }
 }
 printf("Fim da leitura da string!\n");
 printf("String lida: %s\n", str_ptr);
        free(str_ptr);
        return 0;
}





#include <iostream>

using namespace std;

int main() {
 int tamanho;
 char *str_ptr = NULL, *ptr = NULL;

 cin >> tamanho;
 try {
  str_ptr = new char[tamanho + 1];
 }
 catch(bad_alloc &e) {
  cout << "Erro alocando memoria" << endl;
 }

 memset(str_ptr, 0, tamanho + 1);
 ptr = str_ptr;

 while(tamanho-- && cin >> *ptr++);

 if (tamanho) {
  cout << "ALERTA: String nao ocupou todo espaco" << endl;
  }
 cout << "Fim da leitura da string!" << endl;
 cout << "String lida: " << str_ptr << endl;
 delete[] str_ptr;

 return 0;
}


Bom pessoal, acho que já deu pra ter uma boa noção, não é??

[]'s
Postem a vontade sugestões, pedidos ou idéias!!

Classificação do conteúdo: SÉRIO
Sobre Bruno Moreira Guedes:
Curriculum Vitae
Site Pessoal

3 comentários:

George Elias Ferreira da Silva disse...

Gostei do post, linguagem bem didática. =D
Outra coisa que assusta muita gente, são os templates, talvez um post no estilo desse, sobre templates.
É uma ideia.

Anônimo disse...

Muito bom este artigo sobre ponteiros um dos melhores que já vi!
Continue assim que já ganhou um leitor............. hehehehhe

Anônimo disse...

na linha:
printf("%d %d\n", *ponteiro2++, *ponteiro2);


*ponteiro2++
Derreferencia e incrementa ou
incrementa e derreferencia?

Sobre Bruno Moreira Guedes