Programação Orientada a Objetos
Programação Orientada a Objetos
Buy on Leanpub
Programação Orientada a Objetos

Sumário

Prefácio

Ministrar uma disciplina, ter estudantes e observar suas dificuldades, identificar as principais armadilhas que capturam e “travam” esses aprendizes, isto te instiga, fomenta e torna-se o principal motivador para um professor tornar suas aulas, exemplos, anotações, explicações, etc, em um livro didático de educação profissional.

Esse livro começou em 2016, quando lecionava a disciplina de Programação Orientada a Objetos (POO) para os cursos de Análise e Desenvolvimento de Sistemas e Técnico em Informática para Internet. Nas primeiras aulas sempre trouxe anotações, que eram uma compilação das bibliografias recomendadas, misturadas com experiência de trabalho e envoltas em um contexto prático, já que os cursos de tecnologia e técnicos são voltados para a aplicação prática. O desafio é achar a didática para obter uma abordagem pragmática, mas que ao mesmo tempo ofereça o alicerce teórico, lembrando sempre que esses estudantes podem, no futuro, tanto atuar profissionalmente, como procurar qualificação continuada, um mestrado por exemplo, ou realizar concursos. Então essas anotações de aula foram desconstruídas e reconstruídas muitas vezes para serem “ensináveis” e “aprendíveis”. O resultado foi um livro-texto da disciplina.

Sem mais delongas, este livro não foi escrito inicialmente para ser publicado, mas como um material didático para usar em sala de aula. Contudo, pensando na utilidade às pessoas que se esforçam em aprender POO, as vezes sozinhas ou em apoio seus cursos, acabei liberando antecipadamente no leanpub. Se ele for só um pouquinho útil o trabalho já se pagou.

Se quiseres me dar um retorno, sugestões e críticas são bem-vindas, meu e-mail é: marciojrtorres@gmail.com.

Introdução

Eu poderia dizer muita coisa aqui, mas vou ser pragmático e resumir o objetivo deste livro em uma frase: utilizar o paradigma de programação orientado a objetos para construir software de qualidade.

Abordagem

Este livro está sendo escrito a partir de notas de aula para a disciplina de Programção Orientada a Objetos ministrada no curso de Análise e Desenvolvimento de Sistemas oferecido pelo Intituto Federal de Educação, Ciência e Tecnologia do Rio Grande do Sul (IFRS). Essa origem dá ao livro as seguintes características:

  • Dedicado ao ensino: projetado para ser didático assim como as estratégias de ensino em sala aula;
  • Focado na prática: para cada explanação teórica há exemplos de aplicação prática, com exemplos e estudos de caso, enfim, ele é usado na educação profissionalizante;
  • No nosso idioma e na nossa cultura: sem cara de tradução, feito para as pessoas que falam e/ou entendem português (com um leve sotaque gaúcho;)
  • Linguagem acessível: tenta evitar expressões rebuscadas e formalidades linguisticas, o que é muito útil para as pessoas não dormirem nas aulas – ou lendo um livro :)

O ponto forte do livro é a não-separação de teoria e prática, então cada conceito ou princípio abordado virá junto com um exemplo “bobo”, um estudo de caso mais elaborado e um exercício. Como exemplo “bobo” quero dizer uma amostra de implementação pequena e direta que não é muito útil na “vida real”, mas que é muito esclarecedora para um aprendiz. Para ilustrar, considere o exemplo canônico1 de herança com a classe Animal e subclasses Gato, Pato, Cachorro, enfim, é um recurso didático excelente para o primeiro o contato. O estudo de caso é mais elaborado e serve para observar uma aplicação mais séria e plausível. Os exercícios servem para a prática do conhecimento e apropriação da habilidade, é extremamente recomendado que eles sejam realizados, seguindo as especificações e passar nos casos de teste quando houverem.

Falando em Especificações e Casos de Teste, os exemplos serão baseados em especificações e assertivas que permitem validar e verificar se o software está saindo conforme o planejado e com as respostas esperadas, que são premissas básicas de um controle de qualidade de software (ver objetivo em Introdução).

Linguagem de programação usada nos exemplos e estudos de caso

Os códigos estão escritos na linguagem de programação Java, que é a linguagem preferida na grande maioria dos livros de referência em Programação Orientada a Objetos. Além disso, Java é bastante usada pelas faculdades e escolas técnicas. Aqui mesmo no IFRS usamos Java na disciplina de POO.

Contudo, pretendo lançar esse livro com outras linguagens de programação. Inclusive, é objetivo desse livro não se “apegar” a linguagem de programação, sempre tentando abordar os conceitos, princípios e implementações de forma mais genérica possível, focando sempre no paradigma e não na linguagem de programação. Dito isso, este livro não é uma referência da linguagem Java, em vez disso, é uma referência para entender e usar programação orientada a objetos!

Fechando, na verdade, a maioria dos códigos presentes no livro são implementáveis em qualquer linguagem de programação que ofereça um suporte razoável à POO, com poucos ajustes. Entenda que POO oferece certas funcionalidades que as linguagens podem oferecer na totalidade, parcialmente ou até mesmo não oferecer.

Todos os códigos estão disponíveis na on-line em um repositório no github.com neste endereço: https://github.com/marciojrtorres/poocomhonra. Fique à vontade para baixá-los, adaptá-los, testá-los, forkar o repositório, bem, faça como quiser, mi código es su código. (mas use-os por sua conta e risco, hehe :).

Organização dos capítulos

O livro está organizado para cobrir tópicos de POO gradualmente, presumindo que o estudante já conheça o fundamental de programação, como detalhes de sintaxe, declaração, desvios condionais, laços, etc (por exemplo, ter passado pela disciplina de Lógica de Programação).

  • Capítulo 0 – Programação Modular: revisitar (ou abordar se ainda não foi visto) os fundamentos de modularização de código por meio de procedimentos codificados como funções ou métodos estáticos.
  • Capítulo 1 – Introdução à Programação Orientada a Objetos: traz um pouco da história e então aborda as noções básicas e comuns no vocabulário da OO, como o que é uma classe e o que são objetos, atributos e métodos, etc.
  • Capítulo 2 – Conceitos Fundamentais da POO: entra na base conceitual importante para a qualidade de um projeto orientado a objetos, como abstração, coesão, encapsulamento, etc.
  • Capítulo 3 – Associações entre Objetos: focado no relacionamento entre os objetos e suas interações, colaborando para modelar e resolver os problemas.
  • Capítulo 4 – Generalização e Especialização de Objetos: introduz o recursos mais popular da POO: a herança! E, claro, os temas relacionados como polimorfismo, generalização, especialização, etc.
  • Capítulo 5 – API’s e Contratos entre Objetos: reforça os conceitos anteriores e qualifica a interação entre os objetos através de suas interfaces com contratos estritos.
  • Capítulo 6 – Princípios de Projeto de Objetos: aborda alguns dos princípios (mantras) mais populares no projeto de objetos, como UAP, SRP, OCP, etc.
  • Capítulo 7 – O que vem depois?

Para quem é este livro

É importante sempre esclarecer que este é um livro voltado à Educação Profissional, baseado na minha experiência profissional e no ensino de programação para iniciantes. Ele é útil para estudantes que já passaram pela disciplina inicial de programação em seus cursos (Lógica de Programação, por exemplo) e estão aprendendo o paradigma de programação orientado a objetos.

Contudo, essa obra também pode atender programadores formados, entusiastas e autodidatas de programação que desejam compreender melhor (e melhor aplicar) o paradigma orientado a objetos.

Profissionalmente, este livro pode ser usado para descobrir melhores estratégias para a modularização de sistemas.

Academicamente, este livro é adequado ao ensino em cursos voltados para a Educação Profissional, Técnica e Tecnológica, tais como: Técnico em Informática, Técnico em Informática para Internet, Tecnologia em Análise e Desenvolvimento de Sistemas, Tecnologia em Sistemas para Internet, além de cursos de formação inicial e continuada na área de desenvolvimento de sistemas. Mesmo uma disciplina de POO de bacharelados, tais como Ciência/Engenharia da Computação ou Sistemas de Informação, se procuram um viés pragmático e aplicado, podem tirar proveito desta obra. Para ajudar, saiba que ele está sendo usado no IFRS para a disciplina de Programação Orientada a Objetos, cuja ementa está a seguir:

Abstração, classes, instâncias, estado e comportamento, atributos e métodos, comandos e consultas, coesão, encapsulamento e ocultação de informações, associações, agregação, composição, delegação, dependência e acoplamento, herança e polimorfismo. Projeto com modelagem visual. Implementação e testes. Noções de arquitetura e padrões de projeto

Para quem não é este livro

Este livro não cobre construções básicas de programas e não tem o objetivo de ensinar a programar do zero. O objetivo do livro é ensinar a usar o paradigma orientado a objetos. Se não sabes programar, não passaste por uma disciplina de lógica de programação ou algoritmos, então ele pode parecer muito avançado para ti e não te ser muito útil no final.

Não espere deste livro uma abordagem completamente teórica. Ele foi escrito por um praticante para praticantes.

Não espere, também, uma abordagem extensa da linguagem de programação, neste caso Java. O objetivo é estudar os conceitos do paradigma orientado a objetos e a linguagem é usada apenas para materializar esses conceitos.

Se tu já sabes POO e procura um livro que aprofunde esse conhecimento, também pode não ser uma boa escolha, para estes casos seria importante para ti um referência de Projeto Orientado a Objetos, como um livro de Padrões de Projeto ou de Arquitetura de Sistemas.

Convenções

A seguir algumas convenções a respeito da abordagem, tipografia e layout.

Acrônimos

Algumas palavras e nomes aparecem abreviados, usando siglas ou acrônimos. Na primeira vez que forem exibidos constará o nome completo e no restante do livro (salvo exceções) é usado o acrônimo. Por exemplo, Programação Orientada a Objetos será abreviada como “POO”, assim como Orientação a Objetos como “OO”.

Inglês

Este livro tem a proposta de proporcionar material técnico no nosso idioma. Entretanto, na área de desenvolvimento de softwares, o idioma inglês é predominante – frequentemente ele é inevitável. Por este motivo, termos e nomes amplamente conhecidos em inglês terão uma explicação em português, mas também serão apresentados na sua forma original (em inglês). É importante te habituares, pois mesmo que pareça estranho alguém dizer “dropa a tabela”, é o modo como as pessoas falam no ambiente de trabalho, esse “dialeto”, por assim dizer, é importante sobretudo em um livro que prepara para o mundo do trabalho.

Códigos

Trechos de código são exibidos usando fonte de largura fixa. Em meio ao texto eles aparecem como “… usamos o método isFuture(Date):boolean para …”, semelhante a notação UML. As assinaturas de métodos são apresentadas como Classe.metodo(TipoArgumento):TipoRetorno.

Trechos mais extensos de código são apresentados em bloco, com título, linhas numeradas (quando for necessário) e link para obter o fonte. Por exemplo:

Exemplo de um código de exemplo
 1 /*
 2  * Os códigos aparecerão em fonte de largura fixa,
 3  * como neste bloco.
 4  */
 5 public class UmaClasse {
 6   private String umAtributo;
 7   public String umMetodo(int umParametro) {
 8     int umRetorno = umParametro * 2;
 9     return this.umAtributo + umRetorno;
10   }
11   // ...
12 }

https://git.io/vKyJK

Obs.: comentários seguido por reticências // ... significam código omitido, pois às vezes o código inteiro é muito longo para ser colocado na listagem, então a parte irrelevante para o exemplo é omitida. Se quiseres ver o código inteiro visite o link no fim da listagem. Minha sugestão é que copies e faças experiências com os códigos.

Dicas, avisos, observações e milongas

O livro está cheio de dicas, a maioria é relacionada com a honra da programação orientada a objetos, isto é, quando ele está sendo aplicado corretamente, ou não.

Sobre o Autor

Atualmente sou Professor no IFRS e atuo nos cursos de Analise e Desenvolvimento de Sistemas e Técnico em Informática para Internet.

Meus alunos (os engraçadinhos) me perguntam: “professor, tu trabalha ou só dá aula?” (sic)

É a vida de quem ensina. Eu trabalho em sala de aula hoje, mas ainda participo de projetos internos e dou uns pitacos em sistemas alheios (atividade conhecida como consultoria :).

Atrás disso, tenho uma história longa, passei por vários marcos na linha do tempo da evolução da computação. Para me conheceres melhor vou contar um pouco dessa história no devaneio a seguir:

