Resumo da Aula 6

Sumário

Classes que reservam recursos externos

Quando as instâncias de uma classe reservam recursos externos para sua utilização exclusiva, é necessário ter uma série de cuidados na implementação da classe.  De longe o recurso externo mais utilizado pelas classes é memória livre.  Sempre que uma classe reserva memória livre para sua utilização exclusiva é necessário tomar atenção a um conjunto característico de problemas.

1.1  Uma pilha com limites

No resumo anterior apresentou-se uma classe para representar pilhas de itens sem qualquer limitação a priori quanto ao número de itens que pode suportar*.  Neste resumo far-se-á o desenvolvimento passo a passo dessa classe partindo de uma versão inicial, com dimensão limitada.

Seja uma classe PilhaDeInt representando pilhas limitadas de inteiros.  O seu código divide-se por dois ficheiros fonte (de interface e auxiliar de implementação):

pilha_de_int.H

#ifndef PILHA_DE_INT_H
#define PILHA_DE_INT_H

/** Representa pilhas de int.
   
@invariant 0 <= número_de_itens <= capacidade_máxima. */
class PilhaDeInt {
  public:
    typedef int Item;

    /** Constrói pilha vazia.
       
@pre V.
       
@post estaVazia(). */
    PilhaDeInt();

    /** Indica se a pilha está vazia.
       
@pre V.
       
@post estaVazia = *this está vazia. */
    bool estáVazia() const;

    /** Indica se a pilha está cheia.
       
@pre V.
       
@post estaVazia = *this está cheia. */
    bool estáCheia() const;

    /** Devolve altura da pilha.
       
@pre V.
       
@post altura = altura de *this. */
    int altura() const;

    /** Devolve o item que está no topo da pilha.
       
@pre ¬estaVazia().
       
@post topo idêntico ao item no topo de *this. */
    Item const& topo() const;

    /** Devolve o item que está no topo da pilha.
       
@pre ¬estaVazia().
       
@post topo idêntico ao item no topo de *this. */
    Item& topo();

    /** Põe um novo item na pilha (no topo).
       
@pre V.
       
@post *this contém um item adicional no topo igual a novo_item. */
    void põe(Item const& novo_item);

    /** Tira o item que está no topo da pilha.
       
@pre ¬estaVazia().
       
@post *this contém os itens originais menos o do topo. */
    void tiraItem();
 
  private:
    static int const capacidade_máxima = 100;

    Item itens[capacidade_máxima];
    int número_de_itens;

    /** Indica se a pilha verifica a condição invariante da classe.
       
@pre V.
       
@post cumpreInvariante
              (0 <= número_de_itens <= capacidade_actual). */
    bool cumpreInvariante() const;
};

#include "pilha_de_int_impl.H"

#endif // PILHA_DE_INT_H

pilha_de_int_impl.H

#include <cassert>

inline PilhaDeInt::PilhaDeInt()

    : número_de_itens(0)
{
    assert(cumpreInvariante());
}

inline bool PilhaDeInt::estáVazia() const
{

    assert(cumpreInvariante());

    return altura() == 0;
}

inline bool PilhaDeInt::estáCheia() const 
{

    assert(cumpreInvariante());

    return altura() == capacidade_máxima;
}

inline int PilhaDeInt::altura() const
{

    assert(cumpreInvariante());

    return número_de_itens;
}

inline PilhaDeInt::Item const& PilhaDeInt::topo() const
{

    assert(cumpreInvariante());
    assert(not estáVazia());

    return itens[número_de_itens - 1];
}

inline PilhaDeInt::Item& PilhaDeInt::topo() 
{

    assert(cumpreInvariante());
    assert(not estáVazia());

    return itens[número_de_itens - 1];

}

inline void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());
    assert(not estáCheia());


    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

inline void PilhaDeInt::tiraItem()
{

    assert(cumpreInvariante());
    assert(not estáVazia());

    --número_de_itens;

    assert(cumpreInvariante());
}

inline bool PilhaDeInt::cumpreInvariante() const
{
    return 0 <= número_de_itens <= capacidade_máxima;
}

Estude atentamente o código acima antes de passar à próxima secção: ele corresponde à versão completa do TAD PilhaDeDouble desenhado na última aula teórica de Introdução à Programação.

* Na realidade há sempre o limite dado pelo tipo inteiro usado para guardar o número de itens na pilha.  Mas esse limite é suficientemente grande para não trazer grandes problemas.

1.2   Problemas com a implementação

Esta classe tem um problema grave: as instâncias têm todas a mesma capacidade e ainda por cima limitada.  É impossível escolher uma capacidade apropriada para todas as aplicações.  Se for muito grande, conduzirá a desperdícios quando o número de itens realmente utilizado for pequeno.  Se for pequeno, torna impossível a resolução de problemas que necessitam de colocar nas pilhas maior número de itens.

Uma possível solução do problema passa por guardar os itens da pilha não numa matriz normal do C++, mas sim numa matriz dinâmica, de modo que se possa ir aumentando a sua dimensão à medida das necessidades.

Na realidade a matriz dinâmica não vai crescer.  Sempre que a capacidade actual da pilha estiver esgotada e for necessário acrescentar-lhe um novo item, será construída uma nova matriz dinâmica de maior dimensão, contendo os mesmos itens que a matriz original, que será usada de aí em diante.  A implementação, portanto, usará um ponteiro para guardar o endereço do primeiro elemento da matriz dinâmica, em vez da matriz clássica (não-dinâmica) usada na implementação original.  O atributo número_de_itens manter-se-á, mas é necessário passar o atributo capacidade_máxima de constante de classe a variável de instância, mudando-lhe o nome para capacidade_actual, de modo a deixar claro que já não reflecte a capacidade máxima de todas as pilhas mas sim a capacidade de cada instância da classe C++, i.e., de cada pilha específica, em cada instante de tempo.  Entretanto introduz-se uma nova constante membro de classe capacidade_inicial que representa a capacidade inicial das pilhas, ou seja, a dimensão inicial da matriz dinâmica:

