Resumo da Aula 11

Sumário

Classes

(Continuação do resumo anterior.)

1.1  Sobrecarga de operadores

É possível usar as operações de uma classe para sobrecarregar operadores.

1.1.1  Sintaxe

Como rotina normal:

tipo_devolvido operator nome_operador(lista_de_parâmetros);

Como operação:

class nome_da_classe {
  public:

    ...

    tipo_devolvido operator nome_operador(lista_de_parâmetros);

    ...

};

Quando a sobrecarga de um operador é feita por intermédio de uma operação de uma classe, o primeiro operando deste operador é a instância da classe que está implícita durante a invocação do operador.  Assim, não existe parâmetro correspondente na declaração/definição do operador.

1.1.2  Exemplo

Definição da classe Racional e declaração de alguns operadores:

/** Representa números racionais.
   
@invariant 0 < denominador_ e mdc(numerador_, denominador_) = 1. */
class Racional {

  public:
    /**
Constrói um novo racional com valor numerador/1.
        @pre V.
       
@post Racional = numerador. */
    Racional(int const numerador = 0);

    /** Constrói um novo racional com valor numerodor/denominador.
        @pre V.
       
@post Racional = numerador/denominador. */
 
   Racional(int const numerador, int const denominador);

    /** Devolve o numerador do racional.
        @pre V.
       
@post numerador = numerador do racional. */
    int numerador();

    /** Devolve o denominador do racional.
        @pre V.
       
@post denominador = denominador do racional. */
    int denominador();

   ... // outros membros públicos da classe Racional.
    /** Devolve a soma do racional com o racional recebido como argumento.
        @pre V.
       
@post operator+ = soma do racional com outro_racional. */
    Racional const operator+(Racional const outro_racional); 
    //
Repare-se que este operador tem apenas um parâmetro...
  private:
    int numerador_;
    int denominador_;

    /** Reduz a representação do racional, que mantém o seu valor.
        @pre numerador_ = n e denominador_ = d e 0 < denominador_.
       
@post 0 < denominador_ e mdc(numerador_, denominador_) = 1 e
              numerador_/denominador = n/d. */
    void reduz();

};

int Racional::numerador()
{
    return numerador_;
}

int Racional::denominador()
{
    return denominador_;
}

// ...enquanto este operador tem dois argumentos, apesar de ambos serem
// utilizados da mesma forma.
Racional const operator-(Racional const um_racional,
                         Racional const outro_racional);

// Repare-se que este operador não pode ser membro da classe Racional
//
porque o primeiro operando não é um Racional.
ostream& operator<<(ostream& out, Racional const um_racional);

Qualquer operador que tenha como primeiro operando uma instância de uma classe pode ser declarado como operação dessa classe.  Exemplo: os operadores + e - declarados acima poderiam ser ambos operações da classe.

Qualquer operador que não tenha como primeiro operando uma instância de uma classe não pode ser declarado como operação dessa classe.  Exemplo: o operador de inserção << declarado acima.

Definição dos operadores:

Racional const Racional::operator+(Racional const outro_racional)
{
    Racional resultado(numerador() * outro_racional.denominador() +
                       outro_racional.numerador() * denominador(),
                       denominador() * outro_racional.denominador());
    return resultado;
    /* ou simplesmente:
    return Racional(numerador() * outro_racional.denominador() +
                    outro_racional.numerador() * denominador(),
                    denominador() * outro_racional.denominador());
    */
}

 Tal como para os métodos usuais, a definição dos operadores membro precisa de indicar a sua pertença à classe (através do prefixo Racional::).

Os operadores membro têm acesso aos membros privados da classe, enquanto os não membro têm de utilizar os membros públicos da classe, tal como acontece, de resto, com quaisquer rotinas.  No entanto, é boa ideia recorrer a outras operações da classe para obter os valores do numerador e do denominador, em vez de aceder a eles directamente.

Racional const operator-(Racional const um_racional, Racional const outro_racional)
{
    return Racional(um_racional.numerador() * outro_racional.denominador() -
                    outro_racional.numerador() * um_racional.denominador(),
                    um_racional.denominador() * outro_racional.denominador());
}

ostream& operator<<(ostream& saída, Racional const um_racional)
{
    saída << um_racional.numerador();
    if(um_racional.denominador() != 1)
        saída << '/' << um_racional.denominador();

    return saída;
}

Utilização:

Racional a(1, 3), b(2, 3), d, e;

Racional d = a + b;
Racional e = a - b;

cout << d << endl;
cout << e << endl;
cout << b - a << endl;

1.2  Construtores: conversões implícitas

Os construtores podem ser invocados explicitamente para criar um novo valor dessa classe.  Por exemplo:

Racional r;
r = Racional(1, 3);