“Eu nasci a dez mil anos atrás”. Comecei programando em Basic, num CP500 da Prológica. Sem Internet, se aprendia lendo revistas técnicas, transcrevendo códigos e fazendo experiências. Mais tarde comecei a desenvolver aplicações comerciais com dBase e então Clipper, ambos sobre a plataforma MS-DOS. Joguei Prince of Persia, Wolfenstein e o primeiro DOOM - tinha que usar o DOS/4GW para liberar a memória estendida. Já montei meu próprio computador – quando se selecionava IRQ por jumpers/switches. Vivenciei a ascensão da interface gráfica - não aguentava mais ver caracteres em fósforo verde. Instalei o Windows 95 - malditos 13 disquetes. Tive um Kit Multimídia da Creative – e uma placa de vídeo Voodoo. Migrei meus sistemas de Clipper para Visual Basic e mais tarde Delphi. Usei a Internet quando ainda só existia HTML com “meia dúzia” de tags – nada de CSS ou JS. Acompanhei a ascensão da Internet e da Web. Presenciei o início do Linux, sua evolução e importância para a consolidação dos sistemas on-line – junto com Perl, Apache, MySQL, PHP, etc. Já instalei o Conectiva Linux, compilei o Kernel e aprendi a usar uma linha de comando de verdade. Comecei a programar em Java a partir da versão 1.3 – ainda sem enums, generics, autoboxing etc – e foi meu primero contato com Orientação a Objetos – velhos hábitos procedimentais são difíceis de perder. Observei a Googlificação – mas usei o Cadê e o AltaVista. Acompanhei o crescimento do Comércio Eletrônico - e também o estouro da bolha da Internet.

Hoje, ainda tenho dúvidas se sou um programador que ensina ou um professor que programa.

Conheci tecnologias e empresas, mas o que levo comigo são as pessoas, colegas, amigos e alunos, foi e é um privilégio trabalhar com tanta gente agradável e talentosa.

Capítulo 0 – Programação Modular

Pequeno…

O mundo é muito grande, mãe. (jovem Clark Kent)

Então faça-o ficar pequeno.

– Martha Kent

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.

Este capítulo traz uma pequena introdução à modularização para reaproveitamento de código, começando com um abordagem Procedimental (separando as funcionalidades em funções/procedimentos) e então comparando (ainda sem muitos detalhes) com a abordagem Orientada a Objetos (separando funcionalidades em objetos e métodos).

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-off2. 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).

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:

Procedimento para somar dois números em Pascal
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 sintaxes3 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:

Calculando um MMC sem um procedimento
 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 }

https://git.io/vKyfh

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:

Calculando dois MMC’s sem um procedimento
 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 }

https://git.io/vKyJq

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:

Um procedimento para calcular o MMC
 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 }

https://git.io/vKyJZ

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 saidas, 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:

Um procedimento com retorno
 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 }

https://git.io/vKyJW

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:

Testando o procedimento com constantes literais e assertivas
 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 }

https://git.io/vKyJ4

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 corrigí-las, até todos os testes passarem. Para minha felicidade (ou não, lembre :P) todos os testes passaram:

Testes passando
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
    // menor inteiro positivo que é múltiplo
    // simultaneamente de a e de b
    System.out.println(mmc(5, -6) == 30);
    System.out.println(mmc(-5, 6) == 30);
    System.out.println(mmc(-5, -6) == 30);
    // se a = 0 ou b = 0, então mmc(a, b)
    // é zero por definição
    System.out.println(mmc(0, 10) == 0);
    System.out.println(mmc(0, -10) == 0);
    System.out.println(mmc(10, 0) == 0);
    System.out.println(mmc(-10, 0) == 0);
    System.out.println(mmc(0, 0) == 0);

https://git.io/vKyJu

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!).

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
public class Proc7 {

  static int dobro(int n) {
    return n + n; // ou n * 2
  }

  public static void main(String[] args) {
    System.out.println(dobro(0) == 0);
    System.out.println(dobro(5) == 10);
    System.out.println(dobro(90) == 180);
  }
}

https://git.io/vKyJ2

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
class Numero {

  int n;

  Numero(int n) {
    this.n = n;
  }

  int dobro() {
    return this.n + this.n; // ou this.n * 2
  }

  public static void main(String[] args) {
    Numero n1 = new Numero(5);
    System.out.println(n1.dobro() == 10);
    System.out.println(new Numero(90).dobro() == 180);
    Numero zero = new Numero(0);
    System.out.println(zero.dobro() == 0);
  }
}

https://git.io/vKyJa

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.

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:

Procedimentos que “chamam” outros procedimentos
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);
  }
}

https://git.io/vK9eB

Resultado da execução
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:

Resultado da execução
double graus = 75.0;
System.out.println(seno(radianos(graus)));

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:

Série de Maclaurin
Série de Maclaurin

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.

Combinando procedimentos
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)
  }
}

https://git.io/vK9e0

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:

Trabalhando com vetores básicos
 1 // declarando um vetor de 6 inteiros
 2 int[] copas = new int[6];
 3 copas[0] = 1994;
 4 copas[1] = 1998;
 5 copas[2] = 2002;
 6 // copas[3] = 2006;
 7 copas[4] = 2010;
 8 copas[5] = 2014;
 9 // comprimento (length) do vetor
10 System.out.println(copas.length); // 6
11 // primeiro elemento
12 System.out.println(copas[0]); // 1994
13 // terceiro elemento
14 System.out.println(copas[2]); // 2002
15 // se não atribuído (ver linha 6 comentada) vale zero (valor default)
16 System.out.println(copas[3]); // 0
17 // último elemento sempre length - 1
18 System.out.println(copas[copas.length - 1]); // 2014
19 // a linha a seguir, se descomentada,
20 // causará uma exceção IndexOutOfBoundException
21 // System.out.println(copas[6]); // fora dos limites
22 // percorrendo e imprimindo os valores
23 for (int i = 0; i < copas.length; i++) {
24   System.out.println(copas[i]);
25 }

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. Três propriedades devem ficar bem claras:

  • vetores têm um comprimento,
  • vetores têm um índice,
  • o maior índice sempre é menor que o comprimento (na verdade é o comprimento - 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:

Dados desestruturadas, informação fragmentada
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);
  }
}

https://git.io/vK9eu

O exemplo anterior pode ser refatorado para usar vetores na representação das datas, como pode ser visto no código a seguir:

Dados estruturados, informação contida
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]);
  }
}

https://git.io/vK9e2

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:

Estruturas de dados passando por funções
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));
  }
}

https://git.io/vK9ew

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.

Testando estruturas e procedimento
 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 }

https://git.io/vK9er

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:

Esboço dos módulos Data e DataMain
// arquivo Data.java
public class Data {
  static int[] somaDias(int[] data, int dias) {
    int[] resultado = new int[3];
    // aqui está o problema!!!
    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};
    int[] dataFim    = {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);
    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 objetos5. 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 um dia. Considere os seguintes casos de teste:

Testes para o procedimento amanha
int[] dataInicio = { 1, 7, 2016};
int[] dataFim    = {31, 7, 2016};
// cumprindo primeiro o amanhã (soma um dia)
int[] dataResult = Data.amanha(dataInicio);
System.out.println(dataResult[0] == 2);
System.out.println(dataResult[1] == 7);
System.out.println(dataResult[2] == 2016);
dataResult = Data.amanha(dataFim);
System.out.println(dataResult[0] == 1);
System.out.println(dataResult[1] == 8);
System.out.println(dataResult[2] == 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:

Resolvendo o 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   }

https://git.io/vK9eo

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 exerício a seguir:

Representação de Texto (char e String)

Fechando este tópico, mais uma coisa a considerar: a representação de texto. 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:

Lendo strings como um vetor
// 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:

Procedimento 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   }

https://git.io/vK9eo

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:

Procedimento 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;
      }
    }
  }

https://git.io/vK9eo

O procedimento amanha2, embora declare retorno void (vazio), gera um retorno refletido na própria entrada. Considere os seguintes testes para entender:

Testando um procedimento que causa efeito colateral
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
class Texto {

  static void corta(String texto, int tamanho) {
    texto.substring(0, tamanho);
  }

  static String corta2(String texto, int tamanho) {
    String cortada = texto.substring(0, tamanho);
    return cortada;
  }

  public static void main(String[] args) {
    String hashtag = "#poocomhonra";
    // cortar a string a partir
    // do 4º caractere (exclusivo)
    Texto.corta(hashtag, 4);
    // o teste não passa (false)
    // a hashtag continua sendo #poocomhonra
    System.out.println(hashtag.equals("#poo"));
    // cortar e devolver em uma cópia
    String hashtagmod = Texto.corta2(hashtag, 4);
    System.out.println(hashtagmod.equals("#poo"));
    System.out.println(hashtag.equals("#poocomhonra"));
  }
}

https://git.io/

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:

Procedimentos para cortar strings
 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 }

https://git.io/

Esse procedimento traz alguns recursos importantes para o aprendizado, como manipular strings caractere a caractere para criar novas strings (ver linha 11).

“Procedimentos Orientados a Objetos”?!?! O que é isso rapaz?!?!

É 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:

Procedimentos misto com Objeto de dados para data
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);
  }
}

https://git.io/

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

POO com Honra!

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 honrar os conceitos do paradigma. No entanto, não quer dizer que, às vezes, não se possa usar outra abordagem, graças as linguagens multiparadigmas 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 em OO “honrado”:

A Data Orientada a Objetos com honra!
 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 }

https://git.io/

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). Se tem muito a discutir ainda, a rigor, a classe Data não está honrando vários outros conceitos essenciais de OO, como encapsulamento e validade do estado, por exemplo, que ficam para os capítulos a seguir.

Capítulo 1 – Introdução à Programação Orientada a Objetos

Sobre fazer o que é certo …

Ou você morre herói ou vive suficiente para ver si mesmo tornar-se o vilão.

– Harvey Dent

Este capítulo aborda os conceitos usados no restante do livro, a essência, os princípios primordiais da Programação Orientada a Objetos. Absorva bem todo os fundamentos presentes nesse capítulo, mais que isso, abrace-os. Exercite bastante, questione e critique.

Origem da POO

As primeiras bases da Programação Orientada a Objetos (POO) surgiram no início da década de 60, com noções de instâncias e objetos sendo discutidas no MIT e na Norwegian Computing Center em Oslo. A primeira linguagem de programação a introduzir o conceito de classes, objetos e herança foi a Simula 67, projetada por Kristen Nygaard e Ole-Johan Dahl. Simula, como o nome revela, era usada para modelar e resolver problemas através da simulação; A inception era que coisas ou fenômenos do mundo real poderiam ser objetos no sistema, como objetos de chão-de-fábrica, máquinas e materiais brutos, sistemas planetários e dinâmica de corpos celestes, etc.

Ainda na década de 60, a mais notável aplicação do conceito de objetos foi na apresentação de objetos visuais interativos em interfaces com o usuário6, como janelas, botões, pastas, documentos, etc (sim, já existia naquela época) e vemos essas marcas conceituais inclusive nos Sistemas Operacionais atuais7. O estudo desse tipo de interface resultou em softwares gráficos interativos como o SketchPad, escrito por Ivan Sutherland. Ainda não era a nível de linguagens de programação, mas o pensamento de que coisas, objetos, do mundo real poderiam ser introduzidas e representadas em sistemas também como objetos, só que virtuais, ganhava força e inspirava novas ideias.

Então, Alan Kay, com o intuito de desenvolver um sistema interativo gráfico e pensando que a linguagem Simula oportunizava a criação de aplicativos com mais facilidade, propos ao Xerox PARC o desenvolvimento de, segundo ele, um computador pessoal para crianças de todas as idades. No começo da década de 70, Alan Kay e sua equipe criam este computador, que foi chamado de The Dynabook. Ele nunca foi produzido comercialmente, mas deixou um legado que influencia a indústria de software até hoje: a computação pessoal e, principalmente, a linguagem de programação criada para programá-lo: Smalltalk.

Smalltalk foi uma linguagem revolucionária, inspirada em Simula 67 ela elevava o patamar da metáfora de Objetos em um sistema e solidificou o que se conhece hoje por Programação Orientada a Objetos. Em meio a diversas outras linguagens procedimentais, matemáticas, lógicas, etc, (caretas, por assim dizer), Smalltalk oxigenava as ideias e provocava o raciocínio do que era modelar e escrever programas por uma perspectiva diferente. Smalltalk foi pouco usada na prática em comparação com outras linguagens da época, mas acabou por influenciar jovens cientistas da computação e profissionais de tecnologia, mostrando que a POO era viável, que funcionava e era eficiente.

