Instituto Politécnico de Beja
Escola Superior de Tecnologia e Gestão
Área Departamental de Engenharia
Disciplina de Linguagens de Programação I
 

Classes contendo memória dinâmica em C++ (versão 1.0)

João Paulo Barros

Beja, 29 de Janeiro de 1999


Sumário
Definição de classes contendo memória dinâmica. Gestão dessa memória. Construtores, construtor de cópia, operador de afectação (operator=) e destrutor. Cópia de referencias e cópia de dados.

Índice

1.Variáveis e memória
2. Os operadores new e delete
2.1 O operador new
2.2 O operador delete
2.3 Situações geradoras de erros na utilização dos operadores new e delete
3. Classes contendo memória dinâmica
3.1 Classes (ainda) com memória estática
3.2 Classes com memória dinâmica (finalmente...)
3.3 Outro exemplos de classes com gestão dinâmica da memória


1. Variáveis e memória

A memória ocupada pelas variáveis em C++ pode ser gerida de três formas distintasa que correspondem três tipos de variável:
  1. Dependente do bloco
  2. Estática
  3. Dinâmica
As variáveis dependentes do bloco, existem desde o momento em que são definidas até ao momento em que o programa sai do bloco. Como um bloco é definido como sendo o código contido entre duas chavetas ({}), a forma mais frequente de um bloco terminar é aquela em que o programa encontra uma chaveta a fechar:  '}'. Outras forma frequentes de sair de um bloco são as instruções return e break.

No segundo caso, a gestão é muito simples: a memória é reservada no início do programa (na verdade antes de ser executada a função main) e libertada apenas no final do programa. Como tal, as variáveis existem durante toda a execução do programa. Em C++, existem duas variedades de variáveis com estas características:

  1. variáveis globais
  2. variáveis static
As primeiras são definidas fora de qualquer bloco, as segundas são definidas dentro de um bloco mas a sua existência não depende do bloco estar ou não a ser executado. Por exemplo:
#include <iostream>

int a = 1;
int b = 2;
void f(int i);

int main()
{
   f(a);
   f(b);
   f(b);
   return 0;
}

void f(int i)
{
   static int x = 0;
   int y = 0;
   std::cout << (x == 0) ? "first time" : "function already called";
   std::cout << "x = " << x << ", y = " << y << ", i = " << i << std::endl;
   ++x;
}
Listagem 3.1 - Variedades de variáveis.
 

Escreverá:
x = 0, y = 0, i = 1
x = 1, y = 0, i = 2
x = 2, y = 0, i = 2


Na terceira variedade, as variáveis são criadas e destruídas por instruções ad hoc dadas pelo programador. Na verdade existem dois operadores específicos, para a reserva e libertação, respectivamente, de variáveis dinâmicas:

  1. O operador new
  2. O operador delete
Com estes operadores o programador tem total controlo sobre o momento e as circunstâncias em que as variáveis são criadas e, seguidamente, destruídas. Tal flexibilidade tem fundamentalmente um tipo aplicação: criação de estruturas de dados dinâmica em que a quantidade de memória por elas ocupada vai variando ao longo do programa. Estão neste caso os chamados vectores dinâmicos. O nome deriva do facto da quantidade de elementos desses vectores não se manter necessariamente constante ao longo da sua existência.
 

2. Os operadores new e delete

Os operadores new e delete são utilizados, respectivamente para a reserva e libertação de memória durante a execução do programa. Vamos começar pelo operador new.

2.1 O operador new

O operador new assume várias formas. Vamos ver aqui as duas principais:
  1. reserva de memória para um objecto de uma dada classe
  2. reserva de memória para um vector de objectos de uma dada classe
Na primeira forma a sintaxe é a seguinte:
Employee* employePtr = new Employee;
O novo objecto criado é manipulado a partir de um apontador para o endereço da memória reservada. Assim sendo, *employePtr é o objecto criado. Para chamar uma função membro teríamos: employePtr->getName().

Na segunda forma temos:

Employee* employees = new Employee[nElements];
em que nElements é a quantidade de elementos do vector criado. Neste caso employees é um vector de baixo-nível da classe Employee.
 

2.2 O operador delete

Para cada uma das formas apresentada do operador new temos a correspondente forma para o operador delete. Este operador encarrega-se de libertar a memória previamente reservada pelo operador new.
A memória criada pela primeira forma do operador new é libertada com:

delete employePtr;

A memória criada pela segunda forma do operador new é libertada com:

delete [] employePtr;

