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., sistemas que são compostos por módulos com funções bem definidas e tão independentes quanto possível, são bem conhecidos.  Por exemplo, a maior parte dos sistemas de alta fidelidade para audiófilos são compostos por módulos: o amplificador, o equalizador, o leitor de CD, o sintonizador, o leitor de cassetes, etc.

A divisão dum sistema em módulos tem várias vantagens.  Para o fabricante, por um lado, a modularização tem a vantagem de reduzir a complexidade do problema, dividindo-o em sub-problemas mais simples, que podem inclusivamente ser resolvidos por equipas independentes.  Até sob o ponto de vista do fabrico é mais simples alterar a composição de um módulo, por exemplo porque se desenvolveram melhores circuitos para o amplificador, do que alterar a composição de um sistema integrado.  Por outro lado, é mais fácil detectar problemas e resolvê-los, pois os módulos são, em princípio, razoavelmente independentes.  Claro que os módulos muitas vezes não são totalmente independentes.  Por exemplo, o sistema de controlo à distância duma aparelhagem implica interacção com todos os módulos simultaneamente.  A arte da modularização está em identificar claramente que módulos devem existir no sistema, de modo a garantir que as ligações entre os módulos são minimizadas e que a sua coesão interna é máxima.  Isto significa que, no caso dum bom sistema de alta fidelidade, os cabos entre os módulos são simplificados ao máximo e que os módulos contêm apenas os circuitos que garantem que o módulo faz a sua função.  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 é coeso se tiver uma única função, bem definida.

Para um utilizador, por outro lado, a modularização tem como vantagem principal permitir a alteração de um único módulo sem ter de comprar um sistema novo.  Claro que para isso acontecer o novo módulo tem de (1) ter a mesma função do módulo substituido e (2) possuir uma interface idêntica (os mesmo tipo de cabos com o mesmo tipo de sinal eléctrico).  Isto é, os módulos, do ponto de vista do utilizador, funcionam como "caixas pretas" com uma função bem definida e com interfaces bem conhecidas.  Para o utilizador o interior de um módulo é irrelevante.  Mas a modularização tem outras vantagens: o amplificador pode no futuro ser reutilizado num sistema de vídeo, por exemplo, evitando a duplicação de circuitos com a mesma função (se nunca pensou nisso, lembre-se que a televisão tem o seu próprio amplificador, normalmente de fraca qualidade, servindo apenas para a encarecer).  Aliás, o amplificador era já utilizado para amplificar o sinal de vários outros módulos (e.g., o leitor de CD ou o sintonizador).

As vantagens da modularização são muitas, como se viu.  A modularização é um dos métodos usados em engenharia da programação para desenvolvimento de programas de grande escala.  Mas a modularização é útil mesmo para pequenos programas, quanto mais não seja pelo treino que proporciona.  Estes assuntos serão estudados com mais profundidade em Engenharia da Programação (IGE) e Concepção e Desenvolvimento de Sistemas de Informação (ETI).

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 verificar qual é o módulo responsável pelo erro.
  2. É mais fácil testar os módulos individualmente do que o programa completo.
  3. É mais fácil fazer a manutenção (correcção de erros, melhoramentos, etc.) módulo por módulo do que no programa total.  Além disso, a modularização aumenta a probabilidade dessa manutenção não ter consequências nefastas nos outros módulos do programa.
  4. Permite o desenvolvimento independente dos módulos.  Isto simplifica o trabalho em equipa, pois cada elemento, ou cada sub-equipa, tem a seu cargo apenas alguns módulos do programa.
  5. Porventura a mais evidente vantagem da modularização em programas de pequena escala, mas também nos de grande escala, é a possibilidade de reutilização do código* desenvolvido.
Um programador assume, ao longo do desenvolvimento dum programa, os dois papeis descritos acima:  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: ele sabe qual a interface do módulo e qual a sua função, e usa-o.  Isto permite reduzir substancialmente a complexidade da informação que o programador tem de ter presente na sua memória, conduzindo por isso a substanciais ganhos de produtividade e a uma menor taxa de erros.

* 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 dum sistema de alta fidelidade é composto por sub-módulos razoavelmente independentes.  O mesmo se passa na programação.  Para já, no entanto, restringir-nos-emos às unidades atómicas de modularização em programação: as funções e os 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 não é feita pelo 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.

Qualquer linguagem de programação, e o C++ em particular, fornecem um conjunto de tipos básicos e de operações que se podem realizar com eles.  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 máximo divisor comum (mdc), mas no capítulo anterior viu-se uma forma de o calcular que pode ser envolvida por uma caixa preta com uma interface apropriada, de tal modo que passa a ser possível escrever algo como

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.  Essencialmente 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ó "artilha" a linguagem com novas operações sobre tipos básicos como a "artilha" também com novos tipos, com operações características!  A este último tipo de programação chama-se programação centrada nos dados, e é a base da programação baseada em objectos e da programação orientada para objectos.

3.2.1  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).  A definição de uma função ou de um procedimento é constituida por um cabeçalho seguido por um corpo, que consiste num conjunto de instruções entre {}.  No cabeçalho são indicados: o tipo do valor calculado (devolvido) por essa função, o nome da função e a lista dos parâmetros da função (tipo nome 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 dum 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 tem-se o nome da função, e finalmente uma lista de parâmtetros, que consiste simpesmente numa lista de definições de variáveis com uma sintaxe semelhante, mas não idêntica, à que se viu até agora.

No exemplo seguinte tem-se o cabeçalho de uma função que tem dois parâmetros (ambos números inteiros) e calcula (devolve) um valor inteiro.  O nome dado a esta função é mdc.

int mdc(int m, int n)
Esta função, depreende-se do nome, pretende-se que calcule o máximo divisor comum de dois inteiros.

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.

O corpo desta função pode ser retirado do exemplo no capítulo anterior:

{
    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;
}
Ou seja, a definição da função seria:
// PC: m > 0 e n > 0
// CO: k = 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;
}
Note-se que se colocaram comentários (as linhas começadas por //) na definição da função.  Estes comentários indicam Note-se que quer a PC quer a CO se colocaram junto ao cabeçalho da função, pois são fundamentais para perceber o que a função faz.  O cabeçalho da função não diz o que ela faz, simplesmente como se utiliza.  Por outro lado, o corpo da função diz como a função funciona.

Idealmente, quer as funções quer os procedimentos devem ser pequenos, com corpos contendo entre uma e dez instruções.  Muito raramente haverá boas razões para ultrapassarem as 60 linhas.

3.2.2  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 é invocar ou chamar a função para que ela seja executada.  A invocação da função acima pode ser feita como se segue:
int m = 5;                // 1
int divisor;              // 2
divisor = mdc(m + 3, 6);  // 3
cout << divisor << endl;  // 4
A sintaxe da invocação de funções e procedimentos consiste simplesmente no 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 e 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).  Os tipos dos argumentos têm de ser compatíveis com o tipo dos parâmetros respectivos (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 na definição.

Uma invocação de uma função (lembre-se que as funções calculam um valor) pode ser usada em expressões mais complexas, tal como qualquer operador do C++.  Acima, por exemplo, a chamada a mdc é usada como operando de uma 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.3  Parâmetros

Os parâmetros duma função não passam de variáveis locais (ver Secção 3.2.8).  Mas têm uma particularidade: são automaticamente inicializadas com o valor dos argumentos respectivos em cada chamada da função.

3.2.4  Argumentos

Argumentos são as expressões cujo valor é utilizado para se inicializar os parâmetros numa dada chamada da função.

3.2.5  Retorno e devolução

Em inglês a palavra return tem dois significados distintos: retornar (ou regressar) e devolver.  Sendo o português mais rico (neste caso), usar-se-ão palavras distintas.  Assim, 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.  Dir-se-á ainda 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 acima o valor inteiro devolvido é usado numa expressão envolvendo o operador de atribuição.

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

3.2.7  Passagem de argumentos por valor e por referência

Observe o seguinte exemplo de procedimento, cujo objectivo é (seria) trocar os valores de duas variáveis (se funcionasse...):
// Atenção! Esta função não funciona!
void trocaValores(int x, int y)
{
    int auxiliar = x;
    x = y;   
    y = auxiliar;
    // Não há instrução de return explícita, pois trata-se dum 
    // procedimento que não devolve qualquer valor.
    // Alternativemente porder-se-ia fazer return;
}
O que acontece ao se invocar este procedimento como se segue?
    int a = 1, b = 2;
    trocaValores(a, b); // Note que não existe utilização do valor
                        // devolvido pelo procedimento, dado que este
                        // procedimento não devolve qualquer valor.
    cout << a << ' ' << b << endl;
  1. São criados os parâmetros x e y.
  2. É atribuído a x o valor 1 (conteúdo de a) e a y o valor 2 (conteúdo de b).
  3. Durante a execução do procedimento os valores de 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 prcedimento são destruídos os parâmetros x e y.
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.  A este tipo de comportamento chama-se passagem de argumentos por valor, e é, 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.
  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 como as variáveis (i.e., de entidades que 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 para as 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.

3.2.8  Variáveis locais e globais

Uma observação cuidadosa dos exemplos das aulas 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.  A essas variáveis chama-se variáveis locais.  As variáveis locais podem ser definidas em qualquer ponto duma função onde possa estar uma instrução.  Alternativamente, existem variáveis globais, que se definem tal como as locais mas fora de qualquer função.

Recorda-se que os parâmetros duma função são variáveis locais como qualquer outras, excepto quanto à sua inicialização, que é feita implicitamente com o valor dos argumentos respectivos a cada chamada da função.

Âmbito ou visibilidade de variáveis

As variáveis globais são visíveis (isto é, utilizáveis em expressões) desde a sua definição até ao final do ficheiro onde se encontram (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.

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-se definir imediatamente antes da primeira utilização.

Um dos principais problemas com a utilização de variáveis globais tem a ver com o facto de estabelecerem ligações entre módulos (funções ou procedimentos) que não são explícitos na sua interface, i.e., na informação presente no cabeçalho.  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.

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.  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 "criadas" 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.

Inicialização

As variáveis estáticas são sempre inicializadas durante a definição com um valor nulo, excepto se se indicar outro valor.  As variáveis automáticas (por uma questão de eficiência) não são incializadas de todo contendo inicialmente "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.9  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 há utilização de dois ou mais verbos, esse procedimento é um bom candidato a ser dividido em dois ou mais procedimentos...

A linguagem C++, sendo imperativa, i.e., consistindo os programas em sequências de instruções, não representa o conteúdo semântico do código senão implicitamente.  I.e., o corpo duma função diz como se calcula qualquer coisa, mas não diz o que se calcula.  É assim 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.

* Assunto a tratar em capítulos posteriores.

3.2.10  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 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 faz a função).

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

3.2.11  Exemplo

O seguinte exemplo utiliza uma função que calcula o máximo divisor comum como auxiliar no cálculo da soma de duas fracções.  Será capaz de explicar qual a vantagem de reduzir as fracções antes de proceder ao cálculo?  Para quê dividir numerador e denominador por divisor?
#include <iostream>
using namespace std;
// Calcula o máximo divisor comum de dois inteiros.
// Assume-se que n > 0 e m >= 0 (não existe mdc(m, n) se ambos
// forem zero).
int mdc(int m, int n)
{
    while(m != 0) {
        int auxiliar = n % m;
        n = m;
        m = auxiliar;
    }
    return n;
}
// Reduz uma fracção.
void reduzFracção(int& numerador, int& denominador)
{
    int divisor = mdc(numerador, denominador);
    numerador /= divisor;
    denominador /= divisor;
}
// Programa que calcula a soma de duas fracções (positivas):
int main()
{
    cout << "Introduza duas fracções (numerador denominador): ";
    int numerador1, denominador1, numerador2, denominador2;
    cin >> numerador1 >> denominador1 >> numerador2 >> denominador2;
    // Reduzem-se as fracções:
    reduzFracção(numerador1, denominador1);
    reduzFracção(numerador2, denominador2);
    // Cálculo do resultado:
    int divisor = mdc(denominador1, denominador2);
    int numerador_do_resultado =
        numerador1 * (denominador2 / divisor) +
        numerador2 * (denominador1 / divisor);
    int denominador_do_resultado =
        denominador1 / divisor * denominador2;
    // Redução do resultado:
    reduzFracção(numerador_do_resultado, denominador_do_resultado);
    // Escrita do resultado:
    cout << numerador_do_resultado << '/' 
         << denominador_do_resultado << endl;
}
Note-se que o algoritmo do mdc utilizado não é o que se desenvolveu no primeiro capítulo!  Neste caso usou-se o algoritmo de Euclides (muito mais eficiente), e 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 ele depois de ter lido sobre metodologias de desenvolvimentos de ciclos.  Aproveite, quando voltar a este algoritmo, para colocar os comentários indicando a PC, a CO e a CI do ciclo.

3.2.12  Exercícios

#include <iostream>
using namespace std;
// Calcula o quadrado dum número.
float quadrado(float x)
{
    return x * x;
}
// Este programa chama a função acima para calcular o quadrado
// dum número.
int main()
{
    float valor, valor_quadrado;

    cout << "Introduza um numero : ";
    cin >> valor;
    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 use o botão Step Into em vez de F10 (Step Over).  Verifique que a próxima instrução executada é a primeira que se encontra no corpo da função.  Observe que as variáveis que se encontram na janela denominada Locals mudam quando entra na função.

2.a)  Implemente um procedimentos 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 este procedimento (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.

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 sua definição recorrente (e reconhecendo que 1! = 1):
n! = n (n - 1)! se n > 0 e n! = 1 se n = 0
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 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 cadeira 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 um pilha como um conjundo de caixas sobrepostas a partir da esquerda.  Por exemplo, a pilha abaixo contem três números, dos quais 11 for o último a chegar:
 
pilha
20
1
11

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

A pilha é utilizada para colocar todas as variáveis locais (mas apenas as automáticas) duma 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 e n > 0
// CO: k = 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 acondece 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, sendo os parâmetros inicializados com os valores dos argumentos:
 
pilha
3B
m:
8
n:
6
k:
?

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, pelo que, 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 memorizar o valor a devolver (no 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 simultaneamente 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 é chamada 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 (no caso só o parâmetro n) são colocadas na pilha a seguir ao endereço da instrução a executar quando função retornar.  Assim, 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 foi inicializado com o valor do argumento, 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, 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 foi inicializado com o valor do argumento, 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, o valor 1 é memorizado, 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, o valor 2 é memorizado, 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, o valor 6 é memorizado, 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.

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, 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.  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 leva tanto tempo a calcular para meses superiores.  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 conclui que N(n) = 2 fn+1 - 1, concluiu bem.  Como fn é aproximadamente 1,618n/2,236, conclui facimente 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.

3.4  Sobrecarga de nomes

Em certos casos é importante ter funções 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 de nomes idênticos variando apenas no tipo (ou no número) dos parâmetros.  É o que se passa na realidade.  Por exemplo, suponha que estão definidas as funções:
int soma(int, int);
float soma(float, float);
double soma(double, double);
Ao executar o seguinte código:
int i1, i2;
float f1, f2;
double d1, d2;
i2 = soma(i1, 4);         // invoca int soma(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 comportamente emula para as funções definidas pelo utilizador o que se passa com os operadores básicos do C++: é que +, por exemplo, significa soma de int se os operandos forem int, significa soma de float se os operandos forem float, etc.  Num capítulo posterior se verá que o C++ permite acrescentar significados aos operadores básicos (como o +) quando aplicados a tipos definidos pelo utilizador, o que transforma o C++ numa linguagem que se "artilha" duma 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).  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.