Então, na década de 80, no Bell Labs, Bjarne Stroustrup cria a linguagem C++, que foi a primeira linguagem de programação orientada a objetos a ser usada amplamente. Bjarne inspirou-se em Simula e criou C++ em cima da linguagem C8. Um fato curioso é que a linguagem C++ era originalmente chamada de C com Classes. C++ é um sucesso comercial, sistemas operacionais, firmwares, drivers, softwares de sistema, jogos, etc, até vírus, são escritos em C/C++ por ser uma linguagem muito eficiente. Além da utilidade prática, um dos maiores legados de C++ e Bjarne Stroustrup foi o de novamente demonstrar a viabilidade do paradigma de POO e inspirar o desenvolvimento de novas linguagens, como Java e C#, por exemplo.

No começo da década de 90, uma equipe da Sun Microsystems9 liderada por James Gosling desenvolvia uma linguagem, inspirada em Smalltalk e C++, porém mais “versátil”, o nome dela era Oak (Carvalho) - isso mesmo, e a equipe era conhecida como “Green Team” e a linguagem antes fora chamada de Greentalk. Antes de se chamar Java, Oak era projetada para ser usada em dispositivos embarcados, mirando especialmente em set-top boxes (algo parecido com os equipamentos da Sky e Net com um software interativo). Depois de tantos nomes elencados, o nome Java foi escolhido em homenagem ao primeiro café produzido na ilha de Java na Indonésia (o porquê de programador gostar tanto de café é uma outra história). A Java (mas que chamamos “O Java”) foi lançada em 1995 e viria a ser uma das linguagens orientadas a objetos mais populares e influentes da computação moderna, o que em grande parte se deu pela simultaneidade do crescimento da Internet e das aplicações Web. Hoje ela é usada especialmente em aplicações corporativas (Java EE) e para Smartphones (Android).

Classes e Objetos (Tipos e Instâncias)

A POO permite descrever com código os objetos do mundo real, com um número restrito de detalhamento - atividade conhecida como abstração. Existem diversas formas de realizar essa abstração, mas geralmente começa com uma observação do objeto em questão, suas características e funcionalidades para então escrever uma definição.

Se tu nunca programaste com base em objetos isso pode parecer bastante confuso. Geralmente é mais simples começar a projetar objetos pensando apenas em suas características.

Considere um livro e suas características relevantes; a definição de quais dados de um livro seriam relevantes é algo bem subjetivo e depende do sistema que se está desenvolvendo, então começaremos com um estudo de caso bem simples: considere um livro com título, autor e número de páginas; após essa definição o próximo exercício é pensar em alguns exemplos, pode ser Livro título: Neuromancer, autor: William Gibson, páginas: 312 e Livro título: Fortaleza Digital, autor: Dan Brown, páginas: 197. Com esse estudo de caso é possível fazer a analogia entre classes, atributos e objetos:

  • Classe: é a definição do tipo10 do objeto, neste estudo de caso é o Livro;
  • Atributo: é a definição de cada característica esperada em objetos de certo tipo/classe, neste estudo de caso são os atributos título, autor e páginas;
  • Objeto: é um exemplar (chamaremos de instância) de um certo tipo/classe e os valores de seus atributos (chamaremos de estado), neste estudo de caso são os livros Neuromancer de William Gibson com 321 páginas e Fortaleza Digital de Dan Brown com 197 páginas.

Revisando, Livro é a classe, titulo é um atributo, Neuromancer de Wiliam Gibson com 321 páginas é um objeto, uma instância da classe Livro com os seguintes valores de titulo, autor e páginas: Neuromancer, William Gibson e 321.

A definição (implementação) da classe Livro e seus atributos “no Java” está a seguir:

Implementando a classe Livro
1 class Livro {
2 
3   String titulo;
4   String autor;
5   int    paginas;
6 
7 }

https://git.io/

As classes (linha 1) são definidas com class Nome, por exemplo class Cliente, class Produto, etc. Os atributos (linha 3, 4 e 5) são definidos como Tipo nome, por exemplo String endereco, double valorFinal, int quantidadeEstoque. Note que a definição de classes implica em um novo Tipo, por exemplo em Livro meuLivro a variável meuLivro é do tipo Livro (além de ser da classe Livro).

Até aqui, implementamos apenas a definição de Livro, faltam as instâncias, em outras palavras, os Livros de fato, os objetos do tipo Livro, que no estudo de caso eram o Neuromancer e Fortaleza Digital. Para implementar o estudo de caso vamos criar uma classe principal Main que contém um método principal main a seguir:

Instanciando objetos da classe Livro
 1 class Main {
 2   public static void main(String[] args) {
 3 
 4     Livro liv1   = new Livro();
 5     liv1.titulo  = "Neuromancer";
 6     liv1.autor   = "William Gibson";
 7     liv1.paginas = 321;
 8 
 9     Livro liv2   = new Livro();
10     liv2.titulo  = "Fortaleza Digital";
11     liv2.autor   = "Dan Brown";
12     liv2.paginas = 197;
13 
14     System.out.println("Livro " + liv1.titulo
15                      + " de "   + liv1.autor
16                      + " com "  + liv1.paginas
17                      + " páginas");
18 
19     System.out.println("Livro " + liv2.titulo
20                      + " de "   + liv2.autor
21                      + " com "  + liv2.paginas
22                      + " páginas");
23   }
24 }

https://git.io/

Os livros são instanciados nas linhas 4 e 9. A palavra-chave, em Java, para construir um objeto é new com a sintaxe new NomeDaClasse() – não esqueça dos parênteses, ele determinam o construtor, que será visto adiante. Nenhum objeto existirá enquanto não for feito um new, lembre disso. Neste exemplo, temos dois livros (dois new’s, dois objetos, duas instâncias) armazenados nas variáveis liv1 e liv2. As variáveis oferecem o acesso aos atributos do objeto Livro, como titulo, autor e paginas, que são atribuídos nas linhas 5, 6, 7, 10, 11 e 12 e lidos nas linhas 14 a 22. Compilando e executando essa é a saída que temos:

Compilando e executando
javac Livro.java Main.java; java Main

Livro Neuromancer de William Gibson com 321 páginas
Livro Fortaleza Digital de Dan Brown com 197 páginas

Embora o exemplo seja bem simples ele já explora alguns conceitos básicos de OO:

  • o que é uma classe (ou tipo): Livro é uma classe que descreve um livro no sistema;
  • o que é um objeto e instâncias: a cada new em Livro um livro é instanciado;
  • o que é um atributo: titulo é um atributo dos objetos da classe Livro;

Construção

Retomando, nós definimos através das classes quais atributos queremos que os objetos, deste tipo, tenha. Bem, dependendo do tipo do atributo ele recebe um valor default, isto é, um valor (ou ausência) padrão, por omissão, em outras palavras, se instanciarmos um objeto os atributos terão um valor inicial.

Atributos de tipo primitivo (int, long, float, double, boolean, por exemplo) recebem o valor 0 (ou false quando é um boolean). Atributos de tipo complexo (Objeto) não recebem valor, o que é conhecido por null.

0 versus NULL (fonte: desconhecida)
0 versus NULL (fonte: desconhecida)

Para exemplificar vamos a um primeiro estudo de caso. Considere uma classe Coordenada com os atributos latitude:double e longitude:double. Note que, neste exemplo, uma Coordenada baseada nos valores default resultaria na posição 0, 0 que é válida, porém inútil11. Então as coordenadas teriam que ser inicializadas com valores úteis de latitudes e longitudes. Vamos começar com uma classe Coordenada sem construtor definido:

Classe Coordenada sem construtor definido
class Coordenada {

  double latitude;
  double longitude;

}

https://git.io/vHAUg

O código no exemplo é uma definição mínima da classe Coordenada, sem um construtor. Se não definirmos um construtor, ainda assim existirá um construtor padrão implícito, sem parâmetros, para podermos instanciar objetos dessa classe. Essa classe funciona como está, mas não é prática, para criar uma coordenada no Chuí, bem na fronteira (-33.692627, -53.455263), teríamos que fazer o seguinte:

Criando uma Coordenada sem construtor personalizado
Coordenada chui = new Coordenada(); // construtor padrão
chui.latitude = -33.692627;
chui.longitude= -53.455263;
System.out.println("Chui " + chui.latitude + ", " + chui.longitude);

Para tornar a criação de coordenadas mais prática vamos definir um construtor para a classe Coordenada. Isso fará com que, para criar uma coordenada, tenhamos que informar latitude e longitude já na construção. Segue a segunda versão da classe Coordenada:

Classe Coordenada com um construtor explícito
 1 class Coordenada {
 2 
 3   double latitude;
 4   double longitude;
 5 
 6   Coordenada(double latitude, double longitude) {
 7     this.latitude  = latitude
 8     this.longitude = longitude;
 9   }
10 
11 }

https://git.io/vHAUr

O código entre as linhas 6 e 9 determinam o construtor da classe Coordenada, que é usado para instanciar coordenadas. Importante, o construtor especifica uma obrigação, isto é, para criar uma coordenada somos obrigados a informar latitude e longitude, somos obrigados a inicializar o objeto, a construção vazia não é mais permitida. As atribuições nas linhas 7 e 8 fazem a passagem dos parâmetros latitude e longitude para os atributos latitude e longitude, a diferença está na palavra-chave this que precede um atributo e serve para acessar a instância estando dentro dela mesma (se te deu um mindblown agora não se preocupe, há muito o que ver do this). O resultado prático é o uso da coordenada assim:

Criando uma Coordenada com um construtor personalizado
Coordenada chui = new Coordenada(-33.692627, -53.455263);
System.out.println("Chui " + chui.latitude + ", " + chui.longitude);

A primeira linha demonstra o construtor sendo invocado com os dois argumentos (-33.692627, -53.455263), eles inicializam o objeto. Nesta segunda versão da classe Coordenada é impossível construir uma coordenada assim new Coordenada(), com o construtor vazio, logo, mesmo que queiramos uma 0, 0 teríamos que construir assim new Coordenada(0, 0). Para voltar a permitir a construção sem inicialização podemos adicionar um segundo construtor sem parâmetros (sim, as classes podem ter vários construtores, desde que eles tenham parâmetros diferentes). Segue a terceira versão:

Classe Coordenada com dois construtores
 1 class Coordenada {
 2 
 3   double latitude;
 4   double longitude;
 5 
 6   Coordenada(double latitude, double longitude) {
 7     this.latitude  = latitude
 8     this.longitude = longitude;
 9   }
10 
11   Coordenada() {
12     // ou this(0, 0), ou nada
13     this.latitude  = 0;
14     this.longitude = 0;
15   }
16 
17 }

https://git.io/vHAUD

Entre as linhas 11 e 15 está o construtor vazio. Note que nem seria necessário inicializar os atributos com 0, que já é o valor default. Ainda, como opção mais sofisticada, poderia passar a coordenada 0,0 para o construtor completo com this(0, 0).

Representação string dos objetos

Nos exemplos dos tópicos anteriores, as instâncias (os objetos) de livro e coordenada eram impressas assim:

Imprimindo os objetos
Livro livro = new Livro();
livro.titulo = "Fortaleza Digital";
livro.autor = "Dan Brown";
livro.paginas = 197;

System.out.println("Livro " + livro.titulo
       + " de " + livro.autor
       + " com " + livro.paginas
       + " páginas");

Coordenada chui = new Coordenada();
chui.latitude = -33.692627;
chui.longitude= -53.455263;
System.out.println("Chui " + chui.latitude + ", " + chui.longitude);

Imprimir um objeto é uma atividade comum, seja com uma formatação agradável ou até mesmo para observar o estado de um objeto e debugar (resolver falhas). As linguagens, geralmente, disponibilizam um método ou convenção para representar textualmente objetos (torná-los uma string). Na linguagem Java este método é chamado de toString declarado na forma (com a assinatura) @Override public String toString() { ... }. Por exemplo, considere a classe Livro com o método toString implementado:

Classe Livro com toString implementado
 1 class Livro {
 2 
 3   String titulo;
 4   String autor;
 5   int    paginas;
 6 
 7   @Override public String toString() {
 8     return "Livro " + this.titulo
 9            + " de " + this.autor
10            + " com " + this.paginas
11            + " páginas";
12   }
13 }

https://git.io/

O código entre as linhas 7 e 12 implementa a representação texto (string) do livro. A instrução System.out.println(v), sempre que v for objeto (não primitivo), executa o método toString tornando isso possível:

Utilizando a representação string de Livro
 1 class Main {
 2   public static void main(String[] args) {
 3     Livro livro = new Livro();
 4     livro.titulo = "Fortaleza Digital";
 5     livro.autor = "Dan Brown";
 6     livro.paginas = 197;
 7 
 8     System.out.println(livro);
 9   }
10 }

https://git.io/vHAUN

A instrução System.out.println(livro) na linha 8 executa como se fosse System.out.println(livro.toString());, não precisando compor a descrição do livro no main. A execução do Main fornece essa saída:

Verificando o toString
javac Livro.java Main.java; java Main

