Programação Orientada a Objetos
Programação Orientada a Objetos
Márcio Torres
Buy on Leanpub

Sumário

Prefácio

Este livro começou a ser escrito em 2016, quando lecionava a disciplina de 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 atuação profissional.

O desafio da educação profissional é achar a didática certa para obter uma abordagem pragmática, mas que ao mesmo tempo ofereça a base teórica, lembrando sempre que esses estudantes podem, no futuro, tanto atuar profissionalmente, como procurar qualificação continuada, fazer uma pós-graduação ou participar de concursos públicos.

Portanto, a reunião das minhas anotações de aula resultou neste livro-texto que, no princípio, não era para ser publicado. Tenha em mente que é um livro incompleto e ainda sem revisão. No entanto, eu acabei disponibilizando ele aqui no Leanpub, pensando na utilidade que ele poderia ter às pessoas que se esforçam em aprender POO, seja na faculdade, curso técnico, ou por conta própria mesmo.

Mesmo que este livro seja só um pouquinho útil, já valeu o esforço!

Se quiseres me dar um retorno, saiba que sugestões e críticas são bem-vindas. Meu e-mail é marcio.torres@riogrande.ifrs.edu.br.

Obs.: estou procurando o autor do desenho da capa para pagar o licenciamento de uso.

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 Orientada a Objetos para construir software de qualidade.

Abordagem

Este livro está sendo escrito a partir de notas de aula usadas na disciplina de Programação Orientada a Objetos, ministrada no curso de Análise e Desenvolvimento de Sistemas oferecido pelo Instituto Federal de Educação, Ciência e Tecnologia do Rio Grande do Sul (IFRS). Essa origem, digamos, dá à esse livro as seguintes propriedades:

  • Para a sala de aula (física ou virtual): projetado para ser didático assim como as estratégias de ensino em sala aula;
  • Foco na prática: para cada explanação teórica há exemplos de aplicação prática, com exemplos e estudos de caso, pois ele é destinado à educação profissionalizante;
  • No nosso idioma e na nossa cultura: sem cara de tradução e escrito para os Brasileiros (com um leve sotaque gaúcho;)
  • Linguagem acessível: são evitadas expressões rebuscadas e formalidades linguísticas - o que é muito útil para as pessoas não dormirem nas aulas.

O ponto forte deste livro, penso, é a não-separação de teoria e prática. Logo, cada conceito ou princípio abordado vem junto com um “exemplo bobo”, depois uma situação de uso mais elaborada e um exercício. Como “exemplo bobo”, quero dizer uma amostra de implementação pequena, direta e evidente que, embora não muito útil para o uso cotidiano, deve ser esclarecedora para o aprendiz, com o mínimo de distrações. A situação de uso, por outro lado, é um 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. Exemplos e exercícios crescem em complexidade ao longo do capítulos. Portanto, certifique-se de compreender bem os tópicos anteriores antes de avançar.

Os exemplos e exercícios são baseados em especificações e assertivas, que permitem validar e verificar se o software está conforme o planejado e com as respostas esperadas. Estas são as premissas mais básicas de um controle de qualidade de software e princípios da programação defensiva, que estarão distribuídos transversalmente no livro.

Considere fortemente realizar os exercícios e atividades, eles representam 80% do aprendizado, enquanto ler proporciona apenas 20%. Por mais que entendas muito bem a teoria, a programação é uma atividade prática, de transpiração mesmo, de errar, de acertar, de sair frustrado sem solução para o problema e só descobrir uma saída durante o sono ou banho. É tudo “parte do jogo”.

Linguagem de programação

Este livro foi planejado para priorizar a POO em vez de uma linguagem específica. Portanto, muitos exemplos estão escritos usando pseudocódigo, isto é, a ideia geral estará escrita em português estruturado, como: classe Teste, método MêsPorExtenso(mês: Inteiro): Texto, se condição // ... fim se etc. Assim, estes modelos são implementáveis na maioria das linguagens orientadas a objetos.

Os códigos específicos que poderão ser executados, por outro lado, estão escritos na linguagem de programação Java. Ela é a linguagem preferida na grande maioria dos livros de referência para a 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 do curso superior de Análise e Desenvolvimento (mas usamos JavaScript/TypeScript na disciplina de POO do curso técnico em Informática para Internet).

No futuro pretendo lançar esse livro com outras linguagens de programação. Como disse antes, é objetivo desse livro não se “apegar” a UMA 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 a POO

Lembrando: 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.

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 os use por sua conta e risco, hehehe :).

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 condicionais, laços, etc, como se já estivesse passado por uma disciplina de lógica ou introdução à programação ou computação. Na lista a seguir há uma ideia do que é visto em cada capítulo, na forma de quais questões deve responder.

  • Capítulo 0 – Introdução: o que há neste livro? ele serve para mim?
  • Capítulo 1 – Programação Modular: quais são os fundamentos de modularização de código?
  • Capítulo 2 – História da Programação Orientada a Objetos: como surgiu a POO?
  • Capítulo 3 – Modelo de Objetos: como é um objeto? como descrevê-los e usá-los?
  • Capítulo 4 – Estado: como é o ciclo de vida de um objeto? como definir o conjunto de valores que representam?
  • Capítulo 5 – Comportamento: como são as operações realizadas pelos objetos? como defini-las?
  • Capítulo 6 – Polimorfismo ad hoc: como uma mesma operação pode ser executada com diferentes entradas?
  • Capítulo 7 – Encapsulamento: como um objeto esconde sua complexidade e protege suas informações vitais?
  • Capítulo 8 – Identidade: como um objeto é diferente de outro objeto?
  • Capítulo 9 – Representação: como um objeto pode ser representado alternativamente? quais formatos são mais utilizados?
  • Capítulo 10 – Imutabilidade: como saber se um objeto deve ser constante ou variável?
  • Capítulo 11 – Coesão: como saber quais informações e operações devem ficar juntas no mesmo objeto?
  • Capítulo 12 – Associação: como os objetos trabalham uns com os outros?
  • Capítulo 13 – Delegação: como os objetos delegam operações para outros objetos?
  • Capítulo 14 – Agregação: como os objetos se unem para resolver um problema maior?
  • Capítulo 15 – Modelagem: como descrever a colaboração desses objetos antes de escrever o código?
  • Capítulo 16 – Responsabilidades: como saber o que cada objeto deve fazer?
  • Capítulo 17 – Direcionalidade: como saber a direção das mensagens?
  • Capítulo 18 – Multiplicidade: como saber quantos objetos conectar a outro objeto?
  • Capítulo 19 – Composição: como saber se um objeto deve ser composto de “objetinhos”?
  • Capítulo 20 – Acoplamento: como as relações de dependência entre objetos afetam o projeto?
  • Capítulo 21 – Interface: como um objeto é usado por outros?
  • Capítulo 22 – Generalização e Especialização (Herança): como um objeto pode compartilhar parte de sua lógica e informação?
  • Capítulo 23 – Polimorfismo por Subtipagem: como os objetos específicos podem redefinir o comportamento?
  • Capítulo 24 – Abstração: quão genérico devem ser os objetos genéricos?
  • Capítulo 25 – Polimorfismo Paramétrico: e se eu não souber o tipo específico? como deixar o tipo em aberto?
  • Capítulo 26 – Considerações Finais: sabendo de onde eu vim, para onde eu vou agora?

Para quem é este livro

É sempre importante esclarecer que este é um livro voltado à educação profissional, baseado na minha experiência profissional e no ensino de programação para iniciantes. O livro é útil para os estudantes que já passaram pela disciplina inicial de lógica ou introdução à programação em seus cursos.

Esta obra também pode atender programadores recém 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 boas estratégias para a modularização de sistemas, embora não se aprofunde em atividades de design de software ou arquitetura 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. As disciplinas de POO em cursos de bacharelado podem tirar proveito desta obra, mas apenas se objetivarem um viés mais prático. Para ajudar, saiba que ele está sendo usado no IFRS para a disciplina de Programação Orientada a Objetos, cuja ementa está a seguir:

Conceitos e princípios de programação orientada a objetos: abstração, classes, instâncias, estado e comportamento, atributos e métodos, comandos e consultas, coesão, encapsulamento, associação, agregação, composição, delegação, dependência e acoplamento, herança e polimorfismo. Modelagem, implementação e testes. Noções de princípios 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”. Se não sabes programar, se não passaste por uma disciplina de lógica de programação ou algoritmos, então ele pode te parecer muito avançado e, ao final, não te ser muito útil.

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

Não espere, também, uma cobertura abrangente da linguagem de programação, neste caso Java. O objetivo é estudar os conceitos do paradigma orientado a objetos. A linguagem é usada apenas para “materializar” esses conceitos. Pensa nela como o instrumento para colocar em prática estes conceitos.

Se tu já sabes POO e procuras um livro que aprofunde esse conhecimento, também pode não ser uma boa escolha. Para estes casos, pode te ser mais útil alguma referência sobre 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 será abreviada 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. É muito importante conhecer esse “dialeto”, por assim dizer, sobretudo em um livro que te 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 …”. As assinaturas de métodos são apresentadas como Classe.metodo(TipoArgumento):TipoRetorno, semelhante à notação UML.

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

Exemplo de um Código de Exemplo
1 // Os códigos aparecerão em fonte de largura fixa e com comentários como este.
2 class UmaClasse {
3   String umAtributo = "";
4   String umMetodo(int umParametro) {
5     int umRetorno = umParametro * 2;
6     return this.umAtributo + umRetorno;
7   }
8   // ...    <-- reticências significa código omitido
9 }

Os comentários seguidos por reticências // ... significam código omitido, pois às vezes o código inteiro é muito longo para ser colocado na listagem. Nestes casos visite o link para acessá-lo na íntegra e ver o exemplo funcional completo.

Dicas, avisos, observações e milongas

O livro está cheio de quadros. A maioria está relacionado com questões da POO e quando ela está sendo empregada corretamente ou não.

Sobre o Autor

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

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

É a vida de quem ensina. Eu trabalho em sala de aula hoje. 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 e tecnologia em geral. 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 primeiro 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.

Tenho dúvidas se sou um programador que ensina ou um professor que programa. Gosto de pensar que sou ambos.

Boa leitura!

1. Programação Modular

“Existem apenas dois tipos de linguagens de programação: aquelas que as pessoas reclamam, e aquelas que ninguém usa.”

Bjarne Stroustrup, A Linguagem de Programação C++

Existem muitas definições para o que é Programação Modular, mas vamos lidar com a que está disponível no wiki do Cunningham & Cunningham:

Programação Modular é o ato de projetar e escrever programas como interações entre funções onde cada uma realiza uma única e clara funcionalidade, e que tem o mínimo de efeitos colaterais entre elas.

1.1 Modularização

A modularização dos códigos é um dos principais recursos para construir softwares com alto nível de qualidade. Em poucos palavras, a meta é simples: extrair uma lógica repetida (código redundante) e compartilhar através de partes reaproveitáveis (módulos).

Nos primórdios da programação a modularização era realizada através de Rotinas e mais tarde por Procedimentos, bem antes de então chegarmos no paradigma Orientado a Objetos (OO). Materializando isso, geralmente os módulos são separados em diversos arquivos, que contém as rotinas/procedimentos/funções/classes/métodos. Entenda que, antes de tudo, é perfeitamente possível escrever um programa inteiro em um único arquivo e até em uma única listagem “corrida” de código (sem separações). Entenda, também, que a granularidade pode ser variável, isto é, o mesmo programa pode ser separado em dezenas ou centenas de arquivos, cada um com uma, duas ou até dúzias de rotinas/procedimentos/funções/classes/métodos.

Embora modularizar traga um trabalho adicional de projetar (pensar) na separação, ainda assim os benefícios são incontestáveis, pagando todo o esforço inicial extra, valendo o trade-off1. Entre os benefícios alguns mais importantes são:

  • Usando módulos há menos código em cada arquivo, resultando em estruturas lógicas mais simples e compreensíveis;
  • As lógicas são reutilizáveis, evitando a rescrita do mesmo código várias vezes;
  • Os membros da equipe podem trabalhar em módulos diferentes;
  • Existe uma maior facilidade para identificar e corrigir erros, dado o isolamento, quando eles estão contidos em módulos;
  • O mesmo módulo pode ser reaproveitado em vários softwares;
  • Cada módulo pode ser testado separadamente.

Os benefícios citados são, entre outros, os principais responsáveis por uma gestão básica da qualidade do software. No tópico a seguir vem o primeiro exemplo de como construir partes reusáveis de códigos através de procedimentos (funções).

1.2 Modularização na Programação Procedimental

A separação da lógica repetida/reusável em procedimentos é a base da programação procedimental. A implementação desses procedimentos depende muito da linguagem de programação utilizada, por exemplo, são usadas as keywords Procedure em Pascal, Sub e Function em Visual Basic. A seguir um procedimento em Pascal para (exemplo “bobo”) mostrar a soma de dois números:

Procedure Soma (Var Valor_1, Valor_2 : Integer);
Var Soma : Integer;
Begin
  Soma := Valor_1 + Valor_2;
  Writeln ('Soma = ', Soma);
End;
// https://gist.github.com/marciojrtorres/3065c164af0b89eb033a108b1936ed3a

Nas linguagens modernas os procedimentos são implementados como funções (ou métodos). Existem diversas sintaxes2 para declarar uma função, dependendo da linguagem de programação, por exemplo: def em Python e Ruby, func em Google GO, fun em Kotlin e, mais óbvio, function em JavaScript e PHP.

Na linguagem Java a definição de funções/procedimentos é feita com métodos estáticos (não é a única, em C# também por exemplo). A presença de métodos estáticos em códigos escritos na linguagem Java caracteriza funcionalidades Procedurais em meio à Orientação a Objetos.

Os procedimentos são projetados para executar uma lógica, recebendo ou não uma entrada e devolvendo ou não um valor, isto é, tanto a entrada de dados como a saída de dados é opcional. Para exemplificar, considere um programa simples, que lê dois números e imprime o MMC. A seguir uma implementação inicial que não faz uso de procedimentos:

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 }

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 }

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 }

Na linha 7 está a assinatura do método, que neste exemplo representa um procedimento. A assinatura, em poucas palavras, descreve o nome do procedimento, entrada e retorno, podendo ser descrita como Proc3.mmc(int, int):void, que significa um procedimento com o nome de mmc, pertencente ao módulo Proc3 (em Java um módulo é uma classe) que recebe dois parâmetros inteiros int, int e não possui retorno void (void pode ser traduzido livremente como vazio).

O procedimento anterior funciona bem, tu mesmo podes fazer os testes. Mas, como se testa? A maneira trivial de testar é chamar o procedimento com MMCs conhecidos e observar se a reposta é esperada, por exemplo, digitando 3 e 5 o resultado deve ser, inevitavelmente, 15. Realizar testes manuais, entrando com os valores e observando as saídas, mesmo com valores triviais, também é a testagem mais simplificada possível.

Testagem

Sem ir muito ao fundo do assunto, a testagem adequada é realizada com a introdução de um conjunto de entradas variadas, válidas e inválidas, as quais tenham saídas previsíveis (esperadas). A previsibilidade é o ponto-chave da testabilidade e para isso os procedimentos devem fornecer alguma saída, seja a resposta correta ou um tratamento de erro. O procedimento anterior não oferece uma saída, não tem retorno (void), o println faz parte da lógica do procedimento. Os dois problemas dessa abordagem são:

  • a estratégia de impressão do resultado é “fixa”,
  • o procedimento não oferece um retorno testável.

Por esses motivos o procedimento será alterado para oferecer um retorno, conforme exemplo a seguir:

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 }

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 }

As expressões booleanas nas linhas 19, 21 e 23 representam três testes: dois óbvios, que podem ser obtidos de cálculo mental, e um extrapolado (no limite, o mais importante!). Testar os limites é importante porque é “nas bordas” que falhas e bugs são revelados e, grave isso na memória: o objetivo de testar é provar que o programa não funciona. Neste exemplo, na verdade, foram realizados poucos testes, além de testar situações limites é importante adicionar mais testes afim de encontrar falhas e corrigi-las, até todos os testes passarem. Para minha felicidade (ou não, lembre :P) todos os testes passaram:

javac Proc5.java; java Proc5

true
true
true

Especificação

Procedimentos e, posteriormente, objetos são projetados e implementados a partir de especificações. As especificações devem ser corretas, precisas, claras e válidas! Sem uma especificação válida é impossível assegurar que as respostas são corretas ou mesmo provar que não são. Então, vamos obter a especificação do MMC:

Em aritmética e em teoria dos números o mínimo múltiplo comum (mmc) de dois inteiros a e b é o menor inteiro positivo que é múltiplo simultaneamente de a e de b. Se não existir tal inteiro positivo, por exemplo, se a =0 ou b = 0, então mmc(a, b) é zero por definição.

– Wikipédia em https://pt.wikipedia.org/wiki/M%C3%ADnimo_m%C3%BAltiplo_comum

Partindo dessa especificação, podemos assegurar que o procedimento implementado está em conformidade com ela? Vamos tentar provar que não, explorando as situações excepcionais e escrevendo os testes a seguir contra a especificação:

<<Testando contra a especificação

Não trate como decepcionante, mas como esclarecedor. Dos três primeiros testes, com números negativos, o primeiro e terceiro falham. A seção de testes com zero causa um erro logo no primeiro Exception in thread "main" java.lang.ArithmeticException: / by zero. Obter false indica uma falha e quer dizer que não está conforme a especificação pois retorna uma resposta incorreta (as duas falhas retornaram 5 e -5 respectivamente). Uma exceção, ou “quebra” do programa, indica um erro. Resumindo, o procedimento não cumpre a especificação, isto foi provado (e provar que não funciona é, acredite, bom!).

1.3 Modularização na Programação Orientada a Objetos

A POO se difere da programação procedimental quando combina os dados e o algoritmo em uma única unidade (módulo). Na POO a lógica é acessada a partir dos dados. Ainda temos o livro inteiro para discutir isso, mas apenas para ilustrar considere um exemplo “bobo”: calcular o dobro de um número inteiro. Na programação procedimental basta criar um procedimento dobro(int):int, ele recebe um inteiro e devolve outro, não é complicado, veja a seguir:

Procedimento para obter o dobro de um número
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);
  }
}

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

Se nunca viste POO antes, o código anterior pode parecer intimidador, confuso e até exagerado, numa avaliação mais crítica, pois POO geralmente exige mais código que o Procedimental para declarar as estruturas básicas. A POO oferece uma nova visão e interpretação (por isso paradigma) de como implementar uma especificação. Direto e em poucas palavras, no código anterior a noção de Número foi implementada com uma classe class Numero, da qual foram obtidas instâncias através de um construtor new Numero(5), que inicializa um atributo int n, até ser invocado o método dobro(), que então retorna o dobro do valor inicializado. O MMC de dois números também pode ser portado para POO, no mesmo sentido: cria-se uma classe que represente números, instancia e inicializa o objeto com 2 números e então invoca o método mmc(). Não será explicado agora, mas considere um desafio, se conseguires realizá-lo então estás a meio caminho andado.

1.4 Subprocedimentos

Procedimentos isolados não servem para construir um programa completo, normalmente os procedimentos (e vale o mesmo depois para os objetos) são compostos para resolver determinados problemas, buscando o reuso de algoritmos comuns e separação de responsabilidades em procedimentos menores.

Considere de volta o MMC e agora também o MDC. O MMC pode ser calculado a partir do MDC com a seguinte expressão mmc(a, b) = a / mdc(a, b) * b. No final podemos fazer o MMC composto pelo subprocedimento mdc(int,int):int, que abre outra oportunidade para um subprocedimento de seleção do menor número menor(int,int):int. A seguir o código que demonstra essa utilização de subprocedimentos:

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);
  }
}
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:

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

1.5 Procedimentos e Dados Estruturados

Ao contrário dos exemplos anteriores, os procedimentos trabalham mais com informações elaboradas do que com dados isolados. Na prática, quer dizer que os procedimentos (e depois objetos e seus métodos) raramente processarão uma variável numérica ou textual simples, mas sim um agrupamento de variáveis. Por exemplo, um cálculo de ICMS seria feito com a Nota Fiscal inteira e não somente com o valor final. Por isso, há a necessidade de agrupar dados para definir uma informação autocontida.

As linguagens de programação disponibilizam estruturas para conter dados relacionados. A estrutura de dados mais popular é o array (arranjo, em português), que também é conhecida como vetor (ou matrix quando é bidimensional). Arrays estão disponíveis em todas linguagens de programação populares, com poucas diferenças de sintaxe, permitindo guardar valores e recuperá-los através de um índice numérico. Existem, portanto, diversas estruturas de dados, como list (lista), set (conjunto), graph (grafo), tuple (tupla), struct (conhecida como registro), associative array (vetor associativo), class (classe, fundamental para a POO e vista no restante do livro), etc.

Para os exemplos deste capítulo vamos usar vetores como estrutura de dados. Se tu ainda não conheces, vetor é uma estrutura e um tipo, que pode ser declarado e atribuído a variáveis, permitindo armazenar valores conforme um índice numérico. Para entender melhor, considere o exemplo:

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

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]);
  }
}

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

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 }

Este último código de exemplo, além de testar o nosso procedimento “bobo” formataData, apresenta detalhes básicos importantes para lidar com vetores. Nas linhas 10 e 11 os vetores são declarados e inicializados com os valores inteiros literais. Na linha 18 o vetor é criado e inicializado ao mesmo tempo que é passado como argumento para o procedimento.

Agora vamos a um exemplo mais elaborado. Além disso, para avançar um pouco mais na programação modular, vamos separar o corpo principal do programa (o main) da unidade com os procedimentos: Data.formataData e Data.somaDias(int[], int):int[] dentro de um módulo Data (class Data em Java).

Neste estudo de caso, vamos projetar um procedimento para adicionar dias a uma data. O procedimento recebe um vetor com a data e a quantidade de dias em inteiro e então devolve outro vetor com o resultado, segundo essa assinatura: somaDias(int[], int):int[]. Não é uma lógica simples ou trivial, trabalhar com datas é sempre desafiador, pois as regras não têm um padrão simples: alguns meses têm 30 dias, outros têm 31, um tem 28 ou 29 nos anos bissextos. Entretanto, o programador pode usar a estratégia de reduzir o problema em parte menores e mais simples de serem resolvidas. Essa estratégia é conhecida como Computational Thinking (Pensamento Computacional) e consiste em analisar uma instância do problema, reconhecer um padrão, decompor o problema, analisar solucionar e solucionar as partes até projetar uma solução genérica para o problema inteiro. Começando com um esboço:

// arquivo Data.java
public class Data {
  static int[] somaDias(int[] data, int dias) {
    int[] resultado = new int[3];
    // aqui 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 objetos4. Projetar um código longo e que cumpra tantas regras pode levar um tempo, então, em vez disso, podemos começar decompondo o problema em um subproblema mais simples e que traz um resultado mais rápido. Considere um procedimento para descobrir o próximo dia de uma data: Data.amanha(int[]):int[] - que é uma simplificação de soma dias para soma 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   }

Este exemplo demonstra a estratégia de decomposição do problema em partes menores que ajudam na solução do problema maior. A simplicidade em saltar um dia é visível nas expressões condicionais entre as linhas 10 e 19. É possível escrever estas regras de várias formas, a apresentada foi um exemplo didático. O ponto alto do exemplo está entre as linhas 25 e 27, que utiliza o amanha como um subprocedimento de somaDias, executado n (ou dias) vezes. Embora tenha passado nos Casos de Teste, ainda há um bug eminente de uma regra não implementada para ver no exercício a seguir:

Representação Textual (char e String)

Fechando este tópico, mais uma coisa a considerar: a representação textual. As linguagens em geral disponibilizam o tipo string, representado literalmente por caracteres entre aspas, por exemplo "#poocomhonra". As strings, embora fundamentais nas linguagens, não são elementares, mas sim compostas por caracteres individuais (em Java um char) e podem ser vistas como um vetor de caracteres. Assim, elas compartilham as propriedades dos vetores, como posição e tamanho, conforme exemplo a seguir:

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   }

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

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"));
  }
}

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 }

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

1.6 “Procedimentos Orientados a Objetos?!”

É possível implementar procedimentos que recebam objetos, já que objetos são Estruturas de Dados e podem substituir os vetores nessa tarefa. No fim, é um misto: usa-se o procedimento para codificar a lógica e o objeto para carregar os dados. Considere o exemplo da data revisitado, agora com objetos, a seguir:

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

Neste exemplo, o módulo DataMisto e o procedimento amanha agem sobre DadosData, que é um substituto do vetor, servindo para “transportar” os dados _dia, mês e ano__.

1.7 Aderindo à Programação Orientada a Objetos com Responsabilidade

Um característica interessante na programação e desenvolvimento de software é a possibilidade de resolver o mesmo problema de várias maneiras diferentes, com códigos diferentes, estruturas diferentes, enfim, programar é uma atividade criativa. Quando se fala em paradigmas, cada um orienta, influencia e impacta fortemente no código resultante, mas para usá-los corretamente o programador deve “chavear” seu modo de pensar, pois o entendimento do paradigma é fundamental para escrever um código sólido e uma solução adequada.

Importante, escolher um paradigma é também abraçar os conceitos e diretrizes presentes nele, projetando seus programas segundo os construtos e ideias (e ideais) principalmente. Por exemplo, aderir a Programação Guiada por Eventos, implica em especificar o fluxo do programa reagindo a eventos, assim como aderir à Programação Funcional implica em encapsular a lógica do programa como funções sem estado.

Programar aderindo ao paradigma Orientado a Objetos implica em seguir as orientações e manter o código consistente com os conceitos básicos e fundamentais, tais como o que são objetos e como se escreve um programa com eles. A maioria das linguagens, no entanto, não “obrigam” os programadores a seguir esses conceitos. Na prática, as linguagens modernas juntam conceitos e técnicas disponíveis de vários paradigmas, por exemplo, Ruby é une programação estruturada, procedimental, orientada a objetos e tem alguns aspectos funcionais e reflexivos.

Então, como Programar Orientado a Objetos do jeito certo?* Basta seguir os conceitos do paradigma. No entanto, não quer dizer que, às vezes, não se possa usar outra abordagem, graças às linguagens multiparadigma isto não é nenhum motivo de embaraçamento. O que não se deve, de verdade, é violar os princípios de OO e afirmar, ainda, que o programa é OO - o pior enganador é o que engana a si mesmo. Por exemplo, um dos princípios mais básicos de OO é manter as estruturas de dados e algoritmos no mesmo módulo (neste caso, na mesma classe). Na prática, significa colocar no mesmo escopo os atributos e os métodos. Como exemplo, considere novamente o exemplo da data adequadamente orientado a objetos:

A Data Orientada a Objetos
 1 public class POOComHonra {
 2   public static void main(String[] args) {
 3     Data umaData = new Data();
 4     umaData.dia = 31;
 5     umaData.mes = 7;
 6     umaData.ano = 2016;
 7     Data resultado = umaData.amanha();
 8     System.out.println(resultado.dia == 1);
 9     System.out.println(resultado.mes == 8);
10     System.out.println(resultado.ano == 2016);
11   }
12 }
13 
14 class Data {
15   int dia, mes, ano;
16   static final int[]
17       diasMes = {31, 28, 31, 30, 31, 30,
18                  31, 31, 30, 31, 30, 31};
19 
20   Data amanha() {
21     Data amanha = new Data();
22     amanha.dia = this.dia + 1;
23     amanha.mes = this.mes;
24     amanha.ano = this.ano;
25     if (amanha.dia > diasMes[amanha.mes - 1]) {
26       amanha.dia = 1;
27       if (amanha.mes < 12) {
28         amanha.mes = amanha.mes + 1;
29       } else {
30         amanha.mes = 1;
31         amanha.ano = amanha.ano + 1;
32       }
33     }
34     return amanha;
35   }
36 }

Esta classe honra o princípio mais básico de acomodar dados e algoritmos na mesma unidade*. Os dados são representados por atributos da classe (linha 15) e o algoritmo no método amanha (linha 20) que não é mais estático (o que se equipara a um procedimento), em vez disso ele é uma operação do objeto Data (métodos não-estáticos são operações/ações dos objetos). Outro detalhe importante está entre as linhas 22 a 24, que é o acesso aos atributos através do this (este objeto).

1.8 Considerações

Se tem muito a discutir ainda, a rigor, a classe Data implementada anteriormente não está de acordo com vários outros conceitos essenciais de orientação a objetos, como o encapsulamento e validade do estado. No entanto, capítulo a capítulo estes conceitos e princípios serão trazidos e os exemplos enriquecidos até alcançar um nível bem alto de qualidade.

2. Uma curta história da Programação Orientada a Objetos

“Quando não tínhamos computadores, também não tínhamos problemas de programação.”

Edsger W. Dijkstra