Note-se a ausência de qualquer número entre os dois parêntesis rectos. É mesmo assim! Os parêntesis rectos servem como indicação de que se está a libertar a memória correspondente a um vector, pelo que na verdade é necessário libertar a memória ocupada por cada um dos elementos do vector.

2.3 Situações geradoras de erros na utilização dos operadores new e delete

Existem várias situações geradoras de erros de execução graves quando se utilizam os operadores new e delete.  Os erros de execução podem ser muito difíceis de corrigir pelo que é fundamental ter muito cuidado. As situações são as seguintes:
  1. Acesso a uma zona de memória não reservada com new (falta de new).
  2. delete de um bloco de memória não reservado (delete sem new)
  3. delete de um bloco de memória já libertado com outro delete (delete sem new).
  4. Esquecimento de um delete (falta de um delete).
O primeiro erro pode surgir mesmo quando não se utiliza memória dinâmica. Basta utilizar um apontador que contem o endereço de uma posição de memória não reservada. Por exemplo:
void f()
{
   int i = 3; // define i reservando memória para a variável i.
   int* ptr1 = &i; // define ptr e inicializa-o com o endereço de i
   cout << *ptr; // ok. Escreve o valor (neste caso 3)no endereço de i
   int* ptr2; // define uma apontador mas não o inicializa
   cout << *ptr2; // Erro! ptr2 não contem um endereço
                  // de memória previamente reservada.
                  // Escreve valor desconhecido.
   *ptr2 = 5; // Error fatal! O programa tenta escrever num endereço
              // desconhecido. Muito provavelmente ira terminar 
              // com um erro do tipo: "segmentation fault".

}
Listagem 3.2 - Erros na utilização de apontadores.

O segundo e terceiro erros são semelhantes.  Ambos correspondem a tentar a libertação de memória que nunca esteve ou já não se encontra reservada.

O terceiro erro é o mais difícil de detectar pois normalmente só será detectado se o programa ficar sem memória dinâmica livre, ou seja,  quando o operador new não conseguir reservar memória. Muitas vezes nessa altura pode ser difícil detectar qual  ou quais os blocos de memória que não são libertados (com delete) e que se mantêm a ocupar memória.

Por último, um apontador com o valor zero é, por convenção da linguagem, uma apontador que não aponta para lado nenhum. Como tal os apontadores que não estejam (ainda) a ser utilizados deverão ter o valor zero. É sempre seguro (não dá erro)  fazer delete de um apontador com o valor zero, embora tal instrução não tenha nenhum efeito. Por outras palavras delete ptr em que ptr tem o valor zero não faz nada e não dá erro.

3. Classes contendo memória dinâmica

Por vezes  é necessário e/ou conveniente que objectos de uma mesma classe possam ocupar diferentes quantidades de memória. É o que sucede por exemplo com uma classe string. Neste caso é claro que cada objecto deverá ocupar uma quantidade de memória proporcional ao comprimento da string. Por exemplo, a string "abcdefgh" deverá ocupar mais memória do que a string "xyz". 

3.1 Classes (ainda) com memória estática

Se utilizarmos um array de baixo-nível com dimensão fixa tal não se consegue pois cada objecto irá ocupar a mesma quantidade de memória independentemente de a utilizar todas ou não. Na classe da  listagem 3.3 todas as strings necessitarão de 16 (MAX_SIZE) bytes de memória independentemente de os utilizarem ou  não.
class StringS
{
public:
   static const int MAX_SIZE = 16;
   //...
private:
   unsigned size;
   char     data[MAX_SIZE];
};
Listagem 3.3 - Classe String estática.
Nota: a constante MAX_SIZE pode estar definida dentro da classe desde que declarada como static. Neste caso, o modificador static significa que só existirá uma constante MAX_SIZE, ou seja, todos os objectos da classe utilizarão a mesma constante. Note que o mesmo não  sucede com as variáveis data e size. cada objecto contem a sua variável data e a sua variável size.

Após a execução da instrução s1 = s2 da listagem 3.4 a distribuição da memória relativas às strings será aapresentada na figura 3.1

#include "StringS.hpp" // string estática do tipo da da listagem 3.3
void f()
{
   StringS s1 = "abcdef";
   StringS s2 = "xyz";
   s2 = s1; // após esta instrução a
            // teremos a situação da figura 3.1
}
Listagem 3.4 - Classe String estática.

Figura 3.1 - Memória ocupada pelos dois objectos da  classe String  estática.

