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 vimos. 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]:
* Dá-se o nome de código a qualquer pedaço de programa numa dada linguagem de programação.
O corpo desta função poderia ser:bool sãoIguais(int x, int y)
ou simplesmente:{ bool valor_a_devolver = x == y; return valor_a_devolver; }
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.{ return x == y; }
Quando a função é invocada:int a = 1; bool iguais; ... iguais = sãoIguais(a, 2); ...
* Palavra-chave é o nome que se dá aos identificadores com um significado especial em C++, tais como int e return.
O que acontece ao se invocar esta função como se segue?// 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; }
int a = 1, b = 2; ... Troca_valores(a, b); // Note que não existe utilização do // valor devolvido pela função, dado que ... // esta função não devolve qualquer valor. cout << a << ' ' << b << endl;
Para resolver este tipo de problemas, onde é do nosso interesse que o valor das variáveis que são usadas como argumentos seja alterado dentro duma função, existe o conceito de passagem de argumentos por referência. A passagem de um argumento por referência é indicada no cabeçalho da função colocando o símbolo & depois do tipo do parâmetro pretendido, como se pode ver abaixo:
Ao invocar como anteriormente:void trocaValores(int& x, int& y) { int auxiliar = x; x = y; y = auxiliar; }
não faz qualquer sentido e conduz a dois erros de compilação.trocaValores(20, a + b);
Assim sendo, verifica-se também que até agora só definimos 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.
As variáveis locais 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ícies de corrigir. O uso de variáveis globais é, por isso, fortemente desaconselhado.
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). Só se devem usar abreviaturas quando forem bem conhecidas.
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 explícito no nome da função, passando-se o mesmo quanto aos procedimentos.
* Assunto a tratar em aulas posteriores.
Note-se que o algoritmo do mdc utilizado não é o que se desenvolveu no início destas aulas! Neste caso usa-se o algoritmo de Euclides, que decorre naturalmente das seguintes propriedades do mdc:#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; }
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.#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; }
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 uma função 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. 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.
4. 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.
5. Crie uma função 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.
2. Considere uma sucessão equência de números que denota o número de coelhos existente numa exploração em determinado mês. Em cada mês cada par de coelhos procria dando origem a outros dois coelhos. Estes dois coelhos começam a reproduzir-se apenas dois meses mais tarde. Portanto, se comprarmos dois coelhos-bebés no primeiro mês, no segundo mês teremos ainda um par de coelhos, mas no terceiro teremos já dois pares de coelhos. No quarto mês os primeiros reproduzem-se novamente (embora os últimos ainda não o possam fazer) e portanto teremos 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 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.