...

/** Representa pilhas de int.
   
@invariant itens aponta matriz com capacidade_actual itens e
              capacidade_inicial <= capacidade_actual e
               0 <= número_de_itens <= capacidade_actual. */
class PilhaDeInt {
  public:

    ...

  private:
    static int const capacidade_inicial = 32;
    int capacidade_actual;

    Item* itens;
    int número_de_itens;

    /** Indica se a pilha verifica a condição invariante da classe.
       
@pre V.
       
@post cumpreInvariante = (itens <> 0 e 
              capacidade_inicial <= capacidade_actual e
              0 <= número_de_itens <= capacidade_actual). */
    bool cumpreInvariante() const;
};

...

inline bool PilhaDeInt::cumpreInvariante() const
{
    return itens != 0 and
           capacidade_inicial <= capacidade_actual and
           0 <= número_de_itens <= capacidade_actual;
}

1.3  Construtores

As mudanças principais a realizar na implementação da classe são na definição do construtor e na definição da operação PilhaDeInt::põe().  O construtor deverá construir a matriz dinâmica com a dimensão apropriada.  A operação PilhaDeInt::põe() terá de aumentar a dimensão da matriz dinâmica sempre que necessário.  Uma vez que na realidade os aumentos são conseguidos através da construção de uma nova matriz dinâmica e consequente cópia dos itens da matriz original para a nova matriz, este processo de reconstrução é oneroso do ponto de vista computacional.  É, pois, conveniente que a matriz dinâmica aumente a bom ritmo, sem no entanto exagerar, pois tal conduziria a grandes desperdícios de memória.

Pode-se demonstrar que uma boa estratégia passa por duplicar a capacidade da matriz sempre que a capacidade actual for insuficiente.  Dessa forma os aumentos da matriz são esporádicos (acabam por ser amortizados entre todas as operações de colocação de novos itens intermédias que não obrigam a aumentos da matriz) e, além disso, garante-se que a matriz dinâmica está sempre ocupada a 50% pelo menos (bom, quase sempre).

Esta estratégia, de fazer a capacidade da matriz aumentar geometricamente, tem claras vantagens face a uma estratégia em que o aumento se faz aritmeticamente.  A seguinte tabela ilustra as diferenças:

  Progressão geométrica em m (capacidade multiplicada por m, com 1 < m).  Admite-se que capacidade inicial é 1. Progressão aritmética em m (capacidade somada de m, com 0 < m).  Admite-se que a capacidade inicial é 0.
Número médio c de cópias de itens realizadas após n inserções de itens c = (1 - 1 / n) / (m - 1) < 1 / (m - 1)
c = (1 - 1 / n) para m = 2 (c aprox= 1 para n grande)
c = (1 - 1 / n) / 2 para m = 3 (c aprox= 1/2 para n grande)
etc.
c = n / m / 2 - 1 / 2 < n / m / 2
c = n / 2 - 1 / 2 para m = 1 (c aprox= n / 2 para n grande)
c = n / 4 - 1 / 2 para m = 2 (c aprox= n / 4 para n grande)
etc.
Taxa t de ocupação da matriz com n itens na pilha 1 / m < t <= 1 n / (n + m) < t <= 1
t
aprox= 1 para n grande 
Conclusões: Pior taxa de ocupação, reduzido número de cópias.
Bom compromisso: m = 2 (c aprox= 1 cópia por inserção, t > 50%)
Melhor taxa de ocupação, elevado número cópias.

  Assim:

inline PilhaDeInt::PilhaDeInt()
    : capacidade_actual(capacidade_inicial), 
      itens(new Item[capacidade_actual]),
      número_de_itens(0)
{

}

void PilhaDeInt::põe(Item const& novo_item)

