Jiraspiom

Blog Pessoal

Lição 09

Lição 9 – Ponteiros e Memória Dinâmica

Ponteiros

A memória do computador pode ser abstraída como uma sucessão de células, representando as posições de memória, cada uma do tamanho mínimo que as máquinas trabalham: 1 byte. Estas posições são numeradas de uma maneira seqüencial, consecutiva, de maneira que em um bloco de memória cada célula tem sua numeração igual à anterior adicionada de 1.

Assim que declaramos uma variável, a quantidade de memória necessária é atribuída a um local específico na memória. Normalmente não decidimos exatamente onde será o exato local da variável dentro da memória, já que é uma tarefa automaticamente realizada pelo Sistema Operacional na execução. Em alguns casos, entretanto, é interessante sabermos o endereço de onde nossa variável está armazenada durante a execução com a finalidade de operar com posições relativas a ela.

O endereço que identifica uma variável na memória é o que chamamos de uma referência àquela variável. Ela pode ser obtida precedendo o identificador de uma variável com o operador de referência, representado pelo símbolo &, que pode ser literalmente traduzido para “o endereço de”.
Exemplo:

var1 = &var2;


Isto significa que estamos atribuindo var1 para o endereço da variável var2. Não estamos falando sobre o conteúdo da variável em si ao preceder var2 pelo operador &, mas sim sobre sua referência, seu endereço de memória. Mostrarei um exemplo simples para clarificar um pouco mais.

Primeiramente vamos assumir que var1 está alocado na memória no endereço 1234 (na realidade não sabemos antes da execução o valor real do endereço que uma variável vai ter na memória, sendo este número somente para exemplificar). Veja o código:

var1 = 40;
var2 = var1;
var3 = &var1;


Os valores contidos nas variáveis são:

  • var1 = 40, pois assumimos este valor no início do programa.
  • var2 = 40, pois atribuímos ao var2 o valor que estava na variável var1 (variável cujo valor era 40 e endereço de memória era 1234)
  • var3 = 1234, pois atribuímos a var3 o endereço de memória em que estava var1.

Veja a figura:

ponteiros

Figura 7 – Ponteiros


Os Ponteiros

Chamamos de ponteiro a variável que armazena uma referência para outra variável, como var3 no último exemplo. Abstraindo novamente, ponteiros são ditos apontando para a variável cuja referência eles armazenam. Ao usar um ponteiro, podemos acessar diretamente o valor armazenado na variável para a qual ele aponta. Para fazer isto, simplesmente precedemos o identificador do ponteiro pelo asterisco (*), que pode ser literalmente traduzido para “valor apontado por”.
Exemplo:

var4 = *var3; //var4 é igual ao valor apontado por var3.
//Como var3 armazena o endereço 1234
e o
//valor contido no endereço 1234 é 40,
//var4 receberia 40.


Devemos claramente diferenciar que a expressão var3 é diferente de *var3, a primeira representando o valor 1234 e a segunda se referindo ao valor armazenado em 1234, que é 40. Note que anteriormente fizemos as seguintes atribuições:

var1 = 40;
var3 = &var1;


Após estas atribuições, as seguintes sentenças retornariam true (verdadeiro) se executadas:

var1 == 40
&var1 == 1234
var3 == 1234
*var3 == 40


Devido à habilidade de referenciar diretamente o valor para o qual aponta, é necessário sempre especificar na declaração para qual tipo de dado um ponteiro irá apontar. Isto é um tanto óbvio visto que não é a mesma coisa apontar para um char do que apontar para um int ou float.

A declaração dos ponteiros segue o seguinte:

tipo * nome;


onde tipo é o tipo de dado do valor para o qual o ponteiro pretende apontar. Este tipo não é o tipo do ponteiro em si! Exemplo de declarações de ponteiros:

int * num;
char * c;
float * fnum;


