Sumário
- Prefácio
- Introdução
-
1. Programação Modular
- 1.1 Modularização
- 1.2 Modularização na Programação Procedimental
- 1.3 Modularização na Programação Orientada a Objetos
- 1.4 Subprocedimentos
- 1.5 Procedimentos e Dados Estruturados
- 1.6 “Procedimentos Orientados a Objetos?!”
- 1.7 Aderindo à Programação Orientada a Objetos com Responsabilidade
- 1.8 Considerações
-
2. Uma curta história da Programação Orientada a Objetos
- 2.1 LISP: átomos
- 2.2 Sketchpad: mestre e definição
- 2.3 ALGOL: procedimentos + estruturas de dados
- 2.4 Simula: classes, subclasses e herança
- 2.5 Smalltalk: troca de mensagens
- 2.6 C++: pondo POO no jogo
- 2.7 Java: POO para as massas
- 2.8 A supremacia Orientada-a-Objetos
- 2.9 Considerações
- 2.10 Exercícios
- 3. Modelo de Objetos
- 4. Estado & Validade
- 5. Comportamento & Operações
- 6. Polimorfismo ad hoc
- 7. Encapsulamento & Visibilidade
- 8. Identidade & Igualdade
- 9. Representação & Formato
- 10. Imutabilidade & Objetos de Valor
- 11. Coesão
- Notes
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:
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:
1
import
java.util.Scanner
;
// scanner é um módulo!
2
public
class
Proc1
{
3
public
static
void
main
(
String
[]
args
)
{
4
// ler dois números
5
Scanner
scan
=
new
Scanner
(
System
.
in
);
6
int
numero1
=
scan
.
nextInt
();
7
int
numero2
=
scan
.
nextInt
();
8
// descobrir o maior número
9
int
maior
=
numero1
>
numero2
?
numero1
:
numero2
;
10
int
mmc
=
maior
;
11
// somar o maior enquanto não for divisível por ambos
12
while
(
mmc
%
numero1
!=
0
||
mmc
%
numero2
!=
0
)
{
13
mmc
+=
maior
;
14
}
15
// imprimir mmc
16
System
.
out
.
println
(
mmc
);
17
}
18
}
A implementação sem separar a lógica em um procedimento incorre na duplicação do código em operações subsequentes, digamos que o cálculo do MMC seja necessário em outras partes do programa, o código entre as linhas 8 e 16, pelo menos, teriam que ser reintroduzidos. Considere o mesmo exemplo com duas operações:
1
import
java.util.Scanner
;
// scanner é um módulo!
2
public
class
Proc2
{
3
public
static
void
main
(
String
[]
args
)
{
4
// ler dois números
5
Scanner
scan
=
new
Scanner
(
System
.
in
);
6
int
numero1
=
scan
.
nextInt
();
7
int
numero2
=
scan
.
nextInt
();
8
// descobrir o maior número
9
int
maior
=
numero1
>
numero2
?
numero1
:
numero2
;
10
int
mmc
=
maior
;
11
// somar o maior enquanto não for divisível
12
while
(
mmc
%
numero1
!=
0
||
mmc
%
numero2
!=
0
)
{
13
mmc
+=
maior
;
14
}
15
// imprimir mmc
16
System
.
out
.
println
(
mmc
);
17
18
// ler mais dois números
19
int
numero3
=
scan
.
nextInt
();
20
int
numero4
=
scan
.
nextInt
();
21
// descobrir o maior número
22
maior
=
numero3
>
numero4
?
numero3
:
numero4
;
23
mmc
=
maior
;
24
// somar o maior enquanto não for divisível
25
while
(
mmc
%
numero3
!=
0
||
mmc
%
numero4
!=
0
)
{
26
mmc
+=
maior
;
27
}
28
// imprimir mmc
29
System
.
out
.
println
(
mmc
);
30
}
31
}
A extração do procedimento deve ser identificada através dos dados de entrada e lógica necessária, observando o que há de comum nos dois cálculos subsequentes: leitura de dois números, descoberta do maior, loop e print. Somente a entrada é diferente, levando a escrever o seguinte procedimento entre as linhas 7 e 18 do código a seguir:
1
import
java.util.Scanner
;
// scanner é um módulo!
2
public
class
Proc3
{
3
// static = método estático (representa uma função/procedimento em Java)
4
// void = sem retorno (return)
5
// mmc = nome da função
6
// int n1, int n2 = dados de entrada necessários
7
static
void
mmc
(
int
n1
,
int
n2
)
{
8
// seja qual for a variável elas entram como n1 e n2
9
// descobrir o maior número
10
int
maior
=
n1
>
n2
?
n1
:
n2
;
11
int
mmc
=
maior
;
12
// somar o maior enquanto não for divisível
13
while
(
mmc
%
n1
!=
0
||
mmc
%
n1
!=
0
)
{
14
mmc
+=
maior
;
15
}
16
// imprimir mmc
17
System
.
out
.
println
(
mmc
);
18
}
19
20
public
static
void
main
(
String
[]
args
)
{
21
// ler dois números
22
Scanner
scan
=
new
Scanner
(
System
.
in
);
23
int
numero1
=
scan
.
nextInt
();
24
int
numero2
=
scan
.
nextInt
();
25
// chamada/invocação da função/procedimento
26
mmc
(
numero1
,
numero2
);
27
// ler mais dois números
28
int
numero3
=
scan
.
nextInt
();
29
int
numero4
=
scan
.
nextInt
();
30
// chamada/invocação da função/procedimento
31
mmc
(
numero3
,
numero4
);
32
}
33
}
Na linha 7 está a assinatura do método, que neste exemplo representa um procedimento. A assinatura, em poucas palavras, descreve o nome do procedimento, entrada e retorno, podendo ser descrita como Proc3.mmc(int, int):void
, que significa um procedimento com o nome de mmc
, pertencente ao módulo Proc3
(em Java um módulo é uma classe) que recebe dois parâmetros inteiros int, int
e não possui retorno void
(void pode ser traduzido livremente como vazio).
O procedimento anterior funciona bem, tu mesmo podes fazer os testes. Mas, como se testa? A maneira trivial de testar é chamar o procedimento com MMCs conhecidos e observar se a reposta é esperada, por exemplo, digitando 3
e 5
o resultado deve ser, inevitavelmente, 15
. Realizar testes manuais, entrando com os valores e observando as saídas, mesmo com valores triviais, também é a testagem mais simplificada possível.
Testagem
Sem ir muito ao fundo do assunto, a testagem adequada é realizada com a introdução de um conjunto de entradas variadas, válidas e inválidas, as quais tenham saídas previsíveis (esperadas). A previsibilidade é o ponto-chave da testabilidade e para isso os procedimentos devem fornecer alguma saída, seja a resposta correta ou um tratamento de erro. O procedimento anterior não oferece uma saída, não tem retorno (void), o println
faz parte da lógica do procedimento. Os dois problemas dessa abordagem são:
- a estratégia de impressão do resultado é “fixa”,
- o procedimento não oferece um retorno testável.
Por esses motivos o procedimento será alterado para oferecer um retorno, conforme exemplo a seguir:
1
import
java.util.Scanner
;
2
3
public
class
Proc4
{
4
// esse int antes do nome signigica um retorno inteiro
5
static
int
mmc
(
int
n1
,
int
n2
)
{
6
int
maior
=
n1
>
n2
?
n1
:
n2
;
7
int
mmc
=
maior
;
8
while
(
mmc
%
n1
!=
0
||
mmc
%
n2
!=
0
)
{
9
mmc
+=
maior
;
10
}
11
// aqui trocamos o print pelo return
12
return
mmc
;
13
}
14
15
public
static
void
main
(
String
[]
args
)
{
16
Scanner
scan
=
new
Scanner
(
System
.
in
);
17
int
numero1
=
scan
.
nextInt
();
18
int
numero2
=
scan
.
nextInt
();
19
// agora a chamada traz um retorno
20
// que pode ser atribuído e impresso
21
int
resultado
=
mmc
(
numero1
,
numero2
);
22
System
.
out
.
println
(
resultado
);
23
// ou então ser feito diretamente:
24
System
.
out
.
println
(
mmc
(
scan
.
nextInt
(),
scan
.
nextInt
()));
25
}
26
}
Na linha 5, o int
antes do nome do procedimento declara um retorno. Isto quer dizer que o procedimento devolve um inteiro que pode ser usado para qualquer fim, como ser impresso na linha 22. É importante notar as mudanças nas responsabilidades: antes a responsabilidade do println
era do procedimento e agora é da seção principal main
.
O próximo passo, agora, é escrever os testes. Preparar a testagem não é complicado, exige apenas duas medidas:
- eliminar a entrada do usuário e introduzir constantes literais (ex.:
int numero1 = 3
), - escrever assertivas simples (expressões booleanas) para declarar o resultado esperado.
Um true
impresso significa que o teste passou e um false
(ou exceção) significa que o teste falhou (ou uma situação inesperada foi encontrada, um bug). Confira a testagem do procedimento mmc
no código a seguir:
1
import
java.util.Scanner
;
2
public
class
Proc5
{
3
4
static
int
mmc
(
int
n1
,
int
n2
)
{
5
int
maior
=
n1
>
n2
?
n1
:
n2
;
6
int
mmc
=
maior
;
7
while
(
mmc
%
n1
!=
0
||
mmc
%
n2
!=
0
)
{
8
mmc
+=
maior
;
9
}
10
return
mmc
;
11
}
12
13
public
static
void
main
(
String
[]
args
)
{
14
int
numero1
=
3
;
15
int
numero2
=
5
;
16
int
resultado
=
mmc
(
numero1
,
numero2
);
17
// esperado que o mmc entre 3 e 5 seja 15
18
// deve imprimir `true`
19
System
.
out
.
println
(
resultado
==
15
);
20
// fazendo testes diretamente:
21
System
.
out
.
println
(
mmc
(
5
,
6
)
==
30
);
22
// testando no limite:
23
System
.
out
.
println
(
mmc
(
14223
,
77323
)
==
1099765029
);
24
}
25
}
As expressões booleanas nas linhas 19, 21 e 23 representam três testes: dois óbvios, que podem ser obtidos de cálculo mental, e um extrapolado (no limite, o mais importante!). Testar os limites é importante porque é “nas bordas” que falhas e bugs são revelados e, grave isso na memória: o objetivo de testar é provar que o programa não funciona. Neste exemplo, na verdade, foram realizados poucos testes, além de testar situações limites é importante adicionar mais testes afim de encontrar falhas e corrigi-las, até todos os testes passarem. Para minha felicidade (ou não, lembre :P) todos os testes passaram:
javac Proc5.java;
java Proc5
true
true
true
Especificação
Procedimentos e, posteriormente, objetos são projetados e implementados a partir de especificações. As especificações devem ser corretas, precisas, claras e válidas! Sem uma especificação válida é impossível assegurar que as respostas são corretas ou mesmo provar que não são. Então, vamos obter a especificação do MMC:
Em aritmética e em teoria dos números o mínimo múltiplo comum (mmc) de dois inteiros a e b é o menor inteiro positivo que é múltiplo simultaneamente de a e de b. Se não existir tal inteiro positivo, por exemplo, se a =0 ou b = 0, então mmc(a, b) é zero por definição.
– Wikipédia em https://pt.wikipedia.org/wiki/M%C3%ADnimo_m%C3%BAltiplo_comum
Partindo dessa especificação, podemos assegurar que o procedimento implementado está em conformidade com ela? Vamos tentar provar que não, explorando as situações excepcionais e escrevendo os testes a seguir contra a especificação:
<<Testando contra a especificação
Não trate como decepcionante, mas como esclarecedor. Dos três primeiros testes, com números negativos, o primeiro e terceiro falham. A seção de testes com zero causa um erro logo no primeiro Exception in thread "main" java.lang.ArithmeticException: / by zero
. Obter false
indica uma falha e quer dizer que não está conforme a especificação pois retorna uma resposta incorreta (as duas falhas retornaram 5
e -5
respectivamente). Uma exceção, ou “quebra” do programa, indica um erro. Resumindo, o procedimento não cumpre a especificação, isto foi provado (e provar que não funciona é, acredite, bom!).
1.3 Modularização na Programação Orientada a Objetos
A POO se difere da programação procedimental quando combina os dados e o algoritmo em uma única unidade (módulo). Na POO a lógica é acessada a partir dos dados. Ainda temos o livro inteiro para discutir isso, mas apenas para ilustrar considere um exemplo “bobo”: calcular o dobro de um número inteiro. Na programação procedimental basta criar um procedimento dobro(int):int
, ele recebe um inteiro e devolve outro, não é complicado, veja a seguir:
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:
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:
public
class
Proc8
{
static
int
menor
(
int
a
,
int
b
)
{
System
.
out
.
println
(
"calculando o menor"
);
int
menor
=
a
<
b
?
a
:
b
;
System
.
out
.
println
(
"menor calculado"
);
return
menor
;
}
static
int
mdc
(
int
a
,
int
b
)
{
System
.
out
.
println
(
"calculando o mdc"
);
int
mdc
=
menor
(
a
,
b
);
while
(
a
%
mdc
!=
0
||
b
%
mdc
!=
0
)
{
mdc
=
mdc
-
1
;
}
System
.
out
.
println
(
"mdc calculado"
);
return
mdc
;
}
static
int
mmc
(
int
a
,
int
b
)
{
System
.
out
.
println
(
"calculando o mmc"
);
int
mmc
=
a
/
mdc
(
a
,
b
)
*
b
;
System
.
out
.
println
(
"mmc calculado"
);
return
mmc
;
}
public
static
void
main
(
String
[]
args
)
{
// testando o mmc
System
.
out
.
println
(
mmc
(
5
,
6
)
==
30
);
}
}
java Proc8
calculando o mmc
calculando o mdc
calculando o menor
menor calculado
mdc calculado
mmc calculado
true
Existe uma consideração importante para tratar: as dependências. O procedimento mmc
depende da resposta do procedimento mdc
. Essa relação de dependência é para o bem e para o mal, quero dizer, se o procedimento mdc
falha ou “quebra” então o procedimento mmc
também falha ou “quebra”. Como mdc
depende de menor
, se menor
falha então mdc
falha e mmc
também falhará, devido a dependência indireta.
Mesmo com a consideração da dependência existe pontos extremamente positivos em separar procedimentos: subprocedimentos são menores e podem ser testados separadamente. Na prática, testar e garantir o bom funcionamento da função menor
assegura a qualidade da função mdc
e todas as outras que dependem de menor
. O mesmo vale em garantir mdc
para respaldar mmc
.
Outra forma de combinar procedimentos é usar a saída (o retorno) de um procedimento como entrada de outro. Por exemplo, considerando que existem os procedimentos raizQuadrada(double):double
, raizCubica(double):double
e menor(double, double):double
, qual seria a resposta da seguinte instrução menor(raizQuadrada(225), raizCubica(216))
? Assim como um expressão matemática, primeiro resolve-se os parenteses internos e depois os externos (a resposta não é difícil de obter).
Para compreender melhor a composição de procedimentos vamos para um exemplo completo e complexo: o cálculo do seno de um ângulo. Considere uma função seno(double):double
onde a entrada é em radianos, contudo o ângulo que temos é em graus (por exemplo 75°
). Então para calcular o seno antes temos de converter o ângulo em radianos com uma função radianos(doule):double
. A seguir como ficaria a composição:
double
graus
=
75.0
;
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](/site_images/poo/serie-de-maclaurin.png)
Observando o algoritmo na imagem percebe-se a necessidade de potência e fatorial. Enfim, o procedimento seno(double):double
depende de potencia(double, double):double
e fatorial(double):double
. É uma opção criar esses subprocedimentos ou codificar tudo em seno
, mas há questões a ponderar, como o reaproveitamento dos subprocedimentos, o aumento da testabilidade, contra o aumento da granularidade dos módulos.
Para estudo eu implementei os procedimentos e subprocedimentos, enfim, todas as funções estão no código que vem a seguir. Não é a melhor implementação possível, há um problema de imprecisão, da própria implementação como da essência do ponto flutuante, mas é boa suficiente para estudo.
public
class
Proc9
{
static
double
potencia
(
double
base
,
int
expoente
)
{
double
potencia
=
base
;
for
(
int
i
=
1
;
i
<
expoente
;
i
++)
{
potencia
*=
base
;
}
return
potencia
;
}
static
int
fatorial
(
int
numero
)
{
int
fatorial
=
1
;
for
(
int
i
=
1
;
i
<=
numero
;
i
++)
{
fatorial
=
fatorial
*
i
;
}
return
fatorial
;
}
static
double
seno
(
double
radianos
)
{
double
seno
=
0
;
seno
=
radianos
-
(
potencia
(
radianos
,
3
)
/
fatorial
(
3
))
+
(
potencia
(
radianos
,
5
)
/
fatorial
(
5
))
-
(
potencia
(
radianos
,
7
)
/
fatorial
(
7
))
+
(
potencia
(
radianos
,
9
)
/
fatorial
(
9
));
return
seno
;
}
static
double
radianos
(
double
graus
)
{
double
PI
=
3.1416
;
double
radianos
;
radianos
=
PI
*
graus
/
180.0
;
return
radianos
;
}
public
static
void
main
(
String
[]
args
)
{
// System.out.println(fatorial(5));
// combinando os procedimentos
System
.
out
.
println
(
seno
(
radianos
(
75
)));
//(seno(1.309))
//(0.9659270976271132)
// é uma resposta aproximada
// a esperada de 0.96592583
// além disso, double sempre trabalha
// com aproximações (ver problemas de precisão)
}
}
1.5 Procedimentos e Dados Estruturados
Ao contrário dos exemplos anteriores, os procedimentos trabalham mais com informações elaboradas do que com dados isolados. Na prática, quer dizer que os procedimentos (e depois objetos e seus métodos) raramente processarão uma variável numérica ou textual simples, mas sim um agrupamento de variáveis. Por exemplo, um cálculo de ICMS seria feito com a Nota Fiscal inteira e não somente com o valor final. Por isso, há a necessidade de agrupar dados para definir uma informação autocontida.
As linguagens de programação disponibilizam estruturas para conter dados relacionados. A estrutura de dados mais popular é o array (arranjo, em português), que também é conhecida como vetor (ou matrix quando é bidimensional). Arrays estão disponíveis em todas linguagens de programação populares, com poucas diferenças de sintaxe, permitindo guardar valores e recuperá-los através de um índice numérico. Existem, portanto, diversas estruturas de dados, como list (lista), set (conjunto), graph (grafo), tuple (tupla), struct (conhecida como registro), associative array (vetor associativo), class (classe, fundamental para a POO e vista no restante do livro), etc.
Para os exemplos deste capítulo vamos usar vetores como estrutura de dados. Se tu ainda não conheces, vetor é uma estrutura e um tipo, que pode ser declarado e atribuído a variáveis, permitindo armazenar valores conforme um índice numérico. Para entender melhor, considere o exemplo:
1
// declarando um vetor de 6 inteiros
2
int
[]
copas
=
new
int
[
6
];
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:
import
java.util.Scanner
;
// scanner é um módulo!
public
class
Estrutura1
{
public
static
void
main
(
String
[]
args
)
{
Scanner
scan
=
new
Scanner
(
System
.
in
);
int
diaInicio
=
scan
.
nextInt
();
int
mesInicio
=
scan
.
nextInt
();
int
anoInicio
=
scan
.
nextInt
();
int
diaFim
=
scan
.
nextInt
();
int
mesFim
=
scan
.
nextInt
();
int
anoFim
=
scan
.
nextInt
();
System
.
out
.
println
(
diaInicio
+
"/"
+
mesInicio
+
"/"
+
anoInicio
+
" a "
+
diaFim
+
"/"
+
mesFim
+
"/"
+
anoFim
);
}
}
O exemplo anterior pode ser refatorado para usar vetores na representação das datas, como pode ser visto no código a seguir:
import
java.util.Scanner
;
public
class
Estrutura2
{
public
static
void
main
(
String
[]
args
)
{
Scanner
scan
=
new
Scanner
(
System
.
in
);
// dois vetores de três posições
int
[]
dataInicio
=
new
int
[
3
];
int
[]
dataFim
=
new
int
[
3
];
// zero based indexes
dataInicio
[
0
]
=
scan
.
nextInt
();
dataInicio
[
1
]
=
scan
.
nextInt
();
dataInicio
[
2
]
=
scan
.
nextInt
();
dataFim
[
0
]
=
scan
.
nextInt
();
dataFim
[
1
]
=
scan
.
nextInt
();
dataFim
[
2
]
=
scan
.
nextInt
();
System
.
out
.
println
(
dataInicio
[
0
]
+
"/"
+
dataInicio
[
1
]
+
"/"
+
dataInicio
[
2
]
+
" a "
+
dataFim
[
0
]
+
"/"
+
dataFim
[
1
]
+
"/"
+
dataFim
[
2
]);
}
}
Conter os dados em vetores (e depois em classes/objetos) facilita o trânsito das informações, permitindo que os dados andem juntos. Para demonstrar vamos evoluir o exemplo anterior, extraindo a apresentação da data “d/m/a” para um procedimento formataData(int[]):String
como pode ser visto no código a seguir:
import
java.util.Scanner
;
public
class
Estrutura3
{
static
String
formataData
(
int
[]
data
)
{
return
data
[
0
]
+
"/"
+
data
[
1
]
+
"/"
+
data
[
2
];
}
public
static
void
main
(
String
[]
args
)
{
int
[]
dataInicio
=
new
int
[
3
];
int
[]
dataFim
=
new
int
[
3
];
dataInicio
[
0
]
=
scan
.
nextInt
();
dataInicio
[
1
]
=
scan
.
nextInt
();
dataInicio
[
2
]
=
scan
.
nextInt
();
dataFim
[
0
]
=
scan
.
nextInt
();
dataFim
[
1
]
=
scan
.
nextInt
();
dataFim
[
2
]
=
scan
.
nextInt
();
System
.
out
.
println
(
formataData
(
dataInicio
)
+
" a "
+
formataData
(
dataFim
));
}
}
Se os dados não fossem contidos a função teria a assinatura formataData(int,int,int):String
, recebendo os três int
’s (dia, mês e ano) e devolvendo a String com a data formatada. O procedimento parece bom suficiente, mas falta um detalhe importante que é indispensável: os testes.
1
public
class
Estrutura4
{
2
3
static
String
formataData
(
int
[]
data
)
{
4
return
data
[
0
]
+
"/"
5
+
data
[
1
]
+
"/"
6
+
data
[
2
];
7
}
8
9
public
static
void
main
(
String
[]
args
)
{
10
int
[]
dataInicio
=
{
1
,
7
,
2016
};
11
int
[]
dataFim
=
{
31
,
7
,
2016
};
12
13
System
.
out
.
println
(
14
formataData
(
dataInicio
).
equals
(
"1/7/2016"
));
15
System
.
out
.
println
(
16
formataData
(
dataFim
).
equals
(
"31/7/2016"
));
17
System
.
out
.
println
(
18
formataData
(
new
int
[]{
10
,
10
,
2010
}).
equals
(
"10/10/2010"
));
19
}
20
}
Este último código de exemplo, além de testar o nosso procedimento “bobo” formataData
, apresenta detalhes básicos importantes para lidar com vetores. Nas linhas 10 e 11 os vetores são declarados e inicializados com os valores inteiros literais. Na linha 18 o vetor é criado e inicializado ao mesmo tempo que é passado como argumento para o procedimento.
Agora vamos a um exemplo mais elaborado. Além disso, para avançar um pouco mais na programação modular, vamos separar o corpo principal do programa (o main
) da unidade com os procedimentos: Data.formataData
e Data.somaDias(int[], int):int[]
dentro de um módulo Data (class Data
em Java).
Neste estudo de caso, vamos projetar um procedimento para adicionar dias a uma data. O procedimento recebe um vetor com a data e a quantidade de dias em inteiro e então devolve outro vetor com o resultado, segundo essa assinatura: somaDias(int[], int):int[]
. Não é uma lógica simples ou trivial, trabalhar com datas é sempre desafiador, pois as regras não têm um padrão simples: alguns meses têm 30 dias, outros têm 31, um tem 28 ou 29 nos anos bissextos. Entretanto, o programador pode usar a estratégia de reduzir o problema em parte menores e mais simples de serem resolvidas. Essa estratégia é conhecida como Computational Thinking (Pensamento Computacional) e consiste em analisar uma instância do problema, reconhecer um padrão, decompor o problema, analisar solucionar e solucionar as partes até projetar uma solução genérica para o problema inteiro. Começando com um esboço:
// arquivo Data.java
public
class
Data
{
static
int
[]
somaDias
(
int
[]
data
,
int
dias
)
{
int
[]
resultado
=
new
int
[
3
];
// aqui 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:
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
:
somaDias
a partir do amanha
1
class
Data
{
2
// Jan Fev Mar Abr Mai Jun
3
static
int
[]
diasMes
=
{
31
,
28
,
31
,
30
,
31
,
30
,
4
31
,
31
,
30
,
31
,
30
,
31
};
5
// Jul Ago Set Out Nov Dez
6
static
int
[]
amanha
(
int
[]
data
)
{
7
// copiar o estado
8
int
[]
amanha
=
{
data
[
0
]
+
1
,
data
[
1
],
data
[
2
]};
9
// se ultrapassou o último dia do mês
10
if
(
amanha
[
0
]
>
diasMes
[
amanha
[
1
]
-
1
])
{
11
amanha
[
0
]
=
1
;
// dia 1
12
// se antes de dezembro
13
if
(
amanha
[
1
]
<
12
)
{
14
amanha
[
1
]
=
amanha
[
1
]
+
1
;
// próximo mês
15
}
else
{
// se em dezembro
16
amanha
[
1
]
=
1
;
// janeiro do
17
amanha
[
2
]
=
amanha
[
2
]
+
1
;
// próximo ano
18
}
19
}
20
return
amanha
;
21
}
22
23
static
int
[]
somaDias
(
int
[]
data
,
int
dias
)
{
24
int
[]
resultado
=
{
data
[
0
],
data
[
1
],
data
[
2
]};
25
for
(
int
i
=
0
;
i
<
dias
;
i
++)
{
26
resultado
=
amanha
(
resultado
);
27
}
28
return
resultado
;
29
}
Este exemplo demonstra a estratégia de decomposição do problema em partes menores que ajudam na solução do problema maior. A simplicidade em saltar um dia é visível nas expressões condicionais entre as linhas 10 e 19. É possível escrever estas regras de várias formas, a apresentada foi um exemplo didático. O ponto alto do exemplo está entre as linhas 25 e 27, que utiliza o amanha
como um subprocedimento de somaDias
, executado n
(ou dias
) vezes. Embora tenha passado nos Casos de Teste, ainda há um bug eminente de uma regra não implementada para ver no exercício a seguir:
Representação Textual (char e String)
Fechando este tópico, mais uma coisa a considerar: a representação textual. As linguagens em geral disponibilizam o tipo string, representado literalmente por caracteres entre aspas, por exemplo "#poocomhonra"
. As strings, embora fundamentais nas linguagens, não são elementares, mas sim compostas por caracteres individuais (em Java um char) e podem ser vistas como um vetor de caracteres. Assim, elas compartilham as propriedades dos vetores, como posição e tamanho, conforme exemplo a seguir:
// dica: marque os índices
//012345678901
String
hashtag
=
"#poocomhonra"
;
// comprimento da string
System
.
out
.
println
(
hashtag
.
length
()
==
12
);
// primeiro caractere
System
.
out
.
println
(
hashtag
.
charAt
(
0
)
==
'#'
);
// em Java um char é representado
// por aspas simples
System
.
out
.
println
(
hashtag
.
charAt
(
4
)
==
'c'
);
System
.
out
.
println
(
hashtag
.
charAt
(
11
)
==
'a'
);
char
[]
caracteres
=
hashtag
.
toCharArray
();
As strings assim como os vetores, em Java, são objetos, referenciáveis, mas com uma diferença: enquanto vetores são mutáveis, strings não são. Essa característica de imutabilidade da string, e que veremos como fazer com nossos objetos nos capítulos a seguir, garante que as strings originais não sofram alterações quando passadas para procedimentos ou métodos. Já os vetores, estão propensos a alterações por onde passam, fenômeno conhecido como efeito colateral do procedimento (e que vale o mesmo para métodos em OO). Para exemplificar, vamos retomar o procedimento amanha
:
amanha
com retorno de cópia modificada da entrada
1
class
Data
{
2
// Jan Fev Mar Abr Mai Jun
3
static
int
[]
diasMes
=
{
31
,
28
,
31
,
30
,
31
,
30
,
4
31
,
31
,
30
,
31
,
30
,
31
};
5
// Jul Ago Set Out Nov Dez
6
static
int
[]
amanha
(
int
[]
data
)
{
7
// copiar o estado
8
int
[]
amanha
=
{
data
[
0
]
+
1
,
data
[
1
],
data
[
2
]};
9
// se ultrapassou o último dia do mês
10
if
(
amanha
[
0
]
>
diasMes
[
amanha
[
1
]
-
1
])
{
11
amanha
[
0
]
=
1
;
// dia 1
12
// se antes de dezembro
13
if
(
amanha
[
1
]
<
12
)
{
14
amanha
[
1
]
=
amanha
[
1
]
+
1
;
// próximo mês
15
}
else
{
// se em dezembro
16
amanha
[
1
]
=
1
;
// janeiro do
17
amanha
[
2
]
=
amanha
[
2
]
+
1
;
// próximo ano
18
}
19
}
20
return
amanha
;
21
}
O procedimento amanha
faz uma cópia do vetor de entrada e devolve essa cópia, contudo, é possível alterar o próprio vetor de entrada e não criar um vetor novo. Essa técnica é econômica, pois reduz processamento e memória, contudo, perde-se o estado anterior do vetor. Para demonstrar essa técnica de mutabilidade considere um novo procedimento chamado amanha2
, nele o vetor original é alterado conforme o exemplo:
amanha2
sem retorno e com efeito colateral
static
void
amanha2
(
int
[]
data
)
{
data
[
0
]
=
data
[
0
]
+
1
;
if
(
data
[
0
]
>
diasMes
[
data
[
1
]
-
1
])
{
data
[
0
]
=
1
;
if
(
data
[
1
]
<
12
)
{
data
[
1
]
=
data
[
1
]
+
1
;
}
else
{
data
[
1
]
=
1
;
data
[
2
]
=
data
[
2
]
+
1
;
}
}
}
O procedimento amanha2
, embora declare retorno void
(vazio), gera um retorno refletido na própria entrada. Considere os seguintes testes para entender:
int
[]
umaData
=
{
31
,
7
,
2016
};
System
.
out
.
println
(
umaData
[
0
]
==
31
);
System
.
out
.
println
(
umaData
[
1
]
==
7
);
System
.
out
.
println
(
umaData
[
2
]
==
2016
);
Data
.
amanha
(
umaData
);
// sem atribuição
System
.
out
.
println
(
umaData
[
0
]
==
1
);
System
.
out
.
println
(
umaData
[
1
]
==
8
);
System
.
out
.
println
(
umaData
[
2
]
==
2016
);
Data
.
amanha
(
umaData
);
// age sobre a data modificada
System
.
out
.
println
(
umaData
[
0
]
==
2
);
System
.
out
.
println
(
umaData
[
1
]
==
8
);
System
.
out
.
println
(
umaData
[
2
]
==
2016
);
Os efeitos colaterais estão propensos quando se usam tipos referenciados, objetos. Contudo, alguns objetos não são mutáveis. Considere o seguinte procedimento com string, que não produz resultado:
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
:
1
class
Texto2
{
2
3
static
String
corta
(
String
texto
,
int
tamanho
)
{
4
// reservando o espaço para os caracteres
5
char
[]
caracteres
=
new
char
[
tamanho
];
6
// copiando da string para o vetor
7
for
(
int
i
=
0
;
i
<
tamanho
;
i
++)
{
8
caracteres
[
i
]
=
texto
.
charAt
(
i
);
9
}
10
// usando o vetor para uma String
11
return
new
String
(
caracteres
);
12
}
13
14
public
static
void
main
(
String
[]
args
)
{
15
String
hashtag
=
"#poocomhonra"
;
16
System
.
out
.
println
(
corta
(
hashtag
,
1
).
equals
(
"#"
));
17
System
.
out
.
println
(
corta
(
hashtag
,
2
).
equals
(
"#p"
));
18
System
.
out
.
println
(
corta
(
hashtag
,
3
).
equals
(
"#po"
));
19
System
.
out
.
println
(
corta
(
hashtag
,
4
).
equals
(
"#poo"
));
20
System
.
out
.
println
(
corta
(
hashtag
,
5
).
equals
(
"#pooc"
));
21
// faltam as situações excepcionais, por exemplo:
22
// System.out.println(corta(hashtag, 20).equals("#poocomhonra"));
23
}
24
}
Esse procedimento traz alguns recursos importantes para o aprendizado, como manipular strings caractere a caractere para criar novas strings (ver linha 11).
1.6 “Procedimentos Orientados a Objetos?!”
É possível implementar procedimentos que recebam objetos, já que objetos são Estruturas de Dados e podem substituir os vetores nessa tarefa. No fim, é um misto: usa-se o procedimento para codificar a lógica e o objeto para carregar os dados. Considere o exemplo da data revisitado, agora com objetos, a seguir:
class
DadosData
{
int
dia
,
mes
,
ano
;
}
class
DataMisto
{
static
int
[]
diasMes
=
{
31
,
28
,
31
,
30
,
31
,
30
,
31
,
31
,
30
,
31
,
30
,
31
};
static
DadosData
amanha
(
DadosData
data
)
{
DadosData
amanha
=
new
DadosData
();
amanha
.
dia
=
data
.
dia
+
1
;
amanha
.
mes
=
data
.
mes
;
amanha
.
ano
=
data
.
ano
;
if
(
amanha
.
dia
>
diasMes
[
amanha
.
mes
-
1
])
{
amanha
.
dia
=
1
;
if
(
amanha
.
mes
<
12
)
{
amanha
.
mes
=
amanha
.
mes
+
1
;
}
else
{
amanha
.
mes
=
1
;
amanha
.
ano
=
amanha
.
ano
+
1
;
}
}
return
amanha
;
}
public
static
void
main
(
String
[]
args
)
{
DadosData
umaData
=
new
DadosData
();
umaData
.
dia
=
31
;
umaData
.
mes
=
7
;
umaData
.
ano
=
2016
;
DadosData
resultado
=
DataMisto
.
amanha
(
umaData
);
System
.
out
.
println
(
resultado
.
dia
==
1
);
System
.
out
.
println
(
resultado
.
mes
==
8
);
System
.
out
.
println
(
resultado
.
ano
==
2016
);
}
}
Neste exemplo, o módulo DataMisto
e o procedimento amanha
agem sobre DadosData
, que é um substituto do vetor, servindo para “transportar” os dados _dia, mês e ano__.
1.7 Aderindo à Programação Orientada a Objetos com Responsabilidade
Um característica interessante na programação e desenvolvimento de software é a possibilidade de resolver o mesmo problema de várias maneiras diferentes, com códigos diferentes, estruturas diferentes, enfim, programar é uma atividade criativa. Quando se fala em paradigmas, cada um orienta, influencia e impacta fortemente no código resultante, mas para usá-los corretamente o programador deve “chavear” seu modo de pensar, pois o entendimento do paradigma é fundamental para escrever um código sólido e uma solução adequada.
Importante, escolher um paradigma é também abraçar os conceitos e diretrizes presentes nele, projetando seus programas segundo os construtos e ideias (e ideais) principalmente. Por exemplo, aderir a Programação Guiada por Eventos, implica em especificar o fluxo do programa reagindo a eventos, assim como aderir à Programação Funcional implica em encapsular a lógica do programa como funções sem estado.
Programar aderindo ao paradigma Orientado a Objetos implica em seguir as orientações e manter o código consistente com os conceitos básicos e fundamentais, tais como o que são objetos e como se escreve um programa com eles. A maioria das linguagens, no entanto, não “obrigam” os programadores a seguir esses conceitos. Na prática, as linguagens modernas juntam conceitos e técnicas disponíveis de vários paradigmas, por exemplo, Ruby é une programação estruturada, procedimental, orientada a objetos e tem alguns aspectos funcionais e reflexivos.
Então, como Programar Orientado a Objetos do jeito certo?* Basta seguir os conceitos do paradigma. No entanto, não quer dizer que, às vezes, não se possa usar outra abordagem, graças às linguagens multiparadigma isto não é nenhum motivo de embaraçamento. O que não se deve, de verdade, é violar os princípios de OO e afirmar, ainda, que o programa é OO - o pior enganador é o que engana a si mesmo. Por exemplo, um dos princípios mais básicos de OO é manter as estruturas de dados e algoritmos no mesmo módulo (neste caso, na mesma classe). Na prática, significa colocar no mesmo escopo os atributos e os métodos. Como exemplo, considere novamente o exemplo da data adequadamente orientado a objetos:
Data
Orientada a Objetos
1
public
class
POOComHonra
{
2
public
static
void
main
(
String
[]
args
)
{
3
Data
umaData
=
new
Data
();
4
umaData
.
dia
=
31
;
5
umaData
.
mes
=
7
;
6
umaData
.
ano
=
2016
;
7
Data
resultado
=
umaData
.
amanha
();
8
System
.
out
.
println
(
resultado
.
dia
==
1
);
9
System
.
out
.
println
(
resultado
.
mes
==
8
);
10
System
.
out
.
println
(
resultado
.
ano
==
2016
);
11
}
12
}
13
14
class
Data
{
15
int
dia
,
mes
,
ano
;
16
static
final
int
[]
17
diasMes
=
{
31
,
28
,
31
,
30
,
31
,
30
,
18
31
,
31
,
30
,
31
,
30
,
31
};
19
20
Data
amanha
()
{
21
Data
amanha
=
new
Data
();
22
amanha
.
dia
=
this
.
dia
+
1
;
23
amanha
.
mes
=
this
.
mes
;
24
amanha
.
ano
=
this
.
ano
;
25
if
(
amanha
.
dia
>
diasMes
[
amanha
.
mes
-
1
])
{
26
amanha
.
dia
=
1
;
27
if
(
amanha
.
mes
<
12
)
{
28
amanha
.
mes
=
amanha
.
mes
+
1
;
29
}
else
{
30
amanha
.
mes
=
1
;
31
amanha
.
ano
=
amanha
.
ano
+
1
;
32
}
33
}
34
return
amanha
;
35
}
36
}
Esta classe honra o princípio mais básico de acomodar dados e algoritmos na mesma unidade*. Os dados são representados por atributos da classe (linha 15) e o algoritmo no método amanha
(linha 20) que não é mais estático (o que se equipara a um procedimento), em vez disso ele é uma operação do objeto Data
(métodos não-estáticos são operações/ações dos objetos). Outro detalhe importante está entre as linhas 22 a 24, que é o acesso aos atributos através do this
(este objeto).
1.8 Considerações
Se tem muito a discutir ainda, a rigor, a classe Data
implementada anteriormente não está de acordo com vários outros conceitos essenciais de orientação a objetos, como o encapsulamento e validade do estado. No entanto, capítulo a capítulo estes conceitos e princípios serão trazidos e os exemplos enriquecidos até alcançar um nível bem alto de qualidade.
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](/site_images/poo/pypl-2020-12.png)
É 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
- Dê uma olhada no ranking atual de popularidade das linguagens de programação PYPL e Tiobe nos endereços http://pypl.github.io/PYPL.html e https://www.tiobe.com/tiobe-index/;
- Escolha uma linguagem e procure por empresas e aplicativos que a usam. Considere procurar por vagas de emprego também;
- Visite o site da linguagem escolhida. Por exemplo, aqui está o da linguagem Perl https://www.perl.org/.
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:
// 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:
// 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
// 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
# 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
:
// 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.
// 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 é readonly
8.
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.
- 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; - Agora, preste atenção à condição
if (this.quantidade >= 100)
, ela protege o estado impedindo que a quantidade fique negativa ao subtrair100mL
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) ouconst
(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 int
s 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](/site_images/poo/referencias.png)
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
edataHoraVenda
existem em função daVenda
do ingresso e não doIngresso
em si. Logo pode ser extraídos (o nome dos atributos já denunciava um não-pertencimento àquele lugar); - O atributo
random
e a contanteALPHA
só existem para a geração doToken
no métodogerarToken
, 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.↩