{
    assert(cumpreInvariante());
    assert(not estáCheia());

    if(número_de_itens == capacidade_actual) {
        // Como não há espaço, constrói-se uma nova matriz dinâmica
        //   com o dobro da dimensão, duplicando-se a capacidade da pilha:
        capacidade_actual *= 2;
        Item* const novos_itens = new Item[capacidade_actual];

        // Copia-se para a nova matriz os itens que estavam na matriz dos
        // itens original:
        for(int i = 0; i != número_de_itens; ++i)
            novos_itens[i] = itens[i];

        // Destrói-se a matriz dos itens original:
        delete[] itens;

        // A matriz dos itens passa a ser a nova matriz construída (já com os
        // itens antigos):
        itens = novos_itens;
    }
    //
Agora há espaço para o novo item de certeza, pode-se inserir
    // normalmente:

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Note-se que se referiu que a ocupação da pilha era sempre superior a 50%.  Será verdade?  Em rigor, não.  Em primeiro lugar porque a capacidade inicial é 32 (e não 1, como indicado na tabela), e portanto a ocupação só passará a ser superior a 50% a partir do momento em que estejam pelo menos 17 itens na pilha.  Em segundo lugar porque os itens também podem sair da pilha!  Claro está que seria possível reduzir o tamanho da matriz dinâmica para metade sempre que se retirasse um item da pilha e a sua ocupação se tornasse inferior ou igual a 50%, ou outro critério semelhante que evitasse possíveis oscilações em torno de duas capacidades sucessivas.  Mas tal não será feito aqui.  Assim a implementação dos restantes métodos da classe mantém-se, com excepção do método PilhaDeInt::estáCheia().

O método PilhaDeInt::estáCheia() representa um problema complicado pela simples razão de que, se a matriz dinâmica estiver ocupada a 100%, não é possível saber se há espaço para um item adicional sem tentar aumentar a dimensão da matriz.  Discutiu-se no resumo da aula 5 que havia essencialmente três soluções para este problema (no contexto das listas):

  1. Fazer a função devolver sempre false.  É a pior das soluções porque há situações em que não corresponde à verdade.
  2. Eliminar a operação.  É má solução, pois pode haver já código que a usa e que terá de ser rescrito.  Deve-se sempre pensar três vezes antes de alterar a interface de uma classe (ou, em geral de um módulo).
  3. A operação PilhaDeInt::estáCheia(), no caso de não haver mais espaço disponível, tenta aumentar (ou melhor, reconstruir) a matriz dos itens.  Se conseguir, devolve false, uma vez que passou a haver de certeza espaço para mais um item.  Senão devolve true.  Note-se que há uma mudança no contrato da classe.  Dantes, se PilhaDeInt::estáCheia() devolvesse true era certo que a próxima operação de inserção (se não fosse precedida por nenhuma de remoção) falharia.  O novo contrato estabelece que se PilhaDeInt::estáCheia() devolver true nada pode ser garantido (pois pode passar a haver memória disponível por um outro programa concorrente ter libertado memória).

No entanto, o problema pode ser visto por um prisma um pouco diferente.  Pode-se considerar que só excepcionalmente poderá falhar a colocação de itens numa pilha que não tenha nenhuma limitação a priori da sua capacidade.  Como tal, o lançamento da excepção bad_alloc pode ser visto como a forma mais indicada de lidar com este problema.  Face a esta forma de lidar com erros, que será vista pormenorizadamente mais abaixo, a melhor solução talvez seja mesmo eliminar a operação PilhaDeInt::estáCheia() deixando à operação PilhaDeInt::põe() a responsabilidade de lançar essa excepção em caso de falta de memória (na realidade não será o código do método correspondente a lançar a excepção, mas o operador new por ele usado, como se verá).

É frequente a construção de variáveis dinâmicas dentro de uma classe e para seu uso exclusivo.  Uma das políticas de gestão variáveis dinâmicas mais simples e eficaz é a de que estas variáveis devem ser destruídas pela mesma entidade que as construiu.  Se se seguir essa política neste caso, é evidente que a classe PilhaDeInt deverá responsabilizar-se pela destruição da matriz dinâmica dos itens.

1.4  Destrutores

Que acontece quando é executado o seguinte ciclo (admite-se que o módulo pilha_de_int define a classe PilhaDeInt)?

#include "pilha_de_int.H"

...

for(int i = 0; i != 100000; ++i) {
    PilhaDeInt p;

    ... // operações com a pilha.
}

Em cada passo do ciclo é criada uma pilha com pelo menos 32 itens.  No fim de cada passo a pilha é destruída.  Mas o que é destruído é o espaço ocupado pela instância da pilha em si (que consiste em dois atributos inteiros, capacidade_actual e número_de_itens, e um ponteiro, itens).  A matriz dinâmica construída no construtor da classe (ou reconstruída no método PilhaDeInt::põe()) nunca chega a ser destruída!  Isto significa que, depois do ciclo, existem em memória (e inacessíveis) 100000 matrizes dinâmicas com pelo menos 32 inteiros cada uma.  Se cada inteiro ocupar 4 octetos (bytes), isso significa 12,8 Moctetos de memória ocupada e impossível de libertar.

Como evitar esta brutal "fuga" de memória?  Se se recordar que as instâncias de uma classe C++, ao serem destruídas, invocam o destrutor dessa classe, é claro que a solução é definir um destrutor para a classe PilhaDeInt que proceda à destruição da matriz dinâmica e consequente libertação da memória ocupada.

Note-se, a propósito, que o C++ procede à invocação dos destrutores de cada variável membro implicitamente, exista ou não destrutor explícito para a classe que as contém.  Ou seja, o C++ fornece sempre implicitamente um destrutor às classes definidas pelo utilizador, desde que estas não declarem um explicitamente.

Acontece, neste caso, que a matriz dinâmica é apontada pelo ponteiro itens, e que o destrutor dos ponteiros não destrói as variáveis apontadas.  Assim, a classe deverá passar a declarar, e definir, explicitamente um destrutor que se encarregue de destruir a matriz dinâmica dos itens:

class PilhaDeInt {
  public:

    ...

    /** Destrói a pilha.
       
@pre V.
       
@post recursos externos reservados foram libertados. */
    ~PilhaDeInt();

    ...

};

que se define simplesmente por

inline PilhaDeInt::~PilhaDeInt() 
{

    assert(cumpreInvariante());

    delete[] itens; // é uma matriz dinâmica, por isso delete[].
}

1.5  Pilhas de ponteiros

É importante não confundir as variáveis dinâmicas com os ponteiros que as apontam: são instâncias diferentes.  Suponha-se que a classe implementando o conceito de pilha era alterada para guardar ponteiros para inteiros passados do exterior, e não inteiros propriamente ditos.   Isso implicava apenas alterar a definição de Item para ser um sinónimo de int* e alterar o nome da classe para PilhaDePonteiroInt:

typedef int* Item;

Esta alteração é suficiente.  Aliás seria muito má ideia ter a tentação de libertar as variáveis apontadas por esses ponteiros na operação PilhaDePonteiroInt::tiraItem(), por exemplo, ou mesmo no destrutor da classe.  Ou seja:

inline void PilhaDePonteiroInt::tiraItem()
{

    assert(cumpreInvariante());
    assert(not estáVazia());

    delete itens[número_de_itens - 1]; // péssima ideia!

    --número_de_itens;

    assert(cumpreInvariante());
}

Porque é essa destruição má ideia?  Imagine-se a seguinte utilização:

PilhaDePonteiroInt pilha;
int i;

pilha.põe(&i);
pilha.tiraItem();

O que acontece ao tentar retirar o item da pilha?  O procedimento tenta destruir uma variável que nem sequer é dinâmica.  A variável i, é local e automática, e não dinâmica: é um erro gravíssimo tentar destruí-la através do operador delete.

1.6  Cópias

Regresse-se de novo às pilhas de inteiros.  O que acontece ao executar o seguinte código?

PilhaDeInt p1;
PilhaDeInt p2;

...

p2 = p1; // atribuição por cópia.

Isto é, o que acontece quando se atribui uma pilha a outra?  O operador de atribuição por cópia é fornecido implicitamente pelo C++, sempre que possível, a todas as classes C++ que não o declarem explicitamente.  Este operador de atribuição por cópia fornecido implicitamente limita-se a copiar os atributos variáveis um a um.  Se existirem atributos de instância constantes ou referências, o operador de atribuição por cópia não é fornecido implicitamente, como é evidente.  O mesmo acontece se a classe possuir atributos variáveis de instância pertencentes a classes C++ que não tenham, por sua vez operador, de atribuição por cópia.

Algo de semelhante se passa com o construtor por cópia, que também é fornecido implicitamente pela linguagem (excepto se a classe possuir atributos de instância pertencentes a classes C++ que não tenham por sua vez construtor por cópia).  Por exemplo, o que resulta do seguinte código?

PilhaDeInt p1;
PilhaDeInt p2(p1); // (ou PilhaDeInt p2 = p1;) construtor por cópia.

Em ambos os casos o resultado é desastroso (mais ainda no primeiro).  Porquê?  Simplesmente porque a variável membro itens da instância p2 passa a conter o mesmo endereço que a variável membro itens da variável p1, o que significa que ambas contêm o endereço da mesma matriz dinâmica.  I.e., alterações numa das pilhas passam a afectar a outra e vice-versa.  Uma situação muito indesejável, sobretudo se se levar em conta que inserir um item numa das pilhas afectará a matriz dinâmica comum, mas não as variáveis membro número_de_itens e capacidade_actual da outra, o que pode ser desastroso.  No caso da atribuição por cópia há ainda um problema adicional grave: perde-se o ponteiro para a matriz dinâmica original, e portanto há uma fuga de memória!

Em geral, as classes C++ que sirvam para concretizar TAD devem ser implementadas usando a chamada semântica de valor.  Isto significa que variáveis diferentes devem ser totalmente independentes, podendo no entanto tomar o mesmo valor.  Por exemplo, depois de

int i = 5;
int j = i;

as duas variáveis continuam perfeitamente independentes embora possuam o mesmo valor.  Uma posterior atribuição ou alteração de uma das variáveis não afecta a outra.  De igual forma se desejaria que depois de

PilhaDeInt p1;
PilhaDeInt p2;

...

p2 = p1; // atribuição por cópia.

as duas variáveis continuassem independentes, embora com o mesmo valor (que neste caso significa com o mesmo número de itens e com itens de valor idêntico).

No caso das pilhas o comportamento descrito acima, em que depois de uma atribuição por cópia ou de uma construção por cópia duas instâncias da classe partilham os seus "órgão internos", é altamente indesejável.  É como se as duas variáveis ficassem siamesas, em vez de gémeos verdadeiros, como se desejava.  É necessário, portanto, declarar (e definir) explicitamente o construtor por cópia e o operador de atribuição por cópia de tal forma que efectuem as operações com a semântica de valor desejada.

1.6.1  Construtor por cópia

O construtor por cópia é semelhante ao construtor já definido, mas tem um parâmetro que é uma referência constante para uma PilhaDeInt (no caso do construtor por cópia tem de se usar uma passagem por referência, tipicamente constante, pois se se usasse uma passagem por valor o próprio construtor por cópia seria invocado recursivamente para copiar o valor do argumento para o parâmetro respectivo...).

O construtor por cópia limita-se a construir a matriz dinâmica com a mesma dimensão que a da pilha passada como argumento, a pilha original, e enche-a com cópias dos seus itens, fazendo também cópias dos restantes atributos:

PilhaDeInt::PilhaDeInt(PilhaDeInt const& original)
    : capacidade_actual(original.capacidade_actual),
      itens(new Item[capacidade_actual]),
      número_de_itens(original.número_de_itens)
{

    // Copia os itens:
    for(int i = 0; i != número_de_itens; ++i)
        itens[i] = original.itens[i];

    assert(cumpreInvariante());
}

Naturalmente é necessário declarar o construtor por cópia na definição da classe:

class PilhaDeInt {
  public:

    ...

    /** Construtor por cópia.
       
@pre V.
       
@post *this = original. */
    PilhaDeInt(PilhaDeInt const& original);

    ...

};

1.6.2  Operador de atribuição por cópia

O caso do operador de atribuição por cópia é um pouco mais complicado.  Em primeiro lugar, é necessário recordar que a pilha à qual é feita a atribuição já possui ela própria uma matriz dinâmica, com endereço guardado na variável membro itens.  Se não se destruir essa matriz, ela permanecerá na memória depois da atribuição, constituindo assim uma fuga de memória.  Assim, no caso da atribuição por cópia, é necessário não só fazer a cópia, como aconteceu no caso do construtor por cópia, mas também libertar a matriz dinâmica pré-existente.  Ou seja, é necessário definir o operador como:

PilhaDeInt& PilhaDeInt::operator=(PilhaDeInt const& modelo)
{

    assert(cumpreInvariante());

    capacidade_actual = modelo.capacidade_actual;
    delete[] itens;
    itens = new Item[capacidade_actual];
    número_de_itens = modelo.número_de_itens;
 
    //
Copia itens:
    for(int i = 0; i != número_de_itens; ++i)
        itens[i] = modelo.itens[i];

    assert(cumpreInvariante());

    return *this;
}

Uma observação atenta do código revela que há um caso para o qual a libertação e subsequente reserva de memória é desnecessária: se ambas as matrizes dinâmicas tiverem o mesmo tamanho.  Assim, a função pode-se rescrever como:

PilhaDeInt& PilhaDeInt::operator=(PilhaDeInt const& modelo)
{
    assert(cumpreInvariante());

    if(capacidade_actual != modelo.capacidade_actual) {
        capacidade_actual = modelo.capacidade_actual;
        delete[] itens;
        itens = new Item[capacidade_actual];
    }

    número_de_itens = modelo.número_de_itens;
 
    for(int i = 0; i != número_de_itens; ++i)

        itens[i] = modelo.itens[i];

    assert(cumpreInvariante());

    return *this;
}

Acidentalmente, esta última alteração corrigiu também um erro grave da versão original.  A versão original daria resultados dramáticos se o utilizador se lembrasse de escrever:

p1 = p1;

Tente perceber porquê executando o corpo do operador passo a passo e lembrando-se que, neste caso particular, capacidade e modelo.capacidade são a mesma variável (a instância implícita *this e a referência modelo dizem respeito à mesma variável p1) tal como todas as outras variáveis membro (atenção ao ponteiro itens!).  Em particular verifique o que acontece durante a cópia dos itens...

De facto, no código original o que acontece é que a matriz dinâmica do modelo é destruída antes de construída a matriz dinâmica da cópia, pelo que, sendo ambas a mesma pilha na realidade, a cópia dos itens faz-se... a partir da matriz dinâmica acabada de construir.  Se os itens forem de tipos básicos do C++, isto equivale a encher a pilha com itens com valores arbitrários.  Se os itens forem de uma classe C++, ficarão todos inicializados com o construtor por omissão.

Para resolver este problema é típico envolver o corpo do operador de atribuição por cópia num teste que verifica se a instância implícita e a referência modelo a partir da qual a cópia será feita se referem à mesma variável.  Isso faz-se comparando os seus endereços, pois variáveis diferentes estão sempre em zonas de memória diferentes, com endereços diferentes: a identidade verifica-se em C++ comparando endereços!  Ou seja, a versão definitiva da função passa a ser:

PilhaDeInt& PilhaDeInt::operator=(PilhaDeInt const& modelo)
{
    assert(cumpreInvariante());

    if(this != &modelo) {
        if(capacidade_actual != modelo.capacidade_actual) {

            capacidade_actual = modelo.capacidade_actual;
            delete[] itens;
            itens = new Item[capacidade_actual];
        }

        número_de_itens = modelo.número_de_itens;
 
        for(int i = 0; i != número_de_itens; ++i)

            itens[i] = modelo.itens[i];
    }

    assert(cumpreInvariante());

    return *this;
}

Naturalmente é necessário declarar o operador de atribuição por cópia na definição da classe:

class PilhaDeInt {
  public:

    ...

    /** Atribuição por cópia.
       
@pre V.
       
@post *this = modelo. */
    PilhaDeInt& operator=(PilhaDeInt const& modelo);

    ...

};

1.7  Recomendação das três operações

"Uma classe que reserve recursos externos para utilização exclusiva pelas suas instâncias deverá geralmente fornecer explicitamente (a) um destrutor, (b) um construtor por cópia e (c) um operador de atribuição por cópia."

De facto, sempre que uma classe reserva recursos externos para utilização exclusiva pelas suas instâncias, há que ter cuidados acrescidos na definição dos seus construtores e há geralmente que declarar, e normalmente definir, explicitamente um destrutor que liberte esses recursos e versões do construtor por cópia e do operador de atribuição por cópia que implementem semântica de valor.  Em particular, o construtor por cópia deverá normalmente reservar os seus próprios recursos externos, o mesmo acontecendo com o operador de atribuição por cópia, que deverá previamente libertar os recursos que a instância já reservava ou, alternativamente, reciclá-los (como se fez no caso das pilhas acima).  O operador de atribuição por cópia deverá ainda ser implementado de modo a ter um comportamento apropriado mesmo quando o modelo e a instância a alterar são a mesma instância, i.e., mesmo quando são idênticas instâncias.

Alternativamente poderia ter-se protegido o operador de atribuição por cópia de modo a realizar a cópia apenas quando a instância a alterar e o modelo fossem diferentes.  Se forem iguais, para quê copiar?  No entanto, isso implicaria definir operadores de igualdade e diferença para a classe C++, o que nem seria má ideia, mas sobretudo isso implicaria que antes de proceder a uma cópia o código teria de percorrer as pilhas para verificar se de facto elas são diferentes.  A solução apresentada verifica se as pilhas são idênticas, e não iguais, pelo que de facto se evitam auto-atribuições, mas por outro lado se realizam cópias mesmo entre pilhas iguais: o trabalho de fazer as cópias dos itens entre as duas pilhas é semelhante ao de verificar se elas são iguais.

Finalmente, note-se a importante diferença que se estabeleceu entre identidade e igualdade.  Podemos resumir essa diferença dizendo que dois gémeos verdadeiros são iguais, mas não são idênticos, ou seja, não são a mesma pessoa.

Introdução às excepções

Como lidar com erros?  Esta é uma pergunta muito importante em programação, e a que não é fácil responder de uma forma taxativa.  A primeira coisa a fazer quando se discute o tratamento de erros é distinguir entre as suas possíveis origens.  Este assunto será tratado com maior cuidado numa aula posterior, distinguindo-se para já três origens para os erros:

  1. Erros do programador (produtor ou consumidor).  Verificados até agora com asserções.
  2. Erros do utilizador humano do programa.  Verificados com instruções condicionais e de selecção e corrigidos à custa de ciclos, por exemplo.
  3. Erros de recursos externos ao programa não humanos e portanto não corrigíveis.  Exemplos: falta de memória, ficheiros corrompidos, falta de espaço em disco, ficheiros inexistentes, etc.  Como lidar com este tipo de erros?

Por outro lado, quando se desenvolvem aplicações, surgem ocasionalmente casos excepcionais que, se se quiser que a aplicação seja capaz de lidar com eles, tendem a obrigar a um considerável aumento da complexidade do código, que deixa de ter um fluxo de execução tão claro.  Quando ocorrem semelhantes casos, é melhor tratá-los como tal, i.e., como excepções, usando as ferramentas que a linguagem C++ fornece para as suportar.

Existem várias abordagens possíveis para lidar com erros.  Até agora já se utilizaram as seguintes:

  1. Os erros são assinalados devolvendo valores especiais (no caso das funções) ou fazendo os procedimentos devolver um valor booleano indicando sucesso ou insucesso (que nesse caso se tornam nas odiadas rotinas dois-em-um").  Quem invoca as rotinas tem a obrigação de verificar se ocorreu um erro e lidar com esse facto apropriadamente.
  2. Os erros conduzem à terminação imediata do programa com uma mensagem de erro (uma versão sofisticada é a utilização de asserções [assert]).
A primeira solução usava-se muito na biblioteca padrão da linguagem C (disponível na biblioteca padrão do C++ através dos ficheiros de interface começados por 'c', como cstdlib).  Tem a vantagem de ser flexível, pois permite ao programador consumidor do código lidar com os erros da forma que lhe parecer mais conveniente.  Mas, como normalmente verificar todos os possíveis erros leva a programas muito complexos (cheios de "ifs" e por isso com muitos possíveis fluxos de execução), os programadores tendem a ignorar os valores devolvidos.  Na prática, portanto, esta é uma não-solução.

A segunda solução é demasiado drástica.  É verdade que muitas vezes é preferível abortar o programa a continuar depois de um erro grave.  Mas esta solução não deixa qualquer possibilidade ao programador consumidor do código que gerou o erro de lidar com ele, sendo certo que há circunstâncias em que é possível recuperar de situações de erro.

O C++ fornece um mecanismo que tem as vantagens de ambas as soluções: as excepções.  Ao ocorrer um erro diz-se que se lança uma excepção de um dado tipo.  Se o programador consumidor nada tiver feito, o programa aborta com uma mensagem apropriada.  Se o programador consumidor tiver preparado o seu código para capturar a excepção, o programa não aborta, sendo executado código específico, escrito pelo programador consumidor, para lidar com o erro.

Será um valor inválido introduzido pelo utilizador do programa um erro que mereça o lançamento de uma excepção, sendo o programa interactivo?  Não.  Será um erro violar as pré-condições de uma função ou procedimento, passando argumentos inválidos?  Claramente.  Deverá nesse caso ser lançada uma excepção?  Para já não, embora este assunto seja discutido em pormenor mais tarde.  Assim, as excepções ficarão reservadas para já para lidar com erros nos recursos externos de um programa e com situações que façam claramente parte das que devem ser previstas pelo programa, mas que constituam casos suficientemente excepcionais para não se justificar aumentar a complexidade do código de modo a lidar directamente com eles, preferindo-se lançar um excepção que assinala esse caso e ter a esperança que algum outro pedaço de código seja capaz de lidar com ele e, por isso, capture essa excepção.

2.1  Definindo excepções

As excepções são instâncias de qualquer tipo, incluindo classes C++ definidas pelo utilizador.  Por exemplo, uma excepção para assinalar que se tentou inserir um item numa pilha cuja capacidade se esgotou e não foi possível reservar mais memória poderia ser simplesmente uma instância da classe:

class PilhaDeIntMemóriaEsgotada {
};

Ou, usando classes embutidas:

class PilhaDeInt {
  public:

    ...

    /* Nota importante: No final verificar-se-á que é má ideia usar esta classe!
       Leia o texto até ao fim! */
    class MemóriaEsgotada {
    };

    ...

};

As classes cujas instâncias são usadas como excepção servem mais para distinguir entre tipos de excepções (erros), do que para guardar dados, embora também se possam usar com esse objectivo, servindo os dados para guardar informação pormenorizada sobre as circunstâncias que lhes deram origem.  Assim, é comum encontrarem-se classes C++ usadas para excepções sem quaisquer membros.

Como se utilizam as excepções?

2.2  Lançando excepções

Suponha-se que se pretende assinalar um erro no procedimento membro PilhaDeInt::põe() quando a pilha estiver na sua capacidade máxima e não for possível construir a nova matriz dinâmica dos itens.  Pode-se definir o procedimento como se segue:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual) {

        Item* const novos_itens = new Item[capacidade_actual * 2];

        se a construção falhou faça-se
            throw MemóriaEsgotada();

        capacidade_actual *= 2;

        for(int i = 0; i != número_de_itens; ++i)
            novos_itens[i] = itens[i];

        delete[] itens;

        itens = novos_itens;
    }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Ou seja, se a pilha estiver no limite de capacidade e não for possível construir uma nova matriz dinâmica dos itens, é lançada uma excepção que é uma instância da classe PilhaDeInt::MemóriaEsgotada.  Note-se bem: uma instância da classe, e não uma classe, daí a necessidade dos parênteses após o nome da classe, que provocam a invocação do construtor por omissão da classe e portanto a construção duma nova instância da classe.

Note-se que se a excepção for lançada a pilha fica rigorosamente no estado em que estava originalmente.  Foi por isso que se atrasou a operação de duplicação do atributo capacidade_actual, pois de outra forma a pilha ficaria num estado inconsistente, i.e., sem que se cumprisse a sua condição invariante, quando fosse lançada uma excepção.  Este tipo de preocupação é muito importante e será visto com mais pormenor mais à frente.

Por vezes os construtores das classes de excepções têm parâmetros que identificam melhor o erro.  Nesse caso colocam-se argumentos entre parênteses na instrução de lançamento da excepção.

2.3  Capturando excepções e a excepção bad_alloc

O código acima não está completo.  Como saber se uma utilização do operador new[] teve sucesso?  Acontece que, em caso de insucesso, o operador new lança uma excepção: std::bad_alloc, que está definida no ficheiro de interface padrão new (fazer #include <new>).  Como pretendemos, como programadores produtores da classe,  lidar com essa excepção, i.e., capturá-la, temos de envolver o código onde a excepção pode ser lançada num bloco de tentativa, dizendo explicitamente o que fazer quando uma excepção de um determinado tipo é capturada *:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual)
        try {

            Item* const novos_itens = new Item[capacidade_actual * 2];

            capacidade_actual *= 2;

            for(int i = 0; i != número_de_itens; ++i)
                novos_itens[i] = itens[i];

            delete[] itens;

            itens = novos_itens;
        } catch(std::bad_alloc) {
            throw MemóriaEsgotada();
        }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Curiosamente neste caso captura-se uma excepção simplesmente para a substituir/traduzir por outra.  I.e., quando se captura uma excepção do tipo std::bad_alloc, lança-se uma excepção do tipo PilhaDeInt::MemóriaEsgotada.

* Para capturar qualquer tipo de excepção, usar catch(...).

2.4  Excepções com dados

A classe excepção definida pode ser melhorada.  Em particular pode ser vantajoso indicar qual a capacidade para a qual não foi possível obter uma nova matriz dinâmica.  

class PilhaDeInt {
  public:

    ...

    class MemóriaEsgotada {
      public:
        MemóriaEsgotada(int dimensão_pretentida)
            : dimensão_pretendida(dimensão_pretendida) 
        {
        }
        int dimensãoPretendida() const
        {
            return dimensão_pretendida;
        }

      private:
        int dimensão_pretendida; 
    };

    ...

};

Nesse caso o método PilhaDeInt::põe() seria:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual)
        try {

            Item* const novos_itens = new Item[capacidade_actual * 2];

            capacidade_actual *= 2;

            for(int i = 0; i != número_de_itens; ++i)
                novos_itens[i] = itens[i];

            delete[] itens;

            itens = novos_itens;
        } catch(std::bad_alloc) {
            throw MemóriaEsgotada(capacidade * 2);
        }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

A captura da excepção poderia ser feita como se segue:

#include <iostream>

using namespace std;

#include "pilha_de_int.H"

int main()

{
    try {
        PilhaDeInt p;

        ...

    } catch(PilhaDeInt::MemóriaEsgotada& excepção) {
        cout << "Estoirou ao tentar aumentar capacidade para " 
             << excepção.dimensãoPretendida() << "!" << endl;

    }
}

Note-se que neste caso se passaram argumentos ao construtor da excepção e que portanto o código que lida com erro recebe mais informação, nomeadamente qual a capacidade pretendida para a pilha que conduziu à excepção PilhaDeInt::MemóriaEsgotada.  Note-se também que para receber a informação da excepção faz-se a captura da excepção nomeando uma variável (neste caso uma referência) para a conter depois de capturada (neste caso de nome excepção).

O mesmo bloco de tentativa pode capturar tipos de excepção diferentes.  Por exemplo:

#include <iostream>

using namespace std;

#include "pilha_de_int.H"

int main()

{
    try {
        PilhaDeInt p;

       
...

    } catch(PilhaDeInt::MemóriaEsgotada excepção) {
        cout << "Estoirou ao tentar aumentar capacidade para " 
             << excepção.dimensãoPretendida() << "!" << endl;

    } catch(...) {
        cout << "Ooops...  Outra excepção qualquer..." << endl;
    }
}

Finalmente, todo o corpo de uma função ou procedimento pode consistir num grande bloco de tentativa.  Por exemplo:

#include <iostream>

using namespace std;

#include "pilha_de_int.H"

int main()

    try {
        PilhaDeInt p;

       
...
    } catch(PilhaDeInt::MemóriaEsgotada excepção) {
        cout << "Estoirou ao tentar aumentar capacidade para " 
             << excepção.dimensãoPretendida() << "!" << endl;

    } catch(...) {
        cout << "Ooops...  Outra excepção qualquer..." << endl;
    }

Como é óbvio, um bloco de tentativa pode constar em qualquer rotina ou método (e não apenas em main()).

2.5  Segurança face a excepções

Na realidade o exemplo dado acima não faz muito sentido.  Se não houver memória suficiente, o melhor é não capturar a excepção std::bad_alloc, deixando que seja código a montante a lidar com ela.  Traduzir excepções raramente é uma solução inteligente.

Muito mais importante do que lidar directamente com excepções é preparar o código para que seja seguro de utilizar mesmo que sejam lançadas excepções.  Voltemos ao código original do método PilhaDeInt::põe():

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual) {

        capacidade_actual *= 2;
        Item* const novos_itens = new Item[capacidade_actual];

        for(int i = 0; i != número_de_itens; ++i)
            novos_itens[i] = itens[i];

        delete[] itens;

        itens = novos_itens;
    }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Que sucede se o operador new[] lançar uma excepção?  O fluxo de execução é interrompido, sendo retomado no primeiro bloco de captura encontrado a montante que esteja preparado para capturar a excepção.  Por exemplo, se no código

