3  Modularização: funções e procedimentos

Methods are more important than facts.
Donald E. Knuth, Selected Papers in Computer Science, 176 (1996)

3.1  Introdução à modularização

Exemplos de modularização, i.e., exemplos de sistemas constituidos por módulos, são bem conhecidos.  A maior parte dos bons sistemas de alta fidelidade são compostos por módulos: o amplificador, o equalizador, o leitor de CD, o sintonizador, o leitor de cassetes, etc.  Para o fabricante dum sistema deste tipo a modularização tem várias vantagens: Também para o utilizador final do sistema a modularização traz vantagens: Estas vantagens não são exclusivas dos sistemas de alta fidelidade: são gerais.  Qualquer sistema pode beneficiar de pelo menos algumas destas vantagens se for modularizado.  A arte da modularização está em identificar claramente que módulos devem existir no sistema.  Uma boa modularização atribui uma única função bem definida a cada módulo, minimiza as ligações entre os módulos e maximiza a coesão interna de cada módulo.  No caso de um bom sistema de alta fidelidade, tal corresponde a minimizar a complexidade dos cabos entre os módulos e a garantir que os módulos contêm apenas os circuitos que contribuem para a função do módulo.  A coesão tem portanto a ver com as ligações internas a um módulo, que idealmente devem ser maximizadas.  Normalmente, um módulo só pode ser coeso se tiver uma única função, bem definida.

Há algumas restrições adicionais a impor a uma boa modularização.  Não basta que um módulo tenha uma função bem definida: tem de ter também uma interface bem definida.  Por interface entende-se aquela parte de um módulo que está acessível do exterior e que permite a sua utilização.  É claro, por exemplo, que um dono de uma alta fidelidade não pode substituir o seu amplificador por um novo modelo se este tiver ligações e cabos que não sejam compatíveis com o modelo mais antigo.

A interface de um módulo é a parte que está acessível ao utilizador.  Tudo o resto faz parte da sua implementação, ou mecanismo, e é típico que esteja encerrado numa caixa fora da vista, ou pelo menos fora do alcance do utilizador.  Um sistema bem desenhado cada módulo mostra a sua interface e esconde a complexidade da sua implementação: cada módulo está encapsulado numa "caixa", a que se costuma chamar uma "caixa preta".  Por exemplo, num relógio vê-se o mostrador, os ponteiros e o manípulo para acertar as horas, mas o mecanismo está escondido numa caixa.  Num automóvel toda a mecânica está escondida no capot.

Para o utilizador o interior (a implementação) de um módulo é irrelevante: o audiófilo só se importa com a constituição interna de um módulo na medida em que ela determina o seu comportamento externo.  O utilizador de um módulo só precisa de conhecer a sua função e a sua interface.  A sua visão de um módulo permite-lhe, abstraindo-se do seu funcionamento interno, preocupar-se apenas com aquilo que lhe interessa: ouvir som de alta fidelidade.

A modularização, o encapsulamento e a abstracção são conceitos fundamentais em engenharia da programação para o desenvolvimento de programas de grande escala.  Mesmo para pequenos programas estes conceitos são úteis, quando mais não seja pelo treino que proporciona a sua utilização e que permite ao programador mais tarde lidar melhor com projectos de maior escala.  Estes conceitos serão estudados com mais profundidade disciplinas posteriores, como Concepção e Desenvolvimento de Sistemas de Informação (IGE e ETI) e Engenharia da Programação (IGE).  Neste capítulo far-se-á uma primeira abordagem ao conceito da modulariação e da abstracção em programação.  Os mesmos conceitos, acrescidos do encapsulamento, serão revisitados ao longo dos capítulos subsequentes.

As vantagens da modularização para a programação são pelo menos as seguintes [1]:

  1. facilita a detecção de erros, pois é em princípio simples identificar o módulo responsável pelo erro, reduzindo-se assim o tempo gasto na identificação de erros;
  2. permite testar os módulos individualmente, em vez de se testar apenas o programa completo, o que reduz a complexidade do teste e permite começar a testar antes de se ter completado o programa;
  3. permite fazer a manutenção do programa (correcção de erros, melhoramentos, etc.) módulo a módulo e não no programa globalmente, o que reduz a probabilidade de essa manutenção ter consequências imprevistas noutras partes do programa;
  4. permite o desenvolvimento independente dos módulos, o que simplifica o trabalho em equipa, pois cada elemento ou cada sub-equipa tem a seu cargo apenas alguns módulos do programa; e
  5. permite a reutilização do código* desenvolvido, que é porventura a mais evidente vantagem da modularização em programas de pequena escala.
Um programador assume, ao longo do desenvolvimento dum programa, dois papeis distintos:  por um lado é fabricante, pois é sua responsabilidade desenvolver módulos; por outro é utilizador, pois fará com certeza uso de outros módulos, desenvolvidos por outrem ou por ele próprio no passado.  Esta é uma noção muito importante.  É de toda a conveniência que um programador possa ser um mero utilizador dos módulos já desenvolvidos, sem se preocupar com o seu funcionamento interno: basta-lhe, como utilizador, sabe qual a função módulo e qual a sua interface.

À utilização de um sistema em que se olha para ele apenas do ponto de vista do seu funcionamento externo chama-se abstracção, e é um dos conceitos mais importantes em programação.  A capacidade de abstracção por parte do programador permite-lhe reduzir substancialmente a complexidade da informação que tem de ter presente na memória, conduzindo por isso a substanciais ganhos de produtividade e a uma menor taxa de erros.  A capacidade de abstracção é tão fundamental na programação como no dia-a-dia.  Ninguém conduz o automóvel com a preocupação de saber se a vela do primeiro ciclindro produzirá a faísca no momento certo para a próxima explosão!  Um automóvel para o condutor normal é um objecto que lhe permite deslocar-se e que possui um interface simples: a ignição para ligar o automóvel, o volante para ajustar a direcção, o acelerador para ganhar velocidade, etc.  O encapsulamento dos módulos, ao esconder do utilizador o seu mecanismo, facilita-lhe esta visão externa dos módulos e portanto facilita a sua capacidade de abstracção.

* Dá-se o nome de código a qualquer pedaço de programa numa dada linguagem de programação.

3.2  Funções e procedimentos em C++

A modularização é, na realidade um processo hierárquico: muito provavelmente cada módulo de um sistema de alta fidelidade é composto por sub-módulos razoavelmente independentes, embora invisíveis para o utilizador.  O mesmo se passa na programação.  Para já, no entanto, abordar-se-ão apenas as unidades atómicas de modularização em programação: funções e procedimentos.
 
Função
Conjunto de instruções, com interface bem definida, que efectua um dado cálculo.
Procedimento
Conjunto de instruções, com interface bem definida, que faz qualquer coisa.

 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Note-se, no entanto, que esta distinção entre funções e procedimentos não é feita pela linguagem C++, que trata funções e procedimentos de igual forma.

As funções e os procedimentos permitem isolar pedaços de código com objectivos bem definidos e torná-los reutilizáveis onde quer que seja necessário.  O "fabrico" duma função corresponde em C++ àquilo que se designa por definição.  Uma vez definida "fabricada", uma função ou um procedimento podem ser utilizados sem que se precise de conhecer o seu funcionamento interno, da mesma forma que o audiófilo não está muito interessado nos circuitos dentro do amplificador, mas simplesmente nas suas características vistas do exterior.  Funções e procedimentos são pois como caixas pretas: uma vez definidas (e correctas), devem ser usadas sem preocupações quanto ao seu funcionamento interno.

Qualquer linguagem de programação, e o C++ em particular, fornece um conjunto de tipos básicos e de operações que se podem realizar com variáveis, constantes e valores desses tipos.  Uma maneira de ver as funções e procedimentos é como extensões a essas operações disponíveis na linguagem "não artilhada".  Por exemplo, o C++ não fornece qualquer operação para calcular o mdc (máximo divisor comum), mas no primeiro capítulo viu-se uma forma de o calcular.  O pedaço de programa que calcula o mdc pode ser colocado numa caixa preta, com uma interface apropriada, de modo a que possa ser reutilizado sempre que necessário.  Depois de o fazer passa a ser possível escrever:

cout << "Introduza dois inteiros: ";
int m, n;
cin >> m >> n;
cout << "mdc(" << m << ", " << n << ") = " << mdc(m, n) << endl;
Assim, ao se "fabricar" funções e procedimentos está-se a construir como que uma nova versão da linguagem C++, mais potente.  Quase todas as tarefas de programação podem ser interpretadas desta forma.  Em particular, ver-se-á mais tarde que o C++ permite que o mesmo tipo de ideias seja aplicado aos tipos de dados disponíveis: o programador de C++ não só pode "artilhar" a linguagem com novas operações sobre tipos básicos, como também a pode "artilhar" com novos tipos!  A este último tipo de programação chama-se programação centrada nos dados, e é a base da programação baseada em objectos e, consequentemente, da programação orientada para objectos.

3.2.1  Abordagem descendente

Nos capítulos anteriores introduziram-se vários conceitos, como os de algoritmos, dados e programas.  Explicaram-se também algumas das ferramentas da linguagem de programação C++, tais como variáveis, constantes, tipos, valores literais, expressões, operações, etc.  Mas, como usar todos estes conceitos para resolver um problema em particular?

Existem muitas possíveis abordagens à resolução de problemas em programação, quase todas com um paralelo perfeito com as abordagens que se usam no dia-a-dia.  Porventura uma das abordagens mais clássicas em programação é a abordagem descendente (ou top-down).

Abordar um problema "de cima para baixo" corresponde a olhar para ele na globalidade e identificar o mais pequeno número de sub-problemas independentes possível.  Depois, sendo esses sub-problemas independentes, podem-se resolver independentemente usando a mesma abordagem: cada sub-problema é dividido num conjunto de sub-sub-problemas mais simples.  Esta abordagem tem a vantagem de limitar a quantidade de informação a processar pelo programador em cada passo e de, por divisão sucessiva, ir reduzindo a complexidade dos problema até à trivialidade.  Quando os problemas identificados se tornam triviais pode-se escrever a sua solução na forma do passo de um algoritmo ou instrução de um programa.

Suponha-se que se pretende escrever um programa que some duas fracções positivas introduzidas do teclado e mostre o resultado na forma de uma fracção reduzida (ou em termos mínimos).  Recorda-se que uma fracção n/d está em termos mínimos se não existir qualquer divisor comum ao numerador e ao denominador com excepção de 1, ou seja, se mdc(n, d) = 1.  Para simplificar admite-se que as fracções introduzidas são representadas, cada uma, por um par de valores inteiros positivos: numerador e denominador.  Pode-se começar por escrever o "esqueleto" do programa:

#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
}

Olhando o problema na globalidade, verifica-se que pode ser dividido em três sub-problemas: ler as fracções de entrada, obter a fracção soma em termos mínimos e escrever o resultado.  Traduzindo para C++:
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    ...

    // Cálculo da fracção soma em termos mínimos:
    ...

    // Escrita do resultado:
    ...
}

Pode-se agora abordar cada sub-problema independentemente.  Começando pela leitura das fracções, identificam-se dois sub-sub-problemas: pedir ao utilizador para introduzir as fracções e ler as fracções.  Estes problemas são tão simples de resolver que se passa directamente ao código.
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Cálculo da fracção soma em termos mínimos:
    ...

    // Escrita do resultado:
    ...
}

Note-se que se usou um única instrução para definir quatro variáveis do tipo int que guardarão os numeradores e denominadores das duas fracções lidas.  O C++ permite definir várias variáveis na mesma instrução.

De seguida pode-se passar ao sub-problema final da escrita do resultado.  Suponha-se que, sendo as fracções de entrada 6/9 e 7/3, se pretendia que surgisse no ecrã:

A soma de 6/9 com 7/3 é 3/1.
Então o problema pode ser resolvido como se segue:
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Cálculo da fracção soma em termos mínimos:
    ...

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

Neste caso adiou-se um problema: admitiu-se que está disponível algures um procedimento chamado escreveFracção() que escreve uma fracção no ecrã.  Isto significa que mais tarde será preciso definir esse procedimento.

Mas sobrou outro problema: como escrever a fracção resultado? onde se encontram o seu numerador e o seu denominador.  Claramente é necessário, para a resolução do sub-problema do cálculo da soma, definir duas variáveis adicionais onde esses valores serão guardados:

#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Cálculo da fracção soma em termos mínimos:
    int n;
    int d;
    ...

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

É necessário agora resolver o sub-problema do cálculo da fracção soma em termos mínimos.  Dadas duas frações, a sua soma é mais fácil se tiverem o mesmo denominador.  Em geral isso não é verdade, pelo que é necessário pensar como consegui-lo.  A forma mais simples consiste em multiplicar ambos os termos da primeira fracção pelo denominador da segunda e vice versa.  Ou seja,
a/b + c/d = ad/bd + bc/bd = (ad + bc)/bd
Então, pode-se escrever
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    ...

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

Usando o mesmo exemplo que anteriormente, se as fracções de entrada forem 6/9 e 7/3, o programa tal como está escreve
A soma de 6/9 com 7/3 é 81/27.
Ou seja, a fracção resultado não está reduzida.  Para a reduzir é necessário dividir numerador e denominador pelo seu mdc (máximo divisor comum):
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    int k = mdc(n, d);
    n /= k;    // o mesmo que n = n / k;
    d /= k;    // o mesmo que d = d / k;

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

Neste caso adiou-se mais um problema: admitiu-se que está disponível algures uma função chamada mdc() que calcula o mdc de dois inteiros.  Isto significa que mais tarde será preciso definir esse procedimento.  Recorda-se, no entanto, que o algoritmo para o cálculo do mdc foi visto no primeiro capítulo.

A solução encontrada ainda precisa de ser refinada.  Suponhamos que o programa é compilado e executado num ambiente onde valores do tipo int são representados com apenas 6 bits.  Nesse caso, de acordo com a discussão do capítulo anterior, essas variáveis podem conter valores entre -32 e 31.  Que acontece quando, sendo as fracções de entrada 6/9 e 7/3, se inicializa a variável n?  O valor da expressão n1 * d2 + n2 * d1 é 81, que excede em muito a gama dos int com 6 bits!  O resultado é desastroso.  Não é possível evitar totalmente este problema, mas é possível minimizá-lo se se reduzir a termos mínimos as fracções de entrada logo após a leitura.  Se isso acontecesse, como 6/9 em termos mínimos é 2/3, a expressão n1 * d2 + n2 * d1 teria o valor 27, dentro da gama de valores dos int.

Nos ambientes típicos os valores do tipo int sejam representados por 32 bits, pelo que o problema acima só se põe para numeradores e denominadores maiores.  Mas não deixa de se pôr, pelo que convém alterar o programa para:

#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;    // o mesmo que n1 = n1 / k;
    d1 /= k;    // o mesmo que d1 = d1 / k;
    int k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    int k = mdc(n, d);
    n /= k;
    d /= k;

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

O programa acima precisa ainda de ser corrigido.  Como se verá mais à frente, não se podem definir múltiplas variáveis com o mesmo nome no mesmo contexto.  Assim, a variável k deve ser definida uma única vez e reutilizada quando necessário:
#include <iostream>
using namespace std;

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;    // o mesmo que n1 = n1 / k;
    d1 /= k;    // o mesmo que d1 = d1 / k;
int k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
int k = mdc(n, d);
    n /= k;
    d /= k;

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

3.2.2  Sintaxe das definições

Antes de se poder utilizar uma função ou um procedimento, é necessário defini-lo (antes de usar a aparelhagem há que fabricá-la).  Falta, portanto, definir a função mdc() e o procedimento escreveFracção().

Um possível algoritmo para o cálculo do mdc de dois inteiros positivos foi visto no primeiro capítulo.  A parte relevante do correspondente programa em C++ é:

int m;
int n;
// Como inicializar m e n ?

int k;
if(n > m)
    k = m;
else
    k = n;

while(m % k != 0 || n % k != 0)
    --k;

Este troço de código C++ calcula o mdc dos valores de m e n e coloca o resultado na variável k.  É necessário colocá-lo numa função, ou seja, num módulo com uma interface e uma implementação escondida numa "caixa".  Começa-se por colocar o código numa "caixa", i.e., entre {}:
{
    int m;
    int n;
    // Como inicializar m e n ?

    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;
}

É necessário atribuir um nome ao módulo, tal como se atribui o nome "Amplificador" ao módulo de uma aparelhagem que amplifica os sinais áudio vindos de outros módulos.  Neste caso o módulo chama-se mdc:
mdc
{
    int m;
    int n;
    // Como inicializar m e n?

    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;
}

Qual é a interface deste módulo, ou melhor, desta função?  No programa original a função é utilizada (invocada ou chamada) em três locais diferentes.  Em cada um deles escreveu-se o nome da função seguida de uma lista de duas expressões (a que se chama argumentos).  Essas expressões, quando calculadas, têm um valor que se pretendia que fosse usado para inicializar as variáveis m e n definidas na função mdc.  Para o conseguir, essas variáveis não devem ser variáveis normais: devem ser parâmetros.  Os parâmetros são definidos entre parênteses logo após o nome da função:
mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;
}

A função ou módulo mdc() já tem entradas, que são os dois parâmetros definidos.  Falta definir as suas saídas.  Uma função em C++ só pode ter uma saída.  Neste caso a saída é o valor guardado em k no final da função.  Para que o seu valor seja usado como saída da função usa-se uma instrução de retorno:
mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

No entanto, a definição da função tem de indicar claramente que a função tem uma saída de um dado tipo.  Neste caso a saída é um valor do tipo int, pelo que a definição da função fica:
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}


A definição de uma função ou de um procedimento é constituida por um cabeçalho seguido por um corpo, que consiste no conjunto de instruções entre {}.  No cabeçalho são indicados o tipo do valor calculado (ou devolvido) por essa função, o nome da função e a lista dos parâmetros da função (cada parâmetro é representado por um par tipo nome, sendo os pares separados por vírgulas).  Isto é:

tipo_de_devolução nome(lista_de_parâmetros)
O cabeçalho de uma função corresponde à sua interface, tal como as tomadas para cabos nas traseiras de um amplificador e os botões de controlo no seu painel frontal constituem a interface do amplificador.  As funções usualmente calculam qualquer coisa, que é de um determinado tipo.  Esse tipo é indicado em primeiro lugar no cabeçalho.  Logo a seguir indica-se o nome da função, e finalmente uma lista de parâmtetros, que consiste simplesmente numa lista de definições de variáveis com uma sintaxe semelhante (embora não idêntica) à que se viu até agora.

No exemplo anterior definiu-se uma função que tem dois parâmetros (ambos do tipo int) e que devolve um valor inteiro.  O seu cabeçalho é:

int mdc(int m, int n)
Note-se que a sintaxe de especificação dos parâmetros é diferente da sintaxe de definição de variáveis.  Assim, o cabeçalho
int mdc(int m, n)
é inválido, pois falta-lhe a especificação do tipo do parâmetro n.

O corpo desta função, a sua implementação, corresponde às instruções entre {}:

{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

A definição completa de uma função deve incluir mais do que um cabeçalho e um corpo, devem ser incluídos alguns comentários:
/*
   Esta função calcula e devolve o máximo divisor comum de dois inteiros
   positivos passados como argumentos.
   PC: m > 0 en > 0.
   CO: mdc = mdc(m, n).
*/
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

Note-se todo o texto colocado entre /* e */ é ignorado pelo compilador: é um comentário.  Este comentário contém: Note-se que se colocou este comentário junto ao cabeçalho da função, pois é fundamental para se perceber o que a função faz.  O cabeçalho da função, por si só, não diz o que a função faz, simplesmente como se utiliza.  Por outro lado, o corpo da função diz como a função funciona.  Uma função é uma caixa preta: no seu interior fica o mecanismo (o corpo da função), no exterior a interface (o cabeçalho da função) e pode-se ainda saber para que serve e como se utiliza a caixa lendo o seu manual de utilização (os comentários contendo a PC e a CO).

Idealmente o corpo das funções e dos procedimentos deve ser pequeno, contendo entre uma e dez instruções.  Muito raramente haverá boas razões para ultrapassar as 60 linhas.  A razão para isso prende-se com a dificuldade dos humanos (sim, os programadores são humanos) em abarcar demasiados assuntos duma só vez: quanto mais curto for o corpo duma função ou procedimento, mais fácil foi de desenvolver e mais fácil é de corrigir ou melhorar.

Para que a função mdc() possa ser utilizada no programa desenvolvido, é necessário que o compilador encontre a sua definição antes da primeira utilização.  Assim, o programa até agora desenvolvido é:

#include <iostream>
using namespace std;

/*
   Esta função calcula e devolve o máximo divisor comum de dois inteiros
   positivos passados como argumentos.
   PC: m > 0 e n > 0.
   CO: mdc = mdc(m, n).
*/
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;    // o mesmo que n1 = n1 / k;
    d1 /= k;    // o mesmo que d1 = d1 / k;
    k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    k = mdc(n, d);
    n /= k;
    d /= k;

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

3.2.3  Sintaxe e semântica da invocação ou chamada

Depois de definidos, funções e procedimentos podem ser utilizados noutros locais dum programa.  A utilização típica corresponde a invocar ou chamar a função ou procedimento para que seja executado com um determinado conjunto de entradas.  A invocação da função mdc() definida acima pode ser feita como se segue:
int x = 5;                // 1
int divisor;              // 2
divisor = mdc(x + 3, 6);  // 3
cout << divisor << endl;  // 4
A sintaxe da invocação de funções e procedimentos consiste simplesmente em colocar o seu nome seguido de uma lista de expressões (separadas por vírgulas) em número igual aos dos parâmetros da função ou procedimento.  A estas expressões chama-se argumentos.

Os parâmetros recebidos e os valores devolvidos de uma função podem ser de qualquer tipo básico do C++ ou de tipos de dados definidos pelo utilizador (falar-se-á destes tipos em capítulos subsequentes).  O tipo de um argumento tem de ser compatível com o tipo do parâmetro respectivo (não esquecer que todas as expressões em C++ são de um determinado tipo).  Como é evidente, uma função pode devolver um único valor do tipo indicado no seu cabeçalho.

Uma invocação de uma função (lembre-se que as funções devolvem um valor calculado) pode ser usada em expressões mais complexas, tal como qualquer operador do C++.  No exemplo acima, a chamada à função mdc() é usada como operando de uma operação de atribuição.

Que acontece quando o código acima é executado?

* Palavra-chave é o nome que se dá aos identificadores com um significado especial em C++, tais como int e return.

3.2.4  Parâmetros

Parâmetros são as variáveis listadas entre parênteses no cabeçalho da definição de uma função ou procedimento.  São variáveis locais (ver Secção 3.2.9), embora com uma particularidade: são automaticamente inicializadas com o valor dos argumentos respectivos em cada chamada da função ou procedimento.

3.2.5  Argumentos

Argumentos são as expressões listadas entre parênteses numa invocação ou chamada de uma função ou procedimento.  O seu valor é utilizado para inicializar os parâmetros da função ou procedimento invocado.

3.2.6  Retorno e devolução

Em inglês a palavra return tem dois significados distintos: retornar (ou regressar) e devolver.  O português é neste caso mais rico, pelo que se usarão palavras distintas: dir-se-á que uma função (ou procedimento) retorna quando termina a sua execução e o fluxo de execução regressa ao ponto de invocação, e que uma função, ao retornar, devolve um valor que pode ser usado na expressão em que a função foi invocada.  No exemplo do mdc acima o valor inteiro devolvido é usado numa expressão envolvendo o operador de atribuição.

Uma função termina quando o fluxo de execução atinge uma instrução de retorno.  As instruções de retorno consistem na palavra chave return seguida de uma expressão e de um ;.  A expressão tem de ser de um tipo compatível com o tipo de devolução da função.  O resultado da expressão é o valor devolvido ou calculado pela função.

No caso da função mdc() o retorno e a devolução fazem-se com a instrução

    return k;
O valor devolvido neste caso é o valor contido na variável k, que é o mdc dos valores iniciais de m e n.

3.2.7  Significado de void

Um procedimento em C++ tem a mesma sintaxe que uma função, mas normalmente não devolve qualquer valor.  Esse facto é indicado colocando a palavra-chave void no local do tipo do valor de devolução.  Um procedimento termina quando se atinge a chaveta final do procedimento ou uma instrução de retorno simples, sem qualquer expressão (i.e., return;).

Os procedimentos têm tipicamente efeitos laterais, isto é, afectam valores de variáveis que lhes são exteriores (usando passagem de argumentos por referência, descrita na próxima secção).  Assim sendo, para evitar efeitos indesejáveis, não se devem usar procedimentos em expressões.  A utilização do tipo de devolução void impede a chamada de procedimentos em expressões, pelo que o seu uso é recomendado.

No programa da soma de fracções ficou em falta a definição do procedimento escreveFracção().  A sua definição é muito simples:

/*
   Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
   é passada na forma de dois argumentos inteiros positivos.
   PC: nenhuma.
   CO: 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 n, int d)
{
    cout << n << '/' << d;
}
Note-se que não é necessária qualquer instrução de retorno, pois o procedimento retorna quando a execução atinge a chaveta final.

O programa completo é então:

#include <iostream>
using namespace std;

/*
   Esta função calcula e devolve o máximo divisor comum de dois inteiros
   positivos passados como argumentos.
   PC: m > 0 en > 0.
   CO: mdc = mdc(m, n).
*/
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

/*
   Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
   é passada na forma de dois argumentos inteiros positivos.
   PC: nenhuma.
   CO: 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 n, int d)
{
    cout << n << '/' << d;
}

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;
    int k = mdc(n1, d1);
    n1 /= k;    // o mesmo que n1 = n1 / k;
    d1 /= k;    // o mesmo que d1 = d1 / k;
    k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    k = mdc(n, d);
    n /= k;
    d /= k;

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

3.2.8  Passagem de argumentos por valor e por referência

Observe o seguinte exemplo de procedimento, cujo programador pretendia que trocasse os valores de duas variáveis passadas como argumentos:
// Atenção!  Este procedimento não funciona!
void trocaValores(int x, int y)
{
    int auxiliar = x;
    x = y;
    y = auxiliar;
    // Não há instrução de retorno explícita, pois trata-se dum
    // procedimento que não devolve qualquer valor.
    // Alternativamente poder-se-ia usar return;.
}
O que acontece ao se invocar este procedimento como se segue?
    int a = 1, b = 2;
    trocaValores(a, b); // Note-se que a invocação não ocorre dentro de
                        // qualquer expressão, dado que o procedimento não
                        // devolve qualquer valor.
    cout << a << ' ' << b << endl;
  1. São criados os parâmetros x e y.
  2. A variável x é inicializada com o valor 1 (conteúdo de a) e a variável y é inicializada com o valor 2 (conteúdo de b).
  3. Durante a execução do procedimento os valores guardados em x e y são trocados.
  4. Antes de o procedimento terminar, a variável (ou parâmetro) x tem o valor 2 e a variável y o valor 1.
  5. Quando termina a execução do procedimento são destruídos os parâmetros x e y (ver explicação mais à frente)
Ou seja, não há qualquer efeito sobre os valores das variáveis a e b!  Os parâmetros mudaram de valor dentro do procedimento mas as variáveis a e b não mudaram de valor: a continua a conter 1 e b a conter 2.  Este tipo de comportamento ocorre quando numa função ou procedimento se usa a chamada "passagem de argumentos por valor".  Normalmente, um comportamento desejável.  Só em alguns casos, como neste exemplo, esta é uma característica indesejável.

Para resolver este tipo de problemas, onde é de interesse que o valor das variáveis que são usadas como argumentos seja alterado dentro dum procedimento, existe o conceito de passagem de argumentos por referência.  A passagem de um argumento por referência é indicada no cabeçalho do procedimento colocando o símbolo & depois do tipo do parâmetro pretendido, como se pode ver abaixo:

void trocaValores(int& x, int& y)
{
    int auxiliar = x;
    x = y;   
    y = auxiliar;
}
Ao invocar como anteriormente:
  1. Os parâmetros x e y tornam-se sinónimos (referências) das variáveis a e b.  Aqui não é feita a cópia dos valores de a e b para x e y.  O que acontece é que os parâmetros x e y passam a referir-se às mesmas posições de memória onde estão guardadas as variáveis a e b.  Ao processo de equiparação de um parâmetro ao argumento respectivo passado por referência chama-se também inicialização.
  2. No corpo do procedimento o valor que está guardado em x é trocado com o valor guardado em y.  Dado que x se refere à mesma posição de memória que a e y à mesma posição de memória que b, ao fazer esta operação está-se efectivamente a trocar os valores das variáveis a e b.
  3. Quando termina a execução da função são destruídos os sinónimos x e y das variáveis a e b (que permanecem intactas), ficando os valores destas alterados (trocados).
Como só podem existir sinónimos/referências de entidades que, tal como as variáveis, têm posições de memória associadas, a chamada
trocaValores(20, a + b);
não faz qualquer sentido e conduz a dois erros de compilação.

É de notar que a utilização de passagens por referência deve ser evitada a todo o custo em funções, pois levariam à ocorrência de efeitos laterais nas expressões onde essas funções fossem chamadas.  Isto evita situações como

int incrementa(int& valor)
{
    return valor = valor + 1;
}

int main()
{
    int i = 0;
    cout << i + incrementa(i) << endl;
}

em que o resultado final tanto pode ser aparecer 1 como 2 no ecrã, dependendo da ordem de cálculo dos operandos da adição.

Assim, as passagens por referência só se devem usar em procedimentos e mesmo aí com parcimónia.  Mais tarde ver-se-á que existe o conceito de passagem por referência constante que permite aliviar um pouco esta recomendação.

Um observação atenta do programa para cálculo das fracções desenvolvido mostra que este contém instruções repetidas, que mereciam ser encapsuladas num procedimento: são as instruções de redução das fracções aos termos mínimos, identificadas abaixo em negrito:

#include <iostream>
using namespace std;

/*
   Esta função calcula e devolve o máximo divisor comum de dois inteiros
   positivos passados como argumentos.
   PC: m > 0 en > 0.
   CO: mdc = mdc(m, n).
*/
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

/*
   Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
   é passada na forma de dois argumentos inteiros positivos.
   PC: nenhuma.
   CO: 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 n, int d)
{
    cout << n << '/' << d;
}

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    int k = mdc(n1, d1);
    n1 /= k;
    d1 /= k;

    k = mdc(n2, d2);
    n2 /= k;
    d2 /= k;

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;

    k = mdc(n, d);
    n /= k;
    d /= k;

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

É necessário, portanto, definir um procedimento que reduza uma fracção passada como argumento na forma de dois inteiros: numerador e denominador.  Note-se que não é possível escrever uma função para este efeito, pois seriam necessárias duas saídas, ou dois valores de devolução, o que as funções em C++ não permitem.  Assim sendo, usa-se um procedimento que tem de ser capaz de afectar os valores dos argumentos.  Ou seja, usa-se passagem de argumentos por referência.  O procedimento é então:
/*
   Este procedimento reduz a fracção passada com argumento na forma de dois inteiros
   positivos.
   PC: n = ned = d e n > 0 e d > 0.
   CO: mdc(n, d) = 1 e n / n  = d / d.
*/
void reduzFracção(int& n, int& d)
{
    int k = mdc(n, d);
    n /= k;
    d /= k;
}
Note-se que se usaram as variáveis matemáticas m e n para representar os valores iniciais das variáveis do programa C++ n e d.

O programa completo é então:

#include <iostream>
using namespace std;

/*
   Esta função calcula e devolve o máximo divisor comum de dois inteiros
   positivos passados como argumentos.
   PC: m > 0 en > 0.
   CO: mdc = mdc(m, n).
*/
int mdc(int m, int n)
{
    int k;
    if(n > m)
        k = m;
    else
        k = n;

    while(m % k != 0 || n % k != 0)
        --k;

    return k;
}

/*
   Este procedimento reduz a fracção passada com argumento na forma de dois inteiros
   positivos.
   PC: n = n e d = d e n > 0 e d > 0.
   CO: mdc(n, d) = 1 e n / n = dd.
*/
void reduzFracção(int& n, int& d)
{
    int k = mdc(n, d);
    n /= k;
    d /= k;
}

/*
   Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
   é passada na forma de dois argumentos inteiros positivos.
   PC: nenhuma.
   CO: 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 n, int d)
{
    cout << n << '/' << d;
}

// Programa que calcula e escreve a soma em termos mínimos de duas fracções
// (positivas) lidas do teclado:
int main()
{
    // Leitura das fracções do teclado:
    cout << "Introduza duas fracções: ";
    int n1, d1, n2, d2;
    cin >> n1 >> d1 >> n2 >> d2;

    reduzFracção(n1, d1);
    reduzFracção(n2, d2);

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;

    reduzFracção(n, d);

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

3.2.9  Variáveis locais e globais

Uma observação cuidadosa dos exemplos anteriores revela que afinal main não passa de uma função.  Mas é uma função especial: é no seu início que começa a execução do programa.

Assim sendo, verifica-se também que até agora só se definiram variáveis dentro de funções.  Às variáveis que se definem no corpo de funções ou procedimentos chama-se "variáveis locais".  As variáveis locais podem ser definidas em qualquer ponto duma função onde possa estar uma instrução.  Às variáveis que se definem fora de qualquer função chama-se variáveis globais.  Os mesmos nomes se aplicam no caso das constantes: há constantes locais e constantes globais.

Recorda-se que os parâmetros duma função ou procedimento são variáveis locais como quaisquer outras, excepto quanto à sua forma de inicialização: os parâmetros são inicializados implicitamente com o valor dos argumentos respectivos em cada invocação da função ou procedimento.

Blocos de instruções ou instruções compostas

Por vezes em C++ é conveniente agrupar um conjunto de instruções e tratá-las como uma única instrução.  Isso consegue-se envolvendo as instruções entre {}.  Por exemplo, no código
double raio1, raio2;
...
if(raio1 < raio2) {
    double aux = raio1;
    raio1 = raio2;
    raio2 = aux;
}
as três instruções
double aux = raio1;
raio1 = raio2;
raio2 = aux;
estão agrupadas num único bloco de instruções, ou numa única instrução composta, com execução dependente da veracidade de uma condição.  Um exemplo simples de um bloco de instruções é o corpo de uma função.

Note-se que os blocos de instruções podem estar embutidos (ou aninhados) dentro de outros blocos de instruções.  Por exemplo, no programa

int main()
{
    int n;
    cin >> n;

    if(n < 0) {
        cout << "Valor negativo!  Usando o módulo!
        n = -n;
    }
    cout << n << endl;
}

existem dois blocos de instruções:  o primeiro corresponde ao corpo da função main() e o segundo à sequência de instruções executada condicionalmente de acordo com o valor de n.  O segundo bloco de instruções encontra-se embutido no primeiro.

Cada variável tem um contexto de definição.  As variáveis globais são definidas no contexto do programa* e as variáveis locais no contexto de um bloco de instruções.  Para todos os efeitos os parâmetros de uma função ou procedimento pertencem ao contexto do bloco de instruções correspondente ao corpo da função.

* Note-se que as variáveis globais também podem ser definidas no contexto do ficheiro, bastando para isso preceder a sua definição do qualificador static.  Este assunto será clarificado quando se discutir a divisão de um programa em ficheiros.

Âmbito ou visibilidade de variáveis

Cada variável tem um âmbito de visibilidade, determinado pelo contexto no qual foi definida.  As variáveis globais são visíveis (isto é, utilizáveis em expressões) desde a sua declaração até ao final do ficheiro (ver-se-á mais tarde que um programa pode consistir de vários ficheiros).  As variáveis locais, por outro lado, são visíveis desde o ponto de definição até à chaveta de fecho do bloco de instruções onde foram definidas.

Por exemplo:

#include <iostream>
using namespace std;

const double pi = 3.1416;

double perímetro(double raio)
{
    return 2.0 * pi * raio;
}

int main()
{
    cout << "Introduza dois raios: ";
    double raio1, raio2;
    cin >> raio1 >> raio2;

    // Ordenação dos raios (por ordem crescente):
    if(raio1 < raio2) {
        double aux = raio1;
        raio1 = raio2;
        raio2 = aux;
    }

    // Escrita do resultado:
    cout << "raio = " << raio1
         << ", perímetro = " << perímetro(raio1) << endl
         << "raio = " << raio2
         << ", perímetro = " << perímetro(raio2) << endl
}

Neste código:
  1. A constante pi é visível desde a sua definição até ao final do corpo da função main().
  2. O parâmetro raio (que é uma variável local a perímetro), é visível em todo o corpo da função perímetro().
  3. As variáveis raio1 e raio2 são visíveis desde o ponto de definição (antes da operação de extracção) até ao final do corpo da função main().
  4. A variável aux é visível desde o ponto de definição até ao fim da instrução composta controlada pelo if.
Quanto mais estreito for o âmbito de visibilidade duma variável, menores os danos causados por possíveis utilizações erróneas.  Assim, as variáveis locais devem definir-se tanto quanto possível imediatamente antes da primeira utilização.

Em cada contexto só pode ser definida uma variável com o mesmo nome.  Por exemplo:

{
    int j;
    int k;
    ...
    int j;   // erro! j definida pela segunda vez!
    float k; // erro! k definida pela segunda vez (nem mesmo com outro tipo)!
}
Por outro lado, o mesmo nome pode ser reutilizado em contextos diferentes.  Por exemplo, no programa da soma de fracções utiliza-se o nome n em contextos diferentes:
int mdc(int m, int n)
{
    ...
}

int main()
{
    ...
    int n = n1 * d2 + n2 * d1;
    ...
}

correspondendo a variáveis diferentes.

Quando um contexto se encontra embutido (ou aninhado) dentro de outro, as variáveis visíveis no contexto exterior são visíveis no contexto mais interior, excepto se o contexto interior definir uma variável com o mesmo nome.  Neste último caso diz-se que a definição interior oculta a definição mais exterior.  Por exemplo, no programa

double f = 1.0;

int main()
{
    if(...) {
        f = 2.0;
    } else {
        double f = 3.0;
        cout << f << endl;
    }
    cout << f << endl;
}

a variável global f é visível desde a sua definição até ao final do programa, incluindo o bloco de instruções após o if (que está embutido no corpo de main()), mas excluindo o bloco de instruções após o else (também embutido em main()), no qual uma outra variável como mesmo nome é definida e portanto visível.

Mesmo quando existe ocultação de uma variável global é possível utilizá-la.  Para isso basta qualificar o nome da variável global com o operador de resolução de âmbito :: aplicado ao espaço nominativo global (i.e., sem prefixo). Por exemplo, no programa

double f = 1.0;

int main()
{
    if(...) {
        f = 2.0;
    } else {
        double f = ::f;
        f += 10.0;
        cout << f << endl;
    }
    cout << f << endl;
}

a variável f definida no bloco após o else é inicializada com o valor da variável f global.

Um dos principais problemas com a utilização de variáveis globais tem a ver com o facto de estabelecerem entre os módulos (funções ou procedimentos) ligações que não são explícitas na sua interface, i.e., na informação presente no cabeçalho.  Dois procedimentos podem usar a mesma variável global, ficando ligados no sentido em que a alteração do valor dessa variável por um procedimento tem efeito sobre o outro procedimento que a usa.  As variáveis globais são assim uma fonte de erros, que ademais são difíceis de corrigir.  O uso de variáveis globais é, por isso, fortemente desaconselhado.  Já o mesmo não se pode dizer de constantes globais, cuja utilização é, muitas vezes, aconselhável.

Outro tipo de prática pouco recomendável é o de ocultar nomes de contextos exteriores através de definições locais com o mesmo nome.  Esta prática dá origem a erros de muito difícil correcção.

Duração ou permanência de variáveis

Quando é que as variáveis existem, i.e., têm espaço de memória reservado para elas?  As variáveis globais existem sempre desde o início ao fim do programa, e por isso dizem-se estáticas.  São construídas no início do programa e destruídas no seu final.

As variáveis locais (parâmetros de funções incluídos) existem em memória apenas enquanto o bloco de instruções em que estão inseridas está a ser executado, sendo assim potencialmente "construídas" e "destruídas" muitas vezes ao longo de um programa.  Variáveis com estas características dizem-se automáticas.

As variáveis locais também podem ser estáticas, desde que se preceda a sua definição do qualificador static.  Nesse caso são construídas no momento em que a execução passa pela primeira vez pela sua definição e são destruídas (deixam de existir) no final do programa.

Inicialização

As variáveis de tipos básicos do C++ podem não ser inicializadas explicitamente.  Quando isso acontece, as variáveis estáticas são inicializadas implicitamente com um valor nulo, enquanto as variáveis automáticas (por uma questão de eficiência) não são incializadas de todo, passando portanto a conter "lixo" (excepto quanto aos parâmetros, que, apesar de locais e automáticos, são inicializados com o valor dos argumentos).

Sempre que possível deve-se inicializar explicitamente as variáveis com valores apropriados.  Mas não se deve inicializar "com qualquer coisa" só para o compilador não "chatear".

3.2.10  Nomes de funções e procedimentos

Tal como no caso das variáveis, o nome das funções e dos procedimentos deverá reflectir claramente aquilo que é calculado ou aquilo que é feito.  Assim, as funções têm tipicamente como nome o nome da entidade calculada (e.g., seno, cosseno, comprimento) enquanto os procedimentos têm normalmente como nome o verbo indicador da acção, possivelmente seguido de complementos (e.g., acrescenta, copiaSemDuplicações).  Só se devem usar abreviaturas quando forem bem conhecidas, tal como é o caso do mdc.

Uma excepção a estas regras dá-se para funções cujo resultado é um valor lógico ou booleano.  Nesse caso o nome da função deve ser um predicado, sendo o sujeito um dos argumentos da função (ou no caso de métodos de classes, o objecto em causa *), de modo que a frase completa seja uma proposição (verdadeira ou falsa).  Por exemplo, estáVazia(fila) ou simplesmente vazia(fila).

Os nomes utilizados para variáveis, funções e procedimentos (e em geral para qualquer outro identificador criado pelo programador), devem ser tais que a leitura do código se faça da forma mais simples possível.

Idealmente, os procedimentos têm um único objectivo, sendo por isso descritíveis usando apenas um verbo.  Assim, quando a descrição de um procedimento obrigar à utilização de dois ou mais verbos, isso indicia que o procedimento tem mais do que um objectivo, sendo por isso um bom candidato a ser dividido em dois ou mais procedimentos.

A linguagem C++ é imperativa, i.e., os programas consistem em sequências de instruções.  Assim, o corpo de uma função diz como se calcula qualquer coisa, mas não diz o que se calcula.  É de toda a conveniência que, usando as regras atrás, esse o que fique o mais possível explícito no nome da função, passando-se o mesmo quanto aos procedimentos.  Isto, claro está, não excluindo a necessidade de comentar funções e procedimentos com as respectivas PC e CO, conforme sugerido atrás.

É de toda a conveniência que se use um estilo de programação uniforme.  Tal facilita a compreensão do código escrito por outros programadores ou pelo próprio programador depois de passados uns meses.  Assim, sugerem-se as seguintes regras adicionais:

  • Os nomes de variáveis, constantes e valores enumerados (a ver posteriormente) devem ser escritos em minúsculas usando-se o sublinhado _ para separar as palavras.  Por exemplo:
  • const int máximo_de_alunos_por_turma = 50;
    int alunos_na_turma;
    enum DiaDeSemana {segunda_feira, /* ... */ };
  • Os nomes de funções ou procedimentos devem ser escritos em minúsculas usando-se letras maiúsculas iniciais em todas as palavras excepto a primeira:
  • int pedeInteiro();
  • Os nomes de tipos criados pelo utilizador (a ver posteriormente) devem ser escritos em minúsculas usando-se letras maiúsculas iniciais em todas as palavras:
  • struct Aluno { /* ... */ };
    class Racional { /* ... */ };
    enum DiaDaSemana { /* ... */ };
    * Assunto a tratar em capítulos posteriores.

    3.2.11  Declaração vs. definição

    Antes de se poder invocar uma função (ou um procedimento), é necessário que esta seja declarada, i.e., que o compilador saiba que ela existe e qual a sua interface.  Declarar uma função consiste pois em dizer qual o seu nome, qual o tipo do valor devolvido, quantos parâmetros tem e de que tipo são esses parâmetros.  Por exemplo:
    void imprimeValorLógico(bool b);
    ou simplesmente
    void imprimeValorLógico(bool);
    são ambas possíveis declarações da função imprimeValorLógico() que se define abaixo:
    // Esta função imprime verdadeiro ou falso consoante o valor
    // lógico do argumento:
    void imprimeValorLógico(bool b)
    {
        if(b)
            cout << "verdadeiro";
        else
            cout << "falso";
    }
    Repare-se que na declaração não é necessário indicar os nomes dos parâmetros (se bem que na maioria dos casos seja conveniente fazê-lo para mostrar claramente ao leitor o que a função faz).

    A sintaxe das declarações em sentido estrito é simples: o cabeçalho da função ou procedimento (como na definição) mas seguido de ; em vez do corpo.  O facto de uma função ou procedimento estar declarada não a dispensa de ter de ser definida mais cedo ou mais tarde: todas as funções e procedimentos têm de estar definidos em algum lado.  Por outro lado, uma definição, um vez que contém o cabeçalho da função, também serve de declaração.  Assim, após uma definição as declarações em sentido estrito são desnecessárias.

    Note-se que, mesmo que já se tenha procedido à declaração prévia de uma função ou procedimento, é necessário incluir o seu cabeçalho durante a definição.

    O facto de que uma definição é também uma declaração foi usado no programa da soma de fracções para, colocando as definições de funções e procedimentos antes da função main(), permitir que estas funções e procedimentos fosse usados no corpo da função main().  É possível inverter a ordem das definições através de declarações prévias:

    #include <iostream>
    using namespace std;

    // Programa que calcula e escreve a soma em termos mínimos de duas fracções
    // (positivas) lidas do teclado:
    int main()
    {
        // Declaração dos procedimentos necessários.  Estas declarações são visíveis apenas
        // dentro da função main().
        void reduzFracção(int& n, int& d);
        void escreveFracção(int n, int d);

        // Leitura das fracções do teclado:
        cout << "Introduza duas fracções: ";
        int n1, d1, n2, d2;
        cin >> n1 >> d1 >> n2 >> d2;
        reduzFracção(n1, d1);
        reduzFracção(n2, d2);

        // Cálculo da fracção soma em termos mínimos:
        int n = n1 * d2 + n2 * d1;
        int d = d1 * d2;
        reduzFracção(n, d);

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

    /*
       Este procedimento reduz a fracção passada com argumento na forma de dois inteiros
       positivos.
       PC: n = ned = d e n > 0 e d > 0.
       CO: mdc(n, d) = 1 e n / n  = d / d.
    */
    void reduzFracção(int& n, int& d)
    {
        // Declaração da função mdc().  Esta declaração é visível apenas
        // dentro deste procedimento.
        int mdc(int m, int n);

        int k = mdc(n, d);
        n /= k;
        d /= k;
    }

    /*
       Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
       é passada na forma de dois argumentos inteiros positivos.
       PC: nenhuma.
       CO: 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 n, int d)
    {
        cout << n << '/' << d;
    }

    /*
       Esta função calcula e devolve o máximo divisor comum de dois inteiros
       positivos passados como argumentos.
       PC: m > 0 e n > 0.
       CO: mdc = mdc(m, n).
    */
    int mdc(int m, int n)
    {
        int k;
        if(n > m)
            k = m;
        else
            k = n;

        while(m % k != 0 || n % k != 0)
            --k;

        return k;
    }

    A vantagem desta disposição é que aparecem primeiro as funções e procedimentos mais globais e só mais tarde os pormenores, o que facilita a leitura do código.

    Note-se que se poderia alternativamente ter declarado as funções e procedimentos fora das funções e procedimentos em que são necessários.  Nesse caso o programa seria:

    #include <iostream>
    using namespace std;

    // Declaração de todas as funções e procedimentos:
    void reduzFracção(int& n, int& d);
    void escreveFracção(int n, int d);
    int mdc(int m, int n);

    // Programa que calcula e escreve a soma em termos mínimos de duas fracções
    // (positivas) lidas do teclado:
    int main()
    {
        // Leitura das fracções do teclado:
        cout << "Introduza duas fracções: ";
        int n1, d1, n2, d2;
        cin >> n1 >> d1 >> n2 >> d2;
        reduzFracção(n1, d1);
        reduzFracção(n2, d2);

        // Cálculo da fracção soma em termos mínimos:
        int n = n1 * d2 + n2 * d1;
        int d = d1 * d2;
        reduzFracção(n, d);

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

    /*
       Este procedimento reduz a fracção passada com argumento na forma de dois inteiros
       positivos.
       PC: n = ned = d e n > 0 e d > 0.
       CO: mdc(n, d) = 1 e n / n  = d / d.
    */
    void reduzFracção(int& n, int& d)
    {
        int k = mdc(n, d);
        n /= k;
        d /= k;
    }

    /*
       Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
       é passada na forma de dois argumentos inteiros positivos.
       PC: nenhuma.
       CO: 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 n, int d)
    {
        cout << n << '/' << d;
    }

    /*
       Esta função calcula e devolve o máximo divisor comum de dois inteiros
       positivos passados como argumentos.
       PC: m > 0 en > 0.
       CO: mdc = mdc(m, n).
    */
    int mdc(int m, int n)
    {
        int k;
        if(n > m)
            k = m;
        else
            k = n;

        while(m % k != 0 || n % k != 0)
            --k;

        return k;
    }

    3.2.12  Melhorando módulos

    Uma das vantagens da modularização, como se viu, é que se pode melhorar a implementação de qualquer módulo sem com isso comprometer o funcionamento do sistema e sem obrigar a qualquer outra alteração.  Na versão do programa da soma de fracções que se segue utiliza-se uma função de cálculo do mdc com um algoritmo diferente, mais eficiente.  É o algoritmo de Euclides, que decorre naturalmente das seguintes propriedades do mdc (lembra-se das sugestões no final do Capítulo 1?):
    1. mdc(m, n) = mdc(n ÷ m, m) se m > 0 e n >= 0.
    2. mdc(0, n) = n, se n > 0 (e também mdc(0, m) = m, se m > 0).
    O algoritmo usado deixou de ser uma busca exaustiva do mdc para passar a ser uma redução sucessiva do problema até à trivialidade.  A demonstração da sua correcção faz-se exactamente da mesma forma que no caso da busca exaustiva, e fica como exercício para o leitor.  Regresse a este algoritmo depois de ter lido sobre metodologias de desenvolvimentos de ciclos (aproveite para colocar o comentário indicando a CI do ciclo).

    Aproveitou-se ainda para relaxar as pré-condições da função, uma vez que o algoritmo utilizado permite calcular o mdc de dois inteiros m e n qualquer que seja m desde que n seja positivo.  Este relaxar das pré-condições permite que o programa some convenientemente fracções negativas.

    #include <iostream>
    using namespace std;

    // Programa que calcula e escreve a soma em termos mínimos de duas fracções
    // (positivas) lidas do teclado:
    int main()
    {
        // Declaração dos procedimentos necessários.  Estas declarações são visíveis apenas
        // dentro da função main().
        void reduzFracção(int& n, int& d);
        void escreveFracção(int n, int d);

        // Leitura das fracções do teclado:
        cout << "Introduza duas fracções: ";
        int n1, d1, n2, d2;
        cin >> n1 >> d1 >> n2 >> d2;
        reduzFracção(n1, d1);
        reduzFracção(n2, d2);

        // Cálculo da fracção soma em termos mínimos:
        int n = n1 * d2 + n2 * d1;
        int d = d1 * d2;
        reduzFracção(n, d);

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

    /*
       Este procedimento reduz a fracção passada com argumento na forma de dois inteiros
       positivos.
       PC: n = ned = d e n > 0 e d > 0.
       CO: mdc(n, d) = 1 e n / n  = d / d.
    */
    void reduzFracção(int& n, int& d)
    {
        // Declaração da função mdc().  Esta declaração é visível apenas
        // dentro deste procedimento.
        int mdc(int m, int n);

        int k = mdc(n, d);
        n /= k;
        d /= k;
    }

    /*
       Este procedimento escreve no ecrã uma fracção, no formato usual, que lhe
       é passada na forma de dois argumentos inteiros positivos.
       PC: nenhuma.
       CO: 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 n, int d)
    {
        cout << n << '/' << d;
    }

    /*
       Esta função calcula e devolve o máximo divisor comum de dois inteiros
       passados como argumentos (o segundo tem de ser positivo).
       PC: m = men = n e n > 0
       CO: mdc = mdc(|m|, n)
    */
    int mdc(int m, int n)
    {
        if(m < 0)
            m = -m;
        while(m != 0) {
            int auxiliar = n % m;
            n = m;
            m = auxiliar;
        }
        return n;
    }

    3.2.13  Exercícios

    #include <iostream>
    using namespace std;

    // Calcula o quadrado dum número.
    // PC: x = x
    // CO: quadrado = x2.
    float quadrado(float x)
    {
        return x * x;
    }

    // Este programa chama a função acima para calcular o quadrado
    // dum número.
    int main()
    {
        cout << "Introduza um numero : ";
        float valor;
        cin >> valor;

        float valor_quadrado = quadrado(valor);
        cout << "O quadrado de " << valor << " é "
             << valor_quadrado << endl;
    }

    1.  Copie o programa acima e faça o seu traçado (execute-o em modo de depuração [debug]) Quando for executar a linha onde a função é invocada entre na função usando a opção apropriada ao seu depurador.  Verifique que a próxima instrução executada é a primeira que se encontra no corpo da função.

    2.a)  Implemente uma função que, dados três valores de vírgula flutuante interpretados como componentes dum vector, calcule a sua norma.  Lembre-se que a norma de um vector é a raiz quadrada do somatório dos quadrados de cada um dos seus componentes.  Faça um pequeno programa para testar esta função (como foi feito no exemplo).  Para calcular a raiz quadrada use a função sqrt, acrescentando ao topo do seu programa a linha #include<cmath>.

    2.b)  Usou a função float quadrado(float a) na alínea anterior?  Se não usou, modifique o seu programa de modo a usar.

    2.c)  Crie um procedimento que, dados três valores de vírgula flutuante interpretados como componentes dum vector, o normalize.  Normalizar um vector é calcular o seu versor, i.e., dividir cada um dos componentes pela norma do vector.

    3.a)  Crie uma função que calcule a média de três números de vírgula flutuante passados como argumentos.

    3.b)  Crie um procedimento que divida três números de vírgula flutuante pela sua média.  Use a função criada acima para calcular a média.

    4.  Faça uma função que calcule a raiz positiva de uma equação de segundo grau.  Recorda-se que a raiz positiva de uma equação do segundo grau é dada por (b + (b2 - 4ac)0,5) / 2a. Assuma que b2 - 4ac >= 0.

    5.  Construa uma função que devolva o valor booleano true caso os seus dois argumentos (do tipo char) sejam iguais e false no caso contrário.

    6.  Crie um procedimento com dois argumentos inteiros que divida ambos os seus parâmetros de entrada por 2.  Ao retornar, os valores dos argumentos de entrada da função devem ter sido modificados.

    7.  O cálculo da soma pode ser melhorado se se calcular o mdc dos dois denominadores antes de proceder à soma.  I.e., pode-se substituir as três instruções

    // Cálculo da fracção soma em termos mínimos:
    int n = n1 * d2 + n2 * d1;
    int d = d1 * d2;
    reduzFracção(n, d);
    por
    // Cálculo da fracção soma em termos mínimos:
    int k = mdc(d1, d2);
    int n = d2 / k * n1 + d1 / k * n2;
    int d = d1 / k * d2;
    reduzFracção(n, d);
    O que se ganha com esta alteração?  Que outra alteração tem de fazer no código da última versão do programa para que compile sem erros?

    3.3  Funções ou procedimentos recursivos

    O C++, como a maior parte das linguagens de programação, permite a definição daquilo a que se chama funções ou procedimentos recursivos.  Diz-se que uma função (ou procedimento) é recursivo se o seu corpo incluir chamadas à própria função *.  Por exemplo:
    int factorial(int n)
    {
        if(n == 0 || n == 1)
            return 1;
        else
            return n * factorial(n - 1);
    }
    é uma função recursiva que calcula o factorial e que foi obtida duma forma imediata a partir da definição recorrente do factorial:
    n! = n × (n - 1)! se n > 0 e
    n! = 1 se n = 0 ou n = 1
    Este tipo de funções pode ser muito útil na resolução de alguns problemas, mas deve ser usado com cautela.  A chamada de uma função recursivamente implica que as variáveis locais (parâmetros incluídos) são criadas (colocadas na memória) tantas vezes quantas a função é chamada, e só são destruídas (retiradas de memória) quando as correspondentes chamadas retornam.  Caso ocorram muitas chamadas recursivas, não só pode ser necessária muita memória para para as várias versões das variáveis locais (uma versão por cada chamada), como também a execução pode tornar-se bastante lenta, pois a chamada de funções implica alguma perda de tempo nas tarefas de "arrumação da casa" do processador.

    Para se compreender profundamente o funcionamento das funções recursivas tem de se compreender o mecanismo de chamada ou invocação de funções, que se explica na próxima secção.

    * É possível ainda que a recursividade seja entre duas ou mais funções, que se chamam todas umas às outras.

    3.3.1  Mecanismo de invocação de funções

    Quando nas secções anteriores se descreveu a chamada da função mdc, referiu-se que os seus parâmetros eram criados no início da chamada e destruidos no seu final, e que a função, ao terminar, retornava para a instrução imediatamente após a instrução de invocação.  Como é que estas ideias funcionam na prática?  Apesar de ser matéria para a disciplina de Arquitectura de Computadores, far-se-á aqui uma descrição breve e simplificada do mecanismo de invocação de funções que será útil (embora não fundamental) para se compreender o funcionamento das funções recursivas.

    O mecanismo de invocação de funções utiliza uma parte da memória do computador como se de uma pilha se tratasse, i.e., como um local onde se pode ir acumulando informação de tal forma que a última informação a ser colocada na pilha seja a primeira a ser retirada, um pouco como acontece com as pilhas de processos das repartições públicas, em os processos dos incautos podem ir envelhecendo ao longo de anos na base de uma pilha...

    Representar-se-á graficamente uma pilha como um conjunto de caixas sobrepostas horizontalmente a partir da esquerda.  Por exemplo, a pilha abaixo contém três números, dos quais 11 foi o último a ser colocado na pilha:
     
    pilha
    20
    1
    11
     

    Note-se que o topo da pilha se representa aqui por dois traços.

    A pilha é utilizada para colocar todas as variáveis locais (mas apenas as automáticas) de uma função quando esta é chamada, sendo aí que se guarda também a instrução para onde o fluxo de execução do programa deve retornar uma vez terminada a execução da função.

    Exemplo não recursivo

    Suponha-se de novo o exemplo
    // PC: m > 0 en > 0
    // CO: mdc = mdc(m, n)
    int mdc(int m, int n)
    {
        int k;
        if(n > m)
            k = m;
        else
            k = n;

        // CI: k >= mdc(m, n)
        while(m % k != 0 || n % k != 0)
            --k;

        return k;
    }

    int main()
    {
        int m = 5;                // 1
        int divisor;              // 2
                  mdc(m + 3, 6)   // 3A
        divisor =              ;  // 3B
        cout << divisor << endl;  // 4
    }

    em que se dividiu a instrução 3 em duas "sub-instruções" 3A e 3B.  Que acontece quando a instrução 3A é executada?  Inicialmente a pilha está vazia, i.e.
     
    pilha
     

    A chamada da função mdc na instrução 3A começa por guardar na pilha o endereço da instrução a executar quando a função retornar, i.e., 3B
     
    pilha
    3B
     

    Em seguida são colocadas (criadas) na pilha as variáveis locais da função, que incluem os seus parâmetros.  Os parâmetros são inicializados com os valores do argumento respectivo:
     
    pilha
    3B
    m:
    8
    n:
    6
    k:
    ?
     

    Note-se que, como a variável k não é inicializada na sua definição, o seu valor é "lixo", representado aqui por um ponto de interrogação.

    A execução passa então para o corpo da função, que coloca em k o mdc de 8 e 6, ou seja 2.  Assim, imediatamente antes da execução da instração de retorno (return), a pilha contém:
     
    pilha
    3B
    m:
    8
    n:
    6
    k:
    2
     

    A instrução de retorno começa por calcular o valor a devolver (neste caso é o valor de k, i.e., 2), retira da pilha as variáveis locais
     
    pilha
    3B
     

    e em seguida retira da pilha a instrução para onde o fluxo de execução deve ser retomado (i.e., 3B) colocando na pilha (mas para lá do seu topo...) o valor devolvido (i.e., 2)
     
    pilha
     
    2

    Em seguida a execução continua em 3B usando-se o valor colocado após o topo da pilha para atribuir à variável divisor, que é depois escrita no ecrã.  O resultado final foi que a pilha ficou vazia, para todos os efeitos, pois o valor de devolução foi colocado após o topo da pilha, pelo que posteriores chamadas a funções sobreporão a sua informação a esse valor.

    Exemplo recursivo

    Suponha-se agora o seguinte exemplo, que envolve a chamada a uma função recursiva:
    int factorial(int n)
    {
        if(n == 0 || n == 1)              // 1
            return 1;                     // 2
        else                              // 3
            return n * factorial(n - 1);  // 4
    }

    int main()
    {
        cout << factorial(3) << endl;     // 5
    }

    Mais uma vez é conveniente dividir a instrução 5 em duas "sub-instruções" como se segue
                factorial(3)              // 5A
        cout <<              << endl;     // 5B
    uma vez que a função é invocada antes da escrita do resultado no ecrã.  Da mesma forma, a instrução 4 pode ser dividida em duas "sub-instruções"
                       factorial(n - 1)   // 4A
            return n *                 ;  // 4B
    Que acontece ao ser executada a instrução 5A?  Essa instrução contém uma chamada à função factorial().  Assim, tal como se viu antes, as variáveis locais da função (neste caso apenas o parâmetro n) são colocadas na pilha logo após ao endereço da instrução a executar quando função retornar.  Quando a execução passa para a instrução 1, já a pilha contém
     
    pilha
    5B
    n:
    3
     

    Em seguida, como n é 3 e portanto diferente de 0 e de 1, é executada a instrução após o else, que é a instrução 4A (se tiver dúvidas acerca do funcionamento do if, consulte a Secção 4.1.1).  Mas a instrução 4A consiste numa nova chamada à função, pelo que os passos acima se repetem (mas sendo agora o parâmetro inicializado com o valor do argumento, i.e., 2, e sendo o endereço de retorno 4B), resultando na pilha
     
    pilha
    5B
    n:
    3
    4B
    n:
    2
     

    Note-se que, neste momento, existem duas versões da variável n na pilha, uma por cada chamada à função que ainda não terminou.  É esta "repetição" que permite às funções recursivas funcionarem sem problemas.

    A execução passa então para o início da função (instrução 1).  De novo, como n (o da chamada em execução correntemente) é 2 e portanto diferente de 0 e de 1, é executada a instrução após o else, que é a instrução 4A.  Mas a instrução 4A consiste numa nova chamada à função, pelo que os passos acima se repetem (mas sendo agora o parâmetro inicializado com o valor do argumento, i.e., 1, e sendo o endereço de retorno 4B), resultando na pilha
     
    pilha
    5B
    n:
    3
    4B
    n:
    2
    4B
    n:
    1
     

    A execução passa então para o início da função (instrução 1).  Agora, como n (o da chamada em execução correntemente) é 1, é executada a instrução após o if, que é a instrução 2.  Mas a instrução 2 consiste numa instrução de retorno com devolução do valor 1.  Assim, as variáveis locais são retiradas da pilha, o endereço de retorno (4B) é retirado da pilha, o valor de devolução 1 é colocado após o topo da pilha, e a execução continua na instrução 4B, ficando a pilha
     
    pilha
    5B
    n:
    3
    4B
    n:
    2
     
    1

    A instrução 4B consiste numa instrução de retorno com devolução do valor n * 1 (ou seja 2), em que n tem o valor 2 e o 1 é o valor de devolução da chamada anterior, que ficou após o topo da pilha.  Assim, as variáveis locais são retiradas da pilha, o endereço de retorno (4B) é retirado da pilha, o valor de devolução 2 é colocado após o topo da pilha, e a execução continua na instrução 4B, ficando a pilha
     
    pilha
    5B
    n:
    3
     
    2

    A instrução 4B consiste numa instrução de retorno com devolução do valor n * 2 (ou seja 6), em que n tem o valor 3 e o 2 é o valor de devolução da chamada anterior, que ficou após o topo da pilha.  Assim, as variáveis locais são retiradas da pilha, o endereço de retorno (5B) é retirado da pilha, o valor de devolução 6 é colocado após o topo da pilha, e a execução continua na instrução 5B, ficando a pilha
     
    pilha
     
    6

    A instrução 5B corresponde simplesmente a escrever no ecrã o valor devolvido pela chamada à função, ou seja, 6 (que é 3!).  É de notar que, terminadas todas as chamadas à função, a pilha voltou à sua situação original (que se supôs ser vazia).

    A razão pela qual as chamadas recursivas funcionam como espectável é que, em cada chamada, são criadas novas versões das variáveis locais e parâmetros (convenientemente inicializados) da função.

    3.3.2  Exercícios

    1.  Escreva uma função que calcule recursivamente uma potência inteira de um número decimal.  Lembre-se que xn = x xn-1 e que x0 = 1.

    2.  Considere uma sucessão de números que denota o número de coelhos existente na exploração do Sr. Fibonacii em determinado mês.  Estes coelhos são duma raça especial, imortal, e que se caracteriza por, em cada mês, cada casal de coelhos procriar dando origem a outro casal de coelhos.  Estes coelhos demoram dois meses a atingir a maturidade.  Admita que, no mês 1, o Sr. Fibonacci comprou um casal bebé destes coelhos miraculosos.  No segundo mês a exploração terá ainda um par de coelhos, mas no terceiro terá já dois pares de coelhos (pois o casal original, atingida a maturidade, reproduziu-se).  No quarto mês os primeiros reproduzem-se novamente (embora os últimos ainda não o possam fazer) e portanto a exploração terá três pares de coelhos.  No quinto mês dois desses três pares já se podem reproduzir, do que resultam cinco coelhos.  E assim sucessivamente.  Se nenhum dos coelhos morrer, o número de pares de coelhos existente em cada mês é dado pela chamada sucessão de Fibonacci.  Essa sucessão pode ser definida recorrentemente por: f0 = 0, f1 = 1, e fn = fn-1 + fn-2 se n > 1.  Os primeiros termos da sequência são portanto: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...

    Crie uma função que calcule quantos coelhos há na exploração num dado mês dado como argumento.  Não use ciclos: use recursividade.  Teste o funcionamento da sua função com meses de 0 a 40 (não se admire se levar muito tempo a calcular estes valores para meses superiores a 30).  Faça um traçado da execução para seis meses e tente explicar porque é que a função leva tanto tempo a calcular o número de coelhos para os meses mais afastados.  Tente determinar analiticamente o número N(n) de chamadas recursivas da função necessárias para calcular o número de casais de coelhos num mês m.

    Se concluiu que N(n) = 2 fn+1 - 1, então concluiu bem.  Como fn é aproximadamente 1,618n/2,236, conclui-se facilmente que o cálculo recursivo de f30 envolve aproximadamente 1 600 000 chamadas à função, e que o cálculo de f40 envolve aproximadamente 200 000 000 chamadas à função!

    Quando souber escrever ciclos, escreva uma solução iterativa para este problema e compare a sua eficiência com a eficiência da solução recursiva.

    3.a)  Dado que

    1. mdc(m, n) = mdc(n ÷ m, m) se m > 0 e n >= 0 e
    2. mdc(0, n) = n, se n > 0 (e também mdc(0, m) = m, se m > 0),
    complete a seguinte definição da função mdc() usando apenas recursividade (não pode usar ciclos for, while e do while nem o infame goto):
    /*
       Esta função calcula e devolve o máximo divisor comum de dois inteiros
       passados como argumentos (o segundo tem de ser positivo).
       PC: m >= 0 en > 0
       CO: mdc = mdc(m, n)
    */
    int mdc(int m, int n)
    {
        ...
    }
    3.b)  Melhore a função de modo a que a PC possa ser relaxada para
       PC: n <> 0.
    3.c)  Se se definir mdc(0, 0) = 1, que alterações deverá fazer à função recursiva e à sua PC?

    4.  Escreva uma função recursiva int divide(int m, int n) que devolva o quociente da divisão inteira de m por n.  Admita que m >= 0 e n > 0.  Faça uso das seguintes propriedades:

    m / n = 0 se m < n
    m / n = (m - n) / n + 1 se m >= n

    3.4  Sobrecarga de nomes

    Em certos casos é importante ter funções ou procedimentos que fazem conceptualmente a mesma operação ou o mesmo cálculo, mas que operam com tipos diferentes de dados.  Seria pois de todo o interesse que o C++ permitisse a definição de funções ou procedimentos com nomes idênticos.  De facto, o C++ apenas proíbe a definição no mesmo contexto de funções ou procedimentos com a mesma assinatura, i.e., com o mesmo nome, o mesmo número de parâmetros, e os mesmos tipos dos parâmetros.  Assim, é de permitida a definição de múltiplas funções ou procedimentos com o mesmo nome, desde que difiram no número ou tipo de parâmetros.  As funções e procedimentos com o mesmo nome dizem-se "sobrepostos".  A invocação de funções ou procedimentos sobrepostos faz-se como habitualmente, sendo a versão a invocar determinada a partir do número e tipo dos argumentos usados na invocação.  Por exemplo, suponha-se que estão definidas as funções:
    int soma(int, int);
    int soma(int, int, int);
    float soma(float, float);
    double soma(double, double);
    Ao executar as instruções
    int i1, i2;
    float f1, f2;
    double d1, d2;
    i2 = soma(i1, 4);         // invoca int soma(int, int).
    i2 = soma(i1, 3, i2);     // invoca int soma(int, int, int).
    f2 = soma(5.6f, f1);      // invoca float soma(float, float).
    d2 = soma(d1, 10.0);      // invoca double soma(double, double).
    são chamadas as funções apropriadas para cada tipo de argumentos usados.  Este tipo de comportamento emula para as funções definidas pelo utilizador o que se passa com os operadores do C++: a operação +, por exemplo, significa soma de int se os operandos forem int, significa soma de float se os operandos forem float, etc.  O exemplo mais claro talvez seja o do operador divisão (/).  As instruções
    cout << 1 / 2 << endl;
    cout << 1.0 / 2.0 << endl;
    têm como resultado no ecrã
    0
    0.5
    porque no primeiro caso, sendo os operandos inteiros, a divisão usada é a divisão inteira.  Assim, cada operador básico do C++ corresponde na realidade a vários operadores com o mesmo nome (i.e., sobrepostos), ou melhor, com a mesma representação gráfica (e.g., +, -, /), cada um para determinado tipo dos operandos.

    Note-se o tipo de devolução da função não faz parte da sua assinatura, não servindo portanto para distinguir entre funções ou procedimentos sobrepostos.

    Num capítulo posterior se verá que o C++ permite sobrepor significados aos operadores básicos (como o +) quando aplicados a tipos definidos pelo utilizador, o que transforma o C++ numa linguagem que se "artilha" de uma forma muito elegante.

    3.4.1  Exercícios

    1.  Crie três funções de soma (com o mesmo nome) para três tipos de dados diferentes (int, float e double).  Escreva um programa que invoque as três funções como proposto acima.  Verifique, fazendo o traçado do programa, que o computador executa a função correcta para o tipo de argumentos usado quando é invocada a função.

    3.5  Referências

    [1]  Doug Bell, Ian Morrey e John Pugh, "Software Engineering: A Programming Approach", segunda edição, Prentice Hall, Nova Iorque, 1992.