A tarefa dum programador é resolver problemas, usando um computador (pelo menos), através da escrita de programas numa linguagem de programação dada. Depois de especificado o problema com exactidão, o programador inteligente começa por procurar, na linguagem básica, na biblioteca padrão e noutras quaisquer bibliotecas existentes, ferramentas que resolvam o problema na totalidade ou quase: esta procura evita as perdas de tempo associadas ao reinventar da roda ainda infelizmente tão em voga *. Se não existirem ferramentas, então há que construí-las. Ao fazê-lo, o programador está a espandir mais uma vez a linguagem disponível, que passa a dispor de ferramentas adicionais (digamos que "incrementa" de novo a linguagem para "C++ ++ ++").
Há essencialmente duas formas de construir ferramentas adicionais para uma linguagem. A primeira passa por equipar a linguagem com operações adicionais usando os tipos existentes (e.g., int, char, bool ou double). A segunda passa por adicionar tipos à linguagem. Para que esses novos tipo tenham algum interesse, à fundamental que tenham operações próprias, que têm de ser concretizadas pelo programador. Assim, a segunda forma de expandir a linguagem passa necessariamente pela primeira.
Até agora, apenas se viu como expandir a linguagem acrescentando-lhe operações (com excepção dos tipos enumerados, uma versão simples da expansão da linguagem através da construção de novos tipos). Viu-se que isso se conseguia construindo funções e procedimentos resolvendo pequenos problemas não resolvidos pela linguagem básica nem pela biblioteca padrão. Foi o caso, por exemplo do cálculo do mdc ou do factorial. Para realizar essas tarefas viu-se ser fundamental dominar os conceitos de função, procedimento, parâmetro e argumento, instrução de selecção e iterativa, e metodologias de desenvolvimento de ciclos, para não falar do conhecimentos sobre os tipos básicos e respectivas operações, variáveis e construção de expressões.
A partir deste ponto o ênfase será posto na construção de novos tipos. De início construir-se-ão novos tipos relativamente simples e independentes uns dos outros. Quando se iniciar o estudo da programação orientada por objectos ver-se-á como desenhar hierarquias de tipos e quais as suas aplicações na resolução de problemas de maior escala.
* Por outro lado, é importante notar que se pede muitas vezes à estudante que reinvente a roda. Acontece que fazê-lo é um passo fundamental para o treino na resolução de problemas concretos. Convém portanto que a estudante se disponha a essa tarefa que, fora do contexto da aprendisagem, é inútil. Mas convém também que não se deixe viciar na resolução por si própria de todos os pequenos problemas que já foram resolvidos milhares de vezes. É importante saber fazer um equilíbrio entre a curiosidade intelectual de resolver esses problemas e o pragmatismo de procurar um solução já pronta. Durante a vida académica, a balança deve pender fortemente no sentido da curiosidade intelectual. Finda a vida académica, o equilíbrio deve pender mais para o pragmatismo.
class Aluno {A sintaxe de definição de um tipo é, portanto
public:
string nome;
int número;
};
class nome_do_tipo {sendo importante notar que este é um dos poucos locais onde o C++ exige um ; depois da } final.
declaração_de_membros
};
Aluno aluno1;cria uma variável do tipo Aluno e inicializa-a com o nome Zacarias Zagalo e o número 123. Assim, a sintaxe de acesso aos membros duma classe corresponde à escrita do nome do membro precedida de um ponto (.) *. Para todos os efeitos, os membros da classe Aluno funcionam como variáveis guardadas na variável aluno1.aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;
É importante perceber que cada variável do tipo Aluno possui as suas próprias variáveis membro. Por exemplo:
Aluno aluno1;cria duas variáveis aluno1 e aluno2 com informação relativa a dois diferentes alunos. Por outro lado, os nomes dos membros duma classe só têm visibilidade dentro dessa classe, pelo que poderia existir um variável de nome número no exemplo acima:aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;Aluno aluno2;
aluno2.nome = "Xisto Ximenes";
aluno1.número = 321;
int número = 1000;sem que isso causasse qualquer problema.
Aluno aluno1;aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;Aluno aluno2;
aluno2.nome = "Xisto Ximenes";
aluno1.número = 321;
* Em rigor, o ponto (.) é o operador de selecção de membro, como se pode ver na Tabela Precedência de operadores.
Ver-se-há mais tarde que também podem existir funções ou procedimentos membro duma classe. A essas funções ou procedimentos é comum chamar-se "métodos". Por vezes também se ouve o termo "passar uma mensagem" em vez de invocar uma função membro.
Neste texto adoptar-se-ão apenas alguns destes termos. Assim, uma classe é constituída por um conjunto de membros. Estes membros podem ser funções (ou procedimentos) ou variáveis. Os membros, ver-se-á mais tarde, podem ser de instância ou de classe, consoante cada instância da classe possua a sua própria cópia do membro ou exista apenas uma cópia partilhada entre todas as instâncias da classe.
Aluno aluno1;Da mesma forma estão bem definidas as devoluções de valores duma classe e a passagem de argumentos de uma dada classe por valor. Assim, as variáveis duma classe definida pelo utilizador podem ser usadas exactamente da mesma forma que as variáveis dos tipos básicos do C++.aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;Aluno aluno2;
aluno2 = aluno1;
Qualquer número racional pode ser representado por uma fracção, que é um par ordenado de números inteiros (n, d), em que n e d são os termos da fracção *. Ao segundo termo dá-se o nome de denominador (é o que dá o nome à fracção) e ao primeiro numerador (diz a quantas fracções nos referimos). Por exemplo, (3, 4) significa "três quartos". Normalmente os racionais representam-se graficamente usando uma notação diferente: n/d.
Uma fracção n/d pode ou não representar um número racional: só se d for não nulo se pode dizer que a fracção corresponde a um número racional. Outra questão importante tem a ver com unicidade de representação. Será que fracções diferentes representam racionais diferentes? A afirmação inversa é verdadeira (racionais diferentes têm forçosamente representações diferentes [ou n ou d o são]), mas -4/2, 2/-1 e -2/1 são fracções que correspondem a um único racional (por acaso também é um inteiro). Para se obter uma representação única de cada racional, é necessário introduzir algumas restrições adicionais à fracções.
Em primeiro lugar, é necessário usar apenas o numerador ou o denominador para conter o sinal do número racional. Como já se impôs uma restrição ao denominador, viz. d <> 0, é natural impor uma restrição adicional: d deve ser não negativo. Assim, d > 0. Mas é necessária uma restrição adicional. Para que a representação seja única, é necessário que n e d não tenham qualquer divisor comum diferente de 1. Ou seja, que mdc(|n|, d) = 1 (uma fracção com mdc(|n|, d) = 1 diz-se nos termos mínimos).
Assim, se n/d for uma fracção em que d > 0 e mdc(n, d) = 1, dir-se-á que a fracção está no formato canónico.
* Há representações alternativas [1].
n1/d1 + n2/d2 = (n1d2 + n2d1)/(d1d2)Mas, tal como definidas, estas transformações de fracções não garantem que o resultado esteja no formato canónico, mesmo que as fracções operando o estejam. No que se segue, assume-se que as fracções operando estão no formato canónico.
n1/d1 - n2/d2 = (n1d2 - n2d1)/(d1d2)
n1/d1 * n2/d2 = (n1n2)/(d1d2)
n1/d1 / n2/d2 = (n1d2)/(n2d1)
n1/d1 + n2/d2 = (l * (n'1d'2 + n'2d'1))/(k d'1d'2)onde
k d'1 = d1Mas o resultado, apesar da divisão por k de ambos os termos da fracção, pode não estar no formato canónico. Repare-se no exemplo:
k d'2 = d2
l n'1 = n1
l n'2 = n2
1/10 + 1/15em que k = mdc(15, 10) = 5. Aplicando a equação acima:
1/10 + 1/15 = (l * (n'1d'2 + n'2d'1))/(k d'1d'2) = (5)/(5 * 6)Para reduzir a fracção aos termos mínimos é necessário dividir ambos os termos da fracção por 5. Ou seja, no caso geral, é necessário calcular m = mdc(|n'1d'2 + n'2d'1|, k) e a versão reduzida da fracção soma é
n1/d1 + n2/d2 = (l * (n'1d'2 + n'2d'1) / m)/((k / m) d'1d'2)As mesmas observações se podem fazer quanto à subtracção.
* Para simplificar, admite-se que mdc(0, 0) = 1.
n1/d1 * n2/d2 = (n1n2)/(d1d2)também é possível que o resultado não esteja no formato canónico, bastando para isso que existam divisores não unitários comuns a n1 e d2 ou a d1 e n2. Mas é fácil verificar que, sendo k = mdc(|n1|, d2) e l = mdc(d1, |n2|), a fracção
n1/d1 * n2/d2 = ((n1 / k) (n2 / l))/((d1 / l) (d2 / k))está, de facto, colocada em termos mínimos.
n1/d1 / n2/d2 = (n1d2)/(n2d1)é muito semelhante ao da multiplicação, sendo mesmo possível usar os métodos acima para a calcular. Mas, primeiro, é necessário verificar se n2 é zero. Se o for, a divisão não está definida. Caso contrário, para se poder usar a multiplicação, ainda é necessário verificar se n2 é positivo. Se o for, a divisão é calculada multiplicando as fracções canónicas n1/d1 e d2/n2. No caso contrário multiplicam-se as fracções canónicas n1/d1 e -d2/-n2.
n1/d1 > n2/d2 <=> n1d2 > n2d1pois ambos os denominadores são positivos.
Podem-se reduzir os valores a comparar a
n'1d'2 > n'2d'1sendo k= mdc(d1, d2), l = mdc(|n1|, |n2|) e l n'1 = n1, l n'2 = n2, k d'1 = d1 e k d'2 = d2.
class Racional {É muito importante estar ciente das diferenças entre a concretização do conceito de racional e o conceito em si: em C++ os int são limitados! Isto significa que não é possível representar qualquer racional numa variável do tipo Racional, tal como não era possível representar qualquer inteiro numa variável do tipo int.
public:
int n; // numerador
int d; // denominador
};
Mas uma classe por si só pouco interesse tem. É necessário definir também as operações que a classe suporta. Por exemplo, se se pretendesse somar dois racionais, poder-se-ia escrever
Racional soma(Racional a, Racional b)(Por enquanto não se garante a canonicidade do resultado.)
{
Racional r;
r.n = b.d * a.n + a.d * b.n;
r.d = a.d * b.d;
return r;
}
Uma possível utilização seria:
#include <iostream>Ao escrever este pedaço de código o programador assumiu dois papeis: fabricante e utilizador. Quando definiu a classe Racional e a função soma(), que opera sobre variáveis dessa classe, fez o papel de fabricante. Quando escreveu a função main(), assumiu o papel de utilizador.
using namespace std;class Racional {
public:
int n; // numerador
int d; // denominador
};Racional soma(Racional a, Racional b)
{
Racional r;
r.n = b.d * a.n + a.d * b.n;
r.d = a.d * b.d;
return r;
}int main()
{
Racional x, y;
x.n = 1;
x.d = 0;
y.n = 7;
y.d = 15;Racional z = soma(x, y);
cout << z.n << '/' << z.d << endl;
}
Podem-se resumir estas ideias num princípio básico da programação:
Princípio do encapsulamento: O fabricante deve esconder do utilizador final tudo o que puder ser escondido. I.e., os pormenores de implementação devem ser escondidos, devendo-se fornecer interfaces limpas e simples para a manipulação das entidades fabricadas (aparelhos de cozinha, relógios, funções C++, classes C++, etc.).
Isso consegue-se, no caso das classes, usando o especificador de acesso private: para esconder os membros da classe:
class Racional {Note-se que, ao se classificar os membros n e d como privados, não se impede o utilizador de, usando mecanismos mais ou menos obscuros, aceder ao seu valor. O facto de um membro ser privado não coloca barreiras muito fortes quanto ao seu acesso. Pode-se dizer que funciona como um aviso, esse sim forte, de que o utilizador não deve aceder a eles, para seu próprio bem (a fabricante poderia, por exemplo, decidir alterar os nomes dos membros para numerador e denominador, com isso invalidando código que fizesse uso directo dos membros da classe). O compilador encarrega-se de gerar erros de compilação por cada acesso ilegal a membros privados duma classe.
private:
int n; // numerador
int d; // denominador
};
Tornados os membros n e d da classe privados, torna-se impossível na função main() atribuir valores directamente aos seus membros. Com isso evitam-se possíveis erros cometidos pelos utilizador. Mas essa solução cria alguns problemas. Como aceder aos membros na função soma()? E como inicializar as variáveis e escrever o resultado no ecrã?
class Racional {São de notar três pontos importantes. O primeiro é que, para o utilizador poder invocar a nova função, é necessário que esta seja um membro público da classe. Daí o especificador de acesso public:, que coloca a nova função membro escreve() na interface da classe com o utilizador. O segundo é que qualquer função membro duma classe tem de ser declarada dentro da definição da classe e definida fora ou, alternativamente, definida (e portanto também declarada) dentro da definição da classe. O terceiro é que a função escreve() foi declarada sem qualquer parâmetro. Onde irá buscar o racional a imprimir?
private:
int n; // numerador
int d; // denominador
public:
void escreve();
};
Em primeiro lugar, lembre-se que o acesso aos membros duma classe se faz usando a notação
variável . nome_do_membroem que variável é uma qualquer instância da classe. Mas nesse caso, a instrução para escrever a variável z no ecrã, no programa acima, deveria passar a ser:
z.escreve();Ou seja, a instância para a qual a função membro escreve() é invocada está explícita na própria invocação, e está implícita durante a execução da função! Mais, essa instância implícita durante a execução pode ser alterada. Tudo funciona como se a instância usada para invocar a função membro fosse passada automaticamente por referência.
Assim sendo, uma possível definição da função membro escreve() seria:
void Racional::escreve()Nesta definição, n e d referem-se aos membros (privados) da instância para a qual a função foi chamada. Assim, nas chamadas
{
cout << n << '/' << d;
}
x.escreve();as variáveis n e d referem-se sucessivamente aos membros de x, y, e z.
y.escreve();
z.escreve();
Mas há um pormenor na definição da função membro escreve() que é novo: o nome da função é precedido de Racional::. Esta notação serve para indicar que escreve() é uma função membro da classe Racional, e não uma função vulgar.
Equipada a classe dessa função membro, o programa pode-se escrever:
#include <iostream>
using namespace std;class Racional {
private:
int n; // numerador
int d; // denominador
public:
void escreve();
};
// PC:
// CO: aparece no ecrã a representação fraccionária do racional *this, sendo *this a instância da classe implícita na execução da função.
void Racional::escreve()
{
cout << n << '/' << d;
}
Racional soma(Racional a, Racional b)Para resolver o problema da função soma() que, não sendo membro da classe, não tem acesso aos membros privados, pode-se usar a mesma técnica: tornar soma() uma função membro. Ou seja:
{
Racional r;
// Erro! Não pode aceder aos membros de a, b e r!
r.n = b.d * a.n + a.d * b.n;
r.d = a.d * b.d;
return r;
}int main()
{
Racional x, y;
// Erro! Não pode aceder aos membros de x e y!x.n = 1;x.d = 0;y.n = 7;y.d = 15;Racional z = soma(x, y);
z.escreve();
cout << endl;
}
class Racional {
private:
int n; // numerador
int d; // denominador
public:
void escreve();
Racional soma(Racional b);
};
// PC:A utilização desta nova função membro faz-se como anteriormente em relação à função escreve():
// CO: r = *this + b, sendo *this a instância da classe implícita na execução da função.
Racional Racional::soma(Racional b)
{
Racional r;
r.n = b.d * n + d * b.n;
r.d = d * b.d;
return r;
}
int main()É certo que a forma de invocar a função membro soma() se tornou, à primeira vista, um pouco estranha. Mas mais tarde ver-se-á como sobrecarregar o operador soma do C++ (+) de modo que se possa escrever simplesmente Racional z = x + y;.
{
Racional x, y;
// Erro! Não pode aceder aos membros de x e y!x.n = 1;x.d = 0;y.n = 7;y.d = 15;Racional z = x.soma(y);
z.escreve();
cout << endl;
}
Idealmente seria desejável inicializar essas variáveis usando as formas usuais do C++. Por exemplo, se se pode escrever
int i = 1;para definir e inicializar as variáveis i e j, do tipo int, com os valores 1 e 10, respectivamente, também seria desejável poder inicializar as variáveis a e b do programa como se segue:
int j(10);
Racional a = 1;Isso é possível desde que se equipe a classe Racional de funções membro especiais chamadas construtores. Estas funções são especiais por várias razões:
Racional b(7, 15);
class Racional {Procede-se da mesma forma se se pretender tornar válida a segunda forma de inicialização (inicialização de b acima). Mas, especificadas essas duas formas de inicialização, torna-se impossível não usar pelo menos uma delas ao definir uma nova variável da classe Racional. Por exemplo,
private:
int n; // numerador
int d; // denominador
public:
// Normalmente os construtores são as primeiras funções membro a
// declarar e a definir:
Racional(int i);
void escreve();
Racional soma(Racional b);
};Racional::Racional(int i)
{
n = i;
d = 1;
}
Racional c;é uma definição inválida. Isto acontece porque não se criou um construtor sem argumentos que fosse chamado nestas circunstâncias e que atribuisse um valor razoável (por exemplo 0) ao racional. Assim, definir-se-ão dois novos construtores:
#include <cstdlib> // para poder usar a função exit().class Racional {
private:
int n; // numerador
int d; // denominador
public:
// Normalmente os construtores são as primeiras funções membro a
// declarar e a definir:
Racional(int num, int den);
Racional(int i);
Racional();
void escreve();
Racional soma(Racional b);
};// Construtor com dois parâmetros (numerador e denominador):
Racional::Racional(int num, int den)
{
// Se den = 0, então o programa deve assinalar uma falha
// grave! Para já terminar-se-á o programa. Mais tarde
// se aprenderão formas mais elegantes de lidar com estes
// casos:
if(den == 0) {
// É má ideia escrever no ecrã aqui, mas por enquanto
// não conhecemos melhores alternativas:
cerr << "Erro: denominador inválido!" << endl;
exit(1);
} else if(den < 0) {
n = -num;
d = -den;
} else {
n = num;
d = den;
}
}// Construtor com um parâmetro (número inteiro):
Racional::Racional(int i)
{
n = i;
d = 1;
}// Construtor sem parâmetros:
Racional::Racional()
{
n = 0;
d = 1;
}
#include <cstdlib> // para poder usar a função exit().class Racional {
private:
int n; // numerador
int d; // denominador
public:
// Normalmente os construtores são as primeiras funções membro a
// declarar e a definir:
Racional(int num = 0, int den = 1);
void escreve();
Racional soma(Racional b);
};// Construtor com dois parâmetros (numerador e denominador):
Racional::Racional(int num, int den)
{
// Se den = 0, então o programa deve assinalar uma falha
// grave! Para já terminar-se-á o programa. Mais tarde
// se aprenderão formas mais elegantes de lidar com estes
// casos:
if(den == 0) {
// É má ideia escrever no ecrã aqui, mas por enquanto
// não conhecemos melhores alternativas:
cerr << "Erro: denominador inválido!" << endl;
exit(1);
} else if(den < 0) {
n = -num;
d = -den;
} else {
n = num;
d = den;
}
}
#include <iostream>
#include <cstdlib>
using namespace std;class Racional {
private:
int n; // numerador
int d; // denominador
public:
// Normalmente os construtores são as primeiras funções membro a
// declarar e a definir:
Racional(int num = 0, int den = 1);
void escreve();
Racional soma(Racional b);
};// Construtor com dois parâmetros (numerador e denominador):
Racional::Racional(int num, int den)
{
// Se den = 0, então o programa deve assinalar uma falha
// grave! Para já terminar-se-á o programa. Mais tarde
// se aprenderão formas mais elegantes de lidar com estes
// casos:
if(den == 0) {
// É má ideia escrever no ecrã aqui, mas por enquanto
// não conhecemos melhores alternativas:
cerr << "Erro: denominador inválido!" << endl;
exit(1);
} else if(den < 0) {
n = -num;
d = -den;
} else {
n = num;
d = den;
}
}
// PC:
// CO: aparece no ecrã a representação fraccionária do racional *this, sendo *this a instância da classe implícita na execução da função.
void Racional::escreve()
{
cout << n << '/' << d;
}// PC:
// CO: r = *this + b, sendo *this a instância da classe implícita na execução da função.
Racional Racional::soma(Racional b)
{
Racional r;
r.n = b.d * n + d * b.n;
r.d = d * b.d;
return r;
}
int main()O leitor mais atento terá reparado que, na CO das duas funções membro escreve() e soma(), aparece a construção *this. Esta construção refere-se à instância implícita durante a execução da função. Assim, quando escreve() é invocada através de z.escreve() (em main()), *this refere-se à variável z. Posto isto, é evidente que o corpo da função escreve() poderia ter sido escrito como
{
Racional x = 1, y(7, 15);
Racional z = x.soma(y);
z.escreve();
cout << endl;
}
cout << (*this).n << '/' << (*this).d;onde os parânteses são necessários porque o operador . tem maior precedência que o operador *. Mas tarde compreenderá o que significa o *: por enquanto não se preocupe.
#include <iostream>ou, ainda mais simples,
#include "racional"int main()
{
Racional x = 1, y(7, 15);
Racional z = x + y;
cout << z << endl;
}
#include <iostream>
#include "racional"int main()
{
cout << Racional(1) + Racional(7, 15) << endl;
}
2. Sendo numerador e denominador representados por variáveis membro do tipo int, é possível que a sua gama de valores admissíveis seja excedido durante o cálculo simplório da soma apresentado (e.g., se os inteiros tivessem 10 bits e fossem representados em complemento para dois, a soma 1/500 + 1/500 daria 1000/250000, em que 250000 excede em muito a gama -512 a 511 correspondente a 10 bits, isto apesar de a soma, em termos mínimos, ser 1/250!). Reescreva a função soma() evitando tanto quanto possível que a gama dos inteiros seja excedida.
3. Complete a classe Racional apresentada acima:
a) Implemente, na forma de funções membro, as operações
elementares adicionais usando números racionais (i.e., subtracção,
multiplicação e divisão). Implemente também
uma função membro para calcular o simétrico.
b) Implemente, na forma de funções membro, as operações
de comparação e relacionais (i.e., igualdade, diferença,
maior, maior ou igual, menor, menor ou igual).
c) Implemente uma função membro void lê()
para ler um número racional do teclado.
d) Escreva um programa que teste a classe desenvolvida.