Aula 10


1  Resumo da matéria

1.1  Introdução

Quando se fala duma linguagem de programação como o C++ não se fala apenas da linguagem em si, com o seu léxico, sintaxe, gramática e semântica.  Fala-se também dum conjunto de ferramentas acessíveis ao programador que, não fazendo parte da linguagem propriamente dita, estão acessíveis em qualquer implementação da linguagem.  Ao conjunto dessas ferramentas adicionais chama-se biblioteca padrão (standard library).  Dela fazem parte, por exemplo, os famosos cin e cout, que permitem leituras e escritas do teclado e para o ecrã.  Assim, em rigor, o programador tem à sua disposição não apenas o C++ em si mas o C++ equipado com a biblioteca padrão.  Para o programador, no entanto, tudo funciona como se a linguagem em si incluisse essas ferramentas originais.  Isto é, para o programador o que está acessível não é o C++, mas um "C++ ++" de que fazem parte todas as ferramentas da biblioteca.

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.

1.2  Introdução às classes

A construção de novos tipos em C++ faz-se usando o conceiro de classe.  Suponha-se que se desejava, no âmbito do desenvolvimento dum programa de gestão de uma escola, equipar a linguagem C++ com um novo tipo chamado Aluno.  Uma vez que um aluno consiste, no mínimo, de informação relativa ao seu nome e número, a forma mais simples de o fazer seria escrever:
class Aluno {
  public:
    string nome;
    int número;
};
A sintaxe de definição de um tipo é, portanto
class nome_do_tipo {
    declaração_de_membros
};
sendo importante notar que este é um dos poucos locais onde o C++ exige um ; depois da } final.

1.2.1  Definição de classes

A definição de uma classe é, portanto, a declaração dos seus membros.  No caso apresentado, as variáveis do tipo Aluno, quando forem criadas, consistem em dois membros: um nome do tipo string (que guarda sequências arbitrárias de caracteres, está definido na biblioteca padrão e é utilizável fazendo #include <string> no início do programa), e um número do tipo int.  Tal como as matrizes, as classes permitem guardar agregados de informação (ou seja, agregados de variáveis, chamados elementos no caso das matrizes e membros no caso das classes), com a diferença de que, no caso das classes, essa informação pode ser de tipos diferentes.

1.2.2  Selecção de membros

Como se pode usar a nova classe?  Por exemplo, o código
Aluno aluno1;

aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;

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.

É importante perceber que cada variável do tipo Aluno possui as suas próprias variáveis membro.  Por exemplo:

Aluno aluno1;

aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;

Aluno aluno2;

aluno2.nome = "Xisto Ximenes";
aluno1.número = 321;
 

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:
int número = 1000;
Aluno aluno1;

aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;

Aluno aluno2;

aluno2.nome = "Xisto Ximenes";
aluno1.número = 321;

sem que isso causasse qualquer problema.

* Em rigor, o ponto (.) é o operador de selecção de membro, como se pode ver na Tabela Precedência de operadores.

1.2.3  Alguma nomenclatura

A uma variável de uma dada classe é usual chamar-se, no jargão da programação orientada para os objectos, uma "instância" da classe.  Esta palavra, já existindo em português, foi importada do inglês recentemente com o novo significado de "exemplo concreto" (cf. a expressão "for instance", com o significado de "por exemplo").  Assim, uma variável duma classe é uma instância ou exemplo concreto dum espécimen dessa classe, tal como uma mulher ou um homem são instâncias da classe dos humanos.  Assim, às variáveis membro é vulgar chamar-se variáveis de instância.

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.

1.2.3  Operações suportadas

Ao contrário do que se passa com as matrizes, as instâncias (variáveis) duma classe podem-se atribuir livremente entre si.  O efeito de uma atribuição é o de copiar todos as variáveis de instância da variável.  Assim,
Aluno aluno1;

aluno1.nome = "Zacarias Zagalo";
aluno1.número = 123;

Aluno aluno2;

aluno2 = 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++.

1.2.4  Membros públicos e privados

No definição da classe Aluno apresentada apareceu uma construção que até agora não foi descrita: public:.  Os membros de uma classe em C++ podem pertencer a uma de três categorias de acesso: público, protegido e privado.  Para já apenas se descreverão a primeira e a última.  Membros públicos, introduzidos pelo especificador de acesso public:, são acessíveis sem qualquer restrição.  Membros privados, introduzidos pelo especificador de acesso private:, são acessíveis apenas por membros da mesma classe (ou, alternativamente, por funções "amigas" da classe, que serão vistas mais tarde).  As vantagens de definir membros privados ficarão claras mais tarde.

1.3  Construção de classes

O exemplo que se segue é algo académico, mas tem a vantagem de mostrar claramente os mecanismos de construção de um novo tipo proporcionados pelo C++.

1.3.1  Os números racionais

Suponha-se que se pretende acrescentar ao C++ um novo tipo para representar números racionais sem qualquer perda de precisão.  Seja esse novo tipo chamado Racional.

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].

