Guião da 3ª Aula Teórica

Sumário

  1. Conceitos de modularização, abstracção e encapsulamento.  Vantagens.  Características desejáveis dos módulos.  Interface e implementação: "caixas pretas".
  2. Rotinas como unidades atómicas de modularização em C++.
  3. Abordagem descendente para a resolução de problemas.
  4. Sintaxe da definição de rotinas: cabeçalho vs. corpo.
  5. Cabeçalho como interface e corpo como implementação.
  6. Contratos: pré-condições e condições objectivo.
  7. Instruções de asserção e programação por contrato.
  8. Parâmetros como variáveis especiais de entrada inicializadas com o valor dos argumentos.  Parâmetros: variáveis.  Argumentos: expressões.
  9. Instrução return.  Conceitos de retorno e devolução.  Caso dos procedimentos: não há devolução (tipo de retorno void).
  10. Sintaxe da invocação ou chamada.

Perguntar quem é audiófilo, ou quem tem aparelhagem.  Pedir para a descrever.

Uma aparelhagem é constituída por várias partes: colunas, amplificador, sintonizador, leitor de cassetes, leitora de CD.

Existem aparelhagens integradas, tipicamente mais baratas.

Quais as vantagens da divisão de uma aparelhagem em módulos?

Listar e discutir.

As vantagens são pelo menos:

  1. Pode-se substituir qualquer módulo por um melhor sem trocar a aparelhagem toda.
  2. Em caso de avaria apenas se manda concertar o módulo avariado, a aparelhagem até pode continuar a funcionar parcialmente (excepto se for o amplificador).
  3. Podem-se acrescentar novos módulos: pode-se acrescentar um leitor de DVD, por exemplo, ou pode-se ligar o computador ao amplificador.
  4. Ou seja, reaproveitam-se os módulos: um único amplificador serve para todos os módulos.
Note-se que a TV normal que temos em casa tem o seu próprio amplificador e colunas!  É um desperdício de dinheiro!

Quais as vantagens da divisão em módulos para o produtor?

Listar e discutir.

As vantagens são pelo menos:

  1. Cada módulo pode ser desenvolvido independentemente.  Pode-se atribuir uma equipa a cada módulo.
  2. Pode-se melhorar o funcionamento de um módulo sem que isso afecte o todo.
  3. Pode-se produzir os módulos de acordo com a procura.  Se os gira-discos têm pouca procura também podem ter menos produção: sensato do ponto de vista económico.
  4. A assistência técnica é simplificada.  É mais fácil identificar a origem do problema.  Por exemplo, é no módulo de amplificação.
Quais é que são as boas características de uma divisão em módulos?

Listar e discutir:

Pelo menos:

  1. Cada módulo tem uma única função bem definida.
  2. Cada módulo é muito coeso: os seus componentes internos têm fortes ligações entre si levando ao funcionamento pretendido para o módulo.
  3. As ligações entre os módulos são o mais pequenas possível.  Se existirem muitas ligações entre módulos diferentes talvez devessem ser integrados num único módulo.  Se se abrir o amplificador e se tentar dividir em dois módulos o mais certo é terminar com um emaranhado de cabos entre os dois, com nenhuma vantagem evidente.
Perguntar a um deles que tenha aparelhagem para que serve o amplificador.  Perguntar também com se usa.  Perguntar como funciona. Se a resposta for que não sabe, perguntar se isso o preocupa.

A isso se chama capacidade de abstracção: para quê preocuparmo-nos com pormenores que são irrelevantes para a utilização?  Quando conduzimos o automóvel preocupamo-nos com o mecanismo da embraiagem? Não!

O que se ganha com a abstracção?  Acontece que o humano tem capacidades limitadas.

Pedir a um deles para dizer rapidamente quantos traços escrevi no ecrã.  Começar por 1 e 2, passar a 5, saltar para 10 e 19.  Explicar a nossa dificuldade em abarcar demasiadas coisas ao mesmo tempo.  Mencionar que os indivíduos capazes de dizer rapidamente quantos fósforos há numa caixa de fósforos aberta poucos instantes antes são raros, casos patológicos, e normalmente vivem em hospitais psiquiátricos.

A abstracção permite-nos limitar a quantidade de informação com que temos de lidar.  Isso leva a um melhor desempenho da tarefa que estamos a realizar, onde cometemos menos erros.  Isto é verdade desde a condução de automóveis até à escrita de programas!

Os módulos têm uma função bem definida e uma interface que permite utilizá-los.  Por exemplo, o amplificador tem, por trás, entradas para os sinais a amplificar e, à frente, o botão da alimentação, o controlo do volume, e pouco mais.  O interior está acessível ao consumidor?  Não!

E no caso de um relógio?  O interior está acessível?  Não!  Só se vê o mostrador com os ponteiros!  Mesmo nos relógios com caixa transparente o mecanismo está inacessível.  Porquê?

Perguntar e discutir.

Porque:

  1. Protege o consumidor dos seus próprios erros.  Se o mecanismo do relógio estivesse à vista o mais certo era deixar de funcionar ao fim de uns dias...
  2. Para facilitar ao consumidor abstrair-se do funcionamento interno do módulo e pensar apenas no seu comportamento externo!  Já imaginaram conduzir um automóvel sem tablier, com o motor à frente dos olhos?  Só com muito treino nos habituaríamos a não olhar para o mecanismo enquanto conduzimos: a probabilidade de acidente aumentaria...
É o chamado princípio do encapsulamento.  O mecanismo interno dos módulos deve ser "encapsulado" numa caixa opaca (a que se chama "caixa preta"), ficando de fora apenas a chamada interface.

O encapsulamento facilita a abstracção!

Em programação a divisão em módulos, a abstracção e o encapsulamento são conceitos muito importantes e úteis!

Hoje vamos falar de divisão em módulos e de abstracção.  O encapsulamento ficará para mais tarde.

Um programador assume dois papeis distintos quando desenvolve um programa.  É produtor dos módulos por um lado, quando desenvolve o seu mecanismo.  É consumidor, quando os integra num sistema mais complexo.

Como produtor preocupa-se com o que o módulo faz, como o consumidor o vai usar, e como funciona o seu mecanismo interno.  Produz o módulo, as suas especificações técnicas e diagramas da sua concretização prática, para além de preparar o manual de utilização.

Como consumidor abstrai-se do mecanismo interno, preocupando-se apenas com o que o módulo faz e como o deve utilizar: basta-lhe o manual do utilizador.

É importante perceber que é estabelecido um contrato informal entre produtor e consumidor: o produtor indica a forma correcta de utilização e garante (pelo menos dentro de um prazo) que se o utilizador fizer como está lá indicado, o módulo funcionará como pretendido.

Mas afinal, o que são os módulos em C++?  Os módulos básicos são as rotinas, que podem ser funções ou procedimentos, e que se estudarão de seguida.

Nas aulas anteriores discutiu-se o que eram algoritmo e programa, qual o significado de tipo, variável e valor literal em C++, alguns dos operadores do C++.  Mas, como se pega em tudo o que aprendemos e se resolve um problema?

Há muitas abordagens para a resolução de problemas.  Porventura uma das mais clássicas em programação é chamada abordagem descendente, ou top down.  Nesta abordagem o problema começa por ser analisado na globalidade, tentando-se identificar um conjunto pequeno de subproblemas em que possa ser decomposto.  Feita esta decomposição, pode-se analisar cada subproblema da mesma forma, dividindo-o em subsubproblemas mais simples.  E assim sucessivamente, até se chegar a problemas de resolução trivial.

Vamos estudar um exemplo.  Suponhamos que pretendemos escrever um programa para somar duas fracções positivas fornecidas pelo utilizador e mostrar o resultado no ecrã na forma de uma fracção irredutível.  Por exemplo, dadas as fracções:

6/9 + 7/3

o resultado deve ser

3/1

Como resolver o problema?  Pensemos no problema na globalidade.  É fácil identificar três subproblemas:
  1. Ler as fracções.
  2. Calcular a fracção soma reduzida.
  3. Escrever o resultado.
Ou seja:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções.
    ...

    // Calcular fracção soma reduzida.
    ...

    // Escrever resultado.
    ...
}

Agora podemos começar a abordar cada subproblema.  Como ler as duas fracções?  

Primeiro é necessário dizer ao utilizador do programa que deve introduzir as duas fracções:

// Ler fracções:
cout << "Introduza duas fracções (numerador denominador): ";

Como as representar no programa?  É necessário criar quatro variáveis int, pois cada fracção tem um numerador e um denominador:

// Ler fracções:
cout << "Introduza duas fracções (numerador denominador): ";
int n1, d1, n2, d2;

Depois é necessário ler o seu valor:

// Ler fracções:
cout << "Introduza duas fracções (numerador denominador): ";
int n1, d1, n2, d2;
cin >> n1 >> d1 >> n2 >> d2;

Ou seja:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Calcular fracção soma reduzida.
    ...

    // Escrever resultado.
    ...
}

Como escrever o resultado?  Escrevendo as fracções de entrada seguidas da fracção de resultado.  Vamos, para já, adiar o problema da escrita de cada fracção, admitindo que existe um procedimento chamado escreveFracção() para o efeito.  O que é um procedimento?  É um módulo constituído por um conjunto de instruções com uma interface bem definida e que faz qualquer coisa.  Ou seja:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Calcular fracção soma reduzida.
    ...

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(?, ?);
    cout << '.' << endl;
}

Frisar reaproveitamento do código escrito três vezes!

Mas escrever o quê para o resultado?  O resultado da soma deve ser colocado em algum lado e já vimos que para isso são necessárias variáveis.  É portanto necessário definir duas variáveis para o numerador e o denominador da soma:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Calcular fracção soma reduzida:
    int n;
    int d;

    ...

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(n, d);
    cout << '.' << endl;
}

Falta-nos pois calcular a soma.  Como o fazer?

Discutir soma de fracções.  Denominador é mínimo múltiplo comum dos denominadores.  Mas existe forma mais simples.

A forma mais simples de somar é reduzir ao mesmo denominador multiplicando ambos os termos da primeira fracção pelo denominador da segunda e vice versa.

Ou seja:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Calcular fracção soma reduzida:
    int n = d2 * n1 + d1 * n2;
    int d = d1 * d2;

    ...

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(n, d);
    cout << '.' << endl;
}

Mas chega?  Que aconteceria com as entradas 6/9 e 7/3?

O resultado seria 81/27!  Não está reduzida!  Como reduzir?

Discutir.

Reduzir uma fracção é dividir numerador e denominador pelo máximo divisor comum dos dois!  Então, podemos escrever:

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Calcular fracção soma reduzida:
    int n = d2 * n1 + d1 * n2;
    int d = d1 * d2;
    int k = mdc(n, d);
    n /= k;
    m /= k;

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(n, d);
    cout << '.' << endl;
}

Recordar /=!

Que significa mdc(n, d)?  Será que a linguagem C++ já tem forma de calcular o máximo divisor comum?  Infelizmente não.  Temos de ensinar o computador.  O que fizemos foi, mais uma vez, adiar o problema.  Assumimos que existia uma função mdc() para fazer o cálculo pretendido.

Assinalar bem:

Procedimento faz qualquer coisa.

Função calcula qualquer coisa.

Já agora podemos também reduzir logo as fracções de entrada!

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;
    d1 /= k;
    int k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Calcular fracção soma reduzida:
    int n = d2 * n1 + d1 * n2;
    int d = d1 * d2;
    int k = mdc(n, d);
    n /= k;
    m /= k;

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(n, d);
    cout << '.' << endl;
}

Mas cometemos um erro!  O C++ não deixa definir duas variáveis com o mesmo nome na mesma rotina!
Podemos usar sempre o mesmo k.

#include <iostream>

using namespace std;

int main()
{
    // Ler fracções:
    cout << "Introduza duas fracções (numerador denominador): ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;
    d1 /= k;
    int k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Calcular fracção soma reduzida:
    int n = d2 * n1 + d1 * n2;
    int d = d1 * d2;
    int k = mdc(n, d);
    n /= k;
    m /= k;

    // Escrever resultado:
    cout << "A soma de ";
    escreveFracção(n1, d1);
    cout << " com ";
    escreveFracção(n2, d2);
    cout << " é ";
    escreveFracção(n, d);
    cout << '.' << endl;
}

Falta-nos pois definir a função mdc() e o procedimento escreveFracção().  No fundo o que estamos a fazer é a artilhar o C++ com mais operações!

Mas o máximo divisor comum já vimos como se calcula!  Para colocar em r o máximo divisor comum de m e n (variáveis inteiras), basta fazer

int m;
int n;
//
Inicializadas automagicamente!

int r;

if(m < n)
    r = m;
else
    r = n;

while(m % r != 0 or n % r != 0)
    --r;

Desenhar variáveis em UML.

Aliás, como m e n nunca mudam de valor, podem ser constantes.  Que é isso?

As variáveis têm três características: nome, tipo e valor.  A terceira característica pode mudar.  As outras são fixas.  Se quisermos que o valor seja fixo, então define-se uma constante.  Uma constante define-se como uma variável, mas tem a palavra chave const após o tipo:

int const m;
int const n;
//
Inicializadas automagicamente!

int r;

if(m < n)
    r = m;
else
    r = n;

while(m % r != 0 or n % r != 0)
    --r;

Desenhar constantes em UML.  Basta escrever {frozen} no cabeçalho.

Se necessário explicar porque, sendo constantes, podem assumir valores diferentes ao longo da execução do programa: duração das variáveis.

É necessário agora envolver este código numa caixa, encapsulá-lo, torná-lo num módulo:

{
    int const m;
    int const n;
    //
Inicializadas automagicamente!

    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;
}

Resta agora dizer que este módulo se chama mdc:

mdc
{
    int const m;
    int const n;
    // Inicializadas automagicamente!

    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;
}

Mas falta algo.  Como inicializar m e n?  Repare-se que se pretende inicializar m e n primeiro com os valores de n1 e d1, depois com os valores de n2 e d2, e finalmente com os valores n e d!  É preciso dizer que m e n são variáveis, aliás, constantes especiais de entrada: os chamados parâmetros.

mdc(int const m, int const n)
{
    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;
}

Finalmente falta dizer que o valor contido em r no final da função é o valor de saída da função.  Para isso usa-se uma instrução de retorno:

mdc(int const m, int const n)
{
    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;

    return r;
}

Mas temos de indicar ao compilador que esta função tem como valor de saída ou devolução um valor do tipo int!

int mdc(int const m, int const n)
{
    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;

    return r;
}

E temos a função definida!  A definição compõe-se de um cabeçalho e um corpo.  O cabeçalho diz como se usa a função: passa-se-lhe dois inteiros e ela devolve outro como resultado.  O corpo diz como funciona.  Mas não há nada que diga o que faz a função!  Para isso usam-se comentários com três partes:

/** Devolve o máximo divisor comum dos inteiros 
    positivos passados como argumento.
    PC: 0 < m e 0 < n.
    CO: o valor r devolvido é o mdc de m e n. */
int mdc(int const m, int const n)
{
    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;

    return r;
}

Explicar PC e CO.  Explicar que normalmente é desejável que a pré-condição seja tão fraca (permissiva) quanto possível, acontecendo o contrário para a condição objectivo.

Dizer que normalmente se usa @pre e @post no código real:

/** Devolve o máximo divisor comum dos inteiros positivos passados como argumento.
    @pre 0 < m e 0 < n.
    @post o valor r devolvido é o mdc de m e n. */
int mdc(int const m, int const n)
{
    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;

    return r;
}

Finalmente, deve-se sempre explicitar a pré-condição e a condição objectivo no próprio código.  Para isso usa-se as instruções de asserção.  

Atenção:  "Asserção" é o mesmo que "afirmação" ou "proposição que se apresenta como verdadeira".

Explicar instruções de asserção.  Dizer o que acontece quando uma asserção é violada.

Com as instruções de asserção, o código fica:

/** Devolve o máximo divisor comum dos inteiros positivos passados como argumento.
    @pre 0 < m e 0 < n.
    @post o valor r devolvido é o mdc de m e n. */
int mdc(int const m, int const n)
{
    assert(0 < m and 0 < n);

    int r;

    if(m < n)
        r = m;
    else
        r = n;

    while(m % r != 0 or n % r != 0)
        --r;

    assert(0 < r and m % r == 0 and n % r == 0);

    return r;
}

Explicar brevemente problemas ao escrever a condição objectivo.

Note-se bem na diferença entre definição e invocação.  Na invocação escreve-se o nome da função seguida de uma lista de expressões que servem para inicializar as variáveis ou constantes que são os parâmetros.  Por isso eles não são inicializadas explicitamente na função!  A estas expressões chama-se argumentos.

É importante perceber que os parâmetros são inicializados com os valores dos argumentos em cada chamada ou invocação da função!

Exemplificar fazendo pequeno traçado.

Explicar retorno.  return + expressão.  A função ao terminar em return retorna (volta ao ponto em que foi chamada) e devolve um valor.  Distinguir bem retornar e devolver!  Dizer que em inglês "return" significa tanto retornar como devolver.  O português aqui é mais rico e tiramos partido disso.

Será que as duas variáveis n, uma na função main() (sim! main() é uma função!) e outra na função mdc() têm alguma coisa em comum?  Não!  Estão em contextos diferentes!  Numa aparelhagem também existem itens distintos com nomes iguais em módulos distintos: por exemplo o botão de ligar e desligar.  São variáveis locais, que existem apenas no contexto da função em que são definidas.

Falta o procedimento escreveFracção().  Um procedimento não devolve nada.  Limita-se a fazer qualquer coisa.  Isso indica-se dizendo que devolve void (vazio)!

/** Escreve no ecrã uma fracção, no formato usual, que
    lhe é passada na forma de dois argumentos inteiros positivos.
    @pre nenhuma.
    @post o ecrã contém n/d em que n e d são os valores de n e d em base
          decimal. */
void escreveFracção(int const n, int const d)

{
}

Qual o corpo do procedimento?  Basta que escreva a fracção:

/** Escreve no ecrã uma fracção, no formato usual, que
    lhe é passada na forma de dois argumentos inteiros positivos.
    @pre nenhuma.
    @post o ecrã contém n/d em que n e d são os valores de n e d em base decimal. */
void escreveFracção(int const n, int const d)
{
    cout << n << '/' << d;
}

Um procedimento retorna quando se atinge a chaveta final ou quando ocorre um return sem expressão!  Ou seja, podia-se ser redundante dizendo:

/** Escreve no ecrã uma fracção, no formato usual, que
    lhe é passada na forma de dois argumentos inteiros positivos.
    @pre nenhuma.
    @post o ecrã contém n/d em que n e d são os valores de n e d em base decimal. */
void escreveFracção(int const n, int const d)
{
    cout << n << '/' << d;
    return;
}

Dizer que mdc() e escreveFracção() devem ficar acima de main()!