...

void f(PilhaDeInt& pilha)
{
    PilhaDeInt outra_pilha;

    pilha.põe(100);
}

void g(PilhaDeInt& pilha)
{
    PilhaDeInt ainda_outra_pilha;

    f(pilha);
}

int main()
{
    PilhaDeInt pilha;

    try {
        g(pilha);
    } catch(std::bad_alloc) {
        cout << "Falta de memória!" << endl;
    } catch(...) {
        cout << "Outro erro!" << endl;
    }
}

for lançada uma excepção ao adicionar um item à pilha, o fluxo de execução abandona o procedimento f(), o procedimento g() que o invocou, e abandonaria a função main(), se esta não tivesse um bloco de tentativa com uma captura da excepção lançada.  Mas o processo de abandono é controlado.  No exemplo acima esse abandono garante a destruição das variáveis locais outra_pilha e ainda_outra_pilha, apesar de não se ter atingido o final do bloco de instruções onde elas estão definidas.

No método PilhaDeInt::põe() esse abandono tem, no entanto, uma consequência nefasta: se for lançada uma excepção, o atributo capacidade_actual foi já alterado (o valor foi duplicado) sem que na realidade a matriz dinâmica apontada pelo atributo itens tenha sido alterado.  Ou seja, o invariante da classe tornou-se falso!  Uma solução para o problema passa por alterar a ordem pela qual as instruções do método são invocadas de modo a garantir que a pilha fica num estado válido (i.e., cumprindo o invariante), apesar do lançamento de uma excepção:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual) {

        capacidade_actual *= 2;
        Item* const novos_itens = new Item[capacidade_actual * 2];

        capacidade_actual *= 2;

        for(int i = 0; i != número_de_itens; ++i)
            novos_itens[i] = itens[i];

        delete[] itens;

        itens = novos_itens;
    }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Este tipo de problema é mais profundo do que parece à primeira vista, e não é fácil lidar com ele.  Suponha-se, por exemplo, que a classe PilhaDeInt era alterada de modo a conter cadeias de caracteres.  Ou seja, mudava-se o nome da classe para PilhaDeString e definia-se Item como sinónimo de string.  Acontece que a classe string usa memória livre para guardar os caracteres que compõem a cadeia de caracteres, e que, por isso, pode precisar de reservar memória dinâmica durante uma atribuição entre duas instâncias da classe.  Isso significa que a excepção std::bad_alloc pode ser lançada durante a cópia dos itens da matriz original para a sua nova versão, com a capacidade duplicada.  Se isso acontecer, o fluxo de execução abandona o método, deixando não apenas o atributo capacidade_actual com o valor errado, facto que se resolve facilmente movendo de novo a instrução que lhe duplica o valor, mas sobretudo deixa a nova matriz dinâmica construída em memória sem nenhum ponteiro que lhe permita aceder, pois a variável novos_itens, sendo local, é destruída.