Livro Fortaleza Digital de Dan Brown com 197 páginas

Estado

Um dos conceitos mais importantes de OO a entender é a definição de Estado. No tópico anterior foi usado o exemplo de um livro com as informações do título, autor e número de páginas. Foi um bom estudo de caso para exemplificar atributos, mas não é suficiente para exemplificar estado. Não quer dizer que o livro não tenha estado, mas considerando o livro Neuromancer de William Gibson com 321 páginas qual a chance de mudança do título, autor ou número de páginas? Quer dizer, o livro tem estado, que são os valores do titulo, autor e paginas, mas ele não é propenso a mudar no tempo.

Para ampliar o entendimento de Estado precisaremos de um estudo de caso mais complexo, neste caso, um objeto que tenha certas características que mudam no tempo, então considere uma aparelho de TV. Claro, vamos simplificar (abstrair) bastante, então considere a definição de um aparelho de TV de certo modelo que suporta canais UHF e permite ajuste do volume, e só!. O modelo da TV não muda, mas outros atributos mudam: ela pode estar ligada ou não, pode estar em um canal de 14 a 69 (canais UHF) e um volume, digamos, de 0 a 100. A seguir o primeiro esboço da TV com a classe TV:

Primeira versão da classe TV
 1 class TV {
 2 
 3   String modelo;
 4   // a TV, por padrão, está desligada
 5   boolean ligada = false;
 6   // inicia no canal 14
 7   int canal = 14;
 8   // inicia com o volume 10
 9   int volume = 10;
10 
11 }

https://git.io/vHAUp

Esta implementação funciona, mas não considera as regras de validade da TV, por exemplo, o canal não pode ser menor que 14 ou maior que 69, então esse tipo de instrução não deveria ser possível:

Invalidando a TV
TV tv = new TV();
System.out.println(tv.modelo); // TV sem modelo definido (NULL)
tv.canal = 12; // atribuindo um canal inválido
System.out.println(tv.canal); // canal mínimo 14

Para programar essa TV honrando o paradigma orientado a objetos temos que fazer algumas alterações que consideram as regras de operação da TV:

  • Tornar a informação do modelo obrigatória: exigir o modelo na construção do objeto, isto é, definir um construtor;
  • Evitar que os atributos tenham valores inválidos: esse requisito é atingido por evitar o acesso direto aos atributos. Também implica em criar métodos para operar a TV e consultar o estado.
Segunda versão da classe TV
 1 class TV {
 2 
 3     private String  modelo;
 4     private boolean ligada = false;
 5     private int     canal  = 14;
 6     private int     volume = 10;
 7 
 8     TV(String modelo) {
 9       this.modelo = modelo;
10       this.ligada = false; // a TV, por padrão, está desligada
11       this.canal  = 14; // inicia no canal 14
12       this.volume = 10; // inicia com o volume 10
13     }
14 
15     String modelo() {
16       return this.modelo;
17     }
18 
19     boolean ligada() {
20       return this.ligada;
21     }
22 
23     int canal() {
24       return this.canal;
25     }
26 
27     int volume() {
28       return this.volume;
29     }
30 
31 }

https://git.io/vHAUj

Preste bastante atenção neste código e entenda o seguinte:

  • a palavra-chave private antes de cada atributo faz com que eles não sejam acessíveis fora da classe TV, isto é, uma instrução tv.canal = 12 não compila. Por outro lado, dentro de TV, um this.canal = 12 seria válido (mas não vamos fazer isso, claro).
  • o construtor TV(String modelo) define a obrigação de informar pelo menos o modelo da TV. Ademais, os variáveis de instância recebem seus valores iniciais esperados. O entendimento é: um new em TV seria como receber a TV com as configurações de fábrica.
  • os métodos permitem a consulta dos atributos (que são private e não podem ser lidos ou alterados). Note que eles não alteram os atributos, eles apenas devolvem o valor deles, isto é, eles não mudam o estado do objeto. Por este motivo são conhecidos como acessores ou métodos consulta.

A nossa segunda versão da TV na classe TV garante alguns requisitos básicos da especificação: uma TV tem um modelo, pode estar ligada ou não, em um certo canal e com certo volume. Para validar esse estado inicial segue alguns testes:

Testando o estado inicial da TV
 1 class Main {
 2   public static void main(String[] args) {
 3 
 4     TV tv = new TV("XingLing T800");
 5 
 6     System.out.println(tv.modelo()); // XingLing T800
 7     System.out.println(tv.modelo().equals("XingLing T800"));
 8 
 9     System.out.println(tv.ligada()); // false
10     System.out.println(tv.ligada() == false);
11 
12     System.out.println(tv.canal()); // 14
13     System.out.println(tv.canal() == 14);
14 
15     System.out.println(tv.volume()); // 10
16     System.out.println(tv.volume() == 10);
17 
18   }
19 }

https://git.io/vHATJ

Passando os testes iniciais agora vem a parte complexa: implementar as operações da TV, alterar seu estado e, ao mesmo tempo, controlar a complexidade. Primeiro vamos definir como operações aumentar e baixar o volume, subir e descer o canal e, claro, ligar e desligar a TV. É importante descrever assim pois essa operações tornar-se-ão métodos no mesmo estilo, por exemplo aumentarVolume() e descerCanal().

As operações devem respeitar as regras, por exemplo só deve ser possível subir o canal até o 69, então temos uma decisão de projeto quando a TV já estiver no canal 69 e a operação subirCanal() for invocada. Já deves ter imaginado uma solução até aqui, vou elencar duas: ou rejeita a operação (não faz nada) ou roda e vai para o primeiro canal (14).

Uma das melhores maneiras de especificar e ao mesmo tempo validar é escrever testes que mostrem a evolução do estado no tempo. A seguir está o Main3 que testa a classe TV:

Testando a evolução do estado da TV
18     // com a TV desligada as operações
19     // não afetam o estado
20     tv.subirCanal();
21     System.out.println(tv.canal()); // 14
22     System.out.println(tv.canal() == 14);
23     tv.descerCanal();
24     System.out.println(tv.canal()); // 14
25     System.out.println(tv.canal() == 14);
26     tv.aumentarVolume();
27     System.out.println(tv.volume()); // 10
28     System.out.println(tv.volume() == 10);
29     tv.baixarVolume();
30     System.out.println(tv.volume()); // 10
31     System.out.println(tv.volume() == 10);
32 
33     // TV ligada
34     tv.ligarDesligar(); // liga se estiver desligada, desliga se estiver ligada
35     System.out.println(tv.ligada()); // true
36     System.out.println(tv.ligada() == true);
37 
38     tv.subirCanal();
39     System.out.println(tv.canal()); // 15
40     System.out.println(tv.canal() == 15);
41 
42     tv.aumentarVolume();
43     System.out.println(tv.volume()); // 11
44     System.out.println(tv.volume() == 11);
45     // aumentar 100 vezes o volume
46     for (int i = 0; i < 100; i++) tv.aumentarVolume();
47     // não deve passar de 100
48     System.out.println(tv.volume()); // 100
49     System.out.println(tv.volume() == 100);

https://git.io/vHATT

Existem mais testes, este é apenas um excerto. Os testes se dividem em aqueles que testam o caminho “feliz”, isto é, sempre pensando que as operações serão executadas na sequência certa sem nenhum caso excepcional, e naqueles que testam o caminho excepcional (“triste”), isto é, operações inválidas e/ou fora da sequência esperada.

Sinceramente, acho que estou queimando um baita exercício apresentando a resposta desse estudo de caso, mas está a seguir:

Excerto da terceira e última versão da classe TV
31     void ligarDesligar() {
32       if (this.ligada == true) { // ou só `if (this.ligada)`
33         this.ligada = false;
34       } else {
35         this.ligada = true;
36       }
37       // ou na forma reduzida:
38       // ligada = !ligada;
39     }
40 
41     void aumentarVolume() {
42       if (this.ligada && this.volume < 100) {
43         this.volume = this.volume + 1;
44       }
45       // ou na forma reduzida;
46       // if (ligada && volume < 100) volume++;
47     }
48 
49     void baixarVolume() {
50       if (this.ligada && this.volume > 0) {
51         this.volume = this.volume - 1;
52       }
53     }
54 
55     void subirCanal() {
56       if (ligada) {
57         if (this.canal < 69) {
58           this.canal = this.canal + 1;
59         } else {
60           this.canal = 14;
61         }
62       }
63       // ou na forma reduzida:
64       // if (ligada) canal = canal < 69 ? canal + 1 : 14;
65     }

https://git.io/vHATt

No código anterior estão apenas as operações da classe TV. Os códigos estão bem didáticos, como entre as linhas 42 a 44, no método aumentarVolume(), então fiz questão de deixar um código extra comentado, como na linha 46, que mostra uma versão compacta da mesma operação.

Validade do Estado

Uma discussão antiga é se um objeto deve ter sempre um estado válido(?). Esse tópico aparece, principalmente, pelas necessidades práticas durante a implementação, por exemplo, nem sempre estão a disposição todos os dados de inicialização do objeto, o que faz o desenvolvedor pensar: “posso instanciar um objeto vazio e popular os dados depois”. No fim, a pergunta é: inicializar o objeto e aguardar esses dados ou inicializar o objeto apenas após ter todos os dados?

Como a OO busca descrever as regras que regem um objeto particular e sua interação com outros objetos, é correto dizer que deixar um objeto num estado inválido não honra os princípios da POO. Preocupar-se em manter os objetos sempre válidos é bem trabalhoso, mas pode ser um diferencial no futuro, reduzindo a quantidade de bugs causados pela má gestão do estado do objeto.

Para exemplificar, considere um objeto Canção onde algumas informações são essenciais, como título e artista, enquanto outras são adicionais, como álbum e tempo. Note que este é um caso particular, onde o objeto não muda no tempo, isto é, uma canção não mudará de título, artista, álbum ou tempo de reprodução, mas os dados adicionais podem ser informados depois. Considere então uma Canção válida aquela que possui um título e artista informados e um tempo maior que zero, mas apenas se for informado.

Vamos começar pela demonstração de um objeto que permite o estado inválido, criando uma classe que permita ser instanciada sem informar os atributos obrigatórios, mas que disponibilize um método que verifique a validade como o método Cancao#ehValida():boolean na linha 6 do código a seguir:

Objeto que permite estado inválido
 1 class Cancao {
 2 
 3   String  titulo, artista, album;
 4   Integer duracao;
 5 
 6   boolean ehValida() {
 7     if (titulo == null || titulo.trim().length() == 0) return false;
 8     if (artista == null || artista.trim().length() == 0) return false;
 9     if (duracao != null && duracao <= 0) return false;
10     return true;
11   }
12 }

Perceba que a classe acima permite instâncias inválidas, necessitando verificar a validade do estado:

Verificando a validade de um objeto
 1 class Main {
 2   public static void main(String[] args) {
 3     Cancao c = new Cancao();
 4     System.out.println(c.ehValida()); // false
 5     c.titulo = "Vertigo";
 6     System.out.println(c.ehValida()); // false
 7     c.artista = "U2";
 8     System.out.println(c.ehValida()); // true
 9     c.duracao = 0;
10     System.out.println(c.ehValida()); // false
11     c.duracao = 193;
12     System.out.println(c.ehValida()); // true
13   }
14 }

Como pode ser visto na classe Main anteriormente, o objeto Canção é inicializado sem título, artista, etc, sendo completo aos poucos, isto é, o objeto “nasce” inválido e precisa de mais interações durante o programa até ser consistente. Agora vamos para uma demonstração de como impedir o estado inválido:

Objeto que NUNCA é inválido
 1 class Cancao {
 2   // final significa constante, não pode ser reatribuído
 3   private final String titulo, artista;
 4   private String  album;
 5   private Integer duracao;
 6   // construtor com os atributos obrigatórios
 7   Cancao(String t, String a) {
 8     // se inválido não é instanciado
 9     if (t == null || t.trim().length() == 0) throw new IllegalArgumentException();
10     if (a == null || a.trim().length() == 0) throw new IllegalArgumentException();
11     // se chegou aqui tudo bem :)
12     this.titulo  = t;
13     this.artista = a;
14   }
15   // mutators: métodos para alterar o álbum e duração
16   void album(String a) { this.album = a; }
17   void duracao(Integer d) {
18     if (d != null && d <= 0) throw new IllegalArgumentException();
19     this.duracao = d;
20   }
21   // accessors: métodos para consultar título, artista, album e duração
22   String  titulo()  { return this.titulo;  }
23   String  artista() { return this.artista; }
24   String  album()   { return this.album;   }
25   Integer duracao() { return this.duracao; }
26 }