Os construtores que podem ser invocados com apenas um argumento definem (por omissão) uma conversão implícita de tipos.  Por exemplo, o primeiro construtor da classe Racional pode ser chamado com apenas um argumento do tipo int.  Assim, sempre que esperar um Racional e encontrar um int, o compilador converte o int implicitamente para um valor Racional.  Por exemplo, estando definido um operator+ com operandos do tipo Racional, o seguinte pedaço de código é perfeitamente legal

Racional x(1, 3);
Racional z = x + 1;

tendo o mesmo significado que

Racional x(1, 3);
Racional z = x + Racional(1);

que coloca em z o racional 4/3.

Em casos em que esta conversão implícita de tipos é indesejável, pode-se preceder o respectivo construtor da palavra chave explicit.  Se a classe Racional estivesse definida como

...

class Racional {

    ...

  public:

    ...

    explicit Racional(int const numerador = 0);

    ...

};

o compilador assinalaria erro ao encontrar a expressão x + 1.  Neste caso, no entanto, a conversão implícita de int para Racional é útil, pelo que o qualificador explicit não é desejável.

1.3  Condição invariante de classe

As instâncias de uma classe só estão bem definidas se os seus atributos verificarem um conjunto de restrições.  A este conjunto de restrições chama-se condição invariante de classe (CIC).  No caso da classe Racional, a condição invariante de indica que os racionais estão sempre representados por uma fracção em termos mínimos (i.e., que mdc(numerador, denominador) = 1) e que o denominador é sempre positivo (i.e., que denominador > 0):

CIC: 0 < denominador e mdc(numerador, denominador) = 1.

Neste caso a condição invariante de classe tem a vantagem de garantir que não apenas a cada fracção corresponde um único racional, mas também que a cada racional corresponde uma única fracção numerador_/denominador_.  I.e., a representação escolhida é única.  Por exemplo, os racionais definidos por:

Racional r1(-2, 6);
Racional r2(4, -12);
Racional r3(-1, 3);

são todos representados pelos atributos numerador_ = -1 e denominador_ = 3, respectivamente.

São os construtores da classe que se encarregam de garantir que os atributos da instância construída cumprem inicialmente a condição invariante de classe.  Todas as outras operações públicas da classe

  1. admitem que inicialmente as instâncias da classe que recebem como argumento ou sobre as quais actuam cumprem a condição invariante de classe e
  2. garantem que no seu final as instâncias que geram ou afectam cumprem a condição invariante de classe.
Já as operações privadas da classe (e.g., Racional::reduz()) podem agir sobre instâncias da classe que não cumprem a condição invariante de classe, e podem, se isso for do interesse do programador produtor, não garantir o seu cumprimento quando terminam.  A razão é simples: as operações privadas são auxiliares, fazendo parte da implementação, e não da interface, da classe.

É possível definir uma operação auxiliar para a classe com o objectivo de devolver a veracidade da condição invariante de classe e usá-la para verificar essa condição para todas as instâncias envolvidas num método correspondente a uma operação pública, quer no seu início, quer no seu fim:

...

class Racional {

    ...

  private:

    ...

    /** Indica se a instância cumpre a condição invariante de classe.
       
@pre V.
       
@post cumpreInvariante = 0 < denominador e
              mdc(numerador, denominador) = 1. */
    bool cumpreInvariante();

};

...

bool Racional::cumpreInvariante()
{
    return 0 < denominador_ and mdc(numerador_, denominador_) == 1;
}

...

Racional const Racional::operator+(Racional const outro_racional)
{
    assert(cumpreInvariante() and 
           outro_racional.cumpreInvariante());

    Racional resultado(numerador() * outro_racional.denominador() +
                       outro_racional.numerador() * denominador(),
                       denominador() * outro_racional.denominador());

    assert(cumpreInvariante() and
           resultado.cumpreInvariante())

    return resultado;
}

...

1.4  Listas de inicializadores

A inicialização dos atributos feita no construtor da classe deve e, por vezes, tem de recorrer a listas de inicializadores.  Por exemplo, no caso da classe Racional:

Racional::Racional(int const numerador)
    : numerador_(numerador), denominador_(1)
{

    assert(cumpreInvariante());
}

Os atributos devem surgir na lista pela mesma ordem pela qual são definidos na classe.  Neste caso o numerador é inicializado com o valor do parâmetro numerador e o denominador com 1.

1.5  Devolução de referências

As funções podem devolver referências para variáveis, em vez de valores simples.  Por exemplo:

int cópia(int& n)
{
    return n;
}

int& mesmo(int& n)
{
    return n;
}

No primeiro caso é devolvida uma variável temporária (e sem nome, dita anónima) que é uma cópia da variável n.  No segundo é devolvida uma referência para a variável n, ou seja, um seu sinónimo.  Ambas as funções se podem usar como se segue:

int i = 11;
int j = cópia(i);
int k = mesmo(i);