Infelizmente, neste caso é necessário capturar a excepção para lidar com a situação:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual) {

        Item* const novos_itens = new Item[capacidade_actual * 2];

        capacidade_actual *= 2;

        try {
            for(int i = 0; i != número_de_itens; ++i)
                novos_itens[i] = itens[i];
        } catch(...) {
            delete[] novos_itens;
        }

        capacidade_actual *= 2;

        delete[] itens;

        itens = novos_itens;
    }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Note-se que se decidiu capturar qualquer excepção.  Desta forma, se classe dos itens lançar um qualquer tipo de excepção durante a atribuição entre os itens, tem-se a garantia de que a matriz dinâmica dos novos itens é destruída.  Infelizmente o código não se pode ficar por aqui.  É que o código acima captura a excepção, destrói a matriz dinâmica, e depois continua como se nada tivesse sucedido!  Nada mais errado.  O que se pretendia era simplesmente lidar com a libertação da matriz dinâmica em caso de erro, e não lidar com as excepções em si!  A solução passa por, depois de destruir a matriz dinâmica, relançar a excepção capturada.

Se excepção capturada tivesse sido de uma tipo específico, relançá-la seria fácil:

try {
    for(int i = 0; i != número_de_itens; ++i)
        novos_itens[i] = itens[i];
} catch(std::bad_alloc& excepção) {
    delete[] novos_itens;

    throw excepção;
}