Cada uma das declarações anteriores pretende apontar para um tipo de dado diferente, mas na realidade todos são ponteiros e ocuparão o mesmo espaço na memória (o tamanho da memória de um ponteiro depende da plataforma onde o código é executado). Todavia, o dado para o qual eles apontam não ocupam o mesmo espaço nem são do mesmo tipo: o primeiro aponta para um int, o segundo para um char e o terceiro para um float. Com isso, apesar de serem todos ponteiros, são ditos terem tipos diferentes: int*, char* e float* respectivamente.

OBS: é importante frisar que o asterisco (*) usado ao declararmos aqui um ponteiro só indica que é um ponteiro, e não deve ser confundido com o operador visto anteriormente usado como um referenciador, que também é escrito com um asterisco (*).

Exemplo de declaração de ponteiros:

#include <iostream>
using namespace std;

int main ()
{
int val1, val2;
int * pont;

pont = &val1;
*pont = 10;
pont = &val2;
*pont = 20;
cout << “val1 é ” << val1 << endl;
cout << “val2 é ” << val2 << endl;
return 0;
}


, o que retorna:

val1 é 10
val2 é 20


Note que mesmo que não tenhamos diretamente setado um valor para val1 e val2, os dois terminam com um valor setado indiretamente pelo uso de pont. Isto pois ao atribuirmos um valor para o local de memória apontado por pont, modificamos o valor de val1 e val2. Note que um ponteiro pode aceitar diversos valores diferentes durante o mesmo programa.-


Um exemplo um pouco mais elaborado:

#include <iostream>
using namespace std;

int main ()
{
int val1 = 5, val2 = 15;
int * p1, * p2;

p1 = &val1; // p1 = endereço de val1
p2 = &val2; // p2 = endereço de val2
*p1 = 10; // valor apontado por p1 = 10
*p2 = *p1; // valor apontado por p2 = valor apontado por p1
p1 = p2; // p1 = p2 (valor do ponteiro é copiado)
*p1 = 20; // valor apontado por p1 = 20

cout << “val1 é ” << val1 << endl;
cout << “val2 é ” << val2 << endl;
return 0;
}


,o que retorna na tela:

val1 é 10
val2 é 20


Note que há expressões com os ponteiros p1 e p2 ambas com e sem o operador de referência (*). O significado de uma expressão usando esse operador é muito diferente da que não o usa (*): quando ele precede o nome de um ponteiro, a expressão refere-se ao valor sendo apontado, enquanto quando o ponteiro aparece sem este operador, refere-se ao valor do ponteiro em si (endereço para o que o ponteiro está apontando). Outra coisa a se chamar atenção é a linha:

int * p1, * p2;


, que declara dois ponteiros usados no exemplo. Mas perceba que há um asterisco (*) para cada ponteiro. Se não usássemos os dois asteriscos, deixando p2 sem o asterisco por exemplo, o tipo dela seria somente int. Desta maneira:

int * p1, p2;


Aqui, p1 tem o tipo int* mas p2 somente tem o tipo int, que não serviria de nada no exemplo.

Ponteiros Aritméticos

Operações aritméticas em ponteiros têm uma maneira um pouco diferente de serem conduzidas do que em tipo inteiros comuns. Somente adição e subtração são permitidas. Mas ambas adição e subtração têm um diferente comportamento com ponteiros de acordo com o tamanho do tipo de dados para o qual ele aponta. Quando vimos os tipos fundamentais vimos que alguns ocupam mais ou menos memória que os outros. Por exemplo, assuma que numa certa máquina char recebe 1 byte, short recebe 2 bytes, e long recebe 4. Suponha também que definimos 3 ponteiros:

char *pchar;
short *pshort;
long *plong;

, e que saibamos que eles apontam para os locais de memória 1000, 2000 e 3000 respectivamente. Então, se escrevermos:

pchar++;
pshort++;
plong++;

ou

pchar = pchar + 1;
pshort = pshort + 1;
plong = plong + 1;

