Universidade Federal de Minas Gerais
Instituto de Ciências Exatas
Departamento de Ciência da Computação

Algoritmos e Estruturas de Dados I

Definição e uso de classes

 

As classes na linguagem C++ nos permitem definir tipos. Para poder formar um modelo mental inicial (é apenas um auxílio mental!) podemos imaginar que as classes são como conjuntos e as instâncias/objetos são como elementos destes conjuntos. Este modelo mental só será útil se a pessoa não confundir os conceitos da Matemática com os conceitos da Computação. Estamos falando aqui do conceito de classe da Computação, mais especificamente o conceito de classe da linguagem C++

OBS: na gênese de C++ houve uma decisão de estender o elemento “struct” da linguagem C para definir o elemento class de C++. Mas não é o caso de poder falar que o termo “class” de C++ possa sempre ser substituido pelo termo “struct”. De forma bem simplificada a concepção inicial de C++ consistiu em como estender o conceito de struct (que traduz a noção de “estado”) de forma a incluir a definição de funções (que traduz a noção de “comportamento”).

O conceito de classe, categoria, conjuntos da Matemática são bastante distintos do conceito de classe na Computação. Para perceber um aspecto bem distinto de (i)classe C++ e (ii) conjunto, considere o problema da categoria dos elementos: só podem ser objetos da classe X objetos do tipo X (a classe define um tipo) mas na matemática um conjunto pode envolver elementos de qualquer tipo, você pode definir de forma extensiva um conjunto Y constituído de 3 elementos: um livro, uma idéia e um barulho. Conforme veremos abaixo as classes C++ têm uma relação muito forte com a questão da tipagem.


As classes servem para definir estrutura e comportamento. Na linguagem C++ as classes constituem um dos mecanismos de definição de tipos. O programador através da definição de classes define novos tipos. As classes e seus objetos(suas instâncias) nos permite compor programas dentro do paradigma denominado Programação Orientada a Objetos. A estrutura corresponde, tipicamente, a variáveis ou campos e o comportamento corresponde a métodos (funções).Na linguagem C++ é comum referir aos elementos de uma classe como sendo seus “membros”, e os métodos referidos como “funções”. Nossos exemplos aqui são artificiais e simples para simplificar os conceitos. Também para efeito de simplificação, discutimos apenas algumas opções de definição e uso de classes.

No programa abaixo temos a definição de uma classe Dado (die/dice) com uma estrutura bem simples (um atributo do tipo int que descreve o estado relativo à face ) e dois comportamentos: obterFace() e rolar().

#include <cstdlib>
#include <iostream>
#include <ctime>
using namespace std;

class Dado{
  private: //default
    int face;
  public:
    Dado(){srand(time(0));rolar();};
    const static int FACES=6;
    int obterFace();
    void rolar();
};

int Dado::obterFace(){return face;}
void Dado::rolar(){face=rand()%FACES + 1;}


int main(int argc, char *argv[])
{
    Dado d1;
    d1.rolar();
    cout<< d1.obterFace()<<"\n";
    system("PAUSE");
    return EXIT_SUCCESS;
}

Exercício: Utilize o programa acima como base para escrever um programa que testa a frequência de valores relativos ao lançamento de dois dados.

Um possível esquema para definição e utilização de uma classe é portanto (de forma sintética):

classe NomeClasse{/*definição*/}

...

NomeClasse variavel;//alguns autores querem diferenciar variável e instância

 

Devemos introduzir as classes envolvendo significado ou de forma sintética?

O mecanismo de classe define um tipo e podemos usar este tipo para definir instâncias (variáveis). Quando o fluxo de controle chega até uma definição de variável que utiliza uma classe: (i) o código de todas as classes envolvidas será carregado na memória e (ii) é alocada e inicializada a memória para as variáveis de classe das classes envolvidas; por fim a memória para as instâncias de classes é alocada e são feitas as inicializações. No caso da classe Dado existe uma única variável de instância a variável (campo) particular/privada face e cada variável tera um campo face. Devemos acessar os elementos de C (variáveis e funções) usando o operador de seleção (ponto). Podemos usar o operador de seleção no nome da instância e selecionar o campo ou função de instância.