No entanto, a melhor forma de relançar uma excepção capturada num bloco de captura é escrever simplesmente

throw;

Esta forma tem a vantagem adicional de garantir o relançamento independentemente de ser ou não conhecido o tipo da excepção.  Ou seja, funciona mesmo que se tenha usado a forma catch(...) de captura indiscriminada.  Assim, o código final fica:

void PilhaDeInt::põe(Item const& novo_item) 
{

    assert(cumpreInvariante());

    if(número_de_itens == capacidade_actual) {

        Item* const novos_itens = new Item[capacidade_actual * 2];

        capacidade_actual *= 2;

        try {
            for(int i = 0; i != número_de_itens; ++i)
                novos_itens[i] = itens[i];
        } catch(...) {
            delete[] novos_itens;

            throw;
        }

        capacidade_actual *= 2;

        delete[] itens;

        itens = novos_itens;
    }

    itens[número_de_itens] = novo_item;
    ++número_de_itens;

    assert(cumpreInvariante());
}

Este método está, agora, "à prova de bala".  Porquê?  Porque nenhuma das restantes instruções pode lançar excepções: as operações sobre tipos básicos do C++ e sobre ponteiros nunca lançam excepções.

O objectivo do código produzido foi garantir que a instância da pilha não sofre qualquer alteração no caso de a inserção de um novo item falhar.  Trata-se de fornecer a operação PilhaDeInt::põe() da garantia forte de segurança face a excepções.  Há três tipos de garantias de segurança face a excepções que podem ser fornecidas:

  1. Nenhuma garantia - Evitar a todo o custo!
  2. Garantia básica - A operação que falhou (por ter sido lançada uma excepção) pode ter produzido alguns efeitos irreversíveis, mas deixou o programa num estado aceitável (e.g., todos os invariantes são verdadeiros).
  3. Garantia forte - A operação que falhou (por ter sido lançada uma excepção) não produziu quaisquer efeitos.  É como se nunca tivesse sido tentada.
  4. Não lança excepções - A operação nunca lança excepções: tem sempre sucesso.

Obviamente que se deve pugnar para obter sempre a garantia mais forte.  Garantir o não-lançamento de excepções é em geral difícil.  Mas há que tentar garanti-lo.  Quando não se puder evitar o lançamento de excepções, a garantia básica só se deve usar se o esforço de fornecer a garantia forte for demasiado pesado (e.g., computacionalmente) para se justificar.  A ausência de garantias é inaceitável.  

Feitas estas observações, torna-se claro que o código produzido até agora precisa de ser revisto.  Nomeadamente o construtor por cópia e a atribuição por cópia não fornecem nenhuma garantia de segurança face a excepções, o que é inaceitável!

Código completo

O código completo da classe PilhaDeInt encontra-se no módulo físico pilha_int, constituído pelos ficheiros pilha_int.H, pilha_int_impl.H e pilha_int.C.