Entendendo C++
Trabalhando com pointers

7.0 - Introdução
7.1 - Funções default
7.2 - Conclusão

7.0 - Introdução

Quando uma classe contém dados membro que são pointers, há uma série de cuidados adicionais para assegurar que essa classe realmente funcione como esperado. Por exemplo, quando uma instância de uma classe é destruída, o construtor deve certificar-se que todos os blocos de memória dentro da classe sejam liberados. Um outro cuidado envolve o operador de atribuição: o comportamento padrão para o operador = - cópia de todos os dados membro - como vimos até agora, não funciona para dados membro pointers.

Para tornar mais perceptível essa diferença, vamos construir uma classe Stack com matriz e com pointers. Aqui está a versão com matriz e uma função main contendo o código para teste. (Esse código é idêntico ao visto no Tutorial 4).

#include <iostream.h> 
 
class Stack
{
	int stk[100];
	int top;
public:
	Stack(): top(0) {}
	~Stack() {}
	void Clear() {top=0;}
 

	void Push(int i) {if (top < 100) stk[top++]=i;}
	int Pop()
	{
		if (top > 0) return stk[--top]; 
		else return 0;
	}
	int Size() {return top;}
};
 
void main()
{
	Stack stack1, stack2;
 
	stack1.Push(10);
	stack1.Push(20);
	stack1.Push(30);
	cout << stack1.Pop() << endl;
	stack2=stack1;
	cout << stack1.Size() << endl;
	cout << stack2.Size() << endl;
	cout << stack2.Pop() << endl;
	cout << stack2.Pop() << endl;
}
O código seguinte implementa a mesma classe Stack usando pointers, mas contém uma série de problemas que iremos examinar em seguida.
typedef struct node
{
int data;
node *next;
} node;
 
class Stack
{
node *top;
public:
Stack(): top(0) {}
~Stack() { Clear(); }
void Clear()
{
node *p=top;
while (p)
{
top = top->next;
delete p;
p = top;
}
}
void Push(int i)
{
node *p = new node;
p->data = i;
p->next = top;
top = p;
}
int Pop()
{
if (top != 0)
{
int d = top->data;
node *p=top;
top = top->next;
delete p;
return d;
}
else return 0;
}
int Size()
{
int c=0;
node *p=top;
while (p)
{
c++;
p = p->next;
}
return c;
}
};
Essa é uma implementação completa da classe. Ela realiza os procedimentos de liberação de memória dentro de seu destrutor, e funciona da mesma maneira que versão anterior da classe Stack. Contudo, essa implementação não funciona conforme o esperado após um comando de atribuição como

stack1 = stack2;

O diagrama seguinte demonstra o que acontece. A operação de atribuição, nesse caso, apenas copia os dados membro de stack2 para stack1, deixando o mesmo conjunto de dados em memória sendo apontado por dois pointers.

Após a atribuição, os pointers stack1.pop e stack2.pop apontam ambos para a mesma cadeia de blocos de memória. Se uma das pilhas for excluída, ou se uma delas executar a função Pop, a outra pilha vai apontar para um endereço de memória que não mais será válido.

Em algumas máquinas esse código será compilado sem erros e tudo parecerá estar correto por um certo tempo durante a execução do programa. Mas tão logo o sistema comece a apontar para endereços de memória que não mais sejam válidos, a execução começará a ter um comportamento errático sem razão aparente até que o programa finalmente falhe.

O que precisamos é de uma maneira de reformular a operação de atribuição para criar uma cópia dos blocos de memória apontados pelos pointers. Mas de onde vem esse operador de atribuição, e como podemos modificá-lo?

Topo
7.1 - Funções default

Quando você cria uma classe, quatro funções default são criadas automaticamente e serão utilizadas, a menos que você as sobrescreva. Essas funções default são:
  • O construtor default
  • O construtor de cópia default
  • O operador de atribuição default
  • O destrutor default

O construtor default é invocado quando você declara uma instância da classe sem passar qualquer parâmetro. Por exemplo, se você criar uma classe Sample sem a definição explícita de um construtor, então o comando seguinte invoca o construtor default para s:

Sample s;

A seguinte declaração com inicialização de s2 invoca o construtor de cópia:

Sample s1;

Sample s2 = s1;

O destrutor default é chamado quando se encerra o escopo dentro do qual a variável foi criada, e o construtor de atribuição é chamado quando ocorre uma operação de atribuição normal.

Você pode sobrescrever qualquer um desses construtores, definindo as suas próprias funções. Por exemplo, se você define explicitamente um construtor para a classe, o construtor default não será criado pelo compilador.

O código seguinte vai nos ajudar a ter uma melhor compreensão do que fazem o construtor e o destrutor default:

#include <iostream.h> 
 
class Class0
{
int data0;
public:
Class0 () { cout << "class0 constructor" << endl; }
~Class0 () { cout << "class0 destructor" << endl; }
};
 
class Class1
{
int data1;
public:
Class1 () { cout << "class1 constructor" << endl; }
~Class1 () { cout << "class1 destructor" << endl; }
};
 
