4. ESTADO & VALIDADE
“Um bom programador é alguém que sempre olha para os dois lados antes de atravessar uma rua de mão única.”
Doug Linder
Para projetar sistemas confiáveis é importantíssimo controlar o estado dos objetos. É preciso assegurar-se de que os objetos sejam consistentes e continuem válidos durante toda a execução do programa. O estado inválido de um único objeto pode levar todo o sistema ou aplicativo à falhar. Imagine o “estrago” que faria um objeto Hora com número negativo de minutos num app de agendamento. Por esse motivo, deve-se especificar quais características podem mudar, e quais não devem mudar nos objetos. Isto é, definir o estado mutável e o imutável. Também envolve escrever as regras de negócio (aquele punhado de if's, else's, for's, while's, ...) pensando se elas devem alterar estado e, caso sim, com que restrições. Nos tópicos a seguir serão abordadas as principais questões sobre estado, como implementá-lo e controlá-lo com responsabilidade.
4.1 Conceito de Estado do Objeto
Retomando o modelo de objetos, sabe-se que eles têm características codificadas na forma de atributos que guardam valores. Logo, cada objeto (ou instância) possui valores específicos para esses atributos, diferenciando as instâncias umas das outros, mesmo que compartilhem uma mesma classe. Por exemplo, enquanto um instância de Gato pode ser descrita por {nome: "Tom", idade: 6, peso: 4.8}, outra pode ter os valores {nome: "Angela", idade: 5, peso: 3.9}.
O estado é definido como o conjunto de valores armazenados em todos os atributos de um objeto em um instante no tempo. Isto é, o objeto está. Por exemplo, o gato “Tom”, por ora, está com peso: 4.8 e, conhecendo o gato doméstico, logo estará com peso: 5.0.
Tenha em mente que nem sempre o objeto está, às vezes ele é. Quer dizer, nem todos os atributos irão (ou devem) mudar de valor no tempo. Os objetos podem ter estado constante e variável, ao mesmo tempo. Por exemplo, a idade e peso do gato “Tom” pode aumentar enquanto ele mantém o mesmo nome 6. Portanto, durante o projeto da classe é preciso pensar sobre quais atributos devem ser constantes e quais exigem variabilidade. Essas questões são discutidas no tópico seguinte.
4.2 Estado constante e instantâneo
Primeiro, é importante mencionar que nem todas as linguagens de programação permitem declarar um atributo constante. Isso não é possível em JavaScript e Python7, por exemplo. As linguagens projetadas para declarar estado constante o fazem com diferentes palavras-chave (keyword). Por exemplo, em Java se adiciona a keyword final na declaração do atributo, enquanto em C# a keyword é readonly8.
Para entender a necessidade de um atributo imutável é preciso exercitar a classificação de um objeto que possua uma característica constante. Mantendo a linha de exemplos simples, considere uma Caneta. Entre várias informações sobre canetas, vamos abstrair e selecionar apenas duas: cor e carga. Isto é, consideramos o pigmento e o nível da tinta como informações úteis e imprescindíveis para o modelo. Seguindo o modelo de objeto apresentado no capítulo anterior, uma implementação possível está a seguir:
// Caneta.pseudo
classe Caneta
atributo cor : textual
atributo carga : numérico
fim classe
// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
can = nova Caneta
can.cor = "Azul"
imprime(can.cor) // "Azul"
can.cor = "Vermelha";
imprime(can.cor) // "Vermelha"
fim procedimento
Embora funcional, essa implementação ignora um detalhe importante sobre as canetas. Elas possuem uma certa carga, que decresce com o uso, mas sua cor não muda. Claro, estamos ignorando a questão da troca da carga (abstração). Assim, uma caneta “Azul” será sempre “Azul”, e uma caneta “Vermelha” seria uma nova caneta. Isso pode ser resolvido declarando a cor como constante e adicionando um construtor:
// Caneta.pseudo
classe Caneta
atributo constante cor : textual
atributo carga : numérico
construtor (parâmetro cor, parâmetro carga = 1000)
atributo cor = parâmetro cor
atributo carga = parâmetro carga
fim construtor
fim classe
// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
can = nova Caneta "Azul"
imprime(can.cor) // "Azul"
// can.cor = "Vermelha" // não é possível, cor é constante
can2 = nova Caneta "Vermelha"
imprime(can2.cor) // "Vermelha"
fim procedimento
Este exemplo em pseudocódigo sintetiza a ideia de estado constante. É importante frisar que não são proibidas canetas de outras cores, apenas é restrito que a mesma caneta mude de cor.
A seguir como é implementado em Java:
// Caneta.java
class Caneta {
final String cor; // final é a keyword para constante
int carga;
Caneta(String cor) {
this.cor = cor;
this.carga = 1000;
}
}
// Main.java
class Main {
public static void main(String[] args) {
Caneta can1 = new Caneta("Verde");
System.out.println(can1.cor); // "Verde"
// can1.cor = "Azul"; // NÃO COMPILA, a cor é imutável, constante
Caneta can2 = new Caneta("Azul");
System.out.println(can2.cor); // "Azul"
System.out.println(can2.carga); // 1000
can2.carga = 800; // carga é mutável, instantânea
System.out.println(can2.carga); // 800
}
}
O pequeno exemplo anterior demonstra um objeto com dois atributos, onde um é instantâneo (carga) e o outro constante (cor). Ainda assim, a caneta não está livre de “furos”. Não há restrições para o estado. Por exemplo, a instrução can.carga = -200 é válida, compila e executa sem problemas. A instrução new Caneta(""), que instancia uma caneta sem cor, também é válida. Portanto, o próximo passo é definir regras tanto para obter um estado inicial válido quanto fazê-lo permanecer válido o tempo todo.
4.3 Validade do estado, invariantes e consistência
A confiabilidade de um objeto é obtida pela consistência e validade do seu estado. Essa questão pode (deve) ser tratada em dois momentos: (1) na inicialização do objeto, (2) na interação com o objeto.
Todos os atributos imutáveis devem ser inicializados no construtor. É também no construtor onde são validados os atributos mutáveis, para garantir que objeto seja (tenha um estado) válido desde o começo.
Por exemplo, considere uma garrafa térmica. Elas possuem várias características que as diferenciam. Mas, vamos abstrair, desconsiderando a marca, modelo, cor, formato, etc, e considerando apenas duas informações: a capacidade e quantidade de líquido.
Vamos analisar: a capacidade é constante, isto é, uma garrafa com capacidade para 2L sempre terá esta capacidade, ela não está com 2L, ela é de 2L. Por outro lado, a quantidade é variável, instantânea, a garrafa pode estar vazia (0L), com líquido para apenas mais um mate (100mL), ou cheia (2L). O que é invariante na classe das garrafas térmicas? A quantidade nunca é negativa ou maior que a capacidade 9. Além dessa, também vamos adicionar uma restrição de capacidade, mínima é de 100mL e máxima de 5L, para não criar garrafas “bizarras”, como um conta-gotas de 1mL ou caixa d’água de 1000L.
As invariantes de classe são uma coleção de condições predefinidas e servem para restringir e garantir o estado do objeto. A construção e inicialização do objeto e subsequentes operações devem respeitar essas condições.
Vamos completar o exemplo com duas operações (métodos). A primeira é encher. Quanto a segunda, considere que há um botão para servir água na nossa garrafa térmica, digital claro, que dispensa 100mL de água ou emite um beep se estiver vazia. A seguir um pseudocódigo:
// GarrafaTérmica.pseudo
classe GarrafaTérmica
atributo constante capacidade : numérico
atributo quantidade : numérico
construtor (parâmetro capacidade : numérico)
se parâmetro capacidade < 100 então // regra
lança a exceção "Capacidade mínima de 100mL"
fimse
se parâmetro capacidade > 5000 então // regra
lança a exceção "Capacidade máxima de 5L"
fimse
atributo capacidade = parâmetro capacidade
atributo quantidade = 0
fim construtor
método encher
atributo quantidade = atributo capacidade
fimmétodo
método servir : boolean
se atributo quantidade >= 100 // regra
atributo quantidade = atributo quantidade - 100
retorna Verdadeiro // foi servido
fimse
retorna Falso // não foi servido
fimmétodo
fim classe
// App.pseudo
usando GarrafaTérmica de GarrafaTérmica.pseudo
procedimento App
chimarrita = nova GarrafaTérmica 1000
imprime(chimarrita.capacidade) // 1000
imprime(chimarrita.quantidade) // 0
chimarrita.encher()
imprime(chimarrita.quantidade) // 1000
chimarrita.servir()
imprime(chimarrita.quantidade) // 900
fim procedimento
Esse programa ficou bem mais longo que os anteriores, mas não se preocupe, eu vou explicar os detalhes. A invariante da classe está comentada como // regra ao longo do código. Como podes ver, elas são pré e pós-condições que se esperam para a validade do objeto durante seu “uso”. Duas coisas são novas: a exceção e o método. As exceções são usadas para interromper o fluxo normal e providenciar informações sobre o motivo dessa interrupção. Neste exemplo, elas são usadas para evitar a instanciação de uma garrafa térmica inválida. As regras no construtor cumprem essa tarefa. Nunca haverão garrafas com capacidades menores que 100mL nem maiores que 5L, e isto é garantido. Os métodos são usados para definir as operações do objeto e intermediar as mudanças de estado. Métodos definem a interação com os objetos. Esses dois conceitos serão discutidos com mais detalhes no Capítulo 4: Comportamento.
O pseudocódigo anterior é implementável em quase qualquer linguagem moderna. A seguir esta lógica está escrita em Java:
// GarrafaTermica.java
class GarrafaTermica {
final int capacidade;
int quantidade;
GarrafaTermica (int capacidade) {
if (capacidade < 100) { // se inválida, lança exceção
throw new IllegalArgumentException("Capacidade mínima de 100mL");
}
if (capacidade > 5000) { // se inválida, lança exceção
throw new IllegalArgumentException("Capacidade máxima de 5L");
}
this.capacidade = capacidade;
this.quantidade = 0;
}
void encher() { // operação/método encher
this.quantidade = this.capacidade;
}
boolean servir() { // operação/método servir
if (this.quantidade >= 100) { // é possível servir?
this.quantidade = this.quantidade - 100;
return true; // foi servido
}
return false; // não foi servido
}
}
// Main.java
class Main {
public static void main(String[] args) {
GarrafaTermica chimarrita = new GarrafaTermica(1000);
System.out.println(chimarrita.capacidade); // 1000
System.out.println(chimarrita.quantidade); // 0
System.out.println(chimarrita.servir()); // false
chimarrita.encher();
System.out.println(chimarrita.quantidade); // 1000
System.out.println(chimarrita.servir()); // true
System.out.println(chimarrita.quantidade) // 900
System.out.println(chimarrita.servir()); // true
System.out.println(chimarrita.quantidade) // 800
while (chimarrita.servir()) {
System.out.println(chimarrita.quantidade); // 700,600,500,400,300,200,100
}
System.out.println(chimarrita.quantidade); // 0
chimarrita.encher();
System.out.println(chimarrita.quantidade); // 1000
}
}
Retomando o primeiro parágrafo, a confiabilidade de um objeto é obtida pela consistência e validade do seu estado em dois momentos: (1) inicialização e (2) na interação.
- 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 subtrair100mLe 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.