1. PROGRAMAÇÃO MODULAR
“Existem apenas dois tipos de linguagens de programação: aquelas que as pessoas reclamam, e aquelas que ninguém usa.”
Bjarne Stroustrup, A Linguagem de Programação C++
Existem muitas definições para o que é Programação Modular, mas vamos lidar com a que está disponível no wiki do Cunningham & Cunningham:
Programação Modular é o ato de projetar e escrever programas como interações entre funções onde cada uma realiza uma única e clara funcionalidade, e que tem o mínimo de efeitos colaterais entre elas.
1.1 Modularização
A modularização dos códigos é um dos principais recursos para construir softwares com alto nível de qualidade. Em poucos palavras, a meta é simples: extrair uma lógica repetida (código redundante) e compartilhar através de partes reaproveitáveis (módulos).
Nos primórdios da programação a modularização era realizada através de Rotinas e mais tarde por Procedimentos, bem antes de então chegarmos no paradigma Orientado a Objetos (OO). Materializando isso, geralmente os módulos são separados em diversos arquivos, que contém as rotinas/procedimentos/funções/classes/métodos. Entenda que, antes de tudo, é perfeitamente possível escrever um programa inteiro em um único arquivo e até em uma única listagem “corrida” de código (sem separações). Entenda, também, que a granularidade pode ser variável, isto é, o mesmo programa pode ser separado em dezenas ou centenas de arquivos, cada um com uma, duas ou até dúzias de rotinas/procedimentos/funções/classes/métodos.
Embora modularizar traga um trabalho adicional de projetar (pensar) na separação, ainda assim os benefícios são incontestáveis, pagando todo o esforço inicial extra, valendo o trade-off1. Entre os benefícios alguns mais importantes são:
- Usando módulos há menos código em cada arquivo, resultando em estruturas lógicas mais simples e compreensíveis;
- As lógicas são reutilizáveis, evitando a rescrita do mesmo código várias vezes;
- Os membros da equipe podem trabalhar em módulos diferentes;
- Existe uma maior facilidade para identificar e corrigir erros, dado o isolamento, quando eles estão contidos em módulos;
- O mesmo módulo pode ser reaproveitado em vários softwares;
- Cada módulo pode ser testado separadamente.
Os benefícios citados são, entre outros, os principais responsáveis por uma gestão básica da qualidade do software. No tópico a seguir vem o primeiro exemplo de como construir partes reusáveis de códigos através de procedimentos (funções).
1.2 Modularização na Programação Procedimental
A separação da lógica repetida/reusável em procedimentos é a base da programação procedimental. A implementação desses procedimentos depende muito da linguagem de programação utilizada, por exemplo, são usadas as keywords Procedure em Pascal, Sub e Function em Visual Basic. A seguir um procedimento em Pascal para (exemplo “bobo”) mostrar a soma de dois números:
Procedure Soma (Var Valor_1, Valor_2 : Integer);
Var Soma : Integer;
Begin
Soma := Valor_1 + Valor_2;
Writeln ('Soma = ', Soma);
End;
// https://gist.github.com/marciojrtorres/3065c164af0b89eb033a108b1936ed3a
Nas linguagens modernas os procedimentos são implementados como funções (ou métodos). Existem diversas sintaxes2 para declarar uma função, dependendo da linguagem de programação, por exemplo: def em Python e Ruby, func em Google GO, fun em Kotlin e, mais óbvio, function em JavaScript e PHP.
Na linguagem Java a definição de funções/procedimentos é feita com métodos estáticos (não é a única, em C# também por exemplo). A presença de métodos estáticos em códigos escritos na linguagem Java caracteriza funcionalidades Procedurais em meio à Orientação a Objetos.
Os procedimentos são projetados para executar uma lógica, recebendo ou não uma entrada e devolvendo ou não um valor, isto é, tanto a entrada de dados como a saída de dados é opcional. Para exemplificar, considere um programa simples, que lê dois números e imprime o MMC. A seguir uma implementação inicial que não faz uso de procedimentos:
1 import java.util.Scanner; // scanner é um módulo!
2 public class Proc1 {
3 public static void main(String[] args) {
4 // ler dois números
5 Scanner scan = new Scanner(System.in);
6 int numero1 = scan.nextInt();
7 int numero2 = scan.nextInt();
8 // descobrir o maior número
9 int maior = numero1 > numero2 ? numero1 : numero2;
10 int mmc = maior;
11 // somar o maior enquanto não for divisível por ambos
12 while (mmc % numero1 != 0 || mmc % numero2 != 0) {
13 mmc += maior;
14 }
15 // imprimir mmc
16 System.out.println(mmc);
17 }
18 }
A implementação sem separar a lógica em um procedimento incorre na duplicação do código em operações subsequentes, digamos que o cálculo do MMC seja necessário em outras partes do programa, o código entre as linhas 8 e 16, pelo menos, teriam que ser reintroduzidos. Considere o mesmo exemplo com duas operações:
1 import java.util.Scanner; // scanner é um módulo!
2 public class Proc2 {
3 public static void main(String[] args) {
4 // ler dois números
5 Scanner scan = new Scanner(System.in);
6 int numero1 = scan.nextInt();
7 int numero2 = scan.nextInt();
8 // descobrir o maior número
9 int maior = numero1 > numero2 ? numero1 : numero2;
10 int mmc = maior;
11 // somar o maior enquanto não for divisível
12 while (mmc % numero1 != 0 || mmc % numero2 != 0) {
13 mmc += maior;
14 }
15 // imprimir mmc
16 System.out.println(mmc);
17
18 // ler mais dois números
19 int numero3 = scan.nextInt();
20 int numero4 = scan.nextInt();
21 // descobrir o maior número
22 maior = numero3 > numero4 ? numero3 : numero4;
23 mmc = maior;
24 // somar o maior enquanto não for divisível
25 while (mmc % numero3 != 0 || mmc % numero4 != 0) {
26 mmc += maior;
27 }
28 // imprimir mmc
29 System.out.println(mmc);
30 }
31 }
A extração do procedimento deve ser identificada através dos dados de entrada e lógica necessária, observando o que há de comum nos dois cálculos subsequentes: leitura de dois números, descoberta do maior, loop e print. Somente a entrada é diferente, levando a escrever o seguinte procedimento entre as linhas 7 e 18 do código a seguir:
1 import java.util.Scanner; // scanner é um módulo!
2 public class Proc3 {
3 // static = método estático (representa uma função/procedimento em Java)
4 // void = sem retorno (return)
5 // mmc = nome da função
6 // int n1, int n2 = dados de entrada necessários
7 static void mmc(int n1, int n2) {
8 // seja qual for a variável elas entram como n1 e n2
9 // descobrir o maior número
10 int maior = n1 > n2 ? n1 : n2;
11 int mmc = maior;
12 // somar o maior enquanto não for divisível
13 while (mmc % n1 != 0 || mmc % n1 != 0) {
14 mmc += maior;
15 }
16 // imprimir mmc
17 System.out.println(mmc);
18 }
19
20 public static void main(String[] args) {
21 // ler dois números
22 Scanner scan = new Scanner(System.in);
23 int numero1 = scan.nextInt();
24 int numero2 = scan.nextInt();
25 // chamada/invocação da função/procedimento
26 mmc(numero1, numero2);
27 // ler mais dois números
28 int numero3 = scan.nextInt();
29 int numero4 = scan.nextInt();
30 // chamada/invocação da função/procedimento
31 mmc(numero3, numero4);
32 }
33 }
Na linha 7 está a assinatura do método, que neste exemplo representa um procedimento. A assinatura, em poucas palavras, descreve o nome do procedimento, entrada e retorno, podendo ser descrita como Proc3.mmc(int, int):void, que significa um procedimento com o nome de mmc, pertencente ao módulo Proc3 (em Java um módulo é uma classe) que recebe dois parâmetros inteiros int, int e não possui retorno void (void pode ser traduzido livremente como vazio).
O procedimento anterior funciona bem, tu mesmo podes fazer os testes. Mas, como se testa? A maneira trivial de testar é chamar o procedimento com MMCs conhecidos e observar se a reposta é esperada, por exemplo, digitando 3 e 5 o resultado deve ser, inevitavelmente, 15. Realizar testes manuais, entrando com os valores e observando as saídas, mesmo com valores triviais, também é a testagem mais simplificada possível.
Testagem
Sem ir muito ao fundo do assunto, a testagem adequada é realizada com a introdução de um conjunto de entradas variadas, válidas e inválidas, as quais tenham saídas previsíveis (esperadas). A previsibilidade é o ponto-chave da testabilidade e para isso os procedimentos devem fornecer alguma saída, seja a resposta correta ou um tratamento de erro. O procedimento anterior não oferece uma saída, não tem retorno (void), o println faz parte da lógica do procedimento. Os dois problemas dessa abordagem são:
- a estratégia de impressão do resultado é “fixa”,
- o procedimento não oferece um retorno testável.
Por esses motivos o procedimento será alterado para oferecer um retorno, conforme exemplo a seguir:
1 import java.util.Scanner;
2
3 public class Proc4 {
4 // esse int antes do nome signigica um retorno inteiro
5 static int mmc(int n1, int n2) {
6 int maior = n1 > n2 ? n1 : n2;
7 int mmc = maior;
8 while (mmc % n1 != 0 || mmc % n2 != 0) {
9 mmc += maior;
10 }
11 // aqui trocamos o print pelo return
12 return mmc;
13 }
14
15 public static void main(String[] args) {
16 Scanner scan = new Scanner(System.in);
17 int numero1 = scan.nextInt();
18 int numero2 = scan.nextInt();
19 // agora a chamada traz um retorno
20 // que pode ser atribuído e impresso
21 int resultado = mmc(numero1, numero2);
22 System.out.println(resultado);
23 // ou então ser feito diretamente:
24 System.out.println(mmc(scan.nextInt(), scan.nextInt()));
25 }
26 }
Na linha 5, o int antes do nome do procedimento declara um retorno. Isto quer dizer que o procedimento devolve um inteiro que pode ser usado para qualquer fim, como ser impresso na linha 22. É importante notar as mudanças nas responsabilidades: antes a responsabilidade do println era do procedimento e agora é da seção principal main.
O próximo passo, agora, é escrever os testes. Preparar a testagem não é complicado, exige apenas duas medidas:
- eliminar a entrada do usuário e introduzir constantes literais (ex.:
int numero1 = 3), - escrever assertivas simples (expressões booleanas) para declarar o resultado esperado.
Um true impresso significa que o teste passou e um false (ou exceção) significa que o teste falhou (ou uma situação inesperada foi encontrada, um bug). Confira a testagem do procedimento mmc no código a seguir:
1 import java.util.Scanner;
2 public class Proc5 {
3
4 static int mmc(int n1, int n2) {
5 int maior = n1 > n2 ? n1 : n2;
6 int mmc = maior;
7 while (mmc % n1 != 0 || mmc % n2 != 0) {
8 mmc += maior;
9 }
10 return mmc;
11 }
12
13 public static void main(String[] args) {
14 int numero1 = 3;
15 int numero2 = 5;
16 int resultado = mmc(numero1, numero2);
17 // esperado que o mmc entre 3 e 5 seja 15
18 // deve imprimir `true`
19 System.out.println(resultado == 15);
20 // fazendo testes diretamente:
21 System.out.println(mmc(5, 6) == 30);
22 // testando no limite:
23 System.out.println(mmc(14223, 77323) == 1099765029);
24 }
25 }
As expressões booleanas nas linhas 19, 21 e 23 representam três testes: dois óbvios, que podem ser obtidos de cálculo mental, e um extrapolado (no limite, o mais importante!). Testar os limites é importante porque é “nas bordas” que falhas e bugs são revelados e, grave isso na memória: o objetivo de testar é provar que o programa não funciona. Neste exemplo, na verdade, foram realizados poucos testes, além de testar situações limites é importante adicionar mais testes afim de encontrar falhas e corrigi-las, até todos os testes passarem. Para minha felicidade (ou não, lembre :P) todos os testes passaram:
javac Proc5.java; java Proc5
true
true
true
Especificação
Procedimentos e, posteriormente, objetos são projetados e implementados a partir de especificações. As especificações devem ser corretas, precisas, claras e válidas! Sem uma especificação válida é impossível assegurar que as respostas são corretas ou mesmo provar que não são. Então, vamos obter a especificação do MMC:
Em aritmética e em teoria dos números o mínimo múltiplo comum (mmc) de dois inteiros a e b é o menor inteiro positivo que é múltiplo simultaneamente de a e de b. Se não existir tal inteiro positivo, por exemplo, se a =0 ou b = 0, então mmc(a, b) é zero por definição.
– Wikipédia em https://pt.wikipedia.org/wiki/M%C3%ADnimo_m%C3%BAltiplo_comum
Partindo dessa especificação, podemos assegurar que o procedimento implementado está em conformidade com ela? Vamos tentar provar que não, explorando as situações excepcionais e escrevendo os testes a seguir contra a especificação:
<<Testando contra a especificação
Não trate como decepcionante, mas como esclarecedor. Dos três primeiros testes, com números negativos, o primeiro e terceiro falham. A seção de testes com zero causa um erro logo no primeiro Exception in thread "main" java.lang.ArithmeticException: / by zero. Obter false indica uma falha e quer dizer que não está conforme a especificação pois retorna uma resposta incorreta (as duas falhas retornaram 5 e -5 respectivamente). Uma exceção, ou “quebra” do programa, indica um erro. Resumindo, o procedimento não cumpre a especificação, isto foi provado (e provar que não funciona é, acredite, bom!).
1.3 Modularização na Programação Orientada a Objetos
A POO se difere da programação procedimental quando combina os dados e o algoritmo em uma única unidade (módulo). Na POO a lógica é acessada a partir dos dados. Ainda temos o livro inteiro para discutir isso, mas apenas para ilustrar considere um exemplo “bobo”: calcular o dobro de um número inteiro. Na programação procedimental basta criar um procedimento dobro(int):int, ele recebe um inteiro e devolve outro, não é complicado, veja a seguir:
<<Procedimento para obter o dobro de um número
No procedimento o número é um parâmetro do procedimento. Na POO não há procedimento, há uma classe e por consequência um objeto que retém a informação (o número) e a lógica (o que seria um “procedimento” ou, no dialeto OO, o método!). A seguir a versão OO do dobro de um número:
<<Objeto para obter o dobro de um número
Se nunca viste POO antes, o código anterior pode parecer intimidador, confuso e até exagerado, numa avaliação mais crítica, pois POO geralmente exige mais código que o Procedimental para declarar as estruturas básicas. A POO oferece uma nova visão e interpretação (por isso paradigma) de como implementar uma especificação. Direto e em poucas palavras, no código anterior a noção de Número foi implementada com uma classe class Numero, da qual foram obtidas instâncias através de um construtor new Numero(5), que inicializa um atributo int n, até ser invocado o método dobro(), que então retorna o dobro do valor inicializado. O MMC de dois números também pode ser portado para POO, no mesmo sentido: cria-se uma classe que represente números, instancia e inicializa o objeto com 2 números e então invoca o método mmc(). Não será explicado agora, mas considere um desafio, se conseguires realizá-lo então estás a meio caminho andado.
1.4 Subprocedimentos
Procedimentos isolados não servem para construir um programa completo, normalmente os procedimentos (e vale o mesmo depois para os objetos) são compostos para resolver determinados problemas, buscando o reuso de algoritmos comuns e separação de responsabilidades em procedimentos menores.
Considere de volta o MMC e agora também o MDC. O MMC pode ser calculado a partir do MDC com a seguinte expressão mmc(a, b) = a / mdc(a, b) * b. No final podemos fazer o MMC composto pelo subprocedimento mdc(int,int):int, que abre outra oportunidade para um subprocedimento de seleção do menor número menor(int,int):int. A seguir o código que demonstra essa utilização de subprocedimentos:
public class Proc8 {
static int menor(int a, int b) {
System.out.println("calculando o menor");
int menor = a < b ? a : b;
System.out.println("menor calculado");
return menor;
}
static int mdc(int a, int b) {
System.out.println("calculando o mdc");
int mdc = menor(a, b);
while (a % mdc != 0 || b % mdc !=0) {
mdc = mdc - 1;
}
System.out.println("mdc calculado");
return mdc;
}
static int mmc(int a, int b) {
System.out.println("calculando o mmc");
int mmc = a / mdc(a, b) * b;
System.out.println("mmc calculado");
return mmc;
}
public static void main(String[] args) {
// testando o mmc
System.out.println(mmc(5, 6) == 30);
}
}
java Proc8
calculando o mmc
calculando o mdc
calculando o menor
menor calculado
mdc calculado
mmc calculado
true
Existe uma consideração importante para tratar: as dependências. O procedimento mmc depende da resposta do procedimento mdc. Essa relação de dependência é para o bem e para o mal, quero dizer, se o procedimento mdc falha ou “quebra” então o procedimento mmc também falha ou “quebra”. Como mdc depende de menor, se menor falha então mdc falha e mmc também falhará, devido a dependência indireta.
Mesmo com a consideração da dependência existe pontos extremamente positivos em separar procedimentos: subprocedimentos são menores e podem ser testados separadamente. Na prática, testar e garantir o bom funcionamento da função menor assegura a qualidade da função mdc e todas as outras que dependem de menor. O mesmo vale em garantir mdc para respaldar mmc.
Outra forma de combinar procedimentos é usar a saída (o retorno) de um procedimento como entrada de outro. Por exemplo, considerando que existem os procedimentos raizQuadrada(double):double, raizCubica(double):double e menor(double, double):double, qual seria a resposta da seguinte instrução menor(raizQuadrada(225), raizCubica(216))? Assim como um expressão matemática, primeiro resolve-se os parenteses internos e depois os externos (a resposta não é difícil de obter).
Para compreender melhor a composição de procedimentos vamos para um exemplo completo e complexo: o cálculo do seno de um ângulo. Considere uma função seno(double):double onde a entrada é em radianos, contudo o ângulo que temos é em graus (por exemplo 75°). Então para calcular o seno antes temos de converter o ângulo em radianos com uma função radianos(doule):double. A seguir como ficaria a composição:
double graus = 75.0; // {graus: 75.0}
System.out.println(seno(radianos(graus))); // => seno(radianos(75.0))
A implementação de radianos(double):double é simples, calculada como PI * graus / 180.0. A implementação de seno(double):double não é tão simples e depende, claramente, de subprocedimentos; o seno pode ser calculado pela Série de Maclaurin (não se preocupe, esse não é um livro de matemática :) simplificada como:
Observando o algoritmo na imagem percebe-se a necessidade de potência e fatorial. Enfim, o procedimento seno(double):double depende de potencia(double, double):double e fatorial(double):double. É uma opção criar esses subprocedimentos ou codificar tudo em seno, mas há questões a ponderar, como o reaproveitamento dos subprocedimentos, o aumento da testabilidade, contra o aumento da granularidade dos módulos.
Para estudo eu implementei os procedimentos e subprocedimentos, enfim, todas as funções estão no código que vem a seguir. Não é a melhor implementação possível, há um problema de imprecisão, da própria implementação como da essência do ponto flutuante, mas é boa suficiente para estudo.
public class Proc9 {
static double potencia(double base, int expoente) {
double potencia = base;
for (int i = 1; i < expoente; i++) {
potencia *= base;
}
return potencia;
}
static int fatorial(int numero) {
int fatorial = 1;
for (int i = 1; i <= numero; i++) {
fatorial = fatorial * i;
}
return fatorial;
}
static double seno(double radianos) {
double seno = 0;
seno = radianos - (potencia(radianos, 3) / fatorial(3))
+ (potencia(radianos, 5) / fatorial(5))
- (potencia(radianos, 7) / fatorial(7))
+ (potencia(radianos, 9) / fatorial(9));
return seno;
}
static double radianos(double graus) {
double PI = 3.1416;
double radianos;
radianos = PI * graus / 180.0;
return radianos;
}
public static void main(String[] args) {
// System.out.println(fatorial(5));
// combinando os procedimentos
System.out.println(seno(radianos(75)));
//(seno(1.309))
//(0.9659270976271132)
// é uma resposta aproximada
// a esperada de 0.96592583
// além disso, double sempre trabalha
// com aproximações (ver problemas de precisão)
}
}
1.5 Procedimentos e Dados Estruturados
Ao contrário dos exemplos anteriores, os procedimentos trabalham mais com informações elaboradas do que com dados isolados. Na prática, quer dizer que os procedimentos (e depois objetos e seus métodos) raramente processarão uma variável numérica ou textual simples, mas sim um agrupamento de variáveis. Por exemplo, um cálculo de ICMS seria feito com a Nota Fiscal inteira e não somente com o valor final. Por isso, há a necessidade de agrupar dados para definir uma informação autocontida.
As linguagens de programação disponibilizam estruturas para conter dados relacionados. A estrutura de dados mais popular é o array (arranjo, em português), que também é conhecida como vetor (ou matrix quando é bidimensional). Arrays estão disponíveis em todas linguagens de programação populares, com poucas diferenças de sintaxe, permitindo guardar valores e recuperá-los através de um índice numérico. Existem, portanto, diversas estruturas de dados, como list (lista), set (conjunto), graph (grafo), tuple (tupla), struct (conhecida como registro), associative array (vetor associativo), class (classe, fundamental para a POO e vista no restante do livro), etc.
Para os exemplos deste capítulo vamos usar vetores como estrutura de dados. Se tu ainda não conheces, vetor é uma estrutura e um tipo, que pode ser declarado e atribuído a variáveis, permitindo armazenar valores conforme um índice numérico. Para entender melhor, considere o exemplo:
1 // declarando um vetor de 6 inteiros:
2 int[] copas = new int[6]; // => [0, 0, 0, 0, 0, 0]
3 // atribuindo as posições/slots:
4 copas[0] = 1994; // => [1994, 0, 0, 0, 0, 0]
5 copas[1] = 1998; // => [1994, 1998, 0, 0, 0, 0]
6 copas[2] = 2002; // => [1994, 1998, 2002, 0, 0, 0]
7 // copas[3] = 2006;
8 copas[4] = 2010; // => [1994, 1998, 2002, 0, 2010, 0]
9 copas[5] = 2014; // => [1994, 1998, 2002, 0, 2010, 2014]
10 // consultando o comprimento (length) do vetor:
11 System.out.println(copas.length); // 6
12 // consultando os elementos
13 System.out.println(copas[0]); // 1994
14 System.out.println(copas[2]); // 2002
15 // se não atribuído, vale zero (valor default para int)
16 System.out.println(copas[3]); // 0
17 // último elemento sempre está na posição length - 1
18 System.out.println(copas[copas.length - 1]); // 2014
19 // descomentar a linha a seguir causa uma exceção IndexOutOfBoundException
20 // System.out.println(copas[6]); // fora dos limites
21 // percorrendo (iterando) e imprimindo os valores:
22 for (int i = 0; i < copas.length; i++) {
23 System.out.println(copas[i]);
24 }
25 // outro meio de iterar os valores é com "for each":
26 for (int ano : copas) { // se lê como: "para cada ano de copas"
27 System.out.println(ano); // não sabemos a posição, mas sabemos os valores
28 }
A linha 2 apresenta a declaração de um vetor. Neste exemplo foi usado um vetor de inteiros de tamanho 6, mas poderia ser um vetor de números reais (double[] valores) ou outros tipos de dados. Quatro propriedades devem ficar bem claras:
- Vetores têm um comprimento (
length), - vetores têm índices (
indexes), - os índices iniciam em zero
0e - o maior índice possível sempre é o comprimento menos um (
length - 1).
Essas propriedades foram exemplificadas nas linhas 18 e 23.
Para exemplificar a necessidade e uso de estruturas de dados, considere um programa “bobo” para ler duas datas e imprimi-las. Datas, em si, são informações compostas por três dados: dia, mês e ano. Em vez de codificar os dados em três variáveis, codifica-se como um vetor. No primeiro exemplo, como ficaria usando apenas variáveis isoladas:
import java.util.Scanner; // scanner é um módulo!
public class Estrutura1 {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int diaInicio = scan.nextInt();
int mesInicio = scan.nextInt();
int anoInicio = scan.nextInt();
int diaFim = scan.nextInt();
int mesFim = scan.nextInt();
int anoFim = scan.nextInt();
System.out.println(diaInicio + "/"
+ mesInicio + "/"
+ anoInicio + " a "
+ diaFim + "/"
+ mesFim + "/"
+ anoFim);
}
}
O exemplo anterior pode ser refatorado para usar vetores na representação das datas, como pode ser visto no código a seguir:
import java.util.Scanner;
public class Estrutura2 {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
// dois vetores de três posições
int[] dataInicio = new int[3];
int[] dataFim = new int[3];
// zero based indexes
dataInicio[0] = scan.nextInt();
dataInicio[1] = scan.nextInt();
dataInicio[2] = scan.nextInt();
dataFim[0] = scan.nextInt();
dataFim[1] = scan.nextInt();
dataFim[2] = scan.nextInt();
System.out.println(dataInicio[0] + "/"
+ dataInicio[1] + "/"
+ dataInicio[2] + " a "
+ dataFim[0] + "/"
+ dataFim[1] + "/"
+ dataFim[2]);
}
}
Conter os dados em vetores (e depois em classes/objetos) facilita o trânsito das informações, permitindo que os dados andem juntos. Para demonstrar vamos evoluir o exemplo anterior, extraindo a apresentação da data “d/m/a” para um procedimento formataData(int[]):String como pode ser visto no código a seguir:
import java.util.Scanner;
public class Estrutura3 {
static String formataData(int[] data) {
return data[0] + "/"
+ data[1] + "/"
+ data[2];
}
public static void main(String[] args) {
int[] dataInicio = new int[3];
int[] dataFim = new int[3];
dataInicio[0] = scan.nextInt();
dataInicio[1] = scan.nextInt();
dataInicio[2] = scan.nextInt();
dataFim[0] = scan.nextInt();
dataFim[1] = scan.nextInt();
dataFim[2] = scan.nextInt();
System.out.println(formataData(dataInicio) + " a "
+ formataData(dataFim));
}
}
Se os dados não fossem contidos a função teria a assinatura formataData(int,int,int):String, recebendo os três int’s (dia, mês e ano) e devolvendo a String com a data formatada. O procedimento parece bom suficiente, mas falta um detalhe importante que é indispensável: os testes.
1 public class Estrutura4 {
2
3 static String formataData(int[] data) {
4 return data[0] + "/"
5 + data[1] + "/"
6 + data[2];
7 }
8
9 public static void main(String[] args) {
10 int[] dataInicio = { 1, 7, 2016};
11 int[] dataFim = {31, 7, 2016};
12
13 System.out.println(
14 formataData(dataInicio).equals("1/7/2016"));
15 System.out.println(
16 formataData(dataFim).equals("31/7/2016"));
17 System.out.println(
18 formataData(new int[]{10,10,2010}).equals("10/10/2010"));
19 }
20 }
Este último código de exemplo, além de testar o nosso procedimento “bobo” formataData, apresenta detalhes básicos importantes para lidar com vetores. Nas linhas 10 e 11 os vetores são declarados e inicializados com os valores inteiros literais. Na linha 18 o vetor é criado e inicializado ao mesmo tempo que é passado como argumento para o procedimento.
Agora vamos a um exemplo mais elaborado. Além disso, para avançar um pouco mais na programação modular, vamos separar o corpo principal do programa (o main) da unidade com os procedimentos: Data.formataData e Data.somaDias(int[], int):int[] dentro de um módulo Data (class Data em Java).
Neste estudo de caso, vamos projetar um procedimento para adicionar dias a uma data. O procedimento recebe um vetor com a data e a quantidade de dias em inteiro e então devolve outro vetor com o resultado, segundo essa assinatura: somaDias(int[], int):int[]. Não é uma lógica simples ou trivial, trabalhar com datas é sempre desafiador, pois as regras não têm um padrão simples: alguns meses têm 30 dias, outros têm 31, um tem 28 ou 29 nos anos bissextos. Entretanto, o programador pode usar a estratégia de reduzir o problema em parte menores e mais simples de serem resolvidas. Essa estratégia é conhecida como Computational Thinking (Pensamento Computacional) e consiste em analisar uma instância do problema, reconhecer um padrão, decompor o problema, analisar solucionar e solucionar as partes até projetar uma solução genérica para o problema inteiro. Começando com um esboço:
// arquivo Data.java
public class Data {
static int[] somaDias(int[] data, int dias) {
int[] resultado = new int[3];
// <= aqui vai o algoritmo.
return resultado;
}
static String formataData(int[] data) {
return data[0] + "/"
+ data[1] + "/"
+ data[2];
}
}
// arquivo DataMain.java
public class DataMain {
public static void main(String[] args) {
int[] dataInicio = { 1, 7, 2016}; // => [ 1, 7, 2016]
int[] dataFim = {31, 7, 2016}; // => [31, 7, 2016]
System.out.println(Data.formataData(dataInicio).equals("1/7/2016"));
System.out.println(Data.formataData(dataFim).equals("31/7/2016"));
System.out.println(Data.formataData(new int[]{10,10,2010})
.equals("10/10/2010"));
int[] umaData = {1, 1, 2016};
int[] resultado = Data.somaDias(umaData, 40); // => [10, 2, 2016]
System.out.println(resultado[0] == 10);
System.out.println(resultado[1] == 2);
System.out.println(resultado[2] == 2016);
}
}
Esse código pode confundir um pouco, pois antes de implementar todo o procedimento somaDias já se tem o corpo principal do programa e um teste. Antecipar testes é uma ótima estratégia para pensar como devem ser projetados e que respostas devem dar os procedimentos ou objetos4. Projetar um código longo e que cumpra tantas regras pode levar um tempo, então, em vez disso, podemos começar decompondo o problema em um subproblema mais simples e que traz um resultado mais rápido. Considere um procedimento para descobrir o próximo dia de uma data: Data.amanha(int[]):int[] - que é uma simplificação de soma dias para soma 1 dia. Considere os seguintes casos de teste:
amanhaint[] dataInicio = { 1, 7, 2016};
int[] dataFim = {31, 7, 2016};
int[] dataResult;
// cumprindo primeiro o "amanhã" (soma um dia):
dataResult = Data.amanha(dataInicio); // [2, 7, 2016]
System.out.println(dataResult[0] == 2);
System.out.println(dataResult[1] == 7);
System.out.println(dataResult[2] == 2016);
dataResult = Data.amanha(dataFim); // [1, 8, 2016]
System.out.println(dataResult[0] == 1);
System.out.println(dataResult[1] == 8);
System.out.println(dataResult[2] == 2016);
// compondo: (depois de depois de amanhã)
dataResult = Data.amanha(Data.amanha(Data.amanha(dataInicio))); // [4, 7, 2016]
Fazer a funcionalidade amanha é percorrer 90% do caminho para implementar um somaDias, que pode ser visto como uma extensão do amanhã, em outras palavras, somar dias é avançar vários amanhã’s. A seguir a solução do amanha:
somaDias a partir do amanha 1 class Data {
2 // Jan Fev Mar Abr Mai Jun
3 static int[] diasMes = {31, 28, 31, 30, 31, 30,
4 31, 31, 30, 31, 30, 31};
5 // Jul Ago Set Out Nov Dez
6 static int[] amanha(int[] data) {
7 // copiar o estado
8 int[] amanha = {data[0] + 1, data[1], data[2]};
9 // se ultrapassou o último dia do mês
10 if (amanha[0] > diasMes[amanha[1] - 1]) {
11 amanha[0] = 1; // dia 1
12 // se antes de dezembro
13 if (amanha[1] < 12) {
14 amanha[1] = amanha[1] + 1; // próximo mês
15 } else { // se em dezembro
16 amanha[1] = 1; // janeiro do
17 amanha[2] = amanha[2] + 1; // próximo ano
18 }
19 }
20 return amanha;
21 }
22
23 static int[] somaDias(int[] data, int dias) {
24 int[] resultado = {data[0], data[1], data[2]};
25 for (int i = 0; i < dias; i++) {
26 resultado = amanha(resultado);
27 }
28 return resultado;
29 }
Este exemplo demonstra a estratégia de decomposição do problema em partes menores que ajudam na solução do problema maior. A simplicidade em saltar um dia é visível nas expressões condicionais entre as linhas 10 e 19. É possível escrever estas regras de várias formas, a apresentada foi um exemplo didático. O ponto alto do exemplo está entre as linhas 25 e 27, que utiliza o amanha como um subprocedimento de somaDias, executado n (ou dias) vezes. Embora tenha passado nos Casos de Teste, ainda há um bug eminente de uma regra não implementada para ver no exercício a seguir:
Representação Textual (char e String)
Fechando este tópico, mais uma coisa a considerar: a representação textual. As linguagens em geral disponibilizam o tipo string, representado literalmente por caracteres entre aspas, por exemplo "#poocomhonra". As strings, embora fundamentais nas linguagens, não são elementares, mas sim compostas por caracteres individuais (em Java um char) e podem ser vistas como um vetor de caracteres. Assim, elas compartilham as propriedades dos vetores, como posição e tamanho, conforme exemplo a seguir:
// dica: marque os índices
//012345678901
String hashtag = "#poocomhonra";
// comprimento da string
System.out.println(hashtag.length() == 12);
// primeiro caractere
System.out.println(hashtag.charAt(0) == '#');
// em Java um char é representado
// por aspas simples
System.out.println(hashtag.charAt(4) == 'c');
System.out.println(hashtag.charAt(11) == 'a');
char[] caracteres = hashtag.toCharArray();
As strings assim como os vetores, em Java, são objetos, referenciáveis, mas com uma diferença: enquanto vetores são mutáveis, strings não são. Essa característica de imutabilidade da string, e que veremos como fazer com nossos objetos nos capítulos a seguir, garante que as strings originais não sofram alterações quando passadas para procedimentos ou métodos. Já os vetores, estão propensos a alterações por onde passam, fenômeno conhecido como efeito colateral do procedimento (e que vale o mesmo para métodos em OO). Para exemplificar, vamos retomar o procedimento amanha:
amanha com retorno de cópia modificada da entrada 1 class Data {
2 // Jan Fev Mar Abr Mai Jun
3 static int[] diasMes = {31, 28, 31, 30, 31, 30,
4 31, 31, 30, 31, 30, 31};
5 // Jul Ago Set Out Nov Dez
6 static int[] amanha(int[] data) {
7 // copiar o estado
8 int[] amanha = {data[0] + 1, data[1], data[2]};
9 // se ultrapassou o último dia do mês
10 if (amanha[0] > diasMes[amanha[1] - 1]) {
11 amanha[0] = 1; // dia 1
12 // se antes de dezembro
13 if (amanha[1] < 12) {
14 amanha[1] = amanha[1] + 1; // próximo mês
15 } else { // se em dezembro
16 amanha[1] = 1; // janeiro do
17 amanha[2] = amanha[2] + 1; // próximo ano
18 }
19 }
20 return amanha;
21 }
O procedimento amanha faz uma cópia do vetor de entrada e devolve essa cópia, contudo, é possível alterar o próprio vetor de entrada e não criar um vetor novo. Essa técnica é econômica, pois reduz processamento e memória, contudo, perde-se o estado anterior do vetor. Para demonstrar essa técnica de mutabilidade considere um novo procedimento chamado amanha2, nele o vetor original é alterado conforme o exemplo:
amanha2 sem retorno e com efeito colateral static void amanha2(int[] data) {
data[0] = data[0] + 1;
if (data[0] > diasMes[data[1] - 1]) {
data[0] = 1;
if (data[1] < 12) {
data[1] = data[1] + 1;
} else {
data[1] = 1;
data[2] = data[2] + 1;
}
}
}
O procedimento amanha2, embora declare retorno void (vazio), gera um retorno refletido na própria entrada. Considere os seguintes testes para entender:
int[] umaData = {31, 7, 2016};
System.out.println(umaData[0] == 31);
System.out.println(umaData[1] == 7);
System.out.println(umaData[2] == 2016);
Data.amanha(umaData); // sem atribuição
System.out.println(umaData[0] == 1);
System.out.println(umaData[1] == 8);
System.out.println(umaData[2] == 2016);
Data.amanha(umaData); // age sobre a data modificada
System.out.println(umaData[0] == 2);
System.out.println(umaData[1] == 8);
System.out.println(umaData[2] == 2016);
Os efeitos colaterais estão propensos quando se usam tipos referenciados, objetos. Contudo, alguns objetos não são mutáveis. Considere o seguinte procedimento com string, que não produz resultado:
<<Procedimentos para cortar strings
Mesmo que strings sejam referenciadas lembre que elas são imutáveis, ou seja, o objeto original nunca é alterado. É por esse motivo que essa instrução String s = s.toUpperCase(); altera o valor em s e essa instrução s.toUpperCase() não. Na primeira instrução a versão maiúscula da string repõe a antiga string, na segunda a cópia não é reatribuída e a string de s permanece original, minúscula.
Para fechar esse tópico, vamos trabalhar um pouco com strings no modo “roots”, isto é, não vamos usar os procedimentos e métodos embutidos da linguagem, em vez disso, vamos trabalhar caractere a caractere, em outras palavras, “lidar com strings no braço”! Como exemplo, vamos retomar o procedimento corta(String,int):String, só que desta vez sem usar o método substring:
1 class Texto2 {
2
3 static String corta(String texto, int tamanho) {
4 // reservando o espaço para os caracteres
5 char[] caracteres = new char[tamanho];
6 // copiando da string para o vetor
7 for (int i = 0; i < tamanho; i++) {
8 caracteres[i] = texto.charAt(i);
9 }
10 // usando o vetor para uma String
11 return new String(caracteres);
12 }
13
14 public static void main(String[] args) {
15 String hashtag = "#poocomhonra";
16 System.out.println(corta(hashtag, 1).equals("#"));
17 System.out.println(corta(hashtag, 2).equals("#p"));
18 System.out.println(corta(hashtag, 3).equals("#po"));
19 System.out.println(corta(hashtag, 4).equals("#poo"));
20 System.out.println(corta(hashtag, 5).equals("#pooc"));
21 // faltam as situações excepcionais, por exemplo:
22 // System.out.println(corta(hashtag, 20).equals("#poocomhonra"));
23 }
24 }
Esse procedimento traz alguns recursos importantes para o aprendizado, como manipular strings caractere a caractere para criar novas strings (ver linha 11).
1.6 “Procedimentos Orientados a Objetos?!”
É possível implementar procedimentos que recebam objetos, já que objetos são Estruturas de Dados e podem substituir os vetores nessa tarefa. No fim, é um misto: usa-se o procedimento para codificar a lógica e o objeto para carregar os dados. Considere o exemplo da data revisitado, agora com objetos, a seguir:
class DadosData {
int dia, mes, ano;
}
class DataMisto {
static int[] diasMes = {31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31};
static DadosData amanha(DadosData data) {
DadosData amanha = new DadosData();
amanha.dia = data.dia + 1;
amanha.mes = data.mes;
amanha.ano = data.ano;
if (amanha.dia > diasMes[amanha.mes - 1]) {
amanha.dia = 1;
if (amanha.mes < 12) {
amanha.mes = amanha.mes + 1;
} else {
amanha.mes = 1;
amanha.ano = amanha.ano + 1;
}
}
return amanha;
}
public static void main(String[] args) {
DadosData umaData = new DadosData();
umaData.dia = 31;
umaData.mes = 7;
umaData.ano = 2016;
DadosData resultado = DataMisto.amanha(umaData);
System.out.println(resultado.dia == 1);
System.out.println(resultado.mes == 8);
System.out.println(resultado.ano == 2016);
}
}
Neste exemplo, o módulo DataMisto e o procedimento amanha agem sobre DadosData, que é um substituto do vetor, servindo para “transportar” os dados _dia, mês e ano__.
1.7 Aderindo à Programação Orientada a Objetos com Responsabilidade
Um característica interessante na programação e desenvolvimento de software é a possibilidade de resolver o mesmo problema de várias maneiras diferentes, com códigos diferentes, estruturas diferentes, enfim, programar é uma atividade criativa. Quando se fala em paradigmas, cada um orienta, influencia e impacta fortemente no código resultante, mas para usá-los corretamente o programador deve “chavear” seu modo de pensar, pois o entendimento do paradigma é fundamental para escrever um código sólido e uma solução adequada.
Importante, escolher um paradigma é também abraçar os conceitos e diretrizes presentes nele, projetando seus programas segundo os construtos e ideias (e ideais) principalmente. Por exemplo, aderir a Programação Guiada por Eventos, implica em especificar o fluxo do programa reagindo a eventos, assim como aderir à Programação Funcional implica em encapsular a lógica do programa como funções sem estado.
Programar aderindo ao paradigma Orientado a Objetos implica em seguir as orientações e manter o código consistente com os conceitos básicos e fundamentais, tais como o que são objetos e como se escreve um programa com eles. A maioria das linguagens, no entanto, não “obrigam” os programadores a seguir esses conceitos. Na prática, as linguagens modernas juntam conceitos e técnicas disponíveis de vários paradigmas, por exemplo, Ruby é une programação estruturada, procedimental, orientada a objetos e tem alguns aspectos funcionais e reflexivos.
Então, como Programar Orientado a Objetos do jeito certo?* Basta seguir os conceitos do paradigma. No entanto, não quer dizer que, às vezes, não se possa usar outra abordagem, graças às linguagens multiparadigma isto não é nenhum motivo de embaraçamento. O que não se deve, de verdade, é violar os princípios de OO e afirmar, ainda, que o programa é OO - o pior enganador é o que engana a si mesmo. Por exemplo, um dos princípios mais básicos de OO é manter as estruturas de dados e algoritmos no mesmo módulo (neste caso, na mesma classe). Na prática, significa colocar no mesmo escopo os atributos e os métodos. Como exemplo, considere novamente o exemplo da data adequadamente orientado a objetos:
Data Orientada a Objetos 1 public class POOComHonra {
2 public static void main(String[] args) {
3 Data umaData = new Data();
4 umaData.dia = 31;
5 umaData.mes = 7;
6 umaData.ano = 2016;
7 Data resultado = umaData.amanha();
8 System.out.println(resultado.dia == 1);
9 System.out.println(resultado.mes == 8);
10 System.out.println(resultado.ano == 2016);
11 }
12 }
13
14 class Data {
15 int dia, mes, ano;
16 static final int[]
17 diasMes = {31, 28, 31, 30, 31, 30,
18 31, 31, 30, 31, 30, 31};
19
20 Data amanha() {
21 Data amanha = new Data();
22 amanha.dia = this.dia + 1;
23 amanha.mes = this.mes;
24 amanha.ano = this.ano;
25 if (amanha.dia > diasMes[amanha.mes - 1]) {
26 amanha.dia = 1;
27 if (amanha.mes < 12) {
28 amanha.mes = amanha.mes + 1;
29 } else {
30 amanha.mes = 1;
31 amanha.ano = amanha.ano + 1;
32 }
33 }
34 return amanha;
35 }
36 }
Esta classe honra o princípio mais básico de acomodar dados e algoritmos na mesma unidade*. Os dados são representados por atributos da classe (linha 15) e o algoritmo no método amanha (linha 20) que não é mais estático (o que se equipara a um procedimento), em vez disso ele é uma operação do objeto Data (métodos não-estáticos são operações/ações dos objetos). Outro detalhe importante está entre as linhas 22 a 24, que é o acesso aos atributos através do this (este objeto).
1.8 Considerações
Se tem muito a discutir ainda, a rigor, a classe Data implementada anteriormente não está de acordo com vários outros conceitos essenciais de orientação a objetos, como o encapsulamento e validade do estado. No entanto, capítulo a capítulo estes conceitos e princípios serão trazidos e os exemplos enriquecidos até alcançar um nível bem alto de qualidade.