3.2 Classes com memória dinâmica (finalmente...)

Para que diferentes objectos, da mesma classe, possam ocupar diferentes quantidades de memória, a classe não deverá conter, directamente, toda a memória mas apenas o endereço onde a parte variável dessa memória irá residir. É o que sucede na classe da listagem 3.4.
class StringD
{
public:
   //...
private:
   unsigned size;
   char*    data;
};
Listagem 3.5 - Classe String dinâmica.
As semelhanças entre esta classe e a da listagem 3.3 são ainda menores do que possa parecer.
Após a execução da instrução s1 = s2 da listagem 3.6 a distribuição da memória relativas às strings será a apresentada na figura 3.2.
#include "StringD.hpp" // string dinâmica do tipo
                       // da da listagem 3.5
void f()
{
   StringD s1 = "abcdef";
   StringD s2 = "xyz";
   s2 = s1; // após esta instrução a
            // teremos a situação da figura 3.2
}
Listagem 3.4 - Classe String dinâmica.

Figura 3.2 - Memória ocupada pelos dois objectos da  classe String  dinâmica.
Conforme se ilustra na figura 3.2, a cópia de uma StringD para outra teve um efeito, à primeira vista surpreendentes! O objecto s2 não ficou com uma verdadeira cópia do objecto s1. Na verdade, ambos os objectos contêm agora uma referência para a mesma informação. Tal, não é, em principio, o que se pretende pelo que poderá originar vários efeitos surpreendentes. Por exemplo, se modificarmos o objecto s2 para "ABCdef" o objecto s1 também será modificado visto ambos utilizarem a mesma informação. Mais grave ainda, é o facto da destruição de um dos objectos originar a destruição dos dados do outro objecto visto serem os mesmos.

A que se deve este estranho comportamento do  operador=? Nada de especial, ele simplesmente fez o mais óbvio: copiou bit por bit as variáveis de um objecto para as correspondentes variáveis do outro objecto (note que ambos os objectos contêm necessariamente as mesmas variáveis visto pertencerem à mesma classe). Ora as únicas variáveis dos objectos são o inteiro size e o apontador data . Logo os apontadores irão ficar a a apontar para a mesma memória, ficando os objectos a utilizar os mesmos dados!

Este e outros problemas relacionados resultam da cópia bit a bit ser o comportamento por omissão do operador de afectaçao. Felizmente existe a possibilidade de definir à nossa vontade o operador de afectação. Dessa forma podemos programá-lo de forma a que seja feita uma verdadeira cópia de dados e não apenas de referências.

Na verdade quando temos uma classe que reserva memória de forma dinâmica  devemos definir as seguintes três funções (note que não temos de as definir quando não utilizamos memória dinâmica):

  1. O construtor de cópia: StringD(const StringD& s);
  2. O destrutor: ~StringD();
  3. O operador de afectação: StringD& operator=(const StringD& s);
Estas três funções têm muito em comum pelo que é boa ideia decompô-las de forma a utilizarem as duas funções seguintes:
  1. Uma função de "limpeza": void clear();
  2. Uma função de cópia: void copy(const StringD& s);
A primeira deverá constituir parte da interface da classe (ou seja, fazer parte da secção public:), pois pode ser útil "limpar" o conteúdo do objecto (neste caso colocar a ""). A segunda deverá fazer parte da secção private: da classe pois apenas será utilizada pelo construtor de cópia e pelo operator= que são ambos funções membro da classe.

Apresenta-se, na listagem 3.5, a definição destas cinco funções. A definição completa de uma classe StringD bem como de todas as suas funções membro também se encontra disponível

String::String(const String& s)
{
   copy(s);
}

String::~String()
{
   clear();
}
String& String::operator=(const String& s)
{
   if (&s != this) // so that a = a does
                   // not give an error
   {
      clear();
      copy(s);
   }
   return *this;
}

void String::copy(const String& s) 
{
   size = s.size;
   data = new char[size + 1];
   strcpy(data, s.data);
}

void String::clear()
{
  delete [] data;
  data = 0; // sets pointer to zero so that
            // another delete will not give an error
}

Listagem 3.5 As cinco funções a definir nas classes com memória dinâmica.

Note que as três primeiras funções podem ter sempre a definição apresentada na listagem 3.5, independentemente da classe. O código que varia de classe para classe é o das funções copy e clear.

3.3 Outro exemplos de classes com gestão dinâmica da memória

  • Vector dinâmico 
  • Vector dinâmico com iterador
  • Lista ligada

  •