Na definição da classe Dados temos ainda a definição de uma constante “estática” identificada por FACES. A constante FACES não é replicada em cada instância! Existe apenas uma constante FACES na classe Dado, compartilhada por todas as instâncias. O trecho:

class Dado{… int face; … const static int FACES=6;}
...
Dado d1, d2;

Corresponde ao seguinte desenho:

Em memória temos a classe Dado (código e dados) indicado pelo retângulo à esquerda, e os dois objetos correspondentes às variáveis d1 e d2 indicados à direita. As setas azuis indicam que d1 e d2 sabem que são instâncias da classe Dado. Observe que uma definição de estrutura (struct) na linguagem C normalmente não envolve espaço de memória em tempo de execução, mas as classes C++ normalmente ocupam a memória. A ocupação de memória das classes envolve funções e variáveis de classe (estáticas).

 

Construtores

Além de métodos e campos uma classe pode definir elementos denominados construtores (Constructors). Se o programador não definir um construtor de forma explícita o compilador irá prover um construtor default. Um possível esquema para definição de construtores é:

class NomeClasse{ ... NomeClasse(){...} ...};

Os construtores são elementos de uma classe “parecidos” com funções mas que têm o mesmo nome da classe. Uma classe pode definir vários construtores, todos eles devem ter o nome da classe e diferem entre sí pelo número e tipo dos parâmetros:

class NomeClasse{ ... NomeClasse(){...}; NomeClasse(int p){...}; NomeClasse(double d){...}; ...};

O construtor gerado pelo compilador (ver abaixo) ou um construtor definido pelo programador que não exija parâmetros, são todos referidos como “construtor default”. O “construtor default”  é “chamado/invocado” de forma similar a um método (é criado um registro de ativação!) de forma IMPLÍCITA no momento em que um objeto é criado (definições sem parâmetros). O programador pode invocar o construtor de forma EXPLÍCITA e neste caso o compilador não gera a chamada implícita do construtor default. Observe que na classe Dado acima o construtor default atribui a semente para geração de valores randômicos a partir da hora do sistema, em seguida faz a primeira invocação do método rolar() para a instância correspondente.

Construtores no caso de arranjos

Quando definimos um arranjo de instâncias de classe, cada elemento terá seu construtor (eventualmente um construtor default!!)  invocado de maneira automática:
#include <cstdlib>
#include <iostream>
using namespace std;

class C{ public: C(){cout<<"oi!"<<endl;}};

int main(int argc, char *argv[]){
    C v1, v2;
    C ac[5];
    system("PAUSE");
    return EXIT_SUCCESS;
}
No programa acima o construtor C() é invocado duas vezes para a construção das instâncias v1 e v2 e invocado cinco vezes para a construção do arranjo ac.

Se o programador não definir construtores o compilador provê um construtor chamado “default constructor”. Por exemplo a classe X a seguir

class X{};

não tem nenhum construtor definido e portanto o compilador irá definir um “construtor default”. A classe X a seguir

class X{public: X(int p){}};

definiu um construtor e neste caso o compilador não vai definir um construtor default. Observe que uma definição de instância desta última classe X da forma

X v(2);

Compila de maneira esperada. Mas uma definição da form  X v; dá erro de compilação e a mensagem informa que não há construtor default para ser invocado. Um construtor sem parâmetros ou um construtor com parâmetros inicializados com valores “default” são considerados construtores default.

class X{public: X(){cout<<"sem!"<<endl;}
                X(int i=3){cout<<i<<endl;}};

A definição desta classe X acima é aceita pelo compilador, mas uma definição que envolva a chamada do construtor default tal como X v;  causa erro e a mensagem informa que existe ambiguidade com relação à escolha do construtor default.

Uma vez que podemos ter vários construtores com diferentes tipos e números de argumentos, devemos lembrar que existe uma espécie de fato notável sobre a definição de instâncias. Considere as seguintes definições:
X v1(a,b,c), v2(d,e), v3(f), v4;
Podemos considerar que a classe X tem um construtor com valores default ou então que a classe X tem vários construtores;  alguém menos familiarizado com C++ poderia querer definir a variável v4 com abre e fecha parênteses:
X v4();
Mas esta expressão corresponde a declarar um protótipo de função com nome correspondente a v4 e que devolve valores do tipo X. Este tipo de confusão é agravado em função da sintaxe do uso explícito de construtores.

A linguagem C++ define os operadores new e delete para alocar e desalocar memória, conforme o seguinte trecho de programa:

int *pi=new int;
*pi=123;
cout<<*p1<<endl;

A linguagem C++ tem uma sintaxe de definição e inicialização:

int *pi=new int(123);
cout<<*p1<<endl;

A devolução da area de memória obtida via new é feita utilizando o operador delete:

int *pi=new int(123);
cout<<*p1<<endl;
delete pi;




X *p1, *p2, *p3, *p4;
p1=new X(a,b,c);
p2=new X(d,e);
p3=new X(f);
p4=new X(); // não podemos usar abre/fecha parênteses para declaração direta
            // mas podemos para declaração anônima
São estas idiossincrasias que tornam o aprendizado de programação mais complexo, houve aí um “choque sintático”.

 

 

Operador resolução de escopo (::)

A definição de comportamento em uma classe se faz através da definição de métodos e a definição de métodos em uma classe pode ser feita dentro do corpo da classe ou fora dele:

(a) class NomeClasse{... void comportamento(){<<definição>>}; .. };

(b) class NomeClasse{... void comportamento(); ...};
   void NomeClasse::comportamento(){<<definição>>};

Para definir um método fora do corpo de uma classe temos que fazer uso do operador :: (operador de escopo ou operador de resolução de escopo ou operador dois pontos dois pontos) observe o seu uso na definição dos métodos obterFace() e rolar() da classe Dado. Este operador pode ser utilizado em várias diferentes situações.

Podemos também definir um construtor fora da classe:
class NomeClasse{... NomeClasse(); ...};
NomeClasse::NomeClasse(){<<definição>>};

 

Na definição dos elementos de uma classe podemos utilizar os chamados rótulo de acesso:

·         private

·         protected

·         public

As recomendações sobre quais rótulos utilizar em quais ocasiões serão vistas em disciplinas futuras. Nesta disciplina vamos discutir de forma introdutória o significado destes rótulos.

Acesso privado

Na classe Dado acima o atributo face é privado. isto significa que um trecho de programa que utiliza a classe Dado não pode acessar este atributo:

Dado d1;
d1.face=3; //não compila

Acesso publico

Na classe Dado acima os métodos mostrarFace() e rolar() são públicos e podem ser invocados a partir das instâncias.

Acesso protegido: iremos discutir mais à frente.

Finalmente observe o uso do fluxo (stream) cout:

cout << exp1<<exp2<<exp3;

--------

 

A linguagem C++ permite a chamada sobrecarga de operadores e neste caso o operador de deslocamento de bits << foi sobrecarregado para indicar o uso de recursos de entrada e saída. A linguagem C++ de forma semelhante à variáveis stdin, stdout e stderr disponibiliza as variáveis (objetos) cin, cout e cerr para dar suporte a operações de entrada e saída.

Considere agora a seguinte variação do programa acima:

#include <cstdlib>
#include <iostream>
#include <ctime>
using namespace std;

class Dado{
  private:
  int face;
  static int cntDados; //declaracao
  public:
  static void mostrarQuantosDados(){
    cout<< "Temos "<<cntDados<<" dados\n";
  }
  Dado(){cntDados++; srand(time(0));rolar();};      
  const static int FACES=6;
  int obterFace();
  void rolar();
};
int Dado::cntDados=0; //definicao
int Dado::obterFace(){return face;}
void Dado::rolar(){face=rand()%FACES + 1;}
int main(int argc, char *argv[])
{
  Dado::mostrarQuantosDados();
  Dado d1;
  Dado::mostrarQuantosDados();
  Dado d2;
  Dado::mostrarQuantosDados();
  system("PAUSE");
  return EXIT_SUCCESS;
}

Elementos de classe (variáveis de classe e métodos de classe) são indicados pelo uso do modificador static.Neste programa além da constante FACES e da variável de instância face temos a variável de classe cntDados. Além dos métodos de instância obterFace() e rolar() temos o método de classe mostrarQuantosDados(). As variáveis e métodos de classe são normalmente acessados utilizando o nome da classe seguido do operador de escopo seguido do nome do elemento, isto em contraposição ao acesso aos elementos de instância que podem ser acessados na forma nome da variável operador (ponto) de seleção seguido de nome do elemento. Apesar do acesso aos membros estáticos serem feito, normalmente, utilizando o nome da classe, podemos também utilizar as instâncias para este acesso, conforme ilustrado no trecho de programa abaixo:

class X{public: static int vs; int vi; };

 
 
 
int X::vs;

 
 
 
int main(int argc, char *argv[]){

 
 
 
    X v1, v2;

 
 
 
    v1.vi=1; v1.vs=1;

 
 
 
    cout<<"X::vs="<<X::vs<<endl;

 
 
 
    v2.vi=2; v2.vs=2;

 
 
 
    cout<<"X::vs="<<X::vs<<endl;

 
 
 
    cout<<"v1.vi="<<v1.vi<<" v1.vs="<<v1.vs<<endl;

 
 
 
    cout<<"v2.vi="<<v2.vi<<" v2.vs="<<v2.vs<<endl;

 
 
 
    system("PAUSE");

 
 
 
    return EXIT_SUCCESS;

 
 
 
}

 
 
 
--------------
 

Destruidores

Quando o sistema de execução cria um objeto é feita uma chamada para um dos construtores, este construtor deverá fazer as inicializações e o objeto passará a existir de forma adequada. Na linguagem C++ é provido também o destruidor ou demolidor ou “desfazedor” que será invocado quando um objeto for destruído (por exemplo quando houver a destruição do registro de ativação onde o objeto foi alocado). Os destruidores são indicados através de um til antes do nome da classe.

Verifique o seguinte trecho de programa:

class C{ public: C(){cout<<”ola!!!\n”;}; ~C(){cout<<”adeus\n”;}};
void f(){ C v; }
...
main() { ... f(); ...}

 

Quando um arranjo de instâncias é destruido o destruidor de cada instância é invocado. No programa abaixo os construtores são invocados na construção do registro de ativação de f() e quando o registro de ativação de f() é destruiído anteriormente a isso são invocados os destruidores de cada instância (em ordem inversa!).

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

class C{private: int id; static int cntId;
        public: C(){id=cntId++; cout<<"Criado:"<<id<<endl;}
               ~C(){cout<<"adeus de "<<id<<endl;}};
int C::cntId=0;
//class C{ public: C(){cout<<"oi!"<<endl;}};

void f(){
     C v1, v2;
     C ac[5];
     }
int main(int argc, char *argv[]){
    f();
    system("PAUSE");
    return EXIT_SUCCESS;
}

Ponteiros para objetos, new & delete

Quando utilizamos variáveis da forma acima a alocação do objeto é automática. Mas quando utilizamos ponteiros para objetos podemos utilizar para a criação de objetos o operador new. Quando quisermos retornar para o sistema a área de memória de um objeto instanciado via new usamos o operador delete:

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

class C{ char eu;
       public:
      C(char c){eu=c; cout<<"ola! sou "<< eu <<"\n";};
      ~C(){cout<<"adeus de "<< eu <<"\n";}};
void f(){
  C v('v'), *p;
  p=new C('p');
  /*...*/
 
delete p;
}
int main(int argc, char *argv[]){
  f();
  system("PAUSE");
  return EXIT_SUCCESS;
}

O programa acima ilustra que podemos selecionar um construtor utilizando abre e fecha parênteses logo após o nome da variável e dentro do abre parênteses parâmetros reais que identifiquem o construtor. No programa acima a criação do objeto correspondente a v é feita no contexto do registro de ativação da função f. A criação do objeto correspondente a p é feita em um espaço/escopo conhecido como “espaço de alocação dinâmica”. Quando termina a execução da função f seu registro de ativação é destruído e a destruição de v é efetivada concomitantemente. O término da execução da função f destroi a variável p mas não destroi o objeto apontado por p. Se removermos a remoção do objeto apontado por p (se deletarmos a linha “delete p;”) o objeto não será mais acessível e ocorre o que é denominado um “vazamento de memória”. Na linguagem C++ o programador deve ter cuidado de não deixar acontecer “vazamentos”.

A palavra chave “this”

A linguagem C++ disponibiliza para o programador de uma classe um campo denominado “this” onde fica um ponteiro para o objeto que está sendo instanciado. Este campo (que só faz sentido nos coonstrutores e nos chamados “métodos de instância” ou  “métodos não estáticos”)  pode ter vários usos, um deles é para, por exemplo, desambiguar nomes:
class X{public:

class X{public:
        int a;
      X(int a){this->a=a;};
      int obtera(){return a;};};

/* . . . */
  X v(20);
  cout<<v.obtera()<<"\n";

No programa acima o “this” desambigua o parâmetro de nome “a”  e o membro de mesmo nome. A linguagem C++ disponibiliza também um campo denominado “super” onde fica um ponteiro para o objeto

---

Na linguagem C++ definição dos métodos de uma classe permite o uso de um mesmo nome desde que a “assinatura” (signature), ou seja, número e tipo dos argumentos seja diferente. A definição de métodos dentro de um mesmo escopo com mesmo nome é conhecido como sobrecarga (overload). O programa abaixo exemplifica uma classe com sobrecarga do método f:

#include <cstdlib>
#include <iostream>

using namespace std;

class C{public:
        int f(){cout<<"f()"<<endl;}
        int f(int i){cout<<"f(int)"<<endl;}
        int f(double d){cout<<"f(double)"<<endl;}
};

int main(int argc, char *argv[])
{
    system("PAUSE");
    return EXIT_SUCCESS;
}


O programador da classe C sobrecarregou o método f. Exercício: escreva o trecho da função main() que utiliza C e invoca as diferentes assinaturas de f.

 

No material à frente, sobre relação de herança entre classes, veremos que uma classe pode ter uma função que “passa por cima” (override)  ou sobrescreve uma função de mesmo nome herdada de uma classe mãe. Os conceitos de sobrecarregar e sobrescrever têm semelhança (definições de funções com mesmo nome) mas certamente são bem distintos (sobrecarga: mesmo nome no mesmo escopo de nomes & sobrescrita: mesmo nome em escopos diferentes). A definição de vários construtores sobrecarrega o nome da classe.

 

Vários tipos de operações são prédefinidas para as classes e sua instâncias. Em particular é possível fazer a atribuição de instâncias:

class C{/* definição de C*/};

...
C v1,v2;
v1=v2;

Se o programador não definir de forma explícita o significado da atribuição será executada uma função/operação default ( o termo correspondente a este conceito é “copy assignment operator”) que simplesmente faz atribuição para os campos da instância v1 dos valores dos campos correspondentes da instância v2. Este tipo de atribuição default é as vezes referida como sendo “atribuição membro a membro” (memberwise assignment). O programador pode definir uma função (operator=) que implementa uma semântica de atribuição diferente da atribuição membro a membro. A inicialização de em objeto na hora da sua criação é operada de maneira diferente da atribuição. Considere o seguinte trecho de programa:

class C{/*definição de C*/};

...

C v1;

C v2=v1;

No trecho acima o objeto v1 é3 inicializado através do construtor sem parâmetros conforme já discutido
 (
C v1; equivale a C v1=C(); ) . A definição do objeto v2 é feita através de uma semântica distinta da criação do objeto v1.
(
C v2=v1; equivale a C v2=C(v1);). Se o programador não definir um “construtor cópia” (Copy constructor) o compilador fornece um onde a semântica é similar a atribuição membro a membro. A forma de um construtor cópia de uma classe C é tipicamente C(const C &p){/* definição */} (também pode ser C(C &p){/*definição*/})

 

Resumindo, a linguagem C++, mais tradicional, prevê quatro “membros especiais” para as classes: (i) o construtor default, (ii) o destrutor (ou destruidor), (iii) o construtor cópia e o (iv) operador de atribuição de cópia. A tabela abaixo exemplifica cada um deles:

construtor “default”

C::C()

detrutor

C::~C()

construtor cópia

C::C(const C&)

operador de atribuição de cópia

C& operator=(const C&)

 

Os projetistas de C++ edição 2011 incluiram dois novos membros especiais:

“move constructor”

C::C(C&&)

“move assigment”

C& operator=(C&&)

 

Nesta disciplina não detalharemos estes dois últimos membros. O entendimento do construtor cópia e operador de atribuição de cópia dependem do material sobre “referências”(denotado pelo uso do caractere &), além disso o entendimento do operador de atribuição de cópia depende do material relacionado a “sobrecarga de operadores”.

Exercícios:

 

Escreva um programa em C++ que define uma classe “Lampada”. O estado da lâmpada deve ser representado de forma privada mas deve ser acessível através dos seguintes métodos de instância: estaAcesa(), estaApagada(), acender(), apagar(). A classe deve manter o registro de quantas lâmpadas (instâncias/objectos) existem.  O programa deve demostrar que a classe monitora as instâncias e use o destruidor para decrementar o número de instâncias!

 

Escreva um programa em C++ que define uma classe Cubo e instancia alguns diferentes cubos.  A classe deve ter pelo menos os seguintes métodos públicos de instância:

double obterArea(),  double obterVolume() e double obterLado();

A classe deve ter o seguinte método estático

 static int totalDeCubos()

que devolve quantos cubos já foram instanciados.

Por agora considerar apenas uma unidades de medida "default”.

A classe deve poder atender a estes usos:

  Cubo c1; //instancia um cubo com lado “default

  Cubo c2(2.0); //instancia um cubo com lado de duas unidades

cout<<c2.obterVolume()

imprime 8.0

 

Escreva um programa em C++ que define uma outra forma geométrica similarmente à classe Cubo acima.

 

Faça alguns programas em C++ com tema envolvendo duas classes: classe Ponto e classe Retangulo. A classe Ponto deve ter um construtor que funciona da seguinte maneira:  Ponto p(1.0, 2.0); Na definição da Retangulo utilize a classe ponto e defina pelo menos dois construtores, um construtor deve ter como parâmetro o ponto superior esquerdo e o ponto inferior direito. O outro construtor deve ter quatro parâmetros do tipo double os dois primeiros definindo o ponto superior esquerdo e os dois seguintes o ponto inferior direito.

 

Escreva um programa em C++ que define uma classe BombaDeCombustivel...quais seriam os métodos?

Escreva um programa em C++ que define uma classe PostoDeCombustivel. A classe deve ter pelo menos os seguintes métodos públicos

bool temGasolina(), tem Diesel()  etc

double precoTotal()... etc

Estude como que você deve fazer para cada instância de posto poder ter uma composição de 1 a N instâncias de BombaDeCombustivel; mude o que for preciso na classe BombaDeCombustivel para poder operar com instâncias de PostoDeCombustivel. Inicialmente faça um programa bem simples.

 

Escreva uma classe chamada Fracao. O construtor de uma fração deve poder receber dois valores do tipo int: Fracao f1(3,10). A classe deve ter métodos estáticos (soma, divide etc) que recebem duas instâncias de Fracao e devolve uma nova instância: Fracao f1=Fracao::soma(new Fracao(3,10), new Fracao(4,5)). Esta classe deverá ser estendida quando for discutido o material de sobrecarga de operadores! E deverá ser sobrecarregado o operador soma (+) para poder ser escrito Fracao f1=(new Fracao(3,10))+(new Fracao(4,5));

 

Explique porque a classe abaixo não compila.

class A{private: int x; public: static void f(int x){this->x=x;}};

 

Faça um desenho, seguindo as convenções de aula, da estrutura criada a partir da variável pb de acordo com o trecho de programa abaixo.

class Nodo(int info;

public:

Nodo *prox;

static int cnt;

Nodo(int i, Nodo *p){info=i; prox=p; cnt++;}};

int Nodo::cnt=0;

...

Nodo a(10,NULL);

Nodo *pb=new Nodo(20, &a);