Operações elementares

As operações elementares (adição, subtracção, multiplicação e divisão) estão bem definidas para os racionais (com excepção da divisão por 0, ou melhor, por 0/1).  Assim, em termos da representação dos racionais como fracções:
n1/d1 + n2/d2 = (n1d2 + n2d1)/(d1d2)
n1/d1 - n2/d2 = (n1d2 - n2d1)/(d1d2)
n1/d1 * n2/d2 = (n1n2)/(d1d2)
n1/d1 / n2/d2 = (n1d2)/(n2d1)
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.
Adição e subtracção
Ao observar o resultado da soma de fracções acima, verifica-se facilmente que, se k = mdc(d1, d2), e se l = mdc(n1, n2) *, então, dividindo ambos os termos da fracção por k e colocando l/k em evidência:
n1/d1 + n2/d2 = (l * (n'1d'2 + n'2d'1))/(k d'1d'2)
onde
k d'1 = d1
k d'2 = d2
l n'1 = n1
l n'2 = n2
Mas 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:
1/10 + 1/15
em 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.

Multiplicação
Relativamente à multiplicação de fracções
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.
Divisão
O caso da divisão de fracções
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.

Operações de igualdade e desigualdade

Sendo duas fracções canónicas f1 = n1/d1 e f2 = n2/d2, é evidente que f1 = f2 se e só se n1 = n2 e d1 = d2.  Ou, o que é o mesmo, f1 <> f2 se e só se n1 <> n2 ou d1 <> d2.

Operações relacionais

Finalmente, como se pode definir uma relação de ordem entre os racionais, podem-se definir as operações relacionais habituais (maior, maior ou igual, menor, e menor ou igual).  Nesse caso tem-se
n1/d1 > n2/d2 <=> n1d2 > n2d1
pois ambos os denominadores são positivos.

Podem-se reduzir os valores a comparar a

n'1d'2 > n'2d'1
sendo 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.

1.3.2  Implementação em C++

Como se representam os racionais em C++, i.e., como se concretiza o conceito de racional criando um tipo Racional em C++?  Poder-se-ia argumentar que, sendo os racionais representáveis por pares de inteiros, bastaria usar matrizes definidas como int r[2]; para representar qualquer racional.  E seria correcto: é possível usar matrizes neste caso.  Mas é, no mínimo, pouco prático, dadas as restrições às operações sobre matrizes (não se podem atribuir, devolver, passar por valor...).  Uma melhor forma de representar os racionais, como se perceberá no final, é usando o conceito de classe em C++.

Definição da classe

Uma vez que se verificou serem os racionais representáveis por fracções, que são pares ordenados de inteiros, uma possível concretização seria:
class Racional {
  public:
    int n; // numerador
    int d; // denominador
};
É 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.

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)
{
    Racional r;
    r.n = b.d * a.n + a.d * b.n;
    r.d = a.d * b.d;
    return r;
}
(Por enquanto não se garante a canonicidade do resultado.)

Uma possível utilização seria:

#include <iostream>
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;
}

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.

Encapsulamento: membros públicos e privados