As primeiras bases da Programação Orientada a Objetos (POO) apareceram no início dos anos sessenta, mas não todas de uma vez só. A POO foi o resultado de algumas ideias reunidas a partir das pesquisas realizadas no Massachusetts Institute of Technology (MIT), no Norwegian Computing Center (NR), e outros estudos mais práticos no Xerox Palo Alto Research Center (Xerox PARC), Bell Labs e Sun Microsystems. Cada uma dessas iniciativas serviu para moldar a POO como conhecemos hoje. Os principais marcos tecnológicos que contribuíram para o desenvolvimento do paradigma orientado a objetos são apresentados nos tópicos a seguir.

2.1 LISP: átomos

O termo objeto foi mencionado no MIT como sinônimo de átomo, que é um dos dois tipos de dados disponíveis na linguagem de programação LISP junto com as listas. Os átomos (ou objetos) possuem propriedades (ou atributos) que podem ser outros átomos. A linguagem LISP foi uma ferramenta essencial para a pesquisa no trabalho pioneiro desenvolvido no MIT’s Artificial Intelligence Lab no fim da década de cinquenta e início de sessenta. Ela foi usada para processar dados simbólicos, conforme foi projetada por John McCarthy e implementada por Steve Russel. É importante dizer que o próprio John McCarthy cunhou o termo Inteligência Artificial (IA). LISP desempenhou um papel importante nas origens do conceito de orientação a objetos, pensando em pequenos e inseparáveis pedaços de dados e lógica que contém e processam mais pedaços de dados e lógica.

2.2 Sketchpad: mestre e definição

Os termos objeto e instância também foram mencionados na MIT PhD Thesis de Ivan Sutherland entitulada Sketchpad: A man-machine graphical communications system, onde ele apresentou os resultados do seu trabalho seminal com interfaces gráficas e formas inovadoras de interação, ainda no início dos anos sessenta. Os termos objeto e instância eram usados alternativamente e com o mesmo significado dos termos principais chamados mestre e definição, que eram respectivamente os nomes conceituais dados à ideação de formas geométricas e suas subsequentes cópias concretas. Nos capítulos seguintes, será visto que essas noções de uma ideia abstrata e suas representações concretas estão alinhadas com as noções atuais de classes e instâncias. Dito isso, o Sketchpad de Sutherland foi um passo essencial em direção ao amadurecimento das ideias de orientação a objetos.

2.3 ALGOL: procedimentos + estruturas de dados

A linguagem de programação ALGOL tinha tido várias versões quando, durante seu desenvolvimento nos anos sessenta, houveram os primeiros esforços para juntar a lógica dos procedimentos com os dados do programa, declarando ambos dentro da mesma unidade de código. Essa funcionalidade foi a predecessora da ideia dos métodos dos objetos como conhecemos hoje, que são funções membros das estruturas de dados. ALGOL influenciou várias linguagens de programação subsequentemente, entre elas Pascal e C, mas foi com Simula 67 que a POO deu um salto.

2.4 Simula: classes, subclasses e herança

Simula 67 foi a primeira linguagem de programação a apresentar os conceitos de classe, subclasse, herança e objetos, juntos. Também conhecida como ALGOL com classes, ela foi projetada pelos noruegueses Kristen Nygaard e Ole-Johan Dahl no Norwegian Computing Center (NR). Naturalmente, Simula carecia de alguns conceitos e princípios que só apareceriam anos depois; contudo, ela influenciou várias linguagens de programação modernas, entre elas as populares C++ e Java, servindo de inspiração como declarado pelos seus respectivos criadores Bjarne Stroustrup e James Gosling. Simula foi um marco, podendo-se dizer que houveram as épocas pré e pós-Simula 67 na linha do tempo da POO.

2.5 Smalltalk: troca de mensagens

A segunda onda na evolução do pensamento orientado a objetos veio com a linguagem de programação Smalltalk. Ela foi projetada por Alan Kay, Dan Ingalls e Adele Goldberg nas instalações do Xerox PARC. Seu projeto foi motivado e influenciado principalmente pelos avanços nas interfaces gráficas e suas ideias de objetos virtuais (widgets), que possuíam algumas propriedades e permitiam algumas ações, semelhante ao Sketchpad de Sutherland. Essa motivação foi liderada por Alan Kay, que previu aplicações educacionais dentro de seu projeto específico chamado The Dynabook, que era um dispositivo para crianças muito semelhante ao tablet que conhecemos hoje.

Smalltalk definiu ou solidificou a maioria dos conceitos e princípios da orientação a objetos conhecidos na atualidade, por exemplo, as noções de classe, objeto, estado e a passagem de mensagem e colaboração entre os objetos para cumprir uma meta. Sobre este último, “passagem de mensagem”, Alan Kay declarou como o princípio central da Programação Orientada a Objetos.

A linguagem de programação Smalltalk não teve profunda penetração de mercado ou o mesmo apelo comercial que as outras linguagens concorrentes tiveram no seu tempo. Ainda assim, ela causou uma evolução e revolução ao mesmo tempo, oxigenando ideias e apresentando um novo modelo mental para a organização dos algoritmos dentro dos programas, liderando um movimento que após seria seguido por outros cientistas da computação e engenheiros de software, sejam teóricos ou praticantes.

Finalmente, Alan Kay cunhou o temo “Object-Oriented” (Orientado a Objetos), inspirando-se em todos os avanços tecnológicos anteriores (Sketchpad, Simula), suas ideações e recursos (“sobre o ombro de gigantes”). Para alcançar esse modelo, sendo um Matemático e Biólogo, imaginou os objetos como sendo pequenos computadores em uma rede compartilhada, cada qual com seu estado e recursos, trocando mensagens e, logo, colaborando para concluir uma tarefa. Ele pensou, também, como se os objetos fossem análogos às células biológicas, que se comunicam entre si e juntas formam tecidos, órgãos e por fim o sistema inteiro. Ambas visões são precisas e um modo bem elegante de definir objetos.

2.6 C++: pondo POO no jogo

A primeira linguagem de programação orientada a objetos usada amplamente foi a C++ (C mais mais ou “C plus plus” em inglês). Ela foi projetada e implementada por Bjarne Stroustrup no Bell Labs (laboratórios Bell) no início dos anos oitenta. Inspirado na linguagem Simula, Bjarne construiu a C++ sobre a já conhecida e estabelecida linguagem C5. Talvez este tenha sido o motivo de por que ela foi (e é) bem sucedida. Aproveitando a sintaxe e construtos básicos da linguagem C, C++ foi até chamada inicialmente de “C com classes” por Bjarne, habilitando a maioria dos conceitos conhecidos de POO tais como abstração, herança, polimorfismo e encapsulamento. Ela é usada, por quase quatro décadas, para desenvolver sistemas operacionais, jogos, drivers, firmwares, utilitários de sistema e uma ampla variedade de softwares.

2.7 Java: POO para as massas

No início dos anos noventa, uma equipe trabalhando na Sun Microsystems (hoje Oracle), liderada por James Gosling, estava trabalhando em uma linguagem de programação orientada a objetos que, apesar da inspiração em Smalltalk e C++, fosse mais “versátil”. Seu nome seria Oak (carvalho), depois Greentralk, mas por fim ficou conhecida como Java (em homenagem ao famoso café indonésio produzido na ilha de Java).

Inicialmente, Java foi projetada para ser usada em uma variedade de set-top boxes (tipo um aparelhinho de TV a cabo da época). Assim, ela foi pensada para ser portável, usando a mesma base de código e executando em qualquer dispositivo que tivesse um subsistema conhecido como Java Virtual Machine (JVM), que é responsável por “reproduzir” os programas. Ela foi lançada em 1995 e tornou-se rapidamente a mais influente e popular linguagem de programação orientada a objetos que já existiu devido, oportunamente, ao rápido crescimento da Internet e dos aplicativos web.

Java foi responsável pela difusão do paradigma de POO, sendo consistente com linguagens de modelagem visual, como a Unified Modelling Language (UML), e razoavelmente simples se comparada com suas predecessoras C++ e Smalltalk (mas não com suas sucessoras), sendo que não possui herança múltipla (recurso alegando como sendo uma significante fonte de problemas) e possui gerenciamento automático de memória alocada realizado pelo seu Garbage Collector (coletor de lixo).

Finalmente, Java inspirou muitas outras linguagens de programação modernas no seu “jeito de fazer POO”, como PHP, C#, ECMAScript, Scala, Kotlin e outras. Ela também passou a ser usada em boa parte das instituições educacionais como primeira linguagem e para o ensino de POO. Hoje em dia, Java é usada em aplicativo corporativos de larga escala (plataforma Java EE), além de ser a linguagem primária para o desenvolvimento de aplicativos para smartphones e embarcados na plataforma Android da Google.

2.8 A supremacia Orientada-a-Objetos

Olhando para os índices e histórico de popularidade das linguagens de programação, agora (Dezembro/2020), as orientadas a objetos estão sempre no topo. O PYPL (ver ilustração a seguir) mostra Python, Java, JavaScript, C#, C/C++, PHP, R, Objective C, Swift e Matlab no top ten. Nas posições inferiores, nos índices PYPL e Tiobe, existem outras como Visual Basic.NET, Ruby, TypeScript, Perl, Scala, Kotlin, Golang, onde todas, em maior ou menor escala, suportam o paradigma orientado a objetos.

PYPL, Dezembro de 2020
PYPL, Dezembro de 2020

É essencial mencionar que, mesmo com esse volume de linguagens de programação, elas não tratam a orientação a objetos da mesma maneira. Uma parte considerável delas são class-based (baseadas em classes), isto é, os objetos são criados usando templates (modelos), conhecidos como classes, de onde eles são instanciados. Esse é o caso da Java, PHP, C++, Python e Ruby, por exemplo. Por outro lado, existem as prototype-based (baseadas em protótipos), isto é, as classes não são a estrutura primária e qualquer objeto anteriormente declarado pode servir como protótipo (modelo) para novos objetos. Esse é o caso das linguagens JavaScript, Lua e Self, por exemplo. Ainda, existem linguagens que suportam orientação a objetos, mas que não são estritamente baseadas nem em classes ou protótipos, como Golang.

Apesar da sua fan-base, as linguagens prototype-based não são tão populares quanto as class-based. Mesmo JavaScript, a linguagem prototype-based mais bem sucedida, também oferece construtos na forma de classes, tentando agradar programadores(as) das “duas escolas”.

2.9 Considerações

Os conceitos e princípios da programação orientada a objetos se aplicam mais ou menos do mesmo modo nas linguagens C++, Java, C#, Objective-C, Swift e PHP, entre outras. Eles também são aplicáveis, com algumas adaptações aqui e ali, em JavaScript, Python, Ruby e Golang, por exemplo.

Este livro não é sobre uma linguagem de programação. Em vez disso, a meta é a compreensão e a confiança na implementação dos conceitos e princípios da POO em qualquer que seja a linguagem de programação que os suportem. Em outras palavras, é mais valorizado o pensamento orientado a objetos do que o conhecimento específico sobre sintaxes e bibliotecas disponíveis nas linguagens.

Assim, cada capítulo e tópico seguinte discorrerá sobre conceitos e princípios que podem ser escritos em (quase) qualquer linguagem de programação. Há exemplos de código primariamente em Java e eventualmente podem ser apresentados códigos em outras linguagens, seja no livro ou no repositório. Como última sugestão, se tu não tens muita experiência com qualquer linguagem, escolha uma e use-a para aprender orientação a objetos.

Espero que aprecies a experiência e aprendizado.

2.10 Exercícios

3. Modelo de Objetos

“De certo modo, programar é como pintar. Você começa com uma tela em branco e certas matérias-primas bem básicas. Você usa uma combinação de ciência, arte e habilidades de ofício para determinar o que fazer com elas.”

Andrew Hunt

Existe um entendimento compartilhado nas comunidade de engenharia de softwares de quais seriam os conceitos e princípios centrais da programação orientada a objetos. Entre as palavras-chave frequentes que emergem dessa comunidade estão: abstração, encapsulamento, polimorfismo, passagem de mensagens, herança, classes, tipos, instâncias, modularidade. Esses e outros termos seriam parte do modelo de objetos, isto é, configuram os tópicos essenciais a estudar e dominar a fim de declarar-se competente na programação orientada a objetos. Entretanto, existem diferentes “caminhos”, em que esses assuntos podem se encaixar ou não (ou sim, com alguns ajustes). Dito isso, é preciso traçar uma rota.

Como mencionado no capítulo anterior, algumas linguagens (como JavaScript e Self) os objetos são projetados pela declaração de protótipos, enquanto em outras (como Java e PHP) eles são declarados como classes. Ainda, outras linguagens (como Simula e C++) tratam os objetos como pertencentes a um tipo, isto é, os objetos são tipificados, pertencem a um tipo particular. Em outras linguagens os objetos são classificados, isto é, pertencem a uma classe particular.

Assim, neste livro optei pela abordagem class-based. Isto é, os conceitos e princípios da POO são estreitados àqueles presentes nas linguagens de programação que descrevem os objetos através da classificação. Contudo, é possível o entendimento e aplicação da maioria dos conceitos e princípios também nas linguagens type-based e prototype-based, sendo que elas compartilham muitas características.

Esse capítulo abrange os conceitos principais para um modelo de objetos: abstração, classes, atributos, objetos e inicialização (construção).

3.1 Abstração: a arte da redução da representação

A POO ajuda a descrever os objetos do “mundo real” começando por elencar suas características e operações essenciais. Após isso, o objeto é classificado, isto é, as características e operações elencadas são codificadas, respectivamente, como atributos e métodos em uma unidade chamada classe.

Objetos reais, sejam tangíveis como uma laranja (a fruta) ou intangíveis como a dívida no cartão de crédito, devem ser descritos com detalhes mínimos em vez de nos mínimos detalhes. De fato, apenas poucos atributos e métodos são selecionados para formar uma classe. Vários outros detalhes são ignorados ou movidos para outras unidades (classes), dada sua irrelevância para a solução do problema pontual. Este ato é chamado de abstração.

A abstração é um dos conceitos centrais da POO. Sem ela, seria impossível classificar qualquer coisa. Por exemplo, considere que tenhamos de classificar copos (de beber). A primeira questão “quais são as características essenciais de um copo?” leva a outras questões, como “ele é feito de vidro? porcelana? plástico? de qual material ele é feito? quanto líquido em tem quando cheio? qual sua capacidade?”. Pensando um pouco mais chegamos em mais questões: “ele é redondo? qual é o diâmetro? se não, qual é o perímetro?. Mais raciocínio sobre o copo leva aos mínimos detalhes, como decoração, cor, medida do gargalo, peso e até questões existenciais, linguísticas e metafísicas do copo (como se os copos com alça mantém sua “copocidade” ou se tornam canecas).

Então, sabemos que não podemos classificar um copo átomo por átomo, no espaço e no tempo. Logo, a capacidade de abstração desempenha um papel essencial na classificação dos objetos. A meta, portanto, é projetar um simulacro bom o suficiente, sem preocupar-se com as pequenas imprecisões.

Dito isso, o conceito e técnica de abstração é profundamente usada neste livro. Até porque os exemplos devem ser sucintos para caber nele. Então, sempre que avistares um objeto supersimplificado, foi de propósito.

3.2 Classes: o gabarito para os objetos

Comecemos com um exemplo: considere um Livro. Quais são as características relevantes para representar um Livro? Naturalmente, depende de duas coisas: do contexto e do observador. O ponto de vista seria diferente dependendo da pessoa (física ou jurídica), se: um leitor, um bibliotecário, a editora ou a livraria.

Mantendo simples, considere que um Livro tem um título, um autor e uma quantidade de páginas. O sujeito da classificação é o substantivo Livro. Os atributos são extraídos dos adjetivos (ex.: título). Com esse dados, é possível classificar e representar vários livros. O código resultante varia de uma linguagem de programação para outra. Então considere o seguinte pseudocódigo em português estruturado:

Pseudocódigo da classificação de Livro
// Livro.pseudocódigo
classe Livro
  atributo Titulo : textual
  atributo Autor : textual
  atributo Paginas : númerico
fim classe

// App.pseudocódigo
usando Livro de Livro.pseudocódigo
procedimento App
  um_livro = inicializa um Livro
  um_livro.Titulo = "Eu Robô"
  um_livro.Autor = "Asimov, Isaac"
  um_livro.Paginas = 256

  outro_livro = inicializa um novo Livro
  outro_livro.Titulo = "Neuromancer"
  outro_livro.Autor = "Gibson, William"
  outro_livro.Paginas = 271
fim procedimento

Esse pseudocódigo não está muito longe do código real, segundo a sintaxe das linguagens orientadas a objetos mais populares. Primeiro, define-se uma classe e, então, define-se os atributos. Essa classe pode ser usada/importada/incluída em outros arquivos/módulos. Ela pode ser inicializada, construindo (guarde essa palavra) um objeto a partir dela, que é preenchido com os valores específicos da instância. (Quase) Infinitos livros individuais podem ser instanciados a partir do gabarito: a classe Livro. A seguir, o mesmo modelo é implementado em três linguagens de programação diferentes:

Classificação de Livro na linguagem Java
// Livro.java
class Livro { // declaração da classe
  String titulo = ""; // atributo textual
  String autor = "";
  int paginas = 0; // atributo numérico inteiro
} // fim da classe
// App.java
class App {
  // procedimento principal
  public static void main(String[] args) {
    Livro um_livro = new Livro(); // construindo um novo Livro
    um_livro.titulo = "I Robot";
    um_livro.autor = "Asimov, Isaac";
    um_livro.paginas = 256;

    Livro outro_livro = new Livro(); // construindo outro novo Livro
    outro_livro.titulo = "Neuromancer";
    outro_livro.autor = "Gibson, William";
    outro_livro.paginas = 271;

    System.out.println(um_livro.titulo);
    System.out.println(outro_livro.titulo);
  }
}
// executar com o comando: javac App.java ; java App
Classificação de Livro na linguagem JavaScript
// livro.js
class Livro { // declaração da classe
  construct() {
    this.title = ""; // atributo textual
    this.author = "";
    this.pageCount = 0; // atributo numérico
  }
} // fim da classe
module.exports = Livro

// app.js
const Livro = require("./livro.js");
// procedimento principal
let um_livro = new Livro(); // construindo um novo Livro
um_livro.titulo = "Eu Robô";
um_livro.autor = "Asimov, Isaac";
um_livro.paginas = 256;

let outro_livro = new Livro(); // construindo outro Livro
outro_livro.titulo = "Neuromancer";
outro_livro.uthor = "Gibson, William";
outro_livro.paginas = 271;

console.log(um_livro.titulo);
console.log(outro_livro.titulo);
// executar com o comando: node app.js
Classificação de Livro na linguagem Python
# livro.py
class Livro: # declaração da classe
  def __init__(self):
    self.titulo  = "" # atributo textual
    self.autor   = ""
    self.paginas = 0 # atributo numérico

# app.py
from livro import Livro

um_livro = Livro() # construindo um novo Livro
um_livro.titulo = "Eu Robô"
um_livro.autor = "Asimov, Isaac"
um_livro.paginas = 256

outro_livro = Livro() # construindo mais um Livro
outro_livro.titulo = "Neuromancer"
outro_livro.autor = "Gibson, William"
outro_livro.paginas = 271

print(um_livro.titulo)
print(outro_livro.titulo)

Existem diferenças sutis entre uma linguagem e outra. Sobretudo se a sintaxe for desconsiderada. O modelo, claro, foi supersimplificado. Se a meta fosse desenvolver um comércio eletrônico (e-commerce), estariam faltando diversas características importantes para a venda dos livros, tais como: edição, ISBN, data de publicação, e até mesmo o peso do exemplar que é usado para o cálculo do frete.

3.3 Objetos: instâncias de uma classe

Ao fim deste tópico, certifique-se de que a diferença e a relação entre classes e objetos estejam bem claras na tua mente. Isso é essencial.

Retomando o exemplo do Livro, a nossa classificação descreve as características que esperamos estar presentes, que representam um livro em particular, no caso: titulo, autor e páginas. A classe é um gabarito, um modelo (um template como dizem em Inglês), que será usado para garantir certas funcionalidades às instâncias, que são os objetos em si. Tecnicamente, a instanciação de uma classe resulta em um novo objeto, e essa ação pode ser repetida inúmeras vezes gerando inúmeros objetos (instâncias) que seguem o mesmo gabarito. No exemplo anterior, todas as instâncias de Livro terão os atributos titulo, autor e páginas.

Para refinar esse entendimento, podemos revisitar o primeiro exemplo do capítulo: o Copo. Vamos abstrair rigorosamente, mantendo apenas duas características relevantes: o material de que é feito e a capacidade em mililitros. Segue pseudocódigo de Copo:

Classificação de Copo em pseudocódigo
// Copo.pseudocódigo
classe Copo
  atributo material : textual
  atributo mililitros : numérico
fim classe

Com essa noção, é possível implementar em qualquer linguagem moderna que suporte classes.

Classificação de Copo em Java
// Copo.java
class Copo {
  String material   = "";
  int    mililitros = 0;
}

A partir dessa classe podemos construir (instanciar) os copos:

// App.java
class App { // java exige o método main envolto em uma classe
  public static void main(String[] args) {
    Copo copo1 = new Copo(); // "Copo()" é o construtor de Copo
    copo1.material = "Plástico"; // o "." é usado para ler/alterar um atributo
    copo1.mililitros = 300;
    System.out.println("Copo de " + copo1.material + "/"
      + copo1.mililitros + "ml"); // Copo de Plástico/300ml

    Copo copo2 = new Copo();
    copo2.material = "Vidro";
    copo2.mililitros = 250;
    System.out.println("Copo de " + copo2.material + "/"
      + copo2.mililitros + "ml"); // Copo de Vidro 250ml
  }
}

Conforme o exemplo, foram construídos (instanciados) dois copos, armazenados nas variáveis copo1 e copo2 (referências). Como especificado na classe, os copos têm dois atributos: material e mililitros. Por enquanto, tu podes pensar no objeto como um agrupamento de dados. Porém, mais tarde, veremos que os objetos agrupam também os algoritmos (funções ou métodos).

Sempre que ver o nome da classe à direita de uma variável, um objeto está sendo construído. Se houver uma class Cartao, seus objetos são construídos no código com new Cartao() (Java e JS), Cartao.new (Ruby), ou apenas Cartao() (Python).

O mesmo exemplo escrito em JavaScript, Python, PHP e C# pode ser visto na pasta src/2.2-objetos.

3.4 Atributos: as “qualidades” do objeto

Nos exemplos anteriores eu classifiquei Livro e Copo, com poucas características. Para Copo foram definidos material e mililitros, e para Livro foram definidos titulo, autor e paginas. Essas características são chamadas de atributos. Assim, dizemos que um Livro tem um atributo titulo e que um Copo tem um atributo material.

Os atributos têm seus tipos pré-definidos nas linguagens de tipagem estática, como Java e C# por exemplo. Essas linguagens disponibilizam os tipos básicos, sendo comuns: int e long para números inteiros, double e float para números reais, boolean (bool em C#) para booleanos, char para um (único) caractere e String para cadeias de caracteres (dados textuais). No exemplo do Livro, enquanto o atributo titulo é do tipo String, isto é, textual, o atributo paginas é do tipo int, isto é, um número inteiro.

Nas linguagens de tipagem dinâmica (ou não-tipadas), os atributos podem armazenar valores de qualquer tipo e não apenas aquele declarado na classe. Por exemplo, embora você declare self.material = "" em Python, isto só quer dizer que ele é inicializado como textual, mas não há restrição de atribuição de valores de outros tipos nas instâncias (como livro1.material = 9 é ok). Outra diferença notável nas linguagens dinâmicas é que mais atributos podem ser adicionados diretamente aos objetos, mesmo sem serem predefinidos nas classe. Por exemplo, seria possível declarar livro1.editora = "Uma editora" em JS, Python e PHP, mesmo que o atributo editora não tenha sido pré-definido, ou seja, os atributos e seus tipos podem ser pós-definidos nas linguagens dinâmicas.

Ditas essas nuances de implementação, o(a) programador(a) deve decidir (projetar) quais atributos são relevantes para classificar um objeto e a restrição de tipo pretendida (mesmo nas dinâmicas, você não vai querer um Livro com a editora "123" ou "asdfg" páginas). O detalhamento pode variar. Então, normalmente o problema é colocado em um contexto. Por exemplo, considere a classificação de Gato (o bicho, não o furto de energia elétrica) no contexto de uma Clínica Veterinária, e pense quais atributos seriam necessários. Eu pensei em “idade, peso, sexo, nome e dono”. Talvez você tenha pensado em mais ou diferentes características, não tem problema. A seguir um código que compila perfeitamente em Java:

// Gato.java
class Gato {
  int    idade;
  int    peso;
  char   sexo;
  String nome;
  String dono;
}

Essa classificação funciona, mas pode melhorar bastante. Antes de ler o restante deste parágrafo, dedique um minuto para ler o código novamente e prever as possíveis más interpretações. É difícil perceber problemas na especificação. Às vezes é melhor ver essa classificação sendo instanciada:

// App.java
class App {
  public static void main(String[] args) {
    Gato g  = new Gato();
    g.nome = "Fito";
    g.idade = 2;
    g.peso  = 2;
    g.sexo  = 'M';
    g.dono  = "Márcio Torres";
  }
}

A instância demonstrada no exemplo apresenta algumas ambiguidades. Por exemplo, qual é a idade do gato? 2 meses ou 2 anos? quanto pesa o gato? deve ser 2kg, pois seria absurdo se fossem 2g, certo? mas e se o gato tem 700g? Dadas essas perguntas, podemos chegar em classificações um pouco melhores, ao esclarecer esses dois atributos. A seguir estão duas opções:

// Gato1.java
class Gato {
  int    idade; // valor e unidade em dois atributos
  String idadeUnidade; // semanas, meses, anos, ...
  int    peso;
  String pesoUnidade; // g, kg, hg, ...
  char   sexo;
  String nome;
  String dono;
}
// Gato2.java
class Gato {
  int    anos; // codificando a unidade no nome do atributo
  double kilogramas; // double para permitir decimais, como 0.7kg == 700g
  char   sexo;
  String nome;
  String dono;
}

Existem várias formas de eliminar ambiguidades e mitigar as possibilidades de más interpretações do modelo. As mudanças nos exemplos anteriores, ressignificando os atributos, são soluções viáveis, mas não as melhores. Uma outra abordagem é de ver idade e peso como objetos por si só, digo, classificá-los (ex.: class Peso { ...) e associá-los à classe Gato, o que será visto no Capítulo 11: Associação.

3.5 Inicialização: construção de objetos

Neste ponto, sabe-se que as características dos objetos são especificadas nas classes na forma de atributos. O próximo passo é a especificação de quais características são essenciais. Por exemplo, informações textuais que não devem estar “em branco” ou numéricas que não devem estar zeradas. Pense nos dados imprescindíveis do objeto. Em outras palavras, é a definição de quais atributos são obrigatórios e quais são suas restrições.

Nas linguagens tipadas (Java, C#, C++), já há a restrição de tipo, por exemplo, int idade deve permitir apenas números inteiros. Isto é, não entrarão dados como 5.5 ou "7 anos" ou "adsasdf". Entretanto, ainda são necessárias outras restrições, que evitem, por exemplo, valores como -1, que é válido para um int, mas não para “representar” uma idade (-1 anos?). Essas restrições e como impô-las será visto no próximo capítulo em Validade do Estado.

Neste tópico vamos usar blocos conhecidos como inicializadores ou construtores para especificar uma obrigatoriedade de informação. De volta ao exemplo do Copo, que possui material e capacidade em mililitros, considere que ambas informações são obrigatórias na instanciação do Copo e não podem ficar em branco.

Para cumprir essa funcionalidade devemos adicionar um construtor à classe Copo, exigindo como parâmetros as informações necessárias para popular os atributos. Os construtores variam de linguagem para linguagem. A seguir um pseudocódigo demonstrando uma classe com construtor:

// Copo.pseudocódigo
classe Copo

  atributo material : textual
  atributo mililitros : numérico

  construtor (parâmetro material, parâmetro mililitros)
    atributo material = parâmetro material
    atributo mililitros = parâmetro mililitros
  fim construtor

fim classe

Em Java, os construtores têm o mesmo nome da classe, como a seguir:

// Copo.java
class Copo {
  String material;   // este é o   this.material
  int    mililitros; // e este é o this.mililitros
  // construtor exigindo material e mililitros na forma de parâmetros
  Copo(String material, int mililitros) {
    // repassando os valores recebidos nos parâmetros aos
    // atributos do objeto no formato: this.atributo = parametro;
    this.material   = material;
    this.mililitros = mililitros;
    // this.material é o atributo e material é o parâmetro
  }
}

Essa classificação define a inicialização obrigatória de Copo passando obrigatoriamente ambos parâmetros material e mililitros, nesta ordem e com os tipos determinados, por exemplo:

// javac App.java; java App
class App {
  public static void main(String[] args) {
    Copo copo1 = new Copo("Plástico", 300);
    System.out.println(copo1.material); // Plástico
    System.out.println(copo1.mililitros); // 300

    Copo copo2 = new Copo("Vidro", 250);
    System.out.println(copo2.material); // Vidro
    System.out.println(copo2.mililitros); // 250
    // as seguintes instruções falham (não compilam)
    // Copo copo3 = new Copo(); // falha por não passar os argumentos
    // Copo copo4 = new Copo("Louça"); // falha por não passar o argumento de mililitros
    // Copo copo5 = new Copo(300, "Vidro"); // falha por não respeitar a ordem
  }
}

Encerrando o tópico, é importante mencionar que os construtores, além de determinar obrigatoriedade, também simplificam a inicialização dos objetos.

3.6 Considerações

Alguns pontos-chave desse capítulo que devem ser lembrados:

  • Objetos têm características;
  • Para especificá-las nós classificamos os objetos;
  • A classificação é feita com a declaração de class NomeClasse na maioria linguagens;
  • Vários objetos podem ser instanciados a partir da mesma classe;
  • Todos os objetos terão os mesmos atributos definidos na classe;
  • Contudo, cada objeto terá valores específicos para estes atributos;
  • Declaramos construtores para garantir que alguns (ou todos) atributos sejam informados;
  • Existem algumas diferenças entre as linguagens de tipagem estática e dinâmica.

Entre as questões que ficaram para serem discutidas adiante destaca-se a validade do objeto. Por exemplo, não é possível construir um Copo() sem informar material e mililitros, mas é possível construir Copo("", 0), que é executável por informar os parâmetros, mas não cria um objeto Copo válido ou útil.

3.7 Exercícios

Implemente Chuveiro

Considere um Chuveiro, classificado segundo sua marca, modelo, tensão (110v ou 220v) e potência (Watts). Escreva a classe Chuveiro que passe nos seguintes testes:

// App.java
Chuveiro chu = new Chuveiro();
chu.marca = "Ducha10";
chu.modelo = "D103500";
chu.potencia = 3500;
System.out.println(chu.marca); // Ducha10
System.out.println(chu.modelo); // D103500
System.out.println(chu.potencia); // 3500
System.out.println(chu.tensao); // 220
chu.tensao = 110;
System.out.println(chu.tensao); // 110
# app.py
from chuveiro import Chuveiro
chu = Chuveiro()
chu.marca = "Ducha10"
chu.modelo = "D103500"
chu.potencia = 3500
print chu.marca # Ducha10
print chu.modelo # D103500
print chu.potencia # 3500
print chu.tensao # 220
chu.tensao = 110
print chu.tensao # 110
// app.js ou app.ts
let chu = new Chuveiro();
chu.marca = "Ducha10";
chu.modelo = "D103500";
chu.potencia = 3500;
console.log(chu.marca); // Ducha10
console.log(chu.modelo); // D103500
console.log(chu.potencia); // 3500
console.log(chu.tensao); // 220
chu.tensao = 110;
console.log(chu.tensao); // 110
// php.js
$chu = new Chuveiro();
$chu->marca = "Ducha10";
$chu->modelo = "D103500";
$chu->potencia = 3500;
print($chu->marca ."\n"); // Ducha10
print($chu->modelo ."\n"); // D103500
print($chu->potencia ."\n"); // 3500
print($chu->tensao ."\n"); // 220
$chu->tensao = 110;
print($chu->tensao ."\n"); // 110
// App.cs
var chu = new Chuveiro();
chu.marca = "Ducha10";
chu.modelo = "D103500";
chu.potencia = 3500;
System.Console.WriteLine(chu.marca); // Ducha10
System.Console.WriteLine(chu.modelo); // D103500
System.Console.WriteLine(chu.potencia); // 3500
System.Console.WriteLine(chu.tensao); // 220
chu.tensao = 110;
System.Console.WriteLine(chu.tensao); // 110

Projete e Implemente a classe de cartões de memória

Considere os detalhes que diferenciam uns cartões de memória de outros e classifique, especificando os atributos e seus tipos (se for o caso). Crie um arquivo principal (App ou Main) e instancie cartões variados a partir da classe.

Escreva um construtor para Chuveiro

Implemente um construtor tornando possível informar os atributos ao instanciar os chuveiros. Considere o seguinte caso de teste:

// App.java
Chuveiro chu = new Chuveiro("Ducha10", "D103500", 3500)
System.out.println(chu.marca); // Ducha10
System.out.println(chu.modelo); // D103500
System.out.println(chu.potencia); // 3500
System.out.println(chu.tensao); // 220
chu.tensao = 110;
System.out.println(chu.tensao); // 110

A mesma estrutura é aplicável às demais linguagens.

Escreva um construtor para cartões de memória

Implemente um construtor na classe de cartões conforme seu projeto e escreva casos de teste.

Considere um Elevador

Considere um Elevador para passageiros em prédios e seus dados básicos:

  • Fabricante e modelo, por exemplo, “ElevaSilva TR5500”;
  • Capacidade em kg e passageiros, por exemplo, “600kg/8 passageiros” ou “900kg/10 passageiros”, sabendo que é considerado 75kg por passageiro;
  • Percurso em metros e paradas, por exemplo, “30m/10 paradas” ou “105m/35 paradas”, sabendo que são necessários 3 metros por parada;
  • Velocidade em metros por segundo, variando de “1.5 m/s” à “3 m/s”.
Projete, especifique e implemente um objeto a sua escolha

Pense em um objeto real, tangível ou ideia, que possa ser projetado e classificado. Procure levantar as características e seus tipos. E não esqueça a abstração.

4. Estado & Validade

“Um bom programador é alguém que sempre olha para os dois lados antes de atravessar uma rua de mão única.”

Doug Linder

Para projetar sistemas confiáveis é importantíssimo controlar o estado dos objetos. É preciso assegurar-se de que os objetos sejam consistentes e continuem válidos durante toda a execução do programa. O estado inválido de um único objeto pode levar todo o sistema ou aplicativo à falhar. Imagine o “estrago” que faria um objeto Hora com número negativo de minutos num app de agendamento. Por esse motivo, deve-se especificar quais características podem mudar, e quais não devem mudar nos objetos. Isto é, definir o estado mutável e o imutável. Também envolve escrever as regras de negócio (aquele punhado de if's, else's, for's, while's, ...) pensando se elas devem alterar estado e, caso sim, com que restrições. Nos tópicos a seguir serão abordadas as principais questões sobre estado, como implementá-lo e controlá-lo com responsabilidade.

4.1 Conceito de Estado do Objeto

Retomando o modelo de objetos, sabe-se que eles têm características codificadas na forma de atributos que guardam valores. Logo, cada objeto (ou instância) possui valores específicos para esses atributos, diferenciando as instâncias umas das outros, mesmo que compartilhem uma mesma classe. Por exemplo, enquanto um instância de Gato pode ser descrita por {nome: "Tom", idade: 6, peso: 4.8}, outra pode ter os valores {nome: "Angela", idade: 5, peso: 3.9}.

O estado é definido como o conjunto de valores armazenados em todos os atributos de um objeto em um instante no tempo. Isto é, o objeto está. Por exemplo, o gato “Tom”, por ora, está com peso: 4.8 e, conhecendo o gato doméstico, logo estará com peso: 5.0.

Tenha em mente que nem sempre o objeto está, às vezes ele é. Quer dizer, nem todos os atributos irão (ou devem) mudar de valor no tempo. Os objetos podem ter estado constante e variável, ao mesmo tempo. Por exemplo, a idade e peso do gato “Tom” pode aumentar enquanto ele mantém o mesmo nome 6. Portanto, durante o projeto da classe é preciso pensar sobre quais atributos devem ser constantes e quais exigem variabilidade. Essas questões são discutidas no tópico seguinte.

4.2 Estado constante e instantâneo

Primeiro, é importante mencionar que nem todas as linguagens de programação permitem declarar um atributo constante. Isso não é possível em JavaScript e Python7, por exemplo. As linguagens projetadas para declarar estado constante o fazem com diferentes palavras-chave (keyword). Por exemplo, em Java se adiciona a keyword final na declaração do atributo, enquanto em C# a keyword é readonly8.

Para entender a necessidade de um atributo imutável é preciso exercitar a classificação de um objeto que possua uma característica constante. Mantendo a linha de exemplos simples, considere uma Caneta. Entre várias informações sobre canetas, vamos abstrair e selecionar apenas duas: cor e carga. Isto é, consideramos o pigmento e o nível da tinta como informações úteis e imprescindíveis para o modelo. Seguindo o modelo de objeto apresentado no capítulo anterior, uma implementação possível está a seguir:

// Caneta.pseudo
classe Caneta
  atributo cor : textual
  atributo carga : numérico
fim classe

// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
  can = nova Caneta
  can.cor = "Azul"
  imprime(can.cor) // "Azul"
  can.cor = "Vermelha";
  imprime(can.cor) // "Vermelha"
fim procedimento

Embora funcional, essa implementação ignora um detalhe importante sobre as canetas. Elas possuem uma certa carga, que decresce com o uso, mas sua cor não muda. Claro, estamos ignorando a questão da troca da carga (abstração). Assim, uma caneta “Azul” será sempre “Azul”, e uma caneta “Vermelha” seria uma nova caneta. Isso pode ser resolvido declarando a cor como constante e adicionando um construtor:

// Caneta.pseudo
classe Caneta

  atributo constante cor : textual
  atributo carga : numérico

  construtor (parâmetro cor, parâmetro carga = 1000)
    atributo cor = parâmetro cor
    atributo carga = parâmetro carga
  fim construtor
fim classe

// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
  can = nova Caneta "Azul"
  imprime(can.cor) // "Azul"
  // can.cor = "Vermelha" // não é possível, cor é constante
  can2 = nova Caneta "Vermelha"
  imprime(can2.cor) // "Vermelha"
fim procedimento

Este exemplo em pseudocódigo sintetiza a ideia de estado constante. É importante frisar que não são proibidas canetas de outras cores, apenas é restrito que a mesma caneta mude de cor.

A seguir como é implementado em Java:

// Caneta.java
class Caneta {
  final String cor; // final é a keyword para constante
  int carga;
  Caneta(String cor) {
    this.cor = cor;
    this.carga = 1000;
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    Caneta can1 = new Caneta("Verde");
    System.out.println(can1.cor); // "Verde"
    // can1.cor = "Azul"; // NÃO COMPILA, a cor é imutável, constante
    Caneta can2 = new Caneta("Azul");
    System.out.println(can2.cor); // "Azul"
    System.out.println(can2.carga); // 1000
    can2.carga = 800; // carga é mutável, instantânea
    System.out.println(can2.carga); // 800
  }
}

O pequeno exemplo anterior demonstra um objeto com dois atributos, onde um é instantâneo (carga) e o outro constante (cor). Ainda assim, a caneta não está livre de “furos”. Não há restrições para o estado. Por exemplo, a instrução can.carga = -200 é válida, compila e executa sem problemas. A instrução new Caneta(""), que instancia uma caneta sem cor, também é válida. Portanto, o próximo passo é definir regras tanto para obter um estado inicial válido quanto fazê-lo permanecer válido o tempo todo.

4.3 Validade do estado, invariantes e consistência

A confiabilidade de um objeto é obtida pela consistência e validade do seu estado. Essa questão pode (deve) ser tratada em dois momentos: (1) na inicialização do objeto, (2) na interação com o objeto.

Todos os atributos imutáveis devem ser inicializados no construtor. É também no construtor onde são validados os atributos mutáveis, para garantir que objeto seja (tenha um estado) válido desde o começo.

Por exemplo, considere uma garrafa térmica. Elas possuem várias características que as diferenciam. Mas, vamos abstrair, desconsiderando a marca, modelo, cor, formato, etc, e considerando apenas duas informações: a capacidade e quantidade de líquido.

Vamos analisar: a capacidade é constante, isto é, uma garrafa com capacidade para 2L sempre terá esta capacidade, ela não está com 2L, ela é de 2L. Por outro lado, a quantidade é variável, instantânea, a garrafa pode estar vazia (0L), com líquido para apenas mais um mate (100mL), ou cheia (2L). O que é invariante na classe das garrafas térmicas? A quantidade nunca é negativa ou maior que a capacidade 9. Além dessa, também vamos adicionar uma restrição de capacidade, mínima é de 100mL e máxima de 5L, para não criar garrafas “bizarras”, como um conta-gotas de 1mL ou caixa d’água de 1000L.

As invariantes de classe são uma coleção de condições predefinidas e servem para restringir e garantir o estado do objeto. A construção e inicialização do objeto e subsequentes operações devem respeitar essas condições.

Vamos completar o exemplo com duas operações (métodos). A primeira é encher. Quanto a segunda, considere que há um botão para servir água na nossa garrafa térmica, digital claro, que dispensa 100mL de água ou emite um beep se estiver vazia. A seguir um pseudocódigo:

// GarrafaTérmica.pseudo
classe GarrafaTérmica

  atributo constante capacidade : numérico
  atributo quantidade : numérico

  construtor (parâmetro capacidade : numérico)
    se parâmetro capacidade < 100 então // regra
      lança a exceção "Capacidade mínima de 100mL"
    fimse

    se parâmetro capacidade > 5000 então // regra
      lança a exceção "Capacidade máxima de 5L"
    fimse

    atributo capacidade = parâmetro capacidade
    atributo quantidade = 0
  fim construtor

  método encher
    atributo quantidade = atributo capacidade
  fimmétodo

  método servir : boolean
    se atributo quantidade >= 100 // regra
      atributo quantidade = atributo quantidade - 100
      retorna Verdadeiro // foi servido
    fimse
    retorna Falso // não foi servido
  fimmétodo
fim classe

// App.pseudo
usando GarrafaTérmica de GarrafaTérmica.pseudo
procedimento App
  chimarrita = nova GarrafaTérmica 1000
  imprime(chimarrita.capacidade) // 1000
  imprime(chimarrita.quantidade) // 0
  chimarrita.encher()
  imprime(chimarrita.quantidade) // 1000
  chimarrita.servir()
  imprime(chimarrita.quantidade) // 900
fim procedimento

Esse programa ficou bem mais longo que os anteriores, mas não se preocupe, eu vou explicar os detalhes. A invariante da classe está comentada como // regra ao longo do código. Como podes ver, elas são pré e pós-condições que se esperam para a validade do objeto durante seu “uso”. Duas coisas são novas: a exceção e o método. As exceções são usadas para interromper o fluxo normal e providenciar informações sobre o motivo dessa interrupção. Neste exemplo, elas são usadas para evitar a instanciação de uma garrafa térmica inválida. As regras no construtor cumprem essa tarefa. Nunca haverão garrafas com capacidades menores que 100mL nem maiores que 5L, e isto é garantido. Os métodos são usados para definir as operações do objeto e intermediar as mudanças de estado. Métodos definem a interação com os objetos. Esses dois conceitos serão discutidos com mais detalhes no Capítulo 4: Comportamento.

O pseudocódigo anterior é implementável em quase qualquer linguagem moderna. A seguir esta lógica está escrita em Java:

// GarrafaTermica.java
class GarrafaTermica {

  final int capacidade;
  int quantidade;

  GarrafaTermica (int capacidade) {
    if (capacidade < 100) { // se inválida, lança exceção
      throw new IllegalArgumentException("Capacidade mínima de 100mL");
    }
    if (capacidade > 5000) { // se inválida, lança exceção
      throw new IllegalArgumentException("Capacidade máxima de 5L");
    }
    this.capacidade = capacidade;
    this.quantidade = 0;
  }

  void encher() { // operação/método encher
    this.quantidade = this.capacidade;
  }

  boolean servir() { // operação/método servir
    if (this.quantidade >= 100) { // é possível servir?
      this.quantidade = this.quantidade - 100;
      return true; // foi servido
    }
    return false; // não foi servido
  }
}

// Main.java
class Main {
  public static void main(String[] args) {
    GarrafaTermica chimarrita = new GarrafaTermica(1000);
    System.out.println(chimarrita.capacidade); // 1000
    System.out.println(chimarrita.quantidade); // 0
    System.out.println(chimarrita.servir()); // false
    chimarrita.encher();
    System.out.println(chimarrita.quantidade); // 1000
    System.out.println(chimarrita.servir()); // true
    System.out.println(chimarrita.quantidade) // 900
    System.out.println(chimarrita.servir()); // true
    System.out.println(chimarrita.quantidade) // 800
    while (chimarrita.servir()) {
      System.out.println(chimarrita.quantidade); // 700,600,500,400,300,200,100
    }
    System.out.println(chimarrita.quantidade); // 0
    chimarrita.encher();
    System.out.println(chimarrita.quantidade); // 1000
  }
}

Retomando o primeiro parágrafo, a confiabilidade de um objeto é obtida pela consistência e validade do seu estado em dois momentos: (1) inicialização e (2) na interação.

  1. O código anterior garante a consistência e validade do estado desde o início, no construtor. Preste atenção às linhas if (capacidade < 100) throw new IllegalArgumentException, elas interrompem a criação do objeto e tratam da validade no momento da inicialização;
  2. Agora, preste atenção à condição if (this.quantidade >= 100), ela protege o estado impedindo que a quantidade fique negativa ao subtrair 100mL e garante a validade do estado durante as interações com o objeto.

4.4 Estratégias para validar o estado

Um objeto válido é aquele que todos os seus atributos armazenam valores esperados, isto é, dentro das regras especificadas. Essas regras podem ser aplicadas logo na inicialização, no construtor. Neste caso, um objeto nunca é inválido, pois nunca será inicializado. Validar cedo é uma estratégia segura para o projeto de sistemas conhecida como fail-fast. O objetivo é interromper a operação normal assim que uma inconsistência é encontrada.

A segunda opção, é usar uma estratégia permissiva, tardia, ou leniente, que permite a criação de objeto inválidos e disponibiliza um meio de validá-los, ou adaptá-los, antes de uma operação final. A primeira estratégia, de validar no construtor, já foi discutida no tópico anterior. Então, este tópico trata da segunda.

Para exemplificar a validação tardia, considere uma playlist para a reprodução de músicas. É preciso que ela tenha um nome e, para reproduzi-la, é preciso criá-la com as músicas. Ou seja, não é possível reproduzir uma playlist vazia ou sem nome. Esse estado válido, no entanto, só é necessário antes da reprodução, assim que uma playlist pode começar inválida e continuar sendo trabalhada até ser possível reproduzi-la.

// Playlist.pseudo
classe Playlist

  atributo nome : textual
  atributo musicas : lista

  construtor
    atributo nome = "Sem nome"
    atributo musicas = nova Lista
  fim construtor

  método adicionarMusica(musica)
    musicas.adiciona(musica)
  fimmétodo

  método renomear(novoNome)
    atributo nome = novoNome
  fimmétodo

  método reproduzir
    se atributo musicas.comprimento > 0 // se há músicas
      para cada musica de musicas
        imprime("Reproduzino " + musica)
        player.play(musica)
      fimpara
    senão // se vazia
      lança a exceção "Playlist " + atributo nome + " está vazia"
    fimse
  fimmétodo
fim classe

// App.pseudo
usando Playlist de Playlist.pseudo
procedimento App
  nada = nova Playlist
  // nada.reproduzir() // lança uma exceção
  u2 = nova Playlist
  // u2.reproduzir() // lançaria uma exceção
  u2.renomear("Favoritas do U2")
  u2.adicionarMusica("Numb")
  u2.adicionarMusica("Where the Streets Have no Name")
  u2.adicionarMusica("City of Blinding Lights")
  u2.reproduzir() // reproduzindo Numb
fim procedimento

O pseudocódigo anterior apresenta um possível projeto de classe Playlist que valida o estado apenas no momento da sua reprodução. Assim, o código de validação está presente no método reproduzir em vez de no construtor. Esse projeto pode ser implementado com poucas adaptações em qualquer linguagem com suporte a orientação a objetos. A seguir o mesmo exemplo em Java:

// Playlist.java
import java.util.ArrayList;

class Playlist {

  String nome;
  ArrayList musicas; // ArrayList é uma estrutura de Java.Util

  Playlist() { // construtor
    this.nome = "Sem nome";
    this.musicas = new ArrayList();
  }

  void adicionarMusica(String musica) {
    musicas.add(musica);
  }

  void renomear(String novoNome) {
    this.nome = novoNome;
  }

  void reproduzir() {
    if (this.musicas.size() > 0) { // se há músicas
      for (String musica : this.musicas) {
        System.out.println("Reproduzindo " + musica);
        // player.play(musica); // não temos um player, ainda :/
      }
    } else {
      throw new RuntimeException ("Playlist " + this.nome + " está vazia");
    }
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    Playlist nada = new Playlist();
    // nada.reproduzir(); // lança uma exceção
    Playlist u2 = new Playlist();
    // u2.reproduzir() // lançaria uma exceção
    u2.renomear("Favoritas do U2");
    u2.adicionarMusica("Numb");
    u2.adicionarMusica("Where the Streets Have no Name");
    u2.adicionarMusica("City of Blinding Lights");
    u2.reproduzir(); // reproduzindo Numb, ...
  }
}

Essa classe considera tanto o valor padrão, como o texto "Sem nome" para uma playlist recém criada, como permite que a playlist fique vazia sem lançar exceções até o momento em que é solicitada sua reprodução no método reproduzir.

Sabendo que o estado do objeto pode ser validado adiantado, logo no construtor, ou atrasado, no método que conclui a operação, um pode ficar na dúvida: devo validar adiantado ou atrasado? Cada caso é um caso. Como sugestão, considere sempre validar adiantado, porque é mais seguro, e avalie se deve-se validar atrasado judiciosamente, isto é, pondere bem os motivos para.

A lógica de validação é fortemente associada ao que se espera da interface do usuário. Por exemplo, nas interfaces que iniciam com um modelo vazio e exigem interação do usuário para completar, é tipicamente adequado validar atrasado. Por exemplo, considere um serviço de streaming. Se é preciso selecionar uma música para criar uma playlist ele já pressupõe que a playlist não estará, nunca, vazia. Se permite primeiro criar a playlist para depois adicionar as músicas, neste caso, ele inicia com um objeto vazio, “inválido”, por um tempo. Outros sistemas que podem precisar um objeto vazio e inválido inicial incluem editores de texto (documento em branco) e carrinhos de compra (como um carrinho vazio), entre outros.

Para fechar este tópico, a seguir está a mesma classe Playlist com validação adiantada (fail-fast).

// Playlist.java
import java.util.ArrayList;

class Playlist {

  String nome;
  final ArrayList musicas = new ArrayList();

  // construtor: obrigatório nome e uma música
  Playlist(String nome, String musica) {
    this.nome = nome;
    this.adicionarMusica(musica);
  }

  void adicionarMusica(String musica) {
    musicas.add(musica);
  }

  void renomear(String novoNome) {
    this.nome = novoNome;
  }

  void reproduzir() {
    for (Object musica : this.musicas) {
      System.out.println("Reproduzindo " + musica);
      // player.play(musica); // não temos um player, ainda :/
    }
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    // Playlist nada = new Playlist(); // não compila sem um nome e música
    Playlist u2 = new Playlist("Favoritas do U2", "Numb");
    u2.adicionarMusica("Where the Streets Have no Name");
    u2.adicionarMusica("City of Blinding Lights");
    u2.reproduzir(); // reproduzindo Numb, ...
  }
}

4.5 Considerações

O controle do estado dos objetos é uma tarefa essencial e que merece muito atenção. Deve-se sempre procurar projetar classes que produzam objetos que não quebram. Algumas atitudes recomendadas são:

  • preferir estado constante sempre que possível: declarar atributos sempre como final (Java) ou const (C#), e tornar variáveis (instantâneos) apenas aqueles que tem um motivo para mudar e regras de mudança claras;
  • fornecer um único ponto de entrada para alteração do estado: de modo que se possa rastrear a passagem da informação em uma sessão de debug, por exemplo, interceptando e logando o parâmetro de um método/construtor;
  • validar cedo (fail-fast) sempre que possível: validar no construtor e mover as regras para uma validação tardia apenas se houver um motivo claro e regras claras de progressão do estado até atingir um método que concluir a finalidade de um objeto.

4.6 Exercícios

Os exercícios a seguir estão descritos na forma de casos de teste. São descritos os nomes de classe, construtores, atributos e métodos esperados. As instruções System.out.println( == ) declaram uma igualdade ou assertiva. A saída true significa um teste que passou, enquanto false o teste falhou. Considere implementar aos poucos, comentando as linhas que testam instruções que ainda não estão prontas, e ir “descomentando” aos poucos, a medida que avança na implementação.

O estado de um Forno

Considere um Forno sofisticado de controle via app Android/iOS. É possível ligar, desligar, ajustar temperatura e outros detalhes. Os objetos variam segundo seu volume, tensão, potência e dimensões (na forma largura, altura e profundidade em centímetros). Implemente conforme especificação a seguir:

// Main.java
class Main {
  public static void main(String[] args) {

    Forno f = new Forno(45, 220, 1700, 66, 40, 54);
    System.out.println(f.volume == 45);
    System.out.println(f.tensao == 220);
    System.out.println(f.potencia == 1700);
    System.out.println(f.largura == 66);
    System.out.println(f.altura == 40);
    System.out.println(f.profundidade == 54);
    // esse atributo não consta no construtor
    System.out.println(f.temperatura == 0);
    // todos esses atributos devem ser constantes,
    // as atribuções a seguir não podem compilar
    // verifique e comente-as
    f.volume = 450;
    f.tensao = 2200;
    f.potencia = 17000;
    f.altura = 400;
    f.largura = 660;
    f.profundidade = 540;

    Forno forno = new Forno(84, 220, 1860, 61, 58, 58);
    System.out.println(forno.volume = 84);
    System.out.println(forno.tensao = 220);
    System.out.println(forno.potencia = 1860);
    System.out.println(forno.altura = 58);
    System.out.println(forno.largura = 61);
    System.out.println(forno.profundidade = 58);

    System.out.println(forno.temperatura); // 0
    System.out.println(forno.temperatura == 0); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 50
    System.out.println(forno.temperatura == 50); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 100
    System.out.println(forno.temperatura == 100); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 150
    System.out.println(forno.temperatura == 150); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 200
    System.out.println(forno.temperatura == 200); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 220
    System.out.println(forno.temperatura == 220); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 250
    System.out.println(forno.temperatura == 250); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 300
    System.out.println(forno.temperatura == 300); // true
    forno.aumentarTemperatura(); // já está no máximo
    System.out.println(forno.temperatura); // 300
    System.out.println(forno.temperatura == 300); // true
    // reduzindo
    forno.diminuirTemperatura();
    forno.diminuirTemperatura();
    forno.diminuirTemperatura();
    System.out.println(forno.temperatura); // 150
    System.out.println(forno.temperatura == 150); // true
    // desligando direto
    forno.desligar();
    System.out.println(forno.temperatura); // 0
    System.out.println(forno.temperatura == 0); // true
    // já está desligado
    forno.diminuirTemperatura();
    System.out.println(forno.temperatura == 0); // true
  }
}

Codificando o estado de uma Televisão

Considere um aparelho de televisão. Cada uma tem um fabricante, modelo, tamanho e resolução. Além disso, a operação da TV é bem simples, permitir aumentar e baixar o volume, numa escala de 0 a 100%, e mudar o canal, suportando a UHF apenas e indo, então, do canal 2 ao 69.

Dada essa especificação, projete (com pseudocódigo ou descritivo textual) e implemente uma classe TV, que guarde as características mencionadas, respeitando a imutabilidade e os métodos com as operações descritas.

Escreva pelo menos 20 Casos de Teste, para situações comuns e excepcionais.

Desafio: implementar as funcionalidades: mudo, ir para canal e voltar canal anterior.

5. Comportamento & Operações

“O filho de uma programadora perguntou:

- Mãe, por que o sol nasce no leste e se põe no oeste?

A mãe respondeu:

- Tá funcionando meu filho? Então não mexe.”

Adaptada de anônimo.

O capítulo anterior tratou do estado, que são os adjetivos ou qualidades do objeto, como: nome, cor, preço, tamanho, etc, enfim, qualquer propriedade. Neste capítulo o assunto é o comportamento que, por outro lado, descreve o que o objeto faz (ou é capaz de), na forma de verbos como: salvar, mover, copiar, andar, atirar, saltar, etc.

Todos os sistemas confiáveis possuem objetos com um comportamento previsível e testável. Os objetos confiam no comportamento uns dos outros para criar um sistema inteiro. O comportamento falho de uma peça pode causar falhas em cascata.

Nos tópicos a seguir é discutido o que se entende por “comportamento” , como ele é projetado e implementado através de métodos, também conhecidos como operações ou funções membro do objeto.

5.1 Conceito de Comportamento do Objeto

Os objetos em um sistema fazem alguma coisa. Isto é, eles exercem uma atividade, senão seriam apenas estruturas de dados. Para declarar o que fazem, eles disponibilizam métodos (ou funções de objeto). Os métodos são declarados como verbos, que indicam uma ação do objeto. Por exemplo, considere um game de corrida, onde o veículo pode acelerar, frear, etc. A reunião dessas habilidades e o que acontece com o objeto após executá-las definem o comportamento do objeto. Qual é o comportamento esperado ao invocar o método acelerar?

Espera-se que o veículo aumente a velocidade. Assim, o comportamento do objeto está firmemente relacionado com o estado. As ações, as operações do objeto implicam no estado. Por exemplo, em um aplicativo de comércio eletrônico há a metáfora do carrinho de compras. Provavelmente haverá um objeto Carrinho no sistema para o controle dessa lógica. Inicialmente, o Carrinho não possui itens, até o método adicionar ser invocado. Isto é, adicionar, remover e alterar itens altera o estado do Carrinho, que lhe confere um comportamento.

Assim, o comportamento é definido como o conjunto de operações que podem ser exercidas em um objeto e como estas afetam seu estado. Isto é, o objeto Gato mia quando está com fome e brinca quando está feliz. Miar e brincar são comportamentos estimulados por pré-condições e que causam pós-condições. O gato come, não tem mais fome, e dorme. É um exemplo bobo, mas que tenta explicar o que se entende por comportamento.

5.2 Métodos (ou operações)

O comportamento dos objetos é descrito por métodos. Eles são como as funções, que têm entrada e saída, porém, além disso, têm acesso ao estado do objeto. Isto é, os métodos podem ler e alterar informações de uma instância.

Porém, nem todos os métodos precisam de entrada ou saída. É possível saber os desdobramentos da execução de um método pela consulta ao estado. Parece complicado a princípio, por isso, considere um exemplo bobo: uma lâmpada:

// Lâmpada.pseudo
classe Lâmpada
  atributo ligada : booleano

  método ligar
    atributo ligada = Verdadeiro
  fim método

  método desligar
    atributo ligada = Falso
  fim método
fim classe

// App.pseudo
usando Lâmpada de Lâmpada.pseudo
procedimento App
  lamp1 = nova Lâmpada
  imprime(lamp1.ligada) // Falso
  lamp1.ligar()
  imprime(lamp1.ligada) // Verdadeiro
  lamp1.desligar()
  imprime(lamp1.ligada) // Falso
  // lamp1.ligada = Verdadeiro // evite fazer isso, use o método
  lamp1.ligar()
fim procedimento

O pseudocódigo anterior representa um objeto da classe Lâmpada, com o estado ligada. Os métodos ligar e desligar são usados para alterar o estado da lâmpada. Como é visto, não há argumento para os métodos e eles não possuem return. A função deles é de apenas alterar o estado do objeto.

O exemplo implementado em Java seria:

// Lampada.java
class Lampada {
  boolean ligada; // estado

  void ligar() {  // altera o estado para ligada
    this.ligada = true;
  }
  void desligar() { // altera o estado para desligada
    this.ligada = false;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Lampada lamp1 = new Lampada();
    System.out.println(lamp1.ligada); // false
    lamp1.ligar();
    System.out.println(lamp1.ligada); // true
    lamp1.desligar();
    System.out.println(lamp1.ligada); // false
    // lamp1.ligada = true; // evite fazer isso, use o método
    lamp1.ligar();
    System.out.println(lamp1.ligada); // true
  }
}

Os métodos também podem ter entrada e saída. Por exemplo, considere um AltoFalante com controle de volume, de 0 (mudo) a 100 (máximo). Segue pseudocódigo:

// AltoFalante.pseudo
classe AltoFalante
  atributo volume : inteiro = 0

  método aumentar : inteiro
    se atributo volume < 100 // cuidado com o limite
      atributo volume = atributo volume + 1
    fim se
    retorna atributo volume
  fim método

  método silenciar
    atributo volume = 0
    // precisa retornar 0?
  fim método

  método ajustar(valor : inteiro)
    se valor >= 0 e valor <= 100 // aceitar um valor válido
      atributo volume = valor
    fim se
    // precisa retornar o volume?
  fim método
fim classe

// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
  af = novo AltoFalante
  imprime(af.volume) // 0
  af.aumentar()
  imprime(af.volume) // 1
  // pode imprimir o retorno do método aumentar
  imprime(af.aumentar()) // 2
  af.silenciar()
  imprime(af.volume) // 0
  // impossível, o método silenciar não tem retorno:
  // imprime(af.silenciar())
  af.ajustar(70) // o método ajustar precisa de argumento
  imprime(af.volume) // 70
fim procedimento

Neste exemplo foram apresentados três métodos diferentes: o método aumentar acresce um ponto ao volume e retorna o resultado. Isto é, imagine que (no mundo real) ao pressionar aumentar é possível saber o volume resultante. O mesmo comportamento não é interessante no método silenciar, pois o resultado sempre será 0, por isso ele não tem retorno. O último método, ajustar, permite passar o valor de volume desejado. Claro, há um se para garantir que o estado seja válido, isto é, o volume esteja sempre entre 0 e 100. Neste último, também não é necessário retornar o volume resultante, já que ele será o valor passado. Poderia se argumentar que ajustar retornasse um booleano, indicando se o valor foi aceito ou não - fica para consideração e exercício a seguir.

5.3 Separação de Comando e Consulta

Existem alguns princípios para o projeto e implementação de métodos/operações. Neste tópico eu vou apresentar um, conhecido como a Separação de Comando e Consulta (do inglês Command Query Separation - CQS), o qual acredito ser de muita importância para o projeto de objetos confiáveis.

Este princípio declara que todo método deve ser um deste dois:

  • um comando que realiza uma ação e que pode alterar o estado.
  • ou uma consulta que retorna dados e não causa efeitos colaterais.

Nunca os dois. “Fazer uma uma pergunta não deve alterar a resposta” (Bertrand Meyer).

Para exemplificar, considere um objeto Altofalante simplificado a seguir:

// AltoFalante.pseudo
classe AltoFalante
  atributo volume : inteiro = 0

  método aumentar : inteiro
    atributo volume = atributo volume + 1
    retorna atributo volume
  fim método

fim classe

// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
  af = novo AltoFalante
  imprime(af.aumentar()) // 1
  imprime(af.aumentar()) // 2
fim procedimento

Esta classe que define o estado e comportamento de objetos alto-falante viola o princípio do comando e consulta. Costumamos dizer que um viola um princípio quando não segue sua recomendação. Perceba que o comportamento do objeto ao invocar o método aumentar() é de, ao mesmo tempo, incrementar e retornar o volume. Isto é, o mesmo método serve de comando e consulta, sem a separação. É como: para eu saber o volume preciso apertar no botão + ou - e, logo, eu sei o volume em que estava (pois ao apertar + acresce um ponto ao volume anterior).

A proposta do CQS é disponibilizar métodos separados, para alterar ou para consultar o estado. Por exemplo:

// AltoFalante.pseudo
classe AltoFalante
  atributo volume : inteiro = 0

  método aumentar : vazio      // é um comando - sem retorno!
    atributo volume = atributo volume + 1
  fim método

  método consultar : inteiro   // é uma consulta - não altera estado!
    retorna atributo volume
  fim método

fim classe

// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
  af = novo AltoFalante
  // imprime(af.aumentar()) // instrução inválida - não se imprime um comando
  af.aumentar()
  imprime(af.consultar()) // 1
  af.aumentar()
  imprime(af.consultar()) // 2
fim procedimento

Escrito em Java este exemplo fica assim:

// AltoFalante.java
class AltoFalante {
  int volume = 0;
  void aumentar() { // é um comando - sem retorno!
    this.volume = this.volume + 1;
  }
  int consultar() { // é uma consulta - não altera estado!
    return this.volume;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    AltoFalante af = new AltoFalante();
    // instrução inválida - não se imprime um comando
    // System.out.println(af.aumentar());
    af.aumentar();
    System.out.println(af.consultar()); // 1
    af.aumentar();
    System.out.println(af.consultar()); // 2
  }
}

O objetivo do CQS é de que os programadores sintam-se seguros de invocar os métodos de consulta ao estado do sistema, sabendo que não causarão nenhum efeito colateral e que a execução do sistema continuará sem interferências. Isto é, os métodos consulta, como consultar o volume no exemplo anterior, podem ser executados diversas vezes, permitindo depuração de um programa através da impressão do seu estado e sem causar bugs.

Como toda tese tem uma antítese, alguém pode argumentar que um método deva realizar uma mudança de estado e retornar, geralmente para simplificação. É um trade-off10, isto é, uma decisão difícil entre simplificar o código ou tornar mais seguro.

Considere um carrinho de compras que permite adicionar e remover produtos.

// Carrinho.pseudo
classe Carrinho
  atributo produtos : lista = []

  método adicionar(produto: texto) : vazio
    atributo produtos.adicionar(produto)
  fim método

  método remover(produto : texto) : booleano
    se atributo produtos.contem(produto)
      atributo produtos.remover(produto)
      return Verdadeiro // um produto foi removido
    fim se
    return Falso // o produto não estava no carrinho
  fim método

fim classe

// App.pseudo
usando Carrinho de Carrinho.pseudo
procedimento App
  carr = novo Carrinho
  carr.adicionar("Teclado")
  carr.adicionar("Mouse")
  se carr.remover("Teclado")
    imprime("O Teclado foi removido do carrinho")
  senão
    imprime("Não há Teclado no carrinho")
  fim se
  se carr.remover("Impressora")
    imprime("A Impressora foi removida do carrinho")
  senão
    imprime("Não há Impressora no carrinho")
  fim se
fim procedimento

O método remover viola o CQS. No entanto, ele é bastante útil para saber se um produto foi removido com sucesso ou não. Pense, como seria o mesmo programa se respeitasse o CQS? Com o método remover sem retorno (vazio ou void), como saber se um produto foi removido?

A seguir um exemplo implementado em Java com as duas versões, um método não-concordante com o CQS removeu(String):boolean e outro par concordante remover(String):void e contem(String):boolean:

// Carrinho.java
import java.util.ArrayList;
class Carrinho {
  ArrayList produtos = new ArrayList();
  void adicionar(String produto) {
    this.produtos.add(produto);
  }
  boolean removeu(String produto) { // este viola o CQS
    // o método remove de ArrayList funciona deste modo
    boolean foiRemovido = this.produtos.remove(produto);
    return foiRemovido;
  }
  void remover(String produto) {
    this.produtos.remove(produto);
  }
  boolean contem(String produto) {
    return this.produtos.contains(produto);
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Carrinho carr = new Carrinho();
    carr.adicionar("Teclado");
    carr.adicionar("Mouse");
    // não-CQS: perceba que a remoção é TENTADA
    if (carr.removeu("Teclado")) {
      System.out.println("Teclado removido");
    } else {
      System.out.println("Não há Teclado");
    }
    // CQS: perceba que primeiro é verificado se é necessário remover
    if (carr.contem("Impressora")) {
      carr.remover("Impressora");
      System.out.println("Impressora removida");
    } else {
      System.out.println("Não há Impressora");
    }
  }
}

Escolher entre aderir ao CQS ou não muda a forma de como os clientes (quem chama os métodos) operam sobre o objeto, isto é, impacta na ordem do algoritmo que usa o objeto. Perceba como, no exemplo anterior, no modo não-CQS a remoção é submetida sem saber se há o objeto, enquanto na versão CQS a consulta é realizada antes.

5.4 Comportamento Excepcional

E se fosse possível efetuar um retorno apenas em certas situações? Este é o conceito de execução excepcional usada para o controle do fluxo do programa em casos especiais.

A maioria das linguagens de programação modernas disponibiliza o tratamento de exceções (do inglês Exception Handling) para a implementação deste tipo de controle de fluxo. Nestes casos, procura-se tratar um comportamento anormal (ou incomum) do objeto. A lógica envolve, geralmente, lançar (to thrown) ou levantar (to raise) exceções que são, então, capturadas (to catch) ou resgatadas (to rescue).

No último exemplo do tópico anterior, sobre [Separação de Comando e Consulta]{#cqs}, há o método removeu(String):boolean, que tenta remover o produto retornando true em caso de sucesso e false no caso de não haver tal produto no carrinho. Este método é um bom candidato à implementação com exceções. Considere o exemplo reprojetado para usar exceções:

// Carrinho.pseudo
classe Carrinho
  atributo produtos : lista = []

  método adicionar(produto: texto) : vazio
    atributo produtos.adicionar(produto)
  fim método

  método remover(produto : texto) : vazio // não há retorno
    se não atributo produtos.contem(produto)
      // situação excepcional lança uma exceção
      lançar nova exceção "Não há {produto} no carrinho"
    fim se
    atributo produtos.remover(produto)
    // nenhum retorno é necessário
  fim método

fim classe

// App.pseudo
usando Carrinho de Carrinho.pseudo
procedimento App
  carr = novo Carrinho
  carr.adicionar("Teclado")
  carr.adicionar("Mouse")

  tente
    carr.remover("Teclado")
  capture exceção
    // essa linha não será executada pois existe Teclado no carrinho
    imprime(exceção)
  fim tente

  tente
    // não há impressora, então uma exceção será capturada
    carr.remover("Impressora")
   capture exceção
    // imprime "Não há Impressora no carrinho"
    imprime(exceção)
  fim tente

fim procedimento

Podem ser definidas diversas situações excepcionais no mesmo método. Por exemplo, uma transferência bancária pode falhar por falta de saldo, limite diário excedido, recusa da conta de destino, etc. Para cada uma destas situações pode ser codificada uma exceção, que poderá (ou deverá) ser tratada por quem chamou o método.

O pseudocódigo anterior pode ser implementado em Java da seguinte maneira:

// Carrinho.java
import java.util.ArrayList;
class Carrinho {
  ArrayList produtos = new ArrayList();
  void adicionar(String produto) {
    this.produtos.add(produto);
  }
  void remover(String produto) {
    if ( ! this.produtos.contains(produto)) {
      throw new RuntimeException("Não há " + produto + " no carrinho");
    }
    this.produtos.remove(produto);
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Carrinho carr = new Carrinho();
    carr.adicionar("Teclado");
    carr.adicionar("Mouse");

    try {
      carr.remover("Teclado"); // ok, há teclado, vazio
    } catch(Exception excecao) { // nada para capturar
      System.err.println(excecao); // não chega aqui
    }

    try {
      carr.remover("Impressora"); // lança exceção
    } catch(Exception excecao) { // captura ela aqui
      System.err.println(excecao); // imprime ela aqui
      // Não há Impressora no carrinho
    }
  }
}

É possível perceber que as construções em par try {} catch {} são parecidas com os pares if {} else {}. De fato, ambos sustentam a ideia geral de um fluxo alternativo. No entanto, é preciso decidir em que situações usar um ou outro. Por isso, deve-se levar sempre em consideração que exceções são destinadas ao controle de fluxo em casos especiais e que são pouco frequentes, por isso são exceções. Digo, não seria uma boa prática lançar exceções para situações regulares.

5.5 Considerações

As questões quanto ao comportamento, operações, métodos, ainda não acabaram. Elas serão retomadas nas discussões quanto à herança, polimorfismo e outras. Os métodos estarão sempre presentes, pois definem a funcionalidade em si, garantindo que os objetos não sejam apenas um depósito de dados, mas que abriguem um corpo de conhecimento na forma de um algoritmo.

5.6 Exercícios

A seguir alguns exercícios que podem te ajudar a entender os conceitos apresentados neste capítulo e até estendê-los.

Codificando o comportamento de um ventilador

Lá vem um exemplo bobo: imagine que tenhamos um objeto ventilador, abstraindo todos os detalhes exceto a velocidade. Considere que na construção de um ventilador seja especificada o número de velocidades, como novo Ventilador 5. Ele parte da posição desligado, que é a velocidade 0 e usando as operações de mais ou menos ele alcança de 1 até a velocidade máxima (5, neste exemplo).

As questões são: o que acontece se o ventilador estiver na velocidade máxima e for invocada a operação mais? E se estiver desligado e invocada a operação menos? Como podemos saber a velocidade atual? Ou saber se o ventilador está ligado?

Implemente um que respeite o princípio da Separação Comando Consulta (CQS) e outro que seja mais prático (que viole o princípio em favor da praticidade). Ademais, considere escrever uma versão que utilize exceções para os casos especiais.

Como se comporta um cartão de crédito

Considere um cartão de crédito com uma bandeira e limite, como novo CartaoCredito "Gisa" 2000 (R$ 2000 de limite). Para simplificação vamos considerar apenas valores inteiros. Considere que possam ser realizadas compras parceladas neste cartão, informando a quantidade e valor das parcelas, como cartao.comprar(5, 100) que compromete R$ 500 (5 x 100) do limite do cartão, sobrando R$ 1500. A compra subsequente não pode ultrapassar este limite. Assim, a compra cartao.comprar(10, 151) não seria efetivada, mas cartao.comprar(10, 150) seria, comprometendo todo o limite.

Este exercício é desenvolvido em duas partes. Nesta primeira, tens de implementar a classe CartaoCredito e cumprir o comportamento esperado segundo os casos de teste a seguir:

// App.java
class App {
  public static void main(String[] args) {
    CartaoCredito cartao = new CartaoCredito("DoctorCard", 5000);
    // Observando o estado na forma de prints
    System.out.println(cartao.operadora); // DoctorCard
    System.out.println(cartao.limiteTotal()); // 5000
    System.out.println(cartao.limiteDisponivel()); // 5000
    // Testagem
    System.out.println(cartao.operadora.equals("DoctorCard")); // true
    System.out.println(cartao.limiteTotal() == 5000); // true
    System.out.println(cartao.limiteDisponivel() == 5000); // true

    cartao.comprar(10, 100); // compromete 1000
    System.out.println(cartao.limiteTotal() == 5000);
    System.out.println(cartao.limiteDisponivel() == 4000);

    cartao.comprar(2, 250);
    System.out.println(cartao.limiteDisponivel() == 3500);

    // o método compra lança uma exceção quando não há limite
    try {
      cartao.comprar(10, 400); // 4000, acima do limite disponível
    } catch (Exception excecao) {
      System.err.println(excecao); // Não há limite disponível
      System.err.println(excecao.getMessage().equals("Não há limite disponível")); //
    }

    // o método compraSePossivel é EAFP
    if (cartao.comprarSeHouverLimite(1, 500)) {
      System.out.println("Comprado");
    }
    System.out.println(cartao.limiteDisponivel() == 3000);
    System.out.println(cartao.comprarSeHouverLimite(2, 500) == true);

    System.out.println(cartao.limiteDisponivel() == 2000);

    System.out.println(cartao.comprarSeHouverLimite(10, 500) == false);
    System.out.println(cartao.comprarSeHouverLimite(5, 500) == false);
    System.out.println(cartao.comprarSeHouverLimite(4, 500) == true);

    System.out.println(cartao.limiteDisponivel() == 0);
  }
}

Nesta segunda parte há outras funcionalidades, como pagar para liberar o limite. Cada pagamento libera uma parcela de todas as contas agendadas que há para pagar. Implemente conforme os casos de teste a seguir:

// App.java
class App {
  public static void main(String[] args) {
    CartaoCredito cartao = new CartaoCredito("LunchsClub", 2000);
    // Testagem
    System.out.println(cartao.operadora.equals("LunchsClub")); // true
    System.out.println(cartao.limiteTotal() == 2000); // true
    System.out.println(cartao.limiteDisponivel() == 2000); // true

    System.out.println(cartao.haFaturaParaPagar() == false);

    cartao.comprar(2, 500); // compromete 1000
    System.out.println(cartao.limiteTotal() == 2000);
    System.out.println(cartao.limiteDisponivel() == 1000);

    System.out.println(cartao.haFaturaParaPagar() == true);

    cartao.comprar(4, 250); // compromete mais 1000
    System.out.println(cartao.limiteTotal() == 2000);
    System.out.println(cartao.limiteDisponivel() == 0);

    cartao.pagar(); // paga uma parcela de 500 e uma de 250
    System.out.println(cartao.limiteDisponivel() == 750);

    cartao.pagar(); // paga a última parcela de 500 e mais uma de 250
    System.out.println(cartao.limiteDisponivel() == 1500);

    cartao.pagar(); // paga a penúltima de 250
    System.out.println(cartao.limiteDisponivel() == 1750);

    System.out.println(cartao.haFaturaParaPagar() == true);

    cartao.pagar(); // paga a última de 250
    System.out.println(cartao.limiteDisponivel() == 2000);

    // tudo pago
    System.out.println(cartao.haFaturaParaPagar() == false);

    // Decisão sua: como deve ser comportar o objeto Cartão de Crédito
    // invocando a operação pagar sem valor devedor? CQS? Não-CQS? Exception?
    // Faça sua escolha e abrace o método pagar a seguir:

    cartao.pagar();
  }
}

6. Polimorfismo ad hoc

“A função do bom software é de fazer o complexo parecer ser simples e a tarefa da equipe de desenvolvimento de software é de projetar a ilusão de simplicidade.”

Grady Booch

O polimorfismo junto com abstração, encapsulamento e herança, formam os famosos 4 pilares da programação orientada a objetos11. A abstração já foi introduzida no Capítulo 2: Modelo de Objetos. Este capítulo é dedicado a introduzir o polimorfismo e trabalhar com seu tipo mais básico, o polimorfismo ad hoc, que é um ótimo candidato para os primeiros passos nessa dinâmica de tipos.

6.1 Conceito de Polimorfismo

O polimorfismo é uma característica dos tipos, que são estudados com mais profundidade nas áreas da matemática, lógica e ciência da computação, em uma área do conhecimento chamada teoria dos tipos.

No entanto, na programação os tipos são estudados mais pragmaticamente, como a utilidade de definir tipos de variáveis, de retorno, de arrays e a criação de novos tipos através de registros (structs) e classes.

Para efeitos práticos, o polimorfismo é entendido como a possibilidade de enviar mensagens iguais a tipos diferentes ou a disponibilidade de uma única interface para diferentes tipos. Por isso, se diz que a interface respeita ou possui muitas formas (do Grego: poli = muitas, morphos = formas).

A área de estudo dos tipos e do polimorfismo é bastante ampla. No entanto, as três classes de polimorfismo comuns e disponíveis nas linguagens de programação são o ad hoc, por subtipagem e paramétrico.

O polimorfismo é dos recursos chave da POO (e da programação em geral). Está amplamente associado ao reaproveitamento de lógica através da variação dos algoritmos. Reaproveitamento é primordial na indústria de software, pois se traduz em menos trabalho, e menos trabalho em menos dinheiro e tempo. Portanto, abstração e polimorfismo estão no centro de um redemoinho tanto técnico, tecnológico como, diria até principalmente, econômico.

6.2 O que é o Polimorfismo ad hoc

O polimorfismo ad hoc se baseia em funções polimórficas que, embora conservem o mesmo nome, recebem diferentes tipos de parâmetros e podem variar de acordo com os argumentos passados.

Ele se diferencia dos outros tipos de polimorfismo, o por subtipagem e parametrização dos tipos, ambos baseados no sistema de tipos. Por esta razão ele é chamado de ad hoc12.

O polimorfismo ad hoc não está disponível em todas as linguagens orientadas a objetos, por não ser um recurso fundamental para o sistema de tipos. Nas linguagens que o implementam, ele é disponibilizado sob o nome de sobrecarga de métodos (ou sobrecarga de funções em linguagens não-OO ou que preferem este termo).

6.3 Sobrecarga de Métodos

A sobrecarga de métodos permite definir múltiplas funções com o mesmo nome e retorno mas com diferentes implementações. O método específico que a ser invocado é definido pelo contexto do algoritmo. Este contexto, por sua vez, é determinando pelos argumentos passados ao método (ou à função).

Portanto, as várias formas (o polimorfismo) assumidas pela função respondem à quantidade ou tipos dos argumentos passados. Por exemplo, considere um sistema imobiliário, onde um método alugar():Aluguel pode ser disponibilizado para que seja passada um valor reserva ou o nome do fiador, como alugar(reserva:numérico):Aluguel ou alugar(fiador:textual):Aluguel. O método específico dependerá do contexto, dado pelos argumentos, isto é, enquanto imovel.alugar(2000) invocará a primeira, imovel.alugar("Marcio") invocará a segunda versão do método alugar.

Considere um exemplo simples, como este e-book onde pode-se movimentar entre as páginas. Veja o pseudocódigo:

// EBook.pseudo
classe EBook
  atributo pagina : inteiro = 1

  método avançar() : vazio
    atributo pagina = atributo pagina + 1
  fim método

  // método avançar sobrecarregado
  método avançar(qtd : inteiro) : vazio
    atributo pagina = atributo pagina + qtd
  fim método

  // método avançar sobrecarregado
  método avançar(título: textual) : vazio
    escolha título
      caso "A história da POO"
        atributo pagina = 10
      caso "Modelo de Objetos"
        atributo pagina = 25
      caso "Estado"
        atributo pagina = 33
    fim escolha
  fim método
fim classe

// App.pseudo
usando EBook de EBook.pseudo
procedimento App
  ebook = novo EBook
  imprime(ebook.pagina) // imprime 1
  // avançar sem argumentos avança uma página apenas
  ebook.avançar()
  imprime(ebook.pagina) // imprime 2
  // avançar com argumento numérico avança "x" páginas
  ebook.avançar(3)
  imprime(ebook.pagina) // imprime 5
  // avançar com argumento textual avança até o título
  ebook.avançar("Modelo de Objetos")
  imprime(ebook.pagina) // imprime 25
fim procedimento

Eu forcei um pouco o exemplo anterior, pois avançar("A história da POO") não seria bem um avançar, mas um retroceder. No entanto, a simplicidade do exemplo está na sobrecarga do método avançar, com opção sem parâmetro avançar(), com parâmetro numérico avançar(numérico) e textual avançar(textual). A abstração do avançar funciona com o movimento das páginas, seja uma ou várias. As várias formas estão codificadas na variação do tipo dos argumentos.

Para um exemplo mais prático e útil vamos resgatar o carrinho, no entanto permitindo a adição de um ou vários produtos e a remoção pelo número e nome do produto. Veja o pseudocódigo:

// Carrinho.pseudo
classe Carrinho
  atributo produtos : lista = []

  método adicionar(produto: texto) : vazio
    atributo produtos.adicionar(produto)
  fim método
  // adicionar sobrecarregado pela quantidade de parâmetros
  método adicionar(produto: texto, quantidade: inteiro) : vazio
    considere i : inteiro = 0
    enquanto i < quantidade
      atributo produtos.adicionar(produto)
      incrementa i
    fim enquanto
  fim método

  método remover(produto : texto) : vazio
    enquanto atributo produtos.contem(produto)
      atributo produtos.remover(produto)
    fim enquanto
  fim método
  // remover sobrecarregado pelo tipo de parâmetro
  método remover(número : inteiro) : vazio
    indice = número - 1
    atributo produtos.remover(indice)
  fim método
fim classe

// App.pseudo
usando Carrinho de Carrinho.pseudo
procedimento App
  carr = novo Carrinho
  // adicionar um produto
  carr.adicionar("Teclado")
  // adicionar vários produtos através do método sobrecarregado
  carr.adicionar("Mouse", 5)
  // remover o segundo produto (um mouse)
  carr.remover(2)
  // remover o teclado
  carr.remover("Teclado")
fim procedimento

Neste exemplo podem ser observadas duas sobrecargas, a do método adicionar e do remover. A primeira foi feita com a variação da quantidade de parâmetros na forma de adicionar(textual) e adicionar(textual, inteiro), e a segunda na variação do tipo como remover(textual) e remover(inteiro). A sobrecarga pode ser obtida tanto variando o tipo do parâmetro quanto a quantidade deles.

Para finalizar este tópico segue a implementação em Java:

// Carrinho.java
import java.util.ArrayList;
class Carrinho {
  ArrayList produtos = new ArrayList();
  void adicionar(String produto) {
    this.produtos.add(produto);
  }
  void adicionar(String produto, int quantidade) {
    for (int i = 0; i < quantidade; i++) {
      this.produtos.add(produto); // ou this.adicionar(produto)
    }
  }
  void remover(String produto) {
    while (this.produtos.contains(produto)) {
      this.produtos.remove(produto);
    }
  }
  void remover(int numero) {
    int indice = numero - 1; // indíce inicia do zero
    // o método remove de ArrayList já é sobrecarregado
    this.produtos.remove(indice);
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Carrinho carr = new Carrinho();
    // adicionar um produto
    carr.adicionar("Teclado");
    System.out.println(carr.produtos.size() == 1);
    // adicionar vários produtos através do método sobrecarregado
    carr.adicionar("Mouse", 5);
    System.out.println(carr.produtos.size() == 6);
    // remover o segundo produto (um mouse)
    carr.remover(2)
    System.out.println(carr.produtos.size() == 5);
    // remover o teclado
    carr.remover("Teclado")
    System.out.println(carr.produtos.size() == 4);
  }
}

6.4 Sobrecarga de Construtores

Assim como os métodos os construtores também podem ser sobrecarregados. Isto é, pode-se ter dois ou mais construtores desde que tenham número e/ou tipo diferentes de parâmetros. Deste modo, é possível instanciar um objeto a partir de informações diferentes de inicialização, porém válidas. Assim como no caso dos métodos, o construtor que será invocado dependerá dos argumentos passados na instrução new.

Por exemplo, considere uma classe Horario que permite armazenar horas e minutos. Considere que embora os atributos sejam números inteiros, deve-se ser possível instanciá-la através de uma string de hora como "09:36". Segue a implementação em Java:

// Horario.java
class Horario {
  // atributos
  int horas;
  int minutos;
  // construtores
  Horario(int horas, int minutos) { // int, int, ex.: 6, 36
    this.horas = horas;
    this.minutos = minutos;
  }
  Horario(String horario) { // String, ex.: "06:36"
    String[] split = horario.split(":"); // "06:36" -> ["06", "36"]
    this.horas = Integer.parseInt(split[0]);
    this.minutos = Integer.parseInt(split[1]);
  }
}
// App.java
class App {
  public static void main(String[] args) {
    // instanciando Horario com o construtor int, int
    Horario horario = new Horario(5, 57);
    System.out.println(horario.horas == 5);
    System.out.println(horario.minutos == 57);

    // instanciando Horario com o construtor String
    horario = new Horario("03:39");
    System.out.println(horario.horas == 3);
    System.out.println(horario.minutos == 39);
  }
}

No exemplo anterior o construtor foi sobrecarregado para aceitar um parâmetro textual, além de dois inteiros. É possível sobrecarregar mais, desde que não sejam repetidos o mesmo número e tipo de parâmetros. Isto é, não pode haver outro construtor que receba dois ints ou uma string.

A sobrecarga de construtores também é um artifício para declarar parâmetros opcionais e valores padrão. Por exemplo, considere que para instanciar Horario os minutos sejam opcionais e new Horario(13) instancia 13:00. A seguir a implementação em Java:

// Horario.java
class Horario {
  // atributos
  int horas;
  int minutos;
  // construtores
  Horario() { // new Horario() -> "00:00"
    this.horas = 0;
    this.minutos = 0;
    // ou
    // this(0, 0)
  }
  Horario(int horas) { // int, ex.: 13
    this.horas = horas;
    this.minutos = 0;
    // ou
    // this(horas, 0)
  }
  Horario(int horas, int minutos) {
    this.horas = horas;
    this.minutos = minutos;
  }
  Horario(String horario) {
    String[] split = horario.split(":");
    this.horas = Integer.parseInt(split[0]);
    this.minutos = Integer.parseInt(split[1]);
  }
}

No exemplo anterior a classe Horario pode ser instanciada de quatro formas diferentes: new Horario(), new Horario(0), new Horario(0, 0) e new Horario("00:00"). Os três primeiros construtores são usados para flexibilizar a quantidade de argumentos necessários, permitindo instanciar 13:00 como new Horario(13) em vez de new Horario(13, 0), assumindo o valor default 0 quando os argumentos não são passados.

6.5 Considerações

Como já foi dito, o polimorfismo é uma funcionalidade chave da POO. Portanto, dominar este recurso é essencial para tornar-se um desenvolvedor de sistemas orientados a objetos. Por outro lado, ele também é muitas vezes mal entendido ou usado. Considere que ao sobrecarregar métodos e construtores se está adicionando novos pontos de mudança no seu código, então use este recurso com sabedoria. A quantidade de bugs é proporcional a quantidade de código, logo, menos código == menos bugs.

6.6 Exercícios

Seguem alguns exercícios onde o polimorfismo ad hoc pode ser usado.

Passagem rodoviária

Considere a compra de passagens na modalidade rodoviária. Para simplificação, considere uma Viagem em um ônibus com 25 assentos. Portanto, ao comprar uma passagem o cliente pode escolher o assento, senão será escolhido o primeiro livre. Implemente a classe Viagem e o comportamento esperado segundo os casos de teste a seguir:

// App.java
class App {
  public static void main(String[] args) {
    Viagem rg_poa = new Viagem("Rio Grande", "Porto Alegre");
    // 25 assentos
    System.out.println(rg_poa.quantidadeAssentosDisponiveis() == 25);
    // numerados de 1 a 25
    System.out.println(rg_poa.estaDisponivel(1) == true);
    System.out.println(rg_poa.estaDisponivel(2) == true);
    System.out.println(rg_poa.estaDisponivel(25) == true);
    // inválidos
    System.out.println(rg_poa.estaDisponivel(0) == false);
    System.out.println(rg_poa.estaDisponivel(26) == false);
    System.out.println(rg_poa.estaDisponivel(-5) == false);
    // comprando/vendendo passagem
    rg_poa.comprarPassagem();
    System.out.println(rg_poa.estaDisponivel(1) == false);
    System.out.println(rg_poa.estaDisponivel(2) == true);
    System.out.println(rg_poa.quantidadeAssentosDisponiveis() == 24);
    // método sobrecarregado
    rg_poa.comprarPassagem(3);
    System.out.println(rg_poa.quantidadeAssentosDisponiveis() == 23);
    System.out.println(rg_poa.estaDisponivel(1) == false);
    System.out.println(rg_poa.estaDisponivel(2) == true);
    System.out.println(rg_poa.estaDisponivel(3) == false);
    try {
      rg_poa.comprarPassagem(26); // assento não existe
      System.out.println("Esta linha não deve ser impressa");
    } catch (Exception e) {
      System.err.println(e); // assento não existe
    }
    try {
      rg_poa.comprarPassagem(3); // assento indisponível/ocupado
      System.out.println("Esta linha não deve ser impressa");
    } catch (Exception e) {
      System.err.println(e); // assento indisponível/ocupado
    }
    rg_poa.comprarPassagem(5);
    System.out.println(rg_poa.quantidadeAssentosDisponiveis() == 22);
    System.out.println(rg_poa.estaDisponivel(1) == false);
    System.out.println(rg_poa.estaDisponivel(2) == true);
    System.out.println(rg_poa.estaDisponivel(3) == false);
    System.out.println(rg_poa.estaDisponivel(4) == true);
    System.out.println(rg_poa.estaDisponivel(3) == false);
    System.out.println(rg_poa.estaDisponivel(6) == true);
    // compra o primeiro livre
    rg_poa.comprarPassagem();
    System.out.println(rg_poa.quantidadeAssentosDisponiveis() == 21);
    System.out.println(rg_poa.estaDisponivel(1) == false);
    System.out.println(rg_poa.estaDisponivel(2) == false);
    System.out.println(rg_poa.estaDisponivel(3) == false);
    System.out.println(rg_poa.estaDisponivel(4) == true);
    System.out.println(rg_poa.estaDisponivel(3) == false);
    System.out.println(rg_poa.estaDisponivel(6) == true);

  }
}

As várias formas do ticket de pedágio

Considere um ticket de pedágio calculado pelo número de eixos e rodagem. Um ticket pode ser para automóveis, que possuem dois eixos e rodagem simples, ônibus e caminhões com dois eixos e rodagem dupla, e depois combinações de automóveis com reboque de rodagem simples e caminhões com rodagem dupla. Os tickets possuem um valor base, para automóveis, que é escalado de acordo com a quantidade de eixos e rodagem. Seguem os casos de teste:

// App.java
class App {
  public static void main(String[] args) {
    int base = 12;
    Ticket automovel = new Ticket(base, 2, 1); // R$ 12,00, dois eixos, rodagem simples
    System.out.println(automovel.eixos == 2);
    System.out.println(automovel.rodagem == 1);
    // valor base
    System.out.println(automovel.valor() == base); // 12

    Ticket onibus = new Ticket(valor, 2, 2); // R$ 24,00, dois eixos, rodagem dupla
    System.out.println(onibus.eixos == 2);
    System.out.println(onibus.rodagem == 2);
    System.out.println(onibus.valor() == base * 2); // 24
    System.out.println(onibus.valor() == 24); // 24
    // valor base

    Ticket carroComReboque = new Ticket(valor, 3, 1); // R$ 18,00, três eixos, rodagem simples
    System.out.println(carroComReboque.valor() == 18); // base * 2.5

    Ticket rodoTrem = new Ticket(valor, 6, 2);
    System.out.println(rodoTrem.valor() == base * 6); // 72

    // sobrecarregar o construtor para aceitar automóvel como padrão
    Ticket carro = new Ticket(base); // dois eixos, rodagem simples por padrão
    System.out.println(carro.eixos == 2);
    System.out.println(carro.rodagem == 1);
    System.out.println(carro.valor() == base); // 12

    // sobrecarregar o construtor para aceitar a rodagem também textualmente
    Ticket van = new Ticket(base, 2, "dupla"); // dois eixos, rodagem simples por padrão
    System.out.println(van.eixos == 2);
    System.out.println(van.rodagem == 2); // rodagem dupla
    System.out.println(van.valor() == base * 2); // 24

    Ticket camioneta = new Ticket(base, 2, "simples"); // dois eixos, rodagem simples por padrão
    System.out.println(camioneta.eixos == 2);
    System.out.println(camioneta.rodagem == 1); // rodagem simples
    System.out.println(camioneta.valor() == base); // 24

  }
}

Revisitando o Cartão de Crédito para implementar novas formas de compra

Considere alterar o exercício do Cartão de Crédito e sobrecarregar o método comprar para ser invocado sem argumentos indicando um compra à vista.

// App.java
class App {
  public static void main(String[] args) {
    CartaoCredito cartao = new CartaoCredito("DoctorCard", 5000);
    // Testagem
    System.out.println(cartao.operadora.equals("DoctorCard")); // true
    System.out.println(cartao.limiteTotal() == 5000); // true
    System.out.println(cartao.limiteDisponivel() == 5000); // true
    cartao.comprar(10, 100); // compromete 1000 em 10x de 100
    cartao.comprar(1000); // compromete mais 1000, mas em 1x de 1000
    System.out.println(cartao.limiteTotal() == 5000);
    System.out.println(cartao.limiteDisponivel() == 3000);
  }
}

Projeto e implemente seu exemplo

Projete uma classe sobre um domínio de seu interesse que faça uso do polimorfismo ad hoc e escreva testes.

7. Encapsulamento & Visibilidade

“Encapsulamento - a integridade pessoal dos objetos não deve ser violada. Isto é verdadeiro seja o objeto um construto de software ou uma pessoa. O limite do público/privado é tido como impermeável.”

David West, Object Thinking.

Todos nós lidamos com diversos objetos, objetos reais, no nosso dia a dia. Dos simples aos complexos, como tesouras, cafeteiras, impressoras, veículos, televisores, etc. Os objetos têm uma utilidade, nós os manuseamos ou operamos com um objetivo em mente. A maioria de nós não sabe criar um objeto assim (eu sou incapaz de criar uma tesoura), mas sabemos usá-los através do seu design e comandos, através de sua interface (<-- guarde esta palavra).

Por exemplo, sabemos que o pedal ou manete de freio faz o veículo diminuir a velocidade até parar, mas não conhecemos todo o processo que leva até a roda. O mecanismo interno dos objetos é escondido (<-- guarde esta palavra também) de nós. Deve ser mesmo, pois os humanos (com raras exceções) não se importam como a coisa funciona desde que funcione.

O encapsulamento na Programação Orientada a Objetos é exatamente sobre isto. Sob a metáfora de adicionar um invólucro opaco, encapsular o objeto é esconder seu mecanismo interno e fornecer uma interface com o mínimo de detalhes (abstração), apenas o suficiente para operá-lo.

7.1 Conceito de encapsulamento

Durante o projeto e implementação do objeto são definidos atributos, construtores, e métodos, enfim, todos os recursos necessários para definir o algoritmo e cumprir a funcionalidade. Pense nestes atributos e construtos lógicos como se fossem fios e engrenagens de uma máquina e que não devem estar expostos. Por isso, eles são ocultos e invisíveis do lado de fora do objeto.

O encapsulamento é a ocultação da representação interna dos objetos. Em termos práticos, ele está além do estado constante imutável, pois não é o caso onde os atributos não podem ser modificados mas que esses atributos não sejam nem mesmo visíveis ou acessáveis de fora da classe/objeto.

Toda a representação e operações escondidas, o que está encapsulado, é conhecido como a implementação do objeto. A representação e operações visíveis é a abstração do objeto. Portanto, abstração e encapsulamento são conceitos complementares. A abstração trata do comportamento observável de um objeto, enquanto o encapsulamento trata da implementação que origina este comportamento.

7.2 Ocultação de informações

O encapsulamento e a ocultação de informações são tratados como o mesmo conceito. No entanto, encapsulamento é visto mais como uma decisão pontual para proteger módulos, uma implementação da ocultação de informações, que seria um conceito mais amplo e abstrato, não relacionado apenas à POO.

A ocultação de informações é anterior a POO, ela já era discutida na década de 70, para tratar questões de modularidade, isto é, aproveitar um código existente empacotado como um módulo ou biblioteca de procedimentos (não necessariamente na forma de classes e objetos) sem expor as configurações destes mesmos módulos

Portanto, mesmo que se encontre na literatura e manuais a expressão ocultação de informações (ou information hinding, em Inglês), este livro seguirá com a expressão e técnica adotada na POO para cumprir esse mecanismo de proteção, o encapsulamento, que é definido, tipicamente, pelo controle do alcance da visibilidade das informações.

7.3 Visibilidade

Nas linguagens de programação modernas é possível “ajustar” o encapsulamento. Em outras palavras, é possível definir quais informações serão visíveis (e acessíveis) apenas internamente de um objeto, para uma “família” de objetos ou objetos em um “contexto” (um módulo, componente, biblioteca).

Portanto, não é apenas uma questão booleana do que se não é visível então é invisível, mas de graus de visibilidade, que estendem ou restringem o acesso, desde apenas a classe onde está declarada a informação, até o pacote ou namespace, módulo, etc.

A visibilidade define o nível de acesso, como já foi dito, de um círculo mais interno ao mais externo. Este nível pode ser controlado por palavras-chave nas linguagens conhecidas como modificadores de acesso.

7.4 Modificadores de acesso

Os modificadores de acesso são palavras-chave disponíveis nas linguagens para restringir ou ampliar o acesso à atributos, construtores e métodos e até as próprias classes elas mesmas.

No nível mais básico está a separação do que é privado e público, declarados como private ou public. Por exemplo, dois atributos, um restrito e outro não, seriam declarados assim nas linguagens Java e C#: private int valorInterno; e public int valor;. O mesmo pode ser aplicado aos métodos, por exemplo, enquanto private int calculaTaxas() { // ... } é um método privado e acessível apenas pela própria classe, o método public int calculaImposto() { // .. } é acessível em qualquer parte do código. A aplicação prática destas configurações será trabalhada nos tópicos seguintes.

Por fim, além do par private/public, existem outros modificadores de acesso tais como protected, package e internal ou combinações destes. No entanto, não há como fazer uma cobertura extensiva de todos estes já que este livro trata dos conceitos e princípios da OO em vez de focar-se nos recursos das linguagens.

7.5 Atributos vs Propriedades

Nem sempre há uma clara distinção entre atributos e propriedades dos objetos. Para complicar, a diferença pode depender da implementação e das linguagens de programação. Se não é o bastante, autores e profissionais da área discordam se há realmente diferença. No fim, é uma questão de terminologia, mas neste livro estes termos serão tomados como distintos.

A questão atributo/propriedade foi levantada apenas neste capítulo porque a diferença depende da visibilidade. Tanto os atributos como as propriedades podem ser públicos ou privados. No entanto, os atributos são mais comumente destinados ao armazenamento da representação do estado interno dos objetos, portanto, geralmente privados ou pelo menos constantes (ou ambos) com o objetivo de proteger as “peças” internas. As propriedades, por outro lado, são mais comumente destinadas à representação externa, lendo atributos para formar esta representação e validando informações antes que estas cheguem aos atributos internos. As propriedades tipicamente fazem parte da interface do objeto, embora possam existir propriedades privadas também.

Algumas linguagens (como JavaScript, C# e Ruby) possuem suporte a uniformidade de atributos e propriedades. Isto é, não é possível distinguir se uma informação é obtida de um atributo ou propriedade porque eles têm a mesma aparência. Considere o seguinte exemplo em JavaScript:

const ANO_ATUAL = 2021;
const MES_ATUAL = 3;

class Pet {
    constructor(nome, anoNascimento, mesNascimento) {
        this.nome = nome;
        this.anoNascimento = anoNascimento;
        this.mesNascimento = mesNascimento;
    }
    get idade() {
        if (this.anoNascimento == ANO_ATUAL && this.mesNascimento == MES_ATUAL) return "recém nascido";
        const totalMeses = MES_ATUAL - this.mesNascimento + (ANO_ATUAL - this.anoNascimento) * 12;
        const anos = parseInt(totalMeses / 12);
        const meses = totalMeses % 12;
        let idade = anos > 0 ? (anos == 1 ? "um ano" : anos + " anos") : "";
        if (anos > 0 && meses > 0) idade += " e ";
        idade += meses > 0 ? (meses == 1 ? "um mês" : (meses == 6 ? "meio" : meses + " meses")) : "";
        return idade;
    }
}

const fito = new Pet('Fito', 2018, 9)
console.log(fito.nome, fito.idade) // Fito 2 anos e meio
const mimi = new Pet('Mimi', 2015, 2)
console.log(mimi.nome, mimi.idade) // Mimi 6 anos e um mês

No exemplo anterior, nome, anoNascimento e mesNascimento são atributos, enquanto get idade é uma propriedade. Do ponto de visa da execução não há diferença entre acessar pet.nome e pet.idade. No entanto, enquanto nome representa um estado armazenado, a idade é uma representação calculada a partir da representação interna (ano e mês de nascimento). Esta uniformidade do acesso é discutida no tópico Princípio do Acesso Uniforme ainda neste capítulo.

A questão central é que ambos, atributos e propriedades, representam o estado. A diferença é que atributos oferecem acesso direto ao estado, enquanto propriedades oferecem acesso indireto.

A linguagem Java não disponibiliza uma interface uniforme para atributos e propriedades. Em vez, Java utiliza um padrão chamado de Java Beans, que usa métodos com o prefixo get para ler as propriedades e set para atribuí-las (também conhecidos como getters e setters). Por exemplo, a instrução String nome; declara um atributo nome enquanto String getNome() { // ... } declara uma propriedade Nome. Considere o seguinte código em Java:

// Dimensoes.java
class Dimensoes {

  int largura;
  int altura;
  int profundidade;
  String unidade;

  Dimensoes(int largura, int altura, int profundidade, String unidade) {
    this.largura = largura;
    this.altura = altura;
    this.profundidade = profundidade;
    this.unidade = unidade;
  }

  String getVolume() {
    return (largura * altura * profundidade) + unidade + "³";
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Dimensoes pacote = new Dimensoes(30, 40, 50, "cm");
    // lendo atributos
    System.out.println(pacote.largura); // 30
    System.out.println(pacote.altura); // 40
    System.out.println(pacote.profundidade); // 50
    System.out.println(pacote.unidade); // cm
    // lendo propriedade
    System.out.println(pacote.getVolume()); // 60000cm³
  }
}

No exemplo anterior, foram lidos os atributos largura, altura, profundidade e unidade. Nenhum deles foi declarado como privado ou público, ou seja, todos são default, que significa visível para as outras classes no mesmo pacote. O volume, no entanto, é um estado calculado a partir das dimensões, portanto ele foi declarado como uma propriedade usando o padrão JavaBean String getVolume().

7.6 Acessores (getters) & Mutadores (setters)

Como visto em Validade do Estado, é necessário proteger os objetos de estados considerados inválidos. Por este motivo é comum adicionar regras de validação na inicialização (nos construtores). Assim como é comum tornar o estado imutável, para garantir que ele se manterá válido depois de validado. No entanto, como fica a situação para objetos mutáveis? Retomemos o exemplo do tópico anterior:

// Dimensoes.java
class Dimensoes {

  int largura;
  int altura;
  int profundidade;
  String unidade;

  Dimensoes(int largura, int altura, int profundidade, String unidade) {
    if (this.largura < 1) {
      throw new IllegalArgumentException("largura precisa ser positiva");
    }
    if (this.altura < 1) {
      throw new IllegalArgumentException("altura precisa ser positiva");
    }
    if (this.profundidade < 1) {
      throw new IllegalArgumentException("profundidade precisa ser positiva");
    }
    if (this.unidade == null || this.unidade.isEmpty()) {
      throw new IllegalArgumentException("unidade precisa ser informada");
    }
    this.largura = largura;
    this.altura = altura;
    this.profundidade = profundidade;
    this.unidade = unidade;
  }

  String getVolume() {
    return (largura * altura * profundidade) + unidade + "³";
  }
}
// App.java
class App {
  public static void main(String[] args) {
    // dimensões inválidas
    try {
      new Dimensoes(30, 0, 50, "cm");
    } catch (Exception e) {
      System.err.println(e); // altura precisa ser positiva
    }
    try {
      new Dimensoes(-30, 40, 50, "cm");
    } catch (Exception e) {
      System.err.println(e); // largura precisa ser positiva
    }
  // dimensões válidas
    Dimensoes pacote = new Dimensoes(30, 40, 50, "cm");
    // lendo atributos
    System.out.println(pacote.largura); // 30
    System.out.println(pacote.altura); // 40
    System.out.println(pacote.profundidade); // 50
    System.out.println(pacote.unidade); // cm
    // lendo propriedade
    System.out.println(pacote.getVolume()); // 60000cm³

    // aqui tudo dá errado:
    pacote.largura = -30;
    pacote.unidade = "";
    System.out.println(pacote.getVolume()); // -60000³
  }
}

Considerando o código do exemplo anterior, parece que o objeto está protegido de um estado inválido com as salvaguardas (os if’s) no início do construtor. No entanto, os atributos podem ser redefinidos após a instanciação, como pode-se notas nas últimas linhas pacote.largura = -30. Solução para isto?

Tornar o objeto imutável com estado constante marcando os atributos como final:

// Dimensoes.java
class Dimensoes {
  // tornando o objeto imutável
  final int largura;
  final int altura;
  final int profundidade;
  final String unidade;
  // ...

A outra opção é encapsular estes atributos e fornecer métodos acessores e mutadores que os expõem como propriedades. A maioria das linguagens modernas possuem a ideia de acessores e mutadores, geralmente na forma de getters e setters, respectivamente. Eles fornecem uma interface de acesso ao estado interno do objeto. Os setters podem ser usados para garantir pré-condições, como validar o novo estado sugerido. Os getters podem realizar um processamento dos valores antes de retorná-los. Obviamente, esta segurança só funciona se os atributos forem protegidos do acesso direto, se eles forem encapsulados, na prática, marcados como private, enquanto os acessores e mutadores representam o estado público (public).

A seguir o mesmo exemplo usando acessores e mutadores e com os modificadores de acesso declarados:

// Dimensoes.java
public class Dimensoes { // a classe é pública/aberta
  // os atributos são invisíveis fora da classe Dimensoes
  private int largura;
  private int altura;
  private int profundidade;
  private String unidade;
  // o construro é público
  public Dimensoes(int largura, int altura, int profundidade, String unidade) {
    this.setLargura(largura); // delega para o mutador
    this.setAltura(altura); // delega para o mutador
    this.setProfundidade(profundidade); // delega para o mutador
    this.setUnidade(unidade); // delega para o mutador
  }
  // métodos mutadores // setters // gravam propriedades
  public void setLargura(int largura) {
    if (this.largura < 1) {
      throw new IllegalArgumentException("largura precisa ser positiva");
    }
    this.largura = largura;
  }
  public void setAltura(int altura) {
    if (this.altura < 1) {
      throw new IllegalArgumentException("altura precisa ser positiva");
    }
    this.altura = altura;
  }
  public void setProfundidade(int profundidade) {
    if (this.profundidade < 1) {
      throw new IllegalArgumentException("profundidade precisa ser positiva");
    }
    this.profundidade = profundidade;
  }
  public void setUnidade(String unidade) {
    if (this.unidade == null || this.unidade.isEmpty()) {
      throw new IllegalArgumentException("unidade precisa ser informada");
    }
    this.unidade = unidade;
  }
  // =================================================
  // métodos acessores // getters // leem propriedades
  public int getLargura() {
    return this.largura;
  }
  public int getAltura() {
    return this.altura;
  }
  public int getProfundidade() {
    return this.profundidade;
  }
  public String getUnidade() {
    return this.unidade;
  }
  public String getVolume() {
    return (largura * altura * profundidade) + unidade + "³";
  }
}
// App.java
class App {
  public static void main(String[] args) {

    Dimensoes pacote = new Dimensoes(30, 40, 50, "cm");

    // Os atributos não podem ser acessados, as seguintes linhas comentadas são /// inválidas e não co\
mpilam:
    // System.out.println(pacote.largura);
    // System.out.println(pacote.altura);
    // System.out.println(pacote.profundidade);
    // System.out.println(pacote.unidade);

    // lendo apenas as propriedades
    System.out.println(pacote.getLargura()); // 30
    System.out.println(pacote.getAltura()); // 40
    System.out.println(pacote.getProfundidade()); // 50
    System.out.println(pacote.getUnidade()); // cm
    System.out.println(pacote.getVolume()); // 60000cm³

    // Isto não é possível:
    // pacote.largura = -30; // o atributo é PRIVADO
    // Isto não é aprovado pela validação:
    // pacote.setLargura(-30);

    // No entanto, é possível alterar as informações se elas forem válidas:
    pacote.setLargura(60);
    pacote.setAltura(20);
    pacote.setProfundidade(80);
    pacote.setUnidade("\""); // polegadas
    System.out.println(pacote.getVolume()); // -96000"³
  }
}

No exemplo anterior, os atributos foram encapsulados, tornando-se invisíveis fora da classe Dimensoes. No entanto, não são inacessíveis, desde que sejam usadas as propriedades expostas pelos métodos getters equivalentes, como getAltura().

7.7 Princípio do mínimo privilégio

Independente do paradigma de programação, programar defensivamente é sempre uma boa prática. Os melhores sistemas, os mais seguros, foram projetados e desenvolvidos por analistas e desenvolvedores prudentes e disciplinados que cultivam o hábito da programação defensiva.

A programação defensiva é apoiada por algumas técnicas e práticas, como o fail-fast discutido no Capítulo 4. O encapsulamento é um conceito, que por si só já apoia a programação defensiva. O modificador de acesso é a funcionalidade disponibilizada pelas linguagens para efetivar o encapsulamento. Resta, então, uma prática, ou regra geral, ou melhor, um princípio para saber quando encapsular. É neste ponto que entra o Princípio do Mínimo Privilégio.

A ideia geral é de que em qualquer ambiente computacional qualquer agente tenha acesso a estritamente o mínimo número de recursos que são indispensáveis para cumprir o trabalho. Este princípio se aplica à várias áreas onde segurança é primordial. Especificamente no projeto de classes ele guia a ideia de encapsulamento, partindo do pressuposto de que tudo deve ser encapsulado, ou invisível, até que se tenha um motivo plausível para liberar.

Considere um sistema para vender ingressos para um sessão no cinema. O projeto inicial poderia ser como o a seguir:

// Sessao.java
class Sessao {

  String filme;
  boolean[] ocupados;
  int vendidos;

  Sessao(String filme, int assentos) {
    this.filme = filme;
    this.ocupados = new boolean[assentos];
  }

  String getFilme() {
    return this.filme;
  }

  int getVagas() {
    this.calculaVendidos();
    return ocupados.length - vendidos;
  }

  void calculaVendidos() {
    this.vendidos = 0;
    for (boolean vendido : this.ocupados) {
      if (vendido) this.vendidos++;
    }
  }

  boolean isAssentoVago(int numero) {
    return ! this.ocupados[numero - 1];
  }

  boolean reservar(int numero) {
    if (this.isAssentoVago(numero)) {
      this.ocupados[numero - 1] = true;
      return true;
    }
    return false;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Sessao jobs = new Sessao("Jobs", 25);
    System.out.println(jobs.getFilme().equals("Jobs"));
    System.out.println(jobs.getVagas() == 25);
    System.out.println(jobs.isAssentoVago(9) == true);
    jobs.reservar(9);
    System.out.println(jobs.isAssentoVago(9) == false);
    System.out.println(jobs.getVagas() == 24);
    // há razões para acessar o array de ocupados?
    // System.out.println(jobs.ocupados);
    // ou o nro de vendidos:
    // System.out.println(jobs.vendidos);
  }
}

No exemplo anterior, a classe Sessao foi codificada como vinha sendo feito neste livro. Foram adicionados acessores, mas não foi planejado, de fato, o encapsulamento. Do ponto de vista do Princípio do Mínimo Privilégio, a mesma classe deveria ser projetada com todos os membros privados e constantes, e serem publicizados ou tornados variáveis apenas se for estritamente necessário. Portanto, seguindo princípio a risca, a classe seria projetada assim:

// Sessao.java
private class Sessao {

  private final String filme;
  private final boolean[] ocupados;
  private final int vendidos;

  private Sessao(String filme, int assentos) {
    this.filme = filme;
    this.ocupados = new boolean[assentos];
  }

  private String getFilme() {
    return this.filme;
  }

  private int getVagas() {
    this.calculaVendidos();
    return ocupados.length - vendidos;
  }

  private void calculaVendidos() {
    this.vendidos = 0;
    for (boolean vendido : this.ocupados) {
      if (vendido) this.vendidos++;
    }
  }

  private boolean isAssentoVago(int numero) {
    return ! this.ocupados[numero - 1];
  }

  private boolean reservar(int numero) {
    if (this.isAssentoVago(numero)) {
      this.ocupados[numero - 1] = true;
      return true;
    }
    return false;
  }
}

Evidentemente, uma boa parte do que foi “privatizado” deve ser liberado. Sendo a própria classe e seu construtor privados ela mesma não será acessível ou instanciável em qualquer parte do programa. Portanto, haveriam as alterações public class Sessao e public Sessao(String filme, int assentos). Quanto aos atributos filme, ocupados e vendidos, os dois primeiros podem ser constantes, já que o nome do filme não será alterado, tampouco a quantidade de assentos. A exceção é o atributo vendidos, que precisa ser calculado pelo método calculaVendidos, portanto este perde o final. O modificador private segue para todos os atributos. A última parte é a revisão dos métodos. É necessário saber o título do filme, logo private String getFilme() torna-se public String getFilme(). O mesmo acontece para o número de vagas, logo ele é publicizado também public int getVagas(). A consulta de assento vago e a operação reservar também devem ser publicizados. A exceção é o método private void calculaVendidos() que existe apenas como utilitário para o cálculo de vagas e não há nenhum motivo para ele ser invocado de fora da classe Sessao, isto é, ele é um método interno e continua private. Ao fim desta análise a classe ficaria, então, como a seguir:

// Sessao.java
public class Sessao {
  // os atributos sempre private e criteriosamente os que não serão final
  private final String filme;
  private final boolean[] ocupados;
  private int vendidos;
  // é preciso pelo menos um construtor público
  public Sessao(String filme, int assentos) {
    this.filme = filme;
    this.ocupados = new boolean[assentos];
  }
  // método consulta, ok!
  public String getFilme() {
    return this.filme;
  }
  // método consulta com estado calculado, ok!
  public int getVagas() {
    this.calculaVendidos();
    return this.ocupados.length - vendidos;
  }
  // operação interna e auxiliar, deve ser invisível/inacessível fora do objeto
  private void calculaVendidos() {
    this.vendidos = 0;
    for (boolean vendido : this.ocupados) {
      if (vendido) this.vendidos++;
    }
  }
  // necessário para o preenchimento de uma "tela" de assentos vagos
  public boolean isAssentoVago(int numero) {
    return ! this.ocupados[numero - 1];
  }
  // operação de compra ou reserva do assento precisa ser acessível
  public boolean reservar(int numero) {
    if (this.isAssentoVago(numero)) {
      this.ocupados[numero - 1] = true;
      return true;
    }
    return false;
  }
}

Resumindo, o princípio sugere que tudo deve ser privado (e constante) e que se vá liberando o que for realmente indispensável para operar o objeto. Ademais, existe uma importante lição que pode ser retirada deste exemplo: tudo o que é privado é considerado IMPLEMENTAÇÃO e tudo o que público é considerado INTERFACE do objeto.

7.8 Abstração e a interface dos objetos

A abstração de um objeto é composta por todos seus membros públicos e seu contrato de funcionamento, isto é, o comportamento esperado. A abstração é uma simplificação do objeto. Junto com o encapsulamento, que impõe uma limitação do que é público, chega-se a um conjunto mínimo de exposição que, ao final, forma a interface do objeto.

Portanto, abstração, encapsulamento se complementam para chegar na interface. O conjunto de construtores, propriedades e operações públicas foram a interface do objeto. É através dela que o objeto é usado, que é operado. Os objetos comunicam-se uns com os outros através de suas interfaces, apenas através do que é visível, o que está oculto é implementação.

Retomando o último exemplo de código no tópico anterior, para todos os efeitos, a classe e objetos desta são vistos assim do lado de fora:

// INTERFACE DE SESSAO == tudo que é público
public class Sessao {
  public Sessao(String filme, int assentos)
  public String getFilme()
  public int getVagas()
  public boolean isAssentoVago(int numero)
  public boolean reservar(int numero)
}

Tudo que não é interface, que não é visível, que é encapsulado, é implementação. Por que é importante separar a implementação da interface? Primeiro, a interface (o que é público) é abstração do objeto, já que representa o mínimo de funcionalidades para operá-lo. Segundo, toda a implementação pode ser alterada sem precisar alterar as classes que usam o objeto. Por exemplo, não é preciso alterar nada de App.java para fazer as seguintes alterações na implementação de Sessao:

// Sessao.java
import java.util.HashSet;
public class Sessao {

  private final String filme;
  private final HashSet ocupados = new HashSet(); // em vez de: boolean[] ocupados;
  private final int assentos; // em vez de: int vendidos

  public Sessao(String filme, int assentos) {
    this.filme = filme;
    this.assentos = assentos;
  }

  public String getFilme() {
    return this.filme;
  }

  public int getVagas() {
    // não é necessário calcular os vendidos
    return this.assentos - this.ocupados.size();
  }

  // método calculaVendidos é desnecessário sem o array

  public boolean isAssentoVago(int numero) {
    return ! this.ocupados.contains(numero); // consulta o conjunto (Set)
  }

  public boolean reservar(int numero) {
    if (this.isAssentoVago(numero)) {
      this.ocupados.add(numero);
      return true;
    }
    return false;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    // NENHUMA ALTERAÇÃO NA CLASSE APP JÁ QUE ELA TEM ACESSO APENAS AOS
    // MÉTODOS PÚBLICOS OU INTERFACE DO OBJETO E NÃO À IMPLEMENTAÇÃO
    Sessao jobs = new Sessao("Jobs", 25);
    System.out.println(jobs.getFilme().equals("Jobs"));
    System.out.println(jobs.getVagas() == 25);
    System.out.println(jobs.isAssentoVago(9) == true);
    jobs.reservar(9);
    System.out.println(jobs.isAssentoVago(9) == false);
    System.out.println(jobs.getVagas() == 24);
  }
}

No exemplo anterior foi apresentada uma implementação bem diferente da inicial, usando um HashSet (conjunto) em vez de um array de booleans para identificar os assentos vendidos. O mais importante: a interface não foi alterada, os métodos públicos seguem iguais e respeitando o contrato (que é outro modo de dizer que a abstração é mantida).

Existem outros modos, diversos, de implementar esta classe sem alterar sua interface, e esta é uma característica fundamental provida pelo encapsulamento: a possibilidade de variar a implementação, de mudar o que está oculto sem quebrar os clientes da classe que a usam através de sua parte visível (a interface).

7.9 Considerações

Não é por acaso que este capítulo seja tão longo, o encapsulamento é um dos conceitos mais importantes para a POO. O próprio conceito mais abrangente, de ocultação de informações, é essencial para o desenvolvimento de software em qualquer linguagem e paradigma. Portanto, em linhas gerais, bom software é aquele em que a visibilidade das informações foi cuidadosamente planejada.

Para fazer este controle do que é visível/acessível ou não, especificamente, é necessário observar os recursos das linguagens e plataformas onde se está desenvolvendo. O mais comum são os modificadores de acesso, que permitem definir o que, no mínimo, é público ou privado. Se não houverem recursos como este, pelo menos deve-se seguir as convenções de código que servem para indicar informações sensíveis (como o _ antes dos nomes).

Finalmente, lembrando que o encapsulamento é trabalhado junto com a abstração para, no fim, definir o que é implementação e o que é interface do objeto. Todos estes conceitos serão usados frequentemente no decorrer do livro, então assegure-se de conhecê-los bem. Releia este capítulo se achar necessário.

7.10 Exercícios

A seguir alguns exercícios para trabalhar com o encapsulamento e seus princípios.

Encapsular Fracao

A seguir estão os casos de teste para a classe Fracao. No entanto, os testes esperam que os atributos numerador e denominador sejam visíveis. O objetivo é implementar Fracao, mas substituir nos testes o acesso ao atributo pelo acesso à propriedade com getter. Por exemplo, System.out.println(f1.numerador); deve tornar-se System.out.println(f1.getNumerador()); e o acesso direto f1.numerador deve ser impossibilitado (o atributo numerador deve ser marcado como private).

// App.java
public class App {
  public static void main(String[] args) {
    Fracao f1 = new Fracao(1, 5);
    System.out.println(f1.numerador); // 1
    System.out.println(f1.numerador == 1);
    System.out.println(f1.denominador); // 5
    System.out.println(f1.denominador == 5);

    Fracao f2 = new Fracao(1, 5);
    System.out.println(f2.numerador == 1);
    System.out.println(f2.denominador == 5);

    f2.mais(2, 5); // +2/5
    System.out.println(f2.numerador == 3);
    System.out.println(f2.denominador == 5);

    f2.mais(f1.numerador, f2.denominador);
    System.out.println(f2.numerador == 4);
    System.out.println(f2.denominador == 5);

    Fracao f3 = new Fracao(3, 7);
    System.out.println(f3.numerador == 3);
    System.out.println(f3.denominador == 7);

    f3.mais(f2.numerador, f2.denominador);
    System.out.println(f3.numerador == 43);
    System.out.println(f3.denominador == 35);

    // Começando com as alternativas
    Fracao f4 = new Fracao(6); // denominador padrão 1
    System.out.println(f4.numerador == 6);
    System.out.println(f4.denominador == 1);

    Fracao f5 = new Fracao(); // numerador padrão 0
    System.out.println(f5.numerador == 0);
    System.out.println(f5.denominador == 1);

    // Fração inválida
    // denominador 0 deve lançar IllegalArgumentException
    Fracao f6 = new Fracao(2, 0); // deve quebrar aqui
    System.out.println(f6.denominador == 0); // não deve chegar aqui
    // comente as linhas após fazer quebrar

    // Operações não suportadas
    // não lidaremos com frações negativas
    // deve lançar UnsupportedOperationException
    Fracao f7 = new Fracao(2, -5);
    Fracao f8 = new Fracao(-2, 5);
    Fracao f9 = new Fracao(-2, -5);
    // comente as linhas após fazer quebrar

    // desafio: especificar e implementar as outras operações (opcional)
  }
}

Implementar Time

Considere um instante no tempo em horas, minutos e segundos, entre 00:00:00 e 23:59:59. Implementar construtores e métodos para lidar com esse tempo de maneira fail-safe (sem rejeitar as entradas, mas adaptando-as). A interface do objeto deve ser implementada na língua inglesa com construtores para h:m:s, h:m e somente h.

O projeto deve seguir o princípio do mínimo privilégio, isto é, a classe Time deve ter seus atributos encapsulados. Considere como exercício, também, adicionar um método auxiliar interno privado.

// App.java
public class App {
  public static void main(String[] args) {
    Time t1 = new Time();
    System.out.println(t1.getHours() == 0);
    System.out.println(t1.getMinutes() == 0);
    System.out.println(t1.getSeconds() == 0);

    Time t2 = new Time(1, 40, 5);
    System.out.println(t2.getHours() == 1);
    System.out.println(t2.getMinutes() == 40);
    System.out.println(t2.getSeconds() == 5);

    // somar objetos time
    t1.plus(t2);
    System.out.println(t1.getHours() == 1);
    System.out.println(t1.getMinutes() == 40);
    System.out.println(t1.getSeconds() == 5);

    t1.plus(t2);
    System.out.println(t1.getHours() == 3);
    System.out.println(t1.getMinutes() == 20);
    System.out.println(t1.getSeconds() == 10);

    t2.plusHours(1);
    System.out.println(t2.getHours() == 2);
    t2.plusHours(23);
    System.out.println(t2.getHours() == 1);
    System.out.println(t2.getMinutes() == 40);
    System.out.println(t2.getSeconds() == 5);
    t2.plusMinutes(10);
    System.out.println(t2.getMinutes() == 50);
    t2.plusSeconds(50);
    System.out.println(t2.getSeconds() == 55);
    t2.plusMinutes(15);
    System.out.println(t2.getHours() == 2);
    System.out.println(t2.getMinutes() == 5);
    System.out.println(t2.getSeconds() == 55);
    t2.plusSeconds(10);
    System.out.println(t2.getMinutes() == 6);
    System.out.println(t2.getSeconds() == 5);
  }
}

Time com apenas UM ATRIBUTO

Tu provavelmente implementaste Time com três atributos: horas, minutos e segundos (ou equivalente em inglês). Tua missão, se decidires aceitá-la, é deixar apenas o atributo dos segundos para acumular todo o tempo nele. Os testes seguem os mesmos do exercício anterior, afinal, não é a interface que está sendo mudada, é a implementação!

8. Identidade & Igualdade

“Eu não sou um ótimo programador; sou apenas um bom programador com ótimos hábitos.”

Kent Beck

A cada invocação do construtor (instrução new) um objeto é instanciado. Instância e objeto significam, para todos os efeitos, a mesma coisa. Em um sistema orientado a objetos há centenas, até milhares, de instâncias na memória ao mesmo tempo. Durante todo o tempo de execução são milhões, bilhões de instâncias, objetos que são construídos e destruídos. Neste mar de instâncias, de objetos, como saber diferenciá-los uns dos outros?

São várias perguntas que se pretende não deixar sem resposta, tais como: o que faz um objeto ser único? quando dois objetos são considerados iguais? existem casos de objetos maiores ou menores que outros? Estas questões e outros pontos específicos são discutidos aqui, neste capítulo.

8.1 Conceito de Identidade

Cada objeto instanciado é único em um sistema. Para isso, a identidade é a propriedade que garante esta unicidade e que uma instância particular seja distinguida de todas as outras.

Portanto, todos os objetos, ou seja, a cada new executado, um identificador de objeto (object identifier ou oid, em inglês) é criado para representar esta instância particular.

O oid é uma propriedade interna dos objetos. O controle e gerenciamento do identificador é realizado pela linguagem/plataforma de programação usada. Ou seja, este é um problema dos projetistas de linguagens. Enquanto aos programadores que usam a linguagem ou plataforma cabe apenas entender que cada instância recebe um identificador único.

Na maioria da linguagens orientadas a objetos, a identidade está conectada com a referência. Isto é, quando parece que uma variável armazena um objeto ela, de fato, armazena a referência para o objeto. As referências recebem um identificador, dado e gerenciado pelo runtime environment ou runtime system.

Para exemplificar este controle automático da identidade considere o seguinte exemplo escrito na linguagem Ruby:

# identidade.rb
require 'date'
data1 = Date.new 2021, 01, 01
data2 = Date.new 2020, 12, 31
data3 = Date.new 2021, 01, 01
puts data1.object_id # imprime 60
puts data2.object_id # imprime 80
puts data3.object_id # imprime 100
data4 = data2
puts data4.object_id # imprime 80

Considere o código anterior. São 3 instâncias (3 objetos), embora existam 4 variáveis data1, data2, data3 e data4. Os objetos são contados pelas instruções new. Cada um recebeu um oid gerenciado pelo runtime do Ruby (que pode ser diferente na sua máquina!). Importante notar que embora os objetos armazenados em data1 e data3 tenham o mesmo estado, isto é, guardem os mesmos valores, eles não têm o mesmo identificador. Por outro lado, data2 e data4 referenciam o mesmo objeto, que é evidenciado pela identidade.

Objetos na Memória
Objetos na Memória

A imagem anterior apresenta uma possível representação dos objetos demonstrados no código de exemplo. Não pretende-se estender neste livro em como a memória funciona. No entanto, esta explicação dos conceitos básicos pode ajudar a entender o conceito de identidade. A memória, tipicamente, é divida em dois espaços stack (pilha) e heap (monte). Variáveis, valores inteiros, um char, métodos, booleanos e outros não-objetos são armazenados na stack. Os objetos, por outro lado, são armazenados no heap. Algumas variáveis são referências para estes objetos no heap, como pode ser visto na ilustração. No final das contas, elas guardam endereços de memória, no entanto está na figura representado apenas os oid’s. Para visualizar o exemplo em código visualmente, data1 e data3 são referências para objetos diferentes, enquanto data2 e data4 fazem referência ao mesmo objeto.

Na linguagem Java não é diferente, pois o runtime (JRE) também armazena um oid para as instâncias durante a execução. No entanto, diferente de Ruby, não há acesso ao número identificador. Considere o código a seguir:

// Estacao.java
public class Estacao {
  // atributos encapsulados
  private final String[] estacoes = {"verão", "outono", "inverno", "primavera"};
  private int estacao = 0; // verão (primeiro índice de this.estacoes)
  // construtor padrão/inicializador
  public Estacao() {}
  // construtor sobrecarregado
  public Estacao(String estacao) {
    switch (estacao) {
      case "verão":     this.estacao = 0; break;
      case "outono":    this.estacao = 1; break;
      case "inverno":   this.estacao = 2; break;
      case "primavera": this.estacao = 3; break;
      default: throw new IllegalArgumentException("Estação desconhecida " + estacao);
    }
  }
  // getter/estado
  public String getEstacao() {
    return this.estacoes[this.estacao];
  }
  // operação/comportamento
  public void avancar() {
    this.estacao = (this.estacao + 1) % 4;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Estacao est1 = new Estacao();
    Estacao est2 = new Estacao("inverno");
    Estacao est3 = est1;
    // são três variáveis, mas dois objetos Estação
    System.out.println(est1); // Estacao@6ff3c5b5
    System.out.println(est2); // Estacao@3764951d
    System.out.println(est3); // Estacao@6ff3c5b5
    // seguem os estados:
    System.out.println(est1.getEstacao()); // verão
    System.out.println(est2.getEstacao()); // inverno
    System.out.println(est3.getEstacao()); // verão
    // o objeto @6ff3c5b5 vai mudar de estado
    est1.avancar();
    // est1 e est3 se referem ao mesmo objeto @6ff3c5b5
    System.out.println(est1.getEstacao()); // outono
    System.out.println(est2.getEstacao()); // inverno
    System.out.println(est3.getEstacao()); // outono
  }
}

Para entender bem o exemplo do código anterior é preciso acompanhar os prints. São instanciados dois objetos Estacao, logo são gerados dois oid’s, que aparecem como @6ff3c5b5 e @3764951d. Cada um tem seu estado, que é impresso a seguir como verão, inverno e verão. Importante: não é o caso em que as variáveis est1 e est3 armazenam verão, mas sim que elas referenciam o mesmo objeto que abriga o estado verão. Portanto, quando o objeto @6ff3c5b5 muda de estado através de est1.avancar(), este estado é refletido nos últimos prints quando est1 e est3 imprimem outono. Pode-se pensar nestas variáveis como se fossem apenas controles remotos para o mesmo objeto. Por isso, executando est3.avancar() vai causa mudanças de estado que serão acessíveis por est1 também.

8.2 Conceito de Igualdade

A identidade dos objetos está fora do controle do programador, mas aos cuidados do ambiente de execução. Por outro lado, a igualdade ou equivalência dos objetos pode ser codificada. Isto é, dois objetos, com identidades diferentes, podem ser considerados equivalentes ou iguais segundo um algoritmo de comparação. Por exemplo, considere um ponto no espaço 2D e suas coordenadas X e Y. A seguir uma implementação em Java:

// Ponto.java
public class Ponto {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    // O operador == compara a identidade, logo
    // false, não são o mesmo objeto:
    System.out.println(p1 == p2);
    // false, não são o mesmo objeto mesmo que guardem os mesmos valores:
    System.out.println(p1 == p3);
    // true, pois as variáveis p2 e p4 referenciam o mesmo objeto:
    System.out.println(p2 == p4);
  }
}

Conforme o código do exemplo anterior, os objetos da classe Ponto possuem duas propriedade, X e Y. No entanto elas não interferem na identidade dos objetos. No exemplo, há três instâncias (três new’s) e quatro variáveis (p1, p2, p3 e p4). A única comparação que retorna true é a de p2 e p4, pois referenciam o mesmo objeto, ou seja, objeto com a mesma identidade. Este é o comportamento esperado. Em Java, o operador == compara a identidade dos objetos. No entanto, se fosse considerada a equivalência, p1 e p3 seriam iguais, embora sejam instâncias diferentes, não apenas porque têm atributos iguais, mas porque no problema em questão ambos possuem a mesma representação, isto é, denotam um mesmo lugar no espaço 2D.

Então, a igualdade é a equivalência das instâncias segundo as regras de domínio. Mesmo se instâncias diferentes, os objetos ainda são considerados iguais se representam uma informação equivalente, onde um pode ser usado no lugar do outro sem prejuízo para o programa.

8.3 Implementando a igualdade com equals

O modo como o algoritmo de comparação e equivalência é implementado varia de acordo com a linguagem de programação em uso. Enquanto em algumas linguagens é possível escrever o comportamento diretamente do operador == (Ruby, por exemplo), em outras isto só é possível através da escrita de um método específico, como __eq__ em Python e equals e Equals em Java e C# respectivamente.

Como dito, na linguagem Java, o algoritmo de comparação da equivalência é codificado no método equals. Todos os objetos vêm com o método equals herdado de uma superclasse comum chamada Object (a herança será vista em detalhes no Capítulo 22). No entanto, a classe Object possui a seguinte implementação13:

package java.lang;
public class Object {
  // ...
  /**
   * ...
   * @param   obj   the reference object with which to compare.
   * @return  {@code true} if this object is the same as the obj
   *          argument; {@code false} otherwise.
   */
  public boolean equals(Object obj) {
    return (this == obj);
  }
}

Portanto, mesmo que o App.java seja escrito como a seguir, as comparações seguem considerando apenas a identidade:

// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    // O método equals compara a identidade internamente,
    // logo os resultados são os mesmos que o operador ==
    System.out.println(p1.equals(p2)); // false
    System.out.println(p1.equals(p3)); // false
    System.out.println(p2.equals(p4)); // true
  }
}

Assim, para os objetos do tipo Ponto serem comparados segundo seus valores, o método equals deve ser sobrescrito (escrito por cima do que foi herdado). Este procedimento varia de linguagem para linguagem. Java, no entanto, possui um contrato específico, descrito na documentação de equals e disponível neste link https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-. Segue minha tradução livre:

public boolean equals(Object obj)

Indica caso algum outro objeto "é igual a" este daqui.
O método equals implementa uma relação de equivalência em referências não-nulas:
  1. Ele é reflexivo: para qualquer referência não-nula X, X.equals(X) deve retornar verdadeiro.
  2. Ele é simétrico: para quaisquer referências não-nulas X e Y, X.equals(Y) deve retornar verdadeiro\
 se e somente se Y.equals(X) retornar verdadeiro.
  3. Ele é transitivo: para quaisquer referências não-nulas X, Y e Z, se X.equals(Y) retorna verdadeir\
o e Y.equals(Z) retorna verdadeiro, então X.equals(Z) deve retornar verdadeiro.
  4. Ele é consistente: para quaisquer referências não-nulas X e Y, múltiplicas chamadas à X.equals(Y)\
 consistentemente retornam verdadeiro ou consistentemente retornam falso, considerando que nenhuma inf\
ormação usada na comparação de ambos objetos foi modificada.
  5. Para qualquer referência não-nula X, X.equals(null) deve retornar falso.

O método equals para a classe Object implementa a equivalência mais possivelmente discriminativa dos o\
bjetos; isto é, para quaisquer referências não-nulas X e Y, este método retorna verdadeiro se e soment\
e se X e Y se referem ao mesmo objeto (X == Y é verdadeiro).

Note que é geralmente necessário sobrescrever o método hashCode sempre quando este método é sobrescrit\
o, de modo a manter o contrato geral para o método hashCode, que declara que objetos iguais devem ter \
os mesmos hashes.

Diferente do original, eu apenas numerei os tópicos do contrato para demonstrar sua implementação. No código a seguir está a classe Ponto com equals e hashCode implementados:

// Ponto.java
public class Ponto {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }

  // a anotação @Override é recomendada nos métodos sobrescritos
  @Override // o método deve receber Object, SEMPRE, e não Ponto ou outro
  public boolean equals(Object outroObjeto) {
    // primeiro, se é a mesma instância retorna verdadeiro (ver regra 1 do contrato: reflexivo)
    if (this == outroObjeto) return true;
    // segundo, verificar se não é nulo (ver regra 5: equals(null) é sempre falso)
    if (outoObjeto == null) return false;
    // verificar se o outro objeto é um Ponto (ou a classe esperada)
    if (outroObjeto instanceof Ponto) {
      // fazer a coerção segura de outro objeto para o tipo Ponto para acessar seus atributos
      Ponto outroPonto = (Ponto) outroObjeto;
      if (this.x == outroPonto.x && this.y == outroPonto.y) return true;
    }
    // se não for uma instância de Ponto ou não tiver os mesmos valores retorna false
    return false;
  }
  // recomenda-se sobrescrever hashCode quando se sobrescreve equals
  @Override
  public int hashCode() {
    // usar os mesmos atributos usados no algoritmo do equals para calcular o hash
    return this.x * 3 + this.y * 5;
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    Ponto p5 = new Ponto(30, 40);

    System.out.println(p1.equals(p2)); // false, ok

    System.out.println(p4.equals(p2)); // true
    System.out.println(p1.equals(p1)); // TRUE! É reflexivo.

    System.out.println(p1.equals(p3)); // true
    System.out.println(p3.equals(p1)); // TRUE! É simétrico.

    System.out.println(p1.equals(p3)); // true
    System.out.println(p3.equals(p5)); // true
    System.out.println(p1.equals(p5)); // TRUE! É transitivo

    System.out.println(p1.equals(p3)); // true
    System.out.println(p1.equals(p3)); // true
    System.out.println(p1.equals(p3)); // TRUE! É consistente

    System.out.println(p1.equals(null)); // FALSE! Sempre é falso se comparado com NULL
  }
}

O código anterior demonstra uma maneira muito didática de implementar o equals. É possível usá-la como modelo. Porém, se for buscado no Google algo como “equals Java”, ou a linguagem que preferir, poderão ser vistas várias implementações de equals, todas (ou quase) igualmente válidas, algumas mais enxutas e mais sofisticadas que esta apresentada. Como saber se são válidas? Se passam nas regras do contrato. Nos casos de teste, são verificadas todas as regras do contrato de equals: reflexividade, simetria, transitividade, consistência, o caso excepcional do null. Sabe-se que o equals está ok se estes casos estão cobertos.

8.4 Comparabilidade

Enquanto a igualdade permite definir se dois objetos são (ou não) equivalentes, a comparabilidade permite dizer um objeto é menor ou maior que outro. Em algumas linguagens de programação, inclusive, a igualdade é implementada no mesmo conjunto da comparabilidade, já que igual pode ser inferido de um objeto nem menor e nem maior que outro.

Na linguagem Ruby, por exemplo, há um módulo que pode ser adicionado às classes chamado Comparable (comparável, em Português), que adiciona os operadores <, <=, ==, !=, >=, e >, mais o método between?. No entanto, não são todas as linguagens que oferecem um módulo, interface ou outra construção para tornar um objeto comparável. JavaScript, por exemplo, precisa de uma função de classificação para ordenar um array.

É preciso dizer, porém, que a comparabilidade é secundária se considerar a igualdade. Os objetos devem ser comparáveis para serem classificados. No entanto, se não for necessário classificá-los ou reordená-los de alguma forma, não há porque torná-los comparáveis.

Na linguagem Java, especificamente, há uma interface chamada Comparable<T> que obriga a implementação de um método compareTo(T): int. O método compareTo deve retornar um número negativo se o objeto em questão for menor que o passado, um número positivo se for maior, ou zero se for igual. No caso da linguagem Java, não há a necessidade de consistência com o equals. Isto é, um objeto pode ser equals outro e seu comparador não retornar zero e vice-e-versa. O contrato de comparable está disponível aqui https://docs.oracle.com/javase/8/docs/api/java/lang/Comparable.html.

O que é considerado para comparação faz parte das regras do domínio. Por exemplo, na comparação de cartões de crédito poderia ser usado o critério de qual tem mais saldo disponível, qual foi usado mais recentemente ou até qual possui maior limite. Enfim, essas regras são implementadas no método de comparação.

Por exemplo, considere um classe para representar Peso, que possui uma implementação bem direta e simples como a seguir:

// Peso.java
public class Peso {
  private int gramas;
  public Peso(int gramas) {
    this.gramas = gramas;
  }
  public int getGramas() {
    return this.gramas;
  }
  public double getKilos() {
    return this.gramas / 1000.0;
  }
}

Para tornar objetos do tipo Peso comparáveis deve-se implementar a interface Comparable<T> como class Peso implements Comparable<Peso>. O exemplo de como isto é feito e os testes estão no código a seguir:

// Peso.java
public class Peso implements Comparable<Peso> { // implementar Comparable<T> onde T é Peso
  private int gramas;
  public Peso(int gramas) {
    this.gramas = gramas;
  }
  public int getGramas() {
    return this.gramas;
  }
  public double getKilos() {
    return this.gramas / 1000.0;
  }
  // método compareTo
  @Override
  public int compareTo(Peso outroPeso) {
    // se este tiver menos gramas retornará negativo,
    // se tiver mais retornará negativo, e zero se os pesos se anularem
    return this.gramas - outroPeso.gramas;
  }
  // para imprimir os objetos
  @Override
  public String toString() {
    return this.gramas + "g";
  }
}
// App.java
import java.util.Arrays; // métodos utilitários para lidar com arrays

public class App {
  public static void main(String[] args) {
    Peso p1 = new Peso(400);
    Peso p2 = new Peso(1200);
    Peso p3 = new Peso(400);
    Peso p4 = new Peso(9600);
    Peso p5 = new Peso(100);

    System.out.println(p1.compareTo(p2)); // -800
    System.out.println(p4.compareTo(p2)); // 8400
    System.out.println(p1.compareTo(p3)); // 0

    System.out.println(p1.compareTo(p2) < 0); // true, p1 é menor que p2
    System.out.println(p4.compareTo(p2) > 0); // true, p4 é maior que p2
    System.out.println(p1.compareTo(p3) == 0); // true, p1 e p3 têm o mesmo valor

    // um array de pesos
    Peso[] pesos = {p1, p2, p3, p4, p5};
    System.out.println(Arrays.toString(pesos)); // imprime [400g, 1200g, 400g, 9600g, 100g]
    Arrays.sort(pesos); // Arrays.sort usa o método compareTo internamente para ordenar
    System.out.println(Arrays.toString(pesos)); // imprime [100g, 400g, 400g, 1200g, 9600g]
  }
}

No exemplo anterior a comparabilidade foi implementada em Peso baseada na quantidade de gramas. É bastante direto, isto é, não aberto a interpretação. Em outras classes pode exigir mais planejamento. Por exemplo, considere novamente a classe Ponto. Quando um ponto é maior ou menor que outro? A sugestão, é que a distância da origem 0, 0 dê aos pontos a noção de maior ou menor. Porém, exige cuidado, um Ponto(-12, -8) é maior que Ponto(10, 5). Uma implementação possível está a seguir:

// Ponto.java
public class Ponto implements Comparable<Ponto> {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }

  @Override
  public int compareTo(Ponto outroPonto) {
    // considerando as posições sem sinal (também poderia ser calculada )
    return (Math.abs(this.x) + Math.abs(this.y)) -
      (Math.abs(outroPonto.x) + Math.abs(outroPonto.y));
    // Ou usando o Teorema de Pitágoras
    // return Math.sqrt(Math.pow(Math.abs(this.x), 2) + Math.pow(Math.abs(this.y), 2)) -
      // Math.sqrt(Math.pow(Math.abs(outroPonto.x), 2) + Math.pow(Math.abs(outroPonto.y), 2));
  }

  // o método toString permite imprimir objetos Ponto
  @Override
  public String toString() {
    return "(" + this.x + ", " + this.y + ")";
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(-12, -8);
    Ponto p2 = new Ponto(10, 5);
    Ponto p3 = new Ponto(2, -3);
    // um array de pontos
    Ponto pontos = {p1, p2, p3};
    // testes:
    System.out.println(Arrays.toString(pontos)); // imprime [(-12, -8), (10, 5), (2, -3)]
    Arrays.sort(pontos); // Arrays.sort usa o método compareTo internamente para ordenar
    System.out.println(Arrays.toString(pontos)); // imprime [(2, -3), (10, 5), (-12, -8)]
  }
}

Este último exemplo pode esclarecer que o algoritmo de comparação depende das regras do domínio que se está implementando, isto é, dos critérios estabelecidos para o objeto no mundo real.

8.5 Considerações

Este é o primeiro capítulo que não trata o projeto dos objetos individualmente. Em outras palavras, é a primeira vez que se trata de um conjunto de instâncias e da comunicação entre objetos. De fato, objetos não vivem sozinhos. A questão de que eles são identificáveis e que podem ser comparados são os conceitos que devem ser levados deste capítulo. Finalmente, é preciso lembrar que embora ambos conceitos tenham sido exemplificados em Java, eles são implementáveis em quase qualquer linguagem orientada a objetos.

8.6 Exercícios

A seguir alguns exercícios para entender e praticar identidade, igualdade e comparabilidade.

Revisitando Time

Resgatando o exercício do capítulo anterior, sobrescreva equals, hashCode, e implemente Comparable em Time para que passe nos seguintes testes:

// App.java
import java.util.Arrays;

public class App {
  public static void main(String[] args) {
    Time t1 = new Time(18, 40, 20);
    Time t2 = new Time(1, 10, 50);
    Time t3 = new Time();
    Time t4 = new Time(1, 10, 51);
    Time t5 = new Time(18, 40, 20);

    System.out.println(t1.equals(t2) == false);

    System.out.println(t1.equals(null) == false);

    System.out.println(t2.equals(t4) == false);

    System.out.println(t1.equals(t1) == true);

    System.out.println(t1.equals(t5) == true);
    System.out.println(t5.equals(t1) == true);

    System.out.println(t5.hashCode() == t1.hashCode()); // true
    System.out.println(t5.hashCode() != t4.hashCode()); // true

    System.out.println(t1.compareTo(t2) > 0); // true
    System.out.println(t1.compareTo(t5) == 0); // true
    System.out.println(t2.compareTo(t4) < 0); // true

    Time moments = {t1, t2, t3, t4, t5};
    Arrays.sort(moments);
    System.out.println(
      Arrays.toString(moments).equals("[00:00:00, 01:10:50, 01:10:51, 18:40:20, 18:40:20]")
    );

  }
}

Implementar Previsão do Tempo

Projetar uma classe para representar uma previsão do tempo. Alguns requisitos sugeridos são as temperaturas mínima e máxima esperada e as condições como se chove, sol, nublado, etc. Sobrescrever e implementar os métodos para igualdade e comparação, respectivamente. Escrever testes.

9. Representação & Formato

“Sem requisitos e projeto, programação acaba sendo a arte de adicionar bugs a um arquivo de texto em branco.”

Louis Srygley

O modelo de objetos, isto é, o próprio código escrito na forma de uma classe com atributos, já é uma representação, embora bastante abstrata, das informações. No entanto, uma mesma informação pode ser representada de várias formas, seja de forma resumida ou completa, exata ou aproximada. Por exemplo, de onde estou até a porta há uma distância de 150cm, que também pode ser expressa como 1,5m, 59" (59 polegadas), aproximadamente 5' (5 pés) ou até 3 cúbitos. Este capítulo trata das representações alternativas para os objetos e de como converter o objeto para elas e a partir delas.

9.1 Conceito de Representação do Objeto

Os próprios objetos começam com uma ideia, que é uma representação mental: distância, carrinho de compras, postagem, like, enfim, seja uma entidade física ou intangível.

A representação dos objetos em código varia segundo as linguagens, porém, nas linguagens class-based (baseadas em classes) geralmente significa descrevê-los através de classes e atributos, e deste “molde” construir instâncias. Este processo, como já foi discutido, envolve deixar de fora uma série de detalhes para tornar sua implementação viável, passo este conhecido como abstração.

Retomando o exemplo da distância, apresentado no início do capítulo, uma classificação de objetos distância poderia ser realizada como no pseudocódigo a seguir:

// Distância.pseucocódigo
classe Distância

  atributo privado centímetros : inteiro

  construtor (parâmetro cm = 0)
    atributo centímetros = cm
  fim construtor

  método centímetros : textual
    retorna atributo centímetros + "cm"
  fim método

  método polegadas : textual
    retorna (atributo centímetros * 0.3937) + " pol."
  fim método

fim classe

// App.pseudocódigo
usando Distância de Distância.pseudocódigo
procedimento App
  distancia_ate_a_porta = nova Distância(150)
  imprime(distancia_ate_a_porta.centímetros) // 150cm
  imprime(distancia_ate_a_porta.polegadas) // 59,0551 pol.
fim procedimento

Considerando o exemplo anterior, a classe Distância precisa de um padrão para a informação, uma unidade interna. No exemplo, foi codificado como centímetros, no entanto, poderia ser outra unidade. A noção de distância pode ser representada em várias unidades. Na verdade, não só em várias unidades, mas em várias notações, como as 59,0551 pol poderiam ser expressas como 59,0551", ou até aproximada, como 59,1".

São valiosos, nestes casos, dois conceitos previamente discutidos: a abstração e o encapsulamento. A abstração para a simplificação do objeto ao ponto de torná-lo representável em código. O encapsulamento para garantir que o modo como ele é representado internamente, como o atríbuto privado centímetros, não “vaze” para fora. Sendo a representação interna oculta, os usuários do objeto podem tê-lo em qualquer representação por sua interface, como nos métodos centímetros e polegadas (e outros poderiam ser adicionados).

As representações alternativas também servem tipicamente para expressar o objeto para apresentação, seja visual, audível, etc. Geralmente, se busca uma representação textual primeiro, que será usada nas interfaces com usuários, como uma aplicação web ou desktop que precisa apresentar (ou imprimir) o objeto. Por isso, é bastante comum oferecer uma representação textual (String), mesmo que simplificada. Por exemplo, considere o seguinte pseudocódigo:

// Celular.pseucocódigo
classe Celular

  atributo privado marca : textual
  atributo privado modelo : textual
  atributo privado memória : número inteiro
  atributo privado nrosérie : textual
  atributo privado so : textual
  // ... dúzias de outros de atributos

  método textualmente : textual
    retorna atributo marca   + " " +
            atributo modelo  + " " +
            atributo memória + "GB"
  fim método

fim classe

No exemplo, a classe e objetos do tipo Celular teriam dúzias de atributos, mas para uma impressão rápida, como um imprime(cel) poderiam ser usados apenas alguns atributos chave que discriminem o aparelho de forma resumida. Neste caso, o método para_textual permitiria escrever o código imprime(celular.textualmente), que providenciaria uma saída como Motorália M2020 64GB, por exemplo.

Enfim, podem ser adicionados quantos métodos de conversão, ou representações alternativas, for necessário. Objetos também podem ser instanciados a partir de representações variadas. Logo, nos tópicos a seguir será discutido como, na prática, se fornece representações e se cria objetos a partir delas.

9.2 A convenção dos métodos toTipo e fromTipo

Quando um objeto de um determinado tipo (de uma determinada classe) pode ser convertido para outro tipo é praxe escrever um método toTipo (paraTipo se fosse em Português). E quando se converte de outro tipo para o objeto em questão, é usado fromTipo (doTipo se fosse Português). E quando se trata do formato, pode ser usada a mesma convenção.

Por exemplo, considere um objeto para representar valor monetário, dinheiro ou moeda. Uma representação em pseucódigo poderia ser a seguinte:

// Dinheiro.pseucódigo
classe Dinheiro
  atributo privado reais : número inteiro
  atributo privado centavos : número inteiro

  construtor (reais = 0, centavos = 0)
    atributo reais = reais
    atributo reais = centavos / 100
    atributp centavos = centavos % 100
  fim construtor
  // método para representação textual, ex: "R$ 99,99"
  método paraTextual : Textual
    retorna "R$ " + atributo reais + "," + atributo centavos
  fim método
  // método para representação decimal, ex: 99.99
  método paraNumérico : número real
    retorna atributo reais + atributo centavos / 100.0
  fim método
fim classe

No exemplo apresentado, uma instância pode ser obtida com dindin = novo Dinheiro(9, 99) e depois sua representação textual com imprime(dindin.paraTextual) que retornaria o texto R$ 9,99. O método paraNumérico retornaria a representação decimal 9.99. Portanto, temos a mesma informação presentada de três formas, flexibilizando a apresentação do objeto.

Mas como é o caso contrário? Isto é, como é possível instanciar um Dinheiro a partir de uma informação textual ou decimal. Uma opção, viável, seria sobrecarregar o construtor, ou seja, adicionar mais dois construtores, um recebendo um valor textual e outro decimal. No entanto, uma opção alternativa aos construtores são métodos descritivos de conversão de representação, como doTexto ou doNumero, para obter instâncias de Dinheiro a partir destes métodos responsáveis por fabricá-los.

A questão com os métodos fábrica é que eles devem estar disponíveis para instanciar o objeto antes de sua construção, isto é, eles não são métodos do objeto, pois não há objeto. Métodos que precisam ser chamados sem o objeto são chamados métodos estáticos, pois existem sem a instância e seu estado e pertecem, logo, à classe.

A implementação de um método estático é simples como colocar a palavra-chave estático à frente do método como a seguir:

// Dinheiro.pseucódigo
classe Dinheiro
  // ... <- atributos, constutor e métodos omitidos

  // recebe valor no formato textual, ex: "R$ 12,34"
  método estático doTexto(txt : Textual) : Dinheiro
    // decompor a representação textual para passar ao construtor
    partes = txt.separa(" ", ",") // ["R$", "12", "34"]
    // retorna novo Dinheiro(12, 34)
    retorna novo Dinheiro(inteiro de partes[1], inteiro de partes[2])
  fim método
  // recebe valor no formato decimal, ex: 12.34
  método estático doDecimal(valor : número real) : Dinheiro
    // novo Dinheiro (reais = 12, centavos = 1234.0 - 1200 = 34)
    retorna novo Dinheiro (inteiro de valor, valor * 100 - inteiro de valor * 100)
  fim método
fim classe
// App.pseudocódigo
usando Dinheiro de Dinheiro.pseucódigo
procedimento App

  // A instrução de instanciação "novo" acontece
  // dentro dos métodos fábrica que recebem as
  // representações textual e decimal:

  dindin1 = Dinheiro.doTexto("R$ 1,99")
  dindin2 = Dinheiro.doDecimal(1.99)

fim procedimento

O código do exemplo anterior demonstra o uso de métodos fábrica estáticos doTipo para instanciar Dinheiro. O método doTexto, por exemplo, recebe o Dinheiro na sua representação string e, internamente, decompõe o texto para o obter a representação objeto, isto é, popular os atributos reais e centavos.

A implementação nas linguagens de programação é semelhante ao pseudocódigo apresentado, com poucas variações. Se a implementação fosse em Java ou C# e no idioma Inglês seria Dinheiro.fromString e Dinheiro.fromDouble. Por exemplo, considere a representação de cor, como ela é representada digitalmente. Cores são representadas digitalmente através de números de 24bits reservados 8 bits para o canal vermelho (Red), 8 para o verde (Green) e mais 8 bits para o azul (Blue). Este sistema é conhecido como RGB. Os números de 8 bits abrangem de 0 a 255 no sistema decimal. Portanto, uma class Cor com atributos int red, green, blue seria de bom tamanho inicialmente para a representação objeto de uma cor:

// Cor.java
public class Cor {
  // atributos encapsulados
  private int red, green, blue;

  public Cor(int red, int green, int blue) {
    // salva-guardas / validação
    if (red < 0 || red > 255) {
      throw new IllegalArgumentException("Red fora do invervalo 0-255");
    }
    if (green < 0 || green > 255) {
      throw new IllegalArgumentException("Red fora do invervalo 0-255");
    }
    if (blue < 0 || blue > 255) {
      throw new IllegalArgumentException("Red fora do invervalo 0-255");
    }
    this.red = red;
    this.green = green;
    this.blue = blue;
  }
  // acessores
  public int getRed()   { return this.red; }
  public int getGreen() { return this.green; }
  public int getBlue()  { return this.blue; }

}
// App.java
public class App {
  public static void main(String[] args) {
    // ok para instanciar uma cor
    Cor chocolate = new Cor(210, 105, 30);
  }
}

No código anterior está uma classe básica para representar cores como objetos. O nome da cor bem como os parâmetros foram obtidos no site da W3C em https://www.w3.org/wiki/CSS/Properties/color/keywords. O World Wide Web Consortium mantém a padronização da web, incluindo as palavras-chave para cores que são aceitas pelos navegadores como Edge, Firefox, Chrome, etc. Na linguagem CSS, as cores são representadas a partir de vários sistemas, não apenas o RGB, pois também há o HSL (Hue Saturation Light) e outros. Mesmo em RGB, ela pode se representada pelo nome padronizado, três números decimais (como foi implementado) e como # (hash) e os três números no formato hexadecimal. A cor chocolate, por exemplo, pode ser representada textualmente como rgb(210, 105, 30), #d2691e, hsl(25, 86, 47) e cmyk(0, 50, 86, 18). Para conhecer mais sobre representação codificada de cores é possível experimentar no https://coolors.co - eu já deixei a cor “chocolate web” selecionada. Portanto, a meta agora é permitir que a representação objeto seja convertida para um textual rgb e #hexadecimal, com os métodos toRGBString e toHexString a seguir:

// Cor.java
public class Cor {
  // ... atributos, construtor e acessores omitidos

  public String toRGBString() {
    // os três %d (Decimal integer) são substituídos pelos três inteiros em String.format
    return String.format("rgb(%d, %d, %d)", this.red, this.green, this.blue);
  }

  public String toHexString() {
    // o especificador de formato %x (heXa) converte o inteiro em hexadecimal
    return String.format("#%x%x%x", this.red, this.green, this.blue);
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    // ok para instanciar uma cor
    Cor chocolate = new Cor(210, 105, 30);
    // os métodos "to" em ação
    System.out.println(chocolate.toRGBString()); // rgb(210, 105, 30)
    System.out.println(chocolate.toHexString()); // #d2691e
  }
}

No código anterior foram implementados dois métodos to. O tipo de saída para ambos foi string, variando o formato (rgb ou hex). Outros métodos to poderiam ser incluídos, seja para novos tipos ou formatos. Por exemplo, caso seja útil entregar uma representação em array poderia ser implementado como: int[] toArray() { return new int[]{this.red, this.green, this.blue}; }. Completando este exemplo, considere métodos para instanciar Cor a partir de um hexadecimal, isto é, o método from a seguir:

// Cor.java
public class Cor {
  // ... atributos, construtor, acessores e métodos "to" omitidos

  // aqui está a palavra-chave static, que permite invocar este método antes de invocar "new"
  public static Cor fromHexString(String hashHexadecimal) {
    // string  #d2691e
    // indexes 0123456
    // convertendo a substring "d2" para inteiro, 16 significa base hexa
    int red   = Integer.parseInt(hashHexadecimal.substring(1, 3), 16);
    int green = Integer.parseInt(hashHexadecimal.substring(3, 5), 16);
    int blue  = Integer.parseInt(hashHexadecimal.substring(5, 7), 16);
    return new Cor(red, green, blue);
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    // instanciar a partir do método fábrica estático fromHexString
    Cor chocolate = Cor.fromHexString("#d2691e")
    // ok, a cor foi instanciada da string para objeto
    // e pode ser convertida de volta para string:
    System.out.println(chocolate.toRGBString()); // rgb(210, 105, 30)
  }
}

Considerando o exemplo anterior, outros métodos from poderiam ser adicionados. Uma cor poderia ser instanciada a partir de uma string RGB, um número inteiro, um array de inteiros, etc. É preciso apenas atentar à diferença dos métodos from para os métodos to, que é a declaração de static para poder ser invocado da classe, como foi feito em Cor.fromHexString"#d2691e").

9.3 Representação string | sobrescrevendo toString

Na maioria das linguagens a conversão do objeto para textual (string) é um caso especial e antecipado pela plataforma. Isto é, geralmente há uma estrutura prévia e convenções para esta transformação. O motivo desta padronização acontecer está em como as linguagens usam métodos de impressão, como print, cout, var_dump, etc, de objetos. Elas (as linguagens, e nós programadores e programadoras) precisam que o objeto seja representado de um modo inteligível.

Como o método paraTextual é implementado varia de linguagem para linguagem. Na linguagem Python, por exemplo, é adicionado um método str às classes que determinam a string resultante da representação objeto, como em:

# Retangulo.py
class Retangulo:
  def __init__(self, largura, altura):
    self.largura = largura
    self.altura  = altura

  def __str__(self):
    return '{L}x{A}'.format(L = self.largura, A = self.altura)

r = Retangulo(40, 60) # Em Python não é necessário `new`
print(r) # imprime 40x60 como especificado em __str__

Enquanto Python baseia-se na convenção str, por outro lado, na linguagem Java, todos os objetos já possuem o método toString, embora não muito útil de início, herdado da classe Object (semelhante ao equals e hashCode). Portanto, para implementar a representação textual em Java é necessário sobrescrever o método toString. Isto requer que o método siga a assinatura da classe pai Object sendo @Overrride public String toString(), como no exemplo a seguir:

// Circulo.java
public class Circulo {
  // ...
}
// Retangulo.java
public class Retangulo {
  private int largura, altura;
  public Retangulo(int largura, int altura) {
    this.largura = largura;
    this.altura  = altura;
  }
  public int getLargura() { return this.largura; }
  public int getAltura()  { return this.altura; }

  @Override
  public String toString() {
    return String.format("%dx%d", this.largura, this.altura);
  }
}
// App.java
public class App {
  public static void main(String[] args) {

    Retangulo r = new Retangulo(40, 60);
    // Classe retangulo tem o método toString definido
    System.out.println(r); // imprime 40x60

    // A classe Circulo não tem toString,
    // logo imprime a classe e identidade por padrão
    Circulo c = new Circulo(40);
    System.out.println(c); // imprime Circulo@6ff3c5b5
  }
}

No exemplo anterior foram apresentadas duas classes Retangulo e Circulo. O método toString em Retangulo permite definir como será impresso nas chamadas print e concatenação com strings. Sem a sobrescrita de toString o comportamento padrão, em Java, é imprimir a classe e o id do objeto, como foi no caso do `Circulo, no exemplo.

9.4 Considerações

Qualquer informação digitalizada pode ser tratada e transformada para e de objetos. Contudo, nem sempre é necessário pensar em todos os cenários e popular as classes com métodos to e from. A introdução de métodos de conversão de representações e formatos deve ser bem planejada, para evitar esforço desnecessário. Por fim, este capítulo, além de introduzir a questão da representação e formato, ainda abordou assuntos colaterais, como a sobrescrita, a formatação de strings e até um pouco sobre representação das cores. Os detalhes específicos da linguagem e mais informações sobre o espaço de cores podem ser encontrados facilmente na internet.

9.5 Exercícios

A seguir dois exercícios para trabalhar com representação e transformação.

Representação string de Time

Resgatando o exercício Time, sobrescreva toString para que a hora seja representada textualmente como hh:mm:ss. Adicione, também, um método toInteger que devolta o tempo total em segundos. É possível também adicionar um método fábrica estático fromString para receber uma hora como 18:10:13 e transformá-la num objeto Time. Escreva testes.

Representação string de Fracao

Retomando o exemplo da Fração do Capítulo 7, implemente conforme os casos de teste a seguir:

// App.java
public class App {
  public static void main(String[] args) {
    Fracao frac1 = new Fracao(1, 5);
    double valor1 = frac1.toDouble();
    System.out.println(valor1); // 0.2

    Fracao frac2 = new Fracao(10, 5);
    double valor2 = frac2.toDouble();
    System.out.println(valor2); // 2.0

    String texto1 = frac1.toString();
    System.out.println(texto1); // 1/5

    String texto2 = frac2.toString();
    System.out.println(texto2); // 10/5

    Fracao frac3 = Fracao.fromDouble(0.4375);

    System.out.println(frac3.numerador == 7);
    System.out.println(frac3.denominador == 16);
    System.out.println(frac3); // 7/16    (invoca implicitamente o toString)

    Fracao frac4 = Fracao.fromString("5/6");
    System.out.println(frac3.numerador == 5);
    System.out.println(frac3.denominador == 6);
    System.out.println(frac4); // 5/6
  }
}

10. Imutabilidade & Objetos de Valor

“Os melhores programas são aqueles escritos quando o programador deveria estar trabalhando em outra coisa.”

Melinda Varian

Os objetos são, por padrão, mutáveis. Eles começam com um estado e sofrem transformações ao longo do seu tempo de vida num programa. No entanto, existem benefícios em projetar objetos que nunca mudam após terem seu estado inicial inicializado.

O fato de um objeto ser imutável não significa que ele não faz mudanças no sistema. Isto parece paroxal, mas este capítulo abordará como objetos imutáveis podem ser projetados, implementados e testados, ainda baseando-se nas propriedades básicas dos objetos: retenção de um estado, exibição de um comportamento, e uma identidade.

10.1 Conceito de Imutabilidade

Se diz que são imutáveis as estruturas de dados que não alteram seu estado após terem sido inicializadas. Objetos são estruturas de dados e, portanto, podem ser mutáveis ou imutáveis. Um objeto imutável é aquele que após construído não poderá ter seu estado alterado ou, em outras palavras, é um objeto que não muda.

A imutabilidade não começa com os objetos. Na verdade, começa com as “partículas”, as variáveis e constantes. Enquanto pode-se declarar int x = 1; e mais tarde reatribuir x = 1;, não se pode fazer o mesmo com final int x = 1; (em Java) ou readonly int x = 1; (C#) ou const x = 1 (JavaScript) onde x é constante ou imutável, isto é, não pode ser reatribuído.

Um objeto com todos os seus atributos contantes torna-se imutável. Por exemplo, retomando o exemplo de um ponto no espaço 2D, porém imutável:

// Ponto.java
public class Ponto {
  // constantes são marcadas como "final" em Java
  public final int X; // geralmente são usadas letras maiúsculas para constantes
  public final int Y; // os atributos não estão encapsulados, mas não podem ser alterados
  // atributos constantes obrigam um construtor para inicializá-los
  public Ponto(int x, int y) {
    this.X = x; // a inicialização é obrigatória para constantes
    this.Y = y;
  }

  @Override
  public String toString() {
    return String.format("(%d, %d)", this.X, this.Y);
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(20, 30);
    System.out.println(p1.X); // 20
    System.out.println(p1.Y); // 30
    System.out.println(p1); // (20, 30)
    // p1.X = 5; // não é possível
    // O único meio de obter um ponto (5, 30) é criando um novo Ponto
    p1 = new Ponto(5, 30);
    System.out.println(p1); // (5, 30)
  }
}

Conforme o exemplo anterior, a classe Ponto com atributos constantes resulta em objetos imutáveis. O fato de serem imutáveis implica na necessidade de criar novos objetos se for necessário valores diferentes para os atributos, como o que foi realizado com o exemplo do Ponto, para um ponto com novas coordenadas X e Y é necessário um novo ponto.

Projetar um objeto imutável não é uma tarefa complexa, basta uma classe com todos os atributos constantes. Ademais, se objetos imutáveis exigem que sejam criados novos objetos para obter novos estados e, portanto, consome mais memória e processamento, para que servem os objetos imutáveis?

10.2 Motivação para a Imutabilidade

Objetos imutáveis garantem que o estado lido de uma instância em qualquer momento será sempre o mesmo. Isto quer dizer que os objetos podem ser compartilhados sem o risco de que leituras subsequentes possam ler valores diferentes.

Por exemplo, considere novamente Ponto, porém usado por outros objetos e na sua versão padrão mutável:

// Ponto.java // Ponto MUTÁVEL
public class Ponto {
  public int x; // atributos mutáveis (não-constantes)
  public int y;
  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }
  @Override
  public String toString() {
    return String.format("(%d, %d)", this.x, this.y);
  }
}
// Figura.java
public class Figura {
  private Ponto pos;
  public Figura(Ponto pos) {
    this.pos = pos;
  }
  @Override
  public String toString() {
    return "Figura na posição " + this.pos;
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p = new Ponto(10, 10);
    Figura f = new Figura(p);
    System.out.println(f); // Figura na posição (10, 10)
    p.x = 15; // ponto é mutável
    // portanto Figura não tem a garantia de estar na mesma
    // posição exceto por sua ações:
    System.out.println(f); // Figura na posição (15, 10)
    // isto é, Figura é afetada pela mutabilidade de ponto e
    // referências externas
  }
}

Os objetos mutáveis permitem alterações em qualquer lugar do programa que tenha uma referência. Isto é, instâncias de objetos compartilhadas e espalhadas no programa podem sofrer alterações e, logo, não são garantidos os mesmo valores em leituras subsequentes.

No exemplo anterior, se Ponto fosse imutável a Figura não teria sua posição alterada por instruções externas. A esta garantia de mesmos resultados em leituras subsequentes é dado o nome de integridade referencial, que é um dos benefícios da imutabilidade.

Com a imutabilidade não há, também, os efeitos colaterais. Eles são comuns em objetos mutáveis, quando são passados como argumentos para funções, métodos e agregados a outras instâncias. Com a ausência de efeito colateral é garantido que a leitura do objeto será a mesma após passá-lo adiante, como no exemplo a seguir:

// Movimento.java
public class Movimento {
  // reatribui a pos x de um ponto
  public static void direita(Ponto p, int d) {
    p.x = p.x + d; // se ponto fosse imutável esta linha seria inválida
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p = new Ponto(10, 10);
    System.out.println(p); // (10, 10)
    Movimento.direita(p, 5);
    // Como ponto é mutável:
    System.out.println(p); // (15, 10)
    // Se ponto fosse imutável:
    // System.out.println(p); // (10, 10)

  }
}

Conforme o exemplo anterior, o efeito colateral na reatribuição de x em p.x = p.x + d; afeta App. Isto é, não há a garantia de que ponto se mantenha em (10. 10) durante a execução de um programa se a instância for utilizada em outros lugares. Justamente esta garantia do estado do objeto quando ele é espalhado pelo programa a principal vantagem dos objetos imutáveis.

Por fim, objetos imutáveis podem ter a mesma identidade. Os runtime systems das linguagens podem reaproveitar a instância mesmo que novos new sejam invocados. Não é o caso em Java, mas conforme o exemplo:

// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(10, 10);
    Ponto p2 = new Ponto(10, 10);
    // acusa false
    System.out.println(p1 == p2);
    // mas poderia acusar true se Ponto fosse imutável
    // e o Java Runtime implementasse cache
  }
}

O objeto ponto em p1 poderia ser reciclado em p2, isto é, o primeiro new criaria um ponto e, embora com novo new, a variável p2 receberia o ponto criado anteriormente. Isto só seria possível se ponto for imutável, garantido que não haveriam efeitos colaterais entre referências.

10.3 Objetos de Valor

A imutabilidade equipa um tipo especial de objeto chamado de objeto de valor e, por isso, é importante aborda-los neste capítulo.

Objetos de valor são objetos pequenos (com poucos atributos) que representam uma entidade simples que não é baseada na identidade mas no seu valor (o conjunto do estado de seu estado). Isto quer dizer que dois objetos de valor podem ter a mesma identidade, já que seus valores determinam sua identidade.

Algumas linguagens de programação suportam nativamente objetos de valor, que já são imutáveis por padrão. Na linguagem C#, por exemplo, há o struct, que é um objeto de valor imutável, como no exemplo a seguir:

// Ponto.cs
public struct Ponto // structs são objetos de valor imutáveis
{
  public int X; // portanto não é necessário declarar readonly
  public int Y; // nos atributos

  public Ponto(int x, int y)
  {
    this.X = x;
    this.Y = y;
  }

  public override string ToString() {
    return $"({this.X}, {this.Y})";
  }
}
// App.cs
public class App {
  public static void Main() {
    Point a = new Point(10, 10);
    // "b" recebe uma cópia do objeto referenciado por "a"
    Point b = a;
    // portanto alterações em "a"
    a.x = 100;
    // não refletem em "b"
    System.Console.WriteLine(b.x); // continua 10
  }
}

O struct Ponto no exemplo anterior é imutável por padrão em C#. No entanto, para implementar o mesmo na linguagem Java é necessário declarar com classes normais, em vez de structs e declarar todos os atributos como final, como já visto anteriormente.

A questão mais importante, neste tópico, é que tipos de entidades deveriam ser objetos de valor. Tipicamente, eles tem poucos atributos e representam valores quantificáveis e comparáveis como peso, comprimento, dinheiro, coordenadas e outros. Por exemplo, em vez de usar double, conhecido pela imprecisão, para representar dinheiro, pode ser projetado um objeto de valor como a seguir:

// Dinheiro.java
public class Dinheiro {

  // um atributo, fácil de armazenar e comparar
  private final int centavos;

  public Dinheiro(int reais, int centavos) {
    this.centavos = reais * 100 + centavos;
  }

  public int getReais() {
    return this.centavos / 100;
  }

  public int getCentavos() {
    return this.centavos % 100;
  }

  @Override
  public boolean equals(Object outroObjeto) {
    if (this == outroObjeto) return true;
    if (outroObjeto == null) return false;
    if (outroObjeto instanceof Dinheiro) {
      Dinheiro outroDinheiro = (Dinheiro) outroObjeto;
      return this.centavos == outroDinheiro.centavos;
    }
    return false;
  }

  @Override
  public String toString() {
    return String.format("R$ %d,%02d", this.getReais(), this.getCentavos());
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Dinheiro d1 = new Dinheiro(3, 99);
    Dinheiro d2 = new Dinheiro(1, 250);
    System.out.println(d1); // R$ 3,99
    System.out.println(d2); // R$ 3,50
  }
}

O exemplo anterior está completo, ele inclui a igualdade e a representação string. Talvez, a questão em aberto é: como fazer operações com este objeto de valor?

Operações em objetos de valor acontecem de forma diferente das classes normais devido à sua imutabilidade. O estado não pode ser alterado, logo é preciso construir novos objetos. Por exemplo, considere que seja necessário somar Dinheiro, portanto um método somar deve ser implementado da seguinte maneira em objetos de valor:

// Dinheiro.java
public class Dinheiro {
  // ... atributos, construtor, getters, equals e toString omitidos

  // Dinheiro é imutável, logo é necessário que as operações
  // retornem novos objetos resultados das operações como este:
  public Dinheiro somar(Dinheiro d) {
    return new Dinheiro(0, this.centavos + d.centavos);
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Dinheiro d1 = new Dinheiro(3, 99);
    Dinheiro d2 = new Dinheiro(1, 250);
    System.out.println(d1); // R$ 3,99
    System.out.println(d2); // R$ 3,50
    // d3 recebe uma nova instância de Dinheiro
    Dinheiro d3 = d1.somar(d2);
    System.out.println(d3); // R$ 7,49
    // as instâncias d1 e d2 não mudam!
    System.out.println(d1); // R$ 3,99
    System.out.println(d2); // R$ 3,50
  }
}

O exemplo anterior apresenta o método somar. Se dinheiro fosse mutável, bastaria realizar this.centavos = this.centavos + d.centavos, alterando o estado de dinheiro. No entanto, com objetos imutáveis é necessário que os métodos que realizam alterações, os comandos, retornem novas instâncias.

10.4 Imutabilidade Fraca e Forte

Tornar todos os atributos de um objeto constantes torna o objeto inteiro imutável, mas não livre de mudanças que revertam este comportamento. Quer dizer, uma classes pode descrever um objeto imutável, porém pode ser estendida o subclassificada de modo que a imutabilidade seja violada.

Por exemplo, considere o exemplo da classe Dinheiro, vista no tópico anterior. Ela foi projetada para gerar objetos imutáveis, mesmo quando o método somar é invocado ela mantém a imutabilidade por retornar um objeto novo, em vez de alterar o próprio estado. No entanto, o que acontecer se Dinheiro for estendida:

// Dinheiro.java
public class Dinheiro {
  // ... implementação vista no tópico anterior
}
public class DinheiroMutavel extends Dinheiro {

  private int centavos;

  public DinheiroMutavel(int reais, int centavos) {
    super(reais, centavos);
    this.centavos = reais * 100 + centavos;
  }

  // DinheiroImutavel sobrescreve
  // todo o comportamento de Dinheiro
  // para torná-lo mutável.

  @Override
  public int getReais() { return this.centavos / 100; }

  @Override
  public int getCentavos() { return this.centavos % 100; }

  @Override
  public Dinheiro somar(Dinheiro d) {
    this.centavos += d.getCentavos() + d.getReais() * 100;
    return this;
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Dinheiro d1 = new Dinheiro(1, 50);
    Dinheiro d2 = new DinheiroMutavel(1, 50);
    System.out.println(d1); // R$ 1,50
    System.out.println(d2); // R$ 1,50
    // embora d1 e d2 sejam do tipo declarado Dinheiro
    // d2 não respeita a imutabilidade
    d1.somar(d2); // d1 não muda
    System.out.println(d1); // R$ 1,50
    d2.somar(d1); // d2 muda, mesmo sendo Dinheiro
    System.out.println(d2); // R$ 3,00
  }
}

A imutabilidade definida na classe Dinheiro é conhecida como imutabilidade fraca porque ela pode ser estendida e ter o comportamento alterado. A imutabilidade forte é alcançada quando a classe, além de projetada para gerar objetos imutáveis, também é protegida de ter este comportamento revertido através da proibição da sua subclassificação, isto é, o impedimento de que a classe Dinheiro seja estendida. Na prática, para ser fortemente imutável este tipo de instrução: extends Dinheiro, deve ser bloqueada.

Projetar classes fortemente imutáveis é bem simples e custa apenas uma palavra-chave na declaração da classe que indique que ela não pode ser estendida, como:

// Dinheiro.java
public final class Dinheiro { // a adição de "final" à classe,
  // mais a declaração de todos os atributos como final,
  // projeta a classe para instanciar objetos imutáveis!

  // ... implementação vista no tópico anterior
}

10.5 Considerações

A imutabilidade é um recurso importante e deve ser considerado ao projetar classes. Objetos de valor têm sua utilidade e, mais frequentemente, a imutabilidade deve ser considerada em oposição ao estado mutável. No paradigma de programação funcional, por exemplo, todas as estruturas são imutáveis. No paradigma orientado a objetos, no entanto, a imutabilidade deve ser projetada.

10.6 Exercícios

A seguir estão dois exercícios para treinar a imutabilidade. Considere declarar ambas classes como final para assegurar a imutabilidade forte.

Implementar o objeto Coordenada

Instâncias de Coordenada devem representar uma posição geográfica no formato de latitude e longitude em graus decimais, sendo que a latitude vai de -90.0 a +90.0 e a longitude de -180.0 a +180.0. A construção sem argumentos de uma coordenada deve instanciar latitude 0 e longitude 0. Após a construção não devem ser permitidas alterações na latitude e longitude a não ser que outra instância seja construída, em outras palavras, os objetos devem ser imutáveis.

Casos de Teste:

public class App {
  public static void main(String[] args) {
    // construtores:
    Coord c1 = new Coord();
    System.out.println(c1.getLat() == 0.0);
    System.out.println(c1.getLong() == 0.0);

    Coord c2 = new Coord(50.0, 134.0);
    System.out.println(c2.getLat() == 50.0);
    System.out.println(c2.getLong() == 134.0);

    Coord c3 = new Coord(-90.0, -180.0);
    System.out.println(c3.getLat() == -90.0);
    System.out.println(c3.getLong() == -180.0);

    // estas coordenadas são inválidas e devem lançar exceção
    // faça serem rejeitadas e depois comente-as para não parar o programa
    Coord e1 = new Coord(-91.0, 0.0);
    Coord e2 = new Coord(100.0, 0.0);
    Coord e3 = new Coord(10.0, -182.0);
    Coord e4 = new Coord(10.0, 200.0);
    Coord e5 = new Coord(-95.0, -200.0);

    // imutabilidade: as linhas a seguir devem causar erro de compilação
    // verifique se está de acordo e depois comente-as
    Coord c4 = new Coord();
    c4.getLat() = 30.0;  // não deve permitir reatribuição
    c4.getLong() = 80.0; // não deve permitir reatribuição

    // operações/comandos:
    Coord in = new Coord(30.0, 50.0);
    Coord out = in.moveNorth(5.0); // deslocamento
    System.out.println(in.getLat() == 30.0); // deve ser imutável
    System.out.println(out.getLat() == 35.0);
    out.moveNorth(5.0); // sem reatribuição sem alteração
    System.out.println(out.getLat() == 35.0);
    out = out.moveNorth(5.0); // reatribuindo
    System.out.println(out.getLat() == 40.0);
    out = out.moveSouth(60.0);
    System.out.println(out.getLat() == -20.0);
    out = out.moveSouth(30.0);
    System.out.println(out.getLat() == -50.0);
    out = out.moveSouth(-10.0);
    System.out.println(out.getLat() == -40.0);
    out = out.moveNorth(-10.0);
    System.out.println(out.getLat() == -50.0);
    System.out.println(out.getLong() == 50.0);
    out = out.moveEast(50.0);
    System.out.println(out.getLong() == 100.0);
    out = out.moveWest(180.0);
    System.out.println(out.getLong() == -80.0);
    out = out.moveWest(-10.0);
    System.out.println(out.getLong() == -70.0);
    out = out.moveEast(-10.0);
    System.out.println(out.getLong() == -80.0);

    // consultas:
    Coord q = new Coord();
    System.out.println(q.getLat() == 0);
    System.out.println(q.getLong() == 0);
    System.out.println(q.isEquatorLine() == true);
    System.out.println(q.isGreenwich() == true);
    q = q.moveNorth(10.0);
    System.out.println(q.getLat() == 10);
    System.out.println(q.isEquatorLine() == false);
    q = q.moveEast(10.0);
    System.out.println(q.isGreenwich() == false);
    q = q.moveEast(170.0);
    System.out.println(q.getLong() == 180.0);
    System.out.println(q.isGreenwich() == false);
    q = q.moveWest(200.0);
    System.out.println(q.getLong() == -20.0);
    System.out.println(q.isGreenwich() == false);
    q = q.moveWest(160.0);
    System.out.println(q.getLong() == -180.0);
    System.out.println(q.isGreenwich() == false);

    Coord r = new Coord(30.0, 70.0);
    System.out.println(r.getLat() == 30.0);
    System.out.println(r.getLong() == 70.0);
    System.out.println(r.isNorth() == true);
    System.out.println(r.isSouth() == false);
    System.out.println(r.isOrient() == true);
    System.out.println(r.isOcident() == false);
    r = r.moveWest(140.0).moveSouth(60.0);
    System.out.println(r.getLat() == -30.0);
    System.out.println(r.getLong() == -70.0);
    System.out.println(r.isNorth() == false);
    System.out.println(r.isSouth() == true);
    System.out.println(r.isOrient() == false);
    System.out.println(r.isOcident() == true);

    // toString:
    System.out.println(c1.toString().equals("0.0°, 0.0°"));
    System.out.println(c2.toString().equals("50.0°, 134.0°"));
    System.out.println(c3.toString().equals("-90.0°, -180.0°"));
    System.out.println(out.toString().equals("-50.0°, -80.0°"));
    System.out.println(q.toString().equals("10.0°, -180.0°"));
    System.out.println(r); // -30.0°, -70.0°
    System.out.println(r.toString().equals("-30.0°, -70.0°"));
  }
}

Implementar a ideia de Tempo Decorrido

Tempo decorrido é chamado de “time span” em Inglês. Diferente de Time, TimeSpan representa um intervalo de tempo em dias, horas, minutos e segundos. TimeSpan deve ser imutável.

Considere os Casos de Teste:

public class App {
  public static void main(String[] args) {

    // construtores
    TimeSpan ts1 = new TimeSpan(7, 3, 45, 35); // dias, horas, minutos, segundos
    System.out.println(ts1.getDays() == 7);
    System.out.println(ts1.getHours() == 3);
    System.out.println(ts1.getMinutes() == 45);
    System.out.println(ts1.getSeconds() == 35);
    ts1 = new TimeSpan(8, 12, 9);
    System.out.println(ts1.getDays() == 0);
    System.out.println(ts1.getHours() == 8);
    System.out.println(ts1.getMinutes() == 12);
    System.out.println(ts1.getSeconds() == 9);
    ts1 = new TimeSpan(4, 18, 110); // entradas adaptáveis
    System.out.println(ts1.getDays() == 0);
    System.out.println(ts1.getHours() == 4);
    System.out.println(ts1.getMinutes() == 19);
    System.out.println(ts1.getSeconds() == 50);
    ts1 = new TimeSpan(4, 68, 110); // entradas adaptáveis
    System.out.println(ts1.getDays() == 0);
    System.out.println(ts1.getHours() == 5);
    System.out.println(ts1.getMinutes() == 9);
    System.out.println(ts1.getSeconds() == 50);

    TimeSpan ts2 = new TimeSpan(1, 1, 1, 1);
    System.out.println(ts2.getDays() == 1);
    System.out.println(ts2.getHours() == 1);
    System.out.println(ts2.getMinutes() == 1);
    System.out.println(ts2.getSeconds() == 1);

    // IMUTABILIDADE
    TimeSpan ts3 = ts2.plus(ts1);
    // ts2 é imutável e não deve ter sofrido alteração
    System.out.println(ts2.getDays() == 1);
    System.out.println(ts2.getHours() == 1);
    System.out.println(ts2.getMinutes() == 1);
    System.out.println(ts2.getSeconds() == 1);
    // No entanto, o objeto recebido da operação reflete o novo estado do sistema
    System.out.println(ts3.getDays() == 1);
    System.out.println(ts3.getHours() == 6);
    System.out.println(ts3.getMinutes() == 10);
    System.out.println(ts3.getSeconds() == 55);


    // validade e exceções
    try {
      ts1 = new TimeSpan(-1, 4, 68, 110);
      // se essa linha for impressa uma exceção não foi lançada
      // falhando no caso de teste
      System.out.println(false);
    } catch (IllegalArgumentException e) {
      System.out.println(true); // se for impresso a exceção foi lançada! ok!
      System.out.println(e.getMessage()); // Can't be negative
    }
    try {
      ts1 = new TimeSpan(1, -4, 68, 110); System.out.println(false);
    } catch (IllegalArgumentException e) {
      System.out.println(true);
    }
    try {
      ts1 = new TimeSpan(1, 4, -68, 110); System.out.println(false);
    } catch (IllegalArgumentException e) {
      System.out.println(true);
    }
    try {
      ts1 = new TimeSpan(1, 4, 68, -110); System.out.println(false);
    } catch (IllegalArgumentException e) {
      System.out.println(true);
    }
    try {
      ts1 = new TimeSpan(0, 0, 0, 0); System.out.println(false);
    } catch (IllegalArgumentException e) {
      System.out.println(e.getMessage()); // Can't be zero
      System.out.println(true);
    }

    // toString
    System.out.println(ts1); // deve imprimir 5 hours, 9 minutes e 50 seconds
    System.out.println(ts1.toString().equals("5 hours, 9 minutes e 50 seconds"));
    TimeSpan ts2 = new TimeSpan(1, 12, 45, 1);
    System.out.println(ts2.toString().equals("1 day, 12 hours, 45 minutes e 1 second"));
    TimeSpan ts3 = new TimeSpan(0, 0, 0, 15);
    System.out.println(ts3.toString().equals("15 seconds"));
    ts3 = new TimeSpan(0, 1, 0, 0);
    System.out.println(ts3.toString().equals("1 hour"));
    ts3 = new TimeSpan(0, 0, 25, 0);
    System.out.println(ts3.toString().equals("25 minutes"));
    // toString em português
    System.out.println(ts1.toString("pt").equals("5 horas, 9 minutos e 50 segundos"));
    System.out.println(ts2.toString("pt")); //1 dia, 12 horas, 45 minutos e 1 segundo
    System.out.println(ts2.toString("pt").equals("1 dia, 12 horas, 45 minutos e 1 segundo"));
    System.out.println(ts3.toString("pt").equals("25 minutos"));

    // equals, greaterThan, lessThan (igual, maior que, menor que)
    TimeSpan ts4 = new TimeSpan(1, 12, 45, 1);
    System.out.println(ts2.equals(ts4) == true);
    System.out.println(ts4.equals(ts2) == true);
    System.out.println(ts4.equals(ts3) == false);
    System.out.println(ts4.greaterThan(ts3) == true);
    System.out.println(ts4.lessThan(ts3) == false);
    System.out.println(ts3.lessThan(ts2) == true);
    System.out.println(ts4.lessThan(ts2) == false);
    System.out.println(ts4.greaterThan(ts2) == false);
  }
}

11. Coesão

“Ninguém na breve história da computação já escreveu uma peça perfeita de software. É improvável que sejas o primeiro.”

Andy Hunt

Bastante do esforço na Programação Orientada a Objeto está em decidir onde (em que objeto) colocar as responsabilidades (os dados e métodos). Frequentemente, acabamos com um objeto que tem muitas responsabilidades e, no pior caso, não relacionadas. A solução é, geralmente, dividir este comportamento em unidades menores e bem definidas ou, melhor dizendo, coesas. Neste capítulo, então, é tratado um traço qualitativo dos módulos chamado coesão, seu conceito, identificação e melhoria.

11.1 Conceito de Coesão

A coesão é uma expressão usada com muita frequência no campo da programação ou engenharia de softwares. Seu significado, na área, é similiar ao entendimento comum ou, especialmente, na escrita de textos. A coesão textual em uma redação diz respeito a harmonia entre os elementos textuais e uma conexão lógica entre as partes de um texto.

Na programação, as classes, que descrevem os objetos, também precisam de harmonização e conexão lógica entre seus elementos. A coesão, portanto, é o grau em que os elementos de uma classe (ou módulo) se relacionam e deve ficar juntos.

Coesão pode ser objetivamente observada e serve, logo, como um índice quantitativo da qualidade, que mede o grau de independência dos módulos (classes, pacotes). Módulos de software que são coesos permitem que um programa seja mais seguro, confiável e, assim como os textos, inteligível!

11.2 Alta Coesão > Baixa Coesão

Uma classe, pode ter uma baixa ou alta coesão. O mesmo vale para um pacote, ou módulo, ou até o (sub)sistema inteiro. A baixa coesão é o resultado da pouca ou nenhuma inter-relação dos atributos e métodos de uma classe ou das próprias classes em um pacote com várias. A alta coesão, por outro lado, é a precisa separação de modo de que estes elementos façam sentido juntos.

Por exemplo, considere a necessidade de registrar a compra de um ticket para um show, como é feito nos websites e apps para eventos e shows. Vamos partir do pressuposto que existisse uma classe Ingresso, o qual seria vendido para um Cliente, conforme exemplo a seguir:

// Ingresso.java
class Ingresso {

  private static final String ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*0123456789";
  private Random random = new Random();
  private int id;
  private String tokenVenda;
  private Evento evento;
  private LocalDateTime dataHoraVenda;
  private Cliente cliente;

  Ingresso(int id, Evento evento) {
    this.id = id;
    this.evento = evento;
  }

  public void vender(Cliente cli) {
    this.dataHoraVenda = LocalDateTime.now();
    this.tokenVenda = gerarToken(10);
    this.cliente = cli;
  }

  private String gerarToken(int length) {
    String token = "";
    for (int i = 0; i < length; i++) {
      token += ALPHA.charAt(random.nextInt(ALPHA.length()));
    }
    return token;
  }
}

O exemplo não é muito longo, para caber num livro, mas a ideia básica está ali. Quais são os estados e comportamentos, ou atributos e métodos, que realmente pertencem ao Ingresso? Um meio de medir é contar as menções dos atributos no métodos e identificar clusters, isto é, blocos ou agrupamentos de código, pedaços de lógica que anda junta. Neste exemplo, em particular, há a dataHoraVenda e o tokenVenda, onde ambos pertencem a uma entidade Venda. Outro agrupamento observado é o método gerarToken que se baseia na constante ALPHA. A Venda é aparte do Ingresso, assim como a geração do token, que é um problema por si só. A classe Ingresso tem baixa coesão, pois contém lógica que não seria de sua responsabilidade.

11.3 Aumentando a coesão

Sabendo que a causa da baixa coesão é a lógica não-relacionada, a solução típica para o aumento da coesão é relacionar essa lógica pela separação em blocos coesos. Há um fator que ajuda bastante nesta tarefa: classes e pacotes menores.

Classes muito longas tendem a ser pouco coesas. É pouco provável que todos os métodos usem todos os atributos ou tenham relação entre si. No caso de pacotes ou conjuntos muito grande de classes, é pouco provável que todas estas classes tenham relação direta ou próxima ou, pelo menos, podem haver subconjuntos de classes com mais afinidade.

Portanto, a coesão aumenta conforme os módulos se tornam menores, onde módulos se entende por uma classe, um arquivo, um pacote ou agrupamento de classes e arquivos. A alteração de códigos, buscando a organização e melhoria da qualidade interna sem mudar o comportamento geral, é chamada refatoração.

11.4 Refatoração: extração/introdução

As refatorações são catalogadas, dependendo do problema que resolvem. Na categoria de quebrar módulos muito grandes em partes menores está a extração ou introdução de novas classes ou novos métodos.

No caso de classes pouco coesas, uma solução típica é extrair a lógica (atributos, métodos, etc) e introduzir uma nova classe ou superclasse.

Voltando ao exemplo do Ingresso que tem baixa coesão, é necessário analisar o seguinte:

  • Os atributos tokenVenda e dataHoraVenda existem em função da Venda do ingresso e não do Ingresso em si. Logo pode ser extraídos (o nome dos atributos já denunciava um não-pertencimento àquele lugar);
  • O atributo random e a contante ALPHA só existem para a geração do Token no método gerarToken, portanto pode, também, ser extraído.

Uma solução com coesão mais alta pode ser vista no código a seguir:

// Ingresso.java
class Ingresso {

  private int id;
  private Evento evento;
  private Venda venda;

  Ingresso(int id, Evento evento) {
    this.id = id;
    this.evento = evento;
  }

  Venda vender(Cliente cli) {
    if (this.isVendido()) {
      throw new IllegalStateException("Ingresso já foi vendido");
    }
    this.venda = new Venda(cli, this);
    return this.venda;
  }
  // ...
}
// Venda.java
class Venda {

  private Cliente cliente;
  private Ingresso ingresso;
  private Token token;
  private LocalDateTime dataHora;

  Venda(Cliente cliente, Ingresso ingresso) {
    this.cliente = cliente;
    this.ingresso = ingresso;
    this.token = new Token(10);
    this.dataHora = LocalDateTime.now();
  }
  // ...
}
// Token.java
class Token {
  private static final String ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*0123456789";
  private Random random = new Random();
  private String token;
  private int length;

  Token(int length) {
    this.length = length;
    this.gerar();
  }

  public String gerar() {
    this.token = "";
    for (int i = 0; i < this.length; i++) {
      this.token += ALPHA.charAt(random.nextInt(ALPHA.length()));
    }
    return this.token;
  }
  // ...
}

No código anterior, foram extraídos os atributos tokenVenda e dataHoraVenda de Ingresso e reorganizados com a introdução da classe Venda, mais coesa. Ainda, o random e ALPHA foram extraídos e usados na introdução da classe Token, que utiliza todos os seus atributos no método gerar.

Com esta refatoração, Ingresso, Venda e Token têm uma afinidade melhor com seus atributos, métodos e responsabilidades, portanto, alta coesão.

11.5 Considerações

Como foi visto neste capítulo, o problema da baixa coesão foi solucionado através da adição de novas classes e delegando as responsabilidades para objetos especializados nestas, como no caso do Token. Enquanto aumenta a coesão, a introdução de novas classes e objetos implica na associação entre objetos para cumprir uma funcionalidade. Isto é, para manter um sistema coeso é preciso dividir as responsabilidades entre os objetos e associá-los. Por exemplo, a venda do Ingresso só é possível com a colaboração dos objetos Venda e Token. A associação é o relacionamento entre objetos, e esta característica colaborativa na Programação Orientada a Objetos será examinada em mais detalhes no capítulo seguinte 12. Associação.

11.6 Exercícios

Notes

1Trade-off é uma decisão onde o aumento de uma quantidade, qualidade ou propriedade vai decrescer outra e não há um cenário onde se ganhe nas duas frentes.

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

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

4Existe 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/.

5C é uma linguagem procedimental criada em 1972 e usada até hoje (o ++ em C++ é uma analogia a um “incremento” da 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++.

6Eu sei que alguém pode dar um novo nome ao Gato, não vamos estragar o exemplo. Façamos assim, a cor do pêlo é constante. Quê? Se o Gato ficar velho ou cair o pêlo? Tá, tá, tu entendeste!

7Até é possível definir estado constante em JS e Python com algumas “manobras”, mas que deixam claro que não é um recurso “natural”.

8Os exemplo a seguir estarão na linguagem Java, e cabe apenas investigar se há e qual keyword está disponível para declarar uma propriedade constante na linguagem que pretendes programar.

9Tá, eu sei que tu podes encher a garrafa até transbordar e que passaria a capacidade nominal, mas, shhhh, não bugue o exemplo!!

10Trade-off é uma decisão onde o aumento de uma quantidade, qualidade ou propriedade vai decrescer outra e não há um cenário onde se ganhe nas duas frentes.

11Os quatro pilares da POO foram originalmente descritos no livro de Grady Booch Object Oriented Analysis and Design with Applications como: abstração, encapsulamento, modularidade e hierarquia.

12Ad hoc é uma expressão do latin que significa literalmente para isto, entendido melhor como algo para esta situação particular.

13Java é open-source, portando os códigos-fonte das classes padrão podem ser consultadas no repositório do Open JDK. Por exemplo, o código da classe Object está aqui https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/Object.java.