, vamos perceber que pchar conterá o valor 1001, pshort conterá 2002, e plong conterá 3004. A razão é que, ao adicionar uma unidade a um ponteiro, estamos fazendo que ele aponte para o elemento seguinte do mesmo tipo em que ele foi definido, e assim o tamanho em bytes do tipo apontado é adicionado ao ponteiro. Isto é aplicável ao adicionarmos ou subtrairmos algum número de um ponteiro.

Note que ambos incremento (++) e decremento (–) têm precedência maior do que o operador (*), mas ambos tẽm um comportamento especial quando usados como sufixos (a expressão é avaliada com o valor que tinha antes de ser incrementada). Com isso, a expressão seguinte pode levar a uma confusão:

*p++

Como (++) tem precedêndia maior que (*), esta expressão é equivalente a *(p++). Então, o que ela faz é incrementar o valor de p (fazendo ele apontar para próximo elemento), mas como ++ é usado pós-fixado, toda a expressão é avaliada com o valor apontado pela referência original (o endereço para o qual apontava antes de ser incrementado). Note a diferença com:

(*p)++

Aqui, a expressão seria avaliada como o valor apontado por p incrementado em uma unidade. O valor de p, o ponteiro em si, não seria modificado, mas sim o que está sendo apontado por este ponteiro. Se escrevermos:

*p++ = *q++;

, como (++) tem precedência maior que (*), ambos p e q são incrementados mas, por ambos usarem os operadores de incremento como pós-fixados, o valor atribuído a *p é *q antes que sejam incrementados. Seria equivalente a

*p = *q;
++p;
++q;

OBS: sempre use parênteses para evitar ambigüidades e melhorar a legibilidade do código.

Ponteiros para Ponteiros

Podemos também usar ponteiros que apontam para ponteiros e, os últimos, por sua vez, apontam para dados ou mesmo para outros ponteiros. Para isto, é necessário adicionar um asterisco (*) para cada nível de referência nas declarações.

char a;
char * b;
char ** c;
a = ‘z’;
b = &a;
c = &b;

Isto, supondo locais de memória aleatoriamente escolhidos para cada variável respectivamente de 7230, 8092, 10502, pode ser representado como:

ponts
Figura 8 – Ponteiros

O valor de cada variável é escrito em cada célula, e abaixo seus respectivos endereços de memória. A novidade aqui é a variável c, que pode ser usada em 3 diferentes níveis de indireção, cada um correspondendo a um valor diferente.

c tem tipo char** e um valor de 8092
*c tem tipo char* e um valor de 7230
**c tem tipo char e um valor de ‘z’

Ponteiros sem tipo

O tipo void de um ponteiro é um tipo especial de ponteiro. Em C++, void representa a ausência de tipo, então ponteiros void são ponteiros que apontam para um valor que não tem tipo (tendo, com isso, tamanho indeterminado). Isto permite que ponteiros void apontem para dados de qualquer tipo. Porém, têm uma limitação: o dado apontado por ele não pode ser referenciado diretamente. Por esta razão, sempre teremos que escalar o endereço de um ponteiro void para um outro tipo de ponteiro que aponte par aum tipo de dado concreto antes de referenciá-lo. São extremamente úteis para passar parâmetros genéricos a uma função. Exemplo:

#include <iostream>
using namespace std;

void increm (void* dado, int ptam)
{
if ( ptam == sizeof(char) ) //sizeof retorna o tamanho em bytes de seu parâmetro. Aqui, retorna 1 (char = 1byte)
{ char* pchar; pchar=(char*)dado; ++(*pchar); }
else if (ptam == sizeof(int) )
{ int* pint; pint=(int*)dado; ++(*pint); }
}

int main ()
{
char a = ‘x’;
int b = 1602;
increm (&a,sizeof(a));
increm (&b,sizeof(b));
cout << a << “, ” << b << endl;
return 0;
}

, que retornará na tela:

y, 1603


Ponteiro Nulo

É um ponteiro de qualquer tipo que tem um valor especial que indica que não está apontando para nenhum endereço. Exemplo:

int * p;
p = 0; // p tem um ponteiro nulo como valor

OBS: É importantíssimo não confundir ponteiro nulo com ponteiro void. Um ponteiro nulo é um valor que qualquer ponteiro pode ter para representar que não está apontando para “lugar nenhum”, enquanto um ponteiro void é um tipo especial que ponteiro que pode apontar para algum lugar sem um tipo específico.

Ponteiros para Funções

Por último, podemos fazer que os ponteiros também apontem para funções. O uso típico é para passar uma função como argumento para uma outra função, já que ela não pode ser passada como referência. Para declarar um ponteiro para uma função temos que declarar como o protótipo de uma função exceto com o nome da função entre parênteses (), e um asterisco (*) é inserido antes do nome:

#include <iostream>
using namespace std;

int adicao(int a, int b)
{ return (a+b); }

int subtracao(int a, int b)
{ return (a-b); }

int operacao(int x, int y, int (*chamafunc)(int,int))
{
int g;
g = (*chamafunc)(x,y);
return (g);
}

int main ()
{
int m,n;
int (*menos)(int,int) = subtracao;

m = operacao(7, 5, adicao);
n = operacao(20, m, menos);
cout <<n;
return 0;
}

, que retorna na tela:

8

No exemplo, menos é um ponteiro para uma função que tem 2 parâmetros do tipo int. É imediatamente feita apontar para a função subtracao, tudo na linha:

int (* minus)(int,int) = subtraction;

Memória Dinâmica

Nos programas e exemplos até agora só tínhamos o tanto de memória disponível quanto declaramos nas variáveis. Se quisermos que a quantidade de memória seja determinada durante a execução, por exemplo no caso em que queiramos que o próprio usuário insira a quantidade necessária de espaço em memória, temos que descartar as estruturas estáticas como vetores e passar a usar estruturas dinâmicas.

Para requisitar memória dinâmica, usamos o operador new. Ele é seguido por um especificador do tipo de dado e, se for requerida uma sentença de mais de um elemento, o número de elementos entre colchetes [ ]. Retorna um ponteiro para o começo do novo bloco de memória alocada.
Seu formato é:

ponteiro = new tipo
ponteiro = new tipo [numero_de_elementos]


A primeira sentença é usada para alocar memória para conter um único elemento do tipo tipo.
A segunda sentença é usada para atribuir um vetor de elementos do tipo tipo, onde numero_de_elementos é um valor inteiro representando a quantidade deles.

Exemplo:

int * fulano;
fulano = new int [5];


Aqui o sistema atribui dinamicamente espaço para cinco elementos do tipo int e retorna um ponteiro para o primeiro elemento da seqüência, que é atribuído a fulano. Então, agora fulano aponta para um bloco de memória válido com espaço para 5 elementos do tipo int. O primeiro elemento apontado por fulano pode ser acessado tanto pela expressão fulano[0] como por *fulano. Já o segundo elemento por ser acessado tanto como fulano[1] ou *(fulano+1) e assim por diante.

A memória dinâmica requerida pelo programa é alocada da memória heap pelo sistema. Entretanto ela pode se esgotar, tornando importante termos um mecanismo para checar se nosso requerimento de alocar memória teve sucesso ou não. Em C++ temos 2 mecanismos:

Um é o tratamento de exceções, ou erros. Um erro do tipo bad_alloc é lançado quando a alocação falha. Exceções serão melhores explicadas adiante, mas aqui já é interessante saber que quando uma exceção é lançada e não é tratada por um manipulador específico a execução é finalizada. Este método é o padrão usado por new e é usado em declarações como:

fulano = new int[5]; //se falhar, uma exceção é lançada.


O outro método é conhecido como nothrow, e o que acontece quando é usado é que quando a alocação de memória falha, ao invés de lançar uma exceção bad_alloc ou terminar o programa, o ponteiro retornado pelo new é um ponteiro nulo, e a execução é continuada. Este método pode ser especificado usando um objeto especial chamado nothrow, declarado no cabeçalho <new>, como um argumento para new:

fulano = new (nothrow) int [5];


Neste caso, se a alocação falhar, o erro poderá ser detectado checando se fulano recebeu um ponteiro nulo:

int * fulano;
fulano = new (nothrow) int [5];
if (fulano == 0) {
// erro de alocação de memoria.
};
Este método querer mais trabalho que o primeiro método, já que o valor retornado tem que ser avaliado após cada e toda alocação de memória. No entanto, usaremos em alguns exemplos devido à sua simplicidade. Entretanto, para projetos maiores, pode ser um método que se torna tedioso, onde o método de exceções é preferido, método este que será melhor explicado adiante no curso.


Assim que o uso de memória dinâmica não for mais necessitado no programa, a área da memória utilizada deve ser limpa para que possa ser aproveitada por outras requisições de uso de memória dinâmica. Faremos esta limpeza usando o operador delete, cujo formato é:

delete ponteiro;
delete [] ponteiro;

A primeira pode ser usada para limpar memória alocada para um único elemento, e a segunda para memória alocada para vetores de elementos. O valor passado como argumento ao delete deve ser ou um ponteiro para um bloco de memória previamente alocado com o new, ou um ponteiro nulo (neste caso, não surtindo efeito algum). Exemplo:

#include <iostream>
#include <new>
using namespace std;

int main ()
{
int i,x;
int * pont;
cout << “Quantos números gostaria de escrever? “;
cin >> i;
pont = new (nothrow) int[i];
if (pont == 0)
cout << “Erro!! memória não pôde ser alocada!”;
else
{
for (x=0; x<i; x++)
{
cout << “Insira um número: “;
cin >> pont[x];
}
cout << “Você inseriu: “;
for (x=0; x<i; x++)
cout << pont[x] << “, “;
delete[] pont;
}
return 0;
}

, o que retorna na tela:

Quantos números gostaria de escrever? 4
Insira um número: 32
Insira um número: 26
Insira um número: 18
Insira um número: 7
Você inseriu: 32, 26, 18, 7,

Note que o valor entre colchetes na sentença new é um valor variável inserido pelo usuário (i), não um valor constante:

cout << “Quantos números gostaria de escrever? “;
cin >> i;
pont = new (nothrow) int[i];

No caso anterior, se o usuário inserir um valor para i muito grande como 1 bilhão, por exemplo, o sistema não poderia alocar memória suficiente e teríamos a mensagem de erro:

Erro!! memória não pôde ser alocada!

No caso de que tentássemos alocar memória para o programa sem especificar o parâmetro nothrow, a exceção seria lançada e, se não fosse tratada, o sistema terminaria a execução. Devemos, então, sempre checar se o bloco de memória dinâmica foi alocado com sucesso. Assim, se usarmos o método nothrow, sempre devemos checar o valor que o ponteiro retornou. Caso contrário, devemos usar o método de exceções, mesmo que não tratemos a exceção. Desta maneira, o programa terminará naquele ponto sem causar o resultado inesperado de continuar a execução do código assumindo que um bloco de memória foi alocado, quando na realidade não foi.

OBS: É importante salientar que os operadores new e delete são exclusivos de C++. Se usarmos a linguagem C, memória dinâmica pode ser usada através das funções malloc, calloc, realloc e free, definidas na biblioteca <cstdlib>. Como C++ é um superset de C, essas funções também estão disponíveis para programadores C++. No entanto, os blocos de memória alocados por estas funções não são necessariamente compatíveis com aqueles retornados pelo new, então cada um deve ser manipulado com seu próprio set de funções ou operadores.


%d blogueiros gostam disto: