Instituto Politécnico de Beja
Escola Superior de Tecnologia e Gestão
Bacharelato em Engenharia Informática

Classes e Objectos em C++

Uma pequena introdução com ênfase na definição de operadores

João Paulo Ramos e Barros
Já sabemos que a linguagem C++ disponibiliza vários tipos de variáveis, nomeadamente: char, int, float, double; e uma série de variantes especificáveis através dos modificadores: unsigned, short e long. Sabemos também que a linguagem contem um terceiro tipo denominado string (char s[32]) que permite representar sequências de caracteres. Como o tipo string suportado é de muito baixo nível prefere-se quase sempre utilizar uma classe já pronta a ser utilizada que oferece uma alternativa de mais alto-nível.

Com um tipo para representar caracteres (char) um para representar números inteiros (int), outros para representar números reais (float e double) e outro ainda para representar sequências de caracteres, poderíamos ser levados a pensar que já não precisaríamos de mais tipos. Isso é em parte verdade pois estes tipos permitem-nos representar toda a informação que queiramos; afinal tudo são números ou caracteres!

Mas se pensarmos melhor rapidamente concluímos que se assim fosse nem sequer precisaríamos destes tipos pois, afinal, eles mais não são do que bits na memória do computador! Ora já vimos que isso não nos interessa pois os bits são demasiado "primitivos", ou seja, são de difícil leitura, compreensão e manipulação. O que os tipos existentes em linguagens como a linguagem C++ nos oferecem é exactamente uma mais fácil leitura, compreensão e manipulação. E isto porquê? Porque se encontram mais próximos daquilo a que estamos habituados a encontrar: números, letras, frases; e não apenas sequências de zeros e uns.

Algumas linguagens contentam-se com estes tipos "fundamentais" (ou fundamentalistas(!)) obrigando o programador a reduzir e a pensar tudo em termos de números, caracteres e sequências de caracteres. Ora a verdade é que a maior parte dos objectos que os programas manipulam não são números e caracteres mas sim coisas muito menos abstractas como pessoas, carros, discos, livros, etc.. Claro que nenhuma linguagem de programação pode prever quais os objectos que cada programador vai necessitar, mas pode, pelo menos, permitir-lhe definir novos tipos que corresponderão muito mais de perto aos objectos que cada programa irá manipular.

Tipos pré-definidos e tipos definidos pelo utilizador

A linguagem C++ deixa o programador definir os seus próprios tipos! Estes tipos são denominados por tipos definidos pelo utilizador, em inglês user-defined types, (notar que neste caso o utilizador é o programador, dado que utiliza a linguagem de programação) ou simplesmente classes, e as suas variáveis objectos. Ou seja, em C++ é usual utilizar duas nomenclaturas distintas conforme estejamos a falar dos tipos já existentes na linguagem (char, int, double, etc.) ou dos tipos definidos pelo programador. Os primeiros designamos por tipos pré-definidos para tornar claro que já estão à partida definidos visto constituirem parte integrante da linguagem; os segundos denominamos tipos definidos pelo utilizador ou classes. Assim temos:
 
Pré-definidos
Definidos pelo utilizador (classes em C++)
Quais?
Os fornecidos pela linguagem Os definidos pelo utilizador e os já definidos em bibliotecas.
Nomes
char, int, double

float, long, char []

Ao critério dos utilizadores e dos criadores das bibliotecas. 

Exemplos: istream, ofstream, Empregado, Livro, etc.

Quadro 1 - Tipos pré-definidos e definidos pelo utilizador. A estes últimos é usual chamar classes e às respectivas variáveis objectos.

Importa notar que é possível definir conjuntos de classes prontas a serem utilizadas. Estes conjuntos de classes denominam-se bibliotecas de classes. É usual encontrar num mesmo "pacote" o compilador de C++ e uma (ou mais) biblioteca(s) de classes. Ainda não existe o standard da linguagem C++ pelo que é frequente encontrar diferentes compiladores acompanhados de diferentes bibliotecas. Por enquanto (Junho de 1997) apenas a biblioteca de input/output se encontra razoavelmente estável o que significa que podemos com bastante segurança acreditar que o código que utiliza streams para input/output irá funcionar na maioria dos compiladores e respectivas bibliotecas.

Muito bonitos estes nomes todos! Mas como é que afinal definimos os nossos tipos?! Já sabemos que os tipos que a linguagem nos oferece são abstracções do que realmente se passa na memória do computador. Sabemos que nesse terrível lugar só existem bits e que para que nós, pobres programadores consigamos, facilmente perceber alguma coisa do que lá se passa, foram inventados os tipos. Dessa forma conseguimos afastarmo-nos dos bits. Ora os tipos que nós podemos definir mais não fazem do que nos afastar ainda mais dos tenebrosos bits. E como é que podemos fazer tal proeza? Utilizando os nossos já conhecidos tipos pré-definidos Assim temos uma hierarquia de utilização:
 

tipos definidos pelo utilizador
tipos pré-definidos
números binários

Quadro 2 - Hierarquia de tipos.

Importa notar que os tipos pré-definidos podem também utilizar outros tipos pré-definidos. Por exemplo, uma classe Linha e uma classe Triangulo poderiam ambas utilizar uma classe Ponto; cada objecto da classe Linha conteria dois objectos da classe Ponto, enquanto um objecto da classe Triangulo conteria três objectos da classe Ponto. Por outro lado, a classe Ponto conteria duas variáveis de um tipo pré-definido, o tipo double, que corresponderiam às coordenadas do ponto no plano.

Representação em C++ de tipos definidos pelo utilizador

Na linguagem C++ os tipos definidos pelo utilizador são definidos utilizando classes. Estas constituem uma forma de agrupar variáveis de tipos pré-definidos e/ou objectos de outras classes. Além de variáveis e/ou objectos, cada classe pode conter um conjunto de funções associadas. Algumas destas funções são definidas dentro da classe, juntamente com as variáveis. Por enquanto não vamos estudar essas funções membro da classe, mas apenas as variáveis e/ou objectos que lhes servem de suporte.

Definição de classes utilizando structs

Quando a classe C++ apenas contem objectos é usual (por razões históricas) dar-lhe o nome de struct. Por enquanto vamos estudar apenas classes que podem ser representadas utilizando structs. Assim sendo vamos já para a definição de uma struct que é também (não esquecer) um tipo definido pelo utilizador:
struct Aluno {
   String nome; // considera-se que existe disponível uma classe String
   int numero;
   String curso;
};
Ora agora que já temos um tipo novinho em folha é de esperar que possamos fazer com ele pelo menos algumas das coisas que fazemos com os tipos pré-definidos. Por exemplo, se Aluno é um tipo então podemos definir os respectivos objectos assim como fazemos com os tipos pré-definidos:
Aluno umAluno;
int umInteiro;
Neste caso definimos um objecto do tipo Aluno e um objecto do tipo int. Mas será que podemos escrevê-lo e lê-lo?

Definição de operadores para classes

Duas das operações mais frequentemente utilizadas são o input e o output de valores. Claro que devemos poder ler e escrever o valor dos objectos do tipo Aluno tal como fazemos, por exemplo, com os ints:
cout << "Indique os dados do aluno: ";
cin >> umAluno;
cout << umAluno;
Ora de facto, podemos definir objectos da classe Aluno mas infelizmente, por enquanto, não podemos lê-los nem escrevê-los, pelo que as duas últimas linhas de código iriam originar erros de compilação! A razão é muito simples: a definição define a parte estática dos objectos do tipo aluno, ou sejam, os dados destes. Para definir um objecto não é necessária mais informação. Mas para fazer algo com o (ou ao) objecto é necessário haver funções que operem sobre objectos da classe Aluno. Ora, nós ainda não definimos nenhuma dessas funções. Para o nosso exemplo é necessário definir os operadores >> e << para que possamos ler e escrever objectos da classe Aluno. Estes operadores são, na realidade, funções que são chamadas utilizando uma notação infixa e que dão pelo nome de operator<< e operator>>, respectivamente.

Seguidamente apresentam-se as definições destes operadores para a classe Aluno:

ostream& operator<<(ostream& output, const Aluno& umAluno) {
   output << umAluno.nome << " " << umAluno.numero << " " << umAluno.curso;
   return output;
}

istream& operator>>(istream& input, Aluno& umAluno) {
   input >> umAluno.nome >> umAluno.numero >> umAluno.curso;
   return input;
}
Relativamente a estas duas definições deve-se constatar o seguinte:

Estes operadores devolvem o seu primeiro argumento, o qual é uma stream (ostream ou istream). Note que o objecto da classe Aluno é passado por referência e, no primeiro caso, a declaração do parâmetro é acompanhada do modificador const. Por último, o & no tipo a devolver (ostream& ou istream&) resulta da necessidade do operador devolver uma stream.

Os operadores << e >> já se encontram definidos para os tipos pré-definidos. Na verdade, todos os tipos pré-definidos têm associados conjuntos de funções e operadores que operam sobre eles. Basta pensarmos que, por exemplo, para o tipo int alêm de podermos definir variáveis podemos depois operar sobre elas somando, subtraindo, afectando, etc. Ou seja, cada tipo pré-definido é na realidade constituído por duas partes: a parte estática (variáveis e constantes) e a parte dinâmica (funções que operam sobre ele).

O mesmo sucede com os tipos definidos pelo utilizador. Além da parte estática que define a estrutura, existe um conjunto de funções que operam sobre ela. Essas funções são, como seria de esperar, também elas definidas pelo utilizador.

Como a definição das classes utiliza os tipos pré-definidos e/ou nas classes já existentes para sua parte estática (por exemplo a classe Aluno utiliza a classe String e o tipo pré-definido int) tal vai implicar que as funções que operam sobre a classe vão utilizar funções que operam sobre esses mesmos tipos pré-definidos e/ou classes. É o que sucede na classe Aluno. O operador << utiliza na sua definição ao operador << que opera sobre Strings e ao operador << que opera sobre ints, para escrever o valor de um objecto da classe Aluno. Nem outra coisa seria de esperar, se um objecto Aluno é constituído por objectos da classe String e uma variável do tipo int então escrever o valor de um Aluno corresponde a escrever o valor de duas Strings e de um int.

A possibilidade de definir os operadores << e >> para qualquer tipo definido pelo utilizador permite apresentar de forma muito genérica as operações sobre ficheiros.

Operadores de comparação, chaves e campos