O leitor mais atento terá reparado que o código acima tem alguns problemas.  Um dos mais graves é de que a classe Racional não tem qualquer mecanismo que impeça o utilizador de atribuir 0 (zero) ao denominador duma fracção!  Isso é claramente indesejável, e tem como origem o facto do fabricante ter tornado públicos os membros n e d da classe.  Tal como o utilizador dum relógio ou de um micro-ondas assume que não precisa de conhecer o funcionamento interno desses aparelhos (cujo mecanismo se encontra escondido por uma caixa) recorrendo apenas a uma interface, também o fabricante da classe Racional deveria ter escondido os pormenores de implementação da classe do utilizador final.

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 {
  private:
    int n; // numerador
    int d; // denominador
};
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.

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ã?

Construção da interface: definição de funções membro

Uma vez que a membros privados têm acesso quaisquer outros membros da classe, a solução passa por criar uma função membro da classe Racional para escrever os racionais no ecrã (mais tarde se verá que, para estes efeitos, existem soluções alternativas).  O primeiro passo é declarar a função membro escreve():
class Racional {
  private:
    int n; // numerador
    int d; // denominador
  public:
    void escreve();
};
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?

Em primeiro lugar, lembre-se que o acesso aos membros duma classe se faz usando a notação

variável . nome_do_membro
em 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()
{
    cout << n << '/' << d;
}
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
x.escreve();
y.escreve();
z.escreve();
as variáveis n e d referem-se sucessivamente aos membros de x, y, e z.

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)
{
    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;
}

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:
class Racional {
  private:
    int n; // numerador
    int d; // denominador
  public:
    void escreve();
    Racional soma(Racional b);
};
// 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;
}
A utilização desta nova função membro faz-se como anteriormente em relação à função escreve():
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 = x.soma(y);
    z.escreve();
    cout << endl;
}

É 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;.

Inicializando membros privados: construtores

Finalmente, falta resolver o problema da inicialização das variáveis x e y da função main(), tornada impossível quando os membros n e d da classe foram definidos como privados.

Idealmente seria desejável inicializar essas variáveis usando as formas usuais do C++.  Por exemplo, se se pode escrever

int i = 1;
int j(10);
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:
Racional a = 1;
Racional b(7, 15);
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:
  1. São usadas para inicializar as variáveis membro da classe quando uma instância da classe é criada.
  2. O seu nome é sempre, por definição, igual ao nome da classe a que pertencem.
  3. Não podem devolver qualquer valor em nenhuma circunstância, pelo que no seu cabeçalho não se coloca nunca o tipo de devolução.
Assim, para criar um construtor que aceite um único argumento inteiro que leve à validade da inicialização da variável a acima,
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 i);
    void escreve();
    Racional soma(Racional b);
};

Racional::Racional(int i)
{
    n = i;
    d = 1;
}

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,
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;
}

Simplificando os construtores: parâmetros com valores por omissão

O C++ permite a definição de funções em que alguns parâmetros têm valores por omissão.  I.e., se não forem colocados os argumentos respectivos numa chamada à função, os parâmetros serão inicializados com os valores por omissão em vez de serem inicializados com o valor dos argumentos.  Mas com uma restrição: os parâmetros com valores por defeito têm de ser os últimos da função.  Este tipo de facilidade permite reduzir os três construtores anteriores a um único:
 
#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;
    }
}

O programa completo

#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()
{
    Racional x = 1, y(7, 15);
    Racional z = x.soma(y);
    z.escreve();
    cout << endl;
}
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
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.

Notas finais

Em aulas posteriores se verá como alterar a classe racional para que o programa se possa escrever simplesmente:
#include <iostream>
#include "racional"

int main()
{
    Racional x = 1, y(7, 15);
    Racional z = x + y;
    cout << z << endl;
}

ou, ainda mais simples,
#include <iostream>
#include "racional"

int main()
{
    cout << Racional(1) + Racional(7, 15) << endl;
}

1.3.3  Exercícios

1.  Reparou que a classe Racional acima não garante que as fracções estejam em termos mínimos?  Que deveria fazer para o garantir?  Se forem necessárias funções auxiliares, pense se deverão ser funções normais ou funções membro da classe e, no último caso, pense se deverão ser membros públicos ou privados.

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.

2  Referências

[1]  Ronald L. Graham, Donald E. Knuth e Oren Patashnik, "Concrete Mathematics: A Foundation for Computer Science", segunda edição, Addison-Wesley, 1994.