ficando as variáveis j e k com o valor 11.  Mas só a segunda se pode usar do lado esquerdo de uma atribuição:

int i = 11;
cópia(i) = 15; // erro!
mesmo(i) = 15; // ok! coloca 15 em i.

pois a variável devolvida pela primeira função é temporária, não fazendo sentido atribuir-lhe um novo valor.  Ainda que fosse possível, a variável i não seria afectada, pois a função cópia() devolve uma cópia de i.  A função mesmo() devolve um sinónimo de i, pelo que ao atribuir-lhe 15 está-se a afectar i.

1.6  A instância implícita

Durante a execução de um método existe sempre uma instância implícita (desde que método seja de instância, e não de classe, como se verá mais tarde), que é a instância através da qual se invocou a respectiva operação.  Os métodos têm acesso directo aos atributos da instância implícita.  Por exemplo, durante a execução da operação Racional::denominador() invocada no código abaixo

Racional r(1, 3);
cout << r.denominador() << endl;

o nome denominador_ refere-se à variável membro do mesmo nome da instância implícita, que neste caso é a variável r.

É possível referir explicitamente a instância implícita (completa) através da construção *this.  Por exemplo, o operador += para os racionais pode-se definir como:

...

class Racional {

    ...

    /** Adiciona o racional recebido como argumento à instânci
       
implícita, devolvendo-a por referência.
       
@pre *this = r.
       
@post *this = r + outro_racional e operator+= idêntico a *this. */
    Racional& operator+=(Racional const outro_racional);

    ...

};

Racional& Racional::operator +=(Racional const outro_racional)
{
    assert(cumpreInvariante() and outro_racional.cumpreInvariante());

    numerador_ = numerador() * outro_racional.denominador() +
                 outro_racional.numerador() * denominador();
    denominador_ *= outro_racional.denominador();

    reduz();

    assert(cumpreInvariante());

    return *this;
}

A devolução de uma referência para a instância implícita, que neste caso é o primeiro operando do operador +=, permite que este operador seja invocado em sequência:

Racional r(1, 2), s(1, 4), t(3, 4),

r += s += t;

o que coloca ½ + ¼ + ¾ = 3/2 em r.

Note-se que, depois de definido este operador, o operador de soma simples pode ser definido fora da classe, como uma rotina normal e de forma muito simples:

Racional const operator+(Racional um_racional, Racional const outro_racional)
{
    um_racional += outro_racional; // um_racional é passado por valor, a alteração não
                                   // afecta o argumento!
    return um_racional;
    // ou simplesmente return um_racional += outro_racional;
}

1.7  Referências constantes

É possível definir referências constantes.  Nesse caso a referência continua a ser um sinónimo de uma variável, mas são proibidas as alterações da variável através do seu sinónimo.  Por exemplo:

int i = 10;
int const& rci = i; // as referências têm de ser sempre inicializadas!
int& ri = i;

ri = 12;  // altera i.
rci = 13; // erro de compilação!

int const j = 14;
int& rj = j; // são proibidas referências não constantes para uma constante!

Como as passagens por valor exigem a inicialização por cópia dos parâmetros a partir dos argumentos respectivos, o que pode ser uma operação dispendiosa em tempo e memória, é comum usarem-se passagens de argumentos por referência constante em vez de passagens por valor.  Por exemplo:

...

class Racional {

    ...

    Racional& operator+=(Racional const& outro_racional);

    ...

};

...

Racional& Racional::operator +=(Racional const& outro_racional)
{
    assert(cumpreInvariante() and outro_racional.cumpreInvariante());

    numerador_ = numerador() * outro_racional.denominador() +
                 outro_racional.numerador() * denominador();
    denominador_ *= outro_racional.denominador();

    reduz();

    assert(cumpreInvariante());

    return *this;
}

Racional const operator+(Racional um_racional, Racional const& outro_racional)
{
    // Note-se que um_racional continua passada por valor, para ser uma cópia e poder ser
    // alterado localmente!
    return um_racional += outro_racional;
}

1.8  Operações constantes

Podem-se definir constantes de uma classe.  Por exemplo:

Racional const um = 1; // o mesmo que um(1);

Como é evidente, o C++ deve proibir qualquer tentativa de alterar uma constante.  Mas toma uma atitude porventura demasiado conservadora a esse respeito: toda e qualquer operação é suspeita de alterar a instância para a qual é invocada, excepto se declarada explicitamente como constante.  Por exemplo, o código

cout << um.denominador() << endl;

gera um erro de compilação, pois o compilador admite que a operação Racional::denominador() altera potencialmente a constante um.  Para deixar bem claro que isso não acontece, a operação tem de ser declarada, e o respectivo método definido, com o qualificador const, que se coloca logo após o cabeçalho.  Ou seja:

...

class Racional {

    ...

    int denominador() const;

    ...

};

...

int Racional::denominador() const
{
    return denominador_;
}