Tal como definimos os operadores de input e output para objectos da classe Aluno, podemos também definir outros operadores já existentes para os tipos pré-definidos. Por exemplo o operador de igualdade irá permitir saber quando é que dois objectos da classe Aluno são considerados iguais. Para podermos implementar este operador necessitamos em primeiro lugar de decidir quando é que dois objectos desta classe representarão o mesmo aluno. Claro que podemos comparar os três objectos (nome, numero e curso) constituintes de cada um dos objectos Aluno. Só se cada um for igual ao respectivo do outro objecto é que os dois objectos são considerados iguais. Desta forma temos a seguinte definição do operador == que mais não é do que uma função que tem por parâmetros dois objectos da classe Aluno e devolve um valor bool que indica se os parâmetros são iguais:
bool operator==(const Aluno& alunoEsq, const Aluno& alunoDir) {
   return (alunoEsq.nome   == alunoDir.nome)   && 
          (alunoEsq.numero == alunoDir.numero) && 
          (alunoEsq.curso  == alunoDir.curso);
}
Mas se pensarmos mais um pouco, facilmente nos apercebemos que esta função está a fazer coisas de mais! Dois alunos com o mesmo número são de certeza o mesmo aluno! Além disso há muitos alunos no mesmo curso e até talvez alguns com o mesmo nome! Ou seja, nem todos os campos (outro nome para servem para distinguir os alunos uns dos outros. Aos campos que servem para distinguir um objecto de outro da mesma classe é usual chamar chave. Os restantes campos são denominados campos de dados e não servem para distinguir os objectos entre si pois diferentes objectos podem conter igual informação num ou mais destes campos. Assim sendo, podemos simplificar a definição do nosso operador:
bool operator==(const Aluno& alunoEsq, const Aluno& alunoDir) {
   return (alunoEsq.numero == alunoDir.numero);
}
Quando definimos o operador == é simpático (e coerente) definir também o operador !=:
bool operator!=(const Aluno& alunoEsq, const Aluno& alunoDir) {
   return (alunoEsq.numero != alunoDir.numero);

}
Se quisermos considerar uma ordenação de objectos da classe Aluno, necessitamos de uma função que nos diga quando é que um aluno é maior ou menor que outro. Claro que o operador < é um excelente candidato a tal proeza:
bool operator<(const Aluno& alunoEsq, const Aluno& alunoDir) {
   return (alunoEsq.numero < alunoDir.numero);

}
Neste caso a ordenação dos alunos seria feita com base no seu número. Claro que poderiamos ordená-los por nome. Nesse caso o nosso operador teria o seguinte aspecto:
bool operator<(const Aluno& alunoEsq, const Aluno& alunoDir) {
   return (alunoEsq.nome < alunoDir.nome);

}
Falta-nos ver qual a melhor forma de estrutura o programa quando este utiliza classes ainda que, como no nosso caso, representadas por structs.

Estruturação de um programa que utiliza tipos definidos pelo utilizador

Para cada programa denominado prog: Para a classe Aluno, temos então:
 
Aluno.h
Aluno.cpp
#ifndef ALUNO_H
#define ALUNO_H

#include <iostream.h>

struct Aluno {
   String nome;
   int numero;
   String curso;
};

ostream& operator<<(ostream& output,
                    const Aluno& umAluno);
istream& operator>>(istream& input,
                    Aluno& umAluno);

#endif

#include "Aluno.h"

ostream& operator<<(ostream& output,
                    const Aluno& umAluno) {
   output << umAluno.nome << " "; 
   output << umAluno.numero << " ";
   output << umAluno.curso;
   return output;
}

istream& operator>>(istream& input,
                    Aluno& umAluno) {
   input >> umAluno.nome;
   input >> umAluno.numero;
   input >> umAluno.curso;
   return input;
}

Portanto, todo o código executável, ou seja, aquele que contem instruções, é colocado num ficheiro com a extensão .cpp. Todo o código não executável, ou seja, aquele que contem protótipos de funções e definições de classes, é colocado em ficheiros com a extensão .h.

Importa notar a presença de três estranhas linhas no ficheiro Aluno.h. Todas elas se iniciam por #. Estas três linhas devem sempre ser incluídas num ficheiro com a extensão .h. Elas correspondem a instruções do pré-processador e nada têm a ver com a linguagem C++. Apenas servem para evitar que um ficheiro .h seja incluído (através da directiva #include) mais do que uma vez num ficheiro com a extensão .cpp, o que originaria erros devido à duplicação de definições. De facto a classe Aluno só pode ser definida uma vez!

A função main é colocada sozinha num ficheiro com o nome do programa e a extensão .cpp visto tratar-se de um ficheiro com código executavel:
 

prog.cpp
#include <iostream.h>
#include "Aluno.h"
int main() {
   Aluno umAluno;
   cout << "Indique os dados do aluno: ";
   cin  >> umAluno;
   cout << umAluno;
   return 0;
}

Compilação de um programa contido em mais do que um ficheiro

A esmagadora maioria dos programas C++ não triviais, são escritos em vários ficheiros. É o que sucede com o nosso pequeno exemplo: Aluno.h, Aluno.cpp e prog.cpp. A compilação de um programa constituído por vários ficheiros corresponde a compilar cada um dos ficheiro com a extensão .cpp. E então os .h para que é que servem? Os .h não ficaram esquecidos. De facto eles também são compilados dado serem incluídos pelos .cpp! Por outras palavras, a compilação dos .cpp corresponde, na realiade, à compilação destes já com os .h incluidos, visto cada #include ser substituído, textualmente, pelo respectivo ficheiro.

Por último resta assinalar que existem alternativas à extensão .cpp. Por exemplo: .cc, .cxx, .C. Por vezes é através desta extensão que o compilador determina que o ficheiro contem código C++, razão pela qual esta extensão pode ser muito importante. Por outro lado, é também comum que o compilador inclua uma opção que permite indicar que o ficheiro contem código C++. Nesse caso a extensão não é utilizada pelo compilador, mas ainda assim é boa ideia especificá-la dado que facilita a identificação do tipo de conteúdo do ficheiro.

É também possível encontrar alternativas à extensão .h, nomeadamente: .hpp, .hxx, .H. Esta extensão não é tão relevante dado que os ficheiros .h não são passados para o compilador, mas apenas incluidos pela directiva #include. As razões para a sua utilização prende-se unicamente com uma correcta especificação do tipo de conteúdo do ficheiro.

Conclusões

A linguagem C++ permite que o programador defina os seus próprios tipos, denominados tipos definidos pelo utilizador. Desta forma, todo o programa deve ser pensado e estruturado partindo dos tipos considerados necessários para a sua realização.

A definição de tipos em C++ é realizada utilizando classes. Quando a classe C++ apenas contem objectos é usual dar-lhe o nome de struct. Vimos apenas classes que podem ser representadas utilizando structs e que portanto não contêm funções membro.

Podemos redefinir alguns dos operadores já existentes para os tipo pré-definidos de forma a que possam operar sobre os tipos definidos pelo utilizador.

Para cada struct denominada X devem existir dois ficheiro: X.h e X.cpp.

Apenas os ficheiros com a extensão .cpp contêm código executável e como tal são os únicos que devem ser compilados.


Para o esclarecimento de quaisquer dúvidas relacionadas com este texto deve contactar os docentes da disciplina.
Beja, 6 de Junho de 1997