class Class2: public Class1
{
int data2;
  Class0 c0;
};
 
void main()
{
Class2 c;
}
A classe Clas2 não tem construtor nem destrutor definidos explicitamente, mas esse código produz a seguinte saída:

class1 constructor
class0 constructor
class0 destructor
class1 destructor

O que aconteceu é que o compilador criou automaticamente tanto o construtor quanto o destrutor default para Clas2. O comportamento do default construtor é chamar o construtor da classe base, bem como o construtor default para cada um dos dados membro que são classes. O destrutor default chama o destrutor da classe base e dos dados membro que são classes.

Digamos que você crie um novo construtor para Clas2 que aceite um inteiro. O compilador ainda assim vai chamar os necessários construtores da classe base e dos dados membro que são classes.

O código seguinte demonstra esse processo:

class Class2: public Class1
{
int data2;
Class0 c0;
public:
Class2(int i) 

cout << "class2 constructor" << endl; 
}
};
 
void main()
{
Class2 c(1);
}
Isso também funciona e produz a seguinte saída:

class1 constructor
class0 constructor
class2 constructor
class0 destructor
class1 destructor


Mas agora você não pode mais declarar uma variável não inicializada do tipo
Clas2 porque não há mais um construtor default. O código seguinte demonstra:
Class2 c(1); // OK
Class2 e; // not OK--no default constructor
É também possível declarar uma matriz de uma classe, a menos que não haja um construtor default definido.

Contudo, você pode recriar o construtor default, criando explicitamente um construtor com uma lista de parâmetros vazia, da mesma maneira que cria outros construtores para a classe.

O operador de atribuição e o construtor de cópia também são criados automaticamente. Ambos apenas copiam os dados membro da instância à direita do sinal = para a instância à esquerda. No caso de nossa classe Stack, nós queremos eliminar essas funções default e usar funções próprias, para que a operação de atribuição funcione corretamente. A seguir estão as duas novas funções para a classe Stack, e a função Copy compartilhada por ambas:

void Copy(const Stack&  s)
{
node *q=0;
node *p=s.top;
 
while (p)
{
if (top==0)
{
top = new node;
q=top;
}
else
{
q->next = new node;
q = q->next;
}
 
q->data = p->data;
p = p->next;
q->next=0;
}
}
Stack&  operator= (const Stack&  s) //assignment
{
if (this == & s)
return *this;
Clear();
Copy(s);
return *this;
    }
Stack(const Stack&  s): top(0) // copy constructor
{
Copy(s);
}
A função de atribuição se inicia verificando o caso de auto-atribuição, como em

s = s;

Se verifica tratar-se de auto-atribuição, a função não faz nada, ou seja, não efetua a auto-atribuição. Não sendo auto-atribuição, a função limpa a instância recipiente e copia a lista ligada existente na memória, de modo que a instância à esquerda do operador de atribuição tenha sua própria cópia da pilha.

O construtor de cópia é basicamente o mesmo que qualquer outro construtor. É usado para manejar os seguintes casos:

Stack s1;
s1.Push(10);
s1.Push(20);
Stack s2(s1); // copy constructor invoked
Stack s3 = s1; // copy constructor invoked
Uma vez implementados o operador de atribuição e o construtor de cópia, a classe Stack está completa. Pode manejar qualquer condição e funcionar corretamente.
Topo
7.2 - Conclusão

Tudo isso talvez lhe pareça muito trabalho a fazer, mas geralmente esses cuidados adicionais são necessários apenas quando se trabalha com pointers. O que acontece é que você tem que realmente proteger suas estruturas baseadas em pointers contra contingências que invalidem os dados.

Em alguns programa em C, os programadores poderão fazer pressuposições como eu posso apontar o mesmo bloco de memória com vários pointers sem problemas, porque nessa parte do código nada modifica os blocos apontados. Contudo, se um outro programador viola essa pressuposição, ainda que acidentalmente, o programa pode falhar, e falhas decorrentes de problemas com pointers são difíceis de seguir e de localizar.

Tais problemas não vão ocorrer em uma classe C++ definida com segurança, porque todas essas contingências estão previamente consideradas e cobertas.

Você pode verificar que a implementação mostrada acima é ainda ineficiente. O que acontecerá se você quiser ter apenas uma cópia dos blocos de memória que formam a pilha? Por exemplo, o que acontecerá se os dados da pilha ocuparem alguns megabytes de memória, e você não tiver memória suficiente para fazer uma cópia?

O que você faz nesse caso é usar uma técnica como um contador de referências - cada instância da classe incrementa uma variável global estática que contém o número de instâncias usando a mesma cópia dos dados. Cada destrutor decrementa esse mesmo contador. Somente quando um construtor, após decrementar o contador, detecta que não há mais qualquer instância da classe usando os dados, é que realmente se libera a memória usada para conter os dados.

Topo Índice
© 1998 Interface Technologies, Inc by Marshall Brain
Tradução de Dagoberto Haele Arnaut

| Home | Bookmarks | Universidades | Para Saber mais | Universidades | WEB Directory | Mapa do site |