Esta classe introduz vários conceitos de OO, mas o principal é que ela garante a regra: não haverá Canções inválidas!. A palavra-chave final na linha 3 garante que título e artista serão atribuídos apenas uma vez, no construtor da linha 7. O construtor garante que os parâmetros t e a para título e artista não sejam nulos ou vazios, se forem será lançada um exceção, interrompendo a criação do objeto. Para garantir o estado, os atributos album e duracao não podem ser alterados livremente, por isso todos são private, que causa uma invisibilidade desses atributos fora da classe Cancao, isto é, apenas o próprio objeto terá acesso a estes atributos, garantindo um objeto encapsulado. Como os atributos são invisíveis fora do objeto, são necessários métodos que permitam a leitura e alteração dos atributos, eles são conhecidos como accessors e mutators. Os accessors estão a partir da linha 22 e os mutators entre as linhas 16 e 20. Note o método que permite alterar a duração na linha 17, ele valida o parâmetro. Com essa estrutura uma Canção nunca será inválida, pois todos os parâmetros são verificados, e não precisa do método Cancao#ehValida():boolean. A seguir a classe em uso:

Mantendo um objeto válido
 1 class Main {
 2   public static void main(String[] args) {
 3     // Cancao c = new Cancao(); // proibido
 4     Cancao c = new Cancao("Vertigo", "U2");
 5     System.out.println(c.titulo().equals("Vertigo"));
 6     System.out.println(c.artista().equals("U2"));
 7     c.duracao(0); // proibido
 8     c.duracao(193);
 9     System.out.println(c.duracao().equals(193));
10   }
11 }

Sobre o exemplo anterior, se a linha 3 ou 7 fossem descomentadas, o programa quebraria, como pode ser visto no trecho a seguir se a linha 7 fosse descomentada:

Quebrando a Canção
> javac Main2.java Cancao2.java && java Main
true
true
Exception in thread "main" java.lang.IllegalArgumentException
	at Cancao.duracao(Cancao2.java:18)
	at Main.main(Main2.java:7)

Usando essa implementação nunca teríamos uma Canção que estivesse sem título, artista ou que tenha uma duração zero ou negativa, isto é, a classe Cancao garante que o estado do objeto seja sempre válido.

Complexidade

Complexidade pode ser definida de muitas maneiras, mas estamos falando da complexidade de sistemas (sistemas não no sentido de software, mas no geral, como sistemas biológicos, por exemplo). Ela é a ciência que estuda como os relacionamento entre pequenas partes fazem emergir comportamentos de um sistema, dependendo apenas de regras pontuais entre as partes.

Existe muito de sistemas complexos para estudar, a área é abrangente, estudando desde biologia, física, química, social e humano, etc. Aqui, o que nos importa é a complexidade dos programas ou complexidade do software.

Aplicativos, websites, utilitários, etc, todo tipo de programa é formado por pequenos pedaços de código cada qual com sua lógica conectados de modo a executar uma funcionalidade. A complexidade na programação estuda como cada peça individual pode interagir com outras pequenas peças de lógica e afetar o comportamento do todo.

Os Engenheiros de Software, entre outras missões, tentam manter a complexidade sob controle, pois o risco de introduzir defeitos nas alterações aumenta segundo a complexidade. Alguns software possuem tanta complexidade que pequenas modificações de um lado quebram outro, que pensava-se ter relação nenhuma com a seção alterada. Frequentemente esses softwares são escritos novamente do zero.

Na prática, observamos o aumento de complexidade cada vez que uma classe se relaciona com outra, que um método invoca outro método, que invoca outro método, quando um método modifica várias varíáveis de estado condicionalmente.

Para exemplificar complexidade sem usar muitos objetos/classes ainda, vamos voltar ao exemplo da TV, mas adicionando duas funcionalidades: irParaCanal e voltar. Esse exemplo é bem útil para observar o aumento de complexidade. Ao usar irParaCanal temos de manter o dado do canal que estava, para poder voltar, quando sobe ou desce canal também, contudo, como fica quando a TV foi desligada? Quando voltamos mais de uma vez? Quando voltamos sem ter trocado de canal? Esses casos especiais demandam instruções condicionais, que aumentam a complexidade.

Primeiro vamos especificar irParaCanal e voltar:

  • irParaCanal: vai para um canal determinado irParaCanal(int):void, se for válido o canal é alterado e se for inválido permanece no canal que está.
  • voltar: volta para o canal anterior, usado após um irParaCanal, subirCanal ou descerCanal ou voltar. Esse estado não é mantido com a TV desligada, então quando a TV é ligada não há canal para voltar e o uso do método não deve alterar o canal.

Agora vamos implementar! Note que precisaremos de um novo atributo canalAnterior, mas atente que nem toda variável de estado é acessível, algumas informações só têm utilidade para o próprio objeto, internamente. Quero dizer, não haverá um método para consultar qual o canal anterior, ele apenas será armazenado e atualizado quando o canal for alterado para poder voltar. A seguir o atributo canalAnterior e sua pequena lógica:

Atributo canalAnterior na classe TV
1 class TV {
2 
3     private String  modelo;
4     private boolean ligada = false;
5     private int     canal  = 14;
6     private int     volume = 10;
7     // 0 (zero) significa que não há anterior
8     private int     canalAnterior = 0;

https://git.io/vHATm

Como visto no comentário da linha 7, um canalAnterior == 0 significa que não há canal para voltar. Preste atenção nas implicações, sempre que subir ou descer canal o canalAnterior deverá ser atualizado e, mais importante, ao desligar a TV ele deve ser zerado. Veja as novas implementações de subir e descer canal e o novo método irParaCanal:

Implemenações de “subir”, “descer” e “ir para canal”
57     void subirCanal() {
58       if (ligada) {
59         this.canalAnterior = this.canal;
60         this.canal = this.canal < 69 ? this.canal + 1 : 14;
61       }
62     }
63 
64     void descerCanal() {
65       if (ligada) {
66         this.canalAnterior = this.canal;
67         this.canal = this.canal > 14 ? this.canal - 1 : 69;
68       }
69     }
70 
71     void irParaCanal(int novoCanal) {
72       if (novoCanal >= 14 && novoCanal <= 69) { // é válido?
73         this.canalAnterior = this.canal;
74         this.canal = novoCanal;
75       }
76     }

https://git.io/vHATm

Note que neste excerto não mostra o método ligarDesligar que deve declarar canalAnterior = 0 sempre que a TV for desligada. O importante é notares a quantidade de menções ao atributo canalAnterior, isto é um aumento de complexidade. Alterações envolvendo canalAnterior possivelmente implicam alterações nesses métodos. Bem, garantindo que canalAlterior tenha o valor devido, isto é, zero se não houver anterior e o exato canal antes das mudanças, o método voltar deve apenas verificar se há anterior, ir e permutar o canal atual pelo anterior. A seguir uma implementação rápida:

Implementação de “voltar”
79     void voltar() {
80       if (this.canalAnterior > 0) {
81         int permuta = this.canal;
82         this.canal = this.canalAnterior;
83         this.canalAnterior = permuta;
84       }
85     }
86 }

https://git.io/vHATm

A instrução mais importante está na linha 81, uma variável local, temporária, para permitir a permuta entre canal e canalAnterior. Na prática, existe outro modo, talvez até mais elegante de fazer isso, reaproveitando uma lógica existente, basta olhar o voltar pelo prisma de que significa ir para um canal anterior, então reaproveita-se o irParaCanal:

Reaproveitando código/lógica de outro método
79     void voltar() {
80       if (this.canalAnterior > 0) {
81         this.irParaCanal(this.canalAnterior);
82       }
83     }
84 }

https://git.io/vHATZ

Claro, sempre é importante executar todos os testes, anteriores e novos. Para exemplificar alguns Casos de Teste foram adicionados, embora ainda faltem muitos:

Casos de Teste para as novas funcionalidades
65     // não deve passar de 69
66     System.out.println(tv.canal()); // 60
67     System.out.println(tv.canal() == 60);
68 
69     tv.descerCanal();
70     System.out.println(tv.canal() == 59);
71 
72     tv.voltar();
73     System.out.println(tv.canal() == 60);
74 
75     tv.irParaCanal(17);
76     System.out.println(tv.canal() == 17);
77 
78     tv.voltar();
79     System.out.println(tv.canal() == 60);
80 
81     tv.voltar();
82     System.out.println(tv.canal() == 17);
83 
84     tv.ligarDesligar();
85 
86     tv.voltar();
87     System.out.println(tv.canal() == 17);
88   }
89 }

https://git.io/vHATC

Alguns entendimentos devem ficar claros ao final desde tópico:

  • o aumento de lógica e consequente espalhamento aumenta a complexidade especialmente quando as regras se entrelaçam;
  • sempre que possível reaproveita-se código, frequentemente essa é a diferença entre um desenvolvedor de software amador e um profissional;
  • os testes são essenciais para garantir que novas alterações não quebrem o que já funciona, quanto mais complexo torna-se o software mais importante os testes;

Referências

Talvez um dos conceitos mais difíceis de compreender em OO seja a noção de referências. Sempre que um objeto é construído (uma instrução new é invocada) um espaço em memória é reservado para guardar as informações da instância e o “endereço” deste espaço é retornado. As variáveis guardam, na verdade, o endereço do objeto e não o objeto em si. Para entender o problema há um exemplo bem simples, considere o seguinte trecho de código:

Variáveis com referências à TV
 1 class Main {
 2   public static void main(String[] args) {
 3 
 4     TV tv1 = new TV("AA");
 5     TV tv2 = tv1;
 6     
 7 
 8     tv1.ligarDesligar();
 9     System.out.println(tv1.volume());
10     tv1.aumentarVolume();
11     System.out.println(tv1.volume());
12     System.out.println(tv2.volume());
13 
14     tv2.aumentarVolume();
15     tv2.aumentarVolume();
16     tv2.aumentarVolume();
17     System.out.println(tv1.volume());
18     System.out.println(tv2.volume());
19   }
20 }

https://git.io/vHATB

Com base no código anterior, a pergunta a se fazer é: quantas TVs existem? A resposta é 2. Outra pergunta: quantas variáveis são usadas para acessar as TVs? 3. O que tens que compreender é que as variáveis tv1 e tv2, nas linhas 4 e 5, armazenam a mesma TV. É por isso que as instruções nas linhas 8 e 10 afetam a leitura de volume nas linhas 11 e 12 (que imprimem 11) e 17 e 18 (que imprimem 14). Uma analogia (emprestada do livro Use a Cabeça Java) é que as variáveis agem como se fossem “controles remotos” para a TV, neste sentido, haveriam dois controles tv1 e tv2 e uma TV, como na imagem a seguir.

Analogia variáveis, referência, objeto
Analogia variáveis, referência, objeto

Compreendendo a ideia de referência, o próximo passo é entender as implicações (e perigos) disto. Quando um objeto é passado como argumento para um método, o que vai é sua referência. Assim, se qualquer um dos métodos alterar o estado do objeto, essa alteração será visível em todos os lugares em que objeto foi passado. Considere o seguinte exemplo simplificado:

Passando as referências para os métodos
 1 class Main {
 2   public static void main(String[] args) {
 3     Usuario u = new Usuario();
 4     u.nome = "fin";
 5     u.senha = "1234";
 6     System.out.println(u.senha);
 7     geraSenha(u);
 8     System.out.println(u.senha);
 9     geraSenha(u);
10     System.out.println(u.senha);
11   }
12 
13   static void geraSenha(Usuario o) {
14     String s = "";
15     while (s.length() < 10) {
16       s += (char) ((int) (Math.random() * 26) + 97);
17     }
18     o.senha = s;
19   }
20 }

https://git.io/vHATg

No código anterior, existem duas variáveis que armazenam um Usuário: u, na linha 3, e o na linha 13. Entretanto, o que deve ser observado é que existe apenas um usuário (basta contar a quantidade de news invocados). O que acontece, na prática, é que o Usuário é instanciado na linha 3 e passado como argumento para o método geraSenha na linha 7, neste ponto a instância de Usuario chega ao método com o nome de o na linha 13, ou seja, o método geraSenha lida com o mesmo Usuário criado na linha 3.

Comportamento

No pensamento Orientado a Objetos os objetos apresentam:

  • Estado: que é o valor instantâneo de todas as características de um objeto (ver Estado), como um objeto de certo tipo está;
  • Comportamento: que é o conjunto de funcionalidades de um objeto, o que um objeto de certo tipo pode fazer;

Enquanto o Estado de um objeto é definido pelos atributos, declarados na classe, na prática, o Comportamento dos objetos é definido como métodos, que determinam tudo o que um objeto pode (ou não) fazer.

Como exemplo, considerar a analogia com um Carro: cor, placa, velocidade, nível de combustível, temperatura, seriam atributos e definem o Estado, enquanto acelerar, freiar, ligar farol, engatar marcha, etc, seriam funcionalidades ou ações possíveis que definem o Comportamento. Neste exemplo, dá para notar que o Comportamento é revelado através de verbos, como acelerar, pagar, imprimir, excluir, copiar, etc.

Os métodos, usados para definir o Comportamento, são funcionalidades que precisam ler ou alterar o Estado (os atributos) para realizar alguma coisa. Os métodos também são conhecidos como operações e podem ser classificados como aqueles que mudam o Estado e são chamados de comandos e os que apenas leem e retorna o Estado, chamados de consultas.

Retomando o exemplo da TV, como visto em Estado, enquanto volume e canal representam o Estado, os métodos volume() e aumentarVolume() representam o Comportamento sendo, respectivamente, uma consulta e um comando: o primeiro apresenta o volume atual enquanto o segundo acrescenta um ponto ao volume.

Para ilustrar Comportamento e sua relação com Estado considere o simples exemplo: a necessidade de contar até um limite. Para isso, vamos implementar um Contador, que tem um valor e pode avançar até um limite (inclusivo). Uma primeira implementação está a seguir:

Estado e Comportamento, juntos!
 1 class Contador {
 2 
 3   int valor;
 4   int limite;
 5 
 6   Contador(int limite) {
 7     this.limite = limite;
 8     this.valor  = 0;
 9   }
10 
11   int valor() {
12     return this.valor;
13   }
14 
15   void avancar() {
16     if (this.valor < this.limite) {
17       valor = valor + 1;
18     }
19   }
20 }

https://git.io/vHATo

A ideia geral do exemplo é bem simples: instanciar um contador, como em new Contador(10) e usar o método avancar() para ir até 10 saindo do 0. Contudo, essa implementação é simples demais, permitindo burlar a lógica básica: de contar sempre para frente até um limite. Nada impede que se faça um cont.valor = 14 ou cont.limite = 1000. Tudo começa simples até ficar complexo. Temos de garantir que o valor só altere para frente, de um em um, e só até o limite, que deve ser fixo. Duas medidas: tornar o valor privado e o limite constante. Essas duas medidas são extremamente importantes para evitar a violação do encapsulamento que é, em poucas palavras, o contorno das regras comportamentais e de estado para o objeto. Segue então a implementação melhor:

Estado e Comportamento, encapsulados
 1 class Contador {
 2 
 3   private int valor;
 4   private final int limite;
 5 
 6   Contador(int limite) {
 7     this.limite = limite;
 8     this.valor  = 0;
 9   }
10 
11   int valor() {
12     return this.valor;
13   }
14 
15   int limite() {
16     return this.limite;
17   }
18 
19   void avancar() {
20     if (this.valor < this.limite) {
21       valor = valor + 1;
22     }
23   }
24 }

https://git.io/vHATP

Esse Contador já é funcional, embora possa ser melhorado. Ele pode ser visto em ação a seguir:

Experimentando o Contador
 1 class Main {
 2   public static void main(String[] args) {
 3     Contador c = new Contador(10);
 4     System.out.println("Tabuada do 7");
 5     while (c.valor() < c.limite()) {
 6       System.out.println("7 x " + c.valor() + " = " + (7 * c.valor()));
 7       c.avancar();
 8     }
 9     System.out.println(c.valor());
10   }
11 }

https://git.io/vHATD

O contador não dá exatamente o resultado esperado, fazendo a tabuada de 7 x 0 a 7 x 9 (que poderia ser resolvido com um do/while em vez while). É possível melhorar este contador sem recorrer ao +1 ou -1 clássicos nos problemas de contagem. Na prática, um método pode ser ao mesmo tempo um comando e uma consulta. Quero dizer, podemos fazer com que o método avancar além de incrementar o valor possa informar se ainda é possível avançar. A seguir a terceira versão do Contador:

Melhor Contador de todos os tempos!
19   boolean avancar() {
20     if (this.valor < this.limite) {
21       valor = valor + 1;
22       return true;
23     }
24     return false;
25   }

https://git.io/vHATH

Essa alteração no método avancar permite usá-lo no while como a seguir:

Experimentando o Melhor Contador
 1 class Main {
 2   public static void main(String[] args) {
 3     Contador c = new Contador(10);
 4     System.out.println("Tabuada do 7");
 5     while (c.avancar()) {
 6       System.out.println("7 x " + c.valor() + " = " + (7 * c.valor()));
 7     }
 8     System.out.println(c.valor());
 9   }
10 }

https://git.io/vHAkm

Enfim, foi um exemplo pequeno, mas ele ainda pode ser esticado. Conside que queiramos reciclar o contador, ou seja, zerá-lo. Podemos implementar um novo Comportamento chamado zerar ou resetar, na forma de método que atribui zero à variável valor.

Novo comportamento de Contador
27   void zerar() {
28     this.valor = 0;
29   }

https://git.io/vHATH

Perceba que os métodos de Contador sempre leem ou alteram os atributos para realizar suas operações. Essa observação é muito importante para compreender um conceito de OO chamado Coesão, que é a medida de consistência do Comportamento com o Estado, em outras palavras, o quanto os métodos fazem uso dos atributos do objeto a que pertencem.

Imutabilidade

Por padrão, todas as classes que projetamos e os objetos dela instanciados são mutáveis. Isto é, eles podem ter seu Estado alterado. Foi o caso em todos os exemplos anteriores, da TV, Livro, Coordenada, Contador, etc. Contudo, nem sempre é desejável que os objetos possam mudar. Como visto em Referências, os objetos podem ter seu Estado alterado através de qualquer variável que o referencie em qualquer lugar do código. Quando se quer ter mais controle sobre o Estado nós projetamos os objetos para serem imutáveis.

Objetos imutáveis, na prática, possuem todos os atributos constantes, implementados como final em suas classes. Para comparação, a seguir está a classe Coordenada original que gera objetos mutáveis:

Coordenada mutável
1 class Coordenada {
2 
3   double latitude;
4   double longitude;

Agora, a seguir, está a mesma classe projetada para gerar objetos imutáveis:

Coordenada imutável
1 class Coordenada {
2 
3   final double latitude;
4   final double longitude;

A outra diferença de objetos imutáveis é como o Comportamento é implementado. Originalmente, os métodos leem e alteram o Estado, contudo, nos objetos imutáveis, os métodos não podem alterar o Estado. Considere que precisemos de um método que “mova” a Coordenada para o norte e para o sul. Em uma versão mutável, convencional, os métodos seriam implementados como norte(double):void e sul(double):void com a seguinte lógica:

Métodos que mutam o objeto
11   void norte(double v) {
12     this.latitude = this.latitude + v;
13   }
14 
15   void sul(double v) {
16     this.latitude = this.latitude - v;
17   }

A operação dessa coordenada seria assim:

Usando uma Coordenada mutável
Coordenada c = new Coordenada(-33.692627, -53.455263);
c.norte(3.0);
System.out.println(c.latitude); // -30.692627, -53.455263

Considere que essa coordenada poderia ser alterada por qualquer referência compartilhada. Se a intenção é proibir isso, devemos projetá-la para ser imutável com dois pequenos passos: tornar os atributos constantes (declarar como final) e fazer com que métodos comando devolvam uma nova instância em vez de alterar o this. Na prática, os métodos seriam implementados como norte(double):Coordenada e sul(double):Coordenada (observe o retorno de Coordenada em vez de void) com a seguinte lógica:

Métodos que não mutam, mas criam novos objetos
11   Coordenada norte(double v) {
12     return new Coordenada(this.latitude + v, this.longitude);
13   }
14 
15   Coordenada sul(double v) {
16     return new Coordenada(this.latitude - v, this.longitude);
17   }

A operação da coordenada imutável seria assim:

Usando uma Coordenada imutável
Coordenada c1 = new Coordenada(-33.692627, -53.455263);
Coordenada c2 = c1.norte(3.0);
System.out.println(c1.latitude); // -33.692627, -53.455263
System.out.println(c2.latitude); // -30.692627, -53.455263

Projetar classes para gerar objetos imutáveis necessita de mais esforço, mas não é só o esforço que deve ser considerado para a imutabilidade. Objetos imutáveis gastam mais memória e processamento, já que criam um objeto novo a cada comando. Na prática, a imutabilidade é útil quando se pensa em compartilhar objetos, especialmente os Objetos de Valor, que será abordado a seguir.

Identidade, Objetos de Valor e Entidades

Ao longo da execução de um software inúmeras instâncias (objetos) são criados. Esses objetos ficam na memória primária, secundária e, por vezes, trafegam na rede em sistemas distribuídos. A questão é como saber se dois objetos representam a mesma informação, mesmo que, por exemplo, ocupem diferentes espaços na memória (ou diferentes memórias). Esse conceito de igualdade ou, por vezes, equivalência depende da identidade de um objeto.

Quando se trabalha com bancos de dados, frequentemente é usada uma chave primária para distinguir rows (ou registros) de um tabela. Claro, mesmo em bancos de dados há o conceito de chave candidata, como um CPF, RG, CREA, etc, qualquer informação que garanta a unicidade dos dados. Em OO, por outro lado, é comum haver o mesmo dado, não é impedido, por padrão, instanciar vários objetos com as mesmas informações. Ao mesmo tempo, é preciso um meio de distinguir as instâncias e, assim como chaves no banco, um objeto precisa de identidade.

Existem, primariamente, duas estratégias de identificação de objetos:

  • pelo valor de todos os seus atributos, isto é, pela plenitudade do estado, conhecido como OBJETO DE VALOR;
  • por um ou mais atributos identificadores, mas nunca todos atributos, conceito de ENTIDADE.

Por exemplo, uma classe Hora com os atributos horas, minutos e segundos, se instanciada diversas vezes, como garantir que são a mesma informação? Neste caso, o valor dos atributos que manda. Se dois objetos Hora com os valores 15:12:17 e 15:12:16 forem comparados, eles são iguais? Obviamente não. Neste caso, eles são iguais apenas se os três atributos coincidem em valor.

Como outro exemplo, considere uma classe Veiculo com os atributos placa, marca, modelo e cilindrada. Dois objetos Veiculo com os dados IHQ8899, Fiat, Palio, 1000 e IHQ8899, Fiat, Punto, 1400 representam o mesmo veículo, basta a placa ser a mesmo para assumir que representam o mesmo objeto, mesmo que os demais dados sejam diferentes.

A implementação de identidade, na linguagem Java e maioria das linguagens orientadas a objetos, se dá pela (sobre)escrita de um método de igualdade (mais a implentação recomendada de um de código hash). Na linguagem Java este método tem a assinatura: equals(Object):boolean, isto é, um método que recebe um Object genérico como parâmetro e retorna true se são equivalentes ou false caso não.

Existe um algoritmo para a implementação de equals (ver box a seguir). Uma implementação possível, está a seguir, começando por verificar se o objeto é o mesmo (==) para depois passar pelas negativas, isto é, realizando os testes para provar a não elegibilidade à comparação. Passando estes testes, então o(s) atributo(s) que identificam o objeto são comparados. Como exemplo, considere dois estudos de caso: Hora e Veiculo. A seguir o código da classe Hora e seu método equals comentado:

Método equals de Hora
 1 class Hora {
 2 
 3   final int horas, minutos, segundos;
 4 
 5   Hora(int horas, int minutos, int segundos) {
 6     this.horas    = horas;
 7     this.minutos  = minutos;
 8     this.segundos = segundos;
 9   }
10 
11   @Override
12   public boolean equals(Object outroObjeto) {
13     // se forem a mesma instância obviamente eles têm o mesmo estado
14     if (this == outroObjeto) return true;
15     // verifica se o parâmetro é uma Hora (e também se não é nulo)
16     if (!(outroObjeto instanceof Hora)) return false;
17     // conversão (coerção (cast)) do outroObjeto para o tipo Hora
18     Hora outraHora = (Hora) outroObjeto;
19     // verdadeiro se a outraHora tem os mesmos valores
20     // para horas, minutos e segundos
21     return (this.horas    == outraHora.horas   &&
22             this.minutos  == outraHora.minutos &&
23             this.segundos == outraHora.segundos);
24   }
25 }

O equals de Hora é bem simples, para dizer a verdade. Passando pelo if da linha 16 o trabalho fica em converter o objeto genérico de Object para Hora e comparar todos os seus atributos. Essa é uma característica fundamental dos chamados objetos de valor. Com esse equals todos os Casos de Teste a seguir passam tranquilamente:

Testando o equals de Hora
    Hora h1 = new Hora(13, 25, 55);
    Hora h2 = h1;
    Hora h3 = new Hora(13, 25, 55);
    Hora h4 = new Hora(13, 25, 56);

    System.out.println(h1 == h2); // true
    System.out.println(h1 == h3); // false, não é a mesma instância!

    // usando equals em Hora
    System.out.println(h1.equals(h2)); // true
    System.out.println(h1.equals(h3)); // true
    System.out.println(!h1.equals(h4)); // true

Para finalizar o tópico a seguir a classe Veiculo e seu equals comentado:

Método equals de Veiculo
 1 class Veiculo {
 2   final String placa, marca, modelo;
 3   final int    cilindrada;
 4 
 5   Veiculo(String placa, String marca, String modelo, int cilindrada) {
 6     this.placa = placa; this.marca = marca; this.modelo = modelo;
 7     this.cilindrada = cilindrada;
 8   }
 9 
10   @Override
11   public boolean equals(Object outroObjeto) {
12     // se forem a mesma instância obviamente eles têm o mesmo estado
13     if (this == outroObjeto) return true;
14     // verifica se o parâmetro é um Veiculo (e também se não é nulo)
15     if (!(outroObjeto instanceof Veiculo)) return false;
16     // conversão (coerção (cast)) do outroObjeto para o tipo Veiculo
17     Veiculo outroVeiculo = (Veiculo) outroObjeto;
18     return this.placa != null && this.placa.equals(outroVeiculo.placa);
19   }
20 }

O equals de Veiculo se diferencia do equals de Hora na quantidade de atributos considerados para comparação. Enquanto Hora avalia todos os atributos, para Veiculo só importa a placa. O resto é irrelevante, não podem ocorrer colisões de placas! A seguir um Caso de Teste:

Testando o equals de Hora
    Veiculo v1 = new Veiculo("IHQ8899", "Fiat", "Palio", 1000);
    Veiculo v2 = new Veiculo("IHQ8899", "Fiat", "Punto", 1400);

    System.out.println(v1.equals(v2)); // true!

Já foi o básico!

Após este capítulo tu já deves saber o básico para escrever as primeiras classes e instanciá-las, modelando problemas e implementando com objetos consistentes, tanto enquanto informação como algoritmo, como atributos/estado e método/comportamento. No entanto, ainda existe muito de Orientação a Objetos para explorar, especialmente os conceitos e princípios que levaram esse a ser o principal paradigma de programação, tais como encapsulamento, coesão e acoplamento.

Capítulo 2 – Conceitos Fundamentais da POO

O que é real …

O que é real? Como você define o “real”? Se você está falando sobre o que você pode sentir, o que você pode cheirar, o que você pode saborear e ver, então “real” são simplesmente sinais elétricos interpretados pelo seu cérebro.

– Morpheus

Este capítulo traz os conceitos fundamentais, aqueles para além de só escrever classes e instanciar objetos, mas de como estruturá-los seguindo métricas qualitativas. Pense como metas, características desejáveis, a busca por bons níveis de abstração, da alta coesão e do baixo acoplamento.

Abstração

Primeiro, o conceito de abstração é muito mais amplo do que o usado na computação, mas, em poucas palavras, pode-se dizer que dado um fenômeno ou uma coleção deles, é a seleção dos aspectos mínimos necessários para a modelagem e resolução de um problema bem claro.

Como exemplo, considere modelos do sistema solar. Podemos montar um pequeno modelo usando esferas como os planetas e anéis circulares para representar as órbitas, como na imagem a seguir:

Modelo do Sistema Solar
Modelo do Sistema Solar

Perceba que só é possível realizar esse modelo ignorando os detalhes. Primeiro, os planetas não são esferas perfeitas. A terra, por exemplo, é achatada nos polos. Segundo, as órbitas não são de fato circulares, mas elipticas. Obviamente, criar um modelo preciso, seria muito mais difícil. Contudo, o modelo simplificado, para a maioria dos observadores, é bom suficiente (lembre desta expressão: bom suficiente).

Estudantes e praticantes da área de matemática e computação em geral exercem a abstração constantemente, seja para a escrita de algoritmos, especificação formal de linguagens e mesmo o desenvolvimento de software. A capacidade de abstração é uma habilidade essencial e determinante para o sucesso nas atividades e desafios do mundo da computação e da área de engenharia de softwares. Primeiro: porque a abstração permite ver o quadro inteiro, e segundo: porque permite decompor um grande e complexo problema em subproblemas menores e mais simples.

Por exemplo, considere o modelo de água e seus estados. O que sabemos: o estado da água varia com a temperatura e pressão (entre outras variáveis). Como codificar a temperatura e pressão? Parece bem complexo, então como podemos abstrair esse sistema obtendo o maior ganho com a menor perda? Abstrair envolve ignorar detalhes, como as variáveis, nisto, a última pergunta é: abstrair (ignorar) a temperatura ou a pressão? Neste caso é mais vantajoso abstrair a pressão e considerar apenas a temperatura, sabendo que o ponto de ebulição é de 100° Celsius e de solidificação a 0°C, ambos com a pressão ao nível do mar. Segue os Casos de Teste:

Especificando a Água
    Agua agua = new Agua();
    System.out.println(agua.temperatura()); // 20
    System.out.println(agua.gasosa()); // false
    System.out.println(agua.solida()); // false
    System.out.println(agua.liquida()); // true
    agua.aquecer(); // +1º
    System.out.println(agua.temperatura()); // 21
    System.out.println(agua.gasosa()); // false
    while ( ! agua.gasosa()) agua.aquecer();
    System.out.println(agua.temperatura()); // 100
    System.out.println(agua.gasosa()); // true
    System.out.println(agua.liquida()); // false

Note que a temperatura nunca é alterada diretamente e, além disso, as fases são verificadas apenas com relação a mudança de temperatura. Uma implementação possível seria essa:

Implementando a Água
 1 class Agua {
 2   private int temp = 20;
 3 
 4   void aquecer() { this.temp++; }
 5 
 6   void esfriar() { this.temp--; }
 7 
 8   int temperatura() {
 9     return this.temp;
10   }
11 
12   boolean solida() {
13     return this.temp <= 0;
14   }
15 
16   boolean gasosa() {
17     return this.temp >= 100;
18   }
19 
20   boolean liquida() {
21     return !solida() && !gasosa();
22   }
23 }

Neste código é importante perceber o atributo usado int temp na linha 2. Ele poderia ser alterado para double, chamar-se t, enfim, poderiam ser usadas outras várias estratégias de implementação sem mudar a especificação. Abstrair também é ocultar os detalhes.

Considere um segundo exemplo, mais complexo. Um jogo tipo RPG com Guerreiros, Arqueiros e Magos. Considere que todos começam com 100 pontos de vista e, enquanto digladiam, baixam seus pontos de vida até a morte. Um ataque de um Arqueiro e Mago reduzem um ponto de vida, enquanto do Guerreiro reduz 2 pontos. Eu até tenho mais regras que tornariam esse sistema mais complexo, mas deixemos assim por equanto. Segue os Casos de Teste:

Especificando o RPG
    Guerreiro gTorvalds = new Guerreiro("Linus Torvalds");
    Guerreiro gRaymond = new Guerreiro("Eric Raymond");
    Mago mJobs = new Mago("Steve Jobs");
    Mago mGates = new Mago("Bill Gates");
    Arqueiro aLiskov = new Arqueiro("Barbara Liskov");
    Arqueiro aDijkstra = new Arqueiro("Edsger Dijkstra");
    System.out.println(gTorvalds.vida() == 100);
    System.out.println(gRaymond.vida() == 100);
    System.out.println(mJobs.vida() == 100);
    System.out.println(mGates.vida() == 100);
    System.out.println(aLiskov.vida() == 100);
    System.out.println(aDijkstra.vida() == 100);
    gTorvalds.ataca(mJobs);
    System.out.println(mJobs.vida() == 98);
    System.out.println(mJobs.estaVivo() == true);
    aLiskov.ataca(mJobs);
    System.out.println(mJobs.vida() == 97);
    System.out.println(mJobs.estaVivo() == true);
    mGates.ataca(mJobs);
    mGates.ataca(mJobs);
    System.out.println(mJobs.vida() == 95);
    mJobs.ataca(mGates);
    System.out.println(mGates.vida() == 99);
    while (mJobs.estaVivo()) aDijkstra.ataca(mJobs);
    System.out.println(mGates.vida() == 0);
    System.out.println(mJobs.estaVivo() == false);
    gTorvalds.ataca(gRaymond);
    System.out.println(gRaymond.vida() == 98);
    gTorvalds.ataca(aDijkstra);
    System.out.println(aDijkstra.vida() == 98);
    mJobs.ataca(gRaymond); // morto não ataca
    System.out.println(gRaymond.vida() == 98);
    // escreva mais testes

Este é, com certeza, um problema bem mais complexo de resolver, implicando em vários métodos sobrecarregados nas classes Guerreiro, Arqueiro e Mago. A abstração é, também, uma arma contra a complexidade. Neste exemplo, pode-se procurar por uma abstração com o objetivo de mitigar a complexidade e ela está nas semelhança entre os objetos! Na prática, essa complexidade seria resolvida com a adição de uma abstração na forma de classe abstrata, que será visto no capítulo Generalização.

Ocultação de Informações, Encapsulamento e Ortogonalidade

Este tópico trata de um único tema, que é a proteção do estado, contudo, existem entrelaçamentos entre essas duas expressões do título.

Vamos começar com a ocultação de informações. Essa expressão antecede o hype da POO, sendo usado antes mesmo da noção de objetos, estado e comportamento, quando popularizou o termo encapsulamento. Enfim, a ocultação de informação é um princípio que objetiva, como diz o nome, tornar invisível algumas partes de um módulo de um programa de modo que outros módulos não “enxerguem” esses detalhes. Essa prática visa diminuir o conhecimento entre módulos, diminuindo o acoplamento.

Durante o processo de abstração, o programador decide que informações serão ocultadas e quais não. Essa decisão é frequentemente bem discutível, dependendo da experiência profissional e alguns princípios e boas práticas, mas em geral busca-se esconder os detalhes de implementação, especialmente o que está mais propenso a mudar, enquanto disponibliza, isto é, deixa visível o que tende a ser mais perene, isto é, as partes que sejam menos prováveis de serem alteradas no futuro.

Neste sentido, as informações, que na prática significam tanto dados quanto algoritmos, são declaradas como particulares de um certo módulo, isto é, oculta aos demais, ou disponíveis para acesso externo, isto é, servem como uma interface para com os outros módulos do programa. Assim, qualquer mudança nas informações particulares, afeta apenas o próprio módulo, enquanto por outro lado, mudanças na parte visível afetam a interface e, logo, todos os outros módulos que interagem com ela.

Na prática, cada linguagem de programação disponibiliza construtos específicos para determinar o que deve ser visível e o que não. A maioria da linguagens, incluindo Java, C++, C# e PHP, por exemplo, disponibilizam palavras-chave que servem como modificadores de acesso para atributos e métodos como: private e public, que significam, respectivamente, privado: acessível apenas dentro da classe, e público: acessível por outras classes. Contudo, existem linguagens que não oferecem tal controle, como JavaScript, por exemplo (embora os programadores “dêem seu jeito” com closures).

Agora, encapsulamento é um termo bastante discutido e nem todos o utilizam com a mesma acepção. Ele se tornou popular junto a POO e, de forma bem simplificada, encapsulamento pode ser definido como uma forma de restringir o acesso direto à alguns membros (atributos, métodos, etc) de um certo objeto. Esse primeiro entendimento parece uma aplicação do princípio da ocultação de informações, mas, por outro lado, encapsulamento também pode ser entendido como um mecanismo utilizado para o agrupamento de dados e algoritmos em uma mesma unidade, isto é, no linguajar OO, juntar atributos e métodos em uma mesma classes consistente para um objeto de certo tipo. Neste sentido, o encapsulamento parece com o recurso prático para a implementação da abstração projetada, e de fato abstração e encapsulamento são complementares, onde o primeiro foca no comportamento observável e o segundo na implementação responsável por esse comportamento. Nas linguagens de programação que não utilizam classes como estruturas de dados é bem mais comum a expressão ocultação de informações que o termo encapsulamento, então, em Java, C#, PHP, C++ e outras, o termo encapsulamento é bem mais popular.

Antes de um exemplo prático, é importante perceber que, seja ocultação de informações ou encapsulamento, ambos objetivam a ortogonalidade de um sistema. A ortogonalidade é uma característica desejável e também uma métrica de qualidade de sistemas. Basicamente, o projeto de um sistema é dito ortogonal se a modificação de um componente não propaga efeitos colaterais em outros componentes.

Para exemplificar, considere a noção bem abstrata de um personagem inimigo em qualquer jogo. Considere que eles tenham um nome, um valor de energia e que possam receber ataques até serem destruídos. Simples, não? Vejamos então os seguintes Casos de Teste:

Especificando um Inimigo
 3     Inimigo zorak = new Inimigo("Zorak", 50);
 4     System.out.println(zorak.nome.equals("Zorak"));
 5     System.out.println(zorak.vivo == true);
 6     System.out.println(zorak.energia == 50);
 7     zorak.dano(10);
 8     System.out.println(zorak.energia == 40);
 9     System.out.println(zorak.vivo == true);
10     zorak.dano(30);
11     System.out.println(zorak.energia == 10);
12     System.out.println(zorak.vivo == true);
13     zorak.dano(20);
14     System.out.println(zorak.energia == 0);
15     System.out.println(zorak.vivo == false);
16     zorak.vivo = true; // violação do encapsulamento
17     System.out.println(zorak.energia == 0);
18     System.out.println(zorak.vivo == true);

A linha 17 apresenta uma violação explícita do encapsulamento, a violação mais simples, que é o acesso ao estado do objeto deixando-o em estado inconsistente: como um inimigo pode ter energia == 0 e estar vivo vivo == true? Verificar a confiabilidade do estado já é mais sutil, por exemplo, considere que abandonemos o atributo vivo:boolean e usemos apenas a energia para saber se o inimigo está vivo ou não. Então, considere que se um inimigo estiver morto possamos ressucitá-lo:

Nova especificação de Inimigo
 3     Inimigo zorak = new Inimigo("Zorak", 50);
 4     System.out.println(zorak.nome.equals("Zorak"));
 5     System.out.println(zorak.energia == 50);
 6     zorak.dano(10);
 7     System.out.println(zorak.energia == 40);
 8     zorak.dano(30);
 9     System.out.println(zorak.energia == 10);
10     zorak.dano(20);
11     System.out.println(zorak.energia == 0);
12     // ressucitação
13     if (zorak.energia == 0) zorak.energia = 50;
14     System.out.println(zorak.energia == 50);

Sem problemas até agora. Os testes passam. Então, onde está a falha? Ela está no acesso ao atributo energia. Olhando as linhas 10, 11 e 12, nota-se que a energia zera mesmo com um dano maior que a energia disponível. Considere a implementação de Inimigo a seguir:

Implementação de Inimigo
 1 class Inimigo {
 2   String nome;
 3   int energia;
 4 
 5   Inimigo(String nome, int energia) {
 6     this.nome = nome;
 7     this.energia = energia;
 8   }
 9 
10   void dano(int dano) {
11     if (dano < this.energia) {
12       this.energia = this.energia - dano;
13     } else {
14       this.energia = 0;
15     }
16   }
17 }

Na verdade, não há uma falha na implementação, há uma fragilidade devido a exposição do estado (que é uma falha de projeo). Considere que outro desenvolvedor continue esse código e reprojete essa classe/objeto. No raciocínio dele é seria melhor deixar a energia negativar em vez de checar no dano (linhas 11 a 15). Para expor a energia ele cria um método de leitura (acessor), assim como um método extra para checar se está vivo. Na verdade, é um boa implementação, não fosse a exposição do atributo energia. Veja a seguir:

Implementação de Inimigo, 2da versão
 1 class Inimigo {
 2   String nome;
 3   int energia;
 4 
 5   Inimigo(String nome, int energia) {
 6     this.nome = nome;
 7     this.energia = energia;
 8   }
 9 
10   void dano(int dano) {
11     if (this.vivo()) {
12       this.energia = this.energia - dano;
13     }
14   }
15 
16   int energia() {
17     return this.energia > 0 ? this.energia : 0;
18   }
19 
20   boolean vivo() {
21     return this.energia() > 0;
22   }
23 }

A lógica está concentrada entre as linhas 10 e 22. Perceba que o atributo energia pode ficar negativo, entretanto o método energia():int retorna zero, no mínimo. A fragilidade está na dependência do atributo energia por outras classes. A segunda especificação de Inimigo falha com essa nova implementação. Assim como o programa pode falhar em qualquer parte onde o atributo energia seja lido. A causa raiz de todos esses problemas está na visibilidade do atributo energia, ele deveria ser privado desde o começo. Então, quando em dúvida, siga a recomendação: todos os atributos devem ser privados a não ser que exista um motivo muito forte para não. A seguir a 3ra versão de Inimigo agora completa:

Implementação de Inimigo, 3ra versão
 1 class Inimigo {
 2   final String nome;
 3   private int energia;
 4   private final int max;
 5 
 6   Inimigo(String nome, int energia) {
 7     this.nome = nome;
 8     this.energia = energia;
 9     this.max = energia;
10   }
11 
12   void dano(int dano) {
13     if (this.vivo()) {
14       this.energia = this.energia - dano;
15     }
16   }
17 
18   int energia() {
19     return this.energia > 0 ? this.energia : 0;
20   }
21 
22   boolean vivo() {
23     return this.energia() > 0;
24   }
25 
26   void ressucitar() {
27     if (this.energia == 0) {
28       this.energia = this.max;
29     }
30   }
31 }

Esta implementação garante que a energia seja lida apenas pelo método na linha 16, note o modificador de acesso private na linha 3, evitando o acesso direto ao atributo energia. Outro ponto a considerar é a definição do atributo nome como constante, note a instrução final na linha 2. Isso garante que o nome possa ser lido, mas não possa ser alterado. Essa classe segue boas diretrizes de projeto, assegurando o encapsulamento, protegendo o estado (os atributos) e fornecendo uma interface (os métodos) para ler e operar o objeto.

Os seguintes Casos de Teste agora são aplicáveis com sucesso:

Última especificação de Inimigo
 3     Inimigo zorak = new Inimigo("Zorak", 50);
 4     System.out.println(zorak.nome.equals("Zorak"));
 5     System.out.println(zorak.energia() == 50);
 6     zorak.dano(10);
 7     System.out.println(zorak.energia() == 40);
 8     zorak.dano(30);
 9     System.out.println(zorak.energia() == 10);
10     zorak.dano(20);
11     System.out.println(zorak.energia() == 0);

Importante, declarar todos os atributos como privados não garante o encapsulamento. Não é uma receita mágica, é preciso mais que isso. Não vai funcionar sem a disciplina de preferir a interface em vez da implementação. Para entender, considere o problema de ressucitar um Inimigo. Como a energia é privada ela pode ser acessada apenas de dentro da classe Inimigo, logo vamos implementar um método ressucitar():void:

Método ressucitar propenso a falha
26   void ressucitar() {
27     if (this.energia == 0) {
28       this.energia = this.max;
29     }
30   }

Este método é falho, ele se apoia no atributo energia, que pode ser negativo, isto é, um Inimigo morto com energia negativa nunca seria ressucitado nesta implementação. A solução é simples, basta apoiar-se na interface do objeto, ou seja, se o Inimigo estiver vivo ele pode ser ressucitado, veja a seguir:

Última implementação de Inimigo, com todas as correções
26   void ressucitar() {
27     if ( ! this.vivo()) {
28       this.energia = this.max;
29     }
30   }

Este método ressucitar():void é mais seguro, pois ele se apoia na interface (método vivo():boolean). As interfaces devem ser projetadas para não mudar, isto é, elas devem ser estáveis e duráveis, logo são mais confiáveis. Então, a segunda recomendação é: preferencialmente opte por se apoiar na interface do objeto.

Coesão

Se tu lembras da Coesão Textual, assunto presente nas aulas de Língua Portuguesa, isso pode te ajudar bastante. Assim como a coesão textual busca a harmonia textual que garante significado a um parágrafo, a coesão na Ciência da Computação também busca a harmonia e significância, apenas trocando parágrafos por módulos.

O nível de coesão é medido pelo grau com que os membros de um mesmo módulo se relacionam, se existe sentido em estes membros, dados e algoritmos, estarem na mesma unidade. Quanto mais as partes de um módulo fazem sentido em estar juntas diz-se que o módulo tem alta coesão. Ao contrário, isto é, quanto mais as peças parecem não se encaixar no mesmo contexto, diz-se que o módulo tem baixa coesão.

Embora existam métricas para medir a coesão, frequentemente ela é assunto de discussão. Quero dizer, a declaração de que um módulo tem alta ou baixa coesão vai depender do ponto de vista dos programadores/desenvolvedores, que, claro, nem sempre concordam. Entretanto, ainda é possível separar um exemplo simples, mas claro, de como a coesão afeta um módulo, neste caso, considere a seguinte classe:

Existem vários tipos de coesão:

  • Coesão Coincidental
  • Coesão Lógica
  • Coesão Temporal
  • Coesão Procedimental
  • Coesão Comunicacional/Informacional
  • Coesão Sequencial
  • Coesão Funcional

Acoplamento

Métricas

O que vem aí: os objetos não vivem sozinhos!

Capítulo 3 – Associações entre Objetos

Trabalhar juntos …

A ideia era reunir um grupo de pessoas notáveis para ver se eles poderiam se tornar algo mais. Para ver se eles poderiam trabalhar juntos quando precisássemos deles, para lutar as batalhas que nunca poderíamos.

– Nick Fury

Conceito de Associação

Delegação

Agregação

Composição

Honrando as Associações

Pontos Negativos das Associações

Zoom Out: saindo do mais específico ao mais genérico

Capítulo 4 – Generalização de Objetos

Herança

Subtipagem (ou subclassficação) e Polimorfismo

Polimorfismo ad-hoc

Polimorfismo por Sobrescrita

Variância, Tipos Covariantes e Contravariantes

Polimorfismo Paramétrico

Honrando a Generalização

Perigos da Generalização

ei pi ai

Capítulo 5 – API’s e Contratos entre Objetos

Interfaces como contratos

Pré-condições, pós-condições e invariantes

Controle de exceções

Interfaces como opção para polimorfismo

Estudo de Caso: o Design Pattern Strategy

Honrando as API’s

Desonrando as API’s

Capítulo 6 – Princípios de Projeto de Objetos

Princípio da Segregação de Interfaces

Princípio do Acesso Uniforme

Separação de Comandos e Consultas

Princípio do Menor Privilégio

Princípio da Responsabilidade Única

Princípio Aberto/Fechado

Princípio de Substituição de Liskov

Lei de Deméter

Notes

1um exemplo clássico e popular para algum tema é conhecido como Exemplo Canônico.

2cada escolha possível é elencada e pesada segundo seus prós e contras em uma balança e não há uma decisão perfeita onde se ganha em todos os quesitos, é preciso escolher o que pode-se perder com mais segurança, é preciso selecionar o tradeoff.

3regras da linguagem de programação para definir o conjunto de palavras (símbolos) permitidos e suas combinações.

4o WolframAlpha é excelente para testar procedimentos (e depois objetos/métodos) que envolvam matemática!

5Existe uma corrente a favor de primeiro criar o teste unitário e então implementar o código que cumpra o especificado. O nome desta técnica é Test-Driven Development (TDD, em português desenvolvimento guiado por testes). Para saber mais a respeito experimente essa leitura http://tdd.caelum.com.br/.

6The Mother of All Demos foi uma apresentação de Douglas Engelbart dos resultados e aplicações no estudo de interfaces humano-computador. Ela aconteceu em 9 de julho de 1968 e introduziu a noção de janelas, navegação por menus, hípertexto e inclusive o mouse! Ela pode ser vista aqui (EN, 1h40m) http://youtu.be/yJDv-zdhzMY.

7Se quiseres conhecer um pouco mais da história das Graphical User Interfaces (GUI’s) considere este excelente artigo disponível na ARS Technica (EN) A History of the GUI http://arstechnica.com/features/2005/05/gui/ ou então GUI Timeline (EN) na ToastyTech http://toastytech.com/guis/guitimeline.html.

8C é uma linguagem procedimental criada em 1972 e usada até hoje (o ++ em C++ é uma analogia a um incremento do C). A maioria dos sistemas embarcados, núcleos de sistemas operacionais como Windows, Linux e Mac OS, por exemplo, são escritos em C ou C++.

9A Sun Microsystems foi fundada em 1982 e sua atividade principal era a fabricação de processadores e distribuição de workstations e servidores, contudo também investia em sistemas operacionais, linguagens de programação e outros softwares. Em 2009 foi vendida para a Oracle Corporation, junto com todas as suas tecnologias incluindo, claro, a linguagem Java.

10na prática estamos falando de classe e tipo como sendo a mesma coisa, mas na teoria tipo é uma definição mais abstrata e elementar de uma informação.

11experimente digitar 0,0 no campo de busca no maps.google.com o_O

12Há bastante material disponível sobre Modelo Anêmico, considere o seguinte post de Maurício Aniche no Blog da Caelum: http://blog.caelum.com.br/o-que-e-modelo-anemico-e-